Catalog onboarding
What it does
Per ADR-0010 D5, D7, D8, D12,
Ratiba lets tenants self-onboard their existing service catalog without
re-typing it row by row. The pipeline accepts five input modalities — image,
multi_image, pdf, text, csv — and produces the same ExtractedRow
envelope regardless of source, so the review-screen UI handles a single shape.
Vision OCR and modality dispatch
Image, multi-image, and PDF modalities call Anthropic Vision (see
Glossary — Anthropic Vision OCR)
through the M11 T3 LLMRouter vision role. Multi-image uploads run one
Vision call per image then merge near-duplicate rows by name similarity
(Levenshtein distance < 3 OR difflib SequenceMatcher ratio >= 0.9),
with per-field confidence merged via max so the more confident extraction wins
each cell. PDFs are rendered page-by-page via pypdf + Pillow and treated
as multi-image. The text modality skips vision and uses the text-LLM
answer_shaper role; csv bypasses the LLM entirely via a stdlib parser.
After extraction, every row passes through a gap-fill pass that asks the
LLM to suggest typical values for missing duration_min / name_sw /
description with confidence < 1.0.
Price safety floor
Safety floor (D7): price is NEVER auto-filled. Rows where extraction
left price=None stay None; the gap-fill step explicitly discards any
LLM-supplied price; the review screen marks those rows red and the admin must
type the price manually before commit. The same rule is enforced again at the
persistence boundary — a draft with a None price and no admin override
raises CatalogImporterError("price_floor_violation") before any DB write.
This is a hard, two-layer guard: client enforcement (Submit disabled) + server enforcement (HTTP 422). Neither layer alone is sufficient because an authenticated API client could bypass the dashboard. Both must be in place.
Why no auto-fill? East African service pricing varies sharply by location, salon tier, and promotions the LLM cannot know. An incorrect auto-filled price could be published to customers before the owner notices. The review screen friction is intentional.
LLM-inferred relation graph
The relation inferrer (M11 T5) is invoked once per import and emits
complementary / alternative / sequential rows for the tenant's
service_relations graph at temperature=0 for re-run idempotency. The
inferrer reasons over service names, not IDs — this avoids hallucinating
numeric foreign keys. Relations are stored in <tenant>.service_relations and
consumed directly by the cross-sell engine; the cross-sell page owns the full
runtime flow: see Cross-sell.
Inference is declared non-load-bearing by ADR-0010 D5: if the inferrer call fails or returns empty, the import still commits. The operator sees zero cross-sell candidates until the next re-import succeeds.
The three relation types and their semantics:
| Type | Meaning | Cross-sell action |
|---|---|---|
complementary | Services that pair well (e.g. manicure + pedicure) | First-candidate for a slot-aware cross-sell offer |
alternative | Substitutable services (e.g. deep-tissue vs Swedish massage) | Offer when primary service is unavailable |
sequential | Services with a natural ordering (e.g. consultation → treatment) | Reserved for future use; stored now, unused at runtime |
Atomic commit with advisory lock
CatalogImporter.import_catalog() returns an ImportDraft and writes
nothing — extraction + relation inference + a snapshot read of the current
services state happen first, the dashboard renders the review screen, and
only commit_import() opens the transaction. Commit holds a per-tenant
pg_advisory_xact_lock for the duration, atomically: upserts each
services row by LOWER(name) match, records one catalog_audits row
per mutation (insert / update), inserts the service_relations rows,
and lands one catalog_imports row carrying snapshot + extracted +
committed JSONB payloads.
Why a Postgres advisory lock? Without it, two concurrent browser tabs on the same admin session could submit two overlapping drafts. The lock is xact-scoped (auto-released on commit/rollback) so no manual cleanup is needed even on error.
Idempotent re-import
Idempotency (D8): a re-import matches existing rows by LOWER(name) and
updates rather than duplicates. The catalog_imports.type column distinguishes
'initial' (snapshot was empty) from 're-import'. All LOWER(name) matches
are case-insensitive — "Swedish Massage" and "swedish massage" are the same
service. New rows in the re-import that have no LOWER(name) match in the
existing catalog are inserted fresh.
Single-snapshot rollback
Rollback (D12): the snapshot persisted into catalog_imports.snapshot_jsonb
powers the 7-day single-snapshot rollback UI. The snapshot captures the full
services state at the moment import_catalog() reads it — before commit_import()
touches any row. If the operator discovers a bad import (wrong prices, stale
services), they open the Imports tab, locate the most recent import row, and
click Rollback. The backend re-applies the snapshot, replacing the live
services rows with the saved state. Only the most recent snapshot is
actionable; older imports can be viewed for audit but cannot be rolled back to.
For programmatic seeding (e.g. development or bulk pilot setup), see Seed test data.
How it fits in the system
How it flows
Initial import (both audiences)
On a fresh tenant the snapshot is empty, so type='initial'. Every extracted
row that passes review is inserted, not updated. The admin types prices for any
red rows before Submit is enabled.
Re-import and merge logic
On re-upload, the snapshot contains the live catalog. The merge walk:
- For each extracted row, compute
LOWER(name). - Scan
snapshotfor a match. - If match found →
UPDATEthe existing row; audit row getsaction='update'withbeforeandafterJSONB. - If no match →
INSERTthe new row; audit row getsaction='insert'. - Services in the snapshot that have no corresponding extracted row are left
untouched (the import is additive by design; deletions require a manual
/toggle-servicecommand via the admin rail — see Admin orchestrator — slash commands).
Multi-image deduplication
When a menu spans multiple images (e.g. a spa menu split across two photos), the extractor runs one Vision call per image and then merges rows that represent the same service. The deduplication pass:
- Normalise names: lowercase + strip punctuation.
- Pair rows across images using Levenshtein distance
< 3ORSequenceMatcherratio>= 0.9. - For each matched pair, keep the row whose extraction confidence is higher. On field-by-field conflict, take max confidence per field independently.
This means a service visible in image 1 with a clear price but a blurry name, and visible in image 2 with a clear name but no price, will produce a merged row with the best confidence for each field.
Where it lives in code
| Concern | File | Key entry point |
|---|---|---|
| Pipeline orchestrator | backend/app/services/catalog_importer.py | CatalogImporter.import_catalog() / commit_import() |
| Vision LLM extraction | backend/app/services/vision_extractor.py | AnthropicVisionExtractor.extract() |
| Test stub | backend/app/services/vision_extractor_fakes.py | FakeVisionExtractor |
| Relation inference | backend/app/services/relation_inferrer.py | LLMRelationInferrer.infer() |
| Import DAO — snapshot + upsert | backend/app/persistence/catalog_imports.py | fetch_services_snapshot() / upsert_service() |
| Import DAO — audit + import row | backend/app/persistence/catalog_imports.py | insert_catalog_import() / insert_catalog_audit() |
Decisions
- ADR-0010 — Tenant self-service customisation:
- D5 locks the LLM-inferred relation graph as the relation source (vs hand-authored or per-booking inference) and pins relation inference as non-load-bearing (failures degrade to "no relations"). Downstream cross-sell runtime is owned by Cross-sell.
- D7 locks Anthropic Vision as the extraction LLM and the price safety
floor: the LLM is never permitted to populate
price; missing prices stayNoneand the review screen forces admin manual entry. - D8 locks idempotent re-imports keyed off
LOWER(name)and thecatalog_imports.typeaudit signal ('initial'vs're-import'). - D12 locks the single-snapshot 7-day rollback semantics — every
catalog_importsrow carries the pre-commitservicesstate insnapshot_jsonb.
Try this on local dev
-
Drop a sample menu. Open
/admin/catalog(the M11 T11 onboarding wizard surface) and drag intests/fixtures/sample_menu.png. Watch the server logs forvision_extractor.extractedfollowed byrelation_inferrer.short_circuit_small_catalog(or the inferrer call if your fixture has>=2services). The review screen should render the extracted rows with per-cell confidence colour-coding. -
Force the safety floor. Confirm any row with
confidence(price) < 0.5is rendered red and the Submit button is disabled until you type a price. Try clicking Submit anyway via the API directly — the server returnsCatalogImporterError(reason="price_floor_violation")with HTTP 422 (per ADR-0010 D7, the rule is enforced both client- and server-side). -
Verify the round-trip. Click Submit; expect a green toast with the
CommitResultsummary. Inpsql, runSELECT name, price FROM <tenant>.servicesto see the inserted rows,SELECT relation_type, count(*) FROM <tenant>.service_relations GROUP BY 1to see the inferred graph, andSELECT type, modality, committed_at FROM <tenant>.catalog_imports ORDER BY committed_at DESC LIMIT 1to confirm the audit row landed withtype='initial'. Re-upload the same image to verify the second commit landstype='re-import'and everycatalog_auditsrow records action'update'. -
Test multi-image deduplication. Drag in two overlapping crop images of the same menu (create them from
sample_menu.pngwith an image editor). Confirm the review screen shows the expected merged row count — the extractor log linevision_extractor.merge_dedupshows how many duplicate pairs were collapsed. -
Trigger the advisory lock. Open two browser tabs to
/admin/catalog, submit the same draft from both tabs simultaneously. The second request should block briefly then complete (not corrupt) — both commits cannot overlap. Checkcatalog_importshas exactly one row per submit, not two partial rows.
Related
- Cross-sell — runtime use of the
service_relationsgraph produced here. - Personality dials — cross-sell and upsell dial settings that gate whether cross-sell offers fire.
- Seed test data — programmatic catalog seeding for development (bypasses the UI pipeline).
- Observability — reading
vision_extractor.*andcatalog_importer.*structured log events. - ADR-0010 — Tenant self-service customisation