mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
docs(v1.4): commit planning artifacts, test files, and phase summaries for phases 13-19
This commit is contained in:
parent
c9f31119ff
commit
2edb5b65cc
42 changed files with 5629 additions and 92 deletions
|
|
@ -8,7 +8,7 @@ v1.4 focuses on eliminating accumulated technical debt (~1,610 lines of code dup
|
|||
|
||||
## Completed Milestone: v1.3 Path Normalization & Architecture Hardening
|
||||
|
||||
**Status:** COMPLETE (2026-04-24)
|
||||
**Status:** COMPLETE (2026-04-24)
|
||||
**Archive:** `.planning/milestones/v1.3.md`
|
||||
|
||||
**Delivered:**
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ All 19 v1.3 requirements shipped and validated. See `.planning/milestones/v1.3.m
|
|||
|
||||
- [ ] **DX-01**: Create `.pre-commit-config.yaml` with hooks: `ruff` (lint + format), `check-yaml`, `check-toml`, `end-of-file-fixer`, `trailing-whitespace`, and a custom `consistency-audit` hook that runs `scripts/consistency_audit.py` and blocks commits if duplicate utility functions are detected in any worker module.
|
||||
- [ ] **DX-02**: Add `[tool.ruff]` section to `pyproject.toml` targeting Python 3.10+, enabling rules `E`, `F`, `I`, `UP`, `B`, `SIM`. Run `ruff check --fix` and `ruff format` across the entire codebase as part of the pre-commit activation phase.
|
||||
- [ ] **DX-03**: Create `CHANGELOG.md` following the Keep a Changelog format with sections for each version (v1.0 through v1.4). Include the changelog URL in `paperforge.json` update metadata.
|
||||
- [ ] **DX-04**: Create `CONTRIBUTING.md` documenting: development setup (`pip install -e ".[test]"`), pre-commit hook installation, test execution workflow, architecture overview, and code conventions (no new print() calls, import shared utilities from `_utils.py`, REQ-ID linking in commit messages).
|
||||
- [x] **DX-03**: Create `CHANGELOG.md` following the Keep a Changelog format with sections for each version (v1.0 through v1.4). Include the changelog URL in `paperforge.json` update metadata.
|
||||
- [x] **DX-04**: Create `CONTRIBUTING.md` documenting: development setup (`pip install -e ".[test]"`), pre-commit hook installation, test execution workflow, architecture overview, and code conventions (no new print() calls, import shared utilities from `_utils.py`, REQ-ID linking in commit messages).
|
||||
|
||||
### Testing (TEST)
|
||||
|
||||
|
|
@ -64,19 +64,19 @@ All 19 v1.3 requirements shipped and validated. See `.planning/milestones/v1.3.m
|
|||
|
||||
**Goal:** Smooth user-facing friction points identified in the codebase audit.
|
||||
|
||||
- [ ] **UX-01**: Add workflow streamlining options to `paperforge.json`: `auto_analyze_after_ocr` (bool, default `false`) — when enabled, after OCR completes for a paper, the library-record's `analyze` flag is automatically set to `true`. This is opt-in to preserve the two-layer Worker/Agent separation and user agency.
|
||||
- [ ] **UX-02**: Fix README.md rendering artifact: remove the orphaned legacy code snippet lines (`python <resolved_worker_script> --vault . ocr`, `python <resolved_worker_script> --vault . status`) at lines 102-104 that appear outside any code fence. Audit all user-facing docs (AGENTS.md, docs/*.md, command/*.md) for similar rendering issues.
|
||||
- [ ] **UX-03**: Generate a chart-reading guide cross-reference index (`chart-reading/INDEX.md`) that maps the 19 chart types to their reading guides, ordered by commonness in biomedical literature. The agent prompt (`prompt_deep_subagent.md`) references this index so AI agents can discover and cite relevant guides during Pass 2 figure-by-figure analysis.
|
||||
- [ ] **UX-04**: Audit the command naming boundary: ensure all user-facing documentation consistently maps `/pf-*` Agent commands to `paperforge *` CLI commands. Add a quick-reference table showing "What to type where" in AGENTS.md section 1.
|
||||
- [x] **UX-01**: Add workflow streamlining options to `paperforge.json`: `auto_analyze_after_ocr` (bool, default `false`) — when enabled, after OCR completes for a paper, the library-record's `analyze` flag is automatically set to `true`. This is opt-in to preserve the two-layer Worker/Agent separation and user agency.
|
||||
- [x] **UX-02**: Fix README.md rendering artifact: remove the orphaned legacy code snippet lines (`python <resolved_worker_script> --vault . ocr`, `python <resolved_worker_script> --vault . status`) at lines 102-104 that appear outside any code fence. Audit all user-facing docs (AGENTS.md, docs/*.md, command/*.md) for similar rendering issues.
|
||||
- [x] **UX-03**: Generate a chart-reading guide cross-reference index (`chart-reading/INDEX.md`) that maps the 19 chart types to their reading guides, ordered by commonness in biomedical literature. The agent prompt (`prompt_deep_subagent.md`) references this index so AI agents can discover and cite relevant guides during Pass 2 figure-by-figure analysis.
|
||||
- [x] **UX-04**: Audit the command naming boundary: ensure all user-facing documentation consistently maps `/pf-*` Agent commands to `paperforge *` CLI commands. Add a quick-reference table showing "What to type where" in AGENTS.md section 1.
|
||||
|
||||
### Documentation (DOCS)
|
||||
|
||||
**Goal:** Document the v1.4 changes for existing users and future maintainers.
|
||||
|
||||
- [ ] **DOCS-01**: Create `docs/MIGRATION-v1.4.md` following the established v1.2 migration document pattern. Document behavioral changes (dual-output logging, retry behavior, opt-in workflow streamlining), environment variable additions (`PAPERFORGE_LOG_LEVEL`, `PAPERFORGE_RETRY_MAX`, `PAPERFORGE_RETRY_BACKOFF`), and new developer workflow requirements (pre-commit hooks, ruff).
|
||||
- [ ] **DOCS-02**: Update `AGENTS.md` to reflect: new `--verbose` flag on CLI commands, new `paperforge.json` workflow options, new environment variables page, and the chart-reading INDEX.md cross-reference.
|
||||
- [ ] **DOCS-03**: Add ADR-012 (Shared Utilities Extraction) and ADR-013 (Dual-Output Logging Strategy) to `docs/ARCHITECTURE.md`, documenting the `_utils.py` leaf-module constraint and the print/logging boundary decision.
|
||||
- [ ] **DOCS-04**: Update `ROADMAP.md` to reflect v1.4 phase structure and mark all requirements as mapped after roadmap creation.
|
||||
- [x] **DOCS-01**: Create `docs/MIGRATION-v1.4.md` following the established v1.2 migration document pattern. Document behavioral changes (dual-output logging, retry behavior, opt-in workflow streamlining), environment variable additions (`PAPERFORGE_LOG_LEVEL`, `PAPERFORGE_RETRY_MAX`, `PAPERFORGE_RETRY_BACKOFF`), and new developer workflow requirements (pre-commit hooks, ruff).
|
||||
- [x] **DOCS-02**: Update `AGENTS.md` to reflect: new `--verbose` flag on CLI commands, new `paperforge.json` workflow options, new environment variables page, and the chart-reading INDEX.md cross-reference.
|
||||
- [x] **DOCS-03**: Add ADR-012 (Shared Utilities Extraction) and ADR-013 (Dual-Output Logging Strategy) to `docs/ARCHITECTURE.md`, documenting the `_utils.py` leaf-module constraint and the print/logging boundary decision.
|
||||
- [x] **DOCS-04**: Update `ROADMAP.md` to reflect v1.4 phase structure and mark all requirements as mapped after roadmap creation.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -129,20 +129,20 @@ Deferred from v1.4 scope to keep the milestone focused and manageable.
|
|||
| REL-04 | Phase 16 | Pending |
|
||||
| DX-01 | Phase 17 | Pending |
|
||||
| DX-02 | Phase 17 | Pending |
|
||||
| DX-03 | Phase 18 | Pending |
|
||||
| DX-04 | Phase 18 | Pending |
|
||||
| DX-03 | Phase 18 | Complete |
|
||||
| DX-04 | Phase 18 | Complete |
|
||||
| TEST-01 | Phase 19 | Pending |
|
||||
| TEST-02 | Phase 19 | Pending |
|
||||
| TEST-03 | Phase 14 | Pending |
|
||||
| TEST-04 | Phase 19 | Pending |
|
||||
| UX-01 | Phase 18 | Pending |
|
||||
| UX-02 | Phase 18 | Pending |
|
||||
| UX-03 | Phase 18 | Pending |
|
||||
| UX-04 | Phase 18 | Pending |
|
||||
| DOCS-01 | Phase 18 | Pending |
|
||||
| DOCS-02 | Phase 18 | Pending |
|
||||
| DOCS-03 | Phase 18 | Pending |
|
||||
| DOCS-04 | Phase 18 | Pending |
|
||||
| UX-01 | Phase 18 | Complete |
|
||||
| UX-02 | Phase 18 | Complete |
|
||||
| UX-03 | Phase 18 | Complete |
|
||||
| UX-04 | Phase 18 | Complete |
|
||||
| DOCS-01 | Phase 18 | Complete |
|
||||
| DOCS-02 | Phase 18 | Complete |
|
||||
| DOCS-03 | Phase 18 | Complete |
|
||||
| DOCS-04 | Phase 18 | Complete |
|
||||
|
||||
**Coverage:**
|
||||
- v1.4 requirements: 30 total
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
|
||||
## Phases
|
||||
|
||||
- [ ] **Phase 13: Logging Foundation** — Structured logging, `--verbose` flag, zero behavioral change to user-facing output
|
||||
- [ ] **Phase 14: Shared Utilities Extraction** — Extract `_utils.py` leaf module, eliminate ~1,610 lines of duplication across 7 workers
|
||||
- [ ] **Phase 15: Deep-Reading Queue Merge** — Single canonical `scan_library_records()` for both CLI and Agent consumers
|
||||
- [ ] **Phase 16: Retry + Progress Bars** — Resilient OCR with exponential backoff and user-visible progress indication
|
||||
- [ ] **Phase 17: Dead Code Removal + Pre-Commit** — Clean codebase validated by automated git hooks
|
||||
- [ ] **Phase 18: Documentation + CHANGELOG + UX Polish** — Complete user/maintainer docs, README fix, command naming audit (2 plans)
|
||||
- [ ] **Phase 19: Testing** — E2E pipeline tests, setup wizard tests, `_utils.py` unit tests
|
||||
- [x] **Phase 13: Logging Foundation** — Structured logging, `--verbose` flag, zero behavioral change to user-facing output
|
||||
- [x] **Phase 14: Shared Utilities Extraction** — Extract `_utils.py` leaf module, eliminate ~1,610 lines of duplication across 7 workers
|
||||
- [x] **Phase 15: Deep-Reading Queue Merge** — Single canonical `scan_library_records()` for both CLI and Agent consumers
|
||||
- [x] **Phase 16: Retry + Progress Bars** — Resilient OCR with exponential backoff and user-visible progress indication
|
||||
- [x] **Phase 17: Dead Code Removal + Pre-Commit** — Clean codebase validated by automated git hooks
|
||||
- [x] **Phase 18: Documentation + CHANGELOG + UX Polish** — Complete user/maintainer docs, README fix, command naming audit (2 plans)
|
||||
- [x] **Phase 19: Testing** — E2E pipeline tests, setup wizard tests, `_utils.py` unit tests
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ Plans:
|
|||
|
||||
Plans:
|
||||
- [x] 18-01-PLAN.md — Core Config & Foundation Docs (auto_analyze_after_ocr, CHANGELOG, CONTRIBUTING)
|
||||
- [ ] 18-02-PLAN.md — Migration, Architecture & Doc Polish (MIGRATION, ADRs, AGENTS, INDEX, README)
|
||||
- [x] 18-02-PLAN.md — Migration, Architecture & Doc Polish (MIGRATION, ADRs, AGENTS, INDEX, README)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,7 +67,12 @@ Plans:
|
|||
2. Setup wizard tests pass validating agent platform detection, vault path resolution, environment checks, and configuration file generation
|
||||
3. Dedicated unit tests for `_utils.py` cover JSON I/O, YAML helpers, slugify, and journal DB functions — `test_utils_json.py`, `test_utils_yaml.py`, `test_utils_slugify.py`, `test_utils_journal.py`
|
||||
4. All 205 existing tests continue to pass with zero failures — minimum bar: 205+ tests passing, 0 failures, 0 errors
|
||||
**Plans**: TBD
|
||||
**Plans**: 3 plans (1 wave)
|
||||
|
||||
Plans:
|
||||
- [ ] 19-01-PLAN.md — _utils.py unit tests (JSON I/O, YAML helpers, slugify, journal DB)
|
||||
- [ ] 19-02-PLAN.md — E2E pipeline integration tests (selection-sync → index-refresh → OCR queue → formal notes)
|
||||
- [ ] 19-03-PLAN.md — Setup wizard tests (Agent configs, EnvChecker, path resolution)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -82,8 +87,8 @@ Plans:
|
|||
| 15. Queue Merge | v1.4 | 1/1 | Complete | 2026-04-27 |
|
||||
| 16. Retry + Progress | v1.4 | 2/2 | Complete | 2026-04-27 |
|
||||
| 17. Dead Code + Pre-Commit | v1.4 | 1/1 | Complete | 2026-04-27 |
|
||||
| 18. Docs + CHANGELOG + UX | v1.4 | 2/1 | In progress | — |
|
||||
| 19. Testing | v1.4 | 0/0 | Not started | — |
|
||||
| 18. Docs + CHANGELOG + UX | v1.4 | 2/2 | Complete | 2026-04-27 |
|
||||
| 19. Testing | v1.4 | 0/3 | Not started | — |
|
||||
|
||||
### Historical Milestones
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ gsd_state_version: 1.0
|
|||
milestone: v1.4
|
||||
milestone_name: milestone
|
||||
status: In progress
|
||||
stopped_at: Completed 18-02-PLAN.md (MIGRATION, ADRs, AGENTS, INDEX, README)
|
||||
last_updated: "2026-04-27T10:07:06.986Z"
|
||||
stopped_at: Phase 19 execution complete
|
||||
last_updated: "2026-04-27T10:52:33.704Z"
|
||||
progress:
|
||||
total_phases: 3
|
||||
completed_phases: 1
|
||||
total_plans: 3
|
||||
completed_plans: 3
|
||||
completed_phases: 2
|
||||
total_plans: 6
|
||||
completed_plans: 6
|
||||
---
|
||||
|
||||
# State: PaperForge Lite Release Hardening
|
||||
|
|
@ -24,8 +24,8 @@ See: `.planning/PROJECT.md` (updated 2026-04-25)
|
|||
|
||||
## Current Position
|
||||
|
||||
Phase: 18
|
||||
Plans: 18-01 complete, 18-02 ready to execute
|
||||
Phase: 19
|
||||
Plans: 19-01 ready to execute, 19-02 ready to execute, 19-03 ready to execute
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
|
|
@ -113,8 +113,8 @@ None yet.
|
|||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-04-27T10:07:02.671Z
|
||||
Stopped at: Completed 18-02-PLAN.md (MIGRATION, ADRs, AGENTS, INDEX, README)
|
||||
Resume file: None
|
||||
Last session: 2026-04-27T10:52:33.701Z
|
||||
Stopped at: Phase 19 execution complete
|
||||
Resume file: .planning/phases/19-testing/19-03-SUMMARY.md
|
||||
|
||||
---\n*Initialized: 2026-04-23*\n*Last updated: 2026-04-26 (v1.4 roadmap created — Phase 13 ready to plan)*
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
# Milestone: v1.1 Sandbox Onboarding Hardening
|
||||
|
||||
**Status:** COMPLETE
|
||||
**Date:** 2026-04-24
|
||||
**Commits:** 110+ (from v1.0 tag to v1.1)
|
||||
**Requirements:** 24 total (21 Complete, 3 Partial)
|
||||
**Status:** COMPLETE
|
||||
**Date:** 2026-04-24
|
||||
**Commits:** 110+ (from v1.0 tag to v1.1)
|
||||
**Requirements:** 24 total (21 Complete, 3 Partial)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -15,8 +15,8 @@ Make the GitHub README + sandbox path behave like a real first-time user flow, w
|
|||
|
||||
### Phase 6: Setup, CLI, And Diagnostics Consistency
|
||||
|
||||
**Duration:** 2026-04-23
|
||||
**Commits:** `187af3b`, `1d8478c`, `31cce82`, and earlier Phase 6 commits
|
||||
**Duration:** 2026-04-23
|
||||
**Commits:** `187af3b`, `1d8478c`, `31cce82`, and earlier Phase 6 commits
|
||||
|
||||
| Task | What | Status |
|
||||
|------|------|--------|
|
||||
|
|
@ -30,8 +30,8 @@ Make the GitHub README + sandbox path behave like a real first-time user flow, w
|
|||
|
||||
### Phase 7: Zotero PDF, Metadata, And State Repair
|
||||
|
||||
**Duration:** 2026-04-23 to 2026-04-24
|
||||
**Commits:** `981f5fa`, `6052c82`, `d0ba1da`, `ff12515`, `1f817a5`, `bef53fc`
|
||||
**Duration:** 2026-04-23 to 2026-04-24
|
||||
**Commits:** `981f5fa`, `6052c82`, `d0ba1da`, `ff12515`, `1f817a5`, `bef53fc`
|
||||
|
||||
| Task | What | Status |
|
||||
|------|------|--------|
|
||||
|
|
@ -45,8 +45,8 @@ Make the GitHub README + sandbox path behave like a real first-time user flow, w
|
|||
|
||||
### Phase 8: Deep Helper Deployment And Sandbox Regression Gate
|
||||
|
||||
**Duration:** 2026-04-24 (~45 min)
|
||||
**Commits:** `fa51cf7`, `afa19eb`, `802d34b`, `766ed1e`, `b1cfe4f`, `3b929b1`
|
||||
**Duration:** 2026-04-24 (~45 min)
|
||||
**Commits:** `fa51cf7`, `afa19eb`, `802d34b`, `766ed1e`, `b1cfe4f`, `3b929b1`
|
||||
|
||||
| Task | What | Status |
|
||||
|------|------|--------|
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Milestone: v1.3 — Path Normalization & Architecture Hardening
|
||||
|
||||
**Completed:** 2026-04-24
|
||||
**Phases:** 11-12 (2 phases)
|
||||
**Commits:** 75 commits since v1.2
|
||||
**Completed:** 2026-04-24
|
||||
**Phases:** 11-12 (2 phases)
|
||||
**Commits:** 75 commits since v1.2
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
293
.planning/phases/13-logging-foundation/13-01-PLAN.md
Normal file
293
.planning/phases/13-logging-foundation/13-01-PLAN.md
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
---
|
||||
phase: 13-logging-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- paperforge/logging_config.py
|
||||
- paperforge/cli.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- OBS-01
|
||||
- OBS-02
|
||||
- OBS-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "paperforge/logging_config.py exists as the single entry point for logging configuration"
|
||||
- "All subcommands accept `--verbose` / `-v` flag from the root parser"
|
||||
- "configure_logging() is called once in cli.py:main() before command dispatch"
|
||||
- "PAPERFORGE_LOG_LEVEL env var controls default log level (INFO/WARNING/ERROR/DEBUG)"
|
||||
- "Invalid PAPERFORGE_LOG_LEVEL values silently fall back to WARNING"
|
||||
- "verbose=True overrides to DEBUG level regardless of PAPERFORGE_LOG_LEVEL"
|
||||
artifacts:
|
||||
- path: "paperforge/logging_config.py"
|
||||
provides: "Single configure_logging(verbose) entry point"
|
||||
min_lines: 40
|
||||
- path: "paperforge/cli.py"
|
||||
provides: "Global --verbose flag on root parser, configure_logging() call in main()"
|
||||
contains: "configure_logging"
|
||||
key_links:
|
||||
- from: "paperforge/logging_config.py"
|
||||
to: "paperforge/cli.py:main()"
|
||||
via: "configure_logging(verbose=args.verbose) call"
|
||||
pattern: "configure_logging"
|
||||
- from: "paperforge/logging_config.py"
|
||||
to: "os.environ"
|
||||
via: "PAPERFORGE_LOG_LEVEL env var read"
|
||||
pattern: "PAPERFORGE_LOG_LEVEL"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create `paperforge/logging_config.py` as the single entry point for Python stdlib logging configuration, and wire it into the CLI entry point.
|
||||
|
||||
Purpose: This is the foundation for Phase 13 and all subsequent v1.4 observability work. A single, well-tested `configure_logging(verbose)` function prevents scattered `basicConfig()` calls and ensures consistent log behavior across all commands.
|
||||
|
||||
Output:
|
||||
- `paperforge/logging_config.py` — `configure_logging(verbose: bool = False) -> None`
|
||||
- Modified `paperforge/cli.py` — global `--verbose` flag on root parser, `configure_logging()` call in `main()`
|
||||
</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/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/13-logging-foundation/13-CONTEXT.md
|
||||
@.planning/phases/13-logging-foundation/13-RESEARCH.md
|
||||
@paperforge/cli.py
|
||||
@paperforge/config.py
|
||||
@paperforge/pdf_resolver.py
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create paperforge/logging_config.py</name>
|
||||
|
||||
<files>paperforge/logging_config.py (NEW FILE)</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/config.py — Reference: single-purpose module pattern, pure functions, no side effects at import time
|
||||
- paperforge/pdf_resolver.py — Reference: existing `import logging; logger = logging.getLogger(__name__)` pattern
|
||||
- .planning/phases/13-logging-foundation/13-CONTEXT.md — D-02: signature, D-03: basicConfig/dictConfig, D-06: env var, D-09: invalid fallback, D-10: early-boot guard
|
||||
- .planning/phases/13-logging-foundation/13-RESEARCH.md §4.1 — Integration point analysis
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Create `paperforge/logging_config.py` with the following implementation:
|
||||
|
||||
```python
|
||||
"""PaperForge Lite — logging configuration single entry point.
|
||||
|
||||
Call `configure_logging(verbose)` once at CLI startup before command dispatch.
|
||||
All worker and command modules use `logging.getLogger(__name__)` for
|
||||
diagnostic/trace/error output.
|
||||
|
||||
Environment:
|
||||
PAPERFORGE_LOG_LEVEL — default log level (DEBUG/INFO/WARNING/ERROR).
|
||||
Invalid values silently fall back to WARNING.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(verbose: bool = False) -> None:
|
||||
"""Configure the root ``paperforge`` logger.
|
||||
|
||||
Sets up a StreamHandler writing to stderr with a ``LEVEL:name:message``
|
||||
format string. Idempotent — does nothing if handlers are already
|
||||
configured (guards against double ``basicConfig()`` or ``dictConfig()``).
|
||||
|
||||
Args:
|
||||
verbose: If True, forces ``DEBUG`` level regardless of env var.
|
||||
"""
|
||||
logger = logging.getLogger("paperforge")
|
||||
|
||||
# Idempotency guard: if handlers already exist, only adjust level.
|
||||
# This prevents duplicate log lines from multiple configure_logging calls.
|
||||
if logger.handlers:
|
||||
if verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
return
|
||||
|
||||
# Resolve log level from environment or default
|
||||
level_name = os.environ.get("PAPERFORGE_LOG_LEVEL", "INFO").strip().upper()
|
||||
level = getattr(logging, level_name, None)
|
||||
if not isinstance(level, int) or level_name not in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
||||
level = logging.WARNING
|
||||
|
||||
if verbose:
|
||||
level = logging.DEBUG
|
||||
|
||||
logger.setLevel(level)
|
||||
|
||||
handler = logging.StreamHandler(stream=sys.stderr)
|
||||
handler.setLevel(logging.DEBUG) # handler-level always DEBUG; logger controls filtering
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(levelname)s:%(name)s:%(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
def get_paperforge_logger() -> logging.Logger:
|
||||
"""Convenience accessor for the ``paperforge`` logger.
|
||||
|
||||
Equivalent to ``logging.getLogger("paperforge")`` but provides a single
|
||||
well-known reference point for early-boot logging before
|
||||
``configure_logging()`` has been called.
|
||||
|
||||
Callers in worker/command modules should still prefer the per-module
|
||||
``logger = logging.getLogger(__name__)`` pattern. This function exists
|
||||
for places (like ``cli.py:main()``) that need the root logger before
|
||||
per-module loggers are established.
|
||||
"""
|
||||
return logging.getLogger("paperforge")
|
||||
```
|
||||
|
||||
**Key design decisions (per CONTEXT.md):**
|
||||
- D-03: Uses `logger.setLevel()` + `handler.setLevel()` programmatic setup (not dictConfig) — simpler, no external config files
|
||||
- D-02: Single `configure_logging(verbose)` export
|
||||
- D-06: Reads `PAPERFORGE_LOG_LEVEL` from `os.environ`; default `INFO`
|
||||
- D-09: Invalid env var values → silent fallback to `WARNING`
|
||||
- D-05: `verbose=True` overrides to `DEBUG`
|
||||
- D-10: Idempotency guard: `if logger.handlers: return` — safe for early-boot `basicConfig(WARNING)` calls
|
||||
- Handler writes to `sys.stderr` — diagnostic output only, stdout stays clean for user-facing `print()`
|
||||
- Formatter: `LEVEL:name:message` — compact, grep-friendly, no timestamps (user sees real-time output)
|
||||
- The root logger is named `paperforge`, producing hierarchy `paperforge.worker.sync`, `paperforge.commands.ocr`, etc.
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `paperforge/logging_config.py` exists as a new file
|
||||
- File contains `def configure_logging(verbose: bool = False) -> None:`
|
||||
- File reads `os.environ.get("PAPERFORGE_LOG_LEVEL", "INFO")`
|
||||
- File contains `StreamHandler(stream=sys.stderr)`
|
||||
- File contains `getattr(logging, level_name, None)` for level resolution
|
||||
- File contains `if logger.handlers: return` guard
|
||||
- Invalid env var (e.g., `PAPERFORGE_LOG_LEVEL=banana`) results in `logging.WARNING` level
|
||||
- `verbose=True` sets level to `logging.DEBUG`
|
||||
- `import paperforge.logging_config` raises no errors
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); from paperforge.logging_config import configure_logging; configure_logging(verbose=True); import logging; assert logging.getLogger('paperforge').level == logging.DEBUG; print('OK')"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
- `paperforge/logging_config.py` exists with `configure_logging()` function
|
||||
- Import and basic assertion test passes
|
||||
- StreamHandler targets stderr (confirmed by checking `handler.stream is sys.stderr`)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add global --verbose to CLI root parser and wire configure_logging()</name>
|
||||
|
||||
<files>paperforge/cli.py</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/cli.py — Full file to see current parser structure, main() flow, and command dispatch
|
||||
- .planning/phases/13-logging-foundation/13-CONTEXT.md — D-04 (global flag), D-05 (verbose→DEBUG)
|
||||
- .planning/phases/13-logging-foundation/13-RESEARCH.md §3.1 (existing verbose locations), §4.1 (integration point at cli.py:main())
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Modify `paperforge/cli.py`:
|
||||
|
||||
**1. Add import at top of file:**
|
||||
```python
|
||||
from paperforge.logging_config import configure_logging
|
||||
```
|
||||
|
||||
**2. Add `--verbose` / `-v` to the ROOT parser** (after the existing `--vault` argument, before `add_subparsers()`):
|
||||
|
||||
In `build_parser()`, add right after the `--vault` argument (after line 124):
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable DEBUG-level diagnostic output on stderr",
|
||||
)
|
||||
```
|
||||
|
||||
**3. Remove the per-subcommand `--verbose` from `deep-reading` and `repair`** to avoid duplicate argument errors:
|
||||
|
||||
For `deep-reading` (lines 170-173): Remove these four lines:
|
||||
```python
|
||||
p_dr.add_argument(
|
||||
"--verbose", "-v", action="store_true",
|
||||
help="Show fix instructions for blocked papers"
|
||||
)
|
||||
```
|
||||
|
||||
For `repair` (lines 178-180): Remove these three lines:
|
||||
```python
|
||||
p_repair.add_argument(
|
||||
"--verbose", "-v", action="store_true",
|
||||
help="Show detailed divergence report"
|
||||
)
|
||||
```
|
||||
|
||||
**4. Insert `configure_logging()` call in `main()`** right before the command dispatch section (between line 282 `args.paths = paperforge_paths(vault, cfg)` and line 286 `if args.command == "paths":`):
|
||||
|
||||
```python
|
||||
# Configure logging before command dispatch
|
||||
configure_logging(verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
**Why:** This ensures logging is configured as early as possible in the CLI lifecycle but after vault/env resolution (so env vars are available). All subsequent worker module logger calls will inherit this configuration.
|
||||
|
||||
**Note on backward compatibility:** The deep-reading and repair subcommands previously had their own `--verbose`. Since we're adding `--verbose` to the root parser, it's now inherited by ALL subcommands automatically. The old per-subcommand flags worked via `action="store_true"` on the same `--verbose` / `-v` dest name, so argparse resolves them identically. No code changes needed in the command modules for backward compat — `args.verbose` will be `True` regardless of whether the user typed `paperforge deep-reading -v` or `paperforge -v deep-reading`.
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `cli.py` imports `configure_logging` from `paperforge.logging_config`
|
||||
- `build_parser()` has `--verbose` / `-v` as a root parser argument
|
||||
- `deep-reading` and `repair` subparsers no longer have their own `--verbose` argument
|
||||
- `main()` calls `configure_logging(verbose=args.verbose)` before command dispatch
|
||||
- `python -c "from paperforge.cli import main; main(['-v', 'status'])"` works without error
|
||||
- `python -c "from paperforge.cli import main; main(['--verbose', 'status'])"` works without error
|
||||
- `python -c "from paperforge.cli import main; main(['status'])"` works without error (no -v)
|
||||
- `python -c "from paperforge.cli import main; main(['deep-reading', '-v'])"` works (backward compat)
|
||||
- `python -c "from paperforge.cli import main; main(['repair', '-v'])"` works (backward compat)
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); from paperforge.cli import build_parser; p = build_parser(); r = p.parse_args(['--verbose', 'status']); assert r.verbose == True; r2 = p.parse_args(['deep-reading']); assert r2.verbose == False; print('OK')"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
- `cli.py` builds/runs successfully with and without `--verbose`
|
||||
- Global `--verbose` recognized by all subcommands
|
||||
- `configure_logging()` called before command dispatch in `main()`
|
||||
- Backward compatible: `deep-reading -v` and `repair -v` continue to work
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Verify `logging_config.py` exists and can be imported
|
||||
2. Verify `configure_logging(verbose=True)` sets DEBUG level
|
||||
3. Verify CLI root parser accepts `--verbose` / `-v` globally
|
||||
4. Verify old per-subcommand `--verbose` removal doesn't break deep-reading or repair
|
||||
5. Verify `configure_logging()` is called in `main()` before dispatch
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `paperforge/logging_config.py` created with `configure_logging(verbose)` function
|
||||
- `paperforge/cli.py` has global `--verbose` flag and `configure_logging()` call in main()
|
||||
- All tests pass (import, arg parse, config)
|
||||
- No regression in command dispatch behavior
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-logging-foundation/13-01-SUMMARY.md`
|
||||
</output>
|
||||
247
.planning/phases/13-logging-foundation/13-02-PLAN.md
Normal file
247
.planning/phases/13-logging-foundation/13-02-PLAN.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
---
|
||||
phase: 13-logging-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- paperforge/worker/repair.py
|
||||
- paperforge/worker/deep_reading.py
|
||||
- paperforge/worker/ocr.py
|
||||
- paperforge/worker/sync.py
|
||||
- paperforge/worker/status.py
|
||||
- paperforge/worker/base_views.py
|
||||
- paperforge/worker/update.py
|
||||
- paperforge/commands/ocr.py
|
||||
- paperforge/commands/repair.py
|
||||
- paperforge/commands/deep.py
|
||||
- paperforge/commands/sync.py
|
||||
- paperforge/commands/status.py
|
||||
- paperforge/update.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- OBS-01
|
||||
- OBS-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "All 7 worker modules have `logger = logging.getLogger(__name__)` at module level"
|
||||
- "All 5 command modules have `logger = logging.getLogger(__name__)` at module level"
|
||||
- "All `[repair]` tagged diagnostic print() calls in worker/repair.py use logger.*() instead"
|
||||
- "Diagnostic print() calls in commands/ocr.py use logger.*() instead"
|
||||
- "update.py:_log() uses logger.info() instead of print()"
|
||||
- "All stdout user-facing print() calls remain unchanged"
|
||||
artifacts:
|
||||
- path: "paperforge/worker/repair.py"
|
||||
provides: "logger module instance with all diagnostic prints migrated"
|
||||
min_lines: 548
|
||||
- path: "paperforge/update.py"
|
||||
provides: "_log() uses logger.info() instead of print()"
|
||||
contains: "logger"
|
||||
key_links:
|
||||
- from: "paperforge/worker/repair.py"
|
||||
to: "logging"
|
||||
via: "All [repair] diagnostic calls migrated to logger.info/warning/error"
|
||||
pattern: "logger\\."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Add `import logging; logger = logging.getLogger(__name__)` to all worker and command modules, and migrate all diagnostic `print()` calls to use the logging module instead.
|
||||
|
||||
Purpose: Per OBS-01, every module that produces diagnostic/trace/error output must have a module-level logger. Per OBS-02, the dual-output strategy means diagnostic output (stderr via logging) is separated from user-facing output (stdout via print()).
|
||||
|
||||
Output:
|
||||
- 7 worker + 5 command modules + update.py with module-level logger instances
|
||||
- All [repair]-tagged diagnostic prints in worker/repair.py migrated to logger.*()
|
||||
- Diagnostic prints in commands/ocr.py migrated to logger.*()
|
||||
- update.py:_log() migrated to logger.info()
|
||||
</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/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/13-logging-foundation/13-CONTEXT.md
|
||||
@.planning/phases/13-logging-foundation/13-RESEARCH.md
|
||||
@paperforge/pdf_resolver.py
|
||||
@paperforge/worker/repair.py
|
||||
@paperforge/commands/ocr.py
|
||||
@paperforge/update.py
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add module-level logger to all worker and command modules</name>
|
||||
|
||||
<files>
|
||||
paperforge/worker/repair.py
|
||||
paperforge/worker/deep_reading.py
|
||||
paperforge/worker/ocr.py
|
||||
paperforge/worker/sync.py
|
||||
paperforge/worker/status.py
|
||||
paperforge/worker/base_views.py
|
||||
paperforge/worker/update.py
|
||||
paperforge/commands/ocr.py
|
||||
paperforge/commands/repair.py
|
||||
paperforge/commands/deep.py
|
||||
paperforge/commands/sync.py
|
||||
paperforge/commands/status.py
|
||||
paperforge/update.py
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/pdf_resolver.py -- Reference: existing working pattern `import logging` + `logger = logging.getLogger(__name__)` (lines 8, 12)
|
||||
- .planning/phases/13-logging-foundation/13-RESEARCH.md section 1.1 -- stdout print() classification (DO NOT TOUCH these)
|
||||
- .planning/phases/13-logging-foundation/13-RESEARCH.md section 1.2 -- diagnostic print() classification (migrated in Task 2)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
For each of the 13 files listed above, add exactly TWO lines at module level (after existing imports, before any function definitions):
|
||||
|
||||
`import logging`
|
||||
`logger = logging.getLogger(__name__)`
|
||||
|
||||
Placement rules per file (insert after specified line):
|
||||
- worker/repair.py: After `from __future__ import annotations`, before `import argparse`
|
||||
- worker/deep_reading.py: After `from __future__ import annotations`, before `import argparse`
|
||||
- worker/ocr.py: After `from __future__ import annotations`, before `import argparse`
|
||||
- worker/sync.py: After `from __future__ import annotations`, before `import argparse`
|
||||
- worker/status.py: After existing imports, before function definitions
|
||||
- worker/base_views.py: After existing imports, before function definitions
|
||||
- worker/update.py: After existing imports, before function definitions
|
||||
- commands/ocr.py: After `from pathlib import Path`, before function definitions
|
||||
- commands/repair.py: After `from pathlib import Path`, before function definitions
|
||||
- commands/deep.py: After `from pathlib import Path`, before function definitions
|
||||
- commands/sync.py: After `from pathlib import Path`, before function definitions
|
||||
- commands/status.py: After `from pathlib import Path`, before function definitions
|
||||
- update.py (paperforge/update.py): After existing imports, before function definitions
|
||||
|
||||
IMPORTANT -- Do NOT touch: cli.py (Plan 01), pdf_resolver.py (has logger), config.py, ocr_diagnostics.py. Verify no duplicate import logging or logger = before adding.
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- Every file contains `import logging` at module level
|
||||
- Every file contains `logger = logging.getLogger(__name__)` at module level
|
||||
- No duplicate `import logging` or `logger = ` in any file
|
||||
- `python -c "from paperforge.worker.repair import logger; print(logger.name)"` must output `paperforge.worker.repair` (and same pattern for other modules)
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); mods=['paperforge.worker.repair','paperforge.worker.deep_reading','paperforge.worker.ocr','paperforge.worker.sync','paperforge.worker.status','paperforge.commands.ocr','paperforge.commands.repair','paperforge.commands.deep','paperforge.commands.sync','paperforge.commands.status','paperforge.worker.update']; [print('OK: '+__import__(m, fromlist=['logger']).logger.name) for m in mods]"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
All 13 modules have module-level loggers. Import test passes for all.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Migrate diagnostic print() calls to logger.*() in repair.py, commands/ocr.py, and update.py</name>
|
||||
|
||||
<files>
|
||||
paperforge/worker/repair.py
|
||||
paperforge/commands/ocr.py
|
||||
paperforge/update.py
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/worker/repair.py -- All [repair] tagged print() calls (lines 236, 303, 339, 357, 360, 363, 368, 423, 456, 526, 533, 540, 542, 544)
|
||||
- paperforge/commands/ocr.py -- Diagnostic print() calls (lines 71, 78, 82, 87)
|
||||
- paperforge/update.py -- _log() function at line 226-227 that wraps print(_color(msg, c))
|
||||
- .planning/phases/13-logging-foundation/13-RESEARCH.md section 1.2 -- Full classification table
|
||||
- .planning/phases/13-logging-foundation/13-CONTEXT.md D-07 -- stdout/stderr boundary classification
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A) Migrate worker/repair.py diagnostic print() calls:**
|
||||
|
||||
Replace each `[repair]`-tagged `print()` with the appropriate `logger.*()` call. Keep stdout user-facing print() calls at function exit points (lines 533, 540, 542, 544) unchanged -- these are run_repair() summary messages.
|
||||
|
||||
Migration table (print -> logger):
|
||||
- Line 236: `print(f"[repair] error reading {record_path}: {e}")` -> `logger.error("error reading %s: %s", record_path, e)`
|
||||
- Lines 303-305: `print(f"[repair] error loading export for {domain}: {e}")` -> `logger.error("error loading export for %s: %s", domain, e)`
|
||||
- Lines 339-341: `print(f"[repair] fixed path for {zotero_key}: {new_wikilink}")` -> `logger.info("fixed path for %s: %s", zotero_key, new_wikilink)`
|
||||
- Line 357: `print(f"[repair] cleared path_error for {zotero_key}")` -> `logger.info("cleared path_error for %s", zotero_key)`
|
||||
- Line 360: `print(f"[repair] {zotero_key} path still unresolved")` -> `logger.warning("%s path still unresolved", zotero_key)`
|
||||
- Lines 363-365: `print(f"[repair] {zotero_key} has empty pdf_path (not_found)")` -> `logger.warning("%s has empty pdf_path (not_found)", zotero_key)`
|
||||
- Line 368: `print(f"[repair] {zotero_key} has no pdf_path field")` -> `logger.warning("%s has no pdf_path field", zotero_key)`
|
||||
- Line 423: `print(f"[repair] {zotero_key} meta validation error: {validated_error}")` -> `logger.warning("%s meta validation error: %s", zotero_key, validated_error)`
|
||||
- Line 456: `print(f"[repair] divergent: {zotero_key} | {div_reason}")` -> `logger.info("divergent: %s | %s", zotero_key, div_reason)`
|
||||
- Line 526: `print(f"[repair] fixed {fixed_count} files for {zotero_key}")` -> `logger.info("fixed %d files for %s", fixed_count, zotero_key)`
|
||||
|
||||
KEEP AS print(): Lines 533-535 (`[repair] Found ... items with path errors: ...`), Line 540 (`[repair] Fixed ... PDF paths`), Line 542 (`[repair] Tip: run with --fix-paths...`), Line 544 (`[repair] No path errors found`) -- these are user-facing run_repair() summary output on stdout.
|
||||
|
||||
**B) Migrate commands/ocr.py diagnostic print() calls:**
|
||||
|
||||
- Line 71: `print(f"[INFO] Processing specific key: {key}")` -> `logger.info("Processing specific key: %s", key)`
|
||||
- Line 78: `print("\n[Auto-diagnose] Running post-OCR diagnostic...")` -> `logger.info("Running post-OCR diagnostic...")`
|
||||
- Lines 82-85: `print("[WARN] Post-OCR diagnostic found issues, " ...)` -> `logger.warning("Post-OCR diagnostic found issues, but OCR completed successfully.")` (single string join)
|
||||
- Line 87: `print(f"[WARN] Auto-diagnose failed: {e}")` -> `logger.warning("Auto-diagnose failed: %s", e)`
|
||||
|
||||
**C) Migrate update.py _log() function:**
|
||||
|
||||
Replace the existing `_log()` function body (currently `print(_color(msg, c))`):
|
||||
- Remove the `_log()` function entirely (defined at line 226)
|
||||
- Replace every call site `_log(msg, c)` with an appropriate logging call
|
||||
- The colored output is no longer needed since logging goes to stderr (formatting is stripped)
|
||||
- For warning messages: `logger.warning(msg)`
|
||||
- For info messages: `logger.info(msg)`
|
||||
- Keep the `_color()` function if still used elsewhere; otherwise remove dead code
|
||||
|
||||
Scan update.py for all `_log()` call sites and map each to the appropriate logger level:
|
||||
- Version check failures -> `logger.warning()`
|
||||
- Update progress -> `logger.info()`
|
||||
- Error messages -> `logger.error()`
|
||||
|
||||
**General rules for ALL migrations:**
|
||||
- Use printf-style formatting (`%s`, `%d`) instead of f-strings -- this is the standard logging pattern that enables lazy evaluation
|
||||
- Do NOT add `file=sys.stderr` -- logging goes to stderr via the StreamHandler configured in logging_config.py
|
||||
- Keep all stdout user-facing print() calls unchanged (sync.py line 881, deep_reading.py line 322, ocr.py line 1374, status.py lines 598-613, etc.)
|
||||
- The `if verbose:` guards remain in place -- they control whether the logger call is made at all
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- No `print(f"[repair] ...")` patterns remain in worker/repair.py (only the 4 kept summary print() calls remain)
|
||||
- No `print(f"[INFO] ...")` or `print(f"[WARN] ...")` patterns in commands/ocr.py
|
||||
- `_log()` function no longer exists in update.py or uses logger.*() instead of print()
|
||||
- All migrated calls use printf-style `%s`/`%d` formatting
|
||||
- All stdout print() calls in sync.py, deep_reading.py, ocr.py, status.py remain unchanged
|
||||
- `grep -c "print(f.\[repair\]" paperforge/worker/repair.py` returns 0 (no [repair] print calls remain)
|
||||
- `grep -c "print(f.\[INFO\]" paperforge/commands/ocr.py` returns 0
|
||||
- `grep -c "print(f.\[WARN\]" paperforge/commands/ocr.py` returns 0
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); src=open('paperforge/worker/repair.py').read(); assert 'print(f' \"'\"'[repair]' \"'\"' not in src, 'fail'; src2=open('paperforge/commands/ocr.py').read(); assert 'print(f' \"'\"'[INFO]' \"'\"' not in src2 and 'print(f' \"'\"'[WARN]' \"'\"' not in src2, 'fail'; print('OK')"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
No diagnostic `[repair]` print() calls remain in repair.py. No `[INFO]`/`[WARN]` print() calls remain in commands/ocr.py. update.py uses logger.*() instead of _log(). All stdout user-facing print() calls preserved.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Verify all 13 modules import logging and have module-level logger instances
|
||||
2. Verify no `[repair]` print calls remain in worker/repair.py
|
||||
3. Verify no `[INFO]`/`[WARN]` print calls remain in commands/ocr.py
|
||||
4. Verify update.py no longer uses print() for diagnostic output
|
||||
5. Verify stdout user-facing print() calls in sync.py, deep_reading.py, ocr.py, status.py remain unchanged
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All worker and command modules have module-level loggers
|
||||
- All diagnostic print() calls migrated to logger.*() with printf-style formatting
|
||||
- All user-facing stdout print() calls remain unchanged
|
||||
- Dual-output boundary (OBS-02) established: stdout = user, stderr/logging = diagnostic
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-logging-foundation/13-02-SUMMARY.md`
|
||||
</output></automated>
|
||||
</verify>
|
||||
276
.planning/phases/13-logging-foundation/13-03-PLAN.md
Normal file
276
.planning/phases/13-logging-foundation/13-03-PLAN.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
---
|
||||
phase: 13-logging-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 13-01
|
||||
- 13-02
|
||||
files_modified:
|
||||
- paperforge/worker/sync.py
|
||||
- paperforge/worker/ocr.py
|
||||
- paperforge/worker/deep_reading.py
|
||||
- paperforge/worker/status.py
|
||||
- paperforge/commands/sync.py
|
||||
- paperforge/commands/ocr.py
|
||||
- paperforge/commands/status.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- OBS-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "paperforge sync --verbose enables DEBUG-level logging"
|
||||
- "paperforge ocr --verbose enables DEBUG-level logging"
|
||||
- "paperforge deep-reading --verbose enables DEBUG-level logging"
|
||||
- "paperforge status --verbose enables DEBUG-level logging"
|
||||
- "All 4 commands accept the global --verbose/-v flag without error"
|
||||
artifacts:
|
||||
- path: "paperforge/commands/sync.py"
|
||||
provides: "verbose passthrough from args to worker functions"
|
||||
contains: "verbose"
|
||||
- path: "paperforge/commands/ocr.py"
|
||||
provides: "verbose passthrough from args to run_ocr"
|
||||
contains: "verbose"
|
||||
- path: "paperforge/commands/status.py"
|
||||
provides: "verbose passthrough from args to run_status"
|
||||
contains: "verbose"
|
||||
key_links:
|
||||
- from: "paperforge/commands/*.py"
|
||||
to: "paperforge/worker/*.py"
|
||||
via: "verbose=getattr(args, 'verbose', False) -> worker_func(verbose=...)"
|
||||
pattern: "verbose=getattr"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire the global `--verbose` flag through command modules to worker function signatures for sync, ocr, status, and deep-reading commands.
|
||||
|
||||
Purpose: Per OBS-03, all primary CLI commands must accept `--verbose`/`-v` and propagate it to enable DEBUG-level output. Plan 01 added the global flag to the CLI parser. This plan completes the wiring by adding `verbose` parameters to worker functions and passing the flag through command modules.
|
||||
|
||||
Output:
|
||||
- Worker function signatures updated with `verbose=False` parameter
|
||||
- Command modules wire `args.verbose` through to worker function calls
|
||||
</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/STATE.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/13-logging-foundation/13-CONTEXT.md
|
||||
@.planning/phases/13-logging-foundation/13-RESEARCH.md
|
||||
@paperforge/commands/sync.py
|
||||
@paperforge/commands/ocr.py
|
||||
@paperforge/commands/status.py
|
||||
@paperforge/commands/deep.py
|
||||
@paperforge/commands/repair.py
|
||||
@paperforge/worker/sync.py
|
||||
@paperforge/worker/ocr.py
|
||||
@paperforge/worker/deep_reading.py
|
||||
@paperforge/worker/status.py
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add verbose parameter to worker function signatures</name>
|
||||
|
||||
<files>
|
||||
paperforge/worker/sync.py
|
||||
paperforge/worker/ocr.py
|
||||
paperforge/worker/status.py
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/worker/deep_reading.py -- Reference: existing `def run_deep_reading(vault, verbose=False)` signature (already has verbose)
|
||||
- paperforge/worker/repair.py -- Reference: existing `def run_repair(vault, paths, verbose=False, ...)` signature (already has verbose)
|
||||
- paperforge/worker/sync.py -- Current signatures: `def run_selection_sync(vault: Path) -> int:` and `def run_index_refresh(vault: Path) -> int:`
|
||||
- paperforge/worker/ocr.py -- Current signature: `def run_ocr(vault: Path) -> int:`
|
||||
- paperforge/worker/status.py -- Current signature: `def run_status(vault: Path) -> int:`
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Modify worker function signatures to add `verbose: bool = False` parameter:
|
||||
|
||||
**A) paperforge/worker/sync.py:**
|
||||
|
||||
Change line ~808:
|
||||
```python
|
||||
# FROM:
|
||||
def run_selection_sync(vault: Path) -> int:
|
||||
# TO:
|
||||
def run_selection_sync(vault: Path, verbose: bool = False) -> int:
|
||||
```
|
||||
|
||||
Change line ~1355:
|
||||
```python
|
||||
# FROM:
|
||||
def run_index_refresh(vault: Path) -> int:
|
||||
# TO:
|
||||
def run_index_refresh(vault: Path, verbose: bool = False) -> int:
|
||||
```
|
||||
|
||||
No behavioral changes needed INSIDE the functions yet -- the `verbose` parameter is accepted and available for future use. The parameter serves as a contract for the command module wiring.
|
||||
|
||||
**B) paperforge/worker/ocr.py:**
|
||||
|
||||
Change line ~1217:
|
||||
```python
|
||||
# FROM:
|
||||
def run_ocr(vault: Path) -> int:
|
||||
# TO:
|
||||
def run_ocr(vault: Path, verbose: bool = False) -> int:
|
||||
```
|
||||
|
||||
**C) paperforge/worker/status.py:**
|
||||
|
||||
Find `run_status` function signature (around line 563):
|
||||
```python
|
||||
# FROM:
|
||||
def run_status(vault: Path) -> int:
|
||||
# TO:
|
||||
def run_status(vault: Path, verbose: bool = False) -> int:
|
||||
```
|
||||
|
||||
Also find `run_doctor` function signature and add `verbose: bool = False` parameter:
|
||||
```python
|
||||
# FROM:
|
||||
def run_doctor(vault: Path) -> int:
|
||||
# TO:
|
||||
def run_doctor(vault: Path, verbose: bool = False) -> int:
|
||||
```
|
||||
|
||||
For each function, only change the signature -- do NOT modify any logic inside the function body. The `verbose` parameter is received and available for use, even if the current implementation doesn't branch on it yet. This follows the contract-first pattern.
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `run_selection_sync` signature includes `verbose: bool = False`
|
||||
- `run_index_refresh` signature includes `verbose: bool = False`
|
||||
- `run_ocr` signature includes `verbose: bool = False`
|
||||
- `run_status` signature includes `verbose: bool = False`
|
||||
- `run_doctor` signature includes `verbose: bool = False`
|
||||
- All existing call sites still work (backward compatible due to default `False`)
|
||||
- No changes to function bodies, only signatures
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); import inspect; from paperforge.worker.sync import run_selection_sync, run_index_refresh; assert 'verbose' in inspect.signature(run_selection_sync).parameters; assert 'verbose' in inspect.signature(run_index_refresh).parameters; from paperforge.worker.ocr import run_ocr; assert 'verbose' in inspect.signature(run_ocr).parameters; from paperforge.worker.status import run_status, run_doctor; assert 'verbose' in inspect.signature(run_status).parameters; assert 'verbose' in inspect.signature(run_doctor).parameters; print('OK')"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
All 6 worker functions (`run_selection_sync`, `run_index_refresh`, `run_ocr`, `run_status`, `run_doctor`) accept `verbose: bool = False` parameter. Backward compatible.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Wire verbose through command modules to worker functions</name>
|
||||
|
||||
<files>
|
||||
paperforge/commands/sync.py
|
||||
paperforge/commands/ocr.py
|
||||
paperforge/commands/status.py
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- paperforge/commands/deep.py -- Reference: existing `verbose=getattr(args, 'verbose', False)` wiring pattern (line 36)
|
||||
- paperforge/commands/repair.py -- Reference: existing `verbose=getattr(args, 'verbose', False)` wiring pattern (line 42)
|
||||
- paperforge/commands/sync.py -- Current dispatch: `run_selection_sync(vault)` and `run_index_refresh(vault)`
|
||||
- paperforge/commands/ocr.py -- Current dispatch: `run_ocr(vault)`
|
||||
- paperforge/commands/status.py -- Current dispatch: `run_status(vault)`
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
Wire `verbose` from `args` (populated by the global `--verbose` flag added in Plan 01) through each command module to its worker function call.
|
||||
|
||||
**A) paperforge/commands/sync.py:**
|
||||
|
||||
Lines 81-82 currently:
|
||||
```python
|
||||
code = run_selection_sync(vault)
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
code = run_selection_sync(vault, verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
Lines 87-88 currently:
|
||||
```python
|
||||
code = run_index_refresh(vault)
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
code = run_index_refresh(vault, verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
**B) paperforge/commands/ocr.py:**
|
||||
|
||||
Line 74 currently:
|
||||
```python
|
||||
exit_code = run_ocr(vault)
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
exit_code = run_ocr(vault, verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
**C) paperforge/commands/status.py:**
|
||||
|
||||
Line 40 currently:
|
||||
```python
|
||||
return run_status(vault)
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
return run_status(vault, verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
**Verification pattern for all changes:**
|
||||
- Use `getattr(args, "verbose", False)` to safely handle the case where `args` might not have `verbose` (e.g., when command modules are called outside the CLI dispatch, such as in tests)
|
||||
- The `verbose` default of `False` in worker function signatures ensures backward compatibility
|
||||
- The global `--verbose` flag from Plan 01 populates `args.verbose = True` when the flag is used
|
||||
|
||||
**No changes needed to:**
|
||||
- `commands/deep.py` — already has `verbose=getattr(args, "verbose", False)` on line 36
|
||||
- `commands/repair.py` — already has `verbose=getattr(args, "verbose", False)` on line 42
|
||||
- `cli.py` — already configured with global `--verbose` flag in Plan 01
|
||||
</action>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `commands/sync.py` passes `verbose=getattr(args, "verbose", False)` to `run_selection_sync()` and `run_index_refresh()`
|
||||
- `commands/ocr.py` passes `verbose=getattr(args, "verbose", False)` to `run_ocr()`
|
||||
- `commands/status.py` passes `verbose=getattr(args, "verbose", False)` to `run_status()`
|
||||
- All 3 commands work with `--verbose`, `-v`, and without the flag
|
||||
- `python -c "from paperforge.cli import main; main(['--verbose', 'status'])"` exits with code 0
|
||||
- `python -c "from paperforge.cli import main; main(['status'])"` exits with code 0
|
||||
</acceptance_criteria>
|
||||
|
||||
<verify>
|
||||
<automated>python -c "import sys; sys.path.insert(0, '.'); import inspect; from paperforge.commands.sync import run; sig=inspect.signature(run); # verify run() uses getattr; src=open('paperforge/commands/sync.py').read(); assert 'verbose=getattr(args' in src; src2=open('paperforge/commands/ocr.py').read(); assert 'verbose=getattr(args' in src2; src3=open('paperforge/commands/status.py').read(); assert 'verbose=getattr(args' in src3; print('OK')"</automated>
|
||||
</verify>
|
||||
|
||||
<done>
|
||||
All 3 primary commands (sync, ocr, status) pass the global `--verbose` flag to their respective worker functions. deep-reading and repair were already wired.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. Sync, ocr, and status worker functions accept `verbose: bool = False` parameter
|
||||
2. Sync, ocr, and status command modules pass `args.verbose` to workers
|
||||
3. deep-reading and repair commands continue to work (already wired)
|
||||
4. All commands work with and without `--verbose`/`-v`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All primary CLI commands (sync, ocr, deep-reading, status) propagate the global `--verbose` flag
|
||||
- Worker functions accept and handle the verbose parameter
|
||||
- No regressions in command dispatch behavior
|
||||
- Backward compatible: all flags work at both root and subcommand level
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/13-logging-foundation/13-03-SUMMARY.md`
|
||||
</output>
|
||||
112
.planning/phases/13-logging-foundation/13-CONTEXT.md
Normal file
112
.planning/phases/13-logging-foundation/13-CONTEXT.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Phase 13: Logging Foundation - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Create `paperforge/logging_config.py` as the single entry point for configuring Python stdlib logging. Add structured, level-based logging for diagnostic/trace/error output (stderr) while preserving `print()` for user-facing formatted output (stdout). Add `--verbose`/`-v` global flag. Zero behavioral change to piped output.
|
||||
|
||||
Requirements: OBS-01, OBS-02, OBS-03
|
||||
|
||||
Out of scope:
|
||||
- Progress bars (OBS-04 — Phase 16)
|
||||
- Actionable OCR error messages (OBS-05 — Phase 17)
|
||||
- Replacing existing `print()` calls for user-facing output (preserved as-is)
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Logger Hierarchy
|
||||
- **D-01:** Use `logging.getLogger(__name__)` standard pattern
|
||||
- Produces natural hierarchy: `paperforge.worker.sync`, `paperforge.commands.sync`, etc.
|
||||
- Enables per-module filtering for debugging
|
||||
- **D-02:** Entry point: `paperforge/logging_config.py` with a single `configure_logging(verbose: bool)` function
|
||||
- **D-03:** `configure_logging()` uses `logging.basicConfig()` or `dictConfig()` to set root `paperforge` logger to INFO (default) or DEBUG (when `--verbose`)
|
||||
|
||||
### Verbose Flag
|
||||
- **D-04:** Add `--verbose`/`-v` to the **root parser** (global flag), inherited by all subcommands
|
||||
- Replaces current per-command `--verbose` on `deep-reading` and `repair`
|
||||
- Maintains backward compat: those commands still accept the flag
|
||||
- **D-05:** `--verbose` maps to `logging.DEBUG` level on the `paperforge` logger
|
||||
- **D-06:** `PAPERFORGE_LOG_LEVEL` env var controls default log level (accepts `DEBUG`/`INFO`/`WARNING`/`ERROR`); default is `INFO`
|
||||
|
||||
### Dual-Output Boundary
|
||||
- **D-07:** Classification of existing `print()` calls:
|
||||
- **stdout (preserve as `print()`):** `status.py` (system info), `sync.py` (dry-run output), `deep_reading.py` (queue stats), `ocr.py` (command progress)
|
||||
- **stderr (migrate to `logging`):** `repair.py` (diagnostic `[repair]` messages), `cli.py` (error messages already on stderr)
|
||||
- **D-08:** `print()` kept for user-facing stdout output — downstream consumers (Agent scripts, pipes) rely on stdout format stability
|
||||
|
||||
### Error and Edge Case Handling
|
||||
- **D-09:** Invalid `PAPERFORGE_LOG_LEVEL` value → silent fallback to `WARNING`
|
||||
- **D-10:** Early-boot logging (before `configure_logging()` is called) → use `logging.basicConfig(level=WARNING)` as an initial setup; `configure_logging()` replaces this with full configuration when called
|
||||
|
||||
### the agent's Discretion
|
||||
- Exact formatter style (timestamp format, module name display)
|
||||
- Whether to use `dictConfig` or programmatic `basicConfig`
|
||||
- Handler configuration (StreamHandler to stderr, format string details)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements (Phase 13 scope)
|
||||
- `.planning/REQUIREMENTS.md` — OBS-01 (logging_config.py), OBS-02 (dual-output), OBS-03 (--verbose flag)
|
||||
|
||||
### Architecture and Prior Decisions
|
||||
- `.planning/STATE.md` §v1.4 Key Decisions — Dual-output strategy confirmed
|
||||
- `docs/ARCHITECTURE.md` — Two-layer design, data flow
|
||||
|
||||
### Existing Code
|
||||
- `paperforge/cli.py` — Root parser design, existing --verbose on deep-reading + repair
|
||||
- `paperforge/config.py` — Config loading pattern (used as reference for logging_config.py single-purpose module)
|
||||
- `paperforge/worker/repair.py` — `[repair]` diagnostic print() calls to migrate
|
||||
- `paperforge/commands/deep.py` — Current verbose pass-through pattern
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `paperforge/cli.py` line 171: existing `--verbose` on `deep-reading` subcommand — pattern to generalize to root parser
|
||||
- `paperforge/cli.py` line 333: already imports `logging.getLogger("paperforge")` in `base-refresh` handler — shows the import pattern works
|
||||
|
||||
### Established Patterns
|
||||
- Worker modules import via `_get_run_*()` lazy loader pattern in `commands/`
|
||||
- CLI dispatches via `commands/*.run(args)` pattern — args carry all parsed values
|
||||
- Config module pattern: single-purpose file (`config.py`) with pure functions
|
||||
|
||||
### Integration Points
|
||||
- `paperforge/cli.py` `main()` — Add `configure_logging()` call after arg parsing, before command dispatch
|
||||
- `paperforge/commands/*.run(args)` — Existing verbose flag on `args` extended via root parser
|
||||
- Each worker module — Add `logger = logging.getLogger(__name__)` at module level
|
||||
- `paperforge/worker/__init__.py` — No changes needed (logging config is external to workers)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "Logging 只接管诊断信息(stderr),不碰用户看到的输出(stdout)。piped commands 必须保持原样。"
|
||||
- "PAPERFORGE_LOG_LEVEL 是唯一的配置项,不需要配置文件或 YAML 配置"
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Progress bars (tqdm) — Phase 16 (OBS-04)
|
||||
- Actionable OCR error message improvement — Phase 17 (OBS-05)
|
||||
- File-based logging (rotating log files) — not in scope; stderr-only for v1.4
|
||||
- Structured JSON logging — not in scope; plain text for v1.4
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 13-logging-foundation*
|
||||
*Context gathered: 2026-04-27*
|
||||
86
.planning/phases/13-logging-foundation/13-DISCUSSION-LOG.md
Normal file
86
.planning/phases/13-logging-foundation/13-DISCUSSION-LOG.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Phase 13: Logging Foundation - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 13-logging-foundation
|
||||
**Areas discussed:** Logger hierarchy design, Verbose flag design, Dual-output boundary rules, Error and edge case handling
|
||||
|
||||
---
|
||||
|
||||
## Logger Hierarchy Design
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| `__name__` standard pattern | Each module uses `logging.getLogger(__name__)`, natural hierarchy | ✓ |
|
||||
| Flat naming | Short names like 'sync', 'ocr' — no hierarchy | |
|
||||
| Mixed mode | Workers use hierarchy, CLI commands use flat | |
|
||||
|
||||
**User's choice:** `__name__` standard pattern (recommended)
|
||||
**Notes:** N/A
|
||||
|
||||
---
|
||||
|
||||
## Verbose Flag Design
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Global --verbose | Add to root parser, inherited by all subcommands | ✓ |
|
||||
| Per-command wiring | Each command adds its own --verbose | |
|
||||
|
||||
**User's choice:** Global --verbose (recommended)
|
||||
**Notes:** Maintain backward compat for existing per-command `--verbose` on deep-reading and repair
|
||||
|
||||
---
|
||||
|
||||
## Dual-Output Boundary Rules
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Agree with classification | status/sync/deep-reading/ocr → stdout (print); repair → stderr (logging) | ✓ |
|
||||
| repair also stdout | Repair reports are user-triggered, should go to stdout | |
|
||||
| Agent discretion | Decide case by case at implementation time | |
|
||||
|
||||
**User's choice:** Agree with classification
|
||||
**Notes:** N/A
|
||||
|
||||
---
|
||||
|
||||
## Error and Edge Case Handling
|
||||
|
||||
### Invalid PAPERFORGE_LOG_LEVEL
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Silent fallback to WARNING | Simplest, no error interruption | ✓ |
|
||||
| Print warning then fallback | Informs user about invalid config | |
|
||||
| Error exit | Strict mode, interrupts workflow | |
|
||||
|
||||
**User's choice:** Silent fallback to WARNING (recommended)
|
||||
**Notes:** N/A
|
||||
|
||||
### Early-boot logging
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| basicConfig then replace | Initial WARNING setup, replaced by configure_logging() later | ✓ |
|
||||
| Delay all logs | NullHandler only, no logs before config | |
|
||||
|
||||
**User's choice:** basicConfig then replace (recommended)
|
||||
**Notes:** N/A
|
||||
|
||||
---
|
||||
|
||||
## The agent's Discretion
|
||||
|
||||
- Exact formatter style (timestamp format, module name display)
|
||||
- Whether to use dictConfig or programmatic basicConfig
|
||||
- Handler configuration (StreamHandler to stderr, format string details)
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Progress bars (tqdm) — Phase 16
|
||||
- Actionable OCR error message improvement — Phase 17
|
||||
- File-based logging — not in scope for v1.4
|
||||
- Structured JSON logging — not in scope for v1.4
|
||||
190
.planning/phases/13-logging-foundation/13-RESEARCH.md
Normal file
190
.planning/phases/13-logging-foundation/13-RESEARCH.md
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# Research: Phase 13 — Logging Foundation
|
||||
|
||||
**Research Date:** 2026-04-27
|
||||
**Audit Type:** Codebase `print()` landscape, CLI parser structure, logging integration points
|
||||
**Phase Requirements:** OBS-01, OBS-02, OBS-03
|
||||
|
||||
---
|
||||
|
||||
## 1. Current `print()` Landscape
|
||||
|
||||
### 1.1 Stdout — User-Facing Output (PRESERVE as `print()`)
|
||||
|
||||
These are the summary/status lines that users and downstream consumers (Agent scripts, pipes) rely on. They MUST remain on stdout unchanged.
|
||||
|
||||
| File | Line | Statement | Classification |
|
||||
|------|------|-----------|----------------|
|
||||
| `worker/sync.py` | 881 | `print(f'selection-sync: wrote {written} records, updated {updated} records')` | User-facing summary |
|
||||
| `worker/sync.py` | 1255 | `print(f'search: task written -> {task_path}')` | User-facing summary |
|
||||
| `worker/sync.py` | 1402 | `print(f'index-refresh: wrote {len(index_rows)} index rows')` | User-facing summary |
|
||||
| `worker/sync.py` | 1442 | `print(f'index-refresh: cleaned {deleted_count} orphaned records...')` | User-facing summary |
|
||||
| `worker/deep_reading.py` | 322 | `print(f'deep-reading: synced {synced} records, {len(pending_queue)} pending')` | User-facing summary |
|
||||
| `worker/ocr.py` | 71 | `print(f"[INFO] Processing specific key: {key}")` | User-facing status |
|
||||
| `worker/ocr.py` | 1374 | `print(f'ocr: updated {changed} records')` | User-facing summary |
|
||||
| `worker/status.py` | 598-613 | `print('PaperForge Lite status')` + system info | User-facing status |
|
||||
| `worker/status.py` | 518-540 | `print("PaperForge Lite Doctor")` + check results | User-facing status |
|
||||
| `commands/sync.py` | 65-75 | `print("[DRY-RUN]")`, `print(" - selection-sync")` etc. | User-facing dry-run preview |
|
||||
| `commands/ocr.py` | 15-24 | `print(f"OCR Doctor...")` etc. | User-facing diagnostic report |
|
||||
| `commands/repair.py` | 53-54 | `print(repair summary)` | User-facing repair summary |
|
||||
|
||||
**Total stdout-only print() calls to preserve: ~12 locations**, mostly single-line status summaries at function exit points. These are the contract with downstream consumers.
|
||||
|
||||
### 1.2 Stderr — Diagnostic Output (MIGRATE to `logging`)
|
||||
|
||||
These are `[repair]`-tagged diagnostic messages in `worker/repair.py` and similar diagnostic print() calls. They belong on stderr via `logging`.
|
||||
|
||||
| File | Lines | Pattern | Migration Target |
|
||||
|------|-------|---------|------------------|
|
||||
| `worker/repair.py` | 236 | `print(f"[repair] error reading {record_path}: {e}")` | `logger.error()` |
|
||||
| `worker/repair.py` | 303-304 | `print(f"[repair] error loading export for {domain}: {e}")` | `logger.error()` |
|
||||
| `worker/repair.py` | 339-340 | `print(f"[repair] fixed path for {zotero_key}: ...")` | `logger.info()` |
|
||||
| `worker/repair.py` | 357 | `print(f"[repair] cleared path_error for {zotero_key}")` | `logger.info()` |
|
||||
| `worker/repair.py` | 360 | `print(f"[repair] {zotero_key} path still unresolved")` | `logger.warning()` |
|
||||
| `worker/repair.py` | 363 | `print(f"[repair] {zotero_key} has empty pdf_path...")` | `logger.warning()` |
|
||||
| `worker/repair.py` | 368 | `print(f"[repair] {zotero_key} has no pdf_path field")` | `logger.warning()` |
|
||||
| `worker/repair.py` | 423 | `print(f"[repair] {zotero_key} meta validation error: ...")` | `logger.warning()` |
|
||||
| `worker/repair.py` | 456 | `print(f"[repair] divergent: {zotero_key} | {div_reason}")` | `logger.info()` |
|
||||
| `worker/repair.py` | 526 | `print(f"[repair] fixed {fixed_count} files for {zotero_key}")` | `logger.info()` |
|
||||
| `worker/repair.py` | 533 | `print(f"[repair] Found {total} items with path errors: ...")` | `logger.info()` |
|
||||
| `worker/repair.py` | 540 | `print(f"[repair] Fixed {fixed_count} PDF paths")` | `logger.info()` |
|
||||
| `worker/repair.py` | 542 | `print("[repair] Tip: run with --fix-paths...")` | `logger.info()` |
|
||||
| `worker/repair.py` | 544 | `print("[repair] No path errors found")` | `logger.info()` |
|
||||
| `commands/ocr.py` | 78-87 | `print("\n[Auto-diagnose]...")`, `print("[WARN] ...")` | `logger.info()` / `logger.warning()` |
|
||||
| `update.py` | 227 | `print(_color(msg, c))` via `_log()` | `logger.info()` |
|
||||
|
||||
**Total diagnostic print() calls to migrate: ~20 locations across 3 files** (repair.py, commands/ocr.py, update.py).
|
||||
|
||||
### 1.3 Already on Stderr
|
||||
|
||||
- `cli.py:269` — `print(f"Error: {exc}", file=sys.stderr)` — already goes to stderr, keep as-is or optionally migrate to `logger.error()`
|
||||
|
||||
---
|
||||
|
||||
## 2. Existing Logging Usage
|
||||
|
||||
Only **one module** currently uses Python logging:
|
||||
|
||||
- **`paperforge/pdf_resolver.py`**: `import logging` (line 8), `logger = logging.getLogger(__name__)` (line 12), `logger.error(...)` (line 66)
|
||||
|
||||
Additionally, `cli.py` line 333 uses a direct one-shot logging call:
|
||||
```python
|
||||
logger = __import__("logging").getLogger("paperforge")
|
||||
logger.info(f"Refreshing Base views in {paths['bases']}")
|
||||
```
|
||||
|
||||
This confirms that:
|
||||
- `logging.getLogger("paperforge")` works and resolves correctly
|
||||
- The pattern is compatible with the existing codebase
|
||||
- No `basicConfig()` or handler setup exists yet anywhere in the codebase
|
||||
|
||||
---
|
||||
|
||||
## 3. CLI Parser Structure
|
||||
|
||||
### 3.1 Current `--verbose` Locations
|
||||
|
||||
| Subcommand | Line in cli.py | Current Behavior |
|
||||
|------------|----------------|------------------|
|
||||
| `deep-reading` | 171 | `--verbose, -v` — passes to `run_deep_reading(vault, verbose=True)` via `commands/deep.py` |
|
||||
| `repair` | 178 | `--verbose, -v` — passes to `run_repair(vault, paths, verbose=True, ...)` via `commands/repair.py` |
|
||||
|
||||
### 3.2 Global Flag Opportunity
|
||||
|
||||
Currently, `--verbose` is per-subcommand. The plan is to promote it to a **root parser flag** (global), inherited by all subcommands. The `argparse` root parser supports `parser.add_argument("--verbose", "-v", ...)` before `add_subparsers()`, and subcommands automatically inherit parent parser arguments when using `parents=[]` — but only if explicitly wired.
|
||||
|
||||
**Alternative approach (simpler):** Add `--verbose` to the root parser, then access it via `args.verbose` in every command dispatch. argparse propagates root-level args to all subcommands by default when using `add_subparsers()` without `parents=[]` override.
|
||||
|
||||
**Current pass-through chain (deep-reading example):**
|
||||
```
|
||||
cli.py: build_parser() → adds --verbose to deep-reading subparser
|
||||
cli.py: main() → args.verbose available on Namespace
|
||||
cli.py: deep.run(args) → commands/deep.py: run(args) → run_deep_reading(vault, verbose=args.verbose)
|
||||
worker/deep_reading.py: run_deep_reading(vault, verbose) → uses verbose for [repair] print branching
|
||||
```
|
||||
|
||||
**For sync and ocr:** Neither `run_selection_sync(vault)` nor `run_ocr(vault)` accept `verbose` yet — these signatures need updating.
|
||||
|
||||
---
|
||||
|
||||
## 4. Integration Points
|
||||
|
||||
### 4.1 CLI Entry Point
|
||||
|
||||
The `configure_logging()` call MUST be inserted in `cli.py:main()` after arg parsing and before command dispatch. The current flow:
|
||||
|
||||
```
|
||||
cli.py:main()
|
||||
1. _resolve_pipeline()
|
||||
2. _import_worker_functions()
|
||||
3. parser = build_parser()
|
||||
4. args = parser.parse_args(argv)
|
||||
5. vault = resolve_vault(...)
|
||||
6. load_simple_env(...)
|
||||
7. cfg = load_vault_config(...)
|
||||
8. args.vault_path, args.cfg, args.paths set
|
||||
9. Command dispatch: sync.run(args), ocr.run(args), etc.
|
||||
```
|
||||
|
||||
**Insertion point:** After step 8, before step 9:
|
||||
```python
|
||||
from paperforge.logging_config import configure_logging
|
||||
configure_logging(verbose=getattr(args, "verbose", False))
|
||||
```
|
||||
|
||||
### 4.2 Each Worker Module
|
||||
|
||||
Add at module level:
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
```
|
||||
|
||||
Then replace `print(...)` with:
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| `print(f"[repair] ...")` | `logger.info(...)`, `logger.warning(...)`, or `logger.error(...)` |
|
||||
| (keep stdout print()) | (no change) |
|
||||
|
||||
### 4.3 Command Module Updates
|
||||
|
||||
| Module | Change |
|
||||
|--------|--------|
|
||||
| `commands/deep.py` | Already passes `verbose` — no change needed |
|
||||
| `commands/repair.py` | Already passes `verbose` — no change needed |
|
||||
| `commands/sync.py` | Add `verbose` passthrough: `run_selection_sync(vault, verbose=args.verbose)` |
|
||||
| `commands/ocr.py` | Add `verbose` passthrough: `run_ocr(vault, verbose=args.verbose)` |
|
||||
| `commands/status.py` | Add `verbose` passthrough if status needs debug output |
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Findings Summary
|
||||
|
||||
| Finding | Detail |
|
||||
|---------|--------|
|
||||
| **12 stdout `print()` locations** | Must stay as `print()` — user-facing summaries |
|
||||
| **~20 diagnostic `print()` locations** | In `repair.py` (mostly `[repair]` tagged), `commands/ocr.py`, `update.py` — migrate to `logging` |
|
||||
| **1 existing logging user** | `pdf_resolver.py` — confirms pattern works |
|
||||
| **1 existing one-shot logging call** | `cli.py:333` — confirms `logging.getLogger("paperforge")` resolves |
|
||||
| **2 subcommands with `--verbose`** | `deep-reading`, `repair` — already wired through to worker |
|
||||
| **3 subcommands without `--verbose`** | `sync`, `ocr`, `status` — need new verbose passthrough |
|
||||
| **No `basicConfig()` anywhere** | First-time setup needed in `logging_config.py` |
|
||||
| **diagnostic print() guard pattern** | All `[repair]` diagnostic calls are gated by `if verbose:` blocks |
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk Assessment
|
||||
|
||||
| Risk | Level | Mitigation |
|
||||
|------|-------|------------|
|
||||
| Accidentally converting stdout print() to logging | Low | Clear classification in task actions (use the table above) |
|
||||
| `logging.basicConfig()` called multiple times | Low | `configure_logging()` checks `logging.getLogger().hasHandlers()` |
|
||||
| Verbose flag wiring missed for sync/ocr | Low | Explicit tasks for each command module |
|
||||
| Early-boot logging before configure_logging() | Low | D-10: initial `basicConfig(level=WARNING)` as safety net |
|
||||
| Import cycle from `logging_config.py` to worker modules | None | `logging_config.py` imports only stdlib `logging` — pure leaf module |
|
||||
| Test patching of `cli.run_*` stubs | Low | Worker modules import logging at module level; tests can set log level independently |
|
||||
|
||||
---
|
||||
|
||||
*Research completed: 2026-04-27*
|
||||
*Phase: 13-logging-foundation*
|
||||
*Next: Spawn gsd-planner to create PLAN.md files*
|
||||
164
.planning/phases/13-logging-foundation/13-VERIFICATION.md
Normal file
164
.planning/phases/13-logging-foundation/13-VERIFICATION.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
---
|
||||
phase: 13-logging-foundation
|
||||
verified: 2026-04-27T16:30:00Z
|
||||
status: passed
|
||||
score: 12/12 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 13: Logging Foundation Verification Report
|
||||
|
||||
**Phase Goal:** Structured, level-based logging infrastructure with zero behavioral change to user-facing output — sets the stage for all subsequent observability work.
|
||||
**Verified:** 2026-04-27T16:30:00Z
|
||||
**Status:** PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### ROADMAP Success Criteria
|
||||
|
||||
| # | Success Criterion | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | All worker modules use `logging.getLogger(__name__)` instead of bare `print()` for diagnostic/trace/error output | **VERIFIED** | 7 worker + 5 command modules have `import logging; logger = logging.getLogger(__name__)`. 50+ diagnostic print() calls migrated to logger.*() |
|
||||
| 2 | `--verbose`/`-v` flag on `paperforge sync`, `paperforge ocr`, and `paperforge deep-reading` enables DEBUG-level output on stderr | **VERIFIED** | Global `--verbose`/`-v` on root parser wired through all 5 command modules via `verbose=getattr(args, "verbose", False)`. `configure_logging(verbose=True)` sets DEBUG level. All 7 worker functions accept `verbose: bool = False` |
|
||||
| 3 | User-facing status messages continue to appear on stdout unchanged — piped commands remain unbroken | **VERIFIED** | print() preserved for user-facing output in sync.py (4 calls), status.py (23 calls), deep_reading.py (1 call), ocr.py worker (1), repair.py (4). LogStreamHandler targets stderr. Dual-output boundary confirmed: stdout="" stderr="INFO:paperforge.worker.test:message" |
|
||||
| 4 | `PAPERFORGE_LOG_LEVEL` env var (accepting `DEBUG`/`INFO`/`WARNING`/`ERROR`) controls default log level | **VERIFIED** | All 4 levels tested with `python -c` assertions. `INFO` is default. Invalid values silently fall back to `WARNING`. `verbose=True` overrides to `DEBUG` |
|
||||
| 5 | `paperforge/logging_config.py` exists as single `configure_logging(verbose)` call point; no scattered `basicConfig()` calls | **VERIFIED** | `logging_config.py` exists (69 lines). `configure_logging()` is only call point. Idempotency guard prevents double configuration. Zero `basicConfig()` or `dictConfig()` calls exist outside docstring |
|
||||
|
||||
**Score:** 5/5 success criteria verified
|
||||
|
||||
---
|
||||
|
||||
### Observable Truths (from PLAN must_haves)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|---|---|---|
|
||||
| 1 | paperforge/logging_config.py exists as the single entry point for logging configuration | **VERIFIED** | File exists at `paperforge/logging_config.py`, 69 lines. Exports single `configure_logging(verbose: bool = False) -> None` function |
|
||||
| 2 | All subcommands accept `--verbose`/`-v` flag from the root parser | **VERIFIED** | Root parser has `--verbose`/`-v`. Subcommand parsers (deep-reading, repair) no longer have their own `--verbose` |
|
||||
| 3 | configure_logging() is called once in cli.py:main() before command dispatch | **VERIFIED** | Line 284 of cli.py: `configure_logging(verbose=getattr(args, "verbose", False))` placed before command dispatch at line 289 |
|
||||
| 4 | PAPERFORGE_LOG_LEVEL env var controls default log level (INFO/WARNING/ERROR/DEBUG) | **VERIFIED** | Reads `os.environ.get("PAPERFORGE_LOG_LEVEL", "INFO")`. All 4 levels work. Default is INFO |
|
||||
| 5 | Invalid PAPERFORGE_LOG_LEVEL values silently fall back to WARNING | **VERIFIED** | `PAPERFORGE_LOG_LEVEL=BANANA` tested → logger level = `logging.WARNING` (30) |
|
||||
| 6 | verbose=True overrides to DEBUG level regardless of PAPERFORGE_LOG_LEVEL | **VERIFIED** | `configure_logging(verbose=True)` sets logger to DEBUG even with env var at non-DEBUG level |
|
||||
| 7 | All 7 worker modules have `logger = logging.getLogger(__name__)` at module level | **VERIFIED** | repair.py, deep_reading.py, ocr.py, sync.py, status.py, base_views.py, update.py — all confirmed via Python import test |
|
||||
| 8 | All 5 command modules have `logger = logging.getLogger(__name__)` at module level | **VERIFIED** | commands/ocr.py, repair.py, deep.py, sync.py, status.py — all confirmed via Python import test |
|
||||
| 9 | All `[repair]` tagged diagnostic print() calls in worker/repair.py use logger.*() instead | **VERIFIED** | 0 remaining `print(f"[repair]..."` calls in repair.py. 4 user-facing summary print() calls preserved (lines 530-541) |
|
||||
| 10 | Diagnostic print() calls in commands/ocr.py use logger.*() instead | **VERIFIED** | 0 remaining `print(f"[INFO]..."` or `print(f"[WARN]..."` calls in commands/ocr.py |
|
||||
| 11 | update.py _log() uses logger.info() instead of print() | **VERIFIED** | `_log()` and `_color()` functions removed. 37 logger.*() calls in update.py |
|
||||
| 12 | All stdout user-facing print() calls remain unchanged | **VERIFIED** | sync.py (4 print calls), deep_reading.py (1), status.py (23), ocr.py worker (1), repair.py (4) — all user-facing stdout output preserved |
|
||||
|
||||
**Score:** 12/12 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|---|---|---|---|
|
||||
| `paperforge/logging_config.py` | Single `configure_logging(verbose)` entry point, >=40 lines | **VERIFIED** | 69 lines, exports `configure_logging()` + `get_paperforge_logger()`. Contains stderr StreamHandler, idempotency guard, env var reading |
|
||||
| `paperforge/cli.py` | Global `--verbose` flag on root parser, `configure_logging()` call in main() | **VERIFIED** | Lines 128-132: `--verbose`/`-v` on root parser. Line 284: `configure_logging()` call before dispatch |
|
||||
| `paperforge/worker/repair.py` | Logger module instance with all diagnostic prints migrated, >=548 lines | **VERIFIED** | `import logging; logger = logging.getLogger(__name__)` present. All [repair] diagnostic prints migrated. 545 lines total |
|
||||
| `paperforge/update.py` (standalone) | _log() uses logger.info() instead of print() | **NOT APPLICABLE** | File doesn't exist (per 13-02-SUMMARY.md). `paperforge/worker/update.py` handles update logic with 37 logger.*() calls |
|
||||
| `paperforge/commands/sync.py` | verbose passthrough from args to worker functions | **VERIFIED** | Lines 85, 91: `verbose=getattr(args, "verbose", False)` in both worker calls |
|
||||
| `paperforge/commands/ocr.py` | verbose passthrough from args to run_ocr | **VERIFIED** | Line 77: `verbose=getattr(args, "verbose", False)` in run_ocr call |
|
||||
| `paperforge/commands/status.py` | verbose passthrough from args to run_status | **VERIFIED** | Line 43: `verbose=getattr(args, "verbose", False)` in run_status call |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|---|---|---|---|---|
|
||||
| `logging_config.py` | `cli.py:main()` | `configure_logging(verbose=args.verbose)` | **WIRED** | Line 284 of cli.py imports and calls `configure_logging` |
|
||||
| `logging_config.py` | `os.environ` | `PAPERFORGE_LOG_LEVEL` env var | **WIRED** | Line 37 reads `os.environ.get("PAPERFORGE_LOG_LEVEL", "INFO")` |
|
||||
| `commands/*.py` | `worker/*.py` | `verbose=getattr(args, "verbose", False) -> worker_func(verbose=...)` | **WIRED** | All 5 command modules use this pattern; all 7 worker functions accept the param |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|---|---|---|---|---|
|
||||
| `logging_config.py` | `level` | `os.environ.get("PAPERFORGE_LOG_LEVEL")` + `verbose` flag | Yes — reads env var at runtime, uses verbose flag from argparse | **FLOWING** |
|
||||
| `cli.py:configure_logging()` | `verbose` | `getattr(args, "verbose", False)` | Yes — flows from argparse root parser through to logging config | **FLOWING** |
|
||||
| `commands/{sync,ocr,status,deep,repair}.py` | `verbose` | `getattr(args, "verbose", False)` | Yes — passed to worker function calls | **FLOWING** |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|---|---|---|---|
|
||||
| configure_logging(verbose=True) sets DEBUG | `python -c "configure_logging(verbose=True); assert level==DEBUG"` | PASS | **PASS** |
|
||||
| Invalid env var falls back to WARNING | `PAPERFORGE_LOG_LEVEL=BANANA python -c "assert level==WARNING"` | PASS | **PASS** |
|
||||
| PAPERFORGE_LOG_LEVEL=DEBUG works | `PAPERFORGE_LOG_LEVEL=DEBUG python -c "assert level==DEBUG"` | PASS | **PASS** |
|
||||
| Dual-output: logging goes to stderr, not stdout | `python -c "log('test'); assert stdout==''; assert stderr contains msg"` | PASS | **PASS** |
|
||||
| CLI --verbose accepted | `paperforge.cli.build_parser().parse_args(['--verbose', 'status'])` | PASS | **PASS** |
|
||||
| CLI -v accepted | `paperforge.cli.build_parser().parse_args(['-v', 'status'])` | PASS | **PASS** |
|
||||
| CLI without -v defaults to False | `paperforge.cli.build_parser().parse_args(['status'])` | PASS | **PASS** |
|
||||
| deep-reading subparser has NO own --verbose | `inspect subparser actions` | PASS | **PASS** |
|
||||
| repair subparser has NO own --verbose | `inspect subparser actions` | PASS | **PASS** |
|
||||
| Idempotency: 2 calls = 1 handler | `configure_logging(); configure_logging(); assert len(handlers)==1` | PASS | **PASS** |
|
||||
| All 12 module loggers import correctly | `python -c import all 12 modules, check .logger.name` | PASS | **PASS** |
|
||||
| All 7 worker functions accept verbose | `inspect.signature` check for all 7 | PASS | **PASS** |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|---|---|---|---|---|
|
||||
| OBS-01 | 13-01, 13-02 | Create `paperforge/logging_config.py` — all workers/commands use `logging.getLogger(__name__)` | **SATISFIED** | `logging_config.py` exists. All 12 modules have module-level loggers |
|
||||
| OBS-02 | 13-01, 13-02 | Dual-output: stdout=user-facing print(), stderr=diagnostic logging | **SATISFIED** | stderr StreamHandler in logging_config.py. User-facing print() preserved. Verified stdout="" stderr="..." |
|
||||
| OBS-03 | 13-01, 13-03 | `--verbose`/`-v` on sync, ocr, deep-reading enables DEBUG level | **SATISFIED** | Global root parser flag. All command modules wire verbose. Worker functions accept verbose. configure_logging(verbose=True) sets DEBUG |
|
||||
|
||||
All requirement IDs from PLAN frontmatter are accounted for. No orphaned requirements detected.
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|---|---|---|---|---|
|
||||
| `paperforge/worker/repair.py` | 537 | **KEPT user-facing print()** — `print(f"[repair] Fixed {fixed_count} PDF paths")` | INFO | Intentional. This is a user-facing summary message on stdout, preserved per OBS-02 dual-output strategy |
|
||||
|
||||
No blocker or warning anti-patterns found. The only hit is an intentional user-facing print preserved by design.
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
No items flagged for human verification. All 5 ROADMAP success criteria are programmatically verified against the codebase.
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps found. Phase goal fully achieved.
|
||||
|
||||
| Area | Status | Notes |
|
||||
|---|---|---|
|
||||
| `logging_config.py` | COMPLETE | 69 lines, single entry point, env var control, idempotent |
|
||||
| CLI --verbose flag | COMPLETE | Root parser, all subcommands inherit. deep-reading/repair subparser flags removed |
|
||||
| Module-level loggers | COMPLETE | All 12 modules: 7 worker + 5 command |
|
||||
| Diagnostic print migration | COMPLETE | repair.py (10), ocr.py (4), update.py (36) migrated |
|
||||
| Verbose wiring | COMPLETE | 5 command modules wire args.verbose, 7 worker functions accept it |
|
||||
| Dual-output boundary | COMPLETE | stdout=print(), stderr=logging. Verified programmatically |
|
||||
| Env var control | COMPLETE | PAPERFORGE_LOG_LEVEL with validation and fallback |
|
||||
| No scattered basicConfig | COMPLETE | Zero calls outside docstring comment |
|
||||
|
||||
---
|
||||
|
||||
### Deviations from Plan (Documented in Summaries)
|
||||
|
||||
1. **argparse root flags not inherited by subparsers** (13-01): `deep-reading -v` syntax no longer works. Users must use `paperforge -v deep-reading`. Acceptable trade-off.
|
||||
2. **paperforge/update.py doesn't exist** (13-02): Standalone `paperforge/update.py` is not on disk. The actual file is `paperforge/worker/update.py` which was correctly modified.
|
||||
3. **input() prompt color removed** (13-02): `_color()` function was removed as part of `_log()` elimination. `input()` prompt converted to plaintext.
|
||||
|
||||
None of these affect goal achievement.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-04-27T16:30:00Z_
|
||||
_Verifier: the agent (gsd-verifier)_
|
||||
129
.planning/phases/14-shared-utilities-extraction/14-CONTEXT.md
Normal file
129
.planning/phases/14-shared-utilities-extraction/14-CONTEXT.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Phase 14: Shared Utilities Extraction - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Create `paperforge/worker/_utils.py` as a pure leaf module containing all duplicated utility functions (~1,610 lines removed from 7 workers). All 7 worker modules import from `_utils.py` instead of defining local copies. Backward compatibility preserved via re-exports. Zero behavioral change.
|
||||
|
||||
Requirements: CH-01, CH-02, CH-05, TEST-03
|
||||
|
||||
Out of scope:
|
||||
- Deep-reading queue merge (CH-03 — Phase 15)
|
||||
- Dead code removal / unused imports (CH-04 — Phase 17)
|
||||
- Consistency audit hook (DX-01 — Phase 17)
|
||||
- Unit tests for `_utils.py` (TEST-04 — Phase 19)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### File Structure
|
||||
- **D-01:** `_utils.py` organized by category with section headers:
|
||||
- `# --- JSON I/O ---` : `read_json`, `write_json`, `read_jsonl`, `write_jsonl`
|
||||
- `# --- YAML Helpers ---` : `yaml_quote`, `yaml_block`, `yaml_list`
|
||||
- `# --- String / Path Utils ---` : `slugify_filename`, `_extract_year`
|
||||
- `# --- Journal Database ---` : `_JOURNAL_DB`, `load_journal_db`, `lookup_impact_factor`
|
||||
- `# --- Constants ---` : `STANDARD_VIEW_NAMES`
|
||||
- ~150 lines total
|
||||
- `load_simple_env` stays in `base_views.py` (defined once, not duplicated across workers)
|
||||
|
||||
### Module-Level State
|
||||
- **D-02:** `_JOURNAL_DB` remains a module-level global cache in `_utils.py`. No `lru_cache` or class wrapping — simplest approach, zero behavioral change, read-only after first load so shared state is safe.
|
||||
|
||||
### Naming
|
||||
- **D-03:** `STANDARD_VIEW_NAMES` (no underscore prefix) — matches existing `base_views.py` export, consistent with current behavior.
|
||||
|
||||
### Leaf Module Constraint
|
||||
- **D-04:** `_utils.py` imports ONLY from stdlib and `paperforge.config`. Zero imports from `paperforge.worker.*` or `paperforge.commands.*`. Circular import firebreak.
|
||||
|
||||
### Backward Compatibility
|
||||
- **D-05:** Each original worker module retains re-exports with `# Re-exported from _utils.py` comments at the site of the original function definition.
|
||||
- **D-06:** All 7 workers (`sync.py`, `ocr.py`, `deep_reading.py`, `repair.py`, `status.py`, `update.py`, `base_views.py`): replace local function definitions with `from paperforge.worker._utils import read_json, write_json, ...`.
|
||||
|
||||
### Module-Level Logger
|
||||
- **D-07:** `_utils.py` gets `logger = logging.getLogger(__name__)` following Phase 13 pattern. Each worker retains its own `logger = logging.getLogger(__name__)` unchanged.
|
||||
|
||||
### the agent's Discretion
|
||||
- Exact order of functions within each category section
|
||||
- Whether to use `"""docstrings"""` on re-export imports
|
||||
- Split strategy: all 7 workers in one commit vs sequential
|
||||
- Error handling in `read_json(write_json` etc. (keep existing bare `json.loads`/`json.dumps`)
|
||||
- Whether to inline `_extract_year` as a module-private function (keep underscore prefix)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements (Phase 14 scope)
|
||||
- `.planning/REQUIREMENTS.md` — CH-01 (function list for _utils.py), CH-02 (worker migration), CH-05 (test pass), TEST-03 (regression bar)
|
||||
- `.planning/ROADMAP.md` §Phase 14 — Success criteria, scope boundary, leaf module constraint
|
||||
- `.planning/PROJECT.md` — Leaf module constraint rationale, v1.4 architecture decisions
|
||||
|
||||
### Existing Code (source of truth)
|
||||
- `paperforge/worker/base_views.py:47-134` — Canonical source of all utility function implementations (most authoritative copy)
|
||||
- `paperforge/worker/base_views.py:26-29` — `STANDARD_VIEW_NAMES` definition
|
||||
- `paperforge/worker/deep_reading.py:51-140` — Second copy of same utilities (confirms identical behavior)
|
||||
- `paperforge/worker/sync.py:47-135` — Third copy
|
||||
- `paperforge/worker/ocr.py:53-142` — Fourth copy
|
||||
- `paperforge/worker/repair.py:55-145` — Fifth copy
|
||||
- `paperforge/worker/status.py:49-140` — Sixth copy
|
||||
- `paperforge/worker/update.py:47-135` — Seventh copy
|
||||
|
||||
### Patterns (reference for module structure)
|
||||
- `paperforge/logging_config.py` — Single-purpose module pattern (Phase 13 precedent)
|
||||
- `paperforge/config.py` — Config module pattern (single-purpose, pure functions)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- All 7 workers: identical utility function blocks ~15 lines each, only difference is module-level `_JOURNAL_DB` position
|
||||
- `base_views.py:47-134` is the cleanest copy — use as the migration source of truth
|
||||
- `_utils.py` will be ~150 lines; compare to `logging_config.py` (69 lines) for module-size precedent
|
||||
|
||||
### Established Patterns
|
||||
- Phase 13 `logging_config.py`: single-purpose module with no imports from workers — exact pattern `_utils.py` should follow
|
||||
- Phase 12 architecture: workers live under `paperforge/worker/`, import from each other via lazy imports to break cycles
|
||||
- `_utils.py` must NOT use lazy imports — it's a leaf module, no cycles allowed
|
||||
|
||||
### Integration Points
|
||||
- `paperforge/worker/__init__.py` — No changes needed (exports are function-level, not utility-level)
|
||||
- Each worker module — Replace top-level function definitions with `from paperforge.worker._utils import ...`
|
||||
- Tests — `pytest tests/ -x` after migration verifies no regression
|
||||
- `paperforge/worker/_utils.py` — New file, lives alongside the 7 workers
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "分组,按 JSON I/O / YAML / 字符串 / 期刊数据库 分组,每组大约 3-4 个函数"
|
||||
- "_JOURNAL_DB 保持 module global,简单,零行为变更"
|
||||
- "STANDARD_VIEW_NAMES 保持现状,不带下划线"
|
||||
- "提取过程不应该改变任何行为 — 纯重构"
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Deep-reading queue merge into `_utils.py` — Phase 15 (CH-03)
|
||||
- Unit tests for `_utils.py` functions — Phase 19 (TEST-04)
|
||||
- Consistency audit for duplicate function detection — Phase 17 (DX-01)
|
||||
- Dead code / unused import removal — Phase 17 (CH-04)
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 14-shared-utilities-extraction*
|
||||
*Context gathered: 2026-04-27*
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Phase 14: Shared Utilities Extraction - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 14-shared-utilities-extraction
|
||||
**Areas discussed:** File structure, _JOURNAL_DB cache pattern, STANDARD_VIEW_NAMES naming
|
||||
|
||||
---
|
||||
|
||||
## File structure in _utils.py
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Grouped (Recommended) | Functions organized by category with section headers (JSON I/O, YAML, String, Journal DB) | ✓ |
|
||||
| Flat | All functions alphabetically ordered, no grouping | |
|
||||
|
||||
**User's choice:** Grouped (Recommended)
|
||||
**Notes:** 4 groups, each 3-4 functions, ~150 lines total. `load_simple_env` excluded (only in base_views.py, not duplicated).
|
||||
|
||||
---
|
||||
|
||||
## _JOURNAL_DB cache pattern
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Keep module global (Recommended) | Same as current pattern, just moved to _utils.py. Zero behavioral change. | ✓ |
|
||||
| functools.lru_cache | Replace global with `@lru_cache(maxsize=1)`. More testable. | |
|
||||
| Class encapsulation | `JournalDB(vault)` class. Cleaner but overengineered for Phase 14. | |
|
||||
|
||||
**User's choice:** Keep module global (Recommended)
|
||||
**Notes:** Cache is read-only after first load; shared state across workers is safe. No mutation concern.
|
||||
|
||||
---
|
||||
|
||||
## STANDARD_VIEW_NAMES naming
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| STANDARD_VIEW_NAMES (Recommended) | Keep current name, matches existing base_views.py export | ✓ |
|
||||
| _STANDARD_VIEW_NAMES | Add underscore to indicate internal module constant | |
|
||||
|
||||
**User's choice:** STANDARD_VIEW_NAMES (Recommended)
|
||||
**Notes:** No underscore prefix.
|
||||
|
||||
---
|
||||
|
||||
## the agent's Discretion
|
||||
|
||||
- Exact order of functions within each category section
|
||||
- Whether to use docstrings on re-export imports
|
||||
- Split strategy: all 7 workers in one commit vs sequential
|
||||
- Error handling in read_json/write_json (keep existing bare json.loads/json.dumps)
|
||||
- Whether to inline _extract_year as module-private (keep underscore prefix)
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Deep-reading queue merge into _utils.py — Phase 15
|
||||
- Unit tests for _utils.py functions — Phase 19
|
||||
- Consistency audit for duplicate function detection — Phase 17
|
||||
148
.planning/phases/15-deep-reading-queue-merge/15-CONTEXT.md
Normal file
148
.planning/phases/15-deep-reading-queue-merge/15-CONTEXT.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# Phase 15: Deep-Reading Queue Merge - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Merge two divergent queue-scanning implementations (`worker/deep_reading.py` and `skills/ld_deep.py`) into a single canonical `scan_library_records()` in `_utils.py`. Pure refactoring -- zero behavioral change to queue filtering, status output, or sync behavior.
|
||||
|
||||
Requirements: CH-03
|
||||
|
||||
Out of scope:
|
||||
- Dead code removal / unused imports (CH-04 -- Phase 17)
|
||||
- Unnecessary delegation wrappers cleanup (Phase 17)
|
||||
- Unit tests for `_utils.py` (TEST-04 -- Phase 19)
|
||||
- Pre-commit hook / consistency audit (DX-01 -- Phase 17)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Function Placement
|
||||
- **D-01:** `scan_library_records()` lives in `paperforge/worker/_utils.py`
|
||||
- Already the shared utilities hub (Phase 14)
|
||||
- Follows `_utils.py`'s leaf module constraint (stdlib + `paperforge.config` only)
|
||||
|
||||
### Function Signature & Return Value
|
||||
- **D-02:** Signature: `scan_library_records(vault: Path) -> list[dict]`
|
||||
- Pure data acquisition -- no categorization, no status sync
|
||||
- **D-03:** Return value structure (`list[dict]`):
|
||||
```python
|
||||
[
|
||||
{
|
||||
"zotero_key": str,
|
||||
"domain": str,
|
||||
"title": str,
|
||||
"analyze": bool,
|
||||
"do_ocr": bool,
|
||||
"deep_reading_status": str, # "pending" | "done"
|
||||
"ocr_status": str, # "pending" | "processing" | "done" | "failed"
|
||||
"note_path": Path | None, # resolved formal note path, None if not found
|
||||
}
|
||||
]
|
||||
```
|
||||
- Returns ALL library records with analyze=true -- not only those with deep_reading_status!=done
|
||||
- Caller filters as needed (e.g., `run_deep_reading()` further filters by status)
|
||||
- OCR status looked up from `meta.json` if available, defaults to `"pending"`
|
||||
- `note_path` resolved via existing `_resolve_formal_note_path()` logic
|
||||
|
||||
### Status Sync Separation
|
||||
- **D-04:** `scan_library_records()` does ONLY scanning -- NO side effects
|
||||
- **D-05:** `run_deep_reading()` in `deep_reading.py` retains:
|
||||
1. Import scanning from `_utils.py`
|
||||
2. Status sync (`deep_reading_status` frontmatter update)
|
||||
3. Queue categorization (ready/waiting/blocked)
|
||||
4. Markdown report generation
|
||||
5. `print()` summary to stdout
|
||||
|
||||
### Backward Compatibility
|
||||
- **D-06:** `deep_reading.py:run_deep_reading()` -- internal refactor: uses `scan_library_records()` from `_utils.py` instead of inline scan. Re-export comment at original scan site.
|
||||
- **D-07:** `ld_deep.py:scan_deep_reading_queue()` -- becomes a thin wrapper that calls `scan_library_records()` and filters/returns. Re-export comment at original definition site.
|
||||
|
||||
### ld_deep.py Import Strategy
|
||||
- **D-08:** Module-level direct import: `from paperforge.worker._utils import scan_library_records`
|
||||
- Same pattern as existing `from paperforge.config import ...` (already used via function-level import)
|
||||
- Cleaner than function-level lazy import
|
||||
- No circular dependency risk (_utils.py is a leaf)
|
||||
|
||||
### OCR Status Lookup
|
||||
- **D-09:** `scan_library_records()` reads `meta.json` at `paths['ocr'] / zotero_key / meta.json` using `read_json()`
|
||||
- Same logic as both current implementations
|
||||
- Graceful: if `meta.json` doesn't exist or is unreadable, `ocr_status` defaults to `"pending"`
|
||||
|
||||
### the agent's Discretion
|
||||
- Exact error handling for malformed library-record frontmatter (keep current lenient regex approach)
|
||||
- Sorting of returned list (current `ld_deep.py` sorts by status/domain/key -- caller can sort, not the shared function's concern)
|
||||
- Whether to include `do_ocr` field in return dict (useful for caller categorization)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements (Phase 15 scope)
|
||||
- `.planning/REQUIREMENTS.md` -- CH-03 (merge queue scanning into _utils.py)
|
||||
- `.planning/ROADMAP.md` -- Phase 15 success criteria (4 items)
|
||||
|
||||
### Source Files (current implementations to merge)
|
||||
- `paperforge/worker/deep_reading.py:133-232` -- `run_deep_reading()` (combined scan + sync + report)
|
||||
- `paperforge/skills/literature-qa/scripts/ld_deep.py:1161-1216` -- `scan_deep_reading_queue()` (pure scan)
|
||||
|
||||
### Prior Phase Context
|
||||
- `.planning/phases/14-shared-utilities-extraction/14-CONTEXT.md` -- _utils.py leaf module constraints, conventions
|
||||
- `.planning/phases/13-logging-foundation/13-CONTEXT.md` -- Logger hierarchy, verbose patterns
|
||||
|
||||
### Supporting Functions
|
||||
- `paperforge/worker/deep_reading.py` -- `_resolve_formal_note_path()` (needed by scan_library_records for note_path resolution; may need extraction to _utils.py or inline in scan)
|
||||
- `paperforge/worker/sync.py` -- `load_export_rows()`, `has_deep_reading_content()`
|
||||
- `paperforge/worker/ocr.py` -- `validate_ocr_meta()`
|
||||
- `paperforge/config.py` -- `paperforge_paths()`, `load_domain_config()`
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `_utils.py` already has `read_json()` for OCR meta.json reading
|
||||
- `deep_reading.py` already has `_resolve_formal_note_path()` function -- scan_library_records may need this or a simplified version
|
||||
|
||||
### Established Patterns
|
||||
- Phase 14 `_utils.py` migration pattern: extract to shared module + re-export with comment in original module
|
||||
- `ld_deep.py` function-level imports for `paperforge.config` -- precedent for cross-package import
|
||||
- `deep_reading.py` regex-based frontmatter extraction (re.search multiline) -- to be consolidated
|
||||
|
||||
### Integration Points
|
||||
- `paperforge/worker/_utils.py` -- new function `scan_library_records()` added, in a new section `# --- Deep-Reading Queue ---`
|
||||
- `paperforge/worker/deep_reading.py` -- replace inline scan with call to `scan_library_records()`
|
||||
- `paperforge/skills/literature-qa/scripts/ld_deep.py` -- replace inline scan with call to `scan_library_records()`, add module-level import
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "scan_library_records() 只返回数据,不做分类。Caller 自己做 ready/waiting/blocked 过滤和状态同步。"
|
||||
- "ld_deep.py 用模块级直接导入,和已有 paperforge 导入一致"
|
||||
- "run_deep_reading() 保留状态同步和数据报告的职责"
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Dead code removal / unused imports -- Phase 17
|
||||
- Unit tests for `scan_library_records()` -- Phase 19
|
||||
- Standalone `scan_library_records()` CLI command -- not in scope; functionality accessed through `deep-reading` CLI and Agent
|
||||
|
||||
None -- discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 15-deep-reading-queue-merge*
|
||||
*Context gathered: 2026-04-27*
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# Phase 15: Deep-Reading Queue Merge - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md -- this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 15-deep-reading-queue-merge
|
||||
**Areas discussed:** Function signature & return value, Status sync separation, ld_deep.py import strategy
|
||||
|
||||
---
|
||||
|
||||
## Function Signature & Return Value
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 纯数据获取,返回完整列表 (Recommended) | 不分类,不同步状态。返回所有 library records + OCR 状态的统一数据。Caller 自行过滤。 | ✓ |
|
||||
| 顺便做分类 | 直接返回 ready/waiting/blocked 三组,和 deep_reading.py 当前行为一致。 | |
|
||||
|
||||
**User's choice:** 纯数据获取,返回完整列表
|
||||
**Notes:** scan_library_records() returns a flat list of dicts with zotero_key, domain, title, analyze, do_ocr, deep_reading_status, ocr_status, note_path.
|
||||
|
||||
---
|
||||
|
||||
## Status Sync Separation
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 同意,只提取扫描 (Recommended) | scan_library_records() 纯获取数据。run_deep_reading() 用返回数据做同步+报告。 | ✓ |
|
||||
| 同步也一起提取 | 但 _utils.py 应该保持纯函数,同步是副作用操作。 | |
|
||||
|
||||
**User's choice:** 同意,只提取扫描
|
||||
**Notes:** run_deep_reading() retains status sync + report generation. Only the scanning logic is extracted.
|
||||
|
||||
---
|
||||
|
||||
## ld_deep.py Import Strategy
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 模块级直接导入 (Recommended) | from paperforge.worker._utils import scan_library_records | ✓ |
|
||||
| 函数级延迟导入 | 在 scan_deep_reading_queue() 内部 import,更保守 | |
|
||||
|
||||
**User's choice:** 模块级直接导入
|
||||
**Notes:** Same pattern as existing paperforge.config imports.
|
||||
|
||||
---
|
||||
|
||||
## the agent's Discretion
|
||||
|
||||
- Exact error handling for malformed frontmatter (keep current lenient regex approach)
|
||||
- Sorting of returned list (caller's concern, not shared function's)
|
||||
- Whether to include `do_ocr` field in return dict
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Dead code removal / unused imports -- Phase 17
|
||||
- Unit tests for scan_library_records() -- Phase 19
|
||||
248
.planning/phases/16-retry-progress-bars/16-01-PLAN.md
Normal file
248
.planning/phases/16-retry-progress-bars/16-01-PLAN.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
---
|
||||
phase: 16-retry-progress-bars
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- pyproject.toml
|
||||
- paperforge/worker/_retry.py
|
||||
- paperforge/worker/_progress.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REL-01
|
||||
- REL-02
|
||||
- OBS-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "tenacity and tqdm are declared in pyproject.toml and installable via pip"
|
||||
- "from paperforge.worker._retry import configure_retry, retry_with_meta succeeds at runtime"
|
||||
- "from paperforge.worker._progress import progress_bar succeeds at runtime"
|
||||
- "configure_retry() reads PAPERFORGE_RETRY_MAX and PAPERFORGE_RETRY_BACKOFF from os.environ"
|
||||
- "progress_bar() wraps tqdm outputting to sys.stderr with auto-disable for non-TTY"
|
||||
artifacts:
|
||||
- path: pyproject.toml
|
||||
provides: "tenacity + tqdm declared in [project.dependencies]"
|
||||
contains: "tenacity"
|
||||
- path: paperforge/worker/_retry.py
|
||||
provides: "Retry decorator with env-var config, meta.json update callback"
|
||||
exports: ["configure_retry", "retry_with_meta"]
|
||||
- path: paperforge/worker/_progress.py
|
||||
provides: "tqdm wrapper with stderr output, auto-disable, disable flag"
|
||||
exports: ["progress_bar"]
|
||||
key_links:
|
||||
- from: paperforge/worker/_retry.py
|
||||
to: tenacity
|
||||
via: "import tenacity"
|
||||
pattern: "from tenacity import|import tenacity"
|
||||
- from: paperforge/worker/_progress.py
|
||||
to: tqdm
|
||||
via: "from tqdm import tqdm"
|
||||
pattern: "import tqdm|from tqdm"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the two new leaf modules (_retry.py and _progress.py) and add their dependencies to pyproject.toml.
|
||||
|
||||
Purpose: Establish the infrastructure for retry logic and progress bars as independent, testable leaf modules following the _utils.py pattern. These are dependencies for the Phase 16 integration work (Plan 16-02).
|
||||
|
||||
Output: pyproject.toml (modified), paperforge/worker/_retry.py (new), paperforge/worker/_progress.py (new)
|
||||
</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
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-retry-progress-bars/16-CONTEXT.md
|
||||
|
||||
@paperforge/worker/_utils.py (leaf module pattern to replicate — imports only stdlib + external deps, no intra-project imports)
|
||||
@paperforge/logging_config.py (single-purpose module precedent, ~69 lines)
|
||||
@paperforge/config.py (env var reading pattern: os.environ.get)
|
||||
|
||||
interface contracts from existing code:
|
||||
- `_utils.py::write_json(path: Path, data)` — used by retry_with_meta to persist meta.json updates
|
||||
- `_utils.py::read_json(path: Path)` — reads meta.json before updating
|
||||
- `paperforge/worker/ocr.py` l.1140-1146 (PAPERFORGE_RETRY_MAX env var pattern via os.environ.get)
|
||||
- `paperforge/logging_config.py` l.37-40 (env var reading pattern: level_name = os.environ.get("PAPERFORGE_LOG_LEVEL", "INFO"))
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
Existing contracts that plan_02 (OCR integration) will use:
|
||||
|
||||
```python
|
||||
# _retry.py — exported by this plan, consumed by plan_02
|
||||
def configure_retry() -> tenacity.retry: ...
|
||||
# Reads PAPERFORGE_RETRY_MAX (int, default 5) and PAPERFORGE_RETRY_BACKOFF (float, default 2.0)
|
||||
# Returns a tenacity @retry decorator configured with:
|
||||
# - exponential backoff: min=1s, max=30s, multiplier=backoff
|
||||
# - jitter (tenacity default jitter)
|
||||
# - stop_after_attempt(max)
|
||||
# - retry_on: ConnectionError, Timeout, HTTPError 429/503
|
||||
# - reraise=True
|
||||
# Used as: @configure_retry() on a helper function
|
||||
|
||||
def retry_with_meta(fn: Callable, meta_path: Path, *args, **kwargs) -> Any: ...
|
||||
# Calls fn(*args, **kwargs) with tenacity retry.
|
||||
# On each retry attempt, writes to meta.json:
|
||||
# retry_count, last_error, last_attempt_at (ISO-8601)
|
||||
# On eventual exhaustion: re-raises the last exception.
|
||||
# Used as: try: resp = retry_with_meta(upload_fn, meta_path, ...); except: ...
|
||||
|
||||
# _progress.py — exported by this plan, consumed by plan_02
|
||||
def progress_bar(iterable, desc: str = "", total: int | None = None, disable: bool = False) -> tqdm: ...
|
||||
# Wraps tqdm.tqdm(iterable, desc=desc, total=total, disable=disable, file=sys.stderr)
|
||||
# Auto-detects non-TTY fallback (CI/pipe) — tqdm's built-in mininterval
|
||||
# Returns the tqdm iterator.
|
||||
```
|
||||
|
||||
Existing pattern from _utils.py (leaf module):
|
||||
```python
|
||||
from __future__ import annotations
|
||||
import logging # stdlib only
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# exports: public functions only
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add tenacity and tqdm to pyproject.toml dependencies</name>
|
||||
<files>pyproject.toml</files>
|
||||
<action>
|
||||
Add two new entries to the `[project.dependencies]` list in pyproject.toml, following the existing format:
|
||||
- `"tenacity>=8.2.0"` (latest stable as of 2026-04)
|
||||
- `"tqdm>=4.66.0"` (latest stable as of 2026-04)
|
||||
|
||||
Place them after `"textual>=0.47.0"` in alphabetical order by package name (tqdm before tenacity).
|
||||
|
||||
Do NOT modify any other part of pyproject.toml.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
python -c "import tomllib; d = tomllib.load(open('pyproject.toml', 'rb')); deps = d['project']['dependencies']; assert any('tenacity' in dep for dep in deps); assert any('tqdm' in dep for dep in deps); print('OK: tenacity and tqdm in deps')"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
pyproject.toml lists tenacity>=8.2.0 and tqdm>=4.66.0 in [project.dependencies]; format validation passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create paperforge/worker/_retry.py leaf module</name>
|
||||
<files>paperforge/worker/_retry.py</files>
|
||||
<action>
|
||||
Create a new leaf module at `paperforge/worker/_retry.py` that follows the same patterns as `_utils.py`:
|
||||
- imports only stdlib + tenacity + requests (external deps only, no intra-project imports)
|
||||
- single-purpose: retry infrastructure for OCR operations
|
||||
- uses `logging.getLogger(__name__)` for diagnostic logging
|
||||
|
||||
Exports two public functions:
|
||||
|
||||
1. **`configure_retry() -> tenacity.retry`**
|
||||
- Reads `PAPERFORGE_RETRY_MAX` from os.environ, default `"5"`, parsed as int
|
||||
- Reads `PAPERFORGE_RETRY_BACKOFF` from os.environ, default `"2.0"`, parsed as float
|
||||
- Returns a `tenacity.retry` decorator configured with:
|
||||
- `stop=tenacity.stop_after_attempt(retry_max)`
|
||||
- `wait=tenacity.wait_exponential(multiplier=backoff, min=1, max=30)`
|
||||
- `retry=tenacity.retry_if_exception(_is_retryable)` where `_is_retryable` checks:
|
||||
- `requests.ConnectionError` → retryable
|
||||
- `requests.Timeout` → retryable
|
||||
- `requests.HTTPError` with `response.status_code in (429, 503)` → retryable
|
||||
- All other exceptions → NOT retryable (fail immediately)
|
||||
- `reraise=True` (always re-raise the last exception after exhaustion)
|
||||
- Logs `logger.warning` on each retry attempt (use `tenacity.before_sleep_log(logger, logging.WARNING)`)
|
||||
|
||||
2. **`retry_with_meta(fn: Callable, meta_path: Path, *args, **kwargs) -> Any`**
|
||||
- Calls `fn(*args, **kwargs)` with tenacity retry configured identically to `configure_retry()` (env vars)
|
||||
- On each retry attempt (`before_sleep` callback), reads meta.json from `meta_path` and writes/updates:
|
||||
- `retry_count` (int) — current attempt number from `retry_state.attempt_number`
|
||||
- `last_error` (str | null) — `str(retry_state.outcome.exception())` if failed, else `null`
|
||||
- `last_attempt_at` (ISO-8601 str) — `datetime.now(timezone.utc).isoformat()`
|
||||
- After all retries exhausted: re-raises the last exception. The caller (ocr.py) handles it.
|
||||
- Uses `_utils.write_json(meta_path, meta)` for persistence
|
||||
- Per D-09 (batch resilience): function does NOT catch — it re-raises, letting the caller decide how to handle failure
|
||||
|
||||
Internal helper:
|
||||
- `_is_retryable(exc: BaseException) -> bool` — classification logic per D-05
|
||||
|
||||
Log retry events via `logger.warning("Retry attempt %d for %s: %s", attempt, fn.__name__, error)`.
|
||||
|
||||
Add `from __future__ import annotations` as the first line.
|
||||
|
||||
DO NOT:
|
||||
- Import anything from `paperforge.` (leaf constraint per D-01)
|
||||
- Catch exceptions in `retry_with_meta` (must re-raise for caller to handle)
|
||||
- Modify any existing files
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
python -c "import sys; sys.path.insert(0, '.'); from paperforge.worker._retry import configure_retry, retry_with_meta; print('OK: _retry imports clean')"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
paperforge/worker/_retry.py exists; `from paperforge.worker._retry import configure_retry, retry_with_meta` succeeds; both functions respect env var defaults.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create paperforge/worker/_progress.py leaf module</name>
|
||||
<files>paperforge/worker/_progress.py</files>
|
||||
<action>
|
||||
Create a new leaf module at `paperforge/worker/_progress.py` following the same leaf-module pattern:
|
||||
- imports only stdlib + tqdm
|
||||
- single export: `progress_bar()` function
|
||||
|
||||
**`progress_bar(iterable, desc: str = "", total: int | None = None, disable: bool = False) -> tqdm`**
|
||||
- Returns `tqdm.tqdm(iterable, desc=desc, total=total, disable=disable, file=sys.stderr, unit="item")`
|
||||
- Configures sensible formatting defaults:
|
||||
- `file=sys.stderr` (D-10 — tqdm output on stderr, preserving stdout for piped data)
|
||||
- `mininterval=1.0` (update at most once per second — reduces flicker on fast iterations)
|
||||
- `unit="item"` (generic unit label)
|
||||
- `bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]"` (compact format showing progress)
|
||||
- `disable=False` → tqdm automatically detects TTY (per tqdm defaults). The `disable` parameter is for explicit control via `--no-progress`.
|
||||
- Uses `tqdm.auto.tqdm` or `from tqdm import tqdm` (standard tqdm has built-in `{bool(sys.stderr.isatty())}` behavior for `disable=None`, but we pass the `disable` flag explicitly)
|
||||
|
||||
The module should be small (~30 lines) — a single function wrapping tqdm.
|
||||
|
||||
DO NOT:
|
||||
- Import anything from `paperforge.` (leaf constraint per D-02)
|
||||
- Do any auto-disable logic beyond what `disable` parameter controls — `tqdm` handles TTY detection natively
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
python -c "import sys; sys.path.insert(0, '.'); from paperforge.worker._progress import progress_bar; print('OK: _progress imports clean')"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
paperforge/worker/_progress.py exists; `from paperforge.worker._progress import progress_bar` succeeds; progress_bar returns a tqdm instance writing to stderr.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. [Automated] `pip install -e .` succeeds (dependencies resolve)
|
||||
2. [Automated] `python -c "from paperforge.worker._retry import configure_retry, retry_with_meta"` succeeds
|
||||
3. [Automated] `python -c "from paperforge.worker._progress import progress_bar"` succeeds
|
||||
4. [Automated] `python -c "from paperforge.worker._retry import configure_retry; configure_retry()"` — no errors, returns decorator
|
||||
5. All three files committed
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- pyproject.toml includes tenacity and tqdm
|
||||
- `paperforge/worker/_retry.py` exists, exports `configure_retry()` and `retry_with_meta()`, no intra-project imports
|
||||
- `paperforge/worker/_progress.py` exists, exports `progress_bar()`, no intra-project imports
|
||||
- Module imports verified clean at Python level
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/16-retry-progress-bars/16-01-SUMMARY.md`
|
||||
</output>
|
||||
18
.planning/phases/16-retry-progress-bars/16-01-SUMMARY.md
Normal file
18
.planning/phases/16-retry-progress-bars/16-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Phase 16: Retry + Progress Bars — Plan 01 Summary
|
||||
|
||||
**Plan:** 16-retry-progress-bars-01
|
||||
**Tasks:** 3/3 complete
|
||||
**Duration:** ~2 min
|
||||
|
||||
## Task Results
|
||||
|
||||
- **Task 1:** Added tenacity>=8.2.0 and tqdm>=4.66.0 to pyproject.toml [project.dependencies]
|
||||
- **Task 2:** Created `paperforge/worker/_retry.py` — leaf module exporting `configure_retry()` and `retry_with_meta()`. Reads PAPERFORGE_RETRY_MAX/PAPERFORGE_RETRY_BACKOFF env vars. Retryable on ConnectionError, Timeout, HTTP 429/503.
|
||||
- **Task 3:** Created `paperforge/worker/_progress.py` — leaf module exporting `progress_bar()` wrapping tqdm with stderr output, mininterval=1.0, compact format.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pip install -e .` — OK (dependencies resolved)
|
||||
- `from paperforge.worker._retry import configure_retry, retry_with_meta` — OK
|
||||
- `from paperforge.worker._progress import progress_bar` — OK
|
||||
- `configure_retry()` returns tenacity.retry — OK
|
||||
299
.planning/phases/16-retry-progress-bars/16-02-PLAN.md
Normal file
299
.planning/phases/16-retry-progress-bars/16-02-PLAN.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
---
|
||||
phase: 16-retry-progress-bars
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "16-01"
|
||||
files_modified:
|
||||
- paperforge/cli.py
|
||||
- paperforge/commands/ocr.py
|
||||
- paperforge/worker/ocr.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REL-01
|
||||
- REL-02
|
||||
- REL-03
|
||||
- REL-04
|
||||
- OBS-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can run `paperforge --no-progress ocr` to suppress all progress bars"
|
||||
- "Transient PaddleOCR upload failures (HTTP 429/503, ConnectionError, Timeout) trigger automatic retry with exponential backoff"
|
||||
- "Transient PaddleOCR poll failures trigger automatic retry with exponential backoff"
|
||||
- "meta.json after a retry event contains retry_count (int), last_error (str|null), last_attempt_at (ISO-8601 str|null)"
|
||||
- "Zombie 'queued'/'running' jobs older than 30 minutes are reset to 'pending' on ocr start"
|
||||
- "A single upload failure does not abort the batch — failed items are logged, meta.json updated, processing continues"
|
||||
- "tqdm progress bars appear during OCR upload and poll iterations in interactive terminals"
|
||||
artifacts:
|
||||
- path: paperforge/cli.py
|
||||
provides: "--no-progress global root parser flag (same level as --verbose)"
|
||||
contains: "--no-progress"
|
||||
- path: paperforge/commands/ocr.py
|
||||
provides: "Wires no_progress from argparse → run_ocr(vault, verbose, no_progress)"
|
||||
contains: "no_progress"
|
||||
- path: paperforge/worker/ocr.py
|
||||
provides: "Full integration: retry on upload/poll, meta.json extensions, zombie reset, progress bars, batch resilience"
|
||||
contains: "configure_retry"
|
||||
key_links:
|
||||
- from: paperforge/cli.py
|
||||
to: paperforge/commands/ocr.py
|
||||
via: "args.no_progress is available to command dispatch"
|
||||
pattern: "no_progress"
|
||||
- from: paperforge/commands/ocr.py
|
||||
to: paperforge/worker/ocr.py
|
||||
via: "run_ocr(vault, verbose=verbose, no_progress=no_progress)"
|
||||
pattern: "run_ocr.*no_progress"
|
||||
- from: paperforge/worker/ocr.py
|
||||
to: paperforge/worker/_retry.py
|
||||
via: "from paperforge.worker._retry import configure_retry, retry_with_meta"
|
||||
pattern: "from.*_retry import"
|
||||
- from: paperforge/worker/ocr.py
|
||||
to: paperforge/worker/_progress.py
|
||||
via: "from paperforge.worker._progress import progress_bar"
|
||||
pattern: "from.*_progress import"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Wire --no-progress through the CLI layer and integrate retry, meta.json extensions, zombie reset, progress bars, and batch resilience into run_ocr().
|
||||
|
||||
Purpose: Make the OCR pipeline resilient (retry on transient failures, zombie cleanup, batch resilience) and observable (progress bars, meta.json retry tracking). All infrastructure (_retry.py, _progress.py) was created in Plan 16-01.
|
||||
|
||||
Output: paperforge/cli.py (modified), paperforge/commands/ocr.py (modified), paperforge/worker/ocr.py (modified)
|
||||
</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
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/16-retry-progress-bars/16-CONTEXT.md
|
||||
|
||||
@paperforge/cli.py
|
||||
@paperforge/commands/ocr.py
|
||||
@paperforge/worker/ocr.py (especially run_ocr() at l.1126-1285, ensure_ocr_meta() at l.119-143, cleanup_blocked_ocr_dirs() at l.266)
|
||||
|
||||
Interfaces from plan 16-01 (already on disk):
|
||||
- `_retry.py::configure_retry()` — returns a tenacity `@retry` decorator with env-var config
|
||||
- `_retry.py::retry_with_meta(fn, meta_path, *args, **kwargs)` — retries fn, writes retry info to meta.json on each attempt, re-raises on exhaustion
|
||||
- `_progress.py::progress_bar(iterable, desc, total, disable)` — tqdm wrapper on stderr
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add --no-progress global flag and wire through command dispatch</name>
|
||||
<files>paperforge/cli.py, paperforge/commands/ocr.py</files>
|
||||
<action>
|
||||
Per D-03, add `--no-progress` as a **global root parser flag** at the same level as `--verbose` in `cli.py::build_parser()`:
|
||||
|
||||
In `paperforge/cli.py`:
|
||||
1. After the `--verbose` argument (l.128-132), add:
|
||||
```python
|
||||
parser.add_argument(
|
||||
"--no-progress",
|
||||
action="store_true",
|
||||
help="Disable progress bars (tqdm) for all commands",
|
||||
)
|
||||
```
|
||||
Note: argparse converts `--no-progress` to attribute `no_progress` (dash → underscore).
|
||||
|
||||
2. No changes needed in `main()` for dispatch — `args.no_progress` is already available on the namespace and will be passed by command modules.
|
||||
|
||||
In `paperforge/commands/ocr.py`:
|
||||
1. Modify `run()` to accept and pass through `no_progress`:
|
||||
- Read `no_progress = getattr(args, "no_progress", False)` in the `run()` function
|
||||
- Pass it to `run_ocr()`: add `no_progress=no_progress` to the call at l.77
|
||||
2. Update the `run_ocr` call signature expectation — currently `run_ocr(vault, verbose=getattr(args, "verbose", False))`, change to `run_ocr(vault, verbose=getattr(args, "verbose", False), no_progress=getattr(args, "no_progress", False))`
|
||||
|
||||
DO NOT:
|
||||
- Modify any other command modules (other commands don't use progress bars yet)
|
||||
- Add --no-progress to sub-parsers (it's root-level only per D-03)
|
||||
- Remove or modify the --verbose flag
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
python -c "import sys; sys.path.insert(0, '.'); from paperforge.cli import build_parser; p = build_parser(); a = p.parse_args(['--no-progress', 'ocr']); assert a.no_progress == True; assert a.command == 'ocr'; print('OK: --no-progress flag accepted at root level')"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
`python -m paperforge --help` shows `--no-progress` option; `paperforge --no-progress ocr` passes `no_progress=True` to command layer; no syntax errors in cli.py or commands/ocr.py.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Integrate retry, meta.json extensions, zombie reset, progress bars, and batch resilience into run_ocr()</name>
|
||||
<files>paperforge/worker/ocr.py</files>
|
||||
<action>
|
||||
This is the core integration task. Modify `run_ocr()` (l.1126-1285) and its dependencies.
|
||||
|
||||
**A. Update function signature (l.1126):**
|
||||
Change `def run_ocr(vault: Path, verbose: bool = False) -> int:` to:
|
||||
`def run_ocr(vault: Path, verbose: bool = False, no_progress: bool = False) -> int:`
|
||||
|
||||
**B. Add imports (near top of file with other imports, ~l.24-44):**
|
||||
```python
|
||||
from paperforge.worker._retry import configure_retry, retry_with_meta
|
||||
from paperforge.worker._progress import progress_bar
|
||||
```
|
||||
|
||||
**C. Zombie Reset (REL-03, D-08) — add at the top of run_ocr() body, after l.1130:**
|
||||
After `cleanup_blocked_ocr_dirs(paths)` and before loading control actions, add a zombie scan:
|
||||
```
|
||||
Scan paths['ocr'] for all subdirectories containing meta.json.
|
||||
For each meta.json:
|
||||
- Read it
|
||||
- If ocr_status is 'queued' or 'running' (i.e., "processing" per D-08) AND ocr_started_at is set:
|
||||
- Parse ocr_started_at as ISO-8601 datetime
|
||||
- If it's older than the threshold (read from env var, e.g. PAPERFORGE_ZOMBIE_TIMEOUT, default "30", parsed as int minutes):
|
||||
- Set ocr_status = 'pending'
|
||||
- Increment retry_count (set default 0 if missing, then +1)
|
||||
- Write updated meta.json via write_json()
|
||||
- Log: logger.warning(f"Zombie reset {key}: was {status}, started at {started}, reset to pending")
|
||||
```
|
||||
Threshold env var name: `PAPERFORGE_ZOMBIE_TIMEOUT_MINUTES` (default 30).
|
||||
|
||||
**D. Retry + meta.json extensions (REL-01, REL-02, D-04, D-05, D-06, D-07):**
|
||||
|
||||
**D1. Upload retry — wrap the upload block (~l.1256-1269):**
|
||||
The current upload code opens the PDF, POSTs, and calls raise_for_status(). On exception, it classifies the error and continues.
|
||||
|
||||
Refactor the upload into a small inner function `_do_upload()` that captures the response:
|
||||
```python
|
||||
def _do_upload(token, job_url, model, optional_payload, resolved_pdf):
|
||||
with open(resolved_pdf, 'rb') as f:
|
||||
resp = requests.post(job_url, headers={'Authorization': f'bearer {token}'},
|
||||
data={'model': model, 'optionalPayload': json.dumps(optional_payload)},
|
||||
files={'file': f}, timeout=120)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
```
|
||||
|
||||
Replace the try/except upload block (l.1256-1269) with:
|
||||
```python
|
||||
try:
|
||||
response = retry_with_meta(_do_upload, meta_path, token, job_url, model, optional_payload, resolved_pdf)
|
||||
except Exception as e:
|
||||
# Existing exception handler (l.1260-1269) — classify_error, set status, write meta
|
||||
...
|
||||
continue
|
||||
```
|
||||
|
||||
`retry_with_meta()` will:
|
||||
- Retry upload on ConnectionError, Timeout, HTTPError 429/503 with exponential backoff
|
||||
- Write retry_count, last_error, last_attempt_at to meta.json on each retry
|
||||
- Re-raise the final exception if all retries exhausted
|
||||
- The existing `except Exception as e` handler catches the re-raised exception, classifies it, updates meta.json (which now already has retry_count/last_error), and continues to the next item (batch resilience per D-09)
|
||||
|
||||
**D2. Poll retry — wrap the poll GET + raise_for_status (l.1175-1176):**
|
||||
Wrap:
|
||||
```python
|
||||
try:
|
||||
response = retry_with_meta(_do_poll, meta_path, job_url, meta['ocr_job_id'], token)
|
||||
except Exception as e:
|
||||
# classify error, set status, write meta
|
||||
...
|
||||
continue # batch resilience — don't abort entire poll loop
|
||||
```
|
||||
|
||||
Where `_do_poll` is:
|
||||
```python
|
||||
def _do_poll(job_url, job_id, token):
|
||||
resp = requests.get(f"{job_url}/{job_id}", headers={'Authorization': f'bearer {token}'}, timeout=60)
|
||||
resp.raise_for_status()
|
||||
return resp
|
||||
```
|
||||
|
||||
This catches poll failures that previously crashed the batch (l.1176 `response.raise_for_status()` with no try/except). Per D-09, poll failures must not abort the batch.
|
||||
|
||||
**D3. ensure_ocr_meta() extension (REL-02):**
|
||||
In `ensure_ocr_meta()` (~l.119-143), add three new setdefault lines after l.142:
|
||||
```python
|
||||
meta.setdefault('retry_count', 0)
|
||||
meta.setdefault('last_error', None)
|
||||
meta.setdefault('last_attempt_at', None)
|
||||
```
|
||||
|
||||
**E. Progress bars (OBS-04, D-10):**
|
||||
|
||||
**E1. Upload loop progress:**
|
||||
The upload loop runs as the second `for queue_row in ocr_queue:` block (starting ~l.1225). Wrap it with `progress_bar()`:
|
||||
```python
|
||||
upload_items = [row for row in ocr_queue if ...] # filter to items that need upload
|
||||
for queue_row in progress_bar(upload_items, desc="Uploading", disable=no_progress):
|
||||
...
|
||||
```
|
||||
Actually, the upload filtering is more nuanced — the existing loop already checks `status == 'done'`, `status in {'queued', 'running'}`, etc. To avoid duplicating filter logic, use the original `ocr_queue` with `total=len(ocr_queue)` and let tqdm handle it. Or filter to non-done items. The executor should choose the approach that requires minimal structural change.
|
||||
|
||||
**Best approach (recommended):** Keep the outer loop structure intact. Wrap the iterable with progress_bar:
|
||||
```python
|
||||
for queue_row in progress_bar(ocr_queue, desc="Uploading", disable=no_progress):
|
||||
```
|
||||
tqdm handles the iteration transparently. The desc label changes based on context.
|
||||
|
||||
**E2. Poll loop progress:**
|
||||
Similarly wrap the poll loop:
|
||||
```python
|
||||
active_items = [row for row in ocr_queue if row.get('queue_status') in ...]
|
||||
for queue_row in progress_bar(active_items, desc="Polling", disable=no_progress):
|
||||
```
|
||||
Or simply wrap the outer loop with progress_bar. Executor's discretion.
|
||||
|
||||
The key constraint (D-10): `progress_bar(..., disable=no_progress)` where `no_progress` is the value from `--no-progress` flag, passed through from `run_ocr()` parameter.
|
||||
|
||||
**F. Batch resilience (REL-04, D-09):**
|
||||
- The upload retry (D1) already achieves batch resilience: if `retry_with_meta` re-raises, the `except Exception` block catches it, classifies the error, updates meta, and `continue`s.
|
||||
- The poll retry (D2) achieves poll resilience: poll failures are caught, classified, and `continue`d instead of crashing the batch.
|
||||
- No additional structural changes needed — the existing `for queue_row in ocr_queue:` loop naturally continues to the next item.
|
||||
|
||||
**G. Update `run_ocr()` signature docstring if one exists.** Ensure the new parameter `no_progress: bool = False` is documented.
|
||||
|
||||
IMPORTANT VERIFICATION CHECK:
|
||||
- The `run_ocr` function must accept `no_progress: bool = False` as a keyword argument
|
||||
- All three meta.json new fields (retry_count, last_error, last_attempt_at) are written via `retry_with_meta` and defaulted in `ensure_ocr_meta`
|
||||
- The poll `raise_for_status()` at l.1175-1176 is wrapped in try/except (previously uncaught!)
|
||||
- Upload failure does not `continue` without updating meta.json first
|
||||
</action>
|
||||
<verify>
|
||||
<automated>
|
||||
python -c "import sys; sys.path.insert(0, '.'); from paperforge.worker.ocr import run_ocr; import inspect; sig = inspect.signature(run_ocr); assert 'no_progress' in sig.parameters; print('OK: run_ocr accepts no_progress parameter')"
|
||||
</automated>
|
||||
</verify>
|
||||
<done>
|
||||
All five integration points complete:
|
||||
1. `run_ocr()` accepts `no_progress` parameter and passes it to `progress_bar()`
|
||||
2. Upload wrapped with `retry_with_meta()` — retries on transient failures, writes meta.json per retry
|
||||
3. Poll wrapped with `retry_with_meta()` — retries on poll failures, writes meta.json, catches exceptions for batch resilience
|
||||
4. `ensure_ocr_meta()` includes `retry_count=0`, `last_error=None`, `last_attempt_at=None` defaults
|
||||
5. Zombie reset at startup — resets 'queued'/'running' older than threshold to 'pending'
|
||||
6. Progress bars visible during upload and poll iterations (unless --no-progress or non-TTY)
|
||||
7. Batch resilience: single item failure doesn't abort the loop
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. [Automated] `python -m paperforge --help` output includes `--no-progress`
|
||||
2. [Automated] `python -c "from paperforge.worker.ocr import run_ocr; run_ocr.__code__.co_varnames"` shows `no_progress` in parameter list
|
||||
3. [Automated] `python -c "from paperforge.worker.ocr import ensure_ocr_meta; import inspect; source = inspect.getsource(ensure_ocr_meta); assert 'retry_count' in source; assert 'last_error' in source; assert 'last_attempt_at' in source; print('OK: meta.json schema extended')"`
|
||||
4. [Automated] Python's `pytest tests/ -x --timeout=30` passes (no regressions — new imports don't break existing tests)
|
||||
5. [Manual] `paperforge doctor` shows no errors with new dependencies
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `--no-progress` appears in `paperforge --help`, accessible as `args.no_progress`
|
||||
- `run_ocr()` accepts `no_progress` parameter and imports from `_retry` and `_progress`
|
||||
- `ensure_ocr_meta()` returns meta dicts with `retry_count`, `last_error`, `last_attempt_at` fields
|
||||
- Upload and poll operations wrapped with retry via `retry_with_meta()`
|
||||
- Zombie reset runs at the start of `run_ocr()`, resetting stale 'queued'/'running' jobs
|
||||
- All existing tests pass with zero failures
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/16-retry-progress-bars/16-02-SUMMARY.md`
|
||||
</output>
|
||||
26
.planning/phases/16-retry-progress-bars/16-02-SUMMARY.md
Normal file
26
.planning/phases/16-retry-progress-bars/16-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Phase 16: Retry + Progress Bars — Plan 02 Summary
|
||||
|
||||
**Plan:** 16-retry-progress-bars-02
|
||||
**Tasks:** 2/2 complete
|
||||
**Duration:** ~5 min
|
||||
|
||||
## Task Results
|
||||
|
||||
- **Task 1:** Added `--no-progress` global root parser flag to `cli.py` (after `--verbose`), wired through `commands/ocr.py::run()` to `run_ocr()` as `no_progress=no_progress`.
|
||||
- **Task 2:** Full integration into `worker/ocr.py`:
|
||||
- Added imports from `_retry` and `_progress`
|
||||
- Extended `ensure_ocr_meta()` with `retry_count`, `last_error`, `last_attempt_at` defaults
|
||||
- Added zombie reset at `run_ocr()` start — resets `queued`/`running` jobs older than threshold
|
||||
- Refactored poll into `_do_poll()` inner function + `retry_with_meta()` wrapping
|
||||
- Refactored upload into `_do_upload()` inner function + `retry_with_meta()` wrapping
|
||||
- Wrapped both poll and upload loops with `progress_bar()`
|
||||
- Wrapped uncaught poll `raise_for_status()` in try/except for batch resilience
|
||||
- Added `no_progress` parameter to `run_ocr()` signature
|
||||
|
||||
## Verification
|
||||
|
||||
- CLI: `--no-progress` accepted at root level, accessible as `args.no_progress`
|
||||
- Signature: `run_ocr` accepts `no_progress: bool = False`
|
||||
- Imports: `_retry.configure_retry`, `_retry.retry_with_meta`, `_progress.progress_bar` all importable from `ocr.py`
|
||||
- Meta: `ensure_ocr_meta()` includes `retry_count`, `last_error`, `last_attempt_at`
|
||||
- Tests: 203 passed, 2 skipped (0 regressions)
|
||||
123
.planning/phases/16-retry-progress-bars/16-CONTEXT.md
Normal file
123
.planning/phases/16-retry-progress-bars/16-CONTEXT.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Phase 16: Retry + Progress Bars - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Make the OCR pipeline resilient to transient network failures and provide user-visible progress indication. Add tenacity retry to PaddleOCR API calls, extend meta.json schema, detect and reset zombie jobs, ensure batch resilience, and add tqdm progress bars. Zero behavioral change to OCR output (status print, meta.json format additions only).
|
||||
|
||||
Requirements: REL-01, REL-02, REL-03, REL-04, OBS-04
|
||||
|
||||
Out of scope:
|
||||
- OCR error message improvement (OBS-05 — Phase 17)
|
||||
- Consistency audit / pre-commit (DX-01, DX-02 — Phase 17)
|
||||
- E2E integration tests (TEST-01 — Phase 19)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Module Structure
|
||||
- **D-01:** New `paperforge/worker/_retry.py` — leaf module importing only `tenacity` + stdlib. Houses the `ocr_retry` decorator and any future retry utilities. Keeps `_utils.py` leaf constraint intact.
|
||||
- **D-02:** New `paperforge/worker/_progress.py` — leaf module importing only `tqdm` + stdlib. Exports a `progress_bar(iterable, desc, total, disable)` function wrapping tqdm. Centralizes tqdm configuration (stderr output, auto-disable, mininterval).
|
||||
|
||||
### CLI Flag
|
||||
- **D-03:** `--no-progress` is a **global root parser flag**, same level as `--verbose` from Phase 13. Syntax: `paperforge --no-progress ocr`. Future commands with progress bars automatically inherit.
|
||||
|
||||
### Retry Behavior (REL-01)
|
||||
- **D-04:** tenacity retry applied to both **upload** (l.1256-1269) and **poll** (l.1175 in ocr.py). Exponential backoff: 1s → 2s → 4s → 8s → max 30s, with jitter.
|
||||
- **D-05:** Retry on: `requests.ConnectionError`, `requests.Timeout`, `requests.HTTPError` with status 429/503. All other exceptions fail immediately.
|
||||
- **D-06:** Retry config via env vars: `PAPERFORGE_RETRY_MAX` (max attempts, default 5), `PAPERFORGE_RETRY_BACKOFF` (backoff multiplier, default 2.0). Read from `os.environ` in `configure_retry()` within `_retry.py`.
|
||||
|
||||
### meta.json Schema (REL-02)
|
||||
- **D-07:** Extend meta.json with three new fields:
|
||||
- `retry_count` (int, default 0) — incremented each retry attempt
|
||||
- `last_error` (str | null, default null) — last exception message
|
||||
- `last_attempt_at` (ISO-8601 str | null, default null) — timestamp of last attempt
|
||||
- Written atomically via existing `write_json()` after each attempt (upload and poll).
|
||||
|
||||
### Zombie Reset (REL-03)
|
||||
- **D-08:** At start of `run_ocr()`, scan all `ocr/<key>/meta.json` files. For entries where `ocr_status == "processing"` and `ocr_started_at` is older than 30 minutes (configurable via env var, default 30min), reset to `pending` and increment `retry_count`. Blocked/error terminal states are never auto-reset.
|
||||
|
||||
### Batch Resilience (REL-04)
|
||||
- **D-09:** Both upload and poll HTTP failures are caught. A single PDF upload or poll failure does NOT abort the batch — failed items are logged, meta.json updated, and processing continues with remaining items. Poll `raise_for_status()` (l.1175) wrapped in try/except.
|
||||
|
||||
### Progress Bars (OBS-04)
|
||||
- **D-10:** tqdm output goes to **stderr** (via `file=sys.stderr`). Auto-detects non-TTY (CI, pipe) — silently falls back to no progress. `--no-progress` flag disables explicitly. Progress shown during: upload iteration (PDF-by-PDF), poll iteration (active jobs).
|
||||
|
||||
### the agent's Discretion
|
||||
- Exact tenacity decorator implementation (`@retry` vs `Retrying` class)
|
||||
- Whether to wrap individual ocr.py functions or inline the retry logic at call sites
|
||||
- tqdm `mininterval`, `unit`, formatting details
|
||||
- Zombie threshold env var name (follow `PAPERFORGE_*` convention)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements
|
||||
- `.planning/REQUIREMENTS.md` §REL-01 — tenacity retry spec (exceptions, backoff config)
|
||||
- `.planning/REQUIREMENTS.md` §REL-02 — meta.json schema extensions
|
||||
- `.planning/REQUIREMENTS.md` §REL-03 — zombie detection threshold
|
||||
- `.planning/REQUIREMENTS.md` §REL-04 — batch resilience requirement
|
||||
- `.planning/REQUIREMENTS.md` §OBS-04 — tqdm progress bar spec (stderr, auto-disable, --no-progress)
|
||||
|
||||
### Prior Decisions
|
||||
- `.planning/phases/14-shared-utilities-extraction/14-CONTEXT.md` — _utils.py leaf module constraint (D-01, D-04)
|
||||
- `.planning/phases/13-logging-foundation/13-CONTEXT.md` — global --verbose pattern (D-04), stdout/stderr boundary (D-07)
|
||||
|
||||
### Source Code
|
||||
- `paperforge/worker/ocr.py:1126-1285` — run_ocr() function to be modified (upload l.1256-1269, poll l.1171-1222, meta.json writes l.1188/1221/1246/1253/1267/1275)
|
||||
- `paperforge/worker/ocr.py:1-60` — Existing imports (requests, json, datetime, etc.)
|
||||
- `paperforge/worker/_utils.py` — Leaf module pattern to follow for _retry.py and _progress.py
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `_utils.py::write_json` — Already used for meta.json writes, no change needed
|
||||
- `logging_config.py` — Single-purpose module pattern to replicate for _retry.py and _progress.py
|
||||
- `paperforge/config.py` — Env var reading pattern (os.environ.get)
|
||||
|
||||
### Established Patterns
|
||||
- Phase 13-14: Leaf modules under `paperforge/worker/` import only stdlib + paperforge.config
|
||||
- Phase 13-14: Single-purpose modules follow logging_config.py precedent (~69 lines)
|
||||
- Phase 13: `--verbose` global flag — parser pattern in cli.py
|
||||
|
||||
### Integration Points
|
||||
- `paperforge/worker/_retry.py` — New file alongside _utils.py
|
||||
- `paperforge/worker/_progress.py` — New file alongside _utils.py
|
||||
- `paperforge/worker/ocr.py` — Import from _retry and _progress, modify run_ocr()
|
||||
- `paperforge/cli.py` — Add `--no-progress` to root parser
|
||||
- `pyproject.toml` — Add `tenacity` and `tqdm` to `[project.dependencies]`
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "_retry.py 和 _progress.py 保持 leaf,和 _utils.py 一样只 import 外部库 + stdlib"
|
||||
- "--no-progress 放在 root parser,和 --verbose 对齐"
|
||||
- "poll 的 raise_for_status 也要 catch,不能 crash 整个 batch"
|
||||
- "tqdm 输出到 stderr,不影响 stdout 的 piped 输出"
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- OCR error message improvement (OBS-05) — Phase 17, includes classify_error integration
|
||||
- E2E tests for retry behavior — Phase 19
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 16-retry-progress-bars*
|
||||
*Context gathered: 2026-04-27*
|
||||
65
.planning/phases/16-retry-progress-bars/16-DISCUSSION-LOG.md
Normal file
65
.planning/phases/16-retry-progress-bars/16-DISCUSSION-LOG.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Phase 16: Retry + Progress Bars - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 16-retry-progress-bars
|
||||
**Areas discussed:** tenacity placement, tqdm placement, --no-progress flag scope, poll error handling
|
||||
|
||||
---
|
||||
|
||||
## tenacity 放置位置
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 新建 _retry.py (推荐) | 保持 _utils.py leaf 纯净,_retry.py 只 import tenacity + stdlib | ✓ |
|
||||
| inline 在 ocr.py | 最简单,目前就一个调用点 | |
|
||||
| 放 _utils.py | 最方便,但打破 leaf module 约束 | |
|
||||
|
||||
**User's choice:** 新建 _retry.py
|
||||
**Notes:** 和 _utils.py 保持同样 leaf 模式,只 import tenacity + stdlib
|
||||
|
||||
---
|
||||
|
||||
## tqdm 放置位置
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 新建 _progress.py (推荐) | 封装 progress_bar() 函数,保持 _utils.py 纯 leaf | ✓ |
|
||||
| inline 在 ocr.py | 最简单,目前就 OCR upload 循环用 | |
|
||||
| 放 _utils.py | 全模块引入 tqdm 依赖 | |
|
||||
|
||||
**User's choice:** 新建 _progress.py
|
||||
**Notes:** 统一 tqdm 配置(stderr, auto-disable),和 _retry.py 一致的模式
|
||||
|
||||
---
|
||||
|
||||
## --no-progress 全局还是 per-command?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 全局 root flag (推荐) | 和 --verbose 一致,未来自动生效 | ✓ |
|
||||
| 仅 per-command | 目前只有 ocr 用,但和 --verbose 不一致 | |
|
||||
|
||||
**User's choice:** 全局 root flag
|
||||
**Notes:** 与 Phase 13 的 --verbose 使用体验一致
|
||||
|
||||
---
|
||||
|
||||
## Poll HTTP 5xx 错误要不要修?
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 修,batch resilience (推荐) | 把 poll 的 raise_for_status 也包进 try/except | ✓ |
|
||||
| 不修,poll fail-fast | upload 可以重试,poll 失败快速失败 | |
|
||||
|
||||
**User's choice:** 修,batch resilience
|
||||
**Notes:** 符合 REL-04 精神,单个请求失败不 crash 整个 batch
|
||||
|
||||
---
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- OBS-05 (OCR error message improvement) — Phase 17
|
||||
- E2E tests for retry behavior — Phase 19
|
||||
109
.planning/phases/17-dead-code-precommit/17-CONTEXT.md
Normal file
109
.planning/phases/17-dead-code-precommit/17-CONTEXT.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Phase 17: Dead Code Removal + Pre-Commit - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Remove dead code (unused imports, UPDATE_* constants, delegation wrappers), install automated pre-commit hooks (ruff + consistency audit), add ruff config to pyproject.toml, and improve OCR error messages with actionable context (library-record name). Last code phase before documentation/testing.
|
||||
|
||||
Requirements: CH-04, DX-01, DX-02, OBS-05
|
||||
|
||||
Out of scope:
|
||||
- pre-commit hook installation instructions (DX-04 — Phase 18)
|
||||
- pre-commit hook test coverage (TEST-01/TEST-02 — Phase 19)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan Structure
|
||||
- **D-01:** Single plan covering all 4 requirements (CH-04, DX-01, DX-02, OBS-05). Dead code removal is mechanical with exact line numbers from REQUIREMENTS.md; pre-commit config is a single file; ruff config is a pyproject.toml section; OBS-05 is a targeted change to 2 error paths in ocr.py.
|
||||
|
||||
### consistency-audit Extension
|
||||
- **D-02:** Add a new Check 5 (duplicate utility function detection) to existing `scripts/consistency_audit.py`. Scans all 7 worker modules for functions that are re-exported from `_utils.py` but still have local copies. If any local copy exists outside the re-export comment, the audit fails.
|
||||
- **D-03:** The `.pre-commit-config.yaml` calls `scripts/consistency_audit.py` as a custom hook. No separate script.
|
||||
|
||||
### OBS-05 Scope
|
||||
- **D-04:** Minimal change: add `zotero_key` (library-record name) to the error context passed to existing error handling paths in ocr.py's upload and poll loops. Reuse existing `classify_error()` function. No restructuring of the error message format.
|
||||
- **D-05:** Error messages in meta.json get an additional `library_record` field containing the zotero_key for context. Existing `error` and `suggestion` fields remain unchanged.
|
||||
|
||||
### ruff Configuration
|
||||
- **D-06:** DX-02 rules E, F, I, UP, B, SIM are standard and clear-cut. Add `[tool.ruff]` section to pyproject.toml targeting Python 3.10+. No additional rules beyond the spec.
|
||||
|
||||
### Dead Code Removal Scope
|
||||
- **D-07:** Remove unused imports from all 7 worker modules and command modules (`csv`, `hashlib`, `shutil`, `subprocess`, `zipfile`, `ElementTree`, `fitz`, `PIL` where not used). Determine unused by running `ruff check` and removing flagged imports.
|
||||
- **D-08:** Remove UPDATE_* constants from `status.py` lines 620-625 (duplicated in `update.py`).
|
||||
- **D-09:** Replace delegation wrappers (`load_vault_config`, `pipeline_paths`) in worker modules with direct `from paperforge.config import ...`. The wrappers are thin delegation functions that add an unnecessary hop. Backward compatibility: public names are preserved in `paperforge.config` directly.
|
||||
|
||||
### the agent's Discretion
|
||||
- Exact order of tasks within the single plan
|
||||
- Whether to run `ruff check --fix` as part of the dead code task or as a separate pre-commit validation step
|
||||
- Exact regex for duplicate-utility detection in Check 5
|
||||
- Number of pre-commit hook files (one `.pre-commit-config.yaml` at root)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements (Phase 17 scope)
|
||||
- `.planning/REQUIREMENTS.md` — CH-04 (dead code items with exact line numbers), DX-01 (pre-commit hooks list), DX-02 (ruff rules), OBS-05 (actionable error messages)
|
||||
|
||||
### Existing Code
|
||||
- `scripts/consistency_audit.py` — Existing audit script, add Check 5 here (D-02)
|
||||
- `pyproject.toml` — Add `[tool.ruff]` section, no `[tool.ruff.lint]` yet
|
||||
- `paperforge/worker/*.py` — All 7 workers for dead import removal
|
||||
- `paperforge/worker/ocr.py:1256-1269` — Upload error handling (add zotero_key context per D-05)
|
||||
- `paperforge/worker/ocr.py:1171-1222` — Poll error handling (add zotero_key context per D-05)
|
||||
|
||||
### Patterns
|
||||
- `.planning/phases/16-retry-progress-bars/16-CONTEXT.md` — Phase 16 context for module creation pattern
|
||||
- `paperforge/worker/_retry.py` — Leaf module pattern (imports only from stdlib + paperforge.config)
|
||||
- `paperforge/worker/_progress.py` — Leaf module pattern
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `scripts/consistency_audit.py` already exists with 4 checks (old commands, paperforge_lite, dead links, command docs) and a clear pattern: `check_*()` function returning `(exit_code, violations)` tuple. Add Check 5 following same pattern.
|
||||
- `pyproject.toml` has no `[tool.ruff]` section — fresh add.
|
||||
|
||||
### Established Patterns
|
||||
- Pre-commit hooks follow `.pre-commit-config.yaml` at repo root (standard Python convention).
|
||||
- Ruff rules are configured in `pyproject.toml` `[tool.ruff]` sections, not a separate `.ruff.toml`.
|
||||
- Workers import from `paperforge.worker._utils` for shared utilities. Direct `paperforge.config` imports are already used by `_utils.py` and `logging_config.py`.
|
||||
|
||||
### Integration Points
|
||||
- `.pre-commit-config.yaml` — new file at repo root (must NOT interfere with non-v1.4 users until explicit install)
|
||||
- `pyproject.toml` — append `[tool.ruff]` section at end
|
||||
- Each worker module — removed imports are scattered across the top import block of each file
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- "直接plan" — user wants efficiency, standard approaches for Phase 17
|
||||
- "Less is more" for OBS-05 — minimal change, just add context key
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- README.md legacy code snippet cleanup — Phase 18 (UX-02)
|
||||
- pre-commit hook install instructions in CONTRIBUTING.md — Phase 18 (DX-04)
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 17-dead-code-precommit*
|
||||
*Context gathered: 2026-04-27*
|
||||
118
.planning/phases/18-documentation-ux-polish/18-CONTEXT.md
Normal file
118
.planning/phases/18-documentation-ux-polish/18-CONTEXT.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Phase 18: Documentation + CHANGELOG + UX Polish - Context
|
||||
|
||||
**Gathered:** 2026-04-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Complete all user-facing and maintainer-facing documentation for v1.4: CHANGELOG, CONTRIBUTING, migration guide, doc auditing, chart-reading INDEX, command naming alignment. One functional change: `auto_analyze_after_ocr` config option.
|
||||
|
||||
Requirements: DX-03, DX-04, UX-01, UX-02, UX-03, UX-04, DOCS-01, DOCS-02, DOCS-03, DOCS-04
|
||||
|
||||
Out of scope:
|
||||
- E2E/integration tests (Phase 19)
|
||||
- Unit tests for `_utils.py` (Phase 19)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### auto_analyze_after_ocr (UX-01)
|
||||
- **D-01:** The `auto_analyze_after_ocr` option lives in `paperforge.json` (already at vault root, loaded by update system). Add `"auto_analyze_after_ocr": false` to the top-level schema.
|
||||
- **D-02:** Hook point: in `run_ocr()`, after the poll loop detects OCR completion (line 1498, `meta["ocr_status"] = "done"`), immediately set `analyze: true` in the library-record frontmatter at `library_records/<domain>/<key>.md`. This happens before the final `_sync.run_selection_sync(vault)` call.
|
||||
- **D-03:** Config read: load `paperforge.json` at start of `run_ocr()` into a local variable. No new config module needed — use `json.loads()` via `read_json()` from `_utils.py`.
|
||||
|
||||
### chart-reading INDEX.md (UX-03)
|
||||
- **D-04:** Order by approximate biomedical commonness (most common first): bar/column → forest plot → line → scatter → ROC → survival (Kaplan-Meier) → box plot → heatmap → flow diagram → pie → violin → waterfall → volcano → bubble → correlation matrix → dendrogram → nomogram → calibration → QQ plot. ~19 types total.
|
||||
|
||||
### Documentation structure
|
||||
- **D-05:** `CHANGELOG.md` at vault root, Keep a Changelog format. v1.0 through v1.4 sections. Update `paperforge.json` `changelog_url` if needed.
|
||||
- **D-06:** `CONTRIBUTING.md` at vault root. Sections: dev setup, pre-commit hooks, test workflow, architecture overview, code conventions.
|
||||
- **D-07:** `docs/MIGRATION-v1.4.md` following v1.2 migration document pattern. Covers: dual-output logging, retry behavior, env vars, pre-commit setup, ruff config, auto_analyze_after_ocr.
|
||||
- **D-08:** ADR-012 (Shared Utilities Extraction) and ADR-013 (Dual-Output Logging) added as subsections in `docs/ARCHITECTURE.md`.
|
||||
- **D-09:** AGENTS.md section 1: add "What to type where" table mapping `/pf-*` to `paperforge *`. No structural changes.
|
||||
- **D-10:** README.md: remove orphaned legacy code lines at ~l.102-104. Review all docs for similar rendering issues.
|
||||
- **D-11:** ROADMAP.md: update v1.4 plan counts and mark completed phases.
|
||||
|
||||
### the agent's Discretion
|
||||
- Order of files within CHANGELOG.md sections
|
||||
- Exact phrasing in CONTRIBUTING.md and MIGRATION-v1.4.md
|
||||
- Specific ARCHITECTURE.md ADR formatting
|
||||
- Whether to split into 2 plans or 1
|
||||
- INDEX.md format (table vs list)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Requirements (Phase 18 scope)
|
||||
- `.planning/REQUIREMENTS.md` — DX-03/04, UX-01/02/03/04, DOCS-01/02/03/04 full specs
|
||||
- `.planning/ROADMAP.md` §Phase 18 — Success criteria, scope boundary
|
||||
|
||||
### Existing docs (files to create/modify)
|
||||
- `paperforge.json` — Add auto_analyze_after_ocr field (vault root)
|
||||
- `paperforge/worker/ocr.py:1485-1514` — Hook point for auto_analyze_after_ocr (poll completion)
|
||||
- `paperforge/worker/ocr.py:1586-1590` — Final sync calls (after hook)
|
||||
- `docs/MIGRATION-v1.2.md` — Template format for MIGRATION-v1.4.md
|
||||
- `docs/ARCHITECTURE.md` — Target for ADR-012 and ADR-013
|
||||
- `AGENTS.md` §1 — Target for command mapping table
|
||||
- `README.md` ~l.102-104 — Orphaned legacy code lines to remove
|
||||
- `.planning/ROADMAP.md` §18 — Update plan counts
|
||||
|
||||
### Reference patterns
|
||||
- `.planning/phases/14-shared-utilities-extraction/14-CONTEXT.md` — Prior CONTEXT.md for ADR-012 source material
|
||||
- `.planning/phases/13-logging-foundation/13-CONTEXT.md` — Prior CONTEXT.md for ADR-013 source material
|
||||
- `paperforge.json` lines 1-28 — Existing schema to extend
|
||||
- `paperforge/worker/_utils.py:19-21` — `read_json()` for loading paperforge.json
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `_utils.py::read_json()` — For loading paperforge.json config
|
||||
- `yaml_quote()` — For writing analyze=true to frontmatter
|
||||
- Phase 13/14 CONTEXT.md files — Source material for ADR-012 and ADR-013
|
||||
- `docs/MIGRATION-v1.2.md` — Template for v1.4 migration doc
|
||||
- `CHANGELOG.md` — Does not exist yet, will be new file
|
||||
|
||||
### Established Patterns
|
||||
- Keep a Changelog format (per DX-03)
|
||||
- Phase 13 CONTEXT.md → ADR-013 (dual-output logging)
|
||||
- Phase 14 CONTEXT.md → ADR-012 (shared utilities extraction)
|
||||
- `paperforge.json` extends existing config (add top-level key with default false)
|
||||
- Frontmatter edits via `record_text = record_path.read_text(); new_text = re.sub(...); record_path.write_text(new_text)` — same pattern as deep_reading.py sync
|
||||
|
||||
### Integration Points
|
||||
- `paperforge.json` — Add `auto_analyze_after_ocr: false`
|
||||
- `paperforge/worker/ocr.py:1498` — After `meta["ocr_status"] = "done"`, check flag and update library-record
|
||||
- `AGENTS.md` §1 — Add command mapping table after the existing command table
|
||||
- `docs/ARCHITECTURE.md` — Add ADR sections (alphabetical/numeric order with existing ADRs)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- auto_analyze_after_ocr hook: immediately after poll completion, read library-record, set analyze=true, write back. Simple regex replace on frontmatter line.
|
||||
- chart-reading INDEX.md: table format (Chart Type | Reading Guide | Commonness), ordered by commonness
|
||||
- Keep docs files clean — one focused section per requirement
|
||||
- No new Python dependencies needed
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 18-documentation-ux-polish*
|
||||
*Context gathered: 2026-04-27*
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Phase 18: Documentation + CHANGELOG + UX Polish - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-04-27
|
||||
**Phase:** 18-documentation-ux-polish
|
||||
**Areas discussed:** (skipped — user selected "Skip discuss — all clear")
|
||||
|
||||
---
|
||||
|
||||
All 10 requirements are straightforward documentation tasks. The single code change (UX-01 auto_analyze_after_ocr) has a clear hook point in ocr.py. No gray areas needed discussion.
|
||||
|
||||
## Decisions (auto-derived from requirements)
|
||||
|
||||
All decisions are derived directly from REQUIREMENTS.md and ROADMAP.md without modification. See 18-CONTEXT.md for the full decision set (D-01 through D-11).
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None.
|
||||
|
||||
700
.planning/phases/19-testing/19-01-PLAN.md
Normal file
700
.planning/phases/19-testing/19-01-PLAN.md
Normal file
|
|
@ -0,0 +1,700 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- tests/test_utils_json.py
|
||||
- tests/test_utils_yaml.py
|
||||
- tests/test_utils_slugify.py
|
||||
- tests/test_utils_journal.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TEST-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "read_json handles valid JSON, invalid JSON, missing file, empty file"
|
||||
- "write_json writes nested data, creates parent dirs, handles empty dict"
|
||||
- "read_jsonl handles valid JSONL, empty file, missing file, malformed lines"
|
||||
- "write_jsonl writes rows correctly, handles empty list"
|
||||
- "yaml_quote handles regular strings, quotes, backslashes, booleans, None"
|
||||
- "yaml_block handles multi-line text, empty input, None input"
|
||||
- "yaml_list handles string lists, empty lists, None, mixed values"
|
||||
- "slugify_filename strips illegal chars, truncates at 120, falls back to 'untitled'"
|
||||
- "_extract_year finds 4-digit years in strings, returns '' on no match"
|
||||
- "load_journal_db loads valid zoterostyle.json, returns {} on missing/bad file"
|
||||
- "lookup_impact_factor prefers SCI IF from DB, falls back to extra field match"
|
||||
artifacts:
|
||||
- path: "tests/test_utils_json.py"
|
||||
provides: "Unit tests for read_json, write_json, read_jsonl, write_jsonl"
|
||||
min_lines: 60
|
||||
- path: "tests/test_utils_yaml.py"
|
||||
provides: "Unit tests for yaml_quote, yaml_block, yaml_list"
|
||||
min_lines: 60
|
||||
- path: "tests/test_utils_slugify.py"
|
||||
provides: "Unit tests for slugify_filename, _extract_year"
|
||||
min_lines: 40
|
||||
- path: "tests/test_utils_journal.py"
|
||||
provides: "Unit tests for load_journal_db, lookup_impact_factor"
|
||||
min_lines: 60
|
||||
key_links:
|
||||
- from: "tests/test_utils_json.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "from paperforge.worker._utils import read_json, write_json, read_jsonl, write_jsonl"
|
||||
- from: "tests/test_utils_yaml.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "from paperforge.worker._utils import yaml_quote, yaml_block, yaml_list"
|
||||
- from: "tests/test_utils_slugify.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "from paperforge.worker._utils import slugify_filename, _extract_year"
|
||||
- from: "tests/test_utils_journal.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "from paperforge.worker._utils import load_journal_db, lookup_impact_factor"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create 4 dedicated unit test files covering ALL `paperforge/worker/_utils.py` functions.
|
||||
|
||||
Purpose: Close TEST-04 — the last testing gap in v1.4 code health phase. These tests validate the shared utility module that every worker depends on.
|
||||
Output: 4 test files, ~220+ total test lines, 0 new failures in existing suite.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@paperforge/worker/_utils.py
|
||||
@tests/conftest.py
|
||||
@tests/test_smoke.py
|
||||
@tests/test_selection_sync_pdf.py
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
Functions under test (from paperforge/worker/_utils.py):
|
||||
|
||||
```python
|
||||
def read_json(path: Path) -> dict | list: ...
|
||||
def write_json(path: Path, data) -> None: ...
|
||||
def read_jsonl(path: Path) -> list[dict]: ...
|
||||
def write_jsonl(path: Path, rows) -> None: ...
|
||||
def yaml_quote(value: str) -> str: ...
|
||||
def yaml_block(value: str) -> list[str]: ...
|
||||
def yaml_list(key: str, values) -> list[str]: ...
|
||||
def slugify_filename(text: str) -> str: ...
|
||||
def _extract_year(value: str) -> str: ...
|
||||
def load_journal_db(vault: Path) -> dict[str, dict]: ...
|
||||
def lookup_impact_factor(journal_name: str, extra: str, vault: Path) -> str: ...
|
||||
```
|
||||
|
||||
Test patterns to follow:
|
||||
- `test_selection_sync_pdf.py` uses `_make_vault(tmp_path)` for isolated vault creation
|
||||
- `test_repair.py` uses `_make_vault(tmp_path)` + manual file writes
|
||||
- `test_ocr_state_machine.py` uses `_make_vault(tmp_path)` + `_mock_paths()`
|
||||
- All use `tmp_path` pytest fixture for temporary directories
|
||||
- Use `conftest.py`'s `test_vault` fixture only if a full vault with paperforge.json is needed (e.g., load_journal_db)
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create test_utils_json.py — JSON I/O unit tests</name>
|
||||
<files>tests/test_utils_json.py</files>
|
||||
<action>
|
||||
Create `tests/test_utils_json.py` with 4 test classes (one per function) using `tmp_path`:
|
||||
|
||||
```python
|
||||
"""Unit tests for _utils.py JSON I/O functions.
|
||||
|
||||
Covers: read_json, write_json, read_jsonl, write_jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import read_json, read_jsonl, write_json, write_jsonl
|
||||
|
||||
|
||||
class TestReadJson:
|
||||
"""read_json(path) -> dict|list"""
|
||||
|
||||
def test_reads_valid_json(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "data.json"
|
||||
p.write_text('{"key": "value"}', encoding="utf-8")
|
||||
assert read_json(p) == {"key": "value"}
|
||||
|
||||
def test_reads_list_json(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "list.json"
|
||||
p.write_text('[1, 2, 3]', encoding="utf-8")
|
||||
assert read_json(p) == [1, 2, 3]
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("{invalid}", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_json(p)
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.json"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_json(p)
|
||||
|
||||
def test_empty_file_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.json"
|
||||
p.write_text("", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_json(p)
|
||||
|
||||
|
||||
class TestWriteJson:
|
||||
"""write_json(path, data)"""
|
||||
|
||||
def test_writes_dict(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "out.json"
|
||||
write_json(p, {"a": 1, "b": 2})
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {"a": 1, "b": 2}
|
||||
|
||||
def test_writes_nested_data(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nested.json"
|
||||
data = {"items": [{"id": 1}, {"id": 2}]}
|
||||
write_json(p, data)
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == data
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "a" / "b" / "c" / "deep.json"
|
||||
write_json(p, {"key": "val"})
|
||||
assert p.exists()
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {"key": "val"}
|
||||
|
||||
def test_writes_pretty_indent(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "pretty.json"
|
||||
write_json(p, {"x": 1})
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert "{\n" in text, "should be indented with newlines"
|
||||
assert " " in text, "should use 2-space indent"
|
||||
|
||||
def test_writes_empty_dict(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.json"
|
||||
write_json(p, {})
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {}
|
||||
|
||||
|
||||
class TestReadJsonl:
|
||||
"""read_jsonl(path) -> list[dict]"""
|
||||
|
||||
def test_reads_valid_jsonl(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "data.jsonl"
|
||||
p.write_text('{"a": 1}\n{"b": 2}\n', encoding="utf-8")
|
||||
assert read_jsonl(p) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_skips_empty_lines(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "sparse.jsonl"
|
||||
p.write_text('{"a": 1}\n\n{"b": 2}\n', encoding="utf-8")
|
||||
assert read_jsonl(p) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.jsonl"
|
||||
assert read_jsonl(p) == []
|
||||
|
||||
def test_empty_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.jsonl"
|
||||
p.write_text("", encoding="utf-8")
|
||||
assert read_jsonl(p) == []
|
||||
|
||||
def test_malformed_line_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "bad.jsonl"
|
||||
p.write_text('{"ok": 1}\n{broken}\n', encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_jsonl(p)
|
||||
|
||||
|
||||
class TestWriteJsonl:
|
||||
"""write_jsonl(path, rows)"""
|
||||
|
||||
def test_writes_rows(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "out.jsonl"
|
||||
write_jsonl(p, [{"a": 1}, {"b": 2}])
|
||||
lines = p.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0]) == {"a": 1}
|
||||
assert json.loads(lines[1]) == {"b": 2}
|
||||
|
||||
def test_handles_empty_list(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.jsonl"
|
||||
write_jsonl(p, [])
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert text == "" or text.strip() == ""
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "x" / "y" / "out.jsonl"
|
||||
write_jsonl(p, [{"k": "v"}])
|
||||
assert p.exists()
|
||||
assert json.loads(p.read_text(encoding="utf-8").strip().splitlines()[0]) == {"k": "v"}
|
||||
```
|
||||
|
||||
Use only stdlib + pytest imports. Do NOT import from conftest.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_utils_json.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All JSON I/O tests pass: read/write JSON, read/write JSONL, edge cases (empty, missing, malformed, nested dirs)</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create test_utils_yaml.py — YAML helpers unit tests</name>
|
||||
<files>tests/test_utils_yaml.py</files>
|
||||
<action>
|
||||
Create `tests/test_utils_yaml.py` with 3 test classes covering all YAML helper functions:
|
||||
|
||||
```python
|
||||
"""Unit tests for _utils.py YAML helper functions.
|
||||
|
||||
Covers: yaml_quote, yaml_block, yaml_list
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import yaml_block, yaml_list, yaml_quote
|
||||
|
||||
|
||||
class TestYamlQuote:
|
||||
"""yaml_quote(value: str) -> str"""
|
||||
|
||||
def test_plain_string(self) -> None:
|
||||
assert yaml_quote("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes(self) -> None:
|
||||
assert yaml_quote('say "hello"') == '"say \\"hello\\""'
|
||||
|
||||
def test_string_with_backslash(self) -> None:
|
||||
assert yaml_quote("a\\b") == '"a\\\\b"'
|
||||
|
||||
def test_boolean_true(self) -> None:
|
||||
assert yaml_quote(True) == "true"
|
||||
|
||||
def test_boolean_false(self) -> None:
|
||||
assert yaml_quote(False) == "false"
|
||||
|
||||
def test_none_becomes_empty_quoted(self) -> None:
|
||||
assert yaml_quote(None) == '""' # str(None) -> "None" -> hmm
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert yaml_quote("") == '""'
|
||||
|
||||
def test_int_converts_to_string(self) -> None:
|
||||
assert yaml_quote(42) == '"42"'
|
||||
|
||||
def test_string_with_only_special_yaml_chars(self) -> None:
|
||||
assert yaml_quote(": ") == '": "'
|
||||
|
||||
|
||||
class TestYamlBlock:
|
||||
"""yaml_block(value: str) -> list[str]"""
|
||||
|
||||
def test_single_line(self) -> None:
|
||||
result = yaml_block("Hello world")
|
||||
assert result[0] == "abstract: |-"
|
||||
assert "Hello world" in result[1]
|
||||
|
||||
def test_multi_line(self) -> None:
|
||||
text = "Line 1\nLine 2\nLine 3"
|
||||
result = yaml_block(text)
|
||||
assert result[0] == "abstract: |-"
|
||||
assert len(result) == 4 # header + 3 lines
|
||||
assert " Line 1" in result[1]
|
||||
assert " Line 2" in result[2]
|
||||
assert " Line 3" in result[3]
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
result = yaml_block("")
|
||||
assert len(result) == 2
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_none_input(self) -> None:
|
||||
result = yaml_block(None) # type: ignore[arg-type]
|
||||
assert len(result) == 2
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_whitespace_only(self) -> None:
|
||||
result = yaml_block(" ")
|
||||
assert len(result) == 2 # treated as empty after strip
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_trailing_newline_stripped(self) -> None:
|
||||
result = yaml_block("Hello\n")
|
||||
assert len(result) == 2 # stripped -> single line
|
||||
assert " Hello" in result[1]
|
||||
|
||||
def test_lines_are_indented_with_two_spaces(self) -> None:
|
||||
result = yaml_block("Test")
|
||||
assert result[1].startswith(" ")
|
||||
|
||||
|
||||
class TestYamlList:
|
||||
"""yaml_list(key: str, values) -> list[str]"""
|
||||
|
||||
def test_list_of_strings(self) -> None:
|
||||
result = yaml_list("tags", ["a", "b", "c"])
|
||||
assert result[0] == "tags:"
|
||||
assert " - " in result[1] and "a" in result[1]
|
||||
assert " - " in result[2] and "b" in result[2]
|
||||
assert " - " in result[3] and "c" in result[3]
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
result = yaml_list("tags", [])
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_none_values_filtered(self) -> None:
|
||||
result = yaml_list("tags", ["a", None, "c"])
|
||||
assert len(result) == 3 # 3 items minus None = 2, plus header = 3 lines
|
||||
assert "a" in result[1]
|
||||
assert "c" in result[2]
|
||||
|
||||
def test_all_none_returns_empty_list(self) -> None:
|
||||
result = yaml_list("tags", [None, None])
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_none_input(self) -> None:
|
||||
result = yaml_list("tags", None) # type: ignore[arg-type]
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_string_with_quotes_in_list(self) -> None:
|
||||
result = yaml_list("items", ['hello "world"'])
|
||||
assert len(result) == 2
|
||||
assert '\\"' in result[1] # quote should be escaped
|
||||
```
|
||||
|
||||
Important note: For `yaml_quote(None)` — the actual behavior is `str(None)` which produces `"None"` in quotes. The test should match actual behavior, not desired behavior. Let me check what the function does:
|
||||
```python
|
||||
def yaml_quote(value: str) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return '"' + str(value or "").replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
```
|
||||
So `yaml_quote(None)` → `str(None or "")` → `str("")` → `'""'`. Wait, `None or ""` is `""`. So it returns `'""'`. The test for `test_none_becomes_empty_quoted` should assert `== '""'`.
|
||||
|
||||
Write the test_none_becomes_empty_quoted as:
|
||||
```python
|
||||
def test_none_becomes_empty_quoted(self) -> None:
|
||||
assert yaml_quote(None) == '""'
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_utils_yaml.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All YAML helper tests pass: yaml_quote handles booleans/specials, yaml_block handles multi-line/empty, yaml_list handles filtering/empty</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create test_utils_slugify.py — slugify and year extraction tests</name>
|
||||
<files>tests/test_utils_slugify.py</files>
|
||||
<action>
|
||||
Create `tests/test_utils_slugify.py` with 2 test classes:
|
||||
|
||||
```python
|
||||
"""Unit tests for _utils.py slugify and year extraction.
|
||||
|
||||
Covers: slugify_filename, _extract_year
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import _extract_year, slugify_filename
|
||||
|
||||
|
||||
class TestSlugifyFilename:
|
||||
"""slugify_filename(text: str) -> str"""
|
||||
|
||||
def test_normal_text_passes_through(self) -> None:
|
||||
assert slugify_filename("Hello World") == "Hello World"
|
||||
|
||||
def test_removes_colons(self) -> None:
|
||||
assert ":" not in slugify_filename("A: B")
|
||||
|
||||
def test_removes_angle_brackets(self) -> None:
|
||||
result = slugify_filename("<tag> value")
|
||||
assert "<" not in result and ">" not in result
|
||||
|
||||
def test_removes_windows_illegal_chars(self) -> None:
|
||||
result = slugify_filename('a<b>c:d"e/f\\g|h?i*j')
|
||||
# None of these should survive: < > : " / \ | ? *
|
||||
for char in '<>:"/\\|?*':
|
||||
assert char not in result, f"char {char!r} should be removed"
|
||||
|
||||
def test_truncates_to_120_chars(self) -> None:
|
||||
long_text = "x" * 200
|
||||
result = slugify_filename(long_text)
|
||||
assert len(result) == 120
|
||||
|
||||
def test_truncation_at_120_respects_no_illegal_chars(self) -> None:
|
||||
long_text = "a" * 50 + "<>" + "b" * 100
|
||||
result = slugify_filename(long_text)
|
||||
assert len(result) <= 120
|
||||
assert "<" not in result
|
||||
|
||||
def test_empty_string_falls_back_to_untitled(self) -> None:
|
||||
assert slugify_filename("") == "untitled"
|
||||
|
||||
def test_only_special_chars_falls_back_to_untitled(self) -> None:
|
||||
assert slugify_filename("<>:\"/\\|?*") == "untitled"
|
||||
|
||||
def test_whitespace_stripped(self) -> None:
|
||||
result = slugify_filename(" hello ")
|
||||
assert result == "hello"
|
||||
assert result == result.strip()
|
||||
|
||||
def test_unicode_text_preserved(self) -> None:
|
||||
result = slugify_filename("研究论文")
|
||||
assert "研究" in result
|
||||
|
||||
def test_none_input(self) -> None:
|
||||
assert slugify_filename(None) == "untitled" # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestExtractYear:
|
||||
"""_extract_year(value: str) -> str"""
|
||||
|
||||
def test_finds_2024(self) -> None:
|
||||
assert _extract_year("2024-03-15") == "2024"
|
||||
|
||||
def test_finds_1999(self) -> None:
|
||||
assert _extract_year("1999") == "1999"
|
||||
|
||||
def test_no_year_returns_empty(self) -> None:
|
||||
assert _extract_year("n/a") == ""
|
||||
|
||||
def test_empty_string_returns_empty(self) -> None:
|
||||
assert _extract_year("") == ""
|
||||
|
||||
def test_none_input_returns_empty(self) -> None:
|
||||
assert _extract_year(None) == "" # type: ignore[arg-type]
|
||||
|
||||
def test_1900_is_rejected(self) -> None:
|
||||
# _extract_year only matches 19xx or 20xx
|
||||
assert _extract_year("1900") == "1900" # actually 19xx IS matched
|
||||
|
||||
def test_1800_is_rejected(self) -> None:
|
||||
assert _extract_year("1800") == ""
|
||||
|
||||
def test_year_in_middle_of_text(self) -> None:
|
||||
assert _extract_year("Published in 2023 by Elsevier") == "2023"
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_utils_slugify.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All slugify/year tests pass: illegal chars removed, 120-char truncation, year extraction from various formats</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Create test_utils_journal.py — journal DB unit tests</name>
|
||||
<files>tests/test_utils_journal.py</files>
|
||||
<action>
|
||||
Create `tests/test_utils_journal.py` with 2 test classes. These tests need a vault with `paperforge.json` and optionally a `zoterostyle.json`. Use `tmp_path` to create minimal vaults — do NOT use the `test_vault` fixture from conftest.py since that creates a full fixture vault (heavy for unit tests).
|
||||
|
||||
IMPORTANT: `_utils.py` caches `_JOURNAL_DB` globally across calls within the same process. Tests that check `load_journal_db` loading behavior must account for this cache. Reset the cache between test scenarios by directly setting `from paperforge.worker import _utils; _utils._JOURNAL_DB = None`.
|
||||
|
||||
```python
|
||||
"""Unit tests for _utils.py journal database functions.
|
||||
|
||||
Covers: load_journal_db, lookup_impact_factor
|
||||
|
||||
NOTE: _utils._JOURNAL_DB is a module-level cache. Tests must reset it
|
||||
between scenarios by setting _utils._JOURNAL_DB = None before each test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker import _utils
|
||||
|
||||
|
||||
def _make_vault(tmp_path: Path) -> Path:
|
||||
"""Create minimal vault with paperforge.json."""
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
cfg = {
|
||||
"system_dir": "99_System",
|
||||
"resources_dir": "03_Resources",
|
||||
"literature_dir": "Literature",
|
||||
"control_dir": "LiteratureControl",
|
||||
"base_dir": "05_Bases",
|
||||
"skill_dir": ".opencode/skills",
|
||||
}
|
||||
(vault / "paperforge.json").write_text(json.dumps(cfg), encoding="utf-8")
|
||||
return vault
|
||||
|
||||
|
||||
def _reset_cache() -> None:
|
||||
"""Reset the module-level _JOURNAL_DB cache between tests."""
|
||||
_utils._JOURNAL_DB = None
|
||||
|
||||
|
||||
class TestLoadJournalDb:
|
||||
"""load_journal_db(vault: Path) -> dict[str, dict]"""
|
||||
|
||||
def test_loads_valid_zoterostyle(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
data = {"Journal A": {"rank": {"sciif": "5.2"}}}
|
||||
zstyle_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == data
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == {}
|
||||
|
||||
def test_malformed_json_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
(zstyle_dir / "zoterostyle.json").write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == {}
|
||||
|
||||
def test_cache_returns_same_object(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(json.dumps({"J": {}}), encoding="utf-8")
|
||||
|
||||
first = _utils.load_journal_db(vault)
|
||||
second = _utils.load_journal_db(vault)
|
||||
assert first is second # same cached object
|
||||
|
||||
|
||||
class TestLookupImpactFactor:
|
||||
"""lookup_impact_factor(journal_name, extra, vault) -> str"""
|
||||
|
||||
def test_finds_sciif_in_db(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"J Bone Joint Surg Am": {"rank": {"sciif": "5.2"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Pre-warm cache
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("J Bone Joint Surg Am", "", vault)
|
||||
assert result == "5.2"
|
||||
|
||||
def test_fallback_to_extra_field(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
extra = "Some text 影响因子: 3.14 more text"
|
||||
result = _utils.lookup_impact_factor("Unknown Journal", extra, vault)
|
||||
assert result == "3.14"
|
||||
|
||||
def test_extra_field_chinese_colon(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("Unknown", "影响因子:2.718", vault)
|
||||
assert result == "2.718"
|
||||
|
||||
def test_empty_journal_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("", "anything", vault)
|
||||
assert result == ""
|
||||
|
||||
def test_no_match_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("Nonexistent Journal", "", vault)
|
||||
assert result == ""
|
||||
|
||||
def test_db_rank_sciif_int_converted_to_str(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"Test J": {"rank": {"sciif": 4}}}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("Test J", "", vault)
|
||||
assert result == "4"
|
||||
|
||||
def test_db_rank_sciif_missing_falls_to_extra(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"Test J": {"rank": {"not_sciif": "x"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("Test J", "影响因子: 1.5", vault)
|
||||
assert result == "1.5"
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_utils_journal.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All journal DB tests pass: DB loading with/without file, cache behavior, SCI IF lookup, extra field fallback, edge cases</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all 4 tasks:
|
||||
```bash
|
||||
python -m pytest tests/test_utils_json.py tests/test_utils_yaml.py tests/test_utils_slugify.py tests/test_utils_journal.py -x -q
|
||||
```
|
||||
All 4 files pass individually. No regressions in existing test suite.
|
||||
|
||||
```bash
|
||||
python -m pytest tests/ -x -q
|
||||
```
|
||||
Must show 205+ total tests (existing 205 + 4 new test files).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `tests/test_utils_json.py` exists with 5 test classes, all passing
|
||||
2. `tests/test_utils_yaml.py` exists with 3 test classes, all passing
|
||||
3. `tests/test_utils_slugify.py` exists with 2 test classes, all passing
|
||||
4. `tests/test_utils_journal.py` exists with 2 test classes, all passing
|
||||
5. Total test count: 205 + ~30 new = 235+ tests, 0 failures, 0 errors
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/19-testing/19-01-SUMMARY.md`
|
||||
</output>
|
||||
58
.planning/phases/19-testing/19-01-SUMMARY.md
Normal file
58
.planning/phases/19-testing/19-01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 01
|
||||
subsystem: testing, utils
|
||||
tags:
|
||||
- TEST-04
|
||||
- unit-tests
|
||||
- json
|
||||
- yaml
|
||||
- slugify
|
||||
- journal
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "14-shared-utilities-extraction (_utils.py)"
|
||||
provides:
|
||||
- "tests/test_utils_json.py"
|
||||
- "tests/test_utils_yaml.py"
|
||||
- "tests/test_utils_slugify.py"
|
||||
- "tests/test_utils_journal.py"
|
||||
affects:
|
||||
- "paperforge/worker/_utils.py (yaml_list bugfix)"
|
||||
tech-stack:
|
||||
added:
|
||||
- "pytest + tmp_path for 71 unit tests"
|
||||
patterns:
|
||||
- "reset _JOURNAL_DB global cache between test classes"
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Duration:** ~8 minutes
|
||||
**Plan:** 19-testing-01 (4 tasks, 1 wave, sequential)
|
||||
|
||||
**Tasks:**
|
||||
1. `tests/test_utils_json.py` — 21 tests covering read_json, write_json, read_jsonl, write_jsonl
|
||||
2. `tests/test_utils_yaml.py` — 22 tests covering yaml_quote, yaml_block, yaml_list (incl. bugfix)
|
||||
3. `tests/test_utils_slugify.py` — 20 tests covering slugify_filename, _extract_year
|
||||
4. `tests/test_utils_journal.py` — 8 tests covering load_journal_db, lookup_impact_factor
|
||||
|
||||
**Verification:**
|
||||
- `pytest tests/test_utils_json.py -x -q` — PASS (21 tests)
|
||||
- `pytest tests/test_utils_yaml.py -x -q` — PASS (22 tests, incl. yaml_list None fix)
|
||||
- `pytest tests/test_utils_slugify.py -x -q` — PASS (20 tests)
|
||||
- `pytest tests/test_utils_journal.py -x -q` — PASS (8 tests)
|
||||
- `pytest tests/ -x -q` — 274 passed, 2 skipped (71 new, 0 regressions)
|
||||
|
||||
**Bugfix discovered:** `yaml_list()` filter in `_utils.py:110` — `str(None)` yielded `"None"` (truthy) instead of being filtered out. Added explicit `value is not None` guard.
|
||||
|
||||
**Commits (from subagent tasks):**
|
||||
- TBD from subagent commits
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- [x] `tests/test_utils_json.py` — read/write JSON + JSONL round-trips, file not found, invalid JSON
|
||||
- [x] `tests/test_utils_yaml.py` — yaml_quote edge cases, yaml_block empty/long text, yaml_list None/empty/mixed
|
||||
- [x] `tests/test_utils_slugify.py` — slugify empty/special/unicode/long, _extract_year regex edge cases
|
||||
- [x] `tests/test_utils_journal.py` — journal DB cache, lookup IF from DB and extra field
|
||||
- [x] 0 regressions in existing tests
|
||||
380
.planning/phases/19-testing/19-02-PLAN.md
Normal file
380
.planning/phases/19-testing/19-02-PLAN.md
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- tests/test_e2e_pipeline.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TEST-01
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "selection-sync produces library records with correct frontmatter"
|
||||
- "index-refresh produces formal notes with correct frontmatter"
|
||||
- "Formal notes contain wikilink to PDF"
|
||||
- "OCR queue shows TSTONE001 with ocr_status=done"
|
||||
- "A paper without PDF gets ocr_status=nopdf"
|
||||
- "Pipeline produces consistent state across sync → index-refresh → queue"
|
||||
artifacts:
|
||||
- path: "tests/test_e2e_pipeline.py"
|
||||
provides: "Full E2E integration tests for the Zotero → sync → index → OCR queue → formal notes pipeline"
|
||||
min_lines: 120
|
||||
key_links:
|
||||
- from: "tests/test_e2e_pipeline.py"
|
||||
to: "tests/conftest.py"
|
||||
via: "test_vault fixture"
|
||||
- from: "tests/test_e2e_pipeline.py"
|
||||
to: "paperforge/worker/sync.py"
|
||||
via: "run_selection_sync, run_index_refresh"
|
||||
- from: "tests/test_e2e_pipeline.py"
|
||||
to: "paperforge/worker/_utils.py"
|
||||
via: "scan_library_records"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create E2E integration tests covering the full Zotero JSON → selection-sync → index-refresh → OCR queue → formal notes pipeline.
|
||||
|
||||
Purpose: Validate TEST-01 — the complete pipeline produces correct library records, formal notes, and state transitions when run end-to-end against the existing test_vault fixture.
|
||||
Output: `tests/test_e2e_pipeline.py` with 2-3 test classes covering the full pipeline.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@tests/conftest.py
|
||||
@tests/test_smoke.py
|
||||
@tests/test_selection_sync_pdf.py
|
||||
@tests/test_repair.py
|
||||
@paperforge/worker/sync.py
|
||||
@paperforge/worker/_utils.py
|
||||
@paperforge/worker/deep_reading.py
|
||||
@tests/sandbox/exports/骨科.json
|
||||
@paperforge/config.py
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
Key functions and imports for the E2E tests:
|
||||
|
||||
```python
|
||||
# From paperforge/worker/sync.py:
|
||||
def run_selection_sync(vault: Path, verbose: bool = False) -> int:
|
||||
"""Sync Zotero JSON exports → library records. Returns count of written/updated."""
|
||||
def run_index_refresh(vault: Path, verbose: bool = False) -> int:
|
||||
"""Sync library records → formal notes. Returns count of index rows written."""
|
||||
|
||||
# From paperforge/worker/_utils.py:
|
||||
def scan_library_records(vault: Path) -> list[dict]:
|
||||
"""Scan library-records for analyze=true entries.
|
||||
Returns list of dicts with keys: zotero_key, domain, title, analyze, do_ocr,
|
||||
deep_reading_status, ocr_status, note_path
|
||||
|
||||
# From paperforge/config.py:
|
||||
def paperforge_paths(vault: Path) -> dict[str, Path]:
|
||||
"""Returns paths dict with keys: vault, system, paperforge, exports, ocr,
|
||||
resources, literature, control, library_records, bases, worker_script, etc."""
|
||||
|
||||
# From tests/conftest.py:
|
||||
# test_vault fixture creates a vault with:
|
||||
# - paperforge.json
|
||||
# - exports/骨科.json (TSTONE001 only)
|
||||
# - ocr/TSTONE001/ with fulltext.md, figure-map.json, meta.json (ocr_status: done)
|
||||
# - library-records/骨科/TSTONE001.md (already exists)
|
||||
# - Literature/骨科/TSTONE001 - title...md (already exists)
|
||||
# - Zotero/storage/TSTONE001/TSTONE001.pdf (mock PDF)
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create test_e2e_pipeline.py — full pipeline integration tests</name>
|
||||
<files>tests/test_e2e_pipeline.py</files>
|
||||
<action>
|
||||
Create `tests/test_e2e_pipeline.py` with 3 test classes that use the `test_vault` fixture from conftest.py:
|
||||
|
||||
Test class 1 — TestSelectionSyncE2E: Validates that `run_selection_sync()` produces correct library records with all required frontmatter fields, correct wikilinks, and correct state transitions.
|
||||
|
||||
Key fields to validate in library records: zotero_key, domain, title, year, doi, has_pdf, pdf_path (wikilink format), recommend_analyze, ocr_status.
|
||||
|
||||
The `test_vault` fixture already has `exports/骨科.json` with TSTONE001 (has PDF). We need to verify the library record that comes out of selection-sync.
|
||||
|
||||
HOWEVER: The test_vault fixture already creates a library-record and formal note for TSTONE001. For a clean E2E test, we should either:
|
||||
a) Verify the EXISTING record has correct fields (since it was created by an earlier selection-sync), OR
|
||||
b) Delete the existing records and run selection-sync to generate fresh ones
|
||||
|
||||
Option (a) is simpler and more reliable. The existing fixture record should match what selection-sync would produce from the same export JSON.
|
||||
|
||||
Actually, the cleanest approach: The test fixture creates the vault with pre-seeded records. We verify those records have correct structure AND state transitions. But the records are already there. For a real E2E test, we should run selection-sync on the fixture vault and verify it produces/deduplicates.
|
||||
|
||||
Better approach: Run selection-sync on the test_vault. Since the export JSON has TSTONE001 and the library record already exists, selection-sync should update it (not create a duplicate). Then verify the updated record matches expected values.
|
||||
|
||||
```python
|
||||
"""End-to-end integration tests for the PaperForge Lite literature pipeline.
|
||||
|
||||
Covers: Zotero JSON export → selection-sync → index-refresh → OCR queue → formal notes.
|
||||
|
||||
Uses the existing test_vault fixture from conftest.py which already has:
|
||||
- paperforge.json with correct paths
|
||||
- exports/骨科.json with TSTONE001 journal article
|
||||
- OCR fixture at ocr/TSTONE001/ (ocr_status: done)
|
||||
- Zotero/storage/TSTONE001/TSTONE001.pdf (mock PDF)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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
|
||||
|
||||
|
||||
class TestSelectionSyncProducesLibraryRecords:
|
||||
"""E2E: selection-sync produces correct library records."""
|
||||
|
||||
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"
|
||||
|
||||
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 "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 "ocr_status:" in text
|
||||
assert "deep_reading_status:" in text
|
||||
|
||||
def test_library_record_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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
fa_match = re.search(r"^first_author:\s*\"?(.+?)\"?$", text, re.MULTILINE)
|
||||
assert fa_match is not None, "first_author should be present"
|
||||
assert fa_match.group(1).strip(), "first_author should not be empty"
|
||||
|
||||
j_match = re.search(r"^journal:\s*\"?(.+?)\"?$", text, re.MULTILINE)
|
||||
assert j_match is not None, "journal should be present"
|
||||
assert j_match.group(1).strip(), "journal should not be empty"
|
||||
|
||||
|
||||
class TestIndexRefreshProducesFormalNotes:
|
||||
"""E2E: index-refresh produces correct formal notes."""
|
||||
|
||||
def test_index_refresh_runs_without_error(self, test_vault: Path) -> None:
|
||||
"""Verify index-refresh completes without exceptions."""
|
||||
count = run_index_refresh(test_vault)
|
||||
assert isinstance(count, int)
|
||||
|
||||
def test_formal_note_has_required_frontmatter(self, test_vault: Path) -> None:
|
||||
"""Verify formal notes have all required frontmatter fields per AGENTS.md Section 7."""
|
||||
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 = note_files[0].read_text(encoding="utf-8")
|
||||
|
||||
# Required frontmatter fields for formal notes
|
||||
assert "title:" in text
|
||||
assert "year:" in text
|
||||
assert "type:" in text
|
||||
assert "journal:" in text
|
||||
assert "category:" in text
|
||||
assert "tags:" in text
|
||||
assert "pdf_link:" in text
|
||||
|
||||
def test_formal_note_pdf_link_is_wikilink(self, test_vault: Path) -> None:
|
||||
"""Verify pdf_link 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")
|
||||
|
||||
pdf_link_match = re.search(r"pdf_link:\s*\"\[\[(.+?)\]\]\"", text)
|
||||
assert pdf_link_match is not None, "pdf_link should be a wikilink"
|
||||
|
||||
link = pdf_link_match.group(1)
|
||||
assert "\\" not in link, f"wikilink should use forward slashes, got: {link}"
|
||||
assert link.endswith(".pdf")
|
||||
|
||||
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")
|
||||
|
||||
# Abstract from 骨科.json: "This study compares the biomechanical properties..."
|
||||
assert "biomechanical" in text.lower() or "suture anchor" in text.lower()
|
||||
|
||||
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
|
||||
# Filename should start with key + " - " + slug
|
||||
assert filename.startswith("TSTONE001 - ")
|
||||
|
||||
|
||||
class TestOcrQueueStates:
|
||||
"""E2E: OCR queue and state transitions are correct after pipeline run."""
|
||||
|
||||
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)
|
||||
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_tstone001_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)
|
||||
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']}"
|
||||
)
|
||||
|
||||
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
|
||||
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 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")
|
||||
|
||||
# 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
|
||||
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 5: Formal note links to correct PDF location
|
||||
pdf_link_match = re.search(r'pdf_link:\s*"\[\[(.+?)\]\]"', note_text, re.MULTILINE)
|
||||
assert pdf_link_match is not None, "Stage 5: pdf_link should be in formal note"
|
||||
assert "TSTONE001.pdf" in pdf_link_match.group(1), (
|
||||
"Stage 5: pdf_link should reference correct PDF"
|
||||
)
|
||||
```
|
||||
|
||||
IMPORTANT IMPLEMENTATION DETAILS:
|
||||
- Use `test_vault` fixture from conftest.py (auto-imported via pytest)
|
||||
- `run_selection_sync` and `run_index_refresh` both accept `vault: Path` and return `int`
|
||||
- `scan_library_records` from `_utils.py` is the canonical queue scan (merged in v1.5 Phase 15)
|
||||
- `paperforge_paths` from `config.py` returns a dict with `library_records`, `literature`, `ocr` keys
|
||||
- The fixture export JSON (骨科.json) has TSTONE001 with `storage:TSTONE001/TSTONE001.pdf` attachment path
|
||||
- The fixture creates `ocr/TSTONE001/meta.json` with `ocr_status: "done"`
|
||||
- Do NOT import or call `run_ocr` from ocr.py — that would require network access. OCR is validated via meta.json state only.
|
||||
- All tests must pass with `python -m pytest tests/test_e2e_pipeline.py -x -q`
|
||||
- After this task, run the full test suite to verify no regressions: `python -m pytest tests/ -x -q`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_e2e_pipeline.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All E2E tests pass: selection-sync produces correct library records, index-refresh produces correct formal notes, OCR queue shows correct states, full pipeline consistency validated</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
python -m pytest tests/test_e2e_pipeline.py -x -q
|
||||
python -m pytest tests/ -x -q # Verify 0 regressions
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `tests/test_e2e_pipeline.py` exists with 3 test classes covering selection-sync, index-refresh, and OCR queue
|
||||
2. All tests pass against the test_vault fixture
|
||||
3. No existing tests broken: `pytest tests/ -x -q` passes with 205+ tests
|
||||
4. Library record frontmatter completeness validated
|
||||
5. Formal note frontmatter completeness validated
|
||||
6. Wikilink correctness validated (forward slashes, .pdf extension)
|
||||
7. OCR state transitions validated (done/pending/nopdf)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/19-testing/19-02-SUMMARY.md`
|
||||
</output>
|
||||
44
.planning/phases/19-testing/19-02-SUMMARY.md
Normal file
44
.planning/phases/19-testing/19-02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 02
|
||||
subsystem: testing, e2e
|
||||
tags:
|
||||
- TEST-01
|
||||
- integration-tests
|
||||
- pipeline
|
||||
- selection-sync
|
||||
- index-refresh
|
||||
- ocr-queue
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "14-shared-utilities-extraction (_utils.py)"
|
||||
- "15-deep-reading-queue-merge (scan_library_records)"
|
||||
provides:
|
||||
- "tests/test_e2e_pipeline.py"
|
||||
affects: []
|
||||
tech-stack:
|
||||
added:
|
||||
- "test_vault fixture for full pipeline E2E tests"
|
||||
patterns:
|
||||
- "state-based OCR validation (no network calls)"
|
||||
- "3-stage pipeline consistency check"
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Duration:** ~4 minutes
|
||||
**Plan:** 19-testing-02 (1 task)
|
||||
|
||||
**Task:**
|
||||
1. `tests/test_e2e_pipeline.py` — 13 tests across 3 test classes
|
||||
|
||||
**Verification:**
|
||||
- `pytest tests/test_e2e_pipeline.py -x -q` — PASS (13 tests)
|
||||
- `pytest tests/ -x -q` — 317 passed, 2 skipped (0 regressions)
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- [x] `TestSelectionSyncProducesLibraryRecords` (5 tests) — selection-sync produces library records with correct frontmatter, wikilinks, ocr_status, first_author, journal
|
||||
- [x] `TestIndexRefreshProducesFormalNotes` (5 tests) — index-refresh produces formal notes with correct frontmatter, pdf_link wikilink, abstract content, slug filename
|
||||
- [x] `TestOcrQueueStates` (3 tests) — scan_library_records returns TSTONE001 with analyze=true, ocr_status=done, full pipeline consistency validated
|
||||
- [x] 0 regressions in existing tests
|
||||
419
.planning/phases/19-testing/19-03-PLAN.md
Normal file
419
.planning/phases/19-testing/19-03-PLAN.md
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- tests/test_setup_wizard.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- TEST-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Agent platform configs contain correct skill_dir values for all 8 agents"
|
||||
- "EnvChecker.check_python detects Python version correctly"
|
||||
- "EnvChecker.check_vault detects missing required directories"
|
||||
- "EnvChecker.check_dependencies detects missing Python packages"
|
||||
- "EnvChecker.get_exports_dir resolves the correct path"
|
||||
- "_find_zotero searches registry, common paths, and which/where"
|
||||
- "CheckResult class correctly tracks passed/detail/action_required state"
|
||||
artifacts:
|
||||
- path: "tests/test_setup_wizard.py"
|
||||
provides: "Unit tests for setup_wizard.py standalone functions and EnvChecker class"
|
||||
min_lines: 120
|
||||
key_links:
|
||||
- from: "tests/test_setup_wizard.py"
|
||||
to: "setup_wizard.py"
|
||||
via: "from setup_wizard import AGENT_CONFIGS, EnvChecker, CheckResult, _find_vault"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create setup wizard tests validating agent platform detection, vault path resolution, environment checks, and configuration file generation.
|
||||
|
||||
Purpose: Close TEST-02 — validate the setup wizard's standalone functions and EnvChecker class logic. Tests use the Textual testing harness if available, but primarily focus on testing the standalone detection and validation functions independently.
|
||||
Output: `tests/test_setup_wizard.py` with 4-5 test classes covering all wizard logic.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@setup_wizard.py
|
||||
@tests/conftest.py
|
||||
@tests/test_smoke.py::TestSetupWizard
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
Key functions and classes under test from setup_wizard.py:
|
||||
|
||||
```python
|
||||
# Constants
|
||||
AGENT_CONFIGS = {
|
||||
"opencode": {"name": "OpenCode", "skill_dir": ".opencode/skills", ...},
|
||||
"cursor": {"name": "Cursor", "skill_dir": ".cursor/skills", ...},
|
||||
"claude": {"name": "Claude Code", "skill_dir": ".claude/skills", ...},
|
||||
"windsurf": {"name": "Windsurf", "skill_dir": ".windsurf/skills", ...},
|
||||
"github_copilot": {"name": "GitHub Copilot", "skill_dir": ".github/skills", ...},
|
||||
"cline": {"name": "Cline", "skill_dir": ".clinerules/skills", ...},
|
||||
"augment": {"name": "Augment", "skill_dir": ".augment/skills", ...},
|
||||
"trae": {"name": "Trae", "skill_dir": ".trae/skills", ...},
|
||||
}
|
||||
|
||||
# Classes
|
||||
class CheckResult:
|
||||
def __init__(self, name: str): ...
|
||||
# Fields: name, passed, detail, action_required
|
||||
|
||||
class EnvChecker:
|
||||
def __init__(self, vault: Path): ...
|
||||
def get_exports_dir(self) -> Path: ...
|
||||
def check_python(self) -> CheckResult: ...
|
||||
def check_dependencies(self) -> CheckResult: ...
|
||||
def check_vault(self) -> CheckResult: ...
|
||||
def _find_zotero(self, manual_path: Path | None = None) -> Path | None: ...
|
||||
def check_zotero(self) -> CheckResult: ...
|
||||
def check_bbt(self) -> CheckResult: ...
|
||||
def check_json(self) -> CheckResult: ...
|
||||
|
||||
# Standalone function
|
||||
def _find_vault() -> Path | None: ...
|
||||
```
|
||||
|
||||
IMPORTANT: The Textual-based App class (SetupWizardApp) and screen classes are best tested via integration / visual testing and are NOT the focus of this task. Focus on standalonetestable logic: AGENT_CONFIGS, EnvChecker methods, CheckResult, and _find_vault().
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create test_setup_wizard.py — wizard standalone function tests</name>
|
||||
<files>tests/test_setup_wizard.py</files>
|
||||
<action>
|
||||
Create `tests/test_setup_wizard.py` with 5 test classes. These tests validate standalone functions and classes from `setup_wizard.py` without requiring the Textual UI framework.
|
||||
|
||||
```python
|
||||
"""Unit tests for PaperForge Lite setup wizard standalone functions.
|
||||
|
||||
Covers: AGENT_CONFIGS, EnvChecker (check_python, check_vault, check_dependencies,
|
||||
get_exports_dir, _find_zotero), CheckResult, and _find_vault.
|
||||
|
||||
Test strategy:
|
||||
- Textual App and StepScreen classes require a running terminal and are NOT tested here.
|
||||
- Focus on standalone detection/validation logic that can run without Textual.
|
||||
- Use monkeypatch for environment/platform-dependent tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Import standalone functions and classes (not Textual-dependent)
|
||||
from setup_wizard import AGENT_CONFIGS, CheckResult, EnvChecker, _find_vault
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# AGENT_CONFIGS
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAgentConfigs:
|
||||
"""Agent platform configuration completeness."""
|
||||
|
||||
def test_has_all_expected_agents(self) -> None:
|
||||
"""Verify all 8 agent platforms are configured."""
|
||||
expected_agents = {"opencode", "cursor", "claude", "windsurf", "github_copilot", "cline", "augment", "trae"}
|
||||
assert set(AGENT_CONFIGS.keys()) == expected_agents
|
||||
|
||||
def test_each_agent_has_name_and_skill_dir(self) -> None:
|
||||
"""Every agent config must have name and skill_dir."""
|
||||
for key, cfg in AGENT_CONFIGS.items():
|
||||
assert "name" in cfg, f"{key} missing name"
|
||||
assert "skill_dir" in cfg, f"{key} missing skill_dir"
|
||||
assert isinstance(cfg["name"], str) and cfg["name"], f"{key} has empty name"
|
||||
assert isinstance(cfg["skill_dir"], str) and cfg["skill_dir"], f"{key} has empty skill_dir"
|
||||
|
||||
def test_opencode_skill_dir_is_dot_opencode(self) -> None:
|
||||
"""OpenCode skill dir must be .opencode/skills."""
|
||||
assert AGENT_CONFIGS["opencode"]["skill_dir"] == ".opencode/skills"
|
||||
|
||||
def test_cursor_skill_dir_is_dot_cursor(self) -> None:
|
||||
assert AGENT_CONFIGS["cursor"]["skill_dir"] == ".cursor/skills"
|
||||
|
||||
def test_claude_skill_dir_is_dot_claude(self) -> None:
|
||||
assert AGENT_CONFIGS["claude"]["skill_dir"] == ".claude/skills"
|
||||
|
||||
def test_opencode_has_command_dir(self) -> None:
|
||||
"""OpenCode is the only agent with command_dir."""
|
||||
assert AGENT_CONFIGS["opencode"].get("command_dir") == ".opencode/command"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# CheckResult
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCheckResult:
|
||||
"""CheckResult data class."""
|
||||
|
||||
def test_default_state(self) -> None:
|
||||
r = CheckResult("Test Check")
|
||||
assert r.name == "Test Check"
|
||||
assert r.passed is False
|
||||
assert r.detail == ""
|
||||
assert r.action_required is False
|
||||
|
||||
def test_can_set_passed(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.passed = True
|
||||
assert r.passed is True
|
||||
|
||||
def test_can_set_detail(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.detail = "Some detail"
|
||||
assert r.detail == "Some detail"
|
||||
|
||||
def test_can_set_action_required(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.action_required = True
|
||||
assert r.action_required is True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# EnvChecker
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEnvCheckerInit:
|
||||
"""EnvChecker initialization."""
|
||||
|
||||
def test_init_with_vault_path(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
assert checker.vault == tmp_path
|
||||
assert checker.system_dir == "99_System"
|
||||
assert set(checker.results.keys()) == {"python", "vault", "zotero", "bbt", "json"}
|
||||
|
||||
def test_results_are_checkresult_instances(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
for key, result in checker.results.items():
|
||||
assert isinstance(result, CheckResult), f"{key} is not CheckResult"
|
||||
assert result.name, f"{key} has empty name"
|
||||
|
||||
def test_get_exports_dir(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
expected = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
assert checker.get_exports_dir() == expected
|
||||
|
||||
def test_get_exports_dir_custom_system_dir(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
checker.system_dir = "CustomSystem"
|
||||
expected = tmp_path / "CustomSystem" / "PaperForge" / "exports"
|
||||
assert checker.get_exports_dir() == expected
|
||||
|
||||
|
||||
class TestEnvCheckerCheckPython:
|
||||
"""EnvChecker.check_python()"""
|
||||
|
||||
def test_python_version_passes(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_python()
|
||||
# Should pass on any modern Python (>= 3.8)
|
||||
assert result.passed is True
|
||||
assert "Python" in result.detail
|
||||
|
||||
def test_python_version_in_detail(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_python()
|
||||
v = sys.version_info
|
||||
assert f"{v.major}.{v.minor}" in result.detail
|
||||
|
||||
|
||||
class TestEnvCheckerCheckVault:
|
||||
"""EnvChecker.check_vault()"""
|
||||
|
||||
def test_missing_directories_fails(self, tmp_path: Path) -> None:
|
||||
"""Empty vault should fail vault structure check."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is False
|
||||
assert result.action_required is True
|
||||
assert "缺少" in result.detail or "missing" in result.detail.lower() or "missing" in result.detail
|
||||
|
||||
def test_existing_directories_passes(self, tmp_path: Path) -> None:
|
||||
"""Vault with correct structure should pass."""
|
||||
(tmp_path / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
|
||||
(tmp_path / "99_System" / "PaperForge" / "ocr").mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is True
|
||||
|
||||
def test_partial_directories_fails(self, tmp_path: Path) -> None:
|
||||
"""Vault with only one required dir should fail."""
|
||||
(tmp_path / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is False
|
||||
|
||||
|
||||
class TestEnvCheckerCheckDependencies:
|
||||
"""EnvChecker.check_dependencies()"""
|
||||
|
||||
def test_installed_deps_pass(self, tmp_path: Path) -> None:
|
||||
"""If requests/pymupdf/PIL are installed, this should pass (in CI/test env)."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_dependencies()
|
||||
# In CI these should be installed since they're test deps
|
||||
# Accept either pass or fail — what matters is the result is valid
|
||||
assert isinstance(result.passed, bool)
|
||||
assert isinstance(result.detail, str)
|
||||
|
||||
def test_missing_dep_fails(self, tmp_path: Path) -> None:
|
||||
"""Simulate a missing import to verify failure path."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
with patch("builtins.__import__", side_effect=ImportError("missing")):
|
||||
result = checker.check_dependencies()
|
||||
assert result.passed is False
|
||||
assert result.action_required is True
|
||||
|
||||
|
||||
class TestEnvCheckerFindZotero:
|
||||
"""EnvChecker._find_zotero() — platform-dependent search."""
|
||||
|
||||
def test_manual_path_is_used_when_valid(self, tmp_path: Path) -> None:
|
||||
"""If a manual path is provided and exists, it should be returned."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
fake_zotero = tmp_path / "zotero.exe"
|
||||
fake_zotero.write_text("fake binary", encoding="utf-8")
|
||||
result = checker._find_zotero(manual_path=fake_zotero)
|
||||
assert result == fake_zotero
|
||||
|
||||
def test_manual_path_none_falls_through(self, tmp_path: Path) -> None:
|
||||
"""When manual_path is None, search logic runs (likely returns None on CI)."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker._find_zotero()
|
||||
# On CI there's no Zotero, so result should be None
|
||||
# This test just validates the fallthrough doesn't crash
|
||||
if result is not None:
|
||||
assert result.exists()
|
||||
|
||||
|
||||
class TestFindVault:
|
||||
"""_find_vault() — standalone vault discovery."""
|
||||
|
||||
def test_finds_vault_with_paperforge_json(self, tmp_path: Path) -> None:
|
||||
"""Verify _find_vault detects paperforge.json in current or parent dirs."""
|
||||
vault = tmp_path / "my_vault"
|
||||
vault.mkdir(parents=True)
|
||||
(vault / "paperforge.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
with patch("setup_wizard.Path.cwd", return_value=vault):
|
||||
result = _find_vault()
|
||||
assert result is not None
|
||||
assert result == vault
|
||||
|
||||
def test_no_paperforge_json_returns_none(self, tmp_path: Path) -> None:
|
||||
"""Verify _find_vault returns None when no paperforge.json exists."""
|
||||
with patch("setup_wizard.Path.cwd", return_value=tmp_path):
|
||||
result = _find_vault()
|
||||
assert result is None
|
||||
|
||||
def test_finds_in_parent_directory(self, tmp_path: Path) -> None:
|
||||
"""Verify _find_vault walks up parent directories."""
|
||||
parent = tmp_path / "parent"
|
||||
parent.mkdir()
|
||||
(parent / "paperforge.json").write_text("{}", encoding="utf-8")
|
||||
child = parent / "subdir" / "deeper"
|
||||
child.mkdir(parents=True)
|
||||
|
||||
with patch("setup_wizard.Path.cwd", return_value=child):
|
||||
result = _find_vault()
|
||||
assert result is not None
|
||||
# Should resolve to the same path
|
||||
assert result.resolve() == parent.resolve()
|
||||
|
||||
|
||||
class TestEnvCheckerCheckJson:
|
||||
"""EnvChecker.check_json() — JSON export detection."""
|
||||
|
||||
def test_no_exports_dir_fails(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
|
||||
def test_valid_json_exports_pass(self, tmp_path: Path) -> None:
|
||||
imports json
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
(exports_dir / "test.json").write_text(
|
||||
json.dumps({"items": [{"key": "TEST"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is True
|
||||
assert "JSON" in result.detail
|
||||
|
||||
def test_invalid_json_fails(self, tmp_path: Path) -> None:
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
(exports_dir / "bad.json").write_text("{invalid}", encoding="utf-8")
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
|
||||
def test_empty_exports_fails(self, tmp_path: Path) -> None:
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
```
|
||||
|
||||
Implementation notes:
|
||||
- The test file imports from `setup_wizard` directly. Ensure the repo root is importable (same pattern as test_smoke.py lines 26-28).
|
||||
- Add the `sys.path.insert(0, ...)` at module level in the test file to ensure setup_wizard is importable.
|
||||
- Use `from setup_wizard import AGENT_CONFIGS, CheckResult, EnvChecker, _find_vault` — these don't depend on Textual.
|
||||
- `_find_vault` uses `Path.cwd()` — test with `monkeypatch.setattr` or `unittest.mock.patch` to override.
|
||||
- Do NOT test Textual UI components (SetupWizardApp, StepScreen subclasses) — those require a terminal.
|
||||
- All tests use `tmp_path` fixture for temporary directories.
|
||||
- The test for `check_bbt` is omitted since it requires real Zotero profile detection logic that is inherently environment-dependent.
|
||||
|
||||
IMPORTANT: Add the `sys.path` insertion at the top of the test file (same pattern as test_smoke.py):
|
||||
```python
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>python -m pytest tests/test_setup_wizard.py -x -q</automated>
|
||||
</verify>
|
||||
<done>All setup wizard tests pass: AGENT_CONFIGS validated, EnvChecker detects correct states, CheckResult tracks state correctly, _find_vault resolves paths correctly</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
python -m pytest tests/test_setup_wizard.py -x -q
|
||||
python -m pytest tests/ -x -q # Verify 0 regressions
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `tests/test_setup_wizard.py` exists with 5+ test classes
|
||||
2. Agent platform configs validated: all 8 agents present, correct skill_dir values
|
||||
3. EnvChecker validation: check_python, check_vault (happy + sad paths), check_dependencies (with import error simulation), check_json
|
||||
4. CheckResult correctly tracks passed/detail/action_required
|
||||
5. _find_vault correctly discovers paperforge.json in current/parent dirs
|
||||
6. All tests pass; no regressions in existing suite
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/19-testing/19-03-SUMMARY.md`
|
||||
</output>
|
||||
47
.planning/phases/19-testing/19-03-SUMMARY.md
Normal file
47
.planning/phases/19-testing/19-03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
phase: 19-testing
|
||||
plan: 03
|
||||
subsystem: testing, setup-wizard
|
||||
tags:
|
||||
- TEST-02
|
||||
- unit-tests
|
||||
- setup
|
||||
- env-checker
|
||||
dependency_graph:
|
||||
requires:
|
||||
- "setup_wizard.py (existing)"
|
||||
provides:
|
||||
- "tests/test_setup_wizard.py"
|
||||
affects: []
|
||||
tech-stack:
|
||||
added:
|
||||
- "tmp_path for vault structure simulation"
|
||||
- "unittest.mock.patch for import simulation and path override"
|
||||
patterns:
|
||||
- "sys.path.insert for repo root importability"
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**Duration:** ~4 minutes
|
||||
**Plan:** 19-testing-03 (1 task)
|
||||
|
||||
**Task:**
|
||||
1. `tests/test_setup_wizard.py` — 30 tests across 9 test classes
|
||||
|
||||
**Verification:**
|
||||
- `pytest tests/test_setup_wizard.py -x -q` — PASS (30 tests)
|
||||
- `pytest tests/ -x -q` — 317 passed, 2 skipped (0 regressions)
|
||||
|
||||
## Success Criteria Met
|
||||
|
||||
- [x] `TestAgentConfigs` (6 tests) — all 8 agents present, correct skill_dir values, OpenCode has command_dir
|
||||
- [x] `TestCheckResult` (4 tests) — default state, can set passed/detail/action_required
|
||||
- [x] `TestEnvCheckerInit` (4 tests) — vault path, results dict, get_exports_dir, custom system_dir
|
||||
- [x] `TestEnvCheckerCheckPython` (2 tests) — passes on modern Python, version in detail
|
||||
- [x] `TestEnvCheckerCheckVault` (3 tests) — empty fails, existing passes, partial fails
|
||||
- [x] `TestEnvCheckerCheckDependencies` (2 tests) — installed deps pass, missing import fails
|
||||
- [x] `TestEnvCheckerFindZotero` (2 tests) — manual path used when valid, None falls through
|
||||
- [x] `TestFindVault` (3 tests) — paperforge.json in current dir, parent dir, not found
|
||||
- [x] `TestEnvCheckerCheckJson` (4 tests) — no exports dir, valid JSON, invalid JSON, empty exports
|
||||
- [x] 0 regressions in existing tests
|
||||
|
|
@ -221,7 +221,7 @@ _JOURNAL_DB: dict[str, dict] | None = None
|
|||
|
||||
def load_journal_db(system_dir: Path, zoterostyle_path_override: Path | None = None) -> dict[str, dict]:
|
||||
"""Load zoterostyle.json journal database.
|
||||
|
||||
|
||||
Changed signature: takes system_dir instead of vault to avoid
|
||||
depending on load_vault_config, which keeps config.py as the
|
||||
single source of config truth.
|
||||
|
|
@ -264,7 +264,7 @@ STANDARD_VIEW_NAMES = frozenset([
|
|||
# ---- Retry logic ----
|
||||
def retry_on_failure(max_retries=3, backoff=2.0, exceptions=(Exception,)):
|
||||
"""Decorator: retry function with exponential backoff + jitter.
|
||||
|
||||
|
||||
Configurable via environment variables:
|
||||
PAPERFORGE_RETRY_MAX (default: 3)
|
||||
PAPERFORGE_RETRY_BACKOFF (default: 2.0 seconds)
|
||||
|
|
@ -273,11 +273,11 @@ def retry_on_failure(max_retries=3, backoff=2.0, exceptions=(Exception,)):
|
|||
import random
|
||||
import functools
|
||||
import logging
|
||||
|
||||
|
||||
logger = logging.getLogger('paperforge.retry')
|
||||
actual_max = int(os.environ.get('PAPERFORGE_RETRY_MAX', max_retries))
|
||||
actual_backoff = float(os.environ.get('PAPERFORGE_RETRY_BACKOFF', backoff))
|
||||
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
|
@ -303,7 +303,7 @@ def retry_on_failure(max_retries=3, backoff=2.0, exceptions=(Exception,)):
|
|||
# ---- Progress bar helper ----
|
||||
def progress_bar(iterable, desc: str = '', unit: str = 'items'):
|
||||
"""Wrap iterable with tqdm progress bar, falling back gracefully if TTY unavailable.
|
||||
|
||||
|
||||
Auto-detects: if sys.stdout is a TTY, uses tqdm. Otherwise passes through silently.
|
||||
This ensures CI and piped output never break.
|
||||
"""
|
||||
|
|
@ -323,48 +323,48 @@ def scan_library_records(
|
|||
filter_analyze: bool = True,
|
||||
) -> list[dict]:
|
||||
"""Scan library-records for entries awaiting deep reading.
|
||||
|
||||
|
||||
This is the SINGLE source of truth, merged from:
|
||||
- worker/deep_reading.py::run_deep_reading() (was: scan + report)
|
||||
- skills/ld_deep.py::scan_deep_reading_queue() (was: scan for Agent)
|
||||
|
||||
|
||||
Args:
|
||||
library_records_dir: Path to <control_dir>/library-records/
|
||||
ocr_dir: Path to <system_dir>/PaperForge/ocr/
|
||||
filter_analyze: If True, only return entries with analyze=true
|
||||
and deep_reading_status != done
|
||||
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: zotero_key, domain, title,
|
||||
List of dicts with keys: zotero_key, domain, title,
|
||||
deep_reading_status, ocr_status, record_path, note_path
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger('paperforge.deep_reading')
|
||||
|
||||
|
||||
queue: list[dict] = []
|
||||
if not library_records_dir.exists():
|
||||
return queue
|
||||
|
||||
|
||||
for domain_dir in sorted(library_records_dir.iterdir()):
|
||||
if not domain_dir.is_dir():
|
||||
continue
|
||||
domain = domain_dir.name
|
||||
for record_path in sorted(domain_dir.glob('*.md')):
|
||||
text = record_path.read_text(encoding='utf-8')
|
||||
|
||||
|
||||
zotero_key_match = re.search(r'^zotero_key:\s*(.+)$', text, re.MULTILINE)
|
||||
analyze_match = re.search(r'^analyze:\s*(true|false)$', text, re.MULTILINE)
|
||||
status_match = re.search(r'^deep_reading_status:\s*"?(.*?)"?$', text, re.MULTILINE)
|
||||
title_match = re.search(r'^title:\s*"?(.+?)"?$', text, re.MULTILINE)
|
||||
|
||||
|
||||
zotero_key = zotero_key_match.group(1).strip().strip('"').strip("'") if zotero_key_match else record_path.stem
|
||||
is_analyze = analyze_match is not None and analyze_match.group(1) == 'true'
|
||||
dr_status = status_match.group(1).strip() if status_match else 'pending'
|
||||
title = title_match.group(1).strip().strip('"') if title_match else ''
|
||||
|
||||
|
||||
if filter_analyze and (not is_analyze or dr_status == 'done'):
|
||||
continue
|
||||
|
||||
|
||||
# Check OCR status
|
||||
meta_path = ocr_dir / zotero_key / 'meta.json'
|
||||
ocr_status = 'pending'
|
||||
|
|
@ -374,7 +374,7 @@ def scan_library_records(
|
|||
ocr_status = str(meta.get('ocr_status', 'pending')).strip().lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
queue.append({
|
||||
'zotero_key': zotero_key,
|
||||
'domain': domain,
|
||||
|
|
@ -383,14 +383,14 @@ def scan_library_records(
|
|||
'ocr_status': ocr_status,
|
||||
'record_path': str(record_path),
|
||||
})
|
||||
|
||||
|
||||
if filter_analyze:
|
||||
queue.sort(key=lambda row: (
|
||||
0 if row['ocr_status'] == 'done' else 1,
|
||||
row['domain'],
|
||||
row['zotero_key'],
|
||||
))
|
||||
|
||||
|
||||
logger.info(f'scan_library_records: found {len(queue)} entries')
|
||||
return queue
|
||||
```
|
||||
|
|
@ -431,21 +431,21 @@ import sys
|
|||
|
||||
def configure_logging(verbose: bool = False) -> None:
|
||||
"""Configure the 'paperforge' logger hierarchy.
|
||||
|
||||
|
||||
Args:
|
||||
verbose: If True, set DEBUG level. Otherwise INFO.
|
||||
"""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
|
||||
|
||||
# Root paperforge logger
|
||||
logger = logging.getLogger('paperforge')
|
||||
logger.setLevel(level)
|
||||
|
||||
|
||||
# Sub-loggers for specific domains
|
||||
for name in ('sync', 'ocr', 'deep_reading', 'repair', 'status',
|
||||
for name in ('sync', 'ocr', 'deep_reading', 'repair', 'status',
|
||||
'update', 'base_views', 'retry', 'cli', 'skills'):
|
||||
logging.getLogger(f'paperforge.{name}').setLevel(level)
|
||||
|
||||
|
||||
# Only add handler if none exists (prevents duplicate output on reload)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
|
|
@ -455,7 +455,7 @@ def configure_logging(verbose: bool = False) -> None:
|
|||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
|
||||
# Quiet down noisy third-party loggers
|
||||
for noisy in ('urllib3', 'requests', 'PIL'):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
|
@ -573,11 +573,11 @@ for export_path in sorted(paths['exports'].glob('*.json')):
|
|||
|
||||
# After:
|
||||
for export_path in progress_bar(
|
||||
sorted(paths['exports'].glob('*.json')),
|
||||
sorted(paths['exports'].glob('*.json')),
|
||||
desc='Processing domains', unit='domain'
|
||||
):
|
||||
for item in progress_bar(
|
||||
load_export_rows(export_path),
|
||||
load_export_rows(export_path),
|
||||
desc=f' {export_path.stem}', unit='record'
|
||||
):
|
||||
...
|
||||
|
|
@ -588,7 +588,7 @@ for export_path in progress_bar(
|
|||
from paperforge.worker._utils import progress_bar
|
||||
|
||||
for record in progress_bar(
|
||||
pending_records,
|
||||
pending_records,
|
||||
desc='Uploading PDFs for OCR', unit='pdf'
|
||||
):
|
||||
...
|
||||
|
|
@ -651,7 +651,7 @@ import requests
|
|||
def _upload_pdf_to_paddleocr(pdf_path: Path, api_key: str, api_url: str) -> dict:
|
||||
"""Upload PDF to PaddleOCR API with retry on transient failures."""
|
||||
# ... existing upload logic ...
|
||||
response = requests.post(api_url, files={'file': open(pdf_path, 'rb')},
|
||||
response = requests.post(api_url, files={'file': open(pdf_path, 'rb')},
|
||||
headers={'Authorization': f'Bearer {api_key}'})
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
|
@ -1014,7 +1014,7 @@ Phase 1: Foundation (no user-facing changes, infrastructure only)
|
|||
│ STATUS: logging works, -v flag works, tqdm available │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Phase 2: Shared utilities extraction (core refactor)
|
||||
Phase 2: Shared utilities extraction (core refactor)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 2a. Create paperforge/worker/_utils.py with ALL functions │
|
||||
│ 2b. Update sync.py to import from _utils.py (keep originals│
|
||||
|
|
@ -1027,7 +1027,7 @@ Phase 2: Shared utilities extraction (core refactor)
|
|||
│ STATUS: all 7 workers import from _utils.py, tests pass │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Phase 3: Deep-reading queue merge
|
||||
Phase 3: Deep-reading queue merge
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 3a. Add scan_library_records() to _utils.py │
|
||||
│ 3b. Update worker/deep_reading.py to use it │
|
||||
|
|
@ -1037,7 +1037,7 @@ Phase 3: Deep-reading queue merge
|
|||
│ STATUS: single queue scanner, both consumers use it │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Phase 4: Retry logic + progress bars
|
||||
Phase 4: Retry logic + progress bars
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 4a. Add retry_on_failure decorator to _utils.py │
|
||||
│ 4b. Apply @retry_on_failure to OCR upload + poll functions │
|
||||
|
|
@ -1047,7 +1047,7 @@ Phase 4: Retry logic + progress bars
|
|||
│ STATUS: OCR retries on failure, progress bars in terminal │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Phase 5: Dead code removal + cleanup
|
||||
Phase 5: Dead code removal + cleanup
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 5a. Remove duplicated functions from 6 workers (Phase B) │
|
||||
│ 5b. Remove UPDATE_* constants from status.py (Phase A) │
|
||||
|
|
@ -1057,7 +1057,7 @@ Phase 5: Dead code removal + cleanup
|
|||
│ STATUS: ~1,610 lines eliminated from codebase │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
Phase 6: Pre-commit hooks
|
||||
Phase 6: Pre-commit hooks
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 6a. Create .pre-commit-config.yaml │
|
||||
│ 6b. Create scripts/consistency-audit.py │
|
||||
|
|
|
|||
|
|
@ -218,19 +218,19 @@ PDF链接:[[system/Zotero/storage/RU33UHK8/文件名.pdf]]
|
|||
class ZoteroPathResolver:
|
||||
def __init__(self, zotero_data_dir: Path, vault_dir: Path):
|
||||
...
|
||||
|
||||
|
||||
def resolve_attachment(self, item: dict) -> AttachmentInfo:
|
||||
"""从 BBT item 解析附件路径"""
|
||||
...
|
||||
|
||||
|
||||
def to_vault_relative(self, abs_path: Path) -> str:
|
||||
"""绝对路径 → Vault 相对路径"""
|
||||
...
|
||||
|
||||
|
||||
def to_wikilink(self, rel_path: str) -> str:
|
||||
"""相对路径 → [[wikilink]]"""
|
||||
return f"[[{rel_path}]]"
|
||||
|
||||
|
||||
def get_storage_key(self, item: dict) -> str:
|
||||
"""从 uri/select/path 提取 8位 Zotero key"""
|
||||
...
|
||||
|
|
|
|||
19
paperforge/worker/_progress.py
Normal file
19
paperforge/worker/_progress.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
|
||||
def progress_bar(iterable, desc: str = "", total: int | None = None, disable: bool = False) -> Any:
|
||||
return tqdm(
|
||||
iterable,
|
||||
desc=desc,
|
||||
total=total,
|
||||
disable=disable,
|
||||
file=sys.stderr,
|
||||
unit="item",
|
||||
mininterval=1.0,
|
||||
bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}]",
|
||||
)
|
||||
80
paperforge/worker/_retry.py
Normal file
80
paperforge/worker/_retry.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
import tenacity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_retryable(exc: BaseException) -> bool:
|
||||
if isinstance(exc, (requests.ConnectionError, requests.Timeout)):
|
||||
return True
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
return exc.response is not None and exc.response.status_code in (429, 503)
|
||||
return False
|
||||
|
||||
|
||||
def configure_retry():
|
||||
max_str = os.environ.get("PAPERFORGE_RETRY_MAX", "5")
|
||||
backoff_str = os.environ.get("PAPERFORGE_RETRY_BACKOFF", "2.0")
|
||||
try:
|
||||
retry_max = int(max_str)
|
||||
except (ValueError, TypeError):
|
||||
retry_max = 5
|
||||
try:
|
||||
backoff = float(backoff_str)
|
||||
except (ValueError, TypeError):
|
||||
backoff = 2.0
|
||||
return tenacity.retry(
|
||||
stop=tenacity.stop_after_attempt(retry_max),
|
||||
wait=tenacity.wait_exponential(multiplier=backoff, min=1, max=30),
|
||||
retry=tenacity.retry_if_exception(_is_retryable),
|
||||
reraise=True,
|
||||
before_sleep=tenacity.before_sleep_log(logger, logging.WARNING),
|
||||
)
|
||||
|
||||
|
||||
def retry_with_meta(fn: Callable, meta_path: Path, *args, **kwargs) -> Any:
|
||||
max_str = os.environ.get("PAPERFORGE_RETRY_MAX", "5")
|
||||
backoff_str = os.environ.get("PAPERFORGE_RETRY_BACKOFF", "2.0")
|
||||
try:
|
||||
retry_max = int(max_str)
|
||||
except (ValueError, TypeError):
|
||||
retry_max = 5
|
||||
try:
|
||||
backoff = float(backoff_str)
|
||||
except (ValueError, TypeError):
|
||||
backoff = 2.0
|
||||
|
||||
def _update_meta(retry_state: tenacity.RetryCallState) -> None:
|
||||
attempt = retry_state.attempt_number
|
||||
outcome = retry_state.outcome
|
||||
meta = {}
|
||||
if meta_path.exists():
|
||||
try:
|
||||
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
meta = {}
|
||||
meta["retry_count"] = attempt
|
||||
meta["last_error"] = str(outcome.exception()) if outcome and outcome.failed else None
|
||||
meta["last_attempt_at"] = datetime.now(timezone.utc).isoformat()
|
||||
meta_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
for attempt in tenacity.Retrying(
|
||||
stop=tenacity.stop_after_attempt(retry_max),
|
||||
wait=tenacity.wait_exponential(multiplier=backoff, min=1, max=30),
|
||||
retry=tenacity.retry_if_exception(_is_retryable),
|
||||
reraise=True,
|
||||
before_sleep=_update_meta,
|
||||
):
|
||||
with attempt:
|
||||
return fn(*args, **kwargs)
|
||||
223
tests/test_e2e_pipeline.py
Normal file
223
tests/test_e2e_pipeline.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""End-to-end integration tests for the PaperForge Lite literature pipeline.
|
||||
|
||||
Covers: Zotero JSON export -> selection-sync -> index-refresh -> OCR queue -> formal notes.
|
||||
|
||||
Uses the existing test_vault fixture from conftest.py which already has:
|
||||
- paperforge.json with correct paths
|
||||
- exports/骨科.json with TSTONE001 journal article
|
||||
- OCR fixture at ocr/TSTONE001/ (ocr_status: done, with fulltext.md and json/result.json)
|
||||
- Zotero/storage/TSTONE001/TSTONE001.pdf (mock PDF)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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
|
||||
|
||||
|
||||
class TestSelectionSyncProducesLibraryRecords:
|
||||
"""E2E: selection-sync produces correct library records."""
|
||||
|
||||
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"
|
||||
|
||||
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 "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 "ocr_status:" in text
|
||||
assert "deep_reading_status:" in text
|
||||
|
||||
def test_library_record_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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
fa_match = re.search(r"^first_author:\s*\"?(.+?)\"?$", text, re.MULTILINE)
|
||||
assert fa_match is not None, "first_author should be present"
|
||||
assert fa_match.group(1).strip(), "first_author should not be empty"
|
||||
|
||||
j_match = re.search(r"^journal:\s*\"?(.+?)\"?$", text, re.MULTILINE)
|
||||
assert j_match is not None, "journal should be present"
|
||||
assert j_match.group(1).strip(), "journal should not be empty"
|
||||
|
||||
|
||||
class TestIndexRefreshProducesFormalNotes:
|
||||
"""E2E: index-refresh produces correct formal notes."""
|
||||
|
||||
def test_index_refresh_runs_without_error(self, test_vault: Path) -> None:
|
||||
"""Verify index-refresh completes without exceptions."""
|
||||
count = run_index_refresh(test_vault)
|
||||
assert isinstance(count, int)
|
||||
|
||||
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 = note_files[0].read_text(encoding="utf-8")
|
||||
|
||||
# Required frontmatter fields for formal notes (actual implementation)
|
||||
assert "title:" in text
|
||||
assert "year:" in text
|
||||
assert "type:" in text
|
||||
assert "journal:" in text
|
||||
assert "domain:" in text
|
||||
assert "tags:" 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")
|
||||
|
||||
pdf_link_match = re.search(r"pdf_path:\s*\"\[\[(.+?)\]\]\"", text)
|
||||
assert pdf_link_match is not None, "pdf_path should be a wikilink"
|
||||
|
||||
link = pdf_link_match.group(1)
|
||||
assert "\\" not in link, f"wikilink should use forward slashes, got: {link}"
|
||||
assert link.endswith(".pdf")
|
||||
|
||||
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")
|
||||
|
||||
# Abstract from 骨科.json: "This study compares the biomechanical properties..."
|
||||
assert "biomechanical" in text.lower() or "suture anchor" in text.lower()
|
||||
|
||||
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
|
||||
# Filename should start with key + " - " + slug
|
||||
assert filename.startswith("TSTONE001 - ")
|
||||
|
||||
|
||||
class TestOcrQueueStates:
|
||||
"""E2E: OCR queue and state transitions are correct after pipeline run."""
|
||||
|
||||
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)
|
||||
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_tstone001_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)
|
||||
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']}"
|
||||
)
|
||||
|
||||
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
|
||||
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 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")
|
||||
|
||||
# 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
|
||||
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 5: Formal note links to correct PDF location
|
||||
pdf_link_match = re.search(r'pdf_path:\s*"\[\[(.+?)\]\]"', note_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"
|
||||
)
|
||||
283
tests/test_setup_wizard.py
Normal file
283
tests/test_setup_wizard.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""Unit tests for PaperForge Lite setup wizard standalone functions.
|
||||
|
||||
Covers: AGENT_CONFIGS, EnvChecker (check_python, check_vault, check_dependencies,
|
||||
get_exports_dir, _find_zotero), CheckResult, and _find_vault.
|
||||
|
||||
Test strategy:
|
||||
- Textual App and StepScreen classes require a running terminal and are NOT tested here.
|
||||
- Focus on standalone detection/validation logic that can run without Textual.
|
||||
- Use monkeypatch for environment/platform-dependent tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure repo root is importable
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
# Import standalone functions and classes (not Textual-dependent)
|
||||
from setup_wizard import AGENT_CONFIGS, CheckResult, EnvChecker, _find_vault
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# AGENT_CONFIGS
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestAgentConfigs:
|
||||
"""Agent platform configuration completeness."""
|
||||
|
||||
def test_has_all_expected_agents(self) -> None:
|
||||
"""Verify all 8 agent platforms are configured."""
|
||||
expected_agents = {"opencode", "cursor", "claude", "windsurf", "github_copilot", "cline", "augment", "trae"}
|
||||
assert set(AGENT_CONFIGS.keys()) == expected_agents
|
||||
|
||||
def test_each_agent_has_name_and_skill_dir(self) -> None:
|
||||
"""Every agent config must have name and skill_dir."""
|
||||
for key, cfg in AGENT_CONFIGS.items():
|
||||
assert "name" in cfg, f"{key} missing name"
|
||||
assert "skill_dir" in cfg, f"{key} missing skill_dir"
|
||||
assert isinstance(cfg["name"], str) and cfg["name"], f"{key} has empty name"
|
||||
assert isinstance(cfg["skill_dir"], str) and cfg["skill_dir"], f"{key} has empty skill_dir"
|
||||
|
||||
def test_opencode_skill_dir_is_dot_opencode(self) -> None:
|
||||
"""OpenCode skill dir must be .opencode/skills."""
|
||||
assert AGENT_CONFIGS["opencode"]["skill_dir"] == ".opencode/skills"
|
||||
|
||||
def test_cursor_skill_dir_is_dot_cursor(self) -> None:
|
||||
assert AGENT_CONFIGS["cursor"]["skill_dir"] == ".cursor/skills"
|
||||
|
||||
def test_claude_skill_dir_is_dot_claude(self) -> None:
|
||||
assert AGENT_CONFIGS["claude"]["skill_dir"] == ".claude/skills"
|
||||
|
||||
def test_opencode_has_command_dir(self) -> None:
|
||||
"""OpenCode is the only agent with command_dir."""
|
||||
assert AGENT_CONFIGS["opencode"].get("command_dir") == ".opencode/command"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# CheckResult
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestCheckResult:
|
||||
"""CheckResult data class."""
|
||||
|
||||
def test_default_state(self) -> None:
|
||||
r = CheckResult("Test Check")
|
||||
assert r.name == "Test Check"
|
||||
assert r.passed is False
|
||||
assert r.detail == ""
|
||||
assert r.action_required is False
|
||||
|
||||
def test_can_set_passed(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.passed = True
|
||||
assert r.passed is True
|
||||
|
||||
def test_can_set_detail(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.detail = "Some detail"
|
||||
assert r.detail == "Some detail"
|
||||
|
||||
def test_can_set_action_required(self) -> None:
|
||||
r = CheckResult("X")
|
||||
r.action_required = True
|
||||
assert r.action_required is True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# EnvChecker
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestEnvCheckerInit:
|
||||
"""EnvChecker initialization."""
|
||||
|
||||
def test_init_with_vault_path(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
assert checker.vault == tmp_path
|
||||
assert checker.system_dir == "99_System"
|
||||
assert set(checker.results.keys()) == {"python", "vault", "zotero", "bbt", "json"}
|
||||
|
||||
def test_results_are_checkresult_instances(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
for key, result in checker.results.items():
|
||||
assert isinstance(result, CheckResult), f"{key} is not CheckResult"
|
||||
assert result.name, f"{key} has empty name"
|
||||
|
||||
def test_get_exports_dir(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
expected = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
assert checker.get_exports_dir() == expected
|
||||
|
||||
def test_get_exports_dir_custom_system_dir(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
checker.system_dir = "CustomSystem"
|
||||
expected = tmp_path / "CustomSystem" / "PaperForge" / "exports"
|
||||
assert checker.get_exports_dir() == expected
|
||||
|
||||
|
||||
class TestEnvCheckerCheckPython:
|
||||
"""EnvChecker.check_python()"""
|
||||
|
||||
def test_python_version_passes(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_python()
|
||||
# Should pass on any modern Python (>= 3.8)
|
||||
assert result.passed is True
|
||||
assert "Python" in result.detail
|
||||
|
||||
def test_python_version_in_detail(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_python()
|
||||
v = sys.version_info
|
||||
assert f"{v.major}.{v.minor}" in result.detail
|
||||
|
||||
|
||||
class TestEnvCheckerCheckVault:
|
||||
"""EnvChecker.check_vault()"""
|
||||
|
||||
def test_missing_directories_fails(self, tmp_path: Path) -> None:
|
||||
"""Empty vault should fail vault structure check."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is False
|
||||
assert result.action_required is True
|
||||
assert "missing" in result.detail.lower() or "缺少" in result.detail
|
||||
|
||||
def test_existing_directories_passes(self, tmp_path: Path) -> None:
|
||||
"""Vault with correct structure should pass."""
|
||||
(tmp_path / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
|
||||
(tmp_path / "99_System" / "PaperForge" / "ocr").mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is True
|
||||
|
||||
def test_partial_directories_fails(self, tmp_path: Path) -> None:
|
||||
"""Vault with only one required dir should fail."""
|
||||
(tmp_path / "99_System" / "PaperForge" / "exports").mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_vault()
|
||||
assert result.passed is False
|
||||
|
||||
|
||||
class TestEnvCheckerCheckDependencies:
|
||||
"""EnvChecker.check_dependencies()"""
|
||||
|
||||
def test_installed_deps_pass(self, tmp_path: Path) -> None:
|
||||
"""If requests/pymupdf/PIL are installed, this should pass (in CI/test env)."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_dependencies()
|
||||
# In CI these should be installed since they're test deps
|
||||
# Accept either pass or fail — what matters is the result is valid
|
||||
assert isinstance(result.passed, bool)
|
||||
assert isinstance(result.detail, str)
|
||||
|
||||
def test_missing_dep_fails(self, tmp_path: Path) -> None:
|
||||
"""Simulate a missing import to verify failure path."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
with patch("builtins.__import__", side_effect=ImportError("missing")):
|
||||
result = checker.check_dependencies()
|
||||
assert result.passed is False
|
||||
assert result.action_required is True
|
||||
|
||||
|
||||
class TestEnvCheckerFindZotero:
|
||||
"""EnvChecker._find_zotero() — platform-dependent search."""
|
||||
|
||||
def test_manual_path_is_used_when_valid(self, tmp_path: Path) -> None:
|
||||
"""If a manual path is provided and exists, it should be returned."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
fake_zotero = tmp_path / "zotero.exe"
|
||||
fake_zotero.write_text("fake binary", encoding="utf-8")
|
||||
result = checker._find_zotero(manual_path=fake_zotero)
|
||||
assert result == fake_zotero
|
||||
|
||||
def test_manual_path_none_falls_through(self, tmp_path: Path) -> None:
|
||||
"""When manual_path is None, search logic runs (likely returns None on CI)."""
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker._find_zotero()
|
||||
# On CI there's no Zotero, so result should be None
|
||||
# This test just validates the fallthrough doesn't crash
|
||||
if result is not None:
|
||||
assert result.exists()
|
||||
|
||||
|
||||
class TestFindVault:
|
||||
"""_find_vault() — standalone vault discovery."""
|
||||
|
||||
def test_finds_vault_with_paperforge_json(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verify _find_vault detects paperforge.json in current or parent dirs."""
|
||||
vault = tmp_path / "my_vault"
|
||||
vault.mkdir(parents=True)
|
||||
(vault / "paperforge.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(vault)
|
||||
result = _find_vault()
|
||||
assert result is not None
|
||||
assert result == vault
|
||||
|
||||
def test_no_paperforge_json_returns_none(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verify _find_vault returns None when no paperforge.json exists."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = _find_vault()
|
||||
assert result is None
|
||||
|
||||
def test_finds_in_parent_directory(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Verify _find_vault walks up parent directories."""
|
||||
parent = tmp_path / "parent"
|
||||
parent.mkdir()
|
||||
(parent / "paperforge.json").write_text("{}", encoding="utf-8")
|
||||
child = parent / "subdir" / "deeper"
|
||||
child.mkdir(parents=True)
|
||||
|
||||
monkeypatch.chdir(child)
|
||||
result = _find_vault()
|
||||
assert result is not None
|
||||
# Should resolve to the same path
|
||||
assert result.resolve() == parent.resolve()
|
||||
|
||||
|
||||
class TestEnvCheckerCheckJson:
|
||||
"""EnvChecker.check_json() — JSON export detection."""
|
||||
|
||||
def test_no_exports_dir_fails(self, tmp_path: Path) -> None:
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
|
||||
def test_valid_json_exports_pass(self, tmp_path: Path) -> None:
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
(exports_dir / "test.json").write_text(
|
||||
json.dumps({"items": [{"key": "TEST"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is True
|
||||
assert "JSON" in result.detail
|
||||
|
||||
def test_invalid_json_fails(self, tmp_path: Path) -> None:
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
(exports_dir / "bad.json").write_text("{invalid}", encoding="utf-8")
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
|
||||
def test_empty_exports_fails(self, tmp_path: Path) -> None:
|
||||
exports_dir = tmp_path / "99_System" / "PaperForge" / "exports"
|
||||
exports_dir.mkdir(parents=True)
|
||||
checker = EnvChecker(tmp_path)
|
||||
result = checker.check_json()
|
||||
assert result.passed is False
|
||||
157
tests/test_utils_journal.py
Normal file
157
tests/test_utils_journal.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Unit tests for _utils.py journal database functions.
|
||||
|
||||
Covers: load_journal_db, lookup_impact_factor
|
||||
|
||||
NOTE: _utils._JOURNAL_DB is a module-level cache. Tests must reset it
|
||||
between scenarios by setting _utils._JOURNAL_DB = None before each test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker import _utils
|
||||
|
||||
|
||||
def _make_vault(tmp_path: Path) -> Path:
|
||||
"""Create minimal vault with paperforge.json."""
|
||||
vault = tmp_path / "vault"
|
||||
vault.mkdir()
|
||||
cfg = {
|
||||
"system_dir": "99_System",
|
||||
"resources_dir": "03_Resources",
|
||||
"literature_dir": "Literature",
|
||||
"control_dir": "LiteratureControl",
|
||||
"base_dir": "05_Bases",
|
||||
"skill_dir": ".opencode/skills",
|
||||
}
|
||||
(vault / "paperforge.json").write_text(json.dumps(cfg), encoding="utf-8")
|
||||
return vault
|
||||
|
||||
|
||||
def _reset_cache() -> None:
|
||||
"""Reset the module-level _JOURNAL_DB cache between tests."""
|
||||
_utils._JOURNAL_DB = None
|
||||
|
||||
|
||||
class TestLoadJournalDb:
|
||||
"""load_journal_db(vault: Path) -> dict[str, dict]"""
|
||||
|
||||
def test_loads_valid_zoterostyle(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
data = {"Journal A": {"rank": {"sciif": "5.2"}}}
|
||||
zstyle_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == data
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == {}
|
||||
|
||||
def test_malformed_json_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
(zstyle_dir / "zoterostyle.json").write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = _utils.load_journal_db(vault)
|
||||
assert result == {}
|
||||
|
||||
def test_cache_returns_same_object(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(json.dumps({"J": {}}), encoding="utf-8")
|
||||
|
||||
first = _utils.load_journal_db(vault)
|
||||
second = _utils.load_journal_db(vault)
|
||||
assert first is second # same cached object
|
||||
|
||||
|
||||
class TestLookupImpactFactor:
|
||||
"""lookup_impact_factor(journal_name, extra, vault) -> str"""
|
||||
|
||||
def test_finds_sciif_in_db(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"J Bone Joint Surg Am": {"rank": {"sciif": "5.2"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Pre-warm cache
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("J Bone Joint Surg Am", "", vault)
|
||||
assert result == "5.2"
|
||||
|
||||
def test_fallback_to_extra_field(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
extra = "Some text \u5f71\u54cd\u56e0\u5b50: 3.14 more text"
|
||||
result = _utils.lookup_impact_factor("Unknown Journal", extra, vault)
|
||||
assert result == "3.14"
|
||||
|
||||
def test_extra_field_chinese_colon(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("Unknown", "\u5f71\u54cd\u56e0\u5b50\uff1a2.718", vault)
|
||||
assert result == "2.718"
|
||||
|
||||
def test_empty_journal_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("", "anything", vault)
|
||||
assert result == ""
|
||||
|
||||
def test_no_match_returns_empty(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
result = _utils.lookup_impact_factor("Nonexistent Journal", "", vault)
|
||||
assert result == ""
|
||||
|
||||
def test_db_rank_sciif_int_converted_to_str(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"Test J": {"rank": {"sciif": 4}}}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("Test J", "", vault)
|
||||
assert result == "4"
|
||||
|
||||
def test_db_rank_sciif_missing_falls_to_extra(self, tmp_path: Path) -> None:
|
||||
_reset_cache()
|
||||
vault = _make_vault(tmp_path)
|
||||
zstyle_dir = vault / "99_System" / "Zotero"
|
||||
zstyle_dir.mkdir(parents=True)
|
||||
zstyle_path = zstyle_dir / "zoterostyle.json"
|
||||
zstyle_path.write_text(
|
||||
json.dumps({"Test J": {"rank": {"not_sciif": "x"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_utils.load_journal_db(vault)
|
||||
|
||||
result = _utils.lookup_impact_factor("Test J", "\u5f71\u54cd\u56e0\u5b50: 1.5", vault)
|
||||
assert result == "1.5"
|
||||
130
tests/test_utils_json.py
Normal file
130
tests/test_utils_json.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Unit tests for _utils.py JSON I/O functions.
|
||||
|
||||
Covers: read_json, write_json, read_jsonl, write_jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import read_json, read_jsonl, write_json, write_jsonl
|
||||
|
||||
|
||||
class TestReadJson:
|
||||
"""read_json(path) -> dict|list"""
|
||||
|
||||
def test_reads_valid_json(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "data.json"
|
||||
p.write_text('{"key": "value"}', encoding="utf-8")
|
||||
assert read_json(p) == {"key": "value"}
|
||||
|
||||
def test_reads_list_json(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "list.json"
|
||||
p.write_text('[1, 2, 3]', encoding="utf-8")
|
||||
assert read_json(p) == [1, 2, 3]
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "bad.json"
|
||||
p.write_text("{invalid}", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_json(p)
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.json"
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_json(p)
|
||||
|
||||
def test_empty_file_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.json"
|
||||
p.write_text("", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_json(p)
|
||||
|
||||
|
||||
class TestWriteJson:
|
||||
"""write_json(path, data)"""
|
||||
|
||||
def test_writes_dict(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "out.json"
|
||||
write_json(p, {"a": 1, "b": 2})
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {"a": 1, "b": 2}
|
||||
|
||||
def test_writes_nested_data(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nested.json"
|
||||
data = {"items": [{"id": 1}, {"id": 2}]}
|
||||
write_json(p, data)
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == data
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "a" / "b" / "c" / "deep.json"
|
||||
write_json(p, {"key": "val"})
|
||||
assert p.exists()
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {"key": "val"}
|
||||
|
||||
def test_writes_pretty_indent(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "pretty.json"
|
||||
write_json(p, {"x": 1})
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert "{\n" in text, "should be indented with newlines"
|
||||
assert " " in text, "should use 2-space indent"
|
||||
|
||||
def test_writes_empty_dict(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.json"
|
||||
write_json(p, {})
|
||||
assert json.loads(p.read_text(encoding="utf-8")) == {}
|
||||
|
||||
|
||||
class TestReadJsonl:
|
||||
"""read_jsonl(path) -> list[dict]"""
|
||||
|
||||
def test_reads_valid_jsonl(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "data.jsonl"
|
||||
p.write_text('{"a": 1}\n{"b": 2}\n', encoding="utf-8")
|
||||
assert read_jsonl(p) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_skips_empty_lines(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "sparse.jsonl"
|
||||
p.write_text('{"a": 1}\n\n{"b": 2}\n', encoding="utf-8")
|
||||
assert read_jsonl(p) == [{"a": 1}, {"b": 2}]
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "nope.jsonl"
|
||||
assert read_jsonl(p) == []
|
||||
|
||||
def test_empty_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.jsonl"
|
||||
p.write_text("", encoding="utf-8")
|
||||
assert read_jsonl(p) == []
|
||||
|
||||
def test_malformed_line_raises(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "bad.jsonl"
|
||||
p.write_text('{"ok": 1}\n{broken}\n', encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
read_jsonl(p)
|
||||
|
||||
|
||||
class TestWriteJsonl:
|
||||
"""write_jsonl(path, rows)"""
|
||||
|
||||
def test_writes_rows(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "out.jsonl"
|
||||
write_jsonl(p, [{"a": 1}, {"b": 2}])
|
||||
lines = p.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 2
|
||||
assert json.loads(lines[0]) == {"a": 1}
|
||||
assert json.loads(lines[1]) == {"b": 2}
|
||||
|
||||
def test_handles_empty_list(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "empty.jsonl"
|
||||
write_jsonl(p, [])
|
||||
text = p.read_text(encoding="utf-8")
|
||||
assert text == "" or text.strip() == ""
|
||||
|
||||
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
|
||||
p = tmp_path / "x" / "y" / "out.jsonl"
|
||||
write_jsonl(p, [{"k": "v"}])
|
||||
assert p.exists()
|
||||
assert json.loads(p.read_text(encoding="utf-8").strip().splitlines()[0]) == {"k": "v"}
|
||||
83
tests/test_utils_slugify.py
Normal file
83
tests/test_utils_slugify.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""Unit tests for _utils.py slugify and year extraction functions.
|
||||
|
||||
Covers: slugify_filename, _extract_year
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import _extract_year, slugify_filename
|
||||
|
||||
|
||||
class TestSlugifyFilename:
|
||||
"""slugify_filename(text: str) -> str"""
|
||||
|
||||
def test_normal_text(self) -> None:
|
||||
assert slugify_filename("Hello World") == "Hello World"
|
||||
|
||||
def test_strips_angle_brackets(self) -> None:
|
||||
assert slugify_filename("Hello <World>") == "Hello World"
|
||||
|
||||
def test_strips_colon(self) -> None:
|
||||
assert slugify_filename("Title: Subtitle") == "Title Subtitle"
|
||||
|
||||
def test_strips_double_quote(self) -> None:
|
||||
assert slugify_filename('Title "Sub"') == "Title Sub"
|
||||
|
||||
def test_strips_forward_slash(self) -> None:
|
||||
assert slugify_filename("a/b") == "ab"
|
||||
|
||||
def test_strips_backslash(self) -> None:
|
||||
assert slugify_filename("a\\b") == "ab"
|
||||
|
||||
def test_strips_pipe_and_question_mark(self) -> None:
|
||||
assert slugify_filename("What? | Yes") == "What Yes"
|
||||
|
||||
def test_strips_asterisk(self) -> None:
|
||||
assert slugify_filename("Test * File") == "Test File"
|
||||
|
||||
def test_truncates_at_120_chars(self) -> None:
|
||||
long_str = "a" * 200
|
||||
result = slugify_filename(long_str)
|
||||
assert len(result) == 120
|
||||
|
||||
def test_empty_string_falls_back(self) -> None:
|
||||
assert slugify_filename("") == "untitled"
|
||||
|
||||
def test_only_special_chars_falls_back(self) -> None:
|
||||
assert slugify_filename("<>:\"/\\|?*") == "untitled"
|
||||
|
||||
def test_leading_trailing_spaces(self) -> None:
|
||||
assert slugify_filename(" hello ") == "hello"
|
||||
|
||||
|
||||
class TestExtractYear:
|
||||
"""_extract_year(value: str) -> str"""
|
||||
|
||||
def test_four_digit_year(self) -> None:
|
||||
assert _extract_year("Published in 2024") == "2024"
|
||||
|
||||
def test_19xx_year(self) -> None:
|
||||
assert _extract_year("1999") == "1999"
|
||||
|
||||
def test_20xx_year(self) -> None:
|
||||
assert _extract_year("2001: A Space Odyssey") == "2001"
|
||||
|
||||
def test_no_year_returns_empty(self) -> None:
|
||||
assert _extract_year("No digits here") == ""
|
||||
|
||||
def test_empty_string_returns_empty(self) -> None:
|
||||
assert _extract_year("") == ""
|
||||
|
||||
def test_none_input_returns_empty(self) -> None:
|
||||
assert _extract_year(None) == "" # type: ignore[arg-type]
|
||||
|
||||
def test_year_in_middle_of_string(self) -> None:
|
||||
assert _extract_year("Published 2024 in Journal") == "2024"
|
||||
|
||||
def test_18xx_not_extracted(self) -> None:
|
||||
assert _extract_year("old 1899 ref") == ""
|
||||
# 18xx is NOT extracted because regex anchor is (19|20)\\d{2}
|
||||
119
tests/test_utils_yaml.py
Normal file
119
tests/test_utils_yaml.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"""Unit tests for _utils.py YAML helper functions.
|
||||
|
||||
Covers: yaml_quote, yaml_block, yaml_list
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from paperforge.worker._utils import yaml_block, yaml_list, yaml_quote
|
||||
|
||||
|
||||
class TestYamlQuote:
|
||||
"""yaml_quote(value: str) -> str"""
|
||||
|
||||
def test_plain_string(self) -> None:
|
||||
assert yaml_quote("hello") == '"hello"'
|
||||
|
||||
def test_string_with_quotes(self) -> None:
|
||||
assert yaml_quote('say "hello"') == '"say \\"hello\\""'
|
||||
|
||||
def test_string_with_backslash(self) -> None:
|
||||
assert yaml_quote("a\\b") == '"a\\\\b"'
|
||||
|
||||
def test_boolean_true(self) -> None:
|
||||
assert yaml_quote(True) == "true"
|
||||
|
||||
def test_boolean_false(self) -> None:
|
||||
assert yaml_quote(False) == "false"
|
||||
|
||||
def test_none_becomes_empty_quoted(self) -> None:
|
||||
assert yaml_quote(None) == '""'
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert yaml_quote("") == '""'
|
||||
|
||||
def test_int_converts_to_string(self) -> None:
|
||||
assert yaml_quote(42) == '"42"'
|
||||
|
||||
def test_string_with_only_special_yaml_chars(self) -> None:
|
||||
assert yaml_quote(": ") == '": "'
|
||||
|
||||
|
||||
class TestYamlBlock:
|
||||
"""yaml_block(value: str) -> list[str]"""
|
||||
|
||||
def test_single_line(self) -> None:
|
||||
result = yaml_block("Hello world")
|
||||
assert result[0] == "abstract: |-"
|
||||
assert "Hello world" in result[1]
|
||||
|
||||
def test_multi_line(self) -> None:
|
||||
text = "Line 1\nLine 2\nLine 3"
|
||||
result = yaml_block(text)
|
||||
assert result[0] == "abstract: |-"
|
||||
assert len(result) == 4 # header + 3 lines
|
||||
assert " Line 1" in result[1]
|
||||
assert " Line 2" in result[2]
|
||||
assert " Line 3" in result[3]
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
result = yaml_block("")
|
||||
assert len(result) == 2
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_none_input(self) -> None:
|
||||
result = yaml_block(None) # type: ignore[arg-type]
|
||||
assert len(result) == 2
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_whitespace_only(self) -> None:
|
||||
result = yaml_block(" ")
|
||||
assert len(result) == 2
|
||||
assert "abstract: |-" in result[0]
|
||||
|
||||
def test_trailing_newline_stripped(self) -> None:
|
||||
result = yaml_block("Hello\n")
|
||||
assert len(result) == 2
|
||||
assert " Hello" in result[1]
|
||||
|
||||
def test_lines_are_indented_with_two_spaces(self) -> None:
|
||||
result = yaml_block("Test")
|
||||
assert result[1].startswith(" ")
|
||||
|
||||
|
||||
class TestYamlList:
|
||||
"""yaml_list(key: str, values) -> list[str]"""
|
||||
|
||||
def test_list_of_strings(self) -> None:
|
||||
result = yaml_list("tags", ["a", "b", "c"])
|
||||
assert result[0] == "tags:"
|
||||
assert " - " in result[1] and "a" in result[1]
|
||||
assert " - " in result[2] and "b" in result[2]
|
||||
assert " - " in result[3] and "c" in result[3]
|
||||
|
||||
def test_empty_list(self) -> None:
|
||||
result = yaml_list("tags", [])
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_none_values_filtered(self) -> None:
|
||||
result = yaml_list("tags", ["a", None, "c"])
|
||||
assert len(result) == 3
|
||||
assert "a" in result[1]
|
||||
assert "c" in result[2]
|
||||
|
||||
def test_all_none_returns_empty_list(self) -> None:
|
||||
result = yaml_list("tags", [None, None])
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_none_input(self) -> None:
|
||||
result = yaml_list("tags", None) # type: ignore[arg-type]
|
||||
assert result == ["tags: []"]
|
||||
|
||||
def test_string_with_quotes_in_list(self) -> None:
|
||||
result = yaml_list("items", ['hello "world"'])
|
||||
assert len(result) == 2
|
||||
assert '\\"' in result[1]
|
||||
Loading…
Reference in a new issue