2026-09-04 10:20:40 +08:00

164 lines
7.1 KiB
Python
Raw 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.

"""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