218 lines
8.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Assess structured product data without creating conversations or re-parsing fields with an LLM."""
import asyncio
import logging
from datetime import date
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.entities import AnomalyCase, StandardRule
from app.schemas.integration_risk import IntegrationRiskRequest
from app.schemas.risk_prediction import (
CategoryWarning,
RiskAssessmentResult,
RiskDimensionScore,
RiskFactorResult,
StandardJudgment,
SupplierFrequency,
)
from app.services.database_risk_scoring import (
DatabaseRiskScoringService,
normalize_category,
normalize_material,
)
from app.services.llm_client import LLMClient
from app.services.purchase_orders import calendar_year_window, supplier_order_counts
from app.services.rag import AnomalyCaseRAG
from app.services.supplier_identity import resolve_supplier
logger = logging.getLogger(__name__)
class ExternalRiskReportService:
def __init__(self, session: AsyncSession):
self.session = session
async def assess(self, payload: IntegrationRiskRequest, as_of: date) -> RiskAssessmentResult:
product = payload.assessment
start, _ = calendar_year_window(as_of, 3)
# Bound both scoring and case retrieval by the supplied cutoff date.
fields = (
AnomalyCase.id,
AnomalyCase.date,
AnomalyCase.product_name,
AnomalyCase.product_category,
AnomalyCase.material_type,
AnomalyCase.supplier,
AnomalyCase.factory,
AnomalyCase.stage_new,
AnomalyCase.defect_level,
AnomalyCase.anomaly_type,
AnomalyCase.description,
AnomalyCase.root_cause,
AnomalyCase.solution,
AnomalyCase.result,
)
rows = [
dict(row)
for row in (
await self.session.execute(
select(*fields).where(
AnomalyCase.date >= start,
AnomalyCase.date <= as_of,
)
)
)
.mappings()
.all()
]
identity = await resolve_supplier(self.session, product.supplier)
supplier = identity.canonical_name if identity else product.supplier
cooperation = await supplier_order_counts(self.session, supplier, start, as_of) if supplier else 0
score = await DatabaseRiskScoringService(self.session, as_of=as_of).score(
category=product.product_category,
material=product.material_type,
supplier=supplier,
factory=product.factory,
stage=product.stage_new,
defect_level=product.defect_level,
rows=rows,
cooperation_count=cooperation or 0,
)
query = ",".join(
value
for value in (
product.product_name,
product.product_category,
product.material_type,
supplier,
product.factory,
product.stage_new,
product.description,
)
if value
)
retrieved = await AnomalyCaseRAG(self.session).search(
query=query,
category=product.product_category,
material=product.material_type,
supplier=supplier,
factory=product.factory,
stage=product.stage_new,
rows=rows,
)
category = normalize_category(product.product_category)
material = normalize_material(product.material_type)
terms = list(
dict.fromkeys(
value
for value in (
product.product_category,
*(category or "").split("/"),
material,
*(term for term in ("PET", "PP", "软管", "泵头", "瓶", "纸盒", "密封", "相容性") if term in query),
)
if value
)
)
clauses = [
column.ilike(f"%{term}%")
for term in terms
for column in (
StandardRule.category_lv1,
StandardRule.category_lv2,
StandardRule.category_lv3,
StandardRule.risk_point,
)
]
standard_rows = list(
(
await self.session.scalars(
select(StandardRule)
.where(
StandardRule.status == "启用",
or_(*clauses),
)
.order_by(StandardRule.id)
.limit(6)
)
).all()
)
standards = [
StandardJudgment(
category=row.category_lv1,
risk_point=row.risk_point,
rule=row.judge_standard,
source=row.regulation or "标准文件.xlsx",
status="待检测",
)
for row in standard_rows
]
dimensions = [
RiskDimensionScore(
dimension=item.name,
score=item.score,
weight=item.weight,
status=item.status,
reason=item.reason,
sample_size=item.sample_size,
raw_value=item.raw_value,
)
for item in score.dimensions
]
category_dimension = next(item for item in score.dimensions if item.name == "产品品类")
supplier_dimension = next(item for item in score.dimensions if item.name == "供应商表现")
result = RiskAssessmentResult(
subject_id=product.subject_id,
score=score.score,
level=score.level,
model_version="database-seven-dimension-v1",
factors=[
RiskFactorResult(
name=item.name,
value=round(100 - item.score, 2),
weight=item.weight,
contribution=round(item.score * item.weight / 100, 2),
)
for item in score.dimensions
],
dimension_scores=dimensions,
category_warning=CategoryWarning(
category=category or "无法归类",
anomaly_count=category_dimension.sample_size or 0,
risk_level="案例较少" if (category_dimension.sample_size or 0) < 10 else score.level.value,
suggestion=category_dimension.reason,
),
supplier_freq=SupplierFrequency(
supplier_name=supplier,
cooperation_count=cooperation if cooperation else None,
anomaly_count=supplier_dimension.sample_size,
alpha=supplier_dimension.frequency_alpha,
frequency_basis=f"{start} 至 {as_of} 采购订单去重单据数",
note=supplier_dimension.reason,
data_available=bool(cooperation),
),
standard_judgment=standards,
similar_cases=[item.case for item in retrieved],
)
fallback = [
"打样阶段完成内容物与包装材料相容性、密封性及加速老化验证。",
"结合历史异常和命中标准制定来料检验、过程巡检和首批试产验证清单。",
"补充供应商、加工厂及实际检测数据,由品控复核默认分维度后再作放行决策。",
"对验证项明确责任人、完成节点及验收依据,并将结果回填形成闭环。",
]
try:
async with asyncio.timeout(20):
result.ai_suggestions = await LLMClient().generate_prevention_suggestions(
{
"input": product.model_dump(mode="json", by_alias=True),
"assessment": result.model_dump(mode="json", by_alias=True),
}
)
except Exception as exc: # noqa: BLE001 - rules and actual scores remain usable without the LLM
logger.warning("External report suggestions unavailable: %s", exc.__class__.__name__)
result.ai_suggestions = fallback
result.alert_text = "AI 建议服务不可用,本报告使用固定验证建议;评分仍来自数据库及规则。"
return result