kx_guo 5c7163d79a 统一供应商统计口径并优化后端查询性能
统一对话页与供应商画像的采购订单、异常数、基础分、频次系数、供应商得分和层级计算;补充供应商维度计算结果字段,保留未归类品类标记并优化供应商列表聚合查询;增加响应压缩并完善供应商统计测试。
2026-09-09 10:45:38 +08:00

1591 lines
62 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 logging
import re
from collections.abc import AsyncGenerator, Sequence
from datetime import UTC, date, datetime, timedelta
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,
SimilarCase,
StandardJudgment,
SupplierFrequency,
)
from app.services.database_risk_scoring import DatabaseRiskScoringService
from app.services.llm_client import LLMClient
from app.services.purchase_orders import supplier_order_counts
from app.services.rag import AnomalyCaseRAG
logger = logging.getLogger("dieya_risk_prediction.conversations")
class ConversationService:
_supplier_history_days = 365 * 3
_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
# Persist the complete assistant text before optional card enrichment.
# A slow or failed RAG/score lookup must not lose the conversation turn.
assistant_message = ConversationMessage(
conversation_id=conversation.id,
role="assistant",
message_type="text",
content=reply,
structured_data={"semantic": semantics},
)
self.session.add(assistant_message)
await self.session.flush()
await self.session.commit()
show_risk = self._is_risk_request(text, semantics)
response_type = self._response_type(text, semantics)
risk_result = None
response_card = None
try:
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
)
except Exception as exc: # noqa: BLE001 - text reply must remain persisted
logger.exception(
"Conversation enrichment failed after assistant text was persisted error=%s",
exc.__class__.__name__,
)
await self.session.rollback()
assistant_message.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 {}),
}
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, a_count, b_count = await self._supplier_history_stats(column, name)
cooperation_count = (
await supplier_order_counts(
self.session,
name,
self._supplier_history_start(),
datetime.now(UTC).date(),
)
if hasattr(self, "session")
else None
)
has_order_data = bool(cooperation_count)
cooperation_display = cooperation_count if has_order_data else None
problems = await self._top_anomaly_types(
column,
name,
since=self._supplier_history_start(),
)
party_type = "加工厂" if is_factory else "供应商"
tier = self._supplier_tier(anomaly_count, a_count=a_count, b_count=b_count)
supplier_dimension = None
if not is_factory and hasattr(self, "session"):
database_score = await DatabaseRiskScoringService(
self.session,
as_of=datetime.now(UTC).date(),
).score(supplier=name)
supplier_dimension = next(
(item for item in database_score.dimensions if item.name == "供应商表现"),
None,
)
return {
"type": "supplier",
"title": "供应商深度画像",
"name": name,
"partyType": party_type,
"anomalyCount": anomaly_count,
"cooperationCount": cooperation_display,
"tier": tier,
"frequencyCoefficient": (
supplier_dimension.frequency_alpha
if supplier_dimension and supplier_dimension.frequency_alpha is not None
else round(min(1.5, 1 + cooperation_display / 100 * 0.3), 2)
if cooperation_display is not None
else 1.0
),
"supplierBaseScore": supplier_dimension.base_score if supplier_dimension else None,
"supplierScore": supplier_dimension.score if supplier_dimension else None,
"topProblems": problems,
"suggestions": self._supplier_management_suggestions(tier),
"frequencyBasis": "近3年采购订单去重单据数" if has_order_data else "暂无匹配采购订单",
"note": (
"合作次数按近3年采购订单去重单据编号统计。"
if has_order_data
else "未匹配到该供应商采购订单,不展示合作次数。"
),
"dataAvailable": has_order_data,
}
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, # noqa: ANN001
value: str | None,
*,
since: date | None = None,
) -> Sequence[dict[str, Any]]:
if not value:
return []
filters = [column.ilike(f"%{value}%"), AnomalyCase.anomaly_type.is_not(None)]
if since:
filters.extend(
[
AnomalyCase.date.is_not(None),
AnomalyCase.date >= since,
AnomalyCase.date <= datetime.now(UTC).date(),
]
)
rows = await self.session.execute(
select(AnomalyCase.anomaly_type, func.count())
.where(*filters)
.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,
as_of=datetime.now(UTC).date(),
).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, supplier_a_count, supplier_b_count = await self._supplier_history_stats(
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"),
)
cooperation_count = (
await supplier_order_counts(
self.session,
supplier,
self._supplier_history_start(),
datetime.now(UTC).date(),
)
if supplier
else None
)
alpha_count = cooperation_count or 0
alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if cooperation_count else 1.0
tier = (
self._supplier_tier(
supplier_count,
a_count=supplier_a_count,
b_count=supplier_b_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
]
supplier_dimension = next(
(item for item in database_score.dimensions if item.name == "供应商表现"),
None,
)
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,
cooperation_count=cooperation_count,
anomaly_count=supplier_count,
alpha=(
supplier_dimension.frequency_alpha
if supplier_dimension and supplier_dimension.frequency_alpha is not None
else alpha
),
tier=tier,
suggestion=";".join(self._supplier_management_suggestions(tier)),
frequency_basis="近3年采购订单去重单据数" if cooperation_count else "暂无匹配采购订单",
note=(
"合作次数按采购订单去重单据编号统计;异常数仍按近3年异常案例统计。"
if cooperation_count
else "未匹配到该供应商采购订单,不展示合作次数。"
),
data_available=bool(cooperation_count),
)
if supplier
else SupplierFrequency(
note="未指定供应商,暂无供应商频次分析。",
frequency_basis="未提供供应商",
)
),
"standard_judgment": standard_rows,
"dimension_scores": dimensions,
"similar_cases": [
SimilarCase.model_validate(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}%"),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
)
)
return int(count or 0)
@classmethod
def _supplier_history_start(cls) -> date:
return datetime.now(UTC).date() - timedelta(days=cls._supplier_history_days)
async def _supplier_history_stats(
self,
column, # noqa: ANN001
value: str | None,
) -> tuple[int, int, int]:
"""Return the PRD's three-year N, A-count, and B-count inputs."""
if not value or value == "未归类":
return 0, 0, 0
rows = (
await self.session.execute(
select(AnomalyCase.defect_level, func.count())
.where(
column.ilike(f"%{value}%"),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
)
.group_by(AnomalyCase.defect_level)
)
).all()
total = sum(int(count or 0) for _, count in rows)
a_count = sum(int(count or 0) for level, count in rows if self._defect_code(level) == "A")
b_count = sum(int(count or 0) for level, count in rows if self._defect_code(level) == "B")
return total, a_count, b_count
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}%"),
),
AnomalyCase.date.is_not(None),
AnomalyCase.date >= self._supplier_history_start(),
AnomalyCase.date <= datetime.now(UTC).date(),
)
)
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 _defect_code(value: str | None) -> str | None:
match = re.search(r"[ABCD]", (value or "").upper())
return match.group(0) if match else None
@staticmethod
def _supplier_tier(
anomaly_count: int,
*,
a_count: int = 0,
b_count: int = 0,
) -> str:
if anomaly_count == 0:
return "新供应商"
ab_ratio = (a_count + b_count) / anomaly_count
if anomaly_count > 120 or a_count > 0 or ab_ratio > 0.5:
return "C级 · 高风险"
if anomaly_count > 60 or ab_ratio >= 0.3:
return "B级 · 关注"
if anomaly_count >= 20:
return "A级 · 优质"
if a_count == 0:
return "S级 · 战略"
return "C级 · 高风险"
@staticmethod
def _supplier_management_suggestions(tier: str | None) -> Sequence[str]:
return {
"S级 · 战略": ["正常合作", "季度回顾"],
"A级 · 优质": ["常规管理", "年度评审"],
"B级 · 关注": ["加强巡检", "新品打样加严"],
"C级 · 高风险": ["安排驻厂巡检", "储备备选供应商", "限制新品使用"],
"新供应商": ["无历史数据", "首单全检", "安排驻厂巡检"],
}.get(tier, [])
@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