think
16px
820px

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 by bunfig.toml preload of src/test-setup.ts. NEVER run bun test against the dev DB. (memory: test DB isolation)
  • New SubmissionType / DocumentType enum values are FORBIDDEN in this plan — dispatchProcessor and reMatchAndValidate enforce const _exhaustive: never, so a new enum value forces building a whole flow. New akta types are classification STRINGS only, mapped to DocumentType.AKTA.
  • The seam reads process.env directly at call time (like apostille-classifier.ts), NOT config.ts — required for the freshClient() 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 pendirianperubahan (the GPU is the trained authority for those two — spec §5.6).
  • The whole wiring is default-OFF: if AKTA_TXN_CLASSIFIER_URL is 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, perubahan only.
  • 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:

  1. Frontend surfacingfrontend/src/lib/classifier-labels.ts (display names for the 5 new labels), frontend/src/components/klasifikasi/detect-banner.tsx (extend DetectedFlow + add a "detected-unsupported" state), KlasifikasiPage.tsx detectedFlow (recognize new types), the per-flow label sets in backend/src/routes/klasifikasi.ts (*_DOC_TYPES), and createSubmission.onSuccess (handle { supported: false, detectedType } → show "belum didukung", block navigation).

⚠️ ROLLOUT CAVEAT (from the whole-branch review): keep AKTA_TXN_CLASSIFIER_URL unset 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 by KlasifikasiPage (lines ~451-455: validKeys comes 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", and detectUnsupportedAktaType never 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 + final txnType + 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 refine Document.aktaSubtype at document-processor.ts:373 using the same classifier (reusing Document.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 in inferSubmissionType when 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, detectUnsupportedAktaType are used with identical signatures across tasks. InferableFile reused from submissions.ts:62.