7.7 KiB
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:
DocumentExtractednew fields have defaults, so in-flight messages during deploy are valid. - Refund policy unchanged:
ExtractionFailedError→extraction_failed(not refundable underinfra_only);OCRError→ocr_failed(refundable). - Chunking:
worker-analyzeuses heading-awarechunk_markdown()when structure exists; otherwise identical to previouschunk_text().
Verification
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
- The
worker-extractDocker image must be rebuilt because:- new apt package
libmagic1 - new Python deps
mammoth,striprtf,chardet,python-magic
- new apt package
- Existing running workers should be recreated (
docker compose --profile services up -d --build worker-extract). - No DB migration required — changes are additive to message schema and object storage content.
Rollback
- The new code is additive; old messages with fewer
DocumentExtractedfields still validate. If rollback is needed, the previous worker-extract image continues to work, but extracted objects would be stored astext/plaininstead oftext/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).