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