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 Projek | Always present, varies in position | Low |
| Nilai Kontrak (RM) | Present 78% of time; sometimes range, sometimes exact | Medium |
| Tarikh Tutup | Multiple date formats, sometimes Malay sometimes English | Medium |
| Ministry / Agensi | Abbreviated inconsistently (KKM, MOH, Kementerian Kesihatan) | High |
| Syarat Bumiputera | Free text, no standard phrasing | High |
| Kod Kategori CIDB / PKK | Present only for construction tenders (~40%) | Medium |
| Spesifikasi Teknikal | Multi-page, unstructured, nested | Very 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 |
|---|---|---|
| LLM | Claude (claude-sonnet-4-6) | Superior instruction following for structured extraction; handles Malay terminology well |
| PDF parsing | pypdf + pdfminer.six | pypdf for clean text; pdfminer fallback for complex layouts. pdfplumber tested but slower for batch processing |
| API layer | FastAPI + async Python | Batch processing endpoint with queue; concurrent document handling |
| Vector store | pgvector on PostgreSQL | Semantic search over tender corpus; find similar past tenders for context enrichment |
| Validation | Pydantic v2 | Schema enforcement on LLM output before database write |
| Queue | Redis + RQ | Batch jobs; retry on failure; priority queue for time-sensitive tenders |
| Deployment | YeahHost VPS (VLN6) + managed PostgreSQL | Client 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
Accuracy by field selepas full iteration:
| Field | Baseline (Pass 1) | Final (Pass 3) | Primary Fix |
|---|---|---|---|
| Nama Projek | 94% | 98% | Format-aware extraction |
| Nilai Kontrak | 61% | 89% | Two-pass + currency normalisation |
| Tarikh Tutup | 79% | 96% | Date format normalization (7 patterns) |
| Agensi | 71% | 94% | 400-alias lookup table |
| Syarat Bumi | 58% | 84% | Enum + confidence threshold |
| Kategori Kod | 82% | 91% | Conditional extraction (only when applicable) |
| Ringkasan Teknikal | — | 88% | 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
- 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.
- 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.
- 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.
- 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:
- Sample dokumen sebenar yang mencukupi. Minimum 100–200 real documents untuk benchmark dan tune. Synthetic atau curated samples akan mislead korang pada production accuracy.
- 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.
- 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.
