完善风险评分、LLM兜底与会话历史加载

- 接入数据库驱动的七维风险评分第一阶段逻辑\n- 增加主模型失败后的 DeepSeek 语义、流式和预防建议兜底\n- 将 AI 预防建议改为基于评分、历史异常和标准规则动态生成\n- 增加无历史案例时的标准库兜底校验和通用规则匹配\n- 修复未知供应商识别及测试占位名称过滤\n- 增加后端滚动日志文件配置和 LLM 请求日志\n- 历史会话直接读取已保存结果,避免重新调用模型和重算风险\n- 补充评分、供应商识别和 LLM 协议测试
This commit is contained in:
kx_guo 2026-09-04 18:02:39 +08:00
parent ec8b83d015
commit e8c122e542
14 changed files with 1549 additions and 162 deletions

View File

@ -1,19 +1,52 @@
# ==================== 应用基础配置 ====================
# 应用名称、版本和运行环境,用于接口文档、日志和运行行为识别。
RISK_APP_NAME=Dieya Risk Prediction Backend RISK_APP_NAME=Dieya Risk Prediction Backend
RISK_APP_VERSION=0.1.0 RISK_APP_VERSION=0.1.0
RISK_ENVIRONMENT=development RISK_ENVIRONMENT=development
RISK_DEBUG=false RISK_DEBUG=false
# ==================== 数据库配置 ====================
# 风险系统业务数据库连接地址,包含数据库用户、密码、主机、端口和库名。
RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction RISK_DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/dieya_risk_prediction
# ==================== 日志配置 ====================
# 控制后端日志级别;可选 DEBUG、INFO、WARNING、ERROR。
RISK_LOG_LEVEL=INFO RISK_LOG_LEVEL=INFO
# ==================== JWT 登录鉴权配置 ====================
# JWT 签名密钥、签名算法和登录态有效期(分钟)。生产环境必须更换签名密钥。
RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION RISK_JWT_SECRET_KEY=CHANGE_THIS_IN_PRODUCTION
RISK_JWT_ALGORITHM=HS256 RISK_JWT_ALGORITHM=HS256
RISK_JWT_EXPIRE_MINUTES=480 RISK_JWT_EXPIRE_MINUTES=480
# ==================== 钉钉免登配置 ====================
# 用于钉钉免登和组织身份校验;未接入钉钉时可以留空。
RISK_DINGTALK_APP_KEY= RISK_DINGTALK_APP_KEY=
RISK_DINGTALK_APP_SECRET= RISK_DINGTALK_APP_SECRET=
RISK_DINGTALK_CORP_ID= RISK_DINGTALK_CORP_ID=
RISK_DINGTALK_AGENT_ID= RISK_DINGTALK_AGENT_ID=
# ==================== 主 LLM 模型配置 ====================
# 主模型 API 密钥、OpenAI 兼容接口地址、模型名、请求超时时间和生成温度。
RISK_LLM_API_KEY= RISK_LLM_API_KEY=
RISK_LLM_BASE_URL=https://api.openai.com/v1 RISK_LLM_BASE_URL=https://api.openai.com/v1
RISK_LLM_MODEL=gpt-4o-mini RISK_LLM_MODEL=gpt-4o-mini
RISK_LLM_TIMEOUT_SECONDS=120 RISK_LLM_TIMEOUT_SECONDS=120
RISK_LLM_TEMPERATURE=0.2 RISK_LLM_TEMPERATURE=0.2
# ==================== LLM 兜底模型配置 ====================
# 主模型请求失败时使用 DeepSeek;填写独立的 DeepSeek API Key 才会真正启用兜底。
# API_MODE=responses 时调用 /responses;如服务商只支持 Chat Completions,可改为 chat_completions。
# MAX_RETRIES 是兜底端点内部的重试次数,REASONING_EFFORT 适用于 Responses 推理配置。
RISK_LLM_FALLBACK_API_KEY=
RISK_LLM_FALLBACK_BASE_URL=https://api.deepseek.com
RISK_LLM_FALLBACK_MODEL=deepseek-v4-pro
RISK_LLM_FALLBACK_API_MODE=responses
RISK_LLM_FALLBACK_TIMEOUT_SECONDS=30
RISK_LLM_FALLBACK_MAX_RETRIES=1
RISK_LLM_FALLBACK_REASONING_EFFORT=high
RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS=16384
# ==================== 跨域配置 ====================
# 允许访问后端接口的前端地址,多个地址用英文逗号分隔。
RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158 RISK_CORS_ALLOW_ORIGINS=http://localhost:39158,http://127.0.0.1:39158

2
.gitignore vendored
View File

@ -6,8 +6,8 @@ __pycache__/
.coverage .coverage
htmlcov/ htmlcov/
.env .env
logs/
*.egg-info/ *.egg-info/
dist/ dist/
build/ build/
data/ data/

View File

@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.api.dependencies import require_permission from app.api.dependencies import DatabaseSession, require_permission
from app.models.entities import UserAccount from app.models.entities import UserAccount
from app.schemas.common import ApiResponse from app.schemas.common import ApiResponse
from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult from app.schemas.risk_prediction import ModelInfo, RiskAssessmentRequest, RiskAssessmentResult
@ -23,6 +23,7 @@ async def model_info(
@router.post("/preview", response_model=ApiResponse[RiskAssessmentResult]) @router.post("/preview", response_model=ApiResponse[RiskAssessmentResult])
async def preview_risk( async def preview_risk(
request: RiskAssessmentRequest, request: RiskAssessmentRequest,
session: DatabaseSession,
_: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))], _: Annotated[UserAccount, Depends(require_permission("risk.assess", operate=True))],
) -> ApiResponse[RiskAssessmentResult]: ) -> ApiResponse[RiskAssessmentResult]:
return ApiResponse(data=RiskPredictionService.preview(request)) return ApiResponse(data=await RiskPredictionService.preview(request, session))

View File

@ -1,7 +1,7 @@
"""Environment-backed application settings.""" """Environment-backed application settings."""
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated, Literal
from pydantic import AliasChoices, Field, SecretStr, field_validator from pydantic import AliasChoices, Field, SecretStr, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
@ -53,6 +53,45 @@ class Settings(BaseSettings):
le=2, le=2,
validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"), validation_alias=AliasChoices("RISK_LLM_TEMPERATURE", "LLM_TEMPERATURE"),
) )
llm_fallback_api_key: SecretStr | None = Field(
default=None,
repr=False,
validation_alias=AliasChoices("RISK_LLM_FALLBACK_API_KEY", "DEEPSEEK_API_KEY"),
)
llm_fallback_base_url: str = Field(
default="https://api.deepseek.com",
validation_alias=AliasChoices("RISK_LLM_FALLBACK_BASE_URL", "DEEPSEEK_BASE_URL"),
)
llm_fallback_model: str = Field(
default="deepseek-v4-pro",
validation_alias=AliasChoices("RISK_LLM_FALLBACK_MODEL", "DEEPSEEK_MODEL"),
)
llm_fallback_api_mode: Literal["chat_completions", "responses"] = Field(
default="responses",
validation_alias=AliasChoices("RISK_LLM_FALLBACK_API_MODE", "DEEPSEEK_API_MODE"),
)
llm_fallback_timeout_seconds: int = Field(
default=30,
gt=0,
le=600,
validation_alias=AliasChoices("RISK_LLM_FALLBACK_TIMEOUT_SECONDS", "DEEPSEEK_TIMEOUT"),
)
llm_fallback_max_retries: int = Field(
default=1,
ge=0,
le=10,
validation_alias=AliasChoices("RISK_LLM_FALLBACK_MAX_RETRIES", "DEEPSEEK_MAX_RETRIES"),
)
llm_fallback_reasoning_effort: Literal["low", "high", "max"] = Field(
default="high",
validation_alias=AliasChoices("RISK_LLM_FALLBACK_REASONING_EFFORT", "DEEPSEEK_REASONING_EFFORT"),
)
llm_fallback_max_output_tokens: int = Field(
default=16_384,
ge=1,
le=393_216,
validation_alias=AliasChoices("RISK_LLM_FALLBACK_MAX_OUTPUT_TOKENS", "DEEPSEEK_MAX_OUTPUT_TOKENS"),
)
cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = ( cors_allow_origins: Annotated[tuple[str, ...], NoDecode] = (
"http://localhost:39158", "http://localhost:39158",
"http://127.0.0.1:39158", "http://127.0.0.1:39158",
@ -77,6 +116,11 @@ class Settings(BaseSettings):
def normalize_llm_base_url(cls, value: str) -> str: def normalize_llm_base_url(cls, value: str) -> str:
return value.rstrip("/") return value.rstrip("/")
@field_validator("llm_fallback_base_url")
@classmethod
def normalize_llm_fallback_base_url(cls, value: str) -> str:
return value.rstrip("/")
@field_validator("cors_allow_origins", mode="before") @field_validator("cors_allow_origins", mode="before")
@classmethod @classmethod
def split_cors_origins(cls, value: object) -> object: def split_cors_origins(cls, value: object) -> object:

View File

@ -1,20 +1,60 @@
"""Shared logging setup.""" """Shared logging setup."""
import logging import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
LOGGER_NAME = "dieya_risk_prediction" LOGGER_NAME = "dieya_risk_prediction"
LOG_DIR = Path(__file__).resolve().parents[2] / "logs"
LOG_FILE = LOG_DIR / "backend.log"
_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
def configure_logging(level: str = "INFO") -> logging.Logger: def configure_logging(level: str = "INFO") -> logging.Logger:
logger = logging.getLogger(LOGGER_NAME) logger = logging.getLogger(LOGGER_NAME)
logger.setLevel(level.upper()) logger.setLevel(level.upper())
logger.propagate = False logger.propagate = False
if not logger.handlers: LOG_DIR.mkdir(parents=True, exist_ok=True)
handler = logging.StreamHandler() formatter = logging.Formatter(_FORMAT)
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) file_handler = next(
logger.addHandler(handler) (
handler
for handler in logger.handlers
if getattr(handler, "_dieya_file_handler", False)
),
None,
)
if file_handler is None:
file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler._dieya_file_handler = True
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
root_logger = logging.getLogger()
root_logger.setLevel(level.upper())
if not any(getattr(handler, "_dieya_file_handler", False) for handler in root_logger.handlers):
root_file_handler = RotatingFileHandler(
LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
root_file_handler._dieya_file_handler = True
root_file_handler.setFormatter(formatter)
root_logger.addHandler(root_file_handler)
if not any(
isinstance(handler, logging.StreamHandler)
and not getattr(handler, "_dieya_file_handler", False)
for handler in logger.handlers
):
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
logger.addHandler(stream_handler)
return logger return logger
logger = configure_logging() logger = configure_logging()

View File

@ -44,7 +44,6 @@ class ConversationRepository:
Conversation.id == conversation_id, Conversation.id == conversation_id,
Conversation.user_id == user_id, Conversation.user_id == user_id,
) )
.options(selectinload(Conversation.messages))
) )
if conversation is None: if conversation is None:
return None return None

View File

@ -14,7 +14,13 @@ class RiskIndicator(ApiSchema):
class RiskAssessmentRequest(ApiSchema): class RiskAssessmentRequest(ApiSchema):
subject_id: str = Field(min_length=1, max_length=128) subject_id: str = Field(min_length=1, max_length=128)
indicators: list[RiskIndicator] = Field(min_length=1, max_length=50) indicators: list[RiskIndicator] = Field(default_factory=list, max_length=50)
product_category: str | None = Field(default=None, max_length=100)
material_type: str | None = Field(default=None, max_length=200)
supplier: str | None = Field(default=None, max_length=300)
factory: str | None = Field(default=None, max_length=300)
stage_new: str | None = Field(default=None, max_length=100)
defect_level: str | None = Field(default=None, max_length=10)
class RiskFactorResult(ApiSchema): class RiskFactorResult(ApiSchema):
@ -54,6 +60,10 @@ class RiskDimensionScore(ApiSchema):
dimension: str dimension: str
score: float score: float
weight: float weight: float
status: str = "database"
reason: str | None = None
sample_size: int | None = None
raw_value: float | int | None = None
class SimilarCase(ApiSchema): class SimilarCase(ApiSchema):

View File

@ -1,6 +1,7 @@
"""Conversation use cases and the initial risk-assistant response adapter.""" """Conversation use cases and the initial risk-assistant response adapter."""
import json import json
import logging
import re import re
from collections.abc import AsyncGenerator, Sequence from collections.abc import AsyncGenerator, Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
@ -25,8 +26,11 @@ from app.schemas.risk_prediction import (
StandardJudgment, StandardJudgment,
SupplierFrequency, SupplierFrequency,
) )
from app.services.database_risk_scoring import DatabaseRiskScoringService
from app.services.llm_client import LLMClient from app.services.llm_client import LLMClient
logger = logging.getLogger("dieya_risk_prediction.conversations")
class ConversationService: class ConversationService:
_engine = BaselineRiskEngine() _engine = BaselineRiskEngine()
@ -93,13 +97,11 @@ class ConversationService:
if page is None: if page is None:
raise AppException("对话不存在或无权访问", code=404, status_code=404) raise AppException("对话不存在或无权访问", code=404, status_code=404)
conversation, messages, total = page conversation, messages, total = page
live_results = await self._refresh_risk_results(messages)
return self._data( return self._data(
conversation, conversation,
messages=messages, messages=messages,
total=total, total=total,
offset=offset, offset=offset,
risk_results=live_results,
) )
async def create(self, user: UserAccount, title: str | None = None) -> ConversationData: async def create(self, user: UserAccount, title: str | None = None) -> ConversationData:
@ -160,13 +162,11 @@ class ConversationService:
if page is None: # pragma: no cover - the row already existed if page is None: # pragma: no cover - the row already existed
raise AppException("对话读取失败", code=500, status_code=500) raise AppException("对话读取失败", code=500, status_code=500)
persisted, messages, total = page persisted, messages, total = page
live_results = await self._refresh_risk_results(messages)
return self._data( return self._data(
persisted, persisted,
messages=messages, messages=messages,
total=total, total=total,
offset=0, offset=0,
risk_results=live_results,
) )
async def stream( async def stream(
@ -208,6 +208,11 @@ class ConversationService:
yield {"type": "error", "message": str(exc)} yield {"type": "error", "message": str(exc)}
return return
semantics = self._normalize_semantics(text, semantics) semantics = self._normalize_semantics(text, semantics)
semantics = await self._normalize_follow_up_semantics(
text,
semantics,
conversation,
)
yield {"type": "semantic", "data": semantics} yield {"type": "semantic", "data": semantics}
context = self._llm_messages(conversation, text, semantics) context = self._llm_messages(conversation, text, semantics)
@ -228,6 +233,11 @@ class ConversationService:
show_risk = self._is_risk_request(text, semantics) show_risk = self._is_risk_request(text, semantics)
response_type = self._response_type(text, semantics) response_type = self._response_type(text, semantics)
response_type = await self._use_solution_card_without_history(
response_type,
text,
semantics,
)
risk_result = ( risk_result = (
await self._enrich_risk_result(text, semantics, self._predict(text)) await self._enrich_risk_result(text, semantics, self._predict(text))
if show_risk and response_type == "risk_assessment" if show_risk and response_type == "risk_assessment"
@ -265,13 +275,11 @@ class ConversationService:
yield {"type": "error", "message": "对话读取失败"} yield {"type": "error", "message": "对话读取失败"}
return return
persisted, messages, total = page persisted, messages, total = page
live_results = await self._refresh_risk_results(messages)
result = self._data( result = self._data(
persisted, persisted,
messages=messages, messages=messages,
total=total, total=total,
offset=0, offset=0,
risk_results=live_results,
) )
yield {"type": "done", "data": result.model_dump(mode="json", by_alias=True)} yield {"type": "done", "data": result.model_dump(mode="json", by_alias=True)}
@ -309,6 +317,8 @@ class ConversationService:
def _response_type(cls, text: str, semantics: dict[str, Any]) -> str: def _response_type(cls, text: str, semantics: dict[str, Any]) -> str:
"""Choose the prototype card from the user's actual question.""" """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() intent = str(semantics.get("intent") or "").strip().lower()
if intent == "supplier" or ( if intent == "supplier" or (
any(word in text for word in ("供应商", "加工厂", "工厂")) any(word in text for word in ("供应商", "加工厂", "工厂"))
@ -322,15 +332,138 @@ class ConversationService:
return "standard" return "standard"
if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")): if intent == "compliance" or any(word in text for word in ("合规", "宣称", "备案", "法规")):
return "compliance" return "compliance"
if intent == "solution" or ( if intent == "solution":
any(word in text for word in ("次氯酸", "消毒喷雾"))
and not any(word in text for word in ("评估", "风险评估", "风险预估"))
):
return "solution" return "solution"
if intent == "assessment" or cls._is_explicit_risk_request(text): if intent == "assessment" or cls._is_explicit_risk_request(text):
return "risk_assessment" return "risk_assessment"
return "general" 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( async def _build_response_card(
self, self,
response_type: str, response_type: str,
@ -347,6 +480,59 @@ class ConversationService:
builder = builders.get(response_type) builder = builders.get(response_type)
return await builder(text, semantics) if builder else None 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]: 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") name = self._semantic_text(semantics, "supplier") or self._semantic_text(semantics, "factory")
if not name: if not name:
@ -451,10 +637,29 @@ class ConversationService:
async def _solution_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]: async def _solution_card(self, text: str, semantics: dict[str, Any]) -> dict[str, Any]:
category = self._semantic_text(semantics, "category") or self._first_match( category = self._semantic_text(semantics, "category") or self._first_match(
text, ("次氯酸消毒喷雾", "消毒喷雾", "清洁剂", "脱毛膏") text,
(
"次氯酸消毒喷雾",
"消毒喷雾",
"清洁剂",
"脱毛膏",
"洗发水",
"沐浴露",
"面霜",
"乳液",
"精华",
"面膜",
"防晒",
"护手霜",
"护发素",
"牙膏",
),
) or "当前新品" ) or "当前新品"
direct_count = await self._count_category_anomalies(category) direct_count = await self._count_category_anomalies(category)
standards = await self._standards_for(category, text) 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]] = [] risks: list[dict[str, str]] = []
if "次氯酸" in text or "铝" in text: if "次氯酸" in text or "铝" in text:
risks.append( risks.append(
@ -480,6 +685,31 @@ class ConversationService:
"detail": "供应商或加工厂的历史表现需要结合异常案例和现场审核确认。", "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 { return {
"type": "solution", "type": "solution",
"title": "AI 案例推理生成", "title": "AI 案例推理生成",
@ -488,19 +718,76 @@ class ConversationService:
f"目标品类「{category}」当前命中历史异常 {direct_count} 条;" f"目标品类「{category}」当前命中历史异常 {direct_count} 条;"
"方案结合已导入异常案例和标准规则生成。" "方案结合已导入异常案例和标准规则生成。"
if direct_count if direct_count
else f"目标品类「{category}」暂无直接历史案例,当前按标准规则库进行兜底分析。" else f"目标品类「{category}」暂无直接历史案例,当前按技术法规规则库进行兜底分析。"
), ),
"standardFallbackEnabled": standard_fallback_enabled,
"standards": [self._standard_dict(item) for item in standards[:4]], "standards": [self._standard_dict(item) for item in standards[:4]],
"risks": risks, "risks": risks,
"suggestions": [ "suggestions": suggestions,
"小样阶段完成内容物与包装材料相容性测试",
"增加倒置密封测试和加速老化测试",
"首单安排来料全检,并根据结果决定是否驻厂巡检",
"实际执行前由品控确认并安排小批量试产验证",
],
"warning": "以上方案由 AI 推理生成,实际执行前请咨询品控部门确认。", "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 async def _top_anomaly_types(self, column, value: str | None) -> Sequence[dict[str, Any]]: # noqa: ANN001
if not value: if not value:
return [] return []
@ -603,20 +890,29 @@ class ConversationService:
supplier = None supplier = None
supplier_or_factory = supplier or factory 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) category_count = await self._count_category_anomalies(category)
supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier_or_factory) supplier_count = await self._count_anomalies(AnomalyCase.supplier, supplier)
factory_count = await self._count_anomalies(AnomalyCase.factory, factory) factory_count = await self._count_anomalies(AnomalyCase.factory, factory)
if factory and not supplier: anomaly_count = supplier_count if supplier else factory_count
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) 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 alpha_count = supplier_count if supplier else 0
tier = self._supplier_tier(anomaly_count) alpha = round(min(1.5, 1 + (alpha_count / 100) * 0.3), 2) if alpha_count else 1.0
level_text = self._risk_level_text(result.level) tier = self._supplier_tier(alpha_count) if supplier else None
level_text = self._risk_level_text(database_score.level)
alert_text = self._alert_text( alert_text = self._alert_text(
category, category,
category_count, category_count,
@ -626,17 +922,45 @@ class ConversationService:
) )
dimensions = [ dimensions = [
RiskDimensionScore( RiskDimensionScore(
dimension=self._dimension_name(factor.name), dimension=item.name,
score=round(max(0, 100 - factor.value), 2), score=item.score,
weight=factor.weight, weight=item.weight,
status=item.status,
reason=item.reason,
sample_size=item.sample_size,
raw_value=item.raw_value,
) )
for factor in result.factors for item in database_score.dimensions
] ]
suggestions = self._suggestions(text, supplier_or_factory) 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( return result.model_copy(
update={ update={
"risk_badge": self._risk_badge(result.level), "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, "alert_text": alert_text,
"category_warning": CategoryWarning( "category_warning": CategoryWarning(
category=category, category=category,
@ -675,6 +999,62 @@ class ConversationService:
) )
return int(count or 0) 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: async def _count_category_anomalies(self, category: str) -> int:
"""The imported workbook has sparse product_category values. """The imported workbook has sparse product_category values.
@ -878,9 +1258,7 @@ class ConversationService:
id=conversation.id, id=conversation.id,
title=conversation.title, title=conversation.title,
message_count=resolved_total, message_count=resolved_total,
last_risk_level=ConversationService._last_risk_level_from_messages( last_risk_level=ConversationService._last_risk_level_from_messages(resolved_messages),
resolved_messages,
) or ConversationService._last_risk_level(conversation),
updated_at=conversation.updated_at, updated_at=conversation.updated_at,
messages=[ messages=[
cls._message( cls._message(
@ -898,41 +1276,6 @@ class ConversationService:
has_more_messages=offset + len(resolved_messages) < resolved_total, 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 @staticmethod
def _message( def _message(
message: ConversationMessage, message: ConversationMessage,

View File

@ -0,0 +1,406 @@
"""First-phase database-backed seven-dimension risk scoring."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.risk_engine import RiskLevel
from app.models.entities import AnomalyCase
DEFAULT_WEIGHTS = {
"供应商表现": 25.0,
"物料品类": 20.0,
"产品品类": 15.0,
"加工厂质量": 15.0,
"标准符合性": 10.0,
"节点阶段": 10.0,
"缺陷等级": 5.0,
}
STAGES = ("需求发现", "产品立项", "产品开发", "量产上市", "产品生命管理")
@dataclass(frozen=True, slots=True)
class DimensionScore:
name: str
score: float
weight: float
status: str
reason: str | None = None
sample_size: int | None = None
raw_value: float | int | None = None
@dataclass(frozen=True, slots=True)
class DatabaseRiskScore:
score: float
level: RiskLevel
dimensions: tuple[DimensionScore, ...]
class DatabaseRiskScoringService:
"""Calculate the PRD first-phase model from anomaly_cases.
Inspection results and standard test results are intentionally not guessed
here. Their dimensions use the PRD neutral score of 50 and expose the
reason so callers can distinguish a real score from a fallback.
"""
def __init__(self, session: AsyncSession, *, as_of: date | None = None) -> None:
self.session = session
self.as_of = as_of or date.today()
async def score(
self,
*,
category: str | None = None,
material: str | None = None,
supplier: str | None = None,
factory: str | None = None,
stage: str | None = None,
defect_level: str | None = None,
) -> DatabaseRiskScore:
rows = await self._load_rows()
category_name = normalize_category(category)
material_name = normalize_material(material)
stage_name = normalize_stage(stage)
party_name = clean_value(supplier)
dimensions = (
self._supplier_dimension(rows, party_name),
self._material_dimension(rows, material_name),
self._category_dimension(rows, category_name),
DimensionScore(
name="加工厂质量",
score=50.0,
weight=DEFAULT_WEIGHTS["加工厂质量"],
status="default",
reason="当前数据库没有巡检合格率/批次数据,按 PRD 中性值 50 分计入",
),
DimensionScore(
name="标准符合性",
score=50.0,
weight=DEFAULT_WEIGHTS["标准符合性"],
status="default",
reason="当前数据库没有检测结果与标准判定记录,按 PRD 中性值 50 分计入",
),
self._stage_dimension(rows, stage_name),
self._defect_dimension(rows, category_name, material_name, party_name, defect_level),
)
score = round(sum(item.score * item.weight / 100 for item in dimensions), 2)
return DatabaseRiskScore(score=score, level=classify_score(score), dimensions=dimensions)
async def _load_rows(self) -> list[dict[str, Any]]:
start = self.as_of - timedelta(days=365 * 3)
statement = select(
AnomalyCase.date,
AnomalyCase.product_name,
AnomalyCase.product_category,
AnomalyCase.material_type,
AnomalyCase.supplier,
AnomalyCase.factory,
AnomalyCase.stage_new,
AnomalyCase.defect_level,
).where(
AnomalyCase.date.is_not(None),
AnomalyCase.date >= start,
AnomalyCase.date <= self.as_of,
)
return [dict(row) for row in (await self.session.execute(statement)).mappings().all()]
def _supplier_dimension(self, rows: list[dict[str, Any]], party_name: str | None) -> DimensionScore:
weight = DEFAULT_WEIGHTS["供应商表现"]
if not party_name:
return DimensionScore(
name="供应商表现",
score=50.0,
weight=weight,
status="default",
reason="未提供供应商/加工厂,按 PRD 缺失维度默认 50 分",
)
matched = [row for row in rows if party_matches(row, party_name)]
count = len(matched)
if count == 0:
base = 70.0
elif count <= 20:
base = 90.0
elif count <= 60:
base = 75.0
elif count <= 100:
base = 65.0
elif count <= 150:
base = 45.0
else:
base = 30.0
a_count = sum(1 for row in matched if normalize_defect(row.get("defect_level")) == "A")
base -= min(15, a_count * 5)
current_start = self.as_of - timedelta(days=183)
previous_start = current_start - timedelta(days=183)
current = sum(1 for row in matched if row.get("date") and row["date"] >= current_start)
previous = sum(
1
for row in matched
if row.get("date") and previous_start <= row["date"] < current_start
)
trend_adjustment = (
-10
if current > previous * 1.2 and current >= 3
else 5
if previous > current * 1.2
else 0
)
base = max(0.0, min(100.0, base + trend_adjustment))
alpha = min(1.5, 1.0 + count / 100 * 0.3)
score = min(100.0, round(base * alpha, 2))
return DimensionScore(
name="供应商表现",
score=score,
weight=weight,
status="database",
reason=f"近 3 年异常 {count} 条,基础分 {base:g},α={alpha:.2f}",
sample_size=count,
raw_value=count,
)
@staticmethod
def _material_dimension(rows: list[dict[str, Any]], material: str | None) -> DimensionScore:
weight = DEFAULT_WEIGHTS["物料品类"]
if not material:
return DimensionScore(
name="物料品类",
score=50.0,
weight=weight,
status="default",
reason="未提供包装材质,按 PRD 缺失维度默认 50 分",
)
counts: dict[str, int] = {}
for row in rows:
normalized = normalize_material(row.get("material_type"))
if normalized:
counts[normalized] = counts.get(normalized, 0) + 1
count = counts.get(material, 0)
maximum = max(counts.values(), default=0)
score = 100.0 if maximum == 0 else round(100 - count / maximum * 100, 2)
return DimensionScore(
name="物料品类",
score=score,
weight=weight,
status="database",
reason=f"物料「{material}」异常 {count} 条,相对最大物料异常 {maximum} 条归一化",
sample_size=count,
raw_value=count,
)
@staticmethod
def _category_dimension(rows: list[dict[str, Any]], category: str | None) -> DimensionScore:
weight = DEFAULT_WEIGHTS["产品品类"]
if not category:
return DimensionScore(
name="产品品类",
score=50.0,
weight=weight,
status="default",
reason="无法从产品名称归类,按 PRD 缺失维度默认 50 分",
)
counts: dict[str, int] = {}
for row in rows:
normalized = normalize_category(row.get("product_category")) or normalize_category(row.get("product_name"))
if normalized:
counts[normalized] = counts.get(normalized, 0) + 1
count = counts.get(category, 0)
maximum = max(counts.values(), default=0)
score = 45.0 if count < 10 else round(100 - count / maximum * 100, 2) if maximum else 45.0
return DimensionScore(
name="产品品类",
score=score,
weight=weight,
status="database",
reason=(
f"品类「{category}」异常 {count} 条"
+ (
",样本少于 10 条按 45 分处理"
if count < 10
else ",按品类最大异常数归一化"
)
),
sample_size=count,
raw_value=count,
)
@staticmethod
def _stage_dimension(rows: list[dict[str, Any]], stage: str | None) -> DimensionScore:
weight = DEFAULT_WEIGHTS["节点阶段"]
if not stage:
return DimensionScore(
name="节点阶段",
score=50.0,
weight=weight,
status="default",
reason="未提供产品阶段,按 PRD 缺失维度默认 50 分",
)
total = len(rows)
count = sum(1 for row in rows if normalize_stage(row.get("stage_new")) == stage)
share = count / total if total else 0
if share > 0.5:
score = 50.0
elif share >= 0.3:
score = 60.0
elif share >= 0.1:
score = 75.0
elif share >= 0.01:
score = 80.0
else:
score = 90.0
return DimensionScore(
name="节点阶段",
score=score,
weight=weight,
status="database",
reason=f"阶段「{stage}」异常占比 {share:.1%},按阶段档位取分",
sample_size=count,
raw_value=share * 100,
)
@staticmethod
def _defect_dimension(
rows: list[dict[str, Any]],
category: str | None,
material: str | None,
party_name: str | None,
defect_level: str | None,
) -> DimensionScore:
weight = DEFAULT_WEIGHTS["缺陷等级"]
if not category and not material and not party_name and not defect_level:
return DimensionScore(
name="缺陷等级",
score=50.0,
weight=weight,
status="default",
reason="缺少品类、物料、供应商和缺陷等级组合,按 PRD 缺失维度默认 50 分",
)
matched = [
row
for row in rows
if (
not category
or normalize_category(row.get("product_category")) == category
or normalize_category(row.get("product_name")) == category
)
and (not material or normalize_material(row.get("material_type")) == material)
and (not party_name or party_matches(row, party_name))
]
counts = {
level: sum(
1
for row in matched
if normalize_defect(row.get("defect_level")) == level
)
for level in ("A", "B", "C")
}
labeled = sum(counts.values())
if not labeled:
return DimensionScore(
name="缺陷等级",
score=50.0,
weight=weight,
status="default",
reason="当前组合没有可用的 A/B/C 缺陷记录,按 PRD 缺失维度默认 50 分",
sample_size=0,
)
penalty = counts["A"] * 40 + counts["B"] * 15 + counts["C"] * 5
score = max(0.0, 100.0 - penalty)
return DimensionScore(
name="缺陷等级",
score=score,
weight=weight,
status="database",
reason=f"组合缺陷记录 A={counts['A']}、B={counts['B']}、C={counts['C']},按严重度扣分",
sample_size=labeled,
raw_value=penalty,
)
def clean_value(value: object) -> str | None:
if value is None:
return None
cleaned = " ".join(str(value).strip().split())
return cleaned if cleaned and cleaned not in {"/", "-", "未填写", "未归类"} else None
def normalize_defect(value: object) -> str | None:
cleaned = clean_value(value)
return cleaned.upper() if cleaned and cleaned.upper() in {"A", "B", "C"} else None
def normalize_stage(value: object) -> str | None:
cleaned = clean_value(value)
if not cleaned:
return None
for stage in STAGES:
if stage in cleaned:
return stage
return None
def normalize_material(value: object) -> str | None:
cleaned = clean_value(value)
if not cleaned:
return None
aliases = (
("软管", "软管"),
("泵头", "泵头"),
("彩盒", "彩盒"),
("标贴", "标贴"),
("标签", "标贴"),
("收缩膜", "收缩膜"),
("瓶", "瓶子"),
("PET", "瓶子"),
("亚克力", "瓶子"),
("盖", "盖子"),
)
for keyword, normalized in aliases:
if keyword.lower() in cleaned.lower():
return normalized
return cleaned
def normalize_category(value: object) -> str | None:
cleaned = clean_value(value)
if not cleaned:
return None
aliases = (
(("洗发", "洗护", "洗发水", "洗发露", "洗发乳"), "洗发水/洗护类"),
(("沐浴", "沐浴露"), "沐浴露类"),
(("面霜", "乳液", "精华", "面膜", "护肤"), "面霜/护肤类"),
(("牙膏", "口腔"), "牙膏/口腔护理类"),
(("清洁剂", "清洁", "洗洁"), "清洁剂类"),
(("脱毛",), "脱毛类"),
(("彩妆", "口红", "粉底", "眼影"), "彩妆/唇部类"),
(("香氛", "香水", "止汗"), "香氛/止汗类"),
)
for keywords, normalized in aliases:
if any(keyword.lower() in cleaned.lower() for keyword in keywords):
return normalized
return None
def party_matches(row: dict[str, Any], party_name: str) -> bool:
supplier = clean_value(row.get("supplier")) or ""
return party_name.lower() in supplier.lower()
def classify_score(score: float) -> RiskLevel:
if score >= 80:
return RiskLevel.LOW
if score >= 60:
return RiskLevel.MODERATELY_LOW
if score >= 40:
return RiskLevel.MEDIUM
if score >= 20:
return RiskLevel.HIGH
return RiskLevel.VERY_HIGH

View File

@ -1,17 +1,21 @@
"""OpenAI-compatible LLM client for semantic extraction and streaming replies.""" """Provider-neutral LLM client with a configurable DeepSeek fallback."""
import json import json
import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import Any from typing import Any, Literal
import httpx import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.core.exceptions import AppException from app.core.exceptions import AppException
logger = logging.getLogger("dieya_risk_prediction.llm_client")
ProviderName = Literal["primary", "fallback"]
class LLMClient: class LLMClient:
"""Small provider-neutral client using the OpenAI chat-completions protocol.""" """Call the primary provider and fail over to DeepSeek when necessary."""
def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None: def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
settings = get_settings() settings = get_settings()
@ -20,22 +24,56 @@ class LLMClient:
self.model = settings.llm_model self.model = settings.llm_model
self.timeout = settings.llm_timeout_seconds self.timeout = settings.llm_timeout_seconds
self.temperature = settings.llm_temperature self.temperature = settings.llm_temperature
self.fallback_api_key = (
settings.llm_fallback_api_key.get_secret_value() if settings.llm_fallback_api_key else ""
)
self.fallback_base_url = settings.llm_fallback_base_url.rstrip("/")
self.fallback_model = settings.llm_fallback_model
self.fallback_api_mode = settings.llm_fallback_api_mode
self.fallback_timeout = settings.llm_fallback_timeout_seconds
self.fallback_max_retries = settings.llm_fallback_max_retries
self.fallback_reasoning_effort = settings.llm_fallback_reasoning_effort
self.fallback_max_output_tokens = settings.llm_fallback_max_output_tokens
self.transport = transport self.transport = transport
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.api_key) return bool(self.api_key or self.fallback_api_key)
@property
def fallback_configured(self) -> bool:
return bool(self.fallback_api_key and self.fallback_base_url and self.fallback_model)
@property @property
def endpoint(self) -> str: def endpoint(self) -> str:
suffix = "/chat/completions" return self._chat_endpoint(self.base_url)
return f"{self.base_url}{suffix}" if self.base_url.endswith("/v1") else f"{self.base_url}/v1{suffix}"
def _headers(self) -> dict[str, str]: @staticmethod
if not self.configured: def _chat_endpoint(base_url: str) -> str:
suffix = "/chat/completions"
return f"{base_url}{suffix}" if base_url.endswith("/v1") else f"{base_url}/v1{suffix}"
def _endpoint(self, provider: ProviderName) -> str:
if provider == "primary":
return self.endpoint
if self.fallback_api_mode == "responses":
return f"{self.fallback_base_url}/responses"
return self._chat_endpoint(self.fallback_base_url)
def _provider_config(self, provider: ProviderName) -> tuple[str, str, int]:
if provider == "primary":
return self.api_key, self.model, self.timeout
return self.fallback_api_key, self.fallback_model, self.fallback_timeout
def _headers(self, provider: ProviderName) -> dict[str, str]:
api_key, _, _ = self._provider_config(provider)
if not api_key:
if provider == "primary" and self.fallback_configured:
raise AppException("主 LLM 未配置 API Key,将尝试使用兜底模型", code=503, status_code=503)
raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503) raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503)
return { return {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"Accept": "text/event-stream", "Accept": "text/event-stream",
} }
@ -45,14 +83,14 @@ class LLMClient:
text: str, text: str,
history: list[dict[str, str]] | None = None, history: list[dict[str, str]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Extract structured product and intent fields before generating an answer.""" """Extract structured fields, retrying with DeepSeek when the primary fails."""
messages = [ messages = [
{ {
"role": "system", "role": "system",
"content": ( "content": (
"你是蝶雅产品风险预估系统的语义理解模块。" "你是蝶雅产品风险预估系统的语义理解模块。"
"只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。" "只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。"
"intent 只能是 greet、general、assessment、supplier、material、cases、" "intent 只能是 greet、general、assessment、supplier、material、cases、"
"standard、compliance、solution 之一。" "standard、compliance、solution 之一。"
"字段必须包含 intent, risk_requested, category, specification, packaging, " "字段必须包含 intent, risk_requested, category, specification, packaging, "
@ -67,39 +105,136 @@ class LLMClient:
] ]
messages.extend(history or []) messages.extend(history or [])
messages.append({"role": "user", "content": text}) messages.append({"role": "user", "content": text})
body = await self._request(messages, stream=False, json_mode=True)
try: try:
content = body["choices"][0]["message"]["content"] body = await self._request(messages, provider="primary", stream=False, json_mode=True)
except (KeyError, IndexError, TypeError) as exc: content = self._response_text(body, provider="primary")
raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc return self._parse_json(content)
if isinstance(content, list): except Exception as primary_exc: # noqa: BLE001 - fallback must cover provider and parse failures
content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict)) logger.warning(
return self._parse_json(str(content)) "LLM semantic request failed provider=primary model=%s error=%s",
self.model,
primary_exc.__class__.__name__,
)
if not self.fallback_configured:
raise
async def stream_reply( try:
self, body = await self._request(messages, provider="fallback", stream=False, json_mode=True)
messages: list[dict[str, str]], content = self._response_text(body, provider="fallback")
) -> AsyncGenerator[str, None]: logger.info("LLM semantic request succeeded provider=fallback model=%s", self.fallback_model)
"""Yield text deltas from an OpenAI-compatible streaming response.""" return self._parse_json(content)
except Exception as fallback_exc: # noqa: BLE001 - normalize both provider failures
headers = self._headers() logger.exception(
payload = { "LLM semantic fallback failed provider=fallback model=%s",
"model": self.model, self.fallback_model,
"messages": messages, )
"temperature": self.temperature,
"stream": True,
}
timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout))
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
async with client.stream("POST", self.endpoint, headers=headers, json=payload) as response:
if response.status_code >= 400:
detail = await response.aread()
raise AppException( raise AppException(
f"LLM 请求失败(HTTP {response.status_code})", "主模型与兜底模型均调用失败,请检查网络、模型配置或额度",
code=503, code=503,
status_code=503, status_code=503,
details=detail.decode("utf-8", errors="replace")[:500], ) from fallback_exc
async def stream_reply(self, messages: list[dict[str, str]]) -> AsyncGenerator[str, None]:
"""Yield reply deltas and fail over only before any primary text is emitted."""
emitted = False
try:
async for delta in self._stream_provider(messages, provider="primary"):
emitted = True
yield delta
return
except Exception as primary_exc: # noqa: BLE001 - fallback is the public behavior
logger.warning(
"LLM stream failed provider=primary model=%s error=%s emitted=%s",
self.model,
primary_exc.__class__.__name__,
emitted,
) )
if emitted or not self.fallback_configured:
raise
logger.info("LLM stream switching provider=primary to provider=fallback model=%s", self.fallback_model)
async for delta in self._stream_provider(messages, provider="fallback"):
yield delta
async def generate_prevention_suggestions(self, context: dict[str, Any]) -> list[str]:
"""Generate executable prevention actions from the current risk context."""
messages = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预估系统的预防方案生成模块。"
"请根据提供的风险评估数据生成可执行的质量预防建议。"
"只输出合法 json object,不要输出 Markdown,格式必须是 "
'{"suggestions":["建议1","建议2"]}。'
"输出 4 到 6 条建议,每条不超过 80 个汉字。"
"建议必须优先针对分数较低的风险维度、历史异常频发项和命中的标准规则,"
"明确检测或管控动作;可以包含责任角色、阶段和验收闭环。"
"不得虚构数据库没有提供的案例、检测结果、法规条款或 RAG 检索结果。"
"数据不足时明确写出需要补充的验证动作,不要把猜测写成事实。"
"不要重复表达,不要输出标题、解释、风险评分或额外字段。"
),
},
{
"role": "user",
"content": "以下是本次风险评估数据,请生成预防建议:\n" + json.dumps(
context, ensure_ascii=False, separators=(",", ":")
),
},
]
try:
body = await self._request(messages, provider="primary", stream=False, json_mode=True)
raw = self._response_text(body, provider="primary")
return self._parse_suggestions(raw)
except Exception as primary_exc: # noqa: BLE001 - prevention suggestions have the same failover policy
logger.warning(
"LLM prevention suggestion request failed provider=primary model=%s error=%s",
self.model,
primary_exc.__class__.__name__,
)
if not self.fallback_configured:
raise
try:
body = await self._request(messages, provider="fallback", stream=False, json_mode=True)
raw = self._response_text(body, provider="fallback")
suggestions = self._parse_suggestions(raw)
logger.info(
"LLM prevention suggestions generated provider=fallback model=%s count=%s",
self.fallback_model,
len(suggestions),
)
return suggestions
except Exception as fallback_exc: # noqa: BLE001 - caller owns deterministic fallback behavior
logger.exception(
"LLM prevention suggestion fallback failed provider=fallback model=%s",
self.fallback_model,
)
raise AppException(
"主模型与兜底模型均无法生成预防建议",
code=503,
status_code=503,
) from fallback_exc
async def _stream_provider(
self,
messages: list[dict[str, str]],
*,
provider: ProviderName,
) -> AsyncGenerator[str, None]:
headers = self._headers(provider)
_, model, timeout_seconds = self._provider_config(provider)
is_responses = provider == "fallback" and self.fallback_api_mode == "responses"
payload = self._payload(messages, provider=provider, stream=True, json_mode=False)
timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds))
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
try:
async with client.stream("POST", self._endpoint(provider), headers=headers, json=payload) as response:
if response.status_code >= 400:
detail = await response.aread()
raise self._http_failure(response.status_code, detail)
async for line in response.aiter_lines(): async for line in response.aiter_lines():
if not line.startswith("data:"): if not line.startswith("data:"):
continue continue
@ -110,44 +245,138 @@ class LLMClient:
chunk = json.loads(raw) chunk = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
choices = chunk.get("choices") or [] delta = self._stream_delta(chunk, responses_api=is_responses)
if not choices:
continue
delta = (choices[0].get("delta") or {}).get("content")
if isinstance(delta, list):
delta = "".join(
str(part.get("text", "")) for part in delta if isinstance(part, dict)
)
if delta: if delta:
yield str(delta) yield delta
except AppException:
raise
except httpx.HTTPError as exc:
raise AppException(
"LLM 流式连接失败",
code=503,
status_code=503,
) from exc
logger.info("LLM stream completed provider=%s model=%s", provider, model)
async def _request( async def _request(
self, self,
messages: list[dict[str, str]], messages: list[dict[str, str]],
*, *,
provider: ProviderName,
stream: bool, stream: bool,
json_mode: bool = False, json_mode: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
headers = self._headers() headers = self._headers(provider)
payload = self._payload(messages, provider=provider, stream=stream, json_mode=json_mode)
_, model, timeout_seconds = self._provider_config(provider)
timeout = httpx.Timeout(timeout_seconds, connect=min(15, timeout_seconds))
logger.info(
"LLM request start provider=%s model=%s endpoint=%s stream=%s",
provider,
model,
self._endpoint(provider),
stream,
)
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
try:
response = await client.post(self._endpoint(provider), headers=headers, json=payload)
if response.status_code >= 400:
detail = await response.aread()
raise self._http_failure(response.status_code, detail)
except AppException:
raise
except httpx.HTTPError as exc:
raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc
try:
data = response.json()
except ValueError as exc:
raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc
if not isinstance(data, dict):
raise AppException("LLM 返回格式无效", code=503, status_code=503)
logger.info("LLM request success provider=%s model=%s status=%s", provider, model, response.status_code)
return data
def _payload(
self,
messages: list[dict[str, str]],
*,
provider: ProviderName,
stream: bool,
json_mode: bool,
) -> dict[str, Any]:
if provider == "fallback" and self.fallback_api_mode == "responses":
payload: dict[str, Any] = { payload: dict[str, Any] = {
"model": self.model, "model": self.fallback_model,
"input": messages,
"stream": stream,
"max_output_tokens": self.fallback_max_output_tokens,
}
if self.fallback_reasoning_effort:
payload["reasoning_effort"] = self.fallback_reasoning_effort
return payload
_, model, _ = self._provider_config(provider)
payload = {
"model": model,
"messages": messages, "messages": messages,
"temperature": self.temperature, "temperature": self.temperature,
"stream": stream, "stream": stream,
} }
if json_mode: if json_mode:
payload["response_format"] = {"type": "json_object"} payload["response_format"] = {"type": "json_object"}
timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) return payload
async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client:
@staticmethod
def _http_failure(status_code: int, detail: bytes) -> AppException:
return AppException(
f"LLM 请求失败(HTTP {status_code})",
code=503,
status_code=503,
details=detail.decode("utf-8", errors="replace")[:500],
)
@staticmethod
def _response_text(body: dict[str, Any], *, provider: ProviderName) -> str:
if provider == "fallback":
output_text = body.get("output_text")
if isinstance(output_text, str) and output_text:
return output_text
parts: list[str] = []
for item in body.get("output") or []:
if not isinstance(item, dict):
continue
content = item.get("content")
if isinstance(content, str):
parts.append(content)
for part in content or []:
if isinstance(part, dict) and part.get("type") in {"output_text", "text"}:
parts.append(str(part.get("text") or ""))
if parts:
return "".join(parts)
try: try:
response = await client.post(self.endpoint, headers=headers, json=payload) content = body["choices"][0]["message"]["content"]
response.raise_for_status() except (KeyError, IndexError, TypeError) as exc:
except httpx.HTTPError as exc: raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc
raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc if isinstance(content, list):
try: content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
return response.json() return str(content)
except ValueError as exc:
raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc @staticmethod
def _stream_delta(chunk: dict[str, Any], *, responses_api: bool) -> str:
if responses_api:
event_type = chunk.get("type") or chunk.get("event")
if event_type in {"response.output_text.delta", "output_text.delta"}:
return str(chunk.get("delta") or "")
return ""
choices = chunk.get("choices") or []
if not choices:
return ""
delta = (choices[0].get("delta") or {}).get("content")
if isinstance(delta, list):
delta = "".join(str(part.get("text", "")) for part in delta if isinstance(part, dict))
return str(delta or "")
@staticmethod @staticmethod
def _parse_json(content: str) -> dict[str, Any]: def _parse_json(content: str) -> dict[str, Any]:
@ -161,3 +390,28 @@ class LLMClient:
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise AppException("LLM 语义结果格式无效", code=503, status_code=503) raise AppException("LLM 语义结果格式无效", code=503, status_code=503)
return parsed return parsed
@staticmethod
def _parse_suggestions(content: str) -> list[str]:
"""Parse and normalize the model's suggestion-only JSON response."""
cleaned = content.strip()
if cleaned.startswith("```"):
cleaned = cleaned.removeprefix("```").removeprefix("json").removesuffix("```").strip()
try:
parsed = json.loads(cleaned)
except json.JSONDecodeError as exc:
raise AppException("LLM 预防建议不是合法 JSON", code=503, status_code=503) from exc
values = parsed.get("suggestions") if isinstance(parsed, dict) else parsed
if not isinstance(values, list):
raise AppException("LLM 预防建议格式无效", code=503, status_code=503)
suggestions: list[str] = []
for value in values:
if not isinstance(value, str):
continue
item = " ".join(value.strip().split())
if item and item not in suggestions:
suggestions.append(item[:100])
if not suggestions:
raise AppException("LLM 未返回有效预防建议", code=503, status_code=503)
return suggestions[:6]

View File

@ -1,12 +1,17 @@
"""Risk prediction use-case orchestration.""" """Risk prediction use-case orchestration."""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import AppException
from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue from app.domain.risk_engine import BaselineRiskEngine, IndicatorValue
from app.schemas.risk_prediction import ( from app.schemas.risk_prediction import (
ModelInfo, ModelInfo,
RiskAssessmentRequest, RiskAssessmentRequest,
RiskAssessmentResult, RiskAssessmentResult,
RiskDimensionScore,
RiskFactorResult, RiskFactorResult,
) )
from app.services.database_risk_scoring import DatabaseRiskScoringService
class RiskPredictionService: class RiskPredictionService:
@ -14,10 +19,63 @@ class RiskPredictionService:
@classmethod @classmethod
def model_info(cls) -> ModelInfo: def model_info(cls) -> ModelInfo:
return ModelInfo(model_version=cls._engine.model_version) return ModelInfo(model_version="database-seven-dimension-v1")
@classmethod @classmethod
def preview(cls, request: RiskAssessmentRequest) -> RiskAssessmentResult: async def preview(
cls,
request: RiskAssessmentRequest,
session: AsyncSession | None = None,
) -> RiskAssessmentResult:
if session and any(
value is not None
for value in (
request.product_category,
request.material_type,
request.supplier,
request.factory,
request.stage_new,
request.defect_level,
)
):
database_score = await DatabaseRiskScoringService(session).score(
category=request.product_category,
material=request.material_type,
supplier=request.supplier,
factory=request.factory,
stage=request.stage_new,
defect_level=request.defect_level,
)
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
]
return RiskAssessmentResult(
subject_id=request.subject_id,
score=database_score.score,
level=database_score.level,
model_version="database-seven-dimension-v1",
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
],
dimension_scores=dimensions,
)
if not request.indicators:
raise AppException("请填写产品评估参数", code=422, status_code=422)
score = cls._engine.predict( score = cls._engine.predict(
[ [
IndicatorValue(name=item.name, value=item.value, weight=item.weight) IndicatorValue(name=item.name, value=item.value, weight=item.weight)
@ -39,4 +97,3 @@ class RiskPredictionService:
for item in score.factors for item in score.factors
], ],
) )

View File

@ -1,5 +1,6 @@
from datetime import UTC, datetime from datetime import UTC, datetime
import pytest
from app.models.entities import Conversation, ConversationMessage from app.models.entities import Conversation, ConversationMessage
from app.services.conversations import ConversationService from app.services.conversations import ConversationService
@ -119,3 +120,61 @@ def test_product_assessment_semantics_override_nlu_miss() -> None:
assert normalized["intent"] == "assessment" assert normalized["intent"] == "assessment"
assert normalized["risk_requested"] is True assert normalized["risk_requested"] is True
def test_short_known_supplier_follow_up_is_routed_to_supplier_card() -> None:
assert ConversationService._infer_supplier_follow_up(
"佳亿",
["佳亿", "祥印", "瑞沣"],
) == "佳亿"
assert ConversationService._infer_supplier_follow_up(
"这个供应商",
["佳亿", "祥印"],
) is None
def test_unknown_standalone_name_is_treated_as_new_supplier() -> None:
assert ConversationService._infer_unknown_supplier_follow_up("德盛") == "德盛"
assert ConversationService._infer_unknown_supplier_follow_up("新供应商A") is None
assert ConversationService._infer_unknown_supplier_follow_up("测试供应商") is None
assert ConversationService._infer_unknown_supplier_follow_up("新供应商B") is None
assert ConversationService._infer_unknown_supplier_follow_up("洗发水") is None
assert ConversationService._infer_unknown_supplier_follow_up("帮我查德盛") is None
assert ConversationService._response_type("测试供应商", {"intent": "supplier"}) == "general"
assert ConversationService._response_type("新供应商A", {"intent": "supplier"}) == "general"
@pytest.mark.asyncio
async def test_any_product_without_history_uses_solution_card(monkeypatch: pytest.MonkeyPatch) -> None:
service = object.__new__(ConversationService)
async def no_history(_: str) -> int:
return 0
monkeypatch.setattr(service, "_count_category_anomalies", no_history)
response_type = ConversationService._response_type(
"评估防晒霜新品,50g,软管包装",
{"intent": "assessment", "risk_requested": True, "category": "防晒霜"},
)
result = await service._use_solution_card_without_history(
response_type,
"评估防晒霜新品,50g,软管包装",
{"intent": "assessment", "risk_requested": True, "category": "防晒霜"},
)
assert response_type == "risk_assessment"
assert result == "solution"
@pytest.mark.asyncio
async def test_unknown_product_without_category_uses_solution_card() -> None:
service = object.__new__(ConversationService)
result = await service._use_solution_card_without_history(
"risk_assessment",
"评估这个新产品的风险",
{"intent": "assessment", "risk_requested": True},
)
assert result == "solution"

View File

@ -0,0 +1,65 @@
from datetime import date
import pytest
from app.services.database_risk_scoring import DatabaseRiskScoringService
def _row(**values):
return {
"date": date(2026, 1, 10),
"product_name": "洗发水 500ml",
"product_category": None,
"material_type": "瓶子",
"supplier": "祥印",
"factory": "一一",
"stage_new": "第三阶段(产品开发)",
"defect_level": None,
**values,
}
@pytest.mark.asyncio
async def test_first_phase_score_uses_database_dimensions_and_prd_fallbacks() -> None:
service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4))
async def load_rows():
return [
_row(),
_row(product_name="洗发水 250ml", defect_level="B"),
]
service._load_rows = load_rows # type: ignore[method-assign]
result = await service.score(
category="洗发水",
material="瓶子",
supplier="祥印",
stage="产品开发",
)
assert len(result.dimensions) == 7
assert sum(item.weight for item in result.dimensions) == 100
assert result.level.value == "medium"
assert result.dimensions[0].score == 95.57
assert result.dimensions[0].raw_value == 2
assert result.dimensions[1].score == 0
assert result.dimensions[2].score == 45
assert result.dimensions[3].status == "default"
assert result.dimensions[4].status == "default"
assert result.dimensions[5].score == 50
assert result.dimensions[6].score == 85
assert result.score == 52.39
@pytest.mark.asyncio
async def test_supplier_dimension_does_not_count_factory_records() -> None:
service = DatabaseRiskScoringService(object(), as_of=date(2026, 9, 4))
async def load_rows():
return [_row(supplier=None, factory="祥印")]
service._load_rows = load_rows # type: ignore[method-assign]
result = await service.score(supplier="祥印")
supplier_dimension = result.dimensions[0]
assert supplier_dimension.score == 70
assert supplier_dimension.raw_value == 0

View File

@ -18,6 +18,9 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
nonlocal calls nonlocal calls
calls += 1 calls += 1
if calls == 1: if calls == 1:
request_body = json.loads(request.content)
assert request_body["response_format"] == {"type": "json_object"}
assert "json" in request_body["messages"][0]["content"]
return httpx.Response( return httpx.Response(
200, 200,
json={ json={
@ -45,13 +48,86 @@ async def test_openai_compatible_semantic_and_stream_protocol(monkeypatch: pytes
client = LLMClient(transport=httpx.MockTransport(handler)) client = LLMClient(transport=httpx.MockTransport(handler))
semantic = await client.extract_semantics("评估洗发水新品") semantic = await client.extract_semantics("评估洗发水新品")
chunks = [ chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])]
chunk
async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])
]
assert semantic["intent"] == "assessment" assert semantic["intent"] == "assessment"
assert semantic["confidence"] == 0.92 assert semantic["confidence"] == 0.92
assert "".join(chunks) == "第一段第二段" assert "".join(chunks) == "第一段第二段"
assert calls == 2 assert calls == 2
get_settings.cache_clear() get_settings.cache_clear()
@pytest.mark.asyncio
async def test_semantic_request_fails_over_to_deepseek_responses(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings.cache_clear()
monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key")
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1")
monkeypatch.setenv("RISK_LLM_MODEL", "primary-model")
monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key")
monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test")
monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro")
monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses")
calls: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
if request.url.path.endswith("/chat/completions"):
return httpx.Response(503, json={"error": {"message": "primary unavailable"}})
body = json.loads(request.content)
assert body["model"] == "deepseek-v4-pro"
assert body["input"][-1]["content"] == "评估洗发水新品"
return httpx.Response(
200,
json={
"output_text": json.dumps(
{"intent": "assessment", "risk_requested": True, "confidence": 0.8},
ensure_ascii=False,
)
},
)
client = LLMClient(transport=httpx.MockTransport(handler))
result = await client.extract_semantics("评估洗发水新品")
assert result["intent"] == "assessment"
assert calls == [
"https://primary.test/v1/chat/completions",
"https://fallback.test/responses",
]
get_settings.cache_clear()
@pytest.mark.asyncio
async def test_stream_request_fails_over_before_primary_emits_text(monkeypatch: pytest.MonkeyPatch) -> None:
get_settings.cache_clear()
monkeypatch.setenv("RISK_LLM_API_KEY", "primary-key")
monkeypatch.setenv("RISK_LLM_BASE_URL", "https://primary.test/v1")
monkeypatch.setenv("RISK_LLM_FALLBACK_API_KEY", "fallback-key")
monkeypatch.setenv("RISK_LLM_FALLBACK_BASE_URL", "https://fallback.test")
monkeypatch.setenv("RISK_LLM_FALLBACK_MODEL", "deepseek-v4-pro")
monkeypatch.setenv("RISK_LLM_FALLBACK_API_MODE", "responses")
calls: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
if request.url.path.endswith("/chat/completions"):
raise httpx.ConnectError("primary connection failed", request=request)
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
content=(
'data: {"type":"response.output_text.delta","delta":"兜底"}\n\n'
'data: {"type":"response.output_text.delta","delta":"回复"}\n\n'
"data: [DONE]\n\n"
).encode(),
)
client = LLMClient(transport=httpx.MockTransport(handler))
chunks = [chunk async for chunk in client.stream_reply([{"role": "user", "content": "继续分析"}])]
assert "".join(chunks) == "兜底回复"
assert calls == [
"https://primary.test/v1/chat/completions",
"https://fallback.test/responses",
]
get_settings.cache_clear()