Akta Transaction-Type Classifier — Design Spec
Date: 2026-07-01
Status: Approved direction, pending spec review
Author: Efran Nathanael (with Claude)
Related: docs/research/sabh-pt-untouched-flows-2026-07-01.md (PT roadmap), memory project_pt_transformation_roadmap
1. Overview & Goal
The PT roadmap adds four new SABH transaction flows — Akuisisi, Pelaporan RUPS Tahunan, Peleburan, Berakhirnya Status Badan Hukum — on top of the shipped Pendirian PT, Perubahan PT, and Perbaikan Data PT. Every flow starts from the same act: the user uploads a notarial deed (Akta) plus supporting documents.
Hard requirement (locked): there is no separate upload button per transaction type. The user uploads documents once; the system determines the transaction type from the Akta and routes to the correct flow. This UX already exists for Pendirian-vs-Perubahan; this spec extends its vocabulary to the four new types.
Goal of this spec: define the classification + routing foundation that all four new flows depend on. It does not design the flows themselves — each flow gets its own spec later. This is the shared substrate: given an uploaded Akta, name its transaction type and route the submission.
2. Scope
In scope
- A new in-app akta transaction-type sub-classifier that reads a notarial deed's content and names its transaction type.
- A swappable seam ("llm" now, "model" later) mirroring apostille-classifier.ts.
- Extension of the backend routing ladder (inferSubmissionType) and the frontend flow-detection (KlasifikasiPage.detectedFlow) to the new types.
- Feature-gating so the classifier can recognize a type before its flow ships.
- A persistence path for operator-confirmed labels (training-data flywheel).
Out of scope (explicit non-goals)
- Building the four transaction flows (Akuisisi / Pelaporan RUPS / Peleburan / Berakhirnya) — separate specs.
- Retraining the GPU ahu-doc-classifier (LayoutLMv3) — deferred until confirmed labels accrue; this spec only prepares the seam for it.
- Changing card / non-akta classification (KTP, NPWP, Bukti Setor, Domisili) — the GPU classifier keeps owning those unchanged.
- Merger PT — deprioritized (effort L, no in-wizard upload).
3. Current Architecture (grounded)
Classification today is two layers:
- Coarse doc-type — GPU
ahu-doc-classifier(trained LayoutLMv3 on x056).
backend/src/ocr/classifier.ts::classifyDocument(fileBuffer, filename)→POST {CLASSIFIER_URL}(https://x056.ahu-azure.val.id/api/classifier/classify) →{ classification: string, confidence: number }. Throws belowCLASSIFIER_MIN_CONFIDENCE(0.6). It already emitsAKTA_PENDIRIANvsAKTA_PERUBAHANas distinct labels — so today's transaction-type split is done inside the GPU model, reading the deed text/layout.
Confirmed internals (from the live GET /api/classifier/info, 2026-07-01): model ahu-classifier-v2, model_type: layoutlmv3 (base layoutlmv3-large), num_labels: 22, best_val_accuracy: 0.98, requires_ocr: true (ocr_provider: azure-di-layout-v3.1), trained via MLflow 2026-06-09. It is a single-stage trained classifier with a flat 22-label taxonomy — no separate "layout stage → text stage" pipeline; LayoutLMv3 fuses OCR tokens + 2D boxes + image in one head. The 22 labels mix coarse doc-type (KTP, NPWP, NPWP_LAMA, SIM, PASPOR, BUKTI_TRANSFER-*, DATA_KONTAK, SURAT_PERNYATAAN_DOMISILI*) with a few deed labels (AKTA_PENDIRIAN, AKTA_PERUBAHAN, AKTA_PEMINDAHAN_HAK, BERITA_ACARA_RUPS, PENETAPAN_GANTI_NAMA, SURAT_PERNYATAAN_PENDIRIAN_PP, SURAT_PERNYATAAN_PERBAIKAN_DATA_PT). None of the four roadmap transaction types (akuisisi / peleburan / pembubaran / berakhirnya / laporan-RUPS) exist as labels.
- Flow derivation — a rule in the app.
- Backend:backend/src/routes/submissions.ts::inferSubmissionType(files)(lines ~69–84) — a single-label precedence ladder over each file's effective label (userOverride ?? classification):Perbaikan > Perubahan > Pendirian PP > Pendirian PT.mapToDocumentType(line 26) maps the label → PrismaDocumentType;getProcessingOrder(line 47) orders extraction.
- Frontend:frontend/src/pages/KlasifikasiPage.tsx::detectedFlow(lines ~250–267) mirrors the ladder for the advisoryDetectBanner, which is operator-overridable (onOverride={setManualFlowOverride}). On "Lanjut ke Ekstraksi",createSubmissionposts the classified files; the backend returnsdata.type; the frontend navigates to the flow route.
Precedents this spec reuses:
- backend/src/services/apostille-classifier.ts — the swappable classification seam ("llm" in-app cleanup-LLM reading masthead+content / "model" future trained head), with deterministic overrides (the KTP↔KK rule) and a keyword-confidence tier. This is the template.
- backend/src/services/change-type-detector.ts — already reads akta text to detect PAD/PPAD/NONPAD jenis perubahan; proof that in-app semantic reading of deeds works here.
4. Why an in-app sub-classifier (not new GPU labels)
The four new types are not separable by title or layout — they are separated by the deed's content (the RUPS agenda / keputusan). Most corporate-action deeds are titled generically — "Pernyataan Keputusan Rapat" or "Berita Acara Rapat" — and the same-looking PKR may be a plain Perubahan, an Akuisisi (control-changing share transfer), a Pembubaran, or an annual RUPS report. A subset carry explicit titles (Akta Penggabungan / Peleburan / Pembubaran / Pengambilalihan), but the majority do not.
The live classifier proves this directly. ahu-classifier-v2 is a single LayoutLMv3 (§3) — a layout/vision-centric model. It already carries one label, BERITA_ACARA_RUPS, that is exactly the ambiguous shape: a BA/Risalah RUPS is one layout but many transaction meanings (perubahan approval, akuisisi share-transfer approval, pembubaran resolution, annual-report ratification). The classifier collapses them into a single label because its features (2D layout + image) carry almost no separating signal for a content-only distinction — the difference lives in the keputusan text, which a reading model must parse. So the transaction type is structurally unrecoverable by adding LayoutLMv3 classes: you would be asking a layout model to make a reading-comprehension call, on the rarest deeds, where its 0.98 accuracy on the current (layout-separable) 22 classes does not transfer.
Consequences:
- A layout model (LayoutLMv3) is structurally weak at content-only distinctions.
- The rare corporate actions (Akuisisi, Peleburan, Berakhirnya) have scarce labeled data — a cold start exactly where retraining needs samples.
- An untrained GPU model will not abstain: it will confidently map a Pembubaran deed to its nearest known class (AKTA_PERUBAHAN) above the 0.6 gate — a silent mis-route (the "confident-wrong" trap).
An LLM that reads the keputusan hits the signal directly, needs zero training data, and ships immediately. The trade — an LLM call per submission and no trained asset — is resolved by the migration seam (§5.5): confirmed labels become training data and graduate into the GPU model per-type later.
5. Design
5.1 The seam
New module backend/src/services/akta-txn-classifier.ts, structured like apostille-classifier.ts:
export const AKTA_TXN_TYPES = [
"pendirian", "perubahan", "akuisisi", "peleburan",
"pembubaran", "berakhirnya", "laporan_rups_tahunan",
] as const;
export type AktaTxnType = (typeof AKTA_TXN_TYPES)[number];
export interface AktaTxnClassification {
txnType: AktaTxnType;
confidence: number; // 0–1; keyword/heuristic tier for the "llm" backend
evidence: string | null; // the deed span that justified the label (audit/debug)
source: string; // "title-rule" | "llm" | "model" | "gpu-prior"
}
export async function classifyAktaTransactionType(
documentId: string,
): Promise<AktaTxnClassification>;
Backend selected by AKTA_TXN_CLASSIFIER = "llm" | "model" (default "llm"). "model" throws-loud until a trained head exists (same pattern as apostille's "model" guard) — a misconfig fails visibly, never silently mis-routes.
The function is pure-ish: reads the doc's cached OCR (reusing Document.ocrCache, the same cache the extractor uses — no double-OCR), returns the classification; it does not persist. The caller (submission pipeline) projects it.
5.2 In-app "llm" backend
- Input: the deed's
kepala akta+ premisse + first block ofkeputusan(not the whole deed — the type is decided in the opening pages). Slice the cached OCR text to a bounded prefix. - Model: the 35B extraction model already serving akta (per memory
reference_onprem_qwen36_serving), not the 3B. The classify prompt is short, so 35B latency is acceptable, and semantic deed-reading is exactly where the 3B is brittle (memoryreference_apostille_extraction_llm). - Output: guided-JSON
{ txnType: enum, evidence: string },temperature: 0. - Prompt: instruct the model to decide from what the deed decides (its RUPS keputusan / premisse), enumerate the seven types with one-line Indonesian descriptions, and return the deciding span as
evidence.
5.3 Deterministic title overrides (pre-LLM)
Before the LLM, short-circuit the clean-title cases (cheap, unambiguous), mirroring apostille's KTP rule:
Title regex (in kepala akta) |
txnType |
|---|---|
AKTA PELEBURAN |
peleburan |
AKTA PENGAMBILALIHAN |
akuisisi |
AKTA PEMBUBARAN |
pembubaran |
AKTA PENDIRIAN |
pendirian |
AKTA PENGGABUNGAN (Merger) is intentionally not an AktaTxnType — Merger is out of scope. A deed with that title is not forced into a wrong enum value; it is detected as an unsupported akta and handled by the §6.4 gating path (surface "belum didukung", operator decides), exactly like a recognized-but-unbuilt type. Generic-titled deeds (PERNYATAAN KEPUTUSAN RAPAT, BERITA ACARA RAPAT, RISALAH RAPAT) fall through to the LLM. This keeps the common explicit cases fast + deterministic and reserves the model for the genuinely ambiguous ones.
5.4 Confidence & low-confidence handling
- A title-rule hit → high confidence (auto-accept on the board).
- The LLM result → a keyword-confirmation confidence tier (as apostille does), not a raw model probability.
- Never auto-route on low confidence. Below a threshold, the deed surfaces on the Klasifikasi board in the amber "needs review" tier with the model's best guess pre-selected; the operator confirms/overrides. The GPU classifier's own
AKTA_PENDIRIAN/PERUBAHANguess is passed in as a prior (source: "gpu-prior") the sub-classifier may keep when it has no stronger signal.
5.5 The "model" backend + flywheel
Every classification the operator confirms or corrects on the Klasifikasi board is persisted as a labeled sample (deed OCR text + final txnType + who/when). Once a type accumulates enough confirmed samples, it can graduate to a trained model — flip AKTA_TXN_CLASSIFIER=model, no caller change.
Graduation target — a TEXT classifier, not new LayoutLMv3 classes. Because the distinction is semantic (§4), the durable trained replacement is a small fine-tuned text model (text-transformer trained on harvested deed-text → txnType labels), not new classes on the layout-centric ahu-classifier-v2. That text model could be hosted inside the classifier service as a second model / route behind /api/classifier (realizing a "one classification service" boundary) or in-app — the seam makes the location a late, reversible choice. Critically this is a cost/latency optimization for PROD scale, not a correctness requirement: the in-app LLM is a permanently valid backend. Graduation is optional and only pays off once volume makes a per-submission LLM call expensive.
5.6 Why not collapse the GPU's akta labels to a single AKTA
Considered: retrain ahu-classifier-v2 to drop AKTA_PENDIRIAN / AKTA_PERUBAHAN → one coarse AKTA, letting the in-app sub-classifier own all akta subtyping (one mechanism, no split-brain).
Decision: make the in-app sub-classifier the AUTHORITY for akta transaction type, but do NOT retrain the GPU to physically remove its two akta labels. Authority ≠ exclusivity:
- Routing keys off the in-app fine label — for the purpose of naming the transaction type, AKTA_PENDIRIAN and AKTA_PERUBAHAN from the GPU are treated as coarse AKTA and the in-app stage decides. This already gives the clean "one place owns akta subtyping" boundary, and freezes the GPU classifier w.r.t. new akta types (never retrain it to add akuisisi/peleburan/…).
- But keep the GPU's two labels physically, demoted to a free trained prior:
- Fast-path: GPU AKTA_PENDIRIAN/AKTA_PERUBAHAN at high confidence + an agreeing deed title → accept without an LLM call. Pendirian and Perubahan are the two highest-volume akta flows; at PROD scale this keeps their common path on the cheap trained classifier instead of paying a per-submission LLM call.
- Fallback: on LLM outage, fall back to the GPU's trained guess — otherwise Pendirian-vs-Perubahan would have no signal (§5.4).
Why not collapse: it costs a retrain + redeploy to remove information, and forfeits the cheap trained prior + outage resilience for the two most common flows — all to buy a tidiness that in-app authority already provides for free.
Scope: this concerns only AKTA_PENDIRIAN vs AKTA_PERUBAHAN (same document kind, different transaction). BERITA_ACARA_RUPS, AKTA_PEMINDAHAN_HAK, PENETAPAN_GANTI_NAMA are genuinely distinct supporting documents — they stay as their own coarse labels; do not fold them.
When collapsing would be fine: only if the GPU is being retrained anyway for other reasons and telemetry shows the pendirian/perubahan prior isn't earning its keep. Never as a standalone retrain.
6. Integration
6.1 Pipeline placement
upload → GPU classifier (coarse: is it an akta? which card?)
→ if label ∈ {AKTA_*}: akta-txn-classifier (fine: which txn type)
→ refined per-file akta label → inferSubmissionType → SubmissionType → route
The sub-classifier refines the coarse akta label into a fine one on the existing single-classification-string carrier. The seam's normalized AktaTxnType (lowercase) maps 1:1 onto an uppercase AKTA_* carrier label — pendirian→AKTA_PENDIRIAN, perubahan→AKTA_PERUBAHAN, akuisisi→AKTA_AKUISISI, peleburan→AKTA_PELEBURAN, pembubaran→AKTA_PEMBUBARAN, berakhirnya→AKTA_BERAKHIRNYA, laporan_rups_tahunan→AKTA_RUPS_TAHUNAN. Concretely, the akta file's stored classification / classifiedType becomes one of those AKTA_* strings. This minimizes churn: mapToDocumentType maps all AKTA_* → AKTA (they are all notarial deeds); only inferSubmissionType and the frontend ladder learn the new labels.
6.2 inferSubmissionType extension — priority ladder + doc-set disambiguation
The type is a submission-level decision derived from the set of akta sub-types plus supporting documents, not a single akta:
- Peleburan uploads both an Akta Peleburan and a new Akta Pendirian → the Peleburan label must win over Pendirian.
- Berakhirnya vs Pembubaran disambiguate on the supporting-doc set (Berakhirnya is a ~6-PDF package: likuidator final report + Pasal 149/152 pengumuman), not the deed title alone.
Extend the existing precedence ladder (most-specific-wins), e.g.:
Perbaikan > Berakhirnya (has likuidasi/pengumuman set) > Peleburan > Akuisisi > Perubahan > Laporan RUPS Tahunan > Pendirian PP > Pendirian PT.
Exact ordering + doc-set predicates finalized per-flow as each flow ships (§6.4). This ladder is the single source of truth; the frontend mirrors it.
6.3 Routing, createSubmission, reMatchAndValidate
createSubmission(insubmissions.ts) continues to own the type decision and returnsdata.type; the frontend navigates. The submission-level classification result (incl.evidence) is persisted on the Submission (alongside the existingchangeTypeResultpattern) for audit.- Every new
SubmissionTypeMUST be dispatched inqueueReMatchAndValidate(memoryreference_rematch_dispatch) — else field edits silently wipe validations. This is a hard checklist item per flow.
6.4 Feature-gating unbuilt flows
The sub-classifier's enum is the full target set from day one (so label collection / flywheel starts immediately). Routing is gated by a built-flows allowlist: inferSubmissionType only returns a type whose flow has shipped. A recognized-but-unbuilt akta (e.g. akuisisi before the Akuisisi flow lands) surfaces on the Klasifikasi board as "Terdeteksi: Akuisisi (belum didukung)" — the operator can override to a built type or is blocked from proceeding, never silently mis-routed into the wrong flow. Each flow's ship = add it to the allowlist + its ladder predicate + its reMatchAndValidate case.
6.5 Frontend
KlasifikasiPage.detectedFlowextends to the new labels (mirror of the backend ladder).DetectBannershows the detected transaction type + confidence, with override — unchanged mechanism.- The Klasifikasi board /
ClassificationListViewlabel set gains the new akta types (so an operator can re-map a deed's transaction type directly).
7. Error Handling & Failure Modes
- LLM outage: fall back to the GPU classifier's coarse
AKTA_PENDIRIAN/PERUBAHANprior at low confidence → amber review tier. Never hard-fail the upload; never auto-route. - Confident-wrong guard: because the untrained GPU model can pass a mislabel above 0.6, the sub-classifier's fine label supersedes the GPU akta subtype for routing. Tests assert a Pembubaran/Akuisisi deed does not route as Perubahan (§8).
- Multi-akta submissions: the ladder + doc-set predicates resolve which deed defines the submission; document the rule per flow.
- Ambiguous / unknown deed: low confidence → operator review, default to the safest built type or block.
8. Testing Strategy (TDD)
Write tests first, per superpowers:test-driven-development; DB tests target ahu_ocr_test (memory feedback_test_db_isolation).
- Fixtures: real (redacted) OCR-text snippets —
kepala akta+ first keputusan — for each of the seven types, incl. generically-titled PKR/BAR variants for akuisisi/pembubaran/laporan-rups. - Unit — deterministic title overrides: each explicit-title regex → correct
txnType; generic titles fall through. - Unit —
inferSubmissionTypeladder: Peleburan-beats-Pendirian; Berakhirnya doc-set vs bare Pembubaran; Akuisisi-beats-Perubahan; existing Pendirian/Perubahan/Perbaikan unaffected (regression). - Unit — seam:
"model"throws-loud;"llm"parses guided-JSON; low-confidence → review tier; LLM outage → GPU prior. - Guard — confident-wrong: a Pembubaran deed the GPU labels
AKTA_PERUBAHANmust not producePERUBAHAN_PT. - Gating: a recognized-but-unbuilt type does not route into a live flow.
9. Rollout / Config
AKTA_TXN_CLASSIFIER=llm(default) |model(future).- Uses the 35B akta-extraction LLM endpoint already configured for
document-processor(ahu-vllm@8001 per memoryreference_onprem_qwen36_serving) — not the 3B cleanup endpoint (CLEANUP_LLM_URL@8003), which §5.2 rejects as too brittle for semantic deed-reading. The exact env var is settled in the implementation plan. - Ships with the classifier recognizing all seven types but only Pendirian/Perubahan/Perbaikan routing enabled; each new flow flips its allowlist entry when it ships. Akuisisi is the first flow to light up — it needs the smallest net-new (it maps 1:1 onto the akta-perubahan pipeline).
- ⚠️ Keep
AKTA_TXN_CLASSIFIER_URLUNSET until the frontend surfacing (§6.5) ships. Until the klasifikasi label sets + board recognize the newAKTA_*labels, an ON classifier that refines a deed to an unbuilt type makes the frontend drop that file (its label isn't in/api/klasifikasi/labels) as a non-blocking "tidak dikenali" warning — the backenddetectUnsupportedAktaTypegate never sees it, so "belum didukung" is not surfaced. The gate is a correct safety net; it is simply unreachable via the UI pre-surfacing. Default-OFF is the intended pre-§6.5 state.
10. Open Questions / Dependencies
- GPU classifier internals — RESOLVED. Introspected via
GET /api/classifier/info(2026-07-01):ahu-classifier-v2, LayoutLMv3-large, 22 flat labels, Azure-DI OCR, MLflow-trained, 0.98 val-acc (§3). SSH to the box is still unavailable (publickey,passwordrejected — my key isn't in itsauthorized_keys); only needed to touch the training pipeline itself (i.e. the optional graduation), not for the in-app build. Action: add my key to x056 if/when we build the graduation text model. - Pembubaran vs Berakhirnya enum boundary. They are distinct
SubmissionTypes but the initiating deed can overlap; the doc-set predicate (§6.2) is the disambiguator. Finalize the exact predicate during the Berakhirnya flow spec. - Where confirmed labels are stored. New table vs a flag/log on the existing document/classification records — decide during implementation planning (small, deferrable).
- Laporan RUPS Tahunan deed shape. Its akta is a Risalah/BA RUPS Tahunan (annual-report approval, no AD change); confirm the fixture distinguishes it from a Perubahan BA RUPS by the agenda (approval of laporan tahunan) not the layout.