fix(P2): no_data 结构化、权重校验、鉴权与前端竞态
P2-1 no_data 门控依赖文案子串(脆弱): - context_builder 新增 SliceResult(text/has_data/n_records), 5 个切片函数改为显式声明 has_data - base._slice_has_data() 优先取结构化结果,str 返回仍走文案回退 (兼容既有测试 mock 与自定义切片) - build_context 的 has_stats/has_injuries 直接取切片声明 P2-2 agent_weights 无校验即落库: - validation 新增 AgentWeightsSchema / validate_agent_weights: 未知专家名丢弃、越界值钳制、总和非 1 时归一化 - orchestrator 落库前对 agent_weights 做校验 P2-3 1x2 与比分不一致被静默修正: - 仍以比分修正,但补 logger.warning 暴露 LLM 自相矛盾 P2-5/P2-6 prompt 缓存不可刷新 + 缓存键不含模板内容: - 新增 clear_prompt_cache() 供改模板后显式失效 - 缓存键纳入模板内容 hash,模板一改缓存自动失效 P2-7 ingest/backtest/settle 接口无鉴权: - 新增 require_admin_key 依赖(X-API-Key), ADMIN_API_KEY 未设置时放行并告警(不破坏本地开发) - 挂到 3 个 ingest 接口 + backtest + eval/settle P2-8 前端请求竞态 + 未使用游标分页: - Matches.tsx 用递增 seq 丢弃过期响应,避免旧筛选结果覆盖新筛选 - 接入后端已有的 cursor 分页 + 「加载更多」按钮 附带: .env.example 补齐 LLM_TIMEOUT / 分档模型 / ADMIN_API_KEY; tests 新增 10 个用例覆盖 P2-1/2/3。
This commit is contained in:
+23
-5
@@ -12,7 +12,7 @@ import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from src.llm.context_builder import MatchHeader
|
||||
from src.llm.context_builder import MatchHeader, SliceResult
|
||||
from src.llm.provider import LLMProvider, LLMResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -80,7 +80,12 @@ class AgentReport:
|
||||
|
||||
|
||||
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("──")]
|
||||
@@ -89,6 +94,18 @@ def _is_no_data(slice_text: str) -> bool:
|
||||
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,
|
||||
@@ -145,13 +162,14 @@ async def run_agent(
|
||||
"""执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。"""
|
||||
# 1. 数据切片
|
||||
try:
|
||||
slice_text = await spec.slice_fn(header, before=before)
|
||||
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
|
||||
if _is_no_data(slice_text):
|
||||
# 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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user