538 lines
11 KiB
Markdown
538 lines
11 KiB
Markdown
# Frontend Integration Guide
|
|
|
|
Dokumen ini menjelaskan kontrak API yang perlu dipakai frontend untuk upload dokumen sprint, menampilkan hasil OCR, menjalankan review manual, dan approve hasil final.
|
|
|
|
## Base URL
|
|
|
|
Default local API:
|
|
|
|
```text
|
|
http://localhost:8000/api/v1
|
|
```
|
|
|
|
Untuk frontend, simpan URL di environment variable:
|
|
|
|
```env
|
|
VITE_OCR_API_BASE_URL=http://localhost:8000/api/v1
|
|
```
|
|
|
|
Jika `API_KEYS` di backend diisi, semua endpoint protected membutuhkan header:
|
|
|
|
```http
|
|
X-API-Key: <api-key>
|
|
```
|
|
|
|
Catatan: jangan expose API key production di frontend publik. Untuk deployment internal, gunakan reverse proxy atau session backend-for-frontend jika aksesnya tidak sepenuhnya trusted.
|
|
|
|
## Health Check
|
|
|
|
```http
|
|
GET /health
|
|
GET /health/ready
|
|
```
|
|
|
|
Contoh response `/health`:
|
|
|
|
```json
|
|
{
|
|
"status": "ok",
|
|
"version": "0.1.0"
|
|
}
|
|
```
|
|
|
|
Contoh response `/health/ready`:
|
|
|
|
```json
|
|
{
|
|
"status": "ready",
|
|
"version": "0.1.0",
|
|
"models": {
|
|
"paddleocr": "ready",
|
|
"pp_structure": "disabled"
|
|
}
|
|
}
|
|
```
|
|
|
|
Gunakan `/health/ready` untuk disable upload button sampai model OCR siap.
|
|
|
|
## Upload Dokumen
|
|
|
|
Endpoint:
|
|
|
|
```http
|
|
POST /documents
|
|
POST /documents?sync=true
|
|
```
|
|
|
|
Body harus `multipart/form-data` dengan field `file`.
|
|
|
|
Backend menerima PDF dan format image umum. Default max upload mengikuti backend config `BLOB_MAX_UPLOAD_MB`, saat ini 25 MB.
|
|
|
|
### Recommended Flow
|
|
|
|
Untuk frontend production, gunakan async flow:
|
|
|
|
1. `POST /documents`
|
|
2. Jika status HTTP `202`, ambil `job_id`
|
|
3. Poll `GET /documents/{job_id}` setiap 1-3 detik
|
|
4. Stop polling saat status `completed`, `needs_review`, atau `failed`
|
|
|
|
Untuk local dev sederhana, `POST /documents?sync=true` boleh dipakai, tetapi request bisa lama karena OCR berjalan inline.
|
|
|
|
### Upload Example
|
|
|
|
```ts
|
|
const API_BASE = import.meta.env.VITE_OCR_API_BASE_URL;
|
|
const API_KEY = import.meta.env.VITE_OCR_API_KEY;
|
|
|
|
async function uploadDocument(file: File) {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
|
|
const res = await fetch(`${API_BASE}/documents`, {
|
|
method: "POST",
|
|
headers: API_KEY ? { "X-API-Key": API_KEY } : undefined,
|
|
body: form,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw await readApiError(res);
|
|
}
|
|
|
|
return (await res.json()) as DocumentResponse;
|
|
}
|
|
```
|
|
|
|
## Polling Job
|
|
|
|
Endpoint:
|
|
|
|
```http
|
|
GET /documents/{job_id}
|
|
```
|
|
|
|
```ts
|
|
const TERMINAL_STATUSES = new Set(["completed", "needs_review", "failed"]);
|
|
|
|
async function getDocument(jobId: string) {
|
|
const res = await fetch(`${API_BASE}/documents/${jobId}`, {
|
|
headers: API_KEY ? { "X-API-Key": API_KEY } : undefined,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw await readApiError(res);
|
|
}
|
|
|
|
return (await res.json()) as DocumentResponse;
|
|
}
|
|
|
|
async function pollDocument(jobId: string, onUpdate: (doc: DocumentResponse) => void) {
|
|
while (true) {
|
|
const doc = await getDocument(jobId);
|
|
onUpdate(doc);
|
|
|
|
if (TERMINAL_STATUSES.has(doc.status)) {
|
|
return doc;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
}
|
|
}
|
|
```
|
|
|
|
## Response Schema
|
|
|
|
### DocumentResponse
|
|
|
|
```ts
|
|
type DocumentStatus =
|
|
| "pending"
|
|
| "processing"
|
|
| "completed"
|
|
| "needs_review"
|
|
| "failed";
|
|
|
|
type DocumentResponse = {
|
|
job_id: string;
|
|
status: DocumentStatus;
|
|
confidence: number | null;
|
|
data: ExtractionResult | null;
|
|
review_flags: ReviewFlag[];
|
|
error: string | null;
|
|
approved: boolean;
|
|
reviewed_by: string | null;
|
|
reviewed_at: string | null;
|
|
};
|
|
```
|
|
|
|
### ExtractionResult
|
|
|
|
```ts
|
|
type ExtractionResult = {
|
|
header: HeaderFields;
|
|
personel: PersonnelEntry[];
|
|
untuk: string[];
|
|
ttd: Signatory;
|
|
raw_text: string;
|
|
confidence: number;
|
|
review_flags: ReviewFlag[];
|
|
};
|
|
|
|
type HeaderFields = {
|
|
nomor_sprint: string | null;
|
|
tanggal: string | null; // YYYY-MM-DD
|
|
satuan_penerbit: string | null;
|
|
perihal: string | null;
|
|
dasar: string[];
|
|
};
|
|
|
|
type PersonnelEntry = {
|
|
no: number | null;
|
|
pangkat: string | null;
|
|
nrp: string | null;
|
|
nama: string | null;
|
|
jabatan_dinas: string | null;
|
|
jabatan_sprint: string | null;
|
|
keterangan: string | null;
|
|
confidence: number;
|
|
};
|
|
|
|
type Signatory = {
|
|
nama: string | null;
|
|
pangkat: string | null;
|
|
nrp: string | null;
|
|
jabatan: string | null;
|
|
};
|
|
```
|
|
|
|
### Review Flags
|
|
|
|
```ts
|
|
type ReviewFlag =
|
|
| "low_ocr_confidence"
|
|
| "missing_field"
|
|
| "invalid_nrp"
|
|
| "unknown_pangkat"
|
|
| "personnel_count_mismatch"
|
|
| "date_parse_failed"
|
|
| "llm_fallback"
|
|
| "llm_unavailable"
|
|
| "personnel_text_fallback"
|
|
| "personnel_text_fallback_no_nrp"
|
|
| "incomplete_personnel_row";
|
|
```
|
|
|
|
Recommended UI labels:
|
|
|
|
| Flag | Label |
|
|
|---|---|
|
|
| `low_ocr_confidence` | Confidence OCR rendah |
|
|
| `missing_field` | Field wajib belum lengkap |
|
|
| `invalid_nrp` | NRP tidak valid |
|
|
| `unknown_pangkat` | Pangkat tidak dikenali |
|
|
| `personnel_count_mismatch` | Jumlah personel perlu dicek |
|
|
| `date_parse_failed` | Tanggal gagal dibaca |
|
|
| `llm_fallback` | Sebagian field diisi fallback LLM |
|
|
| `llm_unavailable` | LLM tidak tersedia |
|
|
| `personnel_text_fallback` | Personel dibaca dari fallback teks |
|
|
| `personnel_text_fallback_no_nrp` | Personel dibaca tanpa NRP |
|
|
| `incomplete_personnel_row` | Baris personel belum lengkap |
|
|
|
|
## Example Final Response
|
|
|
|
```json
|
|
{
|
|
"job_id": "e21e83ed-a42c-4672-baec-914e5c60cc5a",
|
|
"status": "needs_review",
|
|
"confidence": 0.82,
|
|
"data": {
|
|
"header": {
|
|
"nomor_sprint": "Sprin/123/IV/2026",
|
|
"tanggal": "2026-04-21",
|
|
"satuan_penerbit": "POLRES BANJAR",
|
|
"perihal": "Instruktur Ops Pekat I Lodaya 2026",
|
|
"dasar": []
|
|
},
|
|
"personel": [
|
|
{
|
|
"no": 1,
|
|
"pangkat": "IPDA",
|
|
"nrp": "12345678",
|
|
"nama": "BUDI SANTOSO",
|
|
"jabatan_dinas": "KANIT",
|
|
"jabatan_sprint": "INSTRUKTUR",
|
|
"keterangan": null,
|
|
"confidence": 0.91
|
|
}
|
|
],
|
|
"untuk": ["Melaksanakan kegiatan sesuai surat perintah."],
|
|
"ttd": {
|
|
"nama": "AGUS",
|
|
"pangkat": "AKBP",
|
|
"nrp": "87654321",
|
|
"jabatan": "KAPOLRES"
|
|
},
|
|
"raw_text": "full OCR text...",
|
|
"confidence": 0.82,
|
|
"review_flags": ["low_ocr_confidence"]
|
|
},
|
|
"review_flags": ["low_ocr_confidence"],
|
|
"error": null,
|
|
"approved": false,
|
|
"reviewed_by": null,
|
|
"reviewed_at": null
|
|
}
|
|
```
|
|
|
|
`raw_text` bisa panjang. Tampilkan di collapsible/debug panel, bukan di layar utama.
|
|
|
|
## Review dan Koreksi HITL
|
|
|
|
Frontend review screen sebaiknya mengizinkan editor untuk:
|
|
|
|
- Header: nomor sprint, tanggal, satuan penerbit, perihal, dasar
|
|
- Personel: pangkat, NRP, nama, jabatan dinas, jabatan sprint, keterangan
|
|
- Untuk: daftar tugas
|
|
- TTD: nama, pangkat, NRP, jabatan
|
|
|
|
### Patch Corrections
|
|
|
|
Endpoint:
|
|
|
|
```http
|
|
PATCH /documents/{job_id}
|
|
```
|
|
|
|
Body:
|
|
|
|
```json
|
|
{
|
|
"corrections": [
|
|
{
|
|
"path": "header.perihal",
|
|
"value": "Pelaksanaan Operasi Pekat I Lodaya 2026",
|
|
"reason": "OCR membaca perihal tidak lengkap"
|
|
},
|
|
{
|
|
"path": "personel[0].nama",
|
|
"value": "BUDI SANTOSO",
|
|
"reason": "Perbaikan nama"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Header opsional untuk audit trail:
|
|
|
|
```http
|
|
X-User-Id: reviewer-a
|
|
```
|
|
|
|
Path yang umum dipakai:
|
|
|
|
```text
|
|
header.nomor_sprint
|
|
header.tanggal
|
|
header.satuan_penerbit
|
|
header.perihal
|
|
header.dasar
|
|
ttd.nama
|
|
ttd.pangkat
|
|
ttd.nrp
|
|
ttd.jabatan
|
|
personel[0].pangkat
|
|
personel[0].nrp
|
|
personel[0].nama
|
|
personel[0].jabatan_dinas
|
|
personel[0].jabatan_sprint
|
|
personel[0].keterangan
|
|
untuk
|
|
```
|
|
|
|
Semua correction dalam satu request bersifat atomic. Jika satu path invalid, seluruh batch ditolak dan tidak ada perubahan disimpan.
|
|
|
|
### Patch Example
|
|
|
|
```ts
|
|
async function patchDocument(jobId: string, corrections: FieldCorrection[], userId?: string) {
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (API_KEY) headers["X-API-Key"] = API_KEY;
|
|
if (userId) headers["X-User-Id"] = userId;
|
|
|
|
const res = await fetch(`${API_BASE}/documents/${jobId}`, {
|
|
method: "PATCH",
|
|
headers,
|
|
body: JSON.stringify({ corrections }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw await readApiError(res);
|
|
}
|
|
|
|
return (await res.json()) as DocumentResponse;
|
|
}
|
|
|
|
type FieldCorrection = {
|
|
path: string;
|
|
value: unknown;
|
|
reason?: string | null;
|
|
};
|
|
```
|
|
|
|
## Correction History
|
|
|
|
Endpoint:
|
|
|
|
```http
|
|
GET /documents/{job_id}/history
|
|
```
|
|
|
|
Response:
|
|
|
|
```ts
|
|
type CorrectionEventResponse = {
|
|
id: number;
|
|
job_id: string;
|
|
field_path: string;
|
|
old_value: unknown | null;
|
|
new_value: unknown | null;
|
|
corrected_by: string | null;
|
|
reason: string | null;
|
|
corrected_at: string;
|
|
};
|
|
```
|
|
|
|
Gunakan endpoint ini untuk audit panel di halaman review.
|
|
|
|
## Approve Final Result
|
|
|
|
Endpoint:
|
|
|
|
```http
|
|
POST /documents/{job_id}/approve
|
|
```
|
|
|
|
Header opsional:
|
|
|
|
```http
|
|
X-User-Id: reviewer-a
|
|
```
|
|
|
|
Response:
|
|
|
|
```json
|
|
{
|
|
"job_id": "e21e83ed-a42c-4672-baec-914e5c60cc5a",
|
|
"approved": true,
|
|
"reviewed_by": "reviewer-a",
|
|
"reviewed_at": "2026-04-26T16:30:00"
|
|
}
|
|
```
|
|
|
|
Setelah approved, `PATCH /documents/{job_id}` akan ditolak dengan `409`.
|
|
|
|
## Error Handling
|
|
|
|
Application errors:
|
|
|
|
```json
|
|
{
|
|
"error": "UnsupportedDocumentError",
|
|
"message": "Uploaded file is empty."
|
|
}
|
|
```
|
|
|
|
FastAPI validation errors memakai shape standar:
|
|
|
|
```json
|
|
{
|
|
"detail": [
|
|
{
|
|
"type": "missing",
|
|
"loc": ["body", "file"],
|
|
"msg": "Field required"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
Helper error:
|
|
|
|
```ts
|
|
async function readApiError(res: Response) {
|
|
let payload: unknown = null;
|
|
try {
|
|
payload = await res.json();
|
|
} catch {
|
|
payload = await res.text();
|
|
}
|
|
|
|
return {
|
|
status: res.status,
|
|
payload,
|
|
};
|
|
}
|
|
```
|
|
|
|
Recommended UI handling:
|
|
|
|
| HTTP Status | UI Handling |
|
|
|---|---|
|
|
| `400` | Tampilkan pesan validasi/upload |
|
|
| `401` | Session/API key tidak valid |
|
|
| `404` | Job tidak ditemukan |
|
|
| `409` | Job belum selesai atau sudah approved |
|
|
| `422` | Form correction tidak valid |
|
|
| `500` | Tampilkan error umum dan minta operator cek log backend |
|
|
|
|
## Ground Truth Admin
|
|
|
|
Endpoint ini opsional untuk dashboard admin/training data.
|
|
|
|
```http
|
|
GET /ground-truth/stats?top_n=10
|
|
GET /ground-truth/export?approved_only=true&has_corrections=true&limit=1000
|
|
```
|
|
|
|
`/ground-truth/export` mengembalikan `application/x-ndjson`, satu JSON per baris. Frontend biasanya cukup menyediakan tombol download, bukan parse seluruh stream di browser.
|
|
|
|
## Recommended Screens
|
|
|
|
1. Upload screen
|
|
- Dropzone file PDF/image
|
|
- Health readiness badge
|
|
- Upload progress
|
|
- Processing state setelah `job_id` diterima
|
|
|
|
2. Result screen
|
|
- Status badge
|
|
- Confidence score
|
|
- Review flags
|
|
- Header summary
|
|
- Personnel table
|
|
- Untuk list
|
|
- TTD section
|
|
- Raw OCR collapsible
|
|
|
|
3. Review screen
|
|
- Editable fields
|
|
- Dirty-state tracking
|
|
- Correction reason input
|
|
- Save corrections via `PATCH`
|
|
- History panel
|
|
- Approve button
|
|
|
|
4. Admin screen
|
|
- Health/ready status
|
|
- Ground-truth stats
|
|
- Export approved samples
|
|
|
|
## UX Rules
|
|
|
|
- Jangan tunggu `POST /documents?sync=true` untuk production UI; gunakan async + polling.
|
|
- Disable approve kalau status masih `pending` atau `processing`.
|
|
- Tampilkan `needs_review` sebagai hasil yang berhasil diproses tetapi perlu validasi manusia.
|
|
- Jangan render `raw_text` sebagai konten utama.
|
|
- Pada `failed`, tampilkan `error` dari response jika ada.
|
|
- Pada confidence rendah, arahkan user ke review fields yang punya flag terkait.
|