Compare commits
No commits in common. "6cc9b3315951aeb25fd867913f6346abf3bfd5c1" and "ec8b83d0153036898ba592ac0e3365022ca1209f" have entirely different histories.
6cc9b33159
...
ec8b83d015
57
.env.example
57
.env.example
@ -1,76 +1,19 @@
|
||||
# ==================== 应用基础配置 ====================
|
||||
# 应用名称、版本和运行环境,用于接口文档、日志和运行行为识别。
|
||||
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
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
# 测试服务器环境变量模板
|
||||
# 使用前复制为服务器项目目录下的 .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=<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=<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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,9 +6,8 @@ __pycache__/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.env
|
||||
logs/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
.runtime-uploads/
|
||||
|
||||
|
||||
15
README.md
15
README.md
@ -22,12 +22,6 @@ 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 表/字段注释。
|
||||
@ -43,15 +37,6 @@ 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,不生成模拟回复。
|
||||
|
||||
@ -6,25 +6,15 @@ 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)
|
||||
|
||||
@ -14,7 +14,6 @@ from app.schemas.conversation import (
|
||||
ConversationData,
|
||||
ConversationMessageCreateRequest,
|
||||
ConversationSummary,
|
||||
DirectQueryRequest,
|
||||
)
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
@ -40,15 +39,6 @@ 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,
|
||||
@ -67,16 +57,6 @@ 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,
|
||||
@ -94,22 +74,6 @@ 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,
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
"""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))
|
||||
@ -1,28 +0,0 @@
|
||||
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")})
|
||||
@ -1,50 +0,0 @@
|
||||
"""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)
|
||||
@ -1,38 +0,0 @@
|
||||
"""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"'},
|
||||
)
|
||||
@ -49,16 +49,6 @@ 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,
|
||||
|
||||
@ -4,7 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.dependencies import DatabaseSession, require_permission
|
||||
from app.api.dependencies import require_permission
|
||||
from app.models.entities import UserAccount
|
||||
from app.schemas.common import ApiResponse
|
||||
from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult
|
||||
@ -23,7 +23,6 @@ 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=await RiskPredictionService.preview(request, session))
|
||||
return ApiResponse(data=RiskPredictionService.preview(request))
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
"""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))
|
||||
@ -1,8 +1,7 @@
|
||||
"""Environment-backed application settings."""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import AliasChoices, Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
@ -25,25 +24,6 @@ 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,
|
||||
@ -73,76 +53,6 @@ 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",
|
||||
@ -167,16 +77,6 @@ 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:
|
||||
|
||||
@ -1,60 +1,20 @@
|
||||
"""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
|
||||
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)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
|
||||
|
||||
logger = configure_logging()
|
||||
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
"""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
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
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
|
||||
@ -26,7 +25,6 @@ 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
|
||||
|
||||
@ -2,36 +2,28 @@
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
@ -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, UniqueConstraint, func
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@ -59,64 +59,6 @@ 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):
|
||||
@ -152,36 +94,6 @@ 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."""
|
||||
|
||||
@ -207,7 +119,9 @@ 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(
|
||||
@ -235,7 +149,9 @@ 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(
|
||||
@ -260,17 +176,13 @@ 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))
|
||||
@ -278,8 +190,6 @@ 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)
|
||||
@ -413,21 +323,6 @@ 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."""
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ class ConversationRepository:
|
||||
Conversation.id == conversation_id,
|
||||
Conversation.user_id == user_id,
|
||||
)
|
||||
.options(selectinload(Conversation.messages))
|
||||
)
|
||||
if conversation is None:
|
||||
return None
|
||||
@ -71,13 +72,3 @@ 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
|
||||
|
||||
@ -19,11 +19,6 @@ 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)
|
||||
|
||||
|
||||
@ -32,9 +32,9 @@ class DashboardMatrixRow(ApiSchema):
|
||||
name: str
|
||||
party_type: str
|
||||
categories: dict[str, int]
|
||||
cooperation_total: int | None
|
||||
cooperation_total: int
|
||||
anomaly_count: int
|
||||
anomaly_rate: float | None
|
||||
anomaly_rate: float
|
||||
tier: str
|
||||
|
||||
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
"""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
|
||||
@ -1,73 +0,0 @@
|
||||
"""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)
|
||||
@ -1,7 +1,6 @@
|
||||
"""Risk item submission and review contracts."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
@ -11,10 +10,8 @@ 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)
|
||||
@ -22,15 +19,11 @@ 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)
|
||||
@ -38,18 +31,14 @@ 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
|
||||
@ -57,8 +46,6 @@ 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
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
"""Risk prediction request and response contracts."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from app.domain.risk_engine import RiskLevel
|
||||
@ -16,13 +14,7 @@ class RiskIndicator(ApiSchema):
|
||||
|
||||
class RiskAssessmentRequest(ApiSchema):
|
||||
subject_id: str = Field(min_length=1, max_length=128)
|
||||
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)
|
||||
indicators: list[RiskIndicator] = Field(min_length=1, max_length=50)
|
||||
|
||||
|
||||
class RiskFactorResult(ApiSchema):
|
||||
@ -45,12 +37,9 @@ class SupplierFrequency(ApiSchema):
|
||||
supplier_name: str | None = None
|
||||
cooperation_count: int | None = None
|
||||
anomaly_count: int | None = None
|
||||
alpha: float | None = None
|
||||
alpha: float = 1.0
|
||||
tier: str | None = None
|
||||
suggestion: str | None = None
|
||||
frequency_basis: str = "暂无匹配采购订单"
|
||||
note: str | None = None
|
||||
data_available: bool = False
|
||||
|
||||
|
||||
class StandardJudgment(ApiSchema):
|
||||
@ -65,82 +54,13 @@ 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):
|
||||
@ -157,8 +77,6 @@ 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):
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
"""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
|
||||
@ -1,179 +0,0 @@
|
||||
"""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))
|
||||
File diff suppressed because it is too large
Load Diff
@ -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"]
|
||||
# No order or production denominator exists in anomaly_cases.
|
||||
cooperation_total = None
|
||||
# Keep the existing cooperation-total calculation unchanged.
|
||||
cooperation_total = item["count"]
|
||||
values = {category: 0 for category in categories}
|
||||
for row in item["items"]:
|
||||
category = cls._category(row["product_name"], row["material_type"])
|
||||
@ -300,7 +300,11 @@ class DashboardService:
|
||||
categories=values,
|
||||
cooperation_total=cooperation_total,
|
||||
anomaly_count=anomaly_count,
|
||||
anomaly_rate=None,
|
||||
anomaly_rate=(
|
||||
round(anomaly_count / cooperation_total * 100, 1)
|
||||
if cooperation_total
|
||||
else 0
|
||||
),
|
||||
tier=item["tier"],
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,454 +0,0 @@
|
||||
"""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
|
||||
@ -1,7 +1,6 @@
|
||||
"""Server-side DingTalk auth-code exchange and local account upsert."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@ -18,8 +17,6 @@ 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):
|
||||
@ -104,12 +101,7 @@ class DingTalkAuthService:
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from exc
|
||||
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
|
||||
)
|
||||
user_id = (data.get("result") or {}).get("userid")
|
||||
if data.get("errcode") or not user_id:
|
||||
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401)
|
||||
return str(user_id)
|
||||
@ -124,104 +116,49 @@ class DingTalkAuthService:
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("钉钉用户详情请求失败 user_id=%s error=%s", user_id, exc.__class__.__name__)
|
||||
except httpx.HTTPError:
|
||||
return {"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
|
||||
return data.get("result") or {"userid": user_id}
|
||||
|
||||
async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount:
|
||||
user = await self.repo.get_by_dingtalk_id(user_id)
|
||||
mapped_role = self.map_role(
|
||||
role = self.map_role(
|
||||
str(detail.get("dept_name") or detail.get("deptName") or ""),
|
||||
str(detail.get("title") or detail.get("position") or ""),
|
||||
self._is_dingtalk_manager(detail),
|
||||
bool(detail.get("is_admin") or detail.get("is_dept_manager")),
|
||||
)
|
||||
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=self._display_name(detail, user_id),
|
||||
role=mapped_role,
|
||||
name=str(detail.get("name") or user_id)[:50],
|
||||
role=role,
|
||||
status="启用",
|
||||
dingtalk_id=user_id,
|
||||
)
|
||||
self.session.add(user)
|
||||
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.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.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_ids = self._department_ids(detail)
|
||||
dept_id = dept_ids[0] if dept_ids else user.dingtalk_dept_id
|
||||
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
|
||||
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 = self._is_dingtalk_manager(detail)
|
||||
role_codes = detail.get("role_list") or detail.get("roleList") or user.dingtalk_role_codes
|
||||
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
|
||||
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
|
||||
@ -233,52 +170,21 @@ class DingTalkAuthService:
|
||||
|
||||
@staticmethod
|
||||
def map_role(dept: str, title: str, is_admin: bool = False) -> str:
|
||||
"""Map DingTalk users to admin (including review) or regular user."""
|
||||
"""Map DingTalk organization snapshots to the PRD seven-role model."""
|
||||
|
||||
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":
|
||||
text = f"{dept} {title}".lower()
|
||||
if is_admin or "it" in text or "信息" in text or "系统" in text:
|
||||
return "admin"
|
||||
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
|
||||
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 "普通用户"
|
||||
|
||||
@ -1,21 +1,17 @@
|
||||
"""Provider-neutral LLM client with a configurable DeepSeek fallback."""
|
||||
"""OpenAI-compatible LLM client for semantic extraction and streaming replies."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
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:
|
||||
"""Call the primary provider and fail over to DeepSeek when necessary."""
|
||||
"""Small provider-neutral client using the OpenAI chat-completions protocol."""
|
||||
|
||||
def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
|
||||
settings = get_settings()
|
||||
@ -24,56 +20,22 @@ 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 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)
|
||||
return bool(self.api_key)
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
return self._chat_endpoint(self.base_url)
|
||||
|
||||
@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}"
|
||||
return f"{self.base_url}{suffix}" if self.base_url.endswith("/v1") else f"{self.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)
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if not self.configured:
|
||||
raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503)
|
||||
return {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
}
|
||||
@ -83,14 +45,14 @@ class LLMClient:
|
||||
text: str,
|
||||
history: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Extract structured fields, retrying with DeepSeek when the primary fails."""
|
||||
"""Extract structured product and intent fields before generating an answer."""
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"你是蝶雅产品风险预警系统的语义理解模块。"
|
||||
"只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。"
|
||||
"你是蝶雅产品风险预估系统的语义理解模块。"
|
||||
"只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。"
|
||||
"intent 只能是 greet、general、assessment、supplier、material、cases、"
|
||||
"standard、compliance、solution 之一。"
|
||||
"字段必须包含 intent, risk_requested, category, specification, packaging, "
|
||||
@ -105,169 +67,39 @@ class LLMClient:
|
||||
]
|
||||
messages.extend(history or [])
|
||||
messages.append({"role": "user", "content": text})
|
||||
|
||||
body = await self._request(messages, stream=False, json_mode=True)
|
||||
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
|
||||
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))
|
||||
|
||||
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(
|
||||
async def stream_reply(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
provider: ProviderName,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
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))
|
||||
"""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))
|
||||
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
|
||||
try:
|
||||
async with client.stream("POST", self._endpoint(provider), headers=headers, json=payload) as response:
|
||||
async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response:
|
||||
if response.status_code >= 400:
|
||||
detail = await response.aread()
|
||||
raise self._http_failure(response.status_code, detail)
|
||||
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
|
||||
@ -278,138 +110,44 @@ class LLMClient:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
delta = self._stream_delta(chunk, responses_api=is_responses)
|
||||
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 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)
|
||||
yield str(delta)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
provider: ProviderName,
|
||||
stream: bool,
|
||||
json_mode: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
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":
|
||||
headers = self._headers()
|
||||
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,
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature,
|
||||
"stream": stream,
|
||||
}
|
||||
if json_mode:
|
||||
payload["response_format"] = {"type": "json_object"}
|
||||
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)
|
||||
|
||||
timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout))
|
||||
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
|
||||
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 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 "")
|
||||
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
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_json(content: str) -> dict[str, Any]:
|
||||
@ -423,28 +161,3 @@ 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]
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@ -1,507 +0,0 @@
|
||||
"""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<item>[A-Za-z\u4e00-\u9fffΔδ()()·/\-]{{1,32}}?)\s*[::]?\s*"
|
||||
rf"(?P<value>[+\-]?\d+(?:\.\d+)?)\s*(?P<unit>{_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))]
|
||||
@ -1,144 +0,0 @@
|
||||
"""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)
|
||||
@ -1,320 +0,0 @@
|
||||
"""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 "请查看原始案例记录"),
|
||||
)
|
||||
@ -1,667 +0,0 @@
|
||||
"""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()
|
||||
@ -19,10 +19,8 @@ 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,
|
||||
@ -30,8 +28,6 @@ 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 "待预审",
|
||||
@ -68,8 +64,6 @@ 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)
|
||||
@ -107,26 +101,6 @@ 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:
|
||||
@ -137,7 +111,9 @@ 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)
|
||||
@ -148,9 +124,7 @@ 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,
|
||||
@ -158,7 +132,6 @@ 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)
|
||||
|
||||
@ -1,17 +1,12 @@
|
||||
"""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:
|
||||
@ -19,63 +14,10 @@ class RiskPredictionService:
|
||||
|
||||
@classmethod
|
||||
def model_info(cls) -> ModelInfo:
|
||||
return ModelInfo(model_version="database-seven-dimension-v1")
|
||||
return ModelInfo(model_version=cls._engine.model_version)
|
||||
|
||||
@classmethod
|
||||
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)
|
||||
def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult:
|
||||
score = cls._engine.predict(
|
||||
[
|
||||
IndicatorValue(name=item.name, value=item.value, weight=item.weight)
|
||||
@ -97,3 +39,4 @@ class RiskPredictionService:
|
||||
for item in score.factors
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
"""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<label>[\u4e00-\u9fffA-Za-z0-9]+)\s*[::]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupplierMention:
|
||||
name: str
|
||||
role: str | None
|
||||
material_type: str | None
|
||||
source_text: str
|
||||
method: str
|
||||
confidence: float
|
||||
|
||||
|
||||
def compact_text(value: str) -> str:
|
||||
return re.sub(r"[\s()()【】\[\]{}]+", "", value.casefold()).strip()
|
||||
|
||||
|
||||
def normalize_name(value: str) -> str:
|
||||
value = re.sub(r"\s+", "", value).strip(" ::、,,;;/\\")
|
||||
for suffix in ("有限责任公司", "有限公司", "公司"):
|
||||
if value.endswith(suffix) and len(value) > len(suffix) + 1:
|
||||
value = value[: -len(suffix)]
|
||||
break
|
||||
if value.endswith("市") and len(value) > 2:
|
||||
# Keep city in canonical display names; this is only a normalized key.
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def normalized_key(value: str) -> str:
|
||||
return compact_text(normalize_name(value))
|
||||
|
||||
|
||||
def supplier_match_key(value: str) -> str:
|
||||
key = normalized_key(value)
|
||||
return re.sub(r"(佛山|广州|深圳|东莞|中山|珠海)市", r"\1", key)
|
||||
|
||||
|
||||
def resolve_canonical_name(name: str, purchase_order_names: list[str]) -> tuple[str, bool]:
|
||||
"""Resolve a short/raw name to one unambiguous procurement full name."""
|
||||
key = supplier_match_key(name)
|
||||
if not key:
|
||||
return name, False
|
||||
exact = [item for item in purchase_order_names if supplier_match_key(item) == key]
|
||||
if len(exact) == 1:
|
||||
return exact[0], True
|
||||
contains = [item for item in purchase_order_names if key in supplier_match_key(item)]
|
||||
if len(contains) == 1:
|
||||
return contains[0], True
|
||||
return name, False
|
||||
|
||||
|
||||
def infer_location(value: str) -> tuple[str | None, str | None]:
|
||||
"""Infer only explicit locations present in a supplier name."""
|
||||
compact = compact_text(value)
|
||||
city_map = {
|
||||
"佛山": ("佛山", "广东"),
|
||||
"广州": ("广州", "广东"),
|
||||
"深圳": ("深圳", "广东"),
|
||||
"东莞": ("东莞", "广东"),
|
||||
"中山": ("中山", "广东"),
|
||||
"珠海": ("珠海", "广东"),
|
||||
}
|
||||
for token, location in city_map.items():
|
||||
if token in compact:
|
||||
return location
|
||||
return None, None
|
||||
|
||||
|
||||
def split_supplier_cell(value: str | None, *, material_type: str | None = None) -> list[SupplierMention]:
|
||||
if not value or value.strip() in {"/", "-", "—", "无", "未知"}:
|
||||
return []
|
||||
text = value.strip()
|
||||
mentions: list[SupplierMention] = []
|
||||
current_role: str | None = None
|
||||
current_material = material_type
|
||||
for chunk in _SEPARATOR_RE.split(text):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
bare_label = chunk.rstrip(" ::")
|
||||
if bare_label in {"内料", "包材", "包装", "瓶子", "泵头", "盖子", "软管", "彩盒", "物料", "加工厂", "供应商"}:
|
||||
current_role = "加工厂" if "工厂" in bare_label else "供应商"
|
||||
if bare_label not in {"加工厂", "供应商"}:
|
||||
current_material = bare_label
|
||||
continue
|
||||
if chunk.endswith((":", ":")) and bare_label:
|
||||
current_role = "加工厂" if "工厂" in bare_label else "供应商"
|
||||
material_label = re.sub(r"(?:加工厂|工厂|供应商)$", "", bare_label)
|
||||
if material_label:
|
||||
current_material = material_label
|
||||
continue
|
||||
labels = list(_LABEL_RE.finditer(chunk))
|
||||
if labels:
|
||||
label = labels[-1].group("label")
|
||||
current_role = "加工厂" if "工厂" in label else "供应商"
|
||||
material_label = re.sub(r"(?:加工厂|工厂|供应商)$", "", label)
|
||||
if material_label:
|
||||
current_material = material_label
|
||||
chunk = chunk[labels[-1].end() :].strip()
|
||||
# A comma can separate material labels from the actual name.
|
||||
if not chunk or chunk in {"/", "-", "—"}:
|
||||
continue
|
||||
name = normalize_name(chunk)
|
||||
if not name:
|
||||
continue
|
||||
mentions.append(
|
||||
SupplierMention(
|
||||
name=name,
|
||||
role=current_role,
|
||||
material_type=current_material,
|
||||
source_text=chunk,
|
||||
method="delimiter" if len(_SEPARATOR_RE.findall(text)) else "raw",
|
||||
confidence=0.95 if len(_SEPARATOR_RE.findall(text)) else 0.75,
|
||||
)
|
||||
)
|
||||
return mentions
|
||||
@ -1,44 +0,0 @@
|
||||
"""Resolve supplier aliases to canonical master-data identities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.entities import SupplierMaster
|
||||
from app.services.supplier_cleaning import supplier_match_key
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SupplierIdentity:
|
||||
id: int
|
||||
canonical_name: str
|
||||
short_name: str | None
|
||||
aliases: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def match_names(self) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(filter(None, (self.short_name, *self.aliases, self.canonical_name))))
|
||||
|
||||
|
||||
async def resolve_supplier(session: AsyncSession, value: str | None) -> SupplierIdentity | None:
|
||||
if not value or not isinstance(session, AsyncSession):
|
||||
return None
|
||||
key = supplier_match_key(value)
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
rows = (await session.execute(select(SupplierMaster).where(SupplierMaster.status == "启用"))).scalars().all()
|
||||
except ProgrammingError:
|
||||
return None
|
||||
matches = []
|
||||
for row in rows:
|
||||
names = (row.canonical_name, row.short_name, *(row.aliases or []))
|
||||
if any(name and supplier_match_key(name) == key for name in names):
|
||||
matches.append(row)
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
row = matches[0]
|
||||
return SupplierIdentity(row.id, row.canonical_name, row.short_name, tuple(row.aliases or []))
|
||||
@ -1,429 +0,0 @@
|
||||
"""Live supplier profile aggregation from the approved anomaly case data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import String, func, or_, select
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
from app.models.entities import AnomalyCase, AnomalyCaseSupplier, SupplierMaster
|
||||
from app.schemas.suppliers import (
|
||||
SupplierCategoryBreakdown,
|
||||
SupplierListData,
|
||||
SupplierMetric,
|
||||
SupplierOption,
|
||||
SupplierProfileData,
|
||||
SupplierRecentRecord,
|
||||
)
|
||||
from app.services.database_risk_scoring import DatabaseRiskScoringService, normalize_category
|
||||
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
|
||||
|
||||
|
||||
class SupplierProfileService:
|
||||
history_years = 3
|
||||
recent_months = 6
|
||||
max_categories = 5
|
||||
max_recent_records = 10
|
||||
|
||||
def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None:
|
||||
self.session = session
|
||||
self.as_of = as_of or datetime.now(UTC).date()
|
||||
|
||||
async def list(self, *, keyword: str | None = None, limit: int = 100) -> SupplierListData:
|
||||
canonical = await self._load_master_summary(keyword=keyword, limit=limit)
|
||||
if canonical:
|
||||
return SupplierListData(items=canonical, default_supplier=canonical[0].name)
|
||||
grouped = await self._load_supplier_summary(keyword=keyword)
|
||||
# Some imported legacy rows have no anomaly date. They are excluded
|
||||
# from the normal three-year risk window, but must still be discoverable
|
||||
# when a user searches by a supplier/city name.
|
||||
if keyword and not grouped:
|
||||
grouped = await self._load_supplier_summary(
|
||||
keyword=keyword,
|
||||
include_undated=True,
|
||||
)
|
||||
items = [
|
||||
SupplierOption(
|
||||
name=name,
|
||||
anomaly_count=summary["total"],
|
||||
tier=self.tier_for(
|
||||
summary["total"],
|
||||
summary["a"],
|
||||
summary["b"],
|
||||
),
|
||||
)
|
||||
for name, summary in sorted(
|
||||
grouped.items(), key=lambda item: (-item[1]["total"], item[0])
|
||||
)[:limit]
|
||||
]
|
||||
return SupplierListData(
|
||||
items=items,
|
||||
default_supplier=items[0].name if items else None,
|
||||
)
|
||||
|
||||
async def detail(self, supplier_name: str) -> SupplierProfileData:
|
||||
linked_rows = await self._load_linked_rows(supplier_name)
|
||||
all_rows = linked_rows or await self._load_rows()
|
||||
rows = linked_rows or [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
||||
used_legacy_fallback = False
|
||||
if not rows:
|
||||
all_rows = await self._load_rows(include_undated=True)
|
||||
rows = [row for row in all_rows if self.matches(row.get("supplier"), supplier_name)]
|
||||
used_legacy_fallback = bool(rows)
|
||||
if not rows:
|
||||
raise AppException("供应商不存在或暂无近3个自然年度异常记录", code=404, status_code=404)
|
||||
|
||||
anomaly_total = len(rows)
|
||||
history_start, history_end = calendar_year_window(self.as_of, self.history_years)
|
||||
order_count = await supplier_order_counts(
|
||||
self.session,
|
||||
supplier_name,
|
||||
history_start,
|
||||
history_end,
|
||||
)
|
||||
a_count = sum(self.defect_code(row.get("defect_level")) == "A" for row in rows)
|
||||
b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows)
|
||||
tier = self.tier_for(anomaly_total, a_count, b_count)
|
||||
categories = self._categories(rows)
|
||||
scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of)
|
||||
score = await scoring.score(
|
||||
supplier=supplier_name,
|
||||
rows=all_rows,
|
||||
cooperation_count=order_count,
|
||||
)
|
||||
supplier_dimension = next(
|
||||
(item for item in score.dimensions if item.name == "供应商表现"),
|
||||
None,
|
||||
)
|
||||
cooperation_display = order_count if order_count > 0 else None
|
||||
display_tier = {
|
||||
"S级": "S级 · 战略",
|
||||
"A级": "A级 · 优质",
|
||||
"B级": "B级 · 关注",
|
||||
"C级": "C级 · 高风险",
|
||||
}.get(tier, tier)
|
||||
return SupplierProfileData(
|
||||
supplier_name=supplier_name,
|
||||
tier=display_tier,
|
||||
tier_condition=self.tier_condition(tier),
|
||||
metric=SupplierMetric(
|
||||
cooperation_total=cooperation_display,
|
||||
anomaly_total=anomaly_total,
|
||||
anomaly_rate=None,
|
||||
frequency_alpha=(
|
||||
supplier_dimension.frequency_alpha
|
||||
if supplier_dimension and supplier_dimension.frequency_alpha is not None
|
||||
else self.alpha(
|
||||
cooperation_display or anomaly_total,
|
||||
coefficient=scoring.frequency_coefficient,
|
||||
cap=scoring.frequency_cap,
|
||||
)
|
||||
),
|
||||
supplier_base_score=(supplier_dimension.base_score if supplier_dimension else None),
|
||||
supplier_score=supplier_dimension.score if supplier_dimension else None,
|
||||
risk_score=score.score,
|
||||
main_category_count=len(categories),
|
||||
),
|
||||
category_breakdown=self._display_categories(categories),
|
||||
monthly_trend=self._monthly_trend(rows),
|
||||
recent_records=self._recent_records(rows),
|
||||
factory_recommendations=[],
|
||||
recommendation_note=(
|
||||
"该供应商仅命中历史导入记录,部分异常缺少发生日期;本页展示可识别的历史记录,"
|
||||
"不将日期缺失数据混入近3年趋势。"
|
||||
if used_legacy_fallback
|
||||
else "供应商画像按标准供应商关联统计;合作次数按采购订单去重单据编号统计。"
|
||||
),
|
||||
)
|
||||
|
||||
async def _load_master_summary(self, *, keyword: str | None, limit: int) -> list[SupplierOption]:
|
||||
filters = [SupplierMaster.status == "启用"]
|
||||
if keyword:
|
||||
pattern = f"%{keyword.strip()}%"
|
||||
filters.append(or_(
|
||||
SupplierMaster.canonical_name.ilike(pattern),
|
||||
SupplierMaster.short_name.ilike(pattern),
|
||||
SupplierMaster.city.ilike(pattern),
|
||||
SupplierMaster.province.ilike(pattern),
|
||||
SupplierMaster.aliases.cast(String).ilike(pattern),
|
||||
))
|
||||
statement = (
|
||||
select(SupplierMaster, func.count(AnomalyCaseSupplier.id).label("anomaly_count"))
|
||||
.outerjoin(AnomalyCaseSupplier, AnomalyCaseSupplier.supplier_id == SupplierMaster.id)
|
||||
.where(*filters)
|
||||
.group_by(SupplierMaster.id)
|
||||
.order_by(func.count(AnomalyCaseSupplier.id).desc(), SupplierMaster.canonical_name)
|
||||
.limit(limit)
|
||||
)
|
||||
try:
|
||||
async with self.session.begin_nested():
|
||||
rows = (await self.session.execute(statement)).all()
|
||||
except ProgrammingError:
|
||||
# Allows a rolling deployment: the legacy raw supplier search stays
|
||||
# available until the privileged migration has created master tables.
|
||||
return []
|
||||
return [SupplierOption(
|
||||
name=master.canonical_name,
|
||||
anomaly_count=int(count or 0),
|
||||
tier="待确认" if master.review_status != "已确认" else self.tier_for(int(count or 0), 0, 0),
|
||||
city=master.city,
|
||||
province=master.province,
|
||||
short_name=master.short_name,
|
||||
review_status=master.review_status,
|
||||
) for master, count in rows]
|
||||
|
||||
async def _load_linked_rows(self, supplier_name: str) -> list[dict]:
|
||||
statement = (
|
||||
select(
|
||||
AnomalyCase.id,
|
||||
AnomalyCase.date,
|
||||
AnomalyCase.product_name,
|
||||
AnomalyCase.product_category,
|
||||
AnomalyCase.material_type,
|
||||
AnomalyCase.supplier,
|
||||
AnomalyCase.factory,
|
||||
AnomalyCase.anomaly_type,
|
||||
AnomalyCase.defect_level,
|
||||
)
|
||||
.join(AnomalyCaseSupplier, AnomalyCaseSupplier.case_id == AnomalyCase.id)
|
||||
.join(SupplierMaster, SupplierMaster.id == AnomalyCaseSupplier.supplier_id)
|
||||
.where(or_(
|
||||
SupplierMaster.canonical_name == supplier_name,
|
||||
SupplierMaster.short_name == supplier_name,
|
||||
SupplierMaster.aliases.cast(String).ilike(f"%{supplier_name}%"),
|
||||
))
|
||||
)
|
||||
try:
|
||||
async with self.session.begin_nested():
|
||||
result = await self.session.execute(statement)
|
||||
return [dict(row) for row in result.mappings().all()]
|
||||
except ProgrammingError:
|
||||
return []
|
||||
|
||||
async def _load_supplier_summary(
|
||||
self,
|
||||
*,
|
||||
keyword: str | None = None,
|
||||
include_undated: bool = False,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Load only supplier aggregates for the selector.
|
||||
|
||||
The selector used to fetch every anomaly row and group it in Python.
|
||||
This grouped query returns a handful of rows even when the anomaly
|
||||
table grows, which materially reduces first-navigation latency over
|
||||
the remote PostgreSQL connection.
|
||||
"""
|
||||
|
||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||
statement = (
|
||||
select(AnomalyCase.supplier, AnomalyCase.defect_level, func.count())
|
||||
.where(AnomalyCase.supplier.is_not(None))
|
||||
.group_by(AnomalyCase.supplier, AnomalyCase.defect_level)
|
||||
)
|
||||
if not include_undated:
|
||||
statement = statement.where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= end,
|
||||
)
|
||||
grouped: dict[str, dict[str, int]] = defaultdict(
|
||||
lambda: {"total": 0, "a": 0, "b": 0}
|
||||
)
|
||||
for supplier, defect_level, count in (await self.session.execute(statement)).all():
|
||||
name = (supplier or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
if keyword and not self.matches(name, keyword):
|
||||
continue
|
||||
value = grouped[name]
|
||||
amount = int(count or 0)
|
||||
value["total"] += amount
|
||||
code = self.defect_code(defect_level)
|
||||
if code == "A":
|
||||
value["a"] += amount
|
||||
elif code == "B":
|
||||
value["b"] += amount
|
||||
return grouped
|
||||
|
||||
async def _load_rows(
|
||||
self,
|
||||
*,
|
||||
supplier_name: str | None = None,
|
||||
include_undated: bool = False,
|
||||
) -> list[dict]:
|
||||
start, end = calendar_year_window(self.as_of, self.history_years)
|
||||
statement = select(
|
||||
AnomalyCase.id,
|
||||
AnomalyCase.date,
|
||||
AnomalyCase.product_name,
|
||||
AnomalyCase.product_category,
|
||||
AnomalyCase.material_type,
|
||||
AnomalyCase.supplier,
|
||||
AnomalyCase.factory,
|
||||
AnomalyCase.anomaly_type,
|
||||
AnomalyCase.defect_level,
|
||||
).where()
|
||||
if not include_undated:
|
||||
statement = statement.where(
|
||||
AnomalyCase.date.is_not(None),
|
||||
AnomalyCase.date >= start,
|
||||
AnomalyCase.date <= end,
|
||||
)
|
||||
if supplier_name:
|
||||
statement = statement.where(AnomalyCase.supplier.ilike(f"%{supplier_name}%"))
|
||||
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
|
||||
|
||||
@staticmethod
|
||||
def matches(value: str | None, target: str) -> bool:
|
||||
if not value or not target:
|
||||
return False
|
||||
# Supplier cells in the source workbooks often contain several names
|
||||
# such as ``皓盛/佛山执得``. Match each segment separately and make
|
||||
# common city/company suffixes optional so ``佛山市`` can find
|
||||
# ``佛山执得`` while preserving the canonical raw value for display.
|
||||
queries = SupplierProfileService._search_terms(target)
|
||||
segments = SupplierProfileService._supplier_segments(value)
|
||||
return any(
|
||||
query and (query in segment or segment in query)
|
||||
for query in queries
|
||||
for segment in segments
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _supplier_segments(value: str) -> list[str]:
|
||||
segments = re.split(r"[//、,,;;|\\\\]+", value)
|
||||
return [SupplierProfileService._compact_text(item) for item in segments if SupplierProfileService._compact_text(item)]
|
||||
|
||||
@staticmethod
|
||||
def _compact_text(value: str) -> str:
|
||||
return re.sub(r"[\s()()【】\[\]{}]+", "", value.casefold())
|
||||
|
||||
@classmethod
|
||||
def _search_terms(cls, value: str) -> set[str]:
|
||||
compact = cls._compact_text(value)
|
||||
terms = {compact}
|
||||
# A user commonly enters a city with the administrative suffix while
|
||||
# the workbook keeps the company/trading name (e.g. 佛山市 vs 佛山执得).
|
||||
if compact.endswith("市") and len(compact) > 2:
|
||||
terms.add(compact[:-1])
|
||||
for suffix in ("有限公司", "有限责任公司", "公司"):
|
||||
if compact.endswith(suffix) and len(compact) > len(suffix) + 1:
|
||||
terms.add(compact[: -len(suffix)])
|
||||
return {term for term in terms if term}
|
||||
|
||||
@classmethod
|
||||
def _group_suppliers(cls, rows: list[dict], keyword: str | None) -> dict[str, list[dict]]:
|
||||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||
for row in rows:
|
||||
name = (row.get("supplier") or "").strip()
|
||||
if name and (not keyword or cls.matches(name, keyword)):
|
||||
grouped[name].append(row)
|
||||
return grouped
|
||||
|
||||
@staticmethod
|
||||
def defect_code(value: str | None) -> str | None:
|
||||
match = re.search(r"[ABCD]", (value or "").upper())
|
||||
return match.group(0) if match else None
|
||||
|
||||
@staticmethod
|
||||
def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float:
|
||||
return round(min(cap, 1 + anomaly_count / 100 * coefficient), 2)
|
||||
|
||||
@classmethod
|
||||
def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str:
|
||||
if anomaly_count == 0:
|
||||
return "新供应商"
|
||||
ab_ratio = (a_count + b_count) / anomaly_count
|
||||
if anomaly_count > 120 or a_count > 0 or ab_ratio > 0.5:
|
||||
return "C级"
|
||||
if anomaly_count > 60 or ab_ratio >= 0.3:
|
||||
return "B级"
|
||||
if anomaly_count >= 20:
|
||||
return "A级"
|
||||
return "S级"
|
||||
|
||||
@staticmethod
|
||||
def tier_condition(tier: str) -> str:
|
||||
return {
|
||||
"S级": "异常记录 < 20 次,且无 A 级缺陷",
|
||||
"A级": "异常记录 20~60 次,且 A/B 级缺陷占比 < 30%",
|
||||
"B级": "异常记录 60~120 次,或 A/B 级缺陷占比 30%~50%",
|
||||
"C级": "异常记录 > 120 次,或存在 A 级缺陷,或 A/B 级缺陷占比 > 50%",
|
||||
"新供应商": "近3个自然年度异常记录为 0",
|
||||
}[tier]
|
||||
|
||||
@classmethod
|
||||
def _categories(cls, rows: list[dict]) -> list[SupplierCategoryBreakdown]:
|
||||
grouped: dict[str, list[dict]] = defaultdict(list)
|
||||
for row in rows:
|
||||
category = normalize_category(row.get("product_category")) or normalize_category(row.get("product_name"))
|
||||
grouped[category or "待补标品类"].append(row)
|
||||
result = []
|
||||
for category, items in grouped.items():
|
||||
is_unclassified = category == "待补标品类"
|
||||
materials = Counter(row.get("material_type") for row in items if row.get("material_type"))
|
||||
problems = Counter(row.get("anomaly_type") for row in items if row.get("anomaly_type"))
|
||||
result.append(
|
||||
SupplierCategoryBreakdown(
|
||||
category=category,
|
||||
is_unclassified=is_unclassified,
|
||||
anomaly_count=len(items),
|
||||
anomaly_rate=None,
|
||||
top_material=materials.most_common(1)[0][0] if materials else None,
|
||||
top_problem=problems.most_common(1)[0][0] if problems else None,
|
||||
)
|
||||
)
|
||||
return sorted(result, key=lambda item: (item.is_unclassified, -item.anomaly_count, item.category))
|
||||
|
||||
@classmethod
|
||||
def _display_categories(
|
||||
cls, categories: list[SupplierCategoryBreakdown]
|
||||
) -> list[SupplierCategoryBreakdown]:
|
||||
"""Keep up to five real categories and always retain the unclassified row."""
|
||||
|
||||
real = [item for item in categories if not item.is_unclassified][: cls.max_categories]
|
||||
unclassified = next((item for item in categories if item.is_unclassified), None)
|
||||
return real + ([unclassified] if unclassified else [])
|
||||
|
||||
def _monthly_trend(self, rows: list[dict]) -> list[dict[str, int | str]]:
|
||||
start = self._month_offset(self.as_of.replace(day=1), -5)
|
||||
counts = Counter(
|
||||
row["date"].strftime("%Y-%m")
|
||||
for row in rows
|
||||
if row.get("date") and row["date"] >= start
|
||||
)
|
||||
output = []
|
||||
cursor = start
|
||||
for _ in range(self.recent_months):
|
||||
key = cursor.strftime("%Y-%m")
|
||||
output.append({"month": key, "count": counts.get(key, 0)})
|
||||
cursor = date(cursor.year + (cursor.month == 12), 1 if cursor.month == 12 else cursor.month + 1, 1)
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _month_offset(value: date, months: int) -> date:
|
||||
index = value.year * 12 + value.month - 1 + months
|
||||
return date(index // 12, index % 12 + 1, 1)
|
||||
|
||||
def _recent_records(self, rows: list[dict]) -> list[SupplierRecentRecord]:
|
||||
start = self._month_offset(self.as_of.replace(day=1), -5)
|
||||
recent = sorted(
|
||||
(row for row in rows if row.get("date") and row["date"] >= start),
|
||||
key=lambda row: row["date"],
|
||||
reverse=True,
|
||||
)
|
||||
return [
|
||||
SupplierRecentRecord(
|
||||
case_id=int(row["id"]),
|
||||
month=row["date"].strftime("%Y-%m") if row.get("date") else None,
|
||||
product=row.get("product_name"),
|
||||
anomaly=row.get("anomaly_type"),
|
||||
defect_level=self.defect_code(row.get("defect_level")),
|
||||
)
|
||||
for row in recent[: self.max_recent_records]
|
||||
]
|
||||
@ -1,51 +0,0 @@
|
||||
"""Add the optional pgvector column for anomaly-case retrieval."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260905_0014"
|
||||
down_revision: str | None = "20260902_0013"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
available = connection.execute(
|
||||
sa.text("SELECT EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'vector')")
|
||||
).scalar()
|
||||
if not available:
|
||||
# The application has a structured/text retrieval fallback. Keep the
|
||||
# migration non-blocking on managed PostgreSQL instances without the
|
||||
# optional extension; install pgvector and rerun this revision later.
|
||||
return
|
||||
is_superuser = connection.execute(
|
||||
sa.text("SELECT rolsuper FROM pg_roles WHERE rolname = current_user")
|
||||
).scalar()
|
||||
if not is_superuser:
|
||||
# Managed PostgreSQL may expose the package but reserve CREATE
|
||||
# EXTENSION for an administrator. Lexical RAG remains usable.
|
||||
return
|
||||
connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"ALTER TABLE anomaly_cases "
|
||||
"ADD COLUMN IF NOT EXISTS embedding_vector halfvec(3072)"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_anomaly_cases_embedding_vector "
|
||||
"ON anomaly_cases USING hnsw (embedding_vector halfvec_cosine_ops)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.execute(sa.text("DROP INDEX IF EXISTS ix_anomaly_cases_embedding_vector"))
|
||||
connection.execute(
|
||||
sa.text("ALTER TABLE anomaly_cases DROP COLUMN IF EXISTS embedding_vector")
|
||||
)
|
||||
@ -1,39 +0,0 @@
|
||||
"""Align full supplier-profile access with the PRD's L2 role matrix."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260907_0015"
|
||||
down_revision: str | None = "20260905_0014"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PERMISSIONS = ("procurement", "reviewer", "manager")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate)
|
||||
VALUES (:role, 'supplier.read', 'L2', true, false)
|
||||
ON CONFLICT (role, func_key) DO UPDATE SET
|
||||
data_level = EXCLUDED.data_level,
|
||||
can_view = EXCLUDED.can_view,
|
||||
can_operate = EXCLUDED.can_operate,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
for role in PERMISSIONS:
|
||||
connection.execute(statement, {"role": role})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
for role in PERMISSIONS:
|
||||
connection.execute(
|
||||
sa.text("DELETE FROM role_permissions WHERE role = :role AND func_key = 'supplier.read'"),
|
||||
{"role": role},
|
||||
)
|
||||
@ -1,37 +0,0 @@
|
||||
"""Add OCR extraction and judgment fields to risk items."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260907_0016"
|
||||
down_revision: str | None = "20260907_0015"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("risk_items", sa.Column("occurred_on", sa.Date(), nullable=True))
|
||||
op.add_column("risk_items", sa.Column("material_type", sa.String(length=200), nullable=True))
|
||||
op.add_column("risk_items", sa.Column("recognition_confidence", sa.Numeric(5, 4), nullable=True))
|
||||
op.add_column(
|
||||
"risk_items",
|
||||
sa.Column("ocr_payload", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False),
|
||||
)
|
||||
op.create_index("ix_risk_items_occurred_on", "risk_items", ["occurred_on"])
|
||||
op.create_index("ix_risk_items_material_type", "risk_items", ["material_type"])
|
||||
op.execute("COMMENT ON COLUMN risk_items.occurred_on IS 'OCR或人工录入的异常发生日期。'")
|
||||
op.execute("COMMENT ON COLUMN risk_items.material_type IS 'OCR或人工录入的异常物料类型。'")
|
||||
op.execute("COMMENT ON COLUMN risk_items.recognition_confidence IS 'OCR综合识别置信度,范围0到1。'")
|
||||
op.execute("COMMENT ON COLUMN risk_items.ocr_payload IS 'OCR检测数值、标准判定和人工确认前的结构化结果。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_risk_items_material_type", table_name="risk_items")
|
||||
op.drop_index("ix_risk_items_occurred_on", table_name="risk_items")
|
||||
op.drop_column("risk_items", "ocr_payload")
|
||||
op.drop_column("risk_items", "recognition_confidence")
|
||||
op.drop_column("risk_items", "material_type")
|
||||
op.drop_column("risk_items", "occurred_on")
|
||||
@ -1,103 +0,0 @@
|
||||
"""Apply the four-role matrix and add configurable model weights."""
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from app.domain.model_config import DEFAULT_WEIGHTS
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260907_0017"
|
||||
down_revision: str | None = "20260907_0016"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
PERMISSIONS = (
|
||||
("普通用户", "risk.assess", "L1", True, True),
|
||||
("普通用户", "standard.read", "L1", True, False),
|
||||
("普通用户", "supplier.read", "L1", True, False),
|
||||
("product_manager", "risk.assess", "L1", True, True),
|
||||
("product_manager", "risk_item.submit", "L1", True, True),
|
||||
("product_manager", "risk_item.review", "L2", True, True),
|
||||
("product_manager", "standard.read", "L1", True, False),
|
||||
("product_manager", "supplier.read", "L2", True, False),
|
||||
("reviewer", "risk.assess", "L1", True, True),
|
||||
("reviewer", "risk_item.review", "L2", True, True),
|
||||
("reviewer", "standard.read", "L1", True, False),
|
||||
("reviewer", "supplier.read", "L2", True, False),
|
||||
("admin", "risk.assess", "L3", True, True),
|
||||
("admin", "risk_item.submit", "L3", True, True),
|
||||
("admin", "risk_item.review", "L3", True, True),
|
||||
("admin", "model_weight.manage", "L3", True, True),
|
||||
("admin", "rbac.manage", "L3", True, True),
|
||||
("admin", "standard.read", "L3", True, True),
|
||||
("admin", "standard.manage", "L3", True, True),
|
||||
("admin", "supplier.read", "L3", True, True),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"risk_model_configs",
|
||||
sa.Column("id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("weights", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("frequency_coefficient", sa.Numeric(5, 4), nullable=False),
|
||||
sa.Column("frequency_cap", sa.Numeric(4, 2), nullable=False),
|
||||
sa.Column("updated_by", sa.String(length=20), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO risk_model_configs (id, weights, frequency_coefficient, frequency_cap)
|
||||
VALUES (1, CAST(:weights AS jsonb), 0.3, 1.5)
|
||||
"""
|
||||
),
|
||||
{"weights": json.dumps(DEFAULT_WEIGHTS, ensure_ascii=False)},
|
||||
)
|
||||
connection.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_accounts
|
||||
SET role = CASE
|
||||
WHEN role IN ('dev', 'manager') THEN 'product_manager'
|
||||
WHEN role IN ('qc', 'reviewer') THEN 'reviewer'
|
||||
WHEN role IN ('procurement', 'ops', '普通用户') THEN '普通用户'
|
||||
ELSE role
|
||||
END,
|
||||
updated_at = now()
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(sa.text("DELETE FROM role_permissions"))
|
||||
statement = sa.text(
|
||||
"""
|
||||
INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate)
|
||||
VALUES (:role, :func_key, :data_level, :can_view, :can_operate)
|
||||
"""
|
||||
)
|
||||
for role, func_key, data_level, can_view, can_operate in PERMISSIONS:
|
||||
connection.execute(
|
||||
statement,
|
||||
{
|
||||
"role": role,
|
||||
"func_key": func_key,
|
||||
"data_level": data_level,
|
||||
"can_view": can_view,
|
||||
"can_operate": can_operate,
|
||||
},
|
||||
)
|
||||
op.execute("COMMENT ON TABLE risk_model_configs IS '管理员维护的七维权重与频次系数配置。'")
|
||||
op.execute("COMMENT ON COLUMN user_accounts.role IS '角色:admin、product_manager、reviewer 或 普通用户。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text("UPDATE user_accounts SET role = 'manager', updated_at = now() WHERE role = 'product_manager'")
|
||||
)
|
||||
connection.execute(sa.text("DELETE FROM role_permissions"))
|
||||
op.drop_table("risk_model_configs")
|
||||
@ -1,48 +0,0 @@
|
||||
"""Create the procurement-order ledger used for real supplier cooperation counts."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260908_0018"
|
||||
down_revision: str | None = "20260907_0017"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"purchase_order_lines",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("document_number", sa.String(length=64), nullable=False),
|
||||
sa.Column("purchase_date", sa.Date(), nullable=True),
|
||||
sa.Column("supplier", sa.String(length=300), nullable=False),
|
||||
sa.Column("document_status", sa.String(length=30), nullable=True),
|
||||
sa.Column("close_status", sa.String(length=30), nullable=True),
|
||||
sa.Column("material_code", sa.String(length=200), nullable=True),
|
||||
sa.Column("material_name", sa.Text(), nullable=True),
|
||||
sa.Column("purchase_unit", sa.String(length=30), nullable=True),
|
||||
sa.Column("purchase_quantity", sa.Numeric(18, 4), nullable=True),
|
||||
sa.Column("delivery_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("tax_inclusive_unit_price", sa.Numeric(18, 6), nullable=True),
|
||||
sa.Column("is_gift", sa.String(length=10), nullable=True),
|
||||
sa.Column("business_close", sa.String(length=30), nullable=True),
|
||||
sa.Column("remark", sa.Text(), nullable=True),
|
||||
sa.Column("source_file", sa.String(length=300), nullable=False),
|
||||
sa.Column("import_fingerprint", sa.String(length=64), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("import_fingerprint"),
|
||||
)
|
||||
op.create_index("ix_purchase_order_lines_supplier_date", "purchase_order_lines", ["supplier", "purchase_date"])
|
||||
op.create_index("ix_purchase_order_lines_document", "purchase_order_lines", ["document_number"])
|
||||
op.create_index("uq_purchase_order_lines_source", "purchase_order_lines", ["import_fingerprint"], unique=True)
|
||||
op.execute("COMMENT ON TABLE purchase_order_lines IS '采购订单明细台账;供应商合作频次按单据编号去重统计。'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_purchase_order_lines_source", table_name="purchase_order_lines")
|
||||
op.drop_index("ix_purchase_order_lines_document", table_name="purchase_order_lines")
|
||||
op.drop_index("ix_purchase_order_lines_supplier_date", table_name="purchase_order_lines")
|
||||
op.drop_table("purchase_order_lines")
|
||||
@ -1,62 +0,0 @@
|
||||
"""Add canonical supplier master data and anomaly relationships."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "20260910_0019"
|
||||
down_revision: str | None = "20260908_0018"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"supplier_master",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("canonical_name", sa.String(length=200), nullable=False),
|
||||
sa.Column("short_name", sa.String(length=100), nullable=True),
|
||||
sa.Column("normalized_name", sa.String(length=200), nullable=False),
|
||||
sa.Column("city", sa.String(length=50), nullable=True),
|
||||
sa.Column("province", sa.String(length=50), nullable=True),
|
||||
sa.Column("supplier_type", sa.String(length=50), nullable=True),
|
||||
sa.Column("aliases", JSONB(), server_default="[]", nullable=False),
|
||||
sa.Column("status", sa.String(length=20), server_default="启用", nullable=False),
|
||||
sa.Column("source", sa.String(length=100), nullable=True),
|
||||
sa.Column("review_status", sa.String(length=20), server_default="待确认", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_supplier_master_city", "supplier_master", ["city"])
|
||||
op.create_index("ix_supplier_master_normalized_name", "supplier_master", ["normalized_name"], unique=True)
|
||||
op.create_table(
|
||||
"anomaly_case_suppliers",
|
||||
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column("case_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("supplier_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("supplier_role", sa.String(length=50), nullable=True),
|
||||
sa.Column("material_type", sa.String(length=200), nullable=True),
|
||||
sa.Column("source_text", sa.Text(), nullable=True),
|
||||
sa.Column("match_method", sa.String(length=30), server_default="manual", nullable=False),
|
||||
sa.Column("match_confidence", sa.Numeric(5, 4), nullable=True),
|
||||
sa.Column("review_status", sa.String(length=20), server_default="待确认", nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.ForeignKeyConstraint(["case_id"], ["anomaly_cases.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["supplier_id"], ["supplier_master.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("case_id", "supplier_id", "supplier_role", name="uq_case_supplier_role"),
|
||||
)
|
||||
op.create_index("ix_case_suppliers_supplier", "anomaly_case_suppliers", ["supplier_id"])
|
||||
op.create_index("ix_case_suppliers_case", "anomaly_case_suppliers", ["case_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_case_suppliers_case", table_name="anomaly_case_suppliers")
|
||||
op.drop_index("ix_case_suppliers_supplier", table_name="anomaly_case_suppliers")
|
||||
op.drop_table("anomaly_case_suppliers")
|
||||
op.drop_index("ix_supplier_master_normalized_name", table_name="supplier_master")
|
||||
op.drop_index("ix_supplier_master_city", table_name="supplier_master")
|
||||
op.drop_table("supplier_master")
|
||||
@ -1,31 +0,0 @@
|
||||
"""Normalize the final Chinese system role code to English."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260911_0020"
|
||||
down_revision: str | None = "20260910_0019"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text("UPDATE user_accounts SET role = 'user', updated_at = now() WHERE role = '普通用户'")
|
||||
)
|
||||
connection.execute(
|
||||
sa.text("UPDATE role_permissions SET role = 'user', updated_at = now() WHERE role = '普通用户'")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
connection.execute(
|
||||
sa.text("UPDATE user_accounts SET role = '普通用户', updated_at = now() WHERE role = 'user'")
|
||||
)
|
||||
connection.execute(
|
||||
sa.text("UPDATE role_permissions SET role = '普通用户', updated_at = now() WHERE role = 'user'")
|
||||
)
|
||||
@ -1,31 +0,0 @@
|
||||
"""Apply the admin/user policy to existing real DingTalk accounts."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260911_0021"
|
||||
down_revision: str | None = "20260911_0020"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_accounts
|
||||
SET role = CASE WHEN dingtalk_is_dept_manager THEN 'admin' ELSE 'user' END,
|
||||
updated_at = now()
|
||||
WHERE dingtalk_id IS NOT NULL
|
||||
AND dingtalk_id NOT LIKE 'local-%'
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The previous product_manager/reviewer assignment cannot be reconstructed
|
||||
# reliably; keep the current safe role instead of guessing on downgrade.
|
||||
pass
|
||||
@ -14,9 +14,6 @@ dependencies = [
|
||||
"fastapi>=0.115,<1.0",
|
||||
"httpx>=0.27,<1.0",
|
||||
"openpyxl>=3.1,<4.0",
|
||||
"python-multipart>=0.0.9,<1.0",
|
||||
"reportlab>=4.0,<5.0",
|
||||
"python-docx>=1.1,<2.0",
|
||||
"PyJWT>=2.8,<3.0",
|
||||
"pydantic>=2.8,<3.0",
|
||||
"pydantic-settings>=2.4,<3.0",
|
||||
|
||||
@ -3,9 +3,6 @@ asyncpg>=0.29,<1.0
|
||||
fastapi>=0.115,<1.0
|
||||
httpx>=0.27,<1.0
|
||||
openpyxl>=3.1,<4.0
|
||||
python-multipart>=0.0.9,<1.0
|
||||
reportlab>=4.0,<5.0
|
||||
python-docx>=1.1,<2.0
|
||||
PyJWT>=2.8,<3.0
|
||||
pydantic>=2.8,<3.0
|
||||
pydantic-settings>=2.4,<3.0
|
||||
|
||||
@ -1,191 +0,0 @@
|
||||
"""Generate and store anomaly-case embeddings after pgvector is available.
|
||||
|
||||
Usage:
|
||||
.venv/Scripts/python -m scripts.backfill_embeddings
|
||||
|
||||
The script is intentionally separate from application startup. Embedding 2,448
|
||||
rows can take time and should never make the API unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from app.core.config import get_settings
|
||||
from app.database.session import SessionFactory
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger("dieya_risk_prediction.embedding_backfill")
|
||||
|
||||
|
||||
def _case_text(row: dict[str, Any]) -> str:
|
||||
labels = (
|
||||
("产品", row.get("product_name")),
|
||||
("品类", row.get("product_category")),
|
||||
("包材", row.get("material_type")),
|
||||
("标准供应商", row.get("canonical_supplier") or row.get("supplier")),
|
||||
("加工厂", row.get("factory")),
|
||||
("阶段", row.get("stage_new")),
|
||||
("异常类型", row.get("anomaly_type")),
|
||||
("异常描述", row.get("description")),
|
||||
("原因", row.get("root_cause")),
|
||||
("解决方案", row.get("solution")),
|
||||
)
|
||||
return ";".join(f"{label}:{value}" for label, value in labels if value)
|
||||
|
||||
|
||||
async def _embedding(
|
||||
client: httpx.AsyncClient,
|
||||
endpoint: str,
|
||||
api_key: str,
|
||||
texts: list[str],
|
||||
model: str,
|
||||
) -> list[list[float]]:
|
||||
for attempt in range(4):
|
||||
try:
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
json={"model": model, "input": texts},
|
||||
)
|
||||
except httpx.RequestError:
|
||||
if attempt == 3:
|
||||
raise
|
||||
delay = 2.0 * (2**attempt) + random.uniform(0, 1)
|
||||
logger.warning("Embedding 网络异常 attempt=%s delay=%.1fs", attempt + 1, delay)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
if response.status_code not in {429, 500, 502, 503, 504}:
|
||||
response.raise_for_status()
|
||||
data = response.json().get("data") or []
|
||||
ordered = sorted(data, key=lambda item: item.get("index", 0))
|
||||
return [list(map(float, item["embedding"])) for item in ordered]
|
||||
if attempt == 3:
|
||||
response.raise_for_status()
|
||||
retry_after = response.headers.get("retry-after")
|
||||
try:
|
||||
delay = min(60.0, max(3.0, float(retry_after))) if retry_after else 3.0 * (2**attempt)
|
||||
except ValueError:
|
||||
delay = 3.0 * (2**attempt)
|
||||
delay += random.uniform(0, 1)
|
||||
logger.warning(
|
||||
"Embedding API 限流或暂时不可用 status=%s attempt=%s delay=%.1fs",
|
||||
response.status_code,
|
||||
attempt + 1,
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError("Embedding 请求重试次数已用尽")
|
||||
|
||||
|
||||
async def main(batch_size: int, *, force: bool = False) -> None:
|
||||
settings = get_settings()
|
||||
api_key = (
|
||||
settings.rag_embedding_api_key.get_secret_value()
|
||||
if settings.rag_embedding_api_key
|
||||
else settings.llm_api_key.get_secret_value() if settings.llm_api_key else ""
|
||||
)
|
||||
base_url = settings.rag_embedding_base_url or settings.llm_base_url
|
||||
if not api_key or not base_url:
|
||||
raise RuntimeError("未配置 Embeddings API Key 或 Base URL")
|
||||
endpoint = f"{base_url.rstrip('/')}/embeddings"
|
||||
if not base_url.rstrip("/").endswith("/v1"):
|
||||
endpoint = f"{base_url.rstrip('/')}/v1/embeddings"
|
||||
|
||||
async with SessionFactory() as session:
|
||||
column_exists = await session.scalar(
|
||||
text(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'anomaly_cases'
|
||||
AND column_name = 'embedding_vector'
|
||||
AND udt_name = 'halfvec'
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
if not column_exists:
|
||||
raise RuntimeError("当前数据库没有 embedding_vector,请先安装 pgvector 并执行 alembic upgrade head")
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT ac.id, ac.product_name, ac.product_category, ac.material_type, ac.supplier,
|
||||
ac.factory, ac.stage_new, ac.anomaly_type, ac.description, ac.root_cause, ac.solution,
|
||||
string_agg(DISTINCT sm.canonical_name, ' / ') AS canonical_supplier
|
||||
FROM anomaly_cases ac
|
||||
LEFT JOIN anomaly_case_suppliers acs ON acs.case_id = ac.id
|
||||
LEFT JOIN supplier_master sm ON sm.id = acs.supplier_id
|
||||
WHERE (:force OR ac.embedding_vector IS NULL)
|
||||
GROUP BY ac.id
|
||||
ORDER BY id
|
||||
"""
|
||||
),
|
||||
{"force": force},
|
||||
)
|
||||
).mappings().all()
|
||||
|
||||
logger.info("待生成案例向量数量=%s", len(rows))
|
||||
print(f"待生成案例向量数量:{len(rows)},批次大小:{batch_size}", flush=True)
|
||||
timeout = httpx.Timeout(
|
||||
max(30, settings.rag_embedding_timeout_seconds),
|
||||
connect=min(10, max(30, settings.rag_embedding_timeout_seconds)),
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
for start in range(0, len(rows), batch_size):
|
||||
batch = rows[start : start + batch_size]
|
||||
try:
|
||||
vectors = await _embedding(
|
||||
client,
|
||||
endpoint,
|
||||
api_key,
|
||||
[_case_text(dict(row)) for row in batch],
|
||||
settings.rag_embedding_model,
|
||||
)
|
||||
except (httpx.HTTPError, ValueError, TypeError, KeyError) as exc:
|
||||
failure_count += len(batch)
|
||||
logger.exception("案例向量生成失败 case_id=%s", batch[0]["id"])
|
||||
print(f"案例 {batch[0]['id']} 失败:{exc.__class__.__name__},已跳过", flush=True)
|
||||
continue
|
||||
if len(vectors) != len(batch):
|
||||
raise RuntimeError("Embeddings 返回数量与请求数量不一致")
|
||||
async with SessionFactory() as session:
|
||||
for row, vector in zip(batch, vectors, strict=True):
|
||||
literal = "[" + ",".join(str(value) for value in vector) + "]"
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE anomaly_cases "
|
||||
"SET embedding_vector = CAST(:embedding AS halfvec) "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{"embedding": literal, "id": row["id"]},
|
||||
)
|
||||
await session.commit()
|
||||
success_count += len(batch)
|
||||
logger.info("已完成案例向量回填 %s/%s", min(start + batch_size, len(rows)), len(rows))
|
||||
print(
|
||||
f"已完成:{min(start + batch_size, len(rows))}/{len(rows)},"
|
||||
f"成功:{success_count},失败:{failure_count},当前案例:{batch[-1]['id']}",
|
||||
flush=True,
|
||||
)
|
||||
# The provider already returns 429 with Retry-After when throttled;
|
||||
# keep only a small pacing gap between successful batches.
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--batch-size", type=int, default=1)
|
||||
parser.add_argument("--force", action="store_true", help="recompute existing vectors after source normalization")
|
||||
args = parser.parse_args()
|
||||
if not 1 <= args.batch_size <= 64:
|
||||
raise SystemExit("--batch-size 必须在 1 到 64 之间")
|
||||
asyncio.run(main(args.batch_size, force=args.force))
|
||||
@ -1,128 +0,0 @@
|
||||
"""Build canonical supplier entities and anomaly links from raw anomaly data.
|
||||
|
||||
The script is intentionally additive: it never overwrites anomaly_cases.supplier.
|
||||
Run after ``alembic upgrade head``. Existing master rows are reused by normalized
|
||||
name; low-confidence mentions remain marked ``待确认`` for manual review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import delete, distinct, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.entities import AnomalyCase, AnomalyCaseSupplier, PurchaseOrderLine, SupplierMaster
|
||||
from app.services.supplier_cleaning import infer_location, normalized_key, resolve_canonical_name, split_supplier_cell
|
||||
|
||||
|
||||
async def clean(*, rebuild: bool = False) -> dict[str, int]:
|
||||
settings = get_settings()
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
stats = defaultdict(int)
|
||||
try:
|
||||
async with factory() as session:
|
||||
if rebuild:
|
||||
await session.execute(delete(AnomalyCaseSupplier))
|
||||
await session.execute(delete(SupplierMaster))
|
||||
stats["derived_tables_rebuilt"] = 1
|
||||
masters = {row.normalized_name: row for row in (await session.execute(select(SupplierMaster))).scalars()}
|
||||
order_names = [
|
||||
value.strip()
|
||||
for value in (await session.execute(select(distinct(PurchaseOrderLine.supplier)))).scalars()
|
||||
if value and value.strip()
|
||||
]
|
||||
cases = (await session.execute(select(AnomalyCase))).scalars().all()
|
||||
parsed: list[tuple[AnomalyCase, object]] = []
|
||||
for case in cases:
|
||||
mentions = split_supplier_cell(case.supplier, material_type=case.material_type)
|
||||
for mention in mentions:
|
||||
canonical_name, confirmed = resolve_canonical_name(mention.name, order_names)
|
||||
key = normalized_key(canonical_name)
|
||||
master = masters.get(key)
|
||||
if master is None:
|
||||
city, province = infer_location(canonical_name)
|
||||
master = SupplierMaster(
|
||||
canonical_name=canonical_name,
|
||||
short_name=mention.name,
|
||||
normalized_name=key,
|
||||
city=city,
|
||||
province=province,
|
||||
supplier_type="供应商",
|
||||
aliases=[mention.name],
|
||||
source="采购订单匹配" if confirmed else "异常数据清洗",
|
||||
review_status="已确认" if confirmed else "待确认",
|
||||
)
|
||||
session.add(master)
|
||||
masters[key] = master
|
||||
stats["masters_created"] += 1
|
||||
if confirmed:
|
||||
stats["masters_confirmed_from_orders"] += 1
|
||||
elif mention.name not in master.aliases:
|
||||
master.aliases = [*master.aliases, mention.name]
|
||||
if len(mention.name) < len(master.short_name or master.canonical_name):
|
||||
master.short_name = mention.name
|
||||
parsed.append((case, mention))
|
||||
stats["cases_scanned"] += 1
|
||||
|
||||
# Assign all master IDs in one database round trip.
|
||||
await session.flush()
|
||||
existing_links = {
|
||||
(case_id, supplier_id, role)
|
||||
for case_id, supplier_id, role in (
|
||||
await session.execute(
|
||||
select(
|
||||
AnomalyCaseSupplier.case_id,
|
||||
AnomalyCaseSupplier.supplier_id,
|
||||
AnomalyCaseSupplier.supplier_role,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
pending_links: set[tuple[int, int, str | None]] = set()
|
||||
for case, mention in parsed:
|
||||
canonical_name, _ = resolve_canonical_name(mention.name, order_names)
|
||||
master = masters[normalized_key(canonical_name)]
|
||||
link_key = (case.id, master.id, mention.role)
|
||||
if link_key in existing_links or link_key in pending_links:
|
||||
stats["links_skipped"] += 1
|
||||
continue
|
||||
session.add(
|
||||
AnomalyCaseSupplier(
|
||||
case_id=case.id,
|
||||
supplier_id=master.id,
|
||||
supplier_role=mention.role,
|
||||
material_type=mention.material_type,
|
||||
source_text=mention.source_text,
|
||||
match_method=mention.method,
|
||||
match_confidence=mention.confidence,
|
||||
review_status="待确认",
|
||||
)
|
||||
)
|
||||
pending_links.add(link_key)
|
||||
stats["links_created"] += 1
|
||||
await session.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
return dict(stats)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--rebuild",
|
||||
action="store_true",
|
||||
help="clear and rebuild only the derived supplier master/link tables",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
result = asyncio.run(clean(rebuild=args.rebuild))
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -47,7 +47,7 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
default="admin",
|
||||
choices=("user", "product_manager", "reviewer", "admin"),
|
||||
choices=("普通用户", "dev", "procurement", "qc", "ops", "reviewer", "manager", "admin"),
|
||||
)
|
||||
parser.add_argument("--email")
|
||||
args = parser.parse_args()
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
"""Enable pgvector schema objects with a PostgreSQL administrator account.
|
||||
|
||||
Run once after the pgvector package is installed on the server:
|
||||
|
||||
.venv/Scripts/python -m scripts.enable_pgvector
|
||||
|
||||
Then the normal application account can run ``scripts.backfill_embeddings``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.database.session import SessionFactory
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with SessionFactory() as session:
|
||||
await session.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
await session.execute(
|
||||
text(
|
||||
"ALTER TABLE anomaly_cases "
|
||||
"ADD COLUMN IF NOT EXISTS embedding_vector halfvec(3072)"
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_anomaly_cases_embedding_vector "
|
||||
"ON anomaly_cases USING hnsw (embedding_vector halfvec_cosine_ops)"
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
print("pgvector 已启用,embedding_vector 和 HNSW 索引已准备完成。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@ -1,32 +0,0 @@
|
||||
"""Import a procurement-order workbook into the purchase order ledger."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.database.session import SessionFactory
|
||||
from app.services.purchase_orders import import_purchase_orders
|
||||
|
||||
|
||||
async def run(path: Path) -> None:
|
||||
async with SessionFactory() as session:
|
||||
result = await import_purchase_orders(session, path)
|
||||
await session.commit()
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--file", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
if not args.file.is_file():
|
||||
parser.error(f"file does not exist: {args.file}")
|
||||
logging.basicConfig(level=get_settings().log_level)
|
||||
asyncio.run(run(args.file))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -2,37 +2,9 @@ from app.services.dingtalk_auth import DingTalkAuthService
|
||||
|
||||
|
||||
def test_dingtalk_role_mapping() -> None:
|
||||
assert DingTalkAuthService.map_role("产品部", "产品经理") == "user"
|
||||
assert DingTalkAuthService.map_role("品控部", "质量主管") == "user"
|
||||
assert DingTalkAuthService.map_role("采购部", "采购专员") == "user"
|
||||
assert DingTalkAuthService.map_role("", "审核员") == "user"
|
||||
assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev"
|
||||
assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc"
|
||||
assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement"
|
||||
assert DingTalkAuthService.map_role("", "审核员") == "reviewer"
|
||||
assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin"
|
||||
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "user"
|
||||
|
||||
|
||||
def test_dingtalk_sync_only_promotes_user_and_never_downgrades_admin() -> None:
|
||||
assert DingTalkAuthService.resolve_synced_role("user", "admin") == "admin"
|
||||
assert DingTalkAuthService.resolve_synced_role("user", "user") == "user"
|
||||
assert DingTalkAuthService.resolve_synced_role("admin", "user") == "admin"
|
||||
assert DingTalkAuthService.resolve_synced_role("admin", "admin") == "admin"
|
||||
assert DingTalkAuthService.resolve_synced_role("reviewer", "admin") == "reviewer"
|
||||
|
||||
|
||||
def test_dingtalk_manager_flag_variants() -> None:
|
||||
assert DingTalkAuthService._is_dingtalk_manager({"admin": True})
|
||||
assert DingTalkAuthService._is_dingtalk_manager({"leader_in_dept": [{"leader": True}]})
|
||||
assert not DingTalkAuthService._is_dingtalk_manager({"admin": False, "leader_in_dept": []})
|
||||
|
||||
|
||||
def test_dingtalk_detail_fields_are_normalized() -> None:
|
||||
detail = {"nick": "张三", "dept_id_list": [123, "456"], "is_leader": True}
|
||||
assert DingTalkAuthService._display_name(detail, "fallback") == "张三"
|
||||
assert DingTalkAuthService._department_ids(detail) == [123, 456]
|
||||
assert DingTalkAuthService._is_dingtalk_manager(detail)
|
||||
|
||||
|
||||
def test_dingtalk_nested_detail_fields_are_supported() -> None:
|
||||
detail = {"user": {"userName": "李四", "deptIdList": [789]}}
|
||||
nested = detail["user"]
|
||||
assert DingTalkAuthService._display_name(nested, "fallback") == "李四"
|
||||
assert DingTalkAuthService._department_ids(nested) == [789]
|
||||
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户"
|
||||
|
||||
@ -1,132 +0,0 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from app.schemas.ocr import OcrMeasurement
|
||||
from app.services.baidu_ocr import BaiduOcrClient
|
||||
from app.services.ocr import OcrService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webimage_recognition_returns_text_and_confidence() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/token"):
|
||||
return httpx.Response(200, json={"access_token": "token", "expires_in": 3600})
|
||||
assert request.url.path.endswith("/webimage")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"words_result": [
|
||||
{"words": "产品名称:测试面霜", "probability": {"average": 0.92}},
|
||||
{"words": "pH:5.6", "probability": {"average": 0.88}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
client = BaiduOcrClient(httpx.MockTransport(handler))
|
||||
client.api_key = "api-key"
|
||||
client.secret_key = "secret-key"
|
||||
text, confidence, pages = await client.recognize_image(b"fake-image")
|
||||
|
||||
assert text == "产品名称:测试面霜\npH:5.6"
|
||||
assert confidence == pytest.approx(0.9)
|
||||
assert pages == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paddle_document_recognition_polls_and_downloads_result() -> None:
|
||||
calls = {"query": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/token"):
|
||||
return httpx.Response(200, json={"access_token": "token", "expires_in": 3600})
|
||||
if request.url.path.endswith("/task/query"):
|
||||
calls["query"] += 1
|
||||
status = "processing" if calls["query"] == 1 else "success"
|
||||
result = {"status": status}
|
||||
if status == "success":
|
||||
result["parse_result_url"] = "https://mock.bcebos.com/result.json"
|
||||
return httpx.Response(200, json={"error_code": 0, "result": result})
|
||||
if request.url.path.endswith("/task"):
|
||||
return httpx.Response(200, json={"error_code": 0, "result": {"task_id": "task-1"}})
|
||||
if request.url.path.endswith("/result.json"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=json.dumps(
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"text": "产品名称:测试面霜",
|
||||
"tables": [{"markdown": "|检测项|值|\n|pH|5.6|"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode(),
|
||||
)
|
||||
raise AssertionError(f"unexpected URL: {request.url}")
|
||||
|
||||
client = BaiduOcrClient(httpx.MockTransport(handler))
|
||||
client.api_key = "api-key"
|
||||
client.secret_key = "secret-key"
|
||||
client.poll_interval = 0.001
|
||||
text, confidence, pages = await client.recognize_document(b"%PDF-test", "report.pdf")
|
||||
|
||||
assert "测试面霜" in text
|
||||
assert "|pH|5.6|" in text
|
||||
assert confidence is None
|
||||
assert pages == 1
|
||||
assert calls["query"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_value", "value", "standard", "expected"),
|
||||
[
|
||||
("5.6", 5.6, "pH 4.5-6.5", "合格"),
|
||||
("3.8", 3.8, "色差 ΔE≤3.0", "不合格"),
|
||||
("+18%", 18, "喷出量偏差≤±15%", "不合格"),
|
||||
("0.9 N·m", 0.9, "开启力矩 0.8~3.2N·m", "合格"),
|
||||
],
|
||||
)
|
||||
def test_standard_numeric_evaluation(raw_value: str, value: float, standard: str, expected: str) -> None:
|
||||
measurement = OcrMeasurement(
|
||||
item="检测项",
|
||||
raw_value=raw_value,
|
||||
value=value,
|
||||
confidence=0.95,
|
||||
needs_review=False,
|
||||
)
|
||||
result, reason = OcrService.evaluate_standard(measurement, standard)
|
||||
|
||||
assert result == expected
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_upload_type_is_detected_from_content_not_filename() -> None:
|
||||
assert OcrService._detect_type(b"%PDF-1.7") == ("application/pdf", ".pdf")
|
||||
assert OcrService._detect_type(b"\x89PNG\r\n\x1a\nrest") == ("image/png", ".png")
|
||||
assert OcrService._detect_type(b"\xff\xd8\xffrest") == ("image/jpeg", ".jpg")
|
||||
|
||||
|
||||
def test_standard_judgment_rejects_unit_mismatch() -> None:
|
||||
measurement = OcrMeasurement(
|
||||
item="铅含量",
|
||||
raw_value="8mg/L",
|
||||
value=8,
|
||||
unit="mg/L",
|
||||
confidence=0.95,
|
||||
needs_review=False,
|
||||
)
|
||||
|
||||
result, reason = OcrService.evaluate_standard(measurement, "铅≤10mg/kg")
|
||||
|
||||
assert result == "待人工确认"
|
||||
assert reason == "检测值单位 mg/L 与标准单位 mg/kg 不一致"
|
||||
|
||||
|
||||
def test_generic_quantity_does_not_fuzzy_match_a_standard() -> None:
|
||||
class Rule:
|
||||
risk_point = "销售包装可视面及最小销售单元完整标注"
|
||||
judge_standard = "中英文标签内容完整"
|
||||
|
||||
assert OcrService._best_rule("数量", [Rule()]) is None
|
||||
@ -1,19 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from app.models.entities import Conversation, UserAccount
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_conversation_rejects_conversation_owned_by_another_user() -> None:
|
||||
service = object.__new__(ConversationService)
|
||||
|
||||
class Repository:
|
||||
async def get_for_user(self, conversation_id, user_id):
|
||||
return None
|
||||
|
||||
service.repository = Repository()
|
||||
service.session = object()
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await service.delete(10, UserAccount(id=2, emp_no="u2", name="用户", role="user"))
|
||||
assert getattr(exc_info.value, "status_code", None) == 404
|
||||
@ -1,8 +1,6 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from app.models.entities import Conversation, ConversationMessage
|
||||
from app.schemas.risk_prediction import SupplierFrequency
|
||||
from app.services.conversations import ConversationService
|
||||
|
||||
|
||||
@ -11,7 +9,7 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None:
|
||||
id=10,
|
||||
conversation_id=3,
|
||||
role="assistant",
|
||||
content="已完成风险预警。",
|
||||
content="已完成风险预估。",
|
||||
structured_data={
|
||||
"riskResult": {
|
||||
"subjectId": "软管新品",
|
||||
@ -32,32 +30,6 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None:
|
||||
assert result.risk_result.level.value == "low"
|
||||
|
||||
|
||||
def test_conversation_message_data_reads_persisted_semantic_and_response_card() -> None:
|
||||
message = ConversationMessage(
|
||||
id=11,
|
||||
conversation_id=3,
|
||||
role="assistant",
|
||||
content="已完成分析。",
|
||||
structured_data={
|
||||
"semantic": {"intent": "solution", "category": "洗发水"},
|
||||
"responseCard": {
|
||||
"type": "solution",
|
||||
"title": "AI 案例推理生成",
|
||||
"similarCases": [{"case_id": 101, "similarity": 0.82}],
|
||||
"suggestions": ["完成包材相容性测试"],
|
||||
},
|
||||
},
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
result = ConversationService._message(message)
|
||||
|
||||
assert result.semantic == {"intent": "solution", "category": "洗发水"}
|
||||
assert result.response_card is not None
|
||||
assert result.response_card["similarCases"][0]["case_id"] == 101
|
||||
assert result.response_card["suggestions"] == ["完成包材相容性测试"]
|
||||
|
||||
|
||||
def test_conversation_data_includes_all_messages() -> None:
|
||||
conversation = Conversation(
|
||||
id=3,
|
||||
@ -104,19 +76,6 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None:
|
||||
) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("评估新品:洗发水,包装材质:泵头", "risk_assessment"),
|
||||
("查物料风险:软管", "material"),
|
||||
("搜索相似案例:软管印刷遇水脱落", "risk_assessment"),
|
||||
("生成 AI 案例推理方案:铝瓶+喷头密封验证", "solution"),
|
||||
],
|
||||
)
|
||||
def test_tool_requests_keep_their_intent_for_card_routing(text: str, expected: str) -> None:
|
||||
assert ConversationService._response_type(text, {"intent": "general"}) == expected
|
||||
|
||||
|
||||
def test_legacy_general_semantics_hide_old_risk_card() -> None:
|
||||
messages = [
|
||||
ConversationMessage(
|
||||
@ -160,170 +119,3 @@ def test_product_assessment_semantics_override_nlu_miss() -> None:
|
||||
|
||||
assert normalized["intent"] == "assessment"
|
||||
assert normalized["risk_requested"] is True
|
||||
|
||||
|
||||
def test_short_known_supplier_follow_up_is_routed_to_supplier_card() -> None:
|
||||
assert ConversationService._infer_supplier_follow_up(
|
||||
"佳亿",
|
||||
["佳亿", "祥印", "瑞沣"],
|
||||
) == "佳亿"
|
||||
assert ConversationService._infer_supplier_follow_up(
|
||||
"这个供应商",
|
||||
["佳亿", "祥印"],
|
||||
) is None
|
||||
|
||||
|
||||
def test_unknown_standalone_name_is_treated_as_new_supplier() -> None:
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("德盛") == "德盛"
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("新供应商A") is None
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("测试供应商") is None
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("新供应商B") is None
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("洗发水") is None
|
||||
assert ConversationService._infer_unknown_supplier_follow_up("帮我查德盛") is None
|
||||
assert ConversationService._response_type("测试供应商", {"intent": "supplier"}) == "general"
|
||||
assert ConversationService._response_type("新供应商A", {"intent": "supplier"}) == "general"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("count", "a_count", "b_count", "expected"),
|
||||
[
|
||||
(0, 0, 0, "新供应商"),
|
||||
(12, 0, 1, "S级 · 战略"),
|
||||
(40, 0, 4, "A级 · 优质"),
|
||||
(70, 0, 10, "B级 · 关注"),
|
||||
(40, 0, 16, "B级 · 关注"),
|
||||
(10, 1, 0, "C级 · 高风险"),
|
||||
(121, 0, 0, "C级 · 高风险"),
|
||||
],
|
||||
)
|
||||
def test_supplier_tier_uses_prd_frequency_and_defect_rules(
|
||||
count: int,
|
||||
a_count: int,
|
||||
b_count: int,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert ConversationService._supplier_tier(
|
||||
count,
|
||||
a_count=a_count,
|
||||
b_count=b_count,
|
||||
) == expected
|
||||
|
||||
|
||||
def test_supplier_management_suggestions_follow_prd_actions() -> None:
|
||||
assert ConversationService._supplier_management_suggestions("B级 · 关注") == [
|
||||
"加强巡检",
|
||||
"新品打样加严",
|
||||
]
|
||||
assert ConversationService._supplier_management_suggestions("新供应商") == [
|
||||
"无历史数据",
|
||||
"首单全检",
|
||||
"安排驻厂巡检",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplier_card_uses_prd_proxy_frequency_and_tier_advice(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = object.__new__(ConversationService)
|
||||
|
||||
async def history_stats(*_args, **_kwargs):
|
||||
return 70, 0, 10
|
||||
|
||||
async def top_anomaly_types(*_args, **_kwargs):
|
||||
return [{"name": "泵头异常", "count": 12}]
|
||||
|
||||
monkeypatch.setattr(service, "_supplier_history_stats", history_stats)
|
||||
monkeypatch.setattr(service, "_top_anomaly_types", top_anomaly_types)
|
||||
|
||||
result = await service._supplier_card(
|
||||
"祥印供应商风险怎么样",
|
||||
{"supplier": "祥印"},
|
||||
)
|
||||
|
||||
assert result["anomalyCount"] == 70
|
||||
assert result["cooperationCount"] is None
|
||||
assert result["frequencyCoefficient"] == 1.0
|
||||
assert result["tier"] == "B级 · 关注"
|
||||
assert result["suggestions"] == ["加强巡检", "新品打样加严"]
|
||||
assert result["frequencyBasis"] == "暂无匹配采购订单"
|
||||
assert result["dataAvailable"] is False
|
||||
|
||||
|
||||
def test_new_supplier_frequency_serializes_zero_proxy_count() -> None:
|
||||
result = SupplierFrequency(
|
||||
supplier_name="新供应商",
|
||||
cooperation_count=0,
|
||||
anomaly_count=0,
|
||||
tier="新供应商",
|
||||
suggestion="无历史数据;首单全检;安排驻厂巡检",
|
||||
note="合作次数为近3年异常记录数代理值,不代表真实采购订单合作次数。",
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
assert result["cooperationCount"] == 0
|
||||
assert result["anomalyCount"] == 0
|
||||
assert result["frequencyBasis"] == "暂无匹配采购订单"
|
||||
|
||||
|
||||
def test_unspecified_supplier_returns_displayable_no_data_frequency_card() -> None:
|
||||
result = SupplierFrequency(
|
||||
note="未指定供应商,暂无供应商频次分析。",
|
||||
frequency_basis="未提供供应商",
|
||||
).model_dump(by_alias=True)
|
||||
|
||||
assert result["dataAvailable"] is False
|
||||
assert result["alpha"] is None
|
||||
assert result["note"] == "未指定供应商,暂无供应商频次分析。"
|
||||
|
||||
|
||||
def test_similar_cases_are_typed_before_risk_result_serialization() -> None:
|
||||
from app.schemas.risk_prediction import RiskAssessmentResult, SimilarCase
|
||||
|
||||
result = RiskAssessmentResult(
|
||||
subject_id="test",
|
||||
score=52,
|
||||
level="medium",
|
||||
model_version="test",
|
||||
factors=[],
|
||||
similar_cases=[SimilarCase(case_id=1, similarity=0.75)],
|
||||
)
|
||||
|
||||
payload = result.model_dump(mode="json")
|
||||
|
||||
assert payload["similar_cases"][0]["case_id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_any_product_without_history_uses_solution_card(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = object.__new__(ConversationService)
|
||||
|
||||
async def no_history(_: str) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(service, "_count_category_anomalies", no_history)
|
||||
|
||||
response_type = ConversationService._response_type(
|
||||
"评估防晒霜新品,50g,软管包装",
|
||||
{"intent": "assessment", "risk_requested": True, "category": "防晒霜"},
|
||||
)
|
||||
result = await service._use_solution_card_without_history(
|
||||
response_type,
|
||||
"评估防晒霜新品,50g,软管包装",
|
||||
{"intent": "assessment", "risk_requested": True, "category": "防晒霜"},
|
||||
)
|
||||
|
||||
assert response_type == "risk_assessment"
|
||||
assert result == "solution"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_product_without_category_uses_solution_card() -> None:
|
||||
service = object.__new__(ConversationService)
|
||||
|
||||
result = await service._use_solution_card_without_history(
|
||||
"risk_assessment",
|
||||
"评估这个新产品的风险",
|
||||
{"intent": "assessment", "risk_requested": True},
|
||||
)
|
||||
|
||||
assert result == "solution"
|
||||
|
||||
@ -1,118 +0,0 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from app.schemas.model_config import ModelWeightConfigUpdate
|
||||
from app.services.database_risk_scoring import DatabaseRiskScoringService
|
||||
from pydantic import ValidationError
|
||||
|
||||
|
||||
def _row(**values):
|
||||
return {
|
||||
"date": date(2026, 1, 10),
|
||||
"product_name": "洗发水 500ml",
|
||||
"product_category": None,
|
||||
"material_type": "瓶子",
|
||||
"supplier": "祥印",
|
||||
"factory": "一一",
|
||||
"stage_new": "第三阶段(产品开发)",
|
||||
"defect_level": None,
|
||||
**values,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_phase_score_uses_database_dimensions_and_prd_fallbacks() -> None:
|
||||
service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4))
|
||||
|
||||
async def load_rows():
|
||||
return [
|
||||
_row(),
|
||||
_row(product_name="洗发水 250ml", defect_level="B"),
|
||||
]
|
||||
|
||||
service._load_rows = load_rows # type: ignore[method-assign]
|
||||
result = await service.score(
|
||||
category="洗发水",
|
||||
material="瓶子",
|
||||
supplier="祥印",
|
||||
stage="产品开发",
|
||||
)
|
||||
|
||||
assert len(result.dimensions) == 7
|
||||
assert sum(item.weight for item in result.dimensions) == 100
|
||||
assert result.level.value == "medium"
|
||||
assert result.dimensions[0].score == 95.57
|
||||
assert result.dimensions[0].raw_value == 2
|
||||
assert result.dimensions[1].score == 0
|
||||
assert result.dimensions[2].score == 45
|
||||
assert result.dimensions[3].status == "default"
|
||||
assert result.dimensions[4].status == "default"
|
||||
assert result.dimensions[5].score == 50
|
||||
assert result.dimensions[6].score == 85
|
||||
assert result.score == 52.39
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supplier_dimension_does_not_count_factory_records() -> None:
|
||||
service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4))
|
||||
|
||||
async def load_rows():
|
||||
return [_row(supplier=None, factory="祥印")]
|
||||
|
||||
service._load_rows = load_rows # type: ignore[method-assign]
|
||||
result = await service.score(supplier="祥印")
|
||||
|
||||
supplier_dimension = result.dimensions[0]
|
||||
assert supplier_dimension.score == 70
|
||||
assert supplier_dimension.raw_value == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_score_uses_persisted_weight_and_frequency_config() -> None:
|
||||
configured_weights = {
|
||||
"供应商表现": 30,
|
||||
"物料品类": 15,
|
||||
"产品品类": 15,
|
||||
"加工厂质量": 15,
|
||||
"标准符合性": 10,
|
||||
"节点阶段": 10,
|
||||
"缺陷等级": 5,
|
||||
}
|
||||
|
||||
class Session:
|
||||
async def get(self, _model, _identifier):
|
||||
return SimpleNamespace(
|
||||
weights=configured_weights,
|
||||
frequency_coefficient=Decimal("0.5"),
|
||||
frequency_cap=Decimal("1.8"),
|
||||
)
|
||||
|
||||
service = DatabaseRiskScoringService(Session(), as_of=date(2026, 9, 4))
|
||||
|
||||
async def load_rows():
|
||||
return [_row(), _row(product_name="洗发水 250ml", defect_level="B")]
|
||||
|
||||
service._load_rows = load_rows # type: ignore[method-assign]
|
||||
result = await service.score(category="洗发水", material="瓶子", supplier="祥印", stage="产品开发")
|
||||
|
||||
assert [item.weight for item in result.dimensions] == [30, 15, 15, 15, 10, 10, 5]
|
||||
assert result.dimensions[0].score == 95.95
|
||||
|
||||
|
||||
def test_weight_config_requires_exactly_one_hundred_percent() -> None:
|
||||
with pytest.raises(ValidationError, match="合计必须为100%"):
|
||||
ModelWeightConfigUpdate(
|
||||
weights={
|
||||
"供应商表现": 20,
|
||||
"物料品类": 20,
|
||||
"产品品类": 15,
|
||||
"加工厂质量": 15,
|
||||
"标准符合性": 10,
|
||||
"节点阶段": 10,
|
||||
"缺陷等级": 5,
|
||||
},
|
||||
frequency_coefficient=0.3,
|
||||
frequency_cap=1.5,
|
||||
)
|
||||
@ -18,9 +18,6 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
request_body = json.loads(request.content)
|
||||
assert request_body["response_format"] == {"type": "json_object"}
|
||||
assert "json" in request_body["messages"][0]["content"]
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
@ -48,86 +45,13 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
|
||||
|
||||
client = LLMClient(transport=httpx.MockTransport(handler))
|
||||
semantic = await client.extract_semantics("评估洗发水新品")
|
||||
chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])]
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])
|
||||
]
|
||||
|
||||
assert semantic["intent"] == "assessment"
|
||||
assert semantic["confidence"] == 0.92
|
||||
assert "".join(chunks) == "第一段第二段"
|
||||
assert calls == 2
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_request_fails_over_to_deepseek_responses(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key")
|
||||
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1")
|
||||
monkeypatch.setenv("RISK_LLM_MODEL", "primary-model")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses")
|
||||
calls: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(str(request.url))
|
||||
if request.url.path.endswith("/chat/completions"):
|
||||
return httpx.Response(503, json={"error": {"message": "primary unavailable"}})
|
||||
body = json.loads(request.content)
|
||||
assert body["model"] == "deepseek-v4-pro"
|
||||
assert body["input"][-1]["content"] == "评估洗发水新品"
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"output_text": json.dumps(
|
||||
{"intent": "assessment", "risk_requested": True, "confidence": 0.8},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
client = LLMClient(transport=httpx.MockTransport(handler))
|
||||
result = await client.extract_semantics("评估洗发水新品")
|
||||
|
||||
assert result["intent"] == "assessment"
|
||||
assert calls == [
|
||||
"https://primary.test/v1/chat/completions",
|
||||
"https://fallback.test/responses",
|
||||
]
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_request_fails_over_before_primary_emits_text(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key")
|
||||
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro")
|
||||
monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses")
|
||||
calls: list[str] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(str(request.url))
|
||||
if request.url.path.endswith("/chat/completions"):
|
||||
raise httpx.ConnectError("primary connection failed", request=request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"content-type": "text/event-stream"},
|
||||
content=(
|
||||
'data: {"type":"response.output_text.delta","delta":"兜底"}\n\n'
|
||||
'data: {"type":"response.output_text.delta","delta":"回复"}\n\n'
|
||||
"data: [DONE]\n\n"
|
||||
).encode(),
|
||||
)
|
||||
|
||||
client = LLMClient(transport=httpx.MockTransport(handler))
|
||||
chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])]
|
||||
|
||||
assert "".join(chunks) == "兜底回复"
|
||||
assert calls == [
|
||||
"https://primary.test/v1/chat/completions",
|
||||
"https://fallback.test/responses",
|
||||
]
|
||||
get_settings.cache_clear()
|
||||
|
||||
@ -7,7 +7,6 @@ from app.models import ( # noqa: F401
|
||||
ReviewRecord,
|
||||
RiskAssessment,
|
||||
RiskItem,
|
||||
RiskModelConfig,
|
||||
RolePermission,
|
||||
StandardRule,
|
||||
SupplierProfile,
|
||||
@ -19,19 +18,15 @@ from sqlalchemy import BigInteger
|
||||
def test_prd_core_tables_are_registered() -> None:
|
||||
assert set(Base.metadata.tables) == {
|
||||
"anomaly_cases",
|
||||
"anomaly_case_suppliers",
|
||||
"conversations",
|
||||
"conversation_messages",
|
||||
"operation_audits",
|
||||
"review_records",
|
||||
"risk_assessments",
|
||||
"risk_items",
|
||||
"risk_model_configs",
|
||||
"purchase_order_lines",
|
||||
"role_permissions",
|
||||
"standard_rules",
|
||||
"supplier_profiles",
|
||||
"supplier_master",
|
||||
"user_accounts",
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from app.services.purchase_orders import calendar_year_window
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("as_of", "expected"),
|
||||
[
|
||||
(date(2026, 9, 10), (date(2023, 1, 1), date(2026, 12, 31))),
|
||||
(date(2025, 1, 1), (date(2022, 1, 1), date(2025, 12, 31))),
|
||||
],
|
||||
)
|
||||
def test_three_year_window_uses_calendar_years(as_of: date, expected: tuple[date, date]) -> None:
|
||||
assert calendar_year_window(as_of, 3) == expected
|
||||
@ -1,87 +0,0 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from app.services.rag import AnomalyCaseRAG
|
||||
|
||||
|
||||
def _row(**values):
|
||||
return {
|
||||
"id": 1,
|
||||
"date": date(2026, 1, 10),
|
||||
"product_name": "洗发水 500ml",
|
||||
"product_category": "洗护类",
|
||||
"material_type": "软管",
|
||||
"supplier": "祥印",
|
||||
"factory": "晶品",
|
||||
"stage_new": "产品开发",
|
||||
"anomaly_type": "密封性异常",
|
||||
"description": "软管漏液",
|
||||
"solution": "增加密封性测试",
|
||||
"root_cause": "封口参数不稳定",
|
||||
"defect_level": "B",
|
||||
**values,
|
||||
}
|
||||
|
||||
|
||||
def test_rag_terms_and_structured_text_score() -> None:
|
||||
terms = AnomalyCaseRAG._terms("软管密封性异常", "洗发水", "软管")
|
||||
score = AnomalyCaseRAG._score(
|
||||
_row(),
|
||||
query="洗发水软管密封性异常",
|
||||
category="洗发水",
|
||||
material="软管",
|
||||
supplier="祥印",
|
||||
factory=None,
|
||||
stage="产品开发",
|
||||
anomaly_type="密封性异常",
|
||||
terms=terms,
|
||||
)
|
||||
|
||||
assert "密封性" in terms
|
||||
assert score > 20
|
||||
case = AnomalyCaseRAG._to_case(_row())
|
||||
assert case.product == "洗发水 500ml"
|
||||
assert case.problem == "密封性异常"
|
||||
assert case.solution == "增加密封性测试"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rag_does_not_call_embedding_until_vectors_are_stored() -> None:
|
||||
class FakeMappings:
|
||||
def all(self):
|
||||
return [_row()]
|
||||
|
||||
class FakeResult:
|
||||
def mappings(self):
|
||||
return FakeMappings()
|
||||
|
||||
class FakeSession:
|
||||
async def execute(self, _statement):
|
||||
return FakeResult()
|
||||
|
||||
rag = AnomalyCaseRAG(FakeSession(), top_k=1)
|
||||
|
||||
async def vector_column_ready():
|
||||
return True
|
||||
|
||||
async def has_stored_vectors():
|
||||
return False
|
||||
|
||||
async def embedding_must_not_run(_query):
|
||||
raise AssertionError("没有已回填向量时不应调用 Embedding")
|
||||
|
||||
rag._vector_column_ready = vector_column_ready
|
||||
rag._has_stored_vectors = has_stored_vectors
|
||||
rag._query_embedding = embedding_must_not_run
|
||||
|
||||
rows = await rag._load_rows(
|
||||
"洗发水软管密封性异常",
|
||||
category="洗发水",
|
||||
material="软管",
|
||||
supplier=None,
|
||||
factory=None,
|
||||
stage=None,
|
||||
anomaly_type=None,
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
@ -1,62 +0,0 @@
|
||||
from io import BytesIO
|
||||
|
||||
from app.schemas.risk_prediction import AiSolutionReport, ReportCard, RiskAssessmentResult
|
||||
from app.services.reports import (
|
||||
build_risk_report_docx,
|
||||
build_risk_report_pdf,
|
||||
effective_report_cards,
|
||||
report_card_lines,
|
||||
)
|
||||
from docx import Document
|
||||
|
||||
|
||||
def _result() -> RiskAssessmentResult:
|
||||
return RiskAssessmentResult(
|
||||
subject_id="洗发水新品",
|
||||
score=72,
|
||||
level="medium",
|
||||
model_version="test",
|
||||
factors=[],
|
||||
report_cards=[
|
||||
ReportCard(
|
||||
type="supplier",
|
||||
title="供应商深度画像",
|
||||
name="佳供应商",
|
||||
party_type="供应商",
|
||||
supplier_score=88.5,
|
||||
frequency_coefficient=1.2,
|
||||
top_problems=[{"name": "泄漏", "count": 3}],
|
||||
suggestions=["安排首单全检"],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_report_card_lines_are_canonical() -> None:
|
||||
lines = dict(report_card_lines(_result().report_cards[0]))
|
||||
assert lines["分析对象"] == "佳供应商"
|
||||
assert lines["供应商得分"] == "88.50 分"
|
||||
assert lines["高频问题"] == "泄漏(3次)"
|
||||
assert lines["建议方案"] == "安排首单全检"
|
||||
|
||||
|
||||
def test_old_ai_solution_payload_is_supported() -> None:
|
||||
result = _result().model_copy(
|
||||
update={"report_cards": [], "ai_solution_cards": [AiSolutionReport(title="旧方案")]}
|
||||
)
|
||||
cards = effective_report_cards(result)
|
||||
assert len(cards) == 1
|
||||
assert cards[0].title == "旧方案"
|
||||
|
||||
|
||||
def test_pdf_and_word_include_appended_card_content() -> None:
|
||||
result = _result()
|
||||
pdf = build_risk_report_pdf(result)
|
||||
word = build_risk_report_docx(result)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
document = Document(BytesIO(word))
|
||||
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
|
||||
text += "\n" + "\n".join(cell.text for table in document.tables for row in table.rows for cell in row.cells)
|
||||
assert "佳供应商" in text
|
||||
assert "88.50 分" in text
|
||||
assert "安排首单全检" in text
|
||||
@ -1,38 +0,0 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.exceptions import AppException
|
||||
from app.models.entities import OperationAudit, RiskItem, UserAccount
|
||||
from app.services.risk_items import RiskItemService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_risk_item_can_be_withdrawn_with_audit() -> None:
|
||||
item = RiskItem(id=12, source_type="表单", status="待审核", photos=[], ocr_payload={})
|
||||
operator = UserAccount(id=3, emp_no="admin-1", name="管理员", role="admin", status="启用")
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=item)
|
||||
session.flush = AsyncMock()
|
||||
|
||||
await RiskItemService(session).withdraw(item.id, operator)
|
||||
|
||||
assert item.status == "已撤回"
|
||||
audit = next(call.args[0] for call in session.add.call_args_list if isinstance(call.args[0], OperationAudit))
|
||||
assert audit.action == "risk_item.withdraw"
|
||||
assert audit.audit_metadata == {"previous_status": "待审核"}
|
||||
session.flush.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reviewed_risk_item_cannot_be_withdrawn() -> None:
|
||||
item = RiskItem(id=13, source_type="表单", status="已通过", photos=[], ocr_payload={})
|
||||
operator = UserAccount(id=3, emp_no="admin-1", name="管理员", role="admin", status="启用")
|
||||
session = MagicMock()
|
||||
session.get = AsyncMock(return_value=item)
|
||||
|
||||
with pytest.raises(AppException, match="只有待处理风险项可以撤回"):
|
||||
await RiskItemService(session).withdraw(item.id, operator)
|
||||
|
||||
session.add.assert_not_called()
|
||||
|
||||
@ -18,12 +18,6 @@ def test_preview_requires_authentication() -> None:
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_supplier_profile_requires_authentication() -> None:
|
||||
response = TestClient(app).get("/api/v1/suppliers")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_baseline_engine_calculates_weighted_score() -> None:
|
||||
score = BaselineRiskEngine().predict(
|
||||
[
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
from app.services.supplier_cleaning import infer_location, normalized_key, resolve_canonical_name, split_supplier_cell
|
||||
|
||||
|
||||
def test_split_supplier_cell_preserves_multiple_supplier_mentions() -> None:
|
||||
mentions = split_supplier_cell("内料:博元\n瓶子,盖子:皓盛/佛山执得")
|
||||
assert [item.name for item in mentions] == ["博元", "皓盛", "佛山执得"]
|
||||
assert mentions[0].material_type == "内料"
|
||||
assert mentions[1].material_type == "盖子"
|
||||
|
||||
|
||||
def test_empty_supplier_markers_are_ignored() -> None:
|
||||
assert split_supplier_cell("/") == []
|
||||
assert normalized_key("佛山市执得包装有限公司") == "佛山市执得包装"
|
||||
assert infer_location("佛山市执得包装有限公司") == ("佛山", "广东")
|
||||
|
||||
|
||||
def test_purchase_order_full_name_wins_when_short_name_is_unambiguous() -> None:
|
||||
names = ["佛山市佳亿包装有限公司", "广州祥印包装有限公司"]
|
||||
assert resolve_canonical_name("佳亿", names) == ("佛山市佳亿包装有限公司", True)
|
||||
assert resolve_canonical_name("佛山执得", ["佛山市执得塑料制品有限公司"]) == (
|
||||
"佛山市执得塑料制品有限公司",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_material_and_factory_labels_are_not_supplier_names() -> None:
|
||||
mentions = split_supplier_cell("内料工厂:众绘\n软管工厂:永禾\n彩盒工厂:佳亿")
|
||||
assert [item.name for item in mentions] == ["众绘", "永禾", "佳亿"]
|
||||
assert all(item.role == "加工厂" for item in mentions)
|
||||
@ -1,123 +0,0 @@
|
||||
from datetime import date
|
||||
|
||||
from app.schemas.suppliers import SupplierListData, SupplierOption
|
||||
from app.services.suppliers import SupplierProfileService
|
||||
|
||||
|
||||
def test_supplier_tier_rules_follow_prd() -> None:
|
||||
assert SupplierProfileService.tier_for(0, 0, 0) == "新供应商"
|
||||
assert SupplierProfileService.tier_for(12, 0, 0) == "S级"
|
||||
assert SupplierProfileService.tier_for(40, 0, 4) == "A级"
|
||||
assert SupplierProfileService.tier_for(70, 0, 10) == "B级"
|
||||
assert SupplierProfileService.tier_for(40, 0, 16) == "B级"
|
||||
assert SupplierProfileService.tier_for(10, 1, 0) == "C级"
|
||||
assert SupplierProfileService.tier_for(121, 0, 0) == "C级"
|
||||
|
||||
|
||||
def test_supplier_name_matching_supports_full_name_and_short_name() -> None:
|
||||
assert SupplierProfileService.matches("佳亿", "佛山市佳亿包装有限公司")
|
||||
assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿")
|
||||
|
||||
|
||||
def test_supplier_name_matching_supports_city_suffix_and_compound_supplier_cells() -> None:
|
||||
assert SupplierProfileService.matches("皓盛/佛山执得", "佛山市")
|
||||
assert SupplierProfileService.matches("皓盛/佛山执得", "佛山")
|
||||
assert SupplierProfileService.matches("佛山市佳亿包装有限公司", "佳亿")
|
||||
|
||||
|
||||
def test_supplier_list_falls_back_to_undated_legacy_rows(monkeypatch) -> None:
|
||||
service = SupplierProfileService.__new__(SupplierProfileService)
|
||||
calls = []
|
||||
|
||||
async def fake_master_summary(*, keyword=None, limit=100):
|
||||
return []
|
||||
|
||||
async def fake_summary(*, keyword=None, include_undated=False):
|
||||
calls.append(include_undated)
|
||||
if not include_undated:
|
||||
return {}
|
||||
return {"皓盛/佛山执得": {"total": 1, "a": 0, "b": 0}}
|
||||
|
||||
service._load_supplier_summary = fake_summary
|
||||
service._load_master_summary = fake_master_summary
|
||||
result = __import__("asyncio").run(service.list(keyword="佛山市", limit=8))
|
||||
assert calls == [False, True]
|
||||
assert result.items[0].name == "皓盛/佛山执得"
|
||||
|
||||
|
||||
def test_supplier_alpha_is_capped_and_rounded() -> None:
|
||||
assert SupplierProfileService.alpha(0) == 1.0
|
||||
assert SupplierProfileService.alpha(42) == 1.13
|
||||
assert SupplierProfileService.alpha(148) == 1.44
|
||||
assert SupplierProfileService.alpha(200) == 1.5
|
||||
|
||||
|
||||
def test_supplier_list_exposes_default_highest_anomaly_supplier() -> None:
|
||||
result = SupplierListData(
|
||||
items=[
|
||||
SupplierOption(name="甲供应商", anomaly_count=2, tier="S级"),
|
||||
SupplierOption(name="乙供应商", anomaly_count=1, tier="S级"),
|
||||
],
|
||||
default_supplier="甲供应商",
|
||||
)
|
||||
|
||||
assert result.default_supplier == result.items[0].name
|
||||
|
||||
|
||||
def test_supplier_category_breakdown_is_top_five_with_material_and_problem() -> None:
|
||||
rows = [
|
||||
{"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"},
|
||||
{"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"},
|
||||
{"product_category": "面霜", "product_name": "面霜", "material_type": "瓶子", "anomaly_type": "刮花"},
|
||||
]
|
||||
|
||||
result = SupplierProfileService._categories(rows)
|
||||
|
||||
assert result[0].category == "洗发水/洗护类"
|
||||
assert result[0].top_material == "泵头"
|
||||
assert result[0].top_problem == "漏液"
|
||||
|
||||
|
||||
def test_unclassified_category_is_marked_and_sorted_after_real_categories() -> None:
|
||||
rows = [
|
||||
{"product_category": None, "product_name": "未命名产品", "material_type": "瓶子", "anomaly_type": "刮花"},
|
||||
{"product_category": "洗发水", "product_name": "洗发水", "material_type": "泵头", "anomaly_type": "漏液"},
|
||||
]
|
||||
|
||||
result = SupplierProfileService._categories(rows)
|
||||
|
||||
assert result[0].category == "洗发水/洗护类"
|
||||
assert result[0].is_unclassified is False
|
||||
assert result[1].category == "待补标品类"
|
||||
assert result[1].is_unclassified is True
|
||||
|
||||
|
||||
def test_display_categories_keeps_unclassified_after_five_real_categories() -> None:
|
||||
rows = [
|
||||
{"product_category": category, "product_name": category, "material_type": "瓶子", "anomaly_type": "异常"}
|
||||
for category in ["洗发水", "面霜", "沐浴露", "牙膏", "清洁剂", "脱毛膏"]
|
||||
] + [{"product_category": None, "product_name": "未命名", "material_type": "瓶子", "anomaly_type": "异常"}]
|
||||
|
||||
result = SupplierProfileService._display_categories(SupplierProfileService._categories(rows))
|
||||
|
||||
assert len(result) == 6
|
||||
assert result[-1].is_unclassified is True
|
||||
|
||||
|
||||
def test_supplier_recent_records_are_limited_and_sorted() -> None:
|
||||
service = SupplierProfileService(object(), as_of=date(2026, 9, 7))
|
||||
rows = [
|
||||
{
|
||||
"id": index,
|
||||
"date": date(2026, 4, index),
|
||||
"product_name": "产品",
|
||||
"anomaly_type": str(index),
|
||||
"defect_level": "B",
|
||||
}
|
||||
for index in range(1, 13)
|
||||
]
|
||||
|
||||
result = service._recent_records(rows)
|
||||
|
||||
assert len(result) == 10
|
||||
assert result[0].case_id == 12
|
||||
Loading…
x
Reference in New Issue
Block a user