PENDIRIAN_PT smoke wave 1 — implementation report
Worktree: /home/efran/remote-development/poc-ahu-ai/ahu-ocr-tidyup, branch
refactor/tidyup-foundation. Smoke submission traced:
cf18d4f0-f41f-4e44-afdd-1dc313e57262 (staging DB, read-only).
Status: all 7 items complete, gates green, zero regressions
- Backend: 2090 pass / 1 skip / 0 fail (baseline 2084/1 — net +6 tests),
tsc --noEmitclean. - Frontend: 827 pass / 115 files, 0 fail (baseline 826/115 — net +1
test),tsc --noEmitclean. - Did NOT touch the legacy
PendirianReviewPage.tsxor its/review-data
route. Did NOTgit push.
Commits (10, oldest → newest)
e04a661— fix(pt-pendirian): resolve gpu-server confidence keys rooted at the job envelope (Item 1)093fd25— fix(pt-pendirian): human doc-type labels + PMA-conditional kontak fields (Item 6/7)ce94131— test(pt-pendirian): lock in contact-info phone confidence key reading (Item 6)2f835fe— fix(pt-pendirian): hide empty field-list sections + Rupiah displayValue (Items 2/3)47c9d5e— feat(review-engine): optional RosterRowVM.confirmable (Item 5 prep)da3070c— fix(pt-pendirian): split pengurus/pemegang_saham rosters + real NIK cell (Item 4)546fadd— fix(pt-pendirian): bukti_setor doc-level rows mirror legacy per-doc table (Item 5)8dd78ed— fix(pt-pendirian): frontend wiring for split pengurus/pemegang_saham rosters (Item 4)b527cb3— docs(pt-pendirian): update projector file-header for the Item 4/5 roster split
(One commit, e04a661, needed a same-session fixup absorbed into the field-transformer
diff before landing: extracting lookupRawConfidence into a new
akta-confidence-lookup.ts and deleting a dead function, because the fix as
first written pushed field-transformer.ts 23 lines past its
architecture-fitness grandfathered ceiling (896). Final file is 890 lines.)
Item 1 — TRACE the zero-confidence akta writes — VERDICT: IN-REPO FIX (done)
Evidence. The smoke submission's akta document (bd86e1c4-…) had
confidence = 0 on all 13 persisted ExtractedField rows. rawExtractionJson
on that Document only stores result.data (the GPU response's extracted
values) — result.confidence is consumed to compute ExtractedField.confidence
at write time but never itself persisted, so the DB alone couldn't prove
whether the response lacked confidence or our code dropped it.
The GPU host (192.168.83.20, same box as the staging DB) still had the
Celery result for this exact job in Redis (ahu-redis, key
celery-task-meta-54af9e03-533c-4f53-8150-8bc9fd695189, ahu-worker-akta).
Pulled and inspected the full JSON:
{"status": "SUCCESS", "result": {"akta_subtype": "pendirian",
"data": {...}, "confidence": {...153 keys...}, "bounding_boxes": [...],
"ocr_metadata": {...}}}
The confidence map is real: 153 keys, values 90-100, well differentiated
per field (e.g. nomor_akta: 95, singkatan: 90, everything else: 100). But
every single key is prefixed "data." — "data.akta.nomor_akta",
"data.perseroan.singkatan", "data.direksi.0.nama", "data.modal.modal_dasar"
— never the bare "akta.nomor_akta" form. field-transformer.ts's
FIELD_MAPPING paths and the direksi/komisaris/pemegangSaham/modal/kbli
inline lookups all look up the bare form (confirmed against the existing
unit-test fixtures, which seed keys like "akta.nomor_akta" with no prefix).
Every lookup missed, ?? 0 fired for all 13+ fields — exactly matching the
DB observation.
Conclusion: the gpu-server response for the "akta" job DOES carry real,
usable per-field confidence. The defect is entirely in this repo:
getConfidence()/the inline array-confidence lookups in
field-transformer.ts assumed an unprefixed key space that the current
gpu-server contract doesn't use. Fixed here (lookupRawConfidence, tries
the bare key first for back-compat, then the "data."-prefixed form) —
no gpu-server change or cross-team ask needed to close this. (Optional
FYI to the gpu-server team: confirm this "data."-rooted-at-full-envelope
shape is the intended, stable contract, since it silently diverged from
whatever shape the original field-transformer code was written against.)
Related finding (not fixed, flagged only): transformAktaPerubahanResult
(AKTA_PERUBAHAN, used by PERUBAHAN_PT/AKUISISI/PEMBUBARAN et al.) has the
identical bare-key lookup pattern in the same file and is very likely subject
to the same bug. Left untouched — out of scope for this PENDIRIAN_PT smoke
fix, no live evidence gathered for that flow specifically. Worth a dedicated
follow-up with its own trace/test.
Item 2 — hide empty sections
data_perseroan/akta_notaris/notaris_pengganti (field-list) now only
emit when fieldRefs.length > 0; same for modal_saham (no AktaModal row)
and domisili (no domisili doc). Roster sections (pengurus,
pemegang_saham, kbli) are omitted when there's no akta doc at all
(so no rows are even possible); bukti_setor is omitted only when there are
zero Bukti Setor documents. An akta doc that legitimately has zero rows for
one of these (e.g. no KBLI entries) still renders (empty table) — matches
legacy's own "0-row table" behavior, only genuinely-absent data disappears.
Item 3 — Modal & Saham formatting
Added formatRupiahDisplay/formatThousandsDisplay to projector-utils.ts
(reusing the Rp ${n.toLocaleString("id-ID")} idiom already inlined in
pp-pendirian-review.ts, rather than adding a third ad-hoc copy). The 4
currency modal fields (modal_dasar/modal_ditempatkan/modal_disetor/
nilai_nominal_saham) get "Rp 1.000.000.000"; the 2 bare share-count
fields (jumlah_saham_ditempatkan/jumlah_saham_modal_dasar) get
thousand-separators only. value stays the raw digit string.
Item 4 — split pengurus vs pemegang_saham + NIK cell — REVERSES T1
Legacy renders Direksi/Komisaris as their OWN section (#5) and Pemegang Saham
as a table NESTED inside modal_saham (#3) — never one merged roster where a
person shows up once per role. Split the single pengurus_pemegang_saham
roster into pengurus (direksi+komisaris) and pemegang_saham
(shareholders, own persentase column). Section order now mirrors the
legacy page (survey §2): pemegang_saham right after modal_saham, kbli,
then pengurus; bukti_setor moved to the very end (legacy's own final
section). This explicitly reverses the T1 projector's merged-roster
design decision, per Efran's smoke feedback.
NIK cell: the akta's own AktaDireksi.nik/AktaKomisaris.nik/
AktaPemegangSaham.nikAtauRegistrasi columns are essentially never
populated — confirmed against the live smoke submission's raw gpu-server
data block: the direksi/pemegang_saham entries carry
nama/jabatan/is_asing/passport/nomor_kitas, no NIK field at
all. Added resolveNikForName(), resolving NIK via the SAME fuzzy
IdentityMatch → KTP linkage the identitas section already uses (name
tokenScore ≥ 0.7, edited-field overlay), so both places show the identical,
possibly-edited value. Corporate shareholders (no KTP/IdentityMatch) fall
back to their own NPWP column, else null — never fabricated.
Frontend (use-pt-review-engine-mutations.ts, PtPendirianReviewPageV2.tsx):
resolveRosterConfirmBody now branches on "pengurus" (role→direksi/
komisaris) and "pemegang_saham" (fixed entityType "pemegangSaham", no
role needed) instead of one merged key.
Item 5 — bukti_setor rows = documents
Legacy's dedicated Bukti Setor table (submissions.ts's buktiSetor list is
per-document; PendirianReviewPage.tsx Section 8 renders exactly one row
per doc: File/Jumlah/Perseroan) has zero confirm affordance anywhere —
confirm only ever happens via the pemegang-saham cascade or the orphan-entry
rows shown inside modal_saham. The engine's roster only emitted per-entry
rows, so a document whose extraction produced entries: [] (all 5
BUKTI_TRANSFER- docs in the cf18d4f0 smoke submission) rendered zero
rows* — the exact bug Efran hit.
Fix: added an optional confirmable?: boolean to the shared
RosterRowVMSchema contract (absent/true = existing behavior for every
other roster row across every tenant, no regression) — confirmable: false
suppresses RosterSection's confirm/unconfirm control and its
data-unconfirmed flag entirely, for a row with no backend confirm
counterpart. The projector now emits one confirmable:false, confirmed:true
doc-level row per Bukti Setor document (filename, jenis via the Item 7
label map, jumlahSetor, namaPerseroan), with the existing per-entry rows
appended after. confirmed:true on these rows ensures they never block
submitGate/section-progress on an action nobody can take.
Item 6 — identity Kontak confidence
Verified already correct: the projector's phoneConfKey was already built as
`contact_info.${entryIndex}.no_telepon`. Added a regression test
against a seeded confidence map (85) to lock it in — no production code
change needed for this item; the coordinator's diagnosis likely predated
the current file state.
Item 7 — kontak PMA conditional + doc label map
manual:currency/manual:kurs now only emit when
sub.currency/sub.kurs are non-null (PMA case) — SABH submissions no
longer carry a permanent conf-100 empty-value phantom row.
Doc-type labels: hoisted routes/classifier.ts's private, unexported
LABEL_MAP into backend/src/lib/document-type-labels.ts
(getDocumentTypeLabel), reused by both the classifier route and the PT
projector's pdfSources.label — so "BUKTI_TRANSFER-SURAT_KETERANGAN_BANK"
now renders as "Surat Keterangan Bank" instead of the raw classifiedType.
Concerns / follow-ups for Efran
transformAktaPerubahanResultlikely has Item 1's exact bug (same
file, same unprefixed-key pattern) — affects PERUBAHAN_PT/AKUISISI/
PEMBUBARAN akta confidence. Not fixed here (out of scope, no live
evidence gathered for that flow). Recommend a dedicated trace+fix pass.- Confirm with the gpu-server team whether
"data."-rooted confidence keys
(vs. keys relative todata) is the intended, stable "akta" job contract
— this diverged from whatever shape the original code assumed, silently. - The new
RosterRowVM.confirmablefield is a small, additive contract
change (optional, backward-compatible) — touches the shared
RosterSection.tsxcomponent used by every tenant's rosters. Verified
zero behavior change for existing rows (confirmable defaults to
true-equivalent), full suites green. - Pemegang Saham → Bukti Setor cross-reference/orphan-highlighting inside
modal_saham(the legacy page's nested table) is still NOT rendered as
its own UI region on the V2 page — the projector carries
detail.buktiSetorRef/detail.matchedfor a future bespoke renderer to
consume, but today the PT V2 page just shows the two rosters as plain
tables via the genericRosterSection. Out of this smoke wave's scope,
flagging since the survey called it out as a real gap (§4's cascade
confirm, §5's WNA/mismatch highlighting).