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