Files
2026-04-08 14:38:04 +08:00

91 lines
3.2 KiB
Python

from __future__ import annotations
import logging
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句。"
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 client init failed: %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 "", "unavailable"
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: %s", exc)
return "", "error"
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()