实现OCR异常识别并完善角色权限与权重配置

This commit is contained in:
kx_guo 2026-09-07 17:49:19 +08:00
parent 1d58bb8913
commit ab77dd9797
29 changed files with 1455 additions and 57 deletions

View File

@ -26,6 +26,20 @@ 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 模型配置 ==================== # ==================== 主 LLM 模型配置 ====================
# 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。 # 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。
RISK_LLM_API_KEY= RISK_LLM_API_KEY=

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ logs/
dist/ dist/
build/ build/
data/ data/
.runtime-uploads/

View File

@ -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`。 审核接口:`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` 健康检查:`GET http://127.0.0.1:18474/api/v1/health`
数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。 数据库表结构和字段释义通过 Alembic 管理。执行 `python -m alembic upgrade head` 会创建或更新表、索引、外键及 PostgreSQL 表/字段注释。

View File

@ -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.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.ocr import router as ocr_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
@ -13,6 +15,8 @@ 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(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)

View File

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

50
app/api/v1/ocr.py Normal file
View File

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

View File

@ -1,6 +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, Literal from typing import Annotated, Literal
from pydantic import AliasChoices, Field, SecretStr, field_validator 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_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,

View File

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

View File

@ -8,6 +8,7 @@ from app.models.entities import (
ReviewRecord, ReviewRecord,
RiskAssessment, RiskAssessment,
RiskItem, RiskItem,
RiskModelConfig,
RolePermission, RolePermission,
StandardRule, StandardRule,
SupplierProfile, SupplierProfile,
@ -22,6 +23,7 @@ __all__ = [
"ReviewRecord", "ReviewRecord",
"RiskAssessment", "RiskAssessment",
"RiskItem", "RiskItem",
"RiskModelConfig",
"RolePermission", "RolePermission",
"StandardRule", "StandardRule",
"SupplierProfile", "SupplierProfile",

View File

@ -119,9 +119,7 @@ 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__ = ( __table_args__ = (Index("ix_conversations_user_updated", "user_id", "updated_at"),)
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(
@ -149,9 +147,7 @@ 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__ = ( __table_args__ = (Index("ix_conversation_messages_conversation_created", "conversation_id", "created_at"),)
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(
@ -176,13 +172,17 @@ 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))
@ -190,6 +190,8 @@ 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)
@ -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): 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

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

73
app/schemas/ocr.py Normal file
View File

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

View File

@ -1,6 +1,7 @@
"""Risk item submission and review contracts.""" """Risk item submission and review contracts."""
from datetime import datetime from datetime import date, datetime
from typing import Any
from pydantic import Field from pydantic import Field
@ -10,8 +11,10 @@ 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)
@ -19,11 +22,15 @@ 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)
@ -31,14 +38,18 @@ 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
@ -46,6 +57,8 @@ 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

179
app/services/baidu_ocr.py Normal file
View File

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

View File

@ -9,18 +9,9 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.domain.risk_engine import RiskLevel
from app.models.entities import AnomalyCase from app.models.entities import AnomalyCase, RiskModelConfig
DEFAULT_WEIGHTS = {
"供应商表现": 25.0,
"物料品类": 20.0,
"产品品类": 15.0,
"加工厂质量": 15.0,
"标准符合性": 10.0,
"节点阶段": 10.0,
"缺陷等级": 5.0,
}
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理") STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
@ -54,6 +45,9 @@ class DatabaseRiskScoringService:
def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None: def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None:
self.session = session self.session = session
self.as_of = as_of or date.today() 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( async def score(
self, self,
@ -65,6 +59,7 @@ class DatabaseRiskScoringService:
stage: str | None = None, stage: str | None = None,
defect_level: str | None = None, defect_level: str | None = None,
) -> DatabaseRiskScore: ) -> DatabaseRiskScore:
await self._load_model_config()
rows = await self._load_rows() rows = await self._load_rows()
category_name = normalize_category(category) category_name = normalize_category(category)
material_name = normalize_material(material) material_name = normalize_material(material)
@ -78,14 +73,14 @@ class DatabaseRiskScoringService:
DimensionScore( DimensionScore(
name="加工厂质量", name="加工厂质量",
score=50.0, score=50.0,
weight=DEFAULT_WEIGHTS["加工厂质量"], weight=self.weights["加工厂质量"],
status="default", status="default",
reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入", reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入",
), ),
DimensionScore( DimensionScore(
name="标准符合性", name="标准符合性",
score=50.0, score=50.0,
weight=DEFAULT_WEIGHTS["标准符合性"], weight=self.weights["标准符合性"],
status="default", status="default",
reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入", reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入",
), ),
@ -95,6 +90,19 @@ class DatabaseRiskScoringService:
score = round(sum(item.score * item.weight / 100 for item in dimensions), 2) score = round(sum(item.score * item.weight / 100 for item in dimensions), 2)
return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions) 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]]: async def _load_rows(self) -> list[dict[str, Any]]:
start = self.as_of - timedelta(days=365 * 3) start = self.as_of - timedelta(days=365 * 3)
statement = select( statement = select(
@ -114,7 +122,7 @@ class DatabaseRiskScoringService:
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()] return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore: def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore:
weight = DEFAULT_WEIGHTS["供应商表现"] weight = self.weights["供应商表现"]
if not party_name: if not party_name:
return DimensionScore( return DimensionScore(
name="供应商表现", name="供应商表现",
@ -155,7 +163,7 @@ class DatabaseRiskScoringService:
else 0 else 0
) )
base = max(0.0, min(100.0, base + trend_adjustment)) 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)) score = min(100.0, round(base * alpha, 2))
return DimensionScore( return DimensionScore(
name="供应商表现", name="供应商表现",
@ -167,9 +175,8 @@ class DatabaseRiskScoringService:
raw_value=count, raw_value=count,
) )
@staticmethod def _material_dimension(self, rows: list[dict[str, Any]], material: str | None) -> DimensionScore:
def _material_dimension(rows: list[dict[str, Any]], material: str | None) -> DimensionScore: weight = self.weights["物料品类"]
weight = DEFAULT_WEIGHTS["物料品类"]
if not material: if not material:
return DimensionScore( return DimensionScore(
name="物料品类", name="物料品类",
@ -196,9 +203,8 @@ class DatabaseRiskScoringService:
raw_value=count, raw_value=count,
) )
@staticmethod def _category_dimension(self, rows: list[dict[str, Any]], category: str | None) -> DimensionScore:
def _category_dimension(rows: list[dict[str, Any]], category: str | None) -> DimensionScore: weight = self.weights["产品品类"]
weight = DEFAULT_WEIGHTS["产品品类"]
if not category: if not category:
return DimensionScore( return DimensionScore(
name="产品品类", name="产品品类",
@ -232,9 +238,8 @@ class DatabaseRiskScoringService:
raw_value=count, raw_value=count,
) )
@staticmethod def _stage_dimension(self, rows: list[dict[str, Any]], stage: str | None) -> DimensionScore:
def _stage_dimension(rows: list[dict[str, Any]], stage: str | None) -> DimensionScore: weight = self.weights["节点阶段"]
weight = DEFAULT_WEIGHTS["节点阶段"]
if not stage: if not stage:
return DimensionScore( return DimensionScore(
name="节点阶段", name="节点阶段",
@ -266,15 +271,15 @@ class DatabaseRiskScoringService:
raw_value=share * 100, raw_value=share * 100,
) )
@staticmethod
def _defect_dimension( def _defect_dimension(
self,
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
category: str | None, category: str | None,
material: str | None, material: str | None,
party_name: str | None, party_name: str | None,
defect_level: str | None, defect_level: str | None,
) -> DimensionScore: ) -> DimensionScore:
weight = DEFAULT_WEIGHTS["缺陷等级"] weight = self.weights["缺陷等级"]
if not category and not material and not party_name and not defect_level: if not category and not material and not party_name and not defect_level:
return DimensionScore( return DimensionScore(
name="缺陷等级", name="缺陷等级",

View File

@ -170,21 +170,15 @@ 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 organization snapshots to the PRD seven-role model.""" """Map DingTalk organization snapshots to the four-role administration model."""
text = f"{dept} {title}".lower() text = f"{dept} {title}".lower()
if is_admin or "it" in text or "信息" in text or "系统" in text: if is_admin or "it" in text or "信息" in text or "系统" in text:
return "admin" return "admin"
if "审核" in text or "review" in text: if "审核" in text or "review" in text:
return "reviewer" return "reviewer"
if "采购" in text or "procurement" in text: if "产品经理" in text or "product manager" in text or (
return "procurement" "产品" in dept and ("经理" in title or "主管" in title)
if "品控" in text or "质量" in text or "qc" in text: ):
return "qc" return "product_manager"
if "管理" in text or "主管" in text or "manager" in text:
return "manager"
if "运营" in text or "ops" in text:
return "ops"
if "开发" in text or "产品" in text or "dev" in text:
return "dev"
return "普通用户" return "普通用户"

View File

@ -135,6 +135,40 @@ class LLMClient:
status_code=503, status_code=503,
) from fallback_exc ) 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]: 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.""" """Yield reply deltas and fail over only before any primary text is emitted."""
@ -179,9 +213,8 @@ class LLMClient:
}, },
{ {
"role": "user", "role": "user",
"content": "以下是本次风险评估数据,请生成预防建议:\n" + json.dumps( "content": "以下是本次风险评估数据,请生成预防建议:\n"
context, ensure_ascii=False, separators=(",", ":") + json.dumps(context, ensure_ascii=False, separators=(",", ":")),
),
}, },
] ]
try: try:

View File

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

507
app/services/ocr.py Normal file
View File

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

@ -19,8 +19,10 @@ 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,
@ -28,6 +30,8 @@ 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 "待预审",
@ -111,9 +115,7 @@ 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) select(ReviewRecord).where(ReviewRecord.risk_item_id == item_id).order_by(ReviewRecord.id.desc())
.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)
@ -124,7 +126,9 @@ 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,
@ -132,6 +136,7 @@ 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

@ -61,7 +61,8 @@ class SupplierProfileService:
b_count = sum(self.defect_code(row.get("defect_level")) == "B" 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) tier = self.tier_for(anomaly_total, a_count, b_count)
categories = self._categories(rows) 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( return SupplierProfileData(
supplier_name=supplier_name, supplier_name=supplier_name,
tier=tier, tier=tier,
@ -70,7 +71,11 @@ class SupplierProfileService:
cooperation_total=None, cooperation_total=None,
anomaly_total=anomaly_total, anomaly_total=anomaly_total,
anomaly_rate=None, 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, risk_score=score.score,
main_category_count=len(categories), main_category_count=len(categories),
), ),
@ -122,8 +127,8 @@ class SupplierProfileService:
return value if value in {"A", "B", "C"} else None return value if value in {"A", "B", "C"} else None
@staticmethod @staticmethod
def alpha(anomaly_count: int) -> float: def alpha(anomaly_count: int, *, coefficient: float = 0.3, cap: float = 1.5) -> float:
return round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) return round(min(cap, 1 + anomaly_count / 100 * coefficient), 2)
@classmethod @classmethod
def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str: def tier_for(cls, anomaly_count: int, a_count: int, b_count: int) -> str:

View File

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

View File

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

View File

@ -14,6 +14,7 @@ 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",
"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,6 +3,7 @@ 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
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

@ -2,9 +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("产品开发部", "工程师") == "dev" assert DingTalkAuthService.map_role("产品部", "产品经理") == "product_manager"
assert DingTalkAuthService.map_role("品控部", "质量主管") == "qc" assert DingTalkAuthService.map_role("品控部", "质量主管") == "普通用户"
assert DingTalkAuthService.map_role("采购部", "采购专员") == "procurement" assert DingTalkAuthService.map_role("采购部", "采购专员") == "普通用户"
assert DingTalkAuthService.map_role("", "审核员") == "reviewer" 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("未知部门", "普通员工") == "普通用户" assert DingTalkAuthService.map_role("未知部门", "普通员工") == "普通用户"

132
tests/test_baidu_ocr.py Normal file
View File

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

View File

@ -1,7 +1,11 @@
from datetime import date from datetime import date
from decimal import Decimal
from types import SimpleNamespace
import pytest import pytest
from app.schemas.model_config import ModelWeightConfigUpdate
from app.services.database_risk_scoring import DatabaseRiskScoringService from app.services.database_risk_scoring import DatabaseRiskScoringService
from pydantic import ValidationError
def _row(**values): def _row(**values):
@ -63,3 +67,52 @@ async def test_supplier_dimension_does_not_count_factory_records() -> None:
supplier_dimension = result.dimensions[0] supplier_dimension = result.dimensions[0]
assert supplier_dimension.score == 70 assert supplier_dimension.score == 70
assert supplier_dimension.raw_value == 0 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

@ -7,6 +7,7 @@ from app.models import ( # noqa: F401
ReviewRecord, ReviewRecord,
RiskAssessment, RiskAssessment,
RiskItem, RiskItem,
RiskModelConfig,
RolePermission, RolePermission,
StandardRule, StandardRule,
SupplierProfile, SupplierProfile,
@ -24,6 +25,7 @@ def test_prd_core_tables_are_registered() -> None:
"review_records", "review_records",
"risk_assessments", "risk_assessments",
"risk_items", "risk_items",
"risk_model_configs",
"role_permissions", "role_permissions",
"standard_rules", "standard_rules",
"supplier_profiles", "supplier_profiles",