"""Agent 基础设施: spec 定义 + 执行器。 执行语义: 1. 数据切片为空 / 明确 no_data → 跳过 LLM, 直接返回 stub(省 token 防幻觉) 2. LLM 调用失败 → fail-open, 报告标记 status=error, 不阻断整体 3. 解析失败(LLM 没输出合法 JSON) → status=parse_error """ from __future__ import annotations import functools import logging from dataclasses import dataclass, field from pathlib import Path from src.llm.context_builder import MatchHeader, SliceResult from src.llm.provider import LLMProvider, LLMResponse logger = logging.getLogger(__name__) _PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts" / "agents" NO_DATA_SENTINELS = ("无数据", "no data", "no_data") @functools.lru_cache(maxsize=16) def load_agent_prompt(name: str, version: str = "v1") -> str: """缓存加载 agent prompt 模板。""" path = _PROMPT_DIR / f"{name}_{version}.md" if not path.exists(): raise FileNotFoundError(f"agent prompt 不存在: {path}") with open(path, encoding="utf-8") as f: return f.read() @dataclass class AgentSpec: """领域专家 agent 定义。""" name: str # h2h / form / home_away / injuries / stats system_prompt: str # system message slice_fn: object # async (header, before) -> str 切片函数 @dataclass class AgentReport: """专家 agent 统一输出契约。""" agent: str status: str = "ok" # ok | no_data | error | parse_error data_sufficiency: str = "medium" # high | medium | low | none analysis: str = "" home_edge: float | None = None # -1.0 ~ 1.0, 正=利主队 subjective_confidence: float | None = None # 0.0 ~ 1.0 key_evidence: list[str] = field(default_factory=list) # xg agent 专属 exp_home_goals: float | None = None exp_away_goals: float | None = None probable_score: str | None = None # 元信息 model: str = "" latency_ms: int | None = None prompt_tokens: int | None = None completion_tokens: int | None = None def to_dict(self) -> dict: return { "agent": self.agent, "status": self.status, "data_sufficiency": self.data_sufficiency, "analysis": self.analysis, "home_edge": self.home_edge, "subjective_confidence": self.subjective_confidence, "key_evidence": self.key_evidence, "exp_home_goals": self.exp_home_goals, "exp_away_goals": self.exp_away_goals, "probable_score": self.probable_score, "model": self.model, "latency_ms": self.latency_ms, "prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, } def _is_no_data(slice_text: str) -> bool: """兜底: 判断纯字符串切片是否全无数据。 仅用于 slice_fn 返回 `str`(未升级为 SliceResult)的场景。 新代码应让切片返回 SliceResult 并显式声明 has_data —— 字符串子串匹配 依赖具体文案(「无比分数据」「无伤停数据」等变体会漏判),不可靠。 """ body = [ln.strip() for ln in slice_text.splitlines() if ln.strip()] # 去掉标题行(── 开头) content = [ln for ln in body if not ln.startswith("──")] if not content: return True return all(any(s in ln for s in NO_DATA_SENTINELS) for ln in content) def _slice_has_data(slice_result) -> tuple[str, bool]: """把切片返回值统一成 (text, has_data)。 优先使用 SliceResult.has_data(结构化,可信);若切片函数仍返回 str, 则回退到文案子串匹配(向后兼容)。 """ if isinstance(slice_result, SliceResult): return slice_result.text, slice_result.has_data text = str(slice_result) return text, not _is_no_data(text) def _stub_no_data(agent: str) -> AgentReport: return AgentReport( agent=agent, status="no_data", data_sufficiency="none", analysis="该维度无数据,跳过分析。", ) def _parse_report(agent: str, parsed: dict, resp: LLMResponse, model: str) -> AgentReport: """把 LLM JSON 输出解析为 AgentReport,经过严格校验。""" from src.llm.validation import validate_agent_output try: validated = validate_agent_output(parsed) except Exception as e: # 校验失败 → 返回 parse_error 而非静默降级 return AgentReport( agent=agent, status="parse_error", analysis=f"输出校验失败: {e}", model=model, latency_ms=resp.latency_ms, prompt_tokens=resp.prompt_tokens, completion_tokens=resp.completion_tokens, ) return AgentReport( agent=agent, status="ok", data_sufficiency=validated.data_sufficiency, analysis=validated.analysis, home_edge=validated.home_edge, subjective_confidence=validated.subjective_confidence, key_evidence=validated.key_evidence, exp_home_goals=validated.exp_home_goals, exp_away_goals=validated.exp_away_goals, probable_score=validated.probable_score, model=model, latency_ms=resp.latency_ms, prompt_tokens=resp.prompt_tokens, completion_tokens=resp.completion_tokens, ) async def run_agent( spec: AgentSpec, header: MatchHeader, provider: LLMProvider, *, before=None, version: str = "v1", ) -> AgentReport: """执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。""" # 1. 数据切片 try: slice_result = await spec.slice_fn(header, before=before) except Exception as e: logger.exception("agent %s slice failed", spec.name) return AgentReport(agent=spec.name, status="error", analysis=f"数据切片失败: {e}") # 2. no_data 门控: 切片显式声明无数据 → 不调 LLM slice_text, has_data = _slice_has_data(slice_result) if not has_data: logger.debug("agent %s: slice is no_data, skipping LLM", spec.name) return _stub_no_data(spec.name) # 3. 拼 prompt(模板中 {{context}} 为切片占位) template = load_agent_prompt(spec.name, version) user_prompt = template.replace("{{context}}", slice_text) # 4. 调 LLM resp = await provider.chat( system=spec.system_prompt, user=user_prompt, json_mode=True, temperature=0.2, max_tokens=4096, # 推理模型需要更大余量 ) if resp.error: logger.warning("agent %s LLM failed: %s", spec.name, resp.error) return AgentReport(agent=spec.name, status="error", analysis=f"LLM 调用失败: {resp.error}") # 5. 解析 if not resp.parsed: return AgentReport( agent=spec.name, status="parse_error", analysis=f"LLM 输出无法解析为 JSON: {resp.content[:200]}", ) return _parse_report(spec.name, resp.parsed, resp, provider.model)