Fix every real lint finding and drop degenerate splice fragments
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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) == []
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)) == []
|
||||
@@ -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
|
||||
@@ -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([]) == []
|
||||
@@ -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)
|
||||
@@ -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"]) == []
|
||||
@@ -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
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user