116 lines
7.7 KiB
Markdown
116 lines
7.7 KiB
Markdown
# Phase 1 Handoff — Extraction Layer Refactor
|
|
|
|
> Scope: implement the structured-extraction port from `document-extraction-spec.md`,
|
|
> preserving the hexagonal architecture and keeping the existing pipeline intact.
|
|
> Completed: 2026-08-16.
|
|
|
|
## Goal
|
|
|
|
Replace flat-text extraction (`core.analysis.extractor.extract_text`) with a
|
|
format-aware extraction **port** that returns Markdown with preserved structure
|
|
(headings from DOCX, tables from PDF). The rest of the pipeline (chunker,
|
|
worker-analyze, RabbitMQ contracts) remains unchanged except for Markdown-aware
|
|
chunking and additive metadata in `DocumentExtracted`.
|
|
|
|
## New / modified files
|
|
|
|
### New: `core/extraction/` hexagonal package
|
|
|
|
| File | Purpose |
|
|
|---|---|
|
|
| `src/contract_check/core/extraction/port.py` | `DocumentExtractor` Protocol, `ExtractedDocument` DTO, `UnsupportedFormatError`, `ExtractionFailedError`, `MIN_TEXT_CHARS` |
|
|
| `src/contract_check/core/extraction/factory.py` | `ExtractorFactory`, `detect_format()`, `SUPPORTED_SUFFIXES`; routes bytes/mime/suffix → adapter. Uses `python-magic` for content sniffing, suffix as fallback. |
|
|
| `src/contract_check/core/extraction/adapters/pdf_pymupdf.py` | `PyMuPDFExtractor` — text + `page.find_tables()` → Markdown pipes. Interleaves tables and text blocks by bbox so cell text is not duplicated. `is_structured=True` iff tables found. |
|
|
| `src/contract_check/core/extraction/adapters/docx_mammoth.py` | `MammothDocxExtractor` — `mammoth.convert_to_markdown()`. Headings/lists/tables become Markdown; `is_structured=True` on headings/tables/lists. |
|
|
| `src/contract_check/core/extraction/adapters/rtf_striprtf.py` | `RtfExtractor` — `striprtf` to plaintext. `is_structured=False`. |
|
|
| `src/contract_check/core/extraction/adapters/txt_chardet.py` | `TxtExtractor` — `chardet` encoding detection, decode, metadata includes `encoding`. |
|
|
| `src/contract_check/core/extraction/adapters/ocr_tesseract.py` | `TesseractOcrExtractor` — OCR for scanned PDFs and raster images (PNG/JPG/TIFF), reusing the same pytesseract/pymupdf logic as `core.analysis.ocr`. |
|
|
| `src/contract_check/core/extraction/__init__.py` | `extract_document()` orchestrator: adapter → extract → OCR fallback for short PDFs. |
|
|
| `src/contract_check/core/extraction/adapters/__init__.py` | Re-exports of all adapter classes. |
|
|
|
|
### Modified worker / pipeline
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `src/contract_check/worker_extract/handler.py` | Bytes-based extraction via `core.extraction.extract_document()`. No temp files. Uploaded text now `text/markdown; charset=utf-8`. `DocumentExtracted` includes `is_structured`/`has_tables`. Duration histogram labeled by `format`. Classification updated for new errors. |
|
|
| `src/contract_check/worker_extract/consumer.py` | Removed `@extract_duration.time()` decorator — labeled timing moved into the handler where the format is known. |
|
|
| `src/contract_check/core/mq/messages.py` | `DocumentExtracted` added `is_structured: bool = False` and `has_tables: bool = False` (backward-compatible defaults). |
|
|
| `src/contract_check/core/metrics.py` | `extract_duration` now has `format` label. Added `extraction_total{format,structured}` counter. |
|
|
| `src/contract_check/core/analysis/chunker.py` | Added `chunk_markdown()` — splits on ATX headings, keeps headings attached to their body, falls back to `chunk_text()` for unstructured text. |
|
|
| `src/contract_check/core/llm/ollama_cloud.py` | Analyzer now uses `chunk_markdown(text)` instead of `chunk_text(text)` so heading boundaries are preserved when the extractor produced structured Markdown. |
|
|
| `src/contract_check/api/services.py` | Upload gate now imports `SUPPORTED_SUFFIXES` from `core.extraction`. `_content_type_from_suffix()` extended for RTF/TXT/CSV/images. |
|
|
| `src/contract_check/bot/handlers.py` | Bot extension gate now uses the same `SUPPORTED_SUFFIXES` set and content-type map. |
|
|
| `src/contract_check/worker_extract/extract_document.py` | **Deleted**. Superseded by `core.extraction.extract_document()`. |
|
|
|
|
### Dependencies / Docker
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `pyproject.toml` | `extract` group adds `mammoth`, `striprtf`, `chardet`, `python-magic`. Mypy ignore list extended for these libs. |
|
|
| `srv/worker-extract/Dockerfile` | Adds `libmagic1` apt package (required by `python-magic`). |
|
|
| `uv.lock` | Regenerated. |
|
|
|
|
### Documentation
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `docs/ARCHITECTURE.md` | Layout updated (`core/extraction/` added, `worker_extract/extract_document.py` removed, new tests listed). Pipeline notes that extracted Markdown is uploaded. `DocumentExtracted` schema updated. Metrics table updated. pyproject `extract` group updated. worker-extract Dockerfile apt row updated. |
|
|
| `src/contract_check/api/routes/README.md` | Upload endpoint extension list updated. |
|
|
|
|
### Tests
|
|
|
|
| File | Change |
|
|
|---|---|
|
|
| `tests/unit/test_extraction_adapters.py` | New: PDF text-only/table, DOCX heading, RTF, TXT chardet, OCR error paths, DTO defaults. |
|
|
| `tests/unit/test_extraction_factory.py` | New: suffix/mime/magic routing, unsupported formats, end-to-end `extract_document()`, OCR fallback behavior. |
|
|
| `tests/unit/test_chunker.py` | New: `chunk_markdown` heading preservation, packing, oversized-section split, preamble handling, fallback. |
|
|
| `tests/integration/test_extract_worker.py` | Expected exceptions updated: `ExtractionFailedError` is now a possible raised error alongside `OCRError` (the failure-class mapping remains `extraction_failed` / `ocr_failed`). |
|
|
|
|
## Behavior changes
|
|
|
|
- **Format support** (upload-gate accepted): `.pdf`, `.docx`, `.rtf`, `.txt`, `.csv`, `.png`, `.jpg`, `.jpeg`, `.tif`, `.tiff`.
|
|
- **Output format**: extracted text is now Markdown; stored with content-type `text/markdown; charset=utf-8`.
|
|
- **Backward compatibility**: `DocumentExtracted` new fields have defaults, so in-flight messages during deploy are valid.
|
|
- **Refund policy unchanged**: `ExtractionFailedError` → `extraction_failed` (not refundable under `infra_only`); `OCRError` → `ocr_failed` (refundable).
|
|
- **Chunking**: `worker-analyze` uses heading-aware `chunk_markdown()` when structure exists; otherwise identical to previous `chunk_text()`.
|
|
|
|
## Verification
|
|
|
|
```bash
|
|
make lint # ruff check + format check — passed
|
|
make typecheck # mypy src — passed (92 files)
|
|
make test-unit # 132 passed, 32 deselected integration tests
|
|
```
|
|
|
|
Integration tests (`tests/integration/test_extract_worker.py` and
|
|
`tests/integration/test_upload_pipeline.py`) passed with the live Docker stack.
|
|
The full `make test-integration` suite is slow because it exercises the real LLM
|
|
path; the extraction-specific integration subset is green.
|
|
|
|
## Deployment notes
|
|
|
|
1. The `worker-extract` Docker image must be rebuilt because:
|
|
- new apt package `libmagic1`
|
|
- new Python deps `mammoth`, `striprtf`, `chardet`, `python-magic`
|
|
2. Existing running workers should be recreated (`docker compose --profile services up -d --build worker-extract`).
|
|
3. No DB migration required — changes are additive to message schema and object storage content.
|
|
|
|
## Rollback
|
|
|
|
- The new code is additive; old messages with fewer `DocumentExtracted` fields
|
|
still validate. If rollback is needed, the previous worker-extract image
|
|
continues to work, but extracted objects would be stored as `text/plain`
|
|
instead of `text/markdown`.
|
|
|
|
## Deviations from the spec
|
|
|
|
- `ExtractedDocument.attachments: list[bytes]` was omitted — there is no consumer
|
|
or storage design for extracted images yet.
|
|
- Heavy adapters (`marker`, `paddleocr`, `easyocr`, `img2table`, `email`) remain
|
|
deferred per the spec.
|
|
|
|
## Next step
|
|
|
|
Phase 2: prescreen stage between `worker-extract` and `worker-analyze`. Based
|
|
on `SPIKE_PHASE0.md`, this will use a deterministic regex+pydantic extractor for
|
|
RU/BY contracts instead of the Needle 2 model (English-only in testing).
|