451 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Provider-neutral LLM client with a configurable DeepSeek fallback."""
import json
import logging
from collections.abc import AsyncGenerator
from typing import Any, Literal
import httpx
from app.core.config import get_settings
from app.core.exceptions import AppException
logger = logging.getLogger("dieya_risk_prediction.llm_client")
ProviderName = Literal["primary", "fallback"]
class LLMClient:
"""Call the primary provider and fail over to DeepSeek when necessary."""
def __init__(self, transport: httpx.AsyncBaseTransport | None = None) -> None:
settings = get_settings()
self.api_key = settings.llm_api_key.get_secret_value() if settings.llm_api_key else ""
self.base_url = settings.llm_base_url.rstrip("/")
self.model = settings.llm_model
self.timeout = settings.llm_timeout_seconds
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
@property
def configured(self) -> bool:
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
def endpoint(self) -> str:
return self._chat_endpoint(self.base_url)
@staticmethod
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)
return {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
}
async def extract_semantics(
self,
text: str,
history: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
"""Extract structured fields, retrying with DeepSeek when the primary fails."""
messages = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预警系统的语义理解模块。"
"只输出合法 JSON,不要输出 Markdown。请输出 json object。抽取用户输入中的意图和风险评估参数。"
"intent 只能是 greet、general、assessment、supplier、material、cases、"
"standard、compliance、solution 之一。"
"字段必须包含 intent, risk_requested, category, specification, packaging, "
"supplier, factory, stage, confidence。"
"只有用户明确要求风险评估、风险查询、标准判定、案例检索或预防方案时 risk_requested 才为 true;"
"用户描述要开发新品并提供规格、容量、包装材质、供应商或加工厂等参数时,"
"即使没有出现‘风险’二字,也必须将 intent 设为 assessment、risk_requested 设为 true;"
"问候、闲聊、测试回复、要求原样回复等情况必须为 false。"
"缺失字段使用 null,confidence 为 0 到 1 的数字。"
),
},
]
messages.extend(history or [])
messages.append({"role": "user", "content": text})
try:
body = await self._request(messages, provider="primary", stream=False, json_mode=True)
content = self._response_text(body, provider="primary")
return self._parse_json(content)
except Exception as primary_exc: # noqa: BLE001 - fallback must cover provider and parse failures
logger.warning(
"LLM semantic 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)
content = self._response_text(body, provider="fallback")
logger.info("LLM semantic request succeeded provider=fallback model=%s", self.fallback_model)
return self._parse_json(content)
except Exception as fallback_exc: # noqa: BLE001 - normalize both provider failures
logger.exception(
"LLM semantic fallback failed provider=fallback model=%s",
self.fallback_model,
)
raise AppException(
"主模型与兜底模型均调用失败,请检查网络、模型配置或额度",
code=503,
status_code=503,
) from fallback_exc
async def extract_ocr_fields(self, text: str) -> dict[str, Any]:
"""Turn OCR text into an editable risk-item draft without inventing absent values."""
messages = [
{
"role": "system",
"content": (
"你是蝶雅产品风险预警系统的OCR字段抽取模块,只输出合法JSON对象。"
"只能提取输入文字明确出现的信息,不得推测或补造。输出字段:product_name、product_category、"
"supplier、factory、material_type、stage_new、description、defect_level、occurred_on、confidence、"
"measurements。缺失标量用null。defect_level只能是A、B、C或null;occurred_on使用YYYY-MM-DD或null。"
"measurements为数组,每项包含item、raw_value、value、unit、confidence;value必须是数字或null。"
"measurements只允许质量检测指标,例如pH、色差、重金属含量、力矩、喷出量、附着力、"
"不合格率;数量、抽检数量、产品规格、容量、日期、单号和批次不得作为检测指标。"
"confidence为0到1。description应保留异常现象和影响比例,但不得加入原文没有的结论。"
),
},
{"role": "user", "content": text[:20_000]},
]
last_error: Exception | None = None
providers: list[ProviderName] = ["primary"]
if self.fallback_configured:
providers.append("fallback")
for provider in providers:
try:
body = await self._request(messages, provider=provider, stream=False, json_mode=True)
return self._parse_json(self._response_text(body, provider=provider))
except Exception as exc: # noqa: BLE001 - deterministic extraction handles provider failures
last_error = exc
logger.warning("LLM OCR extraction failed provider=%s error=%s", provider, exc.__class__.__name__)
if last_error:
raise last_error
return {}
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():
if not line.startswith("data:"):
continue
raw = line[5:].strip()
if not raw or raw == "[DONE]":
continue
try:
chunk = json.loads(raw)
except json.JSONDecodeError:
continue
delta = self._stream_delta(chunk, responses_api=is_responses)
if 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(
self,
messages: list[dict[str, str]],
*,
provider: ProviderName,
stream: bool,
json_mode: bool = False,
) -> dict[str, Any]:
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] = {
"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,
"temperature": self.temperature,
"stream": stream,
}
if json_mode:
payload["response_format"] = {"type": "json_object"}
return payload
@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:
content = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise AppException("LLM 语义结果缺少 content", code=503, status_code=503) from exc
if isinstance(content, list):
content = "".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
return str(content)
@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
def _parse_json(content: str) -> dict[str, Any]:
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
if not isinstance(parsed, dict):
raise AppException("LLM 语义结果格式无效", code=503, status_code=503)
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]