Chapter-aware figures & cross-chapter linking (2026-08-16)
- GitHub epic: #342 (chapter-aware figures) — successor work to the merged repeat-fingerprint asset filter (#342 / PR #385).
- Status: Phase A implemented (this PR — schema-free groundwork). Phases B–F planned below.
Problem
Figures in the IPN curriculum are defined in one chapter but frequently referenced from other chapters ("voir figure 3.2", "انظر الشكل ٢"). Today:
- A figure is stored as an
assetsrow + a searchable figure-chunk, tagged with the chapter of the page it sits on — but nothing records the figure's own identity (its printed number/label) or which chapters' prose reference it. VisionService.describe_imagereturns a bare free-text blob. Itscontextparameter is declared but never used, so the nearby caption thatLlmStructuralAnalyzeralready extracts (references.llm_analysis.figures[].caption_nearby) is discarded — the description is ungrounded and the figure's number is never captured.- There is no way, at retrieval time, to pull a figure into a chapter that only mentions it.
Resolved product decisions
The founder has resolved the three open product decisions:
- PD-1 = I-B "referenced-by" (body cross-references). We link a chapter's prose to a figure defined elsewhere when the prose cites it ("figure 3.2" / "الشكل ٢"). This is the useful, tractable signal — not full semantic figure-to-concept mapping.
- PD-2 = "describe now, show later". This track improves text description + identity for retrieval. No image-bytes / Supabase Storage / display work in this track (deferred to Phase E).
- PD-3 = "enable real LLM chapters first". Turning on
INGEST_USE_LLM_STRUCTUREin prod is an ops step, handled separately in Phase F — it is not application code in Phase A.
Design: figure identity + cross-reference edges
Two data ideas, introduced incrementally:
- Figure identity — a figure carries its printed label (
figure_label_verbatim), a script-folded canonical number (figure_number_normalized), and its caption. The normalized number is the cross-script join key: a French "figure 2" and an Arabic "الشكل ٢" both canonicalize to"2". - Reference edges — for each chunk of prose, the set of figure numbers it cross-references
(parsed from the body text). An edge is
(referencing chunk/chapter) -> (figure number), later resolved to the concrete asset that defines that number.
Canonicalization reuses the existing Arabic letter normalization in
app/services/text_normalizer.py (tatweel/alef) and adds only digit-script folding
(Arabic-Indic ↔ Latin) + label-word stripping + separator unification.
Phased rollout
| Phase | Where | What | Status |
|---|---|---|---|
| A | rag |
Identity/parsing groundwork, schema-free, no new deps: bilingual cross-reference extractor + figure-number normalizer (pure, tested); VisionService forced-JSON structured output that also returns identity fields and finally uses context; wire the already-extracted caption_nearby into the vision call; surface figure identity in the admin preview for canary validation. |
this PR |
| B | migration |
Schema: add assets columns (figure_label_verbatim, figure_number_normalized, caption) + a figure_references table (edge: referencing chunk/chapter → figure number). |
planned |
| C | rag |
Persistence: write the new identity fields on assets; run the cross-reference extractor over text chunks and persist edges into figure_references. |
planned |
| D | rag |
Retrieval R1: context-expansion — when a retrieved chunk references a figure defined elsewhere, pull that figure's chunk in; add a system-prompt rule so the model uses referenced figures. | planned |
| E | deferred | Image display (PD-2 later): Supabase Storage upload of figure crops + a display path so clients can render the actual image. | deferred |
| F | ops | Enable INGEST_USE_LLM_STRUCTURE in prod so real LLM chapter structure (and its caption_nearby) flows end-to-end (PD-3). |
ops step |
Phase A scope (this PR)
In: pure figure_references module + tests; VisionService structured output + context
use; AssetService caption lookup by page; admin-preview identity fields. All schema-free,
no new dependencies, built on top of the merged #342 watermark filter.
Out (deferred): any assets/chunks schema change or figure_references table (B/C); any
write of the new fields; Supabase Storage / image bytes (E); enabling INGEST_USE_LLM_STRUCTURE
(F); retrieval/RPC changes (D).
Non-negotiables honored
- One capability = one runtime path: identity extraction lives in
VisionService; cross-ref parsing lives in one pure module reused by every future caller. - No new runtime services, no new deps, no raised
mem_limit. - Backward-compatible:
extract()/AssetCandidateunchanged;describe_imagereturns aFigureDescriptionwhose.descriptionaccessor preserves the old plain-text contract.
Validation
ruff check . && ruff format . && pytest green; mkdocs build --strict green. Identity-quality
is judged on the canary reference f170a1a4-… via the (non-persisting) admin asset preview.