Pillar 05 — Project Log · Log #002 · Gov / GLC · Tender Document Processing

← Project Log
Home / The Lab / Project Log / Log #002
Pillar 05 · Log #002 · Tender Document Processing

Log #002: Pipeline untuk Parse Tender Documents ePerolehan Secara Automatik

91% entity extraction accuracy pada 977 government PDFs. Ini breakdown architecture, apa yang fail, dan kenapa structured output lebih susah dari yang korang sangka.

.// Project Brief
Client TypeIT company sub-contracting on Malaysian government procurement monitoring
IndustryGov / GLC / Professional Services
Timeline8 weeks build, validated against 977 real ePerolehan tenders
StackClaude claude-sonnet-4-6 + pgvector + FastAPI + PostgreSQL + pypdf
TL;DR — Kalau busy, baca ni je dulu
  • 91% entity extraction accuracy pada 977 ePerolehan tenders selepas 3 rounds of prompt iteration — naik dari 67% baseline pada first pass.
  • Government PDFs ada tiga format berbeza yang tak consistent: kami kena bina format detector sebelum LLM call, bukan guna satu prompt untuk semua.
  • Structured output via JSON schema lebih reliable dari free-form extraction, tapi ada edge cases yang schema pun tak dapat handle — kena ada fallback logic.
  • Biggest lesson: jangan benchmark dengan synthetic data. Real government documents ada ejaan inconsistent, abbreviations tak standard, dan missing fields yang field tersebut pun sometimes ada, sometimes takda.

The Problem

ePerolehan adalah portal perolehan kerajaan Malaysia. Setiap hari, beratus-ratus tender baru keluar — each sebagai PDF dengan maklumat seperti nama projek, nilai kontrak, syarat kelayakan, tarikh tutup, ministry responsible, dan spesifikasi teknikal.

Client kami perlu monitor ini secara systematik untuk identify relevant tenders for their clients. Manual process sebelum ni: seorang staff download tender PDFs setiap pagi, baca satu-satu, fill in spreadsheet manually. Masa yang dihabiskan: 3–4 jam sehari. Accuracy: highly dependent on who was doing it that day.

Breakdown apa yang perlu di-extract dari tiap-tiap tender document:

Field Format dalam PDF Extraction Difficulty
Nama ProjekAlways present, varies in positionLow
Nilai Kontrak (RM)Present 78% of time; sometimes range, sometimes exactMedium
Tarikh TutupMultiple date formats, sometimes Malay sometimes EnglishMedium
Ministry / AgensiAbbreviated inconsistently (KKM, MOH, Kementerian Kesihatan)High
Syarat BumiputeraFree text, no standard phrasingHigh
Kod Kategori CIDB / PKKPresent only for construction tenders (~40%)Medium
Spesifikasi TeknikalMulti-page, unstructured, nestedVery High

Tujuh field. Tapi consistency dalam real documents jauh lebih complicated dari yang spreadsheet tu suggest.


Our Approach — Architecture Decision Log

Decision 1: Format detection sebelum extraction

Assumption awal kami: semua ePerolehan PDFs ikut template yang sama. Assumption tu salah.

Selepas inspect 50 documents secara manual, kami identify tiga format berbeza yang keluar dalam corpus:

  • Format A — Standard ePerolehan template (~62%): Header ministry di atas, maklumat tender dalam table, terma dan syarat di bawah. Consistent structure, senang extract.
  • Format B — Ministry-specific letterhead (~27%): Ministry yang besar macam KKM dan KPM ada template sendiri yang berbeza dari standard ePerolehan. Field ada, tapi position lain-lain.
  • Format C — Scanned documents (~11%): PDF generated dari scan fizikal. Teks boleh extract, tapi ada OCR artifacts yang affect extraction quality.

Satu prompt untuk semua format menghasilkan 67% accuracy. Format-specific prompts naik ke 88% sebelum further tuning.

# Format detection — run before LLM call
def detect_format(pdf_text: str) -> str:
    # Check for standard ePerolehan header markers
    if "SISTEM PEROLEHAN ELEKTRONIK" in pdf_text[:500]:
        return "format_a"

    # Check for known ministry letterhead patterns
    ministry_headers = [
        "KEMENTERIAN KESIHATAN MALAYSIA",
        "KEMENTERIAN PENDIDIKAN MALAYSIA",
        "KEMENTERIAN KEWANGAN",
        # ... 12 more registered ministry formats
    ]
    if any(h in pdf_text[:1000] for h in ministry_headers):
        return "format_b"

    # OCR artifact detection — look for common scan patterns
    ocr_indicators = pdf_text.count("l") / max(pdf_text.count("1"), 1)
    if ocr_indicators > 8.0:  # l/1 ratio unusually high = OCR
        return "format_c"

    return "format_a"  # default to standard

Decision 2: JSON schema untuk structured output

Kami experiment dengan dua extraction approaches:

  • Free-form extraction: Prompt LLM untuk extract maklumat, parse response manually. Flexible tapi inconsistent output format — kami kena write brittle regex untuk parse result.
  • JSON schema enforcement: Define exact output schema, prompt model untuk fill schema. Output predictable, validation simple. Trade-off: model kadang-kadang hallucinate nilai untuk field yang sebenarnya tak wujud dalam document.

Kami pilih JSON schema dengan tambahan `confidence` field per extracted entity. Bila confidence di bawah threshold (0.70), field tu flagged untuk human review. Ini yang buat perbezaan besar dalam usability.

# Output schema — required fields + confidence tracking
TENDER_SCHEMA = {
    "nama_projek":        { "value": str, "confidence": float },
    "nilai_kontrak_rm":   { "value": float | None, "confidence": float },
    "tarikh_tutup":       { "value": str,  "confidence": float },  # ISO 8601
    "agensi":             { "value": str,  "confidence": float },
    "syarat_bumi":        { "value": bool | None, "confidence": float },
    "kategori_kod":       { "value": str | None, "confidence": float },
    "ringkasan_teknikal": { "value": str,  "confidence": float },
}

# Field flagged for human review if confidence < 0.70
# Full document queued for review if >2 fields below threshold

Decision 3: Two-pass extraction untuk nilai kontrak

Nilai kontrak adalah field dengan extraction failure rate tertinggi — 23% pada first pass. Sebab utama:

  • Sesetengah tender ada nilai anggaran berbeza dari nilai kontrak maksimum
  • Format RM varies: "RM 250,000", "RM250,000.00", "MYR 250K", "dua ratus lima puluh ribu ringgit"
  • Construction tenders kadang-kadang ada multiple lots dengan nilai berbeza

Fix: two-pass approach. First pass extract semua currency values yang appear dalam document. Second pass classify mana satu adalah nilai kontrak yang relevan berdasarkan context. Failure rate turun ke 8%.

Decision 4: Ministry name normalisation

ePerolehan ada 26 kementerian, tapi setiap satu ada multiple abbreviations yang digunakan secara inconsistent. "KKM", "MOH", "Kementerian Kesihatan", "Kementerian Kesihatan Malaysia", "KES" — semua refer to the same ministry.

Kami bina normalisation lookup table dengan 400+ aliases mapped to canonical names. This single fix improved agensi extraction dari 71% ke 94%.


Technical Stack

Layer Technology Why
LLMClaude (claude-sonnet-4-6)Superior instruction following for structured extraction; handles Malay terminology well
PDF parsingpypdf + pdfminer.sixpypdf for clean text; pdfminer fallback for complex layouts. pdfplumber tested but slower for batch processing
API layerFastAPI + async PythonBatch processing endpoint with queue; concurrent document handling
Vector storepgvector on PostgreSQLSemantic search over tender corpus; find similar past tenders for context enrichment
ValidationPydantic v2Schema enforcement on LLM output before database write
QueueRedis + RQBatch jobs; retry on failure; priority queue for time-sensitive tenders
DeploymentYeahHost VPS (VLN6) + managed PostgreSQLClient cost constraint; adequate for 200–400 documents/day throughput

What Broke in Production

Failure 1: Truncated context pada tender panjang

Beberapa tender — terutamanya infrastructure dan IT projects — ada spesifikasi teknikal yang panjang. Document boleh cecah 80–120 pages. LLM context window ada limit.

First approach: feed entire PDF text ke LLM. Untuk documents 60+ pages, ini truncate content yang penting. Syarat kelayakan yang biasanya ada di halaman 35–40 terlepas dari context window.

Fix: Chunked extraction dengan section detection. Kami classify setiap section dalam PDF (header info, syarat kelayakan, spesifikasi teknikal, terma kontrak) dan extract each section dengan targeted prompt. Accuracy untuk long documents naik dari 61% ke 87%.

Failure 2: Syarat Bumiputera detection edge cases

Syarat Bumiputera ada dalam banyak cara berbeza dalam government documents. Kami sangka binary field (ada/takda) tapi realiti lebih nuanced:

  • "Terbuka kepada kontraktor Bumiputera sahaja" — clear yes
  • "Syarikat Bumiputera diutamakan" — preference, bukan requirement
  • "30% subcontract kepada Bumiputera" — partial requirement
  • Tender yang tak mention langsung — assumed open, tapi sometimes ada implicit requirement

Fix: Changed field dari boolean ke enum: `required`, `preferred`, `partial`, `none`, `unclear`. Confidence score untuk unclear cases selalu low — auto-flag for review. Ini eliminated majority of false positives.

Failure 3: Redis queue memory leak under load

Bila batch besar masuk (awal minggu, government release banyak tenders sekaligus), Redis queue grew faster dari worker processing. Selepas ~2,000 queued jobs, memory usage spike dan queue processor slow down.

Fix: Implement job TTL (48 hours) dan priority-based processing — tenders closing dalam 7 hari dapat priority queue. Implement worker autoscaling trigger bila queue depth melebihi 500 jobs. Problem resolved without hardware upgrade.


Accuracy Benchmarks

91%
Overall extraction accuracy (977 tenders)
3.4 min
Manual processing time (was 8 min avg)
94%
Agensi normalisation accuracy post-lookup-table

Accuracy by field selepas full iteration:

Field Baseline (Pass 1) Final (Pass 3) Primary Fix
Nama Projek94%98%Format-aware extraction
Nilai Kontrak61%89%Two-pass + currency normalisation
Tarikh Tutup79%96%Date format normalization (7 patterns)
Agensi71%94%400-alias lookup table
Syarat Bumi58%84%Enum + confidence threshold
Kategori Kod82%91%Conditional extraction (only when applicable)
Ringkasan Teknikal88%Chunked extraction, section classifier

On measuring "accuracy": Untuk project ni, accuracy didefinisikan sebagai human reviewer agreement — iaitu, human expert yang baca sama document setuju dengan extracted value. Ini bukan perfect metric (human reviewers pun tak always agree), tapi lebih practical dari exact string matching yang terlalu strict untuk unstructured documents.

Masa processing: average 3.4 minit per tender batch yang typical (15–20 documents). Manual alternative: 8 minit per document. Untuk 200 documents sehari, ini 1,600 minit saved daily — yang translate ke 2+ FTE hours.


Apa yang Kami Akan Buat Lain Kali

  1. Benchmark dengan real documents dari day 1. Kami habiskan 2 minggu tuning prompts dengan synthetic test cases yang kami buat sendiri. Bila switch ke real ePerolehan documents, accuracy drop drastically. Real documents adalah ground truth — guna ia dari awal.
  2. Build the ministry lookup table before starting extraction. Normalisation lookup table yang kami bina selepas failure mode ditemui — sepatutnya ni kerja minggu pertama. Ministry naming inconsistency adalah predictable problem untuk Malaysian government documents.
  3. Design for "unknown" from the start. Kami assume semua fields extractable. Reality: sesetengah fields genuinely tak ada dalam certain tender types. Schema yang include explicit `null` + `reason_missing` dari awal saves a lot of downstream confusion.
  4. Version control prompts like code. Kami buat tiga rounds of prompt iteration — tapi pada mulanya tak ada systematic version control untuk prompts. Bila accuracy drop selepas prompt change, susah nak rollback. Treat prompts as code artifacts dengan proper versioning.

Takeaway untuk Business Yang Similar

Kalau business korang ada document processing workflow yang repetitive dan rule-based — tender monitoring, invoice extraction, contract review — LLM-based extraction pipeline adalah strong candidate.

Tiga prerequisite yang realistik sebelum start:

  1. Sample dokumen sebenar yang mencukupi. Minimum 100–200 real documents untuk benchmark dan tune. Synthetic atau curated samples akan mislead korang pada production accuracy.
  2. Clear definition of "done". Apa maksud "extracted correctly"? Human agreement? Exact string match? Structured value match? Define ini sebelum build, bukan selepas. Ia affect every architecture decision downstream.
  3. Human review workflow untuk low-confidence cases. 91% accuracy bermaksud 9% error rate. Untuk 200 documents sehari, itu 18 documents with errors. Pipeline yang ada human review fallback untuk low-confidence extractions adalah more useful than pure automation yang silently wrong.

91% bukan perfect. Tapi 91% accurate automated extraction dengan flagged review queue — vs. 3–4 jam manual work sehari dengan human error rate yang tak diukur — adalah significant operational improvement untuk mana-mana business yang process volume documents.

Nak bina document processing pipeline untuk business korang?

Kalau korang ada workflow yang involve processing volume documents — tenders, invoices, contracts, reports — kami boleh assess extractability dan design pipeline yang sesuai. Termasuk honest assessment kalau manual review masih lebih practical untuk use case korang.

Book Teh Tarik Session ❯❯

PROJECT SPECS

Stack: Claude + pypdf + FastAPI
DB: PostgreSQL + pgvector
Queue: Redis + RQ
Deploy: YeahHost VPS VLN6
Corpus: 977 ePerolehan tenders
Build time: 8 weeks

NOTIFY ME

Get notified bila log baru keluar.