Fix personnel extraction + header bugs on real Polres Cimahi sprint

This fixes 4 bugs found on a real Polres Cimahi SPRIN PDF:

1. satuan_penerbit captured the generic 'KEPOLISIAN NEGARA REPUBLIK
   INDONESIA' letterhead line instead of the most-specific issuing unit
   (e.g. RESOR CIMAHI / SEKTOR PADALARANG). Reworked find_satuan to
   scan for each level independently and return the deepest available.

2. find_dasar_list dropped numbered items when OCR put the marker on
   its own line ("1.\n Undang-Undang ..."). Refactored into
   _collect_numbered_section that buffers a bare-number line and uses
   the next non-empty line as the body. Also reused for the new
   find_untuk_list which extracts the previously-empty 'untuk' bullets.

3. find_perihal returned None for documents that use 'Pertimbangan'
   (very common in Polres-level sprint), forcing the LLM to guess.
   Added a regex fallback that picks up the first line under a
   'Pertimbangan' label so we keep extraction deterministic.

4. Personnel rows were emitted with only nama populated when
   PP-Structure detected a table but the column mapper degraded.
   Added a text-based fallback (extract_personnel_from_text) that
   scans raw OCR for <rank> + <8-digit NRP> patterns. Triggered when
   the PP-Structure result has fewer than 30% rank/NRP-bearing rows.
   Reviewed by raising the new PERSONNEL_TEXT_FALLBACK flag.

5. Validation now flags rows with neither pangkat nor nrp as
   INCOMPLETE_PERSONNEL_ROW, so the document routes to needs_review
   even when individual nrp/pangkat checks pass on empty values.

6. Added 'BRIGPOL' as a variant of BRIGADIR (seen in real scans).

Tests: 229 (was 203) — 26 new tests covering the regex fixes,
text-based personnel extractor, low-quality detector, validator
behaviour, and orchestrator wiring of the fallback path.

Co-Authored-By: adrian kuman firmansah <adriancuman@gmail.com>
This commit is contained in:
Devin AI
2026-04-26 05:35:42 +00:00
parent dce77e80e1
commit 58a2bf2648
11 changed files with 747 additions and 39 deletions

View File

@@ -14,6 +14,7 @@ from ocr_sprint.pipeline.extract.regex_rules import (
find_satuan,
find_signatory,
find_tanggal,
find_untuk_list,
)
@@ -60,6 +61,36 @@ class TestSatuan:
result = find_satuan("KEPOLISIAN NEGARA REPUBLIK INDONESIA")
assert result is not None
def test_prefers_resor_over_negara_when_both_present(self) -> None:
# The Polri letterhead lists units hierarchically; the issuing unit
# is the deepest level, not the topmost generic "NEGARA" line.
text = (
"KEPOLISIAN NEGARA REPUBLIK INDONESIA\n"
"DAERAH JAWA BARAT\n"
"RESOR CIMAHI\n"
"SURAT PERINTAH\n"
)
result = find_satuan(text)
assert result == "KEPOLISIAN RESOR CIMAHI"
def test_prefers_sektor_over_resor(self) -> None:
text = (
"KEPOLISIAN NEGARA REPUBLIK INDONESIA\n"
"DAERAH JAWA BARAT\n"
"RESOR CIMAHI\n"
"SEKTOR PADALARANG\n"
)
result = find_satuan(text)
assert result == "KEPOLISIAN SEKTOR PADALARANG"
def test_handles_daerah_only(self) -> None:
text = "KEPOLISIAN NEGARA REPUBLIK INDONESIA\nDAERAH JAWA BARAT\n"
result = find_satuan(text)
assert result == "KEPOLISIAN DAERAH JAWA BARAT"
def test_returns_none_when_no_letterhead(self) -> None:
assert find_satuan("no police letterhead here") is None
class TestPerihal:
def test_extracts_perihal_line(self) -> None:
@@ -69,6 +100,25 @@ class TestPerihal:
def test_returns_none_when_absent(self) -> None:
assert find_perihal("no perihal field") is None
def test_falls_back_to_pertimbangan_block(self) -> None:
# Many Polres-level sprints use "Pertimbangan" instead of "Perihal".
# The fallback should pick up the first non-empty line under it.
text = (
"Pertimbangan\n"
"Bahwa dalam rangka mendukung kepentingan Dinas Polres Cimahi.\n"
"DASAR :\n"
"1. ...\n"
)
result = find_perihal(text)
assert result is not None
assert result.startswith("Bahwa dalam rangka mendukung")
def test_perihal_wins_over_pertimbangan_when_both_present(self) -> None:
# If the document has both a Perihal label AND a Pertimbangan
# paragraph, the explicit Perihal wins.
text = "Pertimbangan\nSome pertimbangan content.\nPERIHAL : The actual perihal.\n"
assert find_perihal(text) == "The actual perihal."
class TestDasar:
def test_numbered_list(self) -> None:
@@ -88,6 +138,57 @@ class TestDasar:
def test_empty_when_section_missing(self) -> None:
assert find_dasar_list("no dasar section") == []
def test_handles_bare_number_lines_split_by_ocr(self) -> None:
# OCR sometimes places the number marker on its own line and the
# body on the next non-empty line. The collector must merge them
# rather than dropping the body or appending it to the previous
# item (which the old implementation did).
text = (
"Dasar\n"
":\n"
"1.\n"
" Undang - Undang Nomor 2 tahun 2002 tentang Kepolisian;\n"
"2. Peraturan Pemerintah Republik Indonesia No. 76 tahun 2020;\n"
"3.\n"
"Keterangan Catatan Kepolisian (SKCK);\n"
"4.\n"
"Pelayanan dilingkungan Badan Intelijen Keamanan Polri.\n"
"5. DIPA Petikan Satker Polres Cimahi.\n"
"DIPERINTAHKAN\n"
)
items = find_dasar_list(text)
assert len(items) == 5
assert items[0].startswith("Undang - Undang")
assert items[2].startswith("Keterangan Catatan")
assert items[3].startswith("Pelayanan dilingkungan")
assert items[4].startswith("DIPA")
class TestUntuk:
def test_extracts_numbered_untuk_bullets(self) -> None:
text = (
"DIPERINTAHKAN\n"
"Kepada\n"
"Untuk\n"
"1.\n"
"melaksanakan tugas A;\n"
"2.\n"
"melaksanakan tugas B;\n"
"Selesai.\n"
)
items = find_untuk_list(text)
assert len(items) == 2
assert items[0] == "melaksanakan tugas A;"
assert items[1] == "melaksanakan tugas B;"
def test_returns_empty_when_section_missing(self) -> None:
assert find_untuk_list("no untuk section") == []
def test_stops_at_dikeluarkan(self) -> None:
text = "Untuk\n1. tugas A;\nDikeluarkan di Cimahi\n2. should not be captured\n"
items = find_untuk_list(text)
assert items == ["tugas A;"]
class TestSignatory:
def test_extracts_last_nrp(self) -> None: