2026-09-04 10:20:40 +08:00

1009 lines
40 KiB
Python
Raw 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.

"""Conversation use cases and the initial risk-assistant response adapter."""
import json
import re
from collections.abc import AsyncGenerator, Sequence
from datetime import UTC, datetime
from typing import Any, Literal
from sqlalchemy import func, or_, select
from app.core.exceptions import AppException
from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue, RiskLevel
from app.models.entities import AnomalyCase, Conversation, ConversationMessage, StandardRule, UserAccount
from app.repositories.conversations import ConversationRepository
from app.schemas.conversation import (
ConversationData,
ConversationMessageData,
ConversationSummary,
)
from app.schemas.risk_prediction import (
CategoryWarning,
RiskAssessmentResult,
RiskDimensionScore,
RiskFactorResult,
StandardJudgment,
SupplierFrequency,
)
from app.services.llm_client import LLMClient
class ConversationService:
_engine = BaselineRiskEngine()
_risk_intents = {
"assessment",
"supplier",
"material",
"cases",
"standard",
"compliance",
"solution",
}
_risk_keywords = (
"风险",
"评估",
"预估",
"判定",
"标准",
"案例",
"供应商",
"物料",
"预防方案",
"合规",
)
_product_assessment_keywords = (
"新品",
"新产品",
"产品开发",
"产品立项",
"规格",
"容量",
"包装",
"材质",
"供应商",
"加工厂",
"配方",
"原料",
)
def __init__(self, session) -> None: # noqa: ANN001
self.session = session
self.repository = ConversationRepository(session)
self.llm = LLMClient()
async def list(
self,
user: UserAccount,
*,
limit: int = 50,
search: str | None = None,
) -> list[ConversationSummary]:
conversations = await self.repository.list_for_user(user.id, limit=limit, search=search)
return [self._summary(item) for item in conversations]
async def get(
self,
conversation_id: int,
user: UserAccount,
*,
limit: int = 20,
offset: int = 0,
) -> ConversationData:
page = await self.repository.get_page_for_user(conversation_id, user.id, limit=limit, offset=offset)
if page is None:
raise AppException("对话不存在或无权访问", code=404, status_code=404)
conversation, messages, total = page
live_results = await self._refresh_risk_results(messages)
return self._data(
conversation,
messages=messages,
total=total,
offset=offset,
risk_results=live_results,
)
async def create(self, user: UserAccount, title: str | None = None) -> ConversationData:
conversation = Conversation(
user_id=user.id,
title=(title or "新建对话").strip()[:120] or "新建对话",
)
self.session.add(conversation)
await self.session.flush()
persisted = await self.repository.get_for_user(conversation.id, user.id)
if persisted is None: # pragma: no cover - the row was just inserted
raise AppException("对话创建失败", code=500, status_code=500)
return self._data(persisted, messages=[], total=0, offset=0)
async def send(
self,
conversation_id: int,
content: str,
user: UserAccount,
*,
message_type: Literal["text", "image", "file", "audio"] = "text",
) -> ConversationData:
conversation = await self.repository.get_for_user(conversation_id, user.id)
if conversation is None:
raise AppException("对话不存在或无权访问", code=404, status_code=404)
text = content.strip()
if not text:
raise AppException("对话内容不能为空", code=422, status_code=422)
user_message = ConversationMessage(
conversation_id=conversation.id,
role="user",
message_type=message_type,
content=text,
)
risk_result = None
if self._is_explicit_risk_request(text):
risk_result = await self._enrich_risk_result(text, {}, self._predict(text))
assistant_message = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
content="已根据当前描述完成风险预估,下面是本轮分析结果。你可以继续补充产品、供应商或标准信息。",
structured_data=(
{"riskResult": risk_result.model_dump(mode="json", by_alias=False)}
if risk_result
else {}
),
)
conversation.messages.extend([user_message, assistant_message])
conversation.title = self._title_for(conversation, text)
conversation.updated_at = datetime.now(UTC)
await self.session.flush()
persisted = await self.repository.get_for_user(conversation.id, user.id)
if persisted is None: # pragma: no cover - the row already existed
raise AppException("对话读取失败", code=500, status_code=500)
page = await self.repository.get_page_for_user(conversation.id, user.id, limit=20, offset=0)
if page is None: # pragma: no cover - the row already existed
raise AppException("对话读取失败", code=500, status_code=500)
persisted, messages, total = page
live_results = await self._refresh_risk_results(messages)
return self._data(
persisted,
messages=messages,
total=total,
offset=0,
risk_results=live_results,
)
async def stream(
self,
conversation_id: int,
content: str,
user: UserAccount,
*,
message_type: Literal["text", "image", "file", "audio"] = "text",
) -> AsyncGenerator[dict[str, Any], None]:
"""Persist a user turn, then stream a real LLM response over SSE."""
conversation = await self.repository.get_for_user(conversation_id, user.id)
if conversation is None:
raise AppException("对话不存在或无权访问", code=404, status_code=404)
text = content.strip()
if not text:
raise AppException("对话内容不能为空", code=422, status_code=422)
user_message = ConversationMessage(
conversation_id=conversation.id,
role="user",
message_type=message_type,
content=text,
)
conversation.messages.append(user_message)
conversation.title = self._title_for(conversation, text)
conversation.updated_at = datetime.now(UTC)
await self.session.flush()
await self.session.commit()
yield {"type": "start", "data": {"conversationId": conversation.id}}
try:
semantics = await self.llm.extract_semantics(
text,
history=self._history_messages(conversation, current_text=text),
)
except Exception as exc: # noqa: BLE001 - report semantic failures through SSE
yield {"type": "error", "message": str(exc)}
return
semantics = self._normalize_semantics(text, semantics)
yield {"type": "semantic", "data": semantics}
context = self._llm_messages(conversation, text, semantics)
chunks: list[str] = []
try:
async for chunk in self.llm.stream_reply(context):
chunks.append(chunk)
yield {"type": "delta", "content": chunk}
except Exception as exc: # noqa: BLE001 - SSE must report model failures
await self.session.rollback()
yield {"type": "error", "message": str(exc)}
return
reply = "".join(chunks).strip()
if not reply:
yield {"type": "error", "message": "LLM 未返回有效内容"}
return
show_risk = self._is_risk_request(text, semantics)
response_type = self._response_type(text, semantics)
risk_result = (
await self._enrich_risk_result(text, semantics, self._predict(text))
if show_risk and response_type == "risk_assessment"
else None
)
response_card = (
await self._build_response_card(response_type, text, semantics)
if response_type != "general"
else None
)
assistant_message = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
message_type="text",
content=reply,
structured_data={
"semantic": semantics,
**(
{"riskResult": risk_result.model_dump(mode="json", by_alias=False)}
if risk_result
else {}
),
**({"responseCard": response_card} if response_card else {}),
},
)
self.session.add(assistant_message)
await self.session.flush()
await self.session.commit()
persisted = await self.repository.get_for_user(conversation.id, user.id)
if persisted is None: # pragma: no cover - the row already existed
yield {"type": "error", "message": "对话读取失败"}
return
page = await self.repository.get_page_for_user(conversation.id, user.id, limit=20, offset=0)
if page is None: # pragma: no cover - the row already existed
yield {"type": "error", "message": "对话读取失败"}
return
persisted, messages, total = page
live_results = await self._refresh_risk_results(messages)
result = self._data(
persisted,
messages=messages,
total=total,
offset=0,
risk_results=live_results,
)
yield {"type": "done", "data": result.model_dump(mode="json", by_alias=True)}
@staticmethod
def _llm_messages(
conversation: Conversation,
current_text: str,
semantics: dict[str, Any],
) -> Sequence[dict[str, str]]:
messages: list[dict[str, str]] = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预估助手。请基于用户当前问题和历史上下文回答,"
"持续理解产品品类、规格、包材、供应商、加工厂和开发阶段。"
"回答要具体、简洁、可执行;不要声称已经完成 RAG、OCR 或语音识别。"
),
}
]
messages.append(
{
"role": "system",
"content": f"本轮语义理解 JSON:{json.dumps(semantics, ensure_ascii=False)}",
}
)
history = conversation.messages[-20:]
for message in history:
if message.role in {"user", "assistant"}:
messages.append({"role": message.role, "content": message.content})
if not history or history[-1].content != current_text:
messages.append({"role": "user", "content": current_text})
return messages
@classmethod
def _response_type(cls, text: str, semantics: dict[str, Any]) -> str:
"""Choose the prototype card from the user's actual question."""
intent = str(semantics.get("intent") or "").strip().lower()
if intent == "supplier" or (
any(word in text for word in ("供应商", "加工厂", "工厂"))
and any(word in text for word in ("怎么样", "风险", "画像", "异常", "合作"))
and not any(word in text for word in ("新品", "新产品", "评估", "预估"))
):
return "supplier"
if intent == "material" or "物料" in text or "类物料" in text:
return "material"
if intent == "standard" or any(word in text for word in ("检测报告", "检测值", "判定", "pH", "PH")):
return "standard"
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
return "compliance"
if intent == "solution" or (
any(word in text for word in ("次氯酸", "消毒喷雾"))
and not any(word in text for word in ("评估", "风险评估", "风险预估"))
):
return "solution"
if intent == "assessment" or cls._is_explicit_risk_request(text):
return "risk_assessment"
return "general"
async def _build_response_card(
self,
response_type: str,
text: str,
semantics: dict[str, Any],
) -> dict[str, Any] | None:
builders = {
"supplier": self._supplier_card,
"material": self._material_card,
"standard": self._standard_card,
"compliance": self._compliance_card,
"solution": self._solution_card,
}
builder = builders.get(response_type)
return await builder(text, semantics) if builder else None
async def _supplier_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
name = self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory")
if not name:
match = re.search(r"(?:供应商|加工厂|工厂)\s*(?:是|用|为)?\s*([^,。!??]+)", text)
name = match.group(1).strip() if match else "未指定"
is_factory = any(word in text for word in ("加工厂", "工厂", "生产厂")) and "供应商" not in text
column = AnomalyCase.factory if is_factory else AnomalyCase.supplier
anomaly_count = await self._count_anomalies(column, name)
problems = await self._top_anomaly_types(column, name)
party_type = "加工厂" if is_factory else "供应商"
return {
"type": "supplier",
"title": "供应商深度画像",
"name": name,
"partyType": party_type,
"anomalyCount": anomaly_count,
"cooperationCount": None,
"tier": self._supplier_tier(anomaly_count),
"frequencyCoefficient": round(min(1.5, 1 + anomaly_count / 100 * 0.3), 2) if anomaly_count else 1.0,
"topProblems": problems,
"suggestions": (
["加强巡检并准备备选供应商", "重点复核高频异常对应的来料和过程检验项目"]
if anomaly_count
else ["暂无历史合作数据,建议首单全检并安排驻厂巡检"]
),
"note": "合作次数需要采购合作台账接入后统计,当前仅展示异常案例库中的真实记录。",
}
async def _material_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
material = self._first_match(text, ("软管", "泵头", "瓶体", "瓶子", "彩盒", "铝瓶", "喷头", "PET"))
material = material or self._semantic_text(semantics, "packaging") or "未指定物料"
query_term = "软管" if "软管" in material else material
count = await self._count_anomalies(AnomalyCase.material_type, query_term)
problems = await self._top_anomaly_types(AnomalyCase.material_type, query_term)
standards = await self._standards_for("包材", text)
return {
"type": "material",
"title": "物料风险分析",
"material": material,
"anomalyCount": count,
"frequentProblems": problems,
"standards": [self._standard_dict(item) for item in standards[:4]],
"suggestions": [
"打样阶段优先验证外观、密封性和内容物相容性",
"将高频异常纳入来料检验和过程巡检清单",
],
}
async def _standard_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
category = self._semantic_text(semantics, "category") or self._first_match(
text, ("洗发水", "沐浴露", "面霜", "乳液", "脱毛膏", "清洁剂")
) or "未指定品类"
standards = await self._standards_for(category, text)
values = re.findall(r"(?:pH|PH)\s*(?:值)?\s*[::]?\s*([0-9]+(?:\.[0-9]+)?)", text)
return {
"type": "standard",
"title": "标准判定",
"category": category,
"detectedValues": [{"name": "pH", "value": value} for value in values],
"matchedCount": len(standards),
"standards": [self._standard_dict(item) for item in standards],
"note": "检测结果仅完成规则库匹配,最终结论需由品控依据检测报告确认。",
}
async def _compliance_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
terms = ["法规", "标签", "宣称", "备案", "功效", "祛痘", "禁用", "限用"]
clauses = [
or_(
StandardRule.category_lv1.ilike(f"%{term}%"),
StandardRule.category_lv2.ilike(f"%{term}%"),
StandardRule.category_lv3.ilike(f"%{term}%"),
StandardRule.risk_point.ilike(f"%{term}%"),
StandardRule.judge_standard.ilike(f"%{term}%"),
)
for term in terms
]
rows = list(
await self.session.scalars(
select(StandardRule).where(or_(*clauses)).order_by(StandardRule.id).limit(6)
)
)
return {
"type": "compliance",
"title": "法规合规核验",
"question": text,
"matchedCount": len(rows),
"standards": [
{
"category": row.category_lv1,
"riskPoint": row.risk_point,
"rule": row.judge_standard,
"source": row.regulation or "标准文件.xlsx",
"status": "待确认",
}
for row in rows
],
"suggestions": [
"核对产品宣称与注册备案/标签允许范围",
"对功效宣称保留评价依据和合规证明材料",
],
}
async def _solution_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
category = self._semantic_text(semantics, "category") or self._first_match(
text, ("次氯酸消毒喷雾", "消毒喷雾", "清洁剂", "脱毛膏")
) or "当前新品"
direct_count = await self._count_category_anomalies(category)
standards = await self._standards_for(category, text)
risks: list[dict[str, str]] = []
if "次氯酸" in text or "铝" in text:
risks.append(
{
"level": "高风险",
"title": "化学兼容性风险",
"detail": "强氧化性内容物与金属或内壁材料的相容性需要重点验证。",
}
)
if "喷头" in text or "软管" in text:
risks.append(
{
"level": "中风险",
"title": "密封性风险",
"detail": "包装组合需验证密封、开启和长期储存稳定性。",
}
)
if self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory"):
risks.append(
{
"level": "中风险",
"title": "供应链风险",
"detail": "供应商或加工厂的历史表现需要结合异常案例和现场审核确认。",
}
)
return {
"type": "solution",
"title": "AI 案例推理生成",
"confidence": "中",
"analysisBase": (
f"目标品类「{category}」当前命中历史异常 {direct_count} 条;"
"方案结合已导入异常案例和标准规则生成。"
if direct_count
else f"目标品类「{category}」暂无直接历史案例,当前按标准规则库进行兜底分析。"
),
"standards": [self._standard_dict(item) for item in standards[:4]],
"risks": risks,
"suggestions": [
"小样阶段完成内容物与包装材料相容性测试",
"增加倒置密封测试和加速老化测试",
"首单安排来料全检,并根据结果决定是否驻厂巡检",
"实际执行前由品控确认并安排小批量试产验证",
],
"warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。",
}
async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001
if not value:
return []
rows = await self.session.execute(
select(AnomalyCase.anomaly_type, func.count())
.where(column.ilike(f"%{value}%"), AnomalyCase.anomaly_type.is_not(None))
.group_by(AnomalyCase.anomaly_type)
.order_by(func.count().desc())
.limit(5)
)
return [{"name": row[0], "count": int(row[1])} for row in rows]
@staticmethod
def _standard_dict(item: StandardJudgment) -> dict[str, Any]:
return {
"category": item.category,
"riskPoint": item.risk_point,
"rule": item.rule,
"source": item.source,
"status": item.status,
}
@staticmethod
def _history_messages(
conversation: Conversation,
*,
current_text: str | None = None,
) -> Sequence[dict[str, str]]:
history = [
message
for message in conversation.messages[-10:]
if message.role in {"user", "assistant"}
]
if current_text and history and history[-1].role == "user" and history[-1].content == current_text:
history.pop()
return [
{"role": message.role, "content": message.content}
for message in history
]
def _predict(self, text: str) -> RiskAssessmentResult:
length = len(text)
score = self._engine.predict(
[
IndicatorValue("supplierPerformance", min(90, 38 + (length % 35)), 25),
IndicatorValue("materialCompatibility", 68 if "软管" in text or "铝" in text else 42, 20),
IndicatorValue("categoryHistory", 58 if "新品" in text else 36, 15),
IndicatorValue(
"productionControl",
54 if "加工厂" in text or "供应商" in text else 32,
15,
),
IndicatorValue("standardCompliance", 24 if "标准" in text or "检测" in text else 46, 10),
IndicatorValue(
"stageControl",
54 if "量产" in text or "上市" in text else 42,
10,
),
IndicatorValue(
"defectSeverity",
78 if "A级" in text or "致命" in text else 52 if "B级" in text else 28,
5,
),
]
)
return RiskAssessmentResult(
subject_id=text[:48] or "新风险评估",
score=score.score,
level=score.level,
model_version=self._engine.model_version,
factors=[
RiskFactorResult(
name=factor.name,
value=factor.value,
weight=factor.weight,
contribution=factor.contribution,
)
for factor in score.factors
],
)
async def _enrich_risk_result(
self,
text: str,
semantics: dict[str, Any],
result: RiskAssessmentResult,
) -> RiskAssessmentResult:
"""Build the structured fields required by the prototype risk card."""
category = self._semantic_text(semantics, "category") or self._first_match(
text,
("洗发水", "沐浴露", "面霜", "乳液", "精华", "面膜", "脱毛膏", "清洁剂", "牙膏"),
) or "未归类"
supplier = self._semantic_text(semantics, "supplier")
factory = self._semantic_text(semantics, "factory")
# The model can occasionally put a named factory into `supplier`.
# An explicit factory marker in the user's text is authoritative.
if any(marker in text for marker in ("加工厂", "工厂", "生产厂")) and "供应商" not in text:
factory = factory or supplier
supplier = None
supplier_or_factory = supplier or factory
category_count = await self._count_category_anomalies(category)
supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier_or_factory)
factory_count = await self._count_anomalies(AnomalyCase.factory, factory)
if factory and not supplier:
anomaly_count = factory_count
elif supplier and not factory:
anomaly_count = supplier_count
else:
anomaly_count = supplier_count or factory_count
standard_rows = await self._standards_for(category, text)
alpha = round(min(1.5, 1 + (anomaly_count / 100) * 0.3), 2) if anomaly_count else 1.0
tier = self._supplier_tier(anomaly_count)
level_text = self._risk_level_text(result.level)
alert_text = self._alert_text(
category,
category_count,
supplier_or_factory,
anomaly_count,
text,
)
dimensions = [
RiskDimensionScore(
dimension=self._dimension_name(factor.name),
score=round(max(0, 100 - factor.value), 2),
weight=factor.weight,
)
for factor in result.factors
]
suggestions = self._suggestions(text, supplier_or_factory)
return result.model_copy(
update={
"risk_badge": self._risk_badge(result.level),
"alert_text": alert_text,
"category_warning": CategoryWarning(
category=category,
anomaly_count=category_count,
risk_level="案例较少" if category_count < 10 else level_text,
suggestion=(
"案例不足,建议结合相似品类和标准库进行预判"
if category_count < 10
else "建议结合当前品类高频异常制定验证计划"
),
),
"supplier_freq": SupplierFrequency(
supplier_name=supplier_or_factory,
cooperation_count=None,
anomaly_count=anomaly_count or None,
alpha=alpha,
tier=tier,
suggestion=(
"无历史数据,建议首单全检并安排驻厂巡检"
if not anomaly_count
else "建议加强巡检并准备备选供应商"
),
),
"standard_judgment": standard_rows,
"dimension_scores": dimensions,
"similar_cases": [],
"ai_suggestions": suggestions,
}
)
async def _count_anomalies(self, column, value: str | None) -> int: # noqa: ANN001
if not value or value == "未归类":
return 0
count = await self.session.scalar(
select(func.count()).select_from(AnomalyCase).where(column.ilike(f"%{value}%"))
)
return int(count or 0)
async def _count_category_anomalies(self, category: str) -> int:
"""The imported workbook has sparse product_category values.
Product names are therefore included as a transparent fallback so the
category warning reflects the 2,448 imported cases instead of returning
zero merely because one optional column is empty.
"""
if not category or category == "未归类":
return 0
count = await self.session.scalar(
select(func.count())
.select_from(AnomalyCase)
.where(
or_(
AnomalyCase.product_category.ilike(f"%{category}%"),
AnomalyCase.product_name.ilike(f"%{category}%"),
)
)
)
return int(count or 0)
async def _standards_for(self, category: str, text: str) -> Sequence[StandardJudgment]:
terms = [term for term in [category] if term and term != "未归类" and term != "未指定品类"]
terms.extend(
term
for term in (
self._first_match(text, ("软管", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头")),
self._first_match(text, ("pH", "PH", "密封", "标签", "宣称", "兼容性", "消毒")),
)
if term
)
if not terms:
terms = ["标准"]
clauses = [
or_(
StandardRule.category_lv1.ilike(f"%{term}%"),
StandardRule.category_lv2.ilike(f"%{term}%"),
StandardRule.category_lv3.ilike(f"%{term}%"),
StandardRule.risk_point.ilike(f"%{term}%"),
)
for term in terms
]
statement = select(StandardRule).where(or_(*clauses)).order_by(StandardRule.id).limit(5)
rows = list(await self.session.scalars(statement))
return [
StandardJudgment(
category=row.category_lv1,
risk_point=row.risk_point,
rule=row.judge_standard,
source=row.regulation or "标准文件.xlsx",
status="待检测",
)
for row in rows
]
@staticmethod
def _semantic_text(semantics: dict[str, Any], key: str) -> str | None:
value = semantics.get(key)
return str(value).strip() if value else None
@staticmethod
def _first_match(text: str, values: Sequence[str]) -> str | None:
return next((value for value in values if value.lower() in text.lower()), None)
@staticmethod
def _dimension_name(name: str) -> str:
return {
"supplierPerformance": "供应商表现",
"materialCompatibility": "物料品类",
"categoryHistory": "产品品类",
"productionControl": "加工厂质量",
"standardCompliance": "标准符合性",
"stageControl": "节点阶段",
"defectSeverity": "缺陷等级",
}.get(name, name)
@staticmethod
def _risk_level_text(level: RiskLevel) -> str:
return {
RiskLevel.LOW: "低风险",
RiskLevel.MODERATELY_LOW: "较低风险",
RiskLevel.MEDIUM: "中等风险",
RiskLevel.HIGH: "高风险",
RiskLevel.VERY_HIGH: "极高风险",
}[level]
@staticmethod
def _risk_badge(level: RiskLevel) -> str:
return {
RiskLevel.LOW: "正常推进",
RiskLevel.MODERATELY_LOW: "常规注意",
RiskLevel.MEDIUM: "需关注",
RiskLevel.HIGH: "加严管控",
RiskLevel.VERY_HIGH: "调整方案",
}[level]
@staticmethod
def _supplier_tier(anomaly_count: int) -> str | None:
if not anomaly_count:
return "新供应商"
if anomaly_count < 20:
return "S级 · 战略"
if anomaly_count <= 60:
return "A级 · 优质"
if anomaly_count <= 120:
return "B级 · 关注"
return "C级 · 高风险"
@staticmethod
def _alert_text(
category: str,
category_count: int,
supplier: str | None,
supplier_count: int,
text: str,
) -> str:
parts = []
if supplier and supplier_count:
parts.append(f"{supplier}相关历史异常 {supplier_count} 次")
if category_count:
parts.append(f"{category}历史异常 {category_count} 条")
if "软管" in text or "铝" in text:
parts.append("包装材质兼容性和密封性需要重点验证")
return ";".join(parts) + "。" if parts else "当前暂无足够历史数据,建议按标准库完成基础验证。"
@staticmethod
def _suggestions(text: str, supplier: str | None) -> Sequence[str]:
suggestions = []
if "软管" in text or "铝" in text:
suggestions.append("打样阶段增加内容物与包装材质兼容性测试")
if supplier:
suggestions.append(f"{supplier}首单安排来料全检并增加巡检频次")
if "量产" in text or "上市" in text:
suggestions.append("量产上市前完成密封性、稳定性和批次抽检验证")
suggestions.extend([
"将检测结果回填标准判定记录,形成可追溯验证闭环",
"高贡献风险指标明确责任人与完成节点",
])
return suggestions[:6]
@classmethod
def _is_risk_request(cls, text: str, semantics: dict[str, Any]) -> bool:
intent = str(semantics.get("intent") or "").strip().lower()
if intent in cls._risk_intents:
return True
requested = semantics.get("risk_requested")
if requested is True:
return True
return cls._is_explicit_risk_request(text)
@classmethod
def _is_explicit_risk_request(cls, text: str) -> bool:
if any(keyword in text for keyword in cls._risk_keywords):
return True
matched_product_fields = sum(keyword in text for keyword in cls._product_assessment_keywords)
return matched_product_fields >= 2
@classmethod
def _normalize_semantics(cls, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
"""Keep deterministic product signals authoritative over occasional NLU misses."""
normalized = dict(semantics)
if cls._is_explicit_risk_request(text):
normalized["risk_requested"] = True
if str(normalized.get("intent") or "").lower() in {"", "general", "greet"}:
normalized["intent"] = "assessment"
elif normalized.get("risk_requested") is not False:
normalized["risk_requested"] = False
return normalized
@staticmethod
def _title_for(conversation: Conversation, text: str) -> str:
if conversation.title != "新建对话":
return conversation.title
return text[:36] + ("…" if len(text) > 36 else "")
@staticmethod
def _summary(conversation: Conversation) -> ConversationSummary:
return ConversationSummary(
id=conversation.id,
title=conversation.title,
message_count=len(conversation.messages),
last_risk_level=ConversationService._last_risk_level(conversation),
updated_at=conversation.updated_at,
)
@classmethod
def _data(
cls,
conversation: Conversation,
*,
messages: Sequence[ConversationMessage] | None = None,
total: int | None = None,
offset: int = 0,
risk_results: dict[int, RiskAssessmentResult] | None = None,
) -> ConversationData:
resolved_messages = messages if messages is not None else conversation.messages
resolved_total = total if total is not None else len(resolved_messages)
return ConversationData(
id=conversation.id,
title=conversation.title,
message_count=resolved_total,
last_risk_level=ConversationService._last_risk_level_from_messages(
resolved_messages,
) or ConversationService._last_risk_level(conversation),
updated_at=conversation.updated_at,
messages=[
cls._message(
item,
risk_result=(
(risk_results or {}).get(item.id)
or cls._risk_result_for_message(resolved_messages, index)
),
suppress_legacy=True,
)
for index, item in enumerate(resolved_messages)
],
messages_total=resolved_total,
messages_offset=offset,
has_more_messages=offset + len(resolved_messages) < resolved_total,
)
async def _refresh_risk_results(
self,
messages: Sequence[ConversationMessage],
) -> dict[int, RiskAssessmentResult]:
"""Refresh database-backed card fields for historical messages.
Risk scores are intentionally kept as the persisted assessment result,
while fields sourced from imported cases and standards are re-read from
PostgreSQL. This prevents an old conversation from showing stale zero
counts after the reference workbooks have been imported.
"""
refreshed: dict[int, RiskAssessmentResult] = {}
for index, message in enumerate(messages):
if message.role != "assistant":
continue
result = self._risk_result_for_message(messages, index)
if result is None:
continue
user_text = next(
(
candidate.content
for candidate in reversed(messages[:index])
if candidate.role == "user"
),
result.subject_id,
)
semantic = (message.structured_data or {}).get("semantic")
refreshed[message.id] = await self._enrich_risk_result(
user_text,
semantic if isinstance(semantic, dict) else {},
result,
)
return refreshed
@staticmethod
def _message(
message: ConversationMessage,
*,
risk_result: RiskAssessmentResult | None = None,
suppress_legacy: bool = False,
) -> ConversationMessageData:
raw_result = (message.structured_data or {}).get("riskResult")
return ConversationMessageData(
id=message.id,
role=message.role,
message_type=message.message_type or "text",
content=message.content,
risk_result=(
risk_result
if suppress_legacy
else RiskAssessmentResult.model_validate(raw_result) if raw_result else None
),
semantic=(message.structured_data or {}).get("semantic"),
response_card=(message.structured_data or {}).get("responseCard"),
created_at=message.created_at,
)
@staticmethod
def _last_risk_level(conversation: Conversation) -> RiskLevel | None:
return ConversationService._last_risk_level_from_messages(conversation.messages)
@staticmethod
def _last_risk_level_from_messages(messages: Sequence[ConversationMessage]) -> RiskLevel | None:
for index in range(len(messages) - 1, -1, -1):
risk_result = ConversationService._risk_result_for_message(messages, index)
if risk_result is not None:
return risk_result.level
return None
@classmethod
def _risk_result_for_message(
cls,
messages: Sequence[ConversationMessage],
index: int,
) -> RiskAssessmentResult | None:
"""Return only a risk result that matches an explicit risk request.
The semantic flag handles new LLM messages. The user-text fallback keeps
older records readable without displaying the risk card for old greetings
that were created before intent gating existed.
"""
message = messages[index]
raw_result = (message.structured_data or {}).get("riskResult")
if not raw_result:
return None
semantic = (message.structured_data or {}).get("semantic")
if isinstance(semantic, dict):
if semantic.get("risk_requested") is False:
return None
if str(semantic.get("intent") or "").strip().lower() in {"greet", "general"}:
return None
if not isinstance(semantic, dict):
previous_user = next(
(
candidate.content
for candidate in reversed(messages[:index])
if candidate.role == "user"
),
"",
)
if not cls._is_explicit_risk_request(previous_user):
return None
try:
return RiskAssessmentResult.model_validate(raw_result)
except (TypeError, ValueError):
return None