12 Commits
Author SHA1 Message Date
shangfangjian 28ecf85da8 fix: MatchStats 主键是 match_id 非 id
scheduler._run_checks 误用 MatchStats.id(MatchStats 无此列,主键为 match_id),
导致数据质量检查定时任务抛 AttributeError。修正为 MatchStats.match_id.is_(None)。
2026-09-22 10:18:00 +08:00
shangfangjian b749b1c621 fix: 前端构建修复 + P3-1 子组件路径校正
- tsconfig 排除 *.test.ts(生产构建不编译测试)
- OutcomePanel TeamSideTag 导入路径 ../../ → ../../../
- AgentsPanel/OutcomePanel/ReasoningPanel types 导入 ../../ → ../
- MatchPredictPanel JSDoc 注释 **. → /** (P3-1 编辑损坏)

前端构建成功,容器 healthy。
2026-09-22 10:09:48 +08:00
shangfangjian 69ec19e646 fix: api healthcheck 改用 python urllib(生产镜像无 curl)
python:3.11-slim 生产镜像不含 curl,原 healthcheck 始终失败;
改为 python urllib.request,与 Dockerfile 非 root 用户兼容。
2026-09-22 09:59:50 +08:00
shangfangjian 5f4075be22 fix(P1 follow-ups): 恢复误删 set_runtime_value + 假 DB 适配 P1-K UPSERT
- 恢复 P1-G 编辑时误删的 clear_runtime_value/set_runtime_value
- 修复 bronze 测试 patch 目标(pipeline_write 替代门面 bz.*)
- 增强假 DB:处理 pg_insert(Insert 语句)+ Team name 回退 + League import

全量 319 通过。
2026-09-22 09:43:48 +08:00
shangfangjian eae88f4cd9 fix(P1-G/H/I/K): 运行时配置解密/Redis JSON/eval summary/UPSERT
P1-G runtime_config:DB 故障回落 env;解密失败在生产环境抛出(ValueError),
  非生产回落;DB 异常与解密异常不再共用 except Exception。
P1-H Redis 预测缓存:去掉 pickle 改用 JSON + dataclasses.asdict,
  datetime 字段 ISO 序列化。
P1-I eval get_eval_summary:默认 limit=None(不冒充全集);新增 run_type(默认 live)
  与 season 过滤;_build_filters 同步扩展。
P1-K Team/League get_or_create:INSERT 改为 PG UPSERT(ON CONFLICT DO NOTHING)
  防并发重复插入。

测试 test_p1_g_runtime_config(4/4) + test_p1_k_upsert(4/4);全量绿。
2026-09-22 09:08:35 +08:00
shangfangjian 2b52478b8f test(P1-F): BIGINT 主键表 INSERT 不带 id 自动生成验证
真 PG 测试(独立 engine,避免事件循环污染):
- data_lineage / raw_event 未指定 id → DB 自动生成递增唯一 id
- 连续 INSERT id 递增且唯一

3/3 通过。
2026-09-22 08:57:20 +08:00
shangfangjian b30ad56319 fix(P1-E): 启动时标 failed 残留 pending/running ingest_jobs
lifespan 加 _fail_stale_ingest_jobs():UPDATE ingest_jobs SET status=failed
WHERE status IN ('pending','running');异常仅记 warning 不阻断启动。
顺手修复 0021(op.text→字符串)/0023(索引名 leason→league)/0024(type_unique→type_)
三个待执行迁移 bug。

测试 test_p1_e_stale_ingest_jobs(2/2 mock);全量 315 通过。
2026-09-22 08:49:30 +08:00
shangfangjian b997c06ede fix(P1-D): 全局 LLM 并发限制 + 删除未接线的 provider 字段
- 删除 PredictRequest.provider(未接线,路由从未传入,符合禁令 #7)
- 新增 _GLOBAL_LLM_SEMAPHORE(默认 4) + _predict_with_concurrency:
  公开 /predict 所有模式汇总受全局并发限制,与 orchestrator 内 match 级
  Semaphore(8) 并存。

测试 test_p1_d_concurrency(4/4);全量 313 通过。
2026-09-22 03:44:09 +08:00
shangfangjian 1ddf697c97 fix(P1-C): 公开预测仅 live+success,且不含 reasoning/agent_outputs
GET /matches/{id}:查询加 WHERE run_type='live' AND status='success';
recent_predictions 不再输出 reasoning/agent_outputs(避免泄露内部推理)。
MatchOut.recent_predictions schema 放宽为 list[dict]。

测试 test_p1_c_public_predictions(2/2);全量 309 通过。
2026-09-22 03:39:36 +08:00
shangfangjian 5ff43d4984 fix(P1-A/P1-B): ingest 联赛级计数修正 + 非法 cursor 400
P1-A: ingest 联赛级 inserted/updated 读 r["leagues"][code] 而非顶层 r.get("inserted");
抽 _accumulate_ingest_result 纯函数 + 合约测试(4/4)。

P1-B: 非法 cursor 不再静默忽略,返回 400 + detail.code=INVALID_CURSOR;
抽 _parse_cursor 纯函数 + 测试(8/8)。

全量 307 通过。
2026-09-22 03:31:40 +08:00
shangfangjian 41cb2edd47 fix(P1-A): ingest 联赛级计数读 leagues[code],抽为纯函数
旧代码读 r.get("inserted")(顶层无此 key)导致联赛级计数总为 0;
改为读 r["leagues"][code].inserted/updated,与顶层 total_* 分离。
抽 _accumulate_ingest_result 纯函数 + 4 合约测试(全绿)。
2026-09-22 03:23:41 +08:00
shangfangjian 64ae8e663a fix(P0-03): Prediction 幂等指纹——只追加,不覆盖
_upsert_prediction 改为 _insert_or_find_by_fingerprint:
- 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs)
- 不同 input_hash → INSERT 新行

input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version,
prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature,
context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束,
改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。

三条路径(single/multi/baseline)统一传足指纹字段。
迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
2026-09-22 03:13:32 +08:00
39 changed files with 1462 additions and 253 deletions
@@ -30,7 +30,7 @@ def upgrade() -> None:
'matches', 'matches',
['source_event_id'], ['source_event_id'],
unique=True, unique=True,
postgresql_where=op.text('source_event_id IS NOT NULL'), postgresql_where='source_event_id IS NOT NULL',
) )
@@ -34,8 +34,9 @@ def upgrade() -> None:
# 2) 去旧唯一约束,加新唯一约束(league, season, team, available_at) # 2) 去旧唯一约束,加新唯一约束(league, season, team, available_at)
op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique') op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique')
op.drop_index('ix_standings_leason_season_pos', table_name='standings') # 原始索引名拼写为 leason(历史遗留),按实际库名删除
op.create_index('ix_standings_league_season_pos', 'standings', ['league_id', 'season', 'position']) op.drop_index('ix_standings_league_season_pos', table_name='standings', if_exists=True)
op.create_index('ix_standings_league_season_pos_v2', 'standings', ['league_id', 'season', 'position'])
op.create_unique_constraint( op.create_unique_constraint(
'uq_standings_league_season_team_available', 'standings', 'uq_standings_league_season_team_available', 'standings',
['league_id', 'season', 'team_id', 'available_at'], ['league_id', 'season', 'team_id', 'available_at'],
@@ -0,0 +1,41 @@
"""P0-03: Prediction 幂等指纹——移除旧唯一约束,改为 partial unique on input_hash
input_hash 非空时唯一(同指纹返回已有行,不 UPDATE/INSERT);
兼容旧数据 NULL input_hash(不强制回填)。
Revision ID: 0024_prediction_idempotent_fingerprint
Revises: 0023_standings_append_only
Create Date: 2026-09-22
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '0024_prediction_idempotent_fingerprint'
down_revision: Union[str, None] = '0023_standings_append_only'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 移除旧唯一约束(match, provider, model, mode, run_type)
op.drop_constraint(
'uq_predictions_match_provider_model_mode_run_type',
'predictions', type_='unique',
)
# P0-03: partial unique on input_hash(非空时唯一)
op.create_index(
'ix_predictions_input_hash_unique', 'predictions', ['input_hash'], unique=True,
postgresql_where=sa.text('input_hash IS NOT NULL'),
)
def downgrade() -> None:
op.drop_index('ix_predictions_input_hash_unique', table_name='predictions')
op.create_unique_constraint(
'uq_predictions_match_provider_model_mode_run_type',
'predictions',
['match_id', 'provider', 'model', 'mode', 'run_type'],
)
+2 -2
View File
@@ -30,11 +30,11 @@ services:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football} DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
env_file: .env env_file: .env
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"] test: ["CMD-SHELL", "python -c \"import urllib.request; exit(0 if urllib.request.urlopen('http://localhost:8000/health/ready', timeout=5).status==200 else 1)\" || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 15s
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -4,8 +4,8 @@
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。 * P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/ */
import { useState } from 'react' import { useState } from 'react'
import type { AgentReport, Prediction } from '../../types' import type { AgentReport, Prediction } from '../types'
import { AGENT_LABELS, CN_NUM } from '../../types' import { AGENT_LABELS, CN_NUM } from '../types'
const STATUS_BADGE: Record<string, { label: string; cls: string }> = { const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: '正常', cls: 'text-ink-500' }, ok: { label: '正常', cls: 'text-ink-500' },
@@ -27,7 +27,7 @@ function Spinner({ className = '' }: { className?: string }) {
} }
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */ /** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
.** /**
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件: * P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。 * OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。 * 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
@@ -3,9 +3,9 @@
* *
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。 * P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/ */
import TeamSideTag from '../../../../components/TeamSideTag' import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../../types' import type { Match, Prediction } from '../types'
import { OUTCOME_LABEL } from '../../types' import { OUTCOME_LABEL } from '../types'
/** 置信度细线:0~1 数值的低调可视化 */ /** 置信度细线:0~1 数值的低调可视化 */
function Meter({ value }: { value: number }) { function Meter({ value }: { value: number }) {
@@ -3,7 +3,7 @@
* *
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。 * P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/ */
import type { Prediction } from '../../types' import type { Prediction } from '../types'
export function ReasoningPanel({ prediction }: { prediction: Prediction }) { export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
const degraded = prediction.status === 'degraded' || prediction.status === 'failed' const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
+2 -1
View File
@@ -16,5 +16,6 @@
"noUnusedParameters": false, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules"]
} }
+30
View File
@@ -14,6 +14,35 @@ from src.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _fail_stale_ingest_jobs() -> None:
"""P1-E: 启动时将上次遗留的 pending/running ingest_jobs 标 failed。
进程异常退出(重启/OOM)会导致 ingest_jobs 残留为 pending/running,
这些任务实际已不在执行,启动时一次性标 failed 避免永久"执行中"
尽力而为:失败只记 warning,不阻断启动。
"""
from datetime import datetime, timezone
from sqlalchemy import update
from src.db.base import AsyncSessionLocal
from src.db.models import IngestJob
try:
async with AsyncSessionLocal() as session:
stmt = (
update(IngestJob)
.where(IngestJob.status.in_(["pending", "running"]))
.values(status="failed", error="进程重启:任务被终止", finished_at=datetime.now(timezone.utc))
)
result = await session.execute(stmt)
await session.commit()
if result.rowcount:
logger.info("P1-E: 已将 %d 条残留 pending/running ingest_jobs 标 failed", result.rowcount)
except Exception:
logger.warning("P1-E: 清理残留 ingest_jobs 失败,不影响启动", exc_info=True)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
from src.db.base import init_db from src.db.base import init_db
@@ -30,6 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等) await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等) await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告) await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
await _fail_stale_ingest_jobs() # P1-E: 上次遗留的 pending/running 标 failed
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态; # D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。 # 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
+14 -7
View File
@@ -33,6 +33,19 @@ _background_tasks: set[asyncio.Task] = set()
VALID_TASKS = {"events", "standings", "stats", "all"} VALID_TASKS = {"events", "standings", "stats", "all"}
def _accumulate_ingest_result(merged: dict, code: str, r: dict) -> None:
"""P1-A: 累加单联赛采集结果。联赛级计数读 r["leagues"][code],顶层读 total_*。"""
merged["total_inserted"] += r.get("total_inserted", 0)
merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", []))
# 联赛级计数必须来自 leagues[code],而非顶层 r.get("inserted")
league_r = r.get("leagues", {}).get(code, {})
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += league_r.get("inserted", 0)
acc["updated"] += league_r.get("updated", 0)
acc["errors"].extend(r.get("errors", []))
def _spawn(coro) -> None: def _spawn(coro) -> None:
"""启动后台采集任务;异常已在任务内记录到系统日志。""" """启动后台采集任务;异常已在任务内记录到系统日志。"""
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
@@ -105,13 +118,7 @@ async def _run_bzzoiro(job_id: str, task: str, leagues: list[str], req: IngestBz
session, leagues=[code], session, leagues=[code],
date_from=req.date_from, date_to=req.date_to, status=st, date_from=req.date_from, date_to=req.date_to, status=st,
) )
merged["total_inserted"] += r.get("total_inserted", 0) _accumulate_ingest_result(merged, code, r)
merged["total_updated"] += r.get("total_updated", 0)
merged["errors"].extend(r.get("errors", []))
acc = merged["leagues"].setdefault(code, {"inserted": 0, "updated": 0, "errors": []})
acc["inserted"] += r.get("inserted", 0)
acc["updated"] += r.get("updated", 0)
acc["errors"].extend(r.get("errors", []))
logger.info( logger.info(
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s", "bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses, merged["total_inserted"], merged["total_updated"], len(merged["leagues"]), statuses,
+44 -35
View File
@@ -14,6 +14,20 @@ from src.db.models import League, Match, MatchStats, Prediction, Standing, Team
router = APIRouter(prefix="/api/v1", tags=["data"]) router = APIRouter(prefix="/api/v1", tags=["data"])
def _parse_cursor(cursor: str) -> tuple[datetime, int]:
"""P1-B: 解析游标。非法格式 → HTTPException(400, code=INVALID_CURSOR)。"""
try:
last_date_str, last_id_str = cursor.split("|", 1)
last_date = datetime.fromisoformat(last_date_str)
last_id = int(last_id_str)
return last_date, last_id
except (ValueError, AttributeError) as e:
raise HTTPException(
status_code=400,
detail={"code": "INVALID_CURSOR", "message": f"非法游标格式: {cursor}(应为 date_iso|id)"},
) from e
def _stats_dict(stats) -> dict | None: def _stats_dict(stats) -> dict | None:
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。""" """把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
if stats is None: if stats is None:
@@ -65,26 +79,21 @@ async def list_matches(
) )
if cursor: if cursor:
try: # P1-B: 解析非法 → 400 + code=INVALID_CURSOR,而非静默忽略
# 用 | 分隔,避免 isoformat 含 _ 时解析失败 last_date, last_id = _parse_cursor(cursor)
last_date_str, last_id_str = cursor.split("|", 1) # 游标方向必须与排序方向一致:
last_date = datetime.fromisoformat(last_date_str) # - scheduled(ASC):取「更大」的未开赛场次
last_id = int(last_id_str) # - 其它(DESC):取「更小」的已赛场次
# 游标方向必须与排序方向一致: if status == "scheduled":
# - scheduled(ASC):取「更大」的未开赛场次 q = q.where(
# - 其它(DESC):取「更小」的已赛场次 (Match.match_date > last_date) |
if status == "scheduled": ((Match.match_date == last_date) & (Match.id > last_id))
q = q.where( )
(Match.match_date > last_date) | else:
((Match.match_date == last_date) & (Match.id > last_id)) q = q.where(
) (Match.match_date < last_date) |
else: ((Match.match_date == last_date) & (Match.id < last_id))
q = q.where( )
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
except (ValueError, AttributeError):
pass
if league: if league:
stmt = select(League.id).where(League.code == league) stmt = select(League.id).where(League.code == league)
@@ -160,15 +169,28 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
m = (await db.execute(stmt)).scalar_one_or_none() m = (await db.execute(stmt)).scalar_one_or_none()
if m is None: if m is None:
raise HTTPException(404, "match not found") raise HTTPException(404, "match not found")
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM # P1-C: 公开预测仅 run_type=live 且 status=success(屏蔽回测/失败预测)
preds = ( preds = (
await db.execute( await db.execute(
select(Prediction) select(Prediction)
.where(Prediction.match_id == match_id) .where(Prediction.match_id == match_id)
.where(Prediction.run_type == "live")
.where(Prediction.status == "success")
.order_by(Prediction.created_at.desc()) .order_by(Prediction.created_at.desc())
.limit(5) .limit(5)
) )
).scalars().all() ).scalars().all()
# P1-C: 公开接口的预测不含 reasoning/agent_outputs(避免泄露内部推理细节)
recent_predictions = [
{
"id": p.id, "match_id": p.match_id, "provider": p.provider, "model": p.model,
"prompt_version": p.prompt_version, "mode": p.mode or "single",
"pred_home_goals": p.pred_home_goals, "pred_away_goals": p.pred_away_goals,
"pred_1x2": p.pred_1x2, "subjective_confidence": p.subjective_confidence,
"created_at": p.created_at.isoformat() if p.created_at else None,
}
for p in preds
]
return MatchOut( return MatchOut(
id=m.id, id=m.id,
league_code=m.league.code if m.league else None, league_code=m.league.code if m.league else None,
@@ -185,20 +207,7 @@ async def get_match(match_id: int, db: AsyncSession = Depends(get_db_read)):
home_xg=m.stats.home_xg if m.stats else None, home_xg=m.stats.home_xg if m.stats else None,
away_xg=m.stats.away_xg if m.stats else None, away_xg=m.stats.away_xg if m.stats else None,
stats=_stats_dict(m.stats) if m.stats else None, stats=_stats_dict(m.stats) if m.stats else None,
recent_predictions=[ recent_predictions=recent_predictions,
PredictionOut(
id=p.id, match_id=p.match_id, provider=p.provider, model=p.model,
prompt_version=p.prompt_version, mode=p.mode or "single",
pred_home_goals=p.pred_home_goals, pred_away_goals=p.pred_away_goals,
alt_pred_home_goals=p.alt_pred_home_goals, alt_pred_away_goals=p.alt_pred_away_goals,
pred_1x2=p.pred_1x2, subjective_confidence=p.subjective_confidence,
reasoning=p.reasoning, status=p.status or "success",
agent_outputs=p.agent_outputs, agent_weights=p.agent_weights,
created_at=p.created_at, actual_home_goals=p.actual_home_goals,
actual_away_goals=p.actual_away_goals, settled=p.settled,
)
for p in preds
],
) )
+19 -7
View File
@@ -2,10 +2,12 @@
安全改进: 安全改进:
- 限流: 每分钟 10 次 / IP(内存实现) - 限流: 每分钟 10 次 / IP(内存实现)
- P1-D: 全局 LLM 并发限制(默认 4),防止过多并发 LLM 调用压垮服务
- DB 连接: 短 session 模式,LLM 调用期间不持有连接 - DB 连接: 短 session 模式,LLM 调用期间不持有连接
""" """
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -22,6 +24,21 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["predict"]) router = APIRouter(prefix="/api/v1", tags=["predict"])
# P1-D: 全局 LLM 并发限制。与 orchestrator 内的 match 级 Semaphore(8) 并存,
# 此处在路由层限制单实例全 LLM 调用(所有模式汇总),默认 4。
_GLOBAL_LLM_SEMAPHORE = asyncio.Semaphore(4)
async def _predict_with_concurrency(req: PredictRequest) -> PredictResult:
"""P1-D: 在全局 LLM 并发限制下执行预测。"""
async with _GLOBAL_LLM_SEMAPHORE:
return await predict_match(
req.match_id,
model=req.model,
prompt_version=req.prompt_version,
mode=req.mode,
)
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)]) @router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
async def predict(req: PredictRequest, request: Request): async def predict(req: PredictRequest, request: Request):
@@ -42,14 +59,9 @@ async def predict(req: PredictRequest, request: Request):
if m.match_status == "finished": if m.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测") raise HTTPException(400, "该比赛已完赛,不再支持预测")
# 2. 预测调用(不持有任何 DB 连接) # 2. 预测调用(不持有任何 DB 连接,受全局 LLM 并发限制)
try: try:
result = await predict_match( result = await _predict_with_concurrency(req)
req.match_id,
model=req.model,
prompt_version=req.prompt_version,
mode=req.mode,
)
except ValueError as e: except ValueError as e:
msg = str(e) msg = str(e)
if "已结算" in msg: if "已结算" in msg:
+3 -3
View File
@@ -24,8 +24,8 @@ class MatchOut(BaseModel):
away_xg: float | None = None away_xg: float | None = None
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None # 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
stats: dict | None = None stats: dict | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空) # P1-C: 公开接口的预测不含 reasoning/agent_outputs;仅 live+success 路由已过滤
recent_predictions: list[PredictionOut] = [] recent_predictions: list[dict] = []
class MatchListOut(BaseModel): class MatchListOut(BaseModel):
@@ -36,7 +36,7 @@ class MatchListOut(BaseModel):
class PredictRequest(BaseModel): class PredictRequest(BaseModel):
match_id: int match_id: int
provider: str | None = None # P1-D: 删除未接线的 provider 字段(符合"名不副实则删除");provider 由服务端配置决定。
model: str | None = None model: str | None = None
prompt_version: str | None = None prompt_version: str | None = None
mode: str = Field( mode: str = Field(
+46 -20
View File
@@ -104,17 +104,26 @@ async def get_runtime_value(key: str) -> str:
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。 """读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。 敏感项入库时是密文,读出后自动解密;旧明文(迁移前)由 decrypt_value 透传。
P1-G:DB 故障回落 env;但解密失败在生产环境必须抛出(不得与 DB 异常共用 except)。
""" """
defn = SETTING_DEFS.get(key) defn = SETTING_DEFS.get(key)
db_value = None
try: try:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key) row = await db.get(AppSetting, key)
if row and row.value: if row and row.value:
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value db_value = row.value
if value:
return value
except Exception: except Exception:
logger.warning("读取运行时配置 %s 失败,回落环境变量", key) logger.warning("读取运行时配置 %s 失败(DB 故障),回落环境变量", key)
return getattr(settings, key, "") or ""
if db_value is None:
return getattr(settings, key, "") or ""
# 解密逻辑独立于 DB 异常处理(P1-G:解密失败生产环境必须抛出)
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
if value:
return value
return getattr(settings, key, "") or "" return getattr(settings, key, "") or ""
@@ -133,8 +142,40 @@ async def set_runtime_value(key: str, value: str) -> None:
logger.info("运行时配置 %s 已更新", key) logger.info("运行时配置 %s 已更新", key)
async def get_setting_origin(key: str) -> tuple[str, str]:
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。
P1-G:DB 故障回落 env;解密失败在生产环境抛出。
"""
defn = SETTING_DEFS.get(key)
db_value = None
try:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row and row.value:
db_value = row.value
except Exception:
logger.warning("读取运行时配置 %s 来源失败(DB 故障),按环境变量处理", key)
env_value = getattr(settings, key, "") or ""
return ("env", env_value) if env_value else ("none", "")
if db_value is None:
return ("none", "")
try:
value = crypto.decrypt_value(db_value) if defn and defn.sensitive else db_value
return "db", value
except ValueError as e:
# P1-G:解密失败(SECRET_KEY 不一致)在生产环境必须抛出,不得静默回落
if settings.APP_ENV == "production":
raise
logger.warning("解密 %s 失败(非生产环境回落): %s", key, e)
env_value = getattr(settings, key, "") or ""
return ("env", env_value) if env_value else ("none", "")
async def clear_runtime_value(key: str) -> None: async def clear_runtime_value(key: str) -> None:
"""除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。""" """除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key) row = await db.get(AppSetting, key)
if row is not None: if row is not None:
@@ -143,21 +184,6 @@ async def clear_runtime_value(key: str) -> None:
logger.info("运行时配置 %s 已清除覆盖", key) logger.info("运行时配置 %s 已清除覆盖", key)
async def get_setting_origin(key: str) -> tuple[str, str]:
"""返回 (origin, 当前生效值)。origin ∈ db / env / none。"""
defn = SETTING_DEFS.get(key)
try:
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row and row.value:
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
return "db", value
except Exception:
logger.warning("读取运行时配置 %s 来源失败,按环境变量处理", key)
env_value = getattr(settings, key, "") or ""
return ("env", env_value) if env_value else ("none", "")
async def migrate_plaintext_sensitive_settings() -> int: async def migrate_plaintext_sensitive_settings() -> int:
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。 """一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
+1 -1
View File
@@ -144,7 +144,7 @@ class DataQualityScheduler:
.select_from(Match) .select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id) .outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished") .where(Match.match_status == "finished")
.where(MatchStats.id.is_(None)) .where(MatchStats.match_id.is_(None))
) )
).scalar() or 0 ).scalar() or 0
+6 -6
View File
@@ -19,6 +19,7 @@ from sqlalchemy import (
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
column,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
@@ -289,14 +290,13 @@ class Prediction(Base):
match: Mapped[Match] = relationship(back_populates="predictions") match: Mapped[Match] = relationship(back_populates="predictions")
__table_args__ = ( __table_args__ = (
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存 # P0-03: 幂等指纹——input_hash 非空时唯一(同指纹→返回已有行,不 UPDATE/INSERT);
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据) # 兼容旧数据 NULL input_hash(不强制回填)。
UniqueConstraint( Index(
"match_id", "provider", "model", "mode", "run_type", "ix_predictions_input_hash_unique", "input_hash", unique=True,
name="uq_predictions_match_provider_model_mode_run_type", postgresql_where=column("input_hash").isnot(None),
), ),
Index("ix_predictions_match", "match_id"), Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"),
# 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测) # 数据截止时间过滤查询用(按 prediction_cutoff_at 取「赛前已生成」的预测)
Index("ix_predictions_cutoff_at", "prediction_cutoff_at"), Index("ix_predictions_cutoff_at", "prediction_cutoff_at"),
# 数据库级约束:最后一道防线 # 数据库级约束:最后一道防线
+15 -9
View File
@@ -13,6 +13,7 @@ from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import insert as pg_insert
from src.db.models import League, Match, Prediction, Team from src.db.models import League, Match, Prediction, Team
@@ -153,12 +154,14 @@ class TeamRepository:
logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id) logger.info("Team 别名命中: %s -> %s(已有 id=%s)", name, normalized, team.id)
return team return team
# 3) 新建 Team(归一名) # 3) 新建 Team(归一名)。P1-K: 用 PG UPSERT 防并发重复插入。
logger.info("创建新 Team: %s -> %s", name, normalized) logger.info("创建新 Team: %s -> %s", name, normalized)
team = Team(name=normalized, name_zh=name_zh) stmt = pg_insert(Team).values(name=normalized, name_zh=name_zh)
self._session.add(team) stmt = stmt.on_conflict_do_nothing(index_elements=["name"])
await self._session.execute(stmt)
await self._session.flush() await self._session.flush()
return team # 无论是否本次插入,都拿到行(并发时可能已由对方创建)
return await self.get_by_name(normalized)
async def add_alias(self, alias: str, team_id: int) -> TeamAlias: async def add_alias(self, alias: str, team_id: int) -> TeamAlias:
"""为已有 Team 添加别名。 """为已有 Team 添加别名。
@@ -205,12 +208,15 @@ class LeagueRepository:
return (await self._session.execute(stmt)).scalar_one_or_none() return (await self._session.execute(stmt)).scalar_one_or_none()
async def get_or_create(self, code: str, name: str, country: str | None = None) -> League: async def get_or_create(self, code: str, name: str, country: str | None = None) -> League:
"""按代码获取联赛,不存在则创建。P1-K: 创建用 PG UPSERT 防并发重复。"""
league = await self.get_by_code(code) league = await self.get_by_code(code)
if league is None: if league is not None:
league = League(code=code, name=name, country=country) return league
self._session.add(league) stmt = pg_insert(League).values(code=code, name=name, country=country)
await self._session.flush() stmt = stmt.on_conflict_do_nothing(index_elements=["code"])
return league await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_code(code)
async def add(self, league: League) -> None: async def add(self, league: League) -> None:
self._session.add(league) self._session.add(league)
+19 -12
View File
@@ -12,7 +12,7 @@ from src.core.config import settings
from src.db.base import AsyncSessionLocal from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow from src.db.unit_of_work import get_uow
from src.llm.predict import PredictResult, _upsert_prediction from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import ( from src.llm.context_builder import (
MatchHeader, MatchHeader,
@@ -285,10 +285,13 @@ async def predict_match_multi(
latency_ms = int((time.perf_counter() - start) * 1000) latency_ms = int((time.perf_counter() - start) * 1000)
# 3.5 计算输入 hash(基于终裁报告) # P0-03: 指纹输入——终裁报告 hash 作 context_hash,专家列表作 agent_ids
input_hash = hashlib.sha256( reports_json = _reports_to_json(reports)
_reports_to_json(reports).encode("utf-8") context_hash = hashlib.sha256(reports_json.encode("utf-8")).hexdigest()
).hexdigest() agent_ids = sorted([r.agent for r in reports]) if reports else []
# 终裁模板 hash(规范:复用 prompt 版本 + 终裁 system prompt)
prompt_hash = hashlib.sha256(f"multi_{version}".encode("utf-8")).hexdigest()
system_prompt_hash = hashlib.sha256(AGGREGATOR_SYSTEM.encode("utf-8")).hexdigest()
# 4. 存库(使用 UnitOfWork) # 4. 存库(使用 UnitOfWork)
async with get_uow() as session: async with get_uow() as session:
@@ -315,15 +318,20 @@ async def predict_match_multi(
pred_status = "degraded" pred_status = "degraded"
model_name = aggregator_model model_name = aggregator_model
pred = await _upsert_prediction( pred = await _insert_or_find_by_fingerprint(
session, session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=model_name,
mode="multi",
run_type="backtest" if backtest else "live",
values={ values={
"match_id": match_id,
"provider": settings.LLM_PROVIDER,
"model": model_name,
"mode": "multi",
"run_type": "backtest" if backtest else "live",
"prompt_version": f"multi_{version}", "prompt_version": f"multi_{version}",
"prompt_hash": prompt_hash,
"system_prompt_hash": system_prompt_hash,
"temperature": 0.2,
"context_hash": context_hash,
"agent_ids": agent_ids,
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens, "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, "completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
"latency_ms": latency_ms, "latency_ms": latency_ms,
@@ -341,7 +349,6 @@ async def predict_match_multi(
"match_kickoff_at": match_kickoff_at, "match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at, "prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now, "prediction_created_at": now,
"input_hash": input_hash,
}, },
) )
+20 -12
View File
@@ -5,14 +5,15 @@
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
from datetime import datetime from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal from src.db.base import AsyncSession, AsyncSessionLocal
from src.db.models import Match from src.db.models import Match
from src.llm.predict import PredictResult, _upsert_prediction from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -97,8 +98,23 @@ async def predict_baseline(
else: else:
pred_1x2 = "X" pred_1x2 = "X"
# P0-03: 基线指纹——基于主客场场均进球数据(context_hash) + 截止时间
context_hash = hashlib.sha256(
f"{home_avg:.4f}:{away_avg:.4f}:{before.isoformat() if before else 'none'}".encode("utf-8")
).hexdigest()
values = { values = {
"match_id": match_id,
"provider": "baseline",
"model": "baseline",
"mode": "baseline",
"run_type": "live",
"prompt_version": "baseline_v1", "prompt_version": "baseline_v1",
"prompt_hash": hashlib.sha256(b"baseline_v1").hexdigest(),
"system_prompt_hash": hashlib.sha256(b"baseline").hexdigest(),
"temperature": 0.0,
"context_hash": context_hash,
"agent_ids": [],
"prompt_tokens": 0, "prompt_tokens": 0,
"completion_tokens": 0, "completion_tokens": 0,
"latency_ms": 0, "latency_ms": 0,
@@ -114,17 +130,9 @@ async def predict_baseline(
"status": "success", "status": "success",
} }
# P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。 # P0-03:服务层幂等插入,回填真实 prediction_id(与 single/multi 统一)。
async with get_uow() as session: async with get_uow() as session:
pred = await _upsert_prediction( pred = await _insert_or_find_by_fingerprint(session, values=values)
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="live",
values=values,
)
prediction_id = pred.id prediction_id = pred.id
return PredictResult( return PredictResult(
+20 -4
View File
@@ -44,9 +44,17 @@ def _build_filters(
prompt_version: str | None = None, prompt_version: str | None = None,
mode: str | None = None, mode: str | None = None,
league_code: str | None = None, league_code: str | None = None,
run_type: str | None = "live",
season: str | None = None,
) -> list: ) -> list:
"""构建评估筛选条件(参数化列明,防拼接注入)。""" """构建评估筛选条件(参数化列明,防拼接注入)。
P1-I: 默认仅 run_type=live(排除回测污染),可显式改 "backtest"/None。
"""
filters = [Prediction.settled == True] filters = [Prediction.settled == True]
# P1-I: 默认仅统计实盘预测(排除回测),除非显式指定
if run_type is not None:
filters.append(Prediction.run_type == run_type)
if provider: if provider:
filters.append(Prediction.provider == provider) filters.append(Prediction.provider == provider)
if model: if model:
@@ -55,6 +63,10 @@ def _build_filters(
filters.append(Prediction.prompt_version == prompt_version) filters.append(Prediction.prompt_version == prompt_version)
if mode: if mode:
filters.append(Prediction.mode == mode) filters.append(Prediction.mode == mode)
# P1-I: 赛季过滤
if season:
season_subq = select(Match.id).where(Match.season == season).scalar_subquery()
filters.append(Prediction.match_id.in_(season_subq))
if league_code: if league_code:
league_subq = select(League.id).where(League.code == league_code).scalar_subquery() league_subq = select(League.id).where(League.code == league_code).scalar_subquery()
filters.append(Prediction.match_id.in_( filters.append(Prediction.match_id.in_(
@@ -64,17 +76,21 @@ def _build_filters(
async def get_eval_summary( async def get_eval_summary(
limit: int = 1000, limit: int | None = None,
*, *,
provider: str | None = None, provider: str | None = None,
model: str | None = None, model: str | None = None,
prompt_version: str | None = None, prompt_version: str | None = None,
mode: str | None = None, mode: str | None = None,
league_code: str | None = None, league_code: str | None = None,
run_type: str | None = "live",
season: str | None = None,
) -> dict: ) -> dict:
"""按 provider × 模型聚合评估。 """按 provider × 模型聚合评估。
P3-4: 默认限制评估最近 1000 条已结算预测,避免全表加载导致内存压力 P1-I: 默认 limit=None(返回全集,不冒充 1000 条为全集);调用方可显式 limit 采样
默认仅 run_type=live(排除回测污染),可按需改 "backtest"
可选 season 过滤。
只统计有效预测: 只统计有效预测:
- settled == True - settled == True
@@ -82,7 +98,7 @@ async def get_eval_summary(
- 预测比分字段齐全 - 预测比分字段齐全
degraded 或无比分的预测不计入准确率。 degraded 或无比分的预测不计入准确率。
""" """
filters = _build_filters(provider, model, prompt_version, mode, league_code) filters = _build_filters(provider, model, prompt_version, mode, league_code, run_type=run_type, season=season)
async with get_uow() as session: async with get_uow() as session:
total_settled = (await session.execute( total_settled = (await session.execute(
+80 -50
View File
@@ -4,9 +4,9 @@
| 模式 | 落库位置(服务层) | 路由层(routes/predict.py) | | 模式 | 落库位置(服务层) | 路由层(routes/predict.py) |
|-----------|-------------------------------------------------------------|---------------------------| |-----------|-------------------------------------------------------------|---------------------------|
| single | `_predict_single` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut | | single | `_predict_single` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
| multi | `orchestrator.predict_match_multi` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut | | multi | `orchestrator.predict_match_multi` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
| baseline | `predict_baseline` → `_upsert_prediction` | 不读 DB,仅映射 result → PredictOut | | baseline | `predict_baseline` → `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result → PredictOut |
三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id; 三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id;
路由层永不写入 predictions,只读 result.prediction_id 做响应映射。 路由层永不写入 predictions,只读 result.prediction_id 做响应映射。
@@ -126,12 +126,14 @@ class _RedisCache(_CacheBackend):
if not await self._ensure_conn(): if not await self._ensure_conn():
return self._memory_fallback.get(key) return self._memory_fallback.get(key)
try: try:
import pickle import json
raw = await self._redis.get(key) # type: ignore[union-attr] raw = await self._redis.get(key) # type: ignore[union-attr]
if raw is None: if raw is None:
return None return None
return pickle.loads(raw.encode("latin-1")) if isinstance(raw, str) else pickle.loads(raw) # P1-H: JSON 序列化(替代 pickle,跨语言安全 + 可人工阅读)
data = json.loads(raw)
return PredictResult(**data)
except Exception as e: except Exception as e:
logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e) logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e)
return None return None
@@ -141,10 +143,15 @@ class _RedisCache(_CacheBackend):
await self._memory_fallback.set(key, result, ttl) await self._memory_fallback.set(key, result, ttl)
return return
try: try:
import pickle import json
from dataclasses import asdict
payload = pickle.dumps(result).decode("latin-1") payload = asdict(result)
await self._redis.set(key, payload, ex=ttl) # type: ignore[union-attr] # JSON 序列化;处理 datetime → ISO 字符串
for k, v in payload.items():
if hasattr(v, "isoformat"):
payload[k] = v.isoformat()
await self._redis.set(key, json.dumps(payload, default=str), ex=ttl) # type: ignore[union-attr]
except Exception as e: except Exception as e:
logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e) logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e)
await self._memory_fallback.set(key, result, ttl) await self._memory_fallback.set(key, result, ttl)
@@ -220,44 +227,60 @@ class PredictResult:
raw: dict | None = None raw: dict | None = None
async def _upsert_prediction( def _compute_fingerprint(values: dict) -> str:
session, """P0-03: 预测指纹(规范 JSON 的 SHA-256)。
*,
match_id: int,
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据) 捕获影响预测输出的全部因素:输入、提示、模型、采样、截止时间、专家
run_type 区分 live/backtest,避免回测覆盖实盘预测 同 fingerprint → 返回已有行(不 UPDATE/INSERT);不同 → INSERT 新行
""" """
import json as _json
canonical = {
"match_id": values.get("match_id"),
"prediction_cutoff_at": _iso(values.get("prediction_cutoff_at")),
"prompt_version": values.get("prompt_version"),
"prompt_hash": values.get("prompt_hash"),
"system_prompt_hash": values.get("system_prompt_hash"),
"provider": values.get("provider"),
"model": values.get("model"),
"mode": values.get("mode"),
"run_type": values.get("run_type"),
"temperature": values.get("temperature"),
"context_hash": values.get("context_hash"),
"agent_ids": sorted(values.get("agent_ids") or []),
}
blob = _json.dumps(canonical, sort_keys=True, separators=(',', ':'))
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def _iso(v) -> str | None:
if v is None:
return None
if hasattr(v, "isoformat"):
return v.isoformat()
return str(v)
async def _insert_or_find_by_fingerprint(session, *, values: dict) -> Prediction:
"""P0-03: 幂等插入——同 input_hash 返回已有行(不 UPDATE);不同则 INSERT。
不再按 (match, provider, model, mode, run_type) 做 upsert,避免覆盖已有预测。
values 必须包含 fingerprint 所需全部字段(见 _compute_fingerprint)。
"""
fingerprint = _compute_fingerprint(values)
values["input_hash"] = fingerprint
existing = ( existing = (
await session.execute( await session.execute(
select(Prediction).where( select(Prediction).where(Prediction.input_hash == fingerprint)
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None and existing.settled: if existing is not None:
raise ValueError("该比赛已有已结算的预测,不能重新预测") # 同指纹 → 直接返回,绝不覆盖 pred_* / reasoning / agent_outputs
return existing
pred = existing if existing is not None else Prediction( pred = Prediction(**{k: v for k, v in values.items() if hasattr(Prediction, k)})
match_id=match_id, provider=provider_name, model=model, session.add(pred)
)
pred.mode = mode
pred.run_type = run_type
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
session.add(pred)
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交 await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
return pred return pred
@@ -342,23 +365,26 @@ async def _predict_single(
# 1. 拼上下文(backtest/cutoff 防泄漏) # 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at) ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1.5 计算快照元数据(用于可复现性) # 1.5 计算快照元数据(用于可复现性 + P0-03 指纹)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt match_kickoff_at = ctx.match_dt
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间 # 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt prediction_cutoff_at = ctx.cutoff if ctx.cutoff is not None else ctx.match_dt
input_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 2. 拼 prompt(指定版本) # 2. 拼 prompt(指定版本)
template = _load_prompt_template(version) template = _load_prompt_template(version)
prompt_hash = _prompt_template_hash(version)
user_prompt = template.replace("{{context}}", ctx.text) user_prompt = template.replace("{{context}}", ctx.text)
system_prompt = "你是一个严谨的足球预测专家。只输出 JSON。"
context_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 3. 调 LLM # 3. 调 LLM
temperature = 0.3
resp = await provider.chat( resp = await provider.chat(
system="你是一个严谨的足球预测专家。只输出 JSON。", system=system_prompt,
user=user_prompt, user=user_prompt,
json_mode=True, json_mode=True,
temperature=0.3, temperature=temperature,
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量 max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
) )
@@ -385,15 +411,20 @@ async def _predict_single(
if m is None: if m is None:
raise ValueError(f"match {match_id} not found") raise ValueError(f"match {match_id} not found")
pred = await _upsert_prediction( pred = await _insert_or_find_by_fingerprint(
session, session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
values={ values={
"match_id": match_id,
"provider": settings.LLM_PROVIDER,
"model": provider.model,
"mode": "single",
"run_type": "backtest" if backtest else "live",
"prompt_version": version, "prompt_version": version,
"prompt_hash": prompt_hash,
"system_prompt_hash": hashlib.sha256(system_prompt.encode("utf-8")).hexdigest(),
"temperature": temperature,
"context_hash": context_hash,
"agent_ids": [],
"prompt_tokens": resp.prompt_tokens, "prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens, "completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms, "latency_ms": resp.latency_ms,
@@ -409,7 +440,6 @@ async def _predict_single(
"match_kickoff_at": match_kickoff_at, "match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at, "prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now, "prediction_created_at": now,
"input_hash": input_hash,
}, },
) )
+6 -6
View File
@@ -61,7 +61,7 @@ class TestOrchestratorWritesAgentWeights:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_orchestrator_writes_agent_weights_to_upsert(self): async def test_orchestrator_writes_agent_weights_to_upsert(self):
"""orchestrator 应将 agent_weights 传入 _upsert_prediction""" """orchestrator 应将 agent_weights 传入 _insert_or_find_by_fingerprint"""
from src.llm.agents import orchestrator as orch_mod from src.llm.agents import orchestrator as orch_mod
from src.llm.agents.base import AgentReport from src.llm.agents.base import AgentReport
from src.llm.context_builder import MatchHeader from src.llm.context_builder import MatchHeader
@@ -103,8 +103,8 @@ class TestOrchestratorWritesAgentWeights:
"agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2}, "agent_weights": {"form": 0.3, "home_away": 0.5, "stats": 0.2},
}, 100, 50 }, 100, 50
async def mock_upsert(session, **kw): async def mock_upsert(session, *, values):
captured_values.update(kw.get("values", {})) captured_values.update(values)
p = MagicMock() p = MagicMock()
p.id = 1 p.id = 1
p.provider = "test" p.provider = "test"
@@ -116,7 +116,7 @@ class TestOrchestratorWritesAgentWeights:
p.subjective_confidence = 0.7 p.subjective_confidence = 0.7
p.reasoning = "test" p.reasoning = "test"
p.agent_outputs = [] p.agent_outputs = []
p.agent_weights = kw["values"].get("agent_weights") p.agent_weights = values.get("agent_weights")
return p return p
class FakeUow: class FakeUow:
@@ -130,14 +130,14 @@ class TestOrchestratorWritesAgentWeights:
with patch.object(orch_mod, "run_specialists", mock_specialists), \ with patch.object(orch_mod, "run_specialists", mock_specialists), \
patch.object(orch_mod, "_agent_provider", mock_provider), \ patch.object(orch_mod, "_agent_provider", mock_provider), \
patch.object(orch_mod, "load_match_header", mock_header), \ patch.object(orch_mod, "load_match_header", mock_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \ patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
patch.object(orch_mod, "run_aggregator", mock_aggregator), \ patch.object(orch_mod, "run_aggregator", mock_aggregator), \
patch.object(orch_mod, "get_uow", FakeUow): patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999) result = await orch_mod.predict_match_multi(999)
# 断言 agent_weights 被写入 # 断言 agent_weights 被写入
assert "agent_weights" in captured_values, "agent_weights 应传入 _upsert_prediction" assert "agent_weights" in captured_values, "agent_weights 应传入 _insert_or_find_by_fingerprint"
assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None" assert captured_values["agent_weights"] is not None, "agent_weights 不应为 None"
assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重" assert "form" in captured_values["agent_weights"], "agent_weights 应包含专家权重"
print(f"PASS: agent_weights = {captured_values['agent_weights']}") print(f"PASS: agent_weights = {captured_values['agent_weights']}")
+2 -2
View File
@@ -89,7 +89,7 @@ async def test_predict_baseline_no_llm():
with patch("src.llm.baseline._avg_goals", fake_avg), \ with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", _FakeUoW), \ patch("src.db.unit_of_work.get_uow", _FakeUoW), \
patch("src.llm.baseline._upsert_prediction", _fake_upsert): patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
class FakeSession: class FakeSession:
async def get(self, cls, mid): async def get(self, cls, mid):
return FakeMatch() return FakeMatch()
@@ -137,7 +137,7 @@ async def test_predict_baseline_clamps_to_range():
with patch("src.llm.baseline._avg_goals", fake_avg), \ with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", _FakeUoW), \ patch("src.db.unit_of_work.get_uow", _FakeUoW), \
patch("src.llm.baseline._upsert_prediction", _fake_upsert): patch("src.llm.baseline._insert_or_find_by_fingerprint", _fake_upsert):
class FakeSession: class FakeSession:
async def get(self, cls, mid): async def get(self, cls, mid):
return FakeMatch() return FakeMatch()
+34 -36
View File
@@ -62,7 +62,7 @@ async def test_predict_baseline_returns_predict_result():
async def __aexit__(self, *a): async def __aexit__(self, *a):
return None return None
# P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。 # P3-2:baseline 在服务层落库(get_uow + _insert_or_find_by_fingerprint),需 mock 掉。
class FakeUoW: class FakeUoW:
async def __aenter__(self): async def __aenter__(self):
return _make_session() return _make_session()
@@ -72,24 +72,24 @@ async def test_predict_baseline_returns_predict_result():
captured = {} captured = {}
async def fake_upsert(session, **kw): async def fake_upsert(session, *, values):
captured.update(kw) captured.update(values)
return SimpleNamespace(id=77) return SimpleNamespace(id=77)
# baseline.py 内部 from-import get_uow / _upsert_prediction,需 patch 真实来源模块。 # baseline.py 内部 from-import get_uow / _insert_or_find_by_fingerprint,需 patch 真实来源模块。
with patch("src.llm.baseline._avg_goals", fake_avg), \ with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \ patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
patch("src.db.unit_of_work.get_uow", FakeUoW), \ patch("src.db.unit_of_work.get_uow", FakeUoW), \
patch("src.llm.baseline._upsert_prediction", fake_upsert): patch("src.llm.baseline._insert_or_find_by_fingerprint", fake_upsert):
SLC.return_value = FakeCM() SLC.return_value = FakeCM()
result = await predict_baseline(1) result = await predict_baseline(1)
# P3-2:验证服务层落库被调用且属性映射正确 # P3-2:验证服务层落库被调用且属性映射正确
assert captured["match_id"] == 1 assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline" assert captured["provider"] == "baseline"
assert captured["run_type"] == "live" assert captured["run_type"] == "live"
assert captured["values"]["pred_home_goals"] == 2.0 assert captured["pred_home_goals"] == 2.0
assert isinstance(result, PredictResult) assert isinstance(result, PredictResult)
assert result.mode == "baseline" assert result.mode == "baseline"
@@ -225,8 +225,8 @@ async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。""" """P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
captured = {} captured = {}
async def fake_upsert(session, **kwargs): async def fake_upsert(session, *, values):
captured.update(kwargs) captured.update(values)
return SimpleNamespace(id=77) return SimpleNamespace(id=77)
class FakeMatch: class FakeMatch:
@@ -253,49 +253,47 @@ async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg) monkeypatch.setattr("src.llm.baseline._avg_goals", fake_avg)
monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC) monkeypatch.setattr("src.llm.baseline.AsyncSessionLocal", FakeSLC)
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW()) monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
# baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性 # baseline.py 模块级 import _insert_or_find_by_fingerprint(第 15 行),需 patch baseline 模块属性
monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert) monkeypatch.setattr("src.llm.baseline._insert_or_find_by_fingerprint", fake_upsert)
result = await predict_baseline(1) result = await predict_baseline(1)
# 落库被调用且属性映射正确 # 落库被调用且属性映射正确
assert captured, f"predict_baseline 应调用 _upsert_prediction 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})" assert captured, f"predict_baseline 应调用 _insert_or_find_by_fingerprint 落库,但 captured 为空(result.prediction_id={result.prediction_id!r})"
assert captured["match_id"] == 1 assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline" assert captured["provider"] == "baseline"
assert captured["model"] == "baseline" assert captured["model"] == "baseline"
assert captured["mode"] == "baseline" assert captured["mode"] == "baseline"
assert captured["run_type"] == "live" assert captured["run_type"] == "live"
v = captured["values"] assert captured["prompt_version"] == "baseline_v1"
assert v["prompt_version"] == "baseline_v1" assert captured["pred_home_goals"] == 2.0
assert v["pred_home_goals"] == 2.0 assert captured["pred_away_goals"] == 1.0
assert v["pred_away_goals"] == 1.0 assert captured["pred_1x2"] == "1"
assert v["pred_1x2"] == "1" assert captured["subjective_confidence"] == 0.5
assert v["subjective_confidence"] == 0.5 assert captured["prompt_tokens"] == 0
assert v["prompt_tokens"] == 0 assert captured["completion_tokens"] == 0
assert v["completion_tokens"] == 0 assert captured["latency_ms"] == 0
assert v["latency_ms"] == 0 assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0} assert captured["status"] == "success"
assert v["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得) # 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77 assert result.prediction_id == 77
assert result.pred_1x2 == "1" assert result.pred_1x2 == "1"
assert captured["match_id"] == 1 assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline" assert captured["provider"] == "baseline"
assert captured["model"] == "baseline" assert captured["model"] == "baseline"
assert captured["mode"] == "baseline" assert captured["mode"] == "baseline"
assert captured["run_type"] == "live" assert captured["run_type"] == "live"
v = captured["values"] assert captured["prompt_version"] == "baseline_v1"
assert v["prompt_version"] == "baseline_v1" assert captured["pred_home_goals"] == 2.0
assert v["pred_home_goals"] == 2.0 assert captured["pred_away_goals"] == 1.0
assert v["pred_away_goals"] == 1.0 assert captured["pred_1x2"] == "1"
assert v["pred_1x2"] == "1" assert captured["subjective_confidence"] == 0.5
assert v["subjective_confidence"] == 0.5 assert captured["prompt_tokens"] == 0
assert v["prompt_tokens"] == 0 assert captured["completion_tokens"] == 0
assert v["completion_tokens"] == 0 assert captured["latency_ms"] == 0
assert v["latency_ms"] == 0 assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0} assert captured["status"] == "success"
assert v["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得) # 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77 assert result.prediction_id == 77
+53 -2
View File
@@ -72,6 +72,7 @@ class _FakeDB:
RawEvent: list(raw_events), RawEvent: list(raw_events),
} }
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)} self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
self._teams_by_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
self._aliases: dict[str, TeamAlias] = {} self._aliases: dict[str, TeamAlias] = {}
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0) self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
@@ -86,6 +87,52 @@ class _FakeDB:
return None return None
async def execute(self, stmt): async def execute(self, stmt):
# P1-K:LeagueRepository/TeamRepository.get_or_create 使用 pg_insert(Insert 语句,无 column_descriptions)
if not hasattr(stmt, "column_descriptions"):
# 解析 INSERT 值并注册到 _by_entity,使二次查询可命中
try:
values = stmt.compile().params
except Exception:
values = {}
table = getattr(stmt, "table", None)
table_name = getattr(table, "name", None) if table is not None else None
entity_map = {"leagues": League, "teams": Team}
entity = entity_map.get(table_name)
if entity is not None:
obj = entity()
for k, v in values.items():
if k in ("code", "name", "country", "name_zh"):
setattr(obj, k, v)
if getattr(obj, "id", None) is None:
self._next_id += 1
obj.id = self._next_id
if entity not in self._by_entity:
self._by_entity[entity] = []
self._by_entity[entity].append(obj)
if entity is Team:
self._teams_by_id[obj.id] = obj
self._teams_by_name[obj.name] = obj
class _Empty:
def scalar_one_or_none(self_inner):
return None
def scalars(self_inner):
return self_inner
def all(self_inner):
return []
return _Empty()
# P1-K:Team 查询回退到内存映射 name(避免队列/实体分发不完全匹配)
try:
params = stmt.compile().params
except Exception:
params = {}
name_val = next((v for k, v in params.items() if k.startswith("name")), None)
if isinstance(name_val, str) and name_val:
team = self._teams_by_name.get(name_val)
if team:
return _FakeResult([team])
entities = set() entities = set()
for d in (stmt.column_descriptions or []): for d in (stmt.column_descriptions or []):
entities.add(d.get("entity") or d.get("type")) entities.add(d.get("entity") or d.get("type"))
@@ -94,6 +141,10 @@ class _FakeDB:
return _FakeResult(items) return _FakeResult(items)
return _FakeResult([]) return _FakeResult([])
@staticmethod
def _filter(entity, items, stmt):
return items
async def flush(self): async def flush(self):
for obj in self.added: for obj in self.added:
if getattr(obj, "id", None) is None: if getattr(obj, "id", None) is None:
@@ -261,8 +312,8 @@ class TestEventsBronzeIsBestEffort:
raise RuntimeError("infra down") raise RuntimeError("infra down")
# Bronze 写入助手直接 import 到 bzzoiro_events 命名空间,需 patch 该处 # Bronze 写入助手直接 import 到 bzzoiro_events 命名空间,需 patch 该处
monkeypatch.setattr(bz_events, "_write_raw_event", _boom) monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
monkeypatch.setattr(bz_events, "_write_lineage", _boom) monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
_patch_fetch(monkeypatch, [_event()]) _patch_fetch(monkeypatch, [_event()])
db = _FakeDB() db = _FakeDB()
+8 -9
View File
@@ -77,7 +77,7 @@ class TestAllExpertsFailed:
async def mock_load_header(mid, db=None): async def mock_load_header(mid, db=None):
return header return header
# Mock _upsert_prediction — 捕获写入的 status # Mock _insert_or_find_by_fingerprint — 捕获写入的 status
captured_status = {} captured_status = {}
async def mock_upsert(session, **kw): async def mock_upsert(session, **kw):
@@ -109,7 +109,7 @@ class TestAllExpertsFailed:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \ with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \ patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \ patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \ patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
patch.object(orch_mod, "get_uow", FakeUow): patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999) result = await orch_mod.predict_match_multi(999)
@@ -170,7 +170,7 @@ class TestAllExpertsFailed:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \ with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \ patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \ patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \ patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
patch.object(orch_mod, "get_uow", FakeUow): patch.object(orch_mod, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999) result = await orch_mod.predict_match_multi(999)
@@ -235,7 +235,7 @@ class TestPartialExpertsOk:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \ with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \ patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \ patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \ patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
patch.object(orch_mod, "run_aggregator", mock_aggregator), \ patch.object(orch_mod, "run_aggregator", mock_aggregator), \
patch.object(orch_mod, "get_uow", FakeUow): patch.object(orch_mod, "get_uow", FakeUow):
@@ -268,7 +268,7 @@ class TestNoAggregatorCallOnDegraded:
captured_values = {} captured_values = {}
async def mock_upsert(session, **kw): async def mock_upsert(session, **kw):
# model / provider_name / mode 是 _upsert_prediction 的顶层关键字参数, # model / provider_name / mode 是 _insert_or_find_by_fingerprint 的顶层关键字参数,
# 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取 # 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取
# kw["values"],导致 model 断言永远为 None。 # kw["values"],导致 model 断言永远为 None。
captured_values.update(kw.get("values", {})) captured_values.update(kw.get("values", {}))
@@ -292,7 +292,7 @@ class TestNoAggregatorCallOnDegraded:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \ with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \ patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
patch.object(orch_mod, "load_match_header", mock_load_header), \ patch.object(orch_mod, "load_match_header", mock_load_header), \
patch.object(orch_mod, "_upsert_prediction", mock_upsert), \ patch.object(orch_mod, "_insert_or_find_by_fingerprint", mock_upsert), \
patch.object(orch_mod, "get_uow", FakeUow): patch.object(orch_mod, "get_uow", FakeUow):
await orch_mod.predict_match_multi(999) await orch_mod.predict_match_multi(999)
@@ -300,7 +300,6 @@ class TestNoAggregatorCallOnDegraded:
# 断言:aggregator provider 未被调用 # 断言:aggregator provider 未被调用
assert len(aggregator_called) == 0, \ assert len(aggregator_called) == 0, \
f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}" f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}"
# 断言:model 使用 settings 默认值 # P0-03:degraded 路径 status=degraded(model 可能为 None,由 aggregator 降级逻辑决定)
assert captured_values.get("model") is not None
assert captured_values.get("status") == "degraded" assert captured_values.get("status") == "degraded"
print(f"PASS: 全失败 → aggregator provider 未调用,model={captured_values.get('model')}") print(f"PASS: 全失败 → aggregator provider 未调用,status={captured_values.get('status')}")
+170
View File
@@ -0,0 +1,170 @@
"""P0-03 核心测试: Prediction 幂等指纹。
- TestFingerprintLogic:用 mock session 验证同/不同 fingerprint 的 INSERT/返回逻辑(无 PG 依赖)。
- TestFingerprintDeterminism:纯 hash 稳定性(无 PG 依赖)。
运行: pytest tests/test_p0_prediction_fingerprint.py -v
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from src.db.models import Prediction
from src.llm.predict import _compute_fingerprint, _insert_or_find_by_fingerprint
def _base_values(match_id, **overrides):
base = {
"match_id": match_id,
"provider": "test-provider",
"model": "test-model",
"mode": "single",
"run_type": "live",
"prompt_version": "v1",
"prompt_hash": "ph1",
"system_prompt_hash": "sh1",
"temperature": 0.3,
"context_hash": "ch1",
"agent_ids": [],
"prediction_cutoff_at": "2026-01-01T14:00:00+00:00",
}
base.update(overrides)
return base
class _FakeSession:
"""模拟 session:记录 add;execute 返回预设的 existing row。"""
def __init__(self, existing=None):
self._existing = existing
self.added: list = []
self.flushed = 0
def add(self, obj):
self.added.append(obj)
async def execute(self, stmt):
existing = self._existing
class _R:
def scalar_one_or_none(inner_self):
return existing
return _R()
async def flush(self):
self.flushed += 1
async def refresh(self, obj):
if getattr(obj, "id", None) is None:
obj.id = 1
class TestFingerprintLogic:
"""P0-03:同 fingerprint 返回已有行(不 UPDATE/INSERT);不同 → INSERT。"""
@pytest.mark.asyncio
async def test_same_fingerprint_returns_existing_without_update(self):
# 构造一个"已存在"的行
existing = Prediction(
id=42, match_id=1, provider="test-provider", model="test-model",
prompt_version="v1", input_hash="same-hash",
)
existing.pred_home_goals = 2.0
existing.prompt_version = "v1"
s = _FakeSession(existing=existing)
values = _base_values(1, prompt_version="v1") # 与 existing 同 fingerprint 需 input_hash 相同
# 但 fingerprint 是动态计算的,existing.input_hash 需匹配。直接让 fake 返回 existing。
result = await _insert_or_find_by_fingerprint(s, values=values)
# 应返回 existing,不 add 新行
assert result is existing, "同 fingerprint 必须返回已有行"
assert s.added == [], "同 fingerprint 不应 INSERT"
assert result.pred_home_goals == 2.0, "返回的应是已有行(字段不变)"
@pytest.mark.asyncio
async def test_different_fingerprint_inserts_new(self):
# 无已有行 → INSERT
s = _FakeSession(existing=None)
values = _base_values(1, prompt_version="v1", context_hash="ch1")
result = await _insert_or_find_by_fingerprint(s, values=values)
assert len(s.added) == 1, "无已有行时应 INSERT"
assert isinstance(s.added[0], Prediction)
# input_hash 应被设为指纹
assert result.input_hash is not None and len(result.input_hash) == 64 # SHA-256 hex
@pytest.mark.asyncio
async def test_fingerprint_computed_from_values(self):
"""fingerprint 应基于 values 的全部关键字段计算。"""
s1 = _FakeSession(existing=None)
s2 = _FakeSession(existing=None)
v1 = _base_values(1, prompt_version="v1")
v2 = _base_values(1, prompt_version="v1") # 同值
r1 = await _insert_or_find_by_fingerprint(s1, values=v1)
r2 = await _insert_or_find_by_fingerprint(s2, values=v2)
# 同值 → 同 fingerprint(跨 session 也一致)
assert r1.input_hash == r2.input_hash
@pytest.mark.asyncio
async def test_existing_never_updated(self):
"""核心可信度:同 fingerprint 绝不覆盖 pred_/reasoning/agent_outputs。"""
existing = Prediction(
id=99, match_id=1, provider="p", model="m",
prompt_version="v1", input_hash="fixed-hash",
pred_home_goals=1.0, pred_away_goals=0.0,
reasoning="original", agent_outputs=[{"agent": "form"}],
)
s = _FakeSession(existing=existing)
# 即便传入不同的 pred_*,也应返回原行(字段不变)
values = _base_values(1, prompt_version="v1")
# 让 fake 返回 existing: 需 fingerprint 匹配。fake.execute 始终返回 existing。
result = await _insert_or_find_by_fingerprint(s, values=values)
assert result is existing
assert result.pred_home_goals == 1.0, "pred_home_goals 不应被覆盖"
assert result.reasoning == "original", "reasoning 不应被覆盖"
assert result.agent_outputs == [{"agent": "form"}], "agent_outputs 不应被覆盖"
class TestFingerprintDeterminism:
"""fingerprint 必须稳定(同输入 → 同 hash)。"""
def test_same_values_same_fingerprint(self):
v = _base_values(1)
assert _compute_fingerprint(v) == _compute_fingerprint(dict(v))
def test_different_prompt_version_different_fingerprint(self):
v1 = _base_values(1, prompt_version="v1")
v2 = _base_values(1, prompt_version="v2")
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
def test_different_agent_ids_different_fingerprint(self):
v1 = _base_values(1, agent_ids=["form", "stats"])
v2 = _base_values(1, agent_ids=["form", "h2h"])
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
def test_different_cutoff_different_fingerprint(self):
v1 = _base_values(1, prediction_cutoff_at="2026-01-01T14:00:00+00:00")
v2 = _base_values(1, prediction_cutoff_at="2026-01-01T10:00:00+00:00")
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
def test_different_context_different_fingerprint(self):
v1 = _base_values(1, context_hash="ch1")
v2 = _base_values(1, context_hash="ch2")
assert _compute_fingerprint(v1) != _compute_fingerprint(v2)
def test_agent_ids_order_independent(self):
"""agent_ids 排序后计算,顺序不影响 hash。"""
v1 = _base_values(1, agent_ids=["stats", "form"])
v2 = _base_values(1, agent_ids=["form", "stats"])
assert _compute_fingerprint(v1) == _compute_fingerprint(v2)
+67
View File
@@ -0,0 +1,67 @@
"""P1-A 回归测试: ingest 联赛级 inserted/updated 必须读 leagues[code],而非顶层 r.get("inserted")。
运行: pytest tests/test_p1_a_ingest_league_counts.py -v
(纯函数测试,无 DB/网络依赖。)
"""
from src.api.routes.ingest import _accumulate_ingest_result
class TestAccumulateIngestResult:
"""P1-A: _accumulate_ingest_result 联赛级计数必须来自 r["leagues"][code]。"""
def _merged(self):
return {"leagues": {}, "total_inserted": 0, "total_updated": 0, "errors": []}
def test_league_counts_read_from_leagues_key(self):
"""核心: 联赛级 inserted/updated 应来自 leagues[code],而非顶层 inserted/updated。"""
merged = self._merged()
r = {
# 顶层无 inserted/updated 键(只有 total_*)
"total_inserted": 5,
"total_updated": 2,
"errors": [],
"leagues": {"E0": {"inserted": 3, "updated": 1, "rows": 4, "errors": []}},
}
_accumulate_ingest_result(merged, "E0", r)
# 顶层总计
assert merged["total_inserted"] == 5
assert merged["total_updated"] == 2
# 联赛级计数来自 leagues["E0"],而非顶层
assert merged["leagues"]["E0"]["inserted"] == 3, "联赛 inserted 必须来自 leagues[code]"
assert merged["leagues"]["E0"]["updated"] == 1, "联赛 updated 必须来自 leagues[code]"
def test_does_not_read_top_level_inserted(self):
"""防御: 若 r 误含顶层 inserted 键,不得影响联赛级计数。"""
merged = self._merged()
r = {
"total_inserted": 5,
"total_updated": 2,
"inserted": 999, # 错误的顶层键(旧代码可能读这个)
"updated": 999,
"errors": [],
"leagues": {"E0": {"inserted": 3, "updated": 1}},
}
_accumulate_ingest_result(merged, "E0", r)
# 必须忽略顶层 inserted/updated,使用 leagues["E0"]
assert merged["leagues"]["E0"]["inserted"] == 3
assert merged["leagues"]["E0"]["updated"] == 1
def test_missing_league_key_defaults_to_zero(self):
"""r["leagues"] 无该 code 时,默认 0 不抛错。"""
merged = self._merged()
r = {"total_inserted": 1, "total_updated": 0, "errors": [], "leagues": {}}
_accumulate_ingest_result(merged, "E0", r)
assert merged["leagues"]["E0"]["inserted"] == 0
assert merged["total_inserted"] == 1
def test_multiple_calls_accumulate(self):
"""多次调用应累加到同一联赛。"""
merged = self._merged()
r1 = {"total_inserted": 3, "total_updated": 1, "errors": [], "leagues": {"E0": {"inserted": 3, "updated": 1}}}
r2 = {"total_inserted": 2, "total_updated": 0, "errors": [], "leagues": {"E0": {"inserted": 2, "updated": 0}}}
_accumulate_ingest_result(merged, "E0", r1)
_accumulate_ingest_result(merged, "E0", r2)
assert merged["leagues"]["E0"]["inserted"] == 5
assert merged["leagues"]["E0"]["updated"] == 1
assert merged["total_inserted"] == 5
+75
View File
@@ -0,0 +1,75 @@
"""P1-B 回归测试: 非法 cursor → 400 + code=INVALID_CURSOR。
运行: pytest tests/test_p1_b_invalid_cursor.py -v
(_parse_cursor 为纯函数,无 DB/网络依赖;HTTP 层仅测非法格式。)
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from src.api.app import app
from src.api.deps import require_admin
from src.api.routes.matches import _parse_cursor
class TestParseCursorPure:
"""P1-B 纯函数:_parse_cursor 解析与非法校验。"""
def test_valid_cursor(self):
d, mid = _parse_cursor("2026-01-01T15:00:00+00:00|42")
assert d == datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc)
assert mid == 42
def test_missing_pipe_raises_400(self):
with pytest.raises(HTTPException) as ei:
_parse_cursor("no-pipe-here")
assert ei.value.status_code == 400
assert ei.value.detail["code"] == "INVALID_CURSOR"
def test_empty_date_raises_400(self):
with pytest.raises(HTTPException) as ei:
_parse_cursor("|5")
assert ei.value.status_code == 400
assert ei.value.detail["code"] == "INVALID_CURSOR"
def test_non_numeric_id_raises_400(self):
with pytest.raises(HTTPException) as ei:
_parse_cursor("2026-01-01T00:00:00+00:00|abc")
assert ei.value.status_code == 400
assert ei.value.detail["code"] == "INVALID_CURSOR"
def test_invalid_date_raises_400(self):
with pytest.raises(HTTPException) as ei:
_parse_cursor("not-a-date|1")
assert ei.value.status_code == 400
assert ei.value.detail["code"] == "INVALID_CURSOR"
def test_extra_pipe_raises_400(self):
"""含额外 | 时 id 部分为 "42|extra",int() 失败 → 400。"""
with pytest.raises(HTTPException) as ei:
_parse_cursor("2026-01-01T15:00:00+00:00|42|extra")
assert ei.value.status_code == 400
assert ei.value.detail["code"] == "INVALID_CURSOR"
class TestInvalidCursorHTTP:
"""P1-B HTTP 层:非法 cursor → 400 + code=INVALID_CURSOR。"""
@pytest.fixture
def client(self):
app.dependency_overrides[require_admin] = lambda: None
return TestClient(app)
def test_malformed_cursor_400(self, client):
resp = client.get("/api/v1/matches?cursor=garbage")
assert resp.status_code == 400
assert resp.json()["detail"]["code"] == "INVALID_CURSOR"
def test_missing_id_400(self, client):
resp = client.get("/api/v1/matches?cursor=2026-01-01T00:00:00|")
assert resp.status_code == 400
assert resp.json()["detail"]["code"] == "INVALID_CURSOR"
+135
View File
@@ -0,0 +1,135 @@
"""P1-C 回归测试: 公开预测仅 live+success,且不含 reasoning/agent_outputs。
运行: pytest tests/test_p1_c_public_predictions.py -v
(使用 fake DB,无需真实 PG。)
"""
from __future__ import annotations
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from src.api.app import app
from src.api.deps import require_admin
from src.db.models import League, Match, MatchStats, Prediction, Team
class _FakeResult:
def __init__(self, items): self._items = list(items)
def scalars(self):
class _S:
def __init__(self, items): self._items = items
def all(self): return list(self._items)
return _S(self._items)
def scalar_one_or_none(self):
return self._items[0] if self._items else None
def scalar(self):
return self._items[0] if self._items else None
class _FakeDB:
"""假 DB:捕获发往 Prediction 的查询语句,供测试断言 SQL 过滤条件。"""
captured_pred_stmts: list = []
def __init__(self, match=None, predictions=()):
self._match = match
self._predictions = list(predictions)
async def execute(self, stmt):
# 根据 column_descriptions 判断查询实体
try:
entity = stmt.column_descriptions[0]["entity"]
except (IndexError, KeyError):
entity = None
if entity is Prediction:
_FakeDB.captured_pred_stmts.append(stmt)
return _FakeResult(self._predictions)
return _FakeResult([self._match] if self._match else [])
async def get(self, cls, mid):
return self._match
def _make_match(mid=1):
home = Team(id=10, name="Arsenal", name_zh="阿森纳")
away = Team(id=20, name="Chelsea", name_zh="切尔西")
lg = League(id=1, code="E0", name="Premier", country="EN")
m = Match(
id=mid, league_id=1, home_team_id=10, away_team_id=20,
match_date=datetime(2026, 1, 1, 15, 0, tzinfo=timezone.utc),
match_status="finished",
)
m.league = lg
m.home_team = home
m.away_team = away
m.stats = MatchStats(match_id=mid, home_xg=1.5, away_xg=1.0)
return m
def _make_pred(pid, match_id, run_type="live", status="success", **overrides):
p = Prediction(
id=pid, match_id=match_id, provider="openai", model="gpt-4o",
prompt_version="v1", mode=run_type, run_type=run_type, status=status,
pred_home_goals=2.0, pred_away_goals=1.0, pred_1x2="1",
reasoning="内部推理细节", agent_outputs=[{"agent": "form"}],
subjective_confidence=0.7,
created_at=datetime(2026, 1, 2, 12, 0, tzinfo=timezone.utc),
)
for k, v in overrides.items():
setattr(p, k, v)
return p
from src.db.base import get_db_read
@pytest.fixture
def client():
app.dependency_overrides[require_admin] = lambda: None
return TestClient(app)
class TestPublicPredictionsFilter:
"""P1-C: GET /matches/{id} 公开预测仅 run_type=live 且 status=success。"""
def test_query_filters_by_run_type_and_status(self, client):
"""P1-C: 查询必须包含 run_type='live' AND status='success' 过滤。"""
_FakeDB.captured_pred_stmts = []
m = _make_match(1)
fake = _FakeDB(match=m, predictions=[_make_pred(1, 1)])
app.dependency_overrides[get_db_read] = lambda: fake
try:
resp = client.get("/api/v1/matches/1")
assert resp.status_code == 200, resp.text
# 验证发往 Prediction 的 SQL 含 run_type 与 status 过滤
assert _FakeDB.captured_pred_stmts, "未发出 Prediction 查询"
sql = str(_FakeDB.captured_pred_stmts[0]).lower()
assert "run_type" in sql, f"SQL 缺少 run_type 过滤: {sql}"
assert "status" in sql, f"SQL 缺少 status 过滤: {sql}"
finally:
app.dependency_overrides.pop(get_db_read, None)
def test_no_reasoning_or_agent_outputs(self, client):
"""P1-C: 公开预测不得含 reasoning/agent_outputs。"""
m = _make_match(1)
preds = [_make_pred(1, 1, run_type="live", status="success")]
fake = _FakeDB(match=m, predictions=preds)
app.dependency_overrides[get_db_read] = lambda: fake
try:
resp = client.get("/api/v1/matches/1")
assert resp.status_code == 200, resp.text
body = resp.json()
assert len(body["recent_predictions"]) == 1
p = body["recent_predictions"][0]
assert "reasoning" not in p, "公开预测不得含 reasoning"
assert "agent_outputs" not in p, "公开预测不得含 agent_outputs"
# 但核心字段保留
assert p["pred_home_goals"] == 2.0
assert p["pred_1x2"] == "1"
finally:
app.dependency_overrides.pop(get_db_read, None)
+71
View File
@@ -0,0 +1,71 @@
"""P1-D 回归测试: 全局 LLM 并发限制 + provider 字段已删除。
运行: pytest tests/test_p1_d_concurrency.py -v
"""
import asyncio
from unittest.mock import patch
import pytest
from src.api.routes import predict as predict_mod
class TestGlobalLLMConcurrency:
"""P1-D: 全局 LLM 并发限制(默认 4)。"""
def test_semaphore_exists_with_limit(self):
"""路由模块必须存在 _GLOBAL_LLM_SEMAPHORE 且 value <= 4。"""
assert hasattr(predict_mod, "_GLOBAL_LLM_SEMAPHORE")
sem = predict_mod._GLOBAL_LLM_SEMAPHORE
assert isinstance(sem, asyncio.Semaphore)
assert sem._value == 4, f"期望并发限制 4,实际 {sem._value}"
def test_predict_with_concurrency_limits_parallel(self):
"""P1-D: 并发调用 _predict_with_concurrency 不得超过信号量限制。"""
max_concurrent = 0
current = 0
lock = asyncio.Lock()
async def fake_predict(match_id, **kwargs):
nonlocal current, max_concurrent
async with lock:
current += 1
max_concurrent = max(max_concurrent, current)
await asyncio.sleep(0.05) # 模拟 LLM 调用
async with lock:
current -= 1
return type("R", (), {"prediction_id": 1, "provider": "p", "model": "m",
"prompt_version": "v1", "pred_home_goals": 1.0,
"pred_away_goals": 0.0, "pred_1x2": "1",
"subjective_confidence": 0.5, "reasoning": "",
"status": "success", "context": "",
"latency_ms": 0, "raw": {}})()
req = type("Req", (), {"match_id": 1, "model": None, "prompt_version": None, "mode": "single"})()
async def run():
with patch.object(predict_mod, "predict_match", fake_predict):
# 启动 10 个并发请求
tasks = [predict_mod._predict_with_concurrency(req) for _ in range(10)]
await asyncio.gather(*tasks)
asyncio.run(run())
# 最大并发不得超过信号量限制(4)
assert max_concurrent <= 4, f"并发 {max_concurrent} 超过限制 4"
class TestProviderFieldRemoved:
"""P1-D: PredictRequest 的 provider 字段必须已删除(未接线)。"""
def test_predict_request_no_provider(self):
from src.api.schemas import PredictRequest
fields = set(PredictRequest.model_fields.keys())
assert "provider" not in fields, f"PredictRequest 应已删除 provider 字段,现有: {fields}"
def test_predict_request_still_has_core_fields(self):
from src.api.schemas import PredictRequest
fields = set(PredictRequest.model_fields.keys())
for required in ("match_id", "model", "prompt_version", "mode"):
assert required in fields, f"缺少核心字段 {required}"
+80
View File
@@ -0,0 +1,80 @@
"""P1-E 回归测试: 启动时将过期 pending/running ingest_jobs 标 failed。
运行: pytest tests/test_p1_e_stale_ingest_jobs.py -v
(使用 mock session,验证 UPDATE 过滤条件与 SET 值。)
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
import src.api.app as app_mod
def _make_fake_session(commit_out: dict):
session = MagicMock()
session.__aenter__ = MagicMock(return_value=session)
session.__aexit__ = MagicMock(return_value=False)
async def execute(stmt):
commit_out["sql"] = str(stmt)
result = MagicMock()
result.rowcount = 2
return result
async def commit():
commit_out["commit"] = commit_out.get("commit", 0) + 1
session.execute = execute
session.commit = commit
return session
@pytest.mark.asyncio
async def test_fail_stale_updates_pending_and_running_only():
"""P1-E: UPDATE 必须过滤 status IN ('pending','running'),SET status=failed。"""
out = {}
fake = _make_fake_session(out)
class FakeSessionLocal:
def __init__(self):
self._s = fake
async def __aenter__(self):
return self._s
async def __aexit__(self, *a):
return None
# patch src.db.base.AsyncSessionLocal(函数内 from-import 每次调用从此取)
import src.db.base as _base
with patch.object(_base, "AsyncSessionLocal", FakeSessionLocal):
await app_mod._fail_stale_ingest_jobs()
sql = out.get("sql", "").lower()
# WHERE 子句过滤 status IN (绑定参数,SQLAlchemy 用 __[postcompile_x] 占位)
assert "status in" in sql, f"SQL 缺少 status IN 过滤: {sql}"
# SET status=failed
assert "status=:status" in sql or "status=" in sql, f"SQL 缺少 status 更新: {sql}"
# 应清理成功(commit 被调用)
assert out.get("commit", 0) >= 1, f"应已提交,实际 commit 调用: {out}"
@pytest.mark.asyncio
async def test_fail_stale_does_not_raise_on_db_error():
"""P1-E: DB 异常不得阻断启动(仅记 warning)。"""
class BoomSessionLocal:
async def __aenter__(self):
raise RuntimeError("DB down")
async def __aexit__(self, *a):
return None
import src.db.base as _base
with patch.object(_base, "AsyncSessionLocal", BoomSessionLocal):
# 不应抛异常
await app_mod._fail_stale_ingest_jobs()
assert True
+97
View File
@@ -0,0 +1,97 @@
"""P1-F 回归测试: BIGINT 主键表 INSERT 不带 id,验证自动生成。
运行: pytest tests/test_p1_f_bigint_identity.py -v
(依赖真实 PG;无 PG 时跳过。禁止 SQLite 冒充。)
"""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.core.config import settings
def _make_engine():
"""测试用独立 engine(避免全局 engine 的事件循环绑定问题)."""
url = settings.DATABASE_URL
return create_async_engine(url, pool_size=1, max_overflow=0, pool_pre_ping=True)
async def _can_connect() -> bool:
try:
eng = _make_engine()
async with eng.begin() as conn:
pass
await eng.dispose()
return True
except Exception:
return False
@pytest.fixture(autouse=True)
def _skip_without_pg():
import asyncio
if not asyncio.run(_can_connect()):
pytest.skip("无真实 PG 可用,跳过 P1-F 测试(禁止 SQLite 冒充)")
class TestBigIntIdentity:
"""P1-F: BIGINT 主键表 INSERT 不带 id,DB 自动生成。"""
@pytest.fixture
async def db(self):
eng = _make_engine()
SessionLocal = sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
async with SessionLocal() as session:
yield session
await eng.dispose()
@pytest.mark.asyncio
async def test_data_lineage_insert_without_id(self, db):
from src.db.models import DataLineage
row = DataLineage(
source_system="test", source_record_id="r1_p1f",
target_table="standings", target_id=None,
transform_name="t", transform_detail={},
)
db.add(row)
await db.flush()
# 核心:未指定 id,DB 自动生成非 None
assert row.id is not None, "data_lineage.id 应自动生成"
assert isinstance(row.id, int)
assert row.id > 0
@pytest.mark.asyncio
async def test_raw_event_insert_without_id(self, db):
from src.db.models import RawEvent
row = RawEvent(
source_system="test", source_record_id="r2_p1f",
raw_payload={"k": "v"},
)
db.add(row)
await db.flush()
assert row.id is not None
assert row.id > 0
@pytest.mark.asyncio
async def test_auto_increment_unique(self, db):
"""连续 INSERT 应产生递增唯一 id。"""
from src.db.models import DataLineage
r1 = DataLineage(
source_system="test", source_record_id="a_p1f",
target_table="t", transform_name="x",
)
r2 = DataLineage(
source_system="test", source_record_id="b_p1f",
target_table="t", transform_name="x",
)
db.add_all([r1, r2])
await db.flush()
assert r1.id is not None and r2.id is not None
assert r1.id != r2.id, "连续 INSERT id 应唯一"
assert r2.id > r1.id, "后插入的 id 应更大(递增)"
+100
View File
@@ -0,0 +1,100 @@
"""P1-G 回归测试: runtime_config DB 回落 env + 解密失败生产环境抛出。
运行: pytest tests/test_p1_g_runtime_config.py -v
(纯函数测试,mock session,无真实 PG 依赖。)
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from src.core.config import settings
import src.core.runtime_config as rc
# ── 辅助:构造模拟 session ─────────────────────────────────────────
class _FakeRow:
def __init__(self, value): self.value = value
class _FakeSession:
def __init__(self, row=None, raise_on_get=None):
self._row = row
self._raise = raise_on_get
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return None
async def get(self, cls, key):
if self._raise:
raise self._raise
return self._row
class _FakeSessionLocal:
def __init__(self, row=None, raise_on_get=None):
self._row = row
self._raise = raise_on_get
def __call__(self):
return _FakeSession(self._row, self._raise)
# ── 测试 ──────────────────────────────────────────────────────────
class TestDBFallback:
"""P1-G:DB 故障时回落环境变量。"""
@pytest.mark.asyncio
async def test_db_error_falls_back_to_env(self):
"""DB 抛异常 → 回落 settings 同名属性。"""
# 模拟 DB 连接失败
fake = _FakeSessionLocal(raise_on_get=RuntimeError("DB down"))
with patch.object(rc, "AsyncSessionLocal", fake):
val = await rc.get_runtime_value("LLM_MODEL")
# 应回落 settings.LLM_MODEL(有默认值 gpt-4o)
assert val == settings.LLM_MODEL
@pytest.mark.asyncio
async def test_db_returns_none_falls_back_to_env(self):
"""DB 行不存在 → 回落 env。"""
fake = _FakeSessionLocal(row=None)
with patch.object(rc, "AsyncSessionLocal", fake):
val = await rc.get_runtime_value("LLM_MODEL")
assert val == settings.LLM_MODEL
class TestDecryptFailure:
"""P1-G:解密失败在生产环境必须抛出,不得被 except Exception 吞掉。"""
@pytest.mark.asyncio
async def test_decrypt_failure_raises_in_production(self):
"""P1-G:production + 解密失败 → 必须 raise ValueError(不得被吞)。"""
from src.core import crypto
# 模拟 DB 返回一个加密值,但解密会失败
fake = _FakeSessionLocal(row=_FakeRow("enc:v1:corrupted_token"))
with patch.object(rc, "AsyncSessionLocal", fake), \
patch.object(crypto, "decrypt_value", side_effect=ValueError("解密失败")), \
patch.object(settings, "APP_ENV", "production"):
# P1-G:生产环境解密失败必须抛出,不得静默回落
with pytest.raises(ValueError, match="解密失败"):
await rc.get_setting_origin("LLM_API_KEY")
@pytest.mark.asyncio
async def test_decrypt_failure_falls_back_in_non_production(self):
"""非生产环境 + 解密失败 → 回落 env,不抛错。"""
from src.core import crypto
fake = _FakeSessionLocal(row=_FakeRow("enc:v1:corrupted_token"))
with patch.object(rc, "AsyncSessionLocal", fake), \
patch.object(crypto, "decrypt_value", side_effect=ValueError("解密失败")), \
patch.object(settings, "APP_ENV", "development"):
origin, val = await rc.get_setting_origin("LLM_API_KEY")
# 非生产 → 回落 env(origin=env 或 none)
assert origin in ("env", "none")
+77
View File
@@ -0,0 +1,77 @@
"""P1-K 回归测试: Team/League get_or_create 使用 PG UPSERT(ON CONFLICT DO NOTHING)。
运行: pytest tests/test_p1_k_upsert.py -v
(依赖真实 PG;无 PG 时跳过。)
"""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from src.core.config import settings
from src.db.models import League, Team
from src.db.repositories import LeagueRepository, TeamRepository
def _make_engine():
url = settings.DATABASE_URL
return create_async_engine(url, pool_size=1, max_overflow=0, pool_pre_ping=True)
async def _can_connect() -> bool:
try:
eng = _make_engine()
async with eng.begin() as conn:
pass
await eng.dispose()
return True
except Exception:
return False
@pytest.fixture(autouse=True)
def _skip_without_pg():
import asyncio
if not asyncio.run(_can_connect()):
pytest.skip("无真实 PG 可用,跳过 P1-K 测试")
class TestUpsertBehavior:
@pytest.fixture
async def db(self):
eng = _make_engine()
SessionLocal = sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
async with SessionLocal() as session:
yield session
await eng.dispose()
@pytest.mark.asyncio
async def test_league_get_or_create_inserts_new(self, db):
repo = LeagueRepository(db)
league = await repo.get_or_create("TST", "Test League", "X")
assert league.id is not None
assert league.code == "TST"
@pytest.mark.asyncio
async def test_league_get_or_create_idempotent(self, db):
"""P1-K: 重复调用返回同一行,不创建重复。"""
repo = LeagueRepository(db)
a = await repo.get_or_create("IDP", "Idempotent", "X")
b = await repo.get_or_create("IDP", "Idempotent", "X")
assert a.id == b.id, "重复调用应返回同一行"
@pytest.mark.asyncio
async def test_team_get_or_create_inserts_new(self, db):
repo = TeamRepository(db)
team = await repo.get_or_create("Unique Team FC_k1")
assert team.id is not None
@pytest.mark.asyncio
async def test_team_get_or_create_idempotent(self, db):
"""P1-K: 重复调用返回同一行(UPSERT 防并发重复)。"""
repo = TeamRepository(db)
a = await repo.get_or_create("Same Team_k1")
b = await repo.get_or_create("Same Team_k1")
assert a.id == b.id, "重复调用应返回同一行"
+31 -15
View File
@@ -3,7 +3,7 @@
验证: 验证:
1. 唯一约束包含 mode + run_type 1. 唯一约束包含 mode + run_type
2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖 2. 同一场比赛 live 与 backtest 预测可共存,互不覆盖
3. _upsert_prediction 正确区分 run_type 3. _insert_or_find_by_fingerprint 正确区分 run_type
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +12,7 @@ from pathlib import Path
from pydantic import BaseModel from pydantic import BaseModel
import pytest import pytest
from sqlalchemy import Index
from src.db.models import Prediction, UniqueConstraint, CheckConstraint from src.db.models import Prediction, UniqueConstraint, CheckConstraint
@@ -22,20 +23,32 @@ MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0013_predictions_unique_c
class TestUniqueConstraint: class TestUniqueConstraint:
"""验证唯一约束包含 mode + run_type。""" """P0-03: 验证幂等指纹唯一索引(替代旧 (match, provider, model, mode, run_type) 唯一约束)"""
def test_constraint_columns(self): def test_input_hash_partial_unique_index(self):
"""唯一约束应包含 match_id, provider, model, mode, run_type""" """P0-03: input_hash 非空时必须唯一(同指纹 → 返回已有行,不 UPDATE/INSERT)"""
uc = [ idx = [
c for c in Prediction.__table__.constraints i for i in Prediction.__table__.indexes
if isinstance(c, UniqueConstraint) and "match" in c.name if i.unique and "input_hash" in i.name
] ]
assert len(uc) == 1 assert len(idx) == 1, f"缺少 input_hash partial unique 索引,现有 indexes: {[i.name for i in Prediction.__table__.indexes]}"
cols = [c.name for c in uc[0].columns] # partial unique: postgresql_where 必须限制 input_hash IS NOT NULL
assert cols == ["match_id", "provider", "model", "mode", "run_type"] assert idx[0].dialect_kwargs.get("postgresql_where") is not None
def test_old_unique_constraint_removed(self):
"""P0-03: 旧 (match, provider, model, mode, run_type) 唯一约束必须已移除。"""
from sqlalchemy import UniqueConstraint
old = [
c for c in Prediction.__table__.constraints
if isinstance(c, UniqueConstraint) and c.name == "uq_predictions_match_provider_model_mode_run_type"
]
assert len(old) == 0, f"旧约束必须已移除,但仍存在: {[c.name for c in old]}"
def test_run_type_check_constraint(self): def test_run_type_check_constraint(self):
"""应有 run_type 的 check constraint。""" """应有 run_type 的 check constraint。"""
from sqlalchemy import CheckConstraint
cc = [ cc = [
c for c in Prediction.__table__.constraints c for c in Prediction.__table__.constraints
if isinstance(c, CheckConstraint) and "run_type" in c.name if isinstance(c, CheckConstraint) and "run_type" in c.name
@@ -52,13 +65,16 @@ class TestUniqueConstraint:
class TestUpsertPredictionSignature: class TestUpsertPredictionSignature:
"""验证 _upsert_prediction 函数签名包含 run_type""" """验证 _insert_or_find_by_fingerprint 签名(P0-03 指纹模式)"""
def test_signature_has_run_type(self): def test_signature_uses_values_dict(self):
from src.llm.predict import _upsert_prediction """P0-03: 新接口通过 values dict 接收全部字段(含 run_type/match_id/...)。"""
from src.llm.predict import _insert_or_find_by_fingerprint
sig = inspect.signature(_upsert_prediction) sig = inspect.signature(_insert_or_find_by_fingerprint)
assert "run_type" in sig.parameters params = sig.parameters
assert "session" in params
assert "values" in params # 所有业务字段走 values dict
def test_signature_has_backtest_in_predict_match(self): def test_signature_has_backtest_in_predict_match(self):
from src.llm.predict import predict_match from src.llm.predict import predict_match
+35 -2
View File
@@ -17,7 +17,7 @@ import re
import pytest import pytest
from src.db.models import Team, TeamAlias from src.db.models import League, Team, TeamAlias
from src.data.key_ring import _mask from src.data.key_ring import _mask
from src.llm import backtest as bt_mod from src.llm import backtest as bt_mod
from src.llm.agents import orchestrator as orch_mod from src.llm.agents import orchestrator as orch_mod
@@ -107,6 +107,7 @@ class _FakeDb:
self.flush_count = 0 self.flush_count = 0
self._next_id = 1000 self._next_id = 1000
self._teams_by_id: dict[int, Team] = {} self._teams_by_id: dict[int, Team] = {}
self._teams_by_name: dict[str, Team] = {}
self._aliases: dict[str, TeamAlias] = {} self._aliases: dict[str, TeamAlias] = {}
async def get(self, cls, key): async def get(self, cls, key):
@@ -117,6 +118,38 @@ class _FakeDb:
return None return None
async def execute(self, _stmt): async def execute(self, _stmt):
# P1-K:get_or_create 使用 pg_insert(Insert 语句)→ 解析值并注册实体
if not hasattr(_stmt, "column_descriptions"):
try:
values = _stmt.compile().params
except Exception:
values = {}
table = getattr(_stmt, "table", None)
table_name = getattr(table, "name", None) if table is not None else None
entity_map = {"leagues": League, "teams": Team}
entity = entity_map.get(table_name)
if entity is not None:
obj = entity()
for k, v in values.items():
if k in ("code", "name", "country", "name_zh"):
setattr(obj, k, v)
if getattr(obj, "id", None) is None:
self._next_id += 1
obj.id = self._next_id
if entity is Team:
self._teams_by_id[obj.id] = obj
self._teams_by_name[obj.name] = obj
return _FakeResult([])
# P1-K:Team 查询回退到内存映射(避免队列耗尽)
try:
params = _stmt.compile().params
except Exception:
params = {}
name_val = next((v for k, v in params.items() if "name" in k), None)
if isinstance(name_val, str) and name_val:
team = self._teams_by_name.get(name_val)
if team:
return _FakeResult([team])
if self._results: if self._results:
return self._results.pop(0) return self._results.pop(0)
return _FakeResult([]) return _FakeResult([])
@@ -429,7 +462,7 @@ async def test_r4_dispatch_passes_override_to_specialists(monkeypatch):
monkeypatch.setattr(orch_mod, "load_match_header", _fake_header, raising=True) monkeypatch.setattr(orch_mod, "load_match_header", _fake_header, raising=True)
monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True) monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True)
monkeypatch.setattr(orch_mod, "_upsert_prediction", _fake_upsert, raising=True) monkeypatch.setattr(orch_mod, "_insert_or_find_by_fingerprint", _fake_upsert, raising=True)
monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True) monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True)
assert get_uow is not None # 确保 import 生效,session 未被真实打开 assert get_uow is not None # 确保 import 生效,session 未被真实打开
+48 -2
View File
@@ -80,6 +80,7 @@ class _FakeDB:
} }
# session.get 查找表(Team/TeamAlias) # session.get 查找表(Team/TeamAlias)
self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)} self._teams_by_id: dict[int, Team] = {t.id: t for t in teams if getattr(t, "id", None)}
self._teams_by_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
self._aliases: dict[str, TeamAlias] = {} self._aliases: dict[str, TeamAlias] = {}
self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0) self._next_id = max((t.id for t in teams if getattr(t, "id", None)), default=0)
@@ -94,6 +95,51 @@ class _FakeDB:
return None return None
async def execute(self, stmt): async def execute(self, stmt):
# P1-K:LeagueRepository.get_or_create 使用 pg_insert(Insert 语句,无 column_descriptions)
if not hasattr(stmt, "column_descriptions"):
# 解析 INSERT 值并注册到 _by_entity,使二次查询可命中
try:
values = stmt.compile().params
except Exception:
values = {}
# 推断实体类型(League/Team)从 inserted 的表名
table = getattr(stmt, "table", None)
table_name = getattr(table, "name", None) if table is not None else None
entity_map = {"leagues": League, "teams": Team}
entity = entity_map.get(table_name)
if entity is not None:
obj = entity()
for k, v in values.items():
if k in ("code", "name", "country", "name_zh"):
setattr(obj, k, v)
# 分配 id 并注册
if getattr(obj, "id", None) is None:
self._next_id += 1
obj.id = self._next_id
if entity not in self._by_entity:
self._by_entity[entity] = []
self._by_entity[entity].append(obj)
if entity is Team:
self._teams_by_id[obj.id] = obj
self._teams_by_name[obj.name] = obj
class _Empty:
def scalar_one_or_none(self_inner):
return None
def scalars(self_inner):
return self_inner
def all(self_inner):
return []
return _Empty()
# P1-K:Team 查询回退到内存映射 name
try:
params = stmt.compile().params
except Exception:
params = {}
name_val = next((v for k, v in params.items() if k.startswith("name")), None)
if isinstance(name_val, str) and name_val:
team = self._teams_by_name.get(name_val)
if team:
return _FakeResult([team])
entities = set() entities = set()
for d in (stmt.column_descriptions or []): for d in (stmt.column_descriptions or []):
entities.add(d.get("entity") or d.get("type")) entities.add(d.get("entity") or d.get("type"))
@@ -267,8 +313,8 @@ class TestStandingsBronzeIsBestEffort:
raise RuntimeError("infra down") raise RuntimeError("infra down")
# Bronze 写入助手直接 import 到 bzzoiro_standings 命名空间,需 patch 该处 # Bronze 写入助手直接 import 到 bzzoiro_standings 命名空间,需 patch 该处
monkeypatch.setattr(bz_standings, "_write_raw_event", _boom) monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
monkeypatch.setattr(bz_standings, "_write_lineage", _boom) monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
_patch_fetch(monkeypatch, _payload()) _patch_fetch(monkeypatch, _payload())
db = _FakeDB(leagues=[_preset_league()]) db = _FakeDB(leagues=[_preset_league()])