Split app/services/ingestion/application.py (2,955 lines) into three focused modules: - page_fetch.py (219 lines): PageFetcher class for single-page fetch, parse-or-layout-error handling, and retry with backoff - pagination.py (486 lines): PaginationEngine class for multi-page orchestration, loop state management, and short-circuit detection - publication_upsert.py (239 lines): module-level functions for resolve_publication, upsert_profile_publications, and all find/create/update helpers Also fixes two external import paths in portability/ that incorrectly sourced normalize_title and build_publication_url from application.py instead of fingerprints.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
Scholarr Decomposition Slices
How to use this file
This file contains self-contained decomposition prompts ("slices") to bring the codebase into compliance with agents.md file size rules. Each slice is an independent task for an LLM executor.
Rules from agents.md:
- File length: 400 lines target, 600 lines hard ceiling
- Function length: 50 lines max
- All business logic in
app/services/<domain>/ - Read
agents.mdfully before starting any slice
Execution protocol:
- Pick the next incomplete slice in order (slices depend on prior ones)
- Read the prompt section — it contains everything you need
- Read all source files mentioned before making changes
- Make the changes described — no business logic changes, only file reorganization
- Verify: all imports resolve, all tests pass
- Commit with the specified message
Progress
| # | Slice | Status |
|---|---|---|
| 1 | Schema split | DONE |
| 2 | Ingestion: pagination + publication upsert | DONE |
| 3 | Ingestion: enrichment + scholar processing + run completion | pending |
| 4 | Scholars service + PDF queue | pending |
| 5 | Routers + scheduler | pending |
Slice 1: Split app/api/schemas.py into domain packages — DONE
Completed. The 945-line app/api/schemas.py was split into app/api/schemas/ package:
| File | Lines | Contents |
|---|---|---|
common.py |
39 | ApiMeta, ApiErrorData, ApiErrorEnvelope, MessageData, MessageEnvelope |
auth.py |
73 | SessionUserData, AuthMe*, CsrfBootstrap*, Login*, ChangePasswordRequest |
scholars.py |
153 | ScholarItem*, ScholarSearch*, ScholarExport*, DataExport*, DataImport* |
publications.py |
151 | DisplayIdentifierData, PublicationItem*, MarkAll*, MarkSelected*, RetryPublication*, TogglePublication* |
runs.py |
226 | RunListItem*, RunSummary*, RunDebug*, RunScholarResult*, RunDetail*, ManualRun*, Queue*, ScrapeSafety* |
admin.py |
289 | All Admin* models including PDF queue, repair, near-duplicate schemas |
settings.py |
54 | SettingsPolicy*, Settings*, SettingsUpdateRequest |
__init__.py |
7 | Wildcard re-exports — all from app.api.schemas import X imports unchanged |
Slice 2: Decompose app/services/ingestion/application.py — pagination + publication upsert — DONE
Completed. Extracted three modules from application.py (2,955 → 2,084 lines):
| File | Lines | Contents |
|---|---|---|
app/services/ingestion/page_fetch.py |
219 | PageFetcher class — single-page fetch, parse-or-layout-error, retry with backoff |
app/services/ingestion/pagination.py |
486 | PaginationEngine class — multi-page orchestration, loop state, short-circuit, fingerprint checks |
app/services/ingestion/publication_upsert.py |
239 | Module-level functions — resolve_publication, upsert_profile_publications, find/create/update helpers |
Also fixed two external import paths (portability/normalize.py, portability/publication_import.py) that imported normalize_title/build_publication_url from application.py — now correctly sourced from fingerprints.py. Updated one integration test to monkeypatch the new module-level function instead of the old instance method.
Slice 3: Decompose app/services/ingestion/application.py — enrichment, scholar processing, run completion
Why: Three remaining logical chunks. Extracting all three brings application.py down to ~500 lines (the orchestration spine).
Files to create: app/services/ingestion/enrichment.py, app/services/ingestion/scholar_processing.py, app/services/ingestion/run_completion.py
Files to modify: app/services/ingestion/application.py
Prompt:
You are working on the scholarr repository. Read agents.md for project conventions. Continue decomposing app/services/ingestion/application.py (slice 2 is complete — page_fetch.py, pagination.py, and publication_upsert.py already extracted). Extract three remaining chunks.
A. app/services/ingestion/enrichment.py (~300 lines)
Move these methods (post-run OpenAlex enrichment):
_run_is_canceled,_enrich_pending_publications,_discover_identifiers_for_enrichment_publish_identifier_update_event,_enrich_publications_with_openalex
Create an EnrichmentRunner class that receives service dependencies (OpenAlex client, identifier service, dedup service, DB session factory) in its constructor.
B. app/services/ingestion/scholar_processing.py (~400 lines)
Move these methods (per-scholar outcome resolution):
_assert_valid_paged_parse_result,_apply_first_page_profile_metadata,_build_result_entry_skipped_no_change_outcome,_upsert_publications_outcome,_upsert_success_or_exception_upsert_success,_upsert_exception_outcome,_parse_failure_outcome_sync_continuation_queue,_process_scholar,_process_scholar_inner_fetch_and_prepare_scholar_result,_resolve_scholar_outcome,_unexpected_scholar_exception_outcome
These become methods on a ScholarProcessor class or module-level functions.
C. app/services/ingestion/run_completion.py (~300 lines)
Move these methods (run finalization + alerting):
_classify_failure_bucket(standalone function)_summarize_failures,_build_alert_summary,_apply_safety_outcome,_finalize_run_record_resolve_run_status,_resolve_continuation_queue_target,_build_failure_debug_context_complete_run_for_user
These become module-level functions accepting explicit parameters.
After all extractions, application.py should contain only:
ScholarIngestionService.__init__and config helpers- Run lifecycle:
initialize_run,execute_run,run_for_user - Scholar iteration orchestration:
_run_scholar_iteration - Progress tracking:
_result_counters,_adjust_progress_counts - Safety gate integration, background task management
Target: application.py ≤ 500 lines. No new file exceeds 400 lines. All tests must pass unchanged.
Commit message: refactor: extract enrichment, scholar processing, and run completion from ingestion service
Slice 4: Decompose app/services/scholars/application.py (996 lines) and app/services/publications/pdf_queue.py (969 lines)
Why: Two service files nearly 2x the hard ceiling. Each has clear internal seams.
Files to create: app/services/scholars/author_search.py, app/services/scholars/author_search_cache.py, app/services/publications/pdf_queue_queries.py, app/services/publications/pdf_queue_resolution.py
Files to modify: app/services/scholars/application.py, app/services/publications/pdf_queue.py
Prompt:
You are working on the scholarr repository. Read agents.md for project conventions. Two service files exceed the 600-line ceiling. Decompose both.
A. app/services/scholars/application.py (996 lines)
This file mixes scholar CRUD (~350 lines) with the entire author search pipeline (~650 lines). Split them:
Create app/services/scholars/author_search_cache.py (~150 lines): Move all _serialize_* and _deserialize_* functions. Move _cache_get_author_search_result, _cache_set_author_search_result, _prune_author_search_cache. These are pure functions operating on the DB cache table.
Create app/services/scholars/author_search.py (~500 lines): Move remaining author search functions — cooldown management, throttle logic, retry wrappers, circuit breaker, lock acquisition, and search_author_candidates. Import cache functions from author_search_cache.py. Accept DB session and settings as explicit parameters.
Update application.py: Keep only scholar CRUD methods. Import and delegate to search_author_candidates from author_search.py. Target: ≤ 350 lines.
B. app/services/publications/pdf_queue.py (969 lines)
Three concerns in one file. Split by concern:
Create app/services/publications/pdf_queue_queries.py (~330 lines): Move SQL query builders (_tracked_queue_select_base, _tracked_queue_select, etc.), result hydration (_queue_item_from_row, _hydrated_queue_items), listing/counting/pagination (list_pdf_queue_items, count_pdf_queue_items, list_pdf_queue_page), retry item builders and missing-PDF candidate queries.
Create app/services/publications/pdf_queue_resolution.py (~300 lines): Move task execution (_mark_attempt_started, _failed_outcome, _fetch_outcome_for_row), outcome persistence (_apply_publication_update, _apply_job_outcome, _persist_outcome), scheduling (_resolve_publication_row, _run_resolution_task, _register_task, _drop_finished_task, _schedule_rows).
Keep in pdf_queue.py (~340 lines): dataclasses, constants, enqueueing logic, public API.
No file should exceed 500 lines. All tests must pass unchanged.
Commit message: refactor: decompose scholars service and pdf_queue into focused modules
Slice 5: Decompose router files and scheduler
Why: Three files slightly over the 600-line ceiling. Each needs helper extraction.
Files to create: app/api/routers/run_serializers.py, app/api/routers/run_manual.py, app/api/routers/scholar_helpers.py, app/services/ingestion/queue_runner.py
Files to modify: app/api/routers/runs.py (875 → ~405), app/api/routers/scholars.py (616 → ~396), app/services/ingestion/scheduler.py (622 → ~280)
Prompt:
You are working on the scholarr repository. Read agents.md for project conventions. Three files are slightly over the 600-line ceiling. Extract helpers from each.
A. app/api/routers/runs.py (875 lines) → 3 files
Create app/api/routers/run_serializers.py (~250 lines): Move all _serialize_*, _normalize_*, _int_value, _str_value, _bool_value helper functions. Move _manual_run_payload_from_run and _manual_run_success_payload.
Create app/api/routers/run_manual.py (~220 lines): Move _load_safety_state, _raise_manual_runs_disabled, _reused_manual_run_payload, _run_ingestion_for_manual, _recover_integrity_error, _execute_manual_run, _raise_manual_blocked_safety, _raise_manual_failed.
Keep in runs.py (~405 lines): only route handler functions + imports.
B. app/api/routers/scholars.py (616 lines) → 2 files
Create app/api/routers/scholar_helpers.py (~220 lines): Move _needs_metadata_hydration, _is_create_hydration_rate_limited, _auto_enqueue_new_scholar_enabled, _enqueue_initial_scrape_job_for_scholar, _uploaded_image_media_path, _serialize_scholar, _hydrate_scholar_metadata_if_needed, _search_kwargs, _search_response_data, _read_uploaded_image, _require_user_profile.
Keep in scholars.py (~396 lines): only route handlers.
C. app/services/ingestion/scheduler.py (622 lines) → 2 files
Create app/services/ingestion/queue_runner.py (~350 lines): Move all continuation-queue job processing — _drain_continuation_queue, _drop_queue_job_if_max_attempts, _mark_queue_job_retrying, _queue_job_has_available_scholar, all _reschedule_* methods, _run_ingestion_for_queue_job, _finalize_queue_job_after_run, _run_queue_job. Create a QueueJobRunner class receiving config params in constructor.
Keep in scheduler.py (~280 lines): auto-run scheduling, _run_loop, _tick_once, candidate loading.
All tests must pass unchanged after each extraction.
Commit message: refactor: extract helpers from oversized router and scheduler files
Verification (after all slices)
# No Python file exceeds 600 lines
find app/ -name "*.py" -exec wc -l {} + | awk '$1 > 600 && !/total/ {print "FAIL:", $0}'
# 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 .