- 保留 pgvector 与混合检索能力,返回 TOP 5 相似案例\n- 将相似案例上下文传入 AI 预防建议生成\n- 修复风险评估调用缺少 similar_cases 参数导致的运行报错\n- 保留 Embedding 配置、数据库迁移、回填脚本及 RAG 测试\n- 恢复基础字符串数组格式的 AI 预防建议协议
1412 lines
55 KiB
Python
1412 lines
55 KiB
Python
"""Conversation use cases and the initial risk-assistant response adapter."""
|
||
|
||
import json
|
||
import logging
|
||
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.database_risk_scoring import DatabaseRiskScoringService
|
||
from app.services.llm_client import LLMClient
|
||
from app.services.rag import AnomalyCaseRAG
|
||
|
||
logger = logging.getLogger("dieya_risk_prediction.conversations")
|
||
|
||
|
||
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
|
||
return self._data(
|
||
conversation,
|
||
messages=messages,
|
||
total=total,
|
||
offset=offset,
|
||
)
|
||
|
||
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
|
||
return self._data(
|
||
persisted,
|
||
messages=messages,
|
||
total=total,
|
||
offset=0,
|
||
)
|
||
|
||
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)
|
||
semantics = await self._normalize_follow_up_semantics(
|
||
text,
|
||
semantics,
|
||
conversation,
|
||
)
|
||
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)
|
||
response_type = await self._use_solution_card_without_history(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
|
||
result = self._data(
|
||
persisted,
|
||
messages=messages,
|
||
total=total,
|
||
offset=0,
|
||
)
|
||
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."""
|
||
|
||
if cls._is_placeholder_supplier_name(text):
|
||
return "general"
|
||
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":
|
||
return "solution"
|
||
if intent == "assessment" or cls._is_explicit_risk_request(text):
|
||
return "risk_assessment"
|
||
return "general"
|
||
|
||
async def _normalize_follow_up_semantics(
|
||
self,
|
||
text: str,
|
||
semantics: dict[str, Any],
|
||
conversation: Conversation,
|
||
) -> dict[str, Any]:
|
||
"""Recover card intent for short follow-ups such as ``佳亿``.
|
||
|
||
The LLM can answer a short follow-up using conversation history while
|
||
still labeling the current turn as ``general``. Card routing needs a
|
||
deterministic signal, so known supplier names are matched locally and
|
||
written back into the semantic payload before routing.
|
||
"""
|
||
|
||
normalized = dict(semantics)
|
||
if self._is_placeholder_supplier_name(text):
|
||
normalized.update(
|
||
{
|
||
"intent": "general",
|
||
"risk_requested": False,
|
||
"semantic_source": "placeholder_filtered",
|
||
}
|
||
)
|
||
normalized.pop("supplier", None)
|
||
return normalized
|
||
intent = str(normalized.get("intent") or "").strip().lower()
|
||
if intent not in {"", "general", "greet"}:
|
||
return normalized
|
||
supplier_names = list(
|
||
(
|
||
await self.session.scalars(
|
||
select(AnomalyCase.supplier)
|
||
.where(AnomalyCase.supplier.is_not(None))
|
||
.distinct()
|
||
)
|
||
).all()
|
||
)
|
||
supplier_name = self._infer_supplier_follow_up(text, supplier_names)
|
||
supplier_name = supplier_name or self._infer_unknown_supplier_follow_up(text)
|
||
if not supplier_name:
|
||
return normalized
|
||
normalized.update(
|
||
{
|
||
"intent": "supplier",
|
||
"risk_requested": True,
|
||
"supplier": supplier_name,
|
||
"confidence": max(float(normalized.get("confidence") or 0), 0.75),
|
||
"semantic_source": "database_supplier_match"
|
||
if supplier_name in supplier_names
|
||
else "standalone_new_supplier_fallback",
|
||
}
|
||
)
|
||
return normalized
|
||
|
||
@staticmethod
|
||
def _infer_supplier_follow_up(text: str, supplier_names: Sequence[str | None]) -> str | None:
|
||
"""Return a known supplier when the current message names it directly."""
|
||
|
||
cleaned = text.strip().lower()
|
||
if not cleaned:
|
||
return None
|
||
candidates = sorted(
|
||
{
|
||
" ".join(name.strip().split())
|
||
for name in supplier_names
|
||
if name and name.strip() and name.strip() not in {"/", "-"}
|
||
},
|
||
key=len,
|
||
reverse=True,
|
||
)
|
||
for name in candidates:
|
||
if cleaned == name.lower():
|
||
return name
|
||
return None
|
||
|
||
@staticmethod
|
||
def _infer_unknown_supplier_follow_up(text: str) -> str | None:
|
||
"""Treat a short standalone business name as a new supplier.
|
||
|
||
This is deliberately conservative: common greetings, product terms,
|
||
workflow commands, and messages containing punctuation are excluded.
|
||
Unknown names are shown as a new supplier with zero historical data.
|
||
"""
|
||
|
||
cleaned = " ".join(text.strip().split())
|
||
if not 2 <= len(cleaned) <= 20 or " " in cleaned:
|
||
return None
|
||
if not re.fullmatch(r"[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9_-]*", cleaned):
|
||
return None
|
||
excluded = {
|
||
"你好",
|
||
"您好",
|
||
"谢谢",
|
||
"好的",
|
||
"继续",
|
||
"继续分析",
|
||
"洗发水",
|
||
"沐浴露",
|
||
"面霜",
|
||
"脱毛膏",
|
||
"清洁剂",
|
||
"牙膏",
|
||
"软管",
|
||
"泵头",
|
||
"瓶子",
|
||
"彩盒",
|
||
"标准库",
|
||
"风险评估",
|
||
"供应商",
|
||
"新供应商A",
|
||
"测试供应商",
|
||
}
|
||
if (
|
||
cleaned in excluded
|
||
or cleaned.startswith("测试")
|
||
or cleaned.startswith("新供应商")
|
||
or any(term in cleaned for term in ("请", "帮我", "分析", "查询", "怎么样"))
|
||
):
|
||
return None
|
||
return cleaned
|
||
|
||
@staticmethod
|
||
def _is_placeholder_supplier_name(text: str) -> bool:
|
||
cleaned = " ".join(text.strip().split())
|
||
return cleaned.startswith("测试") or cleaned.startswith("新供应商")
|
||
|
||
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 _use_solution_card_without_history(
|
||
self,
|
||
response_type: str,
|
||
text: str,
|
||
semantics: dict[str, Any],
|
||
) -> str:
|
||
"""Use the solution card for a product risk question with no history."""
|
||
|
||
if response_type != "risk_assessment":
|
||
return response_type
|
||
category = self._semantic_text(semantics, "category") or self._first_match(
|
||
text,
|
||
(
|
||
"洗发水",
|
||
"沐浴露",
|
||
"面霜",
|
||
"乳液",
|
||
"精华",
|
||
"面膜",
|
||
"防晒",
|
||
"护手霜",
|
||
"护发素",
|
||
"脱毛膏",
|
||
"清洁剂",
|
||
"牙膏",
|
||
"消毒喷雾",
|
||
"次氯酸",
|
||
),
|
||
)
|
||
has_product_context = bool(
|
||
category
|
||
or any(
|
||
key in text
|
||
for key in (
|
||
"新品",
|
||
"新产品",
|
||
"产品开发",
|
||
"产品立项",
|
||
"规格",
|
||
"容量",
|
||
"包装",
|
||
"材质",
|
||
"配方",
|
||
"原料",
|
||
)
|
||
)
|
||
)
|
||
if not has_product_context:
|
||
return response_type
|
||
if not category or await self._count_category_anomalies(category) == 0:
|
||
return "solution"
|
||
return response_type
|
||
|
||
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)
|
||
similar_cases = await self._similar_cases(
|
||
text,
|
||
category=category,
|
||
material=self._first_match(
|
||
text,
|
||
("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"),
|
||
),
|
||
supplier=self._semantic_text(semantics, "supplier"),
|
||
factory=self._semantic_text(semantics, "factory"),
|
||
stage=self._semantic_text(semantics, "stage"),
|
||
anomaly_type=self._semantic_text(semantics, "anomaly_type"),
|
||
)
|
||
standard_fallback_enabled = direct_count == 0
|
||
if standard_fallback_enabled and not standards:
|
||
standards = await self._fallback_standards_for(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": "供应商或加工厂的历史表现需要结合异常案例和现场审核确认。",
|
||
}
|
||
)
|
||
fallback_suggestions = [
|
||
"小样阶段完成内容物与包装材料相容性测试",
|
||
"增加倒置密封测试和加速老化测试",
|
||
"首单安排来料全检,并根据结果决定是否驻厂巡检",
|
||
"实际执行前由品控确认并安排小批量试产验证",
|
||
]
|
||
try:
|
||
suggestions = await self.llm.generate_prevention_suggestions(
|
||
{
|
||
"user_input": text,
|
||
"category": category,
|
||
"historical_case_count": direct_count,
|
||
"standard_fallback_enabled": standard_fallback_enabled,
|
||
"matched_standards": [self._standard_dict(item) for item in standards[:5]],
|
||
"identified_risks": risks,
|
||
"rag_similar_cases": [
|
||
{"similarity": item.case.similarity, **item.case.model_dump(mode="json", by_alias=False)}
|
||
for item in similar_cases
|
||
],
|
||
}
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - the solution card must remain available
|
||
logger.warning(
|
||
"Solution card AI suggestions failed error=%s; using deterministic fallback",
|
||
exc.__class__.__name__,
|
||
)
|
||
suggestions = fallback_suggestions
|
||
|
||
return {
|
||
"type": "solution",
|
||
"title": "AI 案例推理生成",
|
||
"confidence": "中",
|
||
"analysisBase": (
|
||
f"目标品类「{category}」当前命中历史异常 {direct_count} 条;"
|
||
"方案结合已导入异常案例和标准规则生成。"
|
||
if direct_count
|
||
else f"目标品类「{category}」暂无直接历史案例,当前按技术法规规则库进行兜底分析。"
|
||
),
|
||
"standardFallbackEnabled": standard_fallback_enabled,
|
||
"standards": [self._standard_dict(item) for item in standards[:4]],
|
||
"similarCases": [item.case.model_dump(mode="json", by_alias=False) for item in similar_cases],
|
||
"risks": risks,
|
||
"suggestions": suggestions,
|
||
"warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。",
|
||
}
|
||
|
||
async def _fallback_standards_for(self, text: str) -> Sequence[StandardJudgment]:
|
||
"""Find generic safety rules when no direct product history is available."""
|
||
|
||
terms = [
|
||
term
|
||
for term in (
|
||
self._first_match(text, ("相容性", "密封", "稳定性", "消毒", "微生物", "氧化")),
|
||
self._first_match(text, ("铝", "金属", "喷头", "软管", "PET", "泵头")),
|
||
)
|
||
if term
|
||
]
|
||
if 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(StandardRule.status == "启用", or_(*clauses))
|
||
.order_by(StandardRule.id)
|
||
.limit(4)
|
||
)
|
||
)
|
||
if rows:
|
||
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
|
||
]
|
||
|
||
rows = list(
|
||
await self.session.scalars(
|
||
select(StandardRule)
|
||
.where(StandardRule.status == "启用")
|
||
.order_by(StandardRule.id)
|
||
.limit(4)
|
||
)
|
||
)
|
||
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
|
||
]
|
||
|
||
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
|
||
|
||
database_score = await DatabaseRiskScoringService(self.session).score(
|
||
category=category,
|
||
material=self._first_match(
|
||
text,
|
||
("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"),
|
||
),
|
||
supplier=supplier,
|
||
factory=factory,
|
||
stage=self._semantic_text(semantics, "stage") or self._first_match(
|
||
text, ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
|
||
),
|
||
defect_level=self._first_match(text, ("A级", "B级", "C级")),
|
||
)
|
||
category_count = await self._count_category_anomalies(category)
|
||
supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier)
|
||
factory_count = await self._count_anomalies(AnomalyCase.factory, factory)
|
||
anomaly_count = supplier_count if supplier else factory_count
|
||
standard_rows = await self._standards_for(category, text)
|
||
similar_cases = await self._similar_cases(
|
||
text,
|
||
category=category,
|
||
material=self._first_match(
|
||
text,
|
||
("软管", "PET瓶", "PET", "铝瓶", "泵头", "彩盒", "瓶子", "喷头", "盖子"),
|
||
),
|
||
supplier=supplier,
|
||
factory=factory,
|
||
stage=self._semantic_text(semantics, "stage"),
|
||
anomaly_type=self._semantic_text(semantics, "anomaly_type"),
|
||
)
|
||
|
||
alpha_count = supplier_count if supplier else 0
|
||
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0
|
||
tier = self._supplier_tier(alpha_count) if supplier else None
|
||
level_text = self._risk_level_text(database_score.level)
|
||
alert_text = self._alert_text(
|
||
category,
|
||
category_count,
|
||
supplier_or_factory,
|
||
anomaly_count,
|
||
text,
|
||
)
|
||
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 database_score.dimensions
|
||
]
|
||
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 database_score.dimensions
|
||
]
|
||
suggestions = await self._generate_ai_suggestions(
|
||
text=text,
|
||
semantics=semantics,
|
||
category=category,
|
||
supplier_or_factory=supplier_or_factory,
|
||
category_count=category_count,
|
||
anomaly_count=anomaly_count,
|
||
alpha=alpha,
|
||
database_score=database_score,
|
||
dimensions=dimensions,
|
||
standard_rows=standard_rows,
|
||
similar_cases=similar_cases,
|
||
)
|
||
|
||
return result.model_copy(
|
||
update={
|
||
"score": database_score.score,
|
||
"level": database_score.level,
|
||
"model_version": "database-seven-dimension-v1",
|
||
"factors": factors,
|
||
"risk_badge": self._risk_badge(database_score.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": [item.case.model_dump(mode="json", by_alias=False) for item in 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 _generate_ai_suggestions(
|
||
self,
|
||
*,
|
||
text: str,
|
||
semantics: dict[str, Any],
|
||
category: str,
|
||
supplier_or_factory: str | None,
|
||
category_count: int,
|
||
anomaly_count: int,
|
||
alpha: float,
|
||
database_score,
|
||
dimensions: Sequence[RiskDimensionScore],
|
||
standard_rows: Sequence[StandardJudgment],
|
||
similar_cases: Sequence[Any],
|
||
) -> Sequence[str]: # noqa: ANN001
|
||
"""Generate suggestions from live scoring data, with a deterministic fallback."""
|
||
|
||
context = {
|
||
"user_input": text,
|
||
"semantic_fields": {
|
||
key: value
|
||
for key, value in semantics.items()
|
||
if key in {"category", "specification", "packaging", "supplier", "factory", "stage"}
|
||
and value
|
||
},
|
||
"category": category,
|
||
"supplier_or_factory": supplier_or_factory,
|
||
"category_anomaly_count": category_count,
|
||
"supplier_or_factory_anomaly_count": anomaly_count,
|
||
"frequency_alpha": alpha,
|
||
"overall_score": database_score.score,
|
||
"risk_level": self._risk_level_text(database_score.level),
|
||
"dimensions": [
|
||
{
|
||
"name": item.dimension,
|
||
"score": item.score,
|
||
"weight": item.weight,
|
||
"status": item.status,
|
||
"reason": item.reason,
|
||
"sample_size": item.sample_size,
|
||
}
|
||
for item in dimensions
|
||
],
|
||
"matched_standards": [self._standard_dict(item) for item in standard_rows[:5]],
|
||
"rag_similar_cases": [
|
||
{
|
||
"similarity": item.case.similarity,
|
||
**item.case.model_dump(mode="json", by_alias=False),
|
||
}
|
||
for item in similar_cases
|
||
],
|
||
}
|
||
try:
|
||
suggestions = await self.llm.generate_prevention_suggestions(context)
|
||
logger.info("AI prevention suggestions generated count=%s", len(suggestions))
|
||
return suggestions
|
||
except Exception as exc: # noqa: BLE001 - suggestions must not break risk assessment
|
||
logger.warning(
|
||
"AI prevention suggestion generation failed error=%s; using deterministic fallback",
|
||
exc.__class__.__name__,
|
||
)
|
||
return self._suggestions(text, supplier_or_factory)
|
||
|
||
async def _similar_cases(
|
||
self,
|
||
query: str,
|
||
*,
|
||
category: str | None = None,
|
||
material: str | None = None,
|
||
supplier: str | None = None,
|
||
factory: str | None = None,
|
||
stage: str | None = None,
|
||
anomaly_type: str | None = None,
|
||
) -> Sequence[Any]:
|
||
"""Return historical cases for both the card and the AI context."""
|
||
|
||
try:
|
||
return await AnomalyCaseRAG(self.session).search(
|
||
query=query,
|
||
category=category,
|
||
material=material,
|
||
supplier=supplier,
|
||
factory=factory,
|
||
stage=stage,
|
||
anomaly_type=anomaly_type,
|
||
)
|
||
except Exception as exc: # noqa: BLE001 - RAG must not block risk scoring
|
||
logger.warning("RAG historical case search failed error=%s", exc.__class__.__name__)
|
||
return []
|
||
|
||
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),
|
||
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,
|
||
)
|
||
|
||
@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
|