think
16px
820px

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:

  1. Modal disetor tidak pernah punya bounding box, padahal nilainya sama dengan modal ditempatkan yang boxnya keluar normal.
  2. Box jumlah_saham_modal_dasar menunjuk 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.rawTexttepat 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_score tertinggi 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_disetor mendapat box pada dokumen 7b458d79, dengan koordinat sama persis dengan salah satu dari dua kemunculan 500.000.000.
  • Dokumen kontrol 17330559 tidak 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 pos adalah digit.
  • Tolak jika karakter tepat sebelum pos adalah . atau , yang didahului digit (kasus 275. + 000).
  • Tolak jika karakter tepat sesudah pos + len(needle) adalah digit.
  • Tolak jika karakter tepat sesudah adalah . atau , yang diikuti digit (kasus 5.001 di dalam 5.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_dasar dan modal.jumlah_saham_ditempatkan tidak lagi berkoordinat sama dengan pemegang_saham.*.nilai_nominal mana pun.
  • Catatan: setelah guard ini, grup nilai 5000 punya 2 field dengan 1 kandidat → cabang len(candidates) == 1 membagikan 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

  1. Seluruh gpu-server/tests/test_bbox.py hijau (tidak ada regresi pada test yang sudah ada).
  2. Proses ulang 4020041531101744_Akta-Pendirian-PT-Virtue-Digital-Indonesia (1).pdf dengan BBOX_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.

  1. Proses ulang perubahan-pp-ke-pt.pdf sebagai kontrol — hasilnya harus tetap sama seperti sebelum perubahan.

Di luar scope

  • Mengubah default BBOX_MATCHER dari string ke llm — keputusan operasional terpisah, tapi perlu dicatat bahwa tanpa llm seluruh perbaikan di dokumen ini tidak berpengaruh karena _find_exact tidak pernah mencocokkan angka berpemisah ribuan sejak awal.
  • Bounding box untuk jenis_perseroan dan tahun_buku. Keduanya nilai turunan yang tidak pernah muncul literal di halaman (tahun_buku malah masuk _SKIP_SUFFIXES); solusinya butuh field evidence terpisah di skema ekstraksi, bukan perbaikan matcher.
  • Perbaikan OCR yang membaca 5.000 sebagai 5.00o.