""" directors.llm — 导演层共用的轻量 LLM 客户端 ============================================= 配置优先级:环境变量 DEEPSEEK_* > agent-local local.yaml(llm) > 09_ave/config.yaml(llm) (与评论生成 AIGenerator 一致的取配置方式,但独立实现,避免跨模块耦合) 仅提供最简 chat 调用 + JSON 解析,供各导演实现使用。 """ from __future__ import annotations import json import logging import os import re import urllib.error import urllib.request from pathlib import Path from typing import Any import yaml from lib.http import urlopen as _noproxy_urlopen # 禁代理+重试 log = logging.getLogger("directors.llm") _SCRIPTS = Path(__file__).resolve().parent.parent # 09_ave/scripts/ _AVE_DIR = _SCRIPTS.parent # 09_ave/ _AGENT_LOCAL = Path(os.environ.get("AGENT_LOCAL") or (_AVE_DIR.parents[2] / "agent-local")) _LOCAL_CFG = _AGENT_LOCAL / "tools" / "ave" / "config" / "local.yaml" _AVE_CFG = _AVE_DIR / "config.yaml" def load_llm_config() -> dict: """三级优先级读取 LLM 配置""" cfg: dict = {} # ③ 09_ave/config.yaml if _AVE_CFG.exists(): try: raw = yaml.safe_load(_AVE_CFG.read_text(encoding="utf-8")) or {} cfg.update(raw.get("llm") or {}) except Exception: pass # ② 配置中心 local.yaml → llm if _LOCAL_CFG.exists(): try: raw = yaml.safe_load(_LOCAL_CFG.read_text(encoding="utf-8")) or {} cfg.update(raw.get("llm") or {}) except Exception: pass # ① 环境变量 if os.environ.get("DEEPSEEK_API_KEY"): cfg["api_key"] = os.environ["DEEPSEEK_API_KEY"] if os.environ.get("DEEPSEEK_BASE_URL"): cfg["base_url"] = os.environ["DEEPSEEK_BASE_URL"] if os.environ.get("DEEPSEEK_MODEL"): cfg["model"] = os.environ["DEEPSEEK_MODEL"] cfg.setdefault("base_url", "https://api.deepseek.com/v1") cfg.setdefault("model", "deepseek-v4-flash") return cfg class DirectorLLM: """导演层 LLM 客户端(单次同步调用)""" def __init__(self, timeout: int = 90): cfg = load_llm_config() self.api_key = (cfg.get("api_key") or "").strip() self.base_url = (cfg.get("base_url") or "https://api.deepseek.com/v1").rstrip("/") self.model = cfg.get("model") or "deepseek-v4-flash" self.timeout = timeout self._fails = 0 @property def available(self) -> bool: return bool(self.api_key) and self._fails < 3 def chat(self, prompt: str, system: str = "", temperature: float = 0.7, max_tokens: int = 3000) -> str: """单次对话(失败返回空串,连续 3 次失败熔断)""" if not self.available: log.warning(" ⚠️ LLM 不可用(未配 key 或已熔断)") return "" messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) body = json.dumps({ "model": self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, # 关思考模式:分镜生成不需要长推理,省 token 且更快 "thinking": {"type": "disabled"}, }).encode("utf-8") req = urllib.request.Request( f"{self.base_url}/chat/completions", data=body, headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"}) try: with _noproxy_urlopen(req, timeout=self.timeout) as r: data = json.loads(r.read().decode("utf-8")) content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "") self._fails = 0 return (content or "").strip() except urllib.error.HTTPError as e: detail = e.read().decode("utf-8", errors="ignore")[:200] log.error(" ❌ LLM HTTP %s: %s", e.code, detail) self._fails += 1 return "" except Exception as e: log.error(" ❌ LLM 调用失败: %s", e) self._fails += 1 return "" # ── JSON 解析(容错)────────────────────────────── @staticmethod def parse_json(text: str) -> Any: """从 LLM 输出里提取 JSON(容忍 markdown 围栏 / 前后废话)""" if not text: return None t = text.strip() # 去 markdown 围栏 m = re.search(r"```(?:json)?\s*(.+?)\s*```", t, re.S) if m: t = m.group(1).strip() # 直接解析 try: return json.loads(t) except Exception: pass # 找第一个 { 或 [ 到最后一个 } 或 ] for open_c, close_c in (("{", "}"), ("[", "]")): i, j = t.find(open_c), t.rfind(close_c) if i >= 0 and j > i: try: return json.loads(t[i:j + 1]) except Exception: continue log.warning(" ⚠️ LLM 输出无法解析为 JSON(前 120 字: %s)", t[:120]) return None