From e8c122e54264d79dbbe0cc339a705f7d33951f02 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Fri, 4 Sep 2026 18:02:39 +0800 Subject: [PATCH 01/24] =?UTF-8?q?=E5=AE=8C=E5=96=84=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E8=AF=84=E5=88=86=E3=80=81LLM=E5=85=9C=E5=BA=95=E4=B8=8E?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接入数据库驱动的七维风险评分第一阶段逻辑\n- 增加主模型失败后的 DeepSeek 语义、流式和预防建议兜底\n- 将 AI 预防建议改为基于评分、历史异常和标准规则动态生成\n- 增加无历史案例时的标准库兜底校验和通用规则匹配\n- 修复未知供应商识别及测试占位名称过滤\n- 增加后端滚动日志文件配置和 LLM 请求日志\n- 历史会话直接读取已保存结果,避免重新调用模型和重算风险\n- 补充评分、供应商识别和 LLM 协议测试 --- .env.example | 33 ++ .gitignore | 2 +- app/api/v1/risk_predictions.py | 5 +- app/core/config.py | 46 ++- app/core/logging.py | 50 ++- app/repositories/conversations.py | 1 - app/schemas/risk_prediction.py | 12 +- app/services/conversations.py | 487 ++++++++++++++++++++++---- app/services/database_risk_scoring.py | 406 +++++++++++++++++++++ app/services/llm_client.py | 398 +++++++++++++++++---- app/services/risk_prediction.py | 63 +++- tests/test_conversations.py | 59 ++++ tests/test_database_risk_scoring.py | 65 ++++ tests/test_llm_protocol.py | 84 ++++- 14 files changed, 1549 insertions(+), 162 deletions(-) create mode 100644 app/services/database_risk_scoring.py create mode 100644 tests/test_database_risk_scoring.py diff --git a/.env.example b/.env.example index ef2d8a7..89e8730 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,52 @@ +# ==================== 应用基础配置 ==================== +# 应用名称、版本和运行环境,用于接口文档、日志和运行行为识别。 RISK_APP_NAME=Dieya Risk Prediction Backend RISK_APP_VERSION=0.1.0 RISK_ENVIRONMENT=development RISK_DEBUG=false + +# ==================== 数据库配置 ==================== +# 风险系统业务数据库连接地址,包含数据库用户、密码、主机、端口和库名。 RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction + +# ==================== 日志配置 ==================== +# 控制后端日志级别;可选 DEBUG、INFO、WARNING、ERROR。 RISK_LOG_LEVEL=INFO + +# ==================== JWT 登录鉴权配置 ==================== +# JWT 签名密钥、签名算法和登录态有效期(分钟)。生产环境必须更换签名密钥。 RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION RISK_JWT_ALGORITHM=HS256 RISK_JWT_EXPIRE_MINUTES=480 + +# ==================== 钉钉免登配置 ==================== +# 用于钉钉免登和组织身份校验;未接入钉钉时可以留空。 RISK_DINGTALK_APP_KEY= RISK_DINGTALK_APP_SECRET= RISK_DINGTALK_CORP_ID= RISK_DINGTALK_AGENT_ID= + +# ==================== 主 LLM 模型配置 ==================== +# 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。 RISK_LLM_API_KEY= RISK_LLM_BASE_URL=https://api.openai.com/v1 RISK_LLM_MODEL=gpt-4o-mini RISK_LLM_TIMEOUT_SECONDS=120 RISK_LLM_TEMPERATURE=0.2 + +# ==================== LLM 兜底模型配置 ==================== +# 主模型请求失败时使用 DeepSeek;填写独立的 DeepSeek API Key 才会真正启用兜底。 +# API_MODE=responses 时调用 /responses;如服务商只支持 Chat Completions,可改为 chat_completions。 +# MAX_RETRIES 是兜底端点内部的重试次数,REASONING_EFFORT 适用于 Responses 推理配置。 +RISK_LLM_FALLBACK_API_KEY= +RISK_LLM_FALLBACK_BASE_URL=https://api.deepseek.com +RISK_LLM_FALLBACK_MODEL=deepseek-v4-pro +RISK_LLM_FALLBACK_API_MODE=responses +RISK_LLM_FALLBACK_TIMEOUT_SECONDS=30 +RISK_LLM_FALLBACK_MAX_RETRIES=1 +RISK_LLM_FALLBACK_REASONING_EFFORT=high +RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS=16384 + +# ==================== 跨域配置 ==================== +# 允许访问后端接口的前端地址,多个地址用英文逗号分隔。 RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158 diff --git a/.gitignore b/.gitignore index 4e156bf..2398d05 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ __pycache__/ .coverage htmlcov/ .env +logs/ *.egg-info/ dist/ build/ data/ - diff --git a/app/api/v1/risk_predictions.py b/app/api/v1/risk_predictions.py index 5344a1b..19fc252 100644 --- a/app/api/v1/risk_predictions.py +++ b/app/api/v1/risk_predictions.py @@ -4,7 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends -from app.api.dependencies import require_permission +from app.api.dependencies import DatabaseSession, require_permission from app.models.entities import UserAccount from app.schemas.common import ApiResponse from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult @@ -23,6 +23,7 @@ async def model_info( @router.post("/preview", response_model=ApiResponse[RiskAssessmentResult]) async def preview_risk( request: RiskAssessmentRequest, + session: DatabaseSession, _: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], ) -> ApiResponse[RiskAssessmentResult]: - return ApiResponse(data=RiskPredictionService.preview(request)) + return ApiResponse(data=await RiskPredictionService.preview(request, session)) diff --git a/app/core/config.py b/app/core/config.py index 6bd7f70..2dbf7f8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,7 +1,7 @@ """Environment-backed application settings.""" from functools import lru_cache -from typing import Annotated +from typing import Annotated, Literal from pydantic import AliasChoices, Field, SecretStr, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -53,6 +53,45 @@ class Settings(BaseSettings): le=2, validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"), ) + llm_fallback_api_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("RISK_LLM_FALLBACK_API_KEY", "DEEPSEEK_API_KEY"), + ) + llm_fallback_base_url: str = Field( + default="https://api.deepseek.com", + validation_alias=AliasChoices("RISK_LLM_FALLBACK_BASE_URL", "DEEPSEEK_BASE_URL"), + ) + llm_fallback_model: str = Field( + default="deepseek-v4-pro", + validation_alias=AliasChoices("RISK_LLM_FALLBACK_MODEL", "DEEPSEEK_MODEL"), + ) + llm_fallback_api_mode: Literal["chat_completions", "responses"] = Field( + default="responses", + validation_alias=AliasChoices("RISK_LLM_FALLBACK_API_MODE", "DEEPSEEK_API_MODE"), + ) + llm_fallback_timeout_seconds: int = Field( + default=30, + gt=0, + le=600, + validation_alias=AliasChoices("RISK_LLM_FALLBACK_TIMEOUT_SECONDS", "DEEPSEEK_TIMEOUT"), + ) + llm_fallback_max_retries: int = Field( + default=1, + ge=0, + le=10, + validation_alias=AliasChoices("RISK_LLM_FALLBACK_MAX_RETRIES", "DEEPSEEK_MAX_RETRIES"), + ) + llm_fallback_reasoning_effort: Literal["low", "high", "max"] = Field( + default="high", + validation_alias=AliasChoices("RISK_LLM_FALLBACK_REASONING_EFFORT", "DEEPSEEK_REASONING_EFFORT"), + ) + llm_fallback_max_output_tokens: int = Field( + default=16_384, + ge=1, + le=393_216, + validation_alias=AliasChoices("RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS", "DEEPSEEK_MAX_OUTPUT_TOKENS"), + ) cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = ( "http://localhost:39158", "http://127.0.0.1:39158", @@ -77,6 +116,11 @@ class Settings(BaseSettings): def normalize_llm_base_url(cls, value: str) -> str: return value.rstrip("/") + @field_validator("llm_fallback_base_url") + @classmethod + def normalize_llm_fallback_base_url(cls, value: str) -> str: + return value.rstrip("/") + @field_validator("cors_allow_origins", mode="before") @classmethod def split_cors_origins(cls, value: object) -> object: diff --git a/app/core/logging.py b/app/core/logging.py index ec2a5e1..0c280eb 100644 --- a/app/core/logging.py +++ b/app/core/logging.py @@ -1,20 +1,60 @@ """Shared logging setup.""" import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path LOGGER_NAME = "dieya_risk_prediction" +LOG_DIR = Path(__file__).resolve().parents[2] / "logs" +LOG_FILE = LOG_DIR / "backend.log" +_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s" def configure_logging(level: str = "INFO") -> logging.Logger: logger = logging.getLogger(LOGGER_NAME) logger.setLevel(level.upper()) logger.propagate = False - if not logger.handlers: - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) - logger.addHandler(handler) + LOG_DIR.mkdir(parents=True, exist_ok=True) + formatter = logging.Formatter(_FORMAT) + file_handler = next( + ( + handler + for handler in logger.handlers + if getattr(handler, "_dieya_file_handler", False) + ), + None, + ) + if file_handler is None: + file_handler = RotatingFileHandler( + LOG_FILE, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + file_handler._dieya_file_handler = True + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + root_logger = logging.getLogger() + root_logger.setLevel(level.upper()) + if not any(getattr(handler, "_dieya_file_handler", False) for handler in root_logger.handlers): + root_file_handler = RotatingFileHandler( + LOG_FILE, + maxBytes=10 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + root_file_handler._dieya_file_handler = True + root_file_handler.setFormatter(formatter) + root_logger.addHandler(root_file_handler) + if not any( + isinstance(handler, logging.StreamHandler) + and not getattr(handler, "_dieya_file_handler", False) + for handler in logger.handlers + ): + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + logger.addHandler(stream_handler) return logger logger = configure_logging() - diff --git a/app/repositories/conversations.py b/app/repositories/conversations.py index 0f8299e..757d4d0 100644 --- a/app/repositories/conversations.py +++ b/app/repositories/conversations.py @@ -44,7 +44,6 @@ class ConversationRepository: Conversation.id == conversation_id, Conversation.user_id == user_id, ) - .options(selectinload(Conversation.messages)) ) if conversation is None: return None diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 528cc72..a02dad4 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -14,7 +14,13 @@ class RiskIndicator(ApiSchema): class RiskAssessmentRequest(ApiSchema): subject_id: str = Field(min_length=1, max_length=128) - indicators: list[RiskIndicator] = Field(min_length=1, max_length=50) + indicators: list[RiskIndicator] = Field(default_factory=list, max_length=50) + product_category: str | None = Field(default=None, max_length=100) + material_type: str | None = Field(default=None, max_length=200) + supplier: str | None = Field(default=None, max_length=300) + factory: str | None = Field(default=None, max_length=300) + stage_new: str | None = Field(default=None, max_length=100) + defect_level: str | None = Field(default=None, max_length=10) class RiskFactorResult(ApiSchema): @@ -54,6 +60,10 @@ class RiskDimensionScore(ApiSchema): dimension: str score: float weight: float + status: str = "database" + reason: str | None = None + sample_size: int | None = None + raw_value: float | int | None = None class SimilarCase(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 454bc75..03b57db 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -1,6 +1,7 @@ """Conversation use cases and the initial risk-assistant response adapter.""" import json +import logging import re from collections.abc import AsyncGenerator, Sequence from datetime import UTC, datetime @@ -25,8 +26,11 @@ from app.schemas.risk_prediction import ( StandardJudgment, SupplierFrequency, ) +from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.llm_client import LLMClient +logger = logging.getLogger("dieya_risk_prediction.conversations") + class ConversationService: _engine = BaselineRiskEngine() @@ -93,13 +97,11 @@ class ConversationService: if page is None: raise AppException("对话不存在或无权访问", code=404, status_code=404) conversation, messages, total = page - live_results = await self._refresh_risk_results(messages) return self._data( conversation, messages=messages, total=total, offset=offset, - risk_results=live_results, ) async def create(self, user: UserAccount, title: str | None = None) -> ConversationData: @@ -160,13 +162,11 @@ class ConversationService: if page is None: # pragma: no cover - the row already existed raise AppException("对话读取失败", code=500, status_code=500) persisted, messages, total = page - live_results = await self._refresh_risk_results(messages) return self._data( persisted, messages=messages, total=total, offset=0, - risk_results=live_results, ) async def stream( @@ -208,6 +208,11 @@ class ConversationService: yield {"type": "error", "message": str(exc)} return semantics = self._normalize_semantics(text, semantics) + semantics = await self._normalize_follow_up_semantics( + text, + semantics, + conversation, + ) yield {"type": "semantic", "data": semantics} context = self._llm_messages(conversation, text, semantics) @@ -228,6 +233,11 @@ class ConversationService: show_risk = self._is_risk_request(text, semantics) response_type = self._response_type(text, semantics) + response_type = await self._use_solution_card_without_history( + response_type, + text, + semantics, + ) risk_result = ( await self._enrich_risk_result(text, semantics, self._predict(text)) if show_risk and response_type == "risk_assessment" @@ -265,13 +275,11 @@ class ConversationService: yield {"type": "error", "message": "对话读取失败"} return persisted, messages, total = page - live_results = await self._refresh_risk_results(messages) result = self._data( persisted, messages=messages, total=total, offset=0, - risk_results=live_results, ) yield {"type": "done", "data": result.model_dump(mode="json", by_alias=True)} @@ -309,6 +317,8 @@ class ConversationService: def _response_type(cls, text: str, semantics: dict[str, Any]) -> str: """Choose the prototype card from the user's actual question.""" + if cls._is_placeholder_supplier_name(text): + return "general" intent = str(semantics.get("intent") or "").strip().lower() if intent == "supplier" or ( any(word in text for word in ("供应商", "加工厂", "工厂")) @@ -322,15 +332,138 @@ class ConversationService: return "standard" if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")): return "compliance" - if intent == "solution" or ( - any(word in text for word in ("次氯酸", "消毒喷雾")) - and not any(word in text for word in ("评估", "风险评估", "风险预估")) - ): + if intent == "solution": return "solution" if intent == "assessment" or cls._is_explicit_risk_request(text): return "risk_assessment" return "general" + async def _normalize_follow_up_semantics( + self, + text: str, + semantics: dict[str, Any], + conversation: Conversation, + ) -> dict[str, Any]: + """Recover card intent for short follow-ups such as ``佳亿``. + + The LLM can answer a short follow-up using conversation history while + still labeling the current turn as ``general``. Card routing needs a + deterministic signal, so known supplier names are matched locally and + written back into the semantic payload before routing. + """ + + normalized = dict(semantics) + if self._is_placeholder_supplier_name(text): + normalized.update( + { + "intent": "general", + "risk_requested": False, + "semantic_source": "placeholder_filtered", + } + ) + normalized.pop("supplier", None) + return normalized + intent = str(normalized.get("intent") or "").strip().lower() + if intent not in {"", "general", "greet"}: + return normalized + supplier_names = list( + ( + await self.session.scalars( + select(AnomalyCase.supplier) + .where(AnomalyCase.supplier.is_not(None)) + .distinct() + ) + ).all() + ) + supplier_name = self._infer_supplier_follow_up(text, supplier_names) + supplier_name = supplier_name or self._infer_unknown_supplier_follow_up(text) + if not supplier_name: + return normalized + normalized.update( + { + "intent": "supplier", + "risk_requested": True, + "supplier": supplier_name, + "confidence": max(float(normalized.get("confidence") or 0), 0.75), + "semantic_source": "database_supplier_match" + if supplier_name in supplier_names + else "standalone_new_supplier_fallback", + } + ) + return normalized + + @staticmethod + def _infer_supplier_follow_up(text: str, supplier_names: Sequence[str | None]) -> str | None: + """Return a known supplier when the current message names it directly.""" + + cleaned = text.strip().lower() + if not cleaned: + return None + candidates = sorted( + { + " ".join(name.strip().split()) + for name in supplier_names + if name and name.strip() and name.strip() not in {"/", "-"} + }, + key=len, + reverse=True, + ) + for name in candidates: + if cleaned == name.lower(): + return name + return None + + @staticmethod + def _infer_unknown_supplier_follow_up(text: str) -> str | None: + """Treat a short standalone business name as a new supplier. + + This is deliberately conservative: common greetings, product terms, + workflow commands, and messages containing punctuation are excluded. + Unknown names are shown as a new supplier with zero historical data. + """ + + cleaned = " ".join(text.strip().split()) + if not 2 <= len(cleaned) <= 20 or " " in cleaned: + return None + if not re.fullmatch(r"[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9_-]*", cleaned): + return None + excluded = { + "你好", + "您好", + "谢谢", + "好的", + "继续", + "继续分析", + "洗发水", + "沐浴露", + "面霜", + "脱毛膏", + "清洁剂", + "牙膏", + "软管", + "泵头", + "瓶子", + "彩盒", + "标准库", + "风险评估", + "供应商", + "新供应商A", + "测试供应商", + } + if ( + cleaned in excluded + or cleaned.startswith("测试") + or cleaned.startswith("新供应商") + or any(term in cleaned for term in ("请", "帮我", "分析", "查询", "怎么样")) + ): + return None + return cleaned + + @staticmethod + def _is_placeholder_supplier_name(text: str) -> bool: + cleaned = " ".join(text.strip().split()) + return cleaned.startswith("测试") or cleaned.startswith("新供应商") + async def _build_response_card( self, response_type: str, @@ -347,6 +480,59 @@ class ConversationService: builder = builders.get(response_type) return await builder(text, semantics) if builder else None + async def _use_solution_card_without_history( + self, + response_type: str, + text: str, + semantics: dict[str, Any], + ) -> str: + """Use the solution card for a product risk question with no history.""" + + if response_type != "risk_assessment": + return response_type + category = self._semantic_text(semantics, "category") or self._first_match( + text, + ( + "洗发水", + "沐浴露", + "面霜", + "乳液", + "精华", + "面膜", + "防晒", + "护手霜", + "护发素", + "脱毛膏", + "清洁剂", + "牙膏", + "消毒喷雾", + "次氯酸", + ), + ) + has_product_context = bool( + category + or any( + key in text + for key in ( + "新品", + "新产品", + "产品开发", + "产品立项", + "规格", + "容量", + "包装", + "材质", + "配方", + "原料", + ) + ) + ) + if not has_product_context: + return response_type + if not category or await self._count_category_anomalies(category) == 0: + return "solution" + return response_type + async def _supplier_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: name = self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory") if not name: @@ -451,10 +637,29 @@ class ConversationService: async def _solution_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: category = self._semantic_text(semantics, "category") or self._first_match( - text, ("次氯酸消毒喷雾", "消毒喷雾", "清洁剂", "脱毛膏") + text, + ( + "次氯酸消毒喷雾", + "消毒喷雾", + "清洁剂", + "脱毛膏", + "洗发水", + "沐浴露", + "面霜", + "乳液", + "精华", + "面膜", + "防晒", + "护手霜", + "护发素", + "牙膏", + ), ) or "当前新品" direct_count = await self._count_category_anomalies(category) standards = await self._standards_for(category, text) + standard_fallback_enabled = direct_count == 0 + if standard_fallback_enabled and not standards: + standards = await self._fallback_standards_for(text) risks: list[dict[str, str]] = [] if "次氯酸" in text or "铝" in text: risks.append( @@ -480,6 +685,31 @@ class ConversationService: "detail": "供应商或加工厂的历史表现需要结合异常案例和现场审核确认。", } ) + fallback_suggestions = [ + "小样阶段完成内容物与包装材料相容性测试", + "增加倒置密封测试和加速老化测试", + "首单安排来料全检,并根据结果决定是否驻厂巡检", + "实际执行前由品控确认并安排小批量试产验证", + ] + try: + suggestions = await self.llm.generate_prevention_suggestions( + { + "user_input": text, + "category": category, + "historical_case_count": direct_count, + "standard_fallback_enabled": standard_fallback_enabled, + "matched_standards": [self._standard_dict(item) for item in standards[:5]], + "identified_risks": risks, + "rag_similar_cases": [], + } + ) + except Exception as exc: # noqa: BLE001 - the solution card must remain available + logger.warning( + "Solution card AI suggestions failed error=%s; using deterministic fallback", + exc.__class__.__name__, + ) + suggestions = fallback_suggestions + return { "type": "solution", "title": "AI 案例推理生成", @@ -488,19 +718,76 @@ class ConversationService: f"目标品类「{category}」当前命中历史异常 {direct_count} 条;" "方案结合已导入异常案例和标准规则生成。" if direct_count - else f"目标品类「{category}」暂无直接历史案例,当前按标准规则库进行兜底分析。" + else f"目标品类「{category}」暂无直接历史案例,当前按技术法规规则库进行兜底分析。" ), + "standardFallbackEnabled": standard_fallback_enabled, "standards": [self._standard_dict(item) for item in standards[:4]], "risks": risks, - "suggestions": [ - "小样阶段完成内容物与包装材料相容性测试", - "增加倒置密封测试和加速老化测试", - "首单安排来料全检,并根据结果决定是否驻厂巡检", - "实际执行前由品控确认并安排小批量试产验证", - ], + "suggestions": suggestions, "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", } + async def _fallback_standards_for(self, text: str) -> Sequence[StandardJudgment]: + """Find generic safety rules when no direct product history is available.""" + + terms = [ + term + for term in ( + self._first_match(text, ("相容性", "密封", "稳定性", "消毒", "微生物", "氧化")), + self._first_match(text, ("铝", "金属", "喷头", "软管", "PET", "泵头")), + ) + if term + ] + if terms: + clauses = [ + or_( + StandardRule.category_lv1.ilike(f"%{term}%"), + StandardRule.category_lv2.ilike(f"%{term}%"), + StandardRule.category_lv3.ilike(f"%{term}%"), + StandardRule.risk_point.ilike(f"%{term}%"), + StandardRule.judge_standard.ilike(f"%{term}%"), + ) + for term in terms + ] + rows = list( + await self.session.scalars( + select(StandardRule) + .where(StandardRule.status == "启用", or_(*clauses)) + .order_by(StandardRule.id) + .limit(4) + ) + ) + if rows: + return [ + StandardJudgment( + category=row.category_lv1, + risk_point=row.risk_point, + rule=row.judge_standard, + source=row.regulation or "标准文件.xlsx", + status="待检测", + ) + for row in rows + ] + + rows = list( + await self.session.scalars( + select(StandardRule) + .where(StandardRule.status == "启用") + .order_by(StandardRule.id) + .limit(4) + ) + ) + return [ + StandardJudgment( + category=row.category_lv1, + risk_point=row.risk_point, + rule=row.judge_standard, + source=row.regulation or "标准文件.xlsx", + status="待检测", + ) + for row in rows + ] + async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001 if not value: return [] @@ -603,20 +890,29 @@ class ConversationService: supplier = None supplier_or_factory = supplier or factory + database_score = await DatabaseRiskScoringService(self.session).score( + category=category, + material=self._first_match( + text, + ("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"), + ), + supplier=supplier, + factory=factory, + stage=self._semantic_text(semantics, "stage") or self._first_match( + text, ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") + ), + defect_level=self._first_match(text, ("A级", "B级", "C级")), + ) category_count = await self._count_category_anomalies(category) - supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier_or_factory) + supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier) factory_count = await self._count_anomalies(AnomalyCase.factory, factory) - if factory and not supplier: - anomaly_count = factory_count - elif supplier and not factory: - anomaly_count = supplier_count - else: - anomaly_count = supplier_count or factory_count + anomaly_count = supplier_count if supplier else factory_count standard_rows = await self._standards_for(category, text) - alpha = round(min(1.5, 1 + (anomaly_count / 100) * 0.3), 2) if anomaly_count else 1.0 - tier = self._supplier_tier(anomaly_count) - level_text = self._risk_level_text(result.level) + alpha_count = supplier_count if supplier else 0 + alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0 + tier = self._supplier_tier(alpha_count) if supplier else None + level_text = self._risk_level_text(database_score.level) alert_text = self._alert_text( category, category_count, @@ -626,17 +922,45 @@ class ConversationService: ) dimensions = [ RiskDimensionScore( - dimension=self._dimension_name(factor.name), - score=round(max(0, 100 - factor.value), 2), - weight=factor.weight, + dimension=item.name, + score=item.score, + weight=item.weight, + status=item.status, + reason=item.reason, + sample_size=item.sample_size, + raw_value=item.raw_value, ) - for factor in result.factors + for item in database_score.dimensions ] - suggestions = self._suggestions(text, supplier_or_factory) + factors = [ + RiskFactorResult( + name=item.name, + value=round(100 - item.score, 2), + weight=item.weight, + contribution=round(item.score * item.weight / 100, 2), + ) + for item in database_score.dimensions + ] + suggestions = await self._generate_ai_suggestions( + text=text, + semantics=semantics, + category=category, + supplier_or_factory=supplier_or_factory, + category_count=category_count, + anomaly_count=anomaly_count, + alpha=alpha, + database_score=database_score, + dimensions=dimensions, + standard_rows=standard_rows, + ) return result.model_copy( update={ - "risk_badge": self._risk_badge(result.level), + "score": database_score.score, + "level": database_score.level, + "model_version": "database-seven-dimension-v1", + "factors": factors, + "risk_badge": self._risk_badge(database_score.level), "alert_text": alert_text, "category_warning": CategoryWarning( category=category, @@ -675,6 +999,62 @@ class ConversationService: ) return int(count or 0) + async def _generate_ai_suggestions( + self, + *, + text: str, + semantics: dict[str, Any], + category: str, + supplier_or_factory: str | None, + category_count: int, + anomaly_count: int, + alpha: float, + database_score, + dimensions: Sequence[RiskDimensionScore], + standard_rows: Sequence[StandardJudgment], + ) -> Sequence[str]: # noqa: ANN001 + """Generate suggestions from live scoring data, with a deterministic fallback.""" + + context = { + "user_input": text, + "semantic_fields": { + key: value + for key, value in semantics.items() + if key in {"category", "specification", "packaging", "supplier", "factory", "stage"} + and value + }, + "category": category, + "supplier_or_factory": supplier_or_factory, + "category_anomaly_count": category_count, + "supplier_or_factory_anomaly_count": anomaly_count, + "frequency_alpha": alpha, + "overall_score": database_score.score, + "risk_level": self._risk_level_text(database_score.level), + "dimensions": [ + { + "name": item.dimension, + "score": item.score, + "weight": item.weight, + "status": item.status, + "reason": item.reason, + "sample_size": item.sample_size, + } + for item in dimensions + ], + "matched_standards": [self._standard_dict(item) for item in standard_rows[:5]], + "rag_similar_cases": [], + } + try: + suggestions = await self.llm.generate_prevention_suggestions(context) + logger.info("AI prevention suggestions generated count=%s", len(suggestions)) + return suggestions + except Exception as exc: # noqa: BLE001 - suggestions must not break risk assessment + logger.warning( + "AI prevention suggestion generation failed error=%s; using deterministic fallback", + exc.__class__.__name__, + ) + return self._suggestions(text, supplier_or_factory) + async def _count_category_anomalies(self, category: str) -> int: """The imported workbook has sparse product_category values. @@ -878,9 +1258,7 @@ class ConversationService: id=conversation.id, title=conversation.title, message_count=resolved_total, - last_risk_level=ConversationService._last_risk_level_from_messages( - resolved_messages, - ) or ConversationService._last_risk_level(conversation), + last_risk_level=ConversationService._last_risk_level_from_messages(resolved_messages), updated_at=conversation.updated_at, messages=[ cls._message( @@ -898,41 +1276,6 @@ class ConversationService: has_more_messages=offset + len(resolved_messages) < resolved_total, ) - async def _refresh_risk_results( - self, - messages: Sequence[ConversationMessage], - ) -> dict[int, RiskAssessmentResult]: - """Refresh database-backed card fields for historical messages. - - Risk scores are intentionally kept as the persisted assessment result, - while fields sourced from imported cases and standards are re-read from - PostgreSQL. This prevents an old conversation from showing stale zero - counts after the reference workbooks have been imported. - """ - - refreshed: dict[int, RiskAssessmentResult] = {} - for index, message in enumerate(messages): - if message.role != "assistant": - continue - result = self._risk_result_for_message(messages, index) - if result is None: - continue - user_text = next( - ( - candidate.content - for candidate in reversed(messages[:index]) - if candidate.role == "user" - ), - result.subject_id, - ) - semantic = (message.structured_data or {}).get("semantic") - refreshed[message.id] = await self._enrich_risk_result( - user_text, - semantic if isinstance(semantic, dict) else {}, - result, - ) - return refreshed - @staticmethod def _message( message: ConversationMessage, diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py new file mode 100644 index 0000000..0c36fb9 --- /dev/null +++ b/app/services/database_risk_scoring.py @@ -0,0 +1,406 @@ +"""First-phase database-backed seven-dimension risk scoring.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.risk_engine import RiskLevel +from app.models.entities import AnomalyCase + +DEFAULT_WEIGHTS = { + "供应商表现": 25.0, + "物料品类": 20.0, + "产品品类": 15.0, + "加工厂质量": 15.0, + "标准符合性": 10.0, + "节点阶段": 10.0, + "缺陷等级": 5.0, +} + +STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") + + +@dataclass(frozen=True, slots=True) +class DimensionScore: + name: str + score: float + weight: float + status: str + reason: str | None = None + sample_size: int | None = None + raw_value: float | int | None = None + + +@dataclass(frozen=True, slots=True) +class DatabaseRiskScore: + score: float + level: RiskLevel + dimensions: tuple[DimensionScore, ...] + + +class DatabaseRiskScoringService: + """Calculate the PRD first-phase model from anomaly_cases. + + Inspection results and standard test results are intentionally not guessed + here. Their dimensions use the PRD neutral score of 50 and expose the + reason so callers can distinguish a real score from a fallback. + """ + + def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None: + self.session = session + self.as_of = as_of or date.today() + + async def score( + self, + *, + category: str | None = None, + material: str | None = None, + supplier: str | None = None, + factory: str | None = None, + stage: str | None = None, + defect_level: str | None = None, + ) -> DatabaseRiskScore: + rows = await self._load_rows() + category_name = normalize_category(category) + material_name = normalize_material(material) + stage_name = normalize_stage(stage) + party_name = clean_value(supplier) + + dimensions = ( + self._supplier_dimension(rows, party_name), + self._material_dimension(rows, material_name), + self._category_dimension(rows, category_name), + DimensionScore( + name="加工厂质量", + score=50.0, + weight=DEFAULT_WEIGHTS["加工厂质量"], + status="default", + reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入", + ), + DimensionScore( + name="标准符合性", + score=50.0, + weight=DEFAULT_WEIGHTS["标准符合性"], + status="default", + reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", + ), + self._stage_dimension(rows, stage_name), + self._defect_dimension(rows, category_name, material_name, party_name, defect_level), + ) + score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) + return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) + + async def _load_rows(self) -> list[dict[str, Any]]: + start = self.as_of - timedelta(days=365 * 3) + statement = select( + AnomalyCase.date, + AnomalyCase.product_name, + AnomalyCase.product_category, + AnomalyCase.material_type, + AnomalyCase.supplier, + AnomalyCase.factory, + AnomalyCase.stage_new, + AnomalyCase.defect_level, + ).where( + AnomalyCase.date.is_not(None), + AnomalyCase.date >= start, + AnomalyCase.date <= self.as_of, + ) + return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] + + def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore: + weight = DEFAULT_WEIGHTS["供应商表现"] + if not party_name: + return DimensionScore( + name="供应商表现", + score=50.0, + weight=weight, + status="default", + reason="未提供供应商/加工厂,按 PRD 缺失维度默认 50 分", + ) + matched = [row for row in rows if party_matches(row, party_name)] + count = len(matched) + if count == 0: + base = 70.0 + elif count <= 20: + base = 90.0 + elif count <= 60: + base = 75.0 + elif count <= 100: + base = 65.0 + elif count <= 150: + base = 45.0 + else: + base = 30.0 + a_count = sum(1 for row in matched if normalize_defect(row.get("defect_level")) == "A") + base -= min(15, a_count * 5) + current_start = self.as_of - timedelta(days=183) + previous_start = current_start - timedelta(days=183) + current = sum(1 for row in matched if row.get("date") and row["date"] >= current_start) + previous = sum( + 1 + for row in matched + if row.get("date") and previous_start <= row["date"] < current_start + ) + trend_adjustment = ( + -10 + if current > previous * 1.2 and current >= 3 + else 5 + if previous > current * 1.2 + else 0 + ) + base = max(0.0, min(100.0, base + trend_adjustment)) + alpha = min(1.5, 1.0 + count / 100 * 0.3) + score = min(100.0, round(base * alpha, 2)) + return DimensionScore( + name="供应商表现", + score=score, + weight=weight, + status="database", + reason=f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}", + sample_size=count, + raw_value=count, + ) + + @staticmethod + def _material_dimension(rows: list[dict[str, Any]], material: str | None) -> DimensionScore: + weight = DEFAULT_WEIGHTS["物料品类"] + if not material: + return DimensionScore( + name="物料品类", + score=50.0, + weight=weight, + status="default", + reason="未提供包装材质,按 PRD 缺失维度默认 50 分", + ) + counts: dict[str, int] = {} + for row in rows: + normalized = normalize_material(row.get("material_type")) + if normalized: + counts[normalized] = counts.get(normalized, 0) + 1 + count = counts.get(material, 0) + maximum = max(counts.values(), default=0) + score = 100.0 if maximum == 0 else round(100 - count / maximum * 100, 2) + return DimensionScore( + name="物料品类", + score=score, + weight=weight, + status="database", + reason=f"物料「{material}」异常 {count} 条,相对最大物料异常 {maximum} 条归一化", + sample_size=count, + raw_value=count, + ) + + @staticmethod + def _category_dimension(rows: list[dict[str, Any]], category: str | None) -> DimensionScore: + weight = DEFAULT_WEIGHTS["产品品类"] + if not category: + return DimensionScore( + name="产品品类", + score=50.0, + weight=weight, + status="default", + reason="无法从产品名称归类,按 PRD 缺失维度默认 50 分", + ) + counts: dict[str, int] = {} + for row in rows: + normalized = normalize_category(row.get("product_category")) or normalize_category(row.get("product_name")) + if normalized: + counts[normalized] = counts.get(normalized, 0) + 1 + count = counts.get(category, 0) + maximum = max(counts.values(), default=0) + score = 45.0 if count < 10 else round(100 - count / maximum * 100, 2) if maximum else 45.0 + return DimensionScore( + name="产品品类", + score=score, + weight=weight, + status="database", + reason=( + f"品类「{category}」异常 {count} 条" + + ( + ",样本少于 10 条按 45 分处理" + if count < 10 + else ",按品类最大异常数归一化" + ) + ), + sample_size=count, + raw_value=count, + ) + + @staticmethod + def _stage_dimension(rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: + weight = DEFAULT_WEIGHTS["节点阶段"] + if not stage: + return DimensionScore( + name="节点阶段", + score=50.0, + weight=weight, + status="default", + reason="未提供产品阶段,按 PRD 缺失维度默认 50 分", + ) + total = len(rows) + count = sum(1 for row in rows if normalize_stage(row.get("stage_new")) == stage) + share = count / total if total else 0 + if share > 0.5: + score = 50.0 + elif share >= 0.3: + score = 60.0 + elif share >= 0.1: + score = 75.0 + elif share >= 0.01: + score = 80.0 + else: + score = 90.0 + return DimensionScore( + name="节点阶段", + score=score, + weight=weight, + status="database", + reason=f"阶段「{stage}」异常占比 {share:.1%},按阶段档位取分", + sample_size=count, + raw_value=share * 100, + ) + + @staticmethod + def _defect_dimension( + rows: list[dict[str, Any]], + category: str | None, + material: str | None, + party_name: str | None, + defect_level: str | None, + ) -> DimensionScore: + weight = DEFAULT_WEIGHTS["缺陷等级"] + if not category and not material and not party_name and not defect_level: + return DimensionScore( + name="缺陷等级", + score=50.0, + weight=weight, + status="default", + reason="缺少品类、物料、供应商和缺陷等级组合,按 PRD 缺失维度默认 50 分", + ) + matched = [ + row + for row in rows + if ( + not category + or normalize_category(row.get("product_category")) == category + or normalize_category(row.get("product_name")) == category + ) + and (not material or normalize_material(row.get("material_type")) == material) + and (not party_name or party_matches(row, party_name)) + ] + counts = { + level: sum( + 1 + for row in matched + if normalize_defect(row.get("defect_level")) == level + ) + for level in ("A", "B", "C") + } + labeled = sum(counts.values()) + if not labeled: + return DimensionScore( + name="缺陷等级", + score=50.0, + weight=weight, + status="default", + reason="当前组合没有可用的 A/B/C 缺陷记录,按 PRD 缺失维度默认 50 分", + sample_size=0, + ) + penalty = counts["A"] * 40 + counts["B"] * 15 + counts["C"] * 5 + score = max(0.0, 100.0 - penalty) + return DimensionScore( + name="缺陷等级", + score=score, + weight=weight, + status="database", + reason=f"组合缺陷记录 A={counts['A']}、B={counts['B']}、C={counts['C']},按严重度扣分", + sample_size=labeled, + raw_value=penalty, + ) + + +def clean_value(value: object) -> str | None: + if value is None: + return None + cleaned = " ".join(str(value).strip().split()) + return cleaned if cleaned and cleaned not in {"/", "-", "未填写", "未归类"} else None + + +def normalize_defect(value: object) -> str | None: + cleaned = clean_value(value) + return cleaned.upper() if cleaned and cleaned.upper() in {"A", "B", "C"} else None + + +def normalize_stage(value: object) -> str | None: + cleaned = clean_value(value) + if not cleaned: + return None + for stage in STAGES: + if stage in cleaned: + return stage + return None + + +def normalize_material(value: object) -> str | None: + cleaned = clean_value(value) + if not cleaned: + return None + aliases = ( + ("软管", "软管"), + ("泵头", "泵头"), + ("彩盒", "彩盒"), + ("标贴", "标贴"), + ("标签", "标贴"), + ("收缩膜", "收缩膜"), + ("瓶", "瓶子"), + ("PET", "瓶子"), + ("亚克力", "瓶子"), + ("盖", "盖子"), + ) + for keyword, normalized in aliases: + if keyword.lower() in cleaned.lower(): + return normalized + return cleaned + + +def normalize_category(value: object) -> str | None: + cleaned = clean_value(value) + if not cleaned: + return None + aliases = ( + (("洗发", "洗护", "洗发水", "洗发露", "洗发乳"), "洗发水/洗护类"), + (("沐浴", "沐浴露"), "沐浴露类"), + (("面霜", "乳液", "精华", "面膜", "护肤"), "面霜/护肤类"), + (("牙膏", "口腔"), "牙膏/口腔护理类"), + (("清洁剂", "清洁", "洗洁"), "清洁剂类"), + (("脱毛",), "脱毛类"), + (("彩妆", "口红", "粉底", "眼影"), "彩妆/唇部类"), + (("香氛", "香水", "止汗"), "香氛/止汗类"), + ) + for keywords, normalized in aliases: + if any(keyword.lower() in cleaned.lower() for keyword in keywords): + return normalized + return None + + +def party_matches(row: dict[str, Any], party_name: str) -> bool: + supplier = clean_value(row.get("supplier")) or "" + return party_name.lower() in supplier.lower() + + +def classify_score(score: float) -> RiskLevel: + if score >= 80: + return RiskLevel.LOW + if score >= 60: + return RiskLevel.MODERATELY_LOW + if score >= 40: + return RiskLevel.MEDIUM + if score >= 20: + return RiskLevel.HIGH + return RiskLevel.VERY_HIGH diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 4d3266f..358b3a8 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -1,17 +1,21 @@ -"""OpenAI-compatible LLM client for semantic extraction and streaming replies.""" +"""Provider-neutral LLM client with a configurable DeepSeek fallback.""" import json +import logging from collections.abc import AsyncGenerator -from typing import Any +from typing import Any, Literal import httpx from app.core.config import get_settings from app.core.exceptions import AppException +logger = logging.getLogger("dieya_risk_prediction.llm_client") +ProviderName = Literal["primary", "fallback"] + class LLMClient: - """Small provider-neutral client using the OpenAI chat-completions protocol.""" + """Call the primary provider and fail over to DeepSeek when necessary.""" def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: settings = get_settings() @@ -20,22 +24,56 @@ class LLMClient: self.model = settings.llm_model self.timeout = settings.llm_timeout_seconds self.temperature = settings.llm_temperature + + self.fallback_api_key = ( + settings.llm_fallback_api_key.get_secret_value() if settings.llm_fallback_api_key else "" + ) + self.fallback_base_url = settings.llm_fallback_base_url.rstrip("/") + self.fallback_model = settings.llm_fallback_model + self.fallback_api_mode = settings.llm_fallback_api_mode + self.fallback_timeout = settings.llm_fallback_timeout_seconds + self.fallback_max_retries = settings.llm_fallback_max_retries + self.fallback_reasoning_effort = settings.llm_fallback_reasoning_effort + self.fallback_max_output_tokens = settings.llm_fallback_max_output_tokens self.transport = transport @property def configured(self) -> bool: - return bool(self.api_key) + return bool(self.api_key or self.fallback_api_key) + + @property + def fallback_configured(self) -> bool: + return bool(self.fallback_api_key and self.fallback_base_url and self.fallback_model) @property def endpoint(self) -> str: - suffix = "/chat/completions" - return f"{self.base_url}{suffix}" if self.base_url.endswith("/v1") else f"{self.base_url}/v1{suffix}" + return self._chat_endpoint(self.base_url) - def _headers(self) -> dict[str, str]: - if not self.configured: + @staticmethod + def _chat_endpoint(base_url: str) -> str: + suffix = "/chat/completions" + return f"{base_url}{suffix}" if base_url.endswith("/v1") else f"{base_url}/v1{suffix}" + + def _endpoint(self, provider: ProviderName) -> str: + if provider == "primary": + return self.endpoint + if self.fallback_api_mode == "responses": + return f"{self.fallback_base_url}/responses" + return self._chat_endpoint(self.fallback_base_url) + + def _provider_config(self, provider: ProviderName) -> tuple[str, str, int]: + if provider == "primary": + return self.api_key, self.model, self.timeout + return self.fallback_api_key, self.fallback_model, self.fallback_timeout + + def _headers(self, provider: ProviderName) -> dict[str, str]: + api_key, _, _ = self._provider_config(provider) + if not api_key: + if provider == "primary" and self.fallback_configured: + raise AppException("主 LLM 未配置 API Key,将尝试使用兜底模型", code=503, status_code=503) raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503) return { - "Authorization": f"Bearer {self.api_key}", + "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "text/event-stream", } @@ -45,14 +83,14 @@ class LLMClient: text: str, history: list[dict[str, str]] | None = None, ) -> dict[str, Any]: - """Extract structured product and intent fields before generating an answer.""" + """Extract structured fields, retrying with DeepSeek when the primary fails.""" messages = [ { "role": "system", "content": ( "你是蝶雅产品风险预估系统的语义理解模块。" - "只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。" + "只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。" "intent 只能是 greet、general、assessment、supplier、material、cases、" "standard、compliance、solution 之一。" "字段必须包含 intent, risk_requested, category, specification, packaging, " @@ -67,87 +105,278 @@ class LLMClient: ] messages.extend(history or []) messages.append({"role": "user", "content": text}) - body = await self._request(messages, stream=False, json_mode=True) - try: - content = body["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError) as exc: - raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc - if isinstance(content, list): - content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) - return self._parse_json(str(content)) - async def stream_reply( + try: + body = await self._request(messages, provider="primary", stream=False, json_mode=True) + content = self._response_text(body, provider="primary") + return self._parse_json(content) + except Exception as primary_exc: # noqa: BLE001 - fallback must cover provider and parse failures + logger.warning( + "LLM semantic request failed provider=primary model=%s error=%s", + self.model, + primary_exc.__class__.__name__, + ) + if not self.fallback_configured: + raise + + try: + body = await self._request(messages, provider="fallback", stream=False, json_mode=True) + content = self._response_text(body, provider="fallback") + logger.info("LLM semantic request succeeded provider=fallback model=%s", self.fallback_model) + return self._parse_json(content) + except Exception as fallback_exc: # noqa: BLE001 - normalize both provider failures + logger.exception( + "LLM semantic fallback failed provider=fallback model=%s", + self.fallback_model, + ) + raise AppException( + "主模型与兜底模型均调用失败,请检查网络、模型配置或额度", + code=503, + status_code=503, + ) from fallback_exc + + async def stream_reply(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]: + """Yield reply deltas and fail over only before any primary text is emitted.""" + + emitted = False + try: + async for delta in self._stream_provider(messages, provider="primary"): + emitted = True + yield delta + return + except Exception as primary_exc: # noqa: BLE001 - fallback is the public behavior + logger.warning( + "LLM stream failed provider=primary model=%s error=%s emitted=%s", + self.model, + primary_exc.__class__.__name__, + emitted, + ) + if emitted or not self.fallback_configured: + raise + + logger.info("LLM stream switching provider=primary to provider=fallback model=%s", self.fallback_model) + async for delta in self._stream_provider(messages, provider="fallback"): + yield delta + + async def generate_prevention_suggestions(self, context: dict[str, Any]) -> list[str]: + """Generate executable prevention actions from the current risk context.""" + + messages = [ + { + "role": "system", + "content": ( + "你是蝶雅产品风险预估系统的预防方案生成模块。" + "请根据提供的风险评估数据生成可执行的质量预防建议。" + "只输出合法 json object,不要输出 Markdown,格式必须是 " + '{"suggestions":["建议1","建议2"]}。' + "输出 4 到 6 条建议,每条不超过 80 个汉字。" + "建议必须优先针对分数较低的风险维度、历史异常频发项和命中的标准规则," + "明确检测或管控动作;可以包含责任角色、阶段和验收闭环。" + "不得虚构数据库没有提供的案例、检测结果、法规条款或 RAG 检索结果。" + "数据不足时明确写出需要补充的验证动作,不要把猜测写成事实。" + "不要重复表达,不要输出标题、解释、风险评分或额外字段。" + ), + }, + { + "role": "user", + "content": "以下是本次风险评估数据,请生成预防建议:\n" + json.dumps( + context, ensure_ascii=False, separators=(",", ":") + ), + }, + ] + try: + body = await self._request(messages, provider="primary", stream=False, json_mode=True) + raw = self._response_text(body, provider="primary") + return self._parse_suggestions(raw) + except Exception as primary_exc: # noqa: BLE001 - prevention suggestions have the same failover policy + logger.warning( + "LLM prevention suggestion request failed provider=primary model=%s error=%s", + self.model, + primary_exc.__class__.__name__, + ) + if not self.fallback_configured: + raise + + try: + body = await self._request(messages, provider="fallback", stream=False, json_mode=True) + raw = self._response_text(body, provider="fallback") + suggestions = self._parse_suggestions(raw) + logger.info( + "LLM prevention suggestions generated provider=fallback model=%s count=%s", + self.fallback_model, + len(suggestions), + ) + return suggestions + except Exception as fallback_exc: # noqa: BLE001 - caller owns deterministic fallback behavior + logger.exception( + "LLM prevention suggestion fallback failed provider=fallback model=%s", + self.fallback_model, + ) + raise AppException( + "主模型与兜底模型均无法生成预防建议", + code=503, + status_code=503, + ) from fallback_exc + + async def _stream_provider( self, messages: list[dict[str, str]], + *, + provider: ProviderName, ) -> AsyncGenerator[str, None]: - """Yield text deltas from an OpenAI-compatible streaming response.""" - - headers = self._headers() - payload = { - "model": self.model, - "messages": messages, - "temperature": self.temperature, - "stream": True, - } - timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) + headers = self._headers(provider) + _, model, timeout_seconds = self._provider_config(provider) + is_responses = provider == "fallback" and self.fallback_api_mode == "responses" + payload = self._payload(messages, provider=provider, stream=True, json_mode=False) + timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds)) async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: - async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response: - if response.status_code >= 400: - detail = await response.aread() - raise AppException( - f"LLM 请求失败(HTTP {response.status_code})", - code=503, - status_code=503, - details=detail.decode("utf-8", errors="replace")[:500], - ) - async for line in response.aiter_lines(): - if not line.startswith("data:"): - continue - raw = line[5:].strip() - if not raw or raw == "[DONE]": - continue - try: - chunk = json.loads(raw) - except json.JSONDecodeError: - continue - choices = chunk.get("choices") or [] - if not choices: - continue - delta = (choices[0].get("delta") or {}).get("content") - if isinstance(delta, list): - delta = "".join( - str(part.get("text", "")) for part in delta if isinstance(part, dict) - ) - if delta: - yield str(delta) + try: + async with client.stream("POST", self._endpoint(provider), headers=headers, json=payload) as response: + if response.status_code >= 400: + detail = await response.aread() + raise self._http_failure(response.status_code, detail) + async for line in response.aiter_lines(): + if not line.startswith("data:"): + continue + raw = line[5:].strip() + if not raw or raw == "[DONE]": + continue + try: + chunk = json.loads(raw) + except json.JSONDecodeError: + continue + delta = self._stream_delta(chunk, responses_api=is_responses) + if delta: + yield delta + except AppException: + raise + except httpx.HTTPError as exc: + raise AppException( + "LLM 流式连接失败", + code=503, + status_code=503, + ) from exc + + logger.info("LLM stream completed provider=%s model=%s", provider, model) async def _request( self, messages: list[dict[str, str]], *, + provider: ProviderName, stream: bool, json_mode: bool = False, ) -> dict[str, Any]: - headers = self._headers() - payload: dict[str, Any] = { - "model": self.model, + headers = self._headers(provider) + payload = self._payload(messages, provider=provider, stream=stream, json_mode=json_mode) + _, model, timeout_seconds = self._provider_config(provider) + timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds)) + logger.info( + "LLM request start provider=%s model=%s endpoint=%s stream=%s", + provider, + model, + self._endpoint(provider), + stream, + ) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post(self._endpoint(provider), headers=headers, json=payload) + if response.status_code >= 400: + detail = await response.aread() + raise self._http_failure(response.status_code, detail) + except AppException: + raise + except httpx.HTTPError as exc: + raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc + try: + data = response.json() + except ValueError as exc: + raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc + if not isinstance(data, dict): + raise AppException("LLM 返回格式无效", code=503, status_code=503) + logger.info("LLM request success provider=%s model=%s status=%s", provider, model, response.status_code) + return data + + def _payload( + self, + messages: list[dict[str, str]], + *, + provider: ProviderName, + stream: bool, + json_mode: bool, + ) -> dict[str, Any]: + if provider == "fallback" and self.fallback_api_mode == "responses": + payload: dict[str, Any] = { + "model": self.fallback_model, + "input": messages, + "stream": stream, + "max_output_tokens": self.fallback_max_output_tokens, + } + if self.fallback_reasoning_effort: + payload["reasoning_effort"] = self.fallback_reasoning_effort + return payload + + _, model, _ = self._provider_config(provider) + payload = { + "model": model, "messages": messages, "temperature": self.temperature, "stream": stream, } if json_mode: payload["response_format"] = {"type": "json_object"} - timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) - async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: - try: - response = await client.post(self.endpoint, headers=headers, json=payload) - response.raise_for_status() - except httpx.HTTPError as exc: - raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc + return payload + + @staticmethod + def _http_failure(status_code: int, detail: bytes) -> AppException: + return AppException( + f"LLM 请求失败(HTTP {status_code})", + code=503, + status_code=503, + details=detail.decode("utf-8", errors="replace")[:500], + ) + + @staticmethod + def _response_text(body: dict[str, Any], *, provider: ProviderName) -> str: + if provider == "fallback": + output_text = body.get("output_text") + if isinstance(output_text, str) and output_text: + return output_text + parts: list[str] = [] + for item in body.get("output") or []: + if not isinstance(item, dict): + continue + content = item.get("content") + if isinstance(content, str): + parts.append(content) + for part in content or []: + if isinstance(part, dict) and part.get("type") in {"output_text", "text"}: + parts.append(str(part.get("text") or "")) + if parts: + return "".join(parts) + try: - return response.json() - except ValueError as exc: - raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc + content = body["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc + if isinstance(content, list): + content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) + return str(content) + + @staticmethod + def _stream_delta(chunk: dict[str, Any], *, responses_api: bool) -> str: + if responses_api: + event_type = chunk.get("type") or chunk.get("event") + if event_type in {"response.output_text.delta", "output_text.delta"}: + return str(chunk.get("delta") or "") + return "" + choices = chunk.get("choices") or [] + if not choices: + return "" + delta = (choices[0].get("delta") or {}).get("content") + if isinstance(delta, list): + delta = "".join(str(part.get("text", "")) for part in delta if isinstance(part, dict)) + return str(delta or "") @staticmethod def _parse_json(content: str) -> dict[str, Any]: @@ -161,3 +390,28 @@ class LLMClient: if not isinstance(parsed, dict): raise AppException("LLM 语义结果格式无效", code=503, status_code=503) return parsed + + @staticmethod + def _parse_suggestions(content: str) -> list[str]: + """Parse and normalize the model's suggestion-only JSON response.""" + + cleaned = content.strip() + if cleaned.startswith("```"): + cleaned = cleaned.removeprefix("```").removeprefix("json").removesuffix("```").strip() + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError as exc: + raise AppException("LLM 预防建议不是合法 JSON", code=503, status_code=503) from exc + values = parsed.get("suggestions") if isinstance(parsed, dict) else parsed + if not isinstance(values, list): + raise AppException("LLM 预防建议格式无效", code=503, status_code=503) + suggestions: list[str] = [] + for value in values: + if not isinstance(value, str): + continue + item = " ".join(value.strip().split()) + if item and item not in suggestions: + suggestions.append(item[:100]) + if not suggestions: + raise AppException("LLM 未返回有效预防建议", code=503, status_code=503) + return suggestions[:6] diff --git a/app/services/risk_prediction.py b/app/services/risk_prediction.py index 9c16855..63d0bc5 100644 --- a/app/services/risk_prediction.py +++ b/app/services/risk_prediction.py @@ -1,12 +1,17 @@ """Risk prediction use-case orchestration.""" +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppException from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue from app.schemas.risk_prediction import ( ModelInfo, RiskAssessmentRequest, RiskAssessmentResult, + RiskDimensionScore, RiskFactorResult, ) +from app.services.database_risk_scoring import DatabaseRiskScoringService class RiskPredictionService: @@ -14,10 +19,63 @@ class RiskPredictionService: @classmethod def model_info(cls) -> ModelInfo: - return ModelInfo(model_version=cls._engine.model_version) + return ModelInfo(model_version="database-seven-dimension-v1") @classmethod - def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult: + async def preview( + cls, + request: RiskAssessmentRequest, + session: AsyncSession | None = None, + ) -> RiskAssessmentResult: + if session and any( + value is not None + for value in ( + request.product_category, + request.material_type, + request.supplier, + request.factory, + request.stage_new, + request.defect_level, + ) + ): + database_score = await DatabaseRiskScoringService(session).score( + category=request.product_category, + material=request.material_type, + supplier=request.supplier, + factory=request.factory, + stage=request.stage_new, + defect_level=request.defect_level, + ) + dimensions = [ + RiskDimensionScore( + dimension=item.name, + score=item.score, + weight=item.weight, + status=item.status, + reason=item.reason, + sample_size=item.sample_size, + raw_value=item.raw_value, + ) + for item in database_score.dimensions + ] + return RiskAssessmentResult( + subject_id=request.subject_id, + score=database_score.score, + level=database_score.level, + model_version="database-seven-dimension-v1", + factors=[ + RiskFactorResult( + name=item.name, + value=round(100 - item.score, 2), + weight=item.weight, + contribution=round(item.score * item.weight / 100, 2), + ) + for item in database_score.dimensions + ], + dimension_scores=dimensions, + ) + if not request.indicators: + raise AppException("请填写产品评估参数", code=422, status_code=422) score = cls._engine.predict( [ IndicatorValue(name=item.name, value=item.value, weight=item.weight) @@ -39,4 +97,3 @@ class RiskPredictionService: for item in score.factors ], ) - diff --git a/tests/test_conversations.py b/tests/test_conversations.py index 165781a..aeec69f 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -1,5 +1,6 @@ from datetime import UTC, datetime +import pytest from app.models.entities import Conversation, ConversationMessage from app.services.conversations import ConversationService @@ -119,3 +120,61 @@ def test_product_assessment_semantics_override_nlu_miss() -> None: assert normalized["intent"] == "assessment" assert normalized["risk_requested"] is True + + +def test_short_known_supplier_follow_up_is_routed_to_supplier_card() -> None: + assert ConversationService._infer_supplier_follow_up( + "佳亿", + ["佳亿", "祥印", "瑞沣"], + ) == "佳亿" + assert ConversationService._infer_supplier_follow_up( + "这个供应商", + ["佳亿", "祥印"], + ) is None + + +def test_unknown_standalone_name_is_treated_as_new_supplier() -> None: + assert ConversationService._infer_unknown_supplier_follow_up("德盛") == "德盛" + assert ConversationService._infer_unknown_supplier_follow_up("新供应商A") is None + assert ConversationService._infer_unknown_supplier_follow_up("测试供应商") is None + assert ConversationService._infer_unknown_supplier_follow_up("新供应商B") is None + assert ConversationService._infer_unknown_supplier_follow_up("洗发水") is None + assert ConversationService._infer_unknown_supplier_follow_up("帮我查德盛") is None + assert ConversationService._response_type("测试供应商", {"intent": "supplier"}) == "general" + assert ConversationService._response_type("新供应商A", {"intent": "supplier"}) == "general" + + +@pytest.mark.asyncio +async def test_any_product_without_history_uses_solution_card(monkeypatch: pytest.MonkeyPatch) -> None: + service = object.__new__(ConversationService) + + async def no_history(_: str) -> int: + return 0 + + monkeypatch.setattr(service, "_count_category_anomalies", no_history) + + response_type = ConversationService._response_type( + "评估防晒霜新品,50g,软管包装", + {"intent": "assessment", "risk_requested": True, "category": "防晒霜"}, + ) + result = await service._use_solution_card_without_history( + response_type, + "评估防晒霜新品,50g,软管包装", + {"intent": "assessment", "risk_requested": True, "category": "防晒霜"}, + ) + + assert response_type == "risk_assessment" + assert result == "solution" + + +@pytest.mark.asyncio +async def test_unknown_product_without_category_uses_solution_card() -> None: + service = object.__new__(ConversationService) + + result = await service._use_solution_card_without_history( + "risk_assessment", + "评估这个新产品的风险", + {"intent": "assessment", "risk_requested": True}, + ) + + assert result == "solution" diff --git a/tests/test_database_risk_scoring.py b/tests/test_database_risk_scoring.py new file mode 100644 index 0000000..242b019 --- /dev/null +++ b/tests/test_database_risk_scoring.py @@ -0,0 +1,65 @@ +from datetime import date + +import pytest +from app.services.database_risk_scoring import DatabaseRiskScoringService + + +def _row(**values): + return { + "date": date(2026, 1, 10), + "product_name": "洗发水 500ml", + "product_category": None, + "material_type": "瓶子", + "supplier": "祥印", + "factory": "一一", + "stage_new": "第三阶段(产品开发)", + "defect_level": None, + **values, + } + + +@pytest.mark.asyncio +async def test_first_phase_score_uses_database_dimensions_and_prd_fallbacks() -> None: + service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4)) + + async def load_rows(): + return [ + _row(), + _row(product_name="洗发水 250ml", defect_level="B"), + ] + + service._load_rows = load_rows # type: ignore[method-assign] + result = await service.score( + category="洗发水", + material="瓶子", + supplier="祥印", + stage="产品开发", + ) + + assert len(result.dimensions) == 7 + assert sum(item.weight for item in result.dimensions) == 100 + assert result.level.value == "medium" + assert result.dimensions[0].score == 95.57 + assert result.dimensions[0].raw_value == 2 + assert result.dimensions[1].score == 0 + assert result.dimensions[2].score == 45 + assert result.dimensions[3].status == "default" + assert result.dimensions[4].status == "default" + assert result.dimensions[5].score == 50 + assert result.dimensions[6].score == 85 + assert result.score == 52.39 + + +@pytest.mark.asyncio +async def test_supplier_dimension_does_not_count_factory_records() -> None: + service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4)) + + async def load_rows(): + return [_row(supplier=None, factory="祥印")] + + service._load_rows = load_rows # type: ignore[method-assign] + result = await service.score(supplier="祥印") + + supplier_dimension = result.dimensions[0] + assert supplier_dimension.score == 70 + assert supplier_dimension.raw_value == 0 diff --git a/tests/test_llm_protocol.py b/tests/test_llm_protocol.py index c8ee269..387bf08 100644 --- a/tests/test_llm_protocol.py +++ b/tests/test_llm_protocol.py @@ -18,6 +18,9 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes nonlocal calls calls += 1 if calls == 1: + request_body = json.loads(request.content) + assert request_body["response_format"] == {"type": "json_object"} + assert "json" in request_body["messages"][0]["content"] return httpx.Response( 200, json={ @@ -45,13 +48,86 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes client = LLMClient(transport=httpx.MockTransport(handler)) semantic = await client.extract_semantics("评估洗发水新品") - chunks = [ - chunk - async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}]) - ] + chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])] assert semantic["intent"] == "assessment" assert semantic["confidence"] == 0.92 assert "".join(chunks) == "第一段第二段" assert calls == 2 get_settings.cache_clear() + + +@pytest.mark.asyncio +async def test_semantic_request_fails_over_to_deepseek_responses(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key") + monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1") + monkeypatch.setenv("RISK_LLM_MODEL", "primary-model") + monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key") + monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test") + monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro") + monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses") + calls: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + if request.url.path.endswith("/chat/completions"): + return httpx.Response(503, json={"error": {"message": "primary unavailable"}}) + body = json.loads(request.content) + assert body["model"] == "deepseek-v4-pro" + assert body["input"][-1]["content"] == "评估洗发水新品" + return httpx.Response( + 200, + json={ + "output_text": json.dumps( + {"intent": "assessment", "risk_requested": True, "confidence": 0.8}, + ensure_ascii=False, + ) + }, + ) + + client = LLMClient(transport=httpx.MockTransport(handler)) + result = await client.extract_semantics("评估洗发水新品") + + assert result["intent"] == "assessment" + assert calls == [ + "https://primary.test/v1/chat/completions", + "https://fallback.test/responses", + ] + get_settings.cache_clear() + + +@pytest.mark.asyncio +async def test_stream_request_fails_over_before_primary_emits_text(monkeypatch: pytest.MonkeyPatch) -> None: + get_settings.cache_clear() + monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key") + monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1") + monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key") + monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test") + monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro") + monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses") + calls: list[str] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + if request.url.path.endswith("/chat/completions"): + raise httpx.ConnectError("primary connection failed", request=request) + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=( + 'data: {"type":"response.output_text.delta","delta":"兜底"}\n\n' + 'data: {"type":"response.output_text.delta","delta":"回复"}\n\n' + "data: [DONE]\n\n" + ).encode(), + ) + + client = LLMClient(transport=httpx.MockTransport(handler)) + chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])] + + assert "".join(chunks) == "兜底回复" + assert calls == [ + "https://primary.test/v1/chat/completions", + "https://fallback.test/responses", + ] + get_settings.cache_clear() From b0b6ef014f3b06621c05f1f45cf46b1999c0ff38 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Sat, 5 Sep 2026 19:00:25 +0800 Subject: [PATCH 02/24] =?UTF-8?q?=E4=BF=9D=E7=95=99RAG=E6=A3=80=E7=B4=A2?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E7=9B=B8=E4=BC=BC=E6=A1=88=E4=BE=8B?= =?UTF-8?q?=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保留 pgvector 与混合检索能力,返回 TOP 5 相似案例\n- 将相似案例上下文传入 AI 预防建议生成\n- 修复风险评估调用缺少 similar_cases 参数导致的运行报错\n- 保留 Embedding 配置、数据库迁移、回填脚本及 RAG 测试\n- 恢复基础字符串数组格式的 AI 预防建议协议 --- .env.example | 10 + app/core/config.py | 36 ++ app/schemas/risk_prediction.py | 8 + app/services/conversations.py | 76 ++++- app/services/rag.py | 311 ++++++++++++++++++ .../20260905_0014_add_optional_pgvector.py | 51 +++ scripts/backfill_embeddings.py | 175 ++++++++++ scripts/enable_pgvector.py | 38 +++ tests/test_rag.py | 87 +++++ 9 files changed, 784 insertions(+), 8 deletions(-) create mode 100644 app/services/rag.py create mode 100644 migrations/versions/20260905_0014_add_optional_pgvector.py create mode 100644 scripts/backfill_embeddings.py create mode 100644 scripts/enable_pgvector.py create mode 100644 tests/test_rag.py diff --git a/.env.example b/.env.example index 89e8730..3e19b2c 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,16 @@ RISK_LLM_FALLBACK_MAX_RETRIES=1 RISK_LLM_FALLBACK_REASONING_EFFORT=high RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS=16384 +# ==================== RAG 向量检索配置 ==================== +# 可选的 Embeddings 服务;留空 API Key 时复用主 LLM Key 和地址。 +# 当前数据库未安装 pgvector 时,系统自动使用业务字段 + 文本相关性混合检索。 +RISK_RAG_EMBEDDING_API_KEY= +RISK_RAG_EMBEDDING_BASE_URL=https://api.openlux.ai/v1 +RISK_RAG_EMBEDDING_MODEL=gemini-embedding-2-preview +RISK_RAG_EMBEDDING_DIMENSIONS=3072 +RISK_RAG_EMBEDDING_TIMEOUT_SECONDS=10 +RISK_RAG_TOP_K=5 + # ==================== 跨域配置 ==================== # 允许访问后端接口的前端地址,多个地址用英文逗号分隔。 RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158 diff --git a/app/core/config.py b/app/core/config.py index 2dbf7f8..24d59c1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -92,6 +92,37 @@ class Settings(BaseSettings): le=393_216, validation_alias=AliasChoices("RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS", "DEEPSEEK_MAX_OUTPUT_TOKENS"), ) + rag_embedding_api_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("RISK_RAG_EMBEDDING_API_KEY", "EMBEDDING_API_KEY"), + ) + rag_embedding_base_url: str = Field( + default="", + validation_alias=AliasChoices("RISK_RAG_EMBEDDING_BASE_URL", "EMBEDDING_BASE_URL"), + ) + rag_embedding_model: str = Field( + default="bge-m3", + validation_alias=AliasChoices("RISK_RAG_EMBEDDING_MODEL", "EMBEDDING_MODEL"), + ) + rag_embedding_dimensions: int = Field( + default=3072, + ge=1, + le=4096, + validation_alias=AliasChoices("RISK_RAG_EMBEDDING_DIMENSIONS", "EMBEDDING_DIMENSIONS"), + ) + rag_embedding_timeout_seconds: int = Field( + default=10, + gt=0, + le=60, + validation_alias=AliasChoices("RISK_RAG_EMBEDDING_TIMEOUT_SECONDS", "EMBEDDING_TIMEOUT"), + ) + rag_top_k: int = Field( + default=5, + ge=1, + le=20, + validation_alias=AliasChoices("RISK_RAG_TOP_K", "RAG_TOP_K"), + ) cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = ( "http://localhost:39158", "http://127.0.0.1:39158", @@ -121,6 +152,11 @@ class Settings(BaseSettings): def normalize_llm_fallback_base_url(cls, value: str) -> str: return value.rstrip("/") + @field_validator("rag_embedding_base_url") + @classmethod + def normalize_rag_embedding_base_url(cls, value: str) -> str: + return value.rstrip("/") + @field_validator("cors_allow_origins", mode="before") @classmethod def split_cors_origins(cls, value: object) -> object: diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index a02dad4..6a5a364 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -67,10 +67,18 @@ class RiskDimensionScore(ApiSchema): class SimilarCase(ApiSchema): + case_id: int | None = None date: str | None = None product: str | None = None + category: str | None = None + material: str | None = None + anomaly_type: str | None = None + description: str | None = None + root_cause: str | None = None + defect_level: str | None = None problem: str | None = None solution: str | None = None + similarity: float | None = None class RiskAssessmentResult(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 03b57db..1ddd7e1 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -28,6 +28,7 @@ from app.schemas.risk_prediction import ( ) from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.llm_client import LLMClient +from app.services.rag import AnomalyCaseRAG logger = logging.getLogger("dieya_risk_prediction.conversations") @@ -233,11 +234,7 @@ class ConversationService: show_risk = self._is_risk_request(text, semantics) response_type = self._response_type(text, semantics) - response_type = await self._use_solution_card_without_history( - response_type, - text, - semantics, - ) + response_type = await self._use_solution_card_without_history(response_type, text, semantics) risk_result = ( await self._enrich_risk_result(text, semantics, self._predict(text)) if show_risk and response_type == "risk_assessment" @@ -657,6 +654,18 @@ class ConversationService: ) or "当前新品" direct_count = await self._count_category_anomalies(category) standards = await self._standards_for(category, text) + similar_cases = await self._similar_cases( + text, + category=category, + material=self._first_match( + text, + ("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"), + ), + supplier=self._semantic_text(semantics, "supplier"), + factory=self._semantic_text(semantics, "factory"), + stage=self._semantic_text(semantics, "stage"), + anomaly_type=self._semantic_text(semantics, "anomaly_type"), + ) standard_fallback_enabled = direct_count == 0 if standard_fallback_enabled and not standards: standards = await self._fallback_standards_for(text) @@ -700,7 +709,10 @@ class ConversationService: "standard_fallback_enabled": standard_fallback_enabled, "matched_standards": [self._standard_dict(item) for item in standards[:5]], "identified_risks": risks, - "rag_similar_cases": [], + "rag_similar_cases": [ + {"similarity": item.case.similarity, **item.case.model_dump(mode="json", by_alias=False)} + for item in similar_cases + ], } ) except Exception as exc: # noqa: BLE001 - the solution card must remain available @@ -722,6 +734,7 @@ class ConversationService: ), "standardFallbackEnabled": standard_fallback_enabled, "standards": [self._standard_dict(item) for item in standards[:4]], + "similarCases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases], "risks": risks, "suggestions": suggestions, "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", @@ -908,6 +921,18 @@ class ConversationService: factory_count = await self._count_anomalies(AnomalyCase.factory, factory) anomaly_count = supplier_count if supplier else factory_count standard_rows = await self._standards_for(category, text) + similar_cases = await self._similar_cases( + text, + category=category, + material=self._first_match( + text, + ("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"), + ), + supplier=supplier, + factory=factory, + stage=self._semantic_text(semantics, "stage"), + anomaly_type=self._semantic_text(semantics, "anomaly_type"), + ) alpha_count = supplier_count if supplier else 0 alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0 @@ -952,6 +977,7 @@ class ConversationService: database_score=database_score, dimensions=dimensions, standard_rows=standard_rows, + similar_cases=similar_cases, ) return result.model_copy( @@ -986,7 +1012,7 @@ class ConversationService: ), "standard_judgment": standard_rows, "dimension_scores": dimensions, - "similar_cases": [], + "similar_cases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases], "ai_suggestions": suggestions, } ) @@ -1012,6 +1038,7 @@ class ConversationService: database_score, dimensions: Sequence[RiskDimensionScore], standard_rows: Sequence[StandardJudgment], + similar_cases: Sequence[Any], ) -> Sequence[str]: # noqa: ANN001 """Generate suggestions from live scoring data, with a deterministic fallback.""" @@ -1042,7 +1069,13 @@ class ConversationService: for item in dimensions ], "matched_standards": [self._standard_dict(item) for item in standard_rows[:5]], - "rag_similar_cases": [], + "rag_similar_cases": [ + { + "similarity": item.case.similarity, + **item.case.model_dump(mode="json", by_alias=False), + } + for item in similar_cases + ], } try: suggestions = await self.llm.generate_prevention_suggestions(context) @@ -1055,6 +1088,33 @@ class ConversationService: ) return self._suggestions(text, supplier_or_factory) + async def _similar_cases( + self, + query: str, + *, + category: str | None = None, + material: str | None = None, + supplier: str | None = None, + factory: str | None = None, + stage: str | None = None, + anomaly_type: str | None = None, + ) -> Sequence[Any]: + """Return historical cases for both the card and the AI context.""" + + try: + return await AnomalyCaseRAG(self.session).search( + query=query, + category=category, + material=material, + supplier=supplier, + factory=factory, + stage=stage, + anomaly_type=anomaly_type, + ) + except Exception as exc: # noqa: BLE001 - RAG must not block risk scoring + logger.warning("RAG historical case search failed error=%s", exc.__class__.__name__) + return [] + async def _count_category_anomalies(self, category: str) -> int: """The imported workbook has sparse product_category values. diff --git a/app/services/rag.py b/app/services/rag.py new file mode 100644 index 0000000..5e50a1b --- /dev/null +++ b/app/services/rag.py @@ -0,0 +1,311 @@ +"""Hybrid retrieval for historical anomaly cases.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from dataclasses import dataclass +from typing import Any + +import httpx +from sqlalchemy import select +from sqlalchemy import text as sql_text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.models.entities import AnomalyCase +from app.schemas.risk_prediction import SimilarCase + +logger = logging.getLogger("dieya_risk_prediction.rag") +_embedding_semaphore = asyncio.Semaphore(1) + + +@dataclass(frozen=True, slots=True) +class RetrievedCase: + case: SimilarCase + score: float + case_id: int + + +class AnomalyCaseRAG: + """Retrieve historical cases with business, text, and optional vector signals.""" + + def __init__( + self, + session: AsyncSession, + *, + top_k: int | None = None, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.session = session + self.top_k = top_k or get_settings().rag_top_k + self.transport = transport + + async def search( + self, + *, + query: str, + category: str | None = None, + material: str | None = None, + supplier: str | None = None, + factory: str | None = None, + stage: str | None = None, + anomaly_type: str | None = None, + ) -> list[RetrievedCase]: + terms = self._terms(query, category, material, supplier, factory, stage, anomaly_type) + rows = await self._load_rows( + query, + category=category, + material=material, + supplier=supplier, + factory=factory, + stage=stage, + anomaly_type=anomaly_type, + ) + ranked = [ + RetrievedCase( + case=self._to_case(row), + score=self._score( + row, + query=query, + category=category, + material=material, + supplier=supplier, + factory=factory, + stage=stage, + anomaly_type=anomaly_type, + terms=terms, + ), + case_id=int(row["id"]), + ) + for row in rows + ] + ranked = [item for item in ranked if item.score > 0] + ranked.sort(key=lambda item: (item.score, item.case_id), reverse=True) + for item in ranked[: self.top_k]: + vector_similarity = next( + ( + float(row["vector_score"]) + for row in rows + if int(row["id"]) == item.case_id and row.get("vector_score") is not None + ), + None, + ) + item.case.similarity = round( + max(0.0, min(1.0, vector_similarity if vector_similarity is not None else item.score / 100)), + 3, + ) + return ranked[: self.top_k] + + async def _load_rows( + self, + query: str, + *, + category: str | None, + material: str | None, + supplier: str | None, + factory: str | None, + stage: str | None, + anomaly_type: str | None, + ) -> list[dict[str, Any]]: + fields = ( + AnomalyCase.id, + AnomalyCase.date, + AnomalyCase.product_name, + AnomalyCase.product_category, + AnomalyCase.material_type, + AnomalyCase.supplier, + AnomalyCase.factory, + AnomalyCase.stage_new, + AnomalyCase.anomaly_type, + AnomalyCase.description, + AnomalyCase.solution, + AnomalyCase.root_cause, + AnomalyCase.defect_level, + ) + vector_ready = await self._vector_column_ready() + has_vectors = await self._has_stored_vectors() if vector_ready else False + vector = await self._query_embedding(query) if has_vectors else None + if vector is None: + return [dict(row) for row in (await self.session.execute(select(*fields).limit(5000))).mappings().all()] + + vector_literal = "[" + ",".join(str(float(value)) for value in vector) + "]" + statement = sql_text( + """ + SELECT id, date, product_name, product_category, material_type, supplier, + factory, stage_new, anomaly_type, description, solution, root_cause, + defect_level, 1 - (embedding_vector <=> CAST(:embedding AS halfvec)) AS vector_score + FROM anomaly_cases + WHERE embedding_vector IS NOT NULL + ORDER BY embedding_vector <=> CAST(:embedding AS halfvec) + LIMIT 5000 + """ + ) + result = await self.session.execute(statement, {"embedding": vector_literal}) + rows = [dict(row) for row in result.mappings().all()] + if rows: + return rows + return [dict(row) for row in (await self.session.execute(select(*fields).limit(5000))).mappings().all()] + + async def _vector_column_ready(self) -> bool: + try: + result = await self.session.execute( + sql_text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'anomaly_cases' + AND column_name = 'embedding_vector' + AND udt_name IN ('vector', 'halfvec') + ) + """ + ) + ) + return bool(result.scalar()) + except Exception: # noqa: BLE001 - a missing optional extension uses lexical retrieval + await self.session.rollback() + return False + + async def _query_embedding(self, query: str) -> list[float] | None: + settings = get_settings() + api_key = ( + settings.rag_embedding_api_key.get_secret_value() + if settings.rag_embedding_api_key + else settings.llm_api_key.get_secret_value() if settings.llm_api_key else "" + ) + base_url = settings.rag_embedding_base_url or settings.llm_base_url + if not api_key or not base_url: + return None + endpoint = f"{base_url.rstrip('/')}/embeddings" + if not base_url.rstrip("/").endswith("/v1"): + endpoint = f"{base_url.rstrip('/')}/v1/embeddings" + timeout = httpx.Timeout( + settings.rag_embedding_timeout_seconds, + connect=min(5, settings.rag_embedding_timeout_seconds), + ) + try: + async with _embedding_semaphore: + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + response = await client.post( + endpoint, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": settings.rag_embedding_model, "input": query[:2000]}, + ) + response.raise_for_status() + body = response.json() + embedding = (body.get("data") or [{}])[0].get("embedding") + if not isinstance(embedding, list) or len(embedding) != settings.rag_embedding_dimensions: + return None + return [float(value) for value in embedding] + except (httpx.HTTPError, ValueError, TypeError, KeyError) as exc: + logger.warning( + "RAG embedding request failed error=%s; using lexical retrieval", + exc.__class__.__name__, + ) + return None + + async def _has_stored_vectors(self) -> bool: + """Avoid a paid/network embedding call until case vectors exist.""" + + try: + result = await self.session.execute( + sql_text( + "SELECT EXISTS (" + "SELECT 1 FROM anomaly_cases WHERE embedding_vector IS NOT NULL LIMIT 1" + ")" + ) + ) + return bool(result.scalar()) + except Exception: # noqa: BLE001 - lexical retrieval is the safe fallback + await self.session.rollback() + return False + + @staticmethod + def _terms(*values: str | None) -> list[str]: + terms: list[str] = [] + for value in values: + if not value: + continue + cleaned = " ".join(str(value).strip().lower().split()) + runs = re.findall(r"[a-z0-9]+|[\u4e00-\u9fff]{2,}", cleaned) + terms.extend(runs) + for run in runs: + if re.fullmatch(r"[\u4e00-\u9fff]+", run) and len(run) > 3: + terms.extend( + run[index : index + size] + for size in (2, 3, 4) + for index in range(len(run) - size + 1) + ) + return list(dict.fromkeys(term for term in terms if len(term) > 1)) + + @classmethod + def _score( + cls, + row: dict[str, Any], + *, + query: str, + category: str | None, + material: str | None, + supplier: str | None, + factory: str | None, + stage: str | None, + anomaly_type: str | None, + terms: list[str], + ) -> float: + score = 0.0 + score += cls._field_score(row.get("product_category"), category, 7) + score += cls._field_score(row.get("product_name"), category, 5) + score += cls._field_score(row.get("material_type"), material, 7) + score += cls._field_score(row.get("supplier"), supplier, 6) + score += cls._field_score(row.get("factory"), factory, 6) + score += cls._field_score(row.get("stage_new"), stage, 3) + score += cls._field_score(row.get("anomaly_type"), anomaly_type, 7) + vector_score = row.get("vector_score") + if vector_score is not None: + score += max(0.0, float(vector_score)) * 40 + searchable = " ".join(str(row.get(key) or "") for key in ( + "product_name", + "product_category", + "material_type", + "anomaly_type", + "description", + "root_cause", + "solution", + )).lower() + query_lower = query.lower().strip() + if query_lower and query_lower in searchable: + score += 8 + score += min(12.0, sum(1 for term in terms if term in searchable) * 1.5) + if str(row.get("defect_level") or "").upper() == "A": + score += 1.0 + return round(score, 2) + + @staticmethod + def _field_score(actual: Any, expected: str | None, weight: float) -> float: + if not actual or not expected: + return 0.0 + actual_text = str(actual).lower() + expected_text = str(expected).lower() + if actual_text == expected_text: + return weight + if expected_text in actual_text or actual_text in expected_text: + return weight * 0.7 + return 0.0 + + @staticmethod + def _to_case(row: dict[str, Any]) -> SimilarCase: + return SimilarCase( + case_id=int(row["id"]) if row.get("id") is not None else None, + date=row.get("date").isoformat() if row.get("date") else None, + product=row.get("product_name") or row.get("product_category") or "未命名产品", + category=row.get("product_category"), + material=row.get("material_type"), + anomaly_type=row.get("anomaly_type"), + description=row.get("description"), + root_cause=row.get("root_cause"), + defect_level=row.get("defect_level"), + problem=(row.get("anomaly_type") or row.get("description") or "历史异常未填写"), + solution=(row.get("solution") or row.get("root_cause") or "请查看原始案例记录"), + ) diff --git a/migrations/versions/20260905_0014_add_optional_pgvector.py b/migrations/versions/20260905_0014_add_optional_pgvector.py new file mode 100644 index 0000000..1598ce6 --- /dev/null +++ b/migrations/versions/20260905_0014_add_optional_pgvector.py @@ -0,0 +1,51 @@ +"""Add the optional pgvector column for anomaly-case retrieval.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260905_0014" +down_revision: str | None = "20260902_0013" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + connection = op.get_bind() + available = connection.execute( + sa.text("SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector')") + ).scalar() + if not available: + # The application has a structured/text retrieval fallback. Keep the + # migration non-blocking on managed PostgreSQL instances without the + # optional extension; install pgvector and rerun this revision later. + return + is_superuser = connection.execute( + sa.text("SELECT rolsuper FROM pg_roles WHERE rolname = current_user") + ).scalar() + if not is_superuser: + # Managed PostgreSQL may expose the package but reserve CREATE + # EXTENSION for an administrator. Lexical RAG remains usable. + return + connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS vector")) + connection.execute( + sa.text( + "ALTER TABLE anomaly_cases " + "ADD COLUMN IF NOT EXISTS embedding_vector halfvec(3072)" + ) + ) + connection.execute( + sa.text( + "CREATE INDEX IF NOT EXISTS ix_anomaly_cases_embedding_vector " + "ON anomaly_cases USING hnsw (embedding_vector halfvec_cosine_ops)" + ) + ) + + +def downgrade() -> None: + connection = op.get_bind() + connection.execute(sa.text("DROP INDEX IF EXISTS ix_anomaly_cases_embedding_vector")) + connection.execute( + sa.text("ALTER TABLE anomaly_cases DROP COLUMN IF EXISTS embedding_vector") + ) diff --git a/scripts/backfill_embeddings.py b/scripts/backfill_embeddings.py new file mode 100644 index 0000000..2da7356 --- /dev/null +++ b/scripts/backfill_embeddings.py @@ -0,0 +1,175 @@ +"""Generate and store anomaly-case embeddings after pgvector is available. + +Usage: + .venv/Scripts/python -m scripts.backfill_embeddings + +The script is intentionally separate from application startup. Embedding 2,448 +rows can take time and should never make the API unavailable. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import random +from typing import Any + +import httpx +from app.core.config import get_settings +from app.database.session import SessionFactory +from sqlalchemy import text + +logger = logging.getLogger("dieya_risk_prediction.embedding_backfill") + + +def _case_text(row: dict[str, Any]) -> str: + labels = ( + ("产品", row.get("product_name")), + ("品类", row.get("product_category")), + ("包材", row.get("material_type")), + ("供应商", row.get("supplier")), + ("加工厂", row.get("factory")), + ("阶段", row.get("stage_new")), + ("异常类型", row.get("anomaly_type")), + ("异常描述", row.get("description")), + ("原因", row.get("root_cause")), + ("解决方案", row.get("solution")), + ) + return ";".join(f"{label}:{value}" for label, value in labels if value) + + +async def _embedding( + client: httpx.AsyncClient, + endpoint: str, + api_key: str, + texts: list[str], + model: str, +) -> list[list[float]]: + for attempt in range(4): + response = await client.post( + endpoint, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": model, "input": texts}, + ) + if response.status_code not in {429, 500, 502, 503, 504}: + response.raise_for_status() + data = response.json().get("data") or [] + ordered = sorted(data, key=lambda item: item.get("index", 0)) + return [list(map(float, item["embedding"])) for item in ordered] + if attempt == 3: + response.raise_for_status() + retry_after = response.headers.get("retry-after") + try: + delay = min(60.0, max(3.0, float(retry_after))) if retry_after else 3.0 * (2**attempt) + except ValueError: + delay = 3.0 * (2**attempt) + delay += random.uniform(0, 1) + logger.warning( + "Embedding API 限流或暂时不可用 status=%s attempt=%s delay=%.1fs", + response.status_code, + attempt + 1, + delay, + ) + await asyncio.sleep(delay) + raise RuntimeError("Embedding 请求重试次数已用尽") + + +async def main(batch_size: int) -> None: + settings = get_settings() + api_key = ( + settings.rag_embedding_api_key.get_secret_value() + if settings.rag_embedding_api_key + else settings.llm_api_key.get_secret_value() if settings.llm_api_key else "" + ) + base_url = settings.rag_embedding_base_url or settings.llm_base_url + if not api_key or not base_url: + raise RuntimeError("未配置 Embeddings API Key 或 Base URL") + endpoint = f"{base_url.rstrip('/')}/embeddings" + if not base_url.rstrip("/").endswith("/v1"): + endpoint = f"{base_url.rstrip('/')}/v1/embeddings" + + async with SessionFactory() as session: + column_exists = await session.scalar( + text( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'anomaly_cases' + AND column_name = 'embedding_vector' + AND udt_name = 'halfvec' + ) + """ + ) + ) + if not column_exists: + raise RuntimeError("当前数据库没有 embedding_vector,请先安装 pgvector 并执行 alembic upgrade head") + rows = ( + await session.execute( + text( + """ + SELECT id, product_name, product_category, material_type, supplier, + factory, stage_new, anomaly_type, description, root_cause, solution + FROM anomaly_cases + WHERE embedding_vector IS NULL + ORDER BY id + """ + ) + ) + ).mappings().all() + + logger.info("待生成案例向量数量=%s", len(rows)) + print(f"待生成案例向量数量:{len(rows)},批次大小:{batch_size}", flush=True) + timeout = httpx.Timeout( + settings.rag_embedding_timeout_seconds, + connect=min(5, settings.rag_embedding_timeout_seconds), + ) + async with httpx.AsyncClient(timeout=timeout) as client: + success_count = 0 + failure_count = 0 + for start in range(0, len(rows), batch_size): + batch = rows[start : start + batch_size] + try: + vectors = await _embedding( + client, + endpoint, + api_key, + [_case_text(dict(row)) for row in batch], + settings.rag_embedding_model, + ) + except (httpx.HTTPError, ValueError, TypeError, KeyError) as exc: + failure_count += len(batch) + logger.exception("案例向量生成失败 case_id=%s", batch[0]["id"]) + print(f"案例 {batch[0]['id']} 失败:{exc.__class__.__name__},已跳过", flush=True) + continue + if len(vectors) != len(batch): + raise RuntimeError("Embeddings 返回数量与请求数量不一致") + async with SessionFactory() as session: + for row, vector in zip(batch, vectors, strict=True): + literal = "[" + ",".join(str(value) for value in vector) + "]" + await session.execute( + text( + "UPDATE anomaly_cases " + "SET embedding_vector = CAST(:embedding AS halfvec) " + "WHERE id = :id" + ), + {"embedding": literal, "id": row["id"]}, + ) + await session.commit() + success_count += len(batch) + logger.info("已完成案例向量回填 %s/%s", min(start + batch_size, len(rows)), len(rows)) + print( + f"已完成:{min(start + batch_size, len(rows))}/{len(rows)}," + f"成功:{success_count},失败:{failure_count},当前案例:{batch[-1]['id']}", + flush=True, + ) + await asyncio.sleep(1.5) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--batch-size", type=int, default=1) + args = parser.parse_args() + if not 1 <= args.batch_size <= 8: + raise SystemExit("--batch-size 必须在 1 到 8 之间") + asyncio.run(main(args.batch_size)) diff --git a/scripts/enable_pgvector.py b/scripts/enable_pgvector.py new file mode 100644 index 0000000..b452ef7 --- /dev/null +++ b/scripts/enable_pgvector.py @@ -0,0 +1,38 @@ +"""Enable pgvector schema objects with a PostgreSQL administrator account. + +Run once after the pgvector package is installed on the server: + + .venv/Scripts/python -m scripts.enable_pgvector + +Then the normal application account can run ``scripts.backfill_embeddings``. +""" + +from __future__ import annotations + +import asyncio + +from app.database.session import SessionFactory +from sqlalchemy import text + + +async def main() -> None: + async with SessionFactory() as session: + await session.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + await session.execute( + text( + "ALTER TABLE anomaly_cases " + "ADD COLUMN IF NOT EXISTS embedding_vector halfvec(3072)" + ) + ) + await session.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_anomaly_cases_embedding_vector " + "ON anomaly_cases USING hnsw (embedding_vector halfvec_cosine_ops)" + ) + ) + await session.commit() + print("pgvector 已启用,embedding_vector 和 HNSW 索引已准备完成。") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/test_rag.py b/tests/test_rag.py new file mode 100644 index 0000000..d026682 --- /dev/null +++ b/tests/test_rag.py @@ -0,0 +1,87 @@ +from datetime import date + +import pytest +from app.services.rag import AnomalyCaseRAG + + +def _row(**values): + return { + "id": 1, + "date": date(2026, 1, 10), + "product_name": "洗发水 500ml", + "product_category": "洗护类", + "material_type": "软管", + "supplier": "祥印", + "factory": "晶品", + "stage_new": "产品开发", + "anomaly_type": "密封性异常", + "description": "软管漏液", + "solution": "增加密封性测试", + "root_cause": "封口参数不稳定", + "defect_level": "B", + **values, + } + + +def test_rag_terms_and_structured_text_score() -> None: + terms = AnomalyCaseRAG._terms("软管密封性异常", "洗发水", "软管") + score = AnomalyCaseRAG._score( + _row(), + query="洗发水软管密封性异常", + category="洗发水", + material="软管", + supplier="祥印", + factory=None, + stage="产品开发", + anomaly_type="密封性异常", + terms=terms, + ) + + assert "密封性" in terms + assert score > 20 + case = AnomalyCaseRAG._to_case(_row()) + assert case.product == "洗发水 500ml" + assert case.problem == "密封性异常" + assert case.solution == "增加密封性测试" + + +@pytest.mark.asyncio +async def test_rag_does_not_call_embedding_until_vectors_are_stored() -> None: + class FakeMappings: + def all(self): + return [_row()] + + class FakeResult: + def mappings(self): + return FakeMappings() + + class FakeSession: + async def execute(self, _statement): + return FakeResult() + + rag = AnomalyCaseRAG(FakeSession(), top_k=1) + + async def vector_column_ready(): + return True + + async def has_stored_vectors(): + return False + + async def embedding_must_not_run(_query): + raise AssertionError("没有已回填向量时不应调用 Embedding") + + rag._vector_column_ready = vector_column_ready + rag._has_stored_vectors = has_stored_vectors + rag._query_embedding = embedding_must_not_run + + rows = await rag._load_rows( + "洗发水软管密封性异常", + category="洗发水", + material="软管", + supplier=None, + factory=None, + stage=None, + anomaly_type=None, + ) + + assert len(rows) == 1 From 1d58bb891308d68c8a0a5b607058b15d54a12976 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Mon, 7 Sep 2026 13:53:29 +0800 Subject: [PATCH 03/24] =?UTF-8?q?=E6=8C=89=E9=9C=80=E6=B1=82=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=BE=9B=E5=BA=94=E5=95=86=E9=A2=91=E6=AC=A1=E5=88=86?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一供应商异常统计为近3年数据口径 - 以近3年异常记录数作为合作频次代理值并明确提示 - 实现 A/B 缺陷占比与 S/A/B/C 层级判定 - 按供应商层级输出正常合作、加强巡检、驻厂及备选供应商等管理建议 - 完善风险卡片和供应商卡片的频次、层级、建议展示 - 补充供应商画像接口、测试和历史会话卡片兼容处理 --- app/api/router.py | 2 + app/api/v1/suppliers.py | 32 +++ app/schemas/dashboard.py | 4 +- app/schemas/risk_prediction.py | 5 +- app/schemas/suppliers.py | 64 +++++ app/services/conversations.py | 235 +++++++++++++----- app/services/dashboard.py | 10 +- app/services/suppliers.py | 208 ++++++++++++++++ ...0907_0015_add_supplier_read_permissions.py | 39 +++ tests/test_conversations.py | 136 ++++++++++ tests/test_risk_prediction.py | 6 + tests/test_suppliers.py | 66 +++++ 12 files changed, 739 insertions(+), 68 deletions(-) create mode 100644 app/api/v1/suppliers.py create mode 100644 app/schemas/suppliers.py create mode 100644 app/services/suppliers.py create mode 100644 migrations/versions/20260907_0015_add_supplier_read_permissions.py create mode 100644 tests/test_suppliers.py diff --git a/app/api/router.py b/app/api/router.py index 944db51..a0268ab 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -9,6 +9,7 @@ from app.api.v1.health import router as health_router from app.api.v1.risk_items import router as risk_items_router from app.api.v1.risk_predictions import router as risk_predictions_router from app.api.v1.standards import router as standards_router +from app.api.v1.suppliers import router as suppliers_router api_router = APIRouter(prefix="/api/v1") api_router.include_router(health_router) @@ -18,3 +19,4 @@ api_router.include_router(dashboard_router) api_router.include_router(risk_items_router) api_router.include_router(risk_predictions_router) api_router.include_router(standards_router) +api_router.include_router(suppliers_router) diff --git a/app/api/v1/suppliers.py b/app/api/v1/suppliers.py new file mode 100644 index 0000000..a28c23f --- /dev/null +++ b/app/api/v1/suppliers.py @@ -0,0 +1,32 @@ +"""Supplier profile endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.suppliers import SupplierListData, SupplierProfileData +from app.services.suppliers import SupplierProfileService + +router = APIRouter(prefix="/suppliers", tags=["suppliers"]) + + +@router.get("", response_model=ApiResponse[SupplierListData]) +async def list_suppliers( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))], + keyword: str | None = Query(default=None, max_length=100), + limit: int = Query(default=100, ge=1, le=100), +) -> ApiResponse[SupplierListData]: + return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit)) + + +@router.get("/{supplier_name}", response_model=ApiResponse[SupplierProfileData]) +async def get_supplier_profile( + supplier_name: str, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))], +) -> ApiResponse[SupplierProfileData]: + return ApiResponse(data=await SupplierProfileService(session).detail(supplier_name)) diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py index c07e6ed..188e9b4 100644 --- a/app/schemas/dashboard.py +++ b/app/schemas/dashboard.py @@ -32,9 +32,9 @@ class DashboardMatrixRow(ApiSchema): name: str party_type: str categories: dict[str, int] - cooperation_total: int + cooperation_total: int | None anomaly_count: int - anomaly_rate: float + anomaly_rate: float | None tier: str diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 6a5a364..f3af422 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -43,9 +43,12 @@ class SupplierFrequency(ApiSchema): supplier_name: str | None = None cooperation_count: int | None = None anomaly_count: int | None = None - alpha: float = 1.0 + alpha: float | None = None tier: str | None = None suggestion: str | None = None + frequency_basis: str = "近3年异常记录数" + note: str | None = None + data_available: bool = False class StandardJudgment(ApiSchema): diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py new file mode 100644 index 0000000..5417498 --- /dev/null +++ b/app/schemas/suppliers.py @@ -0,0 +1,64 @@ +"""Supplier profile API contracts.""" + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class SupplierOption(ApiSchema): + name: str + anomaly_count: int + tier: str + + +class SupplierListData(ApiSchema): + items: list[SupplierOption] = Field(default_factory=list) + default_supplier: str | None = None + + +class SupplierMetric(ApiSchema): + cooperation_total: int | None = None + anomaly_total: int + anomaly_rate: float | None = None + frequency_alpha: float + risk_score: float | None = None + main_category_count: int + cooperation_count_basis: str = "暂无采购订单台账;合作次数不可由异常记录推算" + anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率" + + +class SupplierCategoryBreakdown(ApiSchema): + category: str + anomaly_count: int + anomaly_rate: float | None = None + top_material: str | None = None + top_problem: str | None = None + anomaly_rate_basis: str = "暂无供应商品类合作总量,无法计算真实异常率" + + +class SupplierRecentRecord(ApiSchema): + case_id: int + month: str | None = None + product: str | None = None + anomaly: str | None = None + defect_level: str | None = None + + +class FactoryRecommendation(ApiSchema): + category: str + preferred_factory: str | None = None + alternative_factory: str | None = None + reason: str + recommendation_basis: str = "暂无加工厂生产总量,无法计算真实异常率" + + +class SupplierProfileData(ApiSchema): + supplier_name: str + tier: str + tier_condition: str + metric: SupplierMetric + category_breakdown: list[SupplierCategoryBreakdown] = Field(default_factory=list) + monthly_trend: list[dict[str, int | str]] = Field(default_factory=list) + recent_records: list[SupplierRecentRecord] = Field(default_factory=list) + factory_recommendations: list[FactoryRecommendation] = Field(default_factory=list) + recommendation_note: str | None = None diff --git a/app/services/conversations.py b/app/services/conversations.py index 1ddd7e1..9d57f15 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -4,7 +4,7 @@ import json import logging import re from collections.abc import AsyncGenerator, Sequence -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta from typing import Any, Literal from sqlalchemy import func, or_, select @@ -23,6 +23,7 @@ from app.schemas.risk_prediction import ( RiskAssessmentResult, RiskDimensionScore, RiskFactorResult, + SimilarCase, StandardJudgment, SupplierFrequency, ) @@ -34,6 +35,7 @@ logger = logging.getLogger("dieya_risk_prediction.conversations") class ConversationService: + _supplier_history_days = 365 * 3 _engine = BaselineRiskEngine() _risk_intents = { "assessment", @@ -232,37 +234,53 @@ class ConversationService: yield {"type": "error", "message": "LLM 未返回有效内容"} return - show_risk = self._is_risk_request(text, semantics) - response_type = self._response_type(text, semantics) - response_type = await self._use_solution_card_without_history(response_type, text, semantics) - risk_result = ( - await self._enrich_risk_result(text, semantics, self._predict(text)) - if show_risk and response_type == "risk_assessment" - else None - ) - response_card = ( - await self._build_response_card(response_type, text, semantics) - if response_type != "general" - else None - ) + # Persist the complete assistant text before optional card enrichment. + # A slow or failed RAG/score lookup must not lose the conversation turn. assistant_message = ConversationMessage( conversation_id=conversation.id, role="assistant", message_type="text", content=reply, - structured_data={ - "semantic": semantics, - **( - {"riskResult": risk_result.model_dump(mode="json", by_alias=False)} - if risk_result - else {} - ), - **({"responseCard": response_card} if response_card else {}), - }, + structured_data={"semantic": semantics}, ) self.session.add(assistant_message) await self.session.flush() await self.session.commit() + + show_risk = self._is_risk_request(text, semantics) + response_type = self._response_type(text, semantics) + risk_result = None + response_card = None + try: + response_type = await self._use_solution_card_without_history(response_type, text, semantics) + risk_result = ( + await self._enrich_risk_result(text, semantics, self._predict(text)) + if show_risk and response_type == "risk_assessment" + else None + ) + response_card = ( + await self._build_response_card(response_type, text, semantics) + if response_type != "general" + else None + ) + except Exception as exc: # noqa: BLE001 - text reply must remain persisted + logger.exception( + "Conversation enrichment failed after assistant text was persisted error=%s", + exc.__class__.__name__, + ) + await self.session.rollback() + + assistant_message.structured_data = { + "semantic": semantics, + **( + {"riskResult": risk_result.model_dump(mode="json", by_alias=False)} + if risk_result + else {} + ), + **({"responseCard": response_card} if response_card else {}), + } + await self.session.flush() + await self.session.commit() persisted = await self.repository.get_for_user(conversation.id, user.id) if persisted is None: # pragma: no cover - the row already existed yield {"type": "error", "message": "对话读取失败"} @@ -537,25 +555,28 @@ class ConversationService: name = match.group(1).strip() if match else "未指定" is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text column = AnomalyCase.factory if is_factory else AnomalyCase.supplier - anomaly_count = await self._count_anomalies(column, name) - problems = await self._top_anomaly_types(column, name) + anomaly_count, a_count, b_count = await self._supplier_history_stats(column, name) + problems = await self._top_anomaly_types( + column, + name, + since=self._supplier_history_start(), + ) party_type = "加工厂" if is_factory else "供应商" + tier = self._supplier_tier(anomaly_count, a_count=a_count, b_count=b_count) return { "type": "supplier", "title": "供应商深度画像", "name": name, "partyType": party_type, "anomalyCount": anomaly_count, - "cooperationCount": None, - "tier": self._supplier_tier(anomaly_count), + "cooperationCount": anomaly_count, + "tier": tier, "frequencyCoefficient": round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) if anomaly_count else 1.0, "topProblems": problems, - "suggestions": ( - ["加强巡检并准备备选供应商", "重点复核高频异常对应的来料和过程检验项目"] - if anomaly_count - else ["暂无历史合作数据,建议首单全检并安排驻厂巡检"] - ), - "note": "合作次数需要采购合作台账接入后统计,当前仅展示异常案例库中的真实记录。", + "suggestions": self._supplier_management_suggestions(tier), + "frequencyBasis": "近3年异常记录数", + "note": "合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。", + "dataAvailable": True, } async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: @@ -801,12 +822,27 @@ class ConversationService: for row in rows ] - async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001 + async def _top_anomaly_types( + self, + column, # noqa: ANN001 + value: str | None, + *, + since: date | None = None, + ) -> Sequence[dict[str, Any]]: if not value: return [] + filters = [column.ilike(f"%{value}%"), AnomalyCase.anomaly_type.is_not(None)] + if since: + filters.extend( + [ + AnomalyCase.date.is_not(None), + AnomalyCase.date >= since, + AnomalyCase.date <= datetime.now(UTC).date(), + ] + ) rows = await self.session.execute( select(AnomalyCase.anomaly_type, func.count()) - .where(column.ilike(f"%{value}%"), AnomalyCase.anomaly_type.is_not(None)) + .where(*filters) .group_by(AnomalyCase.anomaly_type) .order_by(func.count().desc()) .limit(5) @@ -917,7 +953,10 @@ class ConversationService: defect_level=self._first_match(text, ("A级", "B级", "C级")), ) category_count = await self._count_category_anomalies(category) - supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier) + supplier_count, supplier_a_count, supplier_b_count = await self._supplier_history_stats( + AnomalyCase.supplier, + supplier, + ) factory_count = await self._count_anomalies(AnomalyCase.factory, factory) anomaly_count = supplier_count if supplier else factory_count standard_rows = await self._standards_for(category, text) @@ -936,7 +975,15 @@ class ConversationService: alpha_count = supplier_count if supplier else 0 alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0 - tier = self._supplier_tier(alpha_count) if supplier else None + tier = ( + self._supplier_tier( + alpha_count, + a_count=supplier_a_count, + b_count=supplier_b_count, + ) + if supplier + else None + ) level_text = self._risk_level_text(database_score.level) alert_text = self._alert_text( category, @@ -998,21 +1045,30 @@ class ConversationService: else "建议结合当前品类高频异常制定验证计划" ), ), - "supplier_freq": SupplierFrequency( - supplier_name=supplier_or_factory, - cooperation_count=None, - anomaly_count=anomaly_count or None, - alpha=alpha, - tier=tier, - suggestion=( - "无历史数据,建议首单全检并安排驻厂巡检" - if not anomaly_count - else "建议加强巡检并准备备选供应商" - ), + "supplier_freq": ( + SupplierFrequency( + supplier_name=supplier, + cooperation_count=supplier_count, + anomaly_count=supplier_count, + alpha=alpha, + tier=tier, + suggestion=";".join(self._supplier_management_suggestions(tier)), + frequency_basis="近3年异常记录数", + note="合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。", + data_available=True, + ) + if supplier + else SupplierFrequency( + note="未指定供应商,暂无供应商频次分析。", + frequency_basis="未提供供应商", + ) ), "standard_judgment": standard_rows, "dimension_scores": dimensions, - "similar_cases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases], + "similar_cases": [ + SimilarCase.model_validate(item.case.model_dump(mode="json", by_alias=False)) + for item in similar_cases + ], "ai_suggestions": suggestions, } ) @@ -1021,10 +1077,47 @@ class ConversationService: if not value or value == "未归类": return 0 count = await self.session.scalar( - select(func.count()).select_from(AnomalyCase).where(column.ilike(f"%{value}%")) + select(func.count()) + .select_from(AnomalyCase) + .where( + column.ilike(f"%{value}%"), + AnomalyCase.date.is_not(None), + AnomalyCase.date >= self._supplier_history_start(), + AnomalyCase.date <= datetime.now(UTC).date(), + ) ) return int(count or 0) + @classmethod + def _supplier_history_start(cls) -> date: + return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days) + + async def _supplier_history_stats( + self, + column, # noqa: ANN001 + value: str | None, + ) -> tuple[int, int, int]: + """Return the PRD's three-year N, A-count, and B-count inputs.""" + + if not value or value == "未归类": + return 0, 0, 0 + rows = ( + await self.session.execute( + select(AnomalyCase.defect_level, func.count()) + .where( + column.ilike(f"%{value}%"), + AnomalyCase.date.is_not(None), + AnomalyCase.date >= self._supplier_history_start(), + AnomalyCase.date <= datetime.now(UTC).date(), + ) + .group_by(AnomalyCase.defect_level) + ) + ).all() + total = sum(int(count or 0) for _, count in rows) + a_count = sum(int(count or 0) for level, count in rows if self._defect_code(level) == "A") + b_count = sum(int(count or 0) for level, count in rows if self._defect_code(level) == "B") + return total, a_count, b_count + async def _generate_ai_suggestions( self, *, @@ -1132,7 +1225,10 @@ class ConversationService: or_( AnomalyCase.product_category.ilike(f"%{category}%"), AnomalyCase.product_name.ilike(f"%{category}%"), - ) + ), + AnomalyCase.date.is_not(None), + AnomalyCase.date >= self._supplier_history_start(), + AnomalyCase.date <= datetime.now(UTC).date(), ) ) return int(count or 0) @@ -1213,17 +1309,40 @@ class ConversationService: }[level] @staticmethod - def _supplier_tier(anomaly_count: int) -> str | None: - if not anomaly_count: + def _defect_code(value: str | None) -> str | None: + match = re.search(r"[ABCD]", (value or "").upper()) + return match.group(0) if match else None + + @staticmethod + def _supplier_tier( + anomaly_count: int, + *, + a_count: int = 0, + b_count: int = 0, + ) -> str: + if anomaly_count == 0: return "新供应商" - if anomaly_count < 20: - return "S级 · 战略" - if anomaly_count <= 60: - return "A级 · 优质" - if anomaly_count <= 120: + ab_ratio = (a_count + b_count) / anomaly_count + if anomaly_count > 120 or a_count > 0 or ab_ratio > 0.5: + return "C级 · 高风险" + if anomaly_count > 60 or ab_ratio >= 0.3: return "B级 · 关注" + if anomaly_count >= 20: + return "A级 · 优质" + if a_count == 0: + return "S级 · 战略" return "C级 · 高风险" + @staticmethod + def _supplier_management_suggestions(tier: str | None) -> Sequence[str]: + return { + "S级 · 战略": ["正常合作", "季度回顾"], + "A级 · 优质": ["常规管理", "年度评审"], + "B级 · 关注": ["加强巡检", "新品打样加严"], + "C级 · 高风险": ["安排驻厂巡检", "储备备选供应商", "限制新品使用"], + "新供应商": ["无历史数据", "首单全检", "安排驻厂巡检"], + }.get(tier, []) + @staticmethod def _alert_text( category: str, diff --git a/app/services/dashboard.py b/app/services/dashboard.py index 374e666..f5006c3 100644 --- a/app/services/dashboard.py +++ b/app/services/dashboard.py @@ -286,8 +286,8 @@ class DashboardService: result = [] for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]): anomaly_count = item["count"] - # Keep the existing cooperation-total calculation unchanged. - cooperation_total = item["count"] + # No order or production denominator exists in anomaly_cases. + cooperation_total = None values = {category: 0 for category in categories} for row in item["items"]: category = cls._category(row["product_name"], row["material_type"]) @@ -300,11 +300,7 @@ class DashboardService: categories=values, cooperation_total=cooperation_total, anomaly_count=anomaly_count, - anomaly_rate=( - round(anomaly_count / cooperation_total * 100, 1) - if cooperation_total - else 0 - ), + anomaly_rate=None, tier=item["tier"], ) ) diff --git a/app/services/suppliers.py b/app/services/suppliers.py new file mode 100644 index 0000000..af18c54 --- /dev/null +++ b/app/services/suppliers.py @@ -0,0 +1,208 @@ +"""Live supplier profile aggregation from the approved anomaly case data.""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from datetime import UTC, date, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import AppException +from app.models.entities import AnomalyCase +from app.schemas.suppliers import ( + SupplierCategoryBreakdown, + SupplierListData, + SupplierMetric, + SupplierOption, + SupplierProfileData, + SupplierRecentRecord, +) +from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category + + +class SupplierProfileService: + history_days = 365 * 3 + recent_months = 6 + max_categories = 5 + max_recent_records = 10 + + def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None: + self.session = session + self.as_of = as_of or datetime.now(UTC).date() + + async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData: + rows = await self._load_rows() + grouped = self._group_suppliers(rows, keyword) + items = [ + SupplierOption( + name=name, + anomaly_count=len(items), + tier=self.tier_for( + len(items), + sum(self.defect_code(row.get("defect_level")) == "A" for row in items), + sum(self.defect_code(row.get("defect_level")) == "B" for row in items), + ), + ) + for name, items in sorted(grouped.items(), key=lambda item: (-len(item[1]), item[0]))[:limit] + ] + return SupplierListData( + items=items, + default_supplier=items[0].name if items else None, + ) + + async def detail(self, supplier_name: str) -> SupplierProfileData: + rows = [row for row in await self._load_rows() if self.matches(row.get("supplier"), supplier_name)] + if not rows: + raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404) + + anomaly_total = len(rows) + a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows) + b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows) + tier = self.tier_for(anomaly_total, a_count, b_count) + categories = self._categories(rows) + score = await DatabaseRiskScoringService(self.session, as_of=self.as_of).score(supplier=supplier_name) + return SupplierProfileData( + supplier_name=supplier_name, + tier=tier, + tier_condition=self.tier_condition(tier), + metric=SupplierMetric( + cooperation_total=None, + anomaly_total=anomaly_total, + anomaly_rate=None, + frequency_alpha=self.alpha(anomaly_total), + risk_score=score.score, + main_category_count=len(categories), + ), + category_breakdown=categories[: self.max_categories], + monthly_trend=self._monthly_trend(rows), + recent_records=self._recent_records(rows), + factory_recommendations=[], + recommendation_note=( + "合作次数、供应商真实异常率和代工厂真实异常率需要采购订单或生产批次总量;" + "当前已接入近3年异常库聚合,但不会用异常记录数伪造上述指标。" + ), + ) + + async def _load_rows(self) -> list[dict]: + start = self.as_of - timedelta(days=self.history_days) + statement = select( + AnomalyCase.id, + AnomalyCase.date, + AnomalyCase.product_name, + AnomalyCase.product_category, + AnomalyCase.material_type, + AnomalyCase.supplier, + AnomalyCase.factory, + AnomalyCase.anomaly_type, + AnomalyCase.defect_level, + ).where( + AnomalyCase.date.is_not(None), + AnomalyCase.date >= start, + AnomalyCase.date <= self.as_of, + ) + return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] + + @staticmethod + def matches(value: str | None, target: str) -> bool: + return bool(value and target.strip().lower() in value.lower()) + + @classmethod + def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]: + grouped: dict[str, list[dict]] = defaultdict(list) + for row in rows: + name = (row.get("supplier") or "").strip() + if name and (not keyword or cls.matches(name, keyword)): + grouped[name].append(row) + return grouped + + @staticmethod + def defect_code(value: str | None) -> str | None: + value = (value or "").strip().upper() + return value if value in {"A", "B", "C"} else None + + @staticmethod + def alpha(anomaly_count: int) -> float: + return round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) + + @classmethod + def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str: + if anomaly_count == 0: + return "新供应商" + ab_ratio = (a_count + b_count) / anomaly_count + if anomaly_count > 120 or a_count > 0 or ab_ratio > 0.5: + return "C级" + if anomaly_count > 60 or ab_ratio >= 0.3: + return "B级" + if anomaly_count >= 20: + return "A级" + return "S级" + + @staticmethod + def tier_condition(tier: str) -> str: + return { + "S级": "异常记录 < 20 次,且无 A 级缺陷", + "A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%", + "B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%", + "C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%", + "新供应商": "近3年异常记录为 0", + }[tier] + + @classmethod + def _categories(cls, rows: list[dict]) -> list[SupplierCategoryBreakdown]: + grouped: dict[str, list[dict]] = defaultdict(list) + for row in rows: + category = normalize_category(row.get("product_category")) or normalize_category(row.get("product_name")) + grouped[category or "待补标品类"].append(row) + result = [] + for category, items in grouped.items(): + materials = Counter(row.get("material_type") for row in items if row.get("material_type")) + problems = Counter(row.get("anomaly_type") for row in items if row.get("anomaly_type")) + result.append( + SupplierCategoryBreakdown( + category=category, + anomaly_count=len(items), + anomaly_rate=None, + top_material=materials.most_common(1)[0][0] if materials else None, + top_problem=problems.most_common(1)[0][0] if problems else None, + ) + ) + return sorted(result, key=lambda item: (-item.anomaly_count, item.category)) + + def _monthly_trend(self, rows: list[dict]) -> list[dict[str, int | str]]: + start = self._month_offset(self.as_of.replace(day=1), -5) + counts = Counter( + row["date"].strftime("%Y-%m") + for row in rows + if row.get("date") and row["date"] >= start + ) + output = [] + cursor = start + for _ in range(self.recent_months): + key = cursor.strftime("%Y-%m") + output.append({"month": key, "count": counts.get(key, 0)}) + cursor = date(cursor.year + (cursor.month == 12), 1 if cursor.month == 12 else cursor.month + 1, 1) + return output + + @staticmethod + def _month_offset(value: date, months: int) -> date: + index = value.year * 12 + value.month - 1 + months + return date(index // 12, index % 12 + 1, 1) + + def _recent_records(self, rows: list[dict]) -> list[SupplierRecentRecord]: + start = self._month_offset(self.as_of.replace(day=1), -5) + recent = sorted( + (row for row in rows if row.get("date") and row["date"] >= start), + key=lambda row: row["date"], + reverse=True, + ) + return [ + SupplierRecentRecord( + case_id=int(row["id"]), + month=row["date"].strftime("%Y-%m") if row.get("date") else None, + product=row.get("product_name"), + anomaly=row.get("anomaly_type"), + defect_level=self.defect_code(row.get("defect_level")), + ) + for row in recent[: self.max_recent_records] + ] diff --git a/migrations/versions/20260907_0015_add_supplier_read_permissions.py b/migrations/versions/20260907_0015_add_supplier_read_permissions.py new file mode 100644 index 0000000..fbba520 --- /dev/null +++ b/migrations/versions/20260907_0015_add_supplier_read_permissions.py @@ -0,0 +1,39 @@ +"""Align full supplier-profile access with the PRD's L2 role matrix.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260907_0015" +down_revision: str | None = "20260905_0014" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PERMISSIONS = ("procurement", "reviewer", "manager") + + +def upgrade() -> None: + connection = op.get_bind() + statement = sa.text( + """ + INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate) + VALUES (:role, 'supplier.read', 'L2', true, false) + ON CONFLICT (role, func_key) DO UPDATE SET + data_level = EXCLUDED.data_level, + can_view = EXCLUDED.can_view, + can_operate = EXCLUDED.can_operate, + updated_at = now() + """ + ) + for role in PERMISSIONS: + connection.execute(statement, {"role": role}) + + +def downgrade() -> None: + connection = op.get_bind() + for role in PERMISSIONS: + connection.execute( + sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = 'supplier.read'"), + {"role": role}, + ) diff --git a/tests/test_conversations.py b/tests/test_conversations.py index aeec69f..cd2b809 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime import pytest from app.models.entities import Conversation, ConversationMessage +from app.schemas.risk_prediction import SupplierFrequency from app.services.conversations import ConversationService @@ -31,6 +32,32 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None: assert result.risk_result.level.value == "low" +def test_conversation_message_data_reads_persisted_semantic_and_response_card() -> None: + message = ConversationMessage( + id=11, + conversation_id=3, + role="assistant", + content="已完成分析。", + structured_data={ + "semantic": {"intent": "solution", "category": "洗发水"}, + "responseCard": { + "type": "solution", + "title": "AI 案例推理生成", + "similarCases": [{"case_id": 101, "similarity": 0.82}], + "suggestions": ["完成包材相容性测试"], + }, + }, + created_at=datetime.now(UTC), + ) + + result = ConversationService._message(message) + + assert result.semantic == {"intent": "solution", "category": "洗发水"} + assert result.response_card is not None + assert result.response_card["similarCases"][0]["case_id"] == 101 + assert result.response_card["suggestions"] == ["完成包材相容性测试"] + + def test_conversation_data_includes_all_messages() -> None: conversation = Conversation( id=3, @@ -144,6 +171,115 @@ def test_unknown_standalone_name_is_treated_as_new_supplier() -> None: assert ConversationService._response_type("新供应商A", {"intent": "supplier"}) == "general" +@pytest.mark.parametrize( + ("count", "a_count", "b_count", "expected"), + [ + (0, 0, 0, "新供应商"), + (12, 0, 1, "S级 · 战略"), + (40, 0, 4, "A级 · 优质"), + (70, 0, 10, "B级 · 关注"), + (40, 0, 16, "B级 · 关注"), + (10, 1, 0, "C级 · 高风险"), + (121, 0, 0, "C级 · 高风险"), + ], +) +def test_supplier_tier_uses_prd_frequency_and_defect_rules( + count: int, + a_count: int, + b_count: int, + expected: str, +) -> None: + assert ConversationService._supplier_tier( + count, + a_count=a_count, + b_count=b_count, + ) == expected + + +def test_supplier_management_suggestions_follow_prd_actions() -> None: + assert ConversationService._supplier_management_suggestions("B级 · 关注") == [ + "加强巡检", + "新品打样加严", + ] + assert ConversationService._supplier_management_suggestions("新供应商") == [ + "无历史数据", + "首单全检", + "安排驻厂巡检", + ] + + +@pytest.mark.asyncio +async def test_supplier_card_uses_prd_proxy_frequency_and_tier_advice( + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = object.__new__(ConversationService) + + async def history_stats(*_args, **_kwargs): + return 70, 0, 10 + + async def top_anomaly_types(*_args, **_kwargs): + return [{"name": "泵头异常", "count": 12}] + + monkeypatch.setattr(service, "_supplier_history_stats", history_stats) + monkeypatch.setattr(service, "_top_anomaly_types", top_anomaly_types) + + result = await service._supplier_card( + "祥印供应商风险怎么样", + {"supplier": "祥印"}, + ) + + assert result["anomalyCount"] == 70 + assert result["cooperationCount"] == 70 + assert result["frequencyCoefficient"] == 1.21 + assert result["tier"] == "B级 · 关注" + assert result["suggestions"] == ["加强巡检", "新品打样加严"] + assert result["frequencyBasis"] == "近3年异常记录数" + assert result["dataAvailable"] is True + + +def test_new_supplier_frequency_serializes_zero_proxy_count() -> None: + result = SupplierFrequency( + supplier_name="新供应商", + cooperation_count=0, + anomaly_count=0, + tier="新供应商", + suggestion="无历史数据;首单全检;安排驻厂巡检", + note="合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。", + ).model_dump(by_alias=True) + + assert result["cooperationCount"] == 0 + assert result["anomalyCount"] == 0 + assert result["frequencyBasis"] == "近3年异常记录数" + + +def test_unspecified_supplier_returns_displayable_no_data_frequency_card() -> None: + result = SupplierFrequency( + note="未指定供应商,暂无供应商频次分析。", + frequency_basis="未提供供应商", + ).model_dump(by_alias=True) + + assert result["dataAvailable"] is False + assert result["alpha"] is None + assert result["note"] == "未指定供应商,暂无供应商频次分析。" + + +def test_similar_cases_are_typed_before_risk_result_serialization() -> None: + from app.schemas.risk_prediction import RiskAssessmentResult, SimilarCase + + result = RiskAssessmentResult( + subject_id="test", + score=52, + level="medium", + model_version="test", + factors=[], + similar_cases=[SimilarCase(case_id=1, similarity=0.75)], + ) + + payload = result.model_dump(mode="json") + + assert payload["similar_cases"][0]["case_id"] == 1 + + @pytest.mark.asyncio async def test_any_product_without_history_uses_solution_card(monkeypatch: pytest.MonkeyPatch) -> None: service = object.__new__(ConversationService) diff --git a/tests/test_risk_prediction.py b/tests/test_risk_prediction.py index 4ca66cb..5d6d1ad 100644 --- a/tests/test_risk_prediction.py +++ b/tests/test_risk_prediction.py @@ -18,6 +18,12 @@ def test_preview_requires_authentication() -> None: assert response.status_code == 401 +def test_supplier_profile_requires_authentication() -> None: + response = TestClient(app).get("/api/v1/suppliers") + + assert response.status_code == 401 + + def test_baseline_engine_calculates_weighted_score() -> None: score = BaselineRiskEngine().predict( [ diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py new file mode 100644 index 0000000..7ec5c05 --- /dev/null +++ b/tests/test_suppliers.py @@ -0,0 +1,66 @@ +from datetime import date + +from app.schemas.suppliers import SupplierListData, SupplierOption +from app.services.suppliers import SupplierProfileService + + +def test_supplier_tier_rules_follow_prd() -> None: + assert SupplierProfileService.tier_for(0, 0, 0) == "新供应商" + assert SupplierProfileService.tier_for(12, 0, 0) == "S级" + assert SupplierProfileService.tier_for(40, 0, 4) == "A级" + assert SupplierProfileService.tier_for(70, 0, 10) == "B级" + assert SupplierProfileService.tier_for(40, 0, 16) == "B级" + assert SupplierProfileService.tier_for(10, 1, 0) == "C级" + assert SupplierProfileService.tier_for(121, 0, 0) == "C级" + + +def test_supplier_alpha_is_capped_and_rounded() -> None: + assert SupplierProfileService.alpha(0) == 1.0 + assert SupplierProfileService.alpha(42) == 1.13 + assert SupplierProfileService.alpha(148) == 1.44 + assert SupplierProfileService.alpha(200) == 1.5 + + +def test_supplier_list_exposes_default_highest_anomaly_supplier() -> None: + result = SupplierListData( + items=[ + SupplierOption(name="甲供应商", anomaly_count=2, tier="S级"), + SupplierOption(name="乙供应商", anomaly_count=1, tier="S级"), + ], + default_supplier="甲供应商", + ) + + assert result.default_supplier == result.items[0].name + + +def test_supplier_category_breakdown_is_top_five_with_material_and_problem() -> None: + rows = [ + {"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"}, + {"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"}, + {"product_category": "面霜", "product_name": "面霜", "material_type": "瓶子", "anomaly_type": "刮花"}, + ] + + result = SupplierProfileService._categories(rows) + + assert result[0].category == "洗发水/洗护类" + assert result[0].top_material == "泵头" + assert result[0].top_problem == "漏液" + + +def test_supplier_recent_records_are_limited_and_sorted() -> None: + service = SupplierProfileService(object(), as_of=date(2026, 9, 7)) + rows = [ + { + "id": index, + "date": date(2026, 4, index), + "product_name": "产品", + "anomaly_type": str(index), + "defect_level": "B", + } + for index in range(1, 13) + ] + + result = service._recent_records(rows) + + assert len(result) == 10 + assert result[0].case_id == 12 From ab77dd97973a41cdbbcea1396988cec0633f2d1f Mon Sep 17 00:00:00 2001 From: kx_guo Date: Mon, 7 Sep 2026 17:49:19 +0800 Subject: [PATCH 04/24] =?UTF-8?q?=E5=AE=9E=E7=8E=B0OCR=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=B9=B6=E5=AE=8C=E5=96=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E6=9D=83=E9=87=8D=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 + .gitignore | 1 + README.md | 4 + app/api/router.py | 4 + app/api/v1/model_config.py | 30 ++ app/api/v1/ocr.py | 50 ++ app/core/config.py | 20 + app/domain/model_config.py | 24 + app/models/__init__.py | 2 + app/models/entities.py | 29 +- app/schemas/model_config.py | 32 ++ app/schemas/ocr.py | 73 +++ app/schemas/risk_item.py | 15 +- app/services/baidu_ocr.py | 179 +++++++ app/services/database_risk_scoring.py | 57 +- app/services/dingtalk_auth.py | 16 +- app/services/llm_client.py | 39 +- app/services/model_config.py | 57 ++ app/services/ocr.py | 507 ++++++++++++++++++ app/services/risk_items.py | 11 +- app/services/suppliers.py | 13 +- .../versions/20260907_0016_add_ocr_fields.py | 37 ++ .../20260907_0017_four_role_model_config.py | 103 ++++ pyproject.toml | 1 + requirements.txt | 1 + tests/test_auth.py | 6 +- tests/test_baidu_ocr.py | 132 +++++ tests/test_database_risk_scoring.py | 53 ++ tests/test_models.py | 2 + 29 files changed, 1455 insertions(+), 57 deletions(-) create mode 100644 app/api/v1/model_config.py create mode 100644 app/api/v1/ocr.py create mode 100644 app/domain/model_config.py create mode 100644 app/schemas/model_config.py create mode 100644 app/schemas/ocr.py create mode 100644 app/services/baidu_ocr.py create mode 100644 app/services/model_config.py create mode 100644 app/services/ocr.py create mode 100644 migrations/versions/20260907_0016_add_ocr_fields.py create mode 100644 migrations/versions/20260907_0017_four_role_model_config.py create mode 100644 tests/test_baidu_ocr.py diff --git a/.env.example b/.env.example index 3e19b2c..ba07f09 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,20 @@ RISK_DINGTALK_APP_SECRET= RISK_DINGTALK_CORP_ID= RISK_DINGTALK_AGENT_ID= +# ==================== 百度 OCR ==================== +# 图片调用 WebImage,PDF 调用 PaddleOCR-VL 文档解析;上传原件仅保存在本机目录。 +BAIDU_OCR_API_KEY= +BAIDU_OCR_SECRET_KEY= +RISK_BAIDU_OCR_IMAGE_URL=https://aip.baidubce.com/rest/2.0/ocr/v1/webimage +RISK_BAIDU_OCR_DOCUMENT_URL=https://aip.baidubce.com/rest/2.0/brain/online/v2/paddle-vl-parser/task +RISK_BAIDU_OCR_TIMEOUT_SECONDS=75 +RISK_BAIDU_OCR_POLL_INTERVAL_SECONDS=2 +RISK_OCR_UPLOAD_DIR=.runtime-uploads/ocr +RISK_OCR_MAX_FILE_MB=10 +RISK_OCR_MAX_BATCH_FILES=5 +RISK_OCR_REVIEW_CONFIDENCE=0.8 +RISK_OCR_JUDGMENT_CONFIDENCE=0.85 + # ==================== 主 LLM 模型配置 ==================== # 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。 RISK_LLM_API_KEY= diff --git a/.gitignore b/.gitignore index 2398d05..0288549 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/ dist/ build/ data/ +.runtime-uploads/ diff --git a/README.md b/README.md index b155f27..d0ccabb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ python scripts/create_user.py --emp-no admin001 --name 管理员 --role admin -- 审核接口:`POST /api/v1/risk-items`、`GET /api/v1/risk-items/pending`、`POST /api/v1/risk-items/{id}/review`。 +OCR 接口:`POST /api/v1/ocr/recognize` 接收最多 5 个 JPG、PNG 或 PDF 文件,图片调用百度 WebImage,PDF 调用 PaddleOCR-VL 文档解析;返回可编辑字段、检测数值、置信度及标准库判定。`POST /api/v1/ocr/judge` 用于人工修正检测值后重新判定,`GET /api/v1/ocr/files/{file_id}` 通过登录鉴权读取原始附件。启用前配置 `BAIDU_OCR_API_KEY` 和 `BAIDU_OCR_SECRET_KEY`,并执行数据库迁移。 + +角色采用管理员 `admin`、产品经理 `product_manager`、审核员 `reviewer`、普通用户 `普通用户` 四类。模型权重由管理员通过 `GET/PATCH /api/v1/model-config` 维护,七维权重合计必须为 100%,修改仅对新评估生效。 + 健康检查:`GET http://127.0.0.1:18474/api/v1/health` 数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 diff --git a/app/api/router.py b/app/api/router.py index a0268ab..4cc5194 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -6,6 +6,8 @@ from app.api.v1.auth import router as auth_router from app.api.v1.conversations import router as conversations_router from app.api.v1.dashboard import router as dashboard_router from app.api.v1.health import router as health_router +from app.api.v1.model_config import router as model_config_router +from app.api.v1.ocr import router as ocr_router from app.api.v1.risk_items import router as risk_items_router from app.api.v1.risk_predictions import router as risk_predictions_router from app.api.v1.standards import router as standards_router @@ -13,6 +15,8 @@ from app.api.v1.suppliers import router as suppliers_router api_router = APIRouter(prefix="/api/v1") api_router.include_router(health_router) +api_router.include_router(model_config_router) +api_router.include_router(ocr_router) api_router.include_router(auth_router) api_router.include_router(conversations_router) api_router.include_router(dashboard_router) diff --git a/app/api/v1/model_config.py b/app/api/v1/model_config.py new file mode 100644 index 0000000..2b37924 --- /dev/null +++ b/app/api/v1/model_config.py @@ -0,0 +1,30 @@ +"""Administrator-only risk-model configuration endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.model_config import ModelWeightConfigData, ModelWeightConfigUpdate +from app.services.model_config import ModelConfigService + +router = APIRouter(prefix="/model-config", tags=["model-config"]) + + +@router.get("", response_model=ApiResponse[ModelWeightConfigData]) +async def get_model_config( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("model_weight.manage"))], +) -> ApiResponse[ModelWeightConfigData]: + return ApiResponse(data=await ModelConfigService(session).get()) + + +@router.patch("", response_model=ApiResponse[ModelWeightConfigData]) +async def update_model_config( + payload: ModelWeightConfigUpdate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("model_weight.manage", operate=True))], +) -> ApiResponse[ModelWeightConfigData]: + return ApiResponse(data=await ModelConfigService(session).update(payload, current_user)) diff --git a/app/api/v1/ocr.py b/app/api/v1/ocr.py new file mode 100644 index 0000000..07c85e7 --- /dev/null +++ b/app/api/v1/ocr.py @@ -0,0 +1,50 @@ +"""Authenticated OCR recognition and source-file endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, UploadFile +from fastapi.responses import FileResponse + +from app.api.dependencies import DatabaseSession, require_permission +from app.core.exceptions import AppException +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.ocr import OcrBatchResult, OcrJudgmentRequest, OcrStandardJudgment +from app.services.ocr import OcrService + +router = APIRouter(prefix="/ocr", tags=["ocr"]) + + +@router.post("/recognize", response_model=ApiResponse[OcrBatchResult]) +async def recognize_files( + files: Annotated[list[UploadFile], File(description="1-5 JPG, PNG, or PDF files")], + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))], +) -> ApiResponse[OcrBatchResult]: + return ApiResponse(data=await OcrService(session).recognize(files)) + + +@router.post("/judge", response_model=ApiResponse[list[OcrStandardJudgment]]) +async def judge_measurements( + payload: OcrJudgmentRequest, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))], +) -> ApiResponse[list[OcrStandardJudgment]]: + return ApiResponse(data=await OcrService(session).judge(payload.measurements)) + + +@router.get("/files/{file_id}", response_class=FileResponse) +async def get_source_file( + file_id: str, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit"))], +) -> FileResponse: + path = OcrService(session).upload_path(file_id) + if not path.is_file(): + raise AppException("附件不存在", code=404, status_code=404) + media_type = { + ".jpg": "image/jpeg", + ".png": "image/png", + ".pdf": "application/pdf", + }[path.suffix.lower()] + return FileResponse(path, media_type=media_type, filename=file_id) diff --git a/app/core/config.py b/app/core/config.py index 24d59c1..c603c9f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,7 @@ """Environment-backed application settings.""" from functools import lru_cache +from pathlib import Path from typing import Annotated, Literal from pydantic import AliasChoices, Field, SecretStr, field_validator @@ -24,6 +25,25 @@ class Settings(BaseSettings): dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False) dingtalk_corp_id: str = "" dingtalk_agent_id: str = "" + baidu_ocr_api_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("BAIDU_OCR_API_KEY", "RISK_BAIDU_OCR_API_KEY"), + ) + baidu_ocr_secret_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("BAIDU_OCR_SECRET_KEY", "RISK_BAIDU_OCR_SECRET_KEY"), + ) + baidu_ocr_image_url: str = "https://aip.baidubce.com/rest/2.0/ocr/v1/webimage" + baidu_ocr_document_url: str = "https://aip.baidubce.com/rest/2.0/brain/online/v2/paddle-vl-parser/task" + baidu_ocr_timeout_seconds: int = Field(default=75, ge=10, le=300) + baidu_ocr_poll_interval_seconds: float = Field(default=2.0, ge=0.5, le=10) + ocr_upload_dir: Path = Path(".runtime-uploads/ocr") + ocr_max_file_mb: int = Field(default=10, ge=1, le=20) + ocr_max_batch_files: int = Field(default=5, ge=1, le=10) + ocr_review_confidence: float = Field(default=0.8, ge=0, le=1) + ocr_judgment_confidence: float = Field(default=0.85, ge=0, le=1) llm_api_key: SecretStr | None = Field( default=None, repr=False, diff --git a/app/domain/model_config.py b/app/domain/model_config.py new file mode 100644 index 0000000..cc29b18 --- /dev/null +++ b/app/domain/model_config.py @@ -0,0 +1,24 @@ +"""Canonical risk-model configuration shared by scoring and administration.""" + +DEFAULT_WEIGHTS = { + "供应商表现": 25.0, + "物料品类": 20.0, + "产品品类": 15.0, + "加工厂质量": 15.0, + "标准符合性": 10.0, + "节点阶段": 10.0, + "缺陷等级": 5.0, +} + +WEIGHT_DESCRIPTIONS = { + "供应商表现": "含频次风险系数 α 修正", + "物料品类": "按物料历史异常率", + "产品品类": "按具体产品品类历史异常密度", + "加工厂质量": "巡检 / 批次合格率", + "标准符合性": "按判定标准库量化达标率计分", + "节点阶段": "开发阶段越靠后风险越高", + "缺陷等级": "A 级缺陷加权", +} + +DEFAULT_FREQUENCY_COEFFICIENT = 0.3 +DEFAULT_FREQUENCY_CAP = 1.5 diff --git a/app/models/__init__.py b/app/models/__init__.py index 9488dce..6f86600 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.entities import ( ReviewRecord, RiskAssessment, RiskItem, + RiskModelConfig, RolePermission, StandardRule, SupplierProfile, @@ -22,6 +23,7 @@ __all__ = [ "ReviewRecord", "RiskAssessment", "RiskItem", + "RiskModelConfig", "RolePermission", "StandardRule", "SupplierProfile", diff --git a/app/models/entities.py b/app/models/entities.py index b2e6ce5..6328709 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -119,9 +119,7 @@ class Conversation(Base): """A persistent assistant conversation owned by one authenticated user.""" __tablename__ = "conversations" - __table_args__ = ( - Index("ix_conversations_user_updated", "user_id", "updated_at"), - ) + __table_args__ = (Index("ix_conversations_user_updated", "user_id", "updated_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( @@ -149,9 +147,7 @@ class ConversationMessage(Base): """One user or assistant message, including optional structured results.""" __tablename__ = "conversation_messages" - __table_args__ = ( - Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"), - ) + __table_args__ = (Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) conversation_id: Mapped[int] = mapped_column( @@ -176,13 +172,17 @@ class RiskItem(Base): Index("ix_risk_items_status", "status"), Index("ix_risk_items_submitted_by", "submitted_by"), Index("ix_risk_items_submitted_at", "submitted_at"), + Index("ix_risk_items_occurred_on", "occurred_on"), + Index("ix_risk_items_material_type", "material_type"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) source_type: Mapped[str] = mapped_column(String(20), nullable=False) source_file_id: Mapped[str | None] = mapped_column(String(50)) + occurred_on: Mapped[date | None] = mapped_column(Date) product_name: Mapped[str | None] = mapped_column(String(200)) product_category: Mapped[str | None] = mapped_column(String(50)) + material_type: Mapped[str | None] = mapped_column(String(200)) supplier: Mapped[str | None] = mapped_column(String(100)) factory: Mapped[str | None] = mapped_column(String(100)) stage_new: Mapped[str | None] = mapped_column(String(50)) @@ -190,6 +190,8 @@ class RiskItem(Base): defect_level: Mapped[str | None] = mapped_column(String(5)) solution: Mapped[str | None] = mapped_column(Text) photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) + recognition_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) + ocr_payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) submitted_by: Mapped[str | None] = mapped_column(String(50)) submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False) @@ -323,6 +325,21 @@ class RolePermission(Base): ) +class RiskModelConfig(Base): + """Administrator-managed seven-dimension weights and frequency parameters.""" + + __tablename__ = "risk_model_configs" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, default=1) + weights: Mapped[dict[str, float]] = mapped_column(JSONB, nullable=False) + frequency_coefficient: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False) + frequency_cap: Mapped[Decimal] = mapped_column(Numeric(4, 2), nullable=False) + updated_by: Mapped[str | None] = mapped_column(String(20)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + class OperationAudit(Base): """Append-only audit record for authentication, permission, and review actions.""" diff --git a/app/schemas/model_config.py b/app/schemas/model_config.py new file mode 100644 index 0000000..ae20f41 --- /dev/null +++ b/app/schemas/model_config.py @@ -0,0 +1,32 @@ +"""Risk-model configuration API contracts.""" + +from datetime import datetime + +from pydantic import Field, model_validator + +from app.domain.model_config import DEFAULT_WEIGHTS +from app.schemas.common import ApiSchema + + +class ModelWeightConfigData(ApiSchema): + weights: dict[str, float] + frequency_coefficient: float + frequency_cap: float + updated_by: str | None = None + updated_at: datetime | None = None + + +class ModelWeightConfigUpdate(ApiSchema): + weights: dict[str, float] + frequency_coefficient: float = Field(ge=0, le=1) + frequency_cap: float = Field(ge=1, le=3) + + @model_validator(mode="after") + def validate_weights(self) -> "ModelWeightConfigUpdate": + if set(self.weights) != set(DEFAULT_WEIGHTS): + raise ValueError("权重必须完整包含七个固定维度") + if any(value <= 0 or value > 100 for value in self.weights.values()): + raise ValueError("每个维度权重必须大于0且不超过100") + if abs(sum(self.weights.values()) - 100) > 0.01: + raise ValueError("七个维度权重合计必须为100%") + return self diff --git a/app/schemas/ocr.py b/app/schemas/ocr.py new file mode 100644 index 0000000..fc35a94 --- /dev/null +++ b/app/schemas/ocr.py @@ -0,0 +1,73 @@ +"""OCR recognition and standard-judgment contracts.""" + +from datetime import date +from typing import Literal + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class OcrField(ApiSchema): + value: str | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + needs_review: bool = True + + +class OcrExtractedFields(ApiSchema): + product_name: OcrField = Field(default_factory=OcrField) + product_category: OcrField = Field(default_factory=OcrField) + supplier: OcrField = Field(default_factory=OcrField) + factory: OcrField = Field(default_factory=OcrField) + material_type: OcrField = Field(default_factory=OcrField) + stage_new: OcrField = Field(default_factory=OcrField) + description: OcrField = Field(default_factory=OcrField) + defect_level: OcrField = Field(default_factory=OcrField) + occurred_on: date | None = None + + +class OcrMeasurement(ApiSchema): + item: str + raw_value: str + value: float | None = None + unit: str | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + needs_review: bool = True + + +class OcrStandardJudgment(ApiSchema): + measurement_item: str + measured_value: str + standard_rule_id: int | None = None + risk_point: str | None = None + standard_value: str | None = None + result: Literal["合格", "不合格", "待人工确认", "未匹配标准"] + basis: str | None = None + reason: str | None = None + + +class OcrFileResult(ApiSchema): + file_id: str + file_name: str + content_type: str + engine: Literal["webimage", "paddle-vl-parser"] + raw_text: str + confidence: float | None = Field(default=None, ge=0, le=1) + page_count: int = Field(default=1, ge=1) + fields: OcrExtractedFields + measurements: list[OcrMeasurement] = Field(default_factory=list) + judgments: list[OcrStandardJudgment] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + requires_review: bool = True + + +class OcrBatchResult(ApiSchema): + files: list[OcrFileResult] + fields: OcrExtractedFields + measurements: list[OcrMeasurement] = Field(default_factory=list) + judgments: list[OcrStandardJudgment] = Field(default_factory=list) + requires_review: bool = True + + +class OcrJudgmentRequest(ApiSchema): + measurements: list[OcrMeasurement] = Field(min_length=1, max_length=50) diff --git a/app/schemas/risk_item.py b/app/schemas/risk_item.py index 851bd74..9bf1815 100644 --- a/app/schemas/risk_item.py +++ b/app/schemas/risk_item.py @@ -1,6 +1,7 @@ """Risk item submission and review contracts.""" -from datetime import datetime +from datetime import date, datetime +from typing import Any from pydantic import Field @@ -10,8 +11,10 @@ from app.schemas.common import ApiSchema class RiskItemCreate(ApiSchema): source_type: str = Field(default="表单", min_length=1, max_length=20) source_file_id: str | None = Field(default=None, max_length=50) + occurred_on: date | None = None product_name: str = Field(min_length=1, max_length=200) product_category: str | None = Field(default=None, max_length=100) + material_type: str | None = Field(default=None, max_length=200) supplier: str | None = Field(default=None, max_length=300) factory: str | None = Field(default=None, max_length=300) stage_new: str | None = Field(default=None, max_length=100) @@ -19,11 +22,15 @@ class RiskItemCreate(ApiSchema): defect_level: str | None = Field(default=None, max_length=10) solution: str | None = None photos: list[str] = Field(default_factory=list, max_length=10) + recognition_confidence: float | None = Field(default=None, ge=0, le=1) + ocr_payload: dict[str, Any] = Field(default_factory=dict) class RiskItemUpdate(ApiSchema): product_name: str | None = Field(default=None, min_length=1, max_length=200) product_category: str | None = Field(default=None, max_length=100) + material_type: str | None = Field(default=None, max_length=200) + occurred_on: date | None = None supplier: str | None = Field(default=None, max_length=300) factory: str | None = Field(default=None, max_length=300) stage_new: str | None = Field(default=None, max_length=100) @@ -31,14 +38,18 @@ class RiskItemUpdate(ApiSchema): defect_level: str | None = Field(default=None, max_length=10) solution: str | None = None photos: list[str] | None = Field(default=None, max_length=10) + recognition_confidence: float | None = Field(default=None, ge=0, le=1) + ocr_payload: dict[str, Any] | None = None class RiskItemData(ApiSchema): id: int source_type: str source_file_id: str | None + occurred_on: date | None product_name: str | None product_category: str | None + material_type: str | None supplier: str | None factory: str | None stage_new: str | None @@ -46,6 +57,8 @@ class RiskItemData(ApiSchema): defect_level: str | None solution: str | None photos: list[str] + recognition_confidence: float | None + ocr_payload: dict[str, Any] submitted_by: str | None submitted_at: datetime | None status: str diff --git a/app/services/baidu_ocr.py b/app/services/baidu_ocr.py new file mode 100644 index 0000000..e04c464 --- /dev/null +++ b/app/services/baidu_ocr.py @@ -0,0 +1,179 @@ +"""Small async client for the Baidu OCR endpoints used by the application.""" + +import asyncio +import base64 +import time +from typing import Any +from urllib.parse import urlparse + +import httpx + +from app.core.config import get_settings +from app.core.exceptions import AppException + + +class BaiduOcrClient: + token_url = "https://aip.baidubce.com/oauth/2.0/token" + + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + settings = get_settings() + self.api_key = settings.baidu_ocr_api_key.get_secret_value() if settings.baidu_ocr_api_key else "" + self.secret_key = settings.baidu_ocr_secret_key.get_secret_value() if settings.baidu_ocr_secret_key else "" + self.image_url = settings.baidu_ocr_image_url.strip() + self.document_url = settings.baidu_ocr_document_url.strip().rstrip("/") + self.timeout_seconds = settings.baidu_ocr_timeout_seconds + self.poll_interval = settings.baidu_ocr_poll_interval_seconds + self.transport = transport + self._access_token = "" + self._token_expires_at = 0.0 + + @property + def configured(self) -> bool: + return bool(self.api_key and self.secret_key) + + async def recognize_image(self, content: bytes) -> tuple[str, float | None, int]: + token = await self._get_access_token() + payload = { + "image": base64.b64encode(content).decode("ascii"), + "language_type": "CHN_ENG", + "detect_direction": "true", + "probability": "true", + } + body = await self._post_form(self.image_url, token, payload) + words = body.get("words_result") or [] + lines = [str(item.get("words") or "").strip() for item in words if isinstance(item, dict)] + confidences = [ + float(item["probability"]["average"]) + for item in words + if isinstance(item, dict) + and isinstance(item.get("probability"), dict) + and isinstance(item["probability"].get("average"), (int, float)) + ] + text = "\n".join(line for line in lines if line) + if not text: + raise AppException("图片中未识别到文字,请重新拍摄或手动填写", code=422, status_code=422) + confidence = sum(confidences) / len(confidences) if confidences else None + return text, confidence, 1 + + async def recognize_document(self, content: bytes, file_name: str) -> tuple[str, float | None, int]: + token = await self._get_access_token() + body = await self._post_form( + self.document_url, + token, + { + "file_data": base64.b64encode(content).decode("ascii"), + "file_name": file_name, + "merge_tables": "true", + "return_span_boxes": "true", + "angle_adjust": "true", + }, + ) + task_id = str((body.get("result") or {}).get("task_id") or "") + if not task_id: + raise AppException("百度文档识别未返回任务编号", code=502, status_code=502) + + deadline = time.monotonic() + self.timeout_seconds + query_url = f"{self.document_url}/query" + result: dict[str, Any] = {} + while time.monotonic() < deadline: + await asyncio.sleep(self.poll_interval) + query_body = await self._post_form(query_url, token, {"task_id": task_id}) + result = query_body.get("result") or {} + status = result.get("status") + if status == "success": + break + if status == "failed": + detail = str(result.get("task_error") or "文档解析失败") + raise AppException(f"百度文档识别失败:{detail}", code=502, status_code=502) + else: + raise AppException("文档识别超时,请稍后重试", code=504, status_code=504) + + parse_url = str(result.get("parse_result_url") or "") + markdown_url = str(result.get("markdown_url") or "") + if parse_url: + parsed = await self._download_result(parse_url, expect_json=True) + text, pages = self._text_from_parse_result(parsed) + elif markdown_url: + text = str(await self._download_result(markdown_url, expect_json=False)) + pages = max(1, text.count("\n---\n") + 1) + else: + raise AppException("百度文档识别未返回解析结果", code=502, status_code=502) + if not text.strip(): + raise AppException("文档中未识别到文字,请重新上传或手动填写", code=422, status_code=422) + return text[:100_000], None, pages + + async def _get_access_token(self) -> str: + if not self.configured: + raise AppException("未配置百度 OCR API Key 或 Secret Key", code=503, status_code=503) + if self._access_token and time.monotonic() < self._token_expires_at: + return self._access_token + timeout = httpx.Timeout(15, connect=10) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post( + self.token_url, + params={ + "grant_type": "client_credentials", + "client_id": self.api_key, + "client_secret": self.secret_key, + }, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise AppException("无法连接百度 OCR 鉴权服务", code=502, status_code=502) from exc + token = str(body.get("access_token") or "") + if not token: + raise AppException("百度 OCR 密钥无效或服务尚未开通", code=502, status_code=502) + expires_in = int(body.get("expires_in") or 2_592_000) + self._access_token = token + self._token_expires_at = time.monotonic() + max(60, expires_in - 300) + return token + + async def _post_form(self, url: str, token: str, data: dict[str, str]) -> dict[str, Any]: + timeout = httpx.Timeout(self.timeout_seconds, connect=15) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post(url, params={"access_token": token}, data=data) + response.raise_for_status() + body = response.json() + except httpx.TimeoutException as exc: + raise AppException("百度 OCR 请求超时,请重试", code=504, status_code=504) from exc + except (httpx.HTTPError, ValueError) as exc: + raise AppException("百度 OCR 服务暂时不可用", code=502, status_code=502) from exc + if body.get("error_code") not in (None, 0, "0"): + message = str(body.get("error_msg") or "识别失败") + raise AppException(f"百度 OCR 返回错误:{message}", code=502, status_code=502) + return body + + async def _download_result(self, url: str, *, expect_json: bool) -> dict[str, Any] | str: + parsed = urlparse(url) + allowed = parsed.scheme == "https" and ( + parsed.hostname == "baidubce.com" + or bool(parsed.hostname and parsed.hostname.endswith((".baidubce.com", ".bcebos.com"))) + ) + if not allowed: + raise AppException("百度 OCR 返回了不受信任的结果地址", code=502, status_code=502) + timeout = httpx.Timeout(self.timeout_seconds, connect=15) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport, follow_redirects=True) as client: + try: + response = await client.get(url) + response.raise_for_status() + return response.json() if expect_json else response.text + except (httpx.HTTPError, ValueError) as exc: + raise AppException("无法下载百度 OCR 解析结果", code=502, status_code=502) from exc + + @staticmethod + def _text_from_parse_result(body: dict[str, Any]) -> tuple[str, int]: + pages = body.get("pages") or [] + chunks: list[str] = [] + for page in pages: + if not isinstance(page, dict): + continue + page_text = str(page.get("text") or "").strip() + if page_text: + chunks.append(page_text) + for table in page.get("tables") or []: + if isinstance(table, dict) and table.get("markdown"): + chunks.append(str(table["markdown"]).strip()) + return "\n\n".join(chunks), max(1, len(pages)) diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index 0c36fb9..5531c06 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -9,18 +9,9 @@ from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS from app.domain.risk_engine import RiskLevel -from app.models.entities import AnomalyCase - -DEFAULT_WEIGHTS = { - "供应商表现": 25.0, - "物料品类": 20.0, - "产品品类": 15.0, - "加工厂质量": 15.0, - "标准符合性": 10.0, - "节点阶段": 10.0, - "缺陷等级": 5.0, -} +from app.models.entities import AnomalyCase, RiskModelConfig STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") @@ -54,6 +45,9 @@ class DatabaseRiskScoringService: def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None: self.session = session self.as_of = as_of or date.today() + self.weights = DEFAULT_WEIGHTS.copy() + self.frequency_coefficient = DEFAULT_FREQUENCY_COEFFICIENT + self.frequency_cap = DEFAULT_FREQUENCY_CAP async def score( self, @@ -65,6 +59,7 @@ class DatabaseRiskScoringService: stage: str | None = None, defect_level: str | None = None, ) -> DatabaseRiskScore: + await self._load_model_config() rows = await self._load_rows() category_name = normalize_category(category) material_name = normalize_material(material) @@ -78,14 +73,14 @@ class DatabaseRiskScoringService: DimensionScore( name="加工厂质量", score=50.0, - weight=DEFAULT_WEIGHTS["加工厂质量"], + weight=self.weights["加工厂质量"], status="default", reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入", ), DimensionScore( name="标准符合性", score=50.0, - weight=DEFAULT_WEIGHTS["标准符合性"], + weight=self.weights["标准符合性"], status="default", reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", ), @@ -95,6 +90,19 @@ class DatabaseRiskScoringService: score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) + async def _load_model_config(self) -> None: + get = getattr(self.session, "get", None) + if get is None: + return + config = await get(RiskModelConfig, 1) + if config is None: + return + weights = {key: float(config.weights.get(key, value)) for key, value in DEFAULT_WEIGHTS.items()} + if abs(sum(weights.values()) - 100) <= 0.01 and all(value > 0 for value in weights.values()): + self.weights = weights + self.frequency_coefficient = float(config.frequency_coefficient) + self.frequency_cap = float(config.frequency_cap) + async def _load_rows(self) -> list[dict[str, Any]]: start = self.as_of - timedelta(days=365 * 3) statement = select( @@ -114,7 +122,7 @@ class DatabaseRiskScoringService: return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["供应商表现"] + weight = self.weights["供应商表现"] if not party_name: return DimensionScore( name="供应商表现", @@ -155,7 +163,7 @@ class DatabaseRiskScoringService: else 0 ) base = max(0.0, min(100.0, base + trend_adjustment)) - alpha = min(1.5, 1.0 + count / 100 * 0.3) + alpha = min(self.frequency_cap, 1.0 + count / 100 * self.frequency_coefficient) score = min(100.0, round(base * alpha, 2)) return DimensionScore( name="供应商表现", @@ -167,9 +175,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _material_dimension(rows: list[dict[str, Any]], material: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["物料品类"] + def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore: + weight = self.weights["物料品类"] if not material: return DimensionScore( name="物料品类", @@ -196,9 +203,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _category_dimension(rows: list[dict[str, Any]], category: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["产品品类"] + def _category_dimension(self, rows: list[dict[str, Any]], category: str | None) -> DimensionScore: + weight = self.weights["产品品类"] if not category: return DimensionScore( name="产品品类", @@ -232,9 +238,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _stage_dimension(rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["节点阶段"] + def _stage_dimension(self, rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: + weight = self.weights["节点阶段"] if not stage: return DimensionScore( name="节点阶段", @@ -266,15 +271,15 @@ class DatabaseRiskScoringService: raw_value=share * 100, ) - @staticmethod def _defect_dimension( + self, rows: list[dict[str, Any]], category: str | None, material: str | None, party_name: str | None, defect_level: str | None, ) -> DimensionScore: - weight = DEFAULT_WEIGHTS["缺陷等级"] + weight = self.weights["缺陷等级"] if not category and not material and not party_name and not defect_level: return DimensionScore( name="缺陷等级", diff --git a/app/services/dingtalk_auth.py b/app/services/dingtalk_auth.py index fdee144..4ba1b02 100644 --- a/app/services/dingtalk_auth.py +++ b/app/services/dingtalk_auth.py @@ -170,21 +170,15 @@ class DingTalkAuthService: @staticmethod def map_role(dept: str, title: str, is_admin: bool = False) -> str: - """Map DingTalk organization snapshots to the PRD seven-role model.""" + """Map DingTalk organization snapshots to the four-role administration model.""" text = f"{dept} {title}".lower() if is_admin or "it" in text or "信息" in text or "系统" in text: return "admin" if "审核" in text or "review" in text: return "reviewer" - if "采购" in text or "procurement" in text: - return "procurement" - if "品控" in text or "质量" in text or "qc" in text: - return "qc" - if "管理" in text or "主管" in text or "manager" in text: - return "manager" - if "运营" in text or "ops" in text: - return "ops" - if "开发" in text or "产品" in text or "dev" in text: - return "dev" + if "产品经理" in text or "product manager" in text or ( + "产品" in dept and ("经理" in title or "主管" in title) + ): + return "product_manager" return "普通用户" diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 358b3a8..c33f055 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -135,6 +135,40 @@ class LLMClient: status_code=503, ) from fallback_exc + async def extract_ocr_fields(self, text: str) -> dict[str, Any]: + """Turn OCR text into an editable risk-item draft without inventing absent values.""" + + messages = [ + { + "role": "system", + "content": ( + "你是蝶雅产品风险预估系统的OCR字段抽取模块,只输出合法JSON对象。" + "只能提取输入文字明确出现的信息,不得推测或补造。输出字段:product_name、product_category、" + "supplier、factory、material_type、stage_new、description、defect_level、occurred_on、confidence、" + "measurements。缺失标量用null。defect_level只能是A、B、C或null;occurred_on使用YYYY-MM-DD或null。" + "measurements为数组,每项包含item、raw_value、value、unit、confidence;value必须是数字或null。" + "measurements只允许质量检测指标,例如pH、色差、重金属含量、力矩、喷出量、附着力、" + "不合格率;数量、抽检数量、产品规格、容量、日期、单号和批次不得作为检测指标。" + "confidence为0到1。description应保留异常现象和影响比例,但不得加入原文没有的结论。" + ), + }, + {"role": "user", "content": text[:20_000]}, + ] + last_error: Exception | None = None + providers: list[ProviderName] = ["primary"] + if self.fallback_configured: + providers.append("fallback") + for provider in providers: + try: + body = await self._request(messages, provider=provider, stream=False, json_mode=True) + return self._parse_json(self._response_text(body, provider=provider)) + except Exception as exc: # noqa: BLE001 - deterministic extraction handles provider failures + last_error = exc + logger.warning("LLM OCR extraction failed provider=%s error=%s", provider, exc.__class__.__name__) + if last_error: + raise last_error + return {} + async def stream_reply(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]: """Yield reply deltas and fail over only before any primary text is emitted.""" @@ -179,9 +213,8 @@ class LLMClient: }, { "role": "user", - "content": "以下是本次风险评估数据,请生成预防建议:\n" + json.dumps( - context, ensure_ascii=False, separators=(",", ":") - ), + "content": "以下是本次风险评估数据,请生成预防建议:\n" + + json.dumps(context, ensure_ascii=False, separators=(",", ":")), }, ] try: diff --git a/app/services/model_config.py b/app/services/model_config.py new file mode 100644 index 0000000..c3a2fbe --- /dev/null +++ b/app/services/model_config.py @@ -0,0 +1,57 @@ +"""Read and update administrator-managed risk-model configuration.""" + +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS +from app.models.entities import OperationAudit, RiskModelConfig, UserAccount +from app.schemas.model_config import ModelWeightConfigData, ModelWeightConfigUpdate + + +class ModelConfigService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self) -> ModelWeightConfigData: + row = await self.session.get(RiskModelConfig, 1) + if row is None: + return ModelWeightConfigData( + weights=DEFAULT_WEIGHTS, + frequency_coefficient=DEFAULT_FREQUENCY_COEFFICIENT, + frequency_cap=DEFAULT_FREQUENCY_CAP, + ) + return self._to_data(row) + + async def update(self, payload: ModelWeightConfigUpdate, user: UserAccount) -> ModelWeightConfigData: + row = await self.session.get(RiskModelConfig, 1) + if row is None: + row = RiskModelConfig(id=1) + self.session.add(row) + row.weights = payload.weights + row.frequency_coefficient = Decimal(str(payload.frequency_coefficient)) + row.frequency_cap = Decimal(str(payload.frequency_cap)) + row.updated_by = user.emp_no + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="model_weight.update", + resource_type="risk_model_config", + resource_id="1", + audit_metadata=payload.model_dump(), + ) + ) + await self.session.flush() + await self.session.refresh(row) + return self._to_data(row) + + @staticmethod + def _to_data(row: RiskModelConfig) -> ModelWeightConfigData: + return ModelWeightConfigData( + weights={key: float(value) for key, value in row.weights.items()}, + frequency_coefficient=float(row.frequency_coefficient), + frequency_cap=float(row.frequency_cap), + updated_by=row.updated_by, + updated_at=row.updated_at, + ) diff --git a/app/services/ocr.py b/app/services/ocr.py new file mode 100644 index 0000000..c74dac7 --- /dev/null +++ b/app/services/ocr.py @@ -0,0 +1,507 @@ +"""OCR upload orchestration, field extraction, and standards evaluation.""" + +import re +from datetime import date +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.models.entities import StandardRule +from app.schemas.ocr import ( + OcrBatchResult, + OcrExtractedFields, + OcrField, + OcrFileResult, + OcrMeasurement, + OcrStandardJudgment, +) +from app.services.baidu_ocr import BaiduOcrClient +from app.services.llm_client import LLMClient + +_FIELD_ALIASES = { + "product_name": ("产品名称", "产品名", "品名"), + "product_category": ("产品品类", "品类"), + "supplier": ("供应商", "供方"), + "factory": ("加工厂", "生产工厂", "工厂"), + "material_type": ("物料名称", "物料类型", "物料"), + "stage_new": ("发生阶段", "发现阶段", "产品阶段", "阶段"), + "description": ("异常描述", "问题描述", "不良描述", "异常现象"), + "defect_level": ("缺陷等级", "异常等级", "等级"), +} +_MEASUREMENT_WORDS = ( + "pH", + "PH", + "色差", + "铅", + "汞", + "砷", + "镉", + "力矩", + "喷出量", + "附着力", + "含量", + "粘度", + "黏度", + "透光率", + "菌落", + "温度", + "湿度", + "不合格率", +) +_NON_MEASUREMENT_NAMES = { + "数量", + "抽检数量", + "产品规格", + "规格", + "容量", + "日期", + "生产日期", + "发生日期", + "单号", + "订单号", + "批次", + "批号", + "页码", +} +_UNIT_PATTERN = r"(?:mg/kg|μg/g|ug/g|g/L|mg/L|N[·.]?m|mPa[·.]?s|Pa[·.]?s|mm|cm|nm|%|级|℃|°C)?" + + +class OcrService: + def __init__( + self, + session: AsyncSession, + *, + client: BaiduOcrClient | None = None, + llm: LLMClient | None = None, + ) -> None: + self.session = session + self.client = client or BaiduOcrClient() + self.llm = llm or LLMClient() + self.settings = get_settings() + + async def recognize(self, files: list[UploadFile]) -> OcrBatchResult: + if not files: + raise AppException("请至少上传一个图片或 PDF", code=422, status_code=422) + if len(files) > self.settings.ocr_max_batch_files: + raise AppException( + f"一次最多上传 {self.settings.ocr_max_batch_files} 个文件", + code=422, + status_code=422, + ) + rules = list( + ( + await self.session.scalars( + select(StandardRule).where(StandardRule.status == "启用").order_by(StandardRule.id) + ) + ).all() + ) + results: list[OcrFileResult] = [] + try: + for upload in files: + results.append(await self._recognize_one(upload, rules)) + except Exception: + for result in results: + self.upload_path(result.file_id).unlink(missing_ok=True) + raise + fields = self._merge_fields([item.fields for item in results]) + measurements = [measurement for item in results for measurement in item.measurements] + judgments = [judgment for item in results for judgment in item.judgments] + return OcrBatchResult( + files=results, + fields=fields, + measurements=measurements, + judgments=judgments, + requires_review=any(item.requires_review for item in results), + ) + + async def judge(self, measurements: list[OcrMeasurement]) -> list[OcrStandardJudgment]: + rules = list( + ( + await self.session.scalars( + select(StandardRule).where(StandardRule.status == "启用").order_by(StandardRule.id) + ) + ).all() + ) + return self._judge_measurements(measurements, rules) + + async def _recognize_one(self, upload: UploadFile, rules: list[StandardRule]) -> OcrFileResult: + content = await self._read_limited(upload) + content_type, suffix = self._detect_type(content) + original_name = Path(upload.filename or f"upload{suffix}").name + file_id = f"{uuid4().hex}{suffix}" + target = self.upload_path(file_id) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + try: + if content_type == "application/pdf": + raw_text, provider_confidence, page_count = await self.client.recognize_document(content, original_name) + engine = "paddle-vl-parser" + else: + raw_text, provider_confidence, page_count = await self.client.recognize_image(content) + engine = "webimage" + extracted = await self._extract(raw_text, provider_confidence) + fields, measurements, extraction_confidence = extracted + judgments = self._judge_measurements(measurements, rules) + warnings = self._warnings(fields, measurements, judgments, provider_confidence) + requires_review = bool(warnings) or any(field.needs_review for field in self._field_values(fields)) + return OcrFileResult( + file_id=file_id, + file_name=original_name, + content_type=content_type, + engine=engine, + raw_text=raw_text, + confidence=provider_confidence if provider_confidence is not None else extraction_confidence, + page_count=page_count, + fields=fields, + measurements=measurements, + judgments=judgments, + warnings=warnings, + requires_review=requires_review, + ) + except Exception: + target.unlink(missing_ok=True) + raise + finally: + await upload.close() + + async def _extract( + self, + text: str, + provider_confidence: float | None, + ) -> tuple[OcrExtractedFields, list[OcrMeasurement], float | None]: + fallback_fields = self._extract_fields_by_labels(text, provider_confidence) + fallback_measurements = self._extract_measurements(text, provider_confidence) + if not self.llm.configured: + return fallback_fields, fallback_measurements, provider_confidence + try: + data = await self.llm.extract_ocr_fields(text) + except Exception: # noqa: BLE001 - OCR remains usable when the optional entity extractor fails + return fallback_fields, fallback_measurements, provider_confidence + confidence = self._as_confidence(data.get("confidence")) + fields = self._fields_from_llm(data, confidence, fallback_fields) + measurements = self._measurements_from_llm(data.get("measurements"), text, confidence) + return fields, measurements or fallback_measurements, confidence + + def _fields_from_llm( + self, + data: dict[str, Any], + confidence: float | None, + fallback: OcrExtractedFields, + ) -> OcrExtractedFields: + values: dict[str, Any] = {} + for key in _FIELD_ALIASES: + value = data.get(key) + if key == "defect_level" and value: + value = str(value).upper().replace("级", "") + if value not in {"A", "B", "C"}: + value = None + if value is None or not str(value).strip(): + values[key] = getattr(fallback, key) + else: + values[key] = self._field(str(value).strip(), confidence) + occurred_on = self._as_date(data.get("occurred_on")) or fallback.occurred_on + return OcrExtractedFields(**values, occurred_on=occurred_on) + + def _measurements_from_llm( + self, + raw: Any, + source_text: str, + default_confidence: float | None, + ) -> list[OcrMeasurement]: + if not isinstance(raw, list): + return [] + results: list[OcrMeasurement] = [] + for item in raw[:50]: + if not isinstance(item, dict): + continue + name = str(item.get("item") or "").strip() + raw_value = str(item.get("raw_value") or item.get("value") or "").strip() + if ( + not name + or self._normalize(name) in {self._normalize(value) for value in _NON_MEASUREMENT_NAMES} + or not raw_value + or not self._numeric_appears(raw_value, source_text) + ): + continue + value = self._as_float(item.get("value")) + if value is None: + value = self._as_float(raw_value) + confidence = self._as_confidence(item.get("confidence")) or default_confidence + results.append( + OcrMeasurement( + item=name[:100], + raw_value=raw_value[:100], + value=value, + unit=str(item.get("unit") or "").strip()[:30] or self._unit_from(raw_value), + confidence=confidence, + needs_review=confidence is None or confidence < self.settings.ocr_judgment_confidence, + ) + ) + return results + + def _extract_fields_by_labels(self, text: str, confidence: float | None) -> OcrExtractedFields: + values: dict[str, OcrField] = {} + for field_name, aliases in _FIELD_ALIASES.items(): + found: str | None = None + for alias in aliases: + match = re.search(rf"{re.escape(alias)}\s*[::]?\s*([^\n||]{{1,120}})", text, re.IGNORECASE) + if match: + found = match.group(1).strip(" ::;;") + break + if field_name == "defect_level" and found: + level = re.search(r"\b([ABC])\s*级?\b", found, re.IGNORECASE) + found = level.group(1).upper() if level else None + values[field_name] = self._field(found, confidence) + occurred = None + date_match = re.search(r"(?:发生日期|日期)\s*[::]?\s*(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text) + if date_match: + occurred = self._as_date("-".join(date_match.groups())) + return OcrExtractedFields(**values, occurred_on=occurred) + + def _extract_measurements(self, text: str, confidence: float | None) -> list[OcrMeasurement]: + results: list[OcrMeasurement] = [] + seen: set[tuple[str, str]] = set() + pattern = re.compile( + rf"(?P[A-Za-z\u4e00-\u9fffΔδ()()·/\-]{{1,32}}?)\s*[::]?\s*" + rf"(?P[+\-]?\d+(?:\.\d+)?)\s*(?P{_UNIT_PATTERN})", + re.IGNORECASE, + ) + for line in text.splitlines(): + if not any(word.lower() in line.lower() for word in _MEASUREMENT_WORDS): + continue + for match in pattern.finditer(line): + name = match.group("item").strip(" ::;;-||") + raw_value = f"{match.group('value')}{match.group('unit') or ''}" + key = (name, raw_value) + if not name or key in seen: + continue + seen.add(key) + results.append( + OcrMeasurement( + item=name[:100], + raw_value=raw_value, + value=float(match.group("value")), + unit=match.group("unit") or None, + confidence=confidence, + needs_review=confidence is None or confidence < self.settings.ocr_judgment_confidence, + ) + ) + return results[:50] + + def _judge_measurements( + self, + measurements: list[OcrMeasurement], + rules: list[StandardRule], + ) -> list[OcrStandardJudgment]: + judgments: list[OcrStandardJudgment] = [] + for measurement in measurements: + rule = self._best_rule(measurement.item, rules) + if rule is None: + judgments.append( + OcrStandardJudgment( + measurement_item=measurement.item, + measured_value=measurement.raw_value, + result="未匹配标准", + reason="标准库中没有找到对应检测项", + ) + ) + continue + result, reason = self.evaluate_standard(measurement, rule.judge_standard) + if measurement.needs_review and result in {"合格", "不合格"}: + result = "待人工确认" + reason = ( + f"识别置信度低于 {self.settings.ocr_judgment_confidence:.2f}," + "需确认检测值后再采用自动判定" + ) + judgments.append( + OcrStandardJudgment( + measurement_item=measurement.item, + measured_value=measurement.raw_value, + standard_rule_id=rule.id, + risk_point=rule.risk_point, + standard_value=rule.judge_standard, + result=result, + basis=rule.regulation, + reason=reason, + ) + ) + return judgments + + @staticmethod + def _best_rule(item: str, rules: list[StandardRule]) -> StandardRule | None: + needle = OcrService._normalize(item) + if not needle: + return None + scored: list[tuple[float, StandardRule]] = [] + for rule in rules: + haystack = OcrService._normalize(f"{rule.risk_point} {rule.judge_standard}") + if needle in haystack or haystack in needle: + score = 2.0 + min(len(needle), len(haystack)) / 100 + else: + left = set(OcrService._bigrams(needle)) + right = set(OcrService._bigrams(haystack)) + score = len(left & right) / max(1, len(left | right)) + threshold = 0.45 if len(needle) <= 4 else 0.3 + if score >= threshold: + scored.append((score, rule)) + return max(scored, key=lambda pair: pair[0])[1] if scored else None + + @staticmethod + def evaluate_standard(measurement: OcrMeasurement, standard: str) -> tuple[str, str | None]: + if measurement.value is None: + return "待人工确认", "检测值不是可比较的数字" + normalized = standard.replace("~", "~").replace("—", "-").replace("至", "~") + range_match = re.search(r"(-?\d+(?:\.\d+)?)\s*(?:~|-)\s*(-?\d+(?:\.\d+)?)", normalized) + if range_match: + mismatch = OcrService._unit_mismatch(measurement.unit, normalized[range_match.end() :]) + if mismatch: + return "待人工确认", mismatch + lower, upper = map(float, range_match.groups()) + passed = lower <= measurement.value <= upper + return ("合格" if passed else "不合格"), None + comparator = re.search(r"(<=|>=|≤|≥|<|>)\s*(?:±\s*)?(-?\d+(?:\.\d+)?)", normalized) + if comparator: + mismatch = OcrService._unit_mismatch(measurement.unit, normalized[comparator.end() :]) + if mismatch: + return "待人工确认", mismatch + operator, raw_limit = comparator.groups() + limit = float(raw_limit) + value = abs(measurement.value) if "±" in comparator.group(0) else measurement.value + passed = { + "<=": value <= limit, + "≤": value <= limit, + ">=": value >= limit, + "≥": value >= limit, + "<": value < limit, + ">": value > limit, + }[operator] + return ("合格" if passed else "不合格"), None + return "待人工确认", "该标准不是系统可自动计算的数值范围" + + def _warnings( + self, + fields: OcrExtractedFields, + measurements: list[OcrMeasurement], + judgments: list[OcrStandardJudgment], + confidence: float | None, + ) -> list[str]: + warnings: list[str] = [] + if confidence is None: + warnings.append("服务未返回文字级置信度,请人工核对识别结果") + elif confidence < self.settings.ocr_review_confidence: + warnings.append("OCR 置信度低于 0.8,请重点核对标黄字段") + if not fields.product_name.value: + warnings.append("未识别到产品名称") + if not fields.description.value: + warnings.append("未识别到异常描述") + if measurements and any(item.result == "不合格" for item in judgments): + warnings.append("检测数值存在不合格项,请确认后提交审核") + return warnings + + async def _read_limited(self, upload: UploadFile) -> bytes: + limit = self.settings.ocr_max_file_mb * 1024 * 1024 + content = await upload.read(limit + 1) + if len(content) > limit: + raise AppException(f"单个文件不能超过 {self.settings.ocr_max_file_mb}MB", code=413, status_code=413) + if not content: + raise AppException("不能上传空文件", code=422, status_code=422) + return content + + @staticmethod + def _detect_type(content: bytes) -> tuple[str, str]: + if content.startswith(b"%PDF-"): + return "application/pdf", ".pdf" + if content.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png", ".png" + if content.startswith(b"\xff\xd8\xff"): + return "image/jpeg", ".jpg" + raise AppException("仅支持 JPG、PNG 或 PDF 文件", code=422, status_code=422) + + def upload_path(self, file_id: str) -> Path: + if not re.fullmatch(r"[0-9a-f]{32}\.(?:jpg|png|pdf)", file_id): + raise AppException("附件不存在", code=404, status_code=404) + root = self.settings.ocr_upload_dir.resolve() + return root / file_id + + @staticmethod + def _field(value: str | None, confidence: float | None) -> OcrField: + threshold = get_settings().ocr_review_confidence + return OcrField( + value=value, + confidence=confidence, + needs_review=not value or confidence is None or confidence < threshold, + ) + + @staticmethod + def _field_values(fields: OcrExtractedFields) -> list[OcrField]: + return [getattr(fields, key) for key in _FIELD_ALIASES] + + @staticmethod + def _merge_fields(items: list[OcrExtractedFields]) -> OcrExtractedFields: + merged: dict[str, Any] = {} + for key in _FIELD_ALIASES: + candidates = [getattr(item, key) for item in items if getattr(item, key).value] + merged[key] = max(candidates, key=lambda field: field.confidence or -1) if candidates else OcrField() + merged["occurred_on"] = next((item.occurred_on for item in items if item.occurred_on), None) + return OcrExtractedFields(**merged) + + @staticmethod + def _as_confidence(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return min(1.0, max(0.0, number)) + + @staticmethod + def _as_float(value: Any) -> float | None: + match = re.search(r"[+\-]?\d+(?:\.\d+)?", str(value or "")) + return float(match.group()) if match else None + + @staticmethod + def _as_date(value: Any) -> date | None: + if not value: + return None + try: + return date.fromisoformat(str(value).strip()[:10]) + except ValueError: + return None + + @staticmethod + def _unit_from(value: str) -> str | None: + match = re.search(_UNIT_PATTERN + r"$", value, re.IGNORECASE) + return match.group() if match and match.group() else None + + @staticmethod + def _unit_mismatch(measured_unit: str | None, standard_tail: str) -> str | None: + if not measured_unit: + return None + standard_unit = re.match(rf"\s*({_UNIT_PATTERN})", standard_tail, re.IGNORECASE) + expected = standard_unit.group(1) if standard_unit and standard_unit.group(1) else None + if not expected: + return None + def normalize(unit: str) -> str: + return unit.lower().replace(".", "·").replace("°c", "℃") + + if normalize(measured_unit) != normalize(expected): + return f"检测值单位 {measured_unit} 与标准单位 {expected} 不一致" + return None + + @staticmethod + def _numeric_appears(value: str, text: str) -> bool: + number = re.search(r"[+\-]?\d+(?:\.\d+)?", value) + return bool(number and number.group() in text) + + @staticmethod + def _normalize(value: str) -> str: + return re.sub(r"[^a-z0-9\u4e00-\u9fffΔδ]", "", value.lower()) + + @staticmethod + def _bigrams(value: str) -> list[str]: + return [value[index : index + 2] for index in range(max(1, len(value) - 1))] diff --git a/app/services/risk_items.py b/app/services/risk_items.py index 7f06c34..02bd07f 100644 --- a/app/services/risk_items.py +++ b/app/services/risk_items.py @@ -19,8 +19,10 @@ class RiskItemService: item = RiskItem( source_type=payload.source_type, source_file_id=payload.source_file_id, + occurred_on=payload.occurred_on, product_name=payload.product_name, product_category=payload.product_category, + material_type=payload.material_type, supplier=payload.supplier, factory=payload.factory, stage_new=payload.stage_new, @@ -28,6 +30,8 @@ class RiskItemService: defect_level=payload.defect_level, solution=payload.solution, photos=payload.photos, + recognition_confidence=payload.recognition_confidence, + ocr_payload=payload.ocr_payload, submitted_by=user.emp_no, submitted_at=datetime.now(UTC), status="待审核" if pre_result == "通过" else "待预审", @@ -111,9 +115,7 @@ class RiskItemService: raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403) record = await self.session.scalar( - select(ReviewRecord) - .where(ReviewRecord.risk_item_id == item_id) - .order_by(ReviewRecord.id.desc()) + select(ReviewRecord).where(ReviewRecord.risk_item_id == item_id).order_by(ReviewRecord.id.desc()) ) if record is None: raise AppException("审核记录不存在", code=409, status_code=409) @@ -124,7 +126,9 @@ class RiskItemService: if action == "通过": case = AnomalyCase( product_name=item.product_name, + date=item.occurred_on, product_category=item.product_category, + material_type=item.material_type, supplier=item.supplier, factory=item.factory, stage_new=item.stage_new, @@ -132,6 +136,7 @@ class RiskItemService: defect_level=item.defect_level, solution=item.solution, source_type=item.source_type, + source_file=item.source_file_id, import_fingerprint=f"risk-item-{item.id}", ) self.session.add(case) diff --git a/app/services/suppliers.py b/app/services/suppliers.py index af18c54..19d90f9 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -61,7 +61,8 @@ class SupplierProfileService: b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows) tier = self.tier_for(anomaly_total, a_count, b_count) categories = self._categories(rows) - score = await DatabaseRiskScoringService(self.session, as_of=self.as_of).score(supplier=supplier_name) + scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of) + score = await scoring.score(supplier=supplier_name) return SupplierProfileData( supplier_name=supplier_name, tier=tier, @@ -70,7 +71,11 @@ class SupplierProfileService: cooperation_total=None, anomaly_total=anomaly_total, anomaly_rate=None, - frequency_alpha=self.alpha(anomaly_total), + frequency_alpha=self.alpha( + anomaly_total, + coefficient=scoring.frequency_coefficient, + cap=scoring.frequency_cap, + ), risk_score=score.score, main_category_count=len(categories), ), @@ -122,8 +127,8 @@ class SupplierProfileService: return value if value in {"A", "B", "C"} else None @staticmethod - def alpha(anomaly_count: int) -> float: - return round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) + def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float: + return round(min(cap, 1 + anomaly_count / 100 * coefficient), 2) @classmethod def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str: diff --git a/migrations/versions/20260907_0016_add_ocr_fields.py b/migrations/versions/20260907_0016_add_ocr_fields.py new file mode 100644 index 0000000..f8d13fb --- /dev/null +++ b/migrations/versions/20260907_0016_add_ocr_fields.py @@ -0,0 +1,37 @@ +"""Add OCR extraction and judgment fields to risk items.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260907_0016" +down_revision: str | None = "20260907_0015" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("risk_items", sa.Column("occurred_on", sa.Date(), nullable=True)) + op.add_column("risk_items", sa.Column("material_type", sa.String(length=200), nullable=True)) + op.add_column("risk_items", sa.Column("recognition_confidence", sa.Numeric(5, 4), nullable=True)) + op.add_column( + "risk_items", + sa.Column("ocr_payload", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + ) + op.create_index("ix_risk_items_occurred_on", "risk_items", ["occurred_on"]) + op.create_index("ix_risk_items_material_type", "risk_items", ["material_type"]) + op.execute("COMMENT ON COLUMN risk_items.occurred_on IS 'OCR或人工录入的异常发生日期。'") + op.execute("COMMENT ON COLUMN risk_items.material_type IS 'OCR或人工录入的异常物料类型。'") + op.execute("COMMENT ON COLUMN risk_items.recognition_confidence IS 'OCR综合识别置信度,范围0到1。'") + op.execute("COMMENT ON COLUMN risk_items.ocr_payload IS 'OCR检测数值、标准判定和人工确认前的结构化结果。'") + + +def downgrade() -> None: + op.drop_index("ix_risk_items_material_type", table_name="risk_items") + op.drop_index("ix_risk_items_occurred_on", table_name="risk_items") + op.drop_column("risk_items", "ocr_payload") + op.drop_column("risk_items", "recognition_confidence") + op.drop_column("risk_items", "material_type") + op.drop_column("risk_items", "occurred_on") diff --git a/migrations/versions/20260907_0017_four_role_model_config.py b/migrations/versions/20260907_0017_four_role_model_config.py new file mode 100644 index 0000000..2835784 --- /dev/null +++ b/migrations/versions/20260907_0017_four_role_model_config.py @@ -0,0 +1,103 @@ +"""Apply the four-role matrix and add configurable model weights.""" + +import json +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from app.domain.model_config import DEFAULT_WEIGHTS +from sqlalchemy.dialects import postgresql + +revision: str = "20260907_0017" +down_revision: str | None = "20260907_0016" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PERMISSIONS = ( + ("普通用户", "risk.assess", "L1", True, True), + ("普通用户", "standard.read", "L1", True, False), + ("普通用户", "supplier.read", "L1", True, False), + ("product_manager", "risk.assess", "L1", True, True), + ("product_manager", "risk_item.submit", "L1", True, True), + ("product_manager", "risk_item.review", "L2", True, True), + ("product_manager", "standard.read", "L1", True, False), + ("product_manager", "supplier.read", "L2", True, False), + ("reviewer", "risk.assess", "L1", True, True), + ("reviewer", "risk_item.review", "L2", True, True), + ("reviewer", "standard.read", "L1", True, False), + ("reviewer", "supplier.read", "L2", True, False), + ("admin", "risk.assess", "L3", True, True), + ("admin", "risk_item.submit", "L3", True, True), + ("admin", "risk_item.review", "L3", True, True), + ("admin", "model_weight.manage", "L3", True, True), + ("admin", "rbac.manage", "L3", True, True), + ("admin", "standard.read", "L3", True, True), + ("admin", "standard.manage", "L3", True, True), + ("admin", "supplier.read", "L3", True, True), +) + + +def upgrade() -> None: + op.create_table( + "risk_model_configs", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("weights", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("frequency_coefficient", sa.Numeric(5, 4), nullable=False), + sa.Column("frequency_cap", sa.Numeric(4, 2), nullable=False), + sa.Column("updated_by", sa.String(length=20), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + connection = op.get_bind() + connection.execute( + sa.text( + """ + INSERT INTO risk_model_configs (id, weights, frequency_coefficient, frequency_cap) + VALUES (1, CAST(:weights AS jsonb), 0.3, 1.5) + """ + ), + {"weights": json.dumps(DEFAULT_WEIGHTS, ensure_ascii=False)}, + ) + connection.execute( + sa.text( + """ + UPDATE user_accounts + SET role = CASE + WHEN role IN ('dev', 'manager') THEN 'product_manager' + WHEN role IN ('qc', 'reviewer') THEN 'reviewer' + WHEN role IN ('procurement', 'ops', '普通用户') THEN '普通用户' + ELSE role + END, + updated_at = now() + """ + ) + ) + connection.execute(sa.text("DELETE FROM role_permissions")) + statement = sa.text( + """ + INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate) + VALUES (:role, :func_key, :data_level, :can_view, :can_operate) + """ + ) + for role, func_key, data_level, can_view, can_operate in PERMISSIONS: + connection.execute( + statement, + { + "role": role, + "func_key": func_key, + "data_level": data_level, + "can_view": can_view, + "can_operate": can_operate, + }, + ) + op.execute("COMMENT ON TABLE risk_model_configs IS '管理员维护的七维权重与频次系数配置。'") + op.execute("COMMENT ON COLUMN user_accounts.role IS '角色:admin、product_manager、reviewer 或 普通用户。'") + + +def downgrade() -> None: + connection = op.get_bind() + connection.execute( + sa.text("UPDATE user_accounts SET role = 'manager', updated_at = now() WHERE role = 'product_manager'") + ) + connection.execute(sa.text("DELETE FROM role_permissions")) + op.drop_table("risk_model_configs") diff --git a/pyproject.toml b/pyproject.toml index 7a738ba..db007ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastapi>=0.115,<1.0", "httpx>=0.27,<1.0", "openpyxl>=3.1,<4.0", + "python-multipart>=0.0.9,<1.0", "PyJWT>=2.8,<3.0", "pydantic>=2.8,<3.0", "pydantic-settings>=2.4,<3.0", diff --git a/requirements.txt b/requirements.txt index 33183a0..6a14f9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ asyncpg>=0.29,<1.0 fastapi>=0.115,<1.0 httpx>=0.27,<1.0 openpyxl>=3.1,<4.0 +python-multipart>=0.0.9,<1.0 PyJWT>=2.8,<3.0 pydantic>=2.8,<3.0 pydantic-settings>=2.4,<3.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index 6051758..b91d328 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,9 +2,9 @@ from app.services.dingtalk_auth import DingTalkAuthService def test_dingtalk_role_mapping() -> None: - assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev" - assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc" - assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement" + assert DingTalkAuthService.map_role("产品部", "产品经理") == "product_manager" + assert DingTalkAuthService.map_role("品控部", "质量主管") == "普通用户" + assert DingTalkAuthService.map_role("采购部", "采购专员") == "普通用户" assert DingTalkAuthService.map_role("", "审核员") == "reviewer" assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin" assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户" diff --git a/tests/test_baidu_ocr.py b/tests/test_baidu_ocr.py new file mode 100644 index 0000000..0c18144 --- /dev/null +++ b/tests/test_baidu_ocr.py @@ -0,0 +1,132 @@ +import json + +import httpx +import pytest +from app.schemas.ocr import OcrMeasurement +from app.services.baidu_ocr import BaiduOcrClient +from app.services.ocr import OcrService + + +@pytest.mark.asyncio +async def test_webimage_recognition_returns_text_and_confidence() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "token", "expires_in": 3600}) + assert request.url.path.endswith("/webimage") + return httpx.Response( + 200, + json={ + "words_result": [ + {"words": "产品名称:测试面霜", "probability": {"average": 0.92}}, + {"words": "pH:5.6", "probability": {"average": 0.88}}, + ] + }, + ) + + client = BaiduOcrClient(httpx.MockTransport(handler)) + client.api_key = "api-key" + client.secret_key = "secret-key" + text, confidence, pages = await client.recognize_image(b"fake-image") + + assert text == "产品名称:测试面霜\npH:5.6" + assert confidence == pytest.approx(0.9) + assert pages == 1 + + +@pytest.mark.asyncio +async def test_paddle_document_recognition_polls_and_downloads_result() -> None: + calls = {"query": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "token", "expires_in": 3600}) + if request.url.path.endswith("/task/query"): + calls["query"] += 1 + status = "processing" if calls["query"] == 1 else "success" + result = {"status": status} + if status == "success": + result["parse_result_url"] = "https://mock.bcebos.com/result.json" + return httpx.Response(200, json={"error_code": 0, "result": result}) + if request.url.path.endswith("/task"): + return httpx.Response(200, json={"error_code": 0, "result": {"task_id": "task-1"}}) + if request.url.path.endswith("/result.json"): + return httpx.Response( + 200, + content=json.dumps( + { + "pages": [ + { + "text": "产品名称:测试面霜", + "tables": [{"markdown": "|检测项|值|\n|pH|5.6|"}], + } + ] + }, + ensure_ascii=False, + ).encode(), + ) + raise AssertionError(f"unexpected URL: {request.url}") + + client = BaiduOcrClient(httpx.MockTransport(handler)) + client.api_key = "api-key" + client.secret_key = "secret-key" + client.poll_interval = 0.001 + text, confidence, pages = await client.recognize_document(b"%PDF-test", "report.pdf") + + assert "测试面霜" in text + assert "|pH|5.6|" in text + assert confidence is None + assert pages == 1 + assert calls["query"] == 2 + + +@pytest.mark.parametrize( + ("raw_value", "value", "standard", "expected"), + [ + ("5.6", 5.6, "pH 4.5-6.5", "合格"), + ("3.8", 3.8, "色差 ΔE≤3.0", "不合格"), + ("+18%", 18, "喷出量偏差≤±15%", "不合格"), + ("0.9 N·m", 0.9, "开启力矩 0.8~3.2N·m", "合格"), + ], +) +def test_standard_numeric_evaluation(raw_value: str, value: float, standard: str, expected: str) -> None: + measurement = OcrMeasurement( + item="检测项", + raw_value=raw_value, + value=value, + confidence=0.95, + needs_review=False, + ) + result, reason = OcrService.evaluate_standard(measurement, standard) + + assert result == expected + assert reason is None + + +def test_upload_type_is_detected_from_content_not_filename() -> None: + assert OcrService._detect_type(b"%PDF-1.7") == ("application/pdf", ".pdf") + assert OcrService._detect_type(b"\x89PNG\r\n\x1a\nrest") == ("image/png", ".png") + assert OcrService._detect_type(b"\xff\xd8\xffrest") == ("image/jpeg", ".jpg") + + +def test_standard_judgment_rejects_unit_mismatch() -> None: + measurement = OcrMeasurement( + item="铅含量", + raw_value="8mg/L", + value=8, + unit="mg/L", + confidence=0.95, + needs_review=False, + ) + + result, reason = OcrService.evaluate_standard(measurement, "铅≤10mg/kg") + + assert result == "待人工确认" + assert reason == "检测值单位 mg/L 与标准单位 mg/kg 不一致" + + +def test_generic_quantity_does_not_fuzzy_match_a_standard() -> None: + class Rule: + risk_point = "销售包装可视面及最小销售单元完整标注" + judge_standard = "中英文标签内容完整" + + assert OcrService._best_rule("数量", [Rule()]) is None diff --git a/tests/test_database_risk_scoring.py b/tests/test_database_risk_scoring.py index 242b019..882a4c3 100644 --- a/tests/test_database_risk_scoring.py +++ b/tests/test_database_risk_scoring.py @@ -1,7 +1,11 @@ from datetime import date +from decimal import Decimal +from types import SimpleNamespace import pytest +from app.schemas.model_config import ModelWeightConfigUpdate from app.services.database_risk_scoring import DatabaseRiskScoringService +from pydantic import ValidationError def _row(**values): @@ -63,3 +67,52 @@ async def test_supplier_dimension_does_not_count_factory_records() -> None: supplier_dimension = result.dimensions[0] assert supplier_dimension.score == 70 assert supplier_dimension.raw_value == 0 + + +@pytest.mark.asyncio +async def test_score_uses_persisted_weight_and_frequency_config() -> None: + configured_weights = { + "供应商表现": 30, + "物料品类": 15, + "产品品类": 15, + "加工厂质量": 15, + "标准符合性": 10, + "节点阶段": 10, + "缺陷等级": 5, + } + + class Session: + async def get(self, _model, _identifier): + return SimpleNamespace( + weights=configured_weights, + frequency_coefficient=Decimal("0.5"), + frequency_cap=Decimal("1.8"), + ) + + service = DatabaseRiskScoringService(Session(), as_of=date(2026, 9, 4)) + + async def load_rows(): + return [_row(), _row(product_name="洗发水 250ml", defect_level="B")] + + service._load_rows = load_rows # type: ignore[method-assign] + result = await service.score(category="洗发水", material="瓶子", supplier="祥印", stage="产品开发") + + assert [item.weight for item in result.dimensions] == [30, 15, 15, 15, 10, 10, 5] + assert result.dimensions[0].score == 95.95 + + +def test_weight_config_requires_exactly_one_hundred_percent() -> None: + with pytest.raises(ValidationError, match="合计必须为100%"): + ModelWeightConfigUpdate( + weights={ + "供应商表现": 20, + "物料品类": 20, + "产品品类": 15, + "加工厂质量": 15, + "标准符合性": 10, + "节点阶段": 10, + "缺陷等级": 5, + }, + frequency_coefficient=0.3, + frequency_cap=1.5, + ) diff --git a/tests/test_models.py b/tests/test_models.py index 0cb8b82..9643dc3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ from app.models import ( # noqa: F401 ReviewRecord, RiskAssessment, RiskItem, + RiskModelConfig, RolePermission, StandardRule, SupplierProfile, @@ -24,6 +25,7 @@ def test_prd_core_tables_are_registered() -> None: "review_records", "risk_assessments", "risk_items", + "risk_model_configs", "role_permissions", "standard_rules", "supplier_profiles", From 680b0c20cfe35f8a295d72ee29c050ebafc64596 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Tue, 8 Sep 2026 11:48:24 +0800 Subject: [PATCH 05/24] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=87=87=E8=B4=AD?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=8F=B0=E8=B4=A6=E5=B9=B6=E7=BB=9F=E4=B8=80?= =?UTF-8?q?PDF=E4=B8=8EWord=E6=8A=A5=E5=91=8A=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按供应商近三年去重采购单据统计真实合作频次,替换异常记录代理值;新增采购订单导入、合作频次计算、风险评分口径同步,以及PDF/Word报告生成接口。 --- app/api/router.py | 2 + app/api/v1/reports.py | 38 ++ app/models/__init__.py | 2 + app/models/entities.py | 30 ++ app/schemas/risk_prediction.py | 2 +- app/schemas/suppliers.py | 2 +- app/services/conversations.py | 55 ++- app/services/database_risk_scoring.py | 35 +- app/services/purchase_orders.py | 130 ++++++ app/services/reports.py | 377 ++++++++++++++++++ app/services/suppliers.py | 16 +- .../20260908_0018_purchase_order_ledger.py | 48 +++ pyproject.toml | 2 + requirements.txt | 2 + scripts/import_purchase_orders.py | 32 ++ tests/test_conversations.py | 10 +- tests/test_models.py | 1 + 17 files changed, 755 insertions(+), 29 deletions(-) create mode 100644 app/api/v1/reports.py create mode 100644 app/services/purchase_orders.py create mode 100644 app/services/reports.py create mode 100644 migrations/versions/20260908_0018_purchase_order_ledger.py create mode 100644 scripts/import_purchase_orders.py diff --git a/app/api/router.py b/app/api/router.py index 4cc5194..9e2c9d8 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -8,6 +8,7 @@ from app.api.v1.dashboard import router as dashboard_router from app.api.v1.health import router as health_router from app.api.v1.model_config import router as model_config_router from app.api.v1.ocr import router as ocr_router +from app.api.v1.reports import router as reports_router from app.api.v1.risk_items import router as risk_items_router from app.api.v1.risk_predictions import router as risk_predictions_router from app.api.v1.standards import router as standards_router @@ -21,6 +22,7 @@ api_router.include_router(auth_router) api_router.include_router(conversations_router) api_router.include_router(dashboard_router) api_router.include_router(risk_items_router) +api_router.include_router(reports_router) api_router.include_router(risk_predictions_router) api_router.include_router(standards_router) api_router.include_router(suppliers_router) diff --git a/app/api/v1/reports.py b/app/api/v1/reports.py new file mode 100644 index 0000000..6692d1d --- /dev/null +++ b/app/api/v1/reports.py @@ -0,0 +1,38 @@ +"""Downloadable risk report endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import Response + +from app.api.dependencies import require_permission +from app.models.entities import UserAccount +from app.schemas.risk_prediction import RiskAssessmentResult +from app.services.reports import build_risk_report_docx, build_risk_report_pdf + +router = APIRouter(prefix="/reports", tags=["reports"]) + + +@router.post("/pdf") +async def download_report_pdf( + payload: RiskAssessmentResult, + _current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> Response: + pdf = build_risk_report_pdf(payload) + return Response( + content=pdf, + media_type="application/pdf", + headers={"Content-Disposition": 'attachment; filename="dieya-risk-report.pdf"'}, + ) + + +@router.post("/word") +async def download_report_word( + payload: RiskAssessmentResult, + _current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> Response: + return Response( + content=build_risk_report_docx(payload), + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": 'attachment; filename="dieya-risk-report.docx"'}, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 6f86600..4eafc59 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.entities import ( Conversation, ConversationMessage, OperationAudit, + PurchaseOrderLine, ReviewRecord, RiskAssessment, RiskItem, @@ -24,6 +25,7 @@ __all__ = [ "RiskAssessment", "RiskItem", "RiskModelConfig", + "PurchaseOrderLine", "RolePermission", "StandardRule", "SupplierProfile", diff --git a/app/models/entities.py b/app/models/entities.py index 6328709..253ca86 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -94,6 +94,36 @@ class SupplierProfile(Base): ) +class PurchaseOrderLine(Base): + """A source row from the procurement order ledger.""" + + __tablename__ = "purchase_order_lines" + __table_args__ = ( + Index("ix_purchase_order_lines_supplier_date", "supplier", "purchase_date"), + Index("ix_purchase_order_lines_document", "document_number"), + Index("uq_purchase_order_lines_source", "import_fingerprint", unique=True), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + document_number: Mapped[str] = mapped_column(String(64), nullable=False) + purchase_date: Mapped[date | None] = mapped_column(Date) + supplier: Mapped[str] = mapped_column(String(300), nullable=False) + document_status: Mapped[str | None] = mapped_column(String(30)) + close_status: Mapped[str | None] = mapped_column(String(30)) + material_code: Mapped[str | None] = mapped_column(String(200)) + material_name: Mapped[str | None] = mapped_column(Text) + purchase_unit: Mapped[str | None] = mapped_column(String(30)) + purchase_quantity: Mapped[Decimal | None] = mapped_column(Numeric(18, 4)) + delivery_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + tax_inclusive_unit_price: Mapped[Decimal | None] = mapped_column(Numeric(18, 6)) + is_gift: Mapped[str | None] = mapped_column(String(10)) + business_close: Mapped[str | None] = mapped_column(String(30)) + remark: Mapped[str | None] = mapped_column(Text) + source_file: Mapped[str] = mapped_column(String(300), nullable=False) + import_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + class RiskAssessment(Base): """A user-triggered risk assessment and its structured result.""" diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index f3af422..4cf102f 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -46,7 +46,7 @@ class SupplierFrequency(ApiSchema): alpha: float | None = None tier: str | None = None suggestion: str | None = None - frequency_basis: str = "近3年异常记录数" + frequency_basis: str = "暂无匹配采购订单" note: str | None = None data_available: bool = False diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index 5417498..7421307 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -23,7 +23,7 @@ class SupplierMetric(ApiSchema): frequency_alpha: float risk_score: float | None = None main_category_count: int - cooperation_count_basis: str = "暂无采购订单台账;合作次数不可由异常记录推算" + cooperation_count_basis: str = "按近3年采购订单去重单据编号统计" anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率" diff --git a/app/services/conversations.py b/app/services/conversations.py index 9d57f15..25408e4 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -29,6 +29,7 @@ from app.schemas.risk_prediction import ( ) from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.llm_client import LLMClient +from app.services.purchase_orders import supplier_order_counts from app.services.rag import AnomalyCaseRAG logger = logging.getLogger("dieya_risk_prediction.conversations") @@ -556,6 +557,18 @@ class ConversationService: is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text column = AnomalyCase.factory if is_factory else AnomalyCase.supplier anomaly_count, a_count, b_count = await self._supplier_history_stats(column, name) + cooperation_count = ( + await supplier_order_counts( + self.session, + name, + self._supplier_history_start(), + datetime.now(UTC).date(), + ) + if hasattr(self, "session") + else None + ) + has_order_data = bool(cooperation_count) + cooperation_display = cooperation_count if has_order_data else None problems = await self._top_anomaly_types( column, name, @@ -569,14 +582,20 @@ class ConversationService: "name": name, "partyType": party_type, "anomalyCount": anomaly_count, - "cooperationCount": anomaly_count, + "cooperationCount": cooperation_display, "tier": tier, - "frequencyCoefficient": round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) if anomaly_count else 1.0, + "frequencyCoefficient": round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2) + if cooperation_display is not None + else 1.0, "topProblems": problems, "suggestions": self._supplier_management_suggestions(tier), - "frequencyBasis": "近3年异常记录数", - "note": "合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。", - "dataAvailable": True, + "frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单", + "note": ( + "合作次数按近3年采购订单去重单据编号统计。" + if has_order_data + else "未匹配到该供应商采购订单,不展示合作次数。" + ), + "dataAvailable": has_order_data, } async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: @@ -973,8 +992,18 @@ class ConversationService: anomaly_type=self._semantic_text(semantics, "anomaly_type"), ) - alpha_count = supplier_count if supplier else 0 - alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0 + cooperation_count = ( + await supplier_order_counts( + self.session, + supplier, + self._supplier_history_start(), + datetime.now(UTC).date(), + ) + if supplier + else None + ) + alpha_count = cooperation_count or 0 + alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0 tier = ( self._supplier_tier( alpha_count, @@ -1048,14 +1077,18 @@ class ConversationService: "supplier_freq": ( SupplierFrequency( supplier_name=supplier, - cooperation_count=supplier_count, + cooperation_count=cooperation_count, anomaly_count=supplier_count, alpha=alpha, tier=tier, suggestion=";".join(self._supplier_management_suggestions(tier)), - frequency_basis="近3年异常记录数", - note="合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。", - data_available=True, + frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单", + note=( + "合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。" + if cooperation_count + else "未匹配到该供应商采购订单,不展示合作次数。" + ), + data_available=bool(cooperation_count), ) if supplier else SupplierFrequency( diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index 5531c06..d007f0a 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS from app.domain.risk_engine import RiskLevel from app.models.entities import AnomalyCase, RiskModelConfig +from app.services.purchase_orders import supplier_order_counts STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") @@ -66,8 +67,9 @@ class DatabaseRiskScoringService: stage_name = normalize_stage(stage) party_name = clean_value(supplier) + cooperation_count = await self._supplier_order_count(party_name) dimensions = ( - self._supplier_dimension(rows, party_name), + self._supplier_dimension(rows, party_name, cooperation_count), self._material_dimension(rows, material_name), self._category_dimension(rows, category_name), DimensionScore( @@ -90,6 +92,19 @@ class DatabaseRiskScoringService: score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) + async def _supplier_order_count(self, party_name: str | None) -> int | None: + if not party_name or not hasattr(self.session, "execute"): + return None + try: + return await supplier_order_counts( + self.session, + party_name, + self.as_of - timedelta(days=365 * 3), + self.as_of, + ) + except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet + return None + async def _load_model_config(self) -> None: get = getattr(self.session, "get", None) if get is None: @@ -121,7 +136,12 @@ class DatabaseRiskScoringService: ) return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] - def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore: + def _supplier_dimension( + self, + rows: list[dict[str, Any]], + party_name: str | None, + cooperation_count: int | None = None, + ) -> DimensionScore: weight = self.weights["供应商表现"] if not party_name: return DimensionScore( @@ -132,7 +152,8 @@ class DatabaseRiskScoringService: reason="未提供供应商/加工厂,按 PRD 缺失维度默认 50 分", ) matched = [row for row in rows if party_matches(row, party_name)] - count = len(matched) + anomaly_count = len(matched) + count = cooperation_count if cooperation_count is not None and cooperation_count > 0 else anomaly_count if count == 0: base = 70.0 elif count <= 20: @@ -170,8 +191,12 @@ class DatabaseRiskScoringService: score=score, weight=weight, status="database", - reason=f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}", - sample_size=count, + reason=( + f"近 3 年采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}" + if cooperation_count is not None and cooperation_count > 0 + else f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}" + ), + sample_size=anomaly_count, raw_value=count, ) diff --git a/app/services/purchase_orders.py b/app/services/purchase_orders.py new file mode 100644 index 0000000..d104043 --- /dev/null +++ b/app/services/purchase_orders.py @@ -0,0 +1,130 @@ +"""Procurement-order ledger import and supplier cooperation aggregation.""" + +from __future__ import annotations + +import hashlib +import re +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from openpyxl import load_workbook +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.entities import PurchaseOrderLine + + +def clean_text(value: Any) -> str | None: + if value is None: + return None + text = str(value).replace("\u200b", "").strip() + return text or None + + +def parse_date(value: Any) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text = clean_text(value) + if not text: + return None + text = re.sub(r"\s+.*$", "", text).replace("/", "-").replace("年", "-").replace("月", "-").replace("日", "") + for fmt in ("%Y-%m-%d", "%Y.%m.%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + +def parse_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value + text = clean_text(value) + if not text: + return None + for fmt in ("%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%d"): + try: + return datetime.strptime(text, fmt) + except ValueError: + continue + return None + + +def fingerprint(file_name: str, row_number: int) -> str: + return hashlib.sha256(f"{file_name}\x1fSheet1\x1f{row_number}".encode()).hexdigest() + + +def read_purchase_order_rows(path: Path) -> list[dict[str, Any]]: + workbook = load_workbook(path, read_only=True, data_only=True) + sheet = workbook.active + headers = [clean_text(value) for value in next(sheet.iter_rows(values_only=True))] + required = ["单据编号", "采购日期", "供应商"] + missing = [name for name in required if name not in headers] + if missing: + raise ValueError(f"采购订单缺少必要字段:{', '.join(missing)}") + indexes = {name: headers.index(name) for name in headers if name} + + def value(row: tuple[Any, ...], name: str) -> Any: + index = indexes.get(name) + return row[index] if index is not None and index < len(row) else None + + rows: list[dict[str, Any]] = [] + for row_number, raw in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + document = clean_text(value(raw, "单据编号")) + supplier = clean_text(value(raw, "供应商")) + if not document or not supplier: + continue + rows.append( + { + "document_number": document, + "purchase_date": parse_date(value(raw, "采购日期")), + "supplier": supplier, + "document_status": clean_text(value(raw, "单据状态")), + "close_status": clean_text(value(raw, "关闭状态")), + "material_code": clean_text(value(raw, "物料编码")), + "material_name": clean_text(value(raw, "物料名称")), + "purchase_unit": clean_text(value(raw, "采购单位")), + "purchase_quantity": value(raw, "采购数量"), + "delivery_date": parse_datetime(value(raw, "交货日期")), + "tax_inclusive_unit_price": value(raw, "含税单价"), + "is_gift": clean_text(value(raw, "是否赠品")), + "business_close": clean_text(value(raw, "业务关闭")), + "remark": clean_text(value(raw, "备注")), + "source_file": path.name, + "import_fingerprint": fingerprint(path.name, row_number), + } + ) + return rows + + +async def import_purchase_orders(session: AsyncSession, path: Path) -> dict[str, int]: + rows = read_purchase_order_rows(path) + inserted = 0 + for start in range(0, len(rows), 500): + batch = rows[start : start + 500] + result = await session.execute( + insert(PurchaseOrderLine).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"]) + ) + inserted += result.rowcount or 0 + await session.flush() + return {"source_rows": len(rows), "inserted_rows": inserted, "skipped_rows": len(rows) - inserted} + + +async def supplier_order_counts( + session: AsyncSession, + supplier: str, + start: date, + end: date, +) -> int: + value = await session.scalar( + select(func.count(func.distinct(PurchaseOrderLine.document_number))).where( + PurchaseOrderLine.supplier.ilike(f"%{supplier}%"), + PurchaseOrderLine.purchase_date >= start, + PurchaseOrderLine.purchase_date <= end, + ) + ) + return int(value or 0) diff --git a/app/services/reports.py b/app/services/reports.py new file mode 100644 index 0000000..a9192d2 --- /dev/null +++ b/app/services/reports.py @@ -0,0 +1,377 @@ +"""PDF rendering for the prototype-aligned risk report.""" + +from io import BytesIO +from pathlib import Path + +from docx import Document +from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.shared import Pt, RGBColor +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle + +from app.schemas.risk_prediction import RiskAssessmentResult + +LEVEL_LABELS = { + "low": "低风险", + "moderately_low": "较低风险", + "medium": "中等风险", + "high": "高风险", + "very_high": "极高风险", +} + + +def _font_name() -> str: + candidates = [ + Path("C:/Windows/Fonts/msyh.ttc"), + Path("C:/Windows/Fonts/msyh.ttf"), + Path("C:/Windows/Fonts/simhei.ttf"), + ] + for path in candidates: + if path.exists(): + name = "DieyaCjk" + pdfmetrics.registerFont(TTFont(name, str(path), subfontIndex=0)) + return name + return "Helvetica" + + +def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: + font = _font_name() + styles = getSampleStyleSheet() + title = ParagraphStyle( + "title", parent=styles["Title"], fontName=font, fontSize=20, alignment=TA_CENTER, spaceAfter=4 + ) + subtitle = ParagraphStyle( + "subtitle", + parent=styles["Normal"], + fontName=font, + fontSize=9, + textColor=colors.HexColor("#64748b"), + alignment=TA_CENTER, + spaceAfter=14, + ) + section = ParagraphStyle( + "section", + parent=styles["Heading2"], + fontName=font, + fontSize=11, + textColor=colors.HexColor("#111827"), + leftIndent=5, + borderPadding=3, + spaceBefore=12, + spaceAfter=7, + ) + body = ParagraphStyle("body", parent=styles["BodyText"], fontName=font, fontSize=9, leading=15) + small = ParagraphStyle("small", parent=body, fontSize=8, textColor=colors.HexColor("#64748b")) + supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None + report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}" + generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M") + story = [ + Paragraph("新品风险预估报告", title), + Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle), + Paragraph( + f"产品/评估对象:{result.subject_id}  供应商:{supplier_name or '未指定'}  报告编号:{report_number}", + body, + ), + ] + + def table(rows: list[list[str]], widths: list[float] | None = None) -> Table: + converted = [[Paragraph(str(cell), body) for cell in row] for row in rows] + item = Table(converted, colWidths=widths, repeatRows=1) + item.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#111827")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTNAME", (0, 0), (-1, -1), font), + ("GRID", (0, 0), (-1, -1), 0.45, colors.HexColor("#dbe3ef")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (-1, -1), 7), + ("RIGHTPADDING", (0, 0), (-1, -1), 7), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + return item + + story += [Paragraph("一、风险结论", section)] + rows = [ + ["评估项", "结论"], + [ + "综合评分", + f"{round(result.score)} / 100 · " + f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · " + f"{result.risk_badge or '需关注'}", + ], + ["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"], + ] + dimensions = result.dimension_scores or [ + {"dimension": factor.name, "score": 100 - factor.value, "weight": factor.weight, "reason": None} + for factor in result.factors + ] + for item in dimensions: + name = item.dimension if hasattr(item, "dimension") else item["dimension"] + if name in {"节点阶段", "缺陷等级"}: + continue + score = item.score if hasattr(item, "score") else item["score"] + weight = item.weight if hasattr(item, "weight") else item["weight"] + reason = item.reason if hasattr(item, "reason") else item.get("reason") + supplier = result.supplier_freq + if name == "供应商表现" and supplier: + order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else "" + conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次" + elif name == "标准符合性" and result.standard_judgment: + conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认" + else: + conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "") + rows.append([f"{name}({weight:g}%)", conclusion]) + story += [table(rows, [55 * mm, 105 * mm])] + + if result.category_warning: + story += [ + Paragraph("二、品类风险预警", section), + table( + [ + ["品类", "历史异常", "风险等级", "建议"], + [ + result.category_warning.category, + f"{result.category_warning.anomaly_count} 条", + result.category_warning.risk_level, + result.category_warning.suggestion or "结合相似品类和标准库预判", + ], + ], + [35 * mm, 30 * mm, 30 * mm, 65 * mm], + ), + ] + + supplier = result.supplier_freq + if supplier and supplier.supplier_name: + supplier_rows = [["维度", "数值", "说明"]] + if supplier.cooperation_count is not None: + supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"]) + supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"]) + if supplier.alpha is not None: + supplier_rows.append( + ["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] + ) + if supplier.tier: + supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""]) + story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])] + + story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)] + if result.similar_cases: + story.append( + table( + [["日期", "产品", "问题", "处理方案"]] + + [ + [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] + for case in result.similar_cases + ], + [28 * mm, 42 * mm, 48 * mm, 42 * mm], + ) + ) + else: + story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small)) + + story += [Paragraph("五、预防建议", section)] + for index, suggestion in enumerate( + result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1 + ): + story.append(Paragraph(f"{index}. {suggestion}", body)) + story += [ + Paragraph("六、AI 推理方案(无历史案例场景)", section), + Paragraph( + "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。", + body, + ), + ] + + story += [Paragraph("七、标准判定结果", section)] + if result.standard_judgment: + story.append( + table( + [["判定维度", "判定标准", "标准来源", "当前状态"]] + + [ + [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] + for item in result.standard_judgment + ], + [38 * mm, 55 * mm, 40 * mm, 27 * mm], + ) + ) + else: + story.append(Paragraph("暂无相关标准。", small)) + story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small)) + + output = BytesIO() + doc = SimpleDocTemplate( + output, + pagesize=A4, + rightMargin=18 * mm, + leftMargin=18 * mm, + topMargin=16 * mm, + bottomMargin=16 * mm, + title="蝶雅新品风险预估报告", + ) + doc.build(story) + return output.getvalue() + + +def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: + document = Document() + section = document.sections[0] + section.page_width = 210 * 36000 + section.page_height = 297 * 36000 + section.top_margin = 16 * 36000 + section.bottom_margin = 16 * 36000 + section.left_margin = 18 * 36000 + section.right_margin = 18 * 36000 + normal = document.styles["Normal"] + normal.font.name = "微软雅黑" + normal.font.size = Pt(9) + title = document.add_paragraph() + title.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = title.add_run("新品风险预估报告") + run.bold = True + run.font.size = Pt(20) + subtitle = document.add_paragraph() + subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER + subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9) + + def heading(text: str) -> None: + p = document.add_paragraph() + p.paragraph_format.space_before = Pt(12) + p.paragraph_format.space_after = Pt(6) + p.paragraph_format.left_indent = Pt(5) + r = p.add_run(text) + r.bold = True + r.font.size = Pt(11) + + def add_table(rows: list[list[str]]) -> None: + table = document.add_table(rows=len(rows), cols=len(rows[0])) + table.style = "Table Grid" + table.autofit = True + for row_index, row in enumerate(rows): + for col_index, value in enumerate(row): + cell = table.cell(row_index, col_index) + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.TOP + cell.text = str(value) + for paragraph in cell.paragraphs: + paragraph.paragraph_format.space_after = Pt(0) + for run in paragraph.runs: + run.font.name = "微软雅黑" + run.font.size = Pt(9) + if row_index == 0: + run.bold = True + run.font.color.rgb = RGBColor(255, 255, 255) + if row_index == 0: + shading = OxmlElement("w:shd") + shading.set("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}fill", "111827") + cell._tc.get_or_add_tcPr().append(shading) + document.add_paragraph() + + heading("一、风险结论") + rows = [ + ["评估项", "结论"], + [ + "综合评分", + f"{round(result.score)} / 100 · " + f"{LEVEL_LABELS.get(result.level.value, result.level.value)} · " + f"{result.risk_badge or '需关注'}", + ], + ["风险警示", result.alert_text or "当前暂无足够历史数据,建议按标准库完成基础验证。"], + ] + dimensions = result.dimension_scores or [ + { + "dimension": factor.name, + "score": 100 - factor.value, + "weight": factor.weight, + "reason": None, + } + for factor in result.factors + ] + for item in dimensions: + name = item.dimension if hasattr(item, "dimension") else item["dimension"] + if name in {"节点阶段", "缺陷等级"}: + continue + score = item.score if hasattr(item, "score") else item["score"] + weight = item.weight if hasattr(item, "weight") else item["weight"] + reason = item.reason if hasattr(item, "reason") else item.get("reason") + supplier = result.supplier_freq + if name == "供应商表现" and supplier: + order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else "" + conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次" + elif name == "标准符合性" and result.standard_judgment: + conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认" + else: + conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "") + rows.append([f"{name}({weight:g}%)", conclusion]) + add_table(rows) + if result.category_warning: + heading("二、品类风险预警") + add_table( + [ + ["品类", "历史异常", "风险等级", "建议"], + [ + result.category_warning.category, + f"{result.category_warning.anomaly_count} 条", + result.category_warning.risk_level, + result.category_warning.suggestion or "结合相似品类和标准库预判", + ], + ] + ) + if result.supplier_freq and result.supplier_freq.supplier_name: + heading("三、供应商合作频次分析") + rows = [["维度", "数值", "说明"]] + if result.supplier_freq.cooperation_count is not None: + rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"]) + rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"]) + if result.supplier_freq.alpha is not None: + rows.append( + ["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] + ) + if result.supplier_freq.tier: + rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""]) + add_table(rows) + heading(f"四、相似案例匹配({len(result.similar_cases)} 条)") + if result.similar_cases: + add_table( + [["日期", "产品", "问题", "处理方案"]] + + [ + [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] + for case in result.similar_cases + ] + ) + else: + document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。") + heading("五、预防建议") + for index, suggestion in enumerate( + result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1 + ): + document.add_paragraph(f"{index}. {suggestion}") + heading("六、AI 推理方案(无历史案例场景)") + document.add_paragraph( + "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。" + ) + heading("七、标准判定结果") + if result.standard_judgment: + add_table( + [["判定维度", "判定标准", "标准来源", "当前状态"]] + + [ + [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] + for item in result.standard_judgment + ] + ) + else: + document.add_paragraph("暂无相关标准。") + document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。") + output = BytesIO() + document.save(output) + return output.getvalue() diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 19d90f9..84003d6 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -19,6 +19,7 @@ from app.schemas.suppliers import ( SupplierRecentRecord, ) from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category +from app.services.purchase_orders import supplier_order_counts class SupplierProfileService: @@ -57,6 +58,12 @@ class SupplierProfileService: raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404) anomaly_total = len(rows) + order_count = await supplier_order_counts( + self.session, + supplier_name, + self.as_of - timedelta(days=self.history_days), + self.as_of, + ) a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows) b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows) tier = self.tier_for(anomaly_total, a_count, b_count) @@ -68,11 +75,11 @@ class SupplierProfileService: tier=tier, tier_condition=self.tier_condition(tier), metric=SupplierMetric( - cooperation_total=None, + cooperation_total=order_count, anomaly_total=anomaly_total, anomaly_rate=None, frequency_alpha=self.alpha( - anomaly_total, + order_count, coefficient=scoring.frequency_coefficient, cap=scoring.frequency_cap, ), @@ -83,10 +90,7 @@ class SupplierProfileService: monthly_trend=self._monthly_trend(rows), recent_records=self._recent_records(rows), factory_recommendations=[], - recommendation_note=( - "合作次数、供应商真实异常率和代工厂真实异常率需要采购订单或生产批次总量;" - "当前已接入近3年异常库聚合,但不会用异常记录数伪造上述指标。" - ), + recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", ) async def _load_rows(self) -> list[dict]: diff --git a/migrations/versions/20260908_0018_purchase_order_ledger.py b/migrations/versions/20260908_0018_purchase_order_ledger.py new file mode 100644 index 0000000..0adaba4 --- /dev/null +++ b/migrations/versions/20260908_0018_purchase_order_ledger.py @@ -0,0 +1,48 @@ +"""Create the procurement-order ledger used for real supplier cooperation counts.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260908_0018" +down_revision: str | None = "20260907_0017" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "purchase_order_lines", + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("document_number", sa.String(length=64), nullable=False), + sa.Column("purchase_date", sa.Date(), nullable=True), + sa.Column("supplier", sa.String(length=300), nullable=False), + sa.Column("document_status", sa.String(length=30), nullable=True), + sa.Column("close_status", sa.String(length=30), nullable=True), + sa.Column("material_code", sa.String(length=200), nullable=True), + sa.Column("material_name", sa.Text(), nullable=True), + sa.Column("purchase_unit", sa.String(length=30), nullable=True), + sa.Column("purchase_quantity", sa.Numeric(18, 4), nullable=True), + sa.Column("delivery_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("tax_inclusive_unit_price", sa.Numeric(18, 6), nullable=True), + sa.Column("is_gift", sa.String(length=10), nullable=True), + sa.Column("business_close", sa.String(length=30), nullable=True), + sa.Column("remark", sa.Text(), nullable=True), + sa.Column("source_file", sa.String(length=300), nullable=False), + sa.Column("import_fingerprint", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("import_fingerprint"), + ) + op.create_index("ix_purchase_order_lines_supplier_date", "purchase_order_lines", ["supplier", "purchase_date"]) + op.create_index("ix_purchase_order_lines_document", "purchase_order_lines", ["document_number"]) + op.create_index("uq_purchase_order_lines_source", "purchase_order_lines", ["import_fingerprint"], unique=True) + op.execute("COMMENT ON TABLE purchase_order_lines IS '采购订单明细台账;供应商合作频次按单据编号去重统计。'") + + +def downgrade() -> None: + op.drop_index("uq_purchase_order_lines_source", table_name="purchase_order_lines") + op.drop_index("ix_purchase_order_lines_document", table_name="purchase_order_lines") + op.drop_index("ix_purchase_order_lines_supplier_date", table_name="purchase_order_lines") + op.drop_table("purchase_order_lines") diff --git a/pyproject.toml b/pyproject.toml index db007ed..d203934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "httpx>=0.27,<1.0", "openpyxl>=3.1,<4.0", "python-multipart>=0.0.9,<1.0", + "reportlab>=4.0,<5.0", + "python-docx>=1.1,<2.0", "PyJWT>=2.8,<3.0", "pydantic>=2.8,<3.0", "pydantic-settings>=2.4,<3.0", diff --git a/requirements.txt b/requirements.txt index 6a14f9b..629f82a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ fastapi>=0.115,<1.0 httpx>=0.27,<1.0 openpyxl>=3.1,<4.0 python-multipart>=0.0.9,<1.0 +reportlab>=4.0,<5.0 +python-docx>=1.1,<2.0 PyJWT>=2.8,<3.0 pydantic>=2.8,<3.0 pydantic-settings>=2.4,<3.0 diff --git a/scripts/import_purchase_orders.py b/scripts/import_purchase_orders.py new file mode 100644 index 0000000..83e81ae --- /dev/null +++ b/scripts/import_purchase_orders.py @@ -0,0 +1,32 @@ +"""Import a procurement-order workbook into the purchase order ledger.""" + +import argparse +import asyncio +import logging +from pathlib import Path + +from app.core.config import get_settings +from app.database.session import SessionFactory +from app.services.purchase_orders import import_purchase_orders + + +async def run(path: Path) -> None: + async with SessionFactory() as session: + result = await import_purchase_orders(session, path) + await session.commit() + for key, value in result.items(): + print(f"{key}={value}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--file", type=Path, required=True) + args = parser.parse_args() + if not args.file.is_file(): + parser.error(f"file does not exist: {args.file}") + logging.basicConfig(level=get_settings().log_level) + asyncio.run(run(args.file)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_conversations.py b/tests/test_conversations.py index cd2b809..fb840ff 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -229,12 +229,12 @@ async def test_supplier_card_uses_prd_proxy_frequency_and_tier_advice( ) assert result["anomalyCount"] == 70 - assert result["cooperationCount"] == 70 - assert result["frequencyCoefficient"] == 1.21 + assert result["cooperationCount"] is None + assert result["frequencyCoefficient"] == 1.0 assert result["tier"] == "B级 · 关注" assert result["suggestions"] == ["加强巡检", "新品打样加严"] - assert result["frequencyBasis"] == "近3年异常记录数" - assert result["dataAvailable"] is True + assert result["frequencyBasis"] == "暂无匹配采购订单" + assert result["dataAvailable"] is False def test_new_supplier_frequency_serializes_zero_proxy_count() -> None: @@ -249,7 +249,7 @@ def test_new_supplier_frequency_serializes_zero_proxy_count() -> None: assert result["cooperationCount"] == 0 assert result["anomalyCount"] == 0 - assert result["frequencyBasis"] == "近3年异常记录数" + assert result["frequencyBasis"] == "暂无匹配采购订单" def test_unspecified_supplier_returns_displayable_no_data_frequency_card() -> None: diff --git a/tests/test_models.py b/tests/test_models.py index 9643dc3..eacd546 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -26,6 +26,7 @@ def test_prd_core_tables_are_registered() -> None: "risk_assessments", "risk_items", "risk_model_configs", + "purchase_order_lines", "role_permissions", "standard_rules", "supplier_profiles", From 4562aa6bdcecb4fbcd80ff4749d1f11a49825d37 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Tue, 8 Sep 2026 18:27:27 +0800 Subject: [PATCH 06/24] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E6=8E=A5=E5=8F=A3=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=A1=88=E4=BE=8B=E7=BB=93=E6=9E=9C=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/router.py | 2 ++ app/api/v1/monitoring.py | 28 ++++++++++++++++++++++++++++ app/schemas/risk_prediction.py | 1 + app/services/rag.py | 4 +++- 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/monitoring.py diff --git a/app/api/router.py b/app/api/router.py index 9e2c9d8..05b141a 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -7,6 +7,7 @@ from app.api.v1.conversations import router as conversations_router from app.api.v1.dashboard import router as dashboard_router from app.api.v1.health import router as health_router from app.api.v1.model_config import router as model_config_router +from app.api.v1.monitoring import router as monitoring_router from app.api.v1.ocr import router as ocr_router from app.api.v1.reports import router as reports_router from app.api.v1.risk_items import router as risk_items_router @@ -17,6 +18,7 @@ from app.api.v1.suppliers import router as suppliers_router api_router = APIRouter(prefix="/api/v1") api_router.include_router(health_router) api_router.include_router(model_config_router) +api_router.include_router(monitoring_router) api_router.include_router(ocr_router) api_router.include_router(auth_router) api_router.include_router(conversations_router) diff --git a/app/api/v1/monitoring.py b/app/api/v1/monitoring.py new file mode 100644 index 0000000..e47151b --- /dev/null +++ b/app/api/v1/monitoring.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import OperationAudit, UserAccount +from app.schemas.common import ApiResponse + +router = APIRouter(prefix="/monitoring", tags=["monitoring"]) + + +@router.post("/risk") +async def monitor_risk( + payload: dict, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[dict]: + session.add( + OperationAudit( + actor_user_id=current_user.id, + actor_emp_no=current_user.emp_no, + action="risk.monitor.add", + resource_type="risk_assessment", + resource_id=str(payload.get("subject_id") or ""), + audit_metadata=payload, + ) + ) + return ApiResponse(data={"monitored": True, "subject_id": payload.get("subject_id")}) diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 4cf102f..6926af1 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -79,6 +79,7 @@ class SimilarCase(ApiSchema): description: str | None = None root_cause: str | None = None defect_level: str | None = None + result: str | None = None problem: str | None = None solution: str | None = None similarity: float | None = None diff --git a/app/services/rag.py b/app/services/rag.py index 5e50a1b..29c70cc 100644 --- a/app/services/rag.py +++ b/app/services/rag.py @@ -123,6 +123,7 @@ class AnomalyCaseRAG: AnomalyCase.solution, AnomalyCase.root_cause, AnomalyCase.defect_level, + AnomalyCase.result, ) vector_ready = await self._vector_column_ready() has_vectors = await self._has_stored_vectors() if vector_ready else False @@ -135,7 +136,7 @@ class AnomalyCaseRAG: """ SELECT id, date, product_name, product_category, material_type, supplier, factory, stage_new, anomaly_type, description, solution, root_cause, - defect_level, 1 - (embedding_vector <=> CAST(:embedding AS halfvec)) AS vector_score + defect_level, result, 1 - (embedding_vector <=> CAST(:embedding AS halfvec)) AS vector_score FROM anomaly_cases WHERE embedding_vector IS NOT NULL ORDER BY embedding_vector <=> CAST(:embedding AS halfvec) @@ -306,6 +307,7 @@ class AnomalyCaseRAG: description=row.get("description"), root_cause=row.get("root_cause"), defect_level=row.get("defect_level"), + result=row.get("result"), problem=(row.get("anomaly_type") or row.get("description") or "历史异常未填写"), solution=(row.get("solution") or row.get("root_cause") or "请查看原始案例记录"), ) From 5c7163d79ae638923fe438686b240919acd43626 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 10:45:38 +0800 Subject: [PATCH 07/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=BB=9F=E8=AE=A1=E5=8F=A3=E5=BE=84=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=90=8E=E7=AB=AF=E6=9F=A5=E8=AF=A2=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一对话页与供应商画像的采购订单、异常数、基础分、频次系数、供应商得分和层级计算;补充供应商维度计算结果字段,保留未归类品类标记并优化供应商列表聚合查询;增加响应压缩并完善供应商统计测试。 --- app/main.py | 2 + app/schemas/suppliers.py | 3 + app/services/conversations.py | 39 ++++++-- app/services/database_risk_scoring.py | 24 +++-- app/services/suppliers.py | 123 +++++++++++++++++++++----- tests/test_suppliers.py | 26 ++++++ 6 files changed, 183 insertions(+), 34 deletions(-) diff --git a/app/main.py b/app/main.py index 6ffe676..10946e7 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from starlette.middleware.gzip import GZipMiddleware from app.api.router import api_router from app.core.config import Settings, get_settings @@ -25,6 +26,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "Accept"], ) + application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5) application.add_exception_handler(AppException, app_exception_handler) application.include_router(api_router) return application diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index 7421307..dbbb959 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -21,6 +21,8 @@ class SupplierMetric(ApiSchema): anomaly_total: int anomaly_rate: float | None = None frequency_alpha: float + supplier_base_score: float | None = None + supplier_score: float | None = None risk_score: float | None = None main_category_count: int cooperation_count_basis: str = "按近3年采购订单去重单据编号统计" @@ -29,6 +31,7 @@ class SupplierMetric(ApiSchema): class SupplierCategoryBreakdown(ApiSchema): category: str + is_unclassified: bool = False anomaly_count: int anomaly_rate: float | None = None top_material: str | None = None diff --git a/app/services/conversations.py b/app/services/conversations.py index 25408e4..33dfc67 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -576,6 +576,16 @@ class ConversationService: ) party_type = "加工厂" if is_factory else "供应商" tier = self._supplier_tier(anomaly_count, a_count=a_count, b_count=b_count) + supplier_dimension = None + if not is_factory and hasattr(self, "session"): + database_score = await DatabaseRiskScoringService( + self.session, + as_of=datetime.now(UTC).date(), + ).score(supplier=name) + supplier_dimension = next( + (item for item in database_score.dimensions if item.name == "供应商表现"), + None, + ) return { "type": "supplier", "title": "供应商深度画像", @@ -584,9 +594,15 @@ class ConversationService: "anomalyCount": anomaly_count, "cooperationCount": cooperation_display, "tier": tier, - "frequencyCoefficient": round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2) - if cooperation_display is not None - else 1.0, + "frequencyCoefficient": ( + supplier_dimension.frequency_alpha + if supplier_dimension and supplier_dimension.frequency_alpha is not None + else round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2) + if cooperation_display is not None + else 1.0 + ), + "supplierBaseScore": supplier_dimension.base_score if supplier_dimension else None, + "supplierScore": supplier_dimension.score if supplier_dimension else None, "topProblems": problems, "suggestions": self._supplier_management_suggestions(tier), "frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单", @@ -958,7 +974,10 @@ class ConversationService: supplier = None supplier_or_factory = supplier or factory - database_score = await DatabaseRiskScoringService(self.session).score( + database_score = await DatabaseRiskScoringService( + self.session, + as_of=datetime.now(UTC).date(), + ).score( category=category, material=self._first_match( text, @@ -1006,7 +1025,7 @@ class ConversationService: alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0 tier = ( self._supplier_tier( - alpha_count, + supplier_count, a_count=supplier_a_count, b_count=supplier_b_count, ) @@ -1033,6 +1052,10 @@ class ConversationService: ) for item in database_score.dimensions ] + supplier_dimension = next( + (item for item in database_score.dimensions if item.name == "供应商表现"), + None, + ) factors = [ RiskFactorResult( name=item.name, @@ -1079,7 +1102,11 @@ class ConversationService: supplier_name=supplier, cooperation_count=cooperation_count, anomaly_count=supplier_count, - alpha=alpha, + alpha=( + supplier_dimension.frequency_alpha + if supplier_dimension and supplier_dimension.frequency_alpha is not None + else alpha + ), tier=tier, suggestion=";".join(self._supplier_management_suggestions(tier)), frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单", diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index d007f0a..bff3302 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -26,6 +26,8 @@ class DimensionScore: reason: str | None = None sample_size: int | None = None raw_value: float | int | None = None + base_score: float | None = None + frequency_alpha: float | None = None @dataclass(frozen=True, slots=True) @@ -59,19 +61,25 @@ class DatabaseRiskScoringService: factory: str | None = None, stage: str | None = None, defect_level: str | None = None, + rows: list[dict[str, Any]] | None = None, + cooperation_count: int | None = None, ) -> DatabaseRiskScore: await self._load_model_config() - rows = await self._load_rows() + source_rows = rows if rows is not None else await self._load_rows() category_name = normalize_category(category) material_name = normalize_material(material) stage_name = normalize_stage(stage) party_name = clean_value(supplier) - cooperation_count = await self._supplier_order_count(party_name) + resolved_cooperation_count = ( + cooperation_count + if cooperation_count is not None + else await self._supplier_order_count(party_name) + ) dimensions = ( - self._supplier_dimension(rows, party_name, cooperation_count), - self._material_dimension(rows, material_name), - self._category_dimension(rows, category_name), + self._supplier_dimension(source_rows, party_name, resolved_cooperation_count), + self._material_dimension(source_rows, material_name), + self._category_dimension(source_rows, category_name), DimensionScore( name="加工厂质量", score=50.0, @@ -86,8 +94,8 @@ class DatabaseRiskScoringService: status="default", reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", ), - self._stage_dimension(rows, stage_name), - self._defect_dimension(rows, category_name, material_name, party_name, defect_level), + self._stage_dimension(source_rows, stage_name), + self._defect_dimension(source_rows, category_name, material_name, party_name, defect_level), ) score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) @@ -198,6 +206,8 @@ class DatabaseRiskScoringService: ), sample_size=anomaly_count, raw_value=count, + base_score=base, + frequency_alpha=alpha, ) def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore: diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 84003d6..439bedc 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -2,10 +2,11 @@ from __future__ import annotations +import re from collections import Counter, defaultdict from datetime import UTC, date, datetime, timedelta -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException @@ -33,19 +34,20 @@ class SupplierProfileService: self.as_of = as_of or datetime.now(UTC).date() async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData: - rows = await self._load_rows() - grouped = self._group_suppliers(rows, keyword) + grouped = await self._load_supplier_summary(keyword=keyword) items = [ SupplierOption( name=name, - anomaly_count=len(items), + anomaly_count=summary["total"], tier=self.tier_for( - len(items), - sum(self.defect_code(row.get("defect_level")) == "A" for row in items), - sum(self.defect_code(row.get("defect_level")) == "B" for row in items), + summary["total"], + summary["a"], + summary["b"], ), ) - for name, items in sorted(grouped.items(), key=lambda item: (-len(item[1]), item[0]))[:limit] + for name, summary in sorted( + grouped.items(), key=lambda item: (-item[1]["total"], item[0]) + )[:limit] ] return SupplierListData( items=items, @@ -53,7 +55,8 @@ class SupplierProfileService: ) async def detail(self, supplier_name: str) -> SupplierProfileData: - rows = [row for row in await self._load_rows() if self.matches(row.get("supplier"), supplier_name)] + all_rows = await self._load_rows() + rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)] if not rows: raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404) @@ -69,31 +72,95 @@ class SupplierProfileService: tier = self.tier_for(anomaly_total, a_count, b_count) categories = self._categories(rows) scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of) - score = await scoring.score(supplier=supplier_name) + score = await scoring.score( + supplier=supplier_name, + rows=all_rows, + cooperation_count=order_count, + ) + supplier_dimension = next( + (item for item in score.dimensions if item.name == "供应商表现"), + None, + ) + cooperation_display = order_count if order_count > 0 else None + display_tier = { + "S级": "S级 · 战略", + "A级": "A级 · 优质", + "B级": "B级 · 关注", + "C级": "C级 · 高风险", + }.get(tier, tier) return SupplierProfileData( supplier_name=supplier_name, - tier=tier, + tier=display_tier, tier_condition=self.tier_condition(tier), metric=SupplierMetric( - cooperation_total=order_count, + cooperation_total=cooperation_display, anomaly_total=anomaly_total, anomaly_rate=None, - frequency_alpha=self.alpha( - order_count, - coefficient=scoring.frequency_coefficient, - cap=scoring.frequency_cap, + frequency_alpha=( + supplier_dimension.frequency_alpha + if supplier_dimension and supplier_dimension.frequency_alpha is not None + else self.alpha( + cooperation_display or anomaly_total, + coefficient=scoring.frequency_coefficient, + cap=scoring.frequency_cap, + ) ), + supplier_base_score=( + supplier_dimension.base_score if supplier_dimension else None + ), + supplier_score=supplier_dimension.score if supplier_dimension else None, risk_score=score.score, main_category_count=len(categories), ), - category_breakdown=categories[: self.max_categories], + category_breakdown=self._display_categories(categories), monthly_trend=self._monthly_trend(rows), recent_records=self._recent_records(rows), factory_recommendations=[], recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", ) - async def _load_rows(self) -> list[dict]: + async def _load_supplier_summary( + self, *, keyword: str | None = None + ) -> dict[str, dict[str, int]]: + """Load only supplier aggregates for the selector. + + The selector used to fetch every anomaly row and group it in Python. + This grouped query returns a handful of rows even when the anomaly + table grows, which materially reduces first-navigation latency over + the remote PostgreSQL connection. + """ + + start = self.as_of - timedelta(days=self.history_days) + statement = ( + select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count()) + .where( + AnomalyCase.supplier.is_not(None), + AnomalyCase.date.is_not(None), + AnomalyCase.date >= start, + AnomalyCase.date <= self.as_of, + ) + .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) + ) + if keyword and keyword.strip(): + statement = statement.where(AnomalyCase.supplier.ilike(f"%{keyword.strip()}%")) + grouped: dict[str, dict[str, int]] = defaultdict( + lambda: {"total": 0, "a": 0, "b": 0} + ) + for supplier, defect_level, count in (await self.session.execute(statement)).all(): + name = (supplier or "").strip() + if not name: + continue + value = grouped[name] + amount = int(count or 0) + value["total"] += amount + code = self.defect_code(defect_level) + if code == "A": + value["a"] += amount + elif code == "B": + value["b"] += amount + return grouped + + async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]: start = self.as_of - timedelta(days=self.history_days) statement = select( AnomalyCase.id, @@ -110,6 +177,8 @@ class SupplierProfileService: AnomalyCase.date >= start, AnomalyCase.date <= self.as_of, ) + if supplier_name: + statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%")) return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] @staticmethod @@ -127,8 +196,8 @@ class SupplierProfileService: @staticmethod def defect_code(value: str | None) -> str | None: - value = (value or "").strip().upper() - return value if value in {"A", "B", "C"} else None + match = re.search(r"[ABCD]", (value or "").upper()) + return match.group(0) if match else None @staticmethod def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float: @@ -165,18 +234,30 @@ class SupplierProfileService: grouped[category or "待补标品类"].append(row) result = [] for category, items in grouped.items(): + is_unclassified = category == "待补标品类" materials = Counter(row.get("material_type") for row in items if row.get("material_type")) problems = Counter(row.get("anomaly_type") for row in items if row.get("anomaly_type")) result.append( SupplierCategoryBreakdown( category=category, + is_unclassified=is_unclassified, anomaly_count=len(items), anomaly_rate=None, top_material=materials.most_common(1)[0][0] if materials else None, top_problem=problems.most_common(1)[0][0] if problems else None, ) ) - return sorted(result, key=lambda item: (-item.anomaly_count, item.category)) + return sorted(result, key=lambda item: (item.is_unclassified, -item.anomaly_count, item.category)) + + @classmethod + def _display_categories( + cls, categories: list[SupplierCategoryBreakdown] + ) -> list[SupplierCategoryBreakdown]: + """Keep up to five real categories and always retain the unclassified row.""" + + real = [item for item in categories if not item.is_unclassified][: cls.max_categories] + unclassified = next((item for item in categories if item.is_unclassified), None) + return real + ([unclassified] if unclassified else []) def _monthly_trend(self, rows: list[dict]) -> list[dict[str, int | str]]: start = self._month_offset(self.as_of.replace(day=1), -5) diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py index 7ec5c05..ff9ca1d 100644 --- a/tests/test_suppliers.py +++ b/tests/test_suppliers.py @@ -47,6 +47,32 @@ def test_supplier_category_breakdown_is_top_five_with_material_and_problem() -> assert result[0].top_problem == "漏液" +def test_unclassified_category_is_marked_and_sorted_after_real_categories() -> None: + rows = [ + {"product_category": None, "product_name": "未命名产品", "material_type": "瓶子", "anomaly_type": "刮花"}, + {"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"}, + ] + + result = SupplierProfileService._categories(rows) + + assert result[0].category == "洗发水/洗护类" + assert result[0].is_unclassified is False + assert result[1].category == "待补标品类" + assert result[1].is_unclassified is True + + +def test_display_categories_keeps_unclassified_after_five_real_categories() -> None: + rows = [ + {"product_category": category, "product_name": category, "material_type": "瓶子", "anomaly_type": "异常"} + for category in ["洗发水", "面霜", "沐浴露", "牙膏", "清洁剂", "脱毛膏"] + ] + [{"product_category": None, "product_name": "未命名", "material_type": "瓶子", "anomaly_type": "异常"}] + + result = SupplierProfileService._display_categories(SupplierProfileService._categories(rows)) + + assert len(result) == 6 + assert result[-1].is_unclassified is True + + def test_supplier_recent_records_are_limited_and_sorted() -> None: service = SupplierProfileService(object(), as_of=date(2026, 9, 7)) rows = [ From 9e13c95ba807feeb7f77dcaffa7735f493e3025e Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 15:39:59 +0800 Subject: [PATCH 08/24] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E4=B8=8E=E6=B6=88=E6=81=AF=E6=92=A4=E5=9B=9E?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AI 案例推理方案报告字段,支持方案追加到当前报告;增加会话消息成对撤回接口并记录审计日志;扩展流式响应消息标识,支持前端停止生成与撤回处理。 --- app/api/v1/conversations.py | 16 +++++++++ app/schemas/risk_prediction.py | 19 +++++++++++ app/services/conversations.py | 62 +++++++++++++++++++++++++++++++--- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/app/api/v1/conversations.py b/app/api/v1/conversations.py index d4bac29..e311284 100644 --- a/app/api/v1/conversations.py +++ b/app/api/v1/conversations.py @@ -74,6 +74,22 @@ async def send_conversation_message( ) +@router.delete("/{conversation_id}/messages/{message_id}", response_model=ApiResponse[ConversationData]) +async def withdraw_conversation_message( + conversation_id: int, + message_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[ConversationData]: + return ApiResponse( + data=await ConversationService(session).withdraw_turn( + conversation_id, + message_id, + current_user, + ) + ) + + @router.post("/{conversation_id}/stream") async def stream_conversation_message( conversation_id: int, diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 6926af1..9af0d2b 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -85,6 +85,24 @@ class SimilarCase(ApiSchema): similarity: float | None = None +class AiSolutionRisk(ApiSchema): + level: str + title: str + detail: str + + +class AiSolutionReport(ApiSchema): + title: str = "AI 案例推理生成" + confidence: str | None = None + analysis_base: str | None = None + standard_fallback_enabled: bool = False + standards: list[StandardJudgment] = Field(default_factory=list) + similar_cases: list[SimilarCase] = Field(default_factory=list) + risks: list[AiSolutionRisk] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + warning: str | None = None + + class RiskAssessmentResult(ApiSchema): subject_id: str score: float @@ -99,6 +117,7 @@ class RiskAssessmentResult(ApiSchema): dimension_scores: list[RiskDimensionScore] = Field(default_factory=list) similar_cases: list[SimilarCase] = Field(default_factory=list) ai_suggestions: list[str] = Field(default_factory=list) + ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list) class ModelInfo(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 33dfc67..90579df 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -7,11 +7,18 @@ from collections.abc import AsyncGenerator, Sequence from datetime import UTC, date, datetime, timedelta from typing import Any, Literal -from sqlalchemy import func, or_, select +from sqlalchemy import delete, func, or_, select from app.core.exceptions import AppException from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue, RiskLevel -from app.models.entities import AnomalyCase, Conversation, ConversationMessage, StandardRule, UserAccount +from app.models.entities import ( + AnomalyCase, + Conversation, + ConversationMessage, + OperationAudit, + StandardRule, + UserAccount, +) from app.repositories.conversations import ConversationRepository from app.schemas.conversation import ( ConversationData, @@ -202,7 +209,10 @@ class ConversationService: await self.session.flush() await self.session.commit() - yield {"type": "start", "data": {"conversationId": conversation.id}} + yield { + "type": "start", + "data": {"conversationId": conversation.id, "messageId": user_message.id}, + } try: semantics = await self.llm.extract_semantics( text, @@ -567,6 +577,7 @@ class ConversationService: if hasattr(self, "session") else None ) + has_order_data = bool(cooperation_count) cooperation_display = cooperation_count if has_order_data else None problems = await self._top_anomaly_types( @@ -614,6 +625,46 @@ class ConversationService: "dataAvailable": has_order_data, } + async def withdraw_turn( + self, + conversation_id: int, + message_id: int, + user: UserAccount, + ) -> ConversationData: + """Delete one user turn and its immediately paired assistant reply.""" + + conversation = await self.repository.get_for_user(conversation_id, user.id) + if conversation is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + message = next((item for item in conversation.messages if item.id == message_id), None) + if message is None or message.role != "user": + raise AppException("只能撤回用户消息", code=422, status_code=422) + messages = sorted(conversation.messages, key=lambda item: (item.created_at, item.id)) + index = messages.index(message) + ids = [message.id] + if index + 1 < len(messages) and messages[index + 1].role == "assistant": + ids.append(messages[index + 1].id) + await self.session.execute( + delete(ConversationMessage).where(ConversationMessage.id.in_(ids)) + ) + conversation.updated_at = datetime.now(UTC) + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="conversation.withdraw", + resource_type="conversation_message", + resource_id=str(message_id), + audit_metadata={"deleted_message_ids": ids}, + ) + ) + await self.session.flush() + page = await self.repository.get_page_for_user(conversation_id, user.id, limit=20, offset=0) + if page is None: + raise AppException("对话读取失败", code=500, status_code=500) + persisted, page_messages, total = page + return self._data(persisted, messages=page_messages, total=total, offset=0) + async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) material = material or self._semantic_text(semantics, "packaging") or "未指定物料" @@ -790,7 +841,10 @@ class ConversationService: ), "standardFallbackEnabled": standard_fallback_enabled, "standards": [self._standard_dict(item) for item in standards[:4]], - "similarCases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases], + "similarCases": [ + item.case.model_dump(mode="json", by_alias=False) + for item in similar_cases + ], "risks": risks, "suggestions": suggestions, "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", From fd8369e2f3ab98884713d4739f032f8586a2a5ee Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 17:04:02 +0800 Subject: [PATCH 09/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A3=8E=E9=99=A9?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E5=8D=A1=E7=89=87=E4=B8=8E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/risk_prediction.py | 41 ++++++++++ app/services/conversations.py | 12 +++ app/services/reports.py | 136 ++++++++++++++++++++++++++++++++- tests/test_conversations.py | 13 ++++ tests/test_reports.py | 62 +++++++++++++++ 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/test_reports.py diff --git a/app/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 9af0d2b..c9871b3 100644 --- a/app/schemas/risk_prediction.py +++ b/app/schemas/risk_prediction.py @@ -1,5 +1,7 @@ """Risk prediction request and response contracts.""" +from typing import Literal + from pydantic import Field from app.domain.risk_engine import RiskLevel @@ -103,6 +105,44 @@ class AiSolutionReport(ApiSchema): warning: str | None = None +class ReportCard(ApiSchema): + """A response card explicitly appended to the risk report. + + The card is intentionally a superset of the five assistant card types so + that the report page and both export formats can consume the exact same + payload without type-specific ad-hoc transformations. + """ + + type: Literal["supplier", "material", "standard", "compliance", "solution"] + title: str = "风险分析卡片" + name: str | None = None + party_type: str | None = None + material: str | None = None + category: str | None = None + question: str | None = None + anomaly_count: int | None = None + cooperation_count: int | None = None + frequency_coefficient: float | None = None + supplier_base_score: float | None = None + supplier_score: float | None = None + tier: str | None = None + matched_count: int | None = None + detected_values: list[dict[str, str]] = Field(default_factory=list) + top_problems: list[dict[str, int | str]] = Field(default_factory=list) + frequent_problems: list[dict[str, int | str]] = Field(default_factory=list) + standards: list[StandardJudgment] = Field(default_factory=list) + similar_cases: list[SimilarCase] = Field(default_factory=list) + confidence: str | None = None + analysis_base: str | None = None + standard_fallback_enabled: bool = False + risks: list[AiSolutionRisk] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + note: str | None = None + warning: str | None = None + frequency_basis: str | None = None + data_available: bool | None = None + + class RiskAssessmentResult(ApiSchema): subject_id: str score: float @@ -118,6 +158,7 @@ class RiskAssessmentResult(ApiSchema): similar_cases: list[SimilarCase] = Field(default_factory=list) ai_suggestions: list[str] = Field(default_factory=list) ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list) + report_cards: list[ReportCard] = Field(default_factory=list) class ModelInfo(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 90579df..9b60b42 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -345,6 +345,14 @@ class ConversationService: if cls._is_placeholder_supplier_name(text): return "general" + if text.startswith("查物料风险"): + return "material" + if text.startswith("搜索相似案例"): + return "risk_assessment" + if text.startswith("生成 AI 案例推理方案"): + return "solution" + if text.startswith("评估新品"): + return "risk_assessment" intent = str(semantics.get("intent") or "").strip().lower() if intent == "supplier" or ( any(word in text for word in ("供应商", "加工厂", "工厂")) @@ -354,6 +362,8 @@ class ConversationService: return "supplier" if intent == "material" or "物料" in text or "类物料" in text: return "material" + if intent == "cases": + return "risk_assessment" if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")): return "standard" if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")): @@ -667,6 +677,8 @@ class ConversationService: async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) + if not material and "查物料风险:" in text: + material = text.split("查物料风险:", 1)[1].strip().split(",", 1)[0] material = material or self._semantic_text(semantics, "packaging") or "未指定物料" query_term = "软管" if "软管" in material else material count = await self._count_anomalies(AnomalyCase.material_type, query_term) diff --git a/app/services/reports.py b/app/services/reports.py index a9192d2..666ea6b 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -17,7 +17,7 @@ from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle -from app.schemas.risk_prediction import RiskAssessmentResult +from app.schemas.risk_prediction import ReportCard, RiskAssessmentResult LEVEL_LABELS = { "low": "低风险", @@ -28,6 +28,121 @@ LEVEL_LABELS = { } +def effective_report_cards(result: RiskAssessmentResult) -> list[ReportCard]: + """Return the single canonical collection used by every report renderer. + + ``ai_solution_cards`` predates the generic card report feature. It is + accepted as a read-only compatibility input when a client sends an old + report payload, while new payloads always use ``report_cards``. + """ + + if result.report_cards: + return list(result.report_cards) + return [ + ReportCard( + type="solution", + title=item.title, + confidence=item.confidence, + analysis_base=item.analysis_base, + standard_fallback_enabled=item.standard_fallback_enabled, + standards=item.standards, + similar_cases=item.similar_cases, + risks=item.risks, + suggestions=item.suggestions, + warning=item.warning, + ) + for item in result.ai_solution_cards + ] + + +def report_card_lines(card: ReportCard) -> list[tuple[str, str]]: + """Flatten a card into ordered, renderer-neutral report lines. + + PDF, Word and the web page all consume this same field contract. Keeping + formatting here prevents one export from silently dropping appended card + details that are visible in another format. + """ + + lines: list[tuple[str, str]] = [("卡片类型", card.type)] + subject = card.name or card.material or card.category + if subject: + lines.append(("分析对象", subject)) + if card.party_type: + lines.append(("对象类型", card.party_type)) + if card.question: + lines.append(("原始问题", card.question)) + if card.anomaly_count is not None: + lines.append(("历史异常", f"{card.anomaly_count} 条")) + if card.cooperation_count is not None: + lines.append(("合作次数", f"{card.cooperation_count} 次")) + if card.frequency_coefficient is not None: + lines.append(("频次系数", f"×{card.frequency_coefficient:.2f}")) + if card.supplier_base_score is not None: + lines.append(("供应商基础分", f"{card.supplier_base_score:.2f} 分")) + if card.supplier_score is not None: + lines.append(("供应商得分", f"{card.supplier_score:.2f} 分")) + if card.tier: + lines.append(("供应商层级", card.tier)) + if card.matched_count is not None: + lines.append(("匹配数量", f"{card.matched_count} 条")) + if card.frequency_basis: + lines.append(("数据口径", card.frequency_basis)) + if card.data_available is not None: + lines.append(("数据状态", "有匹配数据" if card.data_available else "暂无匹配数据")) + if card.note: + lines.append(("备注", card.note)) + if card.detected_values: + values = "、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values) + lines.append(("检测值", values)) + if card.confidence: + lines.append(("置信度", card.confidence)) + if card.analysis_base: + lines.append(("分析基础", card.analysis_base)) + if card.standard_fallback_enabled: + lines.append(("标准兜底校验", "已启用(无直接历史案例)")) + if card.top_problems: + lines.append( + ("高频问题", "、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.top_problems)) + ) + if card.frequent_problems: + lines.append( + ( + "常见问题", + "、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.frequent_problems), + ) + ) + if card.standards: + lines.append( + ( + "关联标准", + ";".join( + f"{item.category} · {item.risk_point}:{item.rule}" + f"({item.source or '标准文件.xlsx'})· {item.status}" + for item in card.standards + ), + ) + ) + if card.similar_cases: + lines.append( + ( + "相似案例", + ";".join( + f"{item.date or '日期未知'} · {item.product or '未命名产品'} · " + f"{item.problem or item.description or '异常问题未填写'} → {item.solution or '解决方案未填写'}" + + (f"(相似度 {round(item.similarity * 100)}%)" if item.similarity is not None else "") + for item in card.similar_cases + ), + ) + ) + if card.risks: + lines.append(("风险项", ";".join(f"{item.level} · {item.title}:{item.detail}" for item in card.risks))) + if card.suggestions: + lines.append(("建议方案", ";".join(card.suggestions))) + if card.warning: + lines.append(("风险提示", card.warning)) + return lines + + def _font_name() -> str: candidates = [ Path("C:/Windows/Fonts/msyh.ttc"), @@ -210,6 +325,18 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: story.append(Paragraph("暂无相关标准。", small)) story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small)) + cards = effective_report_cards(result) + if cards: + story += [Paragraph("八、追加卡片分析", section)] + for index, card in enumerate(cards, 1): + story.append(Paragraph(f"{index}. {card.title}", body)) + story.append( + table( + [["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)], + [38 * mm, 122 * mm], + ) + ) + output = BytesIO() doc = SimpleDocTemplate( output, @@ -372,6 +499,13 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: else: document.add_paragraph("暂无相关标准。") document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。") + + cards = effective_report_cards(result) + if cards: + heading("八、追加卡片分析") + for index, card in enumerate(cards, 1): + document.add_paragraph(f"{index}. {card.title}") + add_table([["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)]) output = BytesIO() document.save(output) return output.getvalue() diff --git a/tests/test_conversations.py b/tests/test_conversations.py index fb840ff..1f9a615 100644 --- a/tests/test_conversations.py +++ b/tests/test_conversations.py @@ -104,6 +104,19 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None: ) is True +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("评估新品:洗发水,包装材质:泵头", "risk_assessment"), + ("查物料风险:软管", "material"), + ("搜索相似案例:软管印刷遇水脱落", "risk_assessment"), + ("生成 AI 案例推理方案:铝瓶+喷头密封验证", "solution"), + ], +) +def test_tool_requests_keep_their_intent_for_card_routing(text: str, expected: str) -> None: + assert ConversationService._response_type(text, {"intent": "general"}) == expected + + def test_legacy_general_semantics_hide_old_risk_card() -> None: messages = [ ConversationMessage( diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..010bdc2 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,62 @@ +from io import BytesIO + +from app.schemas.risk_prediction import AiSolutionReport, ReportCard, RiskAssessmentResult +from app.services.reports import ( + build_risk_report_docx, + build_risk_report_pdf, + effective_report_cards, + report_card_lines, +) +from docx import Document + + +def _result() -> RiskAssessmentResult: + return RiskAssessmentResult( + subject_id="洗发水新品", + score=72, + level="medium", + model_version="test", + factors=[], + report_cards=[ + ReportCard( + type="supplier", + title="供应商深度画像", + name="佳供应商", + party_type="供应商", + supplier_score=88.5, + frequency_coefficient=1.2, + top_problems=[{"name": "泄漏", "count": 3}], + suggestions=["安排首单全检"], + ) + ], + ) + + +def test_report_card_lines_are_canonical() -> None: + lines = dict(report_card_lines(_result().report_cards[0])) + assert lines["分析对象"] == "佳供应商" + assert lines["供应商得分"] == "88.50 分" + assert lines["高频问题"] == "泄漏(3次)" + assert lines["建议方案"] == "安排首单全检" + + +def test_old_ai_solution_payload_is_supported() -> None: + result = _result().model_copy( + update={"report_cards": [], "ai_solution_cards": [AiSolutionReport(title="旧方案")]} + ) + cards = effective_report_cards(result) + assert len(cards) == 1 + assert cards[0].title == "旧方案" + + +def test_pdf_and_word_include_appended_card_content() -> None: + result = _result() + pdf = build_risk_report_pdf(result) + word = build_risk_report_docx(result) + assert pdf.startswith(b"%PDF") + document = Document(BytesIO(word)) + text = "\n".join(paragraph.text for paragraph in document.paragraphs) + text += "\n" + "\n".join(cell.text for table in document.tables for row in table.rows for cell in row.cells) + assert "佳供应商" in text + assert "88.50 分" in text + assert "安排首单全检" in text From 2f4d2260c81a6d62c82d5355defcd17f4bc40954 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 17:23:43 +0800 Subject: [PATCH 10/24] =?UTF-8?q?=E6=8C=89=E6=8A=A5=E5=91=8A=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=BD=92=E5=B9=B6=E8=BF=BD=E5=8A=A0=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/reports.py | 214 ++++++++++++++++++++++++++++++---------- 1 file changed, 164 insertions(+), 50 deletions(-) diff --git a/app/services/reports.py b/app/services/reports.py index 666ea6b..8f46c6b 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -2,6 +2,7 @@ from io import BytesIO from pathlib import Path +from typing import Any from docx import Document from docx.enum.table import WD_CELL_VERTICAL_ALIGNMENT @@ -143,6 +144,65 @@ def report_card_lines(card: ReportCard) -> list[tuple[str, str]]: return lines +def _unique_by(items: list[Any], key) -> list[Any]: # noqa: ANN001 + seen: set[Any] = set() + result: list[Any] = [] + for item in items: + value = key(item) + if value in seen: + continue + seen.add(value) + result.append(item) + return result + + +def aggregate_report_data(result: RiskAssessmentResult) -> dict[str, Any]: + """Merge appended cards into the report's native sections. + + A card is an input action, not a report section. Its fields are folded + into the matching section so the browser, PDF and Word output have the + same information architecture. + """ + + cards = effective_report_cards(result) + supplier_cards = [card for card in cards if card.type == "supplier"] + material_cards = [card for card in cards if card.type == "material"] + solution_cards = [card for card in cards if card.type == "solution"] + standards = _unique_by( + [*result.standard_judgment, *(item for card in cards for item in card.standards)], + lambda item: (item.category, item.risk_point, item.rule, item.status), + ) + similar_cases = _unique_by( + [*result.similar_cases, *(item for card in cards for item in card.similar_cases)], + lambda item: ( + item.case_id, + item.date, + item.product, + item.problem or item.description, + item.solution, + ), + ) + suggestions = _unique_by( + [*result.ai_suggestions, *(item for card in cards for item in card.suggestions)], + lambda item: item, + ) + detected_values = [item for card in cards for item in card.detected_values] + risks = [(card.title, risk) for card in solution_cards for risk in card.risks] + analyses = [card for card in solution_cards if card.analysis_base or card.confidence or card.warning] + return { + "cards": cards, + "supplier_cards": supplier_cards, + "material_cards": material_cards, + "solution_cards": solution_cards, + "standards": standards, + "similar_cases": similar_cases, + "suggestions": suggestions, + "detected_values": detected_values, + "risks": risks, + "analyses": analyses, + } + + def _font_name() -> str: candidates = [ Path("C:/Windows/Fonts/msyh.ttc"), @@ -188,6 +248,10 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: supplier_name = result.supplier_freq.supplier_name if result.supplier_freq else None report_number = f"RP-{result.subject_id[:8]}-{round(result.score)}" generated_at = __import__("datetime").datetime.now().strftime("%Y-%m-%d %H:%M") + report_data = aggregate_report_data(result) + standards = report_data["standards"] + similar_cases = report_data["similar_cases"] + suggestions = report_data["suggestions"] story = [ Paragraph("新品风险预估报告", title), Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle), @@ -268,27 +332,52 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: ] supplier = result.supplier_freq - if supplier and supplier.supplier_name: + supplier_cards = report_data["supplier_cards"] + if (supplier and supplier.supplier_name) or supplier_cards: supplier_rows = [["维度", "数值", "说明"]] - if supplier.cooperation_count is not None: + if supplier and supplier.cooperation_count is not None: supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"]) - supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"]) - if supplier.alpha is not None: + if supplier: + supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"]) + if supplier and supplier.alpha is not None: supplier_rows.append( ["频次风险系数", f"×{supplier.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] ) - if supplier.tier: + if supplier and supplier.tier: supplier_rows.append(["供应商层级", supplier.tier, supplier.suggestion or ""]) + for card in supplier_cards: + if card.name: + supplier_rows.append(["供应商名称", card.name, "供应商画像统计"]) + for label, value in report_card_lines(card): + if label not in {"卡片类型", "分析对象", "对象类型"}: + supplier_rows.append([label, value, "追加供应商画像数据"]) story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])] - story += [Paragraph(f"四、相似案例匹配({len(result.similar_cases)} 条)", section)] - if result.similar_cases: + material_cards = report_data["material_cards"] + if material_cards: + story += [Paragraph("四、物料风险分析", section)] + material_rows = [["物料/字段", "分析结果"]] + for card in material_cards: + material_rows.append( + [ + card.material or card.title, + ";".join( + f"{label}:{value}" + for label, value in report_card_lines(card) + if label not in {"卡片类型", "分析对象"} + ), + ] + ) + story.append(table(material_rows, [45 * mm, 115 * mm])) + + story += [Paragraph(f"五、相似案例匹配({len(similar_cases)} 条)", section)] + if similar_cases: story.append( table( [["日期", "产品", "问题", "处理方案"]] + [ [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] - for case in result.similar_cases + for case in similar_cases ], [28 * mm, 42 * mm, 48 * mm, 42 * mm], ) @@ -296,27 +385,35 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: else: story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small)) - story += [Paragraph("五、预防建议", section)] - for index, suggestion in enumerate( - result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1 - ): + story += [Paragraph("六、预防建议", section)] + for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1): story.append(Paragraph(f"{index}. {suggestion}", body)) story += [ - Paragraph("六、AI 推理方案(无历史案例场景)", section), + Paragraph("七、AI 推理方案(无历史案例场景)", section), Paragraph( "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。", body, ), ] - story += [Paragraph("七、标准判定结果", section)] - if result.standard_judgment: + for card in report_data["solution_cards"]: + if card.analysis_base: + story.append(Paragraph(f"分析基础:{card.analysis_base}", body)) + if card.confidence: + story.append(Paragraph(f"置信度:{card.confidence}", body)) + for risk in card.risks: + story.append(Paragraph(f"{risk.level} · {risk.title}:{risk.detail}", body)) + if card.warning: + story.append(Paragraph(f"风险提示:{card.warning}", body)) + + story += [Paragraph("八、标准判定结果", section)] + if standards: story.append( table( [["判定维度", "判定标准", "标准来源", "当前状态"]] + [ [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] - for item in result.standard_judgment + for item in standards ], [38 * mm, 55 * mm, 40 * mm, 27 * mm], ) @@ -325,18 +422,6 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: story.append(Paragraph("暂无相关标准。", small)) story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small)) - cards = effective_report_cards(result) - if cards: - story += [Paragraph("八、追加卡片分析", section)] - for index, card in enumerate(cards, 1): - story.append(Paragraph(f"{index}. {card.title}", body)) - story.append( - table( - [["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)], - [38 * mm, 122 * mm], - ) - ) - output = BytesIO() doc = SimpleDocTemplate( output, @@ -371,6 +456,10 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: subtitle = document.add_paragraph() subtitle.alignment = WD_ALIGN_PARAGRAPH.CENTER subtitle.add_run(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {result.subject_id}").font.size = Pt(9) + report_data = aggregate_report_data(result) + standards = report_data["standards"] + similar_cases = report_data["similar_cases"] + suggestions = report_data["suggestions"] def heading(text: str) -> None: p = document.add_paragraph() @@ -454,58 +543,83 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: ], ] ) - if result.supplier_freq and result.supplier_freq.supplier_name: + supplier_cards = report_data["supplier_cards"] + if (result.supplier_freq and result.supplier_freq.supplier_name) or supplier_cards: heading("三、供应商合作频次分析") rows = [["维度", "数值", "说明"]] - if result.supplier_freq.cooperation_count is not None: + if result.supplier_freq and result.supplier_freq.cooperation_count is not None: rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"]) - rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"]) - if result.supplier_freq.alpha is not None: + if result.supplier_freq: + rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"]) + if result.supplier_freq and result.supplier_freq.alpha is not None: rows.append( ["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] ) - if result.supplier_freq.tier: + if result.supplier_freq and result.supplier_freq.tier: rows.append(["供应商层级", result.supplier_freq.tier, result.supplier_freq.suggestion or ""]) + for card in supplier_cards: + if card.name: + rows.append(["供应商名称", card.name, "供应商画像统计"]) + for label, value in report_card_lines(card): + if label not in {"卡片类型", "分析对象", "对象类型"}: + rows.append([label, value, "追加供应商画像数据"]) add_table(rows) - heading(f"四、相似案例匹配({len(result.similar_cases)} 条)") - if result.similar_cases: + material_cards = report_data["material_cards"] + if material_cards: + heading("四、物料风险分析") + rows = [["物料/字段", "分析结果"]] + for card in material_cards: + rows.append( + [ + card.material or card.title, + ";".join( + f"{label}:{value}" + for label, value in report_card_lines(card) + if label not in {"卡片类型", "分析对象"} + ), + ] + ) + add_table(rows) + heading(f"五、相似案例匹配({len(similar_cases)} 条)") + if similar_cases: add_table( [["日期", "产品", "问题", "处理方案"]] + [ [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] - for case in result.similar_cases + for case in similar_cases ] ) else: document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。") - heading("五、预防建议") - for index, suggestion in enumerate( - result.ai_suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1 - ): + heading("六、预防建议") + for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1): document.add_paragraph(f"{index}. {suggestion}") - heading("六、AI 推理方案(无历史案例场景)") + heading("七、AI 推理方案(无历史案例场景)") document.add_paragraph( "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。" ) - heading("七、标准判定结果") - if result.standard_judgment: + for card in report_data["solution_cards"]: + if card.analysis_base: + document.add_paragraph(f"分析基础:{card.analysis_base}") + if card.confidence: + document.add_paragraph(f"置信度:{card.confidence}") + for risk in card.risks: + document.add_paragraph(f"{risk.level} · {risk.title}:{risk.detail}") + if card.warning: + document.add_paragraph(f"风险提示:{card.warning}") + heading("八、标准判定结果") + if standards: add_table( [["判定维度", "判定标准", "标准来源", "当前状态"]] + [ [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] - for item in result.standard_judgment + for item in standards ] ) else: document.add_paragraph("暂无相关标准。") document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。") - cards = effective_report_cards(result) - if cards: - heading("八、追加卡片分析") - for index, card in enumerate(cards, 1): - document.add_paragraph(f"{index}. {card.title}") - add_table([["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)]) output = BytesIO() document.save(output) return output.getvalue() From 814da7ffc08a7a0e66dfcde70e84f9c68df8af88 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 17:30:06 +0800 Subject: [PATCH 11/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=BD=92=E5=B9=B6=E4=B8=8E=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/reports.py | 49 +++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/app/services/reports.py b/app/services/reports.py index 8f46c6b..b6fdd0f 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -168,6 +168,7 @@ def aggregate_report_data(result: RiskAssessmentResult) -> dict[str, Any]: supplier_cards = [card for card in cards if card.type == "supplier"] material_cards = [card for card in cards if card.type == "material"] solution_cards = [card for card in cards if card.type == "solution"] + standard_cards = [card for card in cards if card.type in {"standard", "compliance"}] standards = _unique_by( [*result.standard_judgment, *(item for card in cards for item in card.standards)], lambda item: (item.category, item.risk_point, item.rule, item.status), @@ -182,10 +183,10 @@ def aggregate_report_data(result: RiskAssessmentResult) -> dict[str, Any]: item.solution, ), ) - suggestions = _unique_by( - [*result.ai_suggestions, *(item for card in cards for item in card.suggestions)], - lambda item: item, - ) + # Only base assessment suggestions belong to the prevention section. + # Card-specific suggestions stay with their owning section (supplier, + # material, standard/compliance or AI reasoning) to avoid duplication. + suggestions = _unique_by(list(result.ai_suggestions), lambda item: item) detected_values = [item for card in cards for item in card.detected_values] risks = [(card.title, risk) for card in solution_cards for risk in card.risks] analyses = [card for card in solution_cards if card.analysis_base or card.confidence or card.warning] @@ -194,6 +195,7 @@ def aggregate_report_data(result: RiskAssessmentResult) -> dict[str, Any]: "supplier_cards": supplier_cards, "material_cards": material_cards, "solution_cards": solution_cards, + "standard_cards": standard_cards, "standards": standards, "similar_cases": similar_cases, "suggestions": suggestions, @@ -252,6 +254,9 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: standards = report_data["standards"] similar_cases = report_data["similar_cases"] suggestions = report_data["suggestions"] + material_cards = report_data["material_cards"] + standard_cards = report_data["standard_cards"] + section_shift = 1 if material_cards else 0 story = [ Paragraph("新品风险预估报告", title), Paragraph(f"蝶雅产品部 · 风险预估系统 · 自动生成 · {generated_at}", subtitle), @@ -308,7 +313,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else "" conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次" elif name == "标准符合性" and result.standard_judgment: - conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认" + conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认" else: conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "") rows.append([f"{name}({weight:g}%)", conclusion]) @@ -353,7 +358,6 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: supplier_rows.append([label, value, "追加供应商画像数据"]) story += [Paragraph("三、供应商合作频次分析", section), table(supplier_rows, [38 * mm, 35 * mm, 87 * mm])] - material_cards = report_data["material_cards"] if material_cards: story += [Paragraph("四、物料风险分析", section)] material_rows = [["物料/字段", "分析结果"]] @@ -370,7 +374,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: ) story.append(table(material_rows, [45 * mm, 115 * mm])) - story += [Paragraph(f"五、相似案例匹配({len(similar_cases)} 条)", section)] + story += [Paragraph(f"{'五' if section_shift else '四'}、相似案例匹配({len(similar_cases)} 条)", section)] if similar_cases: story.append( table( @@ -385,11 +389,11 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: else: story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small)) - story += [Paragraph("六、预防建议", section)] + story += [Paragraph(f"{'六' if section_shift else '五'}、预防建议", section)] for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1): story.append(Paragraph(f"{index}. {suggestion}", body)) story += [ - Paragraph("七、AI 推理方案(无历史案例场景)", section), + Paragraph(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)", section), Paragraph( "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。", body, @@ -406,15 +410,28 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: if card.warning: story.append(Paragraph(f"风险提示:{card.warning}", body)) - story += [Paragraph("八、标准判定结果", section)] - if standards: + story += [Paragraph(f"{'八' if section_shift else '七'}、标准判定结果", section)] + standard_rows = [ + [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] + for item in standards + ] + for card in standard_cards: + if card.detected_values: + standard_rows.append( + [ + "检测值", + "、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values), + card.title, + "待确认", + ] + ) + if card.note: + standard_rows.append(["补充说明", card.note, card.title, "待确认"]) + if standard_rows: story.append( table( [["判定维度", "判定标准", "标准来源", "当前状态"]] - + [ - [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] - for item in standards - ], + + standard_rows, [38 * mm, 55 * mm, 40 * mm, 27 * mm], ) ) @@ -525,7 +542,7 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: order_text = f"合作 {supplier.cooperation_count} 次、" if supplier.cooperation_count is not None else "" conclusion = f"{round(score)} 分 · {order_text}异常 {supplier.anomaly_count or 0} 次" elif name == "标准符合性" and result.standard_judgment: - conclusion = f"{round(score)} 分 · 命中 {len(result.standard_judgment)} 条判定标准,待检测项需人工确认" + conclusion = f"{round(score)} 分 · 命中 {len(standards)} 条判定标准,待检测项需人工确认" else: conclusion = f"{round(score)} 分" + (f" · {reason}" if reason else "") rows.append([f"{name}({weight:g}%)", conclusion]) From 58ffe7b90b8160b7b4443691c4cbd1bdc6f1f81c Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 17:31:51 +0800 Subject: [PATCH 12/24] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BD=92=E5=B9=B6?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E4=B8=AD=E7=9A=84=E5=8D=A1=E7=89=87=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/reports.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/services/reports.py b/app/services/reports.py index b6fdd0f..0f36d62 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -407,6 +407,8 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: story.append(Paragraph(f"置信度:{card.confidence}", body)) for risk in card.risks: story.append(Paragraph(f"{risk.level} · {risk.title}:{risk.detail}", body)) + for index, suggestion in enumerate(card.suggestions, 1): + story.append(Paragraph(f"方案建议 {index}:{suggestion}", body)) if card.warning: story.append(Paragraph(f"风险提示:{card.warning}", body)) @@ -427,6 +429,8 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: ) if card.note: standard_rows.append(["补充说明", card.note, card.title, "待确认"]) + for suggestion in card.suggestions: + standard_rows.append(["执行建议", suggestion, card.title, "待确认"]) if standard_rows: story.append( table( @@ -622,6 +626,8 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: document.add_paragraph(f"置信度:{card.confidence}") for risk in card.risks: document.add_paragraph(f"{risk.level} · {risk.title}:{risk.detail}") + for index, suggestion in enumerate(card.suggestions, 1): + document.add_paragraph(f"方案建议 {index}:{suggestion}") if card.warning: document.add_paragraph(f"风险提示:{card.warning}") heading("八、标准判定结果") From dbf7ba2d6495f8116bedbba3fe82c25941c4fa20 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Wed, 9 Sep 2026 17:36:57 +0800 Subject: [PATCH 13/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E5=BD=92=E5=B9=B6=E5=B9=B6=E4=BF=9D=E7=95=99?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/reports.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/app/services/reports.py b/app/services/reports.py index 0f36d62..804344f 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -481,6 +481,9 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: standards = report_data["standards"] similar_cases = report_data["similar_cases"] suggestions = report_data["suggestions"] + material_cards = report_data["material_cards"] + standard_cards = report_data["standard_cards"] + section_shift = 1 if material_cards else 0 def heading(text: str) -> None: p = document.add_paragraph() @@ -585,7 +588,6 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: if label not in {"卡片类型", "分析对象", "对象类型"}: rows.append([label, value, "追加供应商画像数据"]) add_table(rows) - material_cards = report_data["material_cards"] if material_cards: heading("四、物料风险分析") rows = [["物料/字段", "分析结果"]] @@ -601,7 +603,7 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: ] ) add_table(rows) - heading(f"五、相似案例匹配({len(similar_cases)} 条)") + heading(f"{'五' if section_shift else '四'}、相似案例匹配({len(similar_cases)} 条)") if similar_cases: add_table( [["日期", "产品", "问题", "处理方案"]] @@ -612,10 +614,10 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: ) else: document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。") - heading("六、预防建议") + heading(f"{'六' if section_shift else '五'}、预防建议") for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1): document.add_paragraph(f"{index}. {suggestion}") - heading("七、AI 推理方案(无历史案例场景)") + heading(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)") document.add_paragraph( "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。" ) @@ -630,14 +632,29 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: document.add_paragraph(f"方案建议 {index}:{suggestion}") if card.warning: document.add_paragraph(f"风险提示:{card.warning}") - heading("八、标准判定结果") - if standards: + heading(f"{'八' if section_shift else '七'}、标准判定结果") + standard_rows = [ + [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] + for item in standards + ] + for card in standard_cards: + if card.detected_values: + standard_rows.append( + [ + "检测值", + "、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values), + card.title, + "待确认", + ] + ) + if card.note: + standard_rows.append(["补充说明", card.note, card.title, "待确认"]) + for suggestion in card.suggestions: + standard_rows.append(["执行建议", suggestion, card.title, "待确认"]) + if standard_rows: add_table( [["判定维度", "判定标准", "标准来源", "当前状态"]] - + [ - [f"{item.category} · {item.risk_point}", item.rule, item.source or "标准文件.xlsx", item.status] - for item in standards - ] + + standard_rows ) else: document.add_paragraph("暂无相关标准。") From b7a79f1791d17c52059131ac7ebad4c8af13ce79 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 10:54:10 +0800 Subject: [PATCH 14/24] =?UTF-8?q?=E6=8C=89=E8=87=AA=E7=84=B6=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E7=BB=9F=E8=AE=A1=E4=BE=9B=E5=BA=94=E5=95=86=E5=90=88?= =?UTF-8?q?=E4=BD=9C=E9=A2=91=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/schemas/suppliers.py | 2 +- app/services/conversations.py | 32 +++++++++++++++------------ app/services/database_risk_scoring.py | 13 +++++------ app/services/purchase_orders.py | 14 ++++++++++++ app/services/reports.py | 6 +++-- app/services/suppliers.py | 25 +++++++++++---------- tests/test_purchase_orders.py | 15 +++++++++++++ 7 files changed, 71 insertions(+), 36 deletions(-) create mode 100644 tests/test_purchase_orders.py diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index dbbb959..fb2ead1 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -25,7 +25,7 @@ class SupplierMetric(ApiSchema): supplier_score: float | None = None risk_score: float | None = None main_category_count: int - cooperation_count_basis: str = "按近3年采购订单去重单据编号统计" + cooperation_count_basis: str = "按自然年度边界采购订单去重单据编号统计" anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率" diff --git a/app/services/conversations.py b/app/services/conversations.py index 9b60b42..8408c3b 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -4,7 +4,7 @@ import json import logging import re from collections.abc import AsyncGenerator, Sequence -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime from typing import Any, Literal from sqlalchemy import delete, func, or_, select @@ -36,14 +36,14 @@ from app.schemas.risk_prediction import ( ) from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.llm_client import LLMClient -from app.services.purchase_orders import supplier_order_counts +from app.services.purchase_orders import calendar_year_window, supplier_order_counts from app.services.rag import AnomalyCaseRAG logger = logging.getLogger("dieya_risk_prediction.conversations") class ConversationService: - _supplier_history_days = 365 * 3 + _supplier_history_years = 3 _engine = BaselineRiskEngine() _risk_intents = { "assessment", @@ -582,7 +582,7 @@ class ConversationService: self.session, name, self._supplier_history_start(), - datetime.now(UTC).date(), + self._supplier_history_end(), ) if hasattr(self, "session") else None @@ -626,9 +626,9 @@ class ConversationService: "supplierScore": supplier_dimension.score if supplier_dimension else None, "topProblems": problems, "suggestions": self._supplier_management_suggestions(tier), - "frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单", + "frequencyBasis": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单", "note": ( - "合作次数按近3年采购订单去重单据编号统计。" + "合作次数按自然年度边界采购订单去重单据编号统计。" if has_order_data else "未匹配到该供应商采购订单,不展示合作次数。" ), @@ -938,7 +938,7 @@ class ConversationService: [ AnomalyCase.date.is_not(None), AnomalyCase.date >= since, - AnomalyCase.date <= datetime.now(UTC).date(), + AnomalyCase.date <= self._supplier_history_end(), ] ) rows = await self.session.execute( @@ -1082,7 +1082,7 @@ class ConversationService: self.session, supplier, self._supplier_history_start(), - datetime.now(UTC).date(), + self._supplier_history_end(), ) if supplier else None @@ -1175,9 +1175,9 @@ class ConversationService: ), tier=tier, suggestion=";".join(self._supplier_management_suggestions(tier)), - frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单", + frequency_basis="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单", note=( - "合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。" + "合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。" if cooperation_count else "未匹配到该供应商采购订单,不展示合作次数。" ), @@ -1209,14 +1209,18 @@ class ConversationService: column.ilike(f"%{value}%"), AnomalyCase.date.is_not(None), AnomalyCase.date >= self._supplier_history_start(), - AnomalyCase.date <= datetime.now(UTC).date(), + AnomalyCase.date <= self._supplier_history_end(), ) ) return int(count or 0) @classmethod def _supplier_history_start(cls) -> date: - return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days) + return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[0] + + @classmethod + def _supplier_history_end(cls) -> date: + return calendar_year_window(datetime.now(UTC).date(), cls._supplier_history_years)[1] async def _supplier_history_stats( self, @@ -1234,7 +1238,7 @@ class ConversationService: column.ilike(f"%{value}%"), AnomalyCase.date.is_not(None), AnomalyCase.date >= self._supplier_history_start(), - AnomalyCase.date <= datetime.now(UTC).date(), + AnomalyCase.date <= self._supplier_history_end(), ) .group_by(AnomalyCase.defect_level) ) @@ -1354,7 +1358,7 @@ class ConversationService: ), AnomalyCase.date.is_not(None), AnomalyCase.date >= self._supplier_history_start(), - AnomalyCase.date <= datetime.now(UTC).date(), + AnomalyCase.date <= self._supplier_history_end(), ) ) return int(count or 0) diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index bff3302..b495a6a 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS from app.domain.risk_engine import RiskLevel from app.models.entities import AnomalyCase, RiskModelConfig -from app.services.purchase_orders import supplier_order_counts +from app.services.purchase_orders import calendar_year_window, supplier_order_counts STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") @@ -107,8 +107,7 @@ class DatabaseRiskScoringService: return await supplier_order_counts( self.session, party_name, - self.as_of - timedelta(days=365 * 3), - self.as_of, + *calendar_year_window(self.as_of, 3), ) except Exception: # noqa: BLE001 - legacy/test databases may not have the order ledger yet return None @@ -127,7 +126,7 @@ class DatabaseRiskScoringService: self.frequency_cap = float(config.frequency_cap) async def _load_rows(self) -> list[dict[str, Any]]: - start = self.as_of - timedelta(days=365 * 3) + start, end = calendar_year_window(self.as_of, 3) statement = select( AnomalyCase.date, AnomalyCase.product_name, @@ -140,7 +139,7 @@ class DatabaseRiskScoringService: ).where( AnomalyCase.date.is_not(None), AnomalyCase.date >= start, - AnomalyCase.date <= self.as_of, + AnomalyCase.date <= end, ) return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] @@ -200,9 +199,9 @@ class DatabaseRiskScoringService: weight=weight, status="database", reason=( - f"近 3 年采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}" + f"近 3 个自然年度采购订单 {count} 单,异常案例 {anomaly_count} 条,基础分 {base:g},α={alpha:.2f}" if cooperation_count is not None and cooperation_count > 0 - else f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}" + else f"近 3 个自然年度异常 {count} 条,基础分 {base:g},α={alpha:.2f}" ), sample_size=anomaly_count, raw_value=count, diff --git a/app/services/purchase_orders.py b/app/services/purchase_orders.py index d104043..b3b3427 100644 --- a/app/services/purchase_orders.py +++ b/app/services/purchase_orders.py @@ -16,6 +16,20 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models.entities import PurchaseOrderLine +def calendar_year_window(as_of: date, years: int = 3) -> tuple[date, date]: + """Return the natural-year window used by historical statistics. + + ``years=3`` means the year three calendar years before ``as_of`` through + the end of ``as_of``'s calendar year. For example, an as-of date in 2026 + uses 2023-01-01 through 2026-12-31, so a partial current year never drops + an otherwise valid record merely because of its month/day. + """ + + if years < 1: + raise ValueError("years must be positive") + return date(as_of.year - years, 1, 1), date(as_of.year, 12, 31) + + def clean_text(value: Any) -> str | None: if value is None: return None diff --git a/app/services/reports.py b/app/services/reports.py index 804344f..def24b9 100644 --- a/app/services/reports.py +++ b/app/services/reports.py @@ -341,7 +341,7 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes: if (supplier and supplier.supplier_name) or supplier_cards: supplier_rows = [["维度", "数值", "说明"]] if supplier and supplier.cooperation_count is not None: - supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "近三年采购订单去重单据数"]) + supplier_rows.append(["合作次数", f"{supplier.cooperation_count} 次", "按自然年度边界采购订单去重单据数"]) if supplier: supplier_rows.append(["异常次数", f"{supplier.anomaly_count or 0} 次", "异常案例库统计"]) if supplier and supplier.alpha is not None: @@ -572,7 +572,9 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes: heading("三、供应商合作频次分析") rows = [["维度", "数值", "说明"]] if result.supplier_freq and result.supplier_freq.cooperation_count is not None: - rows.append(["合作次数", f"{result.supplier_freq.cooperation_count} 次", "近三年采购订单去重单据数"]) + rows.append( + ["合作次数", f"{result.supplier_freq.cooperation_count} 次", "按自然年度边界采购订单去重单据数"] + ) if result.supplier_freq: rows.append(["异常次数", f"{result.supplier_freq.anomaly_count or 0} 次", "异常案例库统计"]) if result.supplier_freq and result.supplier_freq.alpha is not None: diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 439bedc..229f9e9 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -4,7 +4,7 @@ from __future__ import annotations import re from collections import Counter, defaultdict -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, date, datetime from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,11 +20,11 @@ from app.schemas.suppliers import ( SupplierRecentRecord, ) from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category -from app.services.purchase_orders import supplier_order_counts +from app.services.purchase_orders import calendar_year_window, supplier_order_counts class SupplierProfileService: - history_days = 365 * 3 + history_years = 3 recent_months = 6 max_categories = 5 max_recent_records = 10 @@ -58,14 +58,15 @@ class SupplierProfileService: all_rows = await self._load_rows() rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)] if not rows: - raise AppException("供应商不存在或暂无近3年异常记录", code=404, status_code=404) + raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404) anomaly_total = len(rows) + history_start, history_end = calendar_year_window(self.as_of, self.history_years) order_count = await supplier_order_counts( self.session, supplier_name, - self.as_of - timedelta(days=self.history_days), - self.as_of, + history_start, + history_end, ) a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows) b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows) @@ -116,7 +117,7 @@ class SupplierProfileService: monthly_trend=self._monthly_trend(rows), recent_records=self._recent_records(rows), factory_recommendations=[], - recommendation_note="合作次数按近3年采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", + recommendation_note="合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", ) async def _load_supplier_summary( @@ -130,14 +131,14 @@ class SupplierProfileService: the remote PostgreSQL connection. """ - start = self.as_of - timedelta(days=self.history_days) + start, end = calendar_year_window(self.as_of, self.history_years) statement = ( select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count()) .where( AnomalyCase.supplier.is_not(None), AnomalyCase.date.is_not(None), AnomalyCase.date >= start, - AnomalyCase.date <= self.as_of, + AnomalyCase.date <= end, ) .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) ) @@ -161,7 +162,7 @@ class SupplierProfileService: return grouped async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]: - start = self.as_of - timedelta(days=self.history_days) + start, end = calendar_year_window(self.as_of, self.history_years) statement = select( AnomalyCase.id, AnomalyCase.date, @@ -175,7 +176,7 @@ class SupplierProfileService: ).where( AnomalyCase.date.is_not(None), AnomalyCase.date >= start, - AnomalyCase.date <= self.as_of, + AnomalyCase.date <= end, ) if supplier_name: statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%")) @@ -223,7 +224,7 @@ class SupplierProfileService: "A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%", "B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%", "C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%", - "新供应商": "近3年异常记录为 0", + "新供应商": "近3个自然年度异常记录为 0", }[tier] @classmethod diff --git a/tests/test_purchase_orders.py b/tests/test_purchase_orders.py new file mode 100644 index 0000000..f6526c2 --- /dev/null +++ b/tests/test_purchase_orders.py @@ -0,0 +1,15 @@ +from datetime import date + +import pytest +from app.services.purchase_orders import calendar_year_window + + +@pytest.mark.parametrize( + ("as_of", "expected"), + [ + (date(2026, 9, 10), (date(2023, 1, 1), date(2026, 12, 31))), + (date(2025, 1, 1), (date(2022, 1, 1), date(2025, 12, 31))), + ], +) +def test_three_year_window_uses_calendar_years(as_of: date, expected: tuple[date, date]) -> None: + assert calendar_year_window(as_of, 3) == expected From a7f4fef025ccb98c055c502edc87e7db62680a59 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 11:43:16 +0800 Subject: [PATCH 15/24] =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E7=AE=80=E7=A7=B0=E4=B8=8E=E4=BC=81=E4=B8=9A=E5=85=A8?= =?UTF-8?q?=E7=A7=B0=E5=8F=8C=E5=90=91=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/suppliers.py | 10 +++++++--- tests/test_suppliers.py | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 229f9e9..99404c5 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -142,8 +142,6 @@ class SupplierProfileService: ) .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) ) - if keyword and keyword.strip(): - statement = statement.where(AnomalyCase.supplier.ilike(f"%{keyword.strip()}%")) grouped: dict[str, dict[str, int]] = defaultdict( lambda: {"total": 0, "a": 0, "b": 0} ) @@ -151,6 +149,8 @@ class SupplierProfileService: name = (supplier or "").strip() if not name: continue + if keyword and not self.matches(name, keyword): + continue value = grouped[name] amount = int(count or 0) value["total"] += amount @@ -184,7 +184,11 @@ class SupplierProfileService: @staticmethod def matches(value: str | None, target: str) -> bool: - return bool(value and target.strip().lower() in value.lower()) + if not value: + return False + source = "".join(value.casefold().split()) + query = "".join(target.casefold().split()) + return bool(query and (query in source or source in query)) @classmethod def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]: diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py index ff9ca1d..7d175d3 100644 --- a/tests/test_suppliers.py +++ b/tests/test_suppliers.py @@ -14,6 +14,11 @@ def test_supplier_tier_rules_follow_prd() -> None: assert SupplierProfileService.tier_for(121, 0, 0) == "C级" +def test_supplier_name_matching_supports_full_name_and_short_name() -> None: + assert SupplierProfileService.matches("佳亿", "佛山市佳亿包装有限公司") + assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿") + + def test_supplier_alpha_is_capped_and_rounded() -> None: assert SupplierProfileService.alpha(0) == 1.0 assert SupplierProfileService.alpha(42) == 1.13 From a967b827115b1634c9dd8fc64c9a33756ffbe22a Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 16:10:28 +0800 Subject: [PATCH 16/24] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E6=9F=A5=E8=AF=A2=E5=85=BC=E5=AE=B9=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=B8=8E=E5=90=8D=E7=A7=B0=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/v1/suppliers.py | 12 +++++- app/services/suppliers.py | 91 ++++++++++++++++++++++++++++++++------- tests/test_suppliers.py | 22 ++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) diff --git a/app/api/v1/suppliers.py b/app/api/v1/suppliers.py index a28c23f..187bdb1 100644 --- a/app/api/v1/suppliers.py +++ b/app/api/v1/suppliers.py @@ -23,7 +23,17 @@ async def list_suppliers( return ApiResponse(data=await SupplierProfileService(session).list(keyword=keyword, limit=limit)) -@router.get("/{supplier_name}", response_model=ApiResponse[SupplierProfileData]) +@router.get("/profile", response_model=ApiResponse[SupplierProfileData]) +async def get_supplier_profile_by_query( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))], + supplier_name: str = Query(min_length=1, max_length=300), +) -> ApiResponse[SupplierProfileData]: + """Fetch a profile by query parameter so names containing '/' are safe.""" + return ApiResponse(data=await SupplierProfileService(session).detail(supplier_name)) + + +@router.get("/{supplier_name:path}", response_model=ApiResponse[SupplierProfileData]) async def get_supplier_profile( supplier_name: str, session: DatabaseSession, diff --git a/app/services/suppliers.py b/app/services/suppliers.py index 99404c5..49fc95f 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -35,6 +35,14 @@ class SupplierProfileService: async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData: grouped = await self._load_supplier_summary(keyword=keyword) + # Some imported legacy rows have no anomaly date. They are excluded + # from the normal three-year risk window, but must still be discoverable + # when a user searches by a supplier/city name. + if keyword and not grouped: + grouped = await self._load_supplier_summary( + keyword=keyword, + include_undated=True, + ) items = [ SupplierOption( name=name, @@ -57,6 +65,11 @@ class SupplierProfileService: async def detail(self, supplier_name: str) -> SupplierProfileData: all_rows = await self._load_rows() rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)] + used_legacy_fallback = False + if not rows: + all_rows = await self._load_rows(include_undated=True) + rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)] + used_legacy_fallback = bool(rows) if not rows: raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404) @@ -117,11 +130,19 @@ class SupplierProfileService: monthly_trend=self._monthly_trend(rows), recent_records=self._recent_records(rows), factory_recommendations=[], - recommendation_note="合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。", + recommendation_note=( + "该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录," + "不将日期缺失数据混入近3年趋势。" + if used_legacy_fallback + else "合作次数按自然年度边界采购订单去重单据编号统计;异常数和供应商层级仍按异常案例库统计。" + ), ) async def _load_supplier_summary( - self, *, keyword: str | None = None + self, + *, + keyword: str | None = None, + include_undated: bool = False, ) -> dict[str, dict[str, int]]: """Load only supplier aggregates for the selector. @@ -134,14 +155,15 @@ class SupplierProfileService: start, end = calendar_year_window(self.as_of, self.history_years) statement = ( select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count()) - .where( - AnomalyCase.supplier.is_not(None), + .where(AnomalyCase.supplier.is_not(None)) + .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) + ) + if not include_undated: + statement = statement.where( AnomalyCase.date.is_not(None), AnomalyCase.date >= start, AnomalyCase.date <= end, ) - .group_by(AnomalyCase.supplier, AnomalyCase.defect_level) - ) grouped: dict[str, dict[str, int]] = defaultdict( lambda: {"total": 0, "a": 0, "b": 0} ) @@ -161,7 +183,12 @@ class SupplierProfileService: value["b"] += amount return grouped - async def _load_rows(self, *, supplier_name: str | None = None) -> list[dict]: + async def _load_rows( + self, + *, + supplier_name: str | None = None, + include_undated: bool = False, + ) -> list[dict]: start, end = calendar_year_window(self.as_of, self.history_years) statement = select( AnomalyCase.id, @@ -173,22 +200,54 @@ class SupplierProfileService: AnomalyCase.factory, AnomalyCase.anomaly_type, AnomalyCase.defect_level, - ).where( - AnomalyCase.date.is_not(None), - AnomalyCase.date >= start, - AnomalyCase.date <= end, - ) + ).where() + if not include_undated: + statement = statement.where( + AnomalyCase.date.is_not(None), + AnomalyCase.date >= start, + AnomalyCase.date <= end, + ) if supplier_name: statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%")) return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] @staticmethod def matches(value: str | None, target: str) -> bool: - if not value: + if not value or not target: return False - source = "".join(value.casefold().split()) - query = "".join(target.casefold().split()) - return bool(query and (query in source or source in query)) + # Supplier cells in the source workbooks often contain several names + # such as ``皓盛/佛山执得``. Match each segment separately and make + # common city/company suffixes optional so ``佛山市`` can find + # ``佛山执得`` while preserving the canonical raw value for display. + queries = SupplierProfileService._search_terms(target) + segments = SupplierProfileService._supplier_segments(value) + return any( + query and (query in segment or segment in query) + for query in queries + for segment in segments + ) + + @staticmethod + def _supplier_segments(value: str) -> list[str]: + segments = re.split(r"[//、,,;;|\\\\]+", value) + return [SupplierProfileService._compact_text(item) for item in segments if SupplierProfileService._compact_text(item)] + + @staticmethod + def _compact_text(value: str) -> str: + return re.sub(r"[\s()()【】\[\]{}]+", "", value.casefold()) + + @classmethod + def _search_terms(cls, value: str) -> set[str]: + compact = cls._compact_text(value) + terms = {compact} + # A user commonly enters a city with the administrative suffix while + # the workbook keeps the company/trading name (e.g. 佛山市 vs 佛山执得). + if compact.endswith("市") and len(compact) > 2: + terms.add(compact[:-1]) + for suffix in ("有限公司", "有限责任公司", "公司"): + if compact.endswith(suffix) and len(compact) > len(suffix) + 1: + terms.add(compact[: -len(suffix)]) + return {term for term in terms if term} @classmethod def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]: diff --git a/tests/test_suppliers.py b/tests/test_suppliers.py index 7d175d3..fa9f89f 100644 --- a/tests/test_suppliers.py +++ b/tests/test_suppliers.py @@ -19,6 +19,28 @@ def test_supplier_name_matching_supports_full_name_and_short_name() -> None: assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿") +def test_supplier_name_matching_supports_city_suffix_and_compound_supplier_cells() -> None: + assert SupplierProfileService.matches("皓盛/佛山执得", "佛山市") + assert SupplierProfileService.matches("皓盛/佛山执得", "佛山") + assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿") + + +def test_supplier_list_falls_back_to_undated_legacy_rows(monkeypatch) -> None: + service = SupplierProfileService.__new__(SupplierProfileService) + calls = [] + + async def fake_summary(*, keyword=None, include_undated=False): + calls.append(include_undated) + if not include_undated: + return {} + return {"皓盛/佛山执得": {"total": 1, "a": 0, "b": 0}} + + service._load_supplier_summary = fake_summary + result = __import__("asyncio").run(service.list(keyword="佛山市", limit=8)) + assert calls == [False, True] + assert result.items[0].name == "皓盛/佛山执得" + + def test_supplier_alpha_is_capped_and_rounded() -> None: assert SupplierProfileService.alpha(0) == 1.0 assert SupplierProfileService.alpha(42) == 1.13 From 5bdb2852e573353ebe3c631d26046face8cfb1d4 Mon Sep 17 00:00:00 2001 From: kx_guo Date: Thu, 10 Sep 2026 17:33:54 +0800 Subject: [PATCH 17/24] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=BE=9B=E5=BA=94?= =?UTF-8?q?=E5=95=86=E4=B8=BB=E6=95=B0=E6=8D=AE=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=90=91=E9=87=8F=E6=A3=80=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 ++ app/models/__init__.py | 4 + app/models/entities.py | 60 +++++++- app/schemas/suppliers.py | 8 ++ app/services/conversations.py | 36 ++++- app/services/database_risk_scoring.py | 19 ++- app/services/rag.py | 9 +- app/services/supplier_cleaning.py | 126 +++++++++++++++++ app/services/supplier_identity.py | 44 ++++++ app/services/suppliers.py | 83 ++++++++++-- ...20260910_0019_supplier_master_and_links.py | 62 +++++++++ scripts/backfill_embeddings.py | 52 ++++--- scripts/clean_suppliers.py | 128 ++++++++++++++++++ tests/test_models.py | 2 + tests/test_supplier_cleaning.py | 29 ++++ tests/test_suppliers.py | 4 + 16 files changed, 635 insertions(+), 40 deletions(-) create mode 100644 app/services/supplier_cleaning.py create mode 100644 app/services/supplier_identity.py create mode 100644 migrations/versions/20260910_0019_supplier_master_and_links.py create mode 100644 scripts/clean_suppliers.py create mode 100644 tests/test_supplier_cleaning.py diff --git a/README.md b/README.md index d0ccabb..d3631d0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,15 @@ python scripts/import_excel_data.py ` 脚本只导入异常明细 Sheet 的 2,448 条记录,以及标准文件中的“包装/包材/法规/内料”四个分类 Sheet 共 219 条记录;“总表”不重复导入。脚本可重复执行,已导入行会按源文件、Sheet 和 Excel 行号自动跳过。原始异常文本和标准附件链接会完整保留,不做截断。 +供应商清洗采用“原始字段保留 + 主数据 + 多对多关联”方式,不覆盖 `anomaly_cases.supplier`。使用具备建表及对 `anomaly_cases` 建外键权限的数据库账号执行: + +```powershell +python -m alembic upgrade head +python scripts/clean_suppliers.py +``` + +脚本会拆分 `/`、`、`、逗号、分号及换行分隔的复合供应商,生成 `supplier_master` 和 `anomaly_case_suppliers`;自动结果默认处于“待确认”状态。管理员应补充主数据中的标准名称、别名、城市与省份后再标记为“已确认”。供应商搜索会优先匹配标准名称、简称、别名、城市和省份;迁移尚未执行时自动回退到原始字段搜索。 + 风险评估预览:`POST http://127.0.0.1:18474/api/v1/risk-predictions/preview`。指标值表示风险程度(0-100),接口返回的综合分按 PRD 转换为安全得分,分数越高风险越低;等级阈值为 80/60/40/20。 对话页真实 AI:配置 `RISK_LLM_API_KEY`、`RISK_LLM_BASE_URL` 和 `RISK_LLM_MODEL` 后,调用 `POST /api/v1/conversations/{id}/stream`。接口使用 OpenAI 兼容 Chat Completions 协议,先提取 `intent/category/specification/packaging/supplier/factory/stage/confidence` 语义 JSON,再通过 SSE 返回增量文本;最近 20 条消息作为上下文,完整助手消息、语义结果和风险评分会在流结束后持久化。也兼容参考项目的 `OPENAI_API_KEY`、`LLM_BASE_URL`、`LLM_MODEL` 配置名。未配置密钥时返回明确的 503,不生成模拟回复。 diff --git a/app/models/__init__.py b/app/models/__init__.py index 4eafc59..c07312e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.entities import ( AnomalyCase, + AnomalyCaseSupplier, Conversation, ConversationMessage, OperationAudit, @@ -13,11 +14,13 @@ from app.models.entities import ( RolePermission, StandardRule, SupplierProfile, + SupplierMaster, UserAccount, ) __all__ = [ "AnomalyCase", + "AnomalyCaseSupplier", "Conversation", "ConversationMessage", "OperationAudit", @@ -29,5 +32,6 @@ __all__ = [ "RolePermission", "StandardRule", "SupplierProfile", + "SupplierMaster", "UserAccount", ] diff --git a/app/models/entities.py b/app/models/entities.py index 253ca86..ea87d86 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -4,7 +4,7 @@ from datetime import date, datetime from decimal import Decimal from typing import Any -from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, func +from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -59,6 +59,64 @@ class AnomalyCase(Base): standard_rule: Mapped["StandardRule | None"] = relationship(back_populates="anomaly_cases") review_records: Mapped[list["ReviewRecord"]] = relationship(back_populates="case") + supplier_links: Mapped[list["AnomalyCaseSupplier"]] = relationship( + back_populates="case", cascade="all, delete-orphan" + ) + + +class SupplierMaster(Base): + """Canonical supplier entity independent from raw workbook text.""" + + __tablename__ = "supplier_master" + __table_args__ = ( + Index("ix_supplier_master_city", "city"), + Index("ix_supplier_master_normalized_name", "normalized_name", unique=True), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + canonical_name: Mapped[str] = mapped_column(String(200), nullable=False) + short_name: Mapped[str | None] = mapped_column(String(100)) + normalized_name: Mapped[str] = mapped_column(String(200), nullable=False) + city: Mapped[str | None] = mapped_column(String(50)) + province: Mapped[str | None] = mapped_column(String(50)) + supplier_type: Mapped[str | None] = mapped_column(String(50)) + aliases: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) + status: Mapped[str] = mapped_column(String(20), default="启用", server_default="启用", nullable=False) + source: Mapped[str | None] = mapped_column(String(100)) + review_status: Mapped[str] = mapped_column(String(20), default="待确认", server_default="待确认", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + anomaly_links: Mapped[list["AnomalyCaseSupplier"]] = relationship( + back_populates="supplier", cascade="all, delete-orphan" + ) + + +class AnomalyCaseSupplier(Base): + """Many-to-many mapping from an anomaly case to canonical suppliers.""" + + __tablename__ = "anomaly_case_suppliers" + __table_args__ = ( + UniqueConstraint("case_id", "supplier_id", "supplier_role", name="uq_case_supplier_role"), + Index("ix_case_suppliers_supplier", "supplier_id"), + Index("ix_case_suppliers_case", "case_id"), + ) + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + case_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("anomaly_cases.id", ondelete="CASCADE"), nullable=False) + supplier_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("supplier_master.id", ondelete="CASCADE"), nullable=False) + supplier_role: Mapped[str | None] = mapped_column(String(50)) + material_type: Mapped[str | None] = mapped_column(String(200)) + source_text: Mapped[str | None] = mapped_column(Text) + match_method: Mapped[str] = mapped_column(String(30), default="manual", server_default="manual", nullable=False) + match_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) + review_status: Mapped[str] = mapped_column(String(20), default="待确认", server_default="待确认", nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + case: Mapped[AnomalyCase] = relationship(back_populates="supplier_links") + supplier: Mapped[SupplierMaster] = relationship(back_populates="anomaly_links") class SupplierProfile(Base): diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py index fb2ead1..eb640a3 100644 --- a/app/schemas/suppliers.py +++ b/app/schemas/suppliers.py @@ -9,6 +9,14 @@ class SupplierOption(ApiSchema): name: str anomaly_count: int tier: str + city: str | None = None + province: str | None = None + short_name: str | None = None + review_status: str | None = None + city: str | None = None + province: str | None = None + short_name: str | None = None + review_status: str | None = None class SupplierListData(ApiSchema): diff --git a/app/services/conversations.py b/app/services/conversations.py index 8408c3b..f8f523c 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -37,6 +37,7 @@ from app.schemas.risk_prediction import ( from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.llm_client import LLMClient from app.services.purchase_orders import calendar_year_window, supplier_order_counts +from app.services.supplier_identity import resolve_supplier from app.services.rag import AnomalyCaseRAG logger = logging.getLogger("dieya_risk_prediction.conversations") @@ -412,6 +413,9 @@ class ConversationService: ).all() ) supplier_name = self._infer_supplier_follow_up(text, supplier_names) + master_identity = await resolve_supplier(self.session, text) + if not supplier_name and master_identity: + supplier_name = master_identity.canonical_name supplier_name = supplier_name or self._infer_unknown_supplier_follow_up(text) if not supplier_name: return normalized @@ -421,7 +425,9 @@ class ConversationService: "risk_requested": True, "supplier": supplier_name, "confidence": max(float(normalized.get("confidence") or 0), 0.75), - "semantic_source": "database_supplier_match" + "semantic_source": "supplier_master_match" + if master_identity + else "database_supplier_match" if supplier_name in supplier_names else "standalone_new_supplier_fallback", } @@ -575,12 +581,19 @@ class ConversationService: match = re.search(r"(?:供应商|加工厂|工厂)\s*(?:是|用|为)?\s*([^,。!??]+)", text) name = match.group(1).strip() if match else "未指定" is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text + identity = ( + None + if is_factory or not hasattr(self, "session") + else await resolve_supplier(self.session, name) + ) + lookup_name = identity.short_name or identity.aliases[0] if identity else name + display_name = identity.canonical_name if identity else name column = AnomalyCase.factory if is_factory else AnomalyCase.supplier - anomaly_count, a_count, b_count = await self._supplier_history_stats(column, name) + anomaly_count, a_count, b_count = await self._supplier_history_stats(column, lookup_name) cooperation_count = ( await supplier_order_counts( self.session, - name, + display_name, self._supplier_history_start(), self._supplier_history_end(), ) @@ -592,7 +605,7 @@ class ConversationService: cooperation_display = cooperation_count if has_order_data else None problems = await self._top_anomaly_types( column, - name, + lookup_name, since=self._supplier_history_start(), ) party_type = "加工厂" if is_factory else "供应商" @@ -602,7 +615,7 @@ class ConversationService: database_score = await DatabaseRiskScoringService( self.session, as_of=datetime.now(UTC).date(), - ).score(supplier=name) + ).score(supplier=display_name) supplier_dimension = next( (item for item in database_score.dimensions if item.name == "供应商表现"), None, @@ -610,7 +623,7 @@ class ConversationService: return { "type": "supplier", "title": "供应商深度画像", - "name": name, + "name": display_name, "partyType": party_type, "anomalyCount": anomaly_count, "cooperationCount": cooperation_display, @@ -1039,6 +1052,15 @@ class ConversationService: factory = factory or supplier supplier = None supplier_or_factory = supplier or factory + supplier_identity = await resolve_supplier(self.session, supplier) + supplier_lookup = ( + supplier_identity.short_name or supplier_identity.aliases[0] + if supplier_identity + else supplier + ) + if supplier_identity: + supplier = supplier_identity.canonical_name + supplier_or_factory = supplier database_score = await DatabaseRiskScoringService( self.session, @@ -1059,7 +1081,7 @@ class ConversationService: category_count = await self._count_category_anomalies(category) supplier_count, supplier_a_count, supplier_b_count = await self._supplier_history_stats( AnomalyCase.supplier, - supplier, + supplier_lookup, ) factory_count = await self._count_anomalies(AnomalyCase.factory, factory) anomaly_count = supplier_count if supplier else factory_count diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index b495a6a..b627017 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -13,6 +13,7 @@ from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COE from app.domain.risk_engine import RiskLevel from app.models.entities import AnomalyCase, RiskModelConfig from app.services.purchase_orders import calendar_year_window, supplier_order_counts +from app.services.supplier_identity import resolve_supplier STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") @@ -69,7 +70,9 @@ class DatabaseRiskScoringService: category_name = normalize_category(category) material_name = normalize_material(material) stage_name = normalize_stage(stage) - party_name = clean_value(supplier) + identity = await resolve_supplier(self.session, clean_value(supplier)) + party_name = identity.canonical_name if identity else clean_value(supplier) + party_names = identity.match_names if identity else ((party_name,) if party_name else ()) resolved_cooperation_count = ( cooperation_count @@ -77,7 +80,7 @@ class DatabaseRiskScoringService: else await self._supplier_order_count(party_name) ) dimensions = ( - self._supplier_dimension(source_rows, party_name, resolved_cooperation_count), + self._supplier_dimension(source_rows, party_name, resolved_cooperation_count, party_names), self._material_dimension(source_rows, material_name), self._category_dimension(source_rows, category_name), DimensionScore( @@ -95,7 +98,7 @@ class DatabaseRiskScoringService: reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", ), self._stage_dimension(source_rows, stage_name), - self._defect_dimension(source_rows, category_name, material_name, party_name, defect_level), + self._defect_dimension(source_rows, category_name, material_name, party_name, defect_level, party_names), ) score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) @@ -148,6 +151,7 @@ class DatabaseRiskScoringService: rows: list[dict[str, Any]], party_name: str | None, cooperation_count: int | None = None, + party_names: tuple[str, ...] = (), ) -> DimensionScore: weight = self.weights["供应商表现"] if not party_name: @@ -158,7 +162,7 @@ class DatabaseRiskScoringService: status="default", reason="未提供供应商/加工厂,按 PRD 缺失维度默认 50 分", ) - matched = [row for row in rows if party_matches(row, party_name)] + matched = [row for row in rows if party_matches_any(row, party_names or (party_name,))] anomaly_count = len(matched) count = cooperation_count if cooperation_count is not None and cooperation_count > 0 else anomaly_count if count == 0: @@ -312,6 +316,7 @@ class DatabaseRiskScoringService: material: str | None, party_name: str | None, defect_level: str | None, + party_names: tuple[str, ...] = (), ) -> DimensionScore: weight = self.weights["缺陷等级"] if not category and not material and not party_name and not defect_level: @@ -331,7 +336,7 @@ class DatabaseRiskScoringService: or normalize_category(row.get("product_name")) == category ) and (not material or normalize_material(row.get("material_type")) == material) - and (not party_name or party_matches(row, party_name)) + and (not party_name or party_matches_any(row, party_names or (party_name,))) ] counts = { level: sum( @@ -433,6 +438,10 @@ def party_matches(row: dict[str, Any], party_name: str) -> bool: return party_name.lower() in supplier.lower() +def party_matches_any(row: dict[str, Any], party_names: tuple[str, ...]) -> bool: + return any(party_matches(row, name) for name in party_names) + + def classify_score(score: float) -> RiskLevel: if score >= 80: return RiskLevel.LOW diff --git a/app/services/rag.py b/app/services/rag.py index 29c70cc..ade10fc 100644 --- a/app/services/rag.py +++ b/app/services/rag.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.models.entities import AnomalyCase from app.schemas.risk_prediction import SimilarCase +from app.services.supplier_identity import resolve_supplier logger = logging.getLogger("dieya_risk_prediction.rag") _embedding_semaphore = asyncio.Semaphore(1) @@ -53,6 +54,12 @@ class AnomalyCaseRAG: stage: str | None = None, anomaly_type: str | None = None, ) -> list[RetrievedCase]: + identity = await resolve_supplier(self.session, supplier) + supplier_match = ( + identity.short_name or identity.aliases[0] + if identity + else supplier + ) terms = self._terms(query, category, material, supplier, factory, stage, anomaly_type) rows = await self._load_rows( query, @@ -71,7 +78,7 @@ class AnomalyCaseRAG: query=query, category=category, material=material, - supplier=supplier, + supplier=supplier_match, factory=factory, stage=stage, anomaly_type=anomaly_type, diff --git a/app/services/supplier_cleaning.py b/app/services/supplier_cleaning.py new file mode 100644 index 0000000..041b6f1 --- /dev/null +++ b/app/services/supplier_cleaning.py @@ -0,0 +1,126 @@ +"""Pure helpers for splitting and normalizing raw supplier cells.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +_SEPARATOR_RE = re.compile(r"[//、,,;;|\\\n]+") +_LABEL_RE = re.compile(r"(?P