llm.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. """文本 LLM:判断 / 拆分 / 解构用,走 OpenRouter /chat/completions(默认 claude-sonnet-4.5)。
  2. 只做一件事:给 system + user,返回解析好的 JSON dict。HTTP/鉴权风格同 extractor。
  3. """
  4. from __future__ import annotations
  5. from typing import Any, Callable, Optional
  6. import httpx
  7. from core.config import Settings
  8. from core.jsonio import extract_json_object
  9. class LLMError(RuntimeError):
  10. pass
  11. def chat_json(
  12. system: str,
  13. user: str,
  14. *,
  15. model: Optional[str] = None,
  16. settings: Optional[Settings] = None,
  17. http_post: Callable[..., Any] = httpx.post,
  18. env_file: str = ".env",
  19. timeout: float = 60.0,
  20. ) -> dict:
  21. """调一次对话,强约束输出 JSON,返回解析后的 dict。带一次重试。"""
  22. settings = settings or Settings.from_env(env_file)
  23. api_key = settings.openrouter_api_key
  24. if not api_key:
  25. raise LLMError("missing OPENROUTER_API_KEY")
  26. model = model or settings.llm_model
  27. messages = [
  28. {"role": "system", "content": system + (
  29. "\n只输出一个严格合法的 JSON 对象,不要解释或 markdown。"
  30. "字符串值要写在一行内,内部的换行写成 \\n、双引号写成 \\\",不要出现裸换行或裸双引号。"
  31. )},
  32. {"role": "user", "content": user},
  33. ]
  34. url = f"{settings.openrouter_base_url.rstrip('/')}/chat/completions"
  35. headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  36. last_exc: Optional[Exception] = None
  37. # 最多 3 轮:HTTP 错误重试;JSON 不合法则把坏输出喂回去做 self-repair
  38. for attempt in range(3):
  39. try:
  40. resp = http_post(url, headers=headers,
  41. json={"model": model, "messages": messages}, timeout=timeout)
  42. resp.raise_for_status()
  43. content = resp.json()["choices"][0]["message"]["content"]
  44. except httpx.HTTPError as exc:
  45. last_exc = exc
  46. if attempt < 2:
  47. continue
  48. raise LLMError(f"llm_http_error: {exc}") from exc
  49. except (KeyError, IndexError, TypeError) as exc:
  50. raise LLMError(f"llm_response_invalid: {exc}") from exc
  51. try:
  52. return extract_json_object(content)
  53. except ValueError as exc:
  54. last_exc = exc
  55. messages = messages + [
  56. {"role": "assistant", "content": content},
  57. {"role": "user", "content": (
  58. "上面的输出不是严格合法的 JSON。请只重新输出严格合法的 JSON,"
  59. "字符串内的换行写成 \\n、双引号写成 \\\",不要任何解释或 markdown。"
  60. )},
  61. ]
  62. raise LLMError(f"llm_json_unrepairable: {last_exc}")
  63. # 默认对话器类型:(system, user) -> dict。stage 可注入假实现做离线测试。
  64. ChatFn = Callable[[str, str], dict]
  65. def default_chat(env_file: str = ".env", model: Optional[str] = None) -> ChatFn:
  66. settings = Settings.from_env(env_file)
  67. return lambda system, user: chat_json(
  68. system, user, model=model, settings=settings
  69. )