Compare commits
402 Commits
eea609d960
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ac96dff64 | |||
| 3dcc7d9899 | |||
| 7386e5af57 | |||
| 9bb09a44d4 | |||
| 0414948682 | |||
| b0be1f1ae5 | |||
| 172b7a8ec4 | |||
| eb12006d38 | |||
| 1930ad32a4 | |||
| 8958eee11b | |||
| 0b839937df | |||
| 23a64042fe | |||
| b3aba7c31d | |||
| 16993ed362 | |||
| ea6bec5421 | |||
| 9e8a254fbb | |||
| e8b66f85ab | |||
| c61a6dac84 | |||
| 30c1cf10c0 | |||
| 232afd525b | |||
| 0a8e1ad18b | |||
| 2ab8088c81 | |||
| 1e92928d4e | |||
| 21c360bc87 | |||
| b02db74237 | |||
| 85ccad3b72 | |||
| 0e8069af27 | |||
| 53a8c4be02 | |||
| 269e0d294c | |||
| cc01d5ed62 | |||
| 065c8ae1b9 | |||
| 766592d855 | |||
| 933de6251e | |||
| 5fdcd9b5aa | |||
| a1c4e051e7 | |||
| 6a9b1ea188 | |||
| 6911c83d66 | |||
| b3ba11c7a5 | |||
| ee69721ea3 | |||
| e1f08dcd3b | |||
| ef244c52bd | |||
| ed59c6091a | |||
| 9be3bdb8eb | |||
| 58a0203522 | |||
| 77fd35830c | |||
| efbdeb1647 | |||
| 65126feada | |||
| caea9150b3 | |||
| c44f6010eb | |||
| 25918a73ff | |||
| 7d02cded99 | |||
| e1cac644c8 | |||
| 03337af8f8 | |||
| b307f51340 | |||
| c68de849da | |||
| 1468a53b7a | |||
| 96884dd149 | |||
| d1516bf56b | |||
| 87908b23eb | |||
| 1cbfa97b8d | |||
| 200053fbdc | |||
| 7cc6aa5b23 | |||
| 564c92a043 | |||
| c5c11ea81b | |||
| 04bda25335 | |||
| 8f349c5c62 | |||
| a0c7e043e8 | |||
| 4d60a545f6 | |||
| a39846af6e | |||
| 5bb8319220 | |||
| 6432ffef9a | |||
| 06c575f0a4 | |||
| 4fd8a6bfef | |||
| 232a56e826 | |||
| 1108235818 | |||
| d3639a94b8 | |||
| 6cdf05d56b | |||
| 39a9210e4d | |||
| a4c429b247 | |||
| 11a5545f57 | |||
| d7e965639e | |||
| 57f590b57e | |||
| 224bb25bb5 | |||
| 8dedd082ff | |||
| 83056688cf | |||
| f1b6865861 | |||
| c5fff7f8b3 | |||
| d6c595c4a4 | |||
| 05294412df | |||
| e6adaad8b3 | |||
| 748a969e0e | |||
| 63dfee1b9b | |||
| 266a1ab4bc | |||
| 94728c5220 | |||
| 26ab536fea | |||
| 8fef7ced42 | |||
| 02b8145b7f | |||
| 2844732126 | |||
| 6a21d80313 | |||
| 43d7346526 | |||
| b497db97ba | |||
| c3187167c8 | |||
| 84b0b30213 | |||
| aa10485b9f | |||
| fd907aab46 | |||
| 53989078a0 | |||
| 3d8bd75372 | |||
| 20eea98cdd | |||
| 9d19328cc9 | |||
| 75a2aac1c0 | |||
| 0a7ba724a9 | |||
| 3a8dd5c167 | |||
| 6b85fcbe1c | |||
| 01f2e7c7b9 | |||
| cf7f15d8bb | |||
| 745b600b5d | |||
| cfa510a7e5 | |||
| 312cee7be7 | |||
| f8ad8f0143 | |||
| a536a81b0d | |||
| 13234426b5 | |||
| e9349b55c0 | |||
| 58a84dc72b | |||
| 3cc8dc9105 | |||
| 5ecbac620c | |||
| 1ecb6ba25c | |||
| bdcc93d7de | |||
| 19911e18e0 | |||
| 6c6fb576ee | |||
| bd1f7a22a2 | |||
| 273984674b | |||
| 7e97081779 | |||
| 4b6d1d8a50 | |||
| dee6bf4896 | |||
| 8a69a54078 | |||
| 61bfa0e633 | |||
| 730caa7e9a | |||
| 2429dad393 | |||
| c412698cff | |||
| d6a3107e4e | |||
| ddb9a44f75 | |||
| 5ef5f2b8b7 | |||
| bef46636fe | |||
| 7ef9b99217 | |||
| 0fe1c96393 | |||
| d9f7dee2df | |||
| 7949f16906 | |||
| fd96f906c2 | |||
| 49b7be78ae | |||
| d84f9bf777 | |||
| b1c1bf7aac | |||
| 441a0aa6c3 | |||
| f36eb66c18 | |||
| 1d222d9f18 | |||
| 73d0bb9190 | |||
| c1565a01c2 | |||
| 15aee0cfc1 | |||
| 8731e448fe | |||
| 4af99b700b | |||
| d4e9852864 | |||
| 24ed7ca322 | |||
| c109f2257a | |||
| 334fbbc94d | |||
| ac115d96fb | |||
| 8b9e8aa19f | |||
| d77cbc95eb | |||
| e41a2b0d08 | |||
| f12dd7e559 | |||
| 841112c6af | |||
| abc9c097d2 | |||
| 96e314c3a0 | |||
| 46ac848176 | |||
| eaab865d84 | |||
| e64c26e578 | |||
| 76f719018c | |||
| b1f15ddb3a | |||
| 58278c6de4 | |||
| ea38e4717b | |||
| b13eab0659 | |||
| d0194f5840 | |||
| 8a54055d02 | |||
| 207e834f30 | |||
| 3f984acc18 | |||
| 2608a3841f | |||
| e68e4b7d57 | |||
| 111c88488f | |||
| ae0a718f67 | |||
| 25f2a845ff | |||
| 92714db370 | |||
| 645585e784 | |||
| ad0bfe3a51 | |||
| 84b52a0311 | |||
| 72befa2bd4 | |||
| 453cf62088 | |||
| 73c5d1e380 | |||
| 89cad891de | |||
| d7d7ce5bdc | |||
| d0a8019fe1 | |||
| eaa58604b3 | |||
| b976c789d2 | |||
| cd0805aad6 | |||
| 7c7ad67d81 | |||
| 36c712defa | |||
| b9616de21e | |||
| afa77173e3 | |||
| c49d0ff12f | |||
| e2325b6535 | |||
| b6b6a48503 | |||
| 832838350a | |||
| 9ee37a8534 | |||
| dec7346da3 | |||
| 37d4519905 | |||
| 671db2f88c | |||
| 5563d0d675 | |||
| c66a00c924 | |||
| e5b07ac974 | |||
| 73ae1f7143 | |||
| 8b48005a17 | |||
| 21c6a53aed | |||
| ec4cbbdd44 | |||
| cd5c9c01fb | |||
| 0d0f275134 | |||
| 4fb7a61f8b | |||
| eecb86e27a | |||
| f46e87258c | |||
| 206c553143 | |||
| 6d6eb8e3a3 | |||
| cf51d2de9d | |||
| 9053233f99 | |||
| 9f739e831d | |||
| f74711cd11 | |||
| 271d1682c9 | |||
| 9627ac9cf9 | |||
| 7abe92eb1c | |||
| 9525726133 | |||
| 74686dde68 | |||
| 6a5424a618 | |||
| c83d771923 | |||
| 75e6d8c550 | |||
| 811ffa648b | |||
| 043d4aa46f | |||
| 8c9adfd3fa | |||
| a51b3c4238 | |||
| 0290005129 | |||
| 928ec8c37b | |||
| c5bf7e1a61 | |||
| 56fe138a46 | |||
| 978ddef91b | |||
| e9073bfdc2 | |||
| 25f5537974 | |||
| 1d2385291a | |||
| 40b156cf69 | |||
| ebbd112d50 | |||
| a046862ca1 | |||
| f0284199b8 | |||
| 1c21e215e2 | |||
| a2c6a97621 | |||
| 4e0e05210d | |||
| 291a8108e1 | |||
| badfcce4cb | |||
| e3184c11f9 | |||
| cf529f2c8f | |||
| 439dc29b3b | |||
| 6034d4172d | |||
| a31b1fbf37 | |||
| 8d84d5e236 | |||
| 39c6352781 | |||
| ef402c46a2 | |||
| a5666b4f16 | |||
| a79c2ec753 | |||
| 658e62054e | |||
| da70eb6e23 | |||
| 307c64c388 | |||
| 735471df3c | |||
| 25c8d5ec42 | |||
| d0148e78ad | |||
| 6a6aae081b | |||
| 69a4d17c0b | |||
| 5b51f4bd0c | |||
| 8d11513e54 | |||
| c191be16e2 | |||
| 09b2773375 | |||
| c84de49dc4 | |||
| 75016c4892 | |||
| 4ebdf0b0ba | |||
| 1f83da9eb7 | |||
| 38a855b33e | |||
| d6ae0d85cd | |||
| 953fb7a456 | |||
| cabe5be038 | |||
| f8f8b5b12d | |||
| ba400417e2 | |||
| a7ca6d15ed | |||
| f21bf345a6 | |||
| 924160c779 | |||
| e7be42570d | |||
| bb6dad7005 | |||
| 16140cafcc | |||
| d3be9b1322 | |||
| c72b55508a | |||
| 160fb3bc7c | |||
| 457944bbfa | |||
| 5d0caa06e1 | |||
| 09d337658b | |||
| 45403b23b4 | |||
| ace0738546 | |||
| f57ee45dc0 | |||
| fad8147d71 | |||
| 93c9be8bfa | |||
| 11f3c90200 | |||
| 6e46d184b8 | |||
| 5aa7cc48c5 | |||
| 90f17e330e | |||
| 40b04f886e | |||
| a597a9f901 | |||
| 9eb9b86954 | |||
| eb15ab315e | |||
| 0b48b889e0 | |||
| 8c383354c5 | |||
| 86573735aa | |||
| 8cb1158a2d | |||
| d6bcf41336 | |||
| 4612123ec4 | |||
| 847def4a03 | |||
| afe80a8b32 | |||
| 8182cb86b1 | |||
| f733c13dd1 | |||
| 5a91f392f0 | |||
| dbc9d38cd7 | |||
| 8a49ef18e6 | |||
| 53286ac3ab | |||
| 91281cb995 | |||
| 9a42bbcf85 | |||
| a668fc173c | |||
| d6e74f2734 | |||
| 6baddcc17d | |||
| 8c9f7d2d37 | |||
| 6b56fc8f98 | |||
| 49e272719e | |||
| 767ac55787 | |||
| d7d8633dae | |||
| 48b423ef35 | |||
| ad8fc176d0 | |||
| 461abf0329 | |||
| 6858da80c7 | |||
| fd76720dcf | |||
| b47f634e61 | |||
| 535bd320b0 | |||
| 5e57cf19b6 | |||
| ca02b5a91d | |||
| 63ad68f737 | |||
| 2db612e0bb | |||
| af8081e9ed | |||
| 283ea27b51 | |||
| 9ab6a89298 | |||
| 5c42e0309b | |||
| 2c5b4c819c | |||
| c6a81f8504 | |||
| fdbf9222ab | |||
| ebd83810aa | |||
| 7f8ed56428 | |||
| 57793fb0f8 | |||
| 47c2de71a2 | |||
| 8bbfbc9b8a | |||
| 2761ca3e82 | |||
| dc19e9e007 | |||
| c42813992f | |||
| 502681694b | |||
| 96e63a3e19 | |||
| 3fe077a249 | |||
| b8706adb33 | |||
| d83e720c4b | |||
| 224c75ba9e | |||
| 83e6475f90 | |||
| f0a49b08e5 | |||
| 2bc5dc74c2 | |||
| 112876de2e | |||
| e36f7c63f3 | |||
| e20bfac48d | |||
| 961e85111b | |||
| bc18fc8635 | |||
| 5ab5adec51 | |||
| 253f36797b | |||
| f94c352d66 | |||
| c4d42eeca0 | |||
| 8f5fbf8d2d | |||
| 9bdfd28c63 | |||
| 15fad65b1b | |||
| faf6f1bd63 | |||
| c61ceca538 | |||
| 8cfdc841da | |||
| 79761852df | |||
| af2aa751b9 | |||
| e87a744df9 | |||
| 3e348f5b21 | |||
| 6fd9522bf3 | |||
| 5ec8dd2f82 | |||
| 1b2442b9c5 | |||
| c4bc5e7cc6 | |||
| fd58a3f02b | |||
| d95b2a3142 | |||
| adf14633e5 |
@@ -22,6 +22,7 @@ Every project goes through this process. A todo list, a single-function utility,
|
||||
You MUST create a task for each of these items and complete them in order:
|
||||
|
||||
1. **Explore project context** — check files, docs, recent commits
|
||||
1.5. **Prior-version audit (mandatory for rewrites/refactors)** — if the task replaces or rewrites an existing module, list every behavior of the old version (including persistence, crash recovery, idempotency, resume) and confirm each is kept, replaced, or deliberately dropped. Undocumented implicit drops = bugs.
|
||||
2. **Offer visual companion** (if topic will involve visual questions) — this is its own message, not combined with a clarifying question. See the Visual Companion section below.
|
||||
3. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria
|
||||
4. **Propose 2-3 approaches** — with trade-offs and your recommendation
|
||||
@@ -92,7 +93,12 @@ digraph brainstorming {
|
||||
- Once you believe you understand what you're building, present the design
|
||||
- Scale each section to its complexity: a few sentences if straightforward, up to 200-300 words if nuanced
|
||||
- Ask after each section whether it looks right so far
|
||||
- Cover: architecture, components, data flow, error handling, testing
|
||||
- Cover: architecture, components, data flow, error handling, testing, **non-functional requirements** (see below)
|
||||
- **Non-functional requirements (mandatory section):** Every design MUST explicitly address these four dimensions — even if the answer is "not applicable":
|
||||
- **Persistence strategy:** When does data hit disk? How much is lost on crash? Overwrite or append?
|
||||
- **Idempotency:** Is the same operation safe to repeat? Does it produce the same result?
|
||||
- **Resume/checkpoint:** Can the process recover from interruption? How is progress persisted?
|
||||
- **Atomicity:** Are writes atomic? Can a partial write corrupt data?
|
||||
- Be ready to go back and clarify if something doesn't make sense
|
||||
|
||||
**Design for isolation and clarity:**
|
||||
@@ -124,6 +130,8 @@ After writing the spec document, look at it with fresh eyes:
|
||||
2. **Internal consistency:** Do any sections contradict each other? Does the architecture match the feature descriptions?
|
||||
3. **Scope check:** Is this focused enough for a single implementation plan, or does it need decomposition?
|
||||
4. **Ambiguity check:** Could any requirement be interpreted two different ways? If so, pick one and make it explicit.
|
||||
5. **Non-functional coverage:** Does the design explicitly address persistence, idempotency, resume, and atomicity? If any dimension is missing, add it now — even if the answer is "not applicable."
|
||||
6. **Prior-version regression check (rewrites only):** If this replaces an existing module, confirm every behavior from the prior-version audit (step 1.5) is accounted for in the design. Any gap = a spec bug.
|
||||
|
||||
Fix any issues inline. No need to re-review — just fix and move on.
|
||||
|
||||
|
||||
+11
-7
@@ -5,27 +5,27 @@ NO_PROXY=dashscope.aliyuncs.com,api.deepseek.com
|
||||
# ── 搜索 Agent LLM ──
|
||||
SEARCH_LLM_MODEL=deepseek-v4-pro
|
||||
SEARCH_LLM_BASE_URL=https://newapi.iomgaa.online/v1
|
||||
SEARCH_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
|
||||
SEARCH_LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# ── 评估 Judge LLM ──
|
||||
JUDGE_LLM_MODEL=deepseek-v4-pro
|
||||
JUDGE_LLM_BASE_URL=https://newapi.iomgaa.online/v1
|
||||
JUDGE_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
|
||||
JUDGE_LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# ── 视觉模型(Qwen VL)──
|
||||
VL_LLM_MODEL=qwen3.6-plus
|
||||
VL_LLM_BASE_URL=https://newapi.iomgaa.online/v1
|
||||
VL_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
|
||||
VL_LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# ── 进化 LLM(Prompt 改写)──
|
||||
EVOLVE_LLM_MODEL=deepseek-v4-pro
|
||||
EVOLVE_LLM_BASE_URL=https://newapi.iomgaa.online/v1
|
||||
EVOLVE_LLM_API_KEY=sk-lhDmxnhlnPd7ketQ3Z4uMRj4dCgnVpSJzdY2VTrjYpKFmCIV
|
||||
EVOLVE_LLM_API_KEY=sk-your-api-key-here
|
||||
|
||||
# ── ASR 字幕生成(Groq Whisper)──
|
||||
ASR_MODEL=whisper-large-v3
|
||||
ASR_BASE_URL=https://api.groq.com/openai/v1
|
||||
ASR_API_KEY=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
|
||||
ASR_API_KEY=gsk_your-groq-api-key-here
|
||||
|
||||
# ── MonkeyOCR ──
|
||||
MONKEY_OCR_URLS=http://10.77.0.20:7866,http://10.77.0.20:7867
|
||||
@@ -41,9 +41,13 @@ REDIS_URL=redis://localhost:6379/0
|
||||
LLM_TIMEOUT=120
|
||||
LLM_MAX_RETRIES=3
|
||||
LLM_RETRY_BASE_DELAY=2.0
|
||||
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
||||
LLM_CIRCUIT_BREAKER_THRESHOLD=48 # 实际阈值 = max(此值, concurrency*2)
|
||||
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||
LLM_TTFT_TIMEOUT=30
|
||||
LLM_INTER_TOKEN_TIMEOUT=15
|
||||
LLM_RETRY_MAX_DELAY=30.0
|
||||
REDIS_CACHE_TTL=86400
|
||||
# 正整数秒,禁止 0(0 会被拒绝启动);训练场景建议 >= 单次训练时长
|
||||
REDIS_CACHE_TTL=604800
|
||||
|
||||
# 建树批量并行:全局 VLM/LLM 在途调用上限(Spec-2 工程配置)
|
||||
TREE_BUILD_API_CONCURRENCY=16
|
||||
|
||||
@@ -182,6 +182,7 @@ pencil/
|
||||
# 数据与实验产物(不提交)
|
||||
store/
|
||||
!store/prompts/
|
||||
!store/skills/
|
||||
workspaces/
|
||||
results/
|
||||
|
||||
|
||||
@@ -96,9 +96,10 @@ MODE=mock N_SAMPLES=10 bash scripts/<experiment>.sh # smoke test
|
||||
### Phase 1: 规划与设计 (Planning)
|
||||
1. **需求探索**: 涉及创建新功能、新组件、修改行为时,**必须**先调用 `brainstorming` skill 进行需求探索与设计。无论用户的指令多么具体、改动多么简单,都不得跳过此步骤(除非用户显式说"跳过 brainstorming")。
|
||||
2. **查阅规格 & 讨论**: 仔细阅读 `research-wiki/`(单一事实源)下对应的文档,了解项目最新情况。对于不理解的地方请与人类进行多轮讨论,确保理解人类的设计意图。
|
||||
3. **日志方案设计**: 功能会产生运行时数据时,**必须**调用 `structured-logging` skill 设计日志方案。
|
||||
4. **撰写计划**: 正式编码前,**必须**调用 `writing-plans` skill 撰写实现计划。
|
||||
5. **审核门控(差异化)**:
|
||||
3. **前序版本对照(重写/重构时强制)**: 当任务涉及重写或重构已有模块时,**必须**列出前序版本的所有行为(包括持久化策略、崩溃恢复、幂等性、断点续跑等非功能性行为),逐一确认新版本是保留、替代、还是删除。未经确认的隐式删除 = bug。
|
||||
4. **日志方案设计**: 功能会产生运行时数据时,**必须**调用 `structured-logging` skill 设计日志方案。
|
||||
5. **撰写计划**: 正式编码前,**必须**调用 `writing-plans` skill 撰写实现计划。
|
||||
6. **审核门控(差异化)**:
|
||||
- **design:Claude 自审 → Codex 审 → 人类审**(保留人类门,批准后方可进入计划阶段)。
|
||||
- **plan:Claude 自审 → Codex 审 → 直接执行**(无 plan 人类门);plan 经 Claude 自审 + Codex 审通过后直接进入 Phase 2 执行。
|
||||
|
||||
@@ -159,6 +160,19 @@ MODE=mock N_SAMPLES=10 bash scripts/<experiment>.sh # smoke test
|
||||
- **功能修改**:
|
||||
- **必须** 不考虑向后兼容,直接修改原文件。代码简洁性优先。
|
||||
|
||||
### 4.2.1 设计文档非功能性需求覆盖(强制)
|
||||
|
||||
> **教训来源**: v2 出题管线重写时未继承 v1 的逐题追加持久化策略,导致多次 run 的题目丢失。
|
||||
|
||||
设计文档**必须**显式覆盖以下非功能性维度(即使答案是"不适用"也要写明):
|
||||
|
||||
| 维度 | 必答问题 |
|
||||
|------|---------|
|
||||
| **持久化策略** | 数据何时落盘?崩溃时最多丢多少?是覆盖写还是追加? |
|
||||
| **幂等性** | 同一操作重复执行是否安全?结果是否一致? |
|
||||
| **断点续跑** | 中断后重启能否从断点恢复?进度如何持久化? |
|
||||
| **原子性** | 写操作是否原子?部分写入是否会损坏数据? |
|
||||
|
||||
### 4.3 Git 工作流规范
|
||||
- **Feature Branch**: 所有开发工作在 feature 分支上进行,**严禁**直接在 main/master 上修改。
|
||||
- **增量提交**: 频繁提交,每个提交有明确的语义。
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""SqliteDiagnosisSignalStore:baseline 逐题诊断信号的 SQLite 持久化适配器。
|
||||
|
||||
实现 core/evolution/protocols.py::DiagnosisSignalStore 端口。信号行以
|
||||
(question_id, baseline_run_id, diag_fingerprint) 为主键,INSERT OR REPLACE
|
||||
保证逐题幂等 upsert;bool 字段以 0/1 存储,可空字段以 NULL 存储。表建在
|
||||
harness.db,供离线诊断编排写入、视频级切分选择器读取。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.evolution.types import DiagnosisSignalRow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import TracebackType
|
||||
|
||||
# 表列顺序即 DiagnosisSignalRow 字段顺序(question_id..session_id),
|
||||
# upsert 写入与 load 还原共用,避免手写列名两处漂移。
|
||||
_COLUMNS: tuple[str, ...] = (
|
||||
"question_id",
|
||||
"video_id",
|
||||
"baseline_run_id",
|
||||
"diag_fingerprint",
|
||||
"task_type",
|
||||
"error_type",
|
||||
"cause_category",
|
||||
"tier",
|
||||
"evolution_target",
|
||||
"degraded",
|
||||
"infra",
|
||||
"session_id",
|
||||
)
|
||||
|
||||
|
||||
class SqliteDiagnosisSignalStore:
|
||||
"""逐题诊断信号的 SQLite 存储实现。
|
||||
|
||||
构造时按需建表(CREATE TABLE IF NOT EXISTS),主键
|
||||
(question_id, baseline_run_id, diag_fingerprint) 保证同键覆盖。
|
||||
每次写操作单事务 commit,保证原子落盘与断点续跑。
|
||||
|
||||
参数:
|
||||
db_path: SQLite 数据库文件路径(通常为 harness.db)。
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str) -> None:
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(db_path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init_table()
|
||||
|
||||
def _init_table(self) -> None:
|
||||
"""创建 baseline_diagnosis 表(若不存在)。"""
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS baseline_diagnosis (
|
||||
question_id TEXT NOT NULL,
|
||||
video_id TEXT NOT NULL,
|
||||
baseline_run_id TEXT NOT NULL,
|
||||
diag_fingerprint TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
error_type TEXT,
|
||||
cause_category TEXT,
|
||||
tier TEXT NOT NULL,
|
||||
evolution_target TEXT,
|
||||
degraded INTEGER NOT NULL,
|
||||
infra INTEGER NOT NULL,
|
||||
session_id TEXT,
|
||||
PRIMARY KEY (question_id, baseline_run_id, diag_fingerprint)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def upsert(self, row: DiagnosisSignalRow) -> None:
|
||||
"""写入或覆盖单题诊断信号(按主键幂等,单事务)。
|
||||
|
||||
参数:
|
||||
row: 待持久化的诊断信号行。bool 字段转 0/1,None 存 NULL。
|
||||
|
||||
关键实现:
|
||||
用 INSERT OR REPLACE 按主键覆盖,避免重复行;commit 保证原子。
|
||||
"""
|
||||
placeholders = ", ".join("?" for _ in _COLUMNS)
|
||||
col_names = ", ".join(_COLUMNS)
|
||||
values = (
|
||||
row.question_id,
|
||||
row.video_id,
|
||||
row.baseline_run_id,
|
||||
row.diag_fingerprint,
|
||||
row.task_type,
|
||||
row.error_type,
|
||||
row.cause_category,
|
||||
row.tier,
|
||||
row.evolution_target,
|
||||
int(row.degraded),
|
||||
int(row.infra),
|
||||
row.session_id,
|
||||
)
|
||||
self._conn.execute(
|
||||
f"INSERT OR REPLACE INTO baseline_diagnosis ({col_names}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def done_question_ids(
|
||||
self,
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
*,
|
||||
retry_uncertain: bool = False,
|
||||
) -> set[str]:
|
||||
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
|
||||
|
||||
参数:
|
||||
baseline_run_id: baseline run 标识。
|
||||
diag_fingerprint: 诊断口径指纹。
|
||||
retry_uncertain: True 时追加 `AND tier != 'uncertain'`,把 uncertain
|
||||
(信号不可信降级)题排除出已完成集,令其被重新诊断;默认 False。
|
||||
|
||||
返回:
|
||||
已落盘信号的 question_id 集合;无匹配时为空集,供断点续跑跳过。
|
||||
"""
|
||||
sql = (
|
||||
"SELECT DISTINCT question_id FROM baseline_diagnosis"
|
||||
" WHERE baseline_run_id = ? AND diag_fingerprint = ?"
|
||||
)
|
||||
if retry_uncertain:
|
||||
sql += " AND tier != 'uncertain'"
|
||||
cursor = self._conn.execute(sql, (baseline_run_id, diag_fingerprint))
|
||||
return {r["question_id"] for r in cursor.fetchall()}
|
||||
|
||||
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
|
||||
"""加载指定 run 与诊断指纹下的全部诊断信号行。
|
||||
|
||||
参数:
|
||||
baseline_run_id: baseline run 标识。
|
||||
diag_fingerprint: 诊断口径指纹。
|
||||
|
||||
返回:
|
||||
还原后的 DiagnosisSignalRow 列表(0/1→bool,NULL→None)。
|
||||
"""
|
||||
col_names = ", ".join(_COLUMNS)
|
||||
cursor = self._conn.execute(
|
||||
f"SELECT {col_names} FROM baseline_diagnosis"
|
||||
" WHERE baseline_run_id = ? AND diag_fingerprint = ?",
|
||||
(baseline_run_id, diag_fingerprint),
|
||||
)
|
||||
return [
|
||||
DiagnosisSignalRow(
|
||||
question_id=r["question_id"],
|
||||
video_id=r["video_id"],
|
||||
baseline_run_id=r["baseline_run_id"],
|
||||
diag_fingerprint=r["diag_fingerprint"],
|
||||
task_type=r["task_type"],
|
||||
error_type=r["error_type"],
|
||||
cause_category=r["cause_category"],
|
||||
tier=r["tier"],
|
||||
evolution_target=r["evolution_target"],
|
||||
degraded=bool(r["degraded"]),
|
||||
infra=bool(r["infra"]),
|
||||
session_id=r["session_id"],
|
||||
)
|
||||
for r in cursor.fetchall()
|
||||
]
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层 SQLite 连接,释放文件描述符与锁。"""
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> SqliteDiagnosisSignalStore:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.close()
|
||||
+18
-3
@@ -20,21 +20,32 @@ class CircuitBreaker:
|
||||
self._cooldown_s = cooldown_s
|
||||
self._fails: dict[str, int] = {}
|
||||
self._open_until: dict[str, float] = {}
|
||||
self._half_open_inflight: dict[str, bool] = {}
|
||||
|
||||
def is_open(self, source_name: str, now: float) -> bool:
|
||||
"""判断指定源是否处于开路状态。
|
||||
|
||||
冷却截止时刻之前为开路;到期返回 False(放行一个试探,即半开)。
|
||||
冷却截止时刻之前为开路;到期进入半开,**只放行一个探针**(其余仍被挡),
|
||||
避免冷却到期瞬间惊群重连再次压垮上游。"检查+标记探针"在 asyncio 单线程内
|
||||
同步执行,天然原子无竞态。
|
||||
|
||||
Args:
|
||||
source_name: 被熔断的源标识。
|
||||
now: 当前时刻(秒级时间戳),由调用方注入。
|
||||
|
||||
Returns:
|
||||
True 表示开路(拒绝请求),False 表示关闭或半开(放行)。
|
||||
True 表示开路(拒绝请求),False 表示关闭或半开放行探针。
|
||||
"""
|
||||
until = self._open_until.get(source_name)
|
||||
return until is not None and now < until
|
||||
if until is None:
|
||||
return False
|
||||
if now < until:
|
||||
return True # 冷却中,全挡
|
||||
# 冷却到期:half-open,只放行一个探针
|
||||
if self._half_open_inflight.get(source_name):
|
||||
return True # 已有探针在途,继续挡
|
||||
self._half_open_inflight[source_name] = True
|
||||
return False
|
||||
|
||||
def record_failure(self, source_name: str, now: float) -> None:
|
||||
"""记录一次失败;累计达阈值则开路至 now + cooldown。
|
||||
@@ -47,6 +58,8 @@ class CircuitBreaker:
|
||||
self._fails[source_name] = count
|
||||
if count >= self._fail_threshold:
|
||||
self._open_until[source_name] = now + self._cooldown_s
|
||||
# 探针失败清在途标记,使下一轮 cooldown 到期后可再放行探针
|
||||
self._half_open_inflight.pop(source_name, None)
|
||||
|
||||
def force_open(self, source_name: str, now: float) -> None:
|
||||
"""强制开路(用于 401/403 等不可恢复错误),一次即熔断。
|
||||
@@ -59,6 +72,7 @@ class CircuitBreaker:
|
||||
"""
|
||||
self._fails[source_name] = self._fail_threshold
|
||||
self._open_until[source_name] = now + self._cooldown_s
|
||||
self._half_open_inflight.pop(source_name, None)
|
||||
|
||||
def record_success(self, source_name: str) -> None:
|
||||
"""记录一次成功;清零失败计数与开路状态(关闭熔断器)。
|
||||
@@ -68,3 +82,4 @@ class CircuitBreaker:
|
||||
"""
|
||||
self._fails.pop(source_name, None)
|
||||
self._open_until.pop(source_name, None)
|
||||
self._half_open_inflight.pop(source_name, None)
|
||||
|
||||
+28
-13
@@ -179,7 +179,10 @@ def _is_transient_error(exc: Exception) -> bool:
|
||||
返回:
|
||||
True 表示可重试,False 表示不可重试。
|
||||
"""
|
||||
if isinstance(exc, (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout)):
|
||||
# 两族基类覆盖断连族:TimeoutException(ConnectTimeout/ReadTimeout/WriteTimeout/PoolTimeout)
|
||||
# 与 TransportError(ConnectError/ReadError/RemoteProtocolError 等)。
|
||||
# 注意 HTTPStatusError 非 TransportError 子类,401/403 致命分支不受影响。
|
||||
if isinstance(exc, (httpx.TimeoutException, httpx.TransportError)):
|
||||
return True
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return exc.response.status_code in _TRANSIENT_STATUS_CODES
|
||||
@@ -274,6 +277,7 @@ class GovernedLLMClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse:
|
||||
"""发起 LLM 调用,经四层治理栈:熔断 → 缓存 → 重试+流式 → 遥测。
|
||||
|
||||
@@ -281,6 +285,7 @@ class GovernedLLMClient:
|
||||
messages: OpenAI 格式消息列表。
|
||||
session_id: 会话 ID(传递到遥测)。
|
||||
parent_call_id: 父调用 ID(传递到遥测)。
|
||||
cache_salt: 可选缓存盐,透传到 Redis 缓存键(如跨 epoch 重采样)。
|
||||
|
||||
返回:
|
||||
LLMResponse 统一响应。
|
||||
@@ -295,12 +300,14 @@ class GovernedLLMClient:
|
||||
if self._breaker.is_open(self._provider, time.monotonic()):
|
||||
raise CircuitOpenError(f"熔断器已开启,拒绝调用 provider={self._provider}")
|
||||
|
||||
# ② call_id 生成
|
||||
call_id = str(uuid4())
|
||||
|
||||
# ③ 缓存查询(cache 为 None 时跳过)
|
||||
cached = await self._cache.get(self._model, messages) if self._cache is not None else None
|
||||
# ② 缓存查询(cache 为 None 时跳过)— call_id 在缓存路径独立生成
|
||||
cached = (
|
||||
await self._cache.get(self._model, messages, cache_salt)
|
||||
if self._cache is not None
|
||||
else None
|
||||
)
|
||||
if cached is not None:
|
||||
cache_call_id = str(uuid4())
|
||||
response = LLMResponse(
|
||||
content=cached.content,
|
||||
thinking=cached.thinking,
|
||||
@@ -312,10 +319,10 @@ class GovernedLLMClient:
|
||||
ttft_ms=None,
|
||||
max_inter_token_ms=None,
|
||||
cache_hit=True,
|
||||
call_id=call_id,
|
||||
call_id=cache_call_id,
|
||||
)
|
||||
await self._telemetry.record_llm_call(
|
||||
call_id=call_id,
|
||||
call_id=cache_call_id,
|
||||
parent_call_id=parent_call_id,
|
||||
session_id=session_id,
|
||||
model_name=self._model,
|
||||
@@ -333,9 +340,10 @@ class GovernedLLMClient:
|
||||
)
|
||||
return response
|
||||
|
||||
# ④ 重试循环 + 流式消费
|
||||
# ③ 重试循环 + 流式消费(每次 attempt 独立 call_id)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(self._max_retries):
|
||||
call_id = str(uuid4())
|
||||
attempt_start = time.monotonic()
|
||||
try:
|
||||
content, thinking_text, ttft_ms, max_itoken_ms, usage = await self._call_streaming(
|
||||
@@ -371,7 +379,7 @@ class GovernedLLMClient:
|
||||
|
||||
# ④ 写缓存(cache 为 None 时跳过)
|
||||
if self._cache is not None:
|
||||
await self._cache.set(self._model, messages, response)
|
||||
await self._cache.set(self._model, messages, response, cache_salt)
|
||||
|
||||
# ⑤ 遥测
|
||||
await self._telemetry.record_llm_call(
|
||||
@@ -450,12 +458,15 @@ class GovernedLLMClient:
|
||||
},
|
||||
)
|
||||
if attempt < self._max_retries - 1:
|
||||
delay = min(
|
||||
import random
|
||||
|
||||
base = min(
|
||||
self._retry_base_delay_s * (2**attempt),
|
||||
self._retry_max_delay_s,
|
||||
)
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
jitter = base * random.uniform(0.5, 1.5)
|
||||
if jitter > 0:
|
||||
await asyncio.sleep(jitter)
|
||||
continue
|
||||
|
||||
# 非瞬时、非致命 → 记遥测后直接抛出
|
||||
@@ -570,6 +581,10 @@ class GovernedLLMClient:
|
||||
else:
|
||||
thinking_parts.append(text)
|
||||
|
||||
# 流耗尽但未收 [DONE] → 服务端截断,视为可重试的 SSE 异常(不写缓存/不当成功)
|
||||
if not usage_sink.get("done"):
|
||||
raise _SseAnomaly("truncated_no_done")
|
||||
|
||||
content = "".join(content_parts)
|
||||
thinking = "".join(thinking_parts)
|
||||
usage = usage_sink.get("usage", {})
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ from pathlib import Path # noqa: TC003 — 运行时需要(方法签名 + ope
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
_TIMEOUT_S = 15
|
||||
_TIMEOUT_S = 300
|
||||
|
||||
|
||||
class MonkeyOCRClient:
|
||||
|
||||
+49
-12
@@ -12,6 +12,26 @@ from loguru import logger
|
||||
from core.types import LLMResponse
|
||||
|
||||
|
||||
def _resolve_cache_ttl(ttl: int) -> int:
|
||||
"""校验 Redis 缓存 TTL:必须为正整数(消灭 0=永不过期 的隐式语义)。
|
||||
|
||||
Args:
|
||||
ttl: 待校验的 TTL 秒数。
|
||||
|
||||
Returns:
|
||||
校验通过的正整数 TTL。
|
||||
|
||||
Raises:
|
||||
ValueError: ttl <= 0。
|
||||
"""
|
||||
if ttl <= 0:
|
||||
raise ValueError(
|
||||
f"REDIS_CACHE_TTL 必须为正整数秒,实际 {ttl}。"
|
||||
"训练场景建议 >= 单次训练时长(如 86400)。"
|
||||
)
|
||||
return ttl
|
||||
|
||||
|
||||
class RedisResponseCache:
|
||||
"""基于 Redis 的 LLM 响应缓存。
|
||||
|
||||
@@ -22,43 +42,55 @@ class RedisResponseCache:
|
||||
|
||||
Args:
|
||||
redis: 异步 Redis 客户端实例(duck-typed,需支持 get/set 方法)。
|
||||
ttl_s: 缓存过期时间(秒)。
|
||||
ttl_s: 缓存过期时间(秒)。None 表示永不过期。
|
||||
"""
|
||||
|
||||
def __init__(self, redis: Any, ttl_s: int) -> None:
|
||||
def __init__(self, redis: Any, ttl_s: int | None) -> None:
|
||||
self._redis = redis
|
||||
self._ttl_s = ttl_s
|
||||
|
||||
def _build_key(self, model: str, messages: list[dict[str, str]]) -> str:
|
||||
def _build_key(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, str]],
|
||||
cache_salt: str | None = None,
|
||||
) -> str:
|
||||
"""构造 content-addressed 缓存键。
|
||||
|
||||
Args:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
cache_salt: 可选缓存盐(如跨 epoch 强制重采样)。仅当非 None 时才加入
|
||||
键 payload,保证默认 None 时键结构与旧缓存一字节不差、旧键不失效。
|
||||
|
||||
Returns:
|
||||
sha256 哈希字符串作为 Redis 键。
|
||||
"""
|
||||
payload = json.dumps(
|
||||
{"model": model, "messages": messages},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
key_obj: dict[str, Any] = {"model": model, "messages": messages}
|
||||
if cache_salt is not None:
|
||||
key_obj["salt"] = cache_salt
|
||||
payload = json.dumps(key_obj, sort_keys=True, ensure_ascii=False)
|
||||
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return f"llm_cache:{digest}"
|
||||
|
||||
async def get(self, model: str, messages: list[dict[str, str]]) -> LLMResponse | None:
|
||||
async def get(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict[str, str]],
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse | None:
|
||||
"""从缓存读取 LLM 响应。
|
||||
|
||||
Args:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
cache_salt: 可选缓存盐,透传到键构造。
|
||||
|
||||
Returns:
|
||||
缓存命中时返回 LLMResponse,未命中或 Redis 异常时返回 None。
|
||||
"""
|
||||
try:
|
||||
key = self._build_key(model, messages)
|
||||
key = self._build_key(model, messages, cache_salt)
|
||||
raw = await self._redis.get(key)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存读取失败,降级为未命中")
|
||||
@@ -75,6 +107,7 @@ class RedisResponseCache:
|
||||
model: str,
|
||||
messages: list[dict[str, str]],
|
||||
response: LLMResponse,
|
||||
cache_salt: str | None = None,
|
||||
) -> None:
|
||||
"""将 LLM 响应写入缓存。
|
||||
|
||||
@@ -82,10 +115,14 @@ class RedisResponseCache:
|
||||
model: 模型名称。
|
||||
messages: 消息列表。
|
||||
response: 待缓存的 LLMResponse。
|
||||
cache_salt: 可选缓存盐,透传到键构造。
|
||||
"""
|
||||
try:
|
||||
key = self._build_key(model, messages)
|
||||
key = self._build_key(model, messages, cache_salt)
|
||||
value = json.dumps(dataclasses.asdict(response), ensure_ascii=False)
|
||||
await self._redis.set(key, value, ex=self._ttl_s)
|
||||
if self._ttl_s:
|
||||
await self._redis.set(key, value, ex=self._ttl_s)
|
||||
else:
|
||||
await self._redis.set(key, value)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存写入失败,跳过缓存")
|
||||
|
||||
+80
-41
@@ -1,17 +1,21 @@
|
||||
"""SQLite 遥测记录器 — TelemetryRecorder Protocol 的生产实现。
|
||||
|
||||
通过 asyncio.to_thread 将 SQLite 同步写入桥接到异步接口,
|
||||
确保事件循环不被阻塞。表在首次写入时懒初始化。
|
||||
通过 asyncio.to_thread 将 SQLite 同步写入桥接到异步接口,确保事件循环不被阻塞。
|
||||
构造时建单持久连接 + 建表(对齐 app/harness/log.py:HarnessLog 的并发写模式),
|
||||
写入经进程内 threading.Lock 串行化,消除多连接并发写的 database is locked。
|
||||
|
||||
零丢失保证范围 = 单进程、单 recorder 实例(当前 main.py / video_split_cli 均单实例
|
||||
注入)。同进程多个 recorder 指向同一 db 会退回跨连接竞争——本实现不支持该场景。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class SQLiteTelemetryRecorder:
|
||||
@@ -45,7 +49,7 @@ class SQLiteTelemetryRecorder:
|
||||
"""
|
||||
|
||||
_INSERT_SQL = """
|
||||
INSERT INTO llm_calls (
|
||||
INSERT OR IGNORE INTO llm_calls (
|
||||
call_id, parent_call_id, session_id,
|
||||
model_name, provider, messages, response, thinking,
|
||||
prompt_tokens, completion_tokens, latency_ms,
|
||||
@@ -55,16 +59,43 @@ class SQLiteTelemetryRecorder:
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = db_path
|
||||
self._table_ready = False
|
||||
"""建单持久连接 + 进程内 Lock(对齐 app/harness/log.py:HarnessLog 并发写模式)。
|
||||
|
||||
def _ensure_table(self, conn: sqlite3.Connection) -> None:
|
||||
"""懒初始化:首次写入时创建 llm_calls 表。"""
|
||||
if self._table_ready:
|
||||
return
|
||||
conn.execute(self._CREATE_TABLE_SQL)
|
||||
conn.commit()
|
||||
self._table_ready = True
|
||||
把并发控制拉到进程内(threading.Lock 串行化写),消除"每次新连接并发写同一
|
||||
db、靠 SQLite busy_timeout 跨连接协调"在高频下撑爆 timeout → database is locked
|
||||
的根因。check_same_thread=False:record_llm_call 经 asyncio.to_thread 在线程池
|
||||
不同线程调用,共享连接跨线程访问需此 flag,串行性由 self._lock 保证。
|
||||
|
||||
遥测哲学(P5):连接初始化失败降级不冒泡(self._conn=None,写入直接丢弃 warning),
|
||||
绝不因遥测故障拖垮 LLM 调用 / 训练。
|
||||
"""
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._conn: sqlite3.Connection | None = None
|
||||
# mkdir / connect / PRAGMA / 建表统一纳入降级边界:任一失败(OSError 含
|
||||
# PermissionError、sqlite3.Error)都降级为 self._conn=None,绝不冒泡拖垮初始化。
|
||||
try:
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute(self._CREATE_TABLE_SQL)
|
||||
conn.commit()
|
||||
self._conn = conn
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.warning("遥测连接初始化失败(已降级,后续写入丢弃): {}", exc)
|
||||
self._conn = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""幂等关闭持久连接(对齐 HarnessLog;进程退出前可选调以释放 fd)。
|
||||
|
||||
不调也不丢数据——每次 _write 已 commit 落 WAL,进程退出 OS 回收 fd、
|
||||
WAL 已提交内容下次打开自动 checkpoint 恢复。
|
||||
"""
|
||||
with self._lock:
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
def _write(
|
||||
self,
|
||||
@@ -85,33 +116,41 @@ class SQLiteTelemetryRecorder:
|
||||
cache_hit: bool,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
"""同步写入一条 LLM 调用记录到 SQLite。"""
|
||||
conn = sqlite3.connect(str(self._db_path))
|
||||
"""同步写入一条 LLM 调用记录(单持久连接 + Lock 串行化,对齐 HarnessLog)。
|
||||
|
||||
三层防御:
|
||||
1. INSERT OR IGNORE — call_id 主键冲突静默忽略(幂等)
|
||||
2. 进程内 threading.Lock 串行化写 — 消除并发锁竞争(非依赖 SQLite busy_timeout)
|
||||
3. try/except sqlite3.Error — DB 错误降级不冒泡,遥测失败绝不拖垮 LLM 调用
|
||||
"""
|
||||
if self._conn is None:
|
||||
logger.warning("遥测连接不可用(已降级),丢弃 call_id={}", call_id)
|
||||
return
|
||||
try:
|
||||
self._ensure_table(conn)
|
||||
conn.execute(
|
||||
self._INSERT_SQL,
|
||||
(
|
||||
call_id,
|
||||
parent_call_id,
|
||||
session_id,
|
||||
model_name,
|
||||
provider,
|
||||
messages,
|
||||
response,
|
||||
thinking,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
latency_ms,
|
||||
ttft_ms,
|
||||
max_inter_token_ms,
|
||||
int(cache_hit),
|
||||
error,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
self._INSERT_SQL,
|
||||
(
|
||||
call_id,
|
||||
parent_call_id,
|
||||
session_id,
|
||||
model_name,
|
||||
provider,
|
||||
messages,
|
||||
response,
|
||||
thinking,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
latency_ms,
|
||||
ttft_ms,
|
||||
max_inter_token_ms,
|
||||
int(cache_hit),
|
||||
error,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("遥测写入失败(已降级),call_id={}: {}", call_id, exc)
|
||||
|
||||
async def record_llm_call(
|
||||
self,
|
||||
|
||||
@@ -36,6 +36,7 @@ class GovernedVLMClient:
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse:
|
||||
"""图文调用:将图片编码为 base64 嵌入 messages,委托给 LLM 客户端。
|
||||
|
||||
@@ -45,6 +46,7 @@ class GovernedVLMClient:
|
||||
images: 图片文件路径列表。
|
||||
session_id: 会话 ID(遥测用)。
|
||||
parent_call_id: 父调用 ID(遥测用)。
|
||||
cache_salt: 可选缓存盐,透传到底层 LLM 缓存键。
|
||||
|
||||
返回:
|
||||
LLMResponse。
|
||||
@@ -54,6 +56,7 @@ class GovernedVLMClient:
|
||||
vision_messages,
|
||||
session_id=session_id,
|
||||
parent_call_id=parent_call_id,
|
||||
cache_salt=cache_salt,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""离线诊断编排:把 baseline run 的错题诊断投影为逐题信号行并断点续跑落库。
|
||||
|
||||
"结果驱动视频级切分"离线管线的诊断步。给定一批可诊断错题:
|
||||
1. 算 remaining(跳过 store 已完成题)实现续跑幂等;
|
||||
2. 对剩余错题调 core.evolution.diagnose.run_diagnosis(经 StepsJsonRunLog
|
||||
包装内层 RunLog,兼容 traces 未落表的历史 run);
|
||||
3. 把 error_attributions / infra / degraded 三类产物确定性投影为
|
||||
DiagnosisSignalRow(tier 由 split_selection.score_signal 判定);
|
||||
4. run 末(Phase 3)逐行 store.upsert 落库——诊断在 Phase 2 全部跑完后才落库,
|
||||
故崩溃丢本次 run 未落库的全部结果(不是"仅一行");靠 GovernedLLMClient 的
|
||||
Redis 缓存缓解重跑时的 LLM 重烧,下次调用命中缓存直接续。
|
||||
|
||||
错误处理诚实标注(不谎称全传播):
|
||||
- run_diagnosis 的 C1/C2 阶段(指标计算、错误归因)网络/API 失败经
|
||||
GovernedLLMClient 重试栈后仍失败会向上抛出,本编排不捕获、不掩盖,
|
||||
直接冒泡给调用方。
|
||||
- 但 C3 阶段(defect/lapse judge)的调用整体包在 `except Exception` 内
|
||||
(core/evolution/diagnose.py:2186),故 C3 judge 的**全部异常(含网络/API
|
||||
失败)都被吞并→warning→默认归为 lapse**,不会向上抛;judge 语义歧义同样
|
||||
按此保护性 fallback 处理。本编排原样接受该判定,不二次兜底、也不谎称
|
||||
C3 阶段网络失败会传播。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.baseline_run_log import StepsJsonRunLog
|
||||
from app.harness.split_selection import evolution_target_of, score_signal
|
||||
from core.evolution.diagnose import run_diagnosis
|
||||
from core.evolution.types import DiagnosisSignalRow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.evolution.protocols import DiagnosisSignalStore
|
||||
from core.evolution.types import DiagnosisResult
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosisDeps:
|
||||
"""离线诊断编排的依赖束(一次编排的全部外部端口 + 运行参数)。
|
||||
|
||||
frozen 保证一次编排内依赖不可变;LLM/RunLog/SkillStore/prompts 走 Protocol
|
||||
注入,便于测试替换成假实现。
|
||||
|
||||
属性:
|
||||
run_log: 内层 RunLog 实现(提供 get_predictions/get_traces),
|
||||
编排内部再用 StepsJsonRunLog 包装以兼容 traces 未落表的 run。
|
||||
llm: LLM 调用端口(治理后的 GovernedLLMClient)。
|
||||
skill_store: 技能文件读取端口。
|
||||
prompts: 诊断模板束(DiagnosePrompts)。
|
||||
tree_data: 树结构字典(多视频 {video_id: tree} 或单棵树),透传给 run_diagnosis。
|
||||
concurrency: 诊断并发上限。
|
||||
"""
|
||||
|
||||
run_log: Any
|
||||
llm: Any
|
||||
skill_store: Any
|
||||
prompts: Any
|
||||
tree_data: dict[str, Any]
|
||||
concurrency: int
|
||||
|
||||
|
||||
async def run_baseline_diagnosis(
|
||||
*,
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
wrong_ids: list[str],
|
||||
questions: dict[str, GeneratedQuestion],
|
||||
store: DiagnosisSignalStore,
|
||||
deps: DiagnosisDeps,
|
||||
retry_uncertain: bool = False,
|
||||
) -> None:
|
||||
"""对 baseline run 的错题跑离线诊断并把信号落库(断点续跑幂等)。
|
||||
|
||||
参数:
|
||||
baseline_run_id: baseline run 标识(如 "infer_adhoc"),信号行主键之一。
|
||||
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号,主键之一。
|
||||
wrong_ids: 本次待诊断的可诊断错题 question_id 列表(保序)。
|
||||
questions: question_id → GeneratedQuestion 映射,需覆盖 wrong_ids 全部题
|
||||
及 run_diagnosis 返回的所有 infra/degraded 题(用于取 video_id/task_type)。
|
||||
store: 诊断信号存储端口,upsert 落盘并提供 done_question_ids 续跑查询。
|
||||
deps: 外部依赖束(见 DiagnosisDeps)。
|
||||
retry_uncertain: True 时把已落 tier='uncertain'(信号不可信降级)的题也纳入
|
||||
remaining 重新诊断,透传给 store.done_question_ids;默认 False。
|
||||
|
||||
返回:
|
||||
None。副作用为把逐题 DiagnosisSignalRow 写入 store。
|
||||
|
||||
关键实现:
|
||||
- remaining = wrong_ids 去除 store 已完成题;空则直接 return(续跑幂等,
|
||||
重复调用零副作用)。
|
||||
- run_diagnosis 只诊断 remaining,避免重复 LLM 调用浪费。
|
||||
- 三类产物投影互斥落库:error_attributions(defect/lapse)、infra_question_ids
|
||||
(T0)、degraded_question_ids(uncertain)。
|
||||
"""
|
||||
# Phase 1: 算 remaining(续跑幂等)
|
||||
done = store.done_question_ids(
|
||||
baseline_run_id, diag_fingerprint, retry_uncertain=retry_uncertain
|
||||
)
|
||||
remaining = [qid for qid in wrong_ids if qid not in done]
|
||||
if not remaining:
|
||||
logger.info(
|
||||
"离线诊断续跑:baseline={} fingerprint={} 无剩余错题(已完成 {} 题),跳过。",
|
||||
baseline_run_id,
|
||||
diag_fingerprint,
|
||||
len(done),
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"离线诊断开始:baseline={} fingerprint={} 剩余 {}/{} 题待诊断。",
|
||||
baseline_run_id,
|
||||
diag_fingerprint,
|
||||
len(remaining),
|
||||
len(wrong_ids),
|
||||
)
|
||||
|
||||
# Phase 2: 对剩余错题跑诊断(StepsJsonRunLog 兼容 traces 未落表的历史 run)
|
||||
result = await run_diagnosis(
|
||||
baseline_run_id,
|
||||
[questions[qid] for qid in remaining],
|
||||
deps.tree_data,
|
||||
deps.llm,
|
||||
StepsJsonRunLog(deps.run_log),
|
||||
deps.skill_store,
|
||||
deps.prompts,
|
||||
concurrency=deps.concurrency,
|
||||
question_ids=list(remaining),
|
||||
only_incorrect=True,
|
||||
)
|
||||
|
||||
# Phase 3: 投影落库
|
||||
counts = _project_and_persist(
|
||||
result=result,
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
questions=questions,
|
||||
store=store,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"离线诊断落库完成:baseline={} fingerprint={} "
|
||||
"T2={} T1={} T0(infra)={} uncertain(degraded)={} 共 {} 行。",
|
||||
baseline_run_id,
|
||||
diag_fingerprint,
|
||||
counts["T2"],
|
||||
counts["T1"],
|
||||
counts["T0"],
|
||||
counts["uncertain"],
|
||||
sum(counts.values()),
|
||||
)
|
||||
|
||||
|
||||
def _project_and_persist(
|
||||
*,
|
||||
result: DiagnosisResult,
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
questions: dict[str, GeneratedQuestion],
|
||||
store: DiagnosisSignalStore,
|
||||
) -> dict[str, int]:
|
||||
"""把 DiagnosisResult 三类产物投影为信号行并逐行 upsert,返回各 tier 计数。
|
||||
|
||||
参数:
|
||||
result: run_diagnosis 的返回,含 error_attributions/infra/degraded 三类产物。
|
||||
baseline_run_id: 信号行主键之一。
|
||||
diag_fingerprint: 信号行主键之一。
|
||||
questions: question_id → GeneratedQuestion,用于取 video_id/task_type。
|
||||
store: 诊断信号存储端口。
|
||||
|
||||
返回:
|
||||
{tier: 行数} 计数字典(T2/T1/T0/uncertain),供上层日志与 manifest。
|
||||
|
||||
关键实现:
|
||||
本函数在 run 末(Phase 3)逐行 upsert(单行单事务);诊断已在 Phase 2 全部
|
||||
跑完,故本阶段中途崩溃丢本次 run 未落库的余下行。三桶**非互斥**:
|
||||
同一 degraded 错题可能同时出现在 error_attributions(judge 解析失败仍建
|
||||
attribution)里,故按 **degraded > infra > attribution** 优先级去重——先落
|
||||
degraded/infra,再在 attribution 循环跳过已落题,保证**每题恰写一行、
|
||||
counts 恰计一次**(否则同 PK 覆盖会导致 counts 双计且分层错乱)。
|
||||
"""
|
||||
counts = {"T2": 0, "T1": 0, "T0": 0, "uncertain": 0}
|
||||
# 优先级去重:degraded > infra > attribution。先记录高优先集合,
|
||||
# attribution 循环遇到已落题即跳过,确保每题唯一落库。
|
||||
persisted: set[str] = set()
|
||||
|
||||
# degraded_question_ids(最高优先):judge 解析失败降级 → uncertain,信号不可信排除出 T2
|
||||
for qid in result.degraded_question_ids:
|
||||
q = questions[qid]
|
||||
store.upsert(
|
||||
DiagnosisSignalRow(
|
||||
question_id=qid,
|
||||
video_id=q.video_id,
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
task_type=q.task_type,
|
||||
error_type=None,
|
||||
cause_category=None,
|
||||
tier="uncertain",
|
||||
evolution_target=None,
|
||||
degraded=True,
|
||||
infra=False,
|
||||
session_id=None,
|
||||
)
|
||||
)
|
||||
persisted.add(qid)
|
||||
counts["uncertain"] += 1
|
||||
|
||||
# infra_question_ids:基础设施失败护栏排除 → T0,不参与训练主体
|
||||
# (防御性跳过已落 degraded 题,虽 infra 通常已在诊断前过滤不重叠)
|
||||
for qid in result.infra_question_ids:
|
||||
if qid in persisted:
|
||||
continue
|
||||
q = questions[qid]
|
||||
store.upsert(
|
||||
DiagnosisSignalRow(
|
||||
question_id=qid,
|
||||
video_id=q.video_id,
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
task_type=q.task_type,
|
||||
error_type=None,
|
||||
cause_category=None,
|
||||
tier="T0",
|
||||
evolution_target=None,
|
||||
degraded=False,
|
||||
infra=True,
|
||||
session_id=None,
|
||||
)
|
||||
)
|
||||
persisted.add(qid)
|
||||
counts["T0"] += 1
|
||||
|
||||
# error_attributions(最低优先):defect→T2 / lapse→T1 / 其它→uncertain(由 score_signal 判定)
|
||||
# 跳过已作为 degraded/infra 落库的题,避免同 PK 覆盖与 counts 双计。
|
||||
for ea in result.error_attributions:
|
||||
if ea.question_id in persisted:
|
||||
continue
|
||||
q = questions[ea.question_id]
|
||||
tier = score_signal(cause_category=ea.cause_category, infra=False, degraded=False).tier
|
||||
# error_type 是 ErrorAttribution 必填字段(永远已知),确定性派生进化目标。
|
||||
evolution_target = evolution_target_of(ea.error_type)
|
||||
store.upsert(
|
||||
DiagnosisSignalRow(
|
||||
question_id=ea.question_id,
|
||||
video_id=q.video_id,
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
task_type=q.task_type,
|
||||
error_type=ea.error_type,
|
||||
cause_category=ea.cause_category,
|
||||
tier=tier,
|
||||
evolution_target=evolution_target,
|
||||
degraded=False,
|
||||
infra=False,
|
||||
session_id=None,
|
||||
)
|
||||
)
|
||||
persisted.add(ea.question_id)
|
||||
counts[tier] = counts.get(tier, 0) + 1
|
||||
|
||||
return counts
|
||||
@@ -0,0 +1,71 @@
|
||||
"""RunLog 包装器:traces 表空时从 predictions.steps_json 重建轨迹。
|
||||
|
||||
用于对 infer_adhoc 这类 traces 未落表、轨迹在 steps_json 的历史 run 跑离线诊断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.harness.steps_json_traces import steps_json_to_trace_rows
|
||||
|
||||
|
||||
class StepsJsonRunLog:
|
||||
"""委托内层 RunLog;get_traces 空表时回退 steps_json。
|
||||
|
||||
实现 core/evolution/protocols.py 的 RunLog Protocol(duck-typing)。
|
||||
对 traces 已落表的正常 run 完全透传;仅当底层 traces 为空时,
|
||||
才从 predictions.steps_json 经 steps_json_to_trace_rows 重建轨迹行。
|
||||
"""
|
||||
|
||||
def __init__(self, inner: Any) -> None:
|
||||
"""构造包装器。
|
||||
|
||||
参数:
|
||||
inner: 内层 RunLog 实现(如 app/harness/log.py::RunLogImpl),
|
||||
需提供 get_predictions / get_traces 两个 async 方法。
|
||||
"""
|
||||
self._inner = inner
|
||||
|
||||
async def get_predictions(
|
||||
self, run_id: str, *, question_ids: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""透传内层预测查询。
|
||||
|
||||
参数:
|
||||
run_id: 运行标识。
|
||||
question_ids: 可选的题目 ID 过滤列表。
|
||||
|
||||
返回:
|
||||
内层返回的预测记录字典列表,原样透传。
|
||||
"""
|
||||
return await self._inner.get_predictions(run_id, question_ids=question_ids)
|
||||
|
||||
async def get_traces(
|
||||
self, run_id: str, *, question_ids: list[str] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""查询轨迹;底层 traces 表空时从 steps_json 回退重建。
|
||||
|
||||
参数:
|
||||
run_id: 运行标识。
|
||||
question_ids: 可选的题目 ID 过滤列表。
|
||||
|
||||
返回:
|
||||
轨迹行字典列表。
|
||||
|
||||
关键实现细节:
|
||||
- 内层 traces 非空 → 原样返回,不触发回退(正常 run 路径)。
|
||||
- 内层 traces 为空 → 拉取同一过滤条件下的 predictions,
|
||||
逐题经 steps_json_to_trace_rows 展开为轨迹行并拼接。
|
||||
- steps_json 缺失时以空串传入,由下游确定性返回 []。
|
||||
"""
|
||||
inner_rows = await self._inner.get_traces(run_id, question_ids=question_ids)
|
||||
if inner_rows:
|
||||
return inner_rows
|
||||
preds = await self._inner.get_predictions(run_id, question_ids=question_ids)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for p in preds:
|
||||
rows.extend(
|
||||
steps_json_to_trace_rows(p["video_id"], p["question_id"], p.get("steps_json") or "")
|
||||
)
|
||||
return rows
|
||||
+214
-103
@@ -1,13 +1,37 @@
|
||||
"""混合 mini-batch 切分:大类打散、小类整锁,供 runner 每 step 处理一个 batch。"""
|
||||
"""混合 mini-batch 切分:以 QuestionUnit 为最小调度粒度,大类打散、小类整锁。
|
||||
|
||||
供 runner 每 step 处理一个 batch。孪生对(AR pair)作为 2 题单元整锁不拆、按单元级
|
||||
正确性分桶;非 AR single 单元的抽样/洗牌 draw 流与"引入 QuestionUnit 前"的旧逐题算法
|
||||
逐字节一致(AR 折叠不干扰非 AR draw 流)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.harness.question_units import build_units, flatten_units, unit_correctness
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.types import GeneratedQuestion
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
|
||||
def _rng_ns(seed: int, ns: str) -> random.Random:
|
||||
"""由 (seed, 命名空间) 稳定派生独立随机数发生器。
|
||||
|
||||
用 SHA-256 派生而非 Python 内置 ``hash()``——后者受 hash randomization 影响,
|
||||
跨进程不可复现。不同命名空间的 draw 流互不干扰,使 AR 单元折叠不扰动非 AR 抽样。
|
||||
|
||||
参数:
|
||||
seed: 实验随机种子。
|
||||
ns: 命名空间标签(如 "AR")。
|
||||
返回:
|
||||
以 SHA-256(f"{ns}:{seed}") 前 8 字节为种子的 ``random.Random``。
|
||||
"""
|
||||
digest = hashlib.sha256(f"{ns}:{seed}".encode()).digest()
|
||||
return random.Random(int.from_bytes(digest[:8], "big"))
|
||||
|
||||
|
||||
def build_batches(
|
||||
@@ -18,55 +42,59 @@ def build_batches(
|
||||
seed: int,
|
||||
correct_ratio: float = 0.0,
|
||||
) -> tuple[list[list[GeneratedQuestion]], int]:
|
||||
"""把诊断池里的题目切成多个混合 mini-batch。
|
||||
"""把诊断池里的题目切成多个混合 mini-batch(以 QuestionUnit 为原子调度单元)。
|
||||
|
||||
当 ``correct_ratio > 0`` 时,按题型为每组错题配比一定数量的正确题,使 batch
|
||||
包含正误混合样本("动量"机制);``correct_ratio <= 0`` 时退化为纯错题模式。
|
||||
single 题为 1 题单元,AR pair 孪生对为 2 题单元;同一 pair 的两题整锁进同一 batch,
|
||||
按单元级正确性(双向 AND)分桶。当 ``correct_ratio > 0`` 时,按题型为每组错误单元配比
|
||||
一定数量的正确单元("动量"机制);``correct_ratio <= 0`` 时退化为纯错误单元模式。
|
||||
|
||||
参数:
|
||||
items: 候选题目全集。
|
||||
items: 候选题目全集(可混含 single 与孪生对成员)。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
batch_size: 单个 batch 的样本数上限(> 0)。
|
||||
min_class_per_batch: 小类判定阈值——题目数 ≤ 此值的题型整组锁进单一
|
||||
batch_size: 单个 batch 的题目数上限(> 0,pair 占 2)。
|
||||
min_class_per_batch: 小类判定阈值——单元题目总数 ≤ 此值的题型整组锁进单一
|
||||
batch(> 0)。
|
||||
seed: 随机种子,保证相同输入产出完全一致的切分。
|
||||
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错题;0.5 = 错题:正确题 = 1:1。
|
||||
correct_ratio: 正确题占比(0.0 ~ 1.0)。0.0 = 纯错误单元;0.5 = 错:正 = 1:1。
|
||||
返回:
|
||||
(非空 mini-batch 列表, selected_count);无错题时返回 ([], 0)。
|
||||
selected_count 是所有 batch 中题目总数。
|
||||
(非空 mini-batch 列表, selected_count);无错误单元时返回 ([], 0)。
|
||||
selected_count 是所有 batch 中题目(展开后)总数。
|
||||
异常:
|
||||
ValueError: batch_size 或 min_class_per_batch < 1, 或
|
||||
min_class_per_batch >= batch_size(破坏小类整组装箱不超容的前提)。
|
||||
关键实现细节:
|
||||
装箱顺序为「先小类后大类」。小类整组用 first-fit-decreasing 装箱:按组大小
|
||||
降序处理(同大小再按 task_type 排序保证确定性),每组放进第一个剩余容量足够
|
||||
的 batch;若现有 batch 都装不下就新开一个空 batch——因小类组大小
|
||||
≤ min_class_per_batch < batch_size,新空 batch 必能容纳,故小类装箱永不抛
|
||||
ValueError,且保证整组不拆。再把大类样本(seed 确定性 shuffle 后)round-robin
|
||||
分发到所有现存 batch 填充剩余容量。这样小类聚集于单 batch、大类散布多 batch
|
||||
且与小类共箱,自然产生多类混合 batch(纯类切片会被 multiclass 断言拒绝)。
|
||||
nb = ceil(总题数/batch_size) 是初始 batch 数下界估计而非硬上限:小类装箱可能
|
||||
新开 bin 使实际 batch 数超过 nb。每次新开 bin 都意味着总容量随之增加,故总容量
|
||||
恒 ≥ 总题数,大类 round-robin 跳过满箱后仍能放下全部样本,不会违反 batch_size
|
||||
上限。题型按名称排序处理以保证跨运行确定性,不依赖 dict 遍历顺序。
|
||||
非 AR(single)与 AR(pair)各用独立稳定派生的 rng:非 AR 用 ``random.Random(seed)``
|
||||
(复现旧逐题算法的确切 draw 序列,保证纯非 AR 输入逐字节一致),AR 用
|
||||
``_rng_ns(seed, "AR")``;二者 draw 流互不干扰,故加入/移除 pair 不改变非 AR 的
|
||||
抽样/洗牌序列。抽样在合并前按流分别进行(``_select_mixed_by_task_type`` 各跑一次),
|
||||
大类洗牌按单元 kind 拆分后各用对应流。装箱顺序「先小类后大类」:小类整组
|
||||
first-fit-decreasing(容量按单元 ``size`` 计,pair 占 2)装入首个容得下的 batch,
|
||||
装不下新开 bin;大类洗牌后 round-robin 分发,遇碎片(size-2 单元放不进任一现存
|
||||
batch 的剩余容量)新开 bin 兜底而非报错。最终每个 batch 展开回题目列表。
|
||||
题型按名称排序处理以保证跨运行确定性。
|
||||
"""
|
||||
_validate_params(batch_size, min_class_per_batch)
|
||||
|
||||
rng = random.Random(seed)
|
||||
grouped = _select_mixed_by_task_type(items, correctness, correct_ratio, rng)
|
||||
total = sum(len(g) for g in grouped.values())
|
||||
# 非 AR 复现旧版 random.Random(seed) 的确切序列以满足黄金 byte-identity;
|
||||
# AR 走独立命名空间派生流,二者互不干扰。
|
||||
rng_nonar = random.Random(seed)
|
||||
rng_ar = _rng_ns(seed, "AR")
|
||||
|
||||
grouped = _group_units_by_task_type(items, correctness, correct_ratio, rng_nonar, rng_ar)
|
||||
|
||||
total = sum(_group_load(g) for g in grouped.values())
|
||||
if total == 0:
|
||||
return [], 0
|
||||
|
||||
nb = max(1, math.ceil(total / batch_size))
|
||||
batches: list[list[GeneratedQuestion]] = [[] for _ in range(nb)]
|
||||
batches: list[list[QuestionUnit]] = [[] for _ in range(nb)]
|
||||
|
||||
small, large = _split_by_size(grouped, min_class_per_batch)
|
||||
for group in _small_groups_decreasing(small):
|
||||
_pack_small_class(batches, group, batch_size)
|
||||
_distribute_large_classes(batches, large, batch_size, rng)
|
||||
_distribute_large_classes(batches, large, batch_size, rng_nonar, rng_ar)
|
||||
|
||||
result = [b for b in batches if b]
|
||||
result = [flatten_units(b) for b in batches if b]
|
||||
selected_count = sum(len(b) for b in result)
|
||||
return result, selected_count
|
||||
|
||||
@@ -74,7 +102,7 @@ def build_batches(
|
||||
def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
|
||||
"""校验切分参数,非法值直接报错而非用默认值掩盖。
|
||||
|
||||
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组大小 ≤
|
||||
除各自 >= 1 外,强制 min_class_per_batch < batch_size:小类组题目总数 ≤
|
||||
min_class_per_batch,唯有此前提成立才能保证小类整组放入单一 batch 而不超容;否则
|
||||
_pack_small_class 新开的 bin 会装入超 batch_size 的整组,静默违反容量合约。此约束
|
||||
与 config._validate_minibatch 一致,是 build_batches 对自身前提的防御性自校验(P5)。
|
||||
@@ -91,52 +119,129 @@ def _validate_params(batch_size: int, min_class_per_batch: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _split_by_size(
|
||||
grouped: dict[str, list[GeneratedQuestion]],
|
||||
min_class_per_batch: int,
|
||||
) -> tuple[dict[str, list[GeneratedQuestion]], dict[str, list[GeneratedQuestion]]]:
|
||||
"""按错题数把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
|
||||
small = {t: g for t, g in grouped.items() if len(g) <= min_class_per_batch}
|
||||
large = {t: g for t, g in grouped.items() if len(g) > min_class_per_batch}
|
||||
return small, large
|
||||
|
||||
|
||||
def _select_mixed_by_task_type(
|
||||
def _group_units_by_task_type(
|
||||
items: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
correct_ratio: float,
|
||||
rng: random.Random,
|
||||
) -> dict[str, list[GeneratedQuestion]]:
|
||||
"""按题型分组,为每组错题按比例采样正确题混入。
|
||||
|
||||
只对有错题的题型做混合——无错题的题型不进 batch,即使有正确题。
|
||||
``correct_ratio <= 0`` 时退化为纯错题模式(向后兼容)。
|
||||
rng_nonar: random.Random,
|
||||
rng_ar: random.Random,
|
||||
) -> dict[str, list[QuestionUnit]]:
|
||||
"""把题目聚合为单元并按题型分组:非 AR 与 AR 各走独立 draw 流后合并。
|
||||
|
||||
参数:
|
||||
items: 候选题目全集。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
correct_ratio: 正确题占比(0.0 ~ 1.0)。
|
||||
rng: 随机数发生器,用于采样正确题。
|
||||
correct_ratio: 正确题占比。
|
||||
rng_nonar: 非 AR(single 单元)抽样用 rng。
|
||||
rng_ar: AR(pair 单元)抽样用 rng。
|
||||
返回:
|
||||
task_type -> 该题型的混合题目列表(错题全部 + 按比例采样的正确题)。
|
||||
task_type -> 混合后的单元列表(single 单元在前、pair 单元在后)。
|
||||
"""
|
||||
errors_by_type: dict[str, list[GeneratedQuestion]] = {}
|
||||
correct_by_type: dict[str, list[GeneratedQuestion]] = {}
|
||||
for q in items:
|
||||
qid = q.question_id
|
||||
if correctness.get(qid) is False:
|
||||
errors_by_type.setdefault(q.task_type, []).append(q)
|
||||
elif correctness.get(qid, False):
|
||||
correct_by_type.setdefault(q.task_type, []).append(q)
|
||||
units = build_units(items)
|
||||
singles = [u for u in units if u.kind == "single"]
|
||||
pairs = [u for u in units if u.kind == "pair"]
|
||||
grouped_nonar = _select_mixed_by_task_type(singles, correctness, correct_ratio, rng_nonar)
|
||||
grouped_ar = _select_mixed_by_task_type(pairs, correctness, correct_ratio, rng_ar)
|
||||
return _merge_grouped(grouped_nonar, grouped_ar)
|
||||
|
||||
|
||||
def _group_load(group: list[QuestionUnit]) -> int:
|
||||
"""一组单元展开后的题目总数(single 计 1,pair 计 2),即占用的 batch 容量。"""
|
||||
return sum(u.size for u in group)
|
||||
|
||||
|
||||
def _batch_load(batch: list[QuestionUnit]) -> int:
|
||||
"""一个 batch 内单元展开后的题目总数,用于容量判断。"""
|
||||
return sum(u.size for u in batch)
|
||||
|
||||
|
||||
def _merge_grouped(
|
||||
grouped_nonar: dict[str, list[QuestionUnit]],
|
||||
grouped_ar: dict[str, list[QuestionUnit]],
|
||||
) -> dict[str, list[QuestionUnit]]:
|
||||
"""按 task_type 合并非 AR 与 AR 两条流的分组(single 在前、pair 在后)。
|
||||
|
||||
参数:
|
||||
grouped_nonar: 非 AR(single 单元)分组。
|
||||
grouped_ar: AR(pair 单元)分组。
|
||||
返回:
|
||||
task_type -> 合并后的单元列表;每类 single 单元在前、pair 单元在后,顺序稳定。
|
||||
"""
|
||||
merged: dict[str, list[QuestionUnit]] = {}
|
||||
for task_type in sorted({*grouped_nonar, *grouped_ar}):
|
||||
merged[task_type] = grouped_nonar.get(task_type, []) + grouped_ar.get(task_type, [])
|
||||
return merged
|
||||
|
||||
|
||||
def _split_by_size(
|
||||
grouped: dict[str, list[QuestionUnit]],
|
||||
min_class_per_batch: int,
|
||||
) -> tuple[dict[str, list[QuestionUnit]], dict[str, list[QuestionUnit]]]:
|
||||
"""按题目总数(单元展开)把题型分为小类(≤ 阈值)与大类(> 阈值)两组。"""
|
||||
small = {t: g for t, g in grouped.items() if _group_load(g) <= min_class_per_batch}
|
||||
large = {t: g for t, g in grouped.items() if _group_load(g) > min_class_per_batch}
|
||||
return small, large
|
||||
|
||||
|
||||
def _classify_unit(unit: QuestionUnit, correctness: dict[str, bool]) -> str | None:
|
||||
"""判定单元落入哪个桶:error / correct / None(未知,跳过)。
|
||||
|
||||
参数:
|
||||
unit: 目标单元。
|
||||
correctness: question_id -> 是否答对(缺键视为未知)。
|
||||
返回:
|
||||
"error"(单元级正确性为 False)、"correct"(双向 AND 为 True);单元内任一题
|
||||
未知(correctness 缺该键)返回 None,与旧逐题算法把未知题排除在错/对两桶之外
|
||||
的语义一致。
|
||||
关键实现:
|
||||
先探测是否有未知题(get 返回 None ⟺ 键缺失,因 correctness 值恒为 bool),
|
||||
全部已知后交由 unit_correctness 计双向 AND(此时 KeyError 不可达)。
|
||||
"""
|
||||
if any(correctness.get(q.question_id) is None for q in unit.questions):
|
||||
return None
|
||||
return "correct" if unit_correctness(unit, correctness) else "error"
|
||||
|
||||
|
||||
def _select_mixed_by_task_type(
|
||||
units: list[QuestionUnit],
|
||||
correctness: dict[str, bool],
|
||||
correct_ratio: float,
|
||||
rng: random.Random,
|
||||
) -> dict[str, list[QuestionUnit]]:
|
||||
"""按题型分组,为每组错误单元按比例采样正确单元混入(单元粒度)。
|
||||
|
||||
只对有错误单元的题型做混合——无错误单元的题型不进 batch,即使有正确单元。
|
||||
``correct_ratio <= 0`` 时退化为纯错误单元模式。本函数只处理单一 draw 流(全 single
|
||||
或全 pair),使非 AR 与 AR 的抽样互不干扰。
|
||||
|
||||
参数:
|
||||
units: 同一流的候选单元(全 single 或全 pair)。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
correct_ratio: 正确题占比(0.0 ~ 1.0)。
|
||||
rng: 本流专用随机数发生器,用于采样正确单元。
|
||||
返回:
|
||||
task_type -> 该题型的混合单元列表(错误单元全部 + 按比例采样的正确单元)。
|
||||
关键实现:
|
||||
n_correct 按错误单元「题目总数」而非单元数计,与旧逐题语义对齐(纯 single 时
|
||||
单元数 == 题目数,采样序列逐字节一致)。
|
||||
"""
|
||||
errors_by_type: dict[str, list[QuestionUnit]] = {}
|
||||
correct_by_type: dict[str, list[QuestionUnit]] = {}
|
||||
for unit in units:
|
||||
bucket = _classify_unit(unit, correctness)
|
||||
if bucket == "error":
|
||||
errors_by_type.setdefault(unit.task_type, []).append(unit)
|
||||
elif bucket == "correct":
|
||||
correct_by_type.setdefault(unit.task_type, []).append(unit)
|
||||
|
||||
if correct_ratio <= 0:
|
||||
return errors_by_type
|
||||
|
||||
# 为每个有错题的 task_type 混入正确题
|
||||
grouped: dict[str, list[GeneratedQuestion]] = {}
|
||||
grouped: dict[str, list[QuestionUnit]] = {}
|
||||
for task_type in sorted(errors_by_type):
|
||||
errs = errors_by_type[task_type]
|
||||
n_correct = round(len(errs) * correct_ratio / (1 - correct_ratio))
|
||||
n_err = _group_load(errs)
|
||||
n_correct = round(n_err * correct_ratio / (1 - correct_ratio))
|
||||
available = correct_by_type.get(task_type, [])
|
||||
sampled = (
|
||||
list(available) if len(available) <= n_correct else rng.sample(available, n_correct)
|
||||
@@ -147,94 +252,100 @@ def _select_mixed_by_task_type(
|
||||
|
||||
|
||||
def _small_groups_decreasing(
|
||||
small: dict[str, list[GeneratedQuestion]],
|
||||
) -> list[list[GeneratedQuestion]]:
|
||||
"""按组大小降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
|
||||
small: dict[str, list[QuestionUnit]],
|
||||
) -> list[list[QuestionUnit]]:
|
||||
"""按组题目总数降序、同大小按 task_type 升序排出小类组(first-fit-decreasing 顺序)。
|
||||
|
||||
参数:
|
||||
small: task_type -> 小类错题列表。
|
||||
small: task_type -> 小类单元列表。
|
||||
返回:
|
||||
排好序的小类组列表;降序处理可降低碎片,确定性 tie-break 保证跨运行一致。
|
||||
"""
|
||||
return [small[t] for t in sorted(small, key=lambda t: (-len(small[t]), t))]
|
||||
return [small[t] for t in sorted(small, key=lambda t: (-_group_load(small[t]), t))]
|
||||
|
||||
|
||||
def _pack_small_class(
|
||||
batches: list[list[GeneratedQuestion]],
|
||||
group: list[GeneratedQuestion],
|
||||
batches: list[list[QuestionUnit]],
|
||||
group: list[QuestionUnit],
|
||||
batch_size: int,
|
||||
) -> None:
|
||||
"""用 first-fit 把一个小类整组放入首个容得下的 batch,装不下则新开 bin(就地修改)。
|
||||
|
||||
因小类组大小 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
|
||||
故此函数永不抛 ValueError,且整组不拆。
|
||||
因小类组题目总数 ≤ min_class_per_batch < batch_size,新开的空 batch 必能容纳整组,
|
||||
故此函数永不抛 ValueError,且整组(含内部 pair 单元)不拆。
|
||||
|
||||
参数:
|
||||
batches: 当前各 batch(就地追加,必要时 append 新空 batch)。
|
||||
group: 待锁定的小类错题(整组不拆)。
|
||||
batch_size: 单 batch 容量上限。
|
||||
group: 待锁定的小类单元组(整组不拆)。
|
||||
batch_size: 单 batch 题目容量上限。
|
||||
"""
|
||||
load = _group_load(group)
|
||||
for b in batches:
|
||||
if len(b) + len(group) <= batch_size:
|
||||
if _batch_load(b) + load <= batch_size:
|
||||
b.extend(group)
|
||||
return
|
||||
batches.append(list(group))
|
||||
|
||||
|
||||
def _distribute_large_classes(
|
||||
batches: list[list[GeneratedQuestion]],
|
||||
large: dict[str, list[GeneratedQuestion]],
|
||||
batches: list[list[QuestionUnit]],
|
||||
large: dict[str, list[QuestionUnit]],
|
||||
batch_size: int,
|
||||
rng: random.Random,
|
||||
rng_nonar: random.Random,
|
||||
rng_ar: random.Random,
|
||||
) -> None:
|
||||
"""将各大类样本 shuffle 后 round-robin 分发到所有现存 batch(就地修改)。
|
||||
"""将各大类单元洗牌后 round-robin 分发到所有现存 batch(就地修改)。
|
||||
|
||||
参数:
|
||||
batches: 当前各 batch(含小类装箱可能新开的 bin,就地追加)。
|
||||
large: task_type -> 大类错题列表。
|
||||
batch_size: 单 batch 容量上限。
|
||||
rng: 复用的随机数发生器,保证 shuffle 确定性。
|
||||
异常:
|
||||
ValueError: 所有 batch 均满仍有样本未放置(总容量估算异常,合法输入不可达)。
|
||||
large: task_type -> 大类单元列表。
|
||||
batch_size: 单 batch 题目容量上限。
|
||||
rng_nonar: 非 AR(single 单元)洗牌用 rng。
|
||||
rng_ar: AR(pair 单元)洗牌用 rng。
|
||||
关键实现细节:
|
||||
轮转范围是「所有现存 batch」而非固定 nb 个——小类装箱新开的 bin 也参与分发。
|
||||
总容量 = 现存 batch 数 × batch_size,每次新开 bin 都同步抬高总容量,故总容量恒
|
||||
≥ 总错题数,防御性 ValueError 在合法输入下不可达。全局指针在所有大类样本间持续
|
||||
轮转(不为每类重置),满箱即跳过,使大类充分散布并与已锁定的小类共箱。题型按名称
|
||||
排序以保证分发顺序确定。
|
||||
每组按单元 kind 拆成 single 子列与 pair 子列,分别用 rng_nonar / rng_ar 洗牌后
|
||||
拼接(single 在前),使非 AR 洗牌 draw 流不受 pair 存在与否影响(纯 single 时
|
||||
single 子列即整组,复现旧版单一 rng.shuffle 的序列)。全局指针在所有大类单元间
|
||||
持续轮转,遇满箱跳过、遇碎片新开 bin。题型按名称排序以保证分发顺序确定。
|
||||
"""
|
||||
nb = len(batches)
|
||||
pointer = 0
|
||||
for task_type in sorted(large):
|
||||
group = list(large[task_type])
|
||||
rng.shuffle(group)
|
||||
for q in group:
|
||||
pointer = _place_round_robin(batches, q, pointer, batch_size, nb)
|
||||
group = large[task_type]
|
||||
singles = [u for u in group if u.kind == "single"]
|
||||
pairs = [u for u in group if u.kind == "pair"]
|
||||
rng_nonar.shuffle(singles)
|
||||
rng_ar.shuffle(pairs)
|
||||
for unit in singles + pairs:
|
||||
pointer = _place_round_robin(batches, unit, pointer, batch_size)
|
||||
|
||||
|
||||
def _place_round_robin(
|
||||
batches: list[list[GeneratedQuestion]],
|
||||
q: GeneratedQuestion,
|
||||
batches: list[list[QuestionUnit]],
|
||||
unit: QuestionUnit,
|
||||
pointer: int,
|
||||
batch_size: int,
|
||||
nb: int,
|
||||
) -> int:
|
||||
"""从 pointer 起找第一个未满 batch 放入 q,返回下一次起始指针。
|
||||
"""从 pointer 起找第一个容量够放 unit 的 batch 放入,返回下一次起始指针。
|
||||
|
||||
参数:
|
||||
batches: 当前各 batch(就地追加)。
|
||||
q: 待放置的样本。
|
||||
unit: 待放置的单元(占用 unit.size 个容量)。
|
||||
pointer: 本次轮转起始 batch 下标。
|
||||
batch_size: 单 batch 容量上限。
|
||||
nb: batch 总数。
|
||||
batch_size: 单 batch 题目容量上限。
|
||||
返回:
|
||||
下一次轮转的起始指针(已前移一位)。
|
||||
异常:
|
||||
ValueError: 扫描一轮所有 batch 均满(总容量估算异常)。
|
||||
关键实现:
|
||||
单个单元容量 ≤ batch_size 是前提(pair 占 2,而 batch_size > min_class ≥ 1 ⇒
|
||||
batch_size ≥ 2),故此处断言防御。扫描一轮所有现存 batch 都放不下(size-2 单元
|
||||
遇满地碎片)时新开 bin 兜底而非报错——聚合容量足够但单箱剩余不足是合法碎片场景。
|
||||
纯 single(size 1)永不触发新开分支,故与旧逐题 round-robin 逐字节一致。
|
||||
"""
|
||||
assert unit.size <= batch_size, f"单元 size={unit.size} 超过 batch_size={batch_size}"
|
||||
nb = len(batches)
|
||||
for offset in range(nb):
|
||||
idx = (pointer + offset) % nb
|
||||
if len(batches[idx]) < batch_size:
|
||||
batches[idx].append(q)
|
||||
if _batch_load(batches[idx]) + unit.size <= batch_size:
|
||||
batches[idx].append(unit)
|
||||
return (idx + 1) % nb
|
||||
raise ValueError("所有 batch 均满仍有样本待放置, 总容量估算异常")
|
||||
batches.append([unit])
|
||||
return len(batches) % len(batches)
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
"""结果驱动视频级切分的顶层编排:诊断信号 → 冻结 pools.json + manifest。
|
||||
|
||||
把已实现的组件串成 capstone 管线:从 harness.db 读 canonical 基线预测、从
|
||||
DiagnosisSignalStore 读逐题诊断信号,构建全视频画像、贪心联合约束选择 trainval /
|
||||
test,再以视频组为原子切出诊断 / 验证池,原子冻结 pools.json 并写溯源 manifest。
|
||||
全程带六条防御断言(P5,任一不满足即 fail-fast,绝不静默兜底)。
|
||||
|
||||
只有基线推理与诊断是上游产物;本模块纯 code-controlled,不发起任何 LLM 调用,
|
||||
读预测走只读连接,不改动 harness.db。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.pools import save_pools, split_by_video_assignment
|
||||
from app.harness.split_manifest import write_manifest
|
||||
from app.harness.split_selection import (
|
||||
SelectConfig,
|
||||
build_video_records,
|
||||
derive_reportable_types,
|
||||
select_split,
|
||||
)
|
||||
from app.question_gen.loader import load_benchmark
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from app.harness.pools import Pools
|
||||
from app.harness.split_selection import SplitAssignment, VideoRecord
|
||||
from core.evolution.protocols import DiagnosisSignalStore
|
||||
from core.evolution.types import DiagnosisSignalRow
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
# 多样性主格子 = 12 题型 × 4 错误类别 = 48 格,覆盖报告以此为分母。
|
||||
_DIVERSITY_GRID_TOTAL = 48
|
||||
_QUESTIONS_PER_VIDEO = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SplitBuildConfig:
|
||||
"""结果驱动切分的旋钮快照(科研配置,随实验扫动)。
|
||||
|
||||
承载贪心选择器与视频组题级切分的全部可扫参数;asdict 后直接写入 manifest 的
|
||||
config 快照,保证复现时可比对。floor_k 为不可哈希容器,标 hash=False 排除出
|
||||
自动 __hash__,避免 frozen dataclass 被哈希时报错(本类不作字典键,仅承载配置)。
|
||||
|
||||
字段:
|
||||
n_trainval: trainval 目标视频数(多样性阶段填充上限)。
|
||||
floor_k: 各高信号 task_type 的 T2 defect 下限(select_split 硬约束)。
|
||||
epsilon: test 相对全局的最大允许分布偏差(题型 / 难度两维)。
|
||||
report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束。
|
||||
select_seed: 贪心选择器预洗牌种子(打破等增益平局)。
|
||||
val_ratio: validation 占 trainval 视频组总数的比例。
|
||||
split_seed: 视频组题级切分的洗牌种子。
|
||||
val_wrong_min: validation 池最少错题数,切分时保证功效(不足则从 diag 换入
|
||||
低 T2 错题组补足,耗尽 fail-loud)。
|
||||
"""
|
||||
|
||||
n_trainval: int
|
||||
floor_k: dict[str, int] = field(hash=False)
|
||||
epsilon: float
|
||||
report_floor: int
|
||||
select_seed: int
|
||||
val_ratio: float
|
||||
split_seed: int
|
||||
val_wrong_min: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SplitBuildResult:
|
||||
"""build_split 的返回结果(冻结产物 + 溯源)。
|
||||
|
||||
字段:
|
||||
pools: 冻结的三池(diagnosis / validation / test)。
|
||||
manifest: 写入 split_manifest.json 的溯源字典(含 pools_sha256)。
|
||||
assignment: video_id -> "trainval" | "test" 归属字典。
|
||||
"""
|
||||
|
||||
pools: Pools
|
||||
manifest: dict
|
||||
assignment: dict[str, str]
|
||||
|
||||
def __getitem__(self, key: str) -> object:
|
||||
"""兼容字典式访问(result["pools"] / ["manifest"] / ["assignment"])。
|
||||
|
||||
参数:
|
||||
key: 字段名,取值 pools / manifest / assignment。
|
||||
|
||||
返回:
|
||||
对应字段值。
|
||||
|
||||
异常:
|
||||
KeyError: key 非上述三者之一。
|
||||
"""
|
||||
if key not in {"pools", "manifest", "assignment"}:
|
||||
raise KeyError(f"未知字段: {key}")
|
||||
return getattr(self, key)
|
||||
|
||||
|
||||
def _unique_backup_path(path: Path, suffix: str) -> Path:
|
||||
"""求 path 的唯一 .bak.<suffix> 备份路径,已存在则追加递增序号避免覆盖。
|
||||
|
||||
首选 ``<name>.bak.<suffix>``;若已存在,退化为 ``<name>.bak.<suffix>.2``、
|
||||
``.3`` … 直到找到不存在的名字。保证连续 forced freeze(同 suffix 或都缺
|
||||
manifest 用 'prev')不会静默覆盖此前保留的备份。
|
||||
|
||||
参数:
|
||||
path: 待备份的原文件路径。
|
||||
suffix: 备份后缀(旧 pools_sha256 前 8 位或 'prev')。
|
||||
|
||||
返回:
|
||||
目录内唯一、尚不存在的备份路径。
|
||||
"""
|
||||
candidate = path.with_name(f"{path.name}.bak.{suffix}")
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = path.with_name(f"{path.name}.bak.{suffix}.{counter}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _guard_frozen_products(out_path: Path, manifest_path: Path, *, force: bool) -> None:
|
||||
"""冻结前的覆盖保护:产物已存在时按 force 决定报错或备份。
|
||||
|
||||
参数:
|
||||
out_path: 目标 pools.json 路径。
|
||||
manifest_path: 目标 split_manifest.json 路径。
|
||||
force: False 时已存在即 FileExistsError;True 时把旧产物重命名为唯一的
|
||||
.bak.<旧 pools_sha256 前 8 位或 'prev'>(同名已存在则追加递增序号)再放行。
|
||||
|
||||
异常:
|
||||
FileExistsError: force=False 且产物已存在(防静默覆盖冻结锚点)。
|
||||
OSError: 备份 rename 失败;已备份的文件先 rollback 回原名再抛出,保证
|
||||
要么两文件都备份、要么都不动(原子性,不留半备份的不一致目录)。
|
||||
"""
|
||||
if not out_path.exists() and not manifest_path.exists():
|
||||
return
|
||||
if not force:
|
||||
raise FileExistsError(
|
||||
f"已存在冻结产物 {out_path}(或其 manifest)。重跑切分会覆盖训练依赖的"
|
||||
"冻结锚点——确认要替换请加 --force(旧产物将备份为 .bak.*)。"
|
||||
)
|
||||
# 备份后缀取旧 manifest 的 pools_sha256 前 8 位,无则用 'prev'
|
||||
suffix = "prev"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
old = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
suffix = str(old.get("pools_sha256", "prev"))[:8] or "prev"
|
||||
except (json.JSONDecodeError, OSError):
|
||||
suffix = "prev"
|
||||
# 先为每个存在的文件求唯一备份路径(互不冲突),再逐个 rename;
|
||||
# 中途失败则把已备份的 rollback 回原名,保证原子性。
|
||||
to_backup = [p for p in (out_path, manifest_path) if p.exists()]
|
||||
done: list[tuple[Path, Path]] = [] # (备份路径, 原路径),供 rollback
|
||||
try:
|
||||
for p in to_backup:
|
||||
dst = _unique_backup_path(p, suffix)
|
||||
p.rename(dst)
|
||||
done.append((dst, p))
|
||||
except OSError:
|
||||
for backup_path, original in reversed(done):
|
||||
backup_path.rename(original)
|
||||
raise
|
||||
|
||||
|
||||
def build_split(
|
||||
*,
|
||||
db_path: Path,
|
||||
baseline_run_id: str,
|
||||
signal_store: DiagnosisSignalStore,
|
||||
diag_fingerprint: str,
|
||||
questions_dir: Path,
|
||||
config: SplitBuildConfig,
|
||||
out_path: Path,
|
||||
manifest_path: Path,
|
||||
generated_at: str,
|
||||
force: bool = False,
|
||||
) -> SplitBuildResult:
|
||||
"""顶层编排结果驱动视频级切分,冻结 pools.json + manifest 并跑防御断言。
|
||||
|
||||
步骤:读 canonical 基线预测 → 读诊断信号 → 构建全视频画像 → 贪心选择 trainval /
|
||||
test → 加载题库并以视频归属切三池 → 原子冻结 pools.json → 写溯源 manifest →
|
||||
六条防御断言 fail-fast 校验。
|
||||
|
||||
契约(Task 11):val_wrong_min 前置到切分内保证功效——build_split 计算
|
||||
wrong_tier_by_video 并连同 config.val_wrong_min 传入 split_by_video_assignment,
|
||||
切分时若 val 错题不足即从 diag 换入低 T2 错题组补足(耗尽 fail-loud)。CLI 的
|
||||
check_mcnemar_power 作切分冻结后的冗余最终确认。
|
||||
|
||||
参数:
|
||||
db_path: harness.db 路径(只读读取 predictions,不改动)。
|
||||
baseline_run_id: 基线 run 标识(如 "infer_adhoc")。
|
||||
signal_store: 逐题诊断信号存储端口,读 (run, fingerprint) 下全部信号行。
|
||||
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号。
|
||||
questions_dir: benchmark 题库目录,加载 GeneratedQuestion。
|
||||
config: 切分旋钮快照。
|
||||
out_path: 冻结 pools.json 目标路径(原子写)。
|
||||
manifest_path: 溯源 manifest 目标路径(原子写)。
|
||||
generated_at: 生成时间戳(ISO 字符串),由调用方传入以保证可复现。
|
||||
force: 覆盖保护开关。False(默认)时若 out_path/manifest_path 已存在即
|
||||
FileExistsError(防静默覆盖训练依赖的冻结锚点);True 时先把旧产物备份为
|
||||
.bak.* 再放行覆盖。
|
||||
|
||||
返回:
|
||||
SplitBuildResult,含 pools / manifest / assignment,支持字典式访问。
|
||||
|
||||
异常:
|
||||
AssertionError: 六条防御断言任一不满足(fail-fast,不静默)。
|
||||
ValueError: 上游依赖校验失败(如 correctness 缺题、assignment 非法)。
|
||||
"""
|
||||
# Phase 1: canonical 基线预测 + 诊断信号。
|
||||
preds = load_canonical_predictions(db_path, baseline_run_id)
|
||||
signal_rows_raw = signal_store.load(baseline_run_id, diag_fingerprint)
|
||||
_assert_fingerprint_consistent(signal_rows_raw, diag_fingerprint)
|
||||
signal_rows = [
|
||||
{
|
||||
"question_id": row.question_id,
|
||||
"task_type": row.task_type,
|
||||
"error_type": row.error_type,
|
||||
"tier": row.tier,
|
||||
}
|
||||
for row in signal_rows_raw
|
||||
]
|
||||
|
||||
# Phase 2: 全视频画像 + 贪心联合约束选择。
|
||||
videos = build_video_records(preds, signal_rows)
|
||||
total_by_type = Counter(pred["task_type"] for pred in preds)
|
||||
reportable_types = derive_reportable_types(dict(total_by_type), config.report_floor)
|
||||
assignment_obj = select_split(
|
||||
videos,
|
||||
config=SelectConfig(
|
||||
n_trainval=config.n_trainval,
|
||||
floor_k=config.floor_k,
|
||||
epsilon=config.epsilon,
|
||||
reportable_types=reportable_types,
|
||||
seed=config.select_seed,
|
||||
),
|
||||
)
|
||||
assignment = _assignment_to_dict(assignment_obj)
|
||||
logger.info(
|
||||
"视频级切分完成: trainval={} test={} (总 {} 视频)",
|
||||
len(assignment_obj.trainval),
|
||||
len(assignment_obj.test),
|
||||
len(videos),
|
||||
)
|
||||
|
||||
# Phase 3: 加载题库 + 视频归属切三池 + 原子冻结。
|
||||
questions = load_benchmark(questions_dir)
|
||||
correctness = {pred["question_id"]: pred["correct"] for pred in preds}
|
||||
tier_by_q = {row["question_id"]: row["tier"] for row in signal_rows}
|
||||
wrong_tier_by_video: dict[str, int] = defaultdict(int)
|
||||
for pred in preds:
|
||||
if not pred["correct"] and tier_by_q.get(pred["question_id"]) == "T2":
|
||||
wrong_tier_by_video[pred["video_id"]] += 1
|
||||
pools = split_by_video_assignment(
|
||||
questions,
|
||||
assignment,
|
||||
correctness,
|
||||
config.val_ratio,
|
||||
config.split_seed,
|
||||
baseline_run_id=baseline_run_id,
|
||||
val_wrong_min=config.val_wrong_min,
|
||||
wrong_tier_by_video=dict(wrong_tier_by_video),
|
||||
)
|
||||
_guard_frozen_products(out_path, manifest_path, force=force)
|
||||
save_pools(pools, out_path)
|
||||
|
||||
# Phase 4: 溯源 manifest(pools_sha256 锚定冻结内容)。
|
||||
coverage_report = _build_coverage_report(
|
||||
videos, assignment_obj, signal_rows_raw, reportable_types, config
|
||||
)
|
||||
manifest = write_manifest(
|
||||
manifest_path,
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
seed=config.split_seed,
|
||||
config=asdict(config),
|
||||
pools_json_text=out_path.read_text(encoding="utf-8"),
|
||||
coverage_report=coverage_report,
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
# Phase 5: 防御断言 fail-fast。
|
||||
_assert_split_invariants(
|
||||
pools=pools,
|
||||
expected_question_ids={q.question_id for q in questions},
|
||||
signal_rows_raw=signal_rows_raw,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
out_path=out_path,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
return SplitBuildResult(pools=pools, manifest=manifest, assignment=assignment)
|
||||
|
||||
|
||||
def _normalize_choice(choice: str | None) -> str:
|
||||
"""选项归一:strip → 大写 → 取首字母,None 归一为空串。
|
||||
|
||||
参数:
|
||||
choice: 原始选项文本(预测或答案),可为 None。
|
||||
|
||||
返回:
|
||||
归一后的单字母(无内容时为空串)。
|
||||
"""
|
||||
return (choice or "").strip().upper()[:1]
|
||||
|
||||
|
||||
def load_canonical_predictions(db_path: Path, baseline_run_id: str) -> list[dict]:
|
||||
"""从 harness.db 只读取指定 run 每题首行(ORDER BY rowid)为 canonical 预测。
|
||||
|
||||
共享口径 helper:CLI(可诊断错题筛选 + INFRA T0 补录)与 build_split(切分)
|
||||
共用同一"每 qid 取 rowid 最小首行 + 归一化 correct 判定"口径,消除两处重复实现。
|
||||
同一 question_id 可能有多行(重跑 / 补测),canonical 口径取 rowid 最小的首行,
|
||||
保证 distinct question 计数与对错判定确定。correct = 预测与答案各自归一
|
||||
(strip → 大写 → 取首字母)后逐字符相等。
|
||||
|
||||
口径边界:旧 build_or_load_pools 的 legacy 池构建路径(app/harness/pools.py)是
|
||||
另一条独立既有链路,不共用本 helper,两者刻意不统一(本次不动 legacy 路径)。
|
||||
|
||||
参数:
|
||||
db_path: harness.db 路径(URI mode=ro 只读打开,绝不改动基线 db)。
|
||||
baseline_run_id: 基线 run 标识。
|
||||
|
||||
返回:
|
||||
canonical 预测行列表,每行含 question_id / video_id / task_type /
|
||||
prediction / answer / stop_reason / correct(bool)。按 rowid 升序去重,
|
||||
每 qid 保留首行。
|
||||
|
||||
异常:
|
||||
ValueError: 该 run 无任何预测行(fail-fast,不返回空切分)。
|
||||
|
||||
实现细节:
|
||||
按 rowid 升序遍历,首次见到的 question_id 即 canonical 行,后续同 qid 行跳过。
|
||||
"""
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT question_id, video_id, task_type, prediction, answer, stop_reason "
|
||||
"FROM predictions WHERE run_id = ? ORDER BY rowid",
|
||||
(baseline_run_id,),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
canonical: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
qid = row["question_id"]
|
||||
if qid in canonical:
|
||||
continue
|
||||
canonical[qid] = {
|
||||
"question_id": qid,
|
||||
"video_id": row["video_id"],
|
||||
"task_type": row["task_type"],
|
||||
"prediction": row["prediction"],
|
||||
"answer": row["answer"],
|
||||
"stop_reason": row["stop_reason"],
|
||||
"correct": _normalize_choice(row["prediction"]) == _normalize_choice(row["answer"]),
|
||||
}
|
||||
if not canonical:
|
||||
raise ValueError(f"run_id={baseline_run_id} 无任何预测行,无法切分")
|
||||
return list(canonical.values())
|
||||
|
||||
|
||||
def _assignment_to_dict(assignment_obj: SplitAssignment) -> dict[str, str]:
|
||||
"""把 SplitAssignment 展平为 video_id -> "trainval" | "test" 归属字典。
|
||||
|
||||
参数:
|
||||
assignment_obj: 贪心选择器产出的切分归属。
|
||||
|
||||
返回:
|
||||
全部视频的归属字典(trainval 与 test 并集,键互斥)。
|
||||
"""
|
||||
assignment = dict.fromkeys(assignment_obj.trainval, "trainval")
|
||||
for vid in assignment_obj.test:
|
||||
assignment[vid] = "test"
|
||||
return assignment
|
||||
|
||||
|
||||
def _assert_fingerprint_consistent(
|
||||
signal_rows_raw: list[DiagnosisSignalRow],
|
||||
diag_fingerprint: str,
|
||||
) -> None:
|
||||
"""防御④:全部诊断信号行的 diag_fingerprint 必须与传入指纹一致。
|
||||
|
||||
参数:
|
||||
signal_rows_raw: store 读回的诊断信号行。
|
||||
diag_fingerprint: 期望的诊断口径指纹。
|
||||
|
||||
异常:
|
||||
AssertionError: 存在指纹不一致的信号行(store 未正确按指纹过滤)。
|
||||
"""
|
||||
mismatched = [
|
||||
row.question_id for row in signal_rows_raw if row.diag_fingerprint != diag_fingerprint
|
||||
]
|
||||
if mismatched:
|
||||
raise AssertionError(
|
||||
f"诊断信号指纹不一致 {len(mismatched)} 行,期望 {diag_fingerprint}: {mismatched[:5]}"
|
||||
)
|
||||
|
||||
|
||||
def _fraction_by_type(records: list[VideoRecord], keys: set[str]) -> dict[str, float]:
|
||||
"""各 task_type 在给定视频集中的承载占比(含该题型的视频数 / 总视频数)。
|
||||
|
||||
参数:
|
||||
records: 视频记录子集。
|
||||
keys: 需计算占比的 task_type 键集。
|
||||
|
||||
返回:
|
||||
{task_type: 占比};records 为空时全部记 0.0。
|
||||
"""
|
||||
total = len(records)
|
||||
if total == 0:
|
||||
return dict.fromkeys(keys, 0.0)
|
||||
return {key: sum(1 for r in records if key in r.type_set) / total for key in keys}
|
||||
|
||||
|
||||
def _fraction_by_difficulty(records: list[VideoRecord], buckets: set[int]) -> dict[int, float]:
|
||||
"""各难度桶(错题数)在给定视频集中的占比。
|
||||
|
||||
参数:
|
||||
records: 视频记录子集。
|
||||
buckets: 难度桶键集。
|
||||
|
||||
返回:
|
||||
{难度桶: 占比};records 为空时全部记 0.0。
|
||||
"""
|
||||
total = len(records)
|
||||
if total == 0:
|
||||
return dict.fromkeys(buckets, 0.0)
|
||||
return {bucket: sum(1 for r in records if r.difficulty == bucket) / total for bucket in buckets}
|
||||
|
||||
|
||||
def _max_dev(global_dist: dict, subset_dist: dict, keys: set) -> float:
|
||||
"""逐键取全局与子集分布的最大绝对偏差(键集为空约定 0.0)。"""
|
||||
if not keys:
|
||||
return 0.0
|
||||
return max(abs(global_dist.get(k, 0.0) - subset_dist.get(k, 0.0)) for k in keys)
|
||||
|
||||
|
||||
def _build_coverage_report(
|
||||
videos: list[VideoRecord],
|
||||
assignment_obj: SplitAssignment,
|
||||
signal_rows_raw: list[DiagnosisSignalRow],
|
||||
reportable_types: set[str],
|
||||
config: SplitBuildConfig,
|
||||
) -> dict:
|
||||
"""组装 manifest 覆盖报告:48 格覆盖 / floor 达标 / test 代表性偏差 / tier 占比。
|
||||
|
||||
参数:
|
||||
videos: 全视频画像记录。
|
||||
assignment_obj: 切分归属(trainval / test)。
|
||||
signal_rows_raw: 诊断信号行(统计 tier 占比)。
|
||||
reportable_types: 参与 ε 代表性校验的题型集。
|
||||
config: 切分旋钮(floor_k / epsilon)。
|
||||
|
||||
返回:
|
||||
覆盖报告字典,含 cells_covered / grid_total / floor_satisfied /
|
||||
test_representativeness_deviation / tier_distribution /
|
||||
evolution_target_distribution。
|
||||
|
||||
实现细节:
|
||||
evolution_target_distribution 只统计 T2 信号(可训练缺陷),按
|
||||
tool / skill / system 计数,报告"哪层参数组拿到梯度";T0/T1/uncertain 行
|
||||
evolution_target 恒为 None,不入该分布。
|
||||
"""
|
||||
by_id = {v.video_id: v for v in videos}
|
||||
trainval = [by_id[vid] for vid in assignment_obj.trainval]
|
||||
test = [by_id[vid] for vid in assignment_obj.test]
|
||||
|
||||
covered_cells: set[tuple[str, str]] = set()
|
||||
trainval_wrong: Counter[str] = Counter()
|
||||
for video in trainval:
|
||||
covered_cells |= set(video.cells)
|
||||
trainval_wrong.update(video.wrong_by_type)
|
||||
floor_satisfied = {
|
||||
task_type: trainval_wrong.get(task_type, 0) >= floor
|
||||
for task_type, floor in config.floor_k.items()
|
||||
}
|
||||
|
||||
type_dev = _max_dev(
|
||||
_fraction_by_type(videos, reportable_types),
|
||||
_fraction_by_type(test, reportable_types),
|
||||
reportable_types,
|
||||
)
|
||||
diff_buckets = {r.difficulty for r in videos}
|
||||
diff_dev = _max_dev(
|
||||
_fraction_by_difficulty(videos, diff_buckets),
|
||||
_fraction_by_difficulty(test, diff_buckets),
|
||||
diff_buckets,
|
||||
)
|
||||
|
||||
tier_distribution, evolution_target_distribution = _signal_distributions(signal_rows_raw)
|
||||
|
||||
return {
|
||||
"cells_covered": len(covered_cells),
|
||||
"grid_total": _DIVERSITY_GRID_TOTAL,
|
||||
"floor_satisfied": floor_satisfied,
|
||||
"test_representativeness_deviation": {
|
||||
"type_max": type_dev,
|
||||
"difficulty_max": diff_dev,
|
||||
"epsilon": config.epsilon,
|
||||
},
|
||||
"tier_distribution": tier_distribution,
|
||||
"evolution_target_distribution": evolution_target_distribution,
|
||||
}
|
||||
|
||||
|
||||
def _signal_distributions(
|
||||
signal_rows_raw: list[DiagnosisSignalRow],
|
||||
) -> tuple[dict[str, float], dict[str, int]]:
|
||||
"""由诊断信号行算 tier 占比分布与 T2 进化目标计数分布。
|
||||
|
||||
参数:
|
||||
signal_rows_raw: 诊断信号行。
|
||||
|
||||
返回:
|
||||
(tier_distribution, evolution_target_distribution) 二元组:
|
||||
- tier_distribution: {tier: 占比},无信号时为空 dict;
|
||||
- evolution_target_distribution: 仅统计 T2(可训练缺陷)信号,按
|
||||
tool / skill / system 计数,报告哪层参数组拿到梯度;T0/T1/uncertain 行
|
||||
evolution_target 恒为 None,不入该分布。
|
||||
"""
|
||||
tier_counts = Counter(row.tier for row in signal_rows_raw)
|
||||
total_signals = sum(tier_counts.values())
|
||||
tier_distribution = (
|
||||
{tier: count / total_signals for tier, count in tier_counts.items()}
|
||||
if total_signals
|
||||
else {}
|
||||
)
|
||||
evolution_target_distribution = dict(
|
||||
Counter(
|
||||
row.evolution_target
|
||||
for row in signal_rows_raw
|
||||
if row.tier == "T2" and row.evolution_target is not None
|
||||
)
|
||||
)
|
||||
return tier_distribution, evolution_target_distribution
|
||||
|
||||
|
||||
def _assert_split_invariants(
|
||||
*,
|
||||
pools: Pools,
|
||||
expected_question_ids: set[str],
|
||||
signal_rows_raw: list[DiagnosisSignalRow],
|
||||
diag_fingerprint: str,
|
||||
out_path: Path,
|
||||
manifest: dict,
|
||||
) -> None:
|
||||
"""六条防御断言 fail-fast:任一不满足即 AssertionError(P5,不静默不兜底)。
|
||||
|
||||
① 三池视频集两两不相交;② 三池覆盖全部题(按 distinct question);
|
||||
③ 每 video 恰 3 题;④ 诊断信号指纹一致;⑤ manifest pools_sha256 == sha256(冻结内容);
|
||||
⑥ question_id 全局唯一(无重复行)。
|
||||
|
||||
参数:
|
||||
pools: 冻结的三池。
|
||||
expected_question_ids: 加载题库的 question_id 全集(覆盖基准)。
|
||||
signal_rows_raw: 诊断信号行(指纹校验)。
|
||||
diag_fingerprint: 期望诊断指纹。
|
||||
out_path: 冻结 pools.json 路径。
|
||||
manifest: 已写入的 manifest 字典。
|
||||
|
||||
异常:
|
||||
AssertionError: 任一防御断言不满足。
|
||||
"""
|
||||
all_questions = pools.diagnosis + pools.validation + pools.test
|
||||
_assert_pools_video_disjoint(pools) # ①
|
||||
_assert_question_ids_unique(all_questions) # ⑥
|
||||
_assert_question_coverage(all_questions, expected_question_ids) # ②
|
||||
_assert_three_questions_per_video(all_questions) # ③
|
||||
_assert_fingerprint_consistent(signal_rows_raw, diag_fingerprint) # ④
|
||||
_assert_pools_sha256(out_path, manifest) # ⑤
|
||||
|
||||
|
||||
def _assert_pools_video_disjoint(pools: Pools) -> None:
|
||||
"""防御①:diagnosis / validation / test 三池视频集两两不相交。"""
|
||||
diag_v = {q.video_id for q in pools.diagnosis}
|
||||
val_v = {q.video_id for q in pools.validation}
|
||||
test_v = {q.video_id for q in pools.test}
|
||||
if diag_v & val_v or diag_v & test_v or val_v & test_v:
|
||||
raise AssertionError(
|
||||
f"三池视频集非互斥: diag∩val={diag_v & val_v}, "
|
||||
f"diag∩test={diag_v & test_v}, val∩test={val_v & test_v}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_question_ids_unique(all_questions: list[GeneratedQuestion]) -> None:
|
||||
"""防御⑥:三池合并后 question_id 全局唯一(无重复行)。"""
|
||||
qids = [q.question_id for q in all_questions]
|
||||
if len(qids) != len(set(qids)):
|
||||
duplicates = [qid for qid, count in Counter(qids).items() if count > 1]
|
||||
raise AssertionError(f"question_id 重复 {len(duplicates)} 个: {duplicates[:5]}")
|
||||
|
||||
|
||||
def _assert_question_coverage(
|
||||
all_questions: list[GeneratedQuestion],
|
||||
expected_question_ids: set[str],
|
||||
) -> None:
|
||||
"""防御②:三池覆盖题库全部题(按 distinct question,缺题 / 多题均 fail-fast)。"""
|
||||
actual = {q.question_id for q in all_questions}
|
||||
if actual != expected_question_ids:
|
||||
missing = expected_question_ids - actual
|
||||
extra = actual - expected_question_ids
|
||||
raise AssertionError(
|
||||
f"三池题目覆盖不完整: 缺 {len(missing)} 多 {len(extra)} (缺样例 {sorted(missing)[:5]})"
|
||||
)
|
||||
|
||||
|
||||
def _assert_three_questions_per_video(all_questions: list[GeneratedQuestion]) -> None:
|
||||
"""防御③:每 video 恰 3 题(视频组原子切分不应劈裂视频的题)。"""
|
||||
per_video = Counter(q.video_id for q in all_questions)
|
||||
bad_videos = {vid: n for vid, n in per_video.items() if n != _QUESTIONS_PER_VIDEO}
|
||||
if bad_videos:
|
||||
raise AssertionError(
|
||||
f"存在 video 题数 != {_QUESTIONS_PER_VIDEO}: {dict(list(bad_videos.items())[:5])}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_pools_sha256(out_path: Path, manifest: dict) -> None:
|
||||
"""防御⑤:manifest 的 pools_sha256 == sha256(冻结 pools.json 内容)。"""
|
||||
actual_sha = hashlib.sha256(out_path.read_text(encoding="utf-8").encode("utf-8")).hexdigest()
|
||||
if actual_sha != manifest["pools_sha256"]:
|
||||
raise AssertionError(
|
||||
f"pools_sha256 不一致: manifest={manifest['pools_sha256']} 实际={actual_sha}"
|
||||
)
|
||||
@@ -41,6 +41,7 @@ _STRUCTURAL_KEYS = (
|
||||
"diag_size",
|
||||
"val_size",
|
||||
"batch_correct_ratio",
|
||||
"trainable_min_units",
|
||||
)
|
||||
|
||||
_DECISION_KEYS = (
|
||||
@@ -57,7 +58,6 @@ _DECISION_KEYS = (
|
||||
"gate_delta_min",
|
||||
"gate_lambda_dir",
|
||||
"gate_e_rollback",
|
||||
"gate_block",
|
||||
"gate_n_max",
|
||||
"gate_p_low",
|
||||
"gate_p_high",
|
||||
@@ -94,7 +94,7 @@ def serialize_state(state: Any) -> dict[str, Any]:
|
||||
"eval_prev_run_id": state.eval_prev_run_id,
|
||||
"baseline_skills_version": state.baseline_skills_version,
|
||||
"baseline_prompts_version": state.baseline_prompts_version,
|
||||
"steps_since_best_improved": state.steps_since_best_improved,
|
||||
"epochs_since_best_improved": state.epochs_since_best_improved,
|
||||
"epoch_start_skills": state.epoch_start_skills,
|
||||
"changed_task_types_this_epoch": sorted(state.changed_task_types_this_epoch),
|
||||
"rejected_buffer": {k: [asdict(x) for x in v] for k, v in state.rejected_buffer.items()},
|
||||
@@ -145,7 +145,7 @@ def deserialize_state_fields(d: dict[str, Any]) -> dict[str, Any]:
|
||||
"eval_prev_run_id": d["eval_prev_run_id"],
|
||||
"baseline_skills_version": d["baseline_skills_version"],
|
||||
"baseline_prompts_version": d["baseline_prompts_version"],
|
||||
"steps_since_best_improved": d["steps_since_best_improved"],
|
||||
"epochs_since_best_improved": d["epochs_since_best_improved"],
|
||||
"epoch_start_skills": d["epoch_start_skills"],
|
||||
"changed_task_types_this_epoch": set(d["changed_task_types_this_epoch"]),
|
||||
"rejected_buffer": {
|
||||
@@ -233,7 +233,8 @@ def write_checkpoint(
|
||||
global_step: 全局 step 序号。
|
||||
total_steps: 全局总 step 数。
|
||||
version_snapshot: skills/prompts 版本快照。
|
||||
epoch_batches: 本 epoch 的 batch 划分(question_id 列表的列表)。
|
||||
epoch_batches: 本 epoch 的 batch 划分(unit_id 列表的列表,孪生对折叠为
|
||||
单个 unit_id;纯非 AR 下 unit_id==question_id)。
|
||||
config: 训练配置对象,用于计算 config_fingerprint。
|
||||
|
||||
关键实现细节:
|
||||
|
||||
+44
-21
@@ -18,10 +18,11 @@ import yaml
|
||||
_VALID_MODES = {"infer", "train", "diagnose", "evolve", "eval", "promote"}
|
||||
_VALID_SKILL_MODES = {"auto", "manual", "none"}
|
||||
_VALID_SKILL_UPDATE_MODES = {"patch", "rewrite"}
|
||||
_VALID_POOL_SPLIT_MODES = {"global", "per_category"}
|
||||
_PATH_FIELDS = {"workspace_dir", "store_dir"}
|
||||
|
||||
# Video-MME 的任务类型数量:验证池每类至少保底 eval_min_per_class 题,共 11 类。
|
||||
_VIDEO_MME_TASK_TYPE_COUNT = 11
|
||||
# Video-MME 的任务类型数量:验证池每类至少保底 eval_min_per_class 题,共 12 类。
|
||||
_VIDEO_MME_TASK_TYPE_COUNT = 12
|
||||
|
||||
# .env 工程配置字段映射(环境变量名 → RunConfig 字段名)。
|
||||
# 仅路径类工程配置走 .env,科研实验参数走 YAML。
|
||||
@@ -59,6 +60,7 @@ class RunConfig:
|
||||
batch_size: mini-batch 单批题目数。
|
||||
min_class_per_batch: 单批中每个任务类型至少保留的题目数(< batch_size)。
|
||||
eval_min_per_class: 验证池中每个任务类型至少保底的题目数。
|
||||
trainable_min_units: 可训练性预检:每题型 diag+val 单元数下限,低于则剔除该题型。
|
||||
early_stop_patience: 全局 best 连续未提升的容忍轮数,达到即早停。
|
||||
test_size: held-out 测试池题目数。
|
||||
use_slow_momentum: 是否启用快慢双速进化中的慢速 momentum 更新。
|
||||
@@ -68,14 +70,13 @@ class RunConfig:
|
||||
gate_delta_min: 最小点估计效应量下限(承接旧 margin 语义)。
|
||||
gate_lambda_dir: Wald 方向拒绝的对数似然比阈值(必须为负)。
|
||||
gate_e_rollback: 试用期对称回滚门(回滚 e 值门槛)。
|
||||
gate_block: 块序贯验证的块大小(=推理并发度,块内跑满)。
|
||||
gate_n_max: 单次 gate 消耗的题数上限。
|
||||
gate_p_low: 信息量阶梯 p-hat 保留区间下界(剔除必错零信息题)。
|
||||
gate_p_high: 信息量阶梯 p-hat 保留区间上界(剔除必对零信息题)。
|
||||
gate_probe_quota: 冷启动探针集比例(全错题中插尾的比例)。
|
||||
gate_gamma_decay: 逐题正确率估计 p-hat 的 EMA 衰减系数。
|
||||
gate_cooldown_steps: 回滚后该题型跳过进化的冷却 step 数。
|
||||
gate_guard_err: gate 内跨块累计 INFRA 错误率护栏。
|
||||
gate_guard_err: gate 内累计 INFRA 错误率护栏。
|
||||
skill_update_mode: skill 进化模式,"patch"(局部 edit)/ "rewrite"(整篇重写)。
|
||||
appendix_consolidate_threshold: appendix note 条数达此值触发 LLM consolidation。
|
||||
run_id: diagnose/evolve 模式要分析的运行 ID,默认空字符串。
|
||||
@@ -85,6 +86,11 @@ class RunConfig:
|
||||
version: eval/promote 模式指定的 store 版本号(如 "v3")。
|
||||
resume: train 模式是否从已有 checkpoint 续训。
|
||||
fresh: train 模式是否从种子全新开始。
|
||||
task_types: 限定参与的任务类型子集,None 表示全部。
|
||||
pool_split_mode: 池划分策略,"global"(全局统一划分)/ "per_category"(按类别独立划分)。
|
||||
train_ratio: 训练集占比,范围 (0, 1)。
|
||||
test_questions: 测试题目集路径(相对路径)。
|
||||
run_holdout_eval: 是否在 epoch 末执行 held-out 四向评估,默认 True。
|
||||
"""
|
||||
|
||||
# ── 必填字段(无默认值,来自 YAML 或 CLI) ──
|
||||
@@ -108,6 +114,7 @@ class RunConfig:
|
||||
batch_size: int
|
||||
min_class_per_batch: int
|
||||
eval_min_per_class: int
|
||||
trainable_min_units: int
|
||||
early_stop_patience: int
|
||||
test_size: int
|
||||
use_slow_momentum: bool
|
||||
@@ -117,7 +124,6 @@ class RunConfig:
|
||||
gate_delta_min: float
|
||||
gate_lambda_dir: float
|
||||
gate_e_rollback: float
|
||||
gate_block: int
|
||||
gate_n_max: int
|
||||
gate_p_low: float
|
||||
gate_p_high: float
|
||||
@@ -136,6 +142,11 @@ class RunConfig:
|
||||
version: str = ""
|
||||
resume: bool = False
|
||||
fresh: bool = False
|
||||
task_types: tuple[str, ...] | None = None
|
||||
pool_split_mode: str = "global"
|
||||
train_ratio: float = 0.667
|
||||
test_questions: str = "benchmarks/Video-MME"
|
||||
run_holdout_eval: bool = True
|
||||
|
||||
|
||||
def _validate(config: RunConfig) -> None:
|
||||
@@ -236,6 +247,13 @@ def _validate_basic(config: RunConfig) -> None:
|
||||
f"appendix_consolidate_threshold 必须 >= 1,"
|
||||
f"实际: {config.appendix_consolidate_threshold}"
|
||||
)
|
||||
if config.pool_split_mode not in _VALID_POOL_SPLIT_MODES:
|
||||
raise ValueError(
|
||||
f"pool_split_mode 必须为 {_VALID_POOL_SPLIT_MODES} 之一,"
|
||||
f"实际: {config.pool_split_mode!r}"
|
||||
)
|
||||
if not (0 < config.train_ratio < 1):
|
||||
raise ValueError(f"train_ratio 必须在 (0, 1) 内,实际: {config.train_ratio}")
|
||||
|
||||
|
||||
def _validate_edit_budget(config: RunConfig) -> None:
|
||||
@@ -266,8 +284,9 @@ def _validate_minibatch(config: RunConfig) -> None:
|
||||
ValueError: 任一约束被违反。
|
||||
|
||||
关键实现细节:
|
||||
val_size 必须 >= eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT,保证验证池
|
||||
能为 Video-MME 的全部 11 个任务类型各保底 eval_min_per_class 题。
|
||||
pool_split_mode != "per_category" 时,val_size 必须 >= eval_min_per_class *
|
||||
_VIDEO_MME_TASK_TYPE_COUNT,保证验证池能为 Video-MME 的全部 12 个任务类型
|
||||
各保底 eval_min_per_class 题。per_category 模式下跳过此硬编码 12 类保底检查。
|
||||
"""
|
||||
if config.batch_size <= 0:
|
||||
raise ValueError(f"batch_size 必须 > 0,实际: {config.batch_size}")
|
||||
@@ -278,14 +297,17 @@ def _validate_minibatch(config: RunConfig) -> None:
|
||||
)
|
||||
if config.eval_min_per_class < 1:
|
||||
raise ValueError(f"eval_min_per_class 必须 >= 1,实际: {config.eval_min_per_class}")
|
||||
floor = config.eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT
|
||||
if config.val_size < floor:
|
||||
raise ValueError(
|
||||
f"val_size 必须 >= eval_min_per_class * {_VIDEO_MME_TASK_TYPE_COUNT}"
|
||||
f"(={floor}):Video-MME 共 {_VIDEO_MME_TASK_TYPE_COUNT} 个任务类型,"
|
||||
f"每类需 eval_min_per_class 题保底,故验证池下限为 {floor},"
|
||||
f"实际: {config.val_size}"
|
||||
)
|
||||
if config.trainable_min_units < 1:
|
||||
raise ValueError(f"trainable_min_units 必须 >= 1,实际: {config.trainable_min_units}")
|
||||
if config.pool_split_mode != "per_category":
|
||||
floor = config.eval_min_per_class * _VIDEO_MME_TASK_TYPE_COUNT
|
||||
if config.val_size < floor:
|
||||
raise ValueError(
|
||||
f"val_size 必须 >= eval_min_per_class * {_VIDEO_MME_TASK_TYPE_COUNT}"
|
||||
f"(={floor}):Video-MME 共 {_VIDEO_MME_TASK_TYPE_COUNT} 个任务类型,"
|
||||
f"每类需 eval_min_per_class 题保底,故验证池下限为 {floor},"
|
||||
f"实际: {config.val_size}"
|
||||
)
|
||||
if config.early_stop_patience <= 0:
|
||||
raise ValueError(f"early_stop_patience 必须 > 0,实际: {config.early_stop_patience}")
|
||||
if config.test_size <= 0:
|
||||
@@ -337,7 +359,7 @@ def _validate_gate_thresholds(config: RunConfig) -> None:
|
||||
|
||||
|
||||
def _validate_gate_ladder(config: RunConfig) -> None:
|
||||
"""校验 CE-Gate 信息量阶梯与块序贯参数。
|
||||
"""校验 CE-Gate 信息量阶梯参数。
|
||||
|
||||
参数:
|
||||
config: 待校验的配置实例。
|
||||
@@ -345,11 +367,8 @@ def _validate_gate_ladder(config: RunConfig) -> None:
|
||||
异常:
|
||||
ValueError: 任一阶梯参数不合法。
|
||||
"""
|
||||
if config.gate_block <= 0 or config.gate_n_max < config.gate_block:
|
||||
raise ValueError(
|
||||
f"需 0 < gate_block <= gate_n_max,"
|
||||
f"实际: block={config.gate_block}, n_max={config.gate_n_max}"
|
||||
)
|
||||
if config.gate_n_max <= 0:
|
||||
raise ValueError(f"需 gate_n_max > 0,实际: n_max={config.gate_n_max}")
|
||||
if not (0 <= config.gate_p_low < config.gate_p_high <= 1):
|
||||
raise ValueError(
|
||||
f"需 0 <= gate_p_low < gate_p_high <= 1,"
|
||||
@@ -421,6 +440,10 @@ def load_config(
|
||||
if field_name in yaml_data:
|
||||
yaml_data[field_name] = Path(yaml_data[field_name])
|
||||
|
||||
# Phase 4b: 类型转换 — task_types list → tuple
|
||||
if "task_types" in yaml_data and yaml_data["task_types"] is not None:
|
||||
yaml_data["task_types"] = tuple(yaml_data["task_types"])
|
||||
|
||||
# Phase 5: 构造并校验
|
||||
config = RunConfig(**{k: v for k, v in yaml_data.items() if k in valid_fields})
|
||||
_validate(config)
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""按 video_id 懒加载 InferenceDeps 并路由 dispatch/prompt_builder。
|
||||
|
||||
每个视频的 TreeIndex、TreeEnvironment、SkillRegistry 等重量级对象
|
||||
只在首次访问时构建并缓存,后续同视频的请求直接复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.factory import InferenceDeps, build_inference_deps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from app.ports import EmbeddingProvider, OCRProvider
|
||||
from core.protocols import LLMProvider, VLMProvider
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
|
||||
class InferenceDepsRouter:
|
||||
"""按 video_id 懒加载 InferenceDeps 并路由工具调度和 prompt 构建。
|
||||
|
||||
职责:
|
||||
1. 维护 question_id → video_id 的映射表(由 prompt_builder 自动注册)。
|
||||
2. 按 (video_id, skills_dir, prompts_dir) 三元组缓存 InferenceDeps。
|
||||
3. 提供 create_dispatch / create_prompt_builder 工厂方法,
|
||||
返回的闭包符合 ToolDispatchFn / PromptBuilderFn Protocol。
|
||||
|
||||
参数:
|
||||
store_dir: store 根目录。
|
||||
embed_provider: 嵌入端口实例。
|
||||
llm: LLM 端口实例。
|
||||
vlm: VLM 端口实例。
|
||||
ocr: OCR 端口实例(None 不启用)。
|
||||
default_prompts_dir: 默认 prompt 文件目录。
|
||||
default_skills_dir: 默认 skill 文件目录(None 则不加载 skill)。
|
||||
skill_mode: skill 模式("auto"/"manual"/"none")。
|
||||
verify_vision: observe_frame 是否执行验证轮。
|
||||
anchor: view_node 是否启用行号锚模式。
|
||||
assemble_mode: 锚模式装配形态。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store_dir: Path,
|
||||
embed_provider: EmbeddingProvider,
|
||||
llm: LLMProvider,
|
||||
vlm: VLMProvider,
|
||||
ocr: OCRProvider | None,
|
||||
default_prompts_dir: Path,
|
||||
default_skills_dir: Path | None,
|
||||
skill_mode: str,
|
||||
verify_vision: bool,
|
||||
anchor: bool,
|
||||
assemble_mode: str,
|
||||
) -> None:
|
||||
self._store_dir = store_dir
|
||||
self._embed = embed_provider
|
||||
self._llm = llm
|
||||
self._vlm = vlm
|
||||
self._ocr = ocr
|
||||
self._default_prompts_dir = default_prompts_dir
|
||||
self._default_skills_dir = default_skills_dir
|
||||
self._skill_mode = skill_mode
|
||||
self._verify_vision = verify_vision
|
||||
self._anchor = anchor
|
||||
self._assemble_mode = assemble_mode
|
||||
self._deps_cache: dict[tuple[str, str, str], InferenceDeps] = {}
|
||||
self._qid_to_vid: dict[str, str] = {}
|
||||
|
||||
def create_dispatch(self, *, skills_dir: Path | None = None) -> Any:
|
||||
"""创建工具调度闭包,按 session_id 路由到对应视频的 InferenceDeps。
|
||||
|
||||
参数:
|
||||
skills_dir: skill 文件目录覆盖(None 使用默认值)。
|
||||
|
||||
返回:
|
||||
符合 ToolDispatchFn 签名的 async 闭包。
|
||||
"""
|
||||
effective_skills = skills_dir or self._default_skills_dir
|
||||
|
||||
async def _dispatch(
|
||||
tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""按 session_id 查找视频 → 获取缓存 deps → 委托执行。"""
|
||||
session_id = context.get("session_id")
|
||||
if not session_id or session_id not in self._qid_to_vid:
|
||||
raise KeyError(
|
||||
f"未注册的 session_id={session_id!r},已注册 {len(self._qid_to_vid)} 条映射"
|
||||
)
|
||||
video_id = self._qid_to_vid[session_id]
|
||||
deps = self._ensure_deps(video_id, effective_skills, self._default_prompts_dir)
|
||||
return await deps.tool_dispatch_fn(tool_name, args, context=context)
|
||||
|
||||
return _dispatch
|
||||
|
||||
def create_prompt_builder(
|
||||
self,
|
||||
*,
|
||||
skills_dir: Path | None = None,
|
||||
prompts_dir: Path | None = None,
|
||||
) -> Any:
|
||||
"""创建 prompt 构建闭包,自动注册 qid→vid 映射并路由到对应视频的 deps。
|
||||
|
||||
参数:
|
||||
skills_dir: skill 文件目录覆盖(None 使用默认值)。
|
||||
prompts_dir: prompt 文件目录覆盖(None 使用默认值)。
|
||||
|
||||
返回:
|
||||
符合 PromptBuilderFn 签名的闭包。
|
||||
"""
|
||||
effective_skills = skills_dir or self._default_skills_dir
|
||||
effective_prompts = prompts_dir or self._default_prompts_dir
|
||||
|
||||
def _builder(qa: GeneratedQuestion) -> tuple[str, str]:
|
||||
"""注册 qid→vid 映射 → 获取缓存 deps → 委托构建 prompt。"""
|
||||
self._qid_to_vid[qa.question_id] = qa.video_id
|
||||
deps = self._ensure_deps(qa.video_id, effective_skills, effective_prompts)
|
||||
return deps.prompt_builder(qa)
|
||||
|
||||
return _builder
|
||||
|
||||
def _ensure_deps(
|
||||
self,
|
||||
video_id: str,
|
||||
skills_dir: Path | None,
|
||||
prompts_dir: Path,
|
||||
) -> InferenceDeps:
|
||||
"""按 (video_id, skills_dir, prompts_dir) 三元组缓存 InferenceDeps。
|
||||
|
||||
参数:
|
||||
video_id: 视频标识。
|
||||
skills_dir: skill 文件目录。
|
||||
prompts_dir: prompt 文件目录。
|
||||
|
||||
返回:
|
||||
缓存命中或新建的 InferenceDeps 实例。
|
||||
"""
|
||||
key = (video_id, str(skills_dir), str(prompts_dir))
|
||||
if key not in self._deps_cache:
|
||||
self._deps_cache[key] = self._build_deps(video_id, skills_dir, prompts_dir)
|
||||
logger.debug("InferenceDeps 已缓存: video_id={}", video_id)
|
||||
return self._deps_cache[key]
|
||||
|
||||
def _build_deps(
|
||||
self,
|
||||
video_id: str,
|
||||
skills_dir: Path | None,
|
||||
prompts_dir: Path,
|
||||
) -> InferenceDeps:
|
||||
"""调用 build_inference_deps 构建 InferenceDeps 实例。
|
||||
|
||||
参数:
|
||||
video_id: 视频标识。
|
||||
skills_dir: skill 文件目录。
|
||||
prompts_dir: prompt 文件目录。
|
||||
|
||||
返回:
|
||||
新建的 InferenceDeps 实例。
|
||||
"""
|
||||
return build_inference_deps(
|
||||
store_dir=self._store_dir,
|
||||
video_id=video_id,
|
||||
prompts_dir=prompts_dir,
|
||||
skills_dir=skills_dir,
|
||||
skill_mode=self._skill_mode,
|
||||
embed_provider=self._embed,
|
||||
llm=self._llm,
|
||||
vlm=self._vlm,
|
||||
ocr=self._ocr,
|
||||
verify_vision=self._verify_vision,
|
||||
anchor=self._anchor,
|
||||
assemble_mode=self._assemble_mode,
|
||||
)
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""清空 deps 缓存和 qid→vid 映射表。"""
|
||||
self._deps_cache.clear()
|
||||
self._qid_to_vid.clear()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""推理依赖工厂 — 组装一次推理所需的全套依赖。
|
||||
|
||||
将 TreeIndex 加载、TreeEnvironment 构建、SkillRegistry 发现、
|
||||
SearchToolDispatcher 装配、PromptManager 初始化等步骤封装为
|
||||
单一工厂函数 ``build_inference_deps``,返回不可变的 ``InferenceDeps``。
|
||||
|
||||
调用方(runner / inference)只需传入配置参数,无需了解内部装配逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.search.prompt import PromptManager
|
||||
from app.search.skills import discover_skills
|
||||
from app.search.tools import SearchToolDispatcher
|
||||
from app.tree.environment import TreeEnvironment
|
||||
from app.tree.index import TreeIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from app.ports import EmbeddingProvider, OCRProvider
|
||||
from core.protocols import LLMProvider, VLMProvider
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferenceDeps:
|
||||
"""跑一次推理所需的全套依赖(不含 HarnessLog,其生命周期由调用方管理)。
|
||||
|
||||
属性:
|
||||
llm: LLM 端口实例。
|
||||
tool_dispatch_fn: SearchToolDispatcher.dispatch 的绑定方法。
|
||||
prompt_builder: (GeneratedQuestion) -> (system_prompt, user_prompt)。
|
||||
"""
|
||||
|
||||
llm: LLMProvider
|
||||
tool_dispatch_fn: Callable[..., Any]
|
||||
prompt_builder: Callable[[GeneratedQuestion], tuple[str, str]]
|
||||
|
||||
|
||||
def build_inference_deps(
|
||||
*,
|
||||
store_dir: Path,
|
||||
video_id: str,
|
||||
prompts_dir: Path,
|
||||
skills_dir: Path | None,
|
||||
skill_mode: str,
|
||||
embed_provider: EmbeddingProvider,
|
||||
llm: LLMProvider,
|
||||
vlm: VLMProvider,
|
||||
ocr: OCRProvider | None,
|
||||
verify_vision: bool,
|
||||
anchor: bool,
|
||||
assemble_mode: str,
|
||||
) -> InferenceDeps:
|
||||
"""组装一次推理所需的全套依赖。
|
||||
|
||||
参数:
|
||||
store_dir: store 根目录(包含 videos/{video_id}/tree.json)。
|
||||
video_id: 视频标识。
|
||||
prompts_dir: prompt 文件目录。
|
||||
skills_dir: skill 文件目录(None 则不加载 skill)。
|
||||
skill_mode: skill 模式("auto"/"manual"/"none")。
|
||||
embed_provider: 嵌入端口实例。
|
||||
llm: LLM 端口实例。
|
||||
vlm: VLM 端口实例。
|
||||
ocr: OCR 端口实例(None 不启用)。
|
||||
verify_vision: observe_frame 是否执行验证轮。
|
||||
anchor: view_node 是否启用行号锚模式。
|
||||
assemble_mode: 锚模式装配形态。
|
||||
|
||||
返回:
|
||||
InferenceDeps 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: tree.json 不存在。
|
||||
"""
|
||||
# Phase 1: 加载 TreeIndex
|
||||
tree_path = store_dir / "videos" / video_id / "tree.json"
|
||||
if not tree_path.exists():
|
||||
raise FileNotFoundError(f"树索引文件不存在: {tree_path}")
|
||||
tree_index = TreeIndex.load_json(str(tree_path))
|
||||
logger.info("已加载 TreeIndex: video_id={}, L1 节点数={}", video_id, len(tree_index.roots))
|
||||
|
||||
# Phase 2: 构建 TreeEnvironment
|
||||
frames_dir = store_dir / "videos" / video_id / "frames"
|
||||
env = TreeEnvironment(index=tree_index, frames_dir=frames_dir)
|
||||
|
||||
# Phase 3: 构建 SkillRegistry
|
||||
skills = None
|
||||
always_skills_text = ""
|
||||
task_skill_map: dict[str, str] = {}
|
||||
catalog_text = ""
|
||||
if skills_dir is not None:
|
||||
always_skills_text, task_skill_map, catalog_text, skills = discover_skills(skills_dir)
|
||||
logger.info(
|
||||
"已发现 skills: always={} 字符, task_map={} 项",
|
||||
len(always_skills_text),
|
||||
len(task_skill_map),
|
||||
)
|
||||
|
||||
# Phase 4: 构建 SearchToolDispatcher
|
||||
dispatcher = SearchToolDispatcher(
|
||||
env,
|
||||
tool_llm=llm,
|
||||
vlm=vlm,
|
||||
ocr=ocr,
|
||||
prompts_dir=prompts_dir,
|
||||
skills=skills,
|
||||
embed_fn=embed_provider.embed,
|
||||
verify_vision=verify_vision,
|
||||
anchor=anchor,
|
||||
assemble_mode=assemble_mode,
|
||||
)
|
||||
|
||||
# Phase 5: 构建 PromptManager + _prompt_builder 闭包
|
||||
pm = PromptManager(prompts_dir)
|
||||
l1_ids = [root.id for root in tree_index.roots]
|
||||
|
||||
def _prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]:
|
||||
"""为单条题目生成 (system_prompt, user_prompt)。
|
||||
|
||||
参数:
|
||||
qa: 生成的题目实例。
|
||||
|
||||
返回:
|
||||
(system_prompt, user_prompt) 二元组。
|
||||
"""
|
||||
system = pm.build_inference_prompt(
|
||||
skill_mode,
|
||||
qa.task_type,
|
||||
always_skills_text,
|
||||
task_skill_map,
|
||||
catalog_text,
|
||||
)
|
||||
user = pm.format_user_prompt(
|
||||
qa.question,
|
||||
list(qa.options),
|
||||
l1_ids,
|
||||
qa.task_type,
|
||||
)
|
||||
return system, user
|
||||
|
||||
logger.info("InferenceDeps 组装完成: video_id={}, skill_mode={}", video_id, skill_mode)
|
||||
return InferenceDeps(
|
||||
llm=llm,
|
||||
tool_dispatch_fn=dispatcher.dispatch,
|
||||
prompt_builder=_prompt_builder,
|
||||
)
|
||||
+132
-63
@@ -1,13 +1,18 @@
|
||||
"""CE-Gate 信息量阶梯与基线缓存。
|
||||
"""CE-Gate 信息量阶梯与基线缓存(unit 粒度,核心算法保真 #5)。
|
||||
|
||||
阶梯(每题型一条):gate 的出题顺序表。冷启动(FRESH)用种子基线对错
|
||||
两档粗排(错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾);
|
||||
epoch >=1 用非 gate run 观测做 gamma-EMA 更新 p_hat,按信息量 p_hat(1-p_hat) 降序、
|
||||
剔 p_hat 不在 [p_low, p_high]。防泄露铁律:gate 内 rollout 永不回流 p_hat
|
||||
(调用方以 run_id 含 "_gate_" 过滤观测源)。
|
||||
阶梯(每题型一条):gate 的出题顺序表,键为 **unit_id**(single 题 unit_id
|
||||
等于 question_id,AR pair 折叠为一个单元、unit_id 等于共享 pair_id)。冷启动
|
||||
(FRESH)用种子基线的**单元级**对错两档粗排(错 unit 高优先 2:1 交错 + 全错
|
||||
unit 的 probe_quota 探针插尾);epoch >=1 用非 gate run 观测**折叠成单元观测**后做
|
||||
gamma-EMA 更新 p_hat,按信息量 p_hat(1-p_hat) 降序、剔 p_hat 不在 [p_low, p_high]。
|
||||
单元错 = 该单元任一成员错(AR pair 双向 AND)。防泄露铁律:gate 内 rollout 永不
|
||||
回流 p_hat(调用方以 run_id 含 "_gate_" 过滤观测源),本迁移不改此过滤。
|
||||
|
||||
BaselineCache:基线侧逐题对错缓存,键 = (task_type, skill_hash,
|
||||
prompts_version, qid) 内容寻址、无显式失效。JSON 持久化到 workspace,
|
||||
持久化门控:gate_pools.json 带 schema_version(当前 = 2,unit 键)。旧版无
|
||||
schema_version(v1、qid 键)加载时**直接报错**,拒绝静默混用 qid/unit 键。
|
||||
|
||||
BaselineCache:基线侧单元级对错缓存,键 = (task_type, skill_hash,
|
||||
prompts_version, unit_id) 内容寻址、无显式失效。JSON 持久化到 workspace,
|
||||
供 resume 后合法复用已冻结阶梯上的新鲜 draw。
|
||||
"""
|
||||
|
||||
@@ -22,10 +27,16 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.question_units import build_units, unit_correctness
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from core.types import GeneratedQuestion
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
# gate_pools.json 结构版本。v1(隐式、无此字段)为逐题 qid 键的存量格式;
|
||||
# v2 起改为 unit_id 键。load 时严格校验,不匹配即报错(不静默迁移/混用)。
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def skill_hash(content: str) -> str:
|
||||
@@ -42,50 +53,52 @@ def skill_hash(content: str) -> str:
|
||||
|
||||
@dataclass
|
||||
class LadderEntry:
|
||||
"""阶梯单元:题目与其估计答对率。
|
||||
"""阶梯单元:题目单元与其估计答对率。
|
||||
|
||||
字段:
|
||||
question_id: 题目唯一标识。
|
||||
unit_id: 单元唯一标识(single 等于 question_id,AR pair 等于共享 pair_id)。
|
||||
p_hat: 估计答对率。冷启动为 Beta(1,1) 平滑的单次观测后验均值
|
||||
(错=1/3、对=2/3),此后经 gamma-EMA 更新。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
unit_id: str
|
||||
p_hat: float
|
||||
|
||||
|
||||
def build_cold_entries(
|
||||
questions: list[GeneratedQuestion],
|
||||
units: list[QuestionUnit],
|
||||
correctness: dict[str, bool],
|
||||
probe_quota: float,
|
||||
seed: int,
|
||||
) -> list[LadderEntry]:
|
||||
"""冷启动排序:错题高优先 2:1 交错 + 全错题 probe_quota 探针插尾。
|
||||
"""冷启动排序(unit 粒度):错 unit 高优先 2:1 交错 + 全错 unit 探针插尾。
|
||||
|
||||
参数:
|
||||
questions: 该题型的全部候选题(已排除 test 池)。
|
||||
correctness: question_id -> 种子基线是否答对(900 题全量对错)。
|
||||
probe_quota: 从错题中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
|
||||
units: 该题型的全部候选单元(已排除 test 池;AR pair 已折叠成单元)。
|
||||
correctness: question_id -> 种子基线是否答对(900 题全量逐题对错)。
|
||||
单元级对错由 unit_correctness(strict=False) 折叠(任一成员错 → 单元错)。
|
||||
probe_quota: 从错 unit 中随机抽出插到梯尾的探针比例(防"解锁新能力"盲区)。
|
||||
seed: 洗牌种子,保证确定性重建。
|
||||
|
||||
返回:
|
||||
排序后的 LadderEntry 列表(p_hat 用 Beta(1,1) 平滑:错=1/3、对=2/3,
|
||||
与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
|
||||
排序后的 LadderEntry 列表(键=unit_id;p_hat 用 Beta(1,1) 平滑:错=1/3、
|
||||
对=2/3,与 warm 阶段 gamma-EMA / 信息量排序自然衔接)。
|
||||
|
||||
关键实现细节:
|
||||
错题、对题各自固定种子洗牌 -> 抽探针 -> 剩余按 错错对 2:1 交错
|
||||
(一方耗尽后顺排另一方)-> 探针追加尾部。
|
||||
与逐题版**同公式、同比例、同顺序**,仅把调度粒度从题换成单元:错 unit、
|
||||
对 unit 各自固定种子洗牌 -> 按 probe_quota 从错 unit 抽探针 -> 剩余按
|
||||
错错对 2:1 交错(一方耗尽后顺排另一方)-> 探针追加尾部。
|
||||
"""
|
||||
rng = random.Random(seed)
|
||||
wrong = [q for q in questions if not correctness.get(q.question_id, False)]
|
||||
right = [q for q in questions if correctness.get(q.question_id, False)]
|
||||
wrong = [u for u in units if not unit_correctness(u, correctness, strict=False)]
|
||||
right = [u for u in units if unit_correctness(u, correctness, strict=False)]
|
||||
rng.shuffle(wrong)
|
||||
rng.shuffle(right)
|
||||
|
||||
n_probe = int(len(wrong) * probe_quota)
|
||||
probes, wrong_main = wrong[:n_probe], wrong[n_probe:]
|
||||
|
||||
interleaved: list[GeneratedQuestion] = []
|
||||
interleaved: list[QuestionUnit] = []
|
||||
wi, ri = 0, 0
|
||||
while wi < len(wrong_main) or ri < len(right):
|
||||
for _ in range(2):
|
||||
@@ -97,10 +110,10 @@ def build_cold_entries(
|
||||
ri += 1
|
||||
interleaved.extend(probes)
|
||||
|
||||
def _p0(q: GeneratedQuestion) -> float:
|
||||
return 2 / 3 if correctness.get(q.question_id, False) else 1 / 3
|
||||
def _p0(u: QuestionUnit) -> float:
|
||||
return 2 / 3 if unit_correctness(u, correctness, strict=False) else 1 / 3
|
||||
|
||||
return [LadderEntry(q.question_id, _p0(q)) for q in interleaved]
|
||||
return [LadderEntry(u.unit_id, _p0(u)) for u in interleaved]
|
||||
|
||||
|
||||
def order_ladder(entries: list[LadderEntry], p_low: float, p_high: float) -> list[LadderEntry]:
|
||||
@@ -135,23 +148,24 @@ class GatePools:
|
||||
def ladder_for(
|
||||
self,
|
||||
task_type: str,
|
||||
exclude_qids: set[str],
|
||||
exclude_units: set[str],
|
||||
p_low: float,
|
||||
p_high: float,
|
||||
cold: bool,
|
||||
) -> list[str]:
|
||||
"""取该题型的 gate 出题序(qid 列表),排除本 step 进化案例包题。
|
||||
"""取该题型的 gate 出题序(unit_id 列表),排除本 step 进化案例包所在单元。
|
||||
|
||||
参数:
|
||||
task_type: 目标题型。
|
||||
exclude_qids: 本 step 案例包(failure/success cases)的题目 id,
|
||||
防止在"刚学的那道题"上自测。
|
||||
exclude_units: 本 step 案例包(failure/success cases)所在单元的
|
||||
unit_id,防止在"刚学的那道题"上自测。按 **unit** 排除:命中单元
|
||||
整体剔除,避免只排 AR pair 半个成员而向 gate 池灌入半个 pair。
|
||||
p_low / p_high: warm 阶段的 p_hat 保留区间。
|
||||
cold: True 表示尚无 epoch 级观测(epoch 1),用冷启动存储序;
|
||||
False 走 order_ladder 信息量排序。
|
||||
|
||||
返回:
|
||||
排除后的有序 question_id 列表。
|
||||
排除后的有序 unit_id 列表。
|
||||
|
||||
异常:
|
||||
ValueError: 该题型无阶梯(冷启动构建缺失),或该题型阶梯为空。
|
||||
@@ -162,33 +176,54 @@ class GatePools:
|
||||
if not pool:
|
||||
raise ValueError(f"task_type={task_type} 阶梯为空,无可出题目")
|
||||
ordered = pool if cold else order_ladder(pool, p_low, p_high)
|
||||
return [e.question_id for e in ordered if e.question_id not in exclude_qids]
|
||||
return [e.unit_id for e in ordered if e.unit_id not in exclude_units]
|
||||
|
||||
def update_probs(self, observations: dict[str, bool], gamma: float) -> None:
|
||||
"""gamma-EMA 更新 p_hat:p_hat <- gamma * p_hat + (1-gamma) * obs。只更新有新观测的题。
|
||||
def update_probs(
|
||||
self,
|
||||
per_q_observations: dict[str, bool],
|
||||
units_by_id: dict[str, QuestionUnit],
|
||||
gamma: float,
|
||||
) -> None:
|
||||
"""gamma-EMA 更新 p_hat:先把逐题观测折叠成单元观测,再按 unit_id 匹配更新。
|
||||
|
||||
p_hat <- gamma * p_hat + (1-gamma) * unit_obs。只更新"整个单元都被观测到"
|
||||
的单元;单元观测 = 成员逐题对错的 AND(任一成员错 → 单元错)。折叠是必需的:
|
||||
AR pair 的 unit_id 是 pair_id,若直接按 unit_id 去逐题观测里匹配将永不命中、
|
||||
导致 gamma-EMA 停摆(核心算法保真 #5)。
|
||||
|
||||
参数:
|
||||
observations: question_id -> 本 epoch 非 gate run 的最新对错。
|
||||
per_q_observations: question_id -> 本 epoch 非 gate run 的最新逐题对错。
|
||||
调用方必须已按 run_id 过滤掉 gate 内 rollout(防泄露铁律)。
|
||||
units_by_id: unit_id -> QuestionUnit,用于把逐题观测折叠成单元观测。
|
||||
gamma: EMA 衰减系数。
|
||||
|
||||
关键实现细节:
|
||||
单元只有在其**全部**成员都出现在 per_q_observations 时才更新;半观测
|
||||
(AR pair 只见一半)跳过,避免用不完整证据污染 p_hat。
|
||||
"""
|
||||
for entries in self.entries.values():
|
||||
for e in entries:
|
||||
if e.question_id in observations:
|
||||
obs = 1.0 if observations[e.question_id] else 0.0
|
||||
e.p_hat = gamma * e.p_hat + (1 - gamma) * obs
|
||||
unit = units_by_id.get(e.unit_id)
|
||||
if unit is None:
|
||||
continue
|
||||
if not all(q.question_id in per_q_observations for q in unit.questions):
|
||||
continue
|
||||
unit_correct = all(per_q_observations[q.question_id] for q in unit.questions)
|
||||
obs = 1.0 if unit_correct else 0.0
|
||||
e.p_hat = gamma * e.p_hat + (1 - gamma) * obs
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""原子写 gate_pools.json(.tmp 再 replace)。
|
||||
"""原子写 gate_pools.json(.tmp 再 replace),落 schema_version + unit_id 键。
|
||||
|
||||
参数:
|
||||
path: 目标 JSON 路径。
|
||||
"""
|
||||
payload = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"seed": self.seed,
|
||||
"fingerprint": self.fingerprint,
|
||||
"entries": {
|
||||
t: [{"question_id": e.question_id, "p_hat": e.p_hat} for e in es]
|
||||
t: [{"unit_id": e.unit_id, "p_hat": e.p_hat} for e in es]
|
||||
for t, es in self.entries.items()
|
||||
},
|
||||
}
|
||||
@@ -198,18 +233,28 @@ class GatePools:
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> GatePools:
|
||||
"""从 gate_pools.json 恢复。
|
||||
"""从 gate_pools.json 恢复;schema_version 不匹配直接报错(不静默混用)。
|
||||
|
||||
参数:
|
||||
path: gate_pools.json 路径。
|
||||
|
||||
返回:
|
||||
复活的 GatePools。
|
||||
|
||||
异常:
|
||||
RuntimeError: 缺 schema_version(存量 v1、qid 键)或版本不等于
|
||||
SCHEMA_VERSION——拒绝把 qid 键当 unit 键静默复用,须 FRESH 重建。
|
||||
"""
|
||||
d = json.loads(path.read_text(encoding="utf-8"))
|
||||
version = d.get("schema_version")
|
||||
if version != SCHEMA_VERSION:
|
||||
raise RuntimeError(
|
||||
f"gate_pools.json schema_version={version!r} 与当前 {SCHEMA_VERSION} 不符"
|
||||
f"(存量 qid 键池不可当 unit 键复用),请删除后 FRESH 重建: {path}"
|
||||
)
|
||||
return cls(
|
||||
entries={
|
||||
t: [LadderEntry(x["question_id"], x["p_hat"]) for x in es]
|
||||
t: [LadderEntry(x["unit_id"], x["p_hat"]) for x in es]
|
||||
for t, es in d["entries"].items()
|
||||
},
|
||||
seed=d["seed"],
|
||||
@@ -264,22 +309,46 @@ def build_or_load_gate_pools(
|
||||
|
||||
entries: dict[str, list[LadderEntry]] = {}
|
||||
for t in task_types:
|
||||
pool = [q for q in questions if q.task_type == t and q.question_id not in test_qids]
|
||||
if not pool:
|
||||
raise ValueError(f"task_type={t} 无非 test 题,无法建阶梯")
|
||||
entries[t] = build_cold_entries(pool, baseline_correctness, probe_quota, seed)
|
||||
logger.info("gate 阶梯[{}]: {} 题(冷启动)", t, len(entries[t]))
|
||||
units = _task_units_excluding_test(questions, t, test_qids)
|
||||
if not units:
|
||||
raise ValueError(f"task_type={t} 无非 test 单元,无法建阶梯")
|
||||
entries[t] = build_cold_entries(units, baseline_correctness, probe_quota, seed)
|
||||
logger.info("gate 阶梯[{}]: {} 单元(冷启动)", t, len(entries[t]))
|
||||
pools = GatePools(entries=entries, seed=seed, fingerprint=fingerprint)
|
||||
pools.save(path)
|
||||
return pools
|
||||
|
||||
|
||||
class BaselineCache:
|
||||
"""基线侧逐题对错缓存(内容寻址,JSON 持久化)。
|
||||
def _task_units_excluding_test(
|
||||
questions: list[GeneratedQuestion], task_type: str, test_qids: set[str]
|
||||
) -> list[QuestionUnit]:
|
||||
"""取某题型的非 test 候选单元:先按 unit 折叠,再整体排除含 test 成员的单元。
|
||||
|
||||
键 = (task_type, skill_hash, prompts_version, qid):任何影响该题型
|
||||
先折叠后排除保证 AR pair 不被拆半(否则半个 pair 交给下游会触发 build_units 的
|
||||
孤儿 fail-fast);single 单元等价于逐题排除(核心算法保真 #5)。
|
||||
|
||||
参数:
|
||||
questions: benchmark 全量题。
|
||||
task_type: 目标题型。
|
||||
test_qids: held-out test 池题目 id。
|
||||
|
||||
返回:
|
||||
该题型下不含任何 test 成员的候选单元列表。
|
||||
"""
|
||||
pool = [q for q in questions if q.task_type == task_type]
|
||||
return [
|
||||
u for u in build_units(pool) if all(q.question_id not in test_qids for q in u.questions)
|
||||
]
|
||||
|
||||
|
||||
class BaselineCache:
|
||||
"""基线侧单元级对错缓存(内容寻址,JSON 持久化)。
|
||||
|
||||
键 = (task_type, skill_hash, prompts_version, unit_id):任何影响该题型
|
||||
有效 skill 的变化(含共享 default-strategy.md 被他类 accept 改写)
|
||||
都使 skill_hash 变化、缓存自然 miss;prompts 版本变化同理。
|
||||
都使 skill_hash 变化、缓存自然 miss;prompts 版本变化同理。unit_id 维度
|
||||
使 single 题以自身 question_id、AR pair 以共享 pair_id 寻址,缓存单元级
|
||||
对错(pair 双向 AND 折叠后一个布尔)。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
@@ -294,32 +363,32 @@ class BaselineCache:
|
||||
self._store = json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
@staticmethod
|
||||
def _key(task_type: str, s_hash: str, prompts_version: str, qid: str) -> str:
|
||||
"""拼缓存键(四维内容寻址)。"""
|
||||
return f"{task_type}|{s_hash}|{prompts_version}|{qid}"
|
||||
def _key(task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> str:
|
||||
"""拼缓存键(四维内容寻址,第四维为 unit_id)。"""
|
||||
return f"{task_type}|{s_hash}|{prompts_version}|{unit_id}"
|
||||
|
||||
def get(self, task_type: str, s_hash: str, prompts_version: str, qid: str) -> bool | None:
|
||||
def get(self, task_type: str, s_hash: str, prompts_version: str, unit_id: str) -> bool | None:
|
||||
"""读缓存;未命中返回 None。
|
||||
|
||||
参数:
|
||||
task_type: 题型。
|
||||
s_hash: 基线侧生效 skill 文件的内容哈希。
|
||||
prompts_version: 当前 prompts 版本。
|
||||
qid: 题目 id。
|
||||
unit_id: 单元 id(single=question_id,AR pair=pair_id)。
|
||||
|
||||
返回:
|
||||
缓存的对错;未命中 None。
|
||||
缓存的单元级对错;未命中 None。
|
||||
"""
|
||||
return self._store.get(self._key(task_type, s_hash, prompts_version, qid))
|
||||
return self._store.get(self._key(task_type, s_hash, prompts_version, unit_id))
|
||||
|
||||
def put(
|
||||
self, task_type: str, s_hash: str, prompts_version: str, qid: str, correct: bool
|
||||
self, task_type: str, s_hash: str, prompts_version: str, unit_id: str, correct: bool
|
||||
) -> None:
|
||||
"""写缓存并落盘(原子写,gate 频度低、全量重写成本可忽略)。
|
||||
|
||||
参数:
|
||||
task_type / s_hash / prompts_version / qid: 缓存键四维。
|
||||
correct: 基线侧该题对错。
|
||||
task_type / s_hash / prompts_version / unit_id: 缓存键四维。
|
||||
correct: 基线侧该单元对错(AR pair 双向 AND 折叠后一个布尔)。
|
||||
|
||||
关键实现细节:
|
||||
先盘后存:新条目先原子落盘(tmp 写 + os.replace)成功后才更新
|
||||
@@ -327,7 +396,7 @@ class BaselineCache:
|
||||
"""
|
||||
updated = {
|
||||
**self._store,
|
||||
self._key(task_type, s_hash, prompts_version, qid): correct,
|
||||
self._key(task_type, s_hash, prompts_version, unit_id): correct,
|
||||
}
|
||||
tmp = self._path.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(updated, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
+185
-36
@@ -14,12 +14,14 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.question_units import build_units, unit_correctness
|
||||
from core.agent.loop import AgentLoop
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -28,22 +30,22 @@ if TYPE_CHECKING:
|
||||
from app.harness.log import HarnessLog
|
||||
from core.agent.types import LoopResult
|
||||
from core.protocols import LLMProvider
|
||||
from core.types import GeneratedQuestion
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InferenceResult:
|
||||
"""推理聚合结果。
|
||||
"""推理聚合结果(正确率按 unit 粒度)。
|
||||
|
||||
属性:
|
||||
run_id: 运行标识。
|
||||
accuracy: 总正确率。
|
||||
total: 总题数。
|
||||
correct: 正确题数。
|
||||
per_task_type: 按题型分组的指标 {task_type: {accuracy, total, correct}}。
|
||||
steps_mean: 平均步数。
|
||||
token_usage: token 总用量 {prompt_tokens, completion_tokens}。
|
||||
stop_reason_counts: 终止原因计数 {reason: count}。
|
||||
accuracy: unit 级正确率(correct / total)。
|
||||
total: unit 总数(single 数 + pair 数,孤儿 pair 已剔除不计入)。
|
||||
correct: 正确 unit 数(single 单题正确;pair 走 original/mirror 双向 AND)。
|
||||
per_task_type: 按题型分组的 unit 级指标 {task_type: {accuracy, total, correct}}。
|
||||
steps_mean: 平均步数(record 粒度,逐题溯源)。
|
||||
token_usage: token 总用量 {prompt_tokens, completion_tokens}(record 粒度)。
|
||||
stop_reason_counts: 终止原因计数 {reason: count}(record 粒度)。
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
@@ -161,6 +163,24 @@ def _to_text_field(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _normalize_prediction(answer: object) -> str | None:
|
||||
"""归一化 prediction 落库值。
|
||||
|
||||
LLM 提交的 answer 有时是 list/dict(如 {'answer': ['B']}),sqlite 无法绑定
|
||||
非标量类型直接入库会抛 ProgrammingError 击穿整轮 gather。None 保留(INFRA 空
|
||||
预测语义,供正确率判定天然计错);str 原样;其余 JSON 序列化为文本。
|
||||
|
||||
参数:
|
||||
answer: LoopResult.result 中的 answer 原始值(可能是 None/str/list/dict)。
|
||||
|
||||
返回:
|
||||
None(保留空预测语义)或可直接入库的字符串。
|
||||
"""
|
||||
if answer is None or isinstance(answer, str):
|
||||
return answer
|
||||
return _to_text_field(answer)
|
||||
|
||||
|
||||
def _zero_result(run_id: str) -> InferenceResult:
|
||||
"""空记录时的零值 InferenceResult。
|
||||
|
||||
@@ -182,23 +202,25 @@ def _zero_result(run_id: str) -> InferenceResult:
|
||||
)
|
||||
|
||||
|
||||
def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""按 task_type 分组聚合正确率指标。
|
||||
def _group_by_task_type(graded: list[tuple[QuestionUnit, bool]]) -> dict[str, dict[str, Any]]:
|
||||
"""按 task_type 分组聚合 unit 级正确率指标。
|
||||
|
||||
pair 单元整体计 1 个 unit,归入其 task_type;single 单元计 1 个 unit。
|
||||
|
||||
参数:
|
||||
records: 预测记录列表。
|
||||
graded: (单元, 该单元是否整体正确) 元组列表。
|
||||
|
||||
返回:
|
||||
{task_type: {accuracy, total, correct}} 映射。
|
||||
{task_type: {accuracy, total, correct}} 映射(unit 粒度)。
|
||||
"""
|
||||
task_groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for r in records:
|
||||
task_groups[r["task_type"]].append(r)
|
||||
task_groups: dict[str, list[bool]] = defaultdict(list)
|
||||
for unit, is_correct in graded:
|
||||
task_groups[unit.task_type].append(is_correct)
|
||||
|
||||
per_task_type: dict[str, dict[str, Any]] = {}
|
||||
for task_type, group in task_groups.items():
|
||||
t_total = len(group)
|
||||
t_correct = sum(1 for r in group if r["prediction"] == r["answer"])
|
||||
for task_type, verdicts in task_groups.items():
|
||||
t_total = len(verdicts)
|
||||
t_correct = sum(verdicts)
|
||||
per_task_type[task_type] = {
|
||||
"accuracy": t_correct / t_total,
|
||||
"total": t_total,
|
||||
@@ -207,35 +229,143 @@ def _group_by_task_type(records: list[dict[str, Any]]) -> dict[str, dict[str, An
|
||||
return per_task_type
|
||||
|
||||
|
||||
def _aggregate_results(records: list[dict[str, Any]], run_id: str) -> InferenceResult:
|
||||
"""从内存 records 聚合推理指标。
|
||||
def _is_valid_pair(group: list[GeneratedQuestion]) -> bool:
|
||||
"""判定同一 pair_id 分组是否为合法孪生对(恰好 1 original + 1 mirror,无多余)。
|
||||
|
||||
TRM4 从 DB 回读 predictions 表聚合;TRM5 改为从内存直接聚合,
|
||||
避免 DB 回读的同步开销和额外依赖。
|
||||
要求分组总数恰为 2 且角色齐备唯一;有额外非法 role 记录(total>2)或角色
|
||||
缺失/重复均视为非法,交由调用方剔除,防非法记录混入 build_units。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的 record 列表。
|
||||
group: 归属同一 pair_id 的题目列表。
|
||||
|
||||
返回:
|
||||
合法孪生对为 True,否则 False。
|
||||
"""
|
||||
if len(group) != 2:
|
||||
return False
|
||||
originals = sum(1 for q in group if q.question_role == "pair_original")
|
||||
mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
|
||||
return originals == 1 and mirrors == 1
|
||||
|
||||
|
||||
def _drop_orphan_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
|
||||
"""剔除收不齐 2 条 / 角色非法的孤儿 pair,告警不静默。
|
||||
|
||||
每条题目均会各答一次并逐题落库;能否合成 pair 单元仅取决于 questions
|
||||
是否同时含该 pair_id 的 original + mirror(且无多余非法记录)。非法者告警并
|
||||
整对剔除,使后续 build_units 只面对合法孪生对(不触发 fail-fast),孤儿 unit
|
||||
不计入 total(对齐设计 §8 聚合入口的"告警 + 剔除")。
|
||||
|
||||
参数:
|
||||
questions: 待聚合的题目列表(可混含 single 与孪生对成员)。
|
||||
|
||||
返回:
|
||||
可安全交给 build_units 的题目列表(single 全保留,pair 仅保留合法成对者)。
|
||||
"""
|
||||
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
|
||||
singles: list[GeneratedQuestion] = []
|
||||
for q in questions:
|
||||
if q.pair_id:
|
||||
by_pair[q.pair_id].append(q)
|
||||
else:
|
||||
singles.append(q)
|
||||
|
||||
kept_pairs: list[GeneratedQuestion] = []
|
||||
for pair_id, group in by_pair.items():
|
||||
if _is_valid_pair(group):
|
||||
kept_pairs.extend(group)
|
||||
else:
|
||||
logger.warning(
|
||||
"孤儿 pair {}:非法配对(total={}),剔除该 unit 不计入 total",
|
||||
pair_id,
|
||||
len(group),
|
||||
)
|
||||
return singles + kept_pairs
|
||||
|
||||
|
||||
def _per_question_correctness(records: list[dict[str, Any]]) -> dict[str, bool]:
|
||||
"""由逐题 record 构造 question_id → 该题作答是否正确 的映射。
|
||||
|
||||
prediction 为 None(作答异常)时与 answer 不相等 → False,天然计错。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的逐题 record 列表。
|
||||
|
||||
返回:
|
||||
{question_id: prediction == answer} 映射,供 unit_correctness 取值。
|
||||
"""
|
||||
return {r["question_id"]: r["prediction"] == r["answer"] for r in records}
|
||||
|
||||
|
||||
def _grade_unit(unit: QuestionUnit, per_q: dict[str, bool]) -> bool:
|
||||
"""判定单元整体正确性,缺 prediction 时 fail-loud(带上下文)。
|
||||
|
||||
_drop_orphan_pairs 已剔除孤儿/非法配对,正常情况下 unit 内每题都应有对应
|
||||
record;若仍缺失说明聚合不变量被破坏(如 records 与 questions 不同源)。此处
|
||||
显式抛带上下文的 ValueError(fail-loud,不 catch/不跳过/不兜底),而非放任
|
||||
unit_correctness 抛裸 KeyError 丢失定位信息。
|
||||
|
||||
参数:
|
||||
unit: 待判定单元。
|
||||
per_q: question_id → 该题是否作答正确 的映射。
|
||||
|
||||
返回:
|
||||
单元整体是否正确(single 即单题正确;pair 走双向 AND)。
|
||||
|
||||
异常:
|
||||
ValueError: unit 内某 question_id 不在 per_q 中(聚合不变量被破坏)。
|
||||
"""
|
||||
missing = [q.question_id for q in unit.questions if q.question_id not in per_q]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"unit {unit.unit_id} 的 question {missing} 缺 prediction"
|
||||
"(_drop_orphan_pairs 后不应发生,聚合不变量被破坏)"
|
||||
)
|
||||
return unit_correctness(unit, per_q)
|
||||
|
||||
|
||||
def _aggregate_results(
|
||||
records: list[dict[str, Any]],
|
||||
questions: list[GeneratedQuestion],
|
||||
run_id: str,
|
||||
) -> InferenceResult:
|
||||
"""从内存 records + 题目列表按 unit 粒度聚合推理指标。
|
||||
|
||||
逐题 record 保留逐题溯源(token/steps/stop_reason 诊断仍按 record 汇总);
|
||||
正确率则按 unit 粒度计:single 计 1,AR pair 经 build_units 收齐 original +
|
||||
mirror 后走 unit_correctness 的双向 AND 判定,整对计 1 个 unit。孤儿 pair
|
||||
在 _drop_orphan_pairs 中告警 + 剔除,不计入 total。
|
||||
|
||||
参数:
|
||||
records: _run_single_question 返回的逐题 record 列表。
|
||||
questions: 与 records 对应的题目列表(提供 pair_id/question_role 元数据)。
|
||||
run_id: 当前运行标识。
|
||||
|
||||
返回:
|
||||
InferenceResult 冻结实例。
|
||||
InferenceResult 冻结实例(total/correct/per_task_type 为 unit 粒度)。
|
||||
"""
|
||||
total = len(records)
|
||||
if total == 0:
|
||||
if not records:
|
||||
return _zero_result(run_id)
|
||||
|
||||
correct = sum(1 for r in records if r["prediction"] == r["answer"])
|
||||
per_q = _per_question_correctness(records)
|
||||
units = build_units(_drop_orphan_pairs(questions))
|
||||
graded = [(unit, _grade_unit(unit, per_q)) for unit in units]
|
||||
|
||||
total = len(graded)
|
||||
correct = sum(1 for _, is_correct in graded if is_correct)
|
||||
|
||||
stop_counts: dict[str, int] = defaultdict(int)
|
||||
for r in records:
|
||||
stop_counts[r["stop_reason"]] += 1
|
||||
|
||||
n_records = len(records)
|
||||
return InferenceResult(
|
||||
run_id=run_id,
|
||||
accuracy=correct / total,
|
||||
accuracy=correct / total if total else 0.0,
|
||||
total=total,
|
||||
correct=correct,
|
||||
per_task_type=_group_by_task_type(records),
|
||||
steps_mean=sum(r["steps_used"] for r in records) / total,
|
||||
per_task_type=_group_by_task_type(graded),
|
||||
steps_mean=sum(r["steps_used"] for r in records) / n_records,
|
||||
token_usage={
|
||||
"prompt_tokens": sum(r["prompt_tokens"] for r in records),
|
||||
"completion_tokens": sum(r["completion_tokens"] for r in records),
|
||||
@@ -258,6 +388,7 @@ async def _run_single_question(
|
||||
log: HarnessLog,
|
||||
max_steps: int,
|
||||
plugins: list[object],
|
||||
run_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""执行单道题目的 Agent 推理。
|
||||
|
||||
@@ -272,11 +403,17 @@ async def _run_single_question(
|
||||
log: HarnessLog 实例(线程安全)。
|
||||
max_steps: AgentLoop 最大步数。
|
||||
plugins: pluggy 插件列表。
|
||||
run_id: 运行标识,用作 cache_salt——run_id 含 _e{epoch} 天然跨 epoch 重采样、
|
||||
同 epoch 续跑命中缓存(算法 #10 透传)。
|
||||
|
||||
返回:
|
||||
预测结果字典(含 video_id, question_id, prediction, answer 等)。
|
||||
"""
|
||||
# run_id 必须显式入 record:HarnessLog.insert 缺省用**实例** run_id 填充,
|
||||
# 连续并发 gate 共享单一 gate_log(实例 run_id 为 step 级)时,各臂行必须
|
||||
# 落自己的臂 run_id,否则 validate 回读 _load_run_rows(臂 run_id) 为空。
|
||||
record: dict[str, Any] = {
|
||||
"run_id": run_id,
|
||||
"video_id": qa.video_id,
|
||||
"question_id": qa.question_id,
|
||||
"task_type": qa.task_type,
|
||||
@@ -301,6 +438,7 @@ async def _run_single_question(
|
||||
dispatcher,
|
||||
plugins=plugins,
|
||||
session_id=qa.question_id,
|
||||
cache_salt=run_id,
|
||||
)
|
||||
|
||||
result_dict = loop_result.result if isinstance(loop_result.result, dict) else {}
|
||||
@@ -308,7 +446,7 @@ async def _run_single_question(
|
||||
reasoning = _to_text_field(result_dict.get("reasoning", ""))
|
||||
record.update(
|
||||
{
|
||||
"prediction": result_dict.get("answer"),
|
||||
"prediction": _normalize_prediction(result_dict.get("answer")),
|
||||
"evidence": evidence,
|
||||
"reasoning": reasoning,
|
||||
"steps_used": loop_result.steps_used,
|
||||
@@ -331,8 +469,18 @@ async def _run_single_question(
|
||||
except Exception:
|
||||
logger.exception("[{}] QA {} 执行异常", qa.video_id, qa.question_id)
|
||||
|
||||
# prediction 必落库(try 外,无论成败)
|
||||
await asyncio.to_thread(log.insert, "predictions", record)
|
||||
# prediction 必落库(try 外,无论成败);绑定异常降级为最小 error 行,不击穿 gather
|
||||
try:
|
||||
await asyncio.to_thread(log.insert, "predictions", record)
|
||||
except (sqlite3.InterfaceError, sqlite3.ProgrammingError):
|
||||
logger.exception("[{}] QA {} 落库绑定异常,降级为 error 行", qa.video_id, qa.question_id)
|
||||
record["prediction"] = None
|
||||
record["stop_reason"] = "error"
|
||||
await asyncio.to_thread(
|
||||
log.insert,
|
||||
"predictions",
|
||||
{k: v for k, v in record.items() if isinstance(v, (str, int, float, type(None)))},
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
@@ -399,7 +547,7 @@ async def run_inference(
|
||||
|
||||
if not questions:
|
||||
logger.info("题目列表为空,返回零值 InferenceResult")
|
||||
return _aggregate_results([], run_id)
|
||||
return _aggregate_results([], [], run_id)
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
total_count = len(questions)
|
||||
@@ -418,6 +566,7 @@ async def run_inference(
|
||||
log=log,
|
||||
max_steps=max_steps,
|
||||
plugins=plugins,
|
||||
run_id=run_id,
|
||||
)
|
||||
logger.info(
|
||||
"[{}/{}] {} QA {} 完成 (stop={})",
|
||||
@@ -431,7 +580,7 @@ async def run_inference(
|
||||
|
||||
results = await asyncio.gather(*[_bounded(i, qa) for i, qa in enumerate(questions)])
|
||||
|
||||
inference_result = _aggregate_results(list(results), run_id)
|
||||
inference_result = _aggregate_results(list(results), questions, run_id)
|
||||
logger.info(
|
||||
"推理完成: accuracy={:.2%} ({}/{})",
|
||||
inference_result.accuracy,
|
||||
|
||||
+37
-16
@@ -1,8 +1,9 @@
|
||||
"""HarnessLog:SQLite 薄包装 + RunLogImpl 只读查询端口。
|
||||
|
||||
HarnessLog 提供统一的结构化日志接口,从 TRM4 直搬,保留全部线程安全与幂等语义。
|
||||
同 run_id 重复创建时通过 ON CONFLICT DO UPDATE 更新 started_at/config/status。
|
||||
RunLogImpl 实现 core/evolution/protocols.py::RunLog Protocol,用独立连接做只读 SELECT,
|
||||
不经 HarnessLog 生命周期(不触发 _runs INSERT OR IGNORE),避免污染运行状态。
|
||||
不经 HarnessLog 生命周期(不触发 _runs upsert),避免污染运行状态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -41,7 +42,7 @@ class HarnessLog:
|
||||
|
||||
关键设计:
|
||||
- WAL 模式 + threading.Lock 保证共享连接下并发安全。
|
||||
- INSERT OR IGNORE INTO _runs 保证幂等(同 run_id 多次创建不报错)。
|
||||
- ON CONFLICT DO UPDATE 保证幂等(同 run_id 多次创建不报错)并更新 started_at/config/status。
|
||||
- query 也持锁:共享连接(check_same_thread=False)下并发 SELECT + INSERT
|
||||
在同一连接上 execute 会损坏游标状态,故读也须串行化。
|
||||
- context manager 语义:正常退出 completed,异常退出 failed。
|
||||
@@ -51,6 +52,9 @@ class HarnessLog:
|
||||
run_id: 本次运行的唯一标识。
|
||||
git_sha: 代码版本,默认自动获取。
|
||||
config_snapshot: 本次运行的配置快照。
|
||||
register_run: 是否注册运行(upsert _runs + 退出时同步 status)。默认 True;
|
||||
只读查询已有 run(如基线预测回读)时传 False,避免把该 run 的
|
||||
started_at/config/status 改写、把基线元数据污染成本次进程的运行状态。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -59,23 +63,35 @@ class HarnessLog:
|
||||
run_id: str,
|
||||
git_sha: str | None = None,
|
||||
config_snapshot: dict[str, Any] | None = None,
|
||||
*,
|
||||
register_run: bool = True,
|
||||
) -> None:
|
||||
self._run_id = run_id
|
||||
self._register_run = register_run
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
# 单持久连接 + 进程内 Lock 串行化写:把并发控制拉到进程内,消除多连接争
|
||||
# SQLite 写锁。同款模式复用于 adapters/telemetry.py:SQLiteTelemetryRecorder。
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._lock = threading.Lock()
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._init_fixed_tables()
|
||||
resolved_sha = git_sha or _get_git_sha()
|
||||
config_json = json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
|
||||
self._conn.execute(
|
||||
"INSERT OR IGNORE INTO _runs"
|
||||
" (run_id, git_sha, started_at, config, status)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(run_id, resolved_sha, _now_iso(), config_json, "running"),
|
||||
)
|
||||
self._conn.commit()
|
||||
if register_run:
|
||||
resolved_sha = git_sha or _get_git_sha()
|
||||
config_json = (
|
||||
json.dumps(config_snapshot, ensure_ascii=False) if config_snapshot else None
|
||||
)
|
||||
self._conn.execute(
|
||||
"INSERT INTO _runs"
|
||||
" (run_id, git_sha, started_at, config, status)"
|
||||
" VALUES (?, ?, ?, ?, ?)"
|
||||
" ON CONFLICT(run_id) DO UPDATE SET"
|
||||
" started_at=excluded.started_at,"
|
||||
" config=excluded.config,"
|
||||
" status=excluded.status",
|
||||
(run_id, resolved_sha, _now_iso(), config_json, "running"),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def _init_fixed_tables(self) -> None:
|
||||
"""创建 _runs 和 _events 固定表。"""
|
||||
@@ -212,13 +228,18 @@ class HarnessLog:
|
||||
|
||||
参数:
|
||||
status: 最终状态,"completed" 或 "failed"。
|
||||
|
||||
关键实现:
|
||||
register_run=False(只读打开)时跳过 status 更新,仅关闭连接,
|
||||
避免只读回读把已有 run 的 finished_at/status 改写。
|
||||
"""
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE _runs SET finished_at = ?, status = ? WHERE run_id = ?",
|
||||
(_now_iso(), status, self._run_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
if self._register_run:
|
||||
self._conn.execute(
|
||||
"UPDATE _runs SET finished_at = ?, status = ? WHERE run_id = ?",
|
||||
(_now_iso(), status, self._run_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> HarnessLog:
|
||||
|
||||
@@ -87,6 +87,8 @@ _HOLDOUT_COLS: dict[str, str] = {
|
||||
_QUADRANT_COLS: dict[str, str] = {
|
||||
"epoch": "INTEGER",
|
||||
"step": "INTEGER",
|
||||
# question_id 列承载 unit_id(single=question_id,pair=pair_id);
|
||||
# 逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
|
||||
"question_id": "TEXT",
|
||||
"task_type": "TEXT",
|
||||
"prev_correct": "INTEGER",
|
||||
@@ -98,8 +100,10 @@ _GATE_EVIDENCE_COLS: dict[str, str] = {
|
||||
"epoch": "INTEGER",
|
||||
"step": "INTEGER",
|
||||
"task_type": "TEXT",
|
||||
# question_id 列承载 unit_id(single=question_id,pair=pair_id);
|
||||
# 逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
|
||||
"question_id": "TEXT",
|
||||
"block_idx": "INTEGER",
|
||||
"ladder_rank": "INTEGER",
|
||||
"baseline_correct": "INTEGER",
|
||||
"candidate_correct": "INTEGER",
|
||||
"e_value": "REAL",
|
||||
@@ -131,7 +135,8 @@ def write_dual_metric(
|
||||
db_path: SQLite 路径。
|
||||
run_id: 训练 run ID。
|
||||
epoch: 轮次(1-based)。
|
||||
version_kind: baseline / best_hard / best_mixed / final。
|
||||
version_kind: baseline / best_hard / best_mixed / final / slow_candidate
|
||||
(slow_candidate = 慢更新 R2 可能被 revert 的候选,不占 epoch 终值 final 口径)。
|
||||
skills_version / prompts_version: 评估的资源版本。
|
||||
pool: val / test。
|
||||
hard_acc: hard 准确率。
|
||||
@@ -275,7 +280,7 @@ def write_quadrant_pairs(
|
||||
step: int,
|
||||
pairs: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""落 quadrant_pair 多行:fast gate 后逐题四象限(prev/curr 翻转 + category)落库。
|
||||
"""落 quadrant_pair 多行:fast gate 后按 **unit** 四象限(prev/curr 翻转 + category)落库。
|
||||
|
||||
参数:
|
||||
db_path: SQLite 路径。
|
||||
@@ -283,7 +288,10 @@ def write_quadrant_pairs(
|
||||
epoch: 轮次(1-based)。
|
||||
step: epoch 内 step 序号(0-based)。
|
||||
pairs: 每条含 question_id/task_type/prev_correct/curr_correct/category;
|
||||
prev_correct/curr_correct 为 bool,写库前转 0/1。
|
||||
question_id 字段承载 **unit_id**(single=question_id,pair=pair_id,
|
||||
与 gate e-process 同粒度)——逐题明细在 predictions 表溯源,按 pair_id
|
||||
join 真实 question 表会 join 不上;prev_correct/curr_correct 为 bool,
|
||||
写库前转 0/1。
|
||||
|
||||
关键实现:
|
||||
用 insert_many 批量落库;pairs 为空时只建表不插入(fast gate 无翻转的极端情况)。
|
||||
@@ -326,16 +334,22 @@ def write_gate_evidence(
|
||||
step: int,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""落 gate_evidence 逐题行:CE-Gate 每次决策的可回放审计记录。
|
||||
"""落 gate_evidence 单元行:CE-Gate 每次决策的可回放审计记录(unit 口径)。
|
||||
|
||||
参数:
|
||||
db_path: SQLite 路径。
|
||||
run_id: 训练 run ID。
|
||||
epoch: 该 gate 所属的轮次(1-based)。
|
||||
step: epoch 内 step 序号(0-based)。
|
||||
rows: 每题一行,含 question_id/task_type/block_idx/baseline_correct/
|
||||
candidate_correct/e_value(该题所在块判定后的累计 e 值)/
|
||||
stop_reason(仅最后一题携带最终 stop_reason,其余空串)。
|
||||
rows: 每 **单元** 一行,含 question_id/task_type/ladder_rank(阶梯序号,
|
||||
0-based)/baseline_correct/
|
||||
candidate_correct/e_value(该单元判定后的累计 e 值)/
|
||||
stop_reason(仅最后一单元携带最终 stop_reason,其余空串)。
|
||||
question_id 字段承载 **unit_id**(single=question_id,pair=pair_id)——
|
||||
逐题明细在 predictions 表溯源,按 pair_id join 真实 question 表会 join 不上。
|
||||
|
||||
返回:
|
||||
无。
|
||||
|
||||
关键实现:
|
||||
逐行 insert(非 insert_many),保证每行独立事务。
|
||||
@@ -344,6 +358,12 @@ def write_gate_evidence(
|
||||
|
||||
with HarnessLog(db_path, run_id) as log:
|
||||
log.create_table("gate_evidence", _GATE_EVIDENCE_COLS)
|
||||
# 幂等迁移(对齐 question_gen/run_store 先例):块序贯时代的旧表只有
|
||||
# block_idx 列,CREATE TABLE IF NOT EXISTS 不补列,直接插 ladder_rank
|
||||
# 会 OperationalError——为旧 workspace 复用补列,新表恒为 no-op。
|
||||
cols = {r["name"] for r in log.query("PRAGMA table_info(gate_evidence)")}
|
||||
if "ladder_rank" not in cols:
|
||||
log.execute("ALTER TABLE gate_evidence ADD COLUMN ladder_rank INTEGER")
|
||||
for row in rows:
|
||||
log.insert("gate_evidence", {"epoch": epoch, "step": step, **row})
|
||||
|
||||
|
||||
+987
-64
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
"""QuestionUnit 组装/展开/校验/单元正确性——pair 契约的唯一入口。
|
||||
|
||||
pool 构建、批处理、推理、评测(Task 3+)均通过本模块聚合/展开孪生对,
|
||||
保证 AR pair 的"两题作为整体调度"契约只在一处实现、fail-fast 暴露非法配对。
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
|
||||
def _assemble_pair(pair_id: str, group: list[GeneratedQuestion]) -> QuestionUnit:
|
||||
"""校验单个 pair 分组的数量/角色并组装为 pair 单元(fail-fast)。
|
||||
|
||||
参数:
|
||||
pair_id: 该分组共享的孪生对标识。
|
||||
group: 归属同一 pair_id 的题目列表。
|
||||
|
||||
返回:
|
||||
kind="pair" 的 QuestionUnit。
|
||||
|
||||
关键实现:
|
||||
- 分组必须恰好 2 条,否则视为孤儿/超员,raise ValueError。
|
||||
- 显式检查 pair_original / pair_mirror 角色齐备且唯一,缺失或重复
|
||||
直接 raise ValueError(防 next(...) 静默 StopIteration)。
|
||||
- 合法孪生对交由 QuestionUnit.from_pair 做 video_id/task_type/flip_axis
|
||||
一致性断言。
|
||||
"""
|
||||
if len(group) != 2:
|
||||
raise ValueError(f"pair {pair_id} 数量={len(group)}≠2(孤儿或超员)")
|
||||
originals = [q for q in group if q.question_role == "pair_original"]
|
||||
mirrors = [q for q in group if q.question_role == "pair_mirror"]
|
||||
if len(originals) != 1 or len(mirrors) != 1:
|
||||
raise ValueError(
|
||||
f"pair {pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)},"
|
||||
"需各恰好 1 条"
|
||||
)
|
||||
return QuestionUnit.from_pair(originals[0], mirrors[0])
|
||||
|
||||
|
||||
def build_units(questions: list[GeneratedQuestion]) -> list[QuestionUnit]:
|
||||
"""将扁平题目列表聚合为单元列表:single 单封、pair 按 pair_id 成对聚合。
|
||||
|
||||
参数:
|
||||
questions: 待聚合的题目列表,可混含 single 与孪生对成员。
|
||||
|
||||
返回:
|
||||
单元列表,先 single 后 pair,顺序稳定(single 保留输入顺序,
|
||||
pair 按首次出现的 pair_id 顺序)。
|
||||
|
||||
关键实现:
|
||||
pair_id 为空 → single 单元;非空 → 归入对应 pair 桶。各 pair 桶的
|
||||
数量/角色校验与组装下沉到 _assemble_pair(fail-fast),本体只做分组
|
||||
与派发。
|
||||
"""
|
||||
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
|
||||
singles: list[QuestionUnit] = []
|
||||
for q in questions:
|
||||
if q.pair_id:
|
||||
by_pair[q.pair_id].append(q)
|
||||
else:
|
||||
singles.append(QuestionUnit.from_single(q))
|
||||
|
||||
pairs = [_assemble_pair(pid, qs) for pid, qs in by_pair.items()]
|
||||
return singles + pairs
|
||||
|
||||
|
||||
def flatten_units(units: list[QuestionUnit]) -> list[GeneratedQuestion]:
|
||||
"""将单元列表无损展开回扁平题目列表。
|
||||
|
||||
参数:
|
||||
units: 单元列表。
|
||||
|
||||
返回:
|
||||
展开后的题目列表,保持单元顺序及单元内题目顺序。
|
||||
"""
|
||||
return [q for u in units for q in u.questions]
|
||||
|
||||
|
||||
def validate_units(units: list[QuestionUnit]) -> list[QuestionUnit]:
|
||||
"""校验单元列表结构合法性,通过则原样返回(便于链式调用)。
|
||||
|
||||
参数:
|
||||
units: 待校验单元列表。
|
||||
|
||||
返回:
|
||||
校验通过的原单元列表。
|
||||
|
||||
关键实现:
|
||||
pair 单元必须恰好含 2 题,否则 raise ValueError;single 单元无需额外
|
||||
校验(构造时即为 1 题)。用于消费方在使用前做一道防御闸门。
|
||||
"""
|
||||
for u in units:
|
||||
if u.kind == "pair" and u.size != 2:
|
||||
raise ValueError(f"unit {u.unit_id} pair 不成对(size={u.size})")
|
||||
return units
|
||||
|
||||
|
||||
def unit_correctness(unit: QuestionUnit, per_q: dict[str, bool], *, strict: bool = True) -> bool:
|
||||
"""计算单元级正确性:AR pair 走双向 AND,single 即单题正确性。
|
||||
|
||||
参数:
|
||||
unit: 目标单元。
|
||||
per_q: 题目 question_id → 该题是否作答正确的映射。
|
||||
strict: 缺键策略。True(默认)时以 per_q[q.question_id] 取值,缺任一题
|
||||
触发 KeyError(防静默兜底,强制上游先补齐全部单题结果);False 时以
|
||||
per_q.get(q.question_id, False) 取值,缺键计 False(宽松口径,供池
|
||||
构建 / gate 冷启动 / 采样等"缺基线对错即视为未答对"的调用点复用)。
|
||||
|
||||
返回:
|
||||
单元内所有题目均正确时为 True,否则 False。
|
||||
|
||||
关键实现:
|
||||
pool 构建(pools)、gate 冷启动(gate_ladder)、分层采样(loader)三处
|
||||
原各自持有的 loose 版 _unit_correct 副本统一收敛到本函数 strict=False 分支,
|
||||
消除重复逻辑与 missing-key 策略分叉。
|
||||
"""
|
||||
if strict:
|
||||
return all(per_q[q.question_id] for q in unit.questions)
|
||||
return all(per_q.get(q.question_id, False) for q in unit.questions)
|
||||
|
||||
|
||||
def unit_correctness_view(
|
||||
units: list[QuestionUnit], per_q: dict[str, bool], *, strict: bool = True
|
||||
) -> dict[str, bool]:
|
||||
"""把逐题对错折叠成单元级视图:unit_id → 单元是否整体正确。
|
||||
|
||||
进化引擎(gate e-process / quadrant / probation / pair_block / compute_accuracy)
|
||||
统一消费此单元视图,保证 AR pair 双向 AND、非 AR single 单题,混格池中
|
||||
孪生对折叠为一个单元、不被 P/Q 单题计分污染(核心算法保真 #5)。
|
||||
|
||||
参数:
|
||||
units: 目标单元列表(single 或 pair)。
|
||||
per_q: 题目 question_id → 该题是否作答正确(唯一逐题溯源来源)。
|
||||
strict: 缺键策略,透传给 unit_correctness。True(默认)缺任一题 raise
|
||||
KeyError;False 缺键计 False(宽松口径)。
|
||||
|
||||
返回:
|
||||
unit_id → 单元级正确性。single 的 unit_id 等于其 question_id,
|
||||
pair 的 unit_id 等于共享 pair_id。
|
||||
|
||||
关键实现:
|
||||
逐单元复用 unit_correctness(strict 透传),默认 strict 禁静默兜底、
|
||||
强制上游先补齐全部单题结果。
|
||||
"""
|
||||
return {u.unit_id: unit_correctness(u, per_q, strict=strict) for u in units}
|
||||
+752
-195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
"""结果驱动视频级切分的冻结溯源 manifest。
|
||||
|
||||
冻结的 pools.json 是切分产物;manifest 记录产出这份切分的关键输入
|
||||
(baseline_run_id、诊断指纹、随机种子、配置)与 pools.json 的内容指纹
|
||||
(pools_sha256),供后续 build_split 写溯源、以及复现校验时比对。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.harness.pools import _atomic_write_json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def write_manifest(
|
||||
path: Path,
|
||||
*,
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
seed: int,
|
||||
config: dict,
|
||||
pools_json_text: str,
|
||||
coverage_report: dict,
|
||||
generated_at: str,
|
||||
) -> dict:
|
||||
"""写切分冻结溯源 manifest(原子写),返回写入的 dict。
|
||||
|
||||
pools_sha256 = sha256(pools_json_text),供复现时校验冻结的 pools.json 内容
|
||||
是否与本次切分一致。generated_at 由调用方传入(库内不用 datetime.now),
|
||||
以保证相同输入产出相同 manifest,可复现。
|
||||
|
||||
参数:
|
||||
path: manifest 目标 JSON 文件路径。
|
||||
baseline_run_id: 产出本次切分所依据的基线 run 标识。
|
||||
diag_fingerprint: 诊断结果指纹(决定 train/val 归属的输入)。
|
||||
seed: 切分使用的随机种子。
|
||||
config: 切分相关配置快照(如 train_ratio 等)。
|
||||
pools_json_text: 冻结的 pools.json 完整文本,用于计算内容指纹。
|
||||
coverage_report: 各类别 train/val 覆盖统计报告。
|
||||
generated_at: 生成时间戳(ISO 字符串),由调用方传入。
|
||||
|
||||
返回:
|
||||
写入 manifest 的 dict(与落盘内容一致)。
|
||||
"""
|
||||
manifest = {
|
||||
"baseline_run_id": baseline_run_id,
|
||||
"diag_fingerprint": diag_fingerprint,
|
||||
"seed": seed,
|
||||
"config": config,
|
||||
"pools_sha256": hashlib.sha256(pools_json_text.encode("utf-8")).hexdigest(),
|
||||
"coverage_report": coverage_report,
|
||||
"generated_at": generated_at,
|
||||
}
|
||||
_atomic_write_json(path, manifest)
|
||||
return manifest
|
||||
@@ -0,0 +1,551 @@
|
||||
"""视频级切分选择:signal 分层、视频聚合、贪心联合约束选择(纯函数)。
|
||||
|
||||
结果驱动切分管线的核心:把诊断信号投影为多样性格子,供贪心选择器最大化覆盖。
|
||||
本模块起步定义 evolution_target 派生与多样性格子;后续追加 score_signal /
|
||||
build_video_records / select_split。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def diag_fingerprint(prompt_version: str, model: str, code_version: str) -> str:
|
||||
"""由 (诊断 prompt 版本, 模型名, 代码 git 短 SHA) 合成诊断口径指纹。
|
||||
|
||||
诊断信号以 (question_id, baseline_run_id, diag_fingerprint) 为主键持久化,
|
||||
指纹隔离不同诊断配置的信号——换 prompt 版本 / 换模型 / 换代码实现都会得到
|
||||
新指纹,从而 `--force` 用新指纹重跑诊断时**不覆盖旧记录**(旧指纹行仍在),
|
||||
保证不同口径的诊断结果可并存、可回溯、可比对。
|
||||
|
||||
参数:
|
||||
prompt_version: 诊断 prompt 的版本标识(如 prompts/diagnose_*.md 的版本)。
|
||||
model: 执行诊断的模型名(如 "deepseek-v4")。
|
||||
code_version: 诊断代码的版本(约定为 git 短 SHA)。
|
||||
|
||||
返回:
|
||||
16 位十六进制指纹(sha256 截断),对三分量任一变化敏感、对相同三元组确定。
|
||||
|
||||
实现细节:
|
||||
三分量用 "|" 分隔后 sha256,取前 16 位;分隔符防止 ("ab","c") 与 ("a","bc")
|
||||
碰撞成同一指纹。纯函数,相同输入永远同输出,可安全用于主键。
|
||||
"""
|
||||
return hashlib.sha256("|".join([prompt_version, model, code_version]).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
_EVOLUTION_TARGET = {
|
||||
"extraction_failure": "tool",
|
||||
"search_failure": "skill",
|
||||
"reasoning_failure": "skill",
|
||||
"mixed": "system",
|
||||
}
|
||||
|
||||
|
||||
def evolution_target_of(error_type: str) -> str:
|
||||
"""由 error_type 确定性派生进化目标(tool/skill/system)。
|
||||
|
||||
这是报告用的派生标注,非独立多样性轴(多样性主格子=task_type×error_type)。
|
||||
|
||||
参数:
|
||||
error_type: 诊断瀑布归因的错误类别(extraction/search/reasoning/mixed_failure)。
|
||||
|
||||
返回:
|
||||
进化目标字符串 tool / skill / system。
|
||||
|
||||
异常:
|
||||
ValueError: error_type 不在已知集合内(不静默兜底)。
|
||||
"""
|
||||
if error_type not in _EVOLUTION_TARGET:
|
||||
raise ValueError(f"未知 error_type: {error_type}")
|
||||
return _EVOLUTION_TARGET[error_type]
|
||||
|
||||
|
||||
def cell_of(task_type: str, error_type: str) -> tuple[str, str]:
|
||||
"""构造多样性主格子 = (task_type, error_type)。
|
||||
|
||||
参数:
|
||||
task_type: 题型(12 类之一)。
|
||||
error_type: 错误类别(4 类之一)。
|
||||
|
||||
返回:
|
||||
(task_type, error_type) 二元组,作为覆盖计数的格子键。
|
||||
"""
|
||||
return (task_type, error_type)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignalLabel:
|
||||
"""诊断信号分层标签(DiagnosisResult 的确定性投影)。
|
||||
|
||||
字段:
|
||||
tier: 信号层级,取值 T0 / T1 / T2 / uncertain(判据见 score_signal)。
|
||||
"""
|
||||
|
||||
tier: str
|
||||
|
||||
|
||||
def score_signal(*, cause_category: str | None, infra: bool, degraded: bool) -> SignalLabel:
|
||||
"""把诊断产物投影为信号分层 tier(不发明新分类,是确定性投影)。
|
||||
|
||||
分层优先级顺序固定(用早返回表达,不用魔法权重):
|
||||
先判 INFRA,再判 degraded,然后 defect / lapse,最后兜底 uncertain。
|
||||
|
||||
各层判据来源:
|
||||
T0 — infra=True,即诊断 INFRA 排除(stop_reason ∈ {error, parse_error}),
|
||||
基础设施失败先于一切判定,排除出可训练主体。
|
||||
uncertain — degraded=True(judge 解析失败)或 cause_category 落不到
|
||||
defect/lapse 上(如为 None),信号不可信,排除出 T2。
|
||||
T2 — cause_category == "defect",可训练核心,进多样性覆盖与训练主体。
|
||||
T1 — cause_category == "lapse",低信号(含无解题),接受但不作训练主体。
|
||||
|
||||
参数:
|
||||
cause_category: 诊断的缺陷归因("defect" / "lapse" / None)。
|
||||
infra: 是否被 INFRA 护栏排除(基础设施失败)。
|
||||
degraded: judge 是否解析失败导致诊断降级。
|
||||
|
||||
返回:
|
||||
SignalLabel,其 tier 字段为上述四层之一。
|
||||
|
||||
实现细节:
|
||||
关键字参数强制传入,防止 infra / degraded 两个 bool 位置混淆。
|
||||
"""
|
||||
if infra:
|
||||
return SignalLabel(tier="T0")
|
||||
if degraded:
|
||||
return SignalLabel(tier="uncertain")
|
||||
if cause_category == "defect":
|
||||
return SignalLabel(tier="T2")
|
||||
if cause_category == "lapse":
|
||||
return SignalLabel(tier="T1")
|
||||
return SignalLabel(tier="uncertain")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoRecord:
|
||||
"""全视频画像单元(贪心选择器 Task 8 的输入单元)。
|
||||
|
||||
覆盖全部视频(含全对、零诊断信号的视频),既承载 test 代表性所需的难度/题型画像,
|
||||
也叠加 T2 可训练缺陷的多样性格子,供选择器算覆盖与补集。
|
||||
|
||||
字段:
|
||||
video_id: 视频唯一标识。
|
||||
type_set: 该视频所有题的 task_type 集合(去重,画像用)。
|
||||
n_correct: 该视频答对题数。
|
||||
difficulty: 难度画像桶 = 错题数 = 题数 - n_correct。
|
||||
cells: 仅 tier=="T2" 信号行投影的 (task_type, error_type) 主格子并集(去重)。
|
||||
wrong_by_type: 各 task_type 的 T2 计数,供选择器 floor 约束(普通 dict)。
|
||||
|
||||
实现细节:
|
||||
frozen 生成的 __hash__ 会遍历各字段;wrong_by_type 为不可哈希 dict,
|
||||
故显式标注 hash=False 将其排除出哈希,避免 VideoRecord 入 set/dict 键时报错,
|
||||
仍保留其参与相等性比较。
|
||||
"""
|
||||
|
||||
video_id: str
|
||||
type_set: frozenset[str]
|
||||
n_correct: int
|
||||
difficulty: int
|
||||
cells: frozenset[tuple[str, str]]
|
||||
wrong_by_type: dict[str, int] = field(hash=False)
|
||||
|
||||
|
||||
def build_video_records(preds: list[dict], signal_rows: list[dict]) -> list[VideoRecord]:
|
||||
"""由全量 predictions 与诊断信号行构建全视频 VideoRecord 列表。
|
||||
|
||||
先按 video_id 聚合全部 predictions(覆盖全对、零信号视频),再叠加仅 tier=="T2"
|
||||
的诊断信号为多样性格子与 wrong_by_type 计数。非 T2 信号行(T0/T1/uncertain)
|
||||
不计入格子与计数。
|
||||
|
||||
参数:
|
||||
preds: 全量预测行,每行含 video_id / question_id / task_type / correct。
|
||||
每视频含其全部题(不限于错题),correct 为布尔答对标记。
|
||||
signal_rows: 诊断信号行,每行含 question_id / task_type / error_type / tier。
|
||||
诊断只覆盖错题子集,正确题无对应信号行属正常,不视为错误。
|
||||
|
||||
返回:
|
||||
全部视频的 VideoRecord 列表,按视频在 preds 中首次出现顺序排列。
|
||||
无任何 T2 信号的视频其 cells 为空 frozenset、wrong_by_type 为空 dict。
|
||||
|
||||
实现细节:
|
||||
signal_rows 的 question_id 若不在 preds 中则忽略(诊断可能滞后于当前预测集,
|
||||
非数据损坏),不 fail-fast;缺失必需键则按 KeyError 直接暴露(不静默兜底)。
|
||||
|
||||
异常:
|
||||
KeyError: preds 或 signal_rows 行缺少必需键(校验前置,防脏数据静默通过)。
|
||||
"""
|
||||
# Phase 1: 按 video_id 聚合 preds(保持首次出现顺序)。
|
||||
signal_by_qid = {row["question_id"]: row for row in signal_rows}
|
||||
aggregates: dict[str, dict] = {}
|
||||
for pred in preds:
|
||||
video_id = pred["video_id"]
|
||||
bucket = aggregates.setdefault(
|
||||
video_id, {"types": set(), "question_ids": [], "n_correct": 0}
|
||||
)
|
||||
bucket["types"].add(pred["task_type"])
|
||||
bucket["question_ids"].append(pred["question_id"])
|
||||
if pred["correct"]:
|
||||
bucket["n_correct"] += 1
|
||||
|
||||
# Phase 2: 逐视频叠加 T2 信号为格子与 wrong_by_type。
|
||||
records: list[VideoRecord] = []
|
||||
for video_id, bucket in aggregates.items():
|
||||
cells: set[tuple[str, str]] = set()
|
||||
wrong_by_type: dict[str, int] = {}
|
||||
for question_id in bucket["question_ids"]:
|
||||
row = signal_by_qid.get(question_id)
|
||||
if row is None or row["tier"] != "T2":
|
||||
continue
|
||||
task_type = row["task_type"]
|
||||
cells.add(cell_of(task_type, row["error_type"]))
|
||||
wrong_by_type[task_type] = wrong_by_type.get(task_type, 0) + 1
|
||||
n_questions = len(bucket["question_ids"])
|
||||
records.append(
|
||||
VideoRecord(
|
||||
video_id=video_id,
|
||||
type_set=frozenset(bucket["types"]),
|
||||
n_correct=bucket["n_correct"],
|
||||
difficulty=n_questions - bucket["n_correct"],
|
||||
cells=frozenset(cells),
|
||||
wrong_by_type=wrong_by_type,
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectConfig:
|
||||
"""贪心联合约束选择器的实验配置(科研配置,随实验扫动)。
|
||||
|
||||
字段:
|
||||
n_trainval: trainval 目标视频数(多样性阶段的填充上限)。
|
||||
floor_k: 各高信号 task_type 的 T2 defect 数下限(硬约束,floor 阶段满足)。
|
||||
epsilon: test 相对全局的最大允许分布偏差(题型占比 / 难度画像两维,逐桶)。
|
||||
reportable_types: 参与 ε 题型代表性校验的 task_type 集(长尾类型不入约束)。
|
||||
seed: 预洗牌随机种子,仅用于打破等增益平局,保证同 config 同 videos 同解。
|
||||
|
||||
实现细节:
|
||||
floor_k / reportable_types 为不可哈希容器,标 hash=False 排除出自动 __hash__,
|
||||
避免 frozen dataclass 被哈希时报错(本类不作为字典键,仅承载配置)。
|
||||
"""
|
||||
|
||||
n_trainval: int
|
||||
floor_k: dict[str, int] = field(hash=False)
|
||||
epsilon: float
|
||||
reportable_types: frozenset[str] | set[str] = field(hash=False)
|
||||
seed: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SplitAssignment:
|
||||
"""视频级切分归属结果(交给 split_by_video_assignment 做题级切分)。
|
||||
|
||||
字段:
|
||||
trainval: 进入 trainval 的 video_id 元组(按选择顺序,确定性)。
|
||||
test: 补集视频的 video_id 元组(按 videos 原始顺序)。
|
||||
"""
|
||||
|
||||
trainval: tuple[str, ...]
|
||||
test: tuple[str, ...]
|
||||
|
||||
|
||||
class InfeasibleSplitError(Exception):
|
||||
"""floor 硬约束与 ε 守护死锁、无法在不破坏 test 代表性下满足 floor 时抛出。
|
||||
|
||||
fail loud(P5):不静默兜底、不随机塞题,直接暴露不可行并报告未达标类型。
|
||||
"""
|
||||
|
||||
|
||||
def derive_reportable_types(total_by_type: dict[str, int], report_floor: int) -> set[str]:
|
||||
"""派生可 per-type 报告的 task_type 集(长尾处理:题数 ≥ report_floor 才报告)。
|
||||
|
||||
参数:
|
||||
total_by_type: 各 task_type 的总题数(或代理承载数)。
|
||||
report_floor: 报告门限,低于此的类型并入长尾、不单独报告也不入 ε 约束。
|
||||
|
||||
返回:
|
||||
总题数 ≥ report_floor 的 task_type 集合。
|
||||
"""
|
||||
return {task_type for task_type, total in total_by_type.items() if total >= report_floor}
|
||||
|
||||
|
||||
def _type_membership_fraction(records: list[VideoRecord], keys: set[str]) -> dict[str, float]:
|
||||
"""计算各 task_type 在给定视频集中的承载占比(含该题型的视频数 / 总视频数)。
|
||||
|
||||
参数:
|
||||
records: 视频记录子集(非空,调用方保证)。
|
||||
keys: 需计算占比的 task_type 键集。
|
||||
|
||||
返回:
|
||||
{task_type: 占比},占比 ∈ [0, 1]。
|
||||
"""
|
||||
total = len(records)
|
||||
return {key: sum(1 for r in records if key in r.type_set) / total for key in keys}
|
||||
|
||||
|
||||
def _difficulty_fraction(records: list[VideoRecord], buckets: set[int]) -> dict[int, float]:
|
||||
"""计算各难度桶在给定视频集中的占比(难度 = 错题数)。
|
||||
|
||||
参数:
|
||||
records: 视频记录子集(非空,调用方保证)。
|
||||
buckets: 需计算占比的难度桶键集。
|
||||
|
||||
返回:
|
||||
{难度桶: 占比},占比 ∈ [0, 1]。
|
||||
"""
|
||||
total = len(records)
|
||||
return {bucket: sum(1 for r in records if r.difficulty == bucket) / total for bucket in buckets}
|
||||
|
||||
|
||||
def _max_deviation(global_dist: dict, subset_dist: dict, keys: set) -> float:
|
||||
"""逐键取全局与子集分布的最大绝对偏差(键集为空时约定为 0.0)。
|
||||
|
||||
参数:
|
||||
global_dist: 全局分布(键 → 占比)。
|
||||
subset_dist: 子集分布(键 → 占比)。
|
||||
keys: 参与比较的键集。
|
||||
|
||||
返回:
|
||||
逐键 |global - subset| 的最大值;keys 为空返回 0.0。
|
||||
"""
|
||||
if not keys:
|
||||
return 0.0
|
||||
return max(abs(global_dist.get(k, 0.0) - subset_dist.get(k, 0.0)) for k in keys)
|
||||
|
||||
|
||||
def _epsilon_ok(
|
||||
test_video_records: list[VideoRecord],
|
||||
videos_all: list[VideoRecord],
|
||||
config: SelectConfig,
|
||||
) -> bool:
|
||||
"""校验 test 子集相对全局在题型占比与难度画像两维的偏差是否均 ≤ epsilon。
|
||||
|
||||
test 越简单则 headline 越虚高,故 test 必须保持代表性:逐 reportable 题型、逐难度桶
|
||||
比较 test 与全局占比,任一维超 epsilon 即判不合格。
|
||||
|
||||
参数:
|
||||
test_video_records: 候选 test 子集(trainval 补集)。
|
||||
videos_all: 全部视频(全局分布基准)。
|
||||
config: 选择配置,提供 epsilon 与 reportable_types。
|
||||
|
||||
返回:
|
||||
两维最大偏差均 ≤ epsilon 为 True;test 为空视为不合格返回 False。
|
||||
"""
|
||||
if not test_video_records:
|
||||
return False
|
||||
type_keys = set(config.reportable_types)
|
||||
global_type = _type_membership_fraction(videos_all, type_keys)
|
||||
subset_type = _type_membership_fraction(test_video_records, type_keys)
|
||||
if _max_deviation(global_type, subset_type, type_keys) > config.epsilon:
|
||||
return False
|
||||
diff_keys = {r.difficulty for r in videos_all}
|
||||
global_diff = _difficulty_fraction(videos_all, diff_keys)
|
||||
subset_diff = _difficulty_fraction(test_video_records, diff_keys)
|
||||
return _max_deviation(global_diff, subset_diff, diff_keys) <= config.epsilon
|
||||
|
||||
|
||||
def _current_wrong_counts(selected: list[VideoRecord]) -> dict[str, int]:
|
||||
"""聚合已选 trainval 视频的 T2 defect 计数(供 floor 达标判定)。
|
||||
|
||||
参数:
|
||||
selected: 当前已进入 trainval 的视频记录。
|
||||
|
||||
返回:
|
||||
{task_type: T2 defect 累计数}。
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
for video in selected:
|
||||
for task_type, wrong in video.wrong_by_type.items():
|
||||
counts[task_type] = counts.get(task_type, 0) + wrong
|
||||
return counts
|
||||
|
||||
|
||||
def _unmet_floors(selected: list[VideoRecord], floor_k: dict[str, int]) -> dict[str, int]:
|
||||
"""计算尚未达标的 floor 类型及其缺口(已达标类型不返回)。
|
||||
|
||||
参数:
|
||||
selected: 当前已进入 trainval 的视频记录。
|
||||
floor_k: 各高信号 task_type 的 defect 下限。
|
||||
|
||||
返回:
|
||||
{task_type: 缺口数},仅含 current < floor 的类型;全达标返回空 dict。
|
||||
"""
|
||||
counts = _current_wrong_counts(selected)
|
||||
return {
|
||||
task_type: floor - counts.get(task_type, 0)
|
||||
for task_type, floor in floor_k.items()
|
||||
if counts.get(task_type, 0) < floor
|
||||
}
|
||||
|
||||
|
||||
def _floor_fill_count(video: VideoRecord, deficits: dict[str, int]) -> int:
|
||||
"""计算某视频能填补的 floor 缺口槽数(逐类型取 min(defect, 缺口) 求和)。
|
||||
|
||||
参数:
|
||||
video: 候选视频记录。
|
||||
deficits: 各未达标类型的缺口。
|
||||
|
||||
返回:
|
||||
该视频实际可填的槽数总和(0 表示对当前缺口无贡献)。
|
||||
"""
|
||||
return sum(
|
||||
min(video.wrong_by_type.get(task_type, 0), deficit)
|
||||
for task_type, deficit in deficits.items()
|
||||
)
|
||||
|
||||
|
||||
def _marginal_gain(video: VideoRecord, current_cells: set[tuple[str, str]]) -> int:
|
||||
"""计算把某视频移入 trainval 的边际覆盖增益(新开的 T2 格子数)。
|
||||
|
||||
参数:
|
||||
video: 候选视频记录。
|
||||
current_cells: 当前 trainval 的 T2 格子并集。
|
||||
|
||||
返回:
|
||||
video.cells 相对 current_cells 的新增格子数(去重)。
|
||||
"""
|
||||
return len(video.cells - current_cells)
|
||||
|
||||
|
||||
def _prospective_test(pool: list[VideoRecord], candidate: VideoRecord) -> list[VideoRecord]:
|
||||
"""构造"把候选移入 trainval 后"的 test 子集 = 当前剩余池去掉候选。
|
||||
|
||||
参数:
|
||||
pool: 当前尚未进入 trainval 的视频(即当前 test 补集)。
|
||||
candidate: 拟移入 trainval 的候选视频。
|
||||
|
||||
返回:
|
||||
pool 去掉 candidate 后的视频列表。
|
||||
"""
|
||||
return [r for r in pool if r.video_id != candidate.video_id]
|
||||
|
||||
|
||||
def _satisfy_floors(
|
||||
selected: list[VideoRecord],
|
||||
pool: list[VideoRecord],
|
||||
videos_all: list[VideoRecord],
|
||||
config: SelectConfig,
|
||||
) -> None:
|
||||
"""Floor 阶段:硬约束优先,逐步移入能填 floor 槽且不破 ε 的视频(就地改 selected/pool)。
|
||||
|
||||
每轮取未达标类型的缺口,候选 = 能填 ≥1 槽 且 移入后 test 仍满足 ε 的视频;候选为空即
|
||||
死锁抛 InfeasibleSplitError;否则选填槽最多者(等槽数按预洗牌顺序取首个,确定性)。
|
||||
|
||||
n_trainval 是硬预算:floor 需求超出预算(尚有缺口却已达 n_trainval)也判不可行 fail loud,
|
||||
保证返回的 trainval 永不超过 n_trainval(不因硬约束悄悄超额、挤占 test)。
|
||||
|
||||
参数:
|
||||
selected: 当前 trainval(就地追加)。
|
||||
pool: 当前剩余池 = test 补集(就地移除)。
|
||||
videos_all: 全部视频(ε 全局基准)。
|
||||
config: 选择配置。
|
||||
|
||||
异常:
|
||||
InfeasibleSplitError: 存在未达标类型但无候选可在不破 ε 下填补,
|
||||
或 floor 需求超过 n_trainval 预算。
|
||||
"""
|
||||
while True:
|
||||
deficits = _unmet_floors(selected, config.floor_k)
|
||||
if not deficits:
|
||||
return
|
||||
if len(selected) >= config.n_trainval:
|
||||
raise InfeasibleSplitError(
|
||||
f"floor 需求超过 n_trainval={config.n_trainval} 预算,剩余缺口: {dict(deficits)}"
|
||||
)
|
||||
candidates = [
|
||||
video
|
||||
for video in pool
|
||||
if _floor_fill_count(video, deficits) > 0
|
||||
and _epsilon_ok(_prospective_test(pool, video), videos_all, config)
|
||||
]
|
||||
if not candidates:
|
||||
raise InfeasibleSplitError(
|
||||
f"floor 无法在 ε≤{config.epsilon} 下满足,未达标类型缺口: {dict(deficits)}"
|
||||
)
|
||||
pick = max(candidates, key=lambda video: _floor_fill_count(video, deficits))
|
||||
selected.append(pick)
|
||||
pool.remove(pick)
|
||||
|
||||
|
||||
def _maximize_diversity(
|
||||
selected: list[VideoRecord],
|
||||
pool: list[VideoRecord],
|
||||
videos_all: list[VideoRecord],
|
||||
config: SelectConfig,
|
||||
) -> None:
|
||||
"""多样性阶段:submodular 贪心,按边际覆盖增益降序填至 n_trainval(就地改 selected/pool)。
|
||||
|
||||
每轮对剩余视频算新开格子数,按 -增益稳定排序(等增益按预洗牌顺序),取第一个移入后 test
|
||||
仍满足 ε 的视频;若无任一视频可加而不破 ε,则停并记 warning(欠额,不静默不报错)。
|
||||
|
||||
参数:
|
||||
selected: 当前 trainval(就地追加)。
|
||||
pool: 当前剩余池 = test 补集(就地移除)。
|
||||
videos_all: 全部视频(ε 全局基准)。
|
||||
config: 选择配置。
|
||||
"""
|
||||
while len(selected) < config.n_trainval:
|
||||
if not pool:
|
||||
logger.warning(
|
||||
"多样性阶段剩余池耗尽,trainval 欠额: {}/{}", len(selected), config.n_trainval
|
||||
)
|
||||
return
|
||||
current_cells = set().union(*(v.cells for v in selected)) if selected else set()
|
||||
ranked = sorted(pool, key=lambda video: -_marginal_gain(video, current_cells))
|
||||
pick = next(
|
||||
(
|
||||
video
|
||||
for video in ranked
|
||||
if _epsilon_ok(_prospective_test(pool, video), videos_all, config)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if pick is None:
|
||||
logger.warning(
|
||||
"多样性阶段 ε 守护阻断全部候选,trainval 欠额: {}/{}",
|
||||
len(selected),
|
||||
config.n_trainval,
|
||||
)
|
||||
return
|
||||
selected.append(pick)
|
||||
pool.remove(pick)
|
||||
|
||||
|
||||
def select_split(videos: list[VideoRecord], *, config: SelectConfig) -> SplitAssignment:
|
||||
"""贪心联合约束视频级切分:floor 硬约束先满足、多样性覆盖后最大化、ε 守护 test 代表性。
|
||||
|
||||
核心洞察:全数据集错题总数固定,越把信号塞 trainval、test 越简单、headline 越虚高,
|
||||
故 test 必须保持代表性(ε 约束),trainval 只靠 floor + 多样性覆盖富集,不从 test 偷难题。
|
||||
|
||||
两阶段贪心(均带 ε 守护):先 Floor 阶段满足各高信号类型 defect 下限(不可行 fail loud),
|
||||
再多样性阶段按边际覆盖增益填至 n_trainval(欠额记 warning)。test = trainval 补集。
|
||||
|
||||
确定性:入场用 random.Random(seed) 对视频列表做一次预洗牌,此后 max / 稳定排序仅取首个,
|
||||
seed 只打破等增益 / 等槽数平局;同 config 同 videos → 同结果。
|
||||
|
||||
参数:
|
||||
videos: 全部视频记录(Task 7 build_video_records 产物)。
|
||||
config: 选择配置(关键字传入,含 n_trainval / floor_k / epsilon / reportable_types / seed)。
|
||||
|
||||
返回:
|
||||
SplitAssignment,trainval 按选择顺序、test 按 videos 原始顺序。
|
||||
|
||||
异常:
|
||||
InfeasibleSplitError: videos 为空、floor 与 ε 死锁无法满足、
|
||||
或 floor 需求超过 n_trainval 预算。
|
||||
"""
|
||||
if not videos:
|
||||
raise InfeasibleSplitError("videos 为空,无法执行切分")
|
||||
rng = random.Random(config.seed)
|
||||
pool = list(videos)
|
||||
rng.shuffle(pool)
|
||||
selected: list[VideoRecord] = []
|
||||
_satisfy_floors(selected, pool, videos, config)
|
||||
_maximize_diversity(selected, pool, videos, config)
|
||||
trainval_ids = {video.video_id for video in selected}
|
||||
trainval = tuple(video.video_id for video in selected)
|
||||
test = tuple(video.video_id for video in videos if video.video_id not in trainval_ids)
|
||||
return SplitAssignment(trainval=trainval, test=test)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""把 predictions.steps_json 转成 RunLog.get_traces 的行形。
|
||||
|
||||
infer_adhoc 的 traces 表为空,轨迹存于 steps_json({thought, tool_call, tool_output})。
|
||||
诊断管线经 get_traces 消费轨迹,故需此确定性转换适配。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def steps_json_to_trace_rows(
|
||||
video_id: str, question_id: str, steps_json: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将单题 steps_json 解析为 trace 行列表(step 从 0 递增)。
|
||||
|
||||
参数:
|
||||
video_id: 视频 ID。
|
||||
question_id: 题 ID。
|
||||
steps_json: predictions.steps_json 原文(JSON 数组字符串)。
|
||||
|
||||
返回:
|
||||
行字典列表,字段对齐 traces 表 schema;空/空数组返回 []。
|
||||
|
||||
关键实现细节:
|
||||
- 工具名读 tool_call.tool(infer_adhoc 真实字段),对极少数历史
|
||||
数据的 name 做 back-compat 回退。
|
||||
- steps_json 非 JSON 数组时直接报错,不做兜底掩盖。
|
||||
"""
|
||||
if not steps_json or not steps_json.strip():
|
||||
return []
|
||||
steps = json.loads(steps_json)
|
||||
if not isinstance(steps, list):
|
||||
raise ValueError(f"steps_json 非数组: {question_id}")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for i, s in enumerate(steps):
|
||||
call = s.get("tool_call") or {}
|
||||
rows.append(
|
||||
{
|
||||
"video_id": video_id,
|
||||
"question_id": question_id,
|
||||
"step": i,
|
||||
# infer_adhoc 用 "tool";back-compat 兼容极少数 "name"
|
||||
"tool_name": call.get("tool", call.get("name")),
|
||||
"tool_args": call.get("args", {}),
|
||||
"tool_output": s.get("tool_output"),
|
||||
"thought": s.get("thought"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
+28
-4
@@ -190,6 +190,9 @@ def init_seed(
|
||||
baseline_run_id: str,
|
||||
parent: str | None,
|
||||
description: str,
|
||||
*,
|
||||
pools_json: Path | None = None,
|
||||
split_manifest: Path | None = None,
|
||||
) -> Path:
|
||||
"""在 store/seeds/<name> 写一个种子:权重 + baseline.db + seed.json。
|
||||
|
||||
@@ -202,6 +205,10 @@ def init_seed(
|
||||
baseline_run_id: 全量记录的 run_id,fresh 时注入 build_pools。
|
||||
parent: 来源(initial 为 None)。
|
||||
description: 人类可读说明。
|
||||
pools_json: 可选,冻结切分 pools.json 源路径;提供时拷入 seed 目录,
|
||||
供 fresh 训练时携带冻结切分进 workspace(见 init_workspace_from_seed)。
|
||||
split_manifest: 可选,冻结切分 split_manifest.json 源路径;提供时拷入 seed
|
||||
目录,供加载时校验 pools.json 内容指纹(pools_sha256)。
|
||||
|
||||
返回:
|
||||
种子目录路径。
|
||||
@@ -216,6 +223,10 @@ def init_seed(
|
||||
shutil.copytree(skills_dir, seed_dir / "skills")
|
||||
shutil.copytree(prompts_dir, seed_dir / "prompts")
|
||||
shutil.copy2(baseline_db, seed_dir / "baseline.db")
|
||||
if pools_json is not None:
|
||||
shutil.copy2(pools_json, seed_dir / "pools.json")
|
||||
if split_manifest is not None:
|
||||
shutil.copy2(split_manifest, seed_dir / "split_manifest.json")
|
||||
(seed_dir / "seed.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -266,7 +277,9 @@ def read_seed(store_dir: Path, name: str) -> dict:
|
||||
return json.loads(seed_json.read_text())
|
||||
|
||||
|
||||
def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
|
||||
def extract_run_db(
|
||||
src_db: Path, dst_db: Path, run_id: str, *, dedupe_per_question: bool = False
|
||||
) -> None:
|
||||
"""从 src_db 抽出某 run_id 的 _runs + predictions 行,写一个最小 db(种子 baseline.db)。
|
||||
|
||||
用源表的**原始 CREATE 语句**重建目标表,保留主键/列类型/约束——
|
||||
@@ -277,6 +290,9 @@ def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
|
||||
src_db: 源 harness.db。
|
||||
dst_db: 目标 db(不得已存在)。
|
||||
run_id: 要抽取的 run。
|
||||
dedupe_per_question: True 时 predictions 表每 question_id 仅保留 rowid 最小
|
||||
的首行(对齐 canonical「每 question_id 取第一行 ORDER BY rowid」口径,
|
||||
902→900)。_runs 表不受影响。
|
||||
|
||||
异常:
|
||||
RuntimeError: 源中无该表或无该 run 的行。
|
||||
@@ -294,9 +310,17 @@ def extract_run_db(src_db: Path, dst_db: Path, run_id: str) -> None:
|
||||
dst.execute(create_sql[0])
|
||||
cols = [r[1] for r in src.execute(f"PRAGMA table_info({table})")]
|
||||
col_sql = ", ".join(cols)
|
||||
rows = src.execute(
|
||||
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
|
||||
).fetchall()
|
||||
if table == "predictions" and dedupe_per_question:
|
||||
rows = src.execute(
|
||||
f"SELECT {col_sql} FROM {table} WHERE run_id=? "
|
||||
"AND rowid IN (SELECT MIN(rowid) FROM predictions "
|
||||
"WHERE run_id=? GROUP BY question_id)",
|
||||
(run_id, run_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = src.execute(
|
||||
f"SELECT {col_sql} FROM {table} WHERE run_id=?", (run_id,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
raise RuntimeError(f"{table} 中无 run_id={run_id} 的行")
|
||||
ph = ", ".join("?" * len(cols))
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""诊断侧树读取适配:把嵌套 tree.json 展平成诊断消费的扁平 nodes dict。
|
||||
|
||||
诊断编排(core/evolution/diagnose.py)期望 tree_data 形如
|
||||
{"nodes": {node_id: {card, level, time_range}}},但 TRM5 建树产物
|
||||
store/videos/<vid>/tree.json 是嵌套 {"metadata","roots":[...]}。本模块递归展平,
|
||||
接通 TRM4→TRM5 迁移时断掉的 ground_truth 加载环。
|
||||
|
||||
不走 TreeIndex 对象层:仅 L1Node 有 to_dict(app/tree/index.py:260),L2/L3 为其内部闭包,
|
||||
且 to_dict 输出无 level、L3 用 timestamp 无 time_range。直接遍历 json 更省且零改建树模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_tree_nodes(store_dir: Path, video_id: str) -> dict[str, Any]:
|
||||
"""加载单视频 tree.json 并展平成扁平 nodes dict。
|
||||
|
||||
参数:
|
||||
store_dir: store 根目录(含 videos/<video_id>/tree.json)。
|
||||
video_id: 视频标识。
|
||||
|
||||
返回:
|
||||
{"nodes": {node_id: {"card": dict, "level": int, "time_range": list}}}。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: tree.json 不存在(沿用 factory.py fail-loud 先例)。
|
||||
ValueError: roots 非 list 或为空、节点缺 id、或节点既无 time_range 又无 timestamp。
|
||||
|
||||
关键实现:
|
||||
level 由遍历深度赋值(root=1/child=2/孙=3),不解析 node_id——node_id 累积式
|
||||
(..._L1_..._L2_..._L3_)用正则首匹配会把 L2/L3 误判成 1。
|
||||
L3 无 time_range,用 timestamp 合成 [t, t]。
|
||||
"""
|
||||
tree_path = store_dir / "videos" / video_id / "tree.json"
|
||||
if not tree_path.exists():
|
||||
raise FileNotFoundError(f"树索引文件不存在: {tree_path}(诊断需真实树,P5 fail loud)")
|
||||
tree = json.loads(tree_path.read_text(encoding="utf-8"))
|
||||
roots = tree.get("roots")
|
||||
if not isinstance(roots, list) or not roots:
|
||||
raise ValueError(f"树无有效 roots: {tree_path}")
|
||||
|
||||
nodes: dict[str, Any] = {}
|
||||
|
||||
def _walk(node: dict[str, Any], level: int) -> None:
|
||||
node_id = node.get("id")
|
||||
if not isinstance(node_id, str) or not node_id:
|
||||
raise ValueError(f"节点缺 id: {tree_path}")
|
||||
time_range = node.get("time_range")
|
||||
if time_range is None:
|
||||
ts = node.get("timestamp")
|
||||
if ts is None:
|
||||
raise ValueError(
|
||||
f"节点既无 time_range 又无 timestamp(树损坏): {node_id} in {tree_path}"
|
||||
)
|
||||
time_range = [ts, ts]
|
||||
nodes[node_id] = {
|
||||
"card": node.get("card", {}),
|
||||
"level": level,
|
||||
"time_range": time_range,
|
||||
}
|
||||
for child in node.get("children", []) or []:
|
||||
_walk(child, level + 1)
|
||||
|
||||
for root in roots:
|
||||
_walk(root, 1)
|
||||
|
||||
return {"nodes": nodes}
|
||||
|
||||
|
||||
def load_tree_data_for_videos(store_dir: Path, video_ids: list[str]) -> dict[str, Any]:
|
||||
"""按一组 video_id 去重加载展平树,供诊断按 video 注入。
|
||||
|
||||
参数:
|
||||
store_dir: store 根目录。
|
||||
video_ids: 视频标识列表(可含重复,内部按首次出现顺序去重)。
|
||||
|
||||
返回:
|
||||
{video_id: {"nodes": {...}}}。
|
||||
|
||||
异常:
|
||||
同 load_tree_nodes(任一视频树缺失/无效即 fail-loud)。
|
||||
"""
|
||||
return {vid: load_tree_nodes(store_dir, vid) for vid in dict.fromkeys(video_ids)}
|
||||
+607
-339
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,824 @@
|
||||
"""结果驱动视频级切分的自包含两阶段 CLI 入口。
|
||||
|
||||
把整条离线管线的编排从 shell 搬进 Python:一次调用内联串起
|
||||
Phase 1 离线诊断(run_baseline_diagnosis,LLM 重活,断点续跑幂等)→
|
||||
Phase 2 冻结切分(build_split,纯 code-controlled,产出 pools.json + manifest)→
|
||||
McNemar 功效护栏(validation 池错题数达阈校验)。
|
||||
|
||||
复现锚点约定(C-2):
|
||||
- pools.json 的内容(+ seed + diag_fingerprint)是切分的**复现锚点**——相同输入
|
||||
产出字节级相同的 pools.json 与 pools_sha256。
|
||||
- manifest 的 generated_at 是**溯源元数据**,非复现锚点:真实运行默认盖真实 UTC
|
||||
now(记录本次切分何时产出),但可用 `--generated-at <ISO>` 显式固定,以对
|
||||
manifest 做字节级复现比对。write_manifest 库内不调 datetime.now,时间戳一律由
|
||||
本 CLI 传入。
|
||||
|
||||
设计要点:
|
||||
- 诊断口径指纹 = (诊断 prompt 版本, 模型名, git 短 SHA) 三分量合成,隔离不同
|
||||
诊断配置的信号;换 prompt / 模型 / 代码实现即换指纹,旧信号不被覆盖。
|
||||
- 真实依赖组装参考 app/harness/runner.py::_run_diagnosis:GovernedLLMClient
|
||||
(search llm, thinking=True) + RunLogImpl(harness.db) + VersionedSkillStore +
|
||||
DiagnosePrompts(项目根 prompts/) + tree_data 按 wrong_ids 涉及 video 预加载
|
||||
(store/videos/<vid>/tree.json 展平)。
|
||||
- 缺 .env / config 关键项一律 fail loud(P5),绝不静默兜底。
|
||||
- `--dry-run` 用假 deps 跑通两阶段 wiring 不真调 LLM,打印将执行的步骤 + 指纹,
|
||||
用于校验装配正确性(对齐 CLAUDE.md §2.5 smoke test)。
|
||||
|
||||
编排函数(run_pipeline)通过依赖注入接收 DiagnosisDeps / signal_store / wrong_ids /
|
||||
questions / canonical_preds,便于单测用假实现替换、不触真实 LLM 与 harness.db。
|
||||
其中 canonical_preds 供 Phase 0 补 INFRA / 空预测错题的 T0 信号(这些题不进诊断)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
from loguru import logger
|
||||
|
||||
from app.harness.baseline_diagnosis import DiagnosisDeps, run_baseline_diagnosis
|
||||
from app.harness.build_split import (
|
||||
SplitBuildConfig,
|
||||
SplitBuildResult,
|
||||
build_split,
|
||||
load_canonical_predictions,
|
||||
)
|
||||
from app.harness.split_selection import diag_fingerprint
|
||||
from app.question_gen.loader import load_benchmark
|
||||
from core.evolution.types import DiagnosisSignalRow
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.harness.pools import Pools
|
||||
from core.evolution.protocols import DiagnosisSignalStore
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
# 与 core.evolution.diagnose._INFRA_STOP_REASONS 对齐:执行/解析层失败排除出可诊断错题。
|
||||
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
|
||||
|
||||
# 工程路径默认值(少变;可经 CLI 单次覆盖)。诊断信号表建在 harness.db。
|
||||
_DEFAULT_HARNESS_DB = Path("workspaces/default/harness.db")
|
||||
_DEFAULT_QUESTIONS_DIR = Path("store/questions/benchmarks/Video-MME")
|
||||
_DEFAULT_OUT_DIR = Path("workspaces/video-split")
|
||||
_DEFAULT_STORE_DIR = Path("store") # tree.json 在 store/videos/<vid>/
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置解析(fail loud)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoSplitConfig:
|
||||
"""结果驱动视频级切分的科研旋钮快照(从 config/video_split.yaml 解析)。
|
||||
|
||||
字段:
|
||||
baseline_run_id: 基线 run 标识(错题诊断与切分依据)。
|
||||
n_trainval: trainval 目标视频数(多样性阶段填充上限)。
|
||||
epsilon: test 相对全局最大允许分布偏差(题型 / 难度两维)。
|
||||
report_floor: per-type 报告门限,题数 ≥ 此值的 task_type 才入 ε 约束。
|
||||
val_wrong_min: validation 池最少错题数(McNemar 功效阈;0=不检查)。
|
||||
val_ratio: validation 占 trainval 视频组总数的比例。
|
||||
seed: 贪心选择器预洗牌 + 视频组题级切分种子。
|
||||
floor_k: 各高信号 task_type 的 T2 defect 下限(硬约束)。
|
||||
prompt_version: 诊断 prompt 版本标识(指纹分量)。
|
||||
model: 执行诊断的模型名(指纹分量)。
|
||||
"""
|
||||
|
||||
baseline_run_id: str
|
||||
n_trainval: int
|
||||
epsilon: float
|
||||
report_floor: int
|
||||
val_wrong_min: int
|
||||
val_ratio: float
|
||||
seed: int
|
||||
floor_k: dict[str, int]
|
||||
prompt_version: str
|
||||
model: str
|
||||
|
||||
|
||||
def _require(section: dict[str, Any], keys: tuple[str, ...], where: str) -> None:
|
||||
"""校验 section 含全部必填键,缺任一即 fail loud(P5,不静默兜底)。
|
||||
|
||||
参数:
|
||||
section: 待校验的配置子字典。
|
||||
keys: 必填键元组。
|
||||
where: 出错信息中标注的段名(如 "video_split")。
|
||||
|
||||
异常:
|
||||
SystemExit: 存在缺失键。
|
||||
"""
|
||||
missing = [k for k in keys if k not in section]
|
||||
if missing:
|
||||
raise SystemExit(f"config {where} 段缺关键项 {missing},无法运行(P5 fail loud)")
|
||||
|
||||
|
||||
def parse_config(raw: dict[str, Any]) -> VideoSplitConfig:
|
||||
"""把 yaml 原始字典解析为 VideoSplitConfig,缺关键项 fail loud。
|
||||
|
||||
参数:
|
||||
raw: yaml.safe_load 的顶层字典,需含 video_split / diag 两段。
|
||||
|
||||
返回:
|
||||
VideoSplitConfig 冻结快照。
|
||||
|
||||
异常:
|
||||
SystemExit: 缺 video_split / diag 段或段内关键项。
|
||||
"""
|
||||
if "video_split" not in raw or "diag" not in raw:
|
||||
raise SystemExit("config 缺 video_split / diag 段,无法运行(P5 fail loud)")
|
||||
vs = raw["video_split"]
|
||||
dg = raw["diag"]
|
||||
_require(
|
||||
vs,
|
||||
(
|
||||
"baseline_run_id",
|
||||
"n_trainval",
|
||||
"epsilon",
|
||||
"report_floor",
|
||||
"val_wrong_min",
|
||||
"val_ratio",
|
||||
"seed",
|
||||
"floor_k",
|
||||
),
|
||||
"video_split",
|
||||
)
|
||||
_require(dg, ("prompt_version", "model"), "diag")
|
||||
return VideoSplitConfig(
|
||||
baseline_run_id=vs["baseline_run_id"],
|
||||
n_trainval=vs["n_trainval"],
|
||||
epsilon=vs["epsilon"],
|
||||
report_floor=vs["report_floor"],
|
||||
val_wrong_min=vs["val_wrong_min"],
|
||||
val_ratio=vs["val_ratio"],
|
||||
seed=vs["seed"],
|
||||
floor_k=dict(vs["floor_k"]),
|
||||
prompt_version=dg["prompt_version"],
|
||||
model=dg["model"],
|
||||
)
|
||||
|
||||
|
||||
def load_config(config_path: Path) -> VideoSplitConfig:
|
||||
"""读取并解析 video_split yaml 配置文件(缺文件 / 关键项 fail loud)。
|
||||
|
||||
参数:
|
||||
config_path: yaml 配置路径。
|
||||
|
||||
返回:
|
||||
VideoSplitConfig。
|
||||
|
||||
异常:
|
||||
SystemExit: 文件不存在或缺关键项。
|
||||
"""
|
||||
if not config_path.exists():
|
||||
raise SystemExit(f"config 文件不存在: {config_path}(P5 fail loud)")
|
||||
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
return parse_config(raw)
|
||||
|
||||
|
||||
def git_short_sha() -> str:
|
||||
"""取当前 git 短 SHA 作为诊断口径指纹的代码分量(诊断代码变则指纹变)。
|
||||
|
||||
返回:
|
||||
git rev-parse --short HEAD 输出(去空白)。
|
||||
|
||||
异常:
|
||||
SystemExit: 非 git 仓库或 git 不可用(fail loud,指纹不可缺分量)。
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
raise SystemExit(f"无法获取 git 短 SHA 作为诊断代码版本: {exc}(P5 fail loud)") from exc
|
||||
sha = out.stdout.strip()
|
||||
if not sha:
|
||||
raise SystemExit("git rev-parse --short HEAD 返回空,诊断指纹缺代码分量(P5 fail loud)")
|
||||
return sha
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实依赖组装(参考 runner.py::_run_diagnosis)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _DiagLLMSettings:
|
||||
"""诊断 LLM 的工程配置(从 .env 读取 search llm 凭证 + 韧性旋钮)。
|
||||
|
||||
仅承载诊断所需字段(搜索 LLM = 诊断 judge),不复用 main.InfraSettings 以免
|
||||
构造整套适配器(embed / vlm)的重活;缺关键凭证 fail loud。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class _Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
search_llm_model: str = ""
|
||||
search_llm_base_url: str = ""
|
||||
search_llm_api_key: str = ""
|
||||
redis_url: str = ""
|
||||
redis_cache_ttl: int = 86400
|
||||
llm_timeout: float = 300.0
|
||||
llm_max_retries: int = 3
|
||||
llm_retry_base_delay: float = 20.0
|
||||
llm_retry_max_delay: float = 120.0
|
||||
llm_circuit_breaker_threshold: int = 48
|
||||
llm_circuit_breaker_cooldown: float = 60.0
|
||||
llm_ttft_timeout: float = 30.0
|
||||
llm_inter_token_timeout: float = 15.0
|
||||
|
||||
self._s = _Settings()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._s, name)
|
||||
|
||||
|
||||
def _build_redis_cache(settings: Any) -> Any | None:
|
||||
"""按 .env redis_url 构建响应缓存(不可用则降级 None,与 main 一致)。"""
|
||||
if not settings.redis_url:
|
||||
return None
|
||||
|
||||
from adapters.redis_cache import RedisResponseCache, _resolve_cache_ttl
|
||||
|
||||
# 配置校验 fail-loud(不属于 Redis 连接故障,不得被下方降级 except 吞掉)
|
||||
ttl_s = _resolve_cache_ttl(settings.redis_cache_ttl)
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
return RedisResponseCache(redis=redis_client, ttl_s=ttl_s)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存不可用,诊断降级为无缓存模式")
|
||||
return None
|
||||
|
||||
|
||||
def build_diagnosis_deps(
|
||||
*,
|
||||
harness_db: Path,
|
||||
store_dir: Path,
|
||||
video_ids: list[str],
|
||||
concurrency: int,
|
||||
expected_model: str,
|
||||
) -> DiagnosisDeps:
|
||||
"""组装 Phase 1 诊断的真实依赖束(GovernedLLMClient + RunLogImpl + prompts)。
|
||||
|
||||
与 runner.py::_run_diagnosis 对齐:search LLM(thinking=True)作诊断 judge,
|
||||
RunLogImpl 只读读取 harness.db 的 predictions/traces,VersionedSkillStore 读技能,
|
||||
DiagnosePrompts 从项目根 prompts/ 加载,tree_data 按 video_ids 从
|
||||
store/videos/<vid>/tree.json 展平预加载(诊断需真实树,缺失即 fail-loud)。
|
||||
|
||||
参数:
|
||||
harness_db: harness.db 路径(诊断读预测 + 信号落库同库)。
|
||||
store_dir: store 根目录(含 videos/<vid>/tree.json)。
|
||||
video_ids: wrong_ids 涉及的 video 标识列表(可含重复,内部去重加载树)。
|
||||
concurrency: 诊断并发上限。
|
||||
expected_model: config.diag.model(诊断口径指纹的模型分量)。必须与 .env
|
||||
SEARCH_LLM_MODEL 一致——指纹里的 model 与实际诊断所用 model 不一致会让
|
||||
信号以错误模型指纹落库,破坏可复现 / resume / 口径隔离,故此处 fail loud。
|
||||
|
||||
返回:
|
||||
DiagnosisDeps 冻结依赖束。
|
||||
|
||||
异常:
|
||||
SystemExit: .env 缺 search LLM 凭证(model / base_url / api_key 任一为空),
|
||||
或 config.diag.model 与 .env SEARCH_LLM_MODEL 不一致(指纹漂移防护)。
|
||||
FileNotFoundError / ValueError: video_ids 中任一 video 的 tree.json 缺失或
|
||||
无效(load_tree_data_for_videos fail-loud,诊断需真实树)。
|
||||
"""
|
||||
from adapters.breaker import CircuitBreaker
|
||||
from adapters.llm import GovernedLLMClient
|
||||
from adapters.telemetry import SQLiteTelemetryRecorder
|
||||
from app.harness.log import RunLogImpl
|
||||
from app.harness.workspace import VersionedSkillStore
|
||||
|
||||
settings = _DiagLLMSettings()
|
||||
if not (
|
||||
settings.search_llm_model and settings.search_llm_base_url and settings.search_llm_api_key
|
||||
):
|
||||
raise SystemExit(
|
||||
"诊断 LLM 凭证缺失:.env 需配置 SEARCH_LLM_MODEL / SEARCH_LLM_BASE_URL / "
|
||||
"SEARCH_LLM_API_KEY(P5 fail loud,不静默兜底)"
|
||||
)
|
||||
if expected_model != settings.search_llm_model:
|
||||
raise SystemExit(
|
||||
"诊断模型指纹漂移:config.diag.model="
|
||||
f"{expected_model!r} 与 .env SEARCH_LLM_MODEL={settings.search_llm_model!r} "
|
||||
"不一致;指纹里的 model 必须等于实际诊断所用 model(P5 fail loud,"
|
||||
"请对齐 config/video_split.yaml diag.model 与 .env SEARCH_LLM_MODEL)"
|
||||
)
|
||||
|
||||
telemetry_db = Path("logs/telemetry.db")
|
||||
telemetry_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
telemetry = SQLiteTelemetryRecorder(telemetry_db)
|
||||
breaker = CircuitBreaker(
|
||||
fail_threshold=max(settings.llm_circuit_breaker_threshold, 1),
|
||||
cooldown_s=settings.llm_circuit_breaker_cooldown,
|
||||
)
|
||||
llm = GovernedLLMClient(
|
||||
model=settings.search_llm_model,
|
||||
base_url=settings.search_llm_base_url,
|
||||
api_key=settings.search_llm_api_key,
|
||||
provider=settings.search_llm_model.split("-")[0],
|
||||
thinking=True,
|
||||
breaker=breaker,
|
||||
cache=_build_redis_cache(settings),
|
||||
telemetry=telemetry,
|
||||
timeout_s=settings.llm_timeout,
|
||||
ttft_timeout_s=settings.llm_ttft_timeout,
|
||||
inter_token_timeout_s=settings.llm_inter_token_timeout,
|
||||
max_retries=settings.llm_max_retries,
|
||||
retry_base_delay_s=settings.llm_retry_base_delay,
|
||||
retry_max_delay_s=settings.llm_retry_max_delay,
|
||||
)
|
||||
from app.harness.tree_nodes import load_tree_data_for_videos
|
||||
|
||||
return DiagnosisDeps(
|
||||
run_log=RunLogImpl(str(harness_db)),
|
||||
llm=llm,
|
||||
skill_store=VersionedSkillStore(_diagnosis_skills_dir()),
|
||||
prompts=_load_diagnose_prompts(),
|
||||
tree_data=load_tree_data_for_videos(store_dir, video_ids),
|
||||
concurrency=concurrency,
|
||||
)
|
||||
|
||||
|
||||
def _diagnosis_skills_dir() -> Path:
|
||||
"""诊断用技能目录:种子 store 的当前技能版本(诊断读技能遵从判定)。
|
||||
|
||||
诊断只读技能内容判断"是否遵从技能",用 store 种子 v1 即可(与基线 run 一致)。
|
||||
"""
|
||||
return Path("store/skills/v1")
|
||||
|
||||
|
||||
def _load_diagnose_prompts() -> Any:
|
||||
"""加载诊断模板束(从项目根 prompts/ 读取;与 runner._load_diagnose_prompts 一致)。"""
|
||||
from core.evolution.types import DiagnosePrompts
|
||||
|
||||
def _read(name: str) -> str:
|
||||
p = Path("prompts") / name
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(f"缺进化/诊断模板: {p}(请从 TRM4 迁移或检查 prompts/)")
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
return DiagnosePrompts(
|
||||
defect_vs_lapse=_read("defect_vs_lapse.md"),
|
||||
reasoning_sub=_read("reasoning_sub.md"),
|
||||
span_eval_system=_read("span_eval_system.md"),
|
||||
missed_nodes=_read("missed_nodes.md"),
|
||||
skill_adherence=_read("skill_adherence.md"),
|
||||
confirmation_bias=_read("confirmation_bias.md"),
|
||||
evidence_sufficiency=_read("evidence_sufficiency.md"),
|
||||
)
|
||||
|
||||
|
||||
def select_diagnosable_wrong_ids(preds: list[dict]) -> list[str]:
|
||||
"""从 canonical 预测筛出可诊断错题 question_id(保序)。
|
||||
|
||||
可诊断错题判据:预测非空 且 stop_reason 非 INFRA(error / parse_error)且
|
||||
归一后预测 != 答案。INFRA / 空预测错题不进 wrong_ids——它们改由
|
||||
persist_infra_t0_rows 直接落 T0(run_diagnosis 内部也会二次排除同类题)。
|
||||
|
||||
参数:
|
||||
preds: load_canonical_predictions 产出的 canonical 预测行(已按 qid 去重)。
|
||||
|
||||
返回:
|
||||
可诊断错题 question_id 列表(保 preds 顺序)。
|
||||
"""
|
||||
wrong_ids: list[str] = []
|
||||
for pred in preds:
|
||||
prediction = (pred["prediction"] or "").strip()
|
||||
if not prediction or pred["stop_reason"] in _INFRA_STOP_REASONS:
|
||||
continue
|
||||
if not pred["correct"]:
|
||||
wrong_ids.append(pred["question_id"])
|
||||
return wrong_ids
|
||||
|
||||
|
||||
def persist_infra_t0_rows(
|
||||
store: DiagnosisSignalStore,
|
||||
preds: list[dict],
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
) -> int:
|
||||
"""把非正确且 INFRA / 空预测的错题以 T0 信号行 upsert 落库(幂等)。
|
||||
|
||||
这些题(stop_reason ∈ {error, parse_error} 或预测为空)从不进入 run_diagnosis
|
||||
(筛选时被前置排除),故其 T0 信号必须在此单独补齐——否则 signal store 缺这些行,
|
||||
tier 分布 / manifest 不完整(计划要求 4 个 INFRA 空预测错题 → T0)。
|
||||
|
||||
投影口径与 baseline_diagnosis 的 INFRA 投影一致:infra=True、tier="T0"、
|
||||
error_type / cause_category / evolution_target 均 None、degraded=False;
|
||||
video_id / task_type 从 canonical 预测取。store.upsert 按主键
|
||||
(question_id, baseline_run_id, diag_fingerprint) 幂等,重复调用零副作用。
|
||||
|
||||
参数:
|
||||
store: 诊断信号存储端口(与诊断落库同一 store)。
|
||||
preds: load_canonical_predictions 产出的 canonical 预测行。
|
||||
baseline_run_id: 基线 run 标识(信号行主键之一)。
|
||||
diag_fingerprint: 诊断口径指纹(信号行主键之一)。
|
||||
|
||||
返回:
|
||||
落库的 T0 行数(供日志)。
|
||||
"""
|
||||
count = 0
|
||||
for pred in preds:
|
||||
prediction = (pred["prediction"] or "").strip()
|
||||
is_infra_or_empty = pred["stop_reason"] in _INFRA_STOP_REASONS or not prediction
|
||||
if pred["correct"] or not is_infra_or_empty:
|
||||
continue
|
||||
store.upsert(
|
||||
DiagnosisSignalRow(
|
||||
question_id=pred["question_id"],
|
||||
video_id=pred["video_id"],
|
||||
baseline_run_id=baseline_run_id,
|
||||
diag_fingerprint=diag_fingerprint,
|
||||
task_type=pred["task_type"],
|
||||
error_type=None,
|
||||
cause_category=None,
|
||||
tier="T0",
|
||||
evolution_target=None,
|
||||
degraded=False,
|
||||
infra=True,
|
||||
session_id=None,
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def load_questions_by_id(questions_dir: Path) -> dict[str, GeneratedQuestion]:
|
||||
"""加载 benchmark 全部题并建 question_id → GeneratedQuestion 映射。
|
||||
|
||||
覆盖 wrong_ids 与 run_diagnosis 返回的全部 infra/degraded 题(取 video_id/task_type)。
|
||||
|
||||
参数:
|
||||
questions_dir: benchmark 题库目录。
|
||||
|
||||
返回:
|
||||
question_id → GeneratedQuestion 映射。
|
||||
"""
|
||||
return {q.question_id: q for q in load_benchmark(questions_dir)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# McNemar 功效护栏
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check_mcnemar_power(pools: Pools, val_wrong_min: int) -> int:
|
||||
"""校验 validation 池错题数达 McNemar 功效阈,不足即 fail loud。
|
||||
|
||||
val_wrong_min 已前置到 build_split 内的切分保证功效(不足即从 diag 换入低 T2
|
||||
错题组补足,耗尽 fail-loud);本函数作切分冻结后的冗余最终确认:val 错题数 < 阈
|
||||
→ 验证信号不足以支撑可靠比较。
|
||||
|
||||
参数:
|
||||
pools: 冻结三池(含 validation 与 correctness)。
|
||||
val_wrong_min: 最少错题数阈(0 = 不检查)。
|
||||
|
||||
返回:
|
||||
validation 池实际错题数(供日志)。
|
||||
|
||||
异常:
|
||||
SystemExit: val_wrong_min > 0 且 val 错题数 < 阈(P5 fail loud,不静默放行)。
|
||||
"""
|
||||
val_wrong = sum(1 for q in pools.validation if not pools.correctness[q.question_id])
|
||||
if val_wrong_min > 0 and val_wrong < val_wrong_min:
|
||||
raise SystemExit(
|
||||
f"validation 池错题数 {val_wrong} < val_wrong_min={val_wrong_min},"
|
||||
"验证信号不足以支撑可靠比较(McNemar 检验功效不够)。"
|
||||
"请放大 val_ratio / 调整旋钮后重跑,勿静默放行。"
|
||||
)
|
||||
return val_wrong
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 两阶段编排(依赖注入,便于单测)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_pipeline(
|
||||
*,
|
||||
config: VideoSplitConfig,
|
||||
fingerprint: str,
|
||||
diagnosis_deps: DiagnosisDeps,
|
||||
signal_store: DiagnosisSignalStore,
|
||||
wrong_ids: list[str],
|
||||
questions: dict[str, GeneratedQuestion],
|
||||
canonical_preds: list[dict],
|
||||
harness_db: Path,
|
||||
questions_dir: Path,
|
||||
out_dir: Path,
|
||||
generated_at: str,
|
||||
force: bool = False,
|
||||
retry_uncertain: bool = False,
|
||||
) -> SplitBuildResult:
|
||||
"""内联三阶段:Phase 0 INFRA T0 补录 → Phase 1 诊断 → Phase 2 冻结切分 → McNemar 护栏。
|
||||
|
||||
参数:
|
||||
config: 科研旋钮快照。
|
||||
fingerprint: 诊断口径指纹(已合成,作诊断信号主键之一)。
|
||||
diagnosis_deps: Phase 1 诊断依赖束(真实或假实现)。
|
||||
signal_store: 诊断信号存储端口(Phase 0/1 写、Phase 2 读)。
|
||||
wrong_ids: 待诊断的可诊断错题 question_id 列表。
|
||||
questions: question_id → GeneratedQuestion 映射。
|
||||
canonical_preds: canonical 预测行(Phase 0 从中筛 INFRA / 空预测错题补 T0)。
|
||||
harness_db: harness.db 路径(Phase 2 读 canonical 预测)。
|
||||
questions_dir: benchmark 题库目录(Phase 2 加载题库切池)。
|
||||
out_dir: 冻结产物目录(pools.json + split_manifest.json)。
|
||||
generated_at: 生成时间戳(ISO 字符串,由调用方传入;见模块 C-2 复现锚点约定)。
|
||||
force: 覆盖已存在冻结产物开关,透传给 build_split(False 时已存在即报错)。
|
||||
retry_uncertain: 透传给 run_baseline_diagnosis,令已落 uncertain 题被重新诊断。
|
||||
|
||||
返回:
|
||||
SplitBuildResult(冻结三池 + manifest + assignment)。
|
||||
"""
|
||||
# Phase 0: INFRA / 空预测错题补 T0(这些题不进诊断,须单独落库保证 tier 分布/manifest 完整)。
|
||||
n_t0 = persist_infra_t0_rows(signal_store, canonical_preds, config.baseline_run_id, fingerprint)
|
||||
logger.info("Phase 0 INFRA T0 补录:落库 {} 行(INFRA / 空预测错题不进诊断)", n_t0)
|
||||
|
||||
# Phase 1: 离线诊断(断点续跑幂等:done_question_ids 已完成题跳过)。
|
||||
logger.info(
|
||||
"Phase 1 离线诊断:baseline={} 待诊断错题 {} 题", config.baseline_run_id, len(wrong_ids)
|
||||
)
|
||||
await run_baseline_diagnosis(
|
||||
baseline_run_id=config.baseline_run_id,
|
||||
diag_fingerprint=fingerprint,
|
||||
wrong_ids=wrong_ids,
|
||||
questions=questions,
|
||||
store=signal_store,
|
||||
deps=diagnosis_deps,
|
||||
retry_uncertain=retry_uncertain,
|
||||
)
|
||||
|
||||
# Phase 2: 冻结切分(读诊断信号 → 贪心选择 → 视频组原子切三池 → 冻结 + 六条断言)。
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info("Phase 2 冻结切分:out={}", out_dir)
|
||||
result = build_split(
|
||||
db_path=harness_db,
|
||||
baseline_run_id=config.baseline_run_id,
|
||||
signal_store=signal_store,
|
||||
diag_fingerprint=fingerprint,
|
||||
questions_dir=questions_dir,
|
||||
config=SplitBuildConfig(
|
||||
n_trainval=config.n_trainval,
|
||||
floor_k=config.floor_k,
|
||||
epsilon=config.epsilon,
|
||||
report_floor=config.report_floor,
|
||||
select_seed=config.seed,
|
||||
val_ratio=config.val_ratio,
|
||||
split_seed=config.seed,
|
||||
val_wrong_min=config.val_wrong_min,
|
||||
),
|
||||
out_path=out_dir / "pools.json",
|
||||
manifest_path=out_dir / "split_manifest.json",
|
||||
generated_at=generated_at,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# McNemar 功效护栏(build_split 契约外的 capstone 层校验)。
|
||||
val_wrong = check_mcnemar_power(result.pools, config.val_wrong_min)
|
||||
logger.info(
|
||||
"切分冻结完成:pools={} manifest={} val错题={}/{}(阈)",
|
||||
out_dir / "pools.json",
|
||||
out_dir / "split_manifest.json",
|
||||
val_wrong,
|
||||
config.val_wrong_min,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实执行 / dry-run 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_paths(args: argparse.Namespace) -> tuple[Path, Path, Path, Path]:
|
||||
"""解析 harness_db / questions_dir / out_dir / store_dir(CLI 覆盖默认工程路径)。"""
|
||||
harness_db = args.harness_db or _DEFAULT_HARNESS_DB
|
||||
questions_dir = args.questions_dir or _DEFAULT_QUESTIONS_DIR
|
||||
out_dir = args.out_dir or _DEFAULT_OUT_DIR
|
||||
store_dir = args.store_dir or _DEFAULT_STORE_DIR
|
||||
return harness_db, questions_dir, out_dir, store_dir
|
||||
|
||||
|
||||
def _execute_real(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
|
||||
"""真实执行两阶段管线:组装真实 deps、读错题、跑诊断 + 冻结切分。"""
|
||||
harness_db, questions_dir, out_dir, store_dir = _resolve_paths(args)
|
||||
if not harness_db.exists():
|
||||
raise SystemExit(f"harness.db 不存在: {harness_db}(P5 fail loud)")
|
||||
canonical_preds = load_canonical_predictions(harness_db, config.baseline_run_id)
|
||||
wrong_ids = select_diagnosable_wrong_ids(canonical_preds)
|
||||
questions = load_questions_by_id(questions_dir)
|
||||
video_ids: list[str] = []
|
||||
for qid in wrong_ids:
|
||||
q = questions.get(qid)
|
||||
if q is None:
|
||||
raise SystemExit(
|
||||
f"wrong_id {qid!r} 不在 questions_dir 题库中"
|
||||
"(baseline predictions 与题库不匹配,P5 fail loud)"
|
||||
)
|
||||
video_ids.append(q.video_id)
|
||||
deps = build_diagnosis_deps(
|
||||
harness_db=harness_db,
|
||||
store_dir=store_dir,
|
||||
video_ids=video_ids,
|
||||
concurrency=args.concurrency,
|
||||
expected_model=config.model,
|
||||
)
|
||||
|
||||
# generated_at:默认盖真实 UTC now(溯源用),--generated-at 可显式固定以复现(C-2)。
|
||||
generated_at = args.generated_at or datetime.datetime.now(datetime.UTC).isoformat()
|
||||
|
||||
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
|
||||
|
||||
store = SqliteDiagnosisSignalStore(str(harness_db))
|
||||
try:
|
||||
asyncio.run(
|
||||
run_pipeline(
|
||||
config=config,
|
||||
fingerprint=fingerprint,
|
||||
diagnosis_deps=deps,
|
||||
signal_store=store,
|
||||
wrong_ids=wrong_ids,
|
||||
questions=questions,
|
||||
canonical_preds=canonical_preds,
|
||||
harness_db=harness_db,
|
||||
questions_dir=questions_dir,
|
||||
out_dir=out_dir,
|
||||
generated_at=generated_at,
|
||||
force=args.force,
|
||||
retry_uncertain=args.retry_uncertain,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
class _DryRunLLM:
|
||||
"""dry-run 假 LLM:被真实调用即报错,保证不真调 LLM。"""
|
||||
|
||||
async def complete(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise AssertionError("dry-run 不应真调 LLM.complete")
|
||||
|
||||
|
||||
class _DryRunLog:
|
||||
"""dry-run 假 RunLog:predictions/traces 均返回空,诊断不真正执行。"""
|
||||
|
||||
async def get_predictions(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
|
||||
return []
|
||||
|
||||
async def get_traces(self, run_id: str, *, question_ids: list[str] | None = None) -> list:
|
||||
return []
|
||||
|
||||
|
||||
def _execute_dry_run(config: VideoSplitConfig, fingerprint: str, args: argparse.Namespace) -> None:
|
||||
"""dry-run:用假 deps 跑通 Phase 1 wiring(空错题 → 诊断早返回),打印步骤 + 指纹。
|
||||
|
||||
Phase 2 build_split 需真实诊断信号方能冻结,dry-run 不真实冻结,仅打印其计划;
|
||||
Phase 1 用空 wrong_ids 走 run_baseline_diagnosis 早返回路径,验证装配可调用而不触 LLM。
|
||||
"""
|
||||
harness_db, questions_dir, out_dir, _store_dir = _resolve_paths(args)
|
||||
logger.info("=== dry-run:校验两阶段装配(不真调 LLM / 不冻结产物)===")
|
||||
logger.info(
|
||||
"诊断口径指纹 diag_fingerprint={} (prompt={} model={})",
|
||||
fingerprint,
|
||||
config.prompt_version,
|
||||
config.model,
|
||||
)
|
||||
logger.info(
|
||||
"解析路径:harness_db={} questions_dir={} out_dir={}", harness_db, questions_dir, out_dir
|
||||
)
|
||||
logger.info(
|
||||
"旋钮:n_trainval={} epsilon={} report_floor={} val_ratio={} seed={} "
|
||||
"val_wrong_min={} floor_k={}",
|
||||
config.n_trainval,
|
||||
config.epsilon,
|
||||
config.report_floor,
|
||||
config.val_ratio,
|
||||
config.seed,
|
||||
config.val_wrong_min,
|
||||
config.floor_k,
|
||||
)
|
||||
|
||||
fake_deps = DiagnosisDeps(
|
||||
run_log=_DryRunLog(),
|
||||
llm=_DryRunLLM(),
|
||||
skill_store=object(),
|
||||
prompts=object(),
|
||||
tree_data={},
|
||||
concurrency=args.concurrency,
|
||||
)
|
||||
|
||||
from adapters.baseline_diagnosis_store import SqliteDiagnosisSignalStore
|
||||
|
||||
dry_db = out_dir / "_dry_run_signals.db"
|
||||
dry_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
store = SqliteDiagnosisSignalStore(str(dry_db))
|
||||
try:
|
||||
# Phase 0 装配:用一条假 INFRA 空预测走通 persist_infra_t0_rows(不触 LLM)。
|
||||
fake_infra_preds = [
|
||||
{
|
||||
"question_id": "_dry_infra",
|
||||
"video_id": "_dry_v",
|
||||
"task_type": "Counting Problem",
|
||||
"prediction": "",
|
||||
"answer": "A",
|
||||
"stop_reason": "error",
|
||||
"correct": False,
|
||||
}
|
||||
]
|
||||
n_t0 = persist_infra_t0_rows(store, fake_infra_preds, config.baseline_run_id, fingerprint)
|
||||
logger.info("Phase 0 装配 OK:persist_infra_t0_rows 落 {} 行 INFRA T0(假数据)", n_t0)
|
||||
logger.info("Phase 1 装配 OK:run_baseline_diagnosis 以空错题走早返回路径(不触 LLM)")
|
||||
asyncio.run(
|
||||
run_baseline_diagnosis(
|
||||
baseline_run_id=config.baseline_run_id,
|
||||
diag_fingerprint=fingerprint,
|
||||
wrong_ids=[],
|
||||
questions={},
|
||||
store=store,
|
||||
deps=fake_deps,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
store.close()
|
||||
dry_db.unlink(missing_ok=True)
|
||||
logger.info(
|
||||
"Phase 2 装配 OK:真实执行将调 build_split 冻结 pools.json + manifest(dry-run 跳过)"
|
||||
)
|
||||
logger.info("=== dry-run 通过:两阶段装配可调用,指纹已算出 ===")
|
||||
|
||||
|
||||
def build_arg_parser() -> argparse.ArgumentParser:
|
||||
"""构建 CLI 参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="结果驱动视频级切分两阶段 CLI(诊断 → 冻结切分)")
|
||||
parser.add_argument("--config", type=Path, default=Path("config/video_split.yaml"))
|
||||
parser.add_argument("--dry-run", action="store_true", dest="dry_run")
|
||||
parser.add_argument("--gpu", type=str, default=None, help="可选:设置 CUDA_VISIBLE_DEVICES")
|
||||
parser.add_argument("--concurrency", type=int, default=8, help="诊断并发上限")
|
||||
parser.add_argument("--harness-db", type=Path, default=None, dest="harness_db")
|
||||
parser.add_argument("--questions-dir", type=Path, default=None, dest="questions_dir")
|
||||
parser.add_argument("--out-dir", type=Path, default=None, dest="out_dir")
|
||||
parser.add_argument("--store-dir", type=Path, default=None, dest="store_dir")
|
||||
parser.add_argument(
|
||||
"--generated-at",
|
||||
type=str,
|
||||
default=None,
|
||||
dest="generated_at",
|
||||
help=(
|
||||
"manifest generated_at 时间戳(ISO 字符串);默认盖真实 UTC now(溯源元数据)。"
|
||||
"复现锚点是 pools.json 内容 + seed + fingerprint;generated_at 可显式传入以"
|
||||
"对 manifest 做字节级复现比对。"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="覆盖已存在的冻结 pools.json/manifest(旧产物备份为 .bak.*)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--retry-uncertain",
|
||||
action="store_true",
|
||||
dest="retry_uncertain",
|
||||
help="把已落 tier='uncertain'(信号不可信降级)的题重新诊断,而非当作已完成跳过",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""CLI 入口:解析参数 → 载配置 → 算指纹 → dry-run 或真实两阶段执行。
|
||||
|
||||
参数:
|
||||
argv: 可选参数列表(默认 sys.argv[1:]),便于测试注入。
|
||||
"""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
args = build_arg_parser().parse_args(argv)
|
||||
if args.gpu is not None:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = args.gpu
|
||||
logger.info("CUDA_VISIBLE_DEVICES={}", args.gpu)
|
||||
|
||||
config = load_config(args.config)
|
||||
fingerprint = diag_fingerprint(config.prompt_version, config.model, git_short_sha())
|
||||
|
||||
if args.dry_run:
|
||||
_execute_dry_run(config, fingerprint, args)
|
||||
return
|
||||
_execute_real(config, fingerprint, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -64,6 +64,21 @@ def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, data: dict) -> None:
|
||||
"""原子写 JSON:tmp + os.replace(对齐 checkpoint.py 范式,防半截损坏)。
|
||||
|
||||
先写同目录临时文件,再 os.replace 原子替换目标;替换阶段崩溃不会留下半截
|
||||
JSON,原文件保持完好。
|
||||
|
||||
参数:
|
||||
path: 目标 JSON 文件路径。
|
||||
data: 待序列化的字典。
|
||||
"""
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspace 核心函数
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -108,7 +123,7 @@ def _scaffold_workspace(
|
||||
},
|
||||
"history": [],
|
||||
}
|
||||
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
_atomic_write_json(workspace_dir / "manifest.json", manifest)
|
||||
|
||||
|
||||
def init_workspace(
|
||||
@@ -196,6 +211,13 @@ def init_workspace_from_seed(
|
||||
shutil.copytree(seed_dir / "prompts", workspace_dir / "prompts" / "v1")
|
||||
shutil.copy2(seed_dir / "baseline.db", workspace_dir / "harness.db")
|
||||
|
||||
seed_pools = seed_dir / "pools.json"
|
||||
if seed_pools.exists():
|
||||
shutil.copy2(seed_pools, workspace_dir / "pools.json")
|
||||
seed_manifest = seed_dir / "split_manifest.json"
|
||||
if seed_manifest.exists():
|
||||
shutil.copy2(seed_manifest, workspace_dir / "split_manifest.json")
|
||||
|
||||
logger.info("Workspace 从种子 '{}' 初始化完成: {}", seed_name, workspace_dir)
|
||||
return meta["baseline_run_id"]
|
||||
|
||||
@@ -279,7 +301,7 @@ def update_manifest(workspace_dir: Path, **version_updates: str) -> None:
|
||||
raise KeyError(f"无效的 manifest current 字段: {invalid}")
|
||||
manifest = load_manifest(workspace_dir)
|
||||
manifest["current"].update(version_updates)
|
||||
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
_atomic_write_json(workspace_dir / "manifest.json", manifest)
|
||||
|
||||
|
||||
def record_run(workspace_dir: Path, run_id: str) -> Path:
|
||||
@@ -308,9 +330,7 @@ def record_run(workspace_dir: Path, run_id: str) -> Path:
|
||||
"questions": current["questions"],
|
||||
}
|
||||
)
|
||||
(workspace_dir / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2)
|
||||
)
|
||||
_atomic_write_json(workspace_dir / "manifest.json", manifest)
|
||||
|
||||
run_dir = workspace_dir / "runs" / run_id
|
||||
# exist_ok:同 run_id 重跑时 run 目录已存在不应崩溃
|
||||
@@ -362,7 +382,7 @@ def update_best(
|
||||
"run_id": run_id,
|
||||
"epoch": epoch,
|
||||
}
|
||||
(workspace_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
_atomic_write_json(workspace_dir / "manifest.json", manifest)
|
||||
logger.info("Best 已更新: val_acc={}, run={}, epoch={}", val_acc, run_id, epoch)
|
||||
|
||||
|
||||
|
||||
+99
-2
@@ -3,13 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path # noqa: TC003 — runtime_checkable Protocol 需运行时可见
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
from app.harness.pools import Pools
|
||||
from app.tree.index import TreeIndex
|
||||
from core.types import GeneratedQuestion
|
||||
from core.types import GeneratedQuestion, PoolConfig
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -74,3 +75,99 @@ class OCRProvider(Protocol):
|
||||
"""
|
||||
|
||||
async def transcribe_frames(self, frame_paths: list[Path]) -> str: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolDispatchFn(Protocol):
|
||||
"""工具调度函数签名。
|
||||
|
||||
参数:
|
||||
tool_name: 工具名称。
|
||||
args: 工具参数字典。
|
||||
context: 上下文字典(包含 session_id)。
|
||||
|
||||
返回:
|
||||
工具执行结果文本。
|
||||
"""
|
||||
|
||||
async def __call__(
|
||||
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
|
||||
) -> str: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolDispatchFactory(Protocol):
|
||||
"""per-version 工具调度工厂。
|
||||
|
||||
通过注入 skills_dir 生产对应版本的 ToolDispatchFn。
|
||||
|
||||
参数:
|
||||
skills_dir: skill 文件目录(None 则不加载 skill)。
|
||||
|
||||
返回:
|
||||
ToolDispatchFn 实例。
|
||||
"""
|
||||
|
||||
def __call__(self, *, skills_dir: Path | None = None) -> ToolDispatchFn: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PromptBuilderFn(Protocol):
|
||||
"""Prompt 构建函数签名。
|
||||
|
||||
参数:
|
||||
qa: 待构建 prompt 的题目。
|
||||
|
||||
返回:
|
||||
(system_prompt, user_prompt) 二元组。
|
||||
"""
|
||||
|
||||
def __call__(self, qa: GeneratedQuestion) -> tuple[str, str]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PromptBuilderFactory(Protocol):
|
||||
"""per-version prompt 构建工厂。
|
||||
|
||||
通过注入 skills_dir 和 prompts_dir 生产对应版本的 PromptBuilderFn。
|
||||
|
||||
参数:
|
||||
skills_dir: skill 文件目录(None 则不加载 skill)。
|
||||
prompts_dir: prompt 文件目录(None 则使用默认目录)。
|
||||
|
||||
返回:
|
||||
PromptBuilderFn 实例。
|
||||
"""
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
*,
|
||||
skills_dir: Path | None = None,
|
||||
prompts_dir: Path | None = None,
|
||||
) -> PromptBuilderFn: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PoolStrategy(Protocol):
|
||||
"""池构建策略端口。
|
||||
|
||||
应用层端口(非 core 层),因为返回类型 Pools 定义在 app/harness/pools.py。
|
||||
两个具体策略(GlobalPoolStrategy / PerCategoryPoolStrategy)实现此接口。
|
||||
"""
|
||||
|
||||
def build(
|
||||
self,
|
||||
questions: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
config: PoolConfig,
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> Pools: ...
|
||||
|
||||
def build_incremental(
|
||||
self,
|
||||
new_task_types: list[str],
|
||||
questions: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
config: PoolConfig,
|
||||
) -> dict[str, dict[str, list[str]]]: ...
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
"""出题模块 — benchmark 加载与分层采样。"""
|
||||
"""出题模块 — benchmark 加载、分层采样、赛题合成与 v2 出题管线。"""
|
||||
|
||||
from app.question_gen.families import ALL_FAMILIES, QuestionFamilySpec
|
||||
from app.question_gen.gates import GateReport, run_gates
|
||||
from app.question_gen.generator_v2 import CandidateQuestion, generate_one_v2
|
||||
from app.question_gen.loader import load_benchmark, stratified_sample
|
||||
from app.question_gen.pipeline_v2 import PipelineConfig, PipelineResult, run_pipeline_v2
|
||||
from app.question_gen.synthesizer import (
|
||||
TASK_TYPE_LEVEL_MAP,
|
||||
AnchorContext,
|
||||
generate_one,
|
||||
sample_anchor,
|
||||
)
|
||||
|
||||
__all__ = ["load_benchmark", "stratified_sample"]
|
||||
__all__ = [
|
||||
# v1 接口
|
||||
"load_benchmark",
|
||||
"stratified_sample",
|
||||
"TASK_TYPE_LEVEL_MAP",
|
||||
"AnchorContext",
|
||||
"generate_one",
|
||||
"sample_anchor",
|
||||
# v2 接口
|
||||
"run_pipeline_v2",
|
||||
"PipelineConfig",
|
||||
"PipelineResult",
|
||||
"QuestionFamilySpec",
|
||||
"ALL_FAMILIES",
|
||||
"CandidateQuestion",
|
||||
"generate_one_v2",
|
||||
"GateReport",
|
||||
"run_gates",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Phase B 对抗过滤层配置 — filter 层配置(非 strategy 属性)。
|
||||
|
||||
设计: research-wiki/designs/2026-07-14-adversarial-question-gen-phaseB-design.md §8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import yaml
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdversarialFilterConfig:
|
||||
"""后置对抗过滤配置。
|
||||
|
||||
属性:
|
||||
filter_task_types: 被过滤的题型(仅这些走 agent 门),默认仅 AR。
|
||||
adversarial_max_rounds: 补生成迭代上限。
|
||||
adversarial_agent_max_steps: agent 试答步数上限。
|
||||
difficulty_warn_threshold: 批次 agent 正确率告警阈值。
|
||||
"""
|
||||
|
||||
filter_task_types: tuple[str, ...] = ("Action Recognition",)
|
||||
adversarial_max_rounds: int = 5
|
||||
adversarial_agent_max_steps: int = 40
|
||||
difficulty_warn_threshold: float = 0.85
|
||||
|
||||
|
||||
def load_adversarial_config(config_path: Path) -> AdversarialFilterConfig:
|
||||
"""从 YAML 的 adversarial_filter 区段加载配置,缺段/缺键用默认值。
|
||||
|
||||
参数:
|
||||
config_path: YAML 配置文件路径。
|
||||
|
||||
返回:
|
||||
AdversarialFilterConfig 实例。
|
||||
"""
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
section = raw.get("adversarial_filter", {}) or {}
|
||||
default = AdversarialFilterConfig()
|
||||
types = section.get("filter_task_types")
|
||||
return AdversarialFilterConfig(
|
||||
filter_task_types=tuple(types) if types else default.filter_task_types,
|
||||
adversarial_max_rounds=int(
|
||||
section.get("adversarial_max_rounds", default.adversarial_max_rounds)
|
||||
),
|
||||
adversarial_agent_max_steps=int(
|
||||
section.get("adversarial_agent_max_steps", default.adversarial_agent_max_steps)
|
||||
),
|
||||
difficulty_warn_threshold=float(
|
||||
section.get("difficulty_warn_threshold", default.difficulty_warn_threshold)
|
||||
),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
"""Grounded 干扰项 selector — 候选池 + VLM 视觉打分 + 区间选择(仅 AR 路径)。
|
||||
|
||||
把干扰项从"VLM 主观写得像"下沉到机制层:VLM 生成 N 个候选干扰项,再对
|
||||
候选 + 正解逐一打"视觉可信度"分,按 [正解分-δ_high, 正解分-δ_low] 区间
|
||||
选 3 个 grounded near-miss,从机制上消灭 Easy-Options Bias。
|
||||
|
||||
设计: research-wiki/designs/2026-07-14-grounded-question-gen-phaseA-design.md §3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from json_repair import repair_json
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.question_gen.sampler_v2 import MaterialContext
|
||||
from core.protocols import VLMProvider
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorConfig:
|
||||
"""selector 科研参数。
|
||||
|
||||
属性:
|
||||
candidate_pool_size: 首轮候选干扰项数 N。
|
||||
delta_low: 干扰项视觉分与正解的最小差(上界,太近=真歧义)。
|
||||
delta_high: 干扰项视觉分与正解的最大差(下界,太低=负空间)。
|
||||
max_delta_relax: δ_high 放宽次数上限(退火)。
|
||||
delta_relax_step: 每次放宽 δ_high 的增量。
|
||||
"""
|
||||
|
||||
candidate_pool_size: int
|
||||
delta_low: float
|
||||
delta_high: float
|
||||
max_delta_relax: int = 2
|
||||
delta_relax_step: float = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorOutcome:
|
||||
"""selector 产出。observation 始终存在(含 hard-fail),供 run_store 落库。
|
||||
|
||||
属性:
|
||||
observation: 打分观测 dict(correct_score/chosen/pool_size/anneal_rounds/hard_fail)。
|
||||
options: 重组四选项(A=正解),hard-fail 时为 None。
|
||||
answer: 正解字母(恒 "A"),hard-fail 时为 None。
|
||||
"""
|
||||
|
||||
observation: dict[str, object]
|
||||
options: tuple[str, ...] | None = None
|
||||
answer: str | None = None
|
||||
|
||||
@property
|
||||
def hard_fail(self) -> bool:
|
||||
"""是否硬失败(凑不齐 3 个 grounded 干扰项)。"""
|
||||
return self.options is None
|
||||
|
||||
|
||||
def _select_in_interval(
|
||||
correct_score: float,
|
||||
candidates: list[str],
|
||||
candidate_scores: list[float],
|
||||
delta_low: float,
|
||||
delta_high: float,
|
||||
) -> list[str] | None:
|
||||
"""从候选中选 3 个视觉分落 [correct-δ_high, correct-δ_low] 区间的干扰项。
|
||||
|
||||
落区间者按分数降序取前 3(分数越高越接近正解=越难)。不足 3 个返回 None。
|
||||
|
||||
参数:
|
||||
correct_score: 正解视觉可信度分。
|
||||
candidates: 候选干扰项文本列表。
|
||||
candidate_scores: 与 candidates 对齐的视觉分列表。
|
||||
delta_low: 最小差(上界 = correct - delta_low)。
|
||||
delta_high: 最大差(下界 = correct - delta_high)。
|
||||
|
||||
返回:
|
||||
选中的 3 个候选文本(降序)或 None(不足 3 个)。
|
||||
"""
|
||||
upper = correct_score - delta_low
|
||||
lower = correct_score - delta_high
|
||||
eligible = [
|
||||
(c, s) for c, s in zip(candidates, candidate_scores, strict=True) if lower <= s <= upper
|
||||
]
|
||||
if len(eligible) < 3:
|
||||
return None
|
||||
eligible.sort(key=lambda cs: cs[1], reverse=True)
|
||||
return [c for c, _ in eligible[:3]]
|
||||
|
||||
|
||||
def _load_prompt(name: str) -> str:
|
||||
path = _PROMPTS_DIR / name
|
||||
if not path.exists():
|
||||
msg = f"Prompt 模板不存在: {path}"
|
||||
raise FileNotFoundError(msg)
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _material_context_block(question: str, correct_text: str, material: MaterialContext) -> str:
|
||||
parts = [f"## Question\n{question}", f"## Correct Answer\n{correct_text}"]
|
||||
if material.subtitle_sentences:
|
||||
parts.append("## Subtitles")
|
||||
parts.extend(f" - {s}" for s in material.subtitle_sentences)
|
||||
if getattr(material, "cross_l2_texts", None):
|
||||
parts.append("## Cross-Segment Context")
|
||||
parts.extend(f" - {t}" for t in material.cross_l2_texts)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _parse_json_object(raw: str) -> dict:
|
||||
content = raw.strip()
|
||||
if "```" in content:
|
||||
for part in content.split("```"):
|
||||
stripped = part.strip()
|
||||
if stripped.startswith("json"):
|
||||
stripped = stripped[4:].strip()
|
||||
if stripped.startswith("{"):
|
||||
content = stripped
|
||||
break
|
||||
data = json.loads(repair_json(content, return_objects=False))
|
||||
if not isinstance(data, dict):
|
||||
msg = f"selector 响应顶层非 JSON 对象: {type(data).__name__}"
|
||||
raise ValueError(msg)
|
||||
return data
|
||||
|
||||
|
||||
async def _generate_pool(
|
||||
vlm: VLMProvider,
|
||||
question: str,
|
||||
correct_text: str,
|
||||
material: MaterialContext,
|
||||
n: int,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> list[str]:
|
||||
"""VLM 生成 n 个候选干扰项文本。"""
|
||||
system = _load_prompt("ar_distractor_pool.md")
|
||||
user = (
|
||||
_material_context_block(question, correct_text, material)
|
||||
+ f"\n## N\nGenerate exactly {n} distractors."
|
||||
)
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
|
||||
# graceful 降级:解析爆炸时返回空池,让退火/hard_fail 接管(区别于契约违反的打分校验)
|
||||
try:
|
||||
data = _parse_json_object(resp.content)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
logger.warning("grounded selector 候选池响应解析失败,降级为空池: {}", exc)
|
||||
return []
|
||||
raw = data.get("distractors", [])
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
# 防御:去空、去重、剔除与正解字面相同者
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for item in raw:
|
||||
text = str(item).strip()
|
||||
if not text or text == correct_text.strip() or text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
out.append(text)
|
||||
return out
|
||||
|
||||
|
||||
async def _score_options(
|
||||
vlm: VLMProvider,
|
||||
question: str,
|
||||
options: list[str],
|
||||
material: MaterialContext,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> list[float]:
|
||||
"""VLM 对 options(首个为正解)逐一打视觉可信度分 [0,1],返回对齐分数列表。"""
|
||||
system = _load_prompt("ar_distractor_score.md")
|
||||
numbered = "\n".join(f"{i}. {opt}" for i, opt in enumerate(options, 1))
|
||||
user = f"## Question\n{question}\n\n## Candidates\n{numbered}"
|
||||
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
resp = await vlm.chat_with_images(messages, list(material.frame_paths), session_id=session_id)
|
||||
data = _parse_json_object(resp.content)
|
||||
scores_raw = data.get("scores", [])
|
||||
if not isinstance(scores_raw, list) or len(scores_raw) != len(options):
|
||||
msg = f"打分数量({len(scores_raw) if isinstance(scores_raw, list) else 'NA'}) != 选项数({len(options)})"
|
||||
raise ValueError(msg)
|
||||
try:
|
||||
return [max(0.0, min(1.0, float(s))) for s in scores_raw]
|
||||
except (TypeError, ValueError) as e:
|
||||
msg = f"打分含非数值元素: {e}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
|
||||
async def build_grounded_options(
|
||||
vlm: VLMProvider,
|
||||
question: str,
|
||||
correct_text: str,
|
||||
material: MaterialContext,
|
||||
config: SelectorConfig,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> SelectorOutcome:
|
||||
"""生成候选池 → 视觉打分 → 区间选 3 干扰项 → 重组四选项。
|
||||
|
||||
退火(凑不齐 3 个时按序):① 最多追加 N 个新候选(去重后实际增量可能更少)再打分;② 逐步放宽
|
||||
δ_high(纯重选,不再调 VLM);③ 仍不足则 hard_fail(调用方走重出)。
|
||||
|
||||
参数:
|
||||
vlm: VLM 端口。
|
||||
question: 题干。
|
||||
correct_text: 正解文本(无字母前缀)。
|
||||
material: 采样素材(提供 frame_paths / subtitles)。
|
||||
config: selector 科研参数。
|
||||
session_id: 遥测会话 ID。
|
||||
|
||||
返回:
|
||||
SelectorOutcome。成功时 options=A 正解+3 grounded 干扰项;hard_fail
|
||||
时 options=None,但 observation 始终存在供落库。
|
||||
"""
|
||||
candidates = await _generate_pool(
|
||||
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
|
||||
)
|
||||
# options[0] 恒为正解
|
||||
scored = await _score_options(
|
||||
vlm, question, [correct_text, *candidates], material, session_id=session_id
|
||||
)
|
||||
correct_score, cand_scores = scored[0], scored[1:]
|
||||
anneal_rounds = 0
|
||||
|
||||
chosen = _select_in_interval(
|
||||
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
|
||||
)
|
||||
|
||||
# 退火 1: 最多追加 N 个新候选(去重后实际增量可能更少),仅对新增候选打分,正解分保持首轮值
|
||||
if chosen is None:
|
||||
anneal_rounds += 1
|
||||
more = await _generate_pool(
|
||||
vlm, question, correct_text, material, config.candidate_pool_size, session_id=session_id
|
||||
)
|
||||
more = [m for m in more if m not in candidates]
|
||||
if more:
|
||||
more_scores = await _score_options(
|
||||
vlm, question, [correct_text, *more], material, session_id=session_id
|
||||
)
|
||||
candidates = candidates + more
|
||||
cand_scores = cand_scores + more_scores[1:]
|
||||
chosen = _select_in_interval(
|
||||
correct_score, candidates, cand_scores, config.delta_low, config.delta_high
|
||||
)
|
||||
|
||||
# 退火 2: 放宽 δ_high(下界下移,纳入更低分候选),δ_low 不动
|
||||
relax = 0
|
||||
delta_high = config.delta_high
|
||||
while chosen is None and relax < config.max_delta_relax:
|
||||
relax += 1
|
||||
anneal_rounds += 1
|
||||
delta_high = delta_high + config.delta_relax_step
|
||||
chosen = _select_in_interval(
|
||||
correct_score, candidates, cand_scores, config.delta_low, delta_high
|
||||
)
|
||||
|
||||
hard_fail = chosen is None
|
||||
observation: dict[str, object] = {
|
||||
"correct_score": correct_score,
|
||||
"chosen": [cand_scores[candidates.index(c)] for c in (chosen or [])],
|
||||
"pool_size": len(candidates),
|
||||
"anneal_rounds": anneal_rounds,
|
||||
"delta_high_final": delta_high,
|
||||
"hard_fail": hard_fail,
|
||||
}
|
||||
if hard_fail:
|
||||
logger.warning(
|
||||
"grounded selector 硬失败: correct={:.3f}, pool={}, anneal={}",
|
||||
correct_score,
|
||||
len(candidates),
|
||||
anneal_rounds,
|
||||
)
|
||||
# observation 仍返回,供 pipeline 落 selector_scores(设计 §3.3 退化观测)
|
||||
return SelectorOutcome(observation=observation)
|
||||
|
||||
options = (
|
||||
f"A. {correct_text}",
|
||||
f"B. {chosen[0]}",
|
||||
f"C. {chosen[1]}",
|
||||
f"D. {chosen[2]}",
|
||||
)
|
||||
return SelectorOutcome(observation=observation, options=options, answer="A")
|
||||
@@ -0,0 +1,252 @@
|
||||
"""题族规格声明 — 定义 5 大问题家族及其采样、泄漏检测、提示模板约束。
|
||||
|
||||
每个 QuestionFamilySpec 对应一种失败机制(skill_target M1–M5),
|
||||
由 get_family_for_slot 在出题时按权重分配。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LeakTestProfile:
|
||||
"""泄漏测试配置 — 定义快捷答题捷径类型与通过阈值。
|
||||
|
||||
Attributes:
|
||||
shortcut_type: 捷径类型标识。
|
||||
probe_template: store/prompts/question_gen/ 下的探测模板文件名。
|
||||
pass_threshold: 通过阈值(0–1),低于此值视为存在泄漏。
|
||||
"""
|
||||
|
||||
shortcut_type: str
|
||||
probe_template: str
|
||||
pass_threshold: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SamplingConstraint:
|
||||
"""采样约束 — 对树节点的最低要求。
|
||||
|
||||
Attributes:
|
||||
min_subtitles: 最少字幕段数。
|
||||
min_l3_nodes: 最少 L3 节点数。
|
||||
require_frames: 是否要求帧图像可用。
|
||||
cross_l2_span: 是否要求跨 L2 段采样。
|
||||
"""
|
||||
|
||||
min_subtitles: int
|
||||
min_l3_nodes: int
|
||||
require_frames: bool
|
||||
cross_l2_span: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestionFamilySpec:
|
||||
"""问题家族规格 — 一个家族的完整声明。
|
||||
|
||||
Attributes:
|
||||
name: 家族标识名(如 "RETRIEVAL")。
|
||||
skill_target: 目标失败机制编号(M1–M5)。
|
||||
sampling: 采样约束。
|
||||
legal_task_types: 该家族合法的任务类型集合(frozenset)。
|
||||
leak_profile: 泄漏测试配置。
|
||||
prompt_template: 出题 prompt 模板文件名。
|
||||
"""
|
||||
|
||||
name: str
|
||||
skill_target: str
|
||||
sampling: SamplingConstraint
|
||||
legal_task_types: frozenset[str]
|
||||
leak_profile: LeakTestProfile
|
||||
prompt_template: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5 大家族实例
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RETRIEVAL_FAMILY = QuestionFamilySpec(
|
||||
name="RETRIEVAL",
|
||||
skill_target="M1",
|
||||
sampling=SamplingConstraint(
|
||||
min_subtitles=2,
|
||||
min_l3_nodes=3,
|
||||
require_frames=False,
|
||||
cross_l2_span=False,
|
||||
),
|
||||
legal_task_types=frozenset(
|
||||
[
|
||||
"Object Recognition",
|
||||
"Object Reasoning",
|
||||
"Action Recognition",
|
||||
"Attribute Perception",
|
||||
"OCR Problems",
|
||||
]
|
||||
),
|
||||
leak_profile=LeakTestProfile(
|
||||
shortcut_type="temporal_proximity",
|
||||
probe_template="gate_leak_retrieval.md",
|
||||
pass_threshold=0.6,
|
||||
),
|
||||
prompt_template="retrieval.md",
|
||||
)
|
||||
|
||||
REASONING_FAMILY = QuestionFamilySpec(
|
||||
name="REASONING",
|
||||
skill_target="M2",
|
||||
sampling=SamplingConstraint(
|
||||
min_subtitles=3,
|
||||
min_l3_nodes=4,
|
||||
require_frames=False,
|
||||
cross_l2_span=True,
|
||||
),
|
||||
legal_task_types=frozenset(
|
||||
[
|
||||
"Action Reasoning",
|
||||
"Object Reasoning",
|
||||
"Information Synopsis",
|
||||
]
|
||||
),
|
||||
leak_profile=LeakTestProfile(
|
||||
shortcut_type="frequency",
|
||||
probe_template="gate_leak_reasoning.md",
|
||||
pass_threshold=0.5,
|
||||
),
|
||||
prompt_template="reasoning.md",
|
||||
)
|
||||
|
||||
ENUMERATION_FAMILY = QuestionFamilySpec(
|
||||
name="ENUMERATION",
|
||||
skill_target="M3",
|
||||
sampling=SamplingConstraint(
|
||||
min_subtitles=2,
|
||||
min_l3_nodes=5,
|
||||
require_frames=False,
|
||||
cross_l2_span=False,
|
||||
),
|
||||
legal_task_types=frozenset(
|
||||
[
|
||||
"Counting Problem",
|
||||
"Temporal Reasoning",
|
||||
"Temporal Perception",
|
||||
"Information Synopsis",
|
||||
]
|
||||
),
|
||||
leak_profile=LeakTestProfile(
|
||||
shortcut_type="option_length",
|
||||
probe_template="gate_leak_enumeration.md",
|
||||
pass_threshold=0.6,
|
||||
),
|
||||
prompt_template="enumeration.md",
|
||||
)
|
||||
|
||||
VISUAL_FAMILY = QuestionFamilySpec(
|
||||
name="VISUAL",
|
||||
skill_target="M4",
|
||||
sampling=SamplingConstraint(
|
||||
min_subtitles=0,
|
||||
min_l3_nodes=3,
|
||||
require_frames=True,
|
||||
cross_l2_span=False,
|
||||
),
|
||||
legal_task_types=frozenset(
|
||||
[
|
||||
"Attribute Perception",
|
||||
"Counting Problem",
|
||||
"OCR Problems",
|
||||
"Action Recognition",
|
||||
]
|
||||
),
|
||||
leak_profile=LeakTestProfile(
|
||||
shortcut_type="visual_salience",
|
||||
probe_template="gate_leak_visual.md",
|
||||
pass_threshold=0.5,
|
||||
),
|
||||
prompt_template="visual.md",
|
||||
)
|
||||
|
||||
SPATIAL_FAMILY = QuestionFamilySpec(
|
||||
name="SPATIAL",
|
||||
skill_target="M5",
|
||||
sampling=SamplingConstraint(
|
||||
min_subtitles=0,
|
||||
min_l3_nodes=3,
|
||||
require_frames=True,
|
||||
cross_l2_span=False,
|
||||
),
|
||||
legal_task_types=frozenset(
|
||||
[
|
||||
"Spatial Perception",
|
||||
"Spatial Reasoning",
|
||||
]
|
||||
),
|
||||
leak_profile=LeakTestProfile(
|
||||
shortcut_type="spatial_default",
|
||||
probe_template="gate_leak_spatial.md",
|
||||
pass_threshold=0.5,
|
||||
),
|
||||
prompt_template="spatial.md",
|
||||
)
|
||||
|
||||
ALL_FAMILIES: tuple[QuestionFamilySpec, ...] = (
|
||||
RETRIEVAL_FAMILY,
|
||||
REASONING_FAMILY,
|
||||
ENUMERATION_FAMILY,
|
||||
VISUAL_FAMILY,
|
||||
SPATIAL_FAMILY,
|
||||
)
|
||||
|
||||
# 按 name 索引,用于 get_family_for_slot 快速查找
|
||||
_FAMILY_BY_NAME: dict[str, QuestionFamilySpec] = {f.name: f for f in ALL_FAMILIES}
|
||||
|
||||
|
||||
def get_family_for_slot(
|
||||
task_type: str,
|
||||
family_ratios: dict[str, float],
|
||||
rng: random.Random,
|
||||
) -> QuestionFamilySpec:
|
||||
"""根据任务类型和家族权重比例,随机选择一个合法的问题家族。
|
||||
|
||||
Args:
|
||||
task_type: 任务类型字符串(必须是 12 种合法类型之一)。
|
||||
family_ratios: 家族名称到权重的映射(如 {"RETRIEVAL": 0.30, ...})。
|
||||
rng: 随机数生成器实例(确保可复现)。
|
||||
|
||||
Returns:
|
||||
被选中的 QuestionFamilySpec。
|
||||
|
||||
Raises:
|
||||
ValueError: task_type 不在任何家族的 legal_task_types 中。
|
||||
ValueError: 给定 task_type 下没有合法家族(所有合法族权重为 0 或不在 ratios 中)。
|
||||
"""
|
||||
# 检查 task_type 是否被任一家族接受
|
||||
all_legal_types: set[str] = set()
|
||||
for family in ALL_FAMILIES:
|
||||
all_legal_types.update(family.legal_task_types)
|
||||
if task_type not in all_legal_types:
|
||||
msg = f"task_type '{task_type}' 不在任何家族的合法类型中"
|
||||
raise ValueError(msg)
|
||||
|
||||
# 过滤出接受该 task_type 且在 ratios 中有正权重的家族
|
||||
candidates: list[QuestionFamilySpec] = []
|
||||
weights: list[float] = []
|
||||
for family_name, weight in family_ratios.items():
|
||||
family = _FAMILY_BY_NAME.get(family_name)
|
||||
if family is None:
|
||||
continue
|
||||
if task_type in family.legal_task_types and weight > 0:
|
||||
candidates.append(family)
|
||||
weights.append(weight)
|
||||
|
||||
if not candidates:
|
||||
msg = f"task_type '{task_type}' 下没有合法家族可选(检查 family_ratios)"
|
||||
raise ValueError(msg)
|
||||
|
||||
# 归一化权重 + 加权随机选择
|
||||
chosen = rng.choices(candidates, weights=weights, k=1)[0]
|
||||
return chosen
|
||||
@@ -0,0 +1,511 @@
|
||||
"""轻量四门质量检查 — 对生成题目执行 4 个独立的 LLM 质量门控。
|
||||
|
||||
四门:
|
||||
1. key_verify: 验证答案在来源素材中有证据支撑。
|
||||
2. blind_answer: 无上下文时 LLM 能否答对(若答对 → 题目泄漏)。
|
||||
3. multi_true: 检测是否有多个选项可被视为正确。
|
||||
4. leak_test: 按策略特定模板探测答题捷径。
|
||||
|
||||
设计要点:
|
||||
- run_gates 先做 verbatim_ratio 前置短路(> 0.5 直接 FAIL key_verify)。
|
||||
- 四门并发执行(asyncio.gather)。
|
||||
- 每门加载 store/prompts/question_gen/ 下对应模板,构造 messages,调 LLM。
|
||||
- LLM 返回 JSON {"verdict": "pass"|"fail", "reason": "..."},解析失败视为 FAIL。
|
||||
- session_id 必须透传至每次 LLM 调用(遥测关联)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.question_gen.postprocess import PostprocessResult
|
||||
from app.tree.index import TreeIndex
|
||||
from core.protocols import LLMProvider, VLMProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
|
||||
|
||||
_VERBATIM_THRESHOLD = 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CandidateQuestion(Task 5 尚未实现,本地定义,后续迁移至 generator_v2.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateQuestion:
|
||||
"""候选题目 — 出题管线生成、待门控审核的题目数据。
|
||||
|
||||
属性:
|
||||
question_id: 题目唯一标识。
|
||||
video_id: 所属视频标识。
|
||||
task_type: 题型(如 "Action Reasoning")。
|
||||
skill_target: 目标失败机制编号(M1-M5)。
|
||||
question: 题目文本。
|
||||
options: 选项元组(如 ("A. ...", "B. ...", "C. ...", "D. ..."))。
|
||||
answer: 正确答案字母(如 "A")。
|
||||
source_nodes: 来源节点 ID 元组。
|
||||
difficulty: 难度等级。
|
||||
subtitle_sentences: 字幕句子元组。
|
||||
frame_paths: 帧图片路径元组。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
video_id: str
|
||||
task_type: str
|
||||
skill_target: str
|
||||
question: str
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
source_nodes: tuple[str, ...]
|
||||
difficulty: str
|
||||
subtitle_sentences: tuple[str, ...] = field(default_factory=tuple)
|
||||
frame_paths: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 门控结果类型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GateVerdict(Enum):
|
||||
"""门控判决枚举。"""
|
||||
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
SKIP = "skip"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateResult:
|
||||
"""单门判决结果。
|
||||
|
||||
属性:
|
||||
verdict: 门控判决(PASS / FAIL / SKIP)。
|
||||
reason: 判决原因说明。
|
||||
raw_response: LLM 原始返回内容。
|
||||
"""
|
||||
|
||||
verdict: GateVerdict
|
||||
reason: str
|
||||
raw_response: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateReport:
|
||||
"""四门判决汇总报告。
|
||||
|
||||
属性:
|
||||
key_verify: 关键验证门结果。
|
||||
blind_answer: 盲答门结果。
|
||||
multi_true: 多正确门结果。
|
||||
leak_test: 泄漏测试门结果。
|
||||
"""
|
||||
|
||||
key_verify: GateResult
|
||||
blind_answer: GateResult
|
||||
multi_true: GateResult
|
||||
leak_test: GateResult
|
||||
|
||||
@property
|
||||
def passed(self) -> bool:
|
||||
"""全门 PASS 或 SKIP 时视为通过。"""
|
||||
for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test):
|
||||
if gate.verdict == GateVerdict.FAIL:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def reject_reason(self) -> str | None:
|
||||
"""首个 FAIL 门的 reason,全部通过返回 None。"""
|
||||
for gate in (self.key_verify, self.blind_answer, self.multi_true, self.leak_test):
|
||||
if gate.verdict == GateVerdict.FAIL:
|
||||
return gate.reason
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内部工具函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_prompt_template(filename: str) -> str:
|
||||
"""加载 store/prompts/question_gen/ 下的模板文件。
|
||||
|
||||
参数:
|
||||
filename: 模板文件名(如 "gate_key_verify.md")。
|
||||
|
||||
返回:
|
||||
模板内容字符串。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 模板文件不存在。
|
||||
"""
|
||||
path = _PROMPTS_DIR / filename
|
||||
if not path.exists():
|
||||
msg = f"门控模板文件不存在: {path}"
|
||||
raise FileNotFoundError(msg)
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _resolve_source_text(candidate: CandidateQuestion, tree: TreeIndex) -> str:
|
||||
"""从树中解析候选题的来源节点文本。
|
||||
|
||||
遍历树的所有层级,匹配 source_nodes 中的 ID,提取对应描述文本。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
tree: 视频树索引。
|
||||
|
||||
返回:
|
||||
拼接的来源节点描述文本。
|
||||
"""
|
||||
texts: list[str] = []
|
||||
target_ids = set(candidate.source_nodes)
|
||||
|
||||
for l1 in tree.roots:
|
||||
if l1.id in target_ids:
|
||||
texts.append(f"[L1 {l1.id}] {l1.card.scene_summary}")
|
||||
for l2 in l1.children:
|
||||
if l2.id in target_ids:
|
||||
texts.append(f"[L2 {l2.id}] {l2.card.event_description}")
|
||||
for l3 in l2.children:
|
||||
if l3.id in target_ids:
|
||||
texts.append(f"[L3 {l3.id}] {l3.card.frame_summary}")
|
||||
|
||||
if not texts:
|
||||
logger.warning(
|
||||
"未找到来源节点: candidate={}, source_nodes={}",
|
||||
candidate.question_id,
|
||||
candidate.source_nodes,
|
||||
)
|
||||
return "(no source material found)"
|
||||
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def _format_options(options: tuple[str, ...]) -> str:
|
||||
"""将选项元组格式化为可读字符串。"""
|
||||
return "\n".join(options)
|
||||
|
||||
|
||||
def _parse_gate_response(raw_content: str) -> tuple[GateVerdict, str]:
|
||||
"""解析 LLM 返回的门控 JSON 响应。
|
||||
|
||||
期望格式: {"verdict": "pass"|"fail", "reason": "..."}
|
||||
解析失败时返回 FAIL + parse_error。
|
||||
|
||||
参数:
|
||||
raw_content: LLM 返回的原始文本。
|
||||
|
||||
返回:
|
||||
(verdict, reason) 元组。
|
||||
"""
|
||||
try:
|
||||
# 尝试从文本中提取 JSON(可能被 markdown 代码块包裹)
|
||||
content = raw_content.strip()
|
||||
if "```" in content:
|
||||
# 提取代码块中的内容
|
||||
parts = content.split("```")
|
||||
for part in parts:
|
||||
stripped = part.strip()
|
||||
if stripped.startswith("json"):
|
||||
stripped = stripped[4:].strip()
|
||||
if stripped.startswith("{"):
|
||||
content = stripped
|
||||
break
|
||||
|
||||
data = json.loads(content)
|
||||
verdict_str = data.get("verdict", "").lower()
|
||||
reason = data.get("reason", "")
|
||||
|
||||
if verdict_str == "pass":
|
||||
return GateVerdict.PASS, reason
|
||||
elif verdict_str == "fail":
|
||||
return GateVerdict.FAIL, reason
|
||||
else:
|
||||
return GateVerdict.FAIL, f"parse_error: invalid verdict '{verdict_str}'"
|
||||
|
||||
except (json.JSONDecodeError, AttributeError, TypeError) as e:
|
||||
return GateVerdict.FAIL, f"parse_error: {e}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 四门实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_source_frames(candidate: CandidateQuestion, tree: TreeIndex) -> list[str]:
|
||||
"""从树中收集候选题来源节点关联的帧路径。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
tree: 视频树索引。
|
||||
|
||||
返回:
|
||||
去重后的帧路径列表(最多 10 张,避免 VLM 输入过长)。
|
||||
"""
|
||||
frames: list[str] = []
|
||||
target_ids = set(candidate.source_nodes)
|
||||
|
||||
for l1 in tree.roots:
|
||||
for l2 in l1.children:
|
||||
if l2.id in target_ids:
|
||||
for l3 in l2.children:
|
||||
if l3.frame_path:
|
||||
frames.append(l3.frame_path)
|
||||
for l3 in l2.children:
|
||||
if l3.id in target_ids and l3.frame_path:
|
||||
frames.append(l3.frame_path)
|
||||
|
||||
# 也使用候选题自带的帧路径
|
||||
frames.extend(candidate.frame_paths)
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for f in frames:
|
||||
if f not in seen:
|
||||
seen.add(f)
|
||||
unique.append(f)
|
||||
return unique[:10]
|
||||
|
||||
|
||||
async def _gate_key_verify(
|
||||
candidate: CandidateQuestion,
|
||||
tree: TreeIndex,
|
||||
vlm: VLMProvider,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> GateResult:
|
||||
"""关键验证门 — 使用 VLM 检查答案在来源素材(文本+帧画面)中是否有证据支撑。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
tree: 视频树索引。
|
||||
vlm: VLM 图文调用端口(同时看文本和帧画面)。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
GateResult 实例。
|
||||
"""
|
||||
source_text = _resolve_source_text(candidate, tree)
|
||||
frames = _resolve_source_frames(candidate, tree)
|
||||
template = _load_prompt_template("gate_key_verify.md")
|
||||
prompt = template.format(
|
||||
source_text=source_text,
|
||||
question=candidate.question,
|
||||
options=_format_options(candidate.options),
|
||||
answer=candidate.answer,
|
||||
)
|
||||
|
||||
if frames:
|
||||
response = await vlm.chat_with_images(
|
||||
[{"role": "user", "content": prompt}],
|
||||
images=frames,
|
||||
session_id=session_id,
|
||||
)
|
||||
else:
|
||||
# 无帧时降级为纯文本(不应常见)
|
||||
logger.warning("key_verify 无可用帧,降级纯文本: {}", candidate.question_id)
|
||||
response = await vlm.chat_with_images(
|
||||
[{"role": "user", "content": prompt}],
|
||||
images=[],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
verdict, reason = _parse_gate_response(response.content)
|
||||
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
|
||||
|
||||
|
||||
async def _gate_blind_answer(
|
||||
candidate: CandidateQuestion,
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> GateResult:
|
||||
"""盲答门 — 无上下文时 LLM 能否答对(答对 → 题目泄漏)。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
llm: LLM 调用端口。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
GateResult 实例。
|
||||
"""
|
||||
template = _load_prompt_template("gate_blind_answer.md")
|
||||
prompt = template.format(
|
||||
question=candidate.question,
|
||||
options=_format_options(candidate.options),
|
||||
)
|
||||
|
||||
response = await llm.chat(
|
||||
[{"role": "user", "content": prompt}],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
verdict, reason = _parse_gate_response(response.content)
|
||||
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
|
||||
|
||||
|
||||
async def _gate_multi_true(
|
||||
candidate: CandidateQuestion,
|
||||
tree: TreeIndex,
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> GateResult:
|
||||
"""多正确门 — 检测是否有多个选项可被视为正确。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
tree: 视频树索引。
|
||||
llm: LLM 调用端口。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
GateResult 实例。
|
||||
"""
|
||||
source_text = _resolve_source_text(candidate, tree)
|
||||
template = _load_prompt_template("gate_multi_true.md")
|
||||
prompt = template.format(
|
||||
source_text=source_text,
|
||||
question=candidate.question,
|
||||
options=_format_options(candidate.options),
|
||||
)
|
||||
|
||||
response = await llm.chat(
|
||||
[{"role": "user", "content": prompt}],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
verdict, reason = _parse_gate_response(response.content)
|
||||
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
|
||||
|
||||
|
||||
async def _gate_leak_test(
|
||||
candidate: CandidateQuestion,
|
||||
leak_probe_template: str,
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
session_id: str,
|
||||
) -> GateResult:
|
||||
"""泄漏测试门 — 按策略特定模板探测答题捷径。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
leak_probe_template: 泄漏探测模板文件名(store/prompts/question_gen/ 下)。
|
||||
llm: LLM 调用端口。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
GateResult 实例。
|
||||
"""
|
||||
template = _load_prompt_template(leak_probe_template)
|
||||
prompt = template.format(
|
||||
question=candidate.question,
|
||||
options=_format_options(candidate.options),
|
||||
answer=candidate.answer,
|
||||
)
|
||||
|
||||
response = await llm.chat(
|
||||
[{"role": "user", "content": prompt}],
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
verdict, reason = _parse_gate_response(response.content)
|
||||
return GateResult(verdict=verdict, reason=reason, raw_response=response.content)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 编排入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def run_gates(
|
||||
candidate: CandidateQuestion,
|
||||
tree: TreeIndex,
|
||||
llm: LLMProvider,
|
||||
leak_probe_template: str,
|
||||
postprocess: PostprocessResult,
|
||||
*,
|
||||
vlm: VLMProvider | None = None,
|
||||
session_id: str,
|
||||
) -> GateReport:
|
||||
"""编排四门并发执行,返回汇总报告。
|
||||
|
||||
前置规则:verbatim_ratio > 0.5 时直接短路 key_verify 为 FAIL(不调 LLM)。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
tree: 视频树索引。
|
||||
llm: LLM 调用端口。
|
||||
leak_probe_template: 泄漏探测模板文件名(store/prompts/question_gen/ 下)。
|
||||
postprocess: 后处理结果(含 verbatim_ratio)。
|
||||
vlm: VLM 图文调用端口(key_verify 使用,None 时降级为 LLM)。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
GateReport 四门汇总。
|
||||
"""
|
||||
# Phase 1: verbatim 前置短路 — 不调 LLM,直接返回
|
||||
if postprocess.verbatim_ratio > _VERBATIM_THRESHOLD:
|
||||
logger.info(
|
||||
"verbatim_ratio={:.3f} > {:.1f},key_verify 短路 FAIL,其余三门 SKIP",
|
||||
postprocess.verbatim_ratio,
|
||||
_VERBATIM_THRESHOLD,
|
||||
)
|
||||
skip_result = GateResult(
|
||||
verdict=GateVerdict.SKIP,
|
||||
reason="skipped due to verbatim short-circuit",
|
||||
raw_response="",
|
||||
)
|
||||
return GateReport(
|
||||
key_verify=GateResult(
|
||||
verdict=GateVerdict.FAIL,
|
||||
reason=f"verbatim_ratio={postprocess.verbatim_ratio:.3f} exceeds threshold {_VERBATIM_THRESHOLD}",
|
||||
raw_response="",
|
||||
),
|
||||
blind_answer=skip_result,
|
||||
multi_true=skip_result,
|
||||
leak_test=skip_result,
|
||||
)
|
||||
|
||||
# Phase 2: 四门并发执行(key_verify 使用 VLM 看帧+文本)
|
||||
key_verify_provider = vlm if vlm is not None else llm
|
||||
key_result, blind_result, multi_result, leak_result = await asyncio.gather(
|
||||
_gate_key_verify(candidate, tree, key_verify_provider, session_id=session_id),
|
||||
_gate_blind_answer(candidate, llm, session_id=session_id),
|
||||
_gate_multi_true(candidate, tree, llm, session_id=session_id),
|
||||
_gate_leak_test(candidate, leak_probe_template, llm, session_id=session_id),
|
||||
)
|
||||
|
||||
report = GateReport(
|
||||
key_verify=key_result,
|
||||
blind_answer=blind_result,
|
||||
multi_true=multi_result,
|
||||
leak_test=leak_result,
|
||||
)
|
||||
|
||||
if report.passed:
|
||||
logger.debug("四门全部通过: question_id={}", candidate.question_id)
|
||||
else:
|
||||
logger.info(
|
||||
"门控拒绝: question_id={}, reason={}",
|
||||
candidate.question_id,
|
||||
report.reject_reason,
|
||||
)
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,461 @@
|
||||
"""v2 生成器 — 基于策略特化 prompt 模板的单题 VLM 出题模块。
|
||||
|
||||
使用 VLMProvider 接口调用视觉语言模型,结合 per-strategy prompt 模板
|
||||
和 MaterialContext 素材上下文,生成一道四选一候选题。
|
||||
|
||||
典型调用路径::
|
||||
|
||||
candidate = await generate_one_v2(
|
||||
vlm=vlm_client,
|
||||
tree=tree_index,
|
||||
material=material_ctx,
|
||||
task_type="Action Reasoning",
|
||||
seq=1,
|
||||
video_id="vid_001",
|
||||
prompt_template="retrieval.md",
|
||||
strategy_name="RETRIEVAL",
|
||||
skill_target="M1",
|
||||
session_id="sess_001",
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from json_repair import repair_json
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.question_gen.sampler_v2 import MaterialContext
|
||||
from app.tree.index import TreeIndex
|
||||
from core.protocols import VLMProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "store" / "prompts" / "question_gen"
|
||||
|
||||
_VALID_ANSWERS = frozenset({"A", "B", "C", "D"})
|
||||
|
||||
_VALID_DIFFICULTIES = frozenset({"easy", "medium", "hard"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CandidateQuestion(规范定义位置)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CandidateQuestion:
|
||||
"""候选题目 — 出题管线生成、待门控审核的题目数据。
|
||||
|
||||
属性:
|
||||
question_id: 题目唯一标识(格式 "{video_id}_{task_type}_{seq:04d}")。
|
||||
video_id: 所属视频标识。
|
||||
task_type: 题型(如 "Action Reasoning")。
|
||||
skill_target: 目标失败机制编号(M1-M5)。
|
||||
question: 题目文本。
|
||||
options: 选项元组(如 ("A. ...", "B. ...", "C. ...", "D. ..."))。
|
||||
answer: 正确答案字母(如 "A")。
|
||||
source_nodes: 来源节点 ID 元组。
|
||||
difficulty: 难度等级(easy/medium/hard)。
|
||||
subtitle_sentences: 验证材料 — 字幕句子元组。
|
||||
frame_paths: 验证材料 — 帧图片路径元组。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
video_id: str
|
||||
task_type: str
|
||||
skill_target: str
|
||||
question: str
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
source_nodes: tuple[str, ...]
|
||||
difficulty: str
|
||||
subtitle_sentences: tuple[str, ...] = field(default_factory=tuple)
|
||||
frame_paths: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 模板加载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_prompt_template(template_name: str) -> str:
|
||||
"""加载 prompt 模板文件。
|
||||
|
||||
参数:
|
||||
template_name: store/prompts/question_gen/ 下的模板文件名。
|
||||
|
||||
返回:
|
||||
模板内容字符串。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 模板文件不存在。
|
||||
"""
|
||||
path = _PROMPTS_DIR / template_name
|
||||
if not path.exists():
|
||||
msg = f"Prompt 模板文件不存在: {path}"
|
||||
raise FileNotFoundError(msg)
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 构建
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_v2_prompt(
|
||||
prompt_template: str,
|
||||
strategy_name: str,
|
||||
material: MaterialContext,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
*,
|
||||
reject_reason: str | None = None,
|
||||
sub_pattern_instruction: str | None = None,
|
||||
) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""构建 VLM 出题调用的 messages 和帧路径列表。
|
||||
|
||||
参数:
|
||||
prompt_template: prompt 模板文件名(store/prompts/question_gen/ 下)。
|
||||
strategy_name: 策略名称(如 "RETRIEVAL")。
|
||||
material: 采样素材上下文。
|
||||
task_type: 任务类型字符串。
|
||||
seq: 当前序号。
|
||||
reject_reason: 上一次被门控拒绝的原因(用于引导 VLM 避免相同错误)。
|
||||
sub_pattern_instruction: 子模式特殊指令(如有)。
|
||||
|
||||
返回:
|
||||
二元组:
|
||||
- messages: 适配 VLMProvider 的 message 列表(system + user)。
|
||||
- frame_paths: 需发送给 VLM 的帧路径列表。
|
||||
"""
|
||||
# Phase 1: 加载策略模板作为 system prompt
|
||||
template_content = _load_prompt_template(prompt_template)
|
||||
system_message = template_content
|
||||
|
||||
# Phase 2: 构建 user prompt — 聚合素材信息
|
||||
user_parts: list[str] = []
|
||||
|
||||
user_parts.append(f"## Task Type: {task_type}")
|
||||
user_parts.append(f"## Question Family: {strategy_name}")
|
||||
user_parts.append(f"## Sequence: #{seq}")
|
||||
|
||||
# 字幕素材
|
||||
if material.subtitle_sentences:
|
||||
user_parts.append("\n## Subtitle Content:")
|
||||
for i, sent in enumerate(material.subtitle_sentences, 1):
|
||||
user_parts.append(f" {i}. {sent}")
|
||||
|
||||
# 跨 L2 上下文
|
||||
if material.cross_l2_texts:
|
||||
user_parts.append("\n## Cross-Segment Context:")
|
||||
for text in material.cross_l2_texts:
|
||||
user_parts.append(f" - {text}")
|
||||
|
||||
# 帧路径提示(VLM 会接收实际图像,此处仅作文本参考)
|
||||
if material.frame_paths:
|
||||
user_parts.append(f"\n## Visual Frames: {len(material.frame_paths)} frames attached.")
|
||||
|
||||
# 拒绝原因注入
|
||||
if reject_reason is not None:
|
||||
user_parts.append(
|
||||
f"\n## IMPORTANT - Previous Attempt Rejected:\n"
|
||||
f"Your previous question was rejected for the following reason:\n"
|
||||
f'"{reject_reason}"\n'
|
||||
f"Please generate a NEW question that avoids this issue."
|
||||
)
|
||||
|
||||
# 子模式特殊指令注入
|
||||
if sub_pattern_instruction is not None:
|
||||
user_parts.append(f"\n## Special Focus:\n{sub_pattern_instruction}")
|
||||
|
||||
# 输出格式指令
|
||||
user_parts.append(
|
||||
"\n## Output Format:\n"
|
||||
"Respond with ONLY a JSON object in this exact format:\n"
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "question": "Your question text here",\n'
|
||||
' "options": ["A. ...", "B. ...", "C. ...", "D. ..."],\n'
|
||||
' "answer": "A",\n'
|
||||
' "difficulty": "easy|medium|hard"\n'
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
|
||||
user_content = "\n".join(user_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_message},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
# Phase 3: 帧路径
|
||||
frame_paths = list(material.frame_paths)
|
||||
|
||||
return messages, frame_paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 响应解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_json_from_text(raw: str) -> str:
|
||||
"""从可能被 markdown 代码块包裹的文本中提取 JSON 部分。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回文本。
|
||||
|
||||
返回:
|
||||
清理后的 JSON 字符串。
|
||||
"""
|
||||
content = raw.strip()
|
||||
if "```" in content:
|
||||
parts = content.split("```")
|
||||
for part in parts:
|
||||
stripped = part.strip()
|
||||
if stripped.startswith("json"):
|
||||
stripped = stripped[4:].strip()
|
||||
if stripped.startswith("{"):
|
||||
return stripped
|
||||
return content
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ValidatedFields:
|
||||
"""字段校验通过后的中间结构。"""
|
||||
|
||||
question: str
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
difficulty: str
|
||||
|
||||
|
||||
def _validate_parsed_fields(data: dict) -> _ValidatedFields:
|
||||
"""校验 VLM 响应 JSON 的必填字段并规范化。
|
||||
|
||||
参数:
|
||||
data: 已解析的 JSON 字典。
|
||||
|
||||
返回:
|
||||
_ValidatedFields 实例(字段已规范化)。
|
||||
|
||||
异常:
|
||||
ValueError: 缺少必填字段或字段值非法。
|
||||
"""
|
||||
missing = [f for f in ("question", "options", "answer", "difficulty") if f not in data]
|
||||
if missing:
|
||||
msg = f"VLM 响应缺少必填字段: {', '.join(missing)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
question_text = str(data["question"])
|
||||
options_raw = data["options"]
|
||||
answer = str(data["answer"]).strip().upper()
|
||||
difficulty = str(data["difficulty"]).strip().lower()
|
||||
|
||||
# 校验 options(answer 约束为 A-D,因此必须恰好 4 个选项)
|
||||
if not isinstance(options_raw, list) or len(options_raw) != 4:
|
||||
msg = f"options 字段必须恰好包含 4 个选项,实际数量: {len(options_raw) if isinstance(options_raw, list) else type(options_raw).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# 校验 answer
|
||||
if answer not in _VALID_ANSWERS:
|
||||
msg = f"answer 字段值 '{answer}' 非法,必须为 A/B/C/D 之一"
|
||||
raise ValueError(msg)
|
||||
|
||||
# 校验 difficulty(宽容处理:非法值回退为 medium)
|
||||
if difficulty not in _VALID_DIFFICULTIES:
|
||||
logger.warning(
|
||||
"difficulty '{}' 不在预设范围 {},回退为 'medium'",
|
||||
difficulty,
|
||||
_VALID_DIFFICULTIES,
|
||||
)
|
||||
difficulty = "medium"
|
||||
|
||||
return _ValidatedFields(
|
||||
question=question_text,
|
||||
options=tuple(str(o) for o in options_raw),
|
||||
answer=answer,
|
||||
difficulty=difficulty,
|
||||
)
|
||||
|
||||
|
||||
def _parse_v2_response(
|
||||
raw: str,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
skill_target: str,
|
||||
seq: int,
|
||||
source_nodes: tuple[str, ...],
|
||||
) -> CandidateQuestion:
|
||||
"""解析 VLM 返回的 JSON 响应,构造 CandidateQuestion。
|
||||
|
||||
流程:
|
||||
1. 提取 JSON(处理 markdown 包裹)。
|
||||
2. json_repair 修复常见格式错误。
|
||||
3. 校验必填字段(委托 _validate_parsed_fields)。
|
||||
4. 构造 CandidateQuestion 实例。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回文本。
|
||||
video_id: 视频标识。
|
||||
task_type: 任务类型。
|
||||
skill_target: 目标技能编号。
|
||||
seq: 当前序号。
|
||||
source_nodes: 来源节点 ID 元组。
|
||||
|
||||
返回:
|
||||
CandidateQuestion 实例。
|
||||
|
||||
异常:
|
||||
ValueError: JSON 解析失败或缺少必填字段或字段值非法。
|
||||
"""
|
||||
# Phase 1: 提取 + 修复 JSON
|
||||
json_text = _extract_json_from_text(raw)
|
||||
repaired = repair_json(json_text, return_objects=False)
|
||||
|
||||
# Phase 2: 解析为字典
|
||||
try:
|
||||
data = json.loads(repaired)
|
||||
except json.JSONDecodeError as e:
|
||||
msg = f"VLM 响应 JSON 解析失败: {e}. 原始文本: {raw[:200]}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
if not isinstance(data, dict):
|
||||
msg = f"VLM 响应顶层不是 JSON 对象: type={type(data).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Phase 3: 字段校验
|
||||
fields = _validate_parsed_fields(data)
|
||||
|
||||
# Phase 4: 构造 CandidateQuestion
|
||||
question_id = f"{video_id}_{task_type}_{seq:04d}"
|
||||
|
||||
return CandidateQuestion(
|
||||
question_id=question_id,
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
skill_target=skill_target,
|
||||
question=fields.question,
|
||||
options=fields.options,
|
||||
answer=fields.answer,
|
||||
source_nodes=source_nodes,
|
||||
difficulty=fields.difficulty,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def generate_one_v2(
|
||||
vlm: VLMProvider,
|
||||
tree: TreeIndex,
|
||||
material: MaterialContext,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
*,
|
||||
video_id: str,
|
||||
prompt_template: str,
|
||||
strategy_name: str,
|
||||
skill_target: str,
|
||||
reject_reason: str | None = None,
|
||||
sub_pattern_instruction: str | None = None,
|
||||
session_id: str,
|
||||
) -> CandidateQuestion:
|
||||
"""调用 VLM 生成一道候选题目。
|
||||
|
||||
流程:
|
||||
1. 构建 per-strategy prompt + 帧路径。
|
||||
2. 调用 VLMProvider.chat_with_images。
|
||||
3. 解析响应为 CandidateQuestion。
|
||||
4. 附加素材验证信息(subtitle_sentences、frame_paths)。
|
||||
|
||||
参数:
|
||||
vlm: VLM 调用端口。
|
||||
tree: 视频树索引(当前未直接使用,预留后续扩展)。
|
||||
material: 采样素材上下文。
|
||||
task_type: 任务类型字符串。
|
||||
seq: 当前序号。
|
||||
video_id: 视频标识。
|
||||
prompt_template: prompt 模板文件名。
|
||||
strategy_name: 策略名称(如 "RETRIEVAL")。
|
||||
skill_target: 目标失败机制编号(M1-M5)。
|
||||
reject_reason: 上一次被门控拒绝的原因。
|
||||
sub_pattern_instruction: 子模式特殊指令(如有)。
|
||||
session_id: 会话 ID(遥测关联)。
|
||||
|
||||
返回:
|
||||
CandidateQuestion 实例(包含验证材料)。
|
||||
|
||||
异常:
|
||||
ValueError: VLM 响应解析失败。
|
||||
FileNotFoundError: prompt 模板不存在。
|
||||
"""
|
||||
# Phase 1: 构建 prompt
|
||||
messages, frame_paths = _build_v2_prompt(
|
||||
prompt_template=prompt_template,
|
||||
strategy_name=strategy_name,
|
||||
material=material,
|
||||
task_type=task_type,
|
||||
seq=seq,
|
||||
reject_reason=reject_reason,
|
||||
sub_pattern_instruction=sub_pattern_instruction,
|
||||
)
|
||||
|
||||
# Phase 2: 调用 VLM
|
||||
logger.debug(
|
||||
"generate_one_v2: strategy={}, task_type={}, seq={}, frames={}",
|
||||
strategy_name,
|
||||
task_type,
|
||||
seq,
|
||||
len(frame_paths),
|
||||
)
|
||||
|
||||
response = await vlm.chat_with_images(
|
||||
messages,
|
||||
frame_paths,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Phase 3: 解析响应
|
||||
candidate = _parse_v2_response(
|
||||
raw=response.content,
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
skill_target=skill_target,
|
||||
seq=seq,
|
||||
source_nodes=material.source_nodes,
|
||||
)
|
||||
|
||||
# Phase 4: 附加验证材料(构造新实例,因 frozen=True)
|
||||
candidate = CandidateQuestion(
|
||||
question_id=candidate.question_id,
|
||||
video_id=candidate.video_id,
|
||||
task_type=candidate.task_type,
|
||||
skill_target=candidate.skill_target,
|
||||
question=candidate.question,
|
||||
options=candidate.options,
|
||||
answer=candidate.answer,
|
||||
source_nodes=candidate.source_nodes,
|
||||
difficulty=candidate.difficulty,
|
||||
subtitle_sentences=tuple(material.subtitle_sentences),
|
||||
frame_paths=tuple(material.frame_paths),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"generate_one_v2 完成: question_id={}, difficulty={}",
|
||||
candidate.question_id,
|
||||
candidate.difficulty,
|
||||
)
|
||||
|
||||
return candidate
|
||||
+83
-45
@@ -15,14 +15,23 @@ from core.types import GeneratedQuestion
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from core.types import QuestionUnit
|
||||
|
||||
_LEGACY_DEFAULT_DIFFICULTY = "medium"
|
||||
|
||||
|
||||
def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
|
||||
"""从 benchmark JSON 目录加载题目列表。
|
||||
|
||||
每个 JSON 文件以文件名(不含扩展名)作为 video_id,
|
||||
文件内容为题目数组。
|
||||
video_id 优先使用题目 JSON 中的 ``video_id`` 字段;若缺失则回退到
|
||||
文件名(不含扩展名)。Video-MME benchmark 按视频拆文件(文件名即
|
||||
video_id),v2 生成题把多视频题目合并在单个 JSON 中(每条记录自带
|
||||
``video_id``),两种格式均兼容。
|
||||
|
||||
pair 契约字段(``pair_id`` / ``question_role`` / ``flip_axis`` / ``unit_id``)
|
||||
用 ``.get`` 读取:旧 benchmark 无这些键时退化为 single(``question_role``
|
||||
默认 "single",``unit_id`` 留空由 __post_init__ 回填为 question_id),
|
||||
保证历史题库可无缝加载。
|
||||
|
||||
参数:
|
||||
questions_dir: 包含 *.json 文件的目录路径。
|
||||
@@ -32,20 +41,30 @@ def load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]:
|
||||
"""
|
||||
results: list[GeneratedQuestion] = []
|
||||
for path in sorted(questions_dir.glob("*.json")):
|
||||
video_id = path.stem
|
||||
fallback_video_id = path.stem
|
||||
with open(path, encoding="utf-8") as f:
|
||||
qa_list: list[dict] = json.load(f)
|
||||
for qa in qa_list:
|
||||
results.append(
|
||||
GeneratedQuestion(
|
||||
question_id=qa["question_id"],
|
||||
video_id=video_id,
|
||||
video_id=qa.get("video_id", fallback_video_id),
|
||||
task_type=qa["task_type"],
|
||||
question=qa["question"],
|
||||
options=tuple(qa["options"]),
|
||||
answer=qa["answer"],
|
||||
source_nodes=tuple(qa.get("source_nodes", ())),
|
||||
difficulty=qa.get("difficulty", _LEGACY_DEFAULT_DIFFICULTY),
|
||||
family=qa.get("family"),
|
||||
skill_target=qa.get("skill_target"),
|
||||
difficulty_steps=qa.get("difficulty_steps"),
|
||||
sub_pattern=qa.get("sub_pattern"),
|
||||
# pair 契约字段:旧 benchmark 无这些键时按 single 默认兜底,
|
||||
# unit_id 留空交由 GeneratedQuestion.__post_init__ 回填。
|
||||
pair_id=qa.get("pair_id"),
|
||||
question_role=qa.get("question_role", "single"),
|
||||
flip_axis=qa.get("flip_axis"),
|
||||
unit_id=qa.get("unit_id", ""),
|
||||
)
|
||||
)
|
||||
return results
|
||||
@@ -60,62 +79,83 @@ def stratified_sample(
|
||||
seed: int,
|
||||
min_per_class: int | None,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""按题型过滤后采样 size 道题,可选按对错比例分层并按题型保底。
|
||||
"""按题型过滤后采样 size 个单元,可选按对错比例分层并按题型保底。
|
||||
|
||||
参数:
|
||||
questions: 候选题目全集。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
size: 采样总量。
|
||||
correct_ratio: 采样中"基线答对"题的占比;None 表示自然分布。
|
||||
questions: 候选题目全集(single 与孪生对成员可混含)。
|
||||
correctness: question_id -> 基线是否答对(单元级正确性取成员 AND)。
|
||||
size: 采样单元总量(single 计 1、pair 计 1)。
|
||||
correct_ratio: 采样中"基线答对"单元的占比;None 表示自然分布。
|
||||
task_types: 限定题型;None 表示不限。
|
||||
seed: 随机种子,保证可复现。
|
||||
min_per_class: 每个题型补足到的下限;None 表示不补足。
|
||||
min_per_class: 每个题型补足到的单元下限;None 表示不补足。
|
||||
|
||||
返回:
|
||||
采样后的题目列表。
|
||||
采样后的题目列表(pair 单元展开为原始的两道题)。
|
||||
|
||||
异常:
|
||||
ValueError: 自然分布时池不足 size,或分层时某层题目不足。
|
||||
ValueError: 自然分布时单元池不足 size,或分层时某层单元不足。
|
||||
|
||||
关键实现:
|
||||
以 **QuestionUnit 为采样原子**(single 计 1、pair 计 1),size /
|
||||
correct_ratio / min_per_class 均按 unit 计数,孪生对两题永不被劈开。
|
||||
采样完成后 flatten_units 展开回逐题列表。纯 single 输入时 build_units
|
||||
与题目一一对应、顺序不变,rng 消耗与旧逐题实现完全一致(字节级回归)。
|
||||
|
||||
build_units / flatten_units 采用函数内延迟导入:loader 属 question_gen,
|
||||
question_units 属 harness,而 harness 包初始化会反向 import question_gen,
|
||||
模块级导入将触发循环依赖(沿用 adversarial_filter 的既有做法)。
|
||||
"""
|
||||
from app.harness.question_units import build_units, flatten_units
|
||||
|
||||
rng = random.Random(seed)
|
||||
pool = [q for q in questions if task_types is None or q.task_type in task_types]
|
||||
units = build_units(questions)
|
||||
pool = [u for u in units if task_types is None or u.task_type in task_types]
|
||||
|
||||
if correct_ratio is None:
|
||||
if len(pool) < size:
|
||||
raise ValueError(f"自然分布采样不足: 需 {size} 道, 实有 {len(pool)} 道")
|
||||
raise ValueError(f"自然分布采样不足: 需 {size} 个单元, 实有 {len(pool)} 个")
|
||||
sampled = rng.sample(pool, size)
|
||||
else:
|
||||
sampled = _ratio_stratified_sample(pool, correctness, size, correct_ratio, rng)
|
||||
|
||||
if min_per_class is not None:
|
||||
sampled = _backfill_per_class(sampled, pool, min_per_class, rng)
|
||||
return sampled
|
||||
return flatten_units(sampled)
|
||||
|
||||
|
||||
def _ratio_stratified_sample(
|
||||
pool: list[GeneratedQuestion],
|
||||
pool: list[QuestionUnit],
|
||||
correctness: dict[str, bool],
|
||||
size: int,
|
||||
correct_ratio: float,
|
||||
rng: random.Random,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""按对错比例分层采样:对题占 correct_ratio,其余为错题。
|
||||
) -> list[QuestionUnit]:
|
||||
"""按对错比例分层采样:对单元占 correct_ratio,其余为错单元。
|
||||
|
||||
参数:
|
||||
pool: 题型过滤后的候选题。
|
||||
pool: 题型过滤后的候选单元。
|
||||
correctness: question_id -> 基线是否答对。
|
||||
size: 采样总量。
|
||||
correct_ratio: 对题占比。
|
||||
size: 采样单元总量。
|
||||
correct_ratio: 对单元占比。
|
||||
rng: 随机数发生器。
|
||||
|
||||
返回:
|
||||
采样后的题目列表(对题在前、错题在后)。
|
||||
采样后的单元列表(对单元在前、错单元在后)。
|
||||
|
||||
异常:
|
||||
ValueError: 对题或错题层不足。
|
||||
ValueError: 对单元或错单元层不足。
|
||||
|
||||
关键实现:
|
||||
unit_correctness 采用函数内延迟导入:loader 属 question_gen,
|
||||
question_units 属 harness,模块级导入将触发循环依赖(沿用 build_units /
|
||||
flatten_units 的既有做法)。以 strict=False 保持"缺基线对错即视为未答对"的
|
||||
原 loose 语义不变。
|
||||
"""
|
||||
correct = [q for q in pool if correctness.get(q.question_id, False)]
|
||||
wrong = [q for q in pool if not correctness.get(q.question_id, False)]
|
||||
from app.harness.question_units import unit_correctness
|
||||
|
||||
correct = [u for u in pool if unit_correctness(u, correctness, strict=False)]
|
||||
wrong = [u for u in pool if not unit_correctness(u, correctness, strict=False)]
|
||||
n_correct = round(size * correct_ratio)
|
||||
n_wrong = size - n_correct
|
||||
if len(correct) < n_correct or len(wrong) < n_wrong:
|
||||
@@ -126,42 +166,40 @@ def _ratio_stratified_sample(
|
||||
|
||||
|
||||
def _backfill_per_class(
|
||||
sampled: list[GeneratedQuestion],
|
||||
pool: list[GeneratedQuestion],
|
||||
sampled: list[QuestionUnit],
|
||||
pool: list[QuestionUnit],
|
||||
min_per_class: int,
|
||||
rng: random.Random,
|
||||
) -> list[GeneratedQuestion]:
|
||||
"""对候选池中出现的每个题型,将采样结果补足到 min_per_class 道。
|
||||
) -> list[QuestionUnit]:
|
||||
"""对候选池中出现的每个题型,将采样单元补足到 min_per_class 个。
|
||||
|
||||
遍历对象是候选池 pool 里出现的全部题型(非仅 sampled 命中的),
|
||||
保证任意稀疏题型都能拿到足额样本。
|
||||
保证任意稀疏题型都能拿到足额样本。补足以 unit 为原子,孪生对整进整出。
|
||||
|
||||
参数:
|
||||
sampled: 主采样结果(不修改,返回新列表)。
|
||||
pool: 候选题全集(补足来源 + 题型枚举来源)。
|
||||
min_per_class: 每个题型的下限。
|
||||
sampled: 主采样结果单元(不修改,返回新列表)。
|
||||
pool: 候选单元全集(补足来源 + 题型枚举来源)。
|
||||
min_per_class: 每个题型的单元下限。
|
||||
rng: 随机数发生器。
|
||||
|
||||
返回:
|
||||
补足后的题目列表。
|
||||
补足后的单元列表。
|
||||
"""
|
||||
selected_ids = {q.question_id for q in sampled}
|
||||
selected_ids = {u.unit_id for u in sampled}
|
||||
result = list(sampled)
|
||||
counts: dict[str, int] = {}
|
||||
for q in sampled:
|
||||
counts[q.task_type] = counts.get(q.task_type, 0) + 1
|
||||
for u in sampled:
|
||||
counts[u.task_type] = counts.get(u.task_type, 0) + 1
|
||||
ordered_task_types: dict[str, None] = {}
|
||||
for q in pool:
|
||||
ordered_task_types.setdefault(q.task_type, None)
|
||||
for u in pool:
|
||||
ordered_task_types.setdefault(u.task_type, None)
|
||||
for task_type in ordered_task_types:
|
||||
deficit = min_per_class - counts.get(task_type, 0)
|
||||
if deficit <= 0:
|
||||
continue
|
||||
candidates = [
|
||||
q for q in pool if q.task_type == task_type and q.question_id not in selected_ids
|
||||
]
|
||||
candidates = [u for u in pool if u.task_type == task_type and u.unit_id not in selected_ids]
|
||||
take = rng.sample(candidates, min(deficit, len(candidates)))
|
||||
for q in take:
|
||||
selected_ids.add(q.question_id)
|
||||
result.append(q)
|
||||
for u in take:
|
||||
selected_ids.add(u.unit_id)
|
||||
result.append(u)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""accepted 题库的 pair 原子成对落盘 helper(纯件,不依赖 pipeline)。
|
||||
|
||||
三件可复用纯件供 Phase 2 新 pipeline 的 on_accept 回调复用:``PairPendingBuffer``
|
||||
(按 pair_id 收齐才 emit 单元)、``write_accepted``(tmp + os.replace 原子写)、
|
||||
``read_accepted``(聚合成单元并剔除磁盘悬挂孤儿)。
|
||||
|
||||
wiring 归属:本模块**只是纯件**,不接任何生成侧回调;真正的 on_accept wiring
|
||||
**见 Phase 2**。当前项目真实 accepted 写入点是 ``adversarial_filter.write_final_bank``
|
||||
(Phase 2 待重建的旧代码),本模块沿用其 tmp + os.replace 模式但不 import/不改动它。
|
||||
|
||||
unit_hash 校验:``QuestionUnit.unit_hash`` 目前恒为 ""(填充是 Phase 2 的事),且
|
||||
``GeneratedQuestion`` 不携带 unit_hash 字段。故把"unit_hash 不一致→拒"落地为**同
|
||||
pair_id 两成员的绑定一致性校验**(video_id / task_type / flip_axis),不一致即
|
||||
fail-fast raise,语义等价——只有 payload 绑定一致的孪生对才允许聚合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from core.types import GeneratedQuestion, QuestionUnit
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _check_pair_binding(first: GeneratedQuestion, second: GeneratedQuestion) -> None:
|
||||
"""校验同 pair_id 两成员的绑定一致性,不一致 fail-fast(unit_hash 语义代偿)。
|
||||
|
||||
参数 first/second 为先后到达的孪生对成员。video_id / task_type / flip_axis 任一
|
||||
不一致即 raise ValueError——绑定不一致的两条题目不构成同一 payload 的孪生对,
|
||||
拒绝聚合而非静默兜底。
|
||||
"""
|
||||
mismatches: list[str] = []
|
||||
if first.video_id != second.video_id:
|
||||
mismatches.append(f"video_id: {first.video_id} != {second.video_id}")
|
||||
if first.task_type != second.task_type:
|
||||
mismatches.append(f"task_type: {first.task_type} != {second.task_type}")
|
||||
if first.flip_axis != second.flip_axis:
|
||||
mismatches.append(f"flip_axis: {first.flip_axis} != {second.flip_axis}")
|
||||
if mismatches:
|
||||
raise ValueError(
|
||||
f"pair {first.pair_id} 两成员绑定不一致(" + ";".join(mismatches) + "),拒绝聚合"
|
||||
)
|
||||
|
||||
|
||||
class PairPendingBuffer:
|
||||
"""按 pair_id 收齐孪生对才 emit 单元的有状态缓冲器。
|
||||
|
||||
喂题接口 ``add`` 逐条消费题目:single 立即 emit ``QuestionUnit.from_single``;
|
||||
pair 成员先缓存,等同一 pair_id 的 original+mirror 都到齐才 emit 一个 pair 单元
|
||||
(复用 ``QuestionUnit.from_pair`` 走 fail-fast 校验)。批次末尾用
|
||||
``pending_orphans`` 检测"只落 P 未落 Q"的悬挂项。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""pending 以 pair_id 索引首个到达的孪生对成员,等伙伴到齐再 emit。"""
|
||||
self._pending: dict[str, GeneratedQuestion] = {}
|
||||
|
||||
def add(self, q: GeneratedQuestion) -> QuestionUnit | None:
|
||||
"""喂入一条题目 q,返回本次凑齐的单元或 None(pair 尚未配齐)。
|
||||
|
||||
single 立即返回 kind="single" 单元;pair 首个成员缓存并返回 None,第二个
|
||||
成员到齐后返回 kind="pair" 单元。同 pair_id 两成员绑定不一致或角色非法(如
|
||||
两个 original)即 raise ValueError。
|
||||
|
||||
关键实现:配齐后**先校验、成功组装出 unit 才从 pending 删除**——若
|
||||
``_check_pair_binding`` / ``_order_pair`` raise,首成员仍留在 pending,调用方
|
||||
``pending_orphans`` 可取回被拒的悬挂成员。绑定校验用显式 ValueError(不依赖
|
||||
会被 ``-O`` 剥除的 assert)。
|
||||
"""
|
||||
if not q.pair_id:
|
||||
return QuestionUnit.from_single(q)
|
||||
|
||||
partner = self._pending.get(q.pair_id)
|
||||
if partner is None:
|
||||
self._pending[q.pair_id] = q
|
||||
return None
|
||||
|
||||
_check_pair_binding(partner, q)
|
||||
original, mirror = _order_pair(partner, q)
|
||||
unit = QuestionUnit.from_pair(original, mirror)
|
||||
del self._pending[q.pair_id]
|
||||
return unit
|
||||
|
||||
def pending_orphans(self) -> list[GeneratedQuestion]:
|
||||
"""返回仍未配齐的悬挂成员,供调用方在批次末尾检测"只落 P 未落 Q"。"""
|
||||
return list(self._pending.values())
|
||||
|
||||
|
||||
def _order_pair(
|
||||
a: GeneratedQuestion, b: GeneratedQuestion
|
||||
) -> tuple[GeneratedQuestion, GeneratedQuestion]:
|
||||
"""按 question_role 把两成员 a/b 定序为 (original, mirror)。
|
||||
|
||||
两成员不构成恰好 1 original + 1 mirror(角色缺失或重复)即 raise ValueError,
|
||||
防 next(...) 静默 StopIteration。
|
||||
"""
|
||||
originals = [q for q in (a, b) if q.question_role == "pair_original"]
|
||||
mirrors = [q for q in (a, b) if q.question_role == "pair_mirror"]
|
||||
if len(originals) != 1 or len(mirrors) != 1:
|
||||
raise ValueError(
|
||||
f"pair {a.pair_id} 角色非法:original={len(originals)} mirror={len(mirrors)},"
|
||||
"需各恰好 1 条"
|
||||
)
|
||||
return originals[0], mirrors[0]
|
||||
|
||||
|
||||
def _validate_disk_pair(pair_id: str, group: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
|
||||
"""三态判定磁盘上同 pair_id 分组,区分悬挂孤儿(drop)与结构损坏(raise)。
|
||||
|
||||
- 恰好 1 original + 1 mirror:合法孪生对,额外做 ``_check_pair_binding`` 显式绑定
|
||||
校验(video_id/task_type/flip_axis,-O 下仍生效),返回其两题。
|
||||
- size==1(只落 P 未落 Q):业务上合法的悬挂孤儿,warn + drop,返回 []。
|
||||
- 其余(size>2 超员、或 size==2 角色重复/缺角色):数据损坏/外部篡改,按 P5
|
||||
fail-loud,raise ValueError(含 pair_id 与成员构成),绝不静默吞。
|
||||
"""
|
||||
originals = sum(1 for q in group if q.question_role == "pair_original")
|
||||
mirrors = sum(1 for q in group if q.question_role == "pair_mirror")
|
||||
if len(group) == 2 and originals == 1 and mirrors == 1:
|
||||
_check_pair_binding(group[0], group[1])
|
||||
return group
|
||||
if len(group) == 1:
|
||||
logger.warning("磁盘悬挂孤儿 pair {}:仅 1 成员(缺伙伴),warn+drop 该 unit", pair_id)
|
||||
return []
|
||||
raise ValueError(
|
||||
f"磁盘 pair {pair_id} 结构损坏:成员数={len(group)}"
|
||||
f"(original={originals} mirror={mirrors}),需恰好 1 original + 1 mirror"
|
||||
)
|
||||
|
||||
|
||||
def _keep_complete_disk_pairs(questions: list[GeneratedQuestion]) -> list[GeneratedQuestion]:
|
||||
"""筛选磁盘读回的题目:single 全保留、pair 按 ``_validate_disk_pair`` 三态处理。
|
||||
|
||||
悬挂孤儿 warn+drop、结构损坏/绑定不一致 raise ValueError、合法成对保留后交给
|
||||
build_units(single 全保留)。
|
||||
"""
|
||||
by_pair: dict[str, list[GeneratedQuestion]] = defaultdict(list)
|
||||
singles: list[GeneratedQuestion] = []
|
||||
for q in questions:
|
||||
if q.pair_id:
|
||||
by_pair[q.pair_id].append(q)
|
||||
else:
|
||||
singles.append(q)
|
||||
|
||||
kept_pairs = [q for pid, grp in by_pair.items() for q in _validate_disk_pair(pid, grp)]
|
||||
return singles + kept_pairs
|
||||
|
||||
|
||||
def write_accepted(path: Path, units: list[QuestionUnit]) -> None:
|
||||
"""把 units 全量原子写到 path(tmp + os.replace),孪生对两题相邻落盘。
|
||||
|
||||
parent 不存在则自动创建;任一 pair 单元结构非法(size≠2)落盘前 fail-fast raise。
|
||||
|
||||
关键实现:沿用 ``write_final_bank`` 的原子写模式(先写同目录 ``.tmp`` 再
|
||||
``os.replace`` 覆盖,保证读到的 JSON 恒完整)。序列化复用 T9 pools.py 的
|
||||
``_q_to_dict``(唯一 GeneratedQuestion↔dict schema,含 pair 四字段),函数内
|
||||
import 规避 app.question_gen↔app.harness 循环依赖。
|
||||
"""
|
||||
from app.harness.pools import _q_to_dict
|
||||
from app.harness.question_units import flatten_units, validate_units
|
||||
|
||||
validate_units(units)
|
||||
records = [_q_to_dict(q) for q in flatten_units(units)]
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(str(tmp), str(path))
|
||||
logger.info("accepted 题库全量原子写: {} 单元 / {} 题 → {}", len(units), len(records), path)
|
||||
|
||||
|
||||
def read_accepted(path: Path) -> list[QuestionUnit]:
|
||||
"""读回 path 的 accepted JSON → 聚合为单元列表。
|
||||
|
||||
磁盘是外部输入,按 P5 全量校验后再用:``_keep_complete_disk_pairs`` 对 pair 分组三态处理
|
||||
——"只落 P 未落 Q"的悬挂孤儿 warn+drop(不进结果、不 raise);结构损坏(超员/
|
||||
角色重复)或绑定不一致(video_id/task_type/flip_axis)显式 raise ValueError(不
|
||||
依赖 build_units 内会被 ``-O`` 剥除的 assert)。single 全保留。
|
||||
|
||||
关键实现:反序列化复用 T9 pools.py 的 ``_dict_to_q``(pair 四字段 .get 兼容),
|
||||
函数内 import 规避循环依赖。sift 后 ``build_units`` 聚合、``validate_units`` 二次
|
||||
防御闸门。
|
||||
"""
|
||||
from app.harness.pools import _dict_to_q
|
||||
from app.harness.question_units import build_units, validate_units
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
questions = [_dict_to_q(d) for d in raw]
|
||||
kept = _keep_complete_disk_pairs(questions)
|
||||
return validate_units(build_units(kept))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
"""确定性后处理层 — 零 LLM 的选项洗牌、指代黑名单、逐字重复率、时间锚点、素材禁区检测。
|
||||
|
||||
所有函数均为纯函数(给定输入必定产出相同输出),
|
||||
用于 pipeline 出题后、门控前的确定性质量检查。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostprocessResult:
|
||||
"""后处理结果汇总。
|
||||
|
||||
属性:
|
||||
options: 洗牌后的选项元组。
|
||||
answer: 重映射后的答案字母。
|
||||
referent_violations: 指代黑名单违规描述列表。
|
||||
verbatim_ratio: 正确选项与来源素材的逐字重复率 [0.0, 1.0]。
|
||||
has_time_anchor: 题目是否包含时间锚点。
|
||||
"""
|
||||
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
referent_violations: list[str]
|
||||
verbatim_ratio: float
|
||||
has_time_anchor: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 指代黑名单(预编译正则)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLACKLIST_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"this\s+clip", re.IGNORECASE), "this clip"),
|
||||
(re.compile(r"the\s+video", re.IGNORECASE), "the video"),
|
||||
(re.compile(r"this\s+video", re.IGNORECASE), "this video"),
|
||||
(re.compile(r"this\s+scene", re.IGNORECASE), "this scene"),
|
||||
(re.compile(r"the\s+clip", re.IGNORECASE), "the clip"),
|
||||
(re.compile(r"this\s+footage", re.IGNORECASE), "this footage"),
|
||||
(re.compile(r"the\s+footage", re.IGNORECASE), "the footage"),
|
||||
(re.compile(r"this\s+segment", re.IGNORECASE), "this segment"),
|
||||
(re.compile(r"this\s+frame", re.IGNORECASE), "this frame"),
|
||||
(re.compile(r"the\s+current\s+frame", re.IGNORECASE), "the current frame"),
|
||||
(re.compile(r"frame\s+summary", re.IGNORECASE), "frame summary"),
|
||||
(re.compile(r"上面的片段", re.IGNORECASE), "上面的片段"),
|
||||
(re.compile(r"这段视频", re.IGNORECASE), "这段视频"),
|
||||
(re.compile(r"该视频", re.IGNORECASE), "该视频"),
|
||||
(re.compile(r"这个片段", re.IGNORECASE), "这个片段"),
|
||||
(re.compile(r"视频中", re.IGNORECASE), "视频中"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 时间锚点正则与短语
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TIMESTAMP_PATTERN = re.compile(r"\d{1,2}:\d{2}")
|
||||
|
||||
_TIME_ANCHOR_PHRASES: list[re.Pattern[str]] = [
|
||||
re.compile(r"at\s+the\s+beginning", re.IGNORECASE),
|
||||
re.compile(r"at\s+the\s+end", re.IGNORECASE),
|
||||
re.compile(r"in\s+the\s+beginning", re.IGNORECASE),
|
||||
re.compile(r"at\s+the\s+start", re.IGNORECASE),
|
||||
re.compile(r"开头", re.IGNORECASE),
|
||||
re.compile(r"结尾", re.IGNORECASE),
|
||||
re.compile(r"末尾", re.IGNORECASE),
|
||||
re.compile(r"片头", re.IGNORECASE),
|
||||
re.compile(r"片尾", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T1 / T7 素材禁区正则
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# T1: 瞬时动作
|
||||
_T1_INSTANT_ACTION_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"瞬间|一闪而过|转瞬即逝|一瞬间|刹那", re.IGNORECASE),
|
||||
re.compile(r"flash|instant|split\s*second|blink", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# T1: 记分牌时序
|
||||
_T1_SCOREBOARD_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"记分牌|比分.*变|比分.*更新|得分.*变化", re.IGNORECASE),
|
||||
re.compile(r"scoreboard|score\s*(changed|updated|went)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# T1: 无对白因果
|
||||
_T1_NO_DIALOGUE_CAUSAL_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"无对白.*因果|因果.*无法.*判断", re.IGNORECASE),
|
||||
re.compile(r"no\s+dialogue.*caus|cannot.*determin.*caus", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# T7: 选项重复
|
||||
_T7_OPTION_REPETITION_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"重复选项|选项.*相同|选项.*重复|完全相同", re.IGNORECASE),
|
||||
re.compile(r"duplicate\s+option|identical\s+option|same\s+option", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# T7: 计数边界口径含糊
|
||||
_T7_COUNTING_AMBIGUITY_PATTERNS: list[re.Pattern[str]] = [
|
||||
re.compile(r"大约.*数量|数量不确定|难以.*计数|不确定.*几|约.*个", re.IGNORECASE),
|
||||
re.compile(r"approximate.*count|uncertain.*number|hard\s+to\s+count", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 公开函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def shuffle_options(
|
||||
options: tuple[str, ...],
|
||||
answer: str,
|
||||
rng: random.Random,
|
||||
) -> tuple[tuple[str, ...], str]:
|
||||
"""确定性洗牌选项并重映射答案字母。
|
||||
|
||||
通过 index permutation 打乱选项顺序,为每个选项重新分配 A/B/C/D 前缀,
|
||||
并将答案字母映射到正确选项的新位置。
|
||||
|
||||
参数:
|
||||
options: 原始选项元组,格式为 ("A. text1", "B. text2", ...)。
|
||||
answer: 原始答案字母("A"/"B"/"C"/"D")。
|
||||
rng: 可控随机数生成器(保证确定性)。
|
||||
|
||||
返回:
|
||||
(新选项元组, 新答案字母) — 选项文本不变,仅前缀和顺序改变。
|
||||
"""
|
||||
# Phase 1: 提取纯文本(去掉 "X. " 前缀)
|
||||
texts = [opt[3:] for opt in options]
|
||||
|
||||
# Phase 2: 确定原正确选项的文本
|
||||
correct_idx = ord(answer) - ord("A")
|
||||
correct_text = texts[correct_idx]
|
||||
|
||||
# Phase 3: 生成随机排列
|
||||
indices = list(range(len(texts)))
|
||||
rng.shuffle(indices)
|
||||
|
||||
# Phase 4: 按排列重组,分配新前缀
|
||||
new_options: list[str] = []
|
||||
new_answer = ""
|
||||
for new_pos, old_idx in enumerate(indices):
|
||||
letter = chr(ord("A") + new_pos)
|
||||
new_options.append(f"{letter}. {texts[old_idx]}")
|
||||
if texts[old_idx] == correct_text:
|
||||
new_answer = letter
|
||||
|
||||
return tuple(new_options), new_answer
|
||||
|
||||
|
||||
def check_referent_blacklist(question_text: str) -> list[str]:
|
||||
"""检测题目文本中的自指代语言。
|
||||
|
||||
在 benchmark 题目中,自指代(如 "this clip"、"这段视频")会泄露视频上下文,
|
||||
使题目脱离视频后无法独立理解。
|
||||
|
||||
参数:
|
||||
question_text: 题目文本。
|
||||
|
||||
返回:
|
||||
违规描述列表,空列表表示通过。
|
||||
"""
|
||||
violations: list[str] = []
|
||||
for pattern, label in _BLACKLIST_PATTERNS:
|
||||
if pattern.search(question_text):
|
||||
violations.append(f"检测到自指代: '{label}'")
|
||||
return violations
|
||||
|
||||
|
||||
def _ngram_overlap_ratio(
|
||||
text_words: list[str],
|
||||
source_ngrams: set[tuple[str, ...]],
|
||||
window: int,
|
||||
) -> float:
|
||||
"""计算单段文本与来源 n-gram 集合的重叠率。
|
||||
|
||||
参数:
|
||||
text_words: 待比较文本的分词列表(已小写化)。
|
||||
source_ngrams: 来源素材的 n-gram 集合。
|
||||
window: n-gram 窗口大小。
|
||||
|
||||
返回:
|
||||
重叠率 [0.0, 1.0],词数不足窗口大小时返回 0.0。
|
||||
"""
|
||||
if len(text_words) < window:
|
||||
return 0.0
|
||||
|
||||
text_ngrams: set[tuple[str, ...]] = {
|
||||
tuple(text_words[i : i + window]) for i in range(len(text_words) - window + 1)
|
||||
}
|
||||
|
||||
if not text_ngrams:
|
||||
return 0.0
|
||||
|
||||
overlap = text_ngrams & source_ngrams
|
||||
return len(overlap) / len(text_ngrams)
|
||||
|
||||
|
||||
def check_verbatim(
|
||||
question_text: str,
|
||||
correct_option: str,
|
||||
source_texts: list[str],
|
||||
window: int = 6,
|
||||
) -> float:
|
||||
"""计算题目文本和正确选项与来源素材的最大逐字重复率。
|
||||
|
||||
使用滑动窗口 n-gram 集合交集方法:分别从题目文本和选项文本提取 n-gram,
|
||||
各自与来源文本的 n-gram 集合求交集,返回两者中较大的重叠比例。
|
||||
|
||||
参数:
|
||||
question_text: 题目文本。
|
||||
correct_option: 正确选项文本(含 "X. " 前缀)。
|
||||
source_texts: 来源素材文本列表。
|
||||
window: n-gram 窗口大小。
|
||||
|
||||
返回:
|
||||
最大重复率 [0.0, 1.0]。0.0 表示无重叠,1.0 表示完全复制。
|
||||
"""
|
||||
# Phase 1: 构造来源文本的 n-gram 集合
|
||||
source_ngrams: set[tuple[str, ...]] = set()
|
||||
for source in source_texts:
|
||||
words = source.lower().split()
|
||||
for i in range(len(words) - window + 1):
|
||||
source_ngrams.add(tuple(words[i : i + window]))
|
||||
|
||||
if not source_ngrams:
|
||||
return 0.0
|
||||
|
||||
# Phase 2: 计算题目文本的重叠率
|
||||
question_words = question_text.lower().split()
|
||||
question_ratio = _ngram_overlap_ratio(question_words, source_ngrams, window)
|
||||
|
||||
# Phase 3: 提取选项纯文本(去掉可能的 "X. " 前缀)
|
||||
option_text = correct_option
|
||||
if len(option_text) >= 3 and option_text[1] == "." and option_text[2] == " ":
|
||||
option_text = option_text[3:]
|
||||
|
||||
option_words = option_text.lower().split()
|
||||
option_ratio = _ngram_overlap_ratio(option_words, source_ngrams, window)
|
||||
|
||||
# Phase 4: 返回两者中较大的重叠率
|
||||
return max(question_ratio, option_ratio)
|
||||
|
||||
|
||||
def has_time_anchor(question_text: str) -> bool:
|
||||
"""检测题目中是否包含时间锚点。
|
||||
|
||||
时间锚点包括:数字时间戳(如 "01:30")和时间短语(如 "at the beginning"、"开头")。
|
||||
|
||||
参数:
|
||||
question_text: 题目文本。
|
||||
|
||||
返回:
|
||||
True 表示包含时间锚点。
|
||||
"""
|
||||
# Phase 1: 检查数字时间戳
|
||||
if _TIMESTAMP_PATTERN.search(question_text):
|
||||
return True
|
||||
|
||||
# Phase 2: 检查时间短语
|
||||
return any(pattern.search(question_text) for pattern in _TIME_ANCHOR_PHRASES)
|
||||
|
||||
|
||||
def _match_any(text: str, patterns: list[re.Pattern[str]]) -> bool:
|
||||
"""判断文本是否匹配任一正则模式。
|
||||
|
||||
参数:
|
||||
text: 待检测文本。
|
||||
patterns: 预编译正则列表。
|
||||
|
||||
返回:
|
||||
True 表示至少命中一条模式。
|
||||
"""
|
||||
return any(p.search(text) for p in patterns)
|
||||
|
||||
|
||||
# 素材禁区规则表:(模式列表, 违规描述)
|
||||
_FORBIDDEN_MATERIAL_RULES: list[tuple[list[re.Pattern[str]], str]] = [
|
||||
(_T1_INSTANT_ACTION_PATTERNS, "T1 违规: 素材包含瞬时动作描述,不适合出题"),
|
||||
(_T1_SCOREBOARD_PATTERNS, "T1 违规: 素材包含记分牌时序信息,不适合出题"),
|
||||
(_T1_NO_DIALOGUE_CAUSAL_PATTERNS, "T1 违规: 素材缺乏对白因果线索,不适合出题"),
|
||||
(_T7_OPTION_REPETITION_PATTERNS, "T7 违规: 素材暗示可能产生重复选项"),
|
||||
(_T7_COUNTING_AMBIGUITY_PATTERNS, "T7 违规: 素材包含计数边界含糊描述"),
|
||||
]
|
||||
|
||||
|
||||
def check_forbidden_material(source_nodes_text: str, task_type: str) -> list[str]:
|
||||
"""出题禁区:检测 T1 类素材和 T7 噪声模式。
|
||||
|
||||
T1 类素材(不适合出题的内容):
|
||||
- 瞬时动作:画面一闪而过,无法稳定观察
|
||||
- 记分牌时序:依赖数字变化的时序信息
|
||||
- 无对白因果:缺乏语言线索的因果推理
|
||||
|
||||
T7 噪声模式(选项质量问题):
|
||||
- 选项重复:多个选项表述相同
|
||||
- 计数边界口径含糊:数量描述不确定
|
||||
|
||||
参数:
|
||||
source_nodes_text: 来源节点的拼接文本。
|
||||
task_type: 题型名称(用于上下文感知检测)。
|
||||
|
||||
返回:
|
||||
违规描述列表,空列表表示通过。
|
||||
"""
|
||||
return [
|
||||
message
|
||||
for patterns, message in _FORBIDDEN_MATERIAL_RULES
|
||||
if _match_any(source_nodes_text, patterns)
|
||||
]
|
||||
|
||||
|
||||
def run_postprocess(
|
||||
question_text: str,
|
||||
options: tuple[str, ...],
|
||||
answer: str,
|
||||
source_texts: list[str],
|
||||
rng: random.Random,
|
||||
) -> PostprocessResult:
|
||||
"""编排全部后处理检查,返回汇总结果。
|
||||
|
||||
执行流程:
|
||||
1. 选项洗牌(确定性)
|
||||
2. 指代黑名单检测
|
||||
3. 逐字重复率计算(基于洗牌后的正确选项)
|
||||
4. 时间锚点检测
|
||||
|
||||
参数:
|
||||
question_text: 题目文本。
|
||||
options: 原始选项元组("A. text", "B. text", ...)。
|
||||
answer: 原始答案字母。
|
||||
source_texts: 来源素材文本列表。
|
||||
rng: 可控随机数生成器。
|
||||
|
||||
返回:
|
||||
PostprocessResult 汇总实例。
|
||||
"""
|
||||
# Phase 1: 选项洗牌
|
||||
shuffled_options, new_answer = shuffle_options(options, answer, rng)
|
||||
|
||||
# Phase 2: 指代黑名单
|
||||
referent_violations = check_referent_blacklist(question_text)
|
||||
|
||||
# Phase 3: 逐字重复率(用洗牌后正确选项的文本)
|
||||
correct_idx = ord(new_answer) - ord("A")
|
||||
correct_option_text = shuffled_options[correct_idx]
|
||||
verbatim_ratio = check_verbatim(question_text, correct_option_text, source_texts)
|
||||
|
||||
# Phase 4: 时间锚点
|
||||
time_anchor = has_time_anchor(question_text)
|
||||
|
||||
logger.debug(
|
||||
"后处理完成: referent_violations={}, verbatim_ratio={:.3f}, has_time_anchor={}",
|
||||
len(referent_violations),
|
||||
verbatim_ratio,
|
||||
time_anchor,
|
||||
)
|
||||
|
||||
return PostprocessResult(
|
||||
options=shuffled_options,
|
||||
answer=new_answer,
|
||||
referent_violations=referent_violations,
|
||||
verbatim_ratio=verbatim_ratio,
|
||||
has_time_anchor=time_anchor,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
"""v2 素材采样器 — 基于采样约束的树节点采样与上下文收集。
|
||||
|
||||
在 v1 synthesizer 的基础上引入 SamplingConstraint 约束验证,
|
||||
为每次出题提供更丰富的素材上下文(字幕、跨 L2 上下文、帧路径)。
|
||||
|
||||
典型调用路径::
|
||||
|
||||
material = sample_material_v2(
|
||||
tree=tree_index,
|
||||
task_type="Action Reasoning",
|
||||
used_node_ids=already_used,
|
||||
rng=rng,
|
||||
level=2,
|
||||
constraint=my_constraint,
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
|
||||
from app.question_gen.families import SamplingConstraint
|
||||
from app.tree.index import L1Node, L2Node, TreeIndex
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 题型 → 采样层级映射
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TASK_TYPE_TO_LEVEL: dict[str, int] = {
|
||||
# Level 3(细粒度帧级)
|
||||
"Action Recognition": 3,
|
||||
"Object Recognition": 3,
|
||||
"Attribute Perception": 3,
|
||||
"OCR Problems": 3,
|
||||
# Level 2(片段/事件级)
|
||||
"Action Reasoning": 2,
|
||||
"Object Reasoning": 2,
|
||||
"Information Synopsis": 2,
|
||||
"Counting Problem": 2,
|
||||
# Level 1(段落/场景级)
|
||||
"Temporal Reasoning": 1,
|
||||
"Temporal Perception": 1,
|
||||
"Spatial Reasoning": 1,
|
||||
"Spatial Perception": 1,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 数据类型
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnchorContext:
|
||||
"""采样锚点上下文。
|
||||
|
||||
属性:
|
||||
node_id: 锚节点 ID。
|
||||
level: 锚节点所在层级(1/2/3)。
|
||||
l2_id: 锚节点所属的 L2 节点 ID(若自身为 L2 则等于 node_id;
|
||||
若为 L1 则取其第一个 L2 子节点 ID)。
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
level: int
|
||||
l2_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MaterialContext:
|
||||
"""采样素材上下文 — 出题所需的全部素材打包。
|
||||
|
||||
属性:
|
||||
anchor: 采样锚点信息。
|
||||
source_nodes: 参与采样的节点 ID 元组。
|
||||
subtitle_sentences: 锚节点子树中收集的字幕句列表。
|
||||
frame_paths: 锚节点子树中可用的帧路径列表。
|
||||
cross_l2_texts: 跨 L2 段的上下文文本列表(仅 cross_l2_span 时填充)。
|
||||
"""
|
||||
|
||||
anchor: AnchorContext
|
||||
source_nodes: tuple[str, ...]
|
||||
subtitle_sentences: list[str]
|
||||
frame_paths: list[str]
|
||||
cross_l2_texts: list[str]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内部索引辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ResolvedSubtree:
|
||||
"""节点解析结果 — 将任意层级 node_id 统一解析为 L2 节点列表 + 父 L1。
|
||||
|
||||
属性:
|
||||
l2_nodes: 与目标节点关联的 L2 节点列表。
|
||||
parent_l1: 父 L1 节点(用于跨 L2 判断)。
|
||||
"""
|
||||
|
||||
l2_nodes: list[L2Node]
|
||||
parent_l1: L1Node | None
|
||||
|
||||
|
||||
def _find_l2_node(tree: TreeIndex, l2_id: str) -> tuple[L2Node, L1Node] | None:
|
||||
"""按 ID 定位 L2 节点及其父 L1。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
l2_id: L2 节点 ID。
|
||||
|
||||
返回:
|
||||
(L2Node, 父L1Node) 元组;未找到返回 None。
|
||||
"""
|
||||
for l1 in tree.roots:
|
||||
for l2 in l1.children:
|
||||
if l2.id == l2_id:
|
||||
return (l2, l1)
|
||||
return None
|
||||
|
||||
|
||||
def _find_l1_node(tree: TreeIndex, l1_id: str) -> L1Node | None:
|
||||
"""按 ID 定位 L1 节点。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
l1_id: L1 节点 ID。
|
||||
|
||||
返回:
|
||||
L1Node;未找到返回 None。
|
||||
"""
|
||||
for l1 in tree.roots:
|
||||
if l1.id == l1_id:
|
||||
return l1
|
||||
return None
|
||||
|
||||
|
||||
def _find_l3_parent(tree: TreeIndex, l3_id: str) -> tuple[L2Node, L1Node] | None:
|
||||
"""按 L3 ID 定位其所属的 L2 和 L1 节点。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
l3_id: L3 节点 ID。
|
||||
|
||||
返回:
|
||||
(L2Node, L1Node) 元组;未找到返回 None。
|
||||
"""
|
||||
for l1 in tree.roots:
|
||||
for l2 in l1.children:
|
||||
for l3 in l2.children:
|
||||
if l3.id == l3_id:
|
||||
return (l2, l1)
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_subtree(tree: TreeIndex, node_id: str) -> _ResolvedSubtree | None:
|
||||
"""将任意层级节点 ID 解析为关联的 L2 节点列表和父 L1。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
node_id: 任意层级的节点 ID。
|
||||
|
||||
返回:
|
||||
_ResolvedSubtree 实例;节点不存在时返回 None。
|
||||
"""
|
||||
# 尝试作为 L2
|
||||
result = _find_l2_node(tree, node_id)
|
||||
if result is not None:
|
||||
l2_node, parent_l1 = result
|
||||
return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1)
|
||||
|
||||
# 尝试作为 L1
|
||||
l1_node = _find_l1_node(tree, node_id)
|
||||
if l1_node is not None:
|
||||
return _ResolvedSubtree(l2_nodes=list(l1_node.children), parent_l1=l1_node)
|
||||
|
||||
# 尝试作为 L3
|
||||
l3_result = _find_l3_parent(tree, node_id)
|
||||
if l3_result is not None:
|
||||
l2_node, parent_l1 = l3_result
|
||||
return _ResolvedSubtree(l2_nodes=[l2_node], parent_l1=parent_l1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 约束检查辅助(单一职责)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_l3_descendants(l2_nodes: list[L2Node]) -> int:
|
||||
"""统计 L2 节点列表下的 L3 总数。
|
||||
|
||||
参数:
|
||||
l2_nodes: L2 节点列表。
|
||||
|
||||
返回:
|
||||
L3 节点总数。
|
||||
"""
|
||||
return sum(len(l2.children) for l2 in l2_nodes)
|
||||
|
||||
|
||||
def _has_frames(l2_nodes: list[L2Node]) -> bool:
|
||||
"""检查 L2 节点列表的子树中是否有可用帧。
|
||||
|
||||
参数:
|
||||
l2_nodes: L2 节点列表。
|
||||
|
||||
返回:
|
||||
True 表示至少有一个 L3 有 frame_path。
|
||||
"""
|
||||
return any(l3.frame_path for l2 in l2_nodes for l3 in l2.children)
|
||||
|
||||
|
||||
def _count_subtitles(l2_nodes: list[L2Node]) -> int:
|
||||
"""统计 L2 节点列表中全部字幕数(L2 + L3)。
|
||||
|
||||
参数:
|
||||
l2_nodes: L2 节点列表。
|
||||
|
||||
返回:
|
||||
非空字幕总数。
|
||||
"""
|
||||
count = 0
|
||||
for l2 in l2_nodes:
|
||||
if l2.card.subtitle:
|
||||
count += 1
|
||||
count += sum(1 for l3 in l2.children if l3.card.subtitle)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 公开辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_sampling_constraints(
|
||||
tree: TreeIndex, node_id: str, constraint: SamplingConstraint
|
||||
) -> bool:
|
||||
"""校验指定节点是否满足采样约束。
|
||||
|
||||
根据节点层级自动判断检查范围:
|
||||
- L2 节点:检查其子 L3 的帧/字幕数量。
|
||||
- L1 节点:检查其下全部 L2/L3 的帧/字幕总数。
|
||||
- L3 节点:检查其所属 L2 的子树。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
node_id: 待检查节点 ID。
|
||||
constraint: 采样约束条件。
|
||||
|
||||
返回:
|
||||
True 表示满足所有约束,False 表示至少一项不满足。
|
||||
"""
|
||||
resolved = _resolve_subtree(tree, node_id)
|
||||
if resolved is None:
|
||||
return False
|
||||
|
||||
if _count_l3_descendants(resolved.l2_nodes) < constraint.min_l3_nodes:
|
||||
return False
|
||||
if constraint.require_frames and not _has_frames(resolved.l2_nodes):
|
||||
return False
|
||||
if _count_subtitles(resolved.l2_nodes) < constraint.min_subtitles:
|
||||
return False
|
||||
return not (
|
||||
constraint.cross_l2_span
|
||||
and (resolved.parent_l1 is None or len(resolved.parent_l1.children) < 2)
|
||||
)
|
||||
|
||||
|
||||
def _subtitles_from_l2_list(l2_nodes: list[L2Node]) -> list[str]:
|
||||
"""从 L2 节点列表收集全部非空字幕。
|
||||
|
||||
参数:
|
||||
l2_nodes: L2 节点列表。
|
||||
|
||||
返回:
|
||||
非空字幕字符串列表。
|
||||
"""
|
||||
sentences: list[str] = []
|
||||
for l2 in l2_nodes:
|
||||
if l2.card.subtitle:
|
||||
sentences.append(l2.card.subtitle)
|
||||
for l3 in l2.children:
|
||||
if l3.card.subtitle:
|
||||
sentences.append(l3.card.subtitle)
|
||||
return sentences
|
||||
|
||||
|
||||
def _collect_subtitle_sentences(tree: TreeIndex, node_ids: tuple[str, ...]) -> list[str]:
|
||||
"""从指定节点集合中收集字幕句。
|
||||
|
||||
遍历每个 node_id 对应的子树,提取非空字幕。
|
||||
对 L2 节点提取自身 + 子 L3 字幕;对 L1 提取下属全部。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
node_ids: 待收集字幕的节点 ID 元组。
|
||||
|
||||
返回:
|
||||
非空字幕句列表。
|
||||
"""
|
||||
sentences: list[str] = []
|
||||
for nid in node_ids:
|
||||
resolved = _resolve_subtree(tree, nid)
|
||||
if resolved is not None:
|
||||
sentences.extend(_subtitles_from_l2_list(resolved.l2_nodes))
|
||||
return sentences
|
||||
|
||||
|
||||
def _collect_cross_l2_context(tree: TreeIndex, anchor_l2_id: str, max_peers: int = 3) -> list[str]:
|
||||
"""收集锚 L2 的同级 L2 节点描述文本(跨 L2 上下文)。
|
||||
|
||||
找到锚 L2 所属的 L1 父节点,取该父节点下除锚 L2 之外的其他 L2 描述。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
anchor_l2_id: 锚 L2 节点 ID。
|
||||
max_peers: 最多返回的同级 L2 描述数量。
|
||||
|
||||
返回:
|
||||
同级 L2 的 event_description 列表(最多 max_peers 条)。
|
||||
"""
|
||||
result = _find_l2_node(tree, anchor_l2_id)
|
||||
if result is None:
|
||||
return []
|
||||
|
||||
_, parent_l1 = result
|
||||
peers: list[str] = []
|
||||
for l2 in parent_l1.children:
|
||||
if l2.id != anchor_l2_id:
|
||||
peers.append(l2.card.event_description)
|
||||
if len(peers) >= max_peers:
|
||||
break
|
||||
|
||||
return peers
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 层级采样策略
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sample_l3_node(
|
||||
tree: TreeIndex,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> tuple[str, str] | None:
|
||||
"""随机采样一个未使用的 L3 节点,返回 (l3_id, 所属l2_id)。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
(l3_id, l2_id) 元组;无候选返回 None。
|
||||
"""
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for l1 in tree.roots:
|
||||
for l2 in l1.children:
|
||||
for l3 in l2.children:
|
||||
if l3.id not in used_node_ids:
|
||||
candidates.append((l3.id, l2.id))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
return rng.choice(candidates)
|
||||
|
||||
|
||||
def _sample_l2_node(
|
||||
tree: TreeIndex,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> tuple[str, str] | None:
|
||||
"""随机采样一个未使用的 L2 节点,返回 (l2_id, l2_id)。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
(l2_id, l2_id) 元组;无候选返回 None。
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
for l1 in tree.roots:
|
||||
for l2 in l1.children:
|
||||
if l2.id not in used_node_ids:
|
||||
candidates.append(l2.id)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
chosen = rng.choice(candidates)
|
||||
return (chosen, chosen)
|
||||
|
||||
|
||||
def _sample_l1_node(
|
||||
tree: TreeIndex,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> tuple[str, str] | None:
|
||||
"""随机采样一个未使用的 L1 节点,返回 (l1_id, 首个子l2_id)。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
(l1_id, first_l2_id) 元组;无候选返回 None。
|
||||
"""
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for l1 in tree.roots:
|
||||
if l1.id not in used_node_ids and l1.children:
|
||||
candidates.append((l1.id, l1.children[0].id))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
return rng.choice(candidates)
|
||||
|
||||
|
||||
def _frames_from_l2_list(l2_nodes: list[L2Node]) -> list[str]:
|
||||
"""从 L2 节点列表收集全部可用帧路径。
|
||||
|
||||
参数:
|
||||
l2_nodes: L2 节点列表。
|
||||
|
||||
返回:
|
||||
帧路径字符串列表。
|
||||
"""
|
||||
return [l3.frame_path for l2 in l2_nodes for l3 in l2.children if l3.frame_path]
|
||||
|
||||
|
||||
def _collect_frame_paths(tree: TreeIndex, node_id: str) -> list[str]:
|
||||
"""收集节点子树下的所有可用帧路径。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
node_id: 目标节点 ID。
|
||||
|
||||
返回:
|
||||
帧路径列表。
|
||||
"""
|
||||
resolved = _resolve_subtree(tree, node_id)
|
||||
if resolved is None:
|
||||
return []
|
||||
return _frames_from_l2_list(resolved.l2_nodes)
|
||||
|
||||
|
||||
def _collect_source_nodes(tree: TreeIndex, node_id: str) -> tuple[str, ...]:
|
||||
"""收集节点子树涉及的全部节点 ID(包含自身)。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
node_id: 目标节点 ID。
|
||||
|
||||
返回:
|
||||
相关节点 ID 元组。
|
||||
"""
|
||||
ids: list[str] = [node_id]
|
||||
|
||||
# L2 节点:加入子 L3
|
||||
result = _find_l2_node(tree, node_id)
|
||||
if result is not None:
|
||||
l2_node, _ = result
|
||||
for l3 in l2_node.children:
|
||||
ids.append(l3.id)
|
||||
return tuple(ids)
|
||||
|
||||
# L1 节点:加入子 L2 + L3
|
||||
l1_node = _find_l1_node(tree, node_id)
|
||||
if l1_node is not None:
|
||||
for l2 in l1_node.children:
|
||||
ids.append(l2.id)
|
||||
for l3 in l2.children:
|
||||
ids.append(l3.id)
|
||||
return tuple(ids)
|
||||
|
||||
# L3 节点:仅自身
|
||||
return tuple(ids)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sample_material_v2(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
*,
|
||||
level: int,
|
||||
constraint: SamplingConstraint,
|
||||
max_attempts: int = 10,
|
||||
) -> MaterialContext:
|
||||
"""基于采样约束从视频树中采样素材上下文。
|
||||
|
||||
采样流程:
|
||||
1. 按指定 level 确定采样层级
|
||||
2. 随机选取候选节点(排除 used_node_ids)
|
||||
3. 验证 SamplingConstraint 约束
|
||||
4. 约束不满足则重试(最多 max_attempts 次)
|
||||
5. 收集字幕、帧路径、跨 L2 上下文
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 任务类型字符串。
|
||||
used_node_ids: 本轮已用节点 ID 集合。
|
||||
rng: 可控随机数生成器。
|
||||
level: 采样层级(1/2/3)。
|
||||
constraint: 采样约束条件。
|
||||
max_attempts: 最大尝试次数。
|
||||
|
||||
返回:
|
||||
MaterialContext 实例。
|
||||
|
||||
异常:
|
||||
ValueError: level 不在 {1, 2, 3} 中。
|
||||
RuntimeError: 耗尽 max_attempts 次尝试仍无法满足约束。
|
||||
"""
|
||||
if level not in (1, 2, 3):
|
||||
msg = f"level 必须为 1、2 或 3,收到: {level}"
|
||||
raise ValueError(msg)
|
||||
|
||||
for attempt in range(max_attempts):
|
||||
# Phase 1: 按层级采样候选节点
|
||||
if level == 3:
|
||||
sampled = _sample_l3_node(tree, used_node_ids, rng)
|
||||
elif level == 2:
|
||||
sampled = _sample_l2_node(tree, used_node_ids, rng)
|
||||
else:
|
||||
sampled = _sample_l1_node(tree, used_node_ids, rng)
|
||||
|
||||
if sampled is None:
|
||||
logger.debug(
|
||||
"sample_material_v2 尝试 {}/{}: 无可用候选节点 (level={})",
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
level,
|
||||
)
|
||||
continue
|
||||
|
||||
node_id, l2_id = sampled
|
||||
|
||||
# Phase 2: 验证约束
|
||||
if not _validate_sampling_constraints(tree, node_id, constraint):
|
||||
logger.debug(
|
||||
"sample_material_v2 尝试 {}/{}: 约束违反 (node={})",
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
node_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Phase 3: 构造 AnchorContext
|
||||
anchor = AnchorContext(node_id=node_id, level=level, l2_id=l2_id)
|
||||
|
||||
# Phase 4: 收集素材
|
||||
source_nodes = _collect_source_nodes(tree, node_id)
|
||||
subtitle_sentences = _collect_subtitle_sentences(tree, (node_id,))
|
||||
frame_paths = _collect_frame_paths(tree, node_id)
|
||||
|
||||
# Phase 5: 跨 L2 上下文(仅 cross_l2_span 时收集)
|
||||
cross_l2_texts: list[str] = []
|
||||
if constraint.cross_l2_span:
|
||||
cross_l2_texts = _collect_cross_l2_context(tree, l2_id)
|
||||
|
||||
logger.debug(
|
||||
"sample_material_v2 成功: node={}, level={}, attempt={}/{}",
|
||||
node_id,
|
||||
level,
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
)
|
||||
|
||||
return MaterialContext(
|
||||
anchor=anchor,
|
||||
source_nodes=source_nodes,
|
||||
subtitle_sentences=subtitle_sentences,
|
||||
frame_paths=frame_paths,
|
||||
cross_l2_texts=cross_l2_texts,
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"sample_material_v2: 耗尽 max_attempts={max_attempts} 次尝试,"
|
||||
f"无法为 task_type='{task_type}' (level={level}) 满足采样约束"
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""题型出题策略 — Clean Architecture 的 Strategy 层。
|
||||
|
||||
将出题管线从 5 个粗粒度 QuestionFamilySpec 替换为 12 个题型级别的
|
||||
TaskTypeStrategy。BaseTaskTypeStrategy 封装现有 family 行为,
|
||||
特化策略(如 ActionRecognitionStrategy)自包含。
|
||||
|
||||
典型用法::
|
||||
|
||||
strategy = get_strategy("Action Reasoning")
|
||||
level = strategy.sampling_level
|
||||
constraint = strategy.sampling_constraint
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from app.question_gen.families import (
|
||||
ENUMERATION_FAMILY,
|
||||
REASONING_FAMILY,
|
||||
RETRIEVAL_FAMILY,
|
||||
SPATIAL_FAMILY,
|
||||
VISUAL_FAMILY,
|
||||
QuestionFamilySpec,
|
||||
SamplingConstraint,
|
||||
)
|
||||
from app.question_gen.sampler_v2 import _TASK_TYPE_TO_LEVEL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubPattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubPattern:
|
||||
"""出题子模式 — 靶向特定失败机制。
|
||||
|
||||
属性:
|
||||
name: 子模式标识名。
|
||||
weight: 选择权重(按比例分配)。
|
||||
sampling_level_override: 覆盖策略默认采样层级(None = 不覆盖)。
|
||||
constraint_override: 覆盖策略默认采样约束(None = 不覆盖)。
|
||||
instruction: 核心出题指令(1-3 句话)。
|
||||
positive_examples: VME 原题 few-shot 示范。
|
||||
negative_examples: 反面示例。
|
||||
distractor_rules: 干扰项构造规则。
|
||||
supports_flip: 是否支持配对翻转门(Phase B 用,默认 False)。
|
||||
flip_axis: 翻转轴("before/after" | "first/last"),None 表示不翻转。
|
||||
"""
|
||||
|
||||
name: str
|
||||
weight: float
|
||||
sampling_level_override: int | None
|
||||
constraint_override: SamplingConstraint | None
|
||||
instruction: str
|
||||
positive_examples: list[dict] = field(default_factory=list)
|
||||
negative_examples: list[dict] = field(default_factory=list)
|
||||
distractor_rules: str = ""
|
||||
supports_flip: bool = False
|
||||
flip_axis: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskTypeStrategy(Protocol):
|
||||
"""题型出题策略 — pipeline 的唯一接口。"""
|
||||
|
||||
@property
|
||||
def task_type(self) -> str: ...
|
||||
|
||||
@property
|
||||
def sampling_level(self) -> int: ...
|
||||
|
||||
@property
|
||||
def sampling_constraint(self) -> SamplingConstraint: ...
|
||||
|
||||
@property
|
||||
def prompt_template(self) -> str: ...
|
||||
|
||||
@property
|
||||
def strategy_name(self) -> str: ...
|
||||
|
||||
@property
|
||||
def skill_target(self) -> str: ...
|
||||
|
||||
@property
|
||||
def leak_probe_template(self) -> str: ...
|
||||
|
||||
@property
|
||||
def uses_grounded_selector(self) -> bool: ...
|
||||
|
||||
def select_sub_pattern(self, rng: random.Random) -> SubPattern | None: ...
|
||||
|
||||
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict: ...
|
||||
|
||||
def extra_gates(self, candidate: Any) -> list: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BaseTaskTypeStrategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 消歧绑定表:多归属题型确定性绑定到一个 family
|
||||
_TASK_TYPE_TO_FAMILY: dict[str, QuestionFamilySpec] = {
|
||||
"Action Recognition": VISUAL_FAMILY, # fallback — 注册表中已被 ActionRecognitionStrategy 替换
|
||||
"Object Recognition": RETRIEVAL_FAMILY,
|
||||
"Object Reasoning": REASONING_FAMILY,
|
||||
"Action Reasoning": REASONING_FAMILY,
|
||||
"Attribute Perception": VISUAL_FAMILY,
|
||||
"OCR Problems": VISUAL_FAMILY,
|
||||
"Counting Problem": ENUMERATION_FAMILY,
|
||||
"Information Synopsis": REASONING_FAMILY,
|
||||
"Temporal Reasoning": ENUMERATION_FAMILY,
|
||||
"Temporal Perception": ENUMERATION_FAMILY,
|
||||
"Spatial Reasoning": SPATIAL_FAMILY,
|
||||
"Spatial Perception": SPATIAL_FAMILY,
|
||||
}
|
||||
|
||||
|
||||
class BaseTaskTypeStrategy:
|
||||
"""封装现有 QuestionFamilySpec 行为的默认策略。
|
||||
|
||||
所有属性委托给绑定的 family,确保未特化题型的行为不变。
|
||||
|
||||
参数:
|
||||
task_type: 题型名。
|
||||
family: 绑定的 QuestionFamilySpec。
|
||||
"""
|
||||
|
||||
def __init__(self, task_type: str, family: QuestionFamilySpec) -> None:
|
||||
self._task_type = task_type
|
||||
self._family = family
|
||||
|
||||
@property
|
||||
def task_type(self) -> str:
|
||||
"""返回题型名。"""
|
||||
return self._task_type
|
||||
|
||||
@property
|
||||
def sampling_level(self) -> int:
|
||||
"""返回采样层级(从 _TASK_TYPE_TO_LEVEL 查询)。"""
|
||||
return _TASK_TYPE_TO_LEVEL[self._task_type]
|
||||
|
||||
@property
|
||||
def sampling_constraint(self) -> SamplingConstraint:
|
||||
"""返回采样约束(委托给绑定的 family)。"""
|
||||
return self._family.sampling
|
||||
|
||||
@property
|
||||
def prompt_template(self) -> str:
|
||||
"""返回 prompt 模板文件名(委托给绑定的 family)。"""
|
||||
return self._family.prompt_template
|
||||
|
||||
@property
|
||||
def strategy_name(self) -> str:
|
||||
"""返回策略名(即 family.name)。"""
|
||||
return self._family.name
|
||||
|
||||
@property
|
||||
def skill_target(self) -> str:
|
||||
"""返回目标失败机制编号(委托给绑定的 family)。"""
|
||||
return self._family.skill_target
|
||||
|
||||
@property
|
||||
def leak_probe_template(self) -> str:
|
||||
"""返回泄漏探测模板文件名(委托给绑定的 family.leak_profile)。"""
|
||||
return self._family.leak_profile.probe_template
|
||||
|
||||
@property
|
||||
def uses_grounded_selector(self) -> bool:
|
||||
"""默认不启用 grounded selector(11 类题型走原路径)。"""
|
||||
return False
|
||||
|
||||
def select_sub_pattern(self, rng: random.Random) -> SubPattern | None:
|
||||
"""BaseTaskTypeStrategy 无子模式。"""
|
||||
return None
|
||||
|
||||
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict:
|
||||
"""返回基础 prompt 上下文(family_name + prompt_template)。"""
|
||||
return {
|
||||
"family_name": self._family.name,
|
||||
"prompt_template": self._family.prompt_template,
|
||||
}
|
||||
|
||||
def extra_gates(self, candidate: Any) -> list:
|
||||
"""BaseTaskTypeStrategy 无额外 gate。"""
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 注册表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STRATEGY_REGISTRY: dict[str, TaskTypeStrategy] = {}
|
||||
|
||||
|
||||
def register_strategy(strategy: TaskTypeStrategy) -> None:
|
||||
"""注册一个题型策略。同一 task_type 重复注册会覆盖。
|
||||
|
||||
参数:
|
||||
strategy: 实现 TaskTypeStrategy 接口的策略实例。
|
||||
"""
|
||||
_STRATEGY_REGISTRY[strategy.task_type] = strategy
|
||||
|
||||
|
||||
_BUILTIN_REGISTERED = False
|
||||
|
||||
|
||||
def get_strategy(task_type: str) -> TaskTypeStrategy:
|
||||
"""获取题型策略。未注册的自动创建 BaseTaskTypeStrategy。
|
||||
|
||||
首次调用时延迟注册内建特化策略(避免循环导入)。
|
||||
|
||||
参数:
|
||||
task_type: 题型名。
|
||||
|
||||
返回:
|
||||
TaskTypeStrategy 实例。
|
||||
|
||||
异常:
|
||||
KeyError: task_type 不在消歧绑定表和注册表中。
|
||||
"""
|
||||
global _BUILTIN_REGISTERED # noqa: PLW0603
|
||||
if not _BUILTIN_REGISTERED:
|
||||
_BUILTIN_REGISTERED = True
|
||||
_register_builtin_strategies()
|
||||
|
||||
if task_type in _STRATEGY_REGISTRY:
|
||||
return _STRATEGY_REGISTRY[task_type]
|
||||
return _build_default_strategy(task_type)
|
||||
|
||||
|
||||
def _build_default_strategy(task_type: str) -> BaseTaskTypeStrategy:
|
||||
"""根据消歧绑定表自动构造 BaseTaskTypeStrategy。
|
||||
|
||||
参数:
|
||||
task_type: 题型名。
|
||||
|
||||
返回:
|
||||
BaseTaskTypeStrategy 实例。
|
||||
|
||||
异常:
|
||||
KeyError: task_type 不在消歧绑定表中。
|
||||
"""
|
||||
family = _TASK_TYPE_TO_FAMILY[task_type]
|
||||
return BaseTaskTypeStrategy(task_type=task_type, family=family)
|
||||
|
||||
|
||||
def _register_builtin_strategies() -> None:
|
||||
"""注册内建的特化策略。由 get_strategy 首次调用时延迟执行。"""
|
||||
from app.question_gen.strategy_action_recognition import ActionRecognitionStrategy
|
||||
|
||||
register_strategy(ActionRecognitionStrategy())
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Action Recognition 特化出题策略。
|
||||
|
||||
靶向 Agent 在动作识别类题目上的 6 种典型失败机制,通过加权随机
|
||||
SubPattern 选择为 VLM 出题提供聚焦指令。
|
||||
|
||||
与 BaseTaskTypeStrategy 完全自包含,不依赖 QuestionFamilySpec。
|
||||
采样从 L3 提升至 L2(跨段推理需要更广视野),3 个 SubPattern
|
||||
进一步覆盖至 L1 以测试全局追踪能力。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.question_gen.families import SamplingConstraint
|
||||
from app.question_gen.strategy import SubPattern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6 个 SubPattern 定义
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PREMATURE_EVIDENCE_ANCHORING = SubPattern(
|
||||
name="premature_evidence_anchoring",
|
||||
weight=0.20,
|
||||
sampling_level_override=1,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道需要考察视频中完整证据链的动作识别题。"
|
||||
"正确答案的关键证据出现在视频后半段或跨多个片段,"
|
||||
"但视频前段包含一个看似合理的局部匹配——"
|
||||
"Agent 若仅凭首条匹配停止搜索就会出错。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "视频中厨师最终采用了哪种烹饪方式?",
|
||||
"answer": "B. 蒸",
|
||||
"why": "厨师先演示了炒(前段),但最终菜品使用蒸制(后段),"
|
||||
"锚定首段证据的 Agent 会错选'炒'。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "视频开头厨师在做什么?",
|
||||
"why": "答案仅需前段信息,不会触发过早锚定失败。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"将视频前段真实出现的局部匹配动作设为强干扰项——它真实发生,"
|
||||
"仅在【时点】这一单一维度上与正解不同(前段 vs 最终结论段)。"
|
||||
"其余干扰项亦须是视频中真实发生的动作,严禁缺席事件。"
|
||||
),
|
||||
)
|
||||
|
||||
_TEMPORAL_REASONING_FAILURE = SubPattern(
|
||||
name="temporal_reasoning_failure",
|
||||
weight=0.20,
|
||||
sampling_level_override=1,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道要求正确排序或定位第 N 次出现的动作识别题。"
|
||||
"题目需要 Agent 追踪事件的时间先后顺序,"
|
||||
"或准确识别某动作在视频中第几次出现。"
|
||||
"打乱时序或错误计数即会答错。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "运动员第三次尝试起跳前做了什么准备动作?",
|
||||
"answer": "C. 深蹲热身",
|
||||
"why": "需要准确定位'第三次'起跳而非其他次,"
|
||||
"时序推理失败的 Agent 会混淆不同次尝试的准备动作。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "运动员在视频中做了什么?",
|
||||
"why": "不涉及时序排序或计数,Agent 无需追踪顺序。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"干扰项必须是视频中真实发生的事件,仅在【事件时序/顺序】这一单一维度上与正解不同——"
|
||||
"即同一组真实事件的错误排列或错误的第 N 次定位。"
|
||||
"严禁使用视频中未出现的缺席事件作为干扰项。"
|
||||
),
|
||||
supports_flip=True,
|
||||
flip_axis="before/after",
|
||||
)
|
||||
|
||||
_SEMANTIC_RIGIDITY = SubPattern(
|
||||
name="semantic_rigidity",
|
||||
weight=0.15,
|
||||
sampling_level_override=None,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道动作识别题,正确选项使用与视频原始描述不同的同义表达。"
|
||||
"Agent 需理解语义等价而非依赖字面匹配——"
|
||||
"例如视频字幕说'奔跑',正确选项写作'快速移动'。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "工人对墙面进行了什么操作?",
|
||||
"answer": "A. 涂覆保护层",
|
||||
"why": "视频中字幕描述为'刷漆',正确答案改写为'涂覆保护层',"
|
||||
"依赖字面匹配的 Agent 会因找不到完全一致的表述而错选。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "工人在刷漆吗?",
|
||||
"why": "选项直接复用视频原文,不考察语义理解。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"干扰项须基于视频真实内容,仅在【表述/语义】这一单一维度上做文章:"
|
||||
"保留一个复用视频原始字幕字面、但在题干限定下语义为假的选项作为陷阱,"
|
||||
"其余选项描述真实动作的不同同义表述。严禁凭空编造缺席动作。"
|
||||
),
|
||||
)
|
||||
|
||||
_FINE_GRAINED_VISUAL_ACTION = SubPattern(
|
||||
name="fine_grained_visual_action",
|
||||
weight=0.15,
|
||||
sampling_level_override=None,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道需要区分细粒度动作方式的识别题。"
|
||||
"题目聚焦于 HOW(怎么做)而非 WHAT(做什么),"
|
||||
"如区分'搅拌'与'翻炒'、'拧'与'拉'等视觉上相似但方式不同的动作。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "维修人员是如何拆卸螺丝的?",
|
||||
"answer": "D. 用扳手逆时针旋转",
|
||||
"why": "需要区分拆卸的具体方式(扳手 vs 螺丝刀、顺时针 vs 逆时针),"
|
||||
"粗粒度识别只能判断'在拆螺丝',无法区分方式。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "维修人员在做什么?",
|
||||
"why": "只需粗粒度动作识别('拆螺丝'),不考察具体方式。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"四个选项须是【同一大类动作】的不同执行方式,全部为视频中真实可见的做法,"
|
||||
"仅在【执行方式】这一单一维度上不同(如顺/逆时针、扳手/螺丝刀)。"
|
||||
"严禁使用明显不相关或视频中未出现的动作作为干扰项。"
|
||||
),
|
||||
)
|
||||
|
||||
_CROSS_SEGMENT_ENTITY_TRACKING = SubPattern(
|
||||
name="cross_segment_entity_tracking",
|
||||
weight=0.15,
|
||||
sampling_level_override=1,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道需要跨视频段落追踪同一实体动作的识别题。"
|
||||
"目标实体在不同片段中外观、称呼或上下文发生变化,"
|
||||
"Agent 需要将多段信息合并才能正确回答关于该实体的动作问题。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "穿红色外套的人在视频中总共完成了哪些动作?",
|
||||
"answer": "B. 先讲解、后示范、最后总结",
|
||||
"why": "该人物在前段穿外套讲解,中段脱外套示范,后段重新穿上总结,"
|
||||
"无法跨段追踪的 Agent 会遗漏某段动作。",
|
||||
},
|
||||
{
|
||||
"question": "主持人在节目不同环节中分别做了什么?",
|
||||
"answer": "A. 开场介绍、采访嘉宾、总结点评",
|
||||
"why": "主持人在不同场景切换中持续出现,需要跨段聚合。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "视频第一个片段中的人在做什么?",
|
||||
"why": "仅需单段信息,不考察跨段追踪。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"干扰项须是视频中【另一真实实体】在相应片段真实做过的动作,"
|
||||
"仅在【动作主体】这一单一维度上与正解不同。"
|
||||
"严禁编造任何实体未做过的缺席动作。"
|
||||
),
|
||||
supports_flip=True,
|
||||
flip_axis="first/last",
|
||||
)
|
||||
|
||||
_EVIDENCE_GAP_CONFABULATION = SubPattern(
|
||||
name="evidence_gap_confabulation",
|
||||
weight=0.15,
|
||||
sampling_level_override=None,
|
||||
constraint_override=None,
|
||||
instruction=(
|
||||
"设计一道动作识别题,视频中存在证据空缺(如遮挡、跳切、画外音)。"
|
||||
"正确答案承认信息不足或基于间接证据推断,"
|
||||
"而非凭空编造因果链。Agent 若虚构缺失证据即会出错。"
|
||||
),
|
||||
positive_examples=[
|
||||
{
|
||||
"question": "画面切走后,演讲者下一步做了什么?",
|
||||
"answer": "C. 无法从视频中直接确定",
|
||||
"why": "画面跳切导致该动作无直接视觉证据,"
|
||||
"倾向于虚构的 Agent 会编造一个看似合理的动作。",
|
||||
},
|
||||
],
|
||||
negative_examples=[
|
||||
{
|
||||
"question": "画面中演讲者正在做什么?",
|
||||
"why": "动作在画面中可见,不存在证据空缺。",
|
||||
},
|
||||
],
|
||||
distractor_rules=(
|
||||
"正解仅陈述视频中可观测的事实或诚实承认证据不足;"
|
||||
"干扰项在【因果完整性】这一单一维度上越界——补上一段视频未展示的因果链,"
|
||||
"但其前提元素仍取自视频真实内容(诱导 Agent 顺势编造),而非完全凭空的缺席事件。"
|
||||
),
|
||||
)
|
||||
|
||||
AR_SUB_PATTERNS: tuple[SubPattern, ...] = (
|
||||
_PREMATURE_EVIDENCE_ANCHORING,
|
||||
_TEMPORAL_REASONING_FAILURE,
|
||||
_SEMANTIC_RIGIDITY,
|
||||
_FINE_GRAINED_VISUAL_ACTION,
|
||||
_CROSS_SEGMENT_ENTITY_TRACKING,
|
||||
_EVIDENCE_GAP_CONFABULATION,
|
||||
)
|
||||
|
||||
# 预计算:名称列表 + 权重列表(避免每次 select 重新构建)
|
||||
_AR_PATTERN_NAMES: list[str] = [sp.name for sp in AR_SUB_PATTERNS]
|
||||
_AR_PATTERN_WEIGHTS: list[float] = [sp.weight for sp in AR_SUB_PATTERNS]
|
||||
_AR_PATTERN_BY_NAME: dict[str, SubPattern] = {sp.name: sp for sp in AR_SUB_PATTERNS}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ActionRecognitionStrategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLING_CONSTRAINT = SamplingConstraint(
|
||||
min_subtitles=3,
|
||||
min_l3_nodes=5,
|
||||
require_frames=True,
|
||||
cross_l2_span=True,
|
||||
)
|
||||
|
||||
|
||||
class ActionRecognitionStrategy:
|
||||
"""Action Recognition 特化出题策略。
|
||||
|
||||
自包含实现,不依赖 BaseTaskTypeStrategy 或 QuestionFamilySpec。
|
||||
靶向 6 种典型失败机制,通过加权随机 SubPattern 引导 VLM 出题。
|
||||
|
||||
属性:
|
||||
task_type: 题型名 — "Action Recognition"。
|
||||
strategy_name: 策略标识 — "ACTION_RECOGNITION"。
|
||||
skill_target: 目标失败机制 — "M1_AR"。
|
||||
sampling_level: 采样层级 — 2(L2,从 L3 升级以获得跨段视野)。
|
||||
sampling_constraint: 采样约束(min_subtitles=3, min_l3_nodes=5, require_frames, cross_l2_span)。
|
||||
prompt_template: 出题模板 — "action_recognition.md"。
|
||||
leak_probe_template: 泄漏探测模板 — "gate_leak_retrieval.md"。
|
||||
"""
|
||||
|
||||
@property
|
||||
def task_type(self) -> str:
|
||||
"""返回题型名。"""
|
||||
return "Action Recognition"
|
||||
|
||||
@property
|
||||
def strategy_name(self) -> str:
|
||||
"""返回策略标识名。"""
|
||||
return "ACTION_RECOGNITION"
|
||||
|
||||
@property
|
||||
def skill_target(self) -> str:
|
||||
"""返回目标失败机制编号。"""
|
||||
return "M1_AR"
|
||||
|
||||
@property
|
||||
def sampling_level(self) -> int:
|
||||
"""返回采样层级(L2)。"""
|
||||
return 2
|
||||
|
||||
@property
|
||||
def sampling_constraint(self) -> SamplingConstraint:
|
||||
"""返回采样约束。"""
|
||||
return _SAMPLING_CONSTRAINT
|
||||
|
||||
@property
|
||||
def prompt_template(self) -> str:
|
||||
"""返回出题 prompt 模板文件名。"""
|
||||
return "action_recognition.md"
|
||||
|
||||
@property
|
||||
def leak_probe_template(self) -> str:
|
||||
"""返回泄漏探测模板文件名。"""
|
||||
return "gate_leak_retrieval.md"
|
||||
|
||||
@property
|
||||
def uses_grounded_selector(self) -> bool:
|
||||
"""AR 启用候选池 + VLM 视觉打分 selector。"""
|
||||
return True
|
||||
|
||||
def select_sub_pattern(self, rng: random.Random) -> SubPattern:
|
||||
"""按权重随机选择一个 SubPattern。
|
||||
|
||||
参数:
|
||||
rng: 随机数生成器(确保可复现)。
|
||||
|
||||
返回:
|
||||
选中的 SubPattern 实例(永不为 None)。
|
||||
"""
|
||||
chosen_name = rng.choices(
|
||||
_AR_PATTERN_NAMES,
|
||||
weights=_AR_PATTERN_WEIGHTS,
|
||||
k=1,
|
||||
)[0]
|
||||
return _AR_PATTERN_BY_NAME[chosen_name]
|
||||
|
||||
def build_prompt_context(self, material: Any, sub_pattern: SubPattern | None) -> dict:
|
||||
"""构建 prompt 上下文字典。
|
||||
|
||||
参数:
|
||||
material: 采样素材(当前未使用,留给管线扩展)。
|
||||
sub_pattern: 选中的子模式。
|
||||
|
||||
返回:
|
||||
包含 family_name, prompt_template, sub_pattern 的字典。
|
||||
"""
|
||||
return {
|
||||
"family_name": self.strategy_name,
|
||||
"prompt_template": self.prompt_template,
|
||||
"sub_pattern": sub_pattern.name if sub_pattern is not None else None,
|
||||
}
|
||||
|
||||
def extra_gates(self, candidate: Any) -> list:
|
||||
"""返回额外门控列表(当前为空)。
|
||||
|
||||
参数:
|
||||
candidate: 候选题目。
|
||||
|
||||
返回:
|
||||
空列表。
|
||||
"""
|
||||
return []
|
||||
@@ -0,0 +1,756 @@
|
||||
"""赛题合成核心逻辑 — 节点采样、prompt 构造、VLM 响应解析、去重。
|
||||
|
||||
纯函数为主,异步编排仅 generate_one。
|
||||
通过 DI 接收 VLMProvider / EmbeddingProvider,不 import adapters/。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.tree.index import L2Node, L3Node, TreeIndex
|
||||
from core.protocols import VLMProvider
|
||||
from core.types import GeneratedQuestion
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnchorContext:
|
||||
"""锚节点上下文——生成单道题所需的全部素材。
|
||||
|
||||
属性:
|
||||
node_id: 锚节点 ID。
|
||||
card_text: 锚节点 card 序列化文本。
|
||||
frame_paths: 帧图片路径列表。
|
||||
subtitle: 对应字幕(可空)。
|
||||
distractor_texts: 同视频其他节点摘要(供 VLM 生成干扰项)。
|
||||
"""
|
||||
|
||||
node_id: str
|
||||
card_text: str
|
||||
frame_paths: list[str]
|
||||
subtitle: str
|
||||
distractor_texts: list[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskTypeSpec:
|
||||
"""题型的生成规格。
|
||||
|
||||
属性:
|
||||
level: 锚定层级("L3" / "L2" / "L1" / "L1-L2")。
|
||||
needs_frames: 是否必须提供帧图。
|
||||
frame_count: 帧数范围描述(如 "1", "2-3", "0-1")。
|
||||
context_fields: 需要提取的 card 字段元组。
|
||||
"""
|
||||
|
||||
level: str
|
||||
needs_frames: bool
|
||||
frame_count: str
|
||||
context_fields: tuple[str, ...]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 12 种 Video-MME 题型 → 树层级 + 生成规格映射
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TASK_TYPE_LEVEL_MAP: dict[str, TaskTypeSpec] = {
|
||||
# --- L3 单帧题型 ---
|
||||
"Object Recognition": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
|
||||
"Attribute Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
|
||||
"OCR Problems": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
|
||||
"Spatial Reasoning": TaskTypeSpec("L3", True, "1", ("frame_summary", "spatial_layout")),
|
||||
"Spatial Perception": TaskTypeSpec("L3", True, "1", ("frame_summary",)),
|
||||
# --- L2 多帧 / 事件级题型 ---
|
||||
"Action Recognition": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
|
||||
"Action Reasoning": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
|
||||
"Counting Problem": TaskTypeSpec("L2", True, "2-3", ("event_description",)),
|
||||
"Temporal Perception": TaskTypeSpec("L2", False, "0-1", ("event_description", "time_range")),
|
||||
# --- L1 / 跨层级题型 ---
|
||||
"Temporal Reasoning": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
|
||||
"Information Synopsis": TaskTypeSpec("L1", True, "per-L2", ("scene_summary",)),
|
||||
"Object Reasoning": TaskTypeSpec("L1-L2", True, "per-L2", ("event_description",)),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 内部辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_l3_card(l3: L3Node, context_fields: tuple[str, ...]) -> str:
|
||||
"""将 L3 节点 card 按 context_fields 序列化为可读文本。
|
||||
|
||||
参数:
|
||||
l3: L3 节点。
|
||||
context_fields: 需提取的字段名元组。
|
||||
|
||||
返回:
|
||||
多行 "field: value" 格式的文本。
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for fld in context_fields:
|
||||
val = getattr(l3.card, fld, None)
|
||||
if val is not None:
|
||||
parts.append(f"{fld}: {val}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _l2_time_range_str(l2: L2Node) -> str:
|
||||
"""将 L2 的 time_range 格式化为可读字符串。
|
||||
|
||||
参数:
|
||||
l2: L2 节点。
|
||||
|
||||
返回:
|
||||
"time_range: (start, end)" 格式,或 "time_range: unknown"。
|
||||
"""
|
||||
if l2.time_range is not None:
|
||||
return f"time_range: ({l2.time_range[0]:.2f}, {l2.time_range[1]:.2f})"
|
||||
return "time_range: unknown"
|
||||
|
||||
|
||||
def _representative_frame(l2: L2Node) -> str | None:
|
||||
"""取 L2 的代表帧路径——第一个有 frame_path 的 L3 子节点。
|
||||
|
||||
参数:
|
||||
l2: L2 节点。
|
||||
|
||||
返回:
|
||||
帧路径字符串,或 None(无可用帧时)。
|
||||
"""
|
||||
for l3 in l2.children:
|
||||
if l3.frame_path:
|
||||
return l3.frame_path
|
||||
return None
|
||||
|
||||
|
||||
def _sort_l2_by_time(l2_nodes: list[L2Node]) -> list[L2Node]:
|
||||
"""按 time_range 升序排列 L2 节点(None 排末尾)。
|
||||
|
||||
参数:
|
||||
l2_nodes: 待排序的 L2 节点列表。
|
||||
|
||||
返回:
|
||||
排序后的新列表(不修改原列表)。
|
||||
"""
|
||||
return sorted(
|
||||
l2_nodes,
|
||||
key=lambda n: n.time_range[0] if n.time_range is not None else float("inf"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 各层级采样策略
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sample_l3(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
spec: TaskTypeSpec,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext:
|
||||
"""L3 层级锚节点采样。
|
||||
|
||||
收集全部 L3 节点,排除已用节点,随机选取一个。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 题型名称。
|
||||
spec: 题型规格。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
AnchorContext 实例。
|
||||
|
||||
异常:
|
||||
ValueError: 候选 L3 节点不足。
|
||||
"""
|
||||
# Phase 1: 收集所有 L3 候选(必须有 frame_path)
|
||||
candidates: list[tuple[L3Node, L2Node]] = []
|
||||
for root in tree.roots:
|
||||
for l2 in root.children:
|
||||
for l3 in l2.children:
|
||||
if l3.id not in used_node_ids and l3.frame_path:
|
||||
candidates.append((l3, l2))
|
||||
|
||||
if not candidates:
|
||||
raise ValueError(f"锚节点不足: {task_type} 无可用 L3 节点(需具备 frame_path)")
|
||||
|
||||
# Phase 2: 随机选取
|
||||
chosen_l3, parent_l2 = rng.choice(candidates)
|
||||
|
||||
# Phase 3: 构造上下文(frame_path 已在候选过滤中保证非 None)
|
||||
card_text = _serialize_l3_card(chosen_l3, spec.context_fields)
|
||||
frame_paths = [chosen_l3.frame_path] # type: ignore[list-item]
|
||||
subtitle = chosen_l3.card.subtitle or ""
|
||||
|
||||
# Phase 4: 干扰项——整棵树中其他 L3 的 frame_summary
|
||||
distractor_texts = [
|
||||
l3.card.frame_summary
|
||||
for root in tree.roots
|
||||
for l2 in root.children
|
||||
for l3 in l2.children
|
||||
if l3.id != chosen_l3.id
|
||||
]
|
||||
|
||||
return AnchorContext(
|
||||
node_id=chosen_l3.id,
|
||||
card_text=card_text,
|
||||
frame_paths=frame_paths,
|
||||
subtitle=subtitle,
|
||||
distractor_texts=distractor_texts,
|
||||
)
|
||||
|
||||
|
||||
def _sample_l2(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
spec: TaskTypeSpec,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext:
|
||||
"""L2 层级锚节点采样(含 Temporal Perception 特殊处理)。
|
||||
|
||||
普通 L2 题型:随机选 1 个 L2,取 2-3 个子 L3 帧。
|
||||
Temporal Perception:0-1 帧,card_text 必含 time_range。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 题型名称。
|
||||
spec: 题型规格。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
AnchorContext 实例。
|
||||
|
||||
异常:
|
||||
ValueError: 候选 L2 节点不足。
|
||||
"""
|
||||
# Phase 1: 收集所有 L2 候选
|
||||
all_l2: list[L2Node] = []
|
||||
for root in tree.roots:
|
||||
for l2 in root.children:
|
||||
if l2.id not in used_node_ids:
|
||||
all_l2.append(l2)
|
||||
|
||||
if not all_l2:
|
||||
raise ValueError(f"锚节点不足: {task_type} 无可用 L2 节点")
|
||||
|
||||
# Phase 2: 随机选取
|
||||
chosen_l2 = rng.choice(all_l2)
|
||||
|
||||
is_temporal_perception = task_type == "Temporal Perception"
|
||||
|
||||
# Phase 3: 帧路径
|
||||
if is_temporal_perception:
|
||||
# 0-1 帧:有子节点则取 1 帧,否则 0 帧
|
||||
frame_paths: list[str] = []
|
||||
if chosen_l2.children:
|
||||
first_frame = chosen_l2.children[0].frame_path
|
||||
if first_frame:
|
||||
frame_paths = [first_frame]
|
||||
else:
|
||||
# 普通 L2:随机采样 2-3 个 L3 帧
|
||||
children_with_frames = [l3 for l3 in chosen_l2.children if l3.frame_path]
|
||||
if len(children_with_frames) < 2:
|
||||
raise ValueError(
|
||||
f"锚节点不足: {task_type} 需要 >=2 个子帧,"
|
||||
f"但 {chosen_l2.id} 仅有 {len(children_with_frames)} 个可用帧"
|
||||
)
|
||||
n_frames = min(rng.randint(2, 3), len(children_with_frames))
|
||||
sampled = rng.sample(children_with_frames, n_frames)
|
||||
frame_paths = [l3.frame_path for l3 in sampled if l3.frame_path]
|
||||
|
||||
# Phase 4: card_text
|
||||
card_text = f"event_description: {chosen_l2.card.event_description}"
|
||||
if is_temporal_perception:
|
||||
card_text += f"\n{_l2_time_range_str(chosen_l2)}"
|
||||
|
||||
# Phase 5: 字幕(优先使用 L2 自身字幕,否则取首个子 L3 字幕)
|
||||
subtitle = chosen_l2.card.subtitle or ""
|
||||
if not subtitle and chosen_l2.children and chosen_l2.children[0].card.subtitle:
|
||||
subtitle = chosen_l2.children[0].card.subtitle
|
||||
|
||||
# Phase 6: 干扰项——整棵树中其他 L2 的 event_description
|
||||
distractor_texts = [
|
||||
l2.card.event_description
|
||||
for root in tree.roots
|
||||
for l2 in root.children
|
||||
if l2.id != chosen_l2.id
|
||||
]
|
||||
|
||||
return AnchorContext(
|
||||
node_id=chosen_l2.id,
|
||||
card_text=card_text,
|
||||
frame_paths=frame_paths,
|
||||
subtitle=subtitle,
|
||||
distractor_texts=distractor_texts,
|
||||
)
|
||||
|
||||
|
||||
def _sample_l1(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
spec: TaskTypeSpec,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext:
|
||||
"""L1 层级锚节点采样(Temporal Reasoning / Information Synopsis)。
|
||||
|
||||
Information Synopsis:使用目标 L1 下全部 L2 子节点。
|
||||
Temporal Reasoning:严格要求 >=3 个 L2 子节点,不足则抛 ValueError。
|
||||
L2 按 time_range 升序排列,每个 L2 取一帧代表。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 题型名称。
|
||||
spec: 题型规格。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
AnchorContext 实例。
|
||||
|
||||
异常:
|
||||
ValueError: 候选 L1 节点不足,或 Temporal Reasoning 的 L2 子节点 <3。
|
||||
"""
|
||||
# Phase 1: 收集可用 L1
|
||||
candidates = [r for r in tree.roots if r.id not in used_node_ids]
|
||||
if not candidates:
|
||||
raise ValueError(f"锚节点不足: {task_type} 无可用 L1 节点")
|
||||
|
||||
# Phase 2: 随机选取
|
||||
chosen_l1 = rng.choice(candidates)
|
||||
|
||||
# Phase 3: 选定 L2 子集
|
||||
if task_type == "Information Synopsis":
|
||||
# 必须使用全部 L2
|
||||
selected_l2 = list(chosen_l1.children)
|
||||
else:
|
||||
# Temporal Reasoning:严格要求 >=3 个 L2
|
||||
if len(chosen_l1.children) < 3:
|
||||
raise ValueError(
|
||||
f"锚节点不足: {task_type} 需要 >=3 个 L2 子节点,"
|
||||
f"但 {chosen_l1.id} 仅有 {len(chosen_l1.children)} 个"
|
||||
)
|
||||
if len(chosen_l1.children) == 3:
|
||||
selected_l2 = list(chosen_l1.children)
|
||||
else:
|
||||
selected_l2 = rng.sample(chosen_l1.children, rng.randint(3, len(chosen_l1.children)))
|
||||
|
||||
# Phase 4: 按 time_range 升序排列
|
||||
selected_l2 = _sort_l2_by_time(selected_l2)
|
||||
|
||||
# Phase 5: card_text(场景摘要)
|
||||
card_text = f"scene_summary: {chosen_l1.card.scene_summary}"
|
||||
|
||||
# Phase 6: 帧路径——每个 L2 取一帧代表
|
||||
frame_paths: list[str] = []
|
||||
for l2 in selected_l2:
|
||||
rep = _representative_frame(l2)
|
||||
if rep:
|
||||
frame_paths.append(rep)
|
||||
|
||||
# Phase 7: 字幕(L1 无字幕)
|
||||
subtitle = ""
|
||||
|
||||
# Phase 8: 干扰项——其他 L1 的 scene_summary
|
||||
distractor_texts = [r.card.scene_summary for r in tree.roots if r.id != chosen_l1.id]
|
||||
|
||||
return AnchorContext(
|
||||
node_id=chosen_l1.id,
|
||||
card_text=card_text,
|
||||
frame_paths=frame_paths,
|
||||
subtitle=subtitle,
|
||||
distractor_texts=distractor_texts,
|
||||
)
|
||||
|
||||
|
||||
def _sample_l1_l2(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
spec: TaskTypeSpec,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext:
|
||||
"""L1-L2 跨层级锚节点采样(Object Reasoning)。
|
||||
|
||||
从全部 L2 中随机选 2-3 个,按 time_range 排序,
|
||||
card_text 为各 L2 的 event_description 拼接。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 题型名称。
|
||||
spec: 题型规格。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
rng: 随机数生成器。
|
||||
|
||||
返回:
|
||||
AnchorContext 实例。
|
||||
|
||||
异常:
|
||||
ValueError: 候选 L2 节点不足。
|
||||
"""
|
||||
# Phase 1: 收集全部 L2
|
||||
all_l2: list[L2Node] = []
|
||||
for root in tree.roots:
|
||||
for l2 in root.children:
|
||||
if l2.id not in used_node_ids:
|
||||
all_l2.append(l2)
|
||||
|
||||
if len(all_l2) < 2:
|
||||
raise ValueError(
|
||||
f"锚节点不足: {task_type} 需要 >=2 个 L2 节点,但仅有 {len(all_l2)} 个可用"
|
||||
)
|
||||
|
||||
# Phase 2: 随机选 2-3 个
|
||||
n_pick = min(rng.randint(2, 3), len(all_l2))
|
||||
selected = rng.sample(all_l2, n_pick)
|
||||
|
||||
# Phase 3: 按 time_range 升序排列
|
||||
selected = _sort_l2_by_time(selected)
|
||||
|
||||
# Phase 4: card_text = 各 L2 event_description 拼接
|
||||
card_text = "\n".join(f"event_description: {l2.card.event_description}" for l2 in selected)
|
||||
|
||||
# Phase 5: 帧路径——每个 L2 取一帧代表
|
||||
frame_paths: list[str] = []
|
||||
for l2 in selected:
|
||||
rep = _representative_frame(l2)
|
||||
if rep:
|
||||
frame_paths.append(rep)
|
||||
|
||||
# Phase 6: 字幕
|
||||
subtitle = ""
|
||||
|
||||
# Phase 7: 干扰项——未被选中的 L2 的 event_description
|
||||
selected_ids = {l2.id for l2 in selected}
|
||||
distractor_texts = [l2.card.event_description for l2 in all_l2 if l2.id not in selected_ids]
|
||||
|
||||
# 使用第一个被选中节点的 ID 作为锚节点 ID
|
||||
anchor_id = selected[0].id
|
||||
|
||||
return AnchorContext(
|
||||
node_id=anchor_id,
|
||||
card_text=card_text,
|
||||
frame_paths=frame_paths,
|
||||
subtitle=subtitle,
|
||||
distractor_texts=distractor_texts,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 公开接口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def sample_anchor(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext:
|
||||
"""根据题型从视频树中采样锚节点及上下文素材。
|
||||
|
||||
依据 TASK_TYPE_LEVEL_MAP 中的层级规格,分发到对应的层级采样策略。
|
||||
每种层级有不同的帧选取、card 序列化和干扰项收集逻辑。
|
||||
|
||||
参数:
|
||||
tree: 三层树索引。
|
||||
task_type: 12 种 Video-MME 题型之一。
|
||||
used_node_ids: 本轮已用节点 ID 集合(避免重复采样)。
|
||||
rng: 可控随机数生成器(保证可复现)。
|
||||
|
||||
返回:
|
||||
AnchorContext 实例,包含锚节点 ID、card 文本、帧路径、字幕和干扰项。
|
||||
|
||||
异常:
|
||||
KeyError: task_type 不在 TASK_TYPE_LEVEL_MAP 中。
|
||||
ValueError: 候选节点不足(全部被 used_node_ids 排除)。
|
||||
"""
|
||||
spec = TASK_TYPE_LEVEL_MAP[task_type]
|
||||
|
||||
if spec.level == "L3":
|
||||
return _sample_l3(tree, task_type, spec, used_node_ids, rng)
|
||||
elif spec.level == "L2":
|
||||
return _sample_l2(tree, task_type, spec, used_node_ids, rng)
|
||||
elif spec.level == "L1":
|
||||
return _sample_l1(tree, task_type, spec, used_node_ids, rng)
|
||||
elif spec.level == "L1-L2":
|
||||
return _sample_l1_l2(tree, task_type, spec, used_node_ids, rng)
|
||||
else:
|
||||
raise ValueError(f"未知层级: {spec.level}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 构造与 VLM 响应解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_VALID_ANSWERS = frozenset({"A", "B", "C", "D"})
|
||||
|
||||
|
||||
def build_generation_prompt(
|
||||
task_type: str,
|
||||
anchor: AnchorContext,
|
||||
exemplars: list[GeneratedQuestion],
|
||||
) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""组装 VLM 出题 prompt。
|
||||
|
||||
构造 OpenAI 格式的 messages 列表和帧图片路径列表,
|
||||
供 VLMProvider.chat_with_images 直接消费。
|
||||
|
||||
参数:
|
||||
task_type: 题型名称(如 "Object Recognition")。
|
||||
anchor: 锚节点上下文(card_text, subtitle, distractor_texts, frame_paths)。
|
||||
exemplars: 少样本示例列表(可为空)。
|
||||
|
||||
返回:
|
||||
(messages, image_paths) — messages 为 OpenAI 格式消息列表,
|
||||
image_paths 为帧图片路径列表,直接喂给 VLMProvider.chat_with_images。
|
||||
"""
|
||||
# Phase 1: 构造 system message
|
||||
system_parts: list[str] = [
|
||||
"你是一个视频理解题目生成器。",
|
||||
f"题型: {task_type}",
|
||||
"约束:",
|
||||
"- 题目必须基于提供的节点内容",
|
||||
"- 干扰选项应来自其他节点的信息",
|
||||
"- 生成风格应与示例保持一致",
|
||||
'- 以 JSON 格式返回: {"question": "...", "options": ["A. ...", "B. ...", "C. ...", "D. ..."], "answer": "A/B/C/D"}',
|
||||
]
|
||||
|
||||
# Phase 2: 加入 few-shot 示例
|
||||
if exemplars:
|
||||
system_parts.append("\n示例:")
|
||||
for i, ex in enumerate(exemplars, 1):
|
||||
system_parts.append(f" 示例 {i}:")
|
||||
system_parts.append(f" question: {ex.question}")
|
||||
system_parts.append(f" options: {list(ex.options)}")
|
||||
system_parts.append(f" answer: {ex.answer}")
|
||||
|
||||
system_content = "\n".join(system_parts)
|
||||
|
||||
# Phase 3: 构造 user message
|
||||
user_parts: list[str] = [f"节点内容:\n{anchor.card_text}"]
|
||||
|
||||
if anchor.subtitle:
|
||||
user_parts.append(f"\n字幕:\n{anchor.subtitle}")
|
||||
|
||||
if anchor.distractor_texts:
|
||||
user_parts.append("\n干扰项来源节点摘要:")
|
||||
for dt in anchor.distractor_texts:
|
||||
user_parts.append(f"- {dt}")
|
||||
|
||||
user_content = "\n".join(user_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": user_content},
|
||||
]
|
||||
|
||||
return messages, list(anchor.frame_paths)
|
||||
|
||||
|
||||
def parse_vlm_response(
|
||||
raw: str,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
) -> dict:
|
||||
"""解析 VLM 返回的 JSON → 部分字段字典。
|
||||
|
||||
尝试直接解析 JSON;若失败,从 markdown 代码块中提取后重试。
|
||||
校验必需字段、选项数量和答案合法性。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回文本。
|
||||
video_id: 所属视频标识。
|
||||
task_type: 题型名称(用于错误消息)。
|
||||
seq: 序列号,用于生成 question_id。
|
||||
|
||||
返回:
|
||||
{"question_id": "gen-{video_id}-{seq:03d}", "question": ..., "options": [...], "answer": ...}
|
||||
调用方(generate_one)补齐 source_nodes/difficulty 后构造 GeneratedQuestion。
|
||||
|
||||
异常:
|
||||
ValueError: JSON 解析失败、缺必需字段、options 非 4 项、answer 不在 A-D。
|
||||
"""
|
||||
# Phase 1: 尝试直接解析 JSON
|
||||
data = None
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
data = json.loads(raw)
|
||||
|
||||
# Phase 2: 从 markdown 代码块提取 JSON
|
||||
if data is None:
|
||||
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", raw, re.DOTALL)
|
||||
if match:
|
||||
with contextlib.suppress(json.JSONDecodeError):
|
||||
data = json.loads(match.group(1))
|
||||
|
||||
if data is None:
|
||||
raise ValueError(f"VLM 返回无法解析为 JSON: {raw[:200]}")
|
||||
|
||||
# Phase 3: 校验必需字段
|
||||
required = ("question", "options", "answer")
|
||||
missing = [f for f in required if f not in data]
|
||||
if missing:
|
||||
raise ValueError(f"VLM 返回缺少必需字段 {missing}: {raw[:200]}")
|
||||
|
||||
# Phase 4: options 必须恰好 4 项
|
||||
options = data["options"]
|
||||
if not isinstance(options, list) or len(options) != 4:
|
||||
raise ValueError(
|
||||
f"options 必须恰好 4 项,实际 {len(options) if isinstance(options, list) else type(options).__name__}: {raw[:200]}"
|
||||
)
|
||||
|
||||
# Phase 5: answer 必须是 A-D
|
||||
answer = data["answer"]
|
||||
if answer not in _VALID_ANSWERS:
|
||||
raise ValueError(f"answer 必须是 A/B/C/D 之一,实际 '{answer}': {raw[:200]}")
|
||||
|
||||
return {
|
||||
"question_id": f"gen-{video_id}-{task_type.lower().replace(' ', '_')}-{seq:03d}",
|
||||
"question": data["question"],
|
||||
"options": list(options),
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding 去重
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def is_duplicate(
|
||||
question_text: str,
|
||||
pool_embeddings: np.ndarray,
|
||||
embed_fn: Callable[[str | list[str]], np.ndarray],
|
||||
threshold: float,
|
||||
) -> bool:
|
||||
"""embedding 去重判定。
|
||||
|
||||
参数:
|
||||
question_text: 待检查的题目文本。
|
||||
pool_embeddings: 已有题目的 embedding 矩阵 [N, D](L2 归一化)。
|
||||
embed_fn: 文本嵌入函数,返回 [N, D] ndarray(L2 归一化)。
|
||||
threshold: 余弦相似度阈值。
|
||||
|
||||
返回:
|
||||
True 表示与池中某题重复。空池永远返回 False。
|
||||
"""
|
||||
if pool_embeddings.shape[0] == 0:
|
||||
return False
|
||||
|
||||
query = embed_fn(question_text) # [1, D]
|
||||
query = query.squeeze(0) # [D]
|
||||
similarities = pool_embeddings @ query # [N]
|
||||
return bool(np.max(similarities) >= threshold)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 单题生成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def generate_one(
|
||||
vlm: VLMProvider,
|
||||
tree: TreeIndex,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
*,
|
||||
exemplars: list[GeneratedQuestion],
|
||||
used_node_ids: set[str],
|
||||
max_retries: int,
|
||||
rng: random.Random,
|
||||
session_id: str,
|
||||
) -> GeneratedQuestion | None:
|
||||
"""生成单道候选题(不含去重——去重在调用方汇总点原子执行)。
|
||||
|
||||
循环最多 max_retries 次尝试生成。每次尝试:
|
||||
1. 采样锚节点
|
||||
2. 构造 prompt
|
||||
3. 调用 VLM
|
||||
4. 解析响应
|
||||
5. 构造 GeneratedQuestion
|
||||
|
||||
返回 None 表示耗尽重试。
|
||||
|
||||
参数:
|
||||
vlm: VLM 调用端口。
|
||||
tree: 三层树索引。
|
||||
video_id: 所属视频标识。
|
||||
task_type: 12 种 Video-MME 题型之一。
|
||||
seq: 序列号,用于生成 question_id。
|
||||
exemplars: 少样本示例列表。
|
||||
used_node_ids: 已用节点 ID 集合。
|
||||
max_retries: 最大重试次数。
|
||||
rng: 可控随机数生成器。
|
||||
session_id: 会话 ID(传递给 VLM 遥测)。
|
||||
|
||||
返回:
|
||||
GeneratedQuestion 实例,或 None(耗尽重试)。
|
||||
"""
|
||||
from core.types import GeneratedQuestion as _GeneratedQuestion
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Phase 1: 采样锚节点
|
||||
anchor = sample_anchor(tree, task_type, used_node_ids, rng)
|
||||
|
||||
# Phase 2: 构造 prompt
|
||||
messages, images = build_generation_prompt(task_type, anchor, exemplars)
|
||||
|
||||
# Phase 3: 调用 VLM
|
||||
response = await vlm.chat_with_images(
|
||||
messages,
|
||||
images,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
# Phase 4: 解析响应
|
||||
parsed = parse_vlm_response(response.content, video_id, task_type, seq)
|
||||
|
||||
# Phase 5: 构造 GeneratedQuestion
|
||||
return _GeneratedQuestion(
|
||||
question_id=parsed["question_id"],
|
||||
video_id=video_id,
|
||||
task_type=task_type,
|
||||
question=parsed["question"],
|
||||
options=tuple(parsed["options"]),
|
||||
answer=parsed["answer"],
|
||||
source_nodes=(anchor.node_id,),
|
||||
difficulty="medium",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"generate_one 尝试 {}/{} 失败 ({}): {}",
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
task_type,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"generate_one 耗尽 {} 次重试 (video={}, task_type={})",
|
||||
max_retries,
|
||||
video_id,
|
||||
task_type,
|
||||
)
|
||||
return None
|
||||
@@ -201,6 +201,10 @@ class SearchToolDispatcher:
|
||||
summary,
|
||||
]
|
||||
|
||||
# Phase 2.5: 确定性追加实体/画面文字原文(防按题摘要吞噬,Spec-1 B)
|
||||
for label, text in self._env.node_entity_fields(node_id).items():
|
||||
parts.append(f"[{label}] {text}")
|
||||
|
||||
# Phase 3: 子节点概览
|
||||
children_info = self._env.get_children_info(node_id)
|
||||
if children_info:
|
||||
|
||||
+91
-16
@@ -19,7 +19,7 @@ from loguru import logger
|
||||
from app.tree.index import L1Node, L2Node, L3Node, TreeIndex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
# 节点联合类型(内部使用)
|
||||
AnyNode = L1Node | L2Node | L3Node
|
||||
@@ -64,26 +64,61 @@ def _node_description(node: AnyNode) -> str:
|
||||
return node.card.frame_summary
|
||||
|
||||
|
||||
def _collect_card_strings(node: AnyNode) -> list[str]:
|
||||
def _collect_card_strings(
|
||||
node: AnyNode,
|
||||
skip_fields: frozenset[str] = frozenset(),
|
||||
) -> list[str]:
|
||||
"""从节点 card 中递归收集所有非空字符串字段。
|
||||
|
||||
参数:
|
||||
node: 树节点实例。
|
||||
skip_fields: 需要跳过的 dataclass 字段名集合(如 subtitle,
|
||||
因为它需要单独添加"字幕:"标签和 [sN] 锚标)。
|
||||
|
||||
返回:
|
||||
字符串列表(每个非空字段值一项,含内嵌换行的按行拆分)。
|
||||
"""
|
||||
result: list[str] = []
|
||||
_collect_from_obj(node.card, result)
|
||||
_collect_from_obj(node.card, result, skip_fields=skip_fields)
|
||||
return result
|
||||
|
||||
|
||||
def _collect_from_obj(obj: object, out: list[str]) -> None:
|
||||
# subtitle 字段在 _node_full_text / _node_anchored_text 中单独处理
|
||||
_SUBTITLE_SKIP: frozenset[str] = frozenset({"subtitle"})
|
||||
|
||||
# 各层级 card 的实体字段名(B 修复:dispatcher 追加原文用)
|
||||
_ENTITY_FIELDS_BY_LEVEL: dict[str, tuple[str, ...]] = {
|
||||
"L1": ("key_entities",),
|
||||
"L2": ("entities",),
|
||||
"L3": ("visible_entities",),
|
||||
}
|
||||
|
||||
|
||||
def _clean_join(values: Iterable[object] | None) -> str:
|
||||
"""过滤非空字符串、去重(保序)、分号拼接;无有效值返回空串。
|
||||
|
||||
参数:
|
||||
values: 待清洗的值集合(通常为 list[str],容忍 None / 混入非字符串)。
|
||||
|
||||
返回:
|
||||
"a; b; c" 形式的拼接串,无有效值时为空串。
|
||||
"""
|
||||
cleaned = [v.strip() for v in (values or []) if isinstance(v, str) and v.strip()]
|
||||
return "; ".join(dict.fromkeys(cleaned))
|
||||
|
||||
|
||||
def _collect_from_obj(
|
||||
obj: object,
|
||||
out: list[str],
|
||||
*,
|
||||
skip_fields: frozenset[str] = frozenset(),
|
||||
) -> None:
|
||||
"""递归收集任意嵌套结构中的非空字符串。
|
||||
|
||||
参数:
|
||||
obj: dict / list / str / 其他。
|
||||
out: 收集结果列表(原地修改)。
|
||||
skip_fields: 需要跳过的 dataclass 字段名集合。
|
||||
"""
|
||||
if isinstance(obj, str):
|
||||
stripped = obj.strip()
|
||||
@@ -91,14 +126,16 @@ def _collect_from_obj(obj: object, out: list[str]) -> None:
|
||||
out.append(stripped)
|
||||
elif isinstance(obj, dict):
|
||||
for v in obj.values():
|
||||
_collect_from_obj(v, out)
|
||||
_collect_from_obj(v, out, skip_fields=skip_fields)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
for item in obj:
|
||||
_collect_from_obj(item, out)
|
||||
_collect_from_obj(item, out, skip_fields=skip_fields)
|
||||
elif hasattr(obj, "__dataclass_fields__"):
|
||||
# frozen dataclass(Card 类型)
|
||||
for field_name in obj.__dataclass_fields__:
|
||||
_collect_from_obj(getattr(obj, field_name), out)
|
||||
if field_name in skip_fields:
|
||||
continue
|
||||
_collect_from_obj(getattr(obj, field_name), out, skip_fields=skip_fields)
|
||||
|
||||
|
||||
class TreeEnvironment:
|
||||
@@ -197,6 +234,39 @@ class TreeEnvironment:
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def node_entity_fields(self, node_id: str) -> dict[str, str]:
|
||||
"""返回节点 card 的实体/画面文字字段原文。
|
||||
|
||||
供 dispatcher 在按题摘要后确定性追加,防止 LLM 摘要吞掉
|
||||
entities/visible_text 信号(benchmark 错题 M1 恶化因素)。
|
||||
|
||||
参数:
|
||||
node_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
{"实体": "...", "画面文字": "..."},空字段不含对应键。
|
||||
|
||||
异常:
|
||||
KeyError: 节点不存在。
|
||||
"""
|
||||
node = self._id_to_node.get(node_id)
|
||||
if node is None:
|
||||
raise KeyError(f"节点不存在: {node_id}")
|
||||
level = _node_level(node)
|
||||
|
||||
entity_values: list[str] = []
|
||||
for field_name in _ENTITY_FIELDS_BY_LEVEL[level]:
|
||||
entity_values.extend(getattr(node.card, field_name) or [])
|
||||
|
||||
out: dict[str, str] = {}
|
||||
entities_text = _clean_join(entity_values)
|
||||
if entities_text:
|
||||
out["实体"] = entities_text
|
||||
visible_text = _clean_join(getattr(node.card, "visible_text", None))
|
||||
if visible_text:
|
||||
out["画面文字"] = visible_text
|
||||
return out
|
||||
|
||||
def search_similar(
|
||||
self,
|
||||
query: str,
|
||||
@@ -367,17 +437,19 @@ class TreeEnvironment:
|
||||
def get_subtitle(self, node_id: str) -> str:
|
||||
"""返回节点字幕文本。
|
||||
|
||||
L2/L3 节点从 card.subtitle 读取,L1 节点不含字幕。
|
||||
|
||||
参数:
|
||||
node_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
字幕文本;无字幕或节点不存在时返回空字符串。
|
||||
字幕文本;无字幕、L1 节点或节点不存在时返回空字符串。
|
||||
"""
|
||||
node = self._id_to_node.get(node_id)
|
||||
if node is None:
|
||||
return ""
|
||||
if isinstance(node, L3Node):
|
||||
return node.subtitle or ""
|
||||
if isinstance(node, (L2Node, L3Node)):
|
||||
return node.card.subtitle or ""
|
||||
return ""
|
||||
|
||||
def resolve_frame_paths(self, node_ids: list[str]) -> list[Path]:
|
||||
@@ -448,22 +520,25 @@ class TreeEnvironment:
|
||||
def _node_full_text(self, node: AnyNode) -> str:
|
||||
"""获取节点完整文本(card 所有字段 + subtitle)。
|
||||
|
||||
subtitle 从 card.subtitle 读取,仅 L2/L3 节点附加"字幕:"标签。
|
||||
|
||||
参数:
|
||||
node: 树节点。
|
||||
|
||||
返回:
|
||||
拼接后的全文本。
|
||||
"""
|
||||
card_strings = _collect_card_strings(node)
|
||||
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
|
||||
text = "\n".join(card_strings)
|
||||
if isinstance(node, L3Node) and node.subtitle:
|
||||
text += f"\n字幕: {node.subtitle}"
|
||||
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
|
||||
text += f"\n字幕: {node.card.subtitle}"
|
||||
return text
|
||||
|
||||
def _node_anchored_text(self, node: AnyNode) -> str:
|
||||
"""获取带行号锚的节点文本。
|
||||
|
||||
card 字符串逐行编 [c1]..[cN],字幕逐行编 [s1]..[sM]。
|
||||
字幕从 card.subtitle 读取,仅 L2/L3 节点产生 [sN] 锚标。
|
||||
|
||||
参数:
|
||||
node: 树节点。
|
||||
@@ -471,15 +546,15 @@ class TreeEnvironment:
|
||||
返回:
|
||||
带锚文本。
|
||||
"""
|
||||
card_strings = _collect_card_strings(node)
|
||||
card_strings = _collect_card_strings(node, skip_fields=_SUBTITLE_SKIP)
|
||||
# 拆分内嵌换行,确保一锚一行
|
||||
card_lines: list[str] = []
|
||||
for s in card_strings:
|
||||
card_lines.extend(ln for ln in s.splitlines() if ln.strip())
|
||||
|
||||
sub_lines: list[str] = []
|
||||
if isinstance(node, L3Node) and node.subtitle:
|
||||
sub_lines = [ln for ln in node.subtitle.splitlines() if ln.strip()]
|
||||
if isinstance(node, (L2Node, L3Node)) and node.card.subtitle:
|
||||
sub_lines = [ln for ln in node.card.subtitle.splitlines() if ln.strip()]
|
||||
|
||||
anchored: list[str] = []
|
||||
for i, line in enumerate(card_lines, 1):
|
||||
|
||||
+16
-4
@@ -20,6 +20,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
@@ -82,6 +83,7 @@ class L3Card:
|
||||
visible_text: 画面中可见的文字列表。
|
||||
spatial_layout: 空间布局描述。
|
||||
visual_attributes: 视觉属性字典(如光照、色调等)。
|
||||
subtitle: 字幕文本(Voronoi 分配后填充,默认空)。
|
||||
"""
|
||||
|
||||
frame_summary: str
|
||||
@@ -90,6 +92,7 @@ class L3Card:
|
||||
visible_text: list[str]
|
||||
spatial_layout: str
|
||||
visual_attributes: dict[str, Any]
|
||||
subtitle: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -106,6 +109,7 @@ class L2Card:
|
||||
visible_text: 片段中可见的文字列表。
|
||||
spatial_relations: 空间关系描述。
|
||||
state_changes: 状态变化描述(可选)。
|
||||
subtitle: 子 L3 字幕聚合文本(Voronoi 分配后填充,默认空)。
|
||||
"""
|
||||
|
||||
event_description: str
|
||||
@@ -115,6 +119,7 @@ class L2Card:
|
||||
visible_text: list[str]
|
||||
spatial_relations: str
|
||||
state_changes: str | None
|
||||
subtitle: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -183,7 +188,6 @@ class L3Node:
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
timestamp: 对应的时间戳(秒,可选)。
|
||||
frame_path: 关联的帧图像路径(可选,仅视频模态)。
|
||||
subtitle: 该帧对应的字幕文本(可选)。
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -191,7 +195,6 @@ class L3Node:
|
||||
embedding: np.ndarray | None = None
|
||||
timestamp: float | None = None
|
||||
frame_path: str | None = None
|
||||
subtitle: str | None = None
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
@@ -274,10 +277,10 @@ class L1Node:
|
||||
"visible_text": n.card.visible_text,
|
||||
"spatial_layout": n.card.spatial_layout,
|
||||
"visual_attributes": n.card.visual_attributes,
|
||||
"subtitle": n.card.subtitle,
|
||||
},
|
||||
"timestamp": n.timestamp,
|
||||
"frame_path": n.frame_path,
|
||||
"subtitle": n.subtitle,
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
@@ -294,6 +297,7 @@ class L1Node:
|
||||
"visible_text": n.card.visible_text,
|
||||
"spatial_relations": n.card.spatial_relations,
|
||||
"state_changes": n.card.state_changes,
|
||||
"subtitle": n.card.subtitle,
|
||||
},
|
||||
"time_range": list(n.time_range) if n.time_range else None,
|
||||
"children": [l3_to_dict(c) for c in n.children],
|
||||
@@ -334,6 +338,8 @@ class L1Node:
|
||||
for l2d in d.get("children", []):
|
||||
l3_nodes: list[L3Node] = []
|
||||
for l3d in l2d.get("children", []):
|
||||
# 向后兼容:旧格式 subtitle 在节点级,新格式在 card 内
|
||||
l3_subtitle = l3d["card"].get("subtitle", "") or l3d.get("subtitle", "") or ""
|
||||
l3_card = L3Card(
|
||||
frame_summary=l3d["card"]["frame_summary"],
|
||||
visible_entities=l3d["card"]["visible_entities"],
|
||||
@@ -341,6 +347,7 @@ class L1Node:
|
||||
visible_text=l3d["card"]["visible_text"],
|
||||
spatial_layout=l3d["card"]["spatial_layout"],
|
||||
visual_attributes=l3d["card"]["visual_attributes"],
|
||||
subtitle=l3_subtitle,
|
||||
)
|
||||
l3_nodes.append(
|
||||
L3Node(
|
||||
@@ -349,7 +356,6 @@ class L1Node:
|
||||
embedding=_embed_from_str(l3d.get("embedding")),
|
||||
timestamp=l3d.get("timestamp"),
|
||||
frame_path=l3d.get("frame_path"),
|
||||
subtitle=l3d.get("subtitle"),
|
||||
)
|
||||
)
|
||||
l2_card = L2Card(
|
||||
@@ -360,7 +366,13 @@ class L1Node:
|
||||
visible_text=l2d["card"]["visible_text"],
|
||||
spatial_relations=l2d["card"]["spatial_relations"],
|
||||
state_changes=l2d["card"]["state_changes"],
|
||||
subtitle=l2d["card"].get("subtitle", ""),
|
||||
)
|
||||
# 旧格式兼容:L2 无 card subtitle 时从 L3 children 聚合
|
||||
if not l2_card.subtitle:
|
||||
l3_subs = [n.card.subtitle for n in l3_nodes if n.card.subtitle]
|
||||
if l3_subs:
|
||||
l2_card = dataclasses.replace(l2_card, subtitle="\n".join(l3_subs))
|
||||
tr2 = l2d.get("time_range")
|
||||
l2_nodes.append(
|
||||
L2Node(
|
||||
|
||||
@@ -40,10 +40,13 @@ def detect_issues(
|
||||
"""扫描树,返回所有问题节点列表。
|
||||
|
||||
检查项:
|
||||
- L3: card 必填字段为空(frame_summary / visible_entities / ongoing_actions / spatial_layout)
|
||||
- L3: card 必填字段为空(frame_summary / spatial_layout)
|
||||
- 注: visible_entities / ongoing_actions 为空是合法状态(静物/黑帧),不纳入检测
|
||||
- L3: frame_path 对应文件不存在(需提供 frames_dir)
|
||||
- L2: event_description 为空
|
||||
- L2/L1: children 列表为空
|
||||
- L2: 相邻 clips 时间范围不连续(gap > 1秒)
|
||||
- L1: scene_summary 为空
|
||||
|
||||
参数:
|
||||
index: 待检测的 TreeIndex。
|
||||
@@ -55,6 +58,17 @@ def detect_issues(
|
||||
issues: list[NodeIssue] = []
|
||||
|
||||
for l1 in index.roots:
|
||||
# L1: scene_summary 不为空
|
||||
if not l1.card.scene_summary:
|
||||
issues.append(
|
||||
NodeIssue(
|
||||
node_id=l1.id,
|
||||
level=1,
|
||||
issue_type="empty_field",
|
||||
details="L1 节点字段为空: scene_summary",
|
||||
)
|
||||
)
|
||||
|
||||
# L1: children 不为空
|
||||
if not l1.children:
|
||||
issues.append(
|
||||
@@ -71,6 +85,17 @@ def detect_issues(
|
||||
_check_time_gaps(l1.children, issues)
|
||||
|
||||
for l2 in l1.children:
|
||||
# L2: event_description 不为空
|
||||
if not l2.card.event_description:
|
||||
issues.append(
|
||||
NodeIssue(
|
||||
node_id=l2.id,
|
||||
level=2,
|
||||
issue_type="empty_field",
|
||||
details="L2 节点字段为空: event_description",
|
||||
)
|
||||
)
|
||||
|
||||
# L2: children 不为空
|
||||
if not l2.children:
|
||||
issues.append(
|
||||
@@ -84,14 +109,10 @@ def detect_issues(
|
||||
continue
|
||||
|
||||
for l3 in l2.children:
|
||||
# L3: 各必填字段不为空
|
||||
# L3: 核心必填字段不为空(visible_entities/ongoing_actions 为空是合法状态)
|
||||
empty_fields: list[str] = []
|
||||
if not l3.card.frame_summary:
|
||||
empty_fields.append("frame_summary")
|
||||
if not l3.card.visible_entities:
|
||||
empty_fields.append("visible_entities")
|
||||
if not l3.card.ongoing_actions:
|
||||
empty_fields.append("ongoing_actions")
|
||||
if not l3.card.spatial_layout:
|
||||
empty_fields.append("spatial_layout")
|
||||
if empty_fields:
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"""树修复重生成器:VLM 重新描述问题节点 + 底向上级联。
|
||||
|
||||
底向上修复流程:
|
||||
1. 收集需修复的 L3 节点 → VLM 重新描述帧
|
||||
2. 收集受影响的 L2 → LLM 从 L3 children 聚合
|
||||
3. 收集受影响的 L1 → LLM 从 L2 children 聚合
|
||||
|
||||
仅处理 issue_type == "empty_field" 且 level == 3 的问题节点。
|
||||
帧文件不存在时跳过该节点(不中断整体修复流程)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from app.tree.index import (
|
||||
L1Card,
|
||||
L1Node,
|
||||
L2Card,
|
||||
L2Node,
|
||||
L3Card,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
from app.tree.subtitle import extract_subtitle_for_range
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from app.tree.repair.detector import NodeIssue
|
||||
from app.tree.subtitle import SRTEntry
|
||||
from core.protocols import LLMProvider, VLMProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 常量(与 VideoTreeBuilder 保持一致风格)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_L3_REPAIR_PROMPT = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"用一到两句话描述这帧画面的具体内容。"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
|
||||
"{subtitle_block}"
|
||||
"返回 JSON 对象,包含以下字段:\n"
|
||||
"- frame_summary: 画面描述\n"
|
||||
"- visible_entities: 可见实体列表\n"
|
||||
"- ongoing_actions: 动作列表\n"
|
||||
"- visible_text: 可见文字列表\n"
|
||||
"- spatial_layout: 空间布局\n"
|
||||
'- visual_attributes: {{"lighting": "...", "dominant_colors": [...], "camera_angle": "..."}}\n'
|
||||
"只返回 JSON 对象,不要其他内容。"
|
||||
)
|
||||
|
||||
_L2_REGEN_PROMPT = (
|
||||
"以下是一个视频片段中各帧的描述:\n{l3_texts}\n"
|
||||
"用1-2句话描述该片段的核心内容。\n"
|
||||
"返回 JSON 对象,包含以下字段:\n"
|
||||
"- event_description: 1-2句片段描述\n"
|
||||
"- entities: 可见实体列表\n"
|
||||
"- actions: 动作列表\n"
|
||||
"- action_subjects: 动作主体列表\n"
|
||||
"- visible_text: 画面中可见文字列表\n"
|
||||
"- spatial_relations: 空间关系描述\n"
|
||||
"- state_changes: 状态变化描述(无则 null)\n"
|
||||
"只返回 JSON 对象,不要其他内容。"
|
||||
)
|
||||
|
||||
_L1_REGEN_PROMPT = (
|
||||
"以下是一个视频段落中各片段的描述:\n{l2_texts}\n"
|
||||
"用2-3句话总结该段落的整体内容,涵盖所有片段的主题。\n"
|
||||
"返回 JSON 对象,包含以下字段:\n"
|
||||
"- scene_summary: 2-3句段落摘要\n"
|
||||
"- main_setting: 主要场景\n"
|
||||
"- key_entities: 关键实体列表\n"
|
||||
"- main_actions: 主要动作列表\n"
|
||||
"- topic_keywords: 主题关键词列表\n"
|
||||
"- visible_text: 出现的文字列表\n"
|
||||
"- temporal_flow: 时间流向描述\n"
|
||||
"只返回 JSON 对象,不要其他内容。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 统计数据类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairStats:
|
||||
"""修复统计信息。
|
||||
|
||||
属性:
|
||||
l3_repaired: 修复的 L3 节点数。
|
||||
l2_regenerated: 重生成的 L2 节点数。
|
||||
l1_regenerated: 重生成的 L1 节点数。
|
||||
"""
|
||||
|
||||
l3_repaired: int = 0
|
||||
l2_regenerated: int = 0
|
||||
l1_regenerated: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON 解析辅助(复用 VideoTreeBuilder 的解析逻辑)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Any:
|
||||
"""从 VLM/LLM 原始输出中提取 JSON(处理 markdown 代码块包裹)。
|
||||
|
||||
参数:
|
||||
raw: 原始返回字符串。
|
||||
|
||||
返回:
|
||||
解析后的 Python 对象(dict/list),解析失败返回 None。
|
||||
"""
|
||||
raw = raw.strip()
|
||||
# Phase 1: 尝试提取 markdown 代码块中的 JSON
|
||||
code_match = re.search(
|
||||
r"```(?:json)?\s*([\[{].*?[\]}])\s*```",
|
||||
raw,
|
||||
re.DOTALL,
|
||||
)
|
||||
if code_match:
|
||||
raw = code_match.group(1)
|
||||
|
||||
# Phase 2: 直接解析
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Phase 3: 尝试提取裸 JSON 对象/数组
|
||||
json_match = re.search(r"[\[{].*[\]}]", raw, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _parse_l3_card(raw: str) -> L3Card | None:
|
||||
"""解析 VLM 输出为 L3Card。解析失败返回 None。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回字符串。
|
||||
|
||||
返回:
|
||||
L3Card 实例或 None(解析失败时)。
|
||||
"""
|
||||
data = _extract_json(raw)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
return L3Card(
|
||||
frame_summary=str(data["frame_summary"]),
|
||||
visible_entities=list(data["visible_entities"]),
|
||||
ongoing_actions=list(data["ongoing_actions"]),
|
||||
visible_text=list(data["visible_text"]),
|
||||
spatial_layout=str(data["spatial_layout"]),
|
||||
visual_attributes=dict(data["visual_attributes"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_l2_card(raw: str) -> L2Card | None:
|
||||
"""解析 LLM 输出为 L2Card。解析失败返回 None。
|
||||
|
||||
参数:
|
||||
raw: LLM 原始返回字符串。
|
||||
|
||||
返回:
|
||||
L2Card 实例或 None(解析失败时)。
|
||||
"""
|
||||
data = _extract_json(raw)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
state_changes = data.get("state_changes")
|
||||
if state_changes is not None:
|
||||
state_changes = str(state_changes)
|
||||
return L2Card(
|
||||
event_description=str(data["event_description"]),
|
||||
entities=list(data["entities"]),
|
||||
actions=list(data["actions"]),
|
||||
action_subjects=list(data["action_subjects"]),
|
||||
visible_text=list(data["visible_text"]),
|
||||
spatial_relations=str(data["spatial_relations"]),
|
||||
state_changes=state_changes,
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _parse_l1_card(raw: str) -> L1Card | None:
|
||||
"""解析 LLM 输出为 L1Card。解析失败返回 None。
|
||||
|
||||
参数:
|
||||
raw: LLM 原始返回字符串。
|
||||
|
||||
返回:
|
||||
L1Card 实例或 None(解析失败时)。
|
||||
"""
|
||||
data = _extract_json(raw)
|
||||
if isinstance(data, dict):
|
||||
try:
|
||||
return L1Card(
|
||||
scene_summary=str(data["scene_summary"]),
|
||||
main_setting=str(data["main_setting"]),
|
||||
key_entities=list(data["key_entities"]),
|
||||
main_actions=list(data["main_actions"]),
|
||||
topic_keywords=list(data["topic_keywords"]),
|
||||
visible_text=list(data["visible_text"]),
|
||||
temporal_flow=str(data["temporal_flow"]),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 节点查找辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_node_lookup(
|
||||
index: TreeIndex,
|
||||
) -> tuple[
|
||||
dict[str, L3Node],
|
||||
dict[str, L2Node],
|
||||
dict[str, L1Node],
|
||||
dict[str, L2Node],
|
||||
dict[str, L1Node],
|
||||
]:
|
||||
"""构建节点 ID 到节点的查找表 + 子节点到父节点的映射。
|
||||
|
||||
参数:
|
||||
index: 树索引。
|
||||
|
||||
返回:
|
||||
(l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1)
|
||||
- l3_by_id: L3 节点 ID → L3Node
|
||||
- l2_by_id: L2 节点 ID → L2Node
|
||||
- l1_by_id: L1 节点 ID → L1Node
|
||||
- l3_parent_l2: L3 节点 ID → 其父 L2Node
|
||||
- l2_parent_l1: L2 节点 ID → 其父 L1Node
|
||||
"""
|
||||
l3_by_id: dict[str, L3Node] = {}
|
||||
l2_by_id: dict[str, L2Node] = {}
|
||||
l1_by_id: dict[str, L1Node] = {}
|
||||
l3_parent_l2: dict[str, L2Node] = {}
|
||||
l2_parent_l1: dict[str, L1Node] = {}
|
||||
|
||||
for l1 in index.roots:
|
||||
l1_by_id[l1.id] = l1
|
||||
for l2 in l1.children:
|
||||
l2_by_id[l2.id] = l2
|
||||
l2_parent_l1[l2.id] = l1
|
||||
for l3 in l2.children:
|
||||
l3_by_id[l3.id] = l3
|
||||
l3_parent_l2[l3.id] = l2
|
||||
|
||||
return l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 字幕辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_subtitle_block(
|
||||
srt_entries: list[SRTEntry] | None,
|
||||
timestamp: float | None,
|
||||
) -> str:
|
||||
"""构建字幕注入文本块。
|
||||
|
||||
参数:
|
||||
srt_entries: SRT 字幕条目列表。
|
||||
timestamp: 帧时间戳(秒)。
|
||||
|
||||
返回:
|
||||
字幕文本块字符串(无匹配时返回空字符串)。
|
||||
"""
|
||||
if not srt_entries or timestamp is None:
|
||||
return ""
|
||||
window = 2.0
|
||||
start = max(0.0, timestamp - window)
|
||||
end = timestamp + window
|
||||
text = extract_subtitle_for_range(srt_entries, (start, end))
|
||||
if not text:
|
||||
return ""
|
||||
return f"字幕信息:\n{text}\n"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主修复函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def repair_tree(
|
||||
index: TreeIndex,
|
||||
issues: list[NodeIssue],
|
||||
vlm: VLMProvider,
|
||||
llm: LLMProvider,
|
||||
frames_dir: Path,
|
||||
srt_entries: list[SRTEntry] | None = None,
|
||||
) -> RepairStats:
|
||||
"""修复有问题的节点,底向上级联。
|
||||
|
||||
流程:
|
||||
1. 收集需修复的 L3 节点 → VLM 重新描述帧
|
||||
2. 收集受影响的 L2 → LLM 从 L3 children 聚合
|
||||
3. 收集受影响的 L1 → LLM 从 L2 children 聚合
|
||||
|
||||
参数:
|
||||
index: 待修复的 TreeIndex(原地修改)。
|
||||
issues: detect_issues() 返回的问题列表。
|
||||
vlm: VLM 调用端口。
|
||||
llm: LLM 调用端口。
|
||||
frames_dir: 帧文件根目录。
|
||||
srt_entries: 字幕条目列表(可选)。
|
||||
|
||||
返回:
|
||||
RepairStats 统计。
|
||||
"""
|
||||
stats = RepairStats()
|
||||
|
||||
if not issues:
|
||||
logger.info("无修复任务,跳过")
|
||||
return stats
|
||||
|
||||
# 构建查找表
|
||||
l3_by_id, l2_by_id, l1_by_id, l3_parent_l2, l2_parent_l1 = _build_node_lookup(index)
|
||||
|
||||
# Step 1: 修复 L3 节点(仅处理 empty_field + level 3)
|
||||
l3_issues = [
|
||||
issue for issue in issues if issue.issue_type == "empty_field" and issue.level == 3
|
||||
]
|
||||
|
||||
affected_l2_ids: set[str] = set()
|
||||
|
||||
for issue in l3_issues:
|
||||
l3_node = l3_by_id.get(issue.node_id)
|
||||
if l3_node is None:
|
||||
logger.warning(
|
||||
"L3 节点 ID 未在树中找到,跳过",
|
||||
node_id=issue.node_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# 查找帧文件
|
||||
if l3_node.frame_path is None:
|
||||
logger.warning(
|
||||
"L3 节点无 frame_path,跳过",
|
||||
node_id=issue.node_id,
|
||||
)
|
||||
continue
|
||||
|
||||
frame_file = frames_dir / l3_node.frame_path
|
||||
if not frame_file.exists():
|
||||
logger.warning(
|
||||
"L3 帧文件不存在,跳过修复",
|
||||
node_id=issue.node_id,
|
||||
frame_path=str(frame_file),
|
||||
)
|
||||
continue
|
||||
|
||||
# 获取 L2 父节点描述作为上下文
|
||||
parent_l2 = l3_parent_l2.get(issue.node_id)
|
||||
l2_description = parent_l2.card.event_description if parent_l2 else ""
|
||||
|
||||
# 构建字幕块
|
||||
subtitle_block = _build_subtitle_block(srt_entries, l3_node.timestamp)
|
||||
|
||||
# VLM 重新描述帧
|
||||
prompt = _L3_REPAIR_PROMPT.format(
|
||||
l2_description=l2_description,
|
||||
subtitle_block=subtitle_block,
|
||||
)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
try:
|
||||
response = await vlm.chat_with_images(messages, [str(frame_file)])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"L3 修复 VLM 调用失败,跳过: {}",
|
||||
exc,
|
||||
node_id=issue.node_id,
|
||||
)
|
||||
continue
|
||||
|
||||
new_card = _parse_l3_card(response.content)
|
||||
if new_card is None:
|
||||
logger.warning(
|
||||
"L3 修复 VLM 输出解析失败,跳过",
|
||||
node_id=issue.node_id,
|
||||
raw_preview=response.content[:200],
|
||||
)
|
||||
continue
|
||||
|
||||
# 原地替换 card(L3Node.card 不是 frozen dataclass 的限制字段)
|
||||
l3_node.card = new_card
|
||||
stats.l3_repaired += 1
|
||||
|
||||
# 标记受影响的 L2 父节点
|
||||
if parent_l2 is not None:
|
||||
affected_l2_ids.add(parent_l2.id)
|
||||
|
||||
logger.debug(
|
||||
"L3 节点修复完成",
|
||||
node_id=issue.node_id,
|
||||
frame_summary=new_card.frame_summary[:50],
|
||||
)
|
||||
|
||||
# Step 2: 重生成受影响的 L2 节点
|
||||
affected_l1_ids: set[str] = set()
|
||||
|
||||
for l2_id in affected_l2_ids:
|
||||
l2_node = l2_by_id.get(l2_id)
|
||||
if l2_node is None:
|
||||
continue
|
||||
|
||||
# 从 L3 children 聚合描述
|
||||
l3_texts = "\n".join(f"- {l3.card.frame_summary}" for l3 in l2_node.children)
|
||||
prompt = _L2_REGEN_PROMPT.format(l3_texts=l3_texts)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
try:
|
||||
response = await llm.chat(messages)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"L2 重生成 LLM 调用失败,跳过: {}",
|
||||
exc,
|
||||
l2_id=l2_id,
|
||||
)
|
||||
continue
|
||||
|
||||
new_card = _parse_l2_card(response.content)
|
||||
if new_card is None:
|
||||
logger.warning(
|
||||
"L2 重生成 LLM 输出解析失败,跳过",
|
||||
l2_id=l2_id,
|
||||
raw_preview=response.content[:200],
|
||||
)
|
||||
continue
|
||||
|
||||
l2_node.card = new_card
|
||||
stats.l2_regenerated += 1
|
||||
|
||||
# 标记受影响的 L1 父节点
|
||||
parent_l1 = l2_parent_l1.get(l2_id)
|
||||
if parent_l1 is not None:
|
||||
affected_l1_ids.add(parent_l1.id)
|
||||
|
||||
logger.debug(
|
||||
"L2 节点重生成完成",
|
||||
l2_id=l2_id,
|
||||
event_description=new_card.event_description[:50],
|
||||
)
|
||||
|
||||
# Step 3: 重生成受影响的 L1 节点
|
||||
for l1_id in affected_l1_ids:
|
||||
l1_node = l1_by_id.get(l1_id)
|
||||
if l1_node is None:
|
||||
continue
|
||||
|
||||
# 从 L2 children 聚合描述
|
||||
l2_texts = "\n".join(f"- {l2.card.event_description}" for l2 in l1_node.children)
|
||||
prompt = _L1_REGEN_PROMPT.format(l2_texts=l2_texts)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
try:
|
||||
response = await llm.chat(messages)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"L1 重生成 LLM 调用失败,跳过: {}",
|
||||
exc,
|
||||
l1_id=l1_id,
|
||||
)
|
||||
continue
|
||||
|
||||
new_card = _parse_l1_card(response.content)
|
||||
if new_card is None:
|
||||
logger.warning(
|
||||
"L1 重生成 LLM 输出解析失败,跳过",
|
||||
l1_id=l1_id,
|
||||
raw_preview=response.content[:200],
|
||||
)
|
||||
continue
|
||||
|
||||
l1_node.card = new_card
|
||||
stats.l1_regenerated += 1
|
||||
|
||||
logger.debug(
|
||||
"L1 节点重生成完成",
|
||||
l1_id=l1_id,
|
||||
scene_summary=new_card.scene_summary[:50],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"树修复完成",
|
||||
l3_repaired=stats.l3_repaired,
|
||||
l2_regenerated=stats.l2_regenerated,
|
||||
l1_regenerated=stats.l1_regenerated,
|
||||
)
|
||||
return stats
|
||||
@@ -84,10 +84,11 @@ def deduplicate_field(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for v in values:
|
||||
key = v.strip().lower()
|
||||
s = str(v).strip()
|
||||
key = s.lower()
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
result.append(v)
|
||||
result.append(s)
|
||||
return result
|
||||
|
||||
|
||||
@@ -278,7 +279,7 @@ def apply_injections(index: TreeIndex, injections: list[dict[str, Any]]) -> Supp
|
||||
stats.facts_skipped += 1
|
||||
continue
|
||||
|
||||
inject_value = instr.get("inject_value", "")
|
||||
inject_value = str(instr.get("inject_value", "")).strip()
|
||||
if not inject_value:
|
||||
stats.facts_skipped += 1
|
||||
continue
|
||||
|
||||
+14
-2
@@ -13,6 +13,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -254,7 +255,8 @@ def assign_subtitles_voronoi(
|
||||
entries: 已解析的 SRTEntry 列表。
|
||||
|
||||
副作用:
|
||||
直接修改每个 L3Node.subtitle 字段。
|
||||
通过 dataclasses.replace 替换 L3Node.card 和 L2Node.card,
|
||||
将字幕写入 card.subtitle 字段。
|
||||
|
||||
迁移来源:
|
||||
TRM3 tools/generate_subtitles.py compute_effective_ranges + assign_subtitles
|
||||
@@ -299,7 +301,17 @@ def assign_subtitles_voronoi(
|
||||
right = (ts + next_ts) / 2.0
|
||||
|
||||
subtitle_text = extract_subtitle_for_range(entries, (left, right))
|
||||
l3.subtitle = subtitle_text if subtitle_text else None
|
||||
l3.card = dataclasses.replace(
|
||||
l3.card,
|
||||
subtitle=subtitle_text or "",
|
||||
)
|
||||
|
||||
# L2 字幕聚合:拼接所有 L3 子节点的字幕
|
||||
l3_subtitles = [l3.card.subtitle for l3 in l2.children if l3.card.subtitle]
|
||||
l2.card = dataclasses.replace(
|
||||
l2.card,
|
||||
subtitle="\n".join(l3_subtitles),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Voronoi 字幕分配完成: {} 个 L1 节点, {} 条字幕条目",
|
||||
|
||||
+5
-4
@@ -97,8 +97,8 @@ def _collect_l3_text(l2_node: L2Node) -> str:
|
||||
for l3 in l2_node.children:
|
||||
parts.append(l3.card.frame_summary)
|
||||
parts.extend(l3.card.visible_text)
|
||||
if l3.subtitle:
|
||||
parts.append(l3.subtitle)
|
||||
if l3.card.subtitle:
|
||||
parts.append(l3.card.subtitle)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -139,8 +139,8 @@ def _collect_descendant_text_corpus(l1_node: L1Node) -> str:
|
||||
for l3 in l2.children:
|
||||
parts.append(l3.card.frame_summary)
|
||||
parts.extend(l3.card.visible_text)
|
||||
if l3.subtitle:
|
||||
parts.append(l3.subtitle)
|
||||
if l3.card.subtitle:
|
||||
parts.append(l3.card.subtitle)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@ def _verify_l2(l2: L2Node, stats: VerifyStats) -> None:
|
||||
visible_text=kept_vt,
|
||||
spatial_relations=old_card.spatial_relations,
|
||||
state_changes=old_card.state_changes,
|
||||
subtitle=old_card.subtitle,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
|
||||
并发模型(异步版)::
|
||||
|
||||
build() → asyncio.run(_build_async())
|
||||
_build_async():
|
||||
build() → asyncio.run(build_async())
|
||||
build_async():
|
||||
asyncio.Semaphore(concurrency) 控制最大 VLM/LLM 并发数
|
||||
各 L1 段并发构建,段内 L2 clip 各启动 _chain 协程:
|
||||
提取全部 L3 帧 → 采样 L2 代表帧 → L2 VLM → L3 VLM
|
||||
@@ -156,8 +156,8 @@ class VideoTreeBuilder:
|
||||
转化为三层 TreeIndex。
|
||||
|
||||
并发架构:
|
||||
build() 为同步壳,内部调用 asyncio.run(_build_async())。
|
||||
_build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM/LLM 数量。
|
||||
build() 为同步壳,内部调用 asyncio.run(build_async())。
|
||||
build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM/LLM 数量。
|
||||
所有 VLM 调用通过 VLMProvider 的异步接口发起,零线程阻塞。
|
||||
所有 LLM 调用通过 LLMProvider 的异步接口发起(L1 摘要)。
|
||||
ffmpeg 提帧在独立 ThreadPoolExecutor 中并行,不阻塞事件循环。
|
||||
@@ -166,6 +166,7 @@ class VideoTreeBuilder:
|
||||
_vlm: VLM 图文调用端口。
|
||||
_llm: LLM 文本调用端口(L1 摘要)。
|
||||
_config: 树构建配置。
|
||||
_api_semaphore: 外部注入的全局 API 并发信号量(None 时 build_async 自建)。
|
||||
_ffmpeg_pool: ffmpeg 专用线程池(max_workers=_FFMPEG_MAX_WORKERS)。
|
||||
"""
|
||||
|
||||
@@ -174,6 +175,8 @@ class VideoTreeBuilder:
|
||||
vlm: VLMProvider,
|
||||
llm: LLMProvider,
|
||||
config: TreeConfig,
|
||||
*,
|
||||
api_semaphore: asyncio.Semaphore | None = None,
|
||||
) -> None:
|
||||
"""初始化视频树构建器。
|
||||
|
||||
@@ -183,10 +186,14 @@ class VideoTreeBuilder:
|
||||
config: 树构建配置(TreeConfig),关键字段:
|
||||
l1_segment_duration, l2_clip_duration, l3_fps,
|
||||
l2_representative_frames, cache_dir, concurrency。
|
||||
api_semaphore: 外部注入的全局 VLM/LLM 并发信号量(批量建树时跨视频共享);
|
||||
None 时 build_async 内部按 config.concurrency 自建,
|
||||
单视频行为零变化。
|
||||
"""
|
||||
self._vlm = vlm
|
||||
self._llm = llm
|
||||
self._config = config
|
||||
self._api_semaphore = api_semaphore
|
||||
self._ffmpeg_pool = ThreadPoolExecutor(max_workers=_FFMPEG_MAX_WORKERS)
|
||||
self._cache_root = Path(self._config.cache_dir)
|
||||
self._session_id: str = ""
|
||||
@@ -296,13 +303,13 @@ class VideoTreeBuilder:
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
"""
|
||||
return asyncio.run(self._build_async(video_path, srt_entries))
|
||||
return asyncio.run(self.build_async(video_path, srt_entries))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 核心异步构建逻辑(保真算法 #1:L2→L3 链式触发)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _build_async(
|
||||
async def build_async(
|
||||
self,
|
||||
video_path: str,
|
||||
srt_entries: list[SRTEntry] | None = None,
|
||||
@@ -353,8 +360,12 @@ class VideoTreeBuilder:
|
||||
# Phase 1.1: 读取已有进度(保真算法 #3:断点续跑)
|
||||
finished_l1_ids = self._load_resume_state(source_id, total_l1)
|
||||
|
||||
# 创建 VLM/LLM 并发控制信号量
|
||||
vlm_sem = asyncio.Semaphore(self._config.concurrency)
|
||||
# 创建 VLM/LLM 并发控制信号量(外部注入时跨视频全局共享,Spec-2)
|
||||
vlm_sem = (
|
||||
self._api_semaphore
|
||||
if self._api_semaphore is not None
|
||||
else asyncio.Semaphore(self._config.concurrency)
|
||||
)
|
||||
|
||||
# Phase 2-5: 按 L1 段并发,段内 L2→L3 链式触发(保真算法 #1)
|
||||
async def _build_segment(
|
||||
@@ -480,7 +491,7 @@ class VideoTreeBuilder:
|
||||
)
|
||||
index = TreeIndex(metadata=metadata, roots=l1_nodes)
|
||||
|
||||
# Phase 7: 字幕 Voronoi 分配(可选)
|
||||
# Phase 7: 字幕 Voronoi 分配到 L3/L2 Card.subtitle(可选)
|
||||
if srt_entries:
|
||||
assign_subtitles_voronoi(index, srt_entries)
|
||||
logger.info("字幕 Voronoi 分配完成", n_entries=len(srt_entries))
|
||||
|
||||
+30
-4
@@ -20,15 +20,15 @@ embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cpu"
|
||||
device: "cuda"
|
||||
|
||||
# ── Harness 自进化循环 ──
|
||||
harness:
|
||||
workspace_dir: "workspaces/default"
|
||||
store_dir: store
|
||||
mode: infer
|
||||
concurrency: 12
|
||||
max_steps: 15
|
||||
concurrency: 24
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "benchmarks/Video-MME"
|
||||
@@ -42,7 +42,6 @@ harness:
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_block: 8
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
@@ -67,5 +66,32 @@ harness:
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
eval_min_per_class: 2
|
||||
trainable_min_units: 8
|
||||
early_stop_patience: 8
|
||||
use_slow_momentum: true
|
||||
# 池构建策略
|
||||
pool_split_mode: global # global | per_category
|
||||
train_ratio: 0.667 # per_category 模式下 train/(train+val) 比例
|
||||
test_questions: "benchmarks/Video-MME" # test 池的题目来源
|
||||
|
||||
# ── 出题管线 v2 ──
|
||||
question_gen_v2:
|
||||
family_ratios:
|
||||
retrieval: 0.30
|
||||
reasoning: 0.25
|
||||
enumeration: 0.20
|
||||
visual: 0.15
|
||||
spatial: 0.10
|
||||
gate:
|
||||
blind_answer_model: "gpt-4.1-mini"
|
||||
leak_test_model: "gpt-4.1-mini"
|
||||
key_verify_model: "gpt-4.1-mini"
|
||||
multi_true_model: "gpt-4.1-mini"
|
||||
dedup_threshold: 0.85
|
||||
retry_limit: 3
|
||||
heavy_sample_rate: 0.15
|
||||
heavy_agent_model: "gpt-4.1-mini"
|
||||
output_dir: "store/questions/generated-v2"
|
||||
per_type: 20 # 12 类 x 20 = 240 题(设计 §3 硬约束)
|
||||
concurrency: 4
|
||||
seed: 42
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# 补生 Video-MME 6 类缺失题型(180 题)
|
||||
# 原 360 题中有 6 类错误类型已归档,此配置只生成缺失的 6 类。
|
||||
|
||||
# ── 建树模块 ──(与 question_gen_360.yaml 一致)
|
||||
tree:
|
||||
max_paragraphs_per_l2: 5
|
||||
l1_segment_duration: 600.0
|
||||
l2_clip_duration: 60.0
|
||||
l3_fps: 0.5
|
||||
l2_representative_frames: 6
|
||||
cache_dir: "cache/trees"
|
||||
concurrency: 16
|
||||
subtitle_inject: true
|
||||
srt_window_sec: 5.0
|
||||
|
||||
# ── Embedding ──
|
||||
embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cuda"
|
||||
|
||||
# ── Harness ──(占位,出题不使用)
|
||||
harness:
|
||||
workspace_dir: "workspaces/default"
|
||||
store_dir: store
|
||||
mode: infer
|
||||
concurrency: 24
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "benchmarks/Video-MME"
|
||||
skills_version: v1
|
||||
prompts_version: v1
|
||||
epochs: 1
|
||||
gate_e_confirm: 20.0
|
||||
gate_e_provisional: 3.0
|
||||
gate_w_net_min: 2
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
gate_probe_quota: 0.2
|
||||
gate_gamma_decay: 0.9
|
||||
gate_cooldown_steps: 2
|
||||
gate_guard_err: 0.10
|
||||
edit_budget_start: 5
|
||||
edit_budget_end: 2
|
||||
skill_update_mode: patch
|
||||
appendix_consolidate_threshold: 6
|
||||
diag_size: 200
|
||||
diag_correct_ratio: 0.5
|
||||
val_size: 30
|
||||
val_correct_ratio: 0.5
|
||||
test_size: 60
|
||||
batch_size: 15
|
||||
min_class_per_batch: 2
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
eval_min_per_class: 2
|
||||
early_stop_patience: 8
|
||||
use_slow_momentum: true
|
||||
|
||||
# ── 出题管线 v2 ──
|
||||
question_gen_v2:
|
||||
family_ratios:
|
||||
retrieval: 0.30
|
||||
reasoning: 0.25
|
||||
enumeration: 0.20
|
||||
visual: 0.15
|
||||
spatial: 0.10
|
||||
dedup_threshold: 0.85
|
||||
retry_limit: 10
|
||||
heavy_sample_rate: 0.15
|
||||
output_dir: "store/questions/generated-v2-180补"
|
||||
per_type: 30 # 6 类 x 30 = 180 题
|
||||
concurrency: 24
|
||||
seed: 43 # 不同于原始 seed=42,避免生成相同题目
|
||||
@@ -0,0 +1,86 @@
|
||||
# config/default.yaml
|
||||
# 科研实验配置默认值来源(会在实验中反复扫动/对比的参数)。
|
||||
# 工程配置(少变、敏感)由 .env / pydantic-settings 管理,不在此文件。
|
||||
# 优先级: CLI args > 此文件。CLI 仅用于单次临时覆盖。
|
||||
|
||||
# ── 建树模块 ──
|
||||
tree:
|
||||
max_paragraphs_per_l2: 5
|
||||
l1_segment_duration: 600.0 # L1 段时长(秒)
|
||||
l2_clip_duration: 60.0 # L2 clip 时长(秒)
|
||||
l3_fps: 0.5 # L3 帧提取频率(帧/秒)
|
||||
l2_representative_frames: 6 # L2 VLM 描述用的代表帧数
|
||||
cache_dir: "cache/trees"
|
||||
concurrency: 16 # asyncio Semaphore 上限
|
||||
subtitle_inject: true # 建树时是否注入 SRT 字幕
|
||||
srt_window_sec: 5.0 # 字幕匹配时间窗口(前后各 N 秒)
|
||||
|
||||
# ── Embedding ──
|
||||
embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cuda"
|
||||
|
||||
# ── Harness 自进化循环 ──
|
||||
harness:
|
||||
workspace_dir: "workspaces/default"
|
||||
store_dir: store
|
||||
mode: infer
|
||||
concurrency: 24
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "benchmarks/Video-MME"
|
||||
skills_version: v1
|
||||
prompts_version: v1
|
||||
epochs: 1
|
||||
# CE-Gate 参数
|
||||
gate_e_confirm: 20.0
|
||||
gate_e_provisional: 3.0
|
||||
gate_w_net_min: 2
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
gate_probe_quota: 0.2
|
||||
gate_gamma_decay: 0.9
|
||||
gate_cooldown_steps: 2
|
||||
gate_guard_err: 0.10
|
||||
# 进化参数
|
||||
edit_budget_start: 5
|
||||
edit_budget_end: 2
|
||||
skill_update_mode: patch
|
||||
appendix_consolidate_threshold: 6
|
||||
# 数据池
|
||||
diag_size: 200
|
||||
diag_correct_ratio: 0.5
|
||||
val_size: 30
|
||||
val_correct_ratio: 0.5
|
||||
test_size: 60
|
||||
# mini-batch
|
||||
batch_size: 15
|
||||
min_class_per_batch: 2
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
eval_min_per_class: 2
|
||||
early_stop_patience: 8
|
||||
use_slow_momentum: true
|
||||
|
||||
# ── 出题管线 v2 ──
|
||||
question_gen_v2:
|
||||
family_ratios:
|
||||
retrieval: 0.30
|
||||
reasoning: 0.25
|
||||
enumeration: 0.20
|
||||
visual: 0.15
|
||||
spatial: 0.10
|
||||
dedup_threshold: 0.85
|
||||
retry_limit: 10
|
||||
heavy_sample_rate: 0.15
|
||||
output_dir: "store/questions/generated-v2-360"
|
||||
per_type: 30 # 12 类 x 30 = 360 题
|
||||
concurrency: 24
|
||||
seed: 42
|
||||
@@ -0,0 +1,21 @@
|
||||
# AR 专项出题配置:使用 ActionRecognitionStrategy + 6 SubPattern 靶向生成
|
||||
# 目标:生成 30 道 Action Recognition 题
|
||||
|
||||
question_gen_v2:
|
||||
per_type: 30 # 只跑 AR 一类,30 题
|
||||
retry_limit: 15 # AR 约束更严,给更多重试机会
|
||||
heavy_sample_rate: 0.0 # 不需要重量抽检
|
||||
dedup_threshold: 0.85
|
||||
concurrency: 8 # AR 需要帧,适度并发
|
||||
seed: 2024
|
||||
output_dir: "store/questions/generated-ar30"
|
||||
candidate_pool_size: 24 # grounded selector 首轮候选干扰项数 N
|
||||
selector_delta_low: 0.05 # 干扰项视觉分与正解的最小差(区间上界)
|
||||
selector_delta_high: 0.35 # 干扰项视觉分与正解的最大差(区间下界)
|
||||
|
||||
# Phase B 后置对抗过滤层配置(filter 层,非 strategy 属性)
|
||||
adversarial_filter:
|
||||
filter_task_types: [Action Recognition] # 仅这些题型走 agent 门(路径隔离开关)
|
||||
adversarial_max_rounds: 5 # 补生成迭代上限
|
||||
adversarial_agent_max_steps: 40 # agent 试答步数上限
|
||||
difficulty_warn_threshold: 0.85 # 批次 agent 正确率告警阈值
|
||||
@@ -0,0 +1,63 @@
|
||||
# config/train_action_recognition.yaml
|
||||
# Action Recognition 单题型首次训练实验
|
||||
# 设计文档: research-wiki/designs/2026-07-14-action-recognition-training-design.md
|
||||
|
||||
harness:
|
||||
workspace_dir: "workspaces/train-action-recognition"
|
||||
store_dir: store
|
||||
mode: train
|
||||
run_id: train_ar_v1
|
||||
concurrency: 24
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "generated-v2-360"
|
||||
skills_version: v1
|
||||
prompts_version: v1
|
||||
epochs: 3
|
||||
# CE-Gate 参数(沿用 default.yaml)
|
||||
gate_e_confirm: 20.0
|
||||
gate_e_provisional: 3.0
|
||||
gate_w_net_min: 2
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
gate_probe_quota: 0.2
|
||||
gate_gamma_decay: 0.9
|
||||
gate_cooldown_steps: 2
|
||||
gate_guard_err: 0.10
|
||||
# 进化参数
|
||||
edit_budget_start: 5
|
||||
edit_budget_end: 2
|
||||
skill_update_mode: patch
|
||||
appendix_consolidate_threshold: 6
|
||||
# 池配置 — per_category 单题型
|
||||
pool_split_mode: per_category
|
||||
task_types:
|
||||
- "Action Recognition"
|
||||
train_ratio: 0.667
|
||||
test_questions: "benchmarks/Video-MME"
|
||||
run_holdout_eval: false
|
||||
# mini-batch
|
||||
batch_size: 10
|
||||
min_class_per_batch: 2
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
eval_min_per_class: 2
|
||||
trainable_min_units: 8
|
||||
early_stop_patience: 4
|
||||
test_size: 63
|
||||
diag_size: 20
|
||||
diag_correct_ratio: 0.5
|
||||
val_size: 10
|
||||
val_correct_ratio: 0.5
|
||||
use_slow_momentum: true
|
||||
|
||||
embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cuda"
|
||||
@@ -0,0 +1,64 @@
|
||||
# config/train_ar30.yaml
|
||||
# Action Recognition 训练 — 基于 SubPattern 靶向生成的 30 题
|
||||
# 对比基线: v2-360 的 AR 题(100% 单帧,训练无效)
|
||||
# 本次: AR30 题(6 种失败子模式靶向,跨段时序)
|
||||
|
||||
harness:
|
||||
workspace_dir: "workspaces/train-ar30"
|
||||
store_dir: store
|
||||
mode: train
|
||||
run_id: train_ar30_v1
|
||||
concurrency: 24
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "generated-ar30"
|
||||
skills_version: v1
|
||||
prompts_version: v1
|
||||
epochs: 3
|
||||
# CE-Gate 参数(沿用 default.yaml)
|
||||
gate_e_confirm: 20.0
|
||||
gate_e_provisional: 3.0
|
||||
gate_w_net_min: 2
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
gate_probe_quota: 0.2
|
||||
gate_gamma_decay: 0.9
|
||||
gate_cooldown_steps: 2
|
||||
gate_guard_err: 0.10
|
||||
# 进化参数
|
||||
edit_budget_start: 5
|
||||
edit_budget_end: 2
|
||||
skill_update_mode: patch
|
||||
appendix_consolidate_threshold: 6
|
||||
# 池配置 — per_category 单题型
|
||||
pool_split_mode: per_category
|
||||
task_types:
|
||||
- "Action Recognition"
|
||||
train_ratio: 0.667
|
||||
test_questions: "benchmarks/Video-MME"
|
||||
run_holdout_eval: false
|
||||
# mini-batch
|
||||
batch_size: 10
|
||||
min_class_per_batch: 2
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
eval_min_per_class: 2
|
||||
trainable_min_units: 8
|
||||
early_stop_patience: 4
|
||||
test_size: 63
|
||||
diag_size: 20
|
||||
diag_correct_ratio: 0.5
|
||||
val_size: 10
|
||||
val_correct_ratio: 0.5
|
||||
use_slow_momentum: true
|
||||
|
||||
embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cuda"
|
||||
@@ -0,0 +1,69 @@
|
||||
# config/train_videomme.yaml
|
||||
# Video-MME 900 题自进化训练 —— 消费 video-split 冻结切分(global 三池)
|
||||
#
|
||||
# 数据来源: workspaces/video-split/pools.json(tier 感知 diag/val + val 功效修复)
|
||||
# 经 adhoc-baseline seed 携带进 workspace(WP2 接线)。
|
||||
# 训练前置: .env REDIS_CACHE_TTL 须为正整数(WP4 fail-loud);见
|
||||
# research-wiki/reviews/2026-07-16-preflight-final-review.md runbook。
|
||||
|
||||
harness:
|
||||
workspace_dir: "workspaces/train-videomme"
|
||||
store_dir: store
|
||||
mode: train
|
||||
run_id: train_videomme_v2
|
||||
concurrency: 32
|
||||
max_steps: 40
|
||||
skill_mode: auto
|
||||
n_samples: 0
|
||||
questions: "benchmarks/Video-MME" # gate 指纹依赖加载全 900 题
|
||||
skills_version: v1
|
||||
prompts_version: v1
|
||||
epochs: 3
|
||||
# CE-Gate 参数(沿用 default.yaml)
|
||||
gate_e_confirm: 20.0
|
||||
gate_e_provisional: 3.0
|
||||
gate_w_net_min: 2
|
||||
gate_delta_min: 0.02
|
||||
gate_lambda_dir: -0.642
|
||||
gate_e_rollback: 10.0
|
||||
gate_n_max: 40
|
||||
gate_p_low: 0.05
|
||||
gate_p_high: 0.95
|
||||
gate_probe_quota: 0.2
|
||||
gate_gamma_decay: 0.9
|
||||
gate_cooldown_steps: 2
|
||||
gate_guard_err: 0.10
|
||||
# 进化参数
|
||||
edit_budget_start: 5
|
||||
edit_budget_end: 2
|
||||
skill_update_mode: patch
|
||||
appendix_consolidate_threshold: 6
|
||||
# 池配置 —— global 冻结切分(diag/val/test 尺寸由 pools.json 冻结,以下采样旋钮加载时忽略)
|
||||
# 实际冻结切分(0.4+tier,fingerprint d456ef4f): diag=180 val=120 test=600
|
||||
pool_split_mode: global
|
||||
diag_size: 180
|
||||
diag_correct_ratio: 0.5
|
||||
val_size: 120
|
||||
val_correct_ratio: 0.5
|
||||
test_size: 600
|
||||
test_questions: "benchmarks/Video-MME"
|
||||
# 可训练性预检(WP3):val 单元 < eval_min_per_class 或 非test单元 < trainable_min_units 的题型剔除
|
||||
eval_min_per_class: 2
|
||||
trainable_min_units: 8
|
||||
# mini-batch —— 对齐 TRM4 正式实验 batch=40(sh --batch-size 40 覆盖 yaml 15 的最终生效值):
|
||||
# 8 可训题型 × 每型约 5 题/step,保住题型级诊断信号;同时 steps/epoch 180/40≈5,
|
||||
# 进化/gate 验证轮数比 batch=10 少 4 倍。
|
||||
batch_size: 40
|
||||
min_class_per_batch: 2
|
||||
batch_correct_ratio: 0.5
|
||||
momentum_samples: 20
|
||||
early_stop_patience: 2 # epoch 粒度(WP3):连续 2 epoch 无 best 刷新即停
|
||||
use_slow_momentum: true
|
||||
run_holdout_eval: true # 逐 epoch test 四向评估(WP3 去重版:baseline 推导 + 版本备忘录)
|
||||
# 全 12 题型(不指定 task_types 子集,避免 I-4 语义偏差;微型类由预检自动剔除)
|
||||
|
||||
embed:
|
||||
backend: "local"
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768
|
||||
device: "cuda"
|
||||
@@ -0,0 +1,33 @@
|
||||
# config/video_split.yaml
|
||||
# 结果驱动视频级切分 —— 科研旋钮快照(会在实验中被反复扫动 / 对比的参数)。
|
||||
# 工程配置(harness.db 路径、LLM 凭证、超时)由 .env / pydantic-settings 管理,不在此文件。
|
||||
# 优先级: CLI args > 此文件。CLI 仅用于单次临时覆盖。
|
||||
#
|
||||
# ⚠️ 下列阈值为「占位值」,必须在离线诊断跑完后用真实 T2 分布标定。
|
||||
# 标定程序见 scripts/build_video_split.sh 顶部注释(诊断产物 → 逐旋钮定值)。
|
||||
|
||||
video_split:
|
||||
baseline_run_id: infer_adhoc # 基线 run 标识(错题诊断与切分依据),对应 workspaces/default/harness.db
|
||||
n_trainval: 100 # trainval 目标视频数(多样性阶段填充上限;标定取 ~100)
|
||||
epsilon: 0.1 # test 相对全局最大允许分布偏差(题型占比 / 难度画像两维,逐桶)
|
||||
report_floor: 27 # per-type 报告门限:题数 ≥ 此值的 task_type 才入 ε 代表性约束
|
||||
val_wrong_min: 20 # validation 池最少错题数(McNemar 检验功效阈 ≈ 20,低于则信号不足)
|
||||
val_ratio: 0.4 # validation 占 trainval 视频组总数的比例(0.3→0.4 提升整包终审功效,WP2)
|
||||
seed: 7 # 贪心选择器预洗牌 + 视频组题级切分种子(打破等增益 / 等槽平局)
|
||||
floor_k: # 各 task_type 的 T2 defect 下限(硬约束)—— 均衡覆盖全 11 类,标定于 1cb1c203 真实 T2 分布
|
||||
Object Reasoning: 5 # T2=25
|
||||
Information Synopsis: 5 # T2=13
|
||||
Action Reasoning: 5 # T2=11
|
||||
Counting Problem: 5 # T2=10
|
||||
Temporal Reasoning: 2 # T2=5
|
||||
Object Recognition: 2 # T2=5
|
||||
Action Recognition: 2 # T2=5
|
||||
Attribute Perception: 1 # T2=3
|
||||
Temporal Perception: 1 # T2=2
|
||||
OCR Problems: 1 # T2=2
|
||||
Spatial Perception: 1 # T2=1
|
||||
|
||||
diag: # 诊断口径指纹三分量(隔离不同诊断配置的信号,参与主键)
|
||||
prompt_version: diagnose_v1 # 诊断 prompt 版本标识(换 prompt 即换指纹,旧记录不被覆盖)
|
||||
model: deepseek-v4-pro # 执行诊断的模型名(必须与 .env SEARCH_LLM_MODEL 一致,CLI 会 fail loud 校验)
|
||||
# code_version 由 build_video_split.sh 注入 git 短 SHA,不写死在此(随代码变动)
|
||||
+108
-9
@@ -17,7 +17,9 @@ TRM4 → TRM5 有意变更(非简化):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pluggy
|
||||
@@ -31,6 +33,9 @@ if TYPE_CHECKING:
|
||||
from core.protocols import LLMProvider
|
||||
from core.types import LLMResponse
|
||||
|
||||
# deepseek 等模型稳定输出变体:```json 围栏包裹 JSON 体
|
||||
_CODE_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*\n?|\n?\s*```\s*$")
|
||||
|
||||
|
||||
async def _call_hook(hook: Any, **kwargs: Any) -> list[Any]:
|
||||
"""调用 pluggy hook 并 await 异步返回值。
|
||||
@@ -67,6 +72,13 @@ class AgentLoop:
|
||||
llm: LLMProvider 实例(Protocol 类型化,提供 async chat 方法)。
|
||||
max_steps: 最大有效步数(每次成功工具调用计一步)。
|
||||
max_retries: JSON 解析连续失败的最大容忍次数。
|
||||
step_retries: LLM 瞬时异常的步级重试次数(不含首次调用)。
|
||||
step_retry_delays: 步级重试的退避秒数序列,超出部分取末值;
|
||||
不得为空(空序列构造时抛 ValueError,fail-fast)。
|
||||
retryable_exceptions: 可重试异常元组,默认 (TimeoutError, OSError)——
|
||||
ssl.SSLError/ConnectionError 均为 OSError 子类,覆盖穿透
|
||||
GovernedLLMClient 内部重试栈的瞬时异常;openai API 类异常由
|
||||
治理层负责,core 不依赖 SDK。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -74,10 +86,19 @@ class AgentLoop:
|
||||
llm: LLMProvider,
|
||||
max_steps: int,
|
||||
max_retries: int = 3,
|
||||
*,
|
||||
step_retries: int = 2,
|
||||
step_retry_delays: tuple[float, ...] = (20.0, 40.0),
|
||||
retryable_exceptions: tuple[type[BaseException], ...] = (TimeoutError, OSError),
|
||||
) -> None:
|
||||
if not step_retry_delays:
|
||||
raise ValueError("step_retry_delays 不得为空")
|
||||
self._llm = llm
|
||||
self._max_steps = max_steps
|
||||
self._max_retries = max_retries
|
||||
self._step_retries = step_retries
|
||||
self._step_retry_delays = step_retry_delays
|
||||
self._retryable_exceptions = retryable_exceptions
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -87,6 +108,7 @@ class AgentLoop:
|
||||
plugins: list[object] | None = None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LoopResult:
|
||||
"""执行 Thinking+JSON 推理循环。
|
||||
|
||||
@@ -96,6 +118,7 @@ class AgentLoop:
|
||||
tool_dispatcher: 工具调度器,ToolDispatcher Protocol 实例。
|
||||
plugins: pluggy 插件列表。
|
||||
session_id: 会话 ID,透传给 LLMProvider。
|
||||
cache_salt: 缓存盐,透传给 LLMProvider(如训练用 run_id 跨 epoch 重采样)。
|
||||
|
||||
返回:
|
||||
LoopResult 实例,包含推理步骤、token 用量、终止原因。
|
||||
@@ -114,11 +137,13 @@ class AgentLoop:
|
||||
while step_count < self._max_steps:
|
||||
await _call_hook(pm.hook.before_step, iteration=iteration, messages=messages)
|
||||
|
||||
# Phase 1: LLM 调用
|
||||
# Phase 1: LLM 调用(步级重试:防穿透 GovernedLLMClient 的瞬时异常)
|
||||
try:
|
||||
response = await self._call_llm(messages, token_usage, session_id=session_id)
|
||||
response = await self._call_llm_with_step_retry(
|
||||
messages, token_usage, session_id=session_id, cache_salt=cache_salt
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("LLM API 调用失败: {}", e)
|
||||
logger.error("LLM API 调用失败({}): {}", type(e).__name__, e)
|
||||
result = LoopResult(
|
||||
steps=steps,
|
||||
steps_used=step_count,
|
||||
@@ -240,14 +265,21 @@ class AgentLoop:
|
||||
pm.register(plugin)
|
||||
return pm
|
||||
|
||||
async def _call_llm(
|
||||
async def _call_llm_with_step_retry(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
token_usage: dict[str, int],
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse:
|
||||
"""调用 LLMProvider 并累加 token 使用量。
|
||||
"""带步级重试的 LLM 调用,兜底穿透治理层重试栈的瞬时异常。
|
||||
|
||||
可重试异常(self._retryable_exceptions)按 self._step_retry_delays
|
||||
退避后重发,重试预算(self._step_retries)耗尽后抛出最后一次异常;
|
||||
非可重试异常直接穿透(fail-fast)。asyncio.CancelledError 继承
|
||||
BaseException,天然不被捕获——取消信号照常传播。失败尝试的 error
|
||||
遥测由 GovernedLLMClient 内部负责,此处仅 loguru 记录。
|
||||
|
||||
参数:
|
||||
messages: 消息历史。
|
||||
@@ -256,8 +288,52 @@ class AgentLoop:
|
||||
|
||||
返回:
|
||||
LLMResponse 实例。
|
||||
|
||||
异常:
|
||||
重试耗尽或非可重试时,原样抛出底层异常。
|
||||
"""
|
||||
response = await self._llm.chat(messages, session_id=session_id)
|
||||
step_attempt = 0
|
||||
while True:
|
||||
try:
|
||||
return await self._call_llm(
|
||||
messages, token_usage, session_id=session_id, cache_salt=cache_salt
|
||||
)
|
||||
except self._retryable_exceptions as e:
|
||||
step_attempt += 1
|
||||
if step_attempt > self._step_retries:
|
||||
raise
|
||||
delay = self._step_retry_delays[
|
||||
min(step_attempt - 1, len(self._step_retry_delays) - 1)
|
||||
]
|
||||
logger.warning(
|
||||
"LLM 瞬时异常,步级重试 {}/{}({}s 后重发): {}",
|
||||
step_attempt,
|
||||
self._step_retries,
|
||||
delay,
|
||||
e,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _call_llm(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
token_usage: dict[str, int],
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse:
|
||||
"""调用 LLMProvider 并累加 token 使用量。
|
||||
|
||||
参数:
|
||||
messages: 消息历史。
|
||||
token_usage: 可变字典,就地累加。
|
||||
session_id: 会话 ID,透传给 LLMProvider。
|
||||
cache_salt: 缓存盐,透传给 LLMProvider(跨 epoch 重采样)。
|
||||
|
||||
返回:
|
||||
LLMResponse 实例。
|
||||
"""
|
||||
response = await self._llm.chat(messages, session_id=session_id, cache_salt=cache_salt)
|
||||
token_usage["prompt_tokens"] += response.prompt_tokens
|
||||
token_usage["completion_tokens"] += response.completion_tokens
|
||||
return response
|
||||
@@ -267,7 +343,8 @@ class AgentLoop:
|
||||
) -> tuple[str, dict, dict, str, dict, str] | None:
|
||||
"""从 LLMResponse 中提取结构化决策数据。
|
||||
|
||||
解析流程: content → repair_json → json.loads → 校验 action/tool/args。
|
||||
解析流程: content → 剥除 ```json 围栏 → repair_json → json.loads
|
||||
→ 收拢 action 平铺参数 → 校验 action/tool/args。
|
||||
|
||||
参数:
|
||||
response: LLMResponse 实例。
|
||||
@@ -282,7 +359,7 @@ class AgentLoop:
|
||||
if not content.strip():
|
||||
return None
|
||||
|
||||
repaired = repair_json(content)
|
||||
repaired = repair_json(_CODE_FENCE_RE.sub("", content).strip())
|
||||
try:
|
||||
data = json.loads(repaired)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
@@ -291,7 +368,7 @@ class AgentLoop:
|
||||
if not isinstance(data, dict) or "action" not in data:
|
||||
return None
|
||||
|
||||
action = data["action"]
|
||||
action = self._normalize_action(data["action"])
|
||||
if not isinstance(action, dict) or "tool" not in action or "args" not in action:
|
||||
return None
|
||||
|
||||
@@ -299,6 +376,28 @@ class AgentLoop:
|
||||
plan = data.get("plan", {})
|
||||
return thought, reflect, plan, content, action, response.call_id
|
||||
|
||||
@staticmethod
|
||||
def _normalize_action(action: Any) -> Any:
|
||||
"""收拢 deepseek 变体的 action 平铺参数。
|
||||
|
||||
deepseek 等模型稳定输出变体: 工具参数平铺在 action 下(缺 args
|
||||
嵌套),确定性收拢为标准 {"tool": ..., "args": {...}} 结构。
|
||||
仅当除 tool 外至少存在一个平铺参数键时才收拢;无参结构
|
||||
(如 {"tool": "x"})原样返回交由调用方校验拒绝,避免把缺参
|
||||
错误静默升级为空 args 合法结构。标准嵌套与非法结构同样原样返回。
|
||||
|
||||
参数:
|
||||
action: 从 LLM 输出解析出的 action 字段(任意类型)。
|
||||
|
||||
返回:
|
||||
归一化后的 action(仅带平铺参数的变体被改写,其余原样返回)。
|
||||
"""
|
||||
if isinstance(action, dict) and "tool" in action and "args" not in action:
|
||||
flat_args = {k: v for k, v in action.items() if k != "tool"}
|
||||
if flat_args:
|
||||
return {"tool": action["tool"], "args": flat_args}
|
||||
return action
|
||||
|
||||
async def _execute_tool(
|
||||
self,
|
||||
dispatcher: ToolDispatcher,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
只依赖 Protocol 接口和标准库,可搬到无 adapters 的环境用假实现原样运行。
|
||||
"""
|
||||
|
||||
from core.evolution.diagnose import run_diagnosis
|
||||
from core.evolution.diagnose import INFRA_STOP_REASONS, run_diagnosis
|
||||
from core.evolution.evolve import (
|
||||
edit_budget_at,
|
||||
evolve_single_skill,
|
||||
@@ -44,6 +44,7 @@ from core.evolution.types import (
|
||||
from core.evolution.validate import classify_quadrants, compute_accuracy, pair_block
|
||||
|
||||
__all__ = [
|
||||
"INFRA_STOP_REASONS",
|
||||
"CaseSample",
|
||||
"DiagnosePrompts",
|
||||
"DiagnosisResult",
|
||||
|
||||
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
|
||||
_SPAN_EVAL_TOOLS: frozenset[str] = frozenset({"view_node", "search_similar", "observe_frame"})
|
||||
"""span 级评估涵盖的工具集合。"""
|
||||
|
||||
_INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
|
||||
INFRA_STOP_REASONS: frozenset[str] = frozenset({"error", "parse_error"})
|
||||
"""执行/解析层失败导致排除的 stop_reason 集合。"""
|
||||
|
||||
|
||||
@@ -1489,12 +1489,15 @@ def _build_skill_case_packs(
|
||||
if qm.correct:
|
||||
continue
|
||||
attr = attribution_map.get(qm.question_id)
|
||||
if attr is not None and attr.cause_category == "lapse":
|
||||
if attr.lapse_note and attr.lapse_note.strip():
|
||||
# 仅明确 defect 且非 degraded 才进正文进化路径;
|
||||
# lapse / cause_category=None(判别失败)/ degraded(judge 解析失败)一律保守走 lapse,
|
||||
# 不以降级或未判定信号驱动错误进化。
|
||||
is_defect = attr is not None and attr.cause_category == "defect" and not qm.degraded
|
||||
if not is_defect:
|
||||
if attr is not None and attr.lapse_note and attr.lapse_note.strip():
|
||||
lapse_notes.append(attr.lapse_note)
|
||||
continue
|
||||
et = attr.error_type if attr else "mixed"
|
||||
wrong_by_error[et].append(qm)
|
||||
wrong_by_error[attr.error_type].append(qm)
|
||||
|
||||
# 单条 fallback
|
||||
n_body_failures = sum(len(group) for group in wrong_by_error.values())
|
||||
@@ -2004,7 +2007,7 @@ def _count_infra_excluded(
|
||||
qids = [
|
||||
row["question_id"]
|
||||
for row in prediction_rows
|
||||
if row.get("stop_reason") in _INFRA_STOP_REASONS
|
||||
if row.get("stop_reason") in INFRA_STOP_REASONS
|
||||
]
|
||||
return len(qids), qids
|
||||
|
||||
@@ -2080,7 +2083,7 @@ async def run_diagnosis(
|
||||
|
||||
for row in all_predictions:
|
||||
stop_reason = row.get("stop_reason")
|
||||
if stop_reason in _INFRA_STOP_REASONS:
|
||||
if stop_reason in INFRA_STOP_REASONS:
|
||||
continue
|
||||
if task_type_filter and row.get("task_type") not in task_type_filter:
|
||||
continue
|
||||
@@ -2142,7 +2145,15 @@ async def run_diagnosis(
|
||||
key = (prediction.get("video_id", ""), prediction.get("question_id", ""))
|
||||
traces = traces_by_question.get(key, [])
|
||||
vid = prediction.get("video_id", "")
|
||||
td = tree_data_by_video.get(vid, {})
|
||||
if vid not in tree_data_by_video:
|
||||
qid = prediction.get("question_id", "")
|
||||
# P5 fail-loud:诊断需真实树,调用方须为每个诊断视频加载 tree_data;
|
||||
# 静默回退空树会让 ground_truth 恒空、error_type 归因坍缩(本次修复的根因)。
|
||||
raise ValueError(
|
||||
f"诊断视频树未覆盖: video_id={vid!r} question_id={qid!r} 不在注入的 tree_data 中"
|
||||
"(调用方须为每个诊断视频加载树,P5 fail loud)"
|
||||
)
|
||||
td = tree_data_by_video[vid]
|
||||
skill_content = skill_cache.get(prediction.get("task_type", ""), "")
|
||||
|
||||
try:
|
||||
|
||||
@@ -19,6 +19,8 @@ from loguru import logger
|
||||
from core.evolution.patch import (
|
||||
APPENDIX_END,
|
||||
APPENDIX_START,
|
||||
MOMENTUM_END,
|
||||
MOMENTUM_START,
|
||||
append_to_appendix,
|
||||
apply_patch_with_report,
|
||||
extract_appendix_notes,
|
||||
@@ -293,10 +295,39 @@ def _tool_protected_spans(text: str) -> list[str]:
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _check_marker_integrity(evolved: str) -> list[str]:
|
||||
"""校验 evolved 中冻结区 marker 的完整性(成对、至多一对、START 先于 END)。
|
||||
|
||||
进化写入可能破坏 appendix/momentum marker 配对,破坏后 append_to_appendix /
|
||||
replace_momentum 等下游会静默误拼或抛错。此处集中拦截:任一 marker 对违反
|
||||
「START 数==END 数、各至多一对、START 在 END 前」即整体 reject。
|
||||
|
||||
参数:
|
||||
evolved: 改写后的全文。
|
||||
返回:
|
||||
错误信息列表(空列表表示 marker 完整)。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for name, start_m, end_m in (
|
||||
("APPENDIX", APPENDIX_START, APPENDIX_END),
|
||||
("MOMENTUM", MOMENTUM_START, MOMENTUM_END),
|
||||
):
|
||||
s = evolved.count(start_m)
|
||||
e = evolved.count(end_m)
|
||||
if s != e:
|
||||
errors.append(f"{name} marker 不配对:START={s} END={e}")
|
||||
elif s > 1:
|
||||
errors.append(f"{name} marker 出现多对({s}),至多一对")
|
||||
elif s == 1 and evolved.index(start_m) > evolved.index(end_m):
|
||||
errors.append(f"{name} marker 顺序错误:END 出现在 START 之前")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_skill(original: str, evolved: str) -> ValidationResult:
|
||||
"""校验 Skill 改写结果。
|
||||
|
||||
检查项: frontmatter 三字段保留(name / description / task_type)、
|
||||
marker 完整性(appendix/momentum 成对且至多一对、顺序正确)、
|
||||
长度比在 [0.3, 2.0]、代码块闭合。
|
||||
|
||||
参数:
|
||||
@@ -305,6 +336,11 @@ def validate_skill(original: str, evolved: str) -> ValidationResult:
|
||||
|
||||
返回:
|
||||
ValidationResult 实例。
|
||||
|
||||
关键实现细节:
|
||||
marker 完整性先于长度校验——长度校验经 _strip_protected_regions 调用
|
||||
momentum_region_bounds,对损坏 marker 会抛 ValueError;故 marker 破坏时先
|
||||
返回失败,避免异常穿透且明确 reject 该候选。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
orig_fm = _parse_frontmatter(original)
|
||||
@@ -319,6 +355,10 @@ def validate_skill(original: str, evolved: str) -> ValidationResult:
|
||||
errors.append(
|
||||
f"frontmatter 字段 {key} 被修改: {orig_fm.get(key)!r} → {evol_fm.get(key)!r}"
|
||||
)
|
||||
marker_errors = _check_marker_integrity(evolved)
|
||||
if marker_errors:
|
||||
errors.extend(marker_errors)
|
||||
return ValidationResult(passed=False, errors=errors)
|
||||
errors.extend(_check_length(original, evolved))
|
||||
errors.extend(_check_code_blocks(evolved))
|
||||
return ValidationResult(passed=len(errors) == 0, errors=errors)
|
||||
|
||||
+47
-19
@@ -15,9 +15,7 @@ APPENDIX_MAX_CHARS = 2000 # appendix 区软上限(守设计「长度上限+wa
|
||||
MOMENTUM_START = "<!-- MOMENTUM_START -->"
|
||||
MOMENTUM_END = "<!-- MOMENTUM_END -->"
|
||||
MOMENTUM_MAX_CHARS = 2000 # momentum 区软上限(与 appendix 一致:超限 warning 不截断)
|
||||
MOMENTUM_HEADING = (
|
||||
"## 动量指导(每轮重写,勿手改)" # replace_momentum 写入的固定标题行
|
||||
)
|
||||
MOMENTUM_HEADING = "## 动量指导(每轮重写,勿手改)" # replace_momentum 写入的固定标题行
|
||||
|
||||
|
||||
def momentum_region_bounds(text: str) -> tuple[int, int] | None:
|
||||
@@ -284,9 +282,34 @@ def _protected_ranges(content: str, spans: list[str]) -> list[tuple[int, int]]:
|
||||
return ranges
|
||||
|
||||
|
||||
def _in_ranges(pos: int, ranges: list[tuple[int, int]]) -> bool:
|
||||
"""判断位置 pos 是否落在任意冻结区间内。"""
|
||||
return any(start <= pos < end for start, end in ranges)
|
||||
def _span_overlaps_ranges(pos: int, length: int, ranges: list[tuple[int, int]]) -> bool:
|
||||
"""判断 [pos, pos+length) 是否与任一冻结区间相交(不止起点)。
|
||||
|
||||
起点落在正文、末端伸入冻结区的 target 也须拦截,否则 replace/delete 会连带
|
||||
改动冻结区(如破坏 appendix/momentum marker)。
|
||||
|
||||
参数:
|
||||
pos: target 在正文中的起点。
|
||||
length: target 长度。
|
||||
ranges: 冻结区间 [start, end) 列表。
|
||||
返回:
|
||||
与任一区间相交返回 True。
|
||||
"""
|
||||
end = pos + length
|
||||
return any(start < end and pos < r_end for start, r_end in ranges)
|
||||
|
||||
|
||||
# 冻结区 marker 字面量:LLM 生成的 edit 不得注入这些字面量,否则破坏 marker 配对
|
||||
_MARKER_LITERALS = (APPENDIX_START, APPENDIX_END, MOMENTUM_START, MOMENTUM_END)
|
||||
|
||||
|
||||
def _edit_injects_marker(edit: dict) -> bool:
|
||||
"""判断 edit 的 target/content 是否含冻结区 marker 字面量(注入拦截)。"""
|
||||
for key in ("target", "content"):
|
||||
value = edit.get(key)
|
||||
if isinstance(value, str) and any(m in value for m in _MARKER_LITERALS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _append_at(content: str, ranges: list[tuple[int, int]]) -> int:
|
||||
@@ -303,9 +326,7 @@ def _insert_at(content: str, at: int, payload: str) -> str:
|
||||
return head + "\n\n" + payload + "\n"
|
||||
|
||||
|
||||
def _do_append(
|
||||
content: str, payload: str, ranges: list[tuple[int, int]]
|
||||
) -> tuple[str, str]:
|
||||
def _do_append(content: str, payload: str, ranges: list[tuple[int, int]]) -> tuple[str, str]:
|
||||
"""执行 append 操作,返回更新后内容与状态字符串。"""
|
||||
return _insert_at(content, _append_at(content, ranges), payload), "applied_append"
|
||||
|
||||
@@ -321,8 +342,8 @@ def _do_insert_after(
|
||||
_insert_at(content, _append_at(content, ranges), payload),
|
||||
"applied_insert_after_fallback",
|
||||
)
|
||||
if _in_ranges(pos, ranges):
|
||||
logger.warning("insert_after 目标在冻结区,跳过 target={}", target[:80])
|
||||
if _span_overlaps_ranges(pos, len(target), ranges):
|
||||
logger.warning("insert_after 目标跨入冻结区,跳过 target={}", target[:80])
|
||||
return content, "skipped_protected"
|
||||
at = pos + len(target)
|
||||
nl = content.find("\n", at)
|
||||
@@ -344,16 +365,14 @@ def _do_replace_delete(
|
||||
if pos == -1:
|
||||
logger.warning("{} 锚点缺失,跳过 target={}", op, target[:80])
|
||||
return content, "skipped_target_not_found"
|
||||
if _in_ranges(pos, ranges):
|
||||
logger.warning("{} 目标在冻结区,跳过 target={}", op, target[:80])
|
||||
if _span_overlaps_ranges(pos, len(target), ranges):
|
||||
logger.warning("{} 目标跨入冻结区,跳过 target={}", op, target[:80])
|
||||
return content, "skipped_protected"
|
||||
new_content = content.replace(target, payload if op == "replace" else "", 1)
|
||||
return new_content, "applied_" + op
|
||||
|
||||
|
||||
def _apply_one(
|
||||
content: str, edit: dict, ranges: list[tuple[int, int]]
|
||||
) -> tuple[str, dict]:
|
||||
def _apply_one(content: str, edit: dict, ranges: list[tuple[int, int]]) -> tuple[str, dict]:
|
||||
"""应用单条 edit,返回 (更新后内容, 状态报告)。"""
|
||||
if not isinstance(edit, dict):
|
||||
return content, {
|
||||
@@ -382,9 +401,7 @@ def _apply_one(
|
||||
return content, report
|
||||
|
||||
if op in ("replace", "delete"):
|
||||
content, report["status"] = _do_replace_delete(
|
||||
op, content, target, payload, ranges
|
||||
)
|
||||
content, report["status"] = _do_replace_delete(op, content, target, payload, ranges)
|
||||
return content, report
|
||||
|
||||
logger.warning("未知 op,跳过: {}", op)
|
||||
@@ -411,6 +428,17 @@ def apply_patch_with_report(
|
||||
reports: list[dict] = []
|
||||
for i, edit in enumerate(edits, 1):
|
||||
try:
|
||||
if isinstance(edit, dict) and _edit_injects_marker(edit):
|
||||
logger.warning("edit 含冻结区 marker 字面量,拒绝该 edit index={}", i)
|
||||
report = {
|
||||
"op": str(edit.get("op", "")),
|
||||
"target": str(edit.get("target", "") or "")[:200],
|
||||
"content_preview": str(edit.get("content", "") or "")[:200],
|
||||
"status": "skipped_marker_injection",
|
||||
}
|
||||
report["index"] = i
|
||||
reports.append(report)
|
||||
continue
|
||||
ranges = _protected_ranges(content, spans)
|
||||
content, report = _apply_one(content, edit, ranges)
|
||||
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""core/evolution/ 子包的只读 Protocol 定义。
|
||||
"""core/evolution/ 子包的持久化 Protocol 定义。
|
||||
|
||||
三个 Protocol 均为只读——core/ 返回结果 dataclass,写入由 app/ 持久化。
|
||||
SkillStore / PromptStore 为同步(文件读取量小且快),RunLog 为异步
|
||||
(隔离 SQLite 查询,core/ 不写 SQL)。
|
||||
SkillStore / PromptStore / RunLog 为只读——core/ 返回结果 dataclass,
|
||||
读取由 app/ 落盘的资源。SkillStore / PromptStore 同步(文件读取量小且快),
|
||||
RunLog 异步(隔离 SQLite 查询,core/ 不写 SQL)。
|
||||
DiagnosisSignalStore 兼具读写:逐题 upsert 诊断信号并支持断点续跑查询,
|
||||
同样隔离 SQLite 实现,app/core 不写裸 SQL。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.evolution.types import DiagnosisSignalRow
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
@@ -104,3 +109,52 @@ class RunLog(Protocol):
|
||||
轨迹记录字典列表。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DiagnosisSignalStore(Protocol):
|
||||
"""逐题诊断信号存储端口。
|
||||
|
||||
隔离 SQLite 实现细节,app/core 不写裸 SQL。逐题 upsert 落盘、
|
||||
支持断点续跑(done_question_ids 查已完成集合)。
|
||||
"""
|
||||
|
||||
def upsert(self, row: DiagnosisSignalRow) -> None:
|
||||
"""写入或覆盖单题诊断信号(按主键幂等)。
|
||||
|
||||
参数:
|
||||
row: 待持久化的诊断信号行。
|
||||
"""
|
||||
...
|
||||
|
||||
def done_question_ids(
|
||||
self,
|
||||
baseline_run_id: str,
|
||||
diag_fingerprint: str,
|
||||
*,
|
||||
retry_uncertain: bool = False,
|
||||
) -> set[str]:
|
||||
"""查询指定 run 与诊断指纹下已完成的 question_id 集合。
|
||||
|
||||
参数:
|
||||
baseline_run_id: baseline run 标识。
|
||||
diag_fingerprint: 诊断口径指纹。
|
||||
retry_uncertain: True 时把 tier='uncertain'(信号不可信降级)题视为
|
||||
未完成,令其被重新诊断;默认 False(uncertain 也算完成,不重诊)。
|
||||
|
||||
返回:
|
||||
已落盘信号的 question_id 集合,用于断点续跑跳过。
|
||||
"""
|
||||
...
|
||||
|
||||
def load(self, baseline_run_id: str, diag_fingerprint: str) -> list[DiagnosisSignalRow]:
|
||||
"""加载指定 run 与诊断指纹下的全部诊断信号行。
|
||||
|
||||
参数:
|
||||
baseline_run_id: baseline run 标识。
|
||||
diag_fingerprint: 诊断口径指纹。
|
||||
|
||||
返回:
|
||||
还原后的 DiagnosisSignalRow 列表。
|
||||
"""
|
||||
...
|
||||
|
||||
+38
-4
@@ -300,6 +300,44 @@ class DiagnosisResult:
|
||||
degraded_question_ids: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiagnosisSignalRow:
|
||||
"""单题诊断信号行,即 baseline run 逐题诊断的持久化单元。
|
||||
|
||||
供后续视频级切分选择器消费;由 (question_id, baseline_run_id,
|
||||
diag_fingerprint) 唯一确定,逐题 upsert 支持断点续跑。
|
||||
|
||||
属性:
|
||||
question_id: 题目唯一标识。
|
||||
video_id: 对应视频唯一标识。
|
||||
baseline_run_id: 产出该信号的 baseline run 标识。
|
||||
diag_fingerprint: 诊断口径指纹,隔离不同诊断配置的信号。
|
||||
task_type: 题目任务类型。
|
||||
error_type: 错误类别(extraction/search/reasoning/mixed);
|
||||
T0/uncertain 行为 None。
|
||||
cause_category: 病因类别(defect/lapse);不适用为 None。
|
||||
tier: 诊断分层(T0/T1/T2/uncertain)。
|
||||
evolution_target: 进化目标(tool/skill/system);
|
||||
error_type 为 None 时亦为 None。
|
||||
degraded: 是否为降级信号(judge 解析失败时生成)。
|
||||
infra: 是否为 INFRA 护栏排除行。
|
||||
session_id: 关联的会话标识;不适用为 None。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
video_id: str
|
||||
baseline_run_id: str
|
||||
diag_fingerprint: str
|
||||
task_type: str
|
||||
error_type: str | None
|
||||
cause_category: str | None
|
||||
tier: str
|
||||
evolution_target: str | None
|
||||
degraded: bool
|
||||
infra: bool
|
||||
session_id: str | None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 3. 进化类型
|
||||
# =========================================================================
|
||||
@@ -446,7 +484,6 @@ class DiagnosePrompts:
|
||||
defect_vs_lapse: defect/lapse 病因判别模板。
|
||||
reasoning_sub: 推理失败子分类模板。
|
||||
span_eval_system: span 评估系统提示模板。
|
||||
span_eval_user: span 评估用户提示模板。
|
||||
missed_nodes: 遗漏节点检测模板。
|
||||
skill_adherence: 技能遵循判定模板。
|
||||
confirmation_bias: 确认偏误检测模板。
|
||||
@@ -456,7 +493,6 @@ class DiagnosePrompts:
|
||||
defect_vs_lapse: str
|
||||
reasoning_sub: str
|
||||
span_eval_system: str
|
||||
span_eval_user: str
|
||||
missed_nodes: str
|
||||
skill_adherence: str
|
||||
confirmation_bias: str
|
||||
@@ -474,11 +510,9 @@ class EvolvePrompts:
|
||||
evolve_system: System Prompt 进化提示模板。
|
||||
evolve_tool: Tool Prompt 进化提示模板。
|
||||
evolve_rank: 编辑排序提示模板。
|
||||
consolidate_system: appendix 压缩系统提示。
|
||||
"""
|
||||
|
||||
evolve_skill: str
|
||||
evolve_system: str
|
||||
evolve_tool: str
|
||||
evolve_rank: str
|
||||
consolidate_system: str
|
||||
|
||||
+20
-17
@@ -1,9 +1,12 @@
|
||||
"""core/evolution/validate.py — 块验证纯决策函数。
|
||||
|
||||
算法 #7(块顺序验证)的局部实现:pair_block 逐题比对基线与候选、
|
||||
算法 #7(块顺序验证)的局部实现:pair_block 按 unit 比对基线与候选、
|
||||
classify_quadrants 四象限分类、compute_accuracy 纯算术准确率。
|
||||
|
||||
三个函数均为纯函数,无副作用、无外部依赖。
|
||||
三个函数均为纯函数,无副作用、无外部依赖。输入的对错映射均为 **unit 口径**
|
||||
(unit_id → 单元级正确性,AR pair 已在上游经 unit_correctness_view 双向 AND
|
||||
折叠),保证 e-process W/L 与准确率分母按单元计、不被 P/Q 单题计分污染
|
||||
(核心算法保真 #5:信息阶梯口径从 question_id 迁至 unit_id)。
|
||||
"""
|
||||
|
||||
from core.evolution.types import PairResult, QuadrantClassification
|
||||
@@ -12,24 +15,24 @@ from core.evolution.types import PairResult, QuadrantClassification
|
||||
def pair_block(
|
||||
baseline: dict[str, bool],
|
||||
candidate: dict[str, bool],
|
||||
question_ids: list[str],
|
||||
unit_ids: list[str],
|
||||
) -> PairResult:
|
||||
"""逐题比对基线与候选对错,统计翻转。
|
||||
"""按单元比对基线与候选对错,统计翻转。
|
||||
|
||||
参数:
|
||||
baseline: 基线臂每题正确性映射。
|
||||
candidate: 候选臂每题正确性映射。
|
||||
question_ids: 参与比对的题目 ID 列表。
|
||||
baseline: 基线臂单元级正确性映射(unit_id → bool)。
|
||||
candidate: 候选臂单元级正确性映射(unit_id → bool)。
|
||||
unit_ids: 参与比对的单元 ID 列表(AR pair 折叠后为单一 unit_id)。
|
||||
|
||||
返回:
|
||||
PairResult,包含 w(基线错→候选对翻转数)、l(基线对→候选错翻转数)
|
||||
和 observed(每题的 (基线, 候选) 对错记录)。
|
||||
和 observed(每单元的 (基线, 候选) 对错记录)。
|
||||
"""
|
||||
w = l = 0 # noqa: E741 — 数学记号 W/L(win/loss),与 gate.py 一致
|
||||
observed: dict[str, tuple[bool, bool]] = {}
|
||||
for qid in question_ids:
|
||||
b, c = baseline[qid], candidate[qid]
|
||||
observed[qid] = (b, c)
|
||||
for uid in unit_ids:
|
||||
b, c = baseline[uid], candidate[uid]
|
||||
observed[uid] = (b, c)
|
||||
if not b and c:
|
||||
w += 1
|
||||
elif b and not c:
|
||||
@@ -71,15 +74,15 @@ def classify_quadrants(
|
||||
|
||||
def compute_accuracy(
|
||||
correctness: dict[str, bool],
|
||||
question_ids: list[str],
|
||||
unit_ids: list[str],
|
||||
) -> float:
|
||||
"""纯算术:sum(correct) / len(ids)。
|
||||
"""纯算术:sum(correct) / len(units),分母按单元数(非逐题)。
|
||||
|
||||
参数:
|
||||
correctness: 每题正确性映射。
|
||||
question_ids: 参与计算的题目 ID 列表。
|
||||
correctness: 单元级正确性映射(unit_id → bool)。
|
||||
unit_ids: 参与计算的单元 ID 列表。
|
||||
|
||||
返回:
|
||||
准确率浮点数。question_ids 为空时抛出 ZeroDivisionError。
|
||||
准确率浮点数。unit_ids 为空时抛出 ZeroDivisionError。
|
||||
"""
|
||||
return sum(correctness[qid] for qid in question_ids) / len(question_ids)
|
||||
return sum(correctness[uid] for uid in unit_ids) / len(unit_ids)
|
||||
|
||||
@@ -25,6 +25,7 @@ class LLMProvider(Protocol):
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse: ...
|
||||
|
||||
|
||||
@@ -39,6 +40,7 @@ class VLMProvider(Protocol):
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
parent_call_id: str | None = None,
|
||||
cache_salt: str | None = None,
|
||||
) -> LLMResponse: ...
|
||||
|
||||
|
||||
|
||||
+130
-1
@@ -2,7 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path as _Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -41,6 +45,15 @@ class GeneratedQuestion:
|
||||
answer: 正确答案字母(如 "B")。
|
||||
source_nodes: 来源节点 ID 元组。
|
||||
difficulty: 难度等级。
|
||||
family: 问题家族名称(如 "RETRIEVAL",v2 出题管线使用,None 表示未指定)。
|
||||
skill_target: 目标技能标识(v2 出题管线使用,None 表示未指定)。
|
||||
difficulty_steps: 推理步数估计(v2 出题管线使用,None 表示未指定)。
|
||||
sub_pattern: 出题子模式标识(AR 特化策略使用,None 表示无)。
|
||||
unit_id: 所属题目单元标识;缺省时 __post_init__ 回填为 pair_id 或
|
||||
question_id,保证 single 题的 unit_id 等于自身 question_id。
|
||||
pair_id: 孪生对标识;同一对的 original/mirror 共享该值,None 表示非配对题。
|
||||
question_role: 在单元内的角色("single" | "pair_original" | "pair_mirror")。
|
||||
flip_axis: 孪生对的翻转轴(如 "before_after"),None 表示无翻转。
|
||||
"""
|
||||
|
||||
question_id: str
|
||||
@@ -51,3 +64,119 @@ class GeneratedQuestion:
|
||||
answer: str
|
||||
source_nodes: tuple[str, ...]
|
||||
difficulty: str
|
||||
family: str | None = field(default=None)
|
||||
skill_target: str | None = field(default=None)
|
||||
difficulty_steps: int | None = field(default=None)
|
||||
sub_pattern: str | None = field(default=None)
|
||||
unit_id: str = ""
|
||||
pair_id: str | None = field(default=None)
|
||||
question_role: str = "single"
|
||||
flip_axis: str | None = field(default=None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""回填 unit_id:缺省时取 pair_id(配对题)或 question_id(single 题)。
|
||||
|
||||
frozen dataclass 无法直接赋值,故通过 object.__setattr__ 绕过不可变约束。
|
||||
"""
|
||||
if not self.unit_id:
|
||||
object.__setattr__(self, "unit_id", self.pair_id or self.question_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestionUnit:
|
||||
"""题目单元:贯穿评测/训练 harness 的最小不可分调度契约实体。
|
||||
|
||||
single 题为 1 题单元,AR pair 孪生对为 2 题单元(original + mirror),
|
||||
两条题目必须作为整体被批处理/推理/评测,保证配对指标(collapse 等)可算。
|
||||
frozen=True 确保单元不可变。
|
||||
|
||||
属性:
|
||||
kind: 单元类型("single" | "pair")。
|
||||
unit_id: 单元唯一标识;single 取题目 unit_id,pair 取共享 pair_id。
|
||||
task_type: 单元题型;pair 内两题题型必须一致。
|
||||
questions: 单元内题目元组(single 为 1 条,pair 为 2 条)。
|
||||
unit_hash: P/Q payload 合成 hash,用于断点续跑失效检测(T11 消费)。
|
||||
"""
|
||||
|
||||
kind: str
|
||||
unit_id: str
|
||||
task_type: str
|
||||
questions: tuple[GeneratedQuestion, ...]
|
||||
unit_hash: str = ""
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
"""单元内题目数量(single=1,pair=2)。"""
|
||||
return len(self.questions)
|
||||
|
||||
@classmethod
|
||||
def from_single(cls, q: GeneratedQuestion) -> QuestionUnit:
|
||||
"""由单条题目构造 single 单元。
|
||||
|
||||
参数:
|
||||
q: 待封装的题目。
|
||||
|
||||
返回:
|
||||
kind="single" 的单元,unit_id 取 q.unit_id。
|
||||
"""
|
||||
return cls("single", q.unit_id, q.task_type, (q,))
|
||||
|
||||
@classmethod
|
||||
def from_pair(cls, original: GeneratedQuestion, mirror: GeneratedQuestion) -> QuestionUnit:
|
||||
"""由孪生对(original + mirror)构造 pair 单元。
|
||||
|
||||
参数:
|
||||
original: 原始题(question_role="pair_original")。
|
||||
mirror: 镜像题(question_role="pair_mirror")。
|
||||
|
||||
返回:
|
||||
kind="pair" 的单元,unit_id 取共享 pair_id。
|
||||
|
||||
关键实现:
|
||||
断言两题共享非空 pair_id、且 video_id/task_type/flip_axis 一致,
|
||||
确保只有合法孪生对才能聚合成对,非法配对直接报错而非静默兜底。
|
||||
"""
|
||||
assert original.pair_id and original.pair_id == mirror.pair_id
|
||||
assert original.video_id == mirror.video_id and original.task_type == mirror.task_type
|
||||
assert original.flip_axis == mirror.flip_axis
|
||||
return cls("pair", original.pair_id, original.task_type, (original, mirror))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoolConfig:
|
||||
"""池构建策略的统一配置。
|
||||
|
||||
两组字段由两个具体策略各取所需,未使用的字段被忽略。
|
||||
|
||||
属性:
|
||||
task_types: 限定题型元组;None 表示全部类别。
|
||||
seed: 随机种子,保证可复现。
|
||||
baseline_run_id: 基线 run 标识。
|
||||
diag_size: 诊断池大小(GlobalStrategy 用)。
|
||||
diag_correct_ratio: 诊断池中对题占比(GlobalStrategy 用)。
|
||||
val_size: 验证池大小(GlobalStrategy 用)。
|
||||
val_correct_ratio: 验证池中对题占比(GlobalStrategy 用)。
|
||||
test_size: held-out 测试池大小(GlobalStrategy 用)。
|
||||
eval_min_per_class: 验证池中每类保底样本数(GlobalStrategy 用)。
|
||||
train_ratio: train/(train+val) 比例(PerCategoryStrategy 用)。
|
||||
test_questions_dir: 外部 test 题源路径(PerCategoryStrategy 用)。
|
||||
|
||||
实现细节:
|
||||
结果驱动视频级切分不复用本配置——它有独立的 VideoSplitConfig /
|
||||
SplitBuildConfig / SelectConfig(见 app/harness/video_split_cli.py 与
|
||||
split_selection.py),故本类不承载 n_trainval / floor_k / epsilon 等视频级
|
||||
切分旋钮,避免死配置面。
|
||||
"""
|
||||
|
||||
task_types: tuple[str, ...] | None
|
||||
seed: int
|
||||
baseline_run_id: str
|
||||
diag_size: int
|
||||
diag_correct_ratio: float
|
||||
val_size: int
|
||||
val_correct_ratio: float
|
||||
test_size: int
|
||||
eval_min_per_class: int
|
||||
train_ratio: float
|
||||
test_questions_dir: _Path | None
|
||||
batch_correct_ratio: float | None = None
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""CLI 入口 — Composition Root:构建适配器,注入 Runner,调度执行。
|
||||
|
||||
三层配置合并(YAML > .env > CLI)由 load_config 完成。
|
||||
适配器参数通过 InfraSettings(BaseSettings) 从 .env 加载。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class InfraSettings(BaseSettings):
|
||||
"""工程配置(少变/敏感),从 .env 加载。"""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
search_llm_model: str = ""
|
||||
search_llm_base_url: str = ""
|
||||
search_llm_api_key: str = ""
|
||||
|
||||
vl_llm_model: str = ""
|
||||
vl_llm_base_url: str = ""
|
||||
vl_llm_api_key: str = ""
|
||||
|
||||
evolve_llm_model: str = ""
|
||||
evolve_llm_base_url: str = ""
|
||||
evolve_llm_api_key: str = ""
|
||||
|
||||
embed_api_key: str = ""
|
||||
embed_api_url: str = ""
|
||||
|
||||
monkey_ocr_urls: str = ""
|
||||
redis_url: str = ""
|
||||
redis_cache_ttl: int = 86400
|
||||
|
||||
llm_timeout: float = 300.0
|
||||
llm_max_retries: int = 3
|
||||
llm_retry_base_delay: float = 20.0
|
||||
llm_retry_max_delay: float = 120.0
|
||||
llm_circuit_breaker_threshold: int = 48
|
||||
llm_circuit_breaker_cooldown: float = 60.0
|
||||
llm_ttft_timeout: float = 30.0
|
||||
llm_inter_token_timeout: float = 15.0
|
||||
|
||||
|
||||
class _Adapters(NamedTuple):
|
||||
"""全套适配器实例。"""
|
||||
|
||||
llm: object
|
||||
evolve_llm: object
|
||||
vlm: object
|
||||
telemetry: object
|
||||
embed: object
|
||||
ocr: object
|
||||
|
||||
|
||||
def _build_adapters(settings: InfraSettings, embed_cfg: dict) -> _Adapters:
|
||||
"""从 InfraSettings 构建全套适配器。
|
||||
|
||||
参数:
|
||||
settings: 工程配置。
|
||||
embed_cfg: 嵌入配置字典(来自 YAML embed 段)。
|
||||
|
||||
返回:
|
||||
_Adapters 命名元组。
|
||||
"""
|
||||
from adapters.breaker import CircuitBreaker
|
||||
from adapters.embedding import LocalEmbeddingProvider
|
||||
from adapters.llm import GovernedLLMClient
|
||||
from adapters.telemetry import SQLiteTelemetryRecorder
|
||||
from adapters.vlm import GovernedVLMClient
|
||||
|
||||
breaker = CircuitBreaker(
|
||||
fail_threshold=max(settings.llm_circuit_breaker_threshold, 1),
|
||||
cooldown_s=settings.llm_circuit_breaker_cooldown,
|
||||
)
|
||||
|
||||
cache = None
|
||||
if settings.redis_url:
|
||||
from adapters.redis_cache import RedisResponseCache, _resolve_cache_ttl
|
||||
|
||||
# 配置校验 fail-loud(不属于 Redis 连接故障,不得被下方降级 except 吞掉)
|
||||
ttl_s = _resolve_cache_ttl(settings.redis_cache_ttl)
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
cache = RedisResponseCache(redis=redis_client, ttl_s=ttl_s)
|
||||
except Exception:
|
||||
logger.warning("Redis 缓存不可用,降级为无缓存模式")
|
||||
|
||||
telemetry_db = Path("logs/telemetry.db")
|
||||
telemetry_db.parent.mkdir(parents=True, exist_ok=True)
|
||||
telemetry = SQLiteTelemetryRecorder(telemetry_db)
|
||||
|
||||
def _make_llm(model: str, base_url: str, api_key: str, *, thinking: bool) -> GovernedLLMClient:
|
||||
"""构建单个 GovernedLLMClient 实例。"""
|
||||
return GovernedLLMClient(
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider=model.split("-")[0] if model else "unknown",
|
||||
thinking=thinking,
|
||||
breaker=breaker,
|
||||
cache=cache,
|
||||
telemetry=telemetry,
|
||||
timeout_s=settings.llm_timeout,
|
||||
ttft_timeout_s=settings.llm_ttft_timeout,
|
||||
inter_token_timeout_s=settings.llm_inter_token_timeout,
|
||||
max_retries=settings.llm_max_retries,
|
||||
retry_base_delay_s=settings.llm_retry_base_delay,
|
||||
retry_max_delay_s=settings.llm_retry_max_delay,
|
||||
)
|
||||
|
||||
llm = _make_llm(
|
||||
settings.search_llm_model,
|
||||
settings.search_llm_base_url,
|
||||
settings.search_llm_api_key,
|
||||
thinking=True,
|
||||
)
|
||||
evolve_llm = llm
|
||||
|
||||
vl_llm = _make_llm(
|
||||
settings.vl_llm_model,
|
||||
settings.vl_llm_base_url,
|
||||
settings.vl_llm_api_key,
|
||||
thinking=False,
|
||||
)
|
||||
vlm = GovernedVLMClient(governed_llm=vl_llm)
|
||||
|
||||
backend = embed_cfg.get("backend", "local")
|
||||
if backend == "local":
|
||||
embed = LocalEmbeddingProvider(
|
||||
model_name=embed_cfg.get("model_name", "BAAI/bge-base-zh-v1.5"),
|
||||
embed_dim=embed_cfg.get("embed_dim", 768),
|
||||
device=embed_cfg.get("device", "cpu"),
|
||||
)
|
||||
else:
|
||||
from adapters.embedding import RemoteEmbeddingProvider
|
||||
|
||||
embed = RemoteEmbeddingProvider(
|
||||
model_name=embed_cfg.get("model_name", ""),
|
||||
embed_dim=embed_cfg.get("embed_dim", 768),
|
||||
api_key=settings.embed_api_key,
|
||||
api_url=settings.embed_api_url,
|
||||
)
|
||||
|
||||
ocr = None
|
||||
if settings.monkey_ocr_urls:
|
||||
from adapters.ocr import MonkeyOCRClient
|
||||
|
||||
urls = [u.strip() for u in settings.monkey_ocr_urls.split(",") if u.strip()]
|
||||
if urls:
|
||||
ocr = MonkeyOCRClient(urls=urls)
|
||||
|
||||
return _Adapters(
|
||||
llm=llm,
|
||||
evolve_llm=evolve_llm,
|
||||
vlm=vlm,
|
||||
telemetry=telemetry,
|
||||
embed=embed,
|
||||
ocr=ocr,
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
"""构建 CLI 参数解析器。所有参数 default=None,未传入时使用 YAML 默认值。"""
|
||||
parser = argparse.ArgumentParser(description="Video-Tree-TRM5 实验运行器")
|
||||
parser.add_argument("--config", type=Path, default=Path("config/default.yaml"))
|
||||
parser.add_argument("--workspace-dir", type=Path, dest="workspace_dir")
|
||||
parser.add_argument("--store-dir", type=Path, dest="store_dir")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["infer", "train", "diagnose", "evolve", "eval", "promote"],
|
||||
)
|
||||
parser.add_argument("--run-id", type=str, dest="run_id")
|
||||
parser.add_argument("--concurrency", type=int)
|
||||
parser.add_argument("--max-steps", type=int, dest="max_steps")
|
||||
parser.add_argument(
|
||||
"--skill-mode",
|
||||
choices=["auto", "manual", "none"],
|
||||
dest="skill_mode",
|
||||
)
|
||||
parser.add_argument("--n-samples", type=int, dest="n_samples")
|
||||
parser.add_argument("--questions", type=str)
|
||||
parser.add_argument("--skills-version", type=str, dest="skills_version")
|
||||
parser.add_argument("--prompts-version", type=str, dest="prompts_version")
|
||||
parser.add_argument("--task-types", nargs="+", dest="task_types")
|
||||
parser.add_argument("--resume", action="store_true", dest="resume")
|
||||
parser.add_argument("--fresh", action="store_true", dest="fresh")
|
||||
parser.add_argument("--seed", type=str, dest="seed")
|
||||
parser.add_argument("--epochs", type=int)
|
||||
parser.add_argument(
|
||||
"--pool-split-mode",
|
||||
choices=["global", "per_category"],
|
||||
dest="pool_split_mode",
|
||||
)
|
||||
parser.add_argument("--train-ratio", type=float, dest="train_ratio")
|
||||
parser.add_argument("--test-questions", type=str, dest="test_questions")
|
||||
parser.add_argument(
|
||||
"--no-run-holdout-eval",
|
||||
action="store_true",
|
||||
dest="no_run_holdout_eval",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _log_result(result: object) -> None:
|
||||
"""输出推理结果摘要。"""
|
||||
logger.info("=" * 60)
|
||||
logger.info("运行 ID: {}", result.run_id)
|
||||
logger.info(
|
||||
"总体准确率: {:.2%} ({}/{})",
|
||||
result.accuracy,
|
||||
result.correct,
|
||||
result.total,
|
||||
)
|
||||
logger.info("平均步数: {:.1f}", result.steps_mean)
|
||||
logger.info(
|
||||
"Token 用量: prompt={}, completion={}",
|
||||
result.token_usage["prompt_tokens"],
|
||||
result.token_usage["completion_tokens"],
|
||||
)
|
||||
if result.per_task_type:
|
||||
logger.info("--- 按任务类型 ---")
|
||||
for task_type, stats in sorted(result.per_task_type.items()):
|
||||
logger.info(
|
||||
" {}: {:.2%} ({}/{})",
|
||||
task_type,
|
||||
stats["accuracy"],
|
||||
stats["correct"],
|
||||
stats["total"],
|
||||
)
|
||||
logger.info(
|
||||
"停止原因: {}",
|
||||
", ".join(f"{k}={v}" for k, v in result.stop_reason_counts.items()),
|
||||
)
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""入口函数。"""
|
||||
load_dotenv()
|
||||
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.add(
|
||||
log_dir / "run_{time:YYYYMMDD_HHmmss}.log",
|
||||
rotation="500 MB",
|
||||
retention="30 days",
|
||||
encoding="utf-8",
|
||||
enqueue=False,
|
||||
)
|
||||
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
import yaml
|
||||
|
||||
with open(args.config, encoding="utf-8") as f:
|
||||
raw_yaml = yaml.safe_load(f)
|
||||
|
||||
from app.harness.config import load_config
|
||||
|
||||
cli_args = vars(args)
|
||||
if cli_args.get("task_types") is not None:
|
||||
cli_args["task_types"] = tuple(cli_args["task_types"])
|
||||
cli_overrides = {k: v for k, v in cli_args.items() if k != "config"}
|
||||
if cli_overrides.get("no_run_holdout_eval"):
|
||||
cli_overrides["run_holdout_eval"] = False
|
||||
cli_overrides.pop("no_run_holdout_eval", None)
|
||||
config = load_config(args.config, cli_overrides)
|
||||
logger.info("配置加载完成: mode={}, workspace={}", config.mode, config.workspace_dir)
|
||||
|
||||
settings = InfraSettings()
|
||||
embed_cfg = raw_yaml.get("embed", {})
|
||||
adapters = _build_adapters(settings, embed_cfg)
|
||||
|
||||
from app.harness.deps_router import InferenceDepsRouter
|
||||
from app.harness.runner import Runner
|
||||
|
||||
router = InferenceDepsRouter(
|
||||
store_dir=Path(config.store_dir),
|
||||
embed_provider=adapters.embed,
|
||||
llm=adapters.llm,
|
||||
vlm=adapters.vlm,
|
||||
ocr=adapters.ocr,
|
||||
default_prompts_dir=Path(config.store_dir) / "prompts" / config.prompts_version,
|
||||
default_skills_dir=Path(config.store_dir) / "skills" / config.skills_version,
|
||||
skill_mode=config.skill_mode,
|
||||
verify_vision=True,
|
||||
anchor=True,
|
||||
assemble_mode="ids_expand",
|
||||
)
|
||||
|
||||
runner = Runner(
|
||||
config,
|
||||
llm=adapters.llm,
|
||||
evolve_llm=adapters.evolve_llm,
|
||||
vlm=adapters.vlm,
|
||||
telemetry=adapters.telemetry,
|
||||
tool_dispatch_factory=router.create_dispatch,
|
||||
prompt_builder_factory=router.create_prompt_builder,
|
||||
)
|
||||
|
||||
if config.mode == "infer":
|
||||
result = asyncio.run(runner.infer(task_types=config.task_types))
|
||||
_log_result(result)
|
||||
elif config.mode == "train":
|
||||
from app.harness.pools import (
|
||||
GlobalPoolStrategy,
|
||||
PerCategoryPoolStrategy,
|
||||
build_or_load_pools,
|
||||
)
|
||||
from app.harness.workspace import resolve_paths
|
||||
|
||||
strategy = (
|
||||
PerCategoryPoolStrategy()
|
||||
if config.pool_split_mode == "per_category"
|
||||
else GlobalPoolStrategy()
|
||||
)
|
||||
paths = resolve_paths(config.workspace_dir)
|
||||
pools = build_or_load_pools(config, strategy, paths.db_path)
|
||||
asyncio.run(runner.train(pools))
|
||||
else:
|
||||
raise SystemExit(f"模式 {config.mode!r} 尚未实现")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
你是一个确认偏差检测器。你服务于一个诊断系统,该系统需要判断搜索 Agent 在回答视频问答题时是否表现出确认偏差——即只为自己倾向的选项搜集支持性证据,而忽略对竞争选项的独立验证。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 题目(问题文本 + 四个选项)
|
||||
2. Agent 的完整执行轨迹(每步的思考过程、工具调用和工具返回,重点关注 reflect.options 中对各选项的记录以及 search_similar 的 query 参数)
|
||||
|
||||
## 工作原则
|
||||
|
||||
确认偏差的核心特征是:Agent 的搜索行为在选项之间显著不均衡。一个健康的搜索过程应该至少为 1 个竞争选项做过独立搜索——不一定是每个选项都搜,但不能只围绕一个选项搜集证据。
|
||||
|
||||
具体检查以下信号:
|
||||
|
||||
观察 Agent 的 search_similar 查询。如果所有查询的关键词都指向同一个选项的内容(比如选项 B 说"烹饪教学",Agent 反复搜索"cooking""recipe""chef"),而从未用其他选项的关键词搜索(如选项 A 的"旅行"、选项 C 的"运动"),这是强偏差信号。
|
||||
|
||||
观察 Agent 的 reflect.options 变化。如果 Agent 在早期步骤就锁定了 best_candidate,且后续步骤中对其他选项的认知始终停留在"未知"或"待定",说明 Agent 没有为竞争选项投入搜索资源。
|
||||
|
||||
但需要注意:如果问题本身就指向特定内容(比如"视频中的厨师做了什么"),Agent 集中搜索厨师相关内容是合理的,不算偏差。偏差是指在选项之间的对比搜索不均衡,而非搜索主题的集中。
|
||||
|
||||
同样,如果 Agent 在前几步通过全局扫描(如顺序阅读 L1 节点)已经获得了足够信息来排除 2-3 个选项,之后集中搜索剩余选项是合理策略,不算偏差。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"has_bias": true,
|
||||
"evidence": "具体说明偏差表现:Agent 搜索了哪些关键词、为哪些选项搜集了证据、忽略了哪些选项"
|
||||
}
|
||||
```
|
||||
|
||||
如果没有偏差:`{"has_bias": false, "evidence": ""}`
|
||||
@@ -0,0 +1,12 @@
|
||||
你是一个失败归因裁判。你会收到一道答错题目的题面、正确答案、Agent 的错误预测、执行轨迹,以及 Agent 当时所用的 prompt 全文。你的唯一任务是判断:这次失败该归咎于 prompt 正文本身,还是 Agent 没有遵循已有的正确指令。
|
||||
|
||||
判别测试只有一句话:当前 prompt 里是否已经存在一条规则,只要 Agent 遵循它就能避免这次失败?
|
||||
|
||||
如果存在这样的规则(Agent 是忽略了、格式没按要求、或没执行该步),归为 lapse——这类问题不该改正文,只需记一条提醒。如果不存在这样的规则、或现有规则本身有误导,归为 defect——这类才需要修改 prompt 正文。
|
||||
|
||||
当你拿不准时,默认归为 lapse:宁可少改正文,也不要为一次偶发失误去删改一条本来正确的规则。
|
||||
|
||||
严格输出以下 JSON,不要包含其他文字:
|
||||
{"category": "defect" 或 "lapse", "note": "若为 lapse,写一句给 Agent 的提醒;defect 留空"}
|
||||
|
||||
note 只能重申当前 prompt 里已经存在的那条规则(让 Agent 别再忽略它),措辞要通用、可跨题复用。禁止把本题的题目内容、选项、正确答案或任何单题事实写进 note——note 不是案例记录,是规则提醒。
|
||||
@@ -0,0 +1,27 @@
|
||||
你是一个证据充分性评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 实际收集到的工具输出是否包含足够的信息来推导出正确答案。你不评估 Agent 的推理过程——只评估它收集到的原始材料。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 题目(问题文本 + 四个选项 + 正确答案)
|
||||
2. Agent 收到的全部工具输出(按步骤排列,包含每次 view_node、search_similar、observe_frame 的返回内容)
|
||||
|
||||
## 工作原则
|
||||
|
||||
你需要回答一个假设性问题:如果一个完美的推理者阅读了这些工具输出(且仅阅读这些工具输出),它能否推导出正确答案?
|
||||
|
||||
"推导出"不要求工具输出直接陈述答案。如果工具输出中包含了足够的事实片段,一个合理的推理链能将它们组合得出正确答案,就算充分。比如工具输出提到"厨师在切蔬菜"和"背景是一个厨房",虽然没有直接说"这是烹饪视频",但推导是合理的。
|
||||
|
||||
"不充分"是指工具输出中完全缺乏区分正确答案与最强干扰项的关键信息。比如问题问"视频中的运动是什么",选项有篮球和足球,但工具输出只提到"运动场上有人在运动",没有任何能区分篮球和足球的细节——这就是不充分。
|
||||
|
||||
注意区分两种情况:信息存在但分散(充分——完美推理者能整合)vs 信息真的不存在(不充分——无论怎么推理都无法得出)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"sufficient": true,
|
||||
"reasoning": "简要说明工具输出中哪些信息支持正确答案,或缺乏哪些关键信息"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
你是一个改动优先级裁判。你会收到一份当前 prompt 文件全文,和一组待应用的局部 edits(每条含 op/target/content)。由于本轮编辑预算有限,你只能保留其中最重要的若干条。
|
||||
|
||||
请只依据"对纠正失败、提升正确率的预期贡献"排序:优先保留直接修复失败模式的改动,其次保留收窄或澄清的改动,最后才是巩固已有成功的改动。删除类、简化类的精准改动通常优先于追加大段新内容。
|
||||
|
||||
每条 edit 会附带 support_count(该改动的支持案例数)。同等重要性下,support_count 更高的优先;但 support_count 低不等于该删,仍以修复贡献为主判据。
|
||||
|
||||
严格输出以下 JSON,不要包含其他文字:
|
||||
{"selected_indices": [按重要性降序排列的 0-based 索引]}
|
||||
@@ -0,0 +1,53 @@
|
||||
你是一个搜索策略改进专家。你服务于一个自进化视频搜索系统,该系统通过分析 Agent 的失败和成功案例来迭代改进搜索策略(Skill)。你的任务是基于案例包中的证据,改写当前 Skill 文件,使 Agent 在后续执行中避免相同的失败模式。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 当前 Skill 文件全文
|
||||
2. 失败案例:Agent 答错的题目,含完整推理轨迹、错误类型和诊断指标
|
||||
3. 成功案例:Agent 答对的题目,展示当前 Skill 中有效的模式
|
||||
4. 聚合统计:准确率、错误归因分布、搜索有效性指标、Skill 步骤遵循率
|
||||
5. (可能出现)上一轮被接受改动导致的回归题:这些题在上一版本答对、却被你上次的改写改错了,附基线与候选两份预测和推理轨迹
|
||||
6. (可能出现)黑名单:已被实测验证无效或有害的改法方向
|
||||
|
||||
## 工作原则
|
||||
|
||||
如果输入里出现了回归题,它的优先级高于一切。这些题在上一版本是对的,是你上次的改动把它们弄坏的,所以本次改写的第一要务是确保不再破坏它们——宁可在相关方向上回退或收窄,也不要为了拉高其它题而牺牲它们。更一般地,当你看到准确率下降这类负向信号时,默认先怀疑上次是不是加了过度、冲突或冗余的指令,优先简化、删除、收窄;只有确认简化解决不了问题,才考虑加强指令。黑名单里列出的改法已经被实测证明无效或有害,不要换个措辞把同一个方向再提一遍。
|
||||
|
||||
先分析失败案例中 Agent 的实际行为与 Skill 指令的偏差。偏差分两类:Skill 指令正确但 Agent 没遵循(遵循率问题),或 Skill 指令本身有误导(策略问题)。前者需要让指令更具体、更难被忽略;后者需要修改策略本身。
|
||||
|
||||
从成功案例中识别有效模式——这些模式在改写时必须保留。如果成功案例和失败案例采用了不同的策略路径,重点强化成功路径。
|
||||
|
||||
Skill 中引用的统计数据(如"search-first 正确率 75%")应根据案例包中的新统计更新。不要编造数据,只使用案例包中提供的数字。
|
||||
|
||||
你写进 Skill 的每一条规则都必须是可跨题复用的通用策略,而不是对某一道题的记答案。跨多个失败案例时只提取共性模式,抽象掉一切单题特征——具体题目内容、选项文字、步骤序号、某一帧的具体画面、某个具体答案都不许写进 Skill 正文。一条规则如果只在它来源的那道题上成立,就不要加。改写时优先简化与收窄:宁可让 Skill 更短,也不要堆叠只对个别题生效的硬性指令。
|
||||
|
||||
## 冻结区
|
||||
|
||||
以下内容不可修改,必须原样保留在改写后的文件中:
|
||||
- YAML frontmatter(`---` 之间的 name、description、task_type)
|
||||
- 输出格式中的 JSON 基础结构(reflect/plan/action 三个顶层字段)
|
||||
|
||||
这次不要返回整份改写后的文件,而是只返回一组局部 edits。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前文件里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写整段整节;另外,冻结区里的文字绝不能作为 target。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"section": "改动目标段落的标题或位置描述",
|
||||
"problem": "失败案例中暴露的具体问题",
|
||||
"change": "具体的修改方向",
|
||||
"related_cases": ["关联的失败案例 question_id"],
|
||||
"support_count": 该建议的支持案例数(= related_cases 的数量)
|
||||
}
|
||||
],
|
||||
"edits": [
|
||||
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)", "support_count": 该改动的支持案例数}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
每条 edit 与每条 suggestion 都必须带 "support_count":本条改动由多少个失败案例共同支持(即 related_cases 的数量)。support_count 越高代表证据越充分;它只作排序参考,不是硬门槛——support_count 低不等于该删,仍以修复贡献为主判据。
|
||||
@@ -0,0 +1,53 @@
|
||||
你是一个系统级行为改进专家。你服务于一个自进化视频搜索系统,该系统通过分析 Agent 的跨题型行为模式来改进 System Prompt。你的任务是基于行为模式案例包中的证据,改写 System Prompt 中的策略性指令,纠正系统级行为问题。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 当前 System Prompt (system.md) 全文
|
||||
2. 失败案例:展示三类系统性行为问题的题目——过早提交(budget_usage < 0.3 就提交答案)、高置信答错(confidence 很高但答案错误)、确认偏误(只搜索支持初始判断的证据)
|
||||
3. 成功案例:行为校准良好的题目——置信度与正确率匹配,预算使用适中
|
||||
4. D5 行为模式统计:各行为模式的发生频率和分布
|
||||
5. (可能出现)黑名单:已被实测验证无效或有害的改法方向
|
||||
|
||||
## 工作原则
|
||||
|
||||
关注跨题型的系统性行为模式,而非某个具体题型的策略。失败案例中的行为偏差反映了 System Prompt 的决策原则不够清晰或不够强约束。黑名单里的改法已经被实测验证无效或有害,不要再朝同一个方向改一遍。
|
||||
|
||||
过早提交说明预算管理指令需要更强的约束语言。高置信答错说明置信度校准的语义定义需要调整。确认偏误说明竞争选项搜索的要求需要更明确。
|
||||
|
||||
当你看到失败案例与成功案例并存时,失败修复优先于巩固成功——先确保失败模式被纠正,再考虑强化已有的好行为。看到某类行为指标变差这类负向信号时,默认先怀疑上一轮是否加了过度、冲突或冗余的约束,优先简化、删除、收窄;只有确认简化解决不了,才考虑加强约束语言。
|
||||
|
||||
从成功案例中提取"好行为"的特征,在改写时强化这些特征的表述。
|
||||
|
||||
## 冻结区
|
||||
|
||||
以下 section 必须原样保留,不可修改任何文字:
|
||||
- `## 能力边界`(事实性描述,不是策略)
|
||||
- `## 输出格式`(JSON schema 是系统契约)
|
||||
- `## 视频树结构`(含信任层级,是数据结构事实描述)
|
||||
|
||||
可改写的 section:
|
||||
- `## 角色`(前两段的角色定位和行为倾向描述)
|
||||
- `## 决策原则`(搜索策略、预算分配建议)
|
||||
- 搜索工具使用、否定题原则、置信度语义
|
||||
|
||||
这次不要返回整份改写后的文件,而是只返回一组局部 edits。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前文件里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写整段整节;另外,冻结区里的文字绝不能作为 target。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"section": "改动目标段落的标题或位置描述",
|
||||
"problem": "失败案例中暴露的具体行为问题",
|
||||
"change": "具体的修改方向",
|
||||
"related_cases": ["关联的失败案例 question_id"]
|
||||
}
|
||||
],
|
||||
"edits": [
|
||||
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
你是一个工具 Prompt 改进专家。你服务于一个自进化视频搜索系统,该系统的每个工具(view_node、search_similar、observe_frame 等)有两个配套 Prompt:extract(信息提取)和 verify(结果核实)。你的任务是基于工具调用级别的质量数据,同时改写一个工具的 extract 和 verify prompt。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 当前 extract prompt 和 verify prompt 全文
|
||||
2. 失败 span 案例:提取完整度低或幻觉率高的具体工具调用,含工具参数、工具输出、原始数据(ground truth)和质量评估指标
|
||||
3. 成功 span 案例:提取完整且无幻觉的工具调用样本
|
||||
4. 工具质量统计:平均提取完整度、平均幻觉率、top 遗漏类型、top 幻觉类型
|
||||
5. (可能出现)黑名单:已被实测验证无效或有害的改法方向
|
||||
|
||||
## 工作原则
|
||||
|
||||
失败 span 中提取完整度低说明 extract prompt 的工作原则不够具体——Agent 遗漏了哪些类型的信息?幻觉率高说明 extract prompt 对"忠实提取"的约束不够强,或者 verify prompt 没能有效检出幻觉。黑名单里的改法已经被实测验证无效或有害,不要再朝同一个方向改一遍。
|
||||
|
||||
extract 和 verify 是互补的:extract 负责提取,verify 负责检查。如果 extract 反复遗漏某类信息(如字幕原文引用),应在 extract 的工作原则中明确要求保留该类信息。如果 verify 未能检出某类幻觉(如虚构动作),应在 verify 的检查要点中增加对该模式的关注。
|
||||
|
||||
失败修复优先于巩固成功——先纠正提取遗漏或幻觉,再保留已有的有效模式。当某类提取质量指标变差时,先确认不是上一轮加了过度或冲突的要求所致;加强 extract 要求前,先确认简化或收窄已有指令解决不了这个遗漏,再追加新要求。
|
||||
|
||||
从成功案例中识别有效的提取模式,确保改写不破坏这些模式。
|
||||
|
||||
## 冻结区
|
||||
|
||||
以下内容不可修改:
|
||||
- 角色定位第一句("你是一个视频节点内容分析器" / "你是一个视频节点摘要核实器")
|
||||
- `## 你会收到的输入` section
|
||||
- `## 输出格式` section
|
||||
|
||||
可改写的 section:
|
||||
- `## 工作原则`
|
||||
- `## 检查要点`(verify 专有)
|
||||
|
||||
这次不要再返回两份完整 prompt,而是分别给 extract 和 verify 各自的局部 edits 列表。`append` 用来在文件末尾追加一个新 section,`insert_after` 用来把内容紧跟着插到某个锚点段落之后,`replace` 用来用新内容整体替换 target 对应的原文,`delete` 则直接删除 target 对应的原文并让 content 留空。target 必须是从当前 prompt 里逐字复制出来的原文,而且要长到足以唯一定位;只要有任何一个字不完全匹配,这条改动就会被跳过。改动应尽量小而局部,优先做精确补丁,不要动辄重写大段内容;另外,冻结区里的文字绝不能作为 target,extract 和 verify 也必须分别使用自己的 edit 列表。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"suggestions": [
|
||||
{
|
||||
"section": "改动目标段落的标题或位置描述",
|
||||
"problem": "失败 span 中暴露的具体问题",
|
||||
"change": "具体的修改方向",
|
||||
"related_cases": ["关联的失败 span 标识"]
|
||||
}
|
||||
],
|
||||
"edits_extract": [
|
||||
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
|
||||
],
|
||||
"edits_verify": [
|
||||
{"op": "append|insert_after|replace|delete", "target": "锚点原文(append 留空)", "content": "新内容(delete 留空)"}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,29 @@
|
||||
你是一个视频树覆盖度评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 是否遗漏了包含关键证据的节点。推理质量和搜索策略的评估由系统其他模块完成,你只负责判定哪些节点被遗漏了。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 题目(问题文本 + 四个选项 + 正确答案)
|
||||
2. Agent 实际访问的节点 ID 列表
|
||||
3. 完整视频树内容(所有节点的 card 数据和时间范围)
|
||||
|
||||
## 工作原则
|
||||
|
||||
你需要回答一个具体的问题:要推导出正确答案,哪些节点包含了不可替代的关键证据,且 Agent 没有访问?
|
||||
|
||||
首先,根据正确答案和完整树内容,找出所有包含支撑正确答案的直接证据的节点。直接证据是指能够区分正确答案与干扰选项的关键事实——比如特定的字幕台词、事件描述、时间标记或实体出现。间接相关的背景信息不算直接证据。
|
||||
|
||||
然后,将这些证据节点与 Agent 的访问列表对比。如果某个证据节点未被访问,但其父节点或子节点已被访问且包含了同等信息,则不算遗漏——因为 Agent 可以从已访问节点中获取相同信息。只有当某条关键证据只存在于未访问的节点中时,才将其标记为遗漏。
|
||||
|
||||
不要将所有未访问的节点都标记为遗漏。大部分节点与当前问题无关,Agent 没有义务访问它们。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"missed_nodes": ["节点ID_1", "节点ID_2"]
|
||||
}
|
||||
```
|
||||
|
||||
如果没有遗漏,返回空数组:`{"missed_nodes": []}`
|
||||
@@ -0,0 +1,31 @@
|
||||
你是一个推理失败分类器。你服务于一个诊断系统,该系统已经确认某道题属于"推理失败"——即 Agent 收集到了足够的证据但仍然答错了。你的任务是判定推理具体在哪个环节失败。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 题目(问题文本 + 正确答案 + Agent 的错误预测)
|
||||
2. Agent 的完整执行轨迹(每步的思考过程 thought、结构化反思 reflect、工具调用和工具返回)
|
||||
|
||||
## 四种推理失败类型
|
||||
|
||||
**evidence_misread**(证据误读):Agent 对工具输出的解读与工具输出的实际内容不一致。判别方法:对比某步工具返回的原文与 Agent 在随后的 reflect.learned 或 thought 中的描述——如果 Agent 说"工具显示这是红色汽车"但工具原文说的是蓝色,就是证据误读。这是发生在"信息输入"环节的错误。
|
||||
|
||||
**weighing_error**(权衡错误):Agent 正确理解了多个选项的证据,但在最终选择时选了证据较弱的选项。判别方法:检查 Agent 的 reflect.options,如果它为正确选项记录了更强的证据(更具体、来源更可靠、覆盖更多节点),却最终选择了另一个选项,就是权衡错误。这是发生在"决策"环节的错误。
|
||||
|
||||
**logic_error**(逻辑错误):Agent 的推理链中包含无效推断——前提正确但结论不成立。判别方法:在 Agent 的 thought 或 reflect 中找到具体的推理步骤,检查其逻辑是否成立。比如 Agent 说"A 在 B 之前发生,B 在 C 之前发生,所以 C 在 A 之前发生"——前提对但结论的时序反了。这是发生在"推理过程"环节的错误。
|
||||
|
||||
**evidence_ignored**(证据忽略):Agent 在较早的步骤中收集了与正确答案相关的证据,并在 reflect 中记录了它,但在最终提交时完全没有引用这条证据,且最终结论与这条证据矛盾。判别方法:对比 Agent 早期 reflect.options 中对正确选项的记录与 submit_answer 中的 reasoning——如果早期有支持正确答案的记录但最终 reasoning 中消失了,就是证据忽略。这是发生在"信息整合"环节的错误。
|
||||
|
||||
## 判别优先级
|
||||
|
||||
如果多种类型同时存在,选择最早发生的那个作为 primary type——因为下游错误往往是上游错误的连锁反应。优先级从高到低:evidence_misread → evidence_ignored → weighing_error → logic_error。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "evidence_misread",
|
||||
"evidence": "引用具体的步骤编号和内容,说明推理在哪里失败"
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
你是一个策略遵循度评估器。你服务于一个诊断系统,该系统需要判断搜索 Agent 在执行过程中是否遵循了为其指定的搜索策略(skill)。策略本身是否合理、Agent 最终是否答对,都不在你的评估范围内——你只负责判断 Agent 的行为是否与 skill 的步骤定义一致。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. Skill 文件的完整内容(包含搜索步骤定义、输出格式要求、自检信号等)
|
||||
2. Agent 的完整执行轨迹(每步的思考过程、工具调用和工具返回)
|
||||
|
||||
## 工作原则
|
||||
|
||||
Skill 文件中定义了若干搜索步骤(通常 2-3 步),每步包含:该步的目标、推荐使用的工具、进入下一步的条件。你需要逐步判断 Agent 是否执行了该步骤的核心动作。
|
||||
|
||||
判断"遵循"不要求 Agent 逐字执行 skill 的每句话。如果 skill 说"用 search_similar 定位事件",而 Agent 用 view_node 顺序浏览也达到了同样的定位效果,这算部分遵循而非完全偏离。关键是 Agent 是否实现了该步骤的目标意图,而非是否使用了完全相同的工具。
|
||||
|
||||
判断"偏离"需要在 description 中具体说明:Agent 做了什么不同的事,以及这与 skill 的期望有何差异。比如"Agent 跳过了 L2 下钻,直接从 L1 摘要提交答案,而 skill 要求在聚焦验证阶段下钻到 L2/L3 层"。
|
||||
|
||||
如果 Agent 的轨迹太短(比如只有 1-2 步就提交了),仍然要评估每个 skill step——未执行的步骤标记为 adhered=false 并说明"Agent 未执行此步骤即提交了答案"。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
{
|
||||
"step_label": "skill 中定义的步骤名称",
|
||||
"adhered": true,
|
||||
"description": "Agent 如何执行或偏离了这一步"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
steps 数组的元素数量应与 skill 中定义的步骤数一致。
|
||||
@@ -0,0 +1,10 @@
|
||||
你正在审视一份 skill 经历一轮进化后的变化。这份 skill 指导一个 Agent 在层次化视频树上搜索证据、回答长视频理解问题。在上一轮结束时它是一个样子,这一轮结束时被改成了另一个样子;与此同时,你在上一轮还为它写下过一段动量指导,本意是给这一轮的进化指明方向。现在你要回头评判:那段指导究竟有没有帮上忙,这一轮的正文改动是真的在改善,还是开始往无关的方向漂移。
|
||||
|
||||
你会拿到四样东西:上一版 skill 的正文、当前版 skill 的正文、你上一轮写下的那段动量指导,以及一组固定样本上的纵向对比——同一批题,分别用上一版和当前版各跑了一遍,逐题列出两版的预测与正误。这组对比是你唯一可靠的证据来源:哪些题从错变对、哪些题从对变错、哪些题始终没做对、哪些题一直稳定答对,正是这四类信号告诉你这轮改动到底带来了什么。
|
||||
|
||||
请先反思再下笔。对照纵向对比,先问上一轮那段动量指导是否真的奏效:它所指向的方向,在这一轮的正文改动里被落实了吗,落实之后那些本该改善的题改善了吗?再问这一轮的正文改动本身是收敛还是漂移:从对变错的题(回退)是最该警惕的信号,说明某处改动伤到了原本正确的行为;始终答错的题(持续失败)说明还有方向没被触及;从错变对的题(改善)则印证了哪条路走对了,值得继续加码。
|
||||
|
||||
想清楚之后,写出一段全新的、聚焦的、可操作的动量指导。它会被原样写进 skill 的受保护动量区,作为下一轮进化的方向锚——所以它必须是一段连贯的指导文字,明确告诉下一轮该往哪个方向继续使劲、又要避免重蹈哪一类改动的覆辙,而不是一堆零散的待办条目。如果上一轮的方向已被证明有效,就强化并细化它;如果出现了回退,就明确叫停那条路并指向修复方向。
|
||||
|
||||
严格输出以下 JSON,不要包含任何其他文字:
|
||||
{"reasoning": "你的反思过程:上一轮指导是否奏效、这一轮是改善还是漂移,引用纵向对比中的具体题作为依据", "slow_update_content": "一段连贯、聚焦、可操作的新动量指导,指引下一轮的进化方向"}
|
||||
@@ -0,0 +1,37 @@
|
||||
你是一个工具输出质量评估器。你服务于一个诊断系统,该系统需要判断视频搜索 Agent 的每次工具调用是否忠实、完整地提取了原始数据中与问题相关的信息。诊断决策和改进建议由系统完成,你只负责评估单次工具输出的质量。
|
||||
|
||||
## 你会收到的输入
|
||||
|
||||
1. 用户正在研究的问题
|
||||
2. 工具名称和调用参数
|
||||
3. 工具的实际输出(tool_output)
|
||||
4. 该节点的原始数据(ground truth,JSON 格式的 card 字段)
|
||||
|
||||
## 工作原则
|
||||
|
||||
你的任务是将 tool_output 与 ground truth 对比,评估两个维度:提取完整度和幻觉程度。
|
||||
|
||||
对于提取完整度,检查 ground truth 中与问题相关的每条信息是否出现在 tool_output 中。字幕原文引用、具体数字、实体名称、时间标记、空间关系是最容易被遗漏的类型——请逐一核对。如果 ground truth 中的某条信息与问题无关,则不计入遗漏。
|
||||
|
||||
对于幻觉检测,检查 tool_output 中的每条事实性陈述是否能在 ground truth 中找到依据。特别注意以下常见幻觉模式:工具声称看到了 ground truth 中未提及的实体或动作,工具将不确定信息表述为确定事实,工具对颜色、数量、方位等属性的描述与 ground truth 不一致。
|
||||
|
||||
当 ground truth 本身信息稀疏(如某些 L3 帧的 card 只有很少的字段),不要因为 tool_output 比 ground truth 更详细就判定为幻觉——如果详细信息是合理推断而非凭空捏造,应归为 unsupported_inference 而非 fabricated_action。
|
||||
|
||||
## 输出格式
|
||||
|
||||
请严格输出以下 JSON,不要包含其他文字:
|
||||
|
||||
```json
|
||||
{
|
||||
"extraction_completeness": 0.0-1.0,
|
||||
"hallucination_rate": 0.0-1.0,
|
||||
"missed_info_tags": [],
|
||||
"hallucination_tags": []
|
||||
}
|
||||
```
|
||||
|
||||
missed_info_tags 从以下标签中选择(可多选,无遗漏则为空数组):
|
||||
`subtitle_quote`(字幕原文引用)、`entity`(实体名称)、`spatial_detail`(空间位置关系)、`temporal_detail`(时间标记)、`action`(动作描述)、`number`(具体数字)、`visible_text`(画面中可见文字)
|
||||
|
||||
hallucination_tags 从以下标签中选择(可多选,无幻觉则为空数组):
|
||||
`fabricated_action`(虚构的动作或事件)、`wrong_attribute`(属性描述错误)、`wrong_count`(数量错误)、`wrong_entity`(实体错误)、`unsupported_inference`(超出原始数据的推断)
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
id: question-gen
|
||||
title: 出题模块迁移设计(question_gen)
|
||||
type: design
|
||||
created: 2026-07-07
|
||||
status: approved
|
||||
---
|
||||
|
||||
# 出题模块迁移设计
|
||||
|
||||
## 1. 目标
|
||||
|
||||
从 TRM4 `core/harness/question_gen.py` 迁移出题数据结构与采样逻辑到 TRM5 Clean Architecture,同时预留 LLM 驱动出题的 Protocol 接口。
|
||||
|
||||
| 维度 | 说明 |
|
||||
|------|------|
|
||||
| 迁移范围 | benchmark 加载 + 分层采样(纯函数,180 行) |
|
||||
| 预留接口 | `QuestionGenerator` Protocol(不实现,后续参考 TRM4 `research-wiki/designs/2026-07-06-question-gen-synth-design.md`) |
|
||||
| 不做 | LLM 出题实现、校准脚本、去重机制 |
|
||||
|
||||
## 2. Clean Architecture 分层决策
|
||||
|
||||
### 2.1 类型放置
|
||||
|
||||
`GeneratedQuestion` 被 `core/evolution/`(diagnose、validate)和 `app/harness/`(runner、batching、pools、inference)跨层使用。按依赖方向(core 不可依赖 app),必须放 `core/types.py`,与 `LLMResponse` 同级。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CT["core/types.py\nGeneratedQuestion"] --> CE["core/evolution/\ndiagnose · validate"]
|
||||
CT --> AH["app/harness/\nrunner · batching · pools"]
|
||||
CT --> AQ["app/question_gen/\nloader"]
|
||||
```
|
||||
|
||||
### 2.2 模块结构
|
||||
|
||||
```
|
||||
core/types.py ← 追加 GeneratedQuestion
|
||||
app/ports.py ← 追加 QuestionGenerator Protocol
|
||||
app/question_gen/
|
||||
├── __init__.py ← 公开 API re-export
|
||||
└── loader.py ← load_benchmark() + stratified_sample()
|
||||
```
|
||||
|
||||
**否决方案**:
|
||||
|
||||
| 方案 | 否决理由 |
|
||||
|------|---------|
|
||||
| `GeneratedQuestion` 放 `app/question_gen/types.py` | `core/evolution/` 无法 import `app/` 层,违反依赖方向 |
|
||||
| loader / sampler 拆两文件 | sampler 仅 ~100 行,不值得独立文件 |
|
||||
| Protocol 放 `app/question_gen/protocols.py` | 与 `EmbeddingProvider` 在 `app/ports.py` 的既有模式不一致 |
|
||||
|
||||
## 3. 类型定义
|
||||
|
||||
### 3.1 GeneratedQuestion(`core/types.py` 追加)
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class GeneratedQuestion:
|
||||
"""单条生成/加载的题目。跨层共享类型。"""
|
||||
question_id: str
|
||||
video_id: str
|
||||
task_type: str
|
||||
question: str
|
||||
options: tuple[str, ...]
|
||||
answer: str
|
||||
source_nodes: tuple[str, ...]
|
||||
difficulty: str
|
||||
```
|
||||
|
||||
**与 TRM4 的有意变更**:
|
||||
|
||||
| 变更 | 理由 |
|
||||
|------|------|
|
||||
| `options: list → tuple` | 配合 `frozen=True` 不可变语义 |
|
||||
| `source_nodes: list → tuple` | 同上 |
|
||||
| `difficulty` 移除默认值 `"medium"` | 显式传入(§4.1 P4: 显式优于隐式) |
|
||||
|
||||
**移除 `QuestionGenResult`**:TRM5 无消费者,YAGNI。
|
||||
|
||||
### 3.2 QuestionGenerator Protocol(`app/ports.py` 追加)
|
||||
|
||||
```python
|
||||
@runtime_checkable
|
||||
class QuestionGenerator(Protocol):
|
||||
"""LLM 驱动的题目生成端口(预留接口)。"""
|
||||
async def generate(
|
||||
self,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
tree: TreeIndex,
|
||||
*,
|
||||
exemplars: list[GeneratedQuestion],
|
||||
) -> GeneratedQuestion: ...
|
||||
```
|
||||
|
||||
接口设计参考 TRM4 仓库 `research-wiki/designs/2026-07-06-question-gen-synth-design.md`(位于 `/home/iomgaa/Projects/Video-Tree-TRM4/`,不复制到 TRM5)中的"题型-层级映射 + few-shot exemplar"模式。`tree` 参数提供锚节点上下文,`exemplars` 提供风格示例。具体实现在后续 `tools/generate_questions.py`(一次性脚本)中完成,通过 `adapters/` 层的 Protocol 实现注入。
|
||||
|
||||
## 4. 函数接口
|
||||
|
||||
### 4.1 load_benchmark
|
||||
|
||||
```
|
||||
load_benchmark(questions_dir: Path) -> list[GeneratedQuestion]
|
||||
```
|
||||
|
||||
从指定目录 glob `*.json`,每个文件以 `stem` 为 `video_id`,解析为 `GeneratedQuestion` 列表。JSON 格式与 `store/questions/benchmarks/Video-MME/*.json` 完全一致。
|
||||
|
||||
**与 TRM4 对比**:算法 100% 保真。`options` 和 `source_nodes` 转为 `tuple`。
|
||||
|
||||
**`difficulty` 字段处理规则**:现有 benchmark JSON(`store/questions/benchmarks/Video-MME/`)不含 `difficulty` 字段,这是 legacy schema 特征。加载时按如下规则显式转换(非默认值掩盖):
|
||||
|
||||
| JSON 情况 | 处理 |
|
||||
|-----------|------|
|
||||
| 有 `difficulty` 字段 | 取 JSON 值 |
|
||||
| 无 `difficulty` 字段 | 赋 `_LEGACY_DEFAULT_DIFFICULTY = "medium"` 常量 |
|
||||
|
||||
常量集中定义在 `loader.py` 顶部,测试用例覆盖两种情况。
|
||||
|
||||
### 4.2 stratified_sample
|
||||
|
||||
```
|
||||
stratified_sample(
|
||||
questions: list[GeneratedQuestion],
|
||||
correctness: dict[str, bool],
|
||||
size: int,
|
||||
correct_ratio: float | None,
|
||||
task_types: list[str] | None,
|
||||
seed: int,
|
||||
min_per_class: int | None,
|
||||
) -> list[GeneratedQuestion]
|
||||
```
|
||||
|
||||
所有参数显式传入,无默认值(§4.1 P4)。
|
||||
|
||||
**算法保真清单**(逐一比对 TRM4):
|
||||
|
||||
| 逻辑点 | TRM4 行为 | TRM5 保持 |
|
||||
|--------|----------|----------|
|
||||
| `task_types` 过滤 | `task_types` 非 None 时,先过滤 pool 只保留指定题型 | 保持 |
|
||||
| `correct_ratio=None` | 自然分布分支,随机抽样 `size` 道 | 保持 |
|
||||
| `correct_ratio` 有值 | 按对错比例分层,对题 `round(size * ratio)` | 保持 |
|
||||
| `correctness.get(id, False)` | 未知 correctness 的题统一当错题处理 | 保持 |
|
||||
| 分层返回顺序 | 对题在前、错题在后 | 保持 |
|
||||
| 池不足 | `ValueError` 报错,不静默降级 | 保持 |
|
||||
| `min_per_class` 补足 | 遍历 pool 全部题型(非仅 sampled 命中的),按首次出现顺序确定性枚举 | 保持 |
|
||||
| 补足不足时 | 全取,不报错 | 保持 |
|
||||
| 随机种子 | `random.Random(seed)` 局部实例 | 保持 |
|
||||
|
||||
内部辅助函数 `_ratio_stratified_sample` 和 `_backfill_per_class` 完整保留。
|
||||
|
||||
## 5. 职责边界
|
||||
|
||||
| 组件 | 职责 | 位置 | 谁 import 谁 |
|
||||
|------|------|------|-------------|
|
||||
| `GeneratedQuestion` | 题目数据结构 | `core/types.py` | 被所有层 import |
|
||||
| `load_benchmark` / `stratified_sample` | 加载 + 采样 | `app/question_gen/loader.py` | 被 `app/harness/` import |
|
||||
| `QuestionGenerator` Protocol | LLM 出题接口定义 | `app/ports.py` | 被未来 `adapters/` 实现 |
|
||||
| `tools/generate_questions.py`(未来) | LLM 出题一次性脚本 | `tools/` | 独立工具,不被其他模块 import |
|
||||
|
||||
`tools/generate_questions.py` 未来可实例化 `QuestionGenerator` 的 adapter 实现,但 `tools/` 本身不被 `app/` import(§5 硬性规则)。
|
||||
|
||||
## 6. 文档同步
|
||||
|
||||
以下章节需要更新:
|
||||
|
||||
| 文档 | 章节 | 变更 |
|
||||
|------|------|------|
|
||||
| `ARCHITECTURE.md` §1 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |
|
||||
| `ARCHITECTURE.md` §2.2 Mermaid | `QGEN` 节点 `generator.py` | → `loader.py` |
|
||||
| `CLAUDE.md` §1.5 表格 | DataLoader 行 `app/question_gen/generator.py` | → `app/question_gen/loader.py` |
|
||||
|
||||
**不变更**:`ARCHITECTURE.md §6` 核心算法保真清单 — `stratified_sample` 是采样工具函数,不属于 13 项核心算法(那些是建树 + 训练的关键算法)。
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
| 测试 | 路径 | 覆盖点 |
|
||||
|------|------|--------|
|
||||
| `GeneratedQuestion` 冻结性 | `tests/unit/test_core_types.py`(追加) | frozen 不可变、字段完整性 |
|
||||
| `load_benchmark` | `tests/unit/test_question_loader.py` | 正常加载、空目录、JSON 格式异常 |
|
||||
| `stratified_sample` | `tests/unit/test_question_loader.py` | 自然分布、分层采样、题型过滤、未知 correctness 当错题、对题在前返回顺序、题型保底、池不足报错、种子可复现 |
|
||||
@@ -0,0 +1,121 @@
|
||||
# 建树修复管线:熔断根因修复 + 断点续跑 + 并发改造
|
||||
|
||||
> 设计日期:2026-07-08
|
||||
> 状态:待批准
|
||||
> 关联:`app/tree/repair/`、`tools/repair_trees.py`、`adapters/telemetry.py`、`adapters/llm.py`、`adapters/breaker.py`
|
||||
|
||||
## 1. 背景与触发
|
||||
|
||||
当前修复管线串行跑 300 个视频,耗时 34 小时。上次运行日志(`logs/repair_trees.log`)与遥测库(`logs/repair_telemetry.db`)交叉定位出:**VLM 熔断并非服务挂了,而是遥测写入的主键冲突污染了重试与熔断器**,导致 102 个视频完全未修复(`L3=0`)即被跳过,而程序最终汇报"失败数: 0"(假象)。
|
||||
|
||||
本设计解决三件事:(1) 根治熔断误触发;(2) 视频级断点续跑;(3) 并发执行,默认 16 路。
|
||||
|
||||
## 2. 根因分析
|
||||
|
||||
VLM 偶发的真实瞬时错误(500/超时)按设计走 `transient → record_failure + 写遥测 + 退避重试`。但 `GovernedLLMClient.chat()`(`adapters/llm.py:299`)的 `call_id` 在重试循环外**只生成一次**,重试时复用同一 `call_id`:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["attempt 1: VLM 真实失败"] --> B["transient 分支<br/>record_failure + 写遥测 call_id=X ✓<br/>sleep 退避, continue"]
|
||||
B --> C["attempt 2: 又失败"]
|
||||
C --> D["transient 分支<br/>写遥测 call_id=X"]
|
||||
D --> E["💥 UNIQUE constraint<br/>SQLiteRecorder 无冲突容忍"]
|
||||
E --> F["IntegrityError 冒泡<br/>_is_transient_error 不认识它"]
|
||||
F --> G["非瞬时非致命分支<br/>再写遥测 call_id=X → 又冲突<br/>raise"]
|
||||
G --> H["regenerator except<br/>吞掉异常,跳过节点"]
|
||||
```
|
||||
|
||||
三个独立缺陷叠加放大:
|
||||
|
||||
| # | 缺陷 | 位置 | 后果 |
|
||||
|---|------|------|------|
|
||||
| 1 | `call_id` 重试时不重新生成 | `llm.py:299` 在 `for attempt` 外 | 同一 `call_id` 多次 INSERT,必然主键冲突 |
|
||||
| 2 | `SQLiteTelemetryRecorder` 无冲突容忍 | `telemetry.py:_write` 裸 INSERT,无 `except` | IntegrityError 冒泡,污染调用方 |
|
||||
| 3 | `_is_transient_error` 不识别 IntegrityError | `llm.py:424` 只认 httpx/StreamLiveness | IntegrityError 当"非瞬时非致命"抛出,重试失效 |
|
||||
|
||||
并发改造还会引爆第四个隐患:`SQLiteTelemetryRecorder` 每次写新建 `sqlite3.connect()`,并发 16 路同时写会撞 SQLite 表锁(`database is locked`)。
|
||||
|
||||
## 3. 三项改造设计
|
||||
|
||||
### 3.1 遥测防御性加固(根治根因)
|
||||
|
||||
**核心原则**:遥测是观测侧信道,绝不能拖垮主调用链(CLAUDE.md P5)。
|
||||
|
||||
**与 P5 的权衡说明**:P5 反对的是"掩盖**数据正确性**错误"(如 LLM 返回解析失败却用默认值继续,污染业务数据)。遥测写失败不损害任何业务数据——它只丢一条观测记录。这是**错误隔离**(isolation),不是掩盖错误。平衡点:捕获后**必须 `logger.warning`**,错误可见可追溯,但**不冒泡**到 LLM 重试链。静默 `pass` 才违反 P5,warning 不违反。
|
||||
|
||||
`SQLiteTelemetryRecorder._write` 三层加固:
|
||||
|
||||
| 层 | 做法 | 解决 |
|
||||
|----|------|------|
|
||||
| SQL 层 | `INSERT` → `INSERT OR IGNORE` | 主键冲突静默 |
|
||||
| 连接层 | `PRAGMA journal_mode=WAL` + `PRAGMA busy_timeout=5000` | 并发写锁降级为排队 |
|
||||
| 异常层 | 整个 `_write` 包 `try/except sqlite3.Error`,仅 `logger.warning` | DB 任何错误不冒泡到 LLM 重试链 |
|
||||
|
||||
WAL 模式允许"1 写 + 多读"并发,写之间靠 `busy_timeout` 自动排队等待(毫秒级,不报错),不引入新瓶颈。
|
||||
|
||||
`GovernedLLMClient.chat()` 的 `call_id` 生成移入重试循环内(每次 `attempt` 重新 `uuid4()`),消除根因——虽 `OR IGNORE` 后冲突不再致命,但 `call_id` 唯一性本身是对的。
|
||||
|
||||
**`call_id` 移入循环的遥测语义**:当前一次 `chat()` 调用在成功/各失败分支共用一个 `call_id`,语义是"一次逻辑调用 = 一条最终记录(最后一次 attempt 的结果)"。移入循环后语义变为"一次逻辑调用 = N 条记录(每 attempt 一条,按 `created_at` 可追溯重试轨迹)"。这更利于事后诊断重试行为。`parent_call_id` 是 `chat()` 入参(`llm.py:276`),在循环外固定,不受影响——每条 attempt 记录都正确关联到父 agent step。
|
||||
|
||||
### 3.2 视频级断点续跑
|
||||
|
||||
**判据双轨**:
|
||||
|
||||
| 轨 | 作用 | 内容 |
|
||||
|----|------|------|
|
||||
| **数据驱动判据** | 跳过已修干净的视频 | `detect_issues` 重扫,有 `empty_field` / L2 `event_description` 空 / L1 `scene_summary` 空才进队 |
|
||||
| **progress 文件** | 加速 + 审计 | `logs/repair_progress.json` 记 `finished_video_ids`,已修干净的视频按 ID 直接跳过,省 detect 开销 |
|
||||
|
||||
**关键判据边界**:跳过判据**只用 `empty_field` + L2/L1 空字段**,**不用 `missing_frame`**——修复根本不处理缺帧,用它判跳过会让缺帧视频永远进队死循环。
|
||||
|
||||
**其他 issue_type 的处理**:`no_children`(L2/L1 无子节点)是结构性缺陷,修复不处理(regenerator 只重生成 card,不改树结构)——**排除**出跳过判据,避免误判。`time_gap`(相邻 L2 时间间隙 >1s)是可接受的时间分布特征,非缺陷——**排除**。即只有 `empty_field`(L3 四必填字段 + 新增 L2 `event_description` + L1 `scene_summary`)参与跳过判定。
|
||||
|
||||
**首次续跑零成本接历史**:progress 文件不存在时,用 detect_issues 扫一遍初始化它,自动识别上次的 102 个未修视频,无需手动补状态文件。
|
||||
|
||||
**重修语义**:队列里的视频跑**完整级联**(修空 L3 后重生成所有 L2 和所有 L1),刷新临界区"陈旧但非空"的上层卡片。LLM 不可用时逐节点 `try/except` 自动优雅降级为"只修剩余"。
|
||||
|
||||
**临界区漏修处理(A 方案)**:临界区视频(L3 好、L2/L1 非空但过时)的卡片非空,`detect_issues` 扫不出 → 进不了队列 → 首次漏修。提供 `--reaggregate-all` 标志兜底(强制全量重聚合)。后续有 progress 文件即再无此问题。
|
||||
|
||||
### 3.3 并发执行
|
||||
|
||||
**编排结构**:照搬建树 `video_builder.py` 的 `asyncio.Semaphore(concurrency)` 范式。
|
||||
|
||||
| 点 | 做法 |
|
||||
|----|------|
|
||||
| 并发粒度 | Semaphore 限视频数 = concurrency,视频内四步(detect→repair→verify→supplement→save)串行 |
|
||||
| 失败隔离 | 单视频失败只记该视频 error,不影响其他路继续 |
|
||||
| progress 写入 | 并发下多协程向同一 `finished_video_ids` 追加,是读-改-写场景。用 **`asyncio.Lock` 保护读改写 + 临时文件 `os.replace` 原子替换**:锁内读旧 json → 追加 ID → 写 `.tmp` → `os.replace` 原子替换。锁保证不丢更新,rename 保证崩溃不留半写文件 |
|
||||
| 完成汇报 | 主循环每 N 个视频汇总进度 |
|
||||
|
||||
**熔断阈值适配并发**:`LLM_CIRCUIT_BREAKER_THRESHOLD`(`.env`)默认从 5 改为 `max(5, concurrency*2)`,保持单实例共享——上游 API 配额是全局的,熔断本就该全局生效。
|
||||
|
||||
**阈值覆盖关系**(D7 配置优先级):CLI `--concurrency` > `.env` 的 `LLM_CIRCUIT_BREAKER_THRESHOLD`。实际阈值为 `max(.env 显式值, concurrency*2)`——用户在 `.env` 显式设的阈值是下限保护(绝不低于它),`concurrency*2` 是并发自适应下限,两者取大。若用户未在 `.env` 设(用默认 5),则按 `concurrency*2` 生效。这样既尊重用户的显式运维配置,又保证并发下不会过激熔断。
|
||||
|
||||
**配置归属**:并发数用 CLI 参数 `--concurrency`(默认 16),**不**进 `config/default.yaml`。理由:`config/default.yaml` 的 `tree.concurrency` 是建树扫动参数(科研对比),修复并发是运维调度参数(本机 CPU/网络),混进同一 YAML 会串台(D7 规则)。
|
||||
|
||||
## 4. 改动范围
|
||||
|
||||
| 文件 | 改动 | 性质 |
|
||||
|------|------|------|
|
||||
| `adapters/telemetry.py` | `_write` 加 WAL + busy_timeout + `INSERT OR IGNORE` + `try/except sqlite3.Error` | 防御加固 |
|
||||
| `adapters/llm.py` | `call_id` 生成移入重试循环内 | 根因修复 |
|
||||
| `adapters/breaker.py` | 无改动 | — |
|
||||
| `tools/repair_trees.py` | 并发编排(Semaphore)+ 断点续跑(progress 文件 + detect 判据)+ `--concurrency`/`--reaggregate-all` CLI | 新增能力 |
|
||||
| `app/tree/repair/detector.py` | 空字段检测扩展到 L2 `event_description` / L1 `scene_summary` | 增强(零 LLM 成本) |
|
||||
| `.env.example` | 熔断阈值说明更新 | 文档 |
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
| 场景 | 验证 |
|
||||
|------|------|
|
||||
| 遥测主键冲突静默 | 重复 `call_id` 写入不抛异常 |
|
||||
| 遥测 DB 错误不冒泡 | 模拟 `sqlite3.OperationalError`,`record_llm_call` 不影响主调用 |
|
||||
| 并发写不报锁错 | 16 路 `to_thread` 并发写,无 `database is locked` |
|
||||
| `call_id` 重试唯一 | transient 重试后 DB 中各 attempt 独立记录 |
|
||||
| 断点续跑幂等 | 已修视频重跑直接跳过;progress 丢失靠 detect 恢复 |
|
||||
| 临界区完整级联 | 队列视频跑完后 L2/L1 全部重生成 |
|
||||
| 并发编排 | Semaphore 限流生效,单视频失败不阻断其他 |
|
||||
|
||||
## 6. 待确认风险
|
||||
|
||||
- **熔断后 progress 仍写入**:熔断期视频虽未修复但会跑完四步(repair 跳过→verify→supplement 失败→save),需要判定这种"跑完但没修"是否计为 `finished`。**建议:不计入 `finished`**,只记 detect 抓到的问题数为 0 且实际未调 LLM 的视频为 `skipped`,保证 progress 语义=真正修复完成。
|
||||
@@ -0,0 +1,293 @@
|
||||
---
|
||||
id: main-inference-entry
|
||||
title: main.py 推理入口 + 初始 Prompt 集设计
|
||||
type: design
|
||||
status: approved
|
||||
created: 2026-07-09
|
||||
---
|
||||
|
||||
# main.py 推理入口 + 初始 Prompt 集设计
|
||||
|
||||
## 1. 目标
|
||||
|
||||
完成第一版 900 道题推理基线,具体交付:
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|--------|------|
|
||||
| `main.py` | CLI Composition Root,本次仅实现 infer 模式 |
|
||||
| `app/harness/deps_router.py` | 按 video_id 懒加载 InferenceDeps 的路由器 |
|
||||
| `app/ports.py` 扩展 | 新增 `ToolDispatchFactory` / `PromptBuilderFactory` Protocol |
|
||||
| `app/harness/runner.py` 小改 | `__init__` 注入 factory,替换 noop 占位 |
|
||||
| `store/skills/v1/` | 13 个 skill(从 TRM4 v1 精简 + 注入 TRM5 card 字段) |
|
||||
| `store/prompts/v1/` | 目录重组(从 `store/prompts/` 扁平结构迁入) |
|
||||
| 配置变更 | concurrency=24, max_steps=40, breaker_threshold=48 |
|
||||
|
||||
## 2. 架构
|
||||
|
||||
### 2.1 依赖流(Clean Architecture Composition Root)
|
||||
|
||||
```
|
||||
main.py (Composition Root — 最外层)
|
||||
│
|
||||
├─ 构建 adapters ──────────────────────────────┐
|
||||
│ CircuitBreaker │
|
||||
│ RedisResponseCache (可选,降级为 None) │ adapters/
|
||||
│ SQLiteTelemetryRecorder │
|
||||
│ GovernedLLMClient (search) │
|
||||
│ GovernedLLMClient (evolve, 本次传同一实例) │
|
||||
│ GovernedVLMClient │
|
||||
│ LocalEmbeddingProvider │
|
||||
│ MonkeyOCRClient (可选) │
|
||||
│ │
|
||||
├─ 构建 InferenceDepsRouter ───────────────────┤
|
||||
│ 接收: store_dir, embed, llm, vlm, ocr │ app/harness/deps_router.py
|
||||
│ 复用: factory.build_inference_deps() │
|
||||
│ │
|
||||
├─ 构建 Runner ────────────────────────────────┤
|
||||
│ 注入: config, llm, evolve_llm, vlm, │ app/harness/runner.py
|
||||
│ telemetry, │
|
||||
│ tool_dispatch_factory, │
|
||||
│ prompt_builder_factory │
|
||||
│ │
|
||||
└─ asyncio.run(runner.infer()) ────────────────┘
|
||||
```
|
||||
|
||||
依赖只向内:`main.py` → `adapters/` + `app/` → `core/`。
|
||||
|
||||
### 2.2 InferenceDepsRouter
|
||||
|
||||
**位置**:`app/harness/deps_router.py`
|
||||
|
||||
**职责**:将 Runner 的全局统一 dispatch/prompt_builder 接口路由到 per-video 的 `InferenceDeps`。
|
||||
|
||||
```
|
||||
prompt_builder(qa) ← 先调用,注册 qid→vid 映射
|
||||
↓
|
||||
AgentLoop.run()
|
||||
↓
|
||||
dispatch(tool, args, ctx) ← 后调用,通过 ctx["session_id"] 查映射
|
||||
```
|
||||
|
||||
核心伪代码:
|
||||
|
||||
```python
|
||||
class InferenceDepsRouter:
|
||||
_deps_cache: dict[tuple, InferenceDeps] # (vid, skills_dir, prompts_dir) → deps
|
||||
_qid_to_vid: dict[str, str] # question_id → video_id
|
||||
|
||||
def create_dispatch(self, *, skills_dir=None):
|
||||
async def _dispatch(tool_name, args, *, context):
|
||||
vid = self._qid_to_vid[context["session_id"]]
|
||||
deps = self._ensure_deps(vid, skills_dir, ...)
|
||||
return await deps.tool_dispatch_fn(tool_name, args, context=context)
|
||||
return _dispatch
|
||||
|
||||
def create_prompt_builder(self, *, skills_dir=None, prompts_dir=None):
|
||||
def _builder(qa):
|
||||
self._qid_to_vid[qa.question_id] = qa.video_id # 注册映射
|
||||
deps = self._ensure_deps(qa.video_id, skills_dir, prompts_dir)
|
||||
return deps.prompt_builder(qa)
|
||||
return _builder
|
||||
|
||||
def _ensure_deps(self, video_id, skills_dir, prompts_dir):
|
||||
key = (video_id, str(skills_dir or ""), str(prompts_dir or ""))
|
||||
if key not in self._deps_cache:
|
||||
self._deps_cache[key] = build_inference_deps(...)
|
||||
return self._deps_cache[key]
|
||||
```
|
||||
|
||||
**时序保证**:`run_inference()` 中每道题先调 `prompt_builder(qa)` 构建 prompt,再启动 AgentLoop(调 dispatch)。映射注册总先于使用。
|
||||
|
||||
### 2.3 Runner 改动
|
||||
|
||||
`__init__` 新增 2 个可选参数,按 mode 做 fail-fast 校验:
|
||||
|
||||
```python
|
||||
def __init__(self, config, *, llm, evolve_llm, vlm, telemetry,
|
||||
tool_dispatch_factory=None,
|
||||
prompt_builder_factory=None):
|
||||
# mode in {"infer", "eval", "train"} 时 factory 为 None → 立即 ValueError
|
||||
if config.mode in {"infer", "eval", "train"}:
|
||||
if tool_dispatch_factory is None or prompt_builder_factory is None:
|
||||
raise ValueError(
|
||||
f"mode={config.mode} 需要 tool_dispatch_factory 和 prompt_builder_factory"
|
||||
)
|
||||
```
|
||||
|
||||
`_make_tool_dispatch_fn` / `_make_prompt_builder` 优先用注入值。测试场景可传 mock factory。
|
||||
|
||||
### 2.4 Protocol 定义
|
||||
|
||||
`app/ports.py` 新增精确类型的 Protocol:
|
||||
|
||||
```python
|
||||
class ToolDispatchFn(Protocol):
|
||||
"""工具调度函数签名。"""
|
||||
async def __call__(
|
||||
self, tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
|
||||
) -> str: ...
|
||||
|
||||
class ToolDispatchFactory(Protocol):
|
||||
"""per-version 工具调度工厂。"""
|
||||
def __call__(self, *, skills_dir: Path | None = None) -> ToolDispatchFn: ...
|
||||
|
||||
class PromptBuilderFn(Protocol):
|
||||
"""Prompt 构建函数签名。"""
|
||||
def __call__(self, qa: GeneratedQuestion) -> tuple[str, str]: ...
|
||||
|
||||
class PromptBuilderFactory(Protocol):
|
||||
"""per-version prompt 构建工厂。"""
|
||||
def __call__(self, *, skills_dir: Path | None = None,
|
||||
prompts_dir: Path | None = None) -> PromptBuilderFn: ...
|
||||
```
|
||||
|
||||
### 2.5 InferenceDepsRouter dispatch 防御
|
||||
|
||||
Router 的 dispatch 函数对缺失/未知 `session_id` 显式 raise 带诊断信息的 `KeyError`:
|
||||
|
||||
```python
|
||||
async def _dispatch(tool_name, args, *, context):
|
||||
session_id = context.get("session_id")
|
||||
if not session_id or session_id not in self._qid_to_vid:
|
||||
raise KeyError(
|
||||
f"未注册的 session_id={session_id!r},"
|
||||
f"已注册 {len(self._qid_to_vid)} 条映射"
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
### 2.6 main.py 结构
|
||||
|
||||
使用 argparse(复用 `RunConfig` + `load_config()` 的 YAML/CLI 三层合并逻辑)。
|
||||
|
||||
适配器参数通过 `InfraSettings(BaseSettings)` 从 `.env` 加载(遵循 CLAUDE.md §4.5 pydantic-settings 规范),禁止 main.py 直接散读环境变量:
|
||||
|
||||
```python
|
||||
class InfraSettings(BaseSettings):
|
||||
"""工程配置(少变/敏感),从 .env 加载。"""
|
||||
search_llm_model: str
|
||||
search_llm_base_url: str
|
||||
search_llm_api_key: str
|
||||
vl_llm_model: str
|
||||
# ... 其余 LLM/VLM/Redis/OCR/timeout/breaker 字段
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
def _build_adapters(settings: InfraSettings) -> _Adapters:
|
||||
# 从 settings 注入构建全套 adapters
|
||||
...
|
||||
|
||||
def _log_result(result): # 输出推理结果摘要
|
||||
def main(): # Composition Root:load_config → settings → adapters → router → runner
|
||||
```
|
||||
|
||||
本次只实现 `mode == "infer"` 分支,其余模式 `raise SystemExit("尚未实现")`。
|
||||
|
||||
> **Supersede 声明**:本设计的 `store/prompts/v1/` 版本化目录结构替代了 `2026-07-07-search-module-design.md` 中 `store/prompts/` 扁平结构的约定。`PromptManager(prompts_dir)` 今后总接收具体版本目录(`store/prompts/v1` 或 workspace 内 `prompts/vN`),不再接收 `store/prompts`。
|
||||
|
||||
## 3. 初始 Prompt 集
|
||||
|
||||
### 3.1 设计原则
|
||||
|
||||
```
|
||||
进化能学到的 → v1 留框架、去细节(给进化留空间)
|
||||
进化学不到的 → v1 用人类版(否则永远缺失)
|
||||
```
|
||||
|
||||
依据 TRM4 进化历史(v1→v40):skills 进化 39 轮(主力),system prompt 仅 1 轮,tool prompts 未变。
|
||||
|
||||
### 3.2 三类 Prompt 处理
|
||||
|
||||
| 类别 | 是否进化 | v1 来源 | 处理 |
|
||||
|------|---------|---------|------|
|
||||
| system.md | 微量 | TRM5 现有版本 | 保留(已含 card 字段说明,是基础设施) |
|
||||
| skills/v1/ (13个) | 主力 | TRM4 v1 精简 + TRM5 注入 | 见 §3.3 |
|
||||
| extract/verify (8个) | 不变 | TRM5 现有版本 | 保留(已适配 card 结构) |
|
||||
|
||||
### 3.3 Skills v1 内容策略
|
||||
|
||||
每个 skill 文件的改造规则:
|
||||
|
||||
| 内容层次 | 进化能学到? | v1 处理 |
|
||||
|----------|------------|---------|
|
||||
| YAML frontmatter(task_type) | 否 | 保留原版 |
|
||||
| Step 标题 + 一句话基本意图 | 否 | 保留原版 |
|
||||
| reflect/plan/action JSON schema | 否 | 保留原版 |
|
||||
| **TRM5 card 字段索引** | **否** | **新增**(注入各层字段名及适用场景) |
|
||||
| 数据驱动统计 | 是 | 移除 |
|
||||
| 步骤间精确转换条件 | 是 | 移除 |
|
||||
| 详细操作性规则 | 是 | 移除 |
|
||||
| 大多数自检信号 | 是 | 移除(保留最基本 1 条) |
|
||||
| 特定失败模式陷阱 | 是 | 移除(保留 1-2 条通用警告) |
|
||||
|
||||
**card 字段索引表**(注入每个 skill):
|
||||
|
||||
| 层级 | 字段 | 适用场景 |
|
||||
|------|------|---------|
|
||||
| L1 | scene_summary | 整体概况 |
|
||||
| L1 | key_entities | 查找人物/物体 |
|
||||
| L1 | main_actions | 主要动作 |
|
||||
| L1 | temporal_flow | 时间线概览 |
|
||||
| L1 | topic_keywords | 主题定位 |
|
||||
| L2 | event_description | 事件因果 |
|
||||
| L2 | entities / actions | 实体和动作细节 |
|
||||
| L2 | state_changes | 状态转变 |
|
||||
| L2 | spatial_relations | 空间关系变化 |
|
||||
| L3 | frame_summary | 精确视觉证据 |
|
||||
| L3 | visible_entities | 具体物体确认 |
|
||||
| L3 | ongoing_actions | 正在发生的动作 |
|
||||
| L3 | spatial_layout | 精确空间位置 |
|
||||
| L3 | visual_attributes | 光照、色调、机位 |
|
||||
| 全层 | visible_text | 画面文字(OCR) |
|
||||
| 全层 | subtitle | 字幕转写 |
|
||||
|
||||
### 3.4 store/ 目录重组
|
||||
|
||||
```
|
||||
store/
|
||||
prompts/
|
||||
v1/ ← 新建(从 store/prompts/*.md 移入)
|
||||
system.md
|
||||
observe_frame_extract.md
|
||||
observe_frame_verify.md
|
||||
search_similar_extract.md
|
||||
search_similar_verify.md
|
||||
view_node_extract.md
|
||||
view_node_verify.md
|
||||
view_node_children_extract.md
|
||||
view_node_children_verify.md
|
||||
skills/
|
||||
v1/ ← 新建(13 个文件)
|
||||
default-strategy.md
|
||||
action-reasoning.md
|
||||
action-recognition.md
|
||||
attribute-perception.md
|
||||
counting-problem.md
|
||||
information-synopsis.md
|
||||
object-reasoning.md
|
||||
object-recognition.md
|
||||
ocr-problems.md
|
||||
spatial-perception.md
|
||||
spatial-reasoning.md
|
||||
temporal-perception.md
|
||||
temporal-reasoning.md
|
||||
```
|
||||
|
||||
## 4. 配置变更
|
||||
|
||||
| 参数 | 原值 | 新值 | 位置 |
|
||||
|------|------|------|------|
|
||||
| concurrency | 12 | 24 | config/default.yaml |
|
||||
| max_steps | 15 | 40 | config/default.yaml |
|
||||
| breaker_threshold | 5 | 48 | .env |
|
||||
|
||||
熔断器行为:所有 24 并发 agent 共享同一 CircuitBreaker 实例,`record_success()` 归零计数。48 = 连续 2 波全失败才触发开路,间歇抖动不会误触。
|
||||
|
||||
## 5. 拒绝的方案
|
||||
|
||||
| 方案 | 拒绝原因 |
|
||||
|------|---------|
|
||||
| Runner 子类覆盖 `_make_*` | Template Method 反模式,违反依赖反转 |
|
||||
| 绕过 Runner 直接调 `run_inference` | 不复用 workspace 管理、日志、报告逻辑 |
|
||||
| 照搬 TRM4 v1 skills | 进化空间不足,且缺少 TRM5 card 字段知识 |
|
||||
| 极简骨架 skills | 基线质量过低,可能不收敛 |
|
||||
| typer CLI | 需重写 `RunConfig` 的 YAML/CLI 合并逻辑,ROI 不值 |
|
||||
@@ -0,0 +1,197 @@
|
||||
# 论文主图设计:Self-Evolving Search Agent 推理训练闭环
|
||||
|
||||
**日期** 2026-07-09 · **状态** 已获用户批准(口头) · **产出物** Figma 图(文件 `xnLGUkZottqnr4dsEt9fGq` Page 1 空白区)
|
||||
|
||||
## 1. 目标与定位
|
||||
|
||||
- **用途**:论文主图(目标会议存在文档分歧:CLAUDE.md=AAAI 2026,ARCHITECTURE.md 与记忆=EMNLP 2026,以用户最终决定为准,不影响本图设计),展示推理训练部分的自进化闭环;不含建树与新题生成(建树已有独立图,位于同一 Figma 文件)。
|
||||
- **核心叙事**:搜索 Agent 通过 推理→诊断→进化→门控 闭环自我改进;**Frozen LLM, trainable harness**——被"训练"的不是模型权重,而是版本化的 Skills+Prompts。
|
||||
- **差异化**:AVP/DVD 等相关工作画的是推理期内环(agent 怎么搜视频);本图内环只是一个面板,**训练期外环是主角**。
|
||||
- **审稿人一句话记忆点**:这是一个不动模型权重的 PyTorch 式训练循环。
|
||||
|
||||
## 2. 已确认的关键决策
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|---|---|
|
||||
| 构图 | 水平流水线 + 底部参数回流闭环(方案 A) |
|
||||
| 版式 | 双栏跨页宽图,画布 2400×1050 px(≈2.3:1,缩印 180mm) |
|
||||
| 信息密度 | 四机制全部可见(Agent 内环 / 诊断瀑布 / patch 引擎 / CE-Gate+信息阶梯),去工程化(无熔断/缓存/遥测) |
|
||||
| PyTorch 类比 | 底部独立双行对照条,与上方区域逐段对齐 |
|
||||
| 示例贯穿 | 延续建树图同一 Video-MME 天文台视频,问题/诊断/patch 文本典型化设计 |
|
||||
| 迭代维度 | Store 处 v1…vN 卡片堆叠 + ×N epochs 循环标记暗示,不加独立时间轴 |
|
||||
| 绘制位置 | 与建树图同文件(素材直接复用),Page 1 空白区 y≥1800,不动现有图层 |
|
||||
| 语言 | 图内文字全英文 |
|
||||
|
||||
## 3. 布局
|
||||
|
||||
> 下方 ASCII 草图为中文说明稿,仅示意区域关系;最终图层文字一律采用 §4/§5 的英文术语。
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [输入] ┌──────────────┐ ┌───────────┐ ┌──────────┐ ┌──────┐ │
|
||||
│ Q+缩略图→│ ① INFERENCE │→ │② DIAGNOSE │→ │③ EVOLVE │→ │④ CE- │ │
|
||||
│ │ 树环境+内环 │ │ 归因瀑布 │ │ patch引擎 │ │ GATE │ │
|
||||
│ └──────↑───────┘ └───────────┘ └──────────┘ └──┬───┘ │
|
||||
│ │ read ┌─────────────────┐ accept│ │
|
||||
│ └─────────────────│⑤ Skills+Prompts │←──────┘ │
|
||||
│ │ Store v1…vN ▤▤ │ reject→保基线
|
||||
│ └─────────────────┘ │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ DataLoader│forward()│backward()│optimizer.step()│grad clip│nn.Parameter│
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**数据流勘误记录**:CE-Gate 位于 Evolve 之后(进化产出候选 → gate 用 e-process 验证候选 vs 基线 → accept 才写入 Store),信息阶梯为 gate 供给高信息量题序,而非控制输入难度。此顺序已与 `core/evolution/gate.py`、`app/harness/gate_ladder.py` 核实。
|
||||
|
||||
## 4. 五区域内容规格
|
||||
|
||||
术语均与代码核实一致(来源见 §7)。
|
||||
|
||||
### 4.1 输入区
|
||||
- 问题卡(Q: *"What happens right after the dome opens?"* 措辞绘制时可打磨)+ 天文台视频缩略图 + 迷你树 icon,标注 "hierarchical video tree (Fig. 2)" 衔接建树图。
|
||||
|
||||
### 4.2 ① INFERENCE(agent-controlled,图上唯一画成循环的面板)
|
||||
- 树环境迷你版:L1/L2/L3 三层色带(绿/蓝/黄,复用建树图配色与缩略图)。
|
||||
- Agent 内环轨迹:Thought → `search_similar` → `view_node` → `observe_frame` → `submit_answer`;侧边 `read_skill` 箭头(来自 ⑤,工具名与 `app/search/tools.py:56-58` 一致)。
|
||||
- 示例结局:answer ✗(答错),输出 trace 流向 ②。
|
||||
- 面板角标:*agent-controlled*;其余面板角标 *code-controlled*。
|
||||
|
||||
### 4.3 ② DIAGNOSE(code-controlled)
|
||||
- 归因瀑布级联:`extraction failure → search failure → reasoning failure`(+ mixed 兜底),画成三级下落台阶。
|
||||
- 二分岔:**defect**(改 skill 正文)vs **lapse**(记 appendix 提醒)。
|
||||
- D1–D5 压缩为一排五个小 chip:attribution / tool quality / search behavior / skill compliance / decision patterns。
|
||||
- 示例:该题归因 `search failure` → 判 **defect**。
|
||||
|
||||
### 4.4 ③ EVOLVE
|
||||
- patch 流水线:candidate edits → **rank-and-clip** → apply patch。
|
||||
- 侧边锁条带:**protected spans**(appendix / momentum 区带锁图标,不可改写)。
|
||||
- 示例 patch 片段:*"+ verify event boundary via L2 card before observe_frame"*。
|
||||
- momentum 机制不单独出现(用户确认),仅隐含于锁条带。
|
||||
|
||||
### 4.5 ④ Validation · CE-GATE
|
||||
- 面板标题 **Validation · CE-Gate**:块顺序验证(`validate.py` 配对翻转 W/L)作为输入喂 e-process——图上画为"candidate vs baseline 配对小图 → e 曲线"。
|
||||
- e-process 小曲线:e 值随题数爬升,越过 `e_confirm` 虚线。
|
||||
- 四出口:**accept (confirmed) / accept (provisional) / reject / continue**(代码中三种 reject 在图上合并,用户确认)。
|
||||
- 侧挂小组件:信息阶梯(2:1 交错题序图标,标注 *info-max question ladder*),尺寸压小避免抢焦点。
|
||||
- 视觉层级:accept 主路径线最粗;reject/continue 细灰次级线。
|
||||
|
||||
### 4.6 ⑤ Skills+Prompts Store
|
||||
- v1…vN 卡片堆叠(复用建树图 Event Card 堆叠画法)+ 版本号 badge。
|
||||
- accept 箭头写入 v(N+1);read 箭头回流至 ①,构成大闭环;循环标记 **×N epochs**。
|
||||
|
||||
## 5. PyTorch 对照条(最底部)
|
||||
|
||||
浅灰底横带,等宽字体,与上方区域逐段对齐:
|
||||
|
||||
| 上方区域 | 对照文字 |
|
||||
|---|---|
|
||||
| 输入 | `DataLoader` |
|
||||
| ① | `model.forward()` |
|
||||
| ② | `loss.backward()` |
|
||||
| ③ | `optimizer.step()` |
|
||||
| ④ | `grad clipping (validate)`(对应 CLAUDE.md 类比表中"进化 validation = grad clipping";④ 面板同时含 validate 配对翻转与 CE-Gate 判定) |
|
||||
| ⑤ | `nn.Parameter` |
|
||||
|
||||
条带一侧放记忆点标语:*Frozen LLM, trainable harness*。
|
||||
|
||||
## 6. 视觉规范与素材复用
|
||||
|
||||
| 元素 | 方案 |
|
||||
|---|---|
|
||||
| 面板样式 | 白底、细虚线外框、顶部居中标题(沿用建树图) |
|
||||
| ① | 淡绿系 · ② 淡橙红系(新增,饱和度对齐现有 pastel) · ③ 淡紫系(复用 VLM 紫) · ④ 淡蓝系 · ⑤ 白卡+badge |
|
||||
| 直接复用 | 视频缩略图(candidate_a_t*)、L1/L2/L3 badge、Scene/Event/Frame Card 组件、VLM 紫块、箭头/chevron 样式 |
|
||||
| 字体 | 与建树图一致(Inter);标题 24px / 正文 16-18px / 标注最小 15px 灰(2400px 画布缩印 180mm 后 15px ≈ 1.1mm,12px 过小已弃用) |
|
||||
| 图层组织 | 顶层 Frame 命名 `Main Figure — Self-Evolving Loop`,五区域各一个子 Group,便于后续人工微调 |
|
||||
|
||||
## 7. 术语出处(代码核实)
|
||||
|
||||
| 图上术语 | 来源 |
|
||||
|---|---|
|
||||
| gate 四出口 accept_confirmed / accept_provisional / reject×3 / continue | `core/evolution/gate.py:57-110` |
|
||||
| 归因瀑布 extraction/search/reasoning/mixed;defect vs lapse | `core/evolution/diagnose.py:910-997` |
|
||||
| D1-D5 五维聚合 | `core/evolution/diagnose.py:1095-1307`(D2-D5)、`1551-1563` + `2230` + `2293-2296`(D1 attribution distribution) |
|
||||
| 信息阶梯冷启动 2:1、信息量排序 | `app/harness/gate_ladder.py:57-117` |
|
||||
| 块顺序验证配对翻转 W/L | `core/evolution/validate.py:12-69` |
|
||||
| rank-and-clip、protected spans、appendix/momentum 区 | `core/evolution/evolve.py:186-594`、`core/evolution/patch.py:11-56` |
|
||||
| agent 工具五件套(含 `read_skill`) | `app/search/tools.py:33-73` |
|
||||
| 版本目录 Store `store/skills/v{N}` / workspace 本地拷贝 | `app/harness/store.py:28-136`、`app/harness/workspace.py:103-108,150-152` |
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
1. 图在 Figma 中为独立顶层 Frame,可整体导出 PNG/SVG,缩印 180mm 宽时最小文字(12px 标注)仍可辨认。
|
||||
2. 五区域 + 对照条齐全,闭环箭头(⑤→① read、④→⑤ accept)无歧义。
|
||||
3. 全部术语与 §7 代码核实结果一致;无熔断/缓存/遥测等工程元素。
|
||||
4. 风格与同文件建树图肉眼一致(配色、字体、面板语言、卡片组件)。
|
||||
5. 不改动/移动建树图的任何现有图层。
|
||||
|
||||
## 9. 构图重构记录(2026-07-09 定稿后追加)
|
||||
|
||||
用户验收反馈:内容正确但"下半部空、无主线重点"。经方案比选(用户选 A),实施:
|
||||
|
||||
| 改动 | 内容 |
|
||||
|---|---|
|
||||
| 显式循环主干 | 面板间 chevron → 4px 黑色实心三角箭头(forward 主线);④→⑤ write 与 ⑤→① read 回流均为 4px 绿色实线带,⟳ ×N epochs 置于带上;黑/绿双色对应 forward / parameter-update 语义 |
|
||||
| 底部压缩 | Store 2080px 全宽行 → 680×170 紧凑块(右缘对齐 ④,write 直指 v5);对照条 130→84px;画布 1050→**940**(2.55:1) |
|
||||
| 填充 | motto 24px 斜体移至左下空区;read 带起点加绿色圆点锚记 |
|
||||
|
||||
逐模块精修均经 Claude 自审 + Codex 独立审双 PASS(Question/①/②/③/④/⑤+对照条/整图重构共 8 轮审核)。Codex 抓到的实质问题:④ 的 W/L 翻转数与 e 曲线出口统计不自洽(修正为序列省略号 + W=8·L=0,E=56.78>e_confirm=20)、ladder 色块数与题数不符、read 线易误读为边框。
|
||||
|
||||
## 10. 定稿修订记录(2026-07-10,本节覆盖 §2/§3 的布局规格)
|
||||
|
||||
逐模块精修完成后的第二轮构图重构与最终决策(均经用户逐项拍板):
|
||||
|
||||
| 决策 | 内容与理由 |
|
||||
|---|---|
|
||||
| 环形布局 | 画布 2400×940 → **1608×960**;顶行 Question→①→②→③(黑色 forward 箭头),③ 底部下沉入 ④,底行 ④→⑤ 右→左(绿色 write),⑤ 顶部 read 回流 ①;顺时针一圈 = 一个 epoch,上下缝压缩至 36px |
|
||||
| ④ 表义化 | 横版 840×280,右→左三段与底行流向一致:入口小票 `v6 · candidate`(③ 箭头落点)→ 配对复测表(v5·baseline 上 / v6·candidate 下,翻转列淡绿高亮,`W = 8 · L = 0` + 小注 `(4 of 8 flips shown)`)→ e-process 曲线越 `e_confirm = 20`(端点 `E = 56.78`,与 gate.py 公式验算一致)→ 绿色放行单卡片(与 ② 红色缺陷工单对仗)+ 虚线幽灵票 `else: no confirmed write`(if/else 结构;虚线沿用 ① 的"未采用路径"编码) |
|
||||
| 出口简化 | gate 四出口在图上压成 accept(confirmed) + else 两出口;else 措辞避免暗示"非 confirmed 全是 reject"(Codex I2) |
|
||||
| PyTorch 对照 | 底部全宽对照条 → Question 下方**纵向对照卡**(248×280 浅灰圆角块,Q/①…⑤ ↔ DataLoader/forward/backward/step/grad clipping/nn.Parameter 六行) |
|
||||
| ⑤ Store | 408×280 与 ④ 同高:v1..v5 灰卡 + **v6 ✦ 绿卡**(write 落点),motto 移入面板底部;版本叙事闭合:本轮 read v5 → 产出 v6 候选 → 放行写入 → `read (next epoch)` 读 v6(Codex I3 时序标注) |
|
||||
| 模型标签统一 | ① Agent / ③ LLM / 建树图 VLM 全部统一为 **Agent**(同紫块同图标;紫=冻结预训练模型,与绿=可训练资源对仗)。备注风险:建树图批量帧描述严格说非 agentic,正文表述需圆(如 captioning agent) |
|
||||
| 删减 | 面板间游离标签(trace + ✗ / defect)、⑤ 的 nn.Parameter 小标签、大 ⟳ 字符、全宽对照条均删除;`⟳ × N epochs` 合并为单行小字嵌缝 |
|
||||
|
||||
审核记录:④⑤+布局经 Codex 独立审(无 Critical;I1/I2/I3/M3 已采纳落实);整图终审 **PASS**(无 Critical/图内必改项)。终审遗留三项归入 **figure caption 与投稿检查**,不改图:
|
||||
1. caption 需说明"紫块 Agent = 冻结预训练模型组件,仅 ① 为 agent-controlled,训练对象是 harness"(消解 ③ 同款 Agent 块的误读,兼答 motto 与标签并存问题);
|
||||
2. 投稿前打印 180mm 宽样张,验证代码字体图例、`e_confirm = 20`、`(4 of 8 flips shown)` 等 14px 小字的缩印可读性。
|
||||
|
||||
## 11. 建树图风格统一记录(2026-07-10)
|
||||
|
||||
主图定稿后,用户认定主图风格更优,建树图向其看齐(内容结构不大改)。改动与决策:
|
||||
|
||||
| 决策 | 内容 |
|
||||
|---|---|
|
||||
| 视觉词汇表统一 | 两图共用同一套编码:虚线=堆叠/次要(不得用作焦点强调)、红色只留给错误、层级色 L1绿/L2蓝/L3黄贯穿;据此 Scene 2/Event 2 焦点框由灰虚线改层级色 3px 实线,Event i 红虚线框与 Event Card 红描边改 L2 蓝,Frame Card 描边补齐 L3 黄 |
|
||||
| 标题与结构 | 三区块标题 ① Long Video / ② Multi-scale Temporal Decomposition / ③ Build Tree(Inter Bold 24 + 编号居中);chevron 换主图同款 4px 黑箭头;全部游离节点收入顶层 Frame(1715×441);修正 3 处 "leval" 拼写与标题缺空格、删重复标题 |
|
||||
| Build Tree 补位 | 用户删除冗余 E1/E2 块后,空区放**空心骨架树**(白底+层级色描边,caption "tree skeleton (from ②) → attach a card to every node");三个 Agent 块加建树顺序徽章 ❶Event(L2)→❷Frame(L3)→❸Scene(L1),与算法#1 "L2 先行→L3 向下→L1 向上"一致 |
|
||||
| 两树同构 | 主图 Question 面板迷你树按同一对称几何重画(实心 24/20/16);空心=待填充骨架(建树图)、实心=成品(主图),唯一差别维度即语义 |
|
||||
| 模型标签 | 建树图 VLM 紫块随主图统一为 Agent(用户决策;正文 caption 需说明紫块=冻结模型组件) |
|
||||
|
||||
审核记录:Codex 独立审出 1 Critical(L3 展开帧时间戳越出 Event 2 区间,已改 0:21:30/0:21:32)+ 2 Important(竖排来源标注弱化为 16px 灰、"The Event Card of L2 corresponding to L3" 改 "parent event card (L2)")+ 1 Minor(Frame Card 黄描边),全部落实。遗留投稿检查项:❶❷❸ 徽章含义入 caption、180mm 样张验证小字。
|
||||
|
||||
### 11.1 ③ Build Tree 二次重构:配方表(2026-07-11,覆盖上文"Build Tree 补位"与"两树同构"两行)
|
||||
|
||||
同门反馈引发讨论后,用户裁定 ③ 的核心信息是**每类 Card 由什么输入生成**(建树流程而非建树结果),推翻骨架树方案。定稿设计与决策:
|
||||
|
||||
| 决策 | 内容 |
|
||||
|---|---|
|
||||
| 配方表结构 | 三行 = 三类卡的生成配方,**行序 = 建树顺序**(❶ Event→❷ Frame→❸ Scene,即 L2→L3→L1),顺序歧义消除;行区上方图例 `1 → 2 → 3 = construction order` 解释徽章 |
|
||||
| 输入模态编码 | **缩略图(边框层级色)= 图像输入;迷你 Event Card = 文本输入**。三行配方:两张蓝框帧 / 黄框帧 + 迷你卡 / 三张迷你卡堆叠 + ⋯(text only)——一眼读出模态递变:纯图 → 图+文 → 纯文 |
|
||||
| 迷你 Event Card | 58×56,淡蓝底 + 14px "Event" 标题 + 字段线 + 点列,与大卡视觉基因一致(经两轮迭代:无标题字形认不出、被拒调用残留一代旧卡致"重影",均已清理) |
|
||||
| 树的去留 | 大树/骨架树全部移除;两图迷你树同构(主图 Question 面板对称重画)保留 |
|
||||
| 内容纠错 | 借重画撞出两处原图错误并修正:Scene Card 字段 "frame summary"→"scene summary"(对照 `scene_summary`)、两卡 "visible test"→"visible text"(对照 `visible_text`) |
|
||||
| 主图 ② 阶段标签 | 三层注释:灰 `replay & collect evidence`(顶部)、斜体 `defect or lapse?`(过渡箭头旁)、红 `verdict`(贴红票左上缘,红色随工单类目);补齐同门指出的流程过渡感 |
|
||||
| 工具图例回退 | ① 工具名就近标注经试验后观感不佳,回退为底部图例(用户决定) |
|
||||
| 备份 | 重构前两图整体克隆锁定:Tree 备份 `2173:2`、Main 备份 `2173:276`(BACKUP 2026-07-10) |
|
||||
|
||||
本轮 Codex 终审 **PASS**(无 Critical/Important):配方三行与 `video_builder.py` 逐行核对一致(L2=chat_with_images(rep_frames)、L3=帧+l2_description、L1=l2_texts 纯文本聚合),字段名全部对应。Minor 归入投稿检查项:
|
||||
1. caption 补一句 "cards show selected fields"(L2 卡代码中还有 action_subjects/visible_text/spatial_relations,图内为节选);
|
||||
2. `verdict` 红字在极小缩印下有被误读为错误标注的微小风险,如需更稳可改深灰或加冒号(当前保持用户拍板的红色随工单方案)。
|
||||
|
||||
## 12. 被拒绝的备选方案
|
||||
|
||||
| 方案 | 拒绝原因 |
|
||||
|---|---|
|
||||
| B 上下双层 S 形回路 | PyTorch 对照条无法与面板逐段对齐,退化为角标 |
|
||||
| C 中心辐射环形 | 2.3:1 宽幅下横向空间浪费大,机制细节难展开,与建树图直线叙事不一致 |
|
||||
| 独立进化时间轴 | 占版面,与对照条拥挤;由 Store 版本堆叠 + ×N epochs 替代 |
|
||||
@@ -0,0 +1,477 @@
|
||||
---
|
||||
id: question-gen-synth
|
||||
title: 赛题生成工具设计(Question Generation Synthesis)
|
||||
type: design
|
||||
created: 2026-07-09
|
||||
status: draft
|
||||
---
|
||||
|
||||
# 赛题生成工具设计
|
||||
|
||||
## 1. 目标与动机
|
||||
|
||||
让视频树自行生成与 Video-MME 原始赛题风格、难度近似的四选一选择题,用于自进化训练循环的 DataLoader。原始 900 道 benchmark 题保留为 held-out 最终评测集,避免"直接拿答案调"的审稿质疑。
|
||||
|
||||
**角色定位**:生成题 = 训练集,原始题 = 测试集。进化循环的改进效果最终由原始 benchmark 验证泛化能力。
|
||||
|
||||
**训练 vs 论文评测的区分**:训练循环全程使用生成题(三池切分——诊断池/验证池/test 池——均来自生成题),论文报告的 held-out 泛化指标是训练结束后,用最终 best 版本对原始 benchmark 全量 900 题单独跑推理得到的结果。两步分离,Runner 代码无需改动。
|
||||
|
||||
## 2. 模块结构与职责边界
|
||||
|
||||
### 2.1 文件布局
|
||||
|
||||
```
|
||||
app/question_gen/
|
||||
├── __init__.py ← 已有:re-export loader API
|
||||
├── loader.py ← 已有:load_benchmark + stratified_sample
|
||||
└── synthesizer.py ← 新增①:出题核心逻辑
|
||||
|
||||
app/harness/
|
||||
└── factory.py ← 新增②:推理依赖组装(wiring)
|
||||
|
||||
tools/generate_questions.py ← 新增③:CLI 壳(generate + calibrate)
|
||||
```
|
||||
|
||||
### 2.2 职责切分
|
||||
|
||||
| 模块 | 职责 | 消费者 |
|
||||
|------|------|--------|
|
||||
| `synthesizer.py` | 题型-层级映射、锚节点采样、prompt 构造(few-shot)、embedding 去重、单题生成编排 | `tools/generate_questions.py` |
|
||||
| `factory.py` | 给定 store 路径 + config → 组装 LLM/VLM/Embedding/SearchToolDispatcher/PromptManager 全套推理依赖 | `tools/generate_questions.py`(校准)、未来 `main.py`、Runner |
|
||||
| `tools/generate_questions.py` | CLI 参数解析、并发编排(Semaphore)、进度日志、JSON 输出 | 用户直接运行 |
|
||||
|
||||
### 2.3 依赖方向
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TOOLS["tools/generate_questions.py"] --> SYN["app/question_gen/synthesizer"]
|
||||
TOOLS --> FAC["app/harness/factory"]
|
||||
TOOLS --> ADP["adapters/*"]
|
||||
FAC --> SEARCH["app/search/*"]
|
||||
FAC --> ENV["app/tree/environment"]
|
||||
FAC --> ADP
|
||||
SYN --> PROTO["core/protocols (VLMProvider, EmbeddingProvider via DI)"]
|
||||
SYN --> TYPES["core/types (GeneratedQuestion)"]
|
||||
SYN --> IDX["app/tree/index (TreeIndex)"]
|
||||
```
|
||||
|
||||
全部合规——外层→内层,`core/` 不依赖任何外层。
|
||||
|
||||
### 2.4 与 QuestionGenerator Protocol 的关系
|
||||
|
||||
`app/ports.py` 已预留 `QuestionGenerator` Protocol。本设计**不实现该 Protocol**——出题是一次性离线工具而非运行时能力,Runner 不需要运行时出题。`synthesizer.py` 的函数式接口(`generate_one` 等纯函数 + async 编排)比 Protocol class 更适合工具脚本场景。`QuestionGenerator` Protocol 保留但标记为"预留,当前无实现",不删除——若未来需要运行时出题可基于 synthesizer 的纯函数包装实现。
|
||||
|
||||
### 2.5 方案选择与否决
|
||||
|
||||
| 方案 | 否决理由 |
|
||||
|------|---------|
|
||||
| A: 单体脚本(全部逻辑放 `tools/`) | 业务逻辑(题型映射、采样、prompt、去重)混在 CLI 编排中,不可独立测试;不匹配 repair 管线的 app/ + tools/ 分层惯例 |
|
||||
| B: Protocol 实现 + 脚本编排(`adapters/` 实现 `QuestionGenerator`) | adapter 层语义是外部服务接口,出题逻辑是应用层业务规则,放 adapter 层语义不匹配 |
|
||||
| **C: app/ 业务逻辑 + tools/ CLI 壳(采用)** | 与 repair 管线结构一致,Clean Architecture 依赖方向合规,业务逻辑可独立测试 |
|
||||
|
||||
## 3. synthesizer.py 核心设计
|
||||
|
||||
### 3.1 题型-层级映射
|
||||
|
||||
模块级常量,沿用 TRM4 设计文档的映射表:
|
||||
|
||||
| 锚定层级 | 题型 | 帧图 | 文本上下文 | 帧数 |
|
||||
|---------|------|------|-----------|------|
|
||||
| L3 | Object Recognition | 必须 | frame_summary | 1 |
|
||||
| L3 | Attribute Perception | 必须 | frame_summary | 1 |
|
||||
| L3 | OCR Problems | 必须 | frame_summary | 1 |
|
||||
| L3 | Spatial Reasoning | 必须 | frame_summary + spatial_layout | 1 |
|
||||
| L3 | Spatial Perception | 必须 | frame_summary | 1 |
|
||||
| L2 | Action Recognition | 必须 | 事件 card | 2-3(子帧均匀采样) |
|
||||
| L2 | Action Reasoning | 必须 | 事件 card | 2-3 |
|
||||
| L2 | Counting Problem | 必须 | 事件 card | 2-3 |
|
||||
| L2 | Temporal Perception | 可选 | 事件 card + time_range | 0-1 |
|
||||
| L1 | Temporal Reasoning | 必须 | 根 card + 多个 L2 card(≥3) | 每 L2 取 1 张代表帧 |
|
||||
| L1 | Information Synopsis | 必须 | 根 card + 全部 L2 card | 每 L2 取 1 张代表帧 |
|
||||
| L1-L2 | Object Reasoning | 必须 | 2-3 个 L2 card | 每 L2 取 1 张代表帧 |
|
||||
|
||||
节点采样:每道题从全部视频树中随机选一棵,在对应层级随机选一个锚节点。同视频同题型不重复。L1 题型使用多个 L2 子节点联合输入时,按时间顺序组织节点,保持叙事连贯性。
|
||||
|
||||
### 3.2 AnchorContext 数据结构
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class AnchorContext:
|
||||
"""锚节点上下文——生成单道题所需的全部素材。"""
|
||||
node_id: str # 锚节点 ID
|
||||
card_text: str # 锚节点 card 序列化文本
|
||||
frame_paths: list[str] # 帧图片路径
|
||||
subtitle: str # 对应字幕(可空)
|
||||
distractor_texts: list[str] # 同视频其他节点摘要(供 VLM 生成干扰项)
|
||||
```
|
||||
|
||||
### 3.3 核心函数签名
|
||||
|
||||
```python
|
||||
# 纯函数:从树中采样锚节点 + 帧 + 上下文
|
||||
def sample_anchor(
|
||||
tree: TreeIndex,
|
||||
task_type: str,
|
||||
used_node_ids: set[str],
|
||||
rng: random.Random,
|
||||
) -> AnchorContext
|
||||
|
||||
# 纯函数:组装 VLM prompt(system + user,含 few-shot exemplar)
|
||||
def build_generation_prompt(
|
||||
task_type: str,
|
||||
anchor: AnchorContext,
|
||||
exemplars: list[GeneratedQuestion],
|
||||
) -> tuple[list[dict], list[str]]
|
||||
# 返回:(messages, image_paths) — 直接喂给 VLMProvider
|
||||
|
||||
# 纯函数:解析 VLM 返回的 JSON → 部分字段字典
|
||||
# source_nodes 和 difficulty 由 generate_one 在 parse 后用 anchor 信息补齐
|
||||
def parse_vlm_response(
|
||||
raw: str,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
) -> dict
|
||||
# 返回:{"question_id", "question", "options", "answer"} 字典
|
||||
# 调用方补齐 source_nodes/difficulty 后构造 GeneratedQuestion
|
||||
|
||||
# 纯函数:embedding 去重判定
|
||||
def is_duplicate(
|
||||
question_text: str,
|
||||
pool_embeddings: np.ndarray,
|
||||
embed_fn: Callable[[str | list[str]], np.ndarray],
|
||||
threshold: float,
|
||||
) -> bool
|
||||
|
||||
# 异步编排:生成单道题(含重试 + 去重循环)
|
||||
async def generate_one(
|
||||
vlm: VLMProvider,
|
||||
embed_fn: Callable[[str | list[str]], np.ndarray],
|
||||
tree: TreeIndex,
|
||||
video_id: str,
|
||||
task_type: str,
|
||||
seq: int,
|
||||
*,
|
||||
exemplars: list[GeneratedQuestion],
|
||||
pool_embeddings: np.ndarray,
|
||||
used_node_ids: set[str],
|
||||
max_retries: int,
|
||||
similarity_threshold: float,
|
||||
rng: random.Random,
|
||||
session_id: str,
|
||||
) -> GeneratedQuestion | None
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 纯函数(sample_anchor、build_generation_prompt、parse_vlm_response、is_duplicate)可独立单测,不需要 VLM
|
||||
- `generate_one` 是唯一异步函数,接收 `VLMProvider` 通过 DI
|
||||
- 干扰项来自 `AnchorContext.distractor_texts`——同视频其他节点的真实信息
|
||||
|
||||
### 3.4 few-shot exemplar 选择
|
||||
|
||||
生成 prompt 包含 2-3 道同题型的原始 benchmark 题作示例,对齐风格和难度。
|
||||
|
||||
选择策略:
|
||||
- 每题型取 `min(3, 该题型 benchmark 总量)` 道
|
||||
- 按 seed 随机采样 + 跨视频去重(避免 exemplar 全来自同一视频)
|
||||
- exemplar 是只读引用,不从 benchmark 评测集中移除
|
||||
|
||||
### 3.5 prompt 结构
|
||||
|
||||
```
|
||||
System: 视频理解题目生成器,根据视频树节点内容和帧图生成 {task_type} 四选一题。
|
||||
|
||||
[2-3 道该题型原始 benchmark 题作示例]
|
||||
|
||||
约束:
|
||||
- 问题必须基于给定节点内容,不能靠常识推断
|
||||
- 干扰项来自同视频其他节点的真实信息(非凭空捏造)
|
||||
- 难度和问法风格与示例一致
|
||||
|
||||
User: [锚节点 card + 字幕 + 帧图] + [同视频其他节点摘要,供干扰项素材]
|
||||
```
|
||||
|
||||
### 3.6 去重机制
|
||||
|
||||
用 `EmbeddingProvider`(nomic-embed-text-v1.5)对 question 文本做 embedding,余弦相似度检查:
|
||||
|
||||
| 检查对 | 阈值 | 处理 |
|
||||
|--------|------|------|
|
||||
| 生成题 vs 原始 benchmark 同题型题 | ≥ similarity_threshold | 丢弃,换节点重试 |
|
||||
| 生成题 vs 已生成的同题型题 | ≥ similarity_threshold | 丢弃,换节点重试 |
|
||||
|
||||
维护 embedding 池(原始题 + 已通过的生成题),每生成一道新题即时查重。单题最多重试 `max_retries` 次。某题型连续耗尽重试配额时,脚本报错退出并输出已完成/未完成的题型统计,不静默少题。
|
||||
|
||||
**并发去重安全**:embedding 池的"检查 + 添加"必须是原子操作。并发 `generate_one` 任务成功后,通过单线程汇总点(asyncio.Queue 或 await 后顺序提交)更新 embedding 池 + 写 JSON + 更新 progress,避免竞态导致相似题同时通过。
|
||||
|
||||
## 4. factory.py 推理依赖组装
|
||||
|
||||
### 4.1 解决的问题
|
||||
|
||||
目前 `Runner._make_tool_dispatch_fn()` 和 `_make_prompt_builder()` 都是 `raise NotImplementedError`,设计为"由 main.py 注入"。组装逻辑涉及 adapter 实例化 + app 组件串联,应提取为可复用的 factory 函数,避免在每个调用方(tools/ 脚本、未来 main.py)重复 wiring。
|
||||
|
||||
### 4.2 核心接口
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class InferenceDeps:
|
||||
"""跑一次推理所需的全套依赖(不含 HarnessLog,其生命周期由调用方管理)。"""
|
||||
llm: LLMProvider
|
||||
tool_dispatch_fn: Callable # SearchToolDispatcher.dispatch
|
||||
prompt_builder: Callable # PromptManager 的偏函数
|
||||
|
||||
def build_inference_deps(
|
||||
*,
|
||||
store_dir: Path,
|
||||
video_id: str,
|
||||
prompts_dir: Path,
|
||||
skills_dir: Path | None,
|
||||
skill_mode: str,
|
||||
embed_provider: EmbeddingProvider,
|
||||
llm: LLMProvider,
|
||||
vlm: VLMProvider,
|
||||
ocr: OCRProvider | None,
|
||||
verify_vision: bool,
|
||||
anchor: bool,
|
||||
assemble_mode: str,
|
||||
) -> InferenceDeps
|
||||
```
|
||||
|
||||
### 4.3 内部流程
|
||||
|
||||
```
|
||||
build_inference_deps()
|
||||
├── 加载 TreeIndex(store_dir/videos/{video_id}/tree.json)
|
||||
├── 构建 TreeEnvironment(index=tree, frames_dir=videos/{video_id}/frames)
|
||||
├── 构建 SkillRegistry(skills_dir,可选)
|
||||
├── 构建 SearchToolDispatcher(env, tool_llm, vlm, ocr, prompts_dir,
|
||||
│ skills, embed_fn, verify_vision, anchor, assemble_mode)
|
||||
├── 构建 PromptManager(prompts_dir)→ 偏函数化 prompt_builder(绑定 skill_mode)
|
||||
└── 返回 InferenceDeps
|
||||
```
|
||||
|
||||
注意:`HarnessLog` 不放入 `InferenceDeps`——其生命周期由调用方通过 `with HarnessLog(...) as log` 管理,作为参数传给 `run_inference`。
|
||||
|
||||
### 4.4 消费者
|
||||
|
||||
| 消费者 | 用法 |
|
||||
|--------|------|
|
||||
| `tools/generate_questions.py` calibrate | 按 video_id 分组题目,对每组调 `build_inference_deps` 构建对应视频树的依赖 → 分组 `run_inference` |
|
||||
| 未来 `main.py --mode infer` | CLI 参数映射到 factory 参数 |
|
||||
| `Runner` | `_make_tool_dispatch_fn` / `_make_prompt_builder` 改为委托 factory |
|
||||
|
||||
### 4.5 设计约束
|
||||
|
||||
- factory 只做**组装**,不持有状态——每次调用返回独立的 `InferenceDeps`
|
||||
- adapter 实例(LLM/VLM/Embedding)由调用方创建并传入,factory 不管 adapter 生命周期
|
||||
- 调用方自由决定 adapter 的复用策略(共享 vs 按需创建)
|
||||
|
||||
## 5. tools/generate_questions.py CLI 设计
|
||||
|
||||
### 5.1 子命令
|
||||
|
||||
```bash
|
||||
# 生成
|
||||
python tools/generate_questions.py generate \
|
||||
--store-dir store \
|
||||
--output-dir store/questions/generated/Video-MME \
|
||||
--per-type 20 \
|
||||
--similarity-threshold 0.85 \
|
||||
--max-retries 3 \
|
||||
--concurrency 8 \
|
||||
--seed 42
|
||||
|
||||
# 校准(生成题 vs benchmark 基线对比)
|
||||
python tools/generate_questions.py calibrate \
|
||||
--generated-dir store/questions/generated/Video-MME \
|
||||
--benchmark-dir store/questions/benchmarks/Video-MME \
|
||||
--store-dir store \
|
||||
--db-path results/calibrate.db \
|
||||
--prompts-dir store/prompts \
|
||||
--concurrency 4 \
|
||||
--max-steps 15 \
|
||||
--skill-mode auto \
|
||||
--tolerance 0.10 \
|
||||
--alpha 0.05 \
|
||||
--baseline-db <可选,已有基线 DB 路径> \
|
||||
--baseline-run-id <可选,已有基线 run_id>
|
||||
```
|
||||
|
||||
除 baseline 复用参数外均必传,无默认值(CLAUDE.md §4.5)。`--baseline-db` + `--baseline-run-id` 可选但必须成对出现:有则从 DB 读 benchmark 基线,无则自动跑一次 benchmark 推理。
|
||||
|
||||
### 5.2 generate 流程
|
||||
|
||||
```
|
||||
1. 加载 300 棵树的 video_id 列表
|
||||
2. 加载 benchmark 题目(作为 few-shot exemplar 来源)
|
||||
3. 初始化 embedding 池(benchmark 题 question text → embedding)
|
||||
4. 实例化 GovernedVLMClient + EmbeddingProvider
|
||||
5. 检查断点续跑文件(progress.json)
|
||||
6. 对 12 题型 × per_type:
|
||||
├── 跳过已完成的(断点续跑)
|
||||
├── 随机选视频 + 锚节点(同视频同题型不重复)
|
||||
├── asyncio.Semaphore(concurrency) 并发调 generate_one
|
||||
├── 成功 → 加入 embedding 池 + 追加到结果 + 更新 progress
|
||||
└── 连续耗尽重试 → 报错退出,输出已完成/未完成统计
|
||||
7. 按 video_id 分组写入 JSON
|
||||
8. 全部完成后删除 progress.json
|
||||
```
|
||||
|
||||
### 5.3 calibrate 流程
|
||||
|
||||
```
|
||||
1. load_benchmark 加载生成题和 benchmark 题
|
||||
2. 获取 benchmark 基线:
|
||||
├── 有 --baseline-db + --baseline-run-id → 从 DB 读 per_task_type accuracy
|
||||
└── 没有 → 按 video_id 分组 benchmark 题 → 每组 build_inference_deps
|
||||
→ 分组 run_inference → 汇总存 DB
|
||||
3. 按 video_id 分组生成题 → 每组 build_inference_deps → 分组 run_inference
|
||||
(每组使用对应视频的 TreeEnvironment,避免跨视频树错用)
|
||||
4. 汇总两组 per_task_type accuracy,对比(Fisher exact test)
|
||||
5. 输出对比表 + 判定结果
|
||||
6. 存在 FAIL → 退出码 1
|
||||
```
|
||||
|
||||
### 5.4 tools/ 脚本职责边界
|
||||
|
||||
脚本**只做**:argparse、adapter 实例化(读 `.env`)、Semaphore 并发、进度日志(loguru)、JSON 写入、calibrate 时调 factory + run_inference。
|
||||
|
||||
脚本**不做**:prompt 构造、节点采样、去重判定(synthesizer.py)、依赖组装逻辑(factory.py)。
|
||||
|
||||
## 6. 校准统计方法
|
||||
|
||||
### 6.1 问题
|
||||
|
||||
benchmark 题型分布极不均匀(Spatial Perception 仅 3 道 vs Object Reasoning 240 道),固定 10% 阈值对小样本题型会产生误判——单题翻转即 33% 波动。
|
||||
|
||||
### 6.2 组合判定:Fisher exact test + effect size
|
||||
|
||||
用 `scipy.stats.fisher_exact` 对每个题型构造 2×2 列联表:
|
||||
|
||||
| | 答对 | 答错 |
|
||||
|--|------|------|
|
||||
| Benchmark | a | b |
|
||||
| Generated | c | d |
|
||||
|
||||
判定规则:
|
||||
|
||||
| \|Δ\| > tolerance | p < α | 判定 | 含义 |
|
||||
|---|---|---|---|
|
||||
| ✗ | — | **PASS** | 差异在容忍范围内 |
|
||||
| ✓ | ✓ | **FAIL** | 差异大且统计显著——生成题难度确实偏了 |
|
||||
| ✓ | ✗ | **WARN** | 差异大但样本不足以确认——可能是噪声 |
|
||||
|
||||
### 6.3 优势
|
||||
|
||||
- 不需要 ad-hoc 的 `min_calibrate_size` 参数
|
||||
- 小样本题型自动降级为 WARN——Fisher test 的 p-value 天然反映样本量不足
|
||||
- CLI 只需两个语义清晰的统计参数:`--tolerance 0.10` + `--alpha 0.05`
|
||||
- 退出码只看是否存在 FAIL(WARN 不阻塞)
|
||||
|
||||
### 6.4 检测灵敏度与 per_type 的关系
|
||||
|
||||
| per_type | 可检出的最小差异(大样本 benchmark 侧) |
|
||||
|----------|---------------------------------------|
|
||||
| 20 | ~30%(仅极大差异) |
|
||||
| 50 | ~15%(中等差异) |
|
||||
|
||||
用户可根据需要的检测灵敏度选择 `--per-type`。
|
||||
|
||||
### 6.5 输出格式
|
||||
|
||||
```
|
||||
题型 | bench | gen | Δ | p-value | 判定
|
||||
-------------------|--------|--------|---------|---------|--------
|
||||
Spatial Perception | 66.7% | 40.0% | -26.7% | 0.590 | ⚠ WARN
|
||||
Action Reasoning | 72.2% | 68.0% | -4.2% | 0.712 | ✓ PASS
|
||||
Object Reasoning | 60.0% | 30.0% | -30.0% | 0.016 | ✗ FAIL
|
||||
```
|
||||
|
||||
## 7. 断点续跑
|
||||
|
||||
生成 240 道题可能中断(VLM 故障、手动 Ctrl-C),沿用项目已有的 progress.json 模式:
|
||||
|
||||
```json
|
||||
{
|
||||
"completed": {
|
||||
"Action Reasoning": ["gen-xyz-001", "gen-xyz-002"],
|
||||
"Object Recognition": ["gen-abc-001"]
|
||||
},
|
||||
"output_dir": "store/questions/generated/Video-MME"
|
||||
}
|
||||
```
|
||||
|
||||
- 启动时检查 `{output_dir}/progress.json`,跳过已完成的题
|
||||
- **恢复 embedding 池**:从已写出的 `{output_dir}/*.json` 重建已生成题的 embedding + `used_node_ids`,避免续跑后产生重复题
|
||||
- 每道题写入 JSON 后立即更新 progress
|
||||
- 全部完成后删除 progress.json
|
||||
|
||||
## 8. 输出格式
|
||||
|
||||
输出路径:`store/questions/generated/Video-MME/{video_id}.json`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"question_id": "gen-{video_id}-{seq}",
|
||||
"task_type": "Action Reasoning",
|
||||
"question": "...",
|
||||
"options": ["A. ...", "B. ...", "C. ...", "D. ..."],
|
||||
"answer": "B",
|
||||
"source_nodes": ["L1_000_L2_003"],
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
与 loader schema 兼容(额外 `source_nodes`/`difficulty` 字段用于溯源),`load_benchmark` 零改动直接加载。
|
||||
|
||||
**训练集成**:`--questions generated/Video-MME`。
|
||||
|
||||
## 9. 受影响的既有接口
|
||||
|
||||
| 接口 | 影响 | 适配 |
|
||||
|------|------|------|
|
||||
| `load_benchmark` | 无 | 输出与 loader schema 兼容(额外 source_nodes/difficulty 字段用于溯源) |
|
||||
| `RunConfig.questions` | 无 | 传 `generated/Video-MME` |
|
||||
| `build_or_load_pools` | 无 | 三池均来自生成题 |
|
||||
| `Runner._make_tool_dispatch_fn` | 改造 | 委托 factory.py |
|
||||
| `Runner._make_prompt_builder` | 改造 | 委托 factory.py |
|
||||
| `_VIDEO_MME_TASK_TYPE_COUNT` | **前置修复** | 从 11 改为 12(`app/harness/config.py:24`),影响验证池保底下限 |
|
||||
|
||||
## 10. 测试策略
|
||||
|
||||
### 10.1 synthesizer.py
|
||||
|
||||
| 测试 | 覆盖点 |
|
||||
|------|--------|
|
||||
| `test_sample_anchor` | 各层级题型正确采锚、同视频同题型不重复、树节点不足时报错 |
|
||||
| `test_build_generation_prompt` | messages 结构正确、exemplar 注入、图片路径列表、干扰项素材包含 |
|
||||
| `test_parse_vlm_response` | 正常解析、格式异常(缺字段/非法 JSON)报错 |
|
||||
| `test_is_duplicate` | 相似度 ≥ 阈值判重、< 阈值通过、空池不判重 |
|
||||
| `test_generate_one` | mock VLMProvider,验证重试+去重循环、耗尽重试返回 None |
|
||||
|
||||
### 10.2 factory.py
|
||||
|
||||
| 测试 | 覆盖点 |
|
||||
|------|--------|
|
||||
| `test_build_inference_deps` | fake LLM/VLM/Embedding 验证返回各字段非 None、类型正确 |
|
||||
| `test_missing_tree_file` | tree.json 不存在时报错 |
|
||||
|
||||
### 10.3 tools/generate_questions.py(集成级)
|
||||
|
||||
| 测试 | 覆盖点 |
|
||||
|------|--------|
|
||||
| `test_generate_smoke` | mock VLM + 1 棵真实树 + per_type=1,验证 JSON 输出格式 |
|
||||
| `test_progress_resume` | 中断后重启,跳过已完成题 |
|
||||
| `test_calibrate_pass_fail` | mock 两组 accuracy,验证 Fisher + tolerance 组合判定 |
|
||||
|
||||
真实 VLM 调用的 integration test 不在此次范围——依赖外部服务,不适合 CI。
|
||||
|
||||
## 11. 实现约束
|
||||
|
||||
- 完整类型注解 + 中文 Docstring(CLAUDE.md §4.2)
|
||||
- 禁用 `print()`,使用 loguru(CLAUDE.md §4.2)
|
||||
- 脚本放 `tools/`,不被其他模块 import(CLAUDE.md §5)
|
||||
- 并发模式:`asyncio.Semaphore`,CLI `--concurrency` 指定(沿用项目既有模式)
|
||||
- 所有 VLM 调用经过 `GovernedLLMClient` 治理栈(CLAUDE.md §4.9)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Spec-1:Agent 执行环境修复(解析容错 + 步级重试 + 摘要附实体)
|
||||
|
||||
- **日期**: 2026-07-11
|
||||
- **状态**: 已批准(用户确认,步级重试退避改为 20s/40s)
|
||||
- **依据**: `research-wiki/findings/2026-07-11-benchmark-failure-taxonomy.md` §四(T8,5 题)与 §二 M1(view_node 摘要吞 entities)
|
||||
- **系列**: Spec-1/2/3 三件套之一,见 [2026-07-11-batch-tree-build-design.md]、[2026-07-11-question-gen-v2-design.md]
|
||||
|
||||
## 1. 问题
|
||||
|
||||
| # | 缺陷 | 证据 | 影响 |
|
||||
|---|------|------|------|
|
||||
| A1 | `_parse_response`(`core/agent/loop.py:265-300`)只接受 `action.args` 嵌套结构;deepseek 稳定输出变体(args 平铺 + ```json 围栏)解析三连拒 → 0 步阵亡 | 637-3、615-3 | 整题报废,且 json_repair 修不了结构错位 |
|
||||
| A2 | LLM 调用异常(`loop.py:118-129`)直接整题终止,无步级重试 | 796-3(SSL BAD_RECORD_MAC 废掉 13 步上下文) | 一次网络抖动损失全部已积累推理 |
|
||||
| B | `summarize_node`(`app/search/summarizer.py`)两轮按题摘要后 entities/visible_text 不保证幸存 | 786-2、872-3、750-1(Agent 站在证据节点上漏读实体) | M1 负证据幻觉的恶化因素 |
|
||||
|
||||
## 2. 设计
|
||||
|
||||
### A1 解析容错(结构归一化层)
|
||||
|
||||
在现有 `repair_json → json.loads → 校验` 之后、返回 None 之前,增加确定性归一化:
|
||||
|
||||
1. **围栏剥除**:repair_json 前先剥 ```json / ``` 围栏(正则,幂等)。
|
||||
2. **args 收拢**:若 `action` 为 dict 且含 `tool` 但缺 `args`,把 action 下除 `tool` 外的所有平铺键收拢为 `args` 嵌套。
|
||||
3. 归一化成功 → 照常执行;失败 → 走现有 retry 追问路径(行为不变)。
|
||||
|
||||
纯函数实现,用 637-3/615-3 的真实坏输出作单测样本。
|
||||
|
||||
### A2 步级重试
|
||||
|
||||
`_call_llm` 异常处理改为步级重试循环:
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 重试次数 | 2 | 第 3 次失败才整题终止(stop_reason=error 不变) |
|
||||
| 退避 | 20s / 40s | 用户指定 |
|
||||
| 可重试异常 | 显式类型元组,默认 `(TimeoutError, OSError)`(`ssl.SSLError`/`ConnectionError` 均为 OSError 子类,覆盖实测穿透案例);作为构造参数可注入扩展 | 遵循 CLAUDE.md P5(不做全 Exception 兜底);core/ 不依赖 openai SDK——API 类异常由 GovernedLLMClient 内部重试栈负责;`asyncio.CancelledError` 绝不吞;未知异常 fail-fast 整题终止(现状行为) |
|
||||
| 上下文 | 原样保留 | messages 不回滚,重试即重发 |
|
||||
| 遥测 | 失败尝试的 error 记录由 `GovernedLLMClient` 内部负责(已有);AgentLoop 侧只以 loguru 记录步级重试事件(不注入 TelemetryRecorder) | AgentLoop 无遥测端口,不越层补写 |
|
||||
|
||||
### B 摘要附带实体原文
|
||||
|
||||
在 **dispatcher 侧**(`SearchToolDispatcher._handle_view_node`)由确定性代码(非 LLM)在摘要结果末尾追加节点 card 的字段原文:
|
||||
|
||||
```
|
||||
[实体] <entities + visible_entities 原文>
|
||||
[画面文字] <visible_text 原文>
|
||||
```
|
||||
|
||||
- **调用链改动**(Codex 审查修正):`summarize_node` 只收 `raw_text: str`、无结构化 card 访问,且 `_node_full_text` 递归收值不保留字段名——因此在 `TreeEnvironment` 新增结构化字段提取方法(如 `node_entity_fields(node_id) -> dict[str, str]`),dispatcher 调用它并把区块拼接到 summarize_node 返回值之后;summarize_node 本体不改
|
||||
- 摘要后追加 → LLM 无法吞掉;字段为空则不加对应区块
|
||||
- 对 anchor / 非 anchor 两种模式一致生效
|
||||
|
||||
## 3. 不做什么(YAGNI)
|
||||
|
||||
- 不改 GovernedLLMClient 的内部重试栈(已有四层治理)
|
||||
- 不改判分协议、不动 prompt 版本化内容
|
||||
- 不做异常分型重试策略(统一兜底已覆盖已知案例)
|
||||
|
||||
## 4. 验证
|
||||
|
||||
1. 单测:坏输出样本(围栏/平铺/两者叠加)归一化正确;空 content、缺 tool 仍拒
|
||||
2. 单测:步级重试计数与退避(mock LLM 抛错)
|
||||
3. 单测:summarize_node 追加区块(有/无实体字段两种节点)
|
||||
4. 集成:抽 10 道 T2/T8 错题重跑(637-3、615-3、786-2、872-3、750-1 必含),对比修复前后
|
||||
5. `make test` 全绿 + 覆盖率不降
|
||||
|
||||
## 5. 被否方案
|
||||
|
||||
- **prompt 层要求 LLM 修正输出格式**:治标,deepseek 变体是稳定行为,代码归一化是确定性修复
|
||||
- **重试时区分异常类型**(仅网络类重试):已知案例全是穿透型异常,分型收益低且易漏
|
||||
@@ -0,0 +1,93 @@
|
||||
# Spec-2:建树批量并行入口
|
||||
|
||||
- **日期**: 2026-07-11
|
||||
- **状态**: 已批准(用户确认两层参数推荐方案)
|
||||
- **系列**: Spec-1/2/3 三件套之一,见 [2026-07-11-agent-runtime-fixes-design.md]、[2026-07-11-question-gen-v2-design.md]
|
||||
|
||||
## 1. 问题
|
||||
|
||||
TRM5 只有单视频建树(`app/tree/video_builder.py`,内部 Semaphore(16) 限 VLM/LLM 调用)与修复/迁移工具,**没有多视频批量构建入口**——批量建树只能视频间串行,非 API 阶段(ffmpeg 帧提取、图像编码、IO)与 API 阶段无法跨视频重叠,太慢。
|
||||
|
||||
## 2. 并发语义调研结论(项目惯例)
|
||||
|
||||
| 位置 | 并行单元 | 惯例 |
|
||||
|------|---------|------|
|
||||
| `app/harness/inference.py` | 题目 | 一个 `asyncio.Semaphore` + `gather`,任务级 |
|
||||
| `tools/repair_trees.py` | 视频 | 视频级 Semaphore + gather + progress.json + 熔断阈值随并发缩放 |
|
||||
| `app/tree/video_builder.py` | API 调用 | Semaphore 作为参数在协程链中显式传递 |
|
||||
|
||||
建树是唯一任务内部本身有大并发的场景 → **视频级与 API 级信号量必须分开**,否则 16×16=256 API 并发打爆端点与熔断器。
|
||||
|
||||
## 3. 设计
|
||||
|
||||
### 入口形态(遵循项目结构规范)
|
||||
|
||||
- `tools/build_trees.py`:独立工具(不被其他模块 import),复刻 `repair_trees.py` 的编排模式
|
||||
- `scripts/build_trees.sh`:自包含实验记录,写死参数、零参数复现(GPU 卡号除外)
|
||||
|
||||
### 两层并发参数
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph tools/build_trees.py
|
||||
V[视频级 Semaphore<br/>video_concurrency=16] --> B1[VideoTreeBuilder 视频A]
|
||||
V --> B2[VideoTreeBuilder 视频B]
|
||||
V --> B3[...]
|
||||
end
|
||||
B1 --> API[全局共享 Semaphore<br/>api_concurrency=16]
|
||||
B2 --> API
|
||||
B3 --> API
|
||||
API --> E[VLM/LLM 端点]
|
||||
```
|
||||
|
||||
| 参数 | 默认 | 语义 |
|
||||
|------|------|------|
|
||||
| `--video-concurrency` | 16 | 同时在建的视频数;吞吐提升来自非 API 阶段跨视频重叠 |
|
||||
| `--api-concurrency` | 16 | 全局在途 VLM/LLM 调用上限,跨所有视频共享**一个** Semaphore 实例——端点压力与今天单视频建树完全一致 |
|
||||
|
||||
熔断阈值按 repair_trees 惯例缩放:`max(cfg_threshold, api_concurrency * 2)`。
|
||||
|
||||
**配置归属(D7 规则,Codex 审查补充)**:
|
||||
|
||||
| 参数 | 归属 | 理由 |
|
||||
|------|------|------|
|
||||
| `api_concurrency` | 工程配置 `.env`(`TREE_BUILD_API_CONCURRENCY=16`) | 端点保护参数,少变、随部署环境定 |
|
||||
| `video_concurrency` | sh 脚本写死(默认 16)+ CLI 单次覆盖 | 单机吞吐参数,随硬件观测调整,不进科研 YAML(不会被实验扫动) |
|
||||
|
||||
### builder 改动(唯一的存量修改)
|
||||
|
||||
两处(Codex 审查修正后):
|
||||
|
||||
1. **公开异步入口**:现有 `build()` 是同步壳(内部 `asyncio.run(self._build_async(...))`),在异步批量编排里调用会触发"事件循环嵌套"运行时错误。将 `_build_async` 提升为公开 `build_async()` 供批量工具调用;同步 `build()` 保留原样(内部改为调 `build_async`),单视频调用方零影响。
|
||||
2. **Semaphore 注入**:内部 Semaphore 改为构造器可选参数(不传则自建,行为零变化)。builder 内部协程链本就显式传递 Semaphore,改动面极小。
|
||||
|
||||
### 断点续跑
|
||||
|
||||
- 视频级:`progress.json`(复用 repair_trees 的 `save_progress` 模式);tree.json 存在且完整性校验通过的视频自动跳过
|
||||
- 视频内:现有段级恢复(核心算法 #3)不动
|
||||
|
||||
### 输入输出
|
||||
|
||||
- 输入:`--videos-dir`(视频文件 + 可选同名 SRT)
|
||||
- 输出:`store/videos/<video_id>/tree.json`;帧持久化沿用现有 cache 机制
|
||||
|
||||
## 4. 风险与观测
|
||||
|
||||
- 16 路并行 ffmpeg/cv2 解码可能压满 CPU/磁盘 → 实现时输出速率日志(视频/分钟,复刻 repair_trees),观测后再调 video_concurrency
|
||||
- 日志遵循"禁止缓存、立即输出"(CLAUDE.md §2.1)
|
||||
|
||||
## 5. 不做什么(YAGNI)
|
||||
|
||||
- 不做分布式/多机;不做动态并发自适应
|
||||
- 不改单视频建树算法(核心算法 #1/#2/#3 保真,仅信号量注入)
|
||||
|
||||
## 6. 验证
|
||||
|
||||
1. 单测:Semaphore 注入后单视频行为不变(默认自建路径)
|
||||
2. 集成:3-4 个短视频小批量构建,验证跨视频并行、progress 跳过、全局 API 信号量生效(遥测里在途调用数 ≤ api_concurrency)
|
||||
3. 中断-恢复测试:构建中 Ctrl+C 后重跑,已完成视频跳过、未完成视频从段级断点续跑
|
||||
|
||||
## 7. 被否方案
|
||||
|
||||
- **单一视频级 Semaphore(repair_trees 原样照搬)**:建树内部并发大,总 API 并发 = 视频数 × 内部并发,不可控
|
||||
- **仅共享全局 API Semaphore、视频数不限**:任意多视频同时提帧会压垮磁盘 IO/CPU
|
||||
@@ -0,0 +1,142 @@
|
||||
# Spec-3:出题管线 v2(失败机理靶向 + 逐题质量门)
|
||||
|
||||
- **日期**: 2026-07-11
|
||||
- **状态**: 已批准(用户确认:双标签体系、轻量档全量 + 重量档 15% 抽检)
|
||||
- **依据**: 三轮分析——`findings/2026-07-11-question-gen-calibration-analysis.md`(生成题缺陷与根因)、`findings/2026-07-11-benchmark-failure-taxonomy.md`(242 错题机理分类与 11 种题型规格)
|
||||
- **系列**: Spec-1/2/3 三件套之一;Spec-1 修好的推理环境是本 spec 重量抽检的前置
|
||||
|
||||
## 1. 目标重定义
|
||||
|
||||
出题目标从"难度与 benchmark 一致"改为:**覆盖已证实的失败机理(M1-M5)+ 逐题质量门**。
|
||||
|
||||
| 决策 | 内容 |
|
||||
|------|------|
|
||||
| calibrate 降级 | 仅作观测性报表,不再是验收门 |
|
||||
| 评分协议不变 | 标准四选一按字母判分;harness/推理侧零改动。题型规格中"附证据节点 id"等要求降级为**构造时验证材料**,存题目元数据供 diagnose 分析 |
|
||||
| 双标签体系 | 主标签 `task_type`(Video-MME 12 类,进化循环 mini-batch/gate/skills/diagnose 零改动)+ 附加字段 `skill_target`(M1-M5/题族,仅用于出题配比、质检、覆盖率统计) |
|
||||
| 质检两档 | 轻量四门全量逐题;重量档(盲 Agent 全树试答)15% 抽检 + 难度标签 |
|
||||
|
||||
## 2. 流水线架构
|
||||
|
||||
重构 `app/question_gen/synthesizer.py` + `tools/generate_questions.py` generate 子命令:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S[采样器<br/>按题族选素材] --> G[生成器<br/>题族 prompt 模板<br/>双标签输出]
|
||||
G --> P[确定性后处理<br/>shuffle+答案重映射<br/>指代黑名单<br/>verbatim 检测]
|
||||
P --> Q{轻量四门 全量}
|
||||
Q -->|拒| R[带拒因重出<br/>同 slot 最多 3 次]
|
||||
R --> G
|
||||
Q -->|过| H[重量抽检 15%<br/>盲 Agent 试答→difficulty_steps]
|
||||
H --> W[入库 generated-v2]
|
||||
```
|
||||
|
||||
### 2.1 五题族(11 种题型的落地归并)
|
||||
|
||||
每族一个采样器 + 一个 prompt 模板,题型(shape)作为模板参数:
|
||||
|
||||
| 题族 | 覆盖题型 | 采样约束 | 治什么 | 默认配比 |
|
||||
|------|---------|---------|--------|---------|
|
||||
| 检索族 | 证据埋深/反"不存在"、ASR 实体对齐、属性辨析 | 证据取自 entities/visible_entities/visible_text/长字幕单句;配镜像题(真不存在) | M1 负证据幻觉(最大杠杆) | 30% |
|
||||
| 推理族 | 转述还原、NOTA 校准对 | 从字幕单句做一步语义变换(蕴含/虚拟语气/序数映射);NOTA 成对生成(半正解半陷阱) | M2 字面匹配 | 25% |
|
||||
| 枚举族 | 多实例锚定、序数枚举、覆盖率权重 | **source_nodes ≥ 3 跨 L1**;同类事件 ≥2 次;首个表面匹配必须是错的 | M3 锚定/盘点 | 20% |
|
||||
| 视觉族 | 状态演化多帧、实例消歧数字 | 证据仅在帧内、不在任何 card 文本;多时刻真实读数做干扰项 | M4 视觉验证 | 15% |
|
||||
| 空间族 | 参照系空间 | spatial_layout 字段 + 时间锚;摄像机/被摄者双参照系 | T5 空间 | 10% |
|
||||
|
||||
**题族 × task_type 兼容矩阵**(Codex 审查补充;✓=合法组合,采样器按此分配双标签,保证 12 类各 20 题的硬约束可满足):
|
||||
|
||||
| 题族 | 合法 task_type |
|
||||
|------|---------------|
|
||||
| 检索族 | Object Recognition、Object Reasoning、Action Recognition、Attribute Perception、OCR Problems |
|
||||
| 推理族 | Action Reasoning、Object Reasoning、Information Synopsis |
|
||||
| 枚举族 | Counting Problem、Temporal Reasoning、Temporal Perception、Information Synopsis |
|
||||
| 视觉族 | Attribute Perception、Counting Problem、OCR Problems、Action Recognition |
|
||||
| 空间族 | Spatial Perception、Spatial Reasoning |
|
||||
|
||||
每个 task_type 至少落入一个题族;Spatial 两类仅由空间族供给。**"不出 T1 类素材题"是素材形态禁用**(不采瞬时动作/记分牌瞬时数值/无对白因果类素材),不删除任何 Video-MME task_type。
|
||||
|
||||
### 2.2 确定性后处理(零 LLM 成本,通用硬约束)
|
||||
|
||||
1. **选项 shuffle** + 答案字母重映射(修复 v1 答案 57% 在 A 的偏斜)
|
||||
2. **指代黑名单**(正则):禁 this segment / this frame / this clip / the current frame / the scene / frame summary 等;题干必须含 L1 time_range 时间锚("between 10:52 and 21:44"式)或全局限定语("in the entire video")
|
||||
3. **verbatim 检测**:题干+正确项 vs 源节点文本的 n-gram 重合门(检索族豁免正确项检测——其证据本来就在文本,见 2.3)
|
||||
4. **出题禁区**:不出 T1 类素材题(瞬时动作/记分牌时序/无对白因果);不复刻 T7 噪声模式(选项重复、口径含糊的计数边界)
|
||||
|
||||
### 2.3 轻量四门(全量逐题,约 4 次单轮 LLM 调用/题)
|
||||
|
||||
| 门 | 判定 | 杀什么 |
|
||||
|----|------|--------|
|
||||
| 键验证 | 拿出题依据(source_nodes 原文)判标注答案是否成立 | 标注幻觉(v1 CP 组 2 例无出处) |
|
||||
| 盲答测试 | 不给任何视频信息裸答,答对即拒 | 常识可解题、干扰项秒排题 |
|
||||
| 多真测试 | 拿全树素材判是否 >1 选项可为真 | 歧义多解题(v1 SR 组 5/8) |
|
||||
| 泄漏测试 | **按题族条件化**(见下) | 一跳检索捷径 |
|
||||
|
||||
**泄漏门的题族捷径画像**("信息不对称化"的落地):
|
||||
|
||||
| 题族 | 捷径画像(该捷径必须失败才放行) |
|
||||
|------|--------------------------------|
|
||||
| 检索族 | top-5 语义搜索片段裸答必须失败(全树 card 文本裸答**允许**成功——考的是检索深度) |
|
||||
| 视觉族 | 全树 card 文本裸答必须失败(逼 observe_frame) |
|
||||
| 推理/枚举/空间族 | 锚点节点文本裸答必须失败(需变换/跨节点) |
|
||||
|
||||
### 2.4 重出循环与重量抽检
|
||||
|
||||
- 拒题 → 拒因回填到生成 prompt → 同 slot 重出,最多 3 次;3 次仍拒则该 slot 换素材重采样
|
||||
- 通过四门的题按 15% 抽样跑盲 Agent 全树试答(复用 Spec-1 修复后的推理管线):验证真实可答性,产出 `difficulty_steps` 难度标签
|
||||
|
||||
## 3. 数据与版本
|
||||
|
||||
| 项 | 决策 |
|
||||
|----|------|
|
||||
| 旧 240 题 | 原地保留 `store/questions/generated/`(infer_gen240 run 引用它,保可复现) |
|
||||
| 新题集 | `store/questions/generated-v2/`,布局与 v1 一致:`{video_id}.json` 平铺(无子目录)。CLI 示例:生成 `python tools/generate_questions.py generate --output-dir store/questions/generated-v2 ...`;推理 `python main.py --mode infer --questions generated-v2 --run-id gen240v2` |
|
||||
| 题目元数据新增 | `skill_target`、`source_nodes`(已有)、`gate_report`(四门判定)、`difficulty_steps`(抽检题) |
|
||||
|
||||
**元数据承载方式(Codex 审查修正)**:现有 `GeneratedQuestion`(`core/types.py`)为固定 8 字段,`load_benchmark` 丢弃未知 JSON 字段,池快照只存固定字段。约定:
|
||||
|
||||
| 字段 | 承载 | 进训练链路 |
|
||||
|------|------|-----------|
|
||||
| `skill_target`、`difficulty_steps` | 扩展 `GeneratedQuestion` 为可选字段(默认 None,benchmark 题不受影响);loader/pools 同步保留 | 是(diagnose 可按 skill_target 聚合报表) |
|
||||
| `gate_report` | 只存在于题目 JSON(溯源用)与生成期 SQLite;loader **不加载**(体积大且训练不需要) | 否 |
|
||||
| 规模 | 默认 240 题;**task_type 均匀(20/类)为硬约束**(进化循环分层需要),**族配比为软目标(±5%)**——采样器按"族 × task_type 兼容矩阵"(如枚举族→Counting/Temporal 类)分配每题的双标签;YAML 可扫 |
|
||||
|
||||
## 4. 配置归属(D7 规则)
|
||||
|
||||
- **科研配置**(per-experiment YAML):族配比、门阈值(n-gram 窗口、多真判定温度)、抽检率、重出上限、规模
|
||||
- **工程配置**(`.env`):LLM/VLM 端点、超时、熔断——沿用现有
|
||||
|
||||
## 5. 运行时数据与治理
|
||||
|
||||
- 每题的门判定记录(哪门拒、拒因文本、重出轮次、最终状态)落 SQLite。表结构在设计批准后走 `structured-logging` skill 单独设计(本 spec 只约定:记录必须逐题可追溯、可聚合出各门拦截率报表)
|
||||
- **质量门 LLM 调用治理(Codex 审查补充)**:轻量四门与生成器的全部 LLM/VLM 调用必须经 `GovernedLLMClient`/`GovernedVLMClient` + `TelemetryRecorder`(CLAUDE.md §4.8/§4.9),严禁裸调 SDK;门执行器通过依赖注入接收客户端实例,session_id 用生成批次 id、parent_call_id 链接到题目生成调用
|
||||
|
||||
## 5.5 核心接口概要(Codex 审查补充,完整签名留给 plan)
|
||||
|
||||
| 类型/函数 | 职责 |
|
||||
|----------|------|
|
||||
| `QuestionFamilySpec` | 题族声明:采样约束、prompt 模板、泄漏门捷径画像、合法 task_type 集合 |
|
||||
| `CandidateQuestion` | 生成器输出:题面 + 双标签 + source_nodes + 构造验证材料(未过门) |
|
||||
| `GateReport` | 四门判定结果:每门 pass/reject + 拒因文本 |
|
||||
| `run_gates(candidate, deps) -> GateReport` | 门执行器(依赖注入 LLM/树环境) |
|
||||
| `generate_one` 迁移 | v1 签名(只收 task_type)废弃,v2 收 `(family_spec, task_type, slot_seed)` 返回 `CandidateQuestion` |
|
||||
|
||||
## 6. 不做什么(YAGNI)
|
||||
|
||||
- 不改判分协议、不改 harness/推理侧、不改 mini-batch/gate/diagnose 的 task_type 分组
|
||||
- 不做对抗式加难迭代(留到自进化循环跑通后,须固定出题对手版本)
|
||||
- 不做 T1 树增强(用户已确认本次不做)
|
||||
- 不引入树外信息源(原始视频重新抽帧出题)——视觉族用现有帧缓存即可
|
||||
|
||||
## 7. 验证
|
||||
|
||||
1. Smoke:12 个视频 × 每族 2-3 题,验证四门拦截率、重出收敛(≤3 轮)、双标签与元数据完整性
|
||||
2. 全量:生成 240 题 → `bash scripts/infer_generated.sh`(指向 generated-v2)→ calibrate 报表观测
|
||||
3. 回归断言:新题集答案位置分布均匀(卡方检验);指代黑名单零命中;泄漏门画像全部通过
|
||||
4. 单测:后处理层纯函数(shuffle 重映射、黑名单、n-gram 门)
|
||||
|
||||
## 8. 被否方案
|
||||
|
||||
- **纯 prompt 补丁**:第二轮设计层分析证明 7 类缺陷中 5 类会换形复发(信息闭环是结构问题)
|
||||
- **失败机理作为主标签**:进化循环全链改造,收益不明确;先以元数据形式观察其价值
|
||||
- **重量档全量**:240 题 ≈ 一次完整推理实验(4-5 小时),轻量四门已拦截三轮发现的全部缺陷类型
|
||||
- **难度一致性作为验收门**:三轮分析证明分数一致性是坏代理指标(假难/假易双向失真)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user