Phase B — Ingestion pipeline¶
Stav: ✅ Dokončeno 29.4.2026 (14 tasků + 9 post-deploy hotfixů + folder model UI) Trvání: 2 dny (28.4 → 29.4) Klíčový artefakt: 5-stage worker pipeline, 5 nových tabulek, taskiq + Redis queue, golden set 13/13 PASS
Cíl fáze¶
Postavit production-ready async ingestion pipeline:
- PDF + Image (PNG/JPEG/WEBP) → Mistral OCR → strukturovaný markdown + figure annotations
- MHTML (Nette report main) → BS4 + trafilatura + Gemini per image (port z v1)
- DOCX (user upload) → python-docx + Gemini per embedded image
- Folder model — report = container, attachments + figures cross-source
- Realtime UX — progress bar live během zpracování
- Cost tracking per-stage
- Nette HMAC attachments endpoint (pre-Phase E)
Tech stack rozhodnutí¶
| # | Decision | Volba | Důvod |
|---|---|---|---|
| B1 | Worker framework | taskiq + RedisStreamBroker | Durable, ack-based, MIT license; arq mělo memory broker issues |
| B2 | Mistral SDK | mistralai 2.3.x | mistral-ocr-latest + bbox_annotation_format + document_annotation_format přes response_format_from_pydantic_model |
| B3 | Gemini fallback | gemini-3-flash-preview |
Plán psal "gemini-3.1-flash" ale neexistuje; preview je aktuální dostupný |
| B4 | MIME detection | python-magic + libmagic1 (apt) | Battle-tested (Apache, ClamAV, GitHub Linguist); pure-Python alternativa filetype slabší pro MHTML |
| B5 | Pydantic AI multimodal | BinaryContent(image_bytes, media_type) + output_type=FigureAnnotation |
Strukturovaný output, automatic JSON validation |
Decisions architecture¶
B1: Worker jako separate Sliplane service¶
NE spawn z backendu. Důvody:
- Backend musí být horizontálně škálovatelný bez worker concurrency conflicts
- Crash isolation — worker exception nesmí položit API
- Independent deploy cycle (i když codebase shared)
- Resource isolation — Mistral OCR může držet 5 min connection, nesmí blokovat API workers
Implementation: app/worker_entry.py runner se 2 procesy taskiq + uvicorn na :8000 pro Sliplane healthcheck.
B2: 5-stage pipeline s idempotent stages¶
(Phase B měla 4 stages, Phase C přidala embed.)
Každý stage:
- Samostatný @broker.task(retry_on_error=True, max_retries=3)
- Idempotent — DELETE existing data před re-run
- Per-stage status update v reports.status → Realtime emit
- Cost tracking přes add_cost_to_target()
- Per-figure resilient — exception na 1 figuře neabortuje batch
B3: Routing per content_type¶
| Content type | Cesta | Implementace |
|---|---|---|
application/pdf |
_parse_via_mistral |
Mistral OCR cesta |
image/png, image/jpeg, image/webp |
_parse_via_mistral |
Stejná cesta — Mistral umí images |
text/html, message/rfc822, multipart/related |
_parse_via_bs4_mhtml |
BS4 + trafilatura, port z v1 |
application/vnd.openxmlformats-officedocument.wordprocessingml.document |
_parse_via_docx |
python-docx |
B4: _Target dataclass abstrakce (post-deploy folder model)¶
Sjednocuje "report" a "attachment" cesty. Každý stage pracuje generically:
@dataclass(slots=True)
class _Target:
kind: str # "report" | "attachment"
target_id: str
tenant_id: UUID
report_id: str # parent (== target_id pro kind='report')
attachment_id: str | None
bucket: storage.Bucket
storage_path: str
content_type: str
filename: str
source_type: str # 'user_upload' | 'main' | 'attachment'
size_bytes: int
Klíč: figures.report_id je VŽDY parent report (i pro attachment-derived figury). figures.attachment_id je jen pro kind='attachment'. → folder retrieval WHERE report_id = X zachytí cokoliv napříč main + všemi attachments.
Klíčové bugy / hotfixy během Phase B¶
Bug B.1 — (select auth.uid()) DEFAULT v migraci¶
apply_migration přes claude_ai_Supabase MCP odmítne default (select auth.uid()) s 0A000: cannot use subquery in DEFAULT expression. Přes supabase CLI / dashboard funguje.
Workaround: column nemá DEFAULT, backend zapisuje created_by explicitně v INSERT. Cleaner.
Bug B.2 — libmagic1 musí být v Docker image¶
python-magic je ctypes wrapper nad libmagic.so.1, knihovna se nahrává at import time. python:3.12-slim ji nemá → import magic v app/storage.py crashne s ImportError: failed to find libmagic.
Fix: apt-get install libmagic1 v Dockerfile (~150 KB).
Bug B.3 — Slowapi @limiter.limit vyžaduje response: Response parametr¶
Bez něj endpoint vrátí 500 Exception: parameter 'response' must be an instance of starlette.responses.Response. Header injection probíhá AFTER endpoint return, takže celá pipeline (Storage upload + DB insert + worker enqueue) může proběhnout úspěšně, klient ale dostane 500.
Fix: přidat response: Response do signature každého rate-limited endpointu.
Bug B.4 — Mistral OCR timeout (2 kola)¶
Velký KOMPLET PDF (~5 MB, 20+ stran) hodil mistral_ocr_failed: The read operation timed out. Mistral SDK default httpx timeout ~60s, ale velké PDF s bbox + document annotation processingem potřebuje 5-15 min.
1. kolo fix: Mistral(api_key=..., timeout_ms=300_000) (5 min)
2. kolo fix (i 5 min nestačí): 3-vrstvý fix:
- mistral_timeout_ms = 900_000 (15 min) + worker_job_timeout_seconds = 1200 (20 min)
- mistral_skip_annotations_above_kb = 500 — soubory > 500 KB jdou bez bbox annotations (lehčí Mistral OCR call), Gemini fallback v B.7 stage doplní per figure
- Retry logic: pokud první call s annotations selže, retry s with_annotations=False
Bug B.5 — Mistral SDK 2.3.x import path¶
mistralai 2.3.x neexponuje Mistral na top-level — musí přes mistralai.client.Mistral + mistralai.client.models pro chunk types.
Bug B.6 — Sliplane API drops env vars s prázdnou hodnotou¶
updateService PUT s [{"key":"FOO","value":""}] smaže FOO. Pro secret keys vždy passnout konkrétní hodnotu, ne "". secret=true pak v getService response masknu jako value="" (Sliplane API masking, ne reálná empty).
Phase B post-deploy UX iterace (29.4)¶
Po dokončení 14/14 backend tasků a wrangler deploy frontendu šli jsme přes reálný UI klikání. 9 hotfixů + folder model UI zachycených během reálného user flow:
| # | Bug / Feature | Symptom | Fix |
|---|---|---|---|
| 1 | apiFetch Content-Type bug |
Upload selhal s 422 missing 'file' |
Detect init.body instanceof FormData a nechej Content-Type na browseru |
| 2 | Mistral OCR timeout 1. kolo | Velké PDF timeoutoval na 60s | timeout_ms=300_000 |
| 3 | UX vendor-leaking labels | Progress bar zobrazoval "Mistral OCR čte…" | Rebrand na vendor-neutral "Prozkoumáváme dokument…", "Popisujeme obrázky a mapy…" |
| 4 | Reports list bez Realtime | Static rendering, žádný progress | Realtime subscribe na nemoreport.reports UPDATE |
| 5 | Chat 400 no_report_context |
v2 reporty clean_text prázdný |
Helper _report_body() čte parsed_markdown OR clean_text |
| 6 | /sections v2 path |
Sekce v chat UI prázdné | Endpoint preferuje parsed_sections rows přes regex split na clean_text |
| 7 | Mistral OCR timeout 2. kolo | I s 5 min timeout selhal | 15 min timeout + skip annotations > 500 KB + retry without annotations |
| 8 | Delete report missing | Reports list neměl × button | DELETE /ingest/{id} (cascade FK) + FE × button s confirm |
| 9 | Folder model — backend | "1 soubor = 1 report" nereflektovala realitu | POST /ingest/{id}/uploads, GET /attachments, DELETE /attachments/{aid} |
| 10 | Folder model — frontend | UI ukazovala jen single file | /reports/[id] plná detail page s attachments + figures grid |
Golden set quality gate (B.14)¶
/tmp/test_golden_set.py iteroval všech 13 NemoReport MHTML reportů přes /ingest.
Výsledek: 13/13 ready (100 %), 78/78 figures s annotation ≥80 chars (100 % — target byl 80 %), avg quality 0.80, total cost 78 halířů (0.78 Kč) za 13 reportů = 6 halířů/report.
Real-world Intermap mapy jsou skutečně extrahované (např. "Brno-Židenice s vyznačením konkrétní nemovitosti v ulici Klíny", 12 entities). Prázdné placeholder mapy Gemini honestně reportuje ("Zcela černý obrázek bez viditelného obsahu", 0.7 quality) — známé MHTML save limitation z prohlížeče dropuje JS-rendered canvas elementy.
Reálné metriky (e2e na 28.4)¶
| Soubor | Velikost | Pages | Figures | Total time | Mistral OCR sám |
|---|---|---|---|---|---|
| Logo PDF | 53 KB | 1 | 0 | < 5s | ~ |
| CSSZ tax | 374 KB | 5 | 0 | 6.1s | ~ |
| Tech výkres | 306 KB | 1 | 2 | 11.2s | 9.2s |
| MHTML report | 2.7 MB | n/a | 6 | 40.85s | n/a (BS4) |
Cena per typický report¶
Per Phase B B.13 cost tracking (Mistral OCR pricing 2026):
- ~0.001-0.030 USD per page × 1-10 stran
- Worst case 10-page KOMPLET PDF ~ 0.30 USD = ~7.50 Kč
- Typický real report: 5-30 halířů (~0.05-0.30 Kč)
Cost je v reports.ingestion_cost_cents per row + admin endpointy /admin/cost/global + /admin/cost/tenant/{id}.
pgTAP testy (28 nových)¶
tests/db/05_ingestion.sql — 28 testů, 28/28 ✓:
- RLS visibility User A/B na všech 5 nových tabulkách
- attachments INSERT WITH CHECK enforces source='user_upload' + cross-tenant deny
- Defense-in-depth grants —
authenticatedSELECT-only na worker-managed tabulky - service_role full CRUD sweep
- FK cascade z reports na všechny child
- Status FSM constraint reject
- Realtime publication membership
- Nette idempotence index existence
- reports rozšíření (7 nových sloupců)
Total pgTAP suite po Phase B: 108 testů.
Co Phase B zanechala pro Phase C¶
parsed_sectionsmámarkdown+tokensready pro chunking (target 1024 tokens/chunk)figuresmáannotation_json(FigureAnnotation strukturované) ready pro multimodal embed (text + binary image)parsed_metadatamáaddresses/parcel_numbers/municipalitiespro filtering- Worker
_Targetdispatch + cost tracking infra ready proembed_targetstage - Folder model live → Phase C jen rozšíří embedding/retrieval scope
- 13 NemoReport MHTML reportů zpracovaných (golden set kandidáti)
Známé limity¶
- Resend sandbox — jen owner email může dostávat magic links. Pro reálné testery potřeba doménová verifikace
auth.algaweb.czneboauth.nemoreport.cz. - MHTML JS-rendered images — některé Intermap mapy jsou broken/black PNGs protože MHTML save z prohlížeče dropne canvas elementy (independent issue, ne náš bug).
- DOCX bez Mistral support — DOCX images parsujeme přes python-docx, ne Mistral.
- Tabulky nepopulované —
parsed_tablesschema existuje ale pipeline zatím tabulky neextrahuje (Mistral document annotations je extrahuje ale ne v structured form).