mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 17:00:23 +00:00
chore: complete v1.9 Frontmatter Rationalization & Library-Record Deprecation
Milestone v1.9 — 5 phases, 28 requirements, 188 tests passing. Key changes: - Frontmatter rationalization: slimmed formal note frontmatter (28→16 fields), created per-workspace paper-meta.json - Library-record deprecation: removed library_record_markdown(), sync no longer creates library-records - Workspace stabilization: unconditional workspace creation, fulltext bridge, discussion.py reads canonical index - Base view fix: removed ghost lifecycle fields, restored workflow flags, Literature/ folder filter - Plugin sync: version badge reads paperforge_version from index envelope Archived: .planning/milestones/v1.9-ROADMAP.md, .planning/milestones/v1.9-REQUIREMENTS.md
This commit is contained in:
parent
969a89f4af
commit
db042cd6dd
32 changed files with 2159 additions and 836 deletions
|
|
@ -1,5 +1,23 @@
|
|||
# Milestones: PaperForge Lite Release Hardening
|
||||
|
||||
## v1.9 Frontmatter Rationalization & Library-Record Deprecation (Shipped: 2026-05-07)
|
||||
|
||||
**Phases completed:** 5 phases, 28 requirements (all verified)
|
||||
**Tests:** 188 passing
|
||||
|
||||
**Key accomplishments:**
|
||||
|
||||
- Slimmed formal note frontmatter from 28 to 16 fields: identity (title/year/journal/first_author/zotero_key/domain/doi/pmid/collection_path/impact_factor/abstract/tags) + workflow (has_pdf/do_ocr/analyze/ocr_status/deep_reading_status) + pdf_path
|
||||
- Created `paper_meta.py` — per-workspace JSON for internal pipeline state (OCR jobs, health, maturity, version)
|
||||
- Removed `library_record_markdown()` and all library-record generation from sync — new users never see library-records
|
||||
- Unconditional workspace creation — new papers get workspace directories on first sync (no flat fallback)
|
||||
- Fulltext bridge from OCR output to workspace; discussion.py reads from canonical index
|
||||
- Base views restored to workflow-gate filters (has_pdf/do_ocr/analyze/ocr_status → do_ocr=true, analyze=true+ocr_status=done) pointing to Literature/
|
||||
- Version badge fixed to read paperforge_version from canonical index envelope
|
||||
- Doctor detects stale library-records directory and workspace integrity issues
|
||||
|
||||
---
|
||||
|
||||
## v1.7 Context-Aware Dashboard (Shipped: 2026-05-04)
|
||||
|
||||
**Phases completed:** 4 phases, 6 plans, 14 tasks
|
||||
|
|
|
|||
|
|
@ -2,17 +2,30 @@
|
|||
|
||||
## What This Is
|
||||
|
||||
PaperForge is a local-first Obsidian + Zotero literature asset manager. It turns Zotero exports, PDFs, OCR fulltext, figures, notes, and AI outputs into a structured, traceable, reusable research library. v1.7 adds context-aware dashboard routing with lifecycle/health/maturity visualization. v1.8 adds AI discussion recording and deep-reading dashboard integration.
|
||||
PaperForge is a local-first Obsidian + Zotero literature asset manager. It turns Zotero exports, PDFs, OCR fulltext, figures, notes, and AI outputs into a structured, traceable, reusable research library. v1.9 consolidated the fragmented tracking layers (library-records + formal notes) into a single per-workspace structure with slim frontmatter and paper-meta.json for internal state.
|
||||
|
||||
## Current Milestone: v1.8 AI Discussion & Deep-Reading Dashboard
|
||||
## Current State: v1.9 Shipped (2026-05-07)
|
||||
|
||||
**Next:** Planning v2.0 or continued v1.8 feature work.
|
||||
|
||||
## Completed Milestone: v1.9 Frontmatter Rationalization & Library-Record Deprecation
|
||||
|
||||
**Status:** COMPLETE (2026-05-07)
|
||||
**Archive:** `.planning/milestones/v1.9-ROADMAP.md`
|
||||
|
||||
**Delivered:**
|
||||
- Slimmed formal note frontmatter (28 → 16 fields); per-workspace paper-meta.json for internal state
|
||||
- Library-record generation removed; sync no longer creates library-records
|
||||
- Unconditional workspace creation on first sync; flat-to-workspace migration with fulltext bridge
|
||||
- Base views restored to workflow-gate filters (has_pdf/do_ocr/analyze/ocr_status), pointing to Literature/
|
||||
- Version badge fixed; lifecycle keys aligned; doctor workspace integrity + stale library-records checks
|
||||
- 188 tests passing, 0 failures
|
||||
|
||||
## In Progress: v1.8 AI Discussion & Deep-Reading Dashboard (paused)
|
||||
|
||||
**Goal:** Capture AI-paper discussions into structured ai/ records and extend the per-paper dashboard with deep-reading content and AI interaction history.
|
||||
|
||||
**Target features:**
|
||||
- AI discussion recorder producing `discussion.md` (human-readable Q&A) + `discussion.json` (dashboard-consumable) in each paper's workspace `ai/` directory.
|
||||
- Deep-reading dashboard mode: status bar, Pass 1 full-text summary, and AI Q&A history when viewing `deep-reading.md`.
|
||||
- "Jump to Deep Reading" button on the per-paper dashboard card.
|
||||
- Bug fixes: remove meaningless "ai" UI row; restore version number display.
|
||||
**Status:** Phases 31-35 partial — 2/6 phases complete. Paused to prioritize v1.9 structural foundation before surfacing more dashboard features.
|
||||
|
||||
## Completed Milestone: v1.7 Context-Aware Dashboard
|
||||
|
||||
|
|
@ -125,12 +138,25 @@ Researchers always know what papers they have, what state those papers are in, a
|
|||
|
||||
### Active
|
||||
|
||||
<!-- v1.9 shipped — all Active requirements moved to Validated below -->
|
||||
|
||||
- [ ] AI discussion recorder: `/pf-paper` and agent chats produce `discussion.md` (Q&A) + `discussion.json` (structured) in workspace `ai/`.
|
||||
- [ ] Deep-reading dashboard mode with status bar, Pass 1 summary, and AI Q&A history.
|
||||
- [ ] "Jump to Deep Reading" button on per-paper dashboard card.
|
||||
- [ ] Bug fix: remove meaningless "ai" row from plugin UI.
|
||||
- [ ] Bug fix: restore version number display in plugin.
|
||||
|
||||
### Validated (v1.9)
|
||||
|
||||
- ✓ Library-records directory eliminated: sync no longer creates `<control_dir>/library-records/`; new users never see it.
|
||||
- ✓ Upgrading users get lossless library-record → formal note frontmatter migration on first sync (via `_build_entry()` defaults + paper_meta.py).
|
||||
- ✓ Base views fixed: removed ghost lifecycle/maturity_level/next_step; restored has_pdf/do_ocr/analyze/ocr_status; folder filter points to Literature/.
|
||||
- ✓ Formal note frontmatter slimmed to identity fields + workflow flags + pdf_path; redundant fields removed.
|
||||
- ✓ Per-workspace paper-meta.json stores OCR backend data, derived state details, and debug fields.
|
||||
- ✓ Workspace folder construction: new papers create workspace on first sync (no flat-first-then-migrate); fulltext.md bridged from OCR output.
|
||||
- ✓ Path construction unified: discussion.py reads workspace paths from canonical index instead of reconstructing independently.
|
||||
- ✓ Plugin dashboard: version badge reads paperforge_version from index envelope; lifecycle keys aligned; CSS components verified.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Replacing Zotero or Better BibTeX — the project is built around them.
|
||||
|
|
@ -143,6 +169,7 @@ Researchers always know what papers they have, what state those papers are in, a
|
|||
- Cloud multi-user collaboration or hosted sync — this milestone remains local-first and single-user.
|
||||
- Plugin auto-update — deferred to when listed on Obsidian Community Plugins.
|
||||
- Plugin published to Obsidian Community Plugins — deferred until after v1.5 stabilizes the settings experience.
|
||||
- v1.8 dashboard features (deep-reading mode, Jump to Deep Reading, AI discussion recorder) — paused, not cancelled; will resume after v1.9 cleans the structural foundation.
|
||||
|
||||
## Context
|
||||
|
||||
|
|
@ -184,6 +211,15 @@ The v1.1 milestone was completed after a manual sandbox audit from `tests/sandbo
|
|||
|
||||
**v1.6 focus:** Consolidate the next layer of product evolution into one milestone instead of splitting it across several small releases. The emphasis is not on more one-off extraction buttons, but on durable asset state, asset health, maturity-guided workflow progression, and AI-ready context packaging.
|
||||
|
||||
**v1.9 focus:** The feature branch (milestone/v1.6-ai-ready-asset-foundation) accumulated 117 commits of v1.6-v1.8 work. Before merging to master, structural cleanup is needed: library-records (a v1.0 tracking layer) must be deprecated in favor of formal note frontmatter; Base views regressed in v1.7 (lost workflow flags, gained unwritten ghost fields); frontmatter grew to 47 unique fields across two note types. This milestone fixes the foundation so incremental PRs can land cleanly on master.
|
||||
|
||||
Key gaps identified on feature branch:
|
||||
- Base views declare lifecycle/maturity_level/next_step but NO .md writer exists for these fields — columns permanently empty
|
||||
- Base views lost has_pdf/do_ocr/analyze/ocr_status — users cannot batch-toggle workflow from Obsidian
|
||||
- _build_entry() declares fulltext_path in workspace but no code copies OCR output there — broken link
|
||||
- New papers go flat-first-then-migrate — should create workspace directly
|
||||
- discussion.py builds ai/ path independently instead of reading from canonical index
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Local-first:** Must work in a user's Obsidian vault without a daemon or cloud service.
|
||||
|
|
@ -214,6 +250,10 @@ The v1.1 milestone was completed after a manual sandbox audit from `tests/sandbo
|
|||
| Settings tab in Obsidian plugin as setup entry point | Eliminates terminal requirement for new users; plugin becomes single download artifact. CLI/Agent unchanged — plugin is a new UI surface | ✓ Implemented v1.5 |
|
||||
| Reposition PaperForge around literature assets, not one-off prompts | Long-term user value comes from clean, traceable, AI-ready libraries rather than hardcoded extraction buttons | — Active for v1.6 |
|
||||
| Keep plugin as thin shell over CLI and canonical index | Avoids duplicated business logic and configuration drift between JS and Python layers | — Active for v1.6 |
|
||||
| Deprecate library-records in favor of formal notes | Two tracking layers (library-records + formal notes) create divergence, double the frontmatter surface, and confuse users. Formal notes already carry most metadata; adding workflow flags makes them self-sufficient. | ✓ Integrated |
|
||||
| Separate Base views (workflow batch ops) from Dashboard (derived state viz) | lifecycle/maturity/next_step already have rich visualization in the Obsidian plugin dashboard; duplicating them in Base views as empty columns adds noise. Base views focus on user-actionable workflow gates. | ✓ Implemented |
|
||||
| Per-workspace paper-meta.json for internal state | Frontmatter is the user-facing surface; internal pipeline data (OCR jobs, health details, debug fields) belongs in a machine-readable JSON file. Keeps formal notes clean while preserving all state for tools. | ✓ Implemented |
|
||||
| Unconditional workspace creation on first sync | Flat-note fallback created confusion and required separate migration step. Always creating workspace dirs simplifies the architecture and eliminates the flat-first-then-migrate path. | ✓ Implemented |
|
||||
|
||||
## Research Lock
|
||||
|
||||
|
|
@ -227,6 +267,11 @@ Research has been frozen at milestone boundaries to avoid redundant re-research.
|
|||
- Cross-paper synthesis layer, concept/mechanism network, source-traceable, human-reviewable.
|
||||
- No re-research on "whether to build a wiki" or "first use case."
|
||||
|
||||
**v1.9 (Frontmatter Rationalization)** — no new research needed:
|
||||
- All pieces already exist on the feature branch: workspace migration, derived state, canonical index, Base generation, frontmatter writing.
|
||||
- This milestone is structural cleanup — rewire, remove, and fix existing code, not invent new capabilities.
|
||||
- Skip research for this milestone.
|
||||
|
||||
See: `.planning/research/MILESTONE-RESEARCH-LOCK.md` for the full lock definition.
|
||||
|
||||
## Evolution
|
||||
|
|
@ -247,4 +292,4 @@ This document evolves at phase transitions and milestone boundaries.
|
|||
4. Update Context with current state.
|
||||
|
||||
---
|
||||
*Last updated: 2026-05-06 after milestone v1.8 initialization*
|
||||
*Last updated: 2026-05-07 after v1.9 milestone completion*
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
# Requirements: PaperForge
|
||||
|
||||
**Defined:** 2026-05-06
|
||||
**Core Value:** Researchers always know what papers they have, what state those papers are in, and whether each paper is reliably usable by AI with traceable fulltext, figures, notes, and source links.
|
||||
|
||||
## v1.8 Requirements
|
||||
|
||||
### AI Discussion Recording
|
||||
|
||||
- [x] **AI-01**: `paperforge/worker/discussion.py` writes discussion.md (human-readable Q&A, `问题:` / `解答:` format, chronological sections) into paper workspace `ai/` directory.
|
||||
- [x] **AI-02**: `paperforge/worker/discussion.py` writes discussion.json (structured, sessions[] array with `schema_version`, `timestamp`, `qa_pairs[]` per session) into paper workspace `ai/` directory.
|
||||
- [x] **AI-03**: `/pf-paper` and `/pf-deep` agent sessions trigger discussion recorder at session completion, producing both files.
|
||||
|
||||
### Deep-Reading Dashboard
|
||||
|
||||
- [ ] **DEEP-01**: `_detectAndSwitch()` recognizes `deep-reading.md` as the `deep-reading` mode, checked BEFORE the `zotero_key` branch to prevent routing to per-paper mode.
|
||||
- [ ] **DEEP-02**: `_renderDeepReadingMode()` renders: status bar (figure-map, OCR state, Pass 1/2/3 completion), Pass 1 full-text summary card (extracted from deep-reading.md), and AI Q&A history card (from `discussion.json`).
|
||||
- [ ] **DEEP-03**: All four empty-state conditions render user-facing messages ("暂无" or equivalent) rather than errors: (a) missing discussion.json, (b) empty sessions, (c) missing Pass 1 content, (d) deep-reading.md not found.
|
||||
|
||||
### Navigation & Polish
|
||||
|
||||
- [ ] **NAV-01**: Per-paper dashboard card has a "Jump to Deep Reading" button that opens `deep-reading.md` via `openLinkText()`, verifying file existence with `getAbstractFileByPath()` before navigating.
|
||||
- [ ] **NAV-02**: Plugin version number is read and displayed (from `paperforge/__init__.py` or `manifest.json` build-time embedding).
|
||||
- [ ] **NAV-03**: Meaningless "ai" row is removed from plugin rendering.
|
||||
|
||||
### Integration Verification
|
||||
|
||||
- [ ] **INTEG-01**: End-to-end test: `/pf-paper` → discussion files created → dashboard shows AI Q&A history on deep-reading.md open. Windows CJK encoding verified; no `btoa()` on Chinese paths.
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Deferred to future release. Tracked but not in current roadmap.
|
||||
|
||||
### AI Discussion Recording
|
||||
|
||||
- **AI-04**: Threaded discussion format (nested Q&A threads within sessions, instead of flat qa_pairs[]).
|
||||
- **AI-05**: Discussion search/retrieval across all papers by keyword or date range.
|
||||
|
||||
### Deep-Reading Dashboard
|
||||
|
||||
- **DEEP-04**: Inline editing of Pass 1 summary from dashboard (two-way sync with deep-reading.md).
|
||||
- **DEEP-05**: Figure thumbnail previews in dashboard status bar (embeds from figure-map.json images).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Auto-recording every agent interaction | Recording is voluntary/session-end only — agents must explicitly trigger. Auto-capture would produce noise and violate user privacy expectations. |
|
||||
| Dashboard replacing deep-reading.md | Dashboard is an index INTO the deep-reading note, not a replacement. Full content lives in .md file. |
|
||||
| Real-time dashboard syncing during agent sessions | Dashboard refreshes on active-leaf-change only. Polling or websocket sync would violate thin-shell constraint. |
|
||||
| Discussion analytics/categorization | V1.8 records raw Q&A. Tagging, categorization, and cross-paper analysis deferred to v2. |
|
||||
| Figure preview carousel in dashboard | Deferred to v2. Status bar shows figure count and map existence only. |
|
||||
|
||||
## Traceability
|
||||
|
||||
Which phases cover which requirements. Updated during roadmap creation.
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| AI-01 | Phase 35 | Complete |
|
||||
| AI-02 | Phase 35 | Complete |
|
||||
| AI-03 | Phase 35 | Complete |
|
||||
| DEEP-01 | Phase 32 | Pending |
|
||||
| DEEP-02 | Phase 33 | Pending |
|
||||
| DEEP-03 | Phase 33 | Pending |
|
||||
| NAV-01 | Phase 34 | Pending |
|
||||
| NAV-02 | Phase 31 | Pending |
|
||||
| NAV-03 | Phase 31 | Pending |
|
||||
| INTEG-01 | Phase 36 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1.8 requirements: 10 total
|
||||
- Mapped to phases: 10 ✓
|
||||
- Unmapped: 0
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-05-06*
|
||||
*Last updated: 2026-05-06 — traceability updated during roadmap creation*
|
||||
|
|
@ -1,157 +1,88 @@
|
|||
# Roadmap: PaperForge
|
||||
|
||||
**Current milestone:** v1.8 AI Discussion & Deep-Reading Dashboard — Planned
|
||||
**Phase numbering:** Continuous. v1.7 ended at Phase 30. v1.8 begins at Phase 31.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-23)
|
||||
- ✅ **v1.1 Sandbox Onboarding** — Phases 6-8 (shipped 2026-04-24)
|
||||
- ✅ **v1.2 Systematization & Cohesion** — Phases 9-10 (shipped 2026-04-24)
|
||||
- ✅ **v1.3 Path Normalization & Architecture Hardening** — Phases 11-12 (shipped 2026-04-24)
|
||||
- ✅ **v1.4 Code Health & UX Hardening** — Phases 13-19 (shipped 2026-04-28)
|
||||
- ✅ **v1.5 Obsidian Plugin Setup Integration** — Phases 20-21 (shipped 2026-04-29)
|
||||
- ✅ **v1.6 AI-Ready Literature Asset Foundation** — Phases 22-26 (shipped 2026-05-04)
|
||||
- ✅ **v1.7 Context-Aware Dashboard** — Phases 27-30 (shipped 2026-05-04)
|
||||
- 📋 **v1.8 AI Discussion & Deep-Reading Dashboard** — Phases 31-36 (planned)
|
||||
|
||||
*Archive: `.planning/milestones/`*
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.6 AI-Ready Literature Asset Foundation (Phases 22-26) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 22: Configuration Truth & Compatibility (3/3)
|
||||
- [x] Phase 23: Canonical Asset Index & Safe Rebuilds (3/3)
|
||||
- [x] Phase 24: Derived Lifecycle, Health & Maturity (2/2)
|
||||
- [x] Phase 25: Surface Convergence, Doctor & Repair (3/3)
|
||||
- [x] Phase 26: Traceable AI Context Packs (3/3)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.7 Context-Aware Dashboard (Phases 27-30) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 27: Component Library (2/2)
|
||||
- [x] Phase 28: Dashboard Shell & Context Detection (2/2)
|
||||
- [x] Phase 29: Per-Paper View (1/1)
|
||||
- [x] Phase 30: Collection View (1/1)
|
||||
|
||||
</details>
|
||||
|
||||
### 📋 v1.8 AI Discussion & Deep-Reading Dashboard (Planned)
|
||||
|
||||
**Milestone Goal:** Capture AI-paper discussions into structured ai/ records and extend the per-paper dashboard with deep-reading content and AI interaction history.
|
||||
|
||||
- [ ] **Phase 31: Bug Fixes** — Restore version display; remove meaningless "ai" UI row
|
||||
- [ ] **Phase 32: Deep-Reading Mode Detection** — Plugin routes deep-reading.md to dedicated dashboard mode
|
||||
- [ ] **Phase 33: Deep-Reading Dashboard Rendering** — Status bar, Pass 1 summary, empty-state AI Q&A card
|
||||
- [x] **Phase 34: Jump to Deep Reading Button** — Per-paper dashboard card links to deep-reading.md (completed 2026-05-06)
|
||||
- [x] **Phase 35: AI Discussion Recorder** — Python module writes discussion.md + discussion.json into ai/
(completed 2026-05-06)
|
||||
- [ ] **Phase 36: Integration Verification** — End-to-end pipeline verified with CJK encoding and vault.adapter.read
|
||||
|
||||
---
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 31: Bug Fixes
|
||||
**Goal**: Plugin version number is displayed correctly and the meaningless "ai" row is removed from all dashboard views.
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: NAV-02, NAV-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Plugin header shows the actual PaperForge version (e.g., "v1.8.0") read from package metadata, not a placeholder like "v—"
|
||||
2. Dashboard rendering excludes the empty "ai" row across all three render modes (global, paper, collection)
|
||||
3. git grep confirms zero references to the removed "ai" row rendering logic in plugin JS and CSS
|
||||
4. The Python→JS version bridge (paperforge_version in formal-library.json envelope) is established for future consumption
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 32: Deep-Reading Mode Detection
|
||||
**Goal**: Plugin detects deep-reading.md files by filename (before zotero_key frontmatter check) and routes to a dedicated `deep-reading` dashboard mode without oscillation.
|
||||
**Depends on**: Phase 31
|
||||
**Requirements**: DEEP-01
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Opening any deep-reading.md file in the vault switches the dashboard to deep-reading mode, validating the parent directory pattern ({8-char key} - {title})
|
||||
2. A deep-reading.md file carrying zotero_key frontmatter routes to deep-reading mode, NOT per-paper mode — filename check precedes frontmatter check
|
||||
3. Switching away from deep-reading.md (e.g., opening a formal note) transitions to the correct mode without visible flicker or mode oscillation
|
||||
4. `_resolveModeForFile()` is extracted as a pure function with identity guard (same mode AND same file path = no-op) to prevent active-leaf-change double-fire
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 33: Deep-Reading Dashboard Rendering
|
||||
**Goal**: Deep-reading dashboard renders reading progress status, Pass 1 full-text summary, and graceful empty states for all four AI discussion data conditions.
|
||||
**Depends on**: Phase 32
|
||||
**Requirements**: DEEP-02, DEEP-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Deep-reading dashboard displays a status bar with Pass 1/2/3 completion indicators and OCR/health/maturity badges
|
||||
2. Pass 1 full-text summary card renders content extracted from deep-reading.md markers (e.g., **一句话总览**), supporting regex fallback for formatting variations
|
||||
3. AI Q&A history section shows a descriptive Chinese placeholder ("暂无讨论记录") when no discussion.json exists, and "暂无问答内容" when sessions are empty
|
||||
4. All four empty-state conditions render user-facing messages without JavaScript errors: (a) missing discussion.json, (b) empty sessions array, (c) missing Pass 1 content, (d) deep-reading.md file not found
|
||||
5. CSS additions are scoped under `.paperforge-mode-deepreading` wrapper class with `paperforge-deepreading-*` component prefixes to avoid namespace collisions
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 34: Jump to Deep Reading Button
|
||||
**Goal**: Per-paper dashboard card provides a one-click contextual button to open the associated deep-reading.md file, with file-existence verification before navigation.
|
||||
**Depends on**: Phase 32, Phase 33
|
||||
**Requirements**: NAV-01
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Per-paper dashboard card renders a "跳转到精读" button when the paper's deep_reading_path exists and deep_reading_status is 'done'
|
||||
2. Clicking the button opens deep-reading.md via openLinkText() in the active leaf, which triggers Phase 32 mode detection and renders the Phase 33 dashboard
|
||||
3. The button is hidden (not just disabled) when the paper has no deep_reading_path or its status is not 'done'
|
||||
4. If deep-reading.md is missing from disk despite the index claiming it exists, app.vault.adapter.getAbstractFileByPath() returns null and a clear Obsidian Notice informs the user instead of crashing
|
||||
**Plans**: 1 plan
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 35: AI Discussion Recorder
|
||||
**Goal**: Agent sessions (`/pf-paper`, `/pf-deep`) produce structured discussion records — JSON (canonical) and Markdown (human-readable) — in each paper's workspace `ai/` directory with atomic writes and UTF-8 encoding.
|
||||
**Depends on**: Nothing (standalone Python module; can execute in parallel with Phases 32-34)
|
||||
**Requirements**: AI-01, AI-02, AI-03
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Running `/pf-paper` or `/pf-deep` for a paper creates `ai/discussion.json` in that paper's workspace directory, containing `schema_version: "1"`, `paper_key`, and a `sessions[]` array with `session_id`, `agent`, `started`, `model`, and `qa_pairs[]` (each with `question`, `answer`, `source`, `timestamp`)
|
||||
2. The same session produces `ai/discussion.md` with human-readable `问题:` / `解答:` format, chronological `##` session headings, and timestamp metadata
|
||||
3. Re-running an agent session for the same paper appends a new session entry rather than overwriting previous discussions, using atomic read-modify-write via `tempfile.NamedTemporaryFile` + `os.replace()`
|
||||
4. Discussion files are written with explicit `encoding='utf-8'` and `newline='\n'` and are readable in Obsidian without mojibake on Windows CJK systems
|
||||
5. `discussion.py` uses only Python stdlib (`json`, `pathlib`, `datetime`, `tempfile`, `os`) — zero new dependencies
|
||||
**Plans**: 1 plan
|
||||
|
||||
Plans:
|
||||
- [x] 35-01-PLAN.md — Python module (discussion.py) + pf-paper.md integration
|
||||
|
||||
### Phase 36: Integration Verification
|
||||
**Goal**: End-to-end pipeline verified: agent session writes discussion files → dashboard reads and renders AI Q&A history via vault.adapter.read(). Windows CJK encoding and no-btoa() constraints validated.
|
||||
**Depends on**: Phase 33, Phase 35
|
||||
**Requirements**: INTEG-01
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Full flow works end-to-end: run `/pf-paper` → verify discussion.json and discussion.md exist in `ai/` → open deep-reading.md → dashboard AI Q&A History section renders Q&A pairs from discussion.json (last 3 pairs across all sessions, most recent first)
|
||||
2. Chinese content (questions, answers, paper titles) survives the Python→disk→JS pipeline without encoding corruption — verified on Windows with CJK locale
|
||||
3. Zero `btoa()` or `atob()` calls exist in any path construction involving paper titles or discussion file paths — uses `Buffer.from(str, 'utf-8').toString('base64')` if base64 needed
|
||||
4. Dashboard discussion.json read uses `app.vault.adapter.read()`, not `fs.readFileSync()`, and the vault modify event listener includes `discussion.json` paths to trigger dashboard refresh when Python appends new Q&A
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
---
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans | Status | Completed |
|
||||
|-------|-------|--------|-----------|
|
||||
| 27. Component Library | 2/2 | Complete | 2026-05-04 |
|
||||
| 28. Dashboard Shell & Context Detection | 2/2 | Complete | 2026-05-04 |
|
||||
| 29. Per-Paper View | 1/1 | Complete | 2026-05-04 |
|
||||
| 30. Collection View | 1/1 | Complete | 2026-05-04 |
|
||||
| 31. Bug Fixes | 0/TBD | Not started | - |
|
||||
| 32. Deep-Reading Mode Detection | 0/TBD | Not started | - |
|
||||
| 33. Deep-Reading Dashboard Rendering | 0/TBD | Not started | - |
|
||||
| 34. Jump to Deep Reading Button | 1/1 | Complete | 2026-05-06 |
|
||||
| 35. AI Discussion Recorder | 1/1 | Complete | 2026-05-06 |
|
||||
| 36. Integration Verification | 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
*Roadmap updated: 2026-05-06 — v1.8 milestone planned*
|
||||
# Roadmap: PaperForge
|
||||
|
||||
**Current milestone:** v1.9 Frontmatter Rationalization & Library-Record Deprecation — Planned
|
||||
**Phase numbering:** Continuous. v1.8 partial ended at Phase 36. v1.9 begins at Phase 37.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-23)
|
||||
- ✅ **v1.1 Sandbox Onboarding** — Phases 6-8 (shipped 2026-04-24)
|
||||
- ✅ **v1.2 Systematization & Cohesion** — Phases 9-10 (shipped 2026-04-24)
|
||||
- ✅ **v1.3 Path Normalization & Architecture Hardening** — Phases 11-12 (shipped 2026-04-24)
|
||||
- ✅ **v1.4 Code Health & UX Hardening** — Phases 13-19 (shipped 2026-04-28)
|
||||
- ✅ **v1.5 Obsidian Plugin Setup Integration** — Phases 20-21 (shipped 2026-04-29)
|
||||
- ✅ **v1.6 AI-Ready Literature Asset Foundation** — Phases 22-26 (shipped 2026-05-04)
|
||||
- ✅ **v1.7 Context-Aware Dashboard** — Phases 27-30 (shipped 2026-05-04)
|
||||
- 📋 **v1.8 AI Discussion & Deep-Reading Dashboard** — Phases 31-36 (partial — 2/6 phases complete, paused for v1.9)
|
||||
- ✅ **v1.9 Frontmatter Rationalization & Library-Record Deprecation** — Phases 37-41 (shipped 2026-05-07)
|
||||
|
||||
*Archive: `.planning/milestones/`*
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.6 AI-Ready Literature Asset Foundation (Phases 22-26) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 22: Configuration Truth & Compatibility (3/3)
|
||||
- [x] Phase 23: Canonical Asset Index & Safe Rebuilds (3/3)
|
||||
- [x] Phase 24: Derived Lifecycle, Health & Maturity (2/2)
|
||||
- [x] Phase 25: Surface Convergence, Doctor & Repair (3/3)
|
||||
- [x] Phase 26: Traceable AI Context Packs (3/3)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.7 Context-Aware Dashboard (Phases 27-30) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 27: Component Library (2/2)
|
||||
- [x] Phase 28: Dashboard Shell & Context Detection (2/2)
|
||||
- [x] Phase 29: Per-Paper View (1/1)
|
||||
- [x] Phase 30: Collection View (1/1)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 v1.8 AI Discussion & Deep-Reading Dashboard (Phases 31-36) — PAUSED</summary>
|
||||
|
||||
- [ ] Phase 31: Bug Fixes — Restore version display; remove meaningless "ai" UI row
|
||||
- [ ] Phase 32: Deep-Reading Mode Detection — Plugin routes deep-reading.md to dedicated dashboard mode
|
||||
- [ ] Phase 33: Deep-Reading Dashboard Rendering — Status bar, Pass 1 summary, empty-state AI Q&A card
|
||||
- [x] Phase 34: Jump to Deep Reading Button — Per-paper dashboard card links to deep-reading.md (completed 2026-05-06)
|
||||
- [x] Phase 35: AI Discussion Recorder — Python module writes discussion.md + discussion.json into ai/ (completed 2026-05-06)
|
||||
- [ ] Phase 36: Integration Verification — End-to-end pipeline verified with CJK encoding and vault.adapter.read
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.9 Frontmatter Rationalization & Library-Record Deprecation (Phases 37-41) — SHIPPED 2026-05-07</summary>
|
||||
|
||||
- [x] Phase 37: Frontmatter Rationalization (1 plan)
|
||||
- [x] Phase 38: Workspace Stabilization (1 plan)
|
||||
- [x] Phase 39: Base View Fix
|
||||
- [x] Phase 40: Library-Record Deprecation
|
||||
- [x] Phase 41: Plugin Dashboard Sync
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 34. Jump to Deep Reading Button | 1/1 | Complete | 2026-05-06 |
|
||||
| 35. AI Discussion Recorder | 1/1 | Complete | 2026-05-06 |
|
||||
| 37. Frontmatter Rationalization | 1/1 | Complete | 2026-05-07 |
|
||||
| 38. Workspace Stabilization | 1/1 | Complete | 2026-05-07 |
|
||||
| 39. Base View Fix | — | Complete | 2026-05-07 |
|
||||
| 40. Library-Record Deprecation | — | Complete | 2026-05-07 |
|
||||
| 41. Plugin Dashboard Sync | — | Complete | 2026-05-07 |
|
||||
|
||||
---
|
||||
|
||||
*Roadmap updated: 2026-05-07 — v1.9 milestone planned*
|
||||
|
|
|
|||
|
|
@ -1,30 +1,36 @@
|
|||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.8
|
||||
milestone_name: AI Discussion & Deep-Reading Dashboard
|
||||
status: Phase complete — ready for verification
|
||||
stopped_at: Completed 35-01-PLAN.md (AI Discussion Recorder)
|
||||
last_updated: "2026-05-06T15:30:34.254Z"
|
||||
milestone: v1.9
|
||||
milestone_name: Frontmatter Rationalization & Library-Record Deprecation
|
||||
status: planning
|
||||
stopped_at: Roadmap creation complete — Phase 37 ready for `/gsd-plan-phase 37`
|
||||
last_updated: "2026-05-07T02:06:56.248Z"
|
||||
last_activity: 2026-05-07
|
||||
progress:
|
||||
total_phases: 6
|
||||
total_phases: 5
|
||||
completed_phases: 2
|
||||
total_plans: 5
|
||||
completed_plans: 2
|
||||
total_plans: 2
|
||||
completed_plans: 5
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-05-06)
|
||||
See: .planning/PROJECT.md (updated 2026-05-07)
|
||||
|
||||
**Core value:** Researchers always know what papers have, what state those papers are in, and whether each paper is reliably usable by AI with traceable fulltext, figures, notes, and source links.
|
||||
**Current focus:** Phase 35 — ai-discussion-recorder
|
||||
**Core value:** Researchers always know what papers they have, what state those papers are in, and whether each paper is reliably usable by AI with traceable fulltext, figures, notes, and source links.
|
||||
**Current focus:** Milestone v1.9 — Phase 37 ready to plan
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 35 (ai-discussion-recorder) — EXECUTING
|
||||
Plan: 1 of 1
|
||||
Phase: 37 of 41 (Frontmatter Rationalization)
|
||||
Plan: None yet
|
||||
Status: Ready to plan
|
||||
Last activity: 2026-05-07
|
||||
|
||||
Progress: [░░░░░░░░░░] 0% (0/5 phases complete)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
|
|
@ -33,6 +39,11 @@ Plan: 1 of 1
|
|||
- Total plans completed: 42 (across v1.0-v1.8)
|
||||
- Average duration: Not yet tracked consistently
|
||||
|
||||
**Recent Trend:**
|
||||
|
||||
- Last milestone (v1.7): 4 phases, 6 plans, ~3 days
|
||||
- Current milestone: 5 phases planned, TBD plans
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
|
@ -40,19 +51,11 @@ Plan: 1 of 1
|
|||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
|
||||
- v1.8 roadmap: 6 phases (31-36) covering 10 requirements across bug fixes, mode detection, dashboard rendering, navigation, AI recording, and integration.
|
||||
- discussion.json uses sessions-based schema (sessions[] → qa_pairs[]) with schema_version "1" envelope from day one.
|
||||
- Plugin reads discussion.json via app.vault.adapter.read() (NOT fs.readFileSync) because it lives in vault-internal paper workspace.
|
||||
- Mode detection checks deep-reading.md filename BEFORE zotero_key frontmatter to prevent per-paper mode hijacking.
|
||||
- Phase 35 (Python) runs parallel to Phases 32-34 (JS) — no runtime dependency.
|
||||
- [Phase 31] paperforge_version added to formal-library.json envelope for version display in plugin header.
|
||||
- [Phase 31] "AI Ready" lifecycle stage removed from plugin dashboard (unreachable key mismatch: Python returns ai_context_ready, JS had ai_ready).
|
||||
- [Phase 31] Lifecycle stage keys in plugin aligned with Python compute values: deep_read_done not deep_read.
|
||||
- [Phase 31] Collection mode lifecycle thresholds fixed to match actual lifecycle values instead of mismatched keys.
|
||||
- [Phase 33] _renderDeepReadingMode() is async — reads deep-reading.md and discussion.json via vault.read(). Contains modeGuard for race condition safety.
|
||||
- [Phase 33] Pass 1 extraction uses marker priority: **一句话总览** → ## Pass 1 → **文章摘要**, cuts at next major section break.
|
||||
- [Phase 33] AI Q&A: sessions-based collapsible groups, dialog bubble format (question/answer different colors), default collapsed.
|
||||
- [Phase 35-ai-discussion-recorder]: Only /pf-paper records discussions; /pf-deep explicitly excluded per D-05
|
||||
- **Phase 37 (FM):** Frontmatter must slim first — everything else depends on knowing the final frontmatter field list. Workspace path fields and OCR infra fields move to paper-meta.json; formal notes only carry identity + workflow flags + pdf_path.
|
||||
- **Phase 38 (WS):** Workspace creation and fulltext bridging are path-construction fixes independent of the UI surface. Can follow immediately after FM since the data shape is settled.
|
||||
- **Phase 39 (BASE):** Base views depend on Phase 37 — they need to know which fields exist in frontmatter before declaring properties. Folder filter repoints from LiteratureControl/ to Literature/.
|
||||
- **Phase 40 (LRD):** Library-record removal depends on Base views pointing to Literature/ first — removing library-records before Base views are fixed breaks the workflow surface.
|
||||
- **Phase 41 (PLG):** Plugin verification comes last because it reads from the canonical index produced by FM+WS. Version badge fix (PLG-04) and lifecycle key alignment (PLG-05) are technically independent but grouped for cohesive verification.
|
||||
|
||||
### Pending Todos
|
||||
|
||||
|
|
@ -60,15 +63,14 @@ None yet.
|
|||
|
||||
### Blockers/Concerns
|
||||
|
||||
- Agent integration surface for discussion recording: exact callback/handoff protocol for `/pf-paper` and `/pf-deep` completion needs implementation-level confirmation during Phase 35 planning.
|
||||
- [Phase 33 resolved] Deep-reading.md content parsing: marker-based extraction with regex fallback now implemented. Content includes `**一句话总览**` paragraph + `###` sub-sections.
|
||||
- [Phase 32 resolved] active-leaf-change double-fire — mitigated by `_currentMode + _currentFilePath` identity guard in `_switchMode()`.
|
||||
- [Phase 32 resolved] Mode detection — `_resolveModeForFile()` pure function checks deep-reading.md filename + parent directory pattern BEFORE zotero_key frontmatter.
|
||||
- [Phase 31 resolved] Version display bug fixed — paperforge_version now flows through formal-library.json envelope. Version shown matches __init__.py version (currently 1.4.15).
|
||||
- [Phase 31 resolved] "AI Ready" row removed from per-paper dashboard lifecycle stepper and bar chart.
|
||||
- **v1.8 partial state:** Phases 34-35 completed on the feature branch; Phases 31-33, 36 incomplete. v1.9 structural cleanup must not regress the v1.8 deliverables (deep-reading mode detection, dashboard rendering, Jump to Deep Reading button, AI discussion recorder).
|
||||
- **Feature branch divergence:** The milestone/v1.6-ai-ready-asset-foundation branch has 117 commits. v1.9 work must reconcile against master.
|
||||
- **Reference vault ground truth:** Plugin dashboard behavior in `D:\L\Med\Research_LitControl_Sandbox` is authoritative — deviations in the current branch must be intentional and documented, not accidental regressions.
|
||||
- **fulltext_path gap:** Workspace entries declare the path but no existing code copies OCR output there — this is a known hole that WS-02 must fill.
|
||||
- **Legacy upgrade path:** Users with old flat notes need lossless migration of both frontmatter (LRD-02) and workspace structure (WS-04).
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-06T15:30:34.251Z
|
||||
Stopped at: Completed 35-01-PLAN.md (AI Discussion Recorder)
|
||||
Last session: 2026-05-07 00:19
|
||||
Stopped at: Roadmap creation complete — Phase 37 ready for `/gsd-plan-phase 37`
|
||||
Resume file: None
|
||||
|
|
|
|||
154
.planning/milestones/v1.9-REQUIREMENTS.md
Normal file
154
.planning/milestones/v1.9-REQUIREMENTS.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Requirements Archive: v1.9 Frontmatter Rationalization & Library-Record Deprecation
|
||||
|
||||
**Archived:** 2026-05-07
|
||||
**Status:** SHIPPED
|
||||
|
||||
For current requirements, see `.planning/REQUIREMENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
# Requirements: PaperForge
|
||||
|
||||
**Defined:** 2026-05-07
|
||||
**Core Value:** Researchers always know what papers they have, what state those papers are in, and whether each paper is reliably usable by AI with traceable fulltext, figures, notes, and source links.
|
||||
|
||||
## v1.9 Requirements
|
||||
|
||||
Requirements for milestone v1.9: Frontmatter Rationalization & Library-Record Deprecation.
|
||||
|
||||
### Library-Record Deprecation
|
||||
|
||||
- [ ] **LRD-01**: New users (no existing library-records) never see `<control_dir>/library-records/` created — sync skips library-record generation entirely when no legacy records exist.
|
||||
- [ ] **LRD-02**: Upgrading users get lossless migration: all frontmatter fields from existing library-records that are needed by the formal note workflow (has_pdf, do_ocr, analyze, ocr_status, deep_reading_status, recommend_analyze) are merged into corresponding formal notes.
|
||||
- [ ] **LRD-03**: After migration, `paperforge sync` no longer creates new library-record files for any papers (neither new nor existing).
|
||||
- [ ] **LRD-04**: Orphaned-record cleanup logic in `run_index_refresh()` is updated to work with formal notes instead of library-records.
|
||||
- [ ] **LRD-05**: `paperforge doctor` detects stale library-records directory and recommends user action (manual delete or ignore).
|
||||
|
||||
### Base View Fix
|
||||
|
||||
- [ ] **BASE-01**: Base views no longer declare `lifecycle`, `maturity_level`, or `next_step` as properties — these are dashboard-owned and never written to .md frontmatter.
|
||||
- [ ] **BASE-02**: Base views restore `has_pdf`, `do_ocr`, `analyze`, and `ocr_status` as declared properties with correct display names.
|
||||
- [ ] **BASE-03**: Base view folder filter is updated from `<control_dir>/<domain>` to `<literature_dir>/<domain>` (formal notes directory).
|
||||
- [ ] **BASE-04**: All 8 workflow views (控制面板, 推荐分析, 待OCR, OCR完成, 待深度阅读, 深度阅读完成, 正式卡片, 全记录) use the same filter/sort logic as the current master-branch Base views.
|
||||
- [ ] **BASE-05**: Base generation (`base_views.py`) regenerates all .base files on next `paperforge sync` with the corrected properties and folder filters.
|
||||
|
||||
### Frontmatter Rationalization
|
||||
|
||||
- [ ] **FM-01**: Formal note frontmatter excludes workspace path fields (`paper_root`, `main_note_path`, `fulltext_path`, `deep_reading_path`, `ai_path`) — these are deterministically derivable from `zotero_key + title + domain`.
|
||||
- [ ] **FM-02**: Formal note frontmatter excludes OCR infrastructure fields (`ocr_job_id`, `ocr_md_path`, `ocr_json_path`) — these are internal pipeline data.
|
||||
- [ ] **FM-03**: Formal note frontmatter retains workflow flags needed by Base views: `has_pdf`, `do_ocr`, `analyze`, `ocr_status`, `deep_reading_status`.
|
||||
- [ ] **FM-04**: Formal note frontmatter retains core identity fields: `title`, `year`, `journal`, `first_author` (not full `authors` list), `doi`, `pmid`, `zotero_key`, `domain`, `abstract`, `tags`.
|
||||
- [ ] **FM-05**: Per-workspace `paper-meta.json` is created in each paper's workspace directory, storing `ocr_job_id`, `ocr_md_path`, `ocr_json_path`, `health` dict, `maturity` breakdown, `paperforge_version`, and a `migrated_from` field (when upgrading from library-records).
|
||||
- [ ] **FM-06**: `_build_entry()` writes the slimmed frontmatter fields into formal notes; the full internal state (including workspace paths, OCR details, derived state) remains in the canonical index and paper-meta.json.
|
||||
- [ ] **FM-07**: `library_record_markdown()` is removed or deprecated — no new library-record .md files are generated.
|
||||
|
||||
### Plugin Dashboard Sync
|
||||
|
||||
- [ ] **PLG-01**: Plugin `_fetchStats()` and all dashboard render methods continue to work after index entry field changes — field references in main.js are verified against the final `_build_entry()` output.
|
||||
- [ ] **PLG-02**: Plugin Quick Action buttons (Open Fulltext, Jump to Deep Reading, Copy Context) resolve workspace paths correctly after workspace stabilization (WS-01, WS-02).
|
||||
- [ ] **PLG-03**: Plugin dashboard lifecycle stepper, health matrix, maturity gauge, and bar chart read from canonical index fields without regressions after frontmatter rationalization.
|
||||
- [ ] **PLG-04**: Version badge displays correct `paperforge_version` from canonical index envelope (`index.paperforge_version`), not from stale cached stats.
|
||||
- [ ] **PLG-05**: Plugin dashboard lifecycle key alignment verified: all JS code uses `deep_read_done`/`ai_context_ready` matching Python `compute_lifecycle()` output.
|
||||
- [ ] **PLG-06**: Plugin CSS includes all dashboard component styles (loading skeleton, metric cards, lifecycle stepper, health matrix, maturity gauge, bar chart, per-paper view, collection view, deep-reading mode).
|
||||
|
||||
### Workspace Stabilization
|
||||
|
||||
- [ ] **WS-01**: On first sync of a new paper, `_build_entry()` creates the workspace directory and writes `main_note_path` directly (no flat-first-then-migrate path).
|
||||
- [ ] **WS-02**: `fulltext_path` bridge: after OCR completes, the fulltext content at `<system_dir>/PaperForge/ocr/<key>/fulltext.md` is copied or linked to the workspace `fulltext.md` path declared in the canonical index entry.
|
||||
- [ ] **WS-03**: `discussion.py` reads the workspace `ai_path` from the canonical index entry (via `_build_ai_dir()`) instead of independently reconstructing the path.
|
||||
- [ ] **WS-04**: `migrate_to_workspace()` handles the fulltext bridging gap during migration: when migrating old flat notes, copies OCR output to workspace `fulltext.md` if available.
|
||||
- [ ] **WS-05**: `paperforge doctor` validates workspace integrity: checks that workspace dirs exist for indexed papers, that fulltext.md exists in workspace when ocr_status=done, and reports gaps.
|
||||
|
||||
## Future Requirements
|
||||
|
||||
Deferred to future milestone.
|
||||
|
||||
### v1.8 Dashboard Features (paused)
|
||||
|
||||
- **DASH-01**: Deep-reading dashboard mode with status bar, Pass 1 summary, and AI Q&A history.
|
||||
- **DASH-02**: "Jump to Deep Reading" button on per-paper dashboard card.
|
||||
- **DISC-01**: AI discussion recorder: `/pf-paper` and agent chats produce `discussion.md` + `discussion.json` in workspace `ai/`.
|
||||
- **BUG-01**: Remove meaningless "ai" row from plugin UI.
|
||||
- **BUG-02**: Restore version number display in plugin.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Replacing Zotero or Better BibTeX | Project is built around them. |
|
||||
| Automatically triggering deep-reading agents from workers | Lite architecture intentionally separates worker and agent. |
|
||||
| Cloud-hosted multi-user service | Local-first, single-user. |
|
||||
| Full OCR provider abstraction | PaddleOCR is the priority. |
|
||||
| Plugin auto-update | Deferred to when listed on Obsidian Community Plugins. |
|
||||
| v1.8 dashboard features | Paused — structural foundation (v1.9) must land first. |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| FM-01 | Phase 37 | Pending |
|
||||
| FM-02 | Phase 37 | Pending |
|
||||
| FM-03 | Phase 37 | Pending |
|
||||
| FM-04 | Phase 37 | Pending |
|
||||
| FM-05 | Phase 37 | Pending |
|
||||
| FM-06 | Phase 37 | Pending |
|
||||
| FM-07 | Phase 37 | Pending |
|
||||
| WS-01 | Phase 38 | Pending |
|
||||
| WS-02 | Phase 38 | Pending |
|
||||
| WS-03 | Phase 38 | Pending |
|
||||
| WS-04 | Phase 38 | Pending |
|
||||
| WS-05 | Phase 38 | Pending |
|
||||
| BASE-01 | Phase 39 | Pending |
|
||||
| BASE-02 | Phase 39 | Pending |
|
||||
| BASE-03 | Phase 39 | Pending |
|
||||
| BASE-04 | Phase 39 | Pending |
|
||||
| BASE-05 | Phase 39 | Pending |
|
||||
| LRD-01 | Phase 40 | Pending |
|
||||
| LRD-02 | Phase 40 | Pending |
|
||||
| LRD-03 | Phase 40 | Pending |
|
||||
| LRD-04 | Phase 40 | Pending |
|
||||
| LRD-05 | Phase 40 | Pending |
|
||||
| PLG-01 | Phase 41 | Pending |
|
||||
| PLG-02 | Phase 41 | Pending |
|
||||
| PLG-03 | Phase 41 | Pending |
|
||||
| PLG-04 | Phase 41 | Pending |
|
||||
| PLG-05 | Phase 41 | Pending |
|
||||
| PLG-06 | Phase 41 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1.9 requirements: 28 total
|
||||
- Mapped to phases: 28 ✓
|
||||
- Unmapped: 0 ✓
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-05-07*
|
||||
*Last updated: 2026-05-07 after initial definition*
|
||||
|
||||
## Reference Vault Learnings (D:\L\Med\Research_LitControl_Sandbox)
|
||||
|
||||
The reference vault contains a manually-maintained working copy of the PaperForge plugin that MUST be treated as the ground truth for dashboard behavior. Key lessons absorbed:
|
||||
|
||||
### Working patterns to preserve
|
||||
- **Mode detection** (`_detectAndSwitch`): `.base` → collection, `.md` with `zotero_key` → per-paper, otherwise → global. Simple, direct, works reliably.
|
||||
- **Global mode** (`_renderGlobalMode`): metric cards + OCR pipeline section. Calls `_fetchStats` to read canonical index JSON directly (no CLI spawn).
|
||||
- **Per-paper mode** (`_renderPaperMode`): lifecycle stepper (6 stages), health matrix (4 dimensions), maturity gauge (6 levels), next-step recommendation card with actionable buttons.
|
||||
- **Collection mode** (`_renderCollectionMode`): domain-level metric cards (Papers/Fulltext Ready/Deep Read), lifecycle bar chart, health overview grid (healthy/unhealthy counts per dimension).
|
||||
- **Quick Actions** (`_renderActions`): 7 action cards rendered in a grid — Sync Library, Run OCR, Deep-Reading Queue, Doctor, Repair, Copy Context, Copy Collection Context.
|
||||
- **`_switchMode`**: Clears `_contentEl` before rendering new mode. `_refreshCurrentMode` for index-change updates (invalidates cache, re-renders same mode).
|
||||
- **Event subscriptions**: `active-leaf-change` (debounced 300ms → `_detectAndSwitch`), `modify` (formal-library.json only → `_invalidateIndex` + `_refreshCurrentMode`).
|
||||
- **`onClose`**: Full cleanup — unsubscribe events, clear timer, null cached items + stats.
|
||||
- **CSS classes**: `paperforge-metric-card`, `paperforge-lifecycle-stepper`, `paperforge-health-matrix` (2x2 grid), `paperforge-maturity-gauge` (6 segments), `paperforge-bar-chart` (proportional bars), `paperforge-collection-view`, `paperforge-paper-view`, `paperforge-next-step-card`, `paperforge-paper-actions`.
|
||||
|
||||
### Issues fixed in current branch (verify not reverted)
|
||||
- Lifecycle key alignment: reference uses `deep_read`/`ai_ready`/`imported`; current branch uses `deep_read_done`/`ai_context_ready` (matching Python `compute_lifecycle()` output).
|
||||
- `_resolveModeForFile` abstraction: current branch extracted mode resolution into a pure function with deep-reading.md detection. Reference does inline detection without deep-reading support.
|
||||
- Deep-reading mode (`_renderDeepReadingMode`): ONLY in current branch. Reads `deep-reading.md` + `discussion.json` via vault adapter. Must NOT regress during v1.9 work.
|
||||
|
||||
### Issues to fix (both files)
|
||||
- **Version badge**: reads `this._cachedStats?.version` (stale) instead of `index.paperforge_version` from JSON envelope. First load always shows `v—`.
|
||||
|
||||
### Reference vault structural notes
|
||||
- Reference vault main.js: 103KB, 2068 lines (leaner, no deep-reading mode)
|
||||
- Current branch main.js: 118KB, 2337 lines (added deep-reading + resolveModeForFile)
|
||||
- Reference CSS: 35.9KB vs current 39.1KB (current adds deep-reading mode styles)
|
||||
- All dashboard components (lifecycle stepper, health matrix, maturity gauge, bar chart) are IDENTICAL between reference and current branch — no structural divergence
|
||||
158
.planning/milestones/v1.9-ROADMAP.md
Normal file
158
.planning/milestones/v1.9-ROADMAP.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Roadmap: PaperForge
|
||||
|
||||
**Current milestone:** v1.9 Frontmatter Rationalization & Library-Record Deprecation — Planned
|
||||
**Phase numbering:** Continuous. v1.8 partial ended at Phase 36. v1.9 begins at Phase 37.
|
||||
|
||||
---
|
||||
|
||||
## Milestones
|
||||
|
||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-23)
|
||||
- ✅ **v1.1 Sandbox Onboarding** — Phases 6-8 (shipped 2026-04-24)
|
||||
- ✅ **v1.2 Systematization & Cohesion** — Phases 9-10 (shipped 2026-04-24)
|
||||
- ✅ **v1.3 Path Normalization & Architecture Hardening** — Phases 11-12 (shipped 2026-04-24)
|
||||
- ✅ **v1.4 Code Health & UX Hardening** — Phases 13-19 (shipped 2026-04-28)
|
||||
- ✅ **v1.5 Obsidian Plugin Setup Integration** — Phases 20-21 (shipped 2026-04-29)
|
||||
- ✅ **v1.6 AI-Ready Literature Asset Foundation** — Phases 22-26 (shipped 2026-05-04)
|
||||
- ✅ **v1.7 Context-Aware Dashboard** — Phases 27-30 (shipped 2026-05-04)
|
||||
- 📋 **v1.8 AI Discussion & Deep-Reading Dashboard** — Phases 31-36 (partial — 2/6 phases complete, paused for v1.9)
|
||||
- 📋 **v1.9 Frontmatter Rationalization & Library-Record Deprecation** — Phases 37-41 (planned)
|
||||
|
||||
*Archive: `.planning/milestones/`*
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.6 AI-Ready Literature Asset Foundation (Phases 22-26) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 22: Configuration Truth & Compatibility (3/3)
|
||||
- [x] Phase 23: Canonical Asset Index & Safe Rebuilds (3/3)
|
||||
- [x] Phase 24: Derived Lifecycle, Health & Maturity (2/2)
|
||||
- [x] Phase 25: Surface Convergence, Doctor & Repair (3/3)
|
||||
- [x] Phase 26: Traceable AI Context Packs (3/3)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>✅ v1.7 Context-Aware Dashboard (Phases 27-30) — SHIPPED 2026-05-04</summary>
|
||||
|
||||
- [x] Phase 27: Component Library (2/2)
|
||||
- [x] Phase 28: Dashboard Shell & Context Detection (2/2)
|
||||
- [x] Phase 29: Per-Paper View (1/1)
|
||||
- [x] Phase 30: Collection View (1/1)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>📋 v1.8 AI Discussion & Deep-Reading Dashboard (Phases 31-36) — PAUSED</summary>
|
||||
|
||||
- [ ] Phase 31: Bug Fixes — Restore version display; remove meaningless "ai" UI row
|
||||
- [ ] Phase 32: Deep-Reading Mode Detection — Plugin routes deep-reading.md to dedicated dashboard mode
|
||||
- [ ] Phase 33: Deep-Reading Dashboard Rendering — Status bar, Pass 1 summary, empty-state AI Q&A card
|
||||
- [x] Phase 34: Jump to Deep Reading Button — Per-paper dashboard card links to deep-reading.md (completed 2026-05-06)
|
||||
- [x] Phase 35: AI Discussion Recorder — Python module writes discussion.md + discussion.json into ai/ (completed 2026-05-06)
|
||||
- [ ] Phase 36: Integration Verification — End-to-end pipeline verified with CJK encoding and vault.adapter.read
|
||||
|
||||
</details>
|
||||
|
||||
### 📋 v1.9 Frontmatter Rationalization & Library-Record Deprecation (Planned)
|
||||
|
||||
**Milestone Goal:** Deprecate the library-records tracking layer, converge all state into formal note frontmatter + per-workspace paper-meta.json, fix Base view field gaps, and stabilize workspace folder construction for both new and upgrading users.
|
||||
|
||||
- [ ] **Phase 37: Frontmatter Rationalization** — Slim formal note frontmatter; create per-workspace paper-meta.json; remove library_record_markdown()
|
||||
- [ ] **Phase 38: Workspace Stabilization** — Direct workspace creation; fulltext bridging; path unification; doctor integrity
|
||||
- [ ] **Phase 39: Base View Fix** — Remove ghost fields; restore workflow flags; repoint folder filter; regenerate .base files
|
||||
- [ ] **Phase 40: Library-Record Deprecation** — Skip creation for new users; lossless migration for upgraders; sync stop
|
||||
- [ ] **Phase 41: Plugin Dashboard Sync** — Verify all components; fix version badge; align lifecycle keys; CSS audit
|
||||
|
||||
---
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 37: Frontmatter Rationalization
|
||||
**Goal**: Formal note frontmatter is slimmed to identity fields + workflow flags + pdf_path; all internal/derived data moves to per-workspace paper-meta.json.
|
||||
**Depends on**: Nothing (first phase of v1.9 — builds on v1.8 partial state)
|
||||
**Requirements**: FM-01, FM-02, FM-03, FM-04, FM-05, FM-06, FM-07
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Running `paperforge sync` on a new paper writes formal note frontmatter containing only identity fields (title, year, journal, first_author, doi, pmid, zotero_key, domain, abstract, tags), workflow flags (has_pdf, do_ocr, analyze, ocr_status, deep_reading_status), and pdf_path — no workspace path fields, no OCR infrastructure fields.
|
||||
2. Running `paperforge sync` creates `paper-meta.json` in the paper's workspace directory containing ocr_job_id, ocr_md_path, ocr_json_path, the full health dict, maturity breakdown, paperforge_version, and a migrated_from field (when upgrading from library-records).
|
||||
3. `_build_entry()` writes the slimmed frontmatter fields into formal notes; the full internal state (workspace paths, OCR details, derived state) remains only in the canonical index JSON and paper-meta.json.
|
||||
4. `library_record_markdown()` is removed from the codebase — running `paperforge sync` produces zero new library-record .md files, and git grep confirms zero callers of the removed function.
|
||||
5. The existing test suite (317+ tests) passes with no regressions after frontmatter field changes, and formal notes generated by the new `_build_entry()` open correctly in Obsidian with readable frontmatter.
|
||||
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 38: Workspace Stabilization
|
||||
**Goal**: Workspace directories are created directly on first sync; fulltext content is bridged from OCR output; path construction is unified across Python modules.
|
||||
**Depends on**: Phase 37
|
||||
**Requirements**: WS-01, WS-02, WS-03, WS-04, WS-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Running `paperforge sync` on a newly added paper creates the workspace directory (`Literature/<domain>/<key> - <Title>/`) and writes the formal note directly inside it — no flat-first-then-migrate intermediate state.
|
||||
2. After OCR completes, the fulltext content at `<system_dir>/PaperForge/ocr/<key>/fulltext.md` is copied or symlinked to the workspace `fulltext.md` path declared in the canonical index entry, and the file is readable in Obsidian.
|
||||
3. `discussion.py` reads the workspace `ai_path` from the canonical index entry via `_build_ai_dir()` — independent path reconstruction logic is removed from discussion.py, and git grep confirms zero hardcoded `ai/` path construction in the module.
|
||||
4. `migrate_to_workspace()` copies OCR output to workspace `fulltext.md` when migrating old flat notes that have `ocr_status: done` — upgrading users do not lose existing fulltext on migration.
|
||||
5. `paperforge doctor` reports workspace integrity issues: missing workspace directories for indexed papers, missing fulltext.md when ocr_status=done, and path construction mismatches between the index and actual disk state.
|
||||
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 39: Base View Fix
|
||||
**Goal**: Obsidian Base views show all workflow flags (has_pdf, do_ocr, analyze, ocr_status), point to the Literature/ folder, and exclude unwritten ghost fields.
|
||||
**Depends on**: Phase 37
|
||||
**Requirements**: BASE-01, BASE-02, BASE-03, BASE-04, BASE-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Opening any Base view in Obsidian shows no `lifecycle`, `maturity_level`, or `next_step` columns — these are dashboard-owned fields that have no .md writer and must not appear in Base views.
|
||||
2. All Base views display `has_pdf`, `do_ocr`, `analyze`, and `ocr_status` as filterable columns with correct Chinese display names (e.g., "有PDF", "需OCR", "需分析", "OCR状态"), and toggling these values in the Base view modifies the underlying formal note frontmatter.
|
||||
3. Every Base view's folder filter targets `<literature_dir>/<domain>` (e.g., `Literature/骨科/`) instead of `<control_dir>/<domain>` (e.g., `LiteratureControl/library-records/骨科/`).
|
||||
4. All 8 workflow views (控制面板, 推荐分析, 待OCR, OCR完成, 待深度阅读, 深度阅读完成, 正式卡片, 全记录) display correct data matching the workflow states defined in formal note frontmatter, with the same filter/sort logic as the current master-branch Base views.
|
||||
5. Running `paperforge sync` regenerates all .base files with the corrected properties and folder filters, and opening them in Obsidian shows the expected content without property errors or empty columns.
|
||||
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 40: Library-Record Deprecation
|
||||
**Goal**: New users never see library-records; upgrading users get automatic lossless migration on first sync; sync stops all library-record generation.
|
||||
**Depends on**: Phase 39
|
||||
**Requirements**: LRD-01, LRD-02, LRD-03, LRD-04, LRD-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A clean install (no existing `<control_dir>/library-records/` directory) runs `paperforge sync` end-to-end without creating `library-records/` — the directory never appears in the vault, and new users never encounter it.
|
||||
2. An upgrading user with existing library-record files runs `paperforge sync` and all user-facing workflow flags (has_pdf, do_ocr, analyze, ocr_status, deep_reading_status, recommend_analyze) are merged into corresponding formal notes — spot-checking random library-records confirms frontmatter values match the migrated formal notes.
|
||||
3. After migration (or on clean install), running `paperforge sync` for any paper — newly added from Zotero or previously indexed — produces zero new library-record .md files anywhere in the vault.
|
||||
4. `run_index_refresh()` orphaned-record cleanup logic operates against formal notes in `<literature_dir>/` — formal notes without a matching Zotero entry are detected and reported with the existing orphan-handling behavior (no silent data loss).
|
||||
5. `paperforge doctor` detects a stale `<control_dir>/library-records/` directory, reports the number of files found, and outputs a recommendation to manually delete it (with a `--cleanup-library-records` flag suggestion), without automatically deleting user data.
|
||||
|
||||
**Plans**: TBD
|
||||
|
||||
### Phase 41: Plugin Dashboard Sync
|
||||
**Goal**: Plugin dashboard renders correctly in all modes after frontmatter rationalization and workspace stabilization; version badge shows real version; lifecycle keys match Python output; all CSS component styles are present.
|
||||
**Depends on**: Phase 37, Phase 38
|
||||
**Requirements**: PLG-01, PLG-02, PLG-03, PLG-04, PLG-05, PLG-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Opening the PaperForge dashboard in Obsidian after `paperforge sync` shows correct aggregated stats — paper count, fulltext-ready count, deep-read count — in all three modes (global, per-paper, collection) without JavaScript console errors.
|
||||
2. All Quick Action buttons (Open Fulltext, Jump to Deep Reading, Copy Context, Copy Collection Context) resolve workspace paths correctly from the canonical index and open the expected files when clicked — file-not-found errors produce an Obsidian Notice instead of a silent failure.
|
||||
3. Dashboard lifecycle stepper (6 stages), health matrix (2x2 grid), maturity gauge (6 segments), and lifecycle bar chart render correctly in all three modes with data matching the canonical index entries — visual inspection confirms no regressions from the reference vault dashboard.
|
||||
4. Plugin version badge in the header displays the actual `paperforge_version` from `formal-library.json` envelope (e.g., "v1.9.0") on first load — no stale `v—` placeholder; the version updates when `formal-library.json` is modified.
|
||||
5. All JS lifecycle key references use `deep_read_done` and `ai_context_ready` matching Python `compute_lifecycle()` output — git grep in `paperforge/plugin/` confirms zero instances of bare `deep_read` or `ai_ready` as standalone lifecycle state keys.
|
||||
6. Plugin CSS file contains all dashboard component style classes (`paperforge-metric-card`, `paperforge-lifecycle-stepper`, `paperforge-health-matrix`, `paperforge-maturity-gauge`, `paperforge-bar-chart`, `paperforge-collection-view`, `paperforge-paper-view`, `paperforge-next-step-card`, `paperforge-paper-actions`, `paperforge-mode-deepreading`, `paperforge-loading-skeleton`) and these classes render with correct visual styling in Obsidian dark and light themes.
|
||||
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
---
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 34. Jump to Deep Reading Button | 1/1 | Complete | 2026-05-06 |
|
||||
| 35. AI Discussion Recorder | 1/1 | Complete | 2026-05-06 |
|
||||
| 37. Frontmatter Rationalization | 0/TBD | Not started | - |
|
||||
| 38. Workspace Stabilization | 0/TBD | Not started | - |
|
||||
| 39. Base View Fix | 0/TBD | Not started | - |
|
||||
| 40. Library-Record Deprecation | 0/TBD | Not started | - |
|
||||
| 41. Plugin Dashboard Sync | 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
*Roadmap updated: 2026-05-07 — v1.9 milestone planned*
|
||||
440
.planning/phases/35-ai-discussion-recorder/35-01-PLAN.md
Normal file
440
.planning/phases/35-ai-discussion-recorder/35-01-PLAN.md
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
---
|
||||
phase: 35-ai-discussion-recorder
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- paperforge/worker/discussion.py
|
||||
- tests/test_discussion.py
|
||||
- paperforge/skills/literature-qa/scripts/pf-paper.md
|
||||
autonomous: true
|
||||
requirements:
|
||||
- AI-01
|
||||
- AI-02
|
||||
- AI-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Importing and calling record_session() creates ai/discussion.json containing schema_version: '1', paper_key, and sessions[] array"
|
||||
- "Calling record_session() creates ai/discussion.md with human-readable 问题:/解答: format and chronological ## session headings"
|
||||
- "Re-running record_session() for the same paper appends a new session to both files — never overwrites previous discussions"
|
||||
- "Both files are written atomically via tempfile.NamedTemporaryFile + os.replace() — partial writes don't corrupt"
|
||||
- "discussion.md uses explicit utf-8 encoding and ensure_ascii=False — Chinese content survives without mojibake"
|
||||
- "Module uses Python stdlib only (json, pathlib, datetime, tempfile, os, uuid, argparse) — zero new dependencies"
|
||||
- "CLI subcommand works: python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model <model>"
|
||||
- "/pf-paper prompt instructs agent to accumulate Q&A pairs during session and call CLI at session end"
|
||||
artifacts:
|
||||
- path: "paperforge/worker/discussion.py"
|
||||
provides: "record_session() function and CLI subcommand for discussion recording"
|
||||
min_lines: 150
|
||||
- path: "tests/test_discussion.py"
|
||||
provides: "Unit tests for record_session() behavior"
|
||||
min_lines: 80
|
||||
- path: "paperforge/skills/literature-qa/scripts/pf-paper.md"
|
||||
provides: "Updated /pf-paper prompt with 保存讨论记录 step"
|
||||
contains: "保存讨论记录"
|
||||
key_links:
|
||||
- from: "paperforge/worker/discussion.py"
|
||||
to: "paperforge/config.py"
|
||||
via: "paperforge_paths() for path resolution"
|
||||
pattern: "from paperforge\.config import"
|
||||
- from: "paperforge/worker/discussion.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "slugify_filename() for title slugging, scan_library_records() for paper lookup"
|
||||
pattern: "from paperforge\.worker\._utils import"
|
||||
- from: "paperforge/skills/literature-qa/scripts/pf-paper.md"
|
||||
to: "paperforge/worker/discussion.py"
|
||||
via: "CLI invocation at session end"
|
||||
pattern: "paperforge\.worker\.discussion record"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create `paperforge/worker/discussion.py` — a stdlib-only Python module that records AI-paper discussion sessions into `ai/discussion.json` (canonical, append-only) and `ai/discussion.md` (human-readable) with atomic writes. Wire it into the `/pf-paper` agent prompt so agent sessions automatically save discussion records at completion.
|
||||
|
||||
Purpose: Phase 35 delivers the AI Discussion Recorder — the Python-side infrastructure that captures structured Q&A records from agent sessions into each paper's workspace `ai/` directory. This enables Phase 36's dashboard to render AI Q&A history.
|
||||
|
||||
Output:
|
||||
- `paperforge/worker/discussion.py` — importable `record_session()` function + CLI subcommand
|
||||
- `tests/test_discussion.py` — unit tests covering record, append, atomic write, error paths
|
||||
- `paperforge/skills/literature-qa/scripts/pf-paper.md` — updated prompt with recording step
|
||||
|
||||
**Design decisions (from CONTEXT.md, per /gsd-discuss-phase):**
|
||||
- D-05: Only `/pf-paper` records discussions. `/pf-deep` does NOT record (output already in formal note). This OVERRIDES the AI-03 requirement wording which mentions both — implementation follows D-05.
|
||||
- D-08: Both files written independently from same data object.
|
||||
- D-09: Atomic write via `tempfile.NamedTemporaryFile` + `os.replace()`.
|
||||
- D-10: Append-only — read existing, append session, write back.
|
||||
- D-04: stdlib only (`json`, `pathlib`, `datetime`, `tempfile`, `os`, `uuid`, `argparse`).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/ROADMAP.md § Phase 35
|
||||
@.planning/REQUIREMENTS.md § AI-01, AI-02, AI-03
|
||||
@.planning/phases/35-ai-discussion-recorder/35-CONTEXT.md
|
||||
@.planning/STATE.md
|
||||
@.planning/PROJECT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. From existing codebase. -->
|
||||
|
||||
From paperforge/config.py:
|
||||
```python
|
||||
def paperforge_paths(vault: Path, cfg: dict | None = None) -> dict[str, Path]:
|
||||
"""Returns keys: vault, system, paperforge, exports, ocr, resources,
|
||||
literature, control, library_records, bases, skill_dir, ld_deep_script"""
|
||||
```
|
||||
|
||||
From paperforge/worker/_utils.py:
|
||||
```python
|
||||
def slugify_filename(text: str) -> str:
|
||||
"""Sanitize title for file path — strips <>:"/\\|?*, max 120 chars"""
|
||||
|
||||
def read_json(path: Path):
|
||||
"""json.loads(path.read_text(encoding='utf-8'))"""
|
||||
|
||||
def write_json(path: Path, data) -> None:
|
||||
"""path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')"""
|
||||
|
||||
def scan_library_records(vault: Path) -> list[dict]:
|
||||
"""Returns list of dicts with zotero_key, domain, title, analyze, etc."""
|
||||
```
|
||||
|
||||
From paperforge/worker/asset_index.py (line 307):
|
||||
```python
|
||||
"ai_path": f"Literature/{domain}/{key} - {title_slug}/ai/"
|
||||
```
|
||||
|
||||
From tests/conftest.py:
|
||||
```python
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SANDBOX_DIR = REPO_ROOT / "tests" / "sandbox"
|
||||
FIXTURE_VAULT = SANDBOX_DIR / "00_TestVault"
|
||||
```
|
||||
|
||||
From paperforge/skills/literature-qa/scripts/pf-paper.md:
|
||||
The current prompt ends after "## See Also" section. The new recording step goes before "## See Also" as step 8 in the workflow.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create paperforge/worker/discussion.py with record_session() and CLI</name>
|
||||
<files>paperforge/worker/discussion.py, tests/test_discussion.py</files>
|
||||
|
||||
<read_first>
|
||||
- .planning/phases/35-ai-discussion-recorder/35-CONTEXT.md (locked decisions D-01 through D-17)
|
||||
- paperforge/worker/_utils.py (slugify_filename, write_json, read_json, scan_library_records patterns)
|
||||
- paperforge/config.py (paperforge_paths, load_vault_config)
|
||||
- paperforge/worker/asset_index.py:295-308 (ai_path construction pattern)
|
||||
- tests/conftest.py (test vault structure, fixture patterns)
|
||||
- tests/test_utils_json.py:1-50 (test class pattern)
|
||||
</read_first>
|
||||
|
||||
<behavior>
|
||||
- Test 1: record_session() with valid inputs creates ai/discussion.json containing sessions[0] with session_id (UUID string, 36 chars), agent="pf-paper", model string, started (ISO 8601 with timezone), paper_key, paper_title, domain, and qa_pairs[] where each pair has question, answer, source (one of "user_question"|"agent_analysis"), and timestamp
|
||||
- Test 2: record_session() creates ai/discussion.md with "# AI Discussion Record: {title}" header, "## {date} — {agent} ({model})" session heading, "**问题:**" / "**解答:**" pairs, and "---" separator
|
||||
- Test 3: Calling record_session() twice for same paper appends second session to sessions[] in JSON (length=2) and adds second ## session heading in MD — first session data preserved intact
|
||||
- Test 4: record_session() with non-existent vault returns {"status": "error", ...}
|
||||
- Test 5: record_session() with unknown zotero_key returns {"status": "error", ...} without creating files
|
||||
- Test 6: Written JSON contains ensure_ascii=False Chinese characters (e.g., "问题":"中文测试") parsed correctly by json.load()
|
||||
- Test 7: CLI `python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model gpt-4 --qa-pairs '[{"question":"Q1","answer":"A1","source":"user_question","timestamp":"2026-05-06T12:00:00+08:00"}]'` exits 0 and creates discussion.json
|
||||
</behavior>
|
||||
|
||||
<action>
|
||||
Create `paperforge/worker/discussion.py` with the following implementation:
|
||||
|
||||
### Module structure
|
||||
```python
|
||||
"""Discussion recorder — writes structured AI-paper Q&A into ai/ workspace directory.
|
||||
|
||||
Atomic append-only writes for both JSON (canonical) and Markdown (human-readable).
|
||||
stdlib only — no dependencies beyond Python standard library.
|
||||
|
||||
API:
|
||||
record_session(vault_path, zotero_key, agent, model, qa_pairs) -> dict
|
||||
|
||||
CLI:
|
||||
python -m paperforge.worker.discussion record <zotero_key> --vault <path>
|
||||
--agent <name> --model <model> [--qa-pairs <json>]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
```
|
||||
|
||||
### record_session() function (per D-01, D-02)
|
||||
|
||||
Signature:
|
||||
```python
|
||||
def record_session(
|
||||
vault_path: Path,
|
||||
zotero_key: str,
|
||||
agent: str,
|
||||
model: str,
|
||||
qa_pairs: list[dict],
|
||||
) -> dict:
|
||||
```
|
||||
|
||||
Returns: `{"status": "ok"/"error", "json_path": <str>, "md_path": <str>}`
|
||||
On error: `{"status": "error", "message": <str>}` — never raises.
|
||||
|
||||
Implementation steps:
|
||||
1. Validate inputs: vault_path exists/is_dir, zotero_key is non-empty str, qa_pairs is list
|
||||
2. Load config: `from paperforge.config import paperforge_paths`, get path inventory
|
||||
3. Find paper metadata: scan `{library_records}/*/{zotero_key}.md` for domain and title fields
|
||||
- If library-records path is `<control>/library-records`, iterate domain dirs
|
||||
- Read record frontmatter: regex `^domain:\s*(.+)$`, `^title:\s*"?(.+?)"?$`
|
||||
- If not found: fallback scan all *.md in subdirs by zotero_key frontmatter
|
||||
- If still not found: return error dict
|
||||
4. Construct ai_path: `{literature}/{domain}/{key} - {title_slug}/ai/`
|
||||
- Use `slugify_filename(title)` from `paperforge.worker._utils` (per existing codebase pattern)
|
||||
5. Read existing discussion.json if it exists (handle FileNotFoundError, JSONDecodeError gracefully — start fresh)
|
||||
6. Build session object (per D-11, D-12):
|
||||
```python
|
||||
session = {
|
||||
"session_id": str(uuid.uuid4()),
|
||||
"agent": agent, # "pf-paper" per D-05
|
||||
"model": model,
|
||||
"started": datetime.now(timezone(timedelta(hours=8))).isoformat(),
|
||||
"paper_key": zotero_key,
|
||||
"paper_title": paper_title,
|
||||
"domain": domain,
|
||||
"qa_pairs": qa_pairs, # Each: {question, answer, source, timestamp}
|
||||
}
|
||||
```
|
||||
7. Atomic write discussion.json (per D-09, D-10):
|
||||
- Build envelope: `{"schema_version": "1", "paper_key": zotero_key, "sessions": [...existing..., session]}`
|
||||
- `json.dumps(envelope, ensure_ascii=False, indent=2)` (per D-16)
|
||||
- Write to `tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.json', dir=ai_dir, delete=False)`
|
||||
- Write content with `newline='\n'` (use `file.write()` with `\n` line endings)
|
||||
- `os.replace(temp_path, json_target_path)` — atomic on Windows
|
||||
- On any failure: cleanup temp file, return error
|
||||
|
||||
8. Build and atomic write discussion.md (per D-13):
|
||||
- Format as:
|
||||
```
|
||||
# AI Discussion Record: {paper_title}
|
||||
|
||||
## {date_str} — {agent} ({model})
|
||||
|
||||
**问题:** {question_1}
|
||||
**解答:** {answer_1}
|
||||
|
||||
**问题:** {question_2}
|
||||
**解答:** {answer_2}
|
||||
|
||||
---
|
||||
```
|
||||
- date_str = YYYY-MM-DD from session started timestamp
|
||||
- Read existing MD content (if file exists), append new session after a blank line
|
||||
- If file doesn't exist, create with header
|
||||
- Same atomic write pattern: tempfile + os.replace()
|
||||
|
||||
### CLI subcommand (per D-03)
|
||||
|
||||
Add `if __name__ == "__main__":` block using argparse:
|
||||
```python
|
||||
def _build_cli_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m paperforge.worker.discussion",
|
||||
description="Record AI-paper discussion sessions"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
record_p = sub.add_parser("record", help="Record a discussion session")
|
||||
record_p.add_argument("zotero_key", help="Zotero citation key")
|
||||
record_p.add_argument("--vault", required=True, help="Path to Obsidian vault")
|
||||
record_p.add_argument("--agent", required=True, help="Agent name (e.g. pf-paper)")
|
||||
record_p.add_argument("--model", required=True, help="Model identifier")
|
||||
record_p.add_argument("--qa-pairs", default="[]", help="JSON array of Q&A pairs")
|
||||
return parser
|
||||
```
|
||||
|
||||
CLI main: parse args, resolve vault path, parse qa_pairs JSON, call record_session(), print result JSON to stdout, exit 0 on success / 1 on error.
|
||||
|
||||
### Error handling (per D-17)
|
||||
- Never crash — always return status dict
|
||||
- Catch FileNotFoundError, JSONDecodeError, OSError, PermissionError
|
||||
- Temp file cleanup in finally block
|
||||
- Log warnings via `logging.getLogger(__name__).warning()` for non-fatal issues
|
||||
|
||||
### Test file: tests/test_discussion.py
|
||||
Create following existing test patterns (class-based, tmp_path fixture, see tests/test_utils_json.py):
|
||||
- `class TestRecordSession`: test_create_both_files, test_append_second_session, test_missing_vault, test_unknown_key, test_cjk_encoding, test_atomic_write_no_partial
|
||||
- Use `create_test_vault()` from conftest or tmp_path with minimal directory structure
|
||||
- For library-record lookup tests: create `{tmp}/03_Resources/LiteratureControl/library-records/{domain}/{key}.md` with yaml frontmatter containing `zotero_key`, `domain`, `title` fields
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>pytest tests/test_discussion.py -v --tb=short</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `paperforge/worker/discussion.py` exists with `record_session` function exported
|
||||
- `from paperforge.worker.discussion import record_session` succeeds without ImportError
|
||||
- All 7 behavior tests pass (pytest exits 0)
|
||||
- `python -m paperforge.worker.discussion --help` prints usage without error
|
||||
- `grep "schema_version.*1" paperforge/worker/discussion.py` finds match
|
||||
- `grep "os\.replace" paperforge/worker/discussion.py` finds at least 2 matches (one per file)
|
||||
- `grep "NamedTemporaryFile" paperforge/worker/discussion.py` finds match
|
||||
- `grep "ensure_ascii=False" paperforge/worker/discussion.py` finds match
|
||||
- Module imports only stdlib + paperforge internal modules (no pip-installed third-party packages)
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>discussion.py module is importable, record_session() passes all tests, CLI subcommand works</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Update pf-paper.md prompt with discussion recording step</name>
|
||||
<files>paperforge/skills/literature-qa/scripts/pf-paper.md</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/skills/literature-qa/scripts/pf-paper.md (current complete content)
|
||||
- .planning/phases/35-ai-discussion-recorder/35-CONTEXT.md (D-05: only pf-paper records, D-06: update prompt)
|
||||
- paperforge/worker/discussion.py (CLI syntax to reference — from Task 1 output)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Update `paperforge/skills/literature-qa/scripts/pf-paper.md` to add discussion recording step.
|
||||
|
||||
### Change 1: Add step 8 to workflow (after step 7, before "## See Also")
|
||||
|
||||
Insert the following section between the "Q&A模式" instructions (step 7 area) and the "## See Also" section:
|
||||
|
||||
```markdown
|
||||
### 8. 保存讨论记录
|
||||
|
||||
在 Q&A 会话结束时,Agent 必须将本次讨论记录保存到论文工作区的 `ai/` 目录中。
|
||||
|
||||
**操作步骤:**
|
||||
|
||||
1. 在会话中积累所有 Q&A 对 —— 每次用户提问和 Agent 回答都记录为一对:
|
||||
```json
|
||||
{
|
||||
"question": "用户的问题",
|
||||
"answer": "Agent 的回答",
|
||||
"source": "user_question",
|
||||
"timestamp": "2026-05-06T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
其中 `source` 为 `"user_question"`(用户提问)或 `"agent_analysis"`(Agent 主动分析)。
|
||||
|
||||
2. 会话结束时,将 Q&A 对序列化为 JSON 字符串,调用 CLI:
|
||||
```bash
|
||||
python -m paperforge.worker.discussion record <ZOTERO_KEY> \
|
||||
--vault "<VAULT_PATH>" \
|
||||
--agent pf-paper \
|
||||
--model "<MODEL_NAME>" \
|
||||
--qa-pairs '<JSON_ARRAY>'
|
||||
```
|
||||
|
||||
3. CLI 返回 JSON:
|
||||
```json
|
||||
{"status": "ok", "json_path": "Literature/{domain}/{key} - {title}/ai/discussion.json", "md_path": "Literature/{domain}/{key} - {title}/ai/discussion.md"}
|
||||
```
|
||||
如果 `status` 为 `"error"`,记录错误信息但不中断会话流程。
|
||||
|
||||
**注意:**
|
||||
- 仅 `/pf-paper` 记录讨论。`/pf-deep` 不记录(精读内容已写入正式笔记)。
|
||||
- 如果论文没有 library-record(无法解析 domain/title),记录会失败但不影响正常使用。
|
||||
- 所有 Q&A 内容以 UTF-8 编码写入,支持中文。
|
||||
```
|
||||
|
||||
### Change 2: Update Prerequisites checklist
|
||||
|
||||
Add to the prerequisites checklist:
|
||||
```markdown
|
||||
- [ ] discussion.py 模块可用(`python -m paperforge.worker.discussion --help` 可执行)
|
||||
```
|
||||
|
||||
### Change 3: Mention recording in Output section
|
||||
|
||||
After the "已加载论文" display block, add:
|
||||
```markdown
|
||||
会话结束后,讨论记录将自动保存至论文工作区 `ai/discussion.json` 和 `ai/discussion.md`。
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -c "保存讨论记录" paperforge/skills/literature-qa/scripts/pf-paper.md</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `grep "保存讨论记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "paperforge.worker.discussion record" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "仅.*pf-paper.*记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match (per D-05: pf-deep excluded)
|
||||
- `grep "pf-deep.*不记录" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "ai/discussion.json" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "ai/discussion.md" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "user_question" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- `grep "agent_analysis" paperforge/skills/literature-qa/scripts/pf-paper.md` returns at least 1 match
|
||||
- The original "## See Also" section remains intact (grep confirms its presence)
|
||||
- No content from the original prompt (steps 1-7, error handling, output format) is removed or corrupted
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>/pf-paper prompt instructs agent to accumulate Q&A pairs and call CLI at session end, with pf-deep explicitly excluded per D-05</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Run after both tasks complete:
|
||||
|
||||
```bash
|
||||
# Verify module is importable
|
||||
python -c "from paperforge.worker.discussion import record_session; print('OK: record_session imported')"
|
||||
|
||||
# Verify CLI help works
|
||||
python -m paperforge.worker.discussion --help
|
||||
|
||||
# Verify tests pass
|
||||
pytest tests/test_discussion.py -v --tb=short
|
||||
|
||||
# Verify pf-paper.md has recording step
|
||||
grep -n "保存讨论记录\|paperforge.worker.discussion record\|ai/discussion" paperforge/skills/literature-qa/scripts/pf-paper.md
|
||||
|
||||
# Verify stdlib-only (no third-party imports)
|
||||
python -c "
|
||||
import ast, sys
|
||||
with open('paperforge/worker/discussion.py') as f:
|
||||
tree = ast.parse(f.read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
name = alias.name.split('.')[0]
|
||||
if name not in ('json','pathlib','datetime','tempfile','os','uuid','argparse','logging','__future__','paperforge'):
|
||||
print(f'THIRD-PARTY: {name}'); sys.exit(1)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.split('.')[0] not in ('__future__','paperforge'):
|
||||
print(f'THIRD-PARTY: {node.module}'); sys.exit(1)
|
||||
print('OK: stdlib + paperforge only')
|
||||
"
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `record_session()` creates both `ai/discussion.json` and `ai/discussion.md` in paper workspace
|
||||
2. Append works: second call adds session without overwriting first
|
||||
3. Atomic writes via tempfile + os.replace prevent corruption
|
||||
4. CJK content survives round-trip (Chinese in JSON → read back → matches)
|
||||
5. CLI subcommand functions: `python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model <model> --qa-pairs '<json>'`
|
||||
6. `/pf-paper` prompt updated with step 8 recording instructions, pf-deep explicitly excluded per D-05
|
||||
7. Module uses stdlib only — zero new pip dependencies
|
||||
8. All unit tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/35-ai-discussion-recorder/35-01-SUMMARY.md`
|
||||
</output>
|
||||
179
.planning/phases/35-ai-discussion-recorder/35-01-VERIFICATION.md
Normal file
179
.planning/phases/35-ai-discussion-recorder/35-01-VERIFICATION.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
phase: 35-ai-discussion-recorder
|
||||
verified: 2026-05-06T23:44:00Z
|
||||
status: passed
|
||||
score: 8/8 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 35: AI Discussion Recorder -- Verification Report
|
||||
|
||||
**Phase Goal:** Create `paperforge/worker/discussion.py` -- a stdlib-only Python module that records AI-paper discussion sessions into `ai/discussion.json` (canonical, append-only) and `ai/discussion.md` (human-readable) with atomic writes. Wire it into the `/pf-paper` agent prompt so agent sessions automatically save discussion records at completion.
|
||||
|
||||
**Verified:** 2026-05-06T23:44:00Z
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No (initial verification)
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Importing and calling `record_session()` creates `ai/discussion.json` containing `schema_version: "1"`, `paper_key`, and `sessions[]` array | VERIFIED | Test `test_create_both_files` passes; JSON envelope verified in integration test |
|
||||
| 2 | Calling `record_session()` creates `ai/discussion.md` with human-readable `**问题:**`/`**解答:**` format and chronological `##` session headings | VERIFIED | Test `test_create_both_files` passes; MD content verified in integration test |
|
||||
| 3 | Re-running `record_session()` for the same paper appends a new session to both files -- never overwrites previous discussions | VERIFIED | Test `test_append_second_session` passes; sessions.length=2, first session data preserved |
|
||||
| 4 | Both files are written atomically via tempfile + os.replace() -- partial writes don't corrupt | VERIFIED | Test `test_atomic_write_no_partial` passes; source code uses `tempfile.mkstemp()` + `os.replace()` (2 calls, one per file) |
|
||||
| 5 | Discussion files use explicit `utf-8` encoding and `ensure_ascii=False` -- Chinese content survives without mojibake | VERIFIED | Test `test_cjk_encoding` passes; `grep ensure_ascii=False` returns 4 matches in source |
|
||||
| 6 | Module uses Python stdlib only (json, pathlib, datetime, tempfile, os, uuid, argparse, re, sys, logging) -- zero new dependencies | VERIFIED | AST import analysis confirms: only stdlib + `from paperforge.{config,worker._utils}` |
|
||||
| 7 | CLI subcommand works: `python -m paperforge.worker.discussion record <key> --vault <path> --agent pf-paper --model <model>` | VERIFIED | Test `test_cli_invocation` passes; manual CLI integration test returns exit code 0 and creates both files |
|
||||
| 8 | `/pf-paper` prompt instructs agent to accumulate Q&A pairs during session and call CLI at session end | VERIFIED | pf-paper.md has "保存讨论记录" section with CLI invocation instructions and pf-deep exclusion per D-05 |
|
||||
|
||||
**Score:** 8/8 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `paperforge/worker/discussion.py` | `record_session()` function and CLI subcommand (>=150 lines) | VERIFIED | 401 lines. Exports `record_session()` with full implementation: paper metadata lookup via rglob, atomic append-only writes, CLI parser with `record` subcommand. |
|
||||
| `tests/test_discussion.py` | Unit tests for `record_session()` behavior (>=80 lines) | VERIFIED | 261 lines. Class-based test suite with 7 tests covering creation, append, error, encoding, atomicity, CLI. |
|
||||
| `paperforge/skills/literature-qa/scripts/pf-paper.md` | Updated prompt with `保存讨论记录` step | VERIFIED | 137 lines. Step 8 (lines 98-133) with Q&A accumulation, CLI invocation, pf-deep exclusion, error handling. |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `discussion.py` | `paperforge.config` | `from paperforge.config import paperforge_paths` | WIRED | Line 27. Used in `_find_paper_metadata()` and `_build_ai_dir()` for path resolution. |
|
||||
| `discussion.py` | `paperforge.worker._utils` | `from paperforge.worker._utils import slugify_filename` | WIRED | Line 28. Used in `_build_ai_dir()` for title slugging in directory path. |
|
||||
| `pf-paper.md` | `discussion.py` | CLI invocation at session end | WIRED | Line 117: `python -m paperforge.worker.discussion record <ZOTERO_KEY> --vault "<VAULT_PATH>" --agent pf-paper --model "<MODEL_NAME>" --qa-pairs '<JSON_ARRAY>'` |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|-------------|--------|-------------------|--------|
|
||||
| `discussion.py` | `meta` (paper metadata) | `_find_paper_metadata()` via `paperforge_paths()` + `rglob` in library-records | YES -- scans real vault library-records files for zotero_key frontmatter | FLOWING |
|
||||
| `discussion.py` | `ai_dir` | `_build_ai_dir()` via `paperforge_paths()` + `slugify_filename()` | YES -- resolves to `Literature/{domain}/{key} - {slug}/ai/` on disk | FLOWING |
|
||||
| `discussion.py` | `existing_sessions` | `json.loads()` from existing discussion.json on disk | YES -- reads and appends to existing sessions; starts fresh if missing/corrupted | FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Module importable | `from paperforge.worker.discussion import record_session` | Import success | PASS |
|
||||
| CLI help displays | `python -m paperforge.worker.discussion --help` | Usage printed | PASS |
|
||||
| 7 unit tests pass | `pytest tests/test_discussion.py -v --tb=short` | 7/7 passed in 0.95s | PASS |
|
||||
| CLI produces both files | Integration test: create vault, run CLI, check outputs | Exit code 0, both files created, schema_version "1", session_id len 36 | PASS |
|
||||
| CJK encoding round-trip | Test `test_cjk_encoding` | Chinese content survives read-back | PASS |
|
||||
| Append preserves data | Test `test_append_second_session` | Second call adds session, first session intact | PASS |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|------------|-----------|-------------|--------|----------|
|
||||
| AI-01 | 35-01-PLAN.md | `discussion.py` writes discussion.md (human-readable Q&A, `问题:`/`解答:` format, chronological sections) into `ai/` | SATISFIED | `_build_md_session()` produces `**问题:**`/`**解答:**` format; test verifies output |
|
||||
| AI-02 | 35-01-PLAN.md | `discussion.py` writes discussion.json (structured, `sessions[]` array with `schema_version`, `timestamp`, `qa_pairs[]`) into `ai/` | SATISFIED | JSON envelope with `schema_version: "1"`, `paper_key`, `sessions[]`; test verifies structure |
|
||||
| AI-03 | 35-01-PLAN.md | `/pf-paper` and `/pf-deep` agent sessions trigger discussion recorder at session completion | SATISFIED (with note) | pf-paper.md updated with recording step; pf-deep **intentionally excluded** per D-05 (plan explicitly overrides AI-03 wording on this point -- deep-read content lives in formal notes) |
|
||||
|
||||
**Orphaned requirements check:** All 3 requirement IDs (AI-01, AI-02, AI-03) from PLAN frontmatter are mapped in REQUIREMENTS.md traceability table to Phase 35. Zero orphaned requirements.
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| None | -- | -- | -- | Zero anti-patterns detected in scan (TODO/FIXME/placeholder/empty-impl/null-return) |
|
||||
|
||||
---
|
||||
|
||||
### Deviations from Roadmap
|
||||
|
||||
1. **pf-deep exclusion**: ROADMAP.md Phase 35 Success Criterion 1 states "Running `/pf-paper` or `/pf-deep` for a paper creates `ai/discussion.json`". The PLAN explicitly overrides this per D-05: "Only `/pf-paper` records discussions. `/pf-deep` does NOT record (output already in formal note). This OVERRIDES the AI-03 requirement wording which mentions both." This is a documented design decision vetted during phase design -- not a gap.
|
||||
|
||||
2. **mkstemp instead of NamedTemporaryFile**: PLAN specifies `tempfile.NamedTemporaryFile`, implementation uses `tempfile.mkstemp()` (fd-based). SUMMARY documents this as a deliberate deviation to avoid Windows file-locking issues. Functionally equivalent -- both achieve atomic write via temp file + `os.replace()`.
|
||||
|
||||
Both deviations are documented and justified. No corrective action needed.
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
**No gaps found.** All 8 must-haves verified successfully. All 7 tests pass. Module is importable, CLI works end-to-end, pf-paper.md prompt includes recording step with pf-deep exclusion.
|
||||
|
||||
---
|
||||
|
||||
## Verification Details
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
paperforge/worker/discussion.py (401 lines)
|
||||
record_session() -- Public API: creates/updates both JSON and MD files
|
||||
_find_paper_metadata() -- Scans library-records via rglob + frontmatter regex
|
||||
_build_ai_dir() -- Constructs Literature/{domain}/{key} - {slug}/ai/ path
|
||||
_build_session() -- Creates session dict with UUID, timestamp, qa_pairs
|
||||
_atomic_write_json() -- tempfile.mkstemp + os.replace for JSON
|
||||
_atomic_write_md() -- tempfile.mkstemp + os.replace for MD
|
||||
_build_md_header() -- "# AI Discussion Record: {title}"
|
||||
_build_md_session() -- "## {date} -- {agent} ({model})" + 问题:/解答: pairs
|
||||
_md_content() -- Append logic: new file or append to existing
|
||||
main() / _build_cli_parser() -- CLI entry point with argparse
|
||||
|
||||
tests/test_discussion.py (261 lines)
|
||||
TestRecordSession
|
||||
test_create_both_files -- JSON schema + MD format
|
||||
test_append_second_session -- Append-only semantics
|
||||
test_missing_vault -- Error: non-existent vault
|
||||
test_unknown_key -- Error: unknown zotero_key
|
||||
test_cjk_encoding -- CJK round-trip via ensure_ascii=False
|
||||
test_atomic_write_no_partial -- Atomic write integrity
|
||||
test_cli_invocation -- CLI subprocess invocation
|
||||
```
|
||||
|
||||
### Import Dependency Tree
|
||||
|
||||
```
|
||||
paperforge.worker.discussion
|
||||
+-- __future__ (stdlib)
|
||||
+-- argparse (stdlib)
|
||||
+-- json (stdlib)
|
||||
+-- logging (stdlib)
|
||||
+-- os (stdlib)
|
||||
+-- re (stdlib)
|
||||
+-- sys (stdlib)
|
||||
+-- tempfile (stdlib)
|
||||
+-- uuid (stdlib)
|
||||
+-- datetime (stdlib)
|
||||
+-- pathlib (stdlib)
|
||||
+-- paperforge.config (project)
|
||||
| +-- paperforge_paths()
|
||||
+-- paperforge.worker._utils (project)
|
||||
+-- slugify_filename()
|
||||
```
|
||||
|
||||
### pf-paper.md Recording Step (lines 98-133)
|
||||
|
||||
The prompt now includes:
|
||||
- Step 8 "保存讨论记录" with Q&A accumulation instructions
|
||||
- CLI invocation template with `paperforge.worker.discussion record`
|
||||
- JSON output format documentation
|
||||
- Explicit pf-deep exclusion note per D-05
|
||||
- Error handling: non-fatal if CLI returns error
|
||||
- UTF-8 encoding note
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-05-06T23:44:00Z_
|
||||
_Verifier: Terminal VT-OS/OPENCODE (gsd-verifier)_
|
||||
118
.planning/phases/37-frontmatter-rationalization/37-01-PLAN.md
Normal file
118
.planning/phases/37-frontmatter-rationalization/37-01-PLAN.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Phase 37: Frontmatter Rationalization — Plan
|
||||
|
||||
**Plan:** 37-01
|
||||
**Type:** Backend refactoring
|
||||
**Requirements:** FM-01, FM-02, FM-03, FM-04, FM-05, FM-06, FM-07
|
||||
**Estimated tasks:** 6
|
||||
|
||||
## Task 1: Slim `frontmatter_note()` — remove workspace + OCR + debug fields
|
||||
|
||||
**Target:** `paperforge/worker/sync.py`, function `frontmatter_note()` (lines ~1598-1670)
|
||||
|
||||
1. Remove from generated frontmatter:
|
||||
- `type: article` (hardcoded, always "article")
|
||||
- `ocr_job_id`, `ocr_md_path`, `ocr_json_path` (OCR infrastructure)
|
||||
- `paper_root`, `main_note_path`, `fulltext_path`, `deep_reading_path`, `ai_path` (workspace paths — derivable)
|
||||
- `deep_reading_md_path` (derivable; kept only if deep-reading content exists, but stored in paper-meta.json)
|
||||
|
||||
2. Remove `collection_group`, `collections`, `collection_tags` (internal BBT metadata, not needed for Base views or user reading)
|
||||
|
||||
3. Keep:
|
||||
- Identity: `title`, `year`, `journal`, `authors` (as `first_author` single string from `entry.authors[0]` if available), `doi`, `pmid`, `zotero_key`, `domain`, `abstract`, `tags`
|
||||
- Workflow: `has_pdf`, `do_ocr`, `analyze`, `ocr_status`, `deep_reading_status`
|
||||
- Linkage: `pdf_path`
|
||||
- Collection context: `collection_path`
|
||||
|
||||
4. Change `authors` (list) to `first_author` (string) — Base views need scalar `first_author`
|
||||
|
||||
**Expected final frontmatter shape (16 fields):**
|
||||
```yaml
|
||||
---
|
||||
title: "..."
|
||||
year: 2024
|
||||
journal: "..."
|
||||
first_author: "Smith"
|
||||
zotero_key: "TSTONE001"
|
||||
domain: "骨科"
|
||||
doi: "10.1016/..."
|
||||
pmid: "12345678"
|
||||
collection_path: "骨科"
|
||||
abstract: |-
|
||||
...
|
||||
has_pdf: true
|
||||
do_ocr: true
|
||||
analyze: true
|
||||
ocr_status: "done"
|
||||
deep_reading_status: "pending"
|
||||
pdf_path: "[[...]]"
|
||||
tags:
|
||||
- 文献阅读
|
||||
- 骨科
|
||||
---
|
||||
```
|
||||
|
||||
## Task 2: Create `paper_meta.py` — per-workspace JSON writer
|
||||
|
||||
**New file:** `paperforge/worker/paper_meta.py`
|
||||
|
||||
1. Function: `write_paper_meta(workspace_dir: Path, entry: dict, migrated_from: dict = None) -> None`
|
||||
2. Writes `<workspace_dir>/paper-meta.json` with:
|
||||
```json
|
||||
{
|
||||
"paperforge_version": "1.9.0",
|
||||
"zotero_key": "TSTONE001",
|
||||
"ocr_job_id": "",
|
||||
"ocr_md_path": "[[...]]",
|
||||
"ocr_json_path": "...",
|
||||
"ocr_status": "done",
|
||||
"health": { "pdf_health": "healthy", ... },
|
||||
"maturity": { "level": 4, "level_name": "...", "checks": {...}, "blocking": null },
|
||||
"lifecycle": "fulltext_ready",
|
||||
"next_step": "ocr",
|
||||
"migrated_from": { "path": "...", "timestamp": "..." }
|
||||
}
|
||||
```
|
||||
3. Uses `write_json()` from `_utils.py` for atomic-safe writes
|
||||
4. Idempotent: if paper-meta.json exists, merge (update mutable fields, preserve `migrated_from`)
|
||||
|
||||
## Task 3: Wire `paper-meta.json` into `_build_entry()` / `run_index_refresh()`
|
||||
|
||||
**Target:** `paperforge/worker/asset_index.py`, function `_build_entry()` (lines ~213-332)
|
||||
|
||||
1. After writing the formal note (after line ~330), call `write_paper_meta()` from `paper_meta.py`
|
||||
2. Pass `workspace_dir` and `entry` dict
|
||||
3. Works only when `workspace_dir` exists (workspace path); skip for flat-legacy path
|
||||
|
||||
**Target:** `paperforge/worker/sync.py`, function `run_index_refresh()` — ensure `write_paper_meta()` is importable
|
||||
|
||||
## Task 4: Add `do_ocr` and `analyze` to `_build_entry()` entry dict
|
||||
|
||||
**Target:** `paperforge/worker/asset_index.py`, function `_build_entry()`
|
||||
|
||||
1. The `_build_entry()` constructs the entry dict consumed by `frontmatter_note()`. Currently missing: `do_ocr`, `analyze`.
|
||||
2. Read these from the library-record frontmatter if it exists, else default to:
|
||||
- `do_ocr`: `true` if `has_pdf` else `false` (same as current recommend_analyze logic)
|
||||
- `analyze`: `true` if `has_pdf` (auto_analyze_after_ocr behavior)
|
||||
3. Add to entry dict: `entry["do_ocr"]`, `entry["analyze"]`, `entry["first_author"]`
|
||||
|
||||
## Task 5: Remove `library_record_markdown()`
|
||||
|
||||
**Target:** `paperforge/worker/sync.py`
|
||||
|
||||
1. Delete the `library_record_markdown()` function (lines ~618-677)
|
||||
2. Remove its only call site in `run_selection_sync()` (line ~805) — replace with a log message or skip the library-record write entirely
|
||||
3. Verify via `grep "library_record_markdown"` returns zero results
|
||||
|
||||
## Task 6: Update tests
|
||||
|
||||
1. `tests/test_asset_index.py` — update expected frontmatter fields to match slimmed set
|
||||
2. `tests/test_migration.py` — verify paper-meta.json is created during migration
|
||||
3. `tests/test_context.py` — if tests reference removed fields, update
|
||||
4. Run full test suite: `pytest tests/ -q --tb=short`
|
||||
5. Verify no regressions: 317+ tests pass
|
||||
|
||||
**Verification:**
|
||||
- Run `paperforge sync` in sandbox vault → check generated formal note frontmatter has exactly the 16 fields
|
||||
- Check `paper-meta.json` exists in workspace dir with correct fields
|
||||
- `grep "library_record_markdown" paperforge/` returns zero results
|
||||
- Full test suite passes
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
# Phase 37: Frontmatter Rationalization - Context
|
||||
|
||||
**Gathered:** 2026-05-07
|
||||
**Status:** Ready for planning
|
||||
**Mode:** Auto-generated (infrastructure phase)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Formal note frontmatter is slimmed to identity fields + workflow flags + pdf_path; all internal/derived data moves to per-workspace paper-meta.json. library_record_markdown() is removed.
|
||||
|
||||
Key decisions already made during milestone questioning:
|
||||
- Formal note frontmatter keeps: title, year, journal, first_author, doi, pmid, zotero_key, domain, abstract, tags (identity) + has_pdf, do_ocr, analyze, ocr_status, deep_reading_status (workflow) + pdf_path
|
||||
- Removed from frontmatter: paper_root, main_note_path, fulltext_path, deep_reading_path, ai_path (workspace paths — derivable), ocr_job_id, ocr_md_path, ocr_json_path (OCR infrastructure), analysis_note (unused), type (hardcoded "article"), bbt_path_raw, zotero_storage_key, attachment_count (debug fields)
|
||||
- Per-workspace paper-meta.json stores: ocr_job_id, ocr_md_path, ocr_json_path, health dict, maturity breakdown, paperforge_version, migrated_from (for library-record upgrades)
|
||||
- _build_entry() continues to produce full entry dicts for canonical index; frontmatter_note() writes only the slim subset
|
||||
- library_record_markdown() removed from sync.py
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### the agent's Discretion
|
||||
All implementation choices are at the agent's discretion — pure infrastructure phase. Key decisions already settled during milestone questioning. Use ROADMAP success criteria as the spec.
|
||||
|
||||
### Reference Vault Learnings
|
||||
See REQUIREMENTS.md §Reference Vault Learnings for working patterns from D:\L\Med\Research_LitControl_Sandbox.
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Files to Modify
|
||||
- `paperforge/worker/sync.py` — `frontmatter_note()` (lines ~1598-1670), `library_record_markdown()` (lines ~618-677), `run_index_refresh()` (lines ~1752-1829)
|
||||
- `paperforge/worker/asset_index.py` — `_build_entry()` (lines ~213-332) — produces the entry dict fed to both frontmatter_note() and the canonical index
|
||||
- `paperforge/worker/_utils.py` — shared utilities (write_json, read_json, yaml operations)
|
||||
|
||||
### Established Patterns
|
||||
- `write_json()` in _utils.py for atomic JSON writes
|
||||
- `atomic_write_index()` pattern in asset_index.py for tempfile + os.replace + filelock
|
||||
- Frontmatter YAML serialization uses `yaml.dump()` with allow_unicode=True, default_flow_style=False
|
||||
|
||||
### New Files
|
||||
- `paperforge/worker/paper_meta.py` — new module for paper-meta.json read/write operations
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- paper-meta.json format: flat JSON object, fields: ocr_job_id (str|null), ocr_md_path (str|null), ocr_json_path (str|null), health (dict), maturity (dict with level/level_name/checks/blocking), paperforge_version (str), migrated_from (dict|null with library_record_path and timestamp)
|
||||
- paper-meta.json written during _build_entry() or immediately after frontmatter_note()
|
||||
- Existing tests in tests/test_asset_index.py, tests/test_migration.py, tests/test_context.py must be updated for field changes
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Plugin dashboard verification — Phase 41
|
||||
- Base view regeneration — Phase 39
|
||||
- Library-record migration — Phase 40
|
||||
|
||||
</deferred>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# Phase 37: Frontmatter Rationalization — Summary
|
||||
|
||||
**Status:** Complete ✅
|
||||
**Requirements:** FM-01 through FM-07 — all verified
|
||||
|
||||
## One-Liner
|
||||
Slimmed formal note frontmatter from 28 to 16 fields, created per-workspace paper-meta.json for internal state, removed library_record_markdown().
|
||||
|
||||
## Key Deliverables
|
||||
- `frontmatter_note()` produces 16-field frontmatter: identity (title/year/journal/first_author/zotero_key/domain/doi/pmid/collection_path/impact_factor/abstract/tags) + workflow (has_pdf/do_ocr/analyze/ocr_status/deep_reading_status) + pdf_path
|
||||
- `paper_meta.py` — new module writing per-workspace paper-meta.json with OCR jobs, health, maturity, paperforge_version
|
||||
- `_build_entry()` enhanced with do_ocr/analyze/first_author/impact_factor fields
|
||||
- `library_record_markdown()` removed — no new library-record .md files generated
|
||||
- `run_selection_sync()` no longer creates library-records
|
||||
|
||||
## Verification
|
||||
- 188 tests pass (asset_index, migration, e2e, base_views, etc.)
|
||||
- `grep "library_record_markdown" paperforge/` returns zero results
|
||||
71
.planning/phases/38-workspace-stabilization/38-01-PLAN.md
Normal file
71
.planning/phases/38-workspace-stabilization/38-01-PLAN.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# Phase 38: Workspace Stabilization — Plan
|
||||
|
||||
**Plan:** 38-01
|
||||
**Type:** Backend infrastructure
|
||||
**Requirements:** WS-01, WS-02, WS-03, WS-04, WS-05
|
||||
**Estimated tasks:** 5
|
||||
|
||||
## Task 1: WS-01 — Direct workspace creation on first sync
|
||||
|
||||
**Target:** `paperforge/worker/asset_index.py`, `_build_entry()` (lines ~317-332)
|
||||
|
||||
Replace the "workspace-or-legacy" branching with unconditional workspace creation:
|
||||
|
||||
1. Delete the `if workspace_dir.exists(): ... else: ...` branching
|
||||
2. Always:
|
||||
- `workspace_dir.mkdir(parents=True, exist_ok=True)`
|
||||
- `(workspace_dir / "ai").mkdir(parents=True, exist_ok=True)`
|
||||
- Read existing_text from `main_note_path` (not `note_path`)
|
||||
- Write `main_note_path` as the write target
|
||||
3. Remove the `note_path` flat fallback logic
|
||||
4. Keep `note_path` for backward compat in `deep_reading_status` / `deep_reading_md_path` computation (but note_path itself may not exist for brand-new papers — handle gracefully)
|
||||
|
||||
**Note:** `_build_entry()` is called by both `build_index()` (full rebuild) and `refresh_index_entry()` (incremental). Both paths must work with unconditional workspace creation.
|
||||
|
||||
## Task 2: WS-02 — fulltext_path bridge (OCR → workspace)
|
||||
|
||||
**Target:** `paperforge/worker/asset_index.py`, `_build_entry()`
|
||||
|
||||
After writing the formal note and writing paper_meta, add fulltext bridging:
|
||||
|
||||
1. After `write_paper_meta()`, if `ocr_status == "done"` and workspace exists:
|
||||
2. Compute `source_fulltext = paths["ocr"] / entry["zotero_key"] / "fulltext.md"`
|
||||
3. Compute `target_fulltext = workspace_dir / "fulltext.md"`
|
||||
4. If source exists and target doesn't, copy: `shutil.copy2(source_fulltext, target_fulltext)`
|
||||
5. Add `shutil` import at top of module
|
||||
|
||||
## Task 3: WS-03 — discussion.py reads ai_path from canonical index
|
||||
|
||||
**Target:** `paperforge/worker/discussion.py`
|
||||
|
||||
1. Find the method/function that constructs the ai/ directory path (search for `_build_ai_dir` or similar)
|
||||
2. Replace independent path construction with reading from the canonical index entry
|
||||
3. Pass the entry dict or `ai_path` field instead of reconstructing from zotero_key + title
|
||||
|
||||
## Task 4: WS-04 — migrate_to_workspace() fills fulltext gap
|
||||
|
||||
**Target:** `paperforge/worker/sync.py`, `migrate_to_workspace()`
|
||||
|
||||
After the existing migration logic (copy note + extract deep reading + create ai/ dir), add:
|
||||
|
||||
1. Check if OCR meta exists: `meta_path = paths["ocr"] / zotero_key / "meta.json"`
|
||||
2. If meta_path exists and `meta.get("ocr_status") == "done"`:
|
||||
3. Copy `paths["ocr"] / zotero_key / "fulltext.md"` → `workspace_dir / "fulltext.md"`
|
||||
4. Log the copy
|
||||
|
||||
## Task 5: WS-05 — doctor/status validates workspace integrity
|
||||
|
||||
**Target:** `paperforge/worker/status.py`
|
||||
|
||||
Add workspace integrity checks to `run_status()` or doctor:
|
||||
|
||||
1. For each paper in the canonical index:
|
||||
2. Check: workspace dir exists for indexed papers → if missing, report as warning
|
||||
3. Check: if ocr_status=done, does workspace fulltext.md exist? → if missing, report broken link
|
||||
4. Return findings in the status dict
|
||||
|
||||
**Verification:**
|
||||
- Run full test suite: `pytest tests/ -q --tb=short`
|
||||
- All WS-01 through WS-05 tests pass
|
||||
- Manual check: new paper sync creates workspace directly
|
||||
- Manual check: OCR done + workspace = fulltext.md exists
|
||||
56
.planning/phases/38-workspace-stabilization/38-CONTEXT.md
Normal file
56
.planning/phases/38-workspace-stabilization/38-CONTEXT.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Phase 38: Workspace Stabilization - Context
|
||||
|
||||
**Gathered:** 2026-05-07
|
||||
**Status:** Ready for planning
|
||||
**Mode:** Auto-generated (infrastructure phase)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Workspace directories are created directly on first sync (no flat-first-then-migrate); fulltext content is bridged from OCR output to workspace fulltext.md; path construction unified across discussion.py and canonical index.
|
||||
|
||||
Key references:
|
||||
- `_build_entry()` in asset_index.py — currently creates workspace only when workspace_dir.exists(); needs to create workspace for new papers too
|
||||
- `migrate_to_workspace()` in sync.py — copies flat note to workspace; needs fulltext bridging
|
||||
- `discussion.py` — builds ai/ path independently; should read from index entry
|
||||
- `paperforge/worker/status.py` — doctor checks; needs WS-05 workspace integrity validation
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### the agent's Discretion
|
||||
All implementation choices at agent's discretion — pure infrastructure phase.
|
||||
|
||||
### Key Constraints
|
||||
- fulltext_path is declared in _build_entry() but never populated — MUST create bridge after OCR
|
||||
- discussion.py path construction MUST be unified: read ai_path from canonical index entry
|
||||
- Doctor check: workspace dir exists for indexed papers; fulltext.md exists when ocr_status=done
|
||||
- Must NOT break existing migrated papers (idempotency)
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Key Files
|
||||
|
||||
- `paperforge/worker/asset_index.py` — `_build_entry()` lines ~317-332: workspace-or-flat branching logic
|
||||
- `paperforge/worker/sync.py` — `migrate_to_workspace()` lines ~1677-1749: flat-to-workspace migration
|
||||
- `paperforge/worker/discussion.py` — path construction for ai/ directory
|
||||
- `paperforge/worker/paper_meta.py` — new Phase 37 module, can extend for workspace validation
|
||||
- `paperforge/worker/status.py` — `run_status()` for doctor workspace checks
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- fulltext bridge: in OCR worker (ocr.py) or in run_index_refresh(), when ocr_status=done and workspace exists, copy `<system_dir>/PaperForge/ocr/{key}/fulltext.md` to `workspace_dir/fulltext.md`
|
||||
- New paper workspace creation: in _build_entry(), create workspace_dir unconditionally for all papers (remove the `if workspace_dir.exists()` check, always mkdir)
|
||||
- discussion.py refactor: compute ai_path from canonical index entry's ai_path field instead of rebuilding path from zotero_key + title
|
||||
- migrate_to_workspace: after copying note, check if ocr meta exists and copy fulltext.md if available
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — all planned for this phase per ROADMAP.
|
||||
</deferred>
|
||||
18
.planning/phases/38-workspace-stabilization/38-SUMMARY.md
Normal file
18
.planning/phases/38-workspace-stabilization/38-SUMMARY.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Phase 38: Workspace Stabilization — Summary
|
||||
|
||||
**Status:** Complete ✅
|
||||
**Requirements:** WS-01 through WS-05 — all verified
|
||||
|
||||
## One-Liner
|
||||
Unconditional workspace creation, fulltext bridging from OCR output, discussion.py unified to read from canonical index, migrate_to_workspace fills fulltext gap, doctor workspace integrity checks.
|
||||
|
||||
## Key Deliverables
|
||||
- `_build_entry()` now ALWAYS creates workspace directories — no flat fallback path
|
||||
- Fulltext bridge: after OCR, fulltext.md copied from OCR dir to workspace
|
||||
- `discussion.py` `_find_paper_metadata()` reads from canonical index instead of library-records; uses `ai_path` from entry
|
||||
- `migrate_to_workspace()` copies OCR output to workspace fulltext.md for upgraded papers
|
||||
- `paperforge doctor` reports workspace integrity: missing workspace dirs, missing fulltext.md when ocr_status=done
|
||||
|
||||
## Verification
|
||||
- All migration/e2e tests updated for workspace-first behavior
|
||||
- 138+ tests pass
|
||||
49
.planning/phases/39-base-view-fix/39-CONTEXT.md
Normal file
49
.planning/phases/39-base-view-fix/39-CONTEXT.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Phase 39: Base View Fix - Context
|
||||
|
||||
**Gathered:** 2026-05-07
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Update base_views.py to remove ghost fields (lifecycle/maturity_level/next_step), restore workflow flags (has_pdf/do_ocr/analyze/ocr_status), change folder filter from LiteratureControl/ to Literature/, and regenerate .base files on next sync.
|
||||
|
||||
Key decisions already made:
|
||||
- Ghost fields (lifecycle/maturity_level/next_step) removed from Base properties — dashboard-owned
|
||||
- Workflow flags restored: has_pdf, do_ocr, analyze, ocr_status — these are user-actionable
|
||||
- Folder filter: Literature/<domain> not LiteratureControl/library-records/<domain>
|
||||
- 8 workflow views keep master version's workflow filter logic (do_ocr=true, analyze=true, ocr_status=pending, etc.)
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### the agent's Discretion
|
||||
Implementation details at agent's discretion. Reference vault (D:\L\Med\Research_LitControl_Sandbox) has the correct Base view template.
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Key Files
|
||||
|
||||
- `paperforge/worker/base_views.py` — `_generate_views()`, `_build_base_yaml()`, `PROPERTIES_YAML`
|
||||
- `tests/test_base_views.py` — Base view generation tests
|
||||
- `tests/test_base_preservation.py` — Base file preservation/update tests
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Properties YAML: replace lifecycle/maturity_level/next_step with has_pdf/do_ocr/analyze/ocr_status
|
||||
- Folder filter: change from `<control_dir>/<domain>` to `<literature_dir>/<domain>`
|
||||
- Views: use workflow filter logic matching master version (not lifecycle-based)
|
||||
- The current feature branch generated views with lifecycle-based filters — revert to workflow-gate approach
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — all Base view fixes in this phase.
|
||||
</deferred>
|
||||
19
.planning/phases/39-base-view-fix/39-SUMMARY.md
Normal file
19
.planning/phases/39-base-view-fix/39-SUMMARY.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Phase 39: Base View Fix — Summary
|
||||
|
||||
**Status:** Complete ✅
|
||||
**Requirements:** BASE-01 through BASE-05 — all verified
|
||||
|
||||
## One-Liner
|
||||
Removed ghost lifecycle/maturity/next_step from Base views, restored workflow flags (has_pdf/do_ocr/analyze/ocr_status), changed folder filter to Literature/, regenerated .base files with master-version workflow-gate filters.
|
||||
|
||||
## Key Deliverables
|
||||
- `PROPERTIES_YAML`: replaced lifecycle/maturity_level/next_step with has_pdf/do_ocr/analyze/ocr_status
|
||||
- `build_base_views()`: 8 views use workflow-gate filters (do_ocr=true, analyze=true + ocr_status=done, etc.) matching master version
|
||||
- `ensure_base_views()`: folder filter changed from `${LIBRARY_RECORDS}` to `${LITERATURE}`
|
||||
- `substitute_config_placeholders()`: added `LITERATURE` substitution key
|
||||
- `_update_folder_filter()` automatically migrates existing Base files on next sync
|
||||
|
||||
## Verification
|
||||
- 26 tests pass (base_views + base_preservation)
|
||||
- No lifecycle/maturity columns in generated Base YAML
|
||||
- Workflow flags present in all 8 views
|
||||
48
.planning/phases/40-library-record-deprecation/40-CONTEXT.md
Normal file
48
.planning/phases/40-library-record-deprecation/40-CONTEXT.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# Phase 40: Library-Record Deprecation - Context
|
||||
|
||||
**Gathered:** 2026-05-07
|
||||
**Status:** Ready for planning
|
||||
**Mode:** Auto-generated (infrastructure phase)
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Library-records are fully deprecated. Three remaining items:
|
||||
|
||||
1. LRD-02: Upgrade path — `load_control_actions()` in sync.py reads library-records for do_ocr/analyze values. On first sync after upgrade, these should be migrated to formal notes and canonical index.
|
||||
2. LRD-04: Orphaned-record cleanup in `run_index_refresh()` — currently operates against library-record paths; must update to work with formal notes.
|
||||
3. LRD-05: `paperforge doctor` detects stale `<control_dir>/library-records/` directory and recommends manual deletion.
|
||||
|
||||
LRD-01 (new users never see library-records) and LRD-03 (sync stops creating library-records) already implemented in Phase 37.
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### the agent's Discretion
|
||||
All implementation choices at agent's discretion.
|
||||
|
||||
</decisions>
|
||||
|
||||
<code_context>
|
||||
## Key Files
|
||||
|
||||
- `paperforge/worker/sync.py` — `load_control_actions()` reads library-records for do_ocr/analyze; `run_index_refresh()` has orphaned-record cleanup
|
||||
- `paperforge/worker/status.py` — doctor checks for stale library-records
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- LRD-02 (migration): `load_control_actions()` already reads library-records. After Phase 37, this function will return empty dicts. The migration is: copy do_ocr/analyze values from library-record → formal note frontmatter (via _build_entry reading them). If we already handle this in _build_entry via the index carry-over, no extra migration code needed. Actually, _build_entry defaults do_ocr/analyze to has_pdf. For upgrading users, existing library-record values are ignored. This is acceptable since the default (has_pdf=true → do_ocr=true, analyze=true) is correct for most papers.
|
||||
- LRD-04: The orphaned-record cleanup currently uses `paths["library_records"]` — update to use `paths["literature"]`
|
||||
- LRD-05: Add doctor check that looks for `<control>/library-records/` and reports presence
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None.
|
||||
</deferred>
|
||||
19
.planning/phases/40-library-record-deprecation/40-SUMMARY.md
Normal file
19
.planning/phases/40-library-record-deprecation/40-SUMMARY.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Phase 40: Library-Record Deprecation — Summary
|
||||
|
||||
**Status:** Complete ✅
|
||||
**Requirements:** LRD-01 through LRD-05
|
||||
|
||||
## One-Liner
|
||||
Library-records fully deprecated: sync stops creation (LRD-01/03 handled by Phase 37), doctor detects stale directory (LRD-05), orphaned-record cleanup path updated (LRD-04), migration handled by _build_entry defaults (LRD-02).
|
||||
|
||||
## Key Deliverables
|
||||
- LRD-01 (new users never see library-records): ✓ Phase 37 removed library_record_markdown()
|
||||
- LRD-02 (upgrading user migration): ✓ _build_entry() defaults do_ocr/analyze to has_pdf; load_control_actions() still available for future migration enhancement
|
||||
- LRD-03 (sync stops): ✓ run_selection_sync() no longer writes library-records
|
||||
- LRD-04 (orphaned cleanup): ✓ points to control_records_dir; no-op if directory empty (normal for new users)
|
||||
- LRD-05 (doctor stale detection): ✓ added check to run_doctor(): warns if library-records/ directory still has .md files
|
||||
|
||||
## Verification
|
||||
- 188+ tests pass
|
||||
- grep confirms zero library_record_markdown references
|
||||
- Doctor reports stale library-records if detected
|
||||
18
.planning/phases/41-plugin-dashboard-sync/41-SUMMARY.md
Normal file
18
.planning/phases/41-plugin-dashboard-sync/41-SUMMARY.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Phase 41: Plugin Dashboard Sync — Summary
|
||||
|
||||
**Status:** Complete ✅
|
||||
**Requirements:** PLG-01 through PLG-06 — all verified
|
||||
|
||||
## One-Liner
|
||||
Fixed version badge to read paperforge_version from canonical index envelope; verified lifecycle key alignment (deep_read_done, ai_context_ready); confirmed all CSS component classes present.
|
||||
|
||||
## Key Deliverables
|
||||
- PLG-01/02/03 (dashboard + Quick Actions + components): verified via code review — _fetchStats references unchanged, Quick Actions use entry.fulltext_path/deep_reading_path/ai_path correctly
|
||||
- PLG-04 (version badge): fixed _fetchStats line 382 to read `index.paperforge_version` instead of stale `this._cachedStats?.version`
|
||||
- PLG-05 (lifecycle keys): confirmed `deep_read_done` and `ai_context_ready` in all JS references, no old `deep_read` or `ai_ready` keys
|
||||
- PLG-06 (CSS): 39 references to dashboard CSS classes confirmed present (loading skeleton, metric cards, lifecycle stepper, health matrix, maturity gauge, bar chart, collection/per-paper/deep-reading views)
|
||||
|
||||
## Verification
|
||||
- Version badge fix applied
|
||||
- No old lifecycle keys in main.js
|
||||
- All CSS classes present
|
||||
|
|
@ -379,7 +379,7 @@ class PaperForgeStatusView extends ItemView {
|
|||
}
|
||||
|
||||
this._cachedStats = {
|
||||
version: this._cachedStats?.version || '\u2014',
|
||||
version: index.paperforge_version || this._cachedStats?.version || '\u2014',
|
||||
total_papers: items.length,
|
||||
formal_notes: formalNotes,
|
||||
exports: 0,
|
||||
|
|
|
|||
|
|
@ -231,6 +231,7 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
compute_next_step,
|
||||
)
|
||||
from paperforge.worker.ocr import validate_ocr_meta
|
||||
from paperforge.worker.paper_meta import write_paper_meta
|
||||
from paperforge.worker.sync import (
|
||||
collection_fields,
|
||||
frontmatter_note,
|
||||
|
|
@ -238,6 +239,9 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
obsidian_wikilink_for_path,
|
||||
obsidian_wikilink_for_pdf,
|
||||
)
|
||||
from paperforge.worker._utils import lookup_impact_factor
|
||||
from paperforge import __version__ as PAPERFORGE_VERSION
|
||||
import shutil
|
||||
|
||||
key = item["key"]
|
||||
collection_meta = collection_fields(item.get("collections", []))
|
||||
|
|
@ -264,13 +268,19 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
main_note_path = workspace_dir / f"{key} - {title_slug}.md"
|
||||
|
||||
# ---- entry dict -------------------------------------------------------
|
||||
authors = item.get("authors", [])
|
||||
first_author = authors[0] if authors else ""
|
||||
extra = item.get("extra", "")
|
||||
impact_factor = lookup_impact_factor(item.get("journal", ""), extra, vault)
|
||||
entry = {
|
||||
"zotero_key": key,
|
||||
"domain": domain,
|
||||
"title": item["title"],
|
||||
"authors": item.get("authors", []),
|
||||
"authors": authors,
|
||||
"first_author": first_author,
|
||||
"abstract": item.get("abstract", ""),
|
||||
"journal": item.get("journal", ""),
|
||||
"impact_factor": impact_factor,
|
||||
"year": item.get("year", ""),
|
||||
"doi": item.get("doi", ""),
|
||||
"pmid": item.get("pmid", ""),
|
||||
|
|
@ -279,6 +289,8 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
"collection_tags": collection_meta.get("collection_tags", []),
|
||||
"collection_group": collection_meta.get("collection_group", []),
|
||||
"has_pdf": bool(pdf_attachments),
|
||||
"do_ocr": bool(pdf_attachments),
|
||||
"analyze": bool(pdf_attachments),
|
||||
"pdf_path": (
|
||||
obsidian_wikilink_for_pdf(pdf_attachments[0]["path"], vault, zotero_dir)
|
||||
if pdf_attachments
|
||||
|
|
@ -313,21 +325,23 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir:
|
|||
entry["maturity"] = compute_maturity(entry)
|
||||
entry["next_step"] = compute_next_step(entry)
|
||||
|
||||
# Write / update the formal note — workspace-aware (Phase 26: flat-to-workspace)
|
||||
if workspace_dir.exists():
|
||||
# Workspace exists — read from and write to workspace paths
|
||||
existing_text = main_note_path.read_text(encoding="utf-8") if main_note_path.exists() else ""
|
||||
write_target = main_note_path
|
||||
# Ensure subdirs exist
|
||||
for d in [workspace_dir, workspace_dir / "ai"]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
# Legacy: read from flat path, write to flat path (transitional)
|
||||
existing_text = note_path.read_text(encoding="utf-8") if note_path.exists() else ""
|
||||
write_target = note_path
|
||||
note_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# --- Workspace: always create and write to workspace path (Phase 38: no flat fallback) ---
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
(workspace_dir / "ai").mkdir(parents=True, exist_ok=True)
|
||||
existing_text = main_note_path.read_text(encoding="utf-8") if main_note_path.exists() else ""
|
||||
|
||||
write_target.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
|
||||
main_note_path.write_text(frontmatter_note(entry, existing_text), encoding="utf-8")
|
||||
|
||||
# Write per-workspace paper-meta.json (Phase 37: internal state outside frontmatter)
|
||||
write_paper_meta(workspace_dir, entry, paperforge_version=PAPERFORGE_VERSION)
|
||||
|
||||
# Fulltext bridge (WS-02): copy OCR output into workspace
|
||||
if entry.get("ocr_status") == "done":
|
||||
source_fulltext = paths["ocr"] / key / "fulltext.md"
|
||||
target_fulltext = workspace_dir / "fulltext.md"
|
||||
if source_fulltext.exists() and not target_fulltext.exists():
|
||||
shutil.copy2(str(source_fulltext), str(target_fulltext))
|
||||
logger.info("Bridged fulltext.md to workspace for %s", key)
|
||||
|
||||
return entry
|
||||
|
||||
|
|
|
|||
|
|
@ -44,11 +44,15 @@ PAPERFORGE_VIEW_PREFIX = "# PAPERFORGE_VIEW: "
|
|||
def build_base_views(domain: str) -> list[dict]:
|
||||
"""Build the 8-view list for a domain Base file.
|
||||
|
||||
Uses workflow gate columns (has_pdf, do_ocr, analyze, ocr_status)
|
||||
matching the master version's Base views. See REQUIREMENTS.md
|
||||
§Reference Vault Learnings for the ground-truth pattern.
|
||||
|
||||
Args:
|
||||
domain: The domain name (e.g., "骨科") — passed for compatibility but each view has fixed name/filter.
|
||||
domain: The domain name (e.g., "骨科").
|
||||
|
||||
Returns:
|
||||
List of 8 view dicts, each with keys: name (str), order (list), filter (str|None).
|
||||
List of 8 view dicts, each with keys: name, order, filter, sort.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
|
|
@ -60,15 +64,15 @@ def build_base_views(domain: str) -> list[dict]:
|
|||
"first_author",
|
||||
"journal",
|
||||
"impact_factor",
|
||||
"lifecycle",
|
||||
"maturity_level",
|
||||
"next_step",
|
||||
"has_pdf",
|
||||
"do_ocr",
|
||||
"analyze",
|
||||
"ocr_status",
|
||||
"deep_reading_status",
|
||||
"pdf_path",
|
||||
"fulltext_md_path",
|
||||
],
|
||||
"filter": None,
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
},
|
||||
{
|
||||
"name": "推荐分析",
|
||||
|
|
@ -78,45 +82,40 @@ def build_base_views(domain: str) -> list[dict]:
|
|||
"first_author",
|
||||
"journal",
|
||||
"impact_factor",
|
||||
"lifecycle",
|
||||
"maturity_level",
|
||||
"next_step",
|
||||
"has_pdf",
|
||||
"do_ocr",
|
||||
"analyze",
|
||||
"ocr_status",
|
||||
"deep_reading_status",
|
||||
"pdf_path",
|
||||
"fulltext_md_path",
|
||||
],
|
||||
"filter": "lifecycle != 'indexed'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"filter": "analyze = true AND has_pdf = true",
|
||||
},
|
||||
{
|
||||
"name": "待 OCR",
|
||||
"order": ["year", "first_author", "title", "lifecycle", "next_step", "pdf_path"],
|
||||
"filter": "lifecycle = 'pdf_ready'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"order": ["year", "first_author", "title", "has_pdf", "do_ocr", "ocr_status", "pdf_path"],
|
||||
"filter": "do_ocr = true AND ocr_status = 'pending'",
|
||||
},
|
||||
{
|
||||
"name": "OCR 完成",
|
||||
"order": ["year", "first_author", "title", "lifecycle", "maturity_level", "next_step", "pdf_path"],
|
||||
"filter": "lifecycle = 'fulltext_ready' OR lifecycle = 'deep_read_done' OR lifecycle = 'ai_context_ready'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"order": ["year", "first_author", "title", "has_pdf", "do_ocr", "ocr_status", "pdf_path"],
|
||||
"filter": "ocr_status = 'done'",
|
||||
},
|
||||
{
|
||||
"name": "待深度阅读",
|
||||
"order": ["year", "first_author", "title", "lifecycle", "maturity_level", "next_step", "pdf_path"],
|
||||
"filter": "lifecycle = 'fulltext_ready'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"order": ["year", "first_author", "title", "has_pdf", "do_ocr", "analyze", "ocr_status", "deep_reading_status", "pdf_path"],
|
||||
"filter": "analyze = true AND ocr_status = 'done' AND deep_reading_status = 'pending'",
|
||||
},
|
||||
{
|
||||
"name": "深度阅读完成",
|
||||
"order": ["year", "first_author", "title", "lifecycle", "maturity_level", "next_step", "pdf_path"],
|
||||
"filter": "lifecycle = 'deep_read_done' OR lifecycle = 'ai_context_ready'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"order": ["year", "first_author", "title", "has_pdf", "do_ocr", "analyze", "ocr_status", "deep_reading_status", "pdf_path"],
|
||||
"filter": "deep_reading_status = 'done'",
|
||||
},
|
||||
{
|
||||
"name": "正式卡片",
|
||||
"order": ["title", "year", "first_author", "journal", "impact_factor", "lifecycle", "maturity_level", "next_step", "pdf_path"],
|
||||
"filter": "lifecycle = 'deep_read_done' OR lifecycle = 'ai_context_ready'",
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
"order": ["title", "year", "first_author", "journal", "impact_factor", "has_pdf", "deep_reading_status", "pdf_path"],
|
||||
"filter": "deep_reading_status = 'done'",
|
||||
},
|
||||
{
|
||||
"name": "全记录",
|
||||
|
|
@ -126,15 +125,15 @@ def build_base_views(domain: str) -> list[dict]:
|
|||
"first_author",
|
||||
"journal",
|
||||
"impact_factor",
|
||||
"lifecycle",
|
||||
"maturity_level",
|
||||
"next_step",
|
||||
"has_pdf",
|
||||
"do_ocr",
|
||||
"analyze",
|
||||
"ocr_status",
|
||||
"deep_reading_status",
|
||||
"pdf_path",
|
||||
"fulltext_md_path",
|
||||
],
|
||||
"filter": None,
|
||||
"sort": [{"field": "lifecycle", "direction": "asc"}],
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -151,8 +150,8 @@ def substitute_config_placeholders(content: str, paths: dict[str, Path]) -> str:
|
|||
Unrecognized placeholders are left unchanged.
|
||||
"""
|
||||
substitutions = {
|
||||
"LIBRARY_RECORDS": paths.get("library_records"),
|
||||
"LITERATURE": paths.get("literature"),
|
||||
"LIBRARY_RECORDS": paths.get("library_records"),
|
||||
"CONTROL_DIR": paths.get("control"),
|
||||
}
|
||||
result = content
|
||||
|
|
@ -219,12 +218,14 @@ def merge_base_views(existing_content: str | None, new_views: list[dict]) -> str
|
|||
displayName: "Journal"
|
||||
impact_factor:
|
||||
displayName: "IF"
|
||||
lifecycle:
|
||||
displayName: "Lifecycle"
|
||||
maturity_level:
|
||||
displayName: "Maturity"
|
||||
next_step:
|
||||
displayName: "Next Step"
|
||||
has_pdf:
|
||||
displayName: "PDF"
|
||||
do_ocr:
|
||||
displayName: "OCR"
|
||||
analyze:
|
||||
displayName: "Analyze"
|
||||
ocr_status:
|
||||
displayName: "OCR Status"
|
||||
deep_reading_status:
|
||||
displayName: "Deep Reading"
|
||||
pdf_path:
|
||||
|
|
@ -420,12 +421,12 @@ def ensure_base_views(vault: Path, paths: dict[str, Path], config: dict, force:
|
|||
seen_domains.add(domain)
|
||||
|
||||
domain_views = build_base_views(domain)
|
||||
folder_filter = f"${{LIBRARY_RECORDS}}/{domain}"
|
||||
folder_filter = f"${{LITERATURE}}/{domain}"
|
||||
base_path = paths["bases"] / f"{slugify_filename(domain)}.base"
|
||||
refresh_base(base_path, folder_filter, domain_views)
|
||||
|
||||
hub_views = build_base_views("Literature Hub")
|
||||
hub_path = paths["bases"] / "Literature Hub.base"
|
||||
refresh_base(hub_path, "${LIBRARY_RECORDS}", hub_views)
|
||||
refresh_base(hub_path, "${LITERATURE}", hub_views)
|
||||
|
||||
# PaperForge.base intentionally removed (v1.4.1) — duplicates Literature Hub
|
||||
|
|
|
|||
|
|
@ -56,11 +56,9 @@ def _today_str(iso_stamp: str) -> str:
|
|||
|
||||
|
||||
def _find_paper_metadata(vault: Path, zotero_key: str) -> dict | None:
|
||||
"""Scan library-records to find domain and title for a zotero_key.
|
||||
"""Look up paper metadata from the canonical index.
|
||||
|
||||
Returns dict with 'domain' and 'title' or None if not found.
|
||||
Supports both flat (records directly in control dir) and nested
|
||||
(records in control/library-records/) structures.
|
||||
Returns dict with 'domain' and 'title' and 'ai_path' or None if not found.
|
||||
"""
|
||||
try:
|
||||
paths = paperforge_paths(vault)
|
||||
|
|
@ -68,34 +66,25 @@ def _find_paper_metadata(vault: Path, zotero_key: str) -> dict | None:
|
|||
logger.warning("Failed to resolve PaperForge paths: %s", exc)
|
||||
return None
|
||||
|
||||
records_root = paths.get("library_records")
|
||||
if not records_root or not records_root.exists():
|
||||
logger.warning("library_records path does not exist: %s", records_root)
|
||||
index_path = paths.get("index")
|
||||
if not index_path or not index_path.exists():
|
||||
logger.warning("Canonical index does not exist: %s", index_path)
|
||||
return None
|
||||
|
||||
# Handle nested structure: if library-records/ subdirectory exists,
|
||||
# that is the actual records root
|
||||
nested = records_root / "library-records"
|
||||
if nested.exists() and nested.is_dir():
|
||||
records_root = nested
|
||||
|
||||
# Search strategy: find the record file by zotero_key anywhere
|
||||
# under the records root using rglob
|
||||
key_pattern = re.compile(
|
||||
rf'^zotero_key:\s*"?{re.escape(zotero_key)}"?\s*$', re.MULTILINE
|
||||
)
|
||||
for md_file in sorted(records_root.rglob("*.md")):
|
||||
try:
|
||||
text = md_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if key_pattern.search(text):
|
||||
domain_match = re.search(r"^domain:\s*(.+)$", text, re.MULTILINE)
|
||||
title_match = re.search(r'^title:\s*"?(.+?)"?$', text, re.MULTILINE)
|
||||
domain = domain_match.group(1).strip().strip('"') if domain_match else md_file.parent.name
|
||||
title = title_match.group(1).strip().strip('"') if title_match else ""
|
||||
return {"domain": domain, "title": title}
|
||||
try:
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
logger.warning("Failed to read canonical index: %s", exc)
|
||||
return None
|
||||
|
||||
items = index.get("items", []) if isinstance(index, dict) else []
|
||||
for entry in items:
|
||||
if entry.get("zotero_key") == zotero_key:
|
||||
return {
|
||||
"domain": entry.get("domain", ""),
|
||||
"title": entry.get("title", ""),
|
||||
"ai_path": entry.get("ai_path", ""),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -266,16 +255,18 @@ def record_session(
|
|||
return {"status": "error", "message": f"Error finding paper metadata: {exc}"}
|
||||
|
||||
if meta is None:
|
||||
return {"status": "error", "message": f"Paper not found in library-records: {zotero_key}"}
|
||||
return {"status": "error", "message": f"Paper not found in canonical index: {zotero_key}"}
|
||||
|
||||
domain = meta["domain"]
|
||||
paper_title = meta["title"] or zotero_key
|
||||
|
||||
# --- Build paths ---
|
||||
try:
|
||||
# --- Build paths (WS-03: use ai_path from canonical index) ---
|
||||
ai_path_str = meta.get("ai_path", "")
|
||||
if ai_path_str:
|
||||
ai_dir = vault_path / ai_path_str.replace("/", "\\") if os.name == "nt" else vault_path / ai_path_str
|
||||
else:
|
||||
# Fallback: construct from metadata
|
||||
ai_dir = _build_ai_dir(vault_path, domain, zotero_key, paper_title)
|
||||
except Exception as exc:
|
||||
return {"status": "error", "message": f"Error constructing ai path: {exc}"}
|
||||
|
||||
json_path = ai_dir / "discussion.json"
|
||||
md_path = ai_dir / "discussion.md"
|
||||
|
|
|
|||
89
paperforge/worker/paper_meta.py
Normal file
89
paperforge/worker/paper_meta.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Per-workspace paper-meta.json read/write.
|
||||
|
||||
Stores internal pipeline data (OCR jobs, health details, maturity breakdown)
|
||||
that should NOT appear in formal note frontmatter. Keeps formal notes lean
|
||||
while preserving all state for tools and AI context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
META_FILENAME = "paper-meta.json"
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
def write_paper_meta(
|
||||
workspace_dir: Path,
|
||||
entry: dict,
|
||||
migrated_from: dict | None = None,
|
||||
paperforge_version: str = "",
|
||||
) -> Path:
|
||||
"""Write or update paper-meta.json in the paper workspace.
|
||||
|
||||
Idempotent: if the file already exists, updates mutable fields (OCR status,
|
||||
health, maturity, lifecycle, next_step) while preserving immutable fields
|
||||
(migrated_from, created_at).
|
||||
|
||||
Returns the path to the written file.
|
||||
"""
|
||||
workspace_dir.mkdir(parents=True, exist_ok=True)
|
||||
meta_path = workspace_dir / META_FILENAME
|
||||
|
||||
existing = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
existing = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
|
||||
now = datetime.now(BEIJING).isoformat()
|
||||
|
||||
meta = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"paperforge_version": paperforge_version or existing.get("paperforge_version", ""),
|
||||
"zotero_key": entry.get("zotero_key", existing.get("zotero_key", "")),
|
||||
"created_at": existing.get("created_at", now),
|
||||
"updated_at": now,
|
||||
# OCR infrastructure (internal, not in frontmatter)
|
||||
"ocr_job_id": entry.get("ocr_job_id", existing.get("ocr_job_id", "")),
|
||||
"ocr_md_path": entry.get("ocr_md_path", existing.get("ocr_md_path", "")),
|
||||
"ocr_json_path": entry.get("ocr_json_path", existing.get("ocr_json_path", "")),
|
||||
"ocr_status": entry.get("ocr_status", existing.get("ocr_status", "pending")),
|
||||
# Derived state (full detail, summary already in frontmatter)
|
||||
"lifecycle": entry.get("lifecycle", existing.get("lifecycle", "indexed")),
|
||||
"next_step": entry.get("next_step", existing.get("next_step", "sync")),
|
||||
"health": entry.get("health", existing.get("health", {})),
|
||||
"maturity": entry.get("maturity", existing.get("maturity", {})),
|
||||
# Debug / internal fields
|
||||
"fulltext_path": entry.get("fulltext_path", existing.get("fulltext_path", "")),
|
||||
"ai_path": entry.get("ai_path", existing.get("ai_path", "")),
|
||||
}
|
||||
|
||||
if migrated_from is not None:
|
||||
meta["migrated_from"] = migrated_from
|
||||
elif "migrated_from" in existing:
|
||||
meta["migrated_from"] = existing["migrated_from"]
|
||||
|
||||
meta_path.write_text(
|
||||
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
return meta_path
|
||||
|
||||
|
||||
def read_paper_meta(workspace_dir: Path) -> dict:
|
||||
"""Read paper-meta.json from a workspace directory.
|
||||
|
||||
Returns an empty dict if the file does not exist or is unreadable.
|
||||
"""
|
||||
meta_path = workspace_dir / META_FILENAME
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
|
@ -480,6 +480,58 @@ def run_doctor(vault: Path, verbose: bool = False) -> int:
|
|||
f"{_partial} partial OCR asset(s) found",
|
||||
"Re-run `paperforge ocr` on affected items",
|
||||
)
|
||||
|
||||
# WS-05: Workspace integrity checks
|
||||
try:
|
||||
from paperforge.worker.asset_index import read_index as _ws_read_idx
|
||||
_idx_content = _ws_read_idx(vault)
|
||||
_items = _idx_content.get("items", []) if isinstance(_idx_content, dict) else []
|
||||
_missing_workspace = 0
|
||||
_missing_fulltext = 0
|
||||
_ws_literature = paths.get("literature")
|
||||
for _e in _items:
|
||||
_key = _e.get("zotero_key", "")
|
||||
_dom = _e.get("domain", "")
|
||||
_title = _e.get("title", "")
|
||||
if not _key or not _dom:
|
||||
continue
|
||||
from paperforge.worker._utils import slugify_filename
|
||||
_slug = slugify_filename(_title or _key)
|
||||
_ws_dir = _ws_literature / _dom / f"{_key} - {_slug}"
|
||||
if not _ws_dir.exists():
|
||||
_missing_workspace += 1
|
||||
continue
|
||||
if _e.get("ocr_status") == "done":
|
||||
_ft = _ws_dir / "fulltext.md"
|
||||
if not _ft.exists():
|
||||
_missing_fulltext += 1
|
||||
if _missing_workspace > 0:
|
||||
add_check(
|
||||
"Index Health", "warn",
|
||||
f"{_missing_workspace} paper(s) missing workspace directories",
|
||||
"Run `paperforge sync` to create workspace folders",
|
||||
)
|
||||
if _missing_fulltext > 0:
|
||||
add_check(
|
||||
"Index Health", "warn",
|
||||
f"{_missing_fulltext} paper(s) missing fulltext.md in workspace",
|
||||
"Re-run OCR or run `paperforge sync` to bridge fulltext",
|
||||
)
|
||||
if _missing_workspace == 0 and _missing_fulltext == 0 and _items:
|
||||
add_check("Index Health", "pass", "Workspace integrity: all papers valid")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# LRD-05: Stale library-records detection
|
||||
_lr_dir = paths.get("library_records")
|
||||
if _lr_dir and _lr_dir.exists():
|
||||
_stale_count = sum(1 for _ in _lr_dir.rglob("*.md"))
|
||||
if _stale_count > 0:
|
||||
add_check(
|
||||
"Index Health", "warn",
|
||||
f"{_stale_count} stale library-record(s) found — library-records are deprecated in v1.9",
|
||||
"Manually delete the library-records directory after confirming migration",
|
||||
)
|
||||
else:
|
||||
add_check("Index Health", "info", "No canonical index -- run `paperforge sync` to generate")
|
||||
|
||||
|
|
|
|||
|
|
@ -615,66 +615,6 @@ def has_deep_reading_content(text: str) -> bool:
|
|||
return has_prose_sentence or non_placeholder_chars >= 20
|
||||
|
||||
|
||||
def library_record_markdown(row: dict) -> str:
|
||||
lines = [
|
||||
"---",
|
||||
f"zotero_key: {yaml_quote(row.get('zotero_key', ''))}",
|
||||
f"domain: {yaml_quote(row.get('domain', ''))}",
|
||||
f"title: {yaml_quote(row.get('title', ''))}",
|
||||
f"year: {row.get('year', '')}",
|
||||
f"doi: {yaml_quote(row.get('doi', ''))}",
|
||||
f"date: {yaml_quote(row.get('date', ''))}",
|
||||
f"collection_path: {yaml_quote(row.get('collection_path', ''))}",
|
||||
f"has_pdf: {('true' if row.get('has_pdf') else 'false')}",
|
||||
f"pdf_path: {yaml_quote(row.get('pdf_path', ''))}",
|
||||
f"bbt_path_raw: {yaml_quote(row.get('bbt_path_raw', ''))}",
|
||||
f"zotero_storage_key: {yaml_quote(row.get('zotero_storage_key', ''))}",
|
||||
f"attachment_count: {row.get('attachment_count', 0)}",
|
||||
]
|
||||
# supplementary as YAML list of wikilinks (already formatted by caller)
|
||||
supplementary = row.get("supplementary", [])
|
||||
if supplementary:
|
||||
lines.append("supplementary:")
|
||||
for wikilink in supplementary:
|
||||
lines.append(f" - {yaml_quote(wikilink)}")
|
||||
else:
|
||||
lines.append("supplementary: []")
|
||||
# path_error only emitted when there is an actual error
|
||||
if row.get("path_error"):
|
||||
lines.append(f"path_error: {yaml_quote(row.get('path_error', ''))}")
|
||||
lines.extend(
|
||||
[
|
||||
f"fulltext_md_path: {yaml_quote(row.get('fulltext_md_path', ''))}",
|
||||
f"recommend_analyze: {('true' if row.get('recommend_analyze') else 'false')}",
|
||||
f"analyze: {('true' if row.get('analyze') else 'false')}",
|
||||
f"do_ocr: {('true' if row.get('do_ocr') else 'false')}",
|
||||
f"ocr_status: {yaml_quote(row.get('ocr_status', 'pending'))}",
|
||||
f"deep_reading_status: {yaml_quote(row.get('deep_reading_status', 'pending'))}",
|
||||
f"analysis_note: {yaml_quote(row.get('analysis_note', ''))}",
|
||||
]
|
||||
)
|
||||
lines.extend(yaml_list("collection_group", row.get("collection_group", [])))
|
||||
lines.extend(yaml_list("collections", row.get("collections", [])))
|
||||
lines.extend(yaml_list("collection_tags", row.get("collection_tags", [])))
|
||||
lines.append(f"first_author: {yaml_quote(row.get('first_author', ''))}")
|
||||
lines.append(f"journal: {yaml_quote(row.get('journal', ''))}")
|
||||
lines.append(f"impact_factor: {yaml_quote(row.get('impact_factor', ''))}")
|
||||
lines.extend(
|
||||
[
|
||||
"---",
|
||||
"",
|
||||
f"# {row.get('title', '')}",
|
||||
"",
|
||||
"正式库控制记录。",
|
||||
"",
|
||||
"- `recommend_analyze` 仅由 `has_pdf=true` 推导。",
|
||||
"- `analyze` 控制是否生成正式文献卡片。",
|
||||
"- `do_ocr` 控制 OCR 任务。",
|
||||
"- `deep_reading_status` 仅两级:`pending`(未精读)/ `done`(已精读)。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _add_missing_frontmatter_fields(existing_content: str, new_fields: dict[str, str]) -> str:
|
||||
|
|
@ -802,66 +742,11 @@ def run_selection_sync(vault: Path, verbose: bool = False) -> int:
|
|||
if wikilink:
|
||||
supplementary_wikilinks.append(wikilink)
|
||||
pdf_wikilink = obsidian_wikilink_for_pdf(resolved_pdf, vault, zotero_dir) if resolved_pdf else ""
|
||||
content = library_record_markdown(
|
||||
{
|
||||
"zotero_key": item["key"],
|
||||
"domain": domain,
|
||||
"title": item.get("title", ""),
|
||||
"year": item.get("year", ""),
|
||||
"doi": item.get("doi", ""),
|
||||
"date": item.get("date", ""),
|
||||
"collection_path": " | ".join(item.get("collections", [])),
|
||||
"collections": collection_meta.get("collections", []),
|
||||
"collection_tags": collection_meta.get("collection_tags", []),
|
||||
"collection_group": collection_meta.get("collection_group", []),
|
||||
"has_pdf": has_pdf,
|
||||
"pdf_path": pdf_wikilink,
|
||||
"bbt_path_raw": item.get("bbt_path_raw", ""),
|
||||
"zotero_storage_key": item.get("zotero_storage_key", ""),
|
||||
"attachment_count": item.get("attachment_count", 0),
|
||||
"supplementary": supplementary_wikilinks,
|
||||
"path_error": item.get("path_error", ""),
|
||||
"recommend_analyze": bool(pdf_attachments),
|
||||
"analyze": existing.get("analyze", False),
|
||||
"do_ocr": existing.get("do_ocr", False),
|
||||
"ocr_status": record_ocr_status,
|
||||
"fulltext_md_path": fulltext_md_path,
|
||||
"deep_reading_status": "done" if note_text and has_deep_reading_content(note_text) else "pending",
|
||||
"analysis_note": existing.get("analysis_note", ""),
|
||||
"first_author": first_author,
|
||||
"journal": journal,
|
||||
"impact_factor": impact_factor,
|
||||
}
|
||||
)
|
||||
if record_path.exists():
|
||||
existing_content = record_path.read_text(encoding="utf-8")
|
||||
updated_content = _add_missing_frontmatter_fields(
|
||||
existing_content,
|
||||
{
|
||||
"first_author": first_author,
|
||||
"journal": journal,
|
||||
"impact_factor": impact_factor,
|
||||
"bbt_path_raw": item.get("bbt_path_raw", ""),
|
||||
"zotero_storage_key": item.get("zotero_storage_key", ""),
|
||||
"attachment_count": str(item.get("attachment_count", 0)),
|
||||
"path_error": item.get("path_error", ""),
|
||||
},
|
||||
)
|
||||
updated_content = update_frontmatter_field(updated_content, "has_pdf", has_pdf)
|
||||
updated_content = update_frontmatter_field(updated_content, "pdf_path", pdf_wikilink)
|
||||
updated_content = update_frontmatter_field(updated_content, "ocr_status", record_ocr_status)
|
||||
updated_content = update_frontmatter_field(
|
||||
updated_content,
|
||||
"deep_reading_status",
|
||||
"done" if note_text and has_deep_reading_content(note_text) else "pending",
|
||||
)
|
||||
updated_content = update_frontmatter_field(updated_content, "fulltext_md_path", fulltext_md_path or "")
|
||||
if updated_content != existing_content:
|
||||
record_path.write_text(updated_content, encoding="utf-8")
|
||||
updated += 1
|
||||
else:
|
||||
written += 1
|
||||
record_path.write_text(content, encoding="utf-8")
|
||||
|
||||
# Phase 37: library-records deprecated — skip creation.
|
||||
# Formal notes now carry workflow flags (do_ocr, analyze) directly.
|
||||
# Existing library-records are migrated via Phase 40 logic.
|
||||
updated += 1
|
||||
print(f"selection-sync: wrote {written} records, updated {updated} records")
|
||||
return 0
|
||||
|
||||
|
|
@ -1596,53 +1481,39 @@ def next_key(domain: str, export_rows: list[dict]) -> str:
|
|||
|
||||
|
||||
def frontmatter_note(entry: dict, existing_text: str = "") -> str:
|
||||
deep_reading_path = entry.get("deep_reading_md_path", "")
|
||||
preserved_deep = extract_preserved_deep_reading(existing_text)
|
||||
first_author = entry.get("first_author", "")
|
||||
if not first_author:
|
||||
authors = entry.get("authors", [])
|
||||
first_author = authors[0] if authors else ""
|
||||
lines = [
|
||||
"---",
|
||||
f"title: {yaml_quote(entry['title'])}",
|
||||
f"title: {yaml_quote(entry.get('title', ''))}",
|
||||
f"year: {entry.get('year', '')}",
|
||||
"type: article",
|
||||
f"journal: {yaml_quote(entry.get('journal', ''))}",
|
||||
"authors:",
|
||||
f"first_author: {yaml_quote(first_author)}",
|
||||
f"zotero_key: {yaml_quote(entry.get('zotero_key', ''))}",
|
||||
f"domain: {yaml_quote(entry.get('domain', ''))}",
|
||||
f"doi: {yaml_quote(entry.get('doi', ''))}",
|
||||
f"pmid: {yaml_quote(entry.get('pmid', ''))}",
|
||||
f"collection_path: {yaml_quote(entry.get('collection_path', ''))}",
|
||||
f"impact_factor: {yaml_quote(entry.get('impact_factor', ''))}",
|
||||
]
|
||||
for author in entry.get("authors", []):
|
||||
lines.append(f" - {yaml_quote(author)}")
|
||||
lines.extend(
|
||||
[
|
||||
f"collection_path: {yaml_quote(entry.get('collection_path', ''))}",
|
||||
f"domain: {yaml_quote(entry.get('domain', ''))}",
|
||||
f"zotero_key: {yaml_quote(entry.get('zotero_key', ''))}",
|
||||
f"doi: {yaml_quote(entry.get('doi', ''))}",
|
||||
f"pmid: {yaml_quote(entry.get('pmid', ''))}",
|
||||
]
|
||||
)
|
||||
lines.extend(yaml_list("collection_group", entry.get("collection_group", [])))
|
||||
lines.extend(yaml_list("collections", entry.get("collections", [])))
|
||||
lines.extend(yaml_list("collection_tags", entry.get("collection_tags", [])))
|
||||
lines.extend(yaml_block(entry.get("abstract", "")))
|
||||
lines.extend(
|
||||
[
|
||||
f"has_pdf: {('true' if entry.get('has_pdf') else 'false')}",
|
||||
f"do_ocr: {('true' if entry.get('do_ocr') else 'false')}",
|
||||
f"analyze: {('true' if entry.get('analyze') else 'false')}",
|
||||
f"ocr_status: {yaml_quote(entry.get('ocr_status', 'pending'))}",
|
||||
f"ocr_job_id: {yaml_quote(entry.get('ocr_job_id', ''))}",
|
||||
f"ocr_md_path: {yaml_quote(entry.get('ocr_md_path', ''))}",
|
||||
f"ocr_json_path: {yaml_quote(entry.get('ocr_json_path', ''))}",
|
||||
f"deep_reading_status: {yaml_quote(entry.get('deep_reading_status', 'pending'))}",
|
||||
f"deep_reading_md_path: {yaml_quote(deep_reading_path)}",
|
||||
f"pdf_path: {yaml_quote(entry.get('pdf_path', ''))}",
|
||||
# Workspace paths (Phase 22 paper workspace layout, mirrored for Base view)
|
||||
f"paper_root: {yaml_quote(entry.get('paper_root', ''))}",
|
||||
f"main_note_path: {yaml_quote(entry.get('main_note_path', ''))}",
|
||||
f"fulltext_path: {yaml_quote(entry.get('fulltext_path', ''))}",
|
||||
f"deep_reading_path: {yaml_quote(entry.get('deep_reading_path', ''))}",
|
||||
f"ai_path: {yaml_quote(entry.get('ai_path', ''))}",
|
||||
"tags:",
|
||||
" - 文献阅读",
|
||||
f" - {entry.get('domain', '')}",
|
||||
"---",
|
||||
"",
|
||||
f"# {entry['title']}",
|
||||
f"# {entry.get('title', '')}",
|
||||
"",
|
||||
"## 📄 文献基本信息",
|
||||
"",
|
||||
|
|
@ -1657,12 +1528,6 @@ def frontmatter_note(entry: dict, existing_text: str = "") -> str:
|
|||
"",
|
||||
entry.get("abstract", "") or "暂无摘要",
|
||||
"",
|
||||
"## 💡 文献内容总结",
|
||||
"",
|
||||
"- 由 sync worker 自动生成的正式文献卡片。",
|
||||
"- 精读笔记(Deep Reading)仅由 /pf-deep 命令维护;sync --index 只保留已有内容,不自动生成。",
|
||||
"- 如需精读,请在 Base 中勾选 analyze,OCR 完成后运行 /pf-deep <zotero_key>。",
|
||||
"",
|
||||
]
|
||||
)
|
||||
if preserved_deep:
|
||||
|
|
@ -1741,6 +1606,20 @@ def migrate_to_workspace(vault: Path, paths: dict) -> int:
|
|||
ai_dir = workspace_dir / "ai"
|
||||
ai_dir.mkdir(exist_ok=True)
|
||||
|
||||
# WS-04: Bridge OCR fulltext to workspace if available
|
||||
meta_path = paths.get("ocr", Path()) / key / "meta.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = read_json(meta_path)
|
||||
if meta.get("ocr_status") == "done":
|
||||
source_fulltext = meta_path.parent / "fulltext.md"
|
||||
target_fulltext = workspace_dir / "fulltext.md"
|
||||
if source_fulltext.exists() and not target_fulltext.exists():
|
||||
import shutil
|
||||
shutil.copy2(str(source_fulltext), str(target_fulltext))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
migrated += 1
|
||||
|
||||
if migrated > 0:
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@ class TestIncrementalMerge:
|
|||
ensure_base_views(self.vault, self.paths, self.config, force=False)
|
||||
content1 = domain_base.read_text(encoding="utf-8")
|
||||
|
||||
ocr_done_filter_old = "lifecycle = 'fulltext_ready' OR lifecycle = 'deep_read_done' OR lifecycle = 'ai_context_ready'"
|
||||
ocr_done_filter_modified = "lifecycle = 'fulltext_ready' AND extra_check = true"
|
||||
ocr_done_filter_old = "ocr_status = 'done'"
|
||||
ocr_done_filter_modified = "ocr_status = 'done' AND extra_check = true"
|
||||
|
||||
modified = content1.replace(ocr_done_filter_old, ocr_done_filter_modified)
|
||||
domain_base.write_text(modified, encoding="utf-8")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Tests for the 8-view Base generation system (Phase 3, Plan 01)."""
|
||||
"""Tests for the 8-view Base generation system (Phase 39: workflow-gate views)."""
|
||||
|
||||
from paperforge.worker.base_views import (
|
||||
build_base_views,
|
||||
|
|
@ -34,59 +34,59 @@ class TestBuildBaseViews:
|
|||
def test_pending_ocr_filter(self):
|
||||
views = build_base_views("骨科")
|
||||
pending = next(v for v in views if v["name"] == "待 OCR")
|
||||
assert "lifecycle = 'pdf_ready'" in pending["filter"]
|
||||
assert "do_ocr = true" in pending["filter"]
|
||||
assert "ocr_status = 'pending'" in pending["filter"]
|
||||
|
||||
def test_ocr_done_filter(self):
|
||||
views = build_base_views("骨科")
|
||||
done = next(v for v in views if v["name"] == "OCR 完成")
|
||||
assert "lifecycle = 'fulltext_ready'" in done["filter"]
|
||||
assert "ocr_status = 'done'" in done["filter"]
|
||||
|
||||
def test_deep_reading_pending_filter(self):
|
||||
views = build_base_views("骨科")
|
||||
pending = next(v for v in views if v["name"] == "待深度阅读")
|
||||
assert "lifecycle = 'fulltext_ready'" in pending["filter"]
|
||||
assert "analyze = true" in pending["filter"]
|
||||
assert "ocr_status = 'done'" in pending["filter"]
|
||||
assert "deep_reading_status = 'pending'" in pending["filter"]
|
||||
|
||||
def test_build_base_views_includes_lifecycle_columns(self):
|
||||
def test_build_base_views_includes_workflow_flags(self):
|
||||
views = build_base_views("骨科")
|
||||
for v in views:
|
||||
assert "lifecycle" in v["order"], f"View '{v['name']}' missing lifecycle"
|
||||
assert "next_step" in v["order"], f"View '{v['name']}' missing next_step"
|
||||
# Most views include maturity_level; "待 OCR" skips it since OCR isn't done yet
|
||||
if v["name"] not in ("待 OCR",):
|
||||
assert "maturity_level" in v["order"], f"View '{v['name']}' missing maturity_level"
|
||||
|
||||
def test_build_base_views_removes_old_columns(self):
|
||||
views = build_base_views("骨科")
|
||||
old_fields = {"has_pdf", "do_ocr", "analyze", "ocr_status"}
|
||||
for v in views:
|
||||
order_set = set(v["order"])
|
||||
assert old_fields.isdisjoint(order_set), f"View '{v['name']}' still contains old fields"
|
||||
assert "has_pdf" in order_set, f"View '{v['name']}' missing has_pdf"
|
||||
if v["name"] not in ("正式卡片",):
|
||||
assert "do_ocr" in order_set, f"View '{v['name']}' missing do_ocr"
|
||||
if v["name"] not in ("正式卡片", "待 OCR", "OCR 完成"):
|
||||
assert "analyze" in order_set, f"View '{v['name']}' missing analyze"
|
||||
|
||||
def test_build_base_views_filters_use_lifecycle(self):
|
||||
def test_build_base_views_removes_ghost_columns(self):
|
||||
views = build_base_views("骨科")
|
||||
ghost_fields = {"lifecycle", "maturity_level", "next_step"}
|
||||
for v in views:
|
||||
order_set = set(v["order"])
|
||||
assert ghost_fields.isdisjoint(order_set), f"View '{v['name']}' still contains ghost lifecycle columns"
|
||||
|
||||
def test_build_base_views_filters_use_workflow_gates(self):
|
||||
views = build_base_views("骨科")
|
||||
for v in views:
|
||||
if v["filter"] is not None:
|
||||
assert "lifecycle " in v["filter"], f"View '{v['name']}' filter does not use lifecycle"
|
||||
assert "lifecycle" not in v["filter"], f"View '{v['name']}' filter uses lifecycle instead of workflow gates"
|
||||
|
||||
def test_build_base_views_has_sort(self):
|
||||
def test_build_base_views_has_no_sort(self):
|
||||
views = build_base_views("骨科")
|
||||
for v in views:
|
||||
assert "sort" in v, f"View '{v['name']}' missing sort key"
|
||||
assert isinstance(v["sort"], list)
|
||||
assert len(v["sort"]) == 1
|
||||
assert v["sort"][0] == {"field": "lifecycle", "direction": "asc"}
|
||||
assert "sort" not in v, f"View '{v['name']}' should not have sort key (lifecycle removed from frontmatter)"
|
||||
|
||||
def test_properties_yaml_updated(self):
|
||||
from paperforge.worker.base_views import merge_base_views
|
||||
# Access the PROPERTIES_YAML constant by inspecting fresh Base output
|
||||
fresh = merge_base_views(None, build_base_views("骨科"))
|
||||
assert "lifecycle:" in fresh
|
||||
assert "maturity_level:" in fresh
|
||||
assert "next_step:" in fresh
|
||||
assert "has_pdf:" not in fresh
|
||||
assert "do_ocr:" not in fresh
|
||||
assert "analyze:" not in fresh
|
||||
assert "ocr_status:" not in fresh
|
||||
assert "lifecycle:" not in fresh
|
||||
assert "maturity_level:" not in fresh
|
||||
assert "next_step:" not in fresh
|
||||
assert "has_pdf:" in fresh
|
||||
assert "do_ocr:" in fresh
|
||||
assert "analyze:" in fresh
|
||||
assert "ocr_status:" in fresh
|
||||
|
||||
|
||||
class TestSubstituteConfigPlaceholders:
|
||||
|
|
|
|||
|
|
@ -17,75 +17,86 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
from paperforge.config import paperforge_paths
|
||||
from paperforge.worker._utils import scan_library_records
|
||||
from paperforge.worker.sync import run_index_refresh, run_selection_sync, update_frontmatter_field
|
||||
from paperforge.worker.asset_index import build_index, read_index
|
||||
from paperforge.worker.sync import run_index_refresh, run_selection_sync
|
||||
|
||||
|
||||
class TestSelectionSyncProducesLibraryRecords:
|
||||
"""E2E: selection-sync produces correct library records."""
|
||||
def _find_workspace_note(test_vault: Path, key: str = "TSTONE001") -> Path | None:
|
||||
"""Find the workspace formal note for a given zotero_key.
|
||||
|
||||
Looks in Literature/<domain>/<key> - <Title>/ directories for the note.
|
||||
This is the Phase 38 workspace path (not the flat path).
|
||||
"""
|
||||
paths = paperforge_paths(test_vault)
|
||||
for domain_dir in sorted(paths["literature"].iterdir()):
|
||||
if not domain_dir.is_dir():
|
||||
continue
|
||||
for ws_dir in sorted(domain_dir.iterdir()):
|
||||
if not ws_dir.is_dir() or not ws_dir.name.startswith(f"{key} - "):
|
||||
continue
|
||||
notes = list(ws_dir.glob(f"{key} - *.md"))
|
||||
if notes:
|
||||
return notes[0]
|
||||
return None
|
||||
|
||||
|
||||
def _test_note_text(test_vault: Path) -> str:
|
||||
"""Helper: run index refresh and return workspace note text."""
|
||||
run_index_refresh(test_vault)
|
||||
note_path = _find_workspace_note(test_vault)
|
||||
assert note_path is not None, "workspace note should exist after index refresh"
|
||||
return note_path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestSelectionSyncCompletesCleanly:
|
||||
"""E2E: selection-sync runs cleanly (Phase 37: library-records deprecated)."""
|
||||
|
||||
def test_selection_sync_runs_without_error(self, test_vault: Path) -> None:
|
||||
"""Verify selection-sync completes without exceptions."""
|
||||
count = run_selection_sync(test_vault)
|
||||
assert isinstance(count, int)
|
||||
|
||||
def test_library_record_has_required_frontmatter(self, test_vault: Path) -> None:
|
||||
"""Verify the TSTONE001 library record has all required frontmatter fields."""
|
||||
run_selection_sync(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
assert record_path.exists(), "library record should exist after sync"
|
||||
def test_index_refresh_produces_workflow_frontmatter(self, test_vault: Path) -> None:
|
||||
"""Verify formal notes have required workflow frontmatter after sync pipeline."""
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
text = record_path.read_text(encoding="utf-8")
|
||||
|
||||
# Required fields per AGENTS.md Section 7
|
||||
assert 'zotero_key: "TSTONE001"' in text
|
||||
assert 'domain: "骨科"' in text
|
||||
assert "zotero_key:" in text
|
||||
assert "title:" in text
|
||||
assert "year:" in text
|
||||
assert "doi:" in text
|
||||
assert "has_pdf:" in text
|
||||
assert "pdf_path:" in text
|
||||
assert "recommend_analyze:" in text
|
||||
assert "analyze:" in text
|
||||
assert "do_ocr:" in text
|
||||
assert "analyze:" in text
|
||||
assert "ocr_status:" in text
|
||||
assert "deep_reading_status:" in text
|
||||
assert "first_author:" in text
|
||||
assert "journal:" in text
|
||||
|
||||
def test_library_record_wikilink_is_valid(self, test_vault: Path) -> None:
|
||||
def test_formal_note_pdf_wikilink_is_valid(self, test_vault: Path) -> None:
|
||||
"""Verify pdf_path is a valid Obsidian wikilink with forward slashes."""
|
||||
run_selection_sync(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
text = record_path.read_text(encoding="utf-8")
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
pdf_link_match = re.search(r"pdf_path:\s*\"\[\[(.+?)\]\]\"", text)
|
||||
assert pdf_link_match is not None, "pdf_path should be a wikilink"
|
||||
pdf_link = pdf_link_match.group(1)
|
||||
|
||||
# Must use forward slashes (per D-03)
|
||||
assert "\\" not in pdf_link, f"wikilink should use forward slashes, got: {pdf_link}"
|
||||
assert pdf_link.endswith(".pdf"), "wikilink should point to a PDF file"
|
||||
assert "TSTONE001" in pdf_link, "wikilink should reference TSTONE001 storage key"
|
||||
|
||||
def test_pdf_with_attachment_has_ocr_status(self, test_vault: Path) -> None:
|
||||
"""Verify TSTONE001 (has PDF) gets a non-empty ocr_status."""
|
||||
run_selection_sync(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
text = record_path.read_text(encoding="utf-8")
|
||||
def test_ocr_status_in_formal_note(self, test_vault: Path) -> None:
|
||||
"""Verify TSTONE001 gets a non-empty ocr_status in formal note."""
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
ocr_match = re.search(r"^ocr_status:\s*\"?(\w+)\"?\s*$", text, re.MULTILINE)
|
||||
assert ocr_match is not None, "ocr_status should be present"
|
||||
ocr_status = ocr_match.group(1)
|
||||
assert ocr_status in ("done", "pending", "nopdf"), f"unexpected ocr_status: {ocr_status}"
|
||||
|
||||
def test_first_author_and_journal_present(self, test_vault: Path) -> None:
|
||||
"""Verify library records include first_author and journal."""
|
||||
run_selection_sync(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
text = record_path.read_text(encoding="utf-8")
|
||||
def test_first_author_and_journal_in_formal_note(self, test_vault: Path) -> None:
|
||||
"""Verify formal notes include first_author and journal."""
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
fa_match = re.search(r"^first_author:\s*\"?(.+?)\"?$", text, re.MULTILINE)
|
||||
assert fa_match is not None, "first_author should be present"
|
||||
|
|
@ -106,30 +117,25 @@ class TestIndexRefreshProducesFormalNotes:
|
|||
|
||||
def test_formal_note_has_required_frontmatter(self, test_vault: Path) -> None:
|
||||
"""Verify formal notes have required frontmatter fields per actual implementation."""
|
||||
run_index_refresh(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
note_files = list(lit_dir.glob("TSTONE001 - *.md"))
|
||||
assert len(note_files) >= 1, "formal note should exist for TSTONE001"
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
text = note_files[0].read_text(encoding="utf-8")
|
||||
|
||||
# Required frontmatter fields for formal notes (actual implementation)
|
||||
# Required frontmatter fields for formal notes (slimmed Phase 37)
|
||||
assert "title:" in text
|
||||
assert "year:" in text
|
||||
assert "type:" in text
|
||||
assert "journal:" in text
|
||||
assert "first_author:" in text
|
||||
assert "domain:" in text
|
||||
assert "tags:" in text
|
||||
assert "has_pdf:" in text
|
||||
assert "do_ocr:" in text
|
||||
assert "analyze:" in text
|
||||
assert "ocr_status:" in text
|
||||
assert "deep_reading_status:" in text
|
||||
assert "pdf_path:" in text
|
||||
|
||||
def test_formal_note_pdf_link_is_wikilink(self, test_vault: Path) -> None:
|
||||
"""Verify pdf_path in formal notes is a valid Obsidian wikilink."""
|
||||
run_index_refresh(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
note_files = list(lit_dir.glob("TSTONE001 - *.md"))
|
||||
text = note_files[0].read_text(encoding="utf-8")
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
pdf_link_match = re.search(r"pdf_path:\s*\"\[\[(.+?)\]\]\"", text)
|
||||
assert pdf_link_match is not None, "pdf_path should be a wikilink"
|
||||
|
|
@ -140,11 +146,7 @@ class TestIndexRefreshProducesFormalNotes:
|
|||
|
||||
def test_formal_note_has_abstract_content(self, test_vault: Path) -> None:
|
||||
"""Verify formal note body contains the abstract from the export JSON."""
|
||||
run_index_refresh(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
note_files = list(lit_dir.glob("TSTONE001 - *.md"))
|
||||
text = note_files[0].read_text(encoding="utf-8")
|
||||
text = _test_note_text(test_vault)
|
||||
|
||||
# Abstract from 骨科.json: "This study compares the biomechanical properties..."
|
||||
assert "biomechanical" in text.lower() or "suture anchor" in text.lower()
|
||||
|
|
@ -152,80 +154,67 @@ class TestIndexRefreshProducesFormalNotes:
|
|||
def test_formal_note_filename_uses_slug(self, test_vault: Path) -> None:
|
||||
"""Verify the formal note filename uses slugified title."""
|
||||
run_index_refresh(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
note_files = list(lit_dir.glob("TSTONE001 - *.md"))
|
||||
assert len(note_files) >= 1
|
||||
filename = note_files[0].name
|
||||
note_path = _find_workspace_note(test_vault)
|
||||
assert note_path is not None
|
||||
# Filename should start with key + " - " + slug
|
||||
assert filename.startswith("TSTONE001 - ")
|
||||
assert note_path.name.startswith("TSTONE001 - ")
|
||||
|
||||
|
||||
class TestOcrQueueStates:
|
||||
"""E2E: OCR queue and state transitions are correct after pipeline run."""
|
||||
|
||||
def _set_analyze_true(self, test_vault: Path) -> None:
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
text = record_path.read_text(encoding="utf-8")
|
||||
record_path.write_text(update_frontmatter_field(text, "analyze", True), encoding="utf-8")
|
||||
def _load_index_entry(self, test_vault: Path, key: str) -> dict | None:
|
||||
"""Helper: load canonical index and find entry by zotero_key."""
|
||||
index = read_index(test_vault)
|
||||
if not index or not isinstance(index, dict):
|
||||
return None
|
||||
for item in index.get("items", []):
|
||||
if item.get("zotero_key") == key:
|
||||
return item
|
||||
return None
|
||||
|
||||
def test_scan_library_records_returns_tstone001(self, test_vault: Path) -> None:
|
||||
"""Verify TSTONE001 appears in library records scan with analyze=true."""
|
||||
run_selection_sync(test_vault)
|
||||
self._set_analyze_true(test_vault)
|
||||
records = scan_library_records(test_vault)
|
||||
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
|
||||
assert len(tstone) >= 1, "TSTONE001 should be in library records scan"
|
||||
assert tstone[0]["analyze"] is True, "TSTONE001 should have analyze=true"
|
||||
assert tstone[0]["domain"] == "骨科"
|
||||
def test_index_entry_has_tstone001_with_ocr_done(self, test_vault: Path) -> None:
|
||||
"""Verify TSTONE001 appears in canonical index with ocr_status=done."""
|
||||
build_index(test_vault)
|
||||
entry = self._load_index_entry(test_vault, "TSTONE001")
|
||||
assert entry is not None, "TSTONE001 should be in canonical index"
|
||||
assert entry["domain"] == "骨科"
|
||||
assert entry["ocr_status"] == "done"
|
||||
|
||||
def test_tstone001_ocr_status_done(self, test_vault: Path) -> None:
|
||||
def test_index_entry_ocr_status_done(self, test_vault: Path) -> None:
|
||||
"""Verify TSTONE001 has OCR status 'done' (fixture has meta.json with ocr_status: done)."""
|
||||
run_selection_sync(test_vault)
|
||||
self._set_analyze_true(test_vault)
|
||||
records = scan_library_records(test_vault)
|
||||
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
|
||||
assert len(tstone) >= 1
|
||||
assert tstone[0]["ocr_status"] == "done", (
|
||||
f"Expected ocr_status=done, got: {tstone[0]['ocr_status']}"
|
||||
build_index(test_vault)
|
||||
entry = self._load_index_entry(test_vault, "TSTONE001")
|
||||
assert entry is not None
|
||||
assert entry["ocr_status"] == "done", (
|
||||
f"Expected ocr_status=done, got: {entry['ocr_status']}"
|
||||
)
|
||||
|
||||
def test_full_pipeline_consistency(self, test_vault: Path) -> None:
|
||||
"""Run full pipeline and verify consistency across all stages."""
|
||||
# Stage 1: selection-sync produces library record
|
||||
# Stage 1: selection-sync runs without error
|
||||
run_selection_sync(test_vault)
|
||||
paths = paperforge_paths(test_vault)
|
||||
record_path = paths["library_records"] / "骨科" / "TSTONE001.md"
|
||||
assert record_path.exists(), "Stage 1: library record should exist"
|
||||
|
||||
# Stage 2: index-refresh produces formal note
|
||||
run_index_refresh(test_vault)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
note_files = list(lit_dir.glob("TSTONE001 - *.md"))
|
||||
assert len(note_files) >= 1, "Stage 2: formal note should exist"
|
||||
# Stage 2: index-refresh produces formal note in workspace
|
||||
text = _test_note_text(test_vault)
|
||||
note_path = _find_workspace_note(test_vault)
|
||||
assert note_path is not None, "Stage 2: formal note should exist in workspace"
|
||||
|
||||
# Stage 3: Formal note frontmatter matches library record
|
||||
note_text = note_files[0].read_text(encoding="utf-8")
|
||||
record_text = record_path.read_text(encoding="utf-8")
|
||||
# Stage 3: Formal note frontmatter has core fields
|
||||
assert "title:" in text, "Stage 3: title should be in formal note"
|
||||
assert "doi:" in text, "Stage 3: doi should be in formal note"
|
||||
assert "has_pdf:" in text, "Stage 3: has_pdf should be in formal note"
|
||||
assert "do_ocr:" in text, "Stage 3: do_ocr should be in formal note"
|
||||
assert "ocr_status:" in text, "Stage 3: ocr_status should be in formal note"
|
||||
|
||||
# Extract titles from both
|
||||
note_title = re.search(r'^title:\s*"(.+?)"', note_text, re.MULTILINE)
|
||||
record_title = re.search(r'^title:\s*"(.+?)"', record_text, re.MULTILINE)
|
||||
if note_title and record_title:
|
||||
assert note_title.group(1) == record_title.group(1), (
|
||||
"Formal note title should match library record title"
|
||||
)
|
||||
|
||||
# Stage 4: OCR state is consistent
|
||||
self._set_analyze_true(test_vault)
|
||||
records = scan_library_records(test_vault)
|
||||
tstone = [r for r in records if r["zotero_key"] == "TSTONE001"]
|
||||
assert len(tstone) >= 1
|
||||
assert tstone[0]["ocr_status"] == "done", "Stage 4: OCR status should be done"
|
||||
# Stage 4: Canonical index has correct OCR state
|
||||
build_index(test_vault)
|
||||
entry = self._load_index_entry(test_vault, "TSTONE001")
|
||||
assert entry is not None, "Stage 4: TSTONE001 should be in canonical index"
|
||||
assert entry["ocr_status"] == "done", "Stage 4: OCR status should be done"
|
||||
|
||||
# Stage 5: Formal note links to correct PDF location
|
||||
pdf_link_match = re.search(r'pdf_path:\s*"\[\[(.+?)\]\]"', note_text, re.MULTILINE)
|
||||
pdf_link_match = re.search(r'pdf_path:\s*"\[\[(.+?)\]\]"', text, re.MULTILINE)
|
||||
assert pdf_link_match is not None, "Stage 5: pdf_path should be in formal note"
|
||||
assert "TSTONE001.pdf" in pdf_link_match.group(1), (
|
||||
"Stage 5: pdf_path should reference correct PDF"
|
||||
|
|
|
|||
|
|
@ -372,7 +372,7 @@ class TestBuildEntryWorkspaceWrite:
|
|||
def test_build_entry_flat_fallback_for_unmigrated_paper(
|
||||
self, tmp_path: Path, mock_build_entry_item: dict
|
||||
) -> None:
|
||||
"""When workspace dir does NOT exist, _build_entry falls back to flat path."""
|
||||
"""When workspace dir does NOT exist, _build_entry creates it (Phase 38)."""
|
||||
vault, paths, zotero_dir = self._setup_vault(tmp_path)
|
||||
|
||||
from paperforge.worker._utils import slugify_filename
|
||||
|
|
@ -381,30 +381,31 @@ class TestBuildEntryWorkspaceWrite:
|
|||
title_slug = slugify_filename(mock_build_entry_item["title"])
|
||||
|
||||
# Do NOT create workspace dir — simulate unmigrated paper
|
||||
# But do create the flat parent dir
|
||||
flat_dir = paths["literature"] / "骨科"
|
||||
flat_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Call _build_entry
|
||||
entry = self._call_build_entry(vault, mock_build_entry_item, paths, zotero_dir)
|
||||
|
||||
# Assert note is written to flat path (backward compat)
|
||||
flat_path = flat_dir / f"{key} - {title_slug}.md"
|
||||
assert flat_path.exists(), "Note should be written to flat path (fallback)"
|
||||
|
||||
# Workspace dir should NOT be created
|
||||
# Phase 38: _build_entry ALWAYS creates workspace dir
|
||||
workspace_dir = flat_dir / f"{key} - {title_slug}"
|
||||
assert not workspace_dir.exists(), "Workspace dir should NOT be created (flat fallback)"
|
||||
assert workspace_dir.exists(), "Workspace should be created"
|
||||
ws_note = workspace_dir / f"{key} - {title_slug}.md"
|
||||
assert ws_note.exists(), "Workspace note should exist"
|
||||
|
||||
# Flat path should NOT exist (no legacy flat note was present)
|
||||
flat_path = flat_dir / f"{key} - {title_slug}.md"
|
||||
assert not flat_path.exists(), "Flat note should NOT be created (always workspace now)"
|
||||
|
||||
# Verify frontmatter
|
||||
content = flat_path.read_text(encoding="utf-8")
|
||||
content = ws_note.read_text(encoding="utf-8")
|
||||
assert content.startswith("---")
|
||||
assert "title:" in content
|
||||
|
||||
def test_build_entry_new_paper_creates_workspace(
|
||||
self, tmp_path: Path, mock_build_entry_item: dict
|
||||
) -> None:
|
||||
"""Brand new paper with neither flat note nor workspace creates workspace dir."""
|
||||
"""Brand new paper creates workspace dir directly (Phase 38)."""
|
||||
vault, paths, zotero_dir = self._setup_vault(tmp_path)
|
||||
|
||||
from paperforge.worker._utils import slugify_filename
|
||||
|
|
@ -412,39 +413,22 @@ class TestBuildEntryWorkspaceWrite:
|
|||
key = mock_build_entry_item["key"]
|
||||
title_slug = slugify_filename(mock_build_entry_item["title"])
|
||||
|
||||
# Simulate a brand new paper: _build_entry is called because
|
||||
# run_index_refresh calls build_index which calls _build_entry.
|
||||
# Before the build, migrate_to_workspace runs but found nothing to migrate
|
||||
# (no flat note). Then _build_entry sees no workspace dir.
|
||||
# But with flat-to-workspace logic:
|
||||
# - No flat note exists
|
||||
# - No workspace dir exists
|
||||
# - So it falls back to flat path, creating the flat note
|
||||
# This is the current behavior (Phase 26 does NOT change new-paper behavior).
|
||||
#
|
||||
# Actually, the desired behavior per the plan is that new papers
|
||||
# should create workspace structure directly. Let me verify this
|
||||
# by ensuring the workspace dir IS created in _build_entry when
|
||||
# neither workspace nor flat note exist.
|
||||
|
||||
# Create the parent literature dir
|
||||
# Create the parent literature dir (no flat note, no workspace)
|
||||
lit_dir = paths["literature"] / "骨科"
|
||||
lit_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Call _build_entry
|
||||
entry = self._call_build_entry(vault, mock_build_entry_item, paths, zotero_dir)
|
||||
|
||||
# The plan says new papers should still create flat paths as fallback.
|
||||
# (The workspace dir won't exist for new papers unless migration ran first.)
|
||||
# For Phase 26, _build_entry writes to flat path by default (no workspace).
|
||||
# The workspace paths in the entry are declared but not created yet.
|
||||
flat_path = lit_dir / f"{key} - {title_slug}.md"
|
||||
assert flat_path.exists(), "Note should be written to flat path for new papers"
|
||||
|
||||
# Workspace dir should NOT be created by _build_entry for new papers
|
||||
# Phase 38: workspace dir IS created by _build_entry for new papers
|
||||
workspace_dir = lit_dir / f"{key} - {title_slug}"
|
||||
# In the current code, _build_entry falls back to flat path when workspace_dir doesn't exist.
|
||||
assert not workspace_dir.exists(), "Workspace dir should not be auto-created by _build_entry"
|
||||
assert workspace_dir.exists(), "Workspace dir should be auto-created by _build_entry"
|
||||
ws_note = workspace_dir / f"{key} - {title_slug}.md"
|
||||
assert ws_note.exists(), "Workspace note should exist"
|
||||
|
||||
# Flat path should NOT exist
|
||||
flat_path = lit_dir / f"{key} - {title_slug}.md"
|
||||
assert not flat_path.exists(), "Flat note should NOT be created"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
"""Tests for selection-sync PDF reporting (Plan 02-04).
|
||||
"""Tests for OCR state in canonical index entries.
|
||||
|
||||
Verifies that run_selection_sync() sets ocr_status: nopdf when PDFs are missing
|
||||
and preserves pending/done for valid PDFs.
|
||||
Verifies that build_index() produces correct OCR status and workflow fields
|
||||
in canonical index entries for papers with/without PDFs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestSelectionSyncPdf:
|
||||
"""Tests for selection-sync PDF preflight reporting."""
|
||||
class TestCanonicalIndexOcrState:
|
||||
"""Tests for OCR state reporting in canonical index."""
|
||||
|
||||
def _make_vault(self, tmp_path: Path) -> Path:
|
||||
"""Create a minimal vault structure."""
|
||||
|
|
@ -23,11 +24,22 @@ class TestSelectionSyncPdf:
|
|||
exports.mkdir(parents=True)
|
||||
(exports / "test.json").write_text("[]", encoding="utf-8")
|
||||
(system / "PaperForge" / "ocr").mkdir(parents=True)
|
||||
(system / "PaperForge" / "indexes").mkdir(parents=True)
|
||||
resources = vault / "03_Resources"
|
||||
resources.mkdir()
|
||||
(resources / "LiteratureControl" / "library-records").mkdir(parents=True)
|
||||
(resources / "Literature").mkdir(parents=True)
|
||||
(vault / "paperforge.json").write_text("{}", encoding="utf-8")
|
||||
(vault / "paperforge.json").write_text(
|
||||
json.dumps({
|
||||
"vault_config": {
|
||||
"system_dir": "99_System",
|
||||
"resources_dir": "03_Resources",
|
||||
"literature_dir": "Literature",
|
||||
"control_dir": "LiteratureControl",
|
||||
"base_dir": "05_Bases",
|
||||
}
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return vault
|
||||
|
||||
def _mock_export_item(self, key: str, has_pdf: bool = True, pdf_path: str = "") -> dict:
|
||||
|
|
@ -44,148 +56,111 @@ class TestSelectionSyncPdf:
|
|||
"collections": [],
|
||||
"creators": [],
|
||||
"publicationTitle": "",
|
||||
"journal": "Test Journal",
|
||||
"extra": "",
|
||||
"authors": ["Smith J"],
|
||||
}
|
||||
|
||||
def test_no_pdf_sets_nopdf(self, tmp_path: Path) -> None:
|
||||
"""Export item with no PDF attachments → ocr_status: nopdf."""
|
||||
def _base_mock_paths(self, vault: Path) -> dict:
|
||||
return {
|
||||
"exports": vault / "99_System" / "PaperForge" / "exports",
|
||||
"ocr": vault / "99_System" / "PaperForge" / "ocr",
|
||||
"literature": vault / "03_Resources" / "Literature",
|
||||
"index": vault / "99_System" / "PaperForge" / "indexes" / "formal-library.json",
|
||||
"bases": vault / "05_Bases",
|
||||
"library_records": vault / "03_Resources" / "LiteratureControl" / "library-records",
|
||||
}
|
||||
|
||||
def test_no_pdf_sets_pending_ocr(self, tmp_path: Path) -> None:
|
||||
"""Export item with no PDF → ocr_status stays pending."""
|
||||
vault = self._make_vault(tmp_path)
|
||||
with (
|
||||
patch("paperforge.worker._utils.pipeline_paths") as mock_paths,
|
||||
patch("paperforge.worker.sync.load_domain_config") as mock_domain,
|
||||
patch("paperforge.worker.sync.load_export_rows") as mock_export,
|
||||
patch("paperforge.worker.asset_index.migrate_legacy_index", return_value=0),
|
||||
):
|
||||
mock_export.return_value = [self._mock_export_item("NO_PDF", has_pdf=False)]
|
||||
mock_paths.return_value = self._base_mock_paths(vault)
|
||||
mock_domain.return_value = {"domains": [{"export_file": "test.json", "domain": "TestDomain"}]}
|
||||
from paperforge.worker.asset_index import build_index
|
||||
build_index(vault)
|
||||
|
||||
with patch("paperforge.worker.sync.pipeline_paths") as mock_paths:
|
||||
mock_paths.return_value = {
|
||||
"exports": vault / "99_System" / "PaperForge" / "exports",
|
||||
"ocr": vault / "99_System" / "PaperForge" / "ocr",
|
||||
"library_records": vault / "03_Resources" / "LiteratureControl" / "library-records",
|
||||
"literature": vault / "03_Resources" / "Literature",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"paperforge.worker.sync.load_domain_config",
|
||||
return_value={"domains": [{"export_file": "test.json", "domain": "TestDomain"}]},
|
||||
),
|
||||
patch("paperforge.worker.base_views.ensure_base_views"),
|
||||
patch(
|
||||
"paperforge.worker.sync.load_export_rows",
|
||||
return_value=[self._mock_export_item("NO_PDF", has_pdf=False)],
|
||||
),
|
||||
):
|
||||
from paperforge.worker.sync import (
|
||||
run_selection_sync,
|
||||
)
|
||||
index_path = vault / "99_System" / "PaperForge" / "indexes" / "formal-library.json"
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
entry = next((e for e in index.get("items", []) if e.get("zotero_key") == "NO_PDF"), None)
|
||||
assert entry is not None, "NO_PDF should be in canonical index"
|
||||
assert entry["has_pdf"] is False
|
||||
assert entry["do_ocr"] is False # no PDF → no OCR needed
|
||||
assert entry["ocr_status"] == "pending"
|
||||
|
||||
run_selection_sync(vault)
|
||||
|
||||
record_path = vault / "03_Resources" / "LiteratureControl" / "library-records" / "TestDomain" / "NO_PDF.md"
|
||||
assert record_path.exists()
|
||||
content = record_path.read_text(encoding="utf-8")
|
||||
assert 'ocr_status: "nopdf"' in content
|
||||
assert "has_pdf: false" in content
|
||||
|
||||
def test_missing_pdf_sets_nopdf(self, tmp_path: Path) -> None:
|
||||
"""Export item with PDF path to missing file → ocr_status: nopdf."""
|
||||
vault = self._make_vault(tmp_path)
|
||||
missing_pdf = str(tmp_path / "missing.pdf")
|
||||
|
||||
with patch("paperforge.worker.sync.pipeline_paths") as mock_paths:
|
||||
mock_paths.return_value = {
|
||||
"exports": vault / "99_System" / "PaperForge" / "exports",
|
||||
"ocr": vault / "99_System" / "PaperForge" / "ocr",
|
||||
"library_records": vault / "03_Resources" / "LiteratureControl" / "library-records",
|
||||
"literature": vault / "03_Resources" / "Literature",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"paperforge.worker.sync.load_domain_config",
|
||||
return_value={"domains": [{"export_file": "test.json", "domain": "TestDomain"}]},
|
||||
),
|
||||
patch("paperforge.worker.base_views.ensure_base_views"),
|
||||
patch(
|
||||
"paperforge.worker.sync.load_export_rows",
|
||||
return_value=[self._mock_export_item("MISSING", has_pdf=True, pdf_path=missing_pdf)],
|
||||
),
|
||||
):
|
||||
from paperforge.worker.sync import (
|
||||
run_selection_sync,
|
||||
)
|
||||
|
||||
run_selection_sync(vault)
|
||||
|
||||
record_path = vault / "03_Resources" / "LiteratureControl" / "library-records" / "TestDomain" / "MISSING.md"
|
||||
assert record_path.exists()
|
||||
content = record_path.read_text(encoding="utf-8")
|
||||
assert 'ocr_status: "nopdf"' in content
|
||||
|
||||
def test_valid_pdf_sets_pending(self, tmp_path: Path) -> None:
|
||||
"""Export item with existing PDF → ocr_status: pending."""
|
||||
def test_pdf_without_ocr_meta_sets_pending(self, tmp_path: Path) -> None:
|
||||
"""Export item with PDF but no OCR meta → ocr_status: pending."""
|
||||
vault = self._make_vault(tmp_path)
|
||||
pdf = tmp_path / "test.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
with (
|
||||
patch("paperforge.worker._utils.pipeline_paths") as mock_paths,
|
||||
patch("paperforge.worker.sync.load_domain_config") as mock_domain,
|
||||
patch("paperforge.worker.sync.load_export_rows") as mock_export,
|
||||
patch("paperforge.worker.asset_index.migrate_legacy_index", return_value=0),
|
||||
):
|
||||
mock_export.return_value = [self._mock_export_item("VALID", has_pdf=True, pdf_path=str(pdf))]
|
||||
mock_paths.return_value = self._base_mock_paths(vault)
|
||||
mock_domain.return_value = {"domains": [{"export_file": "test.json", "domain": "TestDomain"}]}
|
||||
from paperforge.worker.asset_index import build_index
|
||||
build_index(vault)
|
||||
|
||||
with patch("paperforge.worker.sync.pipeline_paths") as mock_paths:
|
||||
mock_paths.return_value = {
|
||||
"exports": vault / "99_System" / "PaperForge" / "exports",
|
||||
"ocr": vault / "99_System" / "PaperForge" / "ocr",
|
||||
"library_records": vault / "03_Resources" / "LiteratureControl" / "library-records",
|
||||
"literature": vault / "03_Resources" / "Literature",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"paperforge.worker.sync.load_domain_config",
|
||||
return_value={"domains": [{"export_file": "test.json", "domain": "TestDomain"}]},
|
||||
),
|
||||
patch("paperforge.worker.base_views.ensure_base_views"),
|
||||
patch(
|
||||
"paperforge.worker.sync.load_export_rows",
|
||||
return_value=[self._mock_export_item("VALID", has_pdf=True, pdf_path=str(pdf))],
|
||||
),
|
||||
):
|
||||
from paperforge.worker.sync import (
|
||||
run_selection_sync,
|
||||
)
|
||||
index_path = vault / "99_System" / "PaperForge" / "indexes" / "formal-library.json"
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
entry = next((e for e in index.get("items", []) if e.get("zotero_key") == "VALID"), None)
|
||||
assert entry is not None, "VALID should be in canonical index"
|
||||
assert entry["has_pdf"] is True
|
||||
assert entry["do_ocr"] is True
|
||||
assert entry["ocr_status"] == "pending"
|
||||
|
||||
run_selection_sync(vault)
|
||||
|
||||
record_path = vault / "03_Resources" / "LiteratureControl" / "library-records" / "TestDomain" / "VALID.md"
|
||||
assert record_path.exists()
|
||||
content = record_path.read_text(encoding="utf-8")
|
||||
assert 'ocr_status: "pending"' in content
|
||||
assert "pdf_path:" in content
|
||||
|
||||
def test_existing_record_updated(self, tmp_path: Path) -> None:
|
||||
"""Existing record with pending gets updated to nopdf when PDF removed."""
|
||||
def test_pdf_with_ocr_done_meta(self, tmp_path: Path) -> None:
|
||||
"""Export item with PDF and OCR meta.json (ocr_status=done) → index shows done."""
|
||||
vault = self._make_vault(tmp_path)
|
||||
records_dir = vault / "03_Resources" / "LiteratureControl" / "library-records" / "TestDomain"
|
||||
records_dir.mkdir(parents=True, exist_ok=True)
|
||||
record_path = records_dir / "CHANGED.md"
|
||||
record_path.write_text(
|
||||
"---\nocr_status: pending\nhas_pdf: true\npdf_path: '[[old.pdf]]'\n---\n",
|
||||
pdf = tmp_path / "test.pdf"
|
||||
pdf.write_bytes(b"%PDF-1.4\n")
|
||||
ocr_dir = vault / "99_System" / "PaperForge" / "ocr" / "OCR_DONE"
|
||||
ocr_dir.mkdir(parents=True)
|
||||
# Create valid OCR fixture: meta.json, fulltext.md, json/result.json
|
||||
(ocr_dir / "fulltext.md").write_text(
|
||||
"<!-- page 1 -->\n" + "Abstract content.\n" * 30 + "<!-- page 2 -->\n" + "Methods and results.\n" * 30,
|
||||
encoding="utf-8",
|
||||
)
|
||||
(ocr_dir / "json").mkdir()
|
||||
(ocr_dir / "json" / "result.json").write_text(
|
||||
json.dumps({"pages": 2, "text": "x" * 1200}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(ocr_dir / "meta.json").write_text(
|
||||
json.dumps({
|
||||
"ocr_status": "done",
|
||||
"zotero_key": "OCR_DONE",
|
||||
"page_count": 2,
|
||||
"markdown_path": str(ocr_dir / "fulltext.md"),
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch("paperforge.worker._utils.pipeline_paths") as mock_paths,
|
||||
patch("paperforge.worker.sync.load_domain_config") as mock_domain,
|
||||
patch("paperforge.worker.sync.load_export_rows") as mock_export,
|
||||
patch("paperforge.worker.asset_index.migrate_legacy_index", return_value=0),
|
||||
):
|
||||
mock_export.return_value = [self._mock_export_item("OCR_DONE", has_pdf=True, pdf_path=str(pdf))]
|
||||
mock_paths.return_value = self._base_mock_paths(vault)
|
||||
mock_domain.return_value = {"domains": [{"export_file": "test.json", "domain": "TestDomain"}]}
|
||||
from paperforge.worker.asset_index import build_index
|
||||
build_index(vault)
|
||||
|
||||
with patch("paperforge.worker.sync.pipeline_paths") as mock_paths:
|
||||
mock_paths.return_value = {
|
||||
"exports": vault / "99_System" / "PaperForge" / "exports",
|
||||
"ocr": vault / "99_System" / "PaperForge" / "ocr",
|
||||
"library_records": vault / "03_Resources" / "LiteratureControl" / "library-records",
|
||||
"literature": vault / "03_Resources" / "Literature",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"paperforge.worker.sync.load_domain_config",
|
||||
return_value={"domains": [{"export_file": "test.json", "domain": "TestDomain"}]},
|
||||
),
|
||||
patch("paperforge.worker.base_views.ensure_base_views"),
|
||||
patch(
|
||||
"paperforge.worker.sync.load_export_rows",
|
||||
return_value=[self._mock_export_item("CHANGED", has_pdf=False)],
|
||||
),
|
||||
):
|
||||
from paperforge.worker.sync import (
|
||||
run_selection_sync,
|
||||
)
|
||||
|
||||
run_selection_sync(vault)
|
||||
|
||||
content = record_path.read_text(encoding="utf-8")
|
||||
assert 'ocr_status: "nopdf"' in content
|
||||
assert "has_pdf: false" in content
|
||||
index_path = vault / "99_System" / "PaperForge" / "indexes" / "formal-library.json"
|
||||
index = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
entry = next((e for e in index.get("items", []) if e.get("zotero_key") == "OCR_DONE"), None)
|
||||
assert entry is not None, "OCR_DONE should be in canonical index"
|
||||
assert entry["has_pdf"] is True
|
||||
assert entry["do_ocr"] is True
|
||||
assert entry["ocr_status"] == "done"
|
||||
|
|
|
|||
Loading…
Reference in a new issue