diff --git a/.env.example b/.env.example index ef2d8a7..ba07f09 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,76 @@ +# ==================== 应用基础配置 ==================== +# 应用名称、版本和运行环境,用于接口文档、日志和运行行为识别。 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= + +# ==================== 百度 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= 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 + +# ==================== 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/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..98920a8 --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,51 @@ +# 测试服务器环境变量模板 +# 使用前复制为服务器项目目录下的 .env,并填写真实密钥。 + +RISK_APP_NAME=Dieya Risk Prediction Backend +RISK_APP_VERSION=0.1.0 +RISK_ENVIRONMENT=testing +RISK_DEBUG=false +RISK_LOG_LEVEL=INFO + +# 数据库 +RISK_DATABASE_URL=postgresql+asyncpg://<数据库用户>:<数据库密码>@<数据库地址>:5432/<数据库名> + +# JWT:请使用至少 32 字节随机字符串,例如 openssl rand -hex 32 +RISK_JWT_SECRET_KEY=<请填写至少32字节随机密钥> +RISK_JWT_ALGORITHM=HS256 +RISK_JWT_EXPIRE_MINUTES=480 + +# 钉钉免登 +RISK_DINGTALK_APP_KEY=<钉钉AppKey> +RISK_DINGTALK_APP_SECRET=<钉钉AppSecret> +RISK_DINGTALK_CORP_ID=<钉钉CorpId> +RISK_DINGTALK_AGENT_ID=<钉钉AgentId> + +# 百度 OCR +BAIDU_OCR_API_KEY=<百度OCR_API_KEY> +BAIDU_OCR_SECRET_KEY=<百度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 + +# LLM +RISK_LLM_API_KEY= +RISK_LLM_BASE_URL=https://api.openlux.ai/v1 +RISK_LLM_MODEL=gpt-4.1 +RISK_LLM_TIMEOUT_SECONDS=120 +RISK_LLM_TEMPERATURE=0.2 + +# 向量检索 +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=30 +RISK_RAG_TOP_K=5 + +# 测试服务器前端来源 +RISK_CORS_ALLOW_ORIGINS=https://risk.dieya.net.cn,http://risk.dieya.net.cn diff --git a/.gitignore b/.gitignore index 4e156bf..0288549 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,9 @@ __pycache__/ .coverage htmlcov/ .env +logs/ *.egg-info/ dist/ build/ data/ - +.runtime-uploads/ diff --git a/README.md b/README.md index b155f27..f475e47 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ 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`、普通用户 `user` 四类。模型权重由管理员通过 `GET/PATCH /api/v1/model-config` 维护,七维权重合计必须为 100%,修改仅对新评估生效。 + +钉钉免登用户按两级规则同步:钉钉管理员或部门主管映射为 `admin`(已包含审核权限),其他钉钉用户统一映射为 `user`。`product_manager` 和 `reviewer` 仅保留给非钉钉账号或后续独立用户管理流程使用。 + 健康检查:`GET http://127.0.0.1:18474/api/v1/health` 数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 @@ -37,6 +43,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/api/router.py b/app/api/router.py index 944db51..05b141a 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -6,15 +6,25 @@ 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.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 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) +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) 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/conversations.py b/app/api/v1/conversations.py index d4bac29..0a3ef0c 100644 --- a/app/api/v1/conversations.py +++ b/app/api/v1/conversations.py @@ -14,6 +14,7 @@ from app.schemas.conversation import ( ConversationData, ConversationMessageCreateRequest, ConversationSummary, + DirectQueryRequest, ) from app.services.conversations import ConversationService @@ -39,6 +40,15 @@ async def create_conversation( return ApiResponse(data=await ConversationService(session).create(current_user, payload.title)) +@router.post("/direct-query", response_model=ApiResponse[dict]) +async def direct_query( + payload: DirectQueryRequest, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[dict]: + return ApiResponse(data=await ConversationService(session).direct_query(payload.kind, payload.query)) + + @router.get("/{conversation_id}", response_model=ApiResponse[ConversationData]) async def get_conversation( conversation_id: int, @@ -57,6 +67,16 @@ async def get_conversation( ) +@router.delete("/{conversation_id}", response_model=ApiResponse[None]) +async def delete_conversation( + conversation_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], +) -> ApiResponse[None]: + await ConversationService(session).delete(conversation_id, current_user) + return ApiResponse(data=None, message="对话已删除") + + @router.post("/{conversation_id}/messages", response_model=ApiResponse[ConversationData]) async def send_conversation_message( conversation_id: int, @@ -74,6 +94,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/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/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/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/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/api/v1/risk_items.py b/app/api/v1/risk_items.py index 54279cd..8153da7 100644 --- a/app/api/v1/risk_items.py +++ b/app/api/v1/risk_items.py @@ -49,6 +49,16 @@ async def update_risk_item( return ApiResponse(data=await RiskItemService(session).update(item_id, payload, current_user)) +@router.post("/{item_id}/withdraw", response_model=ApiResponse[None]) +async def withdraw_risk_item( + item_id: int, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("risk_item.review", operate=True))], +) -> ApiResponse[None]: + await RiskItemService(session).withdraw(item_id, current_user) + return ApiResponse(data=None, message="风险项已撤回") + + @router.post("/{item_id}/review", response_model=ApiResponse[ReviewRecordData]) async def review_risk_item( item_id: int, 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/api/v1/suppliers.py b/app/api/v1/suppliers.py new file mode 100644 index 0000000..187bdb1 --- /dev/null +++ b/app/api/v1/suppliers.py @@ -0,0 +1,42 @@ +"""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("/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, + _current_user: Annotated[UserAccount, Depends(require_permission("supplier.read"))], +) -> ApiResponse[SupplierProfileData]: + return ApiResponse(data=await SupplierProfileService(session).detail(supplier_name)) diff --git a/app/core/config.py b/app/core/config.py index 6bd7f70..c603c9f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,7 +1,8 @@ """Environment-backed application settings.""" from functools import lru_cache -from typing import Annotated +from pathlib import Path +from typing import Annotated, Literal from pydantic import AliasChoices, Field, SecretStr, field_validator from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict @@ -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, @@ -53,6 +73,76 @@ 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"), + ) + 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", @@ -77,6 +167,16 @@ 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("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/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/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/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/models/__init__.py b/app/models/__init__.py index 9488dce..c07312e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,28 +2,36 @@ from app.models.entities import ( AnomalyCase, + AnomalyCaseSupplier, Conversation, ConversationMessage, OperationAudit, + PurchaseOrderLine, ReviewRecord, RiskAssessment, RiskItem, + RiskModelConfig, RolePermission, StandardRule, SupplierProfile, + SupplierMaster, UserAccount, ) __all__ = [ "AnomalyCase", + "AnomalyCaseSupplier", "Conversation", "ConversationMessage", "OperationAudit", "ReviewRecord", "RiskAssessment", "RiskItem", + "RiskModelConfig", + "PurchaseOrderLine", "RolePermission", "StandardRule", "SupplierProfile", + "SupplierMaster", "UserAccount", ] diff --git a/app/models/entities.py b/app/models/entities.py index b2e6ce5..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): @@ -94,6 +152,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.""" @@ -119,9 +207,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 +235,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 +260,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 +278,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 +413,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/repositories/conversations.py b/app/repositories/conversations.py index 0f8299e..24608a1 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 @@ -72,3 +71,13 @@ class ConversationRepository: .options(selectinload(Conversation.messages)) ) return await self.session.scalar(statement) + + async def delete_for_user(self, conversation_id: int, user_id: int) -> bool: + """Delete a conversation only when it belongs to the requesting user.""" + + conversation = await self.get_for_user(conversation_id, user_id) + if conversation is None: + return False + await self.session.delete(conversation) + await self.session.flush() + return True diff --git a/app/schemas/conversation.py b/app/schemas/conversation.py index 3c60ee4..7dedf78 100644 --- a/app/schemas/conversation.py +++ b/app/schemas/conversation.py @@ -19,6 +19,11 @@ class ConversationMessageCreateRequest(ApiSchema): message_type: Literal["text", "image", "file", "audio"] = "text" +class DirectQueryRequest(ApiSchema): + kind: Literal["supplier", "material", "cases", "solution"] + query: str = Field(min_length=1, max_length=2000) + + class ConversationListQuery(ApiSchema): limit: int = Field(default=50, ge=1, le=100) 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/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/schemas/risk_prediction.py b/app/schemas/risk_prediction.py index 528cc72..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 @@ -14,7 +16,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): @@ -37,9 +45,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 = "暂无匹配采购订单" + note: str | None = None + data_available: bool = False class StandardJudgment(ApiSchema): @@ -54,13 +65,82 @@ 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): + 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 + result: str | None = None problem: str | None = None solution: str | None = None + 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 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): @@ -77,6 +157,8 @@ 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) + report_cards: list[ReportCard] = Field(default_factory=list) class ModelInfo(ApiSchema): diff --git a/app/schemas/suppliers.py b/app/schemas/suppliers.py new file mode 100644 index 0000000..eb640a3 --- /dev/null +++ b/app/schemas/suppliers.py @@ -0,0 +1,75 @@ +"""Supplier profile API contracts.""" + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +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): + 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 + supplier_base_score: float | None = None + supplier_score: float | None = None + risk_score: float | None = None + main_category_count: int + cooperation_count_basis: str = "按自然年度边界采购订单去重单据编号统计" + anomaly_rate_basis: str = "暂无采购订单/生产批次总量,无法计算真实异常率" + + +class SupplierCategoryBreakdown(ApiSchema): + category: str + is_unclassified: bool = False + 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/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/conversations.py b/app/services/conversations.py index 454bc75..f78d1b6 100644 --- a/app/services/conversations.py +++ b/app/services/conversations.py @@ -1,16 +1,24 @@ """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 +from datetime import UTC, date, datetime 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, @@ -22,13 +30,21 @@ from app.schemas.risk_prediction import ( RiskAssessmentResult, RiskDimensionScore, RiskFactorResult, + SimilarCase, StandardJudgment, SupplierFrequency, ) +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") class ConversationService: + _supplier_history_years = 3 _engine = BaselineRiskEngine() _risk_intents = { "assessment", @@ -93,13 +109,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: @@ -114,6 +128,67 @@ class ConversationService: raise AppException("对话创建失败", code=500, status_code=500) return self._data(persisted, messages=[], total=0, offset=0) + async def direct_query(self, kind: str, query: str) -> dict[str, Any]: + """Build a shortcut result without creating a conversation message.""" + text = query.strip() + if not text: + raise AppException("查询内容不能为空", code=422, status_code=422) + if kind == "supplier": + return await self._supplier_card(text, {"supplier": text}) + if kind == "material": + return await self._material_card(text, {"packaging": text}) + if kind == "solution": + semantics = self._normalize_semantics( + text, + await self.llm.extract_semantics(text, history=[]), + ) + return await self._solution_card(text, semantics) + results = await self._similar_cases( + text, + category=None, + material=None, + supplier=None, + factory=None, + stage=None, + anomaly_type=None, + ) + top_cases = list(results[:3]) + # Keep the same similarity signal used by the risk assessment card. + # Vector retrieval populates it in RAG; lexical fallback still gets a + # deterministic score so the shortcut card never loses that field. + for item in top_cases: + if item.case.similarity is None: + item.case.similarity = round(max(0.0, min(1.0, item.score / 100)), 3) + return { + "type": "cases", + "title": "案例检索", + "query": text, + "cases": [item.case.model_dump(mode="json", by_alias=True) for item in top_cases], + "matchedCount": len(top_cases), + "suggestions": [ + "同类问题优先复用历史方案,再按判定标准复测验证", + "如已出货,准备客诉应对预案并对同批次抽检", + ], + } + + async def delete(self, conversation_id: int, user: UserAccount) -> None: + """Delete one user-owned conversation and retain an audit trail.""" + + conversation = await self.repository.get_for_user(conversation_id, user.id) + if conversation is None: + raise AppException("对话不存在或无权访问", code=404, status_code=404) + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="conversation.delete", + resource_type="conversation", + resource_id=str(conversation_id), + audit_metadata={"title": conversation.title[:120]}, + ) + ) + await self.repository.delete_for_user(conversation_id, user.id) + async def send( self, conversation_id: int, @@ -142,7 +217,7 @@ class ConversationService: assistant_message = ConversationMessage( conversation_id=conversation.id, role="assistant", - content="已根据当前描述完成风险预估,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。", + content="已根据当前描述完成风险预警分析,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。", structured_data=( {"riskResult": risk_result.model_dump(mode="json", by_alias=False)} if risk_result @@ -160,13 +235,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( @@ -198,7 +271,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, @@ -208,6 +284,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) @@ -226,36 +307,53 @@ class ConversationService: yield {"type": "error", "message": "LLM 未返回有效内容"} return - show_risk = self._is_risk_request(text, semantics) - response_type = self._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": "对话读取失败"} @@ -265,13 +363,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)} @@ -285,7 +381,7 @@ class ConversationService: { "role": "system", "content": ( - "你是蝶雅产品风险预估助手。请基于用户当前问题和历史上下文回答," + "你是蝶雅产品风险预警助手。请基于用户当前问题和历史上下文回答," "持续理解产品品类、规格、包材、供应商、加工厂和开发阶段。" "回答要具体、简洁、可执行;不要声称已经完成 RAG、OCR 或语音识别。" ), @@ -309,6 +405,16 @@ 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" + 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 ("供应商", "加工厂", "工厂")) @@ -318,19 +424,149 @@ 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 ("合规", "宣称", "备案", "法规")): 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) + 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 + normalized.update( + { + "intent": "supplier", + "risk_requested": True, + "supplier": supplier_name, + "confidence": max(float(normalized.get("confidence") or 0), 0.75), + "semantic_source": "supplier_master_match" + if master_identity + else "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,36 +583,176 @@ 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: 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 = 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, lookup_name) + cooperation_count = ( + await supplier_order_counts( + self.session, + display_name, + self._supplier_history_start(), + self._supplier_history_end(), + ) + 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, + lookup_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) + 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=display_name) + supplier_dimension = next( + (item for item in database_score.dimensions if item.name == "供应商表现"), + None, + ) return { "type": "supplier", "title": "供应商深度画像", - "name": name, + "name": display_name, "partyType": party_type, "anomalyCount": anomaly_count, - "cooperationCount": None, - "tier": self._supplier_tier(anomaly_count), - "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 ["暂无历史合作数据,建议首单全检并安排驻厂巡检"] + "cooperationCount": cooperation_display, + "tier": tier, + "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 ), - "note": "合作次数需要采购合作台账接入后统计,当前仅展示异常案例库中的真实记录。", + "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": "按自然年度边界采购订单去重单据数" if has_order_data else "暂无匹配采购订单", + "note": ( + "合作次数按自然年度边界采购订单去重单据编号统计。" + if has_order_data + else "未匹配到该供应商采购订单,不展示合作次数。" + ), + "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")) + 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) @@ -451,10 +827,41 @@ 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) + 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) risks: list[dict[str, str]] = [] if "次氯酸" in text or "铝" in text: risks.append( @@ -480,33 +887,143 @@ 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": [ + {"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 + logger.warning( + "Solution card AI suggestions failed error=%s; using deterministic fallback", + exc.__class__.__name__, + ) + suggestions = fallback_suggestions + return { "type": "solution", "title": "AI 案例推理生成", "confidence": "中", + "thinkingSummary": [ + f"识别产品场景:{category},提取包装、供应链和风险问题等关键信息", + f"检索历史异常案例,匹配到 {len(similar_cases)} 条相似案例并进行相似度排序", + f"关联判定标准库 {len(standards)} 条规则,完成规则引擎兜底校验", + f"综合历史案例与标准规则,整理 {len(suggestions)} 条可执行建议", + ], "analysisBase": ( 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": [ - "小样阶段完成内容物与包装材料相容性测试", - "增加倒置密封测试和加速老化测试", - "首单安排来料全检,并根据结果决定是否驻厂巡检", - "实际执行前由品控确认并安排小批量试产验证", + "similarCases": [ + item.case.model_dump(mode="json", by_alias=False) + for item in similar_cases ], + "risks": risks, + "suggestions": suggestions, "warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", } - async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001 + 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, # 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 <= self._supplier_history_end(), + ] + ) 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) @@ -602,21 +1119,75 @@ 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, + as_of=datetime.now(UTC).date(), + ).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, supplier_a_count, supplier_b_count = await self._supplier_history_stats( + AnomalyCase.supplier, + supplier_lookup, + ) 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) + 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 = 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) + cooperation_count = ( + await supplier_order_counts( + self.session, + supplier, + self._supplier_history_start(), + self._supplier_history_end(), + ) + 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( + supplier_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, category_count, @@ -626,17 +1197,50 @@ 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) + supplier_dimension = next( + (item for item in database_score.dimensions if item.name == "供应商表现"), + None, + ) + 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, + similar_cases=similar_cases, + ) 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, @@ -648,21 +1252,38 @@ 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=cooperation_count, + anomaly_count=supplier_count, + 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="按自然年度边界采购订单去重单据数" if cooperation_count else "暂无匹配采购订单", + note=( + "合作次数按自然年度边界采购订单去重单据编号统计;异常数按自然年度边界异常案例统计。" + if cooperation_count + else "未匹配到该供应商采购订单,不展示合作次数。" + ), + data_available=bool(cooperation_count), + ) + if supplier + else SupplierFrequency( + note="未指定供应商,暂无供应商频次分析。", + frequency_basis="未提供供应商", + ) ), "standard_judgment": standard_rows, "dimension_scores": dimensions, - "similar_cases": [], + "similar_cases": [ + SimilarCase.model_validate(item.case.model_dump(mode="json", by_alias=False)) + for item in similar_cases + ], "ai_suggestions": suggestions, } ) @@ -671,10 +1292,141 @@ 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 <= self._supplier_history_end(), + ) ) return int(count or 0) + @classmethod + def _supplier_history_start(cls) -> date: + 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, + 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 <= self._supplier_history_end(), + ) + .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, + *, + 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], + similar_cases: Sequence[Any], + ) -> 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": [ + { + "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) + 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 _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. @@ -692,7 +1444,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 <= self._supplier_history_end(), ) ) return int(count or 0) @@ -773,17 +1528,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, @@ -878,9 +1656,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 +1674,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/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/database_risk_scoring.py b/app/services/database_risk_scoring.py new file mode 100644 index 0000000..b627017 --- /dev/null +++ b/app/services/database_risk_scoring.py @@ -0,0 +1,454 @@ +"""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.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 calendar_year_window, supplier_order_counts +from app.services.supplier_identity import resolve_supplier + +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 + base_score: float | None = None + frequency_alpha: float | 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() + self.weights = DEFAULT_WEIGHTS.copy() + self.frequency_coefficient = DEFAULT_FREQUENCY_COEFFICIENT + self.frequency_cap = DEFAULT_FREQUENCY_CAP + + 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, + rows: list[dict[str, Any]] | None = None, + cooperation_count: int | None = None, + ) -> DatabaseRiskScore: + await self._load_model_config() + 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) + 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 + if cooperation_count is not None + else await self._supplier_order_count(party_name) + ) + dimensions = ( + 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( + name="加工厂质量", + score=50.0, + weight=self.weights["加工厂质量"], + status="default", + reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入", + ), + DimensionScore( + name="标准符合性", + score=50.0, + weight=self.weights["标准符合性"], + status="default", + reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", + ), + self._stage_dimension(source_rows, stage_name), + 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) + + 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, + *calendar_year_window(self.as_of, 3), + ) + 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: + 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, end = calendar_year_window(self.as_of, 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 <= end, + ) + 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, + cooperation_count: int | None = None, + party_names: tuple[str, ...] = (), + ) -> DimensionScore: + weight = self.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_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: + 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(self.frequency_cap, 1.0 + count / 100 * self.frequency_coefficient) + score = min(100.0, round(base * alpha, 2)) + return DimensionScore( + name="供应商表现", + score=score, + weight=weight, + status="database", + 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, + base_score=base, + frequency_alpha=alpha, + ) + + def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore: + weight = self.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, + ) + + def _category_dimension(self, rows: list[dict[str, Any]], category: str | None) -> DimensionScore: + weight = self.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, + ) + + def _stage_dimension(self, rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: + weight = self.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, + ) + + def _defect_dimension( + self, + rows: list[dict[str, Any]], + category: str | None, + 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: + 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_any(row, party_names or (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 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 + 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/dingtalk_auth.py b/app/services/dingtalk_auth.py index fdee144..17cae50 100644 --- a/app/services/dingtalk_auth.py +++ b/app/services/dingtalk_auth.py @@ -1,6 +1,7 @@ """Server-side DingTalk auth-code exchange and local account upsert.""" import json +import logging import time from datetime import UTC, datetime @@ -17,6 +18,8 @@ from app.services.auth import AuthService TOKEN_URL = "https://oapi.dingtalk.com/gettoken" USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo" USERDETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/user/get" +DEPARTMENT_DETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/department/get" +logger = logging.getLogger("dieya_risk_prediction.dingtalk") class DingTalkServiceError(AppException): @@ -101,7 +104,12 @@ class DingTalkAuthService: data = response.json() except httpx.HTTPError as exc: raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from exc - user_id = (data.get("result") or {}).get("userid") + result = data.get("result") or data.get("user") or data.get("data") or {} + user_id = ( + result.get("userid") or result.get("userId") or result.get("unionid") + if isinstance(result, dict) + else None + ) if data.get("errcode") or not user_id: raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) return str(user_id) @@ -116,49 +124,104 @@ class DingTalkAuthService: ) response.raise_for_status() data = response.json() - except httpx.HTTPError: + except httpx.HTTPError as exc: + logger.warning("钉钉用户详情请求失败 user_id=%s error=%s", user_id, exc.__class__.__name__) return {"userid": user_id} - return data.get("result") or {"userid": user_id} + if data.get("errcode") not in (None, 0, "0"): + logger.warning( + "钉钉用户详情接口返回错误 user_id=%s errcode=%s errmsg=%s", + user_id, + data.get("errcode"), + data.get("errmsg") or data.get("message"), + ) + detail = data.get("result") or data.get("user") or {"userid": user_id} + if not isinstance(detail, dict): + detail = {"userid": user_id} + detail.setdefault("userid", user_id) + # Some DingTalk responses wrap the actual user object one level deeper. + nested = detail.get("user") or detail.get("data") + if isinstance(nested, dict): + detail = {**detail, **nested} + dept_ids = self._department_ids(detail) + if dept_ids and not self._department_name(detail): + dept_name = await self._department_name_from_id(access_token, dept_ids[0]) + if dept_name: + detail["dept_name"] = dept_name + return detail + + async def _department_name_from_id(self, access_token: str, department_id: int) -> str | None: + try: + async with httpx.AsyncClient(timeout=8) as client: + response = await client.post( + DEPARTMENT_DETAIL_URL, + params={"access_token": access_token}, + json={"dept_id": department_id, "language": "zh_CN"}, + ) + response.raise_for_status() + data = response.json() + except (httpx.HTTPError, ValueError): + return None + result = data.get("result") or data.get("department") or {} + if not isinstance(result, dict): + return None + name = result.get("name") or result.get("dept_name") or result.get("deptName") + return str(name).strip()[:50] if name else None + + @staticmethod + def _department_name(detail: dict) -> str | None: + value = detail.get("dept_name") or detail.get("deptName") or detail.get("department_name") or detail.get("departmentName") + return str(value).strip() if value else None + + @staticmethod + def _department_ids(detail: dict) -> list[int]: + raw = (detail.get("dept_id_list") or detail.get("deptIdList") or detail.get("dept_id_list_str") or detail.get("department_ids") or detail.get("dept_id") or detail.get("main_dept_id") or detail.get("mainDeptId") or []) + if not isinstance(raw, list): + raw = [raw] + result: list[int] = [] + for value in raw: + try: + result.append(int(value)) + except (TypeError, ValueError): + continue + return result + + @staticmethod + def _display_name(detail: dict, fallback: str) -> str: + value = detail.get("name") or detail.get("nick") or detail.get("nickname") or detail.get("real_name") or detail.get("display_name") or detail.get("user_name") or detail.get("userName") or fallback + return str(value).strip()[:50] or fallback async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount: user = await self.repo.get_by_dingtalk_id(user_id) - role = self.map_role( + mapped_role = self.map_role( str(detail.get("dept_name") or detail.get("deptName") or ""), str(detail.get("title") or detail.get("position") or ""), - bool(detail.get("is_admin") or detail.get("is_dept_manager")), + self._is_dingtalk_manager(detail), ) - if user is not None and role == "普通用户" and user.role != "普通用户": - role = user.role if user is None: user = UserAccount( emp_no=f"dt_{user_id}"[:20], - name=str(detail.get("name") or user_id)[:50], - role=role, + name=self._display_name(detail, user_id), + role=mapped_role, status="启用", dingtalk_id=user_id, ) self.session.add(user) - user.name = str(detail.get("name") or user.name)[:50] - user.dept = str(detail.get("dept_name") or user.dept or "")[:50] or None - user.position = str(detail.get("title") or user.position or "")[:50] or None - user.role = role + user.name = self._display_name(detail, user.name or user_id) + user.dept = (self._department_name(detail) or user.dept or "")[:50] or None + user.position = str(detail.get("title") or detail.get("position") or detail.get("job_title") or user.position or "")[:50] or None + user.role = self.resolve_synced_role(user.role, mapped_role) user.dingtalk_union_id = str(detail.get("unionid") or user.dingtalk_union_id or "")[:64] or None user.dingtalk_avatar = str(detail.get("avatar") or user.dingtalk_avatar or "")[:512] or None user.dingtalk_job_number = str(detail.get("job_number") or user.dingtalk_job_number or "")[:64] or None user.dingtalk_title = str(detail.get("title") or user.dingtalk_title or "")[:128] or None - dept_id = detail.get("main_dept_id") or detail.get("dept_id") or user.dingtalk_dept_id - if isinstance(dept_id, list): - dept_id = dept_id[0] if dept_id else None + dept_ids = self._department_ids(detail) + dept_id = dept_ids[0] if dept_ids else user.dingtalk_dept_id try: user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None except (TypeError, ValueError): user.dingtalk_dept_id = None - user.dingtalk_is_dept_manager = bool( - detail.get("is_admin") - or detail.get("is_dept_manager") - or user.dingtalk_is_dept_manager - ) - role_codes = detail.get("role_list") or user.dingtalk_role_codes + user.dingtalk_is_dept_manager = self._is_dingtalk_manager(detail) + role_codes = detail.get("role_list") or detail.get("roleList") or user.dingtalk_role_codes if isinstance(role_codes, (list, dict)): role_codes = json.dumps(role_codes, ensure_ascii=False) user.dingtalk_role_codes = str(role_codes or "")[:512] or None @@ -170,21 +233,52 @@ 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 users to admin (including review) or regular user.""" - text = f"{dept} {title}".lower() - if is_admin or "it" in text or "信息" in text or "系统" in text: + return "admin" if is_admin else "user" + + @staticmethod + def resolve_synced_role(current_role: str, mapped_role: str) -> str: + """Apply DingTalk role mapping without downgrading assigned roles. + + Existing roles are treated as administrator-managed data. DingTalk may + promote a regular user when it reports a manager flag, but a missing + flag must never downgrade an existing admin (or another assigned role). + """ + + if current_role == "user" and mapped_role == "admin": 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" - return "普通用户" + return current_role + + @staticmethod + def _is_dingtalk_manager(detail: dict) -> bool: + """Read administrator/department-leader flags from DingTalk API variants.""" + + direct = ( + detail.get("admin"), + detail.get("is_admin"), + detail.get("isAdmin"), + detail.get("is_leader"), + detail.get("isLeader"), + detail.get("is_boss"), + detail.get("isBoss"), + detail.get("is_dept_manager"), + detail.get("isDeptManager"), + ) + if any(value is True or str(value).lower() in {"1", "true"} for value in direct if value is not None): + return True + leaders = detail.get("leader_in_dept") or detail.get("leaderInDept") or [] + if isinstance(leaders, dict): + leaders = [leaders] + if isinstance(leaders, list): + return any( + item is True + or str(item).lower() in {"1", "true"} + or isinstance(item, dict) + and any( + value is True or str(value).lower() in {"1", "true"} + for value in item.values() + ) + for item in leaders + ) + return False diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 4d3266f..df26f3c 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,311 @@ 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 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.""" + + 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 +423,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/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/purchase_orders.py b/app/services/purchase_orders.py new file mode 100644 index 0000000..b3b3427 --- /dev/null +++ b/app/services/purchase_orders.py @@ -0,0 +1,144 @@ +"""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 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 + 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/rag.py b/app/services/rag.py new file mode 100644 index 0000000..ade10fc --- /dev/null +++ b/app/services/rag.py @@ -0,0 +1,320 @@ +"""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 +from app.services.supplier_identity import resolve_supplier + +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]: + 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, + 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_match, + 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, + AnomalyCase.result, + ) + 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, 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) + 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"), + result=row.get("result"), + problem=(row.get("anomaly_type") or row.get("description") or "历史异常未填写"), + solution=(row.get("solution") or row.get("root_cause") or "请查看原始案例记录"), + ) diff --git a/app/services/reports.py b/app/services/reports.py new file mode 100644 index 0000000..beecf76 --- /dev/null +++ b/app/services/reports.py @@ -0,0 +1,667 @@ +"""PDF rendering for the prototype-aligned risk report.""" + +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 +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 ReportCard, RiskAssessmentResult + +LEVEL_LABELS = { + "low": "低风险", + "moderately_low": "较低风险", + "medium": "中等风险", + "high": "高风险", + "very_high": "极高风险", +} + + +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 _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"] + 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), + ) + 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, + ), + ) + # 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] + return { + "cards": cards, + "supplier_cards": supplier_cards, + "material_cards": material_cards, + "solution_cards": solution_cards, + "standard_cards": standard_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"), + 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") + report_data = aggregate_report_data(result) + 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), + 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(standards)} 条判定标准,待检测项需人工确认" + 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 + supplier_cards = report_data["supplier_cards"] + 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} 次", "按自然年度边界采购订单去重单据数"]) + 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 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])] + + 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"{'五' if section_shift else '四'}、相似案例匹配({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 similar_cases + ], + [28 * mm, 42 * mm, 48 * mm, 42 * mm], + ) + ) + else: + story.append(Paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。", small)) + + 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(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)", section), + Paragraph( + "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。", + body, + ), + ] + + 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)) + 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)) + + 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, "待确认"]) + for suggestion in card.suggestions: + standard_rows.append(["执行建议", suggestion, card.title, "待确认"]) + if standard_rows: + story.append( + table( + [["判定维度", "判定标准", "标准来源", "当前状态"]] + + standard_rows, + [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) + report_data = aggregate_report_data(result) + 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() + 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(standards)} 条判定标准,待检测项需人工确认" + 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 "结合相似品类和标准库预判", + ], + ] + ) + 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 and result.supplier_freq.cooperation_count is not None: + 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: + rows.append( + ["频次风险系数", f"×{result.supplier_freq.alpha:.2f}", "α = 1.0 +(采购订单数 / 100)× 0.3,最高1.5"] + ) + 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) + 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"{'五' if section_shift else '四'}、相似案例匹配({len(similar_cases)} 条)") + if similar_cases: + add_table( + [["日期", "产品", "问题", "处理方案"]] + + [ + [case.date or "未知", case.product or "未命名", case.problem or "未填写", case.solution or "未填写"] + for case in similar_cases + ] + ) + else: + document.add_paragraph("暂无相关案例,RAG 相似案例检索未返回匹配结果。") + heading(f"{'六' if section_shift else '五'}、预防建议") + for index, suggestion in enumerate(suggestions or ["优先针对低分维度安排专项验证,并明确责任人与完成节点。"], 1): + document.add_paragraph(f"{index}. {suggestion}") + heading(f"{'七' if section_shift else '六'}、AI 推理方案(无历史案例场景)") + document.add_paragraph( + "本次评估已采用历史案例与标准库双引擎结论;若后续未匹配到足够历史案例,将基于相似品类、相似物料生成类比方案,并由标准库进行规则兜底。" + ) + 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}") + for index, suggestion in enumerate(card.suggestions, 1): + document.add_paragraph(f"方案建议 {index}:{suggestion}") + if card.warning: + document.add_paragraph(f"风险提示:{card.warning}") + 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( + [["判定维度", "判定标准", "标准来源", "当前状态"]] + + standard_rows + ) + else: + document.add_paragraph("暂无相关标准。") + document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。") + + output = BytesIO() + document.save(output) + return output.getvalue() diff --git a/app/services/risk_items.py b/app/services/risk_items.py index 7f06c34..e6eeef8 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 "待预审", @@ -64,6 +68,8 @@ class RiskItemService: filters = [] if status != "all": filters.append(RiskItem.status == status) + else: + filters.append(RiskItem.status != "已撤回") if source_type != "all": filters.append(RiskItem.source_type == source_type) statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit) @@ -101,6 +107,26 @@ class RiskItemService: await self.session.flush() return self.to_data(item) + async def withdraw(self, item_id: int, operator: UserAccount) -> None: + item = await self.session.get(RiskItem, item_id) + if item is None: + raise AppException("风险项不存在", code=404, status_code=404) + if item.status not in {"待审核", "待预审"}: + raise AppException("只有待处理风险项可以撤回", code=409, status_code=409) + previous_status = item.status + item.status = "已撤回" + self.session.add( + OperationAudit( + actor_user_id=operator.id, + actor_emp_no=operator.emp_no, + action="risk_item.withdraw", + resource_type="risk_item", + resource_id=str(item.id), + audit_metadata={"previous_status": previous_status}, + ) + ) + await self.session.flush() + async def review(self, item_id: int, action: str, note: str, reviewer: UserAccount) -> ReviewRecordData: item = await self.session.get(RiskItem, item_id) if item is None: @@ -111,9 +137,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 +148,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 +158,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/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/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