安全加固 + 数据管线修复 + 质量改进(第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:
+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