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:
@@ -10,6 +10,11 @@ LLM_PROVIDER=openai
|
|||||||
LLM_API_KEY=sk-xxxx
|
LLM_API_KEY=sk-xxxx
|
||||||
LLM_BASE_URL=https://api.openai.com/v1
|
LLM_BASE_URL=https://api.openai.com/v1
|
||||||
LLM_MODEL=gpt-4o
|
LLM_MODEL=gpt-4o
|
||||||
|
# 多 Agent 分档模型(留空则回落 LLM_MODEL)
|
||||||
|
LLM_SPECIALIST_MODEL=
|
||||||
|
LLM_AGGREGATOR_MODEL=
|
||||||
|
# 单次 LLM 调用超时(秒)
|
||||||
|
LLM_TIMEOUT=60
|
||||||
|
|
||||||
# ---- 数据源 ----
|
# ---- 数据源 ----
|
||||||
BZZOIRO_KEY=
|
BZZOIRO_KEY=
|
||||||
@@ -17,3 +22,8 @@ API_FOOTBALL_KEY=
|
|||||||
|
|
||||||
# ---- CORS ----
|
# ---- CORS ----
|
||||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# ---- 管理接口鉴权 ----
|
||||||
|
# 采集/回测/回填接口的访问密钥(请求头 X-API-Key)。
|
||||||
|
# 留空 = 不启用鉴权(本地开发默认);生产环境必须设置强随机值。
|
||||||
|
ADMIN_API_KEY=
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
interface Match {
|
interface Match {
|
||||||
id: number
|
id: number
|
||||||
@@ -69,31 +69,69 @@ export default function Matches() {
|
|||||||
const [league, setLeague] = useState('E0')
|
const [league, setLeague] = useState('E0')
|
||||||
const [status, setStatus] = useState('scheduled')
|
const [status, setStatus] = useState('scheduled')
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
const [matches, setMatches] = useState<Match[]>([])
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [predictingId, setPredictingId] = useState<number | null>(null)
|
const [predictingId, setPredictingId] = useState<number | null>(null)
|
||||||
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
const [prediction, setPrediction] = useState<Prediction | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
||||||
|
|
||||||
|
// 请求竞态防护:切换联赛/状态很快时,先发的慢请求可能后返回,
|
||||||
|
// 把旧结果覆盖到新筛选上。用递增序号只认最后一次请求的响应。
|
||||||
|
const loadSeq = useRef(0)
|
||||||
|
const predictSeq = useRef(0)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
// 切换筛选时作废进行中的「加载更多」,避免其标志位卡住
|
||||||
|
setLoadingMore(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ league, status, limit: '50' })
|
const params = new URLSearchParams({ league, status, limit: '50' })
|
||||||
const res = await fetch(`/api/v1/matches?${params}`)
|
const res = await fetch(`/api/v1/matches?${params}`)
|
||||||
|
if (seq !== loadSeq.current) return // 已有更新的请求,丢弃本次结果
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
if (seq !== loadSeq.current) return
|
||||||
setMatches(data.items)
|
setMatches(data.items)
|
||||||
|
setNextCursor(data.next_cursor ?? null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== loadSeq.current) return
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (seq === loadSeq.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [league, status])
|
}, [league, status])
|
||||||
|
|
||||||
|
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用,
|
||||||
|
// 导致 limit=50 之后的数据永远看不到(见审查报告 P2-8)。
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!nextCursor || loadingMore) return
|
||||||
|
const seq = loadSeq.current // 不做自增:切换筛选会自增,这里只跟随当前序列
|
||||||
|
setLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ league, status, limit: '50', cursor: nextCursor })
|
||||||
|
const res = await fetch(`/api/v1/matches?${params}`)
|
||||||
|
if (seq !== loadSeq.current) return
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (seq !== loadSeq.current) return
|
||||||
|
setMatches(prev => [...prev, ...data.items])
|
||||||
|
setNextCursor(data.next_cursor ?? null)
|
||||||
|
} catch (e) {
|
||||||
|
if (seq !== loadSeq.current) return
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
if (seq === loadSeq.current) setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
const predict = async (matchId: number) => {
|
const predict = async (matchId: number) => {
|
||||||
|
const seq = ++predictSeq.current
|
||||||
setPredictingId(matchId)
|
setPredictingId(matchId)
|
||||||
setError(null)
|
setError(null)
|
||||||
setPrediction(null)
|
setPrediction(null)
|
||||||
@@ -103,16 +141,19 @@ export default function Matches() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ match_id: matchId, mode }),
|
body: JSON.stringify({ match_id: matchId, mode }),
|
||||||
})
|
})
|
||||||
|
if (seq !== predictSeq.current) return
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text()
|
const t = await res.text()
|
||||||
throw new Error(`HTTP ${res.status}: ${t}`)
|
throw new Error(`HTTP ${res.status}: ${t}`)
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
if (seq !== predictSeq.current) return
|
||||||
setPrediction(data)
|
setPrediction(data)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== predictSeq.current) return
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
setPredictingId(null)
|
if (seq === predictSeq.current) setPredictingId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +252,16 @@ export default function Matches() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 分页: 加载更多 */}
|
||||||
|
{nextCursor && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button onClick={loadMore} disabled={loadingMore}
|
||||||
|
className="bg-white border text-gray-700 text-sm px-6 py-2 rounded hover:bg-gray-50 disabled:opacity-50">
|
||||||
|
{loadingMore ? '加载中...' : '加载更多'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 预测中指示 */}
|
{/* 预测中指示 */}
|
||||||
{predictingId && (
|
{predictingId && (
|
||||||
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm flex items-center gap-2">
|
<div className="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""API 依赖:鉴权等横切关注点。
|
||||||
|
|
||||||
|
审查报告 P2-7:ingest / backtest / settle 这类「写入型或高成本」接口此前
|
||||||
|
完全无鉴权 —— 任何能访问到服务的人都可触发采集、或直接烧掉 LLM 额度。
|
||||||
|
|
||||||
|
策略(渐进式,不破坏本地开发):
|
||||||
|
- `ADMIN_API_KEY` 未配置 → 直接放行,并打一次 warning。
|
||||||
|
这样本地 `docker compose up` 无需额外配置即可用。
|
||||||
|
- 已配置 → 必须带匹配的 `X-API-Key` 请求头,否则 401。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Header, HTTPException
|
||||||
|
|
||||||
|
from src.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_warned_unset = False
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin_key(x_api_key: str | None = Header(None, alias="X-API-Key")) -> None:
|
||||||
|
"""保护「写入型 / 高成本」接口的依赖。
|
||||||
|
|
||||||
|
用法: `@router.post("/ingest/bzzoiro", dependencies=[Depends(require_admin_key)])`
|
||||||
|
"""
|
||||||
|
global _warned_unset
|
||||||
|
|
||||||
|
expected = settings.ADMIN_API_KEY
|
||||||
|
if not expected:
|
||||||
|
if not _warned_unset:
|
||||||
|
logger.warning(
|
||||||
|
"ADMIN_API_KEY 未设置,采集/回测接口当前【无鉴权】。"
|
||||||
|
"生产环境请设置该环境变量。"
|
||||||
|
)
|
||||||
|
_warned_unset = True
|
||||||
|
return
|
||||||
|
|
||||||
|
if not x_api_key or not secrets.compare_digest(x_api_key, expected):
|
||||||
|
raise HTTPException(status_code=401, detail="无效或缺失的 X-API-Key")
|
||||||
@@ -3,9 +3,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.api.deps import require_admin_key
|
||||||
from src.llm.backtest import run_backtest
|
from src.llm.backtest import run_backtest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,10 +23,13 @@ class BacktestRequest(BaseModel):
|
|||||||
model: str | None = Field(None, description="指定模型 (空=默认)")
|
model: str | None = Field(None, description="指定模型 (空=默认)")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/backtest")
|
@router.post("/backtest", dependencies=[Depends(require_admin_key)])
|
||||||
async def backtest(req: BacktestRequest):
|
async def backtest(req: BacktestRequest):
|
||||||
"""对历史比赛运行回测。
|
"""对历史比赛运行回测。
|
||||||
|
|
||||||
|
该接口会对每场已完赛比赛各发起一次 LLM 预测,成本高 —— 因此需要
|
||||||
|
`X-API-Key` 鉴权(见审查报告 P2-7)。
|
||||||
|
|
||||||
对每场已完赛比赛:
|
对每场已完赛比赛:
|
||||||
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
|
1. 用比赛之前的数据构建上下文 (防未来信息泄漏)
|
||||||
2. 调 LLM 预测
|
2. 调 LLM 预测
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from src.api.deps import require_admin_key
|
||||||
from src.api.schemas import EvalSummaryOut, SettleRequest
|
from src.api.schemas import EvalSummaryOut, SettleRequest
|
||||||
from src.db.base import AsyncSession, get_db, get_db_read
|
from src.db.base import AsyncSession, get_db, get_db_read
|
||||||
from src.llm.eval import get_eval_summary, settle_prediction
|
from src.llm.eval import get_eval_summary, settle_prediction
|
||||||
@@ -14,7 +15,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/v1", tags=["eval"])
|
router = APIRouter(prefix="/api/v1", tags=["eval"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/eval/settle")
|
@router.post("/eval/settle", dependencies=[Depends(require_admin_key)])
|
||||||
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
async def settle(req: SettleRequest, db: AsyncSession = Depends(get_db)):
|
||||||
"""回填实际结果。"""
|
"""回填实际结果。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from src.api.deps import require_admin_key
|
||||||
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
from src.api.schemas import IngestBzzoiroRequest, IngestResponse, IngestUnderstatRequest, IngestInjuriesRequest, IngestSimpleResponse
|
||||||
from src.data.sources import get_source
|
from src.data.sources import get_source
|
||||||
from src.data.injuries import ingest_injuries
|
from src.data.injuries import ingest_injuries
|
||||||
@@ -15,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
router = APIRouter(prefix="/api/v1", tags=["ingest"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest/bzzoiro", response_model=IngestResponse)
|
@router.post("/ingest/bzzoiro", response_model=IngestResponse, dependencies=[Depends(require_admin_key)])
|
||||||
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
||||||
"""触发 bzzoiro 采集。"""
|
"""触发 bzzoiro 采集。"""
|
||||||
source = get_source("bzzoiro")
|
source = get_source("bzzoiro")
|
||||||
@@ -34,7 +35,7 @@ async def ingest_bzzoiro_route(req: IngestBzzoiroRequest):
|
|||||||
raise HTTPException(500, "数据采集失败,请查看服务器日志")
|
raise HTTPException(500, "数据采集失败,请查看服务器日志")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest/understat", response_model=IngestSimpleResponse)
|
@router.post("/ingest/understat", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
|
||||||
async def ingest_understat_route(req: IngestUnderstatRequest):
|
async def ingest_understat_route(req: IngestUnderstatRequest):
|
||||||
"""触发 understat xG 回填。"""
|
"""触发 understat xG 回填。"""
|
||||||
source = get_source("understat")
|
source = get_source("understat")
|
||||||
@@ -47,7 +48,7 @@ async def ingest_understat_route(req: IngestUnderstatRequest):
|
|||||||
raise HTTPException(500, "xG 回填失败,请查看服务器日志")
|
raise HTTPException(500, "xG 回填失败,请查看服务器日志")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ingest/injuries", response_model=IngestSimpleResponse)
|
@router.post("/ingest/injuries", response_model=IngestSimpleResponse, dependencies=[Depends(require_admin_key)])
|
||||||
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
async def ingest_injuries_route(req: IngestInjuriesRequest):
|
||||||
"""触发伤停采集。"""
|
"""触发伤停采集。"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,5 +33,11 @@ class Settings(BaseSettings):
|
|||||||
# --- CORS ---
|
# --- CORS ---
|
||||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"
|
||||||
|
|
||||||
|
# --- 管理接口鉴权 ---
|
||||||
|
# 采集 / 回测等高成本或写入型接口需要此 Key(请求头 X-API-Key)。
|
||||||
|
# 留空表示「未启用鉴权」(本地开发默认),生产环境必须设置。
|
||||||
|
# 见审查报告 P2-7:ingest/backtest 无鉴权可被任意调用并烧掉 LLM 额度。
|
||||||
|
ADMIN_API_KEY: str = ""
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
+23
-5
@@ -12,7 +12,7 @@ import logging
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
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
|
from src.llm.provider import LLMProvider, LLMResponse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -80,7 +80,12 @@ class AgentReport:
|
|||||||
|
|
||||||
|
|
||||||
def _is_no_data(slice_text: str) -> bool:
|
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()]
|
body = [ln.strip() for ln in slice_text.splitlines() if ln.strip()]
|
||||||
# 去掉标题行(── 开头)
|
# 去掉标题行(── 开头)
|
||||||
content = [ln for ln in body if not ln.startswith("──")]
|
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)
|
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:
|
def _stub_no_data(agent: str) -> AgentReport:
|
||||||
return AgentReport(
|
return AgentReport(
|
||||||
agent=agent,
|
agent=agent,
|
||||||
@@ -145,13 +162,14 @@ async def run_agent(
|
|||||||
"""执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。"""
|
"""执行单个专家 agent: 切片 → no_data 门控 → 调 LLM → 解析报告。"""
|
||||||
# 1. 数据切片
|
# 1. 数据切片
|
||||||
try:
|
try:
|
||||||
slice_text = await spec.slice_fn(header, before=before)
|
slice_result = await spec.slice_fn(header, before=before)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("agent %s slice failed", spec.name)
|
logger.exception("agent %s slice failed", spec.name)
|
||||||
return AgentReport(agent=spec.name, status="error", analysis=f"数据切片失败: {e}")
|
return AgentReport(agent=spec.name, status="error", analysis=f"数据切片失败: {e}")
|
||||||
|
|
||||||
# 2. no_data 门控: 切片无数据 → 不调 LLM
|
# 2. no_data 门控: 切片显式声明无数据 → 不调 LLM
|
||||||
if _is_no_data(slice_text):
|
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)
|
logger.debug("agent %s: slice is no_data, skipping LLM", spec.name)
|
||||||
return _stub_no_data(spec.name)
|
return _stub_no_data(spec.name)
|
||||||
|
|
||||||
|
|||||||
@@ -195,13 +195,14 @@ async def predict_match_multi(
|
|||||||
raise ValueError(f"match {match_id} not found")
|
raise ValueError(f"match {match_id} not found")
|
||||||
|
|
||||||
# 严格校验终裁输出
|
# 严格校验终裁输出
|
||||||
from src.llm.validation import validate_prediction_output
|
from src.llm.validation import validate_agent_weights, validate_prediction_output
|
||||||
try:
|
try:
|
||||||
validated = validate_prediction_output(final)
|
validated = validate_prediction_output(final)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"终裁输出校验失败: {e}")
|
raise RuntimeError(f"终裁输出校验失败: {e}")
|
||||||
|
|
||||||
agent_weights = final.get("agent_weights")
|
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
|
||||||
|
agent_weights = validate_agent_weights(final.get("agent_weights"))
|
||||||
pred = Prediction(
|
pred = Prediction(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
provider=settings.LLM_PROVIDER,
|
provider=settings.LLM_PROVIDER,
|
||||||
|
|||||||
+57
-35
@@ -39,6 +39,22 @@ def _is_stats_available(stats, before) -> bool:
|
|||||||
return stats.available_at <= before
|
return stats.available_at <= before
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SliceResult:
|
||||||
|
"""数据切片的显式结果(替代「靠文案子串猜有无数据」)。
|
||||||
|
|
||||||
|
旧实现用 `"无数据" in slice_text` 判断,依赖具体文案 —— 一旦某个切片
|
||||||
|
写成「无比分数据」「无伤停数据」这类变体,判断就会静默失配
|
||||||
|
(见审查报告 P2-1)。这里让切片函数直接声明 `has_data`,不再猜。
|
||||||
|
"""
|
||||||
|
text: str
|
||||||
|
has_data: bool
|
||||||
|
n_records: int = 0
|
||||||
|
|
||||||
|
def __str__(self) -> str: # 让老调用点可直接当 str 用
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MatchContext:
|
class MatchContext:
|
||||||
match_id: int
|
match_id: int
|
||||||
@@ -98,16 +114,18 @@ def header_text(h: MatchHeader) -> str:
|
|||||||
# 切片函数: 每个领域 agent 一个
|
# 切片函数: 每个领域 agent 一个
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> str:
|
async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> SliceResult:
|
||||||
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
|
"""E - 历史交锋切片: 过去数年 + 近期交手数据,提取交手规律。before=match_date 用于回测。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
h2h = await _get_h2h(db, header.home_team_id, header.away_team_id, before=before, limit=limit)
|
||||||
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
lines = [f"── 历史交锋(近 {limit} 次) ──"]
|
||||||
|
n_with_score = 0
|
||||||
if h2h:
|
if h2h:
|
||||||
home_wins = draws = away_wins = 0
|
home_wins = draws = away_wins = 0
|
||||||
for hm in h2h:
|
for hm in h2h:
|
||||||
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
d = hm.match_date.strftime("%Y-%m") if hm.match_date else "?"
|
||||||
if hm.home_goals is not None:
|
if hm.home_goals is not None:
|
||||||
|
n_with_score += 1
|
||||||
if hm.home_goals > hm.away_goals: home_wins += 1
|
if hm.home_goals > hm.away_goals: home_wins += 1
|
||||||
elif hm.home_goals == hm.away_goals: draws += 1
|
elif hm.home_goals == hm.away_goals: draws += 1
|
||||||
else: away_wins += 1
|
else: away_wins += 1
|
||||||
@@ -119,15 +137,17 @@ async def h2h_slice(header: MatchHeader, *, limit: int = 8, before=None) -> str:
|
|||||||
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
lines.append(f" 总计 {total} 场: 主队 {home_wins}胜 {draws}平 {away_wins}负")
|
||||||
else:
|
else:
|
||||||
lines.append(" 无数据")
|
lines.append(" 无数据")
|
||||||
return "\n".join(lines)
|
# has_data 以「有比分的交锋」为准:仅有对阵无比分时不足以支撑分析
|
||||||
|
return SliceResult(text="\n".join(lines), has_data=n_with_score > 0, n_records=n_with_score)
|
||||||
|
|
||||||
|
|
||||||
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> str:
|
async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> SliceResult:
|
||||||
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
|
"""A - 近期状态切片: 两队近 N 场赛果、关键事件、走势判断。before=match_date 用于回测。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = []
|
lines = []
|
||||||
|
n_scored = 0
|
||||||
for label, name, form, side in (
|
for label, name, form, side in (
|
||||||
("主队", header.home_name, home_form, "home"),
|
("主队", header.home_name, home_form, "home"),
|
||||||
("客队", header.away_name, away_form, "away"),
|
("客队", header.away_name, away_form, "away"),
|
||||||
@@ -140,6 +160,8 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> str
|
|||||||
if o == "W": wins += 1
|
if o == "W": wins += 1
|
||||||
elif o == "D": draws += 1
|
elif o == "D": draws += 1
|
||||||
else: losses += 1
|
else: losses += 1
|
||||||
|
if fm.home_goals is not None:
|
||||||
|
n_scored += 1
|
||||||
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
score = f"{fm.home_goals}-{fm.away_goals}" if fm.home_goals is not None else "vs"
|
||||||
xg = ""
|
xg = ""
|
||||||
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
if fm.stats and _is_stats_available(fm.stats, before) and fm.stats.home_xg is not None:
|
||||||
@@ -150,15 +172,16 @@ async def form_slice(header: MatchHeader, *, limit: int = 5, before=None) -> str
|
|||||||
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
lines.append(f" 近 {len(form)} 场: {wins}胜 {draws}平 {losses}负")
|
||||||
else:
|
else:
|
||||||
lines.append(" 无数据")
|
lines.append(" 无数据")
|
||||||
return "\n".join(lines)
|
return SliceResult(text="\n".join(lines), has_data=n_scored > 0, n_records=n_scored)
|
||||||
|
|
||||||
|
|
||||||
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
|
async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
|
||||||
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
|
"""B - 攻防数据切片: 进球、射门、控球,评估攻防强度。before=match_date 用于回测。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
home_form = await _get_form(db, header.home_team_id, before=before, limit=limit)
|
||||||
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
away_form = await _get_form(db, header.away_team_id, before=before, limit=limit)
|
||||||
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
lines = [f"── 攻防数据(近 {limit} 场) ──"]
|
||||||
|
n_total = 0
|
||||||
for label, name, form, side in (
|
for label, name, form, side in (
|
||||||
("主队", header.home_name, home_form, "home"),
|
("主队", header.home_name, home_form, "home"),
|
||||||
("客队", header.away_name, away_form, "away"),
|
("客队", header.away_name, away_form, "away"),
|
||||||
@@ -184,6 +207,7 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> s
|
|||||||
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
xg += fm.stats.home_xg if side == "home" else fm.stats.away_xg
|
||||||
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
xga += fm.stats.away_xg if side == "home" else fm.stats.home_xg
|
||||||
n_xg += 1
|
n_xg += 1
|
||||||
|
n_total += n
|
||||||
if n > 0:
|
if n > 0:
|
||||||
lines.append(f" {label} {name}:")
|
lines.append(f" {label} {name}:")
|
||||||
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
lines.append(f" 场均进球 {gf/n:.2f}, 场均失球 {ga/n:.2f}")
|
||||||
@@ -194,15 +218,16 @@ async def stats_slice(header: MatchHeader, *, limit: int = 10, before=None) -> s
|
|||||||
lines.append(f" {label} {name}: 无比分数据")
|
lines.append(f" {label} {name}: 无比分数据")
|
||||||
else:
|
else:
|
||||||
lines.append(f" {label} {name}: 无数据")
|
lines.append(f" {label} {name}: 无数据")
|
||||||
return "\n".join(lines)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> str:
|
async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None) -> SliceResult:
|
||||||
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
|
"""C - 主客因素切片: 主场战绩 vs 客场战绩,评估地理优势影响。before=match_date 用于回测。"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
|
home_home = await _get_home_away(db, header.home_team_id, "home", before=before, limit=limit)
|
||||||
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
|
away_away = await _get_home_away(db, header.away_team_id, "away", before=before, limit=limit)
|
||||||
lines = ["── 主客因素 ──"]
|
lines = ["── 主客因素 ──"]
|
||||||
|
n_total = 0
|
||||||
for label, name, matches, side in (
|
for label, name, matches, side in (
|
||||||
("主队主场", header.home_name, home_home, "home"),
|
("主队主场", header.home_name, home_home, "home"),
|
||||||
("客队客场", header.away_name, away_away, "away"),
|
("客队客场", header.away_name, away_away, "away"),
|
||||||
@@ -218,6 +243,7 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None)
|
|||||||
gf += m.home_goals if side == "home" else m.away_goals
|
gf += m.home_goals if side == "home" else m.away_goals
|
||||||
ga += m.away_goals if side == "home" else m.home_goals
|
ga += m.away_goals if side == "home" else m.home_goals
|
||||||
n = wins + draws + losses
|
n = wins + draws + losses
|
||||||
|
n_total += n
|
||||||
if n > 0:
|
if n > 0:
|
||||||
pct = wins / n * 100
|
pct = wins / n * 100
|
||||||
lines.append(f" {label} {name}(近 {n} 场): {wins}胜 {draws}平 {losses}负, 胜率 {pct:.0f}%")
|
lines.append(f" {label} {name}(近 {n} 场): {wins}胜 {draws}平 {losses}负, 胜率 {pct:.0f}%")
|
||||||
@@ -226,10 +252,10 @@ async def home_away_slice(header: MatchHeader, *, limit: int = 10, before=None)
|
|||||||
lines.append(f" {label} {name}: 无比分数据")
|
lines.append(f" {label} {name}: 无比分数据")
|
||||||
else:
|
else:
|
||||||
lines.append(f" {label} {name}: 无数据")
|
lines.append(f" {label} {name}: 无数据")
|
||||||
return "\n".join(lines)
|
return SliceResult(text="\n".join(lines), has_data=n_total > 0, n_records=n_total)
|
||||||
|
|
||||||
|
|
||||||
async def injuries_slice(header: MatchHeader, *, before=None) -> str:
|
async def injuries_slice(header: MatchHeader, *, before=None) -> SliceResult:
|
||||||
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
"""D - 阵容完整性切片: 伤停与停赛名单,评估战力缺失程度。
|
||||||
|
|
||||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
||||||
@@ -242,10 +268,10 @@ async def injuries_slice(header: MatchHeader, *, before=None) -> str:
|
|||||||
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||||
|
|
||||||
lines = ["── 阵容完整性 ──"]
|
lines = ["── 阵容完整性 ──"]
|
||||||
has_data = False
|
n_records = 0
|
||||||
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
|
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
|
||||||
if injuries:
|
if injuries:
|
||||||
has_data = True
|
n_records += len(injuries)
|
||||||
lines.append(f" {label}伤停({len(injuries)}人):")
|
lines.append(f" {label}伤停({len(injuries)}人):")
|
||||||
for inj in injuries[:8]: # 最多显示 8 条
|
for inj in injuries[:8]: # 最多显示 8 条
|
||||||
reason = inj.reason or inj.injury_type or "未知"
|
reason = inj.reason or inj.injury_type or "未知"
|
||||||
@@ -255,10 +281,10 @@ async def injuries_slice(header: MatchHeader, *, before=None) -> str:
|
|||||||
else:
|
else:
|
||||||
lines.append(f" {label}: 无伤停数据")
|
lines.append(f" {label}: 无伤停数据")
|
||||||
|
|
||||||
if not has_data:
|
if n_records == 0:
|
||||||
return "── 阵容完整性 ──\n 无数据"
|
return SliceResult(text="── 阵容完整性 ──\n 无数据", has_data=False, n_records=0)
|
||||||
|
|
||||||
return "\n".join(lines)
|
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -266,42 +292,38 @@ async def injuries_slice(header: MatchHeader, *, before=None) -> str:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
|
async def build_context(match_id: int, *, form_last: int = 5, h2h_last: int = 5) -> MatchContext:
|
||||||
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。"""
|
"""单 agent 路径的完整上下文: 拼接全部切片(before=比赛时间,防未来信息)。
|
||||||
|
|
||||||
|
has_stats / has_injuries 直接取切片显式声明的 has_data,
|
||||||
|
不再靠文案子串匹配(见审查报告 P2-1)。
|
||||||
|
"""
|
||||||
header = await load_match_header(match_id)
|
header = await load_match_header(match_id)
|
||||||
parts = [header_text(header), ""]
|
parts = [header_text(header), ""]
|
||||||
has_stats = False
|
|
||||||
has_injuries = False
|
|
||||||
|
|
||||||
form_text = await form_slice(header, limit=form_last, before=header.match_dt)
|
form_res = await form_slice(header, limit=form_last, before=header.match_dt)
|
||||||
if "无数据" not in form_text:
|
parts.append(form_res.text)
|
||||||
has_stats = True
|
|
||||||
parts.append(form_text)
|
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
h2h_text = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
|
h2h_res = await h2h_slice(header, limit=h2h_last, before=header.match_dt)
|
||||||
parts.append(h2h_text)
|
parts.append(h2h_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
stats_text = await stats_slice(header, before=header.match_dt)
|
stats_res = await stats_slice(header, before=header.match_dt)
|
||||||
if "无数据" not in stats_text:
|
parts.append(stats_res.text)
|
||||||
has_stats = True
|
|
||||||
parts.append(stats_text)
|
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
home_away_text = await home_away_slice(header, before=header.match_dt)
|
home_away_res = await home_away_slice(header, before=header.match_dt)
|
||||||
parts.append(home_away_text)
|
parts.append(home_away_res.text)
|
||||||
parts.append("")
|
parts.append("")
|
||||||
|
|
||||||
injuries_text = await injuries_slice(header, before=header.match_dt)
|
injuries_res = await injuries_slice(header, before=header.match_dt)
|
||||||
if "无数据" not in injuries_text:
|
parts.append(injuries_res.text)
|
||||||
has_injuries = True
|
|
||||||
parts.append(injuries_text)
|
|
||||||
|
|
||||||
return MatchContext(
|
return MatchContext(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
text="\n".join(parts),
|
text="\n".join(parts),
|
||||||
has_stats=has_stats,
|
has_stats=form_res.has_data or stats_res.has_data,
|
||||||
has_injuries=has_injuries,
|
has_injuries=injuries_res.has_data,
|
||||||
match_dt=header.match_dt,
|
match_dt=header.match_dt,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+31
-9
@@ -27,12 +27,18 @@ _cache: dict[str, tuple[float, PredictResult]] = {}
|
|||||||
_cache_lock = Lock()
|
_cache_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
def _cache_key(match_id: int, provider: str, model: str, version: str) -> str:
|
def _cache_key(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> str:
|
||||||
return f"{match_id}:{provider}:{model}:{version}"
|
"""缓存键:含 prompt 模板内容 hash。
|
||||||
|
|
||||||
|
仅用 version 做键不够 —— 编辑器里改动 `match_prediction_v1.md` 而版本号
|
||||||
|
不变时,进程内缓存仍会返回旧模板产生的旧结果(见审查报告 P2-6)。
|
||||||
|
把模板内容 hash 纳入键,模板一改缓存自动失效。
|
||||||
|
"""
|
||||||
|
return f"{match_id}:{provider}:{model}:{version}:{tpl_hash[:12]}"
|
||||||
|
|
||||||
|
|
||||||
def _get_cached(match_id: int, provider: str, model: str, version: str) -> PredictResult | None:
|
def _get_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str) -> PredictResult | None:
|
||||||
key = _cache_key(match_id, provider, model, version)
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
if key in _cache:
|
if key in _cache:
|
||||||
ts, result = _cache[key]
|
ts, result = _cache[key]
|
||||||
@@ -42,12 +48,22 @@ def _get_cached(match_id: int, provider: str, model: str, version: str) -> Predi
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _set_cached(match_id: int, provider: str, model: str, version: str, result: PredictResult) -> None:
|
def _set_cached(match_id: int, provider: str, model: str, version: str, tpl_hash: str, result: PredictResult) -> None:
|
||||||
key = _cache_key(match_id, provider, model, version)
|
key = _cache_key(match_id, provider, model, version, tpl_hash)
|
||||||
with _cache_lock:
|
with _cache_lock:
|
||||||
_cache[key] = (time.time(), result)
|
_cache[key] = (time.time(), result)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_prompt_cache() -> None:
|
||||||
|
"""清空 prompt 模板缓存(供开发/热更新时手动调用)。
|
||||||
|
|
||||||
|
lru_cache 的模板缓存是进程级的,改完 .md 需要重启进程才能生效;
|
||||||
|
提供显式清理入口,避免"改了模板却看不到变化"的困惑(见审查报告 P2-5)。
|
||||||
|
"""
|
||||||
|
_load_prompt_template.cache_clear()
|
||||||
|
logger.info("prompt 模板缓存已清空")
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=8)
|
@functools.lru_cache(maxsize=8)
|
||||||
def _load_prompt_template(version: str = "v1") -> str:
|
def _load_prompt_template(version: str = "v1") -> str:
|
||||||
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
|
"""缓存 prompt 模板(进程生命周期内每个版本只读一次)。"""
|
||||||
@@ -58,6 +74,11 @@ def _load_prompt_template(version: str = "v1") -> str:
|
|||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_template_hash(version: str) -> str:
|
||||||
|
"""prompt 模板内容 hash(用于缓存键,模板变更即失效)。"""
|
||||||
|
return hashlib.sha256(_load_prompt_template(version).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PredictResult:
|
class PredictResult:
|
||||||
prediction_id: int
|
prediction_id: int
|
||||||
@@ -117,10 +138,11 @@ async def _predict_single(
|
|||||||
if model:
|
if model:
|
||||||
provider.model = model
|
provider.model = model
|
||||||
version = prompt_version or "v1"
|
version = prompt_version or "v1"
|
||||||
|
tpl_hash = _prompt_template_hash(version)
|
||||||
|
|
||||||
# 0. 查缓存(同 match+provider+model+version 5 分钟内直接返)
|
# 0. 查缓存(同 match+provider+model+version+模板hash 5 分钟内直接返)
|
||||||
if use_cache:
|
if use_cache:
|
||||||
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version)
|
cached = _get_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
logger.debug("predict cache hit match=%s", match_id)
|
logger.debug("predict cache hit match=%s", match_id)
|
||||||
return cached
|
return cached
|
||||||
@@ -206,5 +228,5 @@ async def _predict_single(
|
|||||||
|
|
||||||
# 5. 写入缓存(仅当允许缓存时)
|
# 5. 写入缓存(仅当允许缓存时)
|
||||||
if use_cache:
|
if use_cache:
|
||||||
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, result)
|
_set_cached(match_id, settings.LLM_PROVIDER, provider.model, version, tpl_hash, result)
|
||||||
return result
|
return result
|
||||||
|
|||||||
+69
-1
@@ -10,6 +10,9 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 已知的 5 个专家 agent 名(与 orchestrator.SPECIALIST_SPECS 保持一致)
|
||||||
|
KNOWN_AGENT_NAMES: tuple[str, ...] = ("form", "stats", "home_away", "injuries", "h2h")
|
||||||
|
|
||||||
|
|
||||||
class AgentReportSchema(BaseModel):
|
class AgentReportSchema(BaseModel):
|
||||||
"""单个专家 Agent 输出的校验 schema。"""
|
"""单个专家 Agent 输出的校验 schema。"""
|
||||||
@@ -64,9 +67,17 @@ class PredictionOutputSchema(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def check_consistency(self) -> "PredictionOutputSchema":
|
def check_consistency(self) -> "PredictionOutputSchema":
|
||||||
"""验证比分与胜平负一致,不一致则自动修正。"""
|
"""验证比分与胜平负一致。
|
||||||
|
|
||||||
|
不一致时以比分为准修正 pred_1x2(比分是更结构化的输出),
|
||||||
|
但**必须告警** —— 静默修正会掩盖 LLM 的自相矛盾,让问题无法被发现。
|
||||||
|
"""
|
||||||
expected = _score_to_1x2(self.pred_home_goals, self.pred_away_goals)
|
expected = _score_to_1x2(self.pred_home_goals, self.pred_away_goals)
|
||||||
if self.pred_1x2 != expected:
|
if self.pred_1x2 != expected:
|
||||||
|
logger.warning(
|
||||||
|
"1x2 与比分不一致: 比分 %.1f-%.1f 推出 '%s',但 LLM 给出 '%s';以比分修正",
|
||||||
|
self.pred_home_goals, self.pred_away_goals, expected, self.pred_1x2,
|
||||||
|
)
|
||||||
self.pred_1x2 = expected
|
self.pred_1x2 = expected
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -80,6 +91,63 @@ def _score_to_1x2(home: float, away: float) -> str:
|
|||||||
return "X"
|
return "X"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentWeightsSchema(BaseModel):
|
||||||
|
"""终裁给出的各专家权重校验 schema。
|
||||||
|
|
||||||
|
权重含义:各专家报告在最终决策中的相对影响力。约束:
|
||||||
|
- key 必须是已知的 5 个专家名
|
||||||
|
- value ∈ [0, 1]
|
||||||
|
- 总和允许有 0.05 的浮点误差(LLM 常凑不到精确 1.0),
|
||||||
|
超出则归一化到 1.0 而不是直接拒收
|
||||||
|
"""
|
||||||
|
weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("weights", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def coerce_weights(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ValueError(f"agent_weights 必须是 dict,得到 {type(v).__name__}")
|
||||||
|
out: dict[str, float] = {}
|
||||||
|
for k, raw in v.items():
|
||||||
|
key = str(k).strip().lower()
|
||||||
|
if key not in KNOWN_AGENT_NAMES:
|
||||||
|
logger.warning("agent_weights 含未知专家 '%s',已忽略", k)
|
||||||
|
continue
|
||||||
|
f = _safe_float(raw)
|
||||||
|
if f is None:
|
||||||
|
logger.warning("agent_weights['%s']=%r 非数值,已忽略", k, raw)
|
||||||
|
continue
|
||||||
|
# 负数直接钳到 0;超过 1 的钳到 1
|
||||||
|
out[key] = min(max(f, 0.0), 1.0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def normalize_sum(self) -> "AgentWeightsSchema":
|
||||||
|
"""权重和不为 1 时归一化(而非拒收),并在偏离较大时告警。"""
|
||||||
|
if not self.weights:
|
||||||
|
return self
|
||||||
|
total = sum(self.weights.values())
|
||||||
|
if total <= 0:
|
||||||
|
return self
|
||||||
|
if abs(total - 1.0) > 0.05:
|
||||||
|
logger.warning("agent_weights 总和为 %.3f,已归一化到 1.0", total)
|
||||||
|
self.weights = {k: v / total for k, v in self.weights.items()}
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
def validate_agent_weights(raw) -> dict[str, float]:
|
||||||
|
"""校验并规范化终裁给出的 agent_weights。非法输入返回空 dict。"""
|
||||||
|
if raw is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return AgentWeightsSchema(weights=raw).weights
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("agent_weights 校验失败,丢弃: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def validate_agent_output(raw: dict) -> AgentReportSchema:
|
def validate_agent_output(raw: dict) -> AgentReportSchema:
|
||||||
"""校验并规范化单个 Agent 输出。"""
|
"""校验并规范化单个 Agent 输出。"""
|
||||||
return AgentReportSchema(
|
return AgentReportSchema(
|
||||||
|
|||||||
@@ -219,3 +219,100 @@ class TestOrchestratorAggregation:
|
|||||||
)
|
)
|
||||||
assert "{{match_header}}" not in rendered
|
assert "{{match_header}}" not in rendered
|
||||||
assert "{{agent_reports}}" not in rendered
|
assert "{{agent_reports}}" not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
class TestSliceResultGate:
|
||||||
|
"""P2-1: 结构化 has_data 门控(替代脆弱的文案子串匹配)。"""
|
||||||
|
|
||||||
|
def test_sliceresult_empty_is_no_data(self):
|
||||||
|
from src.llm.agents.base import _slice_has_data
|
||||||
|
from src.llm.context_builder import SliceResult
|
||||||
|
|
||||||
|
text, has = _slice_has_data(SliceResult(text="── x ──\n 无数据", has_data=False))
|
||||||
|
assert has is False
|
||||||
|
|
||||||
|
def test_sliceresult_with_data_beats_text(self):
|
||||||
|
"""即使文案里出现「无数据」字样,结构化 has_data=True 也应胜出。
|
||||||
|
|
||||||
|
这正是旧实现的漏洞:文案匹配会把「主队: 无伤停数据 / 客队: 2人伤停」
|
||||||
|
这类混合输出……这里显式验证结构化声明优先。
|
||||||
|
"""
|
||||||
|
from src.llm.agents.base import _slice_has_data
|
||||||
|
from src.llm.context_builder import SliceResult
|
||||||
|
|
||||||
|
tricky = SliceResult(
|
||||||
|
text="── 阵容完整性 ──\n 主队: 无伤停数据\n 客队伤停(1人):\n - X: 拉伤",
|
||||||
|
has_data=True,
|
||||||
|
)
|
||||||
|
_, has = _slice_has_data(tricky)
|
||||||
|
assert has is True
|
||||||
|
|
||||||
|
def test_str_fallback_still_works(self):
|
||||||
|
"""旧式 str 切片(测试 mock / 自定义切片)仍走文案回退,保持兼容。"""
|
||||||
|
from src.llm.agents.base import _slice_has_data
|
||||||
|
|
||||||
|
assert _slice_has_data("── 伤停 ──\n 无数据")[1] is False
|
||||||
|
assert _slice_has_data("── 交锋 ──\n A 2-1 B")[1] is True
|
||||||
|
|
||||||
|
def test_sliceresult_str_compat(self):
|
||||||
|
"""SliceResult 可当 str 用(老调用点无需改)。"""
|
||||||
|
from src.llm.context_builder import SliceResult
|
||||||
|
|
||||||
|
s = SliceResult(text="hello", has_data=True)
|
||||||
|
assert str(s) == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAgentWeightsValidation:
|
||||||
|
"""P2-2: agent_weights 必须过校验才能落库。"""
|
||||||
|
|
||||||
|
def test_unknown_agent_dropped(self):
|
||||||
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|
||||||
|
w = validate_agent_weights({"form": 0.4, "h2h": 0.4, "bogus": 0.2, "stats": 0.4})
|
||||||
|
assert "bogus" not in w
|
||||||
|
assert set(w) <= {"form", "stats", "home_away", "injuries", "h2h"}
|
||||||
|
|
||||||
|
def test_out_of_range_clamped(self):
|
||||||
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|
||||||
|
w = validate_agent_weights({"form": 5.0, "h2h": -1.0})
|
||||||
|
assert w["form"] == 1.0
|
||||||
|
assert w["h2h"] == 0.0
|
||||||
|
|
||||||
|
def test_sum_normalized(self):
|
||||||
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|
||||||
|
w = validate_agent_weights({"form": 2.0, "stats": 2.0})
|
||||||
|
assert abs(sum(w.values()) - 1.0) < 1e-9
|
||||||
|
|
||||||
|
def test_no_weights_returns_empty(self):
|
||||||
|
from src.llm.validation import validate_agent_weights
|
||||||
|
|
||||||
|
assert validate_agent_weights(None) == {}
|
||||||
|
assert validate_agent_weights("not a dict") == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredictionConsistencyWarn:
|
||||||
|
"""P2-3: 比分与 1x2 不一致 → 以比分修正(且告警)。"""
|
||||||
|
|
||||||
|
def test_mismatch_is_corrected_to_score(self):
|
||||||
|
from src.llm.validation import validate_prediction_output
|
||||||
|
|
||||||
|
v = validate_prediction_output({
|
||||||
|
"pred_home_goals": 2.0,
|
||||||
|
"pred_away_goals": 1.0,
|
||||||
|
"pred_1x2": "X", # 与 2-1 矛盾
|
||||||
|
"subjective_confidence": 0.7,
|
||||||
|
})
|
||||||
|
assert v.pred_1x2 == "1" # 按比分修正
|
||||||
|
|
||||||
|
def test_consistent_passes_through(self):
|
||||||
|
from src.llm.validation import validate_prediction_output
|
||||||
|
|
||||||
|
v = validate_prediction_output({
|
||||||
|
"pred_home_goals": 0.0,
|
||||||
|
"pred_away_goals": 0.0,
|
||||||
|
"pred_1x2": "X",
|
||||||
|
"subjective_confidence": 0.5,
|
||||||
|
})
|
||||||
|
assert v.pred_1x2 == "X"
|
||||||
|
|||||||
Reference in New Issue
Block a user