Skip to content

fix(citations): correct outline and cited pages - #3

Closed
yoiberdev wants to merge 8 commits into
mainfrom
fix/outline-page-citations
Closed

yoiberdev wants to merge 8 commits into
mainfrom
fix/outline-page-citations

Conversation

@yoiberdev

Copy link
Copy Markdown
Owner

Depende de #1 (chore/clippy-lockfile). Esta rama sale de ella, así que mientras #1 no esté mezclada el diff contra main también muestra sus 4 commits. Los de esta PR son los 4 últimos.

Se solapa con #2 (fix/mcp-tool-safety) en src/mcp/server.rs, src/mcp/tools.rs, src/retrieval/context.rs, src/retrieval/hybrid.rs y src/document/model.rs. La que se mezcle en segundo lugar tendrá que rebasarse.

Problema

Con los dos libros reales (Aprendiendo Git y Sumérgete en los patrones de diseño), las citas no eran fiables:

  • a) Páginas del índice (libro de Git). 61 de las 96 secciones empezaban en una página equivocada. Los marcadores de LaTeX/hyperref usan destinos con nombre (/A << /S /GoTo /D (0) >>), y esos nombres están en un árbol /Names /Dests repartido en /Kids con /Limits. resolve_named_destination solo miraba el array /Names de la raíz, así que nunca encontraba nada. Después, la reserva de resolve_dest leía el nombre «N» como la página N+1.
  • b) parent_id del outline nativo. traverse_outline_items pasaba el padre solo al primer hijo (/First) y recorría /Next con None. En cada capítulo, solo la primera subsección conocía a su padre.
  • c) Página citada. Si el resultado era una sección, get_evidence citaba page_start (la primera página de la sección) aunque el fragmento viniera de otra página.
  • d) Páginas impresas. Solo se devolvía la página física del PDF.

Cambios (un commit por problema)

  1. fix(links): resolve nested named destinations
    • Recorre el árbol de nombres por /Kids, podando con /Limits. Si esa búsqueda falla, repite el recorrido completo sin poda.
    • Acepta destinos en forma de diccionario (<< /D [...] >>), tanto en /Names como en el /Dests de PDF 1.1.
    • Quita la adivinanza «nombre numérico → página N+1». Un nombre que no existe ahora da None, y el enlace queda como named en vez de apuntar a una página falsa.
    • Las recursiones tienen un límite de profundidad y el árbol se recorre con control de nodos ya visitados.
  2. fix(outline): keep parent_id on sibling sections
    • La cadena /Next se recorre en un bucle que da el mismo padre a todos los hermanos.
    • Un ciclo /Next o /First mal formado ya no provoca un bucle infinito.
  3. fix(evidence): cite the page of each snippet
    • Cada unidad de búsqueda guarda dónde empieza el texto de cada página.
    • Los resultados de document_search y document_search_hybrid exponen snippet_page: la página donde está el término encontrado.
    • get_evidence y los fragmentos de página de get_context citan esa página.
  4. feat(pages): expose printed page labels
    • Si el catálogo tiene /PageLabels, se calcula la etiqueta de cada página a partir del árbol de números: /Nums y /Kids; estilos D, R, r, A y a; prefijo /P; inicio /St.
    • Dónde aparece la etiqueta:
      • document_info: has_page_labels y rangos «pp. 96-101, impresas 89-94».
      • document_outline: page_label_start y page_label_end.
      • document_read_pages: «--- Página 190 (impresa 183) ---».
      • get_evidence y get_context: «p. 190 (impresa 183)».
      • Búsquedas: snippet_page_label.
    • Solo para PDFs con /PageLabels: no se inventan desfases.
      • Git trae /PageLabels (/Nums [0 <</S/r>> 3 <</P()>> 7 <</S/D>>]) y el resultado coincide con el desfase medido (−7).
      • Patrones no trae /PageLabels (su catálogo solo tiene /Lang, /Outlines y /Pages), así que no sale etiqueta. El desfase de −1 que se midió a mano no se aplica.

Los tests nuevos generan sus propios PDFs sintéticos con lopdf; no hay nada de los libros en el repo.

  • tests/outline_pages_test.rs: árbol de nombres de tres niveles con claves «0», «1a», «2c»…; /Dests de PDF 1.1; nombre inexistente; parent_id en tres niveles; /PageLabels con /Kids, prefijo vacío, /St y referencias, comprobado de punta a punta con info, outline, read_pages y evidence.
  • tests/page_citations_test.rs: snippet_page en search; cita de sección con la página del fragmento; formatos romano y alfabético; documento sin etiquetas.

Cómo se verificó

En local, en c1795c3, con nice -n 19 ionice -c3 y -j 2:

$ cargo fmt --all -- --check                              -> exit 0
$ cargo clippy --locked --all-targets -j 2 -- -D warnings -> exit 0
$ cargo test --locked -j 2 <cada objetivo>                -> todos exit 0
lib, bins, doc                          0 passed
attachments_and_embedded_files_test     5 passed
context_benchmark_test                  5 passed
forms_and_tagged_pdf_test               7 passed
links_and_outlines_test                 8 passed
mcp_protocol_test                       4 passed
mcp_tools_suite_test                    7 passed
multimodal_render_test                  5 passed
outline_pages_test (nuevo)              4 passed
page_citations_test (nuevo)             6 passed
pdf_ingestion_test                      4 passed, 1 ignored
retrieval_hybrid_test                   6 passed
scanned_document_detection_test         5 passed
security_and_encryption_test            6 passed
spatial_reading_order_test              5 passed
table_extraction_test                   5 passed
total: 82 passed, 0 failed, 1 ignored
  • cargo test se lanzó objetivo a objetivo, borrando cada binario de test al terminar, porque el /tmp compartido (tmpfs) estaba al 98-100 %. Son los mismos objetivos que ejecuta cargo test de una vez, y la CI lo corre completo.
  • El build local usa CARGO_PROFILE_DEV_DEBUG=0 y STRIP=symbols por la misma razón.
  • Cada commit intermedio pasa por separado fmt --check, clippy --all-targets -D warnings y sus tests nuevos:
    • 37cf666: 2 passed
    • 81bdc57: 3 passed
    • a6b648c: 3 + 2 passed

Medición con los libros reales:

  • Se indexaron con el binario de la base (d2d6941) y con el de esta rama, cada uno en su caché dentro del sandbox. Las cachés no salen de ahí ni se suben.
  • Secciones: el outline de la caché se compara con los marcadores reales (pdftohtml -xml) por orden, título, nivel y página.
  • Batería de 12 consultas:
    • Llamadas: document_search_hybrid (limit 5, con document_id) y document_get_evidence (por defecto).
    • Comprobación: cada fragmento se busca en el texto de pdftotext para saber en qué página está de verdad.
    • El binario es de depuración, así que los tiempos no son representativos.
  • Presencia de /PageLabels: se descomprimieron los flujos de objetos de cada PDF y se buscó la clave.
    • Git: 1 aparición, en el catálogo.
    • Patrones: 0 en 1748 flujos.

Cifras antes y después (libros reales)

Métrica Antes (d2d6941) Después (c1795c3)
Git: secciones con la página de inicio correcta 35 / 96 96 / 96
Patrones: secciones con la página correcta 51 / 51 51 / 51
Git: subsecciones con parent_id 14 / 81 81 / 81
Patrones: subsecciones con parent_id 8 / 38 38 / 38
get_evidence: citas con la página real del fragmento (48 citas, 47 localizables en pdftotext) 40 / 47 46 / 47
Top-1 de search_hybrid con rótulo o página falsos 2 (G1, G5) 0
Git: páginas con etiqueta impresa 0 220 / 224 (PDF 8 → «1», 96 → «89», 190 → «183»)
Patrones: páginas con etiqueta impresa 0 0 (el PDF no trae /PageLabels)

Top-1 de search_hybrid: cambia en 4 de las 12 consultas; las otras 8 (P1-P6, G2, G6) quedan igual.

Consulta Antes Después
G1 rebase interactivo «Probando, probando…» pp. 33-34 (rótulo falso: la sección real está en la p. 94) «¿Cómo puedo comprobar mi configuración de Git?» pp. 34-35 (sección y página reales; el libro no trata el rebase interactivo)
G3 git bisect Página 190 Sección «Cómo detectar qué commit…» pp. 189-191, cita p. 189 (impresa 182)
G4 revertir un commit ya publicado Página 142 Sección «¿Debería alterar el historial de mi proyecto?» p. 142 (impresa 135)
G5 estrategias de ramas «¿Cómo puedo comprobar mi configuración de Git?» p. 17 (rótulo falso) «Flujos de trabajo y estrategias de ramas en Git» pp. 95-125 (correcta)

Con el mismo criterio manual del informe previo, las 12 respuestas pasan de 6 correctas, 2 parciales y 4 incorrectas a 7, 2 y 3.

Detalle de las citas de get_evidence:

  • Antes: las 7 citas erróneas eran todas de secciones que citaban page_start. Estaban en P1 (dos), P3, P6, G1, G4 y G6.
  • Después: queda una. Es la sección «Trucos con Git» en G3.
    • Su p. 186 es la portada del capítulo, con 16 caracteres.
    • El término aparece en el título, pero el fragmento de 250 caracteres sigue en la p. 187.
    • Se cita la página del término.
  • La cita que no se pudo localizar es de P4 y falla igual antes y después: las palabras pegadas del texto de Patrones no aparecen en pdftotext.

Tiempos, con binario de depuración y solo como orientación:

  • Indexar: Git pasa de 4,3 s a 4,9 s; Patrones, de 9,5 s a 10,1 s. La RSS no cambia (39 MB y 83 MB).
  • search_hybrid en Git: pasa de 5,3-5,5 s a 3,0-3,5 s, porque las secciones ya no abarcan rangos enormes de páginas equivocadas. En Patrones sigue en 7,1-7,9 s.

Notas

  • Las cachés indexadas con versiones anteriores conservan las páginas mal y no tienen etiquetas. Hay que reindexar (docugraph index).
  • Los JSON solo añaden campos (snippet_page, snippet_page_label, page_label_start/end, has_page_labels, page_label en EvidenceItem). El texto de las citas añade «(impresa N)» solo cuando la etiqueta existe y es distinta del número de página del PDF.
  • Queda fuera de esta PR el texto con palabras pegadas del libro de Patrones (extracción de texto): eso va aparte.

Clippy 0.1.98 flags two new lints that break the CI step
`cargo clippy --all-targets -- -D warnings` on stable:

- chunks_exact_to_as_chunks (attachments.rs): iterate the
  /Names leaf with `as_chunks::<2>()`. The trailing odd element
  is still skipped, as with `chunks_exact(2)`.
- collapsible_match (layout.rs): move the "no trailing space"
  check into the TJ match guards. A failing guard falls through
  to `_ => {}`, which is what the inner `if` did.

No behaviour change. `as_chunks` needs Rust 1.88 or newer.
docugraph ships a binary, so the lockfile belongs in the repo.
Without it every build resolves dependencies again and a fixed
commit does not pin rmcp, lopdf, tokio and the rest.

The lock is the one resolved for 2832efa (182 packages, rmcp
3.3.0, lopdf 0.45.0, tokio 1.53.1) and matches Cargo.toml under
`--locked`.
test_real_pdf_loading_and_extraction read a hardcoded Windows
path and returned early when it was missing, so it reported "ok"
without testing anything on every machine but one.

Read the path from DOCUGRAPH_TEST_PDF instead and mark the test
#[ignore] with a reason, so it shows up as ignored by default.
Run it with `DOCUGRAPH_TEST_PDF=... cargo test -- --ignored`;
a missing variable or file now fails loudly.
Now that Cargo.lock is tracked, fail CI when it is out of date
with Cargo.toml instead of silently rewriting it.

There is no separate build step: clippy is the first step that
resolves dependencies and `cargo test` builds the crate, so both
get --locked. Without it on clippy the lock would be rewritten
before the test step runs.
Named destinations were only looked up in the root /Names array of the
/Dests name tree, so trees split into /Kids (as LaTeX/hyperref writes
them) never matched. The fallback then read a numeric name "N" as page
N+1, which put 61 of 96 bookmarks of a real hyperref book on the wrong
page.

Walk /Kids using /Limits to prune (with a full scan if that misses),
accept destination dictionaries (<< /D [...] >>) and drop the numeric
guess: an unknown name now resolves to None. Recursion through
references, names and tree nodes is depth-limited.
traverse_outline_items passed the parent id only to the first child
(/First) and recursed into /Next with None, so every later sibling lost
its parent: in a real book only 14 of 81 subsections had a parent_id.

Walk the /Next chain in a loop that gives every sibling the same parent,
skipping items already visited so a malformed cycle cannot loop forever.
When a search hit was a section, get_evidence cited the section's first
page even if the snippet came from a later page of that section.

Search units now record the offset where each page's text starts, so a
hit can report snippet_page: the page holding the matched term. Search
results expose it, and get_evidence and the page snippets of
get_context cite it instead of page_start.
PDF page numbers are physical positions, while books print their own
numbering (roman front matter, arabic pages restarting at 1). When the
catalog has /PageLabels, compute each page's label from the number tree
(/Nums and /Kids; styles D, R, r, A, a; /P prefix; /St start) and store
it on the page.

The label is shown next to the PDF page: page_label_start/end in
document_outline, has_page_labels and "impresas" ranges in
document_info, "(impresa N)" in document_read_pages headers and in
get_evidence/get_context citations, and snippet_page_label in search
hits. PDFs without /PageLabels get no labels: no offset is guessed.
@yoiberdev

Copy link
Copy Markdown
Owner Author

Se descarta: los arreglos de docugraph los está haciendo el autor en su copia local y estos cambios chocarían con ellos.

@yoiberdev yoiberdev closed this Sep 14, 2026
@yoiberdev
yoiberdev deleted the fix/outline-page-citations branch September 14, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant