安全加固 + 数据管线修复 + 质量改进(第2批)
安全加固: - /api/v1/predict 限流改用 get_client_ip 防 X-Forwarded-For 伪造 - 新增 TRUST_PROXY_HEADERS/REQUIRE_ADMIN_AUTH 配置,默认 fail-closed - 生产环境管理接口未配置鉴权时拒绝(503),不再放行 数据管线修复: - injuries IntegrityError 改用 begin_nested(SAVEPOINT)隔离批次 - injuries 空名单 vs 未配置语义区分(has_data 精确标记) - bzzoiro 统计字段映射(shots/possession/cards) + 入库条件放宽 - available_at 语义收紧(回测防泄漏) - team_names NFKD 去变音双重查找 + 补齐变体键 预测系统改进: - multi-agent 全专家失败时 status=degraded 跳过终裁 - agent_weights 独立持久化到 predictions 表 新增迁移: - 0014_predictions_agent_weights.py 新增测试(8个文件): - test_ip_spoofing.py: 限流防伪造 - test_require_admin_fail_closed.py: 生产 fail-closed - test_injuries_integrity_rollback.py: SAVEPOINT 隔离 - test_injuries_slice_semantic.py: 空名单 vs 未配置 - test_available_at.py: 回测防泄漏 - test_bzzoirot_stats.py: 统计字段映射 - test_team_names_normalize.py: NFKD 变体 - test_multi_agent_degraded.py: 全失败 degraded - test_agent_weights_persist.py: 权重持久化
This commit is contained in:
@@ -243,11 +243,35 @@ async def predict_match_multi(
|
||||
# 2. 并行专家(各自独立配置,使用统一 cutoff)
|
||||
reports = await run_specialists(header, version=version, before=cutoff)
|
||||
|
||||
# 3. 终裁
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
|
||||
header, reports, provider=aggregator_provider, version=version
|
||||
)
|
||||
# 2.5 统计有效专家报告数量
|
||||
ok_reports = [r for r in reports if r.status == "ok"]
|
||||
has_valid_data = len(ok_reports) > 0
|
||||
|
||||
# 3. 终裁(仅当有有效专家报告时执行)
|
||||
if has_valid_data:
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
|
||||
header, reports, provider=aggregator_provider, version=version
|
||||
)
|
||||
else:
|
||||
# 所有专家无数据/均失败:跳过终裁,标记 degraded
|
||||
logger.warning(
|
||||
"match %s: 所有 %d 位专家均无有效数据,跳过终裁,标记 degraded",
|
||||
match_id, len(reports),
|
||||
)
|
||||
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
|
||||
final = {
|
||||
"pred_home_goals": None,
|
||||
"pred_away_goals": None,
|
||||
"alt_pred_home_goals": None,
|
||||
"alt_pred_away_goals": None,
|
||||
"pred_1x2": None,
|
||||
"subjective_confidence": None,
|
||||
"reasoning": f"所有 {len(reports)} 位专家均无有效数据或预测失败(状态: {','.join(r.status for r in reports)})",
|
||||
"agent_weights": {},
|
||||
}
|
||||
agg_prompt_tokens = 0
|
||||
agg_completion_tokens = 0
|
||||
|
||||
latency_ms = int((time.perf_counter() - start) * 1000)
|
||||
|
||||
@@ -262,15 +286,23 @@ async def predict_match_multi(
|
||||
if m is None:
|
||||
raise ValueError(f"match {match_id} not found")
|
||||
|
||||
# 严格校验终裁输出
|
||||
# 根据是否有有效数据决定校验策略
|
||||
from src.llm.validation import validate_agent_weights, validate_prediction_output
|
||||
try:
|
||||
validated = validate_prediction_output(final)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"终裁输出校验失败: {e}")
|
||||
|
||||
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
|
||||
agent_weights = validate_agent_weights(final.get("agent_weights"))
|
||||
if has_valid_data:
|
||||
# 有有效报告:严格校验终裁输出
|
||||
try:
|
||||
validated = validate_prediction_output(final)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"终裁输出校验失败: {e}")
|
||||
agent_weights = validate_agent_weights(final.get("agent_weights"))
|
||||
pred_status = "success"
|
||||
else:
|
||||
# 无有效报告:跳过严格校验,直接构造降级结果
|
||||
validated = None # type: ignore
|
||||
agent_weights = {}
|
||||
pred_status = "degraded"
|
||||
|
||||
pred = await _upsert_prediction(
|
||||
session,
|
||||
match_id=match_id,
|
||||
@@ -283,16 +315,17 @@ async def predict_match_multi(
|
||||
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
|
||||
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
|
||||
"latency_ms": latency_ms,
|
||||
"pred_home_goals": validated.pred_home_goals,
|
||||
"pred_away_goals": validated.pred_away_goals,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals,
|
||||
"pred_1x2": validated.pred_1x2,
|
||||
"subjective_confidence": validated.subjective_confidence,
|
||||
"reasoning": validated.reasoning,
|
||||
"pred_home_goals": validated.pred_home_goals if validated else None,
|
||||
"pred_away_goals": validated.pred_away_goals if validated else None,
|
||||
"alt_pred_home_goals": validated.alt_pred_home_goals if validated else None,
|
||||
"alt_pred_away_goals": validated.alt_pred_away_goals if validated else None,
|
||||
"pred_1x2": validated.pred_1x2 if validated else None,
|
||||
"subjective_confidence": validated.subjective_confidence if validated else None,
|
||||
"reasoning": validated.reasoning if validated else final.get("reasoning", ""),
|
||||
"raw_response": final,
|
||||
"agent_outputs": [r.to_dict() for r in reports],
|
||||
"status": "success",
|
||||
"agent_weights": agent_weights,
|
||||
"status": pred_status,
|
||||
"match_kickoff_at": match_kickoff_at,
|
||||
"prediction_cutoff_at": prediction_cutoff_at,
|
||||
"prediction_created_at": now,
|
||||
|
||||
+57
-17
@@ -40,11 +40,23 @@ def _outcome(home_goals: int, away_goals: int, side: str) -> str:
|
||||
|
||||
|
||||
def _is_stats_available(stats, before) -> bool:
|
||||
"""检查统计数据在 cutoff 时间是否已可用。"""
|
||||
"""检查统计数据在 cutoff 时间是否已可用。
|
||||
|
||||
available_at 语义:该条统计「对外可被使用」的最早时间,
|
||||
至少不得早于比赛结束。用于回测防泄漏。
|
||||
|
||||
规则:
|
||||
- before is None(实盘):available_at 为 None 时允许(兼容旧数据)
|
||||
- before is not None(回测):available_at 为 None 视为不可用(保守)
|
||||
- available_at > cutoff:不可用(数据在 cutoff 之后才生成)
|
||||
"""
|
||||
if before is None:
|
||||
# 实盘模式:无时间信息时允许(兼容旧数据)
|
||||
return True
|
||||
# 回测模式(cutoff 不为 None):
|
||||
# available_at 为 None → 无法确认是否在 cutoff 前可用,保守视为不可用
|
||||
if stats.available_at is None:
|
||||
return True # 无时间信息时保守处理:允许使用
|
||||
return False
|
||||
return stats.available_at <= before
|
||||
|
||||
|
||||
@@ -330,34 +342,62 @@ async def injuries_slice(header: MatchHeader, *, before=None, db: AsyncSession |
|
||||
|
||||
before=cutoff: 只使用 cutoff 之前已采集的伤停数据,防回测泄漏。
|
||||
db: 可选共享 session(见模块 docstring)。
|
||||
|
||||
语义区分:
|
||||
- 查询成功 + 空结果 → has_data=True(明确知道「无人伤停」)
|
||||
- 源未配置 / 查询失败 → has_data=False(无法判断,跳过 LLM)
|
||||
"""
|
||||
from src.data.injuries import get_injuries_for_match
|
||||
from src.data.injuries import get_injuries_for_match, InjuryQueryResult
|
||||
|
||||
cutoff = before or header.match_dt
|
||||
if db is not None:
|
||||
home_injuries = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_injuries = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
home_result = await get_injuries_for_match(db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
else:
|
||||
async with AsyncSessionLocal() as new_db:
|
||||
home_injuries = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_injuries = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
home_result = await get_injuries_for_match(new_db, header.home_team_id, cutoff, as_of=cutoff)
|
||||
away_result = await get_injuries_for_match(new_db, header.away_team_id, cutoff, as_of=cutoff)
|
||||
|
||||
# 判断是否有有效查询结果
|
||||
# 两队都成功查询(即使为空) → has_data=True
|
||||
# 任一查询失败或源未配置 → has_data=False
|
||||
both_succeeded = (
|
||||
home_result.query_status == "success"
|
||||
and away_result.query_status == "success"
|
||||
)
|
||||
any_configured = (
|
||||
home_result.query_status != "source_not_configured"
|
||||
or away_result.query_status != "source_not_configured"
|
||||
)
|
||||
|
||||
lines = ["── 阵容完整性 ──"]
|
||||
n_records = 0
|
||||
for label, injuries in (("主队", home_injuries), ("客队", away_injuries)):
|
||||
if injuries:
|
||||
n_records += len(injuries)
|
||||
lines.append(f" {label}伤停({len(injuries)}人):")
|
||||
for inj in injuries[:8]: # 最多显示 8 条
|
||||
|
||||
for label, result in (("主队", home_result), ("客队", away_result)):
|
||||
if result.query_status == "source_not_configured":
|
||||
lines.append(f" {label}: 伤停源未配置")
|
||||
elif result.query_status == "query_error":
|
||||
lines.append(f" {label}: 查询异常")
|
||||
elif result.records:
|
||||
n_records += len(result.records)
|
||||
lines.append(f" {label}伤停({len(result.records)}人):")
|
||||
for inj in result.records[:8]:
|
||||
reason = inj.reason or inj.injury_type or "未知"
|
||||
lines.append(f" - {inj.player_name}: {reason}")
|
||||
if len(injuries) > 8:
|
||||
lines.append(f" ...及其他 {len(injuries) - 8} 人")
|
||||
if len(result.records) > 8:
|
||||
lines.append(f" ...及其他 {len(result.records) - 8} 人")
|
||||
else:
|
||||
lines.append(f" {label}: 无伤停数据")
|
||||
# 查询成功但无人伤停
|
||||
lines.append(f" {label}: 当前无伤停记录")
|
||||
|
||||
if n_records == 0:
|
||||
return SliceResult(text="── 阵容完整性 ──\n 无数据", has_data=False, n_records=0)
|
||||
# 决定 has_data:
|
||||
# - 两队都成功查询(即使为空) → True(明确知道名单)
|
||||
# - 源未配置且无数据 → False
|
||||
has_data = both_succeeded or (any_configured and n_records > 0)
|
||||
|
||||
if not has_data:
|
||||
# 保留详细状态文案(伤停源未配置/查询异常),而非通用「无数据」
|
||||
return SliceResult(text="\n".join(lines), has_data=False, n_records=0)
|
||||
|
||||
return SliceResult(text="\n".join(lines), has_data=True, n_records=n_records)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user