PRD — Perbaikan Bounding Box Modal & Saham (gpu-server)
Status: siap dikerjakan
Tanggal: 2026-08-03
Scope: gpu-server/app/utils/bbox.py + gpu-server/tests/test_bbox.py
Tidak menyentuh: backend/, frontend/, prompt ekstraksi, skema data
Lanjutan dari prd-llm-bounding-box-matching.md. Dua bug produksi yang dilaporkan Efran sudah di-root-cause dengan bukti dari DB dev (ahu_ocr_dev5); dokumen ini berisi diagnosis lengkap + instruksi perbaikan.
Prasyarat penting: semua ini hanya berlaku pada jalur BBOX_MATCHER=llm (merge_bounding_boxes_llm). Jalur default string (merge_bounding_boxes → _find_exact) tidak memakai _value_variants sama sekali, sehingga angka bernominal (5001000000 vs OCR 5.001.000.000) tidak pernah cocok dan tidak ada box modal yang dihasilkan. Bukti bahwa jalur llm yang aktif di dev: tabel BoundingBox berisi baris modal.* dengan koordinat nyata.
Ringkasan gejala
Sebaran box pada 9 dokumen Akta Pendirian di ahu_ocr_dev5:
| fieldKey | jumlah baris |
|---|---|
modal.modal_dasar |
9 |
modal.modal_ditempatkan |
9 |
modal.nilai_nominal_saham |
9 |
modal.jumlah_saham_ditempatkan |
9 |
modal.jumlah_saham_modal_dasar |
9 |
modal.modal_disetor |
1 |
Dua keluhan pengguna:
- Modal disetor tidak pernah punya bounding box, padahal nilainya sama dengan modal ditempatkan yang boxnya keluar normal.
- Box
jumlah_saham_modal_dasarmenunjuk ke nominal saham salah satu pendiri, bukan ke angka jumlah saham yang sebenarnya.
Pada kedua kasus nilai data yang diekstrak sudah benar — hanya koordinat box yang salah. Ini konsisten dengan arsitekturnya: nilai berasal dari ekstraksi LLM yang membaca konteks kalimat, sedangkan box dicari belakangan lewat pencocokan string yang buta konteks.
Dokumen reproduksi
| Peran | Document.id | Berkas |
|---|---|---|
| Reproduksi kedua bug | 7b458d79-dd9e-4274-a21b-2d4efda02f08 |
4020041531101744_Akta-Pendirian-PT-Virtue-Digital-Indonesia (1).pdf |
| Kontrol (lolos) | 17330559-1b20-4e16-92b6-89eee9724087 |
perubahan-pp-ke-pt.pdf |
AktaModal dokumen reproduksi:
modalDasar 500000000
modalDitempatkan 500000000 ← ketiganya IDENTIK
modalDisetor 500000000
nilaiNominal 100000
jumlahSaham 5000 ← keduanya IDENTIK
jumlahSahamModalDasar 5000
AktaPemegangSaham dokumen yang sama:
0 PT MGM INTEGRA TEKNOLOGI 2750 lembar Rp 275.000.000 55%
1 FARIZ ISKANDAR 1500 lembar Rp 150.000.000 30%
2 DANNY 500 lembar Rp 50.000.000 10%
3 ERLANGGA BUDI SANGGRAMA 250 lembar Rp 25.000.000 5%
Bug 1 — modal_disetor kehabisan kandidat
Diagnosis
_triage_fields mengelompokkan field berdasarkan nilai. Karena modal_dasar == modal_ditempatkan == modal_disetor, ketiganya masuk satu grup dengan 3 field key.
Kemunculan "500.000.000" di Document.rawText — tepat 2 kali:
pos 5529 | ... nilai nominal seluruhnya sebesar Rp. 500.000.000,- (limaratus juta Rupiah)
pos 27715 | ... nilai nominal seluruhnya sebesar (limaratus juta Rupiah); Rp. 500.000.000,-
3 field, 2 kandidat. _reconcile_assignment menjamin "every candidate is used by at most one field … a field with no spare stays boxless", sehingga satu field pasti kosong. Yang kosong adalah field terakhir dalam urutan flatten (modal_dasar → modal_ditempatkan → modal_disetor), karena Pass 1 memakai tie-break -field_keys.index(fk) dan Pass 2 iterasi for fk in field_keys berurutan. modal_disetor selalu kalah — deterministik, bukan kebetulan.
Konfirmasi lewat dokumen kontrol: 17330559 punya "5.001.000.000" sebanyak 5 kali, cukup untuk ketiga field — dan itu satu-satunya dokumen yang modal_disetor-nya dapat box.
Inkonsistensi yang jadi dasar perbaikan
Ketika kandidat hanya satu, _triage_fields justru membagikan box yang sama ke semua field yang bernilai sama:
elif len(candidates) == 1:
# Unambiguous: assign the single bbox to all fields sharing this value
for fk in field_keys:
...
Terbukti di dokumen yang sama: pemegang_saham.1.jumlah_lembar dan pemegang_saham.2.jumlah_lembar memiliki koordinat identik (571, 801, 1162, 843).
Jadi larangan duplikasi hanya berlaku di jalur ambigu (≥2 kandidat). Di kasus modal, berbagi box justru jawaban yang benar — akta memang menulis satu angka untuk "ditempatkan dan disetor".
Perbaikan
Tambahkan Pass 3 di _reconcile_assignment: field yang masih belum dapat kandidat setelah Pass 2 boleh memakai ulang kandidat yang sudah dipakai field lain dalam grup nilai yang sama.
- Pilih kandidat dengan
_context_scoretertinggi terhadap keyword seksi field tersebut; tie-break: kandidat dengan indeks terkecil. - Semantiknya aman: satu grup = satu nilai identik, jadi kandidat mana pun adalah kemunculan nyata dari nilai itu. Ini menyamakan perilaku dengan cabang 1-kandidat yang sudah ada.
- Jangan mengubah Pass 1 dan Pass 2 — prioritas "setiap field dapat kandidat unik dulu" tetap dipertahankan. Pass 3 hanya jaring pengaman agar hasilnya box yang benar-bagi-pakai, bukan tanpa box sama sekali.
- Perbarui docstring
_reconcile_assignment— kalimat "a field with no spare stays boxless" menjadi tidak berlaku lagi.
Kriteria terima
modal.modal_disetormendapat box pada dokumen7b458d79, dengan koordinat sama persis dengan salah satu dari dua kemunculan500.000.000.- Dokumen kontrol
17330559tidak berubah (5 kandidat, ketiga field tetap dapat kandidat unik masing-masing).
Bug 2 — pencocokan menembus tengah angka lain
Diagnosis
jumlahSahamModalDasar = 5000 → varian "5.000". Kemunculannya di rawText:
pos 26626 | ... kas Perseroan sejumlah 5.000 (lima ribu) lembar saham ← ASLI
pos 26998 | ... juta Rupiah);------ Rp. 275.000.000,- b. Tuan FARIZ ISKANDAR ← HANTU (dalam 275.000.000)
pos 27567 | ... (duapuluh lima juta Rupiah);- Rp. 25.000.000,- sehingga ... ← HANTU (dalam 25.000.000)
Dua dari tiga kandidat adalah potongan di tengah nominal pemegang saham. Koordinat di DB memastikan hasilnya:
modal.jumlah_saham_modal_dasar hal 18 (1216, 1706, 1563, 1751)
pemegang_saham.3.nilai_nominal hal 18 (1216, 1706, 1563, 1751) ← IDENTIK
Box jumlah_saham_modal_dasar mendarat persis di sel nominal ERLANGGA BUDI SANGGRAMA (Rp 25.000.000).
Sebab: _find_all_exact memakai idx.text_lower.find(needle, search_start) tanpa batas token. Guard sibling yang ada saat ini hanya menolak match yang dimulai di posisi yang sama dengan nilai saudara yang lebih panjang:
claimed_by_longer_sibling = any(
len(sib) > len(needle) and idx.text_lower.startswith(sib, pos)
for sib in sibling_lower
)
Guard ini menangani kasus prefix (bug 5001 vs 5.001.000.000 yang sudah diperbaiki sebelumnya), tapi tidak menangani match di tengah token: "25.000.000" tidak dimulai di posisi "5.000", jadi lolos.
Faktor pemberat: kemunculan sah kedua (total tabel saham) dirusak OCR menjadi 5.00o — huruf o, bukan angka nol. Jadi dari 3 kandidat hanya 1 yang asli, dan field kedua pasti mendarat di hantu. Ini menegaskan perbaikan harus bersifat menolak kandidat palsu, bukan sekadar mengurutkan prioritas.
Perbaikan
Tambahkan guard batas token numerik di _find_all_exact, dijalankan setelah pos ditemukan dan sebelum kandidat dibentuk:
- Berlaku hanya untuk needle yang numerik (hanya digit dan pemisah
./,/ spasi). Needle teks tidak terpengaruh. - Tolak jika karakter tepat sebelum
posadalah digit. - Tolak jika karakter tepat sebelum
posadalah.atau,yang didahului digit (kasus275.+000). - Tolak jika karakter tepat sesudah
pos + len(needle)adalah digit. - Tolak jika karakter tepat sesudah adalah
.atau,yang diikuti digit (kasus5.001di dalam5.001.000.000).
Guard baru ini menggantikan fungsi guard prefix untuk kasus numerik. Pertahankan guard sibling yang lama — ia masih dibutuhkan untuk kasus teks ("Direktur" vs "Direktur Utama"), yang tidak tersentuh aturan batas token.
Kriteria terima
- Pada dokumen
7b458d79,_find_all_exact("5000", …, siblings)mengembalikan tepat 1 kandidat (pos 26626), bukan 3. modal.jumlah_saham_modal_dasardanmodal.jumlah_saham_ditempatkantidak lagi berkoordinat sama denganpemegang_saham.*.nilai_nominalmana pun.- Catatan: setelah guard ini, grup nilai
5000punya 2 field dengan 1 kandidat → cabanglen(candidates) == 1membagikan box yang sama ke keduanya. Itu hasil yang diinginkan (memang satu angka yang sama di akta).
Perbaikan 3 — keyword seksi untuk field modal
_SECTION_KEYWORDS saat ini hanya mengenal pemegang_saham, komisaris, direksi, penghadap. Untuk semua field modal.*, _context_score selalu 0, sehingga Pass 1 dan Pass 2 _reconcile_assignment kehilangan sinyal konteks dan jatuh ke urutan dokumen belaka — inilah kenapa field modal mudah "nyasar" ke tabel pemegang saham.
Tambahkan entri:
(("modal_dasar", "jumlah_saham_modal_dasar"), ("modal dasar", "modal_dasar")),
(("modal_ditempatkan", "jumlah_saham_ditempatkan"), ("ditempatkan",)),
(("modal_disetor",), ("disetor", "telah disetor")),
(("nilai_nominal_saham",), ("nilai nominal", "nominal")),
Perhatikan urutan pencocokan. _section_keywords_for memakai any(p in fk for p in prefixes) dan mengembalikan entri pertama yang cocok. Field pemegang_saham.N.nilai_nominal mengandung substring pemegang_saham, jadi entri pemegang_saham yang sudah ada harus tetap berada di urutan lebih awal daripada entri nilai_nominal_saham yang baru — jika tidak, field pemegang saham akan salah ambil keyword. Verifikasi ini dengan test eksplisit.
Perbaikan ini bersifat pencegahan (mengurangi kemungkinan kasus serupa), bukan syarat untuk menutup Bug 1 atau Bug 2.
Test yang harus ditambahkan
Di gpu-server/tests/test_bbox.py, mengikuti konvensi yang ada (_make_words, kelas TestFindAllExact / TestTriageFields):
Bug 2 — batas token numerik
def test_share_count_does_not_match_inside_a_larger_amount(self):
"""Bug live Efran: jumlah_saham (5000) menyorot sel nominal pemegang
saham (Rp 25.000.000), karena '5.000' adalah substring di TENGAH
'25.000.000'. Guard sibling lama hanya menangkap kasus prefix."""
words = _make_words(
["sejumlah", "5.000", "lembar", "saham", "Rp.", "275.000.000,-", "Rp.", "25.000.000,-"]
)
indices = _build_page_indices(words)
siblings = {"5000", "275000000", "25000000"}
results = _find_all_exact("5000", indices, siblings)
assert len(results) == 1
assert "lembar" in results[0].context
Regresi — kasus prefix lama tetap tertangkap (test_short_number_does_not_claim_a_longer_sibling_numbers_prefix yang sudah ada harus tetap hijau).
Regresi — pemisah ribuan normal tetap cocok (test_number_with_thousands_separator_indonesian / _english harus tetap hijau; "2750" → "2.750" adalah token utuh sehingga tidak boleh tertolak guard baru).
Bug 1 — berbagi kandidat
def test_three_identical_capital_fields_share_two_occurrences(self):
"""modal_dasar == modal_ditempatkan == modal_disetor, tapi angkanya
hanya muncul 2x di akta. Ketiganya tetap harus dapat box."""
# 3 field key, 2 kandidat → Pass 3 membagi ulang kandidat terpakai
Perbaikan 3 — prioritas keyword
def test_pemegang_saham_nilai_nominal_keeps_its_own_keywords(self):
assert "pemegang saham" in _section_keywords_for("pemegang_saham.0.nilai_nominal")
assert "nilai nominal" in _section_keywords_for("modal.nilai_nominal_saham")
Verifikasi akhir
- Seluruh
gpu-server/tests/test_bbox.pyhijau (tidak ada regresi pada test yang sudah ada). - Proses ulang
4020041531101744_Akta-Pendirian-PT-Virtue-Digital-Indonesia (1).pdfdenganBBOX_MATCHER=llm, lalu periksa:
SELECT "fieldKey", page, x1, y1, x2, y2
FROM "BoundingBox"
WHERE "documentId" = '<doc-id-baru>'
AND ("fieldKey" LIKE 'modal.%' OR "fieldKey" LIKE 'pemegang_saham%')
ORDER BY "fieldKey";
Diharapkan: keenam modal.* punya baris, dan tidak ada modal.* yang berkoordinat identik dengan pemegang_saham.*.nilai_nominal.
- Proses ulang
perubahan-pp-ke-pt.pdfsebagai kontrol — hasilnya harus tetap sama seperti sebelum perubahan.
Di luar scope
- Mengubah default
BBOX_MATCHERdaristringkellm— keputusan operasional terpisah, tapi perlu dicatat bahwa tanpallmseluruh perbaikan di dokumen ini tidak berpengaruh karena_find_exacttidak pernah mencocokkan angka berpemisah ribuan sejak awal. - Bounding box untuk
jenis_perseroandantahun_buku. Keduanya nilai turunan yang tidak pernah muncul literal di halaman (tahun_bukumalah masuk_SKIP_SUFFIXES); solusinya butuh fieldevidenceterpisah di skema ekstraksi, bukan perbaikan matcher. - Perbaikan OCR yang membaca
5.000sebagai5.00o.