109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from typing import Optional
|
|
from collections import deque
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LLMService:
|
|
def __init__(self) -> None:
|
|
self._ready = False
|
|
self._error: Optional[str] = None
|
|
self._client = None
|
|
self._model = (settings.llm_model or "").strip()
|
|
self._base_url = (settings.llm_base_url or "").strip()
|
|
self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant)
|
|
self._system_prompt = (settings.llm_system_prompt or "").strip() or "请用中文口语化简短回复,1-2句。"
|
|
|
|
if not settings.llm_api_key:
|
|
self._error = "missing LLM api key: set LLM_API_KEY or DEEPSEEK_API_KEY"
|
|
return
|
|
|
|
# If DeepSeek key is provided but no explicit base URL override,
|
|
# route to DeepSeek-compatible OpenAI endpoint by default.
|
|
if (
|
|
os.getenv("DEEPSEEK_API_KEY")
|
|
and not os.getenv("LLM_BASE_URL")
|
|
and not os.getenv("DEEPSEEK_BASE_URL")
|
|
and (self._base_url.lower() == "https://api.openai.com/v1" or not self._base_url)
|
|
):
|
|
self._base_url = "https://api.deepseek.com/v1"
|
|
|
|
if not self._model:
|
|
base = self._base_url.lower()
|
|
self._model = "deepseek-chat" if "deepseek" in base else "gpt-4o-mini"
|
|
try:
|
|
from openai import AsyncOpenAI
|
|
|
|
self._client = AsyncOpenAI(
|
|
api_key=settings.llm_api_key,
|
|
base_url=self._base_url,
|
|
timeout=settings.llm_timeout,
|
|
)
|
|
self._ready = True
|
|
self._error = None
|
|
except Exception as exc: # pragma: no cover
|
|
self._error = str(exc)
|
|
logger.warning("LLM online mode unavailable: %s", exc)
|
|
|
|
@staticmethod
|
|
def _normalize_reply_text(text: str) -> str:
|
|
cleaned = (text or "").strip()
|
|
if not cleaned:
|
|
return ""
|
|
cleaned = cleaned.replace("\r", "\n")
|
|
cleaned = re.sub(r"```.*?```", "", cleaned, flags=re.S)
|
|
cleaned = re.sub(r"`([^`]*)`", r"\1", cleaned)
|
|
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
|
|
cleaned = re.sub(r"\*(.*?)\*", r"\1", cleaned)
|
|
cleaned = re.sub(r"\s*([,。!?;:、,.!?;:])\s*", r"\1", cleaned)
|
|
cleaned = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", cleaned)
|
|
cleaned = re.sub(r"\n+", " ", cleaned)
|
|
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
|
return cleaned.strip()
|
|
|
|
async def reply_with_meta(self, text: str) -> tuple[str, str]:
|
|
if not self._ready or self._client is None:
|
|
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
|
|
|
try:
|
|
resp = await self._client.chat.completions.create(
|
|
model=self._model,
|
|
messages=[{"role": "system", "content": self._system_prompt}, *list(self._history), {"role": "user", "content": text}],
|
|
max_tokens=settings.llm_max_tokens,
|
|
temperature=max(0.1, min(1.2, settings.llm_temperature)),
|
|
)
|
|
content = self._normalize_reply_text(resp.choices[0].message.content or "")
|
|
if content:
|
|
self._history.append({"role": "user", "content": text})
|
|
self._history.append({"role": "assistant", "content": content})
|
|
return content, "online"
|
|
except Exception as exc: # pragma: no cover
|
|
self._error = str(exc)
|
|
logger.warning("LLM request failed, fallback used: %s", exc)
|
|
|
|
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
|
|
|
async def reply(self, text: str) -> str:
|
|
content, _ = await self.reply_with_meta(text)
|
|
return content
|
|
|
|
@property
|
|
def health(self) -> dict:
|
|
return {
|
|
"ready": self._ready,
|
|
"model": self._model,
|
|
"base_url": self._base_url,
|
|
"history_items": len(self._history),
|
|
"error": self._error,
|
|
}
|
|
|
|
def clear_history(self) -> None:
|
|
self._history.clear()
|