Files
Profeto/src/llm/agents/base.py
T

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)