Author SHA1 Message Date
WorkBuddy f0c1cc1491 feat(logging): 日志持久化落盘(LOG_FILE 滚动文件)
- LOG_FILE 配置: 空(默认)保持 stdout + Admin 内存日志页(重启清零);
  填路径后额外写 RotatingFileHandler(单文件 10MB × 5 份,utf-8)
- setup_logging 幂等挂载(按 abspath 判重,相对/绝对同文件算一个);
  目录自动创建;文件基础设施失败只 warning 绝不拖垮启动
- docker-compose: api 挂 applogs 卷,默认 LOG_FILE=/app/logs/app.log,
  容器重建日志不丢;.env.example/.gitignore/docs 同步
- tests/test_log_persistence.py: 落盘/幂等/空值禁用/自动建目录/失败降级
- test_p0_standings_cutoff: 匿名约束 name=None 使 any() 子串匹配 TypeError
  (集合顺序不定 → flaky),先判真值再匹配(存量缺陷顺手加固)
2026-09-22 12:18:03 +08:00
shangfangjian 8fef411ced fix: 清理 LLM prompt 中的过时引用(confidence/injuries)
- 全部 agent/match_prediction prompt: deprecated "confidence" → "subjective_confidence"
- aggregator prompt: 移除已删除的 injuries 专家(→standings),修正专家中文名(阵容完整性→联赛排名)

消除预测日志中的 Deprecated field / 未知专家警告。
2026-09-22 11:05:07 +08:00
shangfangjian 197641b9f7 fix(P1-async): 预测改为异步 POST+轮询,避免网关超时(Cloudflare 524)
后端:POST /predict 立即返回 job_id,后台 asyncio.create_task 执行;
新增 GET /predict/jobs/{job_id} 轮询状态(running/success/failed)。

前端: useMatchPredict 改为 POST 拿 job_id → 每 3s 轮询直到终态;
整体超时 5 分钟不变。避免多专家预测 60-180s 触发 Cloudflare 100s 超时(HTTP 524)。
2026-09-22 11:00:40 +08:00
shangfangjian 1a9dc63edd fix: Standing 无 xgd 属性,用 xg_for - xg_against 计算 xG差
standings_slice._fmt 误用 row.xgd(Standing 无此列),预测时报 AttributeError。
修正:直接用 xg_for - xg_against 计算 xG差,去掉对 goal_diff 的冗余判断。
2026-09-22 10:52:26 +08:00
shangfangjian b15678b3f6 fix: http client URL 构造必须统一加 /api/v1 前缀
公开页调用 /matches(无 /api/v1 前缀),被 nginx 当 SPA 回退返回 index.html,
前端 JSON 解析报 "Unexpected token <"。

修复:build_url 函数确保所有相对路径统一走 /api/v1,
已含前缀或绝对 URL 保持不变。

首页加载比赛恢复正常。
2026-09-22 10:44:12 +08:00
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
59 changed files with 1766 additions and 341 deletions
+3
View File
@@ -3,6 +3,9 @@
# production 启动时会强制校验:SECRET_KEY 非空且非弱值、鉴权已配置、DB 弱密码阻断。
APP_ENV=development
LOG_LEVEL=INFO
# 日志持久化:空=仅 stdout + Admin 内存日志页(重启清零);填路径则额外写滚动文件(10MB×5)。
# 本地开发示例: LOG_FILE=./logs/app.log (容器内由 compose 默认设为 /app/logs/app.log 并挂卷)
LOG_FILE=
API_PORT=8000
FRONTEND_PORT=3000
+1
View File
@@ -5,6 +5,7 @@ __pycache__/
.pytest_cache/
frontend/node_modules/
frontend/dist/
logs/
# AI 助手上下文文件(不入库)
CLAUDE.md
+1 -1
View File
@@ -203,7 +203,7 @@ Profeto/
│ │ ├── config.py # pydantic-settings 配置
│ │ ├── crypto.py # 加密/哈希
│ │ ├── http_client.py # 共享 httpx 客户端
│ │ ├── log_buffer.py # 内存日志缓冲(admin 日志页)
│ │ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选文件持久化(LOG_FILE)
│ │ ├── runtime_config.py # DB 配置覆盖(.env → app_settings)
│ │ ├── scheduler.py # 进程内 cron 调度器
│ │ └── security_check.py # 启动安全校验
@@ -30,7 +30,7 @@ def upgrade() -> None:
'matches',
['source_event_id'],
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)
op.drop_constraint('uq_standings_league_season_team', 'standings', type_='unique')
op.drop_index('ix_standings_leason_season_pos', table_name='standings')
op.create_index('ix_standings_league_season_pos', 'standings', ['league_id', 'season', 'position'])
# 原始索引名拼写为 leason(历史遗留),按实际库名删除
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(
'uq_standings_league_season_team_available', 'standings',
['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'],
)
+6 -2
View File
@@ -28,13 +28,15 @@ services:
# Fix 2: 容器内 DATABASE_URL 使用 postgres 服务名(非 localhost)
# 必须覆盖 .env 中的 DATABASE_URL,因为 Settings 不读 DB_HOST/DB_PORT
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:?POSTGRES_USER 未设置}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD 未设置}@postgres:5432/${POSTGRES_DB:-football}
# 日志持久化:写入挂载卷,容器重建不丢(应用内滚动 10MB×6 份封顶)
LOG_FILE: /app/logs/app.log
env_file: .env
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
timeout: 5s
retries: 3
start_period: 10s
start_period: 15s
depends_on:
postgres:
condition: service_healthy
@@ -42,6 +44,7 @@ services:
- ./src:/app/src
- ./alembic:/app/alembic
- ./alembic.ini:/app/alembic.ini
- applogs:/app/logs
frontend:
# Fix 4: 多阶段构建 —— 先 build 静态文件,再复制到 nginx
@@ -57,3 +60,4 @@ services:
volumes:
pgdata:
applogs:
+1
View File
@@ -86,6 +86,7 @@ cd frontend && npm install && npm run dev
|---|---|---|---|
| `APP_ENV` | ❌ | `development` | `production` / `development` |
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
| `LOG_FILE` | ❌ | (空) | 日志持久化文件路径;空=仅 stdout + Admin 内存日志页(重启清零)。compose 已默认设为 `/app/logs/app.log` 并挂 `applogs` 卷,滚动上限约 10MB×6 份 |
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
+1 -1
View File
@@ -117,7 +117,7 @@ Profeto/
│ ├── config.py # pydantic-settings 配置
│ ├── http_client.py # 共享 httpx 客户端
│ ├── crypto.py # 对称加密(Fernet)与密码哈希
│ ├── log_buffer.py # 内存日志缓冲(admin「系统日志」页)
│ ├── log_buffer.py # 日志基础设施:内存环形缓冲(admin 日志页) + 可选滚动文件持久化(LOG_FILE)
│ ├── runtime_config.py # 运行时配置(数据库优先,回落 .env)
│ ├── scheduler.py # 定时任务调度器(cron 触发采集)
│ └── security_check.py # 生产启动安全校验(缺配置拒绝启动)
+9 -1
View File
@@ -17,6 +17,14 @@ export const UNAUTHORIZED_EVENT = 'profeto:unauthorized'
const API_BASE = '/api/v1'
const DEFAULT_TIMEOUT = 30_000
/** 所有 API 路径统一走 /api/v1,避免浏览器直接请求 /matches 被 nginx 当 SPA 回退 */
function build_url(path: string): string {
if (path.startsWith('http')) return path
if (path.startsWith(API_BASE)) return path
if (path.startsWith('/')) return `${API_BASE}${path}`
return `${API_BASE}/${path}`
}
export class ApiError extends Error {
constructor(
message: string,
@@ -37,7 +45,7 @@ interface RequestOptions {
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = path.startsWith('http') ? path : path.startsWith('/') ? path : `${API_BASE}${path}`
const url = build_url(path)
const { timeoutMs = DEFAULT_TIMEOUT, skipAuthHandling, signal } = options
const controller = new AbortController()
@@ -4,8 +4,8 @@
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import { useState } from 'react'
import type { AgentReport, Prediction } from '../../types'
import { AGENT_LABELS, CN_NUM } from '../../types'
import type { AgentReport, Prediction } from '../types'
import { AGENT_LABELS, CN_NUM } from '../types'
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
ok: { label: '正常', cls: 'text-ink-500' },
@@ -27,7 +27,7 @@ function Spinner({ className = '' }: { className?: string }) {
}
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
.**
/**
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
@@ -3,9 +3,9 @@
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import TeamSideTag from '../../../../components/TeamSideTag'
import type { Match, Prediction } from '../../types'
import { OUTCOME_LABEL } from '../../types'
import TeamSideTag from '../../../components/TeamSideTag'
import type { Match, Prediction } from '../types'
import { OUTCOME_LABEL } from '../types'
/** 置信度细线:0~1 数值的低调可视化 */
function Meter({ value }: { value: number }) {
@@ -3,7 +3,7 @@
*
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
*/
import type { Prediction } from '../../types'
import type { Prediction } from '../types'
export function ReasoningPanel({ prediction }: { prediction: Prediction }) {
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
@@ -11,6 +11,8 @@ import { useRef, useState } from 'react'
import { http } from '../../../lib/http'
import type { Match, Prediction } from '../types'
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
/** 把后端/网络错误翻译成用户可读文案 */
function readablePredictError(e: unknown): string {
if (e instanceof Error) {
@@ -59,17 +61,43 @@ export function useMatchPredict({ onError }: UseMatchPredictOptions) {
onError(null)
setPrediction(null)
setPredictionFor(m)
// LLM 多专家预测耗时可达数分钟,给足超时(与 nginx 代理 300s 对齐)
// P1-async: 预测改为异步,POST 立即返回 job_id,轮询结果避免网关超时(Cloudflare 100s → 524)
const controller = new AbortController()
predictAbort.current = controller
const timer = setTimeout(() => controller.abort(), 300_000)
const overallTimer = setTimeout(() => controller.abort(), 300_000)
try {
const data = await http.post<Prediction>('/predict', { match_id: m.id, mode: 'multi' }, {
timeoutMs: 300_000,
signal: controller.signal,
})
// 1) 发起预测,拿到 job_id
const started = await http.post<{ job_id: string; poll_url: string }>(
'/predict',
{ match_id: m.id, mode: 'multi' },
{ timeoutMs: 10_000, signal: controller.signal },
)
if (seq !== predictSeq.current) return
setPrediction(data)
// 2) 轮询直到终态(success/failed)或整体超时
const deadline = Date.now() + 300_000
while (Date.now() < deadline) {
if (controller.signal.aborted) throw new DOMException('aborted', 'AbortError')
await sleep(3000)
const job = await http.get<{ status: string; result?: Prediction; error?: string }>(
`/predict/jobs/${started.job_id}`,
{ timeoutMs: 5000, signal: controller.signal },
)
if (seq !== predictSeq.current) return
if (job.status === 'success') {
setPrediction(job.result ?? null)
return
}
if (job.status === 'failed') {
onError(job.error || '预测失败')
return
}
// status === 'running' → 继续轮询
}
// 整体超时
onError('预测超时(5 分钟),请稍后重试')
} catch (e) {
if (seq !== predictSeq.current) return
onError(
@@ -78,7 +106,7 @@ export function useMatchPredict({ onError }: UseMatchPredictOptions) {
: readablePredictError(e),
)
} finally {
clearTimeout(timer)
clearTimeout(overallTimer)
if (seq === predictSeq.current) setPredictingId(null)
}
}
+2 -1
View File
@@ -16,5 +16,6 @@
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.spec.ts", "node_modules"]
}
+32 -2
View File
@@ -14,6 +14,35 @@ from src.core.config import settings
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
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
from src.db.base import init_db
@@ -30,6 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await migrate_plaintext_sensitive_settings() # 明文敏感配置 → 加密(幂等)
await ensure_admin_password_hashed() # .env 明文密码 → scrypt 哈希(幂等)
await assert_security_on_startup() # 启动安全校验(生产拒绝/开发警告)
await _fail_stale_ingest_jobs() # P1-E: 上次遗留的 pending/running 标 failed
# D7(工程债): 进程内限流(_RateLimiter)与 KeyRing 均为单进程状态;
# 多 worker 部署时各进程独立计数,限流阈值会按 worker 数放大、KeyRing 不共享。
@@ -94,8 +124,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
def create_app() -> FastAPI:
from src.core.log_buffer import setup_memory_logging
setup_memory_logging(settings.LOG_LEVEL)
from src.core.log_buffer import setup_logging
setup_logging(settings.LOG_LEVEL, settings.LOG_FILE)
# 生产环境不暴露 OpenAPI 文档(避免向访客泄露接口结构)
openapi_url = "/openapi.json" if settings.APP_ENV != "production" else None
+14 -7
View File
@@ -33,6 +33,19 @@ _background_tasks: set[asyncio.Task] = set()
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:
"""启动后台采集任务;异常已在任务内记录到系统日志。"""
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],
date_from=req.date_from, date_to=req.date_to, status=st,
)
merged["total_inserted"] += r.get("total_inserted", 0)
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", []))
_accumulate_ingest_result(merged, code, r)
logger.info(
"bzzoiro 比赛采集完成: 新增 %d, 更新 %d, 联赛 %d 个, 状态 %s",
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"])
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:
"""把 MatchStats ORM 对象序列化为前端可读的扁平 dict。"""
if stats is None:
@@ -65,26 +79,21 @@ async def list_matches(
)
if cursor:
try:
# 用 | 分隔,避免 isoformat 含 _ 时解析失败
last_date_str, last_id_str = cursor.split("|", 1)
last_date = datetime.fromisoformat(last_date_str)
last_id = int(last_id_str)
# 游标方向必须与排序方向一致:
# - scheduled(ASC):取「更大」的未开赛场次
# - 其它(DESC):取「更小」的已赛场次
if status == "scheduled":
q = q.where(
(Match.match_date > last_date) |
((Match.match_date == last_date) & (Match.id > last_id))
)
else:
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
except (ValueError, AttributeError):
pass
# P1-B: 解析非法 → 400 + code=INVALID_CURSOR,而非静默忽略
last_date, last_id = _parse_cursor(cursor)
# 游标方向必须与排序方向一致:
# - scheduled(ASC):取「更大」的未开赛场次
# - 其它(DESC):取「更小」的已赛场次
if status == "scheduled":
q = q.where(
(Match.match_date > last_date) |
((Match.match_date == last_date) & (Match.id > last_id))
)
else:
q = q.where(
(Match.match_date < last_date) |
((Match.match_date == last_date) & (Match.id < last_id))
)
if 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()
if m is None:
raise HTTPException(404, "match not found")
# 最近预测(倒序,最多 5 条)——复用 PredictionOut 结构,只读,不触发 LLM
# P1-C: 公开预测仅 run_type=live 且 status=success(屏蔽回测/失败预测)
preds = (
await db.execute(
select(Prediction)
.where(Prediction.match_id == match_id)
.where(Prediction.run_type == "live")
.where(Prediction.status == "success")
.order_by(Prediction.created_at.desc())
.limit(5)
)
).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(
id=m.id,
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,
away_xg=m.stats.away_xg if m.stats else None,
stats=_stats_dict(m.stats) if m.stats else None,
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
],
recent_predictions=recent_predictions,
)
+71 -61
View File
@@ -2,11 +2,15 @@
安全改进:
- 限流: 每分钟 10 次 / IP(内存实现)
- P1-D: 全局 LLM 并发限制(默认 4),防止过多并发 LLM 调用压垮服务
- DB 连接: 短 session 模式,LLM 调用期间不持有连接
- P1-async: 预测改为异步(后台任务 + 轮询),避免网关超时(Cloudflare 100s)
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
@@ -22,19 +26,65 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["predict"])
# P1-D: 全局 LLM 并发限制。与 orchestrator 内的 match 级 Semaphore(8) 并存,
# 此处在路由层限制单实例全 LLM 调用(所有模式汇总),默认 4。
_GLOBAL_LLM_SEMAPHORE = asyncio.Semaphore(4)
@router.post("/predict", response_model=PredictOut, dependencies=[Depends(rate_limit_predict)])
# P1-async: 预测任务内存存储(job_id → 结果/异常)。单进程部署足够,无需入库。
_predict_jobs: dict[str, dict] = {}
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,
)
async def _run_predict_async(job_id: str, req: PredictRequest) -> None:
"""P1-async: 后台执行预测,结果写入 _predict_jobs。"""
try:
result = await _predict_with_concurrency(req)
_predict_jobs[job_id] = {
"status": "success",
"result": {
"prediction_id": result.prediction_id,
"provider": result.provider,
"model": result.model,
"prompt_version": result.prompt_version,
"mode": req.mode,
"pred_home_goals": result.pred_home_goals,
"pred_away_goals": result.pred_away_goals,
"alt_pred_home_goals": result.alt_pred_home_goals,
"alt_pred_away_goals": result.alt_pred_away_goals,
"pred_1x2": result.pred_1x2,
"subjective_confidence": result.subjective_confidence,
"reasoning": result.reasoning,
"status": result.status,
"agent_outputs": result.agent_outputs,
"agent_weights": result.agent_weights,
"context": result.context,
"latency_ms": result.latency_ms,
},
}
except Exception as e:
logger.exception("predict job %s failed", job_id)
_predict_jobs[job_id] = {"status": "failed", "error": str(e)}
@router.post("/predict", dependencies=[Depends(rate_limit_predict)])
async def predict(req: PredictRequest, request: Request):
"""对一场比赛调 LLM 预测。mode=multi(默认,5专家+终裁)、single 或 baseline。
"""对一场比赛调 LLM 预测(异步)。mode=multi(默认,5专家+终裁)、single 或 baseline。
公开接口,仅做限流保护(不要求登录)。
DB 连接优化:
1. 短 read session 检查比赛存在性/状态
2. 释放连接后调用 LLM(可能几十秒)
3. 短 write session 保存 Prediction
P1-async: 立即返回 job_id,预测在后台执行,前端轮询 GET /predict/jobs/{job_id}
避免多专家预测耗时 60-180s 触发网关超时(Cloudflare 100s → HTTP 524)。
"""
# 1. 短 read session: 检查比赛(连接立即释放)
# 1. 短 read session: 检查比赛存在性/状态
async with short_read() as session:
m = await session.get(Match, req.match_id)
if m is None:
@@ -42,61 +92,21 @@ async def predict(req: PredictRequest, request: Request):
if m.match_status == "finished":
raise HTTPException(400, "该比赛已完赛,不再支持预测")
# 2. 预测调用(不持有任何 DB 连接)
try:
result = await predict_match(
req.match_id,
model=req.model,
prompt_version=req.prompt_version,
mode=req.mode,
)
except ValueError as e:
msg = str(e)
if "已结算" in msg:
raise HTTPException(409, msg)
logger.warning("predict validation error: %s", e)
raise HTTPException(404, "比赛不存在")
except RuntimeError as e:
logger.error("predict LLM error: %s", e)
raise HTTPException(502, "LLM 预测失败,请查看服务器日志")
except Exception as e:
logger.exception("predict unexpected error")
raise HTTPException(500, "预测失败,请查看服务器日志")
# 2. P1-async: 启动后台任务,立即返回 job_id
job_id = str(uuid.uuid4())
_predict_jobs[job_id] = {"status": "running"}
asyncio.create_task(_run_predict_async(job_id, req))
logger.info("predict job started: %s match=%s mode=%s", job_id, req.match_id, req.mode)
return {"job_id": job_id, "status": "running", "poll_url": f"/api/v1/predict/jobs/{job_id}"}
# D2: 三种模式统一返回 PredictResult —— 字段映射单一化,无 dict 分支。
# P3-2:baseline 已在服务层(predict_baseline)落库并回填真实 prediction_id,
# 路由层不再需要特殊的 _persist_baseline,与 single/multi 路径统一。
prediction_id = result.prediction_id
# 3. 结果映射(无 DB 访问)
logger.info(
"预测完成 match=%s mode=%s pred=%s:%s (%s)",
req.match_id, req.mode,
result.pred_home_goals, result.pred_away_goals, result.pred_1x2,
)
return PredictOut(
prediction_id=prediction_id,
provider=result.provider,
model=result.model,
prompt_version=result.prompt_version,
mode=req.mode,
pred_home_goals=result.pred_home_goals,
pred_away_goals=result.pred_away_goals,
alt_pred_home_goals=result.alt_pred_home_goals,
alt_pred_away_goals=result.alt_pred_away_goals,
pred_1x2=result.pred_1x2,
subjective_confidence=result.subjective_confidence,
reasoning=result.reasoning,
status=result.status,
agent_outputs=result.agent_outputs,
agent_weights=result.agent_weights,
context=result.context,
latency_ms=result.latency_ms,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
rate_limit_remaining=get_predict_rate_limit_remaining(request),
)
@router.get("/predict/jobs/{job_id}")
async def get_predict_job(job_id: str):
"""P1-async: 轮询预测任务状态。"""
job = _predict_jobs.get(job_id)
if job is None:
raise HTTPException(404, f"预测任务不存在: {job_id}")
return job
@router.get("/predictions", response_model=list[PredictionOut], dependencies=[Depends(require_admin)])
+3 -3
View File
@@ -24,8 +24,8 @@ class MatchOut(BaseModel):
away_xg: float | None = None
# 比赛详细统计(bzzoiro /events/{id}/stats/),无统计为 None
stats: dict | None = None
# 该场比赛的最近预测摘要(按时间倒序,最多 5 条;无预测为空)
recent_predictions: list[PredictionOut] = []
# P1-C: 公开接口的预测不含 reasoning/agent_outputs;仅 live+success 路由已过滤
recent_predictions: list[dict] = []
class MatchListOut(BaseModel):
@@ -36,7 +36,7 @@ class MatchListOut(BaseModel):
class PredictRequest(BaseModel):
match_id: int
provider: str | None = None
# P1-D: 删除未接线的 provider 字段(符合"名不副实则删除");provider 由服务端配置决定。
model: str | None = None
prompt_version: str | None = None
mode: str = Field(
+4
View File
@@ -11,6 +11,10 @@ class Settings(BaseSettings):
# --- app ---
APP_ENV: str = "development"
LOG_LEVEL: str = "INFO"
# 日志持久化:空(默认)只输出 stdout + Admin 内存日志页(重启清零)。
# 填文件路径(如 /app/logs/app.log)后额外写入滚动文件(单文件 10MB × 5 份),
# 进程/容器重启不丢。容器部署需配合 volume 挂载该目录,否则重建仍会丢。
LOG_FILE: str = ""
# P3-3:多 worker 时应用内限流与 KeyRing 各自独立计数(配额放大 N 倍)。
# 设为 True 时若以多 worker 启动 uvicorn 则拒绝启动,避免静默配额漂移。
# 仅在你已前置 Nginx/网关做全局限流、确认不需要此守护时留空/False。
+48 -8
View File
@@ -65,14 +65,54 @@ def get_entries(
return out
def setup_memory_logging(level: str = "INFO") -> None:
"""挂载内存 handler 到 root logger(幂等),并确保 root 级别不低于 INFO。"""
def setup_logging(level: str = "INFO", log_file: str = "") -> None:
"""配置应用日志:stdout(容器收集) + 内存环形缓冲(Admin 日志页) + 可选滚动文件(持久化)。
幂等:重复调用不会重复挂 handler(文件 handler 按 abspath 判重,相对/绝对
路径指向同一文件视为同一个)。文件写入基础设施失败只记 warning,绝不
影响启动与业务 —— 与 _safe_write_ingest_failure 同级约束:可观测性
基础设施不许拖垮主流程。
"""
import os
from logging.handlers import RotatingFileHandler
root = logging.getLogger()
if any(isinstance(h, MemoryLogHandler) for h in root.handlers):
return
handler = MemoryLogHandler()
handler.setLevel(logging.INFO)
handler.addFilter(_SQLNoiseFilter())
root.addHandler(handler)
if root.level == logging.NOTSET or root.level > logging.INFO:
root.setLevel(getattr(logging, level.upper(), logging.INFO))
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
handler = MemoryLogHandler()
handler.setLevel(logging.INFO)
handler.addFilter(_SQLNoiseFilter())
root.addHandler(handler)
if not log_file:
return
# 滚动上限:单文件 10MB × 当前+5 份 ≈ 60MB,足够回溯数周的关键事件,
# 又不会吃满磁盘。格式含时间/级别/logger 名,便于事后 grep 排查。
target = os.path.abspath(log_file)
if any(
isinstance(h, RotatingFileHandler) and getattr(h, "baseFilename", None) == target
for h in root.handlers
):
return
try:
os.makedirs(os.path.dirname(target), exist_ok=True)
file_handler = RotatingFileHandler(
target,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
)
file_handler.setLevel(logging.INFO)
file_handler.addFilter(_SQLNoiseFilter())
root.addHandler(file_handler)
logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
except Exception:
logging.getLogger(__name__).warning(
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,
)
+46 -20
View File
@@ -104,17 +104,26 @@ async def get_runtime_value(key: str) -> str:
"""读运行时配置:DB 覆盖值 → .env 默认值 → 空串。
敏感项入库时是密文,读出后自动解密;旧明文(迁移前) decrypt_value 透传
P1-G:DB 故障回落 env;但解密失败在生产环境必须抛出(不得与 DB 异常共用 except)
"""
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:
value = crypto.decrypt_value(row.value) if defn and defn.sensitive else row.value
if value:
return value
db_value = row.value
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 ""
@@ -133,8 +142,40 @@ async def set_runtime_value(key: str, value: str) -> None:
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:
"""除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
"""除 DB 覆盖值,回落 .env(调用方需先校验 key 在白名单内)。"""
async with AsyncSessionLocal() as db:
row = await db.get(AppSetting, key)
if row is not None:
@@ -143,21 +184,6 @@ async def clear_runtime_value(key: str) -> None:
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:
"""一次性迁移:把库中仍是明文的敏感项加密(幂等,启动时执行)。
+1 -1
View File
@@ -144,7 +144,7 @@ class DataQualityScheduler:
.select_from(Match)
.outerjoin(MatchStats, Match.id == MatchStats.match_id)
.where(Match.match_status == "finished")
.where(MatchStats.id.is_(None))
.where(MatchStats.match_id.is_(None))
)
).scalar() or 0
+6 -6
View File
@@ -19,6 +19,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
column,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
@@ -289,14 +290,13 @@ class Prediction(Base):
match: Mapped[Match] = relationship(back_populates="predictions")
__table_args__ = (
# Fix: 唯一约束增加 mode + run_type,允许 live 与 backtest 共存
# 防止回测覆盖未结算的实盘预测(后续 settle 会污染评估数据)
UniqueConstraint(
"match_id", "provider", "model", "mode", "run_type",
name="uq_predictions_match_provider_model_mode_run_type",
# P0-03: 幂等指纹——input_hash 非空时唯一(同指纹→返回已有行,不 UPDATE/INSERT);
# 兼容旧数据 NULL input_hash(不强制回填)。
Index(
"ix_predictions_input_hash_unique", "input_hash", unique=True,
postgresql_where=column("input_hash").isnot(None),
),
Index("ix_predictions_match", "match_id"),
Index("ix_predictions_provider_model", "provider", "model"),
# 数据截止时间过滤查询用(按 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.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.dialects.postgresql import insert as pg_insert
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)
return team
# 3) 新建 Team(归一名)
# 3) 新建 Team(归一名)。P1-K: 用 PG UPSERT 防并发重复插入。
logger.info("创建新 Team: %s -> %s", name, normalized)
team = Team(name=normalized, name_zh=name_zh)
self._session.add(team)
stmt = pg_insert(Team).values(name=normalized, name_zh=name_zh)
stmt = stmt.on_conflict_do_nothing(index_elements=["name"])
await self._session.execute(stmt)
await self._session.flush()
return team
# 无论是否本次插入,都拿到行(并发时可能已由对方创建)
return await self.get_by_name(normalized)
async def add_alias(self, alias: str, team_id: int) -> TeamAlias:
"""为已有 Team 添加别名。
@@ -205,12 +208,15 @@ class LeagueRepository:
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:
"""按代码获取联赛,不存在则创建。P1-K: 创建用 PG UPSERT 防并发重复。"""
league = await self.get_by_code(code)
if league is None:
league = League(code=code, name=name, country=country)
self._session.add(league)
await self._session.flush()
return league
if league is not None:
return league
stmt = pg_insert(League).values(code=code, name=name, country=country)
stmt = stmt.on_conflict_do_nothing(index_elements=["code"])
await self._session.execute(stmt)
await self._session.flush()
return await self.get_by_code(code)
async def add(self, league: League) -> None:
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.models import Match, Prediction
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.context_builder import (
MatchHeader,
@@ -285,10 +285,13 @@ async def predict_match_multi(
latency_ms = int((time.perf_counter() - start) * 1000)
# 3.5 计算输入 hash(基于终裁报告)
input_hash = hashlib.sha256(
_reports_to_json(reports).encode("utf-8")
).hexdigest()
# P0-03: 指纹输入——终裁报告 hash 作 context_hash,专家列表作 agent_ids
reports_json = _reports_to_json(reports)
context_hash = hashlib.sha256(reports_json.encode("utf-8")).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)
async with get_uow() as session:
@@ -315,15 +318,20 @@ async def predict_match_multi(
pred_status = "degraded"
model_name = aggregator_model
pred = await _upsert_prediction(
pred = await _insert_or_find_by_fingerprint(
session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=model_name,
mode="multi",
run_type="backtest" if backtest else "live",
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_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,
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
"latency_ms": latency_ms,
@@ -341,7 +349,6 @@ async def predict_match_multi(
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
+20 -12
View File
@@ -5,14 +5,15 @@
"""
from __future__ import annotations
import hashlib
import logging
from datetime import datetime
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from src.db.base import AsyncSession, AsyncSessionLocal
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__)
@@ -97,8 +98,23 @@ async def predict_baseline(
else:
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 = {
"match_id": match_id,
"provider": "baseline",
"model": "baseline",
"mode": "baseline",
"run_type": "live",
"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,
"completion_tokens": 0,
"latency_ms": 0,
@@ -114,17 +130,9 @@ async def predict_baseline(
"status": "success",
}
# P3-2:服务层落库,回填真实 prediction_id(与 single/multi 统一)。
# P0-03:服务层幂等插入,回填真实 prediction_id(与 single/multi 统一)。
async with get_uow() as session:
pred = await _upsert_prediction(
session,
match_id=match_id,
provider_name="baseline",
model="baseline",
mode="baseline",
run_type="live",
values=values,
)
pred = await _insert_or_find_by_fingerprint(session, values=values)
prediction_id = pred.id
return PredictResult(
+20 -4
View File
@@ -44,9 +44,17 @@ def _build_filters(
prompt_version: str | None = None,
mode: str | None = None,
league_code: str | None = None,
run_type: str | None = "live",
season: str | None = None,
) -> list:
"""构建评估筛选条件(参数化列明,防拼接注入)。"""
"""构建评估筛选条件(参数化列明,防拼接注入)。
P1-I: 默认仅 run_type=live(排除回测污染),可显式改 "backtest"/None
"""
filters = [Prediction.settled == True]
# P1-I: 默认仅统计实盘预测(排除回测),除非显式指定
if run_type is not None:
filters.append(Prediction.run_type == run_type)
if provider:
filters.append(Prediction.provider == provider)
if model:
@@ -55,6 +63,10 @@ def _build_filters(
filters.append(Prediction.prompt_version == prompt_version)
if 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:
league_subq = select(League.id).where(League.code == league_code).scalar_subquery()
filters.append(Prediction.match_id.in_(
@@ -64,17 +76,21 @@ def _build_filters(
async def get_eval_summary(
limit: int = 1000,
limit: int | None = None,
*,
provider: str | None = None,
model: str | None = None,
prompt_version: str | None = None,
mode: str | None = None,
league_code: str | None = None,
run_type: str | None = "live",
season: str | None = None,
) -> dict:
"""按 provider × 模型聚合评估。
P3-4: 默认限制评估最近 1000 条已结算预测,避免全表加载导致内存压力
P1-I: 默认 limit=None(返回全集,不冒充 1000 条为全集);调用方可显式 limit 采样
默认仅 run_type=live(排除回测污染),可按需改 "backtest"
可选 season 过滤
只统计有效预测:
- settled == True
@@ -82,7 +98,7 @@ async def get_eval_summary(
- 预测比分字段齐全
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:
total_settled = (await session.execute(
+80 -50
View File
@@ -4,9 +4,9 @@
| 模式 | 落库位置(服务层) | 路由层(routes/predict.py) |
|-----------|-------------------------------------------------------------|---------------------------|
| single | `_predict_single` `_upsert_prediction` | 不读 DB,仅映射 result PredictOut |
| multi | `orchestrator.predict_match_multi` `_upsert_prediction` | 不读 DB,仅映射 result PredictOut |
| baseline | `predict_baseline` `_upsert_prediction` | 不读 DB,仅映射 result PredictOut |
| single | `_predict_single` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
| multi | `orchestrator.predict_match_multi` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
| baseline | `predict_baseline` `_insert_or_find_by_fingerprint` | 不读 DB,仅映射 result PredictOut |
三种模式统一在服务层经 UnitOfWork 落库并回填真实 prediction_id;
路由层永不写入 predictions,只读 result.prediction_id 做响应映射
@@ -126,12 +126,14 @@ class _RedisCache(_CacheBackend):
if not await self._ensure_conn():
return self._memory_fallback.get(key)
try:
import pickle
import json
raw = await self._redis.get(key) # type: ignore[union-attr]
if raw is 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:
logger.warning("predict cache: Redis GET 失败(%s),跳过缓存", e)
return None
@@ -141,10 +143,15 @@ class _RedisCache(_CacheBackend):
await self._memory_fallback.set(key, result, ttl)
return
try:
import pickle
import json
from dataclasses import asdict
payload = pickle.dumps(result).decode("latin-1")
await self._redis.set(key, payload, ex=ttl) # type: ignore[union-attr]
payload = asdict(result)
# 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:
logger.warning("predict cache: Redis SET 失败(%s),降级内存写入", e)
await self._memory_fallback.set(key, result, ttl)
@@ -220,44 +227,60 @@ class PredictResult:
raw: dict | None = None
async def _upsert_prediction(
session,
*,
match_id: int,
provider_name: str,
model: str,
mode: str,
run_type: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model, mode, run_type) 唯一约束写入预测。
def _compute_fingerprint(values: dict) -> str:
"""P0-03: 预测指纹(规范 JSON 的 SHA-256)。
已存在且未结算 覆盖更新(重新预测语义);已结算 拒绝(保护评估数据)
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 = (
await session.execute(
select(Prediction).where(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
Prediction.mode == mode,
Prediction.run_type == run_type,
)
select(Prediction).where(Prediction.input_hash == fingerprint)
)
).scalar_one_or_none()
if existing is not None and existing.settled:
raise ValueError("该比赛已有已结算的预测,不能重新预测")
if existing is not None:
# 同指纹 → 直接返回,绝不覆盖 pred_* / reasoning / agent_outputs
return existing
pred = existing if existing is not None else Prediction(
match_id=match_id, provider=provider_name, model=model,
)
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)
pred = Prediction(**{k: v for k, v in values.items() if hasattr(Prediction, k)})
session.add(pred)
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
return pred
@@ -342,23 +365,26 @@ async def _predict_single(
# 1. 拼上下文(backtest/cutoff 防泄漏)
ctx = await build_context(match_id, backtest=backtest, cutoff_at=cutoff_at)
# 1.5 计算快照元数据(用于可复现性)
# 1.5 计算快照元数据(用于可复现性 + P0-03 指纹)
now = datetime.now(timezone.utc)
match_kickoff_at = ctx.match_dt
# 使用上下文实际计算的 cutoff(回测时可能为 match_dt-1天),而非开球时间
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(指定版本)
template = _load_prompt_template(version)
prompt_hash = _prompt_template_hash(version)
user_prompt = template.replace("{{context}}", ctx.text)
system_prompt = "你是一个严谨的足球预测专家。只输出 JSON。"
context_hash = hashlib.sha256(ctx.text.encode("utf-8")).hexdigest()
# 3. 调 LLM
temperature = 0.3
resp = await provider.chat(
system="你是一个严谨的足球预测专家。只输出 JSON。",
system=system_prompt,
user=user_prompt,
json_mode=True,
temperature=0.3,
temperature=temperature,
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
)
@@ -385,15 +411,20 @@ async def _predict_single(
if m is None:
raise ValueError(f"match {match_id} not found")
pred = await _upsert_prediction(
pred = await _insert_or_find_by_fingerprint(
session,
match_id=match_id,
provider_name=settings.LLM_PROVIDER,
model=provider.model,
mode="single",
run_type="backtest" if backtest else "live",
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_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,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
@@ -409,7 +440,6 @@ async def _predict_single(
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
+4 -4
View File
@@ -8,8 +8,8 @@
你的任务: 综合权衡各报告,输出最终预测。
裁决规则:
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
- 各报告的 subjective_confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
- 5 位专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 联赛排名分析专家 / 历史交锋分析专家
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
@@ -23,8 +23,8 @@
"alt_pred_home_goals": <int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>,
"alt_pred_away_goals": <int 0-10, 备选比分客队进球>,
"1x2": "<'1'|'X'|'2'>",
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"reasoning": "<250 字内推理,引用各报告证据>",
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "injuries": <0-1>, "h2h": <0-1>}
"agent_weights": {"form": <0-1>, "stats": <0-1>, "home_away": <0-1>, "standings": <0-1>, "h2h": <0-1>}
}
```
+1 -1
View File
@@ -15,7 +15,7 @@
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,含关键事件与走势判断>",
"home_edge": <-1.0 到 1.0, 正数=主队状态更好/走势更向上>,
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```
+1 -1
View File
@@ -16,7 +16,7 @@
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,提取交手规律>",
"home_edge": <-1.0 到 1.0, 正数=主队交锋占优>,
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```
+1 -1
View File
@@ -15,7 +15,7 @@
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,量化主客因素影响>",
"home_edge": <-1.0 到 1.0, 正数=主场优势明显>,
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```
+1 -1
View File
@@ -17,7 +17,7 @@
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,量化两队实力差距>",
"home_edge": <-1.0 到 1.0, 正数=主队实力占优>,
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```
+1 -1
View File
@@ -16,7 +16,7 @@
"data_sufficiency": "high|medium|low|none",
"analysis": "<150 字内分析,量化攻防强度>",
"home_edge": <-1.0 到 1.0, 正数=主队攻防占优>,
"confidence": <0.0-1.0>,
"subjective_confidence": <0.0-1.0>,
"key_evidence": ["<证据1>", "<证据2>"]
}
```
+1 -1
View File
@@ -10,7 +10,7 @@
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"subjective_confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
"reasoning": "<200 字内推理>"
}
+1 -1
View File
@@ -15,7 +15,7 @@
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"subjective_confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
"reasoning": "<200 字内推理,需引用具体数据>"
}
+1 -1
View File
@@ -59,7 +59,7 @@ async def standings_slice(header: MatchHeader, *, before=None, db: AsyncSession
n_records = 0
def _fmt(row) -> str:
zg = f" xG差 {row.xgd:+.1f}" if row.xg_for is not None and row.xg_against is not None and row.goal_diff is not None else ""
zg = f" xG差 {row.xg_for - row.xg_against:+.1f}" if row.xg_for is not None and row.xg_against is not None else ""
form = f" 近5场 {row.form}" if row.form else ""
zone = f" [{row.zone}]" if row.zone else ""
return (
+6 -6
View File
@@ -61,7 +61,7 @@ class TestOrchestratorWritesAgentWeights:
@pytest.mark.asyncio
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.base import AgentReport
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},
}, 100, 50
async def mock_upsert(session, **kw):
captured_values.update(kw.get("values", {}))
async def mock_upsert(session, *, values):
captured_values.update(values)
p = MagicMock()
p.id = 1
p.provider = "test"
@@ -116,7 +116,7 @@ class TestOrchestratorWritesAgentWeights:
p.subjective_confidence = 0.7
p.reasoning = "test"
p.agent_outputs = []
p.agent_weights = kw["values"].get("agent_weights")
p.agent_weights = values.get("agent_weights")
return p
class FakeUow:
@@ -130,14 +130,14 @@ class TestOrchestratorWritesAgentWeights:
with patch.object(orch_mod, "run_specialists", mock_specialists), \
patch.object(orch_mod, "_agent_provider", mock_provider), \
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, "get_uow", FakeUow):
result = await orch_mod.predict_match_multi(999)
# 断言 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 "form" in captured_values["agent_weights"], "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), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
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:
async def get(self, cls, mid):
return FakeMatch()
@@ -137,7 +137,7 @@ async def test_predict_baseline_clamps_to_range():
with patch("src.llm.baseline._avg_goals", fake_avg), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
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:
async def get(self, cls, mid):
return FakeMatch()
+34 -36
View File
@@ -62,7 +62,7 @@ async def test_predict_baseline_returns_predict_result():
async def __aexit__(self, *a):
return None
# P3-2:baseline 在服务层落库(get_uow + _upsert_prediction),需 mock 掉。
# P3-2:baseline 在服务层落库(get_uow + _insert_or_find_by_fingerprint),需 mock 掉。
class FakeUoW:
async def __aenter__(self):
return _make_session()
@@ -72,24 +72,24 @@ async def test_predict_baseline_returns_predict_result():
captured = {}
async def fake_upsert(session, **kw):
captured.update(kw)
async def fake_upsert(session, *, values):
captured.update(values)
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), \
patch("src.llm.baseline.AsyncSessionLocal") as SLC, \
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()
result = await predict_baseline(1)
# P3-2:验证服务层落库被调用且属性映射正确
assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline"
assert captured["provider"] == "baseline"
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 result.mode == "baseline"
@@ -225,8 +225,8 @@ async def test_baseline_service_persists_with_correct_attributes(monkeypatch):
"""P3-2:baseline 在服务层(predict_baseline)落库,属性映射与路由旧版一致。"""
captured = {}
async def fake_upsert(session, **kwargs):
captured.update(kwargs)
async def fake_upsert(session, *, values):
captured.update(values)
return SimpleNamespace(id=77)
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.AsyncSessionLocal", FakeSLC)
monkeypatch.setattr("src.db.unit_of_work.get_uow", lambda: _FakeUoW())
# baseline.py 模块级 import _upsert_prediction(第 15 行),需 patch baseline 模块属性
monkeypatch.setattr("src.llm.baseline._upsert_prediction", fake_upsert)
# baseline.py 模块级 import _insert_or_find_by_fingerprint(第 15 行),需 patch baseline 模块属性
monkeypatch.setattr("src.llm.baseline._insert_or_find_by_fingerprint", fake_upsert)
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["provider_name"] == "baseline"
assert captured["provider"] == "baseline"
assert captured["model"] == "baseline"
assert captured["mode"] == "baseline"
assert captured["run_type"] == "live"
v = captured["values"]
assert v["prompt_version"] == "baseline_v1"
assert v["pred_home_goals"] == 2.0
assert v["pred_away_goals"] == 1.0
assert v["pred_1x2"] == "1"
assert v["subjective_confidence"] == 0.5
assert v["prompt_tokens"] == 0
assert v["completion_tokens"] == 0
assert v["latency_ms"] == 0
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["status"] == "success"
assert captured["prompt_version"] == "baseline_v1"
assert captured["pred_home_goals"] == 2.0
assert captured["pred_away_goals"] == 1.0
assert captured["pred_1x2"] == "1"
assert captured["subjective_confidence"] == 0.5
assert captured["prompt_tokens"] == 0
assert captured["completion_tokens"] == 0
assert captured["latency_ms"] == 0
assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert captured["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77
assert result.pred_1x2 == "1"
assert captured["match_id"] == 1
assert captured["provider_name"] == "baseline"
assert captured["provider"] == "baseline"
assert captured["model"] == "baseline"
assert captured["mode"] == "baseline"
assert captured["run_type"] == "live"
v = captured["values"]
assert v["prompt_version"] == "baseline_v1"
assert v["pred_home_goals"] == 2.0
assert v["pred_away_goals"] == 1.0
assert v["pred_1x2"] == "1"
assert v["subjective_confidence"] == 0.5
assert v["prompt_tokens"] == 0
assert v["completion_tokens"] == 0
assert v["latency_ms"] == 0
assert v["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert v["status"] == "success"
assert captured["prompt_version"] == "baseline_v1"
assert captured["pred_home_goals"] == 2.0
assert captured["pred_away_goals"] == 1.0
assert captured["pred_1x2"] == "1"
assert captured["subjective_confidence"] == 0.5
assert captured["prompt_tokens"] == 0
assert captured["completion_tokens"] == 0
assert captured["latency_ms"] == 0
assert captured["raw_response"] == {"home_avg": 2.0, "away_avg": 1.0}
assert captured["status"] == "success"
# 回填真实 prediction_id(服务层落库后取得)
assert result.prediction_id == 77
+53 -2
View File
@@ -72,6 +72,7 @@ class _FakeDB:
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_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
self._aliases: dict[str, TeamAlias] = {}
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
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()
for d in (stmt.column_descriptions or []):
entities.add(d.get("entity") or d.get("type"))
@@ -94,6 +141,10 @@ class _FakeDB:
return _FakeResult(items)
return _FakeResult([])
@staticmethod
def _filter(entity, items, stmt):
return items
async def flush(self):
for obj in self.added:
if getattr(obj, "id", None) is None:
@@ -261,8 +312,8 @@ class TestEventsBronzeIsBestEffort:
raise RuntimeError("infra down")
# Bronze 写入助手直接 import 到 bzzoiro_events 命名空间,需 patch 该处
monkeypatch.setattr(bz_events, "_write_raw_event", _boom)
monkeypatch.setattr(bz_events, "_write_lineage", _boom)
monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
_patch_fetch(monkeypatch, [_event()])
db = _FakeDB()
+127
View File
@@ -0,0 +1,127 @@
"""日志持久化(LOG_FILE)测试:setup_logging 启用滚动文件后日志必须落盘。
背景: 此前应用日志只进 stdout(docker json-file 收集,不可控) + Admin 内存
日志页(环形缓冲 2000 ,进程重启清零),没有任何应用层持久化 排查
昨晚采集为什么失败这类问题时无据可查本测试守护:
1. log_file root logger RotatingFileHandler,日志写入文件
2. 幂等:重复调用不重复挂 handler
3. log_file 为空 不挂文件 handler(保持旧行为)
4. 目录不存在 自动创建
5. 文件打开失败(如路径是已存在的目录) 只降级不炸,stdout/内存日志仍在
范式: 直接操作 root logger + tmp_path;fixture 保存/恢复 root 状态,
并关闭新增 handler 的文件句柄(Windows 上句柄不关会锁住 tmp 目录)
"""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
import pytest
from src.core.log_buffer import MemoryLogHandler, setup_logging
@pytest.fixture(autouse=True)
def _restore_root_logger():
"""保存/恢复 root logger;关闭测试期间新挂 handler 的句柄。"""
root = logging.getLogger()
saved_handlers = list(root.handlers)
saved_level = root.level
yield
for h in root.handlers:
if h not in saved_handlers and hasattr(h, "close"):
try:
h.close()
except Exception:
pass
root.handlers[:] = saved_handlers
root.setLevel(saved_level)
def _file_handlers():
return [h for h in logging.getLogger().handlers if isinstance(h, RotatingFileHandler)]
def _memory_handlers():
return [h for h in logging.getLogger().handlers if isinstance(h, MemoryLogHandler)]
class TestFileHandlerAttached:
def test_attaches_rotating_file_handler(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
fhs = _file_handlers()
assert len(fhs) == 1
assert fhs[0].baseFilename == str(log_file)
# 内存日志页照常工作,两者并存
assert len(_memory_handlers()) == 1
# 滚动参数与文档口径一致: 单文件 10MB × 5 份
assert fhs[0].maxBytes == 10 * 1024 * 1024
assert fhs[0].backupCount == 5
def test_log_written_to_file(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
logging.getLogger("persist-test").info("hello-persist-12345")
content = log_file.read_text(encoding="utf-8")
assert "hello-persist-12345" in content
assert "persist-test" in content # logger 名可追溯
assert "INFO" in content # 级别在行首可过滤
class TestIdempotent:
def test_repeated_call_does_not_duplicate_handlers(self, tmp_path):
log_file = tmp_path / "app.log"
setup_logging("INFO", str(log_file))
setup_logging("INFO", str(log_file))
setup_logging("INFO", str(log_file))
assert len(_file_handlers()) == 1
assert len(_memory_handlers()) == 1
def test_same_file_via_relative_and_absolute_path_counts_as_one(self, tmp_path, monkeypatch):
"""相对/绝对路径指向同一文件时不得重复挂(幂等按 abspath 判重)。"""
monkeypatch.chdir(tmp_path)
setup_logging("INFO", "app.log")
setup_logging("INFO", str(tmp_path / "app.log"))
assert len(_file_handlers()) == 1
class TestDisabled:
def test_empty_log_file_keeps_old_behavior(self):
setup_logging("INFO", "")
assert _file_handlers() == []
assert len(_memory_handlers()) == 1
class TestAutoMkdir:
def test_creates_missing_directories(self, tmp_path):
log_file = tmp_path / "deep" / "nested" / "app.log"
setup_logging("INFO", str(log_file))
assert len(_file_handlers()) == 1
assert log_file.parent.is_dir()
class TestGracefulDegradation:
def test_unopenable_path_degrades_without_raising(self, tmp_path):
"""路径是已存在的目录 → 打开必然失败;只降级,不炸启动。"""
dir_as_file = tmp_path / "occupied"
dir_as_file.mkdir()
# 不应抛异常(文件日志是可观测性基础设施,失败只 warning)
setup_logging("INFO", str(dir_as_file))
assert _file_handlers() == []
# 降级后 stdout/内存日志路径仍在
assert len(_memory_handlers()) == 1
+8 -9
View File
@@ -77,7 +77,7 @@ class TestAllExpertsFailed:
async def mock_load_header(mid, db=None):
return header
# Mock _upsert_prediction — 捕获写入的 status
# Mock _insert_or_find_by_fingerprint — 捕获写入的 status
captured_status = {}
async def mock_upsert(session, **kw):
@@ -109,7 +109,7 @@ class TestAllExpertsFailed:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
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):
result = await orch_mod.predict_match_multi(999)
@@ -170,7 +170,7 @@ class TestAllExpertsFailed:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
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):
result = await orch_mod.predict_match_multi(999)
@@ -235,7 +235,7 @@ class TestPartialExpertsOk:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
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, "get_uow", FakeUow):
@@ -268,7 +268,7 @@ class TestNoAggregatorCallOnDegraded:
captured_values = {}
async def mock_upsert(session, **kw):
# model / provider_name / mode 是 _upsert_prediction 的顶层关键字参数,
# model / provider_name / mode 是 _insert_or_find_by_fingerprint 的顶层关键字参数,
# 不在 values 字典里(见 orchestrator.py 的调用点)。原测试只取
# kw["values"],导致 model 断言永远为 None。
captured_values.update(kw.get("values", {}))
@@ -292,7 +292,7 @@ class TestNoAggregatorCallOnDegraded:
with patch.object(orch_mod, "run_specialists", mock_run_specialists), \
patch.object(orch_mod, "_agent_provider", mock_agent_provider), \
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):
await orch_mod.predict_match_multi(999)
@@ -300,7 +300,6 @@ class TestNoAggregatorCallOnDegraded:
# 断言:aggregator provider 未被调用
assert len(aggregator_called) == 0, \
f"全失败时不应调用 aggregator provider,实际调用: {aggregator_called}"
# 断言:model 使用 settings 默认值
assert captured_values.get("model") is not None
# P0-03:degraded 路径 status=degraded(model 可能为 None,由 aggregator 降级逻辑决定)
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)
+3 -1
View File
@@ -73,7 +73,9 @@ class TestStandingsModel:
def test_unique_constraint_includes_available_at(self):
names = {c.name for c in Standing.__table__.constraints}
assert any("available" in n and n.startswith("uq_") for n in names), \
# 匿名约束(PK/Check)的 name 是 None,必须先判真值再子串匹配,
# 否则 any() 撞上 None 直接 TypeError(集合顺序不定 → flaky)
assert any(n and "available" in n and n.startswith("uq_") for n in names), \
f"缺少含 available_at 的唯一约束,现有: {names}"
def test_old_unique_constraint_removed(self):
+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
2. 同一场比赛 live backtest 预测可共存,互不覆盖
3. _upsert_prediction 正确区分 run_type
3. _insert_or_find_by_fingerprint 正确区分 run_type
"""
from __future__ import annotations
@@ -12,6 +12,7 @@ from pathlib import Path
from pydantic import BaseModel
import pytest
from sqlalchemy import Index
from src.db.models import Prediction, UniqueConstraint, CheckConstraint
@@ -22,20 +23,32 @@ MIGRATION_PATH = REPO_ROOT / "alembic" / "versions" / "0013_predictions_unique_c
class TestUniqueConstraint:
"""验证唯一约束包含 mode + run_type。"""
"""P0-03: 验证幂等指纹唯一索引(替代旧 (match, provider, model, mode, run_type) 唯一约束)"""
def test_constraint_columns(self):
"""唯一约束应包含 match_id, provider, model, mode, run_type"""
uc = [
c for c in Prediction.__table__.constraints
if isinstance(c, UniqueConstraint) and "match" in c.name
def test_input_hash_partial_unique_index(self):
"""P0-03: input_hash 非空时必须唯一(同指纹 → 返回已有行,不 UPDATE/INSERT)"""
idx = [
i for i in Prediction.__table__.indexes
if i.unique and "input_hash" in i.name
]
assert len(uc) == 1
cols = [c.name for c in uc[0].columns]
assert cols == ["match_id", "provider", "model", "mode", "run_type"]
assert len(idx) == 1, f"缺少 input_hash partial unique 索引,现有 indexes: {[i.name for i in Prediction.__table__.indexes]}"
# partial unique: postgresql_where 必须限制 input_hash IS NOT NULL
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):
"""应有 run_type 的 check constraint。"""
from sqlalchemy import CheckConstraint
cc = [
c for c in Prediction.__table__.constraints
if isinstance(c, CheckConstraint) and "run_type" in c.name
@@ -52,13 +65,16 @@ class TestUniqueConstraint:
class TestUpsertPredictionSignature:
"""验证 _upsert_prediction 函数签名包含 run_type"""
"""验证 _insert_or_find_by_fingerprint 签名(P0-03 指纹模式)"""
def test_signature_has_run_type(self):
from src.llm.predict import _upsert_prediction
def test_signature_uses_values_dict(self):
"""P0-03: 新接口通过 values dict 接收全部字段(含 run_type/match_id/...)。"""
from src.llm.predict import _insert_or_find_by_fingerprint
sig = inspect.signature(_upsert_prediction)
assert "run_type" in sig.parameters
sig = inspect.signature(_insert_or_find_by_fingerprint)
params = sig.parameters
assert "session" in params
assert "values" in params # 所有业务字段走 values dict
def test_signature_has_backtest_in_predict_match(self):
from src.llm.predict import predict_match
+35 -2
View File
@@ -17,7 +17,7 @@ import re
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.llm import backtest as bt_mod
from src.llm.agents import orchestrator as orch_mod
@@ -107,6 +107,7 @@ class _FakeDb:
self.flush_count = 0
self._next_id = 1000
self._teams_by_id: dict[int, Team] = {}
self._teams_by_name: dict[str, Team] = {}
self._aliases: dict[str, TeamAlias] = {}
async def get(self, cls, key):
@@ -117,6 +118,38 @@ class _FakeDb:
return None
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:
return self._results.pop(0)
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, "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)
assert get_uow is not None # 确保 import 生效,session 未被真实打开
+48 -2
View File
@@ -80,6 +80,7 @@ class _FakeDB:
}
# 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_name: dict[str, Team] = {t.name: t for t in teams if getattr(t, "id", None)}
self._aliases: dict[str, TeamAlias] = {}
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
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()
for d in (stmt.column_descriptions or []):
entities.add(d.get("entity") or d.get("type"))
@@ -267,8 +313,8 @@ class TestStandingsBronzeIsBestEffort:
raise RuntimeError("infra down")
# Bronze 写入助手直接 import 到 bzzoiro_standings 命名空间,需 patch 该处
monkeypatch.setattr(bz_standings, "_write_raw_event", _boom)
monkeypatch.setattr(bz_standings, "_write_lineage", _boom)
monkeypatch.setattr("src.data.pipeline_write._write_raw_event", _boom)
monkeypatch.setattr("src.data.pipeline_write._write_lineage", _boom)
_patch_fetch(monkeypatch, _payload())
db = _FakeDB(leagues=[_preset_league()])