Přeskočit obsah

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:

  1. PDF + Image (PNG/JPEG/WEBP) → Mistral OCR → strukturovaný markdown + figure annotations
  2. MHTML (Nette report main) → BS4 + trafilatura + Gemini per image (port z v1)
  3. DOCX (user upload) → python-docx + Gemini per embedded image
  4. Folder model — report = container, attachments + figures cross-source
  5. Realtime UX — progress bar live během zpracování
  6. Cost tracking per-stage
  7. 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

scan → parse → annotate → embed → finalize

(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 — authenticated SELECT-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_sectionsmarkdown + tokens ready pro chunking (target 1024 tokens/chunk)
  • figuresannotation_json (FigureAnnotation strukturované) ready pro multimodal embed (text + binary image)
  • parsed_metadataaddresses / parcel_numbers / municipalities pro filtering
  • Worker _Target dispatch + cost tracking infra ready pro embed_target stage
  • 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.cz nebo auth.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_tables schema existuje ale pipeline zatím tabulky neextrahuje (Mistral document annotations je extrahuje ale ne v structured form).