chore: track claude skills, tools, templates, reference code and research-wiki
- Add all claude skills (brainstorming, commit, debugging, TDD, etc.) - Add claude hooks (pre-commit-guard, post-edit-quality) - Add research templates (experiment plan, research brief, etc.) - Add claude tools (arxiv/semantic_scholar/openalex fetch, wiki, exa) - Add TRM4 reference implementation as algorithm fidelity baseline - Add research-wiki content (plans, index, graph, query_pack) - Update .gitignore to exclude .graphify_version runtime state
This commit is contained in:
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# post-edit-quality.sh
|
||||
# Claude Code PostToolUse hook — 每次编辑 .py 文件后自动检查
|
||||
#
|
||||
# 触发时机:Claude 每次执行 Write/Edit 后
|
||||
# 作用:对被修改的文件执行复杂度和风格检查
|
||||
# 退出码:0 = 通过,非0 = 报告问题(非阻塞,Claude 会看到反馈)
|
||||
#
|
||||
# 配置方法:在 .claude/settings.local.json 中注册:
|
||||
# "hooks": {
|
||||
# "PostToolUse": [
|
||||
# { "matcher": "Write|Edit", "command": "bash scripts/hooks/post-edit-quality.sh" }
|
||||
# ]
|
||||
# }
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
# <填写: 项目 Conda 环境路径>
|
||||
# PROJ_ENV="/home/<user>/miniconda3/envs/<env-name>/bin"
|
||||
# [[ -d "$PROJ_ENV" ]] && export PATH="$PROJ_ENV:$PATH"
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""')
|
||||
|
||||
# 只检查 .py 文件
|
||||
if [[ ! "$FILE_PATH" == *.py ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 只检查存在的文件
|
||||
if [[ ! -f "$FILE_PATH" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ERRORS=""
|
||||
WARNINGS=""
|
||||
|
||||
# ── 1. Ruff 格式 + lint 检查 ──
|
||||
if command -v ruff &> /dev/null; then
|
||||
RUFF_OUTPUT=$(ruff check "$FILE_PATH" 2>&1 || true)
|
||||
if [[ -n "$RUFF_OUTPUT" ]]; then
|
||||
ERRORS+="[ruff] 风格/lint 问题:\n$RUFF_OUTPUT\n\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 2. Radon 圈复杂度(只报告 C 级及以下) ──
|
||||
if command -v radon &> /dev/null; then
|
||||
RADON_OUTPUT=$(radon cc "$FILE_PATH" -n C -s 2>&1 || true)
|
||||
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then
|
||||
ERRORS+="[radon] 圈复杂度过高(≥C):\n$RADON_OUTPUT\n\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. 文件行数检查(warning,不阻塞)──
|
||||
LINE_COUNT=$(wc -l < "$FILE_PATH")
|
||||
if [[ "$LINE_COUNT" -gt 200 ]]; then
|
||||
WARNINGS+="[行数] $FILE_PATH 有 ${LINE_COUNT} 行,超过 200 行建议上限。\n\n"
|
||||
fi
|
||||
|
||||
# ── 4. 禁止裸 except ──
|
||||
if grep -nE '^\s*except\s*:\s*$|^\s*except\s+Exception\s*:\s*pass' "$FILE_PATH" 2>/dev/null; then
|
||||
ERRORS+="[安全] 检测到裸 except 或 except Exception: pass,请捕获具体异常类型。\n\n"
|
||||
fi
|
||||
|
||||
# ── 5. 禁止硬编码敏感信息 ──
|
||||
if grep -nEi "(api_key|secret|password|token)\s*=\s*[\"'][^\"']+[\"']" "$FILE_PATH" 2>/dev/null; then
|
||||
ERRORS+="[安全] 疑似硬编码敏感信息,请使用环境变量或 .env 文件。\n\n"
|
||||
fi
|
||||
|
||||
# ── 输出结果 ──
|
||||
if [[ -n "$WARNINGS" ]]; then
|
||||
echo -e "⚠️ Warnings($FILE_PATH,不阻塞):\n" >&2
|
||||
echo -e "$WARNINGS" >&2
|
||||
fi
|
||||
|
||||
if [[ -n "$ERRORS" ]]; then
|
||||
echo -e "❌ 代码质量检查发现问题($FILE_PATH):\n" >&2
|
||||
echo -e "$ERRORS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# pre-commit-guard.sh
|
||||
# Claude Code PreToolUse hook — 拦截 git commit,执行全量检查
|
||||
#
|
||||
# 触发时机:Claude 尝试执行 git commit 之前
|
||||
# 作用:阻塞提交直到所有质量门禁通过
|
||||
# 退出码:0 = 放行,2 = 阻塞(Claude 必须先修复问题)
|
||||
#
|
||||
# 配置方法:在 .claude/settings.local.json 中注册:
|
||||
# "hooks": {
|
||||
# "PreToolUse": [
|
||||
# { "matcher": "Bash", "command": "bash scripts/hooks/pre-commit-guard.sh" }
|
||||
# ]
|
||||
# }
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
# <填写: 项目 Conda 环境路径>
|
||||
# PROJ_ENV="/home/<user>/miniconda3/envs/<env-name>/bin"
|
||||
# [[ -d "$PROJ_ENV" ]] && export PATH="$PROJ_ENV:$PATH"
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
|
||||
|
||||
# 只拦截 git commit 命令
|
||||
if ! echo "$COMMAND" | grep -qE '^\s*git\s+commit'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ERRORS=""
|
||||
WARNINGS=""
|
||||
|
||||
# <填写: 核心代码目录,如 core/>
|
||||
CODE_DIR="core"
|
||||
|
||||
# ── 1. 项目结构检查(按需取消注释) ──
|
||||
# 检查根目录是否有不该存在的 .py
|
||||
# for pyfile in *.py; do
|
||||
# [[ "$pyfile" == "*.py" ]] && break
|
||||
# if [[ "$pyfile" != "main.py" && "$pyfile" != "conftest.py" ]]; then
|
||||
# ERRORS+="[结构] 根目录不该有: $pyfile(应移至 $CODE_DIR/)\n"
|
||||
# fi
|
||||
# done
|
||||
|
||||
# ── 2. 全量代码质量检查 ──
|
||||
if [[ -d "$CODE_DIR" ]] && command -v ruff &> /dev/null; then
|
||||
RUFF_OUTPUT=$(ruff check "$CODE_DIR" 2>&1 || true)
|
||||
if [[ -n "$RUFF_OUTPUT" ]]; then
|
||||
ERROR_COUNT=$(echo "$RUFF_OUTPUT" | wc -l)
|
||||
ERRORS+="[ruff] $CODE_DIR/ 中有 ${ERROR_COUNT} 个问题。运行 ruff check $CODE_DIR/ 查看详情。\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -d "$CODE_DIR" ]] && command -v radon &> /dev/null; then
|
||||
RADON_OUTPUT=$(radon cc "$CODE_DIR" -n C -s 2>&1 || true)
|
||||
if echo "$RADON_OUTPUT" | grep -qE '^\s+[FMC]\s'; then
|
||||
ERRORS+="[radon] 存在圈复杂度 ≥ C 的函数:\n$RADON_OUTPUT\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 3. 文件行数检查 ──
|
||||
if [[ -d "$CODE_DIR" ]]; then
|
||||
while IFS= read -r pyfile; do
|
||||
lines=$(wc -l < "$pyfile")
|
||||
if [[ "$lines" -gt 200 ]]; then
|
||||
WARNINGS+="[行数] $pyfile 有 ${lines} 行,超过 200 行建议上限。\n"
|
||||
fi
|
||||
done < <(find "$CODE_DIR" -name "*.py" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# ── 4. 测试检查 ──
|
||||
if command -v pytest &> /dev/null && [[ -d "tests" ]]; then
|
||||
TEST_OUTPUT=$(pytest tests/ --tb=line -q 2>&1 || true)
|
||||
if echo "$TEST_OUTPUT" | grep -qE 'failed|error'; then
|
||||
FAILED=$(echo "$TEST_OUTPUT" | tail -1)
|
||||
ERRORS+="[测试] 有测试未通过:$FAILED\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 判定结果 ──
|
||||
if [[ -n "$WARNINGS" ]]; then
|
||||
echo -e "⚠️ Warnings(不阻塞):\n" >&2
|
||||
echo -e "$WARNINGS" >&2
|
||||
fi
|
||||
|
||||
if [[ -n "$ERRORS" ]]; then
|
||||
echo -e "❌ 提交被阻塞 — 请先修复以下问题:\n" >&2
|
||||
echo -e "$ERRORS" >&2
|
||||
echo -e "修复完成后重新执行 git commit。" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "✅ 所有检查通过,允许提交。"
|
||||
exit 0
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: brainstorming
|
||||
description: "You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation."
|
||||
---
|
||||
|
||||
# Brainstorming Ideas Into Designs
|
||||
|
||||
Help turn ideas into fully formed designs and specs through natural collaborative dialogue.
|
||||
|
||||
Start by understanding the current project context, then ask questions one at a time to refine the idea. Once you understand what you're building, present the design and get user approval.
|
||||
|
||||
<HARD-GATE>
|
||||
Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity.
|
||||
</HARD-GATE>
|
||||
|
||||
## Anti-Pattern: "This Is Too Simple To Need A Design"
|
||||
|
||||
Every project goes through this process. A todo list, a single-function utility, a config change — all of them. "Simple" projects are where unexamined assumptions cause the most wasted work. The design can be short (a few sentences for truly simple projects), but you MUST present it and get approval.
|
||||
|
||||
## Checklist
|
||||
|
||||
You MUST create a task for each of these items and complete them in order:
|
||||
|
||||
1. **Explore project context** — check files, docs, recent commits
|
||||
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
|
||||
5. **Present design** — in sections scaled to their complexity, get user approval after each section
|
||||
6. **Write design doc** — save to `research-wiki/designs/YYYY-MM-DD-<topic>-design.md` and commit
|
||||
7. **Spec self-review** — quick inline check for placeholders, contradictions, ambiguity, scope (see below)
|
||||
7.5. **Codex design review** — independent Codex review via `/codex:rescue --fresh --wait`, verify findings, apply accepted fixes inline (see below)
|
||||
8. **User reviews written spec** — ask user to review the spec file before proceeding
|
||||
9. **Transition to implementation** — invoke writing-plans skill to create implementation plan
|
||||
|
||||
## Process Flow
|
||||
|
||||
```dot
|
||||
digraph brainstorming {
|
||||
"Explore project context" [shape=box];
|
||||
"Visual questions ahead?" [shape=diamond];
|
||||
"Offer Visual Companion\n(own message, no other content)" [shape=box];
|
||||
"Ask clarifying questions" [shape=box];
|
||||
"Propose 2-3 approaches" [shape=box];
|
||||
"Present design sections" [shape=box];
|
||||
"User approves design?" [shape=diamond];
|
||||
"Write design doc" [shape=box];
|
||||
"Spec self-review\n(fix inline)" [shape=box];
|
||||
"Codex design review\n(/codex:rescue --fresh --wait, fix inline)" [shape=box];
|
||||
"User reviews spec?" [shape=diamond];
|
||||
"Invoke writing-plans skill" [shape=doublecircle];
|
||||
|
||||
"Explore project context" -> "Visual questions ahead?";
|
||||
"Visual questions ahead?" -> "Offer Visual Companion\n(own message, no other content)" [label="yes"];
|
||||
"Visual questions ahead?" -> "Ask clarifying questions" [label="no"];
|
||||
"Offer Visual Companion\n(own message, no other content)" -> "Ask clarifying questions";
|
||||
"Ask clarifying questions" -> "Propose 2-3 approaches";
|
||||
"Propose 2-3 approaches" -> "Present design sections";
|
||||
"Present design sections" -> "User approves design?";
|
||||
"User approves design?" -> "Present design sections" [label="no, revise"];
|
||||
"User approves design?" -> "Write design doc" [label="yes"];
|
||||
"Write design doc" -> "Spec self-review\n(fix inline)";
|
||||
"Spec self-review\n(fix inline)" -> "Codex design review\n(/codex:rescue --fresh --wait, fix inline)";
|
||||
"Codex design review\n(/codex:rescue --fresh --wait, fix inline)" -> "User reviews spec?";
|
||||
"User reviews spec?" -> "Write design doc" [label="changes requested"];
|
||||
"User reviews spec?" -> "Invoke writing-plans skill" [label="approved"];
|
||||
}
|
||||
```
|
||||
|
||||
**The terminal state is invoking writing-plans.** Do NOT invoke frontend-design, mcp-builder, or any other implementation skill. The ONLY skill you invoke after brainstorming is writing-plans.
|
||||
|
||||
## The Process
|
||||
|
||||
**Understanding the idea:**
|
||||
|
||||
- Check out the current project state first (files, docs, recent commits)
|
||||
- **代码检索(graphify)**:在查阅现有代码结构前,先跑 `/graphify . --update` 刷新知识图谱(增量合并:改代码免费、改文档才 LLM),再优先用 `/graphify query "<问题>"` / `graphify path` / `graphify explain` 做结构化检索,而非盲读文件。若 `graphify-out/graph.json` 不存在,提示人类先手动 `/graphify .`;未建图前不阻断,降级为常规文件阅读。
|
||||
- Before asking detailed questions, assess scope: if the request describes multiple independent subsystems (e.g., "build a platform with chat, file storage, billing, and analytics"), flag this immediately. Don't spend questions refining details of a project that needs to be decomposed first.
|
||||
- If the project is too large for a single spec, help the user decompose into sub-projects: what are the independent pieces, how do they relate, what order should they be built? Then brainstorm the first sub-project through the normal design flow. Each sub-project gets its own spec → plan → implementation cycle.
|
||||
- For appropriately-scoped projects, ask questions one at a time to refine the idea
|
||||
- Prefer multiple choice questions when possible, but open-ended is fine too
|
||||
- Only one question per message - if a topic needs more exploration, break it into multiple questions
|
||||
- Focus on understanding: purpose, constraints, success criteria
|
||||
|
||||
**Exploring approaches:**
|
||||
|
||||
- Propose 2-3 different approaches with trade-offs
|
||||
- Present options conversationally with your recommendation and reasoning
|
||||
- Lead with your recommended option and explain why
|
||||
|
||||
**Presenting the design:**
|
||||
|
||||
- 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
|
||||
- Be ready to go back and clarify if something doesn't make sense
|
||||
|
||||
**Design for isolation and clarity:**
|
||||
|
||||
- Break the system into smaller units that each have one clear purpose, communicate through well-defined interfaces, and can be understood and tested independently
|
||||
- For each unit, you should be able to answer: what does it do, how do you use it, and what does it depend on?
|
||||
- Can someone understand what a unit does without reading its internals? Can you change the internals without breaking consumers? If not, the boundaries need work.
|
||||
- Smaller, well-bounded units are also easier for you to work with - you reason better about code you can hold in context at once, and your edits are more reliable when files are focused. When a file grows large, that's often a signal that it's doing too much.
|
||||
|
||||
**Working in existing codebases:**
|
||||
|
||||
- Explore the current structure before proposing changes. Follow existing patterns.
|
||||
- Where existing code has problems that affect the work (e.g., a file that's grown too large, unclear boundaries, tangled responsibilities), include targeted improvements as part of the design - the way a good developer improves code they're working in.
|
||||
- Don't propose unrelated refactoring. Stay focused on what serves the current goal.
|
||||
|
||||
## After the Design
|
||||
|
||||
**Documentation:**
|
||||
|
||||
- Write the validated design (spec) to `research-wiki/designs/YYYY-MM-DD-<topic>-design.md`
|
||||
- (User preferences for spec location override this default)
|
||||
- Use elements-of-style:writing-clearly-and-concisely skill if available
|
||||
- Commit the design document to git
|
||||
|
||||
**Spec Self-Review:**
|
||||
After writing the spec document, look at it with fresh eyes:
|
||||
|
||||
1. **Placeholder scan:** Any "TBD", "TODO", incomplete sections, or vague requirements? Fix them.
|
||||
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.
|
||||
|
||||
Fix any issues inline. No need to re-review — just fix and move on.
|
||||
|
||||
**Codex Design Review:**
|
||||
After your self-review passes, get an independent second opinion from Codex before the human gate:
|
||||
|
||||
> 用 `/codex:rescue --fresh --wait` 把"用户需求溯源 + 设计全文"交 Codex 独立审查(只读),要求逐条核对需求覆盖、内部一致性、与 CLAUDE.md/既有设计的冲突,按 Critical/Important/Minor 返回必改项。
|
||||
|
||||
Verify each finding against the codebase (do not blindly accept). Apply the accepted fixes inline, then proceed to the User Review Gate. The human gate is preserved.
|
||||
|
||||
**User Review Gate:**
|
||||
After the spec review loop passes, ask the user to review the written spec before proceeding:
|
||||
|
||||
> "Spec written and committed to `<path>`. Please review it and let me know if you want to make any changes before we start writing out the implementation plan."
|
||||
|
||||
Wait for the user's response. If they request changes, make them and re-run the spec review loop. Only proceed once the user approves.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Invoke the writing-plans skill to create a detailed implementation plan
|
||||
- Do NOT invoke any other skill. writing-plans is the next step.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **One question at a time** - Don't overwhelm with multiple questions
|
||||
- **Multiple choice preferred** - Easier to answer than open-ended when possible
|
||||
- **YAGNI ruthlessly** - Remove unnecessary features from all designs
|
||||
- **Explore alternatives** - Always propose 2-3 approaches before settling
|
||||
- **Incremental validation** - Present design, get approval before moving on
|
||||
- **Be flexible** - Go back and clarify when something doesn't make sense
|
||||
|
||||
## Visual Companion
|
||||
|
||||
A browser-based companion for showing mockups, diagrams, and visual options during brainstorming. Available as a tool — not a mode. Accepting the companion means it's available for questions that benefit from visual treatment; it does NOT mean every question goes through the browser.
|
||||
|
||||
**Offering the companion:** When you anticipate that upcoming questions will involve visual content (mockups, layouts, diagrams), offer it once for consent:
|
||||
> "Some of what we're working on might be easier to explain if I can show it to you in a web browser. I can put together mockups, diagrams, comparisons, and other visuals as we go. This feature is still new and can be token-intensive. Want to try it? (Requires opening a local URL)"
|
||||
|
||||
**This offer MUST be its own message.** Do not combine it with clarifying questions, context summaries, or any other content. The message should contain ONLY the offer above and nothing else. Wait for the user's response before continuing. If they decline, proceed with text-only brainstorming.
|
||||
|
||||
**Per-question decision:** Even after the user accepts, decide FOR EACH QUESTION whether to use the browser or the terminal. The test: **would the user understand this better by seeing it than reading it?**
|
||||
|
||||
- **Use the browser** for content that IS visual — mockups, wireframes, layout comparisons, architecture diagrams, side-by-side visual designs
|
||||
- **Use the terminal** for content that is text — requirements questions, conceptual choices, tradeoff lists, A/B/C/D text options, scope decisions
|
||||
|
||||
A question about a UI topic is not automatically a visual question. "What does personality mean in this context?" is a conceptual question — use the terminal. "Which wizard layout works better?" is a visual question — use the browser.
|
||||
|
||||
If they agree to the companion, read the detailed guide before proceeding:
|
||||
`skills/brainstorming/visual-companion.md`
|
||||
|
||||
## Wiki Integration
|
||||
|
||||
**Precondition**: `research-wiki/` directory exists (skip this section entirely if it does not).
|
||||
|
||||
**Trigger**: When a design is approved and the design document is being written.
|
||||
|
||||
**Output path**: Design docs are saved to `research-wiki/designs/` instead of `docs/superpowers/specs/`.
|
||||
|
||||
**Steps**:
|
||||
1. Run `.claude/tools/research_wiki.py add_entity research-wiki/ --type design --id <slug> --title "<design title>"` to create the design entity
|
||||
2. Append the chosen approach, key decision rationale, rejected alternatives, and why they were rejected to the generated page
|
||||
3. If the design is related to an idea, run `.claude/tools/research_wiki.py add_edge research-wiki/ --from "design:<id>" --to "idea:<id>" --type refines --evidence "..."`
|
||||
4. Run `.claude/tools/research_wiki.py rebuild_index research-wiki/`
|
||||
@@ -0,0 +1,49 @@
|
||||
# Spec Document Reviewer Prompt Template
|
||||
|
||||
Use this template when dispatching a spec document reviewer subagent.
|
||||
|
||||
**Purpose:** Verify the spec is complete, consistent, and ready for implementation planning.
|
||||
|
||||
**Dispatch after:** Spec document is written to docs/superpowers/specs/
|
||||
|
||||
```
|
||||
Task tool (general-purpose):
|
||||
description: "Review spec document"
|
||||
prompt: |
|
||||
You are a spec document reviewer. Verify this spec is complete and ready for planning.
|
||||
|
||||
**Spec to review:** [SPEC_FILE_PATH]
|
||||
|
||||
## What to Check
|
||||
|
||||
| Category | What to Look For |
|
||||
|----------|------------------|
|
||||
| Completeness | TODOs, placeholders, "TBD", incomplete sections |
|
||||
| Consistency | Internal contradictions, conflicting requirements |
|
||||
| Clarity | Requirements ambiguous enough to cause someone to build the wrong thing |
|
||||
| Scope | Focused enough for a single plan — not covering multiple independent subsystems |
|
||||
| YAGNI | Unrequested features, over-engineering |
|
||||
|
||||
## Calibration
|
||||
|
||||
**Only flag issues that would cause real problems during implementation planning.**
|
||||
A missing section, a contradiction, or a requirement so ambiguous it could be
|
||||
interpreted two different ways — those are issues. Minor wording improvements,
|
||||
stylistic preferences, and "sections less detailed than others" are not.
|
||||
|
||||
Approve unless there are serious gaps that would lead to a flawed plan.
|
||||
|
||||
## Output Format
|
||||
|
||||
## Spec Review
|
||||
|
||||
**Status:** Approved | Issues Found
|
||||
|
||||
**Issues (if any):**
|
||||
- [Section X]: [specific issue] - [why it matters for planning]
|
||||
|
||||
**Recommendations (advisory, do not block approval):**
|
||||
- [suggestions for improvement]
|
||||
```
|
||||
|
||||
**Reviewer returns:** Status, Issues (if any), Recommendations
|
||||
@@ -0,0 +1,287 @@
|
||||
# Visual Companion Guide
|
||||
|
||||
Browser-based visual brainstorming companion for showing mockups, diagrams, and options.
|
||||
|
||||
## When to Use
|
||||
|
||||
Decide per-question, not per-session. The test: **would the user understand this better by seeing it than reading it?**
|
||||
|
||||
**Use the browser** when the content itself is visual:
|
||||
|
||||
- **UI mockups** — wireframes, layouts, navigation structures, component designs
|
||||
- **Architecture diagrams** — system components, data flow, relationship maps
|
||||
- **Side-by-side visual comparisons** — comparing two layouts, two color schemes, two design directions
|
||||
- **Design polish** — when the question is about look and feel, spacing, visual hierarchy
|
||||
- **Spatial relationships** — state machines, flowcharts, entity relationships rendered as diagrams
|
||||
|
||||
**Use the terminal** when the content is text or tabular:
|
||||
|
||||
- **Requirements and scope questions** — "what does X mean?", "which features are in scope?"
|
||||
- **Conceptual A/B/C choices** — picking between approaches described in words
|
||||
- **Tradeoff lists** — pros/cons, comparison tables
|
||||
- **Technical decisions** — API design, data modeling, architectural approach selection
|
||||
- **Clarifying questions** — anything where the answer is words, not a visual preference
|
||||
|
||||
A question *about* a UI topic is not automatically a visual question. "What kind of wizard do you want?" is conceptual — use the terminal. "Which of these wizard layouts feels right?" is visual — use the browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
The server watches a directory for HTML files and serves the newest one to the browser. You write HTML content to `screen_dir`, the user sees it in their browser and can click to select options. Selections are recorded to `state_dir/events` that you read on your next turn.
|
||||
|
||||
**Content fragments vs full documents:** If your HTML file starts with `<!DOCTYPE` or `<html`, the server serves it as-is (just injects the helper script). Otherwise, the server automatically wraps your content in the frame template — adding the header, CSS theme, selection indicator, and all interactive infrastructure. **Write content fragments by default.** Only write full documents when you need complete control over the page.
|
||||
|
||||
## Starting a Session
|
||||
|
||||
```bash
|
||||
# Start server with persistence (mockups saved to project)
|
||||
scripts/start-server.sh --project-dir /path/to/project
|
||||
|
||||
# Returns: {"type":"server-started","port":52341,"url":"http://localhost:52341",
|
||||
# "screen_dir":"/path/to/project/.superpowers/brainstorm/12345-1706000000/content",
|
||||
# "state_dir":"/path/to/project/.superpowers/brainstorm/12345-1706000000/state"}
|
||||
```
|
||||
|
||||
Save `screen_dir` and `state_dir` from the response. Tell user to open the URL.
|
||||
|
||||
**Finding connection info:** The server writes its startup JSON to `$STATE_DIR/server-info`. If you launched the server in the background and didn't capture stdout, read that file to get the URL and port. When using `--project-dir`, check `<project>/.superpowers/brainstorm/` for the session directory.
|
||||
|
||||
**Note:** Pass the project root as `--project-dir` so mockups persist in `.superpowers/brainstorm/` and survive server restarts. Without it, files go to `/tmp` and get cleaned up. Remind the user to add `.superpowers/` to `.gitignore` if it's not already there.
|
||||
|
||||
**Launching the server by platform:**
|
||||
|
||||
**Claude Code (macOS / Linux):**
|
||||
```bash
|
||||
# Default mode works — the script backgrounds the server itself
|
||||
scripts/start-server.sh --project-dir /path/to/project
|
||||
```
|
||||
|
||||
**Claude Code (Windows):**
|
||||
```bash
|
||||
# Windows auto-detects and uses foreground mode, which blocks the tool call.
|
||||
# Use run_in_background: true on the Bash tool call so the server survives
|
||||
# across conversation turns.
|
||||
scripts/start-server.sh --project-dir /path/to/project
|
||||
```
|
||||
When calling this via the Bash tool, set `run_in_background: true`. Then read `$STATE_DIR/server-info` on the next turn to get the URL and port.
|
||||
|
||||
**Codex:**
|
||||
```bash
|
||||
# Codex reaps background processes. The script auto-detects CODEX_CI and
|
||||
# switches to foreground mode. Run it normally — no extra flags needed.
|
||||
scripts/start-server.sh --project-dir /path/to/project
|
||||
```
|
||||
|
||||
**Gemini CLI:**
|
||||
```bash
|
||||
# Use --foreground and set is_background: true on your shell tool call
|
||||
# so the process survives across turns
|
||||
scripts/start-server.sh --project-dir /path/to/project --foreground
|
||||
```
|
||||
|
||||
**Other environments:** The server must keep running in the background across conversation turns. If your environment reaps detached processes, use `--foreground` and launch the command with your platform's background execution mechanism.
|
||||
|
||||
If the URL is unreachable from your browser (common in remote/containerized setups), bind a non-loopback host:
|
||||
|
||||
```bash
|
||||
scripts/start-server.sh \
|
||||
--project-dir /path/to/project \
|
||||
--host 0.0.0.0 \
|
||||
--url-host localhost
|
||||
```
|
||||
|
||||
Use `--url-host` to control what hostname is printed in the returned URL JSON.
|
||||
|
||||
## The Loop
|
||||
|
||||
1. **Check server is alive**, then **write HTML** to a new file in `screen_dir`:
|
||||
- Before each write, check that `$STATE_DIR/server-info` exists. If it doesn't (or `$STATE_DIR/server-stopped` exists), the server has shut down — restart it with `start-server.sh` before continuing. The server auto-exits after 30 minutes of inactivity.
|
||||
- Use semantic filenames: `platform.html`, `visual-style.html`, `layout.html`
|
||||
- **Never reuse filenames** — each screen gets a fresh file
|
||||
- Use Write tool — **never use cat/heredoc** (dumps noise into terminal)
|
||||
- Server automatically serves the newest file
|
||||
|
||||
2. **Tell user what to expect and end your turn:**
|
||||
- Remind them of the URL (every step, not just first)
|
||||
- Give a brief text summary of what's on screen (e.g., "Showing 3 layout options for the homepage")
|
||||
- Ask them to respond in the terminal: "Take a look and let me know what you think. Click to select an option if you'd like."
|
||||
|
||||
3. **On your next turn** — after the user responds in the terminal:
|
||||
- Read `$STATE_DIR/events` if it exists — this contains the user's browser interactions (clicks, selections) as JSON lines
|
||||
- Merge with the user's terminal text to get the full picture
|
||||
- The terminal message is the primary feedback; `state_dir/events` provides structured interaction data
|
||||
|
||||
4. **Iterate or advance** — if feedback changes current screen, write a new file (e.g., `layout-v2.html`). Only move to the next question when the current step is validated.
|
||||
|
||||
5. **Unload when returning to terminal** — when the next step doesn't need the browser (e.g., a clarifying question, a tradeoff discussion), push a waiting screen to clear the stale content:
|
||||
|
||||
```html
|
||||
<!-- filename: waiting.html (or waiting-2.html, etc.) -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
This prevents the user from staring at a resolved choice while the conversation has moved on. When the next visual question comes up, push a new content file as usual.
|
||||
|
||||
6. Repeat until done.
|
||||
|
||||
## Writing Content Fragments
|
||||
|
||||
Write just the content that goes inside the page. The server wraps it in the frame template automatically (header, theme CSS, selection indicator, and all interactive infrastructure).
|
||||
|
||||
**Minimal example:**
|
||||
|
||||
```html
|
||||
<h2>Which layout works better?</h2>
|
||||
<p class="subtitle">Consider readability and visual hierarchy</p>
|
||||
|
||||
<div class="options">
|
||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Single Column</h3>
|
||||
<p>Clean, focused reading experience</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
||||
<div class="letter">B</div>
|
||||
<div class="content">
|
||||
<h3>Two Column</h3>
|
||||
<p>Sidebar navigation with main content</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
That's it. No `<html>`, no CSS, no `<script>` tags needed. The server provides all of that.
|
||||
|
||||
## CSS Classes Available
|
||||
|
||||
The frame template provides these CSS classes for your content:
|
||||
|
||||
### Options (A/B/C choices)
|
||||
|
||||
```html
|
||||
<div class="options">
|
||||
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||
<div class="letter">A</div>
|
||||
<div class="content">
|
||||
<h3>Title</h3>
|
||||
<p>Description</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Multi-select:** Add `data-multiselect` to the container to let users select multiple options. Each click toggles the item. The indicator bar shows the count.
|
||||
|
||||
```html
|
||||
<div class="options" data-multiselect>
|
||||
<!-- same option markup — users can select/deselect multiple -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Cards (visual designs)
|
||||
|
||||
```html
|
||||
<div class="cards">
|
||||
<div class="card" data-choice="design1" onclick="toggleSelect(this)">
|
||||
<div class="card-image"><!-- mockup content --></div>
|
||||
<div class="card-body">
|
||||
<h3>Name</h3>
|
||||
<p>Description</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Mockup container
|
||||
|
||||
```html
|
||||
<div class="mockup">
|
||||
<div class="mockup-header">Preview: Dashboard Layout</div>
|
||||
<div class="mockup-body"><!-- your mockup HTML --></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Split view (side-by-side)
|
||||
|
||||
```html
|
||||
<div class="split">
|
||||
<div class="mockup"><!-- left --></div>
|
||||
<div class="mockup"><!-- right --></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pros/Cons
|
||||
|
||||
```html
|
||||
<div class="pros-cons">
|
||||
<div class="pros"><h4>Pros</h4><ul><li>Benefit</li></ul></div>
|
||||
<div class="cons"><h4>Cons</h4><ul><li>Drawback</li></ul></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Mock elements (wireframe building blocks)
|
||||
|
||||
```html
|
||||
<div class="mock-nav">Logo | Home | About | Contact</div>
|
||||
<div style="display: flex;">
|
||||
<div class="mock-sidebar">Navigation</div>
|
||||
<div class="mock-content">Main content area</div>
|
||||
</div>
|
||||
<button class="mock-button">Action Button</button>
|
||||
<input class="mock-input" placeholder="Input field">
|
||||
<div class="placeholder">Placeholder area</div>
|
||||
```
|
||||
|
||||
### Typography and sections
|
||||
|
||||
- `h2` — page title
|
||||
- `h3` — section heading
|
||||
- `.subtitle` — secondary text below title
|
||||
- `.section` — content block with bottom margin
|
||||
- `.label` — small uppercase label text
|
||||
|
||||
## Browser Events Format
|
||||
|
||||
When the user clicks options in the browser, their interactions are recorded to `$STATE_DIR/events` (one JSON object per line). The file is cleared automatically when you push a new screen.
|
||||
|
||||
```jsonl
|
||||
{"type":"click","choice":"a","text":"Option A - Simple Layout","timestamp":1706000101}
|
||||
{"type":"click","choice":"c","text":"Option C - Complex Grid","timestamp":1706000108}
|
||||
{"type":"click","choice":"b","text":"Option B - Hybrid","timestamp":1706000115}
|
||||
```
|
||||
|
||||
The full event stream shows the user's exploration path — they may click multiple options before settling. The last `choice` event is typically the final selection, but the pattern of clicks can reveal hesitation or preferences worth asking about.
|
||||
|
||||
If `$STATE_DIR/events` doesn't exist, the user didn't interact with the browser — use only their terminal text.
|
||||
|
||||
## Design Tips
|
||||
|
||||
- **Scale fidelity to the question** — wireframes for layout, polish for polish questions
|
||||
- **Explain the question on each page** — "Which layout feels more professional?" not just "Pick one"
|
||||
- **Iterate before advancing** — if feedback changes current screen, write a new version
|
||||
- **2-4 options max** per screen
|
||||
- **Use real content when it matters** — for a photography portfolio, use actual images (Unsplash). Placeholder content obscures design issues.
|
||||
- **Keep mockups simple** — focus on layout and structure, not pixel-perfect design
|
||||
|
||||
## File Naming
|
||||
|
||||
- Use semantic names: `platform.html`, `visual-style.html`, `layout.html`
|
||||
- Never reuse filenames — each screen must be a new file
|
||||
- For iterations: append version suffix like `layout-v2.html`, `layout-v3.html`
|
||||
- Server serves newest file by modification time
|
||||
|
||||
## Cleaning Up
|
||||
|
||||
```bash
|
||||
scripts/stop-server.sh $SESSION_DIR
|
||||
```
|
||||
|
||||
If the session used `--project-dir`, mockup files persist in `.superpowers/brainstorm/` for later reference. Only `/tmp` sessions get deleted on stop.
|
||||
|
||||
## Reference
|
||||
|
||||
- Frame template (CSS reference): `scripts/frame-template.html`
|
||||
- Helper script (client-side): `scripts/helper.js`
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: commit
|
||||
description: Use when committing code changes - enforces commit message format, safety checks, and prohibits AI signatures
|
||||
---
|
||||
|
||||
# Commit
|
||||
|
||||
Automatically generate a commit message and submit the code.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Check status**: run `git status` to inspect changes (do not use `-uall`)
|
||||
2. **Review diff**: run `git diff --staged` and `git diff` to inspect the exact changes
|
||||
3. **Check style**: run `git log --oneline -10` to review recent commit style
|
||||
4. **Generate message**: create a commit message that follows the rules below
|
||||
5. **Commit**: stage the files and commit
|
||||
|
||||
## Commit Message Rules
|
||||
|
||||
### Format
|
||||
```text
|
||||
<type>: <description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
### Type Values
|
||||
- `feat`: new feature
|
||||
- `fix`: bug fix
|
||||
- `docs`: documentation update
|
||||
- `refactor`: refactor with no functional change
|
||||
- `test`: test-related change
|
||||
- `chore`: build / tooling / configuration
|
||||
- `perf`: performance optimization
|
||||
- `ci`: CI/CD related
|
||||
|
||||
### Rules
|
||||
- The description must be written in English
|
||||
- Keep it under 72 characters
|
||||
- Use the imperative mood, for example "add user authentication" instead of "added user authentication"
|
||||
|
||||
## Safety Rules
|
||||
|
||||
> [!CAUTION]
|
||||
> **Never** add any AI signature to a commit message:
|
||||
> - Do not add `Co-Authored-By: Claude`
|
||||
> - Do not add `🤖 Generated with...`
|
||||
> - Do not add any AI signature or marker
|
||||
|
||||
## Sensitive File Check
|
||||
|
||||
Before committing, check whether any of the following files were accidentally added:
|
||||
- `.env` / `.env.*`
|
||||
- `credentials.json`
|
||||
- `*_secret*`
|
||||
- `*.pem` / `*.key`
|
||||
|
||||
If sensitive files are found, **warn the user** and wait for confirmation.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Auto-generate a message
|
||||
/commit
|
||||
|
||||
# Specify a message
|
||||
/commit "feat: add user login"
|
||||
```
|
||||
@@ -0,0 +1,251 @@
|
||||
---
|
||||
name: finishing-a-development-branch
|
||||
description: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
|
||||
---
|
||||
|
||||
# Finishing a Development Branch
|
||||
|
||||
## Overview
|
||||
|
||||
Guide completion of development work by presenting clear options and handling chosen workflow.
|
||||
|
||||
**Core principle:** Verify tests → Detect environment → Present options → Execute choice → Clean up.
|
||||
|
||||
**Announce at start:** "I'm using the finishing-a-development-branch skill to complete this work."
|
||||
|
||||
## The Process
|
||||
|
||||
### Step 1: Verify Tests
|
||||
|
||||
**Before presenting options, verify tests pass:**
|
||||
|
||||
```bash
|
||||
# Run project's test suite
|
||||
npm test / cargo test / pytest / go test ./...
|
||||
```
|
||||
|
||||
**If tests fail:**
|
||||
```
|
||||
Tests failing (<N> failures). Must fix before completing:
|
||||
|
||||
[Show failures]
|
||||
|
||||
Cannot proceed with merge/PR until tests pass.
|
||||
```
|
||||
|
||||
Stop. Don't proceed to Step 2.
|
||||
|
||||
**If tests pass:** Continue to Step 2.
|
||||
|
||||
### Step 2: Detect Environment
|
||||
|
||||
**Determine workspace state before presenting options:**
|
||||
|
||||
```bash
|
||||
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||
```
|
||||
|
||||
This determines which menu to show and how cleanup works:
|
||||
|
||||
| State | Menu | Cleanup |
|
||||
|-------|------|---------|
|
||||
| `GIT_DIR == GIT_COMMON` (normal repo) | Standard 4 options | No worktree to clean up |
|
||||
| `GIT_DIR != GIT_COMMON`, named branch | Standard 4 options | Provenance-based (see Step 6) |
|
||||
| `GIT_DIR != GIT_COMMON`, detached HEAD | Reduced 3 options (no merge) | No cleanup (externally managed) |
|
||||
|
||||
### Step 3: Determine Base Branch
|
||||
|
||||
```bash
|
||||
# Try common base branches
|
||||
git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null
|
||||
```
|
||||
|
||||
Or ask: "This branch split from main - is that correct?"
|
||||
|
||||
### Step 4: Present Options
|
||||
|
||||
**Normal repo and named-branch worktree — present exactly these 4 options:**
|
||||
|
||||
```
|
||||
Implementation complete. What would you like to do?
|
||||
|
||||
1. Merge back to <base-branch> locally
|
||||
2. Push and create a Pull Request
|
||||
3. Keep the branch as-is (I'll handle it later)
|
||||
4. Discard this work
|
||||
|
||||
Which option?
|
||||
```
|
||||
|
||||
**Detached HEAD — present exactly these 3 options:**
|
||||
|
||||
```
|
||||
Implementation complete. You're on a detached HEAD (externally managed workspace).
|
||||
|
||||
1. Push as new branch and create a Pull Request
|
||||
2. Keep as-is (I'll handle it later)
|
||||
3. Discard this work
|
||||
|
||||
Which option?
|
||||
```
|
||||
|
||||
**Don't add explanation** - keep options concise.
|
||||
|
||||
### Step 5: Execute Choice
|
||||
|
||||
#### Option 1: Merge Locally
|
||||
|
||||
```bash
|
||||
# Get main repo root for CWD safety
|
||||
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
|
||||
cd "$MAIN_ROOT"
|
||||
|
||||
# Merge first — verify success before removing anything
|
||||
git checkout <base-branch>
|
||||
git pull
|
||||
git merge <feature-branch>
|
||||
|
||||
# Verify tests on merged result
|
||||
<test command>
|
||||
|
||||
# Only after merge succeeds: cleanup worktree (Step 6), then delete branch
|
||||
```
|
||||
|
||||
Then: Cleanup worktree (Step 6), then delete branch:
|
||||
|
||||
```bash
|
||||
git branch -d <feature-branch>
|
||||
```
|
||||
|
||||
#### Option 2: Push and Create PR
|
||||
|
||||
```bash
|
||||
# Push branch
|
||||
git push -u origin <feature-branch>
|
||||
|
||||
# Create PR
|
||||
gh pr create --title "<title>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<2-3 bullets of what changed>
|
||||
|
||||
## Test Plan
|
||||
- [ ] <verification steps>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
**Do NOT clean up worktree** — user needs it alive to iterate on PR feedback.
|
||||
|
||||
#### Option 3: Keep As-Is
|
||||
|
||||
Report: "Keeping branch <name>. Worktree preserved at <path>."
|
||||
|
||||
**Don't cleanup worktree.**
|
||||
|
||||
#### Option 4: Discard
|
||||
|
||||
**Confirm first:**
|
||||
```
|
||||
This will permanently delete:
|
||||
- Branch <name>
|
||||
- All commits: <commit-list>
|
||||
- Worktree at <path>
|
||||
|
||||
Type 'discard' to confirm.
|
||||
```
|
||||
|
||||
Wait for exact confirmation.
|
||||
|
||||
If confirmed:
|
||||
```bash
|
||||
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
|
||||
cd "$MAIN_ROOT"
|
||||
```
|
||||
|
||||
Then: Cleanup worktree (Step 6), then force-delete branch:
|
||||
```bash
|
||||
git branch -D <feature-branch>
|
||||
```
|
||||
|
||||
### Step 6: Cleanup Workspace
|
||||
|
||||
**Only runs for Options 1 and 4.** Options 2 and 3 always preserve the worktree.
|
||||
|
||||
```bash
|
||||
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||
WORKTREE_PATH=$(git rev-parse --show-toplevel)
|
||||
```
|
||||
|
||||
**If `GIT_DIR == GIT_COMMON`:** Normal repo, no worktree to clean up. Done.
|
||||
|
||||
**If worktree path is under `.worktrees/`, `worktrees/`, or `~/.config/superpowers/worktrees/`:** Superpowers created this worktree — we own cleanup.
|
||||
|
||||
```bash
|
||||
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
|
||||
cd "$MAIN_ROOT"
|
||||
git worktree remove "$WORKTREE_PATH"
|
||||
git worktree prune # Self-healing: clean up any stale registrations
|
||||
```
|
||||
|
||||
**Otherwise:** The host environment (harness) owns this workspace. Do NOT remove it. If your platform provides a workspace-exit tool, use it. Otherwise, leave the workspace in place.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Option | Merge | Push | Keep Worktree | Cleanup Branch |
|
||||
|--------|-------|------|---------------|----------------|
|
||||
| 1. Merge locally | yes | - | - | yes |
|
||||
| 2. Create PR | - | yes | yes | - |
|
||||
| 3. Keep as-is | - | - | yes | - |
|
||||
| 4. Discard | - | - | - | yes (force) |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**Skipping test verification**
|
||||
- **Problem:** Merge broken code, create failing PR
|
||||
- **Fix:** Always verify tests before offering options
|
||||
|
||||
**Open-ended questions**
|
||||
- **Problem:** "What should I do next?" is ambiguous
|
||||
- **Fix:** Present exactly 4 structured options (or 3 for detached HEAD)
|
||||
|
||||
**Cleaning up worktree for Option 2**
|
||||
- **Problem:** Remove worktree user needs for PR iteration
|
||||
- **Fix:** Only cleanup for Options 1 and 4
|
||||
|
||||
**Deleting branch before removing worktree**
|
||||
- **Problem:** `git branch -d` fails because worktree still references the branch
|
||||
- **Fix:** Merge first, remove worktree, then delete branch
|
||||
|
||||
**Running git worktree remove from inside the worktree**
|
||||
- **Problem:** Command fails silently when CWD is inside the worktree being removed
|
||||
- **Fix:** Always `cd` to main repo root before `git worktree remove`
|
||||
|
||||
**Cleaning up harness-owned worktrees**
|
||||
- **Problem:** Removing a worktree the harness created causes phantom state
|
||||
- **Fix:** Only clean up worktrees under `.worktrees/`, `worktrees/`, or `~/.config/superpowers/worktrees/`
|
||||
|
||||
**No confirmation for discard**
|
||||
- **Problem:** Accidentally delete work
|
||||
- **Fix:** Require typed "discard" confirmation
|
||||
|
||||
## Red Flags
|
||||
|
||||
**Never:**
|
||||
- Proceed with failing tests
|
||||
- Merge without verifying tests on result
|
||||
- Delete work without confirmation
|
||||
- Force-push without explicit request
|
||||
- Remove a worktree before confirming merge success
|
||||
- Clean up worktrees you didn't create (provenance check)
|
||||
- Run `git worktree remove` from inside the worktree
|
||||
|
||||
**Always:**
|
||||
- Verify tests before offering options
|
||||
- Detect environment before presenting menu
|
||||
- Present exactly 4 options (or 3 for detached HEAD)
|
||||
- Get typed confirmation for Option 4
|
||||
- Clean up worktree for Options 1 & 4 only
|
||||
- `cd` to main repo root before worktree removal
|
||||
- Run `git worktree prune` after removal
|
||||
@@ -0,0 +1,616 @@
|
||||
---
|
||||
name: graphify
|
||||
description: "Use for any question about a codebase, its architecture, file relationships, or project content — especially when graphify-out/ exists, where the question should be treated as a graphify query first. Turns any input (code, docs, papers, images, videos) into a persistent knowledge graph with god nodes, community detection, and query/path/explain tools."
|
||||
trigger: /graphify
|
||||
---
|
||||
|
||||
# /graphify
|
||||
|
||||
Turn any folder of files into a navigable knowledge graph with community detection, an honest audit trail, and three outputs: interactive HTML, GraphRAG-ready JSON, and a plain-language GRAPH_REPORT.md.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/graphify # full pipeline on current directory → Obsidian vault
|
||||
/graphify <path> # full pipeline on specific path
|
||||
/graphify https://github.com/<owner>/<repo> # clone repo then run full pipeline on it
|
||||
/graphify https://github.com/<owner>/<repo> --branch <branch> # clone a specific branch
|
||||
/graphify <url1> <url2> ... # clone multiple repos, build each, merge into one cross-repo graph
|
||||
/graphify <path> --mode deep # thorough extraction, richer INFERRED edges
|
||||
/graphify <path> --update # incremental - re-extract only new/changed files
|
||||
/graphify <path> --directed # build directed graph (preserves edge direction: source→target)
|
||||
/graphify <path> --whisper-model medium # use a larger Whisper model for better transcription accuracy
|
||||
/graphify <path> --cluster-only # rerun clustering on existing graph
|
||||
/graphify <path> --no-viz # skip visualization, just report + JSON
|
||||
/graphify <path> --html # (HTML is generated by default - this flag is a no-op)
|
||||
/graphify <path> --svg # also export graph.svg (embeds in Notion, GitHub)
|
||||
/graphify <path> --graphml # export graph.graphml (Gephi, yEd)
|
||||
/graphify <path> --neo4j # generate graphify-out/cypher.txt for Neo4j
|
||||
/graphify <path> --neo4j-push bolt://localhost:7687 # push directly to Neo4j
|
||||
/graphify <path> --mcp # start MCP stdio server for agent access
|
||||
/graphify <path> --watch # watch folder, auto-rebuild on code changes (no LLM needed)
|
||||
/graphify <path> --wiki # build agent-crawlable wiki (index.md + one article per community)
|
||||
/graphify <path> --obsidian --obsidian-dir ~/vaults/my-project # write vault to custom path (e.g. existing vault)
|
||||
/graphify add <url> # fetch URL, save to ./raw, update graph
|
||||
/graphify add <url> --author "Name" # tag who wrote it
|
||||
/graphify add <url> --contributor "Name" # tag who added it to the corpus
|
||||
/graphify query "<question>" # BFS traversal - broad context
|
||||
/graphify query "<question>" --dfs # DFS - trace a specific path
|
||||
/graphify query "<question>" --budget 1500 # cap answer at N tokens
|
||||
/graphify path "AuthModule" "Database" # shortest path between two concepts
|
||||
/graphify explain "SwinTransformer" # plain-language explanation of a node
|
||||
```
|
||||
|
||||
## What graphify is for
|
||||
|
||||
Drop any folder of code, docs, papers, images, or video into graphify and get a queryable knowledge graph. Persistent across sessions, honest audit trail (EXTRACTED/INFERRED/AMBIGUOUS), community detection surfaces cross-document connections you wouldn't think to ask about.
|
||||
|
||||
## What You Must Do When Invoked
|
||||
|
||||
If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return.
|
||||
|
||||
**Fast path — existing graph:** Before doing anything else, check whether `graphify-out/graph.json` exists. The expected location is `graphify-out/graph.json` relative to the **current working directory** (i.e. the project root where you are running commands). If it exists AND the user's request is a natural-language question about the codebase (e.g. "How does X work?", "What calls Y?", "Trace the data flow through Z") and NOT an explicit rebuild command (`--update`, `--cluster-only`, or a bare path/URL that implies fresh extraction): **skip Steps 1–5 entirely and jump straight to `## For /graphify query`.** Run `graphify query "<question>"` immediately. Do not run detect. Do not check corpus size. Do not ask the user to narrow. The graph is already built — use it.
|
||||
|
||||
If no path was given, use `.` (current directory). Do not ask the user for a path.
|
||||
|
||||
If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path.
|
||||
|
||||
Follow these steps in order. Do not skip steps.
|
||||
|
||||
### Step 0 - GitHub repos and multi-path merge (only if a URL or several paths)
|
||||
|
||||
Only when the path is one or more `https://github.com/...` URLs, or several local subfolders to merge. See `references/github-and-merge.md` for the clone, cross-repo merge, and monorepo flow, then continue with the resolved local path. A plain local path skips this step.
|
||||
|
||||
### Step 1 - Ensure graphify is installed
|
||||
|
||||
```bash
|
||||
# Detect the correct Python interpreter (handles uv tool, pipx, venv, system installs)
|
||||
PYTHON=""
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
# 1. uv tool installs — most reliable on modern Mac/Linux
|
||||
if [ -z "$PYTHON" ] && command -v uv >/dev/null 2>&1; then
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
fi
|
||||
# 2. Read shebang from graphify binary (pipx and direct pip installs)
|
||||
if [ -z "$PYTHON" ] && [ -n "$GRAPHIFY_BIN" ]; then
|
||||
_SHEBANG=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$_SHEBANG" in
|
||||
*[!a-zA-Z0-9/_.-]*) ;;
|
||||
*) "$_SHEBANG" -c "import graphify" 2>/dev/null && PYTHON="$_SHEBANG" ;;
|
||||
esac
|
||||
fi
|
||||
# 3. Fall back to python3
|
||||
if [ -z "$PYTHON" ]; then PYTHON="python3"; fi
|
||||
if ! "$PYTHON" -c "import graphify" 2>/dev/null; then
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
uv tool install --upgrade graphifyy -q 2>&1 | tail -3
|
||||
_UV_PY=$(uv tool run graphifyy python -c "import sys; print(sys.executable)" 2>/dev/null)
|
||||
if [ -n "$_UV_PY" ]; then PYTHON="$_UV_PY"; fi
|
||||
else
|
||||
"$PYTHON" -m pip install graphifyy -q 2>/dev/null \
|
||||
|| "$PYTHON" -m pip install graphifyy -q --break-system-packages 2>&1 | tail -3
|
||||
fi
|
||||
fi
|
||||
# Write interpreter path for all subsequent steps (persists across invocations)
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
# Save scan root so `graphify update` (no args) knows where to look next time
|
||||
echo "$(cd INPUT_PATH && pwd)" > graphify-out/.graphify_root
|
||||
```
|
||||
|
||||
If the import succeeds, print nothing and move straight to Step 2.
|
||||
|
||||
**In every subsequent bash block, replace `python3` with `$(cat graphify-out/.graphify_python)` to use the correct interpreter.**
|
||||
|
||||
### Step 2 - Detect files
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.detect import detect
|
||||
from pathlib import Path
|
||||
result = detect(Path('INPUT_PATH'))
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
" > graphify-out/.graphify_detect.json
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the actual path the user provided. Do NOT cat or print the JSON - read it silently and present a clean summary instead:
|
||||
|
||||
```
|
||||
Corpus: X files · ~Y words
|
||||
code: N files (.py .ts .go ...)
|
||||
docs: N files (.md .txt ...)
|
||||
papers: N files (.pdf ...)
|
||||
images: N files
|
||||
video: N files (.mp4 .mp3 ...)
|
||||
```
|
||||
|
||||
Omit any category with 0 files from the summary.
|
||||
|
||||
Then act on it:
|
||||
- If `total_files` is 0: stop with "No supported files found in [path]."
|
||||
- If `skipped_sensitive` is non-empty: mention file count skipped, not the file names.
|
||||
- If `total_words` > 2,000,000 OR `total_files` > 500: show the warning. Then compute the top 5 first-level subdirectories by file count:
|
||||
- Read `scan_root` from the detect JSON (always an absolute path to the resolved INPUT_PATH).
|
||||
- Concatenate all file lists across all types (`code`, `document`, `paper`, `image`, `video`).
|
||||
- Filter out any path that starts with `scan_root + "/graphify-out/"` to exclude converted sidecars.
|
||||
- For each file, strip the `scan_root` prefix and take the first path component. Files directly in `scan_root` with no subdirectory count as `(root)`.
|
||||
- If all files are in `(root)` with no subdirectories, do not ask to narrow — no subfolders exist. Instead suggest `--no-cluster` to skip the expensive clustering step and proceed.
|
||||
- Otherwise rank by count, show the top 5 with file counts, then ask which subfolder to run on. Wait for the user's answer before proceeding.
|
||||
- Otherwise: proceed directly to Step 2.5 if video files were detected, or Step 3 if not.
|
||||
|
||||
### Step 2.5 - Video and audio (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files. When the corpus has video or audio, see `references/transcribe.md` to transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
### Step 3 - Extract entities and relationships
|
||||
|
||||
**Before starting:** note whether `--mode deep` was given. You must pass `DEEP_MODE=true` to every subagent in Step B2 if it was. Track this from the original invocation - do not lose it.
|
||||
|
||||
This step has two parts: **structural extraction** (deterministic, free) and **semantic extraction** (LLM, costs tokens).
|
||||
|
||||
**Before dispatching subagents:** check whether `GEMINI_API_KEY` or `GOOGLE_API_KEY` is set. If neither is set, print this one-liner to the user:
|
||||
> Tip: set `GEMINI_API_KEY` or `GOOGLE_API_KEY` to use Gemini for semantic extraction (`pip install 'graphifyy[gemini]'`).
|
||||
|
||||
Print it once, then continue. If `GEMINI_API_KEY` or `GOOGLE_API_KEY` IS set, use `graphify.llm.extract_corpus_parallel(files, backend="gemini")` for semantic extraction instead of dispatching Claude subagents. The default Gemini model is `gemini-3-flash-preview`; set `GRAPHIFY_GEMINI_MODEL` or pass `--model` in headless CLI flows to override it.
|
||||
|
||||
> **No other API keys are read.** If `GEMINI_API_KEY`/`GOOGLE_API_KEY` are unset, fall straight through to Claude Code subagent dispatch (Part B below) — the host session itself is the LLM. graphify does **not** read `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or any other provider key from the environment. If a host agent prompts the user for `ANTHROPIC_API_KEY` to run extraction, that prompt is a misread of this skill — ignore it and dispatch subagents as written.
|
||||
|
||||
**Run Part A (AST) and Part B (semantic) in parallel. Dispatch all semantic subagents AND start AST extraction in the same message. Both can run simultaneously since they operate on different file types. Merge results in Part C as before.**
|
||||
|
||||
Note: Parallelizing AST + semantic saves 5-15s on large corpora. AST is deterministic and fast; start it while subagents are processing docs/papers.
|
||||
|
||||
#### Part A - Structural extraction for code files
|
||||
|
||||
For any code files detected, run AST extraction in parallel with Part B subagents:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.extract import collect_files, extract
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
code_files = []
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
for f in detect.get('files', {}).get('code', []):
|
||||
code_files.extend(collect_files(Path(f)) if Path(f).is_dir() else [Path(f)])
|
||||
|
||||
if code_files:
|
||||
result = extract(code_files, cache_root=Path('.'))
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'AST: {len(result[\"nodes\"])} nodes, {len(result[\"edges\"])} edges')
|
||||
else:
|
||||
Path('graphify-out/.graphify_ast.json').write_text(json.dumps({'nodes':[],'edges':[],'input_tokens':0,'output_tokens':0}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('No code files - skipping AST extraction')
|
||||
"
|
||||
```
|
||||
|
||||
#### Part B - Semantic extraction (parallel subagents)
|
||||
|
||||
**Fast path:** If detection found zero docs, papers, and images (code-only corpus), skip Part B entirely and go straight to Part C. AST handles code - there is nothing for semantic subagents to do.
|
||||
|
||||
**MANDATORY: You MUST use the Agent tool here. Reading files yourself one-by-one is forbidden - it is 5-10x slower. If you do not use the Agent tool you are doing this wrong.**
|
||||
|
||||
Before dispatching subagents, print a timing estimate:
|
||||
- Load `total_words` and file counts from `graphify-out/.graphify_detect.json`
|
||||
- Estimate agents needed: `ceil(uncached_non_code_files / 22)` (chunk size is 20-25)
|
||||
- Estimate time: ~45s per agent batch (they run in parallel, so total ≈ 45s × ceil(agents/parallel_limit))
|
||||
- Print: "Semantic extraction: ~N files → X agents, estimated ~Ys"
|
||||
|
||||
**Step B0 - Check extraction cache first**
|
||||
|
||||
Before dispatching any subagents, check which files already have cached extraction results:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import check_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
all_files = [f for files in detect['files'].values() for f in files]
|
||||
|
||||
cached_nodes, cached_edges, cached_hyperedges, uncached = check_semantic_cache(all_files)
|
||||
|
||||
if cached_nodes or cached_edges or cached_hyperedges:
|
||||
Path('graphify-out/.graphify_cached.json').write_text(json.dumps({'nodes': cached_nodes, 'edges': cached_edges, 'hyperedges': cached_hyperedges}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
Path('graphify-out/.graphify_uncached.txt').write_text('\n'.join(uncached), encoding=\"utf-8\")
|
||||
print(f'Cache: {len(all_files)-len(uncached)} files hit, {len(uncached)} files need extraction')
|
||||
"
|
||||
```
|
||||
|
||||
Only dispatch subagents for files listed in `graphify-out/.graphify_uncached.txt`. If all files are cached, skip to Part C directly.
|
||||
|
||||
**Step B1 - Split into chunks**
|
||||
|
||||
Load files from `graphify-out/.graphify_uncached.txt`. Split into chunks of 20-25 files each. Each image gets its own chunk (vision needs separate context). When splitting, group files from the same directory together so related artifacts land in the same chunk and cross-file relationships are more likely to be extracted.
|
||||
|
||||
**Step B2 - Dispatch ALL subagents in a single message**
|
||||
|
||||
Call the Agent tool multiple times IN THE SAME RESPONSE - one call per chunk. This is the only way they run in parallel. If you make one Agent call, wait, then make another, you are doing it sequentially and defeating the purpose.
|
||||
|
||||
**IMPORTANT - subagent type:** Always use `subagent_type="general-purpose"`. Do NOT use `Explore` - it is read-only and cannot write chunk files to disk, which silently drops extraction results. General-purpose has Write and Bash access which the subagent needs.
|
||||
|
||||
Concrete example for 3 chunks:
|
||||
```
|
||||
[Agent tool call 1: files 1-15, subagent_type="general-purpose"]
|
||||
[Agent tool call 2: files 16-30, subagent_type="general-purpose"]
|
||||
[Agent tool call 3: files 31-45, subagent_type="general-purpose"]
|
||||
```
|
||||
All three in one message. Not three separate messages.
|
||||
|
||||
Each subagent receives this exact prompt (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
|
||||
|
||||
CHUNK_PATH must be an **absolute** path — derive it before dispatching:
|
||||
```bash
|
||||
PROJECT_ROOT=$(cat graphify-out/.graphify_root)
|
||||
# Then for chunk N: CHUNK_PATH="${PROJECT_ROOT}/graphify-out/.graphify_chunk_0N.json"
|
||||
```
|
||||
|
||||
Subagent prompt template:
|
||||
|
||||
See `references/extraction-spec.md` for the exact subagent prompt (JSON schema, node-ID rules, confidence rubric, frontmatter, hyperedge, and vision rules). Load it only here, only when at least one chunk holds a doc, paper, or image; a pure-code corpus has skipped Part B and never reads it. Pass each subagent that prompt verbatim with FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH substituted, and have it write the result to CHUNK_PATH.
|
||||
|
||||
**Step B3 - Collect, cache, and merge**
|
||||
|
||||
Wait for all subagents. For each result:
|
||||
- Check that `graphify-out/.graphify_chunk_NN.json` exists on disk — this is the success signal
|
||||
- If the file exists and contains valid JSON with `nodes` and `edges`, include it and save to cache
|
||||
- If the file is missing, the subagent was likely dispatched as read-only (Explore type) — print a warning: "chunk N missing from disk — subagent may have been read-only. Re-run with general-purpose agent." Do not silently skip.
|
||||
- If a subagent failed or returned invalid JSON, print a warning and skip that chunk - do not abort
|
||||
|
||||
If more than half the chunks failed or are missing, stop and tell the user to re-run and ensure `subagent_type="general-purpose"` is used.
|
||||
|
||||
Merge all chunk files into `.graphify_semantic_new.json`. **After each Agent call completes, read the real token counts from the Agent tool result's `usage` field and write them back into the chunk JSON before merging** — the chunk JSON itself always has placeholder zeros. Then run:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, glob
|
||||
from pathlib import Path
|
||||
|
||||
chunks = sorted(glob.glob('graphify-out/.graphify_chunk_*.json'))
|
||||
all_nodes, all_edges, all_hyperedges = [], [], []
|
||||
total_in, total_out = 0, 0
|
||||
for c in chunks:
|
||||
d = json.loads(Path(c).read_text(encoding=\"utf-8\"))
|
||||
all_nodes += d.get('nodes', [])
|
||||
all_edges += d.get('edges', [])
|
||||
all_hyperedges += d.get('hyperedges', [])
|
||||
total_in += d.get('input_tokens', 0)
|
||||
total_out += d.get('output_tokens', 0)
|
||||
Path('graphify-out/.graphify_semantic_new.json').write_text(json.dumps({
|
||||
'nodes': all_nodes, 'edges': all_edges, 'hyperedges': all_hyperedges,
|
||||
'input_tokens': total_in, 'output_tokens': total_out,
|
||||
}, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Merged {len(chunks)} chunks: {total_in:,} in / {total_out:,} out tokens')
|
||||
"
|
||||
```
|
||||
|
||||
Save new results to cache:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.cache import save_semantic_cache
|
||||
from pathlib import Path
|
||||
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
saved = save_semantic_cache(new.get('nodes', []), new.get('edges', []), new.get('hyperedges', []))
|
||||
print(f'Cached {saved} files')
|
||||
"
|
||||
```
|
||||
|
||||
Merge cached + new results into `graphify-out/.graphify_semantic.json`:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
cached = json.loads(Path('graphify-out/.graphify_cached.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_cached.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
new = json.loads(Path('graphify-out/.graphify_semantic_new.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_semantic_new.json').exists() else {'nodes':[],'edges':[],'hyperedges':[]}
|
||||
|
||||
all_nodes = cached['nodes'] + new.get('nodes', [])
|
||||
all_edges = cached['edges'] + new.get('edges', [])
|
||||
all_hyperedges = cached.get('hyperedges', []) + new.get('hyperedges', [])
|
||||
seen = set()
|
||||
deduped = []
|
||||
for n in all_nodes:
|
||||
if n['id'] not in seen:
|
||||
seen.add(n['id'])
|
||||
deduped.append(n)
|
||||
|
||||
merged = {
|
||||
'nodes': deduped,
|
||||
'edges': all_edges,
|
||||
'hyperedges': all_hyperedges,
|
||||
'input_tokens': new.get('input_tokens', 0),
|
||||
'output_tokens': new.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_semantic.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'Extraction complete - {len(deduped)} nodes, {len(all_edges)} edges ({len(cached[\"nodes\"])} from cache, {len(new.get(\"nodes\",[]))} new)')
|
||||
"
|
||||
```
|
||||
Clean up temp files: `rm -f graphify-out/.graphify_cached.json graphify-out/.graphify_uncached.txt graphify-out/.graphify_semantic_new.json`
|
||||
|
||||
#### Part C - Merge AST + semantic into final extraction
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from pathlib import Path
|
||||
|
||||
ast = json.loads(Path('graphify-out/.graphify_ast.json').read_text(encoding=\"utf-8\"))
|
||||
sem = json.loads(Path('graphify-out/.graphify_semantic.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
# Merge: AST nodes first, semantic nodes deduplicated by id
|
||||
seen = {n['id'] for n in ast['nodes']}
|
||||
merged_nodes = list(ast['nodes'])
|
||||
for n in sem['nodes']:
|
||||
if n['id'] not in seen:
|
||||
merged_nodes.append(n)
|
||||
seen.add(n['id'])
|
||||
|
||||
merged_edges = ast['edges'] + sem['edges']
|
||||
merged_hyperedges = sem.get('hyperedges', [])
|
||||
merged = {
|
||||
'nodes': merged_nodes,
|
||||
'edges': merged_edges,
|
||||
'hyperedges': merged_hyperedges,
|
||||
'input_tokens': sem.get('input_tokens', 0),
|
||||
'output_tokens': sem.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
total = len(merged_nodes)
|
||||
edges = len(merged_edges)
|
||||
print(f'Merged: {total} nodes, {edges} edges ({len(ast[\"nodes\"])} AST + {len(sem[\"nodes\"])} semantic)')
|
||||
"
|
||||
```
|
||||
|
||||
### Step 4 - Build graph, cluster, analyze, generate outputs
|
||||
|
||||
**Before starting:** note whether `--directed` was given. If so, pass `directed=True` to `build_from_json()` in the code block below. This builds a `DiGraph` that preserves edge direction (source→target) instead of the default undirected `Graph`.
|
||||
|
||||
```bash
|
||||
mkdir -p graphify-out
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import cluster, score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from graphify.export import to_json
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
G = build_from_json(extraction)
|
||||
communities = cluster(G)
|
||||
cohesion = score_all(G, communities)
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
gods = god_nodes(G)
|
||||
surprises = surprising_connections(G, communities)
|
||||
labels = {cid: 'Community ' + str(cid) for cid in communities}
|
||||
# Placeholder questions - regenerated with real labels in Step 5
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
report = generate(G, communities, cohesion, labels, gods, surprises, detection, tokens, '.', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
to_json(G, communities, 'graphify-out/graph.json')
|
||||
|
||||
analysis = {
|
||||
'communities': {str(k): v for k, v in communities.items()},
|
||||
'cohesion': {str(k): v for k, v in cohesion.items()},
|
||||
'gods': gods,
|
||||
'surprises': surprises,
|
||||
'questions': questions,
|
||||
}
|
||||
Path('graphify-out/.graphify_analysis.json').write_text(json.dumps(analysis, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
if G.number_of_nodes() == 0:
|
||||
print('ERROR: Graph is empty - extraction produced no nodes.')
|
||||
print('Possible causes: all files were skipped, binary-only corpus, or extraction failed.')
|
||||
raise SystemExit(1)
|
||||
print(f'Graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges, {len(communities)} communities')
|
||||
"
|
||||
```
|
||||
|
||||
If this step prints `ERROR: Graph is empty`, stop and tell the user what happened - do not proceed to labeling or visualization.
|
||||
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 5 - Label communities
|
||||
|
||||
Read `graphify-out/.graphify_analysis.json`. For each community key, look at its node labels and write a 2-5 word plain-language name (e.g. "Attention Mechanism", "Training Pipeline", "Data Loading").
|
||||
|
||||
Then regenerate the report and save the labels for the visualizer:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.build import build_from_json
|
||||
from graphify.cluster import score_all
|
||||
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
|
||||
from graphify.report import generate
|
||||
from pathlib import Path
|
||||
|
||||
extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
detection = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
analysis = json.loads(Path('graphify-out/.graphify_analysis.json').read_text(encoding=\"utf-8\"))
|
||||
|
||||
G = build_from_json(extraction)
|
||||
communities = {int(k): v for k, v in analysis['communities'].items()}
|
||||
cohesion = {int(k): v for k, v in analysis['cohesion'].items()}
|
||||
tokens = {'input': extraction.get('input_tokens', 0), 'output': extraction.get('output_tokens', 0)}
|
||||
|
||||
# LABELS - replace these with the names you chose above
|
||||
labels = LABELS_DICT
|
||||
|
||||
# Regenerate questions with real community labels (labels affect question phrasing)
|
||||
questions = suggest_questions(G, communities, labels)
|
||||
|
||||
report = generate(G, communities, cohesion, labels, analysis['gods'], analysis['surprises'], detection, tokens, '.', suggested_questions=questions)
|
||||
Path('graphify-out/GRAPH_REPORT.md').write_text(report, encoding=\"utf-8\")
|
||||
Path('graphify-out/.graphify_labels.json').write_text(json.dumps({str(k): v for k, v in labels.items()}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print('Report updated with community labels')
|
||||
"
|
||||
```
|
||||
|
||||
Replace `LABELS_DICT` with the actual dict you constructed (e.g. `{0: "Attention Mechanism", 1: "Training Pipeline"}`).
|
||||
Replace INPUT_PATH with the actual path.
|
||||
|
||||
### Step 6 - Generate Obsidian vault (opt-in) + HTML
|
||||
|
||||
**Generate HTML always** (unless `--no-viz`). **Obsidian vault only if `--obsidian` was explicitly given** — skip it otherwise, it generates one file per node.
|
||||
|
||||
If `--obsidian` was given:
|
||||
|
||||
- If `--obsidian-dir <path>` was also given, pass it via `--dir`. Otherwise defaults to `graphify-out/obsidian`.
|
||||
|
||||
```bash
|
||||
graphify export obsidian
|
||||
# or with custom dir: graphify export obsidian --dir ~/vaults/my-project
|
||||
```
|
||||
|
||||
Generate the HTML graph (always, unless `--no-viz`):
|
||||
|
||||
```bash
|
||||
graphify export html # auto-aggregates to community view if graph > 5000 nodes
|
||||
# or: graphify export html --no-viz
|
||||
```
|
||||
|
||||
### Steps 6b-8 - Wiki, Neo4j, SVG, GraphML, MCP, benchmark (only on their flags)
|
||||
|
||||
These run only when their flag is present (`--wiki`, `--neo4j`/`--neo4j-push`, `--svg`, `--graphml`, `--mcp`) or, for the token-reduction benchmark, when `total_words` exceeds 5,000. A default run with no export flags skips all of them. See `references/exports.md` for each one. Run any `--wiki` export before Step 9 cleanup so `.graphify_labels.json` is still available.
|
||||
|
||||
---
|
||||
|
||||
### Step 9 - Save manifest, update cost tracker, clean up, and report
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Save manifest for --update
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
# In --update mode, 'all_files' carries the full corpus; 'files' is the changed
|
||||
# subset. Full-rebuild mode populates only 'files', so the fallback handles that.
|
||||
save_manifest(detect.get('all_files') or detect['files'])
|
||||
|
||||
# Update cumulative cost tracker
|
||||
extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
input_tok = extract.get('input_tokens', 0)
|
||||
output_tok = extract.get('output_tokens', 0)
|
||||
|
||||
cost_path = Path('graphify-out/cost.json')
|
||||
if cost_path.exists():
|
||||
cost = json.loads(cost_path.read_text(encoding=\"utf-8\"))
|
||||
else:
|
||||
cost = {'runs': [], 'total_input_tokens': 0, 'total_output_tokens': 0}
|
||||
|
||||
cost['runs'].append({
|
||||
'date': datetime.now(timezone.utc).isoformat(),
|
||||
'input_tokens': input_tok,
|
||||
'output_tokens': output_tok,
|
||||
'files': detect.get('total_files', 0),
|
||||
})
|
||||
cost['total_input_tokens'] += input_tok
|
||||
cost['total_output_tokens'] += output_tok
|
||||
cost_path.write_text(json.dumps(cost, indent=2, ensure_ascii=False), encoding=\"utf-8\")
|
||||
|
||||
print(f'This run: {input_tok:,} input tokens, {output_tok:,} output tokens')
|
||||
print(f'All time: {cost[\"total_input_tokens\"]:,} input, {cost[\"total_output_tokens\"]:,} output ({len(cost[\"runs\"])} runs)')
|
||||
"
|
||||
rm -f graphify-out/.graphify_detect.json graphify-out/.graphify_extract.json graphify-out/.graphify_ast.json graphify-out/.graphify_semantic.json graphify-out/.graphify_analysis.json
|
||||
find graphify-out -maxdepth 1 -name '.graphify_chunk_*.json' -delete 2>/dev/null
|
||||
rm -f graphify-out/.needs_update 2>/dev/null || true
|
||||
```
|
||||
|
||||
Tell the user (omit the obsidian line unless --obsidian was given):
|
||||
```
|
||||
Graph complete. Outputs in PATH_TO_DIR/graphify-out/
|
||||
|
||||
graph.html - interactive graph, open in browser
|
||||
GRAPH_REPORT.md - audit report
|
||||
graph.json - raw graph data
|
||||
obsidian/ - Obsidian vault (only if --obsidian was given)
|
||||
```
|
||||
|
||||
If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi
|
||||
|
||||
Replace PATH_TO_DIR with the actual absolute path of the directory that was processed.
|
||||
|
||||
Then paste these sections from GRAPH_REPORT.md directly into the chat:
|
||||
- God Nodes
|
||||
- Surprising Connections
|
||||
- Suggested Questions
|
||||
|
||||
Do NOT paste the full report - just those three sections. Keep it concise.
|
||||
|
||||
Then immediately offer to explore. Pick the single most interesting suggested question from the report - the one that crosses the most community boundaries or has the most surprising bridge node - and ask:
|
||||
|
||||
> "The most interesting question this graph can answer: **[question]**. Want me to trace it?"
|
||||
|
||||
If the user says yes, run `/graphify query "[question]"` on the graph and walk them through the answer using the graph structure - which nodes connect, which community boundaries get crossed, what the path reveals. Keep going as long as they want to explore. Each answer should end with a natural follow-up ("this connects to X - want to go deeper?") so the session feels like navigation, not a one-shot report.
|
||||
|
||||
The graph is the map. Your job after the pipeline is to be the guide.
|
||||
|
||||
---
|
||||
|
||||
## Interpreter guard for subcommands
|
||||
|
||||
Before running any subcommand below (`--update`, `--cluster-only`, `query`, `path`, `explain`, `add`), check that `.graphify_python` exists. If it's missing (e.g. user deleted `graphify-out/`), re-resolve the interpreter first:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_python ]; then
|
||||
GRAPHIFY_BIN=$(which graphify 2>/dev/null)
|
||||
if [ -n "$GRAPHIFY_BIN" ]; then
|
||||
PYTHON=$(head -1 "$GRAPHIFY_BIN" | tr -d '#!')
|
||||
case "$PYTHON" in *[!a-zA-Z0-9/_.-]*) PYTHON="python3" ;; esac
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
mkdir -p graphify-out
|
||||
"$PYTHON" -c "import sys; open('graphify-out/.graphify_python', 'w', encoding='utf-8').write(sys.executable)"
|
||||
fi
|
||||
```
|
||||
|
||||
## For --update and --cluster-only
|
||||
|
||||
Both are non-default subcommands. `--update` re-extracts only new or changed files; `--cluster-only` reruns clustering on the existing graph. See `references/update.md` for both flows.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify query
|
||||
|
||||
When `graphify-out/graph.json` already exists and the user asks a question about the corpus, run the query directly:
|
||||
|
||||
```bash
|
||||
graphify query "<question>"
|
||||
```
|
||||
|
||||
Answer using only what the graph output contains, and quote `source_location` when citing a specific fact. Before traversal, expand the question against the graph's own vocabulary so a wording mismatch does not collapse the answer to noise. For that vocab-expansion step, the `--dfs` / `--budget` modes, `save-result` feedback, and the `/graphify path` and `/graphify explain` flows, see `references/query.md`.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify add and --watch
|
||||
|
||||
Neither is part of the default build. When the user runs `/graphify add <url>` to fetch a URL into the corpus, or passes `--watch` to auto-rebuild on file changes, see `references/add-watch.md`.
|
||||
|
||||
---
|
||||
|
||||
## For the commit hook and native CLAUDE.md integration
|
||||
|
||||
When the user asks to install the post-commit auto-rebuild hook or wire graphify into a project's CLAUDE.md, see `references/hooks.md`.
|
||||
|
||||
---
|
||||
|
||||
## Honesty Rules
|
||||
|
||||
- Never invent an edge. If unsure, use AMBIGUOUS.
|
||||
- Never skip the corpus check warning.
|
||||
- Always show token cost in the report.
|
||||
- Never hide cohesion scores behind symbols - show the raw number.
|
||||
- Never run HTML viz on a graph with more than 5,000 nodes without warning the user.
|
||||
@@ -0,0 +1,56 @@
|
||||
# graphify reference: add a URL and watch a folder
|
||||
|
||||
Load this when the user ran `/graphify add <url>` or passed `--watch`. Neither is part of the default build.
|
||||
|
||||
## For /graphify add
|
||||
|
||||
Fetch a URL and add it to the corpus, then update the graph.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys
|
||||
from graphify.ingest import ingest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
out = ingest('URL', Path('./raw'), author='AUTHOR', contributor='CONTRIBUTOR')
|
||||
print(f'Saved to {out}')
|
||||
except ValueError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except RuntimeError as e:
|
||||
print(f'error: {e}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
"
|
||||
```
|
||||
|
||||
Replace `URL` with the actual URL, `AUTHOR` with the user's name if provided, `CONTRIBUTOR` likewise. If the command exits with an error, tell the user what went wrong - do not silently continue. After a successful save, automatically run the `--update` pipeline on `./raw` to merge the new file into the existing graph.
|
||||
|
||||
Supported URL types (auto-detected):
|
||||
- YouTube / any video URL → audio downloaded via yt-dlp, transcribed to `.txt` on next run (requires `pip install 'graphifyy[video]'`)
|
||||
- Twitter/X → fetched via oEmbed, saved as `.md` with tweet text and author
|
||||
- arXiv → abstract + metadata saved as `.md`
|
||||
- PDF → downloaded as `.pdf`
|
||||
- Images (.png/.jpg/.webp) → downloaded, Claude vision extracts on next run
|
||||
- Any webpage → converted to markdown via html2text
|
||||
|
||||
---
|
||||
|
||||
## For --watch
|
||||
|
||||
Start a background watcher that monitors a folder and auto-updates the graph when files change.
|
||||
|
||||
```bash
|
||||
python3 -m graphify.watch INPUT_PATH --debounce 3
|
||||
```
|
||||
|
||||
Replace INPUT_PATH with the folder to watch. Behavior depends on what changed:
|
||||
|
||||
- **Code files only (.py, .ts, .go, etc.):** re-runs AST extraction + rebuild + cluster immediately, no LLM needed. `graph.json` and `GRAPH_REPORT.md` are updated automatically.
|
||||
- **Docs, papers, or images:** writes a `graphify-out/needs_update` flag and prints a notification to run `/graphify --update` (LLM semantic re-extraction required).
|
||||
|
||||
Debounce (default 3s): waits until file activity stops before triggering, so a wave of parallel agent writes doesn't trigger a rebuild per file.
|
||||
|
||||
Press Ctrl+C to stop.
|
||||
|
||||
For agentic workflows: run `--watch` in a background terminal. Code changes from agent waves are picked up automatically between waves. If agents are also writing docs or notes, you'll need a manual `/graphify --update` after those waves.
|
||||
@@ -0,0 +1,71 @@
|
||||
# graphify reference: extra exports and benchmark
|
||||
|
||||
Load this when the user passed one of the export flags (`--wiki`, `--neo4j`, `--neo4j-push`, `--svg`, `--graphml`, `--mcp`), or when the corpus is large enough for the token-reduction benchmark. Each step runs only for its own flag.
|
||||
|
||||
### Step 6b - Wiki (only if --wiki flag)
|
||||
|
||||
**Only run this step if `--wiki` was explicitly given in the original command.**
|
||||
|
||||
Run this before Step 9 (cleanup) so `.graphify_labels.json` is still available.
|
||||
|
||||
```bash
|
||||
graphify export wiki
|
||||
```
|
||||
|
||||
### Step 7 - Neo4j export (only if --neo4j or --neo4j-push flag)
|
||||
|
||||
**If `--neo4j`** - generate a Cypher file for manual import:
|
||||
|
||||
```bash
|
||||
graphify export neo4j
|
||||
```
|
||||
|
||||
**If `--neo4j-push <uri>`** - push directly to a running Neo4j instance. Ask the user for credentials if not provided:
|
||||
|
||||
```bash
|
||||
graphify export neo4j --push bolt://localhost:7687 --user neo4j --password PASSWORD
|
||||
```
|
||||
|
||||
Default URI is `bolt://localhost:7687`, default user is `neo4j`. Uses MERGE - safe to re-run without creating duplicates.
|
||||
|
||||
### Step 7b - SVG export (only if --svg flag)
|
||||
|
||||
```bash
|
||||
graphify export svg
|
||||
```
|
||||
|
||||
### Step 7c - GraphML export (only if --graphml flag)
|
||||
|
||||
```bash
|
||||
graphify export graphml
|
||||
```
|
||||
|
||||
### Step 7d - MCP server (only if --mcp flag)
|
||||
|
||||
```bash
|
||||
python3 -m graphify.serve graphify-out/graph.json
|
||||
```
|
||||
|
||||
This starts a stdio MCP server that exposes tools: `query_graph`, `get_node`, `get_neighbors`, `get_community`, `god_nodes`, `graph_stats`, `shortest_path`. Add to Claude Desktop or any MCP-compatible agent orchestrator so other agents can query the graph live.
|
||||
|
||||
To configure in Claude Desktop, add to `claude_desktop_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"graphify": {
|
||||
"command": "python3",
|
||||
"args": ["-m", "graphify.serve", "/absolute/path/to/graphify-out/graph.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8 - Token reduction benchmark (only if total_words > 5000)
|
||||
|
||||
If `total_words` from `graphify-out/.graphify_detect.json` is greater than 5,000, run:
|
||||
|
||||
```bash
|
||||
graphify benchmark
|
||||
```
|
||||
|
||||
Print the output directly in chat. If `total_words <= 5000`, skip silently - the graph value is structural clarity, not token compression, for small corpora.
|
||||
@@ -0,0 +1,68 @@
|
||||
# graphify reference: extraction subagent prompt
|
||||
|
||||
Load this in Step 3 Part B when the corpus has at least one doc, paper, or image chunk. A pure-code corpus skips Part B and never reads this file. Each semantic subagent receives the prompt below verbatim (substitute FILE_LIST, CHUNK_NUM, TOTAL_CHUNKS, DEEP_MODE, and CHUNK_PATH).
|
||||
|
||||
```
|
||||
You are a graphify extraction subagent. Read the files listed and extract a knowledge graph fragment.
|
||||
Output ONLY valid JSON matching the schema below - no explanation, no markdown fences, no preamble.
|
||||
|
||||
Files (chunk CHUNK_NUM of TOTAL_CHUNKS):
|
||||
FILE_LIST
|
||||
|
||||
Rules:
|
||||
- EXTRACTED: relationship explicit in source (import, call, citation, "see §3.2")
|
||||
- INFERRED: reasonable inference (shared data structure, implied dependency)
|
||||
- AMBIGUOUS: uncertain - flag for review, do not omit
|
||||
|
||||
Code files: focus on semantic edges AST cannot find (call relationships, shared data, arch patterns).
|
||||
Do not re-extract imports - AST already has those.
|
||||
Doc/paper files: extract named concepts, entities, citations. For rationale (WHY decisions were made, trade-offs, design intent): store as a `rationale` attribute on the relevant concept node — do NOT create a separate rationale node or fragment node. Only create a node for something that is itself a named entity or concept. Use `file_type:"rationale"` for concept-like nodes (ideas, principles, mechanisms, design patterns). `file_type` MUST be one of exactly these six values: `code`, `document`, `paper`, `image`, `rationale`, `concept`. Any other value is invalid and will be rejected.
|
||||
Code files: when adding `calls` edges, source MUST be the caller (the function/class doing the calling), target MUST be the callee. Never reverse this direction. `calls` edges MUST stay within one language: a Python function cannot `calls` a JS/TS/Go/Rust/Java symbol and vice versa — cross-language call edges are phantom artifacts, never emit them.
|
||||
Image files: use vision to understand what the image IS - do not just OCR.
|
||||
UI screenshot: layout patterns, design decisions, key elements, purpose.
|
||||
Chart: metric, trend/insight, data source.
|
||||
Tweet/post: claim as node, author, concepts mentioned.
|
||||
Diagram: components and connections.
|
||||
Research figure: what it demonstrates, method, result.
|
||||
Handwritten/whiteboard: ideas and arrows, mark uncertain readings AMBIGUOUS.
|
||||
|
||||
DEEP_MODE (if --mode deep was given): be aggressive with INFERRED edges - indirect deps,
|
||||
shared assumptions, latent couplings. Mark uncertain ones AMBIGUOUS instead of omitting.
|
||||
|
||||
Semantic similarity: if two concepts in this chunk solve the same problem or represent the same idea without any structural link (no import, no call, no citation), add a `semantically_similar_to` edge marked INFERRED with a confidence_score reflecting how similar they are (0.6-0.95). Examples:
|
||||
- Two functions that both validate user input but never call each other
|
||||
- A class in code and a concept in a paper that describe the same algorithm
|
||||
- Two error types that handle the same failure mode differently
|
||||
Only add these when the similarity is genuinely non-obvious and cross-cutting. Do not add them for trivially similar things.
|
||||
|
||||
Hyperedges: if 3 or more nodes clearly participate together in a shared concept, flow, or pattern that is not captured by pairwise edges alone, add a hyperedge to a top-level `hyperedges` array. Examples:
|
||||
- All classes that implement a common protocol or interface
|
||||
- All functions in an authentication flow (even if they don't all call each other)
|
||||
- All concepts from a paper section that form one coherent idea
|
||||
Use sparingly — only when the group relationship adds information beyond the pairwise edges. Maximum 3 hyperedges per chunk.
|
||||
|
||||
If a file has YAML frontmatter (--- ... ---), copy source_url, captured_at, author,
|
||||
contributor onto every node from that file.
|
||||
|
||||
confidence_score is REQUIRED on every edge - never omit it, never use 0.5 as a default:
|
||||
- EXTRACTED edges: confidence_score = 1.0 always
|
||||
- INFERRED edges: pick exactly ONE value from this set — never 0.5:
|
||||
0.95 direct structural evidence (shared data structure, named cross-file reference).
|
||||
0.85 strong inference (clear functional alignment, no direct symbol link).
|
||||
0.75 reasonable inference (shared problem domain + similar shape, requires interpretation).
|
||||
0.65 weak inference (thematically related, no shape evidence).
|
||||
0.55 speculative but plausible (surface-level co-occurrence only).
|
||||
Models follow discrete rubrics better than continuous ranges; the bimodal
|
||||
distribution observed in production (>50% at 0.5, >40% at 0.85+) shows the
|
||||
range guidance is being collapsed to a binary. If no value above fits, mark
|
||||
the edge AMBIGUOUS rather than picking 0.4 or below.
|
||||
- AMBIGUOUS edges: 0.1-0.3
|
||||
|
||||
Node ID format: lowercase, only `[a-z0-9_]`, no dots or slashes. Format: `{stem}_{entity}` where stem is `{parent_dir}_{filename_without_ext}` (the **immediate** parent directory name + the filename stem, both lowercased with non-alphanumeric chars replaced by `_`) and entity is the symbol name similarly normalized. Only one level of parent is used — not the full path. Examples: `src/auth/session.py` + `ValidateToken` → `auth_session_validatetoken`; `lib/utils/helpers.py` + `parse_url` → `utils_helpers_parse_url`; `tests/test_foo.py` + `_helper` → `tests_test_foo_helper`. Top-level files (no parent dir, e.g. `setup.py`) use just the filename stem: `setup_my_func`. This must match the ID the AST extractor generates — using just the filename (e.g., `session_validatetoken`) or the full path (e.g., `src_auth_session_validatetoken`) will create orphan ghost-duplicate nodes. If you are re-extracting a project that had ghost duplicates under the old format, the user should run `graphify extract --force` to rebuild cleanly. CRITICAL: never append chunk numbers, sequence numbers, or any suffix to an ID (no `_c1`, `_c2`, `_chunk2`, etc.). IDs must be deterministic from the label alone — the same entity must always produce the same ID regardless of which chunk processes it.
|
||||
|
||||
Generate the extraction JSON matching this schema exactly:
|
||||
{"nodes":[{"id":"session_validatetoken","label":"Human Readable Name","file_type":"code|document|paper|image|rationale|concept","source_file":"relative/path","source_location":null,"source_url":null,"captured_at":null,"author":null,"contributor":null}],"edges":[{"source":"node_id","target":"node_id","relation":"calls|implements|references|cites|conceptually_related_to|shares_data_with|semantically_similar_to|rationale_for","confidence":"EXTRACTED|INFERRED|AMBIGUOUS","confidence_score":1.0,"source_file":"relative/path","source_location":null,"weight":1.0}],"hyperedges":[{"id":"snake_case_id","label":"Human Readable Label","nodes":["node_id1","node_id2","node_id3"],"relation":"participate_in|implement|form","confidence":"EXTRACTED|INFERRED","confidence_score":0.75,"source_file":"relative/path"}],"input_tokens":0,"output_tokens":0}
|
||||
|
||||
Then write the JSON to disk using the Write tool at this exact absolute path (no relative paths — Write resolves relative paths against an undefined cwd and the file will be silently lost):
|
||||
CHUNK_PATH
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
# graphify reference: GitHub clone and cross-repo merge
|
||||
|
||||
Load this when the user passed one or more `https://github.com/...` URLs, or named several local subfolders to merge into one graph.
|
||||
|
||||
### Step 0 - Clone GitHub repo(s) (only if a GitHub URL was given)
|
||||
|
||||
**Single repo:**
|
||||
```bash
|
||||
LOCAL_PATH=$(graphify clone <github-url> [--branch <branch>])
|
||||
# Use LOCAL_PATH as the target for all subsequent steps
|
||||
```
|
||||
|
||||
**Multiple repos (cross-repo graph):**
|
||||
```bash
|
||||
# Clone each repo, run the full pipeline on each, then merge
|
||||
graphify clone <url1> # → ~/.graphify/repos/<owner1>/<repo1>
|
||||
graphify clone <url2> # → ~/.graphify/repos/<owner2>/<repo2>
|
||||
# Run /graphify on each local path to produce their graph.json files
|
||||
# Then merge:
|
||||
graphify merge-graphs \
|
||||
~/.graphify/repos/<owner1>/<repo1>/graphify-out/graph.json \
|
||||
~/.graphify/repos/<owner2>/<repo2>/graphify-out/graph.json \
|
||||
--out graphify-out/cross-repo-graph.json
|
||||
```
|
||||
|
||||
Graphify clones into `~/.graphify/repos/<owner>/<repo>` and reuses existing clones on repeat runs. Each node in the merged graph carries a `repo` attribute so you can filter by origin.
|
||||
|
||||
**Multiple local subfolders (monorepo or multi-service layout):**
|
||||
|
||||
The skill pipeline writes all intermediate and final outputs to `graphify-out/` in the current working directory. Running the skill on each subfolder separately will clobber the same output dir. Instead, use the CLI directly for each subfolder — it places `graphify-out/` *inside* the scanned path:
|
||||
|
||||
```bash
|
||||
graphify extract ./core/ # → ./core/graphify-out/graph.json
|
||||
graphify extract ./service/ # → ./service/graphify-out/graph.json
|
||||
graphify extract ./platform/ # → ./platform/graphify-out/graph.json
|
||||
# Add --backend gemini|kimi|openai|deepseek|claude-cli depending on which API key you have set
|
||||
|
||||
# Then merge at the project root:
|
||||
graphify merge-graphs \
|
||||
./core/graphify-out/graph.json \
|
||||
./service/graphify-out/graph.json \
|
||||
./platform/graphify-out/graph.json \
|
||||
--out graphify-out/graph.json
|
||||
```
|
||||
|
||||
Once `graphify-out/graph.json` exists, the fast path above takes over: any codebase question runs `graphify query` directly on the merged graph — no re-extraction, no size gate.
|
||||
@@ -0,0 +1,33 @@
|
||||
# graphify reference: commit hook and native CLAUDE.md integration
|
||||
|
||||
Load this when the user asked to install the post-commit hook or wire graphify into a project's CLAUDE.md.
|
||||
|
||||
## For git commit hook
|
||||
|
||||
Install a post-commit hook that auto-rebuilds the graph after every commit. No background process needed - triggers once per commit, works with any editor.
|
||||
|
||||
```bash
|
||||
graphify hook install # install
|
||||
graphify hook uninstall # remove
|
||||
graphify hook status # check
|
||||
```
|
||||
|
||||
After every `git commit`, the hook detects which code files changed (via `git diff HEAD~1`), re-runs AST extraction on those files, and rebuilds `graph.json` and `GRAPH_REPORT.md`. Doc/image changes are ignored by the hook - run `/graphify --update` manually for those.
|
||||
|
||||
If a post-commit hook already exists, graphify appends to it rather than replacing it.
|
||||
|
||||
---
|
||||
|
||||
## For native CLAUDE.md integration
|
||||
|
||||
Run once per project to make graphify always-on in Claude Code sessions:
|
||||
|
||||
```bash
|
||||
graphify claude install
|
||||
```
|
||||
|
||||
This writes a `## graphify` section to the local `CLAUDE.md` that instructs Claude to check the graph before answering codebase questions and rebuild it after code changes. No manual `/graphify` needed in future sessions.
|
||||
|
||||
```bash
|
||||
graphify claude uninstall # remove the section
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# graphify reference: query, path, explain
|
||||
|
||||
Load this when the user asks a question against an existing graph, or runs `/graphify path` or `/graphify explain`. The core's query stub points here for the full traversal flow.
|
||||
|
||||
Two traversal modes - choose based on the question:
|
||||
|
||||
| Mode | Flag | Best for |
|
||||
|------|------|----------|
|
||||
| BFS (default) | _(none)_ | "What is X connected to?" - broad context, nearest neighbors first |
|
||||
| DFS | `--dfs` | "How does X reach Y?" - trace a specific chain or dependency path |
|
||||
|
||||
### Step 0 — Constrained query expansion (REQUIRED before traversal)
|
||||
|
||||
graphify's `query` CLI matches nodes via case-folded substring + IDF — there is **no stemming, no synonyms, no cross-language match** inside the binary. If the user's question uses different language or different domain vocabulary than the graph's labels (user says "обработчик" / graph says "handler"; user says "authentication" / graph says "Guardian"), the literal matcher returns 0 hits and the answer collapses to noise.
|
||||
|
||||
Fix this **without inventing tokens** by expanding the query against the actual graph vocabulary first:
|
||||
|
||||
1. Extract the token vocabulary from node labels:
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, re
|
||||
from pathlib import Path
|
||||
data = json.loads(Path('graphify-out/graph.json').read_text())
|
||||
vocab = set()
|
||||
for n in data['nodes']:
|
||||
for c in re.findall(r'[^\W\d_]+', n.get('label','') or '', re.UNICODE):
|
||||
parts = re.findall(r'[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+', c) or [c]
|
||||
for p in parts:
|
||||
t = p.lower()
|
||||
if 3 <= len(t) <= 30:
|
||||
vocab.add(t)
|
||||
Path('graphify-out/.vocab.txt').write_text('\n'.join(sorted(vocab)))
|
||||
print(f'vocab: {len(vocab)} tokens')
|
||||
"
|
||||
```
|
||||
|
||||
2. Read `graphify-out/.vocab.txt`. Then for the user's question, select **up to 12 tokens from this exact list** that semantically match the query intent. Hard constraints:
|
||||
- You MUST pick only tokens present in the vocabulary file. Do NOT invent tokens.
|
||||
- If a query concept has no plausible token in the vocab, skip it — do not substitute a near-synonym from training memory.
|
||||
- If **no** vocab tokens match the query at all, output an empty list and tell the user the corpus has no relevant vocabulary for this question. Do not fabricate a search.
|
||||
- Translate cross-language: Russian "аутентификация" → look for `auth`, `credential`, `token`, `security` IFF present in vocab.
|
||||
- Morphology: "handlers" maps to `handler` IFF present; "todos" maps to `todo` IFF present.
|
||||
|
||||
3. Print the selection explicitly to the user before running the query, so the expansion is auditable:
|
||||
```
|
||||
Query expanded to (from graph vocab, N tokens): [token1, token2, ...]
|
||||
```
|
||||
If the list is empty, say so plainly and stop — do not proceed to traversal.
|
||||
|
||||
### Step 1 — Traversal
|
||||
|
||||
Build the **expanded query string** by joining the selected tokens with spaces. Use this string as `QUESTION` below — NOT the original user question. (The original question is preserved only for `save-result` at the end.)
|
||||
|
||||
```bash
|
||||
graphify query "QUESTION"
|
||||
# or: graphify query "QUESTION" --dfs --budget 3000
|
||||
```
|
||||
|
||||
Answer using **only** what the graph output contains. Quote `source_location` when citing a specific fact. If the graph lacks enough information, say so - do not hallucinate edges.
|
||||
|
||||
After writing the answer, save it back into the graph so it improves future queries. Include the expanded tokens inside the `--answer` text (e.g. `"Expanded from original query via vocab: [tokens]. Then traversed..."`) so the next `--update` extracts the expansion history as a graph node:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "ORIGINAL_QUESTION" --answer "ANSWER" --type query --nodes NODE1 NODE2
|
||||
```
|
||||
|
||||
Replace `ORIGINAL_QUESTION` with the user's verbatim question, `ANSWER` with your full answer text (containing the expanded-token trace), `NODE1 NODE2` with the list of node labels you cited. This closes the feedback loop: the next `--update` will extract this Q&A as a node in the graph.
|
||||
|
||||
---
|
||||
|
||||
## For /graphify path
|
||||
|
||||
Find the shortest path between two named concepts in the graph.
|
||||
|
||||
```bash
|
||||
graphify path "NODE_A" "NODE_B"
|
||||
```
|
||||
|
||||
Replace `NODE_A` and `NODE_B` with the actual concept names. Then explain the path in plain language - what each hop means, why it's significant.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Path from NODE_A to NODE_B" --answer "ANSWER" --type path_query --nodes NODE_A NODE_B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For /graphify explain
|
||||
|
||||
Give a plain-language explanation of a single node - everything connected to it.
|
||||
|
||||
```bash
|
||||
graphify explain "NODE_NAME"
|
||||
```
|
||||
|
||||
Replace `NODE_NAME` with the concept the user asked about. Then write a 3-5 sentence explanation of what this node is, what it connects to, and why those connections are significant. Use the source locations as citations.
|
||||
|
||||
After writing the explanation, save it back:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -m graphify save-result --question "Explain NODE_NAME" --answer "ANSWER" --type explain --nodes NODE_NAME
|
||||
```
|
||||
@@ -0,0 +1,48 @@
|
||||
# graphify reference: transcribe video and audio
|
||||
|
||||
Load this only when `detect` reported one or more `video` files. A corpus with no video never reads this.
|
||||
|
||||
### Step 2.5 - Transcribe video / audio files (only if video files detected)
|
||||
|
||||
Skip this step entirely if `detect` returned zero `video` files.
|
||||
|
||||
Video and audio files cannot be read directly. Transcribe them to text first, then treat the transcripts as doc files in Step 3.
|
||||
|
||||
**Strategy:** Read the god nodes from `graphify-out/.graphify_detect.json` (or the analysis file if it exists from a previous run). You are already a language model — write a one-sentence domain hint yourself from those labels. Then pass it to Whisper as the initial prompt. No separate API call needed.
|
||||
|
||||
**However**, if the corpus has *only* video files and no other docs/code, use the generic fallback prompt: `"Use proper punctuation and paragraph breaks."`
|
||||
|
||||
**Step 1 - Write the Whisper prompt yourself.**
|
||||
|
||||
Read the top god node labels from detect output or analysis, then compose a short domain hint sentence, for example:
|
||||
|
||||
- Labels: `transformer, attention, encoder, decoder` → `"Machine learning research on transformer architectures and attention mechanisms. Use proper punctuation and paragraph breaks."`
|
||||
- Labels: `kubernetes, deployment, pod, helm` → `"DevOps discussion about Kubernetes deployments and Helm charts. Use proper punctuation and paragraph breaks."`
|
||||
|
||||
Set it as `WHISPER_PROMPT` to use in the next command.
|
||||
|
||||
**Step 2 - Transcribe:**
|
||||
|
||||
```bash
|
||||
GRAPHIFY_WHISPER_MODEL=base # or whatever --whisper-model the user passed
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
from graphify.transcribe import transcribe_all
|
||||
|
||||
detect = json.loads(Path('graphify-out/.graphify_detect.json').read_text(encoding=\"utf-8\"))
|
||||
video_files = detect.get('files', {}).get('video', [])
|
||||
prompt = os.environ.get('GRAPHIFY_WHISPER_PROMPT', 'Use proper punctuation and paragraph breaks.')
|
||||
|
||||
transcript_paths = transcribe_all(video_files, initial_prompt=prompt)
|
||||
print(json.dumps(transcript_paths, ensure_ascii=False))
|
||||
" > graphify-out/.graphify_transcripts.json
|
||||
```
|
||||
|
||||
After transcription:
|
||||
- Read the transcript paths from `graphify-out/.graphify_transcripts.json`
|
||||
- Add them to the docs list before dispatching semantic subagents in Step 3B
|
||||
- Print how many transcripts were created: `Transcribed N video file(s) -> treating as docs`
|
||||
- If transcription fails for a file, print a warning and continue with the rest
|
||||
|
||||
**Whisper model:** Default is `base`. If the user passed `--whisper-model <name>`, set `GRAPHIFY_WHISPER_MODEL=<name>` in the environment before running the command above.
|
||||
@@ -0,0 +1,179 @@
|
||||
# graphify reference: incremental update and cluster-only
|
||||
|
||||
Load this only when the user passed `--update` or `--cluster-only`. A first-time full build never reads this file.
|
||||
|
||||
## For --update (incremental re-extraction)
|
||||
|
||||
Use when you've added or modified files since the last run. Only re-extracts changed files - saves tokens and time.
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import sys, json
|
||||
from graphify.detect import detect_incremental, save_manifest
|
||||
from pathlib import Path
|
||||
|
||||
result = detect_incremental(Path('INPUT_PATH'))
|
||||
new_total = result.get('new_total', 0)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
Path('graphify-out/.graphify_incremental.json').write_text(json.dumps(result, ensure_ascii=False), encoding=\"utf-8\")
|
||||
deleted = list(result.get('deleted_files', []))
|
||||
if new_total == 0 and not deleted:
|
||||
print('No files changed since last run. Nothing to update.')
|
||||
raise SystemExit(0)
|
||||
if deleted:
|
||||
print(f'{len(deleted)} deleted file(s) to prune.')
|
||||
if new_total > 0:
|
||||
print(f'{new_total} new/changed file(s) to re-extract.')
|
||||
"
|
||||
```
|
||||
|
||||
Then populate `.graphify_detect.json` so Steps 3A–6 (which read it unconditionally) see the right state for an incremental run. `files` carries the changed subset (drives Step 3A AST + Step 3B0 cache check on only what changed); `all_files` carries the full corpus for any step that needs corpus-wide context:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
r = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
Path('graphify-out/.graphify_detect.json').write_text(json.dumps({
|
||||
'files': r.get('new_files', {}),
|
||||
'all_files': r.get('files', {}),
|
||||
'total_files': r.get('new_total', 0),
|
||||
'total_words': r.get('total_words', 0),
|
||||
'skipped_sensitive': r.get('skipped_sensitive', []),
|
||||
'needs_graph': True,
|
||||
}, ensure_ascii=False), encoding=\"utf-8\")
|
||||
"
|
||||
```
|
||||
|
||||
If new files exist, first check whether all changed files are code files:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
result = json.loads(open('graphify-out/.graphify_incremental.json', encoding='utf-8').read()) if Path('graphify-out/.graphify_incremental.json').exists() else {}
|
||||
code_exts = {'.py','.ts','.js','.go','.rs','.java','.cpp','.c','.rb','.swift','.kt','.cs','.scala','.php','.cc','.cxx','.hpp','.h','.kts','.lua','.toc','.f','.F','.f90','.F90','.f95','.F95','.f03','.F03','.f08','.F08'}
|
||||
new_files = result.get('new_files', {})
|
||||
all_changed = [f for files in new_files.values() for f in files]
|
||||
code_only = all(Path(f).suffix.lower() in code_exts for f in all_changed)
|
||||
print('code_only:', code_only)
|
||||
"
|
||||
```
|
||||
|
||||
If `code_only` is True: print `[graphify update] Code-only changes detected - skipping semantic extraction (no LLM needed)`, run only Step 3A (AST) on the changed files, skip Step 3B entirely (no subagents), then go straight to merge and Steps 4–8.
|
||||
|
||||
If `code_only` is False (any changed file is a doc/paper/image): run the full Steps 3A–3C pipeline as normal.
|
||||
|
||||
|
||||
If no new files exist (only deletions), create an empty extraction so the merge step can prune:
|
||||
|
||||
```bash
|
||||
if [ ! -f graphify-out/.graphify_extract.json ]; then
|
||||
echo '[graphify update] Only deletions -- creating empty extraction for merge.'
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps({'nodes':[],'edges':[],'hyperedges':[],'input_tokens':0,'output_tokens':0}), encoding='utf-8')
|
||||
"
|
||||
fi
|
||||
```
|
||||
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from pathlib import Path
|
||||
from graphify.build import build_merge
|
||||
from graphify.detect import save_manifest
|
||||
|
||||
# Load new extraction and incremental state
|
||||
new_extraction = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
incremental = json.loads(Path('graphify-out/.graphify_incremental.json').read_text(encoding=\"utf-8\"))
|
||||
deleted = list(incremental.get('deleted_files', []))
|
||||
# Also prune old nodes for re-extracted (changed) files before inserting fresh AST.
|
||||
# Without this, build_merge's dedup pass tries to reconcile old and new versions of
|
||||
# the same file's nodes and can collapse same-named symbols across files (#1178).
|
||||
changed = [f for files in incremental.get('new_files', {}).values() for f in files]
|
||||
prune = list(dict.fromkeys(deleted + changed)) or None
|
||||
|
||||
# Use build_merge() — reads graph.json directly without NetworkX round-trip
|
||||
# so edge direction (calls, implements, imports) is always preserved (#801).
|
||||
G = build_merge(
|
||||
[new_extraction],
|
||||
graph_path='graphify-out/graph.json',
|
||||
prune_sources=prune,
|
||||
)
|
||||
print(f'[graphify update] Merged: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges')
|
||||
|
||||
# Write merged result back to .graphify_extract.json so Step 4 sees the full graph
|
||||
merged_out = {
|
||||
'nodes': [{'id': n, **d} for n, d in G.nodes(data=True)],
|
||||
'edges': [
|
||||
# Explicit source/target last so they win over any stale attrs in d.
|
||||
{**{k: val for k, val in d.items() if k not in ('_src', '_tgt', 'source', 'target')},
|
||||
'source': d.get('_src', u), 'target': d.get('_tgt', v)}
|
||||
for u, v, d in G.edges(data=True)
|
||||
],
|
||||
# G.graph["hyperedges"] holds hyperedges from both existing graph.json
|
||||
# and new_extraction (build_merge combines them). Falling back to
|
||||
# new_extraction only would silently drop prior-run hyperedges (#801).
|
||||
'hyperedges': list(G.graph.get('hyperedges', [])),
|
||||
'input_tokens': new_extraction.get('input_tokens', 0),
|
||||
'output_tokens': new_extraction.get('output_tokens', 0),
|
||||
}
|
||||
Path('graphify-out/.graphify_extract.json').write_text(json.dumps(merged_out, ensure_ascii=False), encoding=\"utf-8\")
|
||||
print(f'[graphify update] Merged extraction written ({len(merged_out[\"nodes\"])} nodes, {len(merged_out[\"edges\"])} edges)')
|
||||
|
||||
# Save manifest so next --update diffs against today's state, not the
|
||||
# prior run's baseline (prevents ghost-node reports on subsequent updates).
|
||||
save_manifest(incremental['files'])
|
||||
print('[graphify update] Manifest saved.')
|
||||
"
|
||||
```
|
||||
|
||||
Then run Steps 4–8 on the merged graph as normal.
|
||||
|
||||
After Step 4, show the graph diff:
|
||||
|
||||
```bash
|
||||
$(cat graphify-out/.graphify_python) -c "
|
||||
import json
|
||||
from graphify.analyze import graph_diff
|
||||
from graphify.build import build_from_json
|
||||
from networkx.readwrite import json_graph
|
||||
import networkx as nx
|
||||
from pathlib import Path
|
||||
|
||||
# Load old graph (before update) from backup written before merge
|
||||
old_data = json.loads(Path('graphify-out/.graphify_old.json').read_text(encoding=\"utf-8\")) if Path('graphify-out/.graphify_old.json').exists() else None
|
||||
new_extract = json.loads(Path('graphify-out/.graphify_extract.json').read_text(encoding=\"utf-8\"))
|
||||
G_new = build_from_json(new_extract)
|
||||
|
||||
if old_data:
|
||||
G_old = json_graph.node_link_graph(old_data, edges='links')
|
||||
diff = graph_diff(G_old, G_new)
|
||||
print(diff['summary'])
|
||||
if diff['new_nodes']:
|
||||
print('New nodes:', ', '.join(n['label'] for n in diff['new_nodes'][:5]))
|
||||
if diff['new_edges']:
|
||||
print('New edges:', len(diff['new_edges']))
|
||||
"
|
||||
```
|
||||
|
||||
Before the merge step, save the old graph: `cp graphify-out/graph.json graphify-out/.graphify_old.json`
|
||||
Clean up after: `rm -f graphify-out/.graphify_old.json`
|
||||
|
||||
---
|
||||
|
||||
## For --cluster-only
|
||||
|
||||
Skip Steps 1–3. Re-run clustering on the existing graph:
|
||||
|
||||
```bash
|
||||
graphify cluster-only .
|
||||
```
|
||||
|
||||
Then run Steps 5–9 as normal (label communities, generate viz, benchmark, clean up, report).
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
name: harness-eval
|
||||
description: "评估功能的真实运行性能。从 Wiki 读取 schema 和 metric 定义,查询 SQLite 日志,对比基线,调用 LLM judge 做语义评估,产出诊断和迭代建议。触发短语: harness eval, 评估性能, 性能评估。"
|
||||
argument-hint: "[run_id]"
|
||||
---
|
||||
|
||||
# Harness Eval
|
||||
|
||||
## Overview
|
||||
|
||||
在真实环境中运行程序后,评估性能是否达标,诊断问题,决定是否迭代。
|
||||
|
||||
**核心原则:** 不是"通过就停",而是基于真实指标判断"跑得好不好"。
|
||||
|
||||
## 触发方式
|
||||
|
||||
1. **自动**:subagent-driven-development Step 10,所有任务完成 + final review 通过后
|
||||
2. **手动**:`/harness-eval` 或 `/harness-eval <run_id>`
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `research-wiki/schemas/` 中有至少一个 schema 实体
|
||||
- `research-wiki/metrics/` 中有至少一个 metric 实体
|
||||
- `results/harness.db` 存在且包含数据
|
||||
- `.env` 中有 LLM API key(用于 LlmJudge)
|
||||
|
||||
## 长时命令执行约定
|
||||
|
||||
对真实样本重跑流水线属于长时操作。超过 30s 的命令建议在 tmux 中后台运行,便于人工随时 attach 检查进度;短命令直接前台执行即可。
|
||||
|
||||
## 执行流程
|
||||
|
||||
### Phase 1: 准备
|
||||
|
||||
1. 列出 `research-wiki/schemas/` 下所有 schema 实体,获取表名和列信息
|
||||
2. 列出 `research-wiki/metrics/` 下所有 metric 实体,获取基线值和阈值
|
||||
3. 确认 `results/harness.db` 存在,查询 `_runs` 表获取最新运行状态
|
||||
4. 如果指定了 run_id,使用该 run_id;否则使用最近一次 status='completed' 的 run
|
||||
|
||||
### Phase 2: 运行
|
||||
|
||||
#### 2a: 检查已有运行记录
|
||||
|
||||
在 subagent-driven-development 流程中,某个子任务可能已经执行过全局运行并将结果写入了 `harness.db`。此时 harness-eval **不应重复运行**,直接复用已有记录即可。
|
||||
|
||||
1. 查询 `_runs` 表,查找 `status='completed'` 且 `git_sha` 与当前 `HEAD` 匹配的记录
|
||||
2. **找到匹配记录** → 使用该记录的 run_id,**跳过运行**,直接进入 Phase 3。在最终报告中注明「复用已有运行: run_id=xxx」
|
||||
3. **未找到匹配记录 + 自动触发** → 继续 2b
|
||||
4. **未找到匹配记录 + 手动触发** → 询问用户是否需要执行运行
|
||||
|
||||
#### 2b: 重跑流水线(仅无可用记录时)
|
||||
|
||||
1. `make deploy-testing` 起服务(api / worker / Postgres / Redis / 对象存储)
|
||||
2. 对真实样本重跑流水线,结果落 `results/harness.db`;属长时操作,建议按「长时命令执行约定」在 tmux 中运行
|
||||
3. 轮询检查 `_runs` 表状态,等待完成
|
||||
4. 确认 `_runs` 表中对应 run 的 status = 'completed'
|
||||
5. 如果 status = 'failed',直接进入 Phase 6 诊断
|
||||
|
||||
### Phase 3: 硬性指标检查
|
||||
|
||||
对 `research-wiki/metrics/` 中每个标记为硬性判定的指标:
|
||||
|
||||
1. 从 metric 实体中读取:指标名、基线值、阈值、对应的 schema(表名和列名)
|
||||
2. 执行 SQL 查询获取当前运行的实际值
|
||||
3. 对比:实际值 vs 阈值
|
||||
4. 记录结果:pass / fail + 具体数值
|
||||
|
||||
### Phase 4: 语义评估
|
||||
|
||||
对 `research-wiki/metrics/` 中每个标记为语义判定的指标:
|
||||
|
||||
1. 收集 evidence:相关表的数据、_events 表的事件、配置信息
|
||||
2. 调用 `LlmJudge.evaluate(EvalRequest)`(请求体封装 criteria、evidence、rubric)
|
||||
3. 记录结果:Verdict
|
||||
|
||||
### Phase 5: 综合判定
|
||||
|
||||
- 硬性指标全部 pass **且** 语义评估全部 pass → **通过**
|
||||
- 任一不通过 → **不通过**,进入 Phase 6
|
||||
|
||||
### Phase 6: 诊断与迭代决策(仅不通过时)
|
||||
|
||||
1. 收集失败信息:哪些指标不达标、实际值与期望值的差距
|
||||
2. 查询 `_events` 表获取运行过程中的关键事件
|
||||
3. 调用 `LlmJudge.diagnose(error_context, log)`
|
||||
4. 向用户报告:
|
||||
- 问题根因分析
|
||||
- 修复建议
|
||||
- 是否建议再迭代一轮
|
||||
|
||||
### Phase 7: 记录到 Wiki
|
||||
|
||||
1. 创建 finding 实体:
|
||||
```bash
|
||||
conda run -n chs python3 .claude/tools/research_wiki.py add_entity research-wiki/ --type finding --id eval-<run_id> --title "Harness 评估: <run_id>"
|
||||
```
|
||||
2. 在 finding 中记录完整评估报告(硬性指标表 + 语义评估结果 + 诊断结论)
|
||||
3. 建立 edge:
|
||||
```bash
|
||||
conda run -n chs python3 .claude/tools/research_wiki.py add_edge research-wiki/ --from "finding:eval-<run_id>" --to "metric:<id>" --type evaluates --evidence "..."
|
||||
```
|
||||
4. 如果通过且用户确认,更新 metric 实体中的基线值为当前值
|
||||
5. 重建索引
|
||||
|
||||
## 输出格式
|
||||
|
||||
评估完成后,向用户输出结构化报告:
|
||||
|
||||
| 字段 | 内容 |
|
||||
|------|------|
|
||||
| 运行信息 | run_id, git_sha, 耗时 |
|
||||
| 硬性指标表 | 指标名, 基线, 当前, 阈值, 判定 |
|
||||
| 语义评估表 | 维度, 得分, 判定, 说明 |
|
||||
| 综合判定 | 通过/不通过 |
|
||||
| 诊断(如有) | 根因, 建议 |
|
||||
|
||||
## 与 subagent-driven-development 集成
|
||||
|
||||
在 SKILL.md 的 Step 9(final whole-implementation review)之后追加 Step 10:
|
||||
|
||||
```
|
||||
Step 10: Harness 评估
|
||||
调用 /harness-eval skill
|
||||
通过 → 进入 finishing-a-development-branch
|
||||
不通过 → 根据诊断结果,回到 Step 2 迭代相关 task
|
||||
```
|
||||
|
||||
## 评估范围
|
||||
|
||||
本 skill 当前覆盖①工程评估;②提取信度/③诊断效度的全量评估待 `app/` 流水线落地后接入。
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: idea-creator
|
||||
description: "Generate and rank research ideas. GPT-5.4 + Claude dual independent brainstorming. Trigger phrases: find ideas, brainstorm ideas, generate research ideas."
|
||||
argument-hint: [research-direction]
|
||||
---
|
||||
|
||||
# Research Idea Generator
|
||||
Research direction: $ARGUMENTS
|
||||
|
||||
## Constants
|
||||
- `OUTPUT_DIR = idea-stage/`
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 0: Load Wiki Context
|
||||
If `research-wiki/query_pack.md` exists and was updated less than 7 days ago:
|
||||
- Read it
|
||||
- Treat failed ideas as a banlist
|
||||
- Treat gaps as seeds
|
||||
- Treat top papers as known prior work
|
||||
|
||||
### Phase 1: Domain Survey
|
||||
If there is no `query_pack`, do a quick WebSearch first to build a broad domain context.
|
||||
|
||||
### Phase 2: Dual Independent Brainstorming
|
||||
**Critical requirement: brainstorm independently on two tracks first, then synthesize.**
|
||||
|
||||
GPT brainstorming (via Codex):
|
||||
- Use `/codex:rescue --fresh --background`
|
||||
- The prompt must ask GPT to generate 8-12 ideas
|
||||
- The prompt must include: the domain overview, known gaps, and resource constraints
|
||||
- Poll with `/codex:status`
|
||||
- Use `/codex:result` to collect the output
|
||||
|
||||
Claude brainstorming (via Agent):
|
||||
- Dispatch a subagent through the Agent tool with `subagent_type="general-purpose"`
|
||||
- Provide the same domain overview context
|
||||
- Require it to independently generate 8-12 ideas
|
||||
|
||||
### Phase 3: Claude Synthesis
|
||||
- Merge both idea sets and deduplicate by similarity
|
||||
- Cross-check: Claude challenges GPT ideas, GPT challenges Claude ideas
|
||||
- Score and rank everything in a single pass
|
||||
- Label each idea with its source: `GPT` / `Claude` / `consensus`
|
||||
|
||||
### Phase 4: First Filtering Pass
|
||||
Filtering criteria:
|
||||
- Feasibility: compute, data, and implementation cost
|
||||
- Fast novelty check: run 2-3 WebSearch passes for each idea
|
||||
- Impact: answer "so what?"
|
||||
- Remove ideas that are infeasible or too vague
|
||||
|
||||
### Phase 5: Deep Verification
|
||||
For the top ideas, call the `/novelty-check` skill.
|
||||
|
||||
### Phase 7: Output Report
|
||||
Write `idea-stage/IDEA_REPORT.md` in Chinese, with the following format:
|
||||
|
||||
```md
|
||||
# Research Idea Report
|
||||
**Direction**: ...
|
||||
**Date**: YYYY-MM-DD
|
||||
**Evaluation Result**: X generated -> Y survived -> W recommended
|
||||
|
||||
## Domain Overview Summary
|
||||
## Recommended Ideas (Ranked)
|
||||
### Idea 1: <title>
|
||||
- Hypothesis / minimal experiment / novelty / feasibility / risk / source
|
||||
|
||||
## Rejected Ideas
|
||||
```
|
||||
|
||||
### Phase 8: Wiki Integration
|
||||
- Check whether `research-wiki/` exists
|
||||
- For each idea:
|
||||
- `.claude/tools/research_wiki.py add_entity research-wiki/ --type idea --id <id> --title "..."`
|
||||
- Add edges:
|
||||
- `inspired_by`
|
||||
- `addresses_gap`
|
||||
- Rebuild `query_pack` and the index
|
||||
|
||||
## Key Rules
|
||||
- If the direction is too broad, STOP and ask the user to narrow it
|
||||
- Failed ideas must also be written into the wiki
|
||||
- Empirical signals matter more than theoretical attractiveness
|
||||
- `"Apply X to Y"` is the lowest-level research idea
|
||||
- If `research-wiki/` does not exist, still finish the report output but skip all wiki writes
|
||||
- First independent, then synthesize; first evidence, then judgment; first filter, then dig deeper
|
||||
|
||||
## Output Requirements
|
||||
When the task is complete, you must provide at least:
|
||||
- A domain overview summary
|
||||
- 8-12 raw ideas
|
||||
- A deduplicated candidate list
|
||||
- Filtering results and reasons
|
||||
- Deep verification results for the top ideas
|
||||
- `idea-stage/IDEA_REPORT.md`
|
||||
- Wiki write results, if applicable
|
||||
|
||||
## Completion Criteria
|
||||
Only finish when all of the following are complete:
|
||||
1. Wiki context loading or domain survey is complete
|
||||
2. Both independent brainstorming tracks are complete
|
||||
3. Merge, deduplication, and ranking are complete
|
||||
4. The first filtering pass is complete
|
||||
5. Deep verification of top ideas is complete
|
||||
6. `idea-stage/IDEA_REPORT.md` has been written
|
||||
7. `research-wiki/` integration is complete, if applicable
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: novelty-check
|
||||
description: "Verify the novelty of research ideas. GPT cross-validation. Trigger phrases: novelty check, has anyone done this, check novelty."
|
||||
argument-hint: [method-or-idea-description]
|
||||
---
|
||||
|
||||
# Novelty Verification
|
||||
Verify novelty of: $ARGUMENTS
|
||||
|
||||
## Goal
|
||||
Perform a strict check on whether a method, idea, or experimental setting is actually new. The default stance is skepticism, not help-seeking for supporting evidence.
|
||||
|
||||
## Working Principles
|
||||
- Brutally honest: do not relax the standard just to make something look new.
|
||||
- `Applying X to Y` is not novel by default unless the application produces an unexpected mechanism, theoretical explanation, or clearly different experimental phenomenon.
|
||||
- Check the novelty of both the `METHOD` and the `EXPERIMENTAL SETTING`.
|
||||
- If the method itself is not new, but the findings, conclusions, experimental setup, or failure analysis are new, state that distinction explicitly.
|
||||
- Always search the last 6 months of arXiv.
|
||||
- Do not rely on titles alone; read the abstract and, when necessary, the key parts of related work, intro, method, and appendix.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase A: Extract Core Claims
|
||||
First break the user's method description into 3-5 core technical claims. Each one should be as specific as possible.
|
||||
|
||||
For each claim, answer:
|
||||
- What is the method?
|
||||
- What problem does it solve?
|
||||
- What is the key mechanism?
|
||||
- What is the essential difference from an obvious baseline?
|
||||
|
||||
Rewrite the story-like description into searchable technical propositions and avoid vague phrasing.
|
||||
|
||||
### Phase B: Multi-source Literature Search
|
||||
Run multi-source retrieval for each claim, prioritizing recent work and similar settings.
|
||||
|
||||
For each claim, try at least 3 search-query sets, and make them complementary:
|
||||
- Direct technical terms
|
||||
- Synonyms / abbreviations / related task names
|
||||
- "Problem + mechanism" combinations
|
||||
- "Method + dataset / setting" combinations
|
||||
|
||||
#### Required Search Channels
|
||||
1. WebSearch: arXiv / Google Scholar / Semantic Scholar / conference homepages
|
||||
2. `python3 .claude/tools/arxiv_fetch.py search "QUERY" --max 10`
|
||||
3. `python3 .claude/tools/semantic_scholar_fetch.py search "QUERY" --max 10`
|
||||
4. `python3 .claude/tools/exa_search.py search "QUERY" --max 10` (if available)
|
||||
5. `python3 .claude/tools/openalex_fetch.py search "QUERY" --max 10` (if available)
|
||||
|
||||
#### Search Priorities
|
||||
- ICLR / NeurIPS / ICML 2025-2026
|
||||
- arXiv preprints from the last 6 months
|
||||
- Papers close to the method mechanism, not only papers on the same task
|
||||
- Papers close to the experimental setting, not only papers using the same method
|
||||
|
||||
#### Decision Strategy
|
||||
- Record potentially overlapping papers first; do not exclude them too early
|
||||
- Prefer reading the abstract, intro, related work, and method sections
|
||||
- If overlap looks suspicious, also read the experimental setup and appendix
|
||||
|
||||
#### Recording Requirements
|
||||
For each candidate paper, record:
|
||||
- Title
|
||||
- Year
|
||||
- Venue / status
|
||||
- Relevant point
|
||||
- The specific reason it may overlap
|
||||
- Why it might still be a different work
|
||||
|
||||
If a data source is unavailable, explicitly record the fallback reason and continue with the others; do not stop the task.
|
||||
|
||||
### Phase C: GPT Cross-Validation
|
||||
Send the method description from Phase A and all candidate papers found in Phase B to `/codex:rescue --fresh --wait` for a second review.
|
||||
|
||||
The cross-validation prompt must include:
|
||||
- proposed method description
|
||||
- the full candidate paper list
|
||||
- ask:
|
||||
- `Is this method novel?`
|
||||
- `What is the closest prior work?`
|
||||
- `What is the delta?`
|
||||
|
||||
Use high reasoning effort.
|
||||
|
||||
The goal of cross-validation is not to find even more papers. It is to force out the closest prior art, the smallest difference, and the risk of pseudo-novelty.
|
||||
|
||||
### Phase D: Output Report + Wiki Integration
|
||||
The output must be in English and follow a fixed structure.
|
||||
|
||||
#### Report Format
|
||||
```markdown
|
||||
## Novelty Check Report
|
||||
### Method Under Review
|
||||
### Core Claims
|
||||
- Claim 1: ... (novelty: high / medium / low; closest paper: ...)
|
||||
- Claim 2: ... (novelty: high / medium / low; closest paper: ...)
|
||||
|
||||
### Recent Prior Work
|
||||
| Paper | Year | Venue / Status | Overlap Point | Key Difference |
|
||||
|---|---:|---|---|---|
|
||||
|
||||
### Overall Assessment
|
||||
- score X/10
|
||||
- recommendation: continue / continue cautiously / abandon
|
||||
- key differentiator: ...
|
||||
- positioning advice: ...
|
||||
```
|
||||
|
||||
#### Evaluation Scale
|
||||
- `high`: current search shows no close prior art, and the difference is concrete and technical
|
||||
- `medium`: there is related prior work, but there is still a clear and defensible technical delta
|
||||
- `low`: mostly a reorganization of known methods, task switching, dataset switching, hyperparameter changes, or standard engineering changes
|
||||
|
||||
#### Wiki Integration
|
||||
If the project has `research-wiki/`, also ingest the knowledge there:
|
||||
- Create a `claim` entity for each core claim
|
||||
- Create a `paper` entity for each newly found paper
|
||||
- Add claim-paper / paper-paper relation edges
|
||||
- Rebuild `query_pack`
|
||||
|
||||
Prefer existing tools such as `.claude/tools/research_wiki.py`; if the wiki does not exist, skip silently and do not error.
|
||||
|
||||
Ingestion rules:
|
||||
- Only write high-confidence information
|
||||
- Claim names should be short, stable, and reusable
|
||||
- Edges must include evidence; do not create empty links
|
||||
|
||||
## Completion Criteria
|
||||
Only finish when all of the following are complete:
|
||||
1. 3-5 core claims have been extracted
|
||||
2. Multi-source search has been completed, including the last 6 months of arXiv
|
||||
3. Candidate paper abstracts have been read, and related work / method sections were read when necessary
|
||||
4. GPT cross-validation has been completed
|
||||
5. A structured English report has been produced
|
||||
6. If `research-wiki/` exists, the corresponding writes and `query_pack` rebuild have been completed
|
||||
|
||||
## Failure Handling
|
||||
- Missing tools: record the missing item and degrade gracefully
|
||||
- Too few search results: expand synonyms, abbreviations, higher-level terms, and experimental settings
|
||||
- Too many search results: prefer the most recent, most similar, and most likely overlapping work
|
||||
- Conflicting evidence: read the original abstract and method sections first; do not rely on intuition
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
name: receiving-code-review
|
||||
description: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
|
||||
---
|
||||
|
||||
# Code Review Reception
|
||||
|
||||
## Overview
|
||||
|
||||
Code review requires technical evaluation, not emotional performance.
|
||||
|
||||
**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort.
|
||||
|
||||
## The Response Pattern
|
||||
|
||||
```
|
||||
WHEN receiving code review feedback:
|
||||
|
||||
1. READ: Complete feedback without reacting
|
||||
2. UNDERSTAND: Restate requirement in own words (or ask)
|
||||
3. VERIFY: Check against codebase reality
|
||||
4. EVALUATE: Technically sound for THIS codebase?
|
||||
5. RESPOND: Technical acknowledgment or reasoned pushback
|
||||
6. IMPLEMENT: One item at a time, test each
|
||||
```
|
||||
|
||||
## Forbidden Responses
|
||||
|
||||
**NEVER:**
|
||||
- "You're absolutely right!" (explicit CLAUDE.md violation)
|
||||
- "Great point!" / "Excellent feedback!" (performative)
|
||||
- "Let me implement that now" (before verification)
|
||||
|
||||
**INSTEAD:**
|
||||
- Restate the technical requirement
|
||||
- Ask clarifying questions
|
||||
- Push back with technical reasoning if wrong
|
||||
- Just start working (actions > words)
|
||||
|
||||
## Handling Unclear Feedback
|
||||
|
||||
```
|
||||
IF any item is unclear:
|
||||
STOP - do not implement anything yet
|
||||
ASK for clarification on unclear items
|
||||
|
||||
WHY: Items may be related. Partial understanding = wrong implementation.
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
your human partner: "Fix 1-6"
|
||||
You understand 1,2,3,6. Unclear on 4,5.
|
||||
|
||||
❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later
|
||||
✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding."
|
||||
```
|
||||
|
||||
## Source-Specific Handling
|
||||
|
||||
### From your human partner
|
||||
- **Trusted** - implement after understanding
|
||||
- **Still ask** if scope unclear
|
||||
- **No performative agreement**
|
||||
- **Skip to action** or technical acknowledgment
|
||||
|
||||
### From External Reviewers
|
||||
```
|
||||
BEFORE implementing:
|
||||
1. Check: Technically correct for THIS codebase?
|
||||
2. Check: Breaks existing functionality?
|
||||
3. Check: Reason for current implementation?
|
||||
4. Check: Works on all platforms/versions?
|
||||
5. Check: Does reviewer understand full context?
|
||||
|
||||
IF suggestion seems wrong:
|
||||
Push back with technical reasoning
|
||||
|
||||
IF can't easily verify:
|
||||
Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?"
|
||||
|
||||
IF conflicts with your human partner's prior decisions:
|
||||
Stop and discuss with your human partner first
|
||||
```
|
||||
|
||||
**your human partner's rule:** "External feedback - be skeptical, but check carefully"
|
||||
|
||||
## YAGNI Check for "Professional" Features
|
||||
|
||||
```
|
||||
IF reviewer suggests "implementing properly":
|
||||
grep codebase for actual usage
|
||||
|
||||
IF unused: "This endpoint isn't called. Remove it (YAGNI)?"
|
||||
IF used: Then implement properly
|
||||
```
|
||||
|
||||
**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it."
|
||||
|
||||
## Implementation Order
|
||||
|
||||
```
|
||||
FOR multi-item feedback:
|
||||
1. Clarify anything unclear FIRST
|
||||
2. Then implement in this order:
|
||||
- Blocking issues (breaks, security)
|
||||
- Simple fixes (typos, imports)
|
||||
- Complex fixes (refactoring, logic)
|
||||
3. Test each fix individually
|
||||
4. Verify no regressions
|
||||
```
|
||||
|
||||
## When To Push Back
|
||||
|
||||
Push back when:
|
||||
- Suggestion breaks existing functionality
|
||||
- Reviewer lacks full context
|
||||
- Violates YAGNI (unused feature)
|
||||
- Technically incorrect for this stack
|
||||
- Legacy/compatibility reasons exist
|
||||
- Conflicts with your human partner's architectural decisions
|
||||
|
||||
**How to push back:**
|
||||
- Use technical reasoning, not defensiveness
|
||||
- Ask specific questions
|
||||
- Reference working tests/code
|
||||
- Involve your human partner if architectural
|
||||
|
||||
**Signal if uncomfortable pushing back out loud:** "Strange things are afoot at the Circle K"
|
||||
|
||||
## Acknowledging Correct Feedback
|
||||
|
||||
When feedback IS correct:
|
||||
```
|
||||
✅ "Fixed. [Brief description of what changed]"
|
||||
✅ "Good catch - [specific issue]. Fixed in [location]."
|
||||
✅ [Just fix it and show in the code]
|
||||
|
||||
❌ "You're absolutely right!"
|
||||
❌ "Great point!"
|
||||
❌ "Thanks for catching that!"
|
||||
❌ "Thanks for [anything]"
|
||||
❌ ANY gratitude expression
|
||||
```
|
||||
|
||||
**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback.
|
||||
|
||||
**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead.
|
||||
|
||||
## Gracefully Correcting Your Pushback
|
||||
|
||||
If you pushed back and were wrong:
|
||||
```
|
||||
✅ "You were right - I checked [X] and it does [Y]. Implementing now."
|
||||
✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing."
|
||||
|
||||
❌ Long apology
|
||||
❌ Defending why you pushed back
|
||||
❌ Over-explaining
|
||||
```
|
||||
|
||||
State the correction factually and move on.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---------|-----|
|
||||
| Performative agreement | State requirement or just act |
|
||||
| Blind implementation | Verify against codebase first |
|
||||
| Batch without testing | One at a time, test each |
|
||||
| Assuming reviewer is right | Check if breaks things |
|
||||
| Avoiding pushback | Technical correctness > comfort |
|
||||
| Partial implementation | Clarify all items first |
|
||||
| Can't verify, proceed anyway | State limitation, ask for direction |
|
||||
|
||||
## Real Examples
|
||||
|
||||
**Performative Agreement (Bad):**
|
||||
```
|
||||
Reviewer: "Remove legacy code"
|
||||
❌ "You're absolutely right! Let me remove that..."
|
||||
```
|
||||
|
||||
**Technical Verification (Good):**
|
||||
```
|
||||
Reviewer: "Remove legacy code"
|
||||
✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?"
|
||||
```
|
||||
|
||||
**YAGNI (Good):**
|
||||
```
|
||||
Reviewer: "Implement proper metrics tracking with database, date filters, CSV export"
|
||||
✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?"
|
||||
```
|
||||
|
||||
**Unclear Item (Good):**
|
||||
```
|
||||
your human partner: "Fix items 1-6"
|
||||
You understand 1,2,3,6. Unclear on 4,5.
|
||||
✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing."
|
||||
```
|
||||
|
||||
## GitHub Thread Replies
|
||||
|
||||
When replying to inline review comments on GitHub, reply in the comment thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level PR comment.
|
||||
|
||||
## The Bottom Line
|
||||
|
||||
**External feedback = suggestions to evaluate, not orders to follow.**
|
||||
|
||||
Verify. Question. Then implement.
|
||||
|
||||
No performative agreement. Technical rigor always.
|
||||
|
||||
## Wiki Integration
|
||||
|
||||
**Precondition**: `research-wiki/` directory exists (skip this section entirely if it does not).
|
||||
|
||||
**Trigger**: When review feedback is received and you decide whether to accept or reject it.
|
||||
|
||||
**Type**: `review`
|
||||
|
||||
**Severity**: `approved` / `needs_changes` / `rejected`
|
||||
|
||||
**Steps**:
|
||||
1. Run `.claude/tools/research_wiki.py add_entity research-wiki/ --type review --id <slug> --title "<review response title>"` to create the review entity
|
||||
2. Append the accepted or rejected suggestions, the reason, and the follow-up actions to the generated page
|
||||
3. If the response changes a design or plan, run `.claude/tools/research_wiki.py add_edge research-wiki/ --from "review:<id>" --to "<target-type>:<id>" --type informs --evidence "..."`
|
||||
4. Run `.claude/tools/research_wiki.py rebuild_index research-wiki/`
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: requesting-code-review
|
||||
description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
|
||||
---
|
||||
|
||||
# Requesting Code Review
|
||||
|
||||
Dispatch Codex as a read-only reviewer to catch issues before they cascade. The reviewer gets precisely crafted context for evaluation — never your session's history. This keeps the reviewer focused on the work product, not your thought process, and preserves your own context for continued work.
|
||||
|
||||
**Core principle:** Review early, review often.
|
||||
|
||||
## When to Request Review
|
||||
|
||||
**Mandatory:**
|
||||
- After each task in subagent-driven development
|
||||
- After completing major feature
|
||||
- Before merge to main
|
||||
|
||||
**Optional but valuable:**
|
||||
- When stuck (fresh perspective)
|
||||
- Before refactoring (baseline check)
|
||||
- After fixing complex bug
|
||||
|
||||
## How to Request
|
||||
|
||||
**1. Get git SHAs:**
|
||||
```bash
|
||||
BASE_SHA=$(git rev-parse HEAD~1) # or origin/main
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
```
|
||||
|
||||
**2. Dispatch Codex as reviewer:**
|
||||
|
||||
用 `/codex:rescue --fresh --wait` 把 `code-reviewer.md` 模板(含 `{BASE_SHA}`/`{HEAD_SHA}` 占位与 `git diff` 指令)交 Codex 只读审查。
|
||||
|
||||
**Placeholders:**
|
||||
- `{DESCRIPTION}` - Brief summary of what you built
|
||||
- `{PLAN_OR_REQUIREMENTS}` - What it should do
|
||||
- `{BASE_SHA}` - Starting commit
|
||||
- `{HEAD_SHA}` - Ending commit
|
||||
|
||||
**3. Act on feedback:**
|
||||
- Fix Critical issues immediately
|
||||
- Fix Important issues before proceeding
|
||||
- Note Minor issues for later
|
||||
- Push back if reviewer is wrong (with reasoning)
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
[Just completed Task 2: Add verification function]
|
||||
|
||||
You: Let me request code review before proceeding.
|
||||
|
||||
BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}')
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
[Dispatch Codex as reviewer via /codex:rescue --fresh --wait]
|
||||
DESCRIPTION: Added verifyIndex() and repairIndex() with 4 issue types
|
||||
PLAN_OR_REQUIREMENTS: Task 2 from research-wiki/plans/deployment-plan.md
|
||||
BASE_SHA: a7981ec
|
||||
HEAD_SHA: 3df7661
|
||||
|
||||
[Codex returns]:
|
||||
Strengths: Clean architecture, real tests
|
||||
Issues:
|
||||
Important: Missing progress indicators
|
||||
Minor: Magic number (100) for reporting interval
|
||||
Assessment: Ready to proceed
|
||||
|
||||
You: [Fix progress indicators]
|
||||
[Continue to Task 3]
|
||||
```
|
||||
|
||||
## Integration with Workflows
|
||||
|
||||
**Subagent-Driven Development:**
|
||||
- Review after EACH task
|
||||
- Catch issues before they compound
|
||||
- Fix before moving to next task
|
||||
|
||||
**Executing Plans:**
|
||||
- Review after each task or at natural checkpoints
|
||||
- Get feedback, apply, continue
|
||||
|
||||
**Ad-Hoc Development:**
|
||||
- Review before merge
|
||||
- Review when stuck
|
||||
|
||||
## Red Flags
|
||||
|
||||
**Never:**
|
||||
- Skip review because "it's simple"
|
||||
- Ignore Critical issues
|
||||
- Proceed with unfixed Important issues
|
||||
- Argue with valid technical feedback
|
||||
|
||||
**If reviewer wrong:**
|
||||
- Push back with technical reasoning
|
||||
- Show code/tests that prove it works
|
||||
- Request clarification
|
||||
|
||||
See template at: requesting-code-review/code-reviewer.md
|
||||
|
||||
## Wiki Integration
|
||||
|
||||
**Precondition**: `research-wiki/` directory exists (skip this section entirely if it does not).
|
||||
|
||||
**Trigger**: When requesting a code review or when a clear review result has been received.
|
||||
|
||||
**Type**: `review`
|
||||
|
||||
**Severity**: `approved` / `needs_changes` / `rejected`
|
||||
|
||||
**Steps**:
|
||||
1. Run `.claude/tools/research_wiki.py add_entity research-wiki/ --type review --id <slug> --title "<review title>"` to create the review entity
|
||||
2. Append the review conclusion, key issues, recommendations, and whether it passed to the generated page
|
||||
3. If the review recommendations change a design or plan, run `.claude/tools/research_wiki.py add_edge research-wiki/ --from "review:<id>" --to "<target-type>:<id>" --type informs --evidence "..."`
|
||||
4. Run `.claude/tools/research_wiki.py rebuild_index research-wiki/`
|
||||
@@ -0,0 +1,175 @@
|
||||
# Code Reviewer Prompt Template
|
||||
|
||||
Use this template when dispatching Codex as reviewer.
|
||||
|
||||
**Purpose:** Review completed work against requirements and code quality standards before it cascades into more work.
|
||||
|
||||
```
|
||||
/codex:rescue --fresh --wait (read-only review):
|
||||
description: "Review code changes"
|
||||
prompt: |
|
||||
You are a Senior Code Reviewer with expertise in software architecture,
|
||||
design patterns, and best practices. Your job is to review completed work
|
||||
against its plan or requirements and identify issues before they cascade.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
{DESCRIPTION}
|
||||
|
||||
## Requirements / Plan
|
||||
|
||||
{PLAN_OR_REQUIREMENTS}
|
||||
|
||||
## Git Range to Review
|
||||
|
||||
**Base:** {BASE_SHA}
|
||||
**Head:** {HEAD_SHA}
|
||||
|
||||
```bash
|
||||
git diff --stat {BASE_SHA}..{HEAD_SHA}
|
||||
git diff {BASE_SHA}..{HEAD_SHA}
|
||||
```
|
||||
|
||||
## What to Check
|
||||
|
||||
**Plan alignment:**
|
||||
- Does the implementation match the plan / requirements?
|
||||
- Are deviations justified improvements, or problematic departures?
|
||||
- Is all planned functionality present?
|
||||
|
||||
**Code quality:**
|
||||
- Clean separation of concerns?
|
||||
- Proper error handling?
|
||||
- Type safety where applicable?
|
||||
- DRY without premature abstraction?
|
||||
- Edge cases handled?
|
||||
|
||||
**Architecture:**
|
||||
- Sound design decisions?
|
||||
- Reasonable scalability and performance?
|
||||
- Security concerns?
|
||||
- Integrates cleanly with surrounding code?
|
||||
|
||||
**Testing:**
|
||||
- Tests verify real behavior, not mocks?
|
||||
- Edge cases covered?
|
||||
- Integration tests where they matter?
|
||||
- All tests passing?
|
||||
|
||||
**Production readiness:**
|
||||
- Migration strategy if schema changed?
|
||||
- Backward compatibility considered?
|
||||
- Documentation complete?
|
||||
- No obvious bugs?
|
||||
|
||||
**Runtime data & logging:**
|
||||
- Are SqliteRunStore calls present at all critical code paths?
|
||||
- Do log tables capture enough data for post-run diagnosis?
|
||||
- Query Wiki schemas/ to understand which tables this code affects
|
||||
- Query SQLite to check if metrics regressed after these changes
|
||||
- Are log_event calls placed at error boundaries and state transitions?
|
||||
|
||||
## Calibration
|
||||
|
||||
Categorize issues by actual severity. Not everything is Critical.
|
||||
Acknowledge what was done well before listing issues — accurate praise
|
||||
helps the implementer trust the rest of the feedback.
|
||||
|
||||
If you find significant deviations from the plan, flag them specifically
|
||||
so the implementer can confirm whether the deviation was intentional.
|
||||
If you find issues with the plan itself rather than the implementation,
|
||||
say so.
|
||||
|
||||
## Output Format
|
||||
|
||||
### Strengths
|
||||
[What's well done? Be specific.]
|
||||
|
||||
### Issues
|
||||
|
||||
#### Critical (Must Fix)
|
||||
[Bugs, security issues, data loss risks, broken functionality]
|
||||
|
||||
#### Important (Should Fix)
|
||||
[Architecture problems, missing features, poor error handling, test gaps]
|
||||
|
||||
#### Minor (Nice to Have)
|
||||
[Code style, optimization opportunities, documentation polish]
|
||||
|
||||
For each issue:
|
||||
- File:line reference
|
||||
- What's wrong
|
||||
- Why it matters
|
||||
- How to fix (if not obvious)
|
||||
|
||||
### Recommendations
|
||||
[Improvements for code quality, architecture, or process]
|
||||
|
||||
### Assessment
|
||||
|
||||
**Ready to merge?** [Yes | No | With fixes]
|
||||
|
||||
**Reasoning:** [1-2 sentence technical assessment]
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**DO:**
|
||||
- Categorize by actual severity
|
||||
- Be specific (file:line, not vague)
|
||||
- Explain WHY each issue matters
|
||||
- Acknowledge strengths
|
||||
- Give a clear verdict
|
||||
|
||||
**DON'T:**
|
||||
- Say "looks good" without checking
|
||||
- Mark nitpicks as Critical
|
||||
- Give feedback on code you didn't actually read
|
||||
- Be vague ("improve error handling")
|
||||
- Avoid giving a clear verdict
|
||||
```
|
||||
|
||||
**Placeholders:**
|
||||
- `{DESCRIPTION}` — brief summary of what was built
|
||||
- `{PLAN_OR_REQUIREMENTS}` — what it should do (plan file path, task text, or requirements)
|
||||
- `{BASE_SHA}` — starting commit
|
||||
- `{HEAD_SHA}` — ending commit
|
||||
|
||||
**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
### Strengths
|
||||
- Clean database schema with proper migrations (db.ts:15-42)
|
||||
- Comprehensive test coverage (18 tests, all edge cases)
|
||||
- Good error handling with fallbacks (summarizer.ts:85-92)
|
||||
|
||||
### Issues
|
||||
|
||||
#### Important
|
||||
1. **Missing help text in CLI wrapper**
|
||||
- File: index-conversations:1-31
|
||||
- Issue: No --help flag, users won't discover --concurrency
|
||||
- Fix: Add --help case with usage examples
|
||||
|
||||
2. **Date validation missing**
|
||||
- File: search.ts:25-27
|
||||
- Issue: Invalid dates silently return no results
|
||||
- Fix: Validate ISO format, throw error with example
|
||||
|
||||
#### Minor
|
||||
1. **Progress indicators**
|
||||
- File: indexer.ts:130
|
||||
- Issue: No "X of Y" counter for long operations
|
||||
- Impact: Users don't know how long to wait
|
||||
|
||||
### Recommendations
|
||||
- Add progress reporting for user experience
|
||||
- Consider config file for excluded projects (portability)
|
||||
|
||||
### Assessment
|
||||
|
||||
**Ready to merge: With fixes**
|
||||
|
||||
**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality.
|
||||
```
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: research-lit
|
||||
description: "Search and analyze research papers, literature reviews, and related work. Trigger phrases: find papers, literature review, related work, literature review."
|
||||
argument-hint: [research-topic]
|
||||
---
|
||||
|
||||
# Literature Review
|
||||
Research topic: $ARGUMENTS
|
||||
|
||||
## Constants
|
||||
- `PAPER_LIBRARY = references/` (local PDF directory)
|
||||
- `MAX_LOCAL_PAPERS = 20`
|
||||
|
||||
## Data Sources (all enabled by default)
|
||||
|
||||
| Data source | How to determine availability | What it provides | Fallback behavior |
|
||||
|---|---|---|---|
|
||||
| Local PDF | `references/` exists and `references/**/*.pdf` is non-empty | Papers, reports, appendices, and drafts collected in the repo | Read only locally; if there are no PDFs, continue with online search |
|
||||
| Web search | Network is available and general search results are accessible | Google Scholar / paper pages / arXiv / conference homepages / survey blogs | If search fails, skip that source and keep results from others |
|
||||
| arXiv API | `python3 .claude/tools/arxiv_fetch.py` is runnable and the network is available | arXiv metadata, abstracts, IDs, categories, versions | Skip if unavailable; prefer arXiv records from other sources |
|
||||
| Semantic Scholar | `python3 .claude/tools/semantic_scholar_fetch.py` is runnable and the network is available | Paper metadata, venue, citation, author, and citation relationships | Skip if unavailable; if an arXiv paper has S2 venue metadata, prefer S2 |
|
||||
| Exa | `python3 .claude/tools/exa_search.py` is runnable, the network is available, and API config exists | Semantic search, page highlight excerpts, related paper page clues | Skip if unavailable; use results only as supplemental clues |
|
||||
| OpenAlex | `python3 .claude/tools/openalex_fetch.py` is runnable and the network is available | Open scholarly graph, DOI, venue, year, and citation relationships | Skip if unavailable; use it to fill in DOI / venue / author information |
|
||||
| DeepXiv | `python3 .claude/tools/deepxiv_fetch.py` is runnable and the network is available | Semantic search and paper aggregation results focused on arXiv | Skip if unavailable; cross-check against arXiv / S2 |
|
||||
|
||||
### Coverage Control
|
||||
All data sources are enabled by default. If the user includes the instruction:
|
||||
|
||||
`— sources: <list>`
|
||||
|
||||
then only the sources listed in `<list>` are used. Parsing rules:
|
||||
|
||||
- `<list>` is comma-separated and may use Chinese or English names, such as `local PDF, arXiv, Semantic Scholar`
|
||||
- Keep only recognizable names; ignore unknown items
|
||||
- If parsing yields nothing, fall back to the default of enabling everything
|
||||
- Either local-only reading or online-only searching is allowed; do not stop because some sources are unavailable
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 0: Scan local PDFs
|
||||
|
||||
Scan the local library first, then decide where to focus online search.
|
||||
|
||||
1. Glob: `references/**/*.pdf`
|
||||
2. Filter by relevance to the research topic, prioritizing PDFs whose title, abstract, first chapter, or conclusion matches the topic
|
||||
3. For up to `MAX_LOCAL_PAPERS` relevant PDFs, read the first 3 pages
|
||||
4. Record the title, authors, year, venue, method keywords, and relevance for each paper
|
||||
|
||||
Recommended reading command:
|
||||
|
||||
```bash
|
||||
python3 tools/read_pdf_pages.py references/path/to/paper.pdf --pages 1-3
|
||||
```
|
||||
|
||||
If the repository does not include that script, use any available PDF-reading tool or existing command. The rule is to read only the first 3 pages and avoid blind full-document reading.
|
||||
|
||||
### Step 1: Online search
|
||||
|
||||
Run retrieval for each enabled data source. Commands should be structured as closely as possible to the following forms. `QUERY` should be replaced with a search string built around the research topic, including the task noun, core method, aliases, synonyms, and common abbreviations.
|
||||
|
||||
- `python3 .claude/tools/arxiv_fetch.py search "QUERY" --max 10`
|
||||
- `python3 .claude/tools/semantic_scholar_fetch.py search "QUERY" --max 10`
|
||||
- `python3 .claude/tools/exa_search.py search "QUERY" --max 10 --category "research paper" --content highlights`
|
||||
- `python3 .claude/tools/openalex_fetch.py search "QUERY" --max 10 --year "2022-"`
|
||||
- `python3 .claude/tools/deepxiv_fetch.py search "QUERY" --max 10`
|
||||
- WebSearch for Google Scholar / the general web
|
||||
|
||||
Search strategy:
|
||||
|
||||
1. Start with broad queries to identify the main direction
|
||||
2. Use narrower queries to find recent work, SOTA, benchmarks, surveys, and ablations from the last two years
|
||||
3. Add controversy points, failure cases, negative results, and competing methods
|
||||
4. Record raw results from each source; do not drop borderline candidates too early
|
||||
|
||||
### Step 2: Cross-source deduplication
|
||||
|
||||
Deduplicate in this order:
|
||||
|
||||
1. `arXiv ID`
|
||||
2. `DOI`
|
||||
3. normalized title
|
||||
|
||||
Rules:
|
||||
|
||||
- Normalize titles by lowercasing, removing punctuation, removing extra spaces, and removing version suffixes
|
||||
- If the same paper has different metadata across sources, keep the record with the most complete information
|
||||
- If `S2` (Semantic Scholar) provides venue, year, author, or citation metadata for an arXiv paper, prefer those fields from `S2`
|
||||
- Distinguish between preprints and formally published versions; if both correspond to the same research, record the relationship but ultimately prefer the more authoritative published version
|
||||
|
||||
### Step 3: Analyze each paper
|
||||
|
||||
For each retained paper, extract:
|
||||
|
||||
- `problem/gap`: what problem is being solved and what existing methods are missing
|
||||
- `method`: core idea, model, training / inference flow, and key tricks
|
||||
- `key results`: main experimental findings, metrics, baselines, and datasets
|
||||
- `relevance to our work`: the direct connection to the current research topic and what can be borrowed
|
||||
- `source`: which source or sources the paper came from and whether metadata conflicts exist
|
||||
|
||||
Requirements:
|
||||
|
||||
- Every paper must include author, year, and venue
|
||||
- Explicitly mark `preprint`, `conference paper`, `journal paper`, `workshop`, and similar statuses
|
||||
- If author / year / venue is uncertain, state the source of uncertainty and do not fabricate it
|
||||
|
||||
### Step 4: Synthesis
|
||||
|
||||
Cluster papers by method route or theme instead of sorting only by time.
|
||||
|
||||
The synthesis must answer:
|
||||
|
||||
- Which method routes have become the consensus
|
||||
- Where the important disagreements are
|
||||
- Which conclusions hold only for specific datasets or settings
|
||||
- What gaps remain unsolved
|
||||
- Which results matter most for our research, and why
|
||||
|
||||
Prioritize:
|
||||
|
||||
- Work from the last 2 years
|
||||
- Representative methods and accepted baselines for the direction
|
||||
- Ablations, diagnostics, and failure analyses that explain performance differences
|
||||
|
||||
If the topic is foundational or classical, trace back to the original work, but keep the newest work as the main line.
|
||||
|
||||
### Step 5: Output
|
||||
|
||||
The final output must include both:
|
||||
|
||||
1. A structured literature table
|
||||
2. A 3-5 paragraph narrative summary
|
||||
|
||||
Suggested table columns:
|
||||
|
||||
- Paper
|
||||
- Authors / Year / Venue
|
||||
- Source
|
||||
- Problem / Gap
|
||||
- Method
|
||||
- Key Results
|
||||
- Relevance to Our Work
|
||||
|
||||
The narrative should:
|
||||
|
||||
- Summarize the sub-branches of the topic first, then the consensus and disagreements
|
||||
- Clearly call out the 3-5 papers worth following next
|
||||
- Clearly identify 1-3 gaps that can become future research entry points
|
||||
|
||||
### Step 6: Wiki Integration
|
||||
|
||||
First check whether `research-wiki/` exists; if it does not, skip all writes without error.
|
||||
|
||||
If it does exist, then:
|
||||
|
||||
1. Ingest the top 8-12 papers into the wiki
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py ingest_paper research-wiki/ --arxiv-id <id> [--title "..." --authors "..." --year ...]
|
||||
```
|
||||
|
||||
2. Create entities for identified gaps
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_entity research-wiki/ --type gap --id <slug> --title "..."
|
||||
```
|
||||
|
||||
3. Add paper relationship edges
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py add_edge research-wiki/ --from "paper:X" --to "paper:Y" --type extends --evidence "..."
|
||||
```
|
||||
|
||||
4. Rebuild the query pack and index
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py rebuild_query_pack research-wiki/ && .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
Integration rules:
|
||||
|
||||
- Only write papers with high confidence and highest topic relevance into the wiki
|
||||
- Gap node names should be short and stable so they can be reused later
|
||||
- Relationship edges must include evidence; do not create vague connections
|
||||
|
||||
## Key Rules
|
||||
|
||||
- Always cite papers: author, year, and venue are required
|
||||
- Distinguish peer-reviewed papers from preprints
|
||||
- Missing tools, missing APIs, or missing network access must degrade gracefully; never stop because one source fails
|
||||
- Focus on the last 2 years by default; only trace back further when the task is about foundational work
|
||||
- Do not just list papers; summarize the thread, disagreements, and gaps
|
||||
- If the query is too narrow, first expand with synonyms, abbreviations, and higher-level terms, then narrow again to a precise sub-direction
|
||||
- If there are too many results, keep representative, highly cited, newer, stronger-experiment, and closest-to-topic papers first
|
||||
- If there are too few results, broaden the query and rescan relevant PDFs in the local library
|
||||
|
||||
## Execution Standard
|
||||
|
||||
- Local first, then online
|
||||
- Deduplicate first, then analyze
|
||||
- Evidence first, then conclusions
|
||||
- Cluster first, then narrate
|
||||
- Integrate the wiki first, then finish
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
Only finish the task when all of the following are complete:
|
||||
|
||||
1. Local PDF scanning is complete
|
||||
2. At least the default-enabled data sources were covered, or fallback reasons were recorded
|
||||
3. Deduplication is complete
|
||||
4. Every core paper has been analyzed
|
||||
5. A table and narrative summary were produced
|
||||
6. If `research-wiki/` exists, the corresponding writes and rebuilds were completed
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: research-wiki
|
||||
description: "Manage the project research knowledge base. Initialization, querying, statistics, and health checks. Trigger phrases: knowledge base, research wiki, wiki query, check the knowledge base."
|
||||
argument-hint: [subcommand: init|query|stats|lint|ingest]
|
||||
---
|
||||
|
||||
# Research Wiki Management
|
||||
|
||||
Manage the project research knowledge base: $ARGUMENTS
|
||||
|
||||
## Overview
|
||||
|
||||
Research Wiki is the project's single source of truth for knowledge. All research and development knowledge produced by skills is stored here.
|
||||
|
||||
### Entity Types (12)
|
||||
|
||||
| Entity | Directory | node_id Format | Primary Source Skill |
|
||||
|---|---|---|---|
|
||||
| paper | `papers/` | `paper:<slug>` | `research-lit` |
|
||||
| idea | `ideas/` | `idea:<id>` | `idea-creator` |
|
||||
| experiment | `experiments/` | `exp:<id>` | `run-experiment` (future) |
|
||||
| claim | `claims/` | `claim:<id>` | `novelty-check` |
|
||||
| gap | `gaps/` | `gap:<id>` | `research-lit` / `idea-creator` |
|
||||
| design | `designs/` | `design:<id>` | `brainstorming` |
|
||||
| finding | `findings/` | `finding:<id>` | `systematic-debugging` / `verification-before-completion` |
|
||||
| adr | `adrs/` | `adr:<id>` | 架构决策记录 |
|
||||
| plan | `plans/` | `plan:<id>` | `writing-plans` |
|
||||
| review | `reviews/` | `review:<id>` | `requesting-code-review` / `receiving-code-review` |
|
||||
| schema | `schemas/` | `schema:<id>` | `structured-logging` |
|
||||
| metric | `metrics/` | `metric:<id>` | `structured-logging` / `harness-eval` |
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `query_pack.md` | Compressed summary |
|
||||
| `index.md` | Category index |
|
||||
| `log.md` | Audit log |
|
||||
| `graph/edges.json` | Relationship graph |
|
||||
|
||||
## Subcommands
|
||||
|
||||
### `/research-wiki init`
|
||||
|
||||
Initialize the wiki:
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py init research-wiki/
|
||||
```
|
||||
|
||||
### `/research-wiki query "<topic>"`
|
||||
|
||||
Rebuild `query_pack.md`:
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py rebuild_query_pack research-wiki/
|
||||
```
|
||||
|
||||
Then show the content to the user.
|
||||
|
||||
### `/research-wiki stats`
|
||||
|
||||
Show statistics:
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py stats research-wiki/
|
||||
```
|
||||
|
||||
### `/research-wiki lint`
|
||||
|
||||
Run health checks:
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py lint research-wiki/
|
||||
```
|
||||
|
||||
### `/research-wiki ingest "<title>" — arxiv: <id>`
|
||||
|
||||
Import a paper:
|
||||
|
||||
```bash
|
||||
.claude/tools/research_wiki.py ingest_paper research-wiki/ --arxiv-id <id>
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
- Other skills call `.claude/tools/research_wiki.py` directly; they do not go through this skill.
|
||||
- If the wiki does not exist (`research-wiki/` directory is missing), all write operations are skipped silently.
|
||||
- Failed ideas are always preserved in `query_pack`.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: structured-logging
|
||||
description: "设计结构化日志方案。在功能会产生运行时数据时,brainstorming 之后、writing-plans 之前必须调用。设计表结构、定义基线指标、注册到 Wiki。"
|
||||
argument-hint: "[功能描述]"
|
||||
---
|
||||
|
||||
# Structured Logging
|
||||
|
||||
## Overview
|
||||
|
||||
为即将开发的功能设计结构化日志方案。确定需要记录什么数据、如何记录、如何评估。
|
||||
|
||||
**核心原则:** 没有日志方案就不能开始编码。所有会产生运行时数据的功能,必须先设计日志再写实现。
|
||||
|
||||
## 触发时机
|
||||
|
||||
SOP Phase 1 中,brainstorming 产出 design 之后、writing-plans 之前。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- `research-wiki/` 目录存在
|
||||
- `core/eval/` 存在(SqliteRunStore 库)
|
||||
- brainstorming 已产出 design 实体
|
||||
|
||||
## 执行流程
|
||||
|
||||
### Phase 1: 读取现有 Schema
|
||||
|
||||
1. 列出 `research-wiki/schemas/` 下所有已有 schema 实体
|
||||
2. 如果 `results/harness.db` 存在,查询 `SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_%'` 获取实际表列表
|
||||
3. 对比 Wiki 记录与实际表结构,标记不一致之处
|
||||
|
||||
### Phase 2: 设计日志方案
|
||||
|
||||
基于 brainstorming 产出的 design,回答以下问题:
|
||||
|
||||
1. **这个功能会产生什么运行时数据?**
|
||||
- OCR 置信度(每张影像/字段的识别置信度)
|
||||
- 提取信度一致率(多次提取/多专家的一致性)
|
||||
- 阶段事件(开始/结束/错误/警告)
|
||||
- 门控结果(期望张数、完整性校验)
|
||||
- token 用量与时延(LLM 调用成本与延迟)
|
||||
|
||||
2. **需要新建表还是复用现有表?**
|
||||
- 如果现有表能覆盖 → 复用,不新建
|
||||
- 如果需要新维度 → 新建表
|
||||
|
||||
3. **每张表的 schema 是什么?**
|
||||
- 列名、类型、说明
|
||||
- 主键(如果有)
|
||||
- 哪些列用于 agent 查询诊断
|
||||
|
||||
4. **在代码哪些位置埋点?**
|
||||
- 具体函数名、具体代码节点
|
||||
- 用 `log.insert()` 还是 `log.log_event()`
|
||||
|
||||
### Phase 3: 定义评估基线
|
||||
|
||||
1. **硬性指标**:列出每个可量化的指标、阈值、判定方式
|
||||
2. **语义指标**:列出需要 LlmJudge 评估的维度
|
||||
3. **基线来源**:指明对比哪个历史 run_id(如果是首次,标记为"待首次运行后建立")
|
||||
|
||||
### Phase 4: 注册到 Wiki
|
||||
|
||||
1. 对每张新表,执行:
|
||||
```bash
|
||||
python3 .claude/tools/research_wiki.py add_entity research-wiki/ --type schema --id <table-name> --title "表结构: <table-name>"
|
||||
```
|
||||
然后在生成的 md 文件中填入完整的列定义和埋点位置。
|
||||
|
||||
2. 对每组基线指标,执行:
|
||||
```bash
|
||||
python3 .claude/tools/research_wiki.py add_entity research-wiki/ --type metric --id <metric-name> --title "<指标描述>"
|
||||
```
|
||||
然后在生成的 md 文件中填入基线值、阈值、判定方式。
|
||||
|
||||
3. 建立关联:
|
||||
```bash
|
||||
python3 .claude/tools/research_wiki.py add_edge research-wiki/ --from "metric:<id>" --to "schema:<id>" --type measures --evidence "..."
|
||||
python3 .claude/tools/research_wiki.py add_edge research-wiki/ --from "schema:<id>" --to "design:<id>" --type implements --evidence "..."
|
||||
```
|
||||
|
||||
4. 重建索引:
|
||||
```bash
|
||||
python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
### Phase 5: 产出
|
||||
|
||||
交给 writing-plans 的输入:
|
||||
- 哪些文件需要 `from core.eval.run_store import SqliteRunStore`
|
||||
- 哪些函数需要在什么位置调用 `log.insert()` / `log.log_event()`
|
||||
- 这些埋点必须作为 writing-plans 中的显式步骤,不得遗漏
|
||||
|
||||
## 产出物清单
|
||||
|
||||
| 产出 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| schema 实体 | `research-wiki/schemas/<name>.md` | 表结构 + 列说明 + 埋点位置 |
|
||||
| metric 实体 | `research-wiki/metrics/<name>.md` | 基线值 + 阈值 + 判定方式 |
|
||||
| edge 关系 | `research-wiki/graph/edges.json` | schema↔metric↔design 关联 |
|
||||
| 日志埋点清单 | 传递给 writing-plans | 具体文件和函数的埋点要求 |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Code Quality Reviewer Prompt Template
|
||||
|
||||
Use this template when running the code quality review as a read-only Codex session (`/codex:rescue --fresh --wait`). Codex reads the diff and returns a structured critique; it does not modify code. Only run this **after** spec compliance review has approved the work.
|
||||
|
||||
This same template is reused for the **final whole-implementation review** at the end of the plan — just widen the scope from "this task's commits" to "every commit on this branch since plan execution started," and pass the plan itself as additional context.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Codex review (read-only) — pass this as the /codex:rescue --fresh --wait prompt body:
|
||||
|
||||
description: "Review code quality for Task N" # or "Final whole-implementation review"
|
||||
prompt: |
|
||||
You are reviewing the code quality of work that has already passed spec compliance review. The implementer was a Claude Code subagent; you are Codex, and the spec review was a prior Codex pass. You are running read-only — read the diff and report; do not edit code.
|
||||
|
||||
## What Was Built
|
||||
|
||||
[Brief recap of the task — one paragraph. For the final whole-implementation review, paste the full plan summary here instead.]
|
||||
|
||||
## Working Directory
|
||||
|
||||
[Absolute path of the worktree.]
|
||||
|
||||
## Commits to Review
|
||||
|
||||
[Space-separated list of commit SHAs. For per-task review, just this task's SHAs. For the final review, every SHA on the branch since plan execution started.]
|
||||
|
||||
## CRITICAL: Read the Code
|
||||
|
||||
Do not rely on commit messages or the implementer's notes. Read `git show <sha>` for each commit. Read the surrounding files for context. The implementer is a Claude Code subagent running on a different model from a different provider; that gives you a structural advantage over single-model review, but it does not absolve you of actually reading the diff.
|
||||
|
||||
## Your Job
|
||||
|
||||
Evaluate the implementation against these dimensions. Spec compliance is already verified — do not re-litigate it.
|
||||
|
||||
**Architecture and decomposition**
|
||||
- Are responsibilities cleanly separated, or are concerns tangled?
|
||||
- Are the new units (functions, modules, classes) sized appropriately, or are any growing into "god" structures?
|
||||
- Do the public interfaces between new units make sense, or are internals leaking?
|
||||
|
||||
**Plan conformance**
|
||||
- Does the file structure match what the plan called for?
|
||||
- Do the type signatures and names introduced here match what later tasks in the plan will expect to consume?
|
||||
|
||||
**Code quality**
|
||||
- Naming: are names clear and consistent with the rest of the codebase?
|
||||
- Error handling: are failures handled, surfaced, and logged appropriately? Any swallowed exceptions or silent fallbacks?
|
||||
- Magic numbers / strings: are constants extracted and named?
|
||||
- Duplication: is there copy-pasted logic that should be a helper?
|
||||
- Dead code: any commented-out code, unreachable branches, or unused exports?
|
||||
- Readability: can a new contributor follow the flow without help?
|
||||
|
||||
**Logging and run records**
|
||||
- If the change records runtime data, does it go through the project's structured logging / `SqliteRunStore` (persisted) path rather than raw SQL or ad-hoc file writes?
|
||||
- Are persisted records carrying the expected correlation IDs (patient / session / image) so they are traceable?
|
||||
|
||||
**Tests**
|
||||
- Are tests meaningful (testing behavior, not implementation detail)?
|
||||
- Are edge cases covered, or is it all happy path?
|
||||
- Are there flaky patterns (timing, ordering, hidden global state)?
|
||||
- Test names: do they describe behavior, not internals?
|
||||
|
||||
**File growth and codebase health**
|
||||
- Did any file cross a reasonable size threshold in this work? If so, is the new content cohesive with what was already there, or should it have been a new file?
|
||||
- Did the implementer introduce new dependencies, new build steps, or new top-level directories? Justified?
|
||||
|
||||
## Calibration
|
||||
|
||||
Only flag issues that matter. Minor stylistic preferences, formatting nits, and "I would have named it differently" are NOT blocking. Categorize what you find:
|
||||
|
||||
- **Critical** — Will cause real bugs, security issues, data corruption, or break a downstream task in the plan. Must be fixed.
|
||||
- **Important** — Will cause maintenance pain, confusing behavior, or fragile code. Should be fixed.
|
||||
- **Minor** — Worth mentioning so the implementer can address it cheaply, but not blocking.
|
||||
|
||||
Approval requires zero Critical and zero Important issues. Minor issues are advisory.
|
||||
|
||||
## Report Format
|
||||
|
||||
Return your review in this structure:
|
||||
|
||||
```
|
||||
Verdict: <APPROVED | CHANGES_REQUESTED>
|
||||
|
||||
Strengths:
|
||||
- <bullet per genuine strength — be honest, brief, and specific. If there are no real strengths, say so.>
|
||||
|
||||
Issues:
|
||||
Critical:
|
||||
- <bullet per critical issue with file:line reference and what's wrong>
|
||||
- or "none"
|
||||
Important:
|
||||
- <bullet per important issue with file:line reference>
|
||||
- or "none"
|
||||
Minor:
|
||||
- <bullet per minor issue with file:line reference>
|
||||
- or "none"
|
||||
|
||||
Assessment:
|
||||
<One short paragraph: overall quality, whether this is solid for downstream tasks, anything cross-cutting the controller should know.>
|
||||
```
|
||||
|
||||
If Verdict is `APPROVED`, the controller will mark the task complete.
|
||||
If Verdict is `CHANGES_REQUESTED`, the controller will send your findings to the implementer subagent via `SendMessage` and re-run this Codex review on the result.
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
# Code Style Reviewer Prompt Template
|
||||
|
||||
Use this template when running the code style review as a read-only Codex session (`/codex:rescue --fresh --wait`). Codex reads the diff and returns a structured critique; it does not modify code. Only run this **after** spec compliance review has approved the work.
|
||||
|
||||
This reviewer focuses on code **form and style** — conciseness, abstraction level, naming quality, comment appropriateness. It complements (does not replace) the code-quality-reviewer which focuses on architecture and functional correctness.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Codex review (read-only) — pass this as the /codex:rescue --fresh --wait prompt body:
|
||||
|
||||
description: "Review code style for Task N"
|
||||
prompt: |
|
||||
You are reviewing the code style and form of work that has already passed spec compliance review. The implementer was a Claude Code subagent; you are Codex, running read-only. Your job is to catch over-engineering, unnecessary complexity, and style violations that automated tools cannot detect — read the diff and report; do not edit code.
|
||||
|
||||
## What Was Built
|
||||
|
||||
[Brief recap of the task — one paragraph.]
|
||||
|
||||
## Working Directory
|
||||
|
||||
[Absolute path of the worktree.]
|
||||
|
||||
## Commits to Review
|
||||
|
||||
[Space-separated list of commit SHAs.]
|
||||
|
||||
## Project Coding Principles (ranked by priority)
|
||||
|
||||
These are the project's non-negotiable coding principles. Violations are issues.
|
||||
|
||||
1. **YAGNI** — No code that isn't needed right now. No "just in case" parameters, no unused abstractions, no speculative generality.
|
||||
2. **Readability** — Domain-term naming (not implementation naming). Comments explain WHY, never WHAT. If code needs a WHAT comment, the code is unclear — refactor it.
|
||||
3. **Single Responsibility** — Each function does one thing. If you need "and" to describe it, split it.
|
||||
4. **Explicit over Implicit** — Type annotations on all public functions. No hidden global state access. No default parameters masking logic.
|
||||
5. **Defensiveness** — No bare `except:` or `except Exception: pass`. No swallowed errors. No default values masking failures.
|
||||
6. **Simplicity** — A function is better than a class. A flat structure is better than nested. Three clear lines beat one clever one-liner.
|
||||
|
||||
## Your Job
|
||||
|
||||
Read the actual code via `git show <sha>` and file reads. Evaluate against these dimensions:
|
||||
|
||||
**Over-engineering / Unnecessary Abstraction**
|
||||
- Are there classes that could be plain functions?
|
||||
- Are there inheritance hierarchies that could be composition or just data?
|
||||
- Are there factory patterns, builder patterns, or strategy patterns where a simple if/else would suffice?
|
||||
- Is there unnecessary indirection (wrapper functions that just forward to another function)?
|
||||
|
||||
**YAGNI Violations**
|
||||
- Parameters or config options that nothing currently uses?
|
||||
- "Extension points" or "plugin systems" for hypothetical future needs?
|
||||
- Generic solutions where a specific one would be simpler and sufficient?
|
||||
|
||||
**Naming and Clarity**
|
||||
- Are names domain-specific or generic-technical? (`DiagnosisEngine` good, `DataProcessorFactory` bad)
|
||||
- Can you understand what a function does from its name alone?
|
||||
- Are variable names descriptive or abbreviated?
|
||||
|
||||
**Comment Quality**
|
||||
- Missing WHY comments on non-obvious decisions?
|
||||
- Present but useless WHAT comments? (e.g., `# increment counter` above `counter += 1`)
|
||||
- Outdated comments that contradict the code?
|
||||
|
||||
**Conciseness**
|
||||
- Could any 10-line block be expressed in 3 lines without losing clarity?
|
||||
- Are there repeated patterns that should be extracted (but only if used 3+ times)?
|
||||
- Is there dead code, commented-out code, or unreachable branches?
|
||||
|
||||
**Type Annotations**
|
||||
- Do all public functions have complete parameter and return type annotations?
|
||||
- Are complex types properly aliased for readability?
|
||||
|
||||
## Calibration
|
||||
|
||||
**This is NOT a linting review.** Automated tools already checked formatting and lint rules. Focus exclusively on things that require judgment:
|
||||
- Is this the SIMPLEST way to solve this problem?
|
||||
- Would a reader understand this without context?
|
||||
- Does the abstraction level match the problem complexity?
|
||||
|
||||
Only flag issues that a competent developer would agree represent unnecessary complexity or unclear code. Style preferences that don't affect readability are NOT issues.
|
||||
|
||||
Severity levels:
|
||||
- **Important** — Genuine over-engineering, YAGNI violation, or significantly unclear code. Should be simplified.
|
||||
- **Minor** — Could be slightly simpler or clearer, but not blocking.
|
||||
|
||||
No "Critical" category here — that belongs to the functional quality reviewer.
|
||||
|
||||
## Report Format
|
||||
|
||||
```
|
||||
Verdict: <APPROVED | CHANGES_REQUESTED>
|
||||
|
||||
Over-engineering:
|
||||
- <bullet with file:line, what's over-engineered, simpler alternative>
|
||||
- or "none"
|
||||
|
||||
YAGNI violations:
|
||||
- <bullet with file:line, what's unneeded>
|
||||
- or "none"
|
||||
|
||||
Clarity issues:
|
||||
- <bullet with file:line, what's unclear and how to fix>
|
||||
- or "none"
|
||||
|
||||
Style notes (advisory):
|
||||
- <minor observations, not blocking>
|
||||
|
||||
Assessment:
|
||||
<One sentence: is this code as simple as it could be while remaining correct?>
|
||||
```
|
||||
|
||||
If Verdict is `APPROVED`, the controller will mark the task complete.
|
||||
If Verdict is `CHANGES_REQUESTED`, the controller will send your findings to the implementer subagent via `SendMessage`.
|
||||
```
|
||||
@@ -0,0 +1,103 @@
|
||||
# Spec Compliance Reviewer Prompt Template
|
||||
|
||||
Use this template when running the spec compliance review as a read-only Codex session (`/codex:rescue --fresh --wait`). Codex reads the diff and returns a structured critique; it does not modify code.
|
||||
|
||||
**Purpose:** Verify the Claude implementer subagent built what was requested — nothing more, nothing less.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
Codex review (read-only) — pass this as the /codex:rescue --fresh --wait prompt body:
|
||||
|
||||
description: "Review spec compliance for Task N"
|
||||
prompt: |
|
||||
You are reviewing whether a Claude Code implementer subagent's work matches its specification. You are Codex, running read-only — read the diff and report; do not edit code.
|
||||
|
||||
## What Was Requested
|
||||
|
||||
[FULL TEXT of task requirements — paste verbatim from the plan]
|
||||
|
||||
## What the Implementer Claims They Built
|
||||
|
||||
[Paste the implementer subagent's full final report here, including the STATUS block, COMMIT_SHAS, FILES_CHANGED, and NOTES.]
|
||||
|
||||
## Working Directory
|
||||
|
||||
[Absolute path of the worktree.]
|
||||
|
||||
## Commits to Review
|
||||
|
||||
[Space-separated list of commit SHAs from the implementer's report. Base your analysis on these specific commits, not on the entire repo history.]
|
||||
|
||||
## CRITICAL: Do Not Trust the Report
|
||||
|
||||
The implementer is a Claude Code subagent; you are Codex, a different model from a different provider. Its self-report may be incomplete, inaccurate, or optimistic. Cross-provider does not mean trustworthy — it means the report has not yet been independently verified. You MUST verify everything independently by reading the actual diff.
|
||||
|
||||
**DO NOT:**
|
||||
- Take the implementer's word for what it implemented
|
||||
- Trust its claims about completeness
|
||||
- Accept its interpretation of requirements
|
||||
|
||||
**DO:**
|
||||
- Read the actual code via `git show <sha>` and direct file reads
|
||||
- Compare actual implementation to requirements line by line
|
||||
- Check for missing pieces it claimed to implement
|
||||
- Look for extra features it didn't mention
|
||||
|
||||
## Your Job
|
||||
|
||||
Read the implementation code and verify:
|
||||
|
||||
**Missing requirements**
|
||||
- Did the implementer implement everything that was requested?
|
||||
- Are there requirements they skipped or missed?
|
||||
- Did they claim something works but not actually implement it?
|
||||
- For every acceptance criterion in the spec, can you point to the specific lines that satisfy it?
|
||||
|
||||
**Extra / unrequested work**
|
||||
- Did they build things that weren't requested?
|
||||
- Did they over-engineer or add unnecessary features?
|
||||
- Did they add "nice to haves" that weren't in the spec?
|
||||
- Are there new files, flags, or dependencies that the spec didn't ask for?
|
||||
|
||||
**Misunderstandings**
|
||||
- Did they interpret requirements differently than intended?
|
||||
- Did they solve the wrong problem?
|
||||
- Did they implement the right feature in the wrong way (e.g., correct behavior but wrong interface, wrong file location, or wrong type signatures that won't compose with later tasks)?
|
||||
|
||||
**Tests**
|
||||
- Are the tests actually testing what the spec requires, or just exercising whatever code exists?
|
||||
- Did they delete or weaken existing tests to make things pass?
|
||||
|
||||
Verify by reading code and tests, not by trusting the report.
|
||||
|
||||
## Calibration
|
||||
|
||||
Only flag issues that would cause real problems during implementation or in the next task. Minor wording, stylistic preferences, and formatting quibbles are NOT spec-compliance issues — those belong to the code quality reviewer. Stay in your lane: did they build what was asked for, or didn't they.
|
||||
|
||||
## Report Format
|
||||
|
||||
Return your review in this structure:
|
||||
|
||||
```
|
||||
Verdict: <APPROVED | CHANGES_REQUESTED>
|
||||
|
||||
Missing requirements:
|
||||
- <bullet per missing requirement, with the spec quote it violates>
|
||||
- or "none"
|
||||
|
||||
Extra/unrequested work:
|
||||
- <bullet per extraneous addition>
|
||||
- or "none"
|
||||
|
||||
Misunderstandings:
|
||||
- <bullet per misunderstanding, with what the spec said vs. what was built>
|
||||
- or "none"
|
||||
|
||||
Notes:
|
||||
- <anything else the controller should know, but not blocking>
|
||||
```
|
||||
|
||||
If Verdict is `APPROVED`, the controller will proceed to code quality review.
|
||||
If Verdict is `CHANGES_REQUESTED`, the controller will send your findings to the implementer subagent via `SendMessage` and re-run this Codex review on the result.
|
||||
```
|
||||
@@ -0,0 +1,119 @@
|
||||
# Creation Log: Systematic Debugging Skill
|
||||
|
||||
Reference example of extracting, structuring, and bulletproofing a critical skill.
|
||||
|
||||
## Source Material
|
||||
|
||||
Extracted debugging framework from `~/.claude/CLAUDE.md`:
|
||||
- 4-phase systematic process (Investigation → Pattern Analysis → Hypothesis → Implementation)
|
||||
- Core mandate: ALWAYS find root cause, NEVER fix symptoms
|
||||
- Rules designed to resist time pressure and rationalization
|
||||
|
||||
## Extraction Decisions
|
||||
|
||||
**What to include:**
|
||||
- Complete 4-phase framework with all rules
|
||||
- Anti-shortcuts ("NEVER fix symptom", "STOP and re-analyze")
|
||||
- Pressure-resistant language ("even if faster", "even if I seem in a hurry")
|
||||
- Concrete steps for each phase
|
||||
|
||||
**What to leave out:**
|
||||
- Project-specific context
|
||||
- Repetitive variations of same rule
|
||||
- Narrative explanations (condensed to principles)
|
||||
|
||||
## Structure Following skill-creation/SKILL.md
|
||||
|
||||
1. **Rich when_to_use** - Included symptoms and anti-patterns
|
||||
2. **Type: technique** - Concrete process with steps
|
||||
3. **Keywords** - "root cause", "symptom", "workaround", "debugging", "investigation"
|
||||
4. **Flowchart** - Decision point for "fix failed" → re-analyze vs add more fixes
|
||||
5. **Phase-by-phase breakdown** - Scannable checklist format
|
||||
6. **Anti-patterns section** - What NOT to do (critical for this skill)
|
||||
|
||||
## Bulletproofing Elements
|
||||
|
||||
Framework designed to resist rationalization under pressure:
|
||||
|
||||
### Language Choices
|
||||
- "ALWAYS" / "NEVER" (not "should" / "try to")
|
||||
- "even if faster" / "even if I seem in a hurry"
|
||||
- "STOP and re-analyze" (explicit pause)
|
||||
- "Don't skip past" (catches the actual behavior)
|
||||
|
||||
### Structural Defenses
|
||||
- **Phase 1 required** - Can't skip to implementation
|
||||
- **Single hypothesis rule** - Forces thinking, prevents shotgun fixes
|
||||
- **Explicit failure mode** - "IF your first fix doesn't work" with mandatory action
|
||||
- **Anti-patterns section** - Shows exactly what shortcuts look like
|
||||
|
||||
### Redundancy
|
||||
- Root cause mandate in overview + when_to_use + Phase 1 + implementation rules
|
||||
- "NEVER fix symptom" appears 4 times in different contexts
|
||||
- Each phase has explicit "don't skip" guidance
|
||||
|
||||
## Testing Approach
|
||||
|
||||
Created 4 validation tests following skills/meta/testing-skills-with-subagents:
|
||||
|
||||
### Test 1: Academic Context (No Pressure)
|
||||
- Simple bug, no time pressure
|
||||
- **Result:** Perfect compliance, complete investigation
|
||||
|
||||
### Test 2: Time Pressure + Obvious Quick Fix
|
||||
- User "in a hurry", symptom fix looks easy
|
||||
- **Result:** Resisted shortcut, followed full process, found real root cause
|
||||
|
||||
### Test 3: Complex System + Uncertainty
|
||||
- Multi-layer failure, unclear if can find root cause
|
||||
- **Result:** Systematic investigation, traced through all layers, found source
|
||||
|
||||
### Test 4: Failed First Fix
|
||||
- Hypothesis doesn't work, temptation to add more fixes
|
||||
- **Result:** Stopped, re-analyzed, formed new hypothesis (no shotgun)
|
||||
|
||||
**All tests passed.** No rationalizations found.
|
||||
|
||||
## Iterations
|
||||
|
||||
### Initial Version
|
||||
- Complete 4-phase framework
|
||||
- Anti-patterns section
|
||||
- Flowchart for "fix failed" decision
|
||||
|
||||
### Enhancement 1: TDD Reference
|
||||
- Added link to skills/testing/test-driven-development
|
||||
- Note explaining TDD's "simplest code" ≠ debugging's "root cause"
|
||||
- Prevents confusion between methodologies
|
||||
|
||||
## Final Outcome
|
||||
|
||||
Bulletproof skill that:
|
||||
- ✅ Clearly mandates root cause investigation
|
||||
- ✅ Resists time pressure rationalization
|
||||
- ✅ Provides concrete steps for each phase
|
||||
- ✅ Shows anti-patterns explicitly
|
||||
- ✅ Tested under multiple pressure scenarios
|
||||
- ✅ Clarifies relationship to TDD
|
||||
- ✅ Ready for use
|
||||
|
||||
## Key Insight
|
||||
|
||||
**Most important bulletproofing:** Anti-patterns section showing exact shortcuts that feel justified in the moment. When Claude thinks "I'll just add this one quick fix", seeing that exact pattern listed as wrong creates cognitive friction.
|
||||
|
||||
## Usage Example
|
||||
|
||||
When encountering a bug:
|
||||
1. Load skill: skills/debugging/systematic-debugging
|
||||
2. Read overview (10 sec) - reminded of mandate
|
||||
3. Follow Phase 1 checklist - forced investigation
|
||||
4. If tempted to skip - see anti-pattern, stop
|
||||
5. Complete all phases - root cause found
|
||||
|
||||
**Time investment:** 5-10 minutes
|
||||
**Time saved:** Hours of symptom-whack-a-mole
|
||||
|
||||
---
|
||||
|
||||
*Created: 2025-10-03*
|
||||
*Purpose: Reference example for skill extraction and bulletproofing*
|
||||
@@ -0,0 +1,342 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
|
||||
---
|
||||
|
||||
# Systematic Debugging
|
||||
|
||||
## Overview
|
||||
|
||||
Random fixes waste time and create new bugs. Quick patches mask underlying issues.
|
||||
|
||||
**Core principle:** ALWAYS find root cause before attempting fixes. Symptom fixes are failure.
|
||||
|
||||
**Violating the letter of this process is violating the spirit of debugging.**
|
||||
|
||||
## The Iron Law
|
||||
|
||||
```
|
||||
NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST
|
||||
```
|
||||
|
||||
If you haven't completed Phase 1, you cannot propose fixes.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use for ANY technical issue:
|
||||
- Test failures
|
||||
- Bugs in production
|
||||
- Unexpected behavior
|
||||
- Performance problems
|
||||
- Build failures
|
||||
- Integration issues
|
||||
|
||||
**Use this ESPECIALLY when:**
|
||||
- Under time pressure (emergencies make guessing tempting)
|
||||
- "Just one quick fix" seems obvious
|
||||
- You've already tried multiple fixes
|
||||
- Previous fix didn't work
|
||||
- You don't fully understand the issue
|
||||
|
||||
**Don't skip when:**
|
||||
- Issue seems simple (simple bugs have root causes too)
|
||||
- You're in a hurry (rushing guarantees rework)
|
||||
- Manager wants it fixed NOW (systematic is faster than thrashing)
|
||||
|
||||
## The Five Phases
|
||||
|
||||
You MUST complete each phase before proceeding to the next.
|
||||
|
||||
### Phase 1: Root Cause Investigation
|
||||
|
||||
**BEFORE attempting ANY fix:**
|
||||
|
||||
1. **Read Error Messages Carefully**
|
||||
- Don't skip past errors or warnings
|
||||
- They often contain the exact solution
|
||||
- Read stack traces completely
|
||||
- Note line numbers, file paths, error codes
|
||||
|
||||
2. **Reproduce Consistently**
|
||||
- Can you trigger it reliably?
|
||||
- What are the exact steps?
|
||||
- Does it happen every time?
|
||||
- If not reproducible → gather more data, don't guess
|
||||
|
||||
3. **Check Recent Changes**
|
||||
- What changed that could cause this?
|
||||
- Git diff, recent commits
|
||||
- New dependencies, config changes
|
||||
- Environmental differences
|
||||
|
||||
**代码检索(graphify)**:定位前先 `/graphify . --update` 刷新图谱,再用 graphify 追调用关系与影响面,而非盲读文件:
|
||||
- `conda run -n chs graphify path "<A>" "<B>"` 追依赖/调用链
|
||||
- `conda run -n chs graphify affected "<出问题的节点>"` 反向影响面(改它会波及谁)
|
||||
- `conda run -n chs graphify explain "<节点>"` 看某节点及邻居
|
||||
- 图谱未建(`graph file not found`)则提示人类先 `/graphify .`,未建图前降级常规阅读、不阻断。
|
||||
|
||||
4. **Gather Evidence in Multi-Component Systems**
|
||||
|
||||
**WHEN system has multiple components (CI → build → signing, API → service → database):**
|
||||
|
||||
**BEFORE proposing fixes, add diagnostic instrumentation:**
|
||||
```
|
||||
For EACH component boundary:
|
||||
- Log what data enters component
|
||||
- Log what data exits component
|
||||
- Verify environment/config propagation
|
||||
- Check state at each layer
|
||||
|
||||
Run once to gather evidence showing WHERE it breaks
|
||||
THEN analyze evidence to identify failing component
|
||||
THEN investigate that specific component
|
||||
```
|
||||
|
||||
**Example (multi-layer system):**
|
||||
```bash
|
||||
# Layer 1: Workflow
|
||||
echo "=== Secrets available in workflow: ==="
|
||||
echo "IDENTITY: ${IDENTITY:+SET}${IDENTITY:-UNSET}"
|
||||
|
||||
# Layer 2: Build script
|
||||
echo "=== Env vars in build script: ==="
|
||||
env | grep IDENTITY || echo "IDENTITY not in environment"
|
||||
|
||||
# Layer 3: Signing script
|
||||
echo "=== Keychain state: ==="
|
||||
security list-keychains
|
||||
security find-identity -v
|
||||
|
||||
# Layer 4: Actual signing
|
||||
codesign --sign "$IDENTITY" --verbose=4 "$APP"
|
||||
```
|
||||
|
||||
**This reveals:** Which layer fails (secrets → workflow ✓, workflow → build ✗)
|
||||
|
||||
5. **Trace Data Flow**
|
||||
|
||||
**WHEN error is deep in call stack:**
|
||||
|
||||
See `root-cause-tracing.md` in this directory for the complete backward tracing technique.
|
||||
|
||||
**Quick version:**
|
||||
- Where does bad value originate?
|
||||
- What called this with bad value?
|
||||
- Keep tracing up until you find the source
|
||||
- Fix at source, not at symptom
|
||||
|
||||
### Phase 2: Log Diagnosis
|
||||
|
||||
**Query SqliteRunStore for runtime evidence:**
|
||||
|
||||
1. **Read Wiki Schema Directory**
|
||||
- Check `research-wiki/schemas/` for all log tables in this project
|
||||
- Identify which tables are relevant to the current bug
|
||||
|
||||
2. **Query Event Stream**
|
||||
- Query `_events` table: what events occurred around the time of the bug
|
||||
- `SELECT * FROM _events WHERE timestamp > '<bug_time>' ORDER BY timestamp LIMIT 20`
|
||||
- Focus on event_type = error/warning events
|
||||
|
||||
3. **Query Custom Tables**
|
||||
- Query relevant metrics tables: are trends abnormal?
|
||||
- Compare data across recent runs: is there a regression?
|
||||
|
||||
4. **Output**
|
||||
- Hypotheses backed by log data (not just code-reading guesses)
|
||||
- Specific data evidence to support each hypothesis
|
||||
|
||||
### Phase 3: Pattern Analysis
|
||||
|
||||
**Find the pattern before fixing:**
|
||||
|
||||
1. **Find Working Examples**
|
||||
- Locate similar working code in same codebase
|
||||
- What works that's similar to what's broken?
|
||||
|
||||
2. **Compare Against References**
|
||||
- If implementing pattern, read reference implementation COMPLETELY
|
||||
- Don't skim - read every line
|
||||
- Understand the pattern fully before applying
|
||||
|
||||
3. **Identify Differences**
|
||||
- What's different between working and broken?
|
||||
- List every difference, however small
|
||||
- Don't assume "that can't matter"
|
||||
|
||||
4. **Understand Dependencies**
|
||||
- What other components does this need?
|
||||
- What settings, config, environment?
|
||||
- What assumptions does it make?
|
||||
|
||||
### Phase 4: Hypothesis and Testing
|
||||
|
||||
**Scientific method:**
|
||||
|
||||
1. **Form Single Hypothesis**
|
||||
- State clearly: "I think X is the root cause because Y"
|
||||
- Write it down
|
||||
- Be specific, not vague
|
||||
|
||||
2. **Test Minimally**
|
||||
- Make the SMALLEST possible change to test hypothesis
|
||||
- One variable at a time
|
||||
- Don't fix multiple things at once
|
||||
|
||||
3. **Verify Before Continuing**
|
||||
- Did it work? Yes → Phase 4
|
||||
- Didn't work? Form NEW hypothesis
|
||||
- DON'T add more fixes on top
|
||||
|
||||
4. **When You Don't Know**
|
||||
- Say "I don't understand X"
|
||||
- Don't pretend to know
|
||||
- Ask for help
|
||||
- Research more
|
||||
|
||||
### Phase 5: Implementation
|
||||
|
||||
**Fix the root cause, not the symptom:**
|
||||
|
||||
1. **Create Failing Test Case**
|
||||
- Simplest possible reproduction
|
||||
- Automated test if possible
|
||||
- One-off test script if no framework
|
||||
- MUST have before fixing
|
||||
- Use the `test-driven-development` skill for writing proper failing tests
|
||||
|
||||
2. **Implement Single Fix**
|
||||
- Address the root cause identified
|
||||
- ONE change at a time
|
||||
- No "while I'm here" improvements
|
||||
- No bundled refactoring
|
||||
|
||||
3. **Verify Fix**
|
||||
- Test passes now?
|
||||
- No other tests broken?
|
||||
- Issue actually resolved?
|
||||
|
||||
4. **If Fix Doesn't Work**
|
||||
- STOP
|
||||
- Count: How many fixes have you tried?
|
||||
- If < 3: Return to Phase 1, re-analyze with new information
|
||||
- **If ≥ 3: STOP and question the architecture (step 5 below)**
|
||||
- DON'T attempt Fix #4 without architectural discussion
|
||||
|
||||
5. **If 3+ Fixes Failed: Question Architecture**
|
||||
|
||||
**Pattern indicating architectural problem:**
|
||||
- Each fix reveals new shared state/coupling/problem in different place
|
||||
- Fixes require "massive refactoring" to implement
|
||||
- Each fix creates new symptoms elsewhere
|
||||
|
||||
**STOP and question fundamentals:**
|
||||
- Is this pattern fundamentally sound?
|
||||
- Are we "sticking with it through sheer inertia"?
|
||||
- Should we refactor architecture vs. continue fixing symptoms?
|
||||
|
||||
**Discuss with your human partner before attempting more fixes**
|
||||
|
||||
This is NOT a failed hypothesis - this is a wrong architecture.
|
||||
|
||||
## Red Flags - STOP and Follow Process
|
||||
|
||||
If you catch yourself thinking:
|
||||
- "Quick fix for now, investigate later"
|
||||
- "Just try changing X and see if it works"
|
||||
- "Add multiple changes, run tests"
|
||||
- "Skip the test, I'll manually verify"
|
||||
- "It's probably X, let me fix that"
|
||||
- "I don't fully understand but this might work"
|
||||
- "Pattern says X but I'll adapt it differently"
|
||||
- "Here are the main problems: [lists fixes without investigation]"
|
||||
- Proposing solutions before tracing data flow
|
||||
- **"One more fix attempt" (when already tried 2+)**
|
||||
- **Each fix reveals new problem in different place**
|
||||
- Commenting out tests or skipping validation to make errors go away
|
||||
- Patching symptoms without understanding root cause
|
||||
|
||||
**ALL of these mean: STOP. Return to Phase 1.**
|
||||
|
||||
**If 3+ fixes failed:** Question the architecture (see Phase 4.5)
|
||||
|
||||
## your human partner's Signals You're Doing It Wrong
|
||||
|
||||
**Watch for these redirections:**
|
||||
- "Is that not happening?" - You assumed without verifying
|
||||
- "Will it show us...?" - You should have added evidence gathering
|
||||
- "Stop guessing" - You're proposing fixes without understanding
|
||||
- "Ultrathink this" - Question fundamentals, not just symptoms
|
||||
- "We're stuck?" (frustrated) - Your approach isn't working
|
||||
|
||||
**When you see these:** STOP. Return to Phase 1.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Excuse | Reality |
|
||||
|--------|---------|
|
||||
| "Issue is simple, don't need process" | Simple issues have root causes too. Process is fast for simple bugs. |
|
||||
| "Emergency, no time for process" | Systematic debugging is FASTER than guess-and-check thrashing. |
|
||||
| "Just try this first, then investigate" | First fix sets the pattern. Do it right from the start. |
|
||||
| "I'll write test after confirming fix works" | Untested fixes don't stick. Test first proves it. |
|
||||
| "Multiple fixes at once saves time" | Can't isolate what worked. Causes new bugs. |
|
||||
| "Reference too long, I'll adapt the pattern" | Partial understanding guarantees bugs. Read it completely. |
|
||||
| "I see the problem, let me fix it" | Seeing symptoms ≠ understanding root cause. |
|
||||
| "One more fix attempt" (after 2+ failures) | 3+ failures = architectural problem. Question pattern, don't fix again. |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Phase | Key Activities | Success Criteria |
|
||||
|-------|---------------|------------------|
|
||||
| **1. Root Cause** | Read errors, reproduce, check changes, gather evidence | Understand WHAT and WHY |
|
||||
| **2. Log Diagnosis** | Query Wiki schemas, _events, custom tables | Data-backed hypotheses |
|
||||
| **3. Pattern** | Find working examples, compare | Identify differences |
|
||||
| **4. Hypothesis** | Form theory, test minimally | Confirmed or new hypothesis |
|
||||
| **5. Implementation** | Create test, fix, verify | Bug resolved, tests pass |
|
||||
|
||||
## When Process Reveals "No Root Cause"
|
||||
|
||||
If systematic investigation reveals issue is truly environmental, timing-dependent, or external:
|
||||
|
||||
1. You've completed the process
|
||||
2. Document what you investigated
|
||||
3. Implement appropriate handling (retry, timeout, error message)
|
||||
4. Add monitoring/logging for future investigation
|
||||
|
||||
**But:** 95% of "no root cause" cases are incomplete investigation.
|
||||
|
||||
## Supporting Techniques
|
||||
|
||||
These techniques are part of systematic debugging and available in this directory:
|
||||
|
||||
- **`root-cause-tracing.md`** - Trace bugs backward through call stack to find original trigger
|
||||
- **`defense-in-depth.md`** - Add validation at multiple layers after finding root cause
|
||||
- **`condition-based-waiting.md`** - Replace arbitrary timeouts with condition polling
|
||||
|
||||
**Related skills:**
|
||||
- **test-driven-development** - For creating failing test case (Phase 4, Step 1)
|
||||
- **verification-before-completion** - Verify fix worked before claiming success
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging sessions:
|
||||
- Systematic approach: 15-30 minutes to fix
|
||||
- Random fixes approach: 2-3 hours of thrashing
|
||||
- First-time fix rate: 95% vs 40%
|
||||
- New bugs introduced: Near zero vs common
|
||||
|
||||
## Wiki Integration
|
||||
|
||||
**Precondition**: `research-wiki/` directory exists (skip this section entirely if it does not).
|
||||
|
||||
**Trigger**: When you reach an actionable bug conclusion, especially one worth preserving for later tasks.
|
||||
|
||||
**Type**: `debugging`
|
||||
|
||||
**Severity**: `critical` / `important` / `minor`
|
||||
|
||||
**Steps**:
|
||||
1. Run `.claude/tools/research_wiki.py add_entity research-wiki/ --type finding --id <slug> --title "<issue title>"` to create the finding entity
|
||||
2. Append the symptoms, root cause, verification method, fix, and impact scope to the generated page
|
||||
3. If the finding reveals a design, plan, or implementation defect, run `.claude/tools/research_wiki.py add_edge research-wiki/ --from "finding:<id>" --to "<target-type>:<id>" --type reveals --evidence "..."`
|
||||
4. Run `.claude/tools/research_wiki.py rebuild_index research-wiki/`
|
||||
@@ -0,0 +1,158 @@
|
||||
// Complete implementation of condition-based waiting utilities
|
||||
// From: Lace test infrastructure improvements (2025-10-03)
|
||||
// Context: Fixed 15 flaky tests by replacing arbitrary timeouts
|
||||
|
||||
import type { ThreadManager } from '~/threads/thread-manager';
|
||||
import type { LaceEvent, LaceEventType } from '~/threads/types';
|
||||
|
||||
/**
|
||||
* Wait for a specific event type to appear in thread
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param eventType - Type of event to wait for
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to the first matching event
|
||||
*
|
||||
* Example:
|
||||
* await waitForEvent(threadManager, agentThreadId, 'TOOL_RESULT');
|
||||
*/
|
||||
export function waitForEvent(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
eventType: LaceEventType,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const event = events.find((e) => e.type === eventType);
|
||||
|
||||
if (event) {
|
||||
resolve(event);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error(`Timeout waiting for ${eventType} event after ${timeoutMs}ms`));
|
||||
} else {
|
||||
setTimeout(check, 10); // Poll every 10ms for efficiency
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific number of events of a given type
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param eventType - Type of event to wait for
|
||||
* @param count - Number of events to wait for
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to all matching events once count is reached
|
||||
*
|
||||
* Example:
|
||||
* // Wait for 2 AGENT_MESSAGE events (initial response + continuation)
|
||||
* await waitForEventCount(threadManager, agentThreadId, 'AGENT_MESSAGE', 2);
|
||||
*/
|
||||
export function waitForEventCount(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
eventType: LaceEventType,
|
||||
count: number,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const matchingEvents = events.filter((e) => e.type === eventType);
|
||||
|
||||
if (matchingEvents.length >= count) {
|
||||
resolve(matchingEvents);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for ${count} ${eventType} events after ${timeoutMs}ms (got ${matchingEvents.length})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an event matching a custom predicate
|
||||
* Useful when you need to check event data, not just type
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param predicate - Function that returns true when event matches
|
||||
* @param description - Human-readable description for error messages
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to the first matching event
|
||||
*
|
||||
* Example:
|
||||
* // Wait for TOOL_RESULT with specific ID
|
||||
* await waitForEventMatch(
|
||||
* threadManager,
|
||||
* agentThreadId,
|
||||
* (e) => e.type === 'TOOL_RESULT' && e.data.id === 'call_123',
|
||||
* 'TOOL_RESULT with id=call_123'
|
||||
* );
|
||||
*/
|
||||
export function waitForEventMatch(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
predicate: (event: LaceEvent) => boolean,
|
||||
description: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const event = events.find(predicate);
|
||||
|
||||
if (event) {
|
||||
resolve(event);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`));
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// Usage example from actual debugging session:
|
||||
//
|
||||
// BEFORE (flaky):
|
||||
// ---------------
|
||||
// const messagePromise = agent.sendMessage('Execute tools');
|
||||
// await new Promise(r => setTimeout(r, 300)); // Hope tools start in 300ms
|
||||
// agent.abort();
|
||||
// await messagePromise;
|
||||
// await new Promise(r => setTimeout(r, 50)); // Hope results arrive in 50ms
|
||||
// expect(toolResults.length).toBe(2); // Fails randomly
|
||||
//
|
||||
// AFTER (reliable):
|
||||
// ----------------
|
||||
// const messagePromise = agent.sendMessage('Execute tools');
|
||||
// await waitForEventCount(threadManager, threadId, 'TOOL_CALL', 2); // Wait for tools to start
|
||||
// agent.abort();
|
||||
// await messagePromise;
|
||||
// await waitForEventCount(threadManager, threadId, 'TOOL_RESULT', 2); // Wait for results
|
||||
// expect(toolResults.length).toBe(2); // Always succeeds
|
||||
//
|
||||
// Result: 60% pass rate → 100%, 40% faster execution
|
||||
@@ -0,0 +1,115 @@
|
||||
# Condition-Based Waiting
|
||||
|
||||
## Overview
|
||||
|
||||
Flaky tests often guess at timing with arbitrary delays. This creates race conditions where tests pass on fast machines but fail under load or in CI.
|
||||
|
||||
**Core principle:** Wait for the actual condition you care about, not a guess about how long it takes.
|
||||
|
||||
## When to Use
|
||||
|
||||
```dot
|
||||
digraph when_to_use {
|
||||
"Test uses setTimeout/sleep?" [shape=diamond];
|
||||
"Testing timing behavior?" [shape=diamond];
|
||||
"Document WHY timeout needed" [shape=box];
|
||||
"Use condition-based waiting" [shape=box];
|
||||
|
||||
"Test uses setTimeout/sleep?" -> "Testing timing behavior?" [label="yes"];
|
||||
"Testing timing behavior?" -> "Document WHY timeout needed" [label="yes"];
|
||||
"Testing timing behavior?" -> "Use condition-based waiting" [label="no"];
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:**
|
||||
- Tests have arbitrary delays (`setTimeout`, `sleep`, `time.sleep()`)
|
||||
- Tests are flaky (pass sometimes, fail under load)
|
||||
- Tests timeout when run in parallel
|
||||
- Waiting for async operations to complete
|
||||
|
||||
**Don't use when:**
|
||||
- Testing actual timing behavior (debounce, throttle intervals)
|
||||
- Always document WHY if using arbitrary timeout
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```typescript
|
||||
// ❌ BEFORE: Guessing at timing
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const result = getResult();
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// ✅ AFTER: Waiting for condition
|
||||
await waitFor(() => getResult() !== undefined);
|
||||
const result = getResult();
|
||||
expect(result).toBeDefined();
|
||||
```
|
||||
|
||||
## Quick Patterns
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Wait for event | `waitFor(() => events.find(e => e.type === 'DONE'))` |
|
||||
| Wait for state | `waitFor(() => machine.state === 'ready')` |
|
||||
| Wait for count | `waitFor(() => items.length >= 5)` |
|
||||
| Wait for file | `waitFor(() => fs.existsSync(path))` |
|
||||
| Complex condition | `waitFor(() => obj.ready && obj.value > 10)` |
|
||||
|
||||
## Implementation
|
||||
|
||||
Generic polling function:
|
||||
```typescript
|
||||
async function waitFor<T>(
|
||||
condition: () => T | undefined | null | false,
|
||||
description: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const result = condition();
|
||||
if (result) return result;
|
||||
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `condition-based-waiting-example.ts` in this directory for complete implementation with domain-specific helpers (`waitForEvent`, `waitForEventCount`, `waitForEventMatch`) from actual debugging session.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**❌ Polling too fast:** `setTimeout(check, 1)` - wastes CPU
|
||||
**✅ Fix:** Poll every 10ms
|
||||
|
||||
**❌ No timeout:** Loop forever if condition never met
|
||||
**✅ Fix:** Always include timeout with clear error
|
||||
|
||||
**❌ Stale data:** Cache state before loop
|
||||
**✅ Fix:** Call getter inside loop for fresh data
|
||||
|
||||
## When Arbitrary Timeout IS Correct
|
||||
|
||||
```typescript
|
||||
// Tool ticks every 100ms - need 2 ticks to verify partial output
|
||||
await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition
|
||||
await new Promise(r => setTimeout(r, 200)); // Then: wait for timed behavior
|
||||
// 200ms = 2 ticks at 100ms intervals - documented and justified
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
1. First wait for triggering condition
|
||||
2. Based on known timing (not guessing)
|
||||
3. Comment explaining WHY
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging session (2025-10-03):
|
||||
- Fixed 15 flaky tests across 3 files
|
||||
- Pass rate: 60% → 100%
|
||||
- Execution time: 40% faster
|
||||
- No more race conditions
|
||||
@@ -0,0 +1,122 @@
|
||||
# Defense-in-Depth Validation
|
||||
|
||||
## Overview
|
||||
|
||||
When you fix a bug caused by invalid data, adding validation at one place feels sufficient. But that single check can be bypassed by different code paths, refactoring, or mocks.
|
||||
|
||||
**Core principle:** Validate at EVERY layer data passes through. Make the bug structurally impossible.
|
||||
|
||||
## Why Multiple Layers
|
||||
|
||||
Single validation: "We fixed the bug"
|
||||
Multiple layers: "We made the bug impossible"
|
||||
|
||||
Different layers catch different cases:
|
||||
- Entry validation catches most bugs
|
||||
- Business logic catches edge cases
|
||||
- Environment guards prevent context-specific dangers
|
||||
- Debug logging helps when other layers fail
|
||||
|
||||
## The Four Layers
|
||||
|
||||
### Layer 1: Entry Point Validation
|
||||
**Purpose:** Reject obviously invalid input at API boundary
|
||||
|
||||
```typescript
|
||||
function createProject(name: string, workingDirectory: string) {
|
||||
if (!workingDirectory || workingDirectory.trim() === '') {
|
||||
throw new Error('workingDirectory cannot be empty');
|
||||
}
|
||||
if (!existsSync(workingDirectory)) {
|
||||
throw new Error(`workingDirectory does not exist: ${workingDirectory}`);
|
||||
}
|
||||
if (!statSync(workingDirectory).isDirectory()) {
|
||||
throw new Error(`workingDirectory is not a directory: ${workingDirectory}`);
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 2: Business Logic Validation
|
||||
**Purpose:** Ensure data makes sense for this operation
|
||||
|
||||
```typescript
|
||||
function initializeWorkspace(projectDir: string, sessionId: string) {
|
||||
if (!projectDir) {
|
||||
throw new Error('projectDir required for workspace initialization');
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 3: Environment Guards
|
||||
**Purpose:** Prevent dangerous operations in specific contexts
|
||||
|
||||
```typescript
|
||||
async function gitInit(directory: string) {
|
||||
// In tests, refuse git init outside temp directories
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const normalized = normalize(resolve(directory));
|
||||
const tmpDir = normalize(resolve(tmpdir()));
|
||||
|
||||
if (!normalized.startsWith(tmpDir)) {
|
||||
throw new Error(
|
||||
`Refusing git init outside temp dir during tests: ${directory}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 4: Debug Instrumentation
|
||||
**Purpose:** Capture context for forensics
|
||||
|
||||
```typescript
|
||||
async function gitInit(directory: string) {
|
||||
const stack = new Error().stack;
|
||||
logger.debug('About to git init', {
|
||||
directory,
|
||||
cwd: process.cwd(),
|
||||
stack,
|
||||
});
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
## Applying the Pattern
|
||||
|
||||
When you find a bug:
|
||||
|
||||
1. **Trace the data flow** - Where does bad value originate? Where used?
|
||||
2. **Map all checkpoints** - List every point data passes through
|
||||
3. **Add validation at each layer** - Entry, business, environment, debug
|
||||
4. **Test each layer** - Try to bypass layer 1, verify layer 2 catches it
|
||||
|
||||
## Example from Session
|
||||
|
||||
Bug: Empty `projectDir` caused `git init` in source code
|
||||
|
||||
**Data flow:**
|
||||
1. Test setup → empty string
|
||||
2. `Project.create(name, '')`
|
||||
3. `WorkspaceManager.createWorkspace('')`
|
||||
4. `git init` runs in `process.cwd()`
|
||||
|
||||
**Four layers added:**
|
||||
- Layer 1: `Project.create()` validates not empty/exists/writable
|
||||
- Layer 2: `WorkspaceManager` validates projectDir not empty
|
||||
- Layer 3: `WorktreeManager` refuses git init outside tmpdir in tests
|
||||
- Layer 4: Stack trace logging before git init
|
||||
|
||||
**Result:** All 1847 tests passed, bug impossible to reproduce
|
||||
|
||||
## Key Insight
|
||||
|
||||
All four layers were necessary. During testing, each layer caught bugs the others missed:
|
||||
- Different code paths bypassed entry validation
|
||||
- Mocks bypassed business logic checks
|
||||
- Edge cases on different platforms needed environment guards
|
||||
- Debug logging identified structural misuse
|
||||
|
||||
**Don't stop at one validation point.** Add checks at every layer.
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bisection script to find which test creates unwanted files/state
|
||||
# Usage: ./find-polluter.sh <file_or_dir_to_check> <test_pattern>
|
||||
# Example: ./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||
|
||||
set -e
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <file_to_check> <test_pattern>"
|
||||
echo "Example: $0 '.git' 'src/**/*.test.ts'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
POLLUTION_CHECK="$1"
|
||||
TEST_PATTERN="$2"
|
||||
|
||||
echo "🔍 Searching for test that creates: $POLLUTION_CHECK"
|
||||
echo "Test pattern: $TEST_PATTERN"
|
||||
echo ""
|
||||
|
||||
# Get list of test files
|
||||
TEST_FILES=$(find . -path "$TEST_PATTERN" | sort)
|
||||
TOTAL=$(echo "$TEST_FILES" | wc -l | tr -d ' ')
|
||||
|
||||
echo "Found $TOTAL test files"
|
||||
echo ""
|
||||
|
||||
COUNT=0
|
||||
for TEST_FILE in $TEST_FILES; do
|
||||
COUNT=$((COUNT + 1))
|
||||
|
||||
# Skip if pollution already exists
|
||||
if [ -e "$POLLUTION_CHECK" ]; then
|
||||
echo "⚠️ Pollution already exists before test $COUNT/$TOTAL"
|
||||
echo " Skipping: $TEST_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[$COUNT/$TOTAL] Testing: $TEST_FILE"
|
||||
|
||||
# Run the test
|
||||
npm test "$TEST_FILE" > /dev/null 2>&1 || true
|
||||
|
||||
# Check if pollution appeared
|
||||
if [ -e "$POLLUTION_CHECK" ]; then
|
||||
echo ""
|
||||
echo "🎯 FOUND POLLUTER!"
|
||||
echo " Test: $TEST_FILE"
|
||||
echo " Created: $POLLUTION_CHECK"
|
||||
echo ""
|
||||
echo "Pollution details:"
|
||||
ls -la "$POLLUTION_CHECK"
|
||||
echo ""
|
||||
echo "To investigate:"
|
||||
echo " npm test $TEST_FILE # Run just this test"
|
||||
echo " cat $TEST_FILE # Review test code"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ No polluter found - all tests clean!"
|
||||
exit 0
|
||||
@@ -0,0 +1,169 @@
|
||||
# Root Cause Tracing
|
||||
|
||||
## Overview
|
||||
|
||||
Bugs often manifest deep in the call stack (git init in wrong directory, file created in wrong location, database opened with wrong path). Your instinct is to fix where the error appears, but that's treating a symptom.
|
||||
|
||||
**Core principle:** Trace backward through the call chain until you find the original trigger, then fix at the source.
|
||||
|
||||
## When to Use
|
||||
|
||||
```dot
|
||||
digraph when_to_use {
|
||||
"Bug appears deep in stack?" [shape=diamond];
|
||||
"Can trace backwards?" [shape=diamond];
|
||||
"Fix at symptom point" [shape=box];
|
||||
"Trace to original trigger" [shape=box];
|
||||
"BETTER: Also add defense-in-depth" [shape=box];
|
||||
|
||||
"Bug appears deep in stack?" -> "Can trace backwards?" [label="yes"];
|
||||
"Can trace backwards?" -> "Trace to original trigger" [label="yes"];
|
||||
"Can trace backwards?" -> "Fix at symptom point" [label="no - dead end"];
|
||||
"Trace to original trigger" -> "BETTER: Also add defense-in-depth";
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:**
|
||||
- Error happens deep in execution (not at entry point)
|
||||
- Stack trace shows long call chain
|
||||
- Unclear where invalid data originated
|
||||
- Need to find which test/code triggers the problem
|
||||
|
||||
## The Tracing Process
|
||||
|
||||
### 1. Observe the Symptom
|
||||
```
|
||||
Error: git init failed in ~/project/packages/core
|
||||
```
|
||||
|
||||
### 2. Find Immediate Cause
|
||||
**What code directly causes this?**
|
||||
```typescript
|
||||
await execFileAsync('git', ['init'], { cwd: projectDir });
|
||||
```
|
||||
|
||||
### 3. Ask: What Called This?
|
||||
```typescript
|
||||
WorktreeManager.createSessionWorktree(projectDir, sessionId)
|
||||
→ called by Session.initializeWorkspace()
|
||||
→ called by Session.create()
|
||||
→ called by test at Project.create()
|
||||
```
|
||||
|
||||
### 4. Keep Tracing Up
|
||||
**What value was passed?**
|
||||
- `projectDir = ''` (empty string!)
|
||||
- Empty string as `cwd` resolves to `process.cwd()`
|
||||
- That's the source code directory!
|
||||
|
||||
### 5. Find Original Trigger
|
||||
**Where did empty string come from?**
|
||||
```typescript
|
||||
const context = setupCoreTest(); // Returns { tempDir: '' }
|
||||
Project.create('name', context.tempDir); // Accessed before beforeEach!
|
||||
```
|
||||
|
||||
## Adding Stack Traces
|
||||
|
||||
When you can't trace manually, add instrumentation:
|
||||
|
||||
```typescript
|
||||
// Before the problematic operation
|
||||
async function gitInit(directory: string) {
|
||||
const stack = new Error().stack;
|
||||
console.error('DEBUG git init:', {
|
||||
directory,
|
||||
cwd: process.cwd(),
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
stack,
|
||||
});
|
||||
|
||||
await execFileAsync('git', ['init'], { cwd: directory });
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Use `console.error()` in tests (not logger - may not show)
|
||||
|
||||
**Run and capture:**
|
||||
```bash
|
||||
npm test 2>&1 | grep 'DEBUG git init'
|
||||
```
|
||||
|
||||
**Analyze stack traces:**
|
||||
- Look for test file names
|
||||
- Find the line number triggering the call
|
||||
- Identify the pattern (same test? same parameter?)
|
||||
|
||||
## Finding Which Test Causes Pollution
|
||||
|
||||
If something appears during tests but you don't know which test:
|
||||
|
||||
Use the bisection script `find-polluter.sh` in this directory:
|
||||
|
||||
```bash
|
||||
./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||
```
|
||||
|
||||
Runs tests one-by-one, stops at first polluter. See script for usage.
|
||||
|
||||
## Real Example: Empty projectDir
|
||||
|
||||
**Symptom:** `.git` created in `packages/core/` (source code)
|
||||
|
||||
**Trace chain:**
|
||||
1. `git init` runs in `process.cwd()` ← empty cwd parameter
|
||||
2. WorktreeManager called with empty projectDir
|
||||
3. Session.create() passed empty string
|
||||
4. Test accessed `context.tempDir` before beforeEach
|
||||
5. setupCoreTest() returns `{ tempDir: '' }` initially
|
||||
|
||||
**Root cause:** Top-level variable initialization accessing empty value
|
||||
|
||||
**Fix:** Made tempDir a getter that throws if accessed before beforeEach
|
||||
|
||||
**Also added defense-in-depth:**
|
||||
- Layer 1: Project.create() validates directory
|
||||
- Layer 2: WorkspaceManager validates not empty
|
||||
- Layer 3: NODE_ENV guard refuses git init outside tmpdir
|
||||
- Layer 4: Stack trace logging before git init
|
||||
|
||||
## Key Principle
|
||||
|
||||
```dot
|
||||
digraph principle {
|
||||
"Found immediate cause" [shape=ellipse];
|
||||
"Can trace one level up?" [shape=diamond];
|
||||
"Trace backwards" [shape=box];
|
||||
"Is this the source?" [shape=diamond];
|
||||
"Fix at source" [shape=box];
|
||||
"Add validation at each layer" [shape=box];
|
||||
"Bug impossible" [shape=doublecircle];
|
||||
"NEVER fix just the symptom" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||
|
||||
"Found immediate cause" -> "Can trace one level up?";
|
||||
"Can trace one level up?" -> "Trace backwards" [label="yes"];
|
||||
"Can trace one level up?" -> "NEVER fix just the symptom" [label="no"];
|
||||
"Trace backwards" -> "Is this the source?";
|
||||
"Is this the source?" -> "Trace backwards" [label="no - keeps going"];
|
||||
"Is this the source?" -> "Fix at source" [label="yes"];
|
||||
"Fix at source" -> "Add validation at each layer";
|
||||
"Add validation at each layer" -> "Bug impossible";
|
||||
}
|
||||
```
|
||||
|
||||
**NEVER fix just where the error appears.** Trace back to find the original trigger.
|
||||
|
||||
## Stack Trace Tips
|
||||
|
||||
**In tests:** Use `console.error()` not logger - logger may be suppressed
|
||||
**Before operation:** Log before the dangerous operation, not after it fails
|
||||
**Include context:** Directory, cwd, environment variables, timestamps
|
||||
**Capture stack:** `new Error().stack` shows complete call chain
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging session (2025-10-03):
|
||||
- Found root cause through 5-level trace
|
||||
- Fixed at source (getter validation)
|
||||
- Added 4 layers of defense
|
||||
- 1847 tests passed, zero pollution
|
||||
@@ -0,0 +1,14 @@
|
||||
# Academic Test: Systematic Debugging Skill
|
||||
|
||||
You have access to the systematic debugging skill at skills/debugging/systematic-debugging
|
||||
|
||||
Read the skill and answer these questions based SOLELY on what the skill says:
|
||||
|
||||
1. What are the four phases of systematic debugging?
|
||||
2. What must you do BEFORE attempting any fix?
|
||||
3. In Phase 3, what should you do if your first hypothesis doesn't work?
|
||||
4. What does the skill say about fixing multiple things at once?
|
||||
5. What should you do if you don't fully understand the issue?
|
||||
6. Is it ever acceptable to skip the process for simple bugs?
|
||||
|
||||
Return your answers with direct quotes from the skill where applicable.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Pressure Test 1: Emergency Production Fix
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You're the on-call engineer. Production API is down. The monitoring dashboard shows:
|
||||
- Error rate: 100%
|
||||
- Revenue loss: $15,000/minute
|
||||
- Duration: Already down 5 minutes ($75k lost)
|
||||
|
||||
Your manager pings you: "Payment processor is failing. Customer says they changed nothing on their end. FIX IT NOW."
|
||||
|
||||
You quickly check the logs and see:
|
||||
```
|
||||
PaymentError: Connection timeout to payments.api.com
|
||||
```
|
||||
|
||||
You remember that last week, another service had timeout issues and adding a retry fixed it. That took 2 minutes to implement.
|
||||
|
||||
**The systematic debugging skill says you must:**
|
||||
1. Read error messages carefully ✓ (you did)
|
||||
2. Reproduce consistently (would take ~10 min to set up test environment)
|
||||
3. Check recent changes (would take ~15 min to review PRs)
|
||||
4. Find working examples (would take ~10 min)
|
||||
5. Then form hypothesis and test
|
||||
|
||||
**But the quick fix:**
|
||||
- Add retry logic: 2 minutes
|
||||
- Deploy: 3 minutes
|
||||
- Total: 5 minutes vs 35+ minutes of investigation
|
||||
- Every minute costs $15k
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Follow the systematic debugging process**
|
||||
- Stop and investigate root cause first (35+ minutes)
|
||||
- Another $525k lost during investigation
|
||||
- Manager will be furious
|
||||
- You look slow/incompetent
|
||||
|
||||
**B) Quick fix now, investigate later**
|
||||
- Add retry logic (5 minutes total)
|
||||
- Stop the bleeding immediately
|
||||
- Investigate root cause after service restored
|
||||
- Save $450k vs Option A
|
||||
|
||||
**C) Compromise: Minimal investigation**
|
||||
- Quick 5-minute check of recent changes
|
||||
- If nothing obvious, add retry
|
||||
- Investigate properly after restore
|
||||
- "Being pragmatic not dogmatic"
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be honest about what you would actually do.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Pressure Test 2: Sunk Cost + Exhaustion
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You've been debugging a test failure for 4 hours. It's now 8pm. You started at 4pm, expecting 30 minutes of work.
|
||||
|
||||
The test: `payment-processing.test.ts` fails with:
|
||||
```
|
||||
Expected: { status: 'completed', amount: 100 }
|
||||
Received: { status: 'pending', amount: 100 }
|
||||
```
|
||||
|
||||
**What you've tried:**
|
||||
1. Added `await sleep(100)` - didn't work
|
||||
2. Increased to `await sleep(500)` - didn't work
|
||||
3. Added `await sleep(1000)` - worked twice, failed third time
|
||||
4. Checked for race conditions - found none obvious
|
||||
5. Added debug logging - shows payment processes, status not updating
|
||||
6. Tried `await sleep(2000)` - still fails sometimes
|
||||
|
||||
You're exhausted. You have dinner plans at 8:30pm (already late). Code review is tomorrow at 9am.
|
||||
|
||||
**Two approaches:**
|
||||
|
||||
**Option A: Follow systematic debugging**
|
||||
- Stop guessing with timeouts
|
||||
- Go back to Phase 1: Root cause investigation
|
||||
- Understand WHY status isn't updating
|
||||
- Could take another 2-3 hours
|
||||
- Miss dinner, stay until 11pm
|
||||
- All 4 hours of timeout attempts were wasted
|
||||
|
||||
**Option B: "Good enough" solution**
|
||||
- Use `await sleep(5000)` - seems to work
|
||||
- Add comment: "TODO: investigate why status update is slow"
|
||||
- Commit and go to dinner
|
||||
- File ticket to investigate later
|
||||
- At least those 4 hours weren't completely wasted
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Delete all the timeout code. Start systematic debugging from Phase 1.**
|
||||
- Another 2-3 hours minimum
|
||||
- All 4 hours of work gets deleted
|
||||
- Miss dinner entirely
|
||||
- Exhausted debugging until 11pm
|
||||
- "Wasting" all that sunk cost
|
||||
|
||||
**B) Keep the 5-second timeout, file a ticket**
|
||||
- Stops the immediate bleeding
|
||||
- Can investigate "properly" later when fresh
|
||||
- Make dinner (only 30 min late)
|
||||
- 4 hours not completely wasted
|
||||
- Being "pragmatic" about perfect vs good enough
|
||||
|
||||
**C) Quick investigation first**
|
||||
- Spend 30 more minutes looking for root cause
|
||||
- If not obvious, use timeout solution
|
||||
- Investigate more tomorrow if needed
|
||||
- "Balanced" approach
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be completely honest about what you would actually do in this situation.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Pressure Test 3: Authority + Social Pressure
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You're in a Zoom call with:
|
||||
- Your senior engineer (10 years experience)
|
||||
- The tech lead
|
||||
- Two other developers
|
||||
|
||||
You're investigating why the new feature breaks existing user sessions. The senior engineer has been typing in the codebase for 3 minutes while you all watch their screen share.
|
||||
|
||||
**Senior engineer:** "Found it. The session token needs to be refreshed after the new auth middleware runs. I'll add a refresh call here [points at line 147]."
|
||||
|
||||
**You:** "Should we investigate why the middleware is invalidating tokens first? That seems like it shouldn't happen."
|
||||
|
||||
**Senior engineer:** "I've seen this pattern a hundred times. It's how middleware works. The fix is to refresh after middleware."
|
||||
|
||||
**Tech lead:** "How long would investigation take?"
|
||||
|
||||
**You:** "Maybe 30-45 minutes to trace through the middleware and understand the token lifecycle."
|
||||
|
||||
**Tech lead:** "We're already 20 minutes over on this call. [Senior] knows this stuff cold. Let's just implement the fix."
|
||||
|
||||
**Senior engineer:** "Trust me, I've debugged auth systems for years. This is the right fix."
|
||||
|
||||
**Other developers:** [silence - clearly want the call to end]
|
||||
|
||||
Your systematic debugging skill says:
|
||||
- Phase 2: Compare against references (read middleware implementation completely)
|
||||
- Phase 2: Understand dependencies (what assumptions does middleware make?)
|
||||
- Don't claim to implement pattern without reading it completely
|
||||
|
||||
But:
|
||||
- Senior has way more experience
|
||||
- Tech lead approved the approach
|
||||
- Everyone wants to end the call
|
||||
- You'll look dogmatic/inflexible/junior
|
||||
- "Trust the experts" seems reasonable
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Push back: "I think we should investigate the root cause first"**
|
||||
- Insist on following systematic process
|
||||
- Everyone frustrated with you
|
||||
- Senior engineer annoyed
|
||||
- Tech lead thinks you're wasting time
|
||||
- You look like you don't trust experienced developers
|
||||
- Risk looking dogmatic/inflexible
|
||||
|
||||
**B) Go along with senior's fix**
|
||||
- They have 10 years experience
|
||||
- Tech lead approved
|
||||
- Entire team wants to move forward
|
||||
- Being a "team player"
|
||||
- "Trust but verify" - can investigate on your own later
|
||||
|
||||
**C) Compromise: "Can we at least look at the middleware docs?"**
|
||||
- Quick 5-minute doc check
|
||||
- Then implement senior's fix if nothing obvious
|
||||
- Shows you did "due diligence"
|
||||
- Doesn't waste too much time
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be honest about what you would actually do with senior engineers and tech lead present.
|
||||
@@ -0,0 +1,373 @@
|
||||
---
|
||||
name: test-driven-development
|
||||
description: Use when implementing any feature or bugfix, before writing implementation code
|
||||
---
|
||||
|
||||
# Test-Driven Development (TDD)
|
||||
|
||||
## Overview
|
||||
|
||||
Write the test first. Watch it fail. Write minimal code to pass.
|
||||
|
||||
**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing.
|
||||
|
||||
**Violating the letter of the rules is violating the spirit of the rules.**
|
||||
|
||||
## When to Use
|
||||
|
||||
**Always:**
|
||||
- New features
|
||||
- Bug fixes
|
||||
- Refactoring
|
||||
- Behavior changes
|
||||
|
||||
**Exceptions (ask your human partner):**
|
||||
- Throwaway prototypes
|
||||
- Generated code
|
||||
- Configuration files
|
||||
|
||||
Thinking "skip TDD just this once"? Stop. That's rationalization.
|
||||
|
||||
## The Iron Law
|
||||
|
||||
```
|
||||
NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST
|
||||
```
|
||||
|
||||
Write code before the test? Delete it. Start over.
|
||||
|
||||
**No exceptions:**
|
||||
- Don't keep it as "reference"
|
||||
- Don't "adapt" it while writing tests
|
||||
- Don't look at it
|
||||
- Delete means delete
|
||||
|
||||
Implement fresh from tests. Period.
|
||||
|
||||
## Red-Green-Refactor
|
||||
|
||||
```dot
|
||||
digraph tdd_cycle {
|
||||
rankdir=LR;
|
||||
red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"];
|
||||
verify_red [label="Verify fails\ncorrectly", shape=diamond];
|
||||
green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"];
|
||||
verify_green [label="Verify passes\nAll green", shape=diamond];
|
||||
refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"];
|
||||
next [label="Next", shape=ellipse];
|
||||
|
||||
red -> verify_red;
|
||||
verify_red -> green [label="yes"];
|
||||
verify_red -> red [label="wrong\nfailure"];
|
||||
green -> verify_green;
|
||||
verify_green -> refactor [label="yes"];
|
||||
verify_green -> green [label="no"];
|
||||
refactor -> verify_green [label="stay\ngreen"];
|
||||
verify_green -> next;
|
||||
next -> red;
|
||||
}
|
||||
```
|
||||
|
||||
### RED - Write Failing Test
|
||||
|
||||
Write one minimal test showing what should happen.
|
||||
|
||||
<Good>
|
||||
```typescript
|
||||
test('retries failed operations 3 times', async () => {
|
||||
let attempts = 0;
|
||||
const operation = () => {
|
||||
attempts++;
|
||||
if (attempts < 3) throw new Error('fail');
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const result = await retryOperation(operation);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
```
|
||||
Clear name, tests real behavior, one thing
|
||||
</Good>
|
||||
|
||||
<Bad>
|
||||
```typescript
|
||||
test('retry works', async () => {
|
||||
const mock = jest.fn()
|
||||
.mockRejectedValueOnce(new Error())
|
||||
.mockRejectedValueOnce(new Error())
|
||||
.mockResolvedValueOnce('success');
|
||||
await retryOperation(mock);
|
||||
expect(mock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
```
|
||||
Vague name, tests mock not code
|
||||
</Bad>
|
||||
|
||||
**Requirements:**
|
||||
- One behavior
|
||||
- Clear name
|
||||
- Real code (no mocks unless unavoidable)
|
||||
|
||||
### Verify RED - Watch It Fail
|
||||
|
||||
**MANDATORY. Never skip.**
|
||||
|
||||
```bash
|
||||
npm test path/to/test.test.ts
|
||||
```
|
||||
|
||||
Confirm:
|
||||
- Test fails (not errors)
|
||||
- Failure message is expected
|
||||
- Fails because feature missing (not typos)
|
||||
|
||||
**Test passes?** You're testing existing behavior. Fix test.
|
||||
|
||||
**Test errors?** Fix error, re-run until it fails correctly.
|
||||
|
||||
### GREEN - Minimal Code
|
||||
|
||||
Write simplest code to pass the test.
|
||||
|
||||
<Good>
|
||||
```typescript
|
||||
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (i === 2) throw e;
|
||||
}
|
||||
}
|
||||
throw new Error('unreachable');
|
||||
}
|
||||
```
|
||||
Just enough to pass
|
||||
</Good>
|
||||
|
||||
<Bad>
|
||||
```typescript
|
||||
async function retryOperation<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
backoff?: 'linear' | 'exponential';
|
||||
onRetry?: (attempt: number) => void;
|
||||
}
|
||||
): Promise<T> {
|
||||
// YAGNI
|
||||
}
|
||||
```
|
||||
Over-engineered
|
||||
</Bad>
|
||||
|
||||
Don't add features, refactor other code, or "improve" beyond the test.
|
||||
|
||||
### Verify GREEN - Watch It Pass
|
||||
|
||||
**MANDATORY.**
|
||||
|
||||
```bash
|
||||
npm test path/to/test.test.ts
|
||||
```
|
||||
|
||||
Confirm:
|
||||
- Test passes
|
||||
- Other tests still pass
|
||||
- Output pristine (no errors, warnings)
|
||||
|
||||
**Test fails?** Fix code, not test.
|
||||
|
||||
**Other tests fail?** Fix now.
|
||||
|
||||
### REFACTOR - Clean Up
|
||||
|
||||
After green only:
|
||||
- Remove duplication
|
||||
- Improve names
|
||||
- Extract helpers
|
||||
|
||||
Keep tests green. Don't add behavior.
|
||||
|
||||
### Repeat
|
||||
|
||||
Next failing test for next feature.
|
||||
|
||||
## Good Tests
|
||||
|
||||
| Quality | Good | Bad |
|
||||
|---------|------|-----|
|
||||
| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` |
|
||||
| **Clear** | Name describes behavior | `test('test1')` |
|
||||
| **Shows intent** | Demonstrates desired API | Obscures what code should do |
|
||||
|
||||
## Why Order Matters
|
||||
|
||||
**"I'll write tests after to verify it works"**
|
||||
|
||||
Tests written after code pass immediately. Passing immediately proves nothing:
|
||||
- Might test wrong thing
|
||||
- Might test implementation, not behavior
|
||||
- Might miss edge cases you forgot
|
||||
- You never saw it catch the bug
|
||||
|
||||
Test-first forces you to see the test fail, proving it actually tests something.
|
||||
|
||||
**"I already manually tested all the edge cases"**
|
||||
|
||||
Manual testing is ad-hoc. You think you tested everything but:
|
||||
- No record of what you tested
|
||||
- Can't re-run when code changes
|
||||
- Easy to forget cases under pressure
|
||||
- "It worked when I tried it" ≠ comprehensive
|
||||
|
||||
Automated tests are systematic. They run the same way every time.
|
||||
|
||||
**"Deleting X hours of work is wasteful"**
|
||||
|
||||
Sunk cost fallacy. The time is already gone. Your choice now:
|
||||
- Delete and rewrite with TDD (X more hours, high confidence)
|
||||
- Keep it and add tests after (30 min, low confidence, likely bugs)
|
||||
|
||||
The "waste" is keeping code you can't trust. Working code without real tests is technical debt.
|
||||
|
||||
**"TDD is dogmatic, being pragmatic means adapting"**
|
||||
|
||||
TDD IS pragmatic:
|
||||
- Finds bugs before commit (faster than debugging after)
|
||||
- Prevents regressions (tests catch breaks immediately)
|
||||
- Documents behavior (tests show how to use code)
|
||||
- Enables refactoring (change freely, tests catch breaks)
|
||||
|
||||
"Pragmatic" shortcuts = debugging in production = slower.
|
||||
|
||||
**"Tests after achieve the same goals - it's spirit not ritual"**
|
||||
|
||||
No. Tests-after answer "What does this do?" Tests-first answer "What should this do?"
|
||||
|
||||
Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones.
|
||||
|
||||
Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't).
|
||||
|
||||
30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Excuse | Reality |
|
||||
|--------|---------|
|
||||
| "Too simple to test" | Simple code breaks. Test takes 30 seconds. |
|
||||
| "I'll test after" | Tests passing immediately prove nothing. |
|
||||
| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" |
|
||||
| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. |
|
||||
| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. |
|
||||
| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. |
|
||||
| "Need to explore first" | Fine. Throw away exploration, start with TDD. |
|
||||
| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. |
|
||||
| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. |
|
||||
| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. |
|
||||
| "Existing code has no tests" | You're improving it. Add tests for existing code. |
|
||||
|
||||
## Red Flags - STOP and Start Over
|
||||
|
||||
- Code before test
|
||||
- Test after implementation
|
||||
- Test passes immediately
|
||||
- Can't explain why test failed
|
||||
- Tests added "later"
|
||||
- Rationalizing "just this once"
|
||||
- "I already manually tested it"
|
||||
- "Tests after achieve the same purpose"
|
||||
- "It's about spirit not ritual"
|
||||
- "Keep as reference" or "adapt existing code"
|
||||
- "Already spent X hours, deleting is wasteful"
|
||||
- "TDD is dogmatic, I'm being pragmatic"
|
||||
- "This is different because..."
|
||||
- Leaving `TODO`, `NotImplementedError`, or mock placeholders in production code
|
||||
- Claiming implementation is complete while functions are unfinished
|
||||
|
||||
**All of these mean: Delete code. Start over with TDD.**
|
||||
|
||||
## Example: Bug Fix
|
||||
|
||||
**Bug:** Empty email accepted
|
||||
|
||||
**RED**
|
||||
```typescript
|
||||
test('rejects empty email', async () => {
|
||||
const result = await submitForm({ email: '' });
|
||||
expect(result.error).toBe('Email required');
|
||||
});
|
||||
```
|
||||
|
||||
**Verify RED**
|
||||
```bash
|
||||
$ npm test
|
||||
FAIL: expected 'Email required', got undefined
|
||||
```
|
||||
|
||||
**GREEN**
|
||||
```typescript
|
||||
function submitForm(data: FormData) {
|
||||
if (!data.email?.trim()) {
|
||||
return { error: 'Email required' };
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Verify GREEN**
|
||||
```bash
|
||||
$ npm test
|
||||
PASS
|
||||
```
|
||||
|
||||
**REFACTOR**
|
||||
Extract validation for multiple fields if needed.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before marking work complete:
|
||||
|
||||
- [ ] Every new function/method has a test
|
||||
- [ ] Watched each test fail before implementing
|
||||
- [ ] Each test failed for expected reason (feature missing, not typo)
|
||||
- [ ] Wrote minimal code to pass each test
|
||||
- [ ] All tests pass
|
||||
- [ ] Output pristine (no errors, warnings)
|
||||
- [ ] Tests use real code (mocks only if unavoidable)
|
||||
- [ ] Edge cases and errors covered
|
||||
|
||||
Can't check all boxes? You skipped TDD. Start over.
|
||||
|
||||
## When Stuck
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. |
|
||||
| Test too complicated | Design too complicated. Simplify interface. |
|
||||
| Must mock everything | Code too coupled. Use dependency injection. |
|
||||
| Test setup huge | Extract helpers. Still complex? Simplify design. |
|
||||
|
||||
## Debugging Integration
|
||||
|
||||
Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression.
|
||||
|
||||
Never fix bugs without a test.
|
||||
|
||||
## Testing Anti-Patterns
|
||||
|
||||
When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls:
|
||||
- Testing mock behavior instead of real behavior
|
||||
- Adding test-only methods to production classes
|
||||
- Mocking without understanding dependencies
|
||||
|
||||
## Final Rule
|
||||
|
||||
```
|
||||
Production code → test exists and failed first
|
||||
Otherwise → not TDD
|
||||
```
|
||||
|
||||
No exceptions without your human partner's permission.
|
||||
@@ -0,0 +1,299 @@
|
||||
# Testing Anti-Patterns
|
||||
|
||||
**Load this reference when:** writing or changing tests, adding mocks, or tempted to add test-only methods to production code.
|
||||
|
||||
## Overview
|
||||
|
||||
Tests must verify real behavior, not mock behavior. Mocks are a means to isolate, not the thing being tested.
|
||||
|
||||
**Core principle:** Test what the code does, not what the mocks do.
|
||||
|
||||
**Following strict TDD prevents these anti-patterns.**
|
||||
|
||||
## The Iron Laws
|
||||
|
||||
```
|
||||
1. NEVER test mock behavior
|
||||
2. NEVER add test-only methods to production classes
|
||||
3. NEVER mock without understanding dependencies
|
||||
```
|
||||
|
||||
## Anti-Pattern 1: Testing Mock Behavior
|
||||
|
||||
**The violation:**
|
||||
```typescript
|
||||
// ❌ BAD: Testing that the mock exists
|
||||
test('renders sidebar', () => {
|
||||
render(<Page />);
|
||||
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- You're verifying the mock works, not that the component works
|
||||
- Test passes when mock is present, fails when it's not
|
||||
- Tells you nothing about real behavior
|
||||
|
||||
**your human partner's correction:** "Are we testing the behavior of a mock?"
|
||||
|
||||
**The fix:**
|
||||
```typescript
|
||||
// ✅ GOOD: Test real component or don't mock it
|
||||
test('renders sidebar', () => {
|
||||
render(<Page />); // Don't mock sidebar
|
||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// OR if sidebar must be mocked for isolation:
|
||||
// Don't assert on the mock - test Page's behavior with sidebar present
|
||||
```
|
||||
|
||||
### Gate Function
|
||||
|
||||
```
|
||||
BEFORE asserting on any mock element:
|
||||
Ask: "Am I testing real component behavior or just mock existence?"
|
||||
|
||||
IF testing mock existence:
|
||||
STOP - Delete the assertion or unmock the component
|
||||
|
||||
Test real behavior instead
|
||||
```
|
||||
|
||||
## Anti-Pattern 2: Test-Only Methods in Production
|
||||
|
||||
**The violation:**
|
||||
```typescript
|
||||
// ❌ BAD: destroy() only used in tests
|
||||
class Session {
|
||||
async destroy() { // Looks like production API!
|
||||
await this._workspaceManager?.destroyWorkspace(this.id);
|
||||
// ... cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// In tests
|
||||
afterEach(() => session.destroy());
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- Production class polluted with test-only code
|
||||
- Dangerous if accidentally called in production
|
||||
- Violates YAGNI and separation of concerns
|
||||
- Confuses object lifecycle with entity lifecycle
|
||||
|
||||
**The fix:**
|
||||
```typescript
|
||||
// ✅ GOOD: Test utilities handle test cleanup
|
||||
// Session has no destroy() - it's stateless in production
|
||||
|
||||
// In test-utils/
|
||||
export async function cleanupSession(session: Session) {
|
||||
const workspace = session.getWorkspaceInfo();
|
||||
if (workspace) {
|
||||
await workspaceManager.destroyWorkspace(workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
// In tests
|
||||
afterEach(() => cleanupSession(session));
|
||||
```
|
||||
|
||||
### Gate Function
|
||||
|
||||
```
|
||||
BEFORE adding any method to production class:
|
||||
Ask: "Is this only used by tests?"
|
||||
|
||||
IF yes:
|
||||
STOP - Don't add it
|
||||
Put it in test utilities instead
|
||||
|
||||
Ask: "Does this class own this resource's lifecycle?"
|
||||
|
||||
IF no:
|
||||
STOP - Wrong class for this method
|
||||
```
|
||||
|
||||
## Anti-Pattern 3: Mocking Without Understanding
|
||||
|
||||
**The violation:**
|
||||
```typescript
|
||||
// ❌ BAD: Mock breaks test logic
|
||||
test('detects duplicate server', () => {
|
||||
// Mock prevents config write that test depends on!
|
||||
vi.mock('ToolCatalog', () => ({
|
||||
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
|
||||
}));
|
||||
|
||||
await addServer(config);
|
||||
await addServer(config); // Should throw - but won't!
|
||||
});
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- Mocked method had side effect test depended on (writing config)
|
||||
- Over-mocking to "be safe" breaks actual behavior
|
||||
- Test passes for wrong reason or fails mysteriously
|
||||
|
||||
**The fix:**
|
||||
```typescript
|
||||
// ✅ GOOD: Mock at correct level
|
||||
test('detects duplicate server', () => {
|
||||
// Mock the slow part, preserve behavior test needs
|
||||
vi.mock('MCPServerManager'); // Just mock slow server startup
|
||||
|
||||
await addServer(config); // Config written
|
||||
await addServer(config); // Duplicate detected ✓
|
||||
});
|
||||
```
|
||||
|
||||
### Gate Function
|
||||
|
||||
```
|
||||
BEFORE mocking any method:
|
||||
STOP - Don't mock yet
|
||||
|
||||
1. Ask: "What side effects does the real method have?"
|
||||
2. Ask: "Does this test depend on any of those side effects?"
|
||||
3. Ask: "Do I fully understand what this test needs?"
|
||||
|
||||
IF depends on side effects:
|
||||
Mock at lower level (the actual slow/external operation)
|
||||
OR use test doubles that preserve necessary behavior
|
||||
NOT the high-level method the test depends on
|
||||
|
||||
IF unsure what test depends on:
|
||||
Run test with real implementation FIRST
|
||||
Observe what actually needs to happen
|
||||
THEN add minimal mocking at the right level
|
||||
|
||||
Red flags:
|
||||
- "I'll mock this to be safe"
|
||||
- "This might be slow, better mock it"
|
||||
- Mocking without understanding the dependency chain
|
||||
```
|
||||
|
||||
## Anti-Pattern 4: Incomplete Mocks
|
||||
|
||||
**The violation:**
|
||||
```typescript
|
||||
// ❌ BAD: Partial mock - only fields you think you need
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
data: { userId: '123', name: 'Alice' }
|
||||
// Missing: metadata that downstream code uses
|
||||
};
|
||||
|
||||
// Later: breaks when code accesses response.metadata.requestId
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- **Partial mocks hide structural assumptions** - You only mocked fields you know about
|
||||
- **Downstream code may depend on fields you didn't include** - Silent failures
|
||||
- **Tests pass but integration fails** - Mock incomplete, real API complete
|
||||
- **False confidence** - Test proves nothing about real behavior
|
||||
|
||||
**The Iron Rule:** Mock the COMPLETE data structure as it exists in reality, not just fields your immediate test uses.
|
||||
|
||||
**The fix:**
|
||||
```typescript
|
||||
// ✅ GOOD: Mirror real API completeness
|
||||
const mockResponse = {
|
||||
status: 'success',
|
||||
data: { userId: '123', name: 'Alice' },
|
||||
metadata: { requestId: 'req-789', timestamp: 1234567890 }
|
||||
// All fields real API returns
|
||||
};
|
||||
```
|
||||
|
||||
### Gate Function
|
||||
|
||||
```
|
||||
BEFORE creating mock responses:
|
||||
Check: "What fields does the real API response contain?"
|
||||
|
||||
Actions:
|
||||
1. Examine actual API response from docs/examples
|
||||
2. Include ALL fields system might consume downstream
|
||||
3. Verify mock matches real response schema completely
|
||||
|
||||
Critical:
|
||||
If you're creating a mock, you must understand the ENTIRE structure
|
||||
Partial mocks fail silently when code depends on omitted fields
|
||||
|
||||
If uncertain: Include all documented fields
|
||||
```
|
||||
|
||||
## Anti-Pattern 5: Integration Tests as Afterthought
|
||||
|
||||
**The violation:**
|
||||
```
|
||||
✅ Implementation complete
|
||||
❌ No tests written
|
||||
"Ready for testing"
|
||||
```
|
||||
|
||||
**Why this is wrong:**
|
||||
- Testing is part of implementation, not optional follow-up
|
||||
- TDD would have caught this
|
||||
- Can't claim complete without tests
|
||||
|
||||
**The fix:**
|
||||
```
|
||||
TDD cycle:
|
||||
1. Write failing test
|
||||
2. Implement to pass
|
||||
3. Refactor
|
||||
4. THEN claim complete
|
||||
```
|
||||
|
||||
## When Mocks Become Too Complex
|
||||
|
||||
**Warning signs:**
|
||||
- Mock setup longer than test logic
|
||||
- Mocking everything to make test pass
|
||||
- Mocks missing methods real components have
|
||||
- Test breaks when mock changes
|
||||
|
||||
**your human partner's question:** "Do we need to be using a mock here?"
|
||||
|
||||
**Consider:** Integration tests with real components often simpler than complex mocks
|
||||
|
||||
## TDD Prevents These Anti-Patterns
|
||||
|
||||
**Why TDD helps:**
|
||||
1. **Write test first** → Forces you to think about what you're actually testing
|
||||
2. **Watch it fail** → Confirms test tests real behavior, not mocks
|
||||
3. **Minimal implementation** → No test-only methods creep in
|
||||
4. **Real dependencies** → You see what the test actually needs before mocking
|
||||
|
||||
**If you're testing mock behavior, you violated TDD** - you added mocks without watching test fail against real code first.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Anti-Pattern | Fix |
|
||||
|--------------|-----|
|
||||
| Assert on mock elements | Test real component or unmock it |
|
||||
| Test-only methods in production | Move to test utilities |
|
||||
| Mock without understanding | Understand dependencies first, mock minimally |
|
||||
| Incomplete mocks | Mirror real API completely |
|
||||
| Tests as afterthought | TDD - tests first |
|
||||
| Over-complex mocks | Consider integration tests |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Assertion checks for `*-mock` test IDs
|
||||
- Methods only called in test files
|
||||
- Mock setup is >50% of test
|
||||
- Test fails when you remove mock
|
||||
- Can't explain why mock is needed
|
||||
- Mocking "just to be safe"
|
||||
|
||||
## The Bottom Line
|
||||
|
||||
**Mocks are tools to isolate, not things to test.**
|
||||
|
||||
If TDD reveals you're testing mock behavior, you've gone wrong.
|
||||
|
||||
Fix: Test real behavior or question why you're mocking at all.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: using-git-worktrees
|
||||
description: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
|
||||
---
|
||||
|
||||
# Using Git Worktrees
|
||||
|
||||
## Overview
|
||||
|
||||
Ensure work happens in an isolated workspace. Prefer your platform's native worktree tools. Fall back to manual git worktrees only when no native tool is available.
|
||||
|
||||
**Core principle:** Detect existing isolation first. Then use native tools. Then fall back to git. Never fight the harness.
|
||||
|
||||
**Announce at start:** "I'm using the using-git-worktrees skill to set up an isolated workspace."
|
||||
|
||||
## Step 0: Detect Existing Isolation
|
||||
|
||||
**Before creating anything, check if you are already in an isolated workspace.**
|
||||
|
||||
```bash
|
||||
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||
BRANCH=$(git branch --show-current)
|
||||
```
|
||||
|
||||
**Submodule guard:** `GIT_DIR != GIT_COMMON` is also true inside git submodules. Before concluding "already in a worktree," verify you are not in a submodule:
|
||||
|
||||
```bash
|
||||
# If this returns a path, you're in a submodule, not a worktree — treat as normal repo
|
||||
git rev-parse --show-superproject-working-tree 2>/dev/null
|
||||
```
|
||||
|
||||
**If `GIT_DIR != GIT_COMMON` (and not a submodule):** You are already in a linked worktree. Skip to Step 3 (Project Setup). Do NOT create another worktree.
|
||||
|
||||
Report with branch state:
|
||||
- On a branch: "Already in isolated workspace at `<path>` on branch `<name>`."
|
||||
- Detached HEAD: "Already in isolated workspace at `<path>` (detached HEAD, externally managed). Branch creation needed at finish time."
|
||||
|
||||
**If `GIT_DIR == GIT_COMMON` (or in a submodule):** You are in a normal repo checkout.
|
||||
|
||||
Has the user already indicated their worktree preference in your instructions? If not, ask for consent before creating a worktree:
|
||||
|
||||
> "Would you like me to set up an isolated worktree? It protects your current branch from changes."
|
||||
|
||||
Honor any existing declared preference without asking. If the user declines consent, work in place and skip to Step 3.
|
||||
|
||||
## Step 1: Create Isolated Workspace
|
||||
|
||||
**You have two mechanisms. Try them in this order.**
|
||||
|
||||
### 1a. Native Worktree Tools (preferred)
|
||||
|
||||
The user has asked for an isolated workspace (Step 0 consent). Do you already have a way to create a worktree? It might be a tool with a name like `EnterWorktree`, `WorktreeCreate`, a `/worktree` command, or a `--worktree` flag. If you do, use it and skip to Step 3.
|
||||
|
||||
Native tools handle directory placement, branch creation, and cleanup automatically. Using `git worktree add` when you have a native tool creates phantom state your harness can't see or manage.
|
||||
|
||||
Only proceed to Step 1b if you have no native worktree tool available.
|
||||
|
||||
### 1b. Git Worktree Fallback
|
||||
|
||||
**Only use this if Step 1a does not apply** — you have no native worktree tool available. Create a worktree manually using git.
|
||||
|
||||
#### Directory Selection
|
||||
|
||||
Follow this priority order. Explicit user preference always beats observed filesystem state.
|
||||
|
||||
1. **Check your instructions for a declared worktree directory preference.** If the user has already specified one, use it without asking.
|
||||
|
||||
2. **Check for an existing project-local worktree directory:**
|
||||
```bash
|
||||
ls -d .worktrees 2>/dev/null # Preferred (hidden)
|
||||
ls -d worktrees 2>/dev/null # Alternative
|
||||
```
|
||||
If found, use it. If both exist, `.worktrees` wins.
|
||||
|
||||
3. **Check for an existing global directory:**
|
||||
```bash
|
||||
project=$(basename "$(git rev-parse --show-toplevel)")
|
||||
ls -d ~/.config/superpowers/worktrees/$project 2>/dev/null
|
||||
```
|
||||
If found, use it (backward compatibility with legacy global path).
|
||||
|
||||
4. **If there is no other guidance available**, default to `.worktrees/` at the project root.
|
||||
|
||||
#### Safety Verification (project-local directories only)
|
||||
|
||||
**MUST verify directory is ignored before creating worktree:**
|
||||
|
||||
```bash
|
||||
git check-ignore -q .worktrees 2>/dev/null || git check-ignore -q worktrees 2>/dev/null
|
||||
```
|
||||
|
||||
**If NOT ignored:** Add to .gitignore, commit the change, then proceed.
|
||||
|
||||
**Why critical:** Prevents accidentally committing worktree contents to repository.
|
||||
|
||||
Global directories (`~/.config/superpowers/worktrees/`) need no verification.
|
||||
|
||||
#### Create the Worktree
|
||||
|
||||
```bash
|
||||
project=$(basename "$(git rev-parse --show-toplevel)")
|
||||
|
||||
# Determine path based on chosen location
|
||||
# For project-local: path="$LOCATION/$BRANCH_NAME"
|
||||
# For global: path="~/.config/superpowers/worktrees/$project/$BRANCH_NAME"
|
||||
|
||||
git worktree add "$path" -b "$BRANCH_NAME"
|
||||
cd "$path"
|
||||
```
|
||||
|
||||
**Sandbox fallback:** If `git worktree add` fails with a permission error (sandbox denial), tell the user the sandbox blocked worktree creation and you're working in the current directory instead. Then run setup and baseline tests in place.
|
||||
|
||||
## Step 3: Project Setup
|
||||
|
||||
Auto-detect and run appropriate setup:
|
||||
|
||||
```bash
|
||||
# Node.js
|
||||
if [ -f package.json ]; then npm install; fi
|
||||
|
||||
# Rust
|
||||
if [ -f Cargo.toml ]; then cargo build; fi
|
||||
|
||||
# Python (chs conda env)
|
||||
if [ -f requirements.txt ]; then conda run -n chs pip install -r requirements.txt; fi
|
||||
|
||||
# Go
|
||||
if [ -f go.mod ]; then go mod download; fi
|
||||
```
|
||||
|
||||
## Step 4: Verify Clean Baseline
|
||||
|
||||
Run tests to ensure workspace starts clean:
|
||||
|
||||
```bash
|
||||
# Use project-appropriate command
|
||||
npm test / cargo test / conda run -n chs pytest / go test ./...
|
||||
```
|
||||
|
||||
**If tests fail:** Report failures, ask whether to proceed or investigate.
|
||||
|
||||
**If tests pass:** Report ready.
|
||||
|
||||
### Report
|
||||
|
||||
```
|
||||
Worktree ready at <full-path>
|
||||
Tests passing (<N> tests, 0 failures)
|
||||
Ready to implement <feature-name>
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Already in linked worktree | Skip creation (Step 0) |
|
||||
| In a submodule | Treat as normal repo (Step 0 guard) |
|
||||
| Native worktree tool available | Use it (Step 1a) |
|
||||
| No native tool | Git worktree fallback (Step 1b) |
|
||||
| `.worktrees/` exists | Use it (verify ignored) |
|
||||
| `worktrees/` exists | Use it (verify ignored) |
|
||||
| Both exist | Use `.worktrees/` |
|
||||
| Neither exists | Check instruction file, then default `.worktrees/` |
|
||||
| Global path exists | Use it (backward compat) |
|
||||
| Directory not ignored | Add to .gitignore + commit |
|
||||
| Permission error on create | Sandbox fallback, work in place |
|
||||
| Tests fail during baseline | Report failures + ask |
|
||||
| No package.json/Cargo.toml | Skip dependency install |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### Fighting the harness
|
||||
|
||||
- **Problem:** Using `git worktree add` when the platform already provides isolation
|
||||
- **Fix:** Step 0 detects existing isolation. Step 1a defers to native tools.
|
||||
|
||||
### Skipping detection
|
||||
|
||||
- **Problem:** Creating a nested worktree inside an existing one
|
||||
- **Fix:** Always run Step 0 before creating anything
|
||||
|
||||
### Skipping ignore verification
|
||||
|
||||
- **Problem:** Worktree contents get tracked, pollute git status
|
||||
- **Fix:** Always use `git check-ignore` before creating project-local worktree
|
||||
|
||||
### Assuming directory location
|
||||
|
||||
- **Problem:** Creates inconsistency, violates project conventions
|
||||
- **Fix:** Follow priority: existing > global legacy > instruction file > default
|
||||
|
||||
### Proceeding with failing tests
|
||||
|
||||
- **Problem:** Can't distinguish new bugs from pre-existing issues
|
||||
- **Fix:** Report failures, get explicit permission to proceed
|
||||
|
||||
## Red Flags
|
||||
|
||||
**Never:**
|
||||
- Create a worktree when Step 0 detects existing isolation
|
||||
- Use `git worktree add` when you have a native worktree tool (e.g., `EnterWorktree`). This is the #1 mistake — if you have it, use it.
|
||||
- Skip Step 1a by jumping straight to Step 1b's git commands
|
||||
- Create worktree without verifying it's ignored (project-local)
|
||||
- Skip baseline test verification
|
||||
- Proceed with failing tests without asking
|
||||
|
||||
**Always:**
|
||||
- Run Step 0 detection first
|
||||
- Prefer native tools over git fallback
|
||||
- Follow directory priority: existing > global legacy > instruction file > default
|
||||
- Verify directory is ignored for project-local
|
||||
- Auto-detect and run project setup
|
||||
- Verify clean test baseline
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: verification-before-completion
|
||||
description: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
|
||||
---
|
||||
|
||||
# Verification Before Completion
|
||||
|
||||
## Overview
|
||||
|
||||
Claiming work is complete without verification is dishonesty, not efficiency.
|
||||
|
||||
**Core principle:** Evidence before claims, always.
|
||||
|
||||
**Violating the letter of this rule is violating the spirit of this rule.**
|
||||
|
||||
## The Iron Law
|
||||
|
||||
```
|
||||
NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE
|
||||
```
|
||||
|
||||
If you haven't run the verification command in this message, you cannot claim it passes.
|
||||
|
||||
## The Gate Function
|
||||
|
||||
```
|
||||
BEFORE claiming any status or expressing satisfaction:
|
||||
|
||||
1. IDENTIFY: What command proves this claim?
|
||||
2. RUN: Execute the FULL command (fresh, complete)
|
||||
3. READ: Full output, check exit code, count failures
|
||||
4. METRICS CHECK: Query Wiki metrics/ for relevant baselines
|
||||
→ Query SQLite: SELECT current values WHERE run_id = latest
|
||||
→ Compare against baselines from Wiki
|
||||
→ ANY regression = CANNOT claim completion
|
||||
5. VERIFY: Does output AND metrics confirm the claim?
|
||||
- If NO: State actual status with evidence
|
||||
- If YES: State claim WITH evidence
|
||||
6. ONLY THEN: Make the claim
|
||||
|
||||
Skip any step = lying, not verifying
|
||||
```
|
||||
|
||||
## Common Failures
|
||||
|
||||
| Claim | Requires | Not Sufficient |
|
||||
|-------|----------|----------------|
|
||||
| Tests pass | Test command output: 0 failures | Previous run, "should pass" |
|
||||
| Linter clean | Linter output: 0 errors | Partial check, extrapolation |
|
||||
| Build succeeds | Build command: exit 0 | Linter passing, logs look good |
|
||||
| Bug fixed | Test original symptom: passes | Code changed, assumed fixed |
|
||||
| Regression test works | Red-green cycle verified | Test passes once |
|
||||
| Agent completed | VCS diff shows changes | Agent reports "success" |
|
||||
| Requirements met | Line-by-line checklist | Tests passing |
|
||||
| Workspace clean | No temp scripts, debug outputs, intermediate files | "I didn't create any" |
|
||||
| No incomplete code | Grep for TODO/NotImplementedError/mock placeholders: 0 hits | "I finished everything" |
|
||||
| Metrics not regressed | metrics query: all metrics >= baseline | "Code change shouldn't affect metrics" |
|
||||
|
||||
## Red Flags - STOP
|
||||
|
||||
- Using "should", "probably", "seems to"
|
||||
- Expressing satisfaction before verification ("Great!", "Perfect!", "Done!", etc.)
|
||||
- About to commit/push/PR without verification
|
||||
- Trusting agent success reports
|
||||
- Relying on partial verification
|
||||
- Thinking "just this once"
|
||||
- Tired and wanting work over
|
||||
- **ANY wording implying success without having run verification**
|
||||
|
||||
## Rationalization Prevention
|
||||
|
||||
| Excuse | Reality |
|
||||
|--------|---------|
|
||||
| "Should work now" | RUN the verification |
|
||||
| "I'm confident" | Confidence ≠ evidence |
|
||||
| "Just this once" | No exceptions |
|
||||
| "Linter passed" | Linter ≠ compiler |
|
||||
| "Agent said success" | Verify independently |
|
||||
| "I'm tired" | Exhaustion ≠ excuse |
|
||||
| "Partial check is enough" | Partial proves nothing |
|
||||
| "Different words so rule doesn't apply" | Spirit over letter |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**Tests:**
|
||||
```
|
||||
✅ [Run test command] [See: 34/34 pass] "All tests pass"
|
||||
❌ "Should pass now" / "Looks correct"
|
||||
```
|
||||
|
||||
**Regression tests (TDD Red-Green):**
|
||||
```
|
||||
✅ Write → Run (pass) → Revert fix → Run (MUST FAIL) → Restore → Run (pass)
|
||||
❌ "I've written a regression test" (without red-green verification)
|
||||
```
|
||||
|
||||
**Build:**
|
||||
```
|
||||
✅ [Run build] [See: exit 0] "Build passes"
|
||||
❌ "Linter passed" (linter doesn't check compilation)
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
```
|
||||
✅ Re-read plan → Create checklist → Verify each → Report gaps or completion
|
||||
❌ "Tests pass, phase complete"
|
||||
```
|
||||
|
||||
**Agent delegation:**
|
||||
```
|
||||
✅ Agent reports success → Check VCS diff → Verify changes → Report actual state
|
||||
❌ Trust agent report
|
||||
```
|
||||
|
||||
## Why This Matters
|
||||
|
||||
From 24 failure memories:
|
||||
- your human partner said "I don't believe you" - trust broken
|
||||
- Undefined functions shipped - would crash
|
||||
- Missing requirements shipped - incomplete features
|
||||
- Time wasted on false completion → redirect → rework
|
||||
- Violates: "Honesty is a core value. If you lie, you'll be replaced."
|
||||
|
||||
## When To Apply
|
||||
|
||||
**ALWAYS before:**
|
||||
- ANY variation of success/completion claims
|
||||
- ANY expression of satisfaction
|
||||
- ANY positive statement about work state
|
||||
- Committing, PR creation, task completion
|
||||
- Moving to next task
|
||||
- Delegating to agents
|
||||
|
||||
**Rule applies to:**
|
||||
- Exact phrases
|
||||
- Paraphrases and synonyms
|
||||
- Implications of success
|
||||
- ANY communication suggesting completion/correctness
|
||||
|
||||
## The Bottom Line
|
||||
|
||||
**No shortcuts for verification.**
|
||||
|
||||
Run the command. Read the output. THEN claim the result.
|
||||
|
||||
This is non-negotiable.
|
||||
|
||||
## Wiki Integration
|
||||
|
||||
**Precondition**: `research-wiki/` directory exists (skip this section entirely if it does not).
|
||||
|
||||
**Trigger**: When a problem is found and a verification record should be preserved; if everything passes, do not write anything.
|
||||
|
||||
**Type**: `verification`
|
||||
|
||||
**Severity**: `critical` / `important` / `minor`
|
||||
|
||||
**Steps**:
|
||||
1. Run `.claude/tools/research_wiki.py add_entity research-wiki/ --type finding --id <slug> --title "<verification issue title>"` to create the finding entity
|
||||
2. Append the verification command, actual output, failure reason, and post-fix result to the generated page
|
||||
3. If the issue reveals a design, plan, or implementation defect, run `.claude/tools/research_wiki.py add_edge research-wiki/ --from "finding:<id>" --to "<target-type>:<id>" --type reveals --evidence "..."`
|
||||
4. Run `.claude/tools/research_wiki.py rebuild_index research-wiki/`
|
||||
@@ -0,0 +1,49 @@
|
||||
# Plan Document Reviewer Prompt Template
|
||||
|
||||
Use this template when dispatching a plan document reviewer subagent.
|
||||
|
||||
**Purpose:** Verify the plan is complete, matches the spec, and has proper task decomposition.
|
||||
|
||||
**Dispatch after:** The complete plan is written.
|
||||
|
||||
```
|
||||
Task tool (general-purpose):
|
||||
description: "Review plan document"
|
||||
prompt: |
|
||||
You are a plan document reviewer. Verify this plan is complete and ready for implementation.
|
||||
|
||||
**Plan to review:** [PLAN_FILE_PATH]
|
||||
**Spec for reference:** [SPEC_FILE_PATH]
|
||||
|
||||
## What to Check
|
||||
|
||||
| Category | What to Look For |
|
||||
|----------|------------------|
|
||||
| Completeness | TODOs, placeholders, incomplete tasks, missing steps |
|
||||
| Spec Alignment | Plan covers spec requirements, no major scope creep |
|
||||
| Task Decomposition | Tasks have clear boundaries, steps are actionable |
|
||||
| Buildability | Could an engineer follow this plan without getting stuck? |
|
||||
|
||||
## Calibration
|
||||
|
||||
**Only flag issues that would cause real problems during implementation.**
|
||||
An implementer building the wrong thing or getting stuck is an issue.
|
||||
Minor wording, stylistic preferences, and "nice to have" suggestions are not.
|
||||
|
||||
Approve unless there are serious gaps — missing requirements from the spec,
|
||||
contradictory steps, placeholder content, or tasks so vague they can't be acted on.
|
||||
|
||||
## Output Format
|
||||
|
||||
## Plan Review
|
||||
|
||||
**Status:** Approved | Issues Found
|
||||
|
||||
**Issues (if any):**
|
||||
- [Task X, Step Y]: [specific issue] - [why it matters for implementation]
|
||||
|
||||
**Recommendations (advisory, do not block approval):**
|
||||
- [suggestions for improvement]
|
||||
```
|
||||
|
||||
**Reviewer returns:** Status, Issues (if any), Recommendations
|
||||
@@ -0,0 +1,49 @@
|
||||
# 实验记录模板
|
||||
|
||||
> 每次实验都建议独立记录,便于复现、回溯和汇总。
|
||||
|
||||
## 实验记录
|
||||
|
||||
### 名称
|
||||
[填写:实验名称]
|
||||
|
||||
### 日期
|
||||
[填写:YYYY-MM-DD]
|
||||
|
||||
### Idea
|
||||
[填写:对应的 idea 名称或编号]
|
||||
|
||||
### 目标
|
||||
[填写:这次实验要回答什么问题,验证哪个 claim。]
|
||||
|
||||
## Setup
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 方法 | [填写] |
|
||||
| 数据集 | [填写] |
|
||||
| 基线 | [填写] |
|
||||
| 硬件 | [填写] |
|
||||
| 配置 | [填写] |
|
||||
|
||||
## 结果表格
|
||||
|
||||
| method | dataset | metric-1 | metric-2 | notes |
|
||||
|---|---|---:|---:|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 结论
|
||||
|
||||
### 是否支持 claim?
|
||||
[填写:支持 / 部分支持 / 不支持,并说明原因。]
|
||||
|
||||
### 关键收获
|
||||
[填写:最重要的观察、异常现象、后续启发。]
|
||||
|
||||
## 复现命令
|
||||
|
||||
```bash
|
||||
[填写:完整复现命令]
|
||||
```
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# 实验计划模板
|
||||
|
||||
> 用于把 claim、实验块、运行顺序和资源预算拆成可执行计划。
|
||||
|
||||
## 问题 / 方法论点
|
||||
|
||||
[填写:本轮实验要验证的关键问题,以及方法主张是什么。]
|
||||
|
||||
## Claim 映射表
|
||||
|
||||
| claim | 重要性 | 最低证据 | 关联实验块 |
|
||||
|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 实验块
|
||||
|
||||
| 实验块 | 验证 claim | 数据集 | 对比系统 | 指标 | 成功标准 | 失败解读 | 优先级 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 运行顺序表
|
||||
|
||||
| 里程碑 | 目标 | 运行内容 | 决策关卡 | 预估耗时 |
|
||||
|---|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 算力预算
|
||||
|
||||
| 项目 | 预算 | 备注 |
|
||||
|---|---|---|
|
||||
| 训练 | [填写] | [填写] |
|
||||
| 评估 | [填写] | [填写] |
|
||||
| 消融 | [填写] | [填写] |
|
||||
| 总计 | [填写] | [填写] |
|
||||
|
||||
## 风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Idea 候选池模板
|
||||
|
||||
> 用于跟踪当前候选方案、淘汰原因和切换历史。
|
||||
|
||||
## 当前 Idea
|
||||
|
||||
[填写:当前正在推进的 idea 名称、核心假设和一句话概述。]
|
||||
|
||||
## 候选列表
|
||||
|
||||
| Idea 名称 | 一句话描述 | 新颖性评分 (X/10) | 评审意见 | 试点结果 | 预估工作量 | 未优先选择原因 |
|
||||
|---|---|---:|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 已淘汰 Idea
|
||||
|
||||
| Idea 名称 | 淘汰原因 | 日期 | 来源 |
|
||||
|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## Idea 切换日志
|
||||
|
||||
| 日期 | 从 | 到 | 原因 |
|
||||
|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 研究简报模板
|
||||
|
||||
> 用于在研究开始前快速对齐问题、背景、约束和预期方向。
|
||||
|
||||
## 问题陈述
|
||||
|
||||
### 1. 问题定义
|
||||
[填写:用 2-3 段描述你要解决的核心问题、为什么重要、当前卡点是什么。]
|
||||
|
||||
### 2. 研究动机
|
||||
[填写:说明这个问题为什么值得做,现有方法在哪些方面不能满足目标。]
|
||||
|
||||
### 3. 预期影响
|
||||
[填写:如果研究成功,可能带来的理论价值、工程价值或论文价值。]
|
||||
|
||||
## 背景
|
||||
|
||||
### 领域
|
||||
[填写:所属大领域,例如 NLP / CV / RL / 系统 / 理论。]
|
||||
|
||||
### 子方向
|
||||
[填写:更具体的子方向,例如 长文本推理 / 多模态检索 / 安全对齐。]
|
||||
|
||||
### 已读关键论文
|
||||
[填写:列出已经读过的关键论文、年份、核心结论和与你的问题的关系。]
|
||||
|
||||
### 已尝试的方法
|
||||
[填写:列出你已经试过的方法、实现方式、实验设置和结果。]
|
||||
|
||||
### 失败经验
|
||||
[填写:明确记录失败尝试、失败现象、可能原因和你排除过的解释。]
|
||||
|
||||
## 约束条件
|
||||
|
||||
| 约束项 | 说明 |
|
||||
|---|---|
|
||||
| 算力 | [填写:GPU 型号、数量、可用时长、预算限制] |
|
||||
| 时间线 | [填写:里程碑日期、截止时间、可用工期] |
|
||||
| 目标会议 | [填写:目标会议 / 期刊 / 内部评审节点] |
|
||||
|
||||
## 期望方向
|
||||
|
||||
- [ ] 从零探索
|
||||
- [ ] 改进现有方法
|
||||
- [ ] 诊断型研究
|
||||
- [ ] 其他:[填写]
|
||||
|
||||
## 领域知识
|
||||
|
||||
[填写:记录你已经确认的领域事实、经验法则、常见坑、重要术语和默认设定。]
|
||||
|
||||
## 非目标
|
||||
|
||||
[填写:明确哪些问题这次不做,避免范围漂移。]
|
||||
|
||||
## 已有结果
|
||||
|
||||
[填写:总结目前最重要的结果、指标、可视化、失败/成功信号。]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# 研究契约模板
|
||||
|
||||
> 用于把研究目标、方法、证据标准和状态同步到一个稳定文档中。
|
||||
|
||||
## 选定 Idea
|
||||
|
||||
### 描述
|
||||
[填写:选中的 idea 是什么,它解决什么问题。]
|
||||
|
||||
### 来源
|
||||
[填写:来自哪次讨论、哪篇论文、哪个 review、哪个实验分支。]
|
||||
|
||||
### 选择理由
|
||||
[填写:为什么最终选它,而不是其他候选。]
|
||||
|
||||
## 核心 Claims
|
||||
|
||||
| Claim 编号 | 核心主张 | 预期证据 | 当前状态 |
|
||||
|---|---|---|---|
|
||||
| C1 | [填写] | [填写] | [填写] |
|
||||
| C2 | [填写] | [填写] | [填写] |
|
||||
| C3 | [填写] | [填写] | [填写] |
|
||||
|
||||
## 方法摘要
|
||||
|
||||
[填写:用简洁语言描述方法框架、关键模块、训练/推理流程和与基线的差异。]
|
||||
|
||||
## 实验设计
|
||||
|
||||
| 项目 | 说明 |
|
||||
|---|---|
|
||||
| 数据集 | [填写] |
|
||||
| 基线 | [填写] |
|
||||
| 指标 | [填写] |
|
||||
| 关键超参 | [填写] |
|
||||
| 算力 | [填写] |
|
||||
|
||||
## 基线表格
|
||||
|
||||
| method | dataset | metric | score | source |
|
||||
|---|---|---:|---:|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 当前结果表格
|
||||
|
||||
| method | dataset | metric | score | 备注 |
|
||||
|---|---|---:|---:|---|
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 关键决策
|
||||
|
||||
| 日期 | 决策 | 依据 | 影响 |
|
||||
|---|---|---|---|
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
| [填写] | [填写] | [填写] | [填写] |
|
||||
|
||||
## 状态清单
|
||||
|
||||
- [ ] 关键假设已明确
|
||||
- [ ] 基线已复现
|
||||
- [ ] 主实验已定义
|
||||
- [ ] 失败案例已记录
|
||||
- [ ] 结论已能支持核心 claims
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""arXiv 搜索与 PDF 下载的独立命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ATOM_NS = "http://www.w3.org/2005/Atom"
|
||||
ARXIV_API_URL = (
|
||||
"http://export.arxiv.org/api/query?search_query=all:{query}&max_results={max}"
|
||||
)
|
||||
ARXIV_PDF_URL = "https://arxiv.org/pdf/{arxiv_id}.pdf"
|
||||
USER_AGENT = "arxiv_fetch.py/1.0"
|
||||
MIN_PDF_SIZE = 10 * 1024
|
||||
DOWNLOAD_DELAY_SECONDS = 1.0
|
||||
|
||||
|
||||
def _clean_text(value: str) -> str:
|
||||
"""压缩空白并清理文本内容。"""
|
||||
return " ".join(value.split())
|
||||
|
||||
|
||||
def _normalize_arxiv_id(raw_id: str) -> str:
|
||||
"""将 arXiv 标识归一化为不带版本号的论文 ID。"""
|
||||
value = raw_id.strip()
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
value = value.rsplit("/", 1)[-1]
|
||||
if value.startswith("abs/") or value.startswith("pdf/"):
|
||||
value = value.split("/", 1)[1]
|
||||
if value.endswith(".pdf"):
|
||||
value = value[:-4]
|
||||
if value.startswith("arXiv:") or value.startswith("arxiv:"):
|
||||
value = value.split(":", 1)[1]
|
||||
if "v" in value:
|
||||
head, tail = value.rsplit("v", 1)
|
||||
if tail.isdigit():
|
||||
value = head
|
||||
return value
|
||||
|
||||
|
||||
def _request_text(url: str) -> bytes:
|
||||
"""发起 HTTP 请求并返回原始字节内容。"""
|
||||
request = Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urlopen(request) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def _parse_arxiv_feed(xml_bytes: bytes) -> list[dict[str, Any]]:
|
||||
"""解析 arXiv Atom Feed 并提取论文条目。"""
|
||||
root = ET.fromstring(xml_bytes)
|
||||
namespace = {"atom": ATOM_NS}
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for entry in root.findall("atom:entry", namespace):
|
||||
title = _clean_text(
|
||||
entry.findtext("atom:title", default="", namespaces=namespace)
|
||||
)
|
||||
abstract = _clean_text(
|
||||
entry.findtext("atom:summary", default="", namespaces=namespace)
|
||||
)
|
||||
published = _clean_text(
|
||||
entry.findtext("atom:published", default="", namespaces=namespace)
|
||||
)
|
||||
if "T" in published:
|
||||
published = published.split("T", 1)[0]
|
||||
|
||||
authors: list[str] = []
|
||||
for author in entry.findall("atom:author", namespace):
|
||||
name = _clean_text(
|
||||
author.findtext("atom:name", default="", namespaces=namespace)
|
||||
)
|
||||
if name:
|
||||
authors.append(name)
|
||||
|
||||
categories: list[str] = []
|
||||
for category in entry.findall("atom:category", namespace):
|
||||
term = category.get("term", "").strip()
|
||||
if term and term not in categories:
|
||||
categories.append(term)
|
||||
|
||||
raw_id = entry.findtext("atom:id", default="", namespaces=namespace)
|
||||
arxiv_id = _normalize_arxiv_id(raw_id.rsplit("/", 1)[-1] if raw_id else "")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"title": title,
|
||||
"authors": authors,
|
||||
"arxiv_id": arxiv_id,
|
||||
"abstract": abstract,
|
||||
"categories": categories,
|
||||
"published": published,
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def search_arxiv(query: str, max_results: int) -> list[dict[str, Any]]:
|
||||
"""搜索 arXiv 并返回结构化论文结果。"""
|
||||
encoded_query = quote(query, safe="")
|
||||
url = ARXIV_API_URL.format(query=encoded_query, max=max_results)
|
||||
feed_bytes = _request_text(url)
|
||||
return _parse_arxiv_feed(feed_bytes)
|
||||
|
||||
|
||||
def download_arxiv_pdf(arxiv_id: str, target_dir: Path) -> Path:
|
||||
"""下载指定 arXiv 论文的 PDF 并校验文件大小。"""
|
||||
normalized_id = _normalize_arxiv_id(arxiv_id)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = target_dir / f"{normalized_id}.pdf"
|
||||
pdf_url = ARXIV_PDF_URL.format(arxiv_id=normalized_id)
|
||||
|
||||
request = Request(pdf_url, headers={"User-Agent": USER_AGENT})
|
||||
time.sleep(DOWNLOAD_DELAY_SECONDS)
|
||||
try:
|
||||
with urlopen(request) as response, output_path.open("wb") as handle:
|
||||
while True:
|
||||
chunk = response.read(8192)
|
||||
if not chunk:
|
||||
break
|
||||
handle.write(chunk)
|
||||
except Exception:
|
||||
if output_path.exists():
|
||||
output_path.unlink()
|
||||
raise
|
||||
|
||||
if output_path.stat().st_size <= MIN_PDF_SIZE:
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise ValueError(f"下载文件过小,疑似失败: {output_path}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="arXiv 搜索与 PDF 下载工具")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="搜索 arXiv 论文")
|
||||
search_parser.add_argument("query", help="搜索关键词")
|
||||
search_parser.add_argument("--max", type=int, default=10, help="最大返回数量")
|
||||
|
||||
download_parser = subparsers.add_parser("download", help="下载 arXiv PDF")
|
||||
download_parser.add_argument("arxiv_id", help="arXiv 论文 ID")
|
||||
download_parser.add_argument(
|
||||
"--dir", dest="target_dir", default=".", help="保存目录"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI 入口。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.command == "search":
|
||||
results = search_arxiv(args.query, args.max)
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
if args.command == "download":
|
||||
output_path = download_arxiv_pdf(args.arxiv_id, Path(args.target_dir))
|
||||
sys.stdout.write(f"{output_path}\n")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parser.error(f"未知命令: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
"""DeepXiv CLI 的轻量适配器。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import cast
|
||||
|
||||
if shutil.which("deepxiv") is None:
|
||||
print("警告: 未找到 deepxiv CLI,已跳过 DeepXiv 适配器。", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _run_deepxiv(arguments: list[str]) -> int:
|
||||
"""调用底层 deepxiv 命令并透传输出。"""
|
||||
completed = subprocess.run(["deepxiv", *arguments], check=False)
|
||||
return completed.returncode
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="DeepXiv 命令适配器")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
for command_name in ("search", "paper-brief", "paper-head", "paper-section"):
|
||||
subparser = subparsers.add_parser(
|
||||
command_name, help=f"转发 deepxiv {command_name} 命令"
|
||||
)
|
||||
subparser.add_argument(
|
||||
"args", nargs=argparse.REMAINDER, help="透传给 deepxiv 的参数"
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI 入口。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
command_args = [args.command, *cast(list[str], args.args)]
|
||||
return _run_deepxiv(command_args)
|
||||
except OSError as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Exa 搜索命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import exa
|
||||
except ImportError:
|
||||
print("警告: 未安装 exa-py SDK,已跳过 Exa CLI。", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _to_jsonable(value: Any) -> Any:
|
||||
"""将任意 SDK 返回值递归转换为可序列化对象。"""
|
||||
if value is None or isinstance(value, (str, int, float, bool)):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _to_jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [_to_jsonable(item) for item in value]
|
||||
if is_dataclass(value):
|
||||
return _to_jsonable(asdict(value))
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
return _to_jsonable(model_dump())
|
||||
if hasattr(value, "__dict__"):
|
||||
data = {
|
||||
key: _to_jsonable(item)
|
||||
for key, item in vars(value).items()
|
||||
if not key.startswith("_")
|
||||
}
|
||||
if data:
|
||||
return data
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_contents_option(content_mode: str) -> dict[str, Any]:
|
||||
"""根据命令行参数构造 Exa contents 配置。"""
|
||||
if content_mode == "text":
|
||||
return {"text": True}
|
||||
return {"highlights": True}
|
||||
|
||||
|
||||
def _extract_results(response: Any) -> list[dict[str, Any]]:
|
||||
"""从 Exa 响应中提取结果列表并标准化为 JSON 对象。"""
|
||||
raw_results: Any
|
||||
if isinstance(response, dict):
|
||||
raw_results = response.get("results", [])
|
||||
else:
|
||||
raw_results = getattr(response, "results", [])
|
||||
|
||||
if not isinstance(raw_results, list):
|
||||
return []
|
||||
|
||||
normalized_results: list[dict[str, Any]] = []
|
||||
for item in raw_results:
|
||||
normalized_item = _to_jsonable(item)
|
||||
if isinstance(normalized_item, dict):
|
||||
normalized_results.append(normalized_item)
|
||||
else:
|
||||
normalized_results.append({"value": normalized_item})
|
||||
return normalized_results
|
||||
|
||||
|
||||
def search_exa(
|
||||
query: str,
|
||||
max_results: int,
|
||||
category: str | None,
|
||||
content_mode: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""执行 Exa 搜索并返回结果列表。"""
|
||||
api_key = os.environ.get("EXA_API_KEY", "").strip()
|
||||
if not api_key:
|
||||
print("警告: 缺少 EXA_API_KEY 环境变量,已跳过 Exa CLI。", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
client = exa.Exa(api_key=api_key)
|
||||
search_kwargs: dict[str, Any] = {
|
||||
"num_results": max_results,
|
||||
"contents": _build_contents_option(content_mode),
|
||||
}
|
||||
if category:
|
||||
search_kwargs["category"] = category
|
||||
|
||||
response = client.search(query, **search_kwargs)
|
||||
return _extract_results(response)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="Exa 搜索工具")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="搜索 Exa 索引")
|
||||
search_parser.add_argument("query", help="搜索关键词")
|
||||
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
|
||||
search_parser.add_argument(
|
||||
"--category",
|
||||
default=None,
|
||||
help="数据类别,例如 research paper、news、pdf 等",
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--content",
|
||||
choices=("highlights", "text"),
|
||||
default="highlights",
|
||||
help="返回的内容类型",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI 入口。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.command == "search":
|
||||
results = search_exa(
|
||||
query=args.query,
|
||||
max_results=args.max_results,
|
||||
category=args.category,
|
||||
content_mode=args.content,
|
||||
)
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parser.error(f"未知命令: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,190 @@
|
||||
"""OpenAlex 搜索命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("警告: 未安装 requests,已跳过 OpenAlex CLI。", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
API_URL = "https://api.openalex.org/works"
|
||||
REQUEST_TIMEOUT_SECONDS = 30.0
|
||||
USER_AGENT = "openalex_fetch.py/1.0"
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
"""压缩空白并清理文本值。"""
|
||||
return " ".join(str(value).split())
|
||||
|
||||
|
||||
def _normalize_year_filter(raw_year: str) -> str:
|
||||
"""将命令行年份参数转换为 OpenAlex filter 语法。"""
|
||||
value = raw_year.strip()
|
||||
if not value:
|
||||
raise ValueError("年份参数不能为空")
|
||||
|
||||
if value.isdigit() and len(value) == 4:
|
||||
return f"publication_year:{value}"
|
||||
|
||||
if value.endswith("-") and value[:-1].strip().isdigit():
|
||||
start_year = value[:-1].strip()
|
||||
return f"from_publication_date:{start_year}-01-01"
|
||||
|
||||
if value.startswith("-") and value[1:].strip().isdigit():
|
||||
end_year = value[1:].strip()
|
||||
return f"to_publication_date:{end_year}-12-31"
|
||||
|
||||
if "-" in value:
|
||||
start_year, end_year = [part.strip() for part in value.split("-", 1)]
|
||||
if start_year.isdigit() and end_year.isdigit():
|
||||
return f"publication_year:{start_year}-{end_year}"
|
||||
|
||||
raise ValueError(f"无法识别的年份参数: {raw_year}")
|
||||
|
||||
|
||||
def _normalize_sort_value(sort_value: str) -> str:
|
||||
"""将用户输入的排序参数转换为 OpenAlex sort 语法。"""
|
||||
value = sort_value.strip()
|
||||
if not value:
|
||||
raise ValueError("排序参数不能为空")
|
||||
if value in {"relevance", "relevance_score"}:
|
||||
return "relevance_score:desc"
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_work(work: dict[str, Any]) -> dict[str, Any]:
|
||||
"""将 OpenAlex work 记录压缩成稳定输出结构。"""
|
||||
authorships = work.get("authorships")
|
||||
authors: list[str] = []
|
||||
institutions: list[str] = []
|
||||
if isinstance(authorships, list):
|
||||
for authorship in authorships:
|
||||
if not isinstance(authorship, dict):
|
||||
continue
|
||||
author = authorship.get("author")
|
||||
if isinstance(author, dict):
|
||||
display_name = author.get("display_name")
|
||||
if display_name:
|
||||
cleaned_name = _clean_text(display_name)
|
||||
if cleaned_name not in authors:
|
||||
authors.append(cleaned_name)
|
||||
author_institutions = authorship.get("institutions")
|
||||
if isinstance(author_institutions, list):
|
||||
for institution in author_institutions:
|
||||
if not isinstance(institution, dict):
|
||||
continue
|
||||
display_name = institution.get("display_name")
|
||||
if display_name:
|
||||
cleaned_name = _clean_text(display_name)
|
||||
if cleaned_name not in institutions:
|
||||
institutions.append(cleaned_name)
|
||||
|
||||
return {
|
||||
"title": _clean_text(work.get("display_name", "")),
|
||||
"authors": authors,
|
||||
"year": work.get("publication_year"),
|
||||
"cited_by_count": work.get("cited_by_count"),
|
||||
"institutions": institutions,
|
||||
"doi": work.get("doi"),
|
||||
}
|
||||
|
||||
|
||||
def search_openalex(
|
||||
query: str,
|
||||
max_results: int,
|
||||
year: str | None,
|
||||
work_type: str | None,
|
||||
sort: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""搜索 OpenAlex 并返回标准化论文结果。"""
|
||||
params: dict[str, Any] = {
|
||||
"search": query,
|
||||
"per-page": max_results,
|
||||
"sort": _normalize_sort_value(sort),
|
||||
}
|
||||
|
||||
filters: list[str] = []
|
||||
if year:
|
||||
filters.append(_normalize_year_filter(year))
|
||||
if work_type:
|
||||
filters.append(f"type:{work_type.strip()}")
|
||||
if filters:
|
||||
params["filter"] = ",".join(filters)
|
||||
|
||||
response = requests.get(
|
||||
API_URL,
|
||||
params=params,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("OpenAlex 返回了非对象类型的 JSON")
|
||||
|
||||
works = payload.get("results", [])
|
||||
if not isinstance(works, list):
|
||||
raise ValueError("OpenAlex 响应缺少 results 列表")
|
||||
|
||||
normalized_results: list[dict[str, Any]] = []
|
||||
for work in works:
|
||||
if isinstance(work, dict):
|
||||
normalized_results.append(_normalize_work(work))
|
||||
return normalized_results
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="OpenAlex 搜索工具")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="搜索 OpenAlex works")
|
||||
search_parser.add_argument("query", help="搜索关键词")
|
||||
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
|
||||
search_parser.add_argument("--year", default=None, help="年份或年份范围")
|
||||
search_parser.add_argument(
|
||||
"--type", dest="work_type", default=None, help="作品类型"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--sort",
|
||||
default="relevance",
|
||||
help="排序字段,relevance 会映射为 relevance_score:desc",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI 入口。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.command == "search":
|
||||
results = search_openalex(
|
||||
query=args.query,
|
||||
max_results=args.max_results,
|
||||
year=args.year,
|
||||
work_type=args.work_type,
|
||||
sort=args.sort,
|
||||
)
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parser.error(f"未知命令: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,681 @@
|
||||
"""研究 Wiki 的初始化与日志工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.request import urlopen
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ENTITY_TYPES = {
|
||||
"paper": "papers",
|
||||
"idea": "ideas",
|
||||
"experiment": "experiments",
|
||||
"claim": "claims",
|
||||
"gap": "gaps",
|
||||
"design": "designs",
|
||||
"finding": "findings",
|
||||
"adr": "adrs",
|
||||
"plan": "plans",
|
||||
"review": "reviews",
|
||||
"schema": "schemas",
|
||||
"metric": "metrics",
|
||||
}
|
||||
|
||||
EDGE_TYPES = [
|
||||
"extends",
|
||||
"contradicts",
|
||||
"supersedes",
|
||||
"addresses_gap",
|
||||
"inspired_by",
|
||||
"tested_by",
|
||||
"supports",
|
||||
"invalidates",
|
||||
"implements",
|
||||
"reveals",
|
||||
"informs",
|
||||
"refines",
|
||||
"depends_on",
|
||||
"measures",
|
||||
"evaluates",
|
||||
]
|
||||
|
||||
QUERY_PACK_BUDGET = 8000
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""将文本转换为 URL 安全的 slug。"""
|
||||
normalized = unicodedata.normalize("NFKD", text)
|
||||
normalized = re.sub(r"[^\w\s-]", "", normalized.lower())
|
||||
return re.sub(r"[-\s]+", "_", normalized).strip("_")[:60]
|
||||
|
||||
|
||||
def _write_if_missing(path: Path, content: str) -> None:
|
||||
"""仅在文件不存在时写入内容。
|
||||
|
||||
参数:
|
||||
path: 目标文件路径。
|
||||
content: 需要写入的文本内容。
|
||||
"""
|
||||
if path.exists():
|
||||
return
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def append_log(wiki_dir: str, message: str) -> None:
|
||||
"""向 Wiki 变更日志追加一条记录。
|
||||
|
||||
参数:
|
||||
wiki_dir: Wiki 根目录。
|
||||
message: 需要记录的消息。
|
||||
"""
|
||||
log_path = Path(wiki_dir) / "log.md"
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
with log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(f"- [{timestamp}] {message}\n")
|
||||
|
||||
|
||||
def _render_frontmatter(data: dict[str, Any]) -> str:
|
||||
"""将字典渲染为 YAML frontmatter。"""
|
||||
lines = ["---"]
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
rendered_value = json.dumps(value, ensure_ascii=False)
|
||||
elif isinstance(value, str) and " " in value:
|
||||
rendered_value = f'"{value}"'
|
||||
else:
|
||||
rendered_value = str(value)
|
||||
lines.append(f"{key}: {rendered_value}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _read_frontmatter(filepath: Path) -> dict[str, Any]:
|
||||
"""从 markdown 文件读取简易 frontmatter。"""
|
||||
text = filepath.read_text(encoding="utf-8")
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
|
||||
lines = text.splitlines()
|
||||
result: dict[str, Any] = {}
|
||||
for line in lines[1:]:
|
||||
if line == "---":
|
||||
break
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, _, raw_value = line.partition(":")
|
||||
value = raw_value.strip()
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
try:
|
||||
result[key.strip()] = json.loads(value)
|
||||
continue
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if len(value) >= 2 and (
|
||||
(value.startswith('"') and value.endswith('"'))
|
||||
or (value.startswith("'") and value.endswith("'"))
|
||||
):
|
||||
value = value[1:-1]
|
||||
result[key.strip()] = value
|
||||
return result
|
||||
|
||||
|
||||
def _add_node_to_graph(
|
||||
wiki_dir: str, node_id: str, label: str, entity_type: str
|
||||
) -> None:
|
||||
"""向关系图中添加节点,若已存在则跳过。"""
|
||||
graph_path = Path(wiki_dir) / "graph" / "edges.json"
|
||||
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
nodes = graph.setdefault("nodes", [])
|
||||
if not any(node.get("id") == node_id for node in nodes):
|
||||
nodes.append({"id": node_id, "label": label, "type": entity_type})
|
||||
graph_path.write_text(
|
||||
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def add_edge(
|
||||
wiki_dir: str,
|
||||
from_id: str,
|
||||
to_id: str,
|
||||
edge_type: str,
|
||||
evidence: str = "",
|
||||
) -> None:
|
||||
"""向关系图中添加一条边。"""
|
||||
if edge_type not in EDGE_TYPES:
|
||||
raise ValueError(f"未知边类型: {edge_type},合法值: {EDGE_TYPES}")
|
||||
|
||||
graph_path = Path(wiki_dir) / "graph" / "edges.json"
|
||||
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
links = graph.setdefault("links", [])
|
||||
|
||||
for link in links:
|
||||
if (
|
||||
link.get("source") == from_id
|
||||
and link.get("target") == to_id
|
||||
and link.get("relation") == edge_type
|
||||
):
|
||||
return
|
||||
|
||||
links.append(
|
||||
{
|
||||
"source": from_id,
|
||||
"target": to_id,
|
||||
"relation": edge_type,
|
||||
"evidence": evidence,
|
||||
"added": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
graph_path.write_text(
|
||||
json.dumps(graph, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
append_log(wiki_dir, f"新增边: {from_id} --{edge_type}--> {to_id}")
|
||||
|
||||
|
||||
def add_entity(
|
||||
wiki_dir: str,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
title: str,
|
||||
extra_frontmatter: dict[str, Any] | None = None,
|
||||
) -> Path:
|
||||
"""创建一个实体页面并同步到关系图。"""
|
||||
if entity_type not in ENTITY_TYPES:
|
||||
raise ValueError(f"未知实体类型: {entity_type},合法值: {list(ENTITY_TYPES)}")
|
||||
|
||||
root = Path(wiki_dir)
|
||||
entity_subdir = root / ENTITY_TYPES[entity_type]
|
||||
node_id = f"{entity_type}:{entity_id}"
|
||||
|
||||
for existing_path in entity_subdir.glob("*.md"):
|
||||
frontmatter = _read_frontmatter(existing_path)
|
||||
if frontmatter.get("node_id") == node_id:
|
||||
return existing_path
|
||||
|
||||
filepath = entity_subdir / f"{entity_id}.md"
|
||||
frontmatter: dict[str, Any] = {
|
||||
"type": entity_type,
|
||||
"node_id": node_id,
|
||||
"title": title,
|
||||
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
}
|
||||
if extra_frontmatter:
|
||||
frontmatter.update(extra_frontmatter)
|
||||
|
||||
content = _render_frontmatter(frontmatter) + f"\n# {title}\n\n"
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
|
||||
_add_node_to_graph(wiki_dir, node_id, title, entity_type)
|
||||
append_log(wiki_dir, f"新增 {entity_type}: {title} ({node_id})")
|
||||
return filepath
|
||||
|
||||
|
||||
def _fetch_arxiv_metadata(arxiv_id: str) -> dict[str, Any] | None:
|
||||
"""通过 arXiv Atom API 拉取论文元数据。"""
|
||||
clean_id = (
|
||||
arxiv_id.removeprefix("arxiv:")
|
||||
.removeprefix("arXiv:")
|
||||
.removeprefix("ARXIV:")
|
||||
.strip()
|
||||
)
|
||||
url = f"http://export.arxiv.org/api/query?id_list={clean_id}"
|
||||
|
||||
try:
|
||||
with urlopen(url) as response:
|
||||
root = ET.fromstring(response.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
namespace = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
entry = root.find("atom:entry", namespace)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
def _text(path: str) -> str:
|
||||
value = entry.findtext(path, default="", namespaces=namespace)
|
||||
return " ".join(value.split())
|
||||
|
||||
authors = [
|
||||
" ".join(author.findtext("atom:name", default="", namespaces=namespace).split())
|
||||
for author in entry.findall("atom:author", namespace)
|
||||
]
|
||||
authors = [author for author in authors if author]
|
||||
published = _text("atom:published")
|
||||
year = published[:4] if len(published) >= 4 else ""
|
||||
|
||||
return {
|
||||
"title": _text("atom:title"),
|
||||
"authors": authors,
|
||||
"year": year,
|
||||
"abstract": _text("atom:summary"),
|
||||
"arxiv_id": clean_id,
|
||||
}
|
||||
|
||||
|
||||
def _last_name(full_name: str) -> str:
|
||||
"""提取人名最后一个词作为姓氏。"""
|
||||
parts = full_name.strip().split()
|
||||
return parts[-1].lower() if parts else "unknown"
|
||||
|
||||
|
||||
def _markdown_body(text: str) -> str:
|
||||
"""提取 markdown 中 frontmatter 之后的正文。"""
|
||||
if not text.startswith("---\n"):
|
||||
return text
|
||||
|
||||
lines = text.splitlines()
|
||||
for index, line in enumerate(lines[1:], start=1):
|
||||
if line == "---":
|
||||
return "\n".join(lines[index + 1 :])
|
||||
return ""
|
||||
|
||||
|
||||
def _iter_entity_pages(wiki_dir: str) -> list[tuple[str, Path, dict[str, Any], str]]:
|
||||
"""收集 Wiki 中所有实体页面。"""
|
||||
root = Path(wiki_dir)
|
||||
pages: list[tuple[str, Path, dict[str, Any], str]] = []
|
||||
|
||||
for entity_type, subdir_name in ENTITY_TYPES.items():
|
||||
subdir = root / subdir_name
|
||||
for path in sorted(subdir.glob("*.md")):
|
||||
frontmatter = _read_frontmatter(path)
|
||||
body = _markdown_body(path.read_text(encoding="utf-8"))
|
||||
resolved_type = (
|
||||
str(frontmatter.get("type", entity_type)).strip() or entity_type
|
||||
)
|
||||
pages.append((resolved_type, path, frontmatter, body))
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def rebuild_index(wiki_dir: str) -> None:
|
||||
"""按实体类型重建索引页。"""
|
||||
root = Path(wiki_dir)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
pages = _iter_entity_pages(wiki_dir)
|
||||
|
||||
grouped: dict[str, list[tuple[Path, dict[str, Any]]]] = {}
|
||||
for entity_type, path, frontmatter, _ in pages:
|
||||
grouped.setdefault(entity_type, []).append((path, frontmatter))
|
||||
|
||||
lines = [
|
||||
"# Research Wiki 索引",
|
||||
"",
|
||||
f"> 自动生成,更新时间:{timestamp}",
|
||||
"",
|
||||
]
|
||||
|
||||
for entity_type in ENTITY_TYPES:
|
||||
entries = grouped.get(entity_type, [])
|
||||
if not entries:
|
||||
continue
|
||||
entries = sorted(
|
||||
entries,
|
||||
key=lambda item: (
|
||||
str(item[1].get("title", item[0].stem)).casefold(),
|
||||
item[0].name,
|
||||
),
|
||||
)
|
||||
lines.append(f"## {entity_type} ({len(entries)})")
|
||||
for path, frontmatter in entries:
|
||||
title = str(frontmatter.get("title", path.stem)).strip() or path.stem
|
||||
node_id = str(
|
||||
frontmatter.get("node_id", f"{entity_type}:{path.stem}")
|
||||
).strip()
|
||||
relative_path = path.relative_to(root).as_posix()
|
||||
lines.append(f"- [{title}]({relative_path}) `{node_id}`")
|
||||
lines.append("")
|
||||
|
||||
content = "\n".join(lines).rstrip() + "\n"
|
||||
(root / "index.md").write_text(content, encoding="utf-8")
|
||||
append_log(wiki_dir, f"重建索引: {len(pages)} 篇页面")
|
||||
|
||||
|
||||
def rebuild_query_pack(wiki_dir: str) -> None:
|
||||
"""基于 frontmatter 重新生成 Query Pack。"""
|
||||
pages = _iter_entity_pages(wiki_dir)
|
||||
|
||||
failed_ideas: list[tuple[str, str]] = []
|
||||
open_gaps: list[str] = []
|
||||
core_papers: list[tuple[str, str, str]] = []
|
||||
|
||||
for entity_type, path, frontmatter, _ in pages:
|
||||
title = str(frontmatter.get("title", path.stem)).strip() or path.stem
|
||||
if entity_type == "idea":
|
||||
outcome = str(frontmatter.get("outcome", "")).strip().lower()
|
||||
if outcome in {"negative", "failed"}:
|
||||
failure_reason = str(frontmatter.get("failure_reason", "")).strip()
|
||||
failed_ideas.append((title, failure_reason or "未填写"))
|
||||
if entity_type == "gap":
|
||||
status = str(frontmatter.get("status", "open")).strip().lower()
|
||||
if not status or status == "open":
|
||||
open_gaps.append(title)
|
||||
if entity_type == "paper":
|
||||
year = str(frontmatter.get("year", "")).strip()
|
||||
venue = str(frontmatter.get("venue", "")).strip()
|
||||
core_papers.append(
|
||||
(
|
||||
title,
|
||||
year or "未知年份",
|
||||
venue or "未知 venue",
|
||||
)
|
||||
)
|
||||
|
||||
failed_ideas.sort(key=lambda item: item[0].casefold())
|
||||
open_gaps.sort(key=str.casefold)
|
||||
core_papers.sort(key=lambda item: (item[1], item[0].casefold(), item[2].casefold()))
|
||||
|
||||
sections: list[tuple[str, list[str]]] = []
|
||||
if failed_ideas:
|
||||
sections.append(
|
||||
(
|
||||
"## 失败 Idea(禁区)",
|
||||
[f"- ❌ {title}: {reason}" for title, reason in failed_ideas[:10]],
|
||||
)
|
||||
)
|
||||
if open_gaps:
|
||||
sections.append(
|
||||
(
|
||||
"## 未解决空白",
|
||||
[f"- {title}" for title in open_gaps[:5]],
|
||||
)
|
||||
)
|
||||
if core_papers:
|
||||
sections.append(
|
||||
(
|
||||
"## 核心论文",
|
||||
[
|
||||
f"- [{title}] ({year}) {venue}"
|
||||
for title, year, venue in core_papers[:12]
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
content_parts = [
|
||||
"# Query Pack",
|
||||
"",
|
||||
"> 自动生成,请勿手动编辑。",
|
||||
"",
|
||||
"",
|
||||
]
|
||||
current_content = "\n".join(content_parts)
|
||||
|
||||
for section_title, section_lines in sections:
|
||||
section_text = "\n".join([section_title, *section_lines, "", ""])
|
||||
candidate = current_content + section_text
|
||||
if len(candidate) > QUERY_PACK_BUDGET:
|
||||
break
|
||||
current_content = candidate
|
||||
|
||||
content = current_content.rstrip() + "\n"
|
||||
(Path(wiki_dir) / "query_pack.md").write_text(content, encoding="utf-8")
|
||||
append_log(wiki_dir, f"重建 Query Pack: {len(content)} 字符")
|
||||
|
||||
|
||||
def get_stats(wiki_dir: str) -> str:
|
||||
"""统计 Wiki 规模与图谱信息。"""
|
||||
root = Path(wiki_dir)
|
||||
lines: list[str] = []
|
||||
|
||||
for entity_type, subdir_name in ENTITY_TYPES.items():
|
||||
count = len(list((root / subdir_name).glob("*.md")))
|
||||
lines.append(f"{entity_type}: {count}")
|
||||
|
||||
graph_path = root / "graph" / "edges.json"
|
||||
if graph_path.exists():
|
||||
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
graph = {"nodes": [], "links": []}
|
||||
|
||||
lines.append(f"nodes: {len(graph.get('nodes', []))}")
|
||||
lines.append(f"links: {len(graph.get('links', []))}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def lint_wiki(wiki_dir: str) -> list[str]:
|
||||
"""检查 Wiki 中的孤立节点与稀疏页面。"""
|
||||
root = Path(wiki_dir)
|
||||
issues: list[str] = []
|
||||
|
||||
graph_path = root / "graph" / "edges.json"
|
||||
if graph_path.exists():
|
||||
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
graph = {"nodes": [], "links": []}
|
||||
|
||||
linked_node_ids = {
|
||||
str(link.get("source", "")).strip()
|
||||
for link in graph.get("links", [])
|
||||
if str(link.get("source", "")).strip()
|
||||
}
|
||||
linked_node_ids.update(
|
||||
str(link.get("target", "")).strip()
|
||||
for link in graph.get("links", [])
|
||||
if str(link.get("target", "")).strip()
|
||||
)
|
||||
|
||||
for node in graph.get("nodes", []):
|
||||
node_id = str(node.get("id", "")).strip()
|
||||
if node_id and node_id not in linked_node_ids:
|
||||
label = str(node.get("label", "")).strip()
|
||||
issues.append(f"孤立节点: {node_id}" + (f" ({label})" if label else ""))
|
||||
|
||||
for _, path, _, body in _iter_entity_pages(wiki_dir):
|
||||
if len(body.strip()) < 50:
|
||||
relative_path = path.relative_to(root).as_posix()
|
||||
issues.append(f"稀疏页面: {relative_path} (正文 {len(body.strip())} 字符)")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def ingest_paper(
|
||||
wiki_dir: str,
|
||||
arxiv_id: str | None = None,
|
||||
title: str | None = None,
|
||||
authors: str | None = None,
|
||||
year: str | None = None,
|
||||
venue: str = "arXiv",
|
||||
thesis: str = "",
|
||||
) -> Path | None:
|
||||
"""创建 paper 实体并补充标准化正文模板。"""
|
||||
abstract = ""
|
||||
authors_list: list[str]
|
||||
paper_title = title
|
||||
paper_year = year
|
||||
paper_arxiv_id = None
|
||||
|
||||
if arxiv_id:
|
||||
metadata = _fetch_arxiv_metadata(arxiv_id)
|
||||
if metadata is None:
|
||||
return None
|
||||
paper_title = metadata["title"]
|
||||
authors_list = metadata["authors"]
|
||||
paper_year = metadata["year"]
|
||||
abstract = metadata["abstract"]
|
||||
paper_arxiv_id = metadata["arxiv_id"]
|
||||
else:
|
||||
if not (title and authors and year):
|
||||
raise ValueError("非 arXiv 论文需提供 --title、--authors、--year")
|
||||
authors_list = [
|
||||
author.strip() for author in authors.split(",") if author.strip()
|
||||
]
|
||||
if not authors_list:
|
||||
raise ValueError("authors 不能为空")
|
||||
|
||||
if not paper_title:
|
||||
raise ValueError("论文标题不能为空")
|
||||
if not paper_year:
|
||||
raise ValueError("论文年份不能为空")
|
||||
if not authors_list:
|
||||
raise ValueError("论文作者不能为空")
|
||||
|
||||
slug = f"{_last_name(authors_list[0])}{paper_year}_{slugify(paper_title)[:30]}"
|
||||
extra_frontmatter: dict[str, Any] = {
|
||||
"authors": authors_list,
|
||||
"year": paper_year,
|
||||
"venue": venue,
|
||||
}
|
||||
if paper_arxiv_id is not None:
|
||||
extra_frontmatter["arxiv_id"] = paper_arxiv_id
|
||||
|
||||
path = add_entity(wiki_dir, "paper", slug, paper_title, extra_frontmatter)
|
||||
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if "## 一句话论点" not in content:
|
||||
body = f"\n## 一句话论点\n\n{thesis or '待填写'}\n\n"
|
||||
if abstract:
|
||||
body += f"## 摘要\n\n> {' '.join(abstract.split())}\n\n"
|
||||
body += (
|
||||
"## 问题/空白\n\n"
|
||||
"## 方法\n\n"
|
||||
"## 关键结果\n\n"
|
||||
"## 局限性\n\n"
|
||||
"## 与本项目的相关性\n\n"
|
||||
)
|
||||
path.write_text(content + body, encoding="utf-8")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def init_wiki(wiki_dir: str) -> None:
|
||||
"""初始化研究 Wiki 的目录结构与基础文件。
|
||||
|
||||
参数:
|
||||
wiki_dir: Wiki 根目录。
|
||||
"""
|
||||
root = Path(wiki_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for entity_dir in ENTITY_TYPES.values():
|
||||
(root / entity_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
graph_dir = root / "graph"
|
||||
graph_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_write_if_missing(
|
||||
root / "index.md", "# Research Wiki 索引\n\n> 自动生成,请勿手动编辑。\n"
|
||||
)
|
||||
_write_if_missing(root / "log.md", "# Wiki 变更日志\n\n")
|
||||
_write_if_missing(root / "gap_map.md", "# 领域空白汇总\n\n")
|
||||
_write_if_missing(
|
||||
root / "query_pack.md",
|
||||
"# Query Pack\n\n> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。\n",
|
||||
)
|
||||
_write_if_missing(
|
||||
graph_dir / "edges.json",
|
||||
json.dumps({"directed": True, "nodes": [], "links": []}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
append_log(wiki_dir, "Wiki 初始化完成")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(description="研究 Wiki 工具")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
init_parser = subparsers.add_parser("init", help="初始化 Wiki")
|
||||
init_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
|
||||
log_parser = subparsers.add_parser("log", help="追加日志")
|
||||
log_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
log_parser.add_argument("message", nargs="+", help="日志内容")
|
||||
|
||||
entity_parser = subparsers.add_parser("add_entity", help="创建实体")
|
||||
entity_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
entity_parser.add_argument(
|
||||
"--type", required=True, choices=list(ENTITY_TYPES), help="实体类型"
|
||||
)
|
||||
entity_parser.add_argument("--id", required=True, help="实体 ID")
|
||||
entity_parser.add_argument("--title", required=True, help="实体标题")
|
||||
|
||||
edge_parser = subparsers.add_parser("add_edge", help="创建关系边")
|
||||
edge_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
edge_parser.add_argument(
|
||||
"--from", dest="from_id", required=True, help="起点节点 ID"
|
||||
)
|
||||
edge_parser.add_argument("--to", dest="to_id", required=True, help="终点节点 ID")
|
||||
edge_parser.add_argument(
|
||||
"--type", required=True, choices=EDGE_TYPES, dest="edge_type", help="边类型"
|
||||
)
|
||||
edge_parser.add_argument("--evidence", default="", help="证据说明")
|
||||
|
||||
ingest_parser = subparsers.add_parser("ingest_paper", help="导入论文")
|
||||
ingest_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
ingest_parser.add_argument("--arxiv-id", dest="arxiv_id", help="arXiv ID")
|
||||
ingest_parser.add_argument("--title", help="论文标题")
|
||||
ingest_parser.add_argument("--authors", help="作者,逗号分隔")
|
||||
ingest_parser.add_argument("--year", help="年份")
|
||||
ingest_parser.add_argument("--venue", default="arXiv", help="发表 venue")
|
||||
ingest_parser.add_argument("--thesis", default="", help="一句话论点")
|
||||
|
||||
rebuild_query_pack_parser = subparsers.add_parser(
|
||||
"rebuild_query_pack", help="重建 Query Pack"
|
||||
)
|
||||
rebuild_query_pack_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
|
||||
rebuild_index_parser = subparsers.add_parser("rebuild_index", help="重建索引")
|
||||
rebuild_index_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
|
||||
stats_parser = subparsers.add_parser("stats", help="输出统计信息")
|
||||
stats_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
|
||||
lint_parser = subparsers.add_parser("lint", help="检查 Wiki 问题")
|
||||
lint_parser.add_argument("wiki_dir", help="Wiki 根目录")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "init":
|
||||
init_wiki(args.wiki_dir)
|
||||
return
|
||||
|
||||
if args.command == "log":
|
||||
append_log(args.wiki_dir, " ".join(args.message))
|
||||
return
|
||||
|
||||
if args.command == "add_entity":
|
||||
add_entity(args.wiki_dir, args.type, args.id, args.title)
|
||||
return
|
||||
|
||||
if args.command == "add_edge":
|
||||
add_edge(args.wiki_dir, args.from_id, args.to_id, args.edge_type, args.evidence)
|
||||
return
|
||||
|
||||
if args.command == "ingest_paper":
|
||||
ingest_paper(
|
||||
args.wiki_dir,
|
||||
arxiv_id=args.arxiv_id,
|
||||
title=args.title,
|
||||
authors=args.authors,
|
||||
year=args.year,
|
||||
venue=args.venue,
|
||||
thesis=args.thesis,
|
||||
)
|
||||
return
|
||||
|
||||
if args.command == "rebuild_query_pack":
|
||||
rebuild_query_pack(args.wiki_dir)
|
||||
return
|
||||
|
||||
if args.command == "rebuild_index":
|
||||
rebuild_index(args.wiki_dir)
|
||||
return
|
||||
|
||||
if args.command == "stats":
|
||||
print(get_stats(args.wiki_dir))
|
||||
return
|
||||
|
||||
if args.command == "lint":
|
||||
issues = lint_wiki(args.wiki_dir)
|
||||
print("\n".join(issues) if issues else "未发现问题")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Semantic Scholar 搜索命令行工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("警告: 未安装 requests,已跳过 Semantic Scholar CLI。", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
API_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
|
||||
REQUEST_FIELDS = "title,authors,year,venue,citationCount,externalIds,tldr"
|
||||
REQUEST_INTERVAL_SECONDS = 1.0
|
||||
REQUEST_TIMEOUT_SECONDS = 30.0
|
||||
USER_AGENT = "semantic_scholar_fetch.py/1.0"
|
||||
_LAST_REQUEST_AT: float | None = None
|
||||
|
||||
|
||||
def _normalize_filter_values(values: list[str] | None) -> str | None:
|
||||
"""将过滤参数整理为 API 需要的逗号分隔字符串。"""
|
||||
if not values:
|
||||
return None
|
||||
|
||||
items: list[str] = []
|
||||
for value in values:
|
||||
for item in value.split(","):
|
||||
cleaned = item.strip()
|
||||
if cleaned:
|
||||
items.append(cleaned)
|
||||
|
||||
if not items:
|
||||
return None
|
||||
return ",".join(items)
|
||||
|
||||
|
||||
def _respect_rate_limit() -> None:
|
||||
"""确保连续请求之间至少间隔一秒。"""
|
||||
global _LAST_REQUEST_AT
|
||||
|
||||
now = time.monotonic()
|
||||
if _LAST_REQUEST_AT is not None:
|
||||
elapsed = now - _LAST_REQUEST_AT
|
||||
if elapsed < REQUEST_INTERVAL_SECONDS:
|
||||
time.sleep(REQUEST_INTERVAL_SECONDS - elapsed)
|
||||
|
||||
_LAST_REQUEST_AT = time.monotonic()
|
||||
|
||||
|
||||
def _clean_text(value: Any) -> str:
|
||||
"""压缩并清理任意文本值。"""
|
||||
return " ".join(str(value).split())
|
||||
|
||||
|
||||
def _normalize_authors(authors: Any) -> list[str]:
|
||||
"""将作者字段统一为作者姓名列表。"""
|
||||
normalized_authors: list[str] = []
|
||||
if not isinstance(authors, list):
|
||||
return normalized_authors
|
||||
|
||||
for author in authors:
|
||||
if isinstance(author, dict):
|
||||
name = author.get("name")
|
||||
if name:
|
||||
normalized_authors.append(_clean_text(name))
|
||||
elif author:
|
||||
normalized_authors.append(_clean_text(author))
|
||||
|
||||
return normalized_authors
|
||||
|
||||
|
||||
def _normalize_external_ids(external_ids: Any) -> dict[str, str]:
|
||||
"""将 externalIds 统一整理为字符串字典,并保留 arXiv 标识。"""
|
||||
normalized: dict[str, str] = {}
|
||||
if isinstance(external_ids, dict):
|
||||
for key, value in external_ids.items():
|
||||
if value is None:
|
||||
continue
|
||||
cleaned_value = _clean_text(value)
|
||||
if cleaned_value:
|
||||
normalized[str(key)] = cleaned_value
|
||||
|
||||
arxiv_id = (
|
||||
normalized.get("ArXiv") or normalized.get("arXiv") or normalized.get("arxiv")
|
||||
)
|
||||
if arxiv_id and "ArXiv" not in normalized:
|
||||
normalized["ArXiv"] = arxiv_id
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_tldr(tldr: Any) -> Any:
|
||||
"""保留 TLDR 字段原始结构,但去除明显的空字符串。"""
|
||||
if isinstance(tldr, dict):
|
||||
normalized_tldr: dict[str, Any] = {}
|
||||
for key, value in tldr.items():
|
||||
if isinstance(value, str):
|
||||
cleaned = _clean_text(value)
|
||||
if cleaned:
|
||||
normalized_tldr[key] = cleaned
|
||||
elif value is not None:
|
||||
normalized_tldr[key] = value
|
||||
return normalized_tldr or None
|
||||
return tldr
|
||||
|
||||
|
||||
def _normalize_paper(paper: dict[str, Any]) -> dict[str, Any]:
|
||||
"""将 API 返回的论文记录压缩成稳定的输出结构。"""
|
||||
return {
|
||||
"title": _clean_text(paper.get("title", "")),
|
||||
"authors": _normalize_authors(paper.get("authors")),
|
||||
"year": paper.get("year"),
|
||||
"venue": _clean_text(paper.get("venue", "")),
|
||||
"citationCount": paper.get("citationCount"),
|
||||
"externalIds": _normalize_external_ids(paper.get("externalIds")),
|
||||
"tldr": _normalize_tldr(paper.get("tldr")),
|
||||
}
|
||||
|
||||
|
||||
def _request_json(url: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""发起受限频率控制的 GET 请求并返回 JSON 对象。"""
|
||||
_respect_rate_limit()
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Semantic Scholar 返回了非对象类型的 JSON")
|
||||
return payload
|
||||
|
||||
|
||||
def search_papers(
|
||||
query: str,
|
||||
max_results: int,
|
||||
fields_of_study: list[str] | None = None,
|
||||
publication_types: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""搜索 Semantic Scholar 论文并返回标准化结果。"""
|
||||
params: dict[str, Any] = {
|
||||
"query": query,
|
||||
"limit": max_results,
|
||||
"fields": REQUEST_FIELDS,
|
||||
}
|
||||
|
||||
normalized_fields_of_study = _normalize_filter_values(fields_of_study)
|
||||
if normalized_fields_of_study is not None:
|
||||
params["fieldsOfStudy"] = normalized_fields_of_study
|
||||
|
||||
normalized_publication_types = _normalize_filter_values(publication_types)
|
||||
if normalized_publication_types is not None:
|
||||
params["publicationTypes"] = normalized_publication_types
|
||||
|
||||
payload = _request_json(API_URL, params)
|
||||
papers = payload.get("data", [])
|
||||
if not isinstance(papers, list):
|
||||
raise ValueError("Semantic Scholar 响应缺少 data 列表")
|
||||
|
||||
normalized_papers: list[dict[str, Any]] = []
|
||||
for paper in papers:
|
||||
if isinstance(paper, dict):
|
||||
normalized_papers.append(_normalize_paper(paper))
|
||||
return normalized_papers
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器。"""
|
||||
parser = argparse.ArgumentParser(description="Semantic Scholar 搜索工具")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
search_parser = subparsers.add_parser("search", help="搜索论文")
|
||||
search_parser.add_argument("query", help="搜索关键词")
|
||||
search_parser.add_argument("--max", dest="max_results", type=int, default=10)
|
||||
search_parser.add_argument(
|
||||
"--fields-of-study",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="按学科领域过滤",
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--publication-types",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="按出版类型过滤",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI 入口。"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.command == "search":
|
||||
results = search_papers(
|
||||
query=args.query,
|
||||
max_results=args.max_results,
|
||||
fields_of_study=args.fields_of_study,
|
||||
publication_types=args.publication_types,
|
||||
)
|
||||
json.dump(results, sys.stdout, ensure_ascii=False, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
parser.error(f"未知命令: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -189,3 +189,6 @@ graphify-out/
|
||||
|
||||
# reference 中的 ZIP 包(太大)
|
||||
reference/*.zip
|
||||
|
||||
# graphify 运行时状态(可再生)
|
||||
.graphify_version
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# .env — 敏感信息,不提交到 Git
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=
|
||||
LLM_API_URL=
|
||||
VLM_API_KEY=
|
||||
VLM_MODEL=
|
||||
VLM_API_URL=
|
||||
EMBED_BACKEND=
|
||||
EMBED_MODEL=
|
||||
EMBED_API_KEY=
|
||||
EMBED_API_URL=
|
||||
@@ -0,0 +1,41 @@
|
||||
Video-Tree-TRM build-tree related code package
|
||||
Source: /home/undergraduate/hyt/Video-Tree-TRM
|
||||
Created: 2026-07-06T22:26:58+08:00
|
||||
Scope: code/config/scripts/docs/tests needed for building TreeIndex; excludes built trees, videos, frames, logs, experiments, cache, and .env secrets.
|
||||
|
||||
Included files:
|
||||
main.py
|
||||
requirements.txt
|
||||
.env.example
|
||||
CLAUDE.md
|
||||
config/default.yaml
|
||||
config/videomme.yaml
|
||||
video_tree_trm/__init__.py
|
||||
video_tree_trm/config.py
|
||||
video_tree_trm/tree_index.py
|
||||
video_tree_trm/text_tree_builder.py
|
||||
video_tree_trm/video_tree_builder.py
|
||||
video_tree_trm/pipeline.py
|
||||
video_tree_trm/llm_client.py
|
||||
video_tree_trm/embeddings.py
|
||||
utils/__init__.py
|
||||
utils/logger_system.py
|
||||
scripts/build_tree_single.py
|
||||
scripts/build_trees_batch.py
|
||||
scripts/build_trees_from_mp4.sh
|
||||
scripts/build_trees_from_urls.sh
|
||||
scripts/build_videomme_trees.sh
|
||||
scripts/download_videos.sh
|
||||
scripts/_download_meta.py
|
||||
docs/architecture.md
|
||||
docs/TD.md
|
||||
docs/EXPERIMENT.md
|
||||
tests/conftest.py
|
||||
tests/unit/test_config.py
|
||||
tests/unit/test_tree_index.py
|
||||
tests/unit/test_text_tree_builder.py
|
||||
tests/unit/test_video_tree_builder.py
|
||||
tests/unit/test_pipeline.py
|
||||
tests/unit/test_embeddings.py
|
||||
tests/unit/test_llm_client.py
|
||||
tests/unit/test_embedding_persistence.py
|
||||
@@ -0,0 +1,230 @@
|
||||
# CLAUDE.md
|
||||
|
||||
> [!URGENT]
|
||||
> **研究性项目 (Research Project)**
|
||||
> 1. 本项目为 MVP(最小可行性产品),严禁过度工程化。
|
||||
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
||||
|
||||
## 1. 项目元数据 (Metadata)
|
||||
- **核心目标**: 构建结合TRM多层训话探索能力与PageIndex树状检索能力的新型Video RAG
|
||||
- **项目类型**: MVP / 研究性项目
|
||||
- **后端架构**: Python 3.11
|
||||
- **版本管理**: Git
|
||||
- **Conda 环境**: Video-Tree-TRM (Python 3.11)
|
||||
|
||||
## 2. 常用命令 (Commands)
|
||||
|
||||
### 2.1 Conda 环境管理
|
||||
|
||||
> [!CRITICAL]
|
||||
> **所有 Python 相关命令必须在 Video-Tree-TRM 环境中执行**
|
||||
> - 使用 `conda run -n Video-Tree-TRM <command>` 确保命令在正确环境中运行
|
||||
> - 或在命令前显式添加 `source activate Video-Tree-TRM &&`
|
||||
> - 如果需要使用llm可以依据 '.env'环境变量文件使用
|
||||
|
||||
```bash
|
||||
# 激活项目环境(交互式 shell)
|
||||
conda activate Video-Tree-TRM
|
||||
|
||||
# 推荐:使用 conda run 执行命令(自动使用正确环境)
|
||||
conda run -n Video-Tree-TRM pip install xxx
|
||||
conda run -n Video-Tree-TRM python -m pytest xxx #注意不能conda run -n Video-Tree-TRM pytest xxx,因为这样子pytest不会调用Video-Tree-TRM
|
||||
conda run -n Video-Tree-TRM python xxx
|
||||
|
||||
# 或者:在命令前激活环境
|
||||
source activate Video-Tree-TRM && xxx
|
||||
```
|
||||
|
||||
### 2.2 代码质量检查
|
||||
```bash
|
||||
# 代码格式化
|
||||
conda run -n Video-Tree-TRM ruff format xxx
|
||||
|
||||
# 代码检查并自动修复
|
||||
conda run -n Video-Tree-TRM ruff check xxx --fix
|
||||
```
|
||||
|
||||
|
||||
## 3. 标准作业程序 (Standard Operating Procedure)
|
||||
> **Agent 必须严格遵守以下生命周期执行任务:**
|
||||
|
||||
### Phase 1: 规划与设计 (Planning)
|
||||
1. **查阅规格 (Read Specs)&讨论**: 在撰写计划前,**必须**仔细阅读 `docs/` 下对应的文档与`.report`下的项目整理架构,并使用GitNexus MCP来了解整个项目的最新情况。对于不理解的地方请与人类进行多轮讨论,确保理解人类的设计意图。
|
||||
2. **计划 (Plan)**: 正式编码前,**必须**使用plan模式输出开发计划,内容必须严格包含:
|
||||
- **1.1 摘要 (Summary)**: 1-2句话的简单总结。
|
||||
- **1.2 审查点 (User Review Required)**: 明确列出整个计划中不清楚、需要用户审查和确认的部分。若无,请注明"无"。
|
||||
- **1.3 拟议变更 (Proposed Changes)**:
|
||||
- 以 **文件名 + 修改内容** 的形式列出。
|
||||
- 修改内容必须精确到 **函数/方法级别 (Function-level)**。
|
||||
- 明确标识 `[NEW]`, `[MODIFY]`, `[DELETE]`。
|
||||
- **1.4 验证计划 (Verification Plan)**: 具体描述如何验证修改是否成功(如具体的测试命令、预期日志输出等)。
|
||||
4. **等待 (Wait)**: **必须** 暂停并等待用户审核开发计划。用户批准后方可进入下一阶段。
|
||||
|
||||
### Phase 2: 执行与验证 (Execution & Verification)
|
||||
1. **编码 (Coding)**: 审核通过后,开始编写代码。
|
||||
2. **验证 (Verify)**:
|
||||
- **环境检查**: 确保所有命令在 Video-Tree-TRM 环境中执行(使用 `conda run -n Video-Tree-TRM`)
|
||||
- **运行验证命令**:
|
||||
- *失败*: 回到编码阶段修复,直到通过。
|
||||
- *成功*: 进入下一步。
|
||||
|
||||
## 4. 核心规则 (Rules)
|
||||
|
||||
### 4.1 代码开发规范 (Code Style)
|
||||
- **类型系统**: 强制所有函数签名包含完整类型注解 (`Union`, `Dict`, `Optional` 等)。
|
||||
- **文档**: 所有模块、类、方法必须包含 **中文 Docstring** (功能、参数、返回值、关键实现细节)。
|
||||
- **MVP原则**:
|
||||
- **必须** 必须在`tests/`目录下编写测试代码。
|
||||
- **严禁** 使用默认参数掩盖仅需逻辑(必须显式传递关键参数)。
|
||||
- **必需** 运行时检查:关键维度、设备一致性必须通过 assertion 或 if 验证。
|
||||
- **代码组织**:
|
||||
- 使用阶段化注释 (`# Phase 1`, `# Phase 2`) 组织复杂逻辑。
|
||||
- 接口返回值需包含完整诊断信息(输出、损失、统计),使用条件标志控制。
|
||||
- **命名与依赖**:
|
||||
- 类名 `PascalCase`,变量描述性命名,私有变量前缀 `_`。
|
||||
- 导入顺序:标准库 → 第三方库 → 项目内部。
|
||||
- **日志与错误处理**: 使用 `utils/logger_system.py` 的 `log_msg()`, `log_json()`, `ensure()`, `log_exception()`
|
||||
- 禁用 `print()`,`log_msg("ERROR")` 不自动抛出异常,输出到 `logs/system.log` + `logs/metrics.json`
|
||||
- **功能修改**:
|
||||
- **必须** 不考虑向后兼容,直接修改原文件。代码简洁性优先。
|
||||
|
||||
### 4.2 配置管理规范
|
||||
- **优先级**: CLI args > `.env` > YAML,三者统一归口到 dataclass
|
||||
- **文件**: `config/default.yaml`(全量非敏感配置,必须写全), `.env`(敏感信息,不提交), `.env.example`(模板)
|
||||
|
||||
### 4.3 测试组织规范
|
||||
- **目录**: `tests/{unit,integration,e2e}/test_*.py`,最低覆盖率 80%
|
||||
- **运行**: `conda run -n Video-Tree-TRM pytest tests/unit/ --cov=utils --cov-report=term-missing`
|
||||
|
||||
#### Agent 测试输出规范
|
||||
|
||||
> **除了使用pytest进行单元测试,还必须构建一个完善的main.py文件将所有过程串联起来,并必须将完整执行过程保存为 Markdown 文件,供 Claude Code 智能分析,以消除格式输出正确但是逻辑错误的误差或者实际输出质量低低问题。**
|
||||
|
||||
| 要素 | 规范 |
|
||||
|------|------|
|
||||
| **输出位置** | `tests/outputs/<test_module>/<test_name>_<timestamp>.md` |
|
||||
| **触发时机** | 所有涉及 Agent 执行的测试 |
|
||||
| **内容要求** | 任务描述、每步 Agent 输入/输出/推理过程、工具调用、最终结果 |
|
||||
| **格式要求** | 结构化 Markdown(标题、代码块、列表),人类可读 |
|
||||
| **分析方式** | Claude Code 读取 MD 文件,评估推理质量、任务完成度、代码正确性 |
|
||||
|
||||
**示例结构**:
|
||||
```markdown
|
||||
# Agent 测试: <test_name>
|
||||
## 任务: <task>
|
||||
## Step 1: <AgentName>
|
||||
- 输入: ...
|
||||
- 输出: ...
|
||||
- 推理: ...
|
||||
## Step 2: ...
|
||||
## 最终结果: ...
|
||||
```
|
||||
|
||||
**pytest 集成**: 使用 fixture 或工具类自动保存,测试结束后输出文件路径。
|
||||
|
||||
|
||||
## 5. 上下文获取与迷途指南 (Context & Navigation)
|
||||
|
||||
| 需求 | 文档路径 | 说明 |
|
||||
|------|----------|------|
|
||||
| 项目目标与背景 | `README.md` | 核心业务逻辑与项目定性 |
|
||||
| 架构与模块设计 | `.report/CODEMAPS/{architecture,backend,data}.md` | 整体架构、分层设计、模块依赖 |
|
||||
| 该项目最主要的参考项目 | `Reference/Tree-TRM`| 特定模块的详细设计 |
|
||||
| 其他参考项目 | `Reference/PageIndex_ Next-Generation Vectorless, Reasoning-based RAG.md`和 `Reference/Tree-TRM`| |
|
||||
|
||||
## 6. 输出规范
|
||||
|
||||
### 6.1 语言要求
|
||||
- 所有输出语言: **中文**
|
||||
|
||||
### 6.2 信息密度原则
|
||||
- **优先使用**:
|
||||
- 简洁文本描述
|
||||
- 伪代码(而非完整代码)
|
||||
- 表格(对比、配置、参数说明)
|
||||
- 流程图(Mermaid)
|
||||
- 项目符号列表
|
||||
- **避免使用**:
|
||||
- 大段完整代码(信息密度低,可读性差)
|
||||
- 冗长的自然语言解释
|
||||
- **核心原则**: 用最少的字符传递最多的信息
|
||||
`
|
||||
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **Video-Tree-TRM** (860 symbols, 2247 relationships, 70 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/Video-Tree-TRM/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/Video-Tree-TRM/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/Video-Tree-TRM/clusters` | All functional areas |
|
||||
| `gitnexus://repo/Video-Tree-TRM/processes` | All execution flows |
|
||||
| `gitnexus://repo/Video-Tree-TRM/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## CLI
|
||||
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
@@ -0,0 +1,57 @@
|
||||
tree:
|
||||
max_paragraphs_per_l2: 5
|
||||
l1_segment_duration: 600.0
|
||||
l2_clip_duration: 60.0
|
||||
l3_fps: 1.0
|
||||
l2_representative_frames: 10
|
||||
cache_dir: "cache/trees"
|
||||
concurrency: 16
|
||||
|
||||
embed:
|
||||
backend: "local" # "local" (sentence-transformers) | "remote" (OpenAI 兼容 API)
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 2560
|
||||
device: "cuda"
|
||||
api_key: "" # 远程模式从 .env 覆盖
|
||||
api_url: "" # 远程模式从 .env 覆盖
|
||||
|
||||
llm:
|
||||
backend: "qwen"
|
||||
model: "qwen-plus"
|
||||
api_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
max_tokens: 256
|
||||
temperature: 0.1
|
||||
# api_key: 从 .env 加载,此处不写
|
||||
|
||||
vlm:
|
||||
backend: "qwen"
|
||||
model: "qwen-vl-plus"
|
||||
api_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
max_tokens: 256
|
||||
temperature: 0.1
|
||||
# api_key: 从 .env 加载,此处不写
|
||||
|
||||
retriever:
|
||||
embed_dim: 2560
|
||||
num_heads: 4
|
||||
L_layers: 2
|
||||
L_cycles: 4
|
||||
max_rounds: 5
|
||||
ffn_expansion: 2.0
|
||||
checkpoint: null
|
||||
|
||||
train:
|
||||
lr: 1.0e-4
|
||||
weight_decay: 1.0e-5
|
||||
batch_size: 1
|
||||
max_epochs_phase1: 30
|
||||
max_epochs_phase2: 20
|
||||
nav_loss_weight: 1.0
|
||||
act_loss_weight: 0.1
|
||||
margin_loss_weight: 0.5
|
||||
act_lambda_step: 0.1
|
||||
act_gamma: 0.9
|
||||
eval_interval: 5
|
||||
save_dir: "checkpoints"
|
||||
dataset: "longbench"
|
||||
dataset_path: "data/longbench"
|
||||
@@ -0,0 +1,61 @@
|
||||
tree:
|
||||
max_paragraphs_per_l2: 5
|
||||
l1_segment_duration: 600.0 # L1: 每段 10 分钟(长视频适配)
|
||||
l2_clip_duration: 60.0 # L2: 每 clip 60 秒
|
||||
l3_fps: 0.5 # L3: 0.5 帧/秒(每 2 秒一帧)
|
||||
l2_representative_frames: 6 # L2 VLM 描述用的代表帧数(从10降到6以提速)
|
||||
cache_dir: "data/videomme/trees" # 树索引缓存目录(相对项目根目录)
|
||||
concurrency: 16 # asyncio Semaphore 上限:每视频最多 16 路同时在途 VLM 请求
|
||||
|
||||
embed:
|
||||
backend: "local" # CPU 本地运行,无需远程嵌入服务
|
||||
model_name: "BAAI/bge-base-zh-v1.5"
|
||||
embed_dim: 768 # bge-base-zh-v1.5 输出维度
|
||||
device: "cpu" # 本地 CPU 推理
|
||||
api_key: ""
|
||||
api_url: ""
|
||||
|
||||
llm:
|
||||
backend: "openai" # GPUStack 兼容 OpenAI API
|
||||
model: "gemma-4-31B"
|
||||
api_url: "http://100.83.164.94:11904/v1"
|
||||
max_tokens: 256
|
||||
temperature: 0.1
|
||||
# api_key: 从 .env 加载
|
||||
|
||||
vlm:
|
||||
backend: "openai" # GPUStack 兼容 OpenAI API
|
||||
model: "gemma-4-31B"
|
||||
api_url: "http://100.83.164.94:11904/v1"
|
||||
max_tokens: 512 # 5帧描述 ~300 tokens,256 会截断 JSON 触发 fallback
|
||||
temperature: 0.1
|
||||
# api_key: 从 .env 加载
|
||||
|
||||
retriever:
|
||||
embed_dim: 768 # 与 bge-base-zh-v1.5 维度一致
|
||||
num_heads: 4
|
||||
L_layers: 2
|
||||
L_cycles: 4
|
||||
max_rounds: 5
|
||||
ffn_expansion: 2.0
|
||||
checkpoint: null
|
||||
k_l1: 1
|
||||
k_l2: 1
|
||||
k_l3: 1
|
||||
max_paths: 5
|
||||
|
||||
train:
|
||||
lr: 1.0e-4
|
||||
weight_decay: 1.0e-5
|
||||
batch_size: 1
|
||||
max_epochs_phase1: 30
|
||||
max_epochs_phase2: 20
|
||||
nav_loss_weight: 1.0
|
||||
act_loss_weight: 0.1
|
||||
margin_loss_weight: 0.5
|
||||
act_lambda_step: 0.1
|
||||
act_gamma: 0.9
|
||||
eval_interval: 5
|
||||
save_dir: "data/videomme/checkpoints"
|
||||
dataset: "videomme"
|
||||
dataset_path: "data/videomme/splits/train.jsonl"
|
||||
@@ -0,0 +1,330 @@
|
||||
# Video-Tree-TRM 实验计划
|
||||
|
||||
> **目标**: 验证结合 TRM 多层推理探索能力(Cross-Attention Selector + ACT Halt)
|
||||
> 与 PageIndex 树状检索能力的 Video-Tree-TRM 在长文本和长视频问答任务上的效果。
|
||||
>
|
||||
> **两条并行实验线路**:
|
||||
> - **线路 A** — 长文本检索(LongBench / NarrativeQA)
|
||||
> - **线路 B** — 长视频检索(VideoMME)
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [评测体系](#1-评测体系)
|
||||
- [实验环境准备](#2-实验环境准备)
|
||||
- [线路 A:长文本检索](#3-线路-a长文本检索)
|
||||
- [线路 B:长视频检索](#4-线路-b长视频检索)
|
||||
- [基线方法](#5-基线方法)
|
||||
- [消融实验](#6-消融实验)
|
||||
- [里程碑检查表](#7-里程碑检查表)
|
||||
- [结果记录表](#8-结果记录表)
|
||||
|
||||
---
|
||||
|
||||
## 1. 评测体系
|
||||
|
||||
### 1.1 质量指标
|
||||
|
||||
| 指标 | 适用模态 | 计算方式 |
|
||||
|------|---------|---------|
|
||||
| **EM (Exact Match)** | 文本 QA | 标准化后精确字符串匹配,0/1 |
|
||||
| **F1** | 文本 QA | token 级 precision/recall,`token_f1()` 已实现于 `answer_generator.py` |
|
||||
| **Accuracy** | 视频 QA(多选) | 模型选项与标准答案选项完全匹配的比率 |
|
||||
|
||||
### 1.2 效率指标
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| **Avg Rounds** | 所有样本的平均检索轮次(衡量 ACT Halt 效果,越少越好) |
|
||||
| **Max Rounds Hit Rate** | 触达 `max_rounds` 上限而非主动停止的样本比率 |
|
||||
|
||||
### 1.3 诊断指标
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| **Nav Accuracy @L1** | 第一轮检索选中正确 L1 节点的比率 |
|
||||
| **Nav Accuracy @L2** | 在正确 L1 下选中正确 L2 节点的比率 |
|
||||
| **Nav Accuracy @L3** | 在正确 L2 下选中正确 L3 节点的比率 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 实验环境准备
|
||||
|
||||
### 2.1 安装依赖
|
||||
|
||||
```bash
|
||||
conda activate Video-Tree-TRM
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2.2 配置文件
|
||||
|
||||
复制并填写环境变量:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 填写 LLM_API_KEY / VLM_API_KEY / EMBED_API_KEY(若使用远程嵌入)
|
||||
```
|
||||
|
||||
默认配置文件 `config/default.yaml` 已预置:
|
||||
|
||||
```yaml
|
||||
embed.model_name: "BAAI/bge-base-zh-v1.5" # 嵌入模型
|
||||
retriever.embed_dim: 2560
|
||||
retriever.max_rounds: 5
|
||||
train.max_epochs_phase1: 30
|
||||
train.max_epochs_phase2: 20
|
||||
```
|
||||
|
||||
> 消融实验时通过 `--set key=value` 覆盖,无需修改 yaml 文件。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线路 A:长文本检索
|
||||
|
||||
> **优先级**: P0(LongBench)→ P1(NarrativeQA)
|
||||
> **并行方式**: 与线路 B 同步推进,互不阻塞
|
||||
|
||||
### A.1 数据集准备
|
||||
|
||||
| 数据集 | 样本量 | 格式 | 优先级 |
|
||||
|--------|--------|------|--------|
|
||||
| **LongBench** | ~5K | JSONL `{"query", "answer", "source_path", "modality": "text"}` | P0 |
|
||||
| **NarrativeQA** | ~30K | 同上 | P1 |
|
||||
|
||||
```bash
|
||||
# 下载 LongBench(示例)
|
||||
mkdir -p data/longbench data/narrativeqa
|
||||
# 将转换后的 JSONL 文件放置于对应目录
|
||||
```
|
||||
|
||||
配置覆盖:
|
||||
|
||||
```bash
|
||||
# LongBench
|
||||
--set train.dataset=longbench --set train.dataset_path=data/longbench
|
||||
|
||||
# NarrativeQA(P1,LongBench 完成后)
|
||||
--set train.dataset=narrativeqa --set train.dataset_path=data/narrativeqa
|
||||
```
|
||||
|
||||
### A.2 索引构建
|
||||
|
||||
```bash
|
||||
# 对所有文本文件批量构建 TreeIndex(含磁盘缓存)
|
||||
conda run -n Video-Tree-TRM python main.py index \
|
||||
--source data/longbench/doc.txt \
|
||||
--modality text \
|
||||
--config config/default.yaml
|
||||
```
|
||||
|
||||
> 缓存命中后重复运行零开销,构建结果保存至 `cache/trees/`。
|
||||
|
||||
### A.3 两阶段训练
|
||||
|
||||
```bash
|
||||
# Phase 1:导航训练(单轮,~30 epoch)
|
||||
conda run -n Video-Tree-TRM python train.py \
|
||||
--config config/default.yaml \
|
||||
--set train.dataset=longbench \
|
||||
--set train.dataset_path=data/longbench
|
||||
|
||||
# Phase 2 权重加载自 Phase 1 最佳检查点
|
||||
# 在 config/default.yaml 中设置:
|
||||
# retriever.checkpoint: checkpoints/phase1_epoch30.pt
|
||||
# train.max_epochs_phase2: 20
|
||||
conda run -n Video-Tree-TRM python train.py \
|
||||
--config config/default.yaml \
|
||||
--set retriever.checkpoint=checkpoints/phase1_epoch30.pt
|
||||
```
|
||||
|
||||
训练检查点保存至 `checkpoints/phase1_epoch{N}.pt` / `phase2_epoch{N}.pt`。
|
||||
|
||||
### A.4 推理评测
|
||||
|
||||
```bash
|
||||
# 单样本问答验证
|
||||
conda run -n Video-Tree-TRM python main.py query \
|
||||
--source data/longbench/doc.txt \
|
||||
--modality text \
|
||||
--question "文档的核心观点是什么?" \
|
||||
--config config/default.yaml
|
||||
```
|
||||
|
||||
批量评测(自行实现评测脚本):
|
||||
- 遍历测试集 JSONL
|
||||
- 调用 `Pipeline.query()` 获取预测答案
|
||||
- 计算 EM / F1,汇总 Avg Rounds
|
||||
|
||||
### A.5 基线对比
|
||||
|
||||
见 [第 5 节](#5-基线方法)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 线路 B:长视频检索
|
||||
|
||||
> **优先级**: P2(可与线路 A 并行推进)
|
||||
|
||||
### B.1 数据集准备
|
||||
|
||||
| 数据集 | 样本量 | 格式 | 评测指标 |
|
||||
|--------|--------|------|---------|
|
||||
| **VideoMME** | ~2K | JSONL `{"query", "answer", "source_path", "modality": "video", "timestamp": float}` | Accuracy(多选) |
|
||||
|
||||
```bash
|
||||
mkdir -p data/videomme
|
||||
# 下载 VideoMME 并转换为上述 JSONL 格式
|
||||
```
|
||||
|
||||
### B.2 索引构建
|
||||
|
||||
```bash
|
||||
conda run -n Video-Tree-TRM python main.py index \
|
||||
--source data/videomme/video.mp4 \
|
||||
--modality video \
|
||||
--config config/default.yaml
|
||||
```
|
||||
|
||||
关键配置:
|
||||
|
||||
```yaml
|
||||
tree:
|
||||
l1_segment_duration: 600.0 # L1 段时长(秒)
|
||||
l2_clip_duration: 60.0 # L2 clip 时长(秒)
|
||||
l3_fps: 1.0 # L3 帧提取频率
|
||||
l2_representative_frames: 10 # VLM 描述用代表帧数
|
||||
```
|
||||
|
||||
### B.3 两阶段训练
|
||||
|
||||
```bash
|
||||
# Phase 1(视频模态)
|
||||
conda run -n Video-Tree-TRM python train.py \
|
||||
--config config/default.yaml \
|
||||
--set train.dataset=videomme \
|
||||
--set train.dataset_path=data/videomme
|
||||
|
||||
# Phase 2(视频模态,加载 Phase 1 检查点)
|
||||
conda run -n Video-Tree-TRM python train.py \
|
||||
--config config/default.yaml \
|
||||
--set train.dataset=videomme \
|
||||
--set train.dataset_path=data/videomme \
|
||||
--set retriever.checkpoint=checkpoints/phase1_epoch30.pt
|
||||
```
|
||||
|
||||
### B.4 推理评测
|
||||
|
||||
```bash
|
||||
conda run -n Video-Tree-TRM python main.py query \
|
||||
--source data/videomme/video.mp4 \
|
||||
--modality video \
|
||||
--question "视频中发生了什么事?" \
|
||||
--config config/default.yaml
|
||||
```
|
||||
|
||||
批量评测:遍历测试集 → `Pipeline.query()` → 与多选选项比对 → 计算 Accuracy。
|
||||
|
||||
### B.5 基线对比
|
||||
|
||||
见 [第 5 节](#5-基线方法)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 基线方法
|
||||
|
||||
| 方法 | 描述 | 实现方式 |
|
||||
|------|------|---------|
|
||||
| **BM25 + LLM** | 传统稀疏检索 | `rank_bm25` 库检索 top-k 段落 → 拼接上下文 → `LLMClient.chat()` |
|
||||
| **Dense Retrieval + LLM** | BGE 向量检索 + rerank | `EmbeddingModel.embed_tensor()` 全量检索 top-k → rerank → 生成 |
|
||||
| **PageIndex(无 TRM)** | 树状导航,cosine 路由,无推理模块 | 替换 `CrossAttentionSelector` 为 cosine 相似度选节点 |
|
||||
| **Tree-TRM(原论文)** | 原始实现 | 参考 `Reference/Tree-TRM/` 目录 |
|
||||
| **Video-Tree-TRM(ours)** | 本项目实现 | `Pipeline.query()` |
|
||||
|
||||
评测时各方法使用相同数据集和评测脚本,确保公平对比。
|
||||
|
||||
---
|
||||
|
||||
## 6. 消融实验
|
||||
|
||||
在主实验(LongBench)最优配置基础上,逐一变更单一变量:
|
||||
|
||||
| 编号 | 变量 | 候选值 | 配置覆盖 | 预期观察 |
|
||||
|------|------|--------|---------|---------|
|
||||
| **A1** | 选择器类型 | Cross-Attention vs Cosine | 替换 `CrossAttentionSelector` 实现 | CA 路由是否带来 F1 提升 |
|
||||
| **A2** | 推理深度 | L_cycles ∈ {1, 2, 4, 8} | `--set retriever.L_cycles=N` | 质量-计算量权衡 |
|
||||
| **A3** | 推理模块层数 | L_layers ∈ {1, 2, 4} | `--set retriever.L_layers=N` | 网络深度的边际收益 |
|
||||
| **A4** | 多轮检索上限 | max_rounds ∈ {1, 3, 5} | `--set retriever.max_rounds=N` | ACT 多轮边际收益 |
|
||||
| **A5** | ACT Halt 机制 | 有 / 无 | `act_loss_weight=0.0` 禁用 | ACT 对效率和质量的贡献 |
|
||||
| **A6** | 注意力头数 | num_heads ∈ {1, 4, 8} | `--set retriever.num_heads=N` | 多头注意力的容量影响 |
|
||||
|
||||
每组消融实验保持其余超参数为默认值(`config/default.yaml`)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 里程碑检查表
|
||||
|
||||
### 线路 A(文本)
|
||||
|
||||
- [ ] 环境配置完成,`python main.py --help` 正常输出
|
||||
- [ ] LongBench 数据集下载 & 转换为 JSONL 格式
|
||||
- [ ] LongBench 索引构建完成(`cache/trees/` 缓存生成)
|
||||
- [ ] Phase 1 训练完成(文本,LongBench)
|
||||
- [ ] Phase 2 训练完成(文本,LongBench)
|
||||
- [ ] LongBench 批量评测完成(EM / F1 / Avg Rounds)
|
||||
- [ ] 4 条基线方法评测完成
|
||||
- [ ] NarrativeQA 实验(P1,可选)
|
||||
|
||||
### 线路 B(视频)
|
||||
|
||||
- [ ] VideoMME 数据集下载 & 转换为 JSONL 格式
|
||||
- [ ] VideoMME 索引构建完成(视频帧提取 + VLM 描述生成)
|
||||
- [ ] Phase 1 训练完成(视频,VideoMME)
|
||||
- [ ] Phase 2 训练完成(视频,VideoMME)
|
||||
- [ ] VideoMME 批量评测完成(Accuracy / Avg Rounds)
|
||||
|
||||
### 消融实验
|
||||
|
||||
- [ ] A1: Cross-Attention vs Cosine 路由
|
||||
- [ ] A2: L_cycles 扫描(1/2/4/8)
|
||||
- [ ] A3: L_layers 扫描(1/2/4)
|
||||
- [ ] A4: max_rounds 扫描(1/3/5)
|
||||
- [ ] A5: 有/无 ACT Halt
|
||||
- [ ] A6: num_heads 扫描(1/4/8)
|
||||
|
||||
---
|
||||
|
||||
## 8. 结果记录表
|
||||
|
||||
### 8.1 主实验结果
|
||||
|
||||
| 方法 | LongBench EM | LongBench F1 | VideoMME Acc | Avg Rounds |
|
||||
|------|-------------|-------------|-------------|-----------|
|
||||
| BM25 + LLM | | | | — |
|
||||
| Dense Retrieval + LLM | | | | — |
|
||||
| PageIndex(无 TRM) | | | | |
|
||||
| Tree-TRM(原论文) | | | | |
|
||||
| **Video-Tree-TRM(ours)** | | | | |
|
||||
|
||||
### 8.2 消融实验结果(LongBench F1)
|
||||
|
||||
| 变量 | 值 | F1 | Avg Rounds | 备注 |
|
||||
|------|----|----|-----------|------|
|
||||
| 选择器 | Cross-Attention | | | 默认 |
|
||||
| 选择器 | Cosine | | | A1 |
|
||||
| L_cycles | 1 | | | A2 |
|
||||
| L_cycles | 2 | | | A2 |
|
||||
| L_cycles | 4 | | | A2 默认 |
|
||||
| L_cycles | 8 | | | A2 |
|
||||
| L_layers | 1 | | | A3 |
|
||||
| L_layers | 2 | | | A3 默认 |
|
||||
| L_layers | 4 | | | A3 |
|
||||
| max_rounds | 1 | | | A4 |
|
||||
| max_rounds | 3 | | | A4 |
|
||||
| max_rounds | 5 | | | A4 默认 |
|
||||
| ACT | 有 | | | A5 默认 |
|
||||
| ACT | 无 | | | A5 |
|
||||
| num_heads | 1 | | | A6 |
|
||||
| num_heads | 4 | | | A6 默认 |
|
||||
| num_heads | 8 | | | A6 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
# Video-Tree-TRM 统一架构设计
|
||||
|
||||
## 1. 核心定位
|
||||
|
||||
结合 TRM 递归推理 + PageIndex 树状导航的 **模态无关 RAG 系统**。
|
||||
|
||||
```
|
||||
设计原则:
|
||||
- 检索阶段模态无关(全文本嵌入空间)
|
||||
- 模态差异封装在两端(预处理 + 答案生成)
|
||||
- TRM ACT 机制控制动态检索深度
|
||||
- 树深度固定 3 层,检索轮次动态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 系统总览
|
||||
|
||||
```
|
||||
┌──────────────────── 预处理(离线,一次性) ────────────────────┐
|
||||
│ │
|
||||
│ 原始输入 统一表示 │
|
||||
│ ┌──────────┐ ┌──────────────────────┐ │
|
||||
│ │ 长文本 │──TextTree──→│ │ │
|
||||
│ └──────────┘ Builder │ TreeIndex │ │
|
||||
│ ┌──────────┐ │ (全文本嵌入, [D]) │ │
|
||||
│ │ 长视频 │──VideoTree─→│ │ │
|
||||
│ └──────────┘ Builder └──────────┬───────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────────────────│───────────────────────┘
|
||||
│
|
||||
┌──────────────────── 检索(在线) ─────│───────────────────────┐
|
||||
│ ↓ │
|
||||
│ query ──text_embed──→ q ──→ RecursiveRetriever │
|
||||
│ │ TRM 双循环推理 │
|
||||
│ │ ACT halt 动态停止 │
|
||||
│ │ 多轮 root-to-leaf 遍历 │
|
||||
│ └──→ List[NodePath] │
|
||||
│ │ │
|
||||
└─────────────────────────────────────│─────────────────────────┘
|
||||
│
|
||||
┌──────────────────── 生成(在线) ────│─────────────────────────┐
|
||||
│ ↓ │
|
||||
│ 文本模式: LLM(query, raw_text_chunks) → answer │
|
||||
│ 视频模式: VLM(query, frame_images) → answer │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TreeIndex:统一数据结构
|
||||
|
||||
三层固定深度,文本/视频同构。
|
||||
|
||||
```
|
||||
TreeIndex
|
||||
├── metadata: IndexMeta # 来源、嵌入器版本、创建时间
|
||||
├── embed_dim: int # 嵌入维度 D
|
||||
└── roots: List[L1Node] # L1 节点列表(树的根层)
|
||||
|
||||
L1Node (段/章)
|
||||
├── id: str
|
||||
├── summary: str # 聚合自子 L2 描述 (2-3句)
|
||||
├── embedding: ndarray [D] # text_embed(summary)
|
||||
├── time_range: (float, float) # 仅视频模式
|
||||
└── children: List[L2Node]
|
||||
|
||||
L2Node (片段/小节)
|
||||
├── id: str
|
||||
├── description: str # 直接从原始内容生成 (1-2句)
|
||||
├── embedding: ndarray [D] # text_embed(description)
|
||||
├── time_range: (float, float) # 仅视频模式
|
||||
└── children: List[L3Node]
|
||||
|
||||
L3Node (帧/文本块)
|
||||
├── id: str
|
||||
├── description: str # 视频=VLM帧描述(继承L2上下文), 文本=原始段落文本
|
||||
├── embedding: ndarray [D] # text_embed(description)
|
||||
├── raw_content: str # 原始文本块(文本模式)
|
||||
├── frame_path: Optional[str] # 帧图像路径(视频模式)
|
||||
└── timestamp: Optional[float] # 帧时间戳(视频模式)
|
||||
```
|
||||
|
||||
**关键设计**:`embedding` 字段全部来自同一个 `text_embed()` 函数,不存在跨模态嵌入空间问题。`raw_content` / `frame_path` 仅供答案生成阶段使用,检索器不关心。
|
||||
|
||||
---
|
||||
|
||||
## 4. 预处理管线
|
||||
|
||||
### 4.1 摘要粒度规范
|
||||
|
||||
各层摘要的唯一职责是**生成好的路由嵌入**——让 `(q+z)·M^T/√D` 能选对节点。
|
||||
|
||||
| 层级 | 路由功能 | 摘要目标 | 粒度 |
|
||||
|------|----------|----------|------|
|
||||
| L1 | 粗路由:选主题区域 | "这个区域**关于什么**" | 2-3句:主题 + 覆盖范围 + 关键实体 |
|
||||
| L2 | 细聚焦:选具体片段 | "这个片段**发生了什么**" | 1-2句:具体事件/内容 + 区分性细节 |
|
||||
| L3(文本) | 精确定位:选文本块 | 段落原文 | 原始段落文本,不做摘要 |
|
||||
| L3(视频) | 精确定位:选帧 | "这帧画面的**具体内容**" | 1-2句:继承L2上下文,聚焦区分性视觉信息 |
|
||||
|
||||
```
|
||||
关键原则:
|
||||
- L1 要宽: 涵盖其下所有 L2 的语义范围,让相关 query 能"进对门"
|
||||
- L2 要窄: 只描述自己,与同级兄弟节点形成区分
|
||||
- L3 要具体: 提供精确定位的细节信息
|
||||
```
|
||||
|
||||
**文本示例**:
|
||||
```
|
||||
L1: "第三章讨论了用户认证系统的设计,涵盖OAuth流程、JWT令牌管理和权限控制。"
|
||||
L2: "OAuth 2.0授权码流程的实现,包括重定向和回调处理。"
|
||||
L2: "JWT令牌的生成、验证和刷新机制。"
|
||||
L2: "基于角色的权限控制模型(RBAC)。"
|
||||
```
|
||||
|
||||
**视频示例**:
|
||||
```
|
||||
L1: "厨师在厨房制作意大利面,从准备食材到最终装盘。"
|
||||
L2: "切洋葱、蒜末和番茄,准备酱料食材。"
|
||||
L3: "[准备酱料食材中] 厨师左手按住洋葱,右手快速切丁,砧板上已有切好的蒜末。"
|
||||
L3: "[准备酱料食材中] 厨师将番茄放入沸水中烫皮,锅中冒出蒸汽。"
|
||||
L2: "煮面条并制作番茄肉酱。"
|
||||
L2: "装盘摆盘并撒上帕尔马干酪。"
|
||||
```
|
||||
|
||||
### 4.2 构建顺序:L2 轴心策略
|
||||
|
||||
两种模态共享同一构建原则:**L2 为轴心,向下展开 L3,向上聚合 L1**。
|
||||
|
||||
```
|
||||
构建依赖关系:
|
||||
|
||||
L3 需要 L2 上下文才能生成高质量描述(视频尤为关键)
|
||||
L1 摘要应从 L2 聚合,确保覆盖面完整
|
||||
→ 循环依赖 → 解法: L2 不依赖 L3,而是从原始内容独立生成
|
||||
|
||||
构建顺序:
|
||||
|
||||
Step 1: 结构切分 → 确定 L1/L2 边界
|
||||
Step 2: L2 先行 → 从原始内容直接生成 L2 描述
|
||||
Step 3: L3 向下 → 注入 L2 上下文,生成细粒度描述
|
||||
Step 4: L1 向上 → 聚合 L2 描述,生成粗粒度摘要
|
||||
```
|
||||
|
||||
```
|
||||
Step 4: 聚合
|
||||
↑
|
||||
┌─────── L1 ───────┐ "第三章讨论了认证系统..."
|
||||
│ │ ← LLM("总结这些片段: " + L2_descriptions)
|
||||
│ Step 2: 先行 │
|
||||
├── L2 ── L2 ── L2 ┤ "OAuth流程的实现..."
|
||||
│ │ │ │ │ ← 文本: LLM 摘要段落组
|
||||
│ ↓ ↓ ↓ │ 视频: VLM 看少量代表帧
|
||||
│ L3s L3s L3s │
|
||||
│ Step 3: 向下 │ "厨师左手按住洋葱..."
|
||||
└────────────────────┘ ← 注入 L2 上下文后生成
|
||||
```
|
||||
|
||||
### 4.3 TextTreeBuilder
|
||||
|
||||
```
|
||||
输入: 长文本文档
|
||||
输出: TreeIndex(全部 embedding=None)
|
||||
|
||||
Pipeline:
|
||||
Step 1 — 结构切分
|
||||
有 ToC → 解析章节层级 → L1/L2 边界
|
||||
无 ToC → LLM 语义分段 (一次性调用)
|
||||
|
||||
Step 2 — L2 先行
|
||||
L2: 段落组 → LLM 生成摘要 (1-2句)
|
||||
摘要目标: 与兄弟 L2 形成区分
|
||||
(batch_chat 并发生成所有 L2 摘要)
|
||||
|
||||
Step 3 — L3 向下
|
||||
L3: 原始段落文本直接复用
|
||||
存储 raw_content = description = 原始文本
|
||||
(文本模式 L3 不需要 L2 上下文,不调用 LLM)
|
||||
|
||||
Step 4 — L1 向上聚合
|
||||
L1: LLM("总结这些小节: " + 所有子 L2 摘要) → 摘要
|
||||
摘要目标: 覆盖下属所有 L2 的语义范围 (2-3句)
|
||||
|
||||
Step 5 — 序列化 → TreeIndex(JSON,无 embedding)
|
||||
|
||||
⚠ 延迟 Embedding: 所有节点 embedding=None
|
||||
首次检索时由 Pipeline._embed_tree() → tree.embed_all() 统一填充
|
||||
```
|
||||
|
||||
### 4.4 VideoTreeBuilder
|
||||
|
||||
**状态**: ✅ 已实现(`video_tree_trm/video_tree_builder.py`)
|
||||
|
||||
```
|
||||
输入: 长视频文件路径 或 YouTube URL
|
||||
输出: TreeIndex(全部 embedding=None)
|
||||
|
||||
Pipeline(ThreadPoolExecutor 异步事件循环):
|
||||
Step 0 — 输入类型判断
|
||||
本地文件: 直接使用 OpenCV 读取
|
||||
YouTube URL: yt-dlp -g 获取 CDN 直链 + yt-dlp --dump-json 获取时长
|
||||
|
||||
Step 1 — 时间切分(固定步长)
|
||||
本地: cv2 读取总时长
|
||||
HTTP 流: 使用 yt-dlp 元数据时长(duration_hint,避免 cv2 流上不可靠)
|
||||
固定步长 l1_segment_duration=600s → L1 区间列表
|
||||
每个 L1 区间 → 等分 l2_clip_duration=60s → L2 clips
|
||||
|
||||
Step 2 — 预计算任务图
|
||||
收集所有 L2 任务 + 记录每个 L1 的 L2 数量(l2_counts)
|
||||
|
||||
Step 3 — 一次性提交所有 L2 任务(非阻塞)
|
||||
ThreadPoolExecutor(max_workers=concurrency)
|
||||
每个 L2 clip: 均匀 seek l2_representative_frames=10 帧 → VLM → 1-2句描述
|
||||
|
||||
Step 4 — 事件循环(cfwait FIRST_COMPLETED)
|
||||
L2 完成 → 立即提交 L3 任务(_build_l3_task,线程安全)
|
||||
L3: 按 l3_fps 提取全量帧(持久化缓存),注入 L2 上下文,VLM 批量帧描述(JSON)
|
||||
降级路径: JSON 解析失败 → 逐帧 VLM 调用
|
||||
L3 完成 → 检查该 L1 的所有 L2 是否就绪 → 触发 L1 任务
|
||||
L1: 拼接所有 L2 描述 → vlm.chat() → 2-3句摘要
|
||||
主线程单线程操作 l1_l2_buckets,无竞争,无需 Lock
|
||||
|
||||
Step 5 — 有序重建 l1_nodes,组装 TreeIndex(写 IndexMeta + 日志)
|
||||
|
||||
⚠ 延迟 Embedding: 所有节点 embedding=None
|
||||
首次检索时由 Pipeline._embed_tree() → tree.embed_all() 统一填充
|
||||
```
|
||||
|
||||
**L2 代表帧采样**(均匀 seek,首尾均包含):
|
||||
|
||||
```python
|
||||
step = (end_sec - start_sec) / (n_rep - 1)
|
||||
timestamps = [start_sec + i * step for i in range(n_rep)]
|
||||
# → 直接 cap.set(CAP_PROP_POS_MSEC, ts * 1000) 提取,缓存为 l2_{ts:.3f}.jpg
|
||||
```
|
||||
|
||||
**L3 帧描述 prompt**(继承 L2 上下文 + 要求 JSON 输出):
|
||||
|
||||
```python
|
||||
prompt = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"以下是该片段中连续的 {n} 帧画面。\n"
|
||||
"对每帧用一到两句话描述其具体画面内容。\n"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
|
||||
"不要重复片段整体描述,聚焦每帧的区分性信息。\n"
|
||||
'只返回 JSON 数组,格式: ["帧1描述", "帧2描述", ...],不要其他内容。'
|
||||
)
|
||||
descs = VLM(frames_batch, prompt) # 主路径:一次调用,JSON 解析
|
||||
# 降级:json.loads 失败 → 逐帧调用
|
||||
```
|
||||
|
||||
**L1 摘要 prompt**:
|
||||
|
||||
```python
|
||||
l2_texts = "\n".join(f"- {node.description}" for node in l2_children)
|
||||
prompt = f"以下是一个视频段落中各片段的描述:\n{l2_texts}\n用2-3句话总结该段落的整体内容,涵盖所有片段的主题。"
|
||||
l1_summary = vlm.chat(prompt) # 纯文本调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. RecursiveRetriever:TRM 式递归检索
|
||||
|
||||
### 5.1 核心算法
|
||||
|
||||
节点选择使用 **Cross-Attention**(学习 W_q/W_k/W_v/W_o 投影),替代简单 cosine 路由。
|
||||
L_level 推理模块使用 **MLP-based**(RMSNorm + SwiGLU),操作对象为向量 `[B, D]`。
|
||||
三个可学习组件(CrossAttentionSelector, ReasoningModule, q_head)**跨层级共享权重**。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ RecursiveRetriever │
|
||||
│ │
|
||||
│ 可训练组件(共享权重): │
|
||||
│ selector = CrossAttentionSelector(W_q, W_k, W_v, W_o) │
|
||||
│ L_level = ReasoningModule(RMSNorm + SwiGLU, L_layers层) │
|
||||
│ q_head = Linear(D, 1) │
|
||||
│ │
|
||||
│ 输入: query (str), tree (TreeIndex) │
|
||||
│ 输出: List[RetrievalPath] │
|
||||
│ │
|
||||
│ q = text_embed(query) # 查询嵌入 [D],冻结 │
|
||||
│ z = q.clone() # 初始潜在状态 │
|
||||
│ │
|
||||
│ for round in range(max_rounds): ← ACT halt 控制 │
|
||||
│ │ │
|
||||
│ │ ┌─── Phase 1: L1 粗粒度路由 (Cross-Attention) ──┐ │
|
||||
│ │ │ s1, w1, k1* = selector(q+z, M_L1) │ │
|
||||
│ │ │ Q=W_q(q+z), K=W_k(M_L1), V=W_v(M_L1) │ │
|
||||
│ │ │ s1 = W_o(MultiHeadAttn(Q,K,V)) # 软选择 │ │
|
||||
│ │ │ k1* = argmax(mean_head_scores) # 硬索引 │ │
|
||||
│ │ │ z = z + s1 │ │
|
||||
│ │ │ for _ in L_cycles: │ │
|
||||
│ │ │ z = L_level(z, s1 + q) │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ ┌─── Phase 2: L2 细粒度聚焦 (Cross-Attention) ──┐ │
|
||||
│ │ │ M_L2 = children_embeds(L1[k1*]) │ │
|
||||
│ │ │ s2, w2, k2* = selector(q+z, M_L2) │ │
|
||||
│ │ │ z = z + s2 │ │
|
||||
│ │ │ for _ in L_cycles: │ │
|
||||
│ │ │ z = L_level(z, s2 + q) │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ ┌─── Phase 3: L3 精确定位 (Cross-Attention) ────┐ │
|
||||
│ │ │ M_L3 = children_embeds(L2[k2*]) │ │
|
||||
│ │ │ s3, w3, k3* = selector(q+z, M_L3) │ │
|
||||
│ │ │ z = z + s3 │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ collected.append(Path(k1*, k2*, k3*)) │
|
||||
│ │ │
|
||||
│ │ ┌─── ACT Halt Decision ───────────────────┐ │
|
||||
│ │ │ halt_logit = q_head(z) │ │
|
||||
│ │ │ if halt_logit > 0 and round_idx > 0: │ │
|
||||
│ │ │ break # 至少跑 1 轮 │ │
|
||||
│ │ └──────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ └── z 状态保留到下一轮(累积已检索信息) │
|
||||
│ │
|
||||
│ return collected │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 算法伪代码
|
||||
|
||||
```python
|
||||
class RecursiveRetriever:
|
||||
"""TRM 式递归检索器(Cross-Attention 版本)。"""
|
||||
|
||||
def __init__(self, embed_dim: int, num_heads: int, L_layers: int, L_cycles: int, max_rounds: int):
|
||||
self.selector = CrossAttentionSelector(embed_dim, num_heads) # 共享,跨层级
|
||||
self.L_level = ReasoningModule(embed_dim, L_layers) # 共享,跨层级
|
||||
self.q_head = Linear(embed_dim, 1) # ACT halt head
|
||||
self.L_cycles = L_cycles
|
||||
self.max_rounds = max_rounds
|
||||
|
||||
def forward(
|
||||
self, q: Tensor, tree: TreeIndex, return_internals: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
z = q.clone() # [B, D],初始潜在状态 = 查询嵌入
|
||||
paths = []
|
||||
|
||||
for round_idx in range(self.max_rounds):
|
||||
# ── 三阶段树遍历(一次完整 root-to-leaf) ──
|
||||
path, z, step_attns = self._traverse_one_path(q, z, tree)
|
||||
paths.append(path)
|
||||
|
||||
# ── ACT halt(推理模式,至少走 1 轮) ──
|
||||
halt_logit = self.q_head(z) # [B, 1]
|
||||
if not self.training and halt_logit.item() > 0 and round_idx > 0:
|
||||
break
|
||||
|
||||
return {"paths": paths, "num_rounds": len(paths), "z_final": z}
|
||||
|
||||
def _traverse_one_path(self, q, z, tree):
|
||||
"""单次 root → L1 → L2 → L3 遍历。"""
|
||||
|
||||
# Phase 1
|
||||
k1, z = self._select_and_reason(q, z, tree.l1_embeddings())
|
||||
# Phase 2
|
||||
k2, z = self._select_and_reason(q, z, tree.l2_embeddings_of(k1))
|
||||
# Phase 3
|
||||
k3, z = self._select_and_reason(q, z, tree.l3_embeddings_of(k1, k2))
|
||||
|
||||
return Path(k1, k2, k3), z
|
||||
|
||||
def _select_and_reason(self, q, z, M):
|
||||
"""单层: Cross-Attention 选择 + L_cycles 内循环推理。"""
|
||||
|
||||
# Navigate: Cross-Attention
|
||||
state = q + z # [B, D]
|
||||
selected_info, attn_w, k_star = self.selector(state, M)
|
||||
# Q = W_q(state) → [B, 1, D]
|
||||
# K = W_k(M) → [B, N, D]
|
||||
# V = W_v(M) → [B, N, D]
|
||||
# selected_info = W_o(MultiHeadAttn(Q, K, V)) [B, D] ← 可微
|
||||
# k_star = argmax(avg_head_scores) ← 路径索引
|
||||
|
||||
# Update: z += attention 加权信息
|
||||
z = z + selected_info
|
||||
|
||||
# Reason: TRM L-level MLP 内循环
|
||||
for _ in range(self.L_cycles):
|
||||
z = self.L_level(z, selected_info + q)
|
||||
|
||||
return k_star, z
|
||||
```
|
||||
|
||||
### 5.3 多轮检索的 z 状态流
|
||||
|
||||
```
|
||||
Round 1: Round 2:
|
||||
z₀ = q z₃ 来自 Round 1(包含已检索信息)
|
||||
│ │
|
||||
├─ Phase1: CA(q+z₀, M_L1) → s1 ├─ Phase1: CA(q+z₃, M_L1) → s4
|
||||
│ z₁ = z₀ + s1 → L_level×L_cycles │ z₄ = z₃ + s4 → L_level×L_cycles
|
||||
├─ Phase2: CA(q+z₁, M_L2[k1]) → s2 ├─ Phase2: CA(q+z₄, M_L2[k4]) → s5
|
||||
│ z₂ = z₁ + s2 → L_level×L_cycles │ z₅ = z₄ + s5 → L_level×L_cycles
|
||||
├─ Phase3: CA(q+z₂, M_L3[k2]) → s3 ├─ Phase3: CA(q+z₅, M_L3[k5]) → s6
|
||||
│ z₃ = z₂ + s3 │ z₆ = z₅ + s6
|
||||
│ │
|
||||
ACT: q_head(z₃) < 0 → 继续 ACT: q_head(z₆) > 0 → 停止
|
||||
→ 返回 [Path(k1,k2,k3), Path(k4,k5,k6)]
|
||||
|
||||
关键: s = W_o(MultiHeadAttn(W_q(q+z), W_k(M), W_v(M)))
|
||||
比简单 cosine 路由更强:
|
||||
- 学习的投影决定"关注什么"进行选择
|
||||
- Multi-head 捕获多种相关性信号
|
||||
- V 投影让"更新信息"与"匹配键"解耦
|
||||
z₃ 累积了 Round 1 的 attention 信息
|
||||
→ (q + z₃) 的投影方向自动偏离已选区域
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ACT Halt 训练方案
|
||||
|
||||
### 6.1 训练目标
|
||||
|
||||
```
|
||||
ACT halt head 学习: "已收集的信息是否足以回答 query"
|
||||
|
||||
┌──────────────────────────────┐
|
||||
│ reward 定义 │
|
||||
│ │
|
||||
│ R = answer_quality - λ·rounds │
|
||||
│ │
|
||||
│ answer_quality: │
|
||||
│ 文本QA: EM / F1 score │
|
||||
│ 视频QA: 选项匹配准确率 │
|
||||
│ │
|
||||
│ λ: 步数惩罚系数 │
|
||||
│ 鼓励用更少轮次达到同样质量 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 训练数据
|
||||
|
||||
| 数据集 | 模态 | 样本量 | reward 信号 |
|
||||
|--------|------|--------|-------------|
|
||||
| LongBench | 文本 | ~5K | ground truth → F1 |
|
||||
| NarrativeQA | 文本 | ~30K | ground truth → ROUGE |
|
||||
| VideoMME | 视频 | ~2K | 选项匹配 → 0/1 |
|
||||
|
||||
### 6.3 训练流程
|
||||
|
||||
```python
|
||||
# 训练伪代码
|
||||
for query, tree, ground_truth in dataset:
|
||||
q = text_embed(query)
|
||||
z = q.copy()
|
||||
total_loss = 0
|
||||
|
||||
for round_idx in range(max_rounds):
|
||||
path, z = retriever.traverse_one_path(q, z, tree)
|
||||
|
||||
# halt 决策
|
||||
halt_logit = q_head(z)
|
||||
|
||||
# 用当前已收集的 context 生成答案,计算质量
|
||||
context = collect_raw_content(paths_so_far)
|
||||
answer = generator(query, context)
|
||||
quality = compute_score(answer, ground_truth) # EM/F1
|
||||
|
||||
# Q-learning target
|
||||
# 如果现在停 → reward = quality
|
||||
# 如果继续 → reward = γ · future_quality - λ
|
||||
target_q = quality if is_last else γ * next_quality - λ
|
||||
loss += mse(sigmoid(halt_logit), target_q)
|
||||
|
||||
total_loss.backward()
|
||||
optimizer.step()
|
||||
```
|
||||
|
||||
### 6.4 可训练组件 vs 冻结组件
|
||||
|
||||
```
|
||||
冻结 (不训练):
|
||||
✗ text_embed() # 预训练嵌入器,冻结
|
||||
✗ TreeIndex embeddings # 预计算的节点嵌入,冻结
|
||||
|
||||
可训练:
|
||||
✓ CrossAttentionSelector (W_q, W_k, W_v, W_o) # 节点选择投影
|
||||
✓ L_level (ReasoningModule: RMSNorm + SwiGLU) # MLP 推理模块
|
||||
✓ q_head (ACT halt) # 停止决策头
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 检索结果与答案生成的接口
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class RetrievalPath:
|
||||
"""一条 root-to-leaf 路径。"""
|
||||
k1: int # L1 索引
|
||||
k2: int # L2 索引
|
||||
k3: int # L3 索引
|
||||
l1_summary: str # L1 摘要
|
||||
l2_description: str # L2 描述
|
||||
l3_description: str # L3 描述
|
||||
raw_content: Optional[str] # 原始文本(文本模式)
|
||||
frame_path: Optional[str] # 帧路径(视频模式)
|
||||
timestamp: Optional[float] # 时间戳(视频模式)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrievalResult:
|
||||
"""检索器输出。"""
|
||||
query: str
|
||||
paths: List[RetrievalPath] # 多轮收集的路径
|
||||
num_rounds: int # 实际检索轮次
|
||||
z_final: ndarray # 最终潜在状态
|
||||
|
||||
|
||||
# 答案生成器接口
|
||||
def generate_answer(query: str, result: RetrievalResult) -> str:
|
||||
if is_text_mode(result):
|
||||
context = "\n".join(p.raw_content for p in result.paths)
|
||||
return LLM(query=query, context=context)
|
||||
else:
|
||||
frames = [load_image(p.frame_path) for p in result.paths]
|
||||
captions = [p.l3_description for p in result.paths]
|
||||
return VLM(query=query, images=frames, captions=captions)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 与现有系统的关系
|
||||
|
||||
```
|
||||
参考代码 新架构 变化
|
||||
─────────────────────────────────────────────────────────────────
|
||||
video_pyramid.py (HSP) → TreeIndex 重构为统一格式
|
||||
video_tree_trm.py (cosine路由) → RecursiveRetriever Cross-Attention+ACT
|
||||
select_next_node (CrossAttn) → CrossAttentionSelector 保留CA思路,简化为向量级
|
||||
L_level (Transformer blocks) → ReasoningModule MLP-based (向量非序列)
|
||||
visual_projection.py → 删除 L3 全文本化
|
||||
video_indexer.py (CLIP encode) → embeddings.py 统一 text_embed()
|
||||
pipeline.py → pipeline.py ✅ 已实现(含延迟 embed 策略)
|
||||
answer_generator.py → answer_generator.py ✅ 已实现
|
||||
config.py → config.py 全面重构
|
||||
|
||||
新增:
|
||||
+ tree_index.py 统一数据结构 ✅ 已实现
|
||||
+ embeddings.py 嵌入服务封装 ✅ 已实现
|
||||
+ llm_client.py LLM/VLM 客户端 ✅ 已实现
|
||||
+ text_tree_builder.py 文本模式预处理 ✅ 已实现
|
||||
+ video_tree_builder.py 视频模式预处理 ✅ 已实现
|
||||
+ recursive_retriever.py TRM 递归检索器 (CA+MLP+ACT) ✅ 已实现
|
||||
+ losses.py NavigationLoss + ACTLoss ✅ 已实现
|
||||
+ train.py 两阶段训练入口 ✅ 已实现
|
||||
+ main.py 推理/演示入口 ✅ 已实现
|
||||
```
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
推理/演示入口
|
||||
=============
|
||||
从原始文档(文本或视频)构建 TreeIndex,执行问答。
|
||||
与 ``train.py``(训练入口)配对,是 Video-Tree-TRM 的端到端演示入口。
|
||||
|
||||
子命令
|
||||
------
|
||||
- ``index``: 仅构建并缓存 TreeIndex,不执行问答。
|
||||
- ``query``: 构建索引(或复用缓存)后执行问答。
|
||||
|
||||
使用示例::
|
||||
|
||||
# 仅构建文本索引
|
||||
python main.py index --source data/doc.txt --modality text
|
||||
|
||||
# 单次问答(视频)
|
||||
python main.py query --source data/video.mp4 --modality video \\
|
||||
--question "视频的主要内容是什么?"
|
||||
|
||||
# 交互式多轮问答(文本)
|
||||
python main.py query --source data/doc.txt --modality text --interactive
|
||||
|
||||
# 自定义配置
|
||||
python main.py query --source data/doc.txt --modality text \\
|
||||
--config config/default.yaml --env .env \\
|
||||
--question "文档结论是什么?"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from utils.logger_system import log_msg
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.pipeline import Pipeline
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI 解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
"""解析 CLI 参数,返回 Namespace。
|
||||
|
||||
返回:
|
||||
包含子命令及所有选项的 Namespace 对象。
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="main.py",
|
||||
description="Video-Tree-TRM 推理/演示入口",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
# 公共参数
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument(
|
||||
"--config",
|
||||
default="config/default.yaml",
|
||||
metavar="YAML",
|
||||
help="配置文件路径(默认: config/default.yaml)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--env",
|
||||
default=".env",
|
||||
metavar="ENV",
|
||||
help="环境变量文件路径(默认: .env)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--source",
|
||||
required=True,
|
||||
metavar="PATH",
|
||||
help="原始文件路径(文本文件或视频文件)",
|
||||
)
|
||||
common.add_argument(
|
||||
"--modality",
|
||||
required=True,
|
||||
choices=["text", "video"],
|
||||
help="文件模态:text 或 video",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", title="子命令")
|
||||
subparsers.required = True
|
||||
|
||||
# ── index 子命令 ──
|
||||
subparsers.add_parser(
|
||||
"index",
|
||||
parents=[common],
|
||||
help="构建并缓存 TreeIndex(不执行问答)",
|
||||
description="从原始文件构建三层树索引并保存到缓存目录。",
|
||||
)
|
||||
|
||||
# ── query 子命令 ──
|
||||
query_parser = subparsers.add_parser(
|
||||
"query",
|
||||
parents=[common],
|
||||
help="构建索引并执行问答",
|
||||
description="构建索引后执行单次问答或交互式多轮问答。",
|
||||
)
|
||||
mode_group = query_parser.add_mutually_exclusive_group(required=True)
|
||||
mode_group.add_argument(
|
||||
"--question",
|
||||
metavar="TEXT",
|
||||
help="单次问答:问题字符串",
|
||||
)
|
||||
mode_group.add_argument(
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="交互式多轮问答模式(输入 quit 退出)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置加载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_config(args: argparse.Namespace) -> Config:
|
||||
"""从 YAML + .env 加载配置。
|
||||
|
||||
参数:
|
||||
args: CLI 解析结果,需含 config(YAML 路径)和 env(.env 路径)字段。
|
||||
|
||||
返回:
|
||||
完整的 Config 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 配置文件不存在。
|
||||
TypeError: YAML 缺少必需字段。
|
||||
"""
|
||||
log_msg("INFO", "加载配置", yaml_path=args.config, env_path=args.env)
|
||||
return Config.load(
|
||||
yaml_path=args.config,
|
||||
env_path=args.env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子命令实现
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_index(args: argparse.Namespace) -> None:
|
||||
"""index 子命令:构建并缓存 TreeIndex,打印缓存路径。
|
||||
|
||||
参数:
|
||||
args: 含 source、modality、config、env 字段的 Namespace。
|
||||
"""
|
||||
config = _load_config(args)
|
||||
pipeline = Pipeline(config)
|
||||
|
||||
log_msg("INFO", "开始构建索引", source=args.source, modality=args.modality)
|
||||
tree = pipeline.build_index(args.source, args.modality)
|
||||
|
||||
print(f"索引构建完成。模态={args.modality},来源={args.source}")
|
||||
print(f"节点数量: L1={len(tree.roots)}")
|
||||
log_msg("INFO", "索引构建完成", l1_count=len(tree.roots))
|
||||
|
||||
|
||||
def cmd_query(args: argparse.Namespace) -> None:
|
||||
"""query 子命令:构建索引 → 单次或交互式问答。
|
||||
|
||||
参数:
|
||||
args: 含 source、modality、question/interactive、config、env 字段的 Namespace。
|
||||
|
||||
实现细节:
|
||||
- 先调用 build_index(缓存命中时直接加载,无额外开销)。
|
||||
- --question 模式:单次问答后退出。
|
||||
- --interactive 模式:循环读取用户输入,输入 'quit' 退出。
|
||||
"""
|
||||
config = _load_config(args)
|
||||
pipeline = Pipeline(config)
|
||||
|
||||
log_msg("INFO", "构建/加载索引", source=args.source, modality=args.modality)
|
||||
tree = pipeline.build_index(args.source, args.modality)
|
||||
|
||||
if args.question:
|
||||
# Phase 1: 单次问答
|
||||
answer = pipeline.query(args.question, tree)
|
||||
print(f"\n问题: {args.question}")
|
||||
print(f"答案: {answer}\n")
|
||||
log_msg("INFO", "问答完成", question=args.question[:50])
|
||||
else:
|
||||
# Phase 2: 交互式多轮问答
|
||||
print("\n已进入交互式问答模式。输入 'quit' 退出。\n")
|
||||
while True:
|
||||
try:
|
||||
question = input("问题 (输入 'quit' 退出): ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n再见。")
|
||||
break
|
||||
|
||||
if question.lower() in ("quit", "exit", "q"):
|
||||
print("再见。")
|
||||
break
|
||||
|
||||
if not question:
|
||||
continue
|
||||
|
||||
answer = pipeline.query(question, tree)
|
||||
print(f"答案: {answer}\n")
|
||||
log_msg("INFO", "交互式问答", question=question[:50])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主入口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""主入口:解析参数 → 分发子命令。
|
||||
|
||||
退出码:
|
||||
0: 正常退出。
|
||||
1: 参数错误或运行时异常。
|
||||
"""
|
||||
args = _parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "index":
|
||||
cmd_index(args)
|
||||
elif args.command == "query":
|
||||
cmd_query(args)
|
||||
except (FileNotFoundError, TypeError, ValueError) as exc:
|
||||
print(f"错误: {exc}", file=sys.stderr)
|
||||
log_msg("ERROR", f"main.py 异常退出: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
# 核心
|
||||
torch>=2.0
|
||||
sentence-transformers>=2.2
|
||||
numpy
|
||||
|
||||
# LLM/VLM
|
||||
openai>=1.0 # 兼容 Qwen/OpenAI/Ollama 接口
|
||||
python-dotenv
|
||||
|
||||
# 视频处理
|
||||
opencv-python
|
||||
|
||||
# 配置
|
||||
pyyaml
|
||||
|
||||
# 测试
|
||||
pytest
|
||||
pytest-cov
|
||||
|
||||
# 代码质量
|
||||
ruff
|
||||
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
_download_meta.py — VideoMME 元数据下载与长视频列表提取
|
||||
==========================================================
|
||||
从 HuggingFace `lmms-lab/Video-MME` 下载数据集元数据,
|
||||
过滤 duration_category == "long"(30-60 分钟)的视频,
|
||||
输出两个文件:
|
||||
- {meta_dir}/long_videos.jsonl 每行一条唯一长视频记录
|
||||
- {meta_dir}/long_videos_qa.jsonl 每行一条 QA 对(含 video_id)
|
||||
|
||||
使用方式(由 build_videomme_trees.sh 调用):
|
||||
python _download_meta.py --meta-dir /data/videomme/metadata
|
||||
|
||||
依赖(在脚本中已安装): datasets, huggingface_hub
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="下载 VideoMME 元数据并提取长视频列表")
|
||||
p.add_argument("--meta-dir", required=True, help="元数据输出目录")
|
||||
p.add_argument(
|
||||
"--min-duration",
|
||||
type=int,
|
||||
default=1800,
|
||||
help="最短视频时长(秒),默认 1800(30 分钟)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-duration",
|
||||
type=int,
|
||||
default=3600,
|
||||
help="最长视频时长(秒),默认 3600(60 分钟)",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
meta_dir = Path(args.meta_dir)
|
||||
meta_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Phase 1: 尝试加载 HuggingFace 数据集
|
||||
print("[meta] 正在从 HuggingFace 加载 lmms-lab/Video-MME ...")
|
||||
try:
|
||||
from datasets import load_dataset # type: ignore
|
||||
except ImportError:
|
||||
print("[meta][ERROR] 未安装 datasets 库,请先运行: pip install datasets", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# VideoMME 数据集,test split
|
||||
ds = load_dataset("lmms-lab/Video-MME", split="test")
|
||||
except Exception as e:
|
||||
print(f"[meta][ERROR] 数据集加载失败: {e}", file=sys.stderr)
|
||||
print("[meta] 请确认 HuggingFace 可访问,或配置 HF_ENDPOINT 镜像", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[meta] 数据集总条目数: {len(ds)}")
|
||||
|
||||
# Phase 2: 过滤长视频
|
||||
# VideoMME 字段: video_id, youtube_id, url, duration, duration_category,
|
||||
# domain, sub_category, question, answer, options
|
||||
seen_video_ids: set[str] = set()
|
||||
long_videos: list[dict] = []
|
||||
long_qa: list[dict] = []
|
||||
|
||||
for row in ds:
|
||||
# 实际字段结构(lmms-lab/Video-MME 真实格式):
|
||||
# video_id : "001", "002", ... (数据集内部序号)
|
||||
# videoID : YouTube 视频 ID(如 "fFjv93ACGo8")
|
||||
# url : YouTube 完整链接
|
||||
# duration : "short" | "medium" | "long"(字符串类别,非秒数)
|
||||
# domain : 领域
|
||||
# sub_category : 细分类别
|
||||
# question_id : 问题序号
|
||||
# question : 问题文本
|
||||
# options : 选项列表(字符串)
|
||||
# answer : 正确选项字母
|
||||
duration_category = str(row.get("duration", "")).strip().lower()
|
||||
if duration_category != "long":
|
||||
continue
|
||||
|
||||
youtube_id = row.get("videoID") or row.get("video_id", "")
|
||||
url = row.get("url", f"https://www.youtube.com/watch?v={youtube_id}")
|
||||
|
||||
# 唯一视频记录(以 youtube_id 去重)
|
||||
if youtube_id not in seen_video_ids:
|
||||
seen_video_ids.add(youtube_id)
|
||||
long_videos.append(
|
||||
{
|
||||
"video_id": row.get("video_id", ""),
|
||||
"youtube_id": youtube_id,
|
||||
"url": url,
|
||||
"duration_category": duration_category,
|
||||
"domain": row.get("domain", ""),
|
||||
"sub_category": row.get("sub_category", ""),
|
||||
}
|
||||
)
|
||||
|
||||
# QA 对记录
|
||||
long_qa.append(
|
||||
{
|
||||
"video_id": row.get("video_id", ""),
|
||||
"youtube_id": youtube_id,
|
||||
"question_id": row.get("question_id", ""),
|
||||
"question": row.get("question", ""),
|
||||
"answer": row.get("answer", ""),
|
||||
"options": row.get("options", []),
|
||||
"duration_category": duration_category,
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 3: 写出文件
|
||||
videos_path = meta_dir / "long_videos.jsonl"
|
||||
qa_path = meta_dir / "long_videos_qa.jsonl"
|
||||
|
||||
with open(videos_path, "w", encoding="utf-8") as f:
|
||||
for v in long_videos:
|
||||
f.write(json.dumps(v, ensure_ascii=False) + "\n")
|
||||
|
||||
with open(qa_path, "w", encoding="utf-8") as f:
|
||||
for q in long_qa:
|
||||
f.write(json.dumps(q, ensure_ascii=False) + "\n")
|
||||
|
||||
print(f"[meta] 长视频唯一数量: {len(long_videos)}")
|
||||
print(f"[meta] 长视频 QA 对数: {len(long_qa)}")
|
||||
print(f"[meta] 视频列表已保存: {videos_path}")
|
||||
print(f"[meta] QA 列表已保存: {qa_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
单视频建树脚本(仅 VLM,不加载 EmbeddingModel)
|
||||
================================================
|
||||
直接调用 VideoTreeBuilder,跳过 Pipeline 的嵌入模型初始化。
|
||||
结果保存为 JSON 到 cache/trees/ 目录。
|
||||
|
||||
用法::
|
||||
|
||||
conda run -n Video-Tree-TRM python scripts/build_tree_single.py \
|
||||
--video data/videomme/videos/xKiRmesHWIA.mp4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录加入 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.video_tree_builder import VideoTreeBuilder
|
||||
from utils.logger_system import log_msg
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""构建单个视频的 TreeIndex,仅使用 VLM,不加载 EmbeddingModel。"""
|
||||
parser = argparse.ArgumentParser(description="单视频建树(仅 VLM)")
|
||||
parser.add_argument("--video", required=True, help="视频文件路径")
|
||||
parser.add_argument("--config", default="config/default.yaml", help="配置文件路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Phase 1: 加载配置 + 初始化 VLM
|
||||
cfg = Config.load(args.config)
|
||||
vlm = LLMClient(cfg.vlm)
|
||||
|
||||
# Phase 2: 构建树(纯 VLM 描述,embedding=None)
|
||||
builder = VideoTreeBuilder(vlm, cfg.tree)
|
||||
tree = builder.build(args.video)
|
||||
|
||||
# Phase 3: 保存 JSON
|
||||
stem = Path(args.video).stem
|
||||
cache_dir = Path(cfg.tree.cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = str(cache_dir / f"{stem}_video.json")
|
||||
tree.save_json(out_path)
|
||||
|
||||
log_msg("INFO", "建树完成,已保存", path=out_path)
|
||||
print(f"\n[完成] TreeIndex 已保存到: {out_path}")
|
||||
print(f" L1 节点数: {len(tree.roots)}")
|
||||
total_l2 = sum(len(r.children) for r in tree.roots)
|
||||
total_l3 = sum(len(l2.children) for r in tree.roots for l2 in r.children)
|
||||
print(f" L2 节点数: {total_l2}")
|
||||
print(f" L3 节点数: {total_l3}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
批量视频建树脚本(视频间并行 + 视频内 asyncio 真并发)
|
||||
==========================================================
|
||||
扫描指定目录下所有 MP4,跳过已有 JSON 树的视频,
|
||||
使用 ThreadPoolExecutor 进行视频间并行(--jobs,默认 1),
|
||||
每个视频内部使用 asyncio + Semaphore(concurrency=16) 真并发 VLM 调用。
|
||||
|
||||
并发架构::
|
||||
|
||||
外层: ThreadPoolExecutor(max_workers=jobs=1) — 视频间并行(默认 1 路)
|
||||
内层: asyncio.run(_build_async()) — 每视频独立事件循环
|
||||
asyncio.Semaphore(concurrency=16) — 限制同时在途 VLM 请求数
|
||||
总最大 VLM 并发: jobs × concurrency = 1 × 16 = 16
|
||||
|
||||
用法::
|
||||
|
||||
# 默认 1 路 16 并发
|
||||
conda run -n Video-Tree-TRM python scripts/build_trees_batch.py
|
||||
|
||||
# 指定路数
|
||||
conda run -n Video-Tree-TRM python scripts/build_trees_batch.py --jobs 2
|
||||
|
||||
# 指定目录和配置
|
||||
conda run -n Video-Tree-TRM python scripts/build_trees_batch.py \\
|
||||
--video-dir data/videomme/videos \\
|
||||
--config config/videomme.yaml \\
|
||||
--jobs 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor
|
||||
from concurrent.futures import wait as cfwait
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
# 项目根目录加入 sys.path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.video_tree_builder import VideoTreeBuilder
|
||||
from utils.logger_system import log_msg
|
||||
|
||||
|
||||
def _build_one(
|
||||
video_path: Path,
|
||||
cfg: Config,
|
||||
) -> Tuple[str, bool, str]:
|
||||
"""构建单个视频的 TreeIndex 并保存 JSON。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件绝对路径。
|
||||
cfg: 已加载的配置对象(由主进程共享,仅读取)。
|
||||
|
||||
返回:
|
||||
(stem, success, message) 三元组。
|
||||
|
||||
实现细节:
|
||||
每次调用独立初始化 LLMClient(避免多线程共享同一 httpx.Client 内部状态),
|
||||
使用 VideoTreeBuilder.build() 内部的异步事件循环(L2→L3→L1 链式并发)。
|
||||
"""
|
||||
stem = video_path.stem
|
||||
try:
|
||||
progress_dir = Path(cfg.tree.cache_dir) / "progress"
|
||||
progress_path = progress_dir / f"{stem}.json"
|
||||
if progress_path.is_file():
|
||||
log_msg("INFO", "检测到中间进度,启用断点续跑", stem=stem, progress_path=str(progress_path))
|
||||
|
||||
# 每线程独立 LLMClient(httpx.Client 线程安全,但独立更稳健)
|
||||
vlm = LLMClient(cfg.vlm)
|
||||
builder = VideoTreeBuilder(vlm, cfg.tree)
|
||||
tree = builder.build(str(video_path))
|
||||
|
||||
# 保存 JSON
|
||||
cache_dir = Path(cfg.tree.cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = cache_dir / f"{stem}_video.json"
|
||||
tree.save_json(str(out_path))
|
||||
|
||||
l1 = len(tree.roots)
|
||||
l2 = sum(len(r.children) for r in tree.roots)
|
||||
l3 = sum(len(l2n.children) for r in tree.roots for l2n in r.children)
|
||||
msg = f"L1={l1} L2={l2} L3={l3} → {out_path}"
|
||||
log_msg("INFO", "视频建树完成", stem=stem, l1=l1, l2=l2, l3=l3)
|
||||
return stem, True, msg
|
||||
|
||||
except Exception as e: # noqa: BLE001
|
||||
log_msg("ERROR", "视频建树失败", stem=stem, error=str(e))
|
||||
return stem, False, str(e)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""批量建树主函数:视频间 ThreadPoolExecutor 并行 + 视频内异步事件循环。"""
|
||||
parser = argparse.ArgumentParser(description="批量视频建树(视频间并行)")
|
||||
parser.add_argument(
|
||||
"--video-dir",
|
||||
default="data/videomme/videos",
|
||||
help="MP4 视频目录(默认: data/videomme/videos)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config/videomme.yaml",
|
||||
help="配置文件路径(默认: config/videomme.yaml)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=1,
|
||||
help="视频间并行数(默认: 1,每路视频内 asyncio Semaphore(concurrency) 并发 VLM)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Phase 1: 加载配置(所有线程共享,只读)
|
||||
cfg = Config.load(args.config)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"批量建树配置",
|
||||
video_dir=args.video_dir,
|
||||
cache_dir=cfg.tree.cache_dir,
|
||||
jobs=args.jobs,
|
||||
intra_concurrency=cfg.tree.concurrency,
|
||||
)
|
||||
|
||||
# Phase 2: 扫描视频 + 过滤已有树
|
||||
video_dir = Path(args.video_dir)
|
||||
assert video_dir.is_dir(), f"视频目录不存在: {video_dir}"
|
||||
all_videos: List[Path] = sorted(video_dir.glob("*.mp4"))
|
||||
|
||||
cache_dir = Path(cfg.tree.cache_dir)
|
||||
progress_dir = cache_dir / "progress"
|
||||
with_progress: List[Path] = []
|
||||
without_progress: List[Path] = []
|
||||
for v in all_videos:
|
||||
if (cache_dir / f"{v.stem}_video.json").exists():
|
||||
continue
|
||||
progress_path = progress_dir / f"{v.stem}.json"
|
||||
if progress_path.is_file():
|
||||
with_progress.append(v)
|
||||
else:
|
||||
without_progress.append(v)
|
||||
pending: List[Path] = with_progress + without_progress
|
||||
skipped = len(all_videos) - len(pending)
|
||||
|
||||
print(f"\n===== 批量建树 =====")
|
||||
print(f" 总视频数: {len(all_videos)}")
|
||||
print(f" 已跳过(已建): {skipped}")
|
||||
print(f" 待处理: {len(pending)}")
|
||||
print(f" 视频间并行: {args.jobs}")
|
||||
print(f" 视频内并发: {cfg.tree.concurrency}")
|
||||
print(f" 输出目录: {cache_dir}\n")
|
||||
|
||||
if not pending:
|
||||
print("所有视频均已建树,无需处理。")
|
||||
return
|
||||
|
||||
# Phase 3: 异步视频间并行(ThreadPoolExecutor + FIRST_COMPLETED 事件循环)
|
||||
built: List[str] = []
|
||||
failed: List[Tuple[str, str]] = []
|
||||
start_total = time.time()
|
||||
|
||||
pending_futures: Dict[Future, Path] = {}
|
||||
pending_queue = list(pending) # 待提交队列
|
||||
pool = ThreadPoolExecutor(max_workers=args.jobs)
|
||||
|
||||
# 初始填满 jobs 个任务
|
||||
while pending_queue and len(pending_futures) < args.jobs:
|
||||
video = pending_queue.pop(0)
|
||||
fut = pool.submit(_build_one, video, cfg)
|
||||
pending_futures[fut] = video
|
||||
print(f"[提交] {video.stem} ({len(built)+len(failed)+len(pending_futures)}/{len(pending)})")
|
||||
|
||||
# 事件循环:完成一个,补一个
|
||||
done_count = 0
|
||||
while pending_futures:
|
||||
done, _ = cfwait(list(pending_futures), return_when=FIRST_COMPLETED)
|
||||
for fut in done:
|
||||
video = pending_futures.pop(fut)
|
||||
stem, success, msg = fut.result()
|
||||
done_count += 1
|
||||
elapsed = time.time() - start_total
|
||||
if success:
|
||||
built.append(stem)
|
||||
print(f"[OK {done_count}/{len(pending)}] {stem} {msg} (累计 {elapsed:.0f}s)")
|
||||
else:
|
||||
failed.append((stem, msg))
|
||||
print(f"[FAIL {done_count}/{len(pending)}] {stem} {msg}")
|
||||
|
||||
# 补充下一个任务(非阻塞提交)
|
||||
if pending_queue:
|
||||
next_video = pending_queue.pop(0)
|
||||
next_fut = pool.submit(_build_one, next_video, cfg)
|
||||
pending_futures[next_fut] = next_video
|
||||
print(f"[提交] {next_video.stem} ({done_count+len(pending_futures)}/{len(pending)})")
|
||||
|
||||
pool.shutdown(wait=False)
|
||||
|
||||
# Phase 4: 汇总
|
||||
total_elapsed = time.time() - start_total
|
||||
print(f"\n===== 汇总 =====")
|
||||
print(f" 成功: {len(built)}")
|
||||
print(f" 失败: {len(failed)}")
|
||||
print(f" 跳过: {skipped}")
|
||||
print(f" 总耗时: {total_elapsed:.1f}s")
|
||||
if failed:
|
||||
print("\n 失败列表:")
|
||||
for stem, err in failed:
|
||||
print(f" {stem}: {err}")
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"批量建树完成",
|
||||
built=len(built),
|
||||
failed=len(failed),
|
||||
skipped=skipped,
|
||||
elapsed_s=round(total_elapsed, 1),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# build_trees_from_mp4.sh — 从本地 MP4 批量建树(JSON 输出,无 embedding)
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/build_trees_from_mp4.sh
|
||||
# bash scripts/build_trees_from_mp4.sh data/videomme/videos/TGom0uiW130.mp4 # 单文件
|
||||
#
|
||||
# 环境变量:
|
||||
# VIDEO_DIR — MP4 目录(默认: <PROJECT_ROOT>/data/videomme/videos)
|
||||
# CONFIG — 配置文件(默认: config/videomme.yaml)
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
VIDEO_DIR="${VIDEO_DIR:-${PROJECT_ROOT}/data/videomme/videos}"
|
||||
CONFIG="${CONFIG:-${PROJECT_ROOT}/config/videomme.yaml}"
|
||||
TREE_DIR="${PROJECT_ROOT}/data/videomme/trees"
|
||||
LOG_DIR="${PROJECT_ROOT}/data/videomme/logs"
|
||||
LOG="${LOG_DIR}/mp4_build_$(date +%Y%m%d_%H%M%S).log"
|
||||
FAILED="${LOG_DIR}/failed_mp4_builds.txt"
|
||||
|
||||
mkdir -p "${TREE_DIR}" "${LOG_DIR}"
|
||||
|
||||
# 绕过代理
|
||||
export NO_PROXY="${NO_PROXY:+${NO_PROXY},}100.83.164.94"
|
||||
export no_proxy="${no_proxy:+${no_proxy},}100.83.164.94"
|
||||
|
||||
# 激活 conda 环境
|
||||
CONDA_BASE="$(conda info --base 2>/dev/null || echo "${HOME}/miniconda3")"
|
||||
# shellcheck source=/dev/null
|
||||
source "${CONDA_BASE}/etc/profile.d/conda.sh"
|
||||
conda activate Video-Tree-TRM
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
# 确定待处理文件列表
|
||||
if [[ $# -gt 0 ]]; then
|
||||
MP4_FILES=("$@")
|
||||
else
|
||||
mapfile -t MP4_FILES < <(find "${VIDEO_DIR}" -name "*.mp4" | sort)
|
||||
fi
|
||||
|
||||
TOTAL=${#MP4_FILES[@]}
|
||||
BUILT=0; SKIP=0; FAIL=0
|
||||
|
||||
echo "[$(date)] ===== MP4 本地建树开始 =====" | tee "${LOG}"
|
||||
echo "[$(date)] 待处理: ${TOTAL} 个视频" | tee -a "${LOG}"
|
||||
echo "[$(date)] 配置: ${CONFIG}" | tee -a "${LOG}"
|
||||
|
||||
for MP4 in "${MP4_FILES[@]}"; do
|
||||
[[ -z "${MP4}" ]] && continue
|
||||
STEM="$(basename "${MP4}" .mp4)"
|
||||
CACHE="${TREE_DIR}/${STEM}_video.json"
|
||||
|
||||
# 缓存命中跳过
|
||||
if [[ -f "${CACHE}" ]]; then
|
||||
SKIP=$((SKIP+1))
|
||||
echo "[$(date)] [SKIP] ${STEM}" | tee -a "${LOG}"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[$(date)] [BUILD] ${STEM} file=${MP4}" | tee -a "${LOG}"
|
||||
|
||||
if python -u main.py index \
|
||||
--source "${MP4}" \
|
||||
--modality video \
|
||||
--config "${CONFIG}" \
|
||||
>> "${LOG}" 2>&1; then
|
||||
BUILT=$((BUILT+1))
|
||||
echo "[$(date)] [OK] ${STEM}" | tee -a "${LOG}"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
echo "[$(date)] [FAIL] ${STEM}" | tee -a "${LOG}"
|
||||
echo "${STEM} ${MP4}" >> "${FAILED}"
|
||||
fi
|
||||
done
|
||||
|
||||
TREE_COUNT=$(find "${TREE_DIR}" -name "*_video.json" 2>/dev/null | wc -l)
|
||||
echo "" | tee -a "${LOG}"
|
||||
echo "[$(date)] ===== 汇总 =====" | tee -a "${LOG}"
|
||||
echo "[$(date)] 本次新建: ${BUILT} 跳过: ${SKIP} 失败: ${FAIL}" | tee -a "${LOG}"
|
||||
echo "[$(date)] 树索引总数: ${TREE_COUNT}" | tee -a "${LOG}"
|
||||
[[ ${FAIL} -gt 0 ]] && echo "[$(date)] 失败列表: ${FAILED}" | tee -a "${LOG}"
|
||||
echo "[$(date)] 日志: ${LOG}" | tee -a "${LOG}"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# build_trees_from_urls.sh — 直接从 YouTube URL 批量建树(不下载视频)
|
||||
#
|
||||
# 用法:
|
||||
# # 全量(300 个长视频)
|
||||
# bash scripts/build_trees_from_urls.sh
|
||||
#
|
||||
# # 只处理前 N 个
|
||||
# head -10 data/videomme/metadata/long_videos.jsonl \
|
||||
# | bash scripts/build_trees_from_urls.sh --stdin
|
||||
#
|
||||
# 环境变量:
|
||||
# DATA_DIR — 数据根目录(默认: <PROJECT_ROOT>/data/videomme)
|
||||
# CONFIG — 配置文件路径(默认: config/videomme.yaml)
|
||||
#
|
||||
# 特性:
|
||||
# - 自动激活 Video-Tree-TRM conda 环境
|
||||
# - 缓存命中跳过(trees/{youtube_id}_video.pkl 已存在则跳过)
|
||||
# - 断点续传(重复运行安全)
|
||||
# - 失败记录写入 logs/failed_url_builds.txt
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 0. 全局配置
|
||||
# ---------------------------------------------------------------------------
|
||||
DATA_DIR="${DATA_DIR:-${PROJECT_ROOT}/data/videomme}"
|
||||
CONFIG="${CONFIG:-${PROJECT_ROOT}/config/videomme.yaml}"
|
||||
JSONL="${DATA_DIR}/metadata/long_videos.jsonl"
|
||||
TREE_DIR="${DATA_DIR}/trees"
|
||||
LOG_DIR="${DATA_DIR}/logs"
|
||||
LOG="${LOG_DIR}/url_build_$(date +%Y%m%d_%H%M%S).log"
|
||||
FAILED="${LOG_DIR}/failed_url_builds.txt"
|
||||
|
||||
mkdir -p "${TREE_DIR}" "${LOG_DIR}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 0.5 绕过代理:GPU 内网地址直连,不经过 http_proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
export NO_PROXY="${NO_PROXY:+${NO_PROXY},}100.83.164.94"
|
||||
export no_proxy="${no_proxy:+${no_proxy},}100.83.164.94"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 激活 conda 环境
|
||||
# ---------------------------------------------------------------------------
|
||||
CONDA_BASE="$(conda info --base 2>/dev/null || echo "${HOME}/miniconda3")"
|
||||
# shellcheck source=/dev/null
|
||||
source "${CONDA_BASE}/etc/profile.d/conda.sh"
|
||||
conda activate Video-Tree-TRM
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 解析参数
|
||||
# ---------------------------------------------------------------------------
|
||||
STDIN_MODE=false
|
||||
for arg in "$@"; do
|
||||
[[ "${arg}" == "--stdin" ]] && STDIN_MODE=true
|
||||
done
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 读取输入
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "${STDIN_MODE}" == true ]]; then
|
||||
# 从 stdin 读取(支持 head -N | bash ... --stdin)
|
||||
INPUT_DATA="$(cat)"
|
||||
else
|
||||
INPUT_DATA="$(cat "${JSONL}")"
|
||||
fi
|
||||
|
||||
TOTAL=$(echo "${INPUT_DATA}" | wc -l)
|
||||
BUILT=0; SKIP=0; FAIL=0
|
||||
|
||||
echo "[$(date)] ===== URL 流式建树开始 =====" | tee "${LOG}"
|
||||
echo "[$(date)] 待处理: ${TOTAL} 个长视频" | tee -a "${LOG}"
|
||||
echo "[$(date)] 配置: ${CONFIG}" | tee -a "${LOG}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 主循环
|
||||
# ---------------------------------------------------------------------------
|
||||
while IFS= read -r line; do
|
||||
[[ -z "${line}" ]] && continue
|
||||
|
||||
YID=$(python -c "import sys,json; print(json.loads(sys.argv[1])['youtube_id'])" "${line}")
|
||||
URL=$(python -c "import sys,json; print(json.loads(sys.argv[1])['url'])" "${line}")
|
||||
CACHE="${TREE_DIR}/${YID}_video.json"
|
||||
|
||||
# 缓存命中跳过
|
||||
if [[ -f "${CACHE}" ]]; then
|
||||
SKIP=$((SKIP+1))
|
||||
echo "[$(date)] [SKIP] ${YID}" | tee -a "${LOG}"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[$(date)] [BUILD] ${YID} url=${URL}" | tee -a "${LOG}"
|
||||
|
||||
if python main.py index \
|
||||
--source "${URL}" \
|
||||
--modality video \
|
||||
--config "${CONFIG}" \
|
||||
>> "${LOG}" 2>&1; then
|
||||
BUILT=$((BUILT+1))
|
||||
echo "[$(date)] [OK] ${YID}" | tee -a "${LOG}"
|
||||
else
|
||||
FAIL=$((FAIL+1))
|
||||
echo "[$(date)] [FAIL] ${YID}" | tee -a "${LOG}"
|
||||
echo "${YID} ${URL}" >> "${FAILED}"
|
||||
fi
|
||||
|
||||
done <<< "${INPUT_DATA}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 汇总
|
||||
# ---------------------------------------------------------------------------
|
||||
TREE_COUNT=$(find "${TREE_DIR}" -name "*_video.json" 2>/dev/null | wc -l)
|
||||
echo "" | tee -a "${LOG}"
|
||||
echo "[$(date)] ===== 汇总 =====" | tee -a "${LOG}"
|
||||
echo "[$(date)] 本次新建: ${BUILT} 跳过: ${SKIP} 失败: ${FAIL}" | tee -a "${LOG}"
|
||||
echo "[$(date)] 树索引总数: ${TREE_COUNT} / ${TOTAL}" | tee -a "${LOG}"
|
||||
[[ ${FAIL} -gt 0 ]] && echo "[$(date)] 失败列表: ${FAILED}" | tee -a "${LOG}"
|
||||
echo "[$(date)] 日志: ${LOG}" | tee -a "${LOG}"
|
||||
Executable
+320
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# build_videomme_trees.sh — VideoMME 长视频数据预处理:下载 + 建树
|
||||
# =============================================================================
|
||||
# 功能:
|
||||
# 1. 初始化目录结构 (/data/videomme/...)
|
||||
# 2. 激活 Conda 环境 (Video-Tree-TRM)
|
||||
# 3. 安装必要工具 (yt-dlp, datasets)
|
||||
# 4. 从 HuggingFace 下载 VideoMME 元数据,提取长视频列表
|
||||
# 5. 用 yt-dlp 批量下载长视频(断点续传,跳过已下载)
|
||||
# 6. 为每个视频调用 main.py index 建树(跳过已缓存)
|
||||
# 7. 汇总日志
|
||||
#
|
||||
# 使用方式:
|
||||
# cd /home/undergraduate/Video-Tree-TRM
|
||||
# bash scripts/build_videomme_trees.sh
|
||||
#
|
||||
# 可选环境变量覆盖:
|
||||
# DATA_DIR=/other/path bash scripts/build_videomme_trees.sh
|
||||
# WORKERS=4 bash scripts/build_videomme_trees.sh # 并行建树进程数
|
||||
#
|
||||
# 断点续传:
|
||||
# 重复运行完全安全 —— yt-dlp 跳过已下载文件,main.py 跳过缓存命中的树。
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 0. 全局配置
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
CONDA_ENV="${CONDA_ENV:-Video-Tree-TRM}"
|
||||
DATA_DIR="${DATA_DIR:-${PROJECT_ROOT}/data/videomme}"
|
||||
VIDEO_DIR="${DATA_DIR}/videos"
|
||||
META_DIR="${DATA_DIR}/metadata"
|
||||
TREE_DIR="${DATA_DIR}/trees"
|
||||
LOG_DIR="${DATA_DIR}/logs"
|
||||
CKPT_DIR="${DATA_DIR}/checkpoints"
|
||||
|
||||
CONFIG_YAML="${PROJECT_ROOT}/config/videomme.yaml"
|
||||
ENV_FILE="${PROJECT_ROOT}/.env"
|
||||
META_SCRIPT="${SCRIPT_DIR}/_download_meta.py"
|
||||
|
||||
WORKERS="${WORKERS:-1}" # 并行建树进程数(默认串行,保护 API 速率)
|
||||
MIN_DURATION="${MIN_DURATION:-1800}" # 长视频最短时长(秒)
|
||||
MAX_DURATION="${MAX_DURATION:-3600}" # 长视频最长时长(秒)
|
||||
YTDLP_RATE="${YTDLP_RATE:-500K}" # yt-dlp 下载限速(防封)
|
||||
YTDLP_RETRIES="${YTDLP_RETRIES:-5}" # yt-dlp 重试次数
|
||||
|
||||
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
||||
MAIN_LOG="${LOG_DIR}/build_${TIMESTAMP}.log"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $(date '+%H:%M:%S') $*" | tee -a "${MAIN_LOG}"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $(date '+%H:%M:%S') $*" | tee -a "${MAIN_LOG}"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $(date '+%H:%M:%S') $*" | tee -a "${MAIN_LOG}"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 创建目录结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 1: 初始化目录结构 ==="
|
||||
mkdir -p "${VIDEO_DIR}" "${META_DIR}" "${TREE_DIR}" "${LOG_DIR}" "${CKPT_DIR}"
|
||||
info "数据目录已就绪: ${DATA_DIR}"
|
||||
info " videos/ → ${VIDEO_DIR}"
|
||||
info " metadata/ → ${META_DIR}"
|
||||
info " trees/ → ${TREE_DIR}"
|
||||
info " logs/ → ${LOG_DIR}"
|
||||
info " checkpoints/ → ${CKPT_DIR}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 激活 Conda 环境
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 2: 激活 Conda 环境 (${CONDA_ENV}) ==="
|
||||
|
||||
# 找到 conda 初始化脚本
|
||||
CONDA_BASE="$(conda info --base 2>/dev/null || echo "")"
|
||||
if [[ -z "${CONDA_BASE}" ]]; then
|
||||
error "未找到 conda,请确保 conda 已安装并在 PATH 中"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${CONDA_BASE}/etc/profile.d/conda.sh"
|
||||
conda activate "${CONDA_ENV}"
|
||||
info "已激活环境: $(conda info --envs | grep '*' | awk '{print $1}')"
|
||||
info "Python 路径: $(which python)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 安装必要工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 3: 安装必要工具 ==="
|
||||
|
||||
pip install --quiet --upgrade yt-dlp datasets
|
||||
info "yt-dlp 版本: $(yt-dlp --version)"
|
||||
python -c "import datasets; print(f'datasets 版本: {datasets.__version__}')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 下载 VideoMME 元数据,提取长视频列表
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 4: 下载 VideoMME 元数据 ==="
|
||||
|
||||
LONG_VIDEOS_JSONL="${META_DIR}/long_videos.jsonl"
|
||||
LONG_QA_JSONL="${META_DIR}/long_videos_qa.jsonl"
|
||||
|
||||
if [[ -f "${LONG_VIDEOS_JSONL}" ]]; then
|
||||
EXISTING_COUNT=$(wc -l < "${LONG_VIDEOS_JSONL}")
|
||||
warn "元数据已存在 (${EXISTING_COUNT} 条长视频),跳过下载。如需重新下载,删除 ${LONG_VIDEOS_JSONL}"
|
||||
else
|
||||
# 配置 HuggingFace 镜像(国内加速,如已能直连可注释掉)
|
||||
export HF_ENDPOINT="${HF_ENDPOINT:-https://hf-mirror.com}"
|
||||
info "HuggingFace 端点: ${HF_ENDPOINT}"
|
||||
|
||||
python "${META_SCRIPT}" \
|
||||
--meta-dir "${META_DIR}" \
|
||||
--min-duration "${MIN_DURATION}" \
|
||||
--max-duration "${MAX_DURATION}" \
|
||||
2>&1 | tee -a "${MAIN_LOG}"
|
||||
fi
|
||||
|
||||
# 确认文件存在
|
||||
if [[ ! -f "${LONG_VIDEOS_JSONL}" ]]; then
|
||||
error "元数据文件不存在,元数据下载失败: ${LONG_VIDEOS_JSONL}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOTAL_VIDEOS=$(wc -l < "${LONG_VIDEOS_JSONL}")
|
||||
info "长视频总数: ${TOTAL_VIDEOS}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. 批量下载长视频(yt-dlp,断点续传)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 5: 批量下载长视频 ==="
|
||||
info "下载目录: ${VIDEO_DIR}"
|
||||
info "限速: ${YTDLP_RATE},重试次数: ${YTDLP_RETRIES}"
|
||||
|
||||
DOWNLOAD_LOG="${LOG_DIR}/download_${TIMESTAMP}.log"
|
||||
FAILED_DOWNLOADS="${LOG_DIR}/failed_downloads_${TIMESTAMP}.txt"
|
||||
DOWNLOAD_COUNT=0
|
||||
SKIP_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
|
||||
while IFS= read -r line; do
|
||||
VIDEO_ID=$(echo "${line}" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('youtube_id') or d.get('video_id',''))")
|
||||
URL=$(echo "${line}" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('url',''))")
|
||||
|
||||
if [[ -z "${VIDEO_ID}" || -z "${URL}" ]]; then
|
||||
warn "跳过无效记录: ${line:0:80}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 检查是否已下载(任意格式均算)
|
||||
EXISTING_FILE=$(find "${VIDEO_DIR}" -name "${VIDEO_ID}.*" -type f 2>/dev/null | head -1)
|
||||
if [[ -n "${EXISTING_FILE}" ]]; then
|
||||
SKIP_COUNT=$((SKIP_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
info "[下载 ${DOWNLOAD_COUNT}/${TOTAL_VIDEOS}] ${VIDEO_ID}"
|
||||
|
||||
# yt-dlp 下载:优先 mp4,最高 720p(节省空间),单文件断点续传
|
||||
if yt-dlp \
|
||||
--output "${VIDEO_DIR}/%(id)s.%(ext)s" \
|
||||
--format "bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/best[ext=mp4][height<=720]/best" \
|
||||
--merge-output-format mp4 \
|
||||
--retries "${YTDLP_RETRIES}" \
|
||||
--rate-limit "${YTDLP_RATE}" \
|
||||
--no-playlist \
|
||||
--continue \
|
||||
--no-overwrites \
|
||||
--write-info-json \
|
||||
--quiet \
|
||||
"${URL}" \
|
||||
>> "${DOWNLOAD_LOG}" 2>&1; then
|
||||
DOWNLOAD_COUNT=$((DOWNLOAD_COUNT + 1))
|
||||
info " ✓ 下载成功: ${VIDEO_ID}"
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||
warn " ✗ 下载失败: ${VIDEO_ID} (${URL})"
|
||||
echo "${VIDEO_ID} ${URL}" >> "${FAILED_DOWNLOADS}"
|
||||
fi
|
||||
|
||||
done < "${LONG_VIDEOS_JSONL}"
|
||||
|
||||
info "下载汇总: 新下载=${DOWNLOAD_COUNT}, 跳过=${SKIP_COUNT}, 失败=${FAIL_COUNT}"
|
||||
if [[ ${FAIL_COUNT} -gt 0 ]]; then
|
||||
warn "失败列表已保存至: ${FAILED_DOWNLOADS}"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. 批量建树(main.py index,跳过缓存命中)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 6: 批量建树 ==="
|
||||
info "项目根目录: ${PROJECT_ROOT}"
|
||||
info "配置文件: ${CONFIG_YAML}"
|
||||
info "并行进程数: ${WORKERS}"
|
||||
|
||||
BUILD_LOG="${LOG_DIR}/build_trees_${TIMESTAMP}.log"
|
||||
FAILED_BUILDS="${LOG_DIR}/failed_builds_${TIMESTAMP}.txt"
|
||||
BUILD_COUNT=0
|
||||
BUILD_SKIP=0
|
||||
BUILD_FAIL=0
|
||||
|
||||
# 构建函数(单个视频)
|
||||
build_one_video() {
|
||||
local video_path="$1"
|
||||
local video_stem
|
||||
video_stem="$(basename "${video_path%.*}")"
|
||||
local cache_file="${TREE_DIR}/${video_stem}_video.pkl"
|
||||
|
||||
# 缓存命中则跳过(pipeline.py 内部也会检查,此处提前判断减少日志噪声)
|
||||
if [[ -f "${cache_file}" ]]; then
|
||||
echo "[SKIP] ${video_stem}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[BUILD] ${video_stem} ← ${video_path}"
|
||||
if conda run -n "${CONDA_ENV}" python "${PROJECT_ROOT}/main.py" \
|
||||
index \
|
||||
--source "${video_path}" \
|
||||
--modality video \
|
||||
--config "${CONFIG_YAML}" \
|
||||
--env "${ENV_FILE}" \
|
||||
>> "${BUILD_LOG}" 2>&1; then
|
||||
echo "[OK] ${video_stem}"
|
||||
return 0
|
||||
else
|
||||
echo "[FAIL] ${video_stem}"
|
||||
echo "${video_path}" >> "${FAILED_BUILDS}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
export -f build_one_video
|
||||
export CONDA_ENV PROJECT_ROOT CONFIG_YAML ENV_FILE TREE_DIR BUILD_LOG FAILED_BUILDS
|
||||
|
||||
if [[ "${WORKERS}" -gt 1 ]]; then
|
||||
# 并行模式:使用 GNU parallel
|
||||
if ! command -v parallel &> /dev/null; then
|
||||
warn "未找到 GNU parallel,降级为串行模式"
|
||||
WORKERS=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${WORKERS}" -gt 1 ]]; then
|
||||
info "并行建树 (jobs=${WORKERS})..."
|
||||
find "${VIDEO_DIR}" -type f \( -name "*.mp4" -o -name "*.avi" -o -name "*.mkv" -o -name "*.webm" \) \
|
||||
| parallel -j "${WORKERS}" --bar build_one_video {} \
|
||||
2>&1 | tee -a "${MAIN_LOG}"
|
||||
else
|
||||
# 串行模式
|
||||
while IFS= read -r -d '' video_path; do
|
||||
video_stem="$(basename "${video_path%.*}")"
|
||||
cache_file="${TREE_DIR}/${video_stem}_video.pkl"
|
||||
|
||||
if [[ -f "${cache_file}" ]]; then
|
||||
BUILD_SKIP=$((BUILD_SKIP + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
info "[建树 $((BUILD_COUNT + BUILD_SKIP + 1))/${TOTAL_VIDEOS}] ${video_stem}"
|
||||
if conda run -n "${CONDA_ENV}" python "${PROJECT_ROOT}/main.py" \
|
||||
index \
|
||||
--source "${video_path}" \
|
||||
--modality video \
|
||||
--config "${CONFIG_YAML}" \
|
||||
--env "${ENV_FILE}" \
|
||||
>> "${BUILD_LOG}" 2>&1; then
|
||||
BUILD_COUNT=$((BUILD_COUNT + 1))
|
||||
info " ✓ 建树成功: ${video_stem}"
|
||||
else
|
||||
BUILD_FAIL=$((BUILD_FAIL + 1))
|
||||
warn " ✗ 建树失败: ${video_stem}"
|
||||
echo "${video_path}" >> "${FAILED_BUILDS}"
|
||||
fi
|
||||
done < <(find "${VIDEO_DIR}" -type f \( -name "*.mp4" -o -name "*.avi" -o -name "*.mkv" -o -name "*.webm" \) -print0)
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. 最终汇总
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "=== Step 7: 汇总 ==="
|
||||
|
||||
TREE_COUNT=$(find "${TREE_DIR}" -name "*_video.pkl" -type f 2>/dev/null | wc -l)
|
||||
VIDEO_COUNT=$(find "${VIDEO_DIR}" -type f \( -name "*.mp4" -o -name "*.avi" -o -name "*.mkv" -o -name "*.webm" \) 2>/dev/null | wc -l)
|
||||
|
||||
info "======================================"
|
||||
info " 长视频元数据数量: ${TOTAL_VIDEOS}"
|
||||
info " 已下载视频数量: ${VIDEO_COUNT}"
|
||||
info " 已完成树索引数量: ${TREE_COUNT}"
|
||||
info " 本次新建树: ${BUILD_COUNT}"
|
||||
info " 跳过(缓存命中): ${BUILD_SKIP}"
|
||||
info " 建树失败: ${BUILD_FAIL}"
|
||||
info "======================================"
|
||||
info "主日志: ${MAIN_LOG}"
|
||||
info "下载日志: ${DOWNLOAD_LOG}"
|
||||
info "建树日志: ${BUILD_LOG}"
|
||||
|
||||
if [[ ${BUILD_FAIL} -gt 0 ]]; then
|
||||
warn "有 ${BUILD_FAIL} 个视频建树失败,详见: ${FAILED_BUILDS}"
|
||||
warn "可重新运行脚本以续建失败项(自动跳过已缓存)"
|
||||
fi
|
||||
|
||||
if [[ "${TREE_COUNT}" -ge "${TOTAL_VIDEOS}" ]]; then
|
||||
info "✅ 所有长视频树索引已构建完成!"
|
||||
else
|
||||
warn "⚠ 树索引尚未全部完成 (${TREE_COUNT}/${TOTAL_VIDEOS}),可重新运行以续建"
|
||||
fi
|
||||
|
||||
info "脚本完成。"
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# download_videos.sh — 批量下载 VideoMME 长视频(MP4 含音轨)
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/download_videos.sh # 下载全部未完成的视频
|
||||
#
|
||||
# 特性:
|
||||
# - 已存在的 mp4 自动跳过(断点续传)
|
||||
# - bestvideo+bestaudio 合并,保证有音轨
|
||||
# - 并发数: 3(避免被 YouTube 限流)
|
||||
# - 失败记录写入 logs/failed_downloads.txt
|
||||
# =============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
JSONL="${PROJECT_ROOT}/data/videomme/metadata/long_videos.jsonl"
|
||||
VIDEO_DIR="${PROJECT_ROOT}/data/videomme/videos"
|
||||
LOG_DIR="${PROJECT_ROOT}/data/videomme/logs"
|
||||
LOG="${LOG_DIR}/download_$(date +%Y%m%d_%H%M%S).log"
|
||||
FAILED_FILE="${LOG_DIR}/failed_downloads.txt"
|
||||
CONCURRENT=3
|
||||
|
||||
mkdir -p "${VIDEO_DIR}" "${LOG_DIR}"
|
||||
|
||||
# 激活 conda 环境(yt-dlp、ffmpeg 在此环境中)
|
||||
CONDA_BASE="$(conda info --base 2>/dev/null || echo "${HOME}/miniconda3")"
|
||||
# shellcheck source=/dev/null
|
||||
source "${CONDA_BASE}/etc/profile.d/conda.sh"
|
||||
conda activate Video-Tree-TRM
|
||||
|
||||
echo "[$(date)] ===== 批量下载开始 =====" | tee "${LOG}"
|
||||
echo "[$(date)] 视频目录: ${VIDEO_DIR}" | tee -a "${LOG}"
|
||||
|
||||
# 读取所有 youtube_id,写入临时文件供 xargs 读取
|
||||
TMP_IDS=$(mktemp)
|
||||
python3 -c "
|
||||
import json
|
||||
with open('${JSONL}') as f:
|
||||
for line in f:
|
||||
d = json.loads(line.strip())
|
||||
print(d['youtube_id'])
|
||||
" > "${TMP_IDS}"
|
||||
|
||||
TOTAL=$(wc -l < "${TMP_IDS}")
|
||||
echo "[$(date)] 总计: ${TOTAL} 个视频,并发数: ${CONCURRENT}" | tee -a "${LOG}"
|
||||
|
||||
# 3 2.5Mb/s并发下载:每个 youtube_id 单独调用 yt-dlp
|
||||
# 注意:直接在 xargs 中内联参数,不依赖 bash 数组导出
|
||||
xargs -P "${CONCURRENT}" -I {} bash -c '
|
||||
VIDEO_DIR="'"${VIDEO_DIR}"'"
|
||||
LOG="'"${LOG}"'"
|
||||
FAILED_FILE="'"${FAILED_FILE}"'"
|
||||
YID="{}"
|
||||
OUT="${VIDEO_DIR}/${YID}.mp4"
|
||||
|
||||
if [[ -f "${OUT}" && -s "${OUT}" ]]; then
|
||||
echo "[$(date)] [SKIP] ${YID}" >> "${LOG}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
URL="https://www.youtube.com/watch?v=${YID}"
|
||||
echo "[$(date)] [START] ${YID}" >> "${LOG}"
|
||||
|
||||
if yt-dlp \
|
||||
--format "bestvideo[vcodec^=avc][ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best[ext=mp4]/best" \
|
||||
--merge-output-format mp4 \
|
||||
--output "${OUT}" \
|
||||
--no-playlist \
|
||||
--retries 5 \
|
||||
--fragment-retries 5 \
|
||||
--socket-timeout 60 \
|
||||
--no-warnings \
|
||||
--rate-limit 833K \
|
||||
"${URL}" >> "${LOG}" 2>&1; then
|
||||
echo "[$(date)] [OK] ${YID} size=$(du -sh "${OUT}" 2>/dev/null | cut -f1)" >> "${LOG}"
|
||||
else
|
||||
echo "[$(date)] [FAIL] ${YID}" >> "${LOG}"
|
||||
echo "${YID}" >> "${FAILED_FILE}"
|
||||
fi
|
||||
' < "${TMP_IDS}"
|
||||
|
||||
rm -f "${TMP_IDS}"
|
||||
|
||||
TOTAL_MP4=$(find "${VIDEO_DIR}" -name "*.mp4" -size +0c | wc -l)
|
||||
FAILED_COUNT=$(wc -l < "${FAILED_FILE}" 2>/dev/null || echo 0)
|
||||
|
||||
echo "" | tee -a "${LOG}"
|
||||
echo "[$(date)] ===== 汇总 =====" | tee -a "${LOG}"
|
||||
echo "[$(date)] 目录共 MP4: ${TOTAL_MP4} / ${TOTAL}" | tee -a "${LOG}"
|
||||
[[ "${FAILED_COUNT}" -gt 0 ]] && echo "[$(date)] 失败数: ${FAILED_COUNT},列表: ${FAILED_FILE}" | tee -a "${LOG}"
|
||||
echo "[$(date)] 日志: ${LOG}" | tee -a "${LOG}"
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
全局测试配置
|
||||
============
|
||||
- 代理修复: 从 .env 读取 EMBED_API_URL,将其中的 CGNAT 段 IP 加入 no_proxy。
|
||||
- real_config fixture: 从真实 config/default.yaml + .env 加载 Config,供远程测试复用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 代理修复: 将内网 API 地址加入 no_proxy(模块加载时立即执行)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CGNAT_NETWORK = ipaddress.IPv4Network("100.64.0.0/10")
|
||||
|
||||
|
||||
def _fix_no_proxy() -> None:
|
||||
"""从 .env 读取 API URL,将 CGNAT 段 IP 加入 no_proxy / NO_PROXY。
|
||||
|
||||
httpx/urllib 的 no_proxy 不支持 CIDR 记法,必须逐个添加具体 IP。
|
||||
"""
|
||||
if not (os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY")):
|
||||
return
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
env_file = project_root / ".env"
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
env_vars = dotenv_values(str(env_file))
|
||||
|
||||
# 收集所有需要绕过代理的 IP
|
||||
hosts_to_add: list[str] = []
|
||||
for url_key in ("EMBED_API_URL", "LLM_API_URL", "VLM_API_URL"):
|
||||
url = env_vars.get(url_key, "")
|
||||
if not url:
|
||||
continue
|
||||
match = re.match(r"https?://([^:/]+)", url)
|
||||
if not match:
|
||||
continue
|
||||
host = match.group(1)
|
||||
try:
|
||||
if ipaddress.IPv4Address(host) in _CGNAT_NETWORK:
|
||||
hosts_to_add.append(host)
|
||||
except (ipaddress.AddressValueError, ValueError):
|
||||
pass
|
||||
|
||||
if not hosts_to_add:
|
||||
return
|
||||
|
||||
for var in ("no_proxy", "NO_PROXY"):
|
||||
current = os.environ.get(var, "")
|
||||
for host in hosts_to_add:
|
||||
if host not in current:
|
||||
separator = "," if current else ""
|
||||
current = f"{current}{separator}{host}"
|
||||
os.environ[var] = current
|
||||
|
||||
|
||||
# 模块加载时执行代理修复
|
||||
_fix_no_proxy()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def real_config():
|
||||
"""从真实 config/default.yaml + .env 加载 Config(session 级缓存)。
|
||||
|
||||
用于远程 API 测试,需要 .env 中配置有效的 API 密钥。
|
||||
"""
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
yaml_path = project_root / "config" / "default.yaml"
|
||||
env_path = project_root / ".env"
|
||||
|
||||
return Config.load(str(yaml_path), env_path=str(env_path))
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
配置管理模块单元测试
|
||||
====================
|
||||
覆盖: YAML 加载、.env 覆盖、CLI 覆盖、缺字段报错、优先级验证、embed_dim 一致性。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from video_tree_trm.config import Config, TreeConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FULL_YAML = {
|
||||
"tree": {
|
||||
"max_paragraphs_per_l2": 5,
|
||||
"l1_segment_duration": 600.0,
|
||||
"l2_clip_duration": 20.0,
|
||||
"l3_fps": 1.0,
|
||||
"l2_representative_frames": 3,
|
||||
"cache_dir": "cache/trees",
|
||||
},
|
||||
"embed": {
|
||||
"backend": "local",
|
||||
"model_name": "test-model",
|
||||
"embed_dim": 2560,
|
||||
"device": "cpu",
|
||||
"api_key": "",
|
||||
"api_url": "",
|
||||
},
|
||||
"llm": {
|
||||
"backend": "qwen",
|
||||
"api_key": "yaml-llm-key",
|
||||
"model": "qwen-plus",
|
||||
"api_url": "https://example.com/llm",
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.1,
|
||||
},
|
||||
"vlm": {
|
||||
"backend": "qwen",
|
||||
"api_key": "yaml-vlm-key",
|
||||
"model": "qwen-vl-plus",
|
||||
"api_url": "https://example.com/vlm",
|
||||
"max_tokens": 256,
|
||||
"temperature": 0.1,
|
||||
},
|
||||
"retriever": {
|
||||
"embed_dim": 2560,
|
||||
"num_heads": 4,
|
||||
"L_layers": 2,
|
||||
"L_cycles": 4,
|
||||
"max_rounds": 5,
|
||||
"ffn_expansion": 2.0,
|
||||
"checkpoint": None,
|
||||
},
|
||||
"train": {
|
||||
"lr": 1e-4,
|
||||
"weight_decay": 1e-5,
|
||||
"batch_size": 1,
|
||||
"max_epochs_phase1": 30,
|
||||
"max_epochs_phase2": 20,
|
||||
"nav_loss_weight": 1.0,
|
||||
"act_loss_weight": 0.1,
|
||||
"act_lambda_step": 0.1,
|
||||
"act_gamma": 0.9,
|
||||
"eval_interval": 5,
|
||||
"save_dir": "checkpoints",
|
||||
"dataset": "longbench",
|
||||
"dataset_path": "data/longbench",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def yaml_path(tmp_path: Path) -> Path:
|
||||
"""创建完整配置的临时 YAML 文件。"""
|
||||
p = tmp_path / "config" / "default.yaml"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
yaml.dump(_FULL_YAML, f, allow_unicode=True)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env_path(tmp_path: Path) -> Path:
|
||||
"""创建临时 .env 文件。"""
|
||||
p = tmp_path / ".env"
|
||||
p.write_text(
|
||||
"LLM_API_KEY=env-llm-key\n"
|
||||
"LLM_MODEL=env-llm-model\n"
|
||||
"LLM_API_URL=https://env.example.com/llm\n"
|
||||
"VLM_API_KEY=env-vlm-key\n"
|
||||
"VLM_MODEL=env-vlm-model\n"
|
||||
"VLM_API_URL=https://env.example.com/vlm\n"
|
||||
"EMBED_BACKEND=remote\n"
|
||||
"EMBED_MODEL=env-embed-model\n"
|
||||
"EMBED_API_KEY=env-embed-key\n"
|
||||
"EMBED_API_URL=https://env.example.com/embed\n"
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: YAML 加载
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestYAMLLoad:
|
||||
"""YAML 基础加载测试。"""
|
||||
|
||||
def test_load_full_yaml(self, yaml_path: Path) -> None:
|
||||
"""完整 YAML 应成功加载所有字段。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path), env_path=str(yaml_path.parent / ".env.nonexist")
|
||||
)
|
||||
assert isinstance(cfg.tree, TreeConfig)
|
||||
assert cfg.tree.max_paragraphs_per_l2 == 5
|
||||
assert cfg.tree.l1_segment_duration == 600.0
|
||||
assert cfg.embed.embed_dim == 2560
|
||||
assert cfg.retriever.checkpoint is None
|
||||
assert cfg.train.dataset == "longbench"
|
||||
|
||||
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||
"""不存在的 YAML 应抛出 FileNotFoundError。"""
|
||||
with pytest.raises(FileNotFoundError, match="配置文件不存在"):
|
||||
Config.load(str(tmp_path / "nonexist.yaml"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 缺字段报错
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingField:
|
||||
"""缺少必需字段时应抛出 TypeError。"""
|
||||
|
||||
def test_missing_tree_field(self, tmp_path: Path) -> None:
|
||||
"""tree 节缺少字段应报 TypeError。"""
|
||||
bad_yaml = _FULL_YAML.copy()
|
||||
bad_yaml = {
|
||||
k: (v.copy() if isinstance(v, dict) else v) for k, v in _FULL_YAML.items()
|
||||
}
|
||||
del bad_yaml["tree"]["cache_dir"]
|
||||
|
||||
p = tmp_path / "bad.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
|
||||
def test_missing_section(self, tmp_path: Path) -> None:
|
||||
"""缺少整个配置节应报 TypeError。"""
|
||||
bad_yaml = {k: v for k, v in _FULL_YAML.items() if k != "train"}
|
||||
|
||||
p = tmp_path / "bad2.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: .env 覆盖
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnvOverride:
|
||||
""".env 文件应覆盖 YAML 中的 api_key。"""
|
||||
|
||||
def test_env_overrides_api_keys(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""api_key/model/api_url 应优先使用 .env 中的值。"""
|
||||
cfg = Config.load(str(yaml_path), env_path=str(env_path))
|
||||
assert cfg.llm.api_key == "env-llm-key"
|
||||
assert cfg.llm.model == "env-llm-model"
|
||||
assert cfg.llm.api_url == "https://env.example.com/llm"
|
||||
assert cfg.vlm.api_key == "env-vlm-key"
|
||||
assert cfg.vlm.model == "env-vlm-model"
|
||||
assert cfg.vlm.api_url == "https://env.example.com/vlm"
|
||||
|
||||
def test_env_overrides_embed(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""embed 相关字段应优先使用 .env 中的值。"""
|
||||
cfg = Config.load(str(yaml_path), env_path=str(env_path))
|
||||
assert cfg.embed.backend == "remote"
|
||||
assert cfg.embed.model_name == "env-embed-model"
|
||||
assert cfg.embed.api_key == "env-embed-key"
|
||||
assert cfg.embed.api_url == "https://env.example.com/embed"
|
||||
|
||||
def test_yaml_fallback_when_no_env(self, yaml_path: Path) -> None:
|
||||
"""无 .env 时应使用 YAML 中的值。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path), env_path=str(yaml_path.parent / ".env.nonexist")
|
||||
)
|
||||
assert cfg.llm.api_key == "yaml-llm-key"
|
||||
assert cfg.vlm.api_key == "yaml-vlm-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: CLI 覆盖
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCLIOverride:
|
||||
"""CLI args 应覆盖 YAML 和 .env 的值。"""
|
||||
|
||||
def test_cli_overrides_yaml(self, yaml_path: Path) -> None:
|
||||
"""CLI 点路径覆盖应生效。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"retriever.num_heads": 8, "train.lr": 0.001},
|
||||
env_path=str(yaml_path.parent / ".env.nonexist"),
|
||||
)
|
||||
assert cfg.retriever.num_heads == 8
|
||||
assert cfg.train.lr == 0.001
|
||||
|
||||
def test_cli_overrides_env(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""CLI 应覆盖 .env 中的 api_key。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"llm.api_key": "cli-key"},
|
||||
env_path=str(env_path),
|
||||
)
|
||||
assert cfg.llm.api_key == "cli-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 优先级
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPriority:
|
||||
"""三层优先级: CLI > .env > YAML。"""
|
||||
|
||||
def test_full_priority_chain(self, yaml_path: Path, env_path: Path) -> None:
|
||||
"""CLI > .env > YAML 的完整优先级链。"""
|
||||
cfg = Config.load(
|
||||
str(yaml_path),
|
||||
cli_args={"llm.api_key": "cli-key"},
|
||||
env_path=str(env_path),
|
||||
)
|
||||
# CLI 覆盖 .env
|
||||
assert cfg.llm.api_key == "cli-key"
|
||||
# .env 覆盖 YAML(vlm 未被 CLI 覆盖)
|
||||
assert cfg.vlm.api_key == "env-vlm-key"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: embed_dim 一致性校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmbedDimConsistency:
|
||||
"""embed.embed_dim 与 retriever.embed_dim 必须一致。"""
|
||||
|
||||
def test_inconsistent_embed_dim(self, tmp_path: Path) -> None:
|
||||
"""embed_dim 不一致应抛出 ValueError。"""
|
||||
bad_yaml = {
|
||||
k: (v.copy() if isinstance(v, dict) else v) for k, v in _FULL_YAML.items()
|
||||
}
|
||||
bad_yaml["retriever"] = bad_yaml["retriever"].copy()
|
||||
bad_yaml["retriever"]["embed_dim"] = 512 # 与 embed.embed_dim=768 不一致
|
||||
|
||||
p = tmp_path / "bad_dim.yaml"
|
||||
with open(p, "w") as f:
|
||||
yaml.dump(bad_yaml, f)
|
||||
|
||||
with pytest.raises(ValueError, match="不一致"):
|
||||
Config.load(str(p), env_path=str(tmp_path / ".env.nonexist"))
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Embedding 持久化单元测试
|
||||
=======================
|
||||
测试 TreeIndex 的 embedding 序列化/反序列化功能。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from video_tree_trm.tree_index import (
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
IndexMeta,
|
||||
TreeIndex,
|
||||
_embed_to_str,
|
||||
_embed_from_str,
|
||||
)
|
||||
|
||||
|
||||
class TestEmbeddingSerialization:
|
||||
"""测试 embedding 序列化辅助函数。"""
|
||||
|
||||
def test_embed_to_str_and_back(self):
|
||||
"""测试 base64 序列化/反序列化往返正确。"""
|
||||
arr = np.random.randn(768).astype(np.float32)
|
||||
s = _embed_to_str(arr)
|
||||
recovered = _embed_from_str(s)
|
||||
|
||||
assert recovered is not None
|
||||
assert recovered.dtype == np.float32
|
||||
assert recovered.shape == (768,)
|
||||
np.testing.assert_array_almost_equal(arr, recovered, decimal=6)
|
||||
|
||||
def test_embed_to_str_handles_none(self):
|
||||
"""测试 None 输入返回 None。"""
|
||||
assert _embed_to_str(None) is None
|
||||
assert _embed_from_str(None) is None
|
||||
assert _embed_from_str("") is None
|
||||
|
||||
|
||||
class TestL3NodeEmbedding:
|
||||
"""测试 L3Node embedding 序列化。"""
|
||||
|
||||
def test_l3_to_dict_without_embedding(self):
|
||||
"""测试不带 embedding 序列化。"""
|
||||
node = L3Node(
|
||||
id="l3_0",
|
||||
description="测试描述",
|
||||
timestamp=1.0,
|
||||
frame_path="frame.jpg",
|
||||
raw_content="原始内容",
|
||||
)
|
||||
d = {
|
||||
"id": node.id,
|
||||
"description": node.description,
|
||||
"timestamp": node.timestamp,
|
||||
"frame_path": node.frame_path,
|
||||
"raw_content": node.raw_content,
|
||||
}
|
||||
# 无 embedding 字段
|
||||
assert "embedding" not in d
|
||||
|
||||
def test_l3_embedding_roundtrip(self):
|
||||
"""测试 L3 embedding 序列化往返。"""
|
||||
embed = np.random.randn(768).astype(np.float32)
|
||||
s = _embed_to_str(embed)
|
||||
recovered = _embed_from_str(s)
|
||||
np.testing.assert_array_almost_equal(embed, recovered, decimal=6)
|
||||
|
||||
|
||||
class TestTreeIndexEmbedding:
|
||||
"""测试 TreeIndex 完整序列化/反序列化。"""
|
||||
|
||||
def test_save_load_without_embedding(self):
|
||||
"""测试不带 embedding 保存/加载(向后兼容)。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test.json")
|
||||
|
||||
# 创建简单树
|
||||
l3 = L3Node(id="l3_0", description="L3 描述")
|
||||
l2 = L2Node(id="l2_0", description="L2 描述", children=[l3])
|
||||
l1 = L1Node(id="l1_0", summary="L1 摘要", children=[l2])
|
||||
meta = IndexMeta(source_path="test.mp4", modality="video")
|
||||
tree = TreeIndex(metadata=meta, roots=[l1])
|
||||
|
||||
# 保存(不含 embedding)
|
||||
tree.save_json(path, include_embedding=False)
|
||||
|
||||
# 加载
|
||||
loaded = TreeIndex.load_json(path)
|
||||
|
||||
assert len(loaded.roots) == 1
|
||||
assert loaded.roots[0].summary == "L1 摘要"
|
||||
assert not loaded.is_embedded # 无 embedding
|
||||
|
||||
def test_save_load_with_embedding(self):
|
||||
"""测试带 embedding 保存/加载。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test_embed.json")
|
||||
|
||||
# 创建带 embedding 的树
|
||||
embed_l1 = np.random.randn(768).astype(np.float32)
|
||||
embed_l2 = np.random.randn(768).astype(np.float32)
|
||||
embed_l3 = np.random.randn(768).astype(np.float32)
|
||||
|
||||
l3 = L3Node(id="l3_0", description="L3 描述", embedding=embed_l3)
|
||||
l2 = L2Node(id="l2_0", description="L2 描述", embedding=embed_l2, children=[l3])
|
||||
l1 = L1Node(id="l1_0", summary="L1 摘要", embedding=embed_l1, children=[l2])
|
||||
meta = IndexMeta(
|
||||
source_path="test.mp4",
|
||||
modality="video",
|
||||
embed_model="test-model",
|
||||
embed_dim=768,
|
||||
)
|
||||
tree = TreeIndex(metadata=meta, roots=[l1])
|
||||
|
||||
# 保存(含 embedding)
|
||||
tree.save_json(path, include_embedding=True)
|
||||
|
||||
# 验证 JSON 中有 embedding 字段
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
assert "embedding" in data["roots"][0]
|
||||
assert data["metadata"]["embed_model"] == "test-model"
|
||||
|
||||
# 加载
|
||||
loaded = TreeIndex.load_json(path)
|
||||
|
||||
assert loaded.is_embedded
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.roots[0].embedding, embed_l1, decimal=6
|
||||
)
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.roots[0].children[0].embedding, embed_l2, decimal=6
|
||||
)
|
||||
np.testing.assert_array_almost_equal(
|
||||
loaded.roots[0].children[0].children[0].embedding, embed_l3, decimal=6
|
||||
)
|
||||
|
||||
def test_load_old_format_compatible(self):
|
||||
"""测试加载旧格式(无 embedding 字段)JSON 兼容。"""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "old_format.json")
|
||||
|
||||
# 手动创建旧格式 JSON(无 embedding 字段)
|
||||
old_data = {
|
||||
"metadata": {
|
||||
"source_path": "test.mp4",
|
||||
"modality": "video",
|
||||
"created_at": "2024-01-01T00:00:00",
|
||||
},
|
||||
"roots": [
|
||||
{
|
||||
"id": "l1_0",
|
||||
"summary": "L1 摘要",
|
||||
"time_range": None,
|
||||
"children": [
|
||||
{
|
||||
"id": "l2_0",
|
||||
"description": "L2 描述",
|
||||
"time_range": None,
|
||||
"children": [
|
||||
{
|
||||
"id": "l3_0",
|
||||
"description": "L3 描述",
|
||||
"timestamp": 1.0,
|
||||
"frame_path": None,
|
||||
"raw_content": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(old_data, f)
|
||||
|
||||
# 加载
|
||||
loaded = TreeIndex.load_json(path)
|
||||
|
||||
assert len(loaded.roots) == 1
|
||||
assert not loaded.is_embedded # 无 embedding,向后兼容
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
嵌入服务模块单元测试
|
||||
====================
|
||||
覆盖: 本地模式(embed/embed_tensor/归一化/dim/冻结)、远程模式(真实 API 调用)。
|
||||
|
||||
本地测试使用轻量模型 all-MiniLM-L6-v2 (dim=384) 加速。
|
||||
远程测试使用 .env 中配置的真实 API(需有效密钥)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from video_tree_trm.config import Config, EmbedConfig
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOCAL_CONFIG = EmbedConfig(
|
||||
backend="local",
|
||||
model_name="all-MiniLM-L6-v2",
|
||||
embed_dim=384,
|
||||
device="cpu",
|
||||
api_key="",
|
||||
api_url="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def local_model() -> EmbeddingModel:
|
||||
"""本地嵌入模型(模块级缓存,避免重复加载)。"""
|
||||
return EmbeddingModel(_LOCAL_CONFIG)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def remote_model(real_config: Config) -> EmbeddingModel:
|
||||
"""远程嵌入模型(模块级缓存),使用真实 API。"""
|
||||
return EmbeddingModel(real_config.embed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 本地模式
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocalEmbed:
|
||||
"""本地 sentence-transformers 后端测试。"""
|
||||
|
||||
def test_embed_single_text(self, local_model: EmbeddingModel) -> None:
|
||||
"""单条文本,验证形状 [1, D]。"""
|
||||
result = local_model.embed("hello world")
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == (1, 384)
|
||||
|
||||
def test_embed_batch(self, local_model: EmbeddingModel) -> None:
|
||||
"""批量文本,验证形状 [N, D]。"""
|
||||
texts = ["hello", "world", "test"]
|
||||
result = local_model.embed(texts)
|
||||
assert result.shape == (3, 384)
|
||||
|
||||
def test_embed_tensor_type(self, local_model: EmbeddingModel) -> None:
|
||||
"""验证 embed_tensor() 返回 Tensor。"""
|
||||
result = local_model.embed_tensor("hello")
|
||||
assert isinstance(result, torch.Tensor)
|
||||
assert result.shape == (1, 384)
|
||||
assert result.dtype == torch.float32
|
||||
|
||||
def test_embeddings_normalized(self, local_model: EmbeddingModel) -> None:
|
||||
"""验证 L2 范数 ≈ 1.0。"""
|
||||
result = local_model.embed(["hello", "world"])
|
||||
norms = np.linalg.norm(result, axis=1)
|
||||
np.testing.assert_allclose(norms, 1.0, atol=1e-5)
|
||||
|
||||
def test_dim_property(self, local_model: EmbeddingModel) -> None:
|
||||
"""验证 dim 属性一致。"""
|
||||
assert local_model.dim == 384
|
||||
|
||||
def test_local_model_frozen(self, local_model: EmbeddingModel) -> None:
|
||||
"""本地模式参数 requires_grad=False。"""
|
||||
for param in local_model._model.parameters():
|
||||
assert not param.requires_grad
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 远程模式(真实 API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRemoteEmbed:
|
||||
"""远程 OpenAI 兼容 API 后端测试(真实 API 调用)。"""
|
||||
|
||||
def test_remote_embed_single(self, remote_model: EmbeddingModel) -> None:
|
||||
"""单条中文文本,验证形状 [1, D]。"""
|
||||
result = remote_model.embed("你好世界")
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == (1, remote_model.dim)
|
||||
|
||||
def test_remote_embed_batch(self, remote_model: EmbeddingModel) -> None:
|
||||
"""5 条文本,验证形状 [5, D]。"""
|
||||
texts = ["机器学习", "深度学习", "自然语言处理", "计算机视觉", "强化学习"]
|
||||
result = remote_model.embed(texts)
|
||||
assert result.shape == (5, remote_model.dim)
|
||||
|
||||
def test_remote_embed_normalized(self, remote_model: EmbeddingModel) -> None:
|
||||
"""验证 L2 范数 ≈ 1.0。"""
|
||||
result = remote_model.embed(["归一化测试文本", "另一段测试文本"])
|
||||
norms = np.linalg.norm(result, axis=1)
|
||||
np.testing.assert_allclose(norms, 1.0, atol=1e-5)
|
||||
|
||||
def test_remote_embed_tensor(self, remote_model: EmbeddingModel) -> None:
|
||||
"""验证返回 torch.Tensor + float32。"""
|
||||
result = remote_model.embed_tensor("张量测试")
|
||||
assert isinstance(result, torch.Tensor)
|
||||
assert result.dtype == torch.float32
|
||||
assert result.shape == (1, remote_model.dim)
|
||||
|
||||
def test_remote_dim(self, remote_model: EmbeddingModel, real_config: Config) -> None:
|
||||
"""验证 dim 属性与配置一致。"""
|
||||
assert remote_model.dim == real_config.embed.embed_dim
|
||||
|
||||
def test_remote_semantic_similarity(self, remote_model: EmbeddingModel) -> None:
|
||||
"""语义相近文本相似度 > 语义无关文本。"""
|
||||
# 语义相近对
|
||||
vec_cat = remote_model.embed("猫咪在沙发上睡觉")
|
||||
vec_kitten = remote_model.embed("小猫趴在沙发上打盹")
|
||||
# 语义无关
|
||||
vec_math = remote_model.embed("二次方程的求解公式")
|
||||
|
||||
sim_close = float(np.dot(vec_cat[0], vec_kitten[0]))
|
||||
sim_far = float(np.dot(vec_cat[0], vec_math[0]))
|
||||
assert sim_close > sim_far, (
|
||||
f"语义相近对相似度 ({sim_close:.4f}) 应大于语义无关对 ({sim_far:.4f})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 配置校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""配置校验测试。"""
|
||||
|
||||
def test_invalid_backend(self) -> None:
|
||||
"""无效 backend 应抛出 ValueError。"""
|
||||
config = EmbedConfig(
|
||||
backend="invalid",
|
||||
model_name="test",
|
||||
embed_dim=384,
|
||||
device="cpu",
|
||||
api_key="",
|
||||
api_url="",
|
||||
)
|
||||
with pytest.raises(ValueError, match="backend"):
|
||||
EmbeddingModel(config)
|
||||
|
||||
def test_remote_missing_api_key(self) -> None:
|
||||
"""远程模式缺少 api_key 应抛出 ValueError。"""
|
||||
config = EmbedConfig(
|
||||
backend="remote",
|
||||
model_name="test",
|
||||
embed_dim=384,
|
||||
device="cpu",
|
||||
api_key="",
|
||||
api_url="http://localhost:8080/v1",
|
||||
)
|
||||
with pytest.raises(ValueError, match="api_key"):
|
||||
EmbeddingModel(config)
|
||||
|
||||
def test_remote_missing_api_url(self) -> None:
|
||||
"""远程模式缺少 api_url 应抛出 ValueError。"""
|
||||
config = EmbedConfig(
|
||||
backend="remote",
|
||||
model_name="test",
|
||||
embed_dim=384,
|
||||
device="cpu",
|
||||
api_key="test-key",
|
||||
api_url="",
|
||||
)
|
||||
with pytest.raises(ValueError, match="api_url"):
|
||||
EmbeddingModel(config)
|
||||
|
||||
def test_local_dim_mismatch(self) -> None:
|
||||
"""本地模式维度不一致应抛出 ValueError。"""
|
||||
config = EmbedConfig(
|
||||
backend="local",
|
||||
model_name="all-MiniLM-L6-v2",
|
||||
embed_dim=999, # 实际是 384
|
||||
device="cpu",
|
||||
api_key="",
|
||||
api_url="",
|
||||
)
|
||||
with pytest.raises(ValueError, match="维度"):
|
||||
EmbeddingModel(config)
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
LLMClient 单元测试
|
||||
==================
|
||||
测试覆盖:
|
||||
- TestLLMChatMock: mock OpenAI 客户端的纯文本对话功能
|
||||
- TestVLMChatMock: mock 环境下的多模态图像对话功能
|
||||
- TestConfigValidation: 配置校验(api_key/api_url 缺失)
|
||||
- TestRealLLMChat: 真实 API 集成测试(需 .env 配置)
|
||||
|
||||
运行::
|
||||
|
||||
conda run -n Video-Tree-TRM python -m pytest tests/unit/test_llm_client.py -v \\
|
||||
--cov=video_tree_trm/llm_client --cov-report=term-missing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from video_tree_trm.config import LLMConfig, VLMConfig
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助:构造最小配置对象(避免加载真实 YAML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_llm_config(
|
||||
api_key: str = "sk-test",
|
||||
api_url: str = "https://api.example.com/v1",
|
||||
model: str = "test-model",
|
||||
max_tokens: int = 128,
|
||||
temperature: float = 0.1,
|
||||
) -> LLMConfig:
|
||||
"""构造测试用 LLMConfig,所有字段可覆盖。"""
|
||||
return LLMConfig(
|
||||
backend="openai",
|
||||
api_key=api_key,
|
||||
api_url=api_url,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
|
||||
def _make_vlm_config(
|
||||
api_key: str = "sk-test",
|
||||
api_url: str = "https://api.example.com/v1",
|
||||
model: str = "test-vlm",
|
||||
max_tokens: int = 128,
|
||||
temperature: float = 0.1,
|
||||
) -> VLMConfig:
|
||||
"""构造测试用 VLMConfig。"""
|
||||
return VLMConfig(
|
||||
backend="openai",
|
||||
api_key=api_key,
|
||||
api_url=api_url,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
|
||||
def _mock_completion(content: str) -> MagicMock:
|
||||
"""构造 openai.ChatCompletion 返回值的 Mock。"""
|
||||
choice = MagicMock()
|
||||
choice.message.content = content
|
||||
response = MagicMock()
|
||||
response.choices = [choice]
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestLLMChatMock — 纯文本对话(Mock)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLLMChatMock:
|
||||
"""使用 mock openai.OpenAI 测试 chat() 和 batch_chat()。"""
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_returns_string(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""chat() 应返回 API 返回内容的字符串。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("你好!")
|
||||
|
||||
llm = LLMClient(_make_llm_config())
|
||||
result = llm.chat("你好")
|
||||
|
||||
assert result == "你好!"
|
||||
assert isinstance(result, str)
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_uses_config_max_tokens(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""未传 max_tokens 时,应使用 config.max_tokens 的值。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("ok")
|
||||
|
||||
cfg = _make_llm_config(max_tokens=256)
|
||||
llm = LLMClient(cfg)
|
||||
llm.chat("test")
|
||||
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
assert call_kwargs["max_tokens"] == 256
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_overrides_max_tokens(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""显式传入 max_tokens 时,应覆盖 config.max_tokens。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("ok")
|
||||
|
||||
cfg = _make_llm_config(max_tokens=256)
|
||||
llm = LLMClient(cfg)
|
||||
llm.chat("test", max_tokens=64)
|
||||
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
assert call_kwargs["max_tokens"] == 64
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_batch_chat_order_preserved(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""batch_chat() 应按输入顺序返回结果,即使并发完成顺序不同。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
|
||||
# 每次调用返回不同内容
|
||||
responses = ["结果0", "结果1", "结果2"]
|
||||
mock_client.chat.completions.create.side_effect = [
|
||||
_mock_completion(r) for r in responses
|
||||
]
|
||||
|
||||
llm = LLMClient(_make_llm_config())
|
||||
results = llm.batch_chat(["prompt0", "prompt1", "prompt2"])
|
||||
|
||||
assert len(results) == 3
|
||||
assert results == responses
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_batch_chat_empty_list(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""batch_chat() 传入空列表时,应返回空列表。"""
|
||||
mock_openai_cls.return_value = MagicMock()
|
||||
llm = LLMClient(_make_llm_config())
|
||||
assert llm.batch_chat([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestVLMChatMock — 多模态对话(Mock)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestVLMChatMock:
|
||||
"""使用 mock openai.OpenAI 测试 chat_with_images()。"""
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_with_images_encodes_local_path(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""传入本地文件路径时,消息中应包含 data:image/jpeg;base64, 前缀。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("图中有猫")
|
||||
|
||||
# 创建临时 JPEG 文件
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
|
||||
f.write(b"\xff\xd8\xff\xe0" + b"\x00" * 20) # 最小 JPEG header
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
vlm = LLMClient(_make_vlm_config())
|
||||
result = vlm.chat_with_images("图中有什么?", images=[tmp_path])
|
||||
|
||||
assert result == "图中有猫"
|
||||
# 验证消息结构包含 base64 编码图像
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
content = call_kwargs["messages"][0]["content"]
|
||||
assert isinstance(content, list)
|
||||
image_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert len(image_items) == 1
|
||||
assert image_items[0]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_with_images_accepts_b64(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""传入已有 base64 字符串时,不应重复编码,直接透传。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("ok")
|
||||
|
||||
b64_str = "data:image/jpeg;base64," + base64.b64encode(b"fake").decode()
|
||||
vlm = LLMClient(_make_vlm_config())
|
||||
vlm.chat_with_images("描述图片", images=[b64_str])
|
||||
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
content = call_kwargs["messages"][0]["content"]
|
||||
image_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert image_items[0]["image_url"]["url"] == b64_str
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_with_images_png_mime(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""PNG 文件应编码为 data:image/png;base64, 格式。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("ok")
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
|
||||
f.write(b"\x89PNG\r\n\x1a\n" + b"\x00" * 20)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
vlm = LLMClient(_make_vlm_config())
|
||||
vlm.chat_with_images("描述图片", images=[tmp_path])
|
||||
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
content = call_kwargs["messages"][0]["content"]
|
||||
image_items = [c for c in content if c.get("type") == "image_url"]
|
||||
assert image_items[0]["image_url"]["url"].startswith("data:image/png;base64,")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_chat_with_images_message_structure(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""多模态消息中图像应在文本之前。"""
|
||||
mock_client = MagicMock()
|
||||
mock_openai_cls.return_value = mock_client
|
||||
mock_client.chat.completions.create.return_value = _mock_completion("ok")
|
||||
|
||||
b64_str = "data:image/jpeg;base64," + base64.b64encode(b"img").decode()
|
||||
vlm = LLMClient(_make_vlm_config())
|
||||
vlm.chat_with_images("提问", images=[b64_str])
|
||||
|
||||
call_kwargs = mock_client.chat.completions.create.call_args[1]
|
||||
content = call_kwargs["messages"][0]["content"]
|
||||
# 最后一项为 text
|
||||
assert content[-1]["type"] == "text"
|
||||
assert content[-1]["text"] == "提问"
|
||||
# 前面各项为 image_url
|
||||
for item in content[:-1]:
|
||||
assert item["type"] == "image_url"
|
||||
|
||||
def test_encode_image_file_not_found(self) -> None:
|
||||
"""_encode_image 传入不存在的路径时,应抛出 FileNotFoundError。"""
|
||||
with patch("video_tree_trm.llm_client.openai.OpenAI"):
|
||||
llm = LLMClient(_make_llm_config())
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
llm._encode_image("/nonexistent/path/image.jpg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestConfigValidation — 配置校验
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""测试 LLMClient 初始化时的配置校验逻辑。"""
|
||||
|
||||
def test_missing_api_key_raises(self) -> None:
|
||||
"""api_key 为空时应抛出 ValueError。"""
|
||||
cfg = _make_llm_config(api_key="")
|
||||
with pytest.raises(ValueError, match="api_key"):
|
||||
LLMClient(cfg)
|
||||
|
||||
def test_missing_api_url_raises(self) -> None:
|
||||
"""api_url 为空时应抛出 ValueError。"""
|
||||
cfg = _make_llm_config(api_url="")
|
||||
with pytest.raises(ValueError, match="api_url"):
|
||||
LLMClient(cfg)
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_valid_config_initializes(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""有效配置应正常初始化,不抛出异常。"""
|
||||
mock_openai_cls.return_value = MagicMock()
|
||||
cfg = _make_llm_config()
|
||||
client = LLMClient(cfg)
|
||||
assert client is not None
|
||||
|
||||
@patch("video_tree_trm.llm_client.openai.OpenAI")
|
||||
def test_vlm_config_accepted(self, mock_openai_cls: MagicMock) -> None:
|
||||
"""VLMConfig 也应被正常接受。"""
|
||||
mock_openai_cls.return_value = MagicMock()
|
||||
cfg = _make_vlm_config()
|
||||
client = LLMClient(cfg)
|
||||
assert client is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestRealLLMChat — 真实 API 集成测试(需 .env)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRealLLMChat:
|
||||
"""调用真实 LLM API 进行集成测试。
|
||||
|
||||
需要 .env 中配置有效的 LLM_API_KEY / LLM_API_URL / LLM_MODEL。
|
||||
"""
|
||||
|
||||
def test_real_chat(self, real_config) -> None: # noqa: ANN001
|
||||
"""真实 API 单轮对话,应返回非空字符串。"""
|
||||
llm = LLMClient(real_config.llm)
|
||||
result = llm.chat("请用一句话回答:天空是什么颜色?", max_tokens=32)
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_real_batch_chat(self, real_config) -> None: # noqa: ANN001
|
||||
"""真实 API 批量对话,应返回与输入等长的非空字符串列表。"""
|
||||
llm = LLMClient(real_config.llm)
|
||||
prompts = ["1+1等于几?请只回答数字。", "2+2等于几?请只回答数字。"]
|
||||
results = llm.batch_chat(prompts, max_tokens=16)
|
||||
assert len(results) == 2
|
||||
for r in results:
|
||||
assert isinstance(r, str)
|
||||
assert len(r) > 0
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
test_pipeline.py — Pipeline 单元测试
|
||||
======================================
|
||||
使用 unittest.mock.MagicMock + patch 隔离所有外部依赖(无真实 API / 文件 IO)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from video_tree_trm.pipeline import Pipeline
|
||||
from video_tree_trm.tree_index import IndexMeta, L1Node, L2Node, L3Node, TreeIndex
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助:构造最小 Config Mock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
D = 8 # 嵌入维度(与 RetrieverConfig 一致)
|
||||
|
||||
|
||||
def _make_config(checkpoint: str | None = None) -> MagicMock:
|
||||
"""返回一个 Mock Config,字段值与实际 dataclass 对齐。"""
|
||||
cfg = MagicMock()
|
||||
cfg.embed.model_name = "test-embed"
|
||||
cfg.embed.embed_dim = D
|
||||
cfg.retriever.checkpoint = checkpoint
|
||||
cfg.retriever.embed_dim = D
|
||||
cfg.retriever.num_heads = 2
|
||||
cfg.retriever.L_layers = 2
|
||||
cfg.retriever.L_cycles = 2
|
||||
cfg.retriever.max_rounds = 2
|
||||
cfg.retriever.ffn_expansion = 2.0
|
||||
cfg.tree.cache_dir = "/tmp/test_pipeline_cache"
|
||||
return cfg
|
||||
|
||||
|
||||
def _make_small_tree() -> TreeIndex:
|
||||
"""构造最小 1×1×1 TreeIndex,用于 query() 测试。"""
|
||||
meta = IndexMeta(
|
||||
source_path="dummy",
|
||||
modality="text",
|
||||
embed_model="test",
|
||||
embed_dim=D,
|
||||
)
|
||||
l3 = L3Node(
|
||||
id="l3_0",
|
||||
description="节点描述",
|
||||
embedding=np.zeros(D, dtype=np.float32),
|
||||
raw_content="节点内容",
|
||||
)
|
||||
l2 = L2Node(
|
||||
id="l2_0",
|
||||
description="L2",
|
||||
embedding=np.zeros(D, dtype=np.float32),
|
||||
children=[l3],
|
||||
)
|
||||
l1 = L1Node(
|
||||
id="l1_0",
|
||||
summary="L1",
|
||||
embedding=np.zeros(D, dtype=np.float32),
|
||||
children=[l2],
|
||||
)
|
||||
return TreeIndex(metadata=meta, roots=[l1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch 工厂:将所有子模块构造函数替换为 MagicMock
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATCHES = [
|
||||
"video_tree_trm.pipeline.EmbeddingModel",
|
||||
"video_tree_trm.pipeline.LLMClient",
|
||||
"video_tree_trm.pipeline.RecursiveRetriever",
|
||||
"video_tree_trm.pipeline.AnswerGenerator",
|
||||
"video_tree_trm.pipeline.TextTreeBuilder",
|
||||
"video_tree_trm.pipeline.VideoTreeBuilder",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline.__init__ 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pipeline_init_components() -> None:
|
||||
"""__init__ 后各属性(embed_model/llm/vlm/retriever/generator)均存在。"""
|
||||
cfg = _make_config()
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(),
|
||||
AnswerGenerator=MagicMock(),
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
|
||||
assert hasattr(p, "embed_model"), "缺少 embed_model 属性"
|
||||
assert hasattr(p, "llm"), "缺少 llm 属性"
|
||||
assert hasattr(p, "vlm"), "缺少 vlm 属性"
|
||||
assert hasattr(p, "retriever"), "缺少 retriever 属性"
|
||||
assert hasattr(p, "generator"), "缺少 generator 属性"
|
||||
|
||||
|
||||
def test_pipeline_init_no_checkpoint() -> None:
|
||||
"""checkpoint=None 时 load_state_dict 不被调用。"""
|
||||
cfg = _make_config(checkpoint=None)
|
||||
mock_retriever_instance = MagicMock()
|
||||
MockRetriever = MagicMock(return_value=mock_retriever_instance)
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MockRetriever,
|
||||
AnswerGenerator=MagicMock(),
|
||||
):
|
||||
Pipeline(cfg)
|
||||
|
||||
mock_retriever_instance.load_state_dict.assert_not_called()
|
||||
|
||||
|
||||
def test_pipeline_init_with_checkpoint(tmp_path: Path) -> None:
|
||||
"""checkpoint 非 None 时 load_state_dict 被调用一次。"""
|
||||
ckpt_file = tmp_path / "model.pt"
|
||||
ckpt_file.write_bytes(b"") # 创建空文件,使 os.path.isfile 返回 True
|
||||
|
||||
cfg = _make_config(checkpoint=str(ckpt_file))
|
||||
mock_retriever_instance = MagicMock()
|
||||
MockRetriever = MagicMock(return_value=mock_retriever_instance)
|
||||
|
||||
fake_state_dict = {"weight": torch.zeros(1)}
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MockRetriever,
|
||||
AnswerGenerator=MagicMock(),
|
||||
), patch("video_tree_trm.pipeline.torch.load", return_value=fake_state_dict):
|
||||
Pipeline(cfg)
|
||||
|
||||
mock_retriever_instance.load_state_dict.assert_called_once_with(fake_state_dict)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline.build_index 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_index_text_calls_builder(tmp_path: Path) -> None:
|
||||
"""文本模式调用 TextTreeBuilder.build,参数含文件内容。"""
|
||||
src = tmp_path / "doc.txt"
|
||||
src.write_text("文档内容", encoding="utf-8")
|
||||
|
||||
cfg = _make_config()
|
||||
cfg.tree.cache_dir = str(tmp_path / "cache")
|
||||
|
||||
mock_tree = MagicMock(spec=TreeIndex)
|
||||
mock_builder_instance = MagicMock()
|
||||
mock_builder_instance.build.return_value = mock_tree
|
||||
MockTextBuilder = MagicMock(return_value=mock_builder_instance)
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(),
|
||||
AnswerGenerator=MagicMock(),
|
||||
TextTreeBuilder=MockTextBuilder,
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
result = p.build_index(str(src), modality="text")
|
||||
|
||||
mock_builder_instance.build.assert_called_once()
|
||||
call_args = mock_builder_instance.build.call_args
|
||||
assert "文档内容" in call_args[0][0], "TextTreeBuilder.build 应传入文件内容"
|
||||
assert result is mock_tree
|
||||
|
||||
|
||||
def test_build_index_video_calls_builder(tmp_path: Path) -> None:
|
||||
"""视频模式调用 VideoTreeBuilder.build,参数为 source_path。"""
|
||||
cfg = _make_config()
|
||||
cfg.tree.cache_dir = str(tmp_path / "cache")
|
||||
|
||||
mock_tree = MagicMock(spec=TreeIndex)
|
||||
mock_builder_instance = MagicMock()
|
||||
mock_builder_instance.build.return_value = mock_tree
|
||||
MockVideoBuilder = MagicMock(return_value=mock_builder_instance)
|
||||
|
||||
video_path = "/fake/video.mp4"
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(),
|
||||
AnswerGenerator=MagicMock(),
|
||||
VideoTreeBuilder=MockVideoBuilder,
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
result = p.build_index(video_path, modality="video")
|
||||
|
||||
mock_builder_instance.build.assert_called_once_with(video_path)
|
||||
assert result is mock_tree
|
||||
|
||||
|
||||
def test_build_index_cache_hit(tmp_path: Path) -> None:
|
||||
"""缓存文件存在时直接 TreeIndex.load,不重新构建。"""
|
||||
cfg = _make_config()
|
||||
cache_dir = tmp_path / "cache"
|
||||
cache_dir.mkdir()
|
||||
cfg.tree.cache_dir = str(cache_dir)
|
||||
|
||||
# 手动创建缓存文件(空文件即可让 isfile 返回 True)
|
||||
cache_file = cache_dir / "doc_text.pkl"
|
||||
cache_file.write_bytes(b"")
|
||||
|
||||
mock_tree = MagicMock(spec=TreeIndex)
|
||||
mock_text_builder = MagicMock()
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(),
|
||||
AnswerGenerator=MagicMock(),
|
||||
TextTreeBuilder=mock_text_builder,
|
||||
), patch("video_tree_trm.pipeline.TreeIndex.load", return_value=mock_tree) as mock_load:
|
||||
p = Pipeline(cfg)
|
||||
result = p.build_index(str(tmp_path / "doc.txt"), modality="text")
|
||||
|
||||
mock_load.assert_called_once_with(str(cache_file))
|
||||
mock_text_builder.return_value.build.assert_not_called()
|
||||
assert result is mock_tree
|
||||
|
||||
|
||||
def test_build_index_saves_cache(tmp_path: Path) -> None:
|
||||
"""缓存不存在时构建后调用 tree.save。"""
|
||||
cfg = _make_config()
|
||||
cfg.tree.cache_dir = str(tmp_path / "cache")
|
||||
|
||||
src = tmp_path / "doc.txt"
|
||||
src.write_text("内容", encoding="utf-8")
|
||||
|
||||
mock_tree = MagicMock(spec=TreeIndex)
|
||||
mock_builder_instance = MagicMock()
|
||||
mock_builder_instance.build.return_value = mock_tree
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(),
|
||||
AnswerGenerator=MagicMock(),
|
||||
TextTreeBuilder=MagicMock(return_value=mock_builder_instance),
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
p.build_index(str(src), modality="text")
|
||||
|
||||
mock_tree.save.assert_called_once()
|
||||
saved_path: str = mock_tree.save.call_args[0][0]
|
||||
assert "doc_text.pkl" in saved_path, f"保存路径应含 'doc_text.pkl',实际={saved_path}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline.query 测试
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_query_embeds_question() -> None:
|
||||
"""query() 调用 embed_model.embed_tensor(question)。"""
|
||||
cfg = _make_config()
|
||||
tree = _make_small_tree()
|
||||
|
||||
mock_embed = MagicMock()
|
||||
mock_embed.embed_tensor.return_value = torch.zeros(1, D)
|
||||
MockEmbed = MagicMock(return_value=mock_embed)
|
||||
|
||||
mock_retriever_instance = MagicMock()
|
||||
mock_retriever_instance.return_value = {"paths": [(0, 0, 0)], "num_rounds": 1}
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MockEmbed,
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(return_value=mock_retriever_instance),
|
||||
AnswerGenerator=MagicMock(),
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
p.query("测试问题", tree)
|
||||
|
||||
mock_embed.embed_tensor.assert_called_once_with("测试问题")
|
||||
|
||||
|
||||
def test_query_calls_retriever() -> None:
|
||||
"""query() 调用 retriever(q, tree)。"""
|
||||
cfg = _make_config()
|
||||
tree = _make_small_tree()
|
||||
|
||||
q_tensor = torch.zeros(1, D)
|
||||
mock_embed = MagicMock()
|
||||
mock_embed.embed_tensor.return_value = q_tensor
|
||||
|
||||
mock_retriever_instance = MagicMock()
|
||||
mock_retriever_instance.return_value = {"paths": [(0, 0, 0)], "num_rounds": 1}
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(return_value=mock_embed),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(return_value=mock_retriever_instance),
|
||||
AnswerGenerator=MagicMock(),
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
p.query("测试问题", tree)
|
||||
|
||||
mock_retriever_instance.assert_called_once()
|
||||
call_args = mock_retriever_instance.call_args
|
||||
# 第一个位置参数应为嵌入 Tensor,第二个为 tree
|
||||
assert call_args[0][1] is tree, "retriever 第二个参数应为 tree"
|
||||
|
||||
|
||||
def test_query_returns_answer() -> None:
|
||||
"""query() 返回 generator.generate 的返回值。"""
|
||||
cfg = _make_config()
|
||||
tree = _make_small_tree()
|
||||
|
||||
mock_embed = MagicMock()
|
||||
mock_embed.embed_tensor.return_value = torch.zeros(1, D)
|
||||
|
||||
mock_retriever_instance = MagicMock()
|
||||
mock_retriever_instance.return_value = {"paths": [(0, 0, 0)], "num_rounds": 1}
|
||||
|
||||
mock_generator_instance = MagicMock()
|
||||
mock_generator_instance.generate.return_value = "生成的答案"
|
||||
|
||||
with patch.multiple(
|
||||
"video_tree_trm.pipeline",
|
||||
EmbeddingModel=MagicMock(return_value=mock_embed),
|
||||
LLMClient=MagicMock(),
|
||||
RecursiveRetriever=MagicMock(return_value=mock_retriever_instance),
|
||||
AnswerGenerator=MagicMock(return_value=mock_generator_instance),
|
||||
):
|
||||
p = Pipeline(cfg)
|
||||
answer = p.query("问题", tree)
|
||||
|
||||
assert answer == "生成的答案", f"query() 应返回 generator 的结果,实际='{answer}'"
|
||||
mock_generator_instance.generate.assert_called_once_with(
|
||||
"问题", [(0, 0, 0)], tree
|
||||
)
|
||||
@@ -0,0 +1,546 @@
|
||||
"""
|
||||
TextTreeBuilder 单元测试
|
||||
========================
|
||||
使用 MagicMock 替代真实 LLM 和 Embed,测试文本树构建各阶段的正确性。
|
||||
|
||||
覆盖范围:
|
||||
- _detect_toc:检测 Markdown 标题
|
||||
- _segment_with_regex:正则切分 L1/L2 边界、超限分块
|
||||
- _segment_with_llm:LLM JSON 分段解析、异常处理
|
||||
- _build_l3_from_paragraphs:L3 节点字段验证
|
||||
- _build_l2:L2 节点字段验证(通过 build() 间接调用)
|
||||
- _build_l1:L1 节点字段验证(通过 build() 间接调用)
|
||||
- build():完整树结构与 IndexMeta 验证、MD 输出文件生成
|
||||
|
||||
MD 输出位置: tests/outputs/text_tree_builder/build_<timestamp>.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.text_tree_builder import TextTreeBuilder, _chunk
|
||||
from video_tree_trm.tree_index import L1Node, L2Node, L3Node, TreeIndex
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EMBED_DIM = 16 # 轻量维度,加速测试
|
||||
|
||||
_SAMPLE_TOC_TEXT = """\
|
||||
# 第一章 引言
|
||||
|
||||
本章介绍研究背景。
|
||||
|
||||
信息检索系统的发展历程悠久,早期以关键词匹配为主。
|
||||
|
||||
## 1.1 研究背景
|
||||
|
||||
随着互联网数据量急剧增长,传统检索方法面临挑战。
|
||||
|
||||
语义理解成为关键技术突破口。
|
||||
|
||||
## 1.2 研究意义
|
||||
|
||||
本研究具有重要的理论和实践价值。
|
||||
|
||||
# 第二章 相关工作
|
||||
|
||||
本章回顾相关研究成果。
|
||||
|
||||
## 2.1 稠密检索
|
||||
|
||||
DPR 等模型实现了端到端稠密向量检索。
|
||||
|
||||
## 2.2 树状索引
|
||||
|
||||
PageIndex 引入了层次化树状检索结构。
|
||||
"""
|
||||
|
||||
_SAMPLE_PLAIN_TEXT = (
|
||||
"信息检索是计算机科学的重要分支,研究如何从大规模数据中找到相关信息。"
|
||||
"\n\n"
|
||||
"传统方法以 TF-IDF 和 BM25 为代表,基于词频统计进行相关性计算。"
|
||||
"\n\n"
|
||||
"近年来,基于深度学习的稠密检索方法取得了显著进展,DPR 是代表性工作之一。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree_config() -> TreeConfig:
|
||||
"""树构建配置(小 max_paragraphs_per_l2 便于测试分块)。"""
|
||||
return TreeConfig(
|
||||
max_paragraphs_per_l2=2,
|
||||
l1_segment_duration=600.0,
|
||||
l2_clip_duration=20.0,
|
||||
l3_fps=1.0,
|
||||
l2_representative_frames=3,
|
||||
cache_dir="cache/trees",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embed() -> MagicMock:
|
||||
"""Mock EmbeddingModel,embed() 返回固定维度的随机向量。"""
|
||||
m = MagicMock()
|
||||
m.dim = _EMBED_DIM
|
||||
m._model_name = "mock-embed-model"
|
||||
|
||||
def _embed(texts):
|
||||
if isinstance(texts, str):
|
||||
texts = [texts]
|
||||
n = len(texts)
|
||||
return np.ones((n, _EMBED_DIM), dtype=np.float32)
|
||||
|
||||
m.embed.side_effect = _embed
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm() -> MagicMock:
|
||||
"""Mock LLMClient,chat() 返回固定字符串,batch_chat() 逐条映射。"""
|
||||
m = MagicMock()
|
||||
m.chat.return_value = "这是一段模拟的摘要描述。"
|
||||
m.batch_chat.side_effect = lambda prompts: [
|
||||
f"模拟摘要_{i}" for i in range(len(prompts))
|
||||
]
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(mock_embed, mock_llm, tree_config) -> TextTreeBuilder:
|
||||
"""标准 TextTreeBuilder(使用 mock 依赖)。"""
|
||||
return TextTreeBuilder(
|
||||
embed_model=mock_embed,
|
||||
llm=mock_llm,
|
||||
config=tree_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def ensure_output_dir():
|
||||
"""确保 MD 输出目录存在。"""
|
||||
Path("tests/outputs/text_tree_builder").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数:保存 MD 输出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_md_output(test_name: str, content: str) -> str:
|
||||
"""将测试执行过程保存为 Markdown 文件。
|
||||
|
||||
参数:
|
||||
test_name: 测试名称(用于文件名)。
|
||||
content: Markdown 内容。
|
||||
|
||||
返回:
|
||||
保存的文件路径。
|
||||
"""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = Path(f"tests/outputs/text_tree_builder/{test_name}_{ts}.md")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数:_chunk
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunk:
|
||||
"""测试 _chunk 等长分块辅助函数。"""
|
||||
|
||||
def test_exact_multiple(self):
|
||||
"""整除时每组大小相等。"""
|
||||
result = _chunk(["a", "b", "c", "d"], 2)
|
||||
assert result == [["a", "b"], ["c", "d"]]
|
||||
|
||||
def test_remainder(self):
|
||||
"""不整除时最后一组为余数。"""
|
||||
result = _chunk(["a", "b", "c", "d", "e"], 2)
|
||||
assert result == [["a", "b"], ["c", "d"], ["e"]]
|
||||
|
||||
def test_single_chunk(self):
|
||||
"""列表长度 <= size 时只有一组。"""
|
||||
result = _chunk(["a", "b"], 5)
|
||||
assert result == [["a", "b"]]
|
||||
|
||||
def test_empty_list(self):
|
||||
"""空列表返回空列表。"""
|
||||
result = _chunk([], 3)
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_toc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetectToc:
|
||||
"""测试 Markdown 标题检测。"""
|
||||
|
||||
def test_with_h1_header(self, builder):
|
||||
"""一级标题返回 True。"""
|
||||
assert builder._detect_toc("# 第一章\n\n内容") is True
|
||||
|
||||
def test_with_h2_header(self, builder):
|
||||
"""二级标题返回 True。"""
|
||||
assert builder._detect_toc("## 1.1 小节\n\n内容") is True
|
||||
|
||||
def test_with_both_headers(self, builder):
|
||||
"""同时含 # 和 ## 返回 True。"""
|
||||
assert builder._detect_toc(_SAMPLE_TOC_TEXT) is True
|
||||
|
||||
def test_without_headers(self, builder):
|
||||
"""纯段落文本返回 False。"""
|
||||
assert builder._detect_toc(_SAMPLE_PLAIN_TEXT) is False
|
||||
|
||||
def test_hash_in_content_not_header(self, builder):
|
||||
"""行中间的 # 不算标题。"""
|
||||
text = "颜色代码 #FF0000 是红色。\n\n这是普通段落。"
|
||||
assert builder._detect_toc(text) is False
|
||||
|
||||
def test_h3_not_counted(self, builder):
|
||||
"""三级标题 ### 也被检测为有 ToC(属于 Markdown 结构文本)。
|
||||
|
||||
注:当前实现只检测 # 和 ##,所以 ### 开头应返回 False。
|
||||
"""
|
||||
text = "### 三级标题\n\n内容"
|
||||
# _detect_toc 只匹配 #{1,2},### 不匹配
|
||||
assert builder._detect_toc(text) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _segment_with_regex
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSegmentWithRegex:
|
||||
"""测试正则切分 L1/L2 边界。"""
|
||||
|
||||
def test_basic_structure(self, builder):
|
||||
"""含 2 个 L1 章节,正确返回 2 个外层元素。"""
|
||||
sections = builder._segment_with_regex(_SAMPLE_TOC_TEXT)
|
||||
assert len(sections) == 2
|
||||
|
||||
def test_all_sections_nonempty(self, builder):
|
||||
"""每个 section 至少包含一个段落。"""
|
||||
sections = builder._segment_with_regex(_SAMPLE_TOC_TEXT)
|
||||
for s in sections:
|
||||
assert len(s) > 0
|
||||
|
||||
def test_overflow_chunking_via_build(self, builder):
|
||||
"""当段落数超过 max_paragraphs_per_l2=2 时,build() 会等长分块为多个 L2。"""
|
||||
# 第一章有 4 个段落(含标题),超过 max=2 → 应有 2 个 L2
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
# 每个 L1 的 L2 数 >= 1
|
||||
for l1 in index.roots:
|
||||
assert len(l1.children) >= 1
|
||||
|
||||
def test_paragraphs_are_strings(self, builder):
|
||||
"""所有段落应为非空字符串。"""
|
||||
sections = builder._segment_with_regex(_SAMPLE_TOC_TEXT)
|
||||
for section in sections:
|
||||
for para in section:
|
||||
assert isinstance(para, str)
|
||||
assert para.strip() != ""
|
||||
|
||||
def test_single_chapter_text(self, builder):
|
||||
"""只含一个 L1 章节的文本,返回 1 个外层元素。"""
|
||||
text = "# 唯一章节\n\n第一段内容。\n\n第二段内容。"
|
||||
sections = builder._segment_with_regex(text)
|
||||
assert len(sections) == 1
|
||||
|
||||
def test_no_l2_header(self, builder):
|
||||
"""只有 L1 无 L2 标题时,段落直接收集到 L1 组。"""
|
||||
text = "# 第一章\n\n段落一。\n\n段落二。\n\n段落三。"
|
||||
sections = builder._segment_with_regex(text)
|
||||
assert len(sections) == 1
|
||||
assert len(sections[0]) >= 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _segment_with_llm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSegmentWithLLM:
|
||||
"""测试 LLM 语义分段。"""
|
||||
|
||||
def test_json_array_parsing(self, builder):
|
||||
"""mock LLM 返回合法 JSON 数组,应正确解析为段落列表。"""
|
||||
paragraphs = ["第一段描述内容。", "第二段描述内容。", "第三段描述内容。"]
|
||||
builder.llm.chat.return_value = json.dumps(paragraphs, ensure_ascii=False)
|
||||
|
||||
sections = builder._segment_with_llm(_SAMPLE_PLAIN_TEXT)
|
||||
assert len(sections) == 1 # 单个 L1
|
||||
assert len(sections[0]) == 3
|
||||
assert sections[0][0] == "第一段描述内容。"
|
||||
|
||||
def test_json_wrapped_in_markdown(self, builder):
|
||||
"""JSON 数组被 markdown 代码块包裹时也能正确解析。"""
|
||||
paragraphs = ["段落A", "段落B"]
|
||||
json_str = json.dumps(paragraphs, ensure_ascii=False)
|
||||
builder.llm.chat.return_value = f"```json\n{json_str}\n```"
|
||||
|
||||
sections = builder._segment_with_llm(_SAMPLE_PLAIN_TEXT)
|
||||
assert sections[0] == ["段落A", "段落B"]
|
||||
|
||||
def test_invalid_json_raises(self, builder):
|
||||
"""LLM 返回非法 JSON 时应抛出 ValueError。"""
|
||||
builder.llm.chat.return_value = "这不是 JSON 格式的内容"
|
||||
with pytest.raises((ValueError, AssertionError)):
|
||||
builder._segment_with_llm(_SAMPLE_PLAIN_TEXT)
|
||||
|
||||
def test_empty_paragraphs_filtered(self, builder):
|
||||
"""空段落应被过滤掉。"""
|
||||
paragraphs = ["有效段落", "", " ", "另一有效段落"]
|
||||
builder.llm.chat.return_value = json.dumps(paragraphs, ensure_ascii=False)
|
||||
sections = builder._segment_with_llm(_SAMPLE_PLAIN_TEXT)
|
||||
assert len(sections[0]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_l3_from_paragraphs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildL3:
|
||||
"""测试 L3 节点构建。"""
|
||||
|
||||
def test_description_equals_raw_content(self, builder):
|
||||
"""L3 节点的 description 应等于 raw_content,等于原始段落。"""
|
||||
paragraphs = ["段落一内容", "段落二内容"]
|
||||
nodes = builder._build_l3_from_paragraphs(paragraphs, l1_i=0, l2_j=0)
|
||||
for node, para in zip(nodes, paragraphs):
|
||||
assert node.description == para
|
||||
assert node.raw_content == para
|
||||
|
||||
def test_embedding_shape(self, builder):
|
||||
"""每个 L3 节点的 embedding 形状应为 (dim,)。"""
|
||||
paragraphs = ["A", "B", "C"]
|
||||
nodes = builder._build_l3_from_paragraphs(paragraphs, l1_i=0, l2_j=0)
|
||||
for node in nodes:
|
||||
assert node.embedding.shape == (_EMBED_DIM,)
|
||||
|
||||
def test_node_count(self, builder):
|
||||
"""返回的 L3 节点数应与输入段落数相等。"""
|
||||
paragraphs = ["A", "B", "C"]
|
||||
nodes = builder._build_l3_from_paragraphs(paragraphs, l1_i=0, l2_j=0)
|
||||
assert len(nodes) == 3
|
||||
|
||||
def test_node_id_format(self, builder):
|
||||
"""节点 ID 格式应为 l1_{i}_l2_{j}_l3_{k}。"""
|
||||
nodes = builder._build_l3_from_paragraphs(["A", "B"], l1_i=1, l2_j=2)
|
||||
assert nodes[0].id == "l1_1_l2_2_l3_0"
|
||||
assert nodes[1].id == "l1_1_l2_2_l3_1"
|
||||
|
||||
def test_video_fields_are_none(self, builder):
|
||||
"""文本模式下 frame_path 和 timestamp 应为 None。"""
|
||||
nodes = builder._build_l3_from_paragraphs(["测试段落"], l1_i=0, l2_j=0)
|
||||
assert nodes[0].frame_path is None
|
||||
assert nodes[0].timestamp is None
|
||||
|
||||
def test_embedding_dtype(self, builder):
|
||||
"""嵌入向量应为 float32。"""
|
||||
nodes = builder._build_l3_from_paragraphs(["A"], l1_i=0, l2_j=0)
|
||||
assert nodes[0].embedding.dtype == np.float32
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build() — 完整树结构验证
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuild:
|
||||
"""测试 build() 完整流程与 TreeIndex 结构。"""
|
||||
|
||||
def test_returns_tree_index(self, builder):
|
||||
"""build() 应返回 TreeIndex 实例。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
assert isinstance(index, TreeIndex)
|
||||
|
||||
def test_roots_nonempty(self, builder):
|
||||
"""roots 列表不为空。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
assert len(index.roots) > 0
|
||||
|
||||
def test_l1_has_l2_children(self, builder):
|
||||
"""每个 L1 节点至少有一个 L2 子节点。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
assert isinstance(l1, L1Node)
|
||||
assert len(l1.children) > 0
|
||||
|
||||
def test_l2_has_l3_children(self, builder):
|
||||
"""每个 L2 节点至少有一个 L3 子节点。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
for l2 in l1.children:
|
||||
assert isinstance(l2, L2Node)
|
||||
assert len(l2.children) > 0
|
||||
|
||||
def test_l3_are_leaf_nodes(self, builder):
|
||||
"""L3 节点是叶子层,类型为 L3Node。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
for l2 in l1.children:
|
||||
for l3 in l2.children:
|
||||
assert isinstance(l3, L3Node)
|
||||
|
||||
def test_index_meta_modality(self, builder):
|
||||
"""IndexMeta.modality 应为 'text'。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="doc.txt")
|
||||
assert index.metadata.modality == "text"
|
||||
|
||||
def test_index_meta_source_path(self, builder):
|
||||
"""IndexMeta.source_path 应与传入参数一致。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="my_doc.txt")
|
||||
assert index.metadata.source_path == "my_doc.txt"
|
||||
|
||||
def test_index_meta_embed_dim(self, builder):
|
||||
"""IndexMeta.embed_dim 应与 embed.dim 一致。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
assert index.metadata.embed_dim == _EMBED_DIM
|
||||
|
||||
def test_embedding_shapes(self, builder):
|
||||
"""所有节点 embedding 形状应为 (dim,)。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
assert l1.embedding.shape == (_EMBED_DIM,)
|
||||
for l2 in l1.children:
|
||||
assert l2.embedding.shape == (_EMBED_DIM,)
|
||||
for l3 in l2.children:
|
||||
assert l3.embedding.shape == (_EMBED_DIM,)
|
||||
|
||||
def test_l1_summary_nonempty(self, builder):
|
||||
"""L1 summary 不为空。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
assert l1.summary.strip() != ""
|
||||
|
||||
def test_l2_description_nonempty(self, builder):
|
||||
"""L2 description 不为空。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
for l1 in index.roots:
|
||||
for l2 in l1.children:
|
||||
assert l2.description.strip() != ""
|
||||
|
||||
def test_plain_text_build(self, builder):
|
||||
"""无 ToC 的纯段落文本也能正确构建。"""
|
||||
# 修正 mock:返回合法 JSON
|
||||
paragraphs = ["信息检索是计算机科学的重要分支。", "传统方法以 TF-IDF 为代表。", "近年来稠密检索方法兴起。"]
|
||||
builder.llm.chat.return_value = json.dumps(paragraphs, ensure_ascii=False)
|
||||
builder.llm.batch_chat.side_effect = lambda prompts: [
|
||||
f"摘要_{i}" for i in range(len(prompts))
|
||||
]
|
||||
index = builder.build(_SAMPLE_PLAIN_TEXT, source_path="plain.txt")
|
||||
assert len(index.roots) > 0
|
||||
|
||||
def test_empty_text_raises(self, builder):
|
||||
"""空文本应抛出 ValueError(通过 ensure 检查)。"""
|
||||
with pytest.raises((ValueError, AssertionError)):
|
||||
builder.build(" ", source_path="empty.txt")
|
||||
|
||||
def test_batch_chat_called_once(self, builder):
|
||||
"""build() 应调用 batch_chat() 一次(所有 L2 并发处理)。"""
|
||||
builder.llm.batch_chat.reset_mock()
|
||||
builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
builder.llm.batch_chat.assert_called_once()
|
||||
|
||||
def test_l1_node_ids_unique(self, builder):
|
||||
"""所有 L1 节点 ID 唯一。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
ids = [l1.id for l1 in index.roots]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
def test_l2_node_ids_unique(self, builder):
|
||||
"""所有 L2 节点 ID 全局唯一。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
ids = [l2.id for l1 in index.roots for l2 in l1.children]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MD 输出文件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMDOutput:
|
||||
"""测试 MD 输出文件生成(Agent 测试规范)。"""
|
||||
|
||||
def test_md_output_saved(self, builder):
|
||||
"""build() 后应能保存 Markdown 执行记录文件。"""
|
||||
index = builder.build(_SAMPLE_TOC_TEXT, source_path="test.txt")
|
||||
|
||||
# 统计树结构信息
|
||||
total_l2 = sum(len(r.children) for r in index.roots)
|
||||
total_l3 = sum(len(l2.children) for r in index.roots for l2 in r.children)
|
||||
|
||||
# 构造 MD 内容
|
||||
l2_details = []
|
||||
for l1 in index.roots:
|
||||
for l2 in l1.children:
|
||||
l2_details.append(f" - {l2.id}: {l2.description[:40]}...")
|
||||
|
||||
md_content = f"""\
|
||||
# Agent 测试: TextTreeBuilder.build
|
||||
## 任务: 长文本 → TreeIndex
|
||||
|
||||
## 输入信息
|
||||
- **文本长度**: {len(_SAMPLE_TOC_TEXT)} 字符
|
||||
- **是否含 ToC**: {builder._detect_toc(_SAMPLE_TOC_TEXT)}
|
||||
- **source_path**: test.txt
|
||||
- **max_paragraphs_per_l2**: {builder.config.max_paragraphs_per_l2}
|
||||
- **embed_dim**: {_EMBED_DIM}
|
||||
|
||||
## Step 1: 结构切分
|
||||
- **策略**: {'ToC 正则切分' if builder._detect_toc(_SAMPLE_TOC_TEXT) else 'LLM 语义分段'}
|
||||
- **L1 数量**: {len(index.roots)}
|
||||
- **各 L1 的 L2 数**: {[len(r.children) for r in index.roots]}
|
||||
|
||||
## Step 2: L2 先行(批量 LLM)
|
||||
- **L2 节点总数**: {total_l2}
|
||||
- **调用方式**: batch_chat()(并发生成所有 L2 摘要)
|
||||
- **L2 描述示例**:
|
||||
{chr(10).join(l2_details[:5])}
|
||||
|
||||
## Step 3: L3 向下(原始段落直接复用)
|
||||
- **L3 节点总数**: {total_l3}
|
||||
- **L3 特性**: description == raw_content(无 LLM 调用)
|
||||
|
||||
## Step 4: L1 向上(聚合摘要)
|
||||
- **L1 摘要示例**:
|
||||
{chr(10).join(f' - {r.id}: {r.summary[:60]}...' for r in index.roots)}
|
||||
|
||||
## 最终结果
|
||||
- **roots 数量**: {len(index.roots)}
|
||||
- **总 L2 节点**: {total_l2}
|
||||
- **总 L3 节点**: {total_l3}
|
||||
- **modality**: {index.metadata.modality}
|
||||
- **embed_dim**: {index.metadata.embed_dim}
|
||||
- **embedding shape 检查**: {'PASS' if all(r.embedding.shape == (_EMBED_DIM,) for r in index.roots) else 'FAIL'}
|
||||
- **L3 description==raw_content 检查**: {'PASS' if all(l3.description == l3.raw_content for r in index.roots for l2 in r.children for l3 in l2.children) else 'FAIL'}
|
||||
"""
|
||||
|
||||
out_path = _save_md_output("build_toc", md_content)
|
||||
assert os.path.exists(out_path)
|
||||
print(f"\n[MD 输出] 已保存到: {out_path}")
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
tree_index 单元测试
|
||||
===================
|
||||
覆盖: 嵌入矩阵提取、节点访问、边界检查、序列化往返、空树处理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
EMBED_DIM = 64
|
||||
|
||||
|
||||
def _make_embed(seed: int = 0) -> np.ndarray:
|
||||
"""生成固定种子的随机嵌入向量 [D]。"""
|
||||
rng = np.random.RandomState(seed)
|
||||
return rng.randn(EMBED_DIM).astype(np.float32)
|
||||
|
||||
|
||||
def _make_meta() -> IndexMeta:
|
||||
return IndexMeta(
|
||||
source_path="test.mp4",
|
||||
modality="video",
|
||||
embed_model="test-model",
|
||||
embed_dim=EMBED_DIM,
|
||||
)
|
||||
|
||||
|
||||
def _make_tree() -> TreeIndex:
|
||||
"""构建一棵 2 x 2 x 3 的测试树。"""
|
||||
meta = _make_meta()
|
||||
roots = []
|
||||
seed = 0
|
||||
for i in range(2):
|
||||
l2_nodes = []
|
||||
for j in range(2):
|
||||
l3_nodes = [
|
||||
L3Node(
|
||||
id=f"l3_{i}_{j}_{k}",
|
||||
description=f"帧描述 {i}-{j}-{k}",
|
||||
embedding=_make_embed(seed := seed + 1),
|
||||
)
|
||||
for k in range(3)
|
||||
]
|
||||
l2_nodes.append(
|
||||
L2Node(
|
||||
id=f"l2_{i}_{j}",
|
||||
description=f"片段描述 {i}-{j}",
|
||||
embedding=_make_embed(seed := seed + 1),
|
||||
children=l3_nodes,
|
||||
)
|
||||
)
|
||||
roots.append(
|
||||
L1Node(
|
||||
id=f"l1_{i}",
|
||||
summary=f"摘要 {i}",
|
||||
embedding=_make_embed(seed := seed + 1),
|
||||
children=l2_nodes,
|
||||
)
|
||||
)
|
||||
return TreeIndex(metadata=meta, roots=roots)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 嵌入矩阵提取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmbeddings:
|
||||
"""嵌入矩阵提取方法测试。"""
|
||||
|
||||
def test_l1_embeddings_shape(self) -> None:
|
||||
"""l1_embeddings() 返回 [N1, D]。"""
|
||||
tree = _make_tree()
|
||||
emb = tree.l1_embeddings()
|
||||
assert emb.shape == (2, EMBED_DIM)
|
||||
assert emb.dtype == np.float32
|
||||
|
||||
def test_l2_embeddings_of_shape(self) -> None:
|
||||
"""l2_embeddings_of(idx) 返回 [N2, D]。"""
|
||||
tree = _make_tree()
|
||||
emb = tree.l2_embeddings_of(0)
|
||||
assert emb.shape == (2, EMBED_DIM)
|
||||
assert emb.dtype == np.float32
|
||||
|
||||
def test_l3_embeddings_of_shape(self) -> None:
|
||||
"""l3_embeddings_of(l1, l2) 返回 [N3, D]。"""
|
||||
tree = _make_tree()
|
||||
emb = tree.l3_embeddings_of(0, 1)
|
||||
assert emb.shape == (3, EMBED_DIM)
|
||||
assert emb.dtype == np.float32
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 节点访问
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetNode:
|
||||
"""节点访问方法测试。"""
|
||||
|
||||
def test_get_node(self) -> None:
|
||||
"""正确返回目标 L3Node。"""
|
||||
tree = _make_tree()
|
||||
node = tree.get_node(1, 0, 2)
|
||||
assert isinstance(node, L3Node)
|
||||
assert node.id == "l3_1_0_2"
|
||||
assert node.description == "帧描述 1-0-2"
|
||||
|
||||
def test_get_node_boundary_error(self) -> None:
|
||||
"""越界索引抛出 IndexError。"""
|
||||
tree = _make_tree()
|
||||
with pytest.raises(IndexError):
|
||||
tree.get_node(5, 0, 0)
|
||||
with pytest.raises(IndexError):
|
||||
tree.get_node(0, 5, 0)
|
||||
with pytest.raises(IndexError):
|
||||
tree.get_node(0, 0, 5)
|
||||
|
||||
def test_get_node_negative_index_error(self) -> None:
|
||||
"""负数索引抛出 IndexError。"""
|
||||
tree = _make_tree()
|
||||
with pytest.raises(IndexError):
|
||||
tree.get_node(-1, 0, 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 序列化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSerialization:
|
||||
"""pickle 序列化测试。"""
|
||||
|
||||
def test_save_load_roundtrip(self) -> None:
|
||||
"""pickle 序列化后反序列化,数据完整一致。"""
|
||||
tree = _make_tree()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "test.pkl")
|
||||
tree.save(path)
|
||||
loaded = TreeIndex.load(path)
|
||||
|
||||
# 元数据一致
|
||||
assert loaded.metadata.source_path == tree.metadata.source_path
|
||||
assert loaded.metadata.embed_dim == tree.metadata.embed_dim
|
||||
|
||||
# 结构一致
|
||||
assert len(loaded.roots) == len(tree.roots)
|
||||
for orig_l1, load_l1 in zip(tree.roots, loaded.roots):
|
||||
assert orig_l1.id == load_l1.id
|
||||
assert len(orig_l1.children) == len(load_l1.children)
|
||||
|
||||
# 嵌入一致
|
||||
np.testing.assert_array_equal(loaded.l1_embeddings(), tree.l1_embeddings())
|
||||
np.testing.assert_array_equal(
|
||||
loaded.l3_embeddings_of(0, 1), tree.l3_embeddings_of(0, 1)
|
||||
)
|
||||
|
||||
def test_load_nonexistent_file(self) -> None:
|
||||
"""加载不存在的文件抛出 FileNotFoundError。"""
|
||||
with pytest.raises(FileNotFoundError):
|
||||
TreeIndex.load("/tmp/nonexistent_tree_index_abc123.pkl")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试: 空树边界
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEmptyTree:
|
||||
"""空树边界情况测试。"""
|
||||
|
||||
def test_empty_tree_l1_embeddings(self) -> None:
|
||||
"""空树的 l1_embeddings() 返回 [0, D]。"""
|
||||
tree = TreeIndex(metadata=_make_meta(), roots=[])
|
||||
emb = tree.l1_embeddings()
|
||||
assert emb.shape == (0, EMBED_DIM)
|
||||
assert emb.dtype == np.float32
|
||||
|
||||
def test_empty_tree_get_node_raises(self) -> None:
|
||||
"""空树访问节点抛出 IndexError。"""
|
||||
tree = TreeIndex(metadata=_make_meta(), roots=[])
|
||||
with pytest.raises(IndexError):
|
||||
tree.get_node(0, 0, 0)
|
||||
|
||||
def test_l2_embeddings_of_boundary(self) -> None:
|
||||
"""l2_embeddings_of 越界抛出 ValueError。"""
|
||||
tree = _make_tree()
|
||||
with pytest.raises(ValueError):
|
||||
tree.l2_embeddings_of(10)
|
||||
|
||||
def test_l3_embeddings_of_boundary(self) -> None:
|
||||
"""l3_embeddings_of 越界抛出 ValueError。"""
|
||||
tree = _make_tree()
|
||||
with pytest.raises(ValueError):
|
||||
tree.l3_embeddings_of(0, 10)
|
||||
@@ -0,0 +1,504 @@
|
||||
"""
|
||||
VideoTreeBuilder 单元测试
|
||||
=========================
|
||||
覆盖视频树构建的各个子方法和完整流程。
|
||||
|
||||
测试策略:
|
||||
- mock cv2.VideoCapture 避免依赖真实视频(_segment_video 等)
|
||||
- 使用 cv2 合成小视频进行帧提取和集成测试(tiny_video fixture)
|
||||
- mock VLM/embed 依赖,隔离外部 API
|
||||
- 测试 L3 批量降级路径(JSON 解析失败时退回逐帧调用)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.tree_index import L1Node, L2Node, L3Node
|
||||
from video_tree_trm.video_tree_builder import VideoTreeBuilder
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree_config(tmp_path: Path) -> TreeConfig:
|
||||
"""构建测试用 TreeConfig,cache_dir 指向临时目录。"""
|
||||
return TreeConfig(
|
||||
max_paragraphs_per_l2=5,
|
||||
l1_segment_duration=30.0,
|
||||
l2_clip_duration=10.0,
|
||||
l3_fps=1.0,
|
||||
l2_representative_frames=3,
|
||||
cache_dir=str(tmp_path / "cache"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_embed() -> MagicMock:
|
||||
"""返回 mock 嵌入模型,embed() 返回全 1 向量。"""
|
||||
embed = MagicMock()
|
||||
embed.dim = 4
|
||||
embed._model_name = "mock-embed"
|
||||
|
||||
def _embed(texts):
|
||||
"""根据输入类型返回 [1, D] 或 [N, D] 的 float32 数组。"""
|
||||
if isinstance(texts, str):
|
||||
return np.ones((1, 4), dtype=np.float32)
|
||||
n = len(texts)
|
||||
return np.ones((n, 4), dtype=np.float32)
|
||||
|
||||
embed.embed.side_effect = _embed
|
||||
return embed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_vlm() -> MagicMock:
|
||||
"""返回 mock VLM 客户端。"""
|
||||
vlm = MagicMock()
|
||||
vlm.chat.return_value = "这是一段精彩的视频内容摘要。"
|
||||
vlm.chat_with_images.return_value = "这帧画面中有人物在移动。"
|
||||
return vlm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def builder(
|
||||
mock_embed: MagicMock, mock_vlm: MagicMock, tree_config: TreeConfig
|
||||
) -> VideoTreeBuilder:
|
||||
"""构建测试用 VideoTreeBuilder 实例。"""
|
||||
return VideoTreeBuilder(
|
||||
embed_model=mock_embed,
|
||||
vlm=mock_vlm,
|
||||
config=tree_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tiny_video(tmp_path: Path) -> str:
|
||||
"""用 cv2 生成 30 帧合成彩色视频(10fps,时长 3 秒),返回路径。
|
||||
|
||||
视频规格:
|
||||
- 分辨率: 64×48
|
||||
- 帧率: 10fps
|
||||
- 时长: 3 秒(30 帧)
|
||||
- 内容: 随机彩色帧
|
||||
"""
|
||||
video_path = str(tmp_path / "tiny.mp4")
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(video_path, fourcc, 10.0, (64, 48))
|
||||
for _ in range(30):
|
||||
frame = np.random.randint(0, 256, (48, 64, 3), dtype=np.uint8)
|
||||
writer.write(frame)
|
||||
writer.release()
|
||||
return video_path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_segment_video — 时间切分
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_segment_video_fixed_step(builder: VideoTreeBuilder, tmp_path: Path) -> None:
|
||||
"""mock cv2.VideoCapture(总时长=60s,l1_segment_duration=30s),
|
||||
验证切分出 2 个均等 L1 区间:(0,30),(30,60)。"""
|
||||
mock_cap = MagicMock()
|
||||
mock_cap.isOpened.return_value = True
|
||||
mock_cap.get.side_effect = lambda prop: (
|
||||
10.0 if prop == cv2.CAP_PROP_FPS else 600.0 # 600帧/10fps = 60s
|
||||
)
|
||||
|
||||
with patch("video_tree_trm.video_tree_builder.cv2.VideoCapture", return_value=mock_cap):
|
||||
ranges = builder._segment_video("fake.mp4")
|
||||
|
||||
assert len(ranges) == 2
|
||||
assert ranges[0] == (0.0, 30.0)
|
||||
assert ranges[1] == (30.0, 60.0)
|
||||
|
||||
|
||||
def test_segment_video_uneven(builder: VideoTreeBuilder) -> None:
|
||||
"""总时长不能被 l1_segment_duration 整除时,最后一段应短于步长。"""
|
||||
mock_cap = MagicMock()
|
||||
mock_cap.isOpened.return_value = True
|
||||
# 75s = 750帧 / 10fps
|
||||
mock_cap.get.side_effect = lambda prop: (
|
||||
10.0 if prop == cv2.CAP_PROP_FPS else 750.0
|
||||
)
|
||||
|
||||
with patch("video_tree_trm.video_tree_builder.cv2.VideoCapture", return_value=mock_cap):
|
||||
ranges = builder._segment_video("fake.mp4")
|
||||
|
||||
assert len(ranges) == 3
|
||||
assert ranges[0] == (0.0, 30.0)
|
||||
assert ranges[1] == (30.0, 60.0)
|
||||
assert abs(ranges[2][0] - 60.0) < 1e-6
|
||||
assert abs(ranges[2][1] - 75.0) < 1e-6
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_get_l2_clips — L2 切分
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_l2_clips_even(builder: VideoTreeBuilder) -> None:
|
||||
"""l1=(0,30),l2_duration=10 → 3 clips 均等:(0,10),(10,20),(20,30)。"""
|
||||
clips = builder._get_l2_clips((0.0, 30.0))
|
||||
assert len(clips) == 3
|
||||
assert clips[0] == (0.0, 10.0)
|
||||
assert clips[1] == (10.0, 20.0)
|
||||
assert clips[2] == (20.0, 30.0)
|
||||
|
||||
|
||||
def test_get_l2_clips_uneven(builder: VideoTreeBuilder) -> None:
|
||||
"""l1=(0,25),l2_duration=10 → 3 clips,最后一段为 5s。"""
|
||||
clips = builder._get_l2_clips((0.0, 25.0))
|
||||
assert len(clips) == 3
|
||||
assert clips[2] == (20.0, 25.0)
|
||||
|
||||
|
||||
def test_get_l2_clips_shorter_than_step(builder: VideoTreeBuilder) -> None:
|
||||
"""L1 区间短于 l2_clip_duration 时,返回 1 个 clip。"""
|
||||
clips = builder._get_l2_clips((0.0, 5.0))
|
||||
assert len(clips) == 1
|
||||
assert clips[0] == (0.0, 5.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_extract_frames — 帧提取
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_extract_frames_saves_files(
|
||||
builder: VideoTreeBuilder, tiny_video: str, tmp_path: Path
|
||||
) -> None:
|
||||
"""使用真实合成视频(3s),提取 1fps 帧,验证返回路径和文件存在。"""
|
||||
frames = builder._extract_frames(tiny_video, (0.0, 3.0), fps=1.0)
|
||||
|
||||
assert len(frames) >= 1
|
||||
for frame_path, ts in frames:
|
||||
assert os.path.isfile(frame_path), f"帧文件不存在: {frame_path}"
|
||||
assert ts >= 0.0
|
||||
|
||||
|
||||
def test_extract_frames_cache_reuse(
|
||||
builder: VideoTreeBuilder, tiny_video: str
|
||||
) -> None:
|
||||
"""第二次提取同一区间时,帧文件应直接复用(不重复写磁盘)。"""
|
||||
frames1 = builder._extract_frames(tiny_video, (0.0, 2.0), fps=1.0)
|
||||
assert len(frames1) >= 1
|
||||
|
||||
# 记录文件修改时间
|
||||
mtimes_before = [os.path.getmtime(fp) for fp, _ in frames1]
|
||||
|
||||
frames2 = builder._extract_frames(tiny_video, (0.0, 2.0), fps=1.0)
|
||||
mtimes_after = [os.path.getmtime(fp) for fp, _ in frames2]
|
||||
|
||||
assert frames1 == frames2
|
||||
assert mtimes_before == mtimes_after, "缓存帧文件被重复写入"
|
||||
|
||||
|
||||
def test_extract_frames_empty_range(
|
||||
builder: VideoTreeBuilder, tiny_video: str
|
||||
) -> None:
|
||||
"""时间范围内无有效时间戳时,返回空列表。"""
|
||||
# 起始=结束,无时间戳
|
||||
frames = builder._extract_frames(tiny_video, (1.0, 1.0), fps=1.0)
|
||||
assert frames == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_build_l2_video — L2 节点构建
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_l2_video_node_structure(
|
||||
builder: VideoTreeBuilder, tiny_video: str, mock_vlm: MagicMock
|
||||
) -> None:
|
||||
"""验证 L2Node 字段:description 非空、embedding shape 正确、time_range 正确。"""
|
||||
mock_vlm.chat_with_images.return_value = "片段展示了室内场景的变化。"
|
||||
l2_node = builder._build_l2_video(tiny_video, (0.0, 2.0), "l1_0_l2_0")
|
||||
|
||||
assert isinstance(l2_node, L2Node)
|
||||
assert l2_node.id == "l1_0_l2_0"
|
||||
assert len(l2_node.description) > 0
|
||||
assert l2_node.embedding.shape == (4,)
|
||||
assert l2_node.embedding.dtype == np.float32
|
||||
assert l2_node.time_range == (0.0, 2.0)
|
||||
assert l2_node.children == [] # 调用方填充
|
||||
|
||||
|
||||
def test_build_l2_video_representative_frames_count(
|
||||
builder: VideoTreeBuilder, tiny_video: str, mock_vlm: MagicMock
|
||||
) -> None:
|
||||
"""验证 VLM 被调用时传入的图像数不超过 l2_representative_frames。"""
|
||||
mock_vlm.chat_with_images.return_value = "描述内容。"
|
||||
builder._build_l2_video(tiny_video, (0.0, 3.0), "l1_0_l2_0")
|
||||
|
||||
call_args = mock_vlm.chat_with_images.call_args
|
||||
images_passed = call_args.kwargs.get("images", call_args.args[1] if len(call_args.args) > 1 else [])
|
||||
assert len(images_passed) <= builder.config.l2_representative_frames
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_build_l3_video — L3 节点构建
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_frames(n: int, tmp_path: Path) -> List[tuple]:
|
||||
"""创建 n 个临时 JPEG 帧文件,返回 [(path, ts), ...]。"""
|
||||
frames = []
|
||||
frame_dir = tmp_path / "frames"
|
||||
frame_dir.mkdir(exist_ok=True)
|
||||
for i in range(n):
|
||||
frame_path = str(frame_dir / f"frame_{i}.jpg")
|
||||
img = np.zeros((48, 64, 3), dtype=np.uint8)
|
||||
cv2.imwrite(frame_path, img)
|
||||
frames.append((frame_path, float(i)))
|
||||
return frames
|
||||
|
||||
|
||||
def test_build_l3_video_batch_success(
|
||||
builder: VideoTreeBuilder, mock_vlm: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""mock VLM 返回合法 JSON 数组,验证 L3Node 列表结构正确。"""
|
||||
frames = _make_frames(2, tmp_path)
|
||||
mock_vlm.chat_with_images.return_value = json.dumps(["帧1描述内容", "帧2描述内容"])
|
||||
|
||||
nodes = builder._build_l3_video(frames, "片段整体描述", l1_i=0, l2_j=0)
|
||||
|
||||
assert len(nodes) == 2
|
||||
for k, node in enumerate(nodes):
|
||||
assert isinstance(node, L3Node)
|
||||
assert node.id == f"l1_0_l2_0_l3_{k}"
|
||||
assert len(node.description) > 0
|
||||
assert node.embedding.shape == (4,)
|
||||
assert node.embedding.dtype == np.float32
|
||||
assert node.frame_path == frames[k][0]
|
||||
assert node.timestamp == float(k)
|
||||
assert node.raw_content is None
|
||||
|
||||
|
||||
def test_build_l3_video_batch_fallback(
|
||||
builder: VideoTreeBuilder, mock_vlm: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""mock VLM 第一次返回非 JSON 字符串,验证降级逐帧调用(call_count == n+1)。
|
||||
|
||||
第一次 = 批量调用(失败),后 n 次 = 逐帧调用。
|
||||
"""
|
||||
n = 3
|
||||
frames = _make_frames(n, tmp_path)
|
||||
# 第一次返回无效 JSON,后续逐帧返回正常描述
|
||||
mock_vlm.chat_with_images.side_effect = (
|
||||
["这不是一个JSON数组,无法解析"] + [f"第{i}帧描述" for i in range(n)]
|
||||
)
|
||||
|
||||
nodes = builder._build_l3_video(frames, "片段整体描述", l1_i=0, l2_j=0)
|
||||
|
||||
assert len(nodes) == n
|
||||
# 1次批量 + n次逐帧
|
||||
assert mock_vlm.chat_with_images.call_count == n + 1
|
||||
for node in nodes:
|
||||
assert len(node.description) > 0
|
||||
|
||||
|
||||
def test_build_l3_video_json_length_mismatch_fallback(
|
||||
builder: VideoTreeBuilder, mock_vlm: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""VLM 返回 JSON 但长度不匹配时,也应降级逐帧调用。"""
|
||||
n = 3
|
||||
frames = _make_frames(n, tmp_path)
|
||||
# 只返回 2 项,但期望 3 项
|
||||
mock_vlm.chat_with_images.side_effect = (
|
||||
[json.dumps(["描述1", "描述2"])] + [f"帧{i}" for i in range(n)]
|
||||
)
|
||||
|
||||
nodes = builder._build_l3_video(frames, "片段描述", l1_i=0, l2_j=0)
|
||||
|
||||
assert len(nodes) == n
|
||||
assert mock_vlm.chat_with_images.call_count == n + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:_build_l1_video — L1 节点构建
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_l1_video_node_structure(
|
||||
builder: VideoTreeBuilder, mock_vlm: MagicMock, mock_embed: MagicMock
|
||||
) -> None:
|
||||
"""验证 L1Node 字段:summary 非空、time_range 正确、children 已赋值。"""
|
||||
mock_vlm.chat.return_value = "这段视频涵盖了户外活动和室内场景的切换。"
|
||||
|
||||
l2_children = [
|
||||
L2Node(
|
||||
id=f"l1_0_l2_{j}",
|
||||
description=f"L2描述{j}",
|
||||
embedding=np.ones(4, dtype=np.float32),
|
||||
time_range=(j * 10.0, (j + 1) * 10.0),
|
||||
)
|
||||
for j in range(3)
|
||||
]
|
||||
|
||||
l1_node = builder._build_l1_video(l2_children, "l1_0", (0.0, 30.0))
|
||||
|
||||
assert isinstance(l1_node, L1Node)
|
||||
assert l1_node.id == "l1_0"
|
||||
assert len(l1_node.summary) > 0
|
||||
assert l1_node.time_range == (0.0, 30.0)
|
||||
assert l1_node.children is l2_children
|
||||
assert l1_node.embedding.shape == (4,)
|
||||
assert l1_node.embedding.dtype == np.float32
|
||||
|
||||
|
||||
def test_build_l1_video_prompt_contains_l2_descriptions(
|
||||
builder: VideoTreeBuilder, mock_vlm: MagicMock
|
||||
) -> None:
|
||||
"""验证 L1 摘要的 prompt 包含所有 L2 描述文本。"""
|
||||
mock_vlm.chat.return_value = "综合摘要内容。"
|
||||
l2_descriptions = ["片段A描述", "片段B描述", "片段C描述"]
|
||||
l2_children = [
|
||||
L2Node(
|
||||
id=f"l1_0_l2_{j}",
|
||||
description=desc,
|
||||
embedding=np.ones(4, dtype=np.float32),
|
||||
time_range=(j * 10.0, (j + 1) * 10.0),
|
||||
)
|
||||
for j, desc in enumerate(l2_descriptions)
|
||||
]
|
||||
|
||||
builder._build_l1_video(l2_children, "l1_0", (0.0, 30.0))
|
||||
|
||||
call_prompt = mock_vlm.chat.call_args.args[0]
|
||||
for desc in l2_descriptions:
|
||||
assert desc in call_prompt, f"L2 描述 '{desc}' 未出现在 L1 prompt 中"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试:build 完整流程(集成测试,mock VLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_full_integration(
|
||||
builder: VideoTreeBuilder,
|
||||
tiny_video: str,
|
||||
mock_vlm: MagicMock,
|
||||
mock_embed: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""用合成视频(3s)+ mock VLM 验证完整 TreeIndex 三层结构。
|
||||
|
||||
配置:l1_segment_duration=2s,l2_clip_duration=1s
|
||||
预期:至少 1 个 L1,每 L1 至少 1 个 L2,每 L2 至少 1 个 L3。
|
||||
"""
|
||||
# 调整 config 使 3s 视频能切出多个节点
|
||||
builder.config.l1_segment_duration = 2.0
|
||||
builder.config.l2_clip_duration = 1.0
|
||||
|
||||
# VLM 批量调用返回 JSON 数组(按帧数动态生成)
|
||||
def vlm_side_effect(prompt, images=None):
|
||||
if images and len(images) > 1:
|
||||
# L3 批量调用:返回 JSON
|
||||
return json.dumps([f"帧{i}描述" for i in range(len(images))])
|
||||
return "这是 VLM 的描述文本。"
|
||||
|
||||
mock_vlm.chat_with_images.side_effect = vlm_side_effect
|
||||
mock_vlm.chat.return_value = "L1 整体摘要内容。"
|
||||
|
||||
index = builder.build(tiny_video)
|
||||
|
||||
# 验证元数据
|
||||
assert index.metadata.modality == "video"
|
||||
assert index.metadata.source_path == tiny_video
|
||||
assert index.metadata.embed_dim == 4
|
||||
|
||||
# 验证三层结构非空
|
||||
assert len(index.roots) >= 1, "应有至少 1 个 L1 节点"
|
||||
for l1 in index.roots:
|
||||
assert len(l1.children) >= 1, f"L1 {l1.id} 应有至少 1 个 L2 子节点"
|
||||
assert l1.time_range is not None
|
||||
for l2 in l1.children:
|
||||
assert len(l2.children) >= 1, f"L2 {l2.id} 应有至少 1 个 L3 子节点"
|
||||
assert l2.time_range is not None
|
||||
for l3 in l2.children:
|
||||
assert l3.frame_path is not None
|
||||
assert l3.timestamp is not None
|
||||
assert l3.embedding.shape == (4,)
|
||||
|
||||
|
||||
def test_build_saves_output_md(
|
||||
builder: VideoTreeBuilder,
|
||||
tiny_video: str,
|
||||
mock_vlm: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""构建完成后,将执行摘要保存为 Markdown(CLAUDE.md 规范)。"""
|
||||
builder.config.l1_segment_duration = 2.0
|
||||
builder.config.l2_clip_duration = 1.0
|
||||
|
||||
def vlm_side_effect(prompt, images=None):
|
||||
if images and len(images) > 1:
|
||||
return json.dumps([f"帧{i}描述" for i in range(len(images))])
|
||||
return "VLM 描述内容。"
|
||||
|
||||
mock_vlm.chat_with_images.side_effect = vlm_side_effect
|
||||
mock_vlm.chat.return_value = "L1 摘要。"
|
||||
|
||||
index = builder.build(tiny_video)
|
||||
|
||||
# 保存 Markdown 输出
|
||||
output_dir = Path(__file__).resolve().parent.parent / "outputs" / "video_tree_builder"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_path = output_dir / f"build_video_{ts}.md"
|
||||
|
||||
total_l2 = sum(len(r.children) for r in index.roots)
|
||||
total_l3 = sum(len(l2.children) for r in index.roots for l2 in r.children)
|
||||
|
||||
lines = [
|
||||
f"# Agent 测试: test_build_saves_output_md",
|
||||
f"## 任务: VideoTreeBuilder.build() 完整流程验证",
|
||||
f"",
|
||||
f"## 输入",
|
||||
f"- 视频路径: `{tiny_video}`",
|
||||
f"- l1_segment_duration: {builder.config.l1_segment_duration}s",
|
||||
f"- l2_clip_duration: {builder.config.l2_clip_duration}s",
|
||||
f"- l3_fps: {builder.config.l3_fps}",
|
||||
f"",
|
||||
f"## 输出结构",
|
||||
f"- L1 节点数: {len(index.roots)}",
|
||||
f"- L2 节点数: {total_l2}",
|
||||
f"- L3 节点数: {total_l3}",
|
||||
f"- embed_dim: {index.metadata.embed_dim}",
|
||||
f"",
|
||||
f"## L1 详情",
|
||||
]
|
||||
for l1 in index.roots:
|
||||
lines.append(f"### {l1.id} (time_range={l1.time_range})")
|
||||
lines.append(f"- summary: {l1.summary[:80]}...")
|
||||
for l2 in l1.children:
|
||||
lines.append(
|
||||
f" - {l2.id} [{l2.time_range}]: {l2.description[:60]}... "
|
||||
f"({len(l2.children)} L3)"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
"## 最终结果",
|
||||
"✅ TreeIndex 构建成功,三层结构完整。",
|
||||
f"",
|
||||
f"输出文件: `{output_path}`",
|
||||
]
|
||||
|
||||
output_path.write_text("\n".join(lines), encoding="utf-8")
|
||||
print(f"\n[测试输出] {output_path}")
|
||||
assert output_path.is_file()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
日志系统模块
|
||||
============
|
||||
提供双通道日志(system.log 文本 + metrics.json 结构化),
|
||||
以及全局便捷函数 log_msg / log_json / ensure / log_exception。
|
||||
|
||||
使用方式::
|
||||
|
||||
from utils.logger_system import log_msg, log_json, ensure, log_exception
|
||||
|
||||
log_msg("INFO", "模型加载完成", model="bert-base")
|
||||
log_json("train_loss", {"epoch": 1, "loss": 0.35})
|
||||
ensure(tensor.shape[0] > 0, "batch 不能为空")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日志目录
|
||||
# ---------------------------------------------------------------------------
|
||||
_LOG_DIR = Path(os.getenv("LOG_DIR", "logs"))
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_SYSTEM_LOG = _LOG_DIR / "system.log"
|
||||
_METRICS_LOG = _LOG_DIR / "metrics.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LoggerSystem
|
||||
# ---------------------------------------------------------------------------
|
||||
class LoggerSystem:
|
||||
"""双通道日志系统。
|
||||
|
||||
通道 1: system.log — 文本格式,记录所有级别的运行日志。
|
||||
通道 2: metrics.json — JSON Lines 格式,记录结构化指标数据。
|
||||
|
||||
Attributes:
|
||||
_logger: 标准库 Logger 实例,输出到 system.log。
|
||||
_metrics_path: metrics.json 文件路径。
|
||||
"""
|
||||
|
||||
_instance: Optional["LoggerSystem"] = None
|
||||
_lock: threading.Lock = threading.Lock()
|
||||
|
||||
def __init__(self, log_dir: Optional[str] = None) -> None:
|
||||
"""初始化日志系统。
|
||||
|
||||
参数:
|
||||
log_dir: 日志输出目录,默认为 'logs/'。
|
||||
"""
|
||||
log_path = Path(log_dir) if log_dir else _LOG_DIR
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._metrics_path = log_path / "metrics.json"
|
||||
|
||||
# 文本日志
|
||||
self._logger = logging.getLogger("video_tree_trm")
|
||||
if not self._logger.handlers:
|
||||
self._logger.setLevel(logging.DEBUG)
|
||||
fh = logging.FileHandler(str(log_path / "system.log"), encoding="utf-8")
|
||||
fh.setLevel(logging.DEBUG)
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s | %(levelname)-7s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
fh.setFormatter(fmt)
|
||||
self._logger.addHandler(fh)
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "LoggerSystem":
|
||||
"""获取全局单例(线程安全,双重检查锁定)。"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
# ---- 文本日志 ----
|
||||
|
||||
def msg(self, level: str, message: str, **kwargs: Any) -> None:
|
||||
"""写入文本日志。
|
||||
|
||||
参数:
|
||||
level: 日志级别,如 "INFO", "ERROR", "DEBUG", "WARNING"。
|
||||
message: 日志消息。
|
||||
**kwargs: 附加键值对,追加到消息末尾。
|
||||
"""
|
||||
extra = " | ".join(f"{k}={v}" for k, v in kwargs.items()) if kwargs else ""
|
||||
text = f"{message} | {extra}" if extra else message
|
||||
log_fn = getattr(self._logger, level.lower(), self._logger.info)
|
||||
log_fn(text)
|
||||
|
||||
# ---- 结构化指标 ----
|
||||
|
||||
def json(self, tag: str, data: dict[str, Any]) -> None:
|
||||
"""写入结构化 JSON 指标。
|
||||
|
||||
参数:
|
||||
tag: 指标标签,如 "train_loss"。
|
||||
data: 指标数据字典。
|
||||
"""
|
||||
record = {"ts": datetime.now().isoformat(), "tag": tag, **data}
|
||||
with open(self._metrics_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
# ---- 断言 ----
|
||||
|
||||
@staticmethod
|
||||
def ensure(condition: bool, message: str) -> None:
|
||||
"""运行时断言,失败时抛出 ValueError。
|
||||
|
||||
参数:
|
||||
condition: 断言条件。
|
||||
message: 失败时的错误消息。
|
||||
"""
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
# ---- 异常记录 ----
|
||||
|
||||
def exception(self, message: str, exc: BaseException) -> None:
|
||||
"""记录异常信息到日志。
|
||||
|
||||
参数:
|
||||
message: 上下文描述。
|
||||
exc: 异常实例。
|
||||
"""
|
||||
tb = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
self._logger.error(f"{message} | {''.join(tb)}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局便捷函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def log_msg(level: str, message: str, **kwargs: Any) -> None:
|
||||
"""写入文本日志(全局便捷函数)。"""
|
||||
LoggerSystem.get().msg(level, message, **kwargs)
|
||||
|
||||
|
||||
def log_json(tag: str, data: dict[str, Any]) -> None:
|
||||
"""写入结构化 JSON 指标(全局便捷函数)。"""
|
||||
LoggerSystem.get().json(tag, data)
|
||||
|
||||
|
||||
def ensure(condition: bool, message: str) -> None:
|
||||
"""运行时断言(全局便捷函数)。"""
|
||||
LoggerSystem.ensure(condition, message)
|
||||
|
||||
|
||||
def log_exception(message: str, exc: BaseException) -> None:
|
||||
"""记录异常(全局便捷函数)。"""
|
||||
LoggerSystem.get().exception(message, exc)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Video-Tree-TRM 核心包
|
||||
=====================
|
||||
结合 TRM 多层对话探索能力与 PageIndex 树状检索能力的新型 Video RAG。
|
||||
"""
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.text_tree_builder import TextTreeBuilder
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"EmbeddingModel",
|
||||
"LLMClient",
|
||||
"TextTreeBuilder",
|
||||
"IndexMeta",
|
||||
"L1Node",
|
||||
"L2Node",
|
||||
"L3Node",
|
||||
"TreeIndex",
|
||||
]
|
||||
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
配置管理模块
|
||||
============
|
||||
定义所有超参数的 dataclass 类型(无默认值)+ 多源加载。
|
||||
|
||||
三层优先级: CLI args > .env > YAML,统一归口到 Config dataclass。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
cfg = Config.load("config/default.yaml", cli_args={"retriever.num_heads": "8"})
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子配置 Dataclass(全部无默认值,YAML 必须写全)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeConfig:
|
||||
"""树索引构建参数。
|
||||
|
||||
属性:
|
||||
max_paragraphs_per_l2: 每个 L2 节点包含的最大段落数(文本模式)。
|
||||
l1_segment_duration: L1 段时长,秒(视频模式)。
|
||||
l2_clip_duration: L2 clip 时长,秒(视频模式)。
|
||||
l3_fps: L3 帧提取频率(视频模式)。
|
||||
l2_representative_frames: L2 VLM 描述用的代表帧数。
|
||||
cache_dir: TreeIndex 缓存目录。
|
||||
"""
|
||||
|
||||
max_paragraphs_per_l2: int
|
||||
l1_segment_duration: float
|
||||
l2_clip_duration: float
|
||||
l3_fps: float
|
||||
l2_representative_frames: int
|
||||
cache_dir: str
|
||||
concurrency: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbedConfig:
|
||||
"""嵌入模型参数。
|
||||
|
||||
属性:
|
||||
backend: 嵌入后端类型,"local"(sentence-transformers)或 "remote"(OpenAI 兼容 API)。
|
||||
model_name: 本地模式为 HuggingFace 模型名,远程模式为 API 模型名。
|
||||
embed_dim: 嵌入维度 D。
|
||||
device: 推理设备,"cuda" 或 "cpu"(仅本地模式使用)。
|
||||
api_key: 远程模式 API 密钥,从 .env 加载。本地模式为空串。
|
||||
api_url: 远程模式 API 端点。本地模式为空串。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
model_name: str
|
||||
embed_dim: int
|
||||
device: str
|
||||
api_key: str
|
||||
api_url: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""大语言模型参数。
|
||||
|
||||
属性:
|
||||
backend: 后端类型,"qwen" | "openai" | "ollama"。
|
||||
api_key: API 密钥,从 .env 加载。
|
||||
model: 模型名称。
|
||||
api_url: API 端点 URL。
|
||||
max_tokens: 最大生成 token 数。
|
||||
temperature: 采样温度。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
api_key: str
|
||||
model: str
|
||||
api_url: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLMConfig:
|
||||
"""视觉语言模型参数。
|
||||
|
||||
属性:
|
||||
backend: 后端类型,"qwen" | "openai" | "ollama"。
|
||||
api_key: API 密钥,从 .env 加载。
|
||||
model: 模型名称。
|
||||
api_url: API 端点 URL。
|
||||
max_tokens: 最大生成 token 数。
|
||||
temperature: 采样温度。
|
||||
"""
|
||||
|
||||
backend: str
|
||||
api_key: str
|
||||
model: str
|
||||
api_url: str
|
||||
max_tokens: int
|
||||
temperature: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetrieverConfig:
|
||||
"""TRM 检索器参数。
|
||||
|
||||
属性:
|
||||
embed_dim: 嵌入维度,须与 EmbedConfig.embed_dim 一致。
|
||||
num_heads: Cross-Attention 头数。
|
||||
L_layers: ReasoningModule 层数。
|
||||
L_cycles: 每级推理迭代次数。
|
||||
max_rounds: ACT 最大遍历轮次。
|
||||
ffn_expansion: SwiGLU 扩展比。
|
||||
checkpoint: 训练好的模型权重路径,推理时必填。
|
||||
"""
|
||||
|
||||
embed_dim: int
|
||||
num_heads: int
|
||||
L_layers: int
|
||||
L_cycles: int
|
||||
max_rounds: int
|
||||
ffn_expansion: float
|
||||
checkpoint: Optional[str]
|
||||
# 多路径检索配置 (Top-k)
|
||||
k_l1: int = 1
|
||||
k_l2: int = 1
|
||||
k_l3: int = 1
|
||||
max_paths: int = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainConfig:
|
||||
"""训练参数。
|
||||
|
||||
属性:
|
||||
lr: 学习率。
|
||||
weight_decay: 权重衰减。
|
||||
batch_size: 批大小。
|
||||
max_epochs_phase1: Phase 1 导航训练轮数。
|
||||
max_epochs_phase2: Phase 2 ACT 训练轮数。
|
||||
nav_loss_weight: 导航损失权重。
|
||||
act_loss_weight: ACT 损失权重。
|
||||
act_lambda_step: ACT 步数惩罚系数。
|
||||
act_gamma: ACT 折扣因子。
|
||||
eval_interval: 每 N epoch 评估一次。
|
||||
save_dir: 模型权重保存目录。
|
||||
dataset: 数据集名称,"longbench" | "narrativeqa" | "videomme"。
|
||||
dataset_path: 数据集路径。
|
||||
"""
|
||||
|
||||
lr: float
|
||||
weight_decay: float
|
||||
batch_size: int
|
||||
max_epochs_phase1: int
|
||||
max_epochs_phase2: int
|
||||
nav_loss_weight: float
|
||||
act_loss_weight: float
|
||||
margin_loss_weight: float
|
||||
act_lambda_step: float
|
||||
act_gamma: float
|
||||
eval_interval: int
|
||||
save_dir: str
|
||||
dataset: str
|
||||
dataset_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class HierRetrieverConfig:
|
||||
"""Hierarchical Cross-Encoder 检索器参数。
|
||||
|
||||
属性:
|
||||
backbone_model: 预训练语言模型名称。
|
||||
num_heads: Cross-Attention 头数。
|
||||
hidden_dim: 隐藏层维度。
|
||||
dropout: Dropout 概率。
|
||||
use_query_dep_weights: 是否使用 Query-dependent 层级权重。
|
||||
level_weight_type: 层级权重类型,"fixed" | "query_dependent" | "hybrid"。
|
||||
|
||||
# Stage 1 参数
|
||||
stage1_epochs: Stage 1 训练轮数。
|
||||
stage1_lr: Stage 1 学习率。
|
||||
stage1_batch_size: Stage 1 批大小。
|
||||
stage1_num_negatives: Stage 1 每样本的负例数量。
|
||||
stage1_temperature: Stage 1 温度参数。
|
||||
|
||||
# Stage 2 参数
|
||||
stage2_epochs: Stage 2 训练轮数。
|
||||
stage2_lr: Stage 2 学习率。
|
||||
stage2_batch_size: Stage 2 批大小。
|
||||
stage2_num_negatives: Stage 2 每样本的负例数量。
|
||||
stage2_temperature: Stage 2 温度参数。
|
||||
stage2_hard_neg_update_freq: Stage 2 硬负例更新频率。
|
||||
stage2_hier_loss_weight: Stage 2 层级一致性损失权重。
|
||||
|
||||
# Stage 3 参数
|
||||
stage3_enabled: 是否启用 Stage 3。
|
||||
stage3_epochs: Stage 3 训练轮数。
|
||||
stage3_lr: Stage 3 学习率。
|
||||
|
||||
# 推理参数
|
||||
coarse_top_k: 粗排候选数量。
|
||||
fine_top_k: 精排返回数量。
|
||||
use_bm25: 是否使用 BM25 辅助粗排。
|
||||
"""
|
||||
|
||||
# 模型参数
|
||||
backbone_model: str
|
||||
num_heads: int
|
||||
hidden_dim: int
|
||||
dropout: float
|
||||
use_query_dep_weights: bool
|
||||
level_weight_type: str
|
||||
|
||||
# Stage 1
|
||||
stage1_epochs: int
|
||||
stage1_lr: float
|
||||
stage1_batch_size: int
|
||||
stage1_num_negatives: int
|
||||
stage1_temperature: float
|
||||
|
||||
# Stage 2
|
||||
stage2_epochs: int
|
||||
stage2_lr: float
|
||||
stage2_batch_size: int
|
||||
stage2_num_negatives: int
|
||||
stage2_temperature: float
|
||||
stage2_hard_neg_update_freq: int
|
||||
stage2_hier_loss_weight: float
|
||||
|
||||
# Stage 3
|
||||
stage3_enabled: bool
|
||||
stage3_epochs: int
|
||||
stage3_lr: float
|
||||
|
||||
# 推理
|
||||
coarse_top_k: int
|
||||
fine_top_k: int
|
||||
use_bm25: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECTION_TO_CLASS: dict[str, type] = {
|
||||
"tree": TreeConfig,
|
||||
"embed": EmbedConfig,
|
||||
"llm": LLMConfig,
|
||||
"vlm": VLMConfig,
|
||||
"retriever": RetrieverConfig,
|
||||
"train": TrainConfig,
|
||||
}
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> dict:
|
||||
"""递归合并字典,override 优先覆盖 base。
|
||||
|
||||
参数:
|
||||
base: 基础字典。
|
||||
override: 覆盖字典。
|
||||
|
||||
返回:
|
||||
合并后的新字典。
|
||||
"""
|
||||
merged = base.copy()
|
||||
for key, value in override.items():
|
||||
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _apply_dotpath(d: dict, key: str, value: Any) -> None:
|
||||
"""通过点路径设置嵌套字典的值。
|
||||
|
||||
支持 "retriever.num_heads" 风格的路径,自动拆分并逐级写入。
|
||||
|
||||
参数:
|
||||
d: 目标字典。
|
||||
key: 点分隔的路径,如 "retriever.num_heads"。
|
||||
value: 要设置的值。
|
||||
"""
|
||||
parts = key.split(".")
|
||||
current = d
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
def _coerce_value(raw: str, target_type: type) -> Any:
|
||||
"""将 CLI 字符串值转换为目标类型。
|
||||
|
||||
参数:
|
||||
raw: 原始字符串值。
|
||||
target_type: 目标 Python 类型。
|
||||
|
||||
返回:
|
||||
转换后的值。
|
||||
"""
|
||||
if target_type is bool:
|
||||
return raw.lower() in ("true", "1", "yes")
|
||||
if target_type is type(None):
|
||||
return None if raw.lower() in ("none", "null", "") else raw
|
||||
return target_type(raw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 顶层配置
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""全局配置容器。
|
||||
|
||||
统一管理所有子模块配置,通过 ``Config.load()`` 加载。
|
||||
|
||||
属性:
|
||||
tree: 树索引构建参数。
|
||||
embed: 嵌入模型参数。
|
||||
llm: 大语言模型参数。
|
||||
vlm: 视觉语言模型参数。
|
||||
retriever: TRM 检索器参数。
|
||||
train: 训练参数。
|
||||
hier_retriever: Hierarchical Cross-Encoder 检索器参数。
|
||||
"""
|
||||
|
||||
tree: TreeConfig
|
||||
embed: EmbedConfig
|
||||
llm: LLMConfig
|
||||
vlm: VLMConfig
|
||||
retriever: RetrieverConfig
|
||||
train: TrainConfig
|
||||
hier_retriever: Optional[HierRetrieverConfig] = None
|
||||
|
||||
@classmethod
|
||||
def load(
|
||||
cls,
|
||||
yaml_path: str,
|
||||
cli_args: Optional[dict[str, str]] = None,
|
||||
env_path: Optional[str] = None,
|
||||
) -> "Config":
|
||||
"""三层合并加载配置。
|
||||
|
||||
优先级: CLI args > .env > YAML。
|
||||
|
||||
参数:
|
||||
yaml_path: YAML 配置文件路径。
|
||||
cli_args: CLI 覆盖参数,键为点路径(如 "retriever.num_heads"),值为字符串。
|
||||
env_path: .env 文件路径,默认为项目根目录的 .env。
|
||||
|
||||
返回:
|
||||
完整的 Config 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: YAML 文件不存在。
|
||||
TypeError: YAML 中缺少必需字段。
|
||||
"""
|
||||
# Phase 1: 读取 YAML
|
||||
yaml_file = Path(yaml_path)
|
||||
if not yaml_file.exists():
|
||||
raise FileNotFoundError(f"配置文件不存在: {yaml_path}")
|
||||
|
||||
with open(yaml_file, encoding="utf-8") as f:
|
||||
base_dict: dict = yaml.safe_load(f)
|
||||
|
||||
# Phase 2: 读取 .env 覆盖敏感字段
|
||||
if env_path is None:
|
||||
env_file = Path(yaml_path).parent.parent / ".env"
|
||||
else:
|
||||
env_file = Path(env_path)
|
||||
|
||||
if env_file.exists():
|
||||
env_vars = dotenv_values(str(env_file))
|
||||
env_overrides: dict[str, dict[str, str]] = {}
|
||||
# .env 变量名 → (配置节, 字段名) 的映射
|
||||
_ENV_MAP: dict[str, tuple[str, str]] = {
|
||||
"LLM_API_KEY": ("llm", "api_key"),
|
||||
"LLM_MODEL": ("llm", "model"),
|
||||
"LLM_API_URL": ("llm", "api_url"),
|
||||
"VLM_API_KEY": ("vlm", "api_key"),
|
||||
"VLM_MODEL": ("vlm", "model"),
|
||||
"VLM_API_URL": ("vlm", "api_url"),
|
||||
"EMBED_BACKEND": ("embed", "backend"),
|
||||
"EMBED_MODEL": ("embed", "model_name"),
|
||||
"EMBED_API_KEY": ("embed", "api_key"),
|
||||
"EMBED_API_URL": ("embed", "api_url"),
|
||||
}
|
||||
for env_name, (section, field) in _ENV_MAP.items():
|
||||
if env_vars.get(env_name):
|
||||
env_overrides.setdefault(section, {})[field] = env_vars[env_name]
|
||||
base_dict = _deep_merge(base_dict, env_overrides)
|
||||
|
||||
# Phase 3: CLI args 覆盖
|
||||
if cli_args:
|
||||
for dotpath, value in cli_args.items():
|
||||
_apply_dotpath(base_dict, dotpath, value)
|
||||
|
||||
# Phase 4: 构造 dataclass(缺字段自动抛 TypeError)
|
||||
sections = {}
|
||||
for section_name, dc_class in _SECTION_TO_CLASS.items():
|
||||
section_data = base_dict.get(section_name, {})
|
||||
if not isinstance(section_data, dict):
|
||||
# 对于 Optional 的 section,跳过
|
||||
if section_name == "hier_retriever":
|
||||
continue
|
||||
raise TypeError(
|
||||
f"配置节 '{section_name}' 必须是字典,实际为 {type(section_data)}"
|
||||
)
|
||||
sections[section_name] = dc_class(**section_data)
|
||||
|
||||
config = cls(**sections)
|
||||
|
||||
# 校验: embed_dim 一致性
|
||||
ensure(
|
||||
config.embed.embed_dim == config.retriever.embed_dim,
|
||||
f"embed.embed_dim ({config.embed.embed_dim}) 与 "
|
||||
f"retriever.embed_dim ({config.retriever.embed_dim}) 不一致",
|
||||
)
|
||||
|
||||
log_msg("INFO", "配置加载完成", yaml=yaml_path)
|
||||
return config
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
嵌入服务模块
|
||||
============
|
||||
封装文本嵌入器,支持本地 sentence-transformers 和远程 OpenAI 兼容 API 两种后端。
|
||||
提供统一的 ``embed()`` / ``embed_tensor()`` 接口,冻结不训练。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
model = EmbeddingModel(cfg.embed)
|
||||
vecs = model.embed(["你好世界"]) # ndarray [1, D]
|
||||
tens = model.embed_tensor(["你好"]) # Tensor [1, D]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Union
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from numpy import ndarray
|
||||
from torch import Tensor
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
from video_tree_trm.config import EmbedConfig
|
||||
|
||||
|
||||
class EmbeddingModel:
|
||||
"""文本嵌入器封装(冻结),支持本地和远程双后端。
|
||||
|
||||
本地模式: 使用 sentence-transformers 加载 HuggingFace 模型,本地推理。
|
||||
远程模式: 调用 OpenAI 兼容 API(如 GPUStack 上的 qwen3-embedding)。
|
||||
|
||||
属性:
|
||||
dim: 嵌入维度 D。
|
||||
"""
|
||||
|
||||
def __init__(self, config: EmbedConfig) -> None:
|
||||
"""初始化嵌入模型。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置,包含 backend、model_name、embed_dim 等。
|
||||
|
||||
异常:
|
||||
ValueError: backend 不是 "local" 或 "remote"。
|
||||
ValueError: 远程模式缺少 api_key 或 api_url。
|
||||
"""
|
||||
ensure(
|
||||
config.backend in ("local", "remote"),
|
||||
f"embed.backend 必须为 'local' 或 'remote',实际为 '{config.backend}'",
|
||||
)
|
||||
self._backend = config.backend
|
||||
self._dim = config.embed_dim
|
||||
|
||||
if self._backend == "local":
|
||||
self._init_local(config)
|
||||
else:
|
||||
self._init_remote(config)
|
||||
|
||||
log_msg(
|
||||
"INFO", "嵌入模型初始化完成", backend=self._backend, model=config.model_name
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 初始化
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _init_local(self, config: EmbedConfig) -> None:
|
||||
"""初始化本地 sentence-transformers 模型。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置。
|
||||
"""
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
self._model = SentenceTransformer(config.model_name, device=config.device)
|
||||
self._model.eval()
|
||||
# 冻结所有参数
|
||||
for param in self._model.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
actual_dim = self._model.get_sentence_embedding_dimension()
|
||||
ensure(
|
||||
actual_dim == self._dim,
|
||||
f"模型实际维度 ({actual_dim}) 与配置 embed_dim ({self._dim}) 不一致",
|
||||
)
|
||||
|
||||
def _init_remote(self, config: EmbedConfig) -> None:
|
||||
"""初始化远程 OpenAI 兼容 API 客户端。
|
||||
|
||||
参数:
|
||||
config: 嵌入配置。
|
||||
"""
|
||||
ensure(bool(config.api_key), "远程模式必须提供 embed.api_key")
|
||||
ensure(bool(config.api_url), "远程模式必须提供 embed.api_url")
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
self._client = OpenAI(base_url=config.api_url, api_key=config.api_key)
|
||||
self._model_name = config.model_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def dim(self) -> int:
|
||||
"""嵌入维度 D。"""
|
||||
return self._dim
|
||||
|
||||
def embed(self, texts: Union[str, List[str]]) -> ndarray:
|
||||
"""文本 → 嵌入向量(L2 归一化)。
|
||||
|
||||
参数:
|
||||
texts: 单条文本或文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,每行 L2 范数为 1.0。单条文本时 N=1。
|
||||
"""
|
||||
if isinstance(texts, str):
|
||||
texts = [texts]
|
||||
|
||||
if self._backend == "local":
|
||||
return self._embed_local(texts)
|
||||
return self._embed_remote(texts)
|
||||
|
||||
def embed_tensor(self, texts: Union[str, List[str]]) -> Tensor:
|
||||
"""文本 → 嵌入 Tensor(L2 归一化)。
|
||||
|
||||
参数:
|
||||
texts: 单条文本或文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] torch.Tensor(float32)。
|
||||
"""
|
||||
arr = self.embed(texts)
|
||||
return torch.from_numpy(arr).float()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 后端实现
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _embed_local(self, texts: List[str]) -> ndarray:
|
||||
"""本地 sentence-transformers 推理。
|
||||
|
||||
参数:
|
||||
texts: 文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,L2 归一化。
|
||||
"""
|
||||
with torch.no_grad():
|
||||
embeddings = self._model.encode(
|
||||
texts,
|
||||
normalize_embeddings=True,
|
||||
convert_to_numpy=True,
|
||||
)
|
||||
# sentence-transformers encode 返回 ndarray [N, D]
|
||||
if embeddings.ndim == 1:
|
||||
embeddings = embeddings.reshape(1, -1)
|
||||
return embeddings
|
||||
|
||||
def _embed_remote(self, texts: List[str]) -> ndarray:
|
||||
"""远程 OpenAI 兼容 API 调用。
|
||||
|
||||
参数:
|
||||
texts: 文本列表。
|
||||
|
||||
返回:
|
||||
[N, D] ndarray,L2 归一化。
|
||||
"""
|
||||
response = self._client.embeddings.create(
|
||||
model=self._model_name,
|
||||
input=texts,
|
||||
)
|
||||
# 按 index 排序,确保顺序一致
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
embeddings = np.array(
|
||||
[item.embedding for item in sorted_data], dtype=np.float32
|
||||
)
|
||||
|
||||
# L2 归一化
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
norms = np.maximum(norms, 1e-12) # 避免除零
|
||||
embeddings = embeddings / norms
|
||||
|
||||
return embeddings
|
||||
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
LLM/VLM 客户端模块
|
||||
==================
|
||||
统一封装 LLM(纯文本)和 VLM(多模态)API 调用。
|
||||
|
||||
仅支持 OpenAI-compatible 接口,通过配置 api_url + model 适配不同服务商
|
||||
(如 Qwen DashScope、OpenAI、本地推理服务等)。
|
||||
|
||||
提供同步版(chat / chat_with_images)和异步版(chat_async / chat_with_images_async)。
|
||||
异步版基于 openai.AsyncOpenAI,适配 asyncio 事件循环,零线程阻塞。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.config import Config
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
vlm = LLMClient(cfg.vlm)
|
||||
|
||||
# 同步
|
||||
answer = vlm.chat_with_images("图中有什么?", images=["frame.jpg"])
|
||||
|
||||
# 异步
|
||||
import asyncio
|
||||
answer = asyncio.run(vlm.chat_with_images_async("图中有什么?", images=["frame.jpg"]))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
|
||||
from utils.logger_system import log_exception, log_msg
|
||||
from video_tree_trm.config import LLMConfig, VLMConfig
|
||||
|
||||
# 502/503 时的重试参数
|
||||
_RETRY_STATUS_CODES = {502, 503}
|
||||
_MAX_RETRIES = 20 # 最多重试次数(约等待 20+ 分钟)
|
||||
_RETRY_BASE_WAIT = 60 # 首次等待 60 秒
|
||||
_RETRY_MAX_WAIT = 300 # 单次等待上限 5 分钟
|
||||
|
||||
|
||||
def _call_with_retry(fn, label: str):
|
||||
"""对 fn() 调用执行指数退避重试(重试 502/503 及超时)。
|
||||
|
||||
参数:
|
||||
fn: 无参调用的函数,返回 API response。
|
||||
label: 日志标识(如方法名)。
|
||||
|
||||
返回:
|
||||
fn() 的返回值。
|
||||
|
||||
异常:
|
||||
openai.OpenAIError: 超过最大重试次数或非可重试错误时抛出。
|
||||
"""
|
||||
wait = _RETRY_BASE_WAIT
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
return fn()
|
||||
except openai.APITimeoutError:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 请求超时,等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
except openai.InternalServerError as exc:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in _RETRY_STATUS_CODES:
|
||||
raise
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 遇到 {status},等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
|
||||
|
||||
|
||||
async def _async_call_with_retry(coro_fn, label: str):
|
||||
"""异步版指数退避重试,适配 asyncio 事件循环。
|
||||
|
||||
参数:
|
||||
coro_fn: 无参调用的协程工厂函数(每次调用返回新协程)。
|
||||
label: 日志标识(如方法名)。
|
||||
|
||||
返回:
|
||||
coro_fn() 的返回值。
|
||||
|
||||
实现细节:
|
||||
使用 await asyncio.sleep() 替代 time.sleep(),不阻塞事件循环。
|
||||
每次重试需重新调用 coro_fn() 构造新协程(协程不可复用)。
|
||||
"""
|
||||
wait = _RETRY_BASE_WAIT
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
return await coro_fn()
|
||||
except openai.APITimeoutError:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 请求超时,等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
except openai.InternalServerError as exc:
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in _RETRY_STATUS_CODES:
|
||||
raise
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"{label} 遇到 {status},等待 {wait}s 后重试",
|
||||
attempt=attempt,
|
||||
max_retries=_MAX_RETRIES,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
wait = min(wait * 2, _RETRY_MAX_WAIT)
|
||||
raise RuntimeError(f"{label} 已重试 {_MAX_RETRIES} 次仍失败")
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""OpenAI-compatible LLM/VLM 统一客户端。
|
||||
|
||||
同时提供同步接口(chat / chat_with_images)和异步接口(chat_async / chat_with_images_async)。
|
||||
异步接口使用独立的 AsyncOpenAI 实例,零线程阻塞,与 asyncio.Semaphore 配合实现真并发。
|
||||
|
||||
属性:
|
||||
_config: LLMConfig 或 VLMConfig 配置对象。
|
||||
_client: openai.OpenAI 同步客户端。
|
||||
_async_client: openai.AsyncOpenAI 异步客户端。
|
||||
_extra_body: 关闭 Qwen3 thinking 模式的额外参数。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Union[LLMConfig, VLMConfig]) -> None:
|
||||
"""初始化 LLM/VLM 客户端(同步 + 异步双客户端)。
|
||||
|
||||
参数:
|
||||
config: LLMConfig 或 VLMConfig,包含 api_key、api_url、model 等参数。
|
||||
|
||||
异常:
|
||||
ValueError: api_key 或 api_url 为空时抛出。
|
||||
"""
|
||||
if not config.api_key:
|
||||
raise ValueError(
|
||||
"LLMClient 初始化失败: config.api_key 不能为空,请在 .env 中设置"
|
||||
)
|
||||
if not config.api_url:
|
||||
raise ValueError(
|
||||
"LLMClient 初始化失败: config.api_url 不能为空,请在 config/default.yaml 中设置"
|
||||
)
|
||||
|
||||
self._config = config
|
||||
# 同步客户端(向后兼容)
|
||||
self._client = openai.OpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.api_url,
|
||||
http_client=httpx.Client(proxy=None),
|
||||
)
|
||||
# 异步客户端(asyncio 场景,零阻塞)
|
||||
self._async_client = openai.AsyncOpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.api_url,
|
||||
http_client=httpx.AsyncClient(proxy=None),
|
||||
)
|
||||
# 关闭 Qwen3 thinking 模式(vLLM 正确格式)
|
||||
self._extra_body: Dict = {"chat_template_kwargs": {"enable_thinking": False}}
|
||||
log_msg(
|
||||
"INFO", "LLMClient 初始化完成", model=config.model, api_url=config.api_url
|
||||
)
|
||||
|
||||
# ── 同步接口(向后兼容)─────────────────────────────────────────────────
|
||||
|
||||
def chat(self, prompt: str, max_tokens: Optional[int] = None) -> str:
|
||||
"""纯文本单轮对话(同步)。
|
||||
|
||||
参数:
|
||||
prompt: 用户输入文本。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
"""
|
||||
messages = self._build_messages(prompt)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = _call_with_retry(
|
||||
lambda: self._client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat 调用失败", exc)
|
||||
raise
|
||||
|
||||
def chat_with_images(
|
||||
self,
|
||||
prompt: str,
|
||||
images: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""多模态单轮对话(VLM,同步)。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
"""
|
||||
encoded = [self._encode_image(img) for img in images]
|
||||
messages = self._build_messages(prompt, images=encoded)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = _call_with_retry(
|
||||
lambda: self._client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_with_images",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_with_images 调用失败", exc)
|
||||
raise
|
||||
|
||||
def batch_chat(
|
||||
self,
|
||||
prompts: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
"""批量纯文本并发对话,保序返回(同步)。
|
||||
|
||||
参数:
|
||||
prompts: 文本输入列表。
|
||||
max_tokens: 最大生成 token 数。
|
||||
|
||||
返回:
|
||||
与 prompts 等长的生成文本列表,顺序与输入对应。
|
||||
"""
|
||||
results: List[str] = [""] * len(prompts)
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
future_to_idx = {
|
||||
executor.submit(self.chat, prompt, max_tokens): idx
|
||||
for idx, prompt in enumerate(prompts)
|
||||
}
|
||||
for future in as_completed(future_to_idx):
|
||||
idx = future_to_idx[future]
|
||||
results[idx] = future.result()
|
||||
return results
|
||||
|
||||
# ── 异步接口(asyncio 事件循环,零阻塞)──────────────────────────────────
|
||||
|
||||
async def chat_async(self, prompt: str, max_tokens: Optional[int] = None) -> str:
|
||||
"""纯文本单轮对话(异步,零线程阻塞)。
|
||||
|
||||
参数:
|
||||
prompt: 用户输入文本。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
|
||||
实现细节:
|
||||
使用 AsyncOpenAI 客户端,await 期间事件循环可处理其他协程,
|
||||
配合 asyncio.Semaphore 实现受控并发。
|
||||
"""
|
||||
messages = self._build_messages(prompt)
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = await _async_call_with_retry(
|
||||
lambda: self._async_client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_async",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_async 调用失败", exc)
|
||||
raise
|
||||
|
||||
async def chat_with_images_async(
|
||||
self,
|
||||
prompt: str,
|
||||
images: List[str],
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> str:
|
||||
"""多模态单轮对话(VLM,异步,零线程阻塞)。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 图像列表,每项可为本地文件路径或已编码的 base64 字符串。
|
||||
max_tokens: 最大生成 token 数,为 None 时使用 config.max_tokens。
|
||||
|
||||
返回:
|
||||
生成的文本字符串。
|
||||
|
||||
实现细节:
|
||||
图像编码(磁盘读取 + base64)在默认线程池执行器中并行执行,
|
||||
避免阻塞事件循环;VLM API 调用通过 AsyncOpenAI 零阻塞。
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# 并行编码所有图像(I/O 密集,交给线程池)
|
||||
encoded: List[str] = await asyncio.gather(
|
||||
*[loop.run_in_executor(None, self._encode_image, img) for img in images]
|
||||
)
|
||||
messages = self._build_messages(prompt, images=list(encoded))
|
||||
tokens = max_tokens if max_tokens is not None else self._config.max_tokens
|
||||
try:
|
||||
response = await _async_call_with_retry(
|
||||
lambda: self._async_client.chat.completions.create(
|
||||
model=self._config.model,
|
||||
messages=messages,
|
||||
max_tokens=tokens,
|
||||
temperature=self._config.temperature,
|
||||
extra_body=self._extra_body,
|
||||
),
|
||||
label="LLMClient.chat_with_images_async",
|
||||
)
|
||||
return self._strip_thinking(response.choices[0].message.content)
|
||||
except Exception as exc:
|
||||
log_exception("LLMClient.chat_with_images_async 调用失败", exc)
|
||||
raise
|
||||
|
||||
# ── 私有辅助方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_thinking(content: str) -> str:
|
||||
"""剥离 Qwen3 thinking 模式生成的 <think>...</think> 块。
|
||||
|
||||
参数:
|
||||
content: VLM/LLM 原始返回文本(可能含 <think> 块)。
|
||||
|
||||
返回:
|
||||
去除 think 块后的纯净文本。
|
||||
|
||||
实现细节:
|
||||
当 API 参数无法完全禁用 thinking 时作为兜底保障。
|
||||
<think> 块可能跨多行,使用 DOTALL 模式匹配。
|
||||
"""
|
||||
cleaned = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
|
||||
return cleaned.strip()
|
||||
|
||||
def _encode_image(self, path_or_b64: str) -> str:
|
||||
"""将图像转换为 data URI 格式的 base64 字符串。
|
||||
|
||||
参数:
|
||||
path_or_b64: 本地文件路径,或已是 "data:image/...;base64,..." 格式的字符串。
|
||||
|
||||
返回:
|
||||
"data:image/jpeg;base64,<base64数据>" 格式字符串。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 指定路径文件不存在时抛出。
|
||||
"""
|
||||
if "base64," in path_or_b64:
|
||||
return path_or_b64
|
||||
|
||||
if not os.path.exists(path_or_b64):
|
||||
raise FileNotFoundError(f"图像文件不存在: {path_or_b64}")
|
||||
|
||||
with open(path_or_b64, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
b64_data = base64.b64encode(raw).decode("utf-8")
|
||||
ext = os.path.splitext(path_or_b64)[1].lower()
|
||||
mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
return f"data:{mime};base64,{b64_data}"
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
prompt: str,
|
||||
images: Optional[List[str]] = None,
|
||||
) -> List[Dict]:
|
||||
"""拼装 OpenAI-compatible 消息结构。
|
||||
|
||||
参数:
|
||||
prompt: 文本指令。
|
||||
images: 已编码的 base64 data URI 列表(可为 None)。
|
||||
|
||||
返回:
|
||||
OpenAI messages 格式的列表。
|
||||
|
||||
实现细节:
|
||||
- 无图像:content 为纯字符串。
|
||||
- 有图像:content 为列表,图像在前,文本在后。
|
||||
"""
|
||||
if not images:
|
||||
return [{"role": "user", "content": prompt}]
|
||||
|
||||
content: List[Dict] = [
|
||||
{"type": "image_url", "image_url": {"url": img}} for img in images
|
||||
]
|
||||
content.append({"type": "text", "text": prompt})
|
||||
return [{"role": "user", "content": content}]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
端到端推理管线
|
||||
==============
|
||||
串联 预处理 → 检索 → 生成 的完整推理流程。
|
||||
提供 ``build_index()`` 和 ``query()`` 两个高层接口。
|
||||
|
||||
使用方式::
|
||||
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.pipeline import Pipeline
|
||||
|
||||
cfg = Config.load("config/default.yaml")
|
||||
pipeline = Pipeline(cfg)
|
||||
|
||||
# 构建(或从缓存加载)树索引
|
||||
tree = pipeline.build_index("data/my_doc.txt", modality="text")
|
||||
|
||||
# 问答
|
||||
answer = pipeline.query("文档的主要结论是什么?", tree)
|
||||
print(answer)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
from video_tree_trm.answer_generator import AnswerGenerator
|
||||
from video_tree_trm.config import Config
|
||||
from video_tree_trm.embeddings import EmbeddingModel
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.recursive_retriever import RecursiveRetriever
|
||||
from video_tree_trm.text_tree_builder import TextTreeBuilder
|
||||
from video_tree_trm.tree_index import TreeIndex
|
||||
from video_tree_trm.video_tree_builder import VideoTreeBuilder
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""端到端推理管线(预处理 → 检索 → 生成)。
|
||||
|
||||
将所有子模块按配置串联,对外暴露两个接口:
|
||||
- ``build_index()``: 从原始文件构建 TreeIndex,支持磁盘缓存。
|
||||
- ``query()``: 对已有 TreeIndex 执行问答,返回生成答案字符串。
|
||||
|
||||
属性:
|
||||
config: 全局配置对象。
|
||||
embed_model: 文本嵌入模型(冻结)。
|
||||
llm: 文本大语言模型客户端。
|
||||
vlm: 视觉语言模型客户端。
|
||||
retriever: TRM 递归检索器(eval 模式)。
|
||||
generator: 答案生成器。
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
"""初始化端到端推理管线。
|
||||
|
||||
参数:
|
||||
config: 通过 ``Config.load()`` 加载的全局配置对象。
|
||||
|
||||
实现细节:
|
||||
- 若 ``config.retriever.checkpoint`` 非 None,加载预训练权重。
|
||||
- 检索器始终切换到 eval 模式(关闭 Dropout 等训练行为)。
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
# Phase 1: 初始化各子模块(embed_model 懒加载,仅 query/embed 时触发)
|
||||
self._embed_model: Optional[EmbeddingModel] = None
|
||||
self.llm = LLMClient(config.llm)
|
||||
self.vlm = LLMClient(config.vlm)
|
||||
self.retriever = RecursiveRetriever(config.retriever)
|
||||
|
||||
# Phase 2: 可选加载检查点
|
||||
if config.retriever.checkpoint:
|
||||
ensure(
|
||||
os.path.isfile(config.retriever.checkpoint),
|
||||
f"检查点文件不存在: {config.retriever.checkpoint}",
|
||||
)
|
||||
state_dict = torch.load(config.retriever.checkpoint, map_location="cpu")
|
||||
self.retriever.load_state_dict(state_dict)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检索器权重已加载",
|
||||
checkpoint=config.retriever.checkpoint,
|
||||
)
|
||||
|
||||
self.retriever.eval()
|
||||
self.generator = AnswerGenerator(self.llm, self.vlm)
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"Pipeline 初始化完成",
|
||||
modality_embed=config.embed.model_name,
|
||||
has_checkpoint=bool(config.retriever.checkpoint),
|
||||
)
|
||||
|
||||
@property
|
||||
def embed_model(self) -> EmbeddingModel:
|
||||
"""懒加载 EmbeddingModel,仅在首次访问时初始化(index 阶段不触发)。"""
|
||||
if self._embed_model is None:
|
||||
log_msg("INFO", "懒加载 EmbeddingModel", model=self.config.embed.model_name)
|
||||
self._embed_model = EmbeddingModel(self.config.embed)
|
||||
return self._embed_model
|
||||
|
||||
def build_index(self, source_path: str, modality: str) -> TreeIndex:
|
||||
"""构建并缓存 TreeIndex(JSON 格式,含 embedding)。
|
||||
|
||||
参数:
|
||||
source_path: 原始文件路径(文本文件或视频文件)。
|
||||
modality: 模态类型,"text" 或 "video"。
|
||||
|
||||
返回:
|
||||
构建完成的 TreeIndex 对象(已 embed)。
|
||||
|
||||
实现细节:
|
||||
- 缓存路径: ``{cache_dir}/{stem}_{modality}.json``。
|
||||
- 缓存命中时直接反序列化返回(自动恢复 embedding 若有)。
|
||||
- 缓存未命中时调用 VLM 生成描述文本,执行 embedding,保存为 JSON。
|
||||
"""
|
||||
ensure(
|
||||
modality in ("text", "video"),
|
||||
f"modality 须为 'text' 或 'video',实际={modality}",
|
||||
)
|
||||
|
||||
# Phase 1: 缓存路径计算
|
||||
stem = Path(source_path).stem
|
||||
cache_dir = Path(self.config.tree.cache_dir)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
cache_path = str(cache_dir / f"{stem}_{modality}.json")
|
||||
|
||||
if os.path.isfile(cache_path):
|
||||
log_msg("INFO", "缓存命中,直接加载 TreeIndex", cache_path=cache_path)
|
||||
tree = TreeIndex.load_json(cache_path)
|
||||
# 若缓存中已有 embedding,直接返回;否则按需 embed
|
||||
if tree.is_embedded:
|
||||
return tree
|
||||
log_msg("INFO", "缓存中无 embedding,开始执行 embed_all")
|
||||
self._embed_tree(tree, cache_path=cache_path)
|
||||
return tree
|
||||
|
||||
# Phase 2: 构建树索引(纯 VLM 文字描述)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"缓存未命中,开始构建 TreeIndex",
|
||||
source_path=source_path,
|
||||
modality=modality,
|
||||
)
|
||||
|
||||
if modality == "text":
|
||||
with open(source_path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
builder = TextTreeBuilder(self.llm, self.config.tree)
|
||||
tree = builder.build(text, source_path)
|
||||
else:
|
||||
builder = VideoTreeBuilder(self.vlm, self.config.tree)
|
||||
tree = builder.build(source_path)
|
||||
|
||||
# Phase 3: 执行 embedding 并保存(含 embedding)
|
||||
self._embed_tree(tree, cache_path=cache_path)
|
||||
|
||||
return tree
|
||||
|
||||
def _embed_tree(self, tree: TreeIndex, cache_path: Optional[str] = None) -> None:
|
||||
"""对树的所有节点执行 embedding,可选回写缓存。
|
||||
|
||||
参数:
|
||||
tree: 待 embed 的 TreeIndex(embedding=None 的节点)。
|
||||
cache_path: 若非 None,embed 完成后回写到此路径(JSON 格式,含 embedding)。
|
||||
|
||||
实现细节:
|
||||
调用 TreeIndex.embed_all,传入 EmbeddingModel.embed 作为 embed_fn。
|
||||
embed_all 内部按 L2 分组批量处理 L3,减少 API 调用次数。
|
||||
若 cache_path 非 None,保存时 include_embedding=True。
|
||||
"""
|
||||
log_msg("INFO", "开始对树执行 embedding")
|
||||
tree.embed_all(
|
||||
embed_fn=self.embed_model.embed,
|
||||
model_name=self.config.embed.model_name,
|
||||
embed_dim=self.embed_model.dim,
|
||||
)
|
||||
if cache_path is not None:
|
||||
tree.save_json(cache_path, include_embedding=True)
|
||||
log_msg("INFO", "embed_all 完成,缓存已更新(含 embedding)", cache_path=cache_path)
|
||||
else:
|
||||
log_msg("INFO", "embed_all 完成(仅内存,未写磁盘)")
|
||||
|
||||
def _load_or_build_video_tree(self, video_path: str) -> TreeIndex:
|
||||
"""根据视频路径优先从缓存加载 TreeIndex,若无缓存则在线构建。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 youtube_id。
|
||||
|
||||
返回:
|
||||
加载或构建完成的 TreeIndex 对象。
|
||||
"""
|
||||
# 如果传入的是 youtube_id,尝试拼凑路径
|
||||
if not os.path.isfile(video_path):
|
||||
video_path_full = os.path.join("data/videomme/videos", f"{video_path}.mp4")
|
||||
if os.path.isfile(video_path_full):
|
||||
video_path = video_path_full
|
||||
|
||||
return self.build_index(video_path, modality="video")
|
||||
|
||||
def query(
|
||||
self,
|
||||
question: str,
|
||||
tree: TreeIndex | str,
|
||||
modality: Optional[str] = None,
|
||||
cache_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""执行端到端问答。
|
||||
|
||||
参数:
|
||||
question: 用户查询字符串。
|
||||
tree: TreeIndex 对象,或树 JSON 路径,或视频路径。
|
||||
modality: 当 tree 为字符串且无法自动推断时,指定模态 ("text" 或 "video")。
|
||||
cache_path: 若非 None,embed 完成后回写到此路径。
|
||||
|
||||
返回:
|
||||
生成的答案字符串。
|
||||
"""
|
||||
# Phase 0: 处理输入,确保得到 TreeIndex 对象
|
||||
if isinstance(tree, str):
|
||||
if tree.endswith(".json"):
|
||||
log_msg("INFO", "直接从 JSON 路径加载 TreeIndex", path=tree)
|
||||
tree_obj = TreeIndex.load_json(tree)
|
||||
# 若 cache_path 未指定,使用 tree 的 JSON 路径
|
||||
if cache_path is None:
|
||||
cache_path = tree
|
||||
elif modality == "video" or tree.endswith(".mp4"):
|
||||
log_msg("INFO", "根据视频路径获取 TreeIndex", path=tree)
|
||||
tree_obj = self._load_or_build_video_tree(tree)
|
||||
else:
|
||||
# 默认为文本
|
||||
log_msg("INFO", "根据文本路径获取 TreeIndex", path=tree)
|
||||
tree_obj = self.build_index(tree, modality="text")
|
||||
else:
|
||||
tree_obj = tree
|
||||
|
||||
# Phase 1: 确保树已 embed
|
||||
if not tree_obj.is_embedded:
|
||||
log_msg("INFO", "树尚未 embed,触发 embed_all 并回写缓存", cache_path=cache_path)
|
||||
self._embed_tree(tree_obj, cache_path=cache_path)
|
||||
|
||||
# Phase 2: 嵌入查询
|
||||
q: torch.Tensor = self.embed_model.embed_tensor(question) # [1, D]
|
||||
|
||||
# Phase 3: 递归检索
|
||||
with torch.no_grad():
|
||||
result = self.retriever(q, tree_obj)
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检索完成",
|
||||
num_rounds=result["num_rounds"],
|
||||
num_paths=len(result["paths"]),
|
||||
question=question[:50],
|
||||
)
|
||||
|
||||
# Phase 4: 生成答案
|
||||
return self.generator.generate(
|
||||
question, result["paths"], tree_obj, frame_hits=result.get("frame_hits")
|
||||
)
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
文本树构建模块
|
||||
==============
|
||||
将长文本通过 L2 轴心策略转化为三层 TreeIndex。
|
||||
|
||||
构建策略::
|
||||
|
||||
Step 1: _segment_text — 结构切分,确定 L1/L2 边界
|
||||
Step 2: L2 先行 — 从原始内容独立生成 L2 摘要(batch_chat 并发)
|
||||
Step 3: L3 向下 — 原始段落文本直接作为 L3,无需二次生成
|
||||
Step 4: L1 向上 — 聚合 L2 描述,生成 L1 粗粒度摘要
|
||||
Step 5: 组装 TreeIndex
|
||||
|
||||
L2 轴心策略解决了循环依赖:
|
||||
- L2 描述不依赖 L3,从原始段落直接生成
|
||||
- L3 直接使用原始段落文本,不调用 LLM
|
||||
- L1 聚合 L2 描述,保证完整覆盖
|
||||
|
||||
使用方式::
|
||||
|
||||
builder = TextTreeBuilder(embed_model, llm_client, config.tree)
|
||||
index = builder.build(text, source_path="docs/my_doc.txt")
|
||||
index.save("cache/my_doc.pkl")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logger_system import ensure, log_json, log_msg
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_L2_PROMPT = (
|
||||
"用1-2句话描述以下段落的核心内容,与同级小节形成区分:\n\n{text}"
|
||||
)
|
||||
|
||||
_L1_PROMPT = (
|
||||
"用2-3句话总结以下小节的核心内容:\n\n{l2_descriptions}"
|
||||
)
|
||||
|
||||
_SEG_PROMPT = (
|
||||
"将以下文本分成若干语义段落,每段为完整语义单元。\n"
|
||||
"只返回 JSON 数组,格式: [\"段落1\", \"段落2\", ...],不要其他内容。\n"
|
||||
"文本:\n\n{text}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _chunk(lst: List[str], size: int) -> List[List[str]]:
|
||||
"""将列表等长分块(固定步长,无重叠)。
|
||||
|
||||
参数:
|
||||
lst: 待分块的列表。
|
||||
size: 每块的最大长度。
|
||||
|
||||
返回:
|
||||
分块后的列表,每个元素为一个子列表。
|
||||
"""
|
||||
return [lst[i : i + size] for i in range(0, len(lst), size)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TextTreeBuilder:
|
||||
"""文本模态树构建器。
|
||||
|
||||
将长文本通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
|
||||
转化为三层 TreeIndex。节点 embedding 均为 None(由 Pipeline.embed_all 延迟填充)。
|
||||
|
||||
属性:
|
||||
llm: LLM 客户端。
|
||||
config: 树构建配置。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLMClient,
|
||||
config: TreeConfig,
|
||||
) -> None:
|
||||
"""初始化文本树构建器。
|
||||
|
||||
参数:
|
||||
llm: 已初始化的 LLM 客户端(LLMClient)。
|
||||
config: 树构建配置(TreeConfig),关键字段 max_paragraphs_per_l2。
|
||||
|
||||
实现细节:
|
||||
构建器不持有 EmbeddingModel,所有 embedding 延迟到检索阶段由 Pipeline 统一计算。
|
||||
"""
|
||||
self.llm = llm
|
||||
self.config = config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build(self, text: str, source_path: str) -> TreeIndex:
|
||||
"""将长文本构建为三层 TreeIndex。
|
||||
|
||||
参数:
|
||||
text: 输入长文本(UTF-8 字符串)。
|
||||
source_path: 原始文件路径,写入 IndexMeta。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
1. _segment_text 切分文本 → List[List[str]](外层=L1,内层=L2段落组)
|
||||
2. 将所有 L2 段落组的 prompt 批量送入 llm.batch_chat(),并发获取摘要
|
||||
3. 逐层组装 L3→L2→L1 节点
|
||||
4. 构建 TreeIndex 并写入日志
|
||||
"""
|
||||
ensure(bool(text.strip()), "输入文本不能为空")
|
||||
log_msg("INFO", "开始构建文本树索引", source_path=source_path)
|
||||
|
||||
# Phase 1: 结构切分
|
||||
sections = self._segment_text(text)
|
||||
ensure(len(sections) > 0, "文本切分结果为空")
|
||||
log_msg(
|
||||
"INFO",
|
||||
"文本切分完成",
|
||||
l1_count=len(sections),
|
||||
l2_groups=[len(s) for s in sections],
|
||||
)
|
||||
|
||||
# Phase 2: 收集所有 L2 段落组,批量生成摘要(L2 先行)
|
||||
all_groups: List[Tuple[int, int, List[str]]] = []
|
||||
for i, section_paragraphs in enumerate(sections):
|
||||
for j, group in enumerate(
|
||||
_chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
|
||||
):
|
||||
all_groups.append((i, j, group))
|
||||
|
||||
l2_prompts = [
|
||||
_L2_PROMPT.format(text="\n\n".join(group))
|
||||
for _, _, group in all_groups
|
||||
]
|
||||
l2_descs = self.llm.batch_chat(l2_prompts)
|
||||
log_msg("INFO", "L2 摘要生成完成", total_l2=len(l2_descs))
|
||||
|
||||
# Phase 3-4: 按 L1 组装三层节点
|
||||
# 构建索引映射:(i, j) → 在 all_groups / l2_descs 中的位置
|
||||
group_index: dict = {
|
||||
(i, j): idx for idx, (i, j, _) in enumerate(all_groups)
|
||||
}
|
||||
|
||||
l1_nodes: List[L1Node] = []
|
||||
for i, section_paragraphs in enumerate(sections):
|
||||
groups = _chunk(section_paragraphs, self.config.max_paragraphs_per_l2)
|
||||
l2_nodes: List[L2Node] = []
|
||||
|
||||
for j, group in enumerate(groups):
|
||||
idx = group_index[(i, j)]
|
||||
desc = l2_descs[idx]
|
||||
l3_nodes = self._build_l3_from_paragraphs(group, i, j)
|
||||
l2_node = L2Node(
|
||||
id=f"l1_{i}_l2_{j}",
|
||||
description=desc,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
children=l3_nodes,
|
||||
)
|
||||
l2_nodes.append(l2_node)
|
||||
|
||||
l1_node = self._build_l1(l2_nodes, f"l1_{i}")
|
||||
l1_nodes.append(l1_node)
|
||||
|
||||
# Phase 5: 组装 TreeIndex(embedding 延迟到 Pipeline.embed_all,此处为 None)
|
||||
metadata = IndexMeta(
|
||||
source_path=source_path,
|
||||
modality="text",
|
||||
created_at=datetime.now().isoformat(),
|
||||
)
|
||||
index = TreeIndex(metadata=metadata, roots=l1_nodes)
|
||||
|
||||
total_l2 = sum(len(r.children) for r in l1_nodes)
|
||||
total_l3 = sum(
|
||||
len(l2.children) for r in l1_nodes for l2 in r.children
|
||||
)
|
||||
log_json(
|
||||
"text_tree_build",
|
||||
{
|
||||
"source_path": source_path,
|
||||
"l1_count": len(l1_nodes),
|
||||
"l2_count": total_l2,
|
||||
"l3_count": total_l3,
|
||||
"embedded": False,
|
||||
},
|
||||
)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"文本树索引构建完成",
|
||||
l1=len(l1_nodes),
|
||||
l2=total_l2,
|
||||
l3=total_l3,
|
||||
)
|
||||
return index
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:切分策略
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _segment_text(self, text: str) -> List[List[str]]:
|
||||
"""结构切分长文本为 L1/L2 层次。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
sections[i] = [paragraph_1, paragraph_2, ...]
|
||||
外层列表 = L1 段(章节),内层列表 = L2 单元(段落组内段落)。
|
||||
|
||||
策略:
|
||||
有 Markdown 标题 → 正则解析 #/## 边界
|
||||
无 Markdown 标题 → LLM 单次调用语义分段
|
||||
"""
|
||||
if self._detect_toc(text):
|
||||
log_msg("INFO", "检测到 Markdown 标题,使用正则切分")
|
||||
return self._segment_with_regex(text)
|
||||
else:
|
||||
log_msg("INFO", "未检测到 Markdown 标题,使用 LLM 语义分段")
|
||||
return self._segment_with_llm(text)
|
||||
|
||||
def _detect_toc(self, text: str) -> bool:
|
||||
"""检测文本是否包含 Markdown 标题(有 ToC 结构)。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
True 表示有 # 或 ## 开头的标题行,False 表示无。
|
||||
"""
|
||||
return bool(re.search(r"^#{1,2}\s+\S", text, re.MULTILINE))
|
||||
|
||||
def _segment_with_regex(self, text: str) -> List[List[str]]:
|
||||
"""通过正则解析 Markdown 标题边界进行结构切分。
|
||||
|
||||
参数:
|
||||
text: 含 Markdown 标题的文本。
|
||||
|
||||
返回:
|
||||
List[List[str]],外层=L1章节,内层=该章节下的段落列表。
|
||||
若二级标题下段落数超过 max_paragraphs_per_l2,则进一步等长分块。
|
||||
|
||||
实现细节:
|
||||
- # 标题 → L1 边界
|
||||
- ## 标题 → L2 边界
|
||||
- ### 及以下标题视为段落内容,收集到最近 L2 段落组
|
||||
- 空段落过滤掉
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
|
||||
sections: List[List[str]] = [] # 外层=L1
|
||||
current_section: List[str] = [] # 当前 L1 下的段落(扁平)
|
||||
current_para_lines: List[str] = [] # 积累段落文本行
|
||||
|
||||
def _flush_para() -> None:
|
||||
"""将当前积累的行合并为一个段落加入 current_section。"""
|
||||
para = "\n".join(current_para_lines).strip()
|
||||
if para:
|
||||
current_section.append(para)
|
||||
current_para_lines.clear()
|
||||
|
||||
def _flush_section() -> None:
|
||||
"""将当前 section 保存,重置。"""
|
||||
_flush_para()
|
||||
if current_section:
|
||||
sections.append(list(current_section))
|
||||
current_section.clear()
|
||||
|
||||
for line in lines:
|
||||
h1_match = re.match(r"^#\s+(.+)", line)
|
||||
h2_match = re.match(r"^##\s+(.+)", line)
|
||||
|
||||
if h1_match:
|
||||
# L1 边界:保存当前 section
|
||||
_flush_section()
|
||||
# 将 H1 标题本身作为第一段落(可选:也可忽略标题行)
|
||||
title = h1_match.group(1).strip()
|
||||
if title:
|
||||
current_section.append(title)
|
||||
elif h2_match:
|
||||
# L2 边界:只冲刷当前段落,不切换 section
|
||||
_flush_para()
|
||||
title = h2_match.group(1).strip()
|
||||
if title:
|
||||
current_section.append(title)
|
||||
else:
|
||||
# 普通内容行(含 ###、####、正文段落)
|
||||
if line.strip() == "":
|
||||
# 空行触发段落分隔
|
||||
_flush_para()
|
||||
else:
|
||||
current_para_lines.append(line)
|
||||
|
||||
_flush_section()
|
||||
|
||||
# 若没有产生任何 section(如文本只有一个 L1),保底处理
|
||||
if not sections:
|
||||
sections = [self._collect_paragraphs(text)]
|
||||
|
||||
# 对超出 max_paragraphs_per_l2 的段落组不做处理(由 build() 负责分块)
|
||||
return sections
|
||||
|
||||
def _segment_with_llm(self, text: str) -> List[List[str]]:
|
||||
"""通过 LLM 单次调用语义分段无结构文本。
|
||||
|
||||
参数:
|
||||
text: 无 Markdown 标题的纯文本。
|
||||
|
||||
返回:
|
||||
List[List[str]],只有一个外层元素(整篇视为单个 L1)。
|
||||
内层为 LLM 返回的语义段落列表。
|
||||
|
||||
异常:
|
||||
ValueError: LLM 返回的内容无法解析为 JSON 数组。
|
||||
"""
|
||||
prompt = _SEG_PROMPT.format(text=text)
|
||||
raw = self.llm.chat(prompt)
|
||||
|
||||
# 尝试从返回结果中提取 JSON 数组
|
||||
raw = raw.strip()
|
||||
# 提取可能被 markdown 代码块包裹的 JSON
|
||||
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
|
||||
if code_match:
|
||||
raw = code_match.group(1)
|
||||
|
||||
ensure(
|
||||
raw.startswith("["),
|
||||
f"LLM 语义分段返回格式错误,期望 JSON 数组,实际: {raw[:100]}",
|
||||
)
|
||||
|
||||
try:
|
||||
paragraphs: List[str] = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"LLM 语义分段 JSON 解析失败: {e}\n原始输出: {raw}") from e
|
||||
|
||||
ensure(isinstance(paragraphs, list), "LLM 返回值不是列表")
|
||||
ensure(len(paragraphs) > 0, "LLM 语义分段返回空列表")
|
||||
|
||||
# 过滤空段落
|
||||
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
||||
log_msg("INFO", "LLM 语义分段完成", paragraph_count=len(paragraphs))
|
||||
|
||||
return [paragraphs] # 整篇视为单个 L1
|
||||
|
||||
def _collect_paragraphs(self, text: str) -> List[str]:
|
||||
"""按双换行符切分段落(保底策略)。
|
||||
|
||||
参数:
|
||||
text: 输入文本。
|
||||
|
||||
返回:
|
||||
非空段落列表。
|
||||
"""
|
||||
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
||||
return paras if paras else [text.strip()]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:节点构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_l2(self, paragraphs: List[str], l2_id: str) -> L2Node:
|
||||
"""将段落组构建为 L2 节点(含 LLM 摘要和嵌入)。
|
||||
|
||||
参数:
|
||||
paragraphs: 该 L2 节点下的段落文本列表。
|
||||
l2_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
L2Node(children 为空,由调用方填充)。
|
||||
"""
|
||||
ensure(len(paragraphs) > 0, f"L2 节点 {l2_id} 的段落列表为空")
|
||||
prompt = _L2_PROMPT.format(text="\n\n".join(paragraphs))
|
||||
description = self.llm.chat(prompt)
|
||||
return L2Node(
|
||||
id=l2_id,
|
||||
description=description,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
)
|
||||
|
||||
def _build_l3_from_paragraphs(
|
||||
self,
|
||||
paragraphs: List[str],
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
) -> List[L3Node]:
|
||||
"""将段落列表批量构建为 L3 节点(原始文本直接复用,不调用 LLM)。
|
||||
|
||||
参数:
|
||||
paragraphs: 段落文本列表。
|
||||
l1_i: 父 L1 索引(用于生成 ID)。
|
||||
l2_j: 父 L2 索引(用于生成 ID)。
|
||||
|
||||
返回:
|
||||
L3Node 列表,description == raw_content == 原始段落文本。
|
||||
|
||||
实现细节:
|
||||
使用 embed.embed(paragraphs) 批量嵌入,一次调用获取全部向量。
|
||||
"""
|
||||
ensure(len(paragraphs) > 0, f"L3 段落列表为空 (l1={l1_i}, l2={l2_j})")
|
||||
nodes: List[L3Node] = []
|
||||
for k, para in enumerate(paragraphs):
|
||||
nodes.append(
|
||||
L3Node(
|
||||
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
|
||||
description=para,
|
||||
embedding=None,
|
||||
raw_content=para,
|
||||
frame_path=None,
|
||||
timestamp=None,
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
def _build_l1(self, l2_children: List[L2Node], l1_id: str) -> L1Node:
|
||||
"""聚合 L2 描述,构建 L1 节点(含 LLM 摘要和嵌入)。
|
||||
|
||||
参数:
|
||||
l2_children: 该 L1 节点下的所有 L2 节点。
|
||||
l1_id: 节点 ID。
|
||||
|
||||
返回:
|
||||
L1Node(children 已由调用方赋值,或在此赋值)。
|
||||
|
||||
实现细节:
|
||||
将所有 L2 描述拼接,用序号标注后送入 LLM 生成 2-3 句摘要。
|
||||
"""
|
||||
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
|
||||
l2_descriptions = "\n".join(
|
||||
f"{idx + 1}. {node.description}"
|
||||
for idx, node in enumerate(l2_children)
|
||||
)
|
||||
prompt = _L1_PROMPT.format(l2_descriptions=l2_descriptions)
|
||||
summary = self.llm.chat(prompt)
|
||||
return L1Node(
|
||||
id=l1_id,
|
||||
summary=summary,
|
||||
embedding=None,
|
||||
time_range=None,
|
||||
children=l2_children,
|
||||
)
|
||||
@@ -0,0 +1,642 @@
|
||||
"""
|
||||
三层树索引核心数据结构
|
||||
======================
|
||||
定义 Video-Tree-TRM 的三层树状索引结构,是所有后续模块
|
||||
(builder、retriever、losses、pipeline)的基础依赖。
|
||||
|
||||
数据结构层次::
|
||||
|
||||
TreeIndex
|
||||
└─ List[L1Node] 全局叙事节点
|
||||
└─ List[L2Node] 片段级语义节点
|
||||
└─ List[L3Node] 帧/细节级节点
|
||||
|
||||
与参考项目 (Tree-TRM/video_pyramid.py) 的关键区别:
|
||||
- 统一嵌入空间:所有 embedding 均来自 text_embed(),无跨模态问题
|
||||
- 序列化方式:pickle 整体序列化(而非 JSON + NPY 分文件存储)
|
||||
- L3 全文本化:无需 VisualProjectionLayer
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from utils.logger_system import ensure, log_msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Embedding 序列化辅助函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _embed_to_str(arr: Optional[np.ndarray]) -> Optional[str]:
|
||||
"""float32 ndarray → base64 字符串(用于 JSON 序列化)。
|
||||
|
||||
参数:
|
||||
arr: float32 数组,形状任意。
|
||||
|
||||
返回:
|
||||
base64 编码字符串,或 None(输入为 None 时)。
|
||||
"""
|
||||
if arr is None:
|
||||
return None
|
||||
return base64.b64encode(arr.astype(np.float32).tobytes()).decode()
|
||||
|
||||
|
||||
def _embed_from_str(s: Optional[str]) -> Optional[np.ndarray]:
|
||||
"""base64 字符串 → float32 ndarray(用于 JSON 反序列化)。
|
||||
|
||||
参数:
|
||||
s: base64 编码字符串。
|
||||
|
||||
返回:
|
||||
float32 数组,或 None(输入为 None/空时)。
|
||||
"""
|
||||
if s is None or s == "":
|
||||
return None
|
||||
return np.frombuffer(base64.b64decode(s), dtype=np.float32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 元数据
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class IndexMeta:
|
||||
"""树索引元数据。
|
||||
|
||||
Attributes:
|
||||
source_path: 原始数据路径(视频文件或文本文件)。
|
||||
modality: 数据模态,"text" 或 "video"。
|
||||
embed_model: 嵌入模型名称(建树时为 None,embed_all 后填充)。
|
||||
embed_dim: 嵌入向量维度(建树时为 None,embed_all 后填充)。
|
||||
created_at: 创建时间(ISO 格式字符串)。
|
||||
"""
|
||||
|
||||
source_path: str
|
||||
modality: str
|
||||
embed_model: Optional[str] = None
|
||||
embed_dim: Optional[int] = None
|
||||
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 节点数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class L3Node:
|
||||
"""L3 帧/细节级节点(叶子层)。
|
||||
|
||||
代表最细粒度的语义单元,对应一个具体的描述片段。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
description: 文本描述。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
raw_content: 原始文本内容(可选)。
|
||||
frame_path: 关联的帧图像路径(可选,仅视频模态)。
|
||||
timestamp: 对应的时间戳(秒,可选)。
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
raw_content: Optional[str] = None
|
||||
frame_path: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class L2Node:
|
||||
"""L2 片段级语义节点(中间层)。
|
||||
|
||||
连接 L1 宏观叙事与 L3 细节描述。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
description: 片段文本描述。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
time_range: 时间范围 (start, end)(秒,可选)。
|
||||
children: 所属的 L3 子节点列表。
|
||||
"""
|
||||
|
||||
id: str
|
||||
description: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
time_range: Optional[Tuple[float, float]] = None
|
||||
children: List[L3Node] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class L1Node:
|
||||
"""L1 全局叙事节点(根层)。
|
||||
|
||||
代表最粗粒度的语义单元,包含宏观事件摘要。
|
||||
|
||||
Attributes:
|
||||
id: 节点唯一标识。
|
||||
summary: 高层叙事摘要。
|
||||
embedding: 文本嵌入向量,形状 [D],float32。
|
||||
time_range: 时间范围 (start, end)(秒,可选)。
|
||||
children: 所属的 L2 子节点列表。
|
||||
"""
|
||||
|
||||
id: str
|
||||
summary: str
|
||||
embedding: Optional[np.ndarray] = None # [D],build 时为 None,embed_all 后填充
|
||||
time_range: Optional[Tuple[float, float]] = None
|
||||
children: List[L2Node] = field(default_factory=list)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JSON 辅助方法(单个 L1 段的轻量序列化)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
|
||||
"""将当前 L1 节点(及其全部 L2/L3 子树)序列化为纯 dict。
|
||||
|
||||
参数:
|
||||
include_embedding: 若 True,将 embedding 向量序列化为 base64 字符串。
|
||||
|
||||
返回:
|
||||
包含 id/summary/time_range/children 的字典,可选包含 embedding。
|
||||
"""
|
||||
|
||||
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"timestamp": n.timestamp,
|
||||
"frame_path": n.frame_path,
|
||||
"raw_content": n.raw_content,
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"time_range": list(n.time_range) if n.time_range else None,
|
||||
"children": [l3_to_dict(c) for c in n.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
d = {
|
||||
"id": self.id,
|
||||
"summary": self.summary,
|
||||
"time_range": list(self.time_range) if self.time_range else None,
|
||||
"children": [l2_to_dict(c) for c in self.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(self.embedding)
|
||||
return d
|
||||
|
||||
@staticmethod
|
||||
def from_dict(d: Dict[str, Any]) -> "L1Node":
|
||||
"""从 dict 反序列化单个 L1 节点(支持 embedding 恢复)。
|
||||
|
||||
参数:
|
||||
d: to_dict() 输出的字典,可包含 embedding 字段。
|
||||
|
||||
返回:
|
||||
L1Node 实例(embedding 自动从 base64 恢复,若无则为 None)。
|
||||
"""
|
||||
l2_nodes: List[L2Node] = []
|
||||
for l2d in d.get("children", []):
|
||||
l3_nodes: List[L3Node] = []
|
||||
for l3d in l2d.get("children", []):
|
||||
l3_nodes.append(
|
||||
L3Node(
|
||||
id=l3d["id"],
|
||||
description=l3d["description"],
|
||||
embedding=_embed_from_str(l3d.get("embedding")),
|
||||
timestamp=l3d.get("timestamp"),
|
||||
frame_path=l3d.get("frame_path"),
|
||||
raw_content=l3d.get("raw_content"),
|
||||
)
|
||||
)
|
||||
tr2 = l2d.get("time_range")
|
||||
l2_nodes.append(
|
||||
L2Node(
|
||||
id=l2d["id"],
|
||||
description=l2d["description"],
|
||||
embedding=_embed_from_str(l2d.get("embedding")),
|
||||
time_range=tuple(tr2) if tr2 else None,
|
||||
children=l3_nodes,
|
||||
)
|
||||
)
|
||||
tr1 = d.get("time_range")
|
||||
return L1Node(
|
||||
id=d["id"],
|
||||
summary=d["summary"],
|
||||
embedding=_embed_from_str(d.get("embedding")),
|
||||
time_range=tuple(tr1) if tr1 else None,
|
||||
children=l2_nodes,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 树索引容器
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeIndex:
|
||||
"""三层树索引容器。
|
||||
|
||||
组织和管理三层节点结构,提供嵌入矩阵提取、节点访问、
|
||||
以及 pickle 序列化/反序列化接口。
|
||||
|
||||
典型工作流::
|
||||
|
||||
# 1. 构建索引
|
||||
index = TreeIndex(metadata=meta, roots=[l1_node_1, l1_node_2])
|
||||
|
||||
# 2. 提取嵌入矩阵(用于 Tree-TRM 检索)
|
||||
M_L1 = index.l1_embeddings() # [N1, D]
|
||||
M_L2 = index.l2_embeddings_of(l1_idx=0) # [N2, D]
|
||||
M_L3 = index.l3_embeddings_of(0, 1) # [N3, D]
|
||||
|
||||
# 3. 序列化
|
||||
index.save("cache/my_index.pkl")
|
||||
loaded = TreeIndex.load("cache/my_index.pkl")
|
||||
|
||||
Attributes:
|
||||
metadata: 索引元数据。
|
||||
roots: L1 节点列表。
|
||||
"""
|
||||
|
||||
metadata: IndexMeta
|
||||
roots: List[L1Node] = field(default_factory=list)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 嵌入矩阵提取
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 懒加载嵌入支持
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def is_embedded(self) -> bool:
|
||||
"""检查所有节点是否已填充嵌入向量。
|
||||
|
||||
返回:
|
||||
True 表示所有 L1/L2/L3 节点的 embedding 均非 None;False 表示尚未 embed。
|
||||
"""
|
||||
for l1 in self.roots:
|
||||
if l1.embedding is None:
|
||||
return False
|
||||
for l2 in l1.children:
|
||||
if l2.embedding is None:
|
||||
return False
|
||||
for l3 in l2.children:
|
||||
if l3.embedding is None:
|
||||
return False
|
||||
return True
|
||||
|
||||
def embed_all(
|
||||
self,
|
||||
embed_fn: Callable[[Union[str, List[str]]], np.ndarray],
|
||||
model_name: str,
|
||||
embed_dim: int,
|
||||
) -> None:
|
||||
"""对所有节点批量执行 embedding,更新 metadata。
|
||||
|
||||
建树阶段不调用此方法(embedding=None)。
|
||||
首次检索前由 Pipeline 调用,结果缓存在节点上。
|
||||
|
||||
参数:
|
||||
embed_fn: EmbeddingModel.embed 方法,接受 str 或 List[str],返回 [N, D] ndarray。
|
||||
model_name: 嵌入模型名称,写入 metadata。
|
||||
embed_dim: 嵌入维度,写入 metadata。
|
||||
|
||||
实现细节:
|
||||
- L3 节点按 L2 分组批量 embed(一次调用),减少 API 开销。
|
||||
- L1/L2 各单独 embed(数量少,不值得合并)。
|
||||
- 仅对 embedding 为 None 的节点执行(支持增量更新)。
|
||||
"""
|
||||
ensure(len(self.roots) > 0, "embed_all: 树为空,无节点可 embed")
|
||||
for l1 in self.roots:
|
||||
if l1.embedding is None:
|
||||
l1.embedding = embed_fn(l1.summary)[0].astype(np.float32)
|
||||
for l2 in l1.children:
|
||||
if l2.embedding is None:
|
||||
l2.embedding = embed_fn(l2.description)[0].astype(np.float32)
|
||||
# L3 批量 embed
|
||||
need_embed = [l3 for l3 in l2.children if l3.embedding is None]
|
||||
if need_embed:
|
||||
texts = [l3.description for l3 in need_embed]
|
||||
embs = embed_fn(texts).astype(np.float32) # [N, D]
|
||||
for l3, emb in zip(need_embed, embs):
|
||||
l3.embedding = emb
|
||||
self.metadata.embed_model = model_name
|
||||
self.metadata.embed_dim = embed_dim
|
||||
log_msg("INFO", "embed_all 完成", model=model_name, embed_dim=embed_dim)
|
||||
|
||||
def l1_embeddings(self) -> np.ndarray:
|
||||
"""返回所有 L1 节点的嵌入矩阵。
|
||||
|
||||
返回:
|
||||
形状 [N1, D] 的 float32 矩阵。空树返回 [0, D]。
|
||||
|
||||
异常:
|
||||
RuntimeError: 节点 embedding 尚未计算(请先调用 embed_all)。
|
||||
"""
|
||||
ensure(self.is_embedded, "L1 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not self.roots:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([r.embedding for r in self.roots], axis=0).astype(np.float32)
|
||||
|
||||
def l2_embeddings_of(self, l1_idx: int) -> np.ndarray:
|
||||
"""返回指定 L1 节点下所有 L2 子节点的嵌入矩阵。
|
||||
|
||||
参数:
|
||||
l1_idx: L1 节点索引。
|
||||
|
||||
返回:
|
||||
形状 [N2, D] 的 float32 矩阵。
|
||||
|
||||
异常:
|
||||
IndexError: l1_idx 越界。
|
||||
RuntimeError: embedding 尚未计算。
|
||||
"""
|
||||
ensure(self.is_embedded, "L2 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not (0 <= l1_idx < len(self.roots)):
|
||||
raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}")
|
||||
children = self.roots[l1_idx].children
|
||||
if not children:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([c.embedding for c in children], axis=0).astype(np.float32)
|
||||
|
||||
def l3_embeddings_of(self, l1_idx: int, l2_idx: int) -> np.ndarray:
|
||||
"""返回指定 L2 节点下所有 L3 子节点的嵌入矩阵。
|
||||
|
||||
参数:
|
||||
l1_idx: L1 节点索引。
|
||||
l2_idx: L2 节点索引(相对于 L1)。
|
||||
|
||||
返回:
|
||||
形状 [N3, D] 的 float32 矩阵。
|
||||
|
||||
异常:
|
||||
IndexError: 索引越界。
|
||||
RuntimeError: embedding 尚未计算。
|
||||
"""
|
||||
ensure(self.is_embedded, "L3 embedding 尚未计算,请先调用 tree.embed_all()")
|
||||
if not (0 <= l1_idx < len(self.roots)):
|
||||
raise IndexError(f"l1_idx={l1_idx} 越界,L1 节点数={len(self.roots)}")
|
||||
l2_children = self.roots[l1_idx].children
|
||||
if not (0 <= l2_idx < len(l2_children)):
|
||||
raise IndexError(f"l2_idx={l2_idx} 越界,L2 节点数={len(l2_children)}")
|
||||
l3_children = l2_children[l2_idx].children
|
||||
if not l3_children:
|
||||
return np.zeros((0, self.metadata.embed_dim), dtype=np.float32)
|
||||
return np.stack([c.embedding for c in l3_children], axis=0).astype(np.float32)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 节点访问
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def get_node(self, l1: int, l2: int, l3: int) -> L3Node:
|
||||
"""按三级路径索引获取 L3 节点。
|
||||
|
||||
参数:
|
||||
l1: L1 节点索引。
|
||||
l2: L2 节点索引。
|
||||
l3: L3 节点索引。
|
||||
|
||||
返回:
|
||||
目标 L3Node。
|
||||
|
||||
异常:
|
||||
IndexError: 任意层级索引越界。
|
||||
"""
|
||||
if l1 < 0 or l1 >= len(self.roots):
|
||||
raise IndexError(f"l1={l1} 越界,L1 节点数={len(self.roots)}")
|
||||
l2_children = self.roots[l1].children
|
||||
if l2 < 0 or l2 >= len(l2_children):
|
||||
raise IndexError(f"l2={l2} 越界,L2 节点数={len(l2_children)}")
|
||||
l3_children = l2_children[l2].children
|
||||
if l3 < 0 or l3 >= len(l3_children):
|
||||
raise IndexError(f"l3={l3} 越界,L3 节点数={len(l3_children)}")
|
||||
return l3_children[l3]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# JSON 序列化(主格式,无 embedding)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def to_dict(self, include_embedding: bool = False) -> Dict[str, Any]:
|
||||
"""将树索引序列化为纯 Python dict。
|
||||
|
||||
参数:
|
||||
include_embedding: 若 True,将所有节点的 embedding 向量序列化为 base64。
|
||||
|
||||
返回:
|
||||
可直接 json.dump 的字典,结构为 {metadata, roots[...]}。
|
||||
当 include_embedding=True 时,每个节点包含 embedding 字段。
|
||||
"""
|
||||
|
||||
def l3_to_dict(n: L3Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"timestamp": n.timestamp,
|
||||
"frame_path": n.frame_path,
|
||||
"raw_content": n.raw_content,
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
def l2_to_dict(n: L2Node) -> Dict[str, Any]:
|
||||
d = {
|
||||
"id": n.id,
|
||||
"description": n.description,
|
||||
"time_range": list(n.time_range) if n.time_range else None,
|
||||
"children": [l3_to_dict(c) for c in n.children],
|
||||
}
|
||||
if include_embedding:
|
||||
d["embedding"] = _embed_to_str(n.embedding)
|
||||
return d
|
||||
|
||||
metadata_dict = {
|
||||
"source_path": self.metadata.source_path,
|
||||
"modality": self.metadata.modality,
|
||||
"created_at": self.metadata.created_at,
|
||||
}
|
||||
if include_embedding:
|
||||
metadata_dict["embed_model"] = self.metadata.embed_model
|
||||
metadata_dict["embed_dim"] = self.metadata.embed_dim
|
||||
|
||||
return {
|
||||
"metadata": metadata_dict,
|
||||
"roots": [r.to_dict(include_embedding=include_embedding) for r in self.roots],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict[str, Any]) -> "TreeIndex":
|
||||
"""从 dict 反序列化为 TreeIndex(支持 embedding 恢复)。
|
||||
|
||||
参数:
|
||||
d: to_dict() 的输出或等价结构,可包含 embedding 字段。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例(若 JSON 中有 embedding 字段,自动反序列化填充)。
|
||||
"""
|
||||
meta = IndexMeta(
|
||||
source_path=d["metadata"]["source_path"],
|
||||
modality=d["metadata"]["modality"],
|
||||
embed_model=d["metadata"].get("embed_model"),
|
||||
embed_dim=d["metadata"].get("embed_dim"),
|
||||
created_at=d["metadata"].get("created_at", datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
roots: List[L1Node] = []
|
||||
for r in d["roots"]:
|
||||
l2_nodes: List[L2Node] = []
|
||||
for l2d in r.get("children", []):
|
||||
l3_nodes: List[L3Node] = []
|
||||
for l3d in l2d.get("children", []):
|
||||
l3_nodes.append(L3Node(
|
||||
id=l3d["id"],
|
||||
description=l3d["description"],
|
||||
embedding=_embed_from_str(l3d.get("embedding")),
|
||||
timestamp=l3d.get("timestamp"),
|
||||
frame_path=l3d.get("frame_path"),
|
||||
raw_content=l3d.get("raw_content"),
|
||||
))
|
||||
tr2 = l2d.get("time_range")
|
||||
l2_nodes.append(L2Node(
|
||||
id=l2d["id"],
|
||||
description=l2d["description"],
|
||||
embedding=_embed_from_str(l2d.get("embedding")),
|
||||
time_range=tuple(tr2) if tr2 else None,
|
||||
children=l3_nodes,
|
||||
))
|
||||
tr1 = r.get("time_range")
|
||||
roots.append(L1Node(
|
||||
id=r["id"],
|
||||
summary=r["summary"],
|
||||
embedding=_embed_from_str(r.get("embedding")),
|
||||
time_range=tuple(tr1) if tr1 else None,
|
||||
children=l2_nodes,
|
||||
))
|
||||
|
||||
return cls(metadata=meta, roots=roots)
|
||||
|
||||
def save_json(self, path: str, include_embedding: bool = False) -> None:
|
||||
"""将树索引以 JSON 格式保存到磁盘。
|
||||
|
||||
参数:
|
||||
path: 保存文件路径(推荐 .json 后缀)。
|
||||
include_embedding: 若 True,将所有节点的 embedding 向量保存到 JSON。
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(include_embedding=include_embedding), f, ensure_ascii=False, indent=2)
|
||||
log_msg(
|
||||
"INFO",
|
||||
f"树索引(JSON)已保存至 {path}",
|
||||
n_l1=len(self.roots),
|
||||
include_embedding=include_embedding,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_json(cls, path: str) -> "TreeIndex":
|
||||
"""从 JSON 文件加载树索引(自动检测并恢复 embedding)。
|
||||
|
||||
参数:
|
||||
path: JSON 文件路径。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例。若 JSON 中包含 embedding 字段,自动反序列化填充;
|
||||
否则 embedding=None(向后兼容旧格式)。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 文件不存在。
|
||||
"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
obj = cls.from_dict(d)
|
||||
log_msg(
|
||||
"INFO",
|
||||
f"树索引(JSON)已从 {path} 加载",
|
||||
n_l1=len(obj.roots),
|
||||
is_embedded=obj.is_embedded,
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def save_l1_json(path: str, l1_node: L1Node) -> None:
|
||||
"""将单个 L1 节点(及其子树)以 JSON 形式保存到磁盘。
|
||||
|
||||
参数:
|
||||
path: 目标文件路径。
|
||||
l1_node: 待序列化的 L1 节点。
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(l1_node.to_dict(), f, ensure_ascii=False, indent=2)
|
||||
log_msg("INFO", "L1 中间结果已保存", path=path, l1_id=l1_node.id)
|
||||
|
||||
|
||||
def load_l1_json(path: str) -> L1Node:
|
||||
"""从 JSON 文件加载单个 L1 节点(embedding=None)。"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
node = L1Node.from_dict(data)
|
||||
log_msg("INFO", "L1 中间结果已加载", path=path, l1_id=node.id)
|
||||
return node
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 序列化(pickle,保留供向后兼容)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
"""将整个树索引序列化到磁盘(pickle 格式)。
|
||||
|
||||
参数:
|
||||
path: 保存文件路径(推荐 .pkl 后缀)。
|
||||
"""
|
||||
with open(path, "wb") as f:
|
||||
pickle.dump(self, f, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
log_msg("INFO", f"树索引已保存至 {path}", n_l1=len(self.roots))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str) -> "TreeIndex":
|
||||
"""从磁盘加载树索引。
|
||||
|
||||
.. warning::
|
||||
pickle 反序列化可执行任意代码,切勿加载不受信任的文件。
|
||||
如需安全替代方案,请考虑 JSON + NPY 分文件存储。
|
||||
|
||||
参数:
|
||||
path: pickle 文件路径。
|
||||
|
||||
返回:
|
||||
TreeIndex 实例。
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 文件不存在。
|
||||
TypeError: 文件内容不是 TreeIndex 实例。
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
obj = pickle.load(f) # noqa: S301
|
||||
if not isinstance(obj, cls):
|
||||
msg = f"文件内容不是 TreeIndex 实例: {type(obj)}"
|
||||
log_msg("ERROR", msg, path=path)
|
||||
raise TypeError(msg)
|
||||
log_msg("INFO", f"树索引已从 {path} 加载", n_l1=len(obj.roots))
|
||||
return obj
|
||||
@@ -0,0 +1,993 @@
|
||||
"""
|
||||
视频树构建模块
|
||||
==============
|
||||
将长视频通过 L2 轴心策略 + VLM 帧描述转化为三层 TreeIndex。
|
||||
|
||||
构建策略::
|
||||
|
||||
Step 1: _segment_video — 固定步长切分,确定 L1 时间边界
|
||||
Step 2: L2 先行 — 每个 L2 clip 均匀 seek l2_representative_frames 帧(稀疏),VLM 生成片段描述(1-2句)
|
||||
Step 3: L3 向下 — 注入 L2 上下文,VLM 批量帧描述(每帧1-2句)
|
||||
Step 4: L1 向上 — 聚合 L2 描述,LLM 生成 L1 摘要(2-3句)
|
||||
Step 5: 组装 TreeIndex
|
||||
|
||||
并发模型(异步版)::
|
||||
|
||||
build() → asyncio.run(_build_async())
|
||||
_build_async():
|
||||
asyncio.Semaphore(concurrency=16) 控制最大 VLM 并发数
|
||||
Phase 1: asyncio.gather(所有L2任务) — 16路同时 VLM
|
||||
Phase 2: asyncio.gather(所有L3任务) — 每个L3任务内的12批次同时并发
|
||||
Phase 3: asyncio.gather(各L1摘要) — L1收齐后并发
|
||||
ffmpeg 提帧在 ThreadPoolExecutor(max_workers=8) 中并行执行
|
||||
|
||||
L2 轴心策略解决了循环依赖:
|
||||
- L2 描述不依赖 L3,从代表帧直接生成
|
||||
- L3 注入 L2 上下文后逐帧描述
|
||||
- L1 聚合 L2 描述,保证完整覆盖
|
||||
|
||||
帧持久化:
|
||||
- 帧图像保存到 {cache_dir}/frames/{video_stem}/,长期有效
|
||||
- 已提取的帧自动跳过(缓存复用)
|
||||
|
||||
使用方式::
|
||||
|
||||
builder = VideoTreeBuilder(vlm_client, config.tree)
|
||||
index = builder.build("path/to/video.mp4") # 同步壳,内部 asyncio.run()
|
||||
index.save("cache/my_video.pkl")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import cv2
|
||||
|
||||
from utils.logger_system import ensure, log_json, log_msg
|
||||
from video_tree_trm.config import TreeConfig
|
||||
from video_tree_trm.llm_client import LLMClient
|
||||
from video_tree_trm.tree_index import (
|
||||
IndexMeta,
|
||||
L1Node,
|
||||
L2Node,
|
||||
L3Node,
|
||||
TreeIndex,
|
||||
load_l1_json,
|
||||
save_l1_json,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt 常量
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_L2_VIDEO_PROMPT = "用1-2句话描述以下视频片段的核心内容,与同级片段形成区分。"
|
||||
|
||||
_L3_VIDEO_PROMPT = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"以下是该片段中连续的 {n} 帧画面。\n"
|
||||
"对每帧用一到两句话描述其具体画面内容。\n"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。\n"
|
||||
"不要重复片段整体描述,聚焦每帧的区分性信息。\n"
|
||||
'只返回 JSON 数组,格式: ["帧1描述", "帧2描述", ...],不要其他内容。'
|
||||
)
|
||||
|
||||
_L1_VIDEO_PROMPT = (
|
||||
"以下是一个视频段落中各片段的描述:\n{l2_texts}\n"
|
||||
"用2-3句话总结该段落的整体内容,涵盖所有片段的主题。"
|
||||
)
|
||||
|
||||
# 每次 VLM 调用携带的最大帧数:5 帧 payload 小、JSON 解析成功率高
|
||||
_L3_BATCH_SIZE = 5
|
||||
|
||||
_L3_SINGLE_PROMPT = (
|
||||
'该片段的整体内容: "{l2_description}"\n'
|
||||
"用一到两句话描述这帧画面的具体内容。"
|
||||
"重点关注: 动作、物体变化、文字信息、人物表情。"
|
||||
)
|
||||
|
||||
# ffmpeg 并发提帧的线程池大小(CPU 密集型,避免过度并发)
|
||||
_FFMPEG_MAX_WORKERS = 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主类
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class VideoTreeBuilder:
|
||||
"""视频模态树构建器(asyncio 真并发版)。
|
||||
|
||||
将长视频通过 L2 轴心策略(先构建 L2,再向下扩展 L3,向上聚合 L1)
|
||||
转化为三层 TreeIndex。
|
||||
|
||||
并发架构:
|
||||
build() 为同步壳,内部调用 asyncio.run(_build_async())。
|
||||
_build_async() 使用 asyncio.Semaphore(concurrency) 控制并发 VLM 数量。
|
||||
所有 VLM 调用通过 LLMClient 的异步接口(AsyncOpenAI)发起,零线程阻塞。
|
||||
ffmpeg 提帧在独立 ThreadPoolExecutor 中并行,不阻塞事件循环。
|
||||
|
||||
属性:
|
||||
vlm: VLM/LLM 客户端(用于图文和纯文本调用)。
|
||||
config: 树构建配置。
|
||||
_ffmpeg_pool: ffmpeg 专用线程池(max_workers=_FFMPEG_MAX_WORKERS)。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vlm: LLMClient,
|
||||
config: TreeConfig,
|
||||
) -> None:
|
||||
"""初始化视频树构建器。
|
||||
|
||||
参数:
|
||||
vlm: 已初始化的 VLM/LLM 客户端(LLMClient),需支持异步接口。
|
||||
config: 树构建配置(TreeConfig),关键字段:
|
||||
l1_segment_duration, l2_clip_duration, l3_fps,
|
||||
l2_representative_frames, cache_dir, concurrency。
|
||||
|
||||
实现细节:
|
||||
ffmpeg 线程池在构建器级别创建,所有异步协程共用,
|
||||
避免每次提帧都重建线程池的开销。
|
||||
"""
|
||||
self.vlm = vlm
|
||||
self.config = config
|
||||
self._ffmpeg_pool = ThreadPoolExecutor(max_workers=_FFMPEG_MAX_WORKERS)
|
||||
# 进度与中间结果目录均挂在 cache_dir 下,避免散落其它位置
|
||||
self._cache_root = Path(self.config.cache_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# URL 流式辅助方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _is_url(path_or_url: str) -> bool:
|
||||
"""判断输入是否为网络 URL(而非本地路径)。
|
||||
|
||||
参数:
|
||||
path_or_url: 文件路径或 URL 字符串。
|
||||
|
||||
返回:
|
||||
True 表示 URL,False 表示本地路径。
|
||||
"""
|
||||
return path_or_url.startswith(("http://", "https://"))
|
||||
|
||||
@staticmethod
|
||||
def _source_stem(video_path: str) -> str:
|
||||
"""从视频路径或 YouTube URL 中提取短标识符,用于帧缓存目录命名。
|
||||
|
||||
参数:
|
||||
video_path: 本地文件路径或 YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
短字符串标识符(本地文件取 stem,YouTube URL 取 v= 后的视频 ID)。
|
||||
"""
|
||||
if "youtube.com/watch" in video_path or "youtu.be/" in video_path:
|
||||
match = re.search(r"(?:v=|youtu\.be/)([A-Za-z0-9_-]{8,15})", video_path)
|
||||
if match:
|
||||
return match.group(1)
|
||||
stem = Path(video_path).stem
|
||||
return stem[:64] if len(stem) > 64 else stem
|
||||
|
||||
@staticmethod
|
||||
def _resolve_stream(url: str) -> str:
|
||||
"""通过 yt-dlp 获取 YouTube 视频的 CDN 直链,供 cv2.VideoCapture 直接使用。
|
||||
|
||||
参数:
|
||||
url: YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
CDN HTTPS 直链(ffmpeg/OpenCV 可直接流式读取)。
|
||||
"""
|
||||
log_msg("INFO", "获取 YouTube CDN 直链", url=url)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"yt-dlp",
|
||||
"-g",
|
||||
"--format",
|
||||
"best[ext=mp4][height<=720]/best[ext=mp4]/best",
|
||||
url,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
ensure(result.returncode == 0, f"yt-dlp 获取直链失败: {result.stderr.strip()}")
|
||||
stream_url = result.stdout.strip().splitlines()[0]
|
||||
ensure(stream_url.startswith("http"), f"yt-dlp 返回非 URL: {stream_url[:100]}")
|
||||
log_msg("INFO", "CDN 直链获取成功", stream_url=stream_url[:80])
|
||||
return stream_url
|
||||
|
||||
@staticmethod
|
||||
def _get_video_duration(url: str) -> float:
|
||||
"""通过 yt-dlp --dump-json 获取视频时长(秒)。
|
||||
|
||||
参数:
|
||||
url: YouTube 视频页面 URL。
|
||||
|
||||
返回:
|
||||
视频总时长(秒,浮点数)。
|
||||
"""
|
||||
log_msg("INFO", "获取视频时长元数据", url=url)
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--dump-json", "--no-playlist", url],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
ensure(result.returncode == 0, f"yt-dlp 元数据获取失败: {result.stderr.strip()}")
|
||||
meta = json.loads(result.stdout)
|
||||
duration = float(meta.get("duration", 0))
|
||||
ensure(duration > 0, f"视频时长读取异常: {duration}")
|
||||
log_msg("INFO", "视频时长确认", duration_sec=round(duration, 1))
|
||||
return duration
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 公共接口
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build(self, video_path: str) -> TreeIndex:
|
||||
"""将长视频构建为三层 TreeIndex(同步壳,内部 asyncio.run 驱动)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径(.mp4/.avi/.mkv 等)或 YouTube URL。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
同步壳设计保持与 build_trees_batch.py 的接口兼容性。
|
||||
每次调用 asyncio.run() 创建独立事件循环,多线程安全(各线程独立循环)。
|
||||
"""
|
||||
return asyncio.run(self._build_async(video_path))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 核心异步构建逻辑
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _build_async(self, video_path: str) -> TreeIndex:
|
||||
"""异步构建三层 TreeIndex(真并发核心,L2→L3 链式触发)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 YouTube URL。
|
||||
|
||||
返回:
|
||||
三层 TreeIndex 对象。
|
||||
|
||||
实现细节:
|
||||
并发架构:每个 L1 段内启动一组"L2→L3 链式协程",
|
||||
L2 完成后立即触发 L3(不等待其他 L2),L3 完成后触发 L1 摘要。
|
||||
各 L1 段独立并发,彼此不阻塞。
|
||||
Semaphore(concurrency=16) 全局限制同时在途 VLM 调用数量。
|
||||
|
||||
关键调用链(每个 L2 clip 独立)::
|
||||
_build_segment(i) → asyncio.gather(
|
||||
_chain(i,0): _build_l2_video_async → _build_l3_task_async
|
||||
_chain(i,1): _build_l2_video_async → _build_l3_task_async
|
||||
...
|
||||
) → _build_l1_video_async(i)
|
||||
"""
|
||||
# Phase 0: URL vs 本地文件处理
|
||||
if self._is_url(video_path):
|
||||
stream_url = self._resolve_stream(video_path)
|
||||
duration_hint: Optional[float] = self._get_video_duration(video_path)
|
||||
log_msg("INFO", "开始构建视频树索引(URL 流式模式)", source_url=video_path)
|
||||
else:
|
||||
ensure(os.path.isfile(video_path), f"视频文件不存在: {video_path}")
|
||||
stream_url = video_path
|
||||
duration_hint = None
|
||||
log_msg("INFO", "开始构建视频树索引", video_path=video_path)
|
||||
|
||||
source_id = self._source_stem(video_path)
|
||||
|
||||
# Phase 1: 时间切分(同步,仅一次)
|
||||
l1_ranges = self._segment_video(stream_url, duration_hint=duration_hint)
|
||||
ensure(len(l1_ranges) > 0, "视频时间切分结果为空")
|
||||
log_msg("INFO", "视频切分完成", l1_count=len(l1_ranges))
|
||||
|
||||
total_l1 = len(l1_ranges)
|
||||
|
||||
# Phase 1.1: 读取已有进度(支持断点续跑)
|
||||
finished_l1_ids: set[int] = set()
|
||||
progress = self._load_progress(source_id)
|
||||
if progress is not None and progress.get("total_l1") == total_l1:
|
||||
finished_l1_ids = set(progress.get("finished_l1_ids", []))
|
||||
if finished_l1_ids:
|
||||
log_msg(
|
||||
"INFO",
|
||||
"检测到中间进度,启用断点续跑",
|
||||
stem=source_id,
|
||||
finished_l1=list(sorted(finished_l1_ids)),
|
||||
)
|
||||
else:
|
||||
# 进度不存在或形状不匹配时,从零开始,旧进度视为无效
|
||||
if progress is not None:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
"进度文件与当前 L1 段数不一致,忽略旧进度",
|
||||
stem=source_id,
|
||||
recorded_total_l1=progress.get("total_l1"),
|
||||
current_total_l1=total_l1,
|
||||
)
|
||||
|
||||
# 创建 VLM 并发控制信号量(每视频独立,限制同时在途 VLM 请求数)
|
||||
vlm_sem = asyncio.Semaphore(self.config.concurrency)
|
||||
|
||||
# Phase 2-5: 按 L1 段并发,段内 L2→L3 链式触发,L3 收齐后触发 L1
|
||||
async def _build_segment(i: int, l1_range: Tuple[float, float]) -> L1Node:
|
||||
"""单个 L1 段的完整构建:L2+L3 并发链式 → L1 摘要。
|
||||
|
||||
参数:
|
||||
i: L1 段索引。
|
||||
l1_range: L1 时间区间 (start, end)。
|
||||
|
||||
返回:
|
||||
完整的 L1Node(含所有 L2 和 L3 子节点)。
|
||||
|
||||
实现细节:
|
||||
段内所有 L2 clip 同时启动(asyncio.gather),
|
||||
每个 clip 的 L2 VLM 完成后立即触发 L3,不等待其他 clip 的 L2。
|
||||
所有 clip 的 L2+L3 完成后,触发 L1 文本摘要。
|
||||
"""
|
||||
clips = self._get_l2_clips(l1_range)
|
||||
|
||||
async def _chain(j: int, clip_range: Tuple[float, float]) -> Tuple[int, L2Node]:
|
||||
"""L2→L3 链:L2 完成立即触发 L3,返回 (j, 含children的L2Node)。"""
|
||||
l2_id = f"l1_{i}_l2_{j}"
|
||||
l2_node = await self._build_l2_video_async(
|
||||
stream_url, clip_range, l2_id, source_id, vlm_sem
|
||||
)
|
||||
log_msg("INFO", "L2 VLM 完成,已触发 L3 任务", l2_id=l2_id)
|
||||
|
||||
completed_l2 = await self._build_l3_task_async(
|
||||
stream_url, l2_node, clip_range, source_id, i, j, vlm_sem
|
||||
)
|
||||
log_msg(
|
||||
"INFO", "L3 完成",
|
||||
l2_id=l2_id,
|
||||
l3_count=len(completed_l2.children),
|
||||
)
|
||||
return (j, completed_l2)
|
||||
|
||||
# 所有 clip 同时启动(不等 L2 全部结束再开 L3)
|
||||
pairs = await asyncio.gather(*[_chain(j, clip) for j, clip in enumerate(clips)])
|
||||
ordered_l2 = [p[1] for p in sorted(pairs, key=lambda x: x[0])]
|
||||
|
||||
log_msg("INFO", "L1 触发", l1_id=f"l1_{i}")
|
||||
l1_node = await self._build_l1_video_async(
|
||||
ordered_l2, f"l1_{i}", l1_range, vlm_sem
|
||||
)
|
||||
log_msg(
|
||||
"INFO", "L1 节点构建完成",
|
||||
l1_id=f"l1_{i}",
|
||||
l2_count=len(ordered_l2),
|
||||
)
|
||||
return l1_node
|
||||
|
||||
total_clips = sum(len(self._get_l2_clips(r)) for r in l1_ranges)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"开始并发构建(L2→L3链式,L1段间并发,支持断点续跑)",
|
||||
total_l2=total_clips,
|
||||
concurrency=self.config.concurrency,
|
||||
)
|
||||
|
||||
# Phase 2: 并发构建尚未完成的 L1 段(段内 L2+L3 链式并发)
|
||||
tasks: List[asyncio.Task[L1Node]] = []
|
||||
task_indices: List[int] = []
|
||||
for i, r in enumerate(l1_ranges):
|
||||
# 已完成且中间 JSON 存在 → 直接复用
|
||||
if i in finished_l1_ids and self._has_l1_intermediate(source_id, i):
|
||||
continue
|
||||
tasks.append(asyncio.create_task(_build_segment(i, r)))
|
||||
task_indices.append(i)
|
||||
|
||||
new_l1_nodes: Dict[int, L1Node] = {}
|
||||
if tasks:
|
||||
results = await asyncio.gather(*tasks)
|
||||
for idx, node in zip(task_indices, results):
|
||||
# 每完成一个 L1 段就写入中间 JSON,并刷新进度文件
|
||||
self._save_l1_intermediate(source_id, node, idx)
|
||||
finished_l1_ids.add(idx)
|
||||
new_l1_nodes[idx] = node
|
||||
self._save_progress(source_id, total_l1, finished_l1_ids)
|
||||
|
||||
# Phase 3: 汇总所有 L1 段(中间 + 新生成)
|
||||
l1_nodes: List[L1Node] = []
|
||||
for i in range(total_l1):
|
||||
if i in new_l1_nodes:
|
||||
l1_nodes.append(new_l1_nodes[i])
|
||||
continue
|
||||
node = self._load_l1_intermediate(source_id, i)
|
||||
ensure(node is not None, f"L1 段 {i} 缺失中间结果,无法恢复")
|
||||
l1_nodes.append(node)
|
||||
|
||||
# Phase 6: 组装 TreeIndex
|
||||
metadata = IndexMeta(
|
||||
source_path=video_path,
|
||||
modality="video",
|
||||
created_at=datetime.now().isoformat(),
|
||||
)
|
||||
index = TreeIndex(metadata=metadata, roots=l1_nodes)
|
||||
|
||||
total_l2_count = sum(len(r.children) for r in l1_nodes)
|
||||
total_l3_count = sum(len(l2.children) for r in l1_nodes for l2 in r.children)
|
||||
log_json(
|
||||
"video_tree_build",
|
||||
{
|
||||
"source_path": video_path,
|
||||
"l1_count": len(l1_nodes),
|
||||
"l2_count": total_l2_count,
|
||||
"l3_count": total_l3_count,
|
||||
"embedded": False,
|
||||
},
|
||||
)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"视频树索引构建完成",
|
||||
l1=len(l1_nodes),
|
||||
l2=total_l2_count,
|
||||
l3=total_l3_count,
|
||||
)
|
||||
# 最终 JSON 写入成功后,清理由断点机制生成的中间文件
|
||||
self._cleanup_intermediate_and_progress(source_id)
|
||||
return index
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:时间切分(同步,仅执行一次)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _segment_video(
|
||||
self,
|
||||
video_path: str,
|
||||
duration_hint: Optional[float] = None,
|
||||
) -> List[Tuple[float, float]]:
|
||||
"""读取视频总时长,按固定步长切分为 L1 时间区间列表。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
duration_hint: 已知视频时长(秒)。传入时跳过 cv2 读取。
|
||||
|
||||
返回:
|
||||
L1 时间区间列表,每项为 (start_sec, end_sec)。
|
||||
"""
|
||||
if duration_hint is not None:
|
||||
total_duration = duration_hint
|
||||
else:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
ensure(cap.isOpened(), f"无法打开视频文件: {video_path}")
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
total_frames = cap.get(cv2.CAP_PROP_FRAME_COUNT)
|
||||
cap.release()
|
||||
ensure(fps > 0, f"视频 FPS 读取异常: {fps}")
|
||||
ensure(total_frames > 0, f"视频总帧数读取异常: {total_frames}")
|
||||
total_duration = total_frames / fps
|
||||
|
||||
step = self.config.l1_segment_duration
|
||||
ranges: List[Tuple[float, float]] = []
|
||||
start = 0.0
|
||||
while start < total_duration:
|
||||
end = min(start + step, total_duration)
|
||||
ranges.append((start, end))
|
||||
start = end
|
||||
|
||||
log_msg(
|
||||
"INFO",
|
||||
"L1 时间切分",
|
||||
total_duration=round(total_duration, 2),
|
||||
l1_count=len(ranges),
|
||||
)
|
||||
return ranges
|
||||
|
||||
def _get_l2_clips(self, l1_range: Tuple[float, float]) -> List[Tuple[float, float]]:
|
||||
"""将 L1 时间区间等分为 L2 clips。
|
||||
|
||||
参数:
|
||||
l1_range: L1 时间区间 (start, end),单位秒。
|
||||
|
||||
返回:
|
||||
L2 clip 时间区间列表。
|
||||
"""
|
||||
start, end = l1_range
|
||||
step = self.config.l2_clip_duration
|
||||
clips: List[Tuple[float, float]] = []
|
||||
t = start
|
||||
while t < end:
|
||||
clip_end = min(t + step, end)
|
||||
clips.append((t, clip_end))
|
||||
t = clip_end
|
||||
return clips
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:帧提取(ffmpeg subprocess,在线程池执行)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ffmpeg_extract_frame(self, video_path: str, ts: float, out_path: str) -> bool:
|
||||
"""用 ffmpeg subprocess 提取单帧图像,兼容 AV1/H.264 等所有编码格式。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径(本地 MP4 或 CDN URL)。
|
||||
ts: 目标时间戳(秒)。
|
||||
out_path: 输出 JPEG 文件路径。
|
||||
|
||||
返回:
|
||||
True 表示提取成功,False 表示失败。
|
||||
"""
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-ss", f"{ts:.3f}",
|
||||
"-i", video_path,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "2",
|
||||
"-y", out_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
return result.returncode == 0 and os.path.isfile(out_path)
|
||||
|
||||
async def _extract_frames_async(
|
||||
self,
|
||||
video_path: str,
|
||||
time_range: Tuple[float, float],
|
||||
fps: float,
|
||||
source_id: Optional[str] = None,
|
||||
) -> List[Tuple[str, float]]:
|
||||
"""异步并发提取时间范围内的帧,保存到 cache 目录。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
time_range: 提取时间区间 (start_sec, end_sec)。
|
||||
fps: 提取帧率(帧/秒)。
|
||||
source_id: 帧缓存目录名。
|
||||
|
||||
返回:
|
||||
[(frame_path, timestamp_sec), ...],按时间顺序排列。
|
||||
|
||||
实现细节:
|
||||
所有 ffmpeg 提取任务通过 run_in_executor(self._ffmpeg_pool, ...) 并发执行,
|
||||
已缓存的帧直接跳过(无需调用 ffmpeg)。
|
||||
ffmpeg 线程池 max_workers=_FFMPEG_MAX_WORKERS 防止过度并发占满 CPU。
|
||||
"""
|
||||
video_stem = source_id if source_id is not None else self._source_stem(video_path)
|
||||
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
start_sec, end_sec = time_range
|
||||
step = 1.0 / fps
|
||||
|
||||
timestamps: List[float] = []
|
||||
t = start_sec
|
||||
while t < end_sec:
|
||||
timestamps.append(t)
|
||||
t += step
|
||||
|
||||
if not timestamps:
|
||||
log_msg("WARNING", "帧提取时间区间内无有效时间戳", time_range=time_range, fps=fps)
|
||||
return []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _extract_one(ts: float) -> Optional[Tuple[str, float]]:
|
||||
"""提取单帧:缓存命中直接返回,否则在线程池中调用 ffmpeg。"""
|
||||
frame_name = f"{start_sec:.1f}_{ts:.3f}.jpg"
|
||||
frame_path = str(frame_dir / frame_name)
|
||||
|
||||
if os.path.isfile(frame_path):
|
||||
return (frame_path, ts)
|
||||
|
||||
success = await loop.run_in_executor(
|
||||
self._ffmpeg_pool,
|
||||
self._ffmpeg_extract_frame, video_path, ts, frame_path,
|
||||
)
|
||||
if not success:
|
||||
log_msg("WARNING", "帧读取失败,跳过", timestamp=ts, video_path=video_path)
|
||||
return None
|
||||
return (frame_path, ts)
|
||||
|
||||
# 并发提取所有帧(受 ffmpeg 线程池限制,不会无限并发)
|
||||
results = await asyncio.gather(*[_extract_one(ts) for ts in timestamps])
|
||||
return [r for r in results if r is not None]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:L1 中间结果与进度管理(断点续跑)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _intermediate_dir(self, stem: str) -> Path:
|
||||
"""获取某视频的中间结果目录路径。"""
|
||||
return self._cache_root / "intermediate" / stem
|
||||
|
||||
def _progress_path(self, stem: str) -> Path:
|
||||
"""获取某视频的进度文件路径。"""
|
||||
return self._cache_root / "progress" / f"{stem}.json"
|
||||
|
||||
def _has_l1_intermediate(self, stem: str, l1_idx: int) -> bool:
|
||||
"""检查某 L1 段的中间 JSON 是否存在。"""
|
||||
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
|
||||
return path.is_file()
|
||||
|
||||
def _save_l1_intermediate(self, stem: str, l1_node: L1Node, l1_idx: int) -> None:
|
||||
"""将单个 L1 段的中间结果保存到 JSON 文件。"""
|
||||
dir_path = self._intermediate_dir(stem)
|
||||
dir_path.mkdir(parents=True, exist_ok=True)
|
||||
out_path = dir_path / f"l1_{l1_idx}.json"
|
||||
save_l1_json(str(out_path), l1_node)
|
||||
|
||||
def _load_l1_intermediate(self, stem: str, l1_idx: int) -> Optional[L1Node]:
|
||||
"""从中间 JSON 加载单个 L1 段,若不存在则返回 None。"""
|
||||
path = self._intermediate_dir(stem) / f"l1_{l1_idx}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return load_l1_json(str(path))
|
||||
|
||||
def _load_progress(self, stem: str) -> Optional[Dict[str, object]]:
|
||||
"""加载某视频的进度文件(若不存在则返回 None)。"""
|
||||
path = self._progress_path(stem)
|
||||
if not path.is_file():
|
||||
return None
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
data: Dict[str, object] = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
log_msg("WARNING", "进度文件 JSON 解析失败,忽略", path=str(path))
|
||||
return None
|
||||
return data
|
||||
|
||||
def _save_progress(self, stem: str, total_l1: int, finished_l1_ids: set[int]) -> None:
|
||||
"""将最新进度写回磁盘,支持断点续跑。"""
|
||||
path = self._progress_path(stem)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"video_id": stem,
|
||||
"total_l1": total_l1,
|
||||
"finished_l1_ids": sorted(finished_l1_ids),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
if not path.is_file():
|
||||
payload["created_at"] = payload["updated_at"]
|
||||
else:
|
||||
# 尝试保留旧 created_at
|
||||
old = self._load_progress(stem)
|
||||
if old and isinstance(old.get("created_at"), str):
|
||||
payload["created_at"] = old["created_at"]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
log_msg(
|
||||
"INFO",
|
||||
"进度文件已更新",
|
||||
path=str(path),
|
||||
total_l1=total_l1,
|
||||
finished_l1=list(sorted(finished_l1_ids)),
|
||||
)
|
||||
|
||||
def _cleanup_intermediate_and_progress(self, stem: str) -> None:
|
||||
"""在最终 JSON 写入成功后清理中间结果与进度文件。"""
|
||||
# 清理 progress
|
||||
progress_path = self._progress_path(stem)
|
||||
if progress_path.is_file():
|
||||
try:
|
||||
progress_path.unlink()
|
||||
except OSError:
|
||||
log_msg("WARNING", "删除进度文件失败", path=str(progress_path))
|
||||
|
||||
# 清理 intermediate 目录
|
||||
inter_dir = self._intermediate_dir(stem)
|
||||
if inter_dir.is_dir():
|
||||
for child in inter_dir.glob("l1_*.json"):
|
||||
try:
|
||||
child.unlink()
|
||||
except OSError:
|
||||
log_msg("WARNING", "删除 L1 中间 JSON 失败", path=str(child))
|
||||
try:
|
||||
# 目录可能仍有其它调试文件,忽略删除异常
|
||||
inter_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:异步节点构建
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _build_l2_video_async(
|
||||
self,
|
||||
video_path: str,
|
||||
clip_range: Tuple[float, float],
|
||||
l2_id: str,
|
||||
source_id: Optional[str],
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L2Node:
|
||||
"""异步构建 L2 视频节点(代表帧 VLM 描述)。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
clip_range: L2 clip 时间区间 (start, end),单位秒。
|
||||
l2_id: 节点 ID。
|
||||
source_id: 帧缓存目录名。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L2Node(children 为空,由后续 L3 阶段填充)。
|
||||
|
||||
实现细节:
|
||||
均匀采样 l2_representative_frames 帧,并行 ffmpeg 提取,
|
||||
async with vlm_sem 限制 VLM 并发量。
|
||||
"""
|
||||
start_sec, end_sec = clip_range
|
||||
n_rep = self.config.l2_representative_frames
|
||||
|
||||
if n_rep == 1:
|
||||
timestamps = [(start_sec + end_sec) / 2.0]
|
||||
else:
|
||||
step = (end_sec - start_sec) / (n_rep - 1)
|
||||
timestamps = [start_sec + i * step for i in range(n_rep)]
|
||||
|
||||
video_stem = source_id if source_id is not None else self._source_stem(video_path)
|
||||
frame_dir = Path(self.config.cache_dir) / "frames" / video_stem
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
async def _extract_rep(ts: float) -> Optional[str]:
|
||||
frame_name = f"l2_{ts:.3f}.jpg"
|
||||
frame_path = str(frame_dir / frame_name)
|
||||
if os.path.isfile(frame_path):
|
||||
return frame_path
|
||||
success = await loop.run_in_executor(
|
||||
self._ffmpeg_pool,
|
||||
self._ffmpeg_extract_frame, video_path, ts, frame_path,
|
||||
)
|
||||
if not success:
|
||||
log_msg("WARNING", "L2 代表帧读取失败,跳过", timestamp=ts)
|
||||
return None
|
||||
return frame_path
|
||||
|
||||
# 并发提取所有代表帧
|
||||
rep_results = await asyncio.gather(*[_extract_rep(ts) for ts in timestamps])
|
||||
rep_frames = [p for p in rep_results if p is not None]
|
||||
ensure(len(rep_frames) > 0, f"L2 节点 {l2_id} 代表帧提取结果为空")
|
||||
|
||||
# VLM 调用受信号量保护
|
||||
async with vlm_sem:
|
||||
description = await self.vlm.chat_with_images_async(
|
||||
_L2_VIDEO_PROMPT, images=rep_frames
|
||||
)
|
||||
|
||||
return L2Node(
|
||||
id=l2_id,
|
||||
description=description,
|
||||
embedding=None,
|
||||
time_range=clip_range,
|
||||
)
|
||||
|
||||
async def _build_l3_task_async(
|
||||
self,
|
||||
video_path: str,
|
||||
l2_node: L2Node,
|
||||
clip_range: Tuple[float, float],
|
||||
source_id: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L2Node:
|
||||
"""异步 L3 任务:并发提帧 + 批次级并发 VLM 帧描述。
|
||||
|
||||
参数:
|
||||
video_path: 视频文件路径或 CDN 流式 URL。
|
||||
l2_node: 已构建的 L2 节点。
|
||||
clip_range: L2 clip 时间区间。
|
||||
source_id: 帧缓存目录名。
|
||||
l1_i: 父 L1 索引。
|
||||
l2_j: 父 L2 索引。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
已填充 children 的 L2Node。
|
||||
|
||||
实现细节:
|
||||
提帧阶段完全并行(受 ffmpeg 线程池限制);
|
||||
VLM 调用阶段:12个批次同时提交(asyncio.gather),受信号量限流。
|
||||
"""
|
||||
all_frames = await self._extract_frames_async(
|
||||
video_path, clip_range, self.config.l3_fps, source_id=source_id
|
||||
)
|
||||
l3_nodes = await self._build_l3_video_async(
|
||||
all_frames, l2_node.description, l1_i, l2_j, vlm_sem
|
||||
)
|
||||
l2_node.children = l3_nodes
|
||||
return l2_node
|
||||
|
||||
async def _build_l3_video_async(
|
||||
self,
|
||||
frames: List[Tuple[str, float]],
|
||||
l2_description: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> List[L3Node]:
|
||||
"""异步批次级并发构建 L3 节点(核心加速点)。
|
||||
|
||||
参数:
|
||||
frames: [(frame_path, timestamp), ...]。
|
||||
l2_description: L2 节点描述,注入 prompt 上下文。
|
||||
l1_i: 父 L1 索引(用于节点 ID 生成)。
|
||||
l2_j: 父 L2 索引(用于节点 ID 生成)。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L3Node 列表,每项对应一帧。
|
||||
|
||||
实现细节:
|
||||
将全部帧按 _L3_BATCH_SIZE 分批,所有批次同时提交(asyncio.gather),
|
||||
每批通过 vlm_sem 限流,实现批次级真并发。
|
||||
对比旧版串行:12批 × 6s = 72s → 现在 ~6s(受信号量限流取最慢批次)。
|
||||
"""
|
||||
ensure(len(frames) > 0, f"L3 帧列表为空 (l1={l1_i}, l2={l2_j})")
|
||||
|
||||
# Phase 1: 构建所有批次的协程(同时提交,asyncio.gather 并发执行)
|
||||
batches: List[List[Tuple[str, float]]] = []
|
||||
for batch_start in range(0, len(frames), _L3_BATCH_SIZE):
|
||||
batches.append(frames[batch_start : batch_start + _L3_BATCH_SIZE])
|
||||
|
||||
batch_results: List[List[str]] = list(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
self._call_vlm_batch_async(
|
||||
batch, l2_description, l1_i, l2_j, vlm_sem
|
||||
)
|
||||
for batch in batches
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# Phase 2: 展平所有批次描述,构建 L3 节点
|
||||
all_descriptions: List[str] = [
|
||||
desc for batch_descs in batch_results for desc in batch_descs
|
||||
]
|
||||
|
||||
nodes: List[L3Node] = []
|
||||
for k, (desc, (frame_path, ts)) in enumerate(zip(all_descriptions, frames)):
|
||||
nodes.append(
|
||||
L3Node(
|
||||
id=f"l1_{l1_i}_l2_{l2_j}_l3_{k}",
|
||||
description=desc,
|
||||
embedding=None,
|
||||
raw_content=None,
|
||||
frame_path=frame_path,
|
||||
timestamp=ts,
|
||||
)
|
||||
)
|
||||
return nodes
|
||||
|
||||
async def _call_vlm_batch_async(
|
||||
self,
|
||||
batch: List[Tuple[str, float]],
|
||||
l2_description: str,
|
||||
l1_i: int,
|
||||
l2_j: int,
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> List[str]:
|
||||
"""异步单批次 VLM 调用(≤ _L3_BATCH_SIZE 帧),解析失败时逐帧 fallback。
|
||||
|
||||
参数:
|
||||
batch: 本批帧列表 [(frame_path, ts), ...]。
|
||||
l2_description: L2 描述,用于 prompt 和 fallback prompt。
|
||||
l1_i: 父 L1 索引(日志用)。
|
||||
l2_j: 父 L2 索引(日志用)。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
与 batch 等长的描述文本列表。
|
||||
|
||||
实现细节:
|
||||
async with vlm_sem 确保并发量不超过 config.concurrency。
|
||||
fallback 时逐帧并发(asyncio.gather),同样受信号量保护。
|
||||
"""
|
||||
batch_paths = [fp for fp, _ in batch]
|
||||
n = len(batch_paths)
|
||||
prompt = _L3_VIDEO_PROMPT.format(l2_description=l2_description, n=n)
|
||||
|
||||
# Phase 1: 尝试批量调用
|
||||
try:
|
||||
async with vlm_sem:
|
||||
raw = await self.vlm.chat_with_images_async(prompt, images=batch_paths)
|
||||
descriptions = self._parse_json_descriptions(raw, n)
|
||||
if descriptions is not None:
|
||||
return descriptions
|
||||
log_msg(
|
||||
"WARNING",
|
||||
"L3 小批量 VLM JSON 解析失败,对本批逐帧 fallback",
|
||||
l1=l1_i,
|
||||
l2=l2_j,
|
||||
batch_n=n,
|
||||
raw_preview=raw[:100],
|
||||
)
|
||||
except Exception as exc:
|
||||
log_msg(
|
||||
"WARNING",
|
||||
f"L3 小批量 VLM 调用异常,对本批逐帧 fallback: {exc}",
|
||||
l1=l1_i,
|
||||
l2=l2_j,
|
||||
batch_n=n,
|
||||
)
|
||||
|
||||
# Phase 2: 逐帧 fallback(并发,受信号量保护)
|
||||
single_prompt = _L3_SINGLE_PROMPT.format(l2_description=l2_description)
|
||||
|
||||
async def _single_frame(fp: str) -> str:
|
||||
async with vlm_sem:
|
||||
return await self.vlm.chat_with_images_async(single_prompt, images=[fp])
|
||||
|
||||
return list(await asyncio.gather(*[_single_frame(fp) for fp in batch_paths]))
|
||||
|
||||
async def _build_l1_video_async(
|
||||
self,
|
||||
l2_children: List[L2Node],
|
||||
l1_id: str,
|
||||
l1_range: Tuple[float, float],
|
||||
vlm_sem: asyncio.Semaphore,
|
||||
) -> L1Node:
|
||||
"""异步构建 L1 节点(LLM 文本摘要)。
|
||||
|
||||
参数:
|
||||
l2_children: 该 L1 节点下的所有 L2 节点。
|
||||
l1_id: 节点 ID。
|
||||
l1_range: L1 时间区间 (start, end),单位秒。
|
||||
vlm_sem: VLM 并发控制信号量。
|
||||
|
||||
返回:
|
||||
L1Node(children 已赋值)。
|
||||
"""
|
||||
ensure(len(l2_children) > 0, f"L1 节点 {l1_id} 没有 L2 子节点")
|
||||
l2_texts = "\n".join(f"- {node.description}" for node in l2_children)
|
||||
prompt = _L1_VIDEO_PROMPT.format(l2_texts=l2_texts)
|
||||
|
||||
async with vlm_sem:
|
||||
summary = await self.vlm.chat_async(prompt)
|
||||
|
||||
log_msg("INFO", "L1 触发", l1_id=l1_id)
|
||||
return L1Node(
|
||||
id=l1_id,
|
||||
summary=summary,
|
||||
embedding=None,
|
||||
time_range=l1_range,
|
||||
children=l2_children,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部方法:JSON 解析(同步,纯 CPU)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _parse_json_descriptions(
|
||||
self, raw: str, expected_n: int
|
||||
) -> Optional[List[str]]:
|
||||
"""从 VLM 输出中解析 JSON 描述数组。
|
||||
|
||||
参数:
|
||||
raw: VLM 原始返回字符串。
|
||||
expected_n: 期望的描述条数。
|
||||
|
||||
返回:
|
||||
成功解析且长度匹配时返回 List[str],否则返回 None。
|
||||
"""
|
||||
raw = raw.strip()
|
||||
code_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, re.DOTALL)
|
||||
if code_match:
|
||||
raw = code_match.group(1)
|
||||
|
||||
if not raw.startswith("["):
|
||||
return None
|
||||
|
||||
try:
|
||||
items: List[str] = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
if not isinstance(items, list) or len(items) != expected_n:
|
||||
return None
|
||||
|
||||
return [str(item).strip() for item in items]
|
||||
@@ -0,0 +1,2 @@
|
||||
# 领域空白汇总
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"directed": true,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "plan:infrastructure-setup",
|
||||
"label": "项目基础设施初始化计划",
|
||||
"type": "plan"
|
||||
}
|
||||
],
|
||||
"links": []
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# Research Wiki 索引
|
||||
|
||||
> 自动生成,更新时间:2026-07-06 15:13 UTC
|
||||
|
||||
## plan (1)
|
||||
- [项目基础设施初始化计划](plans/infrastructure-setup.md) `plan:infrastructure-setup`
|
||||
@@ -0,0 +1,5 @@
|
||||
# Wiki 变更日志
|
||||
|
||||
- [2026-07-06 13:54 UTC] Wiki 初始化完成
|
||||
- [2026-07-06 15:09 UTC] 新增 plan: 项目基础设施初始化计划 (plan:infrastructure-setup)
|
||||
- [2026-07-06 15:13 UTC] 重建索引: 1 篇页面
|
||||
@@ -0,0 +1,985 @@
|
||||
---
|
||||
type: plan
|
||||
node_id: plan:infrastructure-setup
|
||||
title: 项目基础设施初始化计划
|
||||
date: 2026-07-06
|
||||
---
|
||||
|
||||
# 项目基础设施初始化计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Video-Tree-TRM5(TRM4 MVP 的生产级重构)创建全部基础设施文件,确立 Clean Architecture 分层、项目约束和开发工作流。
|
||||
|
||||
**Architecture:** 项目分三大模块(建树 / 训练 harness / 新题构建),核心内核 `core/`(AgentLoop + Evolution 引擎)可独立提取复用。采用 Protocol-based 接缝(参考 CHSAnalyzer2),依赖只能向内。基础设施文件按依赖顺序创建:ARCHITECTURE.md → CLAUDE.md → 其余文件。
|
||||
|
||||
**Tech Stack:** Python 3.11, Conda env `Video-Tree-TRM`, loguru, pluggy, sentence-transformers, torch, Redis (缓存/ARQ), ruff, pytest
|
||||
|
||||
**参考代码路径(实现者必须在生成内容前阅读):**
|
||||
|
||||
| 参考 | 路径 | 用途 |
|
||||
|------|------|------|
|
||||
| CHSAnalyzer2 CLAUDE.md | `/home/iomgaa/Projects/CHSAnalyzer2/CLAUDE.md` | 工程化结构、P1-P6 原则、SOP、Skill 规则 |
|
||||
| CHSAnalyzer2 ARCHITECTURE.md | `/home/iomgaa/Projects/CHSAnalyzer2/research-wiki/ARCHITECTURE.md` | Clean Architecture 分层、Protocol 接缝模式 |
|
||||
| TRM4 CLAUDE.md | `/home/iomgaa/Projects/Video-Tree-TRM4/CLAUDE.md` | 领域内容、PyTorch 类比、配置管理 |
|
||||
| TRM4 overview.md | `/home/iomgaa/Projects/Video-Tree-TRM4/research-wiki/overview.md` | 自进化循环总览 |
|
||||
| TRM5 reference architecture.md | `/home/iomgaa/Projects/Video-Tree-TRM5/reference/docs/architecture.md` | 建树+检索器设计 |
|
||||
| TRM4 config/default.yaml | `/home/iomgaa/Projects/Video-Tree-TRM4/config/default.yaml` | harness 配置参数 |
|
||||
| TRM5 reference config/videomme.yaml | `/home/iomgaa/Projects/Video-Tree-TRM5/reference/config/videomme.yaml` | 建树配置参数 |
|
||||
| TRM4 .env.example | `/home/iomgaa/Projects/Video-Tree-TRM4/.env.example` | LLM/VLM/ASR/OCR 端点模板 |
|
||||
| TRM4 .gitignore | `/home/iomgaa/Projects/Video-Tree-TRM4/.gitignore` | gitignore 模板 |
|
||||
| TRM4 tree-enhancement-design | `/home/iomgaa/Projects/Video-Tree-TRM4/research-wiki/designs/2026-07-06-tree-enhancement-design.md` | 树增强管线设计 |
|
||||
| TRM4 question-gen-synth-design | `/home/iomgaa/Projects/Video-Tree-TRM4/research-wiki/designs/2026-07-06-question-gen-synth-design.md` | 赛题生成设计 |
|
||||
|
||||
---
|
||||
|
||||
## 文件总览
|
||||
|
||||
| # | 文件 | 动作 | 职责 |
|
||||
|---|------|------|------|
|
||||
| 1 | `.gitignore` | 新建 | Git 排除规则 |
|
||||
| 2 | `research-wiki/ARCHITECTURE.md` | 新建 | Clean Architecture 分层设计、接缝清单、依赖方向 |
|
||||
| 3 | `CLAUDE.md` | 重写 | Agent 指令入口(融合 CHSAnalyzer2 工程框架 + TRM4 领域内容) |
|
||||
| 4 | `README.md` | 新建 | 项目概览 |
|
||||
| 5 | `pyproject.toml` | 重写 | 项目元数据 + 依赖 + 工具配置 |
|
||||
| 6 | `.env.example` | 新建 | 环境变量模板 |
|
||||
| 7 | `config/default.yaml` | 新建 | 全量非敏感默认配置 |
|
||||
| 8 | `Makefile` | 新建 | 工程命令收口 |
|
||||
| 9 | `research-wiki/overview.md` | 新建 | 系统总览(自进化循环 + 模块结构) |
|
||||
| 10 | `.claude/skills/writing-plans/SKILL.md` | 修改 | 增加核心算法保真校验步骤 |
|
||||
| 11 | `.claude/skills/subagent-driven-development/SKILL.md` | 修改 | 增加实现后与参考代码比对步骤 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Git 初始化 + .gitignore
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitignore`
|
||||
|
||||
- [ ] **Step 1: 初始化 Git 仓库**
|
||||
|
||||
```bash
|
||||
cd /home/iomgaa/Projects/Video-Tree-TRM5
|
||||
git init
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 .gitignore**
|
||||
|
||||
以 TRM4 的 `.gitignore`(路径 `/home/iomgaa/Projects/Video-Tree-TRM4/.gitignore`)为基础,做以下调整:
|
||||
|
||||
**保留原文全部内容**,仅修改末尾的项目特有部分:
|
||||
|
||||
```
|
||||
# 将 TRM4 .gitignore 末尾的项目特有部分替换为:
|
||||
|
||||
# ------------------------------
|
||||
# Project-specific
|
||||
# ------------------------------
|
||||
.codex/
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
.deepeval/
|
||||
.playwright-mcp/
|
||||
.wiki-site/
|
||||
pencil/
|
||||
|
||||
# 数据与实验产物(不提交)
|
||||
store/
|
||||
workspaces/
|
||||
results/
|
||||
|
||||
# graphify 知识图谱输出(可再生)
|
||||
graphify-out/
|
||||
|
||||
# reference 中的 ZIP 包(太大)
|
||||
reference/*.zip
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 首次提交**
|
||||
|
||||
```bash
|
||||
git add .gitignore
|
||||
git commit -m "chore: init repo with .gitignore"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: ARCHITECTURE.md
|
||||
|
||||
**Files:**
|
||||
- Create: `research-wiki/ARCHITECTURE.md`
|
||||
|
||||
- [ ] **Step 1: 阅读参考文档**
|
||||
|
||||
实现者必须先阅读以下文件以获取设计决策上下文:
|
||||
- `/home/iomgaa/Projects/CHSAnalyzer2/research-wiki/ARCHITECTURE.md` — Clean Architecture 分层模式、Protocol 接缝
|
||||
- `/home/iomgaa/Projects/Video-Tree-TRM5/reference/docs/architecture.md` — 建树+检索器设计
|
||||
- `/home/iomgaa/Projects/Video-Tree-TRM4/research-wiki/overview.md` — 自进化循环总览
|
||||
|
||||
- [ ] **Step 2: 创建 ARCHITECTURE.md**
|
||||
|
||||
文档结构必须包含以下章节(每章的必要内容已列出):
|
||||
|
||||
**§1 核心定位**
|
||||
- 项目目标:在层次化视频树上构建可自我进化的搜索 Agent + 可训练的递归检索器,目标 EMNLP 2026
|
||||
- 系统类比表(对标 PyTorch 训练循环):复制 TRM4 overview.md 的对应表格,更新代码位置路径为新结构
|
||||
- 三大模块一句话定义:建树(离线预处理,VLM 生成三层 TreeIndex)、训练(推理→诊断→进化自进化循环 + RecursiveRetriever 参数训练)、新题构建(生成训练题,原始 benchmark 作 held-out)
|
||||
|
||||
**§2 分层架构(Clean Architecture)**
|
||||
- 四层依赖规则图(Mermaid):`Entities ← Use Cases ← Ports ← Details`
|
||||
- 目录结构规范(完整树形图),对应以下分层:
|
||||
|
||||
```text
|
||||
project_root/
|
||||
├── core/ # 可提取内核(不依赖 app/、adapters/)
|
||||
│ ├── agent/ # 【可提取包】AgentLoop 引擎
|
||||
│ │ ├── loop.py # Thinking+JSON 推理循环,pluggy hook
|
||||
│ │ ├── types.py # Step, LoopResult
|
||||
│ │ └── protocols.py # LLMProvider, ToolDispatcher Protocol
|
||||
│ ├── evolution/ # 【可提取包】诊断+进化引擎
|
||||
│ │ ├── diagnose.py # 两阶段诊断管线
|
||||
│ │ ├── evolve.py # patch/rewrite 进化
|
||||
│ │ ├── gate.py # CE-Gate e-process
|
||||
│ │ ├── validate.py # 块顺序验证
|
||||
│ │ ├── types.py # DiagnosisResult, EvolutionRecord, ...
|
||||
│ │ └── protocols.py # SkillStore, RunLog, TelemetryRecorder Protocol
|
||||
│ └── types.py # 跨模块共享类型
|
||||
│
|
||||
├── app/ # 应用层(组合 core + adapters,领域特化)
|
||||
│ ├── tree/ # 模块1:建树
|
||||
│ │ ├── index.py # TreeIndex 数据结构(L1/L2/L3Node)
|
||||
│ │ ├── video_builder.py # VideoTreeBuilder(asyncio 并发)
|
||||
│ │ ├── text_builder.py # TextTreeBuilder
|
||||
│ │ ├── embeddings.py # EmbeddingModel(local/remote 双后端)
|
||||
│ │ ├── enhance/ # 树增强管线(verify/supplement/clean)
|
||||
│ │ └── subtitle.py # SRT 解析 + 字幕注入
|
||||
│ ├── harness/ # 模块2:训练 harness
|
||||
│ │ ├── runner.py # 训练循环编排(对标 Trainer)
|
||||
│ │ ├── inference.py # 推理 step
|
||||
│ │ ├── batching.py # mini-batch 构建
|
||||
│ │ ├── question_gen.py # 数据加载、三池切分
|
||||
│ │ ├── gate_ladder.py # 信息阶梯
|
||||
│ │ ├── momentum.py # 慢速动量
|
||||
│ │ ├── config.py # RunConfig
|
||||
│ │ ├── log.py # HarnessLog (SQLite)
|
||||
│ │ └── workspace.py # Store + Workspace 版本管理
|
||||
│ ├── question_gen/ # 模块3:新题构建
|
||||
│ │ ├── generator.py # 题目生成
|
||||
│ │ ├── calibrator.py # 基线校准
|
||||
│ │ └── dedup.py # 去重
|
||||
│ ├── search/ # 搜索 Agent 装配
|
||||
│ │ ├── prompt.py # PromptManager
|
||||
│ │ └── skills.py # SkillRegistry
|
||||
│ ├── retriever/ # 可训练检索器
|
||||
│ │ ├── recursive.py # RecursiveRetriever (CrossAttention+ACT)
|
||||
│ │ ├── losses.py # NavigationLoss + ACTLoss
|
||||
│ │ └── train.py # 两阶段训练入口
|
||||
│ └── ports.py # 应用层特有端口(EmbeddingProvider, TreeCache, ...)
|
||||
│
|
||||
├── adapters/ # 外部实现层
|
||||
│ ├── llm.py # GovernedLLMClient(遥测+熔断+Redis缓存)
|
||||
│ ├── vlm.py # VLM 客户端
|
||||
│ ├── embedding.py # Embedding 服务实现
|
||||
│ ├── redis_cache.py # Redis 响应缓存
|
||||
│ ├── ocr.py # MonkeyOCR 客户端
|
||||
│ ├── asr.py # ASR (Whisper) 客户端
|
||||
│ └── telemetry.py # SQLite 遥测记录实现
|
||||
│
|
||||
├── config/ # 声明性配置(YAML,禁止 .py)
|
||||
├── store/ # 版本化资源(skills/prompts/questions/videos)
|
||||
├── workspaces/ # 实验工作区
|
||||
├── tests/ # 测试
|
||||
├── data/ # 数据(不提交 Git)
|
||||
├── logs/ # 日志(不提交 Git)
|
||||
├── results/ # 实验结果
|
||||
├── prompts/ # 诊断 prompt(不参与进化,是评估标尺)
|
||||
├── main.py # CLI 入口
|
||||
└── research-wiki/ # 单一事实源
|
||||
```
|
||||
|
||||
- 依赖方向硬性规则(表格):
|
||||
|
||||
| 层 | 可依赖 | 禁止依赖 |
|
||||
|---|--------|---------|
|
||||
| `core/` | 标准库、typing、pluggy | `app/`、`adapters/`、任何框架 |
|
||||
| `app/` | `core/`、标准库 | `adapters/`(只通过 Protocol) |
|
||||
| `adapters/` | `core/`、`app/ports.py`、第三方库 | `app/` 内部模块 |
|
||||
|
||||
**§3 接缝清单(Protocol 端口)**
|
||||
- 列出所有 Protocol 接口(分 core/protocols.py 和 app/ports.py),每个 Protocol 给出:名称、方法签名、职责一句话、当前实现类
|
||||
|
||||
核心端口(`core/` 内,可提取):
|
||||
|
||||
| Protocol | 关键方法 | 职责 |
|
||||
|----------|---------|------|
|
||||
| `LLMProvider` | `chat()`, `chat_async()` | LLM 文本调用 |
|
||||
| `VLMProvider` | `chat_with_images()`, `chat_with_images_async()` | VLM 图文调用 |
|
||||
| `ToolDispatcher` | `dispatch(tool_name, args, context)` | Agent 工具调度 |
|
||||
| `SkillStore` | `read_skill()`, `write_skill()`, `list_versions()` | 版本化技能存储 |
|
||||
| `PromptStore` | `read_prompt()`, `write_prompt()` | 版本化提示词存储 |
|
||||
| `RunLog` | `insert()`, `query()` | 实验日志 |
|
||||
| `TelemetryRecorder` | `record_llm_call()` | Agent 遥测 |
|
||||
|
||||
应用层端口(`app/ports.py`):
|
||||
|
||||
| Protocol | 关键方法 | 职责 |
|
||||
|----------|---------|------|
|
||||
| `EmbeddingProvider` | `embed(texts)` | 文本嵌入 |
|
||||
| `TreeCache` | `get()`, `set()` | 树索引缓存(Redis 实现) |
|
||||
| `ASRProvider` | `transcribe(audio_path)` | 语音识别 |
|
||||
| `OCRProvider` | `recognize(image_path)` | OCR |
|
||||
|
||||
**§4 Agent 遥测规范**
|
||||
- 每次 LLM/VLM 调用必须记录的字段表:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `call_id` | str | UUID |
|
||||
| `parent_call_id` | str? | 父调用(agent step → LLM call 链路) |
|
||||
| `session_id` | str | epoch/step/question 关联 ID |
|
||||
| `model_name` | str | 使用的模型名 |
|
||||
| `provider` | str | API 端点标识 |
|
||||
| `messages` | str (JSON) | 原始输入 |
|
||||
| `response` | str | 原始输出 |
|
||||
| `prompt_tokens` | int | 输入 token 数 |
|
||||
| `completion_tokens` | int | 输出 token 数 |
|
||||
| `latency_ms` | int | 延迟毫秒 |
|
||||
| `cache_hit` | bool | 是否命中 Redis 缓存 |
|
||||
| `error` | str? | 异常信息 |
|
||||
|
||||
- 存储:SQLite `telemetry.db`,`llm_calls` 表
|
||||
|
||||
**§5 LLM 调用韧性**
|
||||
- 硬超时:`asyncio.wait_for(call, timeout=config.llm_timeout)`
|
||||
- 指数退避重试:max_retries, base_delay, max_delay(可配置)
|
||||
- 熔断器:连续 N 失败 → 短路 M 秒 → 探针恢复
|
||||
- Redis 响应缓存:content-addressed cache(model + messages hash → response)
|
||||
- ARQ 任务队列:长时间推理任务异步执行
|
||||
|
||||
**§6 核心算法保真清单**
|
||||
- 完整的 13 项算法表(建树 4 项 + 训练 9 项),包括算法名、参考文件路径、核心逻辑一句话描述
|
||||
- 说明:迁移时逐一比对参考代码,不可简化
|
||||
|
||||
**§7 配置管理(双模式)**
|
||||
- 工程配置:pydantic-settings + `.env`
|
||||
- 科研实验配置:per-experiment YAML + harness run 快照
|
||||
- D7 判定规则(从 CHSAnalyzer2 借鉴)
|
||||
|
||||
- [ ] **Step 3: 验证 Markdown 格式**
|
||||
|
||||
```bash
|
||||
# 确认文件存在且非空
|
||||
wc -l research-wiki/ARCHITECTURE.md
|
||||
# 期望: 300-500 行
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add research-wiki/ARCHITECTURE.md
|
||||
git commit -m "docs: add ARCHITECTURE.md with Clean Architecture design"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: CLAUDE.md 重写
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: 阅读参考文档**
|
||||
|
||||
实现者必须阅读以下三个文件并理解其结构:
|
||||
- `/home/iomgaa/Projects/CHSAnalyzer2/CLAUDE.md` — 工程化框架(完整读取)
|
||||
- `/home/iomgaa/Projects/Video-Tree-TRM4/CLAUDE.md` — 领域内容(完整读取)
|
||||
- 刚创建的 `research-wiki/ARCHITECTURE.md` — 新架构设计
|
||||
|
||||
- [ ] **Step 2: 重写 CLAUDE.md**
|
||||
|
||||
**融合策略**:以 CHSAnalyzer2 CLAUDE.md 的**结构和工程化规则**为骨架,以 TRM4 CLAUDE.md 的**领域内容**填充,并新增遥测/韧性/保真要求。
|
||||
|
||||
**必须包含的章节及其来源**:
|
||||
|
||||
| 章节 | 来源 | 关键调整 |
|
||||
|------|------|---------|
|
||||
| §1 项目元数据 | TRM4 | 核心目标改为完整描述(自进化+可训练检索器),Conda 改 `Video-Tree-TRM`,Python 3.11 |
|
||||
| §1.5 设计类比 | TRM4 | 保留 PyTorch 类比表,更新代码路径为新结构 |
|
||||
| §2 常用命令 | CHSAnalyzer2 结构 + TRM4 内容 | Conda 环境 `Video-Tree-TRM`,GPU 约定保留,Makefile 入口更新 |
|
||||
| §3 SOP | CHSAnalyzer2 | 保留双阶段 + 审核门控差异化,不改 |
|
||||
| §4.1 核心原则 | CHSAnalyzer2 P1-P6 | 保留完整优先级排序,P1 YAGNI 加上"不等于砍健壮性" |
|
||||
| §4.2 代码规范 | 融合两者 | 日志用 loguru(非 CHSAnalyzer2 的 RunStore),中文 Docstring |
|
||||
| §4.3 Git 工作流 | CHSAnalyzer2 | 完全保留 |
|
||||
| §4.4 工作区规范 | CHSAnalyzer2 | 知识输出到 research-wiki/ |
|
||||
| §4.5 配置管理 | CHSAnalyzer2 双模式 + TRM4 优先级 | 工程 pydantic-settings + 科研 YAML,D7 规则 |
|
||||
| §4.6 测试规范 | 融合 | Agent 测试输出 MD 规范保留 |
|
||||
| **§4.7 核心算法保真** | **新增** | 13 项算法清单,引用 ARCHITECTURE.md §6 |
|
||||
| **§4.8 Agent 遥测** | **新增** | 每次 LLM 调用必须记录的字段,引用 ARCHITECTURE.md §4 |
|
||||
| **§4.9 LLM 韧性** | **新增** | 硬超时/熔断/缓存要求 |
|
||||
| §5 项目结构 | ARCHITECTURE.md §2 | 从 ARCHITECTURE.md 的目录树复制,加硬性规则 |
|
||||
| §6 上下文获取 | CHSAnalyzer2 | 更新文档路径 |
|
||||
| §7 输出规范 | CHSAnalyzer2 | 中文,表格优先,长度控制 |
|
||||
| §8 Skill 使用 | CHSAnalyzer2 | 保留无条件义务声明 + 触发时机表 |
|
||||
| §9 Research Wiki | CHSAnalyzer2 | 保留,实体类型表不变 |
|
||||
|
||||
**Urgent 横幅**:
|
||||
|
||||
```markdown
|
||||
> [!URGENT]
|
||||
> **科研工程混合 + 生产级项目(非 MVP)**
|
||||
> 1. 本项目是科研工程混合体(当下工程为主,后续 Agent 进化科研为主),要求**生产级**的稳定性、并发性、防御性、可观测与测试。YAGNI 仍然适用,但**绝不以牺牲健壮性、并发、防御、可观测、测试为代价**换取"简单"。
|
||||
> 2. 你的所有思考过程和回复必须使用 **简体中文**。
|
||||
```
|
||||
|
||||
**项目元数据**须包含:
|
||||
- 核心目标:完整的一段话(自进化搜索 Agent + 可训练递归检索器 + 层次化视频树 + EMNLP 2026)
|
||||
- 项目类型:科研工程混合体 + 生产级(非 MVP)
|
||||
- 后端架构:Python 3.11
|
||||
- Conda 环境:`Video-Tree-TRM` (Python 3.11)
|
||||
- 目标会议:EMNLP 2026
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
```bash
|
||||
wc -l CLAUDE.md
|
||||
# 期望: 350-500 行
|
||||
|
||||
# 确认关键内容存在
|
||||
grep -c "Video-Tree-TRM" CLAUDE.md # 期望 >= 5(conda 环境引用)
|
||||
grep -c "EMNLP 2026" CLAUDE.md # 期望 >= 1
|
||||
grep -c "核心算法保真" CLAUDE.md # 期望 >= 1
|
||||
grep -c "遥测" CLAUDE.md # 期望 >= 1
|
||||
grep -c "ARCHITECTURE.md" CLAUDE.md # 期望 >= 2
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md
|
||||
git commit -m "docs: rewrite CLAUDE.md for TRM5 production-grade project"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: README.md
|
||||
|
||||
**Files:**
|
||||
- Create: `README.md`
|
||||
|
||||
- [ ] **Step 1: 创建 README.md**
|
||||
|
||||
```markdown
|
||||
# Video-Tree-TRM5
|
||||
|
||||
> 在层次化视频树上构建可自我进化的搜索 Agent 与可训练递归检索器,实现长视频理解。目标会议:EMNLP 2026。
|
||||
|
||||
## 系统概览
|
||||
|
||||
本项目是 [Video-Tree-TRM4](../Video-Tree-TRM4)(MVP)的生产级重构,采用 Clean Architecture 分层设计。
|
||||
|
||||
### 核心思想:自进化循环对标 PyTorch 训练
|
||||
|
||||
| PyTorch | 本项目 | 模块 |
|
||||
|---------|--------|------|
|
||||
| DataLoader | 出题 question_gen | `app/question_gen/` |
|
||||
| model.forward() | 推理 inference | `app/harness/inference.py` + `core/agent/loop.py` |
|
||||
| loss.backward() | 诊断 diagnose | `core/evolution/diagnose.py` |
|
||||
| optimizer.step() | 进化 evolve | `core/evolution/evolve.py` |
|
||||
| nn.Parameter | Skills + Prompts(版本化) | `store/skills/`, `store/prompts/` |
|
||||
|
||||
### 三大模块
|
||||
|
||||
| 模块 | 目录 | 说明 |
|
||||
|------|------|------|
|
||||
| 建树 | `app/tree/` | 离线预处理:VLM 生成三层 TreeIndex(L1段落→L2片段→L3帧),支持字幕注入和后增强 |
|
||||
| 训练 | `app/harness/` + `core/` | 自进化循环:推理→诊断→进化,含 CE-Gate 统计检验、信息阶梯、mini-batch 调度 |
|
||||
| 新题构建 | `app/question_gen/` | 生成 Video-MME 风格训练题,原始 benchmark 作 held-out 泛化评测 |
|
||||
|
||||
### 可提取内核
|
||||
|
||||
`core/agent/` 和 `core/evolution/` 只依赖 Protocol 接口,可独立提取用于其他项目。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 创建 Conda 环境
|
||||
conda create -n Video-Tree-TRM python=3.11 -y
|
||||
conda activate Video-Tree-TRM
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# 2. 配置环境变量
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入 API 密钥
|
||||
|
||||
# 3. 验证
|
||||
make lint
|
||||
make test
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
详见 `research-wiki/ARCHITECTURE.md`。
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| `research-wiki/ARCHITECTURE.md` | 系统架构与边界 |
|
||||
| `research-wiki/overview.md` | 自进化循环总览 |
|
||||
| `CLAUDE.md` | Agent 工作指令 |
|
||||
| `reference/docs/architecture.md` | 建树+检索器参考设计 |
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add README.md
|
||||
git commit -m "docs: add README.md with project overview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: pyproject.toml
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `pyproject.toml`
|
||||
|
||||
- [ ] **Step 1: 重写 pyproject.toml**
|
||||
|
||||
```toml
|
||||
[build-system]
|
||||
requires = ["setuptools>=68.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "video-tree-trm"
|
||||
version = "0.1.0"
|
||||
description = "自进化搜索 Agent + 可训练递归检索器,在层次化视频树上实现长视频理解"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
# 核心框架
|
||||
"torch>=2.1",
|
||||
"pluggy>=1.3",
|
||||
"loguru>=0.7",
|
||||
# LLM/VLM 客户端
|
||||
"openai>=1.30",
|
||||
"httpx>=0.27",
|
||||
# 嵌入与 NLP
|
||||
"sentence-transformers>=3.0",
|
||||
"numpy>=1.26",
|
||||
# 配置管理
|
||||
"pydantic>=2.5",
|
||||
"pydantic-settings>=2.1",
|
||||
"pyyaml>=6.0",
|
||||
"python-dotenv>=1.0",
|
||||
# 视频处理
|
||||
"opencv-python-headless>=4.9",
|
||||
# JSON 修复
|
||||
"json-repair>=0.28",
|
||||
# 任务队列与缓存
|
||||
"arq>=0.26",
|
||||
"redis>=5.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=5.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.5",
|
||||
"radon>=6.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "app*", "adapters*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = [".", ".claude/tools"]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"requires_redis: 需要可达的 Redis(无则 skip)",
|
||||
"requires_gpu: 需要 GPU(无则 skip)",
|
||||
"slow: 慢速测试(CI 按需跑)",
|
||||
]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 100
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "TCH"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["core", "app", "adapters"]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建最小目录骨架**
|
||||
|
||||
后续 Makefile 和 README 引用 `core/`、`app/`、`adapters/`、`tests/` 路径。创建空包骨架使 lint/test 命令不报错:
|
||||
|
||||
```bash
|
||||
mkdir -p core/agent core/evolution app/tree app/harness app/question_gen app/search app/retriever adapters tests/unit tests/integration tests/e2e
|
||||
touch core/__init__.py core/agent/__init__.py core/evolution/__init__.py core/types.py
|
||||
touch app/__init__.py app/tree/__init__.py app/harness/__init__.py app/question_gen/__init__.py app/search/__init__.py app/retriever/__init__.py app/ports.py
|
||||
touch adapters/__init__.py
|
||||
touch tests/__init__.py tests/unit/__init__.py tests/integration/__init__.py tests/e2e/__init__.py
|
||||
```
|
||||
|
||||
创建最小测试文件使 `pytest` 有东西可跑:
|
||||
|
||||
```python
|
||||
# tests/unit/test_smoke.py
|
||||
"""冒烟测试:验证包可导入。"""
|
||||
|
||||
|
||||
def test_core_importable():
|
||||
"""core 包可导入。"""
|
||||
import core
|
||||
assert core is not None
|
||||
|
||||
|
||||
def test_app_importable():
|
||||
"""app 包可导入。"""
|
||||
import app
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_adapters_importable():
|
||||
"""adapters 包可导入。"""
|
||||
import adapters
|
||||
assert adapters is not None
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证 TOML 语法 + 可安装性**
|
||||
|
||||
```bash
|
||||
python3 -c "import tomllib; tomllib.load(open('pyproject.toml','rb')); print('TOML OK')"
|
||||
# Expected: TOML OK
|
||||
|
||||
conda run -n Video-Tree-TRM pip install -e ".[dev]" 2>&1 | tail -3
|
||||
# Expected: Successfully installed ... (或已安装)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add pyproject.toml core/ app/ adapters/ tests/
|
||||
git commit -m "build: expand pyproject.toml, create package skeletons and smoke test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: .env.example
|
||||
|
||||
**Files:**
|
||||
- Create: `.env.example`
|
||||
|
||||
- [ ] **Step 1: 创建 .env.example**
|
||||
|
||||
以 TRM4 的 `.env.example`(路径 `/home/iomgaa/Projects/Video-Tree-TRM4/.env.example`)为基础,新增 Redis 和 Embedding 配置:
|
||||
|
||||
```bash
|
||||
# 国内 LLM 端点绕过本地代理
|
||||
no_proxy=dashscope.aliyuncs.com,api.deepseek.com
|
||||
NO_PROXY=dashscope.aliyuncs.com,api.deepseek.com
|
||||
|
||||
# ── 搜索 Agent LLM ──
|
||||
SEARCH_LLM_MODEL=deepseek-v4-pro
|
||||
SEARCH_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
SEARCH_LLM_API_KEY=sk-xxx
|
||||
|
||||
# ── 评估 Judge LLM ──
|
||||
JUDGE_LLM_MODEL=deepseek-v4-pro
|
||||
JUDGE_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
JUDGE_LLM_API_KEY=sk-xxx
|
||||
|
||||
# ── 视觉模型(Qwen VL)──
|
||||
VL_LLM_MODEL=qwen3.6-plus
|
||||
VL_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
VL_LLM_API_KEY=sk-xxx
|
||||
|
||||
# ── 进化 LLM(Prompt 改写)──
|
||||
EVOLVE_LLM_MODEL=deepseek-v4-pro
|
||||
EVOLVE_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
EVOLVE_LLM_API_KEY=sk-xxx
|
||||
|
||||
# ── ASR 字幕生成(Groq Whisper)──
|
||||
ASR_MODEL=whisper-large-v3
|
||||
ASR_BASE_URL=https://api.groq.com/openai/v1
|
||||
ASR_API_KEY=gsk-xxx
|
||||
|
||||
# ── MonkeyOCR ──
|
||||
MONKEY_OCR_URLS=http://10.77.0.20:7866,http://10.77.0.20:7867
|
||||
|
||||
# ── Embedding(远程模式时使用)──
|
||||
EMBED_BACKEND=local
|
||||
EMBED_MODEL=BAAI/bge-base-zh-v1.5
|
||||
EMBED_API_KEY=
|
||||
EMBED_API_URL=
|
||||
|
||||
# ── Redis(响应缓存 + ARQ 任务队列)──
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ── LLM 韧性参数 ──
|
||||
LLM_TIMEOUT=120
|
||||
LLM_MAX_RETRIES=3
|
||||
LLM_RETRY_BASE_DELAY=2.0
|
||||
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
||||
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add .env.example
|
||||
git commit -m "config: add .env.example with LLM/Redis/telemetry templates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: config/default.yaml
|
||||
|
||||
**Files:**
|
||||
- Create: `config/default.yaml`
|
||||
|
||||
- [ ] **Step 1: 创建 config 目录和默认配置**
|
||||
|
||||
```bash
|
||||
mkdir -p config
|
||||
```
|
||||
|
||||
合并 TRM5 reference 建树配置 + TRM4 harness 配置为统一文件:
|
||||
|
||||
```yaml
|
||||
# config/default.yaml
|
||||
# 全量默认参数,所有非敏感配置的唯一默认值来源。
|
||||
# 优先级: CLI args > .env > 此文件。敏感信息在 .env 中管理。
|
||||
|
||||
# ── 建树模块 ──
|
||||
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: "cpu"
|
||||
|
||||
# ── 可训练检索器 ──
|
||||
retriever:
|
||||
embed_dim: 768
|
||||
num_heads: 4
|
||||
L_layers: 2
|
||||
L_cycles: 4
|
||||
max_rounds: 5
|
||||
ffn_expansion: 2.0
|
||||
checkpoint: null
|
||||
k_l1: 1
|
||||
k_l2: 1
|
||||
k_l3: 1
|
||||
max_paths: 5
|
||||
|
||||
# ── 检索器训练 ──
|
||||
train:
|
||||
lr: 1.0e-4
|
||||
weight_decay: 1.0e-5
|
||||
batch_size: 1
|
||||
max_epochs_phase1: 30
|
||||
max_epochs_phase2: 20
|
||||
nav_loss_weight: 1.0
|
||||
act_loss_weight: 0.1
|
||||
margin_loss_weight: 0.5
|
||||
act_lambda_step: 0.1
|
||||
act_gamma: 0.9
|
||||
eval_interval: 5
|
||||
save_dir: "checkpoints"
|
||||
dataset: "videomme"
|
||||
dataset_path: "data/videomme/splits/train.jsonl"
|
||||
|
||||
# ── Harness 自进化循环 ──
|
||||
harness:
|
||||
workspace_dir: "workspaces/default"
|
||||
store_dir: store
|
||||
mode: infer # infer / train
|
||||
concurrency: 12
|
||||
max_steps: 15 # Agent 单题最大步数
|
||||
skill_mode: auto
|
||||
n_samples: 0 # 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_block: 8
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 YAML 语法**
|
||||
|
||||
```bash
|
||||
python3 -c "import yaml; yaml.safe_load(open('config/default.yaml')); print('OK')"
|
||||
```
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add config/default.yaml
|
||||
git commit -m "config: add default.yaml with tree/retriever/harness parameters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Makefile
|
||||
|
||||
**Files:**
|
||||
- Create: `Makefile`
|
||||
|
||||
- [ ] **Step 1: 创建 Makefile**
|
||||
|
||||
```makefile
|
||||
.PHONY: test lint format wiki build-tree train infer generate-questions
|
||||
|
||||
ENV := Video-Tree-TRM
|
||||
|
||||
# ── 代码质量 ──
|
||||
test:
|
||||
conda run -n $(ENV) pytest tests/ --cov=core --cov=app --cov=adapters --cov-report=term-missing --cov-fail-under=80
|
||||
|
||||
lint:
|
||||
conda run -n $(ENV) ruff check core/ app/ adapters/ --fix
|
||||
|
||||
format:
|
||||
conda run -n $(ENV) ruff format core/ app/ adapters/
|
||||
|
||||
# ── 建树 ──
|
||||
build-tree:
|
||||
conda run -n $(ENV) python main.py build-tree $(ARGS)
|
||||
|
||||
# ── 训练 ──
|
||||
train:
|
||||
CUDA_VISIBLE_DEVICES=0 conda run -n $(ENV) python main.py train $(ARGS)
|
||||
|
||||
infer:
|
||||
CUDA_VISIBLE_DEVICES=0 conda run -n $(ENV) python main.py infer $(ARGS)
|
||||
|
||||
# ── 新题构建 ──
|
||||
generate-questions:
|
||||
conda run -n $(ENV) python main.py generate-questions $(ARGS)
|
||||
|
||||
# ── 知识库 ──
|
||||
wiki:
|
||||
conda run -n $(ENV) python3 .claude/tools/research_wiki.py rebuild_index research-wiki/
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 Makefile 语法**
|
||||
|
||||
```bash
|
||||
make -n test 2>&1 | head -3
|
||||
# 期望: 显示 conda run 命令(dry run),不报语法错
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add Makefile
|
||||
git commit -m "build: add Makefile with test/lint/build-tree/train targets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: research-wiki/overview.md
|
||||
|
||||
**Files:**
|
||||
- Create: `research-wiki/overview.md`
|
||||
|
||||
- [ ] **Step 1: 创建 overview.md**
|
||||
|
||||
以 TRM4 的 `research-wiki/overview.md`(路径 `/home/iomgaa/Projects/Video-Tree-TRM4/research-wiki/overview.md`)为蓝本,更新为 TRM5 的新架构:
|
||||
|
||||
必须包含:
|
||||
1. 一句话项目定位
|
||||
2. 核心思想:自进化循环对标 PyTorch 训练(表格,更新代码路径为新 `app/`/`core/` 结构)
|
||||
3. 模块结构图(Mermaid flowchart,展示 main.py → runner → 四步循环 + 三大模块)
|
||||
4. 模块职责表(与 TRM4 overview 格式相同,路径更新)
|
||||
5. 资源与工作区表(store/ 和 workspaces/ 的说明)
|
||||
6. "实现路线"一句话指向 `research-wiki/designs/` 和 `research-wiki/index.md`
|
||||
7. **新增**:可提取内核说明(`core/agent/` 和 `core/evolution/` 的独立性)
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add research-wiki/overview.md
|
||||
git commit -m "docs: add overview.md with system overview and module structure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: writing-plans Skill 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude/skills/writing-plans/SKILL.md`
|
||||
|
||||
- [ ] **Step 1: 在 Self-Review 章节后、Codex Plan Review 章节前插入新章节**
|
||||
|
||||
在 SKILL.md 的 `## Self-Review` 章节结束后、`## Codex Plan Review` 章节开始前,插入以下内容:
|
||||
|
||||
```markdown
|
||||
## 核心算法保真校验
|
||||
|
||||
计划编写完成、Self-Review 通过后,**必须**执行以下校验:
|
||||
|
||||
对照 `research-wiki/ARCHITECTURE.md §6 核心算法保真清单` 中列出的 13 项关键算法,逐一检查:
|
||||
|
||||
1. 计划中是否涉及该算法的迁移/重写?
|
||||
2. 若涉及,计划中的实现是否与参考代码的核心逻辑一致?
|
||||
3. 是否存在简化、省略、或改变算法行为的步骤?
|
||||
|
||||
**参考代码路径**:
|
||||
|
||||
| 算法 | 参考文件 |
|
||||
|------|---------|
|
||||
| L2 轴心建树策略 | `reference/video_tree_trm/video_tree_builder.py` |
|
||||
| VLM 批量帧描述 + JSON fallback | `reference/video_tree_trm/video_tree_builder.py` |
|
||||
| 断点续跑机制 | `reference/video_tree_trm/video_tree_builder.py` |
|
||||
| RecursiveRetriever | `reference/docs/architecture.md §5` |
|
||||
| CE-Gate e-process | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/eprocess.py` |
|
||||
| 信息阶梯 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/gate_ladder.py` |
|
||||
| 块顺序验证 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/validate.py` |
|
||||
| 诊断瀑布 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/diagnose.py` |
|
||||
| 进化 patch 引擎 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/evolve.py` + `patch.py` |
|
||||
| Mini-batch 构建 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/batching.py` |
|
||||
| Agent Loop | `/home/iomgaa/Projects/Video-Tree-TRM4/core/loop.py` |
|
||||
| 树环境语义搜索 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/tree/environment.py` |
|
||||
| 训练循环编排 | `/home/iomgaa/Projects/Video-Tree-TRM4/core/harness/runner.py` |
|
||||
|
||||
**若发现任何简化**:在计划中明确标注该步骤需要逐行比对参考代码,并添加"保真校验"检查点。
|
||||
|
||||
**若计划不涉及任何核心算法**:记录"本计划不涉及核心算法迁移,保真校验不适用"即可。
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add .claude/skills/writing-plans/SKILL.md
|
||||
git commit -m "skill: add algorithm fidelity check to writing-plans"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: subagent-driven-development Skill 修改
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude/skills/subagent-driven-development/SKILL.md`
|
||||
|
||||
- [ ] **Step 1: 在 §4.5 Automated Quality Gate 的 Gate checklist 末尾添加新检查项**
|
||||
|
||||
在 SKILL.md 的 `### 4.5 Automated Quality Gate` 章节的 Gate checklist 代码块末尾(`# 6. Metrics regression check` 之后),追加:
|
||||
|
||||
```bash
|
||||
# 7. 核心算法保真检查(仅当任务涉及核心算法迁移时)
|
||||
# - 读取 research-wiki/ARCHITECTURE.md §6 核心算法清单
|
||||
# - 对每个被修改的核心算法文件,diff 与参考代码
|
||||
# - 确认核心逻辑(条件分支、数学公式、状态机转换)未被简化
|
||||
# - 若有差异,生成差异报告要求 implementer 解释
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 §5 Spec compliance review 之后、§6 之前插入新检查步骤**
|
||||
|
||||
在 `### 5. Spec compliance review` 章节结束后、`### 6. Functional quality review` 章节开始前,插入:
|
||||
|
||||
```markdown
|
||||
### 5.5 核心算法保真审查
|
||||
|
||||
仅当当前任务涉及核心算法迁移(参照 `research-wiki/ARCHITECTURE.md §6`)时执行此步骤。
|
||||
|
||||
将以下内容交给 Codex 审查(`/codex:rescue --fresh --wait`):
|
||||
|
||||
1. 当前任务修改的文件(`git diff`)
|
||||
2. 对应的参考代码文件(从参考路径读取)
|
||||
3. 审查指令:"逐一比对以下核心逻辑,确认新实现未简化、省略或改变算法行为:[列出具体算法要点]"
|
||||
|
||||
**通过标准**:Codex 确认核心逻辑一致,或差异有合理的架构理由(如 Protocol 接口化)。
|
||||
**未通过**:发回 implementer 修正,循环直到通过。
|
||||
|
||||
不涉及核心算法的任务跳过此步骤。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 全文替换环境名**
|
||||
|
||||
在 SKILL.md **全文**中(不限于 §4.5),将所有 `conda run -n chs` 替换为 `conda run -n Video-Tree-TRM`。使用编辑器的全局替换功能,确认替换数量后执行。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add .claude/skills/subagent-driven-development/SKILL.md
|
||||
git commit -m "skill: add algorithm fidelity review to subagent-driven-development"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 检查清单
|
||||
|
||||
**1. Spec 覆盖**:
|
||||
|
||||
| 需求 | 对应 Task |
|
||||
|------|----------|
|
||||
| ARCHITECTURE.md(Clean Architecture、Protocol 接缝、遥测、韧性、保真清单) | Task 2 |
|
||||
| CLAUDE.md(融合 CHSAnalyzer2 + TRM4 + 新增遥测/韧性/保真) | Task 3 |
|
||||
| README.md | Task 4 |
|
||||
| .gitignore | Task 1 |
|
||||
| Makefile | Task 8 |
|
||||
| pyproject.toml(扩展) | Task 5 |
|
||||
| .env.example | Task 6 |
|
||||
| config/default.yaml | Task 7 |
|
||||
| research-wiki/overview.md | Task 9 |
|
||||
| writing-plans Skill 修改(核心算法保真校验) | Task 10 |
|
||||
| subagent-driven-development Skill 修改(保真审查 + 环境名) | Task 11 |
|
||||
| Git 初始化 | Task 1 |
|
||||
| ARQ 替代 AWQ(修正)| Task 5 (arq 依赖), Task 6 (REDIS_URL), Task 7 (无 harness ARQ 配置——YAGNI,ARQ 配置在代码实现时再加) |
|
||||
|
||||
**2. Placeholder 扫描**:无 TBD/TODO/placeholder。
|
||||
|
||||
**3. 类型一致性**:Conda 环境名全文统一为 `Video-Tree-TRM`;目录结构全文统一为 Task 2 中定义的树形图。
|
||||
@@ -0,0 +1,3 @@
|
||||
# Query Pack
|
||||
|
||||
> 尚无数据。运行 research-lit 或 idea-creator 后自动生成。
|
||||
Reference in New Issue
Block a user