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