"""OpenAI-compatible LLM client for semantic extraction and streaming replies.""" import json from collections.abc import AsyncGenerator from typing import Any import httpx from app.core.config import get_settings from app.core.exceptions import AppException class LLMClient: """Small provider-neutral client using the OpenAI chat-completions protocol.""" 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.transport = transport @property def configured(self) -> bool: return bool(self.api_key) @property def endpoint(self) -> str: suffix = "/chat/completions" 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]: if not self.configured: raise AppException("未配置 LLM_API_KEY,暂时无法使用 AI 对话", code=503, status_code=503) return { "Authorization": f"Bearer {self.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 product and intent fields before generating an answer.""" messages = [ { "role": "system", "content": ( "你是蝶雅产品风险预估系统的语义理解模块。" "只输出合法 JSON,不要输出 Markdown。抽取用户输入中的意图和风险评估参数。" "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}) body = await self._request(messages, stream=False, json_mode=True) 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 self._parse_json(str(content)) async def stream_reply( self, messages: list[dict[str, str]], ) -> AsyncGenerator[str, None]: """Yield text deltas from an OpenAI-compatible streaming response.""" headers = self._headers() payload = { "model": self.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( f"LLM 请求失败(HTTP {response.status_code})", code=503, status_code=503, details=detail.decode("utf-8", errors="replace")[:500], ) 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 choices = chunk.get("choices") or [] 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: yield str(delta) async def _request( self, messages: list[dict[str, str]], *, stream: bool, json_mode: bool = False, ) -> dict[str, Any]: headers = self._headers() payload: dict[str, Any] = { "model": self.model, "messages": messages, "temperature": self.temperature, "stream": stream, } if json_mode: payload["response_format"] = {"type": "json_object"} timeout = httpx.Timeout(self.timeout, connect=min(15, self.timeout)) async with httpx.AsyncClient(timeout=timeout, transport=self.transport) as client: try: response = await client.post(self.endpoint, headers=headers, json=payload) response.raise_for_status() except httpx.HTTPError as exc: raise AppException("LLM 语义理解请求失败", code=503, status_code=503) from exc try: return response.json() except ValueError as exc: raise AppException("LLM 返回格式无效", code=503, status_code=503) from exc @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