From ab77dd97973a41cdbbcea1396988cec0633f2d1f Mon Sep 17 00:00:00 2001 From: kx_guo Date: Mon, 7 Sep 2026 17:49:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0OCR=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=B9=B6=E5=AE=8C=E5=96=84=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8E=E6=9D=83=E9=87=8D=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 14 + .gitignore | 1 + README.md | 4 + app/api/router.py | 4 + app/api/v1/model_config.py | 30 ++ app/api/v1/ocr.py | 50 ++ app/core/config.py | 20 + app/domain/model_config.py | 24 + app/models/__init__.py | 2 + app/models/entities.py | 29 +- app/schemas/model_config.py | 32 ++ app/schemas/ocr.py | 73 +++ app/schemas/risk_item.py | 15 +- app/services/baidu_ocr.py | 179 +++++++ app/services/database_risk_scoring.py | 57 +- app/services/dingtalk_auth.py | 16 +- app/services/llm_client.py | 39 +- app/services/model_config.py | 57 ++ app/services/ocr.py | 507 ++++++++++++++++++ app/services/risk_items.py | 11 +- app/services/suppliers.py | 13 +- .../versions/20260907_0016_add_ocr_fields.py | 37 ++ .../20260907_0017_four_role_model_config.py | 103 ++++ pyproject.toml | 1 + requirements.txt | 1 + tests/test_auth.py | 6 +- tests/test_baidu_ocr.py | 132 +++++ tests/test_database_risk_scoring.py | 53 ++ tests/test_models.py | 2 + 29 files changed, 1455 insertions(+), 57 deletions(-) create mode 100644 app/api/v1/model_config.py create mode 100644 app/api/v1/ocr.py create mode 100644 app/domain/model_config.py create mode 100644 app/schemas/model_config.py create mode 100644 app/schemas/ocr.py create mode 100644 app/services/baidu_ocr.py create mode 100644 app/services/model_config.py create mode 100644 app/services/ocr.py create mode 100644 migrations/versions/20260907_0016_add_ocr_fields.py create mode 100644 migrations/versions/20260907_0017_four_role_model_config.py create mode 100644 tests/test_baidu_ocr.py diff --git a/.env.example b/.env.example index 3e19b2c..ba07f09 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,20 @@ RISK_DINGTALK_APP_SECRET= RISK_DINGTALK_CORP_ID= RISK_DINGTALK_AGENT_ID= +# ==================== 百度 OCR ==================== +# 图片调用 WebImage,PDF 调用 PaddleOCR-VL 文档解析;上传原件仅保存在本机目录。 +BAIDU_OCR_API_KEY= +BAIDU_OCR_SECRET_KEY= +RISK_BAIDU_OCR_IMAGE_URL=https://aip.baidubce.com/rest/2.0/ocr/v1/webimage +RISK_BAIDU_OCR_DOCUMENT_URL=https://aip.baidubce.com/rest/2.0/brain/online/v2/paddle-vl-parser/task +RISK_BAIDU_OCR_TIMEOUT_SECONDS=75 +RISK_BAIDU_OCR_POLL_INTERVAL_SECONDS=2 +RISK_OCR_UPLOAD_DIR=.runtime-uploads/ocr +RISK_OCR_MAX_FILE_MB=10 +RISK_OCR_MAX_BATCH_FILES=5 +RISK_OCR_REVIEW_CONFIDENCE=0.8 +RISK_OCR_JUDGMENT_CONFIDENCE=0.85 + # ==================== 主 LLM 模型配置 ==================== # 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。 RISK_LLM_API_KEY= diff --git a/.gitignore b/.gitignore index 2398d05..0288549 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/ dist/ build/ data/ +.runtime-uploads/ diff --git a/README.md b/README.md index b155f27..d0ccabb 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ python scripts/create_user.py --emp-no admin001 --name 管理员 --role admin -- 审核接口:`POST /api/v1/risk-items`、`GET /api/v1/risk-items/pending`、`POST /api/v1/risk-items/{id}/review`。 +OCR 接口:`POST /api/v1/ocr/recognize` 接收最多 5 个 JPG、PNG 或 PDF 文件,图片调用百度 WebImage,PDF 调用 PaddleOCR-VL 文档解析;返回可编辑字段、检测数值、置信度及标准库判定。`POST /api/v1/ocr/judge` 用于人工修正检测值后重新判定,`GET /api/v1/ocr/files/{file_id}` 通过登录鉴权读取原始附件。启用前配置 `BAIDU_OCR_API_KEY` 和 `BAIDU_OCR_SECRET_KEY`,并执行数据库迁移。 + +角色采用管理员 `admin`、产品经理 `product_manager`、审核员 `reviewer`、普通用户 `普通用户` 四类。模型权重由管理员通过 `GET/PATCH /api/v1/model-config` 维护,七维权重合计必须为 100%,修改仅对新评估生效。 + 健康检查:`GET http://127.0.0.1:18474/api/v1/health` 数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 diff --git a/app/api/router.py b/app/api/router.py index a0268ab..4cc5194 100644 --- a/app/api/router.py +++ b/app/api/router.py @@ -6,6 +6,8 @@ from app.api.v1.auth import router as auth_router from app.api.v1.conversations import router as conversations_router from app.api.v1.dashboard import router as dashboard_router from app.api.v1.health import router as health_router +from app.api.v1.model_config import router as model_config_router +from app.api.v1.ocr import router as ocr_router from app.api.v1.risk_items import router as risk_items_router from app.api.v1.risk_predictions import router as risk_predictions_router from app.api.v1.standards import router as standards_router @@ -13,6 +15,8 @@ from app.api.v1.suppliers import router as suppliers_router api_router = APIRouter(prefix="/api/v1") api_router.include_router(health_router) +api_router.include_router(model_config_router) +api_router.include_router(ocr_router) api_router.include_router(auth_router) api_router.include_router(conversations_router) api_router.include_router(dashboard_router) diff --git a/app/api/v1/model_config.py b/app/api/v1/model_config.py new file mode 100644 index 0000000..2b37924 --- /dev/null +++ b/app/api/v1/model_config.py @@ -0,0 +1,30 @@ +"""Administrator-only risk-model configuration endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.dependencies import DatabaseSession, require_permission +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.model_config import ModelWeightConfigData, ModelWeightConfigUpdate +from app.services.model_config import ModelConfigService + +router = APIRouter(prefix="/model-config", tags=["model-config"]) + + +@router.get("", response_model=ApiResponse[ModelWeightConfigData]) +async def get_model_config( + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("model_weight.manage"))], +) -> ApiResponse[ModelWeightConfigData]: + return ApiResponse(data=await ModelConfigService(session).get()) + + +@router.patch("", response_model=ApiResponse[ModelWeightConfigData]) +async def update_model_config( + payload: ModelWeightConfigUpdate, + session: DatabaseSession, + current_user: Annotated[UserAccount, Depends(require_permission("model_weight.manage", operate=True))], +) -> ApiResponse[ModelWeightConfigData]: + return ApiResponse(data=await ModelConfigService(session).update(payload, current_user)) diff --git a/app/api/v1/ocr.py b/app/api/v1/ocr.py new file mode 100644 index 0000000..07c85e7 --- /dev/null +++ b/app/api/v1/ocr.py @@ -0,0 +1,50 @@ +"""Authenticated OCR recognition and source-file endpoints.""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, UploadFile +from fastapi.responses import FileResponse + +from app.api.dependencies import DatabaseSession, require_permission +from app.core.exceptions import AppException +from app.models.entities import UserAccount +from app.schemas.common import ApiResponse +from app.schemas.ocr import OcrBatchResult, OcrJudgmentRequest, OcrStandardJudgment +from app.services.ocr import OcrService + +router = APIRouter(prefix="/ocr", tags=["ocr"]) + + +@router.post("/recognize", response_model=ApiResponse[OcrBatchResult]) +async def recognize_files( + files: Annotated[list[UploadFile], File(description="1-5 JPG, PNG, or PDF files")], + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))], +) -> ApiResponse[OcrBatchResult]: + return ApiResponse(data=await OcrService(session).recognize(files)) + + +@router.post("/judge", response_model=ApiResponse[list[OcrStandardJudgment]]) +async def judge_measurements( + payload: OcrJudgmentRequest, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit", operate=True))], +) -> ApiResponse[list[OcrStandardJudgment]]: + return ApiResponse(data=await OcrService(session).judge(payload.measurements)) + + +@router.get("/files/{file_id}", response_class=FileResponse) +async def get_source_file( + file_id: str, + session: DatabaseSession, + _current_user: Annotated[UserAccount, Depends(require_permission("risk_item.submit"))], +) -> FileResponse: + path = OcrService(session).upload_path(file_id) + if not path.is_file(): + raise AppException("附件不存在", code=404, status_code=404) + media_type = { + ".jpg": "image/jpeg", + ".png": "image/png", + ".pdf": "application/pdf", + }[path.suffix.lower()] + return FileResponse(path, media_type=media_type, filename=file_id) diff --git a/app/core/config.py b/app/core/config.py index 24d59c1..c603c9f 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,7 @@ """Environment-backed application settings.""" from functools import lru_cache +from pathlib import Path from typing import Annotated, Literal from pydantic import AliasChoices, Field, SecretStr, field_validator @@ -24,6 +25,25 @@ class Settings(BaseSettings): dingtalk_app_secret: SecretStr | None = Field(default=None, repr=False) dingtalk_corp_id: str = "" dingtalk_agent_id: str = "" + baidu_ocr_api_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("BAIDU_OCR_API_KEY", "RISK_BAIDU_OCR_API_KEY"), + ) + baidu_ocr_secret_key: SecretStr | None = Field( + default=None, + repr=False, + validation_alias=AliasChoices("BAIDU_OCR_SECRET_KEY", "RISK_BAIDU_OCR_SECRET_KEY"), + ) + baidu_ocr_image_url: str = "https://aip.baidubce.com/rest/2.0/ocr/v1/webimage" + baidu_ocr_document_url: str = "https://aip.baidubce.com/rest/2.0/brain/online/v2/paddle-vl-parser/task" + baidu_ocr_timeout_seconds: int = Field(default=75, ge=10, le=300) + baidu_ocr_poll_interval_seconds: float = Field(default=2.0, ge=0.5, le=10) + ocr_upload_dir: Path = Path(".runtime-uploads/ocr") + ocr_max_file_mb: int = Field(default=10, ge=1, le=20) + ocr_max_batch_files: int = Field(default=5, ge=1, le=10) + ocr_review_confidence: float = Field(default=0.8, ge=0, le=1) + ocr_judgment_confidence: float = Field(default=0.85, ge=0, le=1) llm_api_key: SecretStr | None = Field( default=None, repr=False, diff --git a/app/domain/model_config.py b/app/domain/model_config.py new file mode 100644 index 0000000..cc29b18 --- /dev/null +++ b/app/domain/model_config.py @@ -0,0 +1,24 @@ +"""Canonical risk-model configuration shared by scoring and administration.""" + +DEFAULT_WEIGHTS = { + "供应商表现": 25.0, + "物料品类": 20.0, + "产品品类": 15.0, + "加工厂质量": 15.0, + "标准符合性": 10.0, + "节点阶段": 10.0, + "缺陷等级": 5.0, +} + +WEIGHT_DESCRIPTIONS = { + "供应商表现": "含频次风险系数 α 修正", + "物料品类": "按物料历史异常率", + "产品品类": "按具体产品品类历史异常密度", + "加工厂质量": "巡检 / 批次合格率", + "标准符合性": "按判定标准库量化达标率计分", + "节点阶段": "开发阶段越靠后风险越高", + "缺陷等级": "A 级缺陷加权", +} + +DEFAULT_FREQUENCY_COEFFICIENT = 0.3 +DEFAULT_FREQUENCY_CAP = 1.5 diff --git a/app/models/__init__.py b/app/models/__init__.py index 9488dce..6f86600 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.entities import ( ReviewRecord, RiskAssessment, RiskItem, + RiskModelConfig, RolePermission, StandardRule, SupplierProfile, @@ -22,6 +23,7 @@ __all__ = [ "ReviewRecord", "RiskAssessment", "RiskItem", + "RiskModelConfig", "RolePermission", "StandardRule", "SupplierProfile", diff --git a/app/models/entities.py b/app/models/entities.py index b2e6ce5..6328709 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -119,9 +119,7 @@ class Conversation(Base): """A persistent assistant conversation owned by one authenticated user.""" __tablename__ = "conversations" - __table_args__ = ( - Index("ix_conversations_user_updated", "user_id", "updated_at"), - ) + __table_args__ = (Index("ix_conversations_user_updated", "user_id", "updated_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column( @@ -149,9 +147,7 @@ class ConversationMessage(Base): """One user or assistant message, including optional structured results.""" __tablename__ = "conversation_messages" - __table_args__ = ( - Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"), - ) + __table_args__ = (Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) conversation_id: Mapped[int] = mapped_column( @@ -176,13 +172,17 @@ class RiskItem(Base): Index("ix_risk_items_status", "status"), Index("ix_risk_items_submitted_by", "submitted_by"), Index("ix_risk_items_submitted_at", "submitted_at"), + Index("ix_risk_items_occurred_on", "occurred_on"), + Index("ix_risk_items_material_type", "material_type"), ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) source_type: Mapped[str] = mapped_column(String(20), nullable=False) source_file_id: Mapped[str | None] = mapped_column(String(50)) + occurred_on: Mapped[date | None] = mapped_column(Date) product_name: Mapped[str | None] = mapped_column(String(200)) product_category: Mapped[str | None] = mapped_column(String(50)) + material_type: Mapped[str | None] = mapped_column(String(200)) supplier: Mapped[str | None] = mapped_column(String(100)) factory: Mapped[str | None] = mapped_column(String(100)) stage_new: Mapped[str | None] = mapped_column(String(50)) @@ -190,6 +190,8 @@ class RiskItem(Base): defect_level: Mapped[str | None] = mapped_column(String(5)) solution: Mapped[str | None] = mapped_column(Text) photos: Mapped[list[str]] = mapped_column(JSONB, default=list, server_default="[]", nullable=False) + recognition_confidence: Mapped[Decimal | None] = mapped_column(Numeric(5, 4)) + ocr_payload: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False) submitted_by: Mapped[str | None] = mapped_column(String(50)) submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) status: Mapped[str] = mapped_column(String(20), default="待预审", server_default="待预审", nullable=False) @@ -323,6 +325,21 @@ class RolePermission(Base): ) +class RiskModelConfig(Base): + """Administrator-managed seven-dimension weights and frequency parameters.""" + + __tablename__ = "risk_model_configs" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, default=1) + weights: Mapped[dict[str, float]] = mapped_column(JSONB, nullable=False) + frequency_coefficient: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False) + frequency_cap: Mapped[Decimal] = mapped_column(Numeric(4, 2), nullable=False) + updated_by: Mapped[str | None] = mapped_column(String(20)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + class OperationAudit(Base): """Append-only audit record for authentication, permission, and review actions.""" diff --git a/app/schemas/model_config.py b/app/schemas/model_config.py new file mode 100644 index 0000000..ae20f41 --- /dev/null +++ b/app/schemas/model_config.py @@ -0,0 +1,32 @@ +"""Risk-model configuration API contracts.""" + +from datetime import datetime + +from pydantic import Field, model_validator + +from app.domain.model_config import DEFAULT_WEIGHTS +from app.schemas.common import ApiSchema + + +class ModelWeightConfigData(ApiSchema): + weights: dict[str, float] + frequency_coefficient: float + frequency_cap: float + updated_by: str | None = None + updated_at: datetime | None = None + + +class ModelWeightConfigUpdate(ApiSchema): + weights: dict[str, float] + frequency_coefficient: float = Field(ge=0, le=1) + frequency_cap: float = Field(ge=1, le=3) + + @model_validator(mode="after") + def validate_weights(self) -> "ModelWeightConfigUpdate": + if set(self.weights) != set(DEFAULT_WEIGHTS): + raise ValueError("权重必须完整包含七个固定维度") + if any(value <= 0 or value > 100 for value in self.weights.values()): + raise ValueError("每个维度权重必须大于0且不超过100") + if abs(sum(self.weights.values()) - 100) > 0.01: + raise ValueError("七个维度权重合计必须为100%") + return self diff --git a/app/schemas/ocr.py b/app/schemas/ocr.py new file mode 100644 index 0000000..fc35a94 --- /dev/null +++ b/app/schemas/ocr.py @@ -0,0 +1,73 @@ +"""OCR recognition and standard-judgment contracts.""" + +from datetime import date +from typing import Literal + +from pydantic import Field + +from app.schemas.common import ApiSchema + + +class OcrField(ApiSchema): + value: str | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + needs_review: bool = True + + +class OcrExtractedFields(ApiSchema): + product_name: OcrField = Field(default_factory=OcrField) + product_category: OcrField = Field(default_factory=OcrField) + supplier: OcrField = Field(default_factory=OcrField) + factory: OcrField = Field(default_factory=OcrField) + material_type: OcrField = Field(default_factory=OcrField) + stage_new: OcrField = Field(default_factory=OcrField) + description: OcrField = Field(default_factory=OcrField) + defect_level: OcrField = Field(default_factory=OcrField) + occurred_on: date | None = None + + +class OcrMeasurement(ApiSchema): + item: str + raw_value: str + value: float | None = None + unit: str | None = None + confidence: float | None = Field(default=None, ge=0, le=1) + needs_review: bool = True + + +class OcrStandardJudgment(ApiSchema): + measurement_item: str + measured_value: str + standard_rule_id: int | None = None + risk_point: str | None = None + standard_value: str | None = None + result: Literal["合格", "不合格", "待人工确认", "未匹配标准"] + basis: str | None = None + reason: str | None = None + + +class OcrFileResult(ApiSchema): + file_id: str + file_name: str + content_type: str + engine: Literal["webimage", "paddle-vl-parser"] + raw_text: str + confidence: float | None = Field(default=None, ge=0, le=1) + page_count: int = Field(default=1, ge=1) + fields: OcrExtractedFields + measurements: list[OcrMeasurement] = Field(default_factory=list) + judgments: list[OcrStandardJudgment] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + requires_review: bool = True + + +class OcrBatchResult(ApiSchema): + files: list[OcrFileResult] + fields: OcrExtractedFields + measurements: list[OcrMeasurement] = Field(default_factory=list) + judgments: list[OcrStandardJudgment] = Field(default_factory=list) + requires_review: bool = True + + +class OcrJudgmentRequest(ApiSchema): + measurements: list[OcrMeasurement] = Field(min_length=1, max_length=50) diff --git a/app/schemas/risk_item.py b/app/schemas/risk_item.py index 851bd74..9bf1815 100644 --- a/app/schemas/risk_item.py +++ b/app/schemas/risk_item.py @@ -1,6 +1,7 @@ """Risk item submission and review contracts.""" -from datetime import datetime +from datetime import date, datetime +from typing import Any from pydantic import Field @@ -10,8 +11,10 @@ from app.schemas.common import ApiSchema class RiskItemCreate(ApiSchema): source_type: str = Field(default="表单", min_length=1, max_length=20) source_file_id: str | None = Field(default=None, max_length=50) + occurred_on: date | None = None product_name: str = Field(min_length=1, max_length=200) product_category: str | None = Field(default=None, max_length=100) + material_type: str | None = Field(default=None, max_length=200) supplier: str | None = Field(default=None, max_length=300) factory: str | None = Field(default=None, max_length=300) stage_new: str | None = Field(default=None, max_length=100) @@ -19,11 +22,15 @@ class RiskItemCreate(ApiSchema): defect_level: str | None = Field(default=None, max_length=10) solution: str | None = None photos: list[str] = Field(default_factory=list, max_length=10) + recognition_confidence: float | None = Field(default=None, ge=0, le=1) + ocr_payload: dict[str, Any] = Field(default_factory=dict) class RiskItemUpdate(ApiSchema): product_name: str | None = Field(default=None, min_length=1, max_length=200) product_category: str | None = Field(default=None, max_length=100) + material_type: str | None = Field(default=None, max_length=200) + occurred_on: date | None = None supplier: str | None = Field(default=None, max_length=300) factory: str | None = Field(default=None, max_length=300) stage_new: str | None = Field(default=None, max_length=100) @@ -31,14 +38,18 @@ class RiskItemUpdate(ApiSchema): defect_level: str | None = Field(default=None, max_length=10) solution: str | None = None photos: list[str] | None = Field(default=None, max_length=10) + recognition_confidence: float | None = Field(default=None, ge=0, le=1) + ocr_payload: dict[str, Any] | None = None class RiskItemData(ApiSchema): id: int source_type: str source_file_id: str | None + occurred_on: date | None product_name: str | None product_category: str | None + material_type: str | None supplier: str | None factory: str | None stage_new: str | None @@ -46,6 +57,8 @@ class RiskItemData(ApiSchema): defect_level: str | None solution: str | None photos: list[str] + recognition_confidence: float | None + ocr_payload: dict[str, Any] submitted_by: str | None submitted_at: datetime | None status: str diff --git a/app/services/baidu_ocr.py b/app/services/baidu_ocr.py new file mode 100644 index 0000000..e04c464 --- /dev/null +++ b/app/services/baidu_ocr.py @@ -0,0 +1,179 @@ +"""Small async client for the Baidu OCR endpoints used by the application.""" + +import asyncio +import base64 +import time +from typing import Any +from urllib.parse import urlparse + +import httpx + +from app.core.config import get_settings +from app.core.exceptions import AppException + + +class BaiduOcrClient: + token_url = "https://aip.baidubce.com/oauth/2.0/token" + + def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: + settings = get_settings() + self.api_key = settings.baidu_ocr_api_key.get_secret_value() if settings.baidu_ocr_api_key else "" + self.secret_key = settings.baidu_ocr_secret_key.get_secret_value() if settings.baidu_ocr_secret_key else "" + self.image_url = settings.baidu_ocr_image_url.strip() + self.document_url = settings.baidu_ocr_document_url.strip().rstrip("/") + self.timeout_seconds = settings.baidu_ocr_timeout_seconds + self.poll_interval = settings.baidu_ocr_poll_interval_seconds + self.transport = transport + self._access_token = "" + self._token_expires_at = 0.0 + + @property + def configured(self) -> bool: + return bool(self.api_key and self.secret_key) + + async def recognize_image(self, content: bytes) -> tuple[str, float | None, int]: + token = await self._get_access_token() + payload = { + "image": base64.b64encode(content).decode("ascii"), + "language_type": "CHN_ENG", + "detect_direction": "true", + "probability": "true", + } + body = await self._post_form(self.image_url, token, payload) + words = body.get("words_result") or [] + lines = [str(item.get("words") or "").strip() for item in words if isinstance(item, dict)] + confidences = [ + float(item["probability"]["average"]) + for item in words + if isinstance(item, dict) + and isinstance(item.get("probability"), dict) + and isinstance(item["probability"].get("average"), (int, float)) + ] + text = "\n".join(line for line in lines if line) + if not text: + raise AppException("图片中未识别到文字,请重新拍摄或手动填写", code=422, status_code=422) + confidence = sum(confidences) / len(confidences) if confidences else None + return text, confidence, 1 + + async def recognize_document(self, content: bytes, file_name: str) -> tuple[str, float | None, int]: + token = await self._get_access_token() + body = await self._post_form( + self.document_url, + token, + { + "file_data": base64.b64encode(content).decode("ascii"), + "file_name": file_name, + "merge_tables": "true", + "return_span_boxes": "true", + "angle_adjust": "true", + }, + ) + task_id = str((body.get("result") or {}).get("task_id") or "") + if not task_id: + raise AppException("百度文档识别未返回任务编号", code=502, status_code=502) + + deadline = time.monotonic() + self.timeout_seconds + query_url = f"{self.document_url}/query" + result: dict[str, Any] = {} + while time.monotonic() < deadline: + await asyncio.sleep(self.poll_interval) + query_body = await self._post_form(query_url, token, {"task_id": task_id}) + result = query_body.get("result") or {} + status = result.get("status") + if status == "success": + break + if status == "failed": + detail = str(result.get("task_error") or "文档解析失败") + raise AppException(f"百度文档识别失败:{detail}", code=502, status_code=502) + else: + raise AppException("文档识别超时,请稍后重试", code=504, status_code=504) + + parse_url = str(result.get("parse_result_url") or "") + markdown_url = str(result.get("markdown_url") or "") + if parse_url: + parsed = await self._download_result(parse_url, expect_json=True) + text, pages = self._text_from_parse_result(parsed) + elif markdown_url: + text = str(await self._download_result(markdown_url, expect_json=False)) + pages = max(1, text.count("\n---\n") + 1) + else: + raise AppException("百度文档识别未返回解析结果", code=502, status_code=502) + if not text.strip(): + raise AppException("文档中未识别到文字,请重新上传或手动填写", code=422, status_code=422) + return text[:100_000], None, pages + + async def _get_access_token(self) -> str: + if not self.configured: + raise AppException("未配置百度 OCR API Key 或 Secret Key", code=503, status_code=503) + if self._access_token and time.monotonic() < self._token_expires_at: + return self._access_token + timeout = httpx.Timeout(15, connect=10) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post( + self.token_url, + params={ + "grant_type": "client_credentials", + "client_id": self.api_key, + "client_secret": self.secret_key, + }, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise AppException("无法连接百度 OCR 鉴权服务", code=502, status_code=502) from exc + token = str(body.get("access_token") or "") + if not token: + raise AppException("百度 OCR 密钥无效或服务尚未开通", code=502, status_code=502) + expires_in = int(body.get("expires_in") or 2_592_000) + self._access_token = token + self._token_expires_at = time.monotonic() + max(60, expires_in - 300) + return token + + async def _post_form(self, url: str, token: str, data: dict[str, str]) -> dict[str, Any]: + timeout = httpx.Timeout(self.timeout_seconds, connect=15) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: + try: + response = await client.post(url, params={"access_token": token}, data=data) + response.raise_for_status() + body = response.json() + except httpx.TimeoutException as exc: + raise AppException("百度 OCR 请求超时,请重试", code=504, status_code=504) from exc + except (httpx.HTTPError, ValueError) as exc: + raise AppException("百度 OCR 服务暂时不可用", code=502, status_code=502) from exc + if body.get("error_code") not in (None, 0, "0"): + message = str(body.get("error_msg") or "识别失败") + raise AppException(f"百度 OCR 返回错误:{message}", code=502, status_code=502) + return body + + async def _download_result(self, url: str, *, expect_json: bool) -> dict[str, Any] | str: + parsed = urlparse(url) + allowed = parsed.scheme == "https" and ( + parsed.hostname == "baidubce.com" + or bool(parsed.hostname and parsed.hostname.endswith((".baidubce.com", ".bcebos.com"))) + ) + if not allowed: + raise AppException("百度 OCR 返回了不受信任的结果地址", code=502, status_code=502) + timeout = httpx.Timeout(self.timeout_seconds, connect=15) + async with httpx.AsyncClient(timeout=timeout, transport=self.transport, follow_redirects=True) as client: + try: + response = await client.get(url) + response.raise_for_status() + return response.json() if expect_json else response.text + except (httpx.HTTPError, ValueError) as exc: + raise AppException("无法下载百度 OCR 解析结果", code=502, status_code=502) from exc + + @staticmethod + def _text_from_parse_result(body: dict[str, Any]) -> tuple[str, int]: + pages = body.get("pages") or [] + chunks: list[str] = [] + for page in pages: + if not isinstance(page, dict): + continue + page_text = str(page.get("text") or "").strip() + if page_text: + chunks.append(page_text) + for table in page.get("tables") or []: + if isinstance(table, dict) and table.get("markdown"): + chunks.append(str(table["markdown"]).strip()) + return "\n\n".join(chunks), max(1, len(pages)) diff --git a/app/services/database_risk_scoring.py b/app/services/database_risk_scoring.py index 0c36fb9..5531c06 100644 --- a/app/services/database_risk_scoring.py +++ b/app/services/database_risk_scoring.py @@ -9,18 +9,9 @@ from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS from app.domain.risk_engine import RiskLevel -from app.models.entities import AnomalyCase - -DEFAULT_WEIGHTS = { - "供应商表现": 25.0, - "物料品类": 20.0, - "产品品类": 15.0, - "加工厂质量": 15.0, - "标准符合性": 10.0, - "节点阶段": 10.0, - "缺陷等级": 5.0, -} +from app.models.entities import AnomalyCase, RiskModelConfig STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") @@ -54,6 +45,9 @@ class DatabaseRiskScoringService: def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None: self.session = session self.as_of = as_of or date.today() + self.weights = DEFAULT_WEIGHTS.copy() + self.frequency_coefficient = DEFAULT_FREQUENCY_COEFFICIENT + self.frequency_cap = DEFAULT_FREQUENCY_CAP async def score( self, @@ -65,6 +59,7 @@ class DatabaseRiskScoringService: stage: str | None = None, defect_level: str | None = None, ) -> DatabaseRiskScore: + await self._load_model_config() rows = await self._load_rows() category_name = normalize_category(category) material_name = normalize_material(material) @@ -78,14 +73,14 @@ class DatabaseRiskScoringService: DimensionScore( name="加工厂质量", score=50.0, - weight=DEFAULT_WEIGHTS["加工厂质量"], + weight=self.weights["加工厂质量"], status="default", reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入", ), DimensionScore( name="标准符合性", score=50.0, - weight=DEFAULT_WEIGHTS["标准符合性"], + weight=self.weights["标准符合性"], status="default", reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", ), @@ -95,6 +90,19 @@ class DatabaseRiskScoringService: score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) + async def _load_model_config(self) -> None: + get = getattr(self.session, "get", None) + if get is None: + return + config = await get(RiskModelConfig, 1) + if config is None: + return + weights = {key: float(config.weights.get(key, value)) for key, value in DEFAULT_WEIGHTS.items()} + if abs(sum(weights.values()) - 100) <= 0.01 and all(value > 0 for value in weights.values()): + self.weights = weights + self.frequency_coefficient = float(config.frequency_coefficient) + self.frequency_cap = float(config.frequency_cap) + async def _load_rows(self) -> list[dict[str, Any]]: start = self.as_of - timedelta(days=365 * 3) statement = select( @@ -114,7 +122,7 @@ class DatabaseRiskScoringService: return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["供应商表现"] + weight = self.weights["供应商表现"] if not party_name: return DimensionScore( name="供应商表现", @@ -155,7 +163,7 @@ class DatabaseRiskScoringService: else 0 ) base = max(0.0, min(100.0, base + trend_adjustment)) - alpha = min(1.5, 1.0 + count / 100 * 0.3) + alpha = min(self.frequency_cap, 1.0 + count / 100 * self.frequency_coefficient) score = min(100.0, round(base * alpha, 2)) return DimensionScore( name="供应商表现", @@ -167,9 +175,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _material_dimension(rows: list[dict[str, Any]], material: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["物料品类"] + def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore: + weight = self.weights["物料品类"] if not material: return DimensionScore( name="物料品类", @@ -196,9 +203,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _category_dimension(rows: list[dict[str, Any]], category: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["产品品类"] + def _category_dimension(self, rows: list[dict[str, Any]], category: str | None) -> DimensionScore: + weight = self.weights["产品品类"] if not category: return DimensionScore( name="产品品类", @@ -232,9 +238,8 @@ class DatabaseRiskScoringService: raw_value=count, ) - @staticmethod - def _stage_dimension(rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: - weight = DEFAULT_WEIGHTS["节点阶段"] + def _stage_dimension(self, rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: + weight = self.weights["节点阶段"] if not stage: return DimensionScore( name="节点阶段", @@ -266,15 +271,15 @@ class DatabaseRiskScoringService: raw_value=share * 100, ) - @staticmethod def _defect_dimension( + self, rows: list[dict[str, Any]], category: str | None, material: str | None, party_name: str | None, defect_level: str | None, ) -> DimensionScore: - weight = DEFAULT_WEIGHTS["缺陷等级"] + weight = self.weights["缺陷等级"] if not category and not material and not party_name and not defect_level: return DimensionScore( name="缺陷等级", diff --git a/app/services/dingtalk_auth.py b/app/services/dingtalk_auth.py index fdee144..4ba1b02 100644 --- a/app/services/dingtalk_auth.py +++ b/app/services/dingtalk_auth.py @@ -170,21 +170,15 @@ class DingTalkAuthService: @staticmethod def map_role(dept: str, title: str, is_admin: bool = False) -> str: - """Map DingTalk organization snapshots to the PRD seven-role model.""" + """Map DingTalk organization snapshots to the four-role administration model.""" text = f"{dept} {title}".lower() if is_admin or "it" in text or "信息" in text or "系统" in text: return "admin" if "审核" in text or "review" in text: return "reviewer" - if "采购" in text or "procurement" in text: - return "procurement" - if "品控" in text or "质量" in text or "qc" in text: - return "qc" - if "管理" in text or "主管" in text or "manager" in text: - return "manager" - if "运营" in text or "ops" in text: - return "ops" - if "开发" in text or "产品" in text or "dev" in text: - return "dev" + if "产品经理" in text or "product manager" in text or ( + "产品" in dept and ("经理" in title or "主管" in title) + ): + return "product_manager" return "普通用户" diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 358b3a8..c33f055 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -135,6 +135,40 @@ class LLMClient: status_code=503, ) from fallback_exc + async def extract_ocr_fields(self, text: str) -> dict[str, Any]: + """Turn OCR text into an editable risk-item draft without inventing absent values.""" + + messages = [ + { + "role": "system", + "content": ( + "你是蝶雅产品风险预估系统的OCR字段抽取模块,只输出合法JSON对象。" + "只能提取输入文字明确出现的信息,不得推测或补造。输出字段:product_name、product_category、" + "supplier、factory、material_type、stage_new、description、defect_level、occurred_on、confidence、" + "measurements。缺失标量用null。defect_level只能是A、B、C或null;occurred_on使用YYYY-MM-DD或null。" + "measurements为数组,每项包含item、raw_value、value、unit、confidence;value必须是数字或null。" + "measurements只允许质量检测指标,例如pH、色差、重金属含量、力矩、喷出量、附着力、" + "不合格率;数量、抽检数量、产品规格、容量、日期、单号和批次不得作为检测指标。" + "confidence为0到1。description应保留异常现象和影响比例,但不得加入原文没有的结论。" + ), + }, + {"role": "user", "content": text[:20_000]}, + ] + last_error: Exception | None = None + providers: list[ProviderName] = ["primary"] + if self.fallback_configured: + providers.append("fallback") + for provider in providers: + try: + body = await self._request(messages, provider=provider, stream=False, json_mode=True) + return self._parse_json(self._response_text(body, provider=provider)) + except Exception as exc: # noqa: BLE001 - deterministic extraction handles provider failures + last_error = exc + logger.warning("LLM OCR extraction failed provider=%s error=%s", provider, exc.__class__.__name__) + if last_error: + raise last_error + return {} + async def stream_reply(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]: """Yield reply deltas and fail over only before any primary text is emitted.""" @@ -179,9 +213,8 @@ class LLMClient: }, { "role": "user", - "content": "以下是本次风险评估数据,请生成预防建议:\n" + json.dumps( - context, ensure_ascii=False, separators=(",", ":") - ), + "content": "以下是本次风险评估数据,请生成预防建议:\n" + + json.dumps(context, ensure_ascii=False, separators=(",", ":")), }, ] try: diff --git a/app/services/model_config.py b/app/services/model_config.py new file mode 100644 index 0000000..c3a2fbe --- /dev/null +++ b/app/services/model_config.py @@ -0,0 +1,57 @@ +"""Read and update administrator-managed risk-model configuration.""" + +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domain.model_config import DEFAULT_FREQUENCY_CAP, DEFAULT_FREQUENCY_COEFFICIENT, DEFAULT_WEIGHTS +from app.models.entities import OperationAudit, RiskModelConfig, UserAccount +from app.schemas.model_config import ModelWeightConfigData, ModelWeightConfigUpdate + + +class ModelConfigService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self) -> ModelWeightConfigData: + row = await self.session.get(RiskModelConfig, 1) + if row is None: + return ModelWeightConfigData( + weights=DEFAULT_WEIGHTS, + frequency_coefficient=DEFAULT_FREQUENCY_COEFFICIENT, + frequency_cap=DEFAULT_FREQUENCY_CAP, + ) + return self._to_data(row) + + async def update(self, payload: ModelWeightConfigUpdate, user: UserAccount) -> ModelWeightConfigData: + row = await self.session.get(RiskModelConfig, 1) + if row is None: + row = RiskModelConfig(id=1) + self.session.add(row) + row.weights = payload.weights + row.frequency_coefficient = Decimal(str(payload.frequency_coefficient)) + row.frequency_cap = Decimal(str(payload.frequency_cap)) + row.updated_by = user.emp_no + self.session.add( + OperationAudit( + actor_user_id=user.id, + actor_emp_no=user.emp_no, + action="model_weight.update", + resource_type="risk_model_config", + resource_id="1", + audit_metadata=payload.model_dump(), + ) + ) + await self.session.flush() + await self.session.refresh(row) + return self._to_data(row) + + @staticmethod + def _to_data(row: RiskModelConfig) -> ModelWeightConfigData: + return ModelWeightConfigData( + weights={key: float(value) for key, value in row.weights.items()}, + frequency_coefficient=float(row.frequency_coefficient), + frequency_cap=float(row.frequency_cap), + updated_by=row.updated_by, + updated_at=row.updated_at, + ) diff --git a/app/services/ocr.py b/app/services/ocr.py new file mode 100644 index 0000000..c74dac7 --- /dev/null +++ b/app/services/ocr.py @@ -0,0 +1,507 @@ +"""OCR upload orchestration, field extraction, and standards evaluation.""" + +import re +from datetime import date +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import UploadFile +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import get_settings +from app.core.exceptions import AppException +from app.models.entities import StandardRule +from app.schemas.ocr import ( + OcrBatchResult, + OcrExtractedFields, + OcrField, + OcrFileResult, + OcrMeasurement, + OcrStandardJudgment, +) +from app.services.baidu_ocr import BaiduOcrClient +from app.services.llm_client import LLMClient + +_FIELD_ALIASES = { + "product_name": ("产品名称", "产品名", "品名"), + "product_category": ("产品品类", "品类"), + "supplier": ("供应商", "供方"), + "factory": ("加工厂", "生产工厂", "工厂"), + "material_type": ("物料名称", "物料类型", "物料"), + "stage_new": ("发生阶段", "发现阶段", "产品阶段", "阶段"), + "description": ("异常描述", "问题描述", "不良描述", "异常现象"), + "defect_level": ("缺陷等级", "异常等级", "等级"), +} +_MEASUREMENT_WORDS = ( + "pH", + "PH", + "色差", + "铅", + "汞", + "砷", + "镉", + "力矩", + "喷出量", + "附着力", + "含量", + "粘度", + "黏度", + "透光率", + "菌落", + "温度", + "湿度", + "不合格率", +) +_NON_MEASUREMENT_NAMES = { + "数量", + "抽检数量", + "产品规格", + "规格", + "容量", + "日期", + "生产日期", + "发生日期", + "单号", + "订单号", + "批次", + "批号", + "页码", +} +_UNIT_PATTERN = r"(?:mg/kg|μg/g|ug/g|g/L|mg/L|N[·.]?m|mPa[·.]?s|Pa[·.]?s|mm|cm|nm|%|级|℃|°C)?" + + +class OcrService: + def __init__( + self, + session: AsyncSession, + *, + client: BaiduOcrClient | None = None, + llm: LLMClient | None = None, + ) -> None: + self.session = session + self.client = client or BaiduOcrClient() + self.llm = llm or LLMClient() + self.settings = get_settings() + + async def recognize(self, files: list[UploadFile]) -> OcrBatchResult: + if not files: + raise AppException("请至少上传一个图片或 PDF", code=422, status_code=422) + if len(files) > self.settings.ocr_max_batch_files: + raise AppException( + f"一次最多上传 {self.settings.ocr_max_batch_files} 个文件", + code=422, + status_code=422, + ) + rules = list( + ( + await self.session.scalars( + select(StandardRule).where(StandardRule.status == "启用").order_by(StandardRule.id) + ) + ).all() + ) + results: list[OcrFileResult] = [] + try: + for upload in files: + results.append(await self._recognize_one(upload, rules)) + except Exception: + for result in results: + self.upload_path(result.file_id).unlink(missing_ok=True) + raise + fields = self._merge_fields([item.fields for item in results]) + measurements = [measurement for item in results for measurement in item.measurements] + judgments = [judgment for item in results for judgment in item.judgments] + return OcrBatchResult( + files=results, + fields=fields, + measurements=measurements, + judgments=judgments, + requires_review=any(item.requires_review for item in results), + ) + + async def judge(self, measurements: list[OcrMeasurement]) -> list[OcrStandardJudgment]: + rules = list( + ( + await self.session.scalars( + select(StandardRule).where(StandardRule.status == "启用").order_by(StandardRule.id) + ) + ).all() + ) + return self._judge_measurements(measurements, rules) + + async def _recognize_one(self, upload: UploadFile, rules: list[StandardRule]) -> OcrFileResult: + content = await self._read_limited(upload) + content_type, suffix = self._detect_type(content) + original_name = Path(upload.filename or f"upload{suffix}").name + file_id = f"{uuid4().hex}{suffix}" + target = self.upload_path(file_id) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + try: + if content_type == "application/pdf": + raw_text, provider_confidence, page_count = await self.client.recognize_document(content, original_name) + engine = "paddle-vl-parser" + else: + raw_text, provider_confidence, page_count = await self.client.recognize_image(content) + engine = "webimage" + extracted = await self._extract(raw_text, provider_confidence) + fields, measurements, extraction_confidence = extracted + judgments = self._judge_measurements(measurements, rules) + warnings = self._warnings(fields, measurements, judgments, provider_confidence) + requires_review = bool(warnings) or any(field.needs_review for field in self._field_values(fields)) + return OcrFileResult( + file_id=file_id, + file_name=original_name, + content_type=content_type, + engine=engine, + raw_text=raw_text, + confidence=provider_confidence if provider_confidence is not None else extraction_confidence, + page_count=page_count, + fields=fields, + measurements=measurements, + judgments=judgments, + warnings=warnings, + requires_review=requires_review, + ) + except Exception: + target.unlink(missing_ok=True) + raise + finally: + await upload.close() + + async def _extract( + self, + text: str, + provider_confidence: float | None, + ) -> tuple[OcrExtractedFields, list[OcrMeasurement], float | None]: + fallback_fields = self._extract_fields_by_labels(text, provider_confidence) + fallback_measurements = self._extract_measurements(text, provider_confidence) + if not self.llm.configured: + return fallback_fields, fallback_measurements, provider_confidence + try: + data = await self.llm.extract_ocr_fields(text) + except Exception: # noqa: BLE001 - OCR remains usable when the optional entity extractor fails + return fallback_fields, fallback_measurements, provider_confidence + confidence = self._as_confidence(data.get("confidence")) + fields = self._fields_from_llm(data, confidence, fallback_fields) + measurements = self._measurements_from_llm(data.get("measurements"), text, confidence) + return fields, measurements or fallback_measurements, confidence + + def _fields_from_llm( + self, + data: dict[str, Any], + confidence: float | None, + fallback: OcrExtractedFields, + ) -> OcrExtractedFields: + values: dict[str, Any] = {} + for key in _FIELD_ALIASES: + value = data.get(key) + if key == "defect_level" and value: + value = str(value).upper().replace("级", "") + if value not in {"A", "B", "C"}: + value = None + if value is None or not str(value).strip(): + values[key] = getattr(fallback, key) + else: + values[key] = self._field(str(value).strip(), confidence) + occurred_on = self._as_date(data.get("occurred_on")) or fallback.occurred_on + return OcrExtractedFields(**values, occurred_on=occurred_on) + + def _measurements_from_llm( + self, + raw: Any, + source_text: str, + default_confidence: float | None, + ) -> list[OcrMeasurement]: + if not isinstance(raw, list): + return [] + results: list[OcrMeasurement] = [] + for item in raw[:50]: + if not isinstance(item, dict): + continue + name = str(item.get("item") or "").strip() + raw_value = str(item.get("raw_value") or item.get("value") or "").strip() + if ( + not name + or self._normalize(name) in {self._normalize(value) for value in _NON_MEASUREMENT_NAMES} + or not raw_value + or not self._numeric_appears(raw_value, source_text) + ): + continue + value = self._as_float(item.get("value")) + if value is None: + value = self._as_float(raw_value) + confidence = self._as_confidence(item.get("confidence")) or default_confidence + results.append( + OcrMeasurement( + item=name[:100], + raw_value=raw_value[:100], + value=value, + unit=str(item.get("unit") or "").strip()[:30] or self._unit_from(raw_value), + confidence=confidence, + needs_review=confidence is None or confidence < self.settings.ocr_judgment_confidence, + ) + ) + return results + + def _extract_fields_by_labels(self, text: str, confidence: float | None) -> OcrExtractedFields: + values: dict[str, OcrField] = {} + for field_name, aliases in _FIELD_ALIASES.items(): + found: str | None = None + for alias in aliases: + match = re.search(rf"{re.escape(alias)}\s*[::]?\s*([^\n||]{{1,120}})", text, re.IGNORECASE) + if match: + found = match.group(1).strip(" ::;;") + break + if field_name == "defect_level" and found: + level = re.search(r"\b([ABC])\s*级?\b", found, re.IGNORECASE) + found = level.group(1).upper() if level else None + values[field_name] = self._field(found, confidence) + occurred = None + date_match = re.search(r"(?:发生日期|日期)\s*[::]?\s*(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})", text) + if date_match: + occurred = self._as_date("-".join(date_match.groups())) + return OcrExtractedFields(**values, occurred_on=occurred) + + def _extract_measurements(self, text: str, confidence: float | None) -> list[OcrMeasurement]: + results: list[OcrMeasurement] = [] + seen: set[tuple[str, str]] = set() + pattern = re.compile( + rf"(?P[A-Za-z\u4e00-\u9fffΔδ()()·/\-]{{1,32}}?)\s*[::]?\s*" + rf"(?P[+\-]?\d+(?:\.\d+)?)\s*(?P{_UNIT_PATTERN})", + re.IGNORECASE, + ) + for line in text.splitlines(): + if not any(word.lower() in line.lower() for word in _MEASUREMENT_WORDS): + continue + for match in pattern.finditer(line): + name = match.group("item").strip(" ::;;-||") + raw_value = f"{match.group('value')}{match.group('unit') or ''}" + key = (name, raw_value) + if not name or key in seen: + continue + seen.add(key) + results.append( + OcrMeasurement( + item=name[:100], + raw_value=raw_value, + value=float(match.group("value")), + unit=match.group("unit") or None, + confidence=confidence, + needs_review=confidence is None or confidence < self.settings.ocr_judgment_confidence, + ) + ) + return results[:50] + + def _judge_measurements( + self, + measurements: list[OcrMeasurement], + rules: list[StandardRule], + ) -> list[OcrStandardJudgment]: + judgments: list[OcrStandardJudgment] = [] + for measurement in measurements: + rule = self._best_rule(measurement.item, rules) + if rule is None: + judgments.append( + OcrStandardJudgment( + measurement_item=measurement.item, + measured_value=measurement.raw_value, + result="未匹配标准", + reason="标准库中没有找到对应检测项", + ) + ) + continue + result, reason = self.evaluate_standard(measurement, rule.judge_standard) + if measurement.needs_review and result in {"合格", "不合格"}: + result = "待人工确认" + reason = ( + f"识别置信度低于 {self.settings.ocr_judgment_confidence:.2f}," + "需确认检测值后再采用自动判定" + ) + judgments.append( + OcrStandardJudgment( + measurement_item=measurement.item, + measured_value=measurement.raw_value, + standard_rule_id=rule.id, + risk_point=rule.risk_point, + standard_value=rule.judge_standard, + result=result, + basis=rule.regulation, + reason=reason, + ) + ) + return judgments + + @staticmethod + def _best_rule(item: str, rules: list[StandardRule]) -> StandardRule | None: + needle = OcrService._normalize(item) + if not needle: + return None + scored: list[tuple[float, StandardRule]] = [] + for rule in rules: + haystack = OcrService._normalize(f"{rule.risk_point} {rule.judge_standard}") + if needle in haystack or haystack in needle: + score = 2.0 + min(len(needle), len(haystack)) / 100 + else: + left = set(OcrService._bigrams(needle)) + right = set(OcrService._bigrams(haystack)) + score = len(left & right) / max(1, len(left | right)) + threshold = 0.45 if len(needle) <= 4 else 0.3 + if score >= threshold: + scored.append((score, rule)) + return max(scored, key=lambda pair: pair[0])[1] if scored else None + + @staticmethod + def evaluate_standard(measurement: OcrMeasurement, standard: str) -> tuple[str, str | None]: + if measurement.value is None: + return "待人工确认", "检测值不是可比较的数字" + normalized = standard.replace("~", "~").replace("—", "-").replace("至", "~") + range_match = re.search(r"(-?\d+(?:\.\d+)?)\s*(?:~|-)\s*(-?\d+(?:\.\d+)?)", normalized) + if range_match: + mismatch = OcrService._unit_mismatch(measurement.unit, normalized[range_match.end() :]) + if mismatch: + return "待人工确认", mismatch + lower, upper = map(float, range_match.groups()) + passed = lower <= measurement.value <= upper + return ("合格" if passed else "不合格"), None + comparator = re.search(r"(<=|>=|≤|≥|<|>)\s*(?:±\s*)?(-?\d+(?:\.\d+)?)", normalized) + if comparator: + mismatch = OcrService._unit_mismatch(measurement.unit, normalized[comparator.end() :]) + if mismatch: + return "待人工确认", mismatch + operator, raw_limit = comparator.groups() + limit = float(raw_limit) + value = abs(measurement.value) if "±" in comparator.group(0) else measurement.value + passed = { + "<=": value <= limit, + "≤": value <= limit, + ">=": value >= limit, + "≥": value >= limit, + "<": value < limit, + ">": value > limit, + }[operator] + return ("合格" if passed else "不合格"), None + return "待人工确认", "该标准不是系统可自动计算的数值范围" + + def _warnings( + self, + fields: OcrExtractedFields, + measurements: list[OcrMeasurement], + judgments: list[OcrStandardJudgment], + confidence: float | None, + ) -> list[str]: + warnings: list[str] = [] + if confidence is None: + warnings.append("服务未返回文字级置信度,请人工核对识别结果") + elif confidence < self.settings.ocr_review_confidence: + warnings.append("OCR 置信度低于 0.8,请重点核对标黄字段") + if not fields.product_name.value: + warnings.append("未识别到产品名称") + if not fields.description.value: + warnings.append("未识别到异常描述") + if measurements and any(item.result == "不合格" for item in judgments): + warnings.append("检测数值存在不合格项,请确认后提交审核") + return warnings + + async def _read_limited(self, upload: UploadFile) -> bytes: + limit = self.settings.ocr_max_file_mb * 1024 * 1024 + content = await upload.read(limit + 1) + if len(content) > limit: + raise AppException(f"单个文件不能超过 {self.settings.ocr_max_file_mb}MB", code=413, status_code=413) + if not content: + raise AppException("不能上传空文件", code=422, status_code=422) + return content + + @staticmethod + def _detect_type(content: bytes) -> tuple[str, str]: + if content.startswith(b"%PDF-"): + return "application/pdf", ".pdf" + if content.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png", ".png" + if content.startswith(b"\xff\xd8\xff"): + return "image/jpeg", ".jpg" + raise AppException("仅支持 JPG、PNG 或 PDF 文件", code=422, status_code=422) + + def upload_path(self, file_id: str) -> Path: + if not re.fullmatch(r"[0-9a-f]{32}\.(?:jpg|png|pdf)", file_id): + raise AppException("附件不存在", code=404, status_code=404) + root = self.settings.ocr_upload_dir.resolve() + return root / file_id + + @staticmethod + def _field(value: str | None, confidence: float | None) -> OcrField: + threshold = get_settings().ocr_review_confidence + return OcrField( + value=value, + confidence=confidence, + needs_review=not value or confidence is None or confidence < threshold, + ) + + @staticmethod + def _field_values(fields: OcrExtractedFields) -> list[OcrField]: + return [getattr(fields, key) for key in _FIELD_ALIASES] + + @staticmethod + def _merge_fields(items: list[OcrExtractedFields]) -> OcrExtractedFields: + merged: dict[str, Any] = {} + for key in _FIELD_ALIASES: + candidates = [getattr(item, key) for item in items if getattr(item, key).value] + merged[key] = max(candidates, key=lambda field: field.confidence or -1) if candidates else OcrField() + merged["occurred_on"] = next((item.occurred_on for item in items if item.occurred_on), None) + return OcrExtractedFields(**merged) + + @staticmethod + def _as_confidence(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return min(1.0, max(0.0, number)) + + @staticmethod + def _as_float(value: Any) -> float | None: + match = re.search(r"[+\-]?\d+(?:\.\d+)?", str(value or "")) + return float(match.group()) if match else None + + @staticmethod + def _as_date(value: Any) -> date | None: + if not value: + return None + try: + return date.fromisoformat(str(value).strip()[:10]) + except ValueError: + return None + + @staticmethod + def _unit_from(value: str) -> str | None: + match = re.search(_UNIT_PATTERN + r"$", value, re.IGNORECASE) + return match.group() if match and match.group() else None + + @staticmethod + def _unit_mismatch(measured_unit: str | None, standard_tail: str) -> str | None: + if not measured_unit: + return None + standard_unit = re.match(rf"\s*({_UNIT_PATTERN})", standard_tail, re.IGNORECASE) + expected = standard_unit.group(1) if standard_unit and standard_unit.group(1) else None + if not expected: + return None + def normalize(unit: str) -> str: + return unit.lower().replace(".", "·").replace("°c", "℃") + + if normalize(measured_unit) != normalize(expected): + return f"检测值单位 {measured_unit} 与标准单位 {expected} 不一致" + return None + + @staticmethod + def _numeric_appears(value: str, text: str) -> bool: + number = re.search(r"[+\-]?\d+(?:\.\d+)?", value) + return bool(number and number.group() in text) + + @staticmethod + def _normalize(value: str) -> str: + return re.sub(r"[^a-z0-9\u4e00-\u9fffΔδ]", "", value.lower()) + + @staticmethod + def _bigrams(value: str) -> list[str]: + return [value[index : index + 2] for index in range(max(1, len(value) - 1))] diff --git a/app/services/risk_items.py b/app/services/risk_items.py index 7f06c34..02bd07f 100644 --- a/app/services/risk_items.py +++ b/app/services/risk_items.py @@ -19,8 +19,10 @@ class RiskItemService: item = RiskItem( source_type=payload.source_type, source_file_id=payload.source_file_id, + occurred_on=payload.occurred_on, product_name=payload.product_name, product_category=payload.product_category, + material_type=payload.material_type, supplier=payload.supplier, factory=payload.factory, stage_new=payload.stage_new, @@ -28,6 +30,8 @@ class RiskItemService: defect_level=payload.defect_level, solution=payload.solution, photos=payload.photos, + recognition_confidence=payload.recognition_confidence, + ocr_payload=payload.ocr_payload, submitted_by=user.emp_no, submitted_at=datetime.now(UTC), status="待审核" if pre_result == "通过" else "待预审", @@ -111,9 +115,7 @@ class RiskItemService: raise AppException("审核人不能审核自己提交的风险项", code=403, status_code=403) record = await self.session.scalar( - select(ReviewRecord) - .where(ReviewRecord.risk_item_id == item_id) - .order_by(ReviewRecord.id.desc()) + select(ReviewRecord).where(ReviewRecord.risk_item_id == item_id).order_by(ReviewRecord.id.desc()) ) if record is None: raise AppException("审核记录不存在", code=409, status_code=409) @@ -124,7 +126,9 @@ class RiskItemService: if action == "通过": case = AnomalyCase( product_name=item.product_name, + date=item.occurred_on, product_category=item.product_category, + material_type=item.material_type, supplier=item.supplier, factory=item.factory, stage_new=item.stage_new, @@ -132,6 +136,7 @@ class RiskItemService: defect_level=item.defect_level, solution=item.solution, source_type=item.source_type, + source_file=item.source_file_id, import_fingerprint=f"risk-item-{item.id}", ) self.session.add(case) diff --git a/app/services/suppliers.py b/app/services/suppliers.py index af18c54..19d90f9 100644 --- a/app/services/suppliers.py +++ b/app/services/suppliers.py @@ -61,7 +61,8 @@ class SupplierProfileService: b_count = sum(self.defect_code(row.get("defect_level")) == "B" for row in rows) tier = self.tier_for(anomaly_total, a_count, b_count) categories = self._categories(rows) - score = await DatabaseRiskScoringService(self.session, as_of=self.as_of).score(supplier=supplier_name) + scoring = DatabaseRiskScoringService(self.session, as_of=self.as_of) + score = await scoring.score(supplier=supplier_name) return SupplierProfileData( supplier_name=supplier_name, tier=tier, @@ -70,7 +71,11 @@ class SupplierProfileService: cooperation_total=None, anomaly_total=anomaly_total, anomaly_rate=None, - frequency_alpha=self.alpha(anomaly_total), + frequency_alpha=self.alpha( + anomaly_total, + coefficient=scoring.frequency_coefficient, + cap=scoring.frequency_cap, + ), risk_score=score.score, main_category_count=len(categories), ), @@ -122,8 +127,8 @@ class SupplierProfileService: return value if value in {"A", "B", "C"} else None @staticmethod - def alpha(anomaly_count: int) -> float: - return round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) + def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float: + return round(min(cap, 1 + anomaly_count / 100 * coefficient), 2) @classmethod def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str: diff --git a/migrations/versions/20260907_0016_add_ocr_fields.py b/migrations/versions/20260907_0016_add_ocr_fields.py new file mode 100644 index 0000000..f8d13fb --- /dev/null +++ b/migrations/versions/20260907_0016_add_ocr_fields.py @@ -0,0 +1,37 @@ +"""Add OCR extraction and judgment fields to risk items.""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "20260907_0016" +down_revision: str | None = "20260907_0015" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("risk_items", sa.Column("occurred_on", sa.Date(), nullable=True)) + op.add_column("risk_items", sa.Column("material_type", sa.String(length=200), nullable=True)) + op.add_column("risk_items", sa.Column("recognition_confidence", sa.Numeric(5, 4), nullable=True)) + op.add_column( + "risk_items", + sa.Column("ocr_payload", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + ) + op.create_index("ix_risk_items_occurred_on", "risk_items", ["occurred_on"]) + op.create_index("ix_risk_items_material_type", "risk_items", ["material_type"]) + op.execute("COMMENT ON COLUMN risk_items.occurred_on IS 'OCR或人工录入的异常发生日期。'") + op.execute("COMMENT ON COLUMN risk_items.material_type IS 'OCR或人工录入的异常物料类型。'") + op.execute("COMMENT ON COLUMN risk_items.recognition_confidence IS 'OCR综合识别置信度,范围0到1。'") + op.execute("COMMENT ON COLUMN risk_items.ocr_payload IS 'OCR检测数值、标准判定和人工确认前的结构化结果。'") + + +def downgrade() -> None: + op.drop_index("ix_risk_items_material_type", table_name="risk_items") + op.drop_index("ix_risk_items_occurred_on", table_name="risk_items") + op.drop_column("risk_items", "ocr_payload") + op.drop_column("risk_items", "recognition_confidence") + op.drop_column("risk_items", "material_type") + op.drop_column("risk_items", "occurred_on") diff --git a/migrations/versions/20260907_0017_four_role_model_config.py b/migrations/versions/20260907_0017_four_role_model_config.py new file mode 100644 index 0000000..2835784 --- /dev/null +++ b/migrations/versions/20260907_0017_four_role_model_config.py @@ -0,0 +1,103 @@ +"""Apply the four-role matrix and add configurable model weights.""" + +import json +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from app.domain.model_config import DEFAULT_WEIGHTS +from sqlalchemy.dialects import postgresql + +revision: str = "20260907_0017" +down_revision: str | None = "20260907_0016" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +PERMISSIONS = ( + ("普通用户", "risk.assess", "L1", True, True), + ("普通用户", "standard.read", "L1", True, False), + ("普通用户", "supplier.read", "L1", True, False), + ("product_manager", "risk.assess", "L1", True, True), + ("product_manager", "risk_item.submit", "L1", True, True), + ("product_manager", "risk_item.review", "L2", True, True), + ("product_manager", "standard.read", "L1", True, False), + ("product_manager", "supplier.read", "L2", True, False), + ("reviewer", "risk.assess", "L1", True, True), + ("reviewer", "risk_item.review", "L2", True, True), + ("reviewer", "standard.read", "L1", True, False), + ("reviewer", "supplier.read", "L2", True, False), + ("admin", "risk.assess", "L3", True, True), + ("admin", "risk_item.submit", "L3", True, True), + ("admin", "risk_item.review", "L3", True, True), + ("admin", "model_weight.manage", "L3", True, True), + ("admin", "rbac.manage", "L3", True, True), + ("admin", "standard.read", "L3", True, True), + ("admin", "standard.manage", "L3", True, True), + ("admin", "supplier.read", "L3", True, True), +) + + +def upgrade() -> None: + op.create_table( + "risk_model_configs", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("weights", postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column("frequency_coefficient", sa.Numeric(5, 4), nullable=False), + sa.Column("frequency_cap", sa.Numeric(4, 2), nullable=False), + sa.Column("updated_by", sa.String(length=20), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + connection = op.get_bind() + connection.execute( + sa.text( + """ + INSERT INTO risk_model_configs (id, weights, frequency_coefficient, frequency_cap) + VALUES (1, CAST(:weights AS jsonb), 0.3, 1.5) + """ + ), + {"weights": json.dumps(DEFAULT_WEIGHTS, ensure_ascii=False)}, + ) + connection.execute( + sa.text( + """ + UPDATE user_accounts + SET role = CASE + WHEN role IN ('dev', 'manager') THEN 'product_manager' + WHEN role IN ('qc', 'reviewer') THEN 'reviewer' + WHEN role IN ('procurement', 'ops', '普通用户') THEN '普通用户' + ELSE role + END, + updated_at = now() + """ + ) + ) + connection.execute(sa.text("DELETE FROM role_permissions")) + statement = sa.text( + """ + INSERT INTO role_permissions (role, func_key, data_level, can_view, can_operate) + VALUES (:role, :func_key, :data_level, :can_view, :can_operate) + """ + ) + for role, func_key, data_level, can_view, can_operate in PERMISSIONS: + connection.execute( + statement, + { + "role": role, + "func_key": func_key, + "data_level": data_level, + "can_view": can_view, + "can_operate": can_operate, + }, + ) + op.execute("COMMENT ON TABLE risk_model_configs IS '管理员维护的七维权重与频次系数配置。'") + op.execute("COMMENT ON COLUMN user_accounts.role IS '角色:admin、product_manager、reviewer 或 普通用户。'") + + +def downgrade() -> None: + connection = op.get_bind() + connection.execute( + sa.text("UPDATE user_accounts SET role = 'manager', updated_at = now() WHERE role = 'product_manager'") + ) + connection.execute(sa.text("DELETE FROM role_permissions")) + op.drop_table("risk_model_configs") diff --git a/pyproject.toml b/pyproject.toml index 7a738ba..db007ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "fastapi>=0.115,<1.0", "httpx>=0.27,<1.0", "openpyxl>=3.1,<4.0", + "python-multipart>=0.0.9,<1.0", "PyJWT>=2.8,<3.0", "pydantic>=2.8,<3.0", "pydantic-settings>=2.4,<3.0", diff --git a/requirements.txt b/requirements.txt index 33183a0..6a14f9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ asyncpg>=0.29,<1.0 fastapi>=0.115,<1.0 httpx>=0.27,<1.0 openpyxl>=3.1,<4.0 +python-multipart>=0.0.9,<1.0 PyJWT>=2.8,<3.0 pydantic>=2.8,<3.0 pydantic-settings>=2.4,<3.0 diff --git a/tests/test_auth.py b/tests/test_auth.py index 6051758..b91d328 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -2,9 +2,9 @@ from app.services.dingtalk_auth import DingTalkAuthService def test_dingtalk_role_mapping() -> None: - assert DingTalkAuthService.map_role("产品开发部", "工程师") == "dev" - assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc" - assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement" + assert DingTalkAuthService.map_role("产品部", "产品经理") == "product_manager" + assert DingTalkAuthService.map_role("品控部", "质量主管") == "普通用户" + assert DingTalkAuthService.map_role("采购部", "采购专员") == "普通用户" assert DingTalkAuthService.map_role("", "审核员") == "reviewer" assert DingTalkAuthService.map_role("", "", is_admin=True) == "admin" assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户" diff --git a/tests/test_baidu_ocr.py b/tests/test_baidu_ocr.py new file mode 100644 index 0000000..0c18144 --- /dev/null +++ b/tests/test_baidu_ocr.py @@ -0,0 +1,132 @@ +import json + +import httpx +import pytest +from app.schemas.ocr import OcrMeasurement +from app.services.baidu_ocr import BaiduOcrClient +from app.services.ocr import OcrService + + +@pytest.mark.asyncio +async def test_webimage_recognition_returns_text_and_confidence() -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "token", "expires_in": 3600}) + assert request.url.path.endswith("/webimage") + return httpx.Response( + 200, + json={ + "words_result": [ + {"words": "产品名称:测试面霜", "probability": {"average": 0.92}}, + {"words": "pH:5.6", "probability": {"average": 0.88}}, + ] + }, + ) + + client = BaiduOcrClient(httpx.MockTransport(handler)) + client.api_key = "api-key" + client.secret_key = "secret-key" + text, confidence, pages = await client.recognize_image(b"fake-image") + + assert text == "产品名称:测试面霜\npH:5.6" + assert confidence == pytest.approx(0.9) + assert pages == 1 + + +@pytest.mark.asyncio +async def test_paddle_document_recognition_polls_and_downloads_result() -> None: + calls = {"query": 0} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/token"): + return httpx.Response(200, json={"access_token": "token", "expires_in": 3600}) + if request.url.path.endswith("/task/query"): + calls["query"] += 1 + status = "processing" if calls["query"] == 1 else "success" + result = {"status": status} + if status == "success": + result["parse_result_url"] = "https://mock.bcebos.com/result.json" + return httpx.Response(200, json={"error_code": 0, "result": result}) + if request.url.path.endswith("/task"): + return httpx.Response(200, json={"error_code": 0, "result": {"task_id": "task-1"}}) + if request.url.path.endswith("/result.json"): + return httpx.Response( + 200, + content=json.dumps( + { + "pages": [ + { + "text": "产品名称:测试面霜", + "tables": [{"markdown": "|检测项|值|\n|pH|5.6|"}], + } + ] + }, + ensure_ascii=False, + ).encode(), + ) + raise AssertionError(f"unexpected URL: {request.url}") + + client = BaiduOcrClient(httpx.MockTransport(handler)) + client.api_key = "api-key" + client.secret_key = "secret-key" + client.poll_interval = 0.001 + text, confidence, pages = await client.recognize_document(b"%PDF-test", "report.pdf") + + assert "测试面霜" in text + assert "|pH|5.6|" in text + assert confidence is None + assert pages == 1 + assert calls["query"] == 2 + + +@pytest.mark.parametrize( + ("raw_value", "value", "standard", "expected"), + [ + ("5.6", 5.6, "pH 4.5-6.5", "合格"), + ("3.8", 3.8, "色差 ΔE≤3.0", "不合格"), + ("+18%", 18, "喷出量偏差≤±15%", "不合格"), + ("0.9 N·m", 0.9, "开启力矩 0.8~3.2N·m", "合格"), + ], +) +def test_standard_numeric_evaluation(raw_value: str, value: float, standard: str, expected: str) -> None: + measurement = OcrMeasurement( + item="检测项", + raw_value=raw_value, + value=value, + confidence=0.95, + needs_review=False, + ) + result, reason = OcrService.evaluate_standard(measurement, standard) + + assert result == expected + assert reason is None + + +def test_upload_type_is_detected_from_content_not_filename() -> None: + assert OcrService._detect_type(b"%PDF-1.7") == ("application/pdf", ".pdf") + assert OcrService._detect_type(b"\x89PNG\r\n\x1a\nrest") == ("image/png", ".png") + assert OcrService._detect_type(b"\xff\xd8\xffrest") == ("image/jpeg", ".jpg") + + +def test_standard_judgment_rejects_unit_mismatch() -> None: + measurement = OcrMeasurement( + item="铅含量", + raw_value="8mg/L", + value=8, + unit="mg/L", + confidence=0.95, + needs_review=False, + ) + + result, reason = OcrService.evaluate_standard(measurement, "铅≤10mg/kg") + + assert result == "待人工确认" + assert reason == "检测值单位 mg/L 与标准单位 mg/kg 不一致" + + +def test_generic_quantity_does_not_fuzzy_match_a_standard() -> None: + class Rule: + risk_point = "销售包装可视面及最小销售单元完整标注" + judge_standard = "中英文标签内容完整" + + assert OcrService._best_rule("数量", [Rule()]) is None diff --git a/tests/test_database_risk_scoring.py b/tests/test_database_risk_scoring.py index 242b019..882a4c3 100644 --- a/tests/test_database_risk_scoring.py +++ b/tests/test_database_risk_scoring.py @@ -1,7 +1,11 @@ from datetime import date +from decimal import Decimal +from types import SimpleNamespace import pytest +from app.schemas.model_config import ModelWeightConfigUpdate from app.services.database_risk_scoring import DatabaseRiskScoringService +from pydantic import ValidationError def _row(**values): @@ -63,3 +67,52 @@ async def test_supplier_dimension_does_not_count_factory_records() -> None: supplier_dimension = result.dimensions[0] assert supplier_dimension.score == 70 assert supplier_dimension.raw_value == 0 + + +@pytest.mark.asyncio +async def test_score_uses_persisted_weight_and_frequency_config() -> None: + configured_weights = { + "供应商表现": 30, + "物料品类": 15, + "产品品类": 15, + "加工厂质量": 15, + "标准符合性": 10, + "节点阶段": 10, + "缺陷等级": 5, + } + + class Session: + async def get(self, _model, _identifier): + return SimpleNamespace( + weights=configured_weights, + frequency_coefficient=Decimal("0.5"), + frequency_cap=Decimal("1.8"), + ) + + service = DatabaseRiskScoringService(Session(), as_of=date(2026, 9, 4)) + + async def load_rows(): + return [_row(), _row(product_name="洗发水 250ml", defect_level="B")] + + service._load_rows = load_rows # type: ignore[method-assign] + result = await service.score(category="洗发水", material="瓶子", supplier="祥印", stage="产品开发") + + assert [item.weight for item in result.dimensions] == [30, 15, 15, 15, 10, 10, 5] + assert result.dimensions[0].score == 95.95 + + +def test_weight_config_requires_exactly_one_hundred_percent() -> None: + with pytest.raises(ValidationError, match="合计必须为100%"): + ModelWeightConfigUpdate( + weights={ + "供应商表现": 20, + "物料品类": 20, + "产品品类": 15, + "加工厂质量": 15, + "标准符合性": 10, + "节点阶段": 10, + "缺陷等级": 5, + }, + frequency_coefficient=0.3, + frequency_cap=1.5, + ) diff --git a/tests/test_models.py b/tests/test_models.py index 0cb8b82..9643dc3 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,6 +7,7 @@ from app.models import ( # noqa: F401 ReviewRecord, RiskAssessment, RiskItem, + RiskModelConfig, RolePermission, StandardRule, SupplierProfile, @@ -24,6 +25,7 @@ def test_prd_core_tables_are_registered() -> None: "review_records", "risk_assessments", "risk_items", + "risk_model_configs", "role_permissions", "standard_rules", "supplier_profiles",