From a03a188b05efbbe30b402233f632922cab09ad8e Mon Sep 17 00:00:00 2001 From: Tenstu <54093040+Tenstu@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:01:45 +0800 Subject: [PATCH] feat: publish exam-memory V2 public harness updates --- .gitignore | 32 +- README.md | 97 +- README_CN.md | 97 +- START_HERE.md | 34 +- algorithms/mistake_log.md | 206 ---- algorithms/mock_exam_log.md | 54 -- .../solutions/example_ring_substring.md | 137 --- daily/README.md | 39 - exam_memory/pyproject.toml | 29 - exam_memory/rebuild_index.py | 50 - llm/transformer-forward-pass.md | 109 --- llm/transformer-review.md | 270 ------ prompts/new_session_prompt.md | 26 +- pytest.ini | 3 + .../cheatsheets/.gitkeep | 0 .../cheatsheets}/agent_project_pitch.md | 0 .../cheatsheets}/llm_core_cheatsheet.md | 0 .../cheatsheets}/sft_lora_miniproject.md | 0 shared/exam_memory/__init__.py | 15 + shared/exam_memory/bank/README.md | 15 + shared/exam_memory/chunking.py | 96 ++ .../exam_memory}/embedding.py | 43 +- shared/exam_memory/frontmatter.py | 38 + shared/exam_memory/fts_store.py | 194 ++++ shared/exam_memory/hybrid_search.py | 114 +++ shared/exam_memory/knowledge_source.py | 171 ++++ shared/exam_memory/pyproject.toml | 40 + shared/exam_memory/question_bank.py | 884 ++++++++++++++++++ shared/exam_memory/rebuild_index.py | 132 +++ {exam_memory => shared/exam_memory}/server.py | 267 +++++- shared/exam_memory/source_connector.py | 187 ++++ shared/exam_memory/source_registry.py | 78 ++ shared/exam_memory/sources.yaml | 16 + .../exam_memory}/vector_store.py | 33 +- shared/exam_memory/vectorstore/.gitkeep | 0 skills/algo-annotation.md | 6 +- skills/choice-q-create.md | 128 +-- skills/choice-q-drill.md | 84 +- skills/exam-assistant.md | 28 +- skills/init-guide.md | 43 +- skills/review-tracker.md | 64 +- skills/solve-analyze/SKILL.md | 32 +- .../references/comparison-template.md | 6 +- .../references/root-cause-tags.md | 4 +- skills/solve-skeleton/SKILL.md | 4 +- .../references/exam-patterns.md | 2 +- .../ai-lab/cheatsheets}/ai_lab_context.md | 0 .../cheatsheets}/gnn_diffusion_cheatsheet.md | 0 .../ai-lab/cheatsheets}/math_fundamentals.md | 0 targets/ai-lab/exam_config.md | 29 + .../ai-lab/prompts}/daily_review_prompt.md | 0 .../ai-lab/prompts}/mock_exam_prompt.md | 0 .../sources}/ai_lab_history_problems.md | 0 .../ai-lab/sources}/source_index.md | 0 targets/exam_config_template.md | 30 + targets/pdd-algo/cheatsheets/.gitkeep | 0 targets/pdd-algo/exam_config.md | 28 + targets/pdd-algo/practice/.gitkeep | 0 .../pdd-algo}/practice/bfs_grid.py | 0 .../pdd-algo}/practice/sliding_window.py | 0 .../pdd-algo}/python_oj_template.py | 0 .../pdd-algo}/solutions_batch.py | 0 .../pdd-algo}/topic_checklist.md | 0 tests/conftest.py | 25 + tests/test_knowledge_source.py | 375 ++++++++ tests/test_question_bank.py | 819 ++++++++++++++++ tests/test_source_connector.py | 235 +++++ 67 files changed, 4185 insertions(+), 1263 deletions(-) delete mode 100644 algorithms/mistake_log.md delete mode 100644 algorithms/mock_exam_log.md delete mode 100644 algorithms/solutions/example_ring_substring.md delete mode 100644 daily/README.md delete mode 100644 exam_memory/pyproject.toml delete mode 100644 exam_memory/rebuild_index.py delete mode 100644 llm/transformer-forward-pass.md delete mode 100644 llm/transformer-review.md create mode 100644 pytest.ini rename exam_memory/__init__.py => shared/cheatsheets/.gitkeep (100%) rename {llm => shared/cheatsheets}/agent_project_pitch.md (100%) rename {llm => shared/cheatsheets}/llm_core_cheatsheet.md (100%) rename {llm => shared/cheatsheets}/sft_lora_miniproject.md (100%) create mode 100644 shared/exam_memory/__init__.py create mode 100644 shared/exam_memory/bank/README.md create mode 100644 shared/exam_memory/chunking.py rename {exam_memory => shared/exam_memory}/embedding.py (62%) create mode 100644 shared/exam_memory/frontmatter.py create mode 100644 shared/exam_memory/fts_store.py create mode 100644 shared/exam_memory/hybrid_search.py create mode 100644 shared/exam_memory/knowledge_source.py create mode 100644 shared/exam_memory/pyproject.toml create mode 100644 shared/exam_memory/question_bank.py create mode 100644 shared/exam_memory/rebuild_index.py rename {exam_memory => shared/exam_memory}/server.py (54%) create mode 100644 shared/exam_memory/source_connector.py create mode 100644 shared/exam_memory/source_registry.py create mode 100644 shared/exam_memory/sources.yaml rename {exam_memory => shared/exam_memory}/vector_store.py (90%) create mode 100644 shared/exam_memory/vectorstore/.gitkeep rename {llm => targets/ai-lab/cheatsheets}/ai_lab_context.md (100%) rename {llm => targets/ai-lab/cheatsheets}/gnn_diffusion_cheatsheet.md (100%) rename {llm => targets/ai-lab/cheatsheets}/math_fundamentals.md (100%) create mode 100644 targets/ai-lab/exam_config.md rename {prompts => targets/ai-lab/prompts}/daily_review_prompt.md (100%) rename {prompts => targets/ai-lab/prompts}/mock_exam_prompt.md (100%) rename {sources => targets/ai-lab/sources}/ai_lab_history_problems.md (100%) rename {sources => targets/ai-lab/sources}/source_index.md (100%) create mode 100644 targets/exam_config_template.md create mode 100644 targets/pdd-algo/cheatsheets/.gitkeep create mode 100644 targets/pdd-algo/exam_config.md create mode 100644 targets/pdd-algo/practice/.gitkeep rename {algorithms => targets/pdd-algo}/practice/bfs_grid.py (100%) rename {algorithms => targets/pdd-algo}/practice/sliding_window.py (100%) rename {algorithms => targets/pdd-algo}/python_oj_template.py (100%) rename {algorithms => targets/pdd-algo}/solutions_batch.py (100%) rename {algorithms => targets/pdd-algo}/topic_checklist.md (100%) create mode 100644 tests/conftest.py create mode 100644 tests/test_knowledge_source.py create mode 100644 tests/test_question_bank.py create mode 100644 tests/test_source_connector.py diff --git a/.gitignore b/.gitignore index 69a423c..ff87ffc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,11 +22,14 @@ Thumbs.db input.txt transformer-forward-pass.md transformer-review.md +.tmp/ # 考试经验沉淀系统 - 运行时个人数据(代码和配置应被跟踪) -exam_memory/experiences/ -exam_memory/user_profile.json -exam_memory/vectorstore/ +shared/exam_memory/experiences/ +shared/exam_memory/bank/*.md +!shared/exam_memory/bank/README.md +shared/exam_memory/user_profile.json +shared/exam_memory/vectorstore/ # V2 临时验证脚本(不提交) _extract.py @@ -34,10 +37,19 @@ _test_v2.py _verify_v2.py _verify_result.json +# V2 review regression tests(dev-only,不同步 clean-main) +tests/test_chunking.py +tests/test_fts_store.py +tests/test_hybrid_search.py +tests/test_security.py +tests/test_server.py +tests/test_vector_store.py + # 个人备考数据(保留目录框架,忽略实际数据文件) # HANDOFF.md 作为模板跟踪(每轮备考时填写具体数据,提交前应清空) daily/* !daily/README.md +shared/daily/*.md progress/choice-questions/* !progress/choice-questions/.gitkeep @@ -49,13 +61,25 @@ progress/task-board/* !progress/task-board/.gitkeep progress/reviews/* !progress/reviews/.gitkeep +shared/progress/** +!shared/progress/README.md +!shared/progress/**/.gitkeep +targets/*/progress/** +!targets/*/progress/**/.gitkeep +targets/*/mistake_log.md +targets/*/mock_exam_log.md # MCP 与个人记忆工具(仅本地,不入 git) .mcp.json .claude/ +.codex/ +.serena/ .chatmem/ .mempalace/ # 个人分支私有文件(dev-only,不允许合并到 clean-main) skills/branch-ops.md -docs/branch-workflow.md +skills/harness-dev-flow/ +skills/dev-review-flow/ +docs/ +prompts/review-fix-session-prompt.md diff --git a/README.md b/README.md index c146645..c283052 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Pass-LLM-with-LLM +# pass-llm-with-llm > Use LLM to pass LLM exams — a Claude Code Skills + MCP powered AI exam preparation engine @@ -34,19 +34,23 @@ Built for AI/算法岗位笔试 preparation, but the core Skill Pipeline is exam | Model Provider | Any provider supported by Claude Code (Claude API, third-party, local) | | Python | 3.10+ (only for exam-memory MCP Server) | -This project was developed using the **Claude Code VS Code extension** with third-party model providers (Xiaomi mimo-v2.5-pro, StepFun step-3.7-flash). The Skill Pipeline is model-agnostic — any capable model works. See [Environment Support](docs/environment-support.md) for details. +This project was developed using the **Claude Code VS Code extension** with third-party model providers. The Skill Pipeline is model-agnostic — any capable model works. ### Install ```bash -git clone https://github.com/Tenstu/Pass-LLM-with-LLM.git -cd Pass-LLM-with-LLM +git clone https://github.com/Tenstu/pass-llm-with-llm.git +cd pass-llm-with-llm ``` +### Configure Your Target Exam + +Edit `HANDOFF.md` with your exam name, date, and daily study hours. Update `targets/{target}/sources/` with your exam's historical patterns. + ### Use 1. Open the project in Claude Code -2. **First time?** Say "init" or "初始化" — the init-guide skill will collect your exam target, date, and daily study hours, then configure `HANDOFF.md`, `sources/`, `AGENTS.md`, and user profile automatically +2. **First time?** Say "init" or "初始化" to launch the onboarding guide — it collects your exam target, date, and scope 3. Daily use: read `START_HERE.md` for session bootstrap 4. For algorithm problems: `Skill(skill="solve-skeleton")` 5. For diagnosis: `Skill(skill="solve-analyze")` @@ -55,22 +59,21 @@ cd Pass-LLM-with-LLM ### Startup Order ``` -git clone → cd Pass-LLM-with-LLM +git clone → cd pass-llm-with-llm │ ├── pip install mcp # optional: for exam-memory MCP server │ - ├── edit .mcp.json # register exam-memory (see docs/mcp-setup-guide.md) + ├── edit .mcp.json # register exam-memory, pointing to shared/exam_memory/server.py │ ├── open in Claude Code │ │ - │ ├── first time → say "init" → init-guide auto-configures everything - │ │ (HANDOFF.md, sources/, AGENTS.md, user profile) + │ ├── first time → say "init" → init-guide Skill walks you through setup │ │ │ └── daily use → read START_HERE.md → Skill Pipeline │ └── (optional) configure external MCPs: ChatMem, mempalace, onefind these are environment-level, not project-bundled - see docs/mcp-setup-guide.md for references + configure these in your Claude Code environment if needed ``` ### MCP Dependencies @@ -84,7 +87,7 @@ This project **bundles one MCP server** (`exam-memory`) and **references externa | mempalace | No | Structured knowledge storage | [External install](https://github.com/MemPalace/mempalace) | | onefind | No | Local knowledge base retrieval | [External install](https://github.com/iawnfoanaowt/OneFind) | -All skills degrade gracefully to local-only mode when MCP is unavailable. See [MCP Setup Guide](docs/mcp-setup-guide.md) for full configuration instructions. +All skills degrade gracefully to local-only mode when MCP is unavailable. To enable the bundled server, register `.mcp.json` with a stdio command that runs `shared/exam_memory/server.py`. ## Skill Reference @@ -112,7 +115,7 @@ All skills with "Optional" MCP degrade gracefully to local-only mode when MCP is | init-guide | Remembers previous onboarding attempts; avoids re-collecting known info | | solve-analyze | Links diagnosis history across sessions for pattern recognition | -Install ChatMem and register it in your Claude Code global config. See [MCP Setup Guide](docs/mcp-setup-guide.md). +Install ChatMem and register it in your Claude Code global config. ### MemPalace Enhancement (Optional) @@ -133,21 +136,21 @@ Best paired with review-tracker (knowledge graph for coverage gaps) and exam-ass | Use Case | How It Helps | |----------|-------------| | Obsidian notes | Search your existing ML/algorithm notes for related concepts when practicing | -| Zotero library | Retrieve reference papers for Transformer, GNN, Diffusion topics in `llm/` cheatsheets | +| Zotero library | Retrieve reference papers for Transformer, GNN, Diffusion topics in `shared/cheatsheets/` or `targets/{target}/cheatsheets/` | | Hybrid search | Combine lexical + semantic search across all local sources | Best paired with choice-q-create (search notes for question material), exam-assistant (retrieve references during explanation), and review-tracker (check if your notes cover the required topics). Not directly called by any bundled skill — use OneFind tools manually via Claude Code. #### OneFind + exam-memory: Complementary Search Layers -OneFind's **folder source** can index `exam_memory/experiences/` for semantic search. However, OneFind is designed as a **read-only retrieval** layer — it cannot replace exam-memory's write-through pipeline (save experience → vectorize → store atomically). The recommended setup: +OneFind's **folder source** can index `shared/exam_memory/experiences/` for semantic search. However, OneFind is designed as a **read-only retrieval** layer — it cannot replace exam-memory's write-through pipeline (save experience → vectorize → store atomically). The recommended setup: | Layer | Role | Write | Read | |-------|------|:-----:|:----:| | `exam-memory` MCP | Experience CRUD + error counting + user profiling | Yes (save, update) | Yes (list, filter by type) | | OneFind folder source | Semantic search overlay on experience files | No (index refresh only) | Yes (semantic + keyword) | -**Setup**: Configure OneFind's `folder_library` to point at `exam_memory/experiences/`, then use `onefind_search` with `target="folder"` for semantic retrieval of past experiences. After saving new experiences via MCP, trigger `onefind_index_refresh` to pick up changes. +**Setup**: Configure OneFind's `folder_library` to point at `shared/exam_memory/experiences/`, then use `onefind_search` with `target="folder"` for semantic retrieval of past experiences. After saving new experiences via MCP, trigger `onefind_index_refresh` to pick up changes. ## Roadmap @@ -184,7 +187,7 @@ Upgrade `exam-memory` from keyword matching to semantic search: ## Directory Structure ``` -Pass-LLM-with-LLM/ +pass-llm-with-llm/ AGENTS.md # Project rules, Component Map, Skill Pipeline START_HERE.md # Session bootstrap + Skill invocation guide HANDOFF.md # Session handoff template @@ -201,39 +204,47 @@ Pass-LLM-with-LLM/ exam-assistant.md # MCP-backed exam assistant review-tracker.md # Progress aggregation - algorithms/ # OJ code assets - python_oj_template.py # Utility function library - solutions_batch.py # Exam problem solution collection - practice/ # Practice problems by topic - mistake_log.md # WA/TLE error patterns - topic_checklist.md # Topic coverage tracking - - llm/ # AI/ML quick-reference notes - llm_core_cheatsheet.md # Transformer, SFT/LoRA, RLHF, RAG, KV cache - gnn_diffusion_cheatsheet.md - math_fundamentals.md - ai_lab_context.md # Target company/lab background - - exam_memory/ # Custom MCP Server (optional) - server.py # 6 MCP tools for experience persistence - experiences/ # Experience files (YAML frontmatter + Markdown) - user_profile.json # User strengths/weaknesses/preferences - - daily/ # Daily plans (YYYY-MM-DD.md) - progress/ # Progress tracking by category - sources/ # External reference materials + targets/ # Target-specific exam content + ai-lab/ + exam_config.md # Exam format and scoring parameters + cheatsheets/ # Target-specific AI/ML quick-reference notes + daily/ # Target-specific daily plans + progress/ # Choice rounds, study planning, exam analysis, task board + prompts/ # Target-specific prompt templates + sources/ # Historical patterns and target-specific references + pdd-algo/ + exam_config.md # PDD algorithm exam configuration + python_oj_template.py # Utility function library + solutions_batch.py # Exam problem solution collection + practice/ # Practice problems by topic + solutions/ # Individual solution write-ups + mistake_log.md # WA/TLE error patterns + topic_checklist.md # Topic coverage tracking + progress/ # Target-specific progress tracking + + shared/ # Cross-target shared content + cheatsheets/ # Generic LLM/ML/project quick-reference notes + daily/ # Shared daily plans (YYYY-MM-DD.md) + exam_memory/ # Custom MCP server and experience store + server.py # MCP tools for experience persistence + experiences/ # Experience files (YAML frontmatter + Markdown) + user_profile.json # User strengths/weaknesses/preferences + progress/ # Shared progress/task-board files + prompts/ # Generic prompt templates + + algorithms/ # Legacy stub; active OJ assets live under targets/ + exam_memory/ # Legacy stub; active MCP code lives under shared/exam_memory/ + progress/ # Legacy stub; active progress lives under shared/ or targets/ prompts/ # Prompt templates - docs/ # Design documents, setup guides, and license - mcp-setup-guide.md # MCP configuration: exam-memory + external MCP references ``` ## Adapting to Other Exams -The framework defaults to AI Lab exam format but is designed to be reconfigured: +The framework defaults to the configured target in `HANDOFF.md` and is designed to be reconfigured: -1. Replace `sources/ai_lab_history_problems.md` with your target exam's pattern analysis -2. Update `skills/choice-q-create.md` question generation parameters -3. Swap `llm/` notes with your domain's core knowledge +1. Add or replace files under `targets/{target}/sources/` with your target exam's pattern analysis +2. Update `targets/{target}/exam_config.md` with question counts, scoring, and timing +3. Put target-specific notes under `targets/{target}/cheatsheets/`; keep reusable notes under `shared/cheatsheets/` 4. Adjust `AGENTS.md` Exam Format table ## Contributing diff --git a/README_CN.md b/README_CN.md index ad4ae94..3fa5e90 100644 --- a/README_CN.md +++ b/README_CN.md @@ -1,4 +1,4 @@ -# Pass-LLM-with-LLM +# pass-llm-with-llm > 用 LLM 备考 LLM 笔试 — 基于 Claude Code Skills + MCP 的 AI 笔试备考引擎 @@ -34,19 +34,23 @@ | Model Provider | 支持 Claude Code 接入的任意 provider(Claude API、第三方、本地模型) | | Python | 3.10+(仅 exam-memory MCP Server 需要) | -本项目基于 **Claude Code VS Code 扩展**开发,使用第三方 model provider(小米 mimo-v2.5-pro、阶跃星辰 step-3.7-flash)。Skill Pipeline 与底层模型无关,任意可用模型均可运行。详见[环境支持文档](docs/environment-support.md)。 +本项目基于 **Claude Code VS Code 扩展**开发,使用第三方 model provider。Skill Pipeline 与底层模型无关,任意可用模型均可运行。 ### 安装 ```bash -git clone https://github.com/Tenstu/Pass-LLM-with-LLM.git -cd Pass-LLM-with-LLM +git clone https://github.com/Tenstu/pass-llm-with-llm.git +cd pass-llm-with-llm ``` +### 配置目标考试 + +编辑 `HANDOFF.md`,填写你的目标考试名称、日期和每日可投入时间。在 `targets/{target}/sources/` 下补充目标考试的历史题型分析。 + ### 使用 1. 在 Claude Code 中打开项目目录 -2. **首次使用?** 说 "init" 或 "初始化" — init-guide 会收集你的备考目标、日期和每日可投入时间,自动配置 `HANDOFF.md`、`sources/`、`AGENTS.md` 和用户画像 +2. **首次使用?** 说 "init" 或 "初始化" 启动导引流程 — 收集备考目标、考试范围、目标日期 3. 日常使用:阅读 `START_HERE.md` 了解 session 启动流程 4. 遇到算法题:`Skill(skill="solve-skeleton")` 5. 需要诊断:`Skill(skill="solve-analyze")` @@ -55,22 +59,21 @@ cd Pass-LLM-with-LLM ### 启动顺序 ``` -git clone → cd Pass-LLM-with-LLM +git clone → cd pass-llm-with-llm │ ├── pip install mcp # 可选:安装 exam-memory MCP Server 依赖 │ - ├── 编辑 .mcp.json # 注册 exam-memory(见 docs/mcp-setup-guide.md) + ├── 编辑 .mcp.json # 注册 exam-memory,指向 shared/exam_memory/server.py │ ├── 在 Claude Code 中打开项目 │ │ - │ ├── 首次使用 → 说 "init" → init-guide 自动完成全部配置 - │ │ (HANDOFF.md、sources/、AGENTS.md、用户画像) + │ ├── 首次使用 → 说 "init" → init-guide Skill 引导完成配置 │ │ │ └── 日常使用 → 读 START_HERE.md → Skill Pipeline │ └── (可选)配置环境级 MCP:ChatMem、mempalace、onefind 这些是外部工具,不由项目自带 - 参考 docs/mcp-setup-guide.md + 如需使用,请在 Claude Code 环境中配置 ``` ### MCP 依赖说明 @@ -84,7 +87,7 @@ git clone → cd Pass-LLM-with-LLM | mempalace | 否 | 结构化知识存储 | [外部安装](https://github.com/MemPalace/mempalace) | | onefind | 否 | 本地知识库检索 | [外部安装](https://github.com/iawnfoanaowt/OneFind) | -所有 Skill 在 MCP 不可用时自动降级为纯本地模式。详见 [MCP 配置指南](docs/mcp-setup-guide.md)。 +所有 Skill 在 MCP 不可用时自动降级为纯本地模式。若要启用项目自带 server,在 `.mcp.json` 中注册一个 stdio 命令运行 `shared/exam_memory/server.py`。 ## Skill 一览 @@ -112,7 +115,7 @@ git clone → cd Pass-LLM-with-LLM | init-guide | 记忆上次配置过程,避免重复收集已知信息 | | solve-analyze | 跨会话关联诊断历史,识别反复出现的错误模式 | -安装 ChatMem 并在 Claude Code 全局配置中注册。详见 [MCP 配置指南](docs/mcp-setup-guide.md)。 +安装 ChatMem 并在 Claude Code 全局配置中注册。 ### MemPalace 增强(可选) @@ -133,21 +136,21 @@ git clone → cd Pass-LLM-with-LLM | 使用场景 | 增强效果 | |---------|---------| | Obsidian 笔记 | 练习时搜索已有的 ML/算法笔记,关联相关概念 | -| Zotero 文献库 | 为 `llm/` 速记资料中的 Transformer、GNN、Diffusion 主题检索参考论文 | +| Zotero 文献库 | 为 `shared/cheatsheets/` 或 `targets/{target}/cheatsheets/` 中的 Transformer、GNN、Diffusion 主题检索参考论文 | | 混合检索 | 跨所有本地源执行词法 + 语义联合搜索 | 适合与 choice-q-create(从笔记中搜索出题素材)、exam-assistant(解答时检索参考资料)和 review-tracker(检查笔记是否覆盖考试知识点)配合使用。项目内 Skill 不直接调用 OneFind,通过 Claude Code 手动使用其工具。 #### OneFind + exam-memory:互补检索层 -OneFind 的 **folder source** 可以索引 `exam_memory/experiences/` 目录,提供语义搜索能力。但 OneFind 是**只读检索**层设计,无法替代 exam-memory 的写入链路(保存经验 → 向量化 → 原子存储)。推荐组合方案: +OneFind 的 **folder source** 可以索引 `shared/exam_memory/experiences/` 目录,提供语义搜索能力。但 OneFind 是**只读检索**层设计,无法替代 exam-memory 的写入链路(保存经验 → 向量化 → 原子存储)。推荐组合方案: | 层级 | 角色 | 写入 | 读取 | |------|------|:----:|:----:| | `exam-memory` MCP | 经验 CRUD + 错误计数 + 用户画像 | 是(save, update) | 是(list, 按类型过滤) | | OneFind folder source | 经验文件的语义搜索覆盖层 | 否(仅索引刷新) | 是(语义 + 关键词) | -**配置方式**:将 OneFind 的 `folder_library` 指向 `exam_memory/experiences/`,然后使用 `onefind_search`(`target="folder"`)对历史经验做语义检索。通过 MCP 保存新经验后,调用 `onefind_index_refresh` 触发索引更新。 +**配置方式**:将 OneFind 的 `folder_library` 指向 `shared/exam_memory/experiences/`,然后使用 `onefind_search`(`target="folder"`)对历史经验做语义检索。通过 MCP 保存新经验后,调用 `onefind_index_refresh` 触发索引更新。 ## 路线图 @@ -184,7 +187,7 @@ OneFind 的 **folder source** 可以索引 `exam_memory/experiences/` 目录, ## 目录结构 ``` -Pass-LLM-with-LLM/ +pass-llm-with-llm/ AGENTS.md # 项目规则、Component Map、Skill Pipeline START_HERE.md # Session 启动引导 + Skill 调用指南 HANDOFF.md # Session 交接模板 @@ -201,39 +204,47 @@ Pass-LLM-with-LLM/ exam-assistant.md # MCP 考试助手 review-tracker.md # 进度汇总 - algorithms/ # OJ 代码资产 - python_oj_template.py # 工具函数库 - solutions_batch.py # 考试真题解答集 - practice/ # 按专题分类的练习题 - mistake_log.md # WA/TLE 错误模式记录 - topic_checklist.md # 知识点覆盖追踪 - - llm/ # AI/ML 速记资料 - llm_core_cheatsheet.md # Transformer, SFT/LoRA, RLHF, RAG, KV cache - gnn_diffusion_cheatsheet.md - math_fundamentals.md - ai_lab_context.md # 目标公司/实验室背景 - - exam_memory/ # 自定义 MCP Server(可选) - server.py # 6 个 MCP 工具,经验持久化 - experiences/ # 经验文件(YAML frontmatter + Markdown) - user_profile.json # 用户画像(强弱项、偏好) - - daily/ # 每日计划(YYYY-MM-DD.md) - progress/ # 按主题分类的进度追踪 - sources/ # 外部参考材料 + targets/ # 目标考试专属内容 + ai-lab/ + exam_config.md # 考试题型、分值、时间配置 + cheatsheets/ # 目标专属 AI/ML 速记资料 + daily/ # 目标专属每日计划 + progress/ # 选择题轮次、学习计划、考试分析、任务看板 + prompts/ # 目标专属 prompt 模板 + sources/ # 历史题型与目标专属参考资料 + pdd-algo/ + exam_config.md # PDD 算法笔试配置 + python_oj_template.py # 工具函数库 + solutions_batch.py # 考试真题解答集 + practice/ # 按专题分类的练习题 + solutions/ # 单题题解 + mistake_log.md # WA/TLE 错误模式记录 + topic_checklist.md # 知识点覆盖追踪 + progress/ # 目标专属进度追踪 + + shared/ # 跨目标共享内容 + cheatsheets/ # 通用 LLM/ML/项目表达速记资料 + daily/ # 共享每日计划(YYYY-MM-DD.md) + exam_memory/ # 自定义 MCP server 与经验库 + server.py # MCP 工具,经验持久化 + experiences/ # 经验文件(YAML frontmatter + Markdown) + user_profile.json # 用户画像(强弱项、偏好) + progress/ # 共享进度与 task-board 文件 + prompts/ # 通用 prompt 模板 + + algorithms/ # 兼容占位;活跃 OJ 资产在 targets/ 下 + exam_memory/ # 兼容占位;活跃 MCP 代码在 shared/exam_memory/ + progress/ # 兼容占位;活跃进度在 shared/ 或 targets/ 下 prompts/ # Prompt 模板 - docs/ # 设计文档、配置指南与许可证 - mcp-setup-guide.md # MCP 配置指南:exam-memory 注册 + 外部 MCP 引用 ``` ## 适配其他考试 -默认配置针对 AI Lab 笔试,但核心机制可复用: +默认使用 `HANDOFF.md` 中配置的目标考试,核心机制可复用: -1. 替换 `sources/ai_lab_history_problems.md` 为目标考试的题型分析 -2. 调整 `skills/choice-q-create.md` 中的出题参数 -3. 更新 `llm/` 下的速记资料为目标领域知识 +1. 在 `targets/{target}/sources/` 下放入目标考试的题型分析 +2. 更新 `targets/{target}/exam_config.md` 中的题数、分值和时间 +3. 将目标专属资料放入 `targets/{target}/cheatsheets/`,可复用资料放入 `shared/cheatsheets/` 4. 修改 `AGENTS.md` 中的 Exam Format 表格 ## Contributing diff --git a/START_HERE.md b/START_HERE.md index 3ba5190..41dfd14 100644 --- a/START_HERE.md +++ b/START_HERE.md @@ -10,17 +10,17 @@ Skill(skill="init-guide") 触发词:"初始化"、"init"、"第一次用"、"开始配置"、"换一个考试目标" -init-guide 会引导你填写备考目标、考试范围、目标日期、每日投入时间,并自动更新 HANDOFF.md、sources/、AGENTS.md 和用户画像。 +init-guide 会引导你填写备考目标、考试范围、目标日期,并自动更新配置文件。 -> **MCP 配置**:如需启用跨会话经验持久化,参见 `docs/mcp-setup-guide.md`。所有 Skill 在 MCP 不可用时自动降级为纯本地模式。 +> **MCP 配置**:如需启用跨会话经验持久化,在 `.mcp.json` 中注册 stdio 命令运行 `shared/exam_memory/server.py`。所有 Skill 在 MCP 不可用时自动降级为纯本地模式。 ## 新 Session 先读 1. `AGENTS.md`:确认目标、优先级和限制,尤其是 Skill Pipeline 和 Component Map。 2. `HANDOFF.md`:了解上次完成了什么。 -3. 今天对应的 `daily/YYYY-MM-DD.md`:直接执行当天任务,底部 Problem Log 记录每题结果。 -4. `progress/task-board/task-board.md`:更新任务状态。 -5. `algorithms/mistake_log.md` 和 `algorithms/mock_exam_log.md`:复盘最近错误。 +3. 今天对应的 `shared/daily/YYYY-MM-DD.md`:直接执行当天任务,底部 Problem Log 记录每题结果。 +4. `targets/{target}/progress/task-board.md` 或 `shared/progress/task-board.md`:更新任务状态。 +5. `targets/{target}/mistake_log.md` 和 `targets/{target}/mock_exam_log.md`:复盘最近错误。 6. `Skill(skill="review-tracker")`:快速查看跨维度进度报告和今日必做清单。 ## Skill Pipeline(必须遵守) @@ -38,14 +38,14 @@ Solve Skeleton → Fill TODOs → Anti-pattern check → Test → [solve-analyze 2. **solve-analyze** — solve() 写完并测试后,如需诊断错误,调用 `Skill(skill="solve-analyze")`。 - 并行运行两条路径:Agent A 静态分析用户代码 + Agent B 生成标准解法 - 输出结构化对比报告:差异表格、根因标签(从 `root-cause-tags.md` 匹配)、建议修正 - - 自动追加到 `algorithms/mistake_log.md`(去重) + - 自动追加到 `targets/{target}/mistake_log.md`(去重) - **可选 MCP**:`update_user_profile` + `save_experience` / `inc_error_count`(MCP 不可用时跳过) - 触发词:"分析一下我的代码"、"帮我看看哪里错了"、"为什么WA"、"为什么超时"、"解法对比" - **何时调用**:WA/TLE 时必须调用;AC 时可选(仅做规范差异检查) 3. **algo-annotation** — solve() 写完后(或 solve-analyze 诊断后),调用 `Skill(skill="algo-annotation")`。 - 添加中文行级注释 - - 标注 `# [防错]`(引用 `algorithms/mistake_log.md`)和 `# [双重角色]` + - 标注 `# [防错]`(引用 `targets/{target}/mistake_log.md`)和 `# [双重角色]` - 一句话总结核心不变量 ### 选择题标准流程 @@ -56,13 +56,13 @@ choice-q-create → choice-q-drill → mistake_log → (回到 choice-q-create) 3. **solve-analyze** — 需要诊断解法错误时,调用 `Skill(skill="solve-analyze")`。 - 对比用户代码 vs 标准解法,提取根因标签,生成结构化报告 - - 自动追加到 `algorithms/mistake_log.md`(去重) + - 自动追加到 `targets/{target}/mistake_log.md`(去重) - 可选 MCP:画像更新 + 经验持久化(MCP 不可用时跳过) - 触发词:"分析一下我的代码"、"为什么WA"、"解法对比"、"代码诊断" 4. **choice-q-create** — 需要生成选择题组时,调用 `Skill(skill="choice-q-create")`。 - 根据指定主题(数学/AI/LLM/算法)生成一组定向多选题 - - 从 `algorithms/mistake_log.md` 读取高频错误模式,重点出易错考点 + - 从 `targets/{target}/mistake_log.md` 读取高频错误模式,重点出易错考点 - **可选 MCP 增强**:调用 `mcp__exam-memory__list_experiences()` 获取跨会话错误模式 - 输出结构化题集:题干 + 4 选项 + 正确答案 + 解析 - 触发词:"出几道选择题"、"生成选择题"、"choice question"、"帮我出题" @@ -70,7 +70,7 @@ choice-q-create → choice-q-drill → mistake_log → (回到 choice-q-create) 5. **choice-q-drill** — 拿到题集后,调用 `Skill(skill="choice-q-drill")` 进入交互答题。 - 使用 AskUserQuestion 工具逐题呈现,等待用户作答 - 即时反馈对错并给出解析 - - 答错题目自动追加到 `algorithms/mistake_log.md`(标记 `# [选择题错题]`) + - 答错题目自动追加到 `targets/{target}/mistake_log.md`(标记 `# [选择题错题]`) - **可选 MCP 双写**:同时调用 `mcp__exam-memory__save_experience()` 或 `mcp__exam-memory__inc_error_count()` - 答题完成后输出得分、错题汇总、薄弱主题建议 - 触发词:"开始答题"、"开始练习"、"drill"、"做选择题"、"quiz" @@ -108,22 +108,22 @@ Skill(skill="review-tracker") ## 今天怎么开始 -如果今天是 2026-06-09: +每天开始时: 1. 调用 `Skill(skill="solve-skeleton")` 熟悉 ACM 输入输出骨架。 2. 做 3-4 道数组/哈希/排序/二分基础题。 3. 每题限时 25-40 分钟,做完立即调用 `Skill(skill="algo-annotation")` 加注释。 -4. 把卡住原因同时写进 `algorithms/mistake_log.md` 和当天 Problem Log。 -5. 用 30-45 分钟读 `llm/llm_core_cheatsheet.md` 的 Transformer 部分。 +4. 把卡住原因同时写进 `targets/{target}/mistake_log.md` 和当天 Problem Log。 +5. 用 30-45 分钟读 `shared/cheatsheets/llm_core_cheatsheet.md` 或目标目录下的对应速记资料。 ## 每日最低闭环 - 算法:至少 3 道题,至少 1 道计时独立完成。 - 每道题走 solve-skeleton → solve-analyze(WA/TLE时) → algo-annotation 完整流程。 - - WA/TLE 必须记录到 `algorithms/mistake_log.md`(错误模式会回流到 annotation 的 `# [防错]`)。 + - WA/TLE 必须记录到 `targets/{target}/mistake_log.md`(错误模式会回流到 annotation 的 `# [防错]`)。 - 选择题:每天花 20-30 分钟做一个主题的数学/AI 选择题练习(AI 实验室题型)。 - 走 `choice-q-create` → `choice-q-drill` 完整流程,错题自动写入 `mistake_log.md`。 -- 记录:每做完一题,立即填写当天 `daily/YYYY-MM-DD.md` 底部的 **Problem Log**(题名、模式、解法、AC/错因、复杂度)。 +- 记录:每做完一题,立即填写当天 `shared/daily/YYYY-MM-DD.md` 底部的 **Problem Log**(题名、模式、解法、AC/错因、复杂度)。 - LLM:至少复习 1 个核心主题(Transformer/GNN/扩散模型/LLaMA3/数学基础),并能口头讲 2 分钟。 - 复盘:写 3 条今天最容易再犯的错误。 - 交接:更新 `HANDOFF.md` 的下一步动作。 @@ -131,7 +131,7 @@ Skill(skill="review-tracker") ## 反馈回路 ``` -algorithms/mistake_log.md algorithms/topic_checklist.md +targets/{target}/mistake_log.md targets/{target}/topic_checklist.md ▲ ▲ ▲ │ │ │ │ │ choice-q-drill │ P0/P1 优先级调整 @@ -165,4 +165,4 @@ algorithms/mistake_log.md algorithms/topic_checklist.md - 15 分钟复习 `skills/solve-skeleton/` 骨架模板。 - 35 分钟做 1 道中等题(走完整 solve-skeleton → annotation 流程)。 -- 10 分钟记录错因到当天 Problem Log 和 `mistake_log.md`。 \ No newline at end of file +- 10 分钟记录错因到当天 Problem Log 和 `mistake_log.md`。 diff --git a/algorithms/mistake_log.md b/algorithms/mistake_log.md deleted file mode 100644 index 87c8daa..0000000 --- a/algorithms/mistake_log.md +++ /dev/null @@ -1,206 +0,0 @@ -# Mistake Log(按主题分类) - -> **Harness 反馈回路的核心**:这个文件是 `algo-annotation` skill 的 `# [防错]` 标记数据源。 -> 每道 WA/TLE 题必须在这里录入一条规则,annotation 会自动读取并标注到代码上。 -> 考前只读这个文件。每行 = 一道题的防错规则,一行一规则。 -> 详细解题过程见对应 `daily/YYYY-MM-DD.md` 的 Problem Log。 -> 日期格式:MM-DD。 - -## Mastery 级别参考 - -| 值 | 含义 | 触发条件 | Redo Date | -|----|------|---------|-----------| -| WA | 答错 | 选错或漏选 | +1 天 | -| lucky_pass | 盲区(猜对) | correct + confidence = 完全不懂 | 当天 | -| partial | 半懂(猜对) | correct + confidence = 猜的有道理 | +1 天 | -| confirmed | 已掌握 | correct + confidence = 确定会 | 不设 | -| skip | 跳过 | 用户跳过此题 | +1 天 | - ---- - -## Root Cause 汇总(考前重点看高频标签) - -| Tag | 出现次数 | 说明 | -|-----|---------|------| -| pattern | 7 | 看到题型不知道怎么下笔(行列式、贝叶斯、KV cache公式、GQA、RAG流程、CNN感受野、偏差-方差) | -| proof | 15 | 概念记混或性质记错(对称矩阵、独立变量、CFG、GAT、过平滑、正交vs正定、GAT多头、QLoRA、位置编码、缩放因子、梯度消失、KV Cache/Flash、LoRA参数、Softmax全选、独立vs不相关) | -| python | 1 | 数值/常识记错(正态分布范围) | - ---- - -## 考前 Day 1 冲刺只看这 15 条(按高频排序) - -### 线性代数(4 条) -1. 对称矩阵可逆 → 逆也是对称的 -2. A² 特征值 = λᵢ²,排序不变——最小是 λₙ² 不是 λ₁² -3. 正交矩阵特征值模为1可含复数(旋转 e^{iθ});正定矩阵特征值才全正实数 -4. SVD 奇异值个数 = min(m,n),不是 max(m,n);Σ 维度 m×n 但非零奇异值只有 min(m,n) 个 - -### 概率统计(2 条) -5. 贝叶斯公式分子分母都算,三步走 -6. 独立 ⇒ 不相关,但不相关 ⇏ 独立。E 可加不需要独立,Var 可加需要独立。 - -### 深度学习 / DL 基础(3 条) -7. Softmax:四选项全对(A溢出 B减max C平移不变 D CE梯度ŷ-y) -8. 3×3 卷积 n 层感受野 = 2n+1(3层 = 7×7),不是 n² -9. ReLU 缓解但不能"完全避免"梯度消失——Dead ReLU(x≤0梯度=0) - -### Transformer / LLM 架构(3 条) -10. 位置编码只在输入层加一次,不是每层 -11. 缩放因子 √d_k 三选项全对(方差归一化 + 防one-hot + 数学依据) -12. LoRA 参数 = d×r + r×k;QLoRA = 降显存不是提效果 - -### 推理优化(2 条) -13. KV cache 公式 = 2(K+V) × 层数 × 头数 × 头维度 × 序列长度 × 字节数 -14. FlashAttention 不改 KV Cache 大小,只改 IO 模式(tiling) - -### GNN / 应用(1 条) -15. 过平滑 ≠ 过拟合;GAT 多头中间 concat,输出 mean - ---- - -## 掌握进度速查 - -> 由 choice-q-drill 每次答题后更新。更新频率:每次 drill session 结束。 - -| 知识点 | Mastery | 错误次数 | 盲区次数 | 最近状态 | -|--------|---------|---------|---------|---------| -| 行列式乘法 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| 对称矩阵逆 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| SVD奇异值 | struggling | 1 | 1 | WA→struggling (06-15) | -| 贝叶斯公式 | confirmed | 1 | 0 | WA→confirmed (06-14) | -| 独立vs不相关 | struggling | 1 | 1 | WA→struggling (06-15) | -| 位置编码 | struggling | 1 | 0 | WA→struggling (06-15) | -| 缩放因子√d_k | confirmed | 1 | 0 | WA→confirmed (06-15) | -| CNN感受野 | struggling | 1 | 0 | WA→struggling (06-15) | -| 梯度消失 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| KV Cache公式 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| GQA/MQA | confirmed | 1 | 0 | WA→confirmed (06-15) | -| QLoRA目的 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| GNN过平滑 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| GAT多头 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| RAG流程 | confirmed | 1 | 0 | WA→confirmed (06-15) | -| Softmax数值稳定 | confirmed | 1 | 0 | WA→confirmed (06-14) | -| CFG条件生成 | confirmed | 1 | 0 | WA→confirmed (06-14) | -| 偏差-方差 | blind_spot | 1 | 0 | WA→blind_spot (06-15) | -| SGD优化 | blind_spot | 1 | 1 | WA→blind_spot (06-15) | -| 矩阵条件数 | blind_spot | 1 | 0 | WA→blind_spot (06-15) | -| SwiGLU激活 | blind_spot | 1 | 0 | WA→blind_spot (06-15) | -| 量化策略 | blind_spot | 1 | 0 | WA→blind_spot (06-15) | -| 正交vs正定 | struggling | 1 | 1 | WA→struggling (06-15) | - ---- - -## 线性代数 / 矩阵 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-14 | Q1 det(A²) | 行列式乘法 | WA | WA | pattern | det(AB) = det(A)det(B),所以 det(A²) = det(A)² | 06-15 | -| 06-14 | Q2 对称矩阵逆 | 逆矩阵对称性 | WA | WA | proof | A 对称可逆 → A⁻¹ = (A⁻¹)ᵀ = (Aᵀ)⁻¹ = A⁻¹,逆也对称 | 06-15 | -| 06-15 | Q1 A²特征值排序 | 特征值排序 | WA | WA | pattern | A²特征值=λᵢ²,排序不变——最小是λₙ²不是λ₁² | 06-16 | -| 06-15 | Q9 正交vs正定 | 正交矩阵特征值 | WA | WA | proof | 正交矩阵特征值模为1但可含复数(如旋转e^{iθ});正定矩阵特征值才全正实数 | 06-16 | -| 06-15 | Q2 SVD奇异值个数 | SVD 奇异值 | WA | WA | pattern | 奇异值个数=min(m,n),不是max(m,n);Σ维度m×n但非零奇异值只有min(m,n)个 | 06-16 | - -### 防错规则:看到"对称矩阵"→ 自动想 (Aᵀ)ᵀ = A 和 (A⁻¹)ᵀ = A⁻¹ 两条。 -### 防错规则:SVD 中 Σ 的维度是 m×n,但非零奇异值只有 min(m,n) 个。不要混淆矩阵维度和奇异值个数。 - ---- - -## 概率统计 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-14 | Q4 贝叶斯 | 贝叶斯公式 | WA | WA | pattern | P(A\|B) = P(B\|A)P(A) / P(B),必须同时算分子分母,不能只看分子 | 06-15 | -| 06-14 | Q5 正态分布 | 正态分布 | WA | WA | python | 68-95-99.7 法则:P(\|X\|>1)≈0.32, P(\|X\|>2)≈0.045, P(X>2)≈0.0228 | 06-15 | -| 06-14 | Q13 独立随机变量 | 独立与期望/方差 | WA | WA | proof | 独立 ⇒ E[XY]=E[X]E[Y], Var(X+Y)=Var(X)+Var(Y);但 E[X+Y]=E[X]+E[Y] 不需要独立 | 06-15 | -| 06-15 | Q7 独立与不相关 | 独立 vs 不相关 | WA | WA | proof | 独立⇒E[XY]=E[X]E[Y]且Var(X+Y)=Var(X)+Var(Y);但不相关⇏独立(E[XY]=E[X]E[Y]仅说明线性无关) | 06-16 | - -### 防错规则:贝叶斯题三步走——(1)写贝叶斯公式 (2)算分子 (3)算分母 P(B)(用全概率)。 -### 防错规则:独立 ⇒ 不相关,但不相关 ⇏ 独立。E 可加不需要独立,Var 可加需要独立。 - ---- - -## Transformer / 注意力机制 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-15 | Q5 位置编码 | 位置编码/RoPE | WA | WA | proof | 位置编码只在输入层添加一次,不是每层;RoPE通过旋转Q/K编码相对位置 | 06-16 | -| 06-15 | Q6 缩放因子 | 注意力缩放因子 | WA | WA | proof | √d_k使点积方差归一化(var∝d_k),防止softmax退化;A/B/C三选项全对选D | 06-16 | - -### 防错规则:位置编码 vs 逐层归一化——位置编码只加一次,LayerNorm 才是每层都有。RoPE 通过旋转 Q/K 实现,不需要单独的编码向量。 -### 防错规则:缩放因子 √d_k——方差与 d_k 成正比,除后归一化;A(方差归一化) B(防one-hot) C(数学依据) 三选项全对。 - ---- - -## 机器学习 / 模型评估 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-15 | Q13 偏差方差权衡 | 偏差-方差权衡 | WA | WA | pattern | 高偏差→欠拟合,高方差→过拟合;增加数据减方差不减偏差 | 06-16 | - -### 防错规则:增加训练数据减少方差,不减偏差。减少偏差需要更强的模型或更好的特征。正则化→增偏差减方差。 - ---- - -## 深度学习 / ML - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-14 | Q7 Softmax | 数值稳定性 | WA | WA | proof | 数值稳定实现:先减最大值再 exp;平移不变性 softmax(z+c) 对常量 c 成立 | 06-15 | -| 06-15 | Q14 Softmax全选项 | Softmax 数值稳定/性质 | WA | WA | proof | Softmax四选项全对:溢出风险(A)、减max实现(B)、平移不变性(C)、CE梯度ŷ-y(D) | 06-16 | -| 06-15 | Q3 CNN感受野 | CNN 感受野 | WA | WA | pattern | 3×3卷积堆叠n层感受野=2n+1(递推RF=RF_prev+(k-1),3层=7×7) | 06-16 | -| 06-15 | Q8 梯度消失 | 梯度消失/爆炸 | WA | WA | proof | ReLU不能"完全避免"梯度消失(Dead ReLU问题);残差连接和梯度裁剪是有效手段 | 06-16 | - -### 防错规则:Softmax 题先看"数值稳定"→ 减 max 是必考点。 -### 防错规则:Softmax 选项逐一验证——A(溢出) B(减max) C(平移不变) D(CE梯度) 全部正确时选全选。 -### 防错规则:感受野递推 RF = RF_prev + (k-1),3×3 每层 +2,3层 = 7×7,不是 9×9。 -### 防错规则:ReLU 缓解但不能"完全避免"梯度消失——Dead ReLU(x≤0梯度=0)和深层负值累积仍是问题。 - ---- - -## 扩散模型 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-14 | Q14 DDPM/CFG | 条件生成 | WA | WA | proof | CFG 不需要额外分类器:ε' = ε_uncond + s·(ε_cond - ε_uncond) | 06-15 | - -### 防错规则:看到 CFG → 想到"线性外推",不需要分类器。 - ---- - -## GNN - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-14 | Q15 GNN 消息传递 | GAT/GNN 框架 | WA | WA | proof | GAT 注意力权重是**可学习的**(通过训练),不是固定由度决定 | 06-15 | -| 06-15 | Q3 过平滑vs过拟合 | GNN 过平滑 | WA | WA | proof | 过平滑=节点表征趋同(表达力退化),不是过拟合;增加边/层数会加剧 | 06-16 | -| 06-15 | Q13 GAT多头策略 | GAT 多头注意力 | WA | WA | proof | GAT多头:中间层用concat,输出层用mean(平均),不是全concat | 06-16 | - -### 防错规则:GAT vs GCN → GCN 权重固定(度归一化),GAT 权重可学习(注意力机制)。 -### 防错规则:GAT 多头:中间 concat,输出 mean。 - ---- - -## 推理优化 / 架构计算 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-15 | Q5 GQA/MQA/MHA | GQA KV cache 计算 | WA | WA | pattern | GQA=折中(如64Q→8KV,cache减到1/8);MQA=极端(1个KV,1/64) | 06-16 | -| 06-15 | Q6 KV Cache 显存 | KV Cache 公式 | WA | WA | pattern | 公式=2×layers×heads×head_dim×seq_len×bytes;别忘乘2(K+V)和层数 | 06-16 | -| 06-15 | Q12 QLoRA 目的 | LoRA/QLoRA | WA | WA | proof | QLoRA 核心是降显存(4-bit量化),不是提效果 | 06-16 | -| 06-15 | Q10 KV Cache/Flash | KV Cache / FlashAttention | WA | WA | proof | FlashAttention改IO模式(HBM↔SRAM),不改KV Cache大小;KV Cache减小靠GQA/MQA | 06-16 | -| 06-15 | Q11 LoRA参数效率 | LoRA 参数效率 | WA | WA | proof | LoRA参数=d×r+r×k;QLoRA=降显存不是提效果;GQA=MQA极端情况(1个KV head) | 06-16 | - -### 防错规则:KV cache 公式五要素——2(K+V) × 层数 × 头数 × 头维度 × 序列长度 × 字节数。GQA 8个KV头=减到1/8,MQA 1个KV头=减到1/64。 -### 防错规则:FlashAttention 不改 KV Cache 大小,只改内存访问模式(tiling 分块)。KV Cache 减小靠 GQA/MQA。 -### 防错规则:LoRA 参数量 = d×r + r×k;QLoRA = 降显存(4-bit量化),不是提效果。 - ---- - -## RAG / 应用层 - -| Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | -|---|---|---|---|---|---|---|---| -| 06-15 | Q8 RAG 流程排序 | RAG 架构 | WA | WA | pattern | 标准流程:Chunk→Embed→Retrieval(粗排)→Rerank(精排)→LLM生成 | 06-16 | - -### 防错规则:Rerank(精排/Cross-Encoder) 在 Retrieval(粗排/Bi-Encoder) 之后,LLM 生成之前。 diff --git a/algorithms/mock_exam_log.md b/algorithms/mock_exam_log.md deleted file mode 100644 index da050b2..0000000 --- a/algorithms/mock_exam_log.md +++ /dev/null @@ -1,54 +0,0 @@ -# Mock Exam Log - -## Summary - -| Date | Duration | Problems | AC | Partial | Failed | Main Bottleneck | Next Fix | -|---|---:|---:|---:|---:|---:|---|---| -| 06-14 | 25 min | 15 | 10 | 0 | 5 | 数学选择题基础薄弱(行列式/贝叶斯/正态/Softmax) | 背公式 + 概念题口述 | - -## Per-Problem Notes - -### 选择题 mock(15 题:10 单选 + 5 多选) - -- 单选正确率:**6/10(60%)** -- 多选正确率:**2/5 全对,其余各丢 1 分(共丢 5 分,实际 23/28)** -- 总分:**28/52(54%)** -- 主要错因: - - 行列式/逆矩阵基本性质不熟(Q1, Q2) - - 贝叶斯公式不会用(Q4) - - 正态分布数值记错(Q5) - - 多选题"独立随机变量"全套性质混淆(Q11 漏选, Q13 漏选) - - 概念题:CFG 不需要分类器(Q14 多选), GAT 注意力可学习(Q15 多选) -- 薄弱知识点:线性代数基础、贝叶斯、正态分布、独立变量性质 - -### 编程题目(练习收录) - -#### 字母环变换子串匹配(DP / 暴力枚举 + 环形距离) - -> 来源:笔试真题 2024 - -| 项目 | 值 | -|------|-----| -| 题型 | 编程(字符串变换 + 子串匹配) | -| 难度 | 中等 | -| 标签 | `暴力枚举` `环形距离` `子串匹配` | -| 来源文件 | `algorithms/solutions/example_ring_substring.md` | - -**题目摘要**:给定串 `s` 和 `t`(`len(s) ≤ len(t) ≤ 1000`),每次可将 `s` 中一个字母替换为字母表中相邻字母(环形,'a'与'z'相邻)。求最少替换次数使 `s` 成为 `t` 的子串。 - -**关键思路**: -- 将 `s` 与 `t` 的每个长度为 `len(s)` 的子串对齐 -- 对每个位置计算字符间的最小环形距离:`min(diff, 26 - diff)` -- 取所有对齐方式的最小总代价 -- 时间复杂度 O(|s| × |t|),本题最大 10^6 可接受 - -**复杂度**:O(|s| × |t|) 时间,O(1) 额外空间 - -**边界/防错**: -- 环形距离:`'a'`→`'z'` 只差 1 步 -- 输入可能只有一行?→ 用 `strip()` 处理空行 -- 答案可能为 0(s 本身已是 t 的子串) - -**参考实现**:`algorithms/solutions/example_ring_substring.md` - ---- diff --git a/algorithms/solutions/example_ring_substring.md b/algorithms/solutions/example_ring_substring.md deleted file mode 100644 index 559c4ae..0000000 --- a/algorithms/solutions/example_ring_substring.md +++ /dev/null @@ -1,137 +0,0 @@ -# 字母环变换子串匹配 - -> 字符串 + 子串匹配 | 中等难度 | 示例题解 - ---- - -## 问题描述 - -给定小写字母串 `s`(待替换)和 `t`(目标串),`|s| ≤ |t| ≤ 1000`。 - -每次操作:将 `s` 中**任意一个**字母替换为字母表中相邻的字母(环形,'a' 的相邻是 'b' 和 'z')。 - -求最少替换次数,使变换后的 `s` 成为 `t` 的**子串**。 - ---- - -## 关键概念:环形字母距离 - -字母表构成环:`a → b → c → ... → z → a`。 - -两个字母 `c1`, `c2` 之间的最小变换步数: - -```python -diff = abs(ord(c1) - ord(c2)) -ring_dist = min(diff, 26 - diff) -``` - -例如: -- `'a'` → `'z'`:`diff = 25`,`min(25, 1) = 1` -- `'c'` → `'b'`:`diff = 1`,`min(1, 25) = 1` - ---- - -## 解题思路 - -### 核心观察 - -`t` 的子串 = 将 `t` 首尾各删去若干字符。等价于:在 `t` 中取一个长度为 `|s|` 的连续窗口,将 `s` 的每个字符变换为该窗口对应字符,求最小总变换步数。 - -### 算法:枚举所有对齐位置 - -1. 遍历 `t` 中每个可能的起始位置 `i`(`0 ≤ i ≤ |t| - |s|`) -2. 将 `s[j]` 与 `t[i+j]` 对齐,计算该对齐的总变换代价 -3. 取所有对齐方式的最小代价 - -```python -for i in range(len(t) - len(s) + 1): - cost = 0 - for j in range(len(s)): - cost += ring_distance(s[j], t[i + j]) - ans = min(ans, cost) -``` - -### 为什么暴力可行 - -`|s|, |t| ≤ 1000`,双重循环 O(|s|×|t|) ≤ 10^6,Python 轻松处理。 - ---- - -## 参考实现 - -```python -def solve(): - input = sys.stdin.readline - s = input().strip() - t = input().strip() - n, m = len(s), len(t) - - ans = float('inf') - for i in range(m - n + 1): - cost = 0 - for j in range(n): - diff = abs(ord(s[j]) - ord(t[i + j])) - cost += min(diff, 26 - diff) - ans = min(ans, cost) - - print(ans) -``` - ---- - -## 复杂度分析 - -| 指标 | 值 | -|------|-----| -| 时间 | O(|s| × |t|) ≤ 10^6 | -| 空间 | O(1) | - ---- - -## 示例验证 - -**示例 1** - -``` -输入: -abc -abbc - -对齐位置 0: s="abc" vs t[0:3]="abc" → cost = 0+0+1 = 1 -对齐位置 1: s="abc" vs t[1:4]="bbc" → cost = 1+0+1 = 2 -答案: 1 -``` - -**示例 2** - -``` -输入: -zzzzzz -xyzabc - -对齐位置 0: s="zzzzzz" vs t[0:6]="xyzabc" - z→x: |25-23|=2, min(2,24)=2 - z→y: |25-24|=1, min(1,25)=1 - z→z: 0 - z→a: |25-0|=25, min(25,1)=1 - z→b: |25-1|=24, min(24,2)=2 - z→c: |25-2|=23, min(23,3)=3 - total: 2+1+0+1+2+3 = 9 -答案: 9 -``` - ---- - -## 易错点 - -1. **环形距离**:`'a'` 和 `'z'` 相邻,距离为 1,不是 25 -2. **输入空行**:OJ 数据末尾可能有空行,用 `strip()` 处理 -3. **答案可能为 0**:`s` 本身已是 `t` 的子串 - ---- - -## 相关模式 - -- 子串匹配 → 滑窗思想(本题直接用暴力枚举即可) -- 环形距离计算 → 常用于字母/时钟类题目 -- 字符串对齐代价 → 扩展到编辑距离(DP) diff --git a/daily/README.md b/daily/README.md deleted file mode 100644 index f2566b7..0000000 --- a/daily/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# daily/ — 每日计划目录 - -存放每日学习计划与 Problem Log,文件命名格式:`YYYY-MM-DD.md`。 - -## 文件模板 - -```markdown -# YYYY-MM-DD 每日计划 - -## 今日目标 - -- [ ] 算法:至少 3 道题(solve-skeleton → algo-annotation 完整流程) -- [ ] LLM:复习 1 个核心主题 -- [ ] 选择题:20-30 分钟专项练习 - -## 时间安排 - -| 时段 | 内容 | 时长 | -|------|------|------| -| 上午 | 算法刷题 | 2h | -| 下午 | AI/ML 知识复习 | 1h | -| 晚上 | 选择题练习 + 复盘 | 1h | - -## Problem Log - -| # | 题名 | 模式 | 解法 | 结果 | 错因/备注 | 复杂度 | -|---|------|------|------|------|-----------|--------| -| 1 | | | | AC/WA/TLE | | | - -## 复盘 - -### 今天最容易再犯的错误 -1. -2. -3. - -### 明日改进 - -``` diff --git a/exam_memory/pyproject.toml b/exam_memory/pyproject.toml deleted file mode 100644 index a1723e9..0000000 --- a/exam_memory/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[project] -name = "exam-memory" -version = "1.0.0" -description = "考试经验沉淀 MCP Server" -requires-python = ">=3.10" -dependencies = [ - "mcp>=1.0.0", - "pyyaml>=6.0", -] - -[project.optional-dependencies] -embed = [ - "sentence-transformers>=2.2.0", - "numpy>=1.24.0", -] -langchain = [ - "langchain-core>=0.2.0", - "langchain-community>=0.2.0", -] -full = [ - "exam-memory[embed,langchain]", -] - -[project.scripts] -exam-memory = "exam_memory.server:main" - -[build-system] -requires = ["setuptools>=68.0"] -build-backend = "setuptools.backends._legacy:_Backend" diff --git a/exam_memory/rebuild_index.py b/exam_memory/rebuild_index.py deleted file mode 100644 index b0a7b96..0000000 --- a/exam_memory/rebuild_index.py +++ /dev/null @@ -1,50 +0,0 @@ -"""rebuild_index.py — CLI 全量重建向量索引。 - -用法: - python -m exam_memory.rebuild_index # 全量重建 - python -m exam_memory.rebuild_index --type 算法 # 只重建算法类 - python -m exam_memory.rebuild_index --force # 强制覆盖已有索引 -""" - -from __future__ import annotations - -import argparse -import sys - - -def main() -> int: - ap = argparse.ArgumentParser( - description="exam-memory 向量索引全量重建" - ) - ap.add_argument("--type", choices=["单选题", "多选题", "算法"], - help="仅重建指定类型的经验") - ap.add_argument("--force", action="store_true", - help="强制覆盖已有索引") - ap.add_argument("--verbose", "-v", action="store_true") - args = ap.parse_args() - - from exam_memory.vector_store import NumpyVectorStore - from exam_memory.embedding import is_available, EmbeddingError - - if not is_available(): - print("[rebuild] embedding 不可用,请先安装: pip install '.[embed]'", - file=sys.stderr) - return 1 - - store = NumpyVectorStore() - if not args.force and store.is_available: - n = store.count - print(f"[rebuild] 索引已存在({n} 条),加 --force 强制覆盖") - return 0 - - n = store.rebuild(verbose=args.verbose) - if n == 0: - print("[rebuild] 未索引任何经验(experiences/ 目录可能为空)") - return 1 - - print(f"[rebuild] 完成: {n} 条经验已向量化") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/llm/transformer-forward-pass.md b/llm/transformer-forward-pass.md deleted file mode 100644 index 2de3b09..0000000 --- a/llm/transformer-forward-pass.md +++ /dev/null @@ -1,109 +0,0 @@ -# Transformer 完整前向传播流程笔记 - -## 1. 输入嵌入层 - -**源序列**:token IDs $x \in \mathbb{R}^{B \times L_{src}}$,经 embedding lookup 后乘以 $\sqrt{d_{model}}$,加上正弦位置编码: - -$$X = \text{Embed}(x) \cdot \sqrt{d_{model}} + PE \in \mathbb{R}^{B \times L_{src} \times d_{model}}$$ - -**目标序列**:训练时将 target 右移一位(去掉末尾 token,起始填 ``),同样做 embedding + PE: - -$$Y = \text{Embed}(y_{shifted}) \cdot \sqrt{d_{model}} + PE \in \mathbb{R}^{B \times L_{tgt} \times d_{model}}$$ - ---- - -## 2. Encoder(×N 层) - -每层包含两个子层,均使用 **残差连接 + LayerNorm**(Post-LN): - -### (a) Multi-Head Self-Attention - -$$Q = XW^Q,\quad K = XW^K,\quad V = XW^V \quad \in \mathbb{R}^{B \times L_{src} \times d_k}$$ - -$$\text{head}_i = \text{softmax}\!\left(\frac{Q_i K_i^T}{\sqrt{d_k}}\right) V_i$$ - -$$\text{MHSA}(X) = \text{Concat}(\text{head}_1,\dots,\text{head}_h) W^O \in \mathbb{R}^{B \times L_{src} \times d_{model}}$$ - -输出经残差 + LN:$X' = \text{LN}(X + \text{MHSA}(X))$ - -### (b) Feed-Forward Network - -$$\text{FFN}(X') = \text{ReLU}(X' W_1 + b_1) W_2 + b_2$$ - -其中 $W_1 \in \mathbb{R}^{d_{model} \times d_{ff}}$,$W_2 \in \mathbb{R}^{d_{ff} \times d_{model}}$,通常 $d_{ff}=4 \cdot d_{model}$。 - -输出:$E = \text{LN}(X' + \text{FFN}(X')) \in \mathbb{R}^{B \times L_{src} \times d_{model}}$ - ---- - -## 3. Decoder(×N 层) - -每层三个子层,均带残差 + LN: - -### (a) Masked Multi-Head Self-Attention - -与 Encoder 相同,但 softmax 前对 attention matrix 加**上三角 mask**($\text{mask}_{ij}=-\infty$ 当 $j>i$),保证位置 $i$ 只能 attend 到 $\leq i$ 的位置。输出:$Y' = \text{LN}(Y + \text{MaskedMHSA}(Y))$ - -### (b) Cross-Attention - -$Q = Y' W^Q$(来自 Decoder),$K = E W^K$,$V = E W^V$(来自 Encoder 输出)。维度不变,输出:$Y'' = \text{LN}(Y' + \text{CrossAttn}(Y', E))$ - -### (c) Feed-Forward Network - -同 Encoder FFN。输出:$D = \text{LN}(Y'' + \text{FFN}(Y'')) \in \mathbb{R}^{B \times L_{tgt} \times d_{model}}$ - ---- - -## 4. 输出层 - -$$\text{logits} = D \cdot W_{out} + b_{out} \in \mathbb{R}^{B \times L_{tgt} \times V_{vocab}}$$ - -$$P = \text{softmax}(\text{logits},\ \text{dim}=-1)$$ - -> $W_{out} \in \mathbb{R}^{d_{model} \times V_{vocab}}$,常与输入 embedding 共享权重(weight tying)。 - ---- - -## 5. PyTorch 伪代码 - -```python -import torch, torch.nn as nn, math - -class Transformer(nn.Module): - def __init__(self, vocab_size, d_model=512, nhead=8, - num_layers=6, d_ff=2048, max_len=512): - super().__init__() - self.embed = nn.Embedding(vocab_size, d_model) - self.pos_enc = nn.Embedding(max_len, d_model) # 可替换为正弦编码 - encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, d_ff) - decoder_layer = nn.TransformerDecoderLayer(d_model, nhead, d_ff) - self.encoder = nn.TransformerEncoder(encoder_layer, num_layers) - self.decoder = nn.TransformerDecoder(decoder_layer, num_layers) - self.fc_out = nn.Linear(d_model, vocab_size) - - def forward(self, src, tgt_shifted): - # 1. 嵌入 - src_emb = self.embed(src) * math.sqrt(self.fc_out.in_features) - tgt_emb = self.embed(tgt_shifted) * math.sqrt(self.fc_out.in_features) - src_emb += self.pos_enc(torch.arange(src.size(1))) - tgt_emb += self.pos_enc(torch.arange(tgt_shifted.size(1))) - # 2. Encoder - memory = self.encoder(src_emb.transpose(0, 1)) - # 3. Decoder(含 causal mask) - tgt_mask = nn.Transformer.generate_square_subsequent_mask(tgt_shifted.size(1)) - dec_out = self.decoder(tgt_emb.transpose(0, 1), memory, tgt_mask) - # 4. 输出层 - logits = self.fc_out(dec_out.transpose(0, 1)) # (B, L_tgt, V) - return torch.softmax(logits, dim=-1) -``` - ---- - -## 维度速查表 - -| 位置 | 维度 | 说明 | -|------|------|------| -| Embedding 输出 | $(B, L, d_{model})$ | 乘 $\sqrt{d_{model}}$ 缩放 | -| Attention $Q,K,V$ | $(B, h, L, d_k)$ | $d_k = d_{model}/h$ | -| FFN 中间层 | $(B, L, d_{ff})$ | $d_{ff}=4d_{model}$ | -| 输出 logits | $(B, L_{tgt}, V_{vocab})$ | softmax 得概率分布 | diff --git a/llm/transformer-review.md b/llm/transformer-review.md deleted file mode 100644 index ccc7515..0000000 --- a/llm/transformer-review.md +++ /dev/null @@ -1,270 +0,0 @@ -# Transformer 前向流程复习笔记 - -> 算法笔试速查 | 只要求能讲清前向流程 - ---- - -## 一、Self-Attention 机制(缩放点积注意力) - -### 1. 公式 - -**线性投影:** - -$$Q = XW_Q, \quad K = XW_K, \quad V = XW_V$$ - -**注意力计算:** - -$$\text{Attention}(Q,K,V) = \text{softmax}\!\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$ - -### 2. 符号说明 - -| 符号 | 含义 | 维度 | -|------|------|------| -| $X$ | 输入序列,每个 token 的嵌入向量 | $(n, d_{\text{model}})$ | -| $W_Q, W_K, W_V$ | 可学习的投影矩阵 | $(d_{\text{model}}, d_k)$ | -| $Q, K, V$ | Query、Key、Value 矩阵 | $(n, d_k)$ | -| $QK^T$ | 注意力得分矩阵 | $(n, n)$ | -| $\sqrt{d_k}$ | 缩放因子,$d_k$ 为 Key 的维度 | 标量 | - -其中 $n$ 是序列长度,$d_{\text{model}}$ 是模型隐藏维度,$d_k$ 是 Key 的维度。 - -### 3. 为什么除以 $\sqrt{d_k}$ - -当 $d_k$ 较大时,$QK^T$ 的元素方差约为 $d_k$,量级随之增大。未经缩放的点积进入 softmax 后,输出趋近 one-hot(某个位置接近 1,其余接近 0),梯度极小,训练难以收敛。除以 $\sqrt{d_k}$ 将方差重新归一化到约 1,使 softmax 处于梯度敏感区间。 - -### 4. 数值示例($n=3, d_k=2, d_{\text{model}}=4$) - -取 $d_k=2$ 以便手算,$X$ 为 $3\times4$,投影后得到: - -$$Q = \begin{pmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{pmatrix}, \quad K = \begin{pmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{pmatrix}, \quad V = \begin{pmatrix} 1 & 2 \\ 3 & 4 \\ 5 & 6 \end{pmatrix}$$ - -**Step 1:** 计算 $QK^T$: - -$$QK^T = \begin{pmatrix} 1 & 0 & 1 \\ 0 & 1 & 1 \\ 1 & 1 & 2 \end{pmatrix}$$ - -**Step 2:** 缩放,$\sqrt{d_k}=\sqrt{2}\approx1.414$: - -$$\frac{QK^T}{\sqrt{2}} \approx \begin{pmatrix} 0.707 & 0 & 0.707 \\ 0 & 0.707 & 0.707 \\ 0.707 & 0.707 & 1.414 \end{pmatrix}$$ - -**Step 3:** 对每行做 softmax(以第 1 行为例): - -$e^{0.707}\approx2.028,\; e^{0}=1,\; e^{0.707}\approx2.028$,总和 $\approx5.056$ - -$\text{softmax} \approx (0.401,\; 0.198,\; 0.401)$ - -**Step 4:** 加权求和第 1 行输出: - -$0.401\times(1,2) + 0.198\times(3,4) + 0.401\times(5,6) = (3.40,\; 4.40)$ - -对第 2、3 行重复同理,最终得到输出矩阵 $3\times2$。 - -**核心要点:** Self-Attention 通过 $QK^T$ 度量序列中任意两个位置的相关性,经 softmax 归一化为权重后对 $V$ 加权求和,实现全局信息聚合。 - ---- - -## 二、Multi-Head Attention - -### 1. 核心公式 - -$$\text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \dots, \text{head}_h)\, W^O$$ - -其中每个头独立计算: - -$$\text{head}_i = \text{Attention}(XW_Q^i,\; XW_K^i,\; XW_V^i)$$ - -参数矩阵维度:$W_Q^i, W_K^i \in \mathbb{R}^{d_{\text{model}} \times d_k}$,$W_V^i \in \mathbb{R}^{d_{\text{model}} \times d_v}$,$W^O \in \mathbb{R}^{hd_v \times d_{\text{model}}}$。 - -### 2. 为什么要多头 - -单头 Attention 只在一种表示子空间中计算相似度,容易被主导模式淹没。多头机制将 $d_{\text{model}}$ 维空间拆成 $h$ 个低维子空间,每个头独立学习一组 $W_Q, W_K, W_V$,从而并行捕获不同类型的语义关系——例如一个头关注句法依存,另一个头关注指代消解,第三个头关注局部 n-gram 搭配。 - -### 3. 维度关系 - -$$d_k = d_v = \frac{d_{\text{model}}}{h}$$ - -$h$ 个头拼接后总维度仍为 $d_{\text{model}}$,与残差连接维度一致。例如 $d_{\text{model}}=512, h=8$,则每个头工作在 $d_k=64$ 维子空间中。 - -### 4. 输出投影 $W^O$ 的作用 - -Concat 将 $h$ 个头的输出简单拼接,各子空间特征相互独立。$W^O$ 将拼接结果投影回 $d_{\text{model}}$ 维,使不同头学到的信息发生交互融合,同时适配残差连接和后续层的输入维度。 - ---- - -## 三、Position Encoding(位置编码) - -### 1. 为什么需要位置编码 - -Self-Attention 对输入序列具有**置换不变性**(permutation invariant):打乱 token 顺序,输出不变。必须显式注入位置信息,否则模型无法区分 "猫吃鱼" 和 "鱼吃猫"。 - -### 2. 正弦/余弦编码公式 - -$$PE_{(pos, 2i)} = \sin\!\left(\frac{pos}{10000^{2i/d_{model}}}\right), \quad PE_{(pos, 2i+1)} = \cos\!\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$ - -其中 $pos$ 为 token 位置,$i$ 为维度索引。不同维度对应不同频率的正弦波,从低频到高频逐步编码。 - -### 3. 为什么选正弦余弦 - -核心优势:**相对位置可通过线性变换获得**。对任意固定偏移 $k$,存在与 $pos$ 无关的矩阵 $M_k$,使得: - -$$PE_{(pos+k)} = M_k \cdot PE_{(pos)}$$ - -这是因为 $\sin(a+b)$ 和 $\cos(a+b)$ 可展开为 $\sin a, \cos a$ 的线性组合。模型因此能泛化至训练时未见过的序列长度。 - -### 4. RoPE(旋转位置编码) - -**一句话核心**:将位置 $m$ 编码为旋转矩阵 $R_m$,分别作用在 $Q$ 和 $K$ 上,使 $q_m^\top k_n$ 的点积天然只依赖于相对位置 $m-n$: - -$$\langle R_m q,\; R_n k \rangle = f(q, k, m-n)$$ - -二维情形下,$R_m = \begin{pmatrix}\cos m\theta & -\sin m\theta \\ \sin m\theta & \cos m\theta\end{pmatrix}$,高维时按二维子空间分组旋转。 - -### 5. 可学习 vs 固定位置编码 - -**固定编码**(如正弦编码)不增加参数、天然支持外推;**可学习编码**(如 BERT)参数化每个位置的向量,表达能力更强但受限于训练长度,外推能力差。RoPE 属于固定编码但兼具外推性。 - ---- - -## 四、Transformer Block - -### Encoder Block - -两个子层,每层带 **残差连接 + LayerNorm**: - -$$\text{output} = \text{LayerNorm}(x + \text{SubLayer}(x))$$ - -> **Pre-Norm vs Post-Norm**:Post-Norm 在子层输出后做 LayerNorm(原始论文,需 warm-up);Pre-Norm 在子层输入前做 LayerNorm(收敛更稳定,现为主流)。 - -**FFN** 逐位置独立计算,先升维再降维(通常扩大 4 倍): - -$$\text{FFN}(x) = \max(0,\, xW_1 + b_1)\,W_2 + b_2$$ - -其中 $x \in \mathbb{R}^{d}$,$W_1 \in \mathbb{R}^{d \times 4d}$,$W_2 \in \mathbb{R}^{4d \times d}$。激活函数原始用 ReLU,后续变体常用 GELU / SwiGLU。 - -### Decoder Block - -三个子层: - -1. **Masked Self-Attention**:对当前位置之后施加 causal mask(上三角置为 $-\infty$),保证自回归性质: - -$$\text{Attention}(Q,K,V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}} + M\right)V, \quad M_{ij} = \begin{cases} 0 & i \ge j \\ -\infty & i < j \end{cases}$$ - -2. **Cross-Attention**:**Q** 来自 Decoder 上一层输出,**K、V** 来自 Encoder 最终输出,让 Decoder 能"查询"源序列信息。 - -3. **FFN**:与 Encoder 相同。 - -推理时通过 **KV Cache** 缓存历史 K/V,避免重复计算。 - ---- - -## 五、完整前向流程(Encoder-Decoder 架构) - -### 符号约定 - -| 符号 | 含义 | -|------|------| -| $d_{\text{model}}$ | 模型隐藏维度(如 512) | -| $d_k = d_{\text{model}}/h$ | 每个头的维度 | -| $d_{\text{ff}} = 4 \cdot d_{\text{model}}$ | FFN 中间维度 | -| $V_{\text{vocab}}$ | 词表大小 | -| $B, L_{src}, L_{tgt}$ | batch size、源/目标序列长度 | - -### Step 1:输入嵌入 - -**源序列**:token IDs 经 embedding lookup 后乘以 $\sqrt{d_{model}}$,加上正弦位置编码: - -$$X = \text{Embed}(x) \cdot \sqrt{d_{model}} + PE \in \mathbb{R}^{B \times L_{src} \times d_{model}}$$ - -**目标序列**:训练时将 target 右移一位(起始填 ``),同样做 embedding + PE: - -$$Y = \text{Embed}(y_{shifted}) \cdot \sqrt{d_{model}} + PE \in \mathbb{R}^{B \times L_{tgt} \times d_{model}}$$ - -### Step 2:Encoder(×N 层) - -每层两个子层,均带残差 + LN: - -**(a) Multi-Head Self-Attention**(Q/K/V 同源) - -$$\text{MHSA}(X) = \text{Concat}(\text{head}_1,\dots,\text{head}_h) W^O, \quad X' = \text{LN}(X + \text{MHSA}(X))$$ - -**(b) FFN** - -$$E = \text{LN}(X' + \text{FFN}(X')), \quad E \in \mathbb{R}^{B \times L_{src} \times d_{model}}$$ - -### Step 3:Decoder(×N 层) - -每层三个子层,均带残差 + LN: - -**(a) Masked Self-Attention**:causal mask 保证位置 $i$ 只能 attend 到 $\leq i$ 的位置 - -$$Y' = \text{LN}(Y + \text{MaskedMHSA}(Y))$$ - -**(b) Cross-Attention**:$Q = Y'W^Q$(来自 Decoder),$K = EW^K, V = EW^V$(来自 Encoder 输出) - -$$Y'' = \text{LN}(Y' + \text{CrossAttn}(Y', E))$$ - -**(c) FFN** - -$$D = \text{LN}(Y'' + \text{FFN}(Y'')), \quad D \in \mathbb{R}^{B \times L_{tgt} \times d_{model}}$$ - -### Step 4:输出层 - -$$\text{logits} = D \cdot W_{out} + b_{out} \in \mathbb{R}^{B \times L_{tgt} \times V_{vocab}}$$ - -$$P = \text{softmax}(\text{logits},\ \text{dim}=-1)$$ - -> $W_{out}$ 常与输入 embedding 共享权重(weight tying)。 - -### 维度速查表 - -| 位置 | 维度 | 说明 | -|------|------|------| -| Embedding 输出 | $(B, L, d_{model})$ | 乘 $\sqrt{d_{model}}$ 缩放 | -| Attention $Q,K,V$ | $(B, h, L, d_k)$ | $d_k = d_{model}/h$ | -| FFN 中间层 | $(B, L, d_{ff})$ | $d_{ff}=4d_{model}$ | -| 输出 logits | $(B, L_{tgt}, V_{vocab})$ | softmax 得概率分布 | - ---- - -## 六、PyTorch 伪代码 - -```python -import torch, torch.nn as nn, math - -class Transformer(nn.Module): - def __init__(self, vocab_size, d_model=512, nhead=8, - num_layers=6, d_ff=2048, max_len=512): - super().__init__() - self.embed = nn.Embedding(vocab_size, d_model) - self.pos_enc = nn.Embedding(max_len, d_model) # 可替换为正弦编码 - encoder_layer = nn.TransformerEncoderLayer(d_model, nhead, d_ff) - decoder_layer = nn.TransformerDecoderLayer(d_model, nhead, d_ff) - self.encoder = nn.TransformerEncoder(encoder_layer, num_layers) - self.decoder = nn.TransformerDecoder(decoder_layer, num_layers) - self.fc_out = nn.Linear(d_model, vocab_size) - - def forward(self, src, tgt_shifted): - # 1. 嵌入 - src_emb = self.embed(src) * math.sqrt(self.fc_out.in_features) - tgt_emb = self.embed(tgt_shifted) * math.sqrt(self.fc_out.in_features) - src_emb += self.pos_enc(torch.arange(src.size(1))) - tgt_emb += self.pos_enc(torch.arange(tgt_shifted.size(1))) - # 2. Encoder - memory = self.encoder(src_emb.transpose(0, 1)) - # 3. Decoder(含 causal mask) - tgt_mask = nn.Transformer.generate_square_subsequent_mask(tgt_shifted.size(1)) - dec_out = self.decoder(tgt_emb.transpose(0, 1), memory, tgt_mask) - # 4. 输出层 - logits = self.fc_out(dec_out.transpose(0, 1)) # (B, L_tgt, V) - return torch.softmax(logits, dim=-1) -``` - ---- - -## 速记口诀 - -- **Self-Attention**:Q·K 算相似度 → 缩放 → softmax 得权重 → 加权求 V -- **Multi-Head**:拆成 h 个头各自算 → 拼接 → W^O 融合 -- **Position Encoding**:Attention 本身不含位置信息,必须显式注入 -- **Encoder**:自注意 + FFN,残差 + Norm 不可少,FFN 升四倍再降回 -- **Decoder**:掩码自注意 + 交叉注意 + FFN -- **前向流程**:Embed(+scale+PE) → N×Encoder → N×Decoder → Linear → Softmax diff --git a/prompts/new_session_prompt.md b/prompts/new_session_prompt.md index f05b894..f630189 100644 --- a/prompts/new_session_prompt.md +++ b/prompts/new_session_prompt.md @@ -1,28 +1,31 @@ # New Session Prompt ```text -我正在准备上海 AI 实验室"基座模型算法(地球空间方向)- AI For Science 中心"线上笔试,2026-06-16,120 分钟。请在当前项目中继续执行备考 harness。 +我正在使用 Pass-LLM-with-LLM 备考 harness,请在当前项目中继续执行。 请先阅读: 1. AGENTS.md — 项目规则、Skill Pipeline、Component Map 2. START_HERE.md — Session bootstrap + Skill 调用顺序 3. HANDOFF.md — 上次 session 交接状态 -4. 今天对应的 daily/YYYY-MM-DD.md -5. progress/task-board/task-board.md -6. algorithms/mistake_log.md 和 algorithms/mock_exam_log.md -7. sources/ai_lab_history_problems.md(历史真题分类 + 复习方向) +4. 今天对应的 shared/daily/YYYY-MM-DD.md +5. shared/progress/task-board.md(全局跨目标 task board) +6. 对应目标目录 targets// 下的 progress/task-board.md 和 mistake_log +7. shared/exam_memory/ 向量记忆系统(如 MCP 可用) + +**切换目标:** 告诉我目标名称即可(如 "切换到 pdd"、"切 ai-lab"),我会加载对应 targets// 的上下文。 **Skill Pipeline(OJ 题必须走这个流程):** - 每道 OJ 题先调用 Skill(skill="solve-skeleton") 获取骨架模板 -- WA/TLE 时调用 Skill(skill="solve-analyze") 诊断对比 + 根因标签 + 自动写 mistake_log +- WA/TLE 时先记录到 `targets//mistake_log.md`,再根据需要做人工诊断和复盘 - 测试通过后调用 Skill(skill="algo-annotation") 添加中文注释 + # [防错] - 选择题:Skill(skill="choice-q-create") → Skill(skill="choice-q-drill") - 进度查看:Skill(skill="review-tracker") -考试结构: -- 选择题:单选 8 题(每题 3 分)+ 不定项 7 题(每题 4 分),共 52 分 -- 编程:3 道 ACM/OJ 题 -- 多选题策略:漏选比多选扣分少,不确定不选 +**目录结构:** +- targets/ai-lab/ — AI Lab 笔试专属(cheatsheets、progress、sources) +- targets/pdd-algo/ — PDD 大模型算法岗专属(practice、solutions、progress) +- shared/ — 跨目标共享(cheatsheets、exam_memory、daily、progress) +- skills/ — 全局 Claude skills MCP 验证:确认 mcp__exam-memory__* 工具是否可用。不可用则所有 skill 自动降级为纯本地模式。 @@ -30,8 +33,7 @@ MCP 验证:确认 mcp__exam-memory__* 工具是否可用。不可用则所有 - 优先帮助我刷 Python ACM/OJ 题并复盘 AC 率。 - 不要把时间花在大范围资料研究或复杂环境部署。 - 每天按 3-5 小时安排执行。 -- 算法占 70%(含数学选择题专项),大模型核心八股占 20%,项目表达占 10%。 - 如果使用 ChatMem,请只加载 compact project context,并把 indexed local-history evidence 和 approved startup rules 区分开。 -请根据今天日期告诉我下一步应该做什么,并协助我执行。 +请根据今天日期和当前目标告诉我下一步应该做什么,并协助我执行。 ``` diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5fdcac4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -v --basetemp=.tmp/pytest diff --git a/exam_memory/__init__.py b/shared/cheatsheets/.gitkeep similarity index 100% rename from exam_memory/__init__.py rename to shared/cheatsheets/.gitkeep diff --git a/llm/agent_project_pitch.md b/shared/cheatsheets/agent_project_pitch.md similarity index 100% rename from llm/agent_project_pitch.md rename to shared/cheatsheets/agent_project_pitch.md diff --git a/llm/llm_core_cheatsheet.md b/shared/cheatsheets/llm_core_cheatsheet.md similarity index 100% rename from llm/llm_core_cheatsheet.md rename to shared/cheatsheets/llm_core_cheatsheet.md diff --git a/llm/sft_lora_miniproject.md b/shared/cheatsheets/sft_lora_miniproject.md similarity index 100% rename from llm/sft_lora_miniproject.md rename to shared/cheatsheets/sft_lora_miniproject.md diff --git a/shared/exam_memory/__init__.py b/shared/exam_memory/__init__.py new file mode 100644 index 0000000..f773689 --- /dev/null +++ b/shared/exam_memory/__init__.py @@ -0,0 +1,15 @@ +"""exam-memory V2 - exam experience MCP Server. + +Exports: + from exam_memory.embedding import encode, is_available, get_embedder + from exam_memory.vector_store import NumpyVectorStore + from exam_memory.fts_store import FTSStore + from exam_memory.hybrid_search import hybrid_search, FUSION_WEIGHTS + from exam_memory.chunking import chunk_text, EXPERIENCE_CHUNK_CHARS + +Usage: + python -m exam_memory.server # start MCP server + python -m exam_memory.rebuild_index # rebuild all indexes +""" + +__version__ = "2.0.0" diff --git a/shared/exam_memory/bank/README.md b/shared/exam_memory/bank/README.md new file mode 100644 index 0000000..1f40722 --- /dev/null +++ b/shared/exam_memory/bank/README.md @@ -0,0 +1,15 @@ +# 题库索引 + +> 自动生成于 `rebuild_index.py --include-bank`,或通过 `QuestionBank` 写入时更新。 + +## 统计 + +| 题型 | 数量 | +|------|------| +| 算法 | 0 | +| 单选题 | 0 | +| 多选题 | 0 | + +## 最近录入 + +(暂无题目,使用 `QuestionBank.add_manual()` 或 `python -m exam_memory.question_bank` 录入。) diff --git a/shared/exam_memory/chunking.py b/shared/exam_memory/chunking.py new file mode 100644 index 0000000..b646a09 --- /dev/null +++ b/shared/exam_memory/chunking.py @@ -0,0 +1,96 @@ +"""文本分块模块(exam-memory V2)。 + +对齐 OneFind v1.7: + - Chunk 大小: 1600 char(对齐 folder source) + - 按段落切分,保持语义完整 + - 保留 chunk_order 顺序 + +用法: + from exam_memory.chunking import chunk_text, EXPERIENCE_CHUNK_CHARS + chunks = chunk_text(long_text, max_chars=1600) +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +# ── 常量 ───────────────────────────────────────────────────── + +# 对齐 OneFind folder source chunk 大小 +EXPERIENCE_CHUNK_CHARS = 1600 + + +# ── 分块函数 ───────────────────────────────────────────────── + +def chunk_text(text: str, max_chars: int = EXPERIENCE_CHUNK_CHARS) -> list[str]: + """将文本按段落切分为不超过 max_chars 的块。 + + 策略: + 1. 文本长度 <= max_chars → 不切分,返回 [text] + 2. 按双换行符(段落边界)切分 + 3. 逐段累积,超限时落盘为独立块 + 4. 最后一段残余单独成块 + + Args: + text: 原始文本。 + max_chars: 单块最大字符数。 + + Returns: + chunk 列表(可能仅含 1 个元素)。 + """ + if len(text) <= max_chars: + return [text] + + paragraphs = text.split("\n\n") + chunks: list[str] = [] + current = "" + + for para in paragraphs: + # 单个段落超长时强制切分 + if len(para) > max_chars: + if current.strip(): + chunks.append(current.strip()) + current = "" + for i in range(0, len(para), max_chars): + chunks.append(para[i:i + max_chars]) + continue + + if len(current) + len(para) + 2 > max_chars and current: + chunks.append(current.strip()) + current = para + else: + current = f"{current}\n\n{para}" if current else para + + if current.strip(): + chunks.append(current.strip()) + + return chunks + + +def chunk_text_with_order( + text: str, + source_id: str, + max_chars: int = EXPERIENCE_CHUNK_CHARS, +) -> list[dict[str, str]]: + """分块并附加 chunk_id 和 chunk_order。 + + Args: + text: 原始文本。 + source_id: 源标识符(如文件名)。 + max_chars: 单块最大字符数。 + + Returns: + list[{"chunk_id", "source_id", "chunk_order", "text"}] + """ + chunks = chunk_text(text, max_chars=max_chars) + return [ + { + "chunk_id": f"{source_id}:{i}", + "source_id": source_id, + "chunk_order": i, + "text": c, + } + for i, c in enumerate(chunks) + ] diff --git a/exam_memory/embedding.py b/shared/exam_memory/embedding.py similarity index 62% rename from exam_memory/embedding.py rename to shared/exam_memory/embedding.py index 16d750b..129b043 100644 --- a/exam_memory/embedding.py +++ b/shared/exam_memory/embedding.py @@ -1,5 +1,10 @@ """bge-m3 embedding 封装(exam-memory V2 核心层)。 +设计对齐 OneFind v1.7: + - pooling: CLS token(非 mean),确保向量空间兼容 + - normalize: L2,使 dot product = cosine similarity + - 维度: 1024,与 OneFind zotero/obsidian/folder source 一致 + 无 LangChain 依赖,CPU-only,静默降级。 用法: @@ -11,11 +16,14 @@ from __future__ import annotations +import logging import os from typing import Union import numpy as np +logger = logging.getLogger(__name__) + # ── 依赖检测 ──────────────────────────────────────────────── class EmbeddingError(Exception): @@ -38,7 +46,11 @@ def _check_dependency() -> None: def get_embedder(model_name: str = "BAAI/bge-m3"): - """延迟加载 bge-m3 模型(单例)。CPU-only。""" + """延迟加载 bge-m3 模型(单例)。CPU-only。 + + 对齐 OneFind: CLS pooling + L2 normalize。 + 验证 model[1].pooling_mode_cls_token == True。 + """ global _MODEL if _MODEL is not None: return _MODEL @@ -48,9 +60,33 @@ def get_embedder(model_name: str = "BAAI/bge-m3"): from sentence_transformers import SentenceTransformer _MODEL = SentenceTransformer(model_name, device="cpu") + + _verify_pooling(_MODEL) return _MODEL +def _verify_pooling(model) -> None: + """验证模型使用 CLS pooling(对齐 OneFind)。""" + try: + pooling = model[1] + if hasattr(pooling, "pooling_mode_cls_token"): + is_cls = pooling.pooling_mode_cls_token + elif hasattr(pooling, "pooling_mode_mean_tokens"): + is_cls = not pooling.pooling_mode_mean_tokens + else: + is_cls = True # 无法检测时假设正确,OneFind 已知配置 + + if not is_cls: + logger.warning( + "[embedding] 模型 pooling 非 CLS 模式,向量空间可能与 OneFind 不兼容。" + "当前 pooling_mode_cls_token=False,建议检查模型配置。" + ) + else: + logger.info("[embedding] CLS pooling 已确认,向量空间与 OneFind 兼容。") + except Exception as e: + logger.debug(f"[embedding] pooling 验证跳过(结构不匹配): {e}") + + def is_available() -> bool: """embedding 层是否可用。""" try: @@ -69,6 +105,11 @@ def encode( ) -> np.ndarray: """将文本编码为 embedding 向量。 + 对齐 OneFind: + - CLS token pooling(模型层已配置) + - L2 归一化(使 dot product = cosine similarity) + - 输出 float32,shape (dim,) 或 (n, dim),dim=1024 + Args: text: 单条文本或文本列表。 normalize: L2 归一化(使 dot product = cosine similarity)。 diff --git a/shared/exam_memory/frontmatter.py b/shared/exam_memory/frontmatter.py new file mode 100644 index 0000000..89bd63d --- /dev/null +++ b/shared/exam_memory/frontmatter.py @@ -0,0 +1,38 @@ +"""frontmatter.py — 共享 YAML frontmatter 解析工具。 + +从 question_bank.py / server.py / vector_store.py 提取的重复逻辑。 +""" + +from __future__ import annotations + +from typing import Any + +import yaml + + +def parse_frontmatter(text: str) -> dict[str, Any]: + """解析 Markdown YAML frontmatter(--- 之间的 YAML)。 + + 自动将 date 对象转为 str 以保证 JSON 序列化兼容。 + """ + if not text.startswith("---"): + return {} + parts = text.split("---", 2) + if len(parts) < 3: + return {} + try: + meta = yaml.safe_load(parts[1]) or {} + for k, v in meta.items(): + if hasattr(v, "isoformat"): + meta[k] = v.isoformat() + return meta + except yaml.YAMLError: + return {} + + +def body_text(text: str) -> str: + """提取 frontmatter 之后的正文。""" + if text.startswith("---"): + parts = text.split("---", 2) + return parts[2].strip() if len(parts) >= 3 else text + return text.strip() diff --git a/shared/exam_memory/fts_store.py b/shared/exam_memory/fts_store.py new file mode 100644 index 0000000..f45aa9a --- /dev/null +++ b/shared/exam_memory/fts_store.py @@ -0,0 +1,194 @@ +"""SQLite FTS5 词法检索层(exam-memory V2 词法通道)。 + +对齐 OneFind v1.7: + - FTS5 虚拟表,按源类型独立建表(通过 type 列过滤) + - unicode61 tokenizer,移除变音符号 + - BM25 评分 + - 零额外依赖(SQLite 标准库) + +与向量存储的对齐: + - 使用 canonical_key(file_name)作为跨层匹配标识 + - 检索结果含 canonical_key,供 hybrid_search 融合 + +用法: + from exam_memory.fts_store import FTSStore + store = FTSStore() + store.upsert(canonical_key="算法_双指针_001.md", title="...", knowledge="...", content="...", type="算法") + results = store.search("双指针", limit=5) +""" + +from __future__ import annotations + +import logging +import sqlite3 +import threading +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +# ── 路径 ───────────────────────────────────────────────────── + +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "vectorstore" / "fts.sqlite" + +_LOCK = threading.Lock() + +# ── 建表 ───────────────────────────────────────────────────── + +_CREATE_SQL = """ +CREATE VIRTUAL TABLE IF NOT EXISTS experience_fts +USING fts5( + canonical_key UNINDEXED, + title, + knowledge, + content, + type, + tokenize='unicode61 remove_diacritics 2' +) +""" + + +def _init(conn: sqlite3.Connection) -> None: + conn.execute(_CREATE_SQL) + conn.execute( + "INSERT OR IGNORE INTO experience_fts(rowid, canonical_key, title, knowledge, content, type) " + "VALUES (0, '', '', '', '', '')" + ) + + +# ── FTSStore ───────────────────────────────────────────────── + +class FTSStore: + """SQLite FTS5 词法检索。线程安全。""" + + def __init__(self, db_path: str | Path | None = None): + self._path = str(db_path) if db_path else str(DB_PATH) + Path(self._path).parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.execute("PRAGMA synchronous=NORMAL") + _init(self._conn) + + # ── 写入 ──────────────────────────────────────────────── + + def upsert( + self, + canonical_key: str, + title: str = "", + knowledge: str = "", + content: str = "", + type: str = "", + ) -> None: + """插入或更新单条经验(delete-then-insert)。""" + with _LOCK: + self._conn.execute( + "DELETE FROM experience_fts WHERE canonical_key = ?", + (canonical_key,), + ) + self._conn.execute( + "INSERT INTO experience_fts(canonical_key, title, knowledge, content, type) " + "VALUES (?, ?, ?, ?, ?)", + (canonical_key, title, knowledge, content, type), + ) + self._conn.commit() + + def upsert_many(self, docs: list[dict[str, str]]) -> None: + """批量 upsert(delete-then-insert)。每条 doc 需含 canonical_key, title, knowledge, content, type。""" + with _LOCK: + for doc in docs: + key = doc.get("canonical_key", "") + self._conn.execute( + "DELETE FROM experience_fts WHERE canonical_key = ?", + (key,), + ) + self._conn.execute( + "INSERT INTO experience_fts(canonical_key, title, knowledge, content, type) " + "VALUES (?, ?, ?, ?, ?)", + ( + key, + doc.get("title", ""), + doc.get("knowledge", ""), + doc.get("content", ""), + doc.get("type", ""), + ), + ) + self._conn.commit() + + # ── 检索 ──────────────────────────────────────────────── + + def search( + self, + query: str, + *, + limit: int = 10, + type_filter: str | None = None, + ) -> list[dict[str, Any]]: + """BM25 词法检索。 + + Args: + query: 查询文本。 + limit: 最多返回条数。 + type_filter: 题型过滤(None 不过滤)。 + + Returns: + 按 BM25 评分升序排列的 dict 列表,每条含: + - canonical_key: str 与向量存储匹配的标识 + - title, knowledge, type, content, score + """ + if not query.strip(): + return [] + + sql = ( + "SELECT canonical_key, title, knowledge, content, type, " + " bm25(experience_fts) as score " + "FROM experience_fts " + "WHERE experience_fts MATCH ? " + ) + params: list[Any] = [query] + + if type_filter: + sql += "AND type = ? " + params.append(type_filter) + + sql += "ORDER BY score LIMIT ?" + params.append(limit) + + try: + rows = self._conn.execute(sql, params).fetchall() + except sqlite3.Fts5Error as e: + logger.warning(f"[fts_store] FTS 查询失败: {e}") + return [] + + return [ + { + "canonical_key": r["canonical_key"], + "title": r["title"], + "knowledge": r["knowledge"], + "content": r["content"], + "type": r["type"], + "score": round(r["score"], 4), + } + for r in rows + ] + + # ── 统计 ──────────────────────────────────────────────── + + def count(self) -> int: + """已索引的经验条数(排除占位行)。""" + row = self._conn.execute( + "SELECT COUNT(*) as n FROM experience_fts WHERE canonical_key != ''" + ).fetchone() + return row["n"] if row else 0 + + def clear(self) -> None: + with _LOCK: + self._conn.execute("DELETE FROM experience_fts") + self._conn.commit() + + def close(self) -> None: + try: + self._conn.close() + except Exception: + pass diff --git a/shared/exam_memory/hybrid_search.py b/shared/exam_memory/hybrid_search.py new file mode 100644 index 0000000..3593b07 --- /dev/null +++ b/shared/exam_memory/hybrid_search.py @@ -0,0 +1,114 @@ +"""Weighted RRF 混合检索(exam-memory V2)。 + +对齐 OneFind v1.7: + - dense_score_weight: 42(42% 语义 + 58% 词法),算法场景微调为 40:60 + - k: 60(RRF 分母常数) + - 直接融合返回,无需 reranker + +用法: + from exam_memory.hybrid_search import hybrid_search, FUSION_WEIGHTS + results = hybrid_search("双指针", fts_store, vector_store, limit=5) +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# ── 融合权重 ───────────────────────────────────────────────── + +FUSION_WEIGHTS: dict[str, dict[str, float]] = { + "单选题": {"dense": 0.40, "sparse": 0.60}, + "多选题": {"dense": 0.40, "sparse": 0.60}, + "算法": {"dense": 0.35, "sparse": 0.65}, +} + +_DEFAULT_DENSE = 0.40 +RRF_K = 60 + + +# ── 融合 ───────────────────────────────────────────────────── + +def hybrid_search( + query: str, + fts_store: Any, + vector_store: Any, + *, + limit: int = 5, + exp_type: str | None = None, + dense_weight: float | None = None, + k: int = RRF_K, +) -> list[dict[str, Any]]: + """混合检索:FTS BM25 + 向量 cosine,Weighted RRF 融合。 + + Args: + query: 查询文本。 + fts_store: FTSStore 实例(词法检索)。 + vector_store: NumpyVectorStore 实例(语义检索)。 + limit: 最终返回条数。 + exp_type: 题型过滤(单选题/多选题/算法)。 + dense_weight: 语义权重 (0~1)。None 时按 exp_type 自动选择。 + k: RRF 分母常数。 + + Returns: + 按融合分数降序排列的 dict 列表。 + """ + preset = FUSION_WEIGHTS.get(exp_type or "单选题", {"dense": _DEFAULT_DENSE, "sparse": 1 - _DEFAULT_DENSE}) + w = dense_weight if dense_weight is not None else preset["dense"] + sparse_w = 1.0 - w + + fts_hits = _safe(fts_store.search, query, type_filter=exp_type, limit=20) + vec_hits = _safe(vector_store.search, query, type_filter=exp_type, top_k=20) + + scores: dict[str, float] = {} + fts_meta: dict[str, dict] = {} + vec_meta: dict[str, dict] = {} + + for rank, hit in enumerate(fts_hits): + key = hit.get("canonical_key", "") + scores[key] = scores.get(key, 0.0) + sparse_w / (k + rank + 1) + fts_meta[key] = hit + + for rank, hit in enumerate(vec_hits): + key = hit.get("canonical_key", hit.get("metadata", {}).get("file_name", "")) + scores[key] = scores.get(key, 0.0) + w / (k + rank + 1) + vec_meta[key] = hit + + ranked = sorted(scores.items(), key=lambda x: -x[1])[:limit] + + results: list[dict[str, Any]] = [] + for key, score in ranked: + fts_data = fts_meta.get(key, {}) + vec_data = vec_meta.get(key, {}) + + text = vec_data.get("text") or fts_data.get("content") or "" + metadata = vec_data.get("metadata") or { + "title": fts_data.get("title", ""), + "knowledge": fts_data.get("knowledge", ""), + } + + results.append({ + "score": round(score, 4), + "fts_score": fts_data.get("score"), + "vec_score": vec_data.get("score"), + "text": text, + "metadata": metadata, + }) + + return results + + +def get_weights(exp_type: str) -> tuple[float, float]: + """获取指定题型的 (dense_weight, sparse_weight)。""" + preset = FUSION_WEIGHTS.get(exp_type, {"dense": _DEFAULT_DENSE, "sparse": 1 - _DEFAULT_DENSE}) + return preset["dense"], preset["sparse"] + + +def _safe(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except Exception as e: + logger.debug(f"[hybrid_search] {fn.__name__} 异常: {e}") + return [] diff --git a/shared/exam_memory/knowledge_source.py b/shared/exam_memory/knowledge_source.py new file mode 100644 index 0000000..4db0309 --- /dev/null +++ b/shared/exam_memory/knowledge_source.py @@ -0,0 +1,171 @@ +"""knowledge_source.py — KnowledgeSource Protocol + DirConnector 实现。 + +定义标准化外部知识源接口,DirConnector 作为首个连接器覆盖本地文档挂载场景。 + +用法: + from exam_memory.knowledge_source import DirConnector + + ds = DirConnector(name="pdd-notes", path="targets/pdd-algo/cheatsheets/") + ds.connect() + chunks = ds.fetch("哈希表", limit=5) +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, Protocol, TypedDict, runtime_checkable + +from exam_memory.chunking import chunk_text + +logger = logging.getLogger(__name__) + + +# ── 类型定义 ───────────────────────────────────────────────── + + +class SourceChunk(TypedDict): + """知识源返回的标准化 chunk。""" + + text: str + source: str + section: str + metadata: dict[str, Any] + + +@runtime_checkable +class KnowledgeSource(Protocol): + """知识源标准化接口。""" + + @property + def name(self) -> str: ... + + @property + def source_type(self) -> str: ... + + @property + def connected(self) -> bool: ... + + def connect(self) -> bool: ... + + def fetch(self, topic: str, limit: int = 10) -> list[SourceChunk]: ... + + def list_topics(self) -> list[str]: ... + + def refresh(self) -> int: ... + + +# ── DirConnector 实现 ──────────────────────────────────────── + + +class DirConnector: + """本地目录连接器:扫描本地文档目录,按关键词检索并分块返回。""" + + def __init__( + self, + name: str, + path: str | Path, + glob_pattern: str = "*.md", + chunk_size: int = 1600, + ) -> None: + self._name = name + self._path = Path(path) + self._glob_pattern = glob_pattern + self._chunk_size = chunk_size + self._connected = False + self._files: list[Path] = [] + + # ── Protocol properties ────────────────────────────────── + + @property + def name(self) -> str: + return self._name + + @property + def source_type(self) -> str: + return "local_dir" + + @property + def connected(self) -> bool: + return self._connected + + # ── Protocol methods ───────────────────────────────────── + + def connect(self) -> bool: + """扫描目录,缓存文件列表。成功返回 True。""" + if not self._path.is_dir(): + logger.warning("DirConnector: 路径不存在或不是目录: %s", self._path) + return False + self._files = sorted(self._path.glob(self._glob_pattern)) + self._connected = True + logger.info("DirConnector '%s': 扫描到 %d 个文件", self._name, len(self._files)) + return True + + def fetch(self, topic: str, limit: int = 10) -> list[SourceChunk]: + """在文件名和文件内容中做关键词匹配,命中文件读取后分块返回。 + + Args: + topic: 搜索关键词。 + limit: 最大返回 chunk 数。 + + Returns: + 匹配到的 SourceChunk 列表,最多 limit 个。 + """ + if not self._connected: + return [] + + topic_lower = topic.lower() + results: list[SourceChunk] = [] + + for fpath in self._files: + stem = fpath.stem.lower() + # 优先匹配文件名 + filename_match = topic_lower in stem + + # 读取内容检查内容匹配 + try: + text = fpath.read_text(encoding="utf-8") + except Exception as e: + logger.warning("DirConnector: 读取失败 %s: %s", fpath, e) + continue + + content_match = topic_lower in text.lower() + + if not filename_match and not content_match: + continue + + # 分块 + chunks = chunk_text(text, max_chars=self._chunk_size) + for chunk_text_str in chunks: + section = chunk_text_str.split("\n")[0].strip() + results.append( + SourceChunk( + text=chunk_text_str, + source=fpath.name, + section=section, + metadata={ + "filename_match": filename_match, + "content_match": content_match, + "path": str(fpath), + }, + ) + ) + + if len(results) >= limit: + break + + return results[:limit] + + def list_topics(self) -> list[str]: + """返回已缓存文件名列表(去扩展名)。""" + return [f.stem for f in self._files] + + def refresh(self) -> int: + """重新扫描目录,返回新增文件数。""" + old_count = len(self._files) + old_set = set(self._files) + self._files = sorted(self._path.glob(self._glob_pattern)) + new_set = set(self._files) + added = len(new_set - old_set) + logger.info("DirConnector '%s': refresh 完成,新增 %d 个文件", self._name, added) + return added diff --git a/shared/exam_memory/pyproject.toml b/shared/exam_memory/pyproject.toml new file mode 100644 index 0000000..992ba6e --- /dev/null +++ b/shared/exam_memory/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "exam-memory" +version = "2.0.0" +description = "考试经验沉淀 MCP Server — V2 语义检索 + FTS 词法混合" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +embed = [ + "sentence-transformers>=2.2.0", + "numpy>=1.24.0", +] +ingest = [ + "docling>=2.0", + "gitingest>=0.1", + "PyGithub>=2.0", + "beautifulsoup4>=4.12", +] +generate = [ + "litellm>=1.0", +] +full = [ + "sentence-transformers>=2.2.0", + "numpy>=1.24.0", + "docling>=2.0", + "gitingest>=0.1", + "PyGithub>=2.0", + "beautifulsoup4>=4.12", + "litellm>=1.0", +] + +[project.scripts] +exam-memory = "exam_memory.server:main" + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.backends._legacy:_Backend" diff --git a/shared/exam_memory/question_bank.py b/shared/exam_memory/question_bank.py new file mode 100644 index 0000000..d5181e1 --- /dev/null +++ b/shared/exam_memory/question_bank.py @@ -0,0 +1,884 @@ +"""question_bank.py — 题库 CRUD + LLM 生成管道(Phase 2.1)。 + +对齐 exam-memory V2 设计: + - 文件格式:YAML frontmatter + Markdown body(与 experiences/ 平行) + - 检索层:复用 hybrid_search,不独立建索引 + - 零强制依赖:LLM 生成可选(litellm),骨架可用无依赖 + +用法: + from exam_memory.question_bank import QuestionBank + bank = QuestionBank() + bank.add_manual(type="算法", knowledge="双指针", title="两数之和", + content="...", answer="C", options={...}, explanation="...") + results = bank.generate(topic="双指针", count=3, q_type="算法") +""" + +from __future__ import annotations + +import glob +import logging +import os +import re +import uuid +from pathlib import Path +from typing import Any + +import yaml + +from exam_memory.frontmatter import parse_frontmatter as _parse_frontmatter +from exam_memory.frontmatter import body_text as _body + +logger = logging.getLogger(__name__) + +# ── 路径常量 ────────────────────────────────────────────────── + +BASE_DIR = Path(__file__).resolve().parent +BANK_DIR = BASE_DIR / "bank" + +# ── 题型映射 ────────────────────────────────────────────────── + +TYPE_PREFIX: dict[str, str] = { + "单选题": "单选题", + "多选题": "多选题", + "算法": "算法", +} + +DIFFICULTY_OPTIONS = ("简单", "中等", "困难") + +# ── 提取模式 ────────────────────────────────────────────────── + +MODE_RAG = "rag" # 从 hybrid_search 检索 chunks 作为上下文 +MODE_TEXT = "text" # 直接使用提供的文本内容作为上下文 +MODE_DIRECT = "direct" # 无上下文,纯知识出题 + + +# ── 文件工具 ────────────────────────────────────────────────── + + +def _validate_id(question_id: str, base_dir: Path) -> Path: + """验证 question_id 不含路径穿越。返回安全的 resolved Path。""" + target = (base_dir / f"{question_id}.md").resolve() + if not target.is_relative_to(base_dir.resolve()): + raise ValueError(f"非法 question_id(路径穿越): {question_id}") + return target + + +def _next_seq(prefix: str, bank_dir: Path = BANK_DIR) -> int: + """扫描 bank/ 目录,返回下一个自增序号。""" + pattern = str(bank_dir / f"{prefix}_*.md") + existing = glob.glob(pattern) + max_n = 0 + for fp in existing: + m = re.search(r"_(\d{3})(?:_[0-9a-f]{6})?\.md$", fp) + if m: + max_n = max(max_n, int(m.group(1))) + return max_n + 1 + + +def _build_filename(q_type: str, knowledge: str, bank_dir: Path = BANK_DIR, seq: int | None = None) -> str: + """构造标准文件名:{type}_{knowledge}_{seq:03d}_{uuid6}.md""" + prefix = TYPE_PREFIX.get(q_type, q_type) + if seq is None: + seq = _next_seq(prefix, bank_dir=bank_dir) + safe_knowledge = re.sub(r'[^\w一-鿿-]', '_', knowledge).strip("_") + short_id = uuid.uuid4().hex[:6] + return f"{prefix}_{safe_knowledge}_{seq:03d}_{short_id}.md" + + +# ── LLM 生成管道 ────────────────────────────────────────────── + +class PromptBuilder: + """构造题库生成 prompt(system + user context + format spec)。""" + + SYSTEM_TEMPLATE = """\ +你是算法面试题库出题专家。根据提供的参考资料,生成高质量的考试题目。 + +要求: +1. 题干清晰,无歧义,答案唯一确定 +2. 选项设计有区分度(常见错误选项应反映典型误区) +3. 解析详细,包含解题思路、关键步骤和复杂度分析 +4. 严格遵循输出格式""" + + FORMAT_TEMPLATE = """\ +对每道题,按以下格式输出(每道题用 --- 分隔): + +--- +## 题干 +题目描述 + +### 选项 +**A.** 选项A +**B.** 选项B +**C.** 选项C +**D.** 选项D + +### 答案 +C + +### 解析 +详细解析 +--- + +数量:{count} 道 +题型:{q_type} +知识点:{topic}""" + + def build( + self, + chunks: list[dict], + topic: str, + q_type: str, + count: int, + mode: str = MODE_RAG, + source_text: str = "", + ) -> tuple[str, str]: + """返回 (system_prompt, user_prompt)。 + + mode: + MODE_RAG — chunks 来自检索,拼接为参考资料(默认,向后兼容) + MODE_TEXT — source_text 直接作为上下文,忽略 chunks + MODE_DIRECT — 无上下文,纯知识出题 + """ + if mode == MODE_TEXT: + ctx = source_text.strip() if source_text else "(无参考资料,请根据你的知识出题)" + elif mode == MODE_DIRECT: + ctx = "" + else: # MODE_RAG + ctx = "\n\n".join( + f"[参考资料 {i+1}]\n{c.get('text', '')}" + for i, c in enumerate(chunks[:5]) + ) if chunks else "(无参考资料,请根据你的知识出题)" + + system = self.SYSTEM_TEMPLATE + if mode == MODE_DIRECT: + user = ( + f"知识点:{topic}\n\n" + + self.FORMAT_TEMPLATE.format(count=count, q_type=q_type, topic=topic) + ) + else: + user = ( + f"知识点:{topic}\n\n" + f"参考资料:\n{ctx}\n\n" + + self.FORMAT_TEMPLATE.format(count=count, q_type=q_type, topic=topic) + ) + return system, user + +class QuestionParser: + """将 LLM 原始输出解析为结构化题目列表。 + + 输入格式(每道题用 --- 分隔): + --- + ## <标题> + 题干内容(Markdown,到 ### 选项 前) + ### 选项 + **A.** ... + **B.** ... + **C.** ... + **D.** ... + ### 答案 + C + ### 解析 + 详细解析 + --- + """ + + SEP_PATTERN = re.compile(r"^---\s*$", re.MULTILINE) + ANSWER_PATTERN = re.compile( + r"^###\s*(?:答案|Answer)\s*\n(.*?)(?=^###\s*(?:解析|Explanation)|\Z)", + re.MULTILINE | re.DOTALL | re.IGNORECASE, + ) + EXPLANATION_PATTERN = re.compile( + r"^###\s*(?:解析|Explanation)\s*\n(.*?)(?=^---\s*$|^##\s|\Z)", + re.MULTILINE | re.DOTALL, + ) + OPTIONS_BLOCK_PATTERN = re.compile( + r"^###\s*(?:选项|Options)\s*\n(.*?)(?=^###\s*(?:答案|Answer))", + re.MULTILINE | re.DOTALL | re.IGNORECASE, + ) + OPTION_LINE_PATTERN = re.compile(r"^\s*\*\*([A-D])\.\*\*\s*(.+)$", re.MULTILINE) + + def parse(self, raw_output: str) -> list[dict[str, Any]]: + """解析 LLM 输出,返回 [{stem, options, answer, explanation}, ...] 列表。""" + blocks = self.SEP_PATTERN.split(raw_output) + questions: list[dict[str, Any]] = [] + + for block in blocks: + block = block.strip() + if not block: + continue + + title_match = re.search(r"^##\s+.+$", block, re.MULTILINE) + options_match = self.OPTIONS_BLOCK_PATTERN.search(block) + + if options_match: + stem_start = title_match.end() if title_match else 0 + raw_stem = block[stem_start:options_match.start()].strip() + stem = re.sub(r"^##\s*.+\n?", "", raw_stem).strip() + else: + stem = "" + + options_raw = options_match.group(1) if options_match else "" + answer = self._extract(block, self.ANSWER_PATTERN) + explanation = self._extract(block, self.EXPLANATION_PATTERN) + + options = self._parse_options(options_raw) + if not stem or not answer: + logger.warning("解析跳过(缺题干或答案):%s", stem[:30] if stem else "N/A") + continue + + questions.append({ + "stem": stem, + "options": options, + "answer": answer.strip().upper(), + "explanation": explanation, + }) + + return questions + + def _extract(self, text: str, pattern) -> str: + m = pattern.search(text) + return m.group(1).strip() if m else "" + + def _parse_options(self, raw: str) -> dict[str, str]: + options: dict[str, str] = {} + for m in self.OPTION_LINE_PATTERN.finditer(raw): + options[m.group(1)] = m.group(2).strip() + return options + +class QualityValidator: + """题目质量校验:过滤不合格条目。""" + + def __init__( + self, + min_options: int = 4, + require_single_answer: bool = True, + require_explanation: bool = True, + ): + self.min_options = min_options + self.require_single_answer = require_single_answer + self.require_explanation = require_explanation + + def validate(self, questions: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]: + """返回 (valid, invalid) 元组。""" + valid, invalid = [], [] + for q in questions: + reasons = self._check(q) + if reasons: + q["_reject_reason"] = "; ".join(reasons) + invalid.append(q) + else: + valid.append(q) + return valid, invalid + + def _check(self, q: dict) -> list[str]: + reasons: list[str] = [] + answer = q.get("answer", "") + options = q.get("options", {}) + + if not q.get("stem"): + reasons.append("题干为空") + if len(options) < self.min_options: + reasons.append(f"选项不足 {self.min_options} 个(实际 {len(options)})") + if self.require_single_answer: + if len(answer) != 1: + reasons.append(f"答案格式错误:{answer!r}") + if answer not in "ABCD": + reasons.append(f"答案超出范围:{answer!r}") + if self.require_explanation and not q.get("explanation"): + reasons.append("解析为空") + + return reasons + + +# ── 审核门控 ────────────────────────────────────────────────── + +class ReviewGate: + """题目审核门控:自动校验 + 人工 approve/reject。 + + 用法: + gate = ReviewGate(bank) + result = gate.review_gate("算法_双指针_001") + # result = {"decision": "approve"|"reject", "reasons": [...]} + gate.approve("算法_双指针_001") # 人工确认通过 + gate.reject("算法_双指针_001") # 人工拒绝 + """ + + def __init__(self, bank: "QuestionBank"): + self._bank = bank + self._validator = QualityValidator() + + def review_gate(self, question_id: str) -> dict[str, Any]: + """自动审核一道题。返回 {"decision": "approve"|"reject", "reasons": [...]}。""" + item = self._bank.get(question_id) + if item is None: + return {"decision": "reject", "reasons": [f"题目不存在:{question_id}"]} + + reasons: list[str] = [] + + # 已审核通过的不重复审核 + if item.get("reviewed"): + return {"decision": "approve", "reasons": ["已审核通过"]} + + # 构造 validator 可检查的 dict + q_for_check = { + "stem": item.get("body", ""), + "options": self._extract_options(item.get("body", "")), + "answer": item.get("answer", ""), + "explanation": self._extract_explanation(item.get("body", "")), + } + check_reasons = self._validator._check(q_for_check) + reasons.extend(check_reasons) + + decision = "approve" if not reasons else "reject" + return {"decision": decision, "reasons": reasons} + + def approve(self, question_id: str) -> bool: + """人工审核通过:设置 reviewed=True。返回是否成功。""" + return self._set_reviewed(question_id, True) + + def reject(self, question_id: str) -> bool: + """人工拒绝:设置 reviewed=False。返回是否成功。""" + return self._set_reviewed(question_id, False) + + def _set_reviewed(self, question_id: str, reviewed: bool) -> bool: + """更新 frontmatter 的 reviewed 字段。""" + target = _validate_id(question_id, self._bank.bank_dir) + if not target.exists(): + return False + text = target.read_text(encoding="utf-8") + fm = _parse_frontmatter(text) + fm["reviewed"] = reviewed + body = _body(text) + doc = QuestionBank._render(fm, body) + target.write_text(doc, encoding="utf-8") + return True + + @staticmethod + def _extract_options(body: str) -> dict[str, str]: + """从正文中提取选项。""" + options: dict[str, str] = {} + for m in re.finditer(r"^\s*\*\*([A-D])\.\*\*\s*(.+)$", body, re.MULTILINE): + options[m.group(1)] = m.group(2).strip() + return options + + @staticmethod + def _extract_explanation(body: str) -> str: + """从正文中提取解析。""" + m = re.search( + r"^###\s*(?:解析|Explanation)\s*\n(.*?)(?=^##\s|\Z)", + body, re.MULTILINE | re.DOTALL, + ) + return m.group(1).strip() if m else "" + + +# ── 主类 ────────────────────────────────────────────────────── + +class QuestionBank: + """题库 CRUD + 生成管道。 + + 不强制依赖 litellm;generate() 在 litellm 不可用时 + 返回原始文本,由调用方处理。 + """ + + def __init__(self, bank_dir: str | Path | None = None): + if bank_dir: + self.bank_dir = Path(bank_dir) + else: + self.bank_dir = BANK_DIR + self.bank_dir.mkdir(parents=True, exist_ok=True) + self._parser = QuestionParser() + self._validator = QualityValidator() + self._stem_prefix_len = 50 # 去重:题干前 N 字符比对 + + # ── 去重 ────────────────────────────────────────────────── + + def check_duplicate(self, stem: str, question_id: str = "") -> list[str]: + """检查是否存在重复题目。返回重复原因列表(空 = 无重复)。 + + 规则: + - stem 前缀比对(前 50 字符标准化后完全匹配) + - question_id 唯一性(如果传入) + """ + reasons: list[str] = [] + norm_stem = self._normalize_stem(stem) + if not norm_stem: + return reasons + + for fp in self.bank_dir.glob("*.md"): + item = self._load_file(fp) + if item is None: + continue + + # question_id 唯一性 + if question_id and item.get("question_id") == question_id: + reasons.append(f"question_id 已存在:{question_id}") + continue + + # stem 前缀比对 + existing_body = item.get("body", "") + existing_title = _extract_title(existing_body) or "" + # 从 body 提取 stem(## 标题行后到 ### 之间的内容) + stem_match = re.search( + r"^##\s+[^\n]+\n(.*?)(?=^###|\Z)", existing_body, + re.MULTILINE | re.DOTALL, + ) + existing_stem_text = stem_match.group(1).strip() if stem_match else existing_title + norm_existing = self._normalize_stem(existing_stem_text) + + if norm_stem == norm_existing: + reasons.append( + f"题干前缀重复:'{norm_stem[:30]}...' 与 {item.get('question_id', '?')} 相同" + ) + + return reasons + + def _normalize_stem(self, stem: str) -> str: + """标准化题干:去除空白和标点,取前 N 字符。""" + s = re.sub(r'\s+', '', stem) + s = re.sub(r'[,。?!、;:""''【】《》()]', '', s) + return s[:self._stem_prefix_len] + + # ── CRUD ──────────────────────────────────────────────── + + def add_manual( + self, + title: str, + content: str, + q_type: str, + knowledge: str, + answer: str, + options: dict[str, str] | None = None, + explanation: str = "", + difficulty: str = "中等", + source: str = "manual", + source_url: str = "", + tags: list[str] | None = None, + reviewed: bool = False, + generated: bool = False, + check_dup: bool = False, + ) -> str: + """人工录入一道题。返回写入的文件名。 + + check_dup: True 时检查 stem 前缀重复(默认 False,保持向后兼容)。 + """ + if q_type not in TYPE_PREFIX: + raise ValueError(f"不支持的题型:{q_type}(支持:{list(TYPE_PREFIX)})") + if difficulty not in DIFFICULTY_OPTIONS: + raise ValueError(f"不支持的难度:{difficulty}(支持:{DIFFICULTY_OPTIONS})") + + # 去重检查(opt-in) + if check_dup: + dup_reasons = self.check_duplicate(content) + if dup_reasons: + raise ValueError(f"题目重复:{'; '.join(dup_reasons)}") + + filename = _build_filename(q_type, knowledge, bank_dir=self.bank_dir) + filepath = self.bank_dir / filename + + today = _today() + fm: dict[str, Any] = { + "type": q_type, + "knowledge": knowledge, + "difficulty": difficulty, + "source": source, + "generated": generated, + "reviewed": reviewed, + "question_id": filename.replace(".md", ""), + "tags": tags or [], + "created": today, + } + if source_url: + fm["source_url"] = source_url + + options = options or {} + option_lines = "\n".join( + f"**{k}.** {v}" for k, v in sorted(options.items()) + ) + + body = ( + f"## {title}\n\n" + f"{content}\n\n" + f"### 选项\n\n{option_lines}\n\n" + f"### 答案\n\n{answer}\n\n" + f"### 解析\n\n{explanation}\n" + ) + + doc = self._render(fm, body) + filepath.write_text(doc, encoding="utf-8") + logger.info("已保存题目: %s", filename) + return filename + + def add(self, question: dict, check_dup: bool = True) -> str: + """从 dict 保存一道题。 + + 支持两种输入格式: + - 完整格式(add_manual 调用方): title, content, type, knowledge, answer, ... + - 解析器格式(generate 管道): stem, options, answer, explanation, type, knowledge + + check_dup: 默认 True,自动检查 stem 重复。管道调用时启用。 + """ + if "stem" in question: + return self.add_manual( + title=question.get("stem", "未命名题目")[:50], + content=question["stem"], + q_type=question["type"], + knowledge=question["knowledge"], + answer=question["answer"], + options=question.get("options"), + explanation=question.get("explanation", ""), + difficulty=question.get("difficulty", "中等"), + source=question.get("source", "llm"), + source_url=question.get("source_url", ""), + tags=question.get("tags", []), + reviewed=False, + generated=True, + check_dup=check_dup, + ) + return self.add_manual( + title=question["title"], + content=question["content"], + q_type=question["type"], + knowledge=question["knowledge"], + answer=question["answer"], + options=question.get("options"), + explanation=question.get("explanation", ""), + difficulty=question.get("difficulty", "中等"), + source=question.get("source", "llm"), + source_url=question.get("source_url", ""), + tags=question.get("tags", []), + reviewed=False, + check_dup=check_dup, + ) + + def get(self, question_id: str) -> dict | None: + """按 question_id 读取题目。""" + target = _validate_id(question_id, self.bank_dir) + if not target.exists(): + return None + return self._load_file(target) + + def list_all( + self, + q_type: str | None = None, + knowledge: str | None = None, + ) -> list[dict]: + """列出题库,支持题型/知识点过滤。""" + results: list[dict] = [] + for fp in sorted(self.bank_dir.glob("*.md")): + item = self._load_file(fp) + if q_type and item.get("type") != q_type: + continue + if knowledge and item.get("knowledge") != knowledge: + continue + results.append(item) + return results + + def delete(self, question_id: str) -> bool: + """删除题目。返回是否成功。""" + target = _validate_id(question_id, self.bank_dir) + if not target.exists(): + return False + target.unlink() + logger.info("已删除: %s", question_id) + return True + + def count(self, q_type: str | None = None) -> int: + """统计题目数(可过滤题型)。""" + if q_type: + return len(self.list_all(q_type=q_type)) + return len(list(self.bank_dir.glob("*.md"))) + + # ── 生成管道 ──────────────────────────────────────────── + + def generate( + self, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + top_k: int = 5, + ) -> dict[str, Any]: + """RAG + LLM 生成题目管道(向后兼容入口)。 + + 等价于 rag_extract()。保留用于已有调用方。 + """ + return self.rag_extract(topic, count, q_type, difficulty, top_k) + + def rag_extract( + self, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + top_k: int = 5, + ) -> dict[str, Any]: + """RAG 模式:hybrid_search 检索 → prompt → LLM → 解析 → 校验 → 保存。""" + chunks = self._retrieve(topic, q_type, top_k) + system_prompt, user_prompt = PromptBuilder().build( + chunks, topic, q_type, count, mode=MODE_RAG, + ) + return self._run_pipeline(system_prompt, user_prompt, topic, q_type, difficulty) + + def text_extract( + self, + source_text: str, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """文本模式:直接使用提供的文本内容作为上下文出题。""" + system_prompt, user_prompt = PromptBuilder().build( + [], topic, q_type, count, + mode=MODE_TEXT, source_text=source_text, + ) + return self._run_pipeline(system_prompt, user_prompt, topic, q_type, difficulty) + + def direct_extract( + self, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """直出模式:无上下文,纯靠 LLM 知识出题。""" + system_prompt, user_prompt = PromptBuilder().build( + [], topic, q_type, count, mode=MODE_DIRECT, + ) + return self._run_pipeline(system_prompt, user_prompt, topic, q_type, difficulty) + + def _run_pipeline( + self, + system_prompt: str, + user_prompt: str, + topic: str, + q_type: str, + difficulty: str, + ) -> dict[str, Any]: + """共享管道:call_llm → parse → validate → save。""" + result: dict[str, Any] = { + "saved": [], + "validated": [], + "rejected": [], + "raw_llm_output": None, + "error": None, + } + + raw = self._call_llm(system_prompt, user_prompt) + if raw is None: + result["error"] = "LLM 不可用(litellm 未安装或未配置 API key)" + return result + result["raw_llm_output"] = raw + + questions = self._parser.parse(raw) + if not questions: + result["error"] = "LLM 输出未解析出任何题目" + return result + + valid, rejected = self._validator.validate(questions) + result["rejected"] = rejected + + for q in valid: + q["type"] = q_type + q["knowledge"] = topic + q["difficulty"] = difficulty + try: + fname = self.add(q) + except ValueError as e: + q["_reject_reason"] = str(e) + result["rejected"].append(q) + continue + result["saved"].append(fname) + loaded = self._load_file(self.bank_dir / fname) + if loaded: + result["validated"].append(loaded) + + return result + + # ── 检索 ───────────────────────────────────────────────── + + def search( + self, + query: str, + limit: int = 5, + q_type: str | None = None, + ) -> list[dict]: + """复用 hybrid_search 搜索题库(不独立建索引)。 + + 扫描 bank/ 的 frontmatter 做过滤,返回完整题目内容。 + """ + try: + from exam_memory.hybrid_search import hybrid_search + from exam_memory.fts_store import FTSStore + from exam_memory.vector_store import NumpyVectorStore + + fts = FTSStore() + vec = NumpyVectorStore() + hits = hybrid_search(query, fts, vec, limit=limit, exp_type=q_type) + fts.close() + except Exception as e: + logger.debug("hybrid_search 失败,降级为全文扫描:%s", e) + hits = [] + + if not hits: + return self._fallback_search(query, limit, q_type) + + results: list[dict] = [] + for hit in hits: + text = hit.get("text", "") + meta = hit.get("metadata", {}) + qid = meta.get("file_name", "").replace(".md", "") + if qid: + full = self.get(qid) + if full: + results.append(full) + continue + results.append({ + "question_id": qid or "", + "title": meta.get("title", ""), + "text": text, + "score": hit.get("score"), + }) + return results + + def import_from_dir(self, path: str | Path) -> int: + """批量导入 Markdown 题库文件到 bank/。返回导入条数。""" + src = Path(path) + if not src.is_dir(): + raise ValueError(f"目录不存在:{path}") + + # 建立已有文件的索引: (type, knowledge) -> filename + existing: set[tuple[str, str]] = set() + for fp in self.bank_dir.glob("*.md"): + text = fp.read_text(encoding="utf-8") + em = _parse_frontmatter(text) + existing.add((em.get("type", ""), em.get("knowledge", ""))) + + imported = 0 + for fp in sorted(src.glob("*.md")): + text = fp.read_text(encoding="utf-8") + fm = _parse_frontmatter(text) + body = _body(text) + + q_type = fm.get("type", "") + if q_type not in TYPE_PREFIX: + logger.warning("跳过(未知题型 %s):%s", q_type, fp.name) + continue + + knowledge = fm.get("knowledge", "未分类") + if (q_type, knowledge) in existing: + logger.debug("已存在(%s / %s),跳过", q_type, knowledge) + continue + + filename = _build_filename(q_type, knowledge, bank_dir=self.bank_dir) + fm.setdefault("question_id", filename.replace(".md", "")) + fm.setdefault("imported", True) + fm.setdefault("source", fm.get("source", fp.name)) + + doc = self._render(fm, body) + (self.bank_dir / filename).write_text(doc, encoding="utf-8") + existing.add((q_type, knowledge)) + imported += 1 + + logger.info("导入完成:%d 条 -> %s", imported, self.bank_dir) + return imported + + # ── 内部方法 ───────────────────────────────────────────── + + def _retrieve(self, topic: str, q_type: str, top_k: int) -> list[dict]: + """从 experiences/ + bank/ 检索相关 chunks。""" + try: + from exam_memory.hybrid_search import hybrid_search + from exam_memory.fts_store import FTSStore + from exam_memory.vector_store import NumpyVectorStore + + fts = FTSStore() + vec = NumpyVectorStore() + hits = hybrid_search(topic, fts, vec, limit=top_k, exp_type=q_type) + fts.close() + return hits + except Exception as e: + logger.debug("检索失败:%s", e) + return [] + + def _call_llm(self, system: str, user: str) -> str | None: + """调用 LLM(litellm),失败返回 None。""" + try: + from litellm import completion + except ImportError: + logger.info("litellm 未安装,跳过 LLM 调用(pip install '.[generate]')") + return None + + try: + resp = completion( + model="openai/gpt-4o-mini", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=0.7, + max_tokens=4000, + ) + return resp.choices[0].message.content or "" + except Exception as e: + logger.error("LLM 调用失败:%s", e) + return None + + def _fallback_search( + self, query: str, limit: int, q_type: str | None + ) -> list[dict]: + """hybrid_search 不可用时的降级:全文扫描 + 简单关键词匹配。""" + results: list[dict] = [] + q_lower = query.lower() + for fp in sorted(self.bank_dir.glob("*.md")): + if len(results) >= limit: + break + item = self._load_file(fp) + if q_type and item.get("type") != q_type: + continue + text_blob = f"{item.get('title','')} {item.get('body','')}".lower() + if q_lower in text_blob or not q_lower: + results.append(item) + return results + + def _load_file(self, fp: Path) -> dict | None: + """读取 .md 文件,返回含 frontmatter + body + raw 的 dict。""" + text = fp.read_text(encoding="utf-8") + fm = _parse_frontmatter(text) + body = _body(text) + answer_match = re.search(r"^###\s*答案\s*\n(.+?)(?:\n\n|\Z)", body, re.MULTILINE) + return { + "question_id": fm.get("question_id", fp.stem), + "file_name": fp.name, + "type": fm.get("type", ""), + "knowledge": fm.get("knowledge", ""), + "difficulty": fm.get("difficulty", "中等"), + "source": fm.get("source", ""), + "source_url": fm.get("source_url", ""), + "generated": fm.get("generated", False), + "reviewed": fm.get("reviewed", False), + "tags": fm.get("tags", []), + "created": fm.get("created", ""), + "title": _extract_title(text) or fm.get("knowledge", fp.stem), + "body": body, + "answer": answer_match.group(1).strip() if answer_match else "", + "raw": text, + } + + @staticmethod + def _render(fm: dict, body: str) -> str: + yaml_str = yaml.dump(fm, allow_unicode=True, default_flow_style=False) + return f"---\n{yaml_str}---\n\n{body}" + + +# ── 辅助函数 ────────────────────────────────────────────────── + +def _extract_title(text: str) -> str | None: + """提取正文第一个 ## 标题。""" + m = re.search(r"^##\s+(.+)$", text, re.MULTILINE) + return m.group(1).strip() if m else None + + +def _today() -> str: + from datetime import datetime + return datetime.now().strftime("%Y-%m-%d") diff --git a/shared/exam_memory/rebuild_index.py b/shared/exam_memory/rebuild_index.py new file mode 100644 index 0000000..9cc0909 --- /dev/null +++ b/shared/exam_memory/rebuild_index.py @@ -0,0 +1,132 @@ +"""rebuild_index.py — CLI 全量重建向量索引 + FTS 词法索引。 + +用法: + python -m exam_memory.rebuild_index # 全量重建 + python -m exam_memory.rebuild_index --type 算法 # 只重建指定类型 + python -m exam_memory.rebuild_index --force # 强制覆盖已有索引 + python -m exam_memory.rebuild_index --fts-only # 仅重建 FTS + python -m exam_memory.rebuild_index --vec-only # 仅重建向量 + python -m exam_memory.rebuild_index --dry-run # 预览不写入 +""" +from __future__ import annotations + +import argparse +import glob +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def _parse_fp(fp: str): + text = Path(fp).read_text(encoding="utf-8") + meta = {} + body = text + if text.startswith("---"): + parts = text.split("---", 2) + try: + import yaml + meta = yaml.safe_load(parts[1]) or {} + except Exception: + pass + body = parts[2].strip() if len(parts) >= 3 else "" + name = Path(fp).name + meta["file_name"] = name + meta.setdefault("id", name) + return meta, body + + +def main() -> int: + ap = argparse.ArgumentParser( + description="exam-memory 索引全量重建" + ) + ap.add_argument("--type", choices=["单选题", "多选题", "算法"], + help="仅重建指定类型") + ap.add_argument("--force", action="store_true") + ap.add_argument("--verbose", "-v", action="store_true") + ap.add_argument("--fts-only", action="store_true") + ap.add_argument("--vec-only", action="store_true") + ap.add_argument("--dry-run", action="store_true") + args = ap.parse_args() + + from exam_memory.vector_store import BASE_DIR, NumpyVectorStore + from exam_memory.fts_store import FTSStore + from exam_memory.embedding import is_available, encode + + exp_dir = BASE_DIR / "experiences" + type_filter = args.type + + all_files = glob.glob(str(exp_dir / "*.md")) + if type_filter: + files = [fp for fp in all_files + if _parse_fp(fp)[0].get("type") == type_filter] + else: + files = all_files + + if not files: + print("[rebuild] experiences/ 为空,无需重建") + return 0 + + docs = [_parse_fp(fp) for fp in files] + metas = [m for m, _ in docs] + bodies = [b for _, b in docs] + total_size = sum(len(b) for b in bodies) + latest = max(Path(fp).stat().st_mtime for fp in files) + latest_str = datetime.fromtimestamp(latest, tz=timezone.utc).isoformat() + + print(f"[rebuild] 扫描到 {len(files)} 条经验({type_filter or '全部'})") + print(f"[rebuild] 签名: count={len(files)}, size={total_size}, mtime={latest_str}") + + if args.dry_run: + print("[rebuild] dry-run,不写入") + return 0 + + do_vec = not args.fts_only + if do_vec: + if not is_available(): + print("[rebuild] embedding 不可用,跳过向量(pip install '.[embed]')", + file=sys.stderr) + else: + store = NumpyVectorStore() + skip = not args.force and store.is_available and not args.vec_only + if skip: + print(f"[rebuild] 向量索引已存在({store.count} 条),加 --force 覆盖") + else: + print(f"[rebuild] 编码 {len(bodies)} 条...") + embs = encode(bodies) + if embs is not None: + store._embs = embs.astype("float32") + store._meta = [] + for i, m in enumerate(metas): + m.setdefault("signature", {})["file_count"] = len(files) + m.setdefault("signature", {})["total_size"] = total_size + m.setdefault("signature", {})["latest_mtime"] = latest_str + m["schema_version"] = 2 + store._meta.append(m) + store.save() + print(f"[rebuild] 向量完成: {len(metas)} 条, {store._embs.shape}") + else: + print("[rebuild] 编码失败", file=sys.stderr) + return 1 + + do_fts = not args.vec_only + if do_fts: + fts = FTSStore() + n = fts.count() + if n > 0 and not args.force: + print(f"[rebuild] FTS 已存在({n} 条),加 --force 覆盖") + else: + fts.clear() + docs2 = [{"canonical_key": m.get("file_name", m.get("id", "")), + "title": m.get("title", m.get("knowledge", "")), + "knowledge": m.get("knowledge", ""), + "content": b, "type": m.get("type", "")} + for m, b in docs] + fts.upsert_many(docs2) + print(f"[rebuild] FTS 完成: {len(docs2)} 条") + fts.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/exam_memory/server.py b/shared/exam_memory/server.py similarity index 54% rename from exam_memory/server.py rename to shared/exam_memory/server.py index 3bde588..685967d 100644 --- a/exam_memory/server.py +++ b/shared/exam_memory/server.py @@ -9,6 +9,7 @@ import json import os import re +import uuid from datetime import datetime, timezone from pathlib import Path @@ -18,6 +19,10 @@ from mcp.server.stdio import stdio_server from mcp.types import ServerCapabilities, ToolsCapability, Tool, TextContent +from exam_memory.knowledge_source import DirConnector +from exam_memory.source_registry import SourceRegistry +from exam_memory.frontmatter import parse_frontmatter as _parse_frontmatter + # ── 路径常量 ────────────────────────────────────────────── BASE_DIR = Path(__file__).resolve().parent EXPERIENCES_DIR = BASE_DIR / "experiences" @@ -25,6 +30,31 @@ EXPERIENCES_DIR.mkdir(parents=True, exist_ok=True) +# ── SourceRegistry(知识源生命周期管理)────────────────────── +_registry = SourceRegistry() + +SOURCES_YAML_PATH = BASE_DIR / "sources.yaml" + +# ── Store 实例缓存(避免每次 MCP tool call 重建)──────────────── +_fts_cache: "FTSStore | None" = None +_vec_cache: "NumpyVectorStore | None" = None + + +def _get_fts(): + global _fts_cache + if _fts_cache is None: + from exam_memory.fts_store import FTSStore + _fts_cache = FTSStore() + return _fts_cache + + +def _get_vec(): + global _vec_cache + if _vec_cache is None: + from exam_memory.vector_store import NumpyVectorStore + _vec_cache = NumpyVectorStore() + return _vec_cache + # ── 工具 JSON Schema ───────────────────────────────────── TOOL_SCHEMAS = [ Tool( @@ -117,6 +147,54 @@ "required": ["query"], }, ), + Tool( + name="mount_source", + description="读取 sources.yaml 配置文件,为每个 source 创建 DirConnector 并挂载到 registry。", + inputSchema={ + "type": "object", + "properties": { + "config_path": { + "type": "string", + "description": "sources.yaml 路径(可选,默认 BASE_DIR/sources.yaml)", + }, + }, + }, + ), + Tool( + name="list_sources", + description="返回已挂载知识源的状态列表。", + inputSchema={"type": "object", "properties": {}}, + ), + Tool( + name="fetch_from_source", + description="从指定知识源检索相关内容。", + inputSchema={ + "type": "object", + "properties": { + "source_name": {"type": "string", "description": "知识源名称"}, + "topic": {"type": "string", "description": "搜索关键词"}, + "limit": { + "type": "integer", + "default": 10, + "description": "最大返回 chunk 数", + }, + }, + "required": ["source_name", "topic"], + }, + ), + Tool( + name="refresh_source", + description="刷新指定知识源或全部知识源(重新扫描目录)。", + inputSchema={ + "type": "object", + "properties": { + "source_name": { + "type": "string", + "description": "知识源名称(可选,不传则刷新全部)", + }, + }, + }, + ), ] @@ -133,25 +211,12 @@ def _next_seq(prefix: str) -> int: existing = glob.glob(pattern) max_n = 0 for f in existing: - m = re.search(r"_(\d{3})\.md$", f) + m = re.search(r"_(\d{3})(?:_[0-9a-f]{6})?\.md$", f) if m: max_n = max(max_n, int(m.group(1))) return max_n + 1 -def _parse_frontmatter(text: str) -> dict: - """解析 Markdown frontmatter(YAML between ---)。""" - if not text.startswith("---"): - return {} - parts = text.split("---", 2) - if len(parts) < 3: - return {} - try: - return yaml.safe_load(parts[1]) or {} - except yaml.YAMLError: - return {} - - def _load_all_experiences(type_filter: str) -> list[tuple[int, str]]: """加载所有匹配类型的经验,返回 (error_count, full_content) 列表。""" prefix = _type_prefix(type_filter) @@ -205,8 +270,7 @@ def _list_experiences_semantic( exp_type: str, query: str, limit: int ) -> list[TextContent]: try: - from exam_memory.vector_store import NumpyVectorStore - store = NumpyVectorStore() + store = _get_vec() results = store.search(query, top_k=limit, type_filter=exp_type) except Exception: results = [] @@ -224,6 +288,59 @@ def _list_experiences_semantic( return [TextContent(type="text", text="\n---\n".join(parts))] +def _list_experiences_hybrid( + exp_type: str, query: str, limit: int +) -> list[TextContent]: + """混合检索:FTS BM25 + 向量 cosine,Weighted RRF 融合。""" + try: + from exam_memory.hybrid_search import hybrid_search + + fts = _get_fts() + vec = _get_vec() + results = hybrid_search(query, fts, vec, limit=limit, exp_type=exp_type) + except Exception: + results = [] + + if not results: + return [TextContent( + type="text", + text=f"混合检索「{query}」在「{exp_type}」类型中未找到匹配经验。" + )] + + parts = [] + for i, r in enumerate(results, 1): + score = r["score"] + fts_s = r.get("fts_score") + vec_s = r.get("vec_score") + detail = "" + if fts_s is not None and vec_s is not None: + detail = f" (FTS:{fts_s} + 向量:{vec_s})" + parts.append(f"### 经验 {i} (相关度: {score}{detail})\n{r['text']}") + return [TextContent(type="text", text="\n---\n".join(parts))] + + +def _list_experiences_fts( + exp_type: str, query: str, limit: int +) -> list[TextContent]: + """纯 FTS 词法检索(无需 embedding 依赖)。""" + try: + store = _get_fts() + results = store.search(query, limit=limit, type_filter=exp_type) + except Exception: + results = [] + + if not results: + return [TextContent( + type="text", + text=f"词法检索「{query}」在「{exp_type}」类型中未找到匹配经验。" + )] + + parts = [] + for i, r in enumerate(results, 1): + parts.append(f"### 经验 {i} (BM25: {r['score']})\n{r.get('content', '')}") + return [TextContent(type="text", text="\n---\n".join(parts))] + + # ── MCP Server ──────────────────────────────────────────── app = Server("exam-memory") @@ -243,7 +360,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: query = arguments.get("query", "") if query: - return _list_experiences_semantic(exp_type, query, limit) + return _list_experiences_hybrid(exp_type, query, limit) else: return _list_experiences_legacy(exp_type, limit) @@ -257,7 +374,9 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: prefix = _type_prefix(exp_type) seq = _next_seq(prefix) - filename = f"{prefix}_{knowledge}_{seq:03d}.md" + safe_knowledge = re.sub(r'[^\w一-鿿-]', '_', knowledge).strip("_") + short_id = uuid.uuid4().hex[:6] + filename = f"{prefix}_{safe_knowledge}_{seq:03d}_{short_id}.md" filepath = EXPERIENCES_DIR / filename today = datetime.now().strftime("%Y-%m-%d") @@ -273,26 +392,36 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: doc = f"---\n{yaml_str}---\n\n## {title}\n\n{content}\n" filepath.write_text(doc, encoding="utf-8") - # 静默向量化入库(失败不影响保存结果) + # 静默向量化 + FTS 入库(失败不影响保存结果) try: - from exam_memory.vector_store import NumpyVectorStore - store = NumpyVectorStore() + store = _get_vec() meta = dict(frontmatter) meta["file_name"] = filename store.upsert(filename, doc, meta) except Exception: pass + try: + fts = _get_fts() + fts.upsert( + canonical_key=filename, + title=title, + knowledge=knowledge, + content=content, + type=exp_type, + ) + except Exception: + pass + return [TextContent(type="text", text=f"已保存: {filename}")] # ── inc_error_count ── if name == "inc_error_count": fp = arguments["file_path"] - # 允许传入完整路径或仅文件名 - if os.sep in fp or "/" in fp: - target = Path(fp) - else: - target = EXPERIENCES_DIR / fp + # 始终在 EXPERIENCES_DIR 内解析,拒绝路径穿越 + target = (EXPERIENCES_DIR / fp).resolve() + if not target.is_relative_to(EXPERIENCES_DIR.resolve()): + return [TextContent(type="text", text=f"非法文件路径: {fp}")] if not target.exists(): return [TextContent(type="text", text=f"文件不存在: {target.name}")] @@ -339,6 +468,92 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: result = _search_ddg(query) return [TextContent(type="text", text=result)] + # ── mount_source ── + if name == "mount_source": + try: + config_path = Path(arguments.get("config_path", "")) if arguments.get("config_path") else SOURCES_YAML_PATH + if not config_path.exists(): + return [TextContent(type="text", text=f"配置文件不存在: {config_path}")] + with open(config_path, encoding="utf-8") as f: + config = yaml.safe_load(f) + sources_list = config.get("sources", []) + if not sources_list: + return [TextContent(type="text", text="sources.yaml 中无 source 定义")] + + config_dir = config_path.parent + mounted = [] + for src in sources_list: + src_type = src.get("type", "") + if src_type != "local_dir": + continue + name_ = src["name"] + src_config = src.get("config", {}) + path = config_dir / src_config.get("path", "") + if not path.exists() or not path.is_dir(): + mounted.append(f"{name_}(目录不存在: {path})") + continue + glob_pattern = src_config.get("glob", "*.md") + processing = src.get("processing", {}) + chunk_size = processing.get("chunk_size", 1600) + connector = DirConnector(name=name_, path=path, glob_pattern=glob_pattern, chunk_size=chunk_size) + if _registry.mount(connector): + mounted.append(name_) + else: + mounted.append(f"{name_}(已存在或挂载失败)") + return [TextContent(type="text", text=f"已挂载: {', '.join(mounted)}")] + except Exception as e: + return [TextContent(type="text", text=f"mount_source 失败: {e}")] + + # ── list_sources ── + if name == "list_sources": + try: + sources = _registry.list_mounted() + if not sources: + return [TextContent(type="text", text="暂无已挂载知识源。")] + parts = [] + for s in sources: + parts.append(f"- {s['name']} ({s['source_type']}) | connected={s['connected']} | topics={s['topic_count']}") + return [TextContent(type="text", text="\n".join(parts))] + except Exception as e: + return [TextContent(type="text", text=f"list_sources 失败: {e}")] + + # ── fetch_from_source ── + if name == "fetch_from_source": + try: + source_name = arguments["source_name"] + topic = arguments["topic"] + limit = arguments.get("limit", 10) + chunks = _registry.fetch_from(source_name, topic, limit=limit) + if not chunks: + return [TextContent(type="text", text=f"源 '{source_name}' 未找到与 '{topic}' 相关的内容。")] + parts = [] + for i, c in enumerate(chunks, 1): + parts.append(f"### Chunk {i} (来源: {c['source']})\n{c['text']}") + return [TextContent(type="text", text="\n---\n".join(parts))] + except Exception as e: + return [TextContent(type="text", text=f"fetch_from_source 失败: {e}")] + + # ── refresh_source ── + if name == "refresh_source": + try: + source_name = arguments.get("source_name") + if source_name: + source = _registry.get(source_name) + if source is None: + return [TextContent(type="text", text=f"知识源不存在: {source_name}")] + added = source.refresh() + return [TextContent(type="text", text=f"已刷新 '{source_name}',新增 {added} 个文件。")] + else: + results = [] + for s in _registry.list_mounted(): + src = _registry.get(s["name"]) + if src: + added = src.refresh() + results.append(f"- {s['name']}: 新增 {added} 个文件") + return [TextContent(type="text", text="全部刷新完成:\n" + "\n".join(results))] + except Exception as e: + return [TextContent(type="text", text=f"refresh_source 失败: {e}")] + return [TextContent(type="text", text=f"未知工具: {name}")] diff --git a/shared/exam_memory/source_connector.py b/shared/exam_memory/source_connector.py new file mode 100644 index 0000000..9c4faed --- /dev/null +++ b/shared/exam_memory/source_connector.py @@ -0,0 +1,187 @@ +"""source_connector.py — 统一输入适配层。 + +将不同来源的输入(raw text / file path / chunks)适配为三类提取管道的调用。 + +用法: + from exam_memory.source_connector import SourceConnector + from exam_memory.question_bank import QuestionBank + + bank = QuestionBank() + connector = SourceConnector(bank) + + # 从文本 + result = connector.connect("哈希表是 O(1) 查找...", topic="哈希表") + + # 从文件 + result = connector.connect("/path/to/notes.md", topic="排序") + + # 从 chunks(RAG 检索结果) + result = connector.connect([{"text": "chunk1"}, {"text": "chunk2"}], topic="BFS") +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from exam_memory.question_bank import QuestionBank +from exam_memory.source_registry import SourceRegistry + +logger = logging.getLogger(__name__) + + +class SourceConnector: + """统一输入适配:自动识别输入类型,路由到对应的提取管道。""" + + def __init__(self, bank: QuestionBank, registry: SourceRegistry | None = None): + self._bank = bank + self._registry = registry + + def connect( + self, + source: str | list[dict], + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """统一入口:自动识别 source 类型并路由到对应提取管道。 + + source 类型判断: + - list[dict] → chunks → rag_extract + - 存在的文件路径 → 读取内容 → text_extract + - 其他 str → raw text → text_extract + + Returns: 与 rag_extract/text_extract 相同的 result dict。 + """ + if isinstance(source, list): + return self._from_chunks(source, topic, count, q_type, difficulty) + + if isinstance(source, str): + path = Path(source) + # 启发式:含路径分隔符或文件扩展名 → 当作文件路径 + if self._looks_like_path(source): + return self._from_file(path, topic, count, q_type, difficulty) + return self._from_text(source, topic, count, q_type, difficulty) + + return self._make_error(f"不支持的 source 类型:{type(source).__name__}") + + def from_text( + self, + text: str, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """显式文本模式入口。""" + return self._from_text(text, topic, count, q_type, difficulty) + + def from_file( + self, + path: str | Path, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """显式文件模式入口。""" + return self._from_file(Path(path), topic, count, q_type, difficulty) + + def from_chunks( + self, + chunks: list[dict], + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """显式 chunks(RAG)模式入口。""" + return self._from_chunks(chunks, topic, count, q_type, difficulty) + + def from_source( + self, + source_name: str, + topic: str, + count: int = 3, + q_type: str = "算法", + difficulty: str = "中等", + ) -> dict[str, Any]: + """从已挂载知识源检索并出题。""" + if self._registry is None: + return self._make_error("registry 未配置,无法使用 from_source") + + source = self._registry.get(source_name) + if source is None: + return self._make_error(f"知识源不存在:{source_name}") + + chunks = source.fetch(topic) + if not chunks: + return self._make_error(f"知识源 '{source_name}' 未找到与 '{topic}' 相关的内容") + + combined = "\n\n".join(c.get("text", "") for c in chunks if c.get("text")) + if not combined.strip(): + return self._make_error(f"知识源 '{source_name}' 返回内容为空") + return self._call_text_extract(combined, topic, count, q_type, difficulty) + + # ── 内部路由 ────────────────────────────────────────────── + + def _from_text(self, text: str, topic: str, count: int, q_type: str, difficulty: str) -> dict: + if not text.strip(): + return self._make_error("source 文本为空") + return self._call_text_extract(text, topic, count, q_type, difficulty) + + def _from_file(self, path: Path, topic: str, count: int, q_type: str, difficulty: str) -> dict: + if not path.exists(): + return self._make_error(f"文件不存在:{path}") + try: + text = path.read_text(encoding="utf-8") + except Exception as e: + return self._make_error(f"文件读取失败:{e}") + if not text.strip(): + return self._make_error(f"文件内容为空:{path}") + return self._call_text_extract(text, topic, count, q_type, difficulty) + + def _from_chunks(self, chunks: list[dict], topic: str, count: int, q_type: str, difficulty: str) -> dict: + if not chunks: + return self._make_error("chunks 列表为空") + # 将 chunks 拼接为文本,走 text_extract + combined = "\n\n".join(c.get("text", "") for c in chunks if c.get("text")) + if not combined.strip(): + return self._make_error("chunks 中无有效文本") + return self._call_text_extract(combined, topic, count, q_type, difficulty) + + def _call_text_extract(self, text: str, topic: str, count: int, q_type: str, difficulty: str) -> dict: + """Call QuestionBank pipeline and convert expected validation errors.""" + try: + return self._bank.text_extract(text, topic, count, q_type, difficulty) + except ValueError as e: + return self._make_error(str(e)) + + @staticmethod + def _looks_like_path(s: str) -> bool: + """启发式判断字符串是否像文件路径。 + + 保守策略:仅当有已知文件扩展名或路径实际存在时才判定为路径。 + 避免 "算法/数据结构" 这类含 / 的中文文本被误判。 + """ + if not s: + return False + p = Path(s) + _KNOWN_EXTS = {".md", ".txt", ".py", ".json", ".yaml", ".yml", ".csv", ".html"} + if p.suffix.lower() in _KNOWN_EXTS: + return True + if p.exists(): + return True + return False + + @staticmethod + def _make_error(msg: str) -> dict[str, Any]: + return { + "saved": [], + "validated": [], + "rejected": [], + "raw_llm_output": None, + "error": msg, + } diff --git a/shared/exam_memory/source_registry.py b/shared/exam_memory/source_registry.py new file mode 100644 index 0000000..dc6f6d4 --- /dev/null +++ b/shared/exam_memory/source_registry.py @@ -0,0 +1,78 @@ +"""source_registry.py — SourceRegistry 生命周期管理。 + +管理已挂载知识源的 mount/unmount/list/fetch 操作。 + +用法: + from exam_memory.source_registry import SourceRegistry + from exam_memory.knowledge_source import DirConnector + + reg = SourceRegistry() + reg.mount(DirConnector(name="notes", path="./notes/")) + chunks = reg.fetch_from("notes", "哈希表") +""" + +from __future__ import annotations + +import logging +from typing import Any + +from exam_memory.knowledge_source import DirConnector, KnowledgeSource, SourceChunk + +logger = logging.getLogger(__name__) + + +class SourceRegistry: + """管理已挂载知识源的生命周期。""" + + def __init__(self) -> None: + self._sources: dict[str, KnowledgeSource] = {} + + def mount(self, source: KnowledgeSource) -> bool: + """挂载知识源。调用 connect(),成功注册,失败返回 False,已存在返回 False。""" + if source.name in self._sources: + logger.warning("SourceRegistry: 源 '%s' 已存在,跳过", source.name) + return False + if not source.connect(): + return False + self._sources[source.name] = source + logger.info("SourceRegistry: 挂载成功 '%s'", source.name) + return True + + def unmount(self, name: str) -> bool: + """移除已挂载源。存在返回 True,不存在返回 False。""" + if name not in self._sources: + return False + del self._sources[name] + logger.info("SourceRegistry: 卸载 '%s'", name) + return True + + def get(self, name: str) -> KnowledgeSource | None: + """获取已挂载源,不存在返回 None。""" + return self._sources.get(name) + + def list_mounted(self) -> list[dict[str, Any]]: + """返回已挂载源的状态列表。""" + return [ + { + "name": src.name, + "source_type": src.source_type, + "connected": src.connected, + "topic_count": len(src.list_topics()), + } + for src in self._sources.values() + ] + + def fetch_from(self, source_name: str, topic: str, limit: int = 10) -> list[SourceChunk]: + """从指定源检索,源不存在时返回空列表。""" + source = self._sources.get(source_name) + if source is None: + return [] + return source.fetch(topic, limit=limit) + + def fetch_all(self, topic: str, limit: int = 5) -> dict[str, list[SourceChunk]]: + """遍历所有已挂载源,合并检索结果。""" + result: dict[str, list[SourceChunk]] = {} + for name, source in self._sources.items(): + chunks = source.fetch(topic, limit=limit) + result[name] = chunks + return result diff --git a/shared/exam_memory/sources.yaml b/shared/exam_memory/sources.yaml new file mode 100644 index 0000000..04f0fa0 --- /dev/null +++ b/shared/exam_memory/sources.yaml @@ -0,0 +1,16 @@ +# sources.yaml — 知识源声明式配置 +# +# 每个 source 由 DirConnector 驱动,通过 MCP mount_source 工具加载。 +# path 相对于本文件所在目录。 +# +# 未来扩展 type: github / web / s3 等。 + +sources: + - name: "pdd-algo-notes" + type: "local_dir" + config: + path: "targets/pdd-algo/cheatsheets/" + glob: "*.md" + processing: + chunk_size: 1600 + tags_from_filename: true diff --git a/exam_memory/vector_store.py b/shared/exam_memory/vector_store.py similarity index 90% rename from exam_memory/vector_store.py rename to shared/exam_memory/vector_store.py index fadfac1..20b1064 100644 --- a/exam_memory/vector_store.py +++ b/shared/exam_memory/vector_store.py @@ -21,6 +21,8 @@ import numpy as np from exam_memory.embedding import encode_safe, EmbeddingError +from exam_memory.frontmatter import parse_frontmatter as _parse_frontmatter +from exam_memory.frontmatter import body_text as _extract_text # ── 路径 ───────────────────────────────────────────────────── @@ -29,41 +31,12 @@ EMB_PATH = VECTOR_DIR / "embeddings.npy" META_PATH = VECTOR_DIR / "metadata.json" -VECTOR_DIR.mkdir(parents=True, exist_ok=True) - # ── 类型别名 ───────────────────────────────────────────────── _TextOrMeta = tuple[str, dict[str, Any]] # (full_text, metadata) # ── 辅助 ───────────────────────────────────────────────────── -def _parse_frontmatter(text: str) -> dict[str, Any]: - """解析 Markdown YAML frontmatter,将 date 转为 str 以保证 JSON 序列化。""" - if not text.startswith("---"): - return {} - parts = text.split("---", 2) - if len(parts) < 3: - return {} - try: - import yaml - meta = yaml.safe_load(parts[1]) or {} - # YAML 的 2026-06-15 会被解析为 date 对象 → 转 str - for k, v in meta.items(): - if hasattr(v, "isoformat"): - meta[k] = v.isoformat() - return meta - except Exception: - return {} - - -def _extract_text(content: str) -> str: - """从 Markdown 提取正文(去掉 frontmatter)。""" - if content.startswith("---"): - parts = content.split("---", 2) - return parts[2].strip() if len(parts) >= 3 else content - return content - - def _load_full_text(filename: str, exp_dir: Path) -> str: """按文件名从 experiences/ 加载完整 Markdown。找不到返回空串。""" if not filename: @@ -106,6 +79,7 @@ def __init__(self): def save(self) -> None: if self._embs is not None and self._meta: + VECTOR_DIR.mkdir(parents=True, exist_ok=True) np.save(str(EMB_PATH), self._embs) META_PATH.write_text( json.dumps(self._meta, ensure_ascii=False, indent=2), @@ -216,6 +190,7 @@ def search( results.append({ "score": round(s, 4), "text": full_text, + "canonical_key": meta.get("file_name", meta.get("id", "")), "metadata": meta, }) return results diff --git a/shared/exam_memory/vectorstore/.gitkeep b/shared/exam_memory/vectorstore/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skills/algo-annotation.md b/skills/algo-annotation.md index a5fed4a..34559fb 100644 --- a/skills/algo-annotation.md +++ b/skills/algo-annotation.md @@ -22,7 +22,7 @@ The skill focuses on making the *why* visible, not just the *what*. When invoked, the skill produces annotated Python code with Chinese comments and, when needed, natural-language explanations of algorithm logic. Annotations follow the repository's established style: stage-separator comments, line-level Chinese comments, and `# [防错]` (pitfall) markers -linked to `algorithms/mistake_log.md`. +linked to `targets/{target}/mistake_log.md`. ## Core Principles @@ -36,7 +36,7 @@ Apply these principles when generating annotations or explanations. 4. Correct misconceptions precisely — name the exact step that is wrong and give the correct understanding. Avoid vague phrases like "you might be confused." 5. Close each explanation with a single sentence that captures the core invariant or trick. -6. Always cross-reference `algorithms/mistake_log.md` for the algorithm's known WA patterns +6. Always cross-reference `targets/{target}/mistake_log.md` for the algorithm's known WA patterns and annotate relevant lines with `# [防错]`. ## Annotation Checklist @@ -116,7 +116,7 @@ Follow these steps in order for each annotation request. 4. Trace a small example manually when the logic is non-trivial. Show the state after each significant step. 5. Summarize in one sentence — the core invariant, trick, or key insight. -6. Cross-reference `algorithms/mistake_log.md`. If the algorithm has recorded WA patterns, +6. Cross-reference `targets/{target}/mistake_log.md`. If the algorithm has recorded WA patterns, add `# [防错]` annotations to the relevant lines. If no entry exists, proceed without forcing a pitfall note. diff --git a/skills/choice-q-create.md b/skills/choice-q-create.md index f21cb0b..ff49897 100644 --- a/skills/choice-q-create.md +++ b/skills/choice-q-create.md @@ -1,7 +1,7 @@ --- name: choice-q-create description: > - Generate targeted multiple-choice question sets for the AI Lab campus recruitment exam. + Generate targeted multiple-choice question sets for the target exam (from exam_config.md). Use this skill whenever the user asks for new practice questions — even casual requests like "出几道题练练" or "帮我准备一下选择题" or "再来一套". Trigger phrases: "准备选择题", "出题", "新题", "选择题制作", "generate choice questions", "new mock", "新一轮选择题", @@ -14,25 +14,27 @@ description: > # Choice Question Creation Skill -Generate targeted multiple-choice question sets for the AI Lab campus recruitment exam. +Generate targeted multiple-choice question sets for the target exam. Combines web search, historical problem bank, mistake_log, and cross-session experience patterns from the exam-memory MCP server to create high-quality practice material. Outputs a self-contained markdown file ready for the `choice-q-drill` skill. The create skill reads from two feedback sources: `mistake_log.md` (local, quick-reference) -and `list_experiences` MCP (cross-session, structured by type/knowledge). Topics appearing +and `mcp__exam-memory__list_experiences` MCP (cross-session, structured by type/knowledge). Topics appearing in both sources get highest priority because they represent persistent weak points that haven't been resolved yet. ## 1. Exam Format Reference +> **从 `targets/{target}/exam_config.md` 读取**。以下为格式模板,运行时以 exam_config 为准。 + | Item | Value | |------|-------| -| 单选题 | 8 题 × 3 分 = 24 分 | -| 不定项选择题 | 7 题 × 4 分 = 28 分 | -| 选择题合计 | 15 题, 52 分 | -| 时间建议 | 30 分钟 | -| 多选策略 | 漏选比多选扣分少,不确定不选 | +| 单选题 | N 题 × X 分 = XX 分 | +| 不定项选择题 | N 题 × X 分 = XX 分 | +| 选择题合计 | {总题数} 题, {总分} 分 | +| 时间建议 | 见 exam_config.md | +| 多选策略 | 见 exam_config.md | ## 2. Topic Pool @@ -40,28 +42,28 @@ Draw questions from these 12 domains, balanced across rounds: | Category | Key Topics | Source | |----------|-----------|--------| -| 线性代数 | 特征值、SVD、正交/正定、行列式 | `llm/math_fundamentals.md` | -| 概率统计 | 贝叶斯、分布、独立vs不相关 | `llm/math_fundamentals.md` | -| 微积分/优化 | 链式法则、梯度、凸函数、SGD | `llm/math_fundamentals.md` | -| DL 基础 | Softmax、BN、CNN、参数量计算 | `llm/llm_core_cheatsheet.md` | -| Transformer | 注意力、位置编码、KV cache | `llm/llm_core_cheatsheet.md` | -| LLM 架构 | LLaMA3、GQA/MQA、RoPE、SwiGLU | `llm/llm_core_cheatsheet.md` | -| 微调/对齐 | LoRA/QLoRA、RLHF/DPO | `llm/llm_core_cheatsheet.md` | -| RAG/Agent | RAG流程、ReAct、Function Calling | `llm/llm_core_cheatsheet.md` | -| GNN | GCN vs GAT、过平滑、消息传递 | `llm/gnn_diffusion_cheatsheet.md` | -| 扩散模型 | DDPM/DDIM、CFG、Latent Diffusion | `llm/gnn_diffusion_cheatsheet.md` | -| 推理优化 | KV Cache、量化、FlashAttention | `llm/llm_core_cheatsheet.md` | -| 数据结构 | 栈/队列、排序、哈希 | `algorithms/mistake_log.md` | +| 线性代数 | 特征值、SVD、正交/正定、行列式 | `targets/{target}/cheatsheets/math_fundamentals.md` | +| 概率统计 | 贝叶斯、分布、独立vs不相关 | `targets/{target}/cheatsheets/math_fundamentals.md` | +| 微积分/优化 | 链式法则、梯度、凸函数、SGD | `targets/{target}/cheatsheets/math_fundamentals.md` | +| DL 基础 | Softmax、BN、CNN、参数量计算 | `shared/cheatsheets/llm_core_cheatsheet.md` | +| Transformer | 注意力、位置编码、KV cache | `shared/cheatsheets/llm_core_cheatsheet.md` | +| LLM 架构 | LLaMA3、GQA/MQA、RoPE、SwiGLU | `shared/cheatsheets/llm_core_cheatsheet.md` | +| 微调/对齐 | LoRA/QLoRA、RLHF/DPO | `shared/cheatsheets/llm_core_cheatsheet.md` | +| RAG/Agent | RAG流程、ReAct、Function Calling | `shared/cheatsheets/llm_core_cheatsheet.md` | +| GNN | GCN vs GAT、过平滑、消息传递 | `targets/{target}/cheatsheets/gnn_diffusion_cheatsheet.md` | +| 扩散模型 | DDPM/DDIM、CFG、Latent Diffusion | `targets/{target}/cheatsheets/gnn_diffusion_cheatsheet.md` | +| 推理优化 | KV Cache、量化、FlashAttention | `shared/cheatsheets/llm_core_cheatsheet.md` | +| 数据结构 | 栈/队列、排序、哈希 | `targets/{target}/mistake_log.md` | ## 3. Question Quality Rules -### Single-choice (单选, 3 分) +### Single-choice (单选) - Exactly 4 options (A/B/C/D), exactly 1 correct. - Distractors must be plausible — based on common misconceptions from `mistake_log.md`. - Each question tests ONE clear concept. - Prefer "下列说法**错误**的是" or "下列说法**正确**的是" format. -### Multi-choice (不定项, 4 分) +### Multi-choice (不定项) - Exactly 4 options (A/B/C/D), 1-4 correct. - Clearly state: "漏选得 2 分,多选/错选 0 分". - Options should be independently evaluable — each is a standalone true/false claim. @@ -74,21 +76,20 @@ Draw questions from these 12 domains, balanced across rounds: ## 4. Weak Point Targeting -0. **Read mastery data** — check `progress/exam-analysis/exam-style-analysis.md` §6 "知识点掌握进度" for mastery levels per topic. Also read `algorithms/mistake_log.md` Mastery column. This is the **canonical source** for mastery info; prefer it over re-querying exam-memory MCP. +0. **Read mastery data** — check `targets/{target}/progress/exam-analysis/exam-style-analysis.md` §6 "知识点掌握进度" for mastery levels per topic. Also read `targets/{target}/mistake_log.md` Mastery column. This is the **canonical source** for mastery info; prefer it over re-querying exam-memory MCP. -Before generating, read `progress/exam-analysis/exam-style-analysis.md` §2 频率表 and §4 趋势预测 to calibrate +Before generating, read `targets/{target}/progress/exam-analysis/exam-style-analysis.md` §2 频率表 and §4 趋势预测 to calibrate the topic allocation ratio — simulated类 36%, 字符串 29%, 数论/栈 各 21% are the empirical baseline. Use §4.2 选择题预测 to identify which domains to overweight based on historical exam patterns. -Then read `algorithms/mistake_log.md` and check: +Then read `targets/{target}/mistake_log.md` and check: 1. Which topics have the most errors? → Allocate 2-3 questions there. 2. Which errors have `Redo Date` that hasn't passed? → Include reinforcement questions. -3. **Cross-session experience patterns** — call `mcp__exam-memory__list_experiences(type="单选题", limit=5)` and `mcp__exam-memory__list_experiences(type="多选题", limit=5)` to retrieve persistent error patterns from the exam-memory MCP server. These patterns survive across sessions and capture errors that may not yet be in mistake_log.md. If the MCP exposes mastery levels (e.g. per-knowledge mastery scores), also query them — but note that `progress/exam-analysis/exam-style-analysis.md` §6 is the canonical source and should be preferred. If exam-memory does not yet expose mastery levels, use `error_count` as a proxy for `struggling` topics. Merge these with mistake_log findings — topics appearing in BOTH sources get highest priority. **MCP 不可用时跳过此步骤**。 +3. **Cross-session experience patterns** — call `mcp__exam-memory__list_experiences(type="单选题", limit=5)` and `mcp__exam-memory__list_experiences(type="多选题", limit=5)` to retrieve persistent error patterns from the exam-memory MCP server. These patterns survive across sessions and capture errors that may not yet be in mistake_log.md. If the MCP exposes mastery levels (e.g. per-knowledge mastery scores), also query them — but note that `targets/{target}/progress/exam-analysis/exam-style-analysis.md` §6 is the canonical source and should be preferred. If exam-memory does not yet expose mastery levels, use `error_count` as a proxy for `struggling` topics. Merge these with mistake_log findings — topics appearing in BOTH sources get highest priority. **MCP 不可用时跳过此步骤**。 4. **Mastery-driven allocation** — For each topic, determine mastery level: - `blind_spot` → allocate 2-3 questions (highest priority, user doesn't really know it) - `struggling` → allocate 2 questions (high priority) - - `partial` → allocate 1 question + 1 variant (medium priority) + - `unknown` → allocate 1 question + 1 variant (exploration) - `confirmed` → allocate 0-1 question (low priority, just for reinforcement) - - `unknown` → allocate 1 question (exploration) Phase-aware adjustment (if user specified a phase in last drill session): - Phase 1 (主攻盲区): blind_spot + struggling get 70% of questions @@ -97,7 +98,7 @@ Then read `algorithms/mistake_log.md` and check: - Phase 4 (考前冲刺): only high-frequency mistake_log topics 5. Which `Root Cause` tags appear most (pattern/proof/python)? → Balance question types. -Also check `progress/choice-questions/round*.md` for previously used questions — **never duplicate**. +Also check `targets/{target}/progress/choice-questions/round*.md` for previously used questions — **never duplicate**. ## 5. Web Search Integration @@ -112,19 +113,19 @@ Only search when a topic needs fresh material or the user requests it. ## 6. Output Format -Write the question set to `progress/choice-questions/roundN.md` (N = next available number). +Write the question set to `targets/{target}/progress/choice-questions/roundN.md` (N = next available number). Use this exact structure: ```markdown -# AI Lab 选择题模拟 — Round N +# 选择题模拟 — Round N -> 上海人工智能实验室 | 基座模型算法 | 考前模拟 -> 时间:30 分钟 | 单选 8 题 × 3 分 = 24 分 + 不定项 7 题 × 4 分 = 28 分 | 满分 52 分 +> {目标单位} | {岗位方向} | 考前模拟 +> 时间:{见 exam_config.md} | 满分 {见 exam_config.md} 分 > 策略:单选每题 ≤ 2 分钟,多选每题 ≤ 3 分钟,不确定的多选宁可漏选不多选 --- -## Part A:单选题(每题 3 分,选唯一正确答案) +## Part A:单选题(选唯一正确答案,分值见 exam_config.md) ### Q1. [Topic Tag] @@ -136,11 +137,11 @@ C. ... D. ... --- -(repeat for Q2-Q8) +(repeat for Q2-Q{单选题数}) -## Part B:不定项选择题(每题 4 分,漏选得 2 分,多选/错选 0 分) +## Part B:不定项选择题(漏选得部分分,多选/错选 0 分,分值见 exam_config.md) -### Q9. [Topic Tag] +### Q{单选题数+1}. [Topic Tag] [Question text] @@ -150,7 +151,7 @@ C. ... D. ... --- -(repeat for Q10-Q15) +(repeat for Q{单选题数+2}-Q{总题数}) --- @@ -175,9 +176,9 @@ D. ... | 题号 | 你的答案 | 正确答案 | 得分 | |------|----------|----------|------| -| Q1 | | X | /3 | +| Q1 | | X | /{分值} | ... -| **总分** | | | **/52** | +| **总分** | | | **/{总分}** | ## 覆盖知识点与错题溯源 @@ -191,7 +192,7 @@ D. ... ### 阶段一:数据采集(Agent 并行) -> **为什么用 Agent**:出题前需要读取多个数据源(exam-style-analysis、mistake_log、历史 round 文件、llm/ 笔记、MCP 经验)。这些文件体积大、内容多,逐文件进入主上下文会导致 token 浪费。通过 Agent 并行采集 + 摘要返回,主会话只拿到结构化出题参数。 +> **为什么用 Agent**:出题前需要读取多个数据源(exam-style-analysis、mistake_log、历史 round 文件、cheatsheets/ 笔记、MCP 经验)。这些文件体积大、内容多,逐文件进入主上下文会导致 token 浪费。通过 Agent 并行采集 + 摘要返回,主会话只拿到结构化出题参数。 ```python # 在 choice-q-create 被调用时,主会话启动 Agent 执行数据采集: @@ -203,7 +204,16 @@ D. ... ``` 你是选择题出题数据助手。请读取以下文件并提取出题所需的结构化摘要,返回 JSON。 -1. progress/exam-analysis/exam-style-analysis.md +0. targets/{target}/exam_config.md(优先读取,确定考试格式参数) + 返回:{ + single_choice: { count: N, points_each: X, total: XX }, + multi_choice: { count: N, points_each: X, total: XX }, + total_time: str, + choice_time: str, + multi_strategy: str + } + +1. targets/{target}/progress/exam-analysis/exam-style-analysis.md 返回:{ frequency_table: [{模式, 频次, 占比, 代表题}], style_features: [str], @@ -217,29 +227,29 @@ D. ... } } -2. algorithms/mistake_log.md +2. targets/{target}/mistake_log.md 返回:{ error_topics: [{topic, count, root_cause, fix_rule, redo_date}], root_cause_summary: { pattern: n, proof: n, python: n }, high_frequency_errors: [{topic, count}] # count >= 2 } -3. progress/choice-questions/round*.md +3. targets/{target}/progress/choice-questions/round*.md 返回:{ existing_rounds: [{round, date, questions_covered: [topic_tags]}], used_topics: [str] # 所有已出现过的知识点标签,用于去重 } -4. llm/math_fundamentals.md(如果存在) +4. targets/{target}/cheatsheets/math_fundamentals.md(如果存在) 返回:{ available_topics: [str], key_formulas: [str] } -5. llm/llm_core_cheatsheet.md(如果存在) +5. shared/cheatsheets/llm_core_cheatsheet.md(如果存在) 返回:{ available_topics: [str] } -6. llm/gnn_diffusion_cheatsheet.md(如果存在) +6. targets/{target}/cheatsheets/gnn_diffusion_cheatsheet.md(如果存在) 返回:{ available_topics: [str] } -7. 调用 MCP(如果可用):list_experiences(type="单选题", limit=5) 和 list_experiences(type="多选题", limit=5) +7. 调用 MCP(如果可用):`mcp__exam-memory__list_experiences(type="单选题", limit=5)` 和 `mcp__exam-memory__list_experiences(type="多选题", limit=5)` 返回:{ single_choice_experiences: [{title, knowledge, error_count}], multi_choice_experiences: [{title, knowledge, error_count}] } @@ -249,21 +259,27 @@ D. ... **降级规则**:Agent 不可用时,主会话回退到逐文件读取(降级模式)。 ### 阶段二:出题 -7. **Write questions** — generate all 15 based on the collected data +7. **Write questions** — generate all {总题数} based on the collected data 8. **Write answers & analysis** — full explanations with 防错 markers -9. **Save file** — `progress/choice-questions/roundN.md` +9. **Save file** — `targets/{target}/progress/choice-questions/roundN.md` 10. **Report** — tell user the file is ready, suggest invoking `choice-q-drill` ### 阶段三:输出 +输出必须包含三部分: + +1. `targets/{target}/progress/choice-questions/roundN.md` 文件路径。 +2. 本轮题目覆盖摘要:单选/多选题数、覆盖知识点、重点补弱主题。 +3. 下一步指令:建议用户调用 `choice-q-drill` 开始交互答题。 + ## 8. Cross-References - `choice-q-drill` — interactive quiz mode for answering the generated questions -- `progress/exam-analysis/exam-style-analysis.md` — 集中式出题风格分析:频率表、趋势预测、mock 成绩(**核心输入**) -- `algorithms/mistake_log.md` — error patterns to target and update -- `sources/ai_lab_history_problems.md` — historical question patterns and high-frequency topics -- `llm/llm_core_cheatsheet.md` — LLM/DL source material -- `llm/gnn_diffusion_cheatsheet.md` — GNN/diffusion source material -- `llm/math_fundamentals.md` — math source material +- `targets/{target}/progress/exam-analysis/exam-style-analysis.md` — 集中式出题风格分析:频率表、趋势预测、mock 成绩(**核心输入**) +- `targets/{target}/mistake_log.md` — error patterns to target and update +- `targets/{target}/sources/` — historical question patterns and high-frequency topics (按目标组织) +- `shared/cheatsheets/llm_core_cheatsheet.md` — LLM/DL source material +- `targets/{target}/cheatsheets/gnn_diffusion_cheatsheet.md` — GNN/diffusion source material +- `targets/{target}/cheatsheets/math_fundamentals.md` — math source material - `exam-memory` MCP tools — cross-session persistent error patterns (mcp__exam-memory__list_experiences) - `skills/exam-assistant.md` — MCP-backed exam assistant with full experience workflow diff --git a/skills/choice-q-drill.md b/skills/choice-q-drill.md index be59d8a..0464b3f 100644 --- a/skills/choice-q-drill.md +++ b/skills/choice-q-drill.md @@ -1,23 +1,23 @@ --- name: choice-q-drill description: > - Interactive quiz mode for AI Lab multiple-choice exam practice. Use this skill whenever + Interactive quiz mode for multiple-choice exam practice. Use this skill whenever the user wants to answer, quiz, drill, or practice multiple-choice questions — even if they just say "做题" or "考我一下" or "来一套". Trigger phrases: "答题", "开始答题", "做题", "交互答题", "选择题练习", "drill", "quiz mode", "开始模拟", "模拟考试", "做选择题", "回答选择题", "start quiz", "test me", "考我", "测验", "刷题", "做一套", "模拟一下", "开始刷", "练选择题". Also use after `choice-q-create` has generated a question set and the user wants to go through it, or when the user opens a - `progress/choice-questions/round*.md` file and says "开始". Reads question files and presents them + `targets/{target}/progress/choice-questions/round*.md` file and says "开始". Reads question files and presents them interactively using AskUserQuestion. After completion, updates both mistake_log.md and the exam-memory MCP server for cross-session persistence. --- # Choice Question Drill Skill -Interactive quiz mode for AI Lab multiple-choice exam practice. Reads question files, +Interactive quiz mode for multiple-choice exam practice. Reads question files, presents them one batch at a time using AskUserQuestion, scores immediately, tracks -results, and updates both `algorithms/mistake_log.md` (local harness feedback) and +results, and updates both `targets/{target}/mistake_log.md` (local harness feedback) and the exam-memory MCP server (cross-session persistence) after completion. The drill→feedback loop is what turns one-time mistakes into lasting knowledge. Without @@ -70,14 +70,14 @@ AskUserQuestion: ``` Present Q1-Q4 as a single batch (4 questions in one AskUserQuestion call), -then Q5-Q8 as a second batch. +then Q5-Q8 as a second batch, and so on until all single-choice questions are covered. ### For multi-choice questions (one at a time): ``` AskUserQuestion: questions: - - question: "Q9. [Topic]\n\n[Question text]\n\n(多选,漏选得2分,多选/错选0分)" + - question: "Q9. [Topic]\n\n[Question text]\n\n(多选,漏选得部分分,多选/错选0分,分值见 exam_config.md)" header: "Q9 多选" multiSelect: true options: @@ -91,19 +91,21 @@ AskUserQuestion: description: "[Option D text]" ``` -Present Q9-Q15 one by one (each is a separate AskUserQuestion call). +Present Q{单选题数+1}-Q{总题数} one by one (each is a separate AskUserQuestion call). ## 3. Scoring Rules +> **从 `targets/{target}/exam_config.md` 读取分值**。以下为默认规则,运行时以 exam_config 为准。 + After each batch/question, immediately show results: -### Single-choice scoring (3 points each) -- Correct: 3 points +### Single-choice scoring (分值见 exam_config.md) +- Correct: 满分 - Wrong: 0 points -### Multi-choice scoring (4 points each) -- All correct (exact match): 4 points -- Partial (subset of correct, no wrong): 2 points +### Multi-choice scoring (分值见 exam_config.md) +- All correct (exact match): 满分 +- Partial (subset of correct, no wrong): 部分分(见 exam_config.md) - Any wrong option selected: 0 points ### Scoring display format @@ -115,11 +117,11 @@ After each batch: | 题号 | 你的答案 | 正确答案 | 得分 | 状态 | |------|----------|----------|------|------| -| Q1 | B | C | 0/3 | ✗ | -| Q2 | A | A | 3/3 | ✓ | +| Q1 | B | C | 0/{分值} | ✗ | +| Q2 | A | A | {分值}/{分值} | ✓ | **本批次得分**:3/6 -**累计得分**:3/52 +**累计得分**:3/{总分} ``` ## 4. Answer Explanation @@ -137,18 +139,18 @@ After scoring each batch, provide **brief** explanations (1-2 lines per question - Only explain wrong answers in detail. - For correct answers, just confirm with ✓. -- Always include 防错 note if the topic has an entry in `mistake_log.md` or `list_experiences` returns a matching experience. +- Always include 防错 note if the topic has an entry in `mistake_log.md` or `mcp__exam-memory__list_experiences` returns a matching experience. ### 4b. Per-Question Error Persistence (必须执行,不要等到最后) > **为什么在这里**:错误上下文最鲜活的时刻就是刚答完的时刻。等到最后批量处理时,模型已经疲劳,容易遗忘细节。每道错题答完就立即持久化,即使中途退出也不会丢失。 > -> **去重规则**:写入 mistake_log.md 前,先检查该题号(如 Q3)是否已在当前主题分表中出现。已存在 → 跳过 mistake_log 写入,仅执行 exam-memory MCP 更新(inc_error_count);不存在 → 正常写入 mistake_log 表格行 + MCP save_experience。 +> **去重规则**:写入 mistake_log.md 前,先检查该题号(如 Q3)是否已在当前主题分表中出现。已存在 → 跳过 mistake_log 写入,仅执行 exam-memory MCP 更新(`mcp__exam-memory__inc_error_count`);不存在 → 正常写入 mistake_log 表格行 + `mcp__exam-memory__save_experience`。 After scoring each wrong/skipped answer, immediately persist to exam-memory MCP: -1. Determine question type: Q1-Q8 = "单选题", Q9-Q15 = "多选题" -2. **Dedup check for mistake_log.md**: Scan the relevant topic table in `algorithms/mistake_log.md` for a row with the same question identifier (e.g., "Q3 topic"). If found, skip §5b mistake_log write and go directly to MCP step. +1. Determine question type: see `targets/{target}/exam_config.md` for which Q-numbers are 单选 vs 多选 +2. **Dedup check for mistake_log.md**: Scan the relevant topic table in `targets/{target}/mistake_log.md` for a row with the same question identifier (e.g., "Q3 topic"). If found, skip §5b mistake_log write and go directly to MCP step. 3. Call `mcp__exam-memory__list_experiences(type=题型, limit=5)` to check for matches 4. **Match found** (same topic + similar error pattern): call `mcp__exam-memory__inc_error_count(file_path=匹配文件名)` 5. **No match**: call `mcp__exam-memory__save_experience` with: @@ -188,14 +190,14 @@ AskUserQuestion: | User choice | Mastery level | |-------------|---------------| | 确定会 | `confirmed` | -| 猜的但有道理 | `partial` | +| 猜的但有道理 | `struggling` | | 完全不懂,猜的 | `blind_spot` | -**When mastery is `blind_spot` or `partial`**, immediately persist to mistake_log.md AND exam-memory MCP (same workflow as §4b, but with Result = `lucky_pass` instead of `WA`): +**When mastery is `blind_spot` or `struggling`**, immediately persist to mistake_log.md AND exam-memory MCP (same workflow as §4b, but with Result = `lucky_pass` instead of `WA`): -1. Append row to the topic table in `algorithms/mistake_log.md`: +1. Append row to the topic table in `targets/{target}/mistake_log.md`: ``` - | MM-DD | QN topic | topic | lucky_pass | partial/confirmed | [root cause] | [fix rule] | MM-DD+1 | + | MM-DD | QN topic | topic | lucky_pass | struggling/blind_spot | [root cause] | [fix rule] | MM-DD+1 | ``` 2. Call `mcp__exam-memory__save_experience` with the same parameters as §4b (title, content, type, knowledge, difficulty), noting in the content that this was a lucky pass. 3. Update the topic's mastery level in tracking. @@ -204,7 +206,7 @@ AskUserQuestion: ## 5. Post-Session Update -After all 15 questions are answered, produce a final summary and update files: +After all questions are answered, produce a final summary and update files: ### 5a. Final Score Report @@ -213,16 +215,16 @@ After all 15 questions are answered, produce a final summary and update files: | 指标 | 数值 | |------|------| -| 单选正确率 | X/8 | -| 多选全对 | X/7 | +| 单选正确率 | X/{单选题数} | +| 多选全对 | X/{多选题数} | | 多选部分分 | X 题 | -| 总分 | XX/52 (XX%) | +| 总分 | XX/{总分} (XX%) | ``` ### 5b. Update mistake_log.md For each wrong answer, check dedup before appending to the relevant topic table in -`algorithms/mistake_log.md`: +`targets/{target}/mistake_log.md`: ```markdown | Date | Problem | Topic | Result | Mastery | Root Cause | Fix Rule | Redo Date | @@ -238,7 +240,7 @@ For each wrong answer, check dedup before appending to the relevant topic table |--------|-----------|---------|-----------| | WA | — | WA | MM-DD+1 | | Correct | 确定会 | confirmed | No redo date (or 2 days after) | -| Correct | 猜的但有道理 | partial | MM-DD+1 | +| Correct | 猜的但有道理 | struggling | MM-DD+1 | | Correct | 完全不懂,猜的 | lucky_pass | Same day (immediate review) | Also update: @@ -249,18 +251,18 @@ Also update: > 主要持久化已移至 §4b(每题答完立即执行)。此处为兜底检查: > 若中途有错题因故未持久化,在此统一补录。 -> **去重**:先 list_experiences 匹配 → 匹配到则 inc_error_count,不新建。 +> **去重**:先调用 `mcp__exam-memory__list_experiences` 匹配 → 匹配到则调用 `mcp__exam-memory__inc_error_count`,不新建。 ### 5c. Update daily plan file -Append results to `daily/YYYY-MM-DD.md` Problem Log section: +Append results to `shared/daily/YYYY-MM-DD.md` Problem Log section: ```markdown ### 选择题 Round N(交互模式) -- **单选正确率**:X/8(XX%)— X/24 分 -- **多选正确率**:X/7 全对 -- **总分**:XX/52(XX%) +- **单选正确率**:X/{单选题数}(XX%)— X/{单选总分} 分 +- **多选正确率**:X/{多选题数} 全对 +- **总分**:XX/{总分}(XX%) - **主要错因**: - [topic](QN)— [brief cause] - **薄弱知识点**:[list] @@ -318,7 +320,7 @@ If the user selects a non-recommended option, respect their choice but note the To find available question sets: ``` -Glob: progress/choice-questions/round*.md +Glob: targets/{target}/progress/choice-questions/round*.md ``` If multiple files exist, ask the user which round to drill. If only one exists, @@ -331,7 +333,7 @@ If no question file exists, suggest invoking `choice-q-create` first. Note the start time when drilling begins. After completion, report: - Total time taken - Average time per question -- Comparison with target (30 min for 15 questions) +- Comparison with target (见 exam_config.md) ## 9. Workflow @@ -370,9 +372,9 @@ Note the start time when drilling begins. After completion, report: 3. **Note start time** — for performance tracking 4. **Single-choice batch 1** — AskUserQuestion with Q1-Q4 (multiSelect: false) 5. **Score & explain** — immediate feedback after batch; for each wrong answer, immediately persist to exam-memory MCP (§4b); for each **correct** answer, ask confidence self-report (§4c) -6. **Single-choice batch 2** — AskUserQuestion with Q5-Q8 +6. **Single-choice batch 2** — AskUserQuestion with Q5-Q8 (repeat batches until all single-choice covered) 7. **Score & explain** — same persistence + confidence self-report logic -8. **Multi-choice Q9-Q15** — AskUserQuestion one at a time (multiSelect: true) +8. **Multi-choice** — AskUserQuestion one at a time (multiSelect: true), starting from Q{单选题数+1} 9. **Score & explain + MCP persist + confidence self-report** — for each answer, persist errors to exam-memory MCP (§4b); for each correct answer, ask confidence self-report (§4c) 10. **Final report** — aggregate scores, time, weak points 11. **Update files** — [must] mistake_log.md (with Mastery column per §5b), [must] exam-memory MCP (per-question §4b + §4c + fallback §5b-2), [must] daily plan, [must] error classification @@ -390,8 +392,8 @@ Note the start time when drilling begins. After completion, report: ## 10. Cross-References - `choice-q-create` — generates the question sets this skill drills -- `algorithms/mistake_log.md` — error log to read (for 防错 notes) and update (after drilling) -- `progress/choice-questions/round*.md` — question file source -- `daily/YYYY-MM-DD.md` — daily plan to update with results +- `targets/{target}/mistake_log.md` — error log to read (for 防错 notes) and update (after drilling) +- `targets/{target}/progress/choice-questions/round*.md` — question file source +- `shared/daily/YYYY-MM-DD.md` — daily plan to update with results - `exam-memory` MCP tools — cross-session error persistence (mcp__exam-memory__save_experience, mcp__exam-memory__inc_error_count) - `skills/exam-assistant.md` — MCP-backed exam assistant skill with full experience workflow diff --git a/skills/exam-assistant.md b/skills/exam-assistant.md index 76cffdd..8a77b55 100644 --- a/skills/exam-assistant.md +++ b/skills/exam-assistant.md @@ -18,11 +18,11 @@ description: > | 工具 | 用途 | |------|------| -| `list_experiences` | 按题型列出历史经验(error_count 降序) | -| `save_experience` | 保存新经验条目 | -| `inc_error_count` | 给某条经验的错误计数 +1 | -| `get_user_profile` | 读取用户画像(强弱项、偏好) | -| `update_user_profile` | 增量更新用户画像 | +| `mcp__exam-memory__list_experiences` | 按题型列出历史经验(error_count 降序) | +| `mcp__exam-memory__save_experience` | 保存新经验条目 | +| `mcp__exam-memory__inc_error_count` | 给某条经验的错误计数 +1 | +| `mcp__exam-memory__get_user_profile` | 读取用户画像(强弱项、偏好) | +| `mcp__exam-memory__update_user_profile` | 增量更新用户画像 | | `WebSearch`(Claude Code 内置) | 联网搜索补充资料(不使用 MCP search_web) | # 工作流规则 @@ -30,14 +30,14 @@ description: > ## 1. 题型识别与经验加载 - 用户发送题目后,首先判断类型:**单选题**、**多选题** 或 **算法题**。 -- **立即**调用 `list_experiences(type=对应类型, limit=5)`。 +- **立即**调用 `mcp__exam-memory__list_experiences(type=对应类型, limit=5)`。 - 将返回的经验作为"先前记忆"融入思考。 - 在回答开头注明: > 📚 根据您过往经验:(简略引用最相关的 1-2 条) ## 2. 用户画像加载 -- 对话开始时,调用 `get_user_profile()` 获取画像。 +- 对话开始时,调用 `mcp__exam-memory__get_user_profile()` 获取画像。 - 画像影响解答风格: - `preferences.skip_basic_explanation = true` → 省略基础概念 - `preferences.preferred_language` → 优先使用该语言写代码 @@ -63,11 +63,11 @@ description: > **记录流程**: -1. 分析错误类型,调用 `list_experiences` 检查是否与已有经验匹配。 -2. **若匹配**:调用 `inc_error_count(file_path=匹配文件名)`。 +1. 分析错误类型,调用 `mcp__exam-memory__list_experiences` 检查是否与已有经验匹配。 +2. **若匹配**:调用 `mcp__exam-memory__inc_error_count(file_path=匹配文件名)`。 3. **若不匹配**: - 询问用户:"本次错误是一种新模式,是否存入经验库?" - - 用户确认后,调用 `save_experience`,参数: + - 用户确认后,调用 `mcp__exam-memory__save_experience`,参数: - `title`:简短描述(如"双指针未去重导致重复") - `content`:包含**错误理解**、**正确解法**、**关键要点** - `type`:题型 @@ -89,7 +89,7 @@ description: > - 用户说"以后不用讲基础"、"直接给代码" → 更新 `preferences.skip_basic_explanation = true` - 用户提到喜欢/不喜欢某种呈现方式 → 更新对应 preference -调用 `update_user_profile(diff={变更内容})` 传入变更。 +调用 `mcp__exam-memory__update_user_profile(diff={变更内容})` 传入变更。 ## 6. 联网查询(可选) @@ -104,7 +104,7 @@ description: > - 若经验文件本身超过 500 字,读取后**自动摘要关键点**,不全文粘贴。 为什么:模型在长文本中容易丢失关键信息,摘要后反而更容易正确引用。 - 每次回答中引用的经验不超过 2 条,其余作为内部参考。 -- **Agent 增强**:当同时需要 `get_user_profile()` + `list_experiences()` 时,可并行通过 Agent 采集,主上下文只接收精炼摘要。 +- **Agent 增强**:当同时需要 `mcp__exam-memory__get_user_profile()` + `mcp__exam-memory__list_experiences()` 时,可并行通过 Agent 采集,主上下文只接收精炼摘要。 ## 8. 格式约束 @@ -116,5 +116,5 @@ description: > ## 9. Cross-References - `choice-q-drill` — 交互答题模式,在答题结束后调用本 skill 的 MCP 工具记录错误 -- `choice-q-create` — 出题模式,调用 `list_experiences` 获取跨会话经验来决定题目分配 -- `algorithms/mistake_log.md` — 本地错误日志(与 MCP 经验库互补维护) +- `choice-q-create` — 出题模式,调用 `mcp__exam-memory__list_experiences` 获取跨会话经验来决定题目分配 +- `targets/{target}/mistake_log.md` — 本地错误日志(与 MCP 经验库互补维护) diff --git a/skills/init-guide.md b/skills/init-guide.md index 25d5054..bddb47e 100644 --- a/skills/init-guide.md +++ b/skills/init-guide.md @@ -45,12 +45,6 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 - 已刷过一轮,需要查漏补缺 - 考前冲刺,需要速查模式 -**Q5: 每日可投入的备考时间?** -- 1-2 小时(碎片时间) -- 3-4 小时(半日备考) -- 5 小时以上(全职备考) -- 不固定,尽量抽时间 - ### Step 2: 确认考试范围 根据 Step 1 的回答,推断默认考试格式并展示给用户确认: @@ -65,12 +59,11 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 | 题型 | {根据方向推断,如:单选 + 多选 + 编程} | | 重点知识 | {根据方向推断} | | 考试时间 | {用户选择} | -| 每日投入时间 | {用户选择} | ``` 询问用户是否需要调整。如果用户的考试目标不在 AI 方向(如公务员行测、金融笔试),提示需要: -1. 替换 `sources/` 下的考试分析文件 -2. 替换 `llm/` 下的速记资料为目标领域 +1. 替换 `targets/{target}/sources/` 下的考试分析文件 +2. 替换 `targets/{target}/cheatsheets/` 和 `shared/cheatsheets/` 下的速记资料为目标领域 3. 可能需要创建新的 Skill 来适配目标题型 ### Step 3: 更新配置文件 @@ -88,7 +81,6 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 - Date: {今天日期} - Target exam: {单位名称}{岗位}笔试,{考试日期或"待定"} -- Daily study hours: {用户选择的时长,如 3-4h} - Current objective: {根据状态推断,如"基础补齐" / "系统复习" / "查漏补缺" / "考前冲刺"} - Strategy: {根据方向推断配比} ``` @@ -103,7 +95,6 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 "target_role": "{岗位方向}", "exam_date": "{考试日期}", "prep_status": "{备考状态}", - "daily_hours": "{每日投入时间}", "recent_focus": "{初始聚焦方向}" } ``` @@ -112,20 +103,23 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 #### 3.3 更新 `AGENTS.md` Exam Format -如果用户的考试目标与默认 AI Lab 配置差异较大,更新 `AGENTS.md` 中的 Exam Format 表格和 Target Role Requirements 部分。 +如果用户的考试目标与默认配置差异较大,更新 `AGENTS.md` 中的 Exam Format 表格和 Target Role Requirements 部分。 + +#### 3.4 创建/更新 `targets/{target}/exam_config.md` -#### 3.4 确认 sources/ 考试分析 +从模板 `targets/exam_config_template.md` 复制,填入目标考试的实际参数: -根据目标考试方向,确认 `sources/` 下的考试分析文件: +```markdown +# {目标名称} 考试配置 -- **AI/算法方向**(大模型、CV、NLP 等):`sources/ai_lab_history_problems.md` 已预配置 AI Lab 历年真题模式,直接使用。如果目标单位不是上海 AI 实验室,提示用户补充目标单位的题型分析。 -- **非 AI 方向**(行测、金融等):提示用户替换 `sources/` 下的分析文件,给出参考结构: - - 考试结构(题型、分值、时长) - - 历年真题模式与高频考点 - - 复习优先级(P0/P1/P2) - - 推荐练习节奏 +| 题型 | 题数 | 分值 | 合计 | +|------|------|------|------| +| 单选题 | {N} | {X}分 | {合计}分 | +| 不定项选择题 | {N} | {X}分 | {合计}分 | +``` -告知用户:"`sources/` 目录下的考试分析文件是 `choice-q-create` 出题和 `review-tracker` 进度评估的重要输入。当前文件已预配置 AI Lab 笔试模式,你可以按需补充目标单位的真题分析。" +`choice-q-create` 和 `choice-q-drill` 从该文件读取格式参数,不再硬编码。 +如果用户不清楚具体分值,填入"待确认",后续根据真题/mock 补全。 ### Step 4: 判断是否需要新 Skill @@ -152,13 +146,12 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 **备考目标**:{单位} - {岗位} **考试时间**:{日期} -**每日投入**:{每日时长} **当前状态**:{状态} **已配置**: -- [x] HANDOFF.md — 备考目标 + 每日时长已填写 +- [x] HANDOFF.md — 备考目标已填写 - [x] 用户画像 — 已更新(MCP 可用时) +- [x] exam_config.md — 考试格式参数已写入 - [x] 考试范围 — 已确认 -- [x] sources/ — 考试分析文件已确认(AI Lab 模式已预配置) **下一步**: 1. 阅读 `START_HERE.md` 了解 Skill Pipeline @@ -166,7 +159,7 @@ description: 首次使用引导 — 收集备考目标、更新个人画像、 3. 开始第一道练习题:`Skill(skill="solve-skeleton")` **可选增强**: -- 启用 exam-memory MCP 获取跨会话经验持久化 → 见 `docs/mcp-setup-guide.md` +- 启用 exam-memory MCP 获取跨会话经验持久化:在 `.mcp.json` 中注册 stdio 命令运行 `shared/exam_memory/server.py`,也可参考 `README.md` 的 MCP 配置说明。 ``` ## 注意事项 diff --git a/skills/review-tracker.md b/skills/review-tracker.md index b55c3e0..ae3f718 100644 --- a/skills/review-tracker.md +++ b/skills/review-tracker.md @@ -32,16 +32,16 @@ description: > | 文件 | 读取内容 | 报告章节 | |------|---------|---------| -| `progress/task-board/task-board.md` | 任务状态 (Done/Today/Pending) | 任务进度 | -| `progress/study-planning/readiness-score.md` | 各维度自评分 + 考试策略 | 就绪度趋势 | -| `progress/exam-analysis/exam-style-analysis.md` | 出题风格、频率表、趋势预测、mock 成绩 | 出题风格分析 | -| `algorithms/mistake_log.md` | WA 记录、Root Cause 分布、Redo Date | 错题待修复清单 | -| `algorithms/topic_checklist.md` | P0/P1/P2 模式覆盖 | 知识点覆盖度 | -| `daily/YYYY-MM-DD.md` (今天) | Problem Log、正确率统计 | 今日练习总结 | -| `daily/YYYY-MM-DD.md` (昨天) | 对比昨天进度 | 进步/退步对比 | -| `progress/choice-questions/round*.md` | 选择题 Round 成绩 | 选择题趋势 | -| `progress/exam-analysis/exam-style-analysis.md` §6 | 知识点掌握进度表 | 掌握进度 | -| `progress/reviews/review-YYYY-MM-DD.md` | 周期性复习总结(可选) | 复习总结 | +| `targets/{target}/progress/task-board/task-board.md` | 任务状态 (Done/Today/Pending) | 任务进度 | +| `targets/{target}/progress/study-planning/readiness-score.md` | 各维度自评分 + 考试策略 | 就绪度趋势 | +| `targets/{target}/progress/exam-analysis/exam-style-analysis.md` | 出题风格、频率表、趋势预测、mock 成绩 | 出题风格分析 | +| `targets/{target}/mistake_log.md` | WA 记录、Root Cause 分布、Redo Date | 错题待修复清单 | +| `targets/{target}/topic_checklist.md` | P0/P1/P2 模式覆盖 | 知识点覆盖度 | +| `shared/daily/YYYY-MM-DD.md` (今天) | Problem Log、正确率统计 | 今日练习总结 | +| `shared/daily/YYYY-MM-DD.md` (昨天) | 对比昨天进度 | 进步/退步对比 | +| `targets/{target}/progress/choice-questions/round*.md` | 选择题 Round 成绩 | 选择题趋势 | +| `targets/{target}/progress/exam-analysis/exam-style-analysis.md` §6 | 知识点掌握进度表 | 掌握进度 | +| `targets/{target}/progress/reviews/review-YYYY-MM-DD.md` | 周期性复习总结(可选) | 复习总结 | ## 工作流 @@ -79,35 +79,35 @@ digraph review { 使用 Glob 查找文件路径(文件名可能含日期变体),然后读取每个文件提取以下指标: -1. progress/task-board/task-board.md +1. targets/{target}/progress/task-board/task-board.md 返回:{ total: int, done: int, today: int, pending: int } -2. progress/study-planning/readiness-score.md +2. targets/{target}/progress/study-planning/readiness-score.md 返回:{ latest_date: str, scores: { 算法: x/50, 数学: x/15, AI_ML: x/25, 项目: x/5, 后勤: x/5 } } 如果最新行是占位符(/50 等),取上一行有实际数字的行。 -3. algorithms/mistake_log.md +3. targets/{target}/mistake_log.md 返回:{ total_entries: int, root_cause_counts: { pattern: n, proof: n, python: n }, due_today: [ { problem, topic, root_cause, fix_rule } ], due_tomorrow: [ { problem, topic, root_cause, fix_rule } ] } Redo Date <= 今天 = due_today,= 明天 = due_tomorrow。 -4. algorithms/topic_checklist.md +4. targets/{target}/topic_checklist.md 返回:{ P0: { total, covered, uncovered_list }, P1: { total, covered, uncovered_list }, P2: { total, covered, uncovered_list } } "covered" = 有对应 solutions_batch.py 实现或 practice/ 文件。 -5. daily/ 目录下今天的文件(如 daily/2026-06-15.md) +5. shared/daily/ 目录下今天的文件(如 shared/daily/2026-06-15.md) 返回:{ ac_count: int, total_count: int, new_wa: n, mock_score: str | null } -6. daily/ 目录下昨天的文件 +6. shared/daily/ 目录下昨天的文件 返回:{ ac_count: int, total_count: int } (用于对比) -7. progress/choice-questions/round*.md +7. targets/{target}/progress/choice-questions/round*.md 返回:{ rounds: [ { round, date, single_score, multi_score, total, main_errors } ] } -8. progress/exam-analysis/exam-style-analysis.md +8. targets/{target}/progress/exam-analysis/exam-style-analysis.md 返回:{ mastery: { confirmed: n, struggling: n, blind_spot: n, unknown: n }, mastery_details: { confirmed: [...], struggling: [...], blind_spot: [...], unknown: [...] }, mock_score: str | null, @@ -128,6 +128,8 @@ digraph review { ### 阶段三:输出 +输出一份可直接执行的进度报告。报告必须使用下方固定 8 节结构;缺失数据的章节保留标题并写"暂无数据",不要把后续章节提前改号。 + ## 降级处理(数据源缺失时) 任何一个数据源文件缺失或为空时,**跳过对应章节并在报告中标注"暂无数据"**,不要编造数字。 @@ -135,8 +137,8 @@ digraph review { | 缺失文件 | 对应章节 | 处理方式 | |----------|---------|---------| | `mistake_log.md` 为空或无条目 | 三、错题待修复 | 输出"暂无错题记录",今日必做清单跳过错题修复项 | -| 今天无 `daily/YYYY-MM-DD.md` | 六、今日练习总结 | 输出"今日尚无练习记录" | -| 昨天无 `daily/YYYY-MM-DD.md` | 二、就绪度趋势 | 跳过对比,仅输出最新分数 | +| 今天无 `shared/daily/YYYY-MM-DD.md` | 六、今日练习总结 | 输出"今日尚无练习记录" | +| 昨天无 `shared/daily/YYYY-MM-DD.md` | 二、就绪度趋势 | 跳过对比,仅输出最新分数 | | 无 `choice_q_round*.md` | 五、选择题趋势 | 输出"暂无选择题模拟记录" | | `readiness_score.md` 全部行为占位符 | 二、就绪度趋势 | 输出"未自评——建议先执行自评打分" | | `topic_checklist.md` 缺失 | 四、知识点覆盖度 | 输出"暂无知识点清单" | @@ -204,13 +206,13 @@ Root Cause 分布:pattern(X) / proof(X) / python(X) | R1 | MM-DD | X/24 | X/28 | X/52 | ... | | R2 | MM-DD | X/24 | X/28 | X/52 | ... | -### 七、今日练习总结(如有 Problem Log) +### 六、今日练习总结(如有 Problem Log) - AC:X/Y 题 - 新增 WA:N 条 - 薄弱点:[列表] -### 六-b、知识点掌握分布 +### 七、知识点掌握分布 | Mastery | 数量 | 知识点 | |---------|------|--------| @@ -221,7 +223,7 @@ Root Cause 分布:pattern(X) / proof(X) / python(X) **建议**:优先处理 blind_spot(立即重做同知识点变体),其次是 struggling(安排明日 Redo)。 -### 七、今日必做清单 +### 八、今日必做清单 1. [ ] **错题修复**:重做到期错题 [题名] 2. [ ] **模式练习**:补齐 [未覆盖的 P0/P1 模式] @@ -293,8 +295,8 @@ else: | 输出内容 | 路径 | 说明 | |---------|------|------| -| 自评打分 | `progress/study-planning/readiness-score.md` | 已有,保持不变 | -| 周期性复习总结 | `progress/reviews/review-YYYY-MM-DD.md` | 周期性 review 时写入 | +| 自评打分 | `targets/{target}/progress/study-planning/readiness-score.md` | 已有,保持不变 | +| 周期性复习总结 | `targets/{target}/progress/reviews/review-YYYY-MM-DD.md` | 周期性 review 时写入 | | 考前速查模式 | 对话中(不写文件) | 保持轻量 | | 进度报告 | 对话中(不写文件) | 每次调用时即时生成 | @@ -304,9 +306,9 @@ else: - `choice-q-drill` — 答题结束后可调用本 skill 生成 session 总结 - `algo-annotation` — 读取 mistake_log 中的防错规则,与本 skill 共享数据源 - `exam-assistant` — MCP 增强的经验检索,本 skill 纯本地 -- `progress/exam-analysis/exam-style-analysis.md` — 出题风格、频率表、趋势预测(**新增核心数据源**) -- `progress/study-planning/readiness-score.md` — 本 skill 可更新自评分 -- `progress/task-board/task-board.md` — 本 skill 读取任务状态 -- `algorithms/mistake_log.md` — 核心错题数据源 -- `progress/reviews/review-YYYY-MM-DD.md` — 周期性复习总结输出 -- `algorithms/topic_checklist.md` — 知识点覆盖数据源 +- `targets/{target}/progress/exam-analysis/exam-style-analysis.md` — 出题风格、频率表、趋势预测(**新增核心数据源**) +- `targets/{target}/progress/study-planning/readiness-score.md` — 本 skill 可更新自评分 +- `targets/{target}/progress/task-board/task-board.md` — 本 skill 读取任务状态 +- `targets/{target}/mistake_log.md` — 核心错题数据源 +- `targets/{target}/progress/reviews/review-YYYY-MM-DD.md` — 周期性复习总结输出 +- `targets/{target}/topic_checklist.md` — 知识点覆盖数据源 diff --git a/skills/solve-analyze/SKILL.md b/skills/solve-analyze/SKILL.md index 57337ef..488447f 100644 --- a/skills/solve-analyze/SKILL.md +++ b/skills/solve-analyze/SKILL.md @@ -179,7 +179,7 @@ Run two analysis tracks concurrently. **Only used when triage routes to full dia - Missing `.strip()` on string reads - Missing `if __name__ == "__main__"` guard - Stale heap entries in Dijkstra (missing `if d != dist[u]: continue`) -4. Cross-check against `algorithms/mistake_log.md` for known WA/TLE patterns in the same +4. Cross-check against `targets/{target}/mistake_log.md` for known WA/TLE patterns in the same algorithm topic. 5. Mark each suspicious line with a line reference and a preliminary root cause tag. @@ -303,9 +303,9 @@ The report ends with a summary table of feedback actions taken: | Action | Target | Status | |--------|--------|--------| -| mistake_log append | `algorithms/mistake_log.md` | Done / Skipped | -| user_profile update | MCP `update_user_profile` | Done / Skipped (MCP unavailable) | -| experience check | MCP `list_experiences` | Match found (inc) / New (asked user) / Skipped | +| mistake_log append | `targets/{target}/mistake_log.md` | Done / Skipped | +| user_profile update | MCP `mcp__exam-memory__update_user_profile` | Done / Skipped (MCP unavailable) | +| experience check | MCP `mcp__exam-memory__list_experiences` | Match found (inc) / New (asked user) / Skipped | ``` ## 5. Feedback Loop Integration @@ -315,7 +315,7 @@ across the entire harness. ### 5a. -> mistake_log.md (Automatic) -Root cause tags are appended to `algorithms/mistake_log.md` under the matching topic section. +Root cause tags are appended to `targets/{target}/mistake_log.md` under the matching topic section. **Append logic:** @@ -405,18 +405,18 @@ automatically offer to invoke algo-annotation after the report is delivered. | `skills/solve-skeleton/references/exam-patterns.md` | Upstream | 6 exam-specific patterns used by Agent B when standard algo templates do not match | | `skills/solve-skeleton/references/io-modes.md` | Upstream | I/O mode templates for generating correct input/output in the standard solution | | `skills/algo-annotation.md` | Downstream | Adds `# [防错]` markers to code using root cause tags from this skill's report | -| `algorithms/mistake_log.md` | Feedback target | Error patterns by topic; this skill appends new entries and updates root cause summary | +| `targets/{target}/mistake_log.md` | Feedback target | Error patterns by topic; this skill appends new entries and updates root cause summary | | `skills/solve-analyze/references/root-cause-tags.md` | Internal | Root cause tag definitions and matching rules | | `skills/solve-analyze/references/comparison-template.md` | Internal | Markdown template for the structured comparison report | ## 7. MCP Dependency Matrix -| MCP Tool | Full Name | Required? | Graceful Degradation | -|----------|-----------|-----------|---------------------| -| `list_experiences` | `mcp__exam-memory__list_experiences` | Optional | Skip experience matching; report "MCP unavailable" in feedback summary | -| `save_experience` | `mcp__exam-memory__save_experience` | Optional | Skip persistence; user's error pattern remains in mistake_log only | -| `inc_error_count` | `mcp__exam-memory__inc_error_count` | Optional | Skip frequency tracking; error count stays stale | -| `update_user_profile` | `mcp__exam-memory__update_user_profile` | Optional | Skip profile update; weakness data not recorded | +| MCP Tool | Required? | Graceful Degradation | +|----------|-----------|---------------------| +| `mcp__exam-memory__list_experiences` | Optional | Skip experience matching; report "MCP unavailable" in feedback summary | +| `mcp__exam-memory__save_experience` | Optional | Skip persistence; user's error pattern remains in mistake_log only | +| `mcp__exam-memory__inc_error_count` | Optional | Skip frequency tracking; error count stays stale | +| `mcp__exam-memory__update_user_profile` | Optional | Skip profile update; weakness data not recorded | **Rules:** @@ -445,10 +445,10 @@ When MCP is unavailable, the skill still runs all local steps: | Comparison report | Full report | Full report | | Root cause tag extraction | Runs normally | Runs normally | | mistake_log.md write | Automatic | Automatic (local file) | -| user_profile update | `update_user_profile` | **Skipped silently** | -| Experience matching | `list_experiences` | **Skipped silently** | -| Error count increment | `inc_error_count` | **Skipped silently** | -| New experience save | `save_experience` (with user confirm) | **Skipped silently** | +| user_profile update | `mcp__exam-memory__update_user_profile` | **Skipped silently** | +| Experience matching | `mcp__exam-memory__list_experiences` | **Skipped silently** | +| Error count increment | `mcp__exam-memory__inc_error_count` | **Skipped silently** | +| New experience save | `mcp__exam-memory__save_experience` (with user confirm) | **Skipped silently** | The only observable difference is the feedback summary table at the end of the report: all MCP actions show "Skipped (MCP unavailable)" instead of "Done". diff --git a/skills/solve-analyze/references/comparison-template.md b/skills/solve-analyze/references/comparison-template.md index 5cbb0b5..d525758 100644 --- a/skills/solve-analyze/references/comparison-template.md +++ b/skills/solve-analyze/references/comparison-template.md @@ -116,7 +116,7 @@ |------------|--------|-------------------|---------| | 有明确逻辑错误 | WA | WA | WA | | 逻辑正确但超时 | TLE | TLE | WA | -| 逻辑正确但有隐患 | AC(有风险) | WA | partial | +| 逻辑正确但有隐患 | AC(有风险) | WA | struggling | | 逻辑正确写法不优 | AC(可优化) | (不记录) | confirmed | ### 回流动作模板 @@ -134,7 +134,7 @@ - `{{mastery}}` — 取自状态判定规则表的 "Mastery" 列 - `{{root_cause_tag}}` — 主因标签(从根因标签库 `root-cause-tags.md` 匹配) - `{{fix_rule}}` — 一句话修正规则(从建议修正中提炼,不超过 60 字) -- `{{redo_date}}` — 按 Mastery 级别参考计算(WA/lucky_pass/partial → +1 天,confirmed → 不设) +- `{{redo_date}}` — 按 Mastery 级别参考计算(WA/lucky_pass/struggling → +1 天,confirmed → 不设) **防错规则追加格式**(仅当该标签尚无防错规则时): ``` @@ -143,7 +143,7 @@ 追加到 mistake_log 对应主题分区的末尾,格式与现有条目一致。 -**MCP save_experience 参数**: +**`mcp__exam-memory__save_experience` 参数**: - title: `{{topic}}: {{short_error_description}}` - content: full comparison report - type: "算法" diff --git a/skills/solve-analyze/references/root-cause-tags.md b/skills/solve-analyze/references/root-cause-tags.md index ab6be23..87cce0c 100644 --- a/skills/solve-analyze/references/root-cause-tags.md +++ b/skills/solve-analyze/references/root-cause-tags.md @@ -1,7 +1,7 @@ # Root Cause Tag Library > solve-analyze skill 的根因标签库。分析报告中引用这些标签来分类错误根因。 -> 标签与 `algorithms/mistake_log.md` 的 Root Cause 列对齐。 +> 标签与 `targets/{target}/mistake_log.md` 的 Root Cause 列对齐。 > 新增标签需同步更新本文件和 comparison-template.md 的标签说明。 > 创建日期:2026-06-16 | 基于 mistake_log.md 23 条实际错误提取 @@ -177,7 +177,7 @@ When analyzing user code, apply tags in this priority order: ## Tag-Mistake Log Alignment -This table shows how tags in this file map to the Root Cause column in `algorithms/mistake_log.md`: +This table shows how tags in this file map to the Root Cause column in `targets/{target}/mistake_log.md`: | Root Cause in mistake_log | Tag in root-cause-tags.md | Category | Count | |--------------------------|--------------------------|----------|-------| diff --git a/skills/solve-skeleton/SKILL.md b/skills/solve-skeleton/SKILL.md index 77e8a0a..d735e40 100644 --- a/skills/solve-skeleton/SKILL.md +++ b/skills/solve-skeleton/SKILL.md @@ -1,7 +1,7 @@ --- name: solve-skeleton description: > - Bare-bones Python solve() skeletons for AI Lab campus recruitment exam OJ problems. + Bare-bones Python solve() skeletons for campus recruitment exam OJ problems. Use when the user asks for a solve() template, coding framework, OJ scaffold, or any of these Chinese trigger phrases: "解题框架", "解题模板", "solve骨架", "OJ模板", "ACM框架", "编程题结构", "solve()模板", "输入输出框架", "算法骨架", "解题规范", "代码模板", "框架代码", @@ -124,5 +124,5 @@ If the problem has multiple test cases or unusual input format, pick an I/O mode - `references/algo-skeletons.md` — 8 algorithm templates with trigger keywords and complexity notes - `references/exam-patterns.md` — 6 exam-specific patterns (simulation, stack GCD, bracket, etc.) - `skills/algo-annotation.md` — adds Chinese line-level comments and `# [防错]` markers -- `algorithms/mistake_log.md` — WA patterns by topic; algo-annotation cross-references this +- `targets/{target}/mistake_log.md` — WA patterns by topic; algo-annotation cross-references this - `algorithms/python_oj_template.py` — utility function library (extended toolkit, not skeleton) diff --git a/skills/solve-skeleton/references/exam-patterns.md b/skills/solve-skeleton/references/exam-patterns.md index 9171cd9..a9bf78b 100644 --- a/skills/solve-skeleton/references/exam-patterns.md +++ b/skills/solve-skeleton/references/exam-patterns.md @@ -1,6 +1,6 @@ # Exam Pattern Templates -AI Lab campus recruitment exam highest-frequency patterns. Replace the Algorithm phase +campus recruitment exam highest-frequency patterns. Replace the Algorithm phase in the standard skeleton with the matching template below. See `SKILL.md` section 3 (Template Selection) for how to choose. diff --git a/llm/ai_lab_context.md b/targets/ai-lab/cheatsheets/ai_lab_context.md similarity index 100% rename from llm/ai_lab_context.md rename to targets/ai-lab/cheatsheets/ai_lab_context.md diff --git a/llm/gnn_diffusion_cheatsheet.md b/targets/ai-lab/cheatsheets/gnn_diffusion_cheatsheet.md similarity index 100% rename from llm/gnn_diffusion_cheatsheet.md rename to targets/ai-lab/cheatsheets/gnn_diffusion_cheatsheet.md diff --git a/llm/math_fundamentals.md b/targets/ai-lab/cheatsheets/math_fundamentals.md similarity index 100% rename from llm/math_fundamentals.md rename to targets/ai-lab/cheatsheets/math_fundamentals.md diff --git a/targets/ai-lab/exam_config.md b/targets/ai-lab/exam_config.md new file mode 100644 index 0000000..64137c6 --- /dev/null +++ b/targets/ai-lab/exam_config.md @@ -0,0 +1,29 @@ +# AI Lab 考试配置 + +> Skills 从本文件读取考试格式参数,不要硬编码。 + +## 考试格式 + +| 题型 | 题数 | 分值 | 合计 | +|------|------|------|------| +| 单选题 | 8 | 3分 | 24分 | +| 不定项选择题 | 7 | 4分 | 28分 | + +- 选择题合计:15 题, 52 分 +- 编程题:3 题, 48 分 +- 考试总时长:150 分钟 +- 选择题建议用时:30 分钟 +- 编程题建议用时:120 分钟 + +## 多选策略 + +- 漏选:得部分分 +- 多选/错选:零分 + +## 知识领域 + +| 领域 | 来源 | +|------|------| +| 线性代数 / 概率统计 / 微积分优化 | `targets/{target}/cheatsheets/math_fundamentals.md` | +| DL 基础 / Transformer / LLM 架构 / 微调对齐 / RAG Agent | `shared/cheatsheets/llm_core_cheatsheet.md` | +| CV / GNN / 扩散模型 | `targets/{target}/cheatsheets/gnn_diffusion_cheatsheet.md` | diff --git a/prompts/daily_review_prompt.md b/targets/ai-lab/prompts/daily_review_prompt.md similarity index 100% rename from prompts/daily_review_prompt.md rename to targets/ai-lab/prompts/daily_review_prompt.md diff --git a/prompts/mock_exam_prompt.md b/targets/ai-lab/prompts/mock_exam_prompt.md similarity index 100% rename from prompts/mock_exam_prompt.md rename to targets/ai-lab/prompts/mock_exam_prompt.md diff --git a/sources/ai_lab_history_problems.md b/targets/ai-lab/sources/ai_lab_history_problems.md similarity index 100% rename from sources/ai_lab_history_problems.md rename to targets/ai-lab/sources/ai_lab_history_problems.md diff --git a/sources/source_index.md b/targets/ai-lab/sources/source_index.md similarity index 100% rename from sources/source_index.md rename to targets/ai-lab/sources/source_index.md diff --git a/targets/exam_config_template.md b/targets/exam_config_template.md new file mode 100644 index 0000000..95e5e16 --- /dev/null +++ b/targets/exam_config_template.md @@ -0,0 +1,30 @@ +# {target_name} 考试配置 + +> Skills 从本文件读取考试格式参数,不要硬编码。 +> 创建方式:复制 `targets/exam_config_template.md`,填入目标考试的实际参数。 + +## 考试格式 + +| 题型 | 题数 | 分值 | 合计 | +|------|------|------|------| +| 单选题 | {N} | {X}分 | {合计}分 | +| 不定项选择题 | {N} | {X}分 | {合计}分 | + +- 选择题合计:{总题数} 题, {总分} 分 +- 编程题:{题数} 题, {合计} 分 +- 考试总时长:{分钟} 分钟 +- 选择题建议用时:{分钟} 分钟 +- 编程题建议用时:{分钟} 分钟 + +## 多选策略 + +- 漏选:{得分规则,如"得部分分"} +- 多选/错选:{得分规则,如"零分"} + +## 知识领域 + +> 列出选择题的主要知识来源域,Skills 据此选题。 + +| 领域 | 来源 | +|------|------| +| {领域名} | {cheatsheet 路径} | diff --git a/targets/pdd-algo/cheatsheets/.gitkeep b/targets/pdd-algo/cheatsheets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/targets/pdd-algo/exam_config.md b/targets/pdd-algo/exam_config.md new file mode 100644 index 0000000..dbe609b --- /dev/null +++ b/targets/pdd-algo/exam_config.md @@ -0,0 +1,28 @@ +# PDD 大模型算法岗 考试配置 + +> Skills 从本文件读取考试格式参数,不要硬编码。 + +## 考试格式 + +| 题型 | 题数 | 分值 | 合计 | +|------|------|------|------| +| 单选题 | 10 | 待确认 | 待确认 | +| 不定项选择题 | 5 | 待确认 | 待确认 | + +- 选择题合计:15 题, 52 分 +- 编程题:待确认 +- 考试总时长:待确认 +- 选择题建议用时:30 分钟 +- 编程题建议用时:待确认 + +## 多选策略 + +- 漏选:得部分分 +- 多选/错选:零分 + +## 知识领域 + +| 领域 | 来源 | +|------|------| +| 线性代数 / 概率统计 / 微积分优化 | `targets/{target}/cheatsheets/math_fundamentals.md` | +| DL 基础 / Transformer / LLM 架构 / 微调对齐 / RAG Agent | `shared/cheatsheets/llm_core_cheatsheet.md` | diff --git a/targets/pdd-algo/practice/.gitkeep b/targets/pdd-algo/practice/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/algorithms/practice/bfs_grid.py b/targets/pdd-algo/practice/bfs_grid.py similarity index 100% rename from algorithms/practice/bfs_grid.py rename to targets/pdd-algo/practice/bfs_grid.py diff --git a/algorithms/practice/sliding_window.py b/targets/pdd-algo/practice/sliding_window.py similarity index 100% rename from algorithms/practice/sliding_window.py rename to targets/pdd-algo/practice/sliding_window.py diff --git a/algorithms/python_oj_template.py b/targets/pdd-algo/python_oj_template.py similarity index 100% rename from algorithms/python_oj_template.py rename to targets/pdd-algo/python_oj_template.py diff --git a/algorithms/solutions_batch.py b/targets/pdd-algo/solutions_batch.py similarity index 100% rename from algorithms/solutions_batch.py rename to targets/pdd-algo/solutions_batch.py diff --git a/algorithms/topic_checklist.md b/targets/pdd-algo/topic_checklist.md similarity index 100% rename from algorithms/topic_checklist.md rename to targets/pdd-algo/topic_checklist.md diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..57ada13 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""tests/conftest.py — pytest fixtures and config.""" + +import os +import sys +from pathlib import Path + +import pytest + +# Ensure shared/ is on sys.path for direct test runs +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "shared")) + +# Force pytest tmp_path to use a writable directory (Windows workaround) +if os.name == "nt": + _proj_temp = Path(__file__).resolve().parent.parent / ".tmp" + _proj_temp.mkdir(exist_ok=True) + os.environ.setdefault("TMPDIR", str(_proj_temp)) + os.environ.setdefault("TEMP", str(_proj_temp)) + os.environ.setdefault("TMP", str(_proj_temp)) + + +@pytest.fixture +def tmp_bank(tmp_path): + """Temporary QuestionBank backed by an isolated temp dir.""" + from exam_memory.question_bank import QuestionBank + return QuestionBank(bank_dir=tmp_path) diff --git a/tests/test_knowledge_source.py b/tests/test_knowledge_source.py new file mode 100644 index 0000000..9dde37f --- /dev/null +++ b/tests/test_knowledge_source.py @@ -0,0 +1,375 @@ +"""tests/test_knowledge_source.py — KnowledgeSource Protocol + DirConnector + SourceRegistry 测试。""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from pathlib import Path + +from exam_memory.knowledge_source import KnowledgeSource, DirConnector, SourceChunk +from exam_memory.source_registry import SourceRegistry + + +# ── DirConnector ───────────────────────────────────────────── + + +class TestDirConnectorConnect: + """DirConnector.connect() 测试。""" + + def test_connect_valid_dir(self, tmp_path: Path) -> None: + """connect() 成功扫描已有文件。""" + d = tmp_path / "notes" + d.mkdir() + (d / "hash.md").write_text("# 哈希表\n内容", encoding="utf-8") + (d / "sort.md").write_text("# 排序\n内容", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + assert ds.connect() is True + assert ds.connected is True + + def test_connect_nonexistent_dir(self) -> None: + """connect() 对不存在目录返回 False。""" + ds = DirConnector(name="test", path="/nonexistent/path/xyz") + assert ds.connect() is False + assert ds.connected is False + + def test_connect_empty_dir(self, tmp_path: Path) -> None: + """connect() 对空目录返回 True(合法但无文件)。""" + d = tmp_path / "empty" + d.mkdir() + + ds = DirConnector(name="test", path=str(d)) + assert ds.connect() is True + assert ds.connected is True + + def test_connected_property(self, tmp_path: Path) -> None: + """connected 在 connect 前后正确反映状态。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + assert ds.connected is False + ds.connect() + assert ds.connected is True + + +class TestDirConnectorFetch: + """DirConnector.fetch() 测试。""" + + def test_fetch_returns_chunks(self, tmp_path: Path) -> None: + """fetch() 按 topic 匹配文件名返回 chunks。""" + d = tmp_path / "notes" + d.mkdir() + (d / "哈希表.md").write_text("# 哈希表\nO(1) 查找", encoding="utf-8") + (d / "排序.md").write_text("# 排序\n快速排序", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + ds.connect() + + chunks = ds.fetch("哈希表") + assert len(chunks) == 1 + assert all(isinstance(c, dict) for c in chunks) + assert "text" in chunks[0] + assert chunks[0]["source"] == "哈希表.md" + + def test_fetch_topic_match_in_content(self, tmp_path: Path) -> None: + """fetch() 按 topic 匹配文件内容返回 chunks。""" + d = tmp_path / "notes" + d.mkdir() + (d / "algos.md").write_text( + "# 算法合集\n\n## 哈希表\n哈希表是一种 O(1) 数据结构", + encoding="utf-8", + ) + + ds = DirConnector(name="test", path=str(d)) + ds.connect() + + chunks = ds.fetch("哈希表") + assert len(chunks) > 0 + assert any("哈希表" in c["text"] for c in chunks) + + def test_fetch_no_match_returns_empty(self, tmp_path: Path) -> None: + """fetch() 无匹配时返回空列表。""" + d = tmp_path / "notes" + d.mkdir() + (d / "sort.md").write_text("# 排序", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + ds.connect() + + chunks = ds.fetch("完全不匹配的关键词") + assert chunks == [] + + def test_fetch_limit_respected(self, tmp_path: Path) -> None: + """fetch() 的 limit 参数限制返回数量。""" + d = tmp_path / "notes" + d.mkdir() + # 写一个长文本,会被分成多个 chunks + long_text = "\n\n".join( + [f"## 段落 {i}\n哈希表相关内容 {i} " + "x" * 200 for i in range(20)] + ) + (d / "hash.md").write_text(long_text, encoding="utf-8") + + ds = DirConnector(name="test", path=str(d), chunk_size=400) + ds.connect() + + chunks = ds.fetch("哈希表", limit=2) + assert len(chunks) <= 2 + + +class TestDirConnectorListTopics: + """DirConnector.list_topics() 测试。""" + + def test_list_topics_returns_filenames_without_extension(self, tmp_path: Path) -> None: + """list_topics() 返回去扩展名的文件名列表。""" + d = tmp_path / "notes" + d.mkdir() + (d / "hash.md").write_text("hash content", encoding="utf-8") + (d / "sort.md").write_text("sort content", encoding="utf-8") + (d / "graph.md").write_text("graph content", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + ds.connect() + + topics = ds.list_topics() + assert "hash" in topics + assert "sort" in topics + assert "graph" in topics + # 确认不含扩展名 + assert not any(t.endswith(".md") for t in topics) + + +class TestDirConnectorRefresh: + """DirConnector.refresh() 测试。""" + + def test_refresh_detects_new_files(self, tmp_path: Path) -> None: + """refresh() 检测新增文件并返回新增数量。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + ds.connect() + assert len(ds.list_topics()) == 1 + + # 添加新文件后 refresh + (d / "b.md").write_text("b", encoding="utf-8") + added = ds.refresh() + assert added >= 1 + assert len(ds.list_topics()) == 2 + + +# ── SourceRegistry ──────────────────────────────────────────── + + +class TestSourceRegistryMount: + """SourceRegistry.mount() 测试。""" + + def test_mount_success(self, tmp_path: Path) -> None: + """mount() 成功注册已连接的源。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + assert reg.mount(ds) is True + + def test_mount_connect_failure(self) -> None: + """mount() 对连接失败的源返回 False。""" + ds = DirConnector(name="bad", path="/nonexistent/xyz") + reg = SourceRegistry() + assert reg.mount(ds) is False + + def test_mount_duplicate_returns_false(self, tmp_path: Path) -> None: + """mount() 重复挂载同名源返回 False。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds1 = DirConnector(name="same", path=str(d)) + ds2 = DirConnector(name="same", path=str(d)) + reg = SourceRegistry() + assert reg.mount(ds1) is True + assert reg.mount(ds2) is False + + +class TestSourceRegistryUnmount: + """SourceRegistry.unmount() 测试。""" + + def test_unmount_existing(self, tmp_path: Path) -> None: + """unmount() 移除已挂载源并返回 True。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + assert reg.unmount("test") is True + assert reg.get("test") is None + + def test_unmount_nonexistent(self) -> None: + """unmount() 不存在的源返回 False。""" + reg = SourceRegistry() + assert reg.unmount("nope") is False + + +class TestSourceRegistryGet: + """SourceRegistry.get() 测试。""" + + def test_get_existing(self, tmp_path: Path) -> None: + """get() 返回已挂载的源实例。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + assert reg.get("test") is ds + + def test_get_nonexistent(self) -> None: + """get() 不存在的源返回 None。""" + reg = SourceRegistry() + assert reg.get("nope") is None + + +class TestSourceRegistryListMounted: + """SourceRegistry.list_mounted() 测试。""" + + def test_list_mounted_returns_info(self, tmp_path: Path) -> None: + """list_mounted() 返回已挂载源的信息列表。""" + d = tmp_path / "notes" + d.mkdir() + (d / "a.md").write_text("a", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + + result = reg.list_mounted() + assert len(result) == 1 + assert result[0]["name"] == "test" + assert result[0]["source_type"] == "local_dir" + assert result[0]["connected"] is True + assert result[0]["topic_count"] == 1 + + +class TestSourceRegistryFetch: + """SourceRegistry.fetch_from / fetch_all 测试。""" + + def test_fetch_from(self, tmp_path: Path) -> None: + """fetch_from() 从指定源检索。""" + d = tmp_path / "notes" + d.mkdir() + (d / "哈希表.md").write_text("# 哈希表\nO(1) 查找", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + + chunks = reg.fetch_from("test", "哈希表") + assert len(chunks) > 0 + + def test_fetch_from_nonexistent(self) -> None: + """fetch_from() 不存在的源返回空列表。""" + reg = SourceRegistry() + chunks = reg.fetch_from("nope", "topic") + assert chunks == [] + + def test_fetch_all_multiple_sources(self, tmp_path: Path) -> None: + """fetch_all() 遍历所有已挂载源合并结果。""" + d1 = tmp_path / "s1" + d1.mkdir() + (d1 / "哈希表.md").write_text("源1 哈希表内容", encoding="utf-8") + + d2 = tmp_path / "s2" + d2.mkdir() + (d2 / "哈希表.md").write_text("源2 哈希表内容", encoding="utf-8") + + ds1 = DirConnector(name="s1", path=str(d1)) + ds2 = DirConnector(name="s2", path=str(d2)) + reg = SourceRegistry() + reg.mount(ds1) + reg.mount(ds2) + + result = reg.fetch_all("哈希表") + assert "s1" in result + assert "s2" in result + assert len(result["s1"]) > 0 + assert len(result["s2"]) > 0 + + +# ── 边界测试 ───────────────────────────────────────────────── + + +class TestDirConnectorEdgeCases: + """DirConnector 边界 case。""" + + def test_connect_path_is_file_not_dir(self, tmp_path: Path) -> None: + """connect() 对文件路径(非目录)返回 False。""" + f = tmp_path / "file.md" + f.write_text("content", encoding="utf-8") + + ds = DirConnector(name="test", path=str(f)) + assert ds.connect() is False + + def test_fetch_glob_no_match(self, tmp_path: Path) -> None: + """glob_pattern 不匹配任何文件时 fetch 返回空。""" + d = tmp_path / "notes" + d.mkdir() + # 文件是 .txt,但 glob 只匹配 *.md + (d / "data.txt").write_text("哈希表内容", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d), glob_pattern="*.md") + ds.connect() + assert ds.list_topics() == [] + assert ds.fetch("哈希表") == [] + + def test_fetch_large_file_chunking(self, tmp_path: Path) -> None: + """大文件正确分块,每个 chunk 不超过 chunk_size。""" + d = tmp_path / "notes" + d.mkdir() + chunk_size = 500 + # 生成超长文本 + large_text = "\n\n".join([f"## Section {i}\n哈希表 section {i} " + "x" * 100 for i in range(30)]) + (d / "big.md").write_text(large_text, encoding="utf-8") + + ds = DirConnector(name="test", path=str(d), chunk_size=chunk_size) + ds.connect() + + chunks = ds.fetch("哈希表", limit=100) + assert len(chunks) > 1 + for c in chunks: + # chunk_text 不会切开一个段落;单个段落最长约 100 个填充字符加标题/空白。 + assert len(c["text"]) <= chunk_size + 100 + + +class TestSourceRegistryEdgeCases: + """SourceRegistry 边界 case。""" + + def test_unmount_then_fetch_returns_empty(self, tmp_path: Path) -> None: + """unmount 后 fetch 返回空。""" + d = tmp_path / "notes" + d.mkdir() + (d / "哈希表.md").write_text("哈希表内容", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + + # 确认 fetch 有结果 + assert len(reg.fetch_from("test", "哈希表")) > 0 + + # unmount 后 fetch 为空 + reg.unmount("test") + assert reg.fetch_from("test", "哈希表") == [] + + def test_fetch_all_empty_registry(self) -> None: + """空 registry 的 fetch_all 返回空 dict。""" + reg = SourceRegistry() + result = reg.fetch_all("any topic") + assert result == {} diff --git a/tests/test_question_bank.py b/tests/test_question_bank.py new file mode 100644 index 0000000..05f8c82 --- /dev/null +++ b/tests/test_question_bank.py @@ -0,0 +1,819 @@ +"""tests/test_question_bank.py — 题库模块单元测试。 + +覆盖: CRUD / QuestionParser / QualityValidator / PromptBuilder / generate 管道 / import_from_dir +运行: pytest tests/test_question_bank.py -v +""" +from __future__ import annotations + +import pytest + +from exam_memory.question_bank import ( + QuestionBank, + QuestionParser, + QualityValidator, + PromptBuilder, + ReviewGate, + MODE_RAG, + MODE_TEXT, + MODE_DIRECT, + _body, + _extract_title, + _parse_frontmatter, +) + + +# ── Fixtures ────────────────────────────────────────────────── + +@pytest.fixture +def sample_question(tmp_path): + """手动创建一条题目,返回 (bank, filename)。""" + bank = QuestionBank(bank_dir=tmp_path) + fn = bank.add_manual( + title="两数之和", + content="给定一个整数数组 nums 和一个目标值 target,找出和为目标值的两个数。", + q_type="算法", + knowledge="双指针", + answer="C", + options={ + "A": "O(n^2) 暴力枚举", + "B": "O(n log n) 排序后二分", + "C": "O(n) 哈希表", + "D": "O(n) 双指针(需先排序)", + }, + explanation="用哈希表记录已遍历的值,查找 complement 在 O(1) 内完成。", + difficulty="中等", + ) + return bank, fn + + +# ── _parse_frontmatter ──────────────────────────────────────── + +class TestParseFrontmatter: + def test_valid(self): + text = "---\ntype: 算法\nknowledge: 双指针\n---\n\nbody" + result = _parse_frontmatter(text) + assert result["type"] == "算法" + assert result["knowledge"] == "双指针" + + def test_no_frontmatter(self): + assert _parse_frontmatter("plain body") == {} + + def test_incomplete(self): + assert _parse_frontmatter("---\ntype: 算法") == {} + + def test_empty_yaml(self): + assert _parse_frontmatter("---\n---\n\nbody") == {} + + +# ── _body / _extract_title ──────────────────────────────────── + +class TestBodyHelpers: + def test_body_with_frontmatter(self): + text = "---\nkey: val\n---\n\n## Hello\n\ncontent" + assert _body(text) == "## Hello\n\ncontent" + + def test_body_without_frontmatter(self): + assert _body("plain text") == "plain text" + + def test_extract_title(self): + text = "# H1\n## My Title\n\nbody" + assert _extract_title(text) == "My Title" + + def test_extract_title_none(self): + assert _extract_title("no heading") is None + + +# ── Filename sequencing ─────────────────────────────────────── + +class TestFilenameSequencing: + def test_uuid_filenames_keep_incrementing_sequence(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + common = { + "content": "题干内容", + "q_type": "算法", + "knowledge": "哈希表", + "answer": "A", + "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + } + + first = bank.add_manual(title="T1", **common) + second = bank.add_manual(title="T2", **common) + + assert first.startswith("算法_哈希表_001_") + assert second.startswith("算法_哈希表_002_") + + +# ── QualityValidator ────────────────────────────────────────── + +class TestQualityValidator: + @pytest.fixture + def v(self): + return QualityValidator() + + def test_valid_question(self, v): + q = {"stem": "题目", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "C", "explanation": "解析"} + valid, invalid = v.validate([q]) + assert len(valid) == 1 + assert len(invalid) == 0 + + def test_empty_stem(self, v): + q = {"stem": "", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "C", "explanation": "解析"} + valid, invalid = v.validate([q]) + assert len(valid) == 0 + assert len(invalid) == 1 + assert "题干为空" in invalid[0]["_reject_reason"] + + def test_too_few_options(self, v): + q = {"stem": "题目", "options": {"A": "1", "B": "2"}, + "answer": "A", "explanation": "解析"} + valid, invalid = v.validate([q]) + assert len(invalid) == 1 + + def test_multi_char_answer(self, v): + q = {"stem": "题目", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "AC", "explanation": "解析"} + valid, invalid = v.validate([q]) + assert len(invalid) == 1 + + def test_out_of_range_answer(self, v): + q = {"stem": "题目", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "E", "explanation": "解析"} + valid, invalid = v.validate([q]) + assert len(invalid) == 1 + + def test_empty_explanation(self, v): + v2 = QualityValidator(require_explanation=True) + q = {"stem": "题目", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "A", "explanation": ""} + valid, invalid = v2.validate([q]) + assert len(invalid) == 1 + + def test_no_explanation_required(self): + v = QualityValidator(require_explanation=False) + q = {"stem": "题目", "options": {"A": "1", "B": "2", "C": "3", "D": "4"}, + "answer": "A", "explanation": ""} + valid, invalid = v.validate([q]) + assert len(valid) == 1 + + +# ── QuestionParser ──────────────────────────────────────────── + +class TestQuestionParser: + @pytest.fixture + def parser(self): + return QuestionParser() + + def test_parse_single(self, parser): + raw = """\ +--- +## 两数之和 + +给定整数数组,求和为 target 的两数。 + +### 选项 + +**A.** O(n^2) 暴力 +**B.** O(n log n) 排序二分 +**C.** O(n) 哈希表 +**D.** O(n) 双指针 + +### 答案 + +C + +### 解析 + +用哈希表记录遍历过的值。 +---""" + results = parser.parse(raw) + assert len(results) == 1 + assert results[0]["answer"] == "C" + assert "给定整数数组" in results[0]["stem"] + assert len(results[0]["options"]) == 4 + assert "哈希表" in results[0]["explanation"] + + def test_parse_multiple(self, parser): + raw = """\ +--- +## Q1 +题干1 +### 选项 +**A.** a1 +**B.** a2 +**C.** a3 +**D.** a4 +### 答案 +B +### 解析 +解析1 +--- +## Q2 +题干2 +### 选项 +**A.** b1 +**B.** b2 +**C.** b3 +### 答案 +C +### 解析 +解析2 +---""" + results = parser.parse(raw) + assert len(results) == 2 + assert results[0]["answer"] == "B" + assert results[1]["answer"] == "C" + + def test_parse_empty(self, parser): + assert parser.parse("") == [] + assert parser.parse("no markers here") == [] + + def test_parse_missing_answer(self, parser): + raw = "## 题\n题干\n### 选项\n**A.** x" + results = parser.parse(raw) + assert len(results) == 0 # no answer -> skipped + + def test_parse_case_insensitive_answer(self, parser): + raw = "## 题\n题干\n### 选项\n**A.** x\n**B.** y\n**C.** z\n**D.** w\n### 答案\nc\n### 解析\n解析\n" + results = parser.parse(raw) + assert results[0]["answer"] == "C" + + +# ── PromptBuilder ───────────────────────────────────────────── + +class TestPromptBuilder: + @pytest.fixture + def builder(self): + return PromptBuilder() + + def test_build_basic(self, builder): + system, user = builder.build([], "双指针", "算法", 3) + assert "双指针" in user + assert "3 道" in user + assert "算法" in user + assert "出题专家" in system + + def test_build_with_chunks(self, builder): + chunks = [{"text": "哈希表可以在 O(n) 时间内完成查找。"}] + system, user = builder.build(chunks, "哈希表", "单选题", 2) + assert "哈希表" in user + assert "参考资料" in user + + def test_build_limits_chunks(self, builder): + chunks = [{"text": f"chunk {i}"} for i in range(10)] + system, user = builder.build(chunks, "x", "算法", 2) + for i in range(5): + assert f"chunk {i}" in user + assert "chunk 5" not in user + + def test_build_mode_text(self, builder): + system, user = builder.build( + [], "排序", "单选题", 2, + mode=MODE_TEXT, source_text="快速排序是分治算法。", + ) + assert "快速排序是分治算法" in user + assert "参考资料" in user + assert "排序" in user + + def test_build_mode_text_empty_source(self, builder): + system, user = builder.build( + [], "排序", "单选题", 2, + mode=MODE_TEXT, source_text="", + ) + assert "无参考资料" in user + + def test_build_mode_direct(self, builder): + system, user = builder.build( + [], "BFS", "算法", 3, mode=MODE_DIRECT, + ) + assert "BFS" in user + assert "3 道" in user + assert "参考资料" not in user + + def test_build_backward_compat_default_rag(self, builder): + """默认模式为 MODE_RAG,保持向后兼容。""" + chunks = [{"text": "chunk A"}] + system, user = builder.build(chunks, "K", "算法", 1) + assert "chunk A" in user + assert "参考资料" in user + + +# ── CRUD ────────────────────────────────────────────────────── + +class TestCRUD: + def test_add_manual_basic(self, tmp_bank): + fn = tmp_bank.add_manual( + title="T", content="C", q_type="算法", + knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + assert fn.startswith("算法_K_") + assert fn.endswith(".md") + assert tmp_bank.count() == 1 + + def test_add_creates_valid_file(self, tmp_bank): + fn = tmp_bank.add_manual( + title="两数之和", content="题干", q_type="算法", + knowledge="双指针", answer="C", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + fp = tmp_bank.bank_dir / fn + assert fp.exists() + text = fp.read_text(encoding="utf-8") + assert text.startswith("---") + assert "type: 算法" in text + assert "knowledge: 双指针" in text + assert "question_id:" in text + + def test_add_invalid_type(self, tmp_bank): + with pytest.raises(ValueError, match="不支持的题型"): + tmp_bank.add_manual( + title="T", content="C", q_type="论述题", + knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + + def test_add_invalid_difficulty(self, tmp_bank): + with pytest.raises(ValueError, match="不支持的难度"): + tmp_bank.add_manual( + title="T", content="C", q_type="算法", + knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + difficulty="极难", + ) + + def test_get_existing(self, sample_question): + bank, fn = sample_question + qid = fn.replace(".md", "") + item = bank.get(qid) + assert item is not None + assert item["title"] == "两数之和" + assert item["knowledge"] == "双指针" + assert "### 答案\n\nC" in item["body"] + assert "双指针" in item["body"] + + def test_get_missing(self, tmp_bank): + assert tmp_bank.get("bank_nonexistent") is None + + def test_list_all_empty(self, tmp_bank): + assert tmp_bank.list_all() == [] + + def test_list_all_filtered(self, tmp_bank): + tmp_bank.add_manual(title="T1", content="C", q_type="算法", + knowledge="双指针", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}) + tmp_bank.add_manual(title="T2", content="C", q_type="单选题", + knowledge="排序", answer="B", + options={"A": "1", "B": "2", "C": "3", "D": "4"}) + all_items = tmp_bank.list_all() + assert len(all_items) == 2 + algo_items = tmp_bank.list_all(q_type="算法") + assert len(algo_items) == 1 + assert algo_items[0]["knowledge"] == "双指针" + + def test_delete_existing(self, sample_question): + bank, fn = sample_question + qid = fn.replace(".md", "") + assert bank.delete(qid) is True + assert bank.count() == 0 + assert bank.get(qid) is None + + def test_delete_missing(self, tmp_bank): + assert tmp_bank.delete("bank_nonexist") is False + + def test_count(self, tmp_bank): + assert tmp_bank.count() == 0 + for i in range(3): + tmp_bank.add_manual( + title=f"T{i}", content="C", q_type="算法", + knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + assert tmp_bank.count() == 3 + assert tmp_bank.count(q_type="算法") == 3 + + +# ── generate 管道 ───────────────────────────────────────────── + +class TestGenerate: + @pytest.fixture + def bank_with_mock(self, tmp_path, monkeypatch): + """带 mock LLM 的 QuestionBank。""" + bank = QuestionBank(bank_dir=tmp_path) + mock_raw = """\ +--- +## 哈希表查找 + +在 O(n) 时间内查找 target 的索引。 + +### 选项 + +**A.** 两层循环 O(n^2) +**B.** 排序后二分 O(n log n) +**C.** 哈希表 O(n) +**D.** 动态规划 O(n^2) + +### 答案 + +C + +### 解析 + +一次遍历,哈希表记录已访问元素及其索引。 +--- + +## 快排分区 + +实现快速排序的分区函数。 + +### 选项 + +**A.** pivot 选首元素 +**B.** pivot 选尾元素 +**C.** pivot 随机选择 +**D.** pivot 选中位数 + +### 答案 + +C + +### 解析 + +随机 pivot 避免最坏情况 O(n^2)。 +--- +""" + monkeypatch.setattr(bank, "_call_llm", lambda s, u: mock_raw) + return bank + + def test_generate_saves_questions(self, bank_with_mock): + result = bank_with_mock.generate( + topic="哈希表", count=2, q_type="算法", difficulty="中等", + ) + assert result["error"] is None + assert len(result["saved"]) == 2 + assert len(result["validated"]) == 2 + assert result["rejected"] == [] + + def test_generate_sets_metadata(self, bank_with_mock): + result = bank_with_mock.generate( + topic="排序", count=1, q_type="算法", difficulty="困难", + ) + saved = result["validated"] + assert len(saved) >= 1 + for s in saved: + assert s["knowledge"] == "排序" + assert s["difficulty"] == "困难" + assert s["generated"] is True + assert s["reviewed"] is False + + def test_generate_no_llm(self, tmp_path, monkeypatch): + """litellm 不可用时返回 error。""" + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: None) + result = bank.generate(topic="x", count=1) + assert result["error"] is not None + assert result["saved"] == [] + + def test_generate_rejects_bad_output(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: "not a valid question format") + result = bank.generate(topic="x", count=1) + assert result["error"] is not None + assert len(result["saved"]) == 0 + + def test_generate_rejects_invalid_options_count(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + raw = "## 题\n题干\n### 选项\n**A.** x\n**B.** y\n### 答案\nA\n### 解析\np\n" + monkeypatch.setattr(bank, "_call_llm", lambda s, u: raw) + result = bank.generate(topic="x", count=1) + assert len(result["rejected"]) >= 1 + + +# ── 三类提取管道 ────────────────────────────────────────────── + +class TestExtractionPipelines: + MOCK_RAW = """\ +--- +## 哈希表查找 + +在 O(n) 时间内查找 target 的索引。 + +### 选项 + +**A.** 两层循环 O(n^2) +**B.** 排序后二分 O(n log n) +**C.** 哈希表 O(n) +**D.** 动态规划 O(n^2) + +### 答案 + +C + +### 解析 + +一次遍历,哈希表记录已访问元素及其索引。 +--- +""" + + def test_rag_extract_saves(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: self.MOCK_RAW) + result = bank.rag_extract(topic="哈希表", count=1, q_type="算法") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_text_extract_saves(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: self.MOCK_RAW) + result = bank.text_extract( + source_text="哈希表是 O(1) 查找的数据结构。", + topic="哈希表", count=1, q_type="算法", + ) + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_text_extract_passes_source_text(self, tmp_path, monkeypatch): + """验证 source_text 出现在 prompt 中。""" + bank = QuestionBank(bank_dir=tmp_path) + captured = {} + def mock_llm(system, user): + captured["user"] = user + return self.MOCK_RAW + monkeypatch.setattr(bank, "_call_llm", mock_llm) + bank.text_extract( + source_text="快排平均 O(n log n)", topic="排序", count=1, + ) + assert "快排平均 O(n log n)" in captured["user"] + + def test_direct_extract_saves(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: self.MOCK_RAW) + result = bank.direct_extract(topic="BFS", count=1, q_type="算法") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_direct_extract_no_context(self, tmp_path, monkeypatch): + """验证直出模式 prompt 中无参考资料。""" + bank = QuestionBank(bank_dir=tmp_path) + captured = {} + def mock_llm(system, user): + captured["user"] = user + return self.MOCK_RAW + monkeypatch.setattr(bank, "_call_llm", mock_llm) + bank.direct_extract(topic="DFS", count=1) + assert "参考资料" not in captured["user"] + + def test_generate_equals_rag_extract(self, tmp_path, monkeypatch): + """generate() 向后兼容,等价于 rag_extract()。两者共享管道。""" + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: self.MOCK_RAW) + r1 = bank.generate(topic="哈希表", count=1) + assert r1["error"] is None + assert len(r1["saved"]) == 1 + # 第二次调用同 topic,重复题目被去重拒绝 + r2 = bank.rag_extract(topic="哈希表", count=1) + assert r2["error"] is None + assert len(r2["saved"]) == 0 # 去重:已存在 + assert len(r2["rejected"]) >= 1 + + def test_text_extract_no_llm(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: None) + result = bank.text_extract(source_text="x", topic="x", count=1) + assert result["error"] is not None + + def test_direct_extract_no_llm(self, tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: None) + result = bank.direct_extract(topic="x", count=1) + assert result["error"] is not None + + +# ── ReviewGate ──────────────────────────────────────────────── + +class TestReviewGate: + @pytest.fixture + def gate_setup(self, tmp_path): + """创建 bank + 一条完整题目 + ReviewGate。""" + bank = QuestionBank(bank_dir=tmp_path) + fn = bank.add_manual( + title="两数之和", content="给定数组和目标值,找两数之和。", + q_type="算法", knowledge="双指针", answer="C", + options={"A": "暴力", "B": "排序", "C": "哈希", "D": "DP"}, + explanation="哈希表 O(n)。", + ) + qid = fn.replace(".md", "") + gate = ReviewGate(bank) + return bank, gate, qid + + def test_approve_sets_reviewed(self, gate_setup): + bank, gate, qid = gate_setup + assert gate.approve(qid) is True + item = bank.get(qid) + assert item["reviewed"] is True + + def test_reject_sets_unreviewed(self, gate_setup): + bank, gate, qid = gate_setup + gate.approve(qid) + assert gate.reject(qid) is True + item = bank.get(qid) + assert item["reviewed"] is False + + def test_approve_nonexistent(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + gate = ReviewGate(bank) + assert gate.approve("nonexistent") is False + + def test_reject_nonexistent(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + gate = ReviewGate(bank) + assert gate.reject("nonexistent") is False + + def test_review_gate_approve_valid(self, gate_setup): + _, gate, qid = gate_setup + result = gate.review_gate(qid) + assert result["decision"] == "approve" + assert result["reasons"] == [] + + def test_review_gate_rejects_unreviewed(self, gate_setup): + """未审核的题目通过 review_gate 自动校验。""" + bank, gate, qid = gate_setup + # 新建的题目 reviewed=False,review_gate 应做质量校验 + result = gate.review_gate(qid) + # 这道题质量合格,应自动 approve + assert result["decision"] == "approve" + + def test_review_gate_nonexistent(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + gate = ReviewGate(bank) + result = gate.review_gate("no_such_id") + assert result["decision"] == "reject" + assert "不存在" in result["reasons"][0] + + def test_review_gate_roundtrip(self, gate_setup): + """approve → review_gate → 自动 approve 流程。""" + bank, gate, qid = gate_setup + gate.approve(qid) + result = gate.review_gate(qid) + assert result["decision"] == "approve" + + +# ── 精确去重 ────────────────────────────────────────────────── + +class TestDedup: + def test_add_duplicate_stem_raises(self, tmp_path): + """相同题干内容应触发去重(check_dup=True)。""" + bank = QuestionBank(bank_dir=tmp_path) + bank.add_manual( + title="两数之和", content="给定数组和目标值,找两数之和。", + q_type="算法", knowledge="双指针", answer="C", + options={"A": "暴力", "B": "排序", "C": "哈希", "D": "DP"}, + check_dup=True, + ) + with pytest.raises(ValueError, match="题目重复"): + bank.add_manual( + title="两数之和", content="给定数组和目标值,找两数之和。", + q_type="算法", knowledge="双指针", answer="C", + options={"A": "暴力", "B": "排序", "C": "哈希", "D": "DP"}, + check_dup=True, + ) + + def test_add_different_stem_allowed(self, tmp_path): + """不同题干内容应允许添加。""" + bank = QuestionBank(bank_dir=tmp_path) + bank.add_manual( + title="Q1", content="题目一的内容。", + q_type="算法", knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + bank.add_manual( + title="Q2", content="题目二完全不同。", + q_type="算法", knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + assert bank.count() == 2 + + def test_check_duplicate_no_match(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + assert bank.check_duplicate("全新题目内容") == [] + + def test_check_duplicate_with_existing(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + bank.add_manual( + title="T", content="已有题目。", + q_type="算法", knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + reasons = bank.check_duplicate("已有题目。") + assert len(reasons) == 1 + assert "重复" in reasons[0] + + def test_check_duplicate_empty_stem(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + assert bank.check_duplicate("") == [] + + def test_stem_normalization(self, tmp_path): + """不同空白/标点但核心内容相同的 stem 应匹配。""" + bank = QuestionBank(bank_dir=tmp_path) + bank.add_manual( + title="T", content="给定一个数组,找出两数之和。", + q_type="算法", knowledge="K", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + # 不同标点但内容相同 + reasons = bank.check_duplicate("给定一个数组 找出两数之和") + assert len(reasons) == 1 + + def test_short_stem_still_dedup(self, tmp_path): + """短题干也能去重。""" + bank = QuestionBank(bank_dir=tmp_path) + bank.add_manual( + title="T", content="什么是快排?", + q_type="单选题", knowledge="排序", answer="A", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + reasons = bank.check_duplicate("什么是快排?") + assert len(reasons) == 1 + + +# ── search ──────────────────────────────────────────────────── + +class TestSearch: + def test_search_empty_bank(self, tmp_bank): + assert tmp_bank.search("anything") == [] + + def test_search_with_items(self, tmp_bank): + tmp_bank.add_manual( + title="两数之和", content="哈希表 O(n) 查找两数和", + q_type="算法", knowledge="双指针", answer="C", + options={"A": "1", "B": "2", "C": "3", "D": "4"}, + ) + results = tmp_bank.search("哈希表") + assert len(results) >= 1 + assert results[0]["knowledge"] == "双指针" + + +# ── import_from_dir ─────────────────────────────────────────── + +class TestImportFromDir: + def test_import_basic(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + src = tmp_path / "src" + src.mkdir() + + q1 = "---\ntype: 算法\nknowledge: 二分查找\n---\n\n## 二分基础\n\n内容\n" + q2 = "---\ntype: 单选题\nknowledge: 链表\n---\n\n## 链表反转\n\n内容\n" + (src / "a.md").write_text(q1, encoding="utf-8") + (src / "b.md").write_text(q2, encoding="utf-8") + + n = bank.import_from_dir(str(src)) + assert n == 2 + assert bank.count(q_type="算法") == 1 + assert bank.count(q_type="单选题") == 1 + + def test_import_skips_existing(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "a.md").write_text( + "---\ntype: 算法\nknowledge: 二分查找\n---\n\n## Q\n\nC\n", + encoding="utf-8", + ) + + bank.import_from_dir(str(src)) + n = bank.import_from_dir(str(src)) + assert n == 0 # already exists + + def test_import_skips_unknown_type(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + src = tmp_path / "src" + src.mkdir() + (src / "x.md").write_text( + "---\ntype: 论述题\nknowledge: x\n---\n\n## Q\n\nC\n", + encoding="utf-8", + ) + n = bank.import_from_dir(str(src)) + assert n == 0 + + def test_import_missing_dir(self, tmp_path): + bank = QuestionBank(bank_dir=tmp_path) + with pytest.raises(ValueError, match="目录不存在"): + bank.import_from_dir(str(tmp_path / "nope")) + + +# ── add() dict 快捷方式 ──────────────────────────────────────── + +class TestAddDict: + def test_add_from_dict(self, tmp_bank): + fn = tmp_bank.add({ + "title": "DP 入门", + "content": "动态规划描述", + "type": "算法", + "knowledge": "动态规划", + "answer": "A", + "options": {"A": "自顶向下", "B": "自底向上", "C": "贪心", "D": "回溯"}, + "explanation": "DP 是分治+记忆化", + }) + assert fn.startswith("算法_动态规划_") + assert tmp_bank.count() == 1 diff --git a/tests/test_source_connector.py b/tests/test_source_connector.py new file mode 100644 index 0000000..6ab61bf --- /dev/null +++ b/tests/test_source_connector.py @@ -0,0 +1,235 @@ +"""tests/test_source_connector.py — SourceConnector 单元测试。""" +from __future__ import annotations + +import pytest + +from exam_memory.question_bank import QuestionBank +from exam_memory.source_connector import SourceConnector +from exam_memory.source_registry import SourceRegistry +from exam_memory.knowledge_source import DirConnector + + +# ── Fixtures ────────────────────────────────────────────────── + +MOCK_RAW = """\ +--- +## 测试题 + +这是一道测试题。 + +### 选项 + +**A.** 选项A +**B.** 选项B +**C.** 选项C +**D.** 选项D + +### 答案 + +C + +### 解析 + +测试解析。 +--- +""" + + +@pytest.fixture +def connector(tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: MOCK_RAW) + return SourceConnector(bank) + + +@pytest.fixture +def connector_no_llm(tmp_path, monkeypatch): + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: None) + return SourceConnector(bank) + + +# ── connect() 自动路由 ──────────────────────────────────────── + +class TestConnect: + def test_connect_text(self, connector): + result = connector.connect("哈希表是 O(1) 查找结构", topic="哈希表") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_connect_file(self, connector, tmp_path): + fp = tmp_path / "notes.md" + fp.write_text("快排平均 O(n log n) 时间复杂度。", encoding="utf-8") + result = connector.connect(str(fp), topic="排序") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_connect_chunks(self, connector): + chunks = [{"text": "BFS 从起点逐层遍历。"}, {"text": "DFS 深度优先。"}] + result = connector.connect(chunks, topic="图遍历") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_connect_empty_text(self, connector): + result = connector.connect("", topic="x") + assert result["error"] is not None + assert "为空" in result["error"] + + def test_connect_nonexistent_file(self, connector): + result = connector.connect("/no/such/file.md", topic="x") + assert result["error"] is not None + assert "不存在" in result["error"] + + def test_connect_empty_chunks(self, connector): + result = connector.connect([], topic="x") + assert result["error"] is not None + assert "为空" in result["error"] + + def test_connect_chunks_no_text(self, connector): + result = connector.connect([{"text": ""}, {"other": "data"}], topic="x") + assert result["error"] is not None + + def test_connect_unsupported_type(self, connector): + result = connector.connect(12345, topic="x") # type: ignore + assert result["error"] is not None + assert "不支持" in result["error"] + + +# ── 显式入口 ────────────────────────────────────────────────── + +class TestExplicitMethods: + def test_from_text(self, connector): + result = connector.from_text("动态规划核心是状态转移。", topic="DP") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_from_file(self, connector, tmp_path): + fp = tmp_path / "dp.md" + fp.write_text("背包问题是经典 DP。", encoding="utf-8") + result = connector.from_file(fp, topic="背包") + assert result["error"] is None + + def test_from_chunks(self, connector): + result = connector.from_chunks( + [{"text": "二分查找 O(log n)"}], topic="二分", + ) + assert result["error"] is None + + def test_from_file_empty(self, connector, tmp_path): + fp = tmp_path / "empty.md" + fp.write_text("", encoding="utf-8") + result = connector.from_file(fp, topic="x") + assert result["error"] is not None + + def test_from_file_nonexistent(self, connector): + result = connector.from_file("/no/such/path.md", topic="x") + assert result["error"] is not None + + +# ── LLM 不可用 ──────────────────────────────────────────────── + +class TestNoLLM: + def test_text_no_llm(self, connector_no_llm): + result = connector_no_llm.from_text("some text", topic="x") + assert result["error"] is not None + + def test_chunks_no_llm(self, connector_no_llm): + result = connector_no_llm.from_chunks([{"text": "chunk"}], topic="x") + assert result["error"] is not None + + +# ── 错误处理边界 ────────────────────────────────────────────── + +class TestErrorBoundary: + def test_from_text_converts_question_bank_value_error(self, connector, monkeypatch): + """SourceConnector 是管道层,应把 QuestionBank 校验异常转换为 result dict。""" + + def fail_text_extract(*args, **kwargs): + raise ValueError("不支持的题型:坏类型") + + monkeypatch.setattr(connector._bank, "text_extract", fail_text_extract) + + result = connector.from_text("有效文本", topic="T", q_type="坏类型") + + assert result["saved"] == [] + assert result["validated"] == [] + assert result["rejected"] == [] + assert result["raw_llm_output"] is None + assert result["error"] == "不支持的题型:坏类型" + + def test_from_chunks_converts_question_bank_value_error(self, connector, monkeypatch): + """chunks 入口同样遵守管道层 result-dict 契约。""" + + def fail_text_extract(*args, **kwargs): + raise ValueError("题目重复:stem") + + monkeypatch.setattr(connector._bank, "text_extract", fail_text_extract) + + result = connector.from_chunks([{"text": "有效 chunk"}], topic="T") + + assert result["saved"] == [] + assert result["error"] == "题目重复:stem" + + +# ── 参数传递 ────────────────────────────────────────────────── + +class TestParameterPassing: + def test_count_and_type_forwarded(self, connector, monkeypatch): + """验证 count, q_type, difficulty 正确传递到 text_extract。""" + captured = {} + original_text_extract = connector._bank.text_extract + + def spy(text, topic, count=3, q_type="算法", difficulty="中等"): + captured["count"] = count + captured["q_type"] = q_type + captured["difficulty"] = difficulty + return original_text_extract(text, topic, count, q_type, difficulty) + + monkeypatch.setattr(connector._bank, "text_extract", spy) + connector.connect("text", topic="T", count=5, q_type="单选题", difficulty="困难") + assert captured["count"] == 5 + assert captured["q_type"] == "单选题" + assert captured["difficulty"] == "困难" + + +# ── from_source() ───────────────────────────────────────────── + +class TestFromSource: + def test_from_source_with_registry(self, tmp_path, monkeypatch): + """from_source() 通过 registry 检索并出题。""" + d = tmp_path / "notes" + d.mkdir() + (d / "哈希表.md").write_text("哈希表 O(1) 查找", encoding="utf-8") + + ds = DirConnector(name="test", path=str(d)) + reg = SourceRegistry() + reg.mount(ds) + + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: MOCK_RAW) + conn = SourceConnector(bank, registry=reg) + + result = conn.from_source("test", "哈希表") + assert result["error"] is None + assert len(result["saved"]) == 1 + + def test_from_source_no_registry_returns_error(self, tmp_path, monkeypatch): + """from_source() 无 registry 时返回错误。""" + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: MOCK_RAW) + conn = SourceConnector(bank) # 无 registry + + result = conn.from_source("test", "topic") + assert result["error"] is not None + assert "registry" in result["error"].lower() or "未配置" in result["error"] + + def test_from_source_nonexistent_source_returns_error(self, tmp_path, monkeypatch): + """from_source() 不存在的源返回错误。""" + reg = SourceRegistry() + bank = QuestionBank(bank_dir=tmp_path) + monkeypatch.setattr(bank, "_call_llm", lambda s, u: MOCK_RAW) + conn = SourceConnector(bank, registry=reg) + + result = conn.from_source("nope", "topic") + assert result["error"] is not None + assert "不存在" in result["error"] or "未找到" in result["error"]