440 lines
20 KiB
Python
440 lines
20 KiB
Python
"""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
|
||
from app.services.conversation_prompts import semantic_system_prompt
|
||
|
||
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": semantic_system_prompt(),
|
||
},
|
||
]
|
||
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]
|