实现OCR异常识别并完善角色权限与权重配置
This commit is contained in:
parent
1d58bb8913
commit
ab77dd9797
14
.env.example
14
.env.example
@ -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
1
.gitignore
vendored
@ -11,3 +11,4 @@ logs/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
data/
|
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`。
|
审核接口:`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 表/字段注释。
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
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."""
|
"""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,
|
||||||
|
|||||||
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,
|
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",
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
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."""
|
"""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
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 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="缺陷等级",
|
||||||
|
|||||||
@ -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 "普通用户"
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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(
|
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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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",
|
"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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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 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,
|
||||||
|
)
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user