think
16px
820px

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:

  1. 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 below CLASSIFIER_MIN_CONFIDENCE (0.6). It already emits AKTA_PENDIRIAN vs AKTA_PERUBAHAN as 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.

  1. 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 → Prisma DocumentType; getProcessingOrder (line 47) orders extraction.
    - Frontend: frontend/src/pages/KlasifikasiPage.tsx::detectedFlow (lines ~250–267) mirrors the ladder for the advisory DetectBanner, which is operator-overridable (onOverride={setManualFlowOverride}). On "Lanjut ke Ekstraksi", createSubmission posts the classified files; the backend returns data.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 of keputusan (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 (memory reference_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/PERUBAHAN guess 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 (in submissions.ts) continues to own the type decision and returns data.type; the frontend navigates. The submission-level classification result (incl. evidence) is persisted on the Submission (alongside the existing changeTypeResult pattern) for audit.
  • Every new SubmissionType MUST be dispatched in queueReMatchAndValidate (memory reference_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.detectedFlow extends to the new labels (mirror of the backend ladder).
  • DetectBanner shows the detected transaction type + confidence, with override — unchanged mechanism.
  • The Klasifikasi board / ClassificationListView label 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/PERUBAHAN prior 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 — inferSubmissionType ladder: 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_PERUBAHAN must not produce PERUBAHAN_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 memory reference_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_URL UNSET until the frontend surfacing (§6.5) ships. Until the klasifikasi label sets + board recognize the new AKTA_* 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 backend detectUnsupportedAktaType gate 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

  1. 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,password rejected — my key isn't in its authorized_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.
  2. 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.
  3. Where confirmed labels are stored. New table vs a flag/log on the existing document/classification records — decide during implementation planning (small, deferrable).
  4. 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.