Akta Transaction-Type Classifier Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Auto-determine a notarial deed's PT transaction type from its content at klasifikasi time — with no per-type upload button — by adding an in-app LLM sub-classifier that refines the GPU classifier's coarse akta label, gated so it only routes to already-built flows.
Architecture: Keep the trained GPU LayoutLMv3 (ahu-classifier-v2) for coarse doc-type. Add a new in-app module akta-txn-classifier.ts that reads a deed's OCR text and returns one of 7 transaction types via a swappable "llm" | "model" seam (mirrors apostille-classifier.ts). Wire it into the klasifikasi /classify/:fileId step (the routing decision point) to refine an akta label into a fine AKTA_* label; extend inferSubmissionType to route built types and reject unbuilt ones as "belum didukung". Recognizing a new type never adds a SubmissionType enum value (which would trip compile-time exhaustiveness checks) until that flow ships.
Tech Stack: Bun + Hono + TypeScript; Prisma 7 / PostgreSQL; bun:test; PaddleOCR via getLayoutProvider("paddleocr"); on-prem vLLM (Qwen3.6-35B-A3B-FP8) via an OpenAI-compatible /chat/completions endpoint with guided_json.
Global Constraints
- Test DB is
ahu_ocr_test, selected bybunfig.tomlpreload ofsrc/test-setup.ts. NEVER runbun testagainst the dev DB. (memory: test DB isolation) - New
SubmissionType/DocumentTypeenum values are FORBIDDEN in this plan —dispatchProcessorandreMatchAndValidateenforceconst _exhaustive: never, so a new enum value forces building a whole flow. New akta types are classification STRINGS only, mapped toDocumentType.AKTA. - The seam reads
process.envdirectly at call time (likeapostille-classifier.ts), NOTconfig.ts— required for thefreshClient()re-import test pattern. - Behavior for existing flows (Pendirian/Perubahan) MUST be preserved: the sub-classifier only overrides to a NEW type; it never flips
pendirian↔perubahan(the GPU is the trained authority for those two — spec §5.6). - The whole wiring is default-OFF: if
AKTA_TXN_CLASSIFIER_URLis unset, the klasifikasi path behaves exactly as today (no extra OCR, no LLM call). - Enum spellings (verbatim):
AktaTxnType = "pendirian" | "perubahan" | "akuisisi" | "peleburan" | "pembubaran" | "berakhirnya" | "laporan_rups_tahunan". Carrier labels:AKTA_PENDIRIAN | AKTA_PERUBAHAN | AKTA_AKUISISI | AKTA_PELEBURAN | AKTA_PEMBUBARAN | AKTA_BERAKHIRNYA | AKTA_RUPS_TAHUNAN. Built (routable) types today:pendirian,perubahanonly. - Reference spec:
docs/superpowers/specs/2026-07-01-akta-transaction-type-classifier-design.md.
File Structure
| File | Responsibility | Action |
|---|---|---|
backend/src/services/akta-txn-classifier.ts |
The classifier: enum, label maps, deterministic title rules, LLM backend, seam | Create |
backend/src/services/__tests__/akta-txn-classifier.test.ts |
Unit tests (pure + fetch-mocked) | Create |
backend/src/services/__tests__/fixtures/akta-txn-*.txt |
Representative deed OCR-text fixtures per type | Create |
backend/src/routes/klasifikasi.ts |
/classify/:fileId: refine akta label; mapToGroupKey recognizes new labels |
Modify |
backend/src/routes/submissions.ts |
inferSubmissionType, mapToDocumentType, getProcessingOrder, new detectUnsupportedAktaType, create-endpoint guard |
Modify |
backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts |
Route-level test for the refine + gating | Create |
Phase B (follow-on, co-lands with the Akuisisi flow — NOT built here): frontend surfacing (detect-banner.tsx, KlasifikasiPage.tsx, classifier-labels.ts), the operator-confirmed-label flywheel export, extraction-time re-verification (document-processor.ts:373), and submission-level doc-set disambiguation (Berakhirnya vs Pembubaran). These deliver value only once a new type is routable; see the final section.
Phase 1 — The classifier module (pure, no I/O)
Task 1: Enum, label maps, and the classification contract
Files:
- Create: backend/src/services/akta-txn-classifier.ts
- Test: backend/src/services/__tests__/akta-txn-classifier.test.ts
Interfaces:
- Produces: AKTA_TXN_TYPES: readonly AktaTxnType[], type AktaTxnType, AKTA_TXN_TYPE_TO_LABEL: Record<AktaTxnType,string>, AKTA_LABEL_TO_TXN_TYPE: Record<string,AktaTxnType>, BUILT_AKTA_TXN_TYPES: ReadonlySet<AktaTxnType>, interface AktaTxnClassification.
- [ ] Step 1: Write the failing test
// backend/src/services/__tests__/akta-txn-classifier.test.ts
import { describe, it, expect } from "bun:test";
import {
AKTA_TXN_TYPES,
AKTA_TXN_TYPE_TO_LABEL,
AKTA_LABEL_TO_TXN_TYPE,
BUILT_AKTA_TXN_TYPES,
} from "../akta-txn-classifier";
describe("akta txn taxonomy — structural integrity", () => {
it("has 7 types and a bijective type<->label mapping", () => {
expect(AKTA_TXN_TYPES.length).toBe(7);
for (const t of AKTA_TXN_TYPES) {
const label = AKTA_TXN_TYPE_TO_LABEL[t];
expect(label, `${t} has no label`).toBeDefined();
expect(AKTA_LABEL_TO_TXN_TYPE[label]).toBe(t);
}
expect(Object.keys(AKTA_LABEL_TO_TXN_TYPE).length).toBe(7);
});
it("only pendirian + perubahan are built/routable today", () => {
expect([...BUILT_AKTA_TXN_TYPES].sort()).toEqual(["pendirian", "perubahan"]);
});
it("maps the two existing GPU labels 1:1", () => {
expect(AKTA_TXN_TYPE_TO_LABEL.pendirian).toBe("AKTA_PENDIRIAN");
expect(AKTA_TXN_TYPE_TO_LABEL.perubahan).toBe("AKTA_PERUBAHAN");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: FAIL — Cannot find module "../akta-txn-classifier".
- [ ] Step 3: Write minimal implementation
// backend/src/services/akta-txn-classifier.ts
/**
* akta-txn-classifier.ts — the swappable in-app "akta transaction-type"
* sub-classifier. Refines the GPU classifier's coarse akta label into the
* PT transaction type by READING the deed's keputusan (the GPU LayoutLMv3
* can't — a Berita Acara RUPS is one layout but many transaction meanings).
* Mirrors apostille-classifier.ts: "llm" backend now, "model" (trained text
* classifier) later, behind AKTA_TXN_CLASSIFIER.
*/
export const AKTA_TXN_TYPES = [
"pendirian", "perubahan", "akuisisi", "peleburan",
"pembubaran", "berakhirnya", "laporan_rups_tahunan",
] as const;
export type AktaTxnType = (typeof AKTA_TXN_TYPES)[number];
/** The txn type <-> the classification-string carrier used across the pipeline. */
export const AKTA_TXN_TYPE_TO_LABEL: Record<AktaTxnType, string> = {
pendirian: "AKTA_PENDIRIAN",
perubahan: "AKTA_PERUBAHAN",
akuisisi: "AKTA_AKUISISI",
peleburan: "AKTA_PELEBURAN",
pembubaran: "AKTA_PEMBUBARAN",
berakhirnya: "AKTA_BERAKHIRNYA",
laporan_rups_tahunan: "AKTA_RUPS_TAHUNAN",
};
export const AKTA_LABEL_TO_TXN_TYPE: Record<string, AktaTxnType> = Object.fromEntries(
(Object.entries(AKTA_TXN_TYPE_TO_LABEL) as [AktaTxnType, string][]).map(([t, l]) => [l, t]),
);
/** Types whose downstream flow exists and can be routed. Grow this per shipped flow. */
export const BUILT_AKTA_TXN_TYPES: ReadonlySet<AktaTxnType> = new Set<AktaTxnType>([
"pendirian",
"perubahan",
]);
export interface AktaTxnClassification {
txnType: AktaTxnType;
/** carrier label (AKTA_TXN_TYPE_TO_LABEL[txnType]) — convenience for callers. */
label: string;
confidence: number; // 0–1 heuristic tier, not a trained probability
evidence: string | null; // the deciding deed span (audit/debug)
supported: boolean; // BUILT_AKTA_TXN_TYPES.has(txnType)
source: string; // "title-rule" | "llm" | "gpu-prior"
}
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: PASS (3 tests).
- [ ] Step 5: Commit
git add backend/src/services/akta-txn-classifier.ts backend/src/services/__tests__/akta-txn-classifier.test.ts
git commit -m "feat(classifier): akta txn-type taxonomy + label maps"
Task 2: Deterministic title rules
Files:
- Modify: backend/src/services/akta-txn-classifier.ts
- Test: backend/src/services/__tests__/akta-txn-classifier.test.ts
Interfaces:
- Produces: detectAktaTitleType(rawText: string): { txnType: AktaTxnType | null; unsupportedTitle: boolean }. unsupportedTitle is true for a clean out-of-scope title (Merger's AKTA PENGGABUNGAN); txnType: null + unsupportedTitle: false means "no clean title — defer to the LLM".
- [ ] Step 1: Write the failing test
import { detectAktaTitleType } from "../akta-txn-classifier";
describe("detectAktaTitleType — clean-title short-circuit", () => {
it("reads explicit corporate-action titles", () => {
expect(detectAktaTitleType("SALINAN\nAKTA PELEBURAN PERSEROAN TERBATAS").txnType).toBe("peleburan");
expect(detectAktaTitleType("AKTA PENGAMBILALIHAN \"PT MAJU\"").txnType).toBe("akuisisi");
expect(detectAktaTitleType("AKTA PEMBUBARAN PERSEROAN").txnType).toBe("pembubaran");
expect(detectAktaTitleType("AKTA PENDIRIAN PERSEROAN TERBATAS").txnType).toBe("pendirian");
});
it("flags Merger (out of scope) as an unsupported title, not a wrong enum", () => {
const r = detectAktaTitleType("AKTA PENGGABUNGAN PERSEROAN TERBATAS");
expect(r.txnType).toBeNull();
expect(r.unsupportedTitle).toBe(true);
});
it("defers generic PKR / Berita Acara deeds to the LLM (null, not unsupported)", () => {
const r = detectAktaTitleType("PERNYATAAN KEPUTUSAN RAPAT\nPT SEJAHTERA");
expect(r.txnType).toBeNull();
expect(r.unsupportedTitle).toBe(false);
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: FAIL — detectAktaTitleType is not a function.
- [ ] Step 3: Write minimal implementation (append to
akta-txn-classifier.ts)
/** Only the first ~800 chars carry the kepala akta / title. */
const titleWindow = (rawText: string) => (rawText || "").slice(0, 800);
const TITLE_RULES: { re: RegExp; txnType: AktaTxnType }[] = [
{ re: /AKTA\s+PELEBURAN/i, txnType: "peleburan" },
{ re: /AKTA\s+PENGAMBILALIHAN/i, txnType: "akuisisi" },
{ re: /AKTA\s+PEMBUBARAN/i, txnType: "pembubaran" },
{ re: /AKTA\s+PENDIRIAN/i, txnType: "pendirian" },
];
/** Merger is out of scope — recognized so we can flag "belum didukung", never mis-enum'd. */
const UNSUPPORTED_TITLE_RE = /AKTA\s+PENGGABUNGAN/i;
export function detectAktaTitleType(
rawText: string,
): { txnType: AktaTxnType | null; unsupportedTitle: boolean } {
const head = titleWindow(rawText);
for (const { re, txnType } of TITLE_RULES) {
if (re.test(head)) return { txnType, unsupportedTitle: false };
}
if (UNSUPPORTED_TITLE_RE.test(head)) return { txnType: null, unsupportedTitle: true };
return { txnType: null, unsupportedTitle: false };
}
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: PASS.
- [ ] Step 5: Commit
git add backend/src/services/akta-txn-classifier.ts backend/src/services/__tests__/akta-txn-classifier.test.ts
git commit -m "feat(classifier): deterministic akta title rules"
Phase 2 — The LLM backend + seam
Task 3: classifyAktaTransactionType seam (title → LLM → GPU prior)
Files:
- Modify: backend/src/services/akta-txn-classifier.ts
- Test: backend/src/services/__tests__/akta-txn-classifier.test.ts
Interfaces:
- Consumes: detectAktaTitleType (Task 2), the taxonomy (Task 1).
- Produces: classifyAktaTransactionType(rawText: string, opts?: { gpuPrior?: AktaTxnType | null; gpuPriorConfidence?: number }): Promise<AktaTxnClassification>. Reads process.env.AKTA_TXN_CLASSIFIER ("llm" default / "model" throws), AKTA_TXN_CLASSIFIER_URL, AKTA_TXN_CLASSIFIER_MODEL (default "Qwen/Qwen3.6-35B-A3B-FP8"), AKTA_TXN_CLASSIFIER_TIMEOUT_MS (default 30000).
- [ ] Step 1: Write the failing test (fetch-mock pattern from
doc-forensic-client.test.ts)
import { describe, it, expect, afterEach, mock } from "bun:test";
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
delete process.env.AKTA_TXN_CLASSIFIER;
delete process.env.AKTA_TXN_CLASSIFIER_URL;
});
async function fresh() { return await import("../akta-txn-classifier"); }
describe("classifyAktaTransactionType", () => {
it("uses a clean title without calling the LLM", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
let called = false;
globalThis.fetch = mock(async () => { called = true; return new Response("{}"); }) as never;
const { classifyAktaTransactionType } = await fresh();
const r = await classifyAktaTransactionType("AKTA PEMBUBARAN PERSEROAN");
expect(r.txnType).toBe("pembubaran");
expect(r.label).toBe("AKTA_PEMBUBARAN");
expect(r.supported).toBe(false);
expect(r.source).toBe("title-rule");
expect(called).toBe(false);
});
it("calls the LLM for a generic PKR deed and parses guided-JSON", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
globalThis.fetch = mock(async () =>
new Response(JSON.stringify({
choices: [{ message: { content: '{"txnType":"akuisisi","evidence":"pengambilalihan seluruh saham"}' } }],
}), { status: 200 }),
) as never;
const { classifyAktaTransactionType } = await fresh();
const r = await classifyAktaTransactionType("PERNYATAAN KEPUTUSAN RAPAT ... pengambilalihan saham");
expect(r.txnType).toBe("akuisisi");
expect(r.source).toBe("llm");
expect(r.evidence).toContain("pengambilalihan");
});
it("falls back to the GPU prior on LLM outage (never throws)", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
globalThis.fetch = mock(async () => new Response("boom", { status: 502 })) as never;
const { classifyAktaTransactionType } = await fresh();
const r = await classifyAktaTransactionType("PERNYATAAN KEPUTUSAN RAPAT", { gpuPrior: "perubahan", gpuPriorConfidence: 0.97 });
expect(r.txnType).toBe("perubahan");
expect(r.source).toBe("gpu-prior");
});
it("falls back to the GPU prior when no URL is configured (default-off)", async () => {
const { classifyAktaTransactionType } = await fresh();
const r = await classifyAktaTransactionType("PERNYATAAN KEPUTUSAN RAPAT", { gpuPrior: "pendirian" });
expect(r.txnType).toBe("pendirian");
expect(r.source).toBe("gpu-prior");
});
it("throws loudly for the not-yet-wired model backend", async () => {
process.env.AKTA_TXN_CLASSIFIER = "model";
const { classifyAktaTransactionType } = await fresh();
await expect(classifyAktaTransactionType("x")).rejects.toThrow(/not wired/i);
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: FAIL — classifyAktaTransactionType is not a function.
- [ ] Step 3: Write minimal implementation (append to
akta-txn-classifier.ts)
const AKTA_TXN_GUIDED_JSON = {
type: "object",
properties: {
txnType: { type: "string", enum: [...AKTA_TXN_TYPES] },
evidence: { type: "string" },
},
required: ["txnType", "evidence"],
additionalProperties: false,
} as const;
const SYSTEM_PROMPT =
"You classify an Indonesian PT (Perseroan Terbatas) notarial deed by the CORPORATE ACTION it effects, " +
"read from its title (kepala akta), premisse, and RUPS keputusan — NOT its layout. Return JSON {txnType, evidence}. " +
"txnType is one of: pendirian (a brand-new PT is founded), perubahan (amendment of an existing PT's anggaran dasar/data), " +
"akuisisi (pengambilalihan — a share transfer that changes control of an existing PT), " +
"peleburan (konsolidasi — two+ PTs merge into a NEW entity), pembubaran (the PT is dissolved/liquidated), " +
"berakhirnya (the PT's legal personhood ends after liquidation), laporan_rups_tahunan (annual RUPS ratifying the yearly report, no anggaran dasar change). " +
"evidence is the short deed span that decided it. Return ONLY the JSON.";
const asTxnType = (v: unknown): AktaTxnType | null =>
(AKTA_TXN_TYPES as readonly string[]).includes(String(v)) ? (v as AktaTxnType) : null;
const KEYWORD_CONFIRM: Partial<Record<AktaTxnType, RegExp>> = {
akuisisi: /pengambilalihan|akuisisi/i,
peleburan: /peleburan|konsolidasi/i,
pembubaran: /pembubaran|likuidasi/i,
berakhirnya: /berakhirnya\s+status|hapusnya\s+badan\s+hukum/i,
laporan_rups_tahunan: /laporan\s+tahunan|rups\s+tahunan/i,
};
function keywordConfidence(t: AktaTxnType, rawText: string): number {
const re = KEYWORD_CONFIRM[t];
if (!re) return 0.75; // pendirian/perubahan — the GPU prior covers these
return re.test(rawText) ? 0.9 : 0.6;
}
function build(txnType: AktaTxnType, confidence: number, evidence: string | null, source: string): AktaTxnClassification {
return { txnType, label: AKTA_TXN_TYPE_TO_LABEL[txnType], confidence, evidence, supported: BUILT_AKTA_TXN_TYPES.has(txnType), source };
}
async function classifyViaLlm(rawText: string): Promise<{ txnType: AktaTxnType; evidence: string | null } | null> {
const url = process.env.AKTA_TXN_CLASSIFIER_URL || "";
const model = process.env.AKTA_TXN_CLASSIFIER_MODEL || "Qwen/Qwen3.6-35B-A3B-FP8";
const timeoutMs = parseInt(process.env.AKTA_TXN_CLASSIFIER_TIMEOUT_MS || "30000", 10);
if (!url || !rawText.trim()) return null;
try {
const res = await fetch(`${url}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model, temperature: 0,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: rawText.slice(0, 8000) },
],
guided_json: AKTA_TXN_GUIDED_JSON,
}),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) { console.error(`[akta-txn-classifier] LLM responded ${res.status}`); return null; }
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const content = json.choices?.[0]?.message?.content;
if (!content) return null;
const m = content.match(/\{[\s\S]*\}/);
if (!m) return null;
const parsed = JSON.parse(m[0]) as { txnType?: unknown; evidence?: unknown };
const t = asTxnType(parsed.txnType);
if (!t) return null;
return { txnType: t, evidence: typeof parsed.evidence === "string" ? parsed.evidence : null };
} catch (err) {
console.error("[akta-txn-classifier] LLM classification failed:", err);
return null;
}
}
export async function classifyAktaTransactionType(
rawText: string,
opts?: { gpuPrior?: AktaTxnType | null; gpuPriorConfidence?: number },
): Promise<AktaTxnClassification> {
if ((process.env.AKTA_TXN_CLASSIFIER || "llm").toLowerCase() === "model") {
throw new Error("AKTA_TXN_CLASSIFIER=model is not wired yet — no trained akta-txn text classifier exists. Use =llm.");
}
// 1. Deterministic clean-title short-circuit.
const title = detectAktaTitleType(rawText);
if (title.txnType) return build(title.txnType, 0.95, "kepala akta title", "title-rule");
// (unsupportedTitle e.g. Merger falls through to LLM/prior; the caller's gate handles it.)
// 2. LLM reads the keputusan.
const llm = await classifyViaLlm(rawText);
if (llm) return build(llm.txnType, keywordConfidence(llm.txnType, rawText), llm.evidence, "llm");
// 3. Outage / disabled: lean on the GPU's trained prior (spec §5.4/§5.6).
const prior = opts?.gpuPrior ?? "perubahan";
return build(prior, opts?.gpuPriorConfidence ?? 0.5, null, "gpu-prior");
}
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: PASS (all Phase 1 + 2 tests).
- [ ] Step 5: Commit
git add backend/src/services/akta-txn-classifier.ts backend/src/services/__tests__/akta-txn-classifier.test.ts
git commit -m "feat(classifier): akta txn-type LLM seam with title short-circuit + GPU-prior fallback"
Task 4: Representative deed fixtures + a keputusan-classification test
Files:
- Create: backend/src/services/__tests__/fixtures/akta-txn-akuisisi-pkr.txt
- Create: backend/src/services/__tests__/fixtures/akta-txn-pembubaran-pkr.txt
- Create: backend/src/services/__tests__/fixtures/akta-txn-laporan-rups.txt
- Test: backend/src/services/__tests__/akta-txn-classifier.test.ts
These are generically-titled (PKR / Berita Acara) deeds whose type lives in the keputusan — the exact case the GPU model can't separate. Replace/augment with real redacted deeds during rollout; the LLM is mocked in tests so these drive the prompt-shape assertions.
- [ ] Step 1: Create fixture — akuisisi PKR
// backend/src/services/__tests__/fixtures/akta-txn-akuisisi-pkr.txt
PERNYATAAN KEPUTUSAN RAPAT
PT SUMBER MAKMUR SENTOSA
Nomor: 12
Rapat Umum Pemegang Saham memutuskan menyetujui pengambilalihan (akuisisi) seluruh
saham milik pemegang saham lama oleh PT Investor Nusantara sehingga terjadi
perubahan pengendalian Perseroan.
- [ ] Step 2: Create fixture — pembubaran PKR
// backend/src/services/__tests__/fixtures/akta-txn-pembubaran-pkr.txt
BERITA ACARA RAPAT
PT KARYA ABADI
Nomor: 07
Rapat memutuskan membubarkan Perseroan dan menunjuk likuidator untuk melakukan
pemberesan harta kekayaan Perseroan sesuai Pasal 142 UUPT.
- [ ] Step 3: Create fixture — laporan RUPS tahunan
// backend/src/services/__tests__/fixtures/akta-txn-laporan-rups.txt
RISALAH RAPAT UMUM PEMEGANG SAHAM TAHUNAN
PT MITRA SEJATI
Nomor: 03
Rapat menyetujui dan mengesahkan Laporan Tahunan dan Laporan Keuangan Perseroan
tahun buku 2025 serta memberikan pelunasan dan pembebasan tanggung jawab kepada Direksi.
- [ ] Step 4: Write the failing test (fixtures feed a mocked LLM that echoes the enum; asserts the classifier plumbs evidence + supported flag)
import { readFileSync } from "fs";
import { join } from "path";
const fx = (n: string) => readFileSync(join(import.meta.dir, "fixtures", n), "utf-8");
describe("classifier over generic-titled deed fixtures", () => {
it("routes an akuisisi PKR as unsupported (recognized, not yet built)", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
globalThis.fetch = mock(async () =>
new Response(JSON.stringify({ choices: [{ message: { content: '{"txnType":"akuisisi","evidence":"pengambilalihan"}' } }] })),
) as never;
const { classifyAktaTransactionType } = await fresh();
const r = await classifyAktaTransactionType(fx("akta-txn-akuisisi-pkr.txt"));
expect(r.txnType).toBe("akuisisi");
expect(r.supported).toBe(false);
});
});
- [ ] Step 5: Run + Commit
Run: cd backend && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts
Expected: PASS.
git add backend/src/services/__tests__/fixtures/akta-txn-*.txt backend/src/services/__tests__/akta-txn-classifier.test.ts
git commit -m "test(classifier): generic-titled deed fixtures for akta txn classification"
Phase 3 — Klasifikasi-time wiring (the routing decision point)
Task 5: Refine the akta label in /classify/:fileId
Files:
- Modify: backend/src/routes/klasifikasi.ts (the /classify/:fileId handler, ~lines 150-203, and mapToGroupKey ~102-111)
- Test: backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
Interfaces:
- Consumes: classifyAktaTransactionType, AKTA_TXN_TYPE_TO_LABEL, AKTA_LABEL_TO_TXN_TYPE, BUILT_AKTA_TXN_TYPES (Phase 1/2); getLayoutProvider("paddleocr").
- Produces: the /classify response's rawClassification becomes a fine AKTA_* label when the sub-classifier is enabled and detects a NEW type; unchanged otherwise.
Behavior (conservative, non-regressing): run only when (a) AKTA_TXN_CLASSIFIER_URL is set AND (b) the GPU coarse label ∈ {AKTA_PENDIRIAN,AKTA_PERUBAHAN}. OCR the temp file via PaddleOCR, pass the GPU label as gpuPrior. Override the label ONLY if the sub-classifier returns a NEW (non-pendirian/perubahan) type — never flip pendirian↔perubahan (the GPU is authority for those two).
- [ ] Step 1: Write the failing test
// backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
import { describe, it, expect } from "bun:test";
import { refineAktaLabel } from "../klasifikasi";
describe("refineAktaLabel — conservative override", () => {
const ocr = async () => "PERNYATAAN KEPUTUSAN RAPAT ... pembubaran perseroan";
it("keeps the GPU label when disabled (no URL)", async () => {
delete process.env.AKTA_TXN_CLASSIFIER_URL;
expect(await refineAktaLabel("AKTA_PERUBAHAN", ocr)).toBe("AKTA_PERUBAHAN");
});
it("overrides to a NEW type when the sub-classifier detects one", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
const cls = async () => ({ txnType: "pembubaran" as const, label: "AKTA_PEMBUBARAN", confidence: 0.9, evidence: null, supported: false, source: "llm" });
expect(await refineAktaLabel("AKTA_PERUBAHAN", ocr, cls)).toBe("AKTA_PEMBUBARAN");
});
it("does NOT flip pendirian<->perubahan (GPU stays authority)", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://llm.local/v1";
const cls = async () => ({ txnType: "pendirian" as const, label: "AKTA_PENDIRIAN", confidence: 0.9, evidence: null, supported: true, source: "llm" });
expect(await refineAktaLabel("AKTA_PERUBAHAN", ocr, cls)).toBe("AKTA_PERUBAHAN");
});
it("ignores non-akta coarse labels", async () => {
expect(await refineAktaLabel("KTP", ocr)).toBe("KTP");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: FAIL — refineAktaLabel is not exported.
- [ ] Step 3: Write minimal implementation — add to
klasifikasi.ts
import { getLayoutProvider } from "../ocr/provider";
import {
classifyAktaTransactionType,
AKTA_LABEL_TO_TXN_TYPE,
type AktaTxnClassification,
} from "../services/akta-txn-classifier";
const COARSE_AKTA_LABELS = new Set(["AKTA_PENDIRIAN", "AKTA_PERUBAHAN"]);
/**
* Refine a GPU coarse akta label into a fine transaction-type label.
* Conservative: only OVERRIDES to a NEW (non-pendirian/perubahan) type; leaves the
* two GPU-authoritative types untouched. Injectable OCR + classifier for tests.
*/
export async function refineAktaLabel(
coarseLabel: string,
ocrText: () => Promise<string>,
classify: (t: string, o?: { gpuPrior?: string | null }) => Promise<AktaTxnClassification> =
(t, o) => classifyAktaTransactionType(t, { gpuPrior: (o?.gpuPrior as never) ?? null }),
): Promise<string> {
if (!process.env.AKTA_TXN_CLASSIFIER_URL || !COARSE_AKTA_LABELS.has(coarseLabel)) return coarseLabel;
try {
const text = await ocrText();
const prior = AKTA_LABEL_TO_TXN_TYPE[coarseLabel];
const r = await classify(text, { gpuPrior: prior });
// Override only to a genuinely new type; never flip pendirian<->perubahan.
if (r.txnType !== "pendirian" && r.txnType !== "perubahan") return r.label;
return coarseLabel;
} catch (err) {
console.warn("[klasifikasi] akta txn refine failed, keeping GPU label:", err);
return coarseLabel;
}
}
Then, in the /classify/:fileId handler after const rawClassification = data.classification as string;, refine before mapToGroupKey:
const refinedClassification = await refineAktaLabel(rawClassification, async () => {
const layout = await getLayoutProvider("paddleocr");
const ocr = await layout.extractTextWithWords(fileBuffer, { unwarp: false, orient: false });
return ocr.rawText ?? "";
});
const groupKey = mapToGroupKey(refinedClassification);
// ...return { ...classification: groupKey, rawClassification: refinedClassification, ... }
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: PASS.
- [ ] Step 5: Commit
git add backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
git commit -m "feat(klasifikasi): refine akta label via in-app txn sub-classifier (default-off, conservative)"
Task 6: Recognize new akta labels in mapToGroupKey + mapToDocumentType + getProcessingOrder
Files:
- Modify: backend/src/routes/klasifikasi.ts (mapToGroupKey)
- Modify: backend/src/routes/submissions.ts (mapToDocumentType line 26, getProcessingOrder line 47)
- Test: backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
Interfaces:
- Consumes: AKTA_TXN_TYPE_TO_LABEL values.
- Produces: every AKTA_* fine label maps to DocumentType.AKTA, processing order 0, and its own klasifikasi group key.
- [ ] Step 1: Write the failing test
import { mapToDocumentType, getProcessingOrder } from "../submissions";
describe("new akta labels map to AKTA doc-type + order 0", () => {
for (const label of ["AKTA_AKUISISI", "AKTA_PELEBURAN", "AKTA_PEMBUBARAN", "AKTA_BERAKHIRNYA", "AKTA_RUPS_TAHUNAN"]) {
it(`${label} -> AKTA / order 0`, () => {
expect(mapToDocumentType(label)).toBe("AKTA");
expect(getProcessingOrder(label)).toBe(0);
});
}
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: FAIL — AKTA_AKUISISI currently hits the getProcessingOrder default (4), not 0.
- [ ] Step 3: Write minimal implementation
In submissions.ts mapToDocumentType (after line 28), add a prefix rule so all fine akta labels resolve to AKTA:
if (classification.startsWith("AKTA_") && classification !== "AKTA_PEMINDAHAN_HAK") return "AKTA";
In getProcessingOrder (after line 49):
if (classification.startsWith("AKTA_") && classification !== "AKTA_PEMINDAHAN_HAK") return 0;
In klasifikasi.ts mapToGroupKey, add the fine akta labels as pass-through group keys (they are their own group) — no remap needed since unknown akta labels already pass through; add an explicit allow so they're recognized rather than landing in OTHER:
if (raw.startsWith("AKTA_")) return raw; // fine akta txn labels are their own group
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: PASS.
- [ ] Step 5: Commit
git add backend/src/routes/klasifikasi.ts backend/src/routes/submissions.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
git commit -m "feat(klasifikasi): map fine akta labels to AKTA doc-type + processing order"
Phase 4 — Submission-type gating (no mis-routing of unbuilt types)
Task 7: detectUnsupportedAktaType + create-endpoint guard
Files:
- Modify: backend/src/routes/submissions.ts (add detectUnsupportedAktaType; guard in the POST create handler ~line 240, before db.submission.create)
- Test: backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
Interfaces:
- Consumes: AKTA_LABEL_TO_TXN_TYPE, BUILT_AKTA_TXN_TYPES.
- Produces: detectUnsupportedAktaType(files: InferableFile[]): AktaTxnType | null — the first recognized-but-unbuilt akta type among the files, else null. The create endpoint returns { supported: false, detectedType } (HTTP 200) instead of creating a mis-routed submission.
- [ ] Step 1: Write the failing test
import { detectUnsupportedAktaType, inferSubmissionType } from "../submissions";
describe("detectUnsupportedAktaType — gating", () => {
it("flags an unbuilt akta type", () => {
expect(detectUnsupportedAktaType([{ classification: "AKTA_AKUISISI" }])).toBe("akuisisi");
});
it("returns null for built types (pendirian/perubahan)", () => {
expect(detectUnsupportedAktaType([{ classification: "AKTA_PERUBAHAN" }])).toBeNull();
expect(detectUnsupportedAktaType([{ classification: "AKTA_PENDIRIAN" }])).toBeNull();
});
it("honors user override", () => {
expect(detectUnsupportedAktaType([{ classification: "AKTA_AKUISISI", userOverride: "AKTA_PERUBAHAN" }])).toBeNull();
});
it("does not perturb existing routing", () => {
expect(inferSubmissionType([{ classification: "AKTA_PERUBAHAN" }])).toBe("PERUBAHAN_PT");
expect(inferSubmissionType([{ classification: "AKTA_PENDIRIAN" }])).toBe("PENDIRIAN_PT");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: FAIL — detectUnsupportedAktaType is not exported.
- [ ] Step 3: Write minimal implementation — add to
submissions.ts
import { AKTA_LABEL_TO_TXN_TYPE, BUILT_AKTA_TXN_TYPES, type AktaTxnType } from "../services/akta-txn-classifier";
/** The first recognized-but-unbuilt akta transaction type among the files, else null. */
export function detectUnsupportedAktaType(files: InferableFile[]): AktaTxnType | null {
for (const f of files) {
const eff = f.userOverride ?? f.classification;
const t = AKTA_LABEL_TO_TXN_TYPE[eff];
if (t && !BUILT_AKTA_TXN_TYPES.has(t)) return t;
}
return null;
}
In the POST create handler, before db.submission.create (~line 240):
const unsupported = detectUnsupportedAktaType(files);
if (unsupported) {
return c.json({ supported: false, detectedType: unsupported }, 200);
}
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx bun test src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: PASS.
- [ ] Step 5: Full typecheck + suite + commit
Run: cd backend && bunx tsc --noEmit && bunx bun test src/services/__tests__/akta-txn-classifier.test.ts src/routes/__tests__/klasifikasi-akta-txn.test.ts
Expected: no type errors; all tests PASS.
git add backend/src/routes/submissions.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts
git commit -m "feat(submissions): gate unbuilt akta transaction types out of routing"
Phase B — Follow-on (co-lands with the Akuisisi flow; NOT built in this plan)
These deliver value only once a new akta type is routable, so they ship with the first new flow (Akuisisi). Each becomes its own plan:
- Frontend surfacing —
frontend/src/lib/classifier-labels.ts(display names for the 5 new labels),frontend/src/components/klasifikasi/detect-banner.tsx(extendDetectedFlow+ add a"detected-unsupported"state),KlasifikasiPage.tsxdetectedFlow(recognize new types), the per-flow label sets inbackend/src/routes/klasifikasi.ts(*_DOC_TYPES), andcreateSubmission.onSuccess(handle{ supported: false, detectedType }→ show "belum didukung", block navigation).
⚠️ ROLLOUT CAVEAT (from the whole-branch review): keep
AKTA_TXN_CLASSIFIER_URLunset in every environment until Phase B ships. Correction to an earlier claim in this plan: with the classifier ON pre-Phase-B, a deed refined to an unbuilt label (e.g.AKTA_AKUISISI) is filtered out of the submission byKlasifikasiPage(lines ~451-455:validKeyscomes from/api/klasifikasi/labels, which does not yet include the new labels), surfacing only as a non-blocking "tidak dikenali" warning — the deed silently drops rather than showing "belum didukung", anddetectUnsupportedAktaTypenever receives it. The backend gate is a correct safety net but is unreachable through the current UI until Phase B adds the label surfacing. Because the whole feature is default-OFF, this is moot until then — but do not enable the URL before Phase B.
2. Flywheel export — persist each operator-confirmed akta label (deed OCR text + finaltxnType+ actor/timestamp) to a training-sample store; this becomes the corpus for the graduation text model (spec §5.5).
3. Extraction-time re-verification — optionally refineDocument.aktaSubtypeatdocument-processor.ts:373using the same classifier (reusingDocument.rawText) as a second check.
4. Submission-level doc-set disambiguation — Berakhirnya vs Pembubaran turns on the supporting-doc set (likuidator report + Pasal 149/152 pengumuman), resolved ininferSubmissionTypewhen those flows are built (spec §6.2).
Self-Review
- Spec coverage: §3 (two-layer arch) → Phase 3 keeps GPU coarse + adds in-app refine. §4/§5.1-5.4 (seam, LLM backend, title rules, confidence, prior) → Tasks 1-3. §5.5 (flywheel/graduation) → Phase B #2 + the
"model"throw (Task 3). §5.6 (don't collapse; GPU prior + conservative override) → Global Constraints + Task 5. §6.1 (refine on carrier) → Tasks 3,5,6. §6.2 (submission-level ladder) → Task 7 + Phase B #4. §6.4 (feature-gating) → Task 7 +BUILT_AKTA_TXN_TYPES. §6.5 (frontend) → Phase B #1. §7 (LLM outage / confident-wrong / multi-akta) → Task 3 fallback + Task 5 conservative rule + Task 7. §8 (tests) → every task is TDD. - Placeholder scan: none — every code step is complete; Phase B is explicitly a separate follow-on, not an in-plan placeholder.
- Type consistency:
AktaTxnType,AktaTxnClassification({txnType,label,confidence,evidence,supported,source}),classifyAktaTransactionType(rawText, opts),detectAktaTitleType,refineAktaLabel,detectUnsupportedAktaTypeare used with identical signatures across tasks.InferableFilereused fromsubmissions.ts:62.