scholarr/DECOMPOSITION_CLEANUP.md
2026-02-27 16:25:51 +01:00

193 lines
12 KiB
Markdown

# Decomposition Cleanup — 3-Pass Fix Prompt
You are working on the scholarr repository (`feat/decomposition` branch). A decomposition refactor split oversized files into focused modules. An audit found residual issues. Fix them in 3 sequential passes.
**Read `agents.md` before starting.** Key rules:
- Function length: 50 lines max
- File length: 400 target, 600 hard ceiling (kwargs-heavy signatures are acceptable overage)
- No dead code, no duplicate boilerplate, no backward-compatibility shims
- All business logic in `app/services/<domain>/`
- Use `structured_log()` for domain logging
**Constraints for every pass:**
- Pure refactoring — no behavioral changes
- All tests must pass after each pass: `docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest`
- Commit after each pass using conventional commit format
---
## Pass 1: Naming, DRY, and Dead Code
**1a. Rename `_int_or_default` → `int_or_default`**
This function in `app/services/ingestion/run_completion.py:38` is imported externally by `app/services/ingestion/application.py:25`, making it public API with a private-convention name.
- Rename the definition in `run_completion.py` (line 38)
- Update all internal call sites in `run_completion.py` (lines 71, 350, 375)
- Update the import in `application.py` (line 25) and call sites (lines 138, 336, 342)
**1b. Consolidate duplicate functions between `pdf_queue.py` and `pdf_queue_resolution.py`**
Three functions exist with identical signatures in both files:
| Function | `pdf_queue.py` | `pdf_queue_resolution.py` |
|---|---|---|
| `_utcnow()` | line 46 | line 33 |
| `_event_row()` | line 150 | line 39 |
| `_queued_job()` | line 165 | line 60 |
Fix: Keep these in `pdf_queue.py` (the parent module). Have `pdf_queue_resolution.py` import them from `pdf_queue.py`. Since `pdf_queue.py` already imports from `pdf_queue_resolution.py`, check for circular imports — if importing `_utcnow`, `_event_row`, `_queued_job` from `pdf_queue` into `pdf_queue_resolution` would create a cycle, extract them to a shared `pdf_queue_common.py` instead.
Also deduplicate the PDF status constants (`PDF_STATUS_QUEUED`, `PDF_STATUS_RUNNING`, `PDF_STATUS_RESOLVED`, `PDF_STATUS_FAILED`) that are duplicated between `pdf_queue.py` (lines 23-27) and `pdf_queue_resolution.py` (lines 20-23). Keep them in `pdf_queue.py` only.
**1c. Consolidate `_effective_request_delay_seconds` duplication**
`scheduler.py` (lines 32-42) and `queue_runner.py` (line 23-28) both define `_effective_request_delay_seconds` with slightly different signatures. The `queue_runner.py` version takes an explicit `floor` parameter (cleaner). Keep the `queue_runner.py` version. In `scheduler.py`, delete `_request_delay_floor_seconds()` and `_effective_request_delay_seconds()`, and instead import from `queue_runner`:
```python
from app.services.ingestion.queue_runner import _effective_request_delay_seconds
```
At the call site in `scheduler.py`, pass `floor=_request_delay_floor_seconds_value` where needed (inline the floor computation or keep it as a one-liner).
Actually — since `_effective_request_delay_seconds` would now be imported externally, rename it to `effective_request_delay_seconds` (drop the underscore) in `queue_runner.py`.
Commit message: `refactor: fix naming, remove duplicate code from decomposition`
---
## Pass 2: Function length — ingestion core (6 worst offenders)
All 6 functions are >75 lines. Extract helpers to bring each under 50 lines. Below is specific guidance for each.
**2a. `EnrichmentRunner.enrich_pending_publications` — 133 lines → ≤50** (`enrichment.py:47-179`)
This function does 4 things: load publications, set up client, batch-loop with API calls, run dedup sweep. Extract:
1. `_load_unenriched_publications(db_session, *, run_id) → list[Publication]` — lines 66-87 (query + return)
2. `_enrich_batch(self, db_session, *, batch, run_id, client, openalex_works, now) → bool` — lines 142-166 (per-publication enrichment loop; return False if canceled). This inner loop iterates `batch`, calls `_discover_identifiers_for_enrichment`, and applies the match. The caller handles the outer batch loop and API call.
3. `_flush_and_sweep_duplicates(db_session, *, run_id)` — lines 167-179 (flush + dedup sweep)
The main function becomes: load pubs → early return → create client → batch loop (API call + `_enrich_batch`) → `_flush_and_sweep_duplicates`.
**2b. `EnrichmentRunner.enrich_publications_with_openalex` — 64 lines → ≤50** (`enrichment.py:260-323`)
Extract:
1. `_sanitize_titles(publications) → list[str]` — lines 279-286 (title cleaning loop)
The outer batch loop + match logic stays in the main method, which should now fit in ~45 lines.
**2c. `ScholarIngestionService.run_for_user` — 99 lines → ≤50** (`application.py:525-623`)
This function calls `initialize_run`, builds paging/threshold kwargs, calls `run_scholar_iteration`, `complete_run_for_user`, handles enrichment, commits, and logs. Extract:
1. `_run_iteration_and_complete(self, db_session, *, run, scholars, user_id, start_cstart_map, paging, thresholds, auto_queue_continuations, queue_delay_seconds, idempotency_key) → tuple[RunProgress, RunFailureSummary, RunAlertSummary]` — lines 579-599 (the iteration + completion block). This calls `run_scholar_iteration` and `complete_run_for_user` and returns their results.
2. `_inline_enrich_and_finalize(self, db_session, *, run, user_settings, intended_final_status) → None` — lines 604-614 (enrichment + status fixup + commit). This calls `enrich_pending_publications`, handles the exception, fixes status, and commits.
**2d. `ScholarIngestionService.execute_run` — 88 lines → ≤50** (`application.py:374-461`)
Extract:
1. `_execute_run_body(self, db_session, session_factory, *, run_id, user_id, scholars, start_cstart_map, paging, thresholds, auto_queue_continuations, queue_delay_seconds, idempotency_key) → None` — lines 411-457 (the try-body: prepare, iterate, complete, commit, log, spawn enrichment task). The outer `execute_run` handles kwargs resolution and the `async with session_factory()` + `except`.
Or alternatively, the `_resolve_paging_kwargs` / `_threshold_kwargs` calls can be lifted into the caller or inlined since they're trivial dict builders — this alone may shave enough lines. Consider whether the kwargs signature itself is the bulk (it is — 22 lines of signature). If so, this function may be acceptable as-is per the kwargs-exception rule. Use your judgment: if the function body (excluding signature and kwargs resolution) is ≤50 lines, document it as acceptable and move on.
**2e. `run_scholar_iteration` — 84 lines → ≤50** (`scholar_processing.py:531-614`)
This has two clear phases: pass 1 (breadth-first, lines 560-584) and pass 2 (depth, lines 586-614). Extract:
1. `_run_first_pass(db_session, *, scholars, pagination, run, user_id, start_cstart_map, scholar_kwargs, request_delay_seconds, queue_delay_seconds, progress) → dict[int, int]` — the breadth-first loop (lines 560-584), returns `first_pass_cstarts`
2. `_run_depth_pass(db_session, *, scholars, first_pass_cstarts, pagination, run, user_id, scholar_kwargs, request_delay_seconds, remaining_max, auto_queue_continuations, queue_delay_seconds, progress) → None` — the depth loop (lines 591-613)
The main function becomes: build kwargs → call pass 1 → compute remaining → call pass 2 → return progress.
**2f. `QueueJobRunner._finalize_queue_job_after_run` — 79 lines → ≤50** (`queue_runner.py:284-362`)
Two branches: success path (lines 287-311) and failure path (lines 312-362). Extract:
1. `_finalize_successful_queue_job(self, session, job, run_summary) → None` — success path
2. `_finalize_failed_queue_job(self, session, job, run_summary) → None` — failure path
The main function opens a session, branches on `failed_count`, and delegates.
Commit message: `refactor: extract helpers from long functions in ingestion core`
---
## Pass 3: Function length — remaining violations (10 functions, 58-75 lines)
**3a. `run_manual` — 105 lines → ≤50** (`app/api/routers/runs.py:183-287`)
This route handler has a large try block. Extract:
1. `_start_manual_run(db_session, *, current_user, ingest_service, idempotency_key) → tuple[CrawlRun, list, dict]` — lines 205-221 (initialize_run call with all the settings kwargs)
2. `_spawn_background_execution(ingest_service, *, run, current_user, scholars, start_cstart_map, user_settings, idempotency_key) → None` — lines 226-249 (create_task + background_tasks management)
3. `_manual_run_success_response(request, *, run, idempotency_key, db_session, current_user) → dict` — lines 251-265 (build success payload)
The route handler becomes: load safety → check disabled → check idempotency → try: start → spawn → respond; except: handle errors.
**3b. `PaginationEngine._paginate_loop` — 75 lines → ≤50** (`pagination.py:276-350`)
Extract:
1. `_upsert_page_publications(self, db_session, *, run, scholar, publications, seen_canonical, state, upsert_publications_fn)` — the dedup + upsert block (appears twice: lines 294-302 and 339-347). Deduplicate by extracting a single helper called from both places.
This should bring the loop body under 50 lines.
**3c. `PaginationEngine.fetch_and_parse_all_pages` — 72 lines → ≤50** (`pagination.py:415-486`)
Most of the length is the kwargs-heavy calls. Check if the function body (excluding the 17-line signature) is ≤50 lines — it should be ~55. Extract:
1. Move the `_short_circuit_initial_page` + `_build_loop_state` + `_paginate_loop` + `_result_from_pagination_state` sequence into a helper `_paginate_from_initial_page(self, *, scholar, run, db_session, state, ...) → PagedParseResult` if needed. Or accept this as kwargs-acceptable overage and document why.
**3d. `PageFetcher.fetch_and_parse_with_retry` — 70 lines → ≤50** (`page_fetch.py:148-217`)
Extract:
1. `_classify_attempt(parsed_page, *, network_attempts, rate_limit_attempts) → tuple[int, int, int]` — lines 173-183 (attempt counter logic, returns updated network_attempts, rate_limit_attempts, total_attempts)
This plus the existing `_should_retry` and `_sleep_backoff` helpers should bring the loop under 50 lines.
**3e. `complete_run_for_user` — 66 lines → ≤50** (`run_completion.py:265-330`)
Extract:
1. `_log_alert_threshold_warnings(*, user_id, run, failure_summary, alert_summary) → None` — lines 284-313 (the 3 if-blocks that log threshold warnings)
The main function becomes: summarize → build alerts → log warnings → apply safety → resolve status → finalize → return. ~35 lines.
**3f. `process_scholar` — 61 lines → ≤50** (`scholar_processing.py:343-403`)
The function body is mostly kwargs pass-through. Check if the actual body (excluding the 18-line signature) is ≤50 lines — it's ~43 lines of body. If so, this is acceptable under the kwargs exception. Document and move on.
**3g. `_fetch_and_prepare_scholar_result` — 59 lines → ≤50** (`scholar_processing.py:406-464`)
Same analysis: 16-line signature, 43-line body. If the body alone is ≤50, this is kwargs-acceptable. Document and move on.
**3h. `_perform_live_author_search` — 60 lines → ≤50** (`author_search.py:459-518`)
18-line signature, 42-line body. Kwargs-acceptable. Document and move on.
**3i. `search_author_candidates` — 60 lines → ≤50** (`author_search.py:521-580`)
19-line signature, 41-line body. Kwargs-acceptable. Document and move on.
**3j. `ScholarIngestionService.initialize_run` — 58 lines → ≤50** (`application.py:315-372`)
20-line signature, 38-line body. Kwargs-acceptable. Document and move on.
Commit message: `refactor: extract helpers from remaining long functions`
---
## Verification (after all 3 passes)
```bash
# No function exceeds 50 lines (body, excluding kwargs signatures that exceed the limit)
# Run the AST checker from the audit to verify
# All tests pass
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app python -m pytest
# Linting
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff check .
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app ruff format --check .
# Type checking
docker compose -f docker-compose.yml -f docker-compose.dev.yml run --rm app mypy app/
```