统一风险报告卡片与导出数据逻辑
This commit is contained in:
parent
9e13c95ba8
commit
fd8369e2f3
@ -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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
62
tests/test_reports.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user