"""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 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) 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": [], } ) 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]], "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) 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, ) 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": [], "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], ) -> 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": [], } 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 _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