Fix every real lint finding and drop degenerate splice fragments

This commit is contained in:
2026-08-01 13:51:38 +07:00
parent 967b917001
commit 834d9e51b0
69 changed files with 10119 additions and 39 deletions
+168
View File
@@ -0,0 +1,168 @@
import json
from pathlib import Path
from ingestion.chunk import (
CHUNK_KIND_BLOCK_DESCRIPTOR,
CHUNK_KIND_PROSE,
SCHEMA_VERSION,
chunk_monograph,
chunk_section,
write_chunks_jsonl,
)
from ingestion.chunk.chunker import _is_label_row, describe_block
from ingestion.segment.models import Heading, Monograph, SectionSpan, TableBlock
from ingestion.tables import SHAPE_FORMULA_2D, SHAPE_MULTI_HEADER, SHAPE_SIMPLE
def _section(key, display, text, page=202):
return SectionSpan(
key=key, display_name=display,
heading=Heading(text=display, physical_page=page, y0=100.0,
is_monograph_title=False, section_key=key),
text=text,
)
def _monograph(sections, tables=()):
return Monograph(
drug_id="ampicilin_va_sulbactam",
drug_name="AMPICILIN VÀ SULBACTAM",
source_page_range=[200, 203],
sections={s.key: s for s in sections},
atc_codes=["J01CR01"],
tables=list(tables),
)
def _block(block_id="p202_t0", shape=SHAPE_SIMPLE, section_key="lieu_luong_va_cach_dung"):
return TableBlock(
table_id=block_id, shape=shape, physical_page=202,
bbox=[299.0, 189.6, 552.4, 300.5], section_key=section_key,
text="Độ thanh thải creatinin Nửa đời Liều 1,5 - 3,0 g",
quarantined=True,
)
def test_a_section_whose_table_was_lifted_says_so():
"""The defect this exists to prevent is silent, not visible.
Without the reference, this chunk is grammatical, complete-looking prose
with the renal-dosing table absent and nothing marking the absence.
"""
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng",
"Liều thường dùng cho người lớn là 1,5 - 3 g mỗi 6 giờ.")
monograph = _monograph([section], [_block()])
chunks = chunk_monograph(monograph)
prose = [c for c in chunks if c.chunk_kind == CHUNK_KIND_PROSE]
assert len(prose) == 1
assert prose[0].has_quarantined_content is True
assert [a.block_id for a in prose[0].attachments] == ["p202_t0"]
assert prose[0].attachments[0].physical_page == 202
assert prose[0].attachments[0].bbox
def test_a_lifted_block_gets_its_own_retrievable_descriptor():
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "Prose.")
monograph = _monograph([section], [_block()])
descriptors = [c for c in chunk_monograph(monograph)
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR]
assert len(descriptors) == 1
assert "AMPICILIN VÀ SULBACTAM" in descriptors[0].text
assert "Liều lượng và cách dùng" in descriptors[0].text
# printed page, which is what a reader holding the book looks for
assert "trang 203" in descriptors[0].text
def test_no_cell_value_ever_reaches_the_descriptor_text():
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "Prose.")
block = _block()
monograph = _monograph([section], [block])
descriptors = [c for c in chunk_monograph(monograph, {"p202_t0": []})
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR]
assert "1,5 - 3,0 g" not in descriptors[0].text
def test_a_header_row_carrying_a_number_is_refused():
"""AMIODARON, physical page 183 — a real case, caught by a gate.
pdfplumber reported the first row as
"Thời gian liệu pháp tĩnh mạch Liều 720 mg/ngày (0,5 mg/phút)", i.e. a
dose inside what it called a header, from an extraction never verified by
eye. Measured: 42 of 124 simple-table headers (34%) contain a digit.
"""
assert _is_label_row(["Các Statin", "Khởi đầu", "Liều duy trì"]) is True
assert _is_label_row(["Liều 720 mg/ngày (0,5 mg/phút)"]) is False
assert _is_label_row(["x" * 45]) is False
assert _is_label_row([]) is False
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "Prose.")
monograph = _monograph([section], [_block()])
chunks = chunk_monograph(
monograph, {"p202_t0": ["Liều 720 mg/ngày (0,5 mg/phút)"]})
descriptor = next(c for c in chunks
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR)
assert "720" not in descriptor.text
assert descriptor.attachments[0].header_row == []
def test_only_a_simple_table_contributes_a_header():
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "Prose.")
header = {"p202_t0": ["Nhóm", "Liều"]}
for shape, expected in ((SHAPE_SIMPLE, ["Nhóm", "Liều"]),
(SHAPE_MULTI_HEADER, [])):
monograph = _monograph([section], [_block(shape=shape)])
descriptor = next(c for c in chunk_monograph(monograph, header)
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR)
assert descriptor.attachments[0].header_row == expected
def test_a_formula_block_is_described_as_a_formula():
section = _section("than_trong", "Thận trọng", "Prose.")
block = _block(block_id="p1042_f0", shape=SHAPE_FORMULA_2D,
section_key="than_trong")
monograph = _monograph([section], [block])
descriptor = next(c for c in chunk_monograph(monograph)
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR)
assert "công thức" in descriptor.text
assert "bảng" not in descriptor.text
def test_attachments_do_not_change_the_prose_text():
"""The condition under which this feature was accepted at all."""
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng",
"Liều thường dùng cho người lớn là 1,5 - 3 g mỗi 6 giờ.")
with_block = chunk_section(_monograph([section], [_block()]), section,
[_block()])
without = chunk_section(_monograph([section]), section)
prose_with = [c for c in with_block if c.chunk_kind == CHUNK_KIND_PROSE]
assert [c.text for c in prose_with] == [c.text for c in without]
assert [c.chunk_id for c in prose_with] == [c.chunk_id for c in without]
def test_a_section_with_no_text_but_a_block_still_yields_the_descriptor():
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "")
chunks = chunk_monograph(_monograph([section], [_block()]))
assert [c.chunk_kind for c in chunks] == [CHUNK_KIND_BLOCK_DESCRIPTOR]
def test_written_chunks_declare_their_schema_version(tmp_path: Path):
section = _section("chi_dinh", "Chỉ định", "Nhiễm khuẩn.")
chunks = chunk_monograph(_monograph([section]))
out = tmp_path / "chunks.jsonl"
assert write_chunks_jsonl(chunks, out) == 1
record = json.loads(out.read_text(encoding="utf-8").splitlines()[0])
assert record["schema_version"] == SCHEMA_VERSION
assert record["chunk_kind"] == CHUNK_KIND_PROSE
assert record["has_quarantined_content"] is False
def test_describe_block_names_the_page_even_with_no_header():
section = _section("lieu_luong_va_cach_dung", "Liều lượng và cách dùng", "Prose.")
monograph = _monograph([section], [_block()])
chunks = chunk_monograph(monograph)
attachment = next(c for c in chunks
if c.chunk_kind == CHUNK_KIND_BLOCK_DESCRIPTOR).attachments[0]
text = describe_block(monograph, section, attachment)
assert "trang 203" in text
assert "không trích dẫn được dưới dạng văn bản" in text
+49
View File
@@ -0,0 +1,49 @@
import pytest
from ingestion.cli import build_parser
def test_run_subcommand_parses_required_pdf_arg():
parser = build_parser()
args = parser.parse_args(["run", "--pdf", "some.pdf"])
assert args.command == "run"
assert args.pdf == "some.pdf"
assert args.out == "data/processed/monographs.jsonl"
def test_run_subcommand_accepts_custom_out():
parser = build_parser()
args = parser.parse_args(["run", "--pdf", "a.pdf", "--out", "b.jsonl"])
assert args.out == "b.jsonl"
def test_run_requires_pdf_arg():
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["run"])
@pytest.mark.parametrize("command", ["visual-diff", "scaffold-golden"])
def test_not_yet_implemented_commands_raise_explicitly(command):
parser = build_parser()
args = parser.parse_args([command])
with pytest.raises(NotImplementedError):
args.func(args)
def test_run_reports_missing_pdf_file(tmp_path, capsys):
parser = build_parser()
missing = tmp_path / "does_not_exist.pdf"
args = parser.parse_args(["run", "--pdf", str(missing)])
exit_code = args.func(args)
assert exit_code == 1
assert "not found" in capsys.readouterr().err
def test_validate_reports_missing_pdf_file(tmp_path, capsys):
parser = build_parser()
missing = tmp_path / "does_not_exist.pdf"
args = parser.parse_args(["validate", "--pdf", str(missing)])
exit_code = args.func(args)
assert exit_code == 1
assert "not found" in capsys.readouterr().err
+75
View File
@@ -0,0 +1,75 @@
import json
from pathlib import Path
from ingestion.extract.formulas import (
FORMULA_BAND_HEIGHT_PT,
FORMULA_SIDE_MARGIN_PT,
load_formula_regions,
)
from ingestion.tables import QUARANTINE_SHAPES, SHAPE_FORMULA_2D
VERIFIED = (Path(__file__).resolve().parents[1] / "data" / "verified"
/ "formula_regions_2d.json")
TRANSCRIPTIONS = (Path(__file__).resolve().parents[1] / "data" / "verified"
/ "outlined_text_transcriptions.json")
def test_a_2d_formula_is_always_quarantined():
# linearised, "a / b" reads as "a x b" — a dosing error, not a cosmetic one
assert SHAPE_FORMULA_2D in QUARANTINE_SHAPES
def test_verified_formula_regions_load_with_the_confirmed_pages():
regions = load_formula_regions()
assert {r.physical_page for r in regions} == {
43, 92, 147, 202, 325, 349, 1042, 1043, 1132, 1402,
}
assert all(r.shape == SHAPE_FORMULA_2D for r in regions)
def test_the_region_covers_numerator_and_denominator_not_just_the_bar():
payload = json.loads(VERIFIED.read_text(encoding="utf-8"))
bar = next(r for r in payload["regions"] if r["physical_page"] == 1042)
region = next(r for r in load_formula_regions() if r.physical_page == 1042)
x0, y0, x1, y1 = bar["bar_bbox"]
assert region.bbox[1] == y0 - FORMULA_BAND_HEIGHT_PT
assert region.bbox[3] == y1 + FORMULA_BAND_HEIGHT_PT
assert region.bbox[0] == x0 - FORMULA_SIDE_MARGIN_PT
def test_the_barless_adenosin_formula_is_recorded_as_a_recall_limit():
"""The source prints no bar, so no geometric detector can find it.
Recorded so a later reader does not mistake the fraction-bar scan for
complete formula coverage — how many bar-less formulas the book contains
has never been measured.
"""
payload = json.loads(VERIFIED.read_text(encoding="utf-8"))
barless = [r for r in payload["regions"] if r.get("source_prints_no_bar")]
assert [r["physical_page"] for r in barless] == [147]
assert "UNMEASURED" in payload["recall_limit"]
def test_outlined_text_transcriptions_cover_every_detected_run():
payload = json.loads(TRANSCRIPTIONS.read_text(encoding="utf-8"))
runs = payload["runs"]
assert len(runs) == 51
assert all(r["text"] for r in runs), "a run with no transcription is data loss"
pages = {}
for run in runs:
pages[run["physical_page"]] = pages.get(run["physical_page"], 0) + 1
assert pages == {714: 31, 736: 16, 1373: 1, 1444: 1, 1445: 2}
def test_single_glyph_transcriptions_name_the_line_they_were_dropped_from():
"""The subtlest form of the defect: one character missing mid-sentence.
"Độ ổn định" extracts as "Độ n định" and reads as ordinary text, so
nothing downstream can notice. Keeping the owning line in the record is
what makes the repair checkable.
"""
payload = json.loads(TRANSCRIPTIONS.read_text(encoding="utf-8"))
singles = [r for r in payload["runs"] if r["single_glyph"]]
assert len(singles) == 29
with_context = [r for r in singles if r["extracted_line_it_belongs_to"]]
assert with_context, "no dropped glyph could be tied back to its line"
@@ -0,0 +1,79 @@
from ingestion.extract.glyph_order import find_reading_order_issues, is_reversed_order
def test_normal_ltr_span_not_flagged():
# ordinary increasing x-origins, as any normal left-to-right span has
assert not is_reversed_order([264.7, 269.4, 271.6, 276.3, 278.5])
def test_confirmed_page_1373_defect_shape_is_flagged():
# exact x-origins read via get_text("rawdict") from physical page 1373's
# affected span (" tịx 4 =" reversed) — see docs/pdf-parsing-outlier-catalog.md item 9
x_origins = [66.32, 64.17, 61.53, 58.89, 54.14, 51.98, 47.23]
assert is_reversed_order(x_origins)
def test_single_char_span_not_flagged():
assert not is_reversed_order([100.0])
def test_empty_span_not_flagged():
assert not is_reversed_order([])
def test_tied_x_origins_not_flagged_as_reversed():
# equal x-origins (e.g. stacked/overlapping glyphs) are not "decreasing"
assert not is_reversed_order([100.0, 100.0, 100.0])
def test_correctly_ordered_row_not_flagged():
row = {(20, 550.9): [(518.0, "n"), (525.2, "h"), (532.6, "i"), (536.8, "e")]}
assert find_reading_order_issues(row) == []
def test_confirmed_page_714_row_misorder_is_flagged():
# reproduces the real page-714 finding: within one PyMuPDF block (20),
# 4 line fragments are emitted out of x-order ("quản ", " ộ", "đ tệih",
# "n " concatenated) that reconstruct correctly ("...nhiệt độ") when
# re-sorted by x-origin — see outlier catalog item 9.
row = {
(20, 550.9): [
(518.06, "n"), (525.20, " "),
(546.59, " "), (553.71, ""),
(541.84, "đ"), (539.45, " "), (536.81, "t"), (532.59, ""), (529.95, "i"), (525.20, "h"),
]
}
issues = find_reading_order_issues(row)
assert len(issues) == 1
assert issues[0].extracted_text != issues[0].corrected_text
def test_different_blocks_at_same_y_not_merged():
# regression test for a real false positive: two DIFFERENT paragraphs in
# different PyMuPDF blocks (a right-column paragraph starting at x=299.4
# and a left-column paragraph starting at x=35.4, page 1104) coincide at
# the same y — grouping by block index (not a hand-picked x-coordinate
# column boundary) is what keeps them from being merged into one "row".
# This is the caller's responsibility (scan_reading_order groups by real
# PyMuPDF block index); find_reading_order_issues just trusts its input
# is already correctly grouped, which these two dict entries demonstrate.
row_block_1 = {(1, 70.4): [(299.39, "m"), (306.78, "ô")]}
row_block_4 = {(4, 70.4): [(35.43, "d"), (40.18, "e")]}
assert find_reading_order_issues(row_block_1) == []
assert find_reading_order_issues(row_block_4) == []
def test_kerning_jitter_not_flagged_as_reading_order_defect():
# regression test for a real false positive found by running against the
# actual PDF: "mefloquin" ('l' at x=491.566, 'o' at x=491.471 — a
# 0.095pt kerning-driven dip) was previously "corrected" into the wrong
# word "mefolquin". A row-level check with no decrease tolerance treats
# ordinary kerning as a defect and corrupts already-correct text.
row = {
(5, 449.7): [
(474.865, "m"), (482.161, "e"), (486.284, "f"),
(491.566, "l"), (491.471, "o"), (496.126, "q"),
(500.781, "u"), (505.436, "i"), (507.982, "n"),
]
}
assert find_reading_order_issues(row) == []
+27
View File
@@ -0,0 +1,27 @@
from ingestion.extract.page_map import pick_folio
def test_single_candidate_is_the_folio():
assert pick_folio([("101", 10.0)]) == 101
def test_no_candidates_is_unrecoverable():
assert pick_folio([]) is None
def test_confirmed_riboflavin_subscript_conflict_resolved_by_size():
# exact (text, size) pairs read from physical page 1243's header band:
# the real folio "1244" (size 10.0, matching the rest of the running
# header) and the "2" subscript from "Vitamin B2" (size 5.83), which
# happens to fall in the same y<60 header band because the RIBOFLAVIN
# title sits high on the page — see module docstring. Silently dropped
# the whole monograph before this fix, confirmed via a whole-book
# `cli validate` run and by rendering the page to an image.
candidates = [("1244", 10.0), ("2", 5.83)]
assert pick_folio(candidates) == 1244
def test_genuine_same_size_conflict_still_returns_none():
# two same-size digit-only candidates: real ambiguity, must not guess
candidates = [("101", 10.0), ("205", 10.0)]
assert pick_folio(candidates) is None
+67
View File
@@ -0,0 +1,67 @@
from ingestion.extract.spans import classify_column, _sort_blocks_reading_order
def _block(x0, y0, x1, y1):
return {"bbox": (x0, y0, x1, y1)}
def testclassify_column_left():
assert classify_column((35.0, 100.0, 280.0, 120.0)) == "left"
def testclassify_column_right():
assert classify_column((299.0, 100.0, 553.0, 120.0)) == "right"
def testclassify_column_full_width_header():
assert classify_column((35.0, 34.0, 552.0, 48.0)) == "full_width"
def testclassify_column_none_bbox_is_unknown():
assert classify_column(None) == "unknown"
def test_confirmed_real_oxymetazolin_page_reversed_order_is_corrected():
# exact bboxes from physical page 1100 (the OXYBUTYNIN/OXYMETAZOLIN
# boundary — see spans.py module docstring): PyMuPDF's raw block order
# is [header, right x7, left x8], right column before left. An earlier
# version of this module trusted that raw order, silently attributing
# OXYMETAZOLIN's "Chống chỉ định" (right column) to the still-open
# OXYBUTYNIN monograph. Confirmed via a whole-book cli validate run,
# a whole-document cross-tool character-diff, and rendering the page.
raw_order = [
_block(34.96, 34.39, 552.10, 47.72), # 0: full_width header
_block(299.39, 60.46, 553.72, 121.96), # 1: right
_block(299.39, 124.33, 553.72, 368.94), # 2: right
_block(299.39, 371.31, 553.72, 408.39), # 3: right
_block(35.43, 60.77, 289.77, 330.56), # 4: left (Xử trí: ...)
_block(35.43, 379.21, 231.23, 391.87), # 5: left (Tên chung quốc tế)
]
sorted_blocks = _sort_blocks_reading_order(raw_order)
columns_in_order = [classify_column(b["bbox"]) for b in sorted_blocks]
assert columns_in_order == ["full_width", "left", "left", "right", "right", "right"]
def test_already_correct_order_is_left_unchanged_in_content():
blocks = [
_block(35.0, 60.0, 280.0, 100.0), # left
_block(35.0, 110.0, 280.0, 150.0), # left, further down
_block(299.0, 60.0, 553.0, 100.0), # right
]
sorted_blocks = _sort_blocks_reading_order(blocks)
assert sorted_blocks == blocks
def test_a_narrow_box_between_the_columns_belongs_to_the_right_column():
"""The two tolerance bands overlap between x=288 and x=319.
Testing left first put everything in that strip in the left column. It is
invisible for a full-width block and wrong for a narrow one: a single 4pt
glyph at x=315 on physical page 714 was classified left, so the ''
missing from "Độ ổn định" could not be matched to its own line and the
corruption survived the repair.
"""
assert classify_column((313.7, 506.2, 317.8, 514.8)) == "right"
assert classify_column((35.4, 500.0, 289.7, 510.0)) == "left"
# a box that lands in neither range still resolves by tolerance
assert classify_column((300.0, 500.0, 305.0, 510.0)) == "left"
+95
View File
@@ -0,0 +1,95 @@
from ingestion.extract.models import Span
from ingestion.normalize import (
PUA_SUBSTITUTIONS,
find_unmapped_pua,
group_visual_lines,
join_spans,
substitute_pua,
)
def _span(text, *, page=100, block=0, line=0, index=0, x0=50.0, x1=None, y0=100.0):
return Span(
physical_page=page, printed_page=page + 1, column="left",
block=block, line=line, span_index=index,
x0=x0, y0=y0, x1=(x0 + len(text) * 4.5) if x1 is None else x1, y1=y0 + 10,
text=text, font="Tiger", size=9.5,
)
def test_pua_map_covers_every_codepoint_confirmed_in_the_corpus():
# all 8 were located in the source PDF, rendered, and read visually —
# see docs/progress-log.md for the page each was confirmed on
assert PUA_SUBSTITUTIONS[""] == ""
assert PUA_SUBSTITUTIONS[""] == ""
assert PUA_SUBSTITUTIONS[""] == "α"
assert PUA_SUBSTITUTIONS[""] == ""
assert PUA_SUBSTITUTIONS[""] == "®"
assert PUA_SUBSTITUTIONS[""] == ""
assert PUA_SUBSTITUTIONS[""] == ""
assert PUA_SUBSTITUTIONS[""] == "γ"
def test_comparison_operators_in_real_dosing_sentences_are_restored():
# the clinically dangerous case: without this, "liều ≤ 100 mg" reaches
# embeddings as "liều  100 mg" and the operator is lost
assert substitute_pua("trẻ em  10 tuổi") == "trẻ em ≥ 10 tuổi"
assert substitute_pua("liều  100 mg") == "liều ≤ 100 mg"
def test_unmapped_pua_is_reported_not_silently_passed_through():
assert find_unmapped_pua("liều  100 mg") == []
assert find_unmapped_pua("bất ngờ  đây") == [""]
def test_subscript_span_rejoins_without_a_spurious_space():
# real corpus case: "cytochrom P450" arrived as "cytochrom P\n450\ngây"
spans = [
_span("cytochrom P", x0=50.0, x1=100.0),
_span("450", x0=100.2, x1=110.0),
_span(" gây chuyển hóa.", x0=110.1, x1=180.0),
]
assert join_spans(spans) == "cytochrom P450 gây chuyển hóa."
def test_italic_run_inside_parentheses_rejoins_on_one_line():
# real corpus case: "(\nfeline immunodeficiency virus\n)"
spans = [
_span("(", x0=50.0, x1=53.0),
_span("feline immunodeficiency virus", x0=53.1, x1=180.0),
_span(")", x0=180.1, x1=183.0),
]
assert join_spans(spans) == "(feline immunodeficiency virus)"
def test_wrap_without_sentence_end_is_joined_with_a_space():
spans = [
_span("không nhai. Nếu", line=0, y0=100.0),
_span("uống viên thuốc", line=1, y0=112.0),
]
assert join_spans(spans) == "không nhai. Nếu uống viên thuốc"
def test_sentence_end_keeps_the_line_break():
spans = [
_span("Liều người lớn: 10 mg.", line=0, y0=100.0),
_span("Trẻ em: 5 mg.", line=1, y0=112.0),
]
assert join_spans(spans) == "Liều người lớn: 10 mg.\nTrẻ em: 5 mg."
def test_wide_gap_on_one_line_still_yields_a_space():
spans = [
_span("Người bệnh", x0=50.0, x1=100.0),
_span("100 kg", x0=104.0, x1=130.0),
]
assert join_spans(spans) == "Người bệnh 100 kg"
def test_visual_lines_group_by_pymupdf_block_and_line_indices():
spans = [
_span("a", block=0, line=0), _span("b", block=0, line=0),
_span("c", block=0, line=1),
_span("d", block=1, line=0),
]
assert [len(g) for g in group_visual_lines(spans)] == [2, 1, 1]
+332
View File
@@ -0,0 +1,332 @@
import pytest
from ingestion.extract.models import Span
from ingestion.segment.assembler import DuplicateDrugIdError, assemble
def _span(text, page, y0, bold=True, size=9.5, printed=None, column="left"):
return Span(
physical_page=page, printed_page=printed if printed is not None else page + 1,
column=column, block=0, line=0, span_index=0,
x0=100.0, y0=y0, x1=200.0, y1=y0 + 12.0,
text=text, font=("TimesNewRomanPS-BoldMT" if bold else "TimesNewRomanPSMT"), size=size,
)
def test_basic_single_monograph_with_sections_and_body():
spans = [
_span("ABACAVIR", 100, 60.0),
_span("Tên chung quốc tế:", 100, 80.0),
_span("Abacavir (Acyclovir-like).", 100, 92.0, bold=False),
_span("Mã ATC:", 100, 104.0),
_span("J05AF06", 100, 116.0, bold=False),
_span("Chỉ định", 101, 60.0),
_span("Điều trị nhiễm HIV.", 101, 72.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
m = monographs[0]
assert m.drug_id == "abacavir"
assert m.drug_name == "ABACAVIR"
assert m.source_page_range == [100, 101]
assert m.sections["ten_chung_quoc_te"].text == "Abacavir (Acyclovir-like)."
assert m.sections["chi_dinh"].text == "Điều trị nhiễm HIV."
assert m.atc_codes == ["J05AF06"]
assert m.atc_stated_absent is False
def test_non_bold_combined_heading_value_span_confirmed_real_amitriptylin_case():
# AMITRIPTYLIN's real "Mã ATC:" heading is a single non-bold span
# combining label and value ("Mã ATC: N06AA09."), unlike Abacavir's
# bold-label + separate-value spans — see outlier item 20.
spans = [
_span("AMITRIPTYLIN", 184, 60.0),
_span("Tên chung quốc tế: ", 184, 85.0),
_span("Amitriptyline.", 184, 85.2, bold=False),
_span("Mã ATC: N06AA09.", 184, 100.0, bold=False),
_span("Loại thuốc:", 184, 115.0),
_span("Thuốc chống trầm cảm.", 184, 115.2, bold=False),
]
m = list(assemble(spans))[0]
assert m.sections["ma_atc"].text == "N06AA09."
assert m.atc_codes == ["N06AA09"]
def test_atc_stated_absent_propagates():
spans = [
_span("ADIPIODON", 100, 60.0),
_span("Tên chung quốc tế:", 100, 72.0),
_span("Adipiodon.", 100, 84.0, bold=False),
_span("Mã ATC:", 100, 96.0),
_span("Chưa có.", 100, 108.0, bold=False),
]
m = list(assemble(spans))[0]
assert m.atc_codes == []
assert m.atc_stated_absent is True
def test_qualifier_line_disambiguates_same_name_monographs():
# reproduces the confirmed real SALBUTAMOL case (outlier item 18):
# same base title, disambiguated by a bold non-caps parenthesized line.
spans = [
_span("SALBUTAMOL", 1261, 60.0),
_span("(Dùng trong hô hấp)", 1261, 72.0),
_span("Tên chung quốc tế:", 1261, 84.0),
_span("Salbutamol.", 1261, 96.0, bold=False),
_span("Chỉ định", 1261, 108.0),
_span("Điều trị hen.", 1261, 120.0, bold=False),
_span("SALBUTAMOL", 1263, 60.0),
_span("(Dùng trong sản khoa)", 1263, 72.0),
_span("Tên chung quốc tế:", 1263, 84.0),
_span("Salbutamol.", 1263, 96.0, bold=False),
_span("Chỉ định", 1263, 108.0),
_span("Điều trị dọa sinh non.", 1263, 120.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 2
assert monographs[0].drug_id == "salbutamol_dung_trong_ho_hap"
assert monographs[0].drug_name == "SALBUTAMOL (Dùng trong hô hấp)"
assert monographs[1].drug_id == "salbutamol_dung_trong_san_khoa"
assert monographs[0].sections["chi_dinh"].text == "Điều trị hen."
assert monographs[1].sections["chi_dinh"].text == "Điều trị dọa sinh non."
def test_genuine_duplicate_drug_id_raises():
spans = [
_span("FOOBARDRUG", 200, 60.0),
_span("Tên chung quốc tế:", 200, 72.0),
_span("Foobardrug.", 200, 84.0, bold=False),
_span("Chỉ định", 200, 96.0),
_span("A.", 200, 108.0, bold=False),
_span("FOOBARDRUG", 300, 60.0),
_span("Tên chung quốc tế:", 300, 72.0),
_span("Foobardrug.", 300, 84.0, bold=False),
_span("Chỉ định", 300, 96.0),
_span("B.", 300, 108.0, bold=False),
]
with pytest.raises(DuplicateDrugIdError):
list(assemble(spans))
def test_gonadotropin_wrap_does_not_falsely_trigger_duplicate_check():
# regression: the multi-line wrap must merge BEFORE the duplicate check
# runs, so this is never treated as two separate "GONADOTROPIN" titles
spans = [
_span("THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG", 1371, 664.4554443359375),
_span("GONADOTROPIN", 1371, 676.2354736328125),
_span("Tên chung quốc tế:", 1371, 690.0),
_span("Gonadorelin.", 1371, 700.0, bold=False),
_span("Chỉ định", 1371, 712.0),
_span("X.", 1371, 724.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_name == "THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG GONADOTROPIN"
def test_front_matter_before_first_monograph_is_ignored():
spans = [
_span("Some front matter heading", 5, 60.0, bold=False, printed=6),
_span("random body text", 5, 72.0, bold=False, printed=6),
_span("ABACAVIR", 100, 60.0),
_span("Tên chung quốc tế:", 100, 80.0),
_span("Abacavir.", 100, 92.0, bold=False),
_span("Chỉ định", 100, 104.0),
_span("X.", 100, 116.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_id == "abacavir"
def test_empty_spans_yields_nothing():
assert list(assemble([])) == []
def test_table_header_false_positive_not_treated_as_monograph():
# reproduces the confirmed real "HSV"/"CMV" table-column-header case
# (outlier item 19, physical page 698, inside the Foscarnet natri
# monograph's dosing table) — bold+all-caps+short, identical shape to a
# real title, but never followed by "Tên chung quốc tế" before the next
# real title. Must not be treated as a monograph boundary.
spans = [
_span("FOSCARNET NATRI", 690, 60.0),
_span("Tên chung quốc tế:", 690, 80.0),
_span("Foscarnet.", 690, 92.0, bold=False),
_span("Chỉ định", 690, 104.0),
_span("Điều trị CMV.", 690, 116.0, bold=False),
_span("HSV", 698, 523.0),
_span("HSV", 698, 523.0),
_span("CMV", 698, 523.0),
_span("CMV", 698, 523.0),
_span("40 mg/kg cách nhau 12 giờ", 698, 540.0, bold=False),
_span("ARTEMETHER", 700, 60.0),
_span("Tên chung quốc tế:", 700, 80.0),
_span("Artemether.", 700, 92.0, bold=False),
]
monographs = list(assemble(spans))
assert [m.drug_id for m in monographs] == ["foscarnet_natri", "artemether"]
# the table row's numbers/labels stay attached to Foscarnet's Chỉ định
# section body (dropped from a dedicated section, which is fine — no
# false monograph boundary is what matters here)
assert "hsv" not in monographs[0].drug_id
assert "cmv" not in monographs[0].drug_id
def test_real_title_immediately_followed_by_anchor_is_kept():
spans = [
_span("ABACAVIR", 100, 60.0),
_span("Tên chung quốc tế:", 100, 80.0),
_span("Abacavir.", 100, 92.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_id == "abacavir"
def test_real_title_with_qualifier_before_anchor_is_still_kept():
# the anchor lookahead must tolerate one intervening qualifier-line
# event (the SALBUTAMOL case), not just immediate adjacency
spans = [
_span("SALBUTAMOL", 1261, 60.0),
_span("(Dùng trong hô hấp)", 1261, 72.0),
_span("Tên chung quốc tế:", 1261, 84.0),
_span("Salbutamol.", 1261, 96.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_id == "salbutamol_dung_trong_ho_hap"
def test_class_level_monograph_sub_heading_not_treated_as_own_monograph():
# reproduces the confirmed real case (outlier item 21): "SIMVASTATIN" is
# a bold+all-caps+short sub-heading *inside* the class-level "CÁC CHẤT
# ỨC CHẾ HMG-CoA REDUCTASE" monograph, immediately followed by its own
# "Liều lượng và cách dùng" but NOT by "Tên chung quốc tế" (that section
# belongs only to the parent). Must stay folded into the parent, not
# become its own monograph.
spans = [
_span("CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE", 284, 60.0, printed=285),
_span("Tên chung quốc tế:", 284, 72.0, printed=285),
_span("Simvastatin, Lovastatin.", 284, 84.0, bold=False, printed=285),
_span("Chỉ định", 284, 96.0, printed=285),
_span("Tăng lipid huyết.", 284, 108.0, bold=False, printed=285),
_span("SIMVASTATIN", 285, 60.0, printed=286),
_span("Liều lượng và cách dùng", 285, 72.0, printed=286),
_span("Uống 10 - 20 mg mỗi tối.", 285, 84.0, bold=False, printed=286),
_span("LOVASTATIN", 285, 96.0, printed=286),
_span("Liều lượng và cách dùng", 285, 108.0, printed=286),
_span("Uống 20 mg mỗi ngày.", 285, 120.0, bold=False, printed=286),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_name == "CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE"
# the sub-headings' own dosing text stays attached to the parent
# monograph's content rather than vanishing or becoming new monographs
assert "Uống 20 mg mỗi ngày." in monographs[0].sections["lieu_luong_va_cach_dung"].text
def test_running_header_boilerplate_stripped_from_mid_section_body_confirmed_real_morphin_case():
# exact confirmed real case: physical page 1008's running header
# ("DTQGVN 2" / "1009" / "Morphin sulfat", all column="full_width",
# y0~34, well inside the header band) falls squarely in the middle of
# MORPHIN SULFAT's "Liều lượng và cách dùng" section, which spans the
# page 1007->1008 boundary — see outlier-catalog item 13 / assembler.py
# module docstring. Whole-corpus measured: 1,374/11,409 sections (12.0%)
# affected before this fix, 671/682 monographs (98.4%) had at least one.
spans = [
_span("MORPHIN SULFAT", 1007, 60.0),
_span("Tên chung quốc tế:", 1007, 80.0),
_span("Morphini sulfas.", 1007, 92.0, bold=False),
_span("Liều lượng và cách dùng", 1007, 700.0),
_span("Với thuốc viên (viên nang hoặc viên nén) không nhai. Nếu", 1007, 785.4, bold=False),
_span("DTQGVN 2", 1008, 34.6, bold=False, column="full_width"),
_span("1009", 1008, 34.6, bold=False, column="full_width"),
_span("Morphin sulfat", 1008, 34.4, bold=False, column="full_width"),
_span("uống viên thuốc giải phóng chậm thì không được nghiền.", 1008, 60.8, bold=False),
]
m = list(assemble(spans))[0]
section_text = m.sections["lieu_luong_va_cach_dung"].text
assert "DTQGVN" not in section_text
assert "1009" not in section_text
# the two body spans are one sentence broken by a page boundary: "Nếu"
# does not end a sentence, so normalize/text_flow rejoins them with a
# space rather than preserving the PDF's visual wrap as a hard newline
assert section_text == (
"Với thuốc viên (viên nang hoặc viên nén) không nhai. Nếu "
"uống viên thuốc giải phóng chậm thì không được nghiền."
)
def test_last_real_monograph_in_book_still_kept_near_end_of_input():
# anchor lookahead must not require a "next title" to exist — the very
# last monograph in the book has no following title at all
spans = [
_span("ZOLPIDEM", 1494, 60.0),
_span("Tên chung quốc tế:", 1494, 80.0),
_span("Zolpidem.", 1494, 92.0, bold=False),
]
monographs = list(assemble(spans))
assert len(monographs) == 1
assert monographs[0].drug_id == "zolpidem"
def test_repeated_section_heading_appends_instead_of_overwriting():
# measured real case: 33 monographs repeat a section heading (38
# occurrences). CEFAMANDOL's "Liều lượng và cách dùng" resumes on
# physical page 339 after a renal-dosing table; the old code replaced the
# SectionSpan, destroying everything captured before the repeat — for
# CEFAMANDOL that left the dosing section holding only the table.
spans = [
_span("CEFAMANDOL", 338, 60.0),
_span("Tên chung quốc tế", 338, 80.0),
_span("Cefamandolum.", 338, 92.0, bold=False),
_span("Liều lượng và cách dùng", 338, 400.0),
_span("Người lớn: 500 mg - 1 g, 4 - 8 giờ/lần.", 338, 412.0, bold=False),
_span("Liều lượng và cách dùng", 339, 200.0),
_span("Suy thận: giảm liều theo độ thanh thải creatinin.", 339, 212.0, bold=False),
]
m = list(assemble(spans))[0]
text = m.sections["lieu_luong_va_cach_dung"].text
assert "Người lớn: 500 mg - 1 g, 4 - 8 giờ/lần." in text
assert "Suy thận: giảm liều theo độ thanh thải creatinin." in text
# the first heading stays the provenance anchor
assert m.sections["lieu_luong_va_cach_dung"].heading.physical_page == 338
def test_a_plain_label_line_under_a_heading_is_body_not_a_new_section():
"""FLUOROURACIL, physical page 681 — verified by rendering the page.
The book prints "Thời kỳ mang thai" / "Chống chỉ định." and "Thời kỳ cho
con bú" / "Chống chỉ định.". The body line matches the section vocabulary,
so it was read as a heading and both sections came out empty — dropping
the statement that fluorouracil is contraindicated in pregnancy and while
breastfeeding.
"""
spans = [
_span("FLUOROURACIL", 681, 60.0),
_span("Tên chung quốc tế", 681, 80.0),
_span("Fluorouracilum.", 681, 92.0, bold=False),
_span("Chống chỉ định", 681, 110.0),
_span("Suy tủy nặng.", 681, 122.0, bold=False),
_span("Thời kỳ mang thai", 681, 140.0),
_span("Chống chỉ định.", 681, 152.0, bold=False),
_span("Thời kỳ cho con bú", 681, 170.0),
_span("Chống chỉ định.", 681, 182.0, bold=False),
]
monograph = list(assemble(spans))[0]
assert monograph.sections["thoi_ky_mang_thai"].text == "Chống chỉ định."
assert monograph.sections["thoi_ky_cho_con_bu"].text == "Chống chỉ định."
assert monograph.sections["chong_chi_dinh"].text == "Suy tủy nặng."
def test_a_bold_label_line_still_opens_its_section():
spans = [
_span("FLUOROURACIL", 681, 60.0),
_span("Tên chung quốc tế", 681, 80.0),
_span("Fluorouracilum.", 681, 92.0, bold=False),
_span("Chống chỉ định", 681, 110.0),
_span("Suy tủy nặng.", 681, 122.0, bold=False),
]
monograph = list(assemble(spans))[0]
assert monograph.sections["chong_chi_dinh"].text == "Suy tủy nặng."
+144
View File
@@ -0,0 +1,144 @@
from ingestion.segment.atc import extract_atc_codes, is_stated_absent, normalize_atc_candidate
def test_stray_whitespace_split_j04a_c01_recovered():
assert normalize_atc_candidate("J04A C01") == "J04AC01"
def test_stray_whitespace_split_n05b_a06_recovered():
assert normalize_atc_candidate("N05B A06") == "N05BA06"
def test_stray_whitespace_split_l01x_x02_recovered():
assert normalize_atc_candidate("L01X X02") == "L01XX02"
def test_digit_letter_confusion_no3ax12_recovered():
assert normalize_atc_candidate("NO3AX12") == "N03AX12"
def test_digit_letter_confusion_jo1dc07_recovered():
assert normalize_atc_candidate("JO1DC07") == "J01DC07"
def test_clean_code_passes_through():
assert normalize_atc_candidate("N03AX12") == "N03AX12"
def test_garbage_not_recovered():
assert normalize_atc_candidate("NOT AN ATC CODE") is None
assert normalize_atc_candidate("") is None
def test_stated_absent_chua_co():
assert is_stated_absent("Mã ATC: Chưa có.") is True
def test_stated_absent_khong_co():
assert is_stated_absent("Không có.") is True
def test_stated_present_not_flagged_absent():
assert is_stated_absent("N03AX12") is False
def test_extract_single_code():
result = extract_atc_codes("N03AX12")
assert result.codes == ["N03AX12"]
assert result.stated_absent is False
def test_extract_multi_code_insulin_style():
result = extract_atc_codes("A10AB01, A10AC01, A10AD01")
assert result.codes == ["A10AB01", "A10AC01", "A10AD01"]
def test_extract_multi_code_with_noise_mixed_in():
# one clean code, one noisy code recovered, matching the real corpus
# pattern where a monograph has some clean and some noisy ATC entries
result = extract_atc_codes("N03AX12, J04A C01")
assert result.codes == ["N03AX12", "J04AC01"]
def test_extract_stated_absent_returns_no_codes():
result = extract_atc_codes("Mã ATC: Chưa có.")
assert result.codes == []
assert result.stated_absent is True
def test_trailing_period_recovered_confirmed_real_abacavir_case():
# real field text is "J05AF06." — a sentence-ending period, not part of
# the code; an earlier version silently produced zero codes here.
assert normalize_atc_candidate("J05AF06.") == "J05AF06"
result = extract_atc_codes("J05AF06.")
assert result.codes == ["J05AF06"]
def test_species_annotation_stripped_confirmed_real_insulin_case():
# annotation-stripping is extract_atc_codes's job (must run before the
# comma/semicolon split, see below) — normalize_atc_candidate itself
# only normalizes an already-isolated code token.
result = extract_atc_codes("A10AB01 (người); A10AB02 (bò)")
assert result.codes == ["A10AB01", "A10AB02"]
def test_leading_colon_from_value_span_stripped_confirmed_real_alcuronium_case():
# real field text for ALCURONIUM CLORID (physical page 152): the bold
# label span is "Mã ATC" with no colon, and the plain value span is
# ": M03AA01." — the colon belongs to the value side here, not the
# label side (Abacavir's equivalent has it on the label side instead:
# "Mã ATC: " + "J05AF06."). See atc.py module docstring, defect 5.
assert normalize_atc_candidate(": M03AA01.") == "M03AA01"
result = extract_atc_codes(": M03AA01.")
assert result.codes == ["M03AA01"]
def test_name_prefixed_code_stripped_confirmed_real_arginin_case():
# real field text for ARGININ (physical page 204): two salt forms, each
# its own "Name: CODE" line, not a bare code — see atc.py module
# docstring, defect 6.
assert normalize_atc_candidate("Arginin glutamat: A05BA01") == "A05BA01"
result = extract_atc_codes("Arginin glutamat: A05BA01\nArginin hydroclorid: B05XB01")
assert result.codes == ["A05BA01", "B05XB01"]
def test_plain_code_with_no_colon_still_normalizes():
assert normalize_atc_candidate("N03AX12") == "N03AX12"
def test_reversed_code_first_shape_confirmed_real_hmg_coa_case():
# real field text for CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE (physical page
# 284): each statin is "CODE: Name", the opposite order from the
# "Name: CODE" shape above — see atc.py module docstring, defect 7.
# "C10A A01" also has the already-fixed stray-whitespace split.
assert normalize_atc_candidate("C10A A01: Simvastatin") == "C10AA01"
result = extract_atc_codes("C10A A01: Simvastatin\nC10A A02: Lovastatin")
assert result.codes == ["C10AA01", "C10AA02"]
def test_annotation_containing_a_comma_does_not_break_the_split_confirmed_vaccine_case():
# real field text for VẮC XIN SỞI (physical page 1437): the English
# annotation "(Measles, live attenuated)" contains its own comma. An
# earlier version split on "," *before* stripping the annotation,
# breaking "J07BD01 (Measles, live attenuated)." into two unrecoverable
# fragments and silently returning zero codes — see atc.py module
# docstring, defect 4.
result = extract_atc_codes("J07BD01 (Measles, live attenuated).")
assert result.codes == ["J07BD01"]
def test_extract_all_20_insulin_codes_from_real_field_text():
# exact real field text for INSULIN (physical page 809) — see atc.py
# module docstring; confirms the fix recovers all 20, not just 2.
field_text = (
"A10AB01 (người); A10AB02 (bò); A10AB03 (lợn);\n"
"A10AB04 (lispro); A10AB05 (aspart); A10AB06 (glulisin);\n"
"A10AC01 (người); A10AC02 (bò); A10AC03 (lợn); A10AC04\n"
"(lispro); A10AD01 (người), A10AD02 (bò), A10AD03 (lợn),\n"
"A10AD04 (lispro), A10AE01 (người); A10AE02 (bò); A10AE03\n"
"(lợn); A10AE04 (glargin); A10AE05 (detemir), A10AF01 (người)."
)
result = extract_atc_codes(field_text)
assert len(result.codes) == 20
assert "A10AB01" in result.codes
assert "A10AF01" in result.codes
+88
View File
@@ -0,0 +1,88 @@
from ingestion.extract.models import Span
from ingestion.segment.detector import detect_monograph_titles, detect_section_headings
def _span(text, physical_page, printed_page, y0=100.0, bold=True, size=10.0):
font = "TimesNewRomanPS-BoldMT" if bold else "TimesNewRomanPSMT"
return Span(
physical_page=physical_page, printed_page=printed_page, column="left",
block=0, line=0, span_index=0,
x0=100.0, y0=y0, x1=200.0, y1=y0 + 12.0,
text=text, font=font, size=size,
)
def test_confirmed_part_divider_excluded_at_page_99_boundary():
# "CÁC CHUYÊN LUẬN THUỐC" at physical page 98 / printed 99 — bold,
# all-caps, short: identical shape to a real title, must be excluded.
spans = [_span("CÁC CHUYÊN LUẬN THUỐC", 98, 99), _span("ABACAVIR", 100, 101)]
titles = [h.text for h in detect_monograph_titles(spans)]
assert titles == ["ABACAVIR"]
def test_monograph_title_outside_page_range_excluded():
# bold all-caps short text in front matter (e.g. an org name) must not
# be picked up — scoping to printed 99-1496 is required, not optional.
spans = [_span("BỘ Y TẾ", 2, 3), _span("ABACAVIR", 100, 101)]
titles = [h.text for h in detect_monograph_titles(spans)]
assert titles == ["ABACAVIR"]
def test_gonadotropin_wrap_detected_as_one_title():
spans = [
_span("THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG", 1371, 1372, y0=664.4554443359375),
_span("GONADOTROPIN", 1371, 1372, y0=676.2354736328125),
]
titles = [h.text for h in detect_monograph_titles(spans)]
assert titles == ["THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG GONADOTROPIN"]
def test_non_bold_all_caps_text_not_a_title_candidate():
spans = [_span("NOT BOLD BUT CAPS", 100, 101, bold=False)]
assert list(detect_monograph_titles(spans)) == []
def test_lowercase_bold_text_not_a_title_candidate():
spans = [_span("Abacavir", 100, 101)]
assert list(detect_monograph_titles(spans)) == []
def test_short_section_label_with_normal_diacritic_not_a_title_candidate():
# regression: an earlier absolute-count (not ratio) version of the
# mixed-case tolerance let "Mã ATC:" through as a false title candidate
# — its single lowercase diacritic ('ã') is normal Vietnamese
# orthography, not a HMG-CoA-style embedded abbreviation. A ratio
# threshold correctly rejects this short label (1/5 = 20% lowercase)
# while still accepting the long HMG-CoA title (1/27 = 3.7%).
spans = [_span("Mã ATC:", 100, 101)]
assert list(detect_monograph_titles(spans)) == []
def test_confirmed_hmg_coa_mixed_case_title_still_detected():
# "CoA" (Coenzyme A) is a real mixed-case abbreviation embedded in an
# otherwise all-caps title — outlier item 21. A strict isupper() check
# silently dropped this entire class-level monograph from the corpus.
spans = [_span("CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE", 284, 285)]
titles = [h.text for h in detect_monograph_titles(spans)]
assert titles == ["CÁC CHẤT ỨC CHẾ HMG-CoA REDUCTASE"]
def test_section_heading_matched_with_and_without_trailing_colon():
spans = [
_span("Tên chung quốc tế:", 100, 101, bold=True, size=9.5),
_span("Chỉ định", 100, 101, bold=True, size=9.5),
]
headings = list(detect_section_headings(spans))
assert [h.section_key for h in headings] == ["ten_chung_quoc_te", "chi_dinh"]
def test_unknown_bold_text_not_matched_as_section():
# e.g. "Cách dùng:" — a real sub-heading within "Liều lượng và cách
# dùng" that is NOT one of the known top-level section names.
spans = [_span("Cách dùng:", 100, 101, bold=True, size=9.5)]
assert list(detect_section_headings(spans)) == []
def test_section_heading_outside_monograph_range_excluded():
spans = [_span("Chỉ định", 5, 6, bold=True, size=9.5)]
assert list(detect_section_headings(spans)) == []
+72
View File
@@ -0,0 +1,72 @@
from ingestion.segment.io import read_monographs_jsonl, write_monographs_jsonl
from ingestion.segment.models import Heading, Monograph, SectionSpan
def test_round_trip_preserves_all_fields(tmp_path):
heading = Heading(text="Chỉ định", physical_page=100, y0=80.0, is_monograph_title=False, section_key="chi_dinh")
section = SectionSpan(key="chi_dinh", display_name="Chỉ định", heading=heading, text="Điều trị nhiễm HIV.")
monograph = Monograph(
drug_id="abacavir", drug_name="ABACAVIR", source_page_range=[100, 101],
sections={"chi_dinh": section}, atc_codes=["J05AF06"], atc_stated_absent=False,
)
path = tmp_path / "monographs.jsonl"
count = write_monographs_jsonl([monograph], path)
assert count == 1
result = list(read_monographs_jsonl(path))
assert len(result) == 1
r = result[0]
assert r.drug_id == "abacavir"
assert r.drug_name == "ABACAVIR"
assert r.source_page_range == [100, 101]
assert r.atc_codes == ["J05AF06"]
assert r.sections["chi_dinh"].text == "Điều trị nhiễm HIV."
assert r.sections["chi_dinh"].heading.section_key == "chi_dinh"
def test_multiple_monographs_round_trip(tmp_path):
m1 = Monograph(drug_id="a", drug_name="A", source_page_range=[1, 2])
m2 = Monograph(drug_id="b", drug_name="B", source_page_range=[3, 4])
path = tmp_path / "monographs.jsonl"
write_monographs_jsonl([m1, m2], path)
result = list(read_monographs_jsonl(path))
assert [r.drug_id for r in result] == ["a", "b"]
def test_empty_write_produces_empty_file(tmp_path):
path = tmp_path / "monographs.jsonl"
count = write_monographs_jsonl([], path)
assert count == 0
assert list(read_monographs_jsonl(path)) == []
def test_table_blocks_survive_a_write_read_round_trip(tmp_path):
# the lifted table blocks were being computed in memory and then dropped
# at the file boundary — 148 blocks existed in the run summary but the
# JSONL had no "tables" key at all
from ingestion.segment.models import Heading, Monograph, SectionSpan, TableBlock
from ingestion.segment.io import read_monographs_jsonl, write_monographs_jsonl
heading = Heading(text="Liều lượng và cách dùng", physical_page=339, y0=200.0,
is_monograph_title=False, section_key="lieu_luong_va_cach_dung")
m = Monograph(
drug_id="cefamandol", drug_name="CEFAMANDOL", source_page_range=[338, 340],
sections={"lieu_luong_va_cach_dung": SectionSpan(
key="lieu_luong_va_cach_dung", display_name="Liều lượng và cách dùng",
heading=heading, text="Cách dùng ...")},
tables=[TableBlock(
table_id="p339_t0", shape="simple_table", physical_page=339,
bbox=[40.0, 380.0, 400.0, 620.0],
section_key="lieu_luong_va_cach_dung",
text="80 - 50 750 mg - 2 g, 6 giờ/lần.", quarantined=True)],
)
path = tmp_path / "m.jsonl"
write_monographs_jsonl([m], path)
back = list(read_monographs_jsonl(path))[0]
assert len(back.tables) == 1
t = back.tables[0]
assert t.table_id == "p339_t0"
assert t.physical_page == 339
assert t.bbox == [40.0, 380.0, 400.0, 620.0]
assert t.quarantined is True
assert "750 mg - 2 g" in t.text
+134
View File
@@ -0,0 +1,134 @@
from ingestion.extract.models import Span
from ingestion.segment.merge import merge_multiline_headings, merge_same_line_bold_fragments
def _span(text, page, y0, size=9.5, font="TimesNewRomanPS-BoldMT"):
return Span(
physical_page=page, printed_page=page + 1, column="right",
block=0, line=0, span_index=0,
x0=100.0, y0=y0, x1=200.0, y1=y0 + 12.0,
text=text, font=font, size=size,
)
def test_confirmed_gonadotropin_wrap_merges_into_one_heading():
# exact bboxes from physical page 1371 (0-indexed) — see module docstring
candidates = [
_span("THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG", 1371, 664.4554443359375),
_span("GONADOTROPIN", 1371, 676.2354736328125),
]
headings = list(merge_multiline_headings(candidates))
assert len(headings) == 1
assert headings[0].text == "THUỐC TƯƠNG TỰ HORMON GIẢI PHÓNG GONADOTROPIN"
def test_unrelated_single_line_titles_on_different_pages_not_merged():
candidates = [
_span("GONADOTROPIN", 755, 200.0),
_span("HYDROCORTISON", 900, 300.0),
]
headings = list(merge_multiline_headings(candidates))
assert len(headings) == 2
assert [h.text for h in headings] == ["GONADOTROPIN", "HYDROCORTISON"]
def test_large_y_gap_on_same_page_not_merged():
# two genuinely separate single-line titles far apart on the same page
# (e.g. two short monographs stacked in one column) must not merge
candidates = [
_span("ATENOLOL", 219, 100.0),
_span("ATRACURIUM BESYLAT", 219, 500.0),
]
headings = list(merge_multiline_headings(candidates))
assert len(headings) == 2
def test_confirmed_aciclovir_same_line_split_merges_without_space():
# exact bboxes from physical page 113 (0-indexed), found by rendering the
# page to an image and reading it directly: "ACIC" (size 10.0) and
# "LOVIR" (size 9.5) are one word split into two spans on the same
# visual line — different font size, ~0.5pt y0 gap, near-zero x-gap.
# Must merge WITHOUT a space ("ACICLOVIR", not "ACIC LOVIR") — see
# module docstring.
candidates = [
_span("ACIC", 113, 515.1914672851562, size=10.0),
_span("LOVIR", 113, 515.7044677734375, size=9.5),
]
headings = list(merge_multiline_headings(candidates))
assert len(headings) == 1
assert headings[0].text == "ACICLOVIR"
def test_wrap_and_same_line_split_use_different_join_characters():
# a genuine line-wrap (large y-gap) still joins with a space even when
# font size differs, since size is no longer part of the merge decision
candidates = [
_span("FIRST LINE", 100, 200.0, size=10.0),
_span("SECOND LINE", 100, 212.0, size=9.5),
]
headings = list(merge_multiline_headings(candidates))
assert len(headings) == 1
assert headings[0].text == "FIRST LINE SECOND LINE"
def test_single_candidate_yields_one_heading():
headings = list(merge_multiline_headings([_span("ABACAVIR", 100, 60.29)]))
assert len(headings) == 1
assert headings[0].text == "ABACAVIR"
def test_empty_input_yields_nothing():
assert list(merge_multiline_headings([])) == []
def test_confirmed_ten_chung_quoc_te_diacritic_split_reassembles():
# exact fragments + y0 from physical page 759's "GUAIFENESIN" monograph,
# found via a whole-book `cli validate` run (the monograph was silently
# dropped because "Tên chung quốc tế" never matched the section
# vocabulary) and confirmed by rendering the page to an image: to a
# human reader the line looks completely normal, but PyMuPDF splits it
# into 5 spans around the diacritic characters — see module docstring.
fragments = [
_span("Tên chung qu", 759, 157.614),
_span("", 759, 157.33),
_span("c t", 759, 157.614),
_span("ế", 759, 157.33),
_span(": ", 759, 157.614),
]
merged = merge_same_line_bold_fragments(fragments)
assert len(merged) == 1
assert merged[0].text == "Tên chung quốc tế: "
def test_non_bold_spans_pass_through_unmerged():
fragments = [
_span("Guaifenesin", 759, 157.24, font="TimesNewRomanPSMT"),
_span(".", 759, 157.24, font="TimesNewRomanPSMT"),
]
merged = merge_same_line_bold_fragments(fragments)
assert len(merged) == 2
def test_bold_spans_on_different_lines_not_merged():
fragments = [_span("Chỉ định", 100, 200.0), _span("Chống chỉ định", 100, 220.0)]
merged = merge_same_line_bold_fragments(fragments)
assert len(merged) == 2
def test_merged_span_keeps_provenance_of_first_fragment():
fragments = [_span("Tên chung qu", 759, 157.614), _span("", 759, 157.33)]
merged = merge_same_line_bold_fragments(fragments)
assert merged[0].physical_page == 759
assert merged[0].printed_page == 760
assert merged[0].x0 == fragments[0].x0
assert merged[0].x1 == fragments[-1].x1
def test_single_bold_span_passes_through_unchanged():
fragments = [_span("ABACAVIR", 100, 60.29)]
merged = merge_same_line_bold_fragments(fragments)
assert merged == fragments
def test_empty_input_to_same_line_merge_yields_nothing():
assert merge_same_line_bold_fragments([]) == []
+108
View File
@@ -0,0 +1,108 @@
from ingestion.extract.models import Span
from ingestion.segment import assemble
from ingestion.tables import SHAPE_GRID_2D, SHAPE_SIMPLE, TableRegion, index_by_page
def _span(text, page, y0, *, bold=False, x0=50.0, block=0, line=0, column="left"):
return Span(
physical_page=page, printed_page=page + 1, column=column,
block=block, line=line, span_index=0,
x0=x0, y0=y0, x1=x0 + len(text) * 4.5, y1=y0 + 10,
text=text, font="Tiger-Bold" if bold else "Tiger", size=9.5,
)
def _monograph_spans(extra):
return [
_span("PARACETAMOL", 109, 60.0, bold=True),
_span("Tên chung quốc tế", 109, 80.0, bold=True),
_span("Paracetamolum.", 109, 92.0),
_span("Dạng thuốc và hàm lượng", 109, 200.0, bold=True),
] + extra
def test_table_spans_are_lifted_out_of_section_prose():
# real measured case: physical page 109's dosage-form table was being
# concatenated cell by cell into the section body
# ('Viên nén' + '1' + '1 - 4' + '8 - 12' + 'Viên nang tác' ...)
spans = _monograph_spans([
_span("Thuốc dùng đường uống.", 109, 220.0),
_span("Viên nén", 109, 400.0, block=5),
_span("1", 109, 400.0, block=5, x0=200.0),
_span("1 - 4", 109, 400.0, block=5, x0=260.0),
_span("Sau khi uống hấp thu nhanh.", 109, 600.0, block=9),
])
# the region must cover the table's first column too — it starts at the
# left margin, same x as body prose
region = TableRegion("p109_t0", 109, (40.0, 380.0, 400.0, 460.0), 3, 3, SHAPE_SIMPLE)
m = list(assemble(spans, table_index=index_by_page([region])))[0]
body = m.sections["dang_thuoc_va_ham_luong"].text
assert "Viên nén" not in body
assert "1 - 4" not in body
assert "Thuốc dùng đường uống." in body
assert "Sau khi uống hấp thu nhanh." in body
assert len(m.tables) == 1
block = m.tables[0]
assert block.table_id == "p109_t0"
assert "Viên nén" in block.text and "1 - 4" in block.text
assert block.section_key == "dang_thuoc_va_ham_luong"
assert block.physical_page == 109
# every multi-column table is quarantined until a real row/column
# reconstruction exists — its linearised text is not safe to cite as prose
assert block.quarantined is True
def test_without_a_region_map_behaviour_is_unchanged():
spans = _monograph_spans([
_span("Thuốc dùng đường uống.", 109, 220.0),
_span("Viên nén", 109, 400.0, block=5),
])
m = list(assemble(spans))[0]
assert m.tables == []
assert "Viên nén" in m.sections["dang_thuoc_va_ham_luong"].text
def test_2d_grid_block_is_quarantined():
# a 2D lookup grid's flattened text is meaningless without row/column
# headers (outlier item 7) — it must be marked, not silently embedded
spans = _monograph_spans([_span("0,52", 109, 400.0, block=5, x0=200.0)])
region = TableRegion("p109_t1", 109, (150.0, 380.0, 400.0, 460.0), 6, 5, SHAPE_GRID_2D)
m = list(assemble(spans, table_index=index_by_page([region])))[0]
assert len(m.tables) == 1
assert m.tables[0].quarantined is True
def test_non_table_regions_are_never_lifted():
# the 17 full-page false positives must not swallow a whole page of prose
spans = _monograph_spans([_span("Thuốc dùng đường uống.", 109, 220.0)])
region = TableRegion("p109_t0", 109, (0.0, 0.0, 595.3, 836.2), 1, 2,
"not_a_table_full_page")
m = list(assemble(spans, table_index=index_by_page([region])))[0]
assert m.tables == []
assert "Thuốc dùng đường uống." in m.sections["dang_thuoc_va_ham_luong"].text
def test_table_block_ids_stay_unique_when_a_section_resumes():
# a region flushed twice (section closes, then resumes) must not emit two
# blocks with the same table_id — provenance ids have to be unique
spans = [
_span("CEFAMANDOL", 339, 60.0, bold=True),
_span("Tên chung quốc tế", 339, 80.0, bold=True),
_span("Cefamandolum.", 339, 92.0),
_span("Liều lượng và cách dùng", 339, 200.0, bold=True),
_span("80 - 50", 339, 400.0, block=5),
_span("Liều lượng và cách dùng", 339, 500.0, bold=True),
_span("< 25 - 10", 339, 600.0, block=9),
]
region = TableRegion("p339_t0", 339, (40.0, 380.0, 400.0, 620.0), 5, 2, SHAPE_SIMPLE)
m = list(assemble(spans, table_index=index_by_page([region])))[0]
# table_id is deterministic per REGION, so two parts of one table share
# it on purpose; table_part_id is the unique key, derived from the first
# source span rather than a counter (a counter would renumber whenever
# anything upstream shifted, hiding rather than identifying a duplicate)
assert len({t.table_part_id for t in m.tables}) == len(m.tables)
assert {t.continuation_group for t in m.tables} == {"p339_t0"}
assert all(t.table_part_id.startswith("p339_t0@") for t in m.tables)
assert all(t.quarantined for t in m.tables)
+30
View File
@@ -0,0 +1,30 @@
from ingestion.segment.units import normalize_unit_token, validate_unit_tokens
def test_clean_unit_passes_through():
assert normalize_unit_token("mg") == "mg"
assert normalize_unit_token("mcg") == "mcg"
assert normalize_unit_token("mmol") == "mmol"
def test_stray_whitespace_split_recovered_by_analogy_to_atc():
assert normalize_unit_token("m g") == "mg"
assert normalize_unit_token("m cg") == "mcg"
def test_case_insensitive():
assert normalize_unit_token("MG") == "mg"
def test_unknown_token_not_recovered():
assert normalize_unit_token("xyz") is None
assert normalize_unit_token("") is None
def test_validate_unit_tokens_flags_only_bad_ones():
bad = validate_unit_tokens(["mg", "mcg", "xyz", "ml"])
assert bad == ["xyz"]
def test_validate_unit_tokens_empty_when_all_valid():
assert validate_unit_tokens(["mg", "mcg", "mmol"]) == []
+83
View File
@@ -0,0 +1,83 @@
from ingestion.segment.vocab import match_section, match_section_with_inline_value
def test_exact_label_match_with_trailing_colon():
d = match_section("Tên chung quốc tế:")
assert d is not None and d.key == "ten_chung_quoc_te"
def test_exact_label_match_without_trailing_colon():
d = match_section("Chỉ định")
assert d is not None and d.key == "chi_dinh"
def test_inline_value_combined_span_confirmed_real_amitriptylin_case():
# AMITRIPTYLIN's real "Mã ATC:" field is one non-bold span combining
# label and value: "Mã ATC: N06AA09." — see outlier item 20.
result = match_section_with_inline_value("Mã ATC: N06AA09.")
assert result is not None
section_def, value = result
assert section_def.key == "ma_atc"
assert value == "N06AA09."
def test_inline_value_not_matched_when_no_colon_follows():
assert match_section_with_inline_value("Mã ATC something else entirely") is None
def test_inline_value_does_not_confuse_plain_body_text():
assert match_section_with_inline_value("Bệnh nhân cần theo dõi chặt chẽ.") is None
def test_exact_match_takes_priority_over_prefix_for_label_only_span():
d = match_section("Mã ATC:")
assert d is not None and d.key == "ma_atc"
def test_real_spelling_variants_found_in_the_book_all_match():
# measured whole-corpus: 42 distinct near-miss heading strings, 542
# occurrences, none of which matched before aliases were added. The
# heaviest is "Thông tin qui chế" (469x) — the book prints "qui" where
# its own documented template says "quy", which cost 586 of 682
# monographs their thong_tin_quy_che section entirely.
from ingestion.segment.vocab import match_section
cases = {
"Thông tin qui chế": "thong_tin_quy_che",
"Thông tin về qui chế": "thong_tin_quy_che",
"Thông tin và quy chế": "thong_tin_quy_che",
"Mã ACT": "ma_atc",
"Chống chỉ đinh": "chong_chi_dinh",
"Thời kì mang thai": "thoi_ky_mang_thai",
"Thời kì cho con bú": "thoi_ky_cho_con_bu",
"Dược lí và cơ chế tác dụng": "duoc_ly_va_co_che_tac_dung",
"Hướng dẫn cách sử trí ADR": "huong_dan_xu_tri_adr",
"Quá liều và xử lý": "qua_lieu_va_xu_tri",
"Lọai thuốc": "loai_thuoc",
}
for text, expected_key in cases.items():
matched = match_section(text)
assert matched is not None, f"{text!r} should match a section"
assert matched.key == expected_key
def test_typesetting_noise_is_folded_without_needing_an_alias_each():
# missing/extra spaces and the Ð/Đ look-alike are handled by the lookup
# key, not enumerated per-variant
from ingestion.segment.vocab import match_section
assert match_section("Chỉđịnh").key == "chi_dinh"
assert match_section("Chống chỉđịnh").key == "chong_chi_dinh"
assert match_section("Độổn định và bảo quản").key == "do_on_dinh_va_bao_quan"
assert match_section("Ðộ ổn định và bảo quản").key == "do_on_dinh_va_bao_quan"
assert match_section("H ướng dẫn cách xử trí ADR").key == "huong_dan_xu_tri_adr"
assert match_section("Tư ơng kỵ").key == "tuong_ky"
assert match_section("Tác dụng khôngmong muốn (ADR)").key == "tac_dung_khong_mong_muon"
assert match_section("Thận trọng.").key == "than_trong"
def test_near_misses_that_are_not_sections_stay_unmatched():
# "Thể trọng" is body weight, not "Thận trọng" (caution) — a 0.84
# similarity that must NOT become an alias; the opioid string is a
# drug-specific sub-heading inside a section, not the section itself
from ingestion.segment.vocab import match_section
assert match_section("Thể trọng") is None
assert match_section("Tác dụng không mong muốn của opioid") is None
+107
View File
@@ -0,0 +1,107 @@
from ingestion.segment.models import Monograph
from ingestion.validation.back_index import GroundTruthEntry
from ingestion.validation.metrics import compute_recall_precision
def _mono(drug_id, drug_name, start_physical):
return Monograph(drug_id=drug_id, drug_name=drug_name, source_page_range=[start_physical, start_physical + 1])
def test_perfect_match_recall_and_precision_are_one():
monographs = [_mono("abacavir", "ABACAVIR", 100)]
ground_truth = [GroundTruthEntry(name="Abacavir", printed_page=101)]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
assert result.precision == 1.0
assert result.matched_count == 1
def test_missed_ground_truth_entry_lowers_recall_not_precision():
monographs = [_mono("abacavir", "ABACAVIR", 100)]
ground_truth = [
GroundTruthEntry(name="Abacavir", printed_page=101),
GroundTruthEntry(name="Acarbose", printed_page=103),
]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 0.5
assert result.precision == 1.0
assert len(result.unmatched_ground_truth) == 1
assert result.unmatched_ground_truth[0].name == "Acarbose"
def test_spurious_detected_monograph_lowers_precision_not_recall():
monographs = [
_mono("abacavir", "ABACAVIR", 100),
_mono("cac_chuyen_luan_thuoc", "CÁC CHUYÊN LUẬN THUỐC", 98),
]
ground_truth = [GroundTruthEntry(name="Abacavir", printed_page=101)]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
assert result.precision == 0.5
assert len(result.unmatched_detected) == 1
def test_page_tolerance_allows_small_offset():
monographs = [_mono("abacavir", "ABACAVIR", 100)]
ground_truth = [GroundTruthEntry(name="Abacavir", printed_page=103)] # +2 tolerance
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
def test_page_beyond_tolerance_does_not_match():
monographs = [_mono("abacavir", "ABACAVIR", 100)]
ground_truth = [GroundTruthEntry(name="Abacavir", printed_page=110)]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 0.0
def test_qualifier_suffixed_name_still_matches_base_ground_truth_name():
# SALBUTAMOL (Dùng trong hô hấp) should still match a ground-truth
# entry that just says "Salbutamol"
monographs = [_mono("salbutamol_dung_trong_ho_hap", "SALBUTAMOL (Dùng trong hô hấp)", 1261)]
ground_truth = [GroundTruthEntry(name="Salbutamol", printed_page=1262)]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
def test_empty_ground_truth_gives_zero_recall_not_error():
result = compute_recall_precision([_mono("a", "A", 1)], [])
assert result.recall == 0.0
def test_empty_monographs_gives_zero_precision_not_error():
result = compute_recall_precision([], [GroundTruthEntry(name="A", printed_page=1)])
assert result.precision == 0.0
assert result.recall == 0.0
def test_exact_match_preferred_over_substring_steal_confirmed_real_case():
# Confirmed real case from a whole-book `cli validate` run: "ISOSORBID"
# and "ISOSORBID DINITRAT" are two distinct, correctly-segmented
# monographs a page apart. A pure substring match lets the shorter name
# "steal" both ground-truth entries (it's a substring of the longer one
# too) via `next()`'s order-dependent first match, leaving the real
# "ISOSORBID DINITRAT" monograph spuriously unmatched even though an
# exact match for it exists.
monographs = [
_mono("isosorbid", "ISOSORBID", 844),
_mono("isosorbid_dinitrat", "ISOSORBID DINITRAT", 845),
]
ground_truth = [
GroundTruthEntry(name="Isosorbid", printed_page=845),
GroundTruthEntry(name="Isosorbid dinitrat", printed_page=846),
]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
assert result.precision == 1.0
assert len(result.unmatched_detected) == 0
def test_double_space_in_detected_name_still_matches_confirmed_real_case():
# confirmed real case from a whole-book `cli validate` run: "ALVERIN
# CITRAT" (double space) failed to match ground truth's single-spaced
# "Alverin citrat" under plain strip+upper comparison.
monographs = [_mono("alverin_citrat", "ALVERIN CITRAT", 171)]
ground_truth = [GroundTruthEntry(name="Alverin citrat", printed_page=172)]
result = compute_recall_precision(monographs, ground_truth)
assert result.recall == 1.0
@@ -0,0 +1,124 @@
from pathlib import Path
import pytest
from ingestion.extract import OutlinedTextRun
from ingestion.tables import TableRegion
from ingestion.validation import (
FRACTION_BAR_CANDIDATE,
HEADER_RULE,
RULE_FRAGMENT,
TABLE_FRAME,
TEXT_AS_VECTOR_OUTLINE,
UNCLASSIFIED,
PageContext,
ResidualRegion,
classify,
scan_page,
)
from ingestion.validation.residual_ink import FRACTION_BAR_CANDIDATE as BAR
PDF_PATH = Path(__file__).resolve().parents[1] / "data" / "raw" / (
"duoc-thu-quoc-gia-viet-nam-2018.pdf"
)
needs_pdf = pytest.mark.skipif(not PDF_PATH.exists(), reason="source PDF not present")
def _region(x0, y0, x1, y1, page=100, ink=500):
return ResidualRegion(physical_page=page, bbox=(x0, y0, x1, y1), ink_px=ink)
def test_running_header_rule_is_named_not_left_unclassified():
# measured on real pages: a ~516pt wide, 0pt tall rule at y≈48-52 appears
# on essentially every page of the book
assert classify(_region(36.0, 48.5, 552.0, 48.5)) == HEADER_RULE
def test_a_thin_bar_below_the_header_band_is_a_fraction_bar_candidate():
# NETILMICIN, physical page 1042: the Cockcroft-Gault fraction bar
assert classify(_region(97.9, 492.0, 286.5, 492.0)) == FRACTION_BAR_CANDIDATE
def test_ink_inside_a_known_table_region_is_a_table_frame_not_a_formula():
table = TableRegion(
table_id="p202_t0", physical_page=202, bbox=(299.0, 189.6, 552.4, 300.5),
n_rows=4, n_cols=3, shape="simple_table",
)
region = _region(299.0, 189.6, 552.4, 300.5, page=202)
assert classify(region, PageContext(tables=[table])) == TABLE_FRAME
# ...and the same geometry with no table map degrades to "look at it",
# never to a silent pass
assert classify(region) == UNCLASSIFIED
def test_a_wide_rule_outside_the_header_band_is_not_treated_as_a_header_rule():
assert classify(_region(36.0, 700.0, 552.0, 700.0)) == FRACTION_BAR_CANDIDATE
def test_a_tall_block_of_unaccounted_ink_stays_unclassified():
# a figure or an image of text must never be silently absorbed by a rule
assert classify(_region(100.0, 300.0, 400.0, 500.0)) == UNCLASSIFIED
def test_hairline_shorter_than_the_minimum_bar_width_is_a_rule_fragment():
# too short to be a fraction bar, too thin to be anything but a rule
assert classify(_region(100.0, 300.0, 105.0, 300.0)) == RULE_FRAGMENT
@needs_pdf
@pytest.mark.parametrize(
"page,expected_bar_width_pt",
[
(1042, 188.6), # NETILMICIN — Cockcroft-Gault
(202, 118.1), # AMPICILIN VÀ SULBACTAM — Cockcroft-Gault
],
)
def test_confirmed_2d_formula_bars_survive_the_span_mask(page, expected_bar_width_pt):
"""Regression fixture for the two visually confirmed corrupted formulas.
Both pages are reported as having zero tables by `pdfplumber` and zero by
`opendataloader-pdf`; the bar is only findable as ink. If the mask padding
is ever loosened again the bar disappears (at 1.0pt page 1042's bar
shrinks from 188.6pt to 9.1pt) — this test is what catches that.
"""
import fitz
doc = fitz.open(PDF_PATH)
bars = [
r for r in scan_page(doc[page])
if classify(r) == BAR and r.bbox[1] > 60.0
]
assert bars, f"no fraction-bar candidate found on physical page {page}"
assert max(b.width_pt for b in bars) == pytest.approx(expected_bar_width_pt, abs=1.0)
def test_vector_outlined_text_is_named_rather_than_left_unclassified():
# physical page 714 prints 17 lines of Gatifloxacin prose as filled paths;
# no text extractor returns them, so the gate must name the defect
line = OutlinedTextRun(
physical_page=714, bbox=(35.3, 75.8, 286.7, 84.4), path_items=1638,
)
region = _region(35.5, 76.0, 120.0, 84.0, page=714)
context = PageContext(outlined_runs=[line])
assert classify(region, context) == TEXT_AS_VECTOR_OUTLINE
# an untranscribed line must never be mistaken for recovered content
assert not line.is_transcribed
@needs_pdf
def test_outlined_text_lines_are_found_on_exactly_the_five_known_pages():
"""Whole-document regression: 51 outlined runs on 5 pages.
Cross-checked two ways at the time of writing — the drawing-shape scan
below, and independently by counting glyph-shaped leftovers in the
residual-ink mask, which found the same five pages.
"""
import fitz
from ingestion.extract import detect_outlined_text
lines = list(detect_outlined_text(fitz.open(PDF_PATH)))
by_page = {}
for line in lines:
by_page[line.physical_page] = by_page.get(line.physical_page, 0) + 1
assert by_page == {714: 31, 736: 16, 1373: 1, 1444: 1, 1445: 2}