---
phase: 02-paddleocr-and-pdf-path-hardening
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
  - paperforge/ocr_diagnostics.py
  - paperforge/cli.py
  - tests/test_ocr_doctor.py
  - tests/test_cli_worker_dispatch.py
  - command/lp-ocr.md
autonomous: true
requirements:
  - OCR-01

must_haves:
  truths:
    - "User can run `paperforge ocr doctor` and see tiered diagnostic output"
    - "Doctor L1 detects missing PADDLEOCR_API_TOKEN"
    - "Doctor L2 detects unreachable or misconfigured PADDLEOCR_JOB_URL"
    - "Doctor L3 detects API schema mismatch"
    - "Doctor L4 (optional --live) validates full PDF round-trip"
    - "Doctor returns non-zero exit code when any check fails"
  artifacts:
    - path: "paperforge/ocr_diagnostics.py"
      provides: "ocr_doctor() function with L1-L4 checks"
      exports: ["ocr_doctor"]
    - path: "paperforge/cli.py"
      provides: "`ocr doctor` subcommand dispatch"
      contains: "doctor", "ocr_action", "_cmd_ocr_doctor"
    - path: "tests/test_ocr_doctor.py"
      provides: "Unit tests for L1-L4 with mocked requests"
    - path: "command/lp-ocr.md"
      provides: "User-facing documentation for ocr doctor"
      contains: "paperforge ocr doctor"
  key_links:
    - from: "paperforge/cli.py"
      to: "paperforge/ocr_diagnostics.py"
      via: "_cmd_ocr_doctor calls ocr_doctor"
      pattern: "ocr_doctor\("
    - from: "tests/test_cli_worker_dispatch.py"
      to: "paperforge/cli.py"
      via: "patch and verify doctor dispatch"
      pattern: "doctor"
---

<objective>
Implement the `paperforge ocr doctor` diagnostic command with tiered L1-L4 checks.

Purpose: Give users a single command to validate their OCR configuration before queueing jobs, eliminating the opaque "PaddleOCR request failed" debugging experience.
Output: ocr_doctor() function, CLI dispatch, tests, documentation.
</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/REQUIREMENTS.md
@.planning/phases/02-paddleocr-and-pdf-path-hardening/02-CONTEXT.md

<interfaces>
From paperforge/cli.py (current ocr dispatch):
```python
p_ocr = sub.add_parser("ocr", help="Run OCR on PDFs requiring processing")
p_ocr.add_argument(
    "action",
    nargs="?",
    default="run",
    choices=["run"],
    help="OCR action (default: run)",
)
...
dispatch_map = {
    ...
    "ocr": run_ocr,
}
```

From CONTEXT.md D-01 (OCR Doctor decisions):
| Level | Check | Action on Failure |
|-------|-------|-------------------|
| **L1** | Token presence | Check `PADDLEOCR_API_KEY` in env/.env |
| **L2** | URL reachability | `GET` base URL, check HTTP status |
| **L3** | API response structure | Submit minimal request, validate response schema |
| **L4** | Live PDF test (optional) | Upload a tiny fixture PDF, verify round-trip |

Default PADDLEOCR_JOB_URL: `https://paddleocr.aistudio-app.com/api/v2/ocr/jobs`
Auth header format: `{'Authorization': f'bearer {token}'}` (lowercase 'bearer' per existing code)
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement ocr_doctor() with L1-L4 diagnostics</name>
  <files>paperforge/ocr_diagnostics.py, tests/test_ocr_doctor.py</files>
  <read_first>
    - paperforge/config.py (for env/config patterns)
    - pipeline/worker/scripts/literature_pipeline.py lines 2314-2325 (token and URL loading)
    - .planning/phases/02-paddleocr-and-pdf-path-hardening/02-CONTEXT.md section 3.1
  </read_first>
  <behavior>
    - L1 missing token → level 1 failed, fix suggestion includes env var name
    - L2 bad URL (404) → level 2 failed, fix suggestion includes checking URL
    - L3 bad response schema → level 3 failed, fix suggests provider docs
    - L3 good response → level 3 passed
    - L4 with live=True and mocked success → level 4 passed
    - L4 with live=True and failure → level 4 failed
    - All-pass without live → report stops at L3
  </behavior>
  <action>
    Create or extend `paperforge/ocr_diagnostics.py` with `ocr_doctor(config, live=False)` function.

    The function signature: `def ocr_doctor(config: dict[str, str] | None, live: bool = False) -> dict:`

    Implementation (per D-01):

    **L1 — Token presence:**
    ```python
    token = os.environ.get('PADDLEOCR_API_TOKEN', '').strip()
    if not token:
        token = os.environ.get('PADDLEOCR_API_TOKEN_USER', '').strip()
    if not token:
        return {
            'level': 1,
            'passed': False,
            'error': 'PADDLEOCR_API_TOKEN not found in environment',
            'fix': 'Set PADDLEOCR_API_TOKEN in .env or environment variables and re-run `paperforge ocr doctor`',
        }
    ```

    **L2 — URL reachability:**
    ```python
    job_url = os.environ.get('PADDLEOCR_JOB_URL', 'https://paddleocr.aistudio-app.com/api/v2/ocr/jobs').strip()
    try:
        resp = requests.get(job_url, headers={'Authorization': f'bearer {token}'}, timeout=30)
        if resp.status_code == 401:
            return {
                'level': 2,
                'passed': False,
                'error': f'URL returned 401 Unauthorized',
                'fix': 'PaddleOCR API token is invalid. Check PADDLEOCR_API_TOKEN and re-run `paperforge ocr doctor`',
            }
        if resp.status_code >= 500:
            return {
                'level': 2,
                'passed': False,
                'error': f'URL returned {resp.status_code}',
                'fix': 'OCR provider is experiencing issues. Retry later with `paperforge ocr doctor`',
            }
        if resp.status_code != 200:
            return {
                'level': 2,
                'passed': False,
                'error': f'URL returned {resp.status_code}',
                'fix': 'Check PADDLEOCR_JOB_URL in .env and re-run `paperforge ocr doctor`',
            }
    except requests.RequestException as e:
        return {
            'level': 2,
            'passed': False,
            'error': f'Failed to reach OCR URL: {e}',
            'fix': 'Check network connection and PADDLEOCR_JOB_URL in .env',
        }
    ```

    **L3 — API response structure:**
    ```python
    try:
        # Minimal payload matching existing worker submission
        minimal_payload = {
            'model': os.environ.get('PADDLEOCR_MODEL', 'PaddleOCR-VL-1.5'),
            'optionalPayload': json.dumps({'useDocOrientationClassify': False}),
        }
        resp = requests.post(job_url, headers={'Authorization': f'bearer {token}'}, data=minimal_payload, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        if 'data' not in data or 'jobId' not in data.get('data', {}):
            return {
                'level': 3,
                'passed': False,
                'error': 'API response missing expected fields (data.jobId)',
                'fix': 'PaddleOCR API schema may have changed. Check provider documentation.',
                'raw_response': resp.text[:500],
            }
        job_id = data['data']['jobId']
        # Cancel the test job immediately to avoid wasting resources
        try:
            requests.delete(f"{job_url}/{job_id}", headers={'Authorization': f'bearer {token}'}, timeout=10)
        except Exception:
            pass  # ignore cancel failure
    except requests.RequestException as e:
        return {
            'level': 3,
            'passed': False,
            'error': f'API submission test failed: {e}',
            'fix': 'Check PADDLEOCR configuration and network. Run `paperforge ocr doctor` again after fixes.',
        }
    except (json.JSONDecodeError, KeyError) as e:
        return {
            'level': 3,
            'passed': False,
            'error': f'API response parsing failed: {e}',
            'fix': 'PaddleOCR API schema may have changed. Check provider documentation.',
        }
    ```

    **L4 — Live PDF test (only if live=True):**
    ```python
    if live:
        fixture_pdf = Path(__file__).parent.parent / 'tests' / 'fixtures' / 'blank.pdf'
        if not fixture_pdf.exists():
            return {
                'level': 4,
                'passed': False,
                'error': 'Live test fixture PDF not found',
                'fix': 'Install test fixtures or run without --live flag.',
            }
        try:
            with open(fixture_pdf, 'rb') as f:
                resp = requests.post(
                    job_url,
                    headers={'Authorization': f'bearer {token}'},
                    data={'model': os.environ.get('PADDLEOCR_MODEL', 'PaddleOCR-VL-1.5')},
                    files={'file': f},
                    timeout=120,
                )
            resp.raise_for_status()
            data = resp.json()
            job_id = data['data']['jobId']
            # Poll for result (max 10 attempts, 5s delay)
            for _ in range(10):
                import time
                time.sleep(5)
                poll = requests.get(f"{job_url}/{job_id}", headers={'Authorization': f'bearer {token}'}, timeout=30)
                poll_data = poll.json()['data']
                if poll_data['state'] == 'done':
                    break
                if poll_data['state'] == 'error':
                    return {
                        'level': 4,
                        'passed': False,
                        'error': f'Live PDF test failed: {poll_data.get("errorMsg", "unknown")}',
                        'fix': 'PDF may be unreadable or OCR service error. Try a different PDF or check service status.',
                    }
            else:
                return {
                    'level': 4,
                    'passed': False,
                    'error': 'Live PDF test timed out',
                    'fix': 'OCR service is slow or overloaded. Retry later without --live flag.',
                }
        except Exception as e:
            return {
                'level': 4,
                'passed': False,
                'error': f'Live PDF test exception: {e}',
                'fix': 'Check network and PDF format. Run without --live for basic diagnostics.',
            }
    ```

    **All-pass return:**
    ```python
    return {
        'level': 4 if live else 3,
        'passed': True,
        'message': 'All diagnostics passed. OCR is ready.',
    }
    ```

    Add `import os`, `import json`, `import requests` at top of file if not already present.

    Create `tests/test_ocr_doctor.py` with mocked requests:
    - `test_l1_missing_token`: Empty env → level 1 failed
    - `test_l2_bad_url`: Mock GET returning 404 → level 2 failed
    - `test_l2_unauthorized`: Mock GET returning 401 → level 2 failed
    - `test_l3_schema_mismatch`: Mock POST returning JSON without data.jobId → level 3 failed
    - `test_l3_success`: Mock POST returning `{"data": {"jobId": "123"}}` → level 3 passed
    - `test_l4_live_success`: Mock POST + GET poll returning done → level 4 passed
    - `test_l4_live_failure`: Mock POST + GET poll returning error → level 4 failed

    Use `unittest.mock.patch.dict(os.environ, {...}, clear=True)` to control env vars.
    Use `unittest.mock.patch('requests.get')` and `patch('requests.post')` for HTTP mocking.
    Use `unittest.mock.patch('requests.delete')` for cleanup mocking.
  </action>
  <verify>
    <automated>pytest tests/test_ocr_doctor.py -v</automated>
  </verify>
  <done>ocr_doctor() exists with L1-L4 checks, all 7 unit tests pass, function returns structured dict with level/passed/error/fix keys</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add CLI dispatch, tests, and documentation</name>
  <files>paperforge/cli.py, tests/test_cli_worker_dispatch.py, command/lp-ocr.md</files>
  <read_first>
    - paperforge/cli.py (full file)
    - tests/test_cli_worker_dispatch.py
    - command/lp-ocr.md
    - paperforge/ocr_diagnostics.py (ocr_doctor from Task 1)
  </read_first>
  <behavior>
    - `paperforge ocr doctor` runs diagnostics and prints formatted report
    - `paperforge ocr doctor --live` includes L4 live test
    - Exit code 0 on pass, 1 on failure
    - CLI test verifies dispatch to ocr_doctor
    - Documentation shows both commands and explains output
  </behavior>
  <action>
    **Modify paperforge/cli.py:**

    1. Change the ocr subparser to support sub-subcommands:
       Replace lines 72-80:
       ```python
       p_ocr = sub.add_parser("ocr", help="Run OCR on PDFs requiring processing")
       p_ocr.add_argument(
           "action",
           nargs="?",
           default="run",
           choices=["run"],
           help="OCR action (default: run)",
       )
       ```
       With:
       ```python
       p_ocr = sub.add_parser("ocr", help="OCR operations")
       ocr_sub = p_ocr.add_subparsers(dest="ocr_action", required=True)
       ocr_sub.add_parser("run", help="Run OCR queue")
       doctor_parser = ocr_sub.add_parser("doctor", help="Diagnose OCR configuration and connectivity")
       doctor_parser.add_argument("--live", action="store_true", help="Run live PDF test (L4)")
       ```

    2. Update the dispatch logic (around lines 117-130):
       Replace:
       ```python
       dispatch_map = {
           "status": run_status,
           "selection-sync": run_selection_sync,
           "index-refresh": run_index_refresh,
           "deep-reading": run_deep_reading,
           "ocr": run_ocr,
       }
       worker_fn = dispatch_map.get(args.command)
       if worker_fn is None:
           print(f"Error: unknown command {args.command}", file=sys.stderr)
           return 1
       return worker_fn(vault)
       ```
       With:
       ```python
       if args.command == "ocr":
           if args.ocr_action == "run":
               return run_ocr(vault)
           elif args.ocr_action == "doctor":
               return _cmd_ocr_doctor(vault, args)
           else:
               print(f"Error: unknown ocr action {args.ocr_action}", file=sys.stderr)
               return 1

       dispatch_map = {
           "status": run_status,
           "selection-sync": run_selection_sync,
           "index-refresh": run_index_refresh,
           "deep-reading": run_deep_reading,
       }
       worker_fn = dispatch_map.get(args.command)
       if worker_fn is None:
           print(f"Error: unknown command {args.command}", file=sys.stderr)
           return 1
       return worker_fn(vault)
       ```

    3. Add `_cmd_ocr_doctor` function before `main()`:
       ```python
       def _cmd_ocr_doctor(vault: Path, args: argparse.Namespace) -> int:
           """Handle `paperforge ocr doctor` and `paperforge ocr doctor --live`."""
           from paperforge.ocr_diagnostics import ocr_doctor

           result = ocr_doctor(config=None, live=args.live)
           level = result.get('level', 0)
           passed = result.get('passed', False)

           print(f"OCR Doctor — Level {level} diagnostic")
           print("-" * 40)
           if passed:
               print(f"[PASS] {result.get('message', 'All checks passed')}")
               return 0
           else:
               print(f"[FAIL] Level {level}: {result.get('error', 'Unknown failure')}")
               print(f"[FIX]  {result.get('fix', 'No fix suggestion available')}")
               if result.get('raw_response'):
                   print(f"[RAW]  {result['raw_response'][:200]}...")
               return 1
       ```

    **Update tests/test_cli_worker_dispatch.py:**
    Add a stub and test for doctor dispatch:
    ```python
    def stub_ocr_doctor(config, live=False):
        CAPTURED_CALLS.append(("ocr_doctor", config, live))
        return {'level': 3, 'passed': True, 'message': 'OK'}
    ```
    And:
    ```python
    def test_ocr_doctor_dispatch(clean_captured, mock_vault):
        import importlib
        import paperforge.cli as cli
        importlib.reload(cli)

        with patch.object(cli, "_cmd_ocr_doctor", lambda vault, args: 0):
            argv = ["--vault", str(mock_vault), "ocr", "doctor"]
            code = cli.main(argv)

        assert code == 0
    ```

    **Update command/lp-ocr.md:**
    Append to the file:
    ```markdown
    ## OCR Doctor

    Validate OCR configuration before queueing jobs:

    ```bash
    paperforge ocr doctor
    ```

    Run full diagnostics including live PDF test:

    ```bash
    paperforge ocr doctor --live
    ```

    ### Diagnostic Levels

    | Level | Check | Failure Meaning |
    |-------|-------|-----------------|
    | L1 | API token presence | `PADDLEOCR_API_TOKEN` missing or empty |
    | L2 | URL reachability | Cannot connect to PaddleOCR service |
    | L3 | API schema validation | Service reachable but response format unexpected |
    | L4 | Live PDF round-trip | Full submission and result retrieval fails |

    Exit code `0` = all checks passed. Exit code `1` = at least one check failed.
    ```

    Do NOT remove existing `/lp-ocr` documentation — append after it.
  </action>
  <verify>
    <automated>
      grep -n "doctor" paperforge/cli.py
      grep -n "ocr_action" paperforge/cli.py
      grep -n "_cmd_ocr_doctor" paperforge/cli.py
      grep -n "paperforge ocr doctor" command/lp-ocr.md
      pytest tests/test_cli_worker_dispatch.py -v -k doctor
    </automated>
  </verify>
  <done>CLI has `ocr doctor` subcommand with --live flag, prints formatted report, returns exit code 0/1, test covers dispatch, documentation updated</done>
</task>

</tasks>

<verification>
- [ ] `pytest tests/test_ocr_doctor.py -v` passes
- [ ] `pytest tests/test_cli_worker_dispatch.py -v -k doctor` passes
- [ ] `grep -n "doctor" paperforge/cli.py` returns at least 4 matches
- [ ] `grep -n "ocr_action" paperforge/cli.py` returns at least 2 matches
- [ ] `grep -n "_cmd_ocr_doctor" paperforge/cli.py` returns at least 2 matches
- [ ] `command/lp-ocr.md` contains `paperforge ocr doctor`
- [ ] Requirement OCR-01 addressed
</verification>

<success_criteria>
- `paperforge ocr doctor` runs L1-L3 diagnostics and prints tiered report
- `paperforge ocr doctor --live` includes L4 live PDF test
- Exit code 0 on all-pass, 1 on any failure
- Tests cover L1 missing token, L2 bad URL, L2 unauthorized, L3 schema mismatch, L3 success, L4 success, L4 failure
- CLI dispatch test verifies doctor command routing
- User documentation explains levels and exit codes
</success_criteria>

<output>
After completion, create `.planning/phases/02-paddleocr-and-pdf-path-hardening/02-03-SUMMARY.md`
</output>
