统一风险报告卡片与导出数据逻辑

This commit is contained in:
kx_guo 2026-09-09 17:04:02 +08:00
parent 9e13c95ba8
commit fd8369e2f3
5 changed files with 263 additions and 1 deletions

View File

@ -1,5 +1,7 @@
"""Risk prediction request and response contracts.""" """Risk prediction request and response contracts."""
from typing import Literal
from pydantic import Field from pydantic import Field
from app.domain.risk_engine import RiskLevel from app.domain.risk_engine import RiskLevel
@ -103,6 +105,44 @@ class AiSolutionReport(ApiSchema):
warning: str | None = None warning: str | None = None
class ReportCard(ApiSchema):
"""A response card explicitly appended to the risk report.
The card is intentionally a superset of the five assistant card types so
that the report page and both export formats can consume the exact same
payload without type-specific ad-hoc transformations.
"""
type: Literal["supplier", "material", "standard", "compliance", "solution"]
title: str = "风险分析卡片"
name: str | None = None
party_type: str | None = None
material: str | None = None
category: str | None = None
question: str | None = None
anomaly_count: int | None = None
cooperation_count: int | None = None
frequency_coefficient: float | None = None
supplier_base_score: float | None = None
supplier_score: float | None = None
tier: str | None = None
matched_count: int | None = None
detected_values: list[dict[str, str]] = Field(default_factory=list)
top_problems: list[dict[str, int | str]] = Field(default_factory=list)
frequent_problems: list[dict[str, int | str]] = Field(default_factory=list)
standards: list[StandardJudgment] = Field(default_factory=list)
similar_cases: list[SimilarCase] = Field(default_factory=list)
confidence: str | None = None
analysis_base: str | None = None
standard_fallback_enabled: bool = False
risks: list[AiSolutionRisk] = Field(default_factory=list)
suggestions: list[str] = Field(default_factory=list)
note: str | None = None
warning: str | None = None
frequency_basis: str | None = None
data_available: bool | None = None
class RiskAssessmentResult(ApiSchema): class RiskAssessmentResult(ApiSchema):
subject_id: str subject_id: str
score: float score: float
@ -118,6 +158,7 @@ class RiskAssessmentResult(ApiSchema):
similar_cases: list[SimilarCase] = Field(default_factory=list) similar_cases: list[SimilarCase] = Field(default_factory=list)
ai_suggestions: list[str] = Field(default_factory=list) ai_suggestions: list[str] = Field(default_factory=list)
ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list) ai_solution_cards: list[AiSolutionReport] = Field(default_factory=list)
report_cards: list[ReportCard] = Field(default_factory=list)
class ModelInfo(ApiSchema): class ModelInfo(ApiSchema):

View File

@ -345,6 +345,14 @@ class ConversationService:
if cls._is_placeholder_supplier_name(text): if cls._is_placeholder_supplier_name(text):
return "general" return "general"
if text.startswith("查物料风险"):
return "material"
if text.startswith("搜索相似案例"):
return "risk_assessment"
if text.startswith("生成 AI 案例推理方案"):
return "solution"
if text.startswith("评估新品"):
return "risk_assessment"
intent = str(semantics.get("intent") or "").strip().lower() intent = str(semantics.get("intent") or "").strip().lower()
if intent == "supplier" or ( if intent == "supplier" or (
any(word in text for word in ("供应商", "加工厂", "工厂")) any(word in text for word in ("供应商", "加工厂", "工厂"))
@ -354,6 +362,8 @@ class ConversationService:
return "supplier" return "supplier"
if intent == "material" or "物料" in text or "类物料" in text: if intent == "material" or "物料" in text or "类物料" in text:
return "material" return "material"
if intent == "cases":
return "risk_assessment"
if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")): if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")):
return "standard" return "standard"
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")): if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
@ -667,6 +677,8 @@ class ConversationService:
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET")) material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET"))
if not material and "查物料风险:" in text:
material = text.split("查物料风险:", 1)[1].strip().split(",", 1)[0]
material = material or self._semantic_text(semantics, "packaging") or "未指定物料" material = material or self._semantic_text(semantics, "packaging") or "未指定物料"
query_term = "软管" if "软管" in material else material query_term = "软管" if "软管" in material else material
count = await self._count_anomalies(AnomalyCase.material_type, query_term) count = await self._count_anomalies(AnomalyCase.material_type, query_term)

View File

@ -17,7 +17,7 @@ from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
from app.schemas.risk_prediction import RiskAssessmentResult from app.schemas.risk_prediction import ReportCard, RiskAssessmentResult
LEVEL_LABELS = { LEVEL_LABELS = {
"low": "低风险", "low": "低风险",
@ -28,6 +28,121 @@ LEVEL_LABELS = {
} }
def effective_report_cards(result: RiskAssessmentResult) -> list[ReportCard]:
"""Return the single canonical collection used by every report renderer.
``ai_solution_cards`` predates the generic card report feature. It is
accepted as a read-only compatibility input when a client sends an old
report payload, while new payloads always use ``report_cards``.
"""
if result.report_cards:
return list(result.report_cards)
return [
ReportCard(
type="solution",
title=item.title,
confidence=item.confidence,
analysis_base=item.analysis_base,
standard_fallback_enabled=item.standard_fallback_enabled,
standards=item.standards,
similar_cases=item.similar_cases,
risks=item.risks,
suggestions=item.suggestions,
warning=item.warning,
)
for item in result.ai_solution_cards
]
def report_card_lines(card: ReportCard) -> list[tuple[str, str]]:
"""Flatten a card into ordered, renderer-neutral report lines.
PDF, Word and the web page all consume this same field contract. Keeping
formatting here prevents one export from silently dropping appended card
details that are visible in another format.
"""
lines: list[tuple[str, str]] = [("卡片类型", card.type)]
subject = card.name or card.material or card.category
if subject:
lines.append(("分析对象", subject))
if card.party_type:
lines.append(("对象类型", card.party_type))
if card.question:
lines.append(("原始问题", card.question))
if card.anomaly_count is not None:
lines.append(("历史异常", f"{card.anomaly_count} 条"))
if card.cooperation_count is not None:
lines.append(("合作次数", f"{card.cooperation_count} 次"))
if card.frequency_coefficient is not None:
lines.append(("频次系数", f"×{card.frequency_coefficient:.2f}"))
if card.supplier_base_score is not None:
lines.append(("供应商基础分", f"{card.supplier_base_score:.2f} 分"))
if card.supplier_score is not None:
lines.append(("供应商得分", f"{card.supplier_score:.2f} 分"))
if card.tier:
lines.append(("供应商层级", card.tier))
if card.matched_count is not None:
lines.append(("匹配数量", f"{card.matched_count} 条"))
if card.frequency_basis:
lines.append(("数据口径", card.frequency_basis))
if card.data_available is not None:
lines.append(("数据状态", "有匹配数据" if card.data_available else "暂无匹配数据"))
if card.note:
lines.append(("备注", card.note))
if card.detected_values:
values = "、".join(f"{item.get('name', '')}:{item.get('value', '')}" for item in card.detected_values)
lines.append(("检测值", values))
if card.confidence:
lines.append(("置信度", card.confidence))
if card.analysis_base:
lines.append(("分析基础", card.analysis_base))
if card.standard_fallback_enabled:
lines.append(("标准兜底校验", "已启用(无直接历史案例)"))
if card.top_problems:
lines.append(
("高频问题", "、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.top_problems))
)
if card.frequent_problems:
lines.append(
(
"常见问题",
"、".join(f"{item.get('name', '')}({item.get('count', 0)}次)" for item in card.frequent_problems),
)
)
if card.standards:
lines.append(
(
"关联标准",
";".join(
f"{item.category} · {item.risk_point}:{item.rule}"
f"({item.source or '标准文件.xlsx'})· {item.status}"
for item in card.standards
),
)
)
if card.similar_cases:
lines.append(
(
"相似案例",
";".join(
f"{item.date or '日期未知'} · {item.product or '未命名产品'} · "
f"{item.problem or item.description or '异常问题未填写'} → {item.solution or '解决方案未填写'}"
+ (f"(相似度 {round(item.similarity * 100)}%)" if item.similarity is not None else "")
for item in card.similar_cases
),
)
)
if card.risks:
lines.append(("风险项", ";".join(f"{item.level} · {item.title}:{item.detail}" for item in card.risks)))
if card.suggestions:
lines.append(("建议方案", ";".join(card.suggestions)))
if card.warning:
lines.append(("风险提示", card.warning))
return lines
def _font_name() -> str: def _font_name() -> str:
candidates = [ candidates = [
Path("C:/Windows/Fonts/msyh.ttc"), Path("C:/Windows/Fonts/msyh.ttc"),
@ -210,6 +325,18 @@ def build_risk_report_pdf(result: RiskAssessmentResult) -> bytes:
story.append(Paragraph("暂无相关标准。", small)) story.append(Paragraph("暂无相关标准。", small))
story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small)) story.append(Paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。", small))
cards = effective_report_cards(result)
if cards:
story += [Paragraph("八、追加卡片分析", section)]
for index, card in enumerate(cards, 1):
story.append(Paragraph(f"{index}. {card.title}", body))
story.append(
table(
[["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)],
[38 * mm, 122 * mm],
)
)
output = BytesIO() output = BytesIO()
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
output, output,
@ -372,6 +499,13 @@ def build_risk_report_docx(result: RiskAssessmentResult) -> bytes:
else: else:
document.add_paragraph("暂无相关标准。") document.add_paragraph("暂无相关标准。")
document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。") document.add_paragraph("双引擎融合:历史异常经验引擎提供风险优先级,标准库提供可执行的检测阈值与验证任务。")
cards = effective_report_cards(result)
if cards:
heading("八、追加卡片分析")
for index, card in enumerate(cards, 1):
document.add_paragraph(f"{index}. {card.title}")
add_table([["字段", "内容"]] + [[label, value] for label, value in report_card_lines(card)])
output = BytesIO() output = BytesIO()
document.save(output) document.save(output)
return output.getvalue() return output.getvalue()

View File

@ -104,6 +104,19 @@ def test_only_explicit_risk_requests_generate_risk_cards() -> None:
) is True ) is True
@pytest.mark.parametrize(
("text", "expected"),
[
("评估新品:洗发水,包装材质:泵头", "risk_assessment"),
("查物料风险:软管", "material"),
("搜索相似案例:软管印刷遇水脱落", "risk_assessment"),
("生成 AI 案例推理方案:铝瓶+喷头密封验证", "solution"),
],
)
def test_tool_requests_keep_their_intent_for_card_routing(text: str, expected: str) -> None:
assert ConversationService._response_type(text, {"intent": "general"}) == expected
def test_legacy_general_semantics_hide_old_risk_card() -> None: def test_legacy_general_semantics_hide_old_risk_card() -> None:
messages = [ messages = [
ConversationMessage( ConversationMessage(

62
tests/test_reports.py Normal file
View File

@ -0,0 +1,62 @@
from io import BytesIO
from app.schemas.risk_prediction import AiSolutionReport, ReportCard, RiskAssessmentResult
from app.services.reports import (
build_risk_report_docx,
build_risk_report_pdf,
effective_report_cards,
report_card_lines,
)
from docx import Document
def _result() -> RiskAssessmentResult:
return RiskAssessmentResult(
subject_id="洗发水新品",
score=72,
level="medium",
model_version="test",
factors=[],
report_cards=[
ReportCard(
type="supplier",
title="供应商深度画像",
name="佳供应商",
party_type="供应商",
supplier_score=88.5,
frequency_coefficient=1.2,
top_problems=[{"name": "泄漏", "count": 3}],
suggestions=["安排首单全检"],
)
],
)
def test_report_card_lines_are_canonical() -> None:
lines = dict(report_card_lines(_result().report_cards[0]))
assert lines["分析对象"] == "佳供应商"
assert lines["供应商得分"] == "88.50 分"
assert lines["高频问题"] == "泄漏(3次)"
assert lines["建议方案"] == "安排首单全检"
def test_old_ai_solution_payload_is_supported() -> None:
result = _result().model_copy(
update={"report_cards": [], "ai_solution_cards": [AiSolutionReport(title="旧方案")]}
)
cards = effective_report_cards(result)
assert len(cards) == 1
assert cards[0].title == "旧方案"
def test_pdf_and_word_include_appended_card_content() -> None:
result = _result()
pdf = build_risk_report_pdf(result)
word = build_risk_report_docx(result)
assert pdf.startswith(b"%PDF")
document = Document(BytesIO(word))
text = "\n".join(paragraph.text for paragraph in document.paragraphs)
text += "\n" + "\n".join(cell.text for table in document.tables for row in table.rows for cell in row.cells)
assert "佳供应商" in text
assert "88.50 分" in text
assert "安排首单全检" in text