diff --git a/.claude/skills/literature-review/SKILL.md b/.claude/skills/literature-review/SKILL.md new file mode 100644 index 00000000..810fdd0f --- /dev/null +++ b/.claude/skills/literature-review/SKILL.md @@ -0,0 +1,95 @@ +--- +name: literature-review +description: This skill should be used when the user asks to "do a literature review", + "survey papers on a topic", "search and summarize research on X", "find papers about + attention mechanisms", "systematic review of the literature", "what papers exist on Y", + or wants a multi-step workflow to search, filter by relevance, score quality, and + summarize academic papers using PaperBot MCP tools. +tools: + - paper_search + - relevance_assess + - paper_judge + - paper_summarize + - export_to_obsidian + - save_to_memory +--- + +# Literature Review Workflow + +Conduct a systematic literature review: search, filter by relevance, judge quality, +summarize top papers, and save findings to memory. + +## Workflow + +### Step 1: Search for papers + +Call `paper_search` with the research question or topic. + +- Parameters: `query` (required), `max_results` (default 10; use 20–50 for broad surveys), + `sources` (optional; omit for all sources, or specify `["arxiv", "semantic_scholar"]`) +- Returns: list of paper dicts with `title`, `abstract`, `authors`, `year`, `venue`, + `arxiv_id`, `doi` + +### Step 2: Filter by relevance + +For each paper, call `relevance_assess` with `title`, `abstract`, and the same `query`. + +- Parameters: `title`, `abstract`, `query`, `keywords` (optional comma-separated terms) +- Returns: dict with `score` (0–100) and `reason` +- Suggested threshold: discard papers with `score` below 40 +- If `degraded=True`, token-overlap scoring is used (less accurate but functional) + +### Step 3: Judge quality of relevant papers + +For papers above the relevance threshold, call `paper_judge`. + +- Parameters: `title`, `abstract`, `full_text` (optional), `rubric` (default `"default"`; + pass the research question for context-aware judging) +- Returns: dimension scores (1–5), `overall_score`, `recommendation` + (`must_read` / `worth_reading` / `skim` / `skip`) +- Prioritize papers with `must_read` and `worth_reading` recommendations + +### Step 4: Summarize top papers + +Call `paper_summarize` for papers recommended as `must_read` or `worth_reading`. + +- Parameters: `title`, `abstract` +- Returns: dict with `summary` key (concise string) +- If `degraded=True`, generate a manual summary from the abstract text + +### Step 5: Export to Obsidian (optional) + +Call `export_to_obsidian` for papers to save as permanent Obsidian notes. + +- Parameters: `title`, `abstract`, `authors` (list), `year`, `venue`, `arxiv_id`, `doi` + (provide whichever identifiers are available) +- Returns: dict with `markdown` key — YAML-frontmattered note ready to write to vault + +### Step 6: Save synthesis to memory + +Call `save_to_memory` with a synthesis of findings across all reviewed papers. + +- Parameters: `content` (synthesis text), `kind` (`"note"` for general observations, + `"hypothesis"` for research directions), `user_id` (default `"default"`), + `scope_type` (`"global"` unless scoping to a specific research track), + `scope_id` (required if `scope_type="track"`), `confidence` (0.0–1.0) +- Returns: dict with `created` or `skipped` status + +## Degraded Mode + +`paper_judge`, `paper_summarize`, and `relevance_assess` require a configured LLM API key. +`paper_search` works without LLM and returns raw search results in all cases. + +When any LLM-backed tool returns `degraded=True`: +- The response also contains an `error` key describing the issue +- Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and restart the MCP server +- In degraded mode, proceed with `paper_search` results only; skip Steps 2–4 + +## Notes + +- For broad surveys (>30 papers), consider running `relevance_assess` in bulk before + `paper_judge` to reduce LLM calls +- Use `rubric="reproducibility"` in `paper_judge` if the review goal is identifying + reproducible papers for implementation +- The `export_to_obsidian` step is optional — skip it if the user has not set up an + Obsidian vault or does not need persistent notes diff --git a/.claude/skills/paper-reproduction/SKILL.md b/.claude/skills/paper-reproduction/SKILL.md new file mode 100644 index 00000000..d40cdbce --- /dev/null +++ b/.claude/skills/paper-reproduction/SKILL.md @@ -0,0 +1,93 @@ +--- +name: paper-reproduction +description: This skill should be used when the user asks to "reproduce a paper", + "implement paper code", "paper2code", "replicate research results", "run experiment + from paper", "implement the algorithm from this paper", or wants to locate, understand, + and plan implementation of a specific academic paper using PaperBot MCP tools. +tools: + - paper_search + - paper_judge + - paper_summarize + - export_to_obsidian + - save_to_memory +--- + +# Paper Reproduction Workflow + +Reproduce or implement a paper: locate it, assess reproducibility, understand its +contributions, save an implementation plan, and export a paper note. + +## Workflow + +### Step 1: Find the paper + +Call `paper_search` with the paper title, topic, or known identifier. + +- Parameters: `query` (required; include ArXiv ID or DOI if known for direct lookup), + `max_results` (default 10; use 3–5 for a known paper to minimize noise) +- Returns: list of paper dicts with `title`, `abstract`, `authors`, `year`, `venue`, + `arxiv_id`, `doi` +- Select the most specific match if multiple results are returned + +### Step 2: Judge reproducibility + +Call `paper_judge` with `rubric="reproducibility"` to assess implementation feasibility. + +- Parameters: `title`, `abstract`, `full_text` (optional; include if available for + richer analysis), `rubric="reproducibility"` +- Returns: dimension scores (1–5) including `rigor`, `clarity`, `novelty`, `reproducibility`, + `overall_score`, and `recommendation` +- Favorable signals: high `rigor` and `clarity` scores +- Unfavorable signals: low `clarity` score may indicate reproduction difficulty; low + `reproducibility` score indicates missing implementation details (pseudocode, datasets) + +### Step 3: Summarize paper contributions + +Call `paper_summarize` to extract key contributions, methods, and findings. + +- Parameters: `title`, `abstract` +- Returns: dict with `summary` key (concise string covering contributions and approach) +- Use the summary to inform the implementation plan in Step 4 + +### Step 4: Save reproduction plan to memory + +Call `save_to_memory` with an outline of the planned implementation steps. + +- Parameters: `content` (implementation plan text), `kind` (`"project"` for structured + plans or `"decision"` for approach decisions), `user_id` (default `"default"`), + `scope_type` (`"global"` or `"track"` if this paper belongs to a research track), + `scope_id` (track ID if `scope_type="track"`), `confidence` (0.0–1.0) +- Include: key algorithms to implement, datasets needed, evaluation metrics, dependencies + +### Step 5: Export paper note + +Call `export_to_obsidian` to create a structured Obsidian note for the paper. + +- Parameters: `title`, `abstract`, `authors` (list), `year`, `venue`, `arxiv_id`, `doi` + (provide all available identifiers) +- Returns: dict with `markdown` key — YAML-frontmattered note ready for Obsidian vault +- The note provides a permanent reference alongside the implementation + +## Implementation Guidance + +After completing the above workflow, proceed with code implementation using available +tools (Bash, Write, etc.). The Paper2Code pipeline in PaperBot +(`src/paperbot/repro/`) provides deeper multi-stage analysis (Planning → Blueprint → +Environment → Generation → Verification) for complex reproductions requiring the full +PaperBot backend. + +For simpler reproductions: +1. Use the summary from Step 3 and the plan from Step 4 as starting context +2. Implement iteratively, checking against paper details in the Obsidian note +3. Store implementation decisions in memory with `kind="decision"` as the work progresses + +## Degraded Mode + +`paper_judge` and `paper_summarize` require a configured LLM API key. +`paper_search` works without LLM. + +When LLM-backed tools return `degraded=True`: +- The response also contains an `error` key describing the issue +- Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and restart the MCP server +- In degraded mode, use `paper_search` to locate the paper and proceed to implementation + using the raw abstract and metadata; skip Steps 2 and 3 diff --git a/.claude/skills/scholar-monitoring/SKILL.md b/.claude/skills/scholar-monitoring/SKILL.md new file mode 100644 index 00000000..b958aeeb --- /dev/null +++ b/.claude/skills/scholar-monitoring/SKILL.md @@ -0,0 +1,82 @@ +--- +name: scholar-monitoring +description: This skill should be used when the user asks to "monitor a scholar", + "check researcher activity", "track publications from author X", "follow author Y", + "scholar update for Z", "what has researcher X published recently", or wants to + retrieve and synthesize a researcher's recent publication activity using PaperBot + MCP tools. +tools: + - check_scholar + - analyze_trends + - save_to_memory +--- + +# Scholar Monitoring Workflow + +Monitor a researcher's recent publication activity: fetch their profile and papers, +optionally analyze output trends, and save a monitoring note. + +## Workflow + +### Step 1: Check scholar activity + +Call `check_scholar` with the researcher's name. + +- Parameters: `scholar_name` (required; use the researcher's full name as commonly + published), `max_papers` (default 10; increase to 20–30 for career-wide coverage) +- Returns: dict with: + - `scholar`: profile dict with `name`, `hIndex`, `citationCount`, `affiliations`, + `paperCount`, `url` + - `recent_papers`: list of paper dicts (title, abstract, year, venue, citation count) + - `candidates`: list of top-3 candidate matches (inspect if the top result is wrong) +- If `degraded=True`, the scholar was not found on Semantic Scholar or the API is + unavailable + +### Step 2: Analyze paper trends (optional) + +If `recent_papers` is non-empty and the user wants thematic analysis, call `analyze_trends`. + +- Parameters: `topic` (use the scholar's name or primary research area as the topic), + `papers` (the `recent_papers` list from Step 1) +- Returns: dict with `trend_analysis` (natural language narrative of the scholar's + research focus and evolution) +- Skip this step if the user only needs raw paper metadata (no LLM API key required + for Step 1 alone) + +### Step 3: Save monitoring note + +Call `save_to_memory` with a summary of the scholar's recent activity. + +- Parameters: `content` (monitoring summary — include scholar name, hIndex, + recent paper titles, and trend analysis if available), `kind="note"`, + `user_id` (default `"default"`), `scope_type="global"`, + `confidence` (0.0–1.0; suggest 0.9 for factual publication data) +- Returns: dict with `created` or `skipped` status + +## Note on Scholar Lookup + +`check_scholar` searches Semantic Scholar by name. Common issues: + +- **Name diacritics:** Names with accents (e.g., "Müller", "Bengio") may need the + ASCII variant ("Muller", "Yoshua Bengio") if exact-match fails +- **New researchers:** Very new researchers may have limited or no Semantic Scholar + records — check `paperCount` in the returned profile +- **Name ambiguity:** The `candidates` field in the response lists the top 3 matches; + inspect these if the top result appears to be the wrong person (wrong affiliation, + wrong research area) +- **Name format:** Use "First Last" format; middle names are generally not needed but + can help disambiguate common names + +## Degraded Mode + +`analyze_trends` (Step 2) requires a configured LLM API key. `check_scholar` (Step 1) +and `save_to_memory` (Step 3) do not require LLM. + +When `analyze_trends` returns `degraded=True`: +- Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and restart the MCP server +- Skip Step 2 and proceed directly to Step 3 with a summary based on raw paper metadata + +When `check_scholar` itself returns `degraded=True`: +- This indicates the scholar was not found or the Semantic Scholar API is unavailable +- Try an alternate name spelling or abbreviation +- Check `candidates` in the response for close matches diff --git a/.claude/skills/trend-analysis/SKILL.md b/.claude/skills/trend-analysis/SKILL.md new file mode 100644 index 00000000..bc0e53c4 --- /dev/null +++ b/.claude/skills/trend-analysis/SKILL.md @@ -0,0 +1,82 @@ +--- +name: trend-analysis +description: This skill should be used when the user asks to "analyze trends in a + research area", "what is trending in X", "research landscape for topic Y", "topic + trend analysis", "emerging themes in machine learning", "what are researchers working + on in Z", or wants to survey a field and identify emerging patterns across multiple + papers using PaperBot MCP tools. +tools: + - paper_search + - analyze_trends + - get_research_context + - save_to_memory +--- + +# Trend Analysis Workflow + +Identify research trends across a topic by collecting papers, analyzing patterns, and +saving a synthesis of emerging themes. + +## Workflow + +### Step 1: Load research context (optional) + +If a research track exists for the topic, call `get_research_context` to retrieve +existing memories and previously found papers. + +- Parameters: `query` (the research topic), `user_id` (default `"default"`), + `track_id` (optional; pass if a specific track ID is known) +- Returns: dict with `papers` (list), `memories` (list), `stage` (workflow stage string) +- Use the existing memories as context when synthesizing results in Step 4 +- Skip this step if no prior research context exists for the topic + +### Step 2: Search for papers + +Call `paper_search` with the topic. Use a broader corpus for trend analysis. + +- Parameters: `query` (required), `max_results` (use 20–50 for trend analysis — a + larger corpus improves trend signal quality), `sources` (optional) +- Returns: list of paper dicts with `title`, `abstract`, `authors`, `year`, `venue` +- If `track_id` context was loaded in Step 1, merge the existing papers with new results + (deduplicate by `arxiv_id` or `doi`) + +### Step 3: Analyze trends + +Call `analyze_trends` with the topic and the list of papers from Step 2. + +- Parameters: `topic` (the research area string), `papers` (list of paper dicts from + `paper_search`; pass the full list for best results) +- Returns: dict with `trend_analysis` (natural language narrative), `topic`, `paper_count` +- Check for `degraded=True` — `analyze_trends` requires a configured LLM API key + +### Step 4: Save synthesis + +Call `save_to_memory` with the trend analysis narrative and any additional observations. + +- Parameters: `content` (the `trend_analysis` text from Step 3, optionally enhanced with + your own observations), `kind` (`"note"` for factual observations, `"hypothesis"` for + directional predictions), `user_id` (default `"default"`), + `scope_type` (`"global"` for broad field trends, `"track"` if scoping to a research area), + `scope_id` (track ID if `scope_type="track"`), `confidence` (0.0–1.0) +- Returns: dict with `created` or `skipped` status + +## Degraded Mode + +`analyze_trends` requires a configured LLM API key. `paper_search` and `get_research_context` +work without LLM. + +When `analyze_trends` returns `degraded=True`: +- The response also contains an `error` key describing the issue +- Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` and restart the MCP server +- In degraded mode, present the raw search results grouped by year or venue as a + manual trend signal; skip Step 3 or surface the paper list to the user directly + +## Notes + +- For fast trend snapshots, use `max_results=20` and skip Step 1 +- For deep research landscape maps, use `max_results=50` and integrate prior context + from `get_research_context` +- When analyzing sub-field trends (e.g., "sparse attention mechanisms"), narrow the + query rather than broadening `max_results` +- Multiple calls with different `topic` variants (e.g., "mixture of experts" vs. + "sparse expert models") can be combined for a richer landscape view diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 00000000..41bb3dca --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,113 @@ +# PaperBot + +## What This Is + +PaperBot is a multi-agent research workflow framework for academic paper discovery, analysis, and reproduction. It provides a FastAPI backend with SSE streaming, a Next.js web dashboard, and a terminal CLI. The platform is evolving toward a Skill-Driven Architecture where PaperBot acts as a capability provider, exposing paper-specific tools via MCP and providing an agent orchestration dashboard for Claude Code and Codex. + +## Core Value + +Paper-specific capability layer: understanding, reproduction, verification, and context — surfaced as standard MCP tools that any agent can consume, with a visual dashboard for agent orchestration. + +## Requirements + +### Validated + + + +- ✓ Paper search and discovery (arxiv, openalex, semantic scholar, reddit, HF daily, paperscool) +- ✓ Paper analysis pipeline: judge, summarize, trend analysis, relevance assessment +- ✓ Scholar tracking and monitoring +- ✓ Paper2Code reproduction pipeline (planning → blueprint → generation → verification) +- ✓ CodeRAG pattern retrieval and CodeMemory cross-file context +- ✓ Research context engine and track routing +- ✓ Memory module (save/retrieve research context) +- ✓ DailyPaper cron workflow (ARQ-backed) +- ✓ FastAPI server with SSE streaming +- ✓ Next.js web dashboard (studio, wiki, papers, research, scholars, workflows) +- ✓ DI container and pipeline framework +- ✓ Event logging and audit trail +- ✓ Authentication (JWT, email/password, API key middleware) +- ✓ Agent infrastructure (codex_dispatcher.py, claude_commander.py in swarm/) + +### Active + + + +- [ ] Codex subagent bridge for Claude Code (custom agent definition) +- [ ] Agent orchestration dashboard (replaces studio page) +- [ ] Agent event logging via MCP (lifecycle, tool calls, file changes, task status) +- [ ] Three-panel IDE layout (tasks | agent activity | files) +- [ ] Live SSE streaming for real-time agent activity +- [ ] Paper2Code overflow delegation workflow (Claude Code → Codex) +- [ ] PostgreSQL migration (replace SQLite) +- [ ] Async data layer (AsyncSession + asyncpg) +- [ ] Systematic data model refactoring +- [ ] PG-native features (tsvector, JSONB) + +### Out of Scope + +- Custom agent orchestration runtime — host agents (Claude Code) own orchestration +- Per-host adapters — one MCP surface serves all +- Business logic duplication — tools must reuse existing services +- Building Codex itself — uses existing Codex CLI + +## Context + +- Architecture pivot from AgentSwarm to Skill-Driven Architecture (2026-03-13) +- Existing `codex_dispatcher.py` and `claude_commander.py` in infrastructure/swarm/ +- Existing `AgentEventEnvelope` with run_id/trace_id/span_id in application/collaboration/ +- Studio page exists with Monaco editor and XTerm terminal +- @xyflow/react already in web dashboard for DAG visualization +- MCP server (v1.0 milestone) is prerequisite — provides tool surface for agent integration +- Dev branch synced to origin/dev at 2e5173d (2026-03-14) +- Current DB: SQLite with 46 models, sync Session, FTS5 virtual tables, optional sqlite-vec + +## Constraints + +- **MCP prerequisite**: v1.0 MCP server must be functional before agent orchestration +- **Reuse**: Event logging must extend existing AgentEventEnvelope, not create parallel system +- **Claude Code bridge**: Codex integration is a Claude Code agent definition, not PaperBot server code +- **Studio integration**: Dashboard integrates with existing Monaco/XTerm, not replaces them +- **Transport**: SSE for live updates (existing infrastructure) + +## Current Milestone: v1.1 Agent Orchestration Dashboard + +**Goal:** Build a Codex subagent bridge for Claude Code and a real-time agent orchestration dashboard in PaperBot's web UI, enabling the Paper2Code overflow delegation workflow. + +**Target features:** +- Codex subagent bridge (`.claude/agents/codex-worker.md`) +- Three-panel agent dashboard (replaces studio page) +- Agent event logging (lifecycle, tools, files, tasks) +- Live SSE streaming for real-time updates +- Paper2Code workflow with Codex overflow delegation + +## Planned Milestone: v2.0 PostgreSQL Migration & Data Layer Refactoring + +**Goal:** Migrate from SQLite to PostgreSQL, refactor all 46 data models systematically, and convert the entire data access layer from synchronous to async (asyncpg + AsyncSession). + +**Target features:** +- Full PostgreSQL migration with Docker-based local development +- Async data layer (AsyncSession + asyncpg) across all stores +- Systematic model refactoring: normalization, constraints, redundancy removal +- PG-native features: tsvector full-text search (replacing FTS5), JSONB columns, proper indexing +- Alembic migration path from SQLite to PostgreSQL +- Data migration tooling for existing SQLite databases + +## Key Decisions + +| Decision | Rationale | Outcome | +|----------|-----------|---------| +| PaperBot = Skill Provider, not runtime | Host agents (Claude Code, Codex) already own orchestration | ✓ Good | +| Codex via custom agent definition | `.claude/agents/codex-worker.md` — simplest, uses existing Claude Code infrastructure | — Pending | +| Replace studio page with agent dashboard | Studio's Monaco/XTerm integrate into agent view | — Pending | +| Extend AgentEventEnvelope | Reuse existing run_id/trace_id/span_id schema | — Pending | +| Overflow delegation model | Claude Code does everything, delegates to Codex when workload is high | — Pending | +| MCP event log as data flow | Agent activity → MCP event log → dashboard reads | — Pending | +| Live SSE streaming | Real-time updates using existing SSE infrastructure | — Pending | +| PG migration over SQLite | SQLite concurrency limits, lack of PG features (tsvector, JSONB), production readiness | — Pending | +| Async data layer (asyncpg) | FastAPI is async; sync DB calls block event loop; do it together with PG migration | — Pending | +| Systematic model refactoring | 46 models accumulated organically; normalize, add constraints, remove redundancy | — Pending | +| Docker PG for local dev | Standard dev setup, matches production topology | — Pending | + +--- +*Last updated: 2026-03-14 after v2.0 milestone added* diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 00000000..5a845078 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,223 @@ +# Requirements: PaperBot + +**Defined:** 2026-03-14 +**Core Value:** Paper-specific capability layer surfaced as standard MCP tools + agent orchestration dashboard + +## v1.0 Requirements + +Requirements for MCP Server milestone. Phases 1-2 shipped; phases 3-6 remain. + +### MCP Tools (shipped) + +- [x] **MCP-S1**: Agent can search papers via `paper_search` MCP tool (Phase 2) +- [x] **MCP-S2**: Agent can judge paper quality via `paper_judge` MCP tool (Phase 2) +- [x] **MCP-S3**: Agent can summarize papers via `paper_summarize` MCP tool (Phase 2) +- [x] **MCP-S4**: Agent can assess paper relevance via `relevance_assess` MCP tool (Phase 2) + +### MCP Tools (remaining) + +- [x] **MCP-01**: Agent can analyze trends across a set of papers via `analyze_trends` MCP tool +- [x] **MCP-02**: Agent can check a scholar's recent publications and activity via `check_scholar` MCP tool +- [x] **MCP-03**: Agent can retrieve research context for a track via `get_research_context` MCP tool +- [x] **MCP-04**: Agent can save research findings to memory via `save_to_memory` MCP tool +- [x] **MCP-05**: Agent can export papers/notes to Obsidian vault format via `export_to_obsidian` MCP tool + +### MCP Resources + +- [x] **MCP-06**: Agent can read track metadata via `paperbot://track/{id}` resource +- [x] **MCP-07**: Agent can read track paper list via `paperbot://track/{id}/papers` resource +- [x] **MCP-08**: Agent can read track memory via `paperbot://track/{id}/memory` resource +- [x] **MCP-09**: Agent can read scholar subscriptions via `paperbot://scholars` resource + +### Transport & Infrastructure + +- [x] **MCP-10**: MCP server runs via stdio transport for local agent integration +- [x] **MCP-11**: MCP server runs via Streamable HTTP transport for remote agent integration +- [x] **MCP-12**: User can start MCP server via `paperbot mcp serve` CLI command + +### Agent Skills + +- [x] **MCP-13**: Agent can discover and load PaperBot workflow skills via `.claude/skills/` SKILL.md files (literature-review, paper-reproduction, trend-analysis, scholar-monitoring) + +## v1.1 Requirements + +Requirements for Agent Orchestration Dashboard milestone. Each maps to roadmap phases. + +### Event System + +- [ ] **EVNT-01**: User can view a real-time scrolling activity feed showing agent events as they happen +- [ ] **EVNT-02**: User can see each agent's lifecycle status (idle, working, completed, errored) at a glance +- [ ] **EVNT-03**: User can view a structured tool call timeline showing tool name, arguments, result summary, and duration +- [x] **EVNT-04**: Agent events are pushed to connected dashboard clients in real-time via SSE (no polling) + +### Dashboard + +- [ ] **DASH-01**: User can view agent orchestration in a three-panel IDE layout (tasks | activity | files) +- [ ] **DASH-02**: User can manage agent tasks via Kanban board showing Claude Code and Codex agent identity +- [ ] **DASH-03**: User can see Codex-specific error states (timeout, sandbox crash) surfaced prominently +- [ ] **DASH-04**: User can resize panels in the three-panel layout to customize workspace + +### File Visualization + +- [ ] **FILE-01**: User can view inline diffs showing what agents changed in each file +- [ ] **FILE-02**: User can see a per-task file list showing created/modified files with status indicators + +### Codex Bridge + +- [ ] **CDX-01**: Claude Code can delegate tasks to Codex via custom agent definition (codex-worker.md) +- [ ] **CDX-02**: Paper2Code pipeline stages can overflow from Claude Code to Codex when workload is high +- [ ] **CDX-03**: User can observe Codex delegation events in the agent activity feed + +### Visualization + +- [ ] **VIZ-01**: User can view an agent task dependency DAG with real-time status color updates +- [ ] **VIZ-02**: User can see cross-agent context sharing (ScoreShareBus data flow) in the dashboard + +## v2.0 Requirements + +Requirements for PostgreSQL Migration & Data Layer Refactoring milestone. + +### PG Infrastructure + +- [ ] **PGINFRA-01**: 开发者可以通过 docker-compose up 一键启动 PostgreSQL + pgvector 本地开发环境 +- [ ] **PGINFRA-02**: Alembic env.py 支持 async 执行路径和 PG/SQLite 双路径检测 +- [ ] **PGINFRA-03**: 用户可以通过 pgloader 脚本将现有 SQLite 数据无损迁移到 PostgreSQL +- [ ] **PGINFRA-04**: 嵌入向量数据可以从 SQLite LargeBinary 迁移到 pgvector 列 + +### Async Data Layer + +- [ ] **ASYNC-01**: 全局共享单个 AsyncEngine,替代 17+ 个独立 SessionProvider 的连接池 +- [ ] **ASYNC-02**: AsyncSessionProvider 提供统一的 async session 工厂,所有 store 通过 DI 注入 +- [ ] **ASYNC-03**: 全部 17 个 Store 类的 ~170 个方法完成 sync→async 转换 +- [ ] **ASYNC-04**: ARQ worker 通过 on_job_start/on_job_complete 管理 async session 生命周期 +- [ ] **ASYNC-05**: MCP 工具层移除 anyio.to_thread.run_sync 包装,直接 await async store 方法 + +### PG-Native Features + +- [ ] **PGNAT-01**: memory_items 和 document_chunks 的全文搜索从 FTS5 迁移到 tsvector + GIN 索引 +- [ ] **PGNAT-02**: 84 个 Text JSON 列迁移到 JSONB 类型,支持原生查询和 GIN 索引 +- [ ] **PGNAT-03**: 向量搜索从 sqlite-vec LargeBinary 迁移到 pgvector Vector(N) + HNSW 索引 + +### Model Refactoring + +- [ ] **MODEL-01**: 所有 relationship 设置 lazy="raise",逐 store 审计并改为显式 selectinload/joinedload +- [ ] **MODEL-02**: 添加 NOT NULL、CHECK、UNIQUE 约束,修复 is_active int→bool 等类型问题 +- [ ] **MODEL-03**: Author 去重、冗余列清理、表合并/拆分等 schema 规范化 + +### Test Infrastructure + +- [ ] **TEST-01**: 建立 testcontainers[postgres] CI fixture,替代 SQLite in-memory 测试数据库 +- [ ] **TEST-02**: 现有测试套件在 PostgreSQL 上全部通过 +- [ ] **TEST-03**: async 测试基础设施:pytest-asyncio async fixture + 每测试事务回滚隔离 +- [ ] **TEST-04**: 关键查询路径的性能基准测试(全文搜索、向量搜索、JSONB 查询) + +### CI Integration + +- [ ] **CI-01**: GitHub Actions 配置 PostgreSQL service container,所有测试在 PG 上运行 +- [ ] **CI-02**: CI 流水线包含 Alembic 迁移验证(fresh DB upgrade head + downgrade 回退测试) +- [ ] **CI-03**: CI 中运行 SQLite→PG 数据迁移冒烟测试,验证 pgloader 脚本正确性 + +### Monitoring + +- [ ] **MON-01**: AsyncEngine 连接池指标暴露(pool_size、checkedout、overflow)可通过 API 查询 +- [ ] **MON-02**: 慢查询日志记录(超过阈值的 SQL 自动 warning 级别记录) +- [ ] **MON-03**: 数据库健康检查端点(/api/health/db),验证连接可用性和迁移版本 + +## Future Requirements + +Deferred to future milestone. Tracked but not in current roadmap. + +(None) + +## Out of Scope + +Explicitly excluded. Documented to prevent scope creep. + +| Feature | Reason | +|---------|--------| +| Custom agent orchestration runtime | Host agents (Claude Code) own orchestration; PaperBot is a skill provider | +| Per-host adapters | One MCP surface serves all agents; no Claude Code vs Codex vs Cursor adapters | +| Visual workflow builder | Massive scope, low value for code-defined pipelines (Paper2Code stages are in code) | +| Agent chat interface | Duplicates Claude Code/Codex conversation UX; dashboard shows output, not input | +| Real-time code editing in dashboard | IDE's job; dashboard shows diffs read-only and deep-links to VS Code | +| Codex CLI wrapper | Agent definition is a file, not server-side Codex management | +| Business logic duplication | Dashboard calls existing API endpoints; no reimplementation of analysis/tracking | + +## Traceability + +Which phases cover which requirements. Updated during roadmap creation. + +| Requirement | Phase | Status | +|-------------|-------|--------| +| MCP-S1 | Phase 2 | Complete | +| MCP-S2 | Phase 2 | Complete | +| MCP-S3 | Phase 2 | Complete | +| MCP-S4 | Phase 2 | Complete | +| MCP-01 | Phase 3 | Complete | +| MCP-02 | Phase 3 | Complete | +| MCP-03 | Phase 3 | Complete | +| MCP-04 | Phase 3 | Complete | +| MCP-05 | Phase 3 | Complete | +| MCP-06 | Phase 4 | Complete | +| MCP-07 | Phase 4 | Complete | +| MCP-08 | Phase 4 | Complete | +| MCP-09 | Phase 4 | Complete | +| MCP-10 | Phase 5 | Complete | +| MCP-11 | Phase 5 | Complete | +| MCP-12 | Phase 5 | Complete | +| MCP-13 | Phase 6 | Complete | +| EVNT-01 | Phase 8 | Pending | +| EVNT-02 | Phase 8 | Pending | +| EVNT-03 | Phase 8 | Pending | +| EVNT-04 | Phase 7 | Complete | +| DASH-01 | Phase 9 | Pending | +| DASH-02 | Phase 10 | Pending | +| DASH-03 | Phase 10 | Pending | +| DASH-04 | Phase 9 | Pending | +| FILE-01 | Phase 9 | Pending | +| FILE-02 | Phase 9 | Pending | +| CDX-01 | Phase 10 | Pending | +| CDX-02 | Phase 10 | Pending | +| CDX-03 | Phase 10 | Pending | +| VIZ-01 | Phase 11 | Pending | +| VIZ-02 | Phase 11 | Pending | +| PGINFRA-01 | Phase 12 | Pending | +| PGINFRA-02 | Phase 12 | Pending | +| PGINFRA-03 | Phase 17 | Pending | +| PGINFRA-04 | Phase 17 | Pending | +| ASYNC-01 | Phase 14 | Pending | +| ASYNC-02 | Phase 14 | Pending | +| ASYNC-03 | Phase 14 | Pending | +| ASYNC-04 | Phase 14 | Pending | +| ASYNC-05 | Phase 14 | Pending | +| PGNAT-01 | Phase 15 | Pending | +| PGNAT-02 | Phase 15 | Pending | +| PGNAT-03 | Phase 15 | Pending | +| MODEL-01 | Phase 16 | Pending | +| MODEL-02 | Phase 16 | Pending | +| MODEL-03 | Phase 16 | Pending | +| TEST-01 | Phase 13 | Pending | +| TEST-02 | Phase 13 | Pending | +| TEST-03 | Phase 13 | Pending | +| TEST-04 | Phase 13 | Pending | +| CI-01 | Phase 17 | Pending | +| CI-02 | Phase 17 | Pending | +| CI-03 | Phase 17 | Pending | +| MON-01 | Phase 17 | Pending | +| MON-02 | Phase 17 | Pending | +| MON-03 | Phase 17 | Pending | + +**Coverage:** +- v1.0 requirements: 17 total (4 shipped, 13 remaining) +- Mapped to phases: 17 +- Unmapped: 0 +- v1.1 requirements: 15 total +- Mapped to phases: 15 +- Unmapped: 0 +- v2.0 requirements: 25 total (counted: PGINFRA x4, ASYNC x5, PGNAT x3, MODEL x3, TEST x4, CI x3, MON x3) +- Mapped to phases: 25 +- Unmapped: 0 + +--- +*Requirements defined: 2026-03-14* +*Last updated: 2026-03-14 after v2.0 roadmap created (phases 12-17)* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 00000000..10fa09d9 --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,314 @@ +# Roadmap: PaperBot + +## Milestones + +- ✅ **v1.0 MCP Server** - Phases 1-6 (complete) +- 📋 **v1.1 Agent Orchestration Dashboard** - Phases 7-11 (planned) +- 📋 **v2.0 PostgreSQL Migration & Data Layer Refactoring** - Phases 12-17 (planned) + +## Phases + +
+v1.0 MCP Server (Phases 1-6) - Complete + +**Milestone Goal:** Complete the MCP server with all 9 tools, 4 resources, transport configuration, and Agent Skills — making PaperBot's full capability surface available to any MCP-compatible agent. + +- [x] **Phase 1: MCP Server Setup** - FastMCP instance, package structure (shipped) +- [x] **Phase 2: Core Paper Tools** - paper_search, paper_judge, paper_summarize, relevance_assess + audit helper (shipped) +- [x] **Phase 3: Remaining MCP Tools** - analyze_trends, check_scholar, get_research_context, save_to_memory, export_to_obsidian (completed 2026-03-14) +- [x] **Phase 4: MCP Resources** - 4 resource URIs for track/scholar data access (completed 2026-03-14) +- [x] **Phase 5: Transport & Entry Point** - stdio + Streamable HTTP transports, CLI command (completed 2026-03-14) +- [x] **Phase 6: Agent Skills** - SKILL.md files for core workflows (completed 2026-03-14) + +### Phase 3: Remaining MCP Tools +**Goal**: All 9 MCP tools are registered and callable, completing the tool surface +**Depends on**: Phase 2 (audit helper and registration pattern) +**Requirements**: MCP-01, MCP-02, MCP-03, MCP-04, MCP-05 +**Success Criteria** (what must be TRUE): + 1. Agent can call `analyze_trends` and receive trend analysis for a set of papers + 2. Agent can call `check_scholar` and receive a scholar's recent publications + 3. Agent can call `get_research_context` and receive context for a research track + 4. Agent can call `save_to_memory` and persist research findings retrievable later + 5. Agent can call `export_to_obsidian` and receive Obsidian-formatted markdown + 6. All 9 tools appear in MCP tools/list + 7. All tools log calls via audit helper +**Plans**: 3 plans + +Plans: +- [ ] 03-01-PLAN.md — analyze_trends + check_scholar tools with TDD +- [ ] 03-02-PLAN.md — get_research_context + save_to_memory + export_to_obsidian tools with TDD +- [ ] 03-03-PLAN.md — Server registration + integration tests for all 9 tools + +### Phase 4: MCP Resources +**Goal**: Agents can read PaperBot data via MCP resource URIs without tool calls +**Depends on**: Phase 2 (server instance) +**Requirements**: MCP-06, MCP-07, MCP-08, MCP-09 +**Success Criteria** (what must be TRUE): + 1. Agent can read `paperbot://track/{id}` and receive track metadata + 2. Agent can read `paperbot://track/{id}/papers` and receive paper list for a track + 3. Agent can read `paperbot://track/{id}/memory` and receive saved research memory + 4. Agent can read `paperbot://scholars` and receive scholar subscription list + 5. All 4 resources appear in MCP resources/list +**Plans**: 2 plans + +Plans: +- [ ] 04-01-PLAN.md — TDD resource implementations (track_metadata, track_papers, track_memory, scholars) with unit tests +- [ ] 04-02-PLAN.md — Server registration + integration tests for all 4 resources + +### Phase 5: Transport & Entry Point +**Goal**: MCP server is runnable via stdio (local) and Streamable HTTP (remote) with a CLI command +**Depends on**: Phase 3, Phase 4 (all tools and resources registered) +**Requirements**: MCP-10, MCP-11, MCP-12 +**Success Criteria** (what must be TRUE): + 1. `paperbot mcp serve --stdio` starts MCP server on stdio transport + 2. `paperbot mcp serve --http` starts MCP server on Streamable HTTP transport + 3. Claude Code can connect to PaperBot MCP server via stdio in `claude_desktop_config.json` + 4. Remote agent can connect via HTTP and call tools +**Plans**: 1 plan + +Plans: +- [ ] 05-01-PLAN.md — Transport dispatch (serve.py), CLI mcp serve subcommand, packaging (pyproject.toml scripts + mcp dep), unit tests + +### Phase 6: Agent Skills +**Goal**: Core PaperBot workflows are available as SKILL.md files for agent discovery +**Depends on**: Phase 3 (tools must exist for skills to reference) +**Requirements**: MCP-13 +**Success Criteria** (what must be TRUE): + 1. `.claude/skills/` directory contains SKILL.md files for literature-review, paper-reproduction, trend-analysis, scholar-monitoring + 2. Each SKILL.md has valid YAML frontmatter (name, description, tools) and markdown instructions + 3. Skills reference MCP tools by name and provide multi-step workflow guidance +**Plans**: 1 plan + +Plans: +- [ ] 06-01-PLAN.md — Structural tests + four SKILL.md agent skill files (literature-review, paper-reproduction, trend-analysis, scholar-monitoring) + +
+ +### v1.1 Agent Orchestration Dashboard + +**Milestone Goal:** Build a real-time agent orchestration dashboard and Codex subagent bridge, enabling users to observe and manage Claude Code and Codex agent activity from PaperBot's web UI. + +**Phase Numbering:** +- Integer phases (7, 8, 9...): Planned milestone work +- Decimal phases (7.1, 7.2): Urgent insertions (marked with INSERTED) + +- [x] **Phase 7: EventBus + SSE Foundation** - In-process event bus with SSE subscription endpoint for real-time push (completed 2026-03-14) +- [ ] **Phase 8: Agent Event Vocabulary** - Extend AgentEventEnvelope types and build activity feed, lifecycle indicators, and tool call timeline +- [ ] **Phase 9: Three-Panel Dashboard** - Frontend store, SSE hook, and three-panel IDE layout with file visualization +- [ ] **Phase 10: Agent Board + Codex Bridge** - Kanban board generalization, Codex worker agent definition, and overflow delegation +- [ ] **Phase 11: DAG Visualization** - Task dependency DAG and cross-agent context sharing visualization + +### v2.0 PostgreSQL Migration & Data Layer Refactoring + +**Milestone Goal:** Migrate from SQLite to PostgreSQL, convert all 17 stores to async SQLAlchemy (asyncpg + AsyncSession), add PG-native features (tsvector, JSONB, pgvector), and systematically refactor all 46 data models. + +**Phase Numbering:** +- Integer phases (12, 13, 14...): Planned milestone work +- Decimal phases (12.1, 12.2): Urgent insertions (marked with INSERTED) + +- [ ] **Phase 12: PG Infrastructure & Schema** - Docker Compose PostgreSQL, Alembic dual-path env.py, is_sqlite guards, tsvector/JSONB/pgvector Alembic migrations +- [ ] **Phase 13: Test Infrastructure** - testcontainers PostgreSQL CI fixture, async pytest infrastructure, full test suite passing on PG, performance benchmarks +- [ ] **Phase 14: Async Data Layer** - Single shared AsyncEngine, AsyncSessionProvider, all 17 stores converted to async, lazy="raise" + selectinload audit, MCP anyio wrappers removed, async ARQ worker +- [ ] **Phase 15: PG-Native Features** - Hybrid tsvector+pgvector search with RRF, JSONB GIN indexes, HNSW vector index, JSON helper method removal +- [ ] **Phase 16: Model Refactoring** - relationship lazy="raise" enforcement, NOT NULL/CHECK/UNIQUE constraints, is_active Boolean migration, Author normalization +- [ ] **Phase 17: Data Migration & CI** - pgloader SQLite->PG tooling, sqlite-vec->pgvector embedding re-encoding, CI PostgreSQL service container, Alembic migration validation, slow query logging, health endpoint, pool metrics API + +## Phase Details + +### Phase 7: EventBus + SSE Foundation +**Goal**: Agent events are pushed to connected clients in real-time without polling +**Depends on**: Phase 6 (v1.0 MCP server provides tool surface) +**Requirements**: EVNT-04 +**Success Criteria** (what must be TRUE): + 1. A dashboard client connected via SSE receives agent events within 1 second of emission + 2. Multiple simultaneous SSE clients each receive all events independently + 3. Existing event_log.append() calls automatically push to SSE subscribers with zero changes to calling code + 4. SSE connections clean up gracefully on client disconnect (no leaked queues or background tasks) +**Plans**: 2 plans + +Plans: +- [ ] 07-01-PLAN.md — EventBusEventLog TDD (ring buffer + fan-out + backpressure) +- [ ] 07-02-PLAN.md — SSE endpoint + main.py wiring + integration tests + +### Phase 8: Agent Event Vocabulary +**Goal**: Users can see meaningful, structured agent activity as it happens +**Depends on**: Phase 7 (events must be pushable before they can be rendered) +**Requirements**: EVNT-01, EVNT-02, EVNT-03 +**Success Criteria** (what must be TRUE): + 1. User sees a scrolling activity feed that updates in real-time as agents emit events + 2. User can see at a glance whether each agent is idle, working, completed, or errored + 3. User can view a structured tool call timeline showing tool name, arguments, result summary, and duration for each call + 4. New event types extend AgentEventEnvelope (no parallel event schema created) +**Plans**: TBD + +Plans: +- [ ] 08-01: TBD +- [ ] 08-02: TBD + +### Phase 9: Three-Panel Dashboard +**Goal**: Users can observe agent work in a three-panel IDE layout with file-level detail +**Depends on**: Phase 8 (activity feed and lifecycle events must exist before dashboard renders them) +**Requirements**: DASH-01, DASH-04, FILE-01, FILE-02 +**Success Criteria** (what must be TRUE): + 1. User sees a three-panel layout (tasks | agent activity | files) when opening the agent dashboard + 2. User can drag panel dividers to resize each panel and the layout persists across page navigation + 3. User can view inline diffs showing exactly what an agent changed in each file + 4. User can see a per-task file list with created/modified indicators for every file an agent touched + 5. Dashboard state is managed by a Zustand store fed by the SSE event stream (no polling) +**Plans**: TBD + +Plans: +- [ ] 09-01: TBD +- [ ] 09-02: TBD +- [ ] 09-03: TBD + +### Phase 10: Agent Board + Codex Bridge +**Goal**: Users can manage agent tasks on a Kanban board and Claude Code can delegate work to Codex +**Depends on**: Phase 9 (dashboard layout must exist for board embedding; Phase 7 SSE for delegation events) +**Requirements**: DASH-02, DASH-03, CDX-01, CDX-02, CDX-03 +**Success Criteria** (what must be TRUE): + 1. User can view and manage agent tasks on a Kanban board that shows which tasks belong to Claude Code vs Codex + 2. User sees Codex-specific error states (timeout, sandbox crash) surfaced prominently on failed tasks + 3. Claude Code can delegate tasks to Codex via the codex-worker.md custom agent definition + 4. Paper2Code pipeline stages can overflow from Claude Code to Codex when workload exceeds capacity + 5. User can observe Codex delegation events (dispatched, accepted, completed, failed) in the activity feed +**Plans**: TBD + +Plans: +- [ ] 10-01: TBD +- [ ] 10-02: TBD +- [ ] 10-03: TBD + +### Phase 11: DAG Visualization +**Goal**: Users can see task dependencies and cross-agent data flow visually +**Depends on**: Phase 10 (tasks and agents must exist before visualizing their relationships) +**Requirements**: VIZ-01, VIZ-02 +**Success Criteria** (what must be TRUE): + 1. User can view an interactive task dependency DAG where node colors update in real-time to reflect task status + 2. User can see ScoreShareBus data flow edges in the DAG showing which agents shared evaluation context with which other agents + 3. DAG renders using existing @xyflow/react (no new visualization dependencies) +**Plans**: TBD + +Plans: +- [ ] 11-01: TBD + +### Phase 12: PG Infrastructure & Schema +**Goal**: PaperBot runs against PostgreSQL with a complete, PG-compatible schema — tsvector, JSONB, and pgvector columns in place — without crashing on any SQLite-only code path +**Depends on**: Phase 11 (v1.1 milestone completes before v2.0 begins) +**Requirements**: PGINFRA-01, PGINFRA-02 +**Success Criteria** (what must be TRUE): + 1. Developer can run `docker-compose up` and get a working PostgreSQL + pgvector environment with no manual extension setup steps + 2. `alembic upgrade head` completes without error against a fresh PostgreSQL database and the dual-path env.py correctly routes async PG vs sync SQLite URLs + 3. All FTS5-dependent and sqlite_master-querying code paths are guarded with `is_sqlite` checks so the application starts and accepts requests on a PostgreSQL URL without crashing + 4. Alembic migrations add tsvector columns + GIN indexes on memory_items and document_chunks, JSONB type casts (with explicit USING col::jsonb) on all 84 JSON columns, and a pgvector Vector(1536) column on memory_items + 5. The existing sync store suite passes against PostgreSQL (minus FTS5 and sqlite-vec paths, which are guarded) +**Plans**: TBD + +Plans: +- [ ] 12-01: TBD +- [ ] 12-02: TBD + +### Phase 13: Test Infrastructure +**Goal**: All store tests run against real PostgreSQL, async pytest infrastructure is established, and CI greenlight is meaningful for PG-specific behavior +**Depends on**: Phase 12 (PostgreSQL schema must exist before PG-targeted tests can pass) +**Requirements**: TEST-01, TEST-02, TEST-03, TEST-04 +**Success Criteria** (what must be TRUE): + 1. Running `pytest -m postgres` spins up a real PostgreSQL container via testcontainers, runs all store integration tests against it, and tears it down with no manual database setup required + 2. Every existing store integration test passes against PostgreSQL — JSONB operators, tsvector queries, and pgvector distance operators all exercise real PG behavior + 3. Async test fixtures use `pytest-asyncio` with per-test transaction rollback so each test starts from a clean database state with no residual data from prior tests + 4. A performance benchmark test exists that measures full-text search, vector search, and JSONB query latency against a seeded dataset and records a repeatable baseline +**Plans**: TBD + +Plans: +- [ ] 13-01: TBD +- [ ] 13-02: TBD + +### Phase 14: Async Data Layer +**Goal**: All 17 stores use async SQLAlchemy with a single shared AsyncEngine; no sync DB calls block the event loop; MCP tools await store methods directly; ARQ jobs each own their session +**Depends on**: Phase 13 (PG test fixture must be in place before any async store ships; Phase 12 for PG schema) +**Requirements**: ASYNC-01, ASYNC-02, ASYNC-03, ASYNC-04, ASYNC-05 +**Success Criteria** (what must be TRUE): + 1. FastAPI starts with a single shared AsyncEngine registered in the DI container — no per-store engine or independent connection pool exists anywhere in the codebase + 2. All 17 stores expose only `async def` methods using `async with` session context; no synchronous `Session` import remains in any store file + 3. Accessing any ORM relationship attribute without an explicit `selectinload` or `joinedload` raises `MissingGreenlet` immediately (lazy="raise" enforced on all relationships before any store conversion begins) + 4. All MCP tools call `await store.method()` directly — zero `anyio.to_thread.run_sync` wrappers remain in any file under `src/paperbot/mcp/` + 5. ARQ jobs each get an `AsyncSession` created in `on_job_start` and closed in `on_job_complete`; no session object is shared across concurrent jobs +**Plans**: TBD + +Plans: +- [ ] 14-01: TBD +- [ ] 14-02: TBD +- [ ] 14-03: TBD +- [ ] 14-04: TBD + +### Phase 15: PG-Native Features +**Goal**: Search uses PostgreSQL's tsvector and pgvector capabilities with production-quality indexing; JSONB columns are queryable natively and all JSON helper methods are eliminated +**Depends on**: Phase 14 (async memory_store must be complete before hybrid search queries are added; tsvector and pgvector columns established in Phase 12) +**Requirements**: PGNAT-01, PGNAT-02, PGNAT-03 +**Success Criteria** (what must be TRUE): + 1. Full-text search on memory_items and document_chunks uses tsvector + GIN index; all FTS5 virtual-table DDL and sqlite_master query code has been deleted (not just guarded) + 2. All 84 formerly-Text JSON columns are stored as JSONB and all `get_*/set_*` JSON helper methods have been removed — application code accesses JSONB attributes directly + 3. Vector search on memory_items uses pgvector `<=>` operator with an HNSW index; all sqlite-vec LargeBinary float-encoding code has been deleted + 4. Hybrid tsvector + pgvector search with Reciprocal Rank Fusion (RRF) is available in memory_store as a single server-side SQL CTE, replacing the Python-side `_hybrid_merge()` function +**Plans**: TBD + +Plans: +- [ ] 15-01: TBD +- [ ] 15-02: TBD + +### Phase 16: Model Refactoring +**Goal**: All 46 data models have explicit relationship loading, correct column types, and normalized schema — no schema debt from the organic SQLite-era growth remains +**Depends on**: Phase 15 (PG-native features must be stable; data must already be on PostgreSQL before NOT NULL constraints are enforced against migrated rows) +**Requirements**: MODEL-01, MODEL-02, MODEL-03 +**Success Criteria** (what must be TRUE): + 1. Every ORM relationship has `lazy="raise"` and every code path that accesses a relationship uses explicit `selectinload` or `joinedload` — confirmed by a test that loads every model relationship and asserts no `MissingGreenlet` is raised + 2. The `is_active` column is stored as Boolean (not Integer) with all 5 call sites in research_store updated; all nullable columns that are semantically required have NOT NULL constraints; status, confidence, and pii_risk columns have CHECK constraints + 3. Author rows are stored in a normalized Authors table with FK references replacing inline denormalized author strings; all redundant columns identified in the refactoring audit are removed or merged into canonical columns +**Plans**: TBD + +Plans: +- [ ] 16-01: TBD +- [ ] 16-02: TBD + +### Phase 17: Data Migration & CI +**Goal**: Existing SQLite users can migrate their data to PostgreSQL without data loss; CI validates all migrations and runs every test against PostgreSQL; operational observability is in place +**Depends on**: Phase 16 (final schema must be stable before migration tooling targets it; Phase 13 for CI test infrastructure) +**Requirements**: PGINFRA-03, PGINFRA-04, CI-01, CI-02, CI-03, MON-01, MON-02, MON-03 +**Success Criteria** (what must be TRUE): + 1. A developer with an existing SQLite database can run a documented pgloader command and land all relational data in PostgreSQL with FK integrity verified before (PRAGMA integrity_check) and after (FK violation report) + 2. Embedding vectors stored as sqlite-vec LargeBinary float bytes are re-encoded as pgvector float arrays during migration — vector search returns the same top-K results before and after on a sample query set + 3. GitHub Actions runs all tests against a PostgreSQL service container and `pytest -m postgres` is a required CI gate that blocks merge on failure + 4. CI validates `alembic upgrade head` on a fresh database and `alembic downgrade -1` (one revision rollback) without error on every push + 5. `GET /api/health/db` returns current connection status and Alembic migration revision; any SQL query exceeding the configured slow-query threshold emits a WARNING-level log entry with query text and duration + 6. AsyncEngine pool metrics (pool_size, checkedout, overflow) are queryable via an API endpoint +**Plans**: TBD + +Plans: +- [ ] 17-01: TBD +- [ ] 17-02: TBD +- [ ] 17-03: TBD + +## Progress + +**Execution Order:** +Phases execute in numeric order: 3 -> 4 -> 5 -> 6 (v1.0) -> 7 -> 8 -> ... -> 11 (v1.1) -> 12 -> 13 -> ... -> 17 (v2.0) + +| Phase | Milestone | Plans Complete | Status | Completed | +|-------|-----------|----------------|--------|-----------| +| 1. MCP Server Setup | v1.0 | — | Complete | 2026-03-13 | +| 2. Core Paper Tools | v1.0 | 3/3 | Complete | 2026-03-14 | +| 3. Remaining MCP Tools | v1.0 | 3/3 | Complete | 2026-03-14 | +| 4. MCP Resources | v1.0 | 2/2 | Complete | 2026-03-14 | +| 5. Transport & Entry Point | v1.0 | 1/1 | Complete | 2026-03-14 | +| 6. Agent Skills | v1.0 | 1/1 | Complete | 2026-03-14 | +| 7. EventBus + SSE Foundation | v1.1 | 2/2 | Complete | 2026-03-14 | +| 8. Agent Event Vocabulary | v1.1 | 0/? | Not started | - | +| 9. Three-Panel Dashboard | v1.1 | 0/? | Not started | - | +| 10. Agent Board + Codex Bridge | v1.1 | 0/? | Not started | - | +| 11. DAG Visualization | v1.1 | 0/? | Not started | - | +| 12. PG Infrastructure & Schema | v2.0 | 0/? | Not started | - | +| 13. Test Infrastructure | v2.0 | 0/? | Not started | - | +| 14. Async Data Layer | v2.0 | 0/? | Not started | - | +| 15. PG-Native Features | v2.0 | 0/? | Not started | - | +| 16. Model Refactoring | v2.0 | 0/? | Not started | - | +| 17. Data Migration & CI | v2.0 | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 00000000..b4fb8ed9 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,109 @@ +--- +gsd_state_version: 1.0 +milestone: v1.0 +milestone_name: MCP Server +status: planning +stopped_at: Completed 07-02-PLAN.md +last_updated: "2026-03-14T06:59:08.790Z" +last_activity: 2026-03-14 -- v2.0 roadmap created (phases 12-17) +progress: + total_phases: 15 + completed_phases: 5 + total_plans: 9 + completed_plans: 9 + percent: 26 +--- + +# Project State + +## Project Reference + +See: .planning/PROJECT.md (updated 2026-03-14) + +**Core value:** Paper-specific capability layer surfaced as standard MCP tools + agent orchestration dashboard +**Current focus:** v1.1 Agent Orchestration Dashboard -- Phase 7 (EventBus + SSE Foundation) + +## Current Position + +Phase: 7 of 17 (EventBus + SSE Foundation) +Plan: 0 of ? in current phase +Status: Ready to plan +Last activity: 2026-03-14 -- v2.0 roadmap created (phases 12-17) + +Progress: [████░░░░░░░░░░░░░] 26% + +## Milestones + +| Milestone | Phases | Status | +|-----------|--------|--------| +| v1.0 MCP Server | 1-6 | In progress (phases 3, 6 remaining) | +| v1.1 Agent Orchestration Dashboard | 7-11 | Planned | +| v2.0 PostgreSQL Migration | 12-17 | Roadmap created 2026-03-14 | + +## Performance Metrics + +**Velocity:** +- Total plans completed: 6 +- Average duration: 6 min +- Total execution time: 0.6 hours + +**By Phase:** + +| Phase | Plans | Total | Avg/Plan | +|-------|-------|-------|----------| +| 02 | 2/3 | 12min | 6min | +| 03-remaining-mcp-tools P01 | 1 | 2min | 2min | +| 03-remaining-mcp-tools P02 | 1 | 2min | 2min | +| 03-remaining-mcp-tools P03 | 1 | 5min | 5min | +| 04-mcp-resources P01 | 1 | 3min | 3min | +| 04-mcp-resources P02 | 1 | 2min | 2min | +| 05-transport-entry-point P01 | 1 | 3min | 3min | + +**Recent Trend:** +- Last 3 plans: 3min, 2min, 3min +- Trend: Stable +| Phase 06-agent-skills P01 | 3 | 2 tasks | 5 files | +| Phase 07-eventbus-sse-foundation P01 | 3 | 2 tasks | 3 files | +| Phase 07 P02 | 4 | 2 tasks | 3 files | + +## Accumulated Context + +### Decisions + +Decisions logged in PROJECT.md Key Decisions table. +Recent decisions affecting current work: + +- [v2.0 roadmap] Phase 12 (infra) before Phase 13 (tests) -- tests need real PG schema to verify against +- [v2.0 roadmap] Phase 13 (tests) before Phase 14 (async stores) -- CI greenlight meaningless without PG fixture +- [v2.0 roadmap] lazy="raise" applied at Phase 14 start (before first store conversion), not as separate phase +- [v2.0 roadmap] anyio.to_thread.run_sync removed per-store during Phase 14, not as final cleanup sweep +- [v2.0 roadmap] PGINFRA-03/04 (data migration tooling) deferred to Phase 17 -- schema must be final first +- [v1.1 init] EventBus as CompositeEventLog backend -- extends existing event system, not parallel +- [v1.1 init] Codex bridge is a .claude/agents/ file, not PaperBot server code +- [Phase 04-mcp-resources]: Track resources use anyio.to_thread.run_sync() because stores are sync (v2.0 removes this) +- [Phase 05-transport-entry-point]: default HTTP port 8001 avoids FastAPI conflict; serve.py redirects logging to stderr for stdio purity +- [Phase 06-agent-skills]: Skill tool names copied verbatim from @mcp.tool() source to prevent name mismatch bugs +- [Phase 06-agent-skills]: Degraded Mode section included in all four skills — LLM tools return degraded=True when API key missing +- [Phase 07-01]: EventBusEventLog uses drop-oldest backpressure (get_nowait+put_nowait) so append() never blocks the producer +- [Phase 07-01]: AgentEventEnvelope serialized once via .to_dict() in append(); fan-out distributes the dict (no re-serialization) +- [Phase 07-01]: stream() returns iter(()) — EventBusEventLog is a live delivery channel, not a historical store +- [Phase 07-02]: Late import of EventBusEventLog inside _get_bus() prevents circular import (events.py loaded at app creation before bus is wired) +- [Phase 07-02]: No wrap_generator() in events.py: events carry own AgentEventEnvelope fields; second envelope layer would confuse consumers +- [Phase 07-02]: asyncio.wait_for(q.get(), timeout=15.0) drives both delivery and idle heartbeat at single await point + +### Pending Todos + +None. + +### Blockers/Concerns + +- v1.0 MCP server (phases 1-6) must be functional before v1.1 work begins +- v1.1 must complete before v2.0 work begins (Phase 12 depends on Phase 11) +- [v2.0] memory_store (Phase 14 Group 2) is highest-complexity store: FTS5 + sqlite-vec + hybrid search + MCP connections; warrants dedicated mini-plan before that group ships +- [v2.0] Phase 17 data migration FK violation profile in existing SQLite DBs is unknown -- run PRAGMA integrity_check on representative DB before Phase 17 planning is finalized + +## Session Continuity + +Last session: 2026-03-14T06:48:05.569Z +Stopped at: Completed 07-02-PLAN.md +Resume file: None diff --git a/.planning/phases/02-core-paper-tools/02-01-PLAN.md b/.planning/phases/02-core-paper-tools/02-01-PLAN.md new file mode 100644 index 00000000..84041df2 --- /dev/null +++ b/.planning/phases/02-core-paper-tools/02-01-PLAN.md @@ -0,0 +1,357 @@ +--- +phase: 02-core-paper-tools +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/paperbot/mcp/tools/__init__.py + - src/paperbot/mcp/tools/_audit.py + - src/paperbot/mcp/tools/paper_search.py + - src/paperbot/mcp/server.py + - tests/unit/test_mcp_audit.py + - tests/unit/test_mcp_paper_search.py +autonomous: true +requirements: [R6.1, R6.2, R2.1] + +must_haves: + truths: + - "Every tool call logs an AgentEventEnvelope to EventLogPort with workflow='mcp', stage='tool_call'" + - "If _run_id is provided, the event uses that run_id; otherwise a new run_id is generated" + - "If EventLogPort is not registered in Container, auditing degrades silently (no tool failure)" + - "paper_search returns a list of paper dicts for a query" + - "paper_search returns an empty list (not an error) when no results found" + artifacts: + - path: "src/paperbot/mcp/tools/__init__.py" + provides: "Package marker for tools submodule" + - path: "src/paperbot/mcp/tools/_audit.py" + provides: "Shared log_tool_call() helper for all tools" + exports: ["log_tool_call"] + - path: "src/paperbot/mcp/tools/paper_search.py" + provides: "paper_search MCP tool wrapping PaperSearchService" + exports: ["register"] + - path: "src/paperbot/mcp/server.py" + provides: "FastMCP instance with paper_search registered" + contains: "paper_search" + - path: "tests/unit/test_mcp_audit.py" + provides: "Unit tests for audit helper" + - path: "tests/unit/test_mcp_paper_search.py" + provides: "Unit tests for paper_search tool" + key_links: + - from: "src/paperbot/mcp/tools/_audit.py" + to: "src/paperbot/application/collaboration/message_schema.py" + via: "make_event(), new_run_id(), new_trace_id()" + pattern: "make_event\\(" + - from: "src/paperbot/mcp/tools/_audit.py" + to: "src/paperbot/core/di/container.py" + via: "Container.instance().resolve(EventLogPort)" + pattern: "Container\\.instance\\(\\)\\.resolve" + - from: "src/paperbot/mcp/tools/paper_search.py" + to: "src/paperbot/application/services/paper_search_service.py" + via: "PaperSearchService.search()" + pattern: "await service\\.search\\(" + - from: "src/paperbot/mcp/tools/paper_search.py" + to: "src/paperbot/mcp/tools/_audit.py" + via: "log_tool_call()" + pattern: "log_tool_call\\(" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/paper_search.py" + via: "register(mcp) call" + pattern: "paper_search\\.register\\(mcp\\)" +--- + + +Create the shared audit/event-log helper and the paper_search MCP tool, with full unit tests for both. + +Purpose: The audit helper (R6.1, R6.2) is foundational infrastructure that every tool depends on. paper_search (R2.1) is the simplest tool (async, no LLM wrapping needed) and validates the tool registration pattern that the remaining three tools will follow. + +Output: Working `_audit.py` with `log_tool_call()`, working `paper_search` tool registered on the MCP server, unit tests for both. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-core-paper-tools/02-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-VALIDATION.md + +@src/paperbot/mcp/server.py +@src/paperbot/mcp/bootstrap.py +@src/paperbot/application/services/paper_search_service.py +@src/paperbot/application/collaboration/message_schema.py +@src/paperbot/application/ports/event_log_port.py +@src/paperbot/infrastructure/event_log/memory_event_log.py +@src/paperbot/domain/paper.py +@tests/unit/test_paper_judge.py + + + + +From src/paperbot/application/collaboration/message_schema.py: +```python +def new_run_id() -> str: ... # uuid4().hex +def new_trace_id() -> str: ... # uuid4().hex +def make_event( + *, run_id: str, trace_id: str, workflow: str, stage: str, + attempt: int, agent_name: str, role: str, type: str, + payload: Optional[Dict[str, Any]] = None, + parent_span_id: Optional[str] = None, + metrics: Optional[Dict[str, Any]] = None, + tags: Optional[Dict[str, Any]] = None, +) -> AgentEventEnvelope: ... +``` + +From src/paperbot/application/ports/event_log_port.py: +```python +class EventLogPort(Protocol): + def append(self, event: Union[AgentEventEnvelope, dict]) -> None: ... + def stream(self, run_id: str) -> Iterable[dict]: ... + def close(self) -> None: ... +``` + +From src/paperbot/infrastructure/event_log/memory_event_log.py: +```python +class InMemoryEventLog(EventLogPort): + def __init__(self) -> None: + self.events: List[dict] = [] + def append(self, event): ... # Stores event.to_dict() or dict(event) +``` + +From src/paperbot/application/services/paper_search_service.py: +```python +@dataclass +class SearchResult: + papers: List[PaperCandidate] = ... + provenance: Dict[str, List[str]] = ... + total_raw: int = 0 + duplicates_removed: int = 0 + +class PaperSearchService: + def __init__(self, adapters: Dict[str, SearchPort], ...): ... + async def search(self, query: str, *, sources=None, max_results=30, ...) -> SearchResult: ... +``` + +From src/paperbot/domain/paper.py: +```python +@dataclass +class PaperCandidate: + title: str + abstract: str = "" + authors: List[str] = ... + # ... many fields + def to_dict(self) -> Dict[str, Any]: ... +``` + +From src/paperbot/infrastructure/adapters/__init__.py: +```python +def build_adapter_registry() -> Dict[str, SearchPort]: ... + # Returns 5 adapters: semantic_scholar, arxiv, papers_cool, hf_daily, openalex +``` + +From src/paperbot/mcp/server.py (created in Phase 1): +```python +from mcp.server.fastmcp import FastMCP +mcp = FastMCP("paperbot") +``` + +Tool registration pattern (avoids circular imports): +```python +# In tools/paper_search.py: +def register(mcp): + @mcp.tool() + async def paper_search(...): ... + +# In server.py: +from paperbot.mcp.tools import paper_search +paper_search.register(mcp) +``` + + + + + + + Task 1: Create audit helper and paper_search tool with tests + + src/paperbot/mcp/tools/__init__.py, + src/paperbot/mcp/tools/_audit.py, + src/paperbot/mcp/tools/paper_search.py, + tests/unit/test_mcp_audit.py, + tests/unit/test_mcp_paper_search.py + + + - Test: log_tool_call() creates an AgentEventEnvelope with workflow="mcp", stage="tool_call", agent_name="paperbot-mcp" + - Test: log_tool_call() with run_id=None generates a new run_id (non-empty string returned) + - Test: log_tool_call() with run_id="abc123" uses that run_id in the event + - Test: log_tool_call() stores event in InMemoryEventLog (event appears in .events list) + - Test: log_tool_call() with no EventLogPort registered degrades silently (returns run_id, no exception) + - Test: log_tool_call() records duration_ms in event metrics + - Test: log_tool_call() records error field when error is provided + - Test: paper_search tool with a fake adapter returns list of paper dicts + - Test: paper_search tool with no adapters returns empty list + - Test: paper_search tool calls log_tool_call with tool_name="paper_search" + + +**Write tests FIRST (TDD red phase), then implement.** + +**Step 1: Create `src/paperbot/mcp/tools/__init__.py`** + +Empty package marker: +```python +"""MCP tool implementations for PaperBot.""" +``` + +**Step 2: Create `tests/unit/test_mcp_audit.py`** + +Test the audit helper using `InMemoryEventLog` from the existing codebase. + +Pattern: Register InMemoryEventLog in Container as EventLogPort, call log_tool_call(), inspect `.events`. + +```python +import pytest +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + +class TestLogToolCall: + def setup_method(self): + Container._instance = None + + # Test 1: Basic event structure + # Test 2: Auto-generated run_id + # Test 3: Provided run_id + # Test 4: Event stored in InMemoryEventLog + # Test 5: Graceful degradation (no EventLogPort registered) + # Test 6: duration_ms in metrics + # Test 7: error field propagation +``` + +Each test must reset `Container._instance = None` in setup_method. + +**Step 3: Create `tests/unit/test_mcp_paper_search.py`** + +Use a `_FakeSearchAdapter` that returns canned `PaperCandidate` objects. Construct a real `PaperSearchService` with the fake adapter. The tool's `_get_service()` must be overridable for testing -- use module-level `_service` variable that tests can set directly. + +```python +import pytest +from dataclasses import dataclass, field +from paperbot.domain.paper import PaperCandidate + +class _FakeSearchAdapter: + """Implements SearchPort with canned results.""" + async def search(self, query, max_results=10, **kwargs): + if query == "empty": + return [] + return [ + PaperCandidate(title="Test Paper", abstract="Test abstract", authors=["Author A"]) + ] +``` + +Mark async tests with `@pytest.mark.asyncio` (strict mode). + +**Step 4: Verify tests fail (red phase).** + +**Step 5: Create `src/paperbot/mcp/tools/_audit.py`** + +Implement `log_tool_call()` following the pattern from 02-RESEARCH.md "Pattern 3: Shared Audit Helper": + +- Import `make_event`, `new_run_id`, `new_trace_id` from `paperbot.application.collaboration.message_schema` +- Import `Container` from `paperbot.core.di` +- Import `EventLogPort` from `paperbot.application.ports.event_log_port` +- `_get_event_log()` resolves EventLogPort from Container, returns None on any exception +- `log_tool_call(tool_name, arguments, result_summary, duration_ms, run_id=None, error=None)` creates event and appends to event log +- Returns the run_id used (provided or generated) +- Event fields: workflow="mcp", stage="tool_call", agent_name="paperbot-mcp", role="system", type="tool_result" (or "error" if error is not None), attempt=0 +- payload: {tool, arguments, result_summary, error} +- metrics: {duration_ms} + +**Step 6: Create `src/paperbot/mcp/tools/paper_search.py`** + +Implement following the pattern from 02-RESEARCH.md "Complete paper_search Tool": + +- Module-level `_service = None` lazy singleton +- `_get_service()` constructs PaperSearchService with `build_adapter_registry()` on first call +- `register(mcp)` defines and registers the tool function +- Tool function signature: `async def paper_search(query: str, max_results: int = 10, sources: list[str] | None = None, _run_id: str = "") -> list[dict]` +- Docstring: describes the tool for MCP clients +- Times the call with `time.monotonic()` +- Calls `await service.search(query, max_results=max_results, sources=sources)` +- Converts result: `[p.to_dict() for p in result.papers]` +- Calls `log_tool_call()` with result_summary including count, total_raw, duplicates_removed +- On exception: logs error via log_tool_call, re-raises +- Returns the list of paper dicts + +**Step 7: Verify tests pass (green phase).** + + + PYTHONPATH=src pytest tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py -v + + + - _audit.py: log_tool_call() creates correct events, handles missing EventLogPort, propagates run_id + - paper_search.py: register() function registers tool, tool returns paper dicts, logs calls + - All unit tests pass for both modules + + + + + Task 2: Register paper_search in MCP server + + src/paperbot/mcp/server.py + + +**Update `src/paperbot/mcp/server.py` to register the paper_search tool.** + +Add after the `mcp = FastMCP("paperbot")` line: + +```python +# Register tools +from paperbot.mcp.tools import paper_search +paper_search.register(mcp) +``` + +This uses the registration function pattern (not direct import of decorated functions) to avoid circular imports. The tool module imports nothing from server.py; server.py calls `register(mcp)` passing the FastMCP instance. + +**Do NOT change anything else in server.py** -- the bootstrap call, transport config, and main() function remain as Phase 1 created them. + +**Verify:** Run both unit tests and the existing bootstrap test to confirm no regression. + + + PYTHONPATH=src pytest tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py tests/unit/test_mcp_bootstrap.py -v + + + - server.py imports paper_search tool module and calls register(mcp) + - No circular import errors + - All existing tests still pass + - paper_search tool is registered on the mcp instance + + + + + + +After both tasks complete: + +1. **Audit tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_audit.py -v` all pass +2. **Paper search tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_paper_search.py -v` all pass +3. **No regression:** `PYTHONPATH=src pytest tests/unit/test_mcp_bootstrap.py -v` still passes +4. **Import check:** `python -c "from paperbot.mcp.tools._audit import log_tool_call; print('audit OK')"` succeeds +5. **Import check:** `python -c "from paperbot.mcp.tools.paper_search import register; print('search OK')"` succeeds + + + +- log_tool_call() creates properly structured events with run_id correlation +- log_tool_call() degrades gracefully when EventLogPort is missing +- paper_search tool wraps PaperSearchService correctly +- paper_search tool logs all calls via audit helper +- Tool registration pattern works without circular imports +- All unit tests green + + + +After completion, create `.planning/phases/02-core-paper-tools/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-core-paper-tools/02-01-SUMMARY.md b/.planning/phases/02-core-paper-tools/02-01-SUMMARY.md new file mode 100644 index 00000000..23c84941 --- /dev/null +++ b/.planning/phases/02-core-paper-tools/02-01-SUMMARY.md @@ -0,0 +1,141 @@ +--- +phase: 02-core-paper-tools +plan: 01 +subsystem: mcp +tags: [mcp, audit, event-log, paper-search, fastmcp] + +# Dependency graph +requires: [] +provides: + - "Shared log_tool_call() audit helper for MCP tool event logging" + - "paper_search MCP tool wrapping PaperSearchService" + - "Tool registration pattern (register(mcp) function per tool module)" + - "MCP server.py with FastMCP instance and tool registration" +affects: [02-core-paper-tools] + +# Tech tracking +tech-stack: + added: [] + patterns: [register(mcp) tool pattern, module-level lazy singleton for services, graceful degradation on missing DI dependencies] + +key-files: + created: + - src/paperbot/mcp/__init__.py + - src/paperbot/mcp/server.py + - src/paperbot/mcp/tools/__init__.py + - src/paperbot/mcp/tools/_audit.py + - src/paperbot/mcp/tools/paper_search.py + - tests/unit/test_mcp_audit.py + - tests/unit/test_mcp_paper_search.py + - tests/unit/test_mcp_bootstrap.py + modified: [] + +key-decisions: + - "Used try/except ImportError for FastMCP import to handle Python 3.9 where mcp package is unavailable" + - "Exposed _paper_search_impl() as module-level function for direct test invocation without FastMCP dependency" + - "Created bootstrap test to verify server module imports and tool registration functions exist" + +patterns-established: + - "register(mcp) pattern: each tool module exports a register() function that receives the FastMCP instance, avoiding circular imports" + - "Module-level _service lazy singleton: tools use a global _service variable that tests can override directly" + - "Graceful degradation: _get_event_log() catches all exceptions when resolving EventLogPort, never blocks tool execution" + +requirements-completed: [R6.1, R6.2, R2.1] + +# Metrics +duration: 4min +completed: 2026-03-14 +--- + +# Phase 02 Plan 01: Audit Helper and Paper Search Tool Summary + +**Shared MCP audit helper (log_tool_call) with event logging and paper_search tool wrapping PaperSearchService, validated with 13 TDD unit tests** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-03-14T02:21:45Z +- **Completed:** 2026-03-14T02:25:32Z +- **Tasks:** 2 +- **Files modified:** 8 + +## Accomplishments +- Created log_tool_call() audit helper that creates AgentEventEnvelope events with workflow="mcp", stage="tool_call" and degrades gracefully when EventLogPort is missing +- Built paper_search MCP tool that wraps PaperSearchService, converts results to dicts, and logs all calls via the audit helper +- Established the register(mcp) tool registration pattern that remaining tools will follow +- All 13 unit tests passing (7 audit, 3 paper_search, 3 bootstrap) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create audit helper and paper_search tool with tests** - `fa16e09` (feat) +2. **Task 2: Register paper_search in MCP server** - `9d912d7` (feat) + +_Note: Task 1 followed TDD (RED/GREEN) - tests written first, then implementation._ + +## Files Created/Modified +- `src/paperbot/mcp/__init__.py` - Package marker for MCP module +- `src/paperbot/mcp/server.py` - FastMCP instance with paper_search registered +- `src/paperbot/mcp/tools/__init__.py` - Package marker for tools submodule +- `src/paperbot/mcp/tools/_audit.py` - Shared log_tool_call() helper for all tools +- `src/paperbot/mcp/tools/paper_search.py` - paper_search MCP tool wrapping PaperSearchService +- `tests/unit/test_mcp_audit.py` - 7 unit tests for audit helper +- `tests/unit/test_mcp_paper_search.py` - 3 unit tests for paper_search tool +- `tests/unit/test_mcp_bootstrap.py` - 3 unit tests for server bootstrap + +## Decisions Made +- Used try/except ImportError for FastMCP import in server.py to handle Python 3.9 compatibility where the mcp package is not installable (requires Python 3.10+). The server degrades to mcp=None, but all tool modules remain fully importable and testable. +- Exposed _paper_search_impl() as a module-level async function separate from the @mcp.tool() decorated wrapper, enabling direct test invocation without requiring FastMCP. +- Created a bootstrap test to verify the server module, register functions, and audit helper are all importable. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Created MCP package structure from scratch** +- **Found during:** Task 1 (audit helper creation) +- **Issue:** Plan referenced Phase 1 creating src/paperbot/mcp/ directory and server.py, but neither existed +- **Fix:** Created __init__.py package markers for mcp/ and mcp/tools/ directories +- **Files modified:** src/paperbot/mcp/__init__.py, src/paperbot/mcp/tools/__init__.py +- **Verification:** Module imports succeed +- **Committed in:** fa16e09 (Task 1 commit) + +**2. [Rule 3 - Blocking] Created server.py with FastMCP compatibility fallback** +- **Found during:** Task 2 (server registration) +- **Issue:** mcp package not installable on Python 3.9.7; server.py did not exist from Phase 1 +- **Fix:** Created server.py with try/except ImportError for FastMCP, falling back to mcp=None +- **Files modified:** src/paperbot/mcp/server.py +- **Verification:** Server module imports cleanly; all tests pass +- **Committed in:** 9d912d7 (Task 2 commit) + +**3. [Rule 3 - Blocking] Created missing bootstrap test** +- **Found during:** Task 2 (verification step references test_mcp_bootstrap.py) +- **Issue:** Plan verification references tests/unit/test_mcp_bootstrap.py but no such file existed +- **Fix:** Created bootstrap test with 3 test cases verifying module imports +- **Files modified:** tests/unit/test_mcp_bootstrap.py +- **Verification:** All 3 bootstrap tests pass +- **Committed in:** 9d912d7 (Task 2 commit) + +--- + +**Total deviations:** 3 auto-fixed (3 blocking) +**Impact on plan:** All auto-fixes necessary for execution. No scope creep -- these were missing prerequisites that should have been created by Phase 1. + +## Issues Encountered +- The mcp Python package (providing FastMCP) requires Python 3.10+ and cannot be installed on this environment's Python 3.9.7. This was handled by wrapping the import in try/except so the server degrades gracefully while all tool modules remain fully functional and testable. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- Audit helper and tool registration pattern are ready for plans 02 and 03 to add paper_analyze, paper_review, and scholar_track tools +- When Python is upgraded to 3.10+, install the mcp package to enable full FastMCP server functionality + +--- +*Phase: 02-core-paper-tools* +*Completed: 2026-03-14* + +## Self-Check: PASSED + +All 8 created files verified present. Both task commits (fa16e09, 9d912d7) verified in git log. diff --git a/.planning/phases/02-core-paper-tools/02-02-PLAN.md b/.planning/phases/02-core-paper-tools/02-02-PLAN.md new file mode 100644 index 00000000..121ec546 --- /dev/null +++ b/.planning/phases/02-core-paper-tools/02-02-PLAN.md @@ -0,0 +1,385 @@ +--- +phase: 02-core-paper-tools +plan: 02 +type: execute +wave: 2 +depends_on: ["02-01"] +files_modified: + - src/paperbot/mcp/tools/paper_judge.py + - src/paperbot/mcp/tools/paper_summarize.py + - src/paperbot/mcp/tools/relevance.py + - src/paperbot/mcp/server.py + - tests/unit/test_mcp_paper_judge.py + - tests/unit/test_mcp_paper_summarize.py + - tests/unit/test_mcp_relevance.py +autonomous: true +requirements: [R2.2, R2.3, R2.4] + +must_haves: + truths: + - "paper_judge returns dimension scores, overall score, recommendation, and judge_model" + - "paper_judge detects missing LLM (judge_model is empty) and sets degraded=true with error message" + - "paper_judge maps 'abstract' parameter to 'snippet' key for PaperJudge service" + - "paper_summarize returns a summary string from PaperSummarizer" + - "paper_summarize detects empty LLM output and sets degraded=true with error message" + - "relevance_assess returns score and reason from RelevanceAssessor" + - "relevance_assess detects fallback scoring ('Fallback' in reason) and annotates with degraded note" + - "All three tools use anyio.to_thread.run_sync() for sync service calls" + - "All three tools log calls via log_tool_call()" + artifacts: + - path: "src/paperbot/mcp/tools/paper_judge.py" + provides: "paper_judge MCP tool wrapping PaperJudge" + exports: ["register"] + - path: "src/paperbot/mcp/tools/paper_summarize.py" + provides: "paper_summarize MCP tool wrapping PaperSummarizer" + exports: ["register"] + - path: "src/paperbot/mcp/tools/relevance.py" + provides: "relevance_assess MCP tool wrapping RelevanceAssessor" + exports: ["register"] + - path: "src/paperbot/mcp/server.py" + provides: "FastMCP instance with all 4 tools registered" + contains: "paper_judge.register" + - path: "tests/unit/test_mcp_paper_judge.py" + provides: "Unit tests for paper_judge including degraded mode" + - path: "tests/unit/test_mcp_paper_summarize.py" + provides: "Unit tests for paper_summarize including degraded mode" + - path: "tests/unit/test_mcp_relevance.py" + provides: "Unit tests for relevance_assess including fallback" + key_links: + - from: "src/paperbot/mcp/tools/paper_judge.py" + to: "src/paperbot/application/workflows/analysis/paper_judge.py" + via: "PaperJudge().judge_single() wrapped in anyio.to_thread.run_sync()" + pattern: "anyio\\.to_thread\\.run_sync" + - from: "src/paperbot/mcp/tools/paper_summarize.py" + to: "src/paperbot/application/workflows/analysis/paper_summarizer.py" + via: "PaperSummarizer().summarize_item() wrapped in anyio.to_thread.run_sync()" + pattern: "anyio\\.to_thread\\.run_sync" + - from: "src/paperbot/mcp/tools/relevance.py" + to: "src/paperbot/application/workflows/analysis/relevance_assessor.py" + via: "RelevanceAssessor().assess() wrapped in anyio.to_thread.run_sync()" + pattern: "anyio\\.to_thread\\.run_sync" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/" + via: "register(mcp) calls for all 3 new tools" + pattern: "paper_judge\\.register|paper_summarize\\.register|relevance\\.register" +--- + + +Implement the three LLM-based paper tools (paper_judge, paper_summarize, relevance_assess) with sync-to-async wrapping, degraded output detection, and full unit tests. + +Purpose: These three tools share the same structural pattern -- wrapping a synchronous LLM-based service with `anyio.to_thread.run_sync()`, detecting degraded output when the LLM is unavailable, and logging via the audit helper from Plan 01. Implementing them together avoids redundant context loading. + +Output: Three working tool modules with registration functions, unit tests covering both normal and degraded modes, all registered on the MCP server. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-core-paper-tools/02-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-01-SUMMARY.md + +@src/paperbot/mcp/tools/_audit.py +@src/paperbot/mcp/tools/paper_search.py +@src/paperbot/mcp/server.py +@src/paperbot/application/workflows/analysis/paper_judge.py +@src/paperbot/application/workflows/analysis/paper_summarizer.py +@src/paperbot/application/workflows/analysis/relevance_assessor.py +@src/paperbot/application/services/llm_service.py +@tests/unit/test_paper_judge.py +@tests/unit/test_mcp_paper_search.py + + + + +From src/paperbot/application/workflows/analysis/paper_judge.py: +```python +class PaperJudge: + def __init__(self, llm_service: Optional[LLMService] = None, rubric=None): ... + def judge_single(self, *, paper: Dict[str, Any], query: str) -> PaperJudgment: ... + # paper dict MUST use "snippet" key, NOT "abstract" + # e.g., {"title": "...", "snippet": "..."} + +@dataclass +class PaperJudgment: + relevance: DimensionScore # .score: int, .rationale: str + novelty: DimensionScore + rigor: DimensionScore + impact: DimensionScore + clarity: DimensionScore + overall: float = 0.0 + one_line_summary: str = "" + recommendation: str = "" # must_read | worth_reading | skim | skip + judge_model: str = "" # EMPTY when LLM not available (degraded) + judge_cost_tier: int = 0 + evidence_quotes: List[Dict[str, str]] = None + def to_dict(self) -> Dict[str, Any]: ... +``` + +From src/paperbot/application/workflows/analysis/paper_summarizer.py: +```python +class PaperSummarizer: + def __init__(self, llm_service: LLMService | None = None): ... + def summarize_item(self, item: Dict[str, Any]) -> str: ... + # item dict: {"title": str, "snippet": str} or {"title": str, "abstract": str} + # Returns: str (raw LLM output, or empty string if LLM unavailable) +``` + +From src/paperbot/application/workflows/analysis/relevance_assessor.py: +```python +class RelevanceAssessor: + def __init__(self, llm_service: LLMService | None = None): ... + def assess(self, *, paper: Dict[str, Any], query: str) -> Dict[str, Any]: ... + # paper dict: {"title": str, "snippet"/"abstract": str, "keywords": list} + # Returns: {"score": int (0-100), "reason": str} + # When LLM fails: reason contains "Fallback" -- token-overlap scoring used +``` + +From src/paperbot/mcp/tools/_audit.py (created in Plan 01): +```python +def log_tool_call( + *, tool_name: str, arguments: Dict[str, Any], + result_summary: Dict[str, Any], duration_ms: float, + run_id: Optional[str] = None, error: Optional[str] = None, +) -> str: ... # Returns run_id used +``` + +Existing test pattern (from tests/unit/test_paper_judge.py): +```python +class _FakeLLMService: + def __init__(self, payload): + self.payload = payload + def complete(self, **kwargs): + return json.dumps(self.payload) + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "judge-model", "cost_tier": 2} + +class _FakeEmptyLLMService: + """Simulates missing API key.""" + def complete(self, **kwargs): + return "" + def describe_task_provider(self, task_type="default"): + return {"provider_name": "", "model_name": "", "cost_tier": 0} +``` + + + + + + + Task 1: Implement paper_judge, paper_summarize, and relevance_assess tools with tests + + src/paperbot/mcp/tools/paper_judge.py, + src/paperbot/mcp/tools/paper_summarize.py, + src/paperbot/mcp/tools/relevance.py, + tests/unit/test_mcp_paper_judge.py, + tests/unit/test_mcp_paper_summarize.py, + tests/unit/test_mcp_relevance.py + + + - Test: paper_judge with fake LLM returns judgment dict with all dimension scores, overall, recommendation + - Test: paper_judge with empty LLM (no API key) returns result with degraded=true and error message + - Test: paper_judge maps abstract param to "snippet" key in paper dict + - Test: paper_judge logs call via log_tool_call with tool_name="paper_judge" + - Test: paper_summarize with fake LLM returns summary string in a result dict + - Test: paper_summarize with empty LLM returns result with degraded=true and error message + - Test: paper_summarize logs call via log_tool_call with tool_name="paper_summarize" + - Test: relevance_assess with fake LLM returns score and reason dict + - Test: relevance_assess with fallback scoring annotates result with degraded note + - Test: relevance_assess logs call via log_tool_call with tool_name="relevance_assess" + + +**Write tests FIRST (TDD red phase), then implement all three tools.** + +All three tools follow the exact same structural pattern. The only differences are: the backing service class, the parameter names, the degraded output detection logic, and the return type. + +**Step 1: Create test files.** + +Use `_FakeLLMService` and `_FakeEmptyLLMService` patterns from `tests/unit/test_paper_judge.py` (already in the codebase). Each tool test must: +- Construct the backing service with a fake LLM +- Set the tool module's `_service` variable directly (bypassing lazy init) +- Call the tool function (imported from the register closure or tested via the backing service) +- Assert on the return dict structure + +For async tool testing, use `@pytest.mark.asyncio` and call the tool function directly (it is an async function defined inside `register()`). To access it for testing, either: +- Export a reference from the module alongside `register()`, or +- Test the underlying logic separately and trust register() wiring (simpler, follows existing patterns) + +Recommended: Test the tool logic as a standalone async function that `register()` wraps. Define the logic as `async def _paper_judge_impl(...)` at module level, have `register()` delegate to it. + +**Step 2: Implement `src/paperbot/mcp/tools/paper_judge.py`.** + +```python +import time +from typing import Any, Dict, Optional +import anyio +from paperbot.mcp.tools._audit import log_tool_call + +_judge = None + +def _get_judge(): + global _judge + if _judge is None: + from paperbot.application.workflows.analysis.paper_judge import PaperJudge + _judge = PaperJudge() + return _judge + +async def _paper_judge_impl( + title: str, abstract: str, full_text: str = "", + rubric: str = "default", _run_id: str = "", +) -> dict: + start = time.monotonic() + args = {"title": title, "abstract_len": len(abstract), "rubric": rubric} + # CRITICAL: Map "abstract" to "snippet" -- PaperJudge expects "snippet" key + paper = {"title": title, "snippet": abstract, "full_text": full_text} + + try: + judge = _get_judge() + result = await anyio.to_thread.run_sync( + lambda: judge.judge_single(paper=paper, query=rubric) + ) + output = result.to_dict() + + # Detect degraded LLM response (judge_model is empty when LLM unavailable) + if not result.judge_model: + output["degraded"] = True + output["error"] = ( + "LLM service unavailable. " + "Configure OPENAI_API_KEY or ANTHROPIC_API_KEY." + ) + + log_tool_call( + tool_name="paper_judge", + arguments=args, + result_summary={ + "overall": output.get("overall"), + "recommendation": output.get("recommendation"), + "degraded": output.get("degraded", False), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + except Exception as exc: + log_tool_call( + tool_name="paper_judge", arguments=args, result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, error=str(exc), + ) + raise + +def register(mcp): + @mcp.tool() + async def paper_judge( + title: str, abstract: str, full_text: str = "", + rubric: str = "default", _run_id: str = "", + ) -> dict: + """Judge a paper's quality across multiple dimensions (relevance, novelty, rigor, impact, clarity). + + Returns dimension scores (1-5), overall score, one-line summary, and recommendation + (must_read, worth_reading, skim, skip). Requires LLM API key. + """ + return await _paper_judge_impl(title, abstract, full_text, rubric, _run_id) +``` + +**Step 3: Implement `src/paperbot/mcp/tools/paper_summarize.py`.** + +Same pattern as paper_judge, but: +- Backing service: `PaperSummarizer()` with `summarize_item(item)` returning `str` +- Parameter mapping: item dict uses both "snippet" and "abstract" keys (summarizer reads `item.get("snippet") or item.get("abstract")`) -- pass as `{"title": title, "snippet": abstract}` +- Degraded detection: check if result string is empty (`not summary.strip()`) +- Return type: `{"summary": str}` on success, or `{"summary": "", "degraded": True, "error": "..."}` on empty LLM output + +**Step 4: Implement `src/paperbot/mcp/tools/relevance.py`.** + +Same pattern, but: +- Backing service: `RelevanceAssessor()` with `assess(paper=dict, query=str)` returning `dict` +- Parameter mapping: paper dict uses `{"title": title, "snippet": abstract, "keywords": []}` (assessor reads snippet/abstract) +- Degraded detection: check if `result["reason"]` contains "Fallback" -- annotate with `degraded=True` and a note explaining token-overlap fallback +- Return type: the dict from `assess()` directly, optionally annotated with `degraded` and `note` fields + +**Step 5: Verify tests pass (green phase).** + +**CRITICAL WARNINGS:** +- Use `anyio.to_thread.run_sync()` for ALL sync service calls. Do NOT call sync methods directly in async tools. +- Use `lambda:` wrapper in `run_sync()` calls, NOT `functools.partial` (closures work better with the service method pattern). +- All logging goes to stderr via the `logging` module. No `print()` anywhere. + + + PYTHONPATH=src pytest tests/unit/test_mcp_paper_judge.py tests/unit/test_mcp_paper_summarize.py tests/unit/test_mcp_relevance.py -v + + + - paper_judge: returns judgment dict, detects degraded LLM (judge_model empty), maps abstract->snippet + - paper_summarize: returns summary dict, detects empty LLM output, wraps sync call + - relevance_assess: returns score/reason dict, detects fallback scoring, annotates degraded + - All three use anyio.to_thread.run_sync() for sync service calls + - All three log via log_tool_call() + - All unit tests pass + + + + + Task 2: Register all three tools in MCP server + + src/paperbot/mcp/server.py + + +**Update `src/paperbot/mcp/server.py` to register the three new tools.** + +Add after the existing paper_search registration: + +```python +from paperbot.mcp.tools import paper_judge +from paperbot.mcp.tools import paper_summarize +from paperbot.mcp.tools import relevance +paper_judge.register(mcp) +paper_summarize.register(mcp) +relevance.register(mcp) +``` + +The file should now have 4 tool registrations total (paper_search from Plan 01 + these 3). + +**Do NOT change anything else** -- bootstrap call, transport config, and main() remain unchanged. + +**Verify:** Run all tool tests plus existing tests to confirm no regressions. + + + PYTHONPATH=src pytest tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py tests/unit/test_mcp_paper_judge.py tests/unit/test_mcp_paper_summarize.py tests/unit/test_mcp_relevance.py tests/unit/test_mcp_bootstrap.py -v + + + - server.py registers all 4 tools: paper_search, paper_judge, paper_summarize, relevance_assess + - No circular import errors + - All unit tests pass (audit + 4 tools + bootstrap) + + + + + + +After both tasks complete: + +1. **Judge tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_paper_judge.py -v` all pass +2. **Summarize tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_paper_summarize.py -v` all pass +3. **Relevance tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_relevance.py -v` all pass +4. **All tool tests:** `PYTHONPATH=src pytest tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py tests/unit/test_mcp_paper_judge.py tests/unit/test_mcp_paper_summarize.py tests/unit/test_mcp_relevance.py -v` all pass +5. **No regression:** `PYTHONPATH=src pytest tests/unit/test_mcp_bootstrap.py -v` still passes +6. **Full suite spot check:** `PYTHONPATH=src pytest tests/unit/ -q --ignore=tests/unit/test_memory_module.py` passes + + + +- All three LLM-based tools correctly wrap their backing services +- Sync-to-async wrapping via anyio.to_thread.run_sync() used for all LLM calls +- Degraded output detection works for all three tools (empty LLM, fallback scoring) +- All tools log via audit helper +- All 4 tools registered on MCP server +- All unit tests green + + + +After completion, create `.planning/phases/02-core-paper-tools/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-core-paper-tools/02-02-SUMMARY.md b/.planning/phases/02-core-paper-tools/02-02-SUMMARY.md new file mode 100644 index 00000000..4b974a30 --- /dev/null +++ b/.planning/phases/02-core-paper-tools/02-02-SUMMARY.md @@ -0,0 +1,115 @@ +--- +phase: 02-core-paper-tools +plan: 02 +subsystem: mcp +tags: [mcp, paper-judge, paper-summarize, relevance, llm, anyio, degraded-mode] + +# Dependency graph +requires: + - "Shared log_tool_call() audit helper for MCP tool event logging (02-01)" + - "Tool registration pattern register(mcp) from 02-01" + - "MCP server.py with FastMCP instance from 02-01" +provides: + - "paper_judge MCP tool wrapping PaperJudge with degraded LLM detection" + - "paper_summarize MCP tool wrapping PaperSummarizer with empty output detection" + - "relevance_assess MCP tool wrapping RelevanceAssessor with fallback scoring detection" + - "All 4 tools registered on MCP server (paper_search + these 3)" +affects: [02-core-paper-tools] + +# Tech tracking +tech-stack: + added: [anyio] + patterns: [anyio.to_thread.run_sync() for sync-to-async wrapping, degraded output detection per tool, module-level _impl function for testability] + +key-files: + created: + - src/paperbot/mcp/tools/paper_judge.py + - src/paperbot/mcp/tools/paper_summarize.py + - src/paperbot/mcp/tools/relevance.py + - tests/unit/test_mcp_paper_judge.py + - tests/unit/test_mcp_paper_summarize.py + - tests/unit/test_mcp_relevance.py + modified: + - src/paperbot/mcp/server.py + +key-decisions: + - "Used module-level _impl async functions for all three tools, matching paper_search pattern from Plan 01" + - "Degraded detection is tool-specific: judge checks judge_model empty, summarize checks empty output, relevance checks Fallback in reason" + - "All sync service calls wrapped with anyio.to_thread.run_sync(lambda: ...) pattern" + +patterns-established: + - "Degraded output detection: each tool checks for specific indicators of LLM unavailability and annotates result with degraded=True" + - "Module-level _impl function: async implementation exposed at module level for direct test invocation" + - "Consistent error handling: try/except wrapping with log_tool_call in both success and error paths" + +requirements-completed: [R2.2, R2.3, R2.4] + +# Metrics +duration: 8min +completed: 2026-03-14 +--- + +# Phase 02 Plan 02: LLM-based Paper Tools Summary + +**Three LLM-based MCP tools (paper_judge, paper_summarize, relevance_assess) with sync-to-async wrapping via anyio, degraded output detection, and 10 TDD unit tests** + +## Performance + +- **Duration:** 8 min +- **Started:** 2026-03-14T02:31:46Z +- **Completed:** 2026-03-14T02:40:10Z +- **Tasks:** 2 +- **Files modified:** 7 + +## Accomplishments +- Built paper_judge tool wrapping PaperJudge.judge_single() with abstract-to-snippet parameter mapping and degraded detection when judge_model is empty +- Built paper_summarize tool wrapping PaperSummarizer.summarize_item() with degraded detection on empty LLM output +- Built relevance_assess tool wrapping RelevanceAssessor.assess() with fallback scoring detection when reason contains "Fallback" +- All three tools use anyio.to_thread.run_sync() for sync service calls and log via log_tool_call() +- MCP server now registers all 4 tools; 23 total MCP tests passing (7 audit + 3 search + 4 judge + 3 summarize + 3 relevance + 3 bootstrap) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Implement paper_judge, paper_summarize, and relevance_assess tools with tests** - `3bff474` (test, RED) + `c2ec8d6` (feat, GREEN) +2. **Task 2: Register all three tools in MCP server** - `b962259` (feat) + +_Note: Task 1 followed TDD (RED/GREEN) -- tests written first (10 failing), then implementation (10 passing)._ + +## Files Created/Modified +- `src/paperbot/mcp/tools/paper_judge.py` - paper_judge MCP tool wrapping PaperJudge with degraded LLM detection +- `src/paperbot/mcp/tools/paper_summarize.py` - paper_summarize MCP tool wrapping PaperSummarizer with empty output detection +- `src/paperbot/mcp/tools/relevance.py` - relevance_assess MCP tool wrapping RelevanceAssessor with fallback scoring detection +- `src/paperbot/mcp/server.py` - Updated to register all 4 tools +- `tests/unit/test_mcp_paper_judge.py` - 4 unit tests for paper_judge (normal, degraded, abstract mapping, audit) +- `tests/unit/test_mcp_paper_summarize.py` - 3 unit tests for paper_summarize (normal, degraded, audit) +- `tests/unit/test_mcp_relevance.py` - 3 unit tests for relevance_assess (normal, fallback, audit) + +## Decisions Made +- Used module-level _impl async functions for all three tools, following the established paper_search pattern from Plan 01. This enables direct test invocation without requiring FastMCP. +- Degraded detection is tool-specific: paper_judge checks if judge_model is empty string, paper_summarize checks if summary output is empty/whitespace, relevance_assess checks if reason contains "Fallback". Each approach matches how the underlying service signals LLM unavailability. +- All sync service calls use `anyio.to_thread.run_sync(lambda: ...)` pattern rather than functools.partial, as closures work better with the service method pattern. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- All 4 paper-related tools are now registered on the MCP server +- Plan 03 can add the remaining tools (scholar_track, etc.) +- When Python is upgraded to 3.10+, install the mcp package to enable full FastMCP server functionality + +--- +*Phase: 02-core-paper-tools* +*Completed: 2026-03-14* + +## Self-Check: PASSED + +All 7 created/modified files verified present. All 3 task commits (3bff474, c2ec8d6, b962259) verified in git log. diff --git a/.planning/phases/02-core-paper-tools/02-03-PLAN.md b/.planning/phases/02-core-paper-tools/02-03-PLAN.md new file mode 100644 index 00000000..0ad00913 --- /dev/null +++ b/.planning/phases/02-core-paper-tools/02-03-PLAN.md @@ -0,0 +1,246 @@ +--- +phase: 02-core-paper-tools +plan: 03 +type: execute +wave: 3 +depends_on: ["02-01", "02-02"] +files_modified: + - tests/integration/test_mcp_tool_calls.py +autonomous: true +requirements: [R2.1, R2.2, R2.3, R2.4, R6.1, R6.2] + +must_haves: + truths: + - "MCP tools/list returns exactly 4 tools: paper_search, paper_judge, paper_summarize, relevance_assess" + - "Each tool has a description and input schema auto-generated by FastMCP" + - "Calling paper_search through MCP protocol returns paper results" + - "Tool call events are logged to EventLogPort during MCP protocol calls" + - "Existing test suite has no regressions" + artifacts: + - path: "tests/integration/test_mcp_tool_calls.py" + provides: "Integration tests verifying tool listing and invocation via MCP protocol" + key_links: + - from: "tests/integration/test_mcp_tool_calls.py" + to: "src/paperbot/mcp/server.py" + via: "Imports mcp instance to test tool listing" + pattern: "from paperbot\\.mcp\\.server import mcp" +--- + + +Create integration tests that verify all 4 tools are discoverable and callable through the MCP protocol, and run the full test suite to confirm zero regressions. + +Purpose: This plan closes the loop on Phase 2 by verifying the tools work at the MCP protocol level (not just as isolated functions). This is the final acceptance gate: tools must appear in `tools/list` and be callable through the protocol layer. + +Output: Integration test file, confirmed full suite green. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/02-core-paper-tools/02-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-01-SUMMARY.md +@.planning/phases/02-core-paper-tools/02-02-SUMMARY.md + +@src/paperbot/mcp/server.py +@tests/unit/test_mcp_paper_search.py +@tests/integration/test_mcp_server.py + + + + +From src/paperbot/mcp/server.py (after Plan 01 + Plan 02): +```python +from mcp.server.fastmcp import FastMCP +mcp = FastMCP("paperbot") + +# 4 tools registered: +# - paper_search (async, wraps PaperSearchService) +# - paper_judge (sync->async, wraps PaperJudge) +# - paper_summarize (sync->async, wraps PaperSummarizer) +# - relevance_assess (sync->async, wraps RelevanceAssessor) +``` + +FastMCP tool listing API: +```python +# mcp.list_tools() returns list of Tool objects +# Each Tool has: name (str), description (str), inputSchema (dict) +``` + +From tests/integration/test_mcp_server.py (Phase 1): +```python +# Existing pattern for MCP integration testing +# Uses subprocess or in-process testing +# Reset Container._instance and bootstrap._bootstrapped in setup +``` + + + + + + + Task 1: Integration tests for MCP tool listing and invocation + + tests/integration/test_mcp_tool_calls.py + + +**Create `tests/integration/test_mcp_tool_calls.py` with tests that verify tools at the MCP protocol level.** + +**Test 1: `test_all_four_tools_listed`** +- Reset Container._instance and bootstrap._bootstrapped +- Import `mcp` from `paperbot.mcp.server` +- Call `mcp.list_tools()` (or the equivalent FastMCP API for listing tools) +- Assert exactly 4 tools are listed +- Assert tool names are: `paper_search`, `paper_judge`, `paper_summarize`, `relevance_assess` +- Assert each tool has a non-empty description +- Assert each tool has an inputSchema dict + +**Test 2: `test_paper_search_tool_has_correct_params`** +- List tools, find paper_search +- Assert inputSchema includes: `query` (required, string), `max_results` (optional, integer), `sources` (optional, array) +- Assert `_run_id` appears in schema (it's a parameter, even if optional) + +**Test 3: `test_paper_judge_tool_has_correct_params`** +- List tools, find paper_judge +- Assert inputSchema includes: `title` (required, string), `abstract` (required, string) +- Assert `full_text` and `rubric` are optional parameters + +**Test 4: `test_tool_call_paper_search_via_protocol`** +- Prefer a real MCP transport path, not a direct implementation call: + - Stand up a short-lived in-process FastMCP server/client pair (for example via `ClientSession` over memory streams) + - Inject a fake search service on the paper_search module's `_service` variable + - Invoke `paper_search` through the MCP transport layer + - Assert the result contains paper dicts +- If `mcp>=1.8.0,<2.0.0` lacks the required in-process helper in this environment: + - Document the SDK limitation explicitly in the test comments + - Keep direct `_paper_search_impl` checks as separate unit coverage only; they do not replace protocol coverage + +**Test 5: `test_tool_call_logs_event`** +- Register InMemoryEventLog in Container +- Call a tool (paper_search with fake adapter) +- Assert an event was logged with workflow="mcp", stage="tool_call" + +**Setup pattern for all tests:** +```python +import pytest +from paperbot.core.di import Container +import paperbot.mcp.bootstrap as bmod + +class TestMCPToolCalls: + def setup_method(self): + Container._instance = None + bmod._bootstrapped = False +``` + +**IMPORTANT:** The FastMCP `list_tools()` API may be synchronous or async depending on SDK version. Check and use the correct call pattern. If async, use `@pytest.mark.asyncio`. + +**After creating the integration test**, run the full test suite to verify zero regressions: +```bash +PYTHONPATH=src pytest -q +``` + +This validates that the entire existing test suite (unit, integration, e2e) still passes with the new MCP tools module imported. + + + PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py tests/unit/test_mcp_paper_judge.py tests/unit/test_mcp_paper_summarize.py tests/unit/test_mcp_relevance.py -v + + + - Integration test confirms 4 tools listed via MCP tools/list + - Each tool has description and input schema + - paper_search callable through MCP layer (or documented limitation) + - Tool calls logged to event log + - Full test suite passes with zero regressions + + + + + Task 2: Full suite regression check and Phase 2 validation + + +**Run the complete CI offline test gate to confirm zero regressions.** + +Execute the exact CI command from CLAUDE.md: + +```bash +PYTHONPATH=src pytest -q \ + tests/unit/test_scholar_from_config.py \ + tests/unit/test_source_registry_modes.py \ + tests/unit/test_arq_worker_settings.py \ + tests/unit/test_jobs_routes_import.py \ + tests/unit/test_dailypaper.py \ + tests/unit/test_paper_judge.py \ + tests/unit/test_memory_module.py \ + tests/unit/test_memory_metric_collector.py \ + tests/unit/test_llm_service.py \ + tests/unit/test_di_container.py \ + tests/unit/test_pipeline.py \ + tests/integration/test_eventlog_sqlalchemy.py \ + tests/integration/test_crawler_contract_parsers.py \ + tests/integration/test_arxiv_connector_fixture.py \ + tests/integration/test_reddit_connector_fixture.py \ + tests/integration/test_x_importer_fixture.py \ + tests/e2e/test_api_track_fullstack_offline.py +``` + +Then run the new MCP tests: + +```bash +PYTHONPATH=src pytest -q \ + tests/unit/test_mcp_audit.py \ + tests/unit/test_mcp_paper_search.py \ + tests/unit/test_mcp_paper_judge.py \ + tests/unit/test_mcp_paper_summarize.py \ + tests/unit/test_mcp_relevance.py \ + tests/integration/test_mcp_tool_calls.py +``` + +**All tests must pass.** If any fail, diagnose and fix before Phase 2 is complete. + +Also run the eval smoke tests: + +```bash +python evals/runners/run_scholar_pipeline_smoke.py +python evals/runners/run_track_pipeline_smoke.py +python evals/runners/run_eventlog_replay_smoke.py +``` + +**Do NOT modify any files in this task** -- this is purely a verification step. If failures are found, go back and fix them in the relevant tool files. + + + PYTHONPATH=src pytest -q tests/unit/test_mcp_audit.py tests/unit/test_mcp_paper_search.py tests/unit/test_mcp_paper_judge.py tests/unit/test_mcp_paper_summarize.py tests/unit/test_mcp_relevance.py tests/integration/test_mcp_tool_calls.py tests/unit/test_di_container.py tests/unit/test_paper_judge.py tests/e2e/test_api_track_fullstack_offline.py + + + - CI offline test gate passes (all existing tests green) + - All new MCP tests pass (audit + 4 tools + integration) + - Eval smoke tests pass + - Phase 2 acceptance criteria met: 4 tools listed, tool calls logged, unit + integration tests pass + + + + + + +Phase 2 acceptance criteria (from ROADMAP.md): + +1. **4 tools listed via MCP tools/list:** Integration test confirms paper_search, paper_judge, paper_summarize, relevance_assess +2. **paper_search returns results for a query:** Unit test with fake adapter confirms +3. **LLM-based tools return structured results or clear error when offline:** Unit tests confirm degraded detection for all 3 LLM tools +4. **Tool calls logged to event log:** Unit tests for audit helper + integration test confirms +5. **Unit + integration tests pass:** Full suite green + + + +- Integration tests confirm all 4 tools discoverable via MCP protocol +- Full CI test gate passes with zero regressions +- Phase 2 acceptance criteria from ROADMAP all met +- Ready for Phase 3 (remaining 5 tools) + + + +After completion, create `.planning/phases/02-core-paper-tools/02-03-SUMMARY.md` + diff --git a/.planning/phases/03-remaining-mcp-tools/03-01-PLAN.md b/.planning/phases/03-remaining-mcp-tools/03-01-PLAN.md new file mode 100644 index 00000000..60eb4335 --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-01-PLAN.md @@ -0,0 +1,243 @@ +--- +phase: 03-remaining-mcp-tools +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/paperbot/mcp/tools/analyze_trends.py + - src/paperbot/mcp/tools/check_scholar.py + - tests/unit/test_mcp_analyze_trends.py + - tests/unit/test_mcp_check_scholar.py +autonomous: true +requirements: [MCP-01, MCP-02] + +must_haves: + truths: + - "analyze_trends returns trend analysis string for a topic and list of papers" + - "analyze_trends returns degraded=True when LLM is unavailable" + - "check_scholar returns scholar info and recent papers for a name query" + - "check_scholar returns degraded result when scholar is not found" + - "Both tools log calls via log_tool_call audit helper" + artifacts: + - path: "src/paperbot/mcp/tools/analyze_trends.py" + provides: "analyze_trends MCP tool wrapping TrendAnalyzer" + exports: ["_analyze_trends_impl", "register"] + - path: "src/paperbot/mcp/tools/check_scholar.py" + provides: "check_scholar MCP tool wrapping SemanticScholarClient" + exports: ["_check_scholar_impl", "register"] + - path: "tests/unit/test_mcp_analyze_trends.py" + provides: "Unit tests for analyze_trends" + min_lines: 40 + - path: "tests/unit/test_mcp_check_scholar.py" + provides: "Unit tests for check_scholar" + min_lines: 40 + key_links: + - from: "src/paperbot/mcp/tools/analyze_trends.py" + to: "paperbot.application.workflows.analysis.trend_analyzer.TrendAnalyzer" + via: "lazy singleton _get_analyzer() + anyio.to_thread.run_sync()" + pattern: "anyio\\.to_thread\\.run_sync.*analyzer\\.analyze" + - from: "src/paperbot/mcp/tools/check_scholar.py" + to: "paperbot.infrastructure.api_clients.semantic_scholar.SemanticScholarClient" + via: "lazy singleton _get_client() + await client.search_authors() / get_author_papers()" + pattern: "await.*client\\.(search_authors|get_author_papers)" +--- + + +Implement the analyze_trends and check_scholar MCP tools with unit tests. + +Purpose: Two of the five remaining MCP tools -- analyze_trends wraps a sync LLM-dependent service (TrendAnalyzer), check_scholar wraps an async network client (SemanticScholarClient). Together they demonstrate both wrapping patterns needed for Phase 3. + +Output: Two tool modules with _impl functions, register() functions, and unit tests covering normal, degraded, and audit paths. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-02-SUMMARY.md + + + + + +From src/paperbot/mcp/tools/_audit.py: +```python +def log_tool_call( + tool_name: str, + arguments: Dict[str, Any], + result_summary: str, + duration_ms: float, + run_id: Optional[str] = None, + error: Optional[str] = None, +) -> str: + """Log an MCP tool call as an AgentEventEnvelope.""" +``` + +From src/paperbot/application/workflows/analysis/trend_analyzer.py: +```python +class TrendAnalyzer: + def __init__(self): ... # uses get_llm_service() internally + def analyze(self, topic: str, items: Sequence[Dict]) -> str: + """Returns raw LLM text string; empty string when LLM unavailable.""" +``` + +From src/paperbot/infrastructure/api_clients/semantic_scholar.py: +```python +class SemanticScholarClient: + def __init__(self, api_key=None): ... + async def search_authors(self, query: str, limit: int = 10, + fields: List[str] = ...) -> List[Dict]: + """Returns [{"authorId": "...", "name": "...", "hIndex": N, ...}]""" + async def get_author_papers(self, author_id: str, limit: int = 10, + fields: List[str] = ...) -> List[Dict]: + """Returns [{"title": "...", "year": ..., ...}]""" +``` + +From src/paperbot/mcp/tools/paper_judge.py (established pattern to follow): +```python +# Module-level lazy singleton +_judge = None + +def _get_judge(): + global _judge + if _judge is None: + from paperbot.application.workflows.analysis.paper_judge import PaperJudge + _judge = PaperJudge() + return _judge + +async def _paper_judge_impl(title: str, abstract: str, ...) -> dict: + start = time.monotonic() + args = {... } + try: + result = await anyio.to_thread.run_sync(lambda: judge.judge_single(...)) + # ... degraded detection ... + log_tool_call(...) + return output + except Exception as exc: + log_tool_call(..., error=str(exc)) + raise + +def register(mcp) -> None: + @mcp.tool() + async def paper_judge(...) -> dict: + """Docstring.""" + return await _paper_judge_impl(...) +``` + +From tests/unit/test_mcp_paper_judge.py (test pattern to follow): +```python +class _FakeLLMService: + def complete(self, **kwargs): return json.dumps(self.payload) + def describe_task_provider(self, task_type="default"): return {...} + +class TestPaperJudgeTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_...(self): + import paperbot.mcp.tools.paper_judge as pj_mod + pj_mod._judge = fake_judge + try: + result = await pj_mod._paper_judge_impl(...) + finally: + pj_mod._judge = None + assert ... +``` + + + + + + + Task 1: Implement analyze_trends and check_scholar MCP tools with TDD + + src/paperbot/mcp/tools/analyze_trends.py, + src/paperbot/mcp/tools/check_scholar.py, + tests/unit/test_mcp_analyze_trends.py, + tests/unit/test_mcp_check_scholar.py + + + analyze_trends tests: + - test_returns_trend_analysis_dict: _analyze_trends_impl with fake TrendAnalyzer returning "Trend: LLMs growing" -> returns {"trend_analysis": "Trend: LLMs growing", "topic": "llms", "paper_count": 2} + - test_degraded_mode_when_llm_unavailable: _analyze_trends_impl with fake TrendAnalyzer returning "" -> returns {"degraded": True, "error": "...", "trend_analysis": ""} + - test_logs_call_via_log_tool_call: _analyze_trends_impl logs event with tool="analyze_trends", workflow="mcp" + + check_scholar tests: + - test_returns_scholar_info_and_papers: _check_scholar_impl with fake S2 client returning author+papers -> returns {"scholar": {name, authorId, hIndex}, "recent_papers": [...]} + - test_degraded_when_scholar_not_found: _check_scholar_impl with fake S2 client returning empty authors list -> returns {"degraded": True, "error": "Scholar not found", "scholar": None, "recent_papers": []} + - test_logs_call_via_log_tool_call: _check_scholar_impl logs event with tool="check_scholar", workflow="mcp" + + + **RED phase:** Write tests first for both tools (6 tests total, 3 per tool). + + For analyze_trends tests (tests/unit/test_mcp_analyze_trends.py): + - Create _FakeTrendAnalyzer class with `analyze(topic, items)` method returning a canned string + - Create _FakeEmptyTrendAnalyzer returning "" to simulate LLM unavailable + - Follow test_mcp_paper_judge.py pattern exactly: setup_method resets Container._instance, inject fake via `mod._analyzer = fake`, always reset in finally block + - Use InMemoryEventLog for audit test + + For check_scholar tests (tests/unit/test_mcp_check_scholar.py): + - Create _FakeS2Client class with async `search_authors()` returning [{"authorId": "123", "name": "Test Scholar", "hIndex": 42, "paperCount": 100, "citationCount": 5000}] + - And async `get_author_papers()` returning [{"title": "Paper A", "year": 2024, "citationCount": 10, "venue": "NeurIPS"}] + - Create _FakeEmptyS2Client where search_authors returns [] + - Follow same injection pattern: `mod._client = fake_client` + + **GREEN phase:** Implement both tool modules. + + For analyze_trends.py: + - Module-level `_analyzer = None` + `_get_analyzer()` lazy singleton constructing TrendAnalyzer() + - `_analyze_trends_impl(topic: str, papers: List[Dict[str, Any]], _run_id: str = "") -> dict` + - Use `anyio.to_thread.run_sync(lambda: analyzer.analyze(topic=topic, items=papers))` for sync wrapping + - Degraded detection: `if not result or not result.strip()` -> set degraded=True with error about LLM API key + - Return: {"trend_analysis": result, "topic": topic, "paper_count": len(papers), "degraded": ...} + - `register(mcp)` with `@mcp.tool()` decorator, docstring: "Analyze trends across a set of papers for a given topic." + + For check_scholar.py: + - Module-level `_client = None` + `_get_client()` lazy singleton constructing SemanticScholarClient() + - `_check_scholar_impl(scholar_name: str, max_papers: int = 10, _run_id: str = "") -> dict` + - Step 1: `authors = await client.search_authors(scholar_name, limit=3, fields=["name", "authorId", "hIndex", "paperCount", "citationCount"])` + - If not authors: return degraded result {"degraded": True, "error": "Scholar not found", "scholar": None, "recent_papers": [], "candidates": []} + - Step 2: Pick top match (first result -- highest relevance from S2 API), get papers: `papers = await client.get_author_papers(author_id, limit=max_papers, fields=["title", "year", "citationCount", "venue", "abstract"])` + - Return: {"scholar": top_author, "recent_papers": papers, "candidates": authors} + - `register(mcp)` with docstring: "Check a scholar's recent publications and activity." + + Both tools: use `from __future__ import annotations`, import logging, time, anyio, log_tool_call. Follow try/except pattern with log_tool_call in both success and error paths. + + + PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py -v + + + - analyze_trends returns trend analysis dict with topic, paper_count, trend_analysis string + - analyze_trends returns degraded=True when LLM returns empty string + - check_scholar returns scholar info + recent papers from fake S2 client + - check_scholar returns degraded result when no scholars found + - Both tools log via audit helper with correct tool names + - 6 unit tests passing + + + + + + +PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py -v + + + +- analyze_trends.py and check_scholar.py exist in src/paperbot/mcp/tools/ with _impl + register pattern +- 6 unit tests pass covering normal, degraded, and audit paths for both tools +- Both tools use module-level lazy singleton pattern (_analyzer, _client) +- analyze_trends wraps sync TrendAnalyzer.analyze() with anyio.to_thread.run_sync() +- check_scholar uses async SemanticScholarClient.search_authors() + get_author_papers() + + + +After completion, create `.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md` + diff --git a/.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md b/.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md new file mode 100644 index 00000000..7692832c --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md @@ -0,0 +1,123 @@ +--- +phase: 03-remaining-mcp-tools +plan: 01 +subsystem: api +tags: [mcp, fastmcp, trend-analyzer, semantic-scholar, anyio, tdd] + +# Dependency graph +requires: + - phase: 02-core-paper-tools + provides: MCP tool pattern (_impl + register + lazy singleton + log_tool_call audit) +provides: + - analyze_trends MCP tool wrapping sync TrendAnalyzer via anyio.to_thread.run_sync() + - check_scholar MCP tool wrapping async SemanticScholarClient + - Unit tests covering normal, degraded, and audit paths for both tools +affects: [03-remaining-mcp-tools, mcp-server-registration] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Lazy singleton with module-level _var + _get_var() function for MCP tool dependencies" + - "anyio.to_thread.run_sync() for wrapping synchronous services in async MCP tools" + - "Degraded detection: empty string from LLM triggers degraded=True response" + - "Direct async client usage in MCP tools (no thread wrapping needed for async services)" + +key-files: + created: + - src/paperbot/mcp/tools/analyze_trends.py + - src/paperbot/mcp/tools/check_scholar.py + - tests/unit/test_mcp_analyze_trends.py + - tests/unit/test_mcp_check_scholar.py + modified: [] + +key-decisions: + - "analyze_trends uses anyio.to_thread.run_sync() because TrendAnalyzer.analyze() is synchronous" + - "check_scholar awaits SemanticScholarClient directly (no thread wrapping needed - already async)" + - "Degraded detection for analyze_trends: empty/whitespace-only string signals LLM unavailability" + - "check_scholar returns degraded=True with candidates=[] on empty author search (not exception)" + +patterns-established: + - "Sync LLM service wrapping: anyio.to_thread.run_sync(lambda: service.method(...))" + - "Async client wrapping: direct await, no thread overhead" + +requirements-completed: [MCP-01, MCP-02] + +# Metrics +duration: 2min +completed: 2026-03-14 +--- + +# Phase 03 Plan 01: analyze_trends and check_scholar MCP Tools Summary + +**Two MCP tools wrapping TrendAnalyzer (sync/anyio) and SemanticScholarClient (async) with degraded-mode detection and audit logging** + +## Performance + +- **Duration:** 2 min +- **Started:** 2026-03-14T04:23:23Z +- **Completed:** 2026-03-14T04:25:05Z +- **Tasks:** 1 (TDD: RED + GREEN) +- **Files modified:** 4 + +## Accomplishments + +- `analyze_trends` MCP tool wraps synchronous `TrendAnalyzer.analyze()` with `anyio.to_thread.run_sync()`, detecting degraded state when LLM returns empty string +- `check_scholar` MCP tool awaits async `SemanticScholarClient.search_authors()` and `get_author_papers()`, returning degraded result when scholar not found +- 6 unit tests covering normal, degraded, and audit paths for both tools - all passing + +## Task Commits + +Each task was committed atomically: + +1. **RED phase: failing tests** - `ada2c56` (test) +2. **GREEN phase: both tool implementations** - `214a027` (feat) + +_Note: TDD task split into two commits (test -> feat)_ + +## Files Created/Modified + +- `src/paperbot/mcp/tools/analyze_trends.py` - analyze_trends MCP tool: lazy singleton _analyzer, anyio thread wrapping, degraded detection, log_tool_call audit +- `src/paperbot/mcp/tools/check_scholar.py` - check_scholar MCP tool: lazy singleton _client, async S2 client calls, not-found degraded path, log_tool_call audit +- `tests/unit/test_mcp_analyze_trends.py` - 3 tests: normal result, degraded (empty LLM), audit log +- `tests/unit/test_mcp_check_scholar.py` - 3 tests: normal result, degraded (scholar not found), audit log + +## Decisions Made + +- `analyze_trends` uses `anyio.to_thread.run_sync()` because `TrendAnalyzer.analyze()` is a synchronous method +- `check_scholar` awaits `SemanticScholarClient` directly since it is already async (no thread overhead) +- Degraded detection for `analyze_trends` uses empty/whitespace-only string check (matches TrendAnalyzer's behavior when LLM is unavailable) +- `check_scholar` returns `degraded=True` with `candidates=[]` on empty author search rather than raising an exception (graceful degradation) + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Two MCP tool patterns now established: sync-wrapped (anyio) and direct-async +- Remaining tools in Phase 03 can follow the same two patterns +- Both tools ready for registration in MCP server `__init__.py` + +## Self-Check: PASSED + +- FOUND: src/paperbot/mcp/tools/analyze_trends.py +- FOUND: src/paperbot/mcp/tools/check_scholar.py +- FOUND: tests/unit/test_mcp_analyze_trends.py +- FOUND: tests/unit/test_mcp_check_scholar.py +- FOUND: .planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md +- FOUND commit: ada2c56 (test - RED phase) +- FOUND commit: 214a027 (feat - GREEN phase) +- All 6 tests: PASSED + +--- +*Phase: 03-remaining-mcp-tools* +*Completed: 2026-03-14* diff --git a/.planning/phases/03-remaining-mcp-tools/03-02-PLAN.md b/.planning/phases/03-remaining-mcp-tools/03-02-PLAN.md new file mode 100644 index 00000000..d2ee9356 --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-02-PLAN.md @@ -0,0 +1,284 @@ +--- +phase: 03-remaining-mcp-tools +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/paperbot/mcp/tools/get_research_context.py + - src/paperbot/mcp/tools/save_to_memory.py + - src/paperbot/mcp/tools/export_to_obsidian.py + - tests/unit/test_mcp_get_research_context.py + - tests/unit/test_mcp_save_to_memory.py + - tests/unit/test_mcp_export_to_obsidian.py +autonomous: true +requirements: [MCP-03, MCP-04, MCP-05] + +must_haves: + truths: + - "get_research_context returns a context pack dict for a query" + - "save_to_memory persists content and returns saved=True with counts" + - "save_to_memory validates kind against allowed MemoryKind values" + - "export_to_obsidian returns markdown string with YAML frontmatter for a paper" + - "All three tools log calls via log_tool_call audit helper" + artifacts: + - path: "src/paperbot/mcp/tools/get_research_context.py" + provides: "get_research_context MCP tool wrapping ContextEngine" + exports: ["_get_research_context_impl", "register"] + - path: "src/paperbot/mcp/tools/save_to_memory.py" + provides: "save_to_memory MCP tool wrapping SqlAlchemyMemoryStore" + exports: ["_save_to_memory_impl", "register"] + - path: "src/paperbot/mcp/tools/export_to_obsidian.py" + provides: "export_to_obsidian MCP tool wrapping ObsidianFilesystemExporter renderer" + exports: ["_export_to_obsidian_impl", "register"] + - path: "tests/unit/test_mcp_get_research_context.py" + provides: "Unit tests for get_research_context" + min_lines: 40 + - path: "tests/unit/test_mcp_save_to_memory.py" + provides: "Unit tests for save_to_memory" + min_lines: 40 + - path: "tests/unit/test_mcp_export_to_obsidian.py" + provides: "Unit tests for export_to_obsidian" + min_lines: 40 + key_links: + - from: "src/paperbot/mcp/tools/get_research_context.py" + to: "paperbot.context_engine.ContextEngine" + via: "lazy singleton _get_engine() + await engine.build_context_pack()" + pattern: "await.*engine\\.build_context_pack" + - from: "src/paperbot/mcp/tools/save_to_memory.py" + to: "paperbot.infrastructure.stores.memory_store.SqlAlchemyMemoryStore" + via: "lazy singleton _get_store() + anyio.to_thread.run_sync()" + pattern: "anyio\\.to_thread\\.run_sync.*store\\.add_memories" + - from: "src/paperbot/mcp/tools/export_to_obsidian.py" + to: "paperbot.infrastructure.exporters.obsidian_exporter.ObsidianFilesystemExporter" + via: "lazy singleton _get_exporter() + anyio.to_thread.run_sync() on _render_paper_note()" + pattern: "anyio\\.to_thread\\.run_sync.*_render_paper_note" +--- + + +Implement the get_research_context, save_to_memory, and export_to_obsidian MCP tools with unit tests. + +Purpose: Three remaining MCP tools covering research context retrieval (async), memory persistence (sync+anyio), and Obsidian export (sync in-memory rendering). Completing these brings the tool surface to 9 total. + +Output: Three tool modules with _impl functions, register() functions, and unit tests covering normal, error, and audit paths. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-02-SUMMARY.md + + + + + +From src/paperbot/mcp/tools/_audit.py: +```python +def log_tool_call( + tool_name: str, + arguments: Dict[str, Any], + result_summary: str, + duration_ms: float, + run_id: Optional[str] = None, + error: Optional[str] = None, +) -> str: + """Log an MCP tool call as an AgentEventEnvelope.""" +``` + +From src/paperbot/context_engine/engine.py: +```python +class ContextEngineConfig: + offline: bool = False + paper_limit: int = 10 + # ... other fields + +class ContextEngine: + def __init__(self, config=None, research_store=None, memory_store=None, ...): ... + async def build_context_pack(self, user_id: str, query: str, + track_id: Optional[int] = None) -> Dict[str, Any]: + """Returns dict with keys: papers, memories, track, stage, routing_suggestion, ...""" +``` + +From src/paperbot/memory/schema.py: +```python +MemoryKind = Literal["profile", "preference", "goal", "project", "constraint", + "todo", "fact", "note", "decision", "hypothesis", "keyword_set"] + +@dataclass +class MemoryCandidate: + kind: MemoryKind + content: str + confidence: float = 0.6 + scope_type: str = "global" # "global", "track", "project", "paper" + scope_id: str = "" +``` + +From src/paperbot/infrastructure/stores/memory_store.py: +```python +class SqlAlchemyMemoryStore: + def __init__(self, db_url=None): ... # defaults to get_db_url() + def add_memories(self, user_id: str, memories: List[MemoryCandidate]) -> Tuple[int, int, List]: + """Returns (created_count, skipped_count, created_rows)""" +``` + +From src/paperbot/infrastructure/exporters/obsidian_exporter.py: +```python +def _yaml_frontmatter(metadata: dict) -> str: + """Returns YAML frontmatter string with --- delimiters.""" + +class ObsidianFilesystemExporter: + def __init__(self): ... + def _render_paper_note(self, template_path, title, abstract, metadata_rows, + track_link, external_links, related_links, reference_links, + cited_by_links, paper, track, related_titles) -> str: + """Returns rendered markdown body (no frontmatter). No filesystem I/O.""" +``` + +From src/paperbot/mcp/tools/paper_judge.py (established pattern): +```python +_judge = None + +def _get_judge(): + global _judge + if _judge is None: + from paperbot.application.workflows.analysis.paper_judge import PaperJudge + _judge = PaperJudge() + return _judge + +async def _paper_judge_impl(...) -> dict: + start = time.monotonic() + try: + result = await anyio.to_thread.run_sync(lambda: ...) + log_tool_call(...) + return output + except Exception as exc: + log_tool_call(..., error=str(exc)) + raise + +def register(mcp) -> None: + @mcp.tool() + async def paper_judge(...) -> dict: + """Docstring.""" + return await _paper_judge_impl(...) +``` + + + + + + + Task 1: Implement get_research_context, save_to_memory, and export_to_obsidian MCP tools with TDD + + src/paperbot/mcp/tools/get_research_context.py, + src/paperbot/mcp/tools/save_to_memory.py, + src/paperbot/mcp/tools/export_to_obsidian.py, + tests/unit/test_mcp_get_research_context.py, + tests/unit/test_mcp_save_to_memory.py, + tests/unit/test_mcp_export_to_obsidian.py + + + get_research_context tests: + - test_returns_context_pack_dict: _get_research_context_impl with fake ContextEngine returning {"papers": [...], "memories": [], "track": None, "stage": "explore"} -> result has those keys + - test_accepts_user_id_and_track_id: _get_research_context_impl passes user_id="custom" and track_id=42 through to engine + - test_logs_call_via_log_tool_call: logs event with tool="get_research_context", workflow="mcp" + + save_to_memory tests: + - test_saves_content_and_returns_counts: _save_to_memory_impl with fake MemoryStore -> returns {"saved": True, "created": 1, "skipped": 0} + - test_handles_invalid_kind_gracefully: _save_to_memory_impl with kind="invalid_kind" -> returns {"saved": False, "error": "..."} or defaults to "note" + - test_logs_call_via_log_tool_call: logs event with tool="save_to_memory", workflow="mcp" + + export_to_obsidian tests: + - test_returns_dict_with_markdown_key: _export_to_obsidian_impl with fake exporter -> returns {"markdown": str} + - test_markdown_contains_frontmatter_and_title: markdown output contains "---" (YAML delimiters) and paper title + - test_logs_call_via_log_tool_call: logs event with tool="export_to_obsidian", workflow="mcp" + + + **RED phase:** Write tests first for all three tools (9 tests total, 3 per tool). + + For get_research_context tests (tests/unit/test_mcp_get_research_context.py): + - Create _FakeContextEngine class with async `build_context_pack(user_id, query, track_id=None)` returning a canned dict {"papers": [{"title": "Test"}], "memories": [], "track": None, "stage": "explore", "routing_suggestion": "default"} + - Spy variant that records call args to verify user_id/track_id passthrough + - Inject via `mod._engine = fake_engine`, reset in finally + - Use InMemoryEventLog for audit test + + For save_to_memory tests (tests/unit/test_mcp_save_to_memory.py): + - Create _FakeMemoryStore class with `add_memories(user_id, memories)` that records calls and returns (1, 0, [{"id": 1}]) + - Test normal save with kind="note", content="Finding X" + - Test invalid kind (e.g. "research_note") -- tool should validate against MemoryKind and either default to "note" or return error + - Inject via `mod._store = fake_store`, reset in finally + + For export_to_obsidian tests (tests/unit/test_mcp_export_to_obsidian.py): + - Create _FakeExporter class with `_render_paper_note(...)` returning "# Paper Title\n\nAbstract text here." + - Test that result is {"markdown": str} and markdown contains frontmatter delimiters "---" + - Test that markdown contains the paper title + - Inject via `mod._exporter = fake_exporter`, reset in finally. Also need to handle _yaml_frontmatter -- either mock it or let the real one run (it's a pure function, safe to use). + + **GREEN phase:** Implement all three tool modules. + + For get_research_context.py: + - Module-level `_engine = None` + `_get_engine()` lazy singleton constructing ContextEngine(config=ContextEngineConfig(offline=True, paper_limit=0)) -- default to offline for fast, side-effect-free tool calls + - `_get_research_context_impl(query: str, user_id: str = "default", track_id: Optional[int] = None, _run_id: str = "") -> dict` + - Directly `await engine.build_context_pack(user_id=user_id, query=query, track_id=track_id)` -- no anyio wrapping needed (already async) + - Return the result dict directly + - `register(mcp)` with docstring: "Retrieve research context for a query, including relevant papers and memories." + + For save_to_memory.py: + - Module-level `_store = None` + `_get_store()` lazy singleton constructing SqlAlchemyMemoryStore() + - `_save_to_memory_impl(content: str, kind: str = "note", user_id: str = "default", scope_type: str = "global", scope_id: str = "", confidence: float = 0.8, _run_id: str = "") -> dict` + - Validate kind against allowed MemoryKind values: ("profile", "preference", "goal", "project", "constraint", "todo", "fact", "note", "decision", "hypothesis", "keyword_set"). If invalid, default to "note" and log a warning. + - Construct MemoryCandidate(kind=kind, content=content, confidence=confidence, scope_type=scope_type, scope_id=scope_id) + - Use `anyio.to_thread.run_sync(lambda: store.add_memories(user_id=user_id, memories=[candidate]))` for sync wrapping + - Return: {"saved": True, "created": created_count, "skipped": skipped_count} + - `register(mcp)` with docstring: "Save research findings to memory for later retrieval." + + For export_to_obsidian.py: + - Module-level `_exporter = None` + `_get_exporter()` lazy singleton constructing ObsidianFilesystemExporter() + - `_export_to_obsidian_impl(title: str, abstract: str, authors: List[str] = [], year: Optional[int] = None, venue: str = "", arxiv_id: str = "", doi: str = "", _run_id: str = "") -> dict` + - Build metadata_rows list from authors, year, venue + - Build external_links from arxiv_id and doi if provided + - Build paper dict: {"title": title, "abstract": abstract, "authors": authors, "year": year, "venue": venue, "arxiv_id": arxiv_id, "doi": doi} + - Use `anyio.to_thread.run_sync(lambda: exporter._render_paper_note(template_path=None, title=title, abstract=abstract, metadata_rows=metadata_rows, track_link=None, external_links=external_links, related_links=[], reference_links=[], cited_by_links=[], paper=paper, track=None, related_titles=[]))` -- note the private method call is intentional (documented in research) + - Import `_yaml_frontmatter` from obsidian_exporter and build frontmatter with {"title": title, "paperbot_type": "paper", "authors": authors} + - Return: {"markdown": frontmatter + body} + - `register(mcp)` with docstring: "Export a paper as Obsidian-formatted markdown with YAML frontmatter." + + All three tools: use `from __future__ import annotations`, import logging, time, log_tool_call. Follow try/except pattern with log_tool_call in both success and error paths. Use `@pytest.mark.asyncio` on all async tests. + + + PYTHONPATH=src pytest tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py -v + + + - get_research_context returns context pack dict from ContextEngine (offline mode default) + - save_to_memory persists content via MemoryCandidate + MemoryStore, returns created/skipped counts + - save_to_memory validates kind against MemoryKind allowed values + - export_to_obsidian returns markdown string with YAML frontmatter, no filesystem I/O + - All three tools log via audit helper with correct tool names + - 9 unit tests passing + + + + + + +PYTHONPATH=src pytest tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py -v + + + +- get_research_context.py, save_to_memory.py, export_to_obsidian.py exist in src/paperbot/mcp/tools/ with _impl + register pattern +- 9 unit tests pass covering normal, error/edge case, and audit paths for all three tools +- All three tools use module-level lazy singleton pattern +- get_research_context uses direct await (async native), save_to_memory and export_to_obsidian use anyio.to_thread.run_sync() +- save_to_memory validates MemoryKind values +- export_to_obsidian uses in-memory rendering (no filesystem writes) + + + +After completion, create `.planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md` + diff --git a/.planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md b/.planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md new file mode 100644 index 00000000..3f8cef7b --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 03-remaining-mcp-tools +plan: 02 +subsystem: mcp +tags: [mcp, fastmcp, context-engine, memory-store, obsidian, anyio, tdd] + +# Dependency graph +requires: + - phase: 02-core-paper-tools + provides: "MCP tool pattern (lazy singleton, _impl, register, log_tool_call audit)" + - phase: 03-remaining-mcp-tools/03-01 + provides: "Phase 3 TDD pattern established for analyze_trends and check_scholar" +provides: + - "get_research_context MCP tool wrapping ContextEngine.build_context_pack() in offline mode" + - "save_to_memory MCP tool wrapping SqlAlchemyMemoryStore.add_memories() via anyio.to_thread" + - "export_to_obsidian MCP tool with in-memory markdown rendering via ObsidianFilesystemExporter" + - "9 unit tests covering normal, edge case, and audit paths for all three tools" +affects: [mcp-server-registration, phase-04, phase-05, phase-06] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Direct async await for ContextEngine (already async-native, no anyio wrapper needed)" + - "anyio.to_thread.run_sync() for synchronous store and exporter methods" + - "MemoryKind validation with silent default to 'note' on invalid input" + - "In-memory Obsidian rendering by calling _render_paper_note() + _yaml_frontmatter() directly" + +key-files: + created: + - src/paperbot/mcp/tools/get_research_context.py + - src/paperbot/mcp/tools/save_to_memory.py + - src/paperbot/mcp/tools/export_to_obsidian.py + - tests/unit/test_mcp_get_research_context.py + - tests/unit/test_mcp_save_to_memory.py + - tests/unit/test_mcp_export_to_obsidian.py + modified: [] + +key-decisions: + - "get_research_context uses ContextEngineConfig(offline=True, paper_limit=0) as default to avoid side effects during tool calls" + - "save_to_memory defaults invalid MemoryKind values to 'note' with a logger.warning rather than raising an error" + - "export_to_obsidian uses _render_paper_note() private method directly (documented in research as intentional) with no filesystem I/O" + +patterns-established: + - "Async-native tools: use direct await; sync-wrapped tools: use anyio.to_thread.run_sync()" + - "Kind validation pattern: frozenset of allowed values, silent default with warning on invalid input" + - "In-memory export pattern: call renderer method directly and return markdown string without filesystem writes" + +requirements-completed: [MCP-03, MCP-04, MCP-05] + +# Metrics +duration: 2min +completed: 2026-03-14 +--- + +# Phase 3 Plan 2: Remaining MCP Tools Summary + +**Three MCP tools added: get_research_context (async ContextEngine), save_to_memory (anyio-wrapped MemoryStore with MemoryKind validation), and export_to_obsidian (in-memory Jinja2 rendering with YAML frontmatter)** + +## Performance + +- **Duration:** 2 min +- **Started:** 2026-03-14T04:23:35Z +- **Completed:** 2026-03-14T04:25:54Z +- **Tasks:** 1 (TDD: RED commit + GREEN commit) +- **Files modified:** 6 + +## Accomplishments +- Implemented get_research_context wrapping ContextEngine.build_context_pack() with offline=True default for side-effect-free tool calls +- Implemented save_to_memory with MemoryKind validation (11 allowed values), defaulting invalid input to "note" with a warning +- Implemented export_to_obsidian performing pure in-memory rendering via ObsidianFilesystemExporter._render_paper_note() and _yaml_frontmatter() +- 9 unit tests pass covering: context pack dict return, user_id/track_id passthrough, invalid kind default, frontmatter presence, and audit logging for all three tools + +## Task Commits + +Each task was committed atomically: + +1. **RED phase (failing tests)** - `d82e0d4` (test) +2. **GREEN phase (implementations)** - `302edcf` (feat) + +*Note: TDD task split into RED (failing tests) and GREEN (implementation) commits.* + +## Files Created/Modified +- `src/paperbot/mcp/tools/get_research_context.py` - Async MCP tool wrapping ContextEngine with lazy singleton +- `src/paperbot/mcp/tools/save_to_memory.py` - MCP tool wrapping SqlAlchemyMemoryStore with MemoryKind validation via anyio.to_thread +- `src/paperbot/mcp/tools/export_to_obsidian.py` - MCP tool for in-memory Obsidian markdown rendering via anyio.to_thread +- `tests/unit/test_mcp_get_research_context.py` - 3 tests: context pack, passthrough, audit +- `tests/unit/test_mcp_save_to_memory.py` - 3 tests: counts, invalid kind, audit +- `tests/unit/test_mcp_export_to_obsidian.py` - 3 tests: markdown key, frontmatter+title, audit + +## Decisions Made +- **ContextEngine defaults to offline mode:** `ContextEngineConfig(offline=True, paper_limit=0)` prevents network calls during MCP tool execution by default; callers can override via environment if needed +- **Invalid MemoryKind defaults silently to 'note':** Raising a validation error would break agent workflows passing loose strings; warning + default is safer for MCP tool callers +- **Private method _render_paper_note() called directly:** This is intentional per plan research notes - it performs pure template rendering with no filesystem I/O, making it safe for MCP tool use + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- 9 MCP tools now implemented (paper_judge, paper_search, paper_summarize, relevance, analyze_trends, check_scholar + the 3 from this plan) +- All tools follow consistent lazy singleton + _impl + register + log_tool_call pattern +- Ready for Phase 3 Plan 3 (MCP server registration) to wire all tools into a single FastMCP server + +## Self-Check: PASSED + +All artifacts verified: +- FOUND: src/paperbot/mcp/tools/get_research_context.py +- FOUND: src/paperbot/mcp/tools/save_to_memory.py +- FOUND: src/paperbot/mcp/tools/export_to_obsidian.py +- FOUND: tests/unit/test_mcp_get_research_context.py +- FOUND: tests/unit/test_mcp_save_to_memory.py +- FOUND: tests/unit/test_mcp_export_to_obsidian.py +- FOUND: .planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md +- FOUND: d82e0d4 (RED commit) +- FOUND: 302edcf (GREEN commit) + +--- +*Phase: 03-remaining-mcp-tools* +*Completed: 2026-03-14* diff --git a/.planning/phases/03-remaining-mcp-tools/03-03-PLAN.md b/.planning/phases/03-remaining-mcp-tools/03-03-PLAN.md new file mode 100644 index 00000000..20327b56 --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-03-PLAN.md @@ -0,0 +1,213 @@ +--- +phase: 03-remaining-mcp-tools +plan: 03 +type: execute +wave: 2 +depends_on: ["03-01", "03-02"] +files_modified: + - src/paperbot/mcp/server.py + - tests/integration/test_mcp_tool_calls.py +autonomous: true +requirements: [MCP-01, MCP-02, MCP-03, MCP-04, MCP-05] + +must_haves: + truths: + - "All 9 MCP tools appear in tools/list (server.py registers all 9)" + - "All 9 tools log calls via audit helper with consistent event structure" + - "Integration tests verify discovery, schema, invocation, and audit for all 9 tools" + artifacts: + - path: "src/paperbot/mcp/server.py" + provides: "MCP server with all 9 tools registered" + contains: "analyze_trends.register" + - path: "tests/integration/test_mcp_tool_calls.py" + provides: "Integration tests for all 9 MCP tools" + min_lines: 200 + key_links: + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/analyze_trends.py" + via: "import + register(mcp)" + pattern: "analyze_trends\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/check_scholar.py" + via: "import + register(mcp)" + pattern: "check_scholar\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/get_research_context.py" + via: "import + register(mcp)" + pattern: "get_research_context\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/save_to_memory.py" + via: "import + register(mcp)" + pattern: "save_to_memory\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/tools/export_to_obsidian.py" + via: "import + register(mcp)" + pattern: "export_to_obsidian\\.register\\(mcp\\)" +--- + + +Register all 5 new tools in the MCP server and update integration tests to verify all 9 tools. + +Purpose: Wire the tools from Plans 01 and 02 into the server and extend integration tests from 4 tools to 9. This is the final wiring step that makes Phase 3 tools discoverable via MCP tools/list. + +Output: Updated server.py with 9 tool registrations, updated integration tests covering discovery, schema, invocation, and audit for all 9 tools. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md +@.planning/phases/02-core-paper-tools/02-03-SUMMARY.md +@.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md +@.planning/phases/03-remaining-mcp-tools/03-02-SUMMARY.md + + + +From src/paperbot/mcp/server.py (current): +```python +try: + from mcp.server.fastmcp import FastMCP + mcp = FastMCP("paperbot") + + from paperbot.mcp.tools import paper_search, paper_judge, paper_summarize, relevance + paper_search.register(mcp) + paper_judge.register(mcp) + paper_summarize.register(mcp) + relevance.register(mcp) +except ImportError: + logger.debug("mcp package not installed; MCP server unavailable") + mcp = None +``` + + +From tests/integration/test_mcp_tool_calls.py: +```python +EXPECTED_TOOLS = ["paper_search", "paper_judge", "paper_summarize", "relevance_assess"] +``` + + +- src/paperbot/mcp/tools/analyze_trends.py: _analyze_trends_impl, register +- src/paperbot/mcp/tools/check_scholar.py: _check_scholar_impl, register +- src/paperbot/mcp/tools/get_research_context.py: _get_research_context_impl, register +- src/paperbot/mcp/tools/save_to_memory.py: _save_to_memory_impl, register +- src/paperbot/mcp/tools/export_to_obsidian.py: _export_to_obsidian_impl, register + + + + + + + Task 1: Register 5 new tools in server.py + src/paperbot/mcp/server.py + + Add 5 new imports and register() calls to server.py, inside the existing try block, after the 4 existing tool registrations: + + ```python + from paperbot.mcp.tools import analyze_trends + from paperbot.mcp.tools import check_scholar + from paperbot.mcp.tools import get_research_context + from paperbot.mcp.tools import save_to_memory + from paperbot.mcp.tools import export_to_obsidian + + analyze_trends.register(mcp) + check_scholar.register(mcp) + get_research_context.register(mcp) + save_to_memory.register(mcp) + export_to_obsidian.register(mcp) + ``` + + Keep existing imports and registrations unchanged. Total should be 9 tools registered. + + + PYTHONPATH=src python -c "import paperbot.mcp.server; print('server.py imports OK')" + + server.py imports and registers all 9 tools without import errors + + + + Task 2: Update integration tests for all 9 tools + tests/integration/test_mcp_tool_calls.py + + Extend the existing integration test file to cover all 9 tools. Changes: + + 1. Update EXPECTED_TOOLS from 4 to 9: + ```python + EXPECTED_TOOLS = [ + "paper_search", "paper_judge", "paper_summarize", "relevance_assess", + "analyze_trends", "check_scholar", "get_research_context", + "save_to_memory", "export_to_obsidian", + ] + ``` + + 2. Add fake classes for the 5 new tools: + - _FakeTrendAnalyzer: `analyze(topic, items)` returns "Trend analysis result" + - _FakeS2Client: async `search_authors()` returns [{"authorId": "123", "name": "Scholar", "hIndex": 10, "paperCount": 50, "citationCount": 1000}], async `get_author_papers()` returns [{"title": "Paper", "year": 2024, "citationCount": 5, "venue": "ICML"}] + - _FakeContextEngine: async `build_context_pack(user_id, query, track_id=None)` returns {"papers": [], "memories": [], "track": None, "stage": "explore"} + - _FakeMemoryStore: `add_memories(user_id, memories)` returns (1, 0, []) + - _FakeExporter: `_render_paper_note(...)` returns "# Title\n\nBody text" + + 3. In TestMCPToolListing: + - Update test_all_four_tools_listed -> test_all_nine_tools_listed: add the 5 new modules and verify register() + _impl for each + - Update test_server_registers_all_four_tools -> test_server_registers_all_nine_tools: check for all 9 register() calls in server source + - Update test_each_tool_has_input_schema_via_signature: add 5 new _impl functions + + 4. In TestMCPToolSchemas: + - Add test_analyze_trends_tool_has_correct_params: topic (required, str), papers (required, list), _run_id (optional) + - Add test_check_scholar_tool_has_correct_params: scholar_name (required, str), max_papers (optional, int, default 10), _run_id (optional) + - Add test_get_research_context_tool_has_correct_params: query (required, str), user_id (optional, default "default"), track_id (optional), _run_id (optional) + - Add test_save_to_memory_tool_has_correct_params: content (required, str), kind (optional, default "note"), user_id (optional), scope_type (optional), confidence (optional), _run_id (optional) + - Add test_export_to_obsidian_tool_has_correct_params: title (required, str), abstract (required, str), authors (optional), year (optional), _run_id (optional) + + 5. In TestMCPToolInvocation: + - Add test_tool_call_analyze_trends_via_impl: inject _FakeTrendAnalyzer, call _analyze_trends_impl, verify result has "trend_analysis" key + - Add test_tool_call_check_scholar_via_impl: inject _FakeS2Client, call _check_scholar_impl, verify result has "scholar" and "recent_papers" + - Add test_tool_call_get_research_context_via_impl: inject _FakeContextEngine, call _get_research_context_impl, verify result has "papers" key + - Add test_tool_call_save_to_memory_via_impl: inject _FakeMemoryStore, call _save_to_memory_impl, verify result has "saved" key + - Add test_tool_call_export_to_obsidian_via_impl: inject _FakeExporter, call _export_to_obsidian_impl, verify result has "markdown" key + + 6. In TestMCPToolEventLogging: + - Add test_tool_call_analyze_trends_logs_event + - Add test_tool_call_check_scholar_logs_event + - Add test_tool_call_get_research_context_logs_event + - Add test_tool_call_save_to_memory_logs_event + - Add test_tool_call_export_to_obsidian_logs_event + - Update test_all_tool_events_have_consistent_structure to include all 9 tools + + Follow exact same patterns as existing tests: setup_method resets Container._instance, inject fakes via module-level singletons, reset in finally blocks, @pytest.mark.asyncio on all async tests. + + + PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -v + + + - EXPECTED_TOOLS lists all 9 tools + - All 9 tools verified discoverable (register + _impl) + - All 9 tools have schema tests validating parameter types and defaults + - All 9 tools have invocation tests via _impl functions + - All 9 tools have event logging tests + - Consistent structure test covers all 9 tools + - All integration tests pass + + + + + + +PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py tests/integration/test_mcp_tool_calls.py -v + + + +- server.py registers all 9 MCP tools (4 existing + 5 new) +- Integration tests cover all 9 tools across 4 test classes (listing, schema, invocation, logging) +- All unit tests (15 from Plans 01+02) and all integration tests pass together +- Phase 3 gate: `PYTHONPATH=src pytest tests/unit/test_mcp_*.py tests/integration/test_mcp_tool_calls.py -q` all green + + + +After completion, create `.planning/phases/03-remaining-mcp-tools/03-03-SUMMARY.md` + diff --git a/.planning/phases/03-remaining-mcp-tools/03-03-SUMMARY.md b/.planning/phases/03-remaining-mcp-tools/03-03-SUMMARY.md new file mode 100644 index 00000000..cc38d73c --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-03-SUMMARY.md @@ -0,0 +1,114 @@ +--- +phase: 03-remaining-mcp-tools +plan: 03 +subsystem: mcp +tags: [mcp, fastmcp, server-registration, integration-tests, tdd] + +# Dependency graph +requires: + - phase: 03-remaining-mcp-tools/03-01 + provides: analyze_trends and check_scholar MCP tool implementations + - phase: 03-remaining-mcp-tools/03-02 + provides: get_research_context, save_to_memory, export_to_obsidian MCP tool implementations +provides: + - "MCP server registering all 9 tools via FastMCP (paper_search, paper_judge, paper_summarize, relevance, analyze_trends, check_scholar, get_research_context, save_to_memory, export_to_obsidian)" + - "Integration test suite with 31 tests covering discovery, schema, invocation, and audit logging for all 9 tools" +affects: [phase-04, phase-05, phase-06, mcp-client-integration] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "All 9 MCP tools share consistent lazy singleton + _impl + register + log_tool_call pattern" + - "Integration tests inject module-level singletons directly (not DI container) for tool isolation" + - "4-class test structure: Listing, Schemas, Invocation, EventLogging" + +key-files: + created: + - tests/integration/test_mcp_tool_calls.py + modified: + - src/paperbot/mcp/server.py + - tests/integration/test_mcp_tool_calls.py + +key-decisions: + - "No architectural changes needed: 5 new tools follow exactly the same registration pattern as existing 4" + - "Integration tests inject fakes via module-level _var singletons (same pattern as unit tests), not via DI container" + - "Consistent structure test validates all 9 tools emit workflow='mcp', stage='tool_call', agent_name='paperbot-mcp' with duration_ms" + +patterns-established: + - "MCP server registration: single try/import block, all tools imported and registered in sequence" + - "Integration test 4-class pattern: TestMCPToolListing, TestMCPToolSchemas, TestMCPToolInvocation, TestMCPToolEventLogging" + +requirements-completed: [MCP-01, MCP-02, MCP-03, MCP-04, MCP-05] + +# Metrics +duration: 5min +completed: 2026-03-14 +--- + +# Phase 03 Plan 03: MCP Server Registration + Integration Tests Summary + +**9-tool FastMCP server with 31 integration tests covering discovery, schema validation, invocation, and audit logging — completing Phase 3's MCP tool surface** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-03-14T04:27:05Z +- **Completed:** 2026-03-14T04:32:00Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments + +- Registered all 5 new tools (analyze_trends, check_scholar, get_research_context, save_to_memory, export_to_obsidian) in server.py alongside existing 4 — 9 total discoverable via MCP tools/list +- Extended integration test file from 4 tools / ~16 tests to 9 tools / 31 tests with full coverage across all 4 test classes (Listing, Schemas, Invocation, EventLogging) +- Phase 3 gate passes: 46 tests total (15 unit + 31 integration), all green + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Register 5 new tools in server.py** - `e16b2e0` (feat) +2. **Task 2: Extend integration tests to all 9 tools** - `f1b8828` (feat) + +## Files Created/Modified + +- `src/paperbot/mcp/server.py` - Added 5 new import+register calls; now registers all 9 MCP tools in single FastMCP server +- `tests/integration/test_mcp_tool_calls.py` - Expanded from ~16 to 31 tests covering all 9 tools across 4 test classes; added fakes for _FakeTrendAnalyzer, _FakeS2Client, _FakeContextEngine, _FakeMemoryStore, _FakeExporter + +## Decisions Made + +- No architectural changes were required: the 5 new tools follow exactly the same import-and-register pattern as the original 4, making server.py extension trivial +- Integration test fakes inject directly into module-level singletons (e.g., `at_mod._analyzer = _FakeTrendAnalyzer()`) rather than via DI container, keeping the same isolation pattern as the unit tests from Plans 01 and 02 +- The consistent structure test validates all 9 tools together in a single call sequence, confirming event log shape (workflow/stage/agent_name/payload/metrics) is maintained across the entire tool surface + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- All 9 Phase 3 MCP tools are registered, tested, and auditable +- Phase 3 is complete: Plans 01 (analyze_trends, check_scholar), 02 (get_research_context, save_to_memory, export_to_obsidian), 03 (server wiring + integration tests) all done +- Requirements MCP-01 through MCP-05 all completed +- Ready for Phase 4 (Scholar Tracking) or any follow-on MCP work + +## Self-Check: PASSED + +- FOUND: src/paperbot/mcp/server.py (with 9 registrations) +- FOUND: tests/integration/test_mcp_tool_calls.py (31 tests) +- FOUND commit: e16b2e0 (feat - Task 1: server registration) +- FOUND commit: f1b8828 (feat - Task 2: integration tests) +- Phase gate: 46/46 tests PASSED + +--- +*Phase: 03-remaining-mcp-tools* +*Completed: 2026-03-14* diff --git a/.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md b/.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md new file mode 100644 index 00000000..c627cad3 --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md @@ -0,0 +1,525 @@ +# Phase 3: Remaining MCP Tools - Research + +**Researched:** 2026-03-14 +**Domain:** FastMCP tool registration, PaperBot application services (TrendAnalyzer, SemanticScholarClient, ContextEngine, MemoryStore, ObsidianExporter) +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| MCP-01 | Agent can analyze trends across a set of papers via `analyze_trends` MCP tool | `TrendAnalyzer.analyze()` wraps `LLMService.analyze_trends()` — sync, needs anyio wrap; accepts `topic: str` + `items: Sequence[Dict]` | +| MCP-02 | Agent can check a scholar's recent publications via `check_scholar` MCP tool | `SemanticScholarClient.search_authors()` + `get_author_papers()` — async native; fallback to name-based search when no S2 ID | +| MCP-03 | Agent can retrieve research context for a track via `get_research_context` MCP tool | `ContextEngine.build_context_pack()` — async native; requires `user_id`, `query`, optional `track_id` | +| MCP-04 | Agent can save research findings to memory via `save_to_memory` MCP tool | `SqlAlchemyMemoryStore.add_memories()` — sync; accepts `MemoryCandidate` objects; needs anyio wrap | +| MCP-05 | Agent can export papers/notes to Obsidian format via `export_to_obsidian` MCP tool | `ObsidianFilesystemExporter._render_paper_note()` returns markdown string without filesystem I/O; wrap as in-memory renderer | + + +--- + +## Summary + +Phase 3 adds five MCP tools (`analyze_trends`, `check_scholar`, `get_research_context`, `save_to_memory`, `export_to_obsidian`) to bring the total registered tool count to 9. All five tools follow the same pattern established in Phase 2: a module file under `src/paperbot/mcp/tools/`, an async `__impl()` function for testability, a `register(mcp)` function for FastMCP, and audit logging via `log_tool_call()`. + +Each tool wraps an existing application-layer service. The services vary in calling convention: `TrendAnalyzer.analyze()` and `SqlAlchemyMemoryStore.add_memories()` are synchronous (use `anyio.to_thread.run_sync()`). `ContextEngine.build_context_pack()` and `SemanticScholarClient` methods are already async. `ObsidianFilesystemExporter._render_paper_note()` is synchronous but can be called directly via thread to produce a markdown string without touching the filesystem. + +The key design choice for `export_to_obsidian` is to avoid requiring a real vault path at MCP call time. Instead, render the markdown in-memory and return it as a string — consistent with how an AI agent consumes the tool (it reads the markdown, it doesn't manage a filesystem). This is a clean divergence from the `ObsidianFilesystemExporter.export_library_snapshot()` API which requires an existing directory. + +**Primary recommendation:** Follow Phase 2 patterns exactly. One file per tool, `_impl` function + `register()`, anyio wrapping for sync services, degraded detection for LLM-dependent tools, log_tool_call() in all paths. + +--- + +## Standard Stack + +### Core (already installed) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `mcp[fastmcp]` | `>=1.8.0,<2.0.0` | FastMCP server + `@mcp.tool()` decorator | Established in Phase 1 | +| `anyio` | existing | Sync-to-async bridge via `to_thread.run_sync()` | Established in Phase 2 | +| `jinja2` | existing | Template rendering in `ObsidianFilesystemExporter._render_paper_note()` | Already a project dep | +| `yaml` (PyYAML) | existing | YAML frontmatter generation in `_yaml_frontmatter()` | Already a project dep | + +### No New Dependencies +All five tools wrap existing services. Zero new packages required. + +**Installation:** No changes needed. + +--- + +## Architecture Patterns + +### Recommended File Structure (Phase 3 additions) +``` +src/paperbot/mcp/tools/ +├── _audit.py # existing: shared audit helper +├── paper_search.py # existing (Phase 2) +├── paper_judge.py # existing (Phase 2) +├── paper_summarize.py # existing (Phase 2) +├── relevance.py # existing (Phase 2) +├── analyze_trends.py # NEW: wraps TrendAnalyzer +├── check_scholar.py # NEW: wraps SemanticScholarClient +├── get_research_context.py# NEW: wraps ContextEngine +├── save_to_memory.py # NEW: wraps SqlAlchemyMemoryStore +└── export_to_obsidian.py # NEW: wraps ObsidianFilesystemExporter renderer + +tests/unit/ +├── test_mcp_analyze_trends.py +├── test_mcp_check_scholar.py +├── test_mcp_get_research_context.py +├── test_mcp_save_to_memory.py +└── test_mcp_export_to_obsidian.py + +tests/integration/ +└── test_mcp_tool_calls.py # existing: add Phase 3 tools to discovery test +``` + +### Pattern 1: Sync service wrapped with anyio (established by Phase 2) +**What:** Wrap a blocking synchronous service call in `anyio.to_thread.run_sync(lambda: ...)` inside an async `_impl` function. +**When to use:** `TrendAnalyzer.analyze()`, `SqlAlchemyMemoryStore.add_memories()` + +```python +# Source: src/paperbot/mcp/tools/paper_judge.py (established pattern) +async def _analyze_trends_impl(topic: str, papers: List[Dict[str, Any]], _run_id: str = "") -> dict: + start = time.monotonic() + analyzer = _get_analyzer() + try: + result = await anyio.to_thread.run_sync( + lambda: analyzer.analyze(topic=topic, items=papers) + ) + # ... audit log + return + except Exception as exc: + # ... audit log with error + raise +``` + +### Pattern 2: Native async service (no wrapping needed) +**What:** Call an already-async service method directly with `await`. +**When to use:** `ContextEngine.build_context_pack()`, `SemanticScholarClient.search_authors()`, `SemanticScholarClient.get_author_papers()` + +```python +# Source: src/paperbot/context_engine/engine.py line 799 +# build_context_pack() is already async +async def _get_research_context_impl(user_id: str, query: str, track_id: Optional[int] = None, _run_id: str = "") -> dict: + engine = _get_context_engine() + result = await engine.build_context_pack(user_id=user_id, query=query, track_id=track_id) + # result is a Dict[str, Any] with keys: papers, memories, track, etc. +``` + +### Pattern 3: Module-level lazy singleton +**What:** Module-level `_service = None` + `_get_service()` helper that constructs on first call. +**When to use:** All 5 new tools (mirrors Phase 2 pattern). +**Why:** Enables test injection via `mod._service = fake_service` without FastMCP. + +### Pattern 4: In-memory Obsidian rendering (new for Phase 3) +**What:** Call `ObsidianFilesystemExporter._render_paper_note()` without writing to disk. +**When to use:** `export_to_obsidian` tool — agent needs the markdown string, not a file. + +```python +# Source: src/paperbot/infrastructure/exporters/obsidian_exporter.py line 519 +# _render_paper_note() returns a str; no filesystem I/O +async def _export_to_obsidian_impl(title: str, abstract: str, authors: List[str] = [], ...) -> dict: + exporter = _get_exporter() + paper = {"title": title, "abstract": abstract, "authors": authors, ...} + # Call _render_paper_note via thread (it is sync) + markdown = await anyio.to_thread.run_sync( + lambda: exporter._render_paper_note( + template_path=None, + title=title, + abstract=abstract, + metadata_rows=[...], + track_link=None, + external_links=[], + related_links=[], + reference_links=[], + cited_by_links=[], + paper=paper, + track=None, + related_titles=[], + ) + ) + frontmatter = _yaml_frontmatter({...}) # from obsidian_exporter module + return {"markdown": frontmatter + markdown} +``` + +### Pattern 5: MemoryCandidate construction for save_to_memory +**What:** Accept flat string inputs from MCP caller, construct `MemoryCandidate` dataclass internally. +**When to use:** `save_to_memory` tool. + +```python +# Source: src/paperbot/memory/schema.py +# MemoryKind is a Literal with allowed values +from paperbot.memory.schema import MemoryCandidate, MemoryKind + +candidate = MemoryCandidate( + kind=kind, # e.g. "note", "fact", "hypothesis", "decision" + content=content, + confidence=confidence, # float 0.0-1.0, default 0.6 + scope_type=scope_type, # "global", "track", "project", "paper" + scope_id=scope_id, # optional: track_id as str +) +memory_store.add_memories(user_id=user_id, memories=[candidate]) +``` + +### Anti-Patterns to Avoid + +- **Calling `ObsidianFilesystemExporter.export_library_snapshot()` from the MCP tool**: requires a real vault_path directory on disk — not appropriate for an MCP tool that returns a value to an agent. +- **Constructing `ContextEngine` without defaults**: `ContextEngine()` with no arguments builds `SqlAlchemyResearchStore()` and `SqlAlchemyMemoryStore()` which opens the DB. Fine for production, but tests need to inject fakes via constructor. +- **Passing papers as raw JSON strings to analyze_trends**: `TrendAnalyzer.analyze()` expects `Sequence[Dict[str, Any]]`. The MCP tool should accept a list of dicts (FastMCP will serialize JSON inputs as Python dicts automatically via type hints). +- **Using `Container.instance().resolve()` inside the lazy singleton**: The lazy singleton pattern (module-level `_service`) is simpler and test-friendlier. Reserve `Container` resolution for the `_audit.py` helper only (established pattern). +- **Making `check_scholar` require a Semantic Scholar author_id**: Agents calling from Claude/Codex won't always have an S2 ID — implement a name-search fallback (`SemanticScholarClient.search_authors()` → pick first match → `get_author_papers()`). + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Trend analysis LLM prompt | Custom prompt logic | `TrendAnalyzer.analyze(topic, items)` | Prompt already in `application/prompts/trend_detection.py` | +| Scholar lookup | Custom HTTP to S2 API | `SemanticScholarClient.search_authors()` + `get_author_papers()` | Rate limiting, error handling already implemented | +| Research context assembly | Custom track/memory queries | `ContextEngine.build_context_pack()` | Multi-layer loading, routing, embedding all handled | +| Memory persistence | Direct SQLAlchemy inserts | `SqlAlchemyMemoryStore.add_memories()` | Dedup, PII detection, audit log, hash generation all built in | +| Obsidian markdown | Custom template | `ObsidianFilesystemExporter._render_paper_note()` | Jinja2 template, frontmatter, wikilinks, metadata rows all handled | + +**Key insight:** Every tool in Phase 3 wraps a fully-featured existing service. The MCP tool layer is thin: parameter translation + anyio wrapping + audit logging. + +--- + +## Common Pitfalls + +### Pitfall 1: `check_scholar` makes live HTTP calls in tests +**What goes wrong:** `SemanticScholarClient` makes real HTTP requests to `api.semanticscholar.org` if not faked out. +**Why it happens:** Unlike Phase 2 tools that wrap local services (LLM, search adapters), `check_scholar` uses a network client. +**How to avoid:** Module-level `_client = None` + `_get_client()` pattern; tests inject a fake client (`mod._client = _FakeS2Client()`) before calling `_impl`. Never hit real S2 in unit tests. +**Warning signs:** Tests passing locally but failing in CI offline (`PAPERBOT_OFFLINE=true`). + +### Pitfall 2: `save_to_memory` creates real DB rows if MemoryStore not faked +**What goes wrong:** Unit tests calling `_save_to_memory_impl()` without injecting a fake store will attempt to open `data/paperbot.db`. +**Why it happens:** `SqlAlchemyMemoryStore()` defaults to `get_db_url()` which reads `PAPERBOT_DB_URL` env var or falls back to `sqlite:///data/paperbot.db`. +**How to avoid:** Use `tmp_path` fixture for test DB, or inject a `_FakeMemoryStore` that records calls without DB access. Follow existing pattern from `tests/unit/test_memory_module.py`. +**Warning signs:** `OperationalError: unable to open database file` in test output. + +### Pitfall 3: `ContextEngine` offline mode conflict +**What goes wrong:** `ContextEngine.build_context_pack()` will attempt external search if `config.offline=False` and `config.paper_limit > 0`. +**Why it happens:** Default `ContextEngineConfig()` has `offline=False`. +**How to avoid:** Pass `config=ContextEngineConfig(offline=True, paper_limit=0)` in the MCP tool's `_get_context_engine()` lazy constructor, or accept that the tool returns cached/stored results only when offline. For tests, inject a mock `ContextEngine` directly. +**Warning signs:** `httpx.ConnectError` or network errors during unit tests. + +### Pitfall 4: MemoryCandidate `kind` validation +**What goes wrong:** `MemoryKind` is a `Literal[...]` type — passing an unsupported kind (e.g. `"research_note"`) will cause a type error at runtime in strict-type contexts. +**Why it happens:** The `kind` field is defined as a Literal with fixed values: `"profile"`, `"preference"`, `"goal"`, `"project"`, `"constraint"`, `"todo"`, `"fact"`, `"note"`, `"decision"`, `"hypothesis"`, `"keyword_set"`. +**How to avoid:** Default the MCP tool's `kind` parameter to `"note"` (safe general-purpose kind). Validate or sanitize against the allowed set before constructing `MemoryCandidate`. +**Warning signs:** `TypeError` or silent DB store with unrecognized kind. + +### Pitfall 5: `export_to_obsidian` private method call +**What goes wrong:** Calling `_render_paper_note()` (a private method by Python convention) may break if `ObsidianFilesystemExporter` is refactored. +**Why it happens:** The `ObsidianFilesystemExporter` doesn't have a public "render to string" API — only `export_library_snapshot()` which writes to disk. +**How to avoid:** Accept this as a known coupling point. In the tool, wrap the private call minimally and add a comment noting the dependency. This is the correct trade-off: avoid reimplementing the template renderer, accept the internal coupling. + +### Pitfall 6: `analyze_trends` LLM degraded detection +**What goes wrong:** When no API key is configured, `TrendAnalyzer.analyze()` returns an empty string (same as `PaperSummarizer`). +**Why it happens:** `LLMService.complete()` returns `""` when no provider is available. +**How to avoid:** Mirror `paper_summarize`'s degraded detection: check `if not result or not result.strip()` → set `degraded=True` + error message. + +--- + +## Code Examples + +Verified patterns from existing codebase: + +### analyze_trends: TrendAnalyzer API +```python +# Source: src/paperbot/application/workflows/analysis/trend_analyzer.py +from paperbot.application.workflows.analysis.trend_analyzer import TrendAnalyzer + +analyzer = TrendAnalyzer() # uses get_llm_service() internally +result: str = analyzer.analyze(topic="large language models", items=[ + {"title": "...", "abstract": "..."}, + {"title": "...", "abstract": "..."}, +]) +# result is a raw LLM text string; empty string when LLM unavailable +``` + +### check_scholar: SemanticScholarClient API +```python +# Source: src/paperbot/infrastructure/api_clients/semantic_scholar.py +from paperbot.infrastructure.api_clients.semantic_scholar import SemanticScholarClient + +client = SemanticScholarClient() # optional api_key from env + +# Step 1: find author by name +authors = await client.search_authors("Yoshua Bengio", limit=3, + fields=["name", "authorId", "hIndex", "paperCount", "citationCount"]) +# returns [{"authorId": "...", "name": "...", "hIndex": N, ...}] + +# Step 2: get recent papers +if authors: + papers = await client.get_author_papers(authors[0]["authorId"], limit=10, + fields=["title", "year", "citationCount", "venue", "abstract"]) +# returns [{"title": "...", "year": ..., ...}] +``` + +### get_research_context: ContextEngine API +```python +# Source: src/paperbot/context_engine/engine.py line 799 +from paperbot.context_engine import ContextEngine, ContextEngineConfig + +engine = ContextEngine(config=ContextEngineConfig(offline=True, paper_limit=0)) +result = await engine.build_context_pack( + user_id="default", + query="attention mechanisms", + track_id=None, # uses active track +) +# result keys: papers, memories, track, routing_suggestion, stage, ... +``` + +### save_to_memory: MemoryStore + MemoryCandidate +```python +# Source: src/paperbot/memory/schema.py + src/paperbot/infrastructure/stores/memory_store.py +from paperbot.memory.schema import MemoryCandidate +from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore + +store = SqlAlchemyMemoryStore() +candidate = MemoryCandidate( + kind="note", # one of: note, fact, decision, hypothesis, etc. + content="Key finding: ...", + confidence=0.8, + scope_type="track", + scope_id="42", # track_id as string +) +created, skipped, rows = store.add_memories( + user_id="default", + memories=[candidate], +) +# returns (created_count, skipped_count, created_rows) +``` + +### export_to_obsidian: In-memory rendering +```python +# Source: src/paperbot/infrastructure/exporters/obsidian_exporter.py line 519 +from paperbot.infrastructure.exporters.obsidian_exporter import ( + ObsidianFilesystemExporter, + _yaml_frontmatter, +) + +exporter = ObsidianFilesystemExporter() +body = exporter._render_paper_note( + template_path=None, + title="Paper Title", + abstract="Paper abstract...", + metadata_rows=["Authors: A, B", "Year: 2024", "Venue: NeurIPS"], + track_link=None, + external_links=[], + related_links=[], + reference_links=[], + cited_by_links=[], + paper={"title": "Paper Title", "abstract": "..."}, + track=None, + related_titles=[], +) +frontmatter = _yaml_frontmatter({"title": "Paper Title", "paperbot_type": "paper"}) +markdown = frontmatter + body +``` + +### server.py registration pattern (what Plan 03 adds) +```python +# Source: src/paperbot/mcp/server.py (current state after Phase 2) +# Phase 3 adds these 5 lines: +from paperbot.mcp.tools import analyze_trends +from paperbot.mcp.tools import check_scholar +from paperbot.mcp.tools import get_research_context +from paperbot.mcp.tools import save_to_memory +from paperbot.mcp.tools import export_to_obsidian + +analyze_trends.register(mcp) +check_scholar.register(mcp) +get_research_context.register(mcp) +save_to_memory.register(mcp) +export_to_obsidian.register(mcp) +``` + +--- + +## Tool Signatures (recommended) + +These are the exact parameter signatures each `_impl` function should expose: + +### `_analyze_trends_impl` +```python +async def _analyze_trends_impl( + topic: str, + papers: List[Dict[str, Any]], + _run_id: str = "", +) -> dict: + # Returns: {"trend_analysis": str, "topic": str, "paper_count": int} + # Degraded: {"degraded": True, "error": "...", "trend_analysis": ""} +``` + +### `_check_scholar_impl` +```python +async def _check_scholar_impl( + scholar_name: str, + max_papers: int = 10, + _run_id: str = "", +) -> dict: + # Returns: {"scholar": {name, authorId, hIndex, paperCount}, "recent_papers": [...]} + # Degraded: {"degraded": True, "error": "Scholar not found", "scholar": None, "recent_papers": []} +``` + +### `_get_research_context_impl` +```python +async def _get_research_context_impl( + query: str, + user_id: str = "default", + track_id: Optional[int] = None, + _run_id: str = "", +) -> dict: + # Returns: ContextEngine.build_context_pack() result dict + # Keys include: papers, memories, track, stage, routing_suggestion +``` + +### `_save_to_memory_impl` +```python +async def _save_to_memory_impl( + content: str, + kind: str = "note", + user_id: str = "default", + scope_type: str = "global", + scope_id: str = "", + confidence: float = 0.8, + _run_id: str = "", +) -> dict: + # Returns: {"saved": True, "created": N, "skipped": N} + # Error: {"saved": False, "error": "..."} +``` + +### `_export_to_obsidian_impl` +```python +async def _export_to_obsidian_impl( + title: str, + abstract: str, + authors: List[str] = [], + year: Optional[int] = None, + venue: str = "", + arxiv_id: str = "", + doi: str = "", + _run_id: str = "", +) -> dict: + # Returns: {"markdown": str} — complete Obsidian note with YAML frontmatter +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Direct tool functions registered inline | `register(mcp)` pattern per module | Phase 2 | Avoids circular imports, enables isolated testing | +| `unittest.mock` | Fake classes (`_FakeLLMService`) | Phase 1 | Matches project test policy (CLAUDE.md) | +| `asyncio.to_thread` | `anyio.to_thread.run_sync()` | Phase 2 | anyio is MCP-compatible event loop agnostic | + +**Current state:** Phase 2 established all patterns. Phase 3 applies them to 5 more services. No new patterns needed. + +--- + +## Open Questions + +1. **`ContextEngine` offline behavior in production** + - What we know: `ContextEngineConfig(offline=True, paper_limit=0)` disables external search + - What's unclear: Should the MCP tool always run offline (return stored context only), or should it optionally trigger a live search? + - Recommendation: Default to offline for the MCP tool to keep tool calls fast and side-effect-free. Agents that want live papers can call `paper_search` first then pass results to `analyze_trends`. + +2. **`save_to_memory` user_id** + - What we know: `MemoryStore.add_memories()` requires `user_id: str` + - What's unclear: MCP callers don't have a user identity system — what `user_id` to use? + - Recommendation: Default `user_id="default"`. This matches how the context engine uses `"default"` for single-user deployments. + +3. **`check_scholar` S2 author name disambiguation** + - What we know: `search_authors()` returns multiple matches for common names + - What's unclear: Should the tool return the first match or all candidates? + - Recommendation: Return the top match (highest hIndex), and include all raw candidates in the response as `"candidates"` key so agents can inspect. + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest with pytest-asyncio (asyncio_mode = "strict") | +| Config file | `pyproject.toml` — `[tool.pytest.ini_options]` | +| Quick run command | `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py -q` | +| Full suite command | `PYTHONPATH=src pytest -q` | + +### Phase Requirements to Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| MCP-01 | `analyze_trends` returns trend analysis string for papers list | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py -x` | ❌ Wave 0 | +| MCP-01 | `analyze_trends` returns degraded=True when LLM unavailable | unit | same | ❌ Wave 0 | +| MCP-01 | `analyze_trends` logs tool call via audit helper | unit | same | ❌ Wave 0 | +| MCP-02 | `check_scholar` returns scholar info + recent papers | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_check_scholar.py -x` | ❌ Wave 0 | +| MCP-02 | `check_scholar` returns degraded when scholar not found | unit | same | ❌ Wave 0 | +| MCP-02 | `check_scholar` logs tool call | unit | same | ❌ Wave 0 | +| MCP-03 | `get_research_context` returns context pack dict | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_get_research_context.py -x` | ❌ Wave 0 | +| MCP-03 | `get_research_context` accepts user_id and track_id | unit | same | ❌ Wave 0 | +| MCP-03 | `get_research_context` logs tool call | unit | same | ❌ Wave 0 | +| MCP-04 | `save_to_memory` persists content and returns saved=True | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_save_to_memory.py -x` | ❌ Wave 0 | +| MCP-04 | `save_to_memory` handles invalid kind gracefully | unit | same | ❌ Wave 0 | +| MCP-04 | `save_to_memory` logs tool call | unit | same | ❌ Wave 0 | +| MCP-05 | `export_to_obsidian` returns dict with `markdown` key | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_export_to_obsidian.py -x` | ❌ Wave 0 | +| MCP-05 | markdown contains YAML frontmatter + paper title + abstract | unit | same | ❌ Wave 0 | +| MCP-05 | `export_to_obsidian` logs tool call | unit | same | ❌ Wave 0 | +| All 9 | All 9 tools listed in tools/list (server.py discovery) | integration | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x` | ✅ (needs update) | +| All 9 | All 9 tools log via audit helper | integration | same | ✅ (needs update) | + +### Sampling Rate +- **Per task commit:** Run the specific tool's unit tests (e.g., `pytest tests/unit/test_mcp_analyze_trends.py -q`) +- **Per wave merge:** `PYTHONPATH=src pytest tests/unit/test_mcp_*.py tests/integration/test_mcp_tool_calls.py -q` +- **Phase gate:** Full CI offline suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/unit/test_mcp_analyze_trends.py` — covers MCP-01 (3 tests) +- [ ] `tests/unit/test_mcp_check_scholar.py` — covers MCP-02 (3 tests) +- [ ] `tests/unit/test_mcp_get_research_context.py` — covers MCP-03 (3 tests) +- [ ] `tests/unit/test_mcp_save_to_memory.py` — covers MCP-04 (3 tests) +- [ ] `tests/unit/test_mcp_export_to_obsidian.py` — covers MCP-05 (3 tests) +- [ ] `tests/integration/test_mcp_tool_calls.py` — update EXPECTED_TOOLS from 4 to 9 + +--- + +## Sources + +### Primary (HIGH confidence) +- `src/paperbot/mcp/server.py` — FastMCP instance, registration pattern +- `src/paperbot/mcp/tools/_audit.py` — `log_tool_call()` API +- `src/paperbot/mcp/tools/paper_judge.py` — anyio wrapping pattern, lazy singleton, degraded detection +- `src/paperbot/mcp/tools/paper_search.py` — async native pattern, `register(mcp)` signature +- `src/paperbot/application/workflows/analysis/trend_analyzer.py` — `TrendAnalyzer.analyze()` API +- `src/paperbot/infrastructure/api_clients/semantic_scholar.py` — `search_authors()`, `get_author_papers()` API +- `src/paperbot/context_engine/engine.py` — `ContextEngine.build_context_pack()` API (async, line 799) +- `src/paperbot/context_engine/engine.py` — `ContextEngineConfig` (line 497) +- `src/paperbot/infrastructure/stores/memory_store.py` — `SqlAlchemyMemoryStore.add_memories()` (line 560) +- `src/paperbot/memory/schema.py` — `MemoryCandidate`, `MemoryKind` +- `src/paperbot/application/ports/memory_port.py` — `MemoryPort` protocol +- `src/paperbot/infrastructure/exporters/obsidian_exporter.py` — `ObsidianFilesystemExporter._render_paper_note()`, `_yaml_frontmatter()` +- `tests/integration/test_mcp_tool_calls.py` — existing test structure to extend +- `tests/unit/test_mcp_paper_judge.py` — fake class pattern, `setup_method` reset +- `.planning/phases/02-core-paper-tools/02-02-SUMMARY.md` — Phase 2 decisions + +### Secondary (MEDIUM confidence) +- `pyproject.toml` — `asyncio_mode = "strict"` confirmed; `@pytest.mark.asyncio` required on all async tests +- `CLAUDE.md` — test patterns: use stub/fake classes, not `unittest.mock` + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — all packages already installed and in use +- Architecture (tool patterns): HIGH — directly reading Phase 2 source +- Service APIs: HIGH — directly reading service source files +- Pitfalls: HIGH — derived from reading actual code (DB URL defaults, S2 HTTP calls, etc.) + +**Research date:** 2026-03-14 +**Valid until:** 2026-04-14 (stable application code) diff --git a/.planning/phases/03-remaining-mcp-tools/03-UAT.md b/.planning/phases/03-remaining-mcp-tools/03-UAT.md new file mode 100644 index 00000000..0fe90e65 --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-UAT.md @@ -0,0 +1,69 @@ +--- +status: testing +phase: 03-remaining-mcp-tools +source: [03-01-SUMMARY.md, 03-02-SUMMARY.md, 03-03-SUMMARY.md] +started: 2026-03-14T05:00:00Z +updated: 2026-03-14T05:00:00Z +--- + +## Current Test + +number: 1 +name: All 9 MCP tools registered in server +expected: | + Run `PYTHONPATH=src python -c "import paperbot.mcp.server; print([t for t in dir(paperbot.mcp.server.mcp) if not t.startswith('_')])"` or inspect server.py — all 9 tools should be importable without errors: paper_search, paper_judge, paper_summarize, relevance, analyze_trends, check_scholar, get_research_context, save_to_memory, export_to_obsidian. +awaiting: user response + +## Tests + +### 1. All 9 MCP tools registered in server +expected: Running `PYTHONPATH=src python -c "import paperbot.mcp.server"` succeeds without import errors. server.py contains 9 `.register(mcp)` calls for all tools. +result: [pending] + +### 2. analyze_trends returns trend analysis +expected: Calling `_analyze_trends_impl(topic="llms", papers=[{"title": "Paper A"}])` with a valid TrendAnalyzer returns a dict with keys `trend_analysis`, `topic`, `paper_count`. Unit test `test_mcp_analyze_trends.py` passes. +result: [pending] + +### 3. analyze_trends degrades gracefully when LLM unavailable +expected: When TrendAnalyzer returns empty string, `_analyze_trends_impl` returns `{"degraded": True, ...}` instead of raising an error. Unit test covers this path. +result: [pending] + +### 4. check_scholar returns scholar info and papers +expected: Calling `_check_scholar_impl(scholar_name="Test")` with a valid S2 client returns a dict with `scholar` (name, authorId, hIndex) and `recent_papers` list. Unit test passes. +result: [pending] + +### 5. check_scholar degrades when scholar not found +expected: When SemanticScholarClient returns empty authors list, `_check_scholar_impl` returns `{"degraded": True, "scholar": None, "recent_papers": []}` instead of crashing. +result: [pending] + +### 6. get_research_context returns context pack +expected: Calling `_get_research_context_impl(query="transformers")` with a ContextEngine returns a dict with `papers`, `memories`, `track`, `stage` keys. Defaults to offline mode. +result: [pending] + +### 7. save_to_memory persists content with kind validation +expected: Calling `_save_to_memory_impl(content="Finding X", kind="note")` returns `{"saved": True, "created": 1, ...}`. Invalid kind (e.g. "research_note") defaults to "note" with a warning instead of erroring. +result: [pending] + +### 8. export_to_obsidian returns markdown with frontmatter +expected: Calling `_export_to_obsidian_impl(title="Paper A", abstract="...")` returns `{"markdown": str}` where the markdown contains YAML frontmatter delimiters `---` and the paper title. No filesystem writes. +result: [pending] + +### 9. All tools log calls via audit helper +expected: Each of the 5 new tools calls `log_tool_call()` in both success and exception paths. Integration test `test_all_tool_events_have_consistent_structure` validates all 9 tools emit `workflow="mcp"`, `stage="tool_call"`. +result: [pending] + +### 10. Full test suite passes +expected: Running `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py tests/integration/test_mcp_tool_calls.py -v` passes all 46 tests (15 unit + 31 integration). +result: [pending] + +## Summary + +total: 10 +passed: 0 +issues: 0 +pending: 10 +skipped: 0 + +## Gaps + +[none yet] diff --git a/.planning/phases/03-remaining-mcp-tools/03-VALIDATION.md b/.planning/phases/03-remaining-mcp-tools/03-VALIDATION.md new file mode 100644 index 00000000..625345ec --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-VALIDATION.md @@ -0,0 +1,86 @@ +--- +phase: 3 +slug: remaining-mcp-tools +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-14 +--- + +# Phase 3 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest 7.x with pytest-asyncio (asyncio_mode = "strict") | +| **Config file** | `pyproject.toml` — `[tool.pytest.ini_options]` | +| **Quick run command** | `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py tests/unit/test_mcp_check_scholar.py tests/unit/test_mcp_get_research_context.py tests/unit/test_mcp_save_to_memory.py tests/unit/test_mcp_export_to_obsidian.py -q` | +| **Full suite command** | `PYTHONPATH=src pytest tests/unit/test_mcp_*.py tests/integration/test_mcp_tool_calls.py -q` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `PYTHONPATH=src pytest tests/unit/test_mcp_.py -q` +- **After every plan wave:** Run `PYTHONPATH=src pytest tests/unit/test_mcp_*.py tests/integration/test_mcp_tool_calls.py -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 5 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 03-01-01 | 01 | 0 | MCP-01 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py -x` | ❌ W0 | ⬜ pending | +| 03-01-02 | 01 | 0 | MCP-02 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_check_scholar.py -x` | ❌ W0 | ⬜ pending | +| 03-01-03 | 01 | 0 | MCP-03 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_get_research_context.py -x` | ❌ W0 | ⬜ pending | +| 03-01-04 | 01 | 0 | MCP-04 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_save_to_memory.py -x` | ❌ W0 | ⬜ pending | +| 03-01-05 | 01 | 0 | MCP-05 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_export_to_obsidian.py -x` | ❌ W0 | ⬜ pending | +| 03-02-01 | 02 | 1 | MCP-01 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py -x` | ❌ W0 | ⬜ pending | +| 03-02-02 | 02 | 1 | MCP-02 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_check_scholar.py -x` | ❌ W0 | ⬜ pending | +| 03-02-03 | 02 | 1 | MCP-03 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_get_research_context.py -x` | ❌ W0 | ⬜ pending | +| 03-02-04 | 02 | 1 | MCP-04 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_save_to_memory.py -x` | ❌ W0 | ⬜ pending | +| 03-02-05 | 02 | 1 | MCP-05 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_export_to_obsidian.py -x` | ❌ W0 | ⬜ pending | +| 03-03-01 | 03 | 2 | All | integration | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x` | ✅ (update) | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/unit/test_mcp_analyze_trends.py` — stubs for MCP-01 (3 tests: success, degraded, audit) +- [ ] `tests/unit/test_mcp_check_scholar.py` — stubs for MCP-02 (3 tests: success, not-found, audit) +- [ ] `tests/unit/test_mcp_get_research_context.py` — stubs for MCP-03 (3 tests: success, params, audit) +- [ ] `tests/unit/test_mcp_save_to_memory.py` — stubs for MCP-04 (3 tests: success, invalid-kind, audit) +- [ ] `tests/unit/test_mcp_export_to_obsidian.py` — stubs for MCP-05 (3 tests: success, frontmatter, audit) + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| All 9 tools appear in MCP tools/list | All | Server startup required | Start MCP server, call `tools/list`, verify 9 entries | + +*Note: Integration test `test_mcp_tool_calls.py` covers this via FastMCP test client, so effectively automated.* + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 5s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/03-remaining-mcp-tools/03-VERIFICATION.md b/.planning/phases/03-remaining-mcp-tools/03-VERIFICATION.md new file mode 100644 index 00000000..83e3ce3f --- /dev/null +++ b/.planning/phases/03-remaining-mcp-tools/03-VERIFICATION.md @@ -0,0 +1,145 @@ +--- +phase: 03-remaining-mcp-tools +verified: 2026-03-14T05:00:00Z +status: passed +score: 7/7 must-haves verified +re_verification: false +--- + +# Phase 3: Remaining MCP Tools Verification Report + +**Phase Goal:** All 9 MCP tools are registered and callable, completing the tool surface +**Verified:** 2026-03-14T05:00:00Z +**Status:** passed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | Agent can call `analyze_trends` and receive trend analysis for a set of papers | VERIFIED | `_analyze_trends_impl` calls `anyio.to_thread.run_sync(lambda: analyzer.analyze(topic, items))` and returns `{"trend_analysis": ..., "topic": ..., "paper_count": ...}`; 3 unit tests pass | +| 2 | Agent can call `check_scholar` and receive a scholar's recent publications | VERIFIED | `_check_scholar_impl` calls `await client.search_authors()` then `await client.get_author_papers()` and returns `{"scholar": ..., "recent_papers": ..., "candidates": ...}`; 3 unit tests pass | +| 3 | Agent can call `get_research_context` and receive context for a research track | VERIFIED | `_get_research_context_impl` calls `await engine.build_context_pack(user_id, query, track_id)` and returns result dict directly; 3 unit tests pass | +| 4 | Agent can call `save_to_memory` and persist research findings retrievable later | VERIFIED | `_save_to_memory_impl` validates `MemoryKind`, constructs `MemoryCandidate`, calls `anyio.to_thread.run_sync(lambda: store.add_memories(...))`, returns `{"saved": True, "created": N, "skipped": N}`; 3 unit tests pass | +| 5 | Agent can call `export_to_obsidian` and receive Obsidian-formatted markdown | VERIFIED | `_export_to_obsidian_impl` calls `anyio.to_thread.run_sync(lambda: exporter._render_paper_note(...))` then prepends `_yaml_frontmatter(...)` and returns `{"markdown": ...}`; 3 unit tests pass | +| 6 | All 9 tools appear in MCP tools/list | VERIFIED | `server.py` imports and calls `register(mcp)` for all 9 tools inside the `try` block; integration test `test_server_registers_all_nine_tools` checks source for all 9 `.register` calls; `EXPECTED_TOOLS` list contains all 9 names | +| 7 | All tools log calls via audit helper | VERIFIED | All 5 new `_impl` functions call `log_tool_call(tool_name=..., ...)` in both success and exception paths; `test_all_tool_events_have_consistent_structure` fires all 9 tools and asserts 9 events with `workflow="mcp"`, `stage="tool_call"`, `agent_name="paperbot-mcp"`, `duration_ms` in metrics | + +**Score:** 7/7 truths verified + +--- + +### Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `src/paperbot/mcp/tools/analyze_trends.py` | analyze_trends MCP tool wrapping TrendAnalyzer | VERIFIED | 115 lines; exports `_analyze_trends_impl`, `register`; lazy singleton `_analyzer`; anyio wrapping; degraded detection | +| `src/paperbot/mcp/tools/check_scholar.py` | check_scholar MCP tool wrapping SemanticScholarClient | VERIFIED | 136 lines; exports `_check_scholar_impl`, `register`; lazy singleton `_client`; direct async calls; not-found degraded path | +| `src/paperbot/mcp/tools/get_research_context.py` | get_research_context MCP tool wrapping ContextEngine | VERIFIED | 102 lines; exports `_get_research_context_impl`, `register`; lazy singleton `_engine`; offline=True default | +| `src/paperbot/mcp/tools/save_to_memory.py` | save_to_memory MCP tool wrapping SqlAlchemyMemoryStore | VERIFIED | 154 lines; exports `_save_to_memory_impl`, `register`; lazy singleton `_store`; `_ALLOWED_KINDS` frozenset validation | +| `src/paperbot/mcp/tools/export_to_obsidian.py` | export_to_obsidian MCP tool with in-memory rendering | VERIFIED | 180 lines; exports `_export_to_obsidian_impl`, `register`; lazy singleton `_exporter`; no filesystem I/O | +| `src/paperbot/mcp/server.py` | MCP server with all 9 tools registered | VERIFIED | Imports and registers all 9 tools; `analyze_trends.register(mcp)` present | +| `tests/unit/test_mcp_analyze_trends.py` | Unit tests for analyze_trends (min 40 lines) | VERIFIED | 91 lines; 3 tests: normal result, degraded (empty LLM), audit log | +| `tests/unit/test_mcp_check_scholar.py` | Unit tests for check_scholar (min 40 lines) | VERIFIED | 107 lines; 3 tests: normal result, degraded (not found), audit log | +| `tests/unit/test_mcp_get_research_context.py` | Unit tests for get_research_context (min 40 lines) | VERIFIED | 105 lines; 3 tests: context pack, user_id/track_id passthrough, audit log | +| `tests/unit/test_mcp_save_to_memory.py` | Unit tests for save_to_memory (min 40 lines) | VERIFIED | 98 lines; 3 tests: counts, invalid kind default, audit log | +| `tests/unit/test_mcp_export_to_obsidian.py` | Unit tests for export_to_obsidian (min 40 lines) | VERIFIED | 88 lines; 3 tests: markdown key, frontmatter+title, audit log | +| `tests/integration/test_mcp_tool_calls.py` | Integration tests for all 9 MCP tools (min 200 lines) | VERIFIED | 1019 lines; 31 tests across 4 classes: Listing, Schemas, Invocation, EventLogging | + +--- + +### Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `analyze_trends.py` | `TrendAnalyzer.analyze()` | `anyio.to_thread.run_sync(lambda: analyzer.analyze(...))` | WIRED | Pattern `anyio.to_thread.run_sync.*analyzer.analyze` confirmed at line 57-59 | +| `check_scholar.py` | `SemanticScholarClient.search_authors/get_author_papers` | `await client.search_authors(...)` / `await client.get_author_papers(...)` | WIRED | Both async calls present at lines 56-87; pattern `await.*client\.(search_authors\|get_author_papers)` confirmed | +| `get_research_context.py` | `ContextEngine.build_context_pack()` | `await engine.build_context_pack(user_id, query, track_id)` | WIRED | Direct await at line 55-59; pattern `await.*engine\.build_context_pack` confirmed | +| `save_to_memory.py` | `SqlAlchemyMemoryStore.add_memories()` | `anyio.to_thread.run_sync(lambda: store.add_memories(...))` | WIRED | Pattern `anyio\.to_thread\.run_sync.*store\.add_memories` confirmed at line 105-107 | +| `export_to_obsidian.py` | `ObsidianFilesystemExporter._render_paper_note()` | `anyio.to_thread.run_sync(lambda: exporter._render_paper_note(...))` | WIRED | Pattern `anyio\.to_thread\.run_sync.*_render_paper_note` confirmed at lines 102-117 | +| `server.py` | `analyze_trends.register(mcp)` | import + register call | WIRED | `analyze_trends.register(mcp)` at line 32 | +| `server.py` | `check_scholar.register(mcp)` | import + register call | WIRED | `check_scholar.register(mcp)` at line 33 | +| `server.py` | `get_research_context.register(mcp)` | import + register call | WIRED | `get_research_context.register(mcp)` at line 34 | +| `server.py` | `save_to_memory.register(mcp)` | import + register call | WIRED | `save_to_memory.register(mcp)` at line 35 | +| `server.py` | `export_to_obsidian.register(mcp)` | import + register call | WIRED | `export_to_obsidian.register(mcp)` at line 36 | + +--- + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| MCP-01 | 03-01-PLAN, 03-03-PLAN | Agent can analyze trends across a set of papers via `analyze_trends` MCP tool | SATISFIED | `analyze_trends.py` exists and is wired in `server.py`; 3 unit + invocation + logging tests pass | +| MCP-02 | 03-01-PLAN, 03-03-PLAN | Agent can check a scholar's recent publications and activity via `check_scholar` MCP tool | SATISFIED | `check_scholar.py` exists and is wired in `server.py`; 3 unit + invocation + logging tests pass | +| MCP-03 | 03-02-PLAN, 03-03-PLAN | Agent can retrieve research context for a track via `get_research_context` MCP tool | SATISFIED | `get_research_context.py` exists and is wired in `server.py`; 3 unit + invocation + logging tests pass | +| MCP-04 | 03-02-PLAN, 03-03-PLAN | Agent can save research findings to memory via `save_to_memory` MCP tool | SATISFIED | `save_to_memory.py` exists and is wired in `server.py`; MemoryKind validation confirmed; 3 unit + invocation + logging tests pass | +| MCP-05 | 03-02-PLAN, 03-03-PLAN | Agent can export papers/notes to Obsidian vault format via `export_to_obsidian` MCP tool | SATISFIED | `export_to_obsidian.py` exists and is wired in `server.py`; in-memory rendering confirmed (no filesystem I/O); 3 unit + invocation + logging tests pass | + +All 5 requirement IDs (MCP-01 through MCP-05) are marked Complete in REQUIREMENTS.md. No orphaned requirements found for Phase 3. + +--- + +### Anti-Patterns Found + +No anti-patterns detected. Scan results: + +- TODO/FIXME/PLACEHOLDER: none found across all 5 new tool modules +- Empty implementations (`return null`, `return {}`, `return []`): none found +- Stub handlers: all `_impl` functions contain real logic (lazy singleton instantiation, service calls, result construction, audit logging) + +--- + +### Human Verification Required + +None. All observable behaviors are verifiable programmatically: + +- Tool return shapes are testable via unit and integration tests +- Audit logging is testable via `InMemoryEventLog` injection +- Server registration is verifiable via source inspection (`inspect.getsource`) +- Degraded paths are covered by unit tests (empty LLM string, empty author list) + +The only behavior that could warrant human spot-check is the live Semantic Scholar API response (network call) and actual LLM trend analysis quality — both are by design covered by fake-based tests and not required for Phase 3 goal achievement. + +--- + +## Test Run Evidence + +``` +PYTHONPATH=src pytest tests/unit/test_mcp_analyze_trends.py \ + tests/unit/test_mcp_check_scholar.py \ + tests/unit/test_mcp_get_research_context.py \ + tests/unit/test_mcp_save_to_memory.py \ + tests/unit/test_mcp_export_to_obsidian.py \ + tests/integration/test_mcp_tool_calls.py -q + +46 passed in 2.38s +``` + +Breakdown: +- Unit tests (Plans 01+02): 15 tests (3 per tool × 5 tools), all pass +- Integration tests (Plan 03): 31 tests across 4 classes, all pass +- Total: 46/46 + +--- + +## Commit Verification + +All 6 documented commits verified present in git history: + +| Commit | Type | Description | +|--------|------|-------------| +| `ada2c56` | test | RED phase — failing tests for analyze_trends and check_scholar | +| `214a027` | feat | GREEN phase — implement analyze_trends and check_scholar | +| `d82e0d4` | test | RED phase — failing tests for get_research_context, save_to_memory, export_to_obsidian | +| `302edcf` | feat | GREEN phase — implement get_research_context, save_to_memory, export_to_obsidian | +| `e16b2e0` | feat | Register all 9 MCP tools in server.py | +| `f1b8828` | feat | Extend integration tests to cover all 9 tools | + +--- + +_Verified: 2026-03-14T05:00:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/04-mcp-resources/04-01-PLAN.md b/.planning/phases/04-mcp-resources/04-01-PLAN.md new file mode 100644 index 00000000..435ef3e7 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-01-PLAN.md @@ -0,0 +1,250 @@ +--- +phase: 04-mcp-resources +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - src/paperbot/mcp/resources/__init__.py + - src/paperbot/mcp/resources/track_metadata.py + - src/paperbot/mcp/resources/track_papers.py + - src/paperbot/mcp/resources/track_memory.py + - src/paperbot/mcp/resources/scholars.py + - tests/unit/test_mcp_track_metadata.py + - tests/unit/test_mcp_track_papers.py + - tests/unit/test_mcp_track_memory.py + - tests/unit/test_mcp_scholars.py +autonomous: true +requirements: [MCP-06, MCP-07, MCP-08, MCP-09] + +must_haves: + truths: + - "_track_metadata_impl('42') returns JSON with track id, name, description, keywords, venues, methods" + - "_track_metadata_impl('99') returns JSON error when track not found" + - "_track_metadata_impl('abc') returns JSON error for non-integer track_id" + - "_track_papers_impl('42') returns JSON with items list of paper dicts" + - "_track_papers_impl returns empty items list when track has no matching papers" + - "_track_memory_impl('42') returns JSON list of memory dicts scoped to track" + - "_track_memory_impl returns empty list when no memories exist for track" + - "_scholars_impl() returns JSON list of scholar dicts with name and semantic_scholar_id" + - "_scholars_impl() returns error JSON when config file not found" + artifacts: + - path: "src/paperbot/mcp/resources/__init__.py" + provides: "Package marker for resources directory" + - path: "src/paperbot/mcp/resources/track_metadata.py" + provides: "paperbot://track/{track_id} resource (MCP-06)" + exports: ["_track_metadata_impl", "register"] + - path: "src/paperbot/mcp/resources/track_papers.py" + provides: "paperbot://track/{track_id}/papers resource (MCP-07)" + exports: ["_track_papers_impl", "register"] + - path: "src/paperbot/mcp/resources/track_memory.py" + provides: "paperbot://track/{track_id}/memory resource (MCP-08)" + exports: ["_track_memory_impl", "register"] + - path: "src/paperbot/mcp/resources/scholars.py" + provides: "paperbot://scholars resource (MCP-09)" + exports: ["_scholars_impl", "register"] + - path: "tests/unit/test_mcp_track_metadata.py" + provides: "Unit tests for MCP-06" + min_lines: 40 + - path: "tests/unit/test_mcp_track_papers.py" + provides: "Unit tests for MCP-07" + min_lines: 30 + - path: "tests/unit/test_mcp_track_memory.py" + provides: "Unit tests for MCP-08" + min_lines: 30 + - path: "tests/unit/test_mcp_scholars.py" + provides: "Unit tests for MCP-09" + min_lines: 30 + key_links: + - from: "src/paperbot/mcp/resources/track_metadata.py" + to: "SqlAlchemyResearchStore.get_track_by_id" + via: "anyio.to_thread.run_sync wrapping sync store call" + pattern: "anyio\\.to_thread\\.run_sync.*get_track_by_id" + - from: "src/paperbot/mcp/resources/track_papers.py" + to: "SqlAlchemyResearchStore.list_track_feed" + via: "anyio.to_thread.run_sync with user_id='default'" + pattern: "anyio\\.to_thread\\.run_sync.*list_track_feed" + - from: "src/paperbot/mcp/resources/track_memory.py" + to: "SqlAlchemyMemoryStore.list_memories" + via: "anyio.to_thread.run_sync with scope_type='track', scope_id=str(id)" + pattern: "anyio\\.to_thread\\.run_sync.*list_memories" + - from: "src/paperbot/mcp/resources/scholars.py" + to: "SubscriptionService.get_scholar_configs" + via: "anyio.to_thread.run_sync wrapping sync service call" + pattern: "get_scholar_configs" +--- + + +Implement 4 MCP resource modules with unit tests using TDD. + +Purpose: Create the read-only data access layer that allows agents to read PaperBot data via `paperbot://` URI scheme without calling tools. Each resource wraps an existing store/service API, serializes to JSON, and follows the same `_impl()` + `register()` pattern established for tools in Phase 2-3. + +Output: 4 resource modules in `src/paperbot/mcp/resources/`, 4 unit test files, all tests passing. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-mcp-resources/04-RESEARCH.md + +# Prior phase patterns (resource registration mirrors tool registration) +@.planning/phases/03-remaining-mcp-tools/03-01-SUMMARY.md + +# Existing code to extend +@src/paperbot/mcp/server.py +@src/paperbot/mcp/tools/save_to_memory.py + + + + +From src/paperbot/infrastructure/stores/research_store.py: +```python +# get_track_by_id() — no user_id needed, returns dict or None +def get_track_by_id(self, track_id: int) -> Optional[Dict[str, Any]]: + # Returns: {"id": int, "name": str, "description": str, "keywords": List[str], + # "venues": List[str], "methods": List[str], "is_active": bool, + # "archived_at": str|None, "created_at": str|None, "updated_at": str|None} + +# list_track_feed() — requires user_id, returns dict with items + total +def list_track_feed(self, user_id: str, track_id: int, limit: int = 50) -> Dict[str, Any]: + # Returns: {"items": List[Dict], "total": int} + # items are paper dicts from _paper_to_dict() +``` + +From src/paperbot/infrastructure/stores/memory_store.py: +```python +# list_memories() — requires user_id, scope filtering optional but required for track +def list_memories(self, user_id: str, scope_type: str = None, scope_id: str = None, limit: int = 100) -> List[Dict]: + # Returns list of dicts: {id, content, kind, confidence, scope_type, scope_id, ...} + # Only returns approved, non-deleted, non-expired items +``` + +From src/paperbot/infrastructure/services/subscription_service.py: +```python +# get_scholar_configs() — returns raw config dicts from YAML +def get_scholar_configs(self) -> List[Dict]: + # Returns: [{"name": str, "semantic_scholar_id": str, "keywords": List[str], ...}, ...] + # Raises FileNotFoundError if config/scholar_subscriptions.yaml missing +``` + +Resource registration pattern (from FastMCP docs): +```python +@mcp.resource("paperbot://track/{track_id}", mime_type="application/json") +async def track_metadata(track_id: str) -> str: + """Docstring becomes resource description.""" + return await _track_metadata_impl(track_id) +``` + + + + + + + Task 1: TDD track resources (metadata, papers, memory) + + src/paperbot/mcp/resources/__init__.py, + src/paperbot/mcp/resources/track_metadata.py, + src/paperbot/mcp/resources/track_papers.py, + src/paperbot/mcp/resources/track_memory.py, + tests/unit/test_mcp_track_metadata.py, + tests/unit/test_mcp_track_papers.py, + tests/unit/test_mcp_track_memory.py + + + track_metadata (MCP-06): + - _track_metadata_impl("42") with fake store returning {"id": 42, "name": "ML"} -> JSON string containing those fields + - _track_metadata_impl("99") with fake store returning None -> JSON string with "error" key + - _track_metadata_impl("abc") -> JSON string with "error" key about invalid track_id + + track_papers (MCP-07): + - _track_papers_impl("42") with fake store returning {"items": [{"title":"P1"}], "total": 1} -> JSON string with items list + - _track_papers_impl("42") with fake store returning {"items": [], "total": 0} -> JSON string with empty items + + track_memory (MCP-08): + - _track_memory_impl("42") with fake store returning [{"id":1,"content":"note"}] -> JSON string with list + - _track_memory_impl("42") with fake store returning [] -> JSON string with empty list + - Verify list_memories called with scope_type="track" and scope_id="42" (not global scope) + + + RED phase: + 1. Create `src/paperbot/mcp/resources/__init__.py` (empty). + 2. Create `tests/unit/test_mcp_track_metadata.py` with 3 tests: normal return, not-found, invalid ID. Use fake store class injected via module-level `_store` singleton (same pattern as `test_mcp_analyze_trends.py`). Each test is `@pytest.mark.asyncio`. Fake store has `get_track_by_id(track_id)` returning canned dict or None. + 3. Create `tests/unit/test_mcp_track_papers.py` with 2 tests: normal return with items, empty items. Fake store has `list_track_feed(user_id, track_id, limit)` returning canned dict. + 4. Create `tests/unit/test_mcp_track_memory.py` with 2 tests: normal return with memories, empty list. Fake store has `list_memories(user_id, scope_type, scope_id, limit)` that captures args to verify scope filtering. + 5. Run tests -- all MUST fail (modules don't exist yet). + 6. Commit: `test(04-01): add failing tests for track resource impls` + + GREEN phase: + 1. Create `src/paperbot/mcp/resources/track_metadata.py` following the exact pattern from RESEARCH.md: `_store = None`, `_get_store()` lazy singleton importing `SqlAlchemyResearchStore`, `_track_metadata_impl(track_id: str) -> str` with int() cast + try/except ValueError, None check, json.dumps(). `register(mcp)` with `@mcp.resource("paperbot://track/{track_id}", mime_type="application/json")`. + 2. Create `src/paperbot/mcp/resources/track_papers.py`: same pattern but wraps `store.list_track_feed(user_id="default", track_id=int(track_id), limit=50)`. Returns json.dumps(feed). Handle invalid track_id and pass through result (list_track_feed returns empty items for nonexistent tracks, no None check needed). + 3. Create `src/paperbot/mcp/resources/track_memory.py`: same pattern but wraps `store.list_memories(user_id="default", scope_type="track", scope_id=str(int(track_id)), limit=100)`. MUST pass both scope_type="track" and scope_id to filter correctly. Returns json.dumps(memories). Handle invalid track_id. + 4. Run tests -- all MUST pass. + 5. Commit: `feat(04-01): implement track metadata, papers, memory resources` + + + PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py -x -q + + 3 track resource modules exist with _impl + register pattern. 7 unit tests pass covering normal, not-found, invalid-id, empty-result, and scope-filtering behaviors. + + + + Task 2: TDD scholars resource + + src/paperbot/mcp/resources/scholars.py, + tests/unit/test_mcp_scholars.py + + + scholars (MCP-09): + - _scholars_impl() with fake service returning [{"name":"Dawn Song","semantic_scholar_id":"123"}] -> JSON list string + - _scholars_impl() with fake service raising FileNotFoundError -> JSON string with error key and empty scholars list + + + RED phase: + 1. Create `tests/unit/test_mcp_scholars.py` with 2 tests: normal return with scholar list, FileNotFoundError handling. Fake service class `_FakeSubscriptionService` with `get_scholar_configs()` that either returns canned list or raises FileNotFoundError. Inject via module-level `_service` singleton. Each test is `@pytest.mark.asyncio`. + 2. Run test -- MUST fail. + 3. Commit: `test(04-01): add failing tests for scholars resource` + + GREEN phase: + 1. Create `src/paperbot/mcp/resources/scholars.py`. This is a STATIC resource (no URI template parameter). Use `_service = None`, `_get_service()` lazy singleton importing `SubscriptionService` and instantiating fresh each call (per RESEARCH.md recommendation -- fresh reads, no caching). `_scholars_impl() -> str` wraps `service.get_scholar_configs()` in `anyio.to_thread.run_sync()`. Wrap entire call in try/except FileNotFoundError, returning `json.dumps({"error": "Scholar config not found", "scholars": []})`. `register(mcp)` with `@mcp.resource("paperbot://scholars", mime_type="application/json")` (static URI, no template param). + + IMPORTANT: For scholars.py, do NOT use a module-level lazy singleton with caching. Instead, instantiate a fresh `SubscriptionService()` each call to get fresh config reads. The `_service` variable is only for test injection (set to None by default, tests set it to fake). + + 2. Run tests -- all MUST pass. + 3. Commit: `feat(04-01): implement scholars resource` + + + PYTHONPATH=src pytest tests/unit/test_mcp_scholars.py -x -q + + scholars.py resource module exists with _impl + register pattern. 2 unit tests pass covering normal return and FileNotFoundError handling. + + + + + +All 9 unit tests pass across all 4 resource modules: +```bash +PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -x -q +``` + +Each resource module exports `__impl` async function and `register` function. + + + +- 4 resource modules exist in `src/paperbot/mcp/resources/` with correct `_impl` + `register` pattern +- 9 unit tests pass covering all requirement behaviors +- Each resource returns JSON string (not Python dict) +- Track resources handle invalid track_id gracefully +- Track memory resource uses scope_type="track" filtering +- Scholars resource handles missing config file +- All resource `register()` functions use `@mcp.resource()` with `mime_type="application/json"` + + + +After completion, create `.planning/phases/04-mcp-resources/04-01-SUMMARY.md` + diff --git a/.planning/phases/04-mcp-resources/04-01-SUMMARY.md b/.planning/phases/04-mcp-resources/04-01-SUMMARY.md new file mode 100644 index 00000000..cd3de288 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-01-SUMMARY.md @@ -0,0 +1,149 @@ +--- +phase: 04-mcp-resources +plan: 01 +subsystem: api +tags: [mcp, fastmcp, resources, anyio, tdd, json, track, scholars, memory] + +# Dependency graph +requires: + - phase: 03-remaining-mcp-tools + provides: MCP tool pattern (_impl + register + lazy singleton + anyio.to_thread.run_sync) +provides: + - track_metadata resource: paperbot://track/{track_id} (MCP-06) + - track_papers resource: paperbot://track/{track_id}/papers (MCP-07) + - track_memory resource: paperbot://track/{track_id}/memory (MCP-08) + - scholars resource: paperbot://scholars (MCP-09) + - Unit tests covering normal, not-found, invalid-id, empty-result, scope-filter, and FileNotFoundError behaviors +affects: [04-mcp-resources, mcp-server-registration] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "MCP resource registration: @mcp.resource(uri, mime_type='application/json') with _impl + register pattern" + - "Lazy singleton _store/_service module-level var with _get_store()/_get_service() for production injection" + - "Test injection via module-level singleton override: mod._store = FakeStore(); try/finally reset to None" + - "anyio.to_thread.run_sync(lambda: store.method(...)) for all synchronous store/service calls" + - "Fresh SubscriptionService instantiation per call (no caching) for always-fresh config file reads" + - "Graceful error handling: invalid track_id and missing config return JSON error objects, not exceptions" + +key-files: + created: + - src/paperbot/mcp/resources/__init__.py + - src/paperbot/mcp/resources/track_metadata.py + - src/paperbot/mcp/resources/track_papers.py + - src/paperbot/mcp/resources/track_memory.py + - src/paperbot/mcp/resources/scholars.py + - tests/unit/test_mcp_track_metadata.py + - tests/unit/test_mcp_track_papers.py + - tests/unit/test_mcp_track_memory.py + - tests/unit/test_mcp_scholars.py + modified: [] + +key-decisions: + - "Track resources use anyio.to_thread.run_sync() because SqlAlchemyResearchStore and SqlAlchemyMemoryStore are synchronous" + - "track_memory passes both scope_type='track' and scope_id=str(tid) to filter correctly (not global scope)" + - "scholars.py instantiates fresh SubscriptionService() each call (no singleton caching) for always-fresh YAML reads" + - "_service in scholars.py is test-injection-only variable, not a lazy singleton" + +patterns-established: + - "MCP resource _impl pattern: async def _X_impl(param: str) -> str (returns JSON string)" + - "MCP resource register pattern: def register(mcp) with @mcp.resource(uri, mime_type='application/json')" + - "Static vs template resources: scholars uses static URI, track resources use {track_id} template" + +requirements-completed: [MCP-06, MCP-07, MCP-08, MCP-09] + +# Metrics +duration: 3min +completed: 2026-03-14 +--- + +# Phase 04 Plan 01: MCP Resource Modules Summary + +**4 read-only paperbot:// MCP resources (track metadata, papers, memory, scholars) wrapping sync stores via anyio.to_thread.run_sync with 12 unit tests** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-03-14T04:59:49Z +- **Completed:** 2026-03-14T05:02:20Z +- **Tasks:** 2 (each TDD: RED + GREEN) +- **Files modified:** 9 + +## Accomplishments + +- `track_metadata` resource (`paperbot://track/{track_id}`) wraps `SqlAlchemyResearchStore.get_track_by_id()` returning full track JSON with name, description, keywords, venues, methods +- `track_papers` resource (`paperbot://track/{track_id}/papers`) wraps `list_track_feed(user_id="default")` returning up to 50 papers with metadata +- `track_memory` resource (`paperbot://track/{track_id}/memory`) wraps `list_memories(scope_type="track", scope_id=str(tid))` for correctly scoped memory retrieval +- `scholars` resource (`paperbot://scholars`) wraps `SubscriptionService.get_scholar_configs()` with FileNotFoundError handled gracefully +- 12 unit tests pass covering all must-have behaviors: normal return, not-found, invalid-id, empty-result, scope-filter, and missing-config-file + +## Task Commits + +Each task was committed atomically (TDD split into test then feat): + +1. **RED: failing track tests** - `2e68e84` (test) +2. **GREEN: track resource implementations** - `877f984` (feat) +3. **RED: failing scholars test** - `5ef18e7` (test) +4. **GREEN: scholars resource implementation** - `45e3d37` (feat) + +_Note: TDD tasks split into two commits each (test -> feat)_ + +## Files Created/Modified + +- `src/paperbot/mcp/resources/__init__.py` - Package marker for resources directory +- `src/paperbot/mcp/resources/track_metadata.py` - paperbot://track/{track_id}, lazy singleton _store, anyio wrapping, invalid-id and not-found error handling +- `src/paperbot/mcp/resources/track_papers.py` - paperbot://track/{track_id}/papers, lazy singleton _store, anyio wrapping, invalid-id error handling +- `src/paperbot/mcp/resources/track_memory.py` - paperbot://track/{track_id}/memory, lazy singleton _store, anyio wrapping, scope_type="track" filtering +- `src/paperbot/mcp/resources/scholars.py` - paperbot://scholars static resource, fresh SubscriptionService per call, FileNotFoundError handling +- `tests/unit/test_mcp_track_metadata.py` - 3 tests: normal metadata, not-found error, invalid-id error +- `tests/unit/test_mcp_track_papers.py` - 3 tests: normal items, empty items, invalid-id error +- `tests/unit/test_mcp_track_memory.py` - 4 tests: normal memories, empty list, scope_type="track" verification, invalid-id error +- `tests/unit/test_mcp_scholars.py` - 2 tests: normal scholar list, FileNotFoundError handling + +## Decisions Made + +- Track resources use `anyio.to_thread.run_sync()` because `SqlAlchemyResearchStore` and `SqlAlchemyMemoryStore` are synchronous (same pattern as Phase 03 analyze_trends) +- `track_memory` passes both `scope_type="track"` and `scope_id=str(tid)` to filter memories correctly to the specific track (not global scope) +- `scholars.py` instantiates a fresh `SubscriptionService()` each call rather than caching -- ensures config file changes are picked up immediately; the `_service` variable is test-injection-only +- All invalid track_id inputs return JSON error objects (not exceptions) for safe agent consumption + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- All 4 MCP resource modules follow established _impl + register pattern +- Resources directory `src/paperbot/mcp/resources/` ready for additional resources in subsequent plans +- Each resource's `register()` function ready for wiring into `server.py` in a future registration task +- 12 unit tests green, all requirement behaviors verified + +## Self-Check: PASSED + +- FOUND: src/paperbot/mcp/resources/__init__.py +- FOUND: src/paperbot/mcp/resources/track_metadata.py +- FOUND: src/paperbot/mcp/resources/track_papers.py +- FOUND: src/paperbot/mcp/resources/track_memory.py +- FOUND: src/paperbot/mcp/resources/scholars.py +- FOUND: tests/unit/test_mcp_track_metadata.py +- FOUND: tests/unit/test_mcp_track_papers.py +- FOUND: tests/unit/test_mcp_track_memory.py +- FOUND: tests/unit/test_mcp_scholars.py +- FOUND commit: 2e68e84 (test RED track) +- FOUND commit: 877f984 (feat GREEN track) +- FOUND commit: 5ef18e7 (test RED scholars) +- FOUND commit: 45e3d37 (feat GREEN scholars) +- All 12 tests: PASSED + +--- +*Phase: 04-mcp-resources* +*Completed: 2026-03-14* diff --git a/.planning/phases/04-mcp-resources/04-02-PLAN.md b/.planning/phases/04-mcp-resources/04-02-PLAN.md new file mode 100644 index 00000000..37e7e73c --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-02-PLAN.md @@ -0,0 +1,211 @@ +--- +phase: 04-mcp-resources +plan: 02 +type: execute +wave: 2 +depends_on: ["04-01"] +files_modified: + - src/paperbot/mcp/server.py + - tests/integration/test_mcp_tool_calls.py +autonomous: true +requirements: [MCP-06, MCP-07, MCP-08, MCP-09] + +must_haves: + truths: + - "All 4 resource modules are imported and registered in server.py" + - "server.py source contains register() calls for track_metadata, track_papers, track_memory, scholars" + - "Integration tests verify all 4 resource modules expose register() and _impl functions" + - "Integration tests verify server.py imports all 4 resource modules" + artifacts: + - path: "src/paperbot/mcp/server.py" + provides: "FastMCP server with 9 tools + 4 resources registered" + contains: "track_metadata.register" + - path: "tests/integration/test_mcp_tool_calls.py" + provides: "Integration tests including resource discovery checks" + contains: "TestMCPResourceListing" + key_links: + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/resources/track_metadata.py" + via: "import + register(mcp) call" + pattern: "track_metadata\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/resources/track_papers.py" + via: "import + register(mcp) call" + pattern: "track_papers\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/resources/track_memory.py" + via: "import + register(mcp) call" + pattern: "track_memory\\.register\\(mcp\\)" + - from: "src/paperbot/mcp/server.py" + to: "src/paperbot/mcp/resources/scholars.py" + via: "import + register(mcp) call" + pattern: "scholars\\.register\\(mcp\\)" +--- + + +Register all 4 MCP resources in server.py and add integration tests verifying resource discovery. + +Purpose: Wire the resource modules created in Plan 01 into the FastMCP server so they appear in MCP resource/template listings, and add integration tests confirming discoverability. This mirrors the Plan 03-03 pattern from Phase 3 where tools were registered and integration-tested. + +Output: Updated server.py with 9 tools + 4 resources, extended integration test file with TestMCPResourceListing class. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/04-mcp-resources/04-RESEARCH.md + +# Prior phase pattern for server registration +@.planning/phases/03-remaining-mcp-tools/03-03-SUMMARY.md + +# Files to modify +@src/paperbot/mcp/server.py +@tests/integration/test_mcp_tool_calls.py + + + + +From src/paperbot/mcp/resources/track_metadata.py: +```python +async def _track_metadata_impl(track_id: str) -> str: ... +def register(mcp) -> None: ... +# Registers @mcp.resource("paperbot://track/{track_id}", mime_type="application/json") +``` + +From src/paperbot/mcp/resources/track_papers.py: +```python +async def _track_papers_impl(track_id: str) -> str: ... +def register(mcp) -> None: ... +# Registers @mcp.resource("paperbot://track/{track_id}/papers", mime_type="application/json") +``` + +From src/paperbot/mcp/resources/track_memory.py: +```python +async def _track_memory_impl(track_id: str) -> str: ... +def register(mcp) -> None: ... +# Registers @mcp.resource("paperbot://track/{track_id}/memory", mime_type="application/json") +``` + +From src/paperbot/mcp/resources/scholars.py: +```python +async def _scholars_impl() -> str: ... +def register(mcp) -> None: ... +# Registers @mcp.resource("paperbot://scholars", mime_type="application/json") +``` + +server.py current structure (to extend): +```python +# Existing pattern: import module, call module.register(mcp) +from paperbot.mcp.tools import paper_search +paper_search.register(mcp) +# ... 8 more tools +``` + +Integration test existing structure: +```python +# TestMCPToolListing — verify modules have register() and _impl +# TestMCPToolSchemas — verify parameter signatures +# TestMCPToolInvocation — verify _impl returns correct results +# TestMCPToolEventLogging — verify audit log events +# NEW: TestMCPResourceListing — verify resource modules + server registration +``` + +IMPORTANT: URI template resources (track/{id}, track/{id}/papers, track/{id}/memory) appear +in `list_resource_templates`, NOT `list_resources`. Static resources (scholars) appear in +`list_resources`. Since we cannot invoke FastMCP directly (Python 3.9 constraint), integration +tests verify by checking source code for register() calls (same approach as TestMCPToolListing). + + + + + + + Task 1: Register 4 resources in server.py + src/paperbot/mcp/server.py + + Add resource imports and register() calls to server.py, after the existing 9 tool registrations. Follow the exact same pattern (import module, call module.register(mcp)): + + ```python + # Register resources (after the tool registration block) + from paperbot.mcp.resources import track_metadata + from paperbot.mcp.resources import track_papers + from paperbot.mcp.resources import track_memory + from paperbot.mcp.resources import scholars + + track_metadata.register(mcp) + track_papers.register(mcp) + track_memory.register(mcp) + scholars.register(mcp) + ``` + + Place these INSIDE the existing `try:` block (after `export_to_obsidian.register(mcp)`), before the `except ImportError:` block. Add a comment `# Register resources` to visually separate tools from resources. + + + PYTHONPATH=src python -c "import paperbot.mcp.server; print('server imports OK')" + + server.py imports and calls register() for all 4 resource modules alongside the 9 existing tools. + + + + Task 2: Add TestMCPResourceListing integration tests + tests/integration/test_mcp_tool_calls.py + + Add a new `TestMCPResourceListing` class at the end of `tests/integration/test_mcp_tool_calls.py` (after TestMCPToolEventLogging). This class verifies: + + 1. `test_all_four_resources_listed` — Import all 4 resource modules (`from paperbot.mcp.resources import track_metadata, track_papers, track_memory, scholars`). Verify each has `register` (callable) and its `_impl` function (`_track_metadata_impl`, `_track_papers_impl`, `_track_memory_impl`, `_scholars_impl`). Assert exactly 4 modules checked. + + 2. `test_server_registers_all_four_resources` — Import `paperbot.mcp.server`, `inspect.getsource(server_mod)`, assert all 4 register calls present in source: `track_metadata.register`, `track_papers.register`, `track_memory.register`, `scholars.register`. Same pattern as `test_server_registers_all_nine_tools`. + + 3. `test_each_resource_impl_has_correct_signature` — Verify `_track_metadata_impl` has `track_id: str` param. Verify `_track_papers_impl` has `track_id: str` param. Verify `_track_memory_impl` has `track_id: str` param. Verify `_scholars_impl` has no required params (it's static). All via `inspect.signature()`. + + Add `EXPECTED_RESOURCES` list alongside existing `EXPECTED_TOOLS` list: + ```python + EXPECTED_RESOURCES = [ + "track_metadata", + "track_papers", + "track_memory", + "scholars", + ] + ``` + + Each test method needs `setup_method` resetting `Container._instance = None` (same as other test classes). No `@pytest.mark.asyncio` needed since these are sync tests checking structure, not calling async _impl functions. + + Document the template-vs-static distinction in the class docstring: "URI template resources (track/{id}*) appear in list_resource_templates; static resources (scholars) appear in list_resources. Both are verified via source inspection since FastMCP cannot be invoked directly on Python 3.9." + + + PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -q -k resource + + TestMCPResourceListing class exists with 3 tests. All pass, confirming 4 resource modules are discoverable and registered in server.py. + + + + + +Full integration test suite (tools + resources) passes: +```bash +PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -q +``` + +All unit tests from Plan 01 still pass: +```bash +PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -x -q +``` + + + +- server.py imports and registers all 4 resource modules +- 3 new integration tests pass in TestMCPResourceListing +- Existing 31 tool integration tests remain green (no regression) +- Total: 34 integration tests (31 tools + 3 resources) +- server.py source contains both `# Register tools` and `# Register resources` sections + + + +After completion, create `.planning/phases/04-mcp-resources/04-02-SUMMARY.md` + diff --git a/.planning/phases/04-mcp-resources/04-02-SUMMARY.md b/.planning/phases/04-mcp-resources/04-02-SUMMARY.md new file mode 100644 index 00000000..796072d8 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-02-SUMMARY.md @@ -0,0 +1,106 @@ +--- +phase: 04-mcp-resources +plan: 02 +subsystem: api +tags: [mcp, fastmcp, resources, integration-tests] + +# Dependency graph +requires: + - phase: 04-mcp-resources/04-01 + provides: "4 resource modules (track_metadata, track_papers, track_memory, scholars) with register() and _impl functions" + - phase: 03-remaining-mcp-tools/03-03 + provides: "FastMCP server with 9 tools registered via import+register pattern" +provides: + - "server.py with 9 tools + 4 resources registered (FastMCP server fully wired)" + - "TestMCPResourceListing integration test class with 3 tests verifying resource discoverability" +affects: [future-phases, mcp-server, mcp-resources] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Resource registration pattern: import module → call module.register(mcp), same as tools" + - "Source inspection testing: verify register() calls in server.py source rather than invoking FastMCP directly" + +key-files: + created: [] + modified: + - src/paperbot/mcp/server.py + - tests/integration/test_mcp_tool_calls.py + +key-decisions: + - "Resources registered with same import+register pattern as tools; no architectural difference in server.py wiring" + - "Integration tests verify via source inspection (inspect.getsource) since FastMCP cannot be invoked on Python 3.9" + +patterns-established: + - "Resource server registration: add imports and register() calls inside the try: block in server.py" + - "TestMCPResourceListing mirrors TestMCPToolListing: structural tests only, no async invocation needed" + +requirements-completed: [MCP-06, MCP-07, MCP-08, MCP-09] + +# Metrics +duration: 2min +completed: 2026-03-14 +--- + +# Phase 04 Plan 02: MCP Resource Registration Summary + +**FastMCP server wired with 4 paperbot:// URI resources and 3 integration tests confirming source-level discoverability** + +## Performance + +- **Duration:** 2 min +- **Started:** 2026-03-14T05:04:55Z +- **Completed:** 2026-03-14T05:06:22Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments +- Registered all 4 resource modules (track_metadata, track_papers, track_memory, scholars) in server.py with `# Register resources` section comment +- Added `EXPECTED_RESOURCES` list and `TestMCPResourceListing` class to integration test file +- Full test suite: 34 integration tests pass (31 tools + 3 resources), 12 resource unit tests pass + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Register 4 resources in server.py** - `6490feb` (feat) +2. **Task 2: Add TestMCPResourceListing integration tests** - `7fa3ed4` (feat) + +**Plan metadata:** _(docs commit follows)_ + +## Files Created/Modified +- `src/paperbot/mcp/server.py` - Added resource imports and register() calls for track_metadata, track_papers, track_memory, scholars (inside existing try: block, after 9 tool registrations) +- `tests/integration/test_mcp_tool_calls.py` - Added EXPECTED_RESOURCES list and TestMCPResourceListing class with 3 tests + +## Decisions Made +- Resources registered inside the existing `try:` block (same as tools) — no new except/import guard needed since resource modules have no external dependencies beyond the stores already in use +- Integration tests use `inspect.getsource(server_mod)` pattern (same as TestMCPToolListing) since FastMCP requires Python 3.10+ and cannot be invoked in the CI Python 3.9 environment + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness +- Phase 04 MCP resources are fully wired: 9 tools + 4 resources registered in server.py +- v1.0 MCP server milestone (phases 1-6) can proceed with remaining plans +- The paperbot:// resource URIs (track/{id}, track/{id}/papers, track/{id}/memory, scholars) are now accessible via MCP protocol when FastMCP is installed + +--- +*Phase: 04-mcp-resources* +*Completed: 2026-03-14* + +## Self-Check: PASSED + +- src/paperbot/mcp/server.py: FOUND +- tests/integration/test_mcp_tool_calls.py: FOUND +- .planning/phases/04-mcp-resources/04-02-SUMMARY.md: FOUND +- commit 6490feb (Task 1): FOUND +- commit 7fa3ed4 (Task 2): FOUND diff --git a/.planning/phases/04-mcp-resources/04-RESEARCH.md b/.planning/phases/04-mcp-resources/04-RESEARCH.md new file mode 100644 index 00000000..a9cb5267 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-RESEARCH.md @@ -0,0 +1,471 @@ +# Phase 4: MCP Resources - Research + +**Researched:** 2026-03-14 +**Domain:** FastMCP resource registration, URI template resources, PaperBot data layer (ResearchStore, MemoryStore, SubscriptionService) +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| MCP-06 | Agent can read track metadata via `paperbot://track/{id}` resource | `SqlAlchemyResearchStore.get_track_by_id(track_id=int)` returns `Dict[str, Any]` with id, name, description, keywords, venues, methods, is_active, timestamps; wrap as JSON string resource | +| MCP-07 | Agent can read track paper list via `paperbot://track/{id}/papers` resource | `SqlAlchemyResearchStore.list_track_feed(user_id, track_id, limit)` returns `{"items": [...], "total": N}`; papers have full metadata via `_paper_to_dict()` | +| MCP-08 | Agent can read track memory via `paperbot://track/{id}/memory` resource | `SqlAlchemyMemoryStore.list_memories(user_id, scope_type="track", scope_id=str(id))` returns `List[Dict]` of approved, non-deleted memories | +| MCP-09 | Agent can read scholar subscriptions via `paperbot://scholars` resource | `SubscriptionService.get_scholar_configs()` returns `List[Dict]` of raw config dicts with name, semantic_scholar_id, keywords, etc. | + + +--- + +## Summary + +Phase 4 adds four MCP resources to the existing server (`paperbot://track/{id}`, `paperbot://track/{id}/papers`, `paperbot://track/{id}/memory`, `paperbot://scholars`). These are read-only data endpoints that agents read without tool calls — they appear in the MCP `resources/templates/list` (URI templates with `{id}`) and `resources/list` (static scholars resource). Resources return JSON strings serialized from existing store/service APIs. + +The implementation follows the same `register(mcp)` module pattern as tools, but uses `@mcp.resource(uri)` instead of `@mcp.tool()`. Resources are organized in a new `src/paperbot/mcp/resources/` subdirectory, registered in `server.py` alongside the 9 existing tools. Each resource module exposes a `__impl` async function for testability and a `register(mcp)` function for FastMCP binding. + +The critical distinction from tools: resources with `{id}` URI template parameters appear in `resources/templates/list`, not in `resources/list`. The static `paperbot://scholars` resource appears in `resources/list`. All 4 must appear in the combined `resources/list` + `resources/templates/list` output, satisfying the success criterion "All 4 resources appear in MCP resources/list." + +**Primary recommendation:** Create `src/paperbot/mcp/resources/` directory with one module per resource, use `@mcp.resource("paperbot://...")` decorator inside `register(mcp)` functions, serialize all data to JSON strings, use `user_id="default"` for single-user deployment consistency with existing tool pattern. + +--- + +## Standard Stack + +### Core (already installed) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `mcp[fastmcp]` | `>=1.8.0,<2.0.0` | `@mcp.resource()` decorator, URI template routing | Established in Phase 1; used for all 9 tools | +| `anyio` | existing | `to_thread.run_sync()` for sync store calls | Established in Phase 2 | +| `json` | stdlib | Serialize Dict/List to JSON string for resource content | No additional dep needed | +| `PyYAML` | existing | Already dep; SubscriptionService uses it | No additional dep needed | + +### No New Dependencies +All 4 resources wrap existing stores and services. Zero new packages required. + +**Installation:** No changes needed. + +--- + +## Architecture Patterns + +### Recommended File Structure (Phase 4 additions) +``` +src/paperbot/mcp/ +├── server.py # existing: add resource imports + register calls +├── tools/ # existing: 9 tool modules +└── resources/ # NEW directory + ├── __init__.py # NEW: empty + ├── track_metadata.py # NEW: paperbot://track/{id} + ├── track_papers.py # NEW: paperbot://track/{id}/papers + ├── track_memory.py # NEW: paperbot://track/{id}/memory + └── scholars.py # NEW: paperbot://scholars + +tests/unit/ +├── test_mcp_track_metadata.py # NEW: covers MCP-06 +├── test_mcp_track_papers.py # NEW: covers MCP-07 +├── test_mcp_track_memory.py # NEW: covers MCP-08 +└── test_mcp_scholars.py # NEW: covers MCP-09 + +tests/integration/ +└── test_mcp_tool_calls.py # existing: add resource discovery checks +``` + +### Pattern 1: Resource module structure (mirrors tool pattern) +**What:** One file per resource, `_impl` async function + `register(mcp)` function. `_impl` is called by the `@mcp.resource` handler and also directly from tests. +**When to use:** All 4 resources in Phase 4. + +```python +# Source: mirrors src/paperbot/mcp/tools/save_to_memory.py pattern +from __future__ import annotations + +import json +import logging +import time +from typing import Any, Dict, Optional + +import anyio + +logger = logging.getLogger(__name__) + +_store = None # module-level lazy singleton + +def _get_store(): + global _store + if _store is None: + from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + _store = SqlAlchemyResearchStore() + return _store + +async def _track_metadata_impl(track_id: str) -> str: + """Fetch track metadata dict and return as JSON string.""" + tid = int(track_id) + store = _get_store() + track = await anyio.to_thread.run_sync( + lambda: store.get_track_by_id(track_id=tid) + ) + if track is None: + return json.dumps({"error": f"Track {tid} not found"}) + return json.dumps(track) + +def register(mcp) -> None: + @mcp.resource("paperbot://track/{track_id}") + async def track_metadata(track_id: str) -> str: + """Read PaperBot research track metadata by ID.""" + return await _track_metadata_impl(track_id) +``` + +### Pattern 2: URI templates vs static resources +**What:** Resources with `{param}` in their URI are registered as resource templates (appear in `resources/templates/list`). Static URIs appear in `resources/list`. +**When to use:** +- `paperbot://track/{id}` → template (MCP-06) +- `paperbot://track/{id}/papers` → template (MCP-07) +- `paperbot://track/{id}/memory` → template (MCP-08) +- `paperbot://scholars` → static resource (MCP-09) + +**Important:** The Phase 4 success criterion states "All 4 resources appear in MCP resources/list." In MCP protocol terms, URI templates appear in `list_resource_templates`, not `list_resources`. Both collectively satisfy "available to agents." For integration tests, verify by checking source code references (same approach as Phase 2/3 with Python 3.9 constraint). + +### Pattern 3: JSON string return type +**What:** Resource functions return `str` (JSON-serialized). FastMCP sends this as `TextResourceContents` with `mime_type="text/plain"` by default. Specify `mime_type="application/json"` for clarity. +**When to use:** All 4 resources (all return structured data). + +```python +# Recommended: explicit mime_type for JSON responses +@mcp.resource("paperbot://scholars", mime_type="application/json") +async def scholars() -> str: + return await _scholars_impl() +``` + +### Pattern 4: anyio wrapping for sync store calls +**What:** All store methods (`SqlAlchemyResearchStore`, `SqlAlchemyMemoryStore`, `SubscriptionService`) are synchronous. Use `anyio.to_thread.run_sync(lambda: ...)`. +**When to use:** All 4 resources — none of the backing services are async. + +```python +# Source: established in Phase 2 (paper_judge.py), Phase 3 (save_to_memory.py) +result = await anyio.to_thread.run_sync( + lambda: store.get_track_by_id(track_id=int(track_id)) +) +``` + +### Pattern 5: `user_id="default"` for memory and track_feed queries +**What:** `SqlAlchemyResearchStore.list_track_feed()` and `SqlAlchemyMemoryStore.list_memories()` require `user_id`. MCP resources don't have a caller identity. Use `"default"` consistently. +**When to use:** `track_papers` (MCP-07) and `track_memory` (MCP-08). +**Why:** Matches the convention established by `get_research_context` and `save_to_memory` tools. + +### Anti-Patterns to Avoid +- **Using `list_track_feed` for MCP-07 with default `user_id="default"` when tracks belong to different users:** `get_track_by_id()` does NOT require `user_id` (it's a global lookup by ID). But `list_track_feed()` requires `user_id`. Use `user_id="default"` consistently since PaperBot is a single-user deployment for MCP use. +- **Returning Python dicts directly from resource handlers:** FastMCP accepts `str` return for resources, not arbitrary Python objects. Always `json.dumps(data)` before returning. +- **Registering resources in `tools/` directory:** Keep resources in `resources/` subdirectory for clear separation of MCP primitives. +- **Calling `SubscriptionService().get_scholars()` instead of `get_scholar_configs()`:** `get_scholars()` returns `Scholar` domain objects which need `.to_dict()` conversion. `get_scholar_configs()` returns raw dicts directly from config — simpler and no domain object dependency. +- **Assuming `get_track_by_id()` scope matches `list_track_feed()` scope:** `get_track_by_id()` has no user scoping (global lookup). `list_track_feed()` is user-scoped. For MCP resources serving a single-user deployment, use `user_id="default"` for `list_track_feed`. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Track metadata lookup | Custom SQLAlchemy query | `SqlAlchemyResearchStore.get_track_by_id(track_id=int)` | Returns pre-formatted dict with `_track_to_dict()` — all fields properly typed/serialized | +| Track paper listing | Custom paper JOIN query | `SqlAlchemyResearchStore.list_track_feed(user_id, track_id, limit)` | Handles feedback scoring, dedup, cap logic already | +| Track memory listing | Custom memory query | `SqlAlchemyMemoryStore.list_memories(user_id, scope_type="track", scope_id=str(id))` | Handles deleted/expired/pending filtering, status="approved" default | +| Scholar config reading | Direct YAML parse | `SubscriptionService.get_scholar_configs()` | Handles config path resolution, validation, caching | + +**Key insight:** Phase 4 resources are thin JSON serializers over existing store methods. The MCP resource layer is: parameter extraction → anyio thread call → json.dumps(). + +--- + +## Common Pitfalls + +### Pitfall 1: `track_id` comes in as `str` from URI template +**What goes wrong:** FastMCP extracts URI template parameters as strings. `store.get_track_by_id(track_id="42")` will fail because `track_id` is typed as `int` in the store API. +**Why it happens:** URI template parameters are always strings in the MCP protocol. +**How to avoid:** Always cast `int(track_id)` before calling store methods. Wrap in try/except for invalid (non-numeric) IDs. +**Warning signs:** `TypeError: int() argument must be a string or number, not 'str'` or SQLAlchemy type errors. + +### Pitfall 2: Track not found returns empty response instead of useful message +**What goes wrong:** `get_track_by_id()` returns `None` when the track doesn't exist. If the resource handler returns `None` or crashes, the agent gets an unhelpful error. +**Why it happens:** MCP resources are read-only; returning `None` to FastMCP is not a valid resource content. +**How to avoid:** Always check for `None` and return a JSON error object: `json.dumps({"error": f"Track {track_id} not found"})`. + +### Pitfall 3: `list_track_feed` requires `user_id` — must default to "default" +**What goes wrong:** `list_track_feed(user_id=None, track_id=42)` raises AttributeError or returns empty because there are no tracks owned by `None`. +**Why it happens:** `list_track_feed` filters by `user_id` in the WHERE clause. +**How to avoid:** Hardcode `user_id="default"` in the resource handler. Document this assumption in the module docstring. + +### Pitfall 4: `SubscriptionService` FileNotFoundError on missing config +**What goes wrong:** If `config/scholar_subscriptions.yaml` doesn't exist, `SubscriptionService.load_config()` raises `FileNotFoundError`. +**Why it happens:** The service uses a hardcoded path relative to the project root. In test environments without the config file, this will fail. +**How to avoid:** Wrap the `SubscriptionService` call in try/except, return `json.dumps({"error": "Scholar config not found", "scholars": []})` on `FileNotFoundError`. In tests, inject a fake service via the module-level singleton. + +### Pitfall 5: `list_memories` returns ALL scope types unless filtered +**What goes wrong:** Without `scope_type="track"` and `scope_id=str(track_id)`, `list_memories` returns all memories for the user (global + track + project). For `paperbot://track/{id}/memory`, only track-scoped memories should be returned. +**Why it happens:** `list_memories()` signature accepts optional `scope_type` and `scope_id` — they're not required. +**How to avoid:** Always pass both `scope_type="track"` and `scope_id=str(int(track_id))` to `list_memories()` in the track memory resource. + +### Pitfall 6: URI template resources appear in `list_resource_templates`, not `list_resources` +**What goes wrong:** Integration tests checking `resources/list` exclusively will not find template resources. The success criterion says "All 4 resources appear in MCP resources/list" — this likely means the combined resource discovery surface. +**Why it happens:** MCP protocol distinction: static URIs → `list_resources`; URI templates → `list_resource_templates`. +**How to avoid:** For integration tests (which don't invoke FastMCP directly due to Python 3.9 constraint), verify by checking server.py source code for all 4 `register()` calls, same pattern as tool discovery tests in Phase 2/3. Name the test class `TestMCPResourceListing` and document the templates-vs-list distinction clearly. + +--- + +## Code Examples + +Verified patterns from codebase: + +### Track metadata resource +```python +# Source: src/paperbot/infrastructure/stores/research_store.py line 314 +# get_track_by_id() - no user_id required, global ID lookup +# Returns: {"id": int, "name": str, "description": str, "keywords": List[str], +# "venues": List[str], "methods": List[str], "is_active": bool, +# "archived_at": str|None, "created_at": str|None, "updated_at": str|None} + +store = SqlAlchemyResearchStore() +track = store.get_track_by_id(track_id=42) +# track is None if not found +``` + +### Track papers resource +```python +# Source: src/paperbot/infrastructure/stores/research_store.py line 930 +# list_track_feed() - requires user_id; returns {"items": [...], "total": int} +# items are paper dicts from _paper_to_dict() (id, title, abstract, authors, etc.) + +store = SqlAlchemyResearchStore() +feed = store.list_track_feed(user_id="default", track_id=42, limit=50) +# feed["items"] is a List[Dict] of papers +# feed["total"] is total count +``` + +### Track memory resource +```python +# Source: src/paperbot/infrastructure/stores/memory_store.py line 706 +# list_memories() with scope filtering -- returns approved, non-deleted, non-expired items + +store = SqlAlchemyMemoryStore() +memories = store.list_memories( + user_id="default", + scope_type="track", + scope_id=str(42), # scope_id must be str, not int + limit=100, +) +# returns List[Dict] with id, content, kind, confidence, scope_type, scope_id, etc. +``` + +### Scholar subscriptions resource +```python +# Source: src/paperbot/infrastructure/services/subscription_service.py line 123 +# get_scholar_configs() - returns raw config dicts (no domain object conversion) +# Each dict has: name, semantic_scholar_id, keywords (optional), affiliations (optional), etc. + +svc = SubscriptionService() # defaults to config/scholar_subscriptions.yaml +scholars = svc.get_scholar_configs() +# returns [{"name": "Dawn Song", "semantic_scholar_id": "1741101", "keywords": [...], ...}, ...] +``` + +### server.py resource registration pattern +```python +# Source: src/paperbot/mcp/server.py (current state -- to be extended) +# Add after existing tool registrations: + +from paperbot.mcp.resources import track_metadata +from paperbot.mcp.resources import track_papers +from paperbot.mcp.resources import track_memory +from paperbot.mcp.resources import scholars + +track_metadata.register(mcp) +track_papers.register(mcp) +track_memory.register(mcp) +scholars.register(mcp) +``` + +### Full resource module example (track_metadata) +```python +# Recommended implementation pattern +from __future__ import annotations + +import json +import logging +from typing import Any + +import anyio + +logger = logging.getLogger(__name__) + +_store = None + + +def _get_store(): + global _store + if _store is None: + from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + _store = SqlAlchemyResearchStore() + return _store + + +async def _track_metadata_impl(track_id: str) -> str: + """Fetch track metadata and return as JSON string. + + Args: + track_id: Track ID as string (URI template extracts strings). + + Returns: + JSON-encoded track metadata dict, or error dict if not found. + """ + try: + tid = int(track_id) + except (ValueError, TypeError): + return json.dumps({"error": f"Invalid track_id: {track_id!r}"}) + + store = _get_store() + track = await anyio.to_thread.run_sync( + lambda: store.get_track_by_id(track_id=tid) + ) + if track is None: + return json.dumps({"error": f"Track {tid} not found"}) + return json.dumps(track) + + +def register(mcp) -> None: + """Register paperbot://track/{track_id} resource on the given FastMCP instance.""" + + @mcp.resource("paperbot://track/{track_id}", mime_type="application/json") + async def track_metadata(track_id: str) -> str: + """Read PaperBot research track metadata by ID. + + Returns track name, description, keywords, venues, methods, + and status for the given track ID. + """ + return await _track_metadata_impl(track_id) +``` + +--- + +## Resource URI Scheme + +| Resource | URI | Type | Backing API | +|----------|-----|------|-------------| +| Track metadata | `paperbot://track/{track_id}` | Template | `SqlAlchemyResearchStore.get_track_by_id(track_id=int)` | +| Track papers | `paperbot://track/{track_id}/papers` | Template | `SqlAlchemyResearchStore.list_track_feed(user_id="default", track_id=int, limit=50)` | +| Track memory | `paperbot://track/{track_id}/memory` | Template | `SqlAlchemyMemoryStore.list_memories(user_id="default", scope_type="track", scope_id=str(id))` | +| Scholar subscriptions | `paperbot://scholars` | Static | `SubscriptionService.get_scholar_configs()` | + +**URI scheme chosen:** `paperbot://` prefix is consistent with the requirement spec. It avoids collision with standard schemes (`file://`, `data://`, `config://`). + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Tools for data retrieval (requires parameters, side effects possible) | Resources for read-only data access | Phase 4 | Agents can use `read_resource` instead of `call_tool` for structured data | +| `@mcp.tool()` only | `@mcp.tool()` + `@mcp.resource()` | Phase 4 | MCP server now surfaces both primitives | +| Static URIs only | URI templates with `{param}` | Phase 4 | Enables per-track resource access without separate static resources per track ID | + +**Deprecated/outdated:** +- None from Phase 3 — all tool patterns continue unchanged. + +--- + +## Open Questions + +1. **`list_track_feed` vs direct paper query for MCP-07** + - What we know: `list_track_feed()` is the only existing "papers for a track" API in `SqlAlchemyResearchStore`. It does fuzzy term matching (keywords/venues/methods), not a strict FK join. + - What's unclear: Does it return papers explicitly associated with the track, or papers that match track keywords? The answer: it matches papers by track keywords/methods/venues, NOT by explicit track-paper FK. There is no explicit assignment of papers to tracks. + - Recommendation: Use `list_track_feed()` as-is — it's the intended API for "papers relevant to a track." Document this in the resource module docstring. The `limit=50` default is reasonable. + +2. **`paperbot://track/{id}/memory` — which `user_id`?** + - What we know: `list_memories()` requires `user_id`. PaperBot MCP tools use `"default"` universally. + - What's unclear: Could there be multi-user deployments where memories belong to non-default users? + - Recommendation: Use `user_id="default"` for Phase 4, consistent with `save_to_memory` and `get_research_context` tools. Document this assumption. + +3. **Should `paperbot://scholars` reflect live config or a cached snapshot?** + - What we know: `SubscriptionService.get_scholar_configs()` caches after first load (`self._config` is set once). If the YAML file is edited at runtime, the cached version will be stale until process restart. + - What's unclear: Does the agent use case require fresh reads on each access? + - Recommendation: Accept the caching behavior (consistent with how `SubscriptionService` works throughout the codebase). For Phase 4, instantiate a fresh `SubscriptionService()` per MCP call (no module-level singleton for `scholars.py`) to ensure fresh reads. Cost is negligible (YAML parse is fast). + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest with pytest-asyncio (asyncio_mode = "strict") | +| Config file | `pyproject.toml` — `[tool.pytest.ini_options]` | +| Quick run command | `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -q` | +| Full suite command | `PYTHONPATH=src pytest -q` | + +### Phase Requirements to Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| MCP-06 | `_track_metadata_impl("42")` returns JSON with id, name, description, keywords | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py -x` | ❌ Wave 0 | +| MCP-06 | `_track_metadata_impl("99")` returns JSON error when track not found | unit | same | ❌ Wave 0 | +| MCP-06 | `_track_metadata_impl("abc")` returns JSON error for non-integer track_id | unit | same | ❌ Wave 0 | +| MCP-07 | `_track_papers_impl("42")` returns JSON with items list | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_papers.py -x` | ❌ Wave 0 | +| MCP-07 | Returns empty items list when track has no matching papers | unit | same | ❌ Wave 0 | +| MCP-08 | `_track_memory_impl("42")` returns JSON list of memories | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_memory.py -x` | ❌ Wave 0 | +| MCP-08 | Returns empty list when no memories exist for track | unit | same | ❌ Wave 0 | +| MCP-09 | `_scholars_impl()` returns JSON list of scholar dicts with name and semantic_scholar_id | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_scholars.py -x` | ❌ Wave 0 | +| MCP-09 | Returns empty list when config file not found | unit | same | ❌ Wave 0 | +| All 4 | All 4 resource modules expose `register()` and `_impl` functions | integration | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -k resource` | ✅ (needs update) | +| All 4 | server.py imports and calls `register()` for all 4 resource modules | integration | same | ✅ (needs update) | + +### Sampling Rate +- **Per task commit:** Run the specific resource's unit tests (e.g., `pytest tests/unit/test_mcp_track_metadata.py -q`) +- **Per wave merge:** `PYTHONPATH=src pytest tests/unit/test_mcp_track_*.py tests/unit/test_mcp_scholars.py tests/integration/test_mcp_tool_calls.py -q` +- **Phase gate:** Full CI offline suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `src/paperbot/mcp/resources/__init__.py` — new directory marker +- [ ] `src/paperbot/mcp/resources/track_metadata.py` — covers MCP-06 +- [ ] `src/paperbot/mcp/resources/track_papers.py` — covers MCP-07 +- [ ] `src/paperbot/mcp/resources/track_memory.py` — covers MCP-08 +- [ ] `src/paperbot/mcp/resources/scholars.py` — covers MCP-09 +- [ ] `tests/unit/test_mcp_track_metadata.py` — 3+ tests for MCP-06 +- [ ] `tests/unit/test_mcp_track_papers.py` — 2+ tests for MCP-07 +- [ ] `tests/unit/test_mcp_track_memory.py` — 2+ tests for MCP-08 +- [ ] `tests/unit/test_mcp_scholars.py` — 2+ tests for MCP-09 +- [ ] `tests/integration/test_mcp_tool_calls.py` — add `TestMCPResourceListing` class + +--- + +## Sources + +### Primary (HIGH confidence) +- `src/paperbot/mcp/server.py` — FastMCP instance, established `register(mcp)` pattern +- `src/paperbot/mcp/tools/save_to_memory.py` — module-level lazy singleton, anyio wrapping +- `src/paperbot/infrastructure/stores/research_store.py` line 314 — `get_track_by_id(track_id=int)` API +- `src/paperbot/infrastructure/stores/research_store.py` line 930 — `list_track_feed(user_id, track_id, limit)` API +- `src/paperbot/infrastructure/stores/research_store.py` line 1881 — `_track_to_dict()` field spec +- `src/paperbot/infrastructure/stores/research_store.py` line 2003 — `_paper_to_dict()` field spec +- `src/paperbot/infrastructure/stores/memory_store.py` line 706 — `list_memories(user_id, scope_type, scope_id)` API +- `src/paperbot/infrastructure/services/subscription_service.py` line 123 — `get_scholar_configs()` API +- `config/scholar_subscriptions.yaml` — actual scholar config structure (name, semantic_scholar_id, keywords) +- `tests/integration/test_mcp_tool_calls.py` — existing test structure to extend +- `tests/unit/test_mcp_analyze_trends.py` — fake injection + `@pytest.mark.asyncio` unit test pattern +- `src/paperbot/mcp/tools/_audit.py` — audit helper (resources may optionally log, but no requirement to) + +### Secondary (MEDIUM confidence) +- [FastMCP Resources & Templates](https://gofastmcp.com/servers/resources) — `@mcp.resource()` decorator API, URI template syntax, return types +- [MCP Python SDK Issue #141](https://github.com/modelcontextprotocol/python-sdk/issues/141) — URI templates appear in `list_resource_templates`, NOT `list_resources` +- `pyproject.toml` — `asyncio_mode = "strict"` confirmed; `@pytest.mark.asyncio` required + +### Tertiary (LOW confidence) +- FastMCP 3.x standalone package docs — Phase 4 uses `mcp.server.fastmcp` (v1.x SDK), not standalone `fastmcp` package; API is compatible but version-specific behavior unverified + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — MCP package already in use, anyio already in use, no new deps +- Architecture (register pattern): HIGH — directly extends established Phase 2/3 tool pattern +- Backend APIs: HIGH — directly reading store source code, method signatures verified +- FastMCP resource decorator API: MEDIUM — verified from official docs + GitHub issue; actual `@mcp.resource` behavior with Python 3.9 not testable (same constraint as tools) +- Pitfalls: HIGH — derived from reading actual store code (user_id requirements, scope_id str cast, None returns) + +**Research date:** 2026-03-14 +**Valid until:** 2026-04-14 (stable application code; MCP API docs valid until major version bump) diff --git a/.planning/phases/04-mcp-resources/04-UAT.md b/.planning/phases/04-mcp-resources/04-UAT.md new file mode 100644 index 00000000..0008562f --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-UAT.md @@ -0,0 +1,49 @@ +--- +status: testing +phase: 04-mcp-resources +source: [04-01-SUMMARY.md, 04-02-SUMMARY.md] +started: 2026-03-14T05:15:00Z +updated: 2026-03-14T05:15:00Z +--- + +## Current Test + +number: 2 +name: Track Resource Unit Tests Pass +expected: | + Run `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py -x -q`. All 10 tests pass covering metadata (normal, not-found, invalid-id), papers (normal, empty, invalid-id), and memory (normal, empty, scope-filter, invalid-id). +awaiting: user response + +## Tests + +### 1. Cold Start Smoke Test +expected: Run `PYTHONPATH=src python -c "import paperbot.mcp.server; print('server imports OK')"`. Server module imports without errors, confirming all 4 resource modules and 9 tool modules load cleanly. +result: pass + +### 2. Track Resource Unit Tests Pass +expected: Run `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py -x -q`. All 10 tests pass covering metadata (normal, not-found, invalid-id), papers (normal, empty, invalid-id), and memory (normal, empty, scope-filter, invalid-id). +result: [pending] + +### 3. Scholars Resource Unit Tests Pass +expected: Run `PYTHONPATH=src pytest tests/unit/test_mcp_scholars.py -x -q`. Both tests pass covering normal scholar list return and FileNotFoundError handling. +result: [pending] + +### 4. Integration Tests Pass (Resources + Tools) +expected: Run `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -q`. All 34 tests pass (31 tool tests + 3 new resource tests). TestMCPResourceListing confirms all 4 resources are listed, registered in server.py, and have correct signatures. +result: [pending] + +### 5. Resource Module Pattern Consistency +expected: Run `PYTHONPATH=src python -c "from paperbot.mcp.resources import track_metadata, track_papers, track_memory, scholars; assert all(hasattr(m, 'register') and callable(m.register) for m in [track_metadata, track_papers, track_memory, scholars]); print('All 4 resources have register()')"`. All 4 resource modules export a callable `register()` function following the established pattern. +result: [pending] + +## Summary + +total: 5 +passed: 1 +issues: 0 +pending: 4 +skipped: 0 + +## Gaps + +[none yet] diff --git a/.planning/phases/04-mcp-resources/04-VALIDATION.md b/.planning/phases/04-mcp-resources/04-VALIDATION.md new file mode 100644 index 00000000..9b775ba5 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-VALIDATION.md @@ -0,0 +1,82 @@ +--- +phase: 4 +slug: mcp-resources +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-14 +--- + +# Phase 4 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest with pytest-asyncio (asyncio_mode = "strict") | +| **Config file** | `pyproject.toml` — `[tool.pytest.ini_options]` | +| **Quick run command** | `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -q` | +| **Full suite command** | `PYTHONPATH=src pytest -q` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -q` +- **After every plan wave:** Run `PYTHONPATH=src pytest -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 5 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 04-01-01 | 01 | 1 | MCP-06 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py -x` | ❌ W0 | ⬜ pending | +| 04-01-02 | 01 | 1 | MCP-06 | unit | same | ❌ W0 | ⬜ pending | +| 04-01-03 | 01 | 1 | MCP-06 | unit | same | ❌ W0 | ⬜ pending | +| 04-01-04 | 01 | 1 | MCP-07 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_papers.py -x` | ❌ W0 | ⬜ pending | +| 04-01-05 | 01 | 1 | MCP-07 | unit | same | ❌ W0 | ⬜ pending | +| 04-01-06 | 01 | 1 | MCP-08 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_track_memory.py -x` | ❌ W0 | ⬜ pending | +| 04-01-07 | 01 | 1 | MCP-08 | unit | same | ❌ W0 | ⬜ pending | +| 04-01-08 | 01 | 1 | MCP-09 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_scholars.py -x` | ❌ W0 | ⬜ pending | +| 04-01-09 | 01 | 1 | MCP-09 | unit | same | ❌ W0 | ⬜ pending | +| 04-02-01 | 02 | 2 | All 4 | integration | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -k resource` | ❌ W0 | ⬜ pending | +| 04-02-02 | 02 | 2 | All 4 | integration | same | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/unit/test_mcp_track_metadata.py` — 3+ tests for MCP-06 (valid, not found, invalid ID) +- [ ] `tests/unit/test_mcp_track_papers.py` — 2+ tests for MCP-07 (with papers, empty) +- [ ] `tests/unit/test_mcp_track_memory.py` — 2+ tests for MCP-08 (with memories, empty) +- [ ] `tests/unit/test_mcp_scholars.py` — 2+ tests for MCP-09 (with scholars, missing config) +- [ ] `tests/integration/test_mcp_tool_calls.py` — add `TestMCPResourceListing` class + +--- + +## Manual-Only Verifications + +*All phase behaviors have automated verification.* + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 5s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/04-mcp-resources/04-VERIFICATION.md b/.planning/phases/04-mcp-resources/04-VERIFICATION.md new file mode 100644 index 00000000..cb7407d4 --- /dev/null +++ b/.planning/phases/04-mcp-resources/04-VERIFICATION.md @@ -0,0 +1,137 @@ +--- +phase: 04-mcp-resources +verified: 2026-03-14T05:30:00Z +status: passed +score: 13/13 must-haves verified +re_verification: false +--- + +# Phase 4: MCP Resources Verification Report + +**Phase Goal:** Implement MCP resources (read-only data access via paperbot:// URI scheme) +**Verified:** 2026-03-14T05:30:00Z +**Status:** passed +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths (Plan 01) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | `_track_metadata_impl('42')` returns JSON with track id, name, description, keywords, venues, methods | VERIFIED | `track_metadata.py` lines 39-50; test passes at `test_returns_track_metadata_for_valid_id` | +| 2 | `_track_metadata_impl('99')` returns JSON error when track not found | VERIFIED | None check + `json.dumps({"error": ...})` at line 48; `test_returns_error_when_track_not_found` passes | +| 3 | `_track_metadata_impl('abc')` returns JSON error for non-integer track_id | VERIFIED | `ValueError` guard at line 42; `test_returns_error_for_non_integer_track_id` passes | +| 4 | `_track_papers_impl('42')` returns JSON with items list of paper dicts | VERIFIED | `list_track_feed` call + `json.dumps(feed)` at line 49; `test_returns_papers_for_valid_track` passes | +| 5 | `_track_papers_impl` returns empty items list when track has no matching papers | VERIFIED | Store returns `{"items": [], "total": 0}`; `test_returns_empty_items_when_track_has_no_papers` passes | +| 6 | `_track_memory_impl('42')` returns JSON list of memory dicts scoped to track | VERIFIED | `list_memories(user_id="default", scope_type="track", scope_id=str(tid), limit=100)` at lines 46-52; passes | +| 7 | `_track_memory_impl` returns empty list when no memories exist for track | VERIFIED | `test_returns_empty_list_when_no_memories` passes | +| 8 | `_scholars_impl()` returns JSON list of scholar dicts with name and semantic_scholar_id | VERIFIED | `anyio.to_thread.run_sync(service.get_scholar_configs)` + `json.dumps(scholars)`; `test_returns_scholar_list` passes | +| 9 | `_scholars_impl()` returns error JSON when config file not found | VERIFIED | `except FileNotFoundError` at line 41 returns `{"error": "Scholar config not found", "scholars": []}`; test passes | + +**Plan 01 Score:** 9/9 truths verified + +### Observable Truths (Plan 02) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 10 | All 4 resource modules are imported and registered in server.py | VERIFIED | `server.py` lines 39-47 — 4 imports + 4 `register(mcp)` calls within `try:` block | +| 11 | server.py source contains register() calls for track_metadata, track_papers, track_memory, scholars | VERIFIED | All 4 patterns confirmed by direct read of `server.py` | +| 12 | Integration tests verify all 4 resource modules expose register() and _impl functions | VERIFIED | `TestMCPResourceListing.test_all_four_resources_listed` — 3 integration tests pass | +| 13 | Integration tests verify server.py imports all 4 resource modules | VERIFIED | `test_server_registers_all_four_resources` uses `inspect.getsource` to confirm presence | + +**Plan 02 Score:** 4/4 truths verified + +**Overall Score:** 13/13 truths verified + +--- + +## Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `src/paperbot/mcp/resources/__init__.py` | Package marker | VERIFIED | Exists, contains module docstring | +| `src/paperbot/mcp/resources/track_metadata.py` | paperbot://track/{track_id} resource (MCP-06) | VERIFIED | Exports `_track_metadata_impl` and `register`; 64 lines | +| `src/paperbot/mcp/resources/track_papers.py` | paperbot://track/{track_id}/papers resource (MCP-07) | VERIFIED | Exports `_track_papers_impl` and `register`; 64 lines | +| `src/paperbot/mcp/resources/track_memory.py` | paperbot://track/{track_id}/memory resource (MCP-08) | VERIFIED | Exports `_track_memory_impl` and `register`; 69 lines | +| `src/paperbot/mcp/resources/scholars.py` | paperbot://scholars resource (MCP-09) | VERIFIED | Exports `_scholars_impl` and `register`; 57 lines | +| `tests/unit/test_mcp_track_metadata.py` | Unit tests for MCP-06 (min 40 lines) | VERIFIED | 74 lines, 3 tests | +| `tests/unit/test_mcp_track_papers.py` | Unit tests for MCP-07 (min 30 lines) | VERIFIED | 66 lines, 3 tests | +| `tests/unit/test_mcp_track_memory.py` | Unit tests for MCP-08 (min 30 lines) | VERIFIED | 92 lines, 4 tests | +| `tests/unit/test_mcp_scholars.py` | Unit tests for MCP-09 (min 30 lines) | VERIFIED | 61 lines, 2 tests | +| `src/paperbot/mcp/server.py` | FastMCP server with 9 tools + 4 resources registered; contains `track_metadata.register` | VERIFIED | All 4 resource register() calls present; 54 lines total | +| `tests/integration/test_mcp_tool_calls.py` | Integration tests including `TestMCPResourceListing` | VERIFIED | Class exists at line 1034; EXPECTED_RESOURCES list at 1026; 1142 lines total | + +--- + +## Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `track_metadata.py` | `SqlAlchemyResearchStore.get_track_by_id` | `anyio.to_thread.run_sync(lambda: store.get_track_by_id(tid))` | WIRED | Line 45: exact pattern confirmed | +| `track_papers.py` | `SqlAlchemyResearchStore.list_track_feed` | `anyio.to_thread.run_sync` with `user_id="default"` | WIRED | Lines 45-47: lambda wraps `list_track_feed(user_id="default", track_id=tid, limit=50)` | +| `track_memory.py` | `SqlAlchemyMemoryStore.list_memories` | `anyio.to_thread.run_sync` with `scope_type="track", scope_id=str(tid)` | WIRED | Lines 45-52: both scope args explicitly set; test verifies args at runtime | +| `scholars.py` | `SubscriptionService.get_scholar_configs` | `anyio.to_thread.run_sync(service.get_scholar_configs)` | WIRED | Line 39: direct method reference (no lambda needed — no args) | +| `server.py` | `track_metadata.py` | `import + register(mcp)` | WIRED | Lines 39, 44: import + call inside `try:` block | +| `server.py` | `track_papers.py` | `import + register(mcp)` | WIRED | Lines 40, 45 | +| `server.py` | `track_memory.py` | `import + register(mcp)` | WIRED | Lines 41, 46 | +| `server.py` | `scholars.py` | `import + register(mcp)` | WIRED | Lines 42, 47 | + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|---------| +| MCP-06 | 04-01, 04-02 | Agent can read track metadata via `paperbot://track/{id}` resource | SATISFIED | `track_metadata.py` implements `@mcp.resource("paperbot://track/{track_id}", mime_type="application/json")`; registered in `server.py`; 3 unit tests + integration tests pass | +| MCP-07 | 04-01, 04-02 | Agent can read track paper list via `paperbot://track/{id}/papers` resource | SATISFIED | `track_papers.py` implements `@mcp.resource("paperbot://track/{track_id}/papers", ...)`; registered; 3 unit tests pass | +| MCP-08 | 04-01, 04-02 | Agent can read track memory via `paperbot://track/{id}/memory` resource | SATISFIED | `track_memory.py` implements `@mcp.resource("paperbot://track/{track_id}/memory", ...)`; scope_type="track" filtering verified by test; 4 unit tests pass | +| MCP-09 | 04-01, 04-02 | Agent can read scholar subscriptions via `paperbot://scholars` resource | SATISFIED | `scholars.py` implements `@mcp.resource("paperbot://scholars", ...)`; static URI; FileNotFoundError handled; 2 unit tests pass | + +No orphaned requirements: REQUIREMENTS.md Traceability table maps exactly MCP-06/07/08/09 to Phase 4, all accounted for by the two plans. + +--- + +## Anti-Patterns Found + +None. Scans for TODO/FIXME/HACK/PLACEHOLDER, empty returns (`return null`, `return {}`, `return []`), and stub handler patterns all returned no matches across all 5 resource modules and 4 unit test files. + +--- + +## Human Verification Required + +None — all goal behaviors are programmatically verifiable via the test suite and static analysis. The resources provide JSON over MCP protocol; there is no UI, no visual appearance, and no real-time streaming behavior to assess. + +--- + +## Test Run Results + +| Suite | Command | Result | +|-------|---------|--------| +| Resource unit tests | `PYTHONPATH=src pytest tests/unit/test_mcp_track_metadata.py tests/unit/test_mcp_track_papers.py tests/unit/test_mcp_track_memory.py tests/unit/test_mcp_scholars.py -x -q` | 12 passed | +| Resource integration tests | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -q -k resource` | 3 passed | +| Full integration suite (regression) | `PYTHONPATH=src pytest tests/integration/test_mcp_tool_calls.py -x -q` | 34 passed (31 tools + 3 resources) | + +All tests run green. No regressions against Phase 3 tool tests. + +--- + +## Verified Commit History + +All commits documented in the SUMMARYs exist in git history: + +| Commit | Description | +|--------|-------------| +| `2e68e84` | test(04-01): add failing tests for track resource impls | +| `877f984` | feat(04-01): implement track metadata, papers, memory resources | +| `5ef18e7` | test(04-01): add failing tests for scholars resource | +| `45e3d37` | feat(04-01): implement scholars resource | +| `6490feb` | feat(04-02): register 4 MCP resources in server.py | +| `7fa3ed4` | feat(04-02): add TestMCPResourceListing integration tests | + +--- + +_Verified: 2026-03-14T05:30:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/05-transport-entry-point/05-01-PLAN.md b/.planning/phases/05-transport-entry-point/05-01-PLAN.md new file mode 100644 index 00000000..863d9d96 --- /dev/null +++ b/.planning/phases/05-transport-entry-point/05-01-PLAN.md @@ -0,0 +1,275 @@ +--- +phase: 05-transport-entry-point +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - src/paperbot/mcp/serve.py + - src/paperbot/presentation/cli/main.py + - pyproject.toml + - requirements.txt + - tests/unit/test_mcp_serve_cli.py +autonomous: true +requirements: [MCP-10, MCP-11, MCP-12] + +must_haves: + truths: + - "`paperbot mcp serve --stdio` starts MCP server on stdio transport" + - "`paperbot mcp serve --http` starts MCP server on Streamable HTTP transport" + - "`paperbot mcp serve --http --host 0.0.0.0 --port 9000` allows host/port override" + - "`paperbot mcp serve` (no flag) prints help without crashing" + - "`paperbot mcp` (no subcommand) prints help without crashing" + - "stdio mode sends zero bytes to stdout (logging goes to stderr only)" + - "Missing mcp package produces clear error message to stderr and exits non-zero" + artifacts: + - path: "src/paperbot/mcp/serve.py" + provides: "Transport dispatch functions" + exports: ["run_stdio", "run_http"] + - path: "src/paperbot/presentation/cli/main.py" + provides: "mcp serve subcommand in CLI parser" + contains: "mcp_parser" + - path: "pyproject.toml" + provides: "project.scripts entry + mcp dependency" + contains: "[project.scripts]" + - path: "requirements.txt" + provides: "mcp[fastmcp] dependency line" + contains: "mcp[fastmcp]" + - path: "tests/unit/test_mcp_serve_cli.py" + provides: "Unit tests for all transport and CLI behaviors" + key_links: + - from: "src/paperbot/presentation/cli/main.py" + to: "src/paperbot/mcp/serve.py" + via: "lazy import in _run_mcp_serve handler" + pattern: "from paperbot\\.mcp\\.serve import run_stdio, run_http" + - from: "src/paperbot/mcp/serve.py" + to: "src/paperbot/mcp/server.py" + via: "lazy import of mcp singleton" + pattern: "from paperbot\\.mcp\\.server import mcp" + - from: "pyproject.toml" + to: "src/paperbot/presentation/cli/main.py" + via: "project.scripts entry point" + pattern: 'paperbot = "paperbot.presentation.cli.main:run_cli"' +--- + + +Make the MCP server (9 tools + 4 resources built in Phases 2-4) runnable by agents via stdio and Streamable HTTP transports, with a CLI command to start either mode. + +Purpose: Without transport wiring, the MCP server singleton exists but cannot be reached by any agent. This plan connects it to the outside world. +Output: `serve.py` module, CLI `mcp serve` command, `[project.scripts]` entry, and comprehensive unit tests. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-transport-entry-point/05-RESEARCH.md + +@src/paperbot/mcp/server.py +@src/paperbot/presentation/cli/main.py +@pyproject.toml +@requirements.txt + + + + + +From src/paperbot/mcp/server.py: +```python +# mcp is either a FastMCP instance or None (when mcp package not installed) +mcp = FastMCP("paperbot") # or mcp = None +``` + +From src/paperbot/presentation/cli/main.py: +```python +def create_parser() -> argparse.ArgumentParser: + """Creates CLI argument parser with subparsers for each command.""" + # Existing subcommands: track, analyze, score, topic-search, daily-paper, export + subparsers = parser.add_subparsers(dest="command", help="...") + +def run_cli(args: Optional[list] = None) -> int: + """Runs CLI, dispatches parsed.command to handler functions. Returns exit code.""" + # Dispatch block: elif parsed.command == "xxx": ... +``` + +From pyproject.toml: +```toml +# Currently NO [project.scripts] section exists +# dependencies list does NOT include mcp[fastmcp] +[project] +name = "paperbot" +dependencies = [...] # mcp not listed +``` + + + + + + Task 1: Create serve.py module and update packaging + src/paperbot/mcp/serve.py, pyproject.toml, requirements.txt, tests/unit/test_mcp_serve_cli.py + + - TestServeModuleImport: `from paperbot.mcp.serve import run_stdio, run_http` succeeds, both are callable + - TestRunStdio: `run_stdio()` calls `mcp.run(transport="stdio")` exactly (verified via _FakeMCP stub that records calls) + - TestRunStdio: `run_stdio()` configures logging to stderr before calling mcp.run (verify logging.basicConfig called with stream=sys.stderr) + - TestRunHttp: `run_http()` calls `mcp.run(transport="streamable-http", host="127.0.0.1", port=8001)` with defaults + - TestRunHttp: `run_http(host="0.0.0.0", port=9000)` passes those values to `mcp.run()` + - TestMcpNoneGuard: When `mcp is None`, `run_stdio()` prints error to stderr and calls `sys.exit(1)` + - TestMcpNoneGuard: When `mcp is None`, `run_http()` prints error to stderr and calls `sys.exit(1)` + - TestPyprojectScripts: `pyproject.toml` contains `[project.scripts]` with `paperbot = "paperbot.presentation.cli.main:run_cli"` + - TestPyprojectDeps: `pyproject.toml` dependencies list includes `mcp[fastmcp]>=1.8.0,<2.0.0` + + + 1. Write tests FIRST in `tests/unit/test_mcp_serve_cli.py` covering the behaviors above. Use the `_FakeMCP` stub pattern (not unittest.mock) per project convention. The fake records `.run_calls` list. Use `monkeypatch.setattr("paperbot.mcp.server.mcp", fake)` + `importlib.reload(serve)` to inject the fake. For the None guard tests, set `mcp = None` via monkeypatch and catch `SystemExit`. For pyproject checks, parse `pyproject.toml` with `tomllib` (Python 3.11+) or read as text and assert substring presence. + + 2. Run tests -- they MUST fail (RED). + + 3. Create `src/paperbot/mcp/serve.py` with: + - `run_stdio()`: configures `logging.basicConfig(stream=sys.stderr, level=logging.WARNING)`, lazy-imports `mcp` from `paperbot.mcp.server`, checks `if mcp is None` -> print error to stderr + `sys.exit(1)`, calls `mcp.run(transport="stdio")` + - `run_http(host="127.0.0.1", port=8001)`: lazy-imports mcp, checks None guard, calls `mcp.run(transport="streamable-http", host=host, port=port)` + - Use `from __future__ import annotations` at top + - Include docstrings with `claude_desktop_config.json` example in `run_stdio()` + - Default HTTP port is 8001 (not 8000) to avoid conflict with FastAPI server + + 4. Update `pyproject.toml`: + - Add `"mcp[fastmcp]>=1.8.0,<2.0.0",` to the `dependencies` list + - Add new section at the end (before `[tool.*]` sections): + ``` + [project.scripts] + paperbot = "paperbot.presentation.cli.main:run_cli" + ``` + + 5. Update `requirements.txt`: Add line `mcp[fastmcp]>=1.8.0,<2.0.0` in the "FastAPI backend" section or near the end. + + 6. Run tests -- they MUST pass (GREEN). + + CRITICAL: Do NOT call `run_stdio()` or `run_http()` directly in tests without the _FakeMCP monkeypatch -- they block indefinitely. All tests must be synchronous (no async). The `_FakeMCP.run()` method must NOT block -- it just records the call and returns immediately. + + + PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -x -q + + + - `serve.py` exports `run_stdio` and `run_http` + - Both functions dispatch to `mcp.run()` with correct transport strings + - stdio mode redirects logging to stderr + - None guard exits cleanly with error message + - `pyproject.toml` has `[project.scripts]` entry and `mcp[fastmcp]` dependency + - `requirements.txt` includes `mcp[fastmcp]` + - All tests pass + + + + + Task 2: Add mcp serve subcommand to CLI + src/paperbot/presentation/cli/main.py, tests/unit/test_mcp_serve_cli.py + + - TestCLIServeCommand: `create_parser().parse_args(["mcp", "serve", "--stdio"])` sets `parsed.command="mcp"`, `parsed.mcp_command="serve"`, `parsed.stdio=True`, `parsed.http=False` + - TestCLIServeCommand: `create_parser().parse_args(["mcp", "serve", "--http"])` sets `parsed.http=True`, `parsed.stdio=False`, `parsed.host="127.0.0.1"`, `parsed.port=8001` + - TestCLIServeCommand: `create_parser().parse_args(["mcp", "serve", "--http", "--host", "0.0.0.0", "--port", "9000"])` sets host/port correctly + - TestCLIServeCommand: `create_parser().parse_args(["mcp", "serve", "--stdio", "--http"])` raises SystemExit (mutually exclusive) + - TestCLIServeCommand: `run_cli(["mcp", "serve", "--stdio"])` calls `run_stdio()` (verify via monkeypatch on `paperbot.mcp.serve.run_stdio`) + - TestCLIServeCommand: `run_cli(["mcp", "serve", "--http", "--port", "9000"])` calls `run_http(host="127.0.0.1", port=9000)` + - TestCLIServeCommand: `run_cli(["mcp"])` returns 0 (prints help, no crash) + - TestCLIServeCommand: `run_cli(["mcp", "serve"])` raises SystemExit or returns non-zero (--stdio or --http required) + + + 1. Add CLI test cases to `tests/unit/test_mcp_serve_cli.py` (append to file created in Task 1) in a new `TestCLIServeCommand` class. For dispatch tests, monkeypatch `paperbot.mcp.serve.run_stdio` and `paperbot.mcp.serve.run_http` with callables that record they were called (do NOT let them run the actual blocking functions). For parse-only tests, call `create_parser().parse_args(...)` and assert attributes. + + 2. Run new tests -- they MUST fail (RED). + + 3. Modify `src/paperbot/presentation/cli/main.py`: + + In `create_parser()`, after the `export` subparser block (before the `--version` argument), add: + ```python + # mcp commands + mcp_parser = subparsers.add_parser("mcp", help="MCP server commands") + mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="Available commands") + + serve_parser = mcp_subparsers.add_parser("serve", help="Start MCP server") + serve_transport = serve_parser.add_mutually_exclusive_group(required=True) + serve_transport.add_argument( + "--stdio", action="store_true", + help="stdio transport (for Claude Desktop / Claude Code)", + ) + serve_transport.add_argument( + "--http", action="store_true", + help="Streamable HTTP transport (for remote agents)", + ) + serve_parser.add_argument("--host", default="127.0.0.1", help="HTTP host (default: 127.0.0.1)") + serve_parser.add_argument("--port", type=int, default=8001, help="HTTP port (default: 8001)") + ``` + + In `run_cli()`, add a new `elif` branch in the dispatch block (after the `export` branch, before `return 0`): + ```python + elif parsed.command == "mcp": + if not getattr(parsed, "mcp_command", None): + mcp_parser.print_help() + return 0 + if parsed.mcp_command == "serve": + return _run_mcp_serve(parsed) + mcp_parser.print_help() + return 0 + ``` + + NOTE: `mcp_parser` is defined inside `create_parser()` scope, not accessible from `run_cli()`. To fix this, EITHER: + (a) Store `mcp_parser` as an attribute on the main parser via `parser._mcp_parser = mcp_parser` and retrieve it in `run_cli()`, OR + (b) Re-create a minimal parser to print help (call `create_parser()` again which is cheap), OR + (c) Simply print a generic help string. + Recommended: option (b) -- call `create_parser().parse_args(["mcp", "--help"])` wrapped in try/except SystemExit, or just print "Usage: paperbot mcp \\n\\nCommands:\\n serve Start MCP server" to stderr. + + Add the handler function (as a module-level function, following the pattern of `_run_topic_search`, `_run_daily_paper`, etc.): + ```python + def _run_mcp_serve(parsed: argparse.Namespace) -> int: + from paperbot.mcp.serve import run_stdio, run_http + if parsed.stdio: + run_stdio() # blocks until client disconnects + else: + run_http(host=parsed.host, port=parsed.port) # blocks until Ctrl+C + return 0 + ``` + + 4. Run tests -- they MUST pass (GREEN). + + 5. Run the full existing test suite to verify no regressions: + `PYTHONPATH=src pytest tests/unit/test_mcp_bootstrap.py tests/unit/test_mcp_serve_cli.py -q` + + + PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py tests/unit/test_mcp_bootstrap.py -x -q + + + - `paperbot mcp serve --stdio` dispatches to `run_stdio()` + - `paperbot mcp serve --http` dispatches to `run_http()` with default host/port + - `--host` and `--port` flags are passed through to `run_http()` + - `--stdio` and `--http` are mutually exclusive + - `paperbot mcp` (no subcommand) prints help and returns 0 + - `paperbot mcp serve` (no transport flag) exits with error (required flag missing) + - No regressions in existing CLI or MCP bootstrap tests + + + + + + +1. All unit tests pass: `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -v` +2. Existing MCP tests still pass: `PYTHONPATH=src pytest tests/unit/test_mcp_bootstrap.py -q` +3. `pyproject.toml` contains `[project.scripts]` with paperbot entry: `grep 'project.scripts' pyproject.toml` +4. `pyproject.toml` dependencies include mcp: `grep 'mcp\[fastmcp\]' pyproject.toml` +5. `requirements.txt` includes mcp: `grep 'mcp\[fastmcp\]' requirements.txt` +6. `serve.py` module exports are importable: `PYTHONPATH=src python -c "from paperbot.mcp.serve import run_stdio, run_http; print('OK')"` + + + +- `run_stdio()` and `run_http()` exist and correctly dispatch to `mcp.run()` with proper transport strings +- CLI parses `paperbot mcp serve --stdio` and `paperbot mcp serve --http --host H --port P` correctly +- `[project.scripts]` makes `paperbot` installable as a command +- `mcp[fastmcp]>=1.8.0,<2.0.0` is declared in both `pyproject.toml` and `requirements.txt` +- All tests pass, no regressions + + + +After completion, create `.planning/phases/05-transport-entry-point/05-01-SUMMARY.md` + diff --git a/.planning/phases/05-transport-entry-point/05-01-SUMMARY.md b/.planning/phases/05-transport-entry-point/05-01-SUMMARY.md new file mode 100644 index 00000000..fcb88645 --- /dev/null +++ b/.planning/phases/05-transport-entry-point/05-01-SUMMARY.md @@ -0,0 +1,115 @@ +--- +phase: 05-transport-entry-point +plan: "01" +subsystem: mcp +tags: [mcp, transport, stdio, http, cli, packaging] +dependency_graph: + requires: [04-mcp-resources] + provides: [mcp-serve-module, paperbot-cli-entry, transport-wiring] + affects: [cli, mcp-server, packaging] +tech_stack: + added: [mcp[fastmcp]>=1.8.0,<2.0.0] + patterns: [TDD-fake-stub, lazy-import, mutually-exclusive-argparse] +key_files: + created: + - src/paperbot/mcp/serve.py + - tests/unit/test_mcp_serve_cli.py + modified: + - src/paperbot/presentation/cli/main.py + - pyproject.toml + - requirements.txt +decisions: + - "_get_mcp() helper function used for testable lazy import of mcp singleton" + - "Default HTTP port is 8001 (not 8000) to avoid conflict with FastAPI server" + - "serve.py uses logging.basicConfig(stream=sys.stderr) before mcp.run() for stdio purity" + - "mcp_parser help printed via inline string (not re-creating parser) for simplicity" + - "_run_mcp_serve uses lazy import of run_stdio/run_http to avoid circular imports" +metrics: + duration: "3 min" + completed: "2026-03-14" + tasks_completed: 2 + files_created: 2 + files_modified: 3 +--- + +# Phase 05 Plan 01: Transport Entry Point Summary + +**One-liner:** stdio and Streamable HTTP transport dispatch wired to CLI `paperbot mcp serve` with `mcp[fastmcp]` packaging. + +## Tasks Completed + +| # | Task | Commit | Files | +|---|------|--------|-------| +| 1 | Create serve.py module and update packaging | b2580d3 | serve.py, pyproject.toml, requirements.txt, test_mcp_serve_cli.py | +| 2 | Add mcp serve subcommand to CLI | b3d4224 | main.py (test_mcp_serve_cli.py updated in place) | + +## What Was Built + +### src/paperbot/mcp/serve.py + +Transport dispatch module with two public functions: + +- `run_stdio()`: redirects all logging to stderr, lazy-imports the `mcp` singleton from `paperbot.mcp.server`, checks None guard with clear error + `sys.exit(1)`, calls `mcp.run(transport="stdio")` +- `run_http(host="127.0.0.1", port=8001)`: same None guard, calls `mcp.run(transport="streamable-http", host=host, port=port)` + +Internal `_get_mcp()` helper is the testable injection point (monkeypatched in tests). + +### CLI: paperbot mcp serve + +Added to `src/paperbot/presentation/cli/main.py`: + +- `mcp` subparser with `serve` sub-subparser +- `--stdio` / `--http` as a `required=True` mutually exclusive group +- `--host` (default `127.0.0.1`) and `--port` (default `8001`) for HTTP mode +- `_run_mcp_serve(parsed)` handler with lazy import of `run_stdio`/`run_http` +- `paperbot mcp` (no subcommand) prints inline help and returns 0 + +### Packaging + +- `pyproject.toml`: added `[project.scripts]` entry `paperbot = "paperbot.presentation.cli.main:run_cli"` and `mcp[fastmcp]>=1.8.0,<2.0.0` to `dependencies` +- `requirements.txt`: added `mcp[fastmcp]>=1.8.0,<2.0.0` line + +## Tests + +18 tests in `tests/unit/test_mcp_serve_cli.py` covering all behaviors: +- `TestServeModuleImport` (1 test) +- `TestRunStdio` (2 tests) +- `TestRunHttp` (2 tests) +- `TestMcpNoneGuard` (2 tests) +- `TestPyprojectScripts` (3 tests) +- `TestCLIServeCommand` (8 tests) + +All 18 pass. No regressions in `test_mcp_bootstrap.py` (3 tests). + +## Deviations from Plan + +### Minor Implementation Deviation + +**Deviation:** `_get_mcp()` helper function introduced instead of direct `from paperbot.mcp.server import mcp` import at module level. + +**Reason:** Python 3.9 module-level import caching makes it impossible to monkeypatch `mcp` after `serve` is imported once — the module-level name is already bound. A `_get_mcp()` function is called at runtime and can be monkeypatched in tests cleanly. + +**Impact:** Functionally equivalent. Tests are cleaner. Pattern is consistent with project's fake-stub convention. + +## Self-Check: PASSED + +Files created: +- FOUND: src/paperbot/mcp/serve.py +- FOUND: tests/unit/test_mcp_serve_cli.py + +Files modified: +- FOUND: src/paperbot/presentation/cli/main.py +- FOUND: pyproject.toml +- FOUND: requirements.txt + +Commits: +- FOUND: b2580d3 (Task 1) +- FOUND: b3d4224 (Task 2) + +Verification: +- 18/18 tests pass in test_mcp_serve_cli.py +- 3/3 tests pass in test_mcp_bootstrap.py +- pyproject.toml has [project.scripts] entry +- pyproject.toml has mcp[fastmcp] dependency +- requirements.txt has mcp[fastmcp] line +- `from paperbot.mcp.serve import run_stdio, run_http` imports OK diff --git a/.planning/phases/05-transport-entry-point/05-RESEARCH.md b/.planning/phases/05-transport-entry-point/05-RESEARCH.md new file mode 100644 index 00000000..fc0a48f6 --- /dev/null +++ b/.planning/phases/05-transport-entry-point/05-RESEARCH.md @@ -0,0 +1,551 @@ +# Phase 5: Transport & Entry Point - Research + +**Researched:** 2026-03-14 +**Domain:** FastMCP transport configuration (stdio / Streamable HTTP), Python CLI entry points (argparse + pyproject.toml `[project.scripts]`), claude_desktop_config.json integration +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| MCP-10 | MCP server runs via stdio transport for local agent integration | `mcp.run(transport="stdio")` — FastMCP built-in; no extra deps; default transport when no arg passed | +| MCP-11 | MCP server runs via Streamable HTTP transport for remote agent integration | `mcp.run(transport="streamable-http")` — FastMCP built-in; server binds to configurable host:port, endpoint at `/mcp` | +| MCP-12 | User can start MCP server via `paperbot mcp serve` CLI command | Add `mcp` subcommand + `serve` sub-subcommand to existing `argparse` CLI in `src/paperbot/presentation/cli/main.py`; wire `--stdio` / `--http` flags to `mcp.run()` | + + +--- + +## Summary + +Phase 5 makes the already-complete MCP server (9 tools + 4 resources, built in Phases 2–4) runnable by agents. Two transport modes are required: **stdio** for local Claude Code/Claude Desktop integration and **Streamable HTTP** for remote agents. Both modes are provided natively by the `mcp[fastmcp]` package already established in earlier phases — no new dependencies are needed. + +The CLI entry point extends the existing `argparse`-based CLI in `src/paperbot/presentation/cli/main.py` with a `mcp` subcommand and a `serve` sub-subcommand. The `--stdio` flag calls `mcp.run(transport="stdio")` and `--http` calls `mcp.run(transport="streamable-http", host=..., port=...)`. The package's `[project.scripts]` entry in `pyproject.toml` (currently absent) must be added so that `pip install -e .` makes `paperbot` available on `$PATH`. + +The `claude_desktop_config.json` pattern for stdio is a standard JSON block that tells Claude Desktop to spawn the server process and communicate via stdin/stdout. The exact command depends on whether the package is installed (`paperbot mcp serve --stdio`) or run from the project root (`python -m paperbot.mcp.serve_stdio` or similar). + +**Primary recommendation:** Add `[project.scripts]` entry to `pyproject.toml`, add `mcp serve` subcommand to the CLI, and document the `claude_desktop_config.json` snippet in a comment block in the serve module. + +--- + +## Standard Stack + +### Core (already installed) +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `mcp[fastmcp]` | `>=1.8.0,<2.0.0` | `FastMCP.run(transport=...)` for both stdio and Streamable HTTP | Established in Phase 1; already used for all tools and resources | +| `argparse` | stdlib | CLI subcommand parsing (`paperbot mcp serve --stdio/--http`) | Already used in `src/paperbot/presentation/cli/main.py` | +| `uvicorn` | existing dep | Optional ASGI runner for HTTP transport in production | Already a project dependency | + +### No New Dependencies +Both stdio and Streamable HTTP transports are built into `mcp[fastmcp]`. Zero new packages required. + +**Installation (for new environments):** +```bash +pip install "mcp[fastmcp]>=1.8.0,<2.0.0" +``` +This must be added to `pyproject.toml` dependencies (currently missing). + +--- + +## Architecture Patterns + +### Recommended Project Structure (Phase 5 additions) +``` +src/paperbot/ +├── mcp/ +│ ├── server.py # existing: FastMCP instance + all registrations +│ └── serve.py # NEW: run_stdio() / run_http() entry functions +├── presentation/ +│ └── cli/ +│ └── main.py # MODIFY: add 'mcp' subcommand + serve sub-subcommand + +pyproject.toml # MODIFY: add [project.scripts] + mcp dependency + +tests/ +└── unit/ + └── test_mcp_serve_cli.py # NEW: covers MCP-10, MCP-11, MCP-12 + +docs/ (optional) +└── claude_desktop_config_example.json # NEW: example config for users +``` + +### Pattern 1: Transport dispatch via `serve.py` module +**What:** Create `src/paperbot/mcp/serve.py` with two public functions — `run_stdio()` and `run_http(host, port)` — that import the `mcp` singleton from `server.py` and call `mcp.run()` with the appropriate transport. +**When to use:** These functions are called from the CLI handler and can also be called directly in scripts or tests. +**Why:** Keeps transport dispatch logic in the `mcp/` package, separate from the CLI argument parsing layer. Makes it testable without argparse. + +```python +# src/paperbot/mcp/serve.py +from __future__ import annotations + +import sys + + +def run_stdio() -> None: + """Start MCP server on stdio transport (local / Claude Desktop mode).""" + from paperbot.mcp.server import mcp + + if mcp is None: + print("Error: mcp package not installed. Run: pip install 'mcp[fastmcp]>=1.8.0,<2.0.0'", + file=sys.stderr) + sys.exit(1) + + mcp.run(transport="stdio") + + +def run_http(host: str = "127.0.0.1", port: int = 8000) -> None: + """Start MCP server on Streamable HTTP transport (remote agent mode).""" + from paperbot.mcp.server import mcp + + if mcp is None: + print("Error: mcp package not installed.", file=sys.stderr) + sys.exit(1) + + mcp.run(transport="streamable-http", host=host, port=port) +``` + +### Pattern 2: `mcp` subcommand added to existing argparse CLI +**What:** Add a `mcp` subparser to `create_parser()` in `main.py`, and a `serve` sub-subparser under it. Wire `--stdio` / `--http` flags and optional `--host` / `--port` parameters. +**When to use:** Follows the pattern already established by `track`, `analyze`, `topic-search`, etc. in the CLI. + +```python +# In create_parser() — add after existing subparser definitions: +mcp_parser = subparsers.add_parser("mcp", help="MCP server commands") +mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="MCP sub-commands") + +serve_parser = mcp_subparsers.add_parser("serve", help="Start MCP server") +serve_transport = serve_parser.add_mutually_exclusive_group(required=True) +serve_transport.add_argument("--stdio", action="store_true", + help="Run on stdio transport (for Claude Desktop / Claude Code)") +serve_transport.add_argument("--http", action="store_true", + help="Run on Streamable HTTP transport (for remote agents)") +serve_parser.add_argument("--host", default="127.0.0.1", + help="HTTP host (default: 127.0.0.1)") +serve_parser.add_argument("--port", type=int, default=8000, + help="HTTP port (default: 8000)") +``` + +```python +# In run_cli() — add to command dispatch: +elif parsed.command == "mcp": + if parsed.mcp_command == "serve": + return _run_mcp_serve(parsed) + mcp_parser.print_help() + return 0 +``` + +```python +# New handler function: +def _run_mcp_serve(parsed: argparse.Namespace) -> int: + from paperbot.mcp.serve import run_stdio, run_http + if parsed.stdio: + run_stdio() + else: + run_http(host=parsed.host, port=parsed.port) + return 0 +``` + +### Pattern 3: `[project.scripts]` entry in `pyproject.toml` +**What:** Add a `[project.scripts]` section so that `pip install -e .` installs a `paperbot` command on `$PATH`. +**When to use:** Required for `claude_desktop_config.json` to reference `paperbot` as the command name. + +```toml +# pyproject.toml — add this section: +[project.scripts] +paperbot = "paperbot.presentation.cli.main:run_cli" +``` + +This is currently absent from `pyproject.toml` despite the CLI being fully implemented. The planner MUST add it. + +### Pattern 4: `claude_desktop_config.json` stdio integration +**What:** Document the JSON block users add to Claude Desktop config to connect to PaperBot MCP via stdio. +**When to use:** MCP-10 success criterion — "Claude Code can connect to PaperBot MCP server via stdio in `claude_desktop_config.json`." + +```json +{ + "mcpServers": { + "paperbot": { + "command": "paperbot", + "args": ["mcp", "serve", "--stdio"], + "env": { + "PAPERBOT_DB_URL": "sqlite:////absolute/path/to/data/paperbot.db", + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +If the package is not installed but the project is cloned: +```json +{ + "mcpServers": { + "paperbot": { + "command": "/absolute/path/to/.venv/bin/python", + "args": ["-m", "paperbot.mcp.serve_stdio"], + "env": { + "PAPERBOT_DB_URL": "sqlite:////absolute/path/to/data/paperbot.db" + } + } + } +} +``` + +### Pattern 5: Stdio logging safety +**What:** Ensure NO output goes to stdout when running in stdio transport. All logging must go to stderr or a file. +**When to use:** Critical for stdio transport — stdout is the MCP protocol channel. Any non-JSON-RPC bytes on stdout corrupt the protocol. + +```python +# In serve.py — configure logging before mcp.run() for stdio mode: +import logging +import sys + +def run_stdio() -> None: + # Redirect all logging to stderr so stdout stays clean for MCP protocol + logging.basicConfig(stream=sys.stderr, level=logging.WARNING) + ... + mcp.run(transport="stdio") +``` + +### Anti-Patterns to Avoid +- **Calling `print()` without `file=sys.stderr` in stdio mode**: stdout is the MCP JSON-RPC channel. Any stray bytes corrupt the protocol. +- **Using `transport="sse"`**: SSE is legacy and deprecated. Always use `"streamable-http"` for HTTP transport. +- **Missing `[project.scripts]` in `pyproject.toml`**: Without this, `paperbot` is not on PATH and `claude_desktop_config.json` will not work with the simple `"command": "paperbot"` form. +- **Calling `mcp.run()` from inside an async function**: `FastMCP.run()` creates its own event loop. Call it only from synchronous code (the CLI handler is already synchronous). Use `mcp.run_async()` only if an event loop is already running. +- **Hardcoding `host="0.0.0.0"` as default**: The default should be `127.0.0.1` for security. Users can override via `--host` flag. +- **Blocking the test suite with `mcp.run()`**: Tests MUST NOT call `run_stdio()` or `run_http()` directly — those block. Test via `run_stdio` / `run_http` being callable, or mock `mcp.run` in unit tests. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| stdio MCP protocol framing | Custom stdin/stdout JSON-RPC loop | `mcp.run(transport="stdio")` | Protocol framing, lifecycle, error recovery all handled | +| HTTP MCP endpoint | Custom FastAPI route | `mcp.run(transport="streamable-http")` | Streamable HTTP spec implementation, session mgmt, SSE upgrade all handled | +| ASGI server for HTTP MCP | Custom uvicorn setup | `mcp.run(transport="streamable-http")` | FastMCP starts its own ASGI server internally (uses uvicorn) | +| Transport negotiation | Custom detection logic | `mcp.run(transport=...)` with explicit string | Explicit transport string is clear and unambiguous | + +**Key insight:** The entire Phase 5 implementation is plumbing — connecting the existing `mcp` singleton (built in Phases 1–4) to a transport. All protocol complexity lives in `mcp[fastmcp]`. + +--- + +## Common Pitfalls + +### Pitfall 1: stdout pollution in stdio mode +**What goes wrong:** Any `print()` call, startup banner, or logging line that goes to stdout corrupts the MCP JSON-RPC stream. Claude Desktop will fail to parse the server's responses. +**Why it happens:** Python `print()` defaults to stdout. Many frameworks (loguru, standard `logging` with default `StreamHandler`) also write to stdout by default. +**How to avoid:** In `run_stdio()`, configure logging to go to stderr before calling `mcp.run()`. Audit all startup code paths for `print()` calls. +**Warning signs:** Claude Desktop shows "Failed to read from MCP server" or JSON parse errors. + +### Pitfall 2: Missing `[project.scripts]` entry +**What goes wrong:** `paperbot mcp serve --stdio` only works if the `paperbot` binary is on PATH. Without `[project.scripts]`, users must run `python -m paperbot.presentation.cli.main mcp serve --stdio` — a path that `claude_desktop_config.json` makes cumbersome. +**Why it happens:** `pyproject.toml` currently has no `[project.scripts]` section. +**How to avoid:** Add `[project.scripts]` as Task 1 of this phase (prerequisite for success criterion 3). +**Warning signs:** `command not found: paperbot` when testing claude_desktop_config.json. + +### Pitfall 3: `mcp` singleton is `None` when mcp package not installed +**What goes wrong:** `server.py` sets `mcp = None` when `import mcp` fails. Calling `mcp.run()` on `None` raises `AttributeError`. +**Why it happens:** Defensive design from Phase 1 — the stub allows imports to succeed in test environments without mcp installed. +**How to avoid:** `serve.py` must check `if mcp is None` and exit with a clear error message before calling `mcp.run()`. +**Warning signs:** `AttributeError: 'NoneType' object has no attribute 'run'` at startup. + +### Pitfall 4: `mcp` package not in `pyproject.toml` dependencies +**What goes wrong:** The `mcp[fastmcp]` package is used in `server.py` but is not listed in `pyproject.toml` dependencies or `requirements.txt`. Fresh installs will fail at runtime. +**Why it happens:** Previous phases deferred the packaging work (server.py uses a try/except import guard). Phase 5 is when this gets fixed. +**How to avoid:** Add `"mcp[fastmcp]>=1.8.0,<2.0.0"` to `pyproject.toml` `dependencies` list AND to `requirements.txt`. +**Warning signs:** `ModuleNotFoundError: No module named 'mcp'` in production or after a clean install. + +### Pitfall 5: Port conflict when testing HTTP transport +**What goes wrong:** If the HTTP server is already running (or the port is in use), `mcp.run(transport="streamable-http", port=8000)` will fail to bind. +**Why it happens:** `127.0.0.1:8000` is the default for uvicorn-based services (including PaperBot's FastAPI server). +**How to avoid:** Use a different default port for MCP HTTP (e.g., `8001`) or make it configurable via `--port`. Document the conflict risk. +**Warning signs:** `OSError: [Errno 98] Address already in use`. + +### Pitfall 6: Async loop conflict with `mcp.run()` inside FastAPI +**What goes wrong:** If a user attempts to call `mcp.run()` from within an async context (e.g., a FastAPI startup event), it will fail because `run()` creates its own event loop. +**Why it happens:** `FastMCP.run()` is a blocking synchronous method that calls `asyncio.run()` internally. +**How to avoid:** The CLI handler (`_run_mcp_serve`) is synchronous. Keep it that way. Document that HTTP transport does NOT require the FastAPI server — they are separate processes. + +--- + +## Code Examples + +Verified patterns from official sources and established codebase: + +### run() with transport string (mcp SDK v1.x) +```python +# Source: MCP Python SDK official docs (modelcontextprotocol.io) +# transport="stdio" is the default; explicit for clarity +mcp.run(transport="stdio") + +# Streamable HTTP — production-recommended for network access +mcp.run(transport="streamable-http", host="127.0.0.1", port=8000) +# MCP endpoint: http://127.0.0.1:8000/mcp +``` + +### serve.py — complete module +```python +# src/paperbot/mcp/serve.py +from __future__ import annotations + +import logging +import sys + + +def run_stdio() -> None: + """Start MCP server on stdio transport. + + Configures logging to stderr (stdout must remain clean for JSON-RPC). + Blocks until the client closes the connection. + + claude_desktop_config.json usage: + { + "mcpServers": { + "paperbot": { + "command": "paperbot", + "args": ["mcp", "serve", "--stdio"] + } + } + } + """ + # Redirect logging to stderr — stdout is the MCP JSON-RPC channel + logging.basicConfig(stream=sys.stderr, level=logging.WARNING, + format="%(asctime)s %(name)s %(levelname)s %(message)s") + + from paperbot.mcp.server import mcp # late import to avoid circular + if mcp is None: + print( + "Error: mcp package not installed. " + "Run: pip install 'mcp[fastmcp]>=1.8.0,<2.0.0'", + file=sys.stderr, + ) + sys.exit(1) + + mcp.run(transport="stdio") + + +def run_http(host: str = "127.0.0.1", port: int = 8001) -> None: + """Start MCP server on Streamable HTTP transport. + + Blocks until the server is stopped (Ctrl+C). + MCP endpoint: http://{host}:{port}/mcp + """ + from paperbot.mcp.server import mcp + if mcp is None: + print("Error: mcp package not installed.", file=sys.stderr) + sys.exit(1) + + mcp.run(transport="streamable-http", host=host, port=port) +``` + +### pyproject.toml additions +```toml +# Add to [project] dependencies list: +"mcp[fastmcp]>=1.8.0,<2.0.0", + +# Add as new top-level section: +[project.scripts] +paperbot = "paperbot.presentation.cli.main:run_cli" +``` + +### CLI addition (main.py excerpt) +```python +# Source: src/paperbot/presentation/cli/main.py — extend create_parser() + +# After existing subparsers: +mcp_parser = subparsers.add_parser("mcp", help="MCP server commands") +mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="Available commands") + +serve_parser = mcp_subparsers.add_parser("serve", help="Start MCP server") +serve_transport = serve_parser.add_mutually_exclusive_group(required=True) +serve_transport.add_argument( + "--stdio", action="store_true", + help="stdio transport — for Claude Desktop and Claude Code local integration", +) +serve_transport.add_argument( + "--http", action="store_true", + help="Streamable HTTP transport — for remote agents", +) +serve_parser.add_argument( + "--host", default="127.0.0.1", help="HTTP host (default: 127.0.0.1)" +) +serve_parser.add_argument( + "--port", type=int, default=8001, help="HTTP port (default: 8001)" +) +``` + +```python +# In run_cli() dispatch block: +elif parsed.command == "mcp": + if not getattr(parsed, "mcp_command", None): + mcp_parser.print_help() + return 0 + if parsed.mcp_command == "serve": + return _run_mcp_serve(parsed) + mcp_parser.print_help() + return 0 + +# Handler function: +def _run_mcp_serve(parsed: argparse.Namespace) -> int: + from paperbot.mcp.serve import run_stdio, run_http + if parsed.stdio: + run_stdio() # blocks + else: + run_http(host=parsed.host, port=parsed.port) # blocks + return 0 +``` + +### claude_desktop_config.json (for users) +```json +{ + "mcpServers": { + "paperbot": { + "command": "paperbot", + "args": ["mcp", "serve", "--stdio"], + "env": { + "PAPERBOT_DB_URL": "sqlite:////Users/yourname/PaperBot/data/paperbot.db", + "OPENAI_API_KEY": "sk-..." + } + } + } +} +``` + +Note for Claude Code users: Claude Code reads MCP config from `~/.claude.json` under `"mcpServers"` key, not `claude_desktop_config.json`. Same format applies. + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| SSE transport (`transport="sse"`) | Streamable HTTP (`transport="streamable-http"`) | 2024–2025 MCP spec evolution | SSE deprecated; new projects MUST use streamable-http | +| Manual stdio framing | `mcp.run(transport="stdio")` built-in | MCP SDK v1.x | No hand-rolling needed | +| Separate `fastmcp` PyPI package | `mcp[fastmcp]` from official SDK | 2024 (FastMCP 1.0 merged into SDK) | Import path is `mcp.server.fastmcp.FastMCP` | + +**Deprecated:** +- `transport="sse"`: Do not use in new code. SSE exists for backward compatibility only. +- Standalone `fastmcp` PyPI package (v3.x): This is a different, independently maintained project by PrefectHQ (`from fastmcp import FastMCP`). PaperBot uses the official Anthropic SDK (`from mcp.server.fastmcp import FastMCP`). Do not mix the two. + +--- + +## Open Questions + +1. **Default HTTP port: 8000 or 8001?** + - What we know: PaperBot FastAPI server uses `--port 8000` (from CLAUDE.md). FastMCP HTTP defaults to `8000` in official examples. + - What's unclear: Will users run both the FastAPI server and the MCP HTTP server simultaneously? + - Recommendation: Default MCP HTTP port to `8001` to avoid collision with the existing FastAPI server at `8000`. + +2. **`mcp.run()` parameters for Streamable HTTP — path configuration?** + - What we know: The MCP endpoint appears at `/mcp` with Streamable HTTP transport. There is no documented parameter to change the path. + - What's unclear: Whether `FastMCP` in the `mcp` SDK v1.x exposes a `path` parameter for the HTTP endpoint (standalone fastmcp does, but the APIs diverge after v1.0). + - Recommendation: Accept `/mcp` as the endpoint path — it's the standard. If path configuration is needed, document it as a Phase 5 open item. + +3. **`mcp` package version pin — v1.x vs v2.x** + - What we know: Current PyPI version of `mcp` is 1.26.0 (Jan 2026). Previous phases established `>=1.8.0,<2.0.0`. + - What's unclear: Whether v1.26.0 has any breaking changes in the `FastMCP.run()` API compared to what was used in Phases 2–4. + - Recommendation: Maintain the established pin `>=1.8.0,<2.0.0` for consistency with all previous phases. Verify `transport="streamable-http"` string works in v1.26.0 (verified via official MCP Python SDK docs). + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest with pytest-asyncio (asyncio_mode = "strict") | +| Config file | `pyproject.toml` — `[tool.pytest.ini_options]` | +| Quick run command | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -q` | +| Full suite command | `PYTHONPATH=src pytest -q` | + +### Phase Requirements to Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| MCP-10 | `run_stdio()` calls `mcp.run(transport="stdio")` | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestRunStdio -x` | ❌ Wave 0 | +| MCP-10 | `run_stdio()` exits with error when `mcp is None` | unit | same | ❌ Wave 0 | +| MCP-10 | `run_stdio()` configures logging to stderr (not stdout) | unit | same | ❌ Wave 0 | +| MCP-11 | `run_http(host, port)` calls `mcp.run(transport="streamable-http", host=..., port=...)` | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestRunHttp -x` | ❌ Wave 0 | +| MCP-11 | `run_http()` exits with error when `mcp is None` | unit | same | ❌ Wave 0 | +| MCP-12 | `paperbot mcp serve --stdio` parses to `run_stdio()` call | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestCLIServeCommand -x` | ❌ Wave 0 | +| MCP-12 | `paperbot mcp serve --http --port 9000` parses to `run_http(port=9000)` call | unit | same | ❌ Wave 0 | +| MCP-12 | `paperbot mcp serve` (no flag) shows help without error | unit | same | ❌ Wave 0 | +| MCP-12 | `paperbot mcp` (no subcommand) shows help without error | unit | same | ❌ Wave 0 | +| MCP-10/11 | `serve.py` module exists and exports `run_stdio` and `run_http` | unit (import) | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestServeModuleImport -x` | ❌ Wave 0 | +| MCP-10/11/12 | `pyproject.toml` contains `[project.scripts]` with `paperbot` entry | static check | `grep -q 'paperbot' pyproject.toml` | ❌ Wave 0 | + +### Test Implementation Notes +Tests MUST NOT call `run_stdio()` or `run_http()` directly — those block indefinitely. Instead: +- Inject a mock for `mcp.run` via monkeypatching: `monkeypatch.setattr("paperbot.mcp.server.mcp", FakeMCP())` +- Or test that the correct arguments are passed to `mcp.run` by capturing the call + +```python +# Example test pattern (does NOT block): +class _FakeMCP: + def __init__(self): + self.run_calls = [] + + def run(self, transport, **kwargs): + self.run_calls.append({"transport": transport, **kwargs}) + +def test_run_stdio_calls_correct_transport(monkeypatch): + fake = _FakeMCP() + monkeypatch.setattr("paperbot.mcp.server.mcp", fake) + from paperbot.mcp import serve + import importlib + importlib.reload(serve) # reload to pick up monkeypatched mcp + serve.run_stdio() + assert fake.run_calls == [{"transport": "stdio"}] +``` + +### Sampling Rate +- **Per task commit:** `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -q` +- **Per wave merge:** `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py tests/unit/test_mcp_bootstrap.py -q` +- **Phase gate:** Full CI offline suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `src/paperbot/mcp/serve.py` — `run_stdio()` and `run_http()` functions +- [ ] `tests/unit/test_mcp_serve_cli.py` — covers MCP-10, MCP-11, MCP-12 (all tests) +- [ ] `pyproject.toml` — add `[project.scripts]` section and `mcp[fastmcp]` dependency +- [ ] `requirements.txt` — add `mcp[fastmcp]>=1.8.0,<2.0.0` + +--- + +## Sources + +### Primary (HIGH confidence) +- `src/paperbot/mcp/server.py` — FastMCP singleton, `mcp = FastMCP("paperbot")`, confirmed `mcp=None` stub when package absent +- `src/paperbot/presentation/cli/main.py` — existing argparse structure, `create_parser()` + `run_cli()` pattern +- `pyproject.toml` — confirmed absence of `[project.scripts]` and `mcp` package in deps +- [MCP Python SDK quickstart](https://modelcontextprotocol.io/quickstart/server) — `mcp.run(transport="stdio")` pattern, `claude_desktop_config.json` format (verified from official docs) +- [FastMCP running-server docs](https://gofastmcp.com/deployment/running-server) — `mcp.run(transport="http", host=..., port=...)`, endpoint at `/mcp`, stdio as default +- `.planning/phases/03-remaining-mcp-tools/03-RESEARCH.md` — established `mcp[fastmcp]>=1.8.0,<2.0.0` pin +- `.planning/phases/04-mcp-resources/04-RESEARCH.md` — confirmed same mcp package version + +### Secondary (MEDIUM confidence) +- [MCP Python SDK PyPI page](https://pypi.org/project/mcp/) — v1.26.0 is current (Jan 2026); package name `mcp`, extras: `cli`, `rich`, `ws` +- [WebSearch results](https://github.com/modelcontextprotocol/python-sdk) — `transport="streamable-http"` confirmed as the correct string for Streamable HTTP (not "http" or "http-streamable") +- [setuptools entry_points docs](https://setuptools.pypa.io/en/latest/userguide/entry_point.html) — `[project.scripts]` format for pyproject.toml + +### Tertiary (LOW confidence) +- FastMCP HTTP default port (`8000`) — from official docs examples; marked LOW because it may be illustrative rather than a hard default; use explicit `port=8001` to avoid conflict + +--- + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — `mcp[fastmcp]` already in use; `argparse` already in use; no new packages needed +- `mcp.run()` API (stdio): HIGH — verified from official MCP Python SDK quickstart docs +- `mcp.run()` API (streamable-http): HIGH — verified from FastMCP docs and official SDK README +- CLI pattern: HIGH — directly extending existing argparse structure in codebase +- `[project.scripts]`: HIGH — standard setuptools pyproject.toml spec +- `claude_desktop_config.json` format: HIGH — verified from official MCP quickstart +- HTTP endpoint path (`/mcp`): MEDIUM — stated in official docs, but no source code verification +- Default port selection: MEDIUM — `8001` is a recommendation to avoid collision, not from official spec + +**Research date:** 2026-03-14 +**Valid until:** 2026-04-14 (stable MCP SDK API; argparse is stdlib) diff --git a/.planning/phases/05-transport-entry-point/05-VALIDATION.md b/.planning/phases/05-transport-entry-point/05-VALIDATION.md new file mode 100644 index 00000000..601ba1fe --- /dev/null +++ b/.planning/phases/05-transport-entry-point/05-VALIDATION.md @@ -0,0 +1,80 @@ +--- +phase: 5 +slug: transport-entry-point +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-14 +--- + +# Phase 5 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest 7.x with pytest-asyncio (asyncio_mode = "strict") | +| **Config file** | `pyproject.toml` — `[tool.pytest.ini_options]` | +| **Quick run command** | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -q` | +| **Full suite command** | `PYTHONPATH=src pytest -q` | +| **Estimated runtime** | ~5 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py -q` +- **After every plan wave:** Run `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py tests/unit/test_mcp_bootstrap.py -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 5 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 05-01-01 | 01 | 1 | MCP-12 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestCLIServeCommand -x` | ❌ W0 | ⬜ pending | +| 05-01-02 | 01 | 1 | MCP-10 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestRunStdio -x` | ❌ W0 | ⬜ pending | +| 05-01-03 | 01 | 1 | MCP-11 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestRunHttp -x` | ❌ W0 | ⬜ pending | +| 05-01-04 | 01 | 1 | MCP-10 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestRunStdio::test_logging_stderr -x` | ❌ W0 | ⬜ pending | +| 05-01-05 | 01 | 1 | MCP-10/11 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestServeModuleImport -x` | ❌ W0 | ⬜ pending | +| 05-01-06 | 01 | 1 | MCP-10/11 | unit | `PYTHONPATH=src pytest tests/unit/test_mcp_serve_cli.py::TestMcpNoneGuard -x` | ❌ W0 | ⬜ pending | +| 05-01-07 | 01 | 1 | MCP-12 | static | `grep -q 'project.scripts' pyproject.toml` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/unit/test_mcp_serve_cli.py` — stubs for MCP-10, MCP-11, MCP-12 +- [ ] `src/paperbot/mcp/serve.py` — `run_stdio()` and `run_http()` entry functions + +*Existing infrastructure covers test framework and fixtures.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Claude Code connects via stdio in `claude_desktop_config.json` | MCP-10 | Requires Claude Desktop/Code runtime | 1. Add config snippet to `~/.claude.json` 2. Start Claude Code 3. Verify tools appear | +| Remote agent connects via HTTP and calls tools | MCP-11 | Requires external agent client | 1. Start `paperbot mcp serve --http` 2. Use MCP client to connect to `http://127.0.0.1:8001/mcp` 3. Call a tool | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 5s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/05-transport-entry-point/05-VERIFICATION.md b/.planning/phases/05-transport-entry-point/05-VERIFICATION.md new file mode 100644 index 00000000..c8b0c6a3 --- /dev/null +++ b/.planning/phases/05-transport-entry-point/05-VERIFICATION.md @@ -0,0 +1,113 @@ +--- +phase: 05-transport-entry-point +verified: 2026-03-14T00:00:00Z +status: passed +score: 7/7 must-haves verified +re_verification: false +--- + +# Phase 5: Transport & Entry Point Verification Report + +**Phase Goal:** MCP server is runnable via stdio (local) and Streamable HTTP (remote) with a CLI command +**Verified:** 2026-03-14 +**Status:** PASSED +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | `paperbot mcp serve --stdio` starts MCP server on stdio transport | VERIFIED | `_run_mcp_serve` in `main.py:693-700` dispatches to `run_stdio()` which calls `mcp.run(transport="stdio")`; test `test_run_cli_mcp_serve_stdio_dispatches` PASSES | +| 2 | `paperbot mcp serve --http` starts MCP server on Streamable HTTP transport | VERIFIED | `_run_mcp_serve` dispatches to `run_http()` which calls `mcp.run(transport="streamable-http", host=host, port=port)`; test `test_run_cli_mcp_serve_http_dispatches` PASSES | +| 3 | `paperbot mcp serve --http --host 0.0.0.0 --port 9000` allows host/port override | VERIFIED | `run_http(host, port)` signature and argparse wiring confirmed; `test_parse_mcp_serve_http_custom_host_port` and `test_passes_custom_host_and_port` both PASS | +| 4 | `paperbot mcp serve` (no flag) prints help without crashing | VERIFIED | mutually exclusive group with `required=True` causes argparse to exit non-zero; `test_run_cli_mcp_serve_no_transport_exits_nonzero` PASSES | +| 5 | `paperbot mcp` (no subcommand) prints help without crashing | VERIFIED | `run_cli(["mcp"])` branch at `main.py:332-338` prints inline help and returns 0; `test_run_cli_mcp_no_subcommand_returns_zero` PASSES | +| 6 | stdio mode sends zero bytes to stdout (logging goes to stderr only) | VERIFIED | `run_stdio()` calls `logging.basicConfig(stream=sys.stderr, ...)` before `mcp.run()`; `test_configures_logging_to_stderr` PASSES | +| 7 | Missing mcp package produces clear error message to stderr and exits non-zero | VERIFIED | Both `run_stdio()` and `run_http()` check `if mcp is None` -> print to `sys.stderr` + `sys.exit(1)`; `TestMcpNoneGuard` tests (2) PASS | + +**Score:** 7/7 truths verified + +--- + +## Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `src/paperbot/mcp/serve.py` | Transport dispatch functions | VERIFIED | 81 lines; exports `run_stdio`, `run_http`, `_get_mcp`; substantive implementation with None guard, logging config, `mcp.run()` calls | +| `src/paperbot/presentation/cli/main.py` | mcp serve subcommand in CLI parser | VERIFIED | `mcp_parser` added at lines 214-231; `_run_mcp_serve` handler at lines 693-701; `mcp` dispatch branch at lines 331-338 | +| `pyproject.toml` | `[project.scripts]` entry + mcp dependency | VERIFIED | `[project.scripts]` at line 101; `paperbot = "paperbot.presentation.cli.main:run_cli"` at line 102; `mcp[fastmcp]>=1.8.0,<2.0.0` at line 62 | +| `requirements.txt` | mcp[fastmcp] dependency line | VERIFIED | `mcp[fastmcp]>=1.8.0,<2.0.0` found at line 112 | +| `tests/unit/test_mcp_serve_cli.py` | Unit tests for all transport and CLI behaviors | VERIFIED | 18 tests across 6 test classes; all 18 PASS | + +--- + +## Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `src/paperbot/presentation/cli/main.py` | `src/paperbot/mcp/serve.py` | lazy import in `_run_mcp_serve` | WIRED | `from paperbot.mcp.serve import run_http, run_stdio` at line 695; import is exercised by `run_cli(["mcp", "serve", ...])` | +| `src/paperbot/mcp/serve.py` | `src/paperbot/mcp/server.py` | `_get_mcp()` helper | WIRED | `from paperbot.mcp import server as _server_mod; return _server_mod.mcp` at lines 29-31; called at runtime in both `run_stdio()` and `run_http()` | +| `pyproject.toml` | `src/paperbot/presentation/cli/main.py` | `[project.scripts]` entry point | WIRED | `paperbot = "paperbot.presentation.cli.main:run_cli"` confirmed at pyproject.toml line 102 | + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|------------|-------------|--------|----------| +| MCP-10 | 05-01-PLAN.md | MCP server runs via stdio transport for local agent integration | SATISFIED | `run_stdio()` calls `mcp.run(transport="stdio")`; CLI wiring confirmed; 18/18 tests pass | +| MCP-11 | 05-01-PLAN.md | MCP server runs via Streamable HTTP transport for remote agent integration | SATISFIED | `run_http()` calls `mcp.run(transport="streamable-http", host, port)`; default port 8001 avoids FastAPI collision | +| MCP-12 | 05-01-PLAN.md | User can start MCP server via `paperbot mcp serve` CLI command | SATISFIED | `mcp` subparser + `serve` sub-subparser wired in `create_parser()`; `_run_mcp_serve` dispatches correctly; `[project.scripts]` makes `paperbot` installable | + +No orphaned requirements for Phase 5: all three (MCP-10, MCP-11, MCP-12) are declared in 05-01-PLAN.md and verified in the codebase. + +--- + +## Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| `src/paperbot/presentation/cli/main.py` | 357 | `# TODO: 实现 Semantic Scholar 客户端调用` | Info | Pre-existing in `_quick_score` function (unrelated to Phase 5 scope); zero impact on transport/MCP goal | + +No blockers. No warnings in Phase 5 scope. The one TODO is in pre-existing `_quick_score` code, not in any Phase 5 deliverable. + +--- + +## Human Verification Required + +### 1. Claude Desktop integration round-trip + +**Test:** Add the following to `claude_desktop_config.json` and launch Claude Desktop: +```json +{ + "mcpServers": { + "paperbot": { + "command": "paperbot", + "args": ["mcp", "serve", "--stdio"] + } + } +} +``` +**Expected:** PaperBot tools appear in the Claude Desktop tool picker; calling `paper_search` returns results. +**Why human:** Requires an installed package, a running MCP package, and a Claude Desktop instance — cannot verify end-to-end connectivity programmatically. + +### 2. Remote agent HTTP connection + +**Test:** Run `paperbot mcp serve --http` and connect a remote MCP client to `http://127.0.0.1:8001`. +**Expected:** Client receives tool list (9 tools) and can call any tool successfully. +**Why human:** Requires `mcp[fastmcp]` installed in the active environment, a running server process, and a live MCP client. + +--- + +## Gaps Summary + +None. All 7 observable truths verified. All 5 artifacts exist, are substantive, and are wired. All 3 key links confirmed. All 3 requirement IDs (MCP-10, MCP-11, MCP-12) satisfied. No blocker anti-patterns. + +--- + +_Verified: 2026-03-14_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/06-agent-skills/06-01-PLAN.md b/.planning/phases/06-agent-skills/06-01-PLAN.md new file mode 100644 index 00000000..5c7d48b6 --- /dev/null +++ b/.planning/phases/06-agent-skills/06-01-PLAN.md @@ -0,0 +1,175 @@ +--- +phase: 06-agent-skills +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - tests/unit/test_agent_skills.py + - .claude/skills/literature-review/SKILL.md + - .claude/skills/paper-reproduction/SKILL.md + - .claude/skills/trend-analysis/SKILL.md + - .claude/skills/scholar-monitoring/SKILL.md +autonomous: true +requirements: [MCP-13] + +must_haves: + truths: + - "`.claude/skills/` directory contains four skill subdirectories" + - "Each SKILL.md has valid YAML frontmatter with `name` and `description` fields" + - "Each SKILL.md body references PaperBot MCP tools by their exact registered names" + - "Each skill's `name` field matches its directory name" + - "Skills cover literature-review, paper-reproduction, trend-analysis, scholar-monitoring workflows" + artifacts: + - path: ".claude/skills/literature-review/SKILL.md" + provides: "Literature review workflow skill" + contains: "paper_search" + - path: ".claude/skills/paper-reproduction/SKILL.md" + provides: "Paper reproduction workflow skill" + contains: "paper_judge" + - path: ".claude/skills/trend-analysis/SKILL.md" + provides: "Trend analysis workflow skill" + contains: "analyze_trends" + - path: ".claude/skills/scholar-monitoring/SKILL.md" + provides: "Scholar monitoring workflow skill" + contains: "check_scholar" + - path: "tests/unit/test_agent_skills.py" + provides: "Structural validation tests for all SKILL.md files" + min_lines: 50 + key_links: + - from: "tests/unit/test_agent_skills.py" + to: ".claude/skills/*/SKILL.md" + via: "pathlib.Path directory scan" + pattern: "SKILLS_DIR.*\\.claude/skills" + - from: ".claude/skills/*/SKILL.md" + to: "src/paperbot/mcp/tools/*.py" + via: "tool name references in skill body" + pattern: "paper_search|paper_judge|analyze_trends|check_scholar" +--- + + +Create four SKILL.md agent skill files and their structural validation tests, completing the v1.0 MCP Server milestone. + +Purpose: Make PaperBot workflows discoverable by Claude Code and Codex agents through the `.claude/skills/` convention. Each skill instructs agents how to use PaperBot MCP tools in multi-step research workflows. + +Output: 4 SKILL.md files in `.claude/skills/{name}/SKILL.md` + 1 test file validating structure. + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/06-agent-skills/06-RESEARCH.md +@.planning/phases/06-agent-skills/06-VALIDATION.md + + + + + + Task 1: Create structural validation tests for SKILL.md files + tests/unit/test_agent_skills.py + + - test_skills_directory_exists: `.claude/skills/` is a directory + - test_skill_files_exist: all 4 SKILL.md files exist (literature-review, paper-reproduction, trend-analysis, scholar-monitoring) + - test_skill_frontmatter_valid: each SKILL.md has valid YAML frontmatter with `name` and `description` fields + - test_skill_name_matches_directory: each skill's `name` field matches its directory name exactly + - test_skill_references_tools: each SKILL.md body references at least one PaperBot MCP tool by its exact registered name + - test_skill_description_has_trigger_phrases: each description contains at least 3 trigger phrases (quoted user utterances) + + +Create `tests/unit/test_agent_skills.py` with structural tests for the SKILL.md files. Use pathlib.Path for file I/O and PyYAML (`yaml.safe_load`) for frontmatter parsing. + +Constants: +- `SKILLS_DIR = pathlib.Path(__file__).resolve().parents[2] / ".claude" / "skills"` (resolve relative to repo root, not cwd) +- `EXPECTED_SKILLS = ["literature-review", "paper-reproduction", "trend-analysis", "scholar-monitoring"]` +- `KNOWN_TOOLS = {"paper_search", "paper_judge", "paper_summarize", "relevance_assess", "analyze_trends", "check_scholar", "get_research_context", "save_to_memory", "export_to_obsidian"}` + +Helper: `_parse_skill(skill_name)` splits on `---` delimiters, returns `(frontmatter_dict, body_str)`. + +No async needed. No `@pytest.mark.asyncio`. Pure synchronous file-reading tests. + +Tests MUST FAIL initially (TDD red phase) since SKILL.md files do not exist yet. + + + PYTHONPATH=src pytest tests/unit/test_agent_skills.py -x -q 2>&1 | tail -5 + + Test file exists with 6 test functions. All tests fail (red) because SKILL.md files do not exist yet. + + + + Task 2: Create four SKILL.md agent skill files + .claude/skills/literature-review/SKILL.md, .claude/skills/paper-reproduction/SKILL.md, .claude/skills/trend-analysis/SKILL.md, .claude/skills/scholar-monitoring/SKILL.md + +Create `.claude/skills/` directory and four subdirectories. Write each SKILL.md following the format from 06-RESEARCH.md. + +**All four skills share these rules:** +- YAML frontmatter with `---` delimiters: `name` (matches directory), `description` (with 4-6 trigger phrases in quotes), `tools` (list of MCP tool names used) +- Body in imperative form (NOT second person "you should") +- Reference MCP tools by exact registered names from the verified table in 06-RESEARCH.md +- Include a "Degraded Mode" section explaining `degraded=True` behavior +- Target 80-200 lines per file +- Include numbered workflow steps with tool parameters and return values + +**literature-review/SKILL.md:** +- name: literature-review +- tools: paper_search, relevance_assess, paper_judge, paper_summarize, export_to_obsidian, save_to_memory +- Trigger phrases: "literature review", "survey papers on", "search and summarize research", "find papers about", "systematic review" +- Steps: search -> filter by relevance -> judge quality -> summarize top papers -> export to Obsidian (optional) -> save synthesis to memory +- Degraded: paper_judge, paper_summarize, relevance_assess require LLM; paper_search works without + +**paper-reproduction/SKILL.md:** +- name: paper-reproduction +- tools: paper_search, paper_judge, paper_summarize, export_to_obsidian, save_to_memory +- Trigger phrases: "reproduce paper", "implement paper", "paper2code", "replicate research", "run experiment from paper" +- Steps: find paper -> judge reproducibility (rubric="reproducibility") -> summarize contributions -> save reproduction plan -> export paper note +- Include guidance on proceeding to implementation after workflow + +**trend-analysis/SKILL.md:** +- name: trend-analysis +- tools: paper_search, analyze_trends, save_to_memory, get_research_context +- Trigger phrases: "analyze trends", "what's trending in", "research landscape", "topic trend analysis", "emerging themes" +- Steps: load research context (optional) -> search broadly (max_results=20-50) -> analyze_trends -> save synthesis +- Degraded: analyze_trends requires LLM + +**scholar-monitoring/SKILL.md:** +- name: scholar-monitoring +- tools: check_scholar, save_to_memory, analyze_trends +- Trigger phrases: "monitor scholar", "check researcher activity", "track publications", "follow author", "scholar update" +- Steps: check_scholar -> analyze trends (optional) -> save monitoring note +- Include guidance on scholar lookup edge cases (diacritics, new researchers, candidates list) + +Use the example content from 06-RESEARCH.md as a starting point but adapt each skill to be complete and self-contained. + + + PYTHONPATH=src pytest tests/unit/test_agent_skills.py -x -q + + All 4 SKILL.md files exist. All 6 tests pass (green). Each file has valid frontmatter with name+description, references correct MCP tools, and has a multi-step workflow body. + + + + + +1. `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -v` -- all 6 tests pass +2. `ls .claude/skills/*/SKILL.md` -- shows 4 files +3. `grep -l "paper_search\|analyze_trends\|check_scholar" .claude/skills/*/SKILL.md` -- each skill references tools +4. Full CI suite: `PYTHONPATH=src pytest -q` -- no regressions + + + +- `.claude/skills/` contains 4 subdirectories with SKILL.md files +- Each SKILL.md has valid YAML frontmatter (name, description, tools) +- Each skill's name matches its directory name +- Each skill body references PaperBot MCP tools by exact registered names +- Each skill includes multi-step workflow instructions in imperative form +- All structural tests pass +- No regressions in existing test suite + + + +After completion, create `.planning/phases/06-agent-skills/06-01-SUMMARY.md` + diff --git a/.planning/phases/06-agent-skills/06-01-SUMMARY.md b/.planning/phases/06-agent-skills/06-01-SUMMARY.md new file mode 100644 index 00000000..042876ce --- /dev/null +++ b/.planning/phases/06-agent-skills/06-01-SUMMARY.md @@ -0,0 +1,139 @@ +--- +phase: 06-agent-skills +plan: 01 +subsystem: agent-skills +tags: [skill-md, mcp, claude-code, agent-discovery, workflow] + +# Dependency graph +requires: + - phase: 05-transport-entry-point + provides: FastMCP HTTP/stdio transport and serve.py entry point + - phase: 04-mcp-resources + provides: MCP resources (track metadata, papers, memory, scholars) + - phase: 03-remaining-mcp-tools + provides: analyze_trends, check_scholar, get_research_context, save_to_memory, export_to_obsidian + - phase: 02-core-paper-tools + provides: paper_search, paper_judge, paper_summarize, relevance_assess MCP tools +provides: + - Four SKILL.md agent skill files in .claude/skills/ for Claude Code/Codex discovery + - literature-review workflow skill (search -> filter -> judge -> summarize -> export -> save) + - paper-reproduction workflow skill (find -> reproducibility-judge -> summarize -> plan -> export) + - trend-analysis workflow skill (context -> search -> analyze_trends -> save) + - scholar-monitoring workflow skill (check_scholar -> optional trends -> save) + - Structural validation tests (tests/unit/test_agent_skills.py) covering all MCP-13 assertions +affects: + - 07-eventbus-sse (v1.1 phase — agent skills complete the v1.0 MCP Server milestone) + +# Tech tracking +tech-stack: + added: [] + patterns: + - "SKILL.md frontmatter: name (matches directory), description (4-6 trigger phrases), tools list" + - "Skill body: imperative form, numbered workflow steps, tool parameters, Degraded Mode section" + - "Progressive disclosure: lean body (80-200 lines), references/ subdirectory for detail if needed" + - "TDD: write structural tests first (RED), then create files (GREEN)" + +key-files: + created: + - .claude/skills/literature-review/SKILL.md + - .claude/skills/paper-reproduction/SKILL.md + - .claude/skills/trend-analysis/SKILL.md + - .claude/skills/scholar-monitoring/SKILL.md + - tests/unit/test_agent_skills.py + modified: [] + +key-decisions: + - "Skill tool names copied verbatim from @mcp.tool() source to prevent name mismatch bugs" + - "Degraded Mode section included in all four skills — LLM tools return degraded=True when API key missing" + - "tools frontmatter field included as advisory documentation, not enforced security boundary" + - "version field omitted from frontmatter — not required by skill discovery mechanism" + - "No references/ subdirectories needed — all four skills fit within 80-200 line target" + +patterns-established: + - "SKILL.md trigger phrases: include 4-6 quoted user-utterance phrases covering casual and formal phrasings" + - "MCP tool references in skill bodies use backtick code formatting for exact tool names" + +requirements-completed: [MCP-13] + +# Metrics +duration: 3min +completed: 2026-03-14 +--- + +# Phase 6 Plan 01: Agent Skills Summary + +**Four SKILL.md workflow skills (.claude/skills/) enabling Claude Code/Codex agents to discover and execute PaperBot MCP tool chains for literature review, paper reproduction, trend analysis, and scholar monitoring** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-03-14T06:16:00Z +- **Completed:** 2026-03-14T06:19:20Z +- **Tasks:** 2 (TDD: test file + skill files) +- **Files modified:** 5 (1 test file + 4 SKILL.md files) + +## Accomplishments + +- Created `tests/unit/test_agent_skills.py` with 6 structural tests (TDD red phase confirmed failing before skill files existed) +- Created 4 SKILL.md files covering the full PaperBot MCP tool surface (all 9 tools referenced across skills) +- All 6 structural tests pass; full CI suite (66 tests) passes with no regressions +- Completed the v1.0 MCP Server milestone (phases 1-6 all plans done) + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create structural validation tests (TDD red)** - `c6bb8c7` (test) +2. **Task 2: Create four SKILL.md agent skill files (TDD green)** - `830cc81` (feat) + +**Plan metadata:** (docs commit follows) + +_Note: TDD task 1 commit was test-only (red phase). Task 2 commit created all SKILL.md files (green phase)._ + +## Files Created/Modified + +- `.claude/skills/literature-review/SKILL.md` - 6-step literature review workflow: paper_search -> relevance_assess -> paper_judge -> paper_summarize -> export_to_obsidian -> save_to_memory +- `.claude/skills/paper-reproduction/SKILL.md` - 5-step paper reproduction workflow: paper_search -> paper_judge(rubric=reproducibility) -> paper_summarize -> save_to_memory -> export_to_obsidian +- `.claude/skills/trend-analysis/SKILL.md` - 4-step trend analysis workflow: get_research_context -> paper_search -> analyze_trends -> save_to_memory +- `.claude/skills/scholar-monitoring/SKILL.md` - 3-step scholar monitoring workflow: check_scholar -> analyze_trends -> save_to_memory +- `tests/unit/test_agent_skills.py` - 6 structural tests covering: directory exists, file existence, frontmatter validity, name-directory match, tool references, trigger phrase count + +## Decisions Made + +- Tool names copied verbatim from `@mcp.tool()` source (verified in 06-RESEARCH.md) to prevent silent name mismatch bugs that would cause "tool not found" at agent runtime +- `Degraded Mode` section included in all four skills because all LLM-backed tools (`paper_judge`, `paper_summarize`, `relevance_assess`, `analyze_trends`) return `degraded=True` when API keys are missing — critical for user diagnosis +- `tools` frontmatter field included as advisory documentation (helps agents pre-allow tools), not enforced as a security boundary +- `version` field omitted — not required by Claude Code skill loader, keeps frontmatter minimal +- No `references/` subdirectories created — all workflow bodies fit within the 80-200 line target + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. Skills are static files; no build steps needed. + +## Next Phase Readiness + +- v1.0 MCP Server milestone complete (phases 1-6 all done) +- Skills are discoverable by Claude Code agents pointing at the PaperBot repo +- Agents can trigger skills with natural language matching the trigger phrases in each description field +- Ready for v1.1 Agent Orchestration Dashboard (Phase 7: EventBus + SSE Foundation) + +## Self-Check + +Verified: +- `c6bb8c7` exists: test commit confirmed +- `830cc81` exists: feat commit confirmed +- All 4 SKILL.md files present at `.claude/skills/*/SKILL.md` +- `tests/unit/test_agent_skills.py` exists with 128 lines (6 test functions) +- `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -v` → 6 passed + +--- +*Phase: 06-agent-skills* +*Completed: 2026-03-14* diff --git a/.planning/phases/06-agent-skills/06-01-VERIFICATION.md b/.planning/phases/06-agent-skills/06-01-VERIFICATION.md new file mode 100644 index 00000000..ce37e193 --- /dev/null +++ b/.planning/phases/06-agent-skills/06-01-VERIFICATION.md @@ -0,0 +1,72 @@ +--- +phase: 06-agent-skills +verified: 2026-03-14T06:22:21Z +status: passed +score: 5/5 must-haves verified +re_verification: false +--- + +# Phase 6: Agent Skills Verification Report + +**Phase Goal:** Create `.claude/skills/` SKILL.md files for Claude Code / Codex agent integration +**Verified:** 2026-03-14T06:22:21Z +**Status:** passed +**Re-verification:** No — initial verification + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +| --- | ---------------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------------------------------------------- | +| 1 | `.claude/skills/` directory contains four skill subdirectories | VERIFIED | `ls` confirms: literature-review, paper-reproduction, scholar-monitoring, trend-analysis | +| 2 | Each SKILL.md has valid YAML frontmatter with `name` and `description` fields | VERIFIED | All 6 structural tests pass; `test_skill_frontmatter_valid` green | +| 3 | Each SKILL.md body references PaperBot MCP tools by their exact registered names | VERIFIED | All 9 tool names in KNOWN_TOOLS confirmed against `@mcp.tool()` function signatures; `test_skill_references_tools` green | +| 4 | Each skill's `name` field matches its directory name | VERIFIED | `test_skill_name_matches_directory` passes for all four skills | +| 5 | Skills cover literature-review, paper-reproduction, trend-analysis, scholar-monitoring workflows | VERIFIED | All four SKILL.md files present with multi-step numbered workflow bodies (82-95 lines each) | + +**Score:** 5/5 truths verified + +### Required Artifacts + +| Artifact | Expected | Status | Details | +| -------------------------------------------------- | ------------------------------------------------- | ---------- | ---------------------------------------------------------- | +| `.claude/skills/literature-review/SKILL.md` | Literature review workflow skill; contains `paper_search` | VERIFIED | 95 lines; references paper_search, relevance_assess, paper_judge, paper_summarize, export_to_obsidian, save_to_memory | +| `.claude/skills/paper-reproduction/SKILL.md` | Paper reproduction workflow skill; contains `paper_judge` | VERIFIED | 93 lines; references paper_search, paper_judge, paper_summarize, save_to_memory, export_to_obsidian | +| `.claude/skills/trend-analysis/SKILL.md` | Trend analysis workflow skill; contains `analyze_trends` | VERIFIED | 82 lines; references paper_search, analyze_trends, get_research_context, save_to_memory | +| `.claude/skills/scholar-monitoring/SKILL.md` | Scholar monitoring workflow skill; contains `check_scholar` | VERIFIED | 82 lines; references check_scholar, analyze_trends, save_to_memory | +| `tests/unit/test_agent_skills.py` | Structural validation tests; min 50 lines | VERIFIED | 128 lines; 6 test functions; all 6 pass | + +All five artifacts pass all three verification levels (exists, substantive, wired). + +### Key Link Verification + +| From | To | Via | Status | Details | +| --------------------------------- | --------------------------------- | ---------------------------- | -------- | ------------------------------------------------------------------------------------------ | +| `tests/unit/test_agent_skills.py` | `.claude/skills/*/SKILL.md` | `pathlib.Path` directory scan | WIRED | `SKILLS_DIR = pathlib.Path(__file__).resolve().parents[2] / ".claude" / "skills"` at line 15; all 4 SKILL.md paths resolved and read | +| `.claude/skills/*/SKILL.md` | `src/paperbot/mcp/tools/*.py` | tool name references in body | WIRED | All 9 tool names in SKILL.md bodies match `async def ` signatures decorated with `@mcp.tool()` exactly | + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +| ----------- | --------------- | ------------------------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------- | +| MCP-13 | 06-01-PLAN.md | Agent can discover and load PaperBot workflow skills via `.claude/skills/` SKILL.md files (literature-review, paper-reproduction, trend-analysis, scholar-monitoring) | SATISFIED | All four SKILL.md files exist with valid frontmatter, correct tool references, and 6 trigger phrases each; marked Complete in REQUIREMENTS.md phase mapping table | + +No orphaned requirements: only MCP-13 is mapped to Phase 6 in REQUIREMENTS.md. + +### Anti-Patterns Found + +None. Scan of all five phase files (4 SKILL.md + 1 test) found no TODO, FIXME, XXX, HACK, PLACEHOLDER, or stub patterns. + +### Human Verification Required + +None. All must-haves are verifiable through file inspection, YAML parsing, regex matching, and test execution. The 6-test suite provides automated coverage of every structural assertion. + +### Gaps Summary + +No gaps. All five artifacts exist, are substantive (82-128 lines with real workflow content), and are correctly wired. Every MCP tool name in each SKILL.md matches a real `@mcp.tool()`-decorated function in `src/paperbot/mcp/tools/`. The test file resolves paths relative to the repo root and all 6 structural tests pass. MCP-13 is the sole requirement for Phase 6 and is fully satisfied. + +--- + +_Verified: 2026-03-14T06:22:21Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/06-agent-skills/06-RESEARCH.md b/.planning/phases/06-agent-skills/06-RESEARCH.md new file mode 100644 index 00000000..bed0caa8 --- /dev/null +++ b/.planning/phases/06-agent-skills/06-RESEARCH.md @@ -0,0 +1,555 @@ +# Phase 6: Agent Skills - Research + +**Researched:** 2026-03-14 +**Domain:** Claude Code SKILL.md format, agent skill authoring conventions, `.claude/skills/` directory structure +**Confidence:** HIGH + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| MCP-13 | Agent can discover and load PaperBot workflow skills via `.claude/skills/` SKILL.md files (literature-review, paper-reproduction, trend-analysis, scholar-monitoring) | SKILL.md format fully documented from canonical `skill-development` skill in Claude Code plugins; tool names verified from implemented MCP server | + + +--- + +## Summary + +Phase 6 is purely a content-creation phase. No Python code is written. The deliverable is four SKILL.md files placed in `.claude/skills/{skill-name}/SKILL.md`. Each file is a self-contained markdown document with YAML frontmatter (name, description, and optionally tools) followed by a workflow body that instructs the agent how to use PaperBot MCP tools to accomplish a multi-step research workflow. + +The SKILL.md format is governed by the Claude Code plugin system. The agent runtime reads skills from `.claude/skills/`, scans subdirectories for SKILL.md, loads metadata always, loads the body when the skill triggers, and loads any `references/` files on demand. All four skills should be lean (under 500 lines of body content), use imperative/infinitive writing style (not second person), and reference specific MCP tool names that PaperBot has already implemented. + +All nine MCP tools and four MCP resources are complete and verified from phases 2–5. The skill files reference these by their exact Python-registered names. Testing for this phase is structural (file presence, YAML parse, required frontmatter fields) — no unit tests need to exercise the skill content at runtime. + +**Primary recommendation:** Create four SKILL.md files under `.claude/skills/` using the established frontmatter schema; reference PaperBot MCP tool names exactly as registered; keep bodies lean and workflow-focused. + +--- + +## Standard Stack + +### Core +| Component | Version | Purpose | Why Standard | +|-----------|---------|---------|--------------| +| SKILL.md files | Claude Code convention | Agent skill discovery and loading | Native Claude Code/Codex skill format — no library needed | +| YAML frontmatter | standard YAML | Metadata (name, description, tools) | Required by Claude Code skill loader | +| Markdown body | CommonMark | Workflow instructions | Human and agent readable; supports headers, code blocks, lists | + +### No Dependencies +This phase requires zero new Python packages, zero npm packages. It is plain file authoring. + +--- + +## Architecture Patterns + +### Recommended Directory Structure +``` +.claude/ +└── skills/ + ├── literature-review/ + │ └── SKILL.md + ├── paper-reproduction/ + │ └── SKILL.md + ├── trend-analysis/ + │ └── SKILL.md + └── scholar-monitoring/ + └── SKILL.md +``` + +Optional (per-skill, if body exceeds ~300 lines or detail is heavy): +``` +.claude/skills/literature-review/ +├── SKILL.md +└── references/ + └── workflow-detail.md # loaded by agent on demand +``` + +For the four PaperBot skills, the bodies are unlikely to exceed 300 lines, so `references/` subdirectories are optional. Keep it minimal. + +### Pattern 1: SKILL.md Frontmatter Schema + +**What:** YAML block at top of file, delimited by `---`. Required fields: `name`, `description`. Optional: `tools`. + +**Critical rules:** +- `name`: lowercase, hyphens only, matches the skill directory name +- `description`: third-person, includes specific trigger phrases the user or agent would say. This is the primary discovery mechanism — the agent reads the description to decide whether to load the skill body. +- `tools`: optional list of MCP tool names or Claude Code built-in tools the skill uses. Informational — helps agents pre-allow the right tools. + +**Template:** +```yaml +--- +name: literature-review +description: This skill should be used when the user asks to "do a literature review", + "survey papers on a topic", "find and summarize research on X", "search papers and + judge quality", or wants a multi-step workflow to search, score, and summarize + academic papers using PaperBot MCP tools. +tools: + - paper_search + - paper_judge + - paper_summarize + - relevance_assess + - save_to_memory + - get_research_context +--- +``` + +### Pattern 2: SKILL.md Body — Workflow Instructions + +**What:** Markdown body following the frontmatter. Written in imperative/infinitive form. Describes multi-step workflow using specific tool names. + +**Rules:** +- Write in imperative form: "Search for papers using `paper_search`." NOT "You should search..." +- Reference MCP tools by their exact registered names (verified below) +- Keep body under 500 lines (ideally 100–250 lines for these focused workflows) +- Use numbered steps for sequential workflows +- Call out degraded states (all PaperBot tools return `degraded=True` when LLM is unavailable) + +**Example body structure:** +```markdown +# Literature Review Workflow + +Conduct a multi-step academic literature review using PaperBot MCP tools. + +## Workflow + +### Step 1: Search for papers +Call `paper_search` with the research topic. +- Parameters: `query` (required), `max_results` (default 10), `sources` (optional) +- Returns: list of paper dicts with title, abstract, authors, year, venue + +### Step 2: Assess relevance +For each paper, call `relevance_assess` to score relevance (0–100). +- Parameters: `title`, `abstract`, `query`, `keywords` (optional) +- Filter out papers with score below threshold (suggest 40) + +### Step 3: Judge quality +For high-relevance papers, call `paper_judge` to assess quality dimensions. +- Parameters: `title`, `abstract`, `full_text` (optional), `rubric` (default "default") +- Returns: dimension scores (1–5), overall score, recommendation (must_read/worth_reading/skim/skip) + +### Step 4: Summarize selected papers +Call `paper_summarize` for papers recommended as must_read or worth_reading. +- Parameters: `title`, `abstract` +- Returns: concise summary string + +### Step 5: Save findings to memory +Call `save_to_memory` with synthesized findings. +- Parameters: `content` (the synthesis), `kind` ("note" or "hypothesis"), `scope_type` ("global" or "track") + +## Degraded Mode +If tools return `degraded=True`, LLM API keys are not configured. +Set OPENAI_API_KEY or ANTHROPIC_API_KEY and restart the MCP server. +``` + +### Pattern 3: Tool Name Reference (Verified) + +The exact names as registered via `@mcp.tool()` decorators in PaperBot: + +| Tool Name | Parameters | Returns | Phase | +|-----------|-----------|---------|-------| +| `paper_search` | `query`, `max_results=10`, `sources=None` | list of paper dicts | Phase 2 | +| `paper_judge` | `title`, `abstract`, `full_text=""`, `rubric="default"` | dict with scores + recommendation | Phase 2 | +| `paper_summarize` | `title`, `abstract` | dict with `summary` key | Phase 2 | +| `relevance_assess` | `title`, `abstract`, `query`, `keywords=""` | dict with `score` (0–100) + `reason` | Phase 2 | +| `analyze_trends` | `topic`, `papers` (list of dicts) | dict with `trend_analysis` string | Phase 3 | +| `check_scholar` | `scholar_name`, `max_papers=10` | dict with `scholar` + `recent_papers` | Phase 3 | +| `get_research_context` | `query`, `user_id="default"`, `track_id=None` | dict with papers, memories, stage | Phase 3 | +| `save_to_memory` | `content`, `kind="note"`, `user_id`, `scope_type`, `scope_id`, `confidence` | dict with created/skipped | Phase 3 | +| `export_to_obsidian` | `title`, `abstract`, `authors=[]`, `year=None`, `venue=""`, `arxiv_id=""`, `doi=""` | dict with `markdown` key | Phase 3 | + +MCP Resources (read-only, referenced by URI not tool call): + +| Resource URI | Returns | +|-------------|---------| +| `paperbot://track/{id}` | Track metadata | +| `paperbot://track/{id}/papers` | Papers in a track | +| `paperbot://track/{id}/memory` | Track memory items | +| `paperbot://scholars` | Scholar subscriptions | + +### Pattern 4: Workflow Mapping to Skills + +| Skill Name | Primary Tools | Description Trigger Phrases | +|-----------|-------------|---------------------------| +| `literature-review` | paper_search, relevance_assess, paper_judge, paper_summarize, save_to_memory | "literature review", "survey papers on", "search and summarize research", "find papers about" | +| `paper-reproduction` | paper_search, paper_summarize, paper_judge, export_to_obsidian, save_to_memory | "reproduce paper", "implement paper code", "paper2code", "replicate research", "run experiment from paper" | +| `trend-analysis` | paper_search, analyze_trends, save_to_memory, get_research_context | "analyze trends", "what's trending in", "research landscape", "topic trend analysis" | +| `scholar-monitoring` | check_scholar, save_to_memory, analyze_trends | "monitor scholar", "check researcher activity", "track publications", "follow author" | + +### Anti-Patterns to Avoid + +- **Wrong tool names:** Any typo in a tool name means the agent calls a non-existent tool. Use the exact names from the table above — they come directly from `@mcp.tool()` function definitions in the Python source. +- **Second-person writing in body:** "You should call `paper_search`" is wrong. "Call `paper_search`" is correct. Imperative form throughout. +- **Vague description field:** "Helps with paper research" will not trigger reliably. Include concrete user phrases ("do a literature review on transformers") in the description. +- **Referencing unimplemented tools:** Do not reference tools not in the verified list above. The four skills cover everything with the 9 existing tools. +- **Body over 500 lines:** If workflow detail is too long, move it to `references/workflow.md` and link from SKILL.md body. +- **Missing `name` or `description` in frontmatter:** The skill loader requires both. Omitting either causes the skill to be silently ignored. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Skill discovery mechanism | Custom skill scanner | `.claude/skills/` directory convention | Claude Code has built-in skill loader | +| Tool routing logic | Custom tool dispatcher | MCP tool calling (already in MCP server) | Phase 3–5 implementation handles dispatch | +| Workflow engine | State machine for multi-step workflows | Skill body instructions + agent reasoning | Agent reasons through steps; no code needed | +| Paper metadata schemas | Custom Paper class in skill | References existing tool return shapes | Tools already define their output format | + +**Key insight:** This phase is content, not code. The "engine" is the agent itself reading the SKILL.md and calling MCP tools. Zero Python is written. + +--- + +## Common Pitfalls + +### Pitfall 1: Tool Name Typos +**What goes wrong:** Skill body says `search_papers` instead of `paper_search`. Agent attempts to call a non-existent tool and fails. +**Why it happens:** Skill authors write from memory, not from verified source. +**How to avoid:** Copy tool names directly from the verified table above, which was extracted from the actual `@mcp.tool()` source files. +**Warning signs:** Agent logs show "Tool not found" or similar MCP errors during skill execution. + +### Pitfall 2: Over-specifying Parameters in Skill Body +**What goes wrong:** Skill hardcodes `max_results=5` in every step, preventing the agent from adjusting for the user's actual query scope. +**Why it happens:** Trying to be too prescriptive. +**How to avoid:** Specify defaults as suggestions, not mandates. "Call `paper_search` with `max_results=10` (adjust as needed)." +**Warning signs:** Users complain that reviews always return the same number of papers regardless of topic breadth. + +### Pitfall 3: Weak Description Trigger Phrases +**What goes wrong:** Skill description says "helps with academic research" — too vague to trigger reliably. Agent loads a different skill or no skill. +**Why it happens:** Generic language in the description field. +**How to avoid:** Include at least 4–6 specific user-utterance trigger phrases in the description. Cover both casual ("find papers about attention mechanisms") and formal ("conduct a systematic literature review on X") phrasings. +**Warning signs:** Agent does not load the skill when the user asks an obvious workflow question. + +### Pitfall 4: Missing Degraded-Mode Guidance +**What goes wrong:** User runs a literature review with no LLM API key configured. Tools return `degraded=True`. Agent silently produces empty results. +**Why it happens:** Skill body doesn't mention the degraded mode pattern. +**How to avoid:** Add a "Degraded Mode" section to each skill body. All PaperBot LLM-backed tools (`paper_judge`, `paper_summarize`, `relevance_assess`, `analyze_trends`) return `degraded=True` plus an `error` key when the LLM is unavailable. `paper_search` and `check_scholar` are degraded-resilient (no LLM needed). +**Warning signs:** Agent returns empty summaries without error messages. + +### Pitfall 5: Directory Name Mismatch +**What goes wrong:** Skill directory named `lit-review/` but frontmatter `name: literature-review`. Some loaders use the directory name as the identifier. +**How to avoid:** Match the directory name to the `name` field in frontmatter exactly. Use the four names defined in the requirement: `literature-review`, `paper-reproduction`, `trend-analysis`, `scholar-monitoring`. +**Warning signs:** Skill is listed under wrong name in `/skills` command output. + +--- + +## Code Examples + +Verified patterns from canonical skill format and PaperBot MCP tool signatures: + +### Minimal Valid SKILL.md Frontmatter +```yaml +--- +name: trend-analysis +description: This skill should be used when the user asks to "analyze trends in a + research area", "what is trending in X", "research landscape for topic Y", "topic + trend analysis", or wants to survey a field and identify emerging themes across + multiple papers using PaperBot. +tools: + - paper_search + - analyze_trends + - save_to_memory + - get_research_context +--- +``` + +### Trend Analysis Workflow Body (Example) +```markdown +# Trend Analysis Workflow + +Identify research trends across a topic by searching, collecting, and analyzing papers. + +## Workflow + +### Step 1: Load research context (optional) +Call `get_research_context` with the topic to retrieve existing memories and papers. +- If `track_id` is known, pass it to scope the context. + +### Step 2: Search for papers +Call `paper_search` with the topic. Use `max_results=20–50` for trend analysis +(broader corpus improves trend signal). + +### Step 3: Analyze trends +Call `analyze_trends` with `topic` and the list of paper dicts from Step 2. +- Returns `trend_analysis` (natural language), `topic`, `paper_count`. +- Check for `degraded=True` — requires LLM API key. + +### Step 4: Save synthesis +Call `save_to_memory` with the trend analysis text. +- Use `kind="note"` or `kind="hypothesis"` as appropriate. +- Use `scope_type="global"` unless scoping to a specific track. + +## Degraded Mode +`analyze_trends` returns `degraded=True` and an `error` key when LLM is unavailable. +Configure OPENAI_API_KEY or ANTHROPIC_API_KEY before using this workflow. +``` + +### Scholar Monitoring Workflow Body (Example) +```markdown +# Scholar Monitoring Workflow + +Monitor a researcher's recent publication activity and synthesize their output. + +## Workflow + +### Step 1: Check scholar activity +Call `check_scholar` with the scholar's name. +- Returns `scholar` (profile with hIndex, citationCount) and `recent_papers` (list). +- If `degraded=True`, the scholar was not found on Semantic Scholar. Try alternate name spellings. + +### Step 2: Analyze paper trends (optional) +If recent_papers is non-empty, call `analyze_trends` with the scholar's name as topic +and the recent_papers list. + +### Step 3: Save monitoring note +Call `save_to_memory` with a summary of the scholar's recent activity. +- Use `kind="note"`, `scope_type="global"`. + +## Note on Scholar Lookup +`check_scholar` searches Semantic Scholar by name. Common issues: +- Names with diacritics may need ASCII variant. +- Very new researchers may have limited Semantic Scholar records. +- The tool returns top 3 candidates in `candidates` — inspect these if top match is wrong. +``` + +### Literature Review Workflow Body (Example) +```markdown +# Literature Review Workflow + +Conduct a systematic literature review: search, filter by relevance, judge quality, +summarize, and save findings. + +## Workflow + +### Step 1: Search for papers +Call `paper_search` with the research question. +- `max_results`: 10–20 for focused reviews, up to 50 for broad surveys. +- `sources`: omit for all sources, or specify `["arxiv", "semantic_scholar"]`. + +### Step 2: Filter by relevance +For each paper, call `relevance_assess` with `title`, `abstract`, and the same `query`. +- `score` is 0–100. Threshold suggestion: discard papers below 40. +- If `degraded=True`, fallback to token-overlap scoring (less accurate but functional). + +### Step 3: Judge quality of relevant papers +For papers above relevance threshold, call `paper_judge`. +- `rubric`: pass the research question as the rubric for context-aware judging. +- `recommendation`: use to prioritize (must_read > worth_reading > skim > skip). + +### Step 4: Summarize top papers +Call `paper_summarize` for must_read and worth_reading papers. +- Returns `summary` text. +- If `degraded=True`, generate manual summary from abstract. + +### Step 5: Export to Obsidian (optional) +Call `export_to_obsidian` for papers to save as permanent notes. +- Provide `title`, `abstract`, `authors`, `year`, `venue`, `arxiv_id`/`doi` as available. +- Returns `markdown` string with YAML frontmatter ready to write to vault. + +### Step 6: Save synthesis to memory +Call `save_to_memory` with a synthesis of findings. +- `kind="note"` for general observations, `kind="hypothesis"` for research directions. + +## Degraded Mode +`paper_judge`, `paper_summarize`, `relevance_assess` require LLM API keys. +`paper_search` works without LLM. When degraded, search results are returned but +quality scoring and summaries are unavailable. +``` + +### Paper Reproduction Workflow Body (Example) +```markdown +# Paper Reproduction Workflow + +Reproduce or implement a paper: locate it, understand its contributions, and guide +implementation or code generation. + +## Workflow + +### Step 1: Find the paper +Call `paper_search` with the paper title or topic. +- If ArXiv ID or DOI is known, include it in the query for direct lookup. + +### Step 2: Judge reproducibility +Call `paper_judge` with `rubric="reproducibility"` to assess implementation feasibility. +- High rigor score and clear methodology are favorable signals. +- Low clarity score may indicate reproduction difficulty. + +### Step 3: Summarize paper contributions +Call `paper_summarize` to get a concise summary of key contributions, methods, findings. + +### Step 4: Save reproduction plan to memory +Call `save_to_memory` with an outline of implementation steps. +- `kind="project"` or `kind="decision"` for planned implementation approach. + +### Step 5: Export paper note +Call `export_to_obsidian` to create an Obsidian note for the paper. +- Provides structured YAML frontmatter and body for the research notebook. + +## Implementation Guidance +After completing the above workflow, proceed with code implementation using available +coding tools (Bash, Write, etc.). The Paper2Code pipeline in PaperBot +(`src/paperbot/repro/`) provides deeper analysis for complex reproductions but +requires the full PaperBot backend. +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Ad-hoc agent prompts | SKILL.md convention | 2024–2025 (Claude Code skill system) | Structured discovery, progressive loading | +| Monolithic agent instructions | Progressive disclosure (metadata → body → references) | Claude Code plugin system | Context-efficient skill loading | +| Tool documentation in CLAUDE.md | Skill-specific workflow files in `.claude/skills/` | 2025 (agent skills ecosystem) | Domain-scoped, discoverable, reloadable | + +**Current convention (HIGH confidence):** +- Skills live in `.claude/skills/{name}/SKILL.md` +- YAML frontmatter with `name` and `description` (required), `tools` (optional) +- Body in imperative/infinitive form +- Progressive disclosure: put detail in `references/` subdirectory, not SKILL.md body + +--- + +## Open Questions + +1. **`tools` frontmatter field: enforced or advisory?** + - What we know: The `skill-development` SKILL.md from Claude Code plugins does not include a `tools` field in its examples. The GSD phase-researcher agent file includes a `tools:` field (e.g., `tools: Read, Write, Bash, Grep`). + - What's unclear: Whether the Claude Code skill loader enforces tool restrictions based on the `tools` field, or whether it is purely advisory/documentation. + - Recommendation: Include `tools` field as documentation of which MCP tools the skill uses. It signals to the agent what tools to pre-allow. Do not rely on it as a security boundary. + +2. **Skill body length for these four workflows** + - What we know: Canonical recommendation is 1,500–2,000 words for plugin skills, under 500 lines for Codex skills. + - What's unclear: PaperBot's skills are MCP-tool-calling workflows, not general knowledge domains. The bodies may be naturally compact (100–200 lines). + - Recommendation: Target 80–200 lines per skill body. All workflow steps fit within this range without needing `references/` subdirectories. + +3. **`version` field in frontmatter** + - What we know: The `skill-development` SKILL.md includes `version: 0.1.0`. The `frontend-design` SKILL.md does not include a version field. + - What's unclear: Whether `version` is required or advisory. + - Recommendation: Omit `version` for simplicity. It is not required by the discovery mechanism. + +--- + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest with pytest-asyncio (asyncio_mode = "strict") | +| Config file | `pyproject.toml` — `[tool.pytest.ini_options]` | +| Quick run command | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` | +| Full suite command | `PYTHONPATH=src pytest -q` | + +### Phase Requirements to Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| MCP-13 | `.claude/skills/` directory exists | static/file check | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py::test_skills_directory_exists -x` | ❌ Wave 0 | +| MCP-13 | `literature-review/SKILL.md` exists | static/file check | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py::test_skill_files_exist -x` | ❌ Wave 0 | +| MCP-13 | `paper-reproduction/SKILL.md` exists | static/file check | same | ❌ Wave 0 | +| MCP-13 | `trend-analysis/SKILL.md` exists | static/file check | same | ❌ Wave 0 | +| MCP-13 | `scholar-monitoring/SKILL.md` exists | static/file check | same | ❌ Wave 0 | +| MCP-13 | Each SKILL.md has valid YAML frontmatter | unit (yaml parse) | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py::test_skill_frontmatter_valid -x` | ❌ Wave 0 | +| MCP-13 | Each SKILL.md frontmatter has `name` field | unit | same | ❌ Wave 0 | +| MCP-13 | Each SKILL.md frontmatter has `description` field | unit | same | ❌ Wave 0 | +| MCP-13 | Each SKILL.md body references at least one PaperBot MCP tool by name | unit (grep) | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py::test_skill_references_tools -x` | ❌ Wave 0 | +| MCP-13 | Skills `name` field matches directory name | unit | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py::test_skill_name_matches_directory -x` | ❌ Wave 0 | + +### Test Implementation Notes + +SKILL.md files are plain text — no async needed. Tests use `pathlib.Path` to read files and `yaml` (PyYAML, already a project transitive dep) to parse frontmatter. + +```python +# Example test pattern (no async needed — file I/O only): +import pathlib +import yaml + +SKILLS_DIR = pathlib.Path(".claude/skills") +EXPECTED_SKILLS = [ + "literature-review", + "paper-reproduction", + "trend-analysis", + "scholar-monitoring", +] +KNOWN_TOOLS = { + "paper_search", "paper_judge", "paper_summarize", "relevance_assess", + "analyze_trends", "check_scholar", "get_research_context", + "save_to_memory", "export_to_obsidian", +} + +def _parse_skill(skill_name: str): + path = SKILLS_DIR / skill_name / "SKILL.md" + content = path.read_text() + # Strip leading/trailing --- delimiters + parts = content.split("---", 2) + frontmatter = yaml.safe_load(parts[1]) + body = parts[2] if len(parts) > 2 else "" + return frontmatter, body + +def test_skills_directory_exists(): + assert SKILLS_DIR.is_dir() + +def test_skill_files_exist(): + for name in EXPECTED_SKILLS: + assert (SKILLS_DIR / name / "SKILL.md").is_file(), f"Missing: {name}/SKILL.md" + +def test_skill_frontmatter_valid(): + for name in EXPECTED_SKILLS: + fm, _ = _parse_skill(name) + assert "name" in fm, f"{name}: missing 'name' in frontmatter" + assert "description" in fm, f"{name}: missing 'description' in frontmatter" + +def test_skill_name_matches_directory(): + for name in EXPECTED_SKILLS: + fm, _ = _parse_skill(name) + assert fm["name"] == name, f"name mismatch: {fm['name']} != {name}" + +def test_skill_references_tools(): + for name in EXPECTED_SKILLS: + _, body = _parse_skill(name) + found = any(tool in body for tool in KNOWN_TOOLS) + assert found, f"{name}: body does not reference any PaperBot MCP tool" +``` + +### Sampling Rate +- **Per task commit:** `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` +- **Per wave merge:** `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` +- **Phase gate:** Full CI offline suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `.claude/skills/` directory — must be created (does not exist yet) +- [ ] `.claude/skills/literature-review/SKILL.md` — covers MCP-13 +- [ ] `.claude/skills/paper-reproduction/SKILL.md` — covers MCP-13 +- [ ] `.claude/skills/trend-analysis/SKILL.md` — covers MCP-13 +- [ ] `.claude/skills/scholar-monitoring/SKILL.md` — covers MCP-13 +- [ ] `tests/unit/test_agent_skills.py` — covers all MCP-13 structural assertions + +--- + +## Sources + +### Primary (HIGH confidence) +- `/home/master1/PaperBot/src/paperbot/mcp/server.py` — verified all 9 tool and 4 resource registrations +- `/home/master1/PaperBot/src/paperbot/mcp/tools/*.py` — verified exact function names and parameter signatures for all tools +- `/home/master1/.claude/plugins/marketplaces/claude-plugins-official/plugins/plugin-dev/skills/skill-development/SKILL.md` — canonical SKILL.md format, frontmatter schema, progressive disclosure rules, writing style +- `/home/master1/.codex/skills/.system/skill-creator/SKILL.md` — Codex-side skill format (same structure, confirms conventions) +- `/home/master1/.claude/agents/gsd-phase-researcher.md` — confirms `skills:` field in `.claude/agents/*.md` files; shows how tools/skills fields relate + +### Secondary (MEDIUM confidence) +- `/home/master1/.claude/plugins/marketplaces/claude-plugins-official/plugins/plugin-dev/skills/mcp-integration/SKILL.md` — shows how MCP tool names are referenced in skills; `tools` field format +- `/home/master1/.claude/plugins/marketplaces/claude-plugins-official/plugins/frontend-design/skills/frontend-design/SKILL.md` — minimal SKILL.md (no `tools`, no `version` field) — shows fields are optional beyond `name` and `description` + +### Tertiary (LOW confidence) +- None — all findings are verified from codebase and canonical skill files on this machine. + +--- + +## Metadata + +**Confidence breakdown:** +- SKILL.md format: HIGH — read canonical `skill-development` SKILL.md directly from installed Claude Code plugins; cross-verified with Codex skill-creator +- MCP tool names: HIGH — extracted directly from Python source `@mcp.tool()` function definitions in `src/paperbot/mcp/tools/` +- Workflow content per skill: MEDIUM — workflow steps are authored judgments based on tool capabilities; agent may adapt steps at runtime. Content is correct but not verified against user testing. +- Test approach: HIGH — standard pytest + pathlib + yaml pattern; no async needed + +**Research date:** 2026-03-14 +**Valid until:** 2026-04-14 (SKILL.md format is stable; MCP tool signatures are locked by Phase 2–5 implementation) diff --git a/.planning/phases/06-agent-skills/06-VALIDATION.md b/.planning/phases/06-agent-skills/06-VALIDATION.md new file mode 100644 index 00000000..6cd5ba9d --- /dev/null +++ b/.planning/phases/06-agent-skills/06-VALIDATION.md @@ -0,0 +1,74 @@ +--- +phase: 6 +slug: agent-skills +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-14 +--- + +# Phase 6 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest 7.x (no async needed — file I/O only) | +| **Config file** | `pyproject.toml` — `[tool.pytest.ini_options]` | +| **Quick run command** | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` | +| **Full suite command** | `PYTHONPATH=src pytest -q` | +| **Estimated runtime** | ~2 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` +- **After every plan wave:** Run `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 2 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 06-01-01 | 01 | 1 | MCP-13 | unit | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -x -q` | ❌ W0 | ⬜ pending | +| 06-01-02 | 01 | 1 | MCP-13 | unit | `PYTHONPATH=src pytest tests/unit/test_agent_skills.py -x -q` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/unit/test_agent_skills.py` — structural tests for SKILL.md files (presence, YAML parse, required fields, tool references, name-directory match) +- [ ] `.claude/skills/` directory — created with four skill subdirectories + +*Existing infrastructure covers test framework and fixtures.* + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Agent discovers and loads skill from `.claude/skills/` | MCP-13 | Requires live Claude Code agent runtime | 1. Start Claude Code session in PaperBot repo 2. Ask "do a literature review on transformers" 3. Verify skill is loaded and workflow steps execute | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 2s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/07-eventbus-sse-foundation/07-01-PLAN.md b/.planning/phases/07-eventbus-sse-foundation/07-01-PLAN.md new file mode 100644 index 00000000..77544ac6 --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-01-PLAN.md @@ -0,0 +1,255 @@ +--- +phase: 07-eventbus-sse-foundation +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - tests/unit/test_event_bus_event_log.py + - tests/integration/test_events_sse_endpoint.py + - src/paperbot/infrastructure/event_log/event_bus_event_log.py +autonomous: true +requirements: + - EVNT-04 +must_haves: + truths: + - "EventBusEventLog.append() fans out to all registered subscriber queues" + - "New subscriber receives ring buffer contents as catch-up burst on subscribe()" + - "Full queue drops oldest event instead of blocking the producer" + - "Unsubscribing a queue removes it from the fan-out set (no leak)" + artifacts: + - path: "src/paperbot/infrastructure/event_log/event_bus_event_log.py" + provides: "EventBusEventLog — EventLogPort backend for in-process SSE fan-out" + exports: ["EventBusEventLog"] + - path: "tests/unit/test_event_bus_event_log.py" + provides: "Unit tests covering fan-out, ring buffer, backpressure, unsubscribe" + contains: "test_fan_out_to_multiple_subscribers" + - path: "tests/integration/test_events_sse_endpoint.py" + provides: "Integration test stubs (failing) for SSE delivery and heartbeat" + contains: "test_event_delivered_within_1s" + key_links: + - from: "EventBusEventLog.append()" + to: "asyncio.Queue.put_nowait()" + via: "_fan_out()" + pattern: "put_nowait" + - from: "EventBusEventLog.subscribe()" + to: "collections.deque ring buffer" + via: "catch-up burst in subscribe()" + pattern: "for event in list\\(self\\._ring\\)" +--- + + +Implement EventBusEventLog — the in-process fan-out backend that intercepts every +event_log.append() call and delivers the event to all connected SSE client queues. + +Purpose: Implements EVNT-04 core mechanism. All real-time SSE delivery flows through +this class. Plan 07-02 wires it into FastAPI; this plan makes it correct first. + +Output: +- src/paperbot/infrastructure/event_log/event_bus_event_log.py (production) +- tests/unit/test_event_bus_event_log.py (unit tests, RED then GREEN) +- tests/integration/test_events_sse_endpoint.py (integration stubs, remain RED + until Plan 07-02 wires the endpoint) + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/07-eventbus-sse-foundation/07-CONTEXT.md +@.planning/phases/07-eventbus-sse-foundation/07-RESEARCH.md +@.planning/phases/07-eventbus-sse-foundation/07-VALIDATION.md + + + + +From src/paperbot/application/ports/event_log_port.py: +```python +@runtime_checkable +class EventLogPort(Protocol): + def append(self, event: Union[AgentEventEnvelope, dict]) -> None: ... + def stream(self, run_id: str) -> Iterable[dict]: ... + def close(self) -> None: ... +``` + +From src/paperbot/application/collaboration/message_schema.py: +```python +def new_run_id() -> str: ... # uuid4().hex +def new_trace_id() -> str: ... # uuid4().hex + +@dataclass +class AgentEventEnvelope: + run_id: str + trace_id: str + # ... (full envelope) + def to_dict(self) -> Dict[str, Any]: ... + def to_json(self) -> str: ... +``` + +From src/paperbot/infrastructure/event_log/composite_event_log.py: +```python +class CompositeEventLog(EventLogPort): + def __init__(self, backends: List[EventLogPort]): ... + # Tees append() to all backends; EventBusEventLog plugs in as a backend +``` + +From src/paperbot/api/streaming.py: +```python +SSE_HEADERS: dict # Cache-Control, Connection, X-Accel-Buffering +def sse_comment(comment: str = "keepalive") -> str: ... # ": keepalive\n\n" +def sse_done() -> str: ... # "data: [DONE]\n\n" +``` + + + + + EventBusEventLog — asyncio fan-out ring buffer backend + + src/paperbot/infrastructure/event_log/event_bus_event_log.py, + tests/unit/test_event_bus_event_log.py + + + Fan-out to multiple subscribers: + - bus.append(event) → event appears in q1.get() AND q2.get() when two subscribers registered + + Ring buffer catch-up: + - append 5 events before subscribe() → new subscriber's queue pre-loaded with those 5 events + - buffer size capped at ring_buffer_size (default 200); oldest evicted automatically + + Backpressure — drop-oldest strategy: + - fill client queue to maxsize → append one more event → oldest item is gone, newest is present + - producer never blocks: no await, no sleep, no exception propagated to caller + + Unsubscribe cleanup: + - subscribe() → unsubscribe(q) → append() → q receives nothing (not in fan-out set) + - len(bus._queues) == 0 after unsubscribe of sole subscriber + + EventLogPort compliance: + - stream() returns empty iterator (no historical replay by run_id) + - close() empties _queues set + - append() accepts both AgentEventEnvelope and plain dict + + Serialization: + - AgentEventEnvelope serialized via .to_dict() once in append(); fan-out distributes the dict + - No re-serialization inside _fan_out() + + Thread-safety note (document in docstring, not enforced at runtime): + - append() is called from the async event loop only (uvicorn single-process) + - put_nowait() is safe; no thread bridging needed for current architecture + + + + + + + Task 1: Write Wave-0 test scaffolds (RED) + tests/unit/test_event_bus_event_log.py, tests/integration/test_events_sse_endpoint.py + +Create two test scaffold files. Both must exist BEFORE the implementation so that the +TDD RED phase is verifiable. + +**tests/unit/test_event_bus_event_log.py** — write actual test bodies (not just stubs), +using asyncio.Queue directly without importing EventBusEventLog yet in failing imports. +All tests must be decorated @pytest.mark.asyncio (asyncio_mode = "strict" in pyproject.toml). + +Required test functions (must match names exactly — VALIDATION.md maps to them): +- test_fan_out_to_multiple_subscribers: subscribe two queues, append one event, assert both queues have it +- test_ring_buffer_catch_up: append 3 events, THEN subscribe, assert queue has those 3 events pre-loaded +- test_backpressure_drops_oldest: create bus with client_queue_size=2, subscribe, fill queue to 2, + append 1 more, assert queue still has 2 items and the oldest is gone (newest is present) +- test_unsubscribe_cleans_up: subscribe q, unsubscribe q, append event, assert len(bus._queues)==0 + and q is empty +- test_composite_includes_bus: create CompositeEventLog([EventBusEventLog()]), call append, assert + the EventBusEventLog's _ring has one item + +Import: `from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog` + +**tests/integration/test_events_sse_endpoint.py** — minimal stubs with pytest.skip markers +so they exist for VALIDATION.md tracking but don't fail the unit suite. Mark with +@pytest.mark.integration. Each stub should raise pytest.skip("endpoint not yet wired — Plan 07-02"). + +Required stub names (exact, per VALIDATION.md): +- test_event_delivered_within_1s +- test_heartbeat_on_idle + +Run after writing: + PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q 2>&1 | head -20 + +Expected: ModuleNotFoundError (EventBusEventLog doesn't exist yet) — this is the RED state. + + + PYTHONPATH=/home/master1/PaperBot/src pytest /home/master1/PaperBot/tests/unit/test_event_bus_event_log.py -q 2>&1 | grep -E "(ERROR|ModuleNotFoundError|ImportError|FAILED|passed)" | head -5 + + Both test files exist. Unit test file has 5 test functions. Integration test file has 2 stub functions. Running pytest on unit tests shows import error (RED state confirmed). + + + + Task 2: Implement EventBusEventLog (GREEN) + src/paperbot/infrastructure/event_log/event_bus_event_log.py + + - test_fan_out_to_multiple_subscribers: append(event) → both q1 and q2 receive it + - test_ring_buffer_catch_up: 3 pre-appended events appear in new subscriber's queue + - test_backpressure_drops_oldest: full queue drops oldest on overflow, never raises + - test_unsubscribe_cleans_up: unsubscribe removes queue from _queues set + - test_composite_includes_bus: CompositeEventLog delegates append to bus backend + + +Create src/paperbot/infrastructure/event_log/event_bus_event_log.py implementing +EventBusEventLog. Follow the exact design from 07-RESEARCH.md Pattern 1. + +Key implementation constraints (per CONTEXT.md locked decisions): +- Ring buffer: collections.deque(maxlen=200) — default, configurable via __init__ param +- Per-client queue: asyncio.Queue(maxsize=256) — default, configurable +- Backpressure: drop-oldest (get_nowait then put_nowait) — NEVER block producer +- No server-side filtering — all events go to all subscriber queues +- subscribe() pre-loads the new queue with ring buffer contents (catch-up burst) +- append() accepts AgentEventEnvelope OR dict; serialize via .to_dict() once, store dict +- _fan_out() iterates list(self._queues) snapshot (safe against concurrent unsubscribe) +- stream() returns iter(()) — bus doesn't support run_id replay +- close() calls self._queues.clear() + +Anti-patterns from research (do NOT do these): +- Do NOT use await anywhere in append() or _fan_out() — append() is a sync def +- Do NOT call asyncio.get_event_loop() at module level +- Do NOT call to_json() inside _fan_out() (serialize once in append()) +- Do NOT iterate self._queues directly (use list() snapshot) + +After writing the file, run the unit tests to achieve GREEN: + PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -v + +Fix any failures before moving on. + + + PYTHONPATH=/home/master1/PaperBot/src pytest /home/master1/PaperBot/tests/unit/test_event_bus_event_log.py -v 2>&1 | tail -15 + + All 5 unit tests pass. GREEN state confirmed. No await inside append() or _fan_out(). Implementation follows drop-oldest backpressure exactly. + + + + + +Full test suite for this plan: + PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q + +All 5 unit tests pass. Integration stubs exist but are skipped (they require Plan 07-02's endpoint). +No regressions in existing event log tests: + PYTHONPATH=src pytest tests/ -k "event_log" -q + + + +1. src/paperbot/infrastructure/event_log/event_bus_event_log.py exists and exports EventBusEventLog +2. EventBusEventLog implements EventLogPort (append, stream, close) +3. All 5 unit tests pass: fan-out, ring-buffer, backpressure, unsubscribe, composite wiring +4. tests/integration/test_events_sse_endpoint.py exists with 2 stub functions (skipped) +5. No await inside append() or _fan_out() +6. No new pyproject.toml dependencies (stdlib only) + + + +After completion, create .planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md + diff --git a/.planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md b/.planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md new file mode 100644 index 00000000..96d6ee51 --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 07-eventbus-sse-foundation +plan: "01" +subsystem: infra +tags: [asyncio, event-bus, sse, fan-out, ring-buffer, backpressure, event-log] + +# Dependency graph +requires: + - phase: existing-event-log + provides: "EventLogPort protocol, CompositeEventLog, AgentEventEnvelope" +provides: + - "EventBusEventLog — in-process asyncio fan-out ring buffer backend" + - "subscribe()/unsubscribe() API for SSE client queue management" + - "Ring buffer catch-up burst on connect (configurable maxlen=200)" + - "Drop-oldest backpressure — producer never blocks" + - "Unit test suite (5 tests) and integration stubs (2 skipped)" +affects: + - 07-02-sse-endpoint + - 07-03-frontend-sse-client + +# Tech tracking +tech-stack: + added: [] + patterns: + - "fan-out via asyncio.Queue.put_nowait() — sync append(), no await in hot path" + - "drop-oldest backpressure: get_nowait() then put_nowait() on full queue" + - "ring buffer catch-up: pre-load new subscriber queue from deque snapshot" + - "list(self._queues) snapshot in _fan_out() guards against concurrent unsubscribe" + +key-files: + created: + - src/paperbot/infrastructure/event_log/event_bus_event_log.py + - tests/unit/test_event_bus_event_log.py + - tests/integration/test_events_sse_endpoint.py + modified: [] + +key-decisions: + - "collections.deque(maxlen=200) ring buffer — configurable via ring_buffer_size param" + - "asyncio.Queue(maxsize=256) per subscriber — configurable via client_queue_size param" + - "drop-oldest backpressure: evict oldest via get_nowait() then insert newest — never block producer" + - "AgentEventEnvelope serialized once via .to_dict() in append(); fan-out distributes dict" + - "stream() returns iter(()) — bus does not support run_id historical replay" + - "integration test stubs remain skipped until Plan 07-02 wires the SSE endpoint" + +patterns-established: + - "EventBusEventLog plugs into CompositeEventLog as a side-effect backend — no changes to existing event log" + - "TDD RED-GREEN: test file committed before implementation file" + +requirements-completed: [EVNT-04] + +# Metrics +duration: 3min +completed: 2026-03-14 +--- + +# Phase 7 Plan 01: EventBusEventLog Fan-out Ring Buffer Summary + +**asyncio fan-out ring buffer (EventBusEventLog) with drop-oldest backpressure and ring buffer catch-up burst for SSE delivery, implementing EVNT-04 without any new dependencies** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-03-14T06:37:58Z +- **Completed:** 2026-03-14T06:40:25Z +- **Tasks:** 2 +- **Files modified:** 3 + +## Accomplishments +- EventBusEventLog implements EventLogPort using stdlib only (asyncio, collections) +- Drop-oldest backpressure via get_nowait()+put_nowait() keeps producer non-blocking in all cases +- subscribe() pre-loads new queue with ring buffer contents for immediate catch-up on SSE connect +- TDD: RED state (ImportError) confirmed before implementation; GREEN (5/5 passing) after +- Integration test stubs created and skipped pending Plan 07-02 endpoint wiring + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Write Wave-0 test scaffolds (RED)** - `f5ffe62` (test) +2. **Task 2: Implement EventBusEventLog (GREEN)** - `b756693` (feat) + +**Plan metadata:** (docs commit below) + +## Files Created/Modified +- `src/paperbot/infrastructure/event_log/event_bus_event_log.py` - EventBusEventLog: subscribe/unsubscribe/append/stream/close + _fan_out/_put_nowait_drop_oldest +- `tests/unit/test_event_bus_event_log.py` - 5 unit tests: fan-out, ring buffer catch-up, backpressure, unsubscribe cleanup, composite wiring +- `tests/integration/test_events_sse_endpoint.py` - 2 integration stubs (skipped): SSE delivery within 1s, heartbeat on idle + +## Decisions Made +- Used `collections.deque(maxlen=200)` for the ring buffer — oldest auto-evicted on overflow, configurable +- Used `asyncio.Queue(maxsize=256)` per subscriber with drop-oldest (not drop-newest, not block) backpressure +- Serialized `AgentEventEnvelope` exactly once in `append()` via `.to_dict()`, then distributed the dict — avoids repeated serialization in `_fan_out()` +- `stream()` returns `iter(())` — the bus is a live delivery channel, not a historical store +- `list(self._queues)` snapshot in `_fan_out()` to guard against concurrent `unsubscribe()` in same event-loop tick + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered +None. All 5 unit tests passed on first run after implementation. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- EventBusEventLog is complete and correct; ready for Plan 07-02 to wire it into the FastAPI SSE endpoint +- subscribe()/unsubscribe() API is stable — Plan 07-02 will call these from the async SSE route handler +- CompositeEventLog integration confirmed working (test_composite_includes_bus passes) +- Integration test stubs in tests/integration/test_events_sse_endpoint.py will be fleshed out in Plan 07-02 + +--- +*Phase: 07-eventbus-sse-foundation* +*Completed: 2026-03-14* + +## Self-Check: PASSED + +- FOUND: src/paperbot/infrastructure/event_log/event_bus_event_log.py +- FOUND: tests/unit/test_event_bus_event_log.py +- FOUND: tests/integration/test_events_sse_endpoint.py +- FOUND: .planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md +- FOUND: commit f5ffe62 (test RED scaffolds) +- FOUND: commit b756693 (feat GREEN implementation) +- All 5 unit tests passing diff --git a/.planning/phases/07-eventbus-sse-foundation/07-02-PLAN.md b/.planning/phases/07-eventbus-sse-foundation/07-02-PLAN.md new file mode 100644 index 00000000..8d73d86c --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-02-PLAN.md @@ -0,0 +1,262 @@ +--- +phase: 07-eventbus-sse-foundation +plan: 02 +type: execute +wave: 2 +depends_on: + - 07-01 +files_modified: + - src/paperbot/api/routes/events.py + - src/paperbot/api/main.py + - tests/integration/test_events_sse_endpoint.py +autonomous: true +requirements: + - EVNT-04 +must_haves: + truths: + - "GET /api/events/stream delivers events to SSE clients within 1 second of emission" + - "Multiple simultaneous SSE clients each receive all events independently" + - "SSE connection sends keepalive heartbeat comments when no events are queued" + - "Client disconnect cleans up subscriber queue with no leak (bus._queues shrinks)" + - "Existing event_log.append() callers require zero changes — bus is transparent" + artifacts: + - path: "src/paperbot/api/routes/events.py" + provides: "GET /api/events/stream SSE fan-out endpoint" + exports: ["router"] + - path: "src/paperbot/api/main.py" + provides: "EventBusEventLog wired as CompositeEventLog backend; events router registered" + contains: "EventBusEventLog" + - path: "tests/integration/test_events_sse_endpoint.py" + provides: "Integration tests: SSE delivery latency and heartbeat" + contains: "test_event_delivered_within_1s" + key_links: + - from: "src/paperbot/api/main.py _startup_eventlog()" + to: "src/paperbot/infrastructure/event_log/event_bus_event_log.py" + via: "EventBusEventLog() added to CompositeEventLog backends list" + pattern: "EventBusEventLog" + - from: "src/paperbot/api/routes/events.py _event_generator()" + to: "EventBusEventLog.subscribe() / unsubscribe()" + via: "try/finally in async generator" + pattern: "bus\\.unsubscribe\\(q\\)" + - from: "src/paperbot/api/main.py" + to: "src/paperbot/api/routes/events.py" + via: "app.include_router(events.router)" + pattern: "include_router.*events" +--- + + +Wire EventBusEventLog into the FastAPI application: add it as a CompositeEventLog +backend in main.py startup, create the GET /api/events/stream SSE endpoint, register +the router, and make the integration tests pass. + +Purpose: Completes EVNT-04. After this plan, a dashboard client can connect via SSE +and receive every event_log.append() call in real-time without any changes to callers. + +Output: +- src/paperbot/api/routes/events.py (new SSE endpoint) +- src/paperbot/api/main.py (modified: bus wiring + router registration) +- tests/integration/test_events_sse_endpoint.py (stubs replaced with real tests, GREEN) + + + +@./.claude/get-shit-done/workflows/execute-plan.md +@./.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/07-eventbus-sse-foundation/07-CONTEXT.md +@.planning/phases/07-eventbus-sse-foundation/07-RESEARCH.md +@.planning/phases/07-eventbus-sse-foundation/07-01-SUMMARY.md + + + + +From src/paperbot/infrastructure/event_log/event_bus_event_log.py (created in Plan 07-01): +```python +class EventBusEventLog(EventLogPort): + def append(self, event: Union[AgentEventEnvelope, dict]) -> None: ... + def subscribe(self) -> asyncio.Queue: ... # returns queue pre-loaded with ring buffer + def unsubscribe(self, q: asyncio.Queue) -> None: ... + def stream(self, run_id: str) -> Iterable[dict]: ... # returns iter(()) + def close(self) -> None: ... +``` + +From src/paperbot/api/streaming.py: +```python +SSE_HEADERS: dict # {"Cache-Control": "no-cache", "Connection": "keep-alive", ...} +def sse_comment(comment: str = "keepalive") -> str: ... # ": keepalive\n\n" +``` + +From src/paperbot/api/main.py (current startup hook to MODIFY): +```python +@app.on_event("startup") +async def _startup_eventlog(): + try: + app.state.event_log = CompositeEventLog([LoggingEventLog(), SqlAlchemyEventLog()]) + except Exception: + app.state.event_log = LoggingEventLog() + obsidian.initialize_obsidian_runtime(app) +``` + +From src/paperbot/infrastructure/event_log/composite_event_log.py: +```python +class CompositeEventLog(EventLogPort): + def __init__(self, backends: List[EventLogPort]): ... + # _backends is a list — EventBusEventLog plugs in as the third element +``` + +Existing router registration pattern (from main.py): +```python +app.include_router(events.router, prefix="/api", tags=["Events"]) +``` + + + + + + + Task 1: Create GET /api/events/stream SSE endpoint + src/paperbot/api/routes/events.py + +Create src/paperbot/api/routes/events.py following the exact pattern from 07-RESEARCH.md +Pattern 2 (SSE Generator — Queue Drain with Heartbeat). + +Router setup: +- APIRouter with prefix="/events" (main.py registers it under prefix="/api") +- Single route: GET "/stream" + +_get_bus() helper: +- Access request.app.state.event_log +- Iterate _backends, find the EventBusEventLog instance +- Raise RuntimeError("EventBusEventLog not registered in CompositeEventLog") if not found +- Import EventBusEventLog inside the function to avoid circular imports at module level + +_event_generator() async generator: +- Call bus.subscribe() to get client queue (already pre-loaded with ring buffer) +- try/finally block: finally MUST call bus.unsubscribe(q) — this is the leak prevention +- Inner loop: await asyncio.wait_for(q.get(), timeout=_HEARTBEAT_SECONDS) +- On TimeoutError: yield sse_comment() (keepalive heartbeat) +- On successful get: yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" +- Catch asyncio.CancelledError and pass (ASGI sends this on client disconnect) +- Check await request.is_disconnected() at the top of each loop iteration + +events_stream() endpoint: +- Returns StreamingResponse(_event_generator(request, bus), media_type="text/event-stream", + headers=dict(SSE_HEADERS)) +- Does NOT use sse_response() or wrap_generator() — they inject per-workflow envelopes + that conflict with AgentEventEnvelope's own fields (per research anti-patterns) + +Heartbeat interval: _HEARTBEAT_SECONDS = 15.0 + +Critical: Do NOT use wrap_generator() here. Events already carry AgentEventEnvelope fields +(run_id, trace_id, workflow, etc.). A second envelope layer confuses consumers. + + + PYTHONPATH=/home/master1/PaperBot/src python -c "from paperbot.api.routes.events import router; print('router OK, routes:', [r.path for r in router.routes])" + + events.py exists. Router imports cleanly. Route /stream is present. No wrap_generator() usage. _event_generator has try/finally with bus.unsubscribe(q). + + + + Task 2: Wire main.py + implement integration tests (GREEN) + src/paperbot/api/main.py, tests/integration/test_events_sse_endpoint.py + + - test_event_delivered_within_1s: connect SSE stream, emit event via app.state.event_log.append(), + read SSE line within 1 second budget, assert event data contains run_id + - test_heartbeat_on_idle: connect SSE stream, wait >heartbeat interval, assert ": keepalive" + comment received (confirms heartbeat fires) + + +**Part A — Modify src/paperbot/api/main.py:** + +1. Add import at top: `from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog` +2. Add import at top: `from .routes import events as events_route` +3. Modify _startup_eventlog() to add EventBusEventLog as third CompositeEventLog backend: + ```python + @app.on_event("startup") + async def _startup_eventlog(): + try: + bus = EventBusEventLog() + app.state.event_log = CompositeEventLog([ + LoggingEventLog(), + SqlAlchemyEventLog(), + bus, + ]) + except Exception: + app.state.event_log = LoggingEventLog() + obsidian.initialize_obsidian_runtime(app) + ``` +4. Register events router after existing router registrations: + `app.include_router(events_route.router, prefix="/api", tags=["Events"])` + +**Part B — Replace stub tests in tests/integration/test_events_sse_endpoint.py:** + +Replace the pytest.skip stubs with real integration tests. Use httpx AsyncClient +with the FastAPI app (not TestClient — we need async client for SSE streaming). + +Pattern for asyncio_mode=strict (from CLAUDE.md): every async test needs @pytest.mark.asyncio. + +test_event_delivered_within_1s: +- Use `async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client` + inside a lifespan context — OR use httpx.Client with the ASGI app and read the stream + synchronously after manually calling _startup_eventlog. +- Simpler approach: call _startup_eventlog() manually in test, then use asyncio.Queue directly + from the bus: bus = [b for b in app.state.event_log._backends if isinstance(b, EventBusEventLog)][0] + q = bus.subscribe(); bus.append({"run_id": "test123", "type": "test"}); + item = await asyncio.wait_for(q.get(), timeout=1.0); assert item["run_id"] == "test123" + This tests delivery latency without needing a live HTTP server. + +test_heartbeat_on_idle: +- Create bus directly, subscribe, do NOT append anything, verify that the event generator + yields a heartbeat comment after the timeout. Test the generator function directly: + use asyncio.wait_for on an async iteration of _event_generator with a mock request object. + OR simplify: just verify sse_comment() returns ": keepalive\n\n" and the generator's + TimeoutError path calls it (white-box check acceptable for heartbeat). + +After writing tests, run them: + PYTHONPATH=src pytest tests/integration/test_events_sse_endpoint.py -v + +Fix any failures before finishing. + + + PYTHONPATH=/home/master1/PaperBot/src pytest /home/master1/PaperBot/tests/integration/test_events_sse_endpoint.py /home/master1/PaperBot/tests/unit/test_event_bus_event_log.py -v 2>&1 | tail -20 + + Both integration tests pass. All 5 unit tests still pass. main.py imports EventBusEventLog and registers events router. GET /api/events/stream route exists in the FastAPI app. + + + + + +Full phase 7 test suite: + PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py tests/integration/test_events_sse_endpoint.py -q + +Expected: 7 tests pass (5 unit + 2 integration). 0 failures. + +Smoke check endpoint is registered: + PYTHONPATH=src python -c " +from paperbot.api.main import app +routes = [r.path for r in app.routes] +assert '/api/events/stream' in routes, f'Missing route, got: {routes}' +print('Route present:', '/api/events/stream') +" + +No regressions in existing SSE endpoints: + PYTHONPATH=src pytest tests/e2e/test_api_track_fullstack_offline.py -q + + + +1. GET /api/events/stream endpoint exists and is registered in FastAPI app +2. EventBusEventLog is wired as third backend in CompositeEventLog at startup +3. Both integration tests pass: event delivery within 1s, heartbeat on idle +4. All 5 unit tests from Plan 07-01 still pass +5. bus.unsubscribe(q) is in the finally block of _event_generator (no queue leak) +6. No wrap_generator() call in events.py +7. Existing callers of event_log.append() have zero changes (confirmed by grep) + + + +After completion, create .planning/phases/07-eventbus-sse-foundation/07-02-SUMMARY.md + diff --git a/.planning/phases/07-eventbus-sse-foundation/07-02-SUMMARY.md b/.planning/phases/07-eventbus-sse-foundation/07-02-SUMMARY.md new file mode 100644 index 00000000..9fe1751e --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-02-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 07-eventbus-sse-foundation +plan: "02" +subsystem: api +tags: [sse, event-bus, fan-out, fastapi, streaming, asyncio, integration-test] + +# Dependency graph +requires: + - phase: 07-01 + provides: "EventBusEventLog subscribe/unsubscribe/append API" + - phase: existing-api + provides: "FastAPI app, SSE_HEADERS, sse_comment, CompositeEventLog" +provides: + - "GET /api/events/stream SSE fan-out endpoint" + - "EventBusEventLog wired as third CompositeEventLog backend in main.py startup" + - "Integration tests: event delivery latency (<1s) and heartbeat on idle" +affects: + - 07-03-frontend-sse-client + +# Tech tracking +tech-stack: + added: [] + patterns: + - "late import of EventBusEventLog inside _get_bus() avoids circular import at module load" + - "_event_generator try/finally guarantees unsubscribe on client disconnect" + - "asyncio.wait_for with timeout=_HEARTBEAT_SECONDS drives keepalive without separate task" + - "request.is_disconnected() checked each loop iteration for fast disconnect detection" + - "StreamingResponse with direct async generator — no wrap_generator() wrapper layer" + +key-files: + created: + - src/paperbot/api/routes/events.py + modified: + - src/paperbot/api/main.py + - tests/integration/test_events_sse_endpoint.py + +key-decisions: + - "Late import of EventBusEventLog inside _get_bus() prevents circular import (events.py loaded during app creation before bus is wired)" + - "_event_generator uses asyncio.wait_for(q.get(), timeout=15.0) — single await point drives both delivery and heartbeat" + - "No wrap_generator(): events carry own AgentEventEnvelope fields; second envelope layer would confuse consumers" + - "test_heartbeat_on_idle patches module-level _HEARTBEAT_SECONDS to 0.05s for speed; restores in finally" + - "test_event_delivered_within_1s exercises bus directly (no HTTP server) — validates delivery latency guarantee" + +requirements-completed: [EVNT-04] + +# Metrics +duration: 4min +completed: 2026-03-14 +--- + +# Phase 7 Plan 02: SSE Endpoint + main.py Wiring Summary + +**GET /api/events/stream fan-out endpoint wired into FastAPI with EventBusEventLog as CompositeEventLog backend, delivering all event_log.append() calls to SSE clients within 1 second** + +## Performance + +- **Duration:** ~4 min +- **Started:** 2026-03-14T06:43:16Z +- **Completed:** 2026-03-14T06:46:48Z +- **Tasks:** 2 +- **Files modified:** 3 + +## Accomplishments + +- Created `src/paperbot/api/routes/events.py` with `GET /api/events/stream` SSE endpoint +- `_event_generator()` uses try/finally to guarantee `bus.unsubscribe(q)` on client disconnect — no queue leak +- Heartbeat: 15s timeout on `q.get()` → yields `: keepalive\n\n` comment on idle +- `_get_bus()` uses late import to avoid circular import; finds EventBusEventLog in `_backends` or directly +- Modified `main.py`: added `EventBusEventLog` as third `CompositeEventLog` backend in `_startup_eventlog()` +- Registered `events_route.router` at `prefix="/api"` — route appears as `/api/events/stream` +- Replaced pytest.skip stubs with real integration tests — both pass GREEN +- All 7 tests pass (5 unit from Plan 07-01 + 2 new integration) +- No existing `event_log.append()` callers required any changes + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Create GET /api/events/stream SSE endpoint** - `b8cb0d4` (feat) +2. **Task 2: Wire main.py + integration tests GREEN** - `5314131` (feat) + +## Files Created/Modified + +- `src/paperbot/api/routes/events.py` (new) — SSE fan-out endpoint: `_get_bus()`, `_event_generator()`, `events_stream()` +- `src/paperbot/api/main.py` (modified) — EventBusEventLog import + CompositeEventLog wiring + events router registration +- `tests/integration/test_events_sse_endpoint.py` (modified) — stubs replaced with two real passing integration tests + +## Decisions Made + +- Late import of `EventBusEventLog` inside `_get_bus()` prevents circular import: `events.py` is imported when `app` is created, before startup hooks run +- `asyncio.wait_for(q.get(), timeout=_HEARTBEAT_SECONDS)` at `_HEARTBEAT_SECONDS = 15.0` — single await point drives both event delivery and idle heartbeat +- No `wrap_generator()` or `sse_response()` — events already carry `AgentEventEnvelope` fields (`run_id`, `trace_id`, `seq`); wrapping adds duplicate envelope layer +- `test_heartbeat_on_idle` patches `_HEARTBEAT_SECONDS` to `0.05` for speed, not by subclassing, to keep test simple +- `test_event_delivered_within_1s` exercises `EventBusEventLog` subscribe/append/get directly (no HTTP), asserting < 1s delivery budget + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. Both integration tests passed on first run after implementation. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- `/api/events/stream` is live and ready for Plan 07-03 (frontend SSE client) +- EventBusEventLog wired as side-effect backend — all existing endpoints transparently fan out to SSE clients +- `subscribe()` / `unsubscribe()` API proven correct by integration tests +- EVNT-04 requirement is now fully complete: end-to-end from `event_log.append()` to SSE frame + +--- +*Phase: 07-eventbus-sse-foundation* +*Completed: 2026-03-14* + +## Self-Check: PASSED + +- FOUND: src/paperbot/api/routes/events.py +- FOUND: tests/integration/test_events_sse_endpoint.py +- FOUND: .planning/phases/07-eventbus-sse-foundation/07-02-SUMMARY.md +- FOUND: commit b8cb0d4 (feat events.py SSE endpoint) +- FOUND: commit 5314131 (feat main.py wiring + integration tests GREEN) +- All 7 tests passing (5 unit + 2 integration) diff --git a/.planning/phases/07-eventbus-sse-foundation/07-CONTEXT.md b/.planning/phases/07-eventbus-sse-foundation/07-CONTEXT.md new file mode 100644 index 00000000..77c4eb14 --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-CONTEXT.md @@ -0,0 +1,94 @@ +# Phase 7: EventBus + SSE Foundation - Context + +**Gathered:** 2026-03-14 +**Status:** Ready for planning + + +## Phase Boundary + +In-process event bus with SSE subscription endpoint for real-time push. Agent events are pushed to connected clients in real-time without polling. Existing `event_log.append()` calls automatically push to SSE subscribers with zero changes to calling code. + +Success criteria: +1. Dashboard client connected via SSE receives agent events within 1 second of emission +2. Multiple simultaneous SSE clients each receive all events independently +3. Existing event_log.append() calls automatically push to SSE subscribers with zero changes to calling code +4. SSE connections clean up gracefully on client disconnect (no leaked queues or background tasks) + + + + +## Implementation Decisions + +### Event filtering +- No server-side filtering — all events go to all connected clients +- Dashboard filters client-side (Zustand store in Phase 9) +- Rationale: single-user tool, low event volume (dozens/min), allows cross-workflow views without reconnecting + +### Reconnection behavior +- Small in-memory ring buffer (last ~200 events) kept by the event bus +- On connect, client receives buffer contents as a catch-up burst, then switches to live streaming +- No Last-Event-ID support — unnecessary complexity for a dashboard +- If client was away longer than the buffer, old events are simply missed (acceptable for real-time dashboard, not a message queue) + +### Backpressure handling +- Each SSE client gets an `asyncio.Queue` with a fixed max size (~256) +- When queue is full, drop the oldest event and enqueue the new one +- Never block the producer — `event_log.append()` must stay fast (called from agent hot paths) +- Never disconnect slow clients — just drop their oldest queued events + +### SSE endpoint design +- Single new endpoint: `GET /api/events/stream` +- No query params needed (all events to all clients) +- Existing per-feature SSE endpoints (agent_board, gen_code, track) stay untouched — they serve different purposes (streaming workflow results) +- This endpoint is specifically for the event bus fan-out to the dashboard + +### Claude's Discretion +- Ring buffer implementation details (collections.deque vs list slice) +- Exact queue size tuning (200 buffer, 256 per-client are guidelines) +- Internal event serialization format within the bus +- SSE event `id` field format (sequential int, UUID, etc.) +- Heartbeat interval for the new endpoint + + + + +## Specific Ideas + +- EventBus plugs in as a new backend in CompositeEventLog — when `append()` is called, the event fans out to all registered SSE subscriber queues automatically +- Zero changes to existing code that calls `event_log.append()` — the bus is transparent +- Zero new dependencies — uses asyncio.Queue, collections.deque, existing streaming.py infrastructure + + + + +## Existing Code Insights + +### Reusable Assets +- `EventLogPort` (application/ports/event_log_port.py): Protocol with `append()`, `stream()`, `close()` — new bus implements this +- `CompositeEventLog` (infrastructure/event_log/composite_event_log.py): Tees to multiple backends — bus plugs in as another backend +- `AgentEventEnvelope` (application/collaboration/message_schema.py): Rich event envelope with run_id, trace_id, span_id, workflow, stage, agent_name, type, payload — already JSON-serializable via `to_dict()`/`to_json()` +- `api/streaming.py`: `sse_response()`, `wrap_generator()`, `StreamEvent`, heartbeat, timeout, SSE_HEADERS — reuse for the new endpoint + +### Established Patterns +- SSE endpoints return `sse_response(async_generator)` — follow same pattern +- Event log backends are registered in DI container (`Container.instance()`) +- CompositeEventLog is assembled in DI container with list of backends + +### Integration Points +- DI container: Add event bus as backend in CompositeEventLog's backend list +- New route file: `api/routes/events.py` with `GET /api/events/stream` +- Router registration in `api/main.py` + + + + +## Deferred Ideas + +None — discussion stayed within phase scope + + + +--- + +*Phase: 07-eventbus-sse-foundation* +*Context gathered: 2026-03-14* diff --git a/.planning/phases/07-eventbus-sse-foundation/07-RESEARCH.md b/.planning/phases/07-eventbus-sse-foundation/07-RESEARCH.md new file mode 100644 index 00000000..74b29f4d --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-RESEARCH.md @@ -0,0 +1,601 @@ +# Phase 7: EventBus + SSE Foundation - Research + +**Researched:** 2026-03-14 +**Domain:** Python asyncio in-process event fan-out + FastAPI SSE endpoint +**Confidence:** HIGH + +--- + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +**Event filtering:** +- No server-side filtering — all events go to all connected clients +- Dashboard filters client-side (Zustand store in Phase 9) +- Rationale: single-user tool, low event volume (dozens/min), allows cross-workflow views without reconnecting + +**Reconnection behavior:** +- Small in-memory ring buffer (last ~200 events) kept by the event bus +- On connect, client receives buffer contents as a catch-up burst, then switches to live streaming +- No Last-Event-ID support — unnecessary complexity for a dashboard +- If client was away longer than the buffer, old events are simply missed (acceptable) + +**Backpressure handling:** +- Each SSE client gets an `asyncio.Queue` with a fixed max size (~256) +- When queue is full, drop the oldest event and enqueue the new one +- Never block the producer — `event_log.append()` must stay fast (called from agent hot paths) +- Never disconnect slow clients — just drop their oldest queued events + +**SSE endpoint design:** +- Single new endpoint: `GET /api/events/stream` +- No query params (all events to all clients) +- Existing per-feature SSE endpoints (agent_board, gen_code, track) stay untouched +- This endpoint is specifically for the event bus fan-out to the dashboard + +**Integration architecture:** +- EventBus plugs in as a new backend in CompositeEventLog +- Zero changes to existing code that calls `event_log.append()` +- Zero new dependencies — uses asyncio.Queue, collections.deque, existing streaming.py + +### Claude's Discretion + +- Ring buffer implementation details (collections.deque vs list slice) +- Exact queue size tuning (200 buffer, 256 per-client are guidelines) +- Internal event serialization format within the bus +- SSE event `id` field format (sequential int, UUID, etc.) +- Heartbeat interval for the new endpoint + +### Deferred Ideas (OUT OF SCOPE) + +None — discussion stayed within phase scope. + + +--- + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| EVNT-04 | Agent events are pushed to connected dashboard clients in real-time via SSE (no polling) | EventBus as CompositeEventLog backend; asyncio.Queue fan-out per client; `GET /api/events/stream` SSE endpoint | + + +--- + +## Summary + +Phase 7 builds an in-process event bus that intercepts every `event_log.append()` call and fan-outs the event to all connected SSE clients. The mechanism is a new `EventBusEventLog` class that implements `EventLogPort` and is inserted into `CompositeEventLog`'s backend list at startup. No caller of `append()` ever changes. The event bus holds a `collections.deque` ring buffer for catch-up on connect, and a set of per-client `asyncio.Queue` instances for live delivery. + +The single new API endpoint `GET /api/events/stream` is a long-lived SSE stream that yields the catch-up burst then blocks on the client's queue. FastAPI's `StreamingResponse` with the existing `SSE_HEADERS` and `sse_comment()` heartbeat pattern is reused verbatim from `api/streaming.py`. The endpoint uses `asyncio.Queue.get()` with a timeout loop to interleave heartbeats and events. + +The central correctness concern is **thread/coroutine safety**: `append()` is called synchronously from agent hot-paths (potentially from non-async code), while the SSE generator awaits queue items from the async event loop. The standard Python idiom is `loop.call_soon_threadsafe(queue.put_nowait, item)` if `append()` is called from a thread, or simply `queue.put_nowait()` if always called from the same async context. Because `append()` is defined as `def` (not `async def`) in the port, and because FastAPI runs uvicorn in a single-process async loop, all handlers calling `append()` do so from within the event loop — making `put_nowait` safe without thread bridging. + +**Primary recommendation:** Implement `EventBusEventLog` with a `collections.deque(maxlen=200)` ring buffer, a `set` of `asyncio.Queue(maxsize=256)` subscriber queues, drop-oldest backpressure on full queues, and register it as the third backend in `CompositeEventLog` at startup. The SSE generator yields buffer contents on connect, then loops on `asyncio.wait_for(queue.get(), timeout=heartbeat_interval)` with `sse_comment()` on timeout. + +--- + +## Standard Stack + +### Core (all already in pyproject.toml — zero new dependencies) + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `asyncio.Queue` | stdlib | Per-client event delivery queue | Async-native, bounded, put_nowait safe from same event loop | +| `collections.deque` | stdlib | Ring buffer for catch-up events | O(1) append/popleft, maxlen enforces ring size | +| `fastapi.StreamingResponse` | 0.115.0 (pinned) | SSE HTTP response | Already used by all 6+ existing SSE endpoints | +| `starlette.responses.StreamingResponse` | >=0.37.2 | Underlying SSE transport | Handles chunked transfer encoding, no buffering | +| `asyncio.wait_for` | stdlib | Heartbeat timeout during queue wait | Standard pattern for interleaving heartbeats | + +### Reused Project Utilities (from `api/streaming.py`) + +| Asset | What It Provides | +|-------|-----------------| +| `SSE_HEADERS` | `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no` | +| `sse_comment()` | Returns `: keepalive\n\n` — heartbeat frame | +| `StreamEvent.to_sse()` | Serializes event to `data: {...}\n\n` | +| `sse_done()` | Returns `data: [DONE]\n\n` | +| `wrap_generator()` | Adds envelope, heartbeat, timeout — usable for the new endpoint | + +**Note:** `wrap_generator()` adds an outer envelope (workflow/run_id/trace_id/seq) designed for workflow-scoped streams. The events endpoint is global fan-out; use a simpler inline generator to avoid injecting misleading envelope fields. Raw `StreamEvent.to_sse()` or a custom serializer that directly emits `AgentEventEnvelope.to_json()` is cleaner for this endpoint. + +### Installation + +No new packages needed. All primitives are stdlib or already in `pyproject.toml`. + +--- + +## Architecture Patterns + +### Recommended File Structure + +``` +src/paperbot/ +├── infrastructure/ +│ └── event_log/ +│ ├── event_bus_event_log.py # NEW: EventBusEventLog class +│ └── composite_event_log.py # unchanged +├── api/ +│ ├── routes/ +│ │ └── events.py # NEW: GET /api/events/stream +│ ├── main.py # MODIFIED: add bus to CompositeEventLog, register events router +│ └── streaming.py # unchanged (reused) +└── ... + +tests/ +├── unit/ +│ └── test_event_bus_event_log.py # NEW: unit tests for bus +└── integration/ + └── test_events_sse_endpoint.py # NEW: httpx/TestClient SSE test +``` + +### Pattern 1: EventBusEventLog — Composite Backend + +`EventBusEventLog` implements `EventLogPort` by: +1. Storing each appended event in a `deque(maxlen=200)` ring buffer +2. Fanning out to all registered subscriber queues via `put_nowait()` +3. Dropping the oldest item from full queues before enqueueing the new item + +```python +# src/paperbot/infrastructure/event_log/event_bus_event_log.py +from __future__ import annotations + +import asyncio +import logging +from collections import deque +from typing import Iterable, Set, Union + +from paperbot.application.collaboration.message_schema import AgentEventEnvelope +from paperbot.application.ports.event_log_port import EventLogPort + +logger = logging.getLogger(__name__) + +_RING_BUFFER_SIZE = 200 +_CLIENT_QUEUE_SIZE = 256 + + +class EventBusEventLog(EventLogPort): + """ + In-process fan-out bus. Plugs into CompositeEventLog as a backend. + + append() is always called from the async event loop (FastAPI/uvicorn + single-process model), so put_nowait() is safe without thread bridging. + """ + + def __init__( + self, + ring_buffer_size: int = _RING_BUFFER_SIZE, + client_queue_size: int = _CLIENT_QUEUE_SIZE, + ) -> None: + self._ring: deque[dict] = deque(maxlen=ring_buffer_size) + self._queues: Set[asyncio.Queue] = set() + self._client_queue_size = client_queue_size + + # --- EventLogPort interface --- + + def append(self, event: Union[AgentEventEnvelope, dict]) -> None: + serialized: dict + if isinstance(event, AgentEventEnvelope): + serialized = event.to_dict() + else: + serialized = dict(event) + self._ring.append(serialized) + self._fan_out(serialized) + + def stream(self, run_id: str) -> Iterable[dict]: + # Bus does not support historical replay by run_id; that is SQLAlchemy's job. + return iter(()) + + def close(self) -> None: + self._queues.clear() + + # --- Subscription management --- + + def subscribe(self) -> asyncio.Queue: + """Return a new per-client queue pre-loaded with ring buffer contents.""" + q: asyncio.Queue = asyncio.Queue(maxsize=self._client_queue_size) + # Drain ring buffer into the new queue (catch-up burst) + for event in list(self._ring): + try: + q.put_nowait(event) + except asyncio.QueueFull: + break # client queue is already saturated by the buffer itself + self._queues.add(q) + return q + + def unsubscribe(self, q: asyncio.Queue) -> None: + self._queues.discard(q) + + # --- Internal --- + + def _fan_out(self, event: dict) -> None: + dead: list = [] + for q in self._queues: + try: + q.put_nowait(event) + except asyncio.QueueFull: + # Drop oldest, enqueue newest — never block producer + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(event) + except asyncio.QueueFull: + logger.debug("EventBus: client queue still full after drop, skipping") + for q in dead: + self._queues.discard(q) +``` + +### Pattern 2: SSE Generator — Queue Drain with Heartbeat + +The SSE generator subscribes, yields catch-up events (already in the queue from `subscribe()`), then loops reading live events with a heartbeat timeout. + +```python +# src/paperbot/api/routes/events.py +from __future__ import annotations + +import asyncio +import json +import logging +from typing import AsyncGenerator + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from paperbot.api.streaming import SSE_HEADERS, sse_comment, sse_done + +router = APIRouter(prefix="/api/events") +log = logging.getLogger(__name__) + +_HEARTBEAT_SECONDS = 15.0 + + +@router.get("/stream") +async def events_stream(request: Request) -> StreamingResponse: + """Fan-out SSE endpoint. All agent events go to all connected clients.""" + bus = _get_bus(request) + return StreamingResponse( + _event_generator(request, bus), + media_type="text/event-stream", + headers=dict(SSE_HEADERS), + ) + + +async def _event_generator(request: Request, bus) -> AsyncGenerator[str, None]: + q = bus.subscribe() + try: + while True: + if await request.is_disconnected(): + break + try: + event: dict = await asyncio.wait_for(q.get(), timeout=_HEARTBEAT_SECONDS) + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except asyncio.TimeoutError: + yield sse_comment() # keepalive heartbeat + except asyncio.CancelledError: + pass + finally: + bus.unsubscribe(q) + log.debug("EventBus SSE client disconnected, queue cleaned up") + + +def _get_bus(request: Request): + # Bus is stored on app.state by main.py startup handler + event_log = request.app.state.event_log + # CompositeEventLog exposes backends + for backend in event_log._backends: + from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog + if isinstance(backend, EventBusEventLog): + return backend + raise RuntimeError("EventBusEventLog not registered in CompositeEventLog") +``` + +### Pattern 3: Startup Wiring in main.py + +```python +# api/main.py — modify _startup_eventlog() +from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog + +@app.on_event("startup") +async def _startup_eventlog(): + try: + bus = EventBusEventLog() + app.state.event_log = CompositeEventLog([ + LoggingEventLog(), + SqlAlchemyEventLog(), + bus, # NEW: fan-out backend + ]) + app.state.event_bus = bus # also stored directly for convenience + except Exception: + app.state.event_log = LoggingEventLog() +``` + +And register the new router in `main.py`: + +```python +from .routes import events as events_route +app.include_router(events_route.router, prefix="", tags=["Events"]) +``` + +### Anti-Patterns to Avoid + +- **Calling `queue.put()` (awaitable) inside `append()`:** `append()` is a sync method; using `await` inside it is impossible. Use `put_nowait()` only. +- **Storing the event loop reference at import time:** `asyncio.get_event_loop()` at module level can return a closed loop after hot reload. Retrieve via `asyncio.get_running_loop()` inside a running coroutine instead, or just use `put_nowait()` which needs no loop reference. +- **Using `wrap_generator()` for this endpoint:** It injects per-workflow envelope fields (run_id, trace_id, seq, workflow). The events endpoint serves global fan-out — events already carry their own envelope via `AgentEventEnvelope.to_dict()`. Adding a second envelope layer confuses consumers. +- **Iterating `self._queues` while modifying it:** Dead/unsubscribed queues should be collected into a `dead` list and discarded after iteration to avoid `RuntimeError: Set changed size during iteration`. +- **Leaking the subscriber queue on disconnect:** FastAPI's `StreamingResponse` generator is cancelled by ASGI middleware when the client drops. The `finally: bus.unsubscribe(q)` block in the generator is the guaranteed cleanup path. +- **Re-serializing inside `_fan_out`:** Convert once in `append()` (to `dict`) and store/fan-out that dict. Do not call `to_json()` inside `_fan_out` — unnecessary allocation per subscriber. + +--- + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Catch-up replay on connect | Custom DB query in the SSE handler | `deque(maxlen=N)` in EventBusEventLog | DB query adds latency; ring buffer is O(N) copy and already in memory | +| Heartbeat mechanism | Custom sleep loop | `asyncio.wait_for(queue.get(), timeout=N)` + `sse_comment()` | Already proven pattern in streaming.py; handles `TimeoutError` correctly | +| SSE framing | Custom `data:` string builder | `json.dumps(event) + "\n\n"` or `StreamEvent.to_sse()` | One-liner; no edge cases for this simple case | +| Disconnect detection | Polling `request.is_disconnected()` on a timer | ASGI CancelledError on generator + `finally` cleanup | FastAPI cancels the generator; `finally` block is guaranteed | +| Queue bounded drop | Manual size check + conditional | `queue.get_nowait()` then `put_nowait()` in QueueFull handler | Built-in QueueFull exception is the correct hook | + +**Key insight:** asyncio.Queue with maxsize is the correct bounded-buffer primitive. Anything hand-rolled around a list + asyncio.Event has subtle race conditions in an async context. Use the queue. + +--- + +## Common Pitfalls + +### Pitfall 1: Sync `append()` calling async queue methods + +**What goes wrong:** Developer writes `await q.put(event)` inside `append()`, causing a `RuntimeError: no running event loop` or `SyntaxError` because `append()` is a plain `def`. + +**Why it happens:** The `EventLogPort.append()` protocol is synchronous (returns `None`, not a coroutine). Changing it to `async def` would break all existing callers. + +**How to avoid:** Always use `put_nowait()` in `append()`. If the queue is full, handle `QueueFull` synchronously by dropping oldest. + +**Warning signs:** Any `await` inside `append()` or `_fan_out()`. + +### Pitfall 2: Generator not cleaned up on client disconnect + +**What goes wrong:** Client disconnects, but the generator coroutine is left suspended at `await q.get()`, and the queue stays registered in `self._queues` forever. Memory and set size grow with every connection. + +**Why it happens:** Missing `finally` block in the generator, or `unsubscribe()` only called on `StopAsyncIteration` (which never fires for a long-lived SSE stream). + +**How to avoid:** Always wrap the `await q.get()` loop in `try/finally: bus.unsubscribe(q)`. FastAPI sends `CancelledError` to the generator on disconnect; the `finally` runs. + +**Warning signs:** `len(bus._queues)` grows monotonically. Unit test: connect + disconnect + assert `len(bus._queues) == 0`. + +### Pitfall 3: Ring buffer not thread-safe with non-async callers + +**What goes wrong:** `deque.append()` is thread-safe in CPython (GIL), but if `append()` is ever called from a thread (e.g., ARQ worker thread), `put_nowait()` on an asyncio.Queue that belongs to a different event loop will raise `RuntimeError`. + +**Why it happens:** asyncio.Queue is bound to the event loop it was created in. If called from a thread, `put_nowait()` is unsafe. + +**How to avoid:** In the current architecture, `append()` is called exclusively from FastAPI request handlers that run in uvicorn's single async event loop. Document this assumption. If ARQ workers ever call `append()` directly in the future, use `loop.call_soon_threadsafe(q.put_nowait, event)`. + +**Warning signs:** `RuntimeError: Event loop is closed` or `got Future attached to a different loop`. + +### Pitfall 4: Bus referenced before startup completes + +**What goes wrong:** A test or early request calls `_get_bus(request)` before `_startup_eventlog()` has run, causing `AttributeError: 'State' object has no attribute 'event_log'`. + +**Why it happens:** FastAPI `app.state` is empty until startup hooks fire. Tests using `TestClient` may not trigger startup unless using the context manager form (`with TestClient(app) as client:`). + +**How to avoid:** Always use `with TestClient(app) as client:` in tests (triggers lifespan). In `_get_bus`, add a fallback or raise a clear error if `event_log` is missing. + +**Warning signs:** `AttributeError` in tests that don't use `with TestClient(app) as client:`. + +### Pitfall 5: `_queues` set mutation during `_fan_out` iteration + +**What goes wrong:** `_fan_out` iterates `self._queues` while a concurrent coroutine calls `unsubscribe()`, raising `RuntimeError: Set changed size during iteration`. + +**Why it happens:** Both operations occur in the async event loop, but `asyncio` tasks can interleave at `await` points. However, since `_fan_out` has no `await` calls it is effectively atomic — this is safe. The concern applies only if `unsubscribe()` is called from a different thread. + +**How to avoid:** Since everything runs in the same event loop thread, the iteration is safe. Document the assumption. Alternatively, iterate a snapshot: `for q in list(self._queues):`. + +**Warning signs:** `RuntimeError: Set changed size during iteration` in logs. + +--- + +## Code Examples + +### Full EventBusEventLog — correct backpressure + +```python +# Source: architecture pattern derived from Python asyncio documentation +# https://docs.python.org/3/library/asyncio-queue.html + +def _fan_out(self, event: dict) -> None: + for q in list(self._queues): # snapshot to avoid mutation-during-iteration + try: + q.put_nowait(event) + except asyncio.QueueFull: + # Drop-oldest strategy: discard head, insert new tail + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(event) + except asyncio.QueueFull: + logger.debug("EventBus: client queue still full after drop, skipping") +``` + +### SSE generator — correct disconnect cleanup + +```python +# Source: FastAPI StreamingResponse CancelledError pattern +# https://fastapi.tiangolo.com/advanced/custom-response/#streamingresponse + +async def _event_generator(request: Request, bus: EventBusEventLog): + q = bus.subscribe() + try: + while True: + if await request.is_disconnected(): + break + try: + event = await asyncio.wait_for(q.get(), timeout=_HEARTBEAT_SECONDS) + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except asyncio.TimeoutError: + yield sse_comment() + except asyncio.CancelledError: + pass # ASGI cancelled us on disconnect + finally: + bus.unsubscribe(q) # always runs — no queue leak +``` + +### Wiring CompositeEventLog with EventBus at startup + +```python +# Source: existing pattern in api/main.py _startup_eventlog() + +@app.on_event("startup") +async def _startup_eventlog(): + try: + bus = EventBusEventLog() + app.state.event_log = CompositeEventLog([ + LoggingEventLog(), + SqlAlchemyEventLog(), + bus, + ]) + app.state.event_bus = bus + except Exception: + app.state.event_log = LoggingEventLog() +``` + +### Test pattern — multiple clients receive independent events + +```python +# Pattern: use asyncio directly (not TestClient) to test async SSE generator +import asyncio +from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog +from paperbot.application.collaboration.message_schema import make_event, new_run_id, new_trace_id + +@pytest.mark.asyncio +async def test_multiple_clients_receive_independent_events(): + bus = EventBusEventLog() + q1 = bus.subscribe() + q2 = bus.subscribe() + + run_id = new_run_id() + evt = make_event( + run_id=run_id, trace_id=new_trace_id(), + workflow="test", stage="s1", attempt=0, + agent_name="A", role="worker", type="score_update", + ) + bus.append(evt) + + item1 = await asyncio.wait_for(q1.get(), timeout=1.0) + item2 = await asyncio.wait_for(q2.get(), timeout=1.0) + assert item1["run_id"] == run_id + assert item2["run_id"] == run_id + assert item1 is not item2 # independent queue items (same dict value, different refs OK) +``` + +--- + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Polling `/api/runs` for new events | SSE push via `GET /api/events/stream` | Phase 7 | Eliminates polling latency; sub-1-second delivery | +| LoggingEventLog + SqlAlchemyEventLog only | CompositeEventLog with added EventBusEventLog | Phase 7 | Zero changes to callers; bus is transparent | +| Per-workflow SSE endpoints (track, analyze, etc.) | Global event bus fan-out as additional channel | Phase 7 | Existing endpoints unchanged; bus is additive | + +**Deprecated/outdated:** +- Polling-based dashboard updates: replaced by the new SSE endpoint. + +--- + +## Open Questions + +1. **`append()` called from ARQ worker thread?** + - What we know: ARQ workers run in a separate process/thread; they currently use their own event log instance (not the in-process bus) + - What's unclear: If an ARQ worker ever calls the shared `app.state.event_log`, the bus would be invoked from a thread + - Recommendation: Verify ARQ workers use their own isolated event log (not app.state). If they ever share it in the future, use `loop.call_soon_threadsafe()` in `_fan_out`. For Phase 7, document the single-loop assumption. + +2. **`request.is_disconnected()` polling overhead?** + - What we know: FastAPI's `is_disconnected()` sends a receive call to the ASGI scope; it is O(1) but has some overhead + - What's unclear: Whether polling it on every heartbeat cycle (every 15 seconds) is sufficient or too slow + - Recommendation: The 15-second heartbeat loop means `is_disconnected()` is polled at most once per 15 seconds. This is fine. The `CancelledError` path handles immediate disconnects during `await q.get()`. + +--- + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | pytest + pytest-asyncio 0.21+ | +| Config file | `pyproject.toml` — `asyncio_mode = "strict"` | +| Quick run command | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q` | +| Full suite command | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py tests/integration/test_events_sse_endpoint.py -q` | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| EVNT-04 | append() fans out to all subscriber queues | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_fan_out_to_multiple_subscribers -x` | Wave 0 | +| EVNT-04 | Ring buffer replayed to new subscriber | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_ring_buffer_catch_up -x` | Wave 0 | +| EVNT-04 | Full queue drops oldest, never blocks | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_backpressure_drops_oldest -x` | Wave 0 | +| EVNT-04 | Unsubscribe removes queue (no leak) | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_unsubscribe_cleans_up -x` | Wave 0 | +| EVNT-04 | SSE endpoint delivers events within 1 second | integration | `PYTHONPATH=src pytest tests/integration/test_events_sse_endpoint.py::test_event_delivered_within_1s -x` | Wave 0 | +| EVNT-04 | SSE endpoint sends heartbeat comment | integration | `PYTHONPATH=src pytest tests/integration/test_events_sse_endpoint.py::test_heartbeat_on_idle -x` | Wave 0 | +| EVNT-04 | CompositeEventLog wires bus as backend | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_composite_includes_bus -x` | Wave 0 | + +**Note:** EVNT-04 success criterion 1 ("within 1 second") is tested via asyncio timing in integration tests using `asyncio.wait_for()` with a 1-second budget. Success criterion 4 ("clean up on disconnect") is tested via queue size assertion after generator `finally` block. + +### Sampling Rate + +- **Per task commit:** `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q` +- **Per wave merge:** `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py tests/integration/test_events_sse_endpoint.py -q` +- **Phase gate:** Full CI suite green before `/gsd:verify-work` + +### Wave 0 Gaps + +- [ ] `tests/unit/test_event_bus_event_log.py` — covers all unit cases above (fan-out, ring buffer, backpressure, unsubscribe, composite wiring) +- [ ] `tests/integration/test_events_sse_endpoint.py` — covers SSE delivery latency and heartbeat; uses `TestClient` with `with` form to trigger startup + +*(No framework install needed — pytest-asyncio already present in `[dev]` extras)* + +--- + +## Sources + +### Primary (HIGH confidence) + +- Codebase direct read: `src/paperbot/infrastructure/event_log/` — all 5 event log files read in full +- Codebase direct read: `src/paperbot/api/streaming.py` — SSE utilities, heartbeat pattern, headers +- Codebase direct read: `src/paperbot/application/ports/event_log_port.py` — Protocol definition +- Codebase direct read: `src/paperbot/application/collaboration/message_schema.py` — AgentEventEnvelope +- Codebase direct read: `src/paperbot/infrastructure/event_log/composite_event_log.py` — backend fan-out pattern +- Codebase direct read: `src/paperbot/api/main.py` — startup hook, CompositeEventLog wiring +- Codebase direct read: `src/paperbot/core/di/container.py` — DI pattern +- Codebase direct read: `pyproject.toml` — asyncio_mode=strict, dependency versions +- Codebase direct read: `tests/e2e/test_api_track_fullstack_offline.py` — existing SSE test pattern +- Python docs: `asyncio.Queue` — `put_nowait`, `QueueFull`, `QueueEmpty` behavior (stdlib, no staleness risk) +- Python docs: `collections.deque(maxlen=N)` — ring buffer behavior (stdlib) + +### Secondary (MEDIUM confidence) + +- FastAPI docs pattern: `StreamingResponse` + `CancelledError` for SSE disconnect — consistent with `streaming.py` implementation observed in codebase +- uvicorn single-process async model — confirms `put_nowait()` is safe without thread bridging for FastAPI handlers + +### Tertiary (LOW confidence) + +- None — all research based on direct codebase inspection + stdlib documentation + +--- + +## Metadata + +**Confidence breakdown:** + +- Standard stack: HIGH — all libraries are stdlib or pinned in pyproject.toml; read directly +- Architecture: HIGH — derived from direct inspection of 10+ existing source files in the repo; patterns match exactly +- Pitfalls: HIGH for asyncio pitfalls (well-known); MEDIUM for thread-safety edge case (ARQ interaction — theoretical, not observed) + +**Research date:** 2026-03-14 +**Valid until:** 2026-09-14 (stable stdlib primitives; FastAPI 0.115.0 is pinned; re-verify if FastAPI is upgraded) diff --git a/.planning/phases/07-eventbus-sse-foundation/07-VALIDATION.md b/.planning/phases/07-eventbus-sse-foundation/07-VALIDATION.md new file mode 100644 index 00000000..859ecb1e --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-VALIDATION.md @@ -0,0 +1,77 @@ +--- +phase: 7 +slug: eventbus-sse-foundation +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-03-14 +--- + +# Phase 7 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | pytest 7.x + pytest-asyncio 0.21+ | +| **Config file** | `pyproject.toml` — `asyncio_mode = "strict"` | +| **Quick run command** | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q` | +| **Full suite command** | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py tests/integration/test_events_sse_endpoint.py -q` | +| **Estimated runtime** | ~3 seconds | + +--- + +## Sampling Rate + +- **After every task commit:** Run `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py -q` +- **After every plan wave:** Run `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py tests/integration/test_events_sse_endpoint.py -q` +- **Before `/gsd:verify-work`:** Full suite must be green +- **Max feedback latency:** 3 seconds + +--- + +## Per-Task Verification Map + +| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|-----------|-------------------|-------------|--------| +| 07-01-01 | 01 | 1 | EVNT-04 | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_fan_out_to_multiple_subscribers -x` | ❌ W0 | ⬜ pending | +| 07-01-02 | 01 | 1 | EVNT-04 | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_ring_buffer_catch_up -x` | ❌ W0 | ⬜ pending | +| 07-01-03 | 01 | 1 | EVNT-04 | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_backpressure_drops_oldest -x` | ❌ W0 | ⬜ pending | +| 07-01-04 | 01 | 1 | EVNT-04 | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_unsubscribe_cleans_up -x` | ❌ W0 | ⬜ pending | +| 07-02-01 | 02 | 1 | EVNT-04 | integration | `PYTHONPATH=src pytest tests/integration/test_events_sse_endpoint.py::test_event_delivered_within_1s -x` | ❌ W0 | ⬜ pending | +| 07-02-02 | 02 | 1 | EVNT-04 | integration | `PYTHONPATH=src pytest tests/integration/test_events_sse_endpoint.py::test_heartbeat_on_idle -x` | ❌ W0 | ⬜ pending | +| 07-02-03 | 02 | 1 | EVNT-04 | unit | `PYTHONPATH=src pytest tests/unit/test_event_bus_event_log.py::test_composite_includes_bus -x` | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `tests/unit/test_event_bus_event_log.py` — stubs for fan-out, ring buffer, backpressure, unsubscribe, composite wiring +- [ ] `tests/integration/test_events_sse_endpoint.py` — stubs for SSE delivery latency and heartbeat + +*No framework install needed — pytest-asyncio already in `[dev]` extras* + +--- + +## Manual-Only Verifications + +*All phase behaviors have automated verification.* + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 3s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/07-eventbus-sse-foundation/07-VERIFICATION.md b/.planning/phases/07-eventbus-sse-foundation/07-VERIFICATION.md new file mode 100644 index 00000000..bcbf6a1e --- /dev/null +++ b/.planning/phases/07-eventbus-sse-foundation/07-VERIFICATION.md @@ -0,0 +1,136 @@ +--- +phase: 07-eventbus-sse-foundation +verified: 2026-03-14T07:15:00Z +status: passed +score: 9/9 must-haves verified +re_verification: false +--- + +# Phase 7: EventBus SSE Foundation — Verification Report + +**Phase Goal:** Agent events are pushed to connected clients in real-time without polling +**Verified:** 2026-03-14T07:15:00Z +**Status:** PASSED +**Re-verification:** No — initial verification + +--- + +## Goal Achievement + +### Observable Truths (Plan 07-01) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 1 | EventBusEventLog.append() fans out to all registered subscriber queues | VERIFIED | `test_fan_out_to_multiple_subscribers` passes; `_fan_out()` iterates `list(self._queues)` snapshot and calls `_put_nowait_drop_oldest()` for each | +| 2 | New subscriber receives ring buffer contents as catch-up burst on subscribe() | VERIFIED | `test_ring_buffer_catch_up` passes; `subscribe()` iterates `list(self._ring)` and pre-loads queue via `_put_nowait_drop_oldest()` | +| 3 | Full queue drops oldest event instead of blocking the producer | VERIFIED | `test_backpressure_drops_oldest` passes; `_put_nowait_drop_oldest()` calls `get_nowait()` then `put_nowait()` — no `await` in path | +| 4 | Unsubscribing a queue removes it from the fan-out set (no leak) | VERIFIED | `test_unsubscribe_cleans_up` passes; `unsubscribe()` calls `_queues.discard(q)`; subsequent `append()` delivers nothing to the queue | + +### Observable Truths (Plan 07-02) + +| # | Truth | Status | Evidence | +|---|-------|--------|----------| +| 5 | GET /api/events/stream delivers events to SSE clients within 1 second of emission | VERIFIED | `test_event_delivered_within_1s` passes; `asyncio.wait_for(q.get(), timeout=1.0)` succeeds with `run_id == "test-run-123"` | +| 6 | Multiple simultaneous SSE clients each receive all events independently | VERIFIED | `test_fan_out_to_multiple_subscribers` verifies fan-out to q1 and q2 independently; `subscribe()` returns distinct queues per caller | +| 7 | SSE connection sends keepalive heartbeat comments when no events are queued | VERIFIED | `test_heartbeat_on_idle` passes; `_event_generator` yields `": keepalive\n\n"` on `TimeoutError` from `wait_for(q.get(), timeout=0.05)` | +| 8 | Client disconnect cleans up subscriber queue with no leak (bus._queues shrinks) | VERIFIED | `try/finally` in `_event_generator` guarantees `bus.unsubscribe(q)` on every exit path; confirmed in `test_event_delivered_within_1s` assertion `q not in bus._queues` | +| 9 | Existing event_log.append() callers require zero changes — bus is transparent | VERIFIED | EventBusEventLog plugs into CompositeEventLog as third backend; no grep hits show any existing caller was modified; e2e test `test_api_track_fullstack_offline` still passes | + +**Score:** 9/9 truths verified + +--- + +## Required Artifacts + +| Artifact | Expected | Status | Details | +|----------|----------|--------|---------| +| `src/paperbot/infrastructure/event_log/event_bus_event_log.py` | EventBusEventLog — EventLogPort backend for in-process SSE fan-out | VERIFIED | 154 lines; exports `EventBusEventLog`; implements `append`, `stream`, `close`, `subscribe`, `unsubscribe`, `_fan_out`, `_put_nowait_drop_oldest` | +| `tests/unit/test_event_bus_event_log.py` | Unit tests covering fan-out, ring buffer, backpressure, unsubscribe | VERIFIED | 5 test functions present and passing: `test_fan_out_to_multiple_subscribers`, `test_ring_buffer_catch_up`, `test_backpressure_drops_oldest`, `test_unsubscribe_cleans_up`, `test_composite_includes_bus` | +| `tests/integration/test_events_sse_endpoint.py` | Integration tests: SSE delivery latency and heartbeat | VERIFIED | 2 real tests (not stubs): `test_event_delivered_within_1s`, `test_heartbeat_on_idle` — both pass | +| `src/paperbot/api/routes/events.py` | GET /api/events/stream SSE fan-out endpoint | VERIFIED | `router` exported with prefix `/events`; route `/stream` confirmed via `app.routes` inspection; no `wrap_generator()` usage | +| `src/paperbot/api/main.py` | EventBusEventLog wired as CompositeEventLog backend; events router registered | VERIFIED | Line 45: imports `EventBusEventLog`; line 40: imports `events as events_route`; line 101: `app.include_router(events_route.router, prefix="/api", tags=["Events"])`; lines 110-115: `bus = EventBusEventLog()` added as third backend in `_startup_eventlog()` | + +--- + +## Key Link Verification + +| From | To | Via | Status | Details | +|------|----|-----|--------|---------| +| `EventBusEventLog.append()` | `asyncio.Queue.put_nowait()` | `_fan_out()` → `_put_nowait_drop_oldest()` | WIRED | `_fan_out()` iterates queue snapshot, calls `_put_nowait_drop_oldest(q, data)`; `put_nowait` confirmed at line 150 | +| `EventBusEventLog.subscribe()` | `collections.deque` ring buffer | catch-up burst: `for event in list(self._ring)` | WIRED | Line 111: `for event in list(self._ring):` with `_put_nowait_drop_oldest(q, event)` | +| `src/paperbot/api/main.py _startup_eventlog()` | `src/paperbot/infrastructure/event_log/event_bus_event_log.py` | `EventBusEventLog()` added to CompositeEventLog backends list | WIRED | Line 45 import; line 110 instantiation; line 111-115: `CompositeEventLog([LoggingEventLog(), SqlAlchemyEventLog(), bus])` | +| `src/paperbot/api/routes/events.py _event_generator()` | `EventBusEventLog.subscribe()` / `unsubscribe()` | `try/finally` in async generator | WIRED | Line 67: `q = bus.subscribe()`; line 83-84: `finally: bus.unsubscribe(q)` | +| `src/paperbot/api/main.py` | `src/paperbot/api/routes/events.py` | `app.include_router(events.router)` | WIRED | Line 101: `app.include_router(events_route.router, prefix="/api", tags=["Events"])`; route `/api/events/stream` confirmed present in app routes | + +--- + +## Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +|-------------|-------------|-------------|--------|----------| +| EVNT-04 | 07-01, 07-02 | Agent events are pushed to connected dashboard clients in real-time via SSE (no polling) | SATISFIED | EventBusEventLog fans out `append()` calls to `asyncio.Queue` per SSE client; `/api/events/stream` endpoint wired in FastAPI; 7/7 tests pass; REQUIREMENTS.md traceability table marks EVNT-04 as Complete for Phase 7 | + +No orphaned requirements: only EVNT-04 is mapped to Phase 7 in REQUIREMENTS.md traceability table (line 172). Both plans claim EVNT-04. Coverage is complete. + +--- + +## Anti-Patterns Scan + +| File | Line | Pattern | Severity | Impact | +|------|------|---------|----------|--------| +| — | — | No TODOs, FIXMEs, placeholders, empty returns, or stub handlers found in any phase-07 file | — | None | + +Additional checks: +- No `async def append` or `async def _fan_out` — both are synchronous (correct, no await in hot path). +- No `wrap_generator()` in `events.py` (only appears in a docstring comment warning against it). +- No `asyncio.get_event_loop()` at module level. +- `list(self._queues)` snapshot used in `_fan_out()` — safe against concurrent unsubscribe. +- `.to_dict()` called once in `append()`, not inside `_fan_out()` — correct single serialization. + +--- + +## Human Verification Required + +### 1. End-to-end SSE delivery with real HTTP client + +**Test:** Connect a browser or `curl` to `GET http://localhost:8000/api/events/stream`, then trigger any agent action via the API (e.g., `POST /api/analyze`). Observe the SSE stream. +**Expected:** JSON event frames appear in the SSE stream within ~1 second of the agent action, formatted as `data: {...}\n\n` with `run_id`, `type`, and `payload` fields. +**Why human:** Integration tests exercise the bus directly without HTTP transport. The `StreamingResponse` + ASGI layer is not exercised in automated tests. + +### 2. Heartbeat visible in browser EventSource + +**Test:** Open browser DevTools → Network tab → connect to `/api/events/stream`. Leave idle for 15+ seconds. +**Expected:** `": keepalive"` comment frames appear every 15 seconds in the EventSource event stream with no data events. +**Why human:** Heartbeat interval is 15 seconds — impractical to wait in automated CI. + +### 3. Multi-client fan-out under live conditions + +**Test:** Open two browser tabs each with an EventSource connection to `/api/events/stream`. Trigger a paper analysis. Both tabs should receive the same event frames. +**Expected:** Identical event data appears in both streams simultaneously. +**Why human:** Concurrent multi-client behavior requires a live server and cannot be tested without a running HTTP server. + +--- + +## Regression Check + +- All existing event_log-related tests: 9 passed (including `test_eventlog_sqlalchemy.py`, `test_composite_event_log` variants). +- E2E test `test_api_track_fullstack_offline`: 1 passed — no regressions from main.py changes. +- No existing `event_log.append()` call sites were modified (bus is additive as a CompositeEventLog backend). + +--- + +## Gaps Summary + +None. All must-haves verified. Phase goal achieved. + +The EventBus SSE foundation is fully implemented: + +- `EventBusEventLog` (Plan 07-01) provides correct fan-out, ring buffer catch-up, drop-oldest backpressure, and clean unsubscribe. +- `GET /api/events/stream` (Plan 07-02) wires the bus into FastAPI with try/finally disconnect cleanup and 15-second heartbeat. +- Existing callers are transparent to the change — no call-site modifications needed. +- 7/7 automated tests pass. 3 human UAT items remain for live HTTP behavior. + +--- + +_Verified: 2026-03-14T07:15:00Z_ +_Verifier: Claude (gsd-verifier)_ diff --git a/.planning/phases/12-pg-infrastructure-schema/12-CONTEXT.md b/.planning/phases/12-pg-infrastructure-schema/12-CONTEXT.md new file mode 100644 index 00000000..37b1d6dc --- /dev/null +++ b/.planning/phases/12-pg-infrastructure-schema/12-CONTEXT.md @@ -0,0 +1,141 @@ +# Phase 12: PG Infrastructure & Schema - Context + +**Gathered:** 2026-03-14 +**Status:** Ready for planning + + +## Phase Boundary + +PaperBot runs against PostgreSQL with a complete, PG-compatible schema — tsvector, JSONB, and pgvector columns in place — without crashing on any SQLite-only code path. All SQLite code is deleted (not guarded). Docker Compose provides the local dev environment. Alembic env.py supports async execution. Full tsvector search implementation and JSONB store cleanup land in this phase. + +Note: This phase's scope is larger than originally planned. It absorbs JSONB model+store cleanup, full tsvector search implementation, and GIN indexes — work originally scoped to Phase 15. Phase 15 is reduced to vector search (HNSW index) + RRF hybrid search only. + + + + +## Implementation Decisions + +### SQLite removal +- One-way migration: SQLite is **dropped entirely** in Phase 12, not guarded +- Delete all SQLite-specific code: FTS5 virtual tables, sqlite-vec extension loading, sqlite_master queries, `ensure_sqlite_parent_dir`, `check_same_thread` connect args +- Delete all 22 old Alembic migration files from `alembic/versions/` +- No SQLite upgrade path — fresh PG only (no `alembic stamp` support for old migrations) +- Tests switch to PG in Phase 13 (testcontainers); Phase 12 uses GH Actions PG service container for CI + +### Docker Compose +- Image: `pgvector/pgvector:pg17` (official pgvector image, PostgreSQL 17) +- PG only — no pgAdmin, no Adminer +- Named volume for data persistence across `docker-compose down/up` +- Standard port 5432 exposed to host +- Hardcoded credentials: user=paperbot, password=paperbot, database=paperbot +- pgvector extension created via `docker-entrypoint-initdb.d` SQL script (not Alembic) + +### Alembic migrations +- Squash all 22 SQLite-era migrations into a single `0001_pg_baseline.py` +- Baseline creates the full PG-native schema from scratch (all tables, indexes, constraints) +- Single file — not split by domain +- All old migration files deleted from git + +### JSONB columns +- All 84 JSON columns born as JSONB in the baseline migration (not Text → JSONB conversion) +- ORM models use native JSONB mapped type (`Mapped[dict]` with `JSONB` column type), not `Mapped[str]` with `Text` +- Full store code cleanup in Phase 12: remove all `json.loads()`/`json.dumps()` calls on these columns +- GIN indexes on all 84 JSONB columns using default operator class (supports @>, ?, ?|, ?& operators) + +### pgvector embeddings +- pgvector `Vector(1536)` column on `memory_items` table only +- Drop the old `LargeBinary` embedding column (no side-by-side with old column) +- Fixed dimension 1536 (matching OpenAI ada-002); model change = Alembic migration + +### tsvector full-text search +- Per-row language detection: Python-side `langdetect` library detects language on write, stores detected language in a column +- PG trigger uses the stored language column to populate tsvector via `to_tsvector(lang, content)` +- All PG built-in dictionaries supported (english, german, french, spanish, etc.) +- Fallback: 'simple' dictionary (no stemming, no stop words) when language is unknown or unsupported +- tsvector columns + GIN indexes on `memory_items` and `document_chunks` +- Full implementation in Phase 12 including search queries (not just schema) + +### Alembic env.py +- Async execution path added proactively in Phase 12 (even though stores are still sync) +- Uses `psycopg` v3 (supports both sync and async modes natively) +- Single driver — no psycopg2/asyncpg split +- SQLite path completely removed from env.py + +### SessionProvider consolidation +- Single shared Engine registered in DI container (`Container.instance()`) +- All stores resolve the shared Engine via DI (same pattern as LLMClient) +- Per-store SessionProviders eliminated in Phase 12 + +### Default DB URL and onboarding +- `DEFAULT_DB_URL` changed to `postgresql+psycopg://paperbot:paperbot@localhost:5432/paperbot` +- Update `env.example` and README with: 1) `docker-compose up`, 2) `alembic upgrade head`, 3) run +- Update `alembic.ini` default URL to match +- No setup script — docs-only onboarding + +### CI continuity +- Phase 12 adds a minimal PostgreSQL service container in GitHub Actions CI +- Uses `postgres:17` (or pgvector equivalent) service block +- Tests connect via `PAPERBOT_DB_URL` env var pointing to CI PG instance +- Phase 13 adds testcontainers for local `pytest` — CI is never broken + +### Phase 15 scope reduction +- Phase 15 reduced to: HNSW vector index on pgvector column + RRF hybrid search (combining tsvector + pgvector) +- JSONB cleanup, tsvector implementation, and GIN indexes are absorbed into Phase 12 + +### Claude's Discretion +- PG trigger implementation details (BEFORE INSERT vs AFTER INSERT, trigger function naming) +- langdetect → PG dictionary mapping table +- Exact GIN index naming convention +- psycopg v3 connection pool configuration (pool_size, max_overflow) +- alembic.ini logging configuration for PG +- docker-compose.yml healthcheck implementation + + + + +## Specific Ideas + +- Clean break from SQLite — no dual-support, no guards, no legacy paths +- Phase 12 is intentionally front-loaded: schema, JSONB cleanup, tsvector full implementation, SessionProvider consolidation all land together so the codebase is PG-native from this point forward +- Hybrid tsvector population (Python langdetect + PG trigger) gives best of both worlds: accurate language detection with automatic tsvector consistency + + + + +## Existing Code Insights + +### Reusable Assets +- `sqlalchemy_db.py`: SessionProvider and engine creation — to be refactored to PG-only shared Engine +- `alembic/env.py`: Already has psycopg connect_args support — extend with async path +- `models.py`: 46 ORM models with 84 `Text` JSON columns — convert to JSONB mapped type +- `DI Container` (`core/di/container.py`): `register()`/`resolve()` pattern — use for shared Engine registration + +### Established Patterns +- DI container singleton for cross-cutting services (LLMClient, EventLog) — follow same pattern for shared Engine +- Stores import SessionProvider and create their own engines — pattern to be replaced with DI-resolved shared Engine +- `memory_store.py` has FTS5 and sqlite-vec code (~200 lines) — to be deleted and replaced with tsvector queries +- `document_index_store.py` has sqlite_master queries — to be deleted + +### Integration Points +- `core/di/bootstrap.py`: Register shared Engine here +- All 17+ store files: Refactor constructor to accept Engine from DI instead of creating SessionProvider +- `alembic/versions/`: Delete all existing files, write single PG baseline +- `.github/workflows/ci.yml`: Add PostgreSQL service container +- `env.example`: Update default DB URL +- `pyproject.toml` or `requirements.txt`: Add psycopg[binary], langdetect dependencies + + + + +## Deferred Ideas + +- Phase 15 still handles HNSW vector index and RRF hybrid search (tsvector + pgvector fusion) +- Phase 17 handles pgloader data migration from existing SQLite databases +- Roadmap update needed: Phase 15 scope description should be updated to reflect reduced scope + + + +--- + +*Phase: 12-pg-infrastructure-schema* +*Context gathered: 2026-03-14* diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md new file mode 100644 index 00000000..03de6e6f --- /dev/null +++ b/.planning/research/ARCHITECTURE.md @@ -0,0 +1,722 @@ +# Architecture Patterns: v2.0 PostgreSQL Migration & Async Data Layer + +**Domain:** Database migration + async refactoring for existing PaperBot app +**Researched:** 2026-03-14 +**Milestone:** v2.0 PostgreSQL Migration & Data Layer Refactoring + +--- + +## Current Architecture Snapshot + +### SessionProvider and the Engine-Per-Instance Problem + +Every store, service, and event log creates its own `SessionProvider(db_url)`, which in turn +calls `create_engine()`. With 17+ store classes plus services and the event log, the process +holds 20+ distinct connection pools at runtime. On SQLite this is tolerable (file-based). On +PostgreSQL, each `create_engine()` call opens a separate `asyncpg` connection pool, wasting +connections and preventing any cross-pool transaction semantics. + +``` +# Current pattern (17+ instances of this): +class PaperStore: + def __init__(self, db_url=None): + self._provider = SessionProvider(db_url) # creates engine + pool + +class SqlAlchemyEventLog: + def __init__(self, db_url=None): + self._provider = SessionProvider(db_url) # another engine + pool +``` + +### Session Context Manager Usage + +All stores use `with self._provider.session() as session:` — a synchronous context manager +returning a sync `Session`. The `sessionmaker` returns the session; stores call +`session.commit()` / `session.add()` directly. There is no async session anywhere today. + +### FTS5 Virtual Tables (SQLite-Only) + +Two stores create SQLite FTS5 virtual tables at startup via raw DDL: + +- `SqlAlchemyMemoryStore._ensure_fts5()` creates `memory_items_fts` + 3 triggers +- `DocumentIndexStore._ensure_fts5()` creates `document_chunks_fts` + triggers + +These are outside Alembic metadata. The `_search_fts5()` method explicitly checks +`if not db_url.startswith("sqlite"): return None` — it degrades silently on PostgreSQL. + +### sqlite-vec Embedding Storage + +`MemoryItemModel` stores embeddings as `LargeBinary` bytes packed as `struct.pack("...f", *vec)`. +On SQLite, `SqlAlchemyMemoryStore._ensure_vec_table()` creates a `vec_items` virtual table. +On PostgreSQL, there is no equivalent; vector search falls back to keyword-only (FTS5 path +returns None, vec path returns empty). This is the biggest functional gap in the migration. + +### Alembic: Already Dual-DB Aware + +`alembic/env.py` already detects PG URLs and applies `prepare_threshold: 0` for PgBouncer +compatibility. `ensure_tables()` on `SessionProvider` skips table creation for PostgreSQL — +it relies on Alembic exclusively. This is correct architecture already. + +### MCP Tool Pattern: anyio.to_thread.run_sync() + +All MCP tools that call sync stores wrap with `anyio.to_thread.run_sync(lambda: ...)`. This +is the current async/sync boundary. It is correct and safe for the interim period, but adds +thread overhead. Once stores become async, this bridge can be removed. + +### FastAPI Routes: Sync Store Calls in Async Handlers + +Route handlers are `async def` but call stores synchronously. Example from `runs.py`: +```python +async def list_runs(request: Request): + return {"runs": event_log.list_runs(limit=limit)} # sync call in async handler +``` +This blocks the event loop. On SQLite with typical loads it is hidden. On PostgreSQL under +concurrent load it will degrade. Converting stores to async eliminates this. + +### ARQ Worker: Sync Event Log in Async Jobs + +ARQ job functions are `async def` but use `SqlAlchemyEventLog.append()` which is synchronous. +The worker module holds a module-level `_EVENT_LOG` singleton. This is a concurrency hazard +if tasks run concurrently (ARQ parallelism > 1) because the same sync session factory is +used across tasks. With async stores, each ARQ task should get its own `AsyncSession`. + +### DI Container: Synchronous Factory Registry + +`Container.register(interface, factory, singleton=True)` stores callable factories. There is +no concept of async factories or async initialization. The container must gain support for +async-initialized singletons (specifically: the shared async engine). + +--- + +## Integration Architecture for v2.0 + +### Core Principle: Single Shared Async Engine + +Replace the N-engine-per-store pattern with one shared `AsyncEngine` created at process +startup and injected via the DI container. All stores receive an `async_sessionmaker` from +this shared engine. + +``` +# v2.0 target: one engine, many session factories sharing the pool +AsyncEngine (created once at startup) + | + +-- async_sessionmaker (one factory) + | + +-- PaperStore (receives factory) + +-- ResearchStore (receives factory) + +-- MemoryStore (receives factory) + +-- SqlAlchemyEventLog (receives factory) + +-- (all 17+ stores) +``` + +### New Component: AsyncSessionProvider + +`AsyncSessionProvider` replaces `SessionProvider`. It accepts an `async_sessionmaker` rather +than creating its own engine. The store's `__init__` no longer calls `create_engine()`. + +```python +# infrastructure/stores/async_db.py (NEW) +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine, AsyncEngine + +def create_async_db_engine(db_url: str | None = None) -> AsyncEngine: + url = _coerce_to_async_url(db_url or get_db_url()) + connect_args = {} + if "postgresql" in url: + connect_args = {"prepare_threshold": 0} # PgBouncer compat + return create_async_engine( + url, + pool_size=20, + max_overflow=10, + pool_pre_ping=True, + pool_recycle=3600, + connect_args=connect_args, + ) + +def create_async_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker(engine, autoflush=False, expire_on_commit=False) + +class AsyncSessionProvider: + """Thin wrapper: accepts an injected async_sessionmaker. Does NOT create an engine.""" + def __init__(self, factory: async_sessionmaker[AsyncSession]): + self._factory = factory + + def session(self) -> AsyncSession: + return self._factory() +``` + +Key difference from `SessionProvider`: `expire_on_commit=False` is mandatory. In async +contexts, accessing expired attributes after commit raises `MissingGreenlet`. Setting +`expire_on_commit=False` means attribute access post-commit is safe. + +### URL Coercion Helper + +asyncpg requires `postgresql+asyncpg://` scheme. The helper converts existing env var URLs: + +```python +def _coerce_to_async_url(url: str) -> str: + """Convert postgresql:// or postgres:// to postgresql+asyncpg://""" + if url.startswith("postgresql://") or url.startswith("postgres://"): + return url.replace("://", "+asyncpg://", 1) + if url.startswith("sqlite:"): + return url.replace("sqlite:", "sqlite+aiosqlite:", 1) + return url +``` + +The `PAPERBOT_DB_URL` env var does not need to change format. The coercion happens +transparently in `create_async_db_engine()`. + +### Modified Component: DI Container + +Add an `AsyncEngine` registration slot to `bootstrap_dependencies`. The engine is created +once and registered as a singleton. All stores resolve it. + +```python +# core/di/bootstrap.py additions +async def bootstrap_async_db(container: Container, db_url: str | None = None) -> None: + """Call once at app startup (inside async startup event).""" + from paperbot.infrastructure.stores.async_db import ( + create_async_db_engine, create_async_session_factory + ) + engine = create_async_db_engine(db_url) + factory = create_async_session_factory(engine) + container.register(AsyncEngine, lambda: engine, singleton=True) + container.register(async_sessionmaker, lambda: factory, singleton=True) +``` + +FastAPI startup hook wires this: +```python +@app.on_event("startup") +async def _startup_db(): + await bootstrap_async_db(Container.instance()) +``` + +### Modified Pattern: Store Constructor + +Stores change from creating their own `SessionProvider` to receiving an injected factory: + +```python +# Before +class PaperStore: + def __init__(self, db_url=None): + self.db_url = db_url or get_db_url() + self._provider = SessionProvider(self.db_url) + +# After +class PaperStore: + def __init__(self, factory: async_sessionmaker | None = None): + resolved = factory or Container.instance().resolve(async_sessionmaker) + self._provider = AsyncSessionProvider(resolved) +``` + +### Modified Pattern: Store Methods + +All store methods become `async def` using `async with` session context: + +```python +# Before +def get_paper(self, paper_id: int) -> PaperModel | None: + with self._provider.session() as session: + return session.get(PaperModel, paper_id) + +# After +async def get_paper(self, paper_id: int) -> PaperModel | None: + async with self._provider.session() as session: + result = await session.get(PaperModel, paper_id) + return result +``` + +For relationship access, use `selectinload` / `joinedload` eagerly. Lazy loading raises +`MissingGreenlet` in async context: + +```python +# Relationships must be eagerly loaded +from sqlalchemy.orm import selectinload + +async def get_paper_with_authors(self, paper_id: int): + async with self._provider.session() as session: + stmt = ( + select(PaperModel) + .where(PaperModel.id == paper_id) + .options(selectinload(PaperModel.author_links)) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() +``` + +### Modified Pattern: FastAPI Route Handlers + +After stores become async, route handlers call `await store.method()` directly. The +`anyio.to_thread.run_sync()` wrapper in MCP tools is also removed: + +```python +# Before (MCP tools) +result = await anyio.to_thread.run_sync(lambda: store.add_memories(...)) + +# After (MCP tools, stores async) +result = await store.add_memories(...) +``` + +Route handlers already use `async def`. After the store conversion they simply `await`: + +```python +# FastAPI route handler - no change to signature +@router.get("/runs") +async def list_runs(request: Request): + return {"runs": await event_log.list_runs(limit=limit)} # now truly async +``` + +### Modified Pattern: ARQ Worker + +ARQ job functions are already `async def`. The module-level `_EVENT_LOG` singleton is +replaced with a per-process `DatabaseConnectionManager` (started in ARQ's `startup` hook): + +```python +# infrastructure/queue/arq_worker.py changes +from contextvars import ContextVar + +_db_session_context: ContextVar[str | None] = ContextVar("arq_session_ctx", default=None) +_db_manager: DatabaseConnectionManager | None = None + +async def startup(ctx) -> None: + global _db_manager + _db_manager = DatabaseConnectionManager(get_db_url()) + await _db_manager.connect() + ctx["db_manager"] = _db_manager + +async def shutdown(ctx) -> None: + if _db_manager: + await _db_manager.disconnect() + +async def on_job_start(ctx, cid=None) -> None: + _db_session_context.set(ctx.get("job_id", "")) + +# Each task receives a fresh AsyncSession via scoped session +async def cron_track_subscriptions(ctx) -> dict: + async with _db_manager.get_session() as session: + elog = AsyncSqlAlchemyEventLog(session) + # ... rest of job +``` + +This ensures each ARQ task has its own `AsyncSession` (scoped by `job_id` ContextVar), +preventing session sharing across concurrent tasks. + +### Modified Component: Alembic env.py + +Alembic's `run_migrations_online()` must use an async-aware runner for asyncpg. The standard +pattern for async Alembic: + +```python +# alembic/env.py additions for async support +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine + +def run_migrations_online_async() -> None: + url = _get_db_url() + if not (url.startswith("postgresql") or url.startswith("postgres")): + # SQLite still uses sync path during dev/test + run_migrations_online_sync() + return + + async_url = _coerce_to_async_url(url) + connectable = create_async_engine(async_url, poolclass=pool.NullPool) + + async def _run(): + async with connectable.connect() as connection: + await connection.run_sync( + lambda sync_conn: context.configure( + connection=sync_conn, + target_metadata=target_metadata, + compare_type=True, + render_as_batch=False, # PG supports native ALTER + ) + ) + async with connection.begin(): + await connection.run_sync(context.run_migrations) + + asyncio.run(_run()) +``` + +SQLite batch migrations remain on the sync path. PostgreSQL uses native ALTER TABLE, so +`render_as_batch=False` is correct. + +--- + +## PostgreSQL-Native Feature Integration + +### FTS5 → tsvector + +The two FTS5 tables (`memory_items_fts`, `document_chunks_fts`) are replaced by PostgreSQL +tsvector columns and GIN indexes. This is a pure Alembic migration — no store code change +beyond swapping the SQL query. + +```sql +-- Migration: add tsvector column to memory_items +ALTER TABLE memory_items ADD COLUMN content_tsv tsvector; +UPDATE memory_items SET content_tsv = to_tsvector('english', coalesce(content, '')); +CREATE INDEX idx_memory_items_content_tsv ON memory_items USING GIN (content_tsv); + +-- Auto-update trigger +CREATE TRIGGER memory_items_tsv_update +BEFORE INSERT OR UPDATE ON memory_items +FOR EACH ROW EXECUTE FUNCTION + tsvector_update_trigger(content_tsv, 'pg_catalog.english', content); +``` + +The `_search_fts5()` method becomes `_search_tsvector()` with a dialect check: + +```python +def _search_tsvector(self, tokens: list[str], **scope): + """PostgreSQL tsvector FTS. Returns None on SQLite (use keyword fallback).""" + if self._is_sqlite: + return None + query = " & ".join(tokens) + stmt = ( + select(MemoryItemModel) + .where(MemoryItemModel.content_tsv.match(query)) + .order_by(func.ts_rank(MemoryItemModel.content_tsv, func.plainto_tsquery(query)).desc()) + .limit(limit) + ) +``` + +### sqlite-vec → pgvector + +Replace `LargeBinary` embedding storage with `pgvector`'s `VECTOR(1536)` column type: + +```python +# models.py: swap LargeBinary for pgvector +from pgvector.sqlalchemy import Vector + +class MemoryItemModel(Base): + # Before: embedding: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True) + embedding: Mapped[Optional[list[float]]] = mapped_column(Vector(1536), nullable=True) +``` + +Alembic migration: drop the `LargeBinary` column, add `VECTOR(1536)`, create HNSW index: + +```sql +ALTER TABLE memory_items DROP COLUMN embedding; +ALTER TABLE memory_items ADD COLUMN embedding vector(1536); +CREATE INDEX idx_memory_items_embedding ON memory_items USING hnsw (embedding vector_cosine_ops); +``` + +The `pgvector` Python package (`pip install pgvector`) provides the `Vector` type for SQLAlchemy. +This is MEDIUM confidence — pgvector is well-established but requires the PostgreSQL extension +to be enabled in the server (`CREATE EXTENSION IF NOT EXISTS vector`). Docker image and migration +must handle this. + +### JSON Text Columns → JSONB + +All `*_json` columns (e.g., `authors_json`, `keywords_json`, `payload_json`) currently store +Python-serialized strings with manual `json.loads()` / `json.dumps()` helpers. On PostgreSQL +these can become native `JSONB`: + +```python +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy import JSON + +# Use JSON (generic) in model; let dialect map to JSONB on PG, TEXT on SQLite +class PaperModel(Base): + keywords: Mapped[dict | list] = mapped_column(JSON, default=list) +``` + +Using `sqlalchemy.JSON` (not `postgresql.JSONB`) keeps models dialect-neutral. SQLAlchemy +maps `JSON` to `jsonb` on PostgreSQL and `TEXT` with serialization on SQLite. All the +manual `get_keywords()` / `set_keywords()` helpers become unnecessary once models use `JSON`. + +**Caution:** Migrating existing `*_json TEXT` columns to `JSONB` requires a data migration. +Alembic can use `ALTER COLUMN ... TYPE jsonb USING column::jsonb` but only if existing data +is valid JSON. Rows with empty strings or malformed JSON must be cleaned first. + +--- + +## Data Model Refactoring Plan + +### Normalization Targets + +| Current Pattern | Problem | PostgreSQL Target | +|----------------|---------|-------------------| +| `authors_json TEXT` (JSON string in every `papers` row) | No FK to `authors` table; denormalized | `paper_authors` join table + `authors` table (already exists, link properly) | +| `keywords_json TEXT` | No indexing, full string match only | `JSONB` column with `@>` containment queries | +| `sources_json TEXT` | Ad-hoc string; no enum validation | `JSONB` + `CHECK (sources_json @> '[]')` | +| `metadata_json TEXT` (on 20+ models) | Catch-all dump; poor queryability | Keep as `JSONB`; add specific columns for frequently queried fields | +| `status` strings (no constraint) | Any string accepted | `VARCHAR(32)` + `CHECK (status IN (...))` | +| Nullable `created_at` (many models) | Inconsistent audit trail | `NOT NULL DEFAULT now()` | + +### Models with PG-Native Upgrade Opportunity + +| Model | Current | v2.0 | +|-------|---------|-------| +| `MemoryItemModel` | `embedding: LargeBinary`, `content: Text`, FTS via virtual table | `embedding: VECTOR(1536)`, `content_tsv: TSVECTOR`, tsvector trigger | +| `PaperModel` | `keywords_json: Text`, `authors_json: Text` | `keywords: JSONB`, `authors: JSONB` | +| `AgentEventModel` | `payload_json: Text`, `metrics_json: Text`, `tags_json: Text` | `payload: JSONB`, `metrics: JSONB`, `tags: JSONB` | +| `AgentRunModel` | `metadata_json: Text` | `metadata: JSONB` | +| `ResearchTrackModel` | `keywords_json: Text`, `venues_json: Text`, `methods_json: Text` | `keywords: JSONB`, `venues: JSONB`, `methods: JSONB` | + +### Constraint Hardening + +Add to PostgreSQL-specific Alembic migrations: +- `CHECK (status IN ('pending', 'running', 'completed', 'failed'))` on status columns +- `CHECK (confidence BETWEEN 0.0 AND 1.0)` on `MemoryItemModel.confidence` +- `NOT NULL DEFAULT NOW()` on all `created_at` columns that are currently nullable +- `CHECK (pii_risk IN (0, 1, 2))` on `MemoryItemModel.pii_risk` + +--- + +## What Is Preserved vs. Must Change + +### Preserved (Zero Changes) + +| Component | Why Preserved | +|-----------|--------------| +| `Base(DeclarativeBase)` | No change; add JSONB/VECTOR types incrementally | +| All model `__tablename__` values | Schema names do not change | +| `alembic/versions/` directory | Existing migrations remain valid history | +| `Container` class interface | `register()` / `resolve()` API unchanged | +| `AgentEventEnvelope` schema | Event envelope structure unchanged | +| All port interfaces (`EventLogPort`, `RegistryPort`, etc.) | Contracts preserved; implementations change internally | +| MCP server registration pattern | `register(mcp)` pattern unchanged | +| ARQ `WorkerSettings.functions` | Function names unchanged; internals refactored | +| FastAPI route signatures | `async def` already; `await` additions only | + +### Must Change + +| Component | Change Required | Risk | +|-----------|-----------------|------| +| `sqlalchemy_db.py` — `SessionProvider` | Add `AsyncSessionProvider`; keep `SessionProvider` for backward compat during transition | Low | +| All 17+ store `__init__` | Accept injected `async_sessionmaker` instead of creating engine | Medium (mechanical but many files) | +| All store methods | Convert `def` to `async def`, `with` to `async with` | High (pervasive change) | +| `sqlalchemy_event_log.py` | Convert `append()`, `stream()`, `list_runs()`, `list_events()` to async | Medium | +| `bootstrap.py` | Add `bootstrap_async_db()` async factory | Low | +| `arq_worker.py` | Add `startup/shutdown/on_job_start` hooks; per-task session management | Medium | +| `alembic/env.py` | Add async migration path for PostgreSQL | Low | +| All MCP tools using `anyio.to_thread` | Remove wrapper after stores go async | Low (cleanup) | +| `memory_store.py` `_ensure_fts5()` / `_ensure_vec_table()` | Replace with tsvector + pgvector; keep SQLite fallback in `_search_*` methods | High | +| `document_index_store.py` `_ensure_fts5()` | Replace with tsvector | Medium | +| JSON helper methods (`get_keywords`, `set_keywords`, etc.) | Remove after JSON column type switch; direct attribute access | Medium | +| `models.py` JSON columns (`*_json: Text`) | Rename + change type to `JSON`/`JSONB` per model | High (requires data migration) | +| `models.py` embedding column | Change `LargeBinary` to `Vector(1536)` | High (data migration + pgvector extension) | + +--- + +## Backward Compatibility Strategy + +### Phase Approach: Sync-First, Then Async + +Do NOT attempt a big-bang sync-to-async conversion. The risk of breaking 40+ test files and +all CI gates is too high. Use a two-phase approach: + +**Phase A — PostgreSQL + Schema (sync stays):** +- Set up PostgreSQL + Docker dev environment +- Add `asyncpg` + `aiosqlite` to dependencies +- Create new Alembic migrations for PG-native columns (JSONB, tsvector, pgvector) +- Run all existing tests against PostgreSQL — sync stores still work on PG +- Fix any PostgreSQL-incompatible DDL (FTS5 virtual tables, sqlite-vec) +- Deliver: PG works with existing sync stores + +**Phase B — Async Data Layer:** +- Add `AsyncSessionProvider` to `sqlalchemy_db.py` alongside `SessionProvider` +- Convert stores one domain at a time (memory, papers, research, event log, etc.) +- For each converted store: update tests to use `pytest-anyio` / `asyncio` fixtures +- Update MCP tools to drop `anyio.to_thread.run_sync()` wrapper +- Update FastAPI routes to `await` store calls +- Update ARQ worker with lifecycle hooks +- Deliver: full async data layer + +**Phase C — Model Refactoring:** +- Convert `*_json TEXT` columns to `JSON`/`JSONB` with data migration scripts +- Add constraint checks +- Remove JSON helper methods from models; use direct attribute access +- Clean up dead code + +### Keeping SQLite Dev Support + +During Phase A and B, SQLite continues to work for local `pytest`. The `AsyncSessionProvider` +with `aiosqlite` makes this possible. Only Phase C features (tsvector, pgvector, JSONB +operators) are PostgreSQL-only. Tests that exercise FTS or vector search can be marked +`@pytest.mark.skipif(is_sqlite, reason="PG-only")`. + +--- + +## Component Boundaries + +| Component | Responsibility | Communicates With | +|-----------|---------------|-------------------| +| `async_db.py` (NEW) | Create and own the single shared `AsyncEngine`; provide `AsyncSessionProvider` | DI container (receives engine), all stores (receive factory) | +| `AsyncSessionProvider` (NEW) | Thin wrapper: yields `AsyncSession` from injected factory | Store methods (`async with`) | +| `SessionProvider` (KEEP) | Sync wrapper for test fixtures and migration scripts | Alembic env, unit tests | +| `bootstrap_async_db()` (NEW) | One-time startup: create engine, register in DI | FastAPI `startup` event, ARQ `startup` hook | +| Each store (MODIFIED) | Same domain logic, now with `async def` methods | `AsyncSessionProvider`, SQLAlchemy ORM | +| `SqlAlchemyEventLog` (MODIFIED) | Async `append()` + `list_runs()` | ARQ worker, FastAPI startup, CompositeEventLog | +| `alembic/env.py` (MODIFIED) | Dual path: async PG migrations, sync SQLite migrations | Alembic CLI | +| MCP tools (MODIFIED) | Remove `anyio.to_thread`; directly `await` store methods | Async stores | + +--- + +## Data Flow Changes + +### Before (sync everywhere) + +``` +FastAPI async handler + | + v (blocking call — blocks event loop) +Store.sync_method() + | + v +SessionProvider.session() — sync context manager + | + v +SQLAlchemy sync Session + | + v +psycopg2 / sqlite3 driver (blocking I/O) +``` + +### After (async throughout) + +``` +FastAPI async handler + | + v (non-blocking await) +await Store.async_method() + | + v +AsyncSessionProvider.session() — async context manager + | + v +SQLAlchemy AsyncSession + | + v +asyncpg / aiosqlite driver (non-blocking I/O) +``` + +### MCP Tools Before/After + +``` +# Before +async def _save_to_memory_impl(...): + store = _get_store() + result = await anyio.to_thread.run_sync( + lambda: store.add_memories(user_id, [candidate]) + ) + +# After +async def _save_to_memory_impl(...): + store = _get_store() + result = await store.add_memories(user_id, [candidate]) +``` + +--- + +## Scalability Considerations + +| Concern | Phase A (PG, sync stores) | Phase B (PG, async stores) | Phase C (full refactor) | +|---------|--------------------------|---------------------------|------------------------| +| Concurrent API requests | Event loop blocks on sync DB calls | Non-blocking; connection pool shared | Same as Phase B | +| Connection pool exhaustion | 20+ independent pools | Single pool, configurable size | Same as Phase B | +| FTS search | Sync tsvector queries (still blocks) | Async tsvector queries | Same as Phase B | +| Vector search | Sync pgvector queries | Async pgvector queries | Same as Phase B | +| ARQ job concurrency | Per-task sync sessions (risk of contention) | Per-task async sessions (safe) | Same as Phase B | + +--- + +## Anti-Patterns to Avoid + +### Anti-Pattern 1: Converting All Stores in One PR +**What goes wrong:** 17+ stores, all tests fail simultaneously, CI blocked for days. +**Prevention:** Convert one domain group at a time. Each group has its own PR + test pass. +**Domain groups:** (1) event log, (2) memory store, (3) paper store + research store, (4) remaining 13 stores. + +### Anti-Pattern 2: Lazy-Loading Relationships in Async Context +**What goes wrong:** `session.get(Model, id)` succeeds; `model.relationship_attr` raises +`MissingGreenlet` after session closes. +**Prevention:** Add `selectinload()` / `joinedload()` to every query that accesses relationships. +Set `expire_on_commit=False` on the session factory (already noted above). + +### Anti-Pattern 3: Running Alembic Autogenerate on Mixed Schema +**What goes wrong:** Alembic sees FTS5 virtual tables in SQLite metadata as "extra tables" and +generates `DROP TABLE memory_items_fts` migrations that break SQLite. +**Prevention:** FTS5 tables are created outside `Base.metadata`; Alembic autogenerate does not +see them. Do not change this. PostgreSQL tsvector columns go in regular models and ARE seen by +autogenerate — which is correct. + +### Anti-Pattern 4: Using `create_all()` on PostgreSQL +**What goes wrong:** `metadata.create_all(engine)` on PostgreSQL bypasses Alembic; migration +history becomes inconsistent. +**Prevention:** `ensure_tables()` on `SessionProvider` already skips PostgreSQL (`startswith("sqlite")`). +Keep this guard. Never call `create_all()` on a PostgreSQL URL. + +### Anti-Pattern 5: Sharing AsyncSession Across Concurrent ARQ Tasks +**What goes wrong:** `AsyncSession` is not thread-safe or task-safe. Multiple concurrent ARQ +tasks using the same session cause data corruption or connection errors. +**Prevention:** Use `async_scoped_session` with a `ContextVar` scoped to the ARQ job ID, as +documented by the ARQ + SQLAlchemy pattern. One session per task, always. + +### Anti-Pattern 6: Migrating JSON Columns Without Data Cleanup +**What goes wrong:** `ALTER COLUMN keywords_json TYPE jsonb USING keywords_json::jsonb` fails +if any row contains `""` (empty string) or malformed JSON. +**Prevention:** Run a cleanup query before the type migration: +`UPDATE papers SET keywords_json = '[]' WHERE keywords_json = '' OR keywords_json IS NULL`. +Do this in the Alembic `upgrade()` before the `ALTER COLUMN`. + +--- + +## Build Order (Dependency-Driven) + +1. **Docker + PostgreSQL dev environment** — Nothing works without a PG target. + - Blocks: all subsequent phases + +2. **Alembic dual-path env.py + async deps** — `asyncpg`, `aiosqlite`, `pgvector` in + `pyproject.toml`; Alembic async runner for PG. + - Depends on: Docker PG + - Blocks: all migrations + +3. **Schema migrations (PostgreSQL-compatible models)** — Convert FTS5 → tsvector, sqlite-vec + → pgvector column, JSON text → JSONB columns. Write new Alembic migrations (0020+). + - Depends on: Alembic async env + - Blocks: PG-native feature usage + +4. **AsyncSessionProvider + bootstrap_async_db** — New `async_db.py`, DI registration. + - Depends on: nothing (new file) + - Blocks: async store conversion + +5. **Data migration scripts** — pgloader or custom Python to move SQLite → PostgreSQL data. + - Depends on: schema migrations + - Blocks: production cutover + +6. **Store-by-store async conversion** — Four domain groups, one at a time. Start with + `SqlAlchemyEventLog` (smallest, most impactful for ARQ) then memory, then papers/research, + then remaining stores. + - Depends on: AsyncSessionProvider + - Blocks: MCP tool cleanup, route cleanup + +7. **ARQ worker async lifecycle** — `startup/shutdown/on_job_start` hooks; per-task sessions. + - Depends on: async event log (step 6, group 1) + - Blocks: safe concurrent ARQ execution + +8. **MCP tool cleanup** — Remove `anyio.to_thread.run_sync()` wrappers. + - Depends on: all stores async (step 6 complete) + - Blocks: nothing (cleanup) + +9. **Model refactoring** — Remove JSON helper methods; add constraints; normalize authors. + - Depends on: JSONB migrations (step 3) + - Blocks: nothing (cleanup + hardening) + +--- + +## Sources + +- Codebase inspection: `src/paperbot/infrastructure/stores/sqlalchemy_db.py` (SessionProvider) +- Codebase inspection: `src/paperbot/infrastructure/stores/models.py` (46 models, LargeBinary embedding, JSON text columns) +- Codebase inspection: `src/paperbot/infrastructure/stores/memory_store.py` (FTS5 + sqlite-vec patterns) +- Codebase inspection: `src/paperbot/infrastructure/stores/document_index_store.py` (FTS5 pattern) +- Codebase inspection: `src/paperbot/infrastructure/event_log/sqlalchemy_event_log.py` (sync event log) +- Codebase inspection: `src/paperbot/infrastructure/queue/arq_worker.py` (module-level singleton, async jobs) +- Codebase inspection: `src/paperbot/mcp/tools/save_to_memory.py` (anyio.to_thread pattern) +- Codebase inspection: `alembic/env.py` (dual-DB detection already present) +- Codebase inspection: `pyproject.toml` (`psycopg[binary]>=3.2.0` already a dependency) +- [SQLAlchemy 2.0 Asyncio Documentation](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) — HIGH confidence +- [ARQ + SQLAlchemy Done Right](https://wazaari.dev/blog/arq-sqlalchemy-done-right) — MEDIUM confidence (async_scoped_session + ContextVar pattern) +- [FastAPI SQLAlchemy 2.0 Modern Async Patterns](https://dev-faizan.medium.com/fastapi-sqlalchemy-2-0-modern-async-database-patterns-7879d39b6843) — MEDIUM confidence +- [Alembic Batch Migrations for SQLite](https://alembic.sqlalchemy.org/en/latest/batch.html) — HIGH confidence +- [pgvector GitHub](https://github.com/pgvector/pgvector) — HIGH confidence +- Project context: `.planning/PROJECT.md` (v2.0 milestone definition) diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md new file mode 100644 index 00000000..87dd88d5 --- /dev/null +++ b/.planning/research/FEATURES.md @@ -0,0 +1,218 @@ +# Feature Landscape + +**Domain:** PostgreSQL migration + async data layer + systematic model refactoring (brownfield) +**Researched:** 2026-03-14 +**Confidence:** HIGH — patterns are well-established; specific complexity estimates are from codebase analysis + +--- + +## Scope Note + +This file covers **v2.0: PostgreSQL Migration & Data Layer Refactoring** only. The existing file +covered v1.1 Agent Orchestration Dashboard. This milestone inherits a specific brownfield baseline: + +- 46 SQLAlchemy 2.0 `Mapped`/`mapped_column` models in a single `models.py` (1 500+ lines) +- Sync `SessionProvider` + `session()` pattern across 17 stores +- FTS5 virtual tables + sqlite-vec virtual table in `memory_store.py` and `document_index_store.py` +- 92 JSON-serialized `Text` columns (hand-rolled `_json` suffix + `json.dumps/loads` helpers) +- 32 Alembic migrations (SQLite chain) +- `psycopg[binary]>=3.2.0` already in `pyproject.toml` — sync driver present, async driver absent +- `create_async_engine` / `asyncpg` / `psycopg[async]` — none present anywhere in `src/` + +--- + +## Table Stakes + +Features that must exist for the milestone to be considered complete. Missing any of these means +the migration is not production-ready. + +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| **PostgreSQL engine + async session factory** | AsyncSession + asyncpg replaces sync SessionProvider. Without this, no other feature in the milestone is possible. | MEDIUM | Replace `create_engine` / `sessionmaker` in `sqlalchemy_db.py` with `create_async_engine` / `async_sessionmaker`. New `AsyncSessionProvider` class. Keep sync path for Alembic env.py (sync is required for `run_migrations_online`). | +| **Alembic env.py async-aware config** | Alembic must be able to apply migrations against PostgreSQL. Common trap: using sync Alembic env against async engine breaks in production. | MEDIUM | Standard pattern: add `run_async_migrations()` function in `env.py` using `AsyncEngine.begin()`. Sync fallback remains for SQLite CI tests. Alembic 1.13+ supports this natively. | +| **Store-by-store async conversion (17 stores)** | Every store that calls `self._provider.session()` currently blocks the event loop when used in FastAPI async routes. 17 stores × ~10 methods each = ~170 method conversions. | HIGH | Each `def method` becomes `async def method` with `await session.execute()`, `await session.commit()`, `await session.refresh()`. Most critical path: `paper_store`, `memory_store`, `research_store`, `document_index_store`. ARQ worker stores need ARQ-specific session lifecycle (on_job_start/after_job_end hooks), not FastAPI DI. | +| **Eager loading for all relationships** | Async SQLAlchemy silently breaks lazy loading — attribute access on an unloaded relationship raises `MissingGreenlet` in async context. All ORM relationships currently use default `lazy="select"` (sync). | HIGH | Audit every `relationship()` declaration in models.py. Most relationships are append-only audit trails (one-to-many) → use `lazy="write_only"`. For read paths: add `selectinload()` to queries that access related collections. `joinedload` for simple many-to-one FKs. | +| **Text → JSONB column migration** | 92 `Text` columns storing hand-serialized JSON. PostgreSQL can store and query these natively as JSONB, which is both faster and queryable. Without this, the migration is superficial. | MEDIUM | Replace `Text` + `json.dumps/loads` helpers with `sqlalchemy.dialects.postgresql.JSONB`. Cross-DB compatibility: use `TypeDecorator` with `with_variant(JSONB(), "postgresql")` for columns that must still work in SQLite test env. Alembic migration: `op.alter_column(..., type_=JSONB, postgresql_using="col::jsonb")`. 92 columns across 46 models — batch by model group. | +| **FTS5 → PostgreSQL tsvector (memory + documents)** | `memory_store._search_fts5()` and `document_index_store._search_fts5()` create SQLite-only virtual tables. These break on PostgreSQL silently (they fall back to no FTS). | HIGH | Two replacement strategies: (A) **Generated tsvector column** — `ALTER TABLE memory_items ADD COLUMN fts tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED` + GIN index. Query with `@@` operator via `func.to_tsvector(...).bool_op("@@")(func.plainto_tsquery(...))`. (B) **Application-side tsquery** — call `to_tsvector`/`plainto_tsquery` in SQLAlchemy Core expressions. Strategy A is preferred: index is maintained by PG automatically, no trigger management. Remove existing FTS5 virtual table creation + 3 insert/update/delete triggers per table. | +| **sqlite-vec → pgvector (memory embeddings)** | `memory_store._ensure_vec()` creates `vec_items USING vec0(...)` virtual table. This is SQLite-only. `MemoryItemModel.embedding` stores raw `LargeBinary` blobs. | MEDIUM | Enable `pgvector` extension via Alembic: `op.execute("CREATE EXTENSION IF NOT EXISTS vector;")`. Replace `LargeBinary` with `pgvector.sqlalchemy.Vector(N_DIM)`. Replace `vec_items` virtual table with native column on `memory_items`. Replace `vec0 MATCH` query with pgvector cosine distance operator `<=>`. Register `vector` type in Alembic `ischema_names` to silence `alembic check` warnings. | +| **Docker Compose for local PostgreSQL dev** | Developers need a PG instance without manual setup. This is the baseline dev environment assumption for all migration work. | LOW | `docker-compose.yml` with `postgres:16-alpine`, named volume, health check. `.env` update: `PAPERBOT_DB_URL=postgresql+asyncpg://paperbot:paperbot@localhost:5432/paperbot`. | +| **Data migration tooling (SQLite → PG)** | Existing users have SQLite databases that contain real data. Without a migration path, the version upgrade is a breaking change with data loss. | HIGH | Two-phase approach: (1) `alembic upgrade head` against fresh PG to create schema; (2) data export script using pgloader or custom Python script to transfer rows. Key risks: FTS5 virtual tables cannot be exported by pgloader (skip them; they rebuild from source data). JSONB cast: pgloader handles `TEXT → JSONB` automatically if JSON is valid. Vector blobs: custom Python script to re-read `LargeBinary` bytes, decode as `float32` array, insert as pgvector. | +| **Systematic model refactoring** | 46 models accumulated organically. Normalization, constraint correctness, and redundancy removal are required before PG adoption or the schema debt compounds. | HIGH | Four categories of work: (a) Add missing `NOT NULL` constraints (many nullable columns are never actually null); (b) Extract repeated JSON payload patterns into proper FK relationships where query frequency justifies it; (c) Add missing `UniqueConstraint` declarations that are currently enforced only in application code; (d) Normalize `String(64)` IDs to `String(36)` UUID columns where appropriate. Do not over-normalize: embedded `_json` arrays that are write-once and never filtered should stay as JSONB. | +| **CI parity: PostgreSQL in test matrix** | Tests currently run on SQLite in-process (`:memory:` or `tmp_path`). Some behaviors diverge (JSONB operator support, tsvector syntax, pgvector operators). Without a CI PostgreSQL target, regressions will reach production. | MEDIUM | Add `pytest` fixture for PostgreSQL test database (use `pytest-asyncio` + `asyncpg` test URL). Keep existing SQLite fixtures for fast unit tests. Add a `@pytest.mark.postgres` marker for integration tests that require PG features. GitHub Actions matrix: add a `postgres:16` service container. | + +--- + +## Differentiators + +Features that go beyond a minimum viable migration and meaningfully improve the system. + +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| **Hybrid search: pgvector + tsvector** | Current `_hybrid_merge()` in `memory_store.py` combines FTS5 (BM25) + sqlite-vec cosine similarity. PostgreSQL enables a proper hybrid search: `ts_rank` for text relevance + `<=>` cosine distance, merged by RRF (Reciprocal Rank Fusion). This is the production-quality RAG pattern. | MEDIUM | `ts_rank(fts, plainto_tsquery(...))` + `embedding <=> :query_vec` in a single CTE with RRF merge. Replaces the Python-side `_hybrid_merge()` function with a server-side SQL query. Fewer round-trips, better ranking. | +| **GIN indexes on JSONB payload columns** | Once `_json` columns become JSONB, frequently-filtered payloads (e.g., `AgentEventModel.tags_json`, `MemoryItemModel.evidence_json`) can have GIN indexes for sub-document queries. Currently unindexable. | LOW | Per-column decision: only add GIN index if the column is actually queried with `@>`, `?`, or `?|` operators. Start with `agent_events.tags_json` and `memory_items.evidence_json` based on current query patterns. | +| **Connection pooling configuration** | `asyncpg` + `create_async_engine` support `pool_size`, `max_overflow`, `pool_timeout`. Current sync SQLite has no meaningful pooling. Proper pooling is critical for FastAPI concurrency. PgBouncer-compatible: `prepare_threshold=0` already in `sqlalchemy_db.py` (a forward-looking comment). | LOW | Configure: `pool_size=10`, `max_overflow=20`, `pool_timeout=30`, `pool_recycle=1800`. Document PgBouncer connection string format. Parameterize via env vars. | +| **ARRAY columns for list-of-strings payloads** | Some JSONB columns store flat string arrays (e.g., `keywords_json`, `venues_json`, `methods_json`, `topics_json`). PostgreSQL `ARRAY(Text)` is queryable with `ANY()`, supports GIN indexing with `gin__int_ops`, and avoids JSONB parsing overhead for flat lists. | LOW | Evaluate case-by-case. Arrays that need `ANY(:keyword) = ANY(column)` queries benefit. Arrays that are read-only aggregations (author lists, venue history) can stay JSONB. Do not convert everything. | +| **Alembic branch for PG-only features** | Current Alembic chain has 32 SQLite-era migrations. PG migration can be a new branch head rather than a continuation, allowing clean separation between SQLite legacy and PG-native schema. | LOW | `alembic revision --autogenerate -m "pg_initial_schema" --head base` creates a fresh branch. Stamp PG databases at this revision. SQLite tests continue on the old chain. Use `alembic merge heads` only if cross-DB support is truly needed long-term. | +| **Async ARQ worker with asyncpg sessions** | Current ARQ worker (`WorkerSettings`) uses sync stores which block its async event loop. Proper async ARQ + asyncpg integration uses `on_job_start`/`after_job_end` hooks with `AsyncSession` context vars. | MEDIUM | Pattern: ARQ `ctx["db"]` key holds `AsyncSession` created at job start, closed at job end. Each job function receives `ctx` and reads `ctx["db"]`. Avoids connection leaks across job boundaries. This is documented in the ARQ + SQLAlchemy community pattern (wazaari.dev). | + +--- + +## Anti-Features + +Features that seem valuable for this migration but should be explicitly avoided. + +| Anti-Feature | Why Avoid | What to Do Instead | +|--------------|-----------|-------------------| +| **Full ORM re-architecture during migration** | Tempting to redesign relationships, add polymorphic inheritance, or switch to SQLModel while migrating. Scope explosion: each design change requires migration, store rewrite, test update, and integration validation. A data migration is already high-risk without adding schema redesign. | Migrate schema and async layer first. Model refactoring is a separate, bounded task within the milestone. Decouple: async conversion → JSONB/tsvector/pgvector → normalization. Never all three simultaneously on the same model. | +| **`run_sync()` as the async migration strategy** | `run_sync()` lets sync store methods work inside `AsyncSession` without full conversion. Appealing as a shortcut. In practice it serializes all DB work through a greenlet, provides no real concurrency benefit, obscures errors, and is explicitly documented as "partial upgrade" not a destination. | Convert stores properly to `async def`. For the small number of sync-only callers (Alembic env.py, tests), use a separate sync engine instance. | +| **SQLite + PostgreSQL dual-target parity** | Maintaining identical behavior on both databases requires `with_variant()` on every JSONB column, conditional FTS code paths, no pgvector columns, and no PG-specific operators. This is the current state and is the problem being solved. | Accept SQLite for fast unit tests only (no FTS, no vectors, no JSONB operators). PostgreSQL for integration tests and production. The test matrix has both, but SQLite tests cannot be expected to cover PG-native features. | +| **Zero-downtime dual-write migration** | Running writes to both SQLite and PostgreSQL simultaneously during transition sounds safe but requires application-level dual-write logic, consistency checks, and a cutover procedure. For PaperBot's current scale (single-server, non-SLA), this complexity is not warranted. | Simple cutover: export SQLite data → apply Alembic on PG → migrate data via pgloader/script → update `PAPERBOT_DB_URL` → restart. Maintenance window acceptable. | +| **pgloader for FTS5 virtual tables** | pgloader handles most SQLite → PG data migration automatically, but it cannot export FTS5 virtual tables (`memory_items_fts`, `document_chunks_fts`) or sqlite-vec virtual tables (`vec_items`). Attempting to pgload these tables will fail or produce garbage. | Skip virtual tables in pgloader. Regenerate FTS data: tsvector generated columns auto-populate on first `UPDATE` or can be bulk-populated via `UPDATE memory_items SET updated_at = updated_at`. For embeddings: re-run the embedding pipeline on existing content after migration. | +| **Big-bang model normalization** | Normalizing all 46 models in a single Alembic revision is the highest-risk operation in the milestone. One constraint violation in production data stops the entire migration. | Normalize incrementally: one model group per Alembic revision. Test each revision against a copy of production data before applying. Use `ALTER TABLE ... ADD CONSTRAINT IF NOT EXISTS` to be idempotent. | + +--- + +## Feature Dependencies + +``` +Docker Compose (PostgreSQL local dev) + | + +-> Alembic env.py async config + | | + | +-> PostgreSQL engine + AsyncSessionProvider + | | + | +-> Store-by-store async conversion (17 stores) + | | | + | | +-> Async ARQ worker integration + | | | + | | +-> CI PostgreSQL test matrix + | | + | +-> Eager loading audit (all relationships) + | | + | +-> Text -> JSONB column migration + | | | + | | +-> GIN indexes on queryable JSONB columns + | | | + | | +-> ARRAY columns for flat string lists (optional) + | | + | +-> FTS5 -> tsvector (memory + documents) + | | | + | | +-> Hybrid pgvector + tsvector search + | | + | +-> sqlite-vec -> pgvector (memory embeddings) + | | | + | | +-> Hybrid pgvector + tsvector search + | | + | +-> Systematic model normalization + | + +-> Data migration tooling (SQLite -> PG) +``` + +### Dependency Notes + +- **AsyncSessionProvider requires Docker Compose:** PG must be running locally before any async engine code can be tested. +- **Store conversion requires eager loading audit:** Converting a store to async without fixing its lazy-loaded relationships will produce `MissingGreenlet` errors at runtime, not at conversion time. These must be done together per-store, not sequentially across the full codebase. +- **JSONB migration requires Alembic PG target:** The `postgresql_using` cast expression in `op.alter_column` is PostgreSQL-only. Migration scripts must be run against PG, not SQLite. +- **pgvector requires FTS5 → tsvector:** The hybrid search feature uses both. Neither can be delivered alone if hybrid search is the goal. +- **Data migration tooling is independent:** pgloader/script migration of existing SQLite data can run after schema is in place. It is not on the critical path for new installations. +- **Model normalization is last:** Schema constraints should be added after data is migrated. Adding `NOT NULL` constraints to a column with nulls in production data will fail. Normalization runs against real data, so data migration must precede it. + +--- + +## MVP Definition + +### Ship First (Milestone Core) + +The minimum needed to make PaperBot run on PostgreSQL with async stores. + +- [ ] Docker Compose PG setup — required for any local development +- [ ] Alembic env.py async config — required to create PG schema +- [ ] `AsyncSessionProvider` + `create_async_engine` — replaces sync engine +- [ ] `paper_store` async conversion — highest-traffic store, most API routes depend on it +- [ ] `memory_store` async conversion + FTS5 → tsvector + sqlite-vec → pgvector — memory system is a first-class feature +- [ ] `document_index_store` async conversion + FTS5 → tsvector — document search depends on it +- [ ] `research_store` async conversion — research tracks are core to paper workflows +- [ ] Remaining 13 stores converted — stores that only write/read without FTS or vector search; low risk +- [ ] Text → JSONB for all 92 columns — prerequisite for any JSONB indexing or querying +- [ ] Eager loading audit — required per-store as part of async conversion + +### Add After MVP Validated + +Once the app runs cleanly on PG in development: + +- [ ] Hybrid pgvector + tsvector search — improves retrieval quality, but BM25-only is functional +- [ ] GIN indexes on JSONB columns — performance optimization, not correctness +- [ ] Async ARQ worker integration — ARQ currently works with sync stores wrapped in thread pool; proper async integration is an improvement +- [ ] Data migration tooling — needed only when upgrading existing SQLite installations +- [ ] CI PostgreSQL service container — add after PG codebase is stable + +### Defer to Post-v2.0 + +- [ ] Systematic model normalization — correctness improvement, not functionality blocker +- [ ] ARRAY columns for flat string lists — micro-optimization, schema change risk +- [ ] Alembic branch strategy — architectural decision with no runtime impact +- [ ] Connection pool tuning — production concern, not development milestone + +--- + +## Feature Prioritization Matrix + +| Feature | User Value | Implementation Cost | Priority | +|---------|------------|---------------------|----------| +| Docker Compose PG | HIGH (unblocks all dev) | LOW | P1 | +| Alembic async env.py | HIGH (unblocks schema) | LOW | P1 | +| AsyncSessionProvider | HIGH (core architecture) | LOW | P1 | +| paper_store async | HIGH (most-used store) | MEDIUM | P1 | +| memory_store async + FTS5/vec | HIGH (search is core) | HIGH | P1 | +| research_store async | HIGH (tracks are core) | MEDIUM | P1 | +| document_index_store async + FTS5 | MEDIUM | MEDIUM | P1 | +| Remaining 13 stores async | HIGH (completeness) | HIGH (volume) | P1 | +| Text → JSONB | HIGH (semantic correctness) | MEDIUM | P1 | +| Eager loading audit | HIGH (correctness) | HIGH | P1 | +| Hybrid pgvector + tsvector search | MEDIUM (quality boost) | MEDIUM | P2 | +| Async ARQ worker | MEDIUM (worker efficiency) | MEDIUM | P2 | +| GIN indexes on JSONB | MEDIUM (query performance) | LOW | P2 | +| CI PostgreSQL matrix | HIGH (regression safety) | LOW | P2 | +| Data migration tooling | HIGH (for existing users) | MEDIUM | P2 | +| Model normalization | MEDIUM (schema hygiene) | HIGH | P3 | +| ARRAY columns | LOW (micro-optimization) | LOW | P3 | +| Connection pool tuning | MEDIUM (production ops) | LOW | P3 | + +**Priority key:** P1 = milestone is incomplete without it; P2 = adds significant value, ship after P1 stable; P3 = polish/optimization. + +--- + +## Complexity Drivers + +These are the aspects that make this migration harder than average: + +| Driver | Impact | Mitigation | +|--------|--------|------------| +| 17 stores × ~10 async method conversions | ~170 method rewrites | Prioritize by traffic; use store-by-store Alembic revisions to isolate risk | +| Lazy loading is pervasive — default `lazy="select"` on all relationships | Runtime errors discovered only at test time, not at conversion time | Add `lazy="raise"` temporarily to all relationships after conversion; run full test suite to surface N+1 violations | +| FTS5 + sqlite-vec virtual tables do not export | Data migration cannot use pgloader for these tables | Skip in pgloader; regenerate from source data after PG import | +| 92 JSON Text columns need Alembic cast migrations | Each column needs `postgresql_using` cast; invalid JSON will cause migration failure | Pre-validate all JSON columns before migration: `SELECT id FROM table WHERE col IS NOT NULL AND col != '{}' AND (col::jsonb IS NULL)` — this will fail on bad JSON, surfacing rows to fix first | +| ARQ worker and FastAPI share the same store classes | ARQ does not have FastAPI DI; session lifecycle is different | Use ARQ lifecycle hooks (`on_job_start`/`after_job_end`) to manage `AsyncSession` in worker context; do not use FastAPI `Depends` patterns in worker code | +| `_ensure_fts5` and `_ensure_vec` are called on `__init__` of stores | Bootstrap code that runs at startup must detect DB type and skip SQLite-only setup | Add DB dialect check: `if session.bind.dialect.name == "postgresql"` before creating PG-specific structures | + +--- + +## Sources + +- [SQLAlchemy 2.0 Async I/O Documentation](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) — AsyncSession, async_sessionmaker, selectinload, run_sync +- [SQLAlchemy: The Async-ening](https://matt.sh/sqlalchemy-the-async-ening) — practical lazy loading pitfalls in async conversion +- [FastAPI + SQLAlchemy 2.0 Modern Async Patterns](https://dev-faizan.medium.com/fastapi-sqlalchemy-2-0-modern-async-database-patterns-7879d39b6843) — session lifecycle, expire_on_commit +- [ARQ + SQLAlchemy Done Right](https://wazaari.dev/blog/arq-sqlalchemy-done-right) — ARQ lifecycle hooks for async session management +- [Alembic Batch Migrations (SQLite + PG)](https://alembic.sqlalchemy.org/en/latest/batch.html) — cross-database migration portability +- [pgvector Python Library](https://github.com/pgvector/pgvector-python) — SQLAlchemy Vector type, Alembic integration, ischema_names fix +- [SQLAlchemy PostgreSQL Dialect — JSONB](https://docs.sqlalchemy.org/en/20/dialects/postgresql.html) — JSONB type, GIN index, with_variant, MutableDict +- [Alembic JSONB Column Migration Discussion](https://github.com/sqlalchemy/alembic/discussions/984) — Text → JSONB alter_column with postgresql_using cast +- [PostgreSQL tsvector FTS with SQLAlchemy](https://amitosh.medium.com/full-text-search-fts-with-postgresql-and-sqlalchemy-edc436330a0c) — generated tsvector column, GIN index, ts_rank +- [pgloader SQLite Reference](https://pgloader.readthedocs.io/en/latest/ref/sqlite.html) — data migration tool, type conversion, FK constraint handling +- [How to Migrate from SQLite to PostgreSQL](https://render.com/articles/how-to-migrate-from-sqlite-to-postgresql) — boolean, datetime, JSON type differences +- [Mixing Async/Sync in FastAPI](https://github.com/fastapi/fastapi/discussions/12995) — run_in_threadpool vs full async conversion +- [Advanced SQLAlchemy 2.0 selectinload Strategies 2025](https://www.johal.in/advanced-sqlalchemy-2-0-selectinload-and-withparent-strategies-2025/) — selectinload pitfalls (composite PKs, recursive relations, fan-outs) + +--- +*Feature research for: PostgreSQL migration + async data layer + model refactoring (PaperBot v2.0)* +*Researched: 2026-03-14* diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md new file mode 100644 index 00000000..74872d27 --- /dev/null +++ b/.planning/research/PITFALLS.md @@ -0,0 +1,478 @@ +# Pitfalls Research + +**Domain:** PostgreSQL migration + async data layer + model refactoring (v2.0) +**Researched:** 2026-03-14 +**Confidence:** HIGH — grounded in codebase inspection + verified SQLAlchemy/Alembic/asyncpg official sources + +> This file covers pitfalls specific to the v2.0 milestone: SQLite → PostgreSQL migration, +> sync Session → AsyncSession conversion across 17+ stores, FTS5 → tsvector, +> sqlite-vec → pgvector, JSON Text → JSONB, and Alembic migration tooling. +> It does NOT cover the v1.1 agent orchestration pitfalls (see PITFALLS.md history). + +--- + +## Critical Pitfalls + +Mistakes that cause rewrites, data loss, or silent behavioral regressions. + +--- + +### Pitfall 1: MissingGreenletError on Lazy-Loaded Relationships + +**What goes wrong:** +After converting stores to `AsyncSession`, any access to a SQLAlchemy relationship attribute that was not explicitly loaded in the original query raises `sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here`. This includes accessing `.events`, `.logs`, `.metrics`, `.runbook_steps`, `.artifacts` on `AgentRunModel`, or `.memories` on `MemorySourceModel`. The error is not raised in tests that use SQLite in-memory with sync sessions — it only appears in production-style async contexts. + +**Why it happens:** +SQLAlchemy's default relationship loading strategy is lazy — it issues a synchronous SELECT when the attribute is first accessed. In an async context there is no greenlet in scope to proxy this synchronous I/O, so the ORM raises `MissingGreenlet` instead of silently blocking. The existing `models.py` has 30+ relationships all using the default `lazy="select"` strategy. None are annotated with `lazy="selectin"` or `lazy="raise"`. + +**How to avoid:** +- Add `lazy="raise"` to ALL relationships in `models.py` immediately. This converts silent runtime errors into loud errors that surface during development. +- For each store query that needs a relationship, add explicit `.options(selectinload(...))` to the `select()` statement. +- Use `expire_on_commit=False` in the `async_sessionmaker` factory. Without this, attributes accessed after a `commit()` will trigger an implicit reload — which also raises `MissingGreenlet`. +- Never serialize a SQLAlchemy model object to a response dict outside of an `AsyncSession` scope without pre-loading all needed attributes. + +**Warning signs:** +- `MissingGreenlet` in logs pointing to a model `.attribute` access. +- Tests pass with SQLite but requests fail in production. +- Pydantic serialization of response models triggers the error. + +**Phase to address:** Model schema phase (before any async session work begins). Add `lazy="raise"` to all relationships as the first step of the conversion so violations surface immediately. + +--- + +### Pitfall 2: is_active Stored as Integer, Compared as Boolean After Column Type Change + +**What goes wrong:** +`ResearchTrackModel.is_active` is declared as `Mapped[int]` and queried with `ResearchTrackModel.is_active == 1` and `.values(is_active=0)` in `research_store.py` (lines 204, 261, 285, 326, 350). If the model refactoring phase changes this column to `Mapped[bool]` / `Boolean`, all 5 call sites must be updated to `True`/`False` simultaneously. Any site that is missed silently sends `1` to a `BOOLEAN` column in PostgreSQL — which PostgreSQL accepts — but reads back as `True`, not `1`. Code paths that did `bool(int(t.is_active or 0))` (research_store.py:1890) work, but code paths that compared `result == 1` break. + +**Why it happens:** +SQLite stores `Boolean` as `0`/`1` integers and Python code learned to treat the column as an integer. PostgreSQL has a native `BOOLEAN` type that returns Python `True`/`False`, not `1`/`0`. The mismatch is invisible in SQLite and explodes in PostgreSQL. + +**How to avoid:** +- During model refactoring, grep for ALL `== 0`, `== 1`, `=0`, `=1` assignments to `is_active` before changing the column type. Change all 5 sites in `research_store.py` at the same time as the model change. +- Treat `Boolean` columns and `Integer` flags as separate migration concerns — do not change types incrementally on different days. +- After schema change, add an integration test that reads the `is_active` field back and asserts `isinstance(result, bool)`. + +**Warning signs:** +- Any store file using `== 0` or `== 1` comparisons on columns declared as `Boolean`. +- `bool(int(...))` wrapper calls indicate the column was not originally `bool`. + +**Phase to address:** Model refactoring phase. Audit all `Integer`-as-boolean columns (also `pii_risk`, `priority` where used as flags) before declaring them `Boolean`. + +--- + +### Pitfall 3: LIKE is Case-Insensitive in SQLite, Case-Sensitive in PostgreSQL + +**What goes wrong:** +`paper_store.py` uses `ilike(pattern)` in 4 places (lines 668–690) and `func.lower(...).like(...)` in `research_store.py` (lines 964–968). The `ilike` calls are safe because `ilike` is portable via SQLAlchemy. The `func.lower(...).like(...)` pattern in `research_store.py` is also safe if the input is already `.lower()`. However, any remaining `.like(...)` without `.lower()` or `ilike` wrapping — whether in the stores or in raw SQL strings — will silently return fewer results after moving to PostgreSQL. + +**Why it happens:** +SQLite's `LIKE` is case-insensitive for ASCII by default. PostgreSQL's `LIKE` is case-sensitive. Developers test search on SQLite in dev/CI where "python" matches "Python", then the same query on PostgreSQL returns 0 results. + +**How to avoid:** +- Audit all `.like(...)` calls across all stores. Any `.like(...)` that is NOT preceded by `func.lower(column)` and `func.lower(value)` must be changed to `.ilike(...)`. +- The existing `ilike` usage in `paper_store.py` is already correct — do not change it. +- The FTS search replacement (tsvector) is inherently case-insensitive via PostgreSQL's text search dictionaries — no action needed there. + +**Warning signs:** +- Full-text search returns fewer results on PostgreSQL than SQLite for the same query with mixed-case input. +- A search for "ArXiv" that finds papers on SQLite returns 0 on PostgreSQL. + +**Phase to address:** Store migration phase. Add a text search regression test with mixed-case inputs before and after migration. + +--- + +### Pitfall 4: Text → JSONB Column Migration Fails Without Explicit CAST + +**What goes wrong:** +PaperBot has 84 `Text` columns storing JSON (`_json` suffix). The model refactoring plan converts these to `JSONB`. Alembic's `autogenerate` cannot automatically migrate `TEXT` data to `JSONB`. Running `alembic upgrade head` on an existing PostgreSQL database with data will fail with: +``` +psycopg2.errors.DatatypeMismatch: column "payload_json" is of type jsonb +but expression is of type text. +HINT: You might need to add an explicit cast. +``` +Data loss risk: if the migration drops and recreates the column instead of altering it, all JSON data is lost. + +**Why it happens:** +PostgreSQL will not implicitly cast `text` to `jsonb`. The `ALTER COLUMN ... TYPE jsonb` command requires an explicit `USING column::jsonb` clause. Alembic's autogenerate does not add this clause automatically. + +**How to avoid:** +- Write ALL `Text` → `JSONB` column migrations manually (not autogenerated). Use the pattern: + ```python + op.execute("ALTER TABLE agent_events ALTER COLUMN payload_json TYPE jsonb USING payload_json::jsonb") + ``` +- Test every migration in a staging PostgreSQL database with real row data, not just with an empty schema. +- For any row where the JSON text is malformed, `::jsonb` cast will fail. Add a pre-migration validation step: `SELECT id FROM table WHERE payload_json IS NOT NULL AND payload_json::text !~ '^[{\\[]'`. +- Never use `autogenerate` for type change migrations — always review and write by hand. + +**Warning signs:** +- Alembic generates `sa.Column('payload_json', postgresql.JSONB(...))` in autogenerate output without a `USING` clause in `op.alter_column`. +- CI migration tests pass on an empty schema but fail on a database with rows. + +**Phase to address:** Alembic migration authoring phase. The golden rule: every `Text → JSONB` alter must be hand-authored with `USING` clause and tested on a seeded database. + +--- + +### Pitfall 5: FTS5 Virtual Table and sqlite_master Queries Break on PostgreSQL Immediately + +**What goes wrong:** +`memory_store.py` and `document_index_store.py` contain 20+ direct calls to `sqlite_master`: +```python +text("SELECT name FROM sqlite_master WHERE type IN ('table', 'shadow')") +text("SELECT name FROM sqlite_master WHERE type='trigger'") +text("PRAGMA table_info(memory_items)") +``` +These queries will raise `ProgrammingError: relation "sqlite_master" does not exist` the first time any code path that calls `_ensure_fts5()` or `_ensure_vec_table()` runs against PostgreSQL. There are also raw FTS5 queries like `CREATE VIRTUAL TABLE ... USING fts5(...)` that have no PostgreSQL equivalent. + +**Why it happens:** +The FTS5 and sqlite-vec tables are created lazily at runtime by the store constructors. They are deeply interleaved with the main store logic, not isolated in migrations. Moving to PostgreSQL requires replacing them entirely — `tsvector` with a GIN index for FTS, and `pgvector`'s `vector` type for ANN search. + +**How to avoid:** +- Before writing a single async-migration line, wrap all SQLite-specific code paths in an `is_sqlite` guard: + ```python + if str(self._engine.url).startswith("sqlite"): + self._ensure_fts5(conn) + ``` + This prevents crash-on-PostgreSQL during the transition period where both backends may be in use. +- Create a `MemorySearchPort` interface that has `search_fts(...)` and `search_vec(...)` methods. Provide a `SqliteMemorySearch` implementation (existing code) and a `PostgresMemorySearch` implementation (tsvector + pgvector). Swap via the DI container based on DB URL. +- The tsvector replacement is a separate migration file: add a `tsvector` column, create a GIN index, add an update trigger. This migration can ONLY run against PostgreSQL — gate it in `alembic/env.py` with `if "postgresql" in db_url`. + +**Warning signs:** +- Any test that passes a PostgreSQL URL to a store constructor crashes with `sqlite_master does not exist`. +- The `_ensure_fts5` method is called from `__init__` with no database-type guard. + +**Phase to address:** Store interface design phase (before PostgreSQL integration). FTS abstraction behind a port is non-negotiable. + +--- + +### Pitfall 6: Alembic Autogenerate Loops Infinitely on tsvector GIN Indexes + +**What goes wrong:** +Once tsvector columns and GIN indexes are added to the PostgreSQL schema, every subsequent `alembic revision --autogenerate` detects the GIN index as "changed" and generates a drop/recreate pair. This produces a stream of identical empty migrations and makes `alembic check` always report "schema out of date" even when nothing has changed. + +**Why it happens:** +Alembic's autogenerate cannot correctly fingerprint `to_tsvector()`-based expression indexes. It sees the index definition as different on every comparison cycle, even if nothing changed. This is a confirmed upstream bug in Alembic 1.13+ (GitHub issue #1390). + +**How to avoid:** +- After creating the initial tsvector GIN index migration, exclude it from autogenerate using `include_object` in `alembic/env.py`: + ```python + def include_object(object, name, type_, reflected, compare_to): + if type_ == "index" and name and "tsvector" in (name or ""): + return False + return True + ``` +- Alternatively, mark the index creation as `manual` (do not autogenerate it at all) and manage it through named migration files. +- Register the `vector` type from pgvector in `env.py` to prevent similar false-positive autogenerate on vector columns: + ```python + from pgvector.sqlalchemy import Vector + connection.dialect.ischema_names["vector"] = Vector + ``` + +**Warning signs:** +- Every `alembic revision --autogenerate` produces a non-empty file for the same indexes. +- `alembic check` reports detected changes but running the migration makes no visible difference. + +**Phase to address:** Alembic tooling setup phase. Add autogenerate exclusions BEFORE writing any tsvector or pgvector migrations. + +--- + +### Pitfall 7: ARQ Worker Session Not Scoped to Individual Jobs + +**What goes wrong:** +After converting stores to `AsyncSession`, the ARQ worker (`arq_worker.py`) uses a shared session across jobs. Two concurrent ARQ jobs both write to `agent_events` or `agent_runs` using the same session. One job's `commit()` commits the other job's uncommitted changes. Or worse, one job's `rollback()` rolls back both jobs' work. + +**Why it happens:** +Unlike FastAPI (which scopes sessions per-request via `Depends`), ARQ has no dependency injection. The naive migration wraps the `WorkerSettings` startup in a single `async with async_session_factory() as session: ...` that lives for the entire worker lifetime. All jobs share it. + +**How to avoid:** +- Use ARQ's `on_job_start` and `on_job_complete` lifecycle hooks to create and destroy a session per job. +- Store the per-job session in the ARQ context dict (`ctx["db_session"]`) keyed to `ctx["job_id"]`. +- The `startup` hook creates the engine and session factory only — not a session. +- `on_job_start` creates `ctx["db_session"] = async_session_factory()`. +- `on_job_complete` calls `await ctx["db_session"].close()`. + +**Warning signs:** +- Jobs that succeed in isolation fail intermittently under concurrent load. +- `IntegrityError` from concurrent jobs writing to the same rows. +- Checking ARQ worker logs: a single session ID appears across multiple job log entries. + +**Phase to address:** ARQ worker migration phase. The ARQ session lifecycle must be explicitly designed before any store method is converted to async. + +--- + +### Pitfall 8: anyio.to_thread.run_sync Wrappers Left in Place After AsyncSession Migration + +**What goes wrong:** +Currently, 16 MCP tool functions call `anyio.to_thread.run_sync(sync_store_method, ...)` to bridge async MCP handlers to sync SQLAlchemy stores. After converting stores to `AsyncSession`, these bridges become unnecessary and harmful: the store method is now a coroutine, not a callable, so `anyio.to_thread.run_sync(coroutine_method)` silently returns a coroutine object instead of awaiting it. No error is raised; the tool returns empty data. + +**Why it happens:** +`anyio.to_thread.run_sync` accepts any callable and runs it in a thread. Passing a coroutine-returning method (e.g., `async def search(...)`) returns the coroutine object itself to `run_sync`, which wraps it and returns a future that resolves to the coroutine object — not its result. This is a silent failure. + +**How to avoid:** +- Convert MCP tools to `await store.method(...)` directly after converting each store. +- Do NOT leave `anyio.to_thread.run_sync` wrappers in place "as a safety net" — they will silently break. +- Write an integration test for each MCP tool that asserts the return value is populated data, not a coroutine object or empty list. + +**Warning signs:** +- MCP tools return empty lists or `None` after store conversion. +- No `MissingGreenlet` errors (the coroutine was never awaited — the error is silence, not crash). +- Adding `print(result)` shows ``. + +**Phase to address:** MCP tool update phase. Each tool must be updated immediately after its corresponding store is converted — not as a final sweep. + +--- + +### Pitfall 9: Alembic Migration Squashing or Merge Breaks PostgreSQL-Specific Branches + +**What goes wrong:** +The existing `alembic/versions/` directory has 28 migration files with a known branch conflict (`4c71b28a2f67_merge_structured_card_and_anchor_author_.py`). Adding new PostgreSQL-specific migrations creates additional branches. Running `alembic upgrade head` on a fresh PostgreSQL database with all branches active hits conflicts and either runs migrations in wrong order or fails outright. + +**Why it happens:** +Alembic's dependency graph for heads gets confused when multiple "head" revisions exist simultaneously. The existing merge migration handles SQLite-era branches. Adding PostgreSQL-gated migrations (e.g., `CREATE EXTENSION vector`) that cannot run on SQLite creates a new branching problem. + +**How to avoid:** +- Before v2.0 migration work starts, squash all existing migrations into a single "v1.x baseline" migration. Test this baseline on both SQLite and a fresh PostgreSQL schema. +- Create a clean single-head starting point for v2.0 work. +- For PostgreSQL-only migrations (tsvector, pgvector), use a dialect check in the migration body, NOT separate branch files: + ```python + def upgrade(): + bind = op.get_bind() + if bind.dialect.name == "postgresql": + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + ``` + +**Warning signs:** +- `alembic heads` shows more than 1 head. +- `alembic upgrade head` on a fresh database takes an unexpected path or skips migrations. + +**Phase to address:** Pre-migration setup phase. Squash first, then add PostgreSQL migrations. + +--- + +### Pitfall 10: Data Migration of Existing SQLite Database Loses Rows Due to FK Violations + +**What goes wrong:** +The PaperBot SQLite database has 46 tables with foreign keys that SQLite historically did not enforce. When importing the SQLite data into PostgreSQL (where FK constraints are always enforced), rows with dangling FK references fail to insert. For example: `paper_feedback` rows referencing `papers.id` values that were cleaned up in SQLite but not deleted from `paper_feedback` due to missing CASCADE. The import fails mid-table, leaving PostgreSQL in a partially migrated state. + +**Why it happens:** +SQLite's foreign key enforcement is opt-in (`PRAGMA foreign_keys = ON`). Most SQLite deployments run without it. PaperBot's models define `ondelete="CASCADE"` on some relationships but not all (e.g., `PaperFeedbackModel.paper_ref_id` has no explicit `ondelete`). Years of data in SQLite may contain orphaned rows that have never caused visible errors. + +**How to avoid:** +- Before migration, validate FK integrity on SQLite with `PRAGMA foreign_keys = ON` and a `PRAGMA integrity_check`. Log all violations. +- Use `pgloader` for the actual data transfer — it handles FK ordering and can generate a violation report. +- Alternatively, use a custom Python migration script that inserts parent tables before child tables and collects FK violations to a separate log for manual triage. +- After migration, run `SELECT * FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'` and spot-check a sample of FK relationships. + +**Warning signs:** +- `pgloader` output shows "condition not verified" rows counted separately from "rows copied". +- `INSERT` failures during migration referencing `foreign key constraint`. +- Row counts differ between SQLite export and PostgreSQL import. + +**Phase to address:** Data migration tooling phase. Build validation-first, migrate-second. + +--- + +### Pitfall 11: Prepared Statement Errors with asyncpg and Connection Poolers + +**What goes wrong:** +If PostgreSQL is deployed behind PgBouncer (or similar) in transaction pooling mode, asyncpg's automatic use of prepared statements causes intermittent errors: `prepared statement "__asyncpg_stmt_XX__" does not exist` or `already exists`. The existing `sqlalchemy_db.py` already disables prepared statements for `psycopg` connections via `prepare_threshold=0`, but this does NOT apply to asyncpg. + +**Why it happens:** +asyncpg prepares statements at the session level by default. PgBouncer in transaction mode does not guarantee the same backend connection across transactions. When a prepared statement exists on backend connection A, and the next query arrives on backend connection B, the statement does not exist on B. + +**How to avoid:** +- Add `statement_cache_size=0` to the asyncpg `connect_args` in `create_async_engine`: + ```python + create_async_engine(url, connect_args={"statement_cache_size": 0}) + ``` + Note: this cannot be set in the connection URL string — it must be a Python kwarg. +- The existing `prepare_threshold: 0` in `sqlalchemy_db.py` covers the sync `psycopg2` path; mirror it for asyncpg explicitly. +- For local Docker development without PgBouncer, this is a non-issue — but production deployments with connection poolers will hit this. + +**Warning signs:** +- Works in Docker dev, fails in production (or CI using a pooler). +- Intermittent errors on high-concurrency endpoints like `POST /api/analyze` or `GET /api/track`. +- Error message references `asyncpg_stmt`. + +**Phase to address:** AsyncSession setup phase. Set `statement_cache_size=0` from the first async engine created. + +--- + +### Pitfall 12: SQLite In-Memory Tests No Longer Valid After AsyncSession Migration + +**What goes wrong:** +The existing test suite uses `SessionProvider(db_url="sqlite:///:memory:")` for unit and integration tests (18+ test files). After converting stores to `AsyncSession`, these tests cannot use SQLite in-memory for two reasons: (1) the async driver for SQLite (`aiosqlite`) has different behavior than asyncpg for PostgreSQL, and (2) FTS5/tsvector and vec0/pgvector have no common interface in SQLite in-memory mode. Tests that use FTS or vector search will either skip silently or crash. + +**Why it happens:** +SQLite's timezone handling is different from PostgreSQL (naive vs aware datetimes), LIKE case sensitivity differs, and the test infrastructure that imports `sqlite_vec` is optional (skipped if not installed). The tests were designed for sync SQLite — they are not valid validators of async PostgreSQL behavior. + +**How to avoid:** +- Migrate tests to `testcontainers[postgres]` for any test that touches stores, sessions, or search. +- Keep a SQLite in-memory path only for pure domain logic tests (no stores). +- The testcontainers fixture pattern for pytest is a session-scoped PostgreSQL container that runs all store tests against real PostgreSQL. +- CI must have Docker available. The existing `requirements-ci.txt` must add `testcontainers[postgres]` and `pytest-asyncio`. +- Do NOT attempt to keep SQLite as a "fast" fallback for store tests — the behavioral differences are too large to trust. + +**Warning signs:** +- Tests pass with `sqlite:///:memory:` but requests fail in production with different results. +- Datetime comparison tests produce different results across environments. + +**Phase to address:** Test infrastructure phase. Establish testcontainers fixture BEFORE converting the first store to async. + +--- + +### Pitfall 13: pgvector Extension Not Registered in Alembic env.py + +**What goes wrong:** +After installing `pgvector` and defining `vector` type columns in models, `alembic revision --autogenerate` emits: +``` +SAWarning: Did not recognize type 'vector' of column 'embedding' +``` +and generates an empty migration that appears to detect no changes. Subsequent attempts to apply the migration fail because the `vector` column was never created. + +**Why it happens:** +Alembic's schema introspection does not know about custom PostgreSQL types like `vector` by default. Without registering the type in `env.py`, autogenerate treats the column as unknown and omits it from the diff. + +**How to avoid:** +- In `alembic/env.py`'s `run_migrations_online()`, before `context.configure(...)`, add: + ```python + from pgvector.sqlalchemy import Vector + connection.dialect.ischema_names["vector"] = Vector + ``` +- Also add `CREATE EXTENSION IF NOT EXISTS vector` in the first migration that uses the `vector` type. +- Write the `vector` column migration by hand — do not rely on autogenerate for it. + +**Warning signs:** +- `alembic revision --autogenerate` produces no change for a model with a new `vector` column. +- `alembic check` says no changes detected even though `memory_items.embedding` is still `LargeBinary`. + +**Phase to address:** pgvector integration phase. Set up the extension registration before writing the embedding migration. + +--- + +## Technical Debt Patterns + +| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable | +|----------|-------------------|----------------|-----------------| +| Keep sync Session with `run_sync()` wrapper instead of native AsyncSession | No store rewrites, faster transition | Every DB call still blocks one greenlet thread; can't use true async DB features; still need asyncpg | Only as a temporary bridge during incremental migration; remove within same milestone | +| Migrate schema but not data (leave SQLite file in production) | Simpler milestone scope | Dual-write or data gap in production; users lose history | Never — v2.0 must include a data migration path for existing installations | +| Use SQLite in-memory for post-migration store tests | Test speed, no Docker dependency | Tests do not catch PostgreSQL-specific bugs (type coercion, LIKE sensitivity, FK enforcement) | Never after AsyncSession conversion | +| Leave `anyio.to_thread.run_sync` wrappers in MCP tools | Zero MCP changes needed during store migration | Silent return of coroutine objects; MCP tools return empty data | Never — remove immediately when each store is converted | +| Skip squashing old migrations before adding PG migrations | Saves 2-4 hours | Alembic head conflicts; harder to onboard new contributors; migration graph debugging nightmare | Never | + +--- + +## Integration Gotchas + +| Integration | Common Mistake | Correct Approach | +|-------------|----------------|------------------| +| asyncpg + PgBouncer | Not disabling prepared statements | Pass `statement_cache_size=0` as a kwarg to `create_async_engine` connect_args | +| pgvector + Alembic | Relying on autogenerate for `vector` column | Register type in `env.py`, write migration by hand, add `CREATE EXTENSION IF NOT EXISTS vector` | +| tsvector + Alembic | Autogenerate loops on GIN expression indexes | Exclude tsvector indexes from autogenerate via `include_object` filter in `env.py` | +| ARQ worker + AsyncSession | Sharing one session across concurrent jobs | Create per-job session in `on_job_start`, destroy in `on_job_complete` hooks | +| Docker PG + asyncpg | Forgetting to wait for PG to be ready on container start | Use `pool_pre_ping=True` on engine + retry logic in startup hook | +| SQLite → PG data copy via pgloader | Foreign key violations stopping import mid-table | Run `PRAGMA integrity_check` on SQLite first; use `pgloader` with `CONTINUE ON ERROR` + violation report | + +--- + +## Performance Traps + +| Trap | Symptoms | Prevention | When It Breaks | +|------|----------|------------|----------------| +| Using `ilike` on unindexed large text columns (title, abstract) without tsvector | Full table scan; queries >500ms as papers table grows | Add tsvector GIN index; use `to_tsquery` for search instead of `ilike` | At ~50K papers rows | +| No connection pool size limit on asyncpg engine | DB reports "too many connections"; asyncpg pool waits indefinitely | Set `pool_size=10, max_overflow=5` on `create_async_engine` | At high concurrency (>20 simultaneous requests) | +| Returning full relationship graphs via `selectinload` on list endpoints | N+1 converted to single SELECT IN, but result set is huge | Use `selectinload` only for needed relationships; add explicit `limit()` | Immediately on large datasets | +| Running Alembic migrations with `NullPool` in production | Each migration step opens and closes a connection; slow for 28 migrations | NullPool is correct for migrations; not a runtime concern | Migrations take >2 min but this is acceptable | +| pgvector ANN search without an index | Sequential scan through all embeddings | Create HNSW or IVFFlat index on `vector` column before enabling ANN search | At ~10K embedding rows | + +--- + +## Security Mistakes + +| Mistake | Risk | Prevention | +|---------|------|------------| +| Storing PostgreSQL credentials in `.env` committed to git | Credential leak | Use environment injection (Docker Compose env, CI secrets); `.env` is in `.gitignore` already | +| Running Alembic with a superuser in production | Migration can drop tables it should not touch | Create a limited `paperbot_migrator` role with only `CONNECT, CREATE TABLE, ALTER TABLE` rights | +| Not rotating `api_key_value` stored in `model_endpoints` table | Plaintext API key accessible to any DB reader | Encrypt with a master key or store only as `api_key_env` references; existing TODO in codebase | + +--- + +## "Looks Done But Isn't" Checklist + +- [ ] **AsyncSession conversion:** Store methods return `await`-able results — verify each store has zero remaining `with self._provider.session()` sync context managers. +- [ ] **Relationship loading:** `lazy="raise"` added to all relationships in `models.py` — verify by running the test suite and checking for no `MissingGreenlet` errors. +- [ ] **FTS migration:** `memory_store._search_fts5` and `document_index_store._search_chunk_ids_with_fts` replaced with tsvector implementations — verify by running keyword search and checking result count matches SQLite baseline. +- [ ] **sqlite-vec to pgvector:** `memory_store._search_vec` replaced with pgvector ANN search — verify by running vector search and checking cosine distances are plausible. +- [ ] **sqlite_master queries removed:** grep for `sqlite_master` in `memory_store.py` and `document_index_store.py` returns 0 results. +- [ ] **PRAGMA removed:** grep for `PRAGMA` in `src/` returns 0 results outside of test files. +- [ ] **anyio.to_thread.run_sync removed from MCP tools:** grep for `anyio.to_thread.run_sync` in `mcp/tools/` and `mcp/resources/` returns 0 results. +- [ ] **Text → JSONB with USING clause:** every migration file that changes a `_json` column from `Text` to `JSONB` contains `USING column::jsonb` in the `op.execute` call. +- [ ] **JSONB autogenerate imports:** every autogenerated migration file that uses `postgresql.JSONB` has `from sqlalchemy.dialects import postgresql` at the top. +- [ ] **ARQ worker lifecycle:** `WorkerSettings` has `on_job_start` and `on_job_complete` hooks; `startup` does NOT create a session, only a factory. +- [ ] **testcontainers in CI:** `requirements-ci.txt` includes `testcontainers[postgres]`; CI runner has Docker socket accessible. +- [ ] **pgvector extension registered:** `alembic/env.py` registers `Vector` in `connection.dialect.ischema_names` before any `context.configure` call. + +--- + +## Recovery Strategies + +| Pitfall | Recovery Cost | Recovery Steps | +|---------|---------------|----------------| +| MissingGreenlet on lazy load in production | HIGH | Add `selectinload` to affected queries; redeploy; no data loss | +| Text → JSONB migration failed mid-run | HIGH | Restore from backup; fix migration with `USING` clause; re-run from last successful step | +| FK violation during SQLite → PG data import | MEDIUM | Identify orphaned rows from pgloader report; delete from SQLite; re-export; re-import | +| ARQ worker shared session corruption | HIGH | Stop worker; identify affected jobs from logs; replay failed jobs; add per-job session lifecycle | +| Alembic autogenerate loop on tsvector GIN index | LOW | Add `include_object` filter to `env.py`; delete spurious empty migration files | +| SQLite in-memory tests passing but PG failing | MEDIUM | Add testcontainers fixture; run failing tests against PG to reveal type/behavior mismatches | +| asyncpg prepared statement errors in production | MEDIUM | Add `statement_cache_size=0` to connect_args; redeploy; no data loss | + +--- + +## Pitfall-to-Phase Mapping + +| Pitfall | Prevention Phase | Verification | +|---------|------------------|--------------| +| MissingGreenlet on lazy relationships (#1) | Model schema refactoring (add `lazy="raise"` to all relationships) | Run full test suite; zero `MissingGreenlet` errors | +| is_active integer → boolean type change (#2) | Model refactoring (audit all `== 0` / `== 1` sites before column type change) | Integration test: read `is_active` field, assert `isinstance(result, bool)` | +| LIKE case sensitivity (#3) | Store migration (audit all `.like()` calls, convert to `.ilike()`) | Search regression test with mixed-case input | +| Text → JSONB without CAST (#4) | Alembic migration authoring (hand-write all type-change migrations) | Run migration against seeded test database; verify row counts unchanged | +| FTS5 sqlite_master queries (#5) | Store interface design (add `is_sqlite` guard or FTS port abstraction) | Start store with PostgreSQL URL; no `sqlite_master` errors | +| tsvector autogenerate loop (#6) | Alembic tooling setup (add `include_object` filter before writing tsvector migrations) | `alembic revision --autogenerate` produces empty file after stable schema | +| ARQ worker session scope (#7) | ARQ worker migration (add `on_job_start`/`on_job_complete` hooks) | Two concurrent ARQ jobs; each sees only its own committed rows | +| anyio.to_thread.run_sync left in MCP tools (#8) | MCP tool update (convert each tool immediately after its store) | MCP tool integration test returns populated data, not empty list | +| Alembic branch conflicts (#9) | Pre-migration setup (squash existing migrations to single baseline) | `alembic heads` returns exactly 1 head | +| Data migration FK violations (#10) | Data migration tooling (SQLite integrity check + pgloader with violation log) | Row counts match between SQLite export and PostgreSQL import | +| asyncpg prepared statements (#11) | AsyncSession setup (set `statement_cache_size=0` on first async engine) | High-concurrency load test against PostgreSQL returns no prepared statement errors | +| SQLite in-memory tests invalid (#12) | Test infrastructure (establish testcontainers fixture before first store conversion) | Store integration tests run against real PostgreSQL container in CI | +| pgvector type not registered (#13) | pgvector integration (register in `env.py` before first embedding migration) | `alembic revision --autogenerate` detects `vector` column as unchanged | + +--- + +## Sources + +- Codebase: `memory_store.py` — 20+ `sqlite_master` queries, `_ensure_fts5`, `_ensure_vec_table`, `sqlite_vec.load` calls +- Codebase: `document_index_store.py` — `_ensure_fts5`, `sqlite_master` queries, `ilike` fallback search +- Codebase: `paper_store.py` — `.ilike()` in 4 locations, `func.lower()` for title matching +- Codebase: `research_store.py` — `func.lower().like()` for search, `is_active == 1` / `== 0` in 5 locations +- Codebase: `sqlalchemy_db.py` — sync `sessionmaker`, `prepare_threshold: 0` for psycopg only +- Codebase: `models.py` — 84 `Text` JSON columns, `is_active: Mapped[int]`, all relationships default to `lazy="select"` +- Codebase: `alembic/versions/` — 28 migration files, existing branch merge at `4c71b28a2f67` +- Codebase: `mcp/tools/` — 16 uses of `anyio.to_thread.run_sync` wrapping sync store calls +- [SQLAlchemy AsyncIO docs](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) — `expire_on_commit=False`, `selectinload`, `MissingGreenlet` behavior +- [SQLAlchemy async discussion #9757](https://github.com/sqlalchemy/sqlalchemy/discussions/9757) — `greenlet_spawn has not been called` +- [SQLAlchemy async discussion #5923](https://github.com/sqlalchemy/sqlalchemy/discussions/5923) — sync and async coexistence +- [SQLAlchemy Boolean/SQLite docs](https://docs.sqlalchemy.org/en/20/dialects/sqlite.html) — type affinity, boolean storage as 0/1 +- [Alembic autogenerate docs](https://alembic.sqlalchemy.org/en/latest/autogenerate.html) — limitations: renames detected as add/drop, `compare_server_default` accuracy +- [Alembic issue #1390](https://github.com/sqlalchemy/alembic/issues/1390) — tsvector GIN index autogenerate false positive loop +- [Alembic issue #1324](https://github.com/sqlalchemy/alembic/discussions/1324) — pgvector type not recognized; `ischema_names` fix +- [Alembic issue #697](https://github.com/sqlalchemy/alembic/issues/697) — Text → JSON migration data loss +- [asyncpg FAQ](https://magicstack.github.io/asyncpg/current/faq.html) — prepared statement conflicts with PgBouncer +- [asyncpg issue #1058](https://github.com/MagicStack/asyncpg/issues/1058) — prepared statements despite disabled +- [ARQ + SQLAlchemy](https://wazaari.dev/blog/arq-sqlalchemy-done-right) — per-job session lifecycle with `on_job_start`/`on_job_complete` +- [Testcontainers Python](https://testcontainers.com/guides/getting-started-with-testcontainers-for-python/) — PostgreSQL fixture pattern for pytest +- [PostgreSQL case sensitivity](https://www.cybertec-postgresql.com/en/case-insensitive-pattern-matching-in-postgresql/) — LIKE vs ILIKE, migration implications +- [pgloader SQLite → PostgreSQL docs](https://pgloader.readthedocs.io/en/latest/ref/sqlite.html) — FK ordering, type casting, violation handling + +--- +*Pitfalls research for: PostgreSQL migration + async data layer + model refactoring (PaperBot v2.0)* +*Researched: 2026-03-14* diff --git a/.planning/research/STACK.md b/.planning/research/STACK.md new file mode 100644 index 00000000..a530407a --- /dev/null +++ b/.planning/research/STACK.md @@ -0,0 +1,307 @@ +# Technology Stack + +**Project:** PaperBot v2.0 — PostgreSQL Migration + Async Data Layer +**Researched:** 2026-03-14 +**Confidence:** HIGH + +--- + +## Principle: Surgical Additions Only + +The existing stack handles every concern except async PostgreSQL access and PG-native +feature types. This document covers only the **new packages required for v2.0** and +exactly how they integrate with existing `SessionProvider`, `SQLAlchemy 2.0`, and +`alembic`. Nothing is added for its own sake. + +--- + +## What Already Exists (Do NOT Re-add) + +| Capability | Existing Package | Status | +|---|---|---| +| SQLAlchemy ORM | `SQLAlchemy>=2.0.0` | Installed. Already uses `future=True` mode. | +| Schema migrations | `alembic>=1.13.0` | Installed. 27 migrations. `env.py` already PG-aware. | +| psycopg3 sync driver | `psycopg[binary]>=3.2.0` | Installed. Used in `create_db_engine` for `prepare_threshold=0`. | +| SQLite vector search | `sqlite-vec>=0.1.6` | Installed. Optional extra — replaced by pgvector in PG. | + +--- + +## New Additions Required + +### Core: Async Driver + +| Technology | Version | Purpose | Why | +|---|---|---|---| +| `asyncpg` | `>=0.31.0` | Native async PostgreSQL binary-protocol driver | Fastest Python PG driver (5x faster than psycopg3 in async benchmarks). No libpq dependency — pure asyncio. SQLAlchemy async engine uses it via `postgresql+asyncpg://` URL. The existing `psycopg[binary]` stays for Alembic migrations (Alembic runs sync DDL; async drivers are not needed there). HIGH confidence — PyPI verified. | + +### Core: SQLAlchemy Async Extensions + +| Technology | Version | Purpose | Why | +|---|---|---|---| +| `sqlalchemy[asyncio]` | `>=2.0.0` (install extra, not version bump) | Unlocks `create_async_engine`, `AsyncSession`, `async_sessionmaker` | SQLAlchemy's asyncio extension requires `greenlet` which is bundled via the `[asyncio]` extra. In SQLAlchemy 2.1+ (released Jan 2026) `greenlet` is no longer auto-installed — the extra is mandatory. Since `SQLAlchemy>=2.0.0` is already pinned, this is a re-install with the extra flag, not a version change. HIGH confidence — official SQLAlchemy 2.1 changelog verified. | + +### Core: PG-Native Feature Types + +| Technology | Version | Purpose | Why | +|---|---|---|---| +| `pgvector` | `>=0.4.2` | `Vector` column type for SQLAlchemy + pgvector PG extension | Replaces `sqlite-vec` LargeBinary blob approach. Provides typed `Vector(N)` mapped column, HNSW/IVFFlat index helpers, and cosine/L2/inner-product distance ops inside SQLAlchemy queries. Async-compatible via `register_vector_async` + `event.listens_for`. HIGH confidence — PyPI 0.4.2 verified, official pgvector-python repo confirmed SQLAlchemy 2.0 support. | + +### Development Infrastructure + +| Technology | Version | Purpose | Why | +|---|---|---|---| +| Docker image `pgvector/pgvector:pg17` | latest (PG 17.x) | Local dev PostgreSQL with pgvector bundled | Single image replaces `postgres:17` + manual `CREATE EXTENSION vector`. The official `pgvector/pgvector` Docker Hub image is maintained alongside the extension. PG 17.3+ required (17.0–17.2 have a symbol linking bug with pgvector). MEDIUM confidence — Docker Hub and pgvector GitHub verified. | + +--- + +## Installation Changes + +```bash +# 1. Add asyncpg (new dependency) +pip install "asyncpg>=0.31.0" + +# 2. Re-install SQLAlchemy with asyncio extra to pull in greenlet +# (required for SQLAlchemy 2.1+; safe on 2.0.x too) +pip install "sqlalchemy[asyncio]>=2.0.0" + +# 3. Add pgvector Python type package (new dependency) +pip install "pgvector>=0.4.2" + +# -- pyproject.toml changes -- +# In [project].dependencies: +# Change: "SQLAlchemy>=2.0.0" +# To: "SQLAlchemy[asyncio]>=2.0.0" +# +# Add to [project].dependencies: +# "asyncpg>=0.31.0" +# +# Move sqlite-vec out of [project.optional-dependencies].search +# and add pgvector in its place for PG installs: +# "pgvector>=0.4.2" +``` + +```bash +# Docker: local PG dev environment +docker run -d \ + --name paperbot-pg \ + -e POSTGRES_USER=paperbot \ + -e POSTGRES_PASSWORD=paperbot \ + -e POSTGRES_DB=paperbot \ + -p 5432:5432 \ + pgvector/pgvector:pg17 + +# Set env var +export PAPERBOT_DB_URL="postgresql+asyncpg://paperbot:paperbot@localhost:5432/paperbot" + +# Run migrations (Alembic uses sync psycopg3 — no change needed here) +alembic upgrade head +``` + +--- + +## Integration with Existing Code + +### SessionProvider: Extend, Not Replace + +The existing `SessionProvider` wraps a sync engine. The pattern is to add an **async +counterpart** `AsyncSessionProvider` in the same file, not to rewrite `SessionProvider`. +Sync sessions remain necessary for Alembic migrations, tests using `tmp_path` SQLite, +and any code that cannot easily be made async (e.g., ARQ workers running in threads). + +```python +# New class — add to sqlalchemy_db.py alongside existing SessionProvider +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker + +def create_async_db_engine(db_url: Optional[str] = None): + url = db_url or get_db_url() + # postgresql+asyncpg:// URL required; sqlite URLs need aiosqlite (not needed here) + return create_async_engine(url, pool_pre_ping=True) + +class AsyncSessionProvider: + def __init__(self, db_url: Optional[str] = None): + self.engine = create_async_db_engine(db_url) + self._factory = async_sessionmaker( + self.engine, class_=AsyncSession, expire_on_commit=False + ) + + def session(self) -> AsyncSession: + return self._factory() +``` + +Stores are then refactored one-by-one: each store's methods become `async def`, each +`with provider.session() as s:` becomes `async with provider.session() as s:`, and +`s.execute(select(...))` already returns `Result` in SQLAlchemy 2.0 — the await is +the only mechanical change per call site. + +### Alembic: Stays Sync + +Alembic's `upgrade()` / `downgrade()` functions must remain synchronous. The existing +`alembic/env.py` already uses `engine_from_config` with psycopg3 for PG URLs — no +change needed. Do NOT switch `env.py` to `async_engine_from_config` unless running +migrations programmatically inside a FastAPI lifespan (then use the `run_sync` pattern +with `conn.run_sync(run_upgrade, cfg)` to avoid event loop conflicts). + +### JSONB: Replace `Text` + `json.dumps` Columns + +Models currently serialize dicts to `Text` (e.g., `payload_json`, `metadata_json`, +`keywords_json`). On PG, migrate these to `JSONB` via Alembic `op.alter_column` + +`server_default='{}'`. The ORM mapping changes from `Text` to +`sqlalchemy.dialects.postgresql.JSONB`. Access via `model.payload` (dict) replaces +`json.loads(model.payload_json)`. + +```python +# Before (SQLite Text) +from sqlalchemy import Text +payload_json: Mapped[str] = mapped_column(Text, default="{}") + +# After (PG JSONB) +from sqlalchemy.dialects.postgresql import JSONB +payload: Mapped[dict] = mapped_column(JSONB, server_default="{}", nullable=False) +``` + +### tsvector: Replace FTS5 Virtual Tables + +The `0019_memory_fts5` migration already guards on `dialect != "sqlite"` and explicitly +comments "Postgres uses pg_trgm / tsvector instead." The PG migration adds a new +Alembic revision that: + +1. Adds a `search_vector tsvector` generated column (or trigger-maintained column) on + `memory_items.content`. +2. Creates a GIN index: `CREATE INDEX ix_memory_items_fts ON memory_items USING gin(search_vector)`. +3. Adds a `before insert or update` trigger calling + `to_tsvector('english', NEW.content)`. + +```python +# In the Alembic upgrade function +from sqlalchemy.dialects.postgresql import TSVECTOR + +op.add_column('memory_items', + sa.Column('search_vector', TSVECTOR(), nullable=True)) +op.create_index('ix_memory_items_fts', 'memory_items', ['search_vector'], + postgresql_using='gin') +op.execute(""" + CREATE OR REPLACE FUNCTION memory_items_fts_update() RETURNS trigger AS $$ + BEGIN + NEW.search_vector := to_tsvector('english', COALESCE(NEW.content, '')); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; +""") +op.execute(""" + CREATE TRIGGER memory_items_fts_trigger + BEFORE INSERT OR UPDATE OF content ON memory_items + FOR EACH ROW EXECUTE FUNCTION memory_items_fts_update(); +""") +``` + +### pgvector: Replace `LargeBinary` Embedding Columns + +`MemoryItemModel.embedding` is currently `LargeBinary` (raw Float32 bytes for +sqlite-vec). On PG, this becomes `Vector(N)` from `pgvector.sqlalchemy`. + +```python +# Before (sqlite-vec) +from sqlalchemy import LargeBinary +embedding: Mapped[Optional[bytes]] = mapped_column(LargeBinary, nullable=True) + +# After (pgvector) +from pgvector.sqlalchemy import Vector +embedding: Mapped[Optional[list]] = mapped_column(Vector(1536), nullable=True) +``` + +Register pgvector type codec on AsyncSession connections: + +```python +from pgvector.psycopg import register_vector_async # noqa — psycopg3 variant +from sqlalchemy import event + +@event.listens_for(async_engine.sync_engine, "connect") +def connect(dbapi_connection, connection_record): + dbapi_connection.run_async(register_vector_async) +``` + +--- + +## Data Migration Tooling + +For migrating existing SQLite data to PG (existing users' `data/paperbot.db`): + +**Use pgloader** — the standard open-source CLI for SQLite-to-PostgreSQL migrations. +It handles type coercion, sequences, and index recreation automatically. + +```bash +# Install pgloader (system package, not a Python dep) +apt-get install pgloader # or brew install pgloader on macOS + +# Migrate schema + data in one command +pgloader sqlite:///data/paperbot.db postgresql://paperbot:paperbot@localhost/paperbot +``` + +pgloader is a **system-level tool for one-time data migration only** — it is not a +Python dependency and must not be added to `requirements.txt`. After pgloader copies +the data, run `alembic upgrade head` on the PG database to apply any PG-specific +migrations (tsvector columns, JSONB conversions, pgvector columns) that were skipped +on SQLite. + +--- + +## Alternatives Considered + +| Category | Recommended | Alternative | Why Not | +|---|---|---|---| +| Async PG driver | `asyncpg` | `psycopg3[asyncio]` | psycopg3 is already installed for sync use. asyncpg is ~5x faster in benchmarks and is the de facto standard for SQLAlchemy async PG. The official FastAPI full-stack template uses psycopg3 for its simplicity (single driver), but PaperBot already has psycopg3 for Alembic — asyncpg adds maximum async throughput without replacing the sync driver. | +| Vector type | `pgvector` | Raw `float[]` array column | `float[]` requires manual distance query SQL. `pgvector` provides typed `Vector(N)` with HNSW/IVFFlat index support and SQLAlchemy-integrated distance operators. No contest. | +| FTS in PG | `tsvector` + GIN trigger | `pg_trgm` trigram index | `pg_trgm` supports fuzzy/partial matching; `tsvector` with `to_tsvector` provides true stemmed, ranked BM25-style search. For the academic paper domain (keywords, abstracts), stemmed full-text search is better. `pg_trgm` can be added later as a supplement if substring matching is needed. | +| Local dev PG | `pgvector/pgvector:pg17` | `postgres:17` + manual extension install | The prebuilt image eliminates a `CREATE EXTENSION vector` step and avoids the need for OS-level pgvector compilation in CI. Zero extra setup cost. | +| JSONB migration | Rename column, drop old | Keep Text column + add JSONB side-by-side | Dual-write is more work and more error-prone. Alembic ALTER COLUMN with server_default cast is clean within a transaction. | +| Data migration tool | `pgloader` | Custom Python ETL script | pgloader handles type coercion, sequence reset, and index recreation automatically. A custom script would need to replicate all of that. pgloader is a one-time dev tool, not a runtime dependency. | + +--- + +## What NOT to Install + +| Library | Why Might You Think You Need It | Why You Don't | +|---|---|---| +| `aiosqlite` | "Need async SQLite for tests" | Tests use sync SQLite sessions via the existing `SessionProvider`. The async layer only activates for PG URLs. Do not add async SQLite complexity to the test suite — it adds zero value and breaks the sync/async separation. | +| `databases` (encode/databases) | "Thin async SQL layer" | Superseded by SQLAlchemy 2.0 async. Adds a second ORM-like layer on top of SQLAlchemy. Creates two competing DB abstractions in the same codebase. | +| `tortoise-orm` | "Pure async ORM" | Would require rewriting 46 models from scratch. SQLAlchemy 2.0 async is the right choice when you already have an SA codebase. | +| `alembic-utils` | "Helpers for PG-specific objects" | The tsvector triggers and functions are written once in raw SQL inside Alembic migrations. `alembic-utils` adds a dependency for a one-time setup task. | +| `psycopg2` | "Might still need it" | `psycopg[binary]>=3.2.0` (psycopg3) is already installed. psycopg2 is a separate, older package. Never install both. | +| `sqlmodel` | "Pydantic-integrated ORM" | SQLModel wraps SQLAlchemy with Pydantic models. Rewriting 46 SA models to SQLModel just to get Pydantic integration is not worth it. Pydantic models for API layer already exist separately. | + +--- + +## Version Compatibility + +| Package | Pin | Notes | +|---|---|---| +| `asyncpg>=0.31.0` | Lower-bound only | 0.31.0 (Nov 2025) adds Python 3.14 support. Requires Python ≥3.9. PaperBot CI tests on 3.10/3.11/3.12 — all compatible. | +| `sqlalchemy[asyncio]>=2.0.0` | As existing | SQLAlchemy 2.1.0b1 (Jan 2026) dropped Python 3.9 support. The existing `>=2.0.0` pin may resolve to 2.1.x on 3.10/3.11/3.12 environments. To stay on stable 2.0.x, consider pinning `>=2.0.0,<2.1.0` until 2.1 stable is released. | +| `pgvector>=0.4.2` | Lower-bound only | 0.4.2 is current (2025). Requires pgvector PG extension ≥0.5.0 for HNSW index support. `pgvector/pgvector:pg17` Docker image includes extension 0.8.x. | +| `psycopg[binary]>=3.2.0` | Unchanged | Stays. Used by Alembic's sync `engine_from_config` for DDL migrations. The `prepare_threshold=0` connect arg in `alembic/env.py` already handles PgBouncer compatibility. | +| PostgreSQL | 17.3+ | PG 17.0–17.2 have a symbol linking bug with pgvector. Use `pgvector/pgvector:pg17` which tracks latest PG 17 patch. | + +--- + +## Sources + +- [asyncpg PyPI — version 0.31.0 confirmed](https://pypi.org/project/asyncpg/) — HIGH confidence +- [asyncpg GitHub MagicStack/asyncpg — Python 3.9–3.14, PG 9.5–18](https://github.com/MagicStack/asyncpg) — HIGH confidence +- [SQLAlchemy 2.0 Asyncio Extension docs](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) — HIGH confidence +- [SQLAlchemy 2.1.0b1 release blog — greenlet no longer auto-installed](https://www.sqlalchemy.org/blog/2026/01/21/sqlalchemy-2.1.0b1-released/) — HIGH confidence +- [SQLAlchemy 2.1 migration guide — Python 3.10 minimum](https://www.sqlalchemy.org/docs/21/changelog/migration_21.html) — HIGH confidence +- [pgvector-python PyPI — version 0.4.2 confirmed](https://pypi.org/project/pgvector/) — HIGH confidence +- [pgvector-python SQLAlchemy integration docs](https://deepwiki.com/pgvector/pgvector-python/3.1-sqlalchemy-integration) — MEDIUM confidence +- [pgvector GitHub — Docker image pgvector/pgvector:pg17](https://github.com/pgvector/pgvector) — HIGH confidence +- [Alembic tsvector + JSONB migration patterns](https://berkkaraal.com/blog/2024/09/19/setup-fastapi-project-with-async-sqlalchemy-2-alembic-postgresql-and-docker/) — MEDIUM confidence +- [pgloader SQLite → PostgreSQL migration](https://pgloader.readthedocs.io/en/latest/ref/sqlite.html) — HIGH confidence +- [psycopg3 vs asyncpg comparison (2026)](https://fernandoarteaga.dev/blog/psycopg-vs-asyncpg/) — MEDIUM confidence +- Codebase: `src/paperbot/infrastructure/stores/sqlalchemy_db.py` — confirmed existing SessionProvider, psycopg3 connect args, future=True mode +- Codebase: `alembic/versions/0019_memory_fts5.py` — confirmed FTS5 guard: `if dialect != "sqlite": return` +- Codebase: `requirements.txt` + `pyproject.toml` — confirmed all existing deps and Python version matrix (3.10/3.11/3.12 in CI) +- Codebase: `src/paperbot/infrastructure/stores/models.py` — confirmed LargeBinary embedding column, Text JSON columns pattern + +--- + +*Stack research for: PostgreSQL migration + async data layer + PG-native features* +*Researched: 2026-03-14* diff --git a/.planning/research/SUMMARY.md b/.planning/research/SUMMARY.md new file mode 100644 index 00000000..ed7897ee --- /dev/null +++ b/.planning/research/SUMMARY.md @@ -0,0 +1,249 @@ +# Project Research Summary + +**Project:** PaperBot v2.0 — PostgreSQL Migration & Async Data Layer +**Domain:** Brownfield database migration — SQLite to PostgreSQL, sync to async SQLAlchemy +**Researched:** 2026-03-14 +**Confidence:** HIGH + +## Executive Summary + +PaperBot v2.0 is a brownfield database migration, not a greenfield build. The project inherits 46 SQLAlchemy 2.0 models, 17 stores all using a sync `SessionProvider`, 84 `Text` columns hand-serializing JSON, two SQLite-only FTS5 virtual table subsystems, and a sqlite-vec embedding layer — none of which function correctly on PostgreSQL without explicit replacement. The recommended approach is a three-layer migration executed in strict sequence: (1) establish PostgreSQL infrastructure and schema compatibility while keeping sync stores, (2) convert all stores to async SQLAlchemy with a single shared asyncpg engine, and (3) clean up model schema and remove dead code. Each layer is independently deliverable and verifiable, which is the core risk-mitigation strategy for a ~170 method conversion across 17 stores. + +The most dangerous failure mode is attempting any two layers simultaneously. The lazy-loading pitfall (`MissingGreenlet`) is pervasive and silent — it surfaces only at runtime, not at conversion time, and can affect every store that accesses ORM relationships after session close. The mitigation is to add `lazy="raise"` to all 30+ model relationships before any store conversion begins, so violations are caught during development. The secondary risk is the Text→JSONB migration: PostgreSQL requires an explicit `USING column::jsonb` cast that Alembic autogenerate never emits, and any row with malformed JSON halts the migration mid-table. Every type-change migration must be hand-authored and tested against a seeded database. + +The test infrastructure is the single most critical enabler for this milestone. The existing SQLite in-memory test suite cannot validate PostgreSQL behavior — type coercion differs, LIKE case sensitivity differs, and FTS and vector search have no SQLite equivalent. A `testcontainers[postgres]` pytest fixture must be established and integrated into CI before the first store conversion ships, otherwise the CI green signal is meaningless. This is a non-negotiable prerequisite for Phase 3 work. + +--- + +## Key Findings + +### Recommended Stack + +The existing stack already has `SQLAlchemy>=2.0.0`, `alembic>=1.13.0`, and `psycopg[binary]>=3.2.0`. Only three new packages are required: `asyncpg>=0.31.0` (async PostgreSQL driver — ~5x faster than psycopg3 in async benchmarks), `sqlalchemy[asyncio]>=2.0.0` (re-install with extra to pull in `greenlet`, mandatory in SQLAlchemy 2.1+), and `pgvector>=0.4.2` (typed `Vector(N)` column for SQLAlchemy). The local dev environment uses the `pgvector/pgvector:pg17` Docker image, which bundles the pgvector extension and eliminates a manual `CREATE EXTENSION` step. PostgreSQL 17.3+ is required — versions 17.0–17.2 have a symbol linking bug with pgvector. + +The data migration tool for existing SQLite users is `pgloader` (a system-level tool, not a Python dependency). It handles type coercion and FK ordering but cannot export FTS5 or sqlite-vec virtual tables — those must be regenerated from source data after migration. The existing `psycopg[binary]` driver stays; it is used by Alembic for synchronous DDL migrations and must not be replaced. + +**Core technologies:** +- `asyncpg>=0.31.0`: async PostgreSQL driver — fastest async PG driver, de facto standard for SQLAlchemy async PG; no libpq dependency +- `sqlalchemy[asyncio]>=2.0.0`: unlocks `create_async_engine`, `AsyncSession`, `async_sessionmaker` — `[asyncio]` extra is mandatory in SQLAlchemy 2.1+ +- `pgvector>=0.4.2`: typed `Vector(N)` column with HNSW/IVFFlat index support — replaces `LargeBinary` blob approach for embeddings +- `pgvector/pgvector:pg17` Docker image: local PG with pgvector bundled — eliminates manual extension setup, requires PG 17.3+ +- `pgloader` (system tool, one-time): SQLite to PostgreSQL data migration — handles type coercion and FK ordering, not a `requirements.txt` entry + +**What NOT to add:** `aiosqlite` (tests use sync SQLite; adding async SQLite complexity has zero value), `databases` (superseded by SQLAlchemy 2.0 async), `tortoise-orm` (would require rewriting 46 models), `psycopg2` (psycopg3 already installed, never install both). + +### Expected Features + +The milestone has a clear three-tier priority structure based on feature dependencies. All P1 features are correctness blockers — the app cannot run on PostgreSQL without them. P2 features add meaningful capability once P1 is stable. P3 is polish and post-launch optimization. + +**Must have (P1 — milestone incomplete without these):** +- Docker Compose PostgreSQL setup — required for all local development; blocks everything else +- Alembic dual-path env.py (async PG + sync SQLite) — required to apply PostgreSQL schema +- `AsyncSessionProvider` + `create_async_engine` with single shared pool — replaces 20+ independent sync engine instances +- All 17 stores converted to `async def` methods with `async with` session context — eliminates event-loop blocking +- Eager loading audit: `lazy="raise"` on all relationships, `selectinload` on query paths — prevents silent `MissingGreenlet` +- Text to JSONB for all 84 JSON columns — semantic correctness and prerequisite for any JSONB indexing +- FTS5 to tsvector for `memory_store` and `document_index_store` — FTS5 is SQLite-only; silent fallback on PG means no search +- sqlite-vec to pgvector for `MemoryItemModel.embedding` — vector search is currently a no-op on PostgreSQL + +**Should have (P2 — ship after P1 validated):** +- Hybrid pgvector + tsvector search with Reciprocal Rank Fusion — upgrades BM25-only to production-quality RAG +- Async ARQ worker with per-job session lifecycle — prevents concurrent job session corruption +- GIN indexes on queryable JSONB columns (`agent_events.tags`, `memory_items.evidence`) — query performance +- CI PostgreSQL service container via `testcontainers[postgres]` — regression safety +- Data migration tooling (pgloader + custom script for embeddings) — required for existing user upgrades + +**Defer to post-v2.0:** +- Systematic model normalization: removing all 84 `_json` helper methods, adding CHECK constraints, normalizing authors to FK table +- ARRAY columns for flat string lists — micro-optimization with schema change risk +- Connection pool tuning and PgBouncer documentation +- Alembic migration squash / clean single-head baseline + +### Architecture Approach + +The architectural pivot is from N-engines-per-store to a single shared `AsyncEngine` owned by the DI container. Today every store calls `SessionProvider(db_url)` in `__init__`, creating an independent connection pool — 20+ separate pools at runtime on PostgreSQL, wasting connections and preventing cross-pool transaction semantics. The v2.0 pattern is a `bootstrap_async_db()` function called once at FastAPI startup that creates one `AsyncEngine`, wraps it in an `async_sessionmaker`, registers it as a DI singleton, and injects the factory into every store constructor. Stores no longer own engines. + +The build sequence is dependency-driven: (A) PostgreSQL + Schema while sync stores stay in place — proves PG compatibility before any async risk; (B) Async Data Layer conversion in four domain groups, one group per iteration; (C) Model refactoring to remove dead code and add constraints. The sync-first strategy means the existing test suite remains valid throughout Phase A, providing a safety net before the higher-risk Phase B work begins. + +**Major components:** +1. `async_db.py` (new) — owns `AsyncEngine` creation, `AsyncSessionProvider` wrapper, URL coercion helper (`postgresql://` to `postgresql+asyncpg://`); injected into DI at startup +2. `bootstrap_async_db()` (new in `core/di/bootstrap.py`) — FastAPI startup hook that wires engine into `Container.instance()`; mirrored in ARQ `startup` hook +3. All 17 stores (modified) — receive injected `async_sessionmaker`, all methods become `async def` with `async with` session context and `selectinload` for relationship access +4. `alembic/env.py` (modified) — dual path: async PG via `connection.run_sync(context.run_migrations)`, sync SQLite path unchanged; `include_object` filter excludes tsvector GIN indexes from autogenerate; `Vector` registered in `ischema_names` +5. `arq_worker.py` (modified) — `startup` creates engine and factory only, `on_job_start`/`on_job_complete` hooks create and close per-job `AsyncSession` scoped by `ContextVar` +6. MCP tools (modified) — all 16 `anyio.to_thread.run_sync()` wrappers removed and replaced with direct `await store.method()` calls, one tool per store as each store is converted + +### Critical Pitfalls + +1. **MissingGreenlet on lazy-loaded relationships** — All 30+ relationships in `models.py` use `lazy="select"` (SQLAlchemy default). In async context, accessing any unloaded relationship attribute after session close raises `sqlalchemy.exc.MissingGreenlet`. This error is invisible on SQLite sync tests and only surfaces at runtime on async PostgreSQL. Prevention: add `lazy="raise"` to every relationship in `models.py` as the very first step, before any store conversion begins. Set `expire_on_commit=False` on the `async_sessionmaker`. Add explicit `selectinload()` or `joinedload()` to every query that accesses related collections. + +2. **Text to JSONB migration fails without explicit CAST** — PostgreSQL will not implicitly cast `text` to `jsonb`. Alembic autogenerate never emits the required `USING column::jsonb` clause. Any row with malformed JSON (empty string, `NULL`, invalid JSON) stops the migration mid-run with a `DatatypeMismatch` error, leaving PostgreSQL in a partially migrated state. Prevention: hand-author every `_json TEXT → JSONB` migration using `op.execute("ALTER TABLE ... ALTER COLUMN ... TYPE jsonb USING col::jsonb")`; run a pre-migration cleanup query to fix empty strings; test against a seeded database — never just an empty schema. + +3. **FTS5 sqlite_master queries crash PostgreSQL immediately** — `memory_store.py` and `document_index_store.py` contain 20+ queries against `sqlite_master` and `CREATE VIRTUAL TABLE ... USING fts5(...)` DDL, both called from store `__init__`. These raise `ProgrammingError: relation "sqlite_master" does not exist` on first use with any PostgreSQL URL. Prevention: wrap all SQLite-specific bootstrap code in `is_sqlite` guards as the first act of Phase 1 work, before any other PG integration. + +4. **anyio.to_thread.run_sync left in place after async store conversion** — Once a store method becomes `async def`, passing it to `anyio.to_thread.run_sync()` returns the coroutine object rather than executing it. No error is raised; the MCP tool silently returns an empty list or `None`. Prevention: update each MCP tool to `await store.method()` directly, immediately after its corresponding store is converted — not as a final cleanup sweep. + +5. **ARQ worker shared AsyncSession across concurrent jobs** — After async conversion, if the worker uses a single `AsyncSession` across concurrent ARQ jobs, one job's `commit()` or `rollback()` affects another job's uncommitted work. Prevention: `startup` hook creates engine and factory only (no session); `on_job_start` creates `ctx["db_session"]` per job; `on_job_complete` closes it. Use `async_scoped_session` with a `ContextVar` scoped to `ctx["job_id"]`. + +--- + +## Implications for Roadmap + +Based on research, the build order is dependency-driven and risk-stratified. The critical constraint is that each layer must be verified before the next begins. Attempting Phase 1 and Phase 3 simultaneously is the single highest-risk anti-pattern identified across all research files. + +### Phase 1: PostgreSQL Infrastructure and Schema Compatibility + +**Rationale:** Nothing works without a running PostgreSQL target and a schema that does not crash on connection. This phase proves PG compatibility with zero async risk — sync stores remain in place, the existing test suite stays valid. All subsequent phases depend on this layer being stable. + +**Delivers:** +- Docker Compose with `pgvector/pgvector:pg17`, health check, named volume, `.env` update to `postgresql+asyncpg://` URL +- `pyproject.toml` additions: `asyncpg>=0.31.0`, `sqlalchemy[asyncio]>=2.0.0`, `pgvector>=0.4.2` +- Alembic dual-path `env.py`: async runner for PG URLs, sync path unchanged for SQLite, `include_object` filter for tsvector GIN indexes, `Vector` registered in `ischema_names` +- Alembic migrations 0028+: `CREATE EXTENSION vector`, tsvector columns + GIN indexes + update triggers on `memory_items` and `document_chunks`, pgvector `Vector(1536)` column replacing `LargeBinary` on `memory_items`, JSONB type changes with `USING` casts on all 84 `_json` columns +- `is_sqlite` guards wrapping `_ensure_fts5`, `_ensure_vec_table`, all `sqlite_master` queries +- Existing sync stores running against PostgreSQL (functionally correct, not yet async) + +**Avoids:** FTS5 `sqlite_master` crash (#5), Text→JSONB missing CAST (#4), pgvector not registered in env.py (#13), tsvector autogenerate loop (#6), Alembic branch conflicts (#9) + +### Phase 2: Test Infrastructure (testcontainers PostgreSQL) + +**Rationale:** This phase is a hard prerequisite for all store conversions. The existing SQLite in-memory fixtures cannot validate PostgreSQL-specific behavior: JSONB operators, tsvector queries, pgvector distance operators, LIKE case sensitivity, and datetime type handling all differ. Shipping a converted store without a PG test target means CI green is meaningless. + +**Delivers:** +- `testcontainers[postgres]` and `pytest-asyncio` added to `requirements-ci.txt` +- Session-scoped `pg_container` pytest fixture providing a real PostgreSQL database +- `@pytest.mark.postgres` marker for store integration tests +- SQLite sync fixtures retained for pure domain-logic unit tests (no stores) +- Baseline store integration tests running against PostgreSQL, confirming the fixture works before any async conversion begins + +**Avoids:** SQLite in-memory tests invalid after AsyncSession migration (#12) + +### Phase 3: Async Data Layer — Store Conversion in Domain Groups + +**Rationale:** Four domain groups, one group per iteration, each with its own PR and test pass. Converting all 17 stores in a single PR is the highest-risk mistake identified in research. Starting with `SqlAlchemyEventLog` forces the async infrastructure pattern to be proven on the smallest, most tightly-coupled component before the larger stores are touched. + +**Delivers:** +- `async_db.py` (new): `AsyncSessionProvider`, `create_async_db_engine`, `create_async_session_factory`, URL coercion helper, `statement_cache_size=0` in `connect_args` +- `bootstrap_async_db()` in `core/di/bootstrap.py`, wired to FastAPI `startup` event +- `lazy="raise"` added to ALL relationships in `models.py` (must be done first, before any store conversion) +- Group 1: `SqlAlchemyEventLog` — async `append()`, `list_runs()`, `list_events()`, `stream()` +- Group 2: `memory_store` — async methods + `_search_tsvector()` replacing `_search_fts5()` + pgvector `<=>` replacing `_search_vec()` + `anyio.to_thread.run_sync` wrappers removed from memory MCP tools +- Group 3: `paper_store` + `research_store` — async methods, all `.like()` calls audited and converted to `.ilike()`, `anyio.to_thread.run_sync` wrappers removed +- Group 4: remaining 13 stores — mechanical async conversion (no FTS or vector complexity), `anyio.to_thread.run_sync` wrappers removed from all remaining MCP tools + +**Avoids:** MissingGreenlet (#1), anyio.to_thread.run_sync silent failure (#8), LIKE case sensitivity (#3), asyncpg prepared statement errors (#11) + +### Phase 4: Async ARQ Worker + +**Rationale:** ARQ requires a distinct session lifecycle from FastAPI — no dependency injection, per-job session scoping via `ContextVar`. This phase is architecturally separate from store conversion because mixing ARQ session lifecycle patterns with FastAPI `Depends` patterns is a documented failure mode. It depends on `SqlAlchemyEventLog` (Group 1 of Phase 3) being complete. + +**Delivers:** +- `WorkerSettings` with `startup`, `shutdown`, `on_job_start`, `on_job_complete` hooks +- Per-job `AsyncSession` scoped to `ctx["job_id"]` via `ContextVar` +- Module-level `_EVENT_LOG` singleton replaced with context-scoped session access +- `startup` creates engine + factory only — no session is created at worker startup + +**Avoids:** ARQ worker shared session corruption (#7) + +### Phase 5: Hybrid Search and Performance Enhancements + +**Rationale:** Once the async foundation is stable and tested, the production-quality search features that PostgreSQL enables can be added. These are improvements over a working baseline, not correctness blockers — they depend on both tsvector and pgvector being in place from Phase 1 and the async `memory_store` from Phase 3. + +**Delivers:** +- Hybrid pgvector + tsvector search with Reciprocal Rank Fusion (RRF) in `memory_store._hybrid_search()` — replaces Python-side `_hybrid_merge()` with a single server-side SQL CTE +- GIN indexes on queryable JSONB columns: `agent_events.tags`, `memory_items.evidence` +- HNSW index on `memory_items.embedding` (replaces default sequential scan) +- Connection pool parameters (`pool_size`, `max_overflow`, `pool_recycle`) parameterized via env vars + +**Addresses:** Hybrid pgvector + tsvector search, GIN indexes, connection pool configuration + +### Phase 6: Data Migration Tooling and Model Refactoring + +**Rationale:** Model normalization must run against real data already on PostgreSQL, so data migration precedes constraint additions. Adding `NOT NULL` constraints to a column with nulls in migrated data will fail. This phase is post-v2.0 in scope but must be planned as part of the milestone to prevent schema debt from compounding further. + +**Delivers:** +- SQLite `PRAGMA foreign_keys = ON` + `PRAGMA integrity_check` as pre-migration gate +- pgloader migration command with FK violation report; custom Python script for re-encoding `LargeBinary` float bytes to pgvector arrays (pgloader cannot handle these) +- Alembic migrations for model normalization: `is_active` Integer to Boolean with all 5 call sites in `research_store.py` updated simultaneously, CHECK constraints on `status`/`confidence`/`pii_risk` columns, `NOT NULL DEFAULT NOW()` on all nullable `created_at` columns +- JSON helper methods removed (`get_keywords`, `set_keywords`, etc.); direct attribute access on JSON/JSONB columns + +**Avoids:** Data migration FK violations (#10), is_active integer/boolean type change (#2), big-bang normalization anti-pattern + +### Phase Ordering Rationale + +- Phase 1 before Phase 3: Alembic migrations and PostgreSQL-native schema (tsvector, pgvector, JSONB) must exist and be verified before async stores can be meaningfully tested against PG-specific features. +- Phase 2 before Phase 3: The testcontainers CI fixture must be established and confirmed working before any store ships as async — otherwise the CI green signal is unreliable for the work being done. +- Phase 3 Group 1 before Phase 4: ARQ worker depends on `SqlAlchemyEventLog` being async; convert the event log first. +- Phase 5 after Phase 3: Hybrid search requires both tsvector (Phase 1 schema + Phase 3 `memory_store` query) and pgvector (same) to be fully operational. +- Phase 6 last: Constraint additions on columns that previously accepted nulls will fail against migrated data that contains nulls. Data must be on PostgreSQL first. + +### Research Flags + +Phases likely needing deeper research or per-phase planning work: + +- **Phase 3, Group 2 (memory_store):** The most complex single store — FTS5 replacement, sqlite-vec replacement, hybrid search paths, and the most MCP tool connections. The specific relationship loading patterns and tsvector query shapes warrant a dedicated mini-plan before the group ships. +- **Phase 6 (Data migration):** The actual FK violation profile of existing SQLite production databases is unknown. Phase 6 planning should not be finalized until a representative database dump has been analyzed with `PRAGMA integrity_check` to quantify the remediation scope. + +Phases with standard patterns (skip deeper research): + +- **Phase 1:** Docker Compose PostgreSQL + Alembic async env.py are extensively documented with exact code patterns in ARCHITECTURE.md. Standard patterns apply directly. +- **Phase 2:** testcontainers Python pytest fixture is a solved, well-documented pattern with official guides. +- **Phase 4:** The ARQ + AsyncSession per-job lifecycle pattern is documented in ARCHITECTURE.md and can be implemented directly from that spec. +- **Phase 5:** Hybrid pgvector + tsvector RRF is an established production RAG pattern; implementation follows directly from FEATURES.md and ARCHITECTURE.md code samples. + +--- + +## Confidence Assessment + +| Area | Confidence | Notes | +|------|------------|-------| +| Stack | HIGH | All new packages verified on PyPI; version requirements confirmed against official changelogs and codebase inspection of `pyproject.toml` and `requirements.txt` | +| Features | HIGH | Feature set and priorities derived from direct codebase analysis — 46 models, 17 stores, 84 JSON columns, 28 migrations, 16 MCP tools counted directly, not estimated | +| Architecture | HIGH | Patterns grounded in official SQLAlchemy 2.0 async docs; phase approach confirmed against established brownfield migration guides; all integration points verified by reading source files | +| Pitfalls | HIGH | Pitfalls verified against codebase with specific file locations and line numbers confirmed; backed by official SQLAlchemy/Alembic/asyncpg sources and confirmed upstream issue tracker tickets (Alembic #1390, #1324) | + +**Overall confidence:** HIGH + +### Gaps to Address + +- **SQLAlchemy 2.1 stability:** The `>=2.0.0` pin may resolve to 2.1.x (currently at beta as of 2026-03-14), which changed `greenlet` handling and dropped Python 3.9. Validate actual resolved version in Phase 1 against the CI matrix (3.10, 3.11, 3.12). Consider pinning `>=2.0.0,<2.1.0` until 2.1 stable is released. +- **Production FK violation profile:** The actual number of orphaned rows in existing SQLite deployments is unknown. Phase 6 planning must include a pre-migration audit step before commitments are made on remediation scope. +- **pgloader in CI:** pgloader is a system package not installable via pip. Phase 6 planning must confirm whether the GitHub Actions runner has pgloader available or whether a Docker-based pgloader or custom Python script alternative is needed. +- **aiosqlite for async test fixtures:** ARCHITECTURE.md's `_coerce_to_async_url` includes a SQLite to `aiosqlite` coercion path. Confirm during Phase 2 whether `aiosqlite` is needed for any async test fixture path or whether testcontainers fully replaces it. + +--- + +## Sources + +### Primary (HIGH confidence) + +- `src/paperbot/infrastructure/stores/sqlalchemy_db.py` — confirmed `SessionProvider` sync pattern, `prepare_threshold=0`, `future=True` +- `src/paperbot/infrastructure/stores/models.py` — confirmed 46 models, `LargeBinary` embedding, 84 `Text` JSON columns, `lazy="select"` default on all relationships, `is_active: Mapped[int]` +- `src/paperbot/infrastructure/stores/memory_store.py` — confirmed `sqlite_master` queries (20+), `_ensure_fts5`, `_ensure_vec_table`, `sqlite_vec.load` +- `src/paperbot/infrastructure/stores/research_store.py` — confirmed `is_active == 1` / `== 0` in 5 locations, `func.lower().like()` search pattern +- `src/paperbot/infrastructure/stores/paper_store.py` — confirmed `.ilike()` in 4 locations +- `alembic/versions/` — confirmed 28 migration files, existing branch merge at `4c71b28a2f67` +- `src/paperbot/mcp/tools/` — confirmed 16 uses of `anyio.to_thread.run_sync` wrapping sync store calls +- `pyproject.toml` — confirmed `psycopg[binary]>=3.2.0`, `SQLAlchemy>=2.0.0`, `alembic>=1.13.0`, CI matrix 3.10/3.11/3.12 +- [SQLAlchemy 2.0 Asyncio Extension docs](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html) — `AsyncSession`, `async_sessionmaker`, `selectinload`, `MissingGreenlet` behavior +- [SQLAlchemy 2.1.0b1 release blog](https://www.sqlalchemy.org/blog/2026/01/21/sqlalchemy-2.1.0b1-released/) — `greenlet` no longer auto-installed in 2.1 +- [asyncpg PyPI 0.31.0](https://pypi.org/project/asyncpg/) — version and Python version matrix confirmed +- [pgvector-python PyPI 0.4.2](https://pypi.org/project/pgvector/) — version confirmed +- [pgloader SQLite reference](https://pgloader.readthedocs.io/en/latest/ref/sqlite.html) — FK ordering, type casting, violation handling +- [asyncpg FAQ](https://magicstack.github.io/asyncpg/current/faq.html) — prepared statement conflicts with PgBouncer + +### Secondary (MEDIUM confidence) + +- [ARQ + SQLAlchemy Done Right](https://wazaari.dev/blog/arq-sqlalchemy-done-right) — per-job session lifecycle with `on_job_start`/`on_job_complete` hooks +- [FastAPI SQLAlchemy 2.0 Modern Async Patterns](https://dev-faizan.medium.com/fastapi-sqlalchemy-2-0-modern-async-database-patterns-7879d39b6843) — session lifecycle, `expire_on_commit` +- [Alembic tsvector + JSONB migration patterns](https://berkkaraal.com/blog/2024/09/19/setup-fastapi-project-with-async-sqlalchemy-2-alembic-postgresql-and-docker/) +- [pgvector-python SQLAlchemy integration](https://deepwiki.com/pgvector/pgvector-python/3.1-sqlalchemy-integration) — `register_vector_async`, `ischema_names` pattern +- [Alembic issue #1390](https://github.com/sqlalchemy/alembic/issues/1390) — tsvector GIN index autogenerate false positive loop (confirmed upstream bug) +- [Alembic issue #1324](https://github.com/sqlalchemy/alembic/discussions/1324) — pgvector `ischema_names` fix +- [Alembic issue #697](https://github.com/sqlalchemy/alembic/issues/697) — Text to JSON migration data loss risk +- [psycopg3 vs asyncpg comparison (2026)](https://fernandoarteaga.dev/blog/psycopg-vs-asyncpg/) — performance benchmark rationale for asyncpg choice + +--- + +*Research completed: 2026-03-14* +*Ready for roadmap: yes* diff --git a/alembic/versions/0028_remove_legacy_user_defaults.py b/alembic/versions/0028_remove_legacy_user_defaults.py new file mode 100644 index 00000000..1c7607a3 --- /dev/null +++ b/alembic/versions/0028_remove_legacy_user_defaults.py @@ -0,0 +1,61 @@ +"""Remove legacy shared default user_id server defaults. + +Revision ID: 0028_remove_legacy_user_defaults +Revises: 0027_global_paper_feedback +Create Date: 2026-03-14 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0028_remove_legacy_user_defaults" +down_revision = "0027_global_paper_feedback" +branch_labels = None +depends_on = None + + +def _has_column(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = sa.inspect(bind) + if table_name not in inspector.get_table_names(): + return False + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_column("repro_context_pack", "user_id"): + with op.batch_alter_table("repro_context_pack") as batch_op: + batch_op.alter_column( + "user_id", + existing_type=sa.String(length=64), + server_default=None, + ) + + if _has_column("intelligence_events", "user_id"): + with op.batch_alter_table("intelligence_events") as batch_op: + batch_op.alter_column( + "user_id", + existing_type=sa.String(length=64), + server_default=None, + ) + + +def downgrade() -> None: + if _has_column("repro_context_pack", "user_id"): + with op.batch_alter_table("repro_context_pack") as batch_op: + batch_op.alter_column( + "user_id", + existing_type=sa.String(length=64), + server_default="default", + ) + + if _has_column("intelligence_events", "user_id"): + with op.batch_alter_table("intelligence_events") as batch_op: + batch_op.alter_column( + "user_id", + existing_type=sa.String(length=64), + server_default="default", + ) diff --git a/pyproject.toml b/pyproject.toml index 286269bd..9e9590b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ dependencies = [ "python-jose[cryptography]>=3.3.0", "bcrypt>=4.0.0", "email-validator>=2.0.0", + "mcp[fastmcp]>=1.8.0,<2.0.0; python_version >= '3.10'", ] [project.optional-dependencies] @@ -97,6 +98,9 @@ search = [ "sqlite-vec>=0.1.6", ] +[project.scripts] +paperbot = "paperbot.presentation.cli.main:run_cli" + [project.urls] Homepage = "https://github.com/jerry609/PaperBot" Repository = "https://github.com/jerry609/PaperBot" diff --git a/requirements.txt b/requirements.txt index f2752cee..1bb5d473 100644 --- a/requirements.txt +++ b/requirements.txt @@ -107,3 +107,6 @@ python-jose[cryptography]>=3.3.0 bcrypt>=4.0.0 email-validator>=2.0.0 resend>=0.7.0 + +# MCP server transport +mcp[fastmcp]>=1.8.0,<2.0.0; python_version >= "3.10" diff --git a/src/paperbot/api/auth/dependencies.py b/src/paperbot/api/auth/dependencies.py index 657513c5..c1525fcc 100644 --- a/src/paperbot/api/auth/dependencies.py +++ b/src/paperbot/api/auth/dependencies.py @@ -62,16 +62,16 @@ def get_current_user( def get_user_id( credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer), -) -> str: +) -> Optional[str]: """Return the authenticated user id as a string. - When AUTH_OPTIONAL=true, missing/invalid tokens fall back to "default" so - legacy callers keep functioning while we migrate to multi-user auth. + When AUTH_OPTIONAL=true, missing/invalid tokens return None so callers can + distinguish anonymous flows from real user-scoped operations. """ user = _resolve_user(credentials) if user is None: - return "default" + return None return str(user.id) diff --git a/src/paperbot/api/main.py b/src/paperbot/api/main.py index 4ccc429c..0dad76ce 100644 --- a/src/paperbot/api/main.py +++ b/src/paperbot/api/main.py @@ -37,10 +37,12 @@ wiki, auth, ) +from .routes import events as events_route from paperbot.api.error_handling import install_api_error_handling from paperbot.infrastructure.event_log.logging_event_log import LoggingEventLog from paperbot.infrastructure.event_log.composite_event_log import CompositeEventLog from paperbot.infrastructure.event_log.sqlalchemy_event_log import SqlAlchemyEventLog +from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog # Load repo .env deterministically so API keys match local config. # override=True is intentional to avoid picking up a different .env from another cwd. @@ -96,22 +98,33 @@ async def health_check(): app.include_router(agent_board.router, tags=["Agent Board"]) app.include_router(wiki.router, prefix="/api", tags=["Wiki"]) app.include_router(auth.router) +app.include_router(events_route.router, prefix="/api", tags=["Events"]) @app.on_event("startup") async def _startup_eventlog(): # Phase-0: create a single event log backend and store on app.state. # Per-request run_id/trace_id are generated in handlers. + # Phase-7: EventBusEventLog added as third backend for SSE fan-out delivery. + bus = EventBusEventLog() + backends = [LoggingEventLog(), bus] try: - app.state.event_log = CompositeEventLog([LoggingEventLog(), SqlAlchemyEventLog()]) + backends.insert(1, SqlAlchemyEventLog()) except Exception: - # If SQLAlchemy isn't available or DB init fails, fall back to logging only. - app.state.event_log = LoggingEventLog() + # If SQLAlchemy isn't available or DB init fails, keep SSE available via the bus. + pass + app.state.event_log = CompositeEventLog(backends) obsidian.initialize_obsidian_runtime(app) @app.on_event("shutdown") -async def _shutdown_obsidian_runtime(): +async def _shutdown_runtime(): + event_log = getattr(app.state, "event_log", None) + if event_log is not None: + try: + event_log.close() + except Exception: + pass obsidian.shutdown_obsidian_runtime(app) diff --git a/src/paperbot/api/routes/events.py b/src/paperbot/api/routes/events.py new file mode 100644 index 00000000..c56cb059 --- /dev/null +++ b/src/paperbot/api/routes/events.py @@ -0,0 +1,100 @@ +""" +SSE fan-out endpoint for the EventBus. + +GET /api/events/stream streams every event_log.append() call to all connected +SSE clients without any additional envelope wrapping — events already carry their +own AgentEventEnvelope fields (run_id, trace_id, workflow, etc.). + +Design: +- _get_bus() — locates the EventBusEventLog inside app.state.event_log +- _event_generator() — subscribe → drain queue with heartbeat → unsubscribe (finally) +- events_stream() — returns StreamingResponse using the generator + +Anti-patterns avoided (per 07-RESEARCH.md): +- No wrap_generator(): would add a second envelope layer around events that already + carry AgentEventEnvelope fields (run_id, trace_id, seq, etc.). +- No sse_response(): same concern. +""" +from __future__ import annotations + +import asyncio +import json + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from paperbot.api.streaming import SSE_HEADERS, sse_comment + +router = APIRouter(prefix="/events") + +_HEARTBEAT_SECONDS = 15.0 + + +def _get_bus(request: Request): + """ + Locate the EventBusEventLog backend inside app.state.event_log. + + The import is done inside the function to avoid a circular import at + module load time (events.py loaded during app creation, before event_log + infrastructure is wired). + + Raises RuntimeError if the bus is not registered (misconfigured startup). + """ + from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog # noqa: PLC0415 + + event_log = request.app.state.event_log + backends = getattr(event_log, "_backends", None) + if backends is not None: + for backend in backends: + if isinstance(backend, EventBusEventLog): + return backend + + # event_log itself might be the bus (useful in tests) + if isinstance(event_log, EventBusEventLog): + return event_log + + raise RuntimeError("EventBusEventLog not registered in CompositeEventLog") + + +async def _event_generator(request: Request, bus): + """ + Async generator: subscribe → yield events / heartbeats → unsubscribe. + + The try/finally guarantees bus.unsubscribe(q) is called even if the + client disconnects mid-stream (ASGI sends CancelledError) or if the + generator is garbage-collected. + """ + q = bus.subscribe() + try: + while True: + # Fast disconnect check — avoids waiting a full heartbeat cycle after drop + if await request.is_disconnected(): + break + + try: + event = await asyncio.wait_for(q.get(), timeout=_HEARTBEAT_SECONDS) + yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + except asyncio.TimeoutError: + # No event arrived within the heartbeat window — send a keepalive comment + yield sse_comment() + except asyncio.CancelledError: + # ASGI framework signals client disconnect via CancelledError + break + finally: + bus.unsubscribe(q) + + +@router.get("/stream") +async def events_stream(request: Request) -> StreamingResponse: + """ + Stream all global events to the SSE client. + + Each event is sent as a plain ``data: {...}\\n\\n`` frame. + When the queue is idle for 15 seconds a ``: keepalive\\n\\n`` comment is sent. + """ + bus = _get_bus(request) + return StreamingResponse( + _event_generator(request, bus), + media_type="text/event-stream", + headers=dict(SSE_HEADERS), + ) diff --git a/src/paperbot/api/routes/paperscool.py b/src/paperbot/api/routes/paperscool.py index e262d4a1..27095136 100644 --- a/src/paperbot/api/routes/paperscool.py +++ b/src/paperbot/api/routes/paperscool.py @@ -48,6 +48,7 @@ from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore from paperbot.infrastructure.stores.wiki_concept_store import WikiConceptStore from paperbot.infrastructure.stores.workflow_metric_store import WorkflowMetricStore +from paperbot.utils.user_identity import optional_user_identity from paperbot.utils.text_processing import extract_github_url router = APIRouter() @@ -232,7 +233,7 @@ def _schedule_document_indexing_for_report( async def _run_topic_search( *, - user_id: str, + user_id: Optional[str], queries: List[str], sources: List[str], branches: List[str], @@ -242,7 +243,7 @@ async def _run_topic_search( ) -> Dict[str, Any]: return await run_unified_topic_search( queries=queries, - user_id=user_id, + user_id=optional_user_identity(user_id), sources=sources, branches=branches, top_k_per_query=top_k_per_query, @@ -255,7 +256,7 @@ async def _run_topic_search( class PapersCoolSearchRequest(BaseModel): - user_id: str = "default" + user_id: Optional[str] = None queries: List[str] = Field(default_factory=list) sources: List[str] = Field(default_factory=lambda: ["papers_cool"]) branches: List[str] = Field(default_factory=lambda: ["arxiv", "venue"]) @@ -291,7 +292,7 @@ class PapersCoolCurateResponse(BaseModel): class DailyPaperRequest(BaseModel): - user_id: str = "default" + user_id: Optional[str] = None queries: List[str] = Field(default_factory=list) sources: List[str] = Field(default_factory=lambda: ["papers_cool"]) branches: List[str] = Field(default_factory=lambda: ["arxiv", "venue"]) diff --git a/src/paperbot/api/routes/repro_context.py b/src/paperbot/api/routes/repro_context.py index 42b9f460..bca58697 100644 --- a/src/paperbot/api/routes/repro_context.py +++ b/src/paperbot/api/routes/repro_context.py @@ -29,6 +29,7 @@ ) from paperbot.application.services.p2c.orchestrator import ExtractionOrchestrator from paperbot.infrastructure.stores.repro_context_store import SqlAlchemyReproContextStore +from paperbot.utils.user_identity import has_user_identity from paperbot.utils.logging_config import LogFiles, Logger, set_trace_id _MAX_OBSERVATION_NARRATIVE = 400 # chars stored per memory item @@ -292,7 +293,7 @@ async def _write_paper_scope_memories( observations: list, ) -> None: """Persist P2C observations as paper-scoped memory items for future reuse.""" - if user_id == "default" or not observations: + if not has_user_identity(user_id) or not observations: return try: from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore diff --git a/src/paperbot/api/routes/wiki.py b/src/paperbot/api/routes/wiki.py index a2c9cc22..4df3ef93 100644 --- a/src/paperbot/api/routes/wiki.py +++ b/src/paperbot/api/routes/wiki.py @@ -2,16 +2,16 @@ from typing import List, Optional -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import BaseModel +from paperbot.api.auth.dependencies import get_required_user_id from paperbot.application.services.wiki_concept_service import WikiConceptService from paperbot.infrastructure.stores.wiki_concept_store import WikiConceptStore router = APIRouter() _service: Optional[WikiConceptService] = None -_DEFAULT_USER_ID = "default" def _get_service() -> WikiConceptService: @@ -21,16 +21,6 @@ def _get_service() -> WikiConceptService: return _service -def _resolve_wiki_user_id(requested_user_id: Optional[str]) -> str: - user_id = str(requested_user_id or _DEFAULT_USER_ID).strip() or _DEFAULT_USER_ID - if user_id != _DEFAULT_USER_ID: - raise HTTPException( - status_code=403, - detail="cross-user wiki grounding requires authenticated user context", - ) - return _DEFAULT_USER_ID - - class WikiConceptResponse(BaseModel): id: str name: str @@ -52,15 +42,14 @@ class WikiConceptListResponse(BaseModel): @router.get("/wiki/concepts", response_model=WikiConceptListResponse) def list_wiki_concepts( - user_id: str = Query("default", description="User ID"), + user_id: str = Depends(get_required_user_id), q: str = Query("", description="Keyword query"), category: Optional[str] = Query(None, description="Category filter"), limit: int = Query(100, ge=1, le=500), ): - resolved_user_id = _resolve_wiki_user_id(user_id) service = _get_service() items = service.list_concepts( - user_id=resolved_user_id, + user_id=user_id, query=q, category=category, limit=limit, diff --git a/src/paperbot/application/services/anchor_service.py b/src/paperbot/application/services/anchor_service.py index efc3ecf3..2cd1dd69 100644 --- a/src/paperbot/application/services/anchor_service.py +++ b/src/paperbot/application/services/anchor_service.py @@ -20,6 +20,7 @@ UserAnchorScoreModel, ) from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url +from paperbot.utils.user_identity import optional_user_identity @dataclass @@ -138,11 +139,13 @@ def discover( self, *, track_id: int, - user_id: str = "default", + user_id: Optional[str] = None, limit: int = 20, window_years: int = 5, personalized: bool = True, ) -> list[dict]: + resolved_user_id = optional_user_identity(user_id) + personalized_mode = bool(personalized and resolved_user_id is not None) now_year = datetime.utcnow().year year_from = max(now_year - max(int(window_years), 1) + 1, 1970) @@ -165,11 +168,15 @@ def discover( max_paper_count = max(x.paper_count for x in aggregates) or 1 max_citation_sum = max(x.citation_sum for x in aggregates) or 1 network_map = self._build_network_map(session, year_from=year_from, now_year=now_year) - action_map = self.list_user_anchor_actions( - user_id=user_id, - track_id=int(track_id), - author_ids=[int(item.author.id) for item in aggregates], - session=session, + action_map = ( + self.list_user_anchor_actions( + user_id=resolved_user_id, + track_id=int(track_id), + author_ids=[int(item.author.id) for item in aggregates], + session=session, + ) + if resolved_user_id is not None + else {} ) payload: list[dict] = [] @@ -205,12 +212,12 @@ def discover( paper_ids = [int(p.id) for p in author_papers if p.id is not None] feedback_rows = [] - if paper_ids: + if paper_ids and resolved_user_id is not None: feedback_rows = ( session.execute( select(PaperFeedbackModel) .where(PaperFeedbackModel.track_id == int(track_id)) - .where(PaperFeedbackModel.user_id == user_id) + .where(PaperFeedbackModel.user_id == resolved_user_id) .where( or_( PaperFeedbackModel.canonical_paper_id.in_(paper_ids), @@ -257,7 +264,7 @@ def discover( citation_map=citation_map, max_citation_sum=max_citation_sum, ) - personalization_score = feedback_signal if personalized else 0.0 + personalization_score = feedback_signal if personalized_mode else 0.0 anchor_score = ( 0.45 * intrinsic_score @@ -321,10 +328,10 @@ def discover( } ) - if personalized: + if personalized_mode and resolved_user_id is not None: self._upsert_user_anchor_score( session, - user_id=user_id, + user_id=resolved_user_id, track_id=int(track_id), author_id=int(item.author.id), score=float(round(anchor_score, 6)), diff --git a/src/paperbot/application/services/enrichment_pipeline.py b/src/paperbot/application/services/enrichment_pipeline.py index a1b4e5dd..72f0372d 100644 --- a/src/paperbot/application/services/enrichment_pipeline.py +++ b/src/paperbot/application/services/enrichment_pipeline.py @@ -18,7 +18,7 @@ class EnrichmentContext: """Shared context passed through the pipeline.""" query: str = "" - user_id: str = "default" + user_id: Optional[str] = None track_id: Optional[int] = None extra: Dict[str, Any] = field(default_factory=dict) diff --git a/src/paperbot/application/services/llm_service.py b/src/paperbot/application/services/llm_service.py index e2a38aa4..705b3fbc 100644 --- a/src/paperbot/application/services/llm_service.py +++ b/src/paperbot/application/services/llm_service.py @@ -141,6 +141,7 @@ def assess_relevance(self, *, paper: Dict[str, Any], query: str) -> Dict[str, An if parsed is None: return { "score": _overlap_relevance_score(query=query, paper=paper), + "fallback": True, "reason": "Fallback score from token overlap (LLM output unavailable).", } diff --git a/src/paperbot/application/services/p2c/context_bridge.py b/src/paperbot/application/services/p2c/context_bridge.py index ad2fc34b..34325b6e 100644 --- a/src/paperbot/application/services/p2c/context_bridge.py +++ b/src/paperbot/application/services/p2c/context_bridge.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Optional from paperbot.application.services.p2c.models import NormalizedInput +from paperbot.utils.user_identity import has_user_identity logger = logging.getLogger(__name__) @@ -44,7 +45,7 @@ async def enrich( self, normalized_input: NormalizedInput, *, - user_id: str, + user_id: Optional[str], track_id: Optional[int] = None, paper_id: Optional[str] = None, ) -> NormalizedInput: @@ -57,7 +58,7 @@ async def enrich( Returns the same NormalizedInput object (mutated in place). """ - if user_id == "default": + if not has_user_identity(user_id): return normalized_input engine = self._get_engine() diff --git a/src/paperbot/application/services/p2c/models.py b/src/paperbot/application/services/p2c/models.py index f9a11984..cdcb518c 100644 --- a/src/paperbot/application/services/p2c/models.py +++ b/src/paperbot/application/services/p2c/models.py @@ -35,7 +35,7 @@ class GenerateContextRequest: """P2C pipeline entry request.""" paper_id: str - user_id: str = "default" + user_id: Optional[str] = None project_id: Optional[str] = None track_id: Optional[int] = None depth: Depth = "standard" diff --git a/src/paperbot/application/workflows/analysis/paper_judge.py b/src/paperbot/application/workflows/analysis/paper_judge.py index 513d904c..3256f540 100644 --- a/src/paperbot/application/workflows/analysis/paper_judge.py +++ b/src/paperbot/application/workflows/analysis/paper_judge.py @@ -82,7 +82,10 @@ def judge_single(self, *, paper: Dict[str, Any], query: str) -> PaperJudgment: temperature=0.1, ) payload = self._parse_payload(raw) - provider_info = self._llm.describe_task_provider("analysis") + if payload: + provider_info = self._llm.describe_task_provider("analysis") + else: + provider_info = {"provider_name": "", "model_name": "", "cost_tier": 0} return self._to_judgment(payload=payload, provider_info=provider_info) def judge_with_calibration( diff --git a/src/paperbot/application/workflows/unified_topic_search.py b/src/paperbot/application/workflows/unified_topic_search.py index 6acc4948..58719d63 100644 --- a/src/paperbot/application/workflows/unified_topic_search.py +++ b/src/paperbot/application/workflows/unified_topic_search.py @@ -11,6 +11,7 @@ search_candidate_papers, ) from paperbot.application.services.paper_search_service import PaperSearchService, SearchResult +from paperbot.utils.user_identity import has_user_identity, optional_user_identity if TYPE_CHECKING: from paperbot.application.services.workflow_query_grounder import ( @@ -224,7 +225,7 @@ def _merge_item(target: Dict[str, Any], incoming: Dict[str, Any]) -> None: async def run_unified_topic_search( *, queries: Sequence[str], - user_id: str = "default", + user_id: Optional[str] = None, branches: Sequence[str] = ("arxiv", "venue"), sources: Sequence[str] = ("papers_cool",), top_k_per_query: int = 5, @@ -235,6 +236,7 @@ async def run_unified_topic_search( persist: bool = False, ) -> Dict[str, Any]: normalized_sources = normalize_topic_sources(sources) + resolved_user_id = optional_user_identity(user_id) query_specs: List[Dict[str, Any]] = [] seen_queries: set[str] = set() @@ -243,8 +245,8 @@ async def run_unified_topic_search( if not raw_query: continue grounded = ( - query_grounder.ground_query(user_id=user_id, query=raw_query) - if query_grounder is not None + query_grounder.ground_query(user_id=resolved_user_id, query=raw_query) + if query_grounder is not None and has_user_identity(resolved_user_id) else _default_grounded_query(raw_query) ) canonical_query = str( diff --git a/src/paperbot/context_engine/engine.py b/src/paperbot/context_engine/engine.py index 0c0d004e..528a5cb7 100644 --- a/src/paperbot/context_engine/engine.py +++ b/src/paperbot/context_engine/engine.py @@ -808,8 +808,9 @@ async def build_context_pack( grounded_query: Optional["GroundedQuery"] = None resolved_query = query routing_query = query - if self.query_grounder is not None: - grounded_query = self.query_grounder.ground_query(user_id=user_id, query=query) + query_grounder = getattr(self, "query_grounder", None) + if query_grounder is not None: + grounded_query = query_grounder.ground_query(user_id=user_id, query=query) if grounded_query.canonical_query: resolved_query = grounded_query.canonical_query routing_query = ( @@ -1200,7 +1201,8 @@ async def build_context_pack( } evidence_hits: List[Dict[str, Any]] = [] - if self.evidence_retriever is not None and self.config.evidence_limit > 0: + evidence_retriever = getattr(self, "evidence_retriever", None) + if evidence_retriever is not None and self.config.evidence_limit > 0: indexed_paper_ids: List[int] = [] for paper in papers: candidate_ids = ( @@ -1219,7 +1221,7 @@ async def build_context_pack( if indexed_paper_ids: try: - raw_hits = self.evidence_retriever.retrieve_evidence( + raw_hits = evidence_retriever.retrieve_evidence( query=merged_query, paper_ids=indexed_paper_ids, limit=int(self.config.evidence_limit), diff --git a/src/paperbot/infrastructure/event_log/event_bus_event_log.py b/src/paperbot/infrastructure/event_log/event_bus_event_log.py new file mode 100644 index 00000000..0ec552df --- /dev/null +++ b/src/paperbot/infrastructure/event_log/event_bus_event_log.py @@ -0,0 +1,156 @@ +""" +EventBusEventLog — asyncio fan-out ring buffer backend for SSE delivery. + +Purpose (EVNT-04): + Intercepts every event_log.append() call and delivers the event to all + connected SSE client queues via a non-blocking put_nowait() fan-out. + Plan 07-02 wires this into the FastAPI /api/events SSE endpoint. + +Design decisions (locked in 07-CONTEXT.md): + - Ring buffer: collections.deque(maxlen=ring_buffer_size) — default 200 + - Client queue: asyncio.Queue(maxsize=client_queue_size) — default 256 + - Backpressure: drop-oldest (get_nowait then put_nowait) — NEVER block producer + - No filtering: all events go to all subscriber queues + - Serialisation: AgentEventEnvelope serialized via .to_dict() once in append(); + fan-out distributes the already-serialised dict + +Thread-safety note: + append() is called from the async event loop only (uvicorn single-process). + put_nowait() is safe; no thread bridging needed for current architecture. + _fan_out() uses list(self._queues) snapshot to guard against concurrent + unsubscribe() calls inside the same event-loop tick. +""" + +from __future__ import annotations + +import asyncio +from collections import deque +from copy import deepcopy +from typing import Iterable, Set, Union + +from paperbot.application.collaboration.message_schema import AgentEventEnvelope + + +class EventBusEventLog: + """ + In-process SSE fan-out backend implementing EventLogPort. + + Usage (Plan 07-02 will wire this into the DI container):: + + bus = EventBusEventLog() + composite = CompositeEventLog([existing_backend, bus]) + + # SSE handler + q = bus.subscribe() + try: + while True: + event = await q.get() + yield f"data: {json.dumps(event)}\\n\\n" + finally: + bus.unsubscribe(q) + """ + + def __init__( + self, + *, + ring_buffer_size: int = 200, + client_queue_size: int = 256, + ) -> None: + self._ring: deque[dict] = deque(maxlen=ring_buffer_size) + self._client_queue_size = client_queue_size + self._queues: Set[asyncio.Queue] = set() + + # ------------------------------------------------------------------ + # EventLogPort interface + # ------------------------------------------------------------------ + + def append(self, event: Union[AgentEventEnvelope, dict]) -> None: + """ + Serialize the event once, store in ring buffer, and fan out to all queues. + + This is a synchronous def — no await, no blocking. + """ + if isinstance(event, AgentEventEnvelope): + data = event.to_dict() + else: + # Already a dict; snapshot it so caller-side mutations cannot leak into the bus. + data = deepcopy(event) + + # Store in ring buffer (oldest auto-evicted when deque is full) + self._ring.append(deepcopy(data)) + + # Fan out to all subscriber queues (non-blocking) + self._fan_out(data) + + def stream(self, run_id: str) -> Iterable[dict]: + """ + Bus does not support run_id-based historical replay. + + Returns an empty iterator to satisfy the EventLogPort protocol. + Use subscribe() / unsubscribe() for real-time delivery. + """ + return iter(()) + + def close(self) -> None: + """Disconnect all subscribers (drop references, let queues GC).""" + self._queues.clear() + + # ------------------------------------------------------------------ + # Fan-out helpers (public so tests can inspect _queues) + # ------------------------------------------------------------------ + + def subscribe(self) -> asyncio.Queue: + """ + Register a new subscriber queue and pre-load it with ring buffer contents. + + The catch-up burst lets SSE clients see recent events immediately on connect. + Returns the asyncio.Queue; the caller is responsible for calling unsubscribe() + when the connection closes. + """ + q: asyncio.Queue = asyncio.Queue(maxsize=self._client_queue_size) + self._queues.add(q) + + # Register first, then replay a snapshot. This favors duplicate delivery + # over silent gaps if an append lands during subscribe(). + ring_snapshot = list(self._ring) + for event in ring_snapshot: + self._put_nowait_drop_oldest(q, deepcopy(event)) + return q + + def unsubscribe(self, q: asyncio.Queue) -> None: + """Remove queue from fan-out set (idempotent).""" + self._queues.discard(q) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _fan_out(self, data: dict) -> None: + """ + Deliver data to all subscriber queues using a snapshot iteration. + + Snapshot (list(...)) protects against concurrent unsubscribe() calls + that modify _queues while we iterate. + """ + for q in list(self._queues): + self._put_nowait_drop_oldest(q, deepcopy(data)) + + @staticmethod + def _put_nowait_drop_oldest(q: asyncio.Queue, data: dict) -> None: + """ + Non-blocking put with drop-oldest backpressure. + + If the queue is full, evict the oldest item then insert the new one. + This guarantees the producer never blocks and the client always sees + the most recent events. + """ + if q.full(): + try: + q.get_nowait() # Discard oldest + except asyncio.QueueEmpty: + pass # Race is harmless — queue emptied between full() and get_nowait() + try: + q.put_nowait(data) + except asyncio.QueueFull: + # Extremely unlikely race; silently drop to protect producer + pass diff --git a/src/paperbot/infrastructure/obsidian/sync.py b/src/paperbot/infrastructure/obsidian/sync.py index 23655029..eb0abaa6 100644 --- a/src/paperbot/infrastructure/obsidian/sync.py +++ b/src/paperbot/infrastructure/obsidian/sync.py @@ -10,6 +10,7 @@ from paperbot.application.ports.event_log_port import EventLogPort from paperbot.application.ports.memory_port import MemoryPort from paperbot.memory.schema import MemoryCandidate +from paperbot.utils.user_identity import optional_user_identity from .conflict import ObsidianManagedConflict, detect_managed_conflict from .parser import ParsedObsidianNote, parse_note_text, user_sections_hash @@ -341,17 +342,22 @@ def _sync_user_notes(self, *, note: ParsedObsidianNote, path: str) -> Tuple[int, ) return created, skipped - def _write_memories(self, *, user_id: str, memories: List[MemoryCandidate]) -> Tuple[int, int]: + def _write_memories( + self, *, user_id: Optional[str], memories: List[MemoryCandidate] + ) -> Tuple[int, int]: + resolved_user_id = optional_user_identity(user_id) + if resolved_user_id is None: + return 0, 0 created, skipped, _ = self._memory_store.add_memories( - user_id=user_id, + user_id=resolved_user_id, memories=memories, actor_id="obsidian-sync", ) return created, skipped - def _scope_for_note(self, note: ParsedObsidianNote) -> tuple[str, str, Optional[str]]: + def _scope_for_note(self, note: ParsedObsidianNote) -> tuple[Optional[str], str, Optional[str]]: frontmatter = note.frontmatter - user_id = _coerce_optional_string(frontmatter.get("user_id")) or "default" + user_id = optional_user_identity(frontmatter.get("user_id")) paperbot_type = str(frontmatter.get("paperbot_type") or "").strip().lower() if paperbot_type == "track": scope_id = _coerce_optional_string(frontmatter.get("track_id")) or ( diff --git a/src/paperbot/infrastructure/services/intelligence_radar_service.py b/src/paperbot/infrastructure/services/intelligence_radar_service.py index ec858e3b..5dfcf219 100644 --- a/src/paperbot/infrastructure/services/intelligence_radar_service.py +++ b/src/paperbot/infrastructure/services/intelligence_radar_service.py @@ -24,6 +24,7 @@ from paperbot.infrastructure.stores.models import PaperRepoModel from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url +from paperbot.utils.user_identity import require_user_identity ATOM_NS = {"atom": "http://www.w3.org/2005/Atom"} @@ -100,7 +101,7 @@ def __init__(self, db_url: Optional[str] = None): def list_feed( self, *, - user_id: str = "default", + user_id: str, limit: int = 8, source: Optional[str] = None, keyword: Optional[str] = None, @@ -108,8 +109,13 @@ def list_feed( sort_by: str = "score", sort_order: str = "desc", ) -> List[Dict[str, Any]]: + resolved_user_id = require_user_identity(user_id) candidate_limit = max(int(limit), 50) - rows = self._store.list_events(user_id=user_id, limit=candidate_limit, max_age_days=14) + rows = self._store.list_events( + user_id=resolved_user_id, + limit=candidate_limit, + max_age_days=14, + ) source_filter = str(source or "").strip().lower() keyword_filter = str(keyword or "").strip().lower() repo_filter = str(repo or "").strip().lower() @@ -137,22 +143,29 @@ def list_feed( filtered_rows.sort(key=lambda row: _signal_sort_value(row, sort_by=sort_by), reverse=reverse) return filtered_rows[: max(1, int(limit))] - def needs_refresh(self, *, user_id: str = "default", max_age_minutes: int = 45) -> bool: - latest = _parse_datetime(self._store.latest_detected_at(user_id=user_id)) + def needs_refresh(self, *, user_id: str, max_age_minutes: int = 45) -> bool: + resolved_user_id = require_user_identity(user_id) + latest = _parse_datetime(self._store.latest_detected_at(user_id=resolved_user_id)) if latest is None: return True return latest <= _utcnow() - timedelta(minutes=max(5, int(max_age_minutes))) - def latest_refresh(self, *, user_id: str = "default") -> Optional[str]: - latest = _parse_datetime(self._store.latest_detected_at(user_id=user_id)) + def latest_refresh(self, *, user_id: str) -> Optional[str]: + resolved_user_id = require_user_identity(user_id) + latest = _parse_datetime(self._store.latest_detected_at(user_id=resolved_user_id)) return latest.isoformat() if latest else None - def build_profile(self, *, user_id: str = "default") -> RadarProfile: + def build_profile(self, *, user_id: str) -> RadarProfile: + resolved_user_id = require_user_identity(user_id) keywords: List[str] = [] scholar_names: List[str] = [] try: - tracks = self._research_store.list_tracks(user_id=user_id, include_archived=False, limit=12) + tracks = self._research_store.list_tracks( + user_id=resolved_user_id, + include_archived=False, + limit=12, + ) except Exception: tracks = [] @@ -204,21 +217,26 @@ def build_profile(self, *, user_id: str = "default") -> RadarProfile: subreddits=_dedupe_preserve_order(subreddits)[:8], ) - def refresh(self, *, user_id: str = "default") -> Dict[str, Any]: - profile = self.build_profile(user_id=user_id) + def refresh(self, *, user_id: str) -> Dict[str, Any]: + resolved_user_id = require_user_identity(user_id) + profile = self.build_profile(user_id=resolved_user_id) detected_at = _utcnow() events: List[Dict[str, Any]] = [] - events.extend(self._collect_reddit_events(user_id=user_id, profile=profile)) - events.extend(self._collect_reddit_comment_events(user_id=user_id, profile=profile)) - events.extend(self._collect_github_events(user_id=user_id, profile=profile)) - events.extend(self._collect_github_issue_events(user_id=user_id, profile=profile)) - events.extend(self._collect_hf_events(user_id=user_id, profile=profile)) - events.extend(self._collect_x_events(user_id=user_id, profile=profile)) + events.extend(self._collect_reddit_events(user_id=resolved_user_id, profile=profile)) + events.extend( + self._collect_reddit_comment_events(user_id=resolved_user_id, profile=profile) + ) + events.extend(self._collect_github_events(user_id=resolved_user_id, profile=profile)) + events.extend( + self._collect_github_issue_events(user_id=resolved_user_id, profile=profile) + ) + events.extend(self._collect_hf_events(user_id=resolved_user_id, profile=profile)) + events.extend(self._collect_x_events(user_id=resolved_user_id, profile=profile)) persisted: List[Dict[str, Any]] = [] for event in events: row = self._store.upsert_event( - user_id=user_id, + user_id=resolved_user_id, external_id=event["external_id"], source=event["source"], source_label=event["source_label"], diff --git a/src/paperbot/infrastructure/stores/models.py b/src/paperbot/infrastructure/stores/models.py index fc79e697..a7351322 100644 --- a/src/paperbot/infrastructure/stores/models.py +++ b/src/paperbot/infrastructure/stores/models.py @@ -1295,7 +1295,7 @@ class ReproContextPackModel(Base): __tablename__ = "repro_context_pack" id: Mapped[str] = mapped_column(String(64), primary_key=True) # "ctxp_{uuid}" - user_id: Mapped[str] = mapped_column(String(64), nullable=False, default="default", index=True) + user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) paper_id: Mapped[str] = mapped_column(String(256), nullable=False, index=True) paper_title: Mapped[Optional[str]] = mapped_column(Text, nullable=True) @@ -1427,7 +1427,7 @@ class ReproCodeExperienceModel(Base): ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default="default") + user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) pack_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) paper_id: Mapped[Optional[str]] = mapped_column(String(256), nullable=True, index=True) # pattern_type: success_pattern | failure_reason | verified_structure @@ -1447,7 +1447,7 @@ class IntelligenceEventModel(Base): ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True, default="default") + user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) external_id: Mapped[str] = mapped_column(String(255), nullable=False) source: Mapped[str] = mapped_column(String(32), default="unknown", index=True) diff --git a/src/paperbot/infrastructure/stores/repro_experience_store.py b/src/paperbot/infrastructure/stores/repro_experience_store.py index 08245fa1..5b04db9e 100644 --- a/src/paperbot/infrastructure/stores/repro_experience_store.py +++ b/src/paperbot/infrastructure/stores/repro_experience_store.py @@ -9,6 +9,7 @@ from paperbot.infrastructure.stores.models import Base, ReproCodeExperienceModel from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url +from paperbot.utils.user_identity import require_user_identity _VALID_TYPES = {"success_pattern", "failure_reason", "verified_structure"} @@ -25,7 +26,7 @@ def __init__(self, db_url: Optional[str] = None, *, auto_create_schema: bool = T def add( self, *, - user_id: str = "default", + user_id: str, pattern_type: str, content: str, paper_id: Optional[str] = None, @@ -38,9 +39,10 @@ def add( normalized_content = (content or "").strip() if not normalized_content: raise ValueError("content must not be empty") + resolved_user_id = require_user_identity(user_id) now = datetime.now(timezone.utc) row = ReproCodeExperienceModel( - user_id=(user_id or "default").strip() or "default", + user_id=resolved_user_id, pack_id=pack_id, paper_id=paper_id, pattern_type=pattern_type, @@ -85,15 +87,16 @@ def get_by_paper_id( self, paper_id: str, *, - user_id: str = "default", + user_id: str, pattern_type: Optional[str] = None, limit: int = 50, ) -> List[Dict[str, Any]]: """Retrieve experiences for a specific paper, newest first.""" + resolved_user_id = require_user_identity(user_id) with self._provider.session() as session: stmt = ( select(ReproCodeExperienceModel) - .where(ReproCodeExperienceModel.user_id == ((user_id or "default").strip() or "default")) + .where(ReproCodeExperienceModel.user_id == resolved_user_id) .where(ReproCodeExperienceModel.paper_id == paper_id) ) if pattern_type: @@ -106,15 +109,16 @@ def get_by_pack_id( self, pack_id: str, *, - user_id: str = "default", + user_id: str, pattern_type: Optional[str] = None, limit: int = 50, ) -> List[Dict[str, Any]]: """Retrieve experiences for a specific P2C pack, newest first.""" + resolved_user_id = require_user_identity(user_id) with self._provider.session() as session: stmt = ( select(ReproCodeExperienceModel) - .where(ReproCodeExperienceModel.user_id == ((user_id or "default").strip() or "default")) + .where(ReproCodeExperienceModel.user_id == resolved_user_id) .where(ReproCodeExperienceModel.pack_id == pack_id) ) if pattern_type: diff --git a/src/paperbot/infrastructure/stores/research_store.py b/src/paperbot/infrastructure/stores/research_store.py index 2bcc966e..fb638083 100644 --- a/src/paperbot/infrastructure/stores/research_store.py +++ b/src/paperbot/infrastructure/stores/research_store.py @@ -29,6 +29,7 @@ ResearchTrackModel, ) from paperbot.infrastructure.stores.sqlalchemy_db import SessionProvider, get_db_url +from paperbot.utils.user_identity import require_user_identity from paperbot.utils.logging_config import LogFiles, Logger @@ -1564,9 +1565,8 @@ def list_paper_repos(self, *, paper_id: str) -> Optional[List[Dict[str, Any]]]: ) return [self._repo_to_dict(row) for row in rows] - def get_paper_detail( - self, *, paper_id: str, user_id: str = "default" - ) -> Optional[Dict[str, Any]]: + def get_paper_detail(self, *, paper_id: str, user_id: str) -> Optional[Dict[str, Any]]: + resolved_user_id = require_user_identity(user_id) with self._provider.session() as session: paper_ref_id = self._resolve_paper_ref_id( session=session, @@ -1584,7 +1584,7 @@ def get_paper_detail( reading_status = session.execute( select(PaperReadingStatusModel).where( - PaperReadingStatusModel.user_id == user_id, + PaperReadingStatusModel.user_id == resolved_user_id, PaperReadingStatusModel.paper_id == int(paper_ref_id), ) ).scalar_one_or_none() @@ -1603,7 +1603,7 @@ def get_paper_detail( session.execute( select(PaperFeedbackModel) .where( - PaperFeedbackModel.user_id == user_id, + PaperFeedbackModel.user_id == resolved_user_id, PaperFeedbackModel.paper_ref_id == int(paper_ref_id), ) .order_by(desc(PaperFeedbackModel.ts), desc(PaperFeedbackModel.id)) diff --git a/src/paperbot/mcp/__init__.py b/src/paperbot/mcp/__init__.py new file mode 100644 index 00000000..a4b81a72 --- /dev/null +++ b/src/paperbot/mcp/__init__.py @@ -0,0 +1 @@ +"""PaperBot MCP server package.""" diff --git a/src/paperbot/mcp/resources/__init__.py b/src/paperbot/mcp/resources/__init__.py new file mode 100644 index 00000000..773a3b02 --- /dev/null +++ b/src/paperbot/mcp/resources/__init__.py @@ -0,0 +1 @@ +"""MCP resource modules for PaperBot paperbot:// URI scheme.""" diff --git a/src/paperbot/mcp/resources/scholars.py b/src/paperbot/mcp/resources/scholars.py new file mode 100644 index 00000000..daef44f5 --- /dev/null +++ b/src/paperbot/mcp/resources/scholars.py @@ -0,0 +1,58 @@ +"""scholars MCP resource wrapping SubscriptionService. + +Exposes paperbot://scholars as a read-only JSON resource. +Returns the list of tracked scholars from config/scholar_subscriptions.yaml. +""" + +from __future__ import annotations + +import json +import logging + +import anyio + +logger = logging.getLogger(__name__) + +# Module-level service reference for test injection only. +# Set to None by default; tests set it to a fake service. +# Production code instantiates a fresh SubscriptionService() each call for fresh config reads. +_service = None + + +async def _scholars_impl() -> str: + """Return JSON list of tracked scholars. + + Returns: + JSON string with list of scholar dicts (name, semantic_scholar_id, keywords, ...), + or JSON error object with empty scholars list if config file is missing. + """ + # Use injected service for tests; otherwise instantiate fresh for each call + # to ensure we always read the latest config file. + if _service is not None: + service = _service + else: + from paperbot.infrastructure.services.subscription_service import SubscriptionService + + service = SubscriptionService() + + try: + scholars = await anyio.to_thread.run_sync(service.get_scholar_configs) + return json.dumps(scholars) + except FileNotFoundError: + return json.dumps({"error": "Scholar config not found", "scholars": []}) + except ValueError as exc: + return json.dumps({"error": str(exc), "scholars": []}) + + +def register(mcp) -> None: + """Register the scholars resource on the given FastMCP instance.""" + + @mcp.resource("paperbot://scholars", mime_type="application/json") + async def scholars() -> str: + """Return the list of PaperBot tracked scholars. + + Returns scholar configurations including name, semantic_scholar_id, and + keyword interests. Useful for understanding which researchers PaperBot + is monitoring. Returns error JSON if config file is missing. + """ + return await _scholars_impl() diff --git a/src/paperbot/mcp/resources/track_memory.py b/src/paperbot/mcp/resources/track_memory.py new file mode 100644 index 00000000..487232cf --- /dev/null +++ b/src/paperbot/mcp/resources/track_memory.py @@ -0,0 +1,80 @@ +"""track_memory MCP resource wrapping SqlAlchemyMemoryStore. + +Exposes paperbot://track/{track_id}/memory as a read-only JSON resource. +Returns memories scoped to a specific track. +""" + +from __future__ import annotations + +import json +import logging + +import anyio + +from paperbot.utils.user_identity import require_user_identity + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the memory store (can be overridden in tests) +_store = None + + +def _get_store(): + """Construct SqlAlchemyMemoryStore on first call (lazy singleton).""" + global _store + if _store is None: + from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore + + _store = SqlAlchemyMemoryStore() + return _store + + +async def _track_memory_impl(user_id: str, track_id: str) -> str: + """Return JSON list of memories scoped to the given track. + + Args: + track_id: Track identifier as a string (will be cast to int). + + Returns: + JSON string with list of memory dicts, or JSON error object. + """ + try: + tid = int(track_id) + except (ValueError, TypeError): + return json.dumps({"error": f"Invalid track_id: {track_id!r}. Must be an integer."}) + + resolved_user_id = require_user_identity(user_id) + try: + store = _get_store() + memories = await anyio.to_thread.run_sync( + lambda: store.list_memories( + user_id=resolved_user_id, + scope_type="track", + scope_id=str(tid), + limit=100, + ) + ) + except Exception: + logger.exception( + "track_memory resource failed for user_id=%s track_id=%s", resolved_user_id, tid + ) + return json.dumps({"error": "failed to list memories", "track_id": str(tid)}) + + return json.dumps(memories) + + +def register(mcp) -> None: + """Register the track_memory resource on the given FastMCP instance.""" + + @mcp.resource( + "paperbot://users/{user_id}/tracks/{track_id}/memory", + mime_type="application/json", + ) + async def track_memory(user_id: str, track_id: str) -> str: + """Return memories scoped to a PaperBot research track. + + Returns approved, non-expired memory entries (notes, hypotheses, decisions, etc.) + that were recorded in the context of this track. Useful for retrieving persistent + agent observations and research findings. + """ + return await _track_memory_impl(user_id, track_id) diff --git a/src/paperbot/mcp/resources/track_metadata.py b/src/paperbot/mcp/resources/track_metadata.py new file mode 100644 index 00000000..80771ecc --- /dev/null +++ b/src/paperbot/mcp/resources/track_metadata.py @@ -0,0 +1,68 @@ +"""track_metadata MCP resource wrapping SqlAlchemyResearchStore. + +Exposes paperbot://track/{track_id} as a read-only JSON resource. +Returns track metadata including name, description, keywords, venues, methods. +""" + +from __future__ import annotations + +import json +import logging + +import anyio + +from paperbot.utils.user_identity import require_user_identity + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the research store (can be overridden in tests) +_store = None + + +def _get_store(): + """Construct SqlAlchemyResearchStore on first call (lazy singleton).""" + global _store + if _store is None: + from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + + _store = SqlAlchemyResearchStore() + return _store + + +async def _track_metadata_impl(user_id: str, track_id: str) -> str: + """Return JSON metadata for the given track. + + Args: + track_id: Track identifier as a string (will be cast to int). + + Returns: + JSON string with track fields, or JSON error object. + """ + try: + tid = int(track_id) + except (ValueError, TypeError): + return json.dumps({"error": f"Invalid track_id: {track_id!r}. Must be an integer."}) + + resolved_user_id = require_user_identity(user_id) + store = _get_store() + track = await anyio.to_thread.run_sync( + lambda: store.get_track(user_id=resolved_user_id, track_id=tid) + ) + + if track is None or track.get("archived_at") is not None: + return json.dumps({"error": f"Track {tid} not found."}) + + return json.dumps(track) + + +def register(mcp) -> None: + """Register the track_metadata resource on the given FastMCP instance.""" + + @mcp.resource("paperbot://users/{user_id}/tracks/{track_id}", mime_type="application/json") + async def track_metadata(user_id: str, track_id: str) -> str: + """Return metadata for a PaperBot research track. + + Provides track name, description, keywords, venues, methods, and status. + Use this to understand what a track monitors before fetching its papers. + """ + return await _track_metadata_impl(user_id, track_id) diff --git a/src/paperbot/mcp/resources/track_papers.py b/src/paperbot/mcp/resources/track_papers.py new file mode 100644 index 00000000..01eceb9e --- /dev/null +++ b/src/paperbot/mcp/resources/track_papers.py @@ -0,0 +1,75 @@ +"""track_papers MCP resource wrapping SqlAlchemyResearchStore. + +Exposes paperbot://track/{track_id}/papers as a read-only JSON resource. +Returns the list of papers in a track's feed. +""" + +from __future__ import annotations + +import json +import logging + +import anyio + +from paperbot.utils.user_identity import require_user_identity + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the research store (can be overridden in tests) +_store = None + + +def _get_store(): + """Construct SqlAlchemyResearchStore on first call (lazy singleton).""" + global _store + if _store is None: + from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + + _store = SqlAlchemyResearchStore() + return _store + + +async def _track_papers_impl(user_id: str, track_id: str) -> str: + """Return JSON list of papers in the given track's feed. + + Args: + track_id: Track identifier as a string (will be cast to int). + + Returns: + JSON string with items list and total count, or JSON error object. + """ + try: + tid = int(track_id) + except (ValueError, TypeError): + return json.dumps({"error": f"Invalid track_id: {track_id!r}. Must be an integer."}) + + resolved_user_id = require_user_identity(user_id) + store = _get_store() + track = await anyio.to_thread.run_sync( + lambda: store.get_track(user_id=resolved_user_id, track_id=tid) + ) + if track is None or track.get("archived_at") is not None: + return json.dumps({"error": f"Track {tid} not found."}) + + feed = await anyio.to_thread.run_sync( + lambda: store.list_track_feed(user_id=resolved_user_id, track_id=tid, limit=50) + ) + + return json.dumps(feed) + + +def register(mcp) -> None: + """Register the track_papers resource on the given FastMCP instance.""" + + @mcp.resource( + "paperbot://users/{user_id}/tracks/{track_id}/papers", + mime_type="application/json", + ) + async def track_papers(user_id: str, track_id: str) -> str: + """Return papers in a PaperBot research track's feed. + + Returns up to 50 most recent papers with metadata including title, authors, + abstract, arxiv_id, and relevance scores. Use track_metadata first to verify + the track exists. + """ + return await _track_papers_impl(user_id, track_id) diff --git a/src/paperbot/mcp/serve.py b/src/paperbot/mcp/serve.py new file mode 100644 index 00000000..08626a05 --- /dev/null +++ b/src/paperbot/mcp/serve.py @@ -0,0 +1,80 @@ +"""Transport dispatch for the PaperBot MCP server. + +Provides two entry-points used by the CLI: + + run_stdio() -- stdio transport (Claude Desktop / Claude Code) + run_http() -- Streamable HTTP transport (remote agents) + +Example ``claude_desktop_config.json`` entry:: + + { + "mcpServers": { + "paperbot": { + "command": "paperbot", + "args": ["mcp", "serve", "--stdio"] + } + } + } +""" + +from __future__ import annotations + +import logging +import sys +from typing import Optional + + +def _get_mcp(): + """Return the mcp singleton (or None if mcp package not installed).""" + from paperbot.mcp import server as _server_mod + + return _server_mod.mcp + + +def run_stdio() -> None: + """Start the MCP server on stdio transport. + + All logging is redirected to stderr so that stdio remains clean for + the MCP protocol framing (zero bytes on stdout from this process). + + Raises SystemExit(1) if the mcp package is not installed. + """ + logging.basicConfig( + stream=sys.stderr, + level=logging.WARNING, + format="%(levelname)s %(name)s: %(message)s", + ) + + mcp = _get_mcp() + if mcp is None: + print( + "Error: mcp package is not installed. " + "Install it with: pip install 'mcp[fastmcp]>=1.8.0,<2.0.0'", + file=sys.stderr, + ) + sys.exit(1) + + mcp.run(transport="stdio") + + +def run_http(host: str = "127.0.0.1", port: int = 8001) -> None: + """Start the MCP server on Streamable HTTP transport. + + Default port is 8001 to avoid conflicting with the FastAPI server (8000). + + Args: + host: Bind address (default ``127.0.0.1``). + port: Listen port (default ``8001``). + + Raises SystemExit(1) if the mcp package is not installed. + """ + mcp = _get_mcp() + if mcp is None: + print( + "Error: mcp package is not installed. " + "Install it with: pip install 'mcp[fastmcp]>=1.8.0,<2.0.0'", + file=sys.stderr, + ) + sys.exit(1) + + mcp.run(transport="streamable-http", host=host, port=port) diff --git a/src/paperbot/mcp/server.py b/src/paperbot/mcp/server.py new file mode 100644 index 00000000..cab466ee --- /dev/null +++ b/src/paperbot/mcp/server.py @@ -0,0 +1,52 @@ +"""PaperBot MCP server. + +Provides the FastMCP instance with all tools registered. +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +try: + from mcp.server.fastmcp import FastMCP +except ImportError: + # FastMCP not available -- create a minimal stub so tool modules + # can still be imported and tested without the mcp package. + logger.debug("mcp package not installed; MCP server unavailable") + mcp = None # type: ignore[assignment] +else: + mcp = FastMCP("paperbot") + + # Register tools + from paperbot.mcp.tools import paper_search + from paperbot.mcp.tools import paper_judge + from paperbot.mcp.tools import paper_summarize + from paperbot.mcp.tools import relevance + from paperbot.mcp.tools import analyze_trends + from paperbot.mcp.tools import check_scholar + from paperbot.mcp.tools import get_research_context + from paperbot.mcp.tools import save_to_memory + from paperbot.mcp.tools import export_to_obsidian + + paper_search.register(mcp) + paper_judge.register(mcp) + paper_summarize.register(mcp) + relevance.register(mcp) + analyze_trends.register(mcp) + check_scholar.register(mcp) + get_research_context.register(mcp) + save_to_memory.register(mcp) + export_to_obsidian.register(mcp) + + # Register resources + from paperbot.mcp.resources import track_metadata + from paperbot.mcp.resources import track_papers + from paperbot.mcp.resources import track_memory + from paperbot.mcp.resources import scholars + + track_metadata.register(mcp) + track_papers.register(mcp) + track_memory.register(mcp) + scholars.register(mcp) diff --git a/src/paperbot/mcp/tools/__init__.py b/src/paperbot/mcp/tools/__init__.py new file mode 100644 index 00000000..42135c9b --- /dev/null +++ b/src/paperbot/mcp/tools/__init__.py @@ -0,0 +1 @@ +"""MCP tool implementations for PaperBot.""" diff --git a/src/paperbot/mcp/tools/_audit.py b/src/paperbot/mcp/tools/_audit.py new file mode 100644 index 00000000..43239b00 --- /dev/null +++ b/src/paperbot/mcp/tools/_audit.py @@ -0,0 +1,152 @@ +"""Shared audit helper for MCP tool calls. + +Every tool call logs an AgentEventEnvelope to EventLogPort with +workflow='mcp', stage='tool_call'. If EventLogPort is not registered +in the DI Container, auditing degrades silently (no tool failure). +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, Optional, Union + +from paperbot.application.collaboration.message_schema import ( + make_event, + new_run_id, + new_trace_id, +) +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.core.di import Container + +logger = logging.getLogger(__name__) + +_SENSITIVE_KEY_PARTS = frozenset({"api_key", "token", "password", "secret", "authorization"}) +_MAX_AUDIT_TEXT_LENGTH = 1000 +_MAX_AUDIT_COLLECTION_ITEMS = 20 +_MAX_AUDIT_NESTING_DEPTH = 4 + + +def _truncate_text(value: str) -> str: + text = str(value) + if len(text) <= _MAX_AUDIT_TEXT_LENGTH: + return text + return text[:_MAX_AUDIT_TEXT_LENGTH] + "...[truncated]" + + +def _truncate_json(value: Any) -> str: + try: + return _truncate_text(json.dumps(value, ensure_ascii=False, default=str)) + except TypeError: + return _truncate_text(str(value)) + + +def _is_sensitive_key(key: str) -> bool: + normalized = str(key or "").strip().lower() + return any(part in normalized for part in _SENSITIVE_KEY_PARTS) + + +def _sanitize_value(value: Any, *, key: Optional[str] = None, depth: int = 0) -> Any: + if key and _is_sensitive_key(key): + return "***redacted***" + + if value is None or isinstance(value, (bool, int, float)): + return value + + if isinstance(value, str): + return _truncate_text(value) + + if isinstance(value, dict): + sanitized: Dict[str, Any] = {} + for index, (child_key, child_value) in enumerate(value.items()): + if index >= _MAX_AUDIT_COLLECTION_ITEMS: + sanitized["__truncated__"] = True + break + normalized_key = str(child_key) + sanitized[normalized_key] = _sanitize_value( + child_value, + key=normalized_key, + depth=depth + 1, + ) + if depth >= _MAX_AUDIT_NESTING_DEPTH: + return _truncate_json(sanitized) + return sanitized + + if isinstance(value, (list, tuple, set)): + items = list(value) + sanitized_items = [ + _sanitize_value(item, depth=depth + 1) for item in items[:_MAX_AUDIT_COLLECTION_ITEMS] + ] + if len(items) > _MAX_AUDIT_COLLECTION_ITEMS: + sanitized_items.append("...[truncated]") + if depth >= _MAX_AUDIT_NESTING_DEPTH: + return _truncate_json(sanitized_items) + return sanitized_items + + if depth >= 2: + return _truncate_json(value) + + return _truncate_text(str(value)) + + +def _sanitize_arguments(arguments: Dict[str, Any]) -> Dict[str, Any]: + return {str(key): _sanitize_value(value, key=str(key)) for key, value in arguments.items()} + + +def _get_event_log() -> Optional[EventLogPort]: + """Resolve EventLogPort from Container, return None on any exception.""" + try: + return Container.instance().resolve(EventLogPort) + except Exception: + return None + + +def log_tool_call( + tool_name: str, + arguments: Dict[str, Any], + result_summary: Union[str, Dict[str, Any]], + duration_ms: float, + run_id: Optional[str] = None, + error: Optional[str] = None, +) -> str: + """Log an MCP tool call as an AgentEventEnvelope. + + Args: + tool_name: Name of the MCP tool being called. + arguments: Arguments passed to the tool. + result_summary: Short summary of the tool result. May be a string or structured dict. + duration_ms: Duration of the tool call in milliseconds. + run_id: Optional run_id for correlation. If None, a new one is generated. + error: Optional error message if the tool call failed. + + Returns: + The run_id used (provided or generated). + """ + rid = run_id if run_id else new_run_id() + + event = make_event( + run_id=rid, + trace_id=new_trace_id(), + workflow="mcp", + stage="tool_call", + attempt=0, + agent_name="paperbot-mcp", + role="system", + type="error" if error is not None else "tool_result", + payload={ + "tool": tool_name, + "arguments": _sanitize_arguments(arguments), + "result_summary": _sanitize_value(result_summary), + "error": _truncate_text(error) if error is not None else None, + }, + metrics={"duration_ms": duration_ms}, + ) + + event_log = _get_event_log() + if event_log is not None: + try: + event_log.append(event) + except Exception: + logger.debug("Failed to append audit event for tool %s", tool_name, exc_info=True) + + return rid diff --git a/src/paperbot/mcp/tools/analyze_trends.py b/src/paperbot/mcp/tools/analyze_trends.py new file mode 100644 index 00000000..12ecd366 --- /dev/null +++ b/src/paperbot/mcp/tools/analyze_trends.py @@ -0,0 +1,131 @@ +"""analyze_trends MCP tool wrapping TrendAnalyzer. + +Analyzes trends across a set of papers for a given topic using the +synchronous TrendAnalyzer service. Uses anyio.to_thread.run_sync() to +wrap the synchronous TrendAnalyzer.analyze() call. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the trend analyzer service +_analyzer = None + + +def _get_analyzer(): + """Construct TrendAnalyzer on first call (lazy singleton).""" + global _analyzer + if _analyzer is None: + from paperbot.application.workflows.analysis.trend_analyzer import TrendAnalyzer + + _analyzer = TrendAnalyzer() + return _analyzer + + +async def _analyze_trends_impl( + topic: str, + papers: Optional[List[Dict[str, Any]]], + _run_id: str = "", +) -> dict: + """Core implementation of analyze_trends, callable from both MCP registration and tests. + + Analyze trends across a set of papers for a given topic. + + Args: + topic: The research topic or theme to analyze trends for. + papers: List of paper dicts (each may contain title, abstract, year, etc.). + _run_id: Optional run ID for event correlation. + + Returns: + Dict with trend_analysis string, topic, and paper_count. + Includes degraded=True and error when LLM is unavailable. + """ + start = time.monotonic() + safe_papers = list(papers or []) + args = {"topic": topic, "paper_count": len(safe_papers)} + + try: + if not safe_papers: + output: Dict[str, Any] = { + "trend_analysis": "", + "topic": topic, + "paper_count": 0, + } + log_tool_call( + tool_name="analyze_trends", + arguments=args, + result_summary={ + "topic": topic, + "paper_count": 0, + "degraded": False, + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + analyzer = _get_analyzer() + result = await anyio.to_thread.run_sync( + lambda: analyzer.analyze(topic=topic, items=safe_papers) + ) + + output: Dict[str, Any] = { + "trend_analysis": result, + "topic": topic, + "paper_count": len(safe_papers), + } + + # Detect degraded LLM response (empty string when LLM unavailable) + if not result or not result.strip(): + output["degraded"] = True + output["error"] = "LLM response unavailable or empty. Check provider configuration." + + log_tool_call( + tool_name="analyze_trends", + arguments=args, + result_summary={ + "topic": topic, + "paper_count": len(safe_papers), + "degraded": output.get("degraded", False), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="analyze_trends", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the analyze_trends tool on the given FastMCP instance.""" + + @mcp.tool() + async def analyze_trends( + topic: str, + papers: Optional[List[Dict[str, Any]]], + _run_id: str = "", + ) -> dict: + """Analyze trends across a set of papers for a given topic. + + Returns a natural language trend analysis string summarizing patterns, + themes, and directions observed in the provided papers. Requires LLM API key. + """ + return await _analyze_trends_impl(topic, papers, _run_id) diff --git a/src/paperbot/mcp/tools/check_scholar.py b/src/paperbot/mcp/tools/check_scholar.py new file mode 100644 index 00000000..8a3c97eb --- /dev/null +++ b/src/paperbot/mcp/tools/check_scholar.py @@ -0,0 +1,135 @@ +"""check_scholar MCP tool wrapping SemanticScholarClient. + +Checks a scholar's recent publications and activity by querying the +Semantic Scholar API. Uses the async SemanticScholarClient directly. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the Semantic Scholar client +_client = None + + +def _get_client(): + """Construct SemanticScholarClient on first call (lazy singleton).""" + global _client + if _client is None: + from paperbot.infrastructure.api_clients.semantic_scholar import SemanticScholarClient + + _client = SemanticScholarClient() + return _client + + +async def _check_scholar_impl( + scholar_name: str, + max_papers: int = 10, + _run_id: str = "", +) -> dict: + """Core implementation of check_scholar, callable from both MCP registration and tests. + + Check a scholar's recent publications and activity. + + Args: + scholar_name: Name or query string to search for the scholar. + max_papers: Maximum number of recent papers to retrieve (default 10). + _run_id: Optional run ID for event correlation. + + Returns: + Dict with scholar info and recent_papers list. + Includes degraded=True and error when scholar is not found. + """ + start = time.monotonic() + args = {"scholar_name": scholar_name, "max_papers": max_papers} + + try: + client = _get_client() + + # Step 1: Search for the scholar + authors = await client.search_authors( + scholar_name, + limit=3, + fields=["name", "authorId", "hIndex", "paperCount", "citationCount"], + ) + + if not authors: + output: Dict[str, Any] = { + "degraded": True, + "error": "Scholar not found", + "scholar": None, + "recent_papers": [], + "candidates": [], + } + log_tool_call( + tool_name="check_scholar", + arguments=args, + result_summary={"degraded": True, "error": "Scholar not found"}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + # Step 2: Pick top match (first result -- highest relevance from S2 API) + top_author = authors[0] + author_id = top_author.get("authorId", "") + + papers = await client.get_author_papers( + author_id, + limit=max_papers, + fields=["title", "year", "citationCount", "venue", "abstract"], + ) + + output = { + "scholar": top_author, + "recent_papers": papers, + "candidates": authors, + } + + log_tool_call( + tool_name="check_scholar", + arguments=args, + result_summary={ + "scholar": top_author.get("name"), + "paper_count": len(papers), + "degraded": False, + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="check_scholar", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the check_scholar tool on the given FastMCP instance.""" + + @mcp.tool() + async def check_scholar( + scholar_name: str, + max_papers: int = 10, + _run_id: str = "", + ) -> dict: + """Check a scholar's recent publications and activity. + + Searches Semantic Scholar for the named scholar and returns their profile + information (hIndex, citation count) along with their recent papers. + Returns degraded=True if the scholar cannot be found. + """ + return await _check_scholar_impl(scholar_name, max_papers, _run_id) diff --git a/src/paperbot/mcp/tools/export_to_obsidian.py b/src/paperbot/mcp/tools/export_to_obsidian.py new file mode 100644 index 00000000..679a72d3 --- /dev/null +++ b/src/paperbot/mcp/tools/export_to_obsidian.py @@ -0,0 +1,182 @@ +"""export_to_obsidian MCP tool wrapping ObsidianFilesystemExporter. + +Renders a paper as Obsidian-formatted markdown with YAML frontmatter. +No filesystem I/O — in-memory rendering only. +Uses anyio.to_thread.run_sync() to wrap the synchronous _render_paper_note() call. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the exporter +_exporter = None + + +def _get_exporter(): + """Construct ObsidianFilesystemExporter on first call (lazy singleton).""" + global _exporter + if _exporter is None: + from paperbot.infrastructure.exporters.obsidian_exporter import ( + ObsidianFilesystemExporter, + ) + + _exporter = ObsidianFilesystemExporter() + return _exporter + + +async def _export_to_obsidian_impl( + title: str, + abstract: str, + authors: Optional[List[str]] = None, + year: Optional[int] = None, + venue: str = "", + arxiv_id: str = "", + doi: str = "", + _run_id: str = "", +) -> Dict[str, Any]: + """Core implementation of export_to_obsidian, callable from both MCP registration and tests. + + Export a paper as Obsidian-formatted markdown with YAML frontmatter. + + Args: + title: Paper title. + abstract: Paper abstract text. + authors: List of author name strings. + year: Publication year. + venue: Conference or journal name. + arxiv_id: arXiv identifier (e.g. '1706.03762'). + doi: DOI identifier. + _run_id: Optional run ID for event correlation. + + Returns: + Dict with key 'markdown' containing the full rendered markdown string + (YAML frontmatter + body). No filesystem writes are performed. + """ + start = time.monotonic() + normalized_authors = list(authors or []) + args = { + "title": title, + "abstract_len": len(abstract), + "author_count": len(normalized_authors), + "year": year, + "arxiv_id": arxiv_id or None, + } + + try: + # Build metadata rows for the template + metadata_rows: List[str] = [] + if normalized_authors: + metadata_rows.append(f"Authors: {', '.join(normalized_authors)}") + if year: + metadata_rows.append(f"Year: {year}") + if venue: + metadata_rows.append(f"Venue: {venue}") + + # Build external links from identifiers + external_links: List[str] = [] + if arxiv_id: + external_links.append(f"[arXiv](https://arxiv.org/abs/{arxiv_id})") + if doi: + external_links.append(f"[DOI](https://doi.org/{doi})") + + # Build paper dict for template rendering + paper: Dict[str, Any] = { + "title": title, + "abstract": abstract, + "authors": normalized_authors, + "year": year, + "venue": venue, + "arxiv_id": arxiv_id, + "doi": doi, + } + + exporter = _get_exporter() + body = await anyio.to_thread.run_sync( + lambda: exporter._render_paper_note( + template_path=None, + title=title, + abstract=abstract, + metadata_rows=metadata_rows, + track_link=None, + external_links=external_links, + related_links=[], + reference_links=[], + cited_by_links=[], + paper=paper, + track=None, + related_titles=[], + ) + ) + + # Import _yaml_frontmatter to build YAML header + from paperbot.infrastructure.exporters.obsidian_exporter import _yaml_frontmatter + + frontmatter = _yaml_frontmatter( + { + "title": title, + "paperbot_type": "paper", + "authors": normalized_authors, + "year": year, + "venue": venue or None, + "arxiv_id": arxiv_id or None, + "doi": doi or None, + } + ) + + markdown = frontmatter + body + + output = {"markdown": markdown} + + log_tool_call( + tool_name="export_to_obsidian", + arguments=args, + result_summary={"markdown_len": len(markdown)}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="export_to_obsidian", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the export_to_obsidian tool on the given FastMCP instance.""" + + @mcp.tool() + async def export_to_obsidian( + title: str, + abstract: str, + authors: Optional[List[str]] = None, + year: Optional[int] = None, + venue: str = "", + arxiv_id: str = "", + doi: str = "", + _run_id: str = "", + ) -> dict: + """Export a paper as Obsidian-formatted markdown with YAML frontmatter. + + Returns in-memory rendered markdown (no filesystem writes). The markdown + includes YAML frontmatter (title, paperbot_type, authors, year, venue, + arxiv_id, doi) and a formatted body with Summary, Metadata, and Links sections. + """ + return await _export_to_obsidian_impl( + title, abstract, authors, year, venue, arxiv_id, doi, _run_id + ) diff --git a/src/paperbot/mcp/tools/get_research_context.py b/src/paperbot/mcp/tools/get_research_context.py new file mode 100644 index 00000000..cb4f0e8d --- /dev/null +++ b/src/paperbot/mcp/tools/get_research_context.py @@ -0,0 +1,196 @@ +"""get_research_context MCP tool wrapping ContextEngine. + +Retrieves research context for a query including relevant papers and memories. +Uses direct async await since ContextEngine.build_context_pack() is already async. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +from paperbot.mcp.tools._audit import log_tool_call +from paperbot.utils.user_identity import require_user_identity + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the context engine +_engine = None +_paper_store = None +_paper_search_service = None +_document_index_store = None +_query_grounder = None + + +def _get_paper_store(): + """Construct PaperStore on first call (lazy singleton).""" + global _paper_store + if _paper_store is None: + from paperbot.infrastructure.stores.paper_store import PaperStore + + _paper_store = PaperStore() + return _paper_store + + +def _get_paper_search_service(): + """Construct PaperSearchService on first call (lazy singleton).""" + global _paper_search_service + if _paper_search_service is None: + from paperbot.application.services.paper_search_service import PaperSearchService + from paperbot.infrastructure.adapters import build_adapter_registry + + _paper_search_service = PaperSearchService( + adapters=build_adapter_registry(), + registry=_get_paper_store(), + ) + return _paper_search_service + + +def _get_document_index_store(): + """Construct DocumentIndexStore on first call (lazy singleton).""" + global _document_index_store + if _document_index_store is None: + from paperbot.infrastructure.stores.document_index_store import DocumentIndexStore + + _document_index_store = DocumentIndexStore() + return _document_index_store + + +def _get_workflow_query_grounder(): + """Construct WorkflowQueryGrounder on first call (lazy singleton).""" + global _query_grounder + if _query_grounder is None: + from paperbot.application.services.workflow_query_grounder import WorkflowQueryGrounder + from paperbot.application.services.wiki_concept_service import WikiConceptService + from paperbot.infrastructure.stores.wiki_concept_store import WikiConceptStore + + _query_grounder = WorkflowQueryGrounder( + concept_service=WikiConceptService(WikiConceptStore()) + ) + return _query_grounder + + +def _get_engine(): + """Construct ContextEngine on first call (lazy singleton).""" + global _engine + if _engine is None: + from paperbot.context_engine.engine import ContextEngine, ContextEngineConfig + + _engine = ContextEngine( + paper_store=_get_paper_store(), + search_service=_get_paper_search_service(), + evidence_retriever=_get_document_index_store(), + query_grounder=_get_workflow_query_grounder(), + config=ContextEngineConfig(), + ) + return _engine + + +def _normalize_context_pack(result: Dict[str, Any]) -> Dict[str, Any]: + """Expose stable MCP aliases while preserving ContextEngine-native keys.""" + normalized = dict(result) + routing = normalized.get("routing") if isinstance(normalized.get("routing"), dict) else {} + + papers = normalized.get("papers") + if papers is None: + papers = normalized.get("paper_recommendations") + if papers is None: + papers = [] + + memories = normalized.get("memories") + if memories is None: + memories = normalized.get("relevant_memories") + if memories is None: + memories = [] + + track = normalized.get("track", normalized.get("active_track")) + stage = normalized.get("stage", routing.get("stage")) + routing_suggestion = normalized.get("routing_suggestion", routing.get("suggestion")) + + normalized.setdefault("paper_recommendations", papers) + normalized.setdefault("relevant_memories", memories) + normalized.setdefault("active_track", track) + normalized["papers"] = papers + normalized["memories"] = memories + normalized["track"] = track + normalized["stage"] = stage + normalized["routing_suggestion"] = routing_suggestion + return normalized + + +async def _get_research_context_impl( + query: str, + user_id: str, + track_id: Optional[int] = None, + _run_id: str = "", +) -> Dict[str, Any]: + """Core implementation of get_research_context, callable from both MCP registration and tests. + + Retrieve research context for a query, including relevant papers and memories. + + Args: + query: The research query string. + user_id: User identifier for personalized context. + track_id: Optional track ID to scope the context retrieval. + _run_id: Optional run ID for event correlation. + + Returns: + Context pack containing ContextEngine-native keys plus stable MCP aliases + for papers, memories, track, stage, and routing_suggestion. + """ + start = time.monotonic() + resolved_user_id = require_user_identity(user_id) + args = {"query": query, "user_id": resolved_user_id, "track_id": track_id} + + try: + engine = _get_engine() + result = await engine.build_context_pack( + user_id=resolved_user_id, + query=query, + track_id=track_id, + ) + normalized = _normalize_context_pack(result) + + log_tool_call( + tool_name="get_research_context", + arguments=args, + result_summary={ + "paper_count": len(normalized.get("papers") or []), + "memory_count": len(normalized.get("memories") or []), + "stage": normalized.get("stage"), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return normalized + + except Exception as exc: + log_tool_call( + tool_name="get_research_context", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the get_research_context tool on the given FastMCP instance.""" + + @mcp.tool() + async def get_research_context( + query: str, + user_id: str, + track_id: Optional[int] = None, + _run_id: str = "", + ) -> dict: + """Retrieve research context for a query, including relevant papers and memories. + + Returns a context pack dict with ContextEngine-native fields plus MCP + compatibility aliases for papers, memories, track info, research stage, + and routing suggestions. + """ + return await _get_research_context_impl(query, user_id, track_id, _run_id) diff --git a/src/paperbot/mcp/tools/paper_judge.py b/src/paperbot/mcp/tools/paper_judge.py new file mode 100644 index 00000000..1df728cb --- /dev/null +++ b/src/paperbot/mcp/tools/paper_judge.py @@ -0,0 +1,118 @@ +"""paper_judge MCP tool wrapping PaperJudge. + +Judges a paper's quality across multiple dimensions (relevance, novelty, +rigor, impact, clarity). Uses anyio.to_thread.run_sync() to wrap the +synchronous PaperJudge.judge_single() call. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the judge service +_judge = None + + +def _get_judge(): + """Construct PaperJudge on first call (lazy singleton).""" + global _judge + if _judge is None: + from paperbot.application.workflows.analysis.paper_judge import PaperJudge + + _judge = PaperJudge() + return _judge + + +async def _paper_judge_impl( + title: str, + abstract: str, + full_text: str = "", + rubric: str = "default", + _run_id: str = "", +) -> dict: + """Core implementation of paper_judge, callable from both MCP registration and tests. + + Judge a paper's quality across multiple dimensions (relevance, novelty, + rigor, impact, clarity). + + Args: + title: Paper title. + abstract: Paper abstract text. + full_text: Optional full paper text for deeper analysis. + rubric: Rubric name or query string for judging context. + _run_id: Optional run ID for event correlation. + + Returns: + Dict with dimension scores, overall score, recommendation, and judge_model. + Includes degraded=True and error when LLM is unavailable. + """ + start = time.monotonic() + args = {"title": title, "abstract_len": len(abstract), "rubric": rubric} + + # CRITICAL: Map "abstract" to "snippet" -- PaperJudge expects "snippet" key + paper: Dict[str, Any] = {"title": title, "snippet": abstract, "full_text": full_text} + + try: + judge = _get_judge() + result = await anyio.to_thread.run_sync( + lambda: judge.judge_single(paper=paper, query=rubric) + ) + output = result.to_dict() + + # Treat parse/LLM fallbacks as degraded even if provider metadata is configured. + if not result.judge_model: + output["degraded"] = True + output["error"] = ( + "LLM response unavailable or invalid. " "Check provider configuration and retry." + ) + + log_tool_call( + tool_name="paper_judge", + arguments=args, + result_summary={ + "overall": output.get("overall"), + "recommendation": output.get("recommendation"), + "degraded": output.get("degraded", False), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="paper_judge", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the paper_judge tool on the given FastMCP instance.""" + + @mcp.tool() + async def paper_judge( + title: str, + abstract: str, + full_text: str = "", + rubric: str = "default", + _run_id: str = "", + ) -> dict: + """Judge a paper's quality across multiple dimensions (relevance, novelty, rigor, impact, clarity). + + Returns dimension scores (1-5), overall score, one-line summary, and recommendation + (must_read, worth_reading, skim, skip). Requires LLM API key. + """ + return await _paper_judge_impl(title, abstract, full_text, rubric, _run_id) diff --git a/src/paperbot/mcp/tools/paper_search.py b/src/paperbot/mcp/tools/paper_search.py new file mode 100644 index 00000000..db68079e --- /dev/null +++ b/src/paperbot/mcp/tools/paper_search.py @@ -0,0 +1,132 @@ +"""paper_search MCP tool wrapping PaperSearchService. + +Provides paper search functionality over multiple academic data sources. +Uses the register(mcp) pattern to avoid circular imports. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, List, Optional + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +_MIN_MAX_RESULTS = 1 +_MAX_MAX_RESULTS = 100 + +# Module-level lazy singleton for the search service +_service = None + + +def _get_service(): + """Construct PaperSearchService on first call (lazy singleton).""" + global _service + if _service is None: + from paperbot.infrastructure.adapters import build_adapter_registry + from paperbot.application.services.paper_search_service import PaperSearchService + + adapters = build_adapter_registry() + _service = PaperSearchService(adapters=adapters) + return _service + + +async def _paper_search_impl( + query: str, + max_results: int = 10, + sources: Optional[List[str]] = None, + _run_id: str = "", +) -> List[Dict[str, Any]]: + """Core implementation of paper_search, callable from both MCP registration and tests. + + Search for academic papers across multiple data sources. + + Args: + query: Search query string describing the papers to find. + max_results: Maximum number of papers to return (default 10). + sources: Optional list of specific sources to search (e.g. ['arxiv', 'semantic_scholar']). + _run_id: Optional run ID for event correlation. + + Returns: + List of paper dictionaries with title, abstract, authors, and metadata. + """ + service = _get_service() + t0 = time.monotonic() + args = { + "query": query, + "max_results": max_results, + "sources": sources, + } + + try: + normalized_max_results = int(max_results) + if not (_MIN_MAX_RESULTS <= normalized_max_results <= _MAX_MAX_RESULTS): + raise ValueError( + f"max_results must be between {_MIN_MAX_RESULTS} and {_MAX_MAX_RESULTS}" + ) + + result = await service.search( + query, + max_results=normalized_max_results, + sources=sources, + persist=False, + ) + papers = [p.to_dict() for p in result.papers] + + elapsed_ms = (time.monotonic() - t0) * 1000.0 + log_tool_call( + tool_name="paper_search", + arguments=args, + result_summary=( + f"returned {len(papers)} papers " + f"(total_raw={result.total_raw}, " + f"duplicates_removed={result.duplicates_removed})" + ), + duration_ms=elapsed_ms, + run_id=_run_id or None, + ) + + return papers + + except Exception as exc: + elapsed_ms = (time.monotonic() - t0) * 1000.0 + log_tool_call( + tool_name="paper_search", + arguments=args, + result_summary="", + duration_ms=elapsed_ms, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the paper_search tool on the given FastMCP instance.""" + + @mcp.tool() + async def paper_search( + query: str, + max_results: int = 10, + sources: Optional[List[str]] = None, + _run_id: str = "", + ) -> List[Dict[str, Any]]: + """Search for academic papers across multiple data sources. + + Args: + query: Search query string describing the papers to find. + max_results: Maximum number of papers to return (default 10). + sources: Optional list of specific sources to search. + _run_id: Optional run ID for event correlation. + + Returns: + List of paper dictionaries with title, abstract, authors, and metadata. + """ + return await _paper_search_impl( + query=query, + max_results=max_results, + sources=sources, + _run_id=_run_id, + ) diff --git a/src/paperbot/mcp/tools/paper_summarize.py b/src/paperbot/mcp/tools/paper_summarize.py new file mode 100644 index 00000000..4b3c5faa --- /dev/null +++ b/src/paperbot/mcp/tools/paper_summarize.py @@ -0,0 +1,113 @@ +"""paper_summarize MCP tool wrapping PaperSummarizer. + +Generates a concise summary of a paper using the LLM. Uses +anyio.to_thread.run_sync() to wrap the synchronous +PaperSummarizer.summarize_item() call. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the summarizer service +_summarizer = None + + +def _get_summarizer(): + """Construct PaperSummarizer on first call (lazy singleton).""" + global _summarizer + if _summarizer is None: + from paperbot.application.workflows.analysis.paper_summarizer import PaperSummarizer + + _summarizer = PaperSummarizer() + return _summarizer + + +async def _paper_summarize_impl( + title: str, + abstract: str, + _run_id: str = "", +) -> dict: + """Core implementation of paper_summarize, callable from both MCP registration and tests. + + Summarize a paper given its title and abstract. + + Args: + title: Paper title. + abstract: Paper abstract text. + _run_id: Optional run ID for event correlation. + + Returns: + Dict with 'summary' key. Includes degraded=True and error when LLM + returns empty output. + """ + start = time.monotonic() + args = {"title": title, "abstract_len": len(abstract)} + + # Build item dict with "snippet" key (PaperSummarizer reads snippet or abstract) + item: Dict[str, Any] = {"title": title, "snippet": abstract} + + try: + summarizer = _get_summarizer() + summary = await anyio.to_thread.run_sync( + lambda: summarizer.summarize_item(item) + ) + + output: Dict[str, Any] = {"summary": summary} + + # Detect degraded LLM response (empty summary when LLM unavailable) + if not summary or not summary.strip(): + output["summary"] = "" + output["degraded"] = True + output["error"] = ( + "LLM service unavailable. " + "Configure OPENAI_API_KEY or ANTHROPIC_API_KEY." + ) + + log_tool_call( + tool_name="paper_summarize", + arguments=args, + result_summary={ + "summary_len": len(summary), + "degraded": output.get("degraded", False), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="paper_summarize", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the paper_summarize tool on the given FastMCP instance.""" + + @mcp.tool() + async def paper_summarize( + title: str, + abstract: str, + _run_id: str = "", + ) -> dict: + """Summarize a paper given its title and abstract. + + Returns a concise summary of the paper's key contributions, methods, + and findings. Requires LLM API key. + """ + return await _paper_summarize_impl(title, abstract, _run_id) diff --git a/src/paperbot/mcp/tools/relevance.py b/src/paperbot/mcp/tools/relevance.py new file mode 100644 index 00000000..0acbcb56 --- /dev/null +++ b/src/paperbot/mcp/tools/relevance.py @@ -0,0 +1,121 @@ +"""relevance_assess MCP tool wrapping RelevanceAssessor. + +Assesses a paper's relevance to a given research query. Uses +anyio.to_thread.run_sync() to wrap the synchronous +RelevanceAssessor.assess() call. +""" + +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call + +logger = logging.getLogger(__name__) + +# Module-level lazy singleton for the assessor service +_assessor = None + + +def _get_assessor(): + """Construct RelevanceAssessor on first call (lazy singleton).""" + global _assessor + if _assessor is None: + from paperbot.application.workflows.analysis.relevance_assessor import RelevanceAssessor + + _assessor = RelevanceAssessor() + return _assessor + + +async def _relevance_assess_impl( + title: str, + abstract: str, + query: str, + keywords: str = "", + _run_id: str = "", +) -> dict: + """Core implementation of relevance_assess, callable from both MCP registration and tests. + + Assess a paper's relevance to a research query. + + Args: + title: Paper title. + abstract: Paper abstract text. + query: Research query to assess relevance against. + keywords: Optional comma-separated keywords. + _run_id: Optional run ID for event correlation. + + Returns: + Dict with 'score' (0-100) and 'reason'. Includes degraded=True and + note when fallback token-overlap scoring is used. + """ + start = time.monotonic() + args = {"title": title, "query": query, "abstract_len": len(abstract)} + + # Build paper dict with snippet key + keyword_list = [k.strip() for k in keywords.split(",") if k.strip()] if keywords else [] + paper: Dict[str, Any] = { + "title": title, + "snippet": abstract, + "keywords": keyword_list, + } + + try: + assessor = _get_assessor() + result = await anyio.to_thread.run_sync(lambda: assessor.assess(paper=paper, query=query)) + + output: Dict[str, Any] = dict(result) + + # Prefer structured fallback metadata; keep reason matching as a compatibility fallback. + reason = str(result.get("reason", "")) + if bool(result.get("fallback") or result.get("degraded")) or "Fallback" in reason: + output["degraded"] = True + output["note"] = ( + "Score computed via token-overlap fallback. " "LLM-based assessment unavailable." + ) + + log_tool_call( + tool_name="relevance_assess", + arguments=args, + result_summary={ + "score": output.get("score"), + "degraded": output.get("degraded", False), + }, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="relevance_assess", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the relevance_assess tool on the given FastMCP instance.""" + + @mcp.tool() + async def relevance_assess( + title: str, + abstract: str, + query: str, + keywords: str = "", + _run_id: str = "", + ) -> dict: + """Assess a paper's relevance to a research query. + + Returns a relevance score (0-100) and reasoning. Uses LLM when available, + falls back to token-overlap scoring otherwise. + """ + return await _relevance_assess_impl(title, abstract, query, keywords, _run_id) diff --git a/src/paperbot/mcp/tools/save_to_memory.py b/src/paperbot/mcp/tools/save_to_memory.py new file mode 100644 index 00000000..bec35847 --- /dev/null +++ b/src/paperbot/mcp/tools/save_to_memory.py @@ -0,0 +1,171 @@ +"""save_to_memory MCP tool wrapping SqlAlchemyMemoryStore. + +Persists research findings, notes, and structured knowledge to the memory store. +Uses anyio.to_thread.run_sync() to wrap the synchronous store.add_memories() call. +""" + +from __future__ import annotations + +import math +import logging +import time +from typing import Any, Dict + +import anyio + +from paperbot.mcp.tools._audit import log_tool_call +from paperbot.utils.user_identity import require_user_identity + +logger = logging.getLogger(__name__) + +# Allowed MemoryKind values (must match paperbot.memory.schema.MemoryKind) +_ALLOWED_KINDS = frozenset([ + "profile", + "preference", + "goal", + "project", + "constraint", + "todo", + "fact", + "note", + "decision", + "hypothesis", + "keyword_set", +]) + +# Module-level lazy singleton for the memory store +_store = None + + +def _get_store(): + """Construct SqlAlchemyMemoryStore on first call (lazy singleton).""" + global _store + if _store is None: + from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore + + _store = SqlAlchemyMemoryStore() + return _store + + +async def _save_to_memory_impl( + content: str, + kind: str = "note", + *, + user_id: str, + scope_type: str = "global", + scope_id: str = "", + confidence: float = 0.8, + _run_id: str = "", +) -> Dict[str, Any]: + """Core implementation of save_to_memory, callable from both MCP registration and tests. + + Save research findings to memory for later retrieval. + + Args: + content: The content to store in memory. + kind: Memory kind. One of: profile, preference, goal, project, constraint, + todo, fact, note, decision, hypothesis, keyword_set. Defaults to 'note'. + user_id: User identifier to scope the memory. + scope_type: Scope type: global, track, project, or paper. Defaults to 'global'. + scope_id: Scope identifier (e.g., track ID or project ID). + confidence: Confidence score for the memory candidate (0.0-1.0). + _run_id: Optional run ID for event correlation. + + Returns: + Dict with keys: saved (bool), created (int), skipped (int). + """ + start = time.monotonic() + resolved_user_id = require_user_identity(user_id) + + args = { + "content_len": len(content), + "kind": kind, + "user_id": resolved_user_id, + "scope_type": scope_type, + "confidence": confidence, + } + + # Validate kind; default to "note" if invalid + effective_kind = kind if kind in _ALLOWED_KINDS else "note" + if kind not in _ALLOWED_KINDS: + logger.warning( + "save_to_memory: invalid kind=%r; defaulting to 'note'. " + "Allowed values: %s", + kind, + sorted(_ALLOWED_KINDS), + ) + + try: + from paperbot.memory.schema import MemoryCandidate + + confidence_value = float(confidence) + if not math.isfinite(confidence_value) or not 0.0 <= confidence_value <= 1.0: + raise ValueError("confidence must be between 0.0 and 1.0") + + args["kind"] = effective_kind + args["confidence"] = confidence_value + + candidate = MemoryCandidate( + kind=effective_kind, + content=content, + confidence=confidence_value, + scope_type=scope_type or None, + scope_id=scope_id or None, + ) + + store = _get_store() + created_count, skipped_count, _rows = await anyio.to_thread.run_sync( + lambda: store.add_memories(user_id=resolved_user_id, memories=[candidate]) + ) + + output = { + "saved": created_count > 0, + "created": created_count, + "skipped": skipped_count, + } + + log_tool_call( + tool_name="save_to_memory", + arguments=args, + result_summary=output, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + ) + return output + + except Exception as exc: + log_tool_call( + tool_name="save_to_memory", + arguments=args, + result_summary={}, + duration_ms=(time.monotonic() - start) * 1000, + run_id=_run_id or None, + error=str(exc), + ) + raise + + +def register(mcp) -> None: + """Register the save_to_memory tool on the given FastMCP instance.""" + + @mcp.tool() + async def save_to_memory( + content: str, + kind: str = "note", + *, + user_id: str, + scope_type: str = "global", + scope_id: str = "", + confidence: float = 0.8, + _run_id: str = "", + ) -> dict: + """Save research findings to memory for later retrieval. + + Persists content as a typed memory candidate. Use kind to categorize + (e.g., 'note', 'hypothesis', 'decision', 'keyword_set'). Invalid kinds + default to 'note'. Confidence must be between 0.0 and 1.0. + Returns created and skipped counts. + """ + return await _save_to_memory_impl( + content, kind, user_id, scope_type, scope_id, confidence, _run_id + ) diff --git a/src/paperbot/presentation/cli/main.py b/src/paperbot/presentation/cli/main.py index 56e4b8f9..bca34299 100644 --- a/src/paperbot/presentation/cli/main.py +++ b/src/paperbot/presentation/cli/main.py @@ -211,6 +211,25 @@ def create_parser() -> argparse.ArgumentParser: help="从最近 checkpoint 恢复执行", ) + # mcp commands + mcp_parser = subparsers.add_parser("mcp", help="MCP server commands") + mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", help="Available commands") + + serve_parser = mcp_subparsers.add_parser("serve", help="Start MCP server") + serve_transport = serve_parser.add_mutually_exclusive_group(required=True) + serve_transport.add_argument( + "--stdio", + action="store_true", + help="stdio transport (for Claude Desktop / Claude Code)", + ) + serve_transport.add_argument( + "--http", + action="store_true", + help="Streamable HTTP transport (for remote agents)", + ) + serve_parser.add_argument("--host", default="127.0.0.1", help="HTTP host (default: 127.0.0.1)") + serve_parser.add_argument("--port", type=int, default=8001, help="HTTP port (default: 8001)") + export_parser = subparsers.add_parser("export", help="导出 PaperBot 数据") export_subparsers = export_parser.add_subparsers(dest="export_target", help="导出目标") @@ -229,7 +248,7 @@ def create_parser() -> argparse.ArgumentParser: default=None, help="按 track 名称导出(大小写不敏感)", ) - obsidian_parser.add_argument("--user-id", default="default", help="用户 ID") + obsidian_parser.add_argument("--user-id", default=None, help="用户 ID") obsidian_parser.add_argument("--limit", type=int, default=200, help="最多导出多少篇论文") obsidian_parser.add_argument( "--root-dir", @@ -309,6 +328,15 @@ def run_cli(args: Optional[list] = None) -> int: print("Error: export target is required", file=sys.stderr) return 1 + elif parsed.command == "mcp": + if not getattr(parsed, "mcp_command", None): + print("Usage: paperbot mcp \n\nCommands:\n serve Start MCP server") + return 0 + if parsed.mcp_command == "serve": + return _run_mcp_serve(parsed) + print("Usage: paperbot mcp \n\nCommands:\n serve Start MCP server") + return 0 + return 0 except Exception as e: @@ -587,6 +615,7 @@ def _find_track_by_name( def _run_obsidian_export(parsed: argparse.Namespace) -> int: from paperbot.infrastructure.exporters import ObsidianFilesystemExporter from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore + from paperbot.utils.user_identity import require_user_identity settings = create_settings() obsidian_config = settings.obsidian @@ -599,26 +628,27 @@ def _run_obsidian_export(parsed: argparse.Namespace) -> int: return 1 root_dir = parsed.root_dir or obsidian_config.root_dir or "PaperBot" paper_template_path = parsed.paper_template or obsidian_config.paper_template_path + user_id = require_user_identity(parsed.user_id) store = SqlAlchemyResearchStore() try: track = None track_id = None if parsed.track_id is not None: - track = store.get_track(user_id=parsed.user_id, track_id=int(parsed.track_id)) + track = store.get_track(user_id=user_id, track_id=int(parsed.track_id)) if track is None: print(f"Error: track not found: {parsed.track_id}", file=sys.stderr) return 1 track_id = int(track["id"]) elif parsed.track_name: - track = _find_track_by_name(store, user_id=parsed.user_id, track_name=parsed.track_name) + track = _find_track_by_name(store, user_id=user_id, track_name=parsed.track_name) if track is None: print(f"Error: track not found: {parsed.track_name}", file=sys.stderr) return 1 track_id = int(track["id"]) saved_items = store.list_saved_papers( - user_id=parsed.user_id, + user_id=user_id, track_id=track_id, limit=max(1, int(parsed.limit)), ) @@ -662,5 +692,16 @@ def _run_obsidian_export(parsed: argparse.Namespace) -> int: store.close() +def _run_mcp_serve(parsed: argparse.Namespace) -> int: + """Dispatch to the appropriate MCP transport based on CLI flags.""" + from paperbot.mcp.serve import run_http, run_stdio + + if parsed.stdio: + run_stdio() # blocks until client disconnects + else: + run_http(host=parsed.host, port=parsed.port) # blocks until Ctrl+C + return 0 + + if __name__ == "__main__": sys.exit(run_cli()) diff --git a/src/paperbot/repro/agents/coding_agent.py b/src/paperbot/repro/agents/coding_agent.py index 0e222e5a..8c321f79 100644 --- a/src/paperbot/repro/agents/coding_agent.py +++ b/src/paperbot/repro/agents/coding_agent.py @@ -96,7 +96,7 @@ async def execute(self, context: Dict[str, Any]) -> AgentResult: gen_result = await self.generation_node.run( gen_input, - user_id=context.get("user_id", "default"), + user_id=context.get("user_id"), pack_id=context.get("pack_id"), ) diff --git a/src/paperbot/repro/agents/debugging_agent.py b/src/paperbot/repro/agents/debugging_agent.py index 12de6073..684ef9a2 100644 --- a/src/paperbot/repro/agents/debugging_agent.py +++ b/src/paperbot/repro/agents/debugging_agent.py @@ -14,6 +14,8 @@ from pathlib import Path from dataclasses import dataclass +from paperbot.utils.user_identity import optional_user_identity + from .base_agent import BaseAgent, AgentResult, AgentStatus from ..models import ErrorType, PaperContext @@ -162,16 +164,15 @@ async def execute(self, context: Dict[str, Any]) -> AgentResult: else None ) try: - user_id = ( - context.get("user_id", "default") or "default" - ).strip() or "default" - self._experience_store.add( - user_id=user_id, - pattern_type="failure_reason", - content=f"[{error_type.value}] fixed: {repair_result.fix_applied}", - paper_id=paper_id, - code_snippet=repair_result.original_error[:1000], - ) + user_id = optional_user_identity(context.get("user_id")) + if user_id: + self._experience_store.add( + user_id=user_id, + pattern_type="failure_reason", + content=f"[{error_type.value}] fixed: {repair_result.fix_applied}", + paper_id=paper_id, + code_snippet=repair_result.original_error[:1000], + ) except Exception: # noqa: BLE001 logger.warning( "Failed to persist code experience for failure reason.", diff --git a/src/paperbot/repro/memory/code_memory.py b/src/paperbot/repro/memory/code_memory.py index c646f6a6..d0757bd8 100644 --- a/src/paperbot/repro/memory/code_memory.py +++ b/src/paperbot/repro/memory/code_memory.py @@ -17,6 +17,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple +from paperbot.utils.user_identity import require_user_identity + from .symbol_index import SymbolIndex, SymbolInfo if TYPE_CHECKING: @@ -365,7 +367,7 @@ def load_experiences_from_db( self, paper_id: str, *, - user_id: str = "default", + user_id: str, pack_id: Optional[str] = None, limit: int = 20, ) -> None: @@ -377,7 +379,7 @@ def load_experiences_from_db( if not self._experience_store or not paper_id: return try: - effective_user_id = (user_id or "default").strip() or "default" + effective_user_id = require_user_identity(user_id) rows = self._experience_store.get_by_paper_id( paper_id, user_id=effective_user_id, @@ -399,7 +401,7 @@ def load_experiences_from_db( def record_success_pattern( self, *, - user_id: str = "default", + user_id: str, paper_id: Optional[str], pack_id: Optional[str] = None, filepath: str, @@ -409,8 +411,9 @@ def record_success_pattern( if not self._experience_store: return try: + resolved_user_id = require_user_identity(user_id) self._experience_store.add( - user_id=(user_id or "default").strip() or "default", + user_id=resolved_user_id, pattern_type="success_pattern", content=f"Successfully generated {filepath}", paper_id=paper_id, @@ -423,7 +426,7 @@ def record_success_pattern( def record_verified_structure( self, *, - user_id: str = "default", + user_id: str, paper_id: Optional[str], pack_id: Optional[str] = None, description: str, @@ -433,8 +436,9 @@ def record_verified_structure( if not self._experience_store: return try: + resolved_user_id = require_user_identity(user_id) self._experience_store.add( - user_id=(user_id or "default").strip() or "default", + user_id=resolved_user_id, pattern_type="verified_structure", content=description, paper_id=paper_id, @@ -447,7 +451,7 @@ def record_verified_structure( def record_failure_reason( self, *, - user_id: str = "default", + user_id: str, paper_id: Optional[str], pack_id: Optional[str] = None, error_type: str, @@ -458,8 +462,9 @@ def record_failure_reason( if not self._experience_store: return try: + resolved_user_id = require_user_identity(user_id) self._experience_store.add( - user_id=(user_id or "default").strip() or "default", + user_id=resolved_user_id, pattern_type="failure_reason", content=f"[{error_type}] fixed: {fix_applied}", paper_id=paper_id, diff --git a/src/paperbot/repro/nodes/generation_node.py b/src/paperbot/repro/nodes/generation_node.py index df02638d..7cbc2b08 100644 --- a/src/paperbot/repro/nodes/generation_node.py +++ b/src/paperbot/repro/nodes/generation_node.py @@ -12,10 +12,12 @@ import logging import time from typing import Dict, Any, Optional, List, Tuple, Union + from .base_node import BaseNode from ..models import PaperContext, ReproductionPlan, ImplementationSpec, Blueprint from ..memory import CodeMemory from ..rag import CodeKnowledgeBase +from paperbot.utils.user_identity import optional_user_identity logger = logging.getLogger(__name__) @@ -165,9 +167,9 @@ async def _execute(self, input_data: tuple, **kwargs) -> Dict[str, str]: paper_id = getattr(paper_context, "paper_id", None) or getattr( paper_context, "arxiv_id", None ) - user_id = (kwargs.get("user_id", "default") or "default").strip() or "default" + user_id = optional_user_identity(kwargs.get("user_id")) pack_id = kwargs.get("pack_id") - if paper_id: + if paper_id and user_id: self.memory.load_experiences_from_db(paper_id, user_id=user_id, pack_id=pack_id) files = {} @@ -199,13 +201,14 @@ async def _execute(self, input_data: tuple, **kwargs) -> Dict[str, str]: logger.debug(f"Generated {filepath} ({len(code)} chars)") # Persist success pattern (issue #162) - self.memory.record_success_pattern( - user_id=user_id, - paper_id=paper_id, - pack_id=pack_id, - filepath=filepath, - code_snippet=code[:1000], - ) + if user_id: + self.memory.record_success_pattern( + user_id=user_id, + paper_id=paper_id, + pack_id=pack_id, + filepath=filepath, + code_snippet=code[:1000], + ) # Add requirements.txt files["requirements.txt"] = self._generate_requirements(plan) diff --git a/src/paperbot/repro/nodes/verification_node.py b/src/paperbot/repro/nodes/verification_node.py index 9e2fdc52..ee3c6669 100644 --- a/src/paperbot/repro/nodes/verification_node.py +++ b/src/paperbot/repro/nodes/verification_node.py @@ -19,6 +19,7 @@ VerificationRuntimePreparationError, prepare_verification_runtime, ) +from paperbot.utils.user_identity import optional_user_identity from .base_node import BaseNode, NodeResult from ..models import ErrorType, PaperContext @@ -465,7 +466,7 @@ async def _execute(self, input_data: Any, **kwargs) -> VerificationResult: else: output_dir = Path(input_data) paper_context = None - user_id = (kwargs.get("user_id", "default") or "default").strip() or "default" + user_id = optional_user_identity(kwargs.get("user_id")) result = VerificationResult() debugger = SelfHealingDebugger(output_dir) if self.enable_self_healing else None @@ -541,7 +542,7 @@ async def _execute(self, input_data: Any, **kwargs) -> VerificationResult: result.smoke_ok = smoke_result["passed"] # Persist verified structure when all essential checks pass (issue #162) - if result.all_passed and self._experience_store: + if result.all_passed and self._experience_store and user_id: paper_context = ( input_data[1] if isinstance(input_data, tuple) and len(input_data) > 1 else None ) diff --git a/src/paperbot/repro/orchestrator.py b/src/paperbot/repro/orchestrator.py index 875c6c31..40fcfc06 100644 --- a/src/paperbot/repro/orchestrator.py +++ b/src/paperbot/repro/orchestrator.py @@ -27,6 +27,7 @@ VerificationAgent, ) from .models import PaperContext, ReproductionResult, ReproPhase +from paperbot.utils.user_identity import optional_user_identity logger = logging.getLogger(__name__) @@ -161,7 +162,7 @@ async def run( self, paper_context: PaperContext, *, - user_id: str = "default", + user_id: Optional[str] = None, pack_id: Optional[str] = None, run_id: Optional[str] = None, trace_id: Optional[str] = None, @@ -183,10 +184,11 @@ async def run( # use them for end-to-end correlation. self._run_id = run_id or new_run_id() self._trace_id = trace_id or new_trace_id() + resolved_user_id = optional_user_identity(user_id) self.context = { "paper_context": paper_context, - "user_id": (user_id or "default").strip() or "default", + "user_id": resolved_user_id, "pack_id": pack_id, "run_id": self._run_id, "trace_id": self._trace_id, diff --git a/src/paperbot/repro/repro_agent.py b/src/paperbot/repro/repro_agent.py index 12f108e7..10e76a32 100644 --- a/src/paperbot/repro/repro_agent.py +++ b/src/paperbot/repro/repro_agent.py @@ -46,6 +46,7 @@ from .orchestrator import Orchestrator, OrchestratorConfig from paperbot.application.services.llm_service import LLMService from paperbot.infrastructure.stores.repro_experience_store import ReproExperienceStore +from paperbot.utils.user_identity import optional_user_identity logger = logging.getLogger(__name__) @@ -255,7 +256,7 @@ async def reproduce_from_paper( paper_context: PaperContext, output_dir: Optional[Path] = None, *, - user_id: str = "default", + user_id: Optional[str] = None, pack_id: Optional[str] = None, event_log: "Optional[EventLogPort]" = None, run_id: Optional[str] = None, @@ -286,13 +287,14 @@ async def reproduce_from_paper( if output_dir is None: output_dir = Path(tempfile.mkdtemp(prefix="repro_")) output_dir.mkdir(parents=True, exist_ok=True) + resolved_user_id = optional_user_identity(user_id) # Use orchestrator mode if enabled if self.use_orchestrator: return await self._reproduce_with_orchestrator( paper_context, output_dir, - user_id=user_id, + user_id=resolved_user_id, pack_id=pack_id, event_log=event_log, run_id=run_id, @@ -303,7 +305,7 @@ async def reproduce_from_paper( return await self._reproduce_legacy( paper_context, output_dir, - user_id=user_id, + user_id=resolved_user_id, pack_id=pack_id, ) @@ -312,7 +314,7 @@ async def _reproduce_with_orchestrator( paper_context: PaperContext, output_dir: Path, *, - user_id: str = "default", + user_id: Optional[str] = None, pack_id: Optional[str] = None, event_log: "Optional[EventLogPort]" = None, run_id: Optional[str] = None, @@ -350,7 +352,7 @@ async def _reproduce_legacy( paper_context: PaperContext, output_dir: Path, *, - user_id: str = "default", + user_id: Optional[str] = None, pack_id: Optional[str] = None, ) -> ReproductionResult: """ @@ -490,7 +492,7 @@ async def _verify_with_self_healing( paper_context: PaperContext, result: ReproductionResult, *, - user_id: str = "default", + user_id: Optional[str] = None, ) -> None: """Run verification with self-healing debugger.""" # Pass paper context for better repair context diff --git a/src/paperbot/utils/user_identity.py b/src/paperbot/utils/user_identity.py new file mode 100644 index 00000000..321b33b0 --- /dev/null +++ b/src/paperbot/utils/user_identity.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import Optional + +LEGACY_DEFAULT_USER_ID = "default" + + +def normalize_user_id(user_id: Optional[str]) -> Optional[str]: + value = str(user_id or "").strip() + return value or None + + +def optional_user_identity(user_id: Optional[str]) -> Optional[str]: + value = normalize_user_id(user_id) + if value == LEGACY_DEFAULT_USER_ID: + return None + return value + + +def has_user_identity(user_id: Optional[str]) -> bool: + return optional_user_identity(user_id) is not None + + +def require_user_identity(user_id: Optional[str]) -> str: + value = optional_user_identity(user_id) + if value is None: + normalized = normalize_user_id(user_id) + if normalized == LEGACY_DEFAULT_USER_ID: + raise ValueError("legacy user_id 'default' is no longer supported") + raise ValueError("user_id is required") + return value diff --git a/tests/integration/test_events_sse_endpoint.py b/tests/integration/test_events_sse_endpoint.py new file mode 100644 index 00000000..3bac6173 --- /dev/null +++ b/tests/integration/test_events_sse_endpoint.py @@ -0,0 +1,121 @@ +""" +Integration tests for the /api/events/stream SSE endpoint. + +These tests exercise: + 1. Event delivery latency — event appended to EventBusEventLog arrives in queue < 1s. + 2. Heartbeat on idle — generator yields a keepalive comment when no events arrive + within the heartbeat window. + +Strategy (per plan 07-02 guidance): + - test_event_delivered_within_1s: calls _startup_eventlog() manually, then exercises + the EventBusEventLog subscribe/append/get path directly. This validates the full + round-trip from event_log.append() to the subscriber queue without needing an HTTP + server. The HTTP layer (StreamingResponse) is tested in e2e / manual UAT. + - test_heartbeat_on_idle: instantiates _event_generator() directly with a mock + request, drives it with a very short heartbeat timeout, and confirms the keepalive + comment is yielded before any data event. + +asyncio_mode = "strict" (pyproject.toml) — every async test needs @pytest.mark.asyncio. +""" +from __future__ import annotations + +import asyncio +import types + +import pytest + +from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog +from paperbot.infrastructure.event_log.composite_event_log import CompositeEventLog +from paperbot.infrastructure.event_log.logging_event_log import LoggingEventLog +from paperbot.api.routes.events import _event_generator + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _make_mock_request(disconnected: bool = False): + """ + Build a minimal async mock of starlette.requests.Request. + + Only `is_disconnected()` is needed by _event_generator. + """ + req = types.SimpleNamespace() + + async def is_disconnected(): + return disconnected + + req.is_disconnected = is_disconnected + return req + + +# --------------------------------------------------------------------------- +# test_event_delivered_within_1s +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_event_delivered_within_1s(): + """ + Event appended to the bus reaches a subscriber queue within 1 second. + + Validates the core delivery guarantee for EVNT-04 without a live HTTP server. + The test: + 1. Creates an EventBusEventLog and wraps it in CompositeEventLog (as main.py does). + 2. Subscribes a queue directly. + 3. Appends a test event via composite.append(). + 4. Awaits the queue item with a 1-second budget. + 5. Asserts the item contains the original run_id. + """ + bus = EventBusEventLog() + composite = CompositeEventLog([LoggingEventLog(), bus]) + + q = bus.subscribe() + + test_event = {"run_id": "test-run-123", "type": "test_event", "payload": "hello"} + composite.append(test_event) + + item = await asyncio.wait_for(q.get(), timeout=1.0) + assert item["run_id"] == "test-run-123" + assert item["type"] == "test_event" + + # Cleanup + bus.unsubscribe(q) + assert q not in bus._queues, "unsubscribe must remove queue from fan-out set" + + +# --------------------------------------------------------------------------- +# test_heartbeat_on_idle +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +@pytest.mark.integration +async def test_heartbeat_on_idle(): + """ + _event_generator emits a keepalive SSE comment when no events arrive + within the heartbeat window. + + We patch _HEARTBEAT_SECONDS to 0.05 s (50 ms) for speed then drive the + generator one iteration — it should yield the ``: keepalive\\n\\n`` comment. + """ + import paperbot.api.routes.events as events_module + + original_heartbeat = events_module._HEARTBEAT_SECONDS + events_module._HEARTBEAT_SECONDS = 0.05 # speed up the test + + try: + bus = EventBusEventLog() + mock_req = _make_mock_request(disconnected=False) + + gen = _event_generator(mock_req, bus) + + # The generator should yield a heartbeat comment (no events were appended) + frame = await asyncio.wait_for(gen.__anext__(), timeout=1.0) + + assert frame == ": keepalive\n\n", ( + f"Expected keepalive comment, got: {frame!r}" + ) + finally: + events_module._HEARTBEAT_SECONDS = original_heartbeat + # Close the generator so unsubscribe() runs + await gen.aclose() diff --git a/tests/integration/test_mcp_tool_calls.py b/tests/integration/test_mcp_tool_calls.py new file mode 100644 index 00000000..26605d72 --- /dev/null +++ b/tests/integration/test_mcp_tool_calls.py @@ -0,0 +1,1165 @@ +"""Integration tests verifying MCP tool listing and invocation via MCP protocol. + +Tests verify: +1. All 9 tools are discoverable (register functions + _impl functions) +2. Each tool has correct parameter signatures (types, required vs optional) +3. Tools are callable through the implementation layer +4. Tool calls are logged to EventLogPort with correct workflow/stage + +Note: The mcp package (FastMCP) requires Python 3.10+ and is not available +on Python 3.9.x. These tests exercise the tool implementations directly +via _impl functions, which is the same code path that FastMCP's @mcp.tool() +decorators call. When mcp is unavailable, server.py sets mcp=None and +tool registration is skipped, but the implementations remain fully functional. +""" + +import inspect +import json +from typing import Any, Dict, List, Optional + +import pytest + +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.application.workflows.analysis.paper_judge import PaperJudge +from paperbot.application.workflows.analysis.paper_summarizer import PaperSummarizer +from paperbot.application.workflows.analysis.relevance_assessor import RelevanceAssessor +from paperbot.core.di import Container +from paperbot.domain.paper import PaperCandidate +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +# --------------------------------------------------------------------------- +# Fakes for existing 4 tools +# --------------------------------------------------------------------------- + + +class _FakeSearchAdapter: + """Implements SearchPort with canned results.""" + + source_name = "fake" + + async def search(self, query, *, max_results=10, year_from=None, year_to=None): + if query == "empty": + return [] + return [ + PaperCandidate( + title="Integration Test Paper", + abstract="Abstract for integration test", + authors=["Author A"], + ) + ] + + async def close(self): + pass + + +class _FakeJudgeLLM: + """Returns a valid JSON payload for PaperJudge.""" + + PAYLOAD = { + "relevance": {"score": 4, "rationale": "relevant"}, + "novelty": {"score": 3, "rationale": "moderate"}, + "rigor": {"score": 4, "rationale": "solid"}, + "impact": {"score": 3, "rationale": "some"}, + "clarity": {"score": 5, "rationale": "clear"}, + "overall": 3.8, + "one_line_summary": "integration test paper", + "recommendation": "worth_reading", + } + + def complete(self, **kwargs): + return json.dumps(self.PAYLOAD) + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "test-model", "cost_tier": 1} + + +class _FakeSummarizerLLM: + """Returns a canned summary for PaperSummarizer.""" + + def summarize_paper(self, title: str, abstract: str) -> str: + return "A concise summary of the paper." + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "sum-model", "cost_tier": 1} + + +class _FakeRelevanceLLM: + """Returns a valid relevance assessment.""" + + def assess_relevance(self, *, paper, query): + return {"score": 90, "reason": "Highly relevant to the query."} + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "rel-model", "cost_tier": 1} + + +# --------------------------------------------------------------------------- +# Fakes for 5 new tools (Plans 01 and 02) +# --------------------------------------------------------------------------- + + +class _FakeTrendAnalyzer: + """TrendAnalyzer stub returning a canned analysis string.""" + + def analyze(self, *, topic: str, items) -> str: + return "Trend analysis result" + + +class _FakeS2Client: + """Fake SemanticScholarClient returning a test author and papers.""" + + async def search_authors(self, query, limit=10, fields=None): + return [ + { + "authorId": "123", + "name": "Scholar", + "hIndex": 10, + "paperCount": 50, + "citationCount": 1000, + } + ] + + async def get_author_papers(self, author_id, limit=10, fields=None): + return [ + { + "title": "Paper", + "year": 2024, + "citationCount": 5, + "venue": "ICML", + } + ] + + +class _FakeContextEngine: + """Fake ContextEngine returning a minimal context pack.""" + + async def build_context_pack(self, user_id, query, track_id=None): + return { + "paper_recommendations": [], + "relevant_memories": [], + "active_track": None, + "routing": {"stage": "explore", "suggestion": None}, + } + + +class _FakeMemoryStore: + """Fake SqlAlchemyMemoryStore returning (created, skipped, rows) tuple.""" + + def add_memories(self, user_id, memories): + return (1, 0, []) + + +class _FakeExporter: + """Fake ObsidianFilesystemExporter returning a minimal rendered note.""" + + def _render_paper_note(self, **kwargs): + return "# Title\n\nBody text" + + def _yaml_frontmatter(self, data): + return "---\ntitle: Title\n---\n" + + +# --------------------------------------------------------------------------- +# Tool listing (discovery) tests +# --------------------------------------------------------------------------- + + +EXPECTED_TOOLS = [ + "paper_search", + "paper_judge", + "paper_summarize", + "relevance_assess", + "analyze_trends", + "check_scholar", + "get_research_context", + "save_to_memory", + "export_to_obsidian", +] + + +class TestMCPToolListing: + """Verify all 9 tools are discoverable and have correct signatures.""" + + def setup_method(self): + Container._instance = None + + def test_all_nine_tools_listed(self): + """All 9 tool modules expose register() and _impl functions.""" + from paperbot.mcp.tools import paper_search, paper_judge, paper_summarize, relevance + from paperbot.mcp.tools import analyze_trends, check_scholar, get_research_context + from paperbot.mcp.tools import save_to_memory, export_to_obsidian + + modules = { + "paper_search": paper_search, + "paper_judge": paper_judge, + "paper_summarize": paper_summarize, + "relevance_assess": relevance, + "analyze_trends": analyze_trends, + "check_scholar": check_scholar, + "get_research_context": get_research_context, + "save_to_memory": save_to_memory, + "export_to_obsidian": export_to_obsidian, + } + + # Verify exactly 9 tools + assert len(modules) == 9, f"Expected 9 tools, found {len(modules)}" + + for tool_name, mod in modules.items(): + # Each module has a register() function + assert hasattr(mod, "register"), f"{tool_name} missing register()" + assert callable(mod.register), f"{tool_name}.register is not callable" + + # Each module has an _impl function with a docstring (description) + impl_name = f"_{tool_name}_impl" + if tool_name == "relevance_assess": + impl_name = "_relevance_assess_impl" + impl_fn = getattr(mod, impl_name, None) + assert impl_fn is not None, f"{tool_name} missing {impl_name}" + assert impl_fn.__doc__, f"{tool_name} impl has no docstring (description)" + + def test_server_registers_all_nine_tools(self): + """server.py imports and calls register() for all 9 tool modules. + + Since mcp package is unavailable on Python 3.9, mcp=None. We verify + by checking that the server module completed import without error and + that all 9 tool register functions are referenced in the module source. + """ + import paperbot.mcp.server as server_mod + + # server.py should have mcp attribute (None if package unavailable) + assert hasattr(server_mod, "mcp") + + # Verify all 9 imports are present in the source + source = inspect.getsource(server_mod) + assert "paper_search.register" in source, "paper_search not registered in server.py" + assert "paper_judge.register" in source, "paper_judge not registered in server.py" + assert "paper_summarize.register" in source, "paper_summarize not registered in server.py" + assert "relevance.register" in source, "relevance not registered in server.py" + assert "analyze_trends.register" in source, "analyze_trends not registered in server.py" + assert "check_scholar.register" in source, "check_scholar not registered in server.py" + assert "get_research_context.register" in source, ( + "get_research_context not registered in server.py" + ) + assert "save_to_memory.register" in source, "save_to_memory not registered in server.py" + assert "export_to_obsidian.register" in source, ( + "export_to_obsidian not registered in server.py" + ) + + def test_each_tool_has_input_schema_via_signature(self): + """Each tool impl has typed parameters serving as input schema.""" + from paperbot.mcp.tools.paper_search import _paper_search_impl + from paperbot.mcp.tools.paper_judge import _paper_judge_impl + from paperbot.mcp.tools.paper_summarize import _paper_summarize_impl + from paperbot.mcp.tools.relevance import _relevance_assess_impl + from paperbot.mcp.tools.analyze_trends import _analyze_trends_impl + from paperbot.mcp.tools.check_scholar import _check_scholar_impl + from paperbot.mcp.tools.get_research_context import _get_research_context_impl + from paperbot.mcp.tools.save_to_memory import _save_to_memory_impl + from paperbot.mcp.tools.export_to_obsidian import _export_to_obsidian_impl + + for name, fn in [ + ("paper_search", _paper_search_impl), + ("paper_judge", _paper_judge_impl), + ("paper_summarize", _paper_summarize_impl), + ("relevance_assess", _relevance_assess_impl), + ("analyze_trends", _analyze_trends_impl), + ("check_scholar", _check_scholar_impl), + ("get_research_context", _get_research_context_impl), + ("save_to_memory", _save_to_memory_impl), + ("export_to_obsidian", _export_to_obsidian_impl), + ]: + sig = inspect.signature(fn) + params = sig.parameters + + # Each tool must have at least one required parameter + required = [ + p + for p in params.values() + if p.default is inspect.Parameter.empty and p.name != "self" + ] + assert len(required) >= 1, f"{name} has no required parameters" + + # Each parameter must have a type annotation (may be a string + # due to ``from __future__ import annotations``) + for pname, param in params.items(): + assert param.annotation is not inspect.Parameter.empty, ( + f"{name}.{pname} has no type annotation" + ) + + +# --------------------------------------------------------------------------- +# Schema validation tests +# --------------------------------------------------------------------------- + + +class TestMCPToolSchemas: + """Verify each tool's parameter schema matches expectations. + + Note: Tool modules use ``from __future__ import annotations`` (PEP 563), + so annotations are strings at runtime. We compare against the string + representation (e.g. ``"str"``) rather than the type object itself. + """ + + def setup_method(self): + Container._instance = None + + @staticmethod + def _annotation_name(param: inspect.Parameter) -> str: + """Return annotation as a plain string for comparison.""" + ann = param.annotation + if ann is inspect.Parameter.empty: + return "" + return ann if isinstance(ann, str) else getattr(ann, "__name__", str(ann)) + + def test_paper_search_tool_has_correct_params(self): + """paper_search has query (required, str), max_results (optional, int), + sources (optional, list), _run_id (optional).""" + from paperbot.mcp.tools.paper_search import _paper_search_impl + + sig = inspect.signature(_paper_search_impl) + params = sig.parameters + + # query is required, str + assert "query" in params + assert params["query"].default is inspect.Parameter.empty + assert self._annotation_name(params["query"]) == "str" + + # max_results is optional, int, default 10 + assert "max_results" in params + assert params["max_results"].default == 10 + assert self._annotation_name(params["max_results"]) == "int" + + # sources is optional, list or None + assert "sources" in params + assert params["sources"].default is None + + # _run_id is present + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_paper_judge_tool_has_correct_params(self): + """paper_judge has title (required, str), abstract (required, str), + full_text (optional), rubric (optional).""" + from paperbot.mcp.tools.paper_judge import _paper_judge_impl + + sig = inspect.signature(_paper_judge_impl) + params = sig.parameters + + # title is required, str + assert "title" in params + assert params["title"].default is inspect.Parameter.empty + assert self._annotation_name(params["title"]) == "str" + + # abstract is required, str + assert "abstract" in params + assert params["abstract"].default is inspect.Parameter.empty + assert self._annotation_name(params["abstract"]) == "str" + + # full_text is optional + assert "full_text" in params + assert params["full_text"].default == "" + + # rubric is optional + assert "rubric" in params + assert params["rubric"].default == "default" + + def test_paper_summarize_tool_has_correct_params(self): + """paper_summarize has title (required, str), abstract (required, str).""" + from paperbot.mcp.tools.paper_summarize import _paper_summarize_impl + + sig = inspect.signature(_paper_summarize_impl) + params = sig.parameters + + # title is required, str + assert "title" in params + assert params["title"].default is inspect.Parameter.empty + assert self._annotation_name(params["title"]) == "str" + + # abstract is required, str + assert "abstract" in params + assert params["abstract"].default is inspect.Parameter.empty + assert self._annotation_name(params["abstract"]) == "str" + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_relevance_assess_tool_has_correct_params(self): + """relevance_assess has title (required), abstract (required), + query (required), keywords (optional).""" + from paperbot.mcp.tools.relevance import _relevance_assess_impl + + sig = inspect.signature(_relevance_assess_impl) + params = sig.parameters + + # title is required, str + assert "title" in params + assert params["title"].default is inspect.Parameter.empty + assert self._annotation_name(params["title"]) == "str" + + # abstract is required, str + assert "abstract" in params + assert params["abstract"].default is inspect.Parameter.empty + + # query is required, str + assert "query" in params + assert params["query"].default is inspect.Parameter.empty + assert self._annotation_name(params["query"]) == "str" + + # keywords is optional + assert "keywords" in params + assert params["keywords"].default == "" + + def test_analyze_trends_tool_has_correct_params(self): + """analyze_trends has topic (required, str), papers (required, list), + _run_id (optional).""" + from paperbot.mcp.tools.analyze_trends import _analyze_trends_impl + + sig = inspect.signature(_analyze_trends_impl) + params = sig.parameters + + # topic is required, str + assert "topic" in params + assert params["topic"].default is inspect.Parameter.empty + assert self._annotation_name(params["topic"]) == "str" + + # papers is required, list + assert "papers" in params + assert params["papers"].default is inspect.Parameter.empty + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_check_scholar_tool_has_correct_params(self): + """check_scholar has scholar_name (required, str), max_papers (optional, int, default 10), + _run_id (optional).""" + from paperbot.mcp.tools.check_scholar import _check_scholar_impl + + sig = inspect.signature(_check_scholar_impl) + params = sig.parameters + + # scholar_name is required, str + assert "scholar_name" in params + assert params["scholar_name"].default is inspect.Parameter.empty + assert self._annotation_name(params["scholar_name"]) == "str" + + # max_papers is optional, int, default 10 + assert "max_papers" in params + assert params["max_papers"].default == 10 + assert self._annotation_name(params["max_papers"]) == "int" + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_get_research_context_tool_has_correct_params(self): + """get_research_context has query/user_id required and track_id/_run_id optional.""" + from paperbot.mcp.tools.get_research_context import _get_research_context_impl + + sig = inspect.signature(_get_research_context_impl) + params = sig.parameters + + # query is required, str + assert "query" in params + assert params["query"].default is inspect.Parameter.empty + assert self._annotation_name(params["query"]) == "str" + + # user_id is required + assert "user_id" in params + assert params["user_id"].default is inspect.Parameter.empty + + # track_id is optional + assert "track_id" in params + assert params["track_id"].default is None + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_save_to_memory_tool_has_correct_params(self): + """save_to_memory has required content/user_id and optional kind/scope/confidence fields.""" + from paperbot.mcp.tools.save_to_memory import _save_to_memory_impl + + sig = inspect.signature(_save_to_memory_impl) + params = sig.parameters + + # content is required, str + assert "content" in params + assert params["content"].default is inspect.Parameter.empty + assert self._annotation_name(params["content"]) == "str" + + # kind is optional with default "note" + assert "kind" in params + assert params["kind"].default == "note" + + # user_id is required and keyword-only + assert "user_id" in params + assert params["user_id"].default is inspect.Parameter.empty + assert params["user_id"].kind is inspect.Parameter.KEYWORD_ONLY + + # scope_type is optional + assert "scope_type" in params + + # confidence is optional + assert "confidence" in params + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + def test_export_to_obsidian_tool_has_correct_params(self): + """export_to_obsidian has title (required, str), abstract (required, str), + authors (optional), year (optional), _run_id (optional).""" + from paperbot.mcp.tools.export_to_obsidian import _export_to_obsidian_impl + + sig = inspect.signature(_export_to_obsidian_impl) + params = sig.parameters + + # title is required, str + assert "title" in params + assert params["title"].default is inspect.Parameter.empty + assert self._annotation_name(params["title"]) == "str" + + # abstract is required, str + assert "abstract" in params + assert params["abstract"].default is inspect.Parameter.empty + assert self._annotation_name(params["abstract"]) == "str" + + # authors is optional + assert "authors" in params + + # year is optional + assert "year" in params + assert params["year"].default is None + + # _run_id is optional + assert "_run_id" in params + assert params["_run_id"].default == "" + + +# --------------------------------------------------------------------------- +# Tool invocation tests (via _impl functions) +# --------------------------------------------------------------------------- + + +class TestMCPToolInvocation: + """Verify tools are callable through the implementation layer. + + Note: FastMCP's in-process tool calling (e.g. mcp.call_tool) requires + the mcp package which needs Python 3.10+. On Python 3.9, we test the + _impl functions directly -- these are the exact same functions that + the @mcp.tool() decorators wrap. + """ + + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_tool_call_paper_search_via_impl(self): + """Calling paper_search through _impl returns paper results.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + service = PaperSearchService(adapters={"fake": _FakeSearchAdapter()}) + ps_mod._service = service + + try: + result = await ps_mod._paper_search_impl(query="transformers", max_results=5) + finally: + ps_mod._service = None + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["title"] == "Integration Test Paper" + assert "abstract" in result[0] + assert "authors" in result[0] + + @pytest.mark.asyncio + async def test_tool_call_paper_judge_via_impl(self): + """Calling paper_judge through _impl returns judgment with scores.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + judge = PaperJudge(llm_service=_FakeJudgeLLM()) + pj_mod._judge = judge + + try: + result = await pj_mod._paper_judge_impl( + title="Integration Test", + abstract="An abstract for integration testing.", + ) + finally: + pj_mod._judge = None + + assert isinstance(result, dict) + assert "overall" in result + assert "recommendation" in result + assert result["recommendation"] == "worth_reading" + + @pytest.mark.asyncio + async def test_tool_call_paper_summarize_via_impl(self): + """Calling paper_summarize through _impl returns summary dict.""" + import paperbot.mcp.tools.paper_summarize as ps_mod + + summarizer = PaperSummarizer(llm_service=_FakeSummarizerLLM()) + ps_mod._summarizer = summarizer + + try: + result = await ps_mod._paper_summarize_impl( + title="Integration Test", + abstract="An abstract for integration testing.", + ) + finally: + ps_mod._summarizer = None + + assert isinstance(result, dict) + assert "summary" in result + assert len(result["summary"]) > 0 + assert "degraded" not in result + + @pytest.mark.asyncio + async def test_tool_call_relevance_assess_via_impl(self): + """Calling relevance_assess through _impl returns score and reason.""" + import paperbot.mcp.tools.relevance as rel_mod + + assessor = RelevanceAssessor(llm_service=_FakeRelevanceLLM()) + rel_mod._assessor = assessor + + try: + result = await rel_mod._relevance_assess_impl( + title="Integration Test", + abstract="An abstract for integration testing.", + query="machine learning", + ) + finally: + rel_mod._assessor = None + + assert isinstance(result, dict) + assert result["score"] == 90 + assert "reason" in result + assert "degraded" not in result + + @pytest.mark.asyncio + async def test_tool_call_analyze_trends_via_impl(self): + """Calling analyze_trends through _impl returns trend_analysis result.""" + import paperbot.mcp.tools.analyze_trends as at_mod + + at_mod._analyzer = _FakeTrendAnalyzer() + + try: + result = await at_mod._analyze_trends_impl( + topic="llms", + papers=[{"title": "Paper A"}, {"title": "Paper B"}], + ) + finally: + at_mod._analyzer = None + + assert isinstance(result, dict) + assert "trend_analysis" in result + assert result["trend_analysis"] == "Trend analysis result" + assert result["topic"] == "llms" + assert result["paper_count"] == 2 + + @pytest.mark.asyncio + async def test_tool_call_check_scholar_via_impl(self): + """Calling check_scholar through _impl returns scholar and recent_papers.""" + import paperbot.mcp.tools.check_scholar as cs_mod + + cs_mod._client = _FakeS2Client() + + try: + result = await cs_mod._check_scholar_impl(scholar_name="Scholar") + finally: + cs_mod._client = None + + assert isinstance(result, dict) + assert "scholar" in result + assert "recent_papers" in result + assert result["scholar"]["name"] == "Scholar" + assert len(result["recent_papers"]) == 1 + + @pytest.mark.asyncio + async def test_tool_call_get_research_context_via_impl(self): + """Calling get_research_context through _impl returns normalized context aliases.""" + import paperbot.mcp.tools.get_research_context as grc_mod + + grc_mod._engine = _FakeContextEngine() + + try: + result = await grc_mod._get_research_context_impl( + query="transformer architectures", + user_id="mcp-user", + ) + finally: + grc_mod._engine = None + + assert isinstance(result, dict) + assert "papers" in result + assert "paper_recommendations" in result + assert result["stage"] == "explore" + + @pytest.mark.asyncio + async def test_tool_call_save_to_memory_via_impl(self): + """Calling save_to_memory through _impl returns dict with saved key.""" + import paperbot.mcp.tools.save_to_memory as stm_mod + + stm_mod._store = _FakeMemoryStore() + + try: + result = await stm_mod._save_to_memory_impl( + content="Important finding about attention mechanisms.", + user_id="mcp-user", + ) + finally: + stm_mod._store = None + + assert isinstance(result, dict) + assert "saved" in result + assert result["saved"] is True + assert "created" in result + assert result["created"] == 1 + + @pytest.mark.asyncio + async def test_tool_call_export_to_obsidian_via_impl(self): + """Calling export_to_obsidian through _impl returns dict with markdown key.""" + import paperbot.mcp.tools.export_to_obsidian as eto_mod + + eto_mod._exporter = _FakeExporter() + + try: + result = await eto_mod._export_to_obsidian_impl( + title="Attention Is All You Need", + abstract="We propose the Transformer, a model architecture...", + authors=["Vaswani", "Shazeer"], + year=2017, + ) + finally: + eto_mod._exporter = None + + assert isinstance(result, dict) + assert "markdown" in result + assert len(result["markdown"]) > 0 + + +# --------------------------------------------------------------------------- +# Event logging tests +# --------------------------------------------------------------------------- + + +class TestMCPToolEventLogging: + """Verify tool calls are logged to EventLogPort during MCP protocol calls.""" + + def setup_method(self): + Container._instance = None + + def _register_event_log(self) -> InMemoryEventLog: + """Register InMemoryEventLog in Container and return it.""" + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + return log + + @pytest.mark.asyncio + async def test_tool_call_paper_search_logs_event(self): + """paper_search logs event with workflow='mcp', stage='tool_call'.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + log = self._register_event_log() + + service = PaperSearchService(adapters={"fake": _FakeSearchAdapter()}) + ps_mod._service = service + + try: + await ps_mod._paper_search_impl(query="test", max_results=5) + finally: + ps_mod._service = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "paper_search" + + @pytest.mark.asyncio + async def test_tool_call_paper_judge_logs_event(self): + """paper_judge logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + log = self._register_event_log() + + judge = PaperJudge(llm_service=_FakeJudgeLLM()) + pj_mod._judge = judge + + try: + await pj_mod._paper_judge_impl(title="Test", abstract="Abstract") + finally: + pj_mod._judge = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "paper_judge" + + @pytest.mark.asyncio + async def test_tool_call_paper_summarize_logs_event(self): + """paper_summarize logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.paper_summarize as ps_mod + + log = self._register_event_log() + + summarizer = PaperSummarizer(llm_service=_FakeSummarizerLLM()) + ps_mod._summarizer = summarizer + + try: + await ps_mod._paper_summarize_impl(title="Test", abstract="Abstract") + finally: + ps_mod._summarizer = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "paper_summarize" + + @pytest.mark.asyncio + async def test_tool_call_relevance_assess_logs_event(self): + """relevance_assess logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.relevance as rel_mod + + log = self._register_event_log() + + assessor = RelevanceAssessor(llm_service=_FakeRelevanceLLM()) + rel_mod._assessor = assessor + + try: + await rel_mod._relevance_assess_impl( + title="Test", abstract="Abstract", query="ML" + ) + finally: + rel_mod._assessor = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "relevance_assess" + + @pytest.mark.asyncio + async def test_tool_call_analyze_trends_logs_event(self): + """analyze_trends logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.analyze_trends as at_mod + + log = self._register_event_log() + + at_mod._analyzer = _FakeTrendAnalyzer() + + try: + await at_mod._analyze_trends_impl( + topic="transformers", + papers=[{"title": "Attention Is All You Need"}], + ) + finally: + at_mod._analyzer = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "analyze_trends" + + @pytest.mark.asyncio + async def test_tool_call_check_scholar_logs_event(self): + """check_scholar logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.check_scholar as cs_mod + + log = self._register_event_log() + + cs_mod._client = _FakeS2Client() + + try: + await cs_mod._check_scholar_impl(scholar_name="Scholar") + finally: + cs_mod._client = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "check_scholar" + + @pytest.mark.asyncio + async def test_tool_call_get_research_context_logs_event(self): + """get_research_context logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.get_research_context as grc_mod + + log = self._register_event_log() + + grc_mod._engine = _FakeContextEngine() + + try: + await grc_mod._get_research_context_impl(query="llms", user_id="mcp-user") + finally: + grc_mod._engine = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "get_research_context" + + @pytest.mark.asyncio + async def test_tool_call_save_to_memory_logs_event(self): + """save_to_memory logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.save_to_memory as stm_mod + + log = self._register_event_log() + + stm_mod._store = _FakeMemoryStore() + + try: + await stm_mod._save_to_memory_impl(content="Important finding.", user_id="mcp-user") + finally: + stm_mod._store = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "save_to_memory" + + @pytest.mark.asyncio + async def test_tool_call_export_to_obsidian_logs_event(self): + """export_to_obsidian logs event with workflow='mcp', stage='tool_call'.""" + import paperbot.mcp.tools.export_to_obsidian as eto_mod + + log = self._register_event_log() + + eto_mod._exporter = _FakeExporter() + + try: + await eto_mod._export_to_obsidian_impl( + title="Test Paper", + abstract="A test abstract.", + ) + finally: + eto_mod._exporter = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["payload"]["tool"] == "export_to_obsidian" + + @pytest.mark.asyncio + async def test_all_tool_events_have_consistent_structure(self): + """All 9 tool events share consistent structure: workflow, stage, + agent_name, payload with tool name.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + import paperbot.mcp.tools.paper_judge as pj_mod + import paperbot.mcp.tools.paper_summarize as psum_mod + import paperbot.mcp.tools.relevance as rel_mod + import paperbot.mcp.tools.analyze_trends as at_mod + import paperbot.mcp.tools.check_scholar as cs_mod + import paperbot.mcp.tools.get_research_context as grc_mod + import paperbot.mcp.tools.save_to_memory as stm_mod + import paperbot.mcp.tools.export_to_obsidian as eto_mod + + log = self._register_event_log() + + # Set up all fakes + ps_mod._service = PaperSearchService(adapters={"fake": _FakeSearchAdapter()}) + pj_mod._judge = PaperJudge(llm_service=_FakeJudgeLLM()) + psum_mod._summarizer = PaperSummarizer(llm_service=_FakeSummarizerLLM()) + rel_mod._assessor = RelevanceAssessor(llm_service=_FakeRelevanceLLM()) + at_mod._analyzer = _FakeTrendAnalyzer() + cs_mod._client = _FakeS2Client() + grc_mod._engine = _FakeContextEngine() + stm_mod._store = _FakeMemoryStore() + eto_mod._exporter = _FakeExporter() + + try: + await ps_mod._paper_search_impl(query="test") + await pj_mod._paper_judge_impl(title="T", abstract="A") + await psum_mod._paper_summarize_impl(title="T", abstract="A") + await rel_mod._relevance_assess_impl(title="T", abstract="A", query="Q") + await at_mod._analyze_trends_impl(topic="llms", papers=[{"title": "P"}]) + await cs_mod._check_scholar_impl(scholar_name="Scholar") + await grc_mod._get_research_context_impl( + query="transformers", + user_id="mcp-user", + ) + await stm_mod._save_to_memory_impl(content="Finding.", user_id="mcp-user") + await eto_mod._export_to_obsidian_impl(title="T", abstract="A") + finally: + ps_mod._service = None + pj_mod._judge = None + psum_mod._summarizer = None + rel_mod._assessor = None + at_mod._analyzer = None + cs_mod._client = None + grc_mod._engine = None + stm_mod._store = None + eto_mod._exporter = None + + assert len(log.events) == 9 + + tool_names_logged = set() + for event in log.events: + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["agent_name"] == "paperbot-mcp" + assert "tool" in event["payload"] + assert "duration_ms" in event["metrics"] + tool_names_logged.add(event["payload"]["tool"]) + + assert tool_names_logged == { + "paper_search", + "paper_judge", + "paper_summarize", + "relevance_assess", + "analyze_trends", + "check_scholar", + "get_research_context", + "save_to_memory", + "export_to_obsidian", + } + + +# --------------------------------------------------------------------------- +# Resource listing (discovery) tests +# --------------------------------------------------------------------------- + + +EXPECTED_RESOURCES = [ + "track_metadata", + "track_papers", + "track_memory", + "scholars", +] + + +class TestMCPResourceListing: + """Verify all 4 resources are discoverable and registered in server.py. + + URI template resources (track/{id}, track/{id}/papers, track/{id}/memory) + appear in list_resource_templates; static resources (scholars) appear in + list_resources. Both are verified via source inspection since FastMCP + cannot be invoked directly on Python 3.9. + """ + + def setup_method(self): + Container._instance = None + + def test_all_four_resources_listed(self): + """All 4 resource modules expose register() and _impl functions.""" + from paperbot.mcp.resources import track_metadata, track_papers, track_memory, scholars + + modules = { + "track_metadata": (track_metadata, "_track_metadata_impl"), + "track_papers": (track_papers, "_track_papers_impl"), + "track_memory": (track_memory, "_track_memory_impl"), + "scholars": (scholars, "_scholars_impl"), + } + + # Verify exactly 4 resources + assert len(modules) == 4, f"Expected 4 resources, found {len(modules)}" + + for resource_name, (mod, impl_name) in modules.items(): + # Each module has a register() function + assert hasattr(mod, "register"), f"{resource_name} missing register()" + assert callable(mod.register), f"{resource_name}.register is not callable" + + # Each module has an _impl function + impl_fn = getattr(mod, impl_name, None) + assert impl_fn is not None, f"{resource_name} missing {impl_name}" + assert impl_fn.__doc__, f"{resource_name} impl has no docstring (description)" + + def test_server_registers_all_four_resources(self): + """server.py imports and calls register() for all 4 resource modules. + + Since mcp package is unavailable on Python 3.9, mcp=None. We verify + by checking that the server module source contains all 4 resource + register() calls (same approach as test_server_registers_all_nine_tools). + """ + import paperbot.mcp.server as server_mod + + # server.py should have mcp attribute (None if package unavailable) + assert hasattr(server_mod, "mcp") + + # Verify all 4 resource registrations are present in the source + source = inspect.getsource(server_mod) + assert "track_metadata.register" in source, ( + "track_metadata not registered in server.py" + ) + assert "track_papers.register" in source, ( + "track_papers not registered in server.py" + ) + assert "track_memory.register" in source, ( + "track_memory not registered in server.py" + ) + assert "scholars.register" in source, ( + "scholars not registered in server.py" + ) + + def test_each_resource_impl_has_correct_signature(self): + """Each resource _impl function has the expected parameter signature. + + User-scoped URI template resources require user_id and track_id: str. + The static scholars resource has no required parameters. + """ + from paperbot.mcp.resources.track_metadata import _track_metadata_impl + from paperbot.mcp.resources.track_papers import _track_papers_impl + from paperbot.mcp.resources.track_memory import _track_memory_impl + from paperbot.mcp.resources.scholars import _scholars_impl + + # track_metadata_impl: requires user_id + track_id: str + sig = inspect.signature(_track_metadata_impl) + params = sig.parameters + assert "user_id" in params, "_track_metadata_impl missing user_id param" + assert params["user_id"].default is inspect.Parameter.empty, ( + "user_id should be required" + ) + assert "track_id" in params, "_track_metadata_impl missing track_id param" + assert params["track_id"].default is inspect.Parameter.empty, ( + "track_id should be required" + ) + + # track_papers_impl: requires user_id + track_id: str + sig = inspect.signature(_track_papers_impl) + params = sig.parameters + assert "user_id" in params, "_track_papers_impl missing user_id param" + assert params["user_id"].default is inspect.Parameter.empty, ( + "user_id should be required" + ) + assert "track_id" in params, "_track_papers_impl missing track_id param" + assert params["track_id"].default is inspect.Parameter.empty, ( + "track_id should be required" + ) + + # track_memory_impl: requires user_id + track_id: str + sig = inspect.signature(_track_memory_impl) + params = sig.parameters + assert "user_id" in params, "_track_memory_impl missing user_id param" + assert params["user_id"].default is inspect.Parameter.empty, ( + "user_id should be required" + ) + assert "track_id" in params, "_track_memory_impl missing track_id param" + assert params["track_id"].default is inspect.Parameter.empty, ( + "track_id should be required" + ) + + # _scholars_impl: static resource — no required parameters + sig = inspect.signature(_scholars_impl) + required_params = [ + p + for p in sig.parameters.values() + if p.default is inspect.Parameter.empty and p.name != "self" + ] + assert len(required_params) == 0, ( + f"_scholars_impl should have no required params, found: " + f"{[p.name for p in required_params]}" + ) diff --git a/tests/integration/test_research_track_context_routes.py b/tests/integration/test_research_track_context_routes.py index 7d059c43..3ae987a4 100644 --- a/tests/integration/test_research_track_context_routes.py +++ b/tests/integration/test_research_track_context_routes.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from paperbot.api import main as api_main +from paperbot.api.auth import dependencies as auth_deps from paperbot.api.routes import research as research_route from paperbot.infrastructure.stores.memory_store import SqlAlchemyMemoryStore from paperbot.infrastructure.stores.paper_store import SqlAlchemyPaperStore @@ -12,6 +13,13 @@ from paperbot.memory.schema import MemoryCandidate +def _override_user_id(user_id: str): + def _dep_override(): + return user_id + + return _dep_override + + def _prepare_context_route_db(tmp_path: Path): db_path = tmp_path / "track-context.db" db_url = f"sqlite:///{db_path}" @@ -98,11 +106,13 @@ def test_track_context_route_returns_consolidated_snapshot(tmp_path, monkeypatch monkeypatch.setattr(research_route, "_research_store", research_store) monkeypatch.setattr(research_route, "_memory_store", memory_store) - with TestClient(api_main.app) as client: - response = client.get( - f"/api/research/tracks/{track_id}/context", - params={"user_id": "u-context"}, - ) + app = api_main.app + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("u-context") + try: + with TestClient(app) as client: + response = client.get(f"/api/research/tracks/{track_id}/context") + finally: + app.dependency_overrides.pop(auth_deps.get_required_user_id, None) assert response.status_code == 200 payload = response.json() @@ -118,7 +128,9 @@ def test_track_context_route_returns_consolidated_snapshot(tmp_path, monkeypatch assert payload["feedback"]["actions"]["save"] == 1 assert payload["feedback"]["actions"]["like"] == 1 assert payload["saved_papers"]["total_items"] == 1 - assert payload["saved_papers"]["recent_items"][0]["paper"]["title"] == "Context-Routed Retrieval" + assert ( + payload["saved_papers"]["recent_items"][0]["paper"]["title"] == "Context-Routed Retrieval" + ) assert "feedback_coverage" in payload["eval_summary"] @@ -127,15 +139,17 @@ def test_track_context_route_returns_404_for_missing_or_inaccessible_track(tmp_p monkeypatch.setattr(research_route, "_research_store", research_store) monkeypatch.setattr(research_route, "_memory_store", memory_store) - with TestClient(api_main.app) as client: - missing = client.get( - "/api/research/tracks/999999/context", - params={"user_id": "u-context"}, - ) - wrong_user = client.get( - f"/api/research/tracks/{track_id}/context", - params={"user_id": "other-user"}, - ) + app = api_main.app + try: + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("u-context") + with TestClient(app) as client: + missing = client.get("/api/research/tracks/999999/context") + + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("other-user") + with TestClient(app) as client: + wrong_user = client.get(f"/api/research/tracks/{track_id}/context") + finally: + app.dependency_overrides.pop(auth_deps.get_required_user_id, None) assert missing.status_code == 404 assert wrong_user.status_code == 404 diff --git a/tests/integration/test_research_track_routes.py b/tests/integration/test_research_track_routes.py index edac9bdd..f7f224b0 100644 --- a/tests/integration/test_research_track_routes.py +++ b/tests/integration/test_research_track_routes.py @@ -3,6 +3,7 @@ import pytest from fastapi.testclient import TestClient +from paperbot.api.auth import dependencies as auth_deps from paperbot.api.main import app from paperbot.infrastructure.stores.research_store import SqlAlchemyResearchStore @@ -17,8 +18,13 @@ def client_with_store(tmp_path, monkeypatch): import paperbot.api.routes.research as research_module monkeypatch.setattr(research_module, "_research_store", store) + app.dependency_overrides[auth_deps.get_required_user_id] = lambda: "test" - return TestClient(app), store + try: + with TestClient(app) as client: + yield client, store + finally: + app.dependency_overrides.clear() def test_patch_track_success(client_with_store): @@ -27,9 +33,7 @@ def test_patch_track_success(client_with_store): track = store.create_track(user_id="test", name="Original", activate=False) - response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", json={"name": "Updated"} - ) + response = client.patch(f"/api/research/tracks/{track['id']}", json={"name": "Updated"}) assert response.status_code == 200 assert response.json()["track"]["name"] == "Updated" @@ -39,9 +43,7 @@ def test_patch_track_not_found(client_with_store): """Test PATCH returns 404 for non-existent track.""" client, _ = client_with_store - response = client.patch( - "/api/research/tracks/99999?user_id=test", json={"name": "Test"} - ) + response = client.patch("/api/research/tracks/99999", json={"name": "Test"}) assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() @@ -54,9 +56,7 @@ def test_patch_track_duplicate_name(client_with_store): store.create_track(user_id="test", name="Track A", activate=False) track_b = store.create_track(user_id="test", name="Track B", activate=False) - response = client.patch( - f"/api/research/tracks/{track_b['id']}?user_id=test", json={"name": "Track A"} - ) + response = client.patch(f"/api/research/tracks/{track_b['id']}", json={"name": "Track A"}) assert response.status_code == 409 assert "already exists" in response.json()["detail"].lower() @@ -68,9 +68,7 @@ def test_patch_track_no_fields(client_with_store): track = store.create_track(user_id="test", name="Test", activate=False) - response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", json={} - ) + response = client.patch(f"/api/research/tracks/{track['id']}", json={}) assert response.status_code == 400 assert "no fields" in response.json()["detail"].lower() @@ -89,10 +87,7 @@ def test_patch_track_partial_update(client_with_store): ) # Update only name - response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", - json={"name": "New Name"}, - ) + response = client.patch(f"/api/research/tracks/{track['id']}", json={"name": "New Name"}) assert response.status_code == 200 result = response.json()["track"] @@ -109,7 +104,7 @@ def test_patch_track_update_description(client_with_store): track = store.create_track(user_id="test", name="Test Track", activate=False) response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", + f"/api/research/tracks/{track['id']}", json={"description": "New description"}, ) @@ -124,7 +119,7 @@ def test_patch_track_update_keywords(client_with_store): track = store.create_track(user_id="test", name="Test Track", activate=False) response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", + f"/api/research/tracks/{track['id']}", json={"keywords": ["ml", "nlp", "transformers"]}, ) @@ -139,7 +134,7 @@ def test_patch_track_update_multiple_fields(client_with_store): track = store.create_track(user_id="test", name="Original", activate=False) response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", + f"/api/research/tracks/{track['id']}", json={ "name": "Updated Name", "description": "Updated Description", @@ -159,26 +154,19 @@ def test_patch_track_wrong_user(client_with_store): client, store = client_with_store track = store.create_track(user_id="user1", name="Test Track", activate=False) - - response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=user2", - json={"name": "New Name"}, - ) + app.dependency_overrides[auth_deps.get_required_user_id] = lambda: "user2" + response = client.patch(f"/api/research/tracks/{track['id']}", json={"name": "New Name"}) assert response.status_code == 404 -def test_patch_track_default_user(client_with_store): - """Test PATCH uses default user_id when not specified.""" +def test_patch_track_requires_authenticated_user_context(client_with_store): + """Test PATCH uses the authenticated user rather than a query-string fallback.""" client, store = client_with_store - track = store.create_track(user_id="default", name="Test Track", activate=False) + track = store.create_track(user_id="test", name="Test Track", activate=False) - # Call without user_id parameter (should default to "default") - response = client.patch( - f"/api/research/tracks/{track['id']}", - json={"name": "Updated Name"}, - ) + response = client.patch(f"/api/research/tracks/{track['id']}", json={"name": "Updated Name"}) assert response.status_code == 200 assert response.json()["track"]["name"] == "Updated Name" @@ -204,7 +192,6 @@ def test_create_track_schedules_obsidian_export(client_with_store, monkeypatch): response = client.post( "/api/research/tracks", json={ - "user_id": "test", "name": "Obsidian Sync Track", "keywords": ["obsidian", "knowledge-base"], "activate": False, @@ -234,10 +221,7 @@ def test_patch_track_schedules_obsidian_export(client_with_store, monkeypatch): ), ) - response = client.patch( - f"/api/research/tracks/{track['id']}?user_id=test", - json={"keywords": ["obsidian", "moc"]}, - ) + response = client.patch(f"/api/research/tracks/{track['id']}", json={"keywords": ["obsidian", "moc"]}) assert response.status_code == 200 assert captured == [{"user_id": "test", "track_id": int(track["id"]), "for_tracks": True}] @@ -276,16 +260,19 @@ def create_track(self, **kwargs): ), ) - with TestClient(app) as client: - response = client.post( - "/api/research/tracks", - json={ - "user_id": "spoofed-request-user", - "name": "Obsidian Sync Track", - "keywords": ["obsidian"], - "activate": False, - }, - ) + app.dependency_overrides[auth_deps.get_required_user_id] = lambda: "authenticated-user" + try: + with TestClient(app) as client: + response = client.post( + "/api/research/tracks", + json={ + "name": "Obsidian Sync Track", + "keywords": ["obsidian"], + "activate": False, + }, + ) + finally: + app.dependency_overrides.clear() assert response.status_code == 200 assert captured == [ diff --git a/tests/unit/test_agent_skills.py b/tests/unit/test_agent_skills.py new file mode 100644 index 00000000..b85ca5f1 --- /dev/null +++ b/tests/unit/test_agent_skills.py @@ -0,0 +1,127 @@ +""" +Structural validation tests for PaperBot agent skill files. + +Tests verify that .claude/skills/{name}/SKILL.md files exist, have valid YAML +frontmatter with required fields, reference PaperBot MCP tools by their exact +registered names, and that each skill's name field matches its directory name. + +No async needed — file I/O only. Pure synchronous tests. +""" + +import pathlib + +import yaml + +# Resolve SKILLS_DIR relative to repo root (2 levels up from tests/unit/) +SKILLS_DIR = pathlib.Path(__file__).resolve().parents[2] / ".claude" / "skills" + +EXPECTED_SKILLS = [ + "literature-review", + "paper-reproduction", + "trend-analysis", + "scholar-monitoring", +] + +KNOWN_TOOLS = { + "paper_search", + "paper_judge", + "paper_summarize", + "relevance_assess", + "analyze_trends", + "check_scholar", + "get_research_context", + "save_to_memory", + "export_to_obsidian", +} + + +def _parse_skill(skill_name: str): + """Parse a SKILL.md file and return (frontmatter_dict, body_str). + + Splits on --- delimiters. Expects content like: + --- + name: ... + description: ... + --- + # Body text + """ + path = SKILLS_DIR / skill_name / "SKILL.md" + content = path.read_text(encoding="utf-8") + # Split on --- delimiter — parts[0] is empty, parts[1] is YAML, parts[2] is body + parts = content.split("---", 2) + if len(parts) < 3: + raise ValueError( + f"{skill_name}/SKILL.md: no YAML frontmatter found (missing --- delimiters)" + ) + frontmatter = yaml.safe_load(parts[1]) + body = parts[2] + return frontmatter, body + + +def test_skills_directory_exists(): + """The .claude/skills/ directory must exist.""" + assert SKILLS_DIR.is_dir(), ( + f"Skills directory not found: {SKILLS_DIR}. " "Create .claude/skills/ at the repo root." + ) + + +def test_skill_files_exist(): + """All four expected SKILL.md files must exist.""" + for name in EXPECTED_SKILLS: + skill_file = SKILLS_DIR / name / "SKILL.md" + assert skill_file.is_file(), f"Missing skill file: .claude/skills/{name}/SKILL.md" + + +def test_skill_frontmatter_valid(): + """Each SKILL.md must have valid YAML frontmatter with 'name' and 'description'.""" + for name in EXPECTED_SKILLS: + frontmatter, _ = _parse_skill(name) + assert isinstance( + frontmatter, dict + ), f"{name}/SKILL.md: frontmatter did not parse to a dict" + assert "name" in frontmatter, f"{name}/SKILL.md: missing 'name' field in frontmatter" + assert ( + "description" in frontmatter + ), f"{name}/SKILL.md: missing 'description' field in frontmatter" + # description must be a non-empty string + assert ( + isinstance(frontmatter["description"], str) and frontmatter["description"].strip() + ), f"{name}/SKILL.md: 'description' must be a non-empty string" + + +def test_skill_name_matches_directory(): + """Each SKILL.md 'name' field must match its directory name exactly.""" + for name in EXPECTED_SKILLS: + frontmatter, _ = _parse_skill(name) + assert frontmatter.get("name") == name, ( + f"{name}/SKILL.md: name field '{frontmatter.get('name')}' does not match " + f"directory name '{name}'" + ) + + +def test_skill_references_tools(): + """Each SKILL.md body must reference at least one PaperBot MCP tool by exact name.""" + for name in EXPECTED_SKILLS: + _, body = _parse_skill(name) + referenced = [tool for tool in KNOWN_TOOLS if tool in body] + assert referenced, ( + f"{name}/SKILL.md: body does not reference any PaperBot MCP tool. " + f"Expected at least one of: {sorted(KNOWN_TOOLS)}" + ) + + +def test_skill_description_has_trigger_phrases(): + """Each skill description must contain at least 3 quoted trigger phrases.""" + for name in EXPECTED_SKILLS: + frontmatter, _ = _parse_skill(name) + description = frontmatter.get("description", "") + # Count quoted phrases (single or double quotes) + import re + + single_quoted = re.findall(r"'[^']{3,}'", description) + double_quoted = re.findall(r'"[^"]{3,}"', description) + total_phrases = len(single_quoted) + len(double_quoted) + assert total_phrases >= 3, ( + f"{name}/SKILL.md: description has only {total_phrases} quoted trigger phrase(s), " + f"need at least 3. Found: {single_quoted + double_quoted}" + ) diff --git a/tests/unit/test_anchor_service.py b/tests/unit/test_anchor_service.py index 74bdb92e..6e4595f0 100644 --- a/tests/unit/test_anchor_service.py +++ b/tests/unit/test_anchor_service.py @@ -28,7 +28,7 @@ def _seed_track(db_url: str) -> int: Base.metadata.create_all(provider.engine) with provider.session() as session: track = ResearchTrackModel( - user_id="default", + user_id="anchor-user", name="LLM Systems", description="", keywords_json=json.dumps(["attention", "transformer"]), @@ -45,6 +45,7 @@ def _seed_track(db_url: str) -> int: def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): + user_id = "anchor-user" db_url = f"sqlite:///{tmp_path / 'anchor-service.db'}" paper_store = PaperStore(db_url=db_url) author_store = AuthorStore(db_url=db_url) @@ -113,7 +114,7 @@ def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): with provider.session() as session: session.add( PaperFeedbackModel( - user_id="default", + user_id=user_id, track_id=track_id, paper_id=str(p2["id"]), paper_ref_id=int(p2["id"]), @@ -127,7 +128,7 @@ def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): session.commit() service = AnchorService(db_url=db_url) - anchors = service.discover(track_id=track_id, user_id="default", limit=5, window_years=15) + anchors = service.discover(track_id=track_id, user_id=user_id, limit=5, window_years=15) assert len(anchors) >= 2 assert anchors[0]["name"] == "Alice Smith" @@ -141,7 +142,7 @@ def test_anchor_service_discovers_and_scores_authors(tmp_path: Path): global_mode = service.discover( track_id=track_id, - user_id="default", + user_id=None, limit=5, window_years=15, personalized=False, @@ -158,7 +159,7 @@ def test_anchor_service_raises_for_unknown_track(tmp_path: Path): db_url = f"sqlite:///{tmp_path / 'anchor-track-missing.db'}" service = AnchorService(db_url=db_url) with pytest.raises(ValueError, match="track not found"): - service.discover(track_id=999, user_id="default") + service.discover(track_id=999, user_id="anchor-user") def test_collapse_effective_feedback_actions_ignores_toggled_off_state() -> None: @@ -254,7 +255,7 @@ def test_cleared_feedback_does_not_contribute_to_anchor_personalization() -> Non now = datetime.now(timezone.utc) rows = [ PaperFeedbackModel( - user_id="default", + user_id="anchor-user", track_id=1, paper_id="paper-1", paper_ref_id=1, @@ -265,7 +266,7 @@ def test_cleared_feedback_does_not_contribute_to_anchor_personalization() -> Non metadata_json="{}", ), PaperFeedbackModel( - user_id="default", + user_id="anchor-user", track_id=1, paper_id="paper-1", paper_ref_id=1, diff --git a/tests/unit/test_api_main_eventlog_lifecycle.py b/tests/unit/test_api_main_eventlog_lifecycle.py new file mode 100644 index 00000000..889040e4 --- /dev/null +++ b/tests/unit/test_api_main_eventlog_lifecycle.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from paperbot.api import main as api_main + + +@pytest.mark.asyncio +async def test_shutdown_closes_event_log_and_obsidian_runtime(monkeypatch): + event_log = Mock() + api_main.app.state.event_log = event_log + shutdown_obsidian = Mock() + monkeypatch.setattr(api_main.obsidian, "shutdown_obsidian_runtime", shutdown_obsidian) + + await api_main._shutdown_runtime() + + event_log.close.assert_called_once_with() + shutdown_obsidian.assert_called_once_with(api_main.app) + + +@pytest.mark.asyncio +async def test_startup_keeps_event_bus_when_sqlalchemy_backend_fails(monkeypatch): + def _raise_sqlalchemy(): + raise RuntimeError("db unavailable") + + initialize_obsidian = Mock() + monkeypatch.setattr(api_main, "SqlAlchemyEventLog", _raise_sqlalchemy) + monkeypatch.setattr(api_main.obsidian, "initialize_obsidian_runtime", initialize_obsidian) + + await api_main._startup_eventlog() + + event_log = api_main.app.state.event_log + assert isinstance(event_log, api_main.CompositeEventLog) + backend_types = {type(backend) for backend in event_log._backends} + assert api_main.LoggingEventLog in backend_types + assert api_main.EventBusEventLog in backend_types + assert len(event_log._backends) == 2 + initialize_obsidian.assert_called_once_with(api_main.app) diff --git a/tests/unit/test_event_bus_event_log.py b/tests/unit/test_event_bus_event_log.py new file mode 100644 index 00000000..6a55fe40 --- /dev/null +++ b/tests/unit/test_event_bus_event_log.py @@ -0,0 +1,203 @@ +""" +Unit tests for EventBusEventLog — asyncio fan-out ring buffer backend. + +RED phase: These tests are written before the implementation file exists. +Expected to fail with ImportError / ModuleNotFoundError until Task 2 creates +src/paperbot/infrastructure/event_log/event_bus_event_log.py. + +asyncio_mode = "strict" in pyproject.toml — every async test must carry +@pytest.mark.asyncio explicitly. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from paperbot.infrastructure.event_log.event_bus_event_log import EventBusEventLog +from paperbot.infrastructure.event_log.composite_event_log import CompositeEventLog +from paperbot.application.collaboration.message_schema import ( + AgentEventEnvelope, + new_run_id, + new_trace_id, +) + + +def _make_envelope(payload: dict | None = None) -> AgentEventEnvelope: + return AgentEventEnvelope( + run_id=new_run_id(), + trace_id=new_trace_id(), + workflow="test_workflow", + stage="test_stage", + attempt=0, + agent_name="test_agent", + role="worker", + type="test_event", + payload=payload or {"key": "value"}, + ) + + +@pytest.mark.asyncio +async def test_fan_out_to_multiple_subscribers(): + """append(event) fans out to all registered subscriber queues.""" + bus = EventBusEventLog() + + q1: asyncio.Queue = bus.subscribe() + q2: asyncio.Queue = bus.subscribe() + + event = _make_envelope() + bus.append(event) + + # Both queues must have received the event + assert not q1.empty(), "q1 did not receive the fanned-out event" + assert not q2.empty(), "q2 did not receive the fanned-out event" + + item1 = q1.get_nowait() + item2 = q2.get_nowait() + + # Both should be the serialized dict form + assert isinstance(item1, dict) + assert isinstance(item2, dict) + assert item1["type"] == "test_event" + assert item2["type"] == "test_event" + + +@pytest.mark.asyncio +async def test_ring_buffer_catch_up(): + """New subscriber receives ring buffer contents as catch-up burst on subscribe().""" + bus = EventBusEventLog() + + # Append 3 events BEFORE any subscriber + for i in range(3): + bus.append(_make_envelope({"index": i})) + + # Now subscribe — should receive catch-up burst of those 3 events + q: asyncio.Queue = bus.subscribe() + + assert q.qsize() == 3, f"Expected 3 catch-up events, got {q.qsize()}" + + items = [q.get_nowait() for _ in range(3)] + indexes = [item["payload"]["index"] for item in items] + assert indexes == [0, 1, 2], f"Expected indexes [0,1,2], got {indexes}" + + +@pytest.mark.asyncio +async def test_backpressure_drops_oldest(): + """Full client queue drops oldest event on overflow — producer never blocks.""" + bus = EventBusEventLog(client_queue_size=2) + q: asyncio.Queue = bus.subscribe() + + # Fill queue to maxsize=2 + bus.append(_make_envelope({"seq": 0})) + bus.append(_make_envelope({"seq": 1})) + assert q.qsize() == 2 + + # Append one more — should drop oldest (seq=0), keep seq=1 and seq=2 + bus.append(_make_envelope({"seq": 2})) + + assert q.qsize() == 2, f"Queue should still have 2 items, got {q.qsize()}" + + items = [q.get_nowait() for _ in range(2)] + seqs = [item["payload"]["seq"] for item in items] + + # Oldest (seq=0) must be gone; newest (seq=2) must be present + assert 0 not in seqs, f"Oldest item (seq=0) should have been dropped, got seqs={seqs}" + assert 2 in seqs, f"Newest item (seq=2) should be present, got seqs={seqs}" + + +@pytest.mark.asyncio +async def test_unsubscribe_cleans_up(): + """unsubscribe(q) removes queue from fan-out set; append after unsubscribe delivers nothing.""" + bus = EventBusEventLog() + q: asyncio.Queue = bus.subscribe() + + bus.unsubscribe(q) + + # After unsubscribe, internal set should be empty + assert len(bus._queues) == 0, f"Expected 0 queues after unsubscribe, got {len(bus._queues)}" + + # Appending should not put anything in q + bus.append(_make_envelope()) + assert q.empty(), "Queue should be empty after unsubscribe — no fan-out expected" + + +@pytest.mark.asyncio +async def test_composite_includes_bus(): + """CompositeEventLog delegates append() to EventBusEventLog backend.""" + bus = EventBusEventLog() + composite = CompositeEventLog([bus]) + + event = _make_envelope() + composite.append(event) + + # The bus ring buffer should contain the event + assert len(bus._ring) == 1, f"Expected 1 item in ring buffer, got {len(bus._ring)}" + + ring_item = list(bus._ring)[0] + assert isinstance(ring_item, dict) + assert ring_item["type"] == "test_event" + + +@pytest.mark.asyncio +async def test_append_snapshots_mutable_input_for_ring_and_queue(): + """Mutating the caller's dict after append() must not affect stored/queued events.""" + bus = EventBusEventLog() + q: asyncio.Queue = bus.subscribe() + + event = {"type": "mutable", "payload": {"count": 1}} + bus.append(event) + event["payload"]["count"] = 99 + + queued = q.get_nowait() + ring_item = list(bus._ring)[0] + + assert queued["payload"]["count"] == 1 + assert ring_item["payload"]["count"] == 1 + + +@pytest.mark.asyncio +async def test_subscribers_receive_independent_event_copies(): + """Mutating one subscriber's item must not leak to other subscribers or the ring buffer.""" + bus = EventBusEventLog() + q1: asyncio.Queue = bus.subscribe() + q2: asyncio.Queue = bus.subscribe() + + bus.append({"type": "mutable", "payload": {"count": 1}}) + + item1 = q1.get_nowait() + item2 = q2.get_nowait() + item1["payload"]["count"] = 7 + + ring_item = list(bus._ring)[0] + assert item2["payload"]["count"] == 1 + assert ring_item["payload"]["count"] == 1 + + +@pytest.mark.asyncio +async def test_subscribe_registers_before_replay_to_avoid_live_event_gaps(monkeypatch): + """A live append during replay should still reach the new subscriber.""" + bus = EventBusEventLog() + bus.append({"type": "seed", "payload": {"seq": "seed"}}) + + original_put = EventBusEventLog._put_nowait_drop_oldest + injected = {"done": False} + + def _put_with_live_append(q: asyncio.Queue, data: dict) -> None: + if not injected["done"] and data.get("payload", {}).get("seq") == "seed": + injected["done"] = True + bus.append({"type": "live", "payload": {"seq": "live"}}) + original_put(q, data) + + monkeypatch.setattr( + EventBusEventLog, + "_put_nowait_drop_oldest", + staticmethod(_put_with_live_append), + ) + + q = bus.subscribe() + items = [q.get_nowait() for _ in range(q.qsize())] + seqs = [item["payload"]["seq"] for item in items] + + assert "seed" in seqs + assert "live" in seqs diff --git a/tests/unit/test_intelligence_radar_service.py b/tests/unit/test_intelligence_radar_service.py index 4eb59817..32f6b661 100644 --- a/tests/unit/test_intelligence_radar_service.py +++ b/tests/unit/test_intelligence_radar_service.py @@ -84,10 +84,10 @@ def test_signal_sort_value_supports_delta_keyword_and_time_modes(): def test_needs_refresh_accepts_naive_latest_detected_at(): class _NaiveStore: - def latest_detected_at(self, *, user_id: str = "default") -> datetime: + def latest_detected_at(self, *, user_id: str = "radar-user") -> datetime: return datetime.utcnow() service = object.__new__(IntelligenceRadarService) service._store = _NaiveStore() - assert service.needs_refresh(user_id="default", max_age_minutes=45) is False + assert service.needs_refresh(user_id="radar-user", max_age_minutes=45) is False diff --git a/tests/unit/test_intelligence_routes.py b/tests/unit/test_intelligence_routes.py index efc52781..0a42ebcc 100644 --- a/tests/unit/test_intelligence_routes.py +++ b/tests/unit/test_intelligence_routes.py @@ -10,16 +10,16 @@ class _FakeIntelligenceService: def __init__(self): self.list_feed_calls = [] - def needs_refresh(self, *, user_id: str = "default", max_age_minutes: int = 45) -> bool: + def needs_refresh(self, *, user_id: str, max_age_minutes: int = 45) -> bool: return False - def refresh(self, *, user_id: str = "default"): + def refresh(self, *, user_id: str): return {"refreshed_at": "2026-03-10T11:00:00+00:00"} def list_feed( self, *, - user_id: str = "default", + user_id: str, limit: int = 8, source=None, keyword=None, @@ -66,10 +66,10 @@ def list_feed( } ][:limit] - def latest_refresh(self, *, user_id: str = "default"): + def latest_refresh(self, *, user_id: str): return "2026-03-10T11:00:00+00:00" - def build_profile(self, *, user_id: str = "default") -> RadarProfile: + def build_profile(self, *, user_id: str) -> RadarProfile: return RadarProfile( keywords=["rag", "agents"], scholar_names=["Alice Zhang"], @@ -103,7 +103,7 @@ def test_intelligence_feed_route_returns_external_signal_payload(monkeypatch): monkeypatch.setattr(intelligence_route, "_research_store", _FakeResearchStore()) app = api_main.app - app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("default") + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("radar-user") try: with TestClient(app) as client: resp = client.get( @@ -126,7 +126,7 @@ def test_intelligence_feed_route_returns_external_signal_payload(monkeypatch): assert service.list_feed_calls == [ { - "user_id": "default", + "user_id": "radar-user", "limit": 50, "source": "reddit", "keyword": "rag", diff --git a/tests/unit/test_intelligence_store.py b/tests/unit/test_intelligence_store.py index aff47d61..62786e6f 100644 --- a/tests/unit/test_intelligence_store.py +++ b/tests/unit/test_intelligence_store.py @@ -8,6 +8,7 @@ def test_intelligence_store_serializes_datetime_payload(tmp_path: Path): + user_id = "radar-user" store = IntelligenceStore( db_url=f"sqlite:///{tmp_path / 'intelligence-store.db'}", auto_create_schema=True, @@ -15,7 +16,7 @@ def test_intelligence_store_serializes_datetime_payload(tmp_path: Path): observed_at = datetime(2026, 3, 11, 12, 30, tzinfo=timezone.utc) row = store.upsert_event( - user_id="default", + user_id=user_id, external_id="signal-1", source="github", source_label="GitHub", @@ -37,6 +38,7 @@ def test_intelligence_store_serializes_datetime_payload(tmp_path: Path): def test_intelligence_store_latest_detected_at_restores_utc_timezone(tmp_path: Path): + user_id = "radar-user" store = IntelligenceStore( db_url=f"sqlite:///{tmp_path / 'intelligence-store-latest.db'}", auto_create_schema=True, @@ -44,7 +46,7 @@ def test_intelligence_store_latest_detected_at_restores_utc_timezone(tmp_path: P observed_at = datetime(2026, 3, 11, 12, 30, tzinfo=timezone.utc) store.upsert_event( - user_id="default", + user_id=user_id, external_id="signal-latest", source="reddit", source_label="Reddit", @@ -54,13 +56,14 @@ def test_intelligence_store_latest_detected_at_restores_utc_timezone(tmp_path: P detected_at=observed_at, ) - latest = store.latest_detected_at(user_id="default") + latest = store.latest_detected_at(user_id=user_id) assert latest == observed_at assert latest.tzinfo == timezone.utc def test_intelligence_store_upsert_event_retries_after_integrity_error(tmp_path: Path, monkeypatch): + user_id = "radar-user" store = IntelligenceStore( db_url=f"sqlite:///{tmp_path / 'intelligence-store-race.db'}", auto_create_schema=True, @@ -80,7 +83,7 @@ def commit(): with original_session_factory() as competing: competing.add( IntelligenceEventModel( - user_id="default", + user_id=user_id, external_id="signal-race", created_at=observed_at, detected_at=observed_at, @@ -102,7 +105,7 @@ def commit(): monkeypatch.setattr(store._provider, "session", session_factory) row = store.upsert_event( - user_id="default", + user_id=user_id, external_id="signal-race", source="github", source_label="GitHub", diff --git a/tests/unit/test_llm_service.py b/tests/unit/test_llm_service.py index 823f41ed..40ec30f1 100644 --- a/tests/unit/test_llm_service.py +++ b/tests/unit/test_llm_service.py @@ -93,6 +93,7 @@ def test_assess_relevance_fallback_when_non_json_response(): ) assert isinstance(relevance["score"], int) + assert relevance["fallback"] is True assert "Fallback" in relevance["reason"] diff --git a/tests/unit/test_mcp_analyze_trends.py b/tests/unit/test_mcp_analyze_trends.py new file mode 100644 index 00000000..ec4da745 --- /dev/null +++ b/tests/unit/test_mcp_analyze_trends.py @@ -0,0 +1,117 @@ +"""Unit tests for the analyze_trends MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +class _FakeTrendAnalyzer: + """TrendAnalyzer stub returning a canned analysis string.""" + + def analyze(self, *, topic: str, items) -> str: + return "Trend: LLMs growing" + + +class _FakeEmptyTrendAnalyzer: + """Simulates LLM unavailable -- returns empty string.""" + + def analyze(self, *, topic: str, items) -> str: + return "" + + +class _FailIfCalledTrendAnalyzer: + """Fails fast if analyze() is invoked unexpectedly.""" + + def analyze(self, *, topic: str, items) -> str: + raise AssertionError("analyze() should not be called") + + +class TestAnalyzeTrendsTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_trend_analysis_dict(self): + """_analyze_trends_impl with a fake TrendAnalyzer returns analysis dict.""" + import paperbot.mcp.tools.analyze_trends as mod + + mod._analyzer = _FakeTrendAnalyzer() + try: + result = await mod._analyze_trends_impl( + topic="llms", + papers=[{"title": "Paper A"}, {"title": "Paper B"}], + ) + finally: + mod._analyzer = None + + assert isinstance(result, dict) + assert result["trend_analysis"] == "Trend: LLMs growing" + assert result["topic"] == "llms" + assert result["paper_count"] == 2 + assert result.get("degraded") is not True + + @pytest.mark.asyncio + async def test_degraded_mode_when_llm_unavailable(self): + """_analyze_trends_impl returns degraded=True when TrendAnalyzer returns empty string.""" + import paperbot.mcp.tools.analyze_trends as mod + + mod._analyzer = _FakeEmptyTrendAnalyzer() + try: + result = await mod._analyze_trends_impl( + topic="llms", + papers=[{"title": "Paper A"}, {"title": "Paper B"}], + ) + finally: + mod._analyzer = None + + assert isinstance(result, dict) + assert result["degraded"] is True + assert "error" in result + assert "API_KEY" not in result["error"] + assert "unavailable" in result["error"].lower() or "empty" in result["error"].lower() + assert result["trend_analysis"] == "" + assert result["topic"] == "llms" + assert result["paper_count"] == 2 + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """_analyze_trends_impl logs event with tool='analyze_trends', workflow='mcp'.""" + import paperbot.mcp.tools.analyze_trends as mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + mod._analyzer = _FakeTrendAnalyzer() + try: + await mod._analyze_trends_impl( + topic="transformers", + papers=[{"title": "Attention Is All You Need"}], + ) + finally: + mod._analyzer = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "analyze_trends" + assert event["workflow"] == "mcp" + + @pytest.mark.asyncio + async def test_treats_missing_papers_as_empty_list(self): + """_analyze_trends_impl skips analyzer work when papers is missing.""" + import paperbot.mcp.tools.analyze_trends as mod + + mod._analyzer = _FailIfCalledTrendAnalyzer() + try: + result = await mod._analyze_trends_impl( + topic="llms", + papers=None, + ) + finally: + mod._analyzer = None + + assert result["paper_count"] == 0 + assert result["trend_analysis"] == "" + assert result.get("degraded") is not True diff --git a/tests/unit/test_mcp_audit.py b/tests/unit/test_mcp_audit.py new file mode 100644 index 00000000..7c7e1d26 --- /dev/null +++ b/tests/unit/test_mcp_audit.py @@ -0,0 +1,212 @@ +"""Unit tests for the MCP audit helper (log_tool_call).""" + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +class TestLogToolCall: + def setup_method(self): + Container._instance = None + + def _register_event_log(self): + """Helper: register InMemoryEventLog in the DI container.""" + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + return log + + def test_creates_event_with_correct_fields(self): + """log_tool_call() creates an AgentEventEnvelope with workflow='mcp', + stage='tool_call', agent_name='paperbot-mcp'.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="test_tool", + arguments={"query": "hello"}, + result_summary="found 3 results", + duration_ms=42.0, + ) + + assert len(log.events) == 1 + event = log.events[0] + assert event["workflow"] == "mcp" + assert event["stage"] == "tool_call" + assert event["agent_name"] == "paperbot-mcp" + assert event["role"] == "system" + assert event["type"] == "tool_result" + + def test_generates_run_id_when_none(self): + """log_tool_call() with run_id=None generates a new run_id (non-empty string).""" + from paperbot.mcp.tools._audit import log_tool_call + + self._register_event_log() + + returned_run_id = log_tool_call( + tool_name="test_tool", + arguments={}, + result_summary="ok", + duration_ms=1.0, + run_id=None, + ) + + assert isinstance(returned_run_id, str) + assert len(returned_run_id) > 0 + + def test_uses_provided_run_id(self): + """log_tool_call() with run_id='abc123' uses that run_id in the event.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + returned_run_id = log_tool_call( + tool_name="test_tool", + arguments={}, + result_summary="ok", + duration_ms=1.0, + run_id="abc123", + ) + + assert returned_run_id == "abc123" + assert log.events[0]["run_id"] == "abc123" + + def test_stores_event_in_event_log(self): + """log_tool_call() stores event in InMemoryEventLog (.events list).""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="store_test", + arguments={"q": "papers"}, + result_summary="5 papers", + duration_ms=100.0, + ) + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "store_test" + assert event["payload"]["arguments"] == {"q": "papers"} + assert event["payload"]["result_summary"] == "5 papers" + + def test_degrades_silently_without_event_log(self): + """log_tool_call() with no EventLogPort registered degrades silently.""" + from paperbot.mcp.tools._audit import log_tool_call + + # Do NOT register any EventLogPort -- container is fresh + Container.instance() # ensure container exists but empty + + returned_run_id = log_tool_call( + tool_name="test_tool", + arguments={}, + result_summary="ok", + duration_ms=1.0, + ) + + # Should return a valid run_id, no exception raised + assert isinstance(returned_run_id, str) + assert len(returned_run_id) > 0 + + def test_records_duration_ms_in_metrics(self): + """log_tool_call() records duration_ms in event metrics.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="test_tool", + arguments={}, + result_summary="ok", + duration_ms=123.45, + ) + + assert log.events[0]["metrics"]["duration_ms"] == 123.45 + + def test_records_error_field(self): + """log_tool_call() records error field when error is provided.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="test_tool", + arguments={}, + result_summary="", + duration_ms=5.0, + error="Connection timeout", + ) + + event = log.events[0] + assert event["type"] == "error" + assert event["payload"]["error"] == "Connection timeout" + + def test_accepts_structured_summary_and_redacts_sensitive_arguments(self): + """log_tool_call() accepts structured summaries and redacts sensitive argument keys.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="test_tool", + arguments={ + "query": "hello", + "api_key": "secret-value", + "nested": {"token": "nested-secret"}, + }, + result_summary={"count": 3, "status": "ok"}, + duration_ms=10.0, + ) + + payload = log.events[0]["payload"] + assert payload["arguments"]["query"] == "hello" + assert payload["arguments"]["api_key"] == "***redacted***" + assert payload["arguments"]["nested"]["token"] == "***redacted***" + assert payload["result_summary"] == {"count": 3, "status": "ok"} + + def test_truncates_oversized_audit_fields(self): + """log_tool_call() truncates oversized text fields before persistence.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + oversized = "x" * 1200 + log_tool_call( + tool_name="test_tool", + arguments={"query": oversized}, + result_summary=oversized, + duration_ms=1.0, + ) + + payload = log.events[0]["payload"] + assert payload["arguments"]["query"].endswith("...[truncated]") + assert len(payload["arguments"]["query"]) > 1000 + assert payload["result_summary"].endswith("...[truncated]") + + def test_redacts_sensitive_keys_inside_nested_collections(self): + """Nested list/dict payloads should still redact sensitive keys before storage.""" + from paperbot.mcp.tools._audit import log_tool_call + + log = self._register_event_log() + + log_tool_call( + tool_name="test_tool", + arguments={ + "batches": [ + {"token": "secret-token", "value": "ok"}, + {"nested": {"authorization": "Bearer secret"}}, + ] + }, + result_summary={ + "steps": [ + {"password": "hidden", "status": "ok"}, + ] + }, + duration_ms=1.0, + ) + + payload = log.events[0]["payload"] + assert payload["arguments"]["batches"][0]["token"] == "***redacted***" + assert payload["arguments"]["batches"][1]["nested"]["authorization"] == "***redacted***" + assert payload["result_summary"]["steps"][0]["password"] == "***redacted***" diff --git a/tests/unit/test_mcp_bootstrap.py b/tests/unit/test_mcp_bootstrap.py new file mode 100644 index 00000000..cc1518af --- /dev/null +++ b/tests/unit/test_mcp_bootstrap.py @@ -0,0 +1,23 @@ +"""Unit tests for MCP server bootstrap and tool registration.""" + +import importlib + + +class TestMCPServerBootstrap: + def test_server_module_imports_without_error(self): + """server.py imports cleanly regardless of mcp package availability.""" + mod = importlib.import_module("paperbot.mcp.server") + # mcp may be None (stub) or a FastMCP instance + assert hasattr(mod, "mcp") + + def test_paper_search_register_function_exists(self): + """paper_search module exposes a register() function.""" + from paperbot.mcp.tools.paper_search import register + + assert callable(register) + + def test_audit_log_tool_call_importable(self): + """_audit module exposes log_tool_call function.""" + from paperbot.mcp.tools._audit import log_tool_call + + assert callable(log_tool_call) diff --git a/tests/unit/test_mcp_check_scholar.py b/tests/unit/test_mcp_check_scholar.py new file mode 100644 index 00000000..cbb277d1 --- /dev/null +++ b/tests/unit/test_mcp_check_scholar.py @@ -0,0 +1,106 @@ +"""Unit tests for the check_scholar MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +class _FakeS2Client: + """Fake SemanticScholarClient returning a test author and papers.""" + + async def search_authors(self, query, limit=10, fields=None): + return [ + { + "authorId": "123", + "name": "Test Scholar", + "hIndex": 42, + "paperCount": 100, + "citationCount": 5000, + } + ] + + async def get_author_papers(self, author_id, limit=10, fields=None): + return [ + { + "title": "Paper A", + "year": 2024, + "citationCount": 10, + "venue": "NeurIPS", + } + ] + + +class _FakeEmptyS2Client: + """Simulates scholar not found -- search_authors returns empty list.""" + + async def search_authors(self, query, limit=10, fields=None): + return [] + + async def get_author_papers(self, author_id, limit=10, fields=None): + return [] + + +class TestCheckScholarTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_scholar_info_and_papers(self): + """_check_scholar_impl with fake S2 client returns scholar info and recent papers.""" + import paperbot.mcp.tools.check_scholar as mod + + mod._client = _FakeS2Client() + try: + result = await mod._check_scholar_impl(scholar_name="Test Scholar") + finally: + mod._client = None + + assert isinstance(result, dict) + scholar = result["scholar"] + assert scholar["name"] == "Test Scholar" + assert scholar["authorId"] == "123" + assert scholar["hIndex"] == 42 + papers = result["recent_papers"] + assert len(papers) == 1 + assert papers[0]["title"] == "Paper A" + assert papers[0]["year"] == 2024 + assert result.get("degraded") is not True + + @pytest.mark.asyncio + async def test_degraded_when_scholar_not_found(self): + """_check_scholar_impl returns degraded=True when search_authors returns empty list.""" + import paperbot.mcp.tools.check_scholar as mod + + mod._client = _FakeEmptyS2Client() + try: + result = await mod._check_scholar_impl(scholar_name="Unknown Person") + finally: + mod._client = None + + assert isinstance(result, dict) + assert result["degraded"] is True + assert "Scholar not found" in result["error"] + assert result["scholar"] is None + assert result["recent_papers"] == [] + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """_check_scholar_impl logs event with tool='check_scholar', workflow='mcp'.""" + import paperbot.mcp.tools.check_scholar as mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + mod._client = _FakeS2Client() + try: + await mod._check_scholar_impl(scholar_name="Test Scholar") + finally: + mod._client = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "check_scholar" + assert event["workflow"] == "mcp" diff --git a/tests/unit/test_mcp_export_to_obsidian.py b/tests/unit/test_mcp_export_to_obsidian.py new file mode 100644 index 00000000..5ad500ae --- /dev/null +++ b/tests/unit/test_mcp_export_to_obsidian.py @@ -0,0 +1,87 @@ +"""Unit tests for the export_to_obsidian MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +class _FakeExporter: + """Fake ObsidianFilesystemExporter that returns a minimal markdown body.""" + + def _render_paper_note(self, **kwargs): + title = kwargs.get("title", "Untitled") + abstract = kwargs.get("abstract", "") + return f"# {title}\n\n{abstract}\n" + + +class TestExportToObsidianTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_dict_with_markdown_key(self): + """_export_to_obsidian_impl returns a dict with a 'markdown' key containing a string.""" + import paperbot.mcp.tools.export_to_obsidian as mod + + mod._exporter = _FakeExporter() + try: + result = await mod._export_to_obsidian_impl( + title="Attention Is All You Need", + abstract="The dominant sequence transduction models are based on complex recurrent or convolutional neural networks.", + ) + finally: + mod._exporter = None + + assert isinstance(result, dict) + assert "markdown" in result + assert isinstance(result["markdown"], str) + assert len(result["markdown"]) > 0 + + @pytest.mark.asyncio + async def test_markdown_contains_frontmatter_and_title(self): + """Returned markdown contains YAML frontmatter delimiters '---' and the paper title.""" + import paperbot.mcp.tools.export_to_obsidian as mod + + mod._exporter = _FakeExporter() + try: + result = await mod._export_to_obsidian_impl( + title="Attention Is All You Need", + abstract="We propose the Transformer architecture.", + authors=["Vaswani, A.", "Shazeer, N."], + year=2017, + venue="NeurIPS", + arxiv_id="1706.03762", + ) + finally: + mod._exporter = None + + markdown = result["markdown"] + # YAML frontmatter delimiters must be present + assert "---" in markdown + # Paper title must appear somewhere in the output + assert "Attention Is All You Need" in markdown + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """_export_to_obsidian_impl logs call via log_tool_call with correct tool name and workflow.""" + import paperbot.mcp.tools.export_to_obsidian as mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + mod._exporter = _FakeExporter() + try: + await mod._export_to_obsidian_impl( + title="BERT: Pre-training of Deep Bidirectional Transformers", + abstract="We introduce BERT.", + ) + finally: + mod._exporter = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "export_to_obsidian" + assert event["workflow"] == "mcp" diff --git a/tests/unit/test_mcp_get_research_context.py b/tests/unit/test_mcp_get_research_context.py new file mode 100644 index 00000000..01147bbe --- /dev/null +++ b/tests/unit/test_mcp_get_research_context.py @@ -0,0 +1,168 @@ +"""Unit tests for the get_research_context MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +_CANNED_CONTEXT = { + "paper_recommendations": [{"title": "Test Paper", "abstract": "Test abstract"}], + "relevant_memories": [{"id": 1, "content": "Remember this"}], + "active_track": {"id": 7, "name": "Transformers"}, + "routing": {"stage": "explore", "suggestion": {"track_id": 7, "score": 0.9}}, +} + + +class _FakeContextEngine: + """Fake ContextEngine returning a canned context pack dict.""" + + async def build_context_pack(self, user_id: str, query: str, track_id=None): + return dict(_CANNED_CONTEXT) + + +class _SpyContextEngine: + """Fake ContextEngine that records call args.""" + + def __init__(self): + self.calls = [] + + async def build_context_pack(self, user_id: str, query: str, track_id=None): + self.calls.append({"user_id": user_id, "query": query, "track_id": track_id}) + return dict(_CANNED_CONTEXT) + + +class _CaptureContextEngine: + """Fake ContextEngine that records constructor kwargs.""" + + last_kwargs = None + + def __init__(self, **kwargs): + type(self).last_kwargs = kwargs + + +class _CaptureContextEngineConfig: + """Fake ContextEngineConfig that records constructor kwargs.""" + + last_kwargs = None + + def __init__(self, **kwargs): + type(self).last_kwargs = kwargs + + +class TestGetResearchContextTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_context_pack_dict(self): + """_get_research_context_impl returns a context pack dict with expected keys.""" + import paperbot.mcp.tools.get_research_context as mod + + mod._engine = _FakeContextEngine() + try: + result = await mod._get_research_context_impl( + query="transformers in NLP", + user_id="mcp-user", + ) + finally: + mod._engine = None + + assert isinstance(result, dict) + assert "papers" in result + assert "memories" in result + assert "stage" in result + assert result["stage"] == "explore" + assert isinstance(result["papers"], list) + assert result["papers"][0]["title"] == "Test Paper" + assert result["paper_recommendations"] == result["papers"] + assert result["memories"] == result["relevant_memories"] + assert result["track"] == result["active_track"] + assert result["routing_suggestion"] == {"track_id": 7, "score": 0.9} + + @pytest.mark.asyncio + async def test_accepts_user_id_and_track_id(self): + """_get_research_context_impl passes user_id and track_id through to the engine.""" + import paperbot.mcp.tools.get_research_context as mod + + spy = _SpyContextEngine() + mod._engine = spy + try: + await mod._get_research_context_impl( + query="neural scaling laws", + user_id="custom_user", + track_id=42, + ) + finally: + mod._engine = None + + assert len(spy.calls) == 1 + call = spy.calls[0] + assert call["user_id"] == "custom_user" + assert call["track_id"] == 42 + assert call["query"] == "neural scaling laws" + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """_get_research_context_impl logs call via log_tool_call with correct tool name and workflow.""" + import paperbot.mcp.tools.get_research_context as mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + mod._engine = _FakeContextEngine() + try: + await mod._get_research_context_impl( + query="attention mechanisms", + user_id="mcp-user", + ) + finally: + mod._engine = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "get_research_context" + assert event["workflow"] == "mcp" + + def test_get_engine_wires_search_and_grounding_dependencies(self, monkeypatch): + """_get_engine builds a ContextEngine with search, paper store, evidence, and grounding.""" + import paperbot.mcp.tools.get_research_context as mod + + mod._engine = None + mod._paper_store = None + mod._paper_search_service = None + mod._document_index_store = None + mod._query_grounder = None + _CaptureContextEngine.last_kwargs = None + _CaptureContextEngineConfig.last_kwargs = None + + monkeypatch.setattr(mod, "_get_paper_store", lambda: "paper-store") + monkeypatch.setattr(mod, "_get_paper_search_service", lambda: "paper-search") + monkeypatch.setattr(mod, "_get_document_index_store", lambda: "document-index") + monkeypatch.setattr(mod, "_get_workflow_query_grounder", lambda: "grounder") + monkeypatch.setattr( + "paperbot.context_engine.engine.ContextEngine", + _CaptureContextEngine, + ) + monkeypatch.setattr( + "paperbot.context_engine.engine.ContextEngineConfig", + _CaptureContextEngineConfig, + ) + + try: + engine = mod._get_engine() + finally: + mod._engine = None + mod._paper_store = None + mod._paper_search_service = None + mod._document_index_store = None + mod._query_grounder = None + + assert isinstance(engine, _CaptureContextEngine) + assert _CaptureContextEngineConfig.last_kwargs == {} + assert _CaptureContextEngine.last_kwargs["paper_store"] == "paper-store" + assert _CaptureContextEngine.last_kwargs["search_service"] == "paper-search" + assert _CaptureContextEngine.last_kwargs["evidence_retriever"] == "document-index" + assert _CaptureContextEngine.last_kwargs["query_grounder"] == "grounder" diff --git a/tests/unit/test_mcp_paper_judge.py b/tests/unit/test_mcp_paper_judge.py new file mode 100644 index 00000000..d8241cce --- /dev/null +++ b/tests/unit/test_mcp_paper_judge.py @@ -0,0 +1,185 @@ +"""Unit tests for the paper_judge MCP tool.""" + +import json + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog +from paperbot.application.workflows.analysis.paper_judge import PaperJudge + + +class _FakeLLMService: + """LLM service returning a valid JSON payload.""" + + def __init__(self, payload): + self.payload = payload + + def complete(self, **kwargs): + return json.dumps(self.payload) + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "judge-model", "cost_tier": 2} + + +class _FakeEmptyLLMService: + """Simulates missing API key -- returns empty output.""" + + def complete(self, **kwargs): + return "" + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "", "model_name": "", "cost_tier": 0} + + +class _FakeConfiguredButEmptyLLMService: + """Simulates a configured provider that returns empty output.""" + + def complete(self, **kwargs): + return "" + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "judge-model", "cost_tier": 2} + + +_VALID_PAYLOAD = { + "relevance": {"score": 5, "rationale": "direct"}, + "novelty": {"score": 4, "rationale": "new"}, + "rigor": {"score": 4, "rationale": "solid"}, + "impact": {"score": 3, "rationale": "good"}, + "clarity": {"score": 5, "rationale": "clear"}, + "overall": 4.2, + "one_line_summary": "strong paper", + "recommendation": "must_read", +} + + +class TestPaperJudgeTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_judgment_dict_with_all_dimensions(self): + """paper_judge with fake LLM returns judgment dict with all dimension scores.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + judge = PaperJudge(llm_service=_FakeLLMService(_VALID_PAYLOAD)) + pj_mod._judge = judge + + try: + result = await pj_mod._paper_judge_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + pj_mod._judge = None + + assert isinstance(result, dict) + assert result["relevance"]["score"] == 5 + assert result["novelty"]["score"] == 4 + assert result["rigor"]["score"] == 4 + assert result["impact"]["score"] == 3 + assert result["clarity"]["score"] == 5 + assert result["overall"] == 4.2 + assert result["recommendation"] == "must_read" + assert result["judge_model"] == "judge-model" + assert "degraded" not in result + + @pytest.mark.asyncio + async def test_degraded_mode_when_llm_unavailable(self): + """paper_judge with empty LLM (no API key) returns degraded=true.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + judge = PaperJudge(llm_service=_FakeEmptyLLMService()) + pj_mod._judge = judge + + try: + result = await pj_mod._paper_judge_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + pj_mod._judge = None + + assert isinstance(result, dict) + assert result["degraded"] is True + assert "error" in result + assert "API_KEY" not in result["error"] + assert "unavailable" in result["error"].lower() or "invalid" in result["error"].lower() + assert result["judge_model"] == "" + + @pytest.mark.asyncio + async def test_maps_abstract_to_snippet(self): + """paper_judge maps 'abstract' parameter to 'snippet' key in paper dict.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + calls = [] + original_judge_single = PaperJudge.judge_single + + class _SpyJudge(PaperJudge): + def judge_single(self, *, paper, query): + calls.append(paper) + return original_judge_single(self, paper=paper, query=query) + + spy = _SpyJudge(llm_service=_FakeLLMService(_VALID_PAYLOAD)) + pj_mod._judge = spy + + try: + await pj_mod._paper_judge_impl( + title="My Title", + abstract="My abstract text", + ) + finally: + pj_mod._judge = None + + assert len(calls) == 1 + assert "snippet" in calls[0] + assert calls[0]["snippet"] == "My abstract text" + assert calls[0]["title"] == "My Title" + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """paper_judge logs call via log_tool_call with tool_name='paper_judge'.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + judge = PaperJudge(llm_service=_FakeLLMService(_VALID_PAYLOAD)) + pj_mod._judge = judge + + try: + await pj_mod._paper_judge_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + pj_mod._judge = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "paper_judge" + assert event["workflow"] == "mcp" + + @pytest.mark.asyncio + async def test_degraded_mode_when_provider_is_configured_but_returns_empty_output(self): + """paper_judge marks empty provider responses as degraded even when metadata exists.""" + import paperbot.mcp.tools.paper_judge as pj_mod + + judge = PaperJudge(llm_service=_FakeConfiguredButEmptyLLMService()) + pj_mod._judge = judge + + try: + result = await pj_mod._paper_judge_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + pj_mod._judge = None + + assert result["degraded"] is True + assert result["judge_model"] == "" + assert "API_KEY" not in result["error"] + assert "invalid" in result["error"].lower() or "unavailable" in result["error"].lower() diff --git a/tests/unit/test_mcp_paper_search.py b/tests/unit/test_mcp_paper_search.py new file mode 100644 index 00000000..9ef6310f --- /dev/null +++ b/tests/unit/test_mcp_paper_search.py @@ -0,0 +1,112 @@ +"""Unit tests for the paper_search MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog +from paperbot.domain.paper import PaperCandidate + + +class _FakeSearchAdapter: + """Implements SearchPort with canned results.""" + + source_name = "fake" + + async def search(self, query, *, max_results=10, year_from=None, year_to=None): + if query == "empty": + return [] + return [ + PaperCandidate( + title="Test Paper", + abstract="Test abstract", + authors=["Author A"], + ) + ] + + async def close(self): + pass + + +class TestPaperSearchTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_list_of_paper_dicts(self): + """paper_search with a fake adapter returns list of paper dicts.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + service = PaperSearchService( + adapters={"fake": _FakeSearchAdapter()}, + ) + ps_mod._service = service + + try: + result = await ps_mod._paper_search_impl( + query="transformers", + max_results=5, + ) + finally: + ps_mod._service = None + + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["title"] == "Test Paper" + assert result[0]["abstract"] == "Test abstract" + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_results(self): + """paper_search with no adapters returns empty list.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + service = PaperSearchService(adapters={}) + ps_mod._service = service + + try: + result = await ps_mod._paper_search_impl(query="empty", max_results=5) + finally: + ps_mod._service = None + + assert isinstance(result, list) + assert len(result) == 0 + + @pytest.mark.asyncio + async def test_calls_log_tool_call(self): + """paper_search tool calls log_tool_call with tool_name='paper_search'.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + service = PaperSearchService( + adapters={"fake": _FakeSearchAdapter()}, + ) + ps_mod._service = service + + try: + await ps_mod._paper_search_impl(query="transformers", max_results=5) + finally: + ps_mod._service = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "paper_search" + assert event["workflow"] == "mcp" + + @pytest.mark.asyncio + async def test_rejects_out_of_range_max_results(self): + """paper_search rejects max_results outside the supported range.""" + from paperbot.application.services.paper_search_service import PaperSearchService + import paperbot.mcp.tools.paper_search as ps_mod + + ps_mod._service = PaperSearchService(adapters={"fake": _FakeSearchAdapter()}) + try: + with pytest.raises(ValueError, match="max_results must be between 1 and 100"): + await ps_mod._paper_search_impl(query="transformers", max_results=0) + finally: + ps_mod._service = None diff --git a/tests/unit/test_mcp_paper_summarize.py b/tests/unit/test_mcp_paper_summarize.py new file mode 100644 index 00000000..903e33c6 --- /dev/null +++ b/tests/unit/test_mcp_paper_summarize.py @@ -0,0 +1,103 @@ +"""Unit tests for the paper_summarize MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog +from paperbot.application.workflows.analysis.paper_summarizer import PaperSummarizer + + +class _FakeLLMService: + """LLM service returning a canned summary.""" + + def __init__(self, summary_text="This paper presents a novel approach."): + self._summary = summary_text + + def summarize_paper(self, title: str, abstract: str) -> str: + return self._summary + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "summary-model", "cost_tier": 1} + + +class _FakeEmptyLLMService: + """Simulates missing API key -- returns empty output.""" + + def summarize_paper(self, title: str, abstract: str) -> str: + return "" + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "", "model_name": "", "cost_tier": 0} + + +class TestPaperSummarizeTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_summary_dict(self): + """paper_summarize with fake LLM returns summary string in a result dict.""" + import paperbot.mcp.tools.paper_summarize as ps_mod + + summarizer = PaperSummarizer(llm_service=_FakeLLMService("Great paper summary.")) + ps_mod._summarizer = summarizer + + try: + result = await ps_mod._paper_summarize_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + ps_mod._summarizer = None + + assert isinstance(result, dict) + assert result["summary"] == "Great paper summary." + assert "degraded" not in result + + @pytest.mark.asyncio + async def test_degraded_mode_when_llm_returns_empty(self): + """paper_summarize with empty LLM returns degraded=true and error message.""" + import paperbot.mcp.tools.paper_summarize as ps_mod + + summarizer = PaperSummarizer(llm_service=_FakeEmptyLLMService()) + ps_mod._summarizer = summarizer + + try: + result = await ps_mod._paper_summarize_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + ps_mod._summarizer = None + + assert isinstance(result, dict) + assert result["summary"] == "" + assert result["degraded"] is True + assert "error" in result + assert "unavailable" in result["error"].lower() or "API_KEY" in result["error"] + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """paper_summarize logs call via log_tool_call with tool_name='paper_summarize'.""" + import paperbot.mcp.tools.paper_summarize as ps_mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + summarizer = PaperSummarizer(llm_service=_FakeLLMService("Summary.")) + ps_mod._summarizer = summarizer + + try: + await ps_mod._paper_summarize_impl( + title="Test Paper", + abstract="Test abstract", + ) + finally: + ps_mod._summarizer = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "paper_summarize" + assert event["workflow"] == "mcp" diff --git a/tests/unit/test_mcp_relevance.py b/tests/unit/test_mcp_relevance.py new file mode 100644 index 00000000..88263e5f --- /dev/null +++ b/tests/unit/test_mcp_relevance.py @@ -0,0 +1,106 @@ +"""Unit tests for the relevance_assess MCP tool.""" + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog +from paperbot.application.workflows.analysis.relevance_assessor import RelevanceAssessor + + +class _FakeLLMService: + """LLM service returning a valid relevance assessment.""" + + def assess_relevance(self, *, paper, query): + return {"score": 85, "reason": "Highly relevant to the query topic."} + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "fake", "model_name": "relevance-model", "cost_tier": 1} + + +class _FakeFallbackLLMService: + """Simulates fallback scoring when LLM is unavailable.""" + + def assess_relevance(self, *, paper, query): + return { + "score": 40, + "reason": "Fallback score from token overlap (LLM output unavailable).", + } + + def describe_task_provider(self, task_type="default"): + return {"provider_name": "", "model_name": "", "cost_tier": 0} + + +class TestRelevanceAssessTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_returns_score_and_reason_dict(self): + """relevance_assess with fake LLM returns score and reason dict.""" + import paperbot.mcp.tools.relevance as rel_mod + + assessor = RelevanceAssessor(llm_service=_FakeLLMService()) + rel_mod._assessor = assessor + + try: + result = await rel_mod._relevance_assess_impl( + title="Test Paper", + abstract="Test abstract", + query="machine learning", + ) + finally: + rel_mod._assessor = None + + assert isinstance(result, dict) + assert result["score"] == 85 + assert result["reason"] == "Highly relevant to the query topic." + assert "degraded" not in result + + @pytest.mark.asyncio + async def test_fallback_scoring_annotates_degraded(self): + """relevance_assess with fallback scoring annotates result with degraded note.""" + import paperbot.mcp.tools.relevance as rel_mod + + assessor = RelevanceAssessor(llm_service=_FakeFallbackLLMService()) + rel_mod._assessor = assessor + + try: + result = await rel_mod._relevance_assess_impl( + title="Test Paper", + abstract="Test abstract", + query="machine learning", + ) + finally: + rel_mod._assessor = None + + assert isinstance(result, dict) + assert result["degraded"] is True + assert "note" in result + assert "token" in result["note"].lower() or "fallback" in result["note"].lower() + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """relevance_assess logs call via log_tool_call with tool_name='relevance_assess'.""" + import paperbot.mcp.tools.relevance as rel_mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + assessor = RelevanceAssessor(llm_service=_FakeLLMService()) + rel_mod._assessor = assessor + + try: + await rel_mod._relevance_assess_impl( + title="Test Paper", + abstract="Test abstract", + query="machine learning", + ) + finally: + rel_mod._assessor = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "relevance_assess" + assert event["workflow"] == "mcp" diff --git a/tests/unit/test_mcp_save_to_memory.py b/tests/unit/test_mcp_save_to_memory.py new file mode 100644 index 00000000..287ac957 --- /dev/null +++ b/tests/unit/test_mcp_save_to_memory.py @@ -0,0 +1,142 @@ +"""Unit tests for the save_to_memory MCP tool.""" + +import re + +import pytest + +from paperbot.core.di import Container +from paperbot.application.ports.event_log_port import EventLogPort +from paperbot.infrastructure.event_log.memory_event_log import InMemoryEventLog + + +class _FakeMemoryStore: + """Fake memory store that records calls and returns canned results.""" + + def __init__(self, created=1, skipped=0): + self.calls = [] + self._created = created + self._skipped = skipped + + def add_memories(self, user_id, memories): + self.calls.append({"user_id": user_id, "memories": memories}) + return (self._created, self._skipped, [{"id": 1}]) + + +class TestSaveToMemoryTool: + def setup_method(self): + Container._instance = None + + @pytest.mark.asyncio + async def test_saves_content_and_returns_counts(self): + """_save_to_memory_impl with fake MemoryStore returns saved=True with created/skipped counts.""" + import paperbot.mcp.tools.save_to_memory as mod + + store = _FakeMemoryStore(created=1, skipped=0) + mod._store = store + try: + result = await mod._save_to_memory_impl( + content="Finding X: attention is all you need", + kind="note", + user_id="test_user", + ) + finally: + mod._store = None + + assert result["saved"] is True + assert result["created"] == 1 + assert result["skipped"] == 0 + assert len(store.calls) == 1 + call = store.calls[0] + assert call["user_id"] == "test_user" + assert len(call["memories"]) == 1 + assert call["memories"][0].content == "Finding X: attention is all you need" + assert call["memories"][0].kind == "note" + + @pytest.mark.asyncio + async def test_handles_invalid_kind_gracefully(self): + """_save_to_memory_impl with kind='invalid_kind' defaults to 'note' without raising.""" + import paperbot.mcp.tools.save_to_memory as mod + + store = _FakeMemoryStore(created=1, skipped=0) + mod._store = store + try: + result = await mod._save_to_memory_impl( + content="Some content", + kind="invalid_kind", + user_id="test_user", + ) + finally: + mod._store = None + + # Should not raise -- invalid kind defaults to "note" + assert result["saved"] is True + assert result["created"] == 1 + # The candidate should have kind="note" after defaulting + assert store.calls[0]["memories"][0].kind == "note" + + @pytest.mark.asyncio + async def test_logs_call_via_log_tool_call(self): + """_save_to_memory_impl logs call via log_tool_call with correct tool name and workflow.""" + import paperbot.mcp.tools.save_to_memory as mod + + log = InMemoryEventLog() + container = Container.instance() + container.register(EventLogPort, lambda: log) + + store = _FakeMemoryStore() + mod._store = store + try: + await mod._save_to_memory_impl( + content="Hypothesis: scaling laws hold", + kind="hypothesis", + user_id="mcp-user", + ) + finally: + mod._store = None + + assert len(log.events) == 1 + event = log.events[0] + assert event["payload"]["tool"] == "save_to_memory" + assert event["workflow"] == "mcp" + + @pytest.mark.asyncio + async def test_returns_saved_false_when_store_skips_write(self): + """_save_to_memory_impl returns saved=False when no new memory row is created.""" + import paperbot.mcp.tools.save_to_memory as mod + + store = _FakeMemoryStore(created=0, skipped=1) + mod._store = store + try: + result = await mod._save_to_memory_impl( + content="Duplicate finding", + kind="note", + user_id="test_user", + ) + finally: + mod._store = None + + assert result["saved"] is False + assert result["created"] == 0 + assert result["skipped"] == 1 + + @pytest.mark.asyncio + async def test_rejects_out_of_range_confidence(self): + """_save_to_memory_impl rejects invalid confidence values before persisting.""" + import paperbot.mcp.tools.save_to_memory as mod + + store = _FakeMemoryStore(created=1, skipped=0) + mod._store = store + try: + with pytest.raises( + ValueError, + match=re.escape("confidence must be between 0.0 and 1.0"), + ): + await mod._save_to_memory_impl( + content="Confidence bug", + confidence=1.5, + user_id="test_user", + ) + finally: + mod._store = None + + assert store.calls == [] diff --git a/tests/unit/test_mcp_scholars.py b/tests/unit/test_mcp_scholars.py new file mode 100644 index 00000000..b3017f89 --- /dev/null +++ b/tests/unit/test_mcp_scholars.py @@ -0,0 +1,83 @@ +"""Unit tests for the scholars MCP resource (MCP-09). + +Tests _scholars_impl with a fake SubscriptionService injected via +the module-level _service singleton pattern. +""" + +import json + +import pytest + + +class _FakeSubscriptionService: + """SubscriptionService stub returning a canned scholar list.""" + + def get_scholar_configs(self): + return [ + {"name": "Dawn Song", "semantic_scholar_id": "123", "keywords": ["security", "ML"]}, + {"name": "Yoshua Bengio", "semantic_scholar_id": "456", "keywords": ["deep learning"]}, + ] + + +class _FakeMissingConfigService: + """SubscriptionService stub that raises FileNotFoundError (config file missing).""" + + def get_scholar_configs(self): + raise FileNotFoundError("config/scholar_subscriptions.yaml not found") + + +class _FakeInvalidConfigService: + """SubscriptionService stub that raises ValueError for malformed config.""" + + def get_scholar_configs(self): + raise ValueError("Invalid YAML in config file: unexpected token") + + +class TestScholarsResource: + @pytest.mark.asyncio + async def test_returns_scholar_list(self): + """_scholars_impl() returns JSON list with name and semantic_scholar_id.""" + import paperbot.mcp.resources.scholars as mod + + mod._service = _FakeSubscriptionService() + try: + result = await mod._scholars_impl() + finally: + mod._service = None + + data = json.loads(result) + assert isinstance(data, list) + assert len(data) == 2 + assert data[0]["name"] == "Dawn Song" + assert data[0]["semantic_scholar_id"] == "123" + + @pytest.mark.asyncio + async def test_returns_error_json_when_config_not_found(self): + """_scholars_impl() returns JSON error when config file not found.""" + import paperbot.mcp.resources.scholars as mod + + mod._service = _FakeMissingConfigService() + try: + result = await mod._scholars_impl() + finally: + mod._service = None + + data = json.loads(result) + assert "error" in data + assert "scholars" in data + assert data["scholars"] == [] + + @pytest.mark.asyncio + async def test_returns_error_json_when_config_is_invalid(self): + """_scholars_impl() returns JSON error when config validation fails.""" + import paperbot.mcp.resources.scholars as mod + + mod._service = _FakeInvalidConfigService() + try: + result = await mod._scholars_impl() + finally: + mod._service = None + + data = json.loads(result) + assert data["error"].startswith("Invalid YAML in config file:") + assert data["scholars"] == [] diff --git a/tests/unit/test_mcp_serve_cli.py b/tests/unit/test_mcp_serve_cli.py new file mode 100644 index 00000000..6b3cc461 --- /dev/null +++ b/tests/unit/test_mcp_serve_cli.py @@ -0,0 +1,270 @@ +"""Unit tests for MCP serve module and CLI subcommand. + +Covers: + - serve.py module imports and exports + - run_stdio() / run_http() dispatch to mcp.run() with correct args + - stdio mode configures logging to stderr + - None guard exits with error message when mcp is unavailable + - pyproject.toml has [project.scripts] entry and mcp[fastmcp] dependency + - requirements.txt includes mcp[fastmcp] + - CLI parses `paperbot mcp serve --stdio` / `--http` correctly + - CLI dispatch calls run_stdio() / run_http() with correct args + - Mutual exclusion of --stdio and --http + - `paperbot mcp` (no subcommand) prints help and returns 0 +""" + +from __future__ import annotations + +import importlib +import logging +import sys +from pathlib import Path +from typing import Any, List, Optional, Tuple + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).parent.parent.parent + + +class _FakeMCP: + """Minimal stub for mcp singleton — records .run() calls without blocking.""" + + def __init__(self) -> None: + self.run_calls: List[Tuple[tuple, dict]] = [] + + def run(self, *args: Any, **kwargs: Any) -> None: # noqa: D401 + self.run_calls.append((args, kwargs)) + + +# --------------------------------------------------------------------------- +# Task 1 tests: serve.py module +# --------------------------------------------------------------------------- + + +class TestServeModuleImport: + def test_import_run_stdio_and_run_http(self): + """from paperbot.mcp.serve import run_stdio, run_http succeeds.""" + from paperbot.mcp.serve import run_http, run_stdio + + assert callable(run_stdio) + assert callable(run_http) + + +class TestRunStdio: + def test_calls_mcp_run_with_stdio_transport(self, monkeypatch): + """run_stdio() calls mcp.run(transport='stdio').""" + fake = _FakeMCP() + monkeypatch.setattr("paperbot.mcp.server.mcp", fake) + + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + + # Patch the module-level reference that serve_mod uses + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: fake) + + serve_mod.run_stdio() + + assert len(fake.run_calls) == 1 + _, kwargs = fake.run_calls[0] + assert kwargs.get("transport") == "stdio" + + def test_configures_logging_to_stderr(self, monkeypatch): + """run_stdio() calls logging.basicConfig(stream=sys.stderr, ...).""" + fake = _FakeMCP() + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: fake) + + captured_calls: List[dict] = [] + original_basicConfig = logging.basicConfig + + def fake_basicConfig(**kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(logging, "basicConfig", fake_basicConfig) + + serve_mod.run_stdio() + + assert any(c.get("stream") is sys.stderr for c in captured_calls) + + +class TestRunHttp: + def test_calls_mcp_run_with_default_host_port(self, monkeypatch): + """run_http() calls mcp.run(transport='streamable-http', host='127.0.0.1', port=8001).""" + fake = _FakeMCP() + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: fake) + + serve_mod.run_http() + + assert len(fake.run_calls) == 1 + _, kwargs = fake.run_calls[0] + assert kwargs.get("transport") == "streamable-http" + assert kwargs.get("host") == "127.0.0.1" + assert kwargs.get("port") == 8001 + + def test_passes_custom_host_and_port(self, monkeypatch): + """run_http(host='0.0.0.0', port=9000) passes those values to mcp.run().""" + fake = _FakeMCP() + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: fake) + + serve_mod.run_http(host="0.0.0.0", port=9000) + + assert len(fake.run_calls) == 1 + _, kwargs = fake.run_calls[0] + assert kwargs.get("host") == "0.0.0.0" + assert kwargs.get("port") == 9000 + + +class TestMcpNoneGuard: + def test_run_stdio_exits_when_mcp_none(self, monkeypatch, capsys): + """run_stdio() prints error to stderr and exits 1 when mcp is None.""" + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: None) + + with pytest.raises(SystemExit) as exc_info: + serve_mod.run_stdio() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.err # some message written to stderr + + def test_run_http_exits_when_mcp_none(self, monkeypatch, capsys): + """run_http() prints error to stderr and exits 1 when mcp is None.""" + import paperbot.mcp.serve as serve_mod + + importlib.reload(serve_mod) + monkeypatch.setattr(serve_mod, "_get_mcp", lambda: None) + + with pytest.raises(SystemExit) as exc_info: + serve_mod.run_http() + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert captured.err + + +class TestPyprojectScripts: + def test_project_scripts_entry_exists(self): + """pyproject.toml has [project.scripts] with paperbot = '...:run_cli'.""" + content = (REPO_ROOT / "pyproject.toml").read_text() + assert "[project.scripts]" in content + assert "paperbot" in content + assert "run_cli" in content + + def test_mcp_fastmcp_in_dependencies(self): + """pyproject.toml keeps mcp[fastmcp] gated behind the Python 3.10 marker.""" + content = (REPO_ROOT / "pyproject.toml").read_text() + assert "mcp[fastmcp]" in content + assert "python_version >= '3.10'" in content + + def test_requirements_txt_has_mcp_fastmcp(self): + """requirements.txt keeps mcp[fastmcp] gated behind the Python 3.10 marker.""" + content = (REPO_ROOT / "requirements.txt").read_text() + assert "mcp[fastmcp]" in content + assert 'python_version >= "3.10"' in content + + +# --------------------------------------------------------------------------- +# Task 2 tests: CLI subcommand +# --------------------------------------------------------------------------- + + +class TestCLIServeCommand: + def _parser(self): + from paperbot.presentation.cli.main import create_parser + + return create_parser() + + def test_parse_mcp_serve_stdio(self): + """parse_args(['mcp', 'serve', '--stdio']) sets expected attributes.""" + parsed = self._parser().parse_args(["mcp", "serve", "--stdio"]) + assert parsed.command == "mcp" + assert parsed.mcp_command == "serve" + assert parsed.stdio is True + assert parsed.http is False + + def test_parse_mcp_serve_http_defaults(self): + """parse_args(['mcp', 'serve', '--http']) sets http=True, default host/port.""" + parsed = self._parser().parse_args(["mcp", "serve", "--http"]) + assert parsed.http is True + assert parsed.stdio is False + assert parsed.host == "127.0.0.1" + assert parsed.port == 8001 + + def test_parse_mcp_serve_http_custom_host_port(self): + """parse_args with --host/--port passes custom values.""" + parsed = self._parser().parse_args( + ["mcp", "serve", "--http", "--host", "0.0.0.0", "--port", "9000"] + ) + assert parsed.host == "0.0.0.0" + assert parsed.port == 9000 + + def test_parse_mcp_serve_mutually_exclusive(self): + """--stdio and --http are mutually exclusive.""" + with pytest.raises(SystemExit): + self._parser().parse_args(["mcp", "serve", "--stdio", "--http"]) + + def test_run_cli_mcp_serve_stdio_dispatches(self, monkeypatch): + """run_cli(['mcp', 'serve', '--stdio']) calls run_stdio().""" + called_with: List[dict] = [] + + def fake_run_stdio(): + called_with.append({"fn": "run_stdio"}) + + monkeypatch.setattr("paperbot.mcp.serve.run_stdio", fake_run_stdio) + + from paperbot.presentation.cli.main import run_cli + + run_cli(["mcp", "serve", "--stdio"]) + + assert len(called_with) == 1 + assert called_with[0]["fn"] == "run_stdio" + + def test_run_cli_mcp_serve_http_dispatches(self, monkeypatch): + """run_cli(['mcp', 'serve', '--http', '--port', '9000']) calls run_http with correct args.""" + called_with: List[dict] = [] + + def fake_run_http(host: str = "127.0.0.1", port: int = 8001): + called_with.append({"fn": "run_http", "host": host, "port": port}) + + monkeypatch.setattr("paperbot.mcp.serve.run_http", fake_run_http) + + from paperbot.presentation.cli.main import run_cli + + run_cli(["mcp", "serve", "--http", "--port", "9000"]) + + assert len(called_with) == 1 + assert called_with[0]["fn"] == "run_http" + assert called_with[0]["host"] == "127.0.0.1" + assert called_with[0]["port"] == 9000 + + def test_run_cli_mcp_no_subcommand_returns_zero(self): + """run_cli(['mcp']) prints help and returns 0.""" + from paperbot.presentation.cli.main import run_cli + + result = run_cli(["mcp"]) + assert result == 0 + + def test_run_cli_mcp_serve_no_transport_exits_nonzero(self): + """run_cli(['mcp', 'serve']) with no --stdio/--http exits or returns non-zero.""" + from paperbot.presentation.cli.main import run_cli + + try: + result = run_cli(["mcp", "serve"]) + assert result != 0 + except SystemExit as e: + assert e.code != 0 diff --git a/tests/unit/test_mcp_track_memory.py b/tests/unit/test_mcp_track_memory.py new file mode 100644 index 00000000..25c73608 --- /dev/null +++ b/tests/unit/test_mcp_track_memory.py @@ -0,0 +1,123 @@ +"""Unit tests for the track_memory MCP resource (MCP-08). + +Tests _track_memory_impl with a fake SqlAlchemyMemoryStore injected via +the module-level _store singleton pattern. Verifies scope_type="track" +filtering is applied correctly. +""" + +import json +from typing import Optional + +import pytest + +TEST_USER_ID = "mcp-user" + + +class _FakeMemoryStore: + """MemoryStore stub that captures args and returns canned memories.""" + + def __init__(self, memories=None): + self._memories = memories if memories is not None else [] + self.last_call_kwargs = {} + + def list_memories( + self, + *, + user_id: str, + scope_type: Optional[str] = None, + scope_id: Optional[str] = None, + limit: int = 100, + ): + self.last_call_kwargs = { + "user_id": user_id, + "scope_type": scope_type, + "scope_id": scope_id, + "limit": limit, + } + return self._memories + + +class _FailingMemoryStore: + def list_memories(self, **kwargs): + raise RuntimeError("db unavailable") + + +class TestTrackMemoryResource: + @pytest.mark.asyncio + async def test_returns_memories_for_track(self): + """_track_memory_impl('42') returns JSON list of memory dicts.""" + import paperbot.mcp.resources.track_memory as mod + + fake_store = _FakeMemoryStore(memories=[{"id": 1, "content": "note", "kind": "note"}]) + mod._store = fake_store + try: + result = await mod._track_memory_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert isinstance(data, list) + assert len(data) == 1 + assert data[0]["content"] == "note" + + @pytest.mark.asyncio + async def test_returns_empty_list_when_no_memories(self): + """_track_memory_impl returns empty JSON list when no memories exist.""" + import paperbot.mcp.resources.track_memory as mod + + fake_store = _FakeMemoryStore(memories=[]) + mod._store = fake_store + try: + result = await mod._track_memory_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert isinstance(data, list) + assert data == [] + + @pytest.mark.asyncio + async def test_uses_track_scope_filtering(self): + """_track_memory_impl calls list_memories with scope_type='track' and scope_id='42'.""" + import paperbot.mcp.resources.track_memory as mod + + fake_store = _FakeMemoryStore(memories=[]) + mod._store = fake_store + try: + await mod._track_memory_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + assert fake_store.last_call_kwargs["scope_type"] == "track" + assert fake_store.last_call_kwargs["scope_id"] == "42" + assert fake_store.last_call_kwargs["user_id"] == TEST_USER_ID + + @pytest.mark.asyncio + async def test_returns_error_for_invalid_track_id(self): + """_track_memory_impl('bad') returns JSON error for non-integer track_id.""" + import paperbot.mcp.resources.track_memory as mod + + fake_store = _FakeMemoryStore() + mod._store = fake_store + try: + result = await mod._track_memory_impl(user_id=TEST_USER_ID, track_id="bad") + finally: + mod._store = None + + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_returns_error_object_when_store_lookup_fails(self): + """_track_memory_impl returns a stable JSON error object on store failures.""" + import paperbot.mcp.resources.track_memory as mod + + mod._store = _FailingMemoryStore() + try: + result = await mod._track_memory_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert data["error"] == "failed to list memories" + assert data["track_id"] == "42" diff --git a/tests/unit/test_mcp_track_metadata.py b/tests/unit/test_mcp_track_metadata.py new file mode 100644 index 00000000..8c26a21c --- /dev/null +++ b/tests/unit/test_mcp_track_metadata.py @@ -0,0 +1,100 @@ +"""Unit tests for the track_metadata MCP resource (MCP-06). + +Tests _track_metadata_impl with a fake SqlAlchemyResearchStore injected via +the module-level _store singleton pattern. +""" + +import json + +import pytest + +TEST_USER_ID = "mcp-user" + + +class _FakeResearchStore: + """ResearchStore stub returning canned track data.""" + + def __init__(self): + self.calls = [] + + def get_track(self, *, user_id: str, track_id: int): + self.calls.append({"user_id": user_id, "track_id": track_id}) + if user_id == TEST_USER_ID and track_id == 42: + return { + "id": 42, + "name": "ML", + "description": "Machine learning research", + "keywords": ["deep learning", "neural networks"], + "venues": ["NeurIPS", "ICML"], + "methods": ["transformers", "diffusion"], + "archived_at": None, + } + if user_id == TEST_USER_ID and track_id == 43: + return {"id": 43, "name": "Archived", "archived_at": "2026-03-14T00:00:00+00:00"} + return None + + +class TestTrackMetadataResource: + @pytest.mark.asyncio + async def test_returns_track_metadata_for_valid_id(self): + """_track_metadata_impl('42') returns JSON with track fields.""" + import paperbot.mcp.resources.track_metadata as mod + + fake_store = _FakeResearchStore() + mod._store = fake_store + try: + result = await mod._track_metadata_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert fake_store.calls == [{"user_id": TEST_USER_ID, "track_id": 42}] + assert data["id"] == 42 + assert data["name"] == "ML" + assert "description" in data + assert "keywords" in data + assert "venues" in data + assert "methods" in data + + @pytest.mark.asyncio + async def test_returns_error_when_track_not_found(self): + """_track_metadata_impl('99') returns JSON error when track not found.""" + import paperbot.mcp.resources.track_metadata as mod + + mod._store = _FakeResearchStore() + try: + result = await mod._track_metadata_impl(user_id=TEST_USER_ID, track_id="99") + finally: + mod._store = None + + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_returns_error_for_non_integer_track_id(self): + """_track_metadata_impl('abc') returns JSON error for invalid track_id.""" + import paperbot.mcp.resources.track_metadata as mod + + mod._store = _FakeResearchStore() + try: + result = await mod._track_metadata_impl(user_id=TEST_USER_ID, track_id="abc") + finally: + mod._store = None + + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_returns_error_when_track_is_archived(self): + """_track_metadata_impl returns error JSON for archived tracks.""" + import paperbot.mcp.resources.track_metadata as mod + + fake_store = _FakeResearchStore() + mod._store = fake_store + try: + result = await mod._track_metadata_impl(user_id=TEST_USER_ID, track_id="43") + finally: + mod._store = None + + data = json.loads(result) + assert data["error"] == "Track 43 not found." diff --git a/tests/unit/test_mcp_track_papers.py b/tests/unit/test_mcp_track_papers.py new file mode 100644 index 00000000..f21adffb --- /dev/null +++ b/tests/unit/test_mcp_track_papers.py @@ -0,0 +1,120 @@ +"""Unit tests for the track_papers MCP resource (MCP-07). + +Tests _track_papers_impl with a fake SqlAlchemyResearchStore injected via +the module-level _store singleton pattern. +""" + +import json + +import pytest + +TEST_USER_ID = "mcp-user" + + +class _FakeResearchStore: + """ResearchStore stub returning canned feed data.""" + + def __init__(self, items=None, track_exists=True, archived=False): + self._items = items if items is not None else [{"title": "P1", "arxiv_id": "2401.0001"}] + self._track_exists = track_exists + self._archived = archived + self.calls = [] + + def get_track(self, *, user_id: str, track_id: int): + self.calls.append({"fn": "get_track", "user_id": user_id, "track_id": track_id}) + if not self._track_exists: + return None + return { + "id": track_id, + "name": "Track", + "archived_at": "2026-03-14T00:00:00+00:00" if self._archived else None, + } + + def list_track_feed(self, *, user_id: str, track_id: int, limit: int = 50): + self.calls.append( + {"fn": "list_track_feed", "user_id": user_id, "track_id": track_id, "limit": limit} + ) + return {"items": self._items, "total": len(self._items)} + + +class TestTrackPapersResource: + @pytest.mark.asyncio + async def test_returns_papers_for_valid_track(self): + """_track_papers_impl('42') returns JSON with items list.""" + import paperbot.mcp.resources.track_papers as mod + + fake_store = _FakeResearchStore(items=[{"title": "P1"}]) + mod._store = fake_store + try: + result = await mod._track_papers_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert "items" in data + assert len(data["items"]) == 1 + assert data["items"][0]["title"] == "P1" + assert fake_store.calls == [ + {"fn": "get_track", "user_id": TEST_USER_ID, "track_id": 42}, + {"fn": "list_track_feed", "user_id": TEST_USER_ID, "track_id": 42, "limit": 50}, + ] + + @pytest.mark.asyncio + async def test_returns_empty_items_when_track_has_no_papers(self): + """_track_papers_impl returns JSON with empty items list for empty track.""" + import paperbot.mcp.resources.track_papers as mod + + mod._store = _FakeResearchStore(items=[]) + try: + result = await mod._track_papers_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert "items" in data + assert data["items"] == [] + + @pytest.mark.asyncio + async def test_returns_error_for_invalid_track_id(self): + """_track_papers_impl('xyz') returns JSON error for non-integer track_id.""" + import paperbot.mcp.resources.track_papers as mod + + mod._store = _FakeResearchStore() + try: + result = await mod._track_papers_impl(user_id=TEST_USER_ID, track_id="xyz") + finally: + mod._store = None + + data = json.loads(result) + assert "error" in data + + @pytest.mark.asyncio + async def test_returns_error_when_track_not_found(self): + """_track_papers_impl returns error JSON when the track does not exist.""" + import paperbot.mcp.resources.track_papers as mod + + fake_store = _FakeResearchStore(track_exists=False) + mod._store = fake_store + try: + result = await mod._track_papers_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert data["error"] == "Track 42 not found." + assert fake_store.calls == [{"fn": "get_track", "user_id": TEST_USER_ID, "track_id": 42}] + + @pytest.mark.asyncio + async def test_returns_error_when_track_is_archived(self): + """_track_papers_impl returns error JSON for archived tracks.""" + import paperbot.mcp.resources.track_papers as mod + + fake_store = _FakeResearchStore(archived=True) + mod._store = fake_store + try: + result = await mod._track_papers_impl(user_id=TEST_USER_ID, track_id="42") + finally: + mod._store = None + + data = json.loads(result) + assert data["error"] == "Track 42 not found." diff --git a/tests/unit/test_obsidian_cli.py b/tests/unit/test_obsidian_cli.py index b5838fb4..e6b10f82 100644 --- a/tests/unit/test_obsidian_cli.py +++ b/tests/unit/test_obsidian_cli.py @@ -9,10 +9,10 @@ class _FakeResearchStore: def get_track(self, *, user_id: str, track_id: int): - if user_id == "default" and track_id == 7: + if user_id == "cli-user" and track_id == 7: return { "id": 7, - "user_id": "default", + "user_id": "cli-user", "name": "ICL Compression", "description": "Track compression methods.", "keywords": ["ICL", "Compression"], @@ -37,7 +37,7 @@ def list_tracks(self, *, user_id: str, include_archived: bool, limit: int): ] def list_saved_papers(self, *, user_id: str, track_id: int | None, limit: int): - assert user_id == "default" + assert user_id == "cli-user" assert track_id == 7 assert limit == 5 return [ @@ -104,6 +104,8 @@ def test_cli_obsidian_export_parser_flags(): "/tmp/my-vault", "--track-name", "ICL Compression", + "--user-id", + "cli-user", "--limit", "5", "--json", @@ -135,6 +137,8 @@ def test_cli_obsidian_export_json_output(monkeypatch, capsys): "/tmp/my-vault", "--track-name", "ICL Compression", + "--user-id", + "cli-user", "--limit", "5", "--json", @@ -177,6 +181,8 @@ def test_cli_obsidian_export_uses_settings_defaults(monkeypatch, capsys): "obsidian", "--track-name", "ICL Compression", + "--user-id", + "cli-user", "--limit", "5", "--json", diff --git a/tests/unit/test_obsidian_sync.py b/tests/unit/test_obsidian_sync.py index 8e31f45f..f2fcb79d 100644 --- a/tests/unit/test_obsidian_sync.py +++ b/tests/unit/test_obsidian_sync.py @@ -11,24 +11,24 @@ def __init__(self) -> None: self.closed = False def get_track(self, *, user_id: str, track_id: int): - assert user_id == "default" + assert user_id == "obsidian-user" assert track_id == 7 - return {"id": 7, "user_id": "default", "name": "ICL Compression"} + return {"id": 7, "user_id": "obsidian-user", "name": "ICL Compression"} def list_saved_papers(self, *, user_id: str, track_id: int, limit: int): - assert user_id == "default" + assert user_id == "obsidian-user" assert track_id == 7 assert limit == 25 return [{"paper": {"id": 1, "title": "UniICL"}}] def list_tasks(self, *, user_id: str, track_id: int, limit: int): - assert user_id == "default" + assert user_id == "obsidian-user" assert track_id == 7 assert limit == 100 return [{"title": "Benchmark prompt compression", "status": "doing"}] def list_milestones(self, *, user_id: str, track_id: int, limit: int): - assert user_id == "default" + assert user_id == "obsidian-user" assert track_id == 7 assert limit == 100 return [{"name": "Submit workshop paper", "status": "todo"}] @@ -83,14 +83,14 @@ def export_library_snapshot( monkeypatch.setattr(obsidian_sync, "SqlAlchemyResearchStore", _FakeResearchStore) monkeypatch.setattr(obsidian_sync, "ObsidianFilesystemExporter", _FakeExporter) - result = obsidian_sync.export_track_snapshot(user_id="default", track_id=7) + result = obsidian_sync.export_track_snapshot(user_id="obsidian-user", track_id=7) assert result == {"paper_count": 1} assert captured["vault_path"] == vault_dir assert captured["root_dir"] == "PaperBot Notes" assert captured["track"] == { "id": 7, - "user_id": "default", + "user_id": "obsidian-user", "name": "ICL Compression", "tasks": [{"title": "Benchmark prompt compression", "status": "doing"}], "milestones": [{"name": "Submit workshop paper", "status": "todo"}], diff --git a/tests/unit/test_obsidian_sync_service.py b/tests/unit/test_obsidian_sync_service.py index 04ae29f5..2a60a994 100644 --- a/tests/unit/test_obsidian_sync_service.py +++ b/tests/unit/test_obsidian_sync_service.py @@ -20,6 +20,7 @@ def add_memories(self, *, user_id: str, memories: list, **_: object): def test_obsidian_sync_scan_captures_user_tags_links_notes_and_conflicts(tmp_path: Path) -> None: + user_id = "obsidian-user" vault = tmp_path / "vault" root = vault / "PaperBot" papers_dir = root / "Papers" @@ -42,7 +43,7 @@ def test_obsidian_sync_scan_captures_user_tags_links_notes_and_conflicts(tmp_pat "---\n" "paperbot_type: paper\n" "paperbot_id: paper-1\n" - "user_id: default\n" + f"user_id: {user_id}\n" "title: UniICL\n" "tags:\n" " - icl\n" @@ -92,7 +93,7 @@ def test_obsidian_sync_scan_captures_user_tags_links_notes_and_conflicts(tmp_pat "---\n" "paperbot_type: paper\n" "paperbot_id: paper-1\n" - "user_id: default\n" + f"user_id: {user_id}\n" "title: UniICL\n" "tags:\n" " - icl\n" diff --git a/tests/unit/test_paper_judge_persistence.py b/tests/unit/test_paper_judge_persistence.py index 4576ffb1..290a4068 100644 --- a/tests/unit/test_paper_judge_persistence.py +++ b/tests/unit/test_paper_judge_persistence.py @@ -83,9 +83,9 @@ def test_feedback_links_to_paper_registry_row(tmp_path: Path): } ) - track = research_store.create_track(user_id="default", name="t1", activate=True) + track = research_store.create_track(user_id="judge-user", name="t1", activate=True) feedback = research_store.add_paper_feedback( - user_id="default", + user_id="judge-user", track_id=int(track["id"]), paper_id="https://arxiv.org/abs/2501.12345", action="save", diff --git a/tests/unit/test_paperscool_route.py b/tests/unit/test_paperscool_route.py index 633fb010..7b7892f2 100644 --- a/tests/unit/test_paperscool_route.py +++ b/tests/unit/test_paperscool_route.py @@ -79,7 +79,7 @@ def run(self, *, queries, sources, branches, top_k_per_query, show_per_branch, m async def _fake_run_topic_search( *, - user_id="default", + user_id=None, queries, sources, branches, @@ -100,7 +100,7 @@ async def _fake_run_topic_search( async def _fake_run_topic_search_multi( *, - user_id="default", + user_id=None, queries, sources, branches, diff --git a/tests/unit/test_research_context_route_explicit_track.py b/tests/unit/test_research_context_route_explicit_track.py index 126496ea..c90a050b 100644 --- a/tests/unit/test_research_context_route_explicit_track.py +++ b/tests/unit/test_research_context_route_explicit_track.py @@ -3,6 +3,7 @@ from fastapi.testclient import TestClient from paperbot.api import main as api_main +from paperbot.api.auth import dependencies as auth_deps from paperbot.api.routes import research as research_route @@ -14,6 +15,13 @@ def record_metric(self, *, track_id=None, **kwargs) -> None: self.track_ids.append(track_id) +def _override_user_id(user_id: str): + def _dep_override(): + return user_id + + return _dep_override + + def test_context_route_uses_explicit_track_id_without_activation(monkeypatch): captured: dict[str, object] = {} metric_store = _FakeWorkflowMetricStore() @@ -46,18 +54,22 @@ async def close(self) -> None: monkeypatch.setattr(research_route, "_workflow_metric_store", metric_store) monkeypatch.setattr(research_route, "ContextEngine", _FakeContextEngine) - with TestClient(api_main.app) as client: - response = client.post( - "/api/research/context", - json={ - "user_id": "u-explicit", - "query": "agentic retrieval", - "track_id": 42, - "paper_limit": 0, - "offline": True, - "include_cross_track": False, - }, - ) + app = api_main.app + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("u-explicit") + try: + with TestClient(app) as client: + response = client.post( + "/api/research/context", + json={ + "query": "agentic retrieval", + "track_id": 42, + "paper_limit": 0, + "offline": True, + "include_cross_track": False, + }, + ) + finally: + app.dependency_overrides.clear() assert response.status_code == 200 assert captured["track_id"] == 42 @@ -104,11 +116,16 @@ def ground_query(self, *, user_id: str, query: str, limit: int = 3): monkeypatch.setattr(research_route, "_track_router", _FakeTrackRouter()) monkeypatch.setattr(research_route, "_workflow_query_grounder", _FakeGrounder()) - with TestClient(api_main.app) as client: - response = client.post( - "/api/research/router/suggest", - json={"user_id": "u1", "query": "rag latency"}, - ) + app = api_main.app + app.dependency_overrides[auth_deps.get_required_user_id] = _override_user_id("u1") + try: + with TestClient(app) as client: + response = client.post( + "/api/research/router/suggest", + json={"query": "rag latency"}, + ) + finally: + app.dependency_overrides.clear() assert response.status_code == 200 assert captured["query"] == "rag latency retrieval augmented generation latency" diff --git a/tests/unit/test_unified_topic_search_grounding.py b/tests/unit/test_unified_topic_search_grounding.py index 75bda4e2..8cc57679 100644 --- a/tests/unit/test_unified_topic_search_grounding.py +++ b/tests/unit/test_unified_topic_search_grounding.py @@ -59,7 +59,7 @@ async def _fake_search_candidate_papers(service, *, query, sources, max_results, grounder = WorkflowQueryGrounder(WikiConceptService(_FakeWikiConceptStore())) result = await uts.run_unified_topic_search( - user_id="default", + user_id="ground-user", queries=["rag latency"], search_service=object(), query_grounder=grounder, diff --git a/tests/unit/test_wiki_concept_service.py b/tests/unit/test_wiki_concept_service.py index ec4780e1..05af6b54 100644 --- a/tests/unit/test_wiki_concept_service.py +++ b/tests/unit/test_wiki_concept_service.py @@ -45,7 +45,7 @@ def load_grounding_snapshot( def test_wiki_concept_service_enriches_catalog_with_live_grounding(): service = WikiConceptService(_FakeWikiConceptStore()) - items = service.list_concepts(user_id="default", query="transformer") + items = service.list_concepts(user_id="wiki-user", query="transformer") assert items top = items[0] @@ -57,7 +57,7 @@ def test_wiki_concept_service_enriches_catalog_with_live_grounding(): def test_wiki_concept_service_filters_by_category(): service = WikiConceptService(_FakeWikiConceptStore()) - items = service.list_concepts(user_id="default", category="Metric") + items = service.list_concepts(user_id="wiki-user", category="Metric") assert items assert all(item.category == "Metric" for item in items) @@ -67,7 +67,7 @@ def test_wiki_concept_service_filters_by_category(): def test_wiki_concept_service_resolves_grounded_concepts_for_query(): service = WikiConceptService(_FakeWikiConceptStore()) - items = service.resolve_concepts(user_id="default", query="rag latency") + items = service.resolve_concepts(user_id="wiki-user", query="rag latency") assert items top = items[0] diff --git a/tests/unit/test_wiki_concept_store.py b/tests/unit/test_wiki_concept_store.py index d85e6e46..e4f44c33 100644 --- a/tests/unit/test_wiki_concept_store.py +++ b/tests/unit/test_wiki_concept_store.py @@ -8,6 +8,7 @@ def test_wiki_concept_store_loads_papers_and_tracks(tmp_path: Path): + user_id = "wiki-user" db_url = f"sqlite:///{tmp_path / 'wiki-grounding.db'}" paper_store = PaperStore(db_url=db_url) research_store = SqlAlchemyResearchStore(db_url=db_url) @@ -33,7 +34,7 @@ def test_wiki_concept_store_loads_papers_and_tracks(tmp_path: Path): } ) track = research_store.create_track( - user_id="default", + user_id=user_id, name="LLM Agents", description="Track the architecture and alignment stack for agents.", keywords=["transformer", "agents"], @@ -41,14 +42,14 @@ def test_wiki_concept_store_loads_papers_and_tracks(tmp_path: Path): activate=True, ) research_store.add_paper_feedback( - user_id="default", + user_id=user_id, track_id=int(track["id"]), paper_id=str(saved_paper["id"]), action="save", ) store = WikiConceptStore(db_url=db_url) - snapshot = store.load_grounding_snapshot(user_id="default") + snapshot = store.load_grounding_snapshot(user_id=user_id) assert len(snapshot["papers"]) == 1 assert snapshot["papers"][0]["title"] == "Attention Is All You Need" diff --git a/tests/unit/test_wiki_route.py b/tests/unit/test_wiki_route.py index 61bed84e..555108c6 100644 --- a/tests/unit/test_wiki_route.py +++ b/tests/unit/test_wiki_route.py @@ -5,6 +5,7 @@ from fastapi.testclient import TestClient from paperbot.api import main as api_main +from paperbot.api.auth import dependencies as auth_deps from paperbot.api.routes import wiki as wiki_route from paperbot.application.services.wiki_concept_service import WikiConceptService from paperbot.infrastructure.stores.paper_store import PaperStore @@ -13,6 +14,7 @@ def test_wiki_route_returns_grounded_concepts(tmp_path: Path, monkeypatch): + user_id = "wiki-user" db_url = f"sqlite:///{tmp_path / 'wiki-route.db'}" paper_store = PaperStore(db_url=db_url) research_store = SqlAlchemyResearchStore(db_url=db_url) @@ -27,7 +29,7 @@ def test_wiki_route_returns_grounded_concepts(tmp_path: Path, monkeypatch): } ) track = research_store.create_track( - user_id="default", + user_id=user_id, name="RAG Systems", description="Track retrieval-augmented generation and context routing papers.", keywords=["rag"], @@ -35,7 +37,7 @@ def test_wiki_route_returns_grounded_concepts(tmp_path: Path, monkeypatch): activate=True, ) research_store.add_paper_feedback( - user_id="default", + user_id=user_id, track_id=int(track["id"]), paper_id=str(saved_paper["id"]), action="save", @@ -47,8 +49,13 @@ def test_wiki_route_returns_grounded_concepts(tmp_path: Path, monkeypatch): WikiConceptService(WikiConceptStore(db_url=db_url)), ) - with TestClient(api_main.app) as client: - response = client.get("/api/wiki/concepts?q=rag") + app = api_main.app + app.dependency_overrides[auth_deps.get_required_user_id] = lambda: user_id + try: + with TestClient(app) as client: + response = client.get("/api/wiki/concepts?q=rag") + finally: + app.dependency_overrides.clear() assert response.status_code == 200 payload = response.json() @@ -59,15 +66,46 @@ def test_wiki_route_returns_grounded_concepts(tmp_path: Path, monkeypatch): assert rag_item["related_papers"] == ["Retrieval-Augmented Generation for Long Context QA"] -def test_wiki_route_rejects_cross_user_grounding(monkeypatch): +def test_wiki_route_uses_authenticated_user_context(monkeypatch, tmp_path: Path): + user_id = "wiki-auth-user" + db_url = f"sqlite:///{tmp_path / 'wiki-route-auth.db'}" + paper_store = PaperStore(db_url=db_url) + research_store = SqlAlchemyResearchStore(db_url=db_url) + saved_paper = paper_store.upsert_paper( + paper={ + "title": "Grounded RAG", + "abstract": "RAG systems grounded by user context.", + "keywords": ["rag"], + "citation_count": 7, + "year": 2026, + } + ) + track = research_store.create_track( + user_id=user_id, + name="Authenticated RAG", + keywords=["rag"], + activate=True, + ) + research_store.add_paper_feedback( + user_id=user_id, + track_id=int(track["id"]), + paper_id=str(saved_paper["id"]), + action="save", + ) monkeypatch.setattr( wiki_route, "_service", - WikiConceptService(WikiConceptStore()), + WikiConceptService(WikiConceptStore(db_url=db_url)), ) - with TestClient(api_main.app) as client: - response = client.get("/api/wiki/concepts?user_id=someone-else&q=rag") + app = api_main.app + app.dependency_overrides[auth_deps.get_required_user_id] = lambda: user_id + try: + with TestClient(app) as client: + response = client.get("/api/wiki/concepts?user_id=someone-else&q=rag") + finally: + app.dependency_overrides.clear() - assert response.status_code == 403 - assert "authenticated user context" in response.json()["detail"] + assert response.status_code == 200 + payload = response.json() + assert any(item["id"] == "rag" for item in payload["items"]) diff --git a/tests/unit/test_workflow_query_grounder.py b/tests/unit/test_workflow_query_grounder.py index 1f93cce6..b5c68ca8 100644 --- a/tests/unit/test_workflow_query_grounder.py +++ b/tests/unit/test_workflow_query_grounder.py @@ -38,7 +38,7 @@ def load_grounding_snapshot( def test_workflow_query_grounder_expands_short_concept_ids(): grounder = WorkflowQueryGrounder(WikiConceptService(_FakeWikiConceptStore())) - grounded = grounder.ground_query(user_id="default", query="rag latency") + grounded = grounder.ground_query(user_id="wiki-user", query="rag latency") assert grounded.original_query == "rag latency" assert grounded.canonical_query == "retrieval augmented generation latency" @@ -52,7 +52,7 @@ def test_workflow_query_grounder_expands_short_concept_ids(): def test_workflow_query_grounder_keeps_broader_aliases_without_overwriting_query(): grounder = WorkflowQueryGrounder(WikiConceptService(_FakeWikiConceptStore())) - grounded = grounder.ground_query(user_id="default", query="alignment roadmap") + grounded = grounder.ground_query(user_id="wiki-user", query="alignment roadmap") assert grounded.canonical_query == "alignment roadmap" assert grounded.search_queries == ["alignment roadmap"]