dev #1
14
.env.example
14
.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=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ logs/
|
||||
dist/
|
||||
build/
|
||||
data/
|
||||
.runtime-uploads/
|
||||
|
||||
@ -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 表/字段注释。
|
||||
|
||||
@ -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)
|
||||
|
||||
30
app/api/v1/model_config.py
Normal file
30
app/api/v1/model_config.py
Normal 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
50
app/api/v1/ocr.py
Normal 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)
|
||||
@ -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,
|
||||
|
||||
24
app/domain/model_config.py
Normal file
24
app/domain/model_config.py
Normal 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
|
||||
@ -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",
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
32
app/schemas/model_config.py
Normal file
32
app/schemas/model_config.py
Normal 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
73
app/schemas/ocr.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
179
app/services/baidu_ocr.py
Normal file
179
app/services/baidu_ocr.py
Normal 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))
|
||||
@ -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="缺陷等级",
|
||||
|
||||
@ -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 "普通用户"
|
||||
|
||||
@ -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:
|
||||
|
||||
57
app/services/model_config.py
Normal file
57
app/services/model_config.py
Normal 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
507
app/services/ocr.py
Normal 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))]
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
37
migrations/versions/20260907_0016_add_ocr_fields.py
Normal file
37
migrations/versions/20260907_0016_add_ocr_fields.py
Normal 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")
|
||||
103
migrations/versions/20260907_0017_four_role_model_config.py
Normal file
103
migrations/versions/20260907_0017_four_role_model_config.py
Normal 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")
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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("未知部门", "普通员工") == "普通用户"
|
||||
|
||||
132
tests/test_baidu_ocr.py
Normal file
132
tests/test_baidu_ocr.py
Normal 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
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user