200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
"""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)
|