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."