Compare commits

..

No commits in common. "6cc9b3315951aeb25fd867913f6346abf3bfd5c1" and "ec8b83d0153036898ba592ac0e3365022ca1209f" have entirely different histories.

72 changed files with 302 additions and 7111 deletions

View File

@ -1,76 +1,19 @@
# ==================== 应用基础配置 ====================
# 应用名称、版本和运行环境,用于接口文档、日志和运行行为识别。
RISK_APP_NAME=Dieya Risk Prediction Backend RISK_APP_NAME=Dieya Risk Prediction Backend
RISK_APP_VERSION=0.1.0 RISK_APP_VERSION=0.1.0
RISK_ENVIRONMENT=development RISK_ENVIRONMENT=development
RISK_DEBUG=false RISK_DEBUG=false
# ==================== 数据库配置 ====================
# 风险系统业务数据库连接地址,包含数据库用户、密码、主机、端口和库名。
RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction
# ==================== 日志配置 ====================
# 控制后端日志级别;可选 DEBUG、INFO、WARNING、ERROR。
RISK_LOG_LEVEL=INFO RISK_LOG_LEVEL=INFO
# ==================== JWT 登录鉴权配置 ====================
# JWT 签名密钥、签名算法和登录态有效期(分钟)。生产环境必须更换签名密钥。
RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION
RISK_JWT_ALGORITHM=HS256 RISK_JWT_ALGORITHM=HS256
RISK_JWT_EXPIRE_MINUTES=480 RISK_JWT_EXPIRE_MINUTES=480
# ==================== 钉钉免登配置 ====================
# 用于钉钉免登和组织身份校验;未接入钉钉时可以留空。
RISK_DINGTALK_APP_KEY= RISK_DINGTALK_APP_KEY=
RISK_DINGTALK_APP_SECRET= RISK_DINGTALK_APP_SECRET=
RISK_DINGTALK_CORP_ID= RISK_DINGTALK_CORP_ID=
RISK_DINGTALK_AGENT_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_API_KEY=
RISK_LLM_BASE_URL=https://api.openai.com/v1 RISK_LLM_BASE_URL=https://api.openai.com/v1
RISK_LLM_MODEL=gpt-4o-mini RISK_LLM_MODEL=gpt-4o-mini
RISK_LLM_TIMEOUT_SECONDS=120 RISK_LLM_TIMEOUT_SECONDS=120
RISK_LLM_TEMPERATURE=0.2 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 RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158

View File

@ -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
View File

@ -6,9 +6,8 @@ __pycache__/
.coverage .coverage
htmlcov/ htmlcov/
.env .env
logs/
*.egg-info/ *.egg-info/
dist/ dist/
build/ build/
data/ data/
.runtime-uploads/

View File

@ -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`。 审核接口:`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` 健康检查:`GET http://127.0.0.1:18474/api/v1/health`
数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。
@ -43,15 +37,6 @@ python scripts/import_excel_data.py `
脚本只导入异常明细 Sheet 的 2,448 条记录,以及标准文件中的“包装/包材/法规/内料”四个分类 Sheet 共 219 条记录;“总表”不重复导入。脚本可重复执行,已导入行会按源文件、Sheet 和 Excel 行号自动跳过。原始异常文本和标准附件链接会完整保留,不做截断。 脚本只导入异常明细 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。 风险评估预览:`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,不生成模拟回复。 对话页真实 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,不生成模拟回复。

View File

@ -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.conversations import router as conversations_router
from app.api.v1.dashboard import router as dashboard_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.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_items import router as risk_items_router
from app.api.v1.risk_predictions import router as risk_predictions_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.standards import router as standards_router
from app.api.v1.suppliers import router as suppliers_router
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health_router) 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(auth_router)
api_router.include_router(conversations_router) api_router.include_router(conversations_router)
api_router.include_router(dashboard_router) api_router.include_router(dashboard_router)
api_router.include_router(risk_items_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(risk_predictions_router)
api_router.include_router(standards_router) api_router.include_router(standards_router)
api_router.include_router(suppliers_router)

View File

@ -14,7 +14,6 @@ from app.schemas.conversation import (
ConversationData, ConversationData,
ConversationMessageCreateRequest, ConversationMessageCreateRequest,
ConversationSummary, ConversationSummary,
DirectQueryRequest,
) )
from app.services.conversations import ConversationService 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)) 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]) @router.get("/{conversation_id}", response_model=ApiResponse[ConversationData])
async def get_conversation( async def get_conversation(
conversation_id: int, 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]) @router.post("/{conversation_id}/messages", response_model=ApiResponse[ConversationData])
async def send_conversation_message( async def send_conversation_message(
conversation_id: int, 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") @router.post("/{conversation_id}/stream")
async def stream_conversation_message( async def stream_conversation_message(
conversation_id: int, conversation_id: int,

View File

@ -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))

View File

@ -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")})

View File

@ -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)

View File

@ -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"'},
)

View File

@ -49,16 +49,6 @@ async def update_risk_item(
return ApiResponse(data=await RiskItemService(session).update(item_id, payload, current_user)) 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]) @router.post("/{item_id}/review", response_model=ApiResponse[ReviewRecordData])
async def review_risk_item( async def review_risk_item(
item_id: int, item_id: int,

View File

@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends 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.models.entities import UserAccount
from app.schemas.common import ApiResponse from app.schemas.common import ApiResponse
from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult
@ -23,7 +23,6 @@ async def model_info(
@router.post("/preview", response_model=ApiResponse[RiskAssessmentResult]) @router.post("/preview", response_model=ApiResponse[RiskAssessmentResult])
async def preview_risk( async def preview_risk(
request: RiskAssessmentRequest, request: RiskAssessmentRequest,
session: DatabaseSession,
_: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], _: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
) -> ApiResponse[RiskAssessmentResult]: ) -> ApiResponse[RiskAssessmentResult]:
return ApiResponse(data=await RiskPredictionService.preview(request, session)) return ApiResponse(data=RiskPredictionService.preview(request))

View File

@ -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))

View File

@ -1,8 +1,7 @@
"""Environment-backed application settings.""" """Environment-backed application settings."""
from functools import lru_cache from functools import lru_cache
from pathlib import Path from typing import Annotated
from typing import Annotated, Literal
from pydantic import AliasChoices, Field, SecretStr, field_validator from pydantic import AliasChoices, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict 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_app_secret: SecretStr | None = Field(default=None, repr=False)
dingtalk_corp_id: str = "" dingtalk_corp_id: str = ""
dingtalk_agent_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( llm_api_key: SecretStr | None = Field(
default=None, default=None,
repr=False, repr=False,
@ -73,76 +53,6 @@ class Settings(BaseSettings):
le=2, le=2,
validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"), 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] = ( cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = (
"http://localhost:39158", "http://localhost:39158",
"http://127.0.0.1:39158", "http://127.0.0.1:39158",
@ -167,16 +77,6 @@ class Settings(BaseSettings):
def normalize_llm_base_url(cls, value: str) -> str: def normalize_llm_base_url(cls, value: str) -> str:
return value.rstrip("/") 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") @field_validator("cors_allow_origins", mode="before")
@classmethod @classmethod
def split_cors_origins(cls, value: object) -> object: def split_cors_origins(cls, value: object) -> object:

View File

@ -1,60 +1,20 @@
"""Shared logging setup.""" """Shared logging setup."""
import logging import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOGGER_NAME = "dieya_risk_prediction" 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: def configure_logging(level: str = "INFO") -> logging.Logger:
logger = logging.getLogger(LOGGER_NAME) logger = logging.getLogger(LOGGER_NAME)
logger.setLevel(level.upper()) logger.setLevel(level.upper())
logger.propagate = False logger.propagate = False
LOG_DIR.mkdir(parents=True, exist_ok=True) if not logger.handlers:
formatter = logging.Formatter(_FORMAT) handler = logging.StreamHandler()
file_handler = next( handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
( logger.addHandler(handler)
handler
for handler in logger.handlers
if getattr(handler, "_dieya_file_handler", False)
),
None,
)
if file_handler is None:
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler._dieya_file_handler = True
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
root_logger = logging.getLogger()
root_logger.setLevel(level.upper())
if not any(getattr(handler, "_dieya_file_handler", False) for handler in root_logger.handlers):
root_file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
root_file_handler._dieya_file_handler = True
root_file_handler.setFormatter(formatter)
root_logger.addHandler(root_file_handler)
if not any(
isinstance(handler, logging.StreamHandler)
and not getattr(handler, "_dieya_file_handler", False)
for handler in logger.handlers
):
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
return logger return logger
logger = configure_logging() logger = configure_logging()

View File

@ -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

View File

@ -2,7 +2,6 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from app.api.router import api_router from app.api.router import api_router
from app.core.config import Settings, get_settings 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_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"], allow_headers=["Authorization", "Content-Type", "Accept"],
) )
application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
application.add_exception_handler(AppException, app_exception_handler) application.add_exception_handler(AppException, app_exception_handler)
application.include_router(api_router) application.include_router(api_router)
return application return application

View File

@ -2,36 +2,28 @@
from app.models.entities import ( from app.models.entities import (
AnomalyCase, AnomalyCase,
AnomalyCaseSupplier,
Conversation, Conversation,
ConversationMessage, ConversationMessage,
OperationAudit, OperationAudit,
PurchaseOrderLine,
ReviewRecord, ReviewRecord,
RiskAssessment, RiskAssessment,
RiskItem, RiskItem,
RiskModelConfig,
RolePermission, RolePermission,
StandardRule, StandardRule,
SupplierProfile, SupplierProfile,
SupplierMaster,
UserAccount, UserAccount,
) )
__all__ = [ __all__ = [
"AnomalyCase", "AnomalyCase",
"AnomalyCaseSupplier",
"Conversation", "Conversation",
"ConversationMessage", "ConversationMessage",
"OperationAudit", "OperationAudit",
"ReviewRecord", "ReviewRecord",
"RiskAssessment", "RiskAssessment",
"RiskItem", "RiskItem",
"RiskModelConfig",
"PurchaseOrderLine",
"RolePermission", "RolePermission",
"StandardRule", "StandardRule",
"SupplierProfile", "SupplierProfile",
"SupplierMaster",
"UserAccount", "UserAccount",
] ]

View File

@ -4,7 +4,7 @@ from datetime import date, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any 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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship 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") standard_rule: Mapped["StandardRule | None"] = relationship(back_populates="anomaly_cases")
review_records: Mapped[list["ReviewRecord"]] = relationship(back_populates="case") 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): 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): class RiskAssessment(Base):
"""A user-triggered risk assessment and its structured result.""" """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.""" """A persistent assistant conversation owned by one authenticated user."""
__tablename__ = "conversations" __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) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column( user_id: Mapped[int] = mapped_column(
@ -235,7 +149,9 @@ class ConversationMessage(Base):
"""One user or assistant message, including optional structured results.""" """One user or assistant message, including optional structured results."""
__tablename__ = "conversation_messages" __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) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
conversation_id: Mapped[int] = mapped_column( conversation_id: Mapped[int] = mapped_column(
@ -260,17 +176,13 @@ class RiskItem(Base):
Index("ix_risk_items_status", "status"), Index("ix_risk_items_status", "status"),
Index("ix_risk_items_submitted_by", "submitted_by"), Index("ix_risk_items_submitted_by", "submitted_by"),
Index("ix_risk_items_submitted_at", "submitted_at"), 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) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
source_type: Mapped[str] = mapped_column(String(20), nullable=False) source_type: Mapped[str] = mapped_column(String(20), nullable=False)
source_file_id: Mapped[str | None] = mapped_column(String(50)) 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_name: Mapped[str | None] = mapped_column(String(200))
product_category: Mapped[str | None] = mapped_column(String(50)) 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)) supplier: Mapped[str | None] = mapped_column(String(100))
factory: Mapped[str | None] = mapped_column(String(100)) factory: Mapped[str | None] = mapped_column(String(100))
stage_new: Mapped[str | None] = mapped_column(String(50)) 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)) defect_level: Mapped[str | None] = mapped_column(String(5))
solution: Mapped[str | None] = mapped_column(Text) solution: Mapped[str | None] = mapped_column(Text)
photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) 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_by: Mapped[str | None] = mapped_column(String(50))
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False) 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): class OperationAudit(Base):
"""Append-only audit record for authentication, permission, and review actions.""" """Append-only audit record for authentication, permission, and review actions."""

View File

@ -44,6 +44,7 @@ class ConversationRepository:
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == user_id, Conversation.user_id == user_id,
) )
.options(selectinload(Conversation.messages))
) )
if conversation is None: if conversation is None:
return None return None
@ -71,13 +72,3 @@ class ConversationRepository:
.options(selectinload(Conversation.messages)) .options(selectinload(Conversation.messages))
) )
return await self.session.scalar(statement) 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

View File

@ -19,11 +19,6 @@ class ConversationMessageCreateRequest(ApiSchema):
message_type: Literal["text", "image", "file", "audio"] = "text" 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): class ConversationListQuery(ApiSchema):
limit: int = Field(default=50, ge=1, le=100) limit: int = Field(default=50, ge=1, le=100)

View File

@ -32,9 +32,9 @@ class DashboardMatrixRow(ApiSchema):
name: str name: str
party_type: str party_type: str
categories: dict[str, int] categories: dict[str, int]
cooperation_total: int | None cooperation_total: int
anomaly_count: int anomaly_count: int
anomaly_rate: float | None anomaly_rate: float
tier: str tier: str

View File

@ -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

View File

@ -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)

View File

@ -1,7 +1,6 @@
"""Risk item submission and review contracts.""" """Risk item submission and review contracts."""
from datetime import date, datetime from datetime import datetime
from typing import Any
from pydantic import Field from pydantic import Field
@ -11,10 +10,8 @@ from app.schemas.common import ApiSchema
class RiskItemCreate(ApiSchema): class RiskItemCreate(ApiSchema):
source_type: str = Field(default="表单", min_length=1, max_length=20) source_type: str = Field(default="表单", min_length=1, max_length=20)
source_file_id: str | None = Field(default=None, max_length=50) 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_name: str = Field(min_length=1, max_length=200)
product_category: str | None = Field(default=None, max_length=100) 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) supplier: str | None = Field(default=None, max_length=300)
factory: 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) 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) defect_level: str | None = Field(default=None, max_length=10)
solution: str | None = None solution: str | None = None
photos: list[str] = Field(default_factory=list, max_length=10) 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): class RiskItemUpdate(ApiSchema):
product_name: str | None = Field(default=None, min_length=1, max_length=200) product_name: str | None = Field(default=None, min_length=1, max_length=200)
product_category: str | None = Field(default=None, max_length=100) 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) supplier: str | None = Field(default=None, max_length=300)
factory: 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) 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) defect_level: str | None = Field(default=None, max_length=10)
solution: str | None = None solution: str | None = None
photos: list[str] | None = Field(default=None, max_length=10) 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): class RiskItemData(ApiSchema):
id: int id: int
source_type: str source_type: str
source_file_id: str | None source_file_id: str | None
occurred_on: date | None
product_name: str | None product_name: str | None
product_category: str | None product_category: str | None
material_type: str | None
supplier: str | None supplier: str | None
factory: str | None factory: str | None
stage_new: str | None stage_new: str | None
@ -57,8 +46,6 @@ class RiskItemData(ApiSchema):
defect_level: str | None defect_level: str | None
solution: str | None solution: str | None
photos: list[str] photos: list[str]
recognition_confidence: float | None
ocr_payload: dict[str, Any]
submitted_by: str | None submitted_by: str | None
submitted_at: datetime | None submitted_at: datetime | None
status: str status: str

View File

@ -1,7 +1,5 @@
"""Risk prediction request and response contracts.""" """Risk prediction request and response contracts."""
from typing import Literal
from pydantic import Field from pydantic import Field
from app.domain.risk_engine import RiskLevel from app.domain.risk_engine import RiskLevel
@ -16,13 +14,7 @@ class RiskIndicator(ApiSchema):
class RiskAssessmentRequest(ApiSchema): class RiskAssessmentRequest(ApiSchema):
subject_id: str = Field(min_length=1, max_length=128) subject_id: str = Field(min_length=1, max_length=128)
indicators: list[RiskIndicator] = Field(default_factory=list, max_length=50) indicators: list[RiskIndicator] = Field(min_length=1, max_length=50)
product_category: str | None = Field(default=None, max_length=100)
material_type: str | None = Field(default=None, max_length=200)
supplier: str | None = Field(default=None, max_length=300)
factory: str | None = Field(default=None, max_length=300)
stage_new: str | None = Field(default=None, max_length=100)
defect_level: str | None = Field(default=None, max_length=10)
class RiskFactorResult(ApiSchema): class RiskFactorResult(ApiSchema):
@ -45,12 +37,9 @@ class SupplierFrequency(ApiSchema):
supplier_name: str | None = None supplier_name: str | None = None
cooperation_count: int | None = None cooperation_count: int | None = None
anomaly_count: int | None = None anomaly_count: int | None = None
alpha: float | None = None alpha: float = 1.0
tier: str | None = None tier: str | None = None
suggestion: str | None = None suggestion: str | None = None
frequency_basis: str = "暂无匹配采购订单"
note: str | None = None
data_available: bool = False
class StandardJudgment(ApiSchema): class StandardJudgment(ApiSchema):
@ -65,82 +54,13 @@ class RiskDimensionScore(ApiSchema):
dimension: str dimension: str
score: float score: float
weight: float weight: float
status: str = "database"
reason: str | None = None
sample_size: int | None = None
raw_value: float | int | None = None
class SimilarCase(ApiSchema): class SimilarCase(ApiSchema):
case_id: int | None = None
date: str | None = None date: str | None = None
product: 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 problem: str | None = None
solution: 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): class RiskAssessmentResult(ApiSchema):
@ -157,8 +77,6 @@ class RiskAssessmentResult(ApiSchema):
dimension_scores: list[RiskDimensionScore] = Field(default_factory=list) dimension_scores: list[RiskDimensionScore] = Field(default_factory=list)
similar_cases: list[SimilarCase] = Field(default_factory=list) similar_cases: list[SimilarCase] = Field(default_factory=list)
ai_suggestions: list[str] = 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): class ModelInfo(ApiSchema):

View File

@ -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

View File

@ -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

View File

@ -286,8 +286,8 @@ class DashboardService:
result = [] result = []
for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]): for (name, party_type), item in sorted(actors.items(), key=lambda entry: -entry[1]["count"]):
anomaly_count = item["count"] anomaly_count = item["count"]
# No order or production denominator exists in anomaly_cases. # Keep the existing cooperation-total calculation unchanged.
cooperation_total = None cooperation_total = item["count"]
values = {category: 0 for category in categories} values = {category: 0 for category in categories}
for row in item["items"]: for row in item["items"]:
category = cls._category(row["product_name"], row["material_type"]) category = cls._category(row["product_name"], row["material_type"])
@ -300,7 +300,11 @@ class DashboardService:
categories=values, categories=values,
cooperation_total=cooperation_total, cooperation_total=cooperation_total,
anomaly_count=anomaly_count, anomaly_count=anomaly_count,
anomaly_rate=None, anomaly_rate=(
round(anomaly_count / cooperation_total * 100, 1)
if cooperation_total
else 0
),
tier=item["tier"], tier=item["tier"],
) )
) )

View File

@ -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

View File

@ -1,7 +1,6 @@
"""Server-side DingTalk auth-code exchange and local account upsert.""" """Server-side DingTalk auth-code exchange and local account upsert."""
import json import json
import logging
import time import time
from datetime import UTC, datetime from datetime import UTC, datetime
@ -18,8 +17,6 @@ from app.services.auth import AuthService
TOKEN_URL = "https://oapi.dingtalk.com/gettoken" TOKEN_URL = "https://oapi.dingtalk.com/gettoken"
USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo" USERINFO_URL = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo"
USERDETAIL_URL = "https://oapi.dingtalk.com/topapi/v2/user/get" 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): class DingTalkServiceError(AppException):
@ -104,12 +101,7 @@ class DingTalkAuthService:
data = response.json() data = response.json()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) from 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 = (data.get("result") or {}).get("userid")
user_id = (
result.get("userid") or result.get("userId") or result.get("unionid")
if isinstance(result, dict)
else None
)
if data.get("errcode") or not user_id: if data.get("errcode") or not user_id:
raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401) raise AppException("钉钉免登码已失效,请重新进入工作台", code=401, status_code=401)
return str(user_id) return str(user_id)
@ -124,104 +116,49 @@ class DingTalkAuthService:
) )
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
except httpx.HTTPError as exc: except httpx.HTTPError:
logger.warning("钉钉用户详情请求失败 user_id=%s error=%s", user_id, exc.__class__.__name__)
return {"userid": user_id} return {"userid": user_id}
if data.get("errcode") not in (None, 0, "0"): return data.get("result") or {"userid": user_id}
logger.warning(
"钉钉用户详情接口返回错误 user_id=%s errcode=%s errmsg=%s",
user_id,
data.get("errcode"),
data.get("errmsg") or data.get("message"),
)
detail = data.get("result") or data.get("user") or {"userid": user_id}
if not isinstance(detail, dict):
detail = {"userid": user_id}
detail.setdefault("userid", user_id)
# Some DingTalk responses wrap the actual user object one level deeper.
nested = detail.get("user") or detail.get("data")
if isinstance(nested, dict):
detail = {**detail, **nested}
dept_ids = self._department_ids(detail)
if dept_ids and not self._department_name(detail):
dept_name = await self._department_name_from_id(access_token, dept_ids[0])
if dept_name:
detail["dept_name"] = dept_name
return detail
async def _department_name_from_id(self, access_token: str, department_id: int) -> str | None:
try:
async with httpx.AsyncClient(timeout=8) as client:
response = await client.post(
DEPARTMENT_DETAIL_URL,
params={"access_token": access_token},
json={"dept_id": department_id, "language": "zh_CN"},
)
response.raise_for_status()
data = response.json()
except (httpx.HTTPError, ValueError):
return None
result = data.get("result") or data.get("department") or {}
if not isinstance(result, dict):
return None
name = result.get("name") or result.get("dept_name") or result.get("deptName")
return str(name).strip()[:50] if name else None
@staticmethod
def _department_name(detail: dict) -> str | None:
value = detail.get("dept_name") or detail.get("deptName") or detail.get("department_name") or detail.get("departmentName")
return str(value).strip() if value else None
@staticmethod
def _department_ids(detail: dict) -> list[int]:
raw = (detail.get("dept_id_list") or detail.get("deptIdList") or detail.get("dept_id_list_str") or detail.get("department_ids") or detail.get("dept_id") or detail.get("main_dept_id") or detail.get("mainDeptId") or [])
if not isinstance(raw, list):
raw = [raw]
result: list[int] = []
for value in raw:
try:
result.append(int(value))
except (TypeError, ValueError):
continue
return result
@staticmethod
def _display_name(detail: dict, fallback: str) -> str:
value = detail.get("name") or detail.get("nick") or detail.get("nickname") or detail.get("real_name") or detail.get("display_name") or detail.get("user_name") or detail.get("userName") or fallback
return str(value).strip()[:50] or fallback
async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount: async def _upsert_user(self, user_id: str, detail: dict) -> UserAccount:
user = await self.repo.get_by_dingtalk_id(user_id) 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("dept_name") or detail.get("deptName") or ""),
str(detail.get("title") or detail.get("position") 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: if user is None:
user = UserAccount( user = UserAccount(
emp_no=f"dt_{user_id}"[:20], emp_no=f"dt_{user_id}"[:20],
name=self._display_name(detail, user_id), name=str(detail.get("name") or user_id)[:50],
role=mapped_role, role=role,
status="启用", status="启用",
dingtalk_id=user_id, dingtalk_id=user_id,
) )
self.session.add(user) self.session.add(user)
user.name = self._display_name(detail, user.name or user_id) user.name = str(detail.get("name") or user.name)[:50]
user.dept = (self._department_name(detail) or user.dept or "")[:50] or None user.dept = str(detail.get("dept_name") 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.position = str(detail.get("title") or user.position or "")[:50] or None
user.role = self.resolve_synced_role(user.role, mapped_role) user.role = role
user.dingtalk_union_id = str(detail.get("unionid") or user.dingtalk_union_id or "")[:64] or None 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_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_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 user.dingtalk_title = str(detail.get("title") or user.dingtalk_title or "")[:128] or None
dept_ids = self._department_ids(detail) dept_id = detail.get("main_dept_id") or detail.get("dept_id") or user.dingtalk_dept_id
dept_id = dept_ids[0] if dept_ids else user.dingtalk_dept_id if isinstance(dept_id, list):
dept_id = dept_id[0] if dept_id else None
try: try:
user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None user.dingtalk_dept_id = int(dept_id) if dept_id is not None else None
except (TypeError, ValueError): except (TypeError, ValueError):
user.dingtalk_dept_id = None user.dingtalk_dept_id = None
user.dingtalk_is_dept_manager = self._is_dingtalk_manager(detail) user.dingtalk_is_dept_manager = bool(
role_codes = detail.get("role_list") or detail.get("roleList") or user.dingtalk_role_codes 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)): if isinstance(role_codes, (list, dict)):
role_codes = json.dumps(role_codes, ensure_ascii=False) role_codes = json.dumps(role_codes, ensure_ascii=False)
user.dingtalk_role_codes = str(role_codes or "")[:512] or None user.dingtalk_role_codes = str(role_codes or "")[:512] or None
@ -233,52 +170,21 @@ class DingTalkAuthService:
@staticmethod @staticmethod
def map_role(dept: str, title: str, is_admin: bool = False) -> str: 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" text = f"{dept} {title}".lower()
if is_admin or "it" in text or "信息" in text or "系统" in text:
@staticmethod
def resolve_synced_role(current_role: str, mapped_role: str) -> str:
"""Apply DingTalk role mapping without downgrading assigned roles.
Existing roles are treated as administrator-managed data. DingTalk may
promote a regular user when it reports a manager flag, but a missing
flag must never downgrade an existing admin (or another assigned role).
"""
if current_role == "user" and mapped_role == "admin":
return "admin" return "admin"
return current_role if "审核" in text or "review" in text:
return "reviewer"
@staticmethod if "采购" in text or "procurement" in text:
def _is_dingtalk_manager(detail: dict) -> bool: return "procurement"
"""Read administrator/department-leader flags from DingTalk API variants.""" if "品控" in text or "质量" in text or "qc" in text:
return "qc"
direct = ( if "管理" in text or "主管" in text or "manager" in text:
detail.get("admin"), return "manager"
detail.get("is_admin"), if "运营" in text or "ops" in text:
detail.get("isAdmin"), return "ops"
detail.get("is_leader"), if "开发" in text or "产品" in text or "dev" in text:
detail.get("isLeader"), return "dev"
detail.get("is_boss"), return "普通用户"
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

View File

@ -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 json
import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any, Literal from typing import Any
import httpx import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.core.exceptions import AppException from app.core.exceptions import AppException
logger = logging.getLogger("dieya_risk_prediction.llm_client")
ProviderName = Literal["primary", "fallback"]
class LLMClient: 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: def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
settings = get_settings() settings = get_settings()
@ -24,56 +20,22 @@ class LLMClient:
self.model = settings.llm_model self.model = settings.llm_model
self.timeout = settings.llm_timeout_seconds self.timeout = settings.llm_timeout_seconds
self.temperature = settings.llm_temperature 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 self.transport = transport
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.api_key or self.fallback_api_key) return bool(self.api_key)
@property
def fallback_configured(self) -> bool:
return bool(self.fallback_api_key and self.fallback_base_url and self.fallback_model)
@property @property
def endpoint(self) -> str: def endpoint(self) -> str:
return self._chat_endpoint(self.base_url)
@staticmethod
def _chat_endpoint(base_url: str) -> str:
suffix = "/chat/completions" 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: def _headers(self) -> dict[str, str]:
if provider == "primary": if not self.configured:
return self.endpoint
if self.fallback_api_mode == "responses":
return f"{self.fallback_base_url}/responses"
return self._chat_endpoint(self.fallback_base_url)
def _provider_config(self, provider: ProviderName) -> tuple[str, str, int]:
if provider == "primary":
return self.api_key, self.model, self.timeout
return self.fallback_api_key, self.fallback_model, self.fallback_timeout
def _headers(self, provider: ProviderName) -> dict[str, str]:
api_key, _, _ = self._provider_config(provider)
if not api_key:
if provider == "primary" and self.fallback_configured:
raise AppException("主 LLM 未配置 API Key,将尝试使用兜底模型", code=503, status_code=503)
raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503) raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503)
return { return {
"Authorization": f"Bearer {api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "text/event-stream", "Accept": "text/event-stream",
} }
@ -83,14 +45,14 @@ class LLMClient:
text: str, text: str,
history: list[dict[str, str]] | None = None, history: list[dict[str, str]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Extract structured fields, retrying with DeepSeek when the primary fails.""" """Extract structured product and intent fields before generating an answer."""
messages = [ messages = [
{ {
"role": "system", "role": "system",
"content": ( "content": (
"你是蝶雅产品风险预警系统的语义理解模块。" "你是蝶雅产品风险预估系统的语义理解模块。"
"只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。" "只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。"
"intent 只能是 greet、general、assessment、supplier、material、cases、" "intent 只能是 greet、general、assessment、supplier、material、cases、"
"standard、compliance、solution 之一。" "standard、compliance、solution 之一。"
"字段必须包含 intent, risk_requested, category, specification, packaging, " "字段必须包含 intent, risk_requested, category, specification, packaging, "
@ -105,169 +67,39 @@ class LLMClient:
] ]
messages.extend(history or []) messages.extend(history or [])
messages.append({"role": "user", "content": text}) messages.append({"role": "user", "content": text})
body = await self._request(messages, stream=False, json_mode=True)
try: try:
body = await self._request(messages, provider="primary", stream=False, json_mode=True) content = body["choices"][0]["message"]["content"]
content = self._response_text(body, provider="primary") except (KeyError, IndexError, TypeError) as exc:
return self._parse_json(content) raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc
except Exception as primary_exc: # noqa: BLE001 - fallback must cover provider and parse failures if isinstance(content, list):
logger.warning( content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
"LLM semantic request failed provider=primary model=%s error=%s", return self._parse_json(str(content))
self.model,
primary_exc.__class__.__name__,
)
if not self.fallback_configured:
raise
try: async def stream_reply(
body = await self._request(messages, provider="fallback", stream=False, json_mode=True)
content = self._response_text(body, provider="fallback")
logger.info("LLM semantic request succeeded provider=fallback model=%s", self.fallback_model)
return self._parse_json(content)
except Exception as fallback_exc: # noqa: BLE001 - normalize both provider failures
logger.exception(
"LLM semantic fallback failed provider=fallback model=%s",
self.fallback_model,
)
raise AppException(
"主模型与兜底模型均调用失败,请检查网络、模型配置或额度",
code=503,
status_code=503,
) from fallback_exc
async def extract_ocr_fields(self, text: str) -> dict[str, Any]:
"""Turn OCR text into an editable risk-item draft without inventing absent values."""
messages = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预警系统的OCR字段抽取模块,只输出合法JSON对象。"
"只能提取输入文字明确出现的信息,不得推测或补造。输出字段:product_name、product_category、"
"supplier、factory、material_type、stage_new、description、defect_level、occurred_on、confidence、"
"measurements。缺失标量用null。defect_level只能是A、B、C或null;occurred_on使用YYYY-MM-DD或null。"
"measurements为数组,每项包含item、raw_value、value、unit、confidence;value必须是数字或null。"
"measurements只允许质量检测指标,例如pH、色差、重金属含量、力矩、喷出量、附着力、"
"不合格率;数量、抽检数量、产品规格、容量、日期、单号和批次不得作为检测指标。"
"confidence为0到1。description应保留异常现象和影响比例,但不得加入原文没有的结论。"
),
},
{"role": "user", "content": text[:20_000]},
]
last_error: Exception | None = None
providers: list[ProviderName] = ["primary"]
if self.fallback_configured:
providers.append("fallback")
for provider in providers:
try:
body = await self._request(messages, provider=provider, stream=False, json_mode=True)
return self._parse_json(self._response_text(body, provider=provider))
except Exception as exc: # noqa: BLE001 - deterministic extraction handles provider failures
last_error = exc
logger.warning("LLM OCR extraction failed provider=%s error=%s", provider, exc.__class__.__name__)
if last_error:
raise last_error
return {}
async def stream_reply(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]:
"""Yield reply deltas and fail over only before any primary text is emitted."""
emitted = False
try:
async for delta in self._stream_provider(messages, provider="primary"):
emitted = True
yield delta
return
except Exception as primary_exc: # noqa: BLE001 - fallback is the public behavior
logger.warning(
"LLM stream failed provider=primary model=%s error=%s emitted=%s",
self.model,
primary_exc.__class__.__name__,
emitted,
)
if emitted or not self.fallback_configured:
raise
logger.info("LLM stream switching provider=primary to provider=fallback model=%s", self.fallback_model)
async for delta in self._stream_provider(messages, provider="fallback"):
yield delta
async def generate_prevention_suggestions(self, context: dict[str, Any]) -> list[str]:
"""Generate executable prevention actions from the current risk context."""
messages = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预警系统的预防方案生成模块。"
"请根据提供的风险评估数据生成可执行的质量预防建议。"
"只输出合法 json object,不要输出 Markdown,格式必须是 "
'{"suggestions":["建议1","建议2"]}。'
"输出 4 到 6 条建议,每条不超过 80 个汉字。"
"建议必须优先针对分数较低的风险维度、历史异常频发项和命中的标准规则,"
"明确检测或管控动作;可以包含责任角色、阶段和验收闭环。"
"不得虚构数据库没有提供的案例、检测结果、法规条款或 RAG 检索结果。"
"数据不足时明确写出需要补充的验证动作,不要把猜测写成事实。"
"不要重复表达,不要输出标题、解释、风险评分或额外字段。"
),
},
{
"role": "user",
"content": "以下是本次风险评估数据,请生成预防建议:\n"
+ json.dumps(context, ensure_ascii=False, separators=(",", ":")),
},
]
try:
body = await self._request(messages, provider="primary", stream=False, json_mode=True)
raw = self._response_text(body, provider="primary")
return self._parse_suggestions(raw)
except Exception as primary_exc: # noqa: BLE001 - prevention suggestions have the same failover policy
logger.warning(
"LLM prevention suggestion request failed provider=primary model=%s error=%s",
self.model,
primary_exc.__class__.__name__,
)
if not self.fallback_configured:
raise
try:
body = await self._request(messages, provider="fallback", stream=False, json_mode=True)
raw = self._response_text(body, provider="fallback")
suggestions = self._parse_suggestions(raw)
logger.info(
"LLM prevention suggestions generated provider=fallback model=%s count=%s",
self.fallback_model,
len(suggestions),
)
return suggestions
except Exception as fallback_exc: # noqa: BLE001 - caller owns deterministic fallback behavior
logger.exception(
"LLM prevention suggestion fallback failed provider=fallback model=%s",
self.fallback_model,
)
raise AppException(
"主模型与兜底模型均无法生成预防建议",
code=503,
status_code=503,
) from fallback_exc
async def _stream_provider(
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
*,
provider: ProviderName,
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
headers = self._headers(provider) """Yield text deltas from an OpenAI-compatible streaming response."""
_, model, timeout_seconds = self._provider_config(provider)
is_responses = provider == "fallback" and self.fallback_api_mode == "responses" headers = self._headers()
payload = self._payload(messages, provider=provider, stream=True, json_mode=False) payload = {
timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds)) "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: async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
try: async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response:
async with client.stream("POST", self._endpoint(provider), headers=headers, json=payload) as response:
if response.status_code >= 400: if response.status_code >= 400:
detail = await response.aread() 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(): async for line in response.aiter_lines():
if not line.startswith("data:"): if not line.startswith("data:"):
continue continue
@ -278,138 +110,44 @@ class LLMClient:
chunk = json.loads(raw) chunk = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
continue 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: if delta:
yield delta yield str(delta)
except AppException:
raise
except httpx.HTTPError as exc:
raise AppException(
"LLM 流式连接失败",
code=503,
status_code=503,
) from exc
logger.info("LLM stream completed provider=%s model=%s", provider, model)
async def _request( async def _request(
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
*, *,
provider: ProviderName,
stream: bool, stream: bool,
json_mode: bool = False, json_mode: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
headers = self._headers(provider) headers = self._headers()
payload = self._payload(messages, provider=provider, stream=stream, json_mode=json_mode)
_, model, timeout_seconds = self._provider_config(provider)
timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds))
logger.info(
"LLM request start provider=%s model=%s endpoint=%s stream=%s",
provider,
model,
self._endpoint(provider),
stream,
)
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
try:
response = await client.post(self._endpoint(provider), headers=headers, json=payload)
if response.status_code >= 400:
detail = await response.aread()
raise self._http_failure(response.status_code, detail)
except AppException:
raise
except httpx.HTTPError as exc:
raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc
try:
data = response.json()
except ValueError as exc:
raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc
if not isinstance(data, dict):
raise AppException("LLM 返回格式无效", code=503, status_code=503)
logger.info("LLM request success provider=%s model=%s status=%s", provider, model, response.status_code)
return data
def _payload(
self,
messages: list[dict[str, str]],
*,
provider: ProviderName,
stream: bool,
json_mode: bool,
) -> dict[str, Any]:
if provider == "fallback" and self.fallback_api_mode == "responses":
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": self.fallback_model, "model": self.model,
"input": messages,
"stream": stream,
"max_output_tokens": self.fallback_max_output_tokens,
}
if self.fallback_reasoning_effort:
payload["reasoning_effort"] = self.fallback_reasoning_effort
return payload
_, model, _ = self._provider_config(provider)
payload = {
"model": model,
"messages": messages, "messages": messages,
"temperature": self.temperature, "temperature": self.temperature,
"stream": stream, "stream": stream,
} }
if json_mode: if json_mode:
payload["response_format"] = {"type": "json_object"} payload["response_format"] = {"type": "json_object"}
return payload timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout))
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
@staticmethod
def _http_failure(status_code: int, detail: bytes) -> AppException:
return AppException(
f"LLM 请求失败(HTTP {status_code})",
code=503,
status_code=503,
details=detail.decode("utf-8", errors="replace")[:500],
)
@staticmethod
def _response_text(body: dict[str, Any], *, provider: ProviderName) -> str:
if provider == "fallback":
output_text = body.get("output_text")
if isinstance(output_text, str) and output_text:
return output_text
parts: list[str] = []
for item in body.get("output") or []:
if not isinstance(item, dict):
continue
content = item.get("content")
if isinstance(content, str):
parts.append(content)
for part in content or []:
if isinstance(part, dict) and part.get("type") in {"output_text", "text"}:
parts.append(str(part.get("text") or ""))
if parts:
return "".join(parts)
try: try:
content = body["choices"][0]["message"]["content"] response = await client.post(self.endpoint, headers=headers, json=payload)
except (KeyError, IndexError, TypeError) as exc: response.raise_for_status()
raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc except httpx.HTTPError as exc:
if isinstance(content, list): raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc
content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) try:
return str(content) return response.json()
except ValueError as exc:
@staticmethod raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc
def _stream_delta(chunk: dict[str, Any], *, responses_api: bool) -> str:
if responses_api:
event_type = chunk.get("type") or chunk.get("event")
if event_type in {"response.output_text.delta", "output_text.delta"}:
return str(chunk.get("delta") or "")
return ""
choices = chunk.get("choices") or []
if not choices:
return ""
delta = (choices[0].get("delta") or {}).get("content")
if isinstance(delta, list):
delta = "".join(str(part.get("text", "")) for part in delta if isinstance(part, dict))
return str(delta or "")
@staticmethod @staticmethod
def _parse_json(content: str) -> dict[str, Any]: def _parse_json(content: str) -> dict[str, Any]:
@ -423,28 +161,3 @@ class LLMClient:
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise AppException("LLM 语义结果格式无效", code=503, status_code=503) raise AppException("LLM 语义结果格式无效", code=503, status_code=503)
return parsed 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]

View File

@ -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,
)

View File

@ -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))]

View File

@ -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)

View File

@ -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 "请查看原始案例记录"),
)

View File

@ -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()

View File

@ -19,10 +19,8 @@ class RiskItemService:
item = RiskItem( item = RiskItem(
source_type=payload.source_type, source_type=payload.source_type,
source_file_id=payload.source_file_id, source_file_id=payload.source_file_id,
occurred_on=payload.occurred_on,
product_name=payload.product_name, product_name=payload.product_name,
product_category=payload.product_category, product_category=payload.product_category,
material_type=payload.material_type,
supplier=payload.supplier, supplier=payload.supplier,
factory=payload.factory, factory=payload.factory,
stage_new=payload.stage_new, stage_new=payload.stage_new,
@ -30,8 +28,6 @@ class RiskItemService:
defect_level=payload.defect_level, defect_level=payload.defect_level,
solution=payload.solution, solution=payload.solution,
photos=payload.photos, photos=payload.photos,
recognition_confidence=payload.recognition_confidence,
ocr_payload=payload.ocr_payload,
submitted_by=user.emp_no, submitted_by=user.emp_no,
submitted_at=datetime.now(UTC), submitted_at=datetime.now(UTC),
status="待审核" if pre_result == "通过" else "待预审", status="待审核" if pre_result == "通过" else "待预审",
@ -68,8 +64,6 @@ class RiskItemService:
filters = [] filters = []
if status != "all": if status != "all":
filters.append(RiskItem.status == status) filters.append(RiskItem.status == status)
else:
filters.append(RiskItem.status != "已撤回")
if source_type != "all": if source_type != "all":
filters.append(RiskItem.source_type == source_type) filters.append(RiskItem.source_type == source_type)
statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit) statement = select(RiskItem).where(*filters).order_by(RiskItem.created_at.desc()).limit(limit)
@ -107,26 +101,6 @@ class RiskItemService:
await self.session.flush() await self.session.flush()
return self.to_data(item) 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: async def review(self, item_id: int, action: str, note: str, reviewer: UserAccount) -> ReviewRecordData:
item = await self.session.get(RiskItem, item_id) item = await self.session.get(RiskItem, item_id)
if item is None: if item is None:
@ -137,7 +111,9 @@ class RiskItemService:
raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403) raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403)
record = await self.session.scalar( 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: if record is None:
raise AppException("审核记录不存在", code=409, status_code=409) raise AppException("审核记录不存在", code=409, status_code=409)
@ -148,9 +124,7 @@ class RiskItemService:
if action == "通过": if action == "通过":
case = AnomalyCase( case = AnomalyCase(
product_name=item.product_name, product_name=item.product_name,
date=item.occurred_on,
product_category=item.product_category, product_category=item.product_category,
material_type=item.material_type,
supplier=item.supplier, supplier=item.supplier,
factory=item.factory, factory=item.factory,
stage_new=item.stage_new, stage_new=item.stage_new,
@ -158,7 +132,6 @@ class RiskItemService:
defect_level=item.defect_level, defect_level=item.defect_level,
solution=item.solution, solution=item.solution,
source_type=item.source_type, source_type=item.source_type,
source_file=item.source_file_id,
import_fingerprint=f"risk-item-{item.id}", import_fingerprint=f"risk-item-{item.id}",
) )
self.session.add(case) self.session.add(case)

View File

@ -1,17 +1,12 @@
"""Risk prediction use-case orchestration.""" """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.domain.risk_engine import BaselineRiskEngine, IndicatorValue
from app.schemas.risk_prediction import ( from app.schemas.risk_prediction import (
ModelInfo, ModelInfo,
RiskAssessmentRequest, RiskAssessmentRequest,
RiskAssessmentResult, RiskAssessmentResult,
RiskDimensionScore,
RiskFactorResult, RiskFactorResult,
) )
from app.services.database_risk_scoring import DatabaseRiskScoringService
class RiskPredictionService: class RiskPredictionService:
@ -19,63 +14,10 @@ class RiskPredictionService:
@classmethod @classmethod
def model_info(cls) -> ModelInfo: def model_info(cls) -> ModelInfo:
return ModelInfo(model_version="database-seven-dimension-v1") return ModelInfo(model_version=cls._engine.model_version)
@classmethod @classmethod
async def preview( def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult:
cls,
request: RiskAssessmentRequest,
session: AsyncSession | None = None,
) -> RiskAssessmentResult:
if session and any(
value is not None
for value in (
request.product_category,
request.material_type,
request.supplier,
request.factory,
request.stage_new,
request.defect_level,
)
):
database_score = await DatabaseRiskScoringService(session).score(
category=request.product_category,
material=request.material_type,
supplier=request.supplier,
factory=request.factory,
stage=request.stage_new,
defect_level=request.defect_level,
)
dimensions = [
RiskDimensionScore(
dimension=item.name,
score=item.score,
weight=item.weight,
status=item.status,
reason=item.reason,
sample_size=item.sample_size,
raw_value=item.raw_value,
)
for item in database_score.dimensions
]
return RiskAssessmentResult(
subject_id=request.subject_id,
score=database_score.score,
level=database_score.level,
model_version="database-seven-dimension-v1",
factors=[
RiskFactorResult(
name=item.name,
value=round(100 - item.score, 2),
weight=item.weight,
contribution=round(item.score * item.weight / 100, 2),
)
for item in database_score.dimensions
],
dimension_scores=dimensions,
)
if not request.indicators:
raise AppException("请填写产品评估参数", code=422, status_code=422)
score = cls._engine.predict( score = cls._engine.predict(
[ [
IndicatorValue(name=item.name, value=item.value, weight=item.weight) IndicatorValue(name=item.name, value=item.value, weight=item.weight)
@ -97,3 +39,4 @@ class RiskPredictionService:
for item in score.factors for item in score.factors
], ],
) )

View File

@ -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

View File

@ -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 []))

View File

@ -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]
]

View File

@ -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")
)

View File

@ -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},
)

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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'")
)

View File

@ -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

View File

@ -14,9 +14,6 @@ dependencies = [
"fastapi>=0.115,<1.0", "fastapi>=0.115,<1.0",
"httpx>=0.27,<1.0", "httpx>=0.27,<1.0",
"openpyxl>=3.1,<4.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", "PyJWT>=2.8,<3.0",
"pydantic>=2.8,<3.0", "pydantic>=2.8,<3.0",
"pydantic-settings>=2.4,<3.0", "pydantic-settings>=2.4,<3.0",

View File

@ -3,9 +3,6 @@ asyncpg>=0.29,<1.0
fastapi>=0.115,<1.0 fastapi>=0.115,<1.0
httpx>=0.27,<1.0 httpx>=0.27,<1.0
openpyxl>=3.1,<4.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 PyJWT>=2.8,<3.0
pydantic>=2.8,<3.0 pydantic>=2.8,<3.0
pydantic-settings>=2.4,<3.0 pydantic-settings>=2.4,<3.0

View File

@ -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))

View File

@ -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()

View File

@ -47,7 +47,7 @@ def main() -> None:
parser.add_argument( parser.add_argument(
"--role", "--role",
default="admin", default="admin",
choices=("user", "product_manager", "reviewer", "admin"), choices=("普通用户", "dev", "procurement", "qc", "ops", "reviewer", "manager", "admin"),
) )
parser.add_argument("--email") parser.add_argument("--email")
args = parser.parse_args() args = parser.parse_args()

View File

@ -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())

View File

@ -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()

View File

@ -2,37 +2,9 @@ from app.services.dingtalk_auth import DingTalkAuthService
def test_dingtalk_role_mapping() -> None: def test_dingtalk_role_mapping() -> None:
assert DingTalkAuthService.map_role("产品部", "产品经理") == "user" assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev"
assert DingTalkAuthService.map_role("品控部", "质量主管") == "user" assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc"
assert DingTalkAuthService.map_role("采购部", "采购专员") == "user" assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement"
assert DingTalkAuthService.map_role("", "审核员") == "user" assert DingTalkAuthService.map_role("", "审核员") == "reviewer"
assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin" assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin"
assert DingTalkAuthService.map_role("未知部门", "普通员工") == "user" assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户"
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]

View File

@ -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

View File

@ -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

View File

@ -1,8 +1,6 @@
from datetime import UTC, datetime from datetime import UTC, datetime
import pytest
from app.models.entities import Conversation, ConversationMessage from app.models.entities import Conversation, ConversationMessage
from app.schemas.risk_prediction import SupplierFrequency
from app.services.conversations import ConversationService from app.services.conversations import ConversationService
@ -11,7 +9,7 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None:
id=10, id=10,
conversation_id=3, conversation_id=3,
role="assistant", role="assistant",
content="已完成风险预警。", content="已完成风险预估。",
structured_data={ structured_data={
"riskResult": { "riskResult": {
"subjectId": "软管新品", "subjectId": "软管新品",
@ -32,32 +30,6 @@ def test_conversation_message_data_reads_persisted_risk_result() -> None:
assert result.risk_result.level.value == "low" 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: def test_conversation_data_includes_all_messages() -> None:
conversation = Conversation( conversation = Conversation(
id=3, id=3,
@ -104,19 +76,6 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None:
) is True ) 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: def test_legacy_general_semantics_hide_old_risk_card() -> None:
messages = [ messages = [
ConversationMessage( ConversationMessage(
@ -160,170 +119,3 @@ def test_product_assessment_semantics_override_nlu_miss() -> None:
assert normalized["intent"] == "assessment" assert normalized["intent"] == "assessment"
assert normalized["risk_requested"] is True 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"

View File

@ -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,
)

View File

@ -18,9 +18,6 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
nonlocal calls nonlocal calls
calls += 1 calls += 1
if 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( return httpx.Response(
200, 200,
json={ json={
@ -48,86 +45,13 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
client = LLMClient(transport=httpx.MockTransport(handler)) client = LLMClient(transport=httpx.MockTransport(handler))
semantic = await client.extract_semantics("评估洗发水新品") 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["intent"] == "assessment"
assert semantic["confidence"] == 0.92 assert semantic["confidence"] == 0.92
assert "".join(chunks) == "第一段第二段" assert "".join(chunks) == "第一段第二段"
assert calls == 2 assert calls == 2
get_settings.cache_clear() 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()

View File

@ -7,7 +7,6 @@ from app.models import ( # noqa: F401
ReviewRecord, ReviewRecord,
RiskAssessment, RiskAssessment,
RiskItem, RiskItem,
RiskModelConfig,
RolePermission, RolePermission,
StandardRule, StandardRule,
SupplierProfile, SupplierProfile,
@ -19,19 +18,15 @@ from sqlalchemy import BigInteger
def test_prd_core_tables_are_registered() -> None: def test_prd_core_tables_are_registered() -> None:
assert set(Base.metadata.tables) == { assert set(Base.metadata.tables) == {
"anomaly_cases", "anomaly_cases",
"anomaly_case_suppliers",
"conversations", "conversations",
"conversation_messages", "conversation_messages",
"operation_audits", "operation_audits",
"review_records", "review_records",
"risk_assessments", "risk_assessments",
"risk_items", "risk_items",
"risk_model_configs",
"purchase_order_lines",
"role_permissions", "role_permissions",
"standard_rules", "standard_rules",
"supplier_profiles", "supplier_profiles",
"supplier_master",
"user_accounts", "user_accounts",
} }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -18,12 +18,6 @@ def test_preview_requires_authentication() -> None:
assert response.status_code == 401 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: def test_baseline_engine_calculates_weighted_score() -> None:
score = BaselineRiskEngine().predict( score = BaselineRiskEngine().predict(
[ [

View File

@ -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)

View File

@ -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