87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
"""LLM 客户端(OpenAI 兼容),无 KEY 时全部调用降级为 None。
|
||
|
||
设计原则(批次 B 计划):**统计引擎是主交付,LLM 只做话术增强**。
|
||
未配置 AI_API_KEY 时不报错、不阻塞、不改变任何统计结果,只把 `available()` 置 False,
|
||
调用方据此走纯统计文案。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
|
||
from django.conf import settings
|
||
|
||
logger = logging.getLogger("dealerhub.ai")
|
||
|
||
|
||
def available() -> bool:
|
||
"""是否可用 LLM 增强(需 provider + key 都配置)。"""
|
||
return bool(getattr(settings, "AI_PROVIDER", "") and getattr(settings, "AI_API_KEY", ""))
|
||
|
||
|
||
def chat(messages: list, *, max_tokens: int = 400, temperature: float = 0.3) -> str | None:
|
||
"""调用 OpenAI 兼容 chat/completions。失败或未配置一律返回 None(调用方降级)。"""
|
||
if not available():
|
||
return None
|
||
|
||
base = (settings.AI_BASE_URL or "").rstrip("/")
|
||
url = f"{base}/chat/completions"
|
||
payload = {
|
||
"model": settings.AI_MODEL,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": temperature,
|
||
}
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Authorization": f"Bearer {settings.AI_API_KEY}",
|
||
}
|
||
|
||
try:
|
||
import httpx
|
||
|
||
with httpx.Client(timeout=getattr(settings, "AI_TIMEOUT", 20)) as client:
|
||
resp = client.post(url, headers=headers, json=payload)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return (data.get("choices") or [{}])[0].get("message", {}).get("content")
|
||
except Exception as exc: # 网络/鉴权/格式任何问题都降级
|
||
logger.warning("LLM call failed, falling back to statistics: %s", exc)
|
||
return None
|
||
|
||
|
||
def collect_advice(prompt: str) -> str | None:
|
||
"""催收/经营建议:单轮问答,返回一句话建议或 None。"""
|
||
return chat(
|
||
[
|
||
{"role": "system", "content": "你是经销商进销存系统的经营参谋,回答要短、要具体、可执行,不超过 60 字。"},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
max_tokens=160,
|
||
)
|
||
|
||
|
||
def extract_json(prompt: str) -> dict | None:
|
||
"""要求 LLM 返回 JSON 并解析;失败返回 None。"""
|
||
raw = chat(
|
||
[
|
||
{"role": "system", "content": "你只输出合法 JSON,不要任何解释文字、不要 markdown 代码块。"},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
max_tokens=600,
|
||
temperature=0.1,
|
||
)
|
||
if not raw:
|
||
return None
|
||
text = raw.strip()
|
||
if text.startswith("```"):
|
||
# 容错:剥掉可能的代码围栏
|
||
text = text.split("```")[1] if "```" in text[3:] else text[3:]
|
||
text = text.lstrip("json").lstrip()
|
||
try:
|
||
return json.loads(text)
|
||
except Exception as exc:
|
||
logger.warning("LLM JSON parse failed: %s | raw=%s", exc, raw[:200])
|
||
return None
|