Fix every real lint finding and drop degenerate splice fragments
This commit is contained in:
@@ -166,11 +166,14 @@ unreliable, always flag them") or its opposite ("formulas extract fine, no
|
||||
special handling needed") — neither is true here. The determining factor is
|
||||
whether the formula's visual layout is fundamentally 1D (left-to-right,
|
||||
like an inline exponent) or 2D (a fraction, a matrix, stacked terms).
|
||||
**Check:** no cheap automatic detector was built for this distinction yet —
|
||||
treat any equation/formula-like content as a manual-review candidate,
|
||||
especially anything with a fraction bar, until a real detector exists
|
||||
(e.g. checking for large vertical bbox gaps between adjacent glyphs that
|
||||
should be visually stacked).
|
||||
**Check:** a detector now exists — `residual_ink.py`'s
|
||||
`fraction_bar_candidate`, which finds the bar as ink no extracted span
|
||||
accounts for. Measured on this book: **precision 16/23 = 69.6%** (the misses
|
||||
are decorative underlines and table borders), recall unknown, and it is blind
|
||||
by construction to a fraction printed without a bar (item 25). Its output is
|
||||
therefore a review queue, not a verdict: all 23 candidates were rendered and
|
||||
read one at a time before any was acted on, and only the confirmed ones went
|
||||
into `ingestion/data/verified/formula_regions_2d.json`.
|
||||
**Generalizes:** yes — any technical/medical/scientific PDF with inline
|
||||
math will have this exact split; don't assume all formulas behave the same
|
||||
way in extraction.
|
||||
@@ -179,25 +182,65 @@ way in extraction.
|
||||
|
||||
## Character/glyph-level risks
|
||||
|
||||
### 9. Rare reversed (right-to-left) glyph-order defect
|
||||
**What it looks like:** confirmed exactly once across the entire
|
||||
1668-page book (physical page 1373): one short text run's glyphs are
|
||||
positioned in **descending x-order** rather than ascending, producing
|
||||
scrambled output (e.g. `" = tịx 8 yàgn gnàh uềil gnổt(..."`) that reverses
|
||||
character-by-character back to the correct Vietnamese sentence
|
||||
(`"(4 xịt = 800 microgam) vào buổi chiều..."`).
|
||||
**Why it matters:** this is a genuine, confirmed data-corruption risk, not
|
||||
theoretical — but it's also extremely rare (1 occurrence in 1668 pages), so
|
||||
it must be *detected*, not assumed to be either absent or common.
|
||||
**Check:** group text fragments into visual rows by rounded y-coordinate,
|
||||
then check whether x-coordinates are non-decreasing across the row; flag
|
||||
(and optionally auto-correct by re-sorting on x) any row that isn't. This
|
||||
full-book check runs in about 20 seconds.
|
||||
### 9. Rare reversed/misordered glyph defects — corrected count: 2, not 1
|
||||
**What it looks like:** re-implemented as real, tested production code
|
||||
(`ingestion/ingestion/extract/glyph_order.py`) rather than trusted from the
|
||||
earlier exploratory script's claim. Found **two distinct shapes**, not the
|
||||
one originally reported:
|
||||
1. **Within-span character reversal** (physical page 1373, the originally
|
||||
reported case): one span's glyphs are positioned in descending x-order,
|
||||
producing `" = tịx 8 yàgn gnàh uềil gnổt(..."`, which reverses
|
||||
character-by-character back to `"(4 xịt = 800 microgam) vào buổi
|
||||
chiều..."`.
|
||||
2. **Cross-fragment row misordering, newly found** (physical page 714): a
|
||||
single visual row is split by PyMuPDF into multiple `line` objects
|
||||
*within one block* that are then emitted out of left-to-right order —
|
||||
each fragment's own characters are fine, but concatenating fragments in
|
||||
extraction order produces `"...bảo quản ộđ tệihn "` instead of the
|
||||
correct `"...bảo quản nhiệt độ "`. This is a different underlying shape
|
||||
from item 1 (multiple mis-ordered fragments, not one reversed span) and
|
||||
was missed by the original narrower (within-span-only) check — the
|
||||
ADR 0003 claim of "exactly 1 occurrence in the whole book" undercounted
|
||||
the real defect population; corrected here.
|
||||
**Getting a trustworthy count took three detector iterations** (documented
|
||||
in the module's own docstring) — the first naive whole-book implementation
|
||||
of the row-level check reported **1113** "issues," almost all false
|
||||
positives from two mechanisms: (a) ordinary font-kerning jitter (e.g. in
|
||||
"mefloquin," two adjacent glyphs differ by 0.095pt — normal kerning, not a
|
||||
defect) treated as a reversal with no decrease-tolerance, which then
|
||||
actively *corrupted* correct text into "mefolquin"; and (b) reconstructing
|
||||
"visual rows" from raw x/y coordinates using a hand-picked column-boundary
|
||||
threshold, which misclassifies a paragraph that happens to start near the
|
||||
natural column gap (confirmed real case: a right-column paragraph starting
|
||||
at x=299.4 got merged with an unrelated left-column paragraph at the same
|
||||
y). The fix that survived whole-book testing: group by PyMuPDF's own
|
||||
`block` index (already validated in ADR 0003 to respect this document's
|
||||
column structure) instead of re-deriving columns from coordinates, plus a
|
||||
minimum-decrease threshold (1.0pt — safely between the ~0.3pt kerning noise
|
||||
floor and the >2pt real-defect magnitude). Final whole-book result: **11
|
||||
row-level issues on 5 pages** — 3 of those pages (92, 94, 805) are formula
|
||||
regions already flagged as unreliable in item 8 below (2D-layout formulas
|
||||
scramble on extraction; this check's "corrected" text for those rows should
|
||||
**not** be trusted or auto-applied, same as item 8's existing guidance),
|
||||
leaving exactly the 2 genuine prose defects above (pages 714, 1373).
|
||||
**Why it matters:** both genuine defects are confirmed real data-corruption
|
||||
risks, not theoretical — but both are also extremely rare (2 occurrences in
|
||||
1668 pages of prose), so they must be *detected*, not assumed either absent
|
||||
or common. Equally important: a naive implementation of "the obvious check"
|
||||
can itself introduce false positives and even actively corrupt correct
|
||||
text — this detector's own false-positive history is as important a lesson
|
||||
as the defects it catches.
|
||||
**Check:** `ingestion.extract.scan_glyph_order` (within-span) and
|
||||
`ingestion.extract.scan_reading_order` (cross-fragment, grouped by real
|
||||
PyMuPDF block index + row y, with a 1.0pt minimum-decrease threshold and
|
||||
header-band exclusion). Both run in seconds over the full book.
|
||||
**Generalizes:** yes, directly — this is a cheap, universal sanity check
|
||||
worth running on any PDF text-extraction pipeline as a standing QA gate,
|
||||
regardless of source document, since it catches a class of PDF-authoring
|
||||
defects (RTL/BiDi overrides, corrupted content streams) that have nothing
|
||||
to do with this book specifically.
|
||||
regardless of source document. The false-positive history also generalizes:
|
||||
any "reconstruct visual rows from raw coordinates" approach needs a
|
||||
decrease-tolerance (font kerning is universal) and should prefer the
|
||||
source tool's own layout-analysis groupings (blocks/lines) over hand-picked
|
||||
coordinate thresholds wherever available.
|
||||
|
||||
---
|
||||
|
||||
@@ -424,12 +467,349 @@ rather than treating them as failures.
|
||||
|
||||
---
|
||||
|
||||
### 15. No embedded images anywhere in the book — measured, not assumed
|
||||
**What it looks like:** a whole-book scan of `page.get_images(full=True)` across
|
||||
all 1668 pages returns **zero** embedded raster/vector images, confirmed via
|
||||
PyMuPDF's own image extraction API (not just "the text doesn't mention an
|
||||
image").
|
||||
**Why it matters:** avoids over-investing in image/caption validation tooling
|
||||
for a corpus that has no images to validate — but this must be a measured
|
||||
fact, not an assumption from the book's general description as "text-heavy."
|
||||
**Generalizes:** the check (`get_images(full=True)` summed over every page)
|
||||
is a cheap one-line whole-document verification worth running on any PDF
|
||||
before deciding whether image-handling code is needed at all.
|
||||
|
||||
### 16. Chemical reaction arrows render as Private-Use-Area glyphs, not Unicode arrows
|
||||
**What it looks like:** confirmed real example — physical page 1033 contains a
|
||||
genuine chemical reaction equation (`Na2S2O3 + CN⁻ → SCN⁻ + Na2SO3`, part of
|
||||
the cyanide-antidote/rhodanese mechanism description). The reaction arrow
|
||||
extracts as a Private-Use-Area codepoint (``), not a standard Unicode
|
||||
arrow (`→`) — the source PDF's font maps a custom symbol glyph (likely from a
|
||||
symbol/wingdings-style embedded font) into a PUA slot, and raw text extraction
|
||||
faithfully returns that codepoint rather than a human-readable arrow.
|
||||
**Why it matters:** any pipeline that treats extracted text as directly
|
||||
human-readable/citable will surface a mangled or invisible character where a
|
||||
reaction arrow should be; a naive keyword/embedding step over raw text would
|
||||
either silently drop it (if PUA codepoints get filtered as junk) or leave a
|
||||
confusing tofu/box character in a chunk shown to a doctor or pharmacist.
|
||||
**Check:** scan extracted text for codepoints in the Unicode Private Use Area
|
||||
ranges (`U+E000–U+F8FF`) — cheap and generalizes to any custom-glyph symbol
|
||||
substitution, not just arrows.
|
||||
**Handling:** for now, flag any monograph/section containing a PUA codepoint
|
||||
for manual review or map known PUA codepoints (e.g. this book's `` →
|
||||
`→`) via an explicit substitution table; do not pass raw PUA codepoints
|
||||
through to chunking/embedding untranslated.
|
||||
**Generalizes:** yes — any PDF built from print-authoring software that uses a
|
||||
symbol font for arrows/special glyphs (common in scientific/medical/chemistry
|
||||
documents) can exhibit this; always check for PUA codepoints in extracted
|
||||
text as a standing sanity check, not just assume standard Unicode symbols.
|
||||
**Confirmed real chemical formula in the corpus, but rare:** a regex scan for
|
||||
molecular-formula-shaped tokens (`[A-Z][a-z]?\d{1,3}` repeated) across the
|
||||
monograph page range found 9 raw hits; manual inspection found most are
|
||||
**false positives** (`H5N1` = flu strain name, `P2Y12` = a receptor name, not
|
||||
molecular formulas) and only one confirmed genuine chemical formula/equation
|
||||
(the Na2S2O3 case above) — real chemical notation exists in this corpus but
|
||||
is genuinely rare, not a systemic pattern requiring a general chemistry
|
||||
parser.
|
||||
|
||||
### 17. Adult/child dosing-population splits are the norm, not an edge case
|
||||
**What it looks like:** measured via a whole-monograph-range text scan for
|
||||
"Người lớn"/"Trẻ em"/"Trẻ sơ sinh" (adult/child/newborn) — these terms appear
|
||||
on **1121 of ~1400** monograph-range pages, i.e. the large majority of drug
|
||||
monographs split dosing by patient population.
|
||||
**Why it matters:** this is exactly the kind of structural content where a
|
||||
segmentation/chunking bug that interleaves or merges adjacent subsections
|
||||
(e.g. a table/list continuation bug, see items 5-6) would be a genuine
|
||||
patient-safety risk, not just a data-quality nicety — mixing an adult dose
|
||||
into a child-dose chunk (or vice versa) is a plausible, concrete failure
|
||||
mode given how common this structure is.
|
||||
**Handling:** treat "does this monograph's dosing section correctly keep
|
||||
adult/child/newborn subsections un-interleaved" as a standing validation
|
||||
check (not a rare-case afterthought), given the measured prevalence.
|
||||
**Generalizes:** yes — any clinical/pharmacological reference document
|
||||
organized with population-specific subsections has this same risk profile;
|
||||
measure real prevalence before deciding how much validation effort a
|
||||
structural risk deserves (same methodology lesson as item 12a).
|
||||
|
||||
### 18. A monograph title can legitimately repeat — disambiguated by a bold, non-caps qualifier line
|
||||
**What it looks like:** confirmed real example, found while smoke-testing
|
||||
the real `segment/detector.py` against the full book: "SALBUTAMOL" is
|
||||
detected as a monograph title **twice** (physical pages 1261 and 1263).
|
||||
Rendering both pages to images and reading them directly (not inferred from
|
||||
coordinates) confirmed these are two genuinely different, complete
|
||||
monographs — "SALBUTAMOL (Dùng trong hô hấp)" (respiratory use) and
|
||||
"SALBUTAMOL (Dùng trong sản khoa)" (obstetric/tocolytic use) — each with
|
||||
its own full 18-section template. The qualifier ("(Dùng trong hô hấp)" /
|
||||
"(Dùng trong sản khoa)") is a bold line immediately below the all-caps
|
||||
title, but is **not itself all-caps** (mixed case inside the parens), so it
|
||||
is correctly excluded from `detect_monograph_titles`'s all-caps candidate
|
||||
filter — it must instead be captured as a *separate* signal and folded into
|
||||
the monograph's disambiguating identity downstream.
|
||||
**Why it matters:** an assembler that derives `drug_id` from the title text
|
||||
alone (e.g. a simple slug of "SALBUTAMOL") will produce a real collision
|
||||
between two legitimately different monographs — this is **not** the same
|
||||
failure mode as the already-fixed GONADOTROPIN false-collision (that one
|
||||
was a detector artifact from unmerged multi-line wrapping; this one is a
|
||||
genuine same-name-different-monograph case that must be preserved, not
|
||||
merged away).
|
||||
**Handling (for Phase 1.3's assembler):** after detecting a monograph title,
|
||||
check for an immediately-following bold, parenthesized, non-all-caps line
|
||||
directly below it (same page, small y-gap) and include it in `drug_id`
|
||||
generation when present, so "salbutamol_ho_hap" and "salbutamol_san_khoa"
|
||||
remain distinct rather than colliding as "salbutamol" twice. The
|
||||
`assembler.py` duplicate-drug_id check (outlier-catalog reasoning already
|
||||
established: raise on a genuine duplicate rather than silently overwriting)
|
||||
must be designed with this real case in mind, or it will incorrectly reject
|
||||
a legitimate second "SALBUTAMOL" entry.
|
||||
**Generalizes:** yes — any drug/entity reference work that documents the
|
||||
same base substance under multiple distinct use-contexts (formulation,
|
||||
indication, route) can have this exact pattern; never assume a title string
|
||||
alone is a unique key without checking for a disambiguating qualifier line.
|
||||
|
||||
### 19. Table column headers can be bold + all-caps + short — identical shape to a real title
|
||||
**What it looks like:** confirmed real example, found via a whole-book
|
||||
`assemble()` run raising a duplicate-drug_id error: "HSV" and "CMV" each
|
||||
appear twice as bold, all-caps, short (3-char) spans on physical page 698 —
|
||||
not drug names at all, but **column headers in a dosing-by-renal-function
|
||||
table** inside the "Foscarnet natri" monograph ("Liều đối với HSV / HSV /
|
||||
CMV / CMV"). Bold+all-caps+short is exactly the monograph-title signal
|
||||
(item 10/12d), so this is a genuine detector ambiguity, not a coding bug.
|
||||
**Why it matters:** unlike item 12d's part-divider titles (a small,
|
||||
enumerable, fixed set of known strings), a table's column headers are
|
||||
unbounded and content-dependent (any future table could use "HSV", "CMV",
|
||||
or something else entirely as a header) — an exclusion list approach
|
||||
doesn't generalize here the way it did for part-dividers.
|
||||
**Handling:** require a **structural anchor** rather than a text exclusion
|
||||
list: a real monograph title is always followed shortly by at least one
|
||||
recognized section heading from the vocabulary (in practice, always "Tên
|
||||
chung quốc tế" first) before the next title-shaped candidate. A
|
||||
table-header false positive is not — the table's own cells are numbers/
|
||||
plain text, matching no vocabulary entry. Implemented as
|
||||
`assembler._filter_false_positive_titles` (lookahead of 6 events, checked
|
||||
against the same coalesced event stream already built for assembly — no
|
||||
separate detection pass, no duplicated logic).
|
||||
**Generalizes:** yes, more broadly than item 12d — any document where
|
||||
section/entity boundaries are marked by a *shape* (bold+caps+short) that a
|
||||
table, list, or figure caption could coincidentally also match should
|
||||
verify a **structural follow-on anchor**, not just a shape match or a
|
||||
denylist of known bad strings, since the space of possible false-shaped
|
||||
content (table headers, figure labels, pull-quotes) is unbounded while the
|
||||
space of "what a real boundary is followed by" is small and known.
|
||||
|
||||
### 20. Section headings are not consistently bold across monographs — some combine label+value in one plain span
|
||||
**What it looks like:** confirmed real example, found by investigating why
|
||||
a whole-book `assemble()` run showed 48 monographs with zero ATC codes and
|
||||
not stated-absent (far more than the ~13-14 the original spot-check
|
||||
extrapolated). AMITRIPTYLIN's real "Mã ATC:" field is a **single, plain
|
||||
(non-bold)** span containing the label AND value together: `"Mã ATC:
|
||||
N06AA09."` — unlike Abacavir's equivalent, which is a bold `"Mã ATC: "`
|
||||
label span followed by a separate plain `"J05AF06."` value span. Both
|
||||
render visually similar but have completely different span/style
|
||||
structure. Given the book's own foreword states it was "biên soạn bởi
|
||||
nhiều tác giả" (written by many authors), this kind of per-author styling
|
||||
inconsistency across ~700 individually-authored monographs is plausible
|
||||
and, once checked, confirmed real — not a one-off.
|
||||
**Why it matters:** a detector that requires `span.bold` to recognize a
|
||||
section heading (reasonable-looking given every *title* is confirmed bold)
|
||||
silently drops entire sections for a meaningful fraction of the corpus —
|
||||
this directly caused undercounted ATC codes (and, structurally, would
|
||||
equally affect any other section) for monographs using this looser style.
|
||||
**Handling:** match section headings by **vocabulary text**, not by
|
||||
boldness — the same "don't gate on a styling attribute, only content is
|
||||
reliable" lesson as item 10 (font size), now applied to boldness. Also
|
||||
handle the "label + value combined in one span" shape explicitly (a prefix
|
||||
match: does the span start with a known label followed by ":", with the
|
||||
remainder treated as the section's inline value) rather than assuming
|
||||
label and value are always separate spans.
|
||||
**Generalizes:** yes — any print-authored reference work assembled from
|
||||
many individual authors/editors over a long production process should
|
||||
expect inconsistent low-level styling of nominally-identical structural
|
||||
elements; verify a structural signal (styling) against the *content* it's
|
||||
supposed to correlate with, across a large real sample, before trusting it
|
||||
as a universal discriminator — the same methodology lesson as item 10,
|
||||
found again independently here.
|
||||
|
||||
### 21. "All-caps" is not 100% reliable either — and a class-level monograph's own internal sub-headings can masquerade as new monographs
|
||||
**What it looks like:** two distinct confirmed real findings from the same
|
||||
investigation:
|
||||
1. The class-level monograph "CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE" embeds the
|
||||
mixed-case abbreviation "CoA" (Coenzyme A) inside an otherwise all-caps
|
||||
title. A strict `text.isupper()` check requires *zero* lowercase
|
||||
letters, so this single embedded abbreviation caused the entire
|
||||
monograph to be silently dropped from the corpus — found only by
|
||||
directly checking whether this specific, previously-known (outlier item
|
||||
12a) class-level monograph was present in a real whole-book `assemble()`
|
||||
run, and discovering it was not.
|
||||
2. Within that same class-level monograph, individual statin names
|
||||
("SIMVASTATIN", "LOVASTATIN", "PRAVASTATIN", "FLUVASTATIN") appear as
|
||||
their own bold+all-caps+short sub-headings, each introducing its own
|
||||
"Liều lượng và cách dùng" sub-section — shape-identical to a real
|
||||
monograph title, and (after fix 1 above made the loosened "any known
|
||||
section" anchor check pass) briefly became a second false-positive
|
||||
category alongside item 19's table headers, since these sub-headings
|
||||
*are* followed by a recognized section, just never by "Tên chung quốc
|
||||
tế" specifically (that section belongs only to the parent).
|
||||
**Why it matters:** together these show that neither "all-caps" nor "loosen
|
||||
the anchor to any section" is safe in isolation — the fix for one false
|
||||
positive (item 19, HSV/CMV) reopened a different one (SIMVASTATIN) until
|
||||
the anchor check was tightened back to the *specific* section the book's
|
||||
own template guarantees is always first for a genuine top-level monograph.
|
||||
**Handling:** `detector._is_mostly_upper` uses a **lowercase-letter ratio**
|
||||
(≤10%), not an absolute count — an earlier absolute-count version (≤2
|
||||
lowercase letters) let a real regression through: "Mã ATC:" has only 1
|
||||
lowercase letter (a normal Vietnamese diacritic, 'ã') but that's 20% of its
|
||||
5 letters, correctly rejected by the ratio while HMG-CoA's 1/27 ≈ 3.7%
|
||||
correctly passes. `assembler._has_anchor_ahead`
|
||||
requires specifically the "ten_chung_quoc_te" section key, not just any
|
||||
recognized section, since that is the one invariant the book's documented
|
||||
template actually guarantees is unique to real top-level monographs.
|
||||
**Generalizes:** yes — (1) don't assume a styling/casing convention holds
|
||||
with zero exceptions across an entire corpus, even one confirmed exception
|
||||
matters at whole-corpus scale; (2) when a document has nested substructure
|
||||
that mimics top-level structure (a class monograph containing per-item
|
||||
sub-entries), the anchor used to confirm a real boundary must be the most
|
||||
*specific* invariant available, not just "some known follow-on content" —
|
||||
a looser check that fixes one false positive can silently reopen another.
|
||||
|
||||
### 22. Running-header boilerplate was never actually stripped, despite item 13's warning — measured whole-corpus at 98.4% of monographs affected
|
||||
**What it looks like:** the running header at the top of every physical page
|
||||
("DTQGVN 2" + printed page number + the current monograph's name, e.g.
|
||||
physical page 1008's "DTQGVN 2" / "1009" / "Morphin sulfat", tagged
|
||||
`column="full_width"` by `extract/spans.py`) matches no section heading and
|
||||
isn't a real all-caps title, so it fell through every classification branch
|
||||
in `assembler._classify` into plain body text — splicing itself into the
|
||||
*middle* of whatever section is open when a physical page turns. Real
|
||||
example, MORPHIN SULFAT's `liều lượng và cách dùng`: `"...Nếu\nDTQGVN 2\n
|
||||
1009\nMorphin sulfat\nuống viên thuốc..."` — the header text lands inside a
|
||||
real dosing sentence.
|
||||
**Why it matters:** item 13 (above) already *warned* "strip the fixed
|
||||
boilerplate before parsing content" back when the extraction layer was
|
||||
first built, but that step was never actually implemented in `assembler.py`
|
||||
— the warning existed in the catalog without a corresponding code path or
|
||||
test enforcing it, and nothing caught the gap until a whole-corpus
|
||||
measurement was actually run. Measured: **1,374 of 11,409 sections (12.0%)
|
||||
contained a literal "DTQGVN" string mid-text; 671 of 682 monographs (98.4%)
|
||||
had at least one affected section** — this is not a rare edge case, it's
|
||||
the default outcome for any section whose text happens to cross a physical
|
||||
page boundary (i.e. most sections longer than about half a page). Left
|
||||
unfixed, boilerplate gets baked into chunks and embeddings and can surface
|
||||
mid-sentence in a citation shown to a doctor/pharmacist.
|
||||
**Handling:** `assembler._is_page_boilerplate` drops any span with
|
||||
`column == "full_width"` and `y0 < HEADER_BAND_Y` (the same header-band
|
||||
threshold `page_map.py` already uses to read the folio) before it reaches
|
||||
any other classification branch. Whole-corpus re-measurement after the fix:
|
||||
0 of 11,409 sections contain "DTQGVN". Regression test uses the exact real
|
||||
MORPHIN SULFAT span shape.
|
||||
**Generalizes:** a documented risk in this catalog is not the same as a
|
||||
verified-fixed risk — "we know this could happen" needs a whole-corpus
|
||||
measurement (not just a warning paragraph) before it can be crossed off,
|
||||
and ideally a regression test that would fail if the fix were ever reverted.
|
||||
|
||||
### 23. PyMuPDF's raw block order doesn't reliably sequence left-column-before-right-column — confirmed wrong on 12 of 1398 pages
|
||||
**What it looks like:** `extract/spans.py` originally trusted PyMuPDF's own
|
||||
block iteration order to already emit left-column content before
|
||||
right-column content, validated only against one example page during ADR
|
||||
0003. On physical page 1100 (the OXYBUTYNIN/OXYMETAZOLIN monograph
|
||||
boundary) and 11 other pages, PyMuPDF's raw block order emits the *right*
|
||||
column first. Since `assembler.assemble` appends section content to
|
||||
whichever monograph is currently open, this silently attributed
|
||||
OXYMETAZOLIN's right-column sections (Chống chỉ định, Thận trọng, Thời kỳ
|
||||
mang thai, Thời kỳ cho con bú, ADR, Hướng dẫn xử trí ADR, Liều lượng và
|
||||
cách dùng) to the still-open OXYBUTYNIN monograph — overwriting
|
||||
OXYBUTYNIN's real sections and leaving OXYMETAZOLIN missing all 7.
|
||||
**Why it matters:** medically relevant — wrong contraindication/ADR content
|
||||
silently attached to the wrong drug. Found via a whole-document
|
||||
(1668-page) character-similarity diff against an independent parser
|
||||
(`opendataloader-pdf`), not from a sample; confirmed by rendering the page
|
||||
to an image and reading it directly, then confirmed again in the actual
|
||||
`assemble()` output.
|
||||
**Handling:** `extract.spans._sort_blocks_reading_order` explicitly sorts
|
||||
each page's blocks by (full_width header band first, then left column,
|
||||
then right column) and then by y-position, instead of trusting raw PyMuPDF
|
||||
order. Whole-range (99-1496) re-scan after the fix: 0 pages with the
|
||||
reversed-order signature (was 12). Directly verified OXYBUTYNIN's and
|
||||
OXYMETAZOLIN's `assemble()`-produced sections are now distinct and
|
||||
drug-appropriate.
|
||||
**Generalizes:** don't trust an upstream library's element ordering just
|
||||
because it happened to be correct on the one page checked during initial
|
||||
validation — for a whole-corpus pipeline, explicitly sort by the actual
|
||||
signal you care about (here: visual column position) rather than an
|
||||
implicit "the library probably does this right" assumption.
|
||||
|
||||
### 24. Some text exists only as vector outlines — no text extractor can read it, and single dropped glyphs corrupt otherwise-clean sentences
|
||||
**What it looks like:** physical page 714 prints 17 full lines of ordinary
|
||||
GATIFLOXACIN prose that `page.get_text()` does not return, `page.search_for()`
|
||||
cannot find, and neither `pdfplumber` nor `opendataloader-pdf` returns either.
|
||||
`page.get_drawings()` shows why: each line is a filled path of 1,126-1,831
|
||||
items, shaped exactly like one line of type and filled with the body-text
|
||||
colour. The same defect occurs at glyph granularity (39-45 path items), and
|
||||
that form is far more dangerous — a single Vietnamese diacritic character
|
||||
drops out of a line that otherwise extracts perfectly: `Độ ổn định` extracts
|
||||
as `Độ n định`, `≥ 1 tuổi` as `≥ 1 tu i`, `tại chỗ` as `tại ch `. The result
|
||||
reads as ordinary text, so no structural check, no count and no cross-tool
|
||||
comparison notices it.
|
||||
**Why it matters:** this is silent loss of clinical prose in a drug
|
||||
formulary, and it is invisible to every check that asks a text layer a
|
||||
question. It survived a whole-document span-coverage ledger reporting
|
||||
`unassigned = 0`, because the spans that existed were all routed correctly —
|
||||
the missing content was never a span at all.
|
||||
**Check:** render the page, white out every extracted span's bbox, and look
|
||||
at the ink that survives (`ingestion/validation/residual_ink.py`, ~0.06
|
||||
s/page). Confirm with `page.get_drawings()`: a filled path with ≥30 items
|
||||
whose box is 3-20pt tall is type, not decoration (real decoration on this
|
||||
book carries 1-2 items).
|
||||
**Handling:** `ingestion/extract/outlined_text.py` detects the runs;
|
||||
recovery cannot be automatic because the paths carry no character codes, so
|
||||
each run was rendered and transcribed by reading it, into
|
||||
`ingestion/data/verified/outlined_text_transcriptions.json` with page, bbox,
|
||||
and the extracted line it belongs to. Whole-document scope: **51 runs on 5
|
||||
pages** (714 ×31, 736 ×16, 1373, 1444, 1445 ×2), 1,116 characters.
|
||||
**Generalizes:** yes — any PDF produced by a layout tool that converts
|
||||
selected text to outlines (common when a font cannot be embedded) has this.
|
||||
Never treat "the text layer returned something for this page" as evidence
|
||||
the page was fully extracted; compare against the rendered pixels.
|
||||
|
||||
### 25. A fraction can be printed with no fraction bar at all, so no geometric detector can find it
|
||||
**What it looks like:** ADENOSIN (physical page 147) prints its infusion-rate
|
||||
formula as three plain lines — `Tốc độ truyền dịch (ml/phút) = 0,140
|
||||
(mg/kg/phút) × trọng lượng cơ thể (kg)` / `Nồng độ adenosin (3 mg/ml).` —
|
||||
with **no rule drawn between numerator and denominator**, confirmed by
|
||||
rendering the region and reading it. Extracted linearly it reads as a
|
||||
multiplication chain, i.e. the division silently disappears.
|
||||
**Why it matters:** it defeats the detector that catches every other 2D
|
||||
formula in this book. The fraction-bar signal (item 8, and
|
||||
`residual_ink.py`'s `fraction_bar_candidate`) finds ink; there is no ink to
|
||||
find here. It was caught only because a prose-leak gate matched its text.
|
||||
**Check:** there is no cheap automatic check. Treat any line ending in a
|
||||
unit-bearing quantity immediately followed by a line that is itself a
|
||||
unit-bearing quantity as a division candidate for human review.
|
||||
**Handling:** quarantined via the verified region list with
|
||||
`source_prints_no_bar: true`. The count of bar-less formulas in this book is
|
||||
**unmeasured** — recorded as `recall_limit` in
|
||||
`ingestion/data/verified/formula_regions_2d.json` so the bar scan is never
|
||||
mistaken for complete formula coverage.
|
||||
**Generalizes:** yes — measured precision of the fraction-bar rule on this
|
||||
book is **16/23 = 69.6%**, and its recall is unknown. A geometric heuristic
|
||||
finds candidates; it never proves absence.
|
||||
|
||||
## Not yet investigated (flagged for future work, not silently ignored)
|
||||
|
||||
- **Footnote-style superscript reference markers** (seen as `a, b, c, d` in
|
||||
one table) — not yet checked for whether the footnote text stays
|
||||
correctly associated with its marker/row during extraction.
|
||||
- **Formula detection heuristic** (item 8) — no automatic detector exists
|
||||
yet to flag 2D-formula regions before they're trusted as chunk content.
|
||||
- **How many bar-less formulas exist** (item 25) — one confirmed, total
|
||||
unmeasured; no geometric signal can bound it.
|
||||
- **Merging the 51 transcribed outlined runs back into monograph text**
|
||||
(item 24) — transcribed and stored, but the corpus still contains
|
||||
`Độ n định`.
|
||||
- **2D grid table reconstruction** (item 7) — no implementation yet for
|
||||
recovering row/column-correct values from a nomogram-style table.
|
||||
- **Exact shortest monograph name+page** — a quick unmerged crude scan (no
|
||||
multi-line title merge) gave a different longest-monograph ranking than
|
||||
the already-documented authoritative one (item 12e: "AMOXICILIN VÀ KALI
|
||||
CLAVULANAT", 45,623 chars), meaning the crude scan's numbers are not
|
||||
reliable enough to name an exact shortest monograph — deferred to the real
|
||||
Phase 1.2 detector (with proper multi-line merge and back-index-validated
|
||||
boundaries), which will produce a trustworthy number as a side effect of
|
||||
its own validation run, rather than trusting today's quick, differently-
|
||||
scoped script.
|
||||
|
||||
Reference in New Issue
Block a user