Adopt agents-settings kit, ship Product/SKU scan models, harden auth, verify OCR accuracy

Backend (app-pfm-ocr-v2/backend):
- Product/SKU scan feature complete: trained DINOv2 index (118 reference
  photos, 16 SKU classes) and YOLO classifier (83.3% top-1 val accuracy),
  fixed scripts/install-pipeline.sh (was missing ultralytics/torch), fully
  browser-verified end-to-end on /scan-pfm. Mobile m-scan-pfm page cancelled
  (Flutter app handles mobile; web UI is desktop-only for pipeline testing).
- Fixed a real data-loss bug: Save Ground Truth (scan-pfm and the DO-flow's
  manual-label) was silently writing into the pfm-web-app container's
  ephemeral filesystem instead of the host, because /sources wasn't
  bind-mounted in docker-compose.yml. Added the mount, recovered an
  orphaned entry.
- accounts.password is now bcrypt-hashed (bcryptjs, idempotent migration
  in db/init.ts) instead of plaintext; login route compares hashes.
- /api/v1/documents/* (list, PUT, upload) now enforces real 401 auth,
  matching what the Flutter client already sends. The "classic" routes
  deliberately stay open — they're dev-only web UI with no login flow and
  won't exist in production.
- OCR accuracy investigated end-to-end: real baseline is 95.10% overall
  (target met; accuracy_report.md was stale at 75.04%, now flagged). Fixed
  one genuine parser.ts bug (SO/DO field duplication in the global fallback
  regex); remaining gaps are OCR/layout-model limitations, not parser bugs.
- Adopted a standalone copy of the fhanyuh/agents-settings e/n workflow
  scoped to backend/ (AGENTS.md Part A/B split, SKILLS.md, plans/, docs/),
  independent of the root copy which now covers Flutter only.
- next-implementation.md deleted; content folded into
  backend/plans/next-enhancements.md for traceability.

Root:
- Adopted fhanyuh/agents-settings kit (AGENTS.md, SKILLS.md, plans/,
  docs/feature-list.md), scoped to the Flutter app only.
- Pending documents queue now persists to Hive (lib/core/storage) instead
  of memory-only, surviving an app kill mid-upload.

Removed backend_backup/ (stale Express/Prisma prototype, superseded by
pfm-web-app) and the completed plans/next-enhancement-plan.md checklist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rafhan Mazaya FathurrahmanandClaude Sonnet 5 committed 2026-07-08 11:56:32 +07:00
1 parent 3df9f6ec5d
commit e60ab63154
129 files changed
+8520 -6684

No files matched your search

+3
View File
@@ -3,3 +3,6 @@ APP_PORT=8000
# GPU index to allocate (e.g. 0, 1, or 0,1)
CUDA_VISIBLE_DEVICES=1
# Secret used to sign/verify account login JWTs
JWT_SECRET=change-me
+115 -175
View File
@@ -1,6 +1,19 @@
# AGENTS: PaddleOCR-VL-1.6 vLLM Service
# AGENTS: PaddleOCR-VL-1.6 vLLM Service + Agents Settings Kit
This repository serves **PaddleOCR-VL-1.6** as a dedicated VLM inference backend using **vLLM**. All Python workflows use **uv** (never bare `pip` or system Python).
This is the authoritative rules file for any AI coding agent (Claude Code, Cursor,
GitHub Copilot, Aider, etc.) working inside `backend/`. Two unrelated concerns live
here side by side: **Part A** is this repo's original vLLM/PaddleOCR service doc.
**Part B** (appended 2026-07-08) is a **backend-scoped copy** of the
[fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings) `e`/`enhance`
and `n`/`next` workflow — see root `../AGENTS.md` for the same kit covering the
Flutter side of this repo. The two copies are independent: this one's
`plans/next-enhancements.md` and `docs/feature-list.md` only track backend work.
---
# Part A — vLLM Service (PaddleOCR-VL-1.6)
This repository serves **PaddleOCR-VL-1.6** as a dedicated VLM inference backend using **vLLM**. All Python workflows use **uv** (never bare `pip` or system Python). Full detail (client usage examples, tuning, troubleshooting, issue-file template) moved to [docs/vllm-service.md](docs/vllm-service.md) 2026-07-08 to keep this file under the Part B kit's 256-line threshold (§3) — this section keeps only the essentials.
## Architecture
@@ -10,90 +23,18 @@ Client (PaddleOCR pipeline) --> HTTP /v1 --> paddleocr genai_server (vLLM ba
This service exposes only the VLM stage. Clients connect with `vl_rec_backend="vllm-server"` and `vl_rec_server_url="http://<host>:8118/v1"`.
## Prerequisites
- Linux with NVIDIA GPU (CC >= 8.0 recommended; CUDA 12.6+ driver support)
- [uv](https://docs.astral.sh/uv/) installed (`uv --version`)
- ~16 GB GPU VRAM for default settings (tune via `config/vllm_config.yaml`)
## Quick start
```bash
cd <YOUR-WORKING-DIR>/ai-ocr-pfm-2026
# 1) Create Python 3.12 venv and install dependencies
./scripts/install.sh
# 2) Start the vLLM-backed genai server
./scripts/serve.sh
./scripts/install.sh # 1) Create Python 3.12 venv and install dependencies
./scripts/serve.sh # 2) Start the vLLM-backed genai server
```
Default endpoint: `http://0.0.0.0:8118/v1`
## uv conventions (always follow)
| Task | Command |
|------|---------|
| Create/sync env | `uv sync` |
| Run any Python | `uv run <command>` |
| Add a package | `uv add <package>` |
| Run server | `./scripts/serve.sh` or `uv run paddleocr genai_server ...` |
Never use `python -m pip`, `pip install`, or `python -m venv` directly in this repo.
Default endpoint: `http://0.0.0.0:8118/v1`. Never use `python -m pip`, `pip install`, or `python -m venv` directly in this repo — always `uv sync` / `uv run` / `uv add`.
## Issue recording (always follow)
**Every problem encountered** during install, serve, debug, or client integration must be written to `issues/` before moving on — even if it was resolved in the same session.
### Naming
```
issues/{NN}-{slug}.md
```
| Part | Rule | Example |
|------|------|---------|
| `{NN}` | Two-digit running number (`01`, `02`, …). Increment from the highest existing file. | `03` |
| `{slug}` | Lowercase kebab-case summary of the problem | `gpu-memory-startup-failure` |
Full example: `issues/04-gpu-memory-startup-failure.md`
### When to create a file
- Install or dependency errors (flash-attn, vLLM, uv conflicts)
- Server startup or runtime failures (OOM, port bind, model load)
- Client integration bugs or misconfiguration
- Workarounds that took non-obvious steps to discover
Do **not** rely on chat history or inline comments alone — if it blocked progress, it belongs in `issues/`.
### File template
```markdown
# Issue {NN}: {Short title}
## Problem
What failed, with exact error message or symptom.
## Context
Environment, command run, relevant config (`.env`, `config/vllm_config.yaml`).
## Solution
What fixed it, or current workaround / open status.
## References
Links, related issue files, or AGENTS.md sections.
```
### Index
Check `issues/` for the next number:
```bash
ls issues/*.md 2>/dev/null | sort
```
See [issues/](issues/) for recorded problems and fixes from this project.
**Every problem encountered** during install, serve, debug, or client integration must be written to `issues/{NN}-{slug}.md` before moving on — even if resolved in the same session. Naming/template details: [docs/vllm-service.md](docs/vllm-service.md#issue-recording--naming-and-template).
## Environment variables
@@ -108,97 +49,7 @@ Copy `.env.example` to `.env` and adjust as needed:
| `VLLM_CONFIG` | `config/vllm_config.yaml` | vLLM backend YAML config |
| `CUDA_VISIBLE_DEVICES` | `1` (see `.env.example`) | GPU index(es) to use |
On dual-GPU hosts, pick the GPU with more free VRAM. If startup fails with a memory error, lower `gpu-memory-utilization` in `config/vllm_config.yaml`.
## Client usage
After the server is running:
```bash
# CLI
uv run paddleocr doc_parser \
--input https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/paddleocr_vl_demo.png \
--vl_rec_backend vllm-server \
--vl_rec_server_url http://localhost:8118/v1
```
```python
from paddleocr import PaddleOCRVL
pipeline = PaddleOCRVL(
vl_rec_backend="vllm-server",
vl_rec_server_url="http://127.0.0.1:8118/v1",
)
output = pipeline.predict("path/to/image.png")
```
Note: The full PaddleOCR-VL client should run in a **separate** environment if it needs PaddlePaddle GPU + Transformers. This repo is the isolated vLLM server only.
## Tuning vLLM
Edit `config/vllm_config.yaml`:
```yaml
gpu-memory-utilization: 0.8
max-num-seqs: 128
```
Reference: [PaddleOCR-VL vLLM parameter tuning](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html#331-server-side-parameter-adjustment)
## Troubleshooting
See `issues/` for full write-ups. Quick pointers:
| Symptom | Issue file |
|---------|------------|
| `paddleocr install_genai_server_deps` / `No module named pip` | [01-genai-server-deps-pip-in-uv-venv.md](issues/01-genai-server-deps-pip-in-uv-venv.md) |
| flash-attn wheel incompatible with Python version | [02-flash-attn-wheel-python-version-mismatch.md](issues/02-flash-attn-wheel-python-version-mismatch.md) |
| `uv pip` targets wrong venv from another project | [03-active-virtual-env-from-other-project.md](issues/03-active-virtual-env-from-other-project.md) |
| Free memory below `gpu-memory-utilization` on startup | [04-gpu-memory-startup-failure.md](issues/04-gpu-memory-startup-failure.md) |
| `TokenizersBackend has no attribute all_special_tokens_extended` | [05-transformers-tokenizers-incompatibility.md](issues/05-transformers-tokenizers-incompatibility.md) |
| Extracted images not shown in Gradio demo (raw base64 in markdown) | [06-extracted-images-raw-base64-not-displayed.md](issues/06-extracted-images-raw-base64-not-displayed.md) |
### flash-attn build failures
Install the prebuilt wheel after `uv sync` (see `scripts/install.sh`):
```bash
FLASH_ATTN_WHEEL="https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.3.14/flash_attn-2.8.2+cu128torch2.8-cp312-cp312-linux_x86_64.whl" \
./scripts/install.sh
```
Pick the wheel matching your Python and CUDA versions from [flash-attention prebuild wheels](https://mjunya.com/flash-attention-prebuild-wheels/). Details: [02-flash-attn-wheel-python-version-mismatch.md](issues/02-flash-attn-wheel-python-version-mismatch.md).
Note: `paddleocr install_genai_server_deps` uses `pip` internally and is incompatible with uv-managed venvs. See [01-genai-server-deps-pip-in-uv-venv.md](issues/01-genai-server-deps-pip-in-uv-venv.md). This repo installs the vLLM stack via `uv sync` + `uv pip`.
### `TokenizersBackend has no attribute all_special_tokens_extended`
Pin transformers (already in `pyproject.toml`):
```bash
uv pip install "transformers==4.57.6"
```
See [05-transformers-tokenizers-incompatibility.md](issues/05-transformers-tokenizers-incompatibility.md).
### Do not install `paddlepaddle-gpu` in this venv
vLLM and PaddlePaddle GPU conflict. This server env uses `paddleocr[doc-parser]` without Paddle GPU.
### GPU memory on startup
If vLLM reports free memory below `gpu-memory-utilization`, either:
- Set `CUDA_VISIBLE_DEVICES` to a less-busy GPU
- Lower `gpu-memory-utilization` in `config/vllm_config.yaml` (e.g. `0.75` or `0.7`)
See [04-gpu-memory-startup-failure.md](issues/04-gpu-memory-startup-failure.md).
### Health check
```bash
curl -s http://localhost:8118/v1/models | jq .
```
On dual-GPU hosts, pick the GPU with more free VRAM. If startup fails with a memory error, lower `gpu-memory-utilization` in `config/vllm_config.yaml` — see [docs/vllm-service.md](docs/vllm-service.md#gpu-memory-on-startup).
## File map
@@ -210,15 +61,11 @@ curl -s http://localhost:8118/v1/models | jq .
| `scripts/serve.sh` | Start `paddleocr genai_server` |
| `config/vllm_config.yaml` | vLLM backend tuning |
| `.env.example` | Environment variable template |
## References
- [PaddleOCR-VL usage tutorial](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html)
- [PaddleOCR genai_server FAQ](https://github.com/PaddlePaddle/PaddleOCR/discussions/16822)
| `docs/vllm-service.md` | Full vLLM reference (client usage, tuning, troubleshooting) |
## Coding Guidelines (always follow)
We use the [karpathy-guidelines](file:///home/user/LABS/OCR/paddle-ocr-vl-1-6-using-vllm-2026/andrej-karpathy-skills/skills/karpathy-guidelines/SKILL.md) skill to reduce common LLM coding mistakes. Refer to [SKILL.md](file:///home/user/LABS/OCR/paddle-ocr-vl-1-6-using-vllm-2026/andrej-karpathy-skills/skills/karpathy-guidelines/SKILL.md) for details:
We use the karpathy-guidelines skill to reduce common LLM coding mistakes:
1. **Think Before Coding**: Explicitly state assumptions and surface tradeoffs instead of making silent choices.
2. **Simplicity First**: Write the minimum amount of code to solve the problem with zero speculative configurations.
3. **Surgical Changes**: Edit only what is required and match the existing coding style exactly.
@@ -235,3 +82,96 @@ When the user intentionally asks to test the app:
- Take a screenshot for each sample image, each step, and each variant/option (if any), until the OCR result appears.
- Save the screenshots in the `/screenshots/` folder.
- Follow the file naming convention: `{2-digit-number}-{step#}-{variant_or_options_if_any}-{slug}.jpg` (e.g., `01-step1-default-upload.jpg`).
---
# Part B — Agents Settings Kit (backend-scoped `e`/`n` workflow)
Backend-scoped copy of the [fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings) kit, adopted 2026-07-08. Covers only `backend/` modules (Next.js API Gateway, OCR Pipeline & Accuracy, Postgres Data Layer, DevOps/Docker) — Flutter modules are tracked by the separate copy at root `../AGENTS.md`. `../CLAUDE.md` (root) and `CLAUDE.md` (this dir) each import their own copy.
## B0. Adopting Into an Existing Project
Already done for this repo (this split *is* that adoption, mirroring root's own §0 audit). Re-run "i"/"init" here to force a re-audit of `backend/` specifically (e.g. after a large refactor).
## B1. Trigger "e" or "enhance"
- Read `plans/next-enhancements.md` (this dir) to understand current backend structure, history, and active tasks.
- Overwrite or update the active tasks list inside it.
- The plan must cover each backend section/module.
- Define **exactly 3 new enhancements per section**, each with a unique number (e.g. `1.1`), a clear functional description, and status `[TODO]`.
- Present the plan to the user in your final summary.
## B2. Trigger "n", "next", or "n{x}"
- Read `plans/next-enhancements.md` to check task status.
- If all tasks are `[DONE]` (or none `[TODO]`), run **"e"/"enhance"** first.
- Otherwise select the most impactful `[TODO]` task(s) by strategic value/impact — not just first-in-order. If `{x}` given, take the top `{x}` sequentially.
### B2a. Clarify before building ("Grill Me" step)
Same rule as root AGENTS.md §2a: if scope/acceptance criteria are genuinely ambiguous, ask one question at a time (`AskUserQuestion` in Claude Code) until unambiguous, and record the resolved criteria as a 1-3 line note next to the task entry before writing code. Skip when the task is already unambiguous.
- Implement the task(s) fully, applying the relevant role(s) from `SKILLS.md` (this dir).
- On completion: flip status to `[DONE]` in `plans/next-enhancements.md`, document the feature in `docs/feature-list.md` (this dir) under the right section.
- **Verify build integrity**: QA pass (golden path + edge cases + regression check on adjacent features — see `backend/CLAUDE.md`'s accuracy regression harness for OCR/parser changes specifically) and Hardware/Compatibility pass (cross-platform, GPU/VRAM footprint under Local/on-prem deployment — see Part A above).
- State which task(s) were completed and the exact route/endpoint/menu path to see the new feature.
## B3. File Size & Refactoring Rules
Same 256-line threshold as root AGENTS.md §3, backend-wide. Applies to this file, `SKILLS.md`, and `CLAUDE.md` too — which is why Part A above was trimmed and linked out to `docs/vllm-service.md` rather than left inline.
## B4. Roles
See `SKILLS.md` (this dir) — same 5 roles as root (Architect, Backend, Frontend, QA, Hardware/Compatibility), applied to backend surfaces only (API routes, OCR pipeline, DB layer, Docker/deploy).
## B5. Mockup Data & Demo/Live Mode
Same as root AGENTS.md §5: mock data under `/data/mockup/`, a mock API layer mirroring the real backend contract, and a Demo/Live switcher. Not yet built for backend — see Adaptation Notes.
## B6. Cloud vs Local (On-Premise)
Same as root AGENTS.md §6, applied to backend service endpoints (Next.js gateway, pipeline API, vLLM server, Postgres) rather than the Flutter client's API base URL.
## B7. Ad-hoc Feature Requests
Direct feature requests not using "e"/"n": implement and document in `docs/feature-list.md` (this dir).
## Adaptation Notes (backend, split from root 2026-07-08)
- **Origin**: sections 5-8 of root `plans/next-enhancements.md` (Backend — Next.js API
Gateway, Backend — OCR Pipeline & Accuracy, Backend — Postgres Data Layer, DevOps —
Docker & Dev Tunnel) copied here as sections 1-4, statuses re-verified against the
live code before the copy (not copied blind) — see task 7.1's `withTransaction`
claim, task 5.1/5.2's dedup + timeout claims, and task 6.1's empty `models/` claim,
all confirmed still accurate as of 2026-07-08. The root copy is frozen/archival
(see root `AGENTS.md`'s "Scope: excludes `backend/`") rather than deleted, so this
file — not the root one — is the single active source of truth going forward.
- **Real commands**: `npm run dev`/`build`/`lint` in `pfm-web-app/`; accuracy
regression harness `node pfm-web-app/scripts/accuracy-check.mts`; Python services
via `./scripts/install.sh` + `./scripts/serve.sh` (this vLLM repo) and
`./scripts/install-pipeline.sh` + `./scripts/serve-pipeline.sh` (pipeline API +
classifier). Full stack: `docker compose up -d --build` **from the repo root**, not
from inside `backend/` (see root `CLAUDE.md` — two `docker-compose.yml` files
exist and running from here risks container-name conflicts).
- **Pre-existing files over the 256-line threshold** (§B3 debt, not a blocker — split
only if/when touched): `pfm-web-app/src/app/scan-pfm/page.tsx` (1169),
`pfm-web-app/src/utils/parser.ts` (908), `pfm-web-app/src/app/page.tsx` (737),
`config/classify_ocr_server.py` (691), `pfm-web-app/src/app/manual-label/page.tsx`
(612), `pfm-web-app/src/app/api/parse/route.ts` (604), `pfm-web-app/src/db/init.ts`
(477), `compare_sources_accuracy.py` (451), `pfm-web-app/src/utils/docker.ts` (362),
`pfm-web-app/public/produk-pfm/train_classifier.py` (351), `compare_accuracy.py`
(308), `pfm-web-app/src/app/api/arena/route.ts` (265). This file itself (`AGENTS.md`)
was at 237 lines pre-kit and would have exceeded 256 once Part B was appended —
hence the split into `docs/vllm-service.md`.
- **No Demo/Live or Cloud/Local switch exists yet** (§B5, §B6) for the backend
either. `docker-compose.override.yml` exposing `db`/`pipeline-api` directly to the
host is a local-dev convenience, not a Cloud/Local deployment switch.
- **Naming collision resolved by this split**: `AGENTS.md` already existed in this
directory (vLLM service doc, committed 2026-06-30, unrelated to this kit) before
Part B was appended — unlike root, where `AGENTS.md` didn't previously exist. Don't
assume backend's `AGENTS.md` is kit-only when reading it from another tool; Part A
is unrelated, pre-existing content kept for a reason.
- **Pre-existing, unrelated governance files left as-is**: `.agents/AGENTS.md` at the
*repo root* (different path, OCR post-processing rules) and root
`plans/next-enhancement-plan.md` (singular, `[DONE]` QA checklist) — neither is
part of this kit; see root `AGENTS.md`'s own Adaptation Notes.
+87
View File
@@ -0,0 +1,87 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this repo is
`app-pfm-ocr-v2/backend` is the **next-generation rewrite of `ai-ocr-pfm-2026`** — same underlying OCR infra (PaddleOCR-VL-1.6 on vLLM + a PaddlePaddle layout-parsing pipeline), same client (Charoen Pokphand/Primafood-branded frozen food products), but a reworked Next.js app (`pfm-web-app/`) and DB schema. If you need background on the shared OCR/vLLM infra (uv conventions, issue-recording workflow, GPU tuning), see `AGENTS.md` — it's carried over near-unchanged from the previous project.
**The active plan for porting the Product/SKU-scanning feature lives in [`plans/next-enhancements.md`](plans/next-enhancements.md) §2** — read it before touching anything related to `scan-pfm`, `produk-pfm`, or the product classifier, since it records exactly what's done vs. still missing and the decisions already made about how to build it. (This used to be a separate `next-implementation.md`; that file was deleted 2026-07-08 once its content was folded into the plan for traceability with the rest of the `e`/`n` backlog.)
## How this project differs from `ai-ocr-pfm-2026`
- **DO-PFM UI is consolidated into a single page.** Unlike the old project's per-route pages (`do-pfm/page.tsx`, `m-do-pfm/page.tsx`), v2's entire upload/history/item-review flow lives in one `pfm-web-app/src/app/page.tsx` (client component, local state, no separate routes). `nginx.conf` still has `/do-pfm`/`/m-do-pfm` location blocks left over from the old routing — these are currently dead (no matching Next.js route, would 404).
- **Standalone-purpose pages still get their own route folder**, e.g. `pfm-web-app/src/app/manual-label/page.tsx` — a self-contained ground-truth annotation tool (own header, own theme, no shared chrome with the root page) backed by `api/manual-label/route.ts` and `sources/manual_labels.json`. This is the pattern to follow for any new single-purpose page (see `plans/next-enhancements.md` §2 for the Product-scan pages, which follow it).
- **Real JWT auth, enforced on the production surface**: `src/utils/auth.ts` signs/verifies tokens (`signAccountToken`/`verifyAccountToken`/`getAccountFromAuthHeader`) against an `accounts` table, each account bound to exactly one `kode_toko` (store) — the intent being that an account's own store is used on upload instead of relying on OCR-based store-text matching. **Passwords are bcrypt-hashed** (`accounts.password`, via `bcryptjs` — chosen over native `bcrypt` since the `pfm-web-app` Docker stage is `node:20-slim` with no build toolchain for native addons; `pfm-web-app/src/db/init.ts` hashes the seed and idempotently migrates any pre-existing plaintext rows on every startup). As of 2026-07-08, `api/v1/documents/*` (list, PUT-by-id, upload) **reject requests with a missing/invalid token (401)** — this is the real production surface, and the Flutter client already does a real login and attaches `Authorization: Bearer <token>` to every request (`lib/features/auth/auth_provider.dart` + `lib/core/network/api_client.dart`). The **classic routes** (`/api/upload`, `/api/scan-pfm`, `/api/parse`, `/api/history`, etc.) and the root/`scan-pfm`/`manual-label` pages deliberately do **not** check auth at all and never will unless that decision changes — they're dev-only web UI with no login screen, not part of the production surface (see `plans/next-enhancements.md` task 1.3, cancelled, and 1.4, shipped instead).
- **Richer SKU master data**: `pfm-web-app/import_sku.js` imports from a TSV with extended packaging columns (`standar_jumlah`, `berat_kemasan`, `isi_outer_kg`, `isi_outer_pac`, `jenis_outer`) added via `ALTER TABLE sku_master ADD COLUMN IF NOT EXISTS`, superseding the old project's bare `no_sku`/`nama_item` seed list.
- **Accuracy regression harness** (new, doesn't exist in the old project): `pfm-web-app/scripts/accuracy-check.mts` hits the live `/api/parse` endpoint for every image in `sources/test-images/`, diffs against hand-labeled ground truth in `sources/manual_labels.json` at three post-processing stages (`layer1RawRegex` → `layer2Sanitized` → `layer3Final` — trace these stage names into `utils/parser.ts` to see where each is produced), and appends run-over-run results to `sources/accuracy_history.jsonl`. Run this after touching `parser.ts` to check for regressions:
```bash
node pfm-web-app/scripts/accuracy-check.mts # reuse cached OCR (fast)
node pfm-web-app/scripts/accuracy-check.mts --refresh-ocr # force fresh pipeline run
node pfm-web-app/scripts/accuracy-check.mts --detail <filename> # full per-stage breakdown for one image
```
`compare_accuracy.py` / `compare_sources_accuracy.py` / `generate_excel.py` at the repo root build human-readable Excel/HTML comparison reports from the same data (`sources/comparison_report.xlsx`, `sources/comparison_side_by_side.html`) — these are analysis tooling, not part of the running app.
- **`api/vllm-proxy/[[...path]]/route.ts`**: a passthrough proxy to the vLLM server (`paddleocr-vllm-server:8118`) that logs every call via `logVllmCallToAll` (`utils/active-log.ts`) — used for debugging/observability, not part of the OCR pipeline itself.
- **`docker-compose.override.yml`** exposes `db` (`5432`) and `pipeline-api` (`8090`) directly to the host for local dev — not present in the old project's compose setup.
## Product/SKU scanning flow — status
See [`plans/next-enhancements.md`](plans/next-enhancements.md) §2 (task 2.1) for full detail — kept there instead of a separate doc so status stays traceable against the rest of the `e`/`n` backlog. **Feature-complete as of 2026-07-08**: the backend (`config/classify_ocr_server.py` with DINOv2 similarity search + YOLO classifier fallback, `api/scan-pfm/route.ts`, `api/produk-pfm/route.ts`, DB schema), the reference photo dataset (`pfm-web-app/public/produk-pfm/foto-kemasan-v2/`, 16 SKU subfolders), the desktop frontend page (`scan-pfm/page.tsx`, full feature parity), and the trained model artifacts (`models/dinov2_index.pkl` — 118/118 photos indexed; `models/produk-pfm-classifier-26n-100e-2026-07-08.pt` — 83.3% top-1 val accuracy on the current thin dataset) all now exist and load cleanly on `pipeline-api` startup. **No mobile web page is planned**: `scan-pfm/page.tsx` is desktop-only, used to test the pipeline; real mobile product scanning goes through the Flutter app instead, so `m-scan-pfm/page.tsx` and its `nginx.conf` route are intentionally left unbuilt/dead (see plan task 2.2, cancelled 2026-07-08). Not yet done: an actual browser pass uploading a photo through `/scan-pfm` end-to-end (verified via container logs/model-loading so far, not a UI test).
## Confidentiality
Same concerns as `ai-ocr-pfm-2026` apply here, plus more surface area:
- `pfm-web-app/src/db/init.ts` and `db/migrations/005_create_sku_master.sql` contain the client's real product catalog and real vendor/customer identities, committed directly in source.
- `sources/` holds live business data: `Rekap SKU Aktif CPI Cikande per April 2026 v2.xlsx`, `Tabel Toko Aktif Juni 2026.xlsx`, `toko_aktif.json`, `manual_labels.json`, `ai_results.json` — real SKU/store master data and hand-labeled ground truth from real scanned documents, not fixtures.
- `uploads/` contains real scanned delivery-order photos and their OCR JSON output.
- The `accounts` table stores bcrypt-hashed passwords as of 2026-07-08 (see above) — still don't log or export its contents, and it's not wired into most routes yet (task 1.3), so don't treat it as a secure boundary for anything beyond the `api/v1/*` REST layer.
## Commands
Web app (`pfm-web-app/`):
```bash
npm run dev # next dev -H 0.0.0.0 (binds all interfaces — for LAN/tunnel access during mobile testing)
npm run build
npm run start
npm run lint
```
Accuracy regression check (see above) — run after any `parser.ts` change:
```bash
node pfm-web-app/scripts/accuracy-check.mts
```
`pfm-web-app/src/utils/parser.test.ts` — same standalone `node:assert` script as the old project, covering `parseDOMetadata`/`sanitizeParsedMetadata`. Run with a TS-capable runner, e.g. `npx tsx pfm-web-app/src/utils/parser.test.ts`.
Python services (uv-managed, same as `ai-ocr-pfm-2026` — see `AGENTS.md`):
```bash
./scripts/install.sh # bootstrap .venv for vLLM server
./scripts/install-pipeline.sh # bootstrap .venv-api — currently missing ultralytics/torch, see plans/next-enhancements.md task 2.1
./scripts/serve.sh # vLLM genai server on :8118
./scripts/serve-pipeline.sh # pipeline API on :8090 + classify_ocr_server.py on :8120
```
Full stack:
```bash
docker compose up -d --build
```
## Agents Settings Kit (backend-scoped)
@AGENTS.md
`AGENTS.md` in this directory now has two parts: Part A is the pre-existing vLLM
service doc referenced above; Part B (appended 2026-07-08) is a **backend-scoped
copy** of the [fhanyuh/agents-settings](https://github.com/fhanyuh/agents-settings)
`e`/`enhance`/`n`/`next` workflow, independent of the root-level copy that covers
the Flutter side (see root `CLAUDE.md`/`AGENTS.md`). Roles are in `SKILLS.md` (this
dir). The backlog and shipped-feature log live in `plans/next-enhancements.md` and
`docs/feature-list.md` (this dir) — these are backend-only and separate from the
root project's equivalents, which now only track Flutter work.
Claude-specific notes (same as root):
- Spawn the relevant `SKILLS.md` role via the `Agent` tool for a fresh-context
review/QA/architecture pass instead of continuing in the implementing context.
- Use `AskUserQuestion` for the one-at-a-time clarification step (§B2a).
- Use `EnterPlanMode` before writing code for any `n`/`next` task that touches
multiple files or has more than one reasonable implementation approach.
+103
View File
@@ -0,0 +1,103 @@
# Skills & Roles (backend)
Backend-scoped copy of the root `SKILLS.md` — same five roles, applied to
`backend/` surfaces (Next.js API gateway, OCR pipeline, Postgres, Docker/deploy)
during `n`/`next` execution (see `AGENTS.md` Part B, this dir). One agent can play
all of them in sequence; a multi-agent harness may spawn each as a separate
subagent for a fresh-context pass. Order matters: Architect → Backend/Frontend →
QA → Hardware/Compatibility.
## 1. Software Architect
**Responsibilities**
- Decide where new backend code lives; keep module boundaries clean (API routes vs.
`utils/` business logic vs. `db/` layer vs. the Python pipeline in `config/`).
- Prefer deep modules (few, well-bounded files with simple interfaces) over shallow
ones — this is what keeps the codebase navigable for an agent.
- Own the 256-LOC split rule (`AGENTS.md` Part B §B3): when a file crosses the
threshold, decide the split boundary before anyone patches around it.
- Keep `plans/next-enhancements.md` (this dir) structured by real backend module
boundaries, not arbitrary groupings.
- Owns the existing-project audit (`AGENTS.md` Part B §B0) for backend specifically.
**When invoked**: start of every `e`/`enhance` run; start of every `n`/`next` task,
before implementation begins.
**Handoff**: hands the Backend/Frontend roles a target file layout and interface
contract, not just a task description.
## 2. Backend Engineer
**Responsibilities**
- Implement Next.js API route logic, DB access (`src/db/`), and the Python OCR
pipeline (`config/classify_ocr_server.py`, pipeline API) as the task requires.
- Wire the mock-vs-live routing required by the Demo/Live switch (`AGENTS.md` Part B
§B5) and the Cloud/Local endpoint switch (§B6) if/when built — both must resolve
through the same contract so swapping either setting never changes calling code.
- Keep business logic out of route handlers (`src/app/api/**/route.ts`); route
handlers stay thin, matching the existing `utils/parser.ts`-style separation.
- Use `withTransaction` (`src/db/index.ts`) for any multi-statement write that must
be atomic — see task 7.1 in the pre-kit history for why this matters here.
**When invoked**: any task touching API routes, the DB layer, or the OCR pipeline.
**Handoff**: gives Frontend a stable contract (types/response shape) to build
against; gives QA the list of new/changed endpoints and their expected error modes.
## 3. Frontend Engineer
**Responsibilities**
- Implement UI for the task inside `pfm-web-app/src/app/`, including Demo/Live and
Cloud/Local switcher controls where relevant.
- Follow this repo's existing page pattern: consolidated single-page flows (root
`page.tsx`) vs. standalone-purpose route folders (`manual-label/page.tsx`,
`scan-pfm/page.tsx`) — see backend `CLAUDE.md` for which pattern a given feature
should follow.
- Consume the Backend Engineer's contract rather than reaching around it.
- Keep components small and composable, respecting the 256-LOC rule.
**When invoked**: any task with a user-facing surface inside `pfm-web-app/`.
**Handoff**: gives QA the golden-path user flow and the edge cases it's aware of.
## 4. QA / Test Engineer
**Responsibilities**
- During clarification (`AGENTS.md` Part B §B2a), turn resolved answers into
concrete acceptance criteria — what "done" verifiably means.
- Write/extend automated tests (`parser.test.ts` pattern) for the change.
- For anything touching `parser.ts` or the OCR pipeline, run the accuracy
regression harness (`node pfm-web-app/scripts/accuracy-check.mts`) and check for
regressions against the current baseline (~89.4% overall, target 95% — see
backend `CLAUDE.md`), not just "it compiles."
- Run the **verify build integrity** pass: golden path + edge cases + regression
check on adjacent features.
- Reject work back to the relevant role if acceptance criteria aren't met — don't
patch around a failing check.
**When invoked**: acceptance-criteria drafting during §B2a; final verification pass
before a task is marked `[DONE]`.
**Handoff**: reports pass/fail with specifics (what broke, under what input) back to
whichever role owns that surface.
## 5. Hardware & Performance Compatibility Reviewer
**Responsibilities**
- Check the change against this stack's real constraints: single vs. dual-GPU dev
mode (`docker-compose.yml` runs `npm run dev`, a known throughput ceiling), VRAM
budget for vLLM (`gpu-memory-utilization` in `config/vllm_config.yaml`), and
behavior under the Local/on-prem deployment mode from `AGENTS.md` Part B §B6.
- Flag newly introduced heavy Python/Node dependencies, GPU-specific assumptions, or
anything that would break the isolated vLLM-server-only environment (no
`paddlepaddle-gpu` in this venv — see `AGENTS.md` Part A / `docs/vllm-service.md`).
- Flag anything that would degrade badly on lower-spec hardware or slower networks
(e.g. the mobile app's 2s polling loop against a slow backend response), and
suggest a lighter-weight alternative when one exists.
**When invoked**: final verification pass, alongside QA, before a task is marked
`[DONE]`; also whenever a task adds a new dependency or changes the deployment/
runtime surface.
**Handoff**: blocks `[DONE]` status until concerns are resolved or explicitly
accepted as a documented trade-off in `docs/feature-list.md` (this dir).
+124 -25
View File
@@ -3,9 +3,12 @@ import io
import os
import re
import traceback
import pickle
from datetime import date
from pathlib import Path
import torch
from torchvision import transforms
import requests
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
@@ -94,6 +97,43 @@ app.add_middleware(
allow_headers=["*"],
)
# DINOv2 Image preprocessing
DINOV2_TRANSFORMS = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# Load DINOv2 Vector Similarity Search Model on startup
print("Loading DINOv2 for similarity search...")
dinov2_model = None
dinov2_index = None
models_dir = resolve_classifier_models_dir()
dinov2_index_path = models_dir / "dinov2_index.pkl" if models_dir else None
if dinov2_index_path and dinov2_index_path.is_file():
try:
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using DINOv2 on device: {device}")
# Load the index
print(f"Loading DINOv2 index from {dinov2_index_path}...")
with open(dinov2_index_path, "rb") as f:
dinov2_index = pickle.load(f)
print(f"DINOv2 index loaded with {len(dinov2_index['metadata'])} reference images.")
# Load DINOv2 model
dinov2_model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14").to(device)
dinov2_model.eval()
print("DINOv2 model loaded successfully.")
except Exception as e:
print(f"Error loading DINOv2 model or index: {e}")
dinov2_model = None
dinov2_index = None
else:
print(f"DINOv2 index not found at {dinov2_index_path}. DINOv2 search disabled.")
# Load models on startup
print("Loading YOLO model...")
yolo_model = None
@@ -431,33 +471,92 @@ async def classify_ocr(payload: ScanRequest):
raw_image = Image.open(io.BytesIO(img_data))
image = ImageOps.exif_transpose(raw_image).convert("RGB")
# 1. Run YOLO Classification
# 1. Run DINOv2 Similarity Search or YOLO Classification
classification_result = {}
top1_name = None
if yolo_model:
results = yolo_model(image)
probs = results[0].probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = results[0].names[top1_idx]
all_probs = []
for idx, val in enumerate(probs.data):
all_probs.append({
"name": results[0].names[idx],
"confidence": float(val)
})
all_probs.sort(key=lambda x: x["confidence"], reverse=True)
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs
}
else:
classification_result = {
"error": "YOLO model not loaded"
}
# Try DINOv2 first if index exists
if dinov2_model and dinov2_index:
try:
device = "cuda" if torch.cuda.is_available() else "cpu"
# Preprocess image
preprocessed = DINOV2_TRANSFORMS(image).unsqueeze(0).to(device)
# Extract query embedding
with torch.no_grad():
query_emb = dinov2_model(preprocessed)
query_emb = query_emb / query_emb.norm(dim=-1, keepdim=True)
query_emb = query_emb.squeeze(0).cpu().numpy()
ref_embeddings = dinov2_index["embeddings"] # (N, 384)
ref_metadata = dinov2_index["metadata"] # List of dicts
# Compute Cosine Similarity
similarities = np.dot(ref_embeddings, query_emb)
# Aggregate similarities per class (max similarity of any reference image in the class)
class_sims = {}
for idx, meta in enumerate(ref_metadata):
c_name = meta["class_name"]
sim = float(similarities[idx])
if c_name not in class_sims or sim > class_sims[c_name]:
class_sims[c_name] = sim
# Sort classes by similarity
sorted_classes = sorted(class_sims.items(), key=lambda x: x[1], reverse=True)
all_probs = []
for c_name, sim in sorted_classes:
all_probs.append({
"name": c_name,
"confidence": sim
})
if all_probs:
top1_name = all_probs[0]["name"]
top1_conf = all_probs[0]["confidence"]
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs,
"method": "dinov2_similarity"
}
print(f"[DINOv2] Best match: {top1_name} ({top1_conf:.4f})")
except Exception as dinov2_err:
print(f"[DINOv2 Error] Similarity search failed, falling back to YOLO: {dinov2_err}")
traceback.print_exc()
top1_name = None
# Fallback to YOLO if DINOv2 was not run or failed
if not top1_name:
if yolo_model:
results = yolo_model(image)
probs = results[0].probs
top1_idx = probs.top1
top1_conf = float(probs.top1conf)
top1_name = results[0].names[top1_idx]
all_probs = []
for idx, val in enumerate(probs.data):
all_probs.append({
"name": results[0].names[idx],
"confidence": float(val)
})
all_probs.sort(key=lambda x: x["confidence"], reverse=True)
classification_result = {
"top1_name": top1_name,
"top1_confidence": top1_conf,
"all_probabilities": all_probs,
"method": "yolo_classifier"
}
print(f"[YOLO Fallback] Best match: {top1_name} ({top1_conf:.4f})")
else:
classification_result = {
"error": "Both DINOv2 index and YOLO model are unavailable"
}
# 2. Run PaddleOCR
ocr_result = {}
+1
View File
@@ -107,6 +107,7 @@ services:
- PGUSER=postgres
- PGPASSWORD=postgres
- PGDATABASE=dopfm
- JWT_SECRET=${JWT_SECRET:-dev-only-insecure-secret-change-me}
pid: "host"
volumes:
- ./pfm-web-app:/app
+51
View File
@@ -0,0 +1,51 @@
# Feature List (backend)
Structured log of shipped backend features, updated by the `n`/`next` workflow (see
[AGENTS.md](../AGENTS.md) Part B) whenever a task in
[plans/next-enhancements.md](../plans/next-enhancements.md) is marked `[DONE]`.
Split out 2026-07-08 from root `docs/feature-list.md`'s backend sections — this file
is the sole home for backend feature history going forward.
## Format
```
## <Section / Module Name>
- **<task number>** <feature description> — shipped <date>
```
---
## Existing Features (pre-kit)
Backfilled 2026-07-08 during adoption of this kit — these predate the `e`/`n`
workflow and have no task numbers; see `git log` for real dates/history.
### Backend — Next.js API Gateway
- Upload/parse/documents CRUD routes, GPU status endpoint, vLLM proxy, manual-label review tool.
### Backend — OCR Pipeline & Accuracy
- PaddleOCR + vLLM classification pipeline with DB layout caching, table column-shift correction, date normalization, and an accuracy regression harness (`pfm-web-app/scripts/accuracy-check.mts`) — **95.10% overall as of 2026-07-08** (target 95% met; see task 2.3 below for the investigation and `CLAUDE.md`).
### Backend — Postgres Data Layer
- Schema/init in `pfm-web-app/src/db/init.ts`, served via the canonical root `docker-compose.yml` stack.
### DevOps — Docker & Dev Tunnel
- Root `docker-compose.yml` (dev, hot-reload) and `docker-compose.demo.yml` (production-mode override); `start-dev-tunnel.ps1` syncs the host LAN IP into the Flutter app config and starts an ngrok tunnel.
*(New features shipped via `n`/`next` go below, organized the same way, with task numbers.)*
## Backend — Next.js API Gateway
- **1.4** Enforced real 401 auth on `/api/v1/documents/*` (list, PUT-by-id, upload) — the actual production API surface, already fully supported by the Flutter client (real login + `Authorization: Bearer` on every request). Previously none of these three routes rejected a missing/invalid token; upload only optionally read it. Added the pre-existing `getAccountFromAuthHeader()` helper (`utils/auth.ts`) + a 401 guard to all three; `OPTIONS` (CORS preflight) untouched. The original task 1.3 (auth on the *classic* routes) was cancelled instead — those routes are dev-only web UI surface with no login flow, going away in production. Verified via `curl`: 401 with no token, success with a real token from `/api/v1/auth/login` — shipped 2026-07-08.
## Backend — OCR Pipeline & Accuracy
- **2.1** Built the Product/SKU scan classifier's model artifacts: `models/dinov2_index.pkl` (118/118 reference photos indexed across 16 SKU classes) and `models/produk-pfm-classifier-26n-100e-2026-07-08.pt` (+ `.onnx` export) — a YOLO classifier fine-tuned 100 epochs, 83.3% top-1 / 90% top-5 validation accuracy on the current (thin, 2-16 photos/class) dataset. Built via a one-off `docker run` from a freshly-rebuilt `pipeline-api` image (bare-metal training isn't viable on Windows — `paddlepaddle-gpu`'s wheel index is Linux-only). `pipeline-api` restarted and confirmed loading both models from logs. Also fixed `scripts/install-pipeline.sh`, which was missing `ultralytics`/`torch` — shipped 2026-07-08.
- **2.1 (verification pass)** Ran a full browser walkthrough of `/scan-pfm` (classification, top-5, OCR expiry extraction + crop, SKU-master matching, Visual/Spotting Grid, Raw Response — all confirmed working with real data). Found and fixed a real bug: "Save Ground Truth" was returning success but silently writing into the `pfm-web-app` container's ephemeral filesystem instead of the host, because `/sources` wasn't a bind-mounted path in root `docker-compose.yml`. Added `./backend/sources:/sources` to the `pfm-web-app` service, recovered an orphaned entry via `docker cp`, and re-verified the save now persists to `backend/sources/product_manual_labels.json` on the host (confirmed the DO-flow's `manual_labels.json` save was fixed by the same change too) — shipped 2026-07-08.
- **2.3** Ran the accuracy regression harness and discovered `sources/accuracy_report.md` was badly stale (claimed 75.04%; real current baseline is **95.10% overall, already at/above the 95% target** — added a staleness banner to that file). Root-caused every remaining mismatch by pulling raw OCR text from Postgres (`documents.layout_parsing_result`): the worst field, `plat` (67.6%), is almost entirely the license-plate region being classified as an image/seal by the layout model rather than OCR'd as text — not fixable in `parser.ts`. Found and fixed one genuine parser logic bug along the way: the "global pattern scanning fallback" could duplicate an already-correctly-extracted `noDO` value into a still-missing `noSO` field; fixed by excluding already-assigned values from that fallback's candidate pool (`pfm-web-app/src/utils/parser.ts`). Doesn't change the aggregate score (a wrong value and "Not Found" score the same) but stops a fabricated-looking wrong number from silently reaching the database. All 48 parser unit tests still pass — shipped 2026-07-08.
## Backend — Postgres Data Layer
- **3.1** Wrapped the `ocr_items` delete-then-reinsert in `/api/parse` and `/api/v1/documents/[id]` PUT inside a DB transaction (`withTransaction` helper, `pfm-web-app/src/db/index.ts`) — a mid-loop insert failure now rolls back to the previous item set instead of leaving a document with a correct header but partial/missing items — shipped 2026-07-08. (Renumbered from root's `7.1` when this file split from root `docs/feature-list.md`.)
- **3.2** Hashed `accounts.password` with `bcryptjs` (pure-JS, no native compile step — the `pfm-web-app` Docker image has no build toolchain). `db/init.ts` hashes the seed and idempotently migrates any pre-existing plaintext rows on every startup; `api/v1/auth/login/route.ts` now compares with `bcrypt.compareSync` and cleanly rejects missing credentials with a 401 instead of risking a raw-query edge case. Verified via `psql` (hash format) and `curl` (correct login succeeds, wrong/missing password returns 401) — shipped 2026-07-08.
+138
View File
@@ -0,0 +1,138 @@
# vLLM Service — Full Reference
Detail split out of `../AGENTS.md` (2026-07-08, to keep that file under the
Agents Settings Kit's 256-line threshold once the `e`/`n` workflow was appended
to it). `AGENTS.md` keeps the short version — architecture, quick start, the
env var table, file map — and links here for everything else.
## Issue recording — naming and template
```
issues/{NN}-{slug}.md
```
| Part | Rule | Example |
|------|------|---------|
| `{NN}` | Two-digit running number (`01`, `02`, …). Increment from the highest existing file. | `03` |
| `{slug}` | Lowercase kebab-case summary of the problem | `gpu-memory-startup-failure` |
Full example: `issues/04-gpu-memory-startup-failure.md`
### File template
```markdown
# Issue {NN}: {Short title}
## Problem
What failed, with exact error message or symptom.
## Context
Environment, command run, relevant config (`.env`, `config/vllm_config.yaml`).
## Solution
What fixed it, or current workaround / open status.
## References
Links, related issue files, or AGENTS.md sections.
```
Check `issues/` for the next number:
```bash
ls issues/*.md 2>/dev/null | sort
```
## Client usage
After the server is running:
```bash
# CLI
uv run paddleocr doc_parser \
--input https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/paddleocr_vl_demo.png \
--vl_rec_backend vllm-server \
--vl_rec_server_url http://localhost:8118/v1
```
```python
from paddleocr import PaddleOCRVL
pipeline = PaddleOCRVL(
vl_rec_backend="vllm-server",
vl_rec_server_url="http://127.0.0.1:8118/v1",
)
output = pipeline.predict("path/to/image.png")
```
Note: The full PaddleOCR-VL client should run in a **separate** environment if it needs PaddlePaddle GPU + Transformers. This repo is the isolated vLLM server only.
## Tuning vLLM
Edit `config/vllm_config.yaml`:
```yaml
gpu-memory-utilization: 0.8
max-num-seqs: 128
```
Reference: [PaddleOCR-VL vLLM parameter tuning](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html#331-server-side-parameter-adjustment)
## Troubleshooting
See `issues/` for full write-ups. Quick pointers:
| Symptom | Issue file |
|---------|------------|
| `paddleocr install_genai_server_deps` / `No module named pip` | [01-genai-server-deps-pip-in-uv-venv.md](../issues/01-genai-server-deps-pip-in-uv-venv.md) |
| flash-attn wheel incompatible with Python version | [02-flash-attn-wheel-python-version-mismatch.md](../issues/02-flash-attn-wheel-python-version-mismatch.md) |
| `uv pip` targets wrong venv from another project | [03-active-virtual-env-from-other-project.md](../issues/03-active-virtual-env-from-other-project.md) |
| Free memory below `gpu-memory-utilization` on startup | [04-gpu-memory-startup-failure.md](../issues/04-gpu-memory-startup-failure.md) |
| `TokenizersBackend has no attribute all_special_tokens_extended` | [05-transformers-tokenizers-incompatibility.md](../issues/05-transformers-tokenizers-incompatibility.md) |
| Extracted images not shown in Gradio demo (raw base64 in markdown) | [06-extracted-images-raw-base64-not-displayed.md](../issues/06-extracted-images-raw-base64-not-displayed.md) |
### flash-attn build failures
Install the prebuilt wheel after `uv sync` (see `scripts/install.sh`):
```bash
FLASH_ATTN_WHEEL="https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.3.14/flash_attn-2.8.2+cu128torch2.8-cp312-cp312-linux_x86_64.whl" \
./scripts/install.sh
```
Pick the wheel matching your Python and CUDA versions from [flash-attention prebuild wheels](https://mjunya.com/flash-attention-prebuild-wheels/). Details: [02-flash-attn-wheel-python-version-mismatch.md](../issues/02-flash-attn-wheel-python-version-mismatch.md).
Note: `paddleocr install_genai_server_deps` uses `pip` internally and is incompatible with uv-managed venvs. See [01-genai-server-deps-pip-in-uv-venv.md](../issues/01-genai-server-deps-pip-in-uv-venv.md). This repo installs the vLLM stack via `uv sync` + `uv pip`.
### `TokenizersBackend has no attribute all_special_tokens_extended`
Pin transformers (already in `pyproject.toml`):
```bash
uv pip install "transformers==4.57.6"
```
See [05-transformers-tokenizers-incompatibility.md](../issues/05-transformers-tokenizers-incompatibility.md).
### Do not install `paddlepaddle-gpu` in this venv
vLLM and PaddlePaddle GPU conflict. This server env uses `paddleocr[doc-parser]` without Paddle GPU.
### GPU memory on startup
If vLLM reports free memory below `gpu-memory-utilization`, either:
- Set `CUDA_VISIBLE_DEVICES` to a less-busy GPU
- Lower `gpu-memory-utilization` in `config/vllm_config.yaml` (e.g. `0.75` or `0.7`)
See [04-gpu-memory-startup-failure.md](../issues/04-gpu-memory-startup-failure.md).
### Health check
```bash
curl -s http://localhost:8118/v1/models | jq .
```
## References
- [PaddleOCR-VL usage tutorial](https://www.paddleocr.ai/latest/en/version3.x/pipeline_usage/PaddleOCR-VL.html)
- [PaddleOCR genai_server FAQ](https://github.com/PaddlePaddle/PaddleOCR/discussions/16822)
+2 -2
View File
@@ -148,7 +148,7 @@ http {
# Route to Pipeline API
location /layout-parsing {
proxy_pass http://paddleocr-pipeline-api-v10:8090/layout-parsing;
proxy_pass http://paddleocr-pipeline-api:8090/layout-parsing;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
@@ -159,7 +159,7 @@ http {
}
location /health {
proxy_pass http://paddleocr-pipeline-api-v10:8090/health;
proxy_pass http://paddleocr-pipeline-api:8090/health;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
+170 -1
View File
@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -17,6 +19,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -1584,6 +1588,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1605,6 +1616,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -2568,6 +2597,15 @@
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -2626,6 +2664,12 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -2988,6 +3032,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.364",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
@@ -4667,6 +4720,40 @@
"node": ">=6"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsonwebtoken/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4683,6 +4770,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5004,6 +5112,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5011,6 +5155,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5120,7 +5270,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5941,6 +6090,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+4
View File
@@ -10,6 +10,8 @@
},
"dependencies": {
"@gradio/client": "^2.2.1",
"bcryptjs": "^3.0.3",
"jsonwebtoken": "^9.0.3",
"next": "16.2.6",
"pg": "^8.21.0",
"puppeteer-core": "^25.1.0",
@@ -18,6 +20,8 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
import os
import re
import time
import pickle
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
from pathlib import Path
# Setup directories
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_DATASET_DIR = SCRIPT_DIR / "foto-kemasan-v2"
DEFAULT_MODELS_DIR = SCRIPT_DIR / "models"
DEFAULT_INDEX_PATH = DEFAULT_MODELS_DIR / "dinov2_index.pkl"
# Allowed image extensions
IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
# DINOv2 Image preprocessing
DINOV2_TRANSFORMS = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
def get_embedding(dinov2_model, image: Image.Image, device):
if image.mode != "RGB":
image = image.convert("RGB")
tensor = DINOV2_TRANSFORMS(image).unsqueeze(0).to(device)
with torch.no_grad():
embedding = dinov2_model(tensor)
# L2 normalization for dot product similarity
embedding = embedding / embedding.norm(dim=-1, keepdim=True)
return embedding.squeeze(0).cpu().numpy()
def run_indexing(src_dir=DEFAULT_DATASET_DIR, out_path=DEFAULT_INDEX_PATH):
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"Using device: {device}")
src_path = Path(src_dir).resolve()
out_file_path = Path(out_path).resolve()
if not src_path.is_dir():
print(f"Error: Source dataset directory not found: {src_path}")
return False
out_file_path.parent.mkdir(parents=True, exist_ok=True)
# Load DINOv2 Model from Torch Hub
print("Loading DINOv2 model (dinov2_vits14)...")
t0 = time.perf_counter()
dinov2_model = torch.hub.load("facebookresearch/dinov2", "dinov2_vits14").to(device)
dinov2_model.eval()
print(f"DINOv2 loaded in {time.perf_counter() - t0:.2f}s")
# Scan dataset directory
class_dirs = [d for d in src_path.iterdir() if d.is_dir()]
class_dirs.sort()
embeddings_list = []
metadata_list = []
total_images = 0
indexed_images = 0
for c_dir in class_dirs:
class_name = c_dir.name
images = sorted(
[f for f in c_dir.iterdir() if f.suffix.lower() in IMAGE_EXTS],
key=lambda p: p.name
)
if not images:
continue
print(f"Processing class: {class_name} ({len(images)} images)")
total_images += len(images)
for img_file in images:
try:
# Load image
image = Image.open(img_file).convert("RGB")
# Extract DINOv2 embedding (using whole image as reference photo)
embedding = get_embedding(dinov2_model, image, device)
embeddings_list.append(embedding)
metadata_list.append({
"class_name": class_name,
"image_path": str(img_file.relative_to(src_path.parent)),
"file_name": img_file.name
})
indexed_images += 1
except Exception as e:
print(f" [Error] Failed to process {img_file.name}: {e}")
# Save the index
if embeddings_list:
embeddings_arr = np.vstack(embeddings_list)
index_data = {
"embeddings": embeddings_arr,
"metadata": metadata_list
}
with open(out_file_path, "wb") as f:
pickle.dump(index_data, f)
print(f"\nSuccess! Indexed {indexed_images}/{total_images} images.")
print(f"DINOv2 Vector Index saved to: {out_file_path}")
return True
else:
print("\n[Warning] No images were successfully indexed.")
return False
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Build DINOv2 image vector index for PFM products")
parser.add_argument("--src-dir", default=str(DEFAULT_DATASET_DIR), help="Source directory of classes")
parser.add_argument("--output", default=str(DEFAULT_INDEX_PATH), help="Output pickle index path")
args = parser.parse_args()
run_indexing(src_dir=args.src_dir, out_path=args.output)
+27 -3
View File
@@ -221,13 +221,35 @@ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: numbe
}
}
async function fetchParse(baseUrl: string, filename: string): Promise<any> {
// Store name/address are assigned per-account now, not OCR-detected (see
// v1/documents/upload/route.ts + /api/parse). To score store/alamat fields
// the same way production behaves, simulate "the account assigned to this
// image's ground-truth store uploaded it" by resolving gt.store -> kode_toko
// and passing that through, same as a real account's JWT would.
async function fetchStoreNameToKodeMap(): Promise<Map<string, string>> {
const { Pool } = await import("pg");
const pool = new Pool({
host: process.env.PGHOST || "localhost",
port: parseInt(process.env.PGPORT || "5432", 10),
user: process.env.PGUSER || "postgres",
password: process.env.PGPASSWORD || "postgres",
database: process.env.PGDATABASE || "dopfm",
});
try {
const res = await pool.query("SELECT nama_toko, kode_toko FROM store_master");
return new Map(res.rows.map((r: any) => [r.nama_toko, r.kode_toko]));
} finally {
await pool.end();
}
}
async function fetchParse(baseUrl: string, filename: string, kodeToko?: string): Promise<any> {
const res = await fetchWithTimeout(
`${baseUrl}/api/parse`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename }),
body: JSON.stringify({ filename, kodeToko }),
},
FETCH_TIMEOUT_MS
);
@@ -540,6 +562,7 @@ async function main() {
const gtMap = loadGroundTruth();
const testImages = listTestImages();
const storeNameToKode = await fetchStoreNameToKodeMap();
if (testImages.length === 0) {
console.error(`No test images found in ${TEST_IMAGES_DIR}`);
@@ -566,7 +589,8 @@ async function main() {
if (!gt) continue;
process.stdout.write(`Parsing ${filename}... `);
try {
const parsed = await fetchParse(args.baseUrl, filename);
const kodeToko = storeNameToKode.get(gt.store);
const parsed = await fetchParse(args.baseUrl, filename, kodeToko);
const { checks, detail } = compareImage(gt, parsed);
allChecks.push(...checks);
detailsByFile[filename] = detail;
+8 -10
View File
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { Client } from "@gradio/client";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const maxDuration = 120; // Allow up to 120 seconds for slow model inference
@@ -21,7 +22,7 @@ export async function GET(req: NextRequest) {
`, [parseInt(runId)]);
if (runRes.rowCount === 0) {
return NextResponse.json({ success: false, error: "Run not found" }, { status: 404 });
return errorResponse(404, "Run not found");
}
return NextResponse.json({ success: true, run: runRes.rows[0] });
}
@@ -66,7 +67,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: true, runs: runsRes.rows });
} catch (error: any) {
console.error("Failed to fetch arena runs/stats:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -81,7 +82,7 @@ export async function POST(req: NextRequest) {
image = body.image;
if (!engine || !image) {
return NextResponse.json({ error: "Missing engine or image" }, { status: 400 });
return errorResponse(400, "Missing engine or image");
}
imageType = body.imageType || "do";
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest) {
const filePath = path.join(process.cwd(), "public", cleanPath);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: `File not found on server: ${image}` }, { status: 404 });
return errorResponse(404, `File not found on server: ${image}`);
}
imageBuffer = fs.readFileSync(filePath);
base64Image = `data:image/jpeg;base64,${imageBuffer.toString("base64")}`;
@@ -117,7 +118,7 @@ export async function POST(req: NextRequest) {
base64Image = `data:image/jpeg;base64,${image}`;
imageBuffer = Buffer.from(image, "base64");
} else {
return NextResponse.json({ error: "Invalid image format" }, { status: 400 });
return errorResponse(400, "Invalid image format");
}
let outputText = "";
@@ -212,7 +213,7 @@ export async function POST(req: NextRequest) {
outputText = data[0] || "";
} else {
return NextResponse.json({ error: `Unknown engine: ${engine}` }, { status: 400 });
return errorResponse(400, `Unknown engine: ${engine}`);
}
const elapsedMs = Date.now() - startTime;
@@ -259,9 +260,6 @@ export async function POST(req: NextRequest) {
console.error("Failed to log failure to arena_runs:", dbErr);
}
return NextResponse.json({
success: false,
error: error.message || "Failed to process OCR request"
}, { status: 500 });
return errorResponse(500, error.message || "Failed to process OCR request");
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error cropping file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -14,7 +15,7 @@ export async function GET(
const docId = parseInt(id, 10);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
return errorResponse(400, "Invalid document ID");
}
const res = await query(
@@ -23,7 +24,7 @@ export async function GET(
);
if (res.rowCount === 0 || !res.rows[0]) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
return NextResponse.json({
@@ -32,6 +33,6 @@ export async function GET(
});
} catch (error: any) {
console.error("Error fetching document logs:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -8,14 +9,14 @@ export async function GET(req: NextRequest) {
try {
const filename = req.nextUrl.searchParams.get("file");
if (!filename) {
return NextResponse.json({ error: "File name is required" }, { status: 400 });
return errorResponse(400, "File name is required");
}
const safeFile = path.basename(filename);
const filePath = path.join(UPLOADS_DIR, safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
// Determine content type based on extension
@@ -41,6 +42,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error serving file from uploads:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
+10 -12
View File
@@ -9,6 +9,7 @@ import {
getProcessName,
unloadOtherEngines
} from "../../../utils/docker";
import { errorResponse } from "@/utils/api-error";
export async function GET(req: NextRequest) {
try {
@@ -32,7 +33,7 @@ export async function GET(req: NextRequest) {
});
} catch (error: any) {
console.error("Failed to fetch GPU/container status:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -44,7 +45,7 @@ export async function POST(req: NextRequest) {
if (action === "kill") {
const pid = parseInt(body.pid);
if (!pid || isNaN(pid)) {
return NextResponse.json({ success: false, error: "Invalid PID" }, { status: 400 });
return errorResponse(400, "Invalid PID");
}
// Check if process is protected (same rules as admin_panel.py)
@@ -52,17 +53,14 @@ export async function POST(req: NextRequest) {
const procNameLower = procName.toLowerCase();
const protectedKeywords = ["rustdesk", "xorg", "nginx", "systemd", "dockerd", "python3", "node"];
if (anyKeywordMatch(procNameLower, protectedKeywords)) {
return NextResponse.json({
success: false,
error: `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`
}, { status: 403 });
return errorResponse(403, `Operation Denied: Process ${pid} (${procName || "system"}) is protected and cannot be killed.`);
}
try {
process.kill(pid, 9);
return NextResponse.json({ success: true, message: `Successfully killed process ${pid}` });
} catch (err: any) {
return NextResponse.json({ success: false, error: `Failed to kill process: ${err.message}` }, { status: 500 });
return errorResponse(500, `Failed to kill process: ${err.message}`);
}
}
@@ -79,12 +77,12 @@ export async function POST(req: NextRequest) {
];
if (!validActions.includes(containerAction) || !validContainers.includes(containerName)) {
return NextResponse.json({ success: false, error: "Invalid container name or action" }, { status: 400 });
return errorResponse(400, "Invalid container name or action");
}
// Prevent self-stopping nextjs app accidentally through UI
if (containerName === "paddleocr-pfm-web-app" && containerAction === "stop") {
return NextResponse.json({ success: false, error: "Cannot stop the active web application container itself." }, { status: 400 });
return errorResponse(400, "Cannot stop the active web application container itself.");
}
await manageContainer(containerName, containerAction);
@@ -97,7 +95,7 @@ export async function POST(req: NextRequest) {
if (action === "saveSettings") {
const { cudaDevices } = body;
if (typeof cudaDevices !== "string" || cudaDevices.trim() === "") {
return NextResponse.json({ success: false, error: "Invalid GPU allocation settings" }, { status: 400 });
return errorResponse(400, "Invalid GPU allocation settings");
}
const cleanCuda = cudaDevices.trim();
@@ -146,10 +144,10 @@ export async function POST(req: NextRequest) {
});
}
return NextResponse.json({ success: false, error: "Invalid API action" }, { status: 400 });
return errorResponse(400, "Invalid API action");
} catch (error: any) {
console.error("GPU API POST error:", error);
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query, cleanupAndReindexItems } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -79,7 +80,7 @@ export async function GET(req: NextRequest) {
});
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
}
// List view: return history list from DB
@@ -121,7 +122,7 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -129,7 +130,7 @@ export async function DELETE(req: NextRequest) {
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -163,11 +164,11 @@ export async function DELETE(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in DELETE history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -175,7 +176,7 @@ export async function POST(req: NextRequest) {
try {
const { filename, remark } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
const safeFile = path.basename(filename);
@@ -192,10 +193,10 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: "Document not found" }, { status: 404 });
return errorResponse(404, "Document not found");
} catch (error: unknown) {
console.error("Error in POST history API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function GET() {
try {
@@ -17,6 +18,6 @@ export async function GET() {
return NextResponse.json({ files });
} catch (error: any) {
console.error("Error reading test-images directory:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
return errorResponse(500, error.message);
}
}
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
// Separate from DO manual_labels.json - product scan ground truth only
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "product_manual_labels.json");
interface ProductScanLabel {
filename: string;
no_sku: string;
nama_item: string;
expiry_date: string;
top1_confidence: number | null;
notes: string;
saved_at: string;
}
function sanitizeFilename(filename: string): string {
let cleaned = filename.replace(/\\/g, "/");
while (cleaned.startsWith("/")) {
cleaned = cleaned.substring(1);
}
return cleaned.replace(/\.\.\//g, "");
}
function normalizeDateString(dateStr: string): string {
if (!dateStr) return "";
const trimmed = dateStr.trim();
// Pattern 1: d Month YYYY (e.g. 7 June 2026)
const textPattern = /^(\d{1,2})\s+([a-zA-Z]+)\s+(\d{4})$/;
const tm = trimmed.match(textPattern);
if (tm) {
const day = tm[1].padStart(2, "0");
const month = tm[2].charAt(0).toUpperCase() + tm[2].slice(1).toLowerCase();
const year = tm[3];
return `${day} ${month} ${year}`;
}
// Pattern 2: d/m/YYYY or d-m-YYYY or d.m.YYYY (e.g. 7/6/2026)
const digitPattern = /^(\d{1,2})([-./])(\d{1,2})\2(\d{2,4})$/;
const dm = trimmed.match(digitPattern);
if (dm) {
const day = dm[1].padStart(2, "0");
const month = dm[3].padStart(2, "0");
let year = dm[4];
if (year.length === 2) {
year = "20" + year;
}
return `${day}/${month}/${year}`;
}
return trimmed;
}
function readLabels(): ProductScanLabel[] {
if (!fs.existsSync(LABELS_PATH)) {
return [];
}
const raw = fs.readFileSync(LABELS_PATH, "utf8");
return raw.trim() ? JSON.parse(raw) : [];
}
function writeLabels(labels: ProductScanLabel[]) {
const dir = path.dirname(LABELS_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const existing = labels.find((l) => l.filename === safeFilename);
if (existing) {
return NextResponse.json(existing);
}
// Return empty default state if not found
return NextResponse.json({
filename: safeFilename,
no_sku: "",
nama_item: "",
expiry_date: "",
top1_confidence: null,
notes: "",
saved_at: ""
});
} catch (err: unknown) {
console.error("Error in GET manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to load product label";
return errorResponse(500, message);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { filename } = body;
if (!filename) {
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = sanitizeFilename(filename);
const labels = readLabels();
const index = labels.findIndex((l) => l.filename === safeFilename);
const entry: ProductScanLabel = {
filename: safeFilename,
no_sku: body.no_sku || "",
nama_item: body.nama_item || "",
expiry_date: normalizeDateString(body.expiry_date || ""),
top1_confidence: typeof body.top1_confidence === "number" ? body.top1_confidence : null,
notes: body.notes || "",
saved_at: new Date().toISOString()
};
if (index >= 0) {
labels[index] = entry;
} else {
labels.push(entry);
}
writeLabels(labels);
return NextResponse.json({ success: true, filePath: LABELS_PATH, entry });
} catch (err: unknown) {
console.error("Error in POST manual-label-scan:", err);
const message = err instanceof Error ? err.message : "Failed to save product label";
return errorResponse(500, message);
}
}
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
const LABELS_PATH = path.join(process.cwd(), "..", "sources", "manual_labels.json");
@@ -17,63 +18,83 @@ function writeLabels(labels: any[]) {
fs.writeFileSync(LABELS_PATH, JSON.stringify(labels, null, 2), "utf8");
}
const SCALAR_FIELDS = ["noPO", "noSO", "noDO", "tanggal", "customer", "store", "alamat", "plat"] as const;
// Fetches the latest automated parser result for a filename, in the same
// shape as a manual_labels.json entry, so it can be used as fill-in data.
async function fetchLatestParsed(safeFilename: string): Promise<Record<string, any> | null> {
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (!docRes.rowCount || docRes.rowCount === 0) return null;
const doc = docRes.rows[0];
const meta = doc.metadata || {};
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[doc.id]
);
return {
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items: itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}))
};
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
return null;
}
}
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const filename = searchParams.get("filename");
if (!filename) {
return NextResponse.json({ error: "Filename parameter is required" }, { status: 400 });
return errorResponse(400, "Filename parameter is required");
}
const safeFilename = path.basename(filename);
const labels = readLabels();
const existing = labels.find(l => l.filename === safeFilename);
const latest = await fetchLatestParsed(safeFilename);
if (existing) {
return NextResponse.json(existing);
// Never overwrite a field the user already corrected manually - only
// fill in whatever is still blank, using the latest AI/DB parse.
const merged = { ...existing, filename: safeFilename };
if (latest) {
for (const field of SCALAR_FIELDS) {
if (!merged[field]) merged[field] = latest[field];
}
if (!merged.items || merged.items.length === 0) {
merged.items = latest.items;
}
}
// aiPredicted is the raw AI value for every field, always included
// (even when a manual value already exists) so the UI can show what
// the AI actually predicted next to the current/manual value.
return NextResponse.json({ ...merged, aiPredicted: latest });
}
// Try fallback to automated parser results in database
try {
const docRes = await query(
"SELECT id, metadata FROM documents WHERE filename = $1",
[safeFilename]
);
if (docRes.rowCount && docRes.rowCount > 0) {
const doc = docRes.rows[0];
const docId = doc.id;
const meta = doc.metadata || {};
// Fetch ocr items
const itemsRes = await query(
"SELECT kode_barang, nama_barang, banyak, jumlah FROM ocr_items WHERE document_id = $1 ORDER BY row_index",
[docId]
);
const items = itemsRes.rows.map(row => ({
kodeBarang: row.kode_barang || "",
namaBarang: row.nama_barang || "",
banyak: row.banyak || "",
jumlah: row.jumlah || ""
}));
return NextResponse.json({
filename,
noPO: meta.noPO || "",
noSO: meta.noSO || "",
noDO: meta.noDO || "",
tanggal: meta.tanggal || "",
customer: meta.customerInfo || "",
store: meta.orderUntuk || "",
alamat: meta.alamat || "",
plat: meta.platTruk || "",
items
});
}
} catch (dbErr) {
console.error("DB fallback failed inside manual-label GET:", dbErr);
if (latest) {
return NextResponse.json({ filename, ...latest, aiPredicted: latest });
}
// Return empty default state if not found anywhere
@@ -87,11 +108,12 @@ export async function GET(req: NextRequest) {
store: "",
alamat: "",
plat: "",
items: []
items: [],
aiPredicted: null
});
} catch (err: any) {
console.error("Error in GET manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to load manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to load manual label");
}
}
@@ -101,7 +123,7 @@ export async function POST(req: NextRequest) {
const { filename } = body;
if (!filename) {
return NextResponse.json({ error: "Filename is required in request body" }, { status: 400 });
return errorResponse(400, "Filename is required in request body");
}
const safeFilename = path.basename(filename);
@@ -120,6 +142,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true, filePath: LABELS_PATH });
} catch (err: any) {
console.error("Error in POST manual-label:", err);
return NextResponse.json({ error: err.message || "Failed to save manual label" }, { status: 500 });
return errorResponse(500, err.message || "Failed to save manual label");
}
}
+53 -32
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { query, withTransaction, cleanupAndReindexItems, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { errorResponse } from "@/utils/api-error";
// Bounds each pipeline call so a wedged GPU container fails fast into the existing
// graceful fallback path instead of hanging the request indefinitely.
@@ -42,9 +43,9 @@ function getStringSimilarity(s1: string, s2: string): number {
export async function POST(req: NextRequest) {
let safeFile = "";
try {
const { filename } = await req.json();
const { filename, kodeToko } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
return errorResponse(400, "Filename is required");
}
safeFile = path.basename(filename);
@@ -59,7 +60,7 @@ export async function POST(req: NextRequest) {
if (!fs.existsSync(filePath)) {
filePath = path.join("/uploads", safeFile);
if (!fs.existsSync(filePath)) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
return errorResponse(404, "File not found");
}
isUpload = true;
isSample = false;
@@ -137,7 +138,7 @@ export async function POST(req: NextRequest) {
} catch (dbErr) {
console.error("Failed to mark document as parsed on pipeline error:", dbErr);
}
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
data = await response.json();
@@ -388,8 +389,23 @@ export async function POST(req: NextRequest) {
}
docMetadata.items = checkedItems;
// Resolve store information using master database
const resolvedStore = await resolveStoreFromText(markdownText);
// Store name/address are assigned per-account, not OCR-detected: if the
// uploading account's store (kodeToko) was passed through, use it
// directly. Only fall back to OCR-based text matching when no account
// context is available (e.g. legacy/internal callers).
let resolvedStore: { orderUntuk: string; alamat: string } | null = null;
if (kodeToko) {
const storeRes = await query(
"SELECT nama_toko, alamat FROM store_master WHERE kode_toko = $1",
[kodeToko]
);
if (storeRes.rowCount && storeRes.rowCount > 0) {
resolvedStore = { orderUntuk: storeRes.rows[0].nama_toko, alamat: storeRes.rows[0].alamat };
}
}
if (!resolvedStore) {
resolvedStore = await resolveStoreFromText(markdownText);
}
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
@@ -443,31 +459,36 @@ export async function POST(req: NextRequest) {
const docId = insertDocRes.rows[0].id;
// Delete existing items for this document to avoid unique constraints / stale data
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document row already upserted above but
// only some (or none) of its items, since the delete has already committed
// independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
for (let i = 0; i < docMetadata.items.length; i++) {
const item = docMetadata.items[i];
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
)
VALUES ($1, $2, $3, $4, $5, $6, $6, $7, $7, false, '')
`, [
docId,
i,
(item as any).kodeBarangOriginal || item.kodeBarang,
item.kodeBarang,
item.namaBarang,
item.banyak,
item.jumlah
]);
}
});
// Return wrapped success response with items, flagged, remarks, and intermediate post-processing details
return NextResponse.json({
@@ -508,7 +529,7 @@ export async function POST(req: NextRequest) {
console.error("Failed to mark document as parsed on route catch:", dbErr);
}
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -44,6 +45,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error fetching produk PFM:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -3,6 +3,7 @@ import fs from "fs";
import path from "path";
import { query } from "../../../db";
import crypto from "crypto";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
const PUBLIC_DIR = path.join(process.cwd(), "public/do-pfm");
@@ -12,7 +13,7 @@ export async function POST(req: NextRequest) {
const { filename, image } = await req.json();
if (!filename || !image) {
return NextResponse.json({ error: "Filename and image base64 data are required" }, { status: 400 });
return errorResponse(400, "Filename and image base64 data are required");
}
const safeFile = path.basename(filename);
@@ -50,6 +51,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error rotating file:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -37,7 +38,7 @@ export async function POST(req: NextRequest) {
try {
const { image_base64 } = await req.json();
if (!image_base64) {
return NextResponse.json({ error: "Image is required" }, { status: 400 });
return errorResponse(400, "Image is required");
}
// Call Python FastAPI server inside the container
@@ -54,7 +55,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
const errText = await response.text();
return NextResponse.json({ error: `Classifier service error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Classifier service error: ${errText}`);
}
const data = await response.json();
@@ -131,6 +132,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in scan-pfm API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -18,6 +19,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in SKUs API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const res = await query(
"SELECT kode_toko, nama_toko, alamat FROM store_master ORDER BY nama_toko"
);
const stores = res.rows.map(row => ({
kodeToko: row.kode_toko,
namaToko: row.nama_toko,
alamat: row.alamat
}));
return NextResponse.json({ stores });
} catch (error: unknown) {
console.error("Error in stores API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return errorResponse(500, message);
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../db";
import path from "path";
import { errorResponse } from "@/utils/api-error";
export async function POST(req: NextRequest) {
try {
@@ -8,7 +9,7 @@ export async function POST(req: NextRequest) {
const { page, rowIndex, action } = body;
if (!page || rowIndex === undefined || !action) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
return errorResponse(400, "Missing required fields");
}
const safeFile = path.basename(page);
@@ -16,14 +17,14 @@ export async function POST(req: NextRequest) {
// Get document ID
const docRes = await query("SELECT id FROM documents WHERE filename = $1", [safeFile]);
if (!docRes.rowCount || docRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found in database" }, { status: 404 });
return errorResponse(404, "Document not found in database");
}
const docId = docRes.rows[0].id;
if (action === "edit") {
const { field, value } = body;
if (!field || value === undefined) {
return NextResponse.json({ error: "Missing edit parameters" }, { status: 400 });
return errorResponse(400, "Missing edit parameters");
}
// Map UI field names to database columns
@@ -35,7 +36,7 @@ export async function POST(req: NextRequest) {
} else if (field === "jumlah") {
colName = "jumlah";
} else {
return NextResponse.json({ error: "Invalid field name" }, { status: 400 });
return errorResponse(400, "Invalid field name");
}
await query(
@@ -49,7 +50,7 @@ export async function POST(req: NextRequest) {
} else if (action === "flag") {
const { isFlagged, remark } = body;
if (isFlagged === undefined || remark === undefined) {
return NextResponse.json({ error: "Missing flag parameters" }, { status: 400 });
return errorResponse(400, "Missing flag parameters");
}
await query(
@@ -61,11 +62,11 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ success: true });
} else {
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
return errorResponse(400, "Invalid action");
}
} catch (error: unknown) {
console.error("Error in update-row API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -5,6 +5,7 @@ import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
const UPLOADS_DIR = "/uploads";
@@ -19,7 +20,7 @@ export async function POST(req: NextRequest) {
const file = formData.get("file") as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
return errorResponse(400, "No file uploaded");
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -67,7 +68,7 @@ export async function POST(req: NextRequest) {
if (!response.ok) {
clearActiveLog(filename);
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
return errorResponse(response.status, `Pipeline API error: ${errText}`);
}
let data = await response.json();
@@ -208,7 +209,7 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500 });
return errorResponse(500, message);
}
}
@@ -1,4 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { errorResponse } from "@/utils/api-error";
import { signAccountToken } from "@/utils/auth";
import { query } from "../../../../../db";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -15,24 +19,37 @@ export async function POST(req: NextRequest) {
const body = await req.json();
const { username, password } = body;
// Simple authentication logic for demo
if (username === "admin" && password === "password") {
if (!username || !password) {
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
}
// Each account is assigned exactly one store (kode_toko) - the token
// carries that assignment so store name/address never need OCR
// detection later; whichever account uploads, its own store is used.
const accountRes = await query(
"SELECT id, username, kode_toko, password FROM accounts WHERE username = $1",
[username]
);
if (accountRes.rowCount && accountRes.rowCount > 0 && bcrypt.compareSync(password, accountRes.rows[0].password)) {
const account = accountRes.rows[0];
const token = signAccountToken({
accountId: account.id,
username: account.username,
kodeToko: account.kode_toko
});
return NextResponse.json({
status: "success",
message: "Login successful",
data: {
token: "demo-auth-token-12345"
}
data: { token }
}, { headers: corsHeaders });
}
return NextResponse.json({
status: "error",
message: "Invalid username or password"
}, { status: 401, headers: corsHeaders });
return errorResponse(401, "Invalid username or password", { headers: corsHeaders });
} catch (error: unknown) {
console.error("Error in login API route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../../db";
import { query, withTransaction } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -16,18 +18,23 @@ export async function PUT(
context: { params: Promise<{ id: string }> }
) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const params = await context.params;
const { id } = params;
const docId = parseInt(id);
if (isNaN(docId)) {
return NextResponse.json({ error: "Invalid document ID" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "Invalid document ID", { headers: corsHeaders });
}
// Check if document exists
const checkRes = await query("SELECT id, filename, upload_time FROM documents WHERE id = $1", [docId]);
if (!checkRes.rowCount || checkRes.rowCount === 0) {
return NextResponse.json({ error: "Document not found" }, { status: 404, headers: corsHeaders });
return errorResponse(404, "Document not found", { headers: corsHeaders });
}
const doc = checkRes.rows[0];
@@ -89,28 +96,32 @@ export async function PUT(
WHERE id = $1
`, [docId, latFloat, lngFloat, JSON.stringify(metadata)]);
// Delete existing ocr_items to recreate them
await query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Delete-then-reinsert must be atomic: without a transaction, a failure partway
// through the insert loop leaves the document with its header already updated
// above but only some (or none) of its items, since the delete has already
// committed independently.
await withTransaction(async (client) => {
await client.query("DELETE FROM ocr_items WHERE document_id = $1", [docId]);
// Insert new items
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
for (let i = 0; i < items.length; i++) {
const item = items[i];
const nomorSku = item.nomor_sku || item.nomorSku || "";
const namaBarang = item.nama_barang || item.namaBarang || "";
const banyak = item.banyak || "";
const jumlah = item.jumlah || "";
await query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
await client.query(`
INSERT INTO ocr_items (
document_id, row_index,
kode_barang_original, kode_barang,
nama_barang,
banyak_original, banyak,
jumlah_original, jumlah,
is_flagged, remark
) VALUES ($1, $2, $3, $3, $4, $5, $5, $6, $6, false, '')
`, [docId, i, nomorSku, namaBarang, banyak, jumlah]);
}
});
// Return the updated document mapping
const mappedData = {
@@ -150,6 +161,6 @@ export async function PUT(
} catch (error: unknown) {
console.error("Error in update document API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { query } from "../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
@@ -13,6 +15,11 @@ export async function OPTIONS() {
export async function GET(req: NextRequest) {
try {
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
// Retrieve all custom-uploaded documents
const docRes = await query(`
SELECT id, filename, upload_time, size, parsed, is_sample, metadata, latitude, longitude
@@ -114,6 +121,6 @@ export async function GET(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in list documents API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -3,6 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query } from "../../../../../db";
import { errorResponse } from "@/utils/api-error";
import { getAccountFromAuthHeader } from "@/utils/auth";
const UPLOADS_DIR = "/uploads";
@@ -23,11 +25,19 @@ export async function POST(req: NextRequest) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
// The account uploading is assigned exactly one store (kode_toko) - pass
// it through to /api/parse so store name/address are set directly from
// that assignment instead of being OCR-detected from the document photo.
const account = getAccountFromAuthHeader(req.headers.get("authorization"));
if (!account) {
return errorResponse(401, "Unauthorized", { headers: corsHeaders });
}
const formData = await req.formData();
const file = (formData.get("image") || formData.get("file")) as Blob | null;
if (!file) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400, headers: corsHeaders });
return errorResponse(400, "No file uploaded", { headers: corsHeaders });
}
const originalName = file instanceof File ? file.name : "document.jpg";
@@ -112,7 +122,7 @@ export async function POST(req: NextRequest) {
await fetch("http://127.0.0.1:3000/api/parse", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: finalFilename }),
body: JSON.stringify({ filename: finalFilename, kodeToko: account?.kodeToko }),
signal: AbortSignal.timeout(210_000)
});
} catch (err) {
@@ -151,6 +161,6 @@ export async function POST(req: NextRequest) {
} catch (error: unknown) {
console.error("Error in upload API v1 route:", error);
const message = error instanceof Error ? error.message : "Internal server error";
return NextResponse.json({ error: message }, { status: 500, headers: corsHeaders });
return errorResponse(500, message, { headers: corsHeaders });
}
}
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { logVllmCallToAll } from "../../../../utils/active-log";
import { errorResponse } from "@/utils/api-error";
export const dynamic = "force-dynamic";
@@ -104,7 +105,7 @@ async function handleProxy(req: NextRequest) {
} catch (error) {
console.error("[vllm-proxy] Error forwarding request:", error);
return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
return errorResponse(500, "Failed to proxy request to vLLM server");
}
}
+130 -13
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface ItemRow {
kodeBarang: string;
@@ -22,9 +23,52 @@ interface FormData {
items: ItemRow[];
}
interface Store {
kodeToko: string;
namaToko: string;
alamat: string;
}
interface Sku {
no_sku: string;
nama_item: string;
}
// Raw AI/DB parse for the current file, shown alongside each field as a
// reference note so the user can see what the AI predicted vs the current
// (possibly manually corrected) value. Same shape as FormData minus filename.
interface AiPredicted {
noPO: string;
noSO: string;
noDO: string;
tanggal: string;
customer: string;
store: string;
alamat: string;
plat: string;
items: ItemRow[];
}
// Small caption under a field showing what the AI predicted for it, so the
// user reviewing/correcting ground truth can see both values at a glance.
// Renders nothing if the file was never parsed (aiValue is undefined).
function AiNote({ current, aiValue }: { current: string; aiValue: string | undefined }) {
if (aiValue === undefined) return null;
const differs = (current || "").trim() !== (aiValue || "").trim();
return (
<span className={`text-[11px] leading-tight ${differs ? "text-amber-500" : "text-slate-600"}`}>
AI: {aiValue || "(tidak terdeteksi)"}
{differs && current ? " · beda dari manual" : ""}
</span>
);
}
export default function ManualLabelPage() {
const [files, setFiles] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState<number>(-1);
const [stores, setStores] = useState<Store[]>([]);
const [skus, setSkus] = useState<Sku[]>([]);
const [aiPredicted, setAiPredicted] = useState<AiPredicted | null>(null);
const [formData, setFormData] = useState<FormData>({
filename: "",
noPO: "",
@@ -66,6 +110,38 @@ export default function ManualLabelPage() {
fetchFiles();
}, []);
// Fetch store master list on mount, to power the store dropdown + address autofill
useEffect(() => {
const fetchStores = async () => {
try {
const res = await fetch("/api/stores");
if (res.ok) {
const data = await res.json();
setStores(data.stores || []);
}
} catch (err) {
console.error("Error loading store list:", err);
}
};
fetchStores();
}, []);
// Fetch SKU master list on mount, to power the item-name autofill by SKU
useEffect(() => {
const fetchSkus = async () => {
try {
const res = await fetch("/api/skus");
if (res.ok) {
const data = await res.json();
setSkus(data.skus || []);
}
} catch (err) {
console.error("Error loading SKU list:", err);
}
};
fetchSkus();
}, []);
// Fetch existing manual label when currentIndex changes
useEffect(() => {
if (currentIndex < 0 || currentIndex >= files.length) return;
@@ -97,6 +173,7 @@ export default function ManualLabelPage() {
plat: data.plat || "",
items: itemsList
});
setAiPredicted(data.aiPredicted || null);
}
} catch (err) {
console.error("Error fetching label data:", err);
@@ -132,13 +209,32 @@ export default function ManualLabelPage() {
}));
};
// When the store name matches a known store_master entry, default the
// address to that store's address. The user can still edit it afterward -
// this only sets a starting point, it doesn't lock the field.
const handleStoreChange = (value: string) => {
const matched = stores.find(s => s.namaToko === value);
setFormData(prev => ({
...prev,
store: value,
alamat: matched ? matched.alamat : prev.alamat
}));
};
const handleItemChange = (index: number, field: keyof ItemRow, value: string) => {
setFormData(prev => {
const updatedItems = [...prev.items];
updatedItems[index] = {
...updatedItems[index],
[field]: value
};
const updatedRow = { ...updatedItems[index], [field]: value };
// When the SKU code matches a known sku_master entry, default the item
// name to that SKU's name. Same convention as the store->alamat
// autofill: it's a starting point, not locked - still editable after.
if (field === "kodeBarang") {
const matched = skus.find(s => s.no_sku === value);
if (matched) updatedRow.namaBarang = matched.nama_item;
}
updatedItems[index] = updatedRow;
return {
...prev,
items: updatedItems
@@ -189,12 +285,12 @@ export default function ManualLabelPage() {
showToast("Ground truth data saved successfully!");
return true;
} else {
const errText = await res.text();
showToast(`Error: ${errText}`, true);
const data = await res.json().catch(() => undefined);
showToast(`Error: ${getErrorMessage(null, data, `HTTP ${res.status}`)}`, true);
return false;
}
} catch (err: any) {
showToast(`Save error: ${err.message}`, true);
showToast(`Save error: ${getErrorMessage(err)}`, true);
return false;
}
};
@@ -308,6 +404,7 @@ export default function ManualLabelPage() {
placeholder="e.g. PO/26/0000241938"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noPO} aiValue={aiPredicted?.noPO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -319,6 +416,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1691972227"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noSO} aiValue={aiPredicted?.noSO} />
</div>
</div>
@@ -332,6 +430,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 1659990561"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.noDO} aiValue={aiPredicted?.noDO} />
</div>
<div className="flex flex-col gap-1.5">
@@ -343,6 +442,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 30 June 2026"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.tanggal} aiValue={aiPredicted?.tanggal} />
</div>
</div>
@@ -356,17 +456,25 @@ export default function ManualLabelPage() {
placeholder="e.g. PT.PRIMAFOOD INTERNATIONAL"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.customer} aiValue={aiPredicted?.customer} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400">Store (Tujuan Kirim)</label>
<input
type="text"
list="storeOptions"
value={formData.store}
onChange={(e) => handleInputChange("store", e.target.value)}
placeholder="e.g. PX HEAD OFFICE ANCOL"
onChange={(e) => handleStoreChange(e.target.value)}
placeholder="Ketik untuk cari, atau pilih dari daftar..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<datalist id="storeOptions">
{stores.map(s => (
<option key={s.kodeToko} value={s.namaToko} />
))}
</datalist>
<AiNote current={formData.store} aiValue={aiPredicted?.store} />
</div>
</div>
@@ -379,6 +487,7 @@ export default function ManualLabelPage() {
placeholder="e.g. B 9256 JXR"
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20"
/>
<AiNote current={formData.plat} aiValue={aiPredicted?.plat} />
</div>
<div className="flex flex-col gap-1.5">
@@ -387,9 +496,10 @@ export default function ManualLabelPage() {
rows={2}
value={formData.alamat}
onChange={(e) => handleInputChange("alamat", e.target.value)}
placeholder="Complete address text..."
placeholder="Terisi otomatis saat memilih store, atau isi manual..."
className="px-3.5 py-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-100 text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 resize-none"
/>
<AiNote current={formData.alamat} aiValue={aiPredicted?.alamat} />
</div>
</div>
</div>
@@ -412,8 +522,10 @@ export default function ManualLabelPage() {
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{formData.items.map((item, idx) => (
<tr key={idx} className="group hover:bg-slate-900/30">
{formData.items.map((item, idx) => {
const aiItem = aiPredicted?.items?.[idx];
return (
<tr key={idx} className="group hover:bg-slate-900/30 align-top">
<td className="py-2 px-1">
<input
type="text"
@@ -422,6 +534,7 @@ export default function ManualLabelPage() {
placeholder="SKU Code"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.kodeBarang} aiValue={aiItem?.kodeBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -431,6 +544,7 @@ export default function ManualLabelPage() {
placeholder="Description"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.namaBarang} aiValue={aiItem?.namaBarang} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -440,6 +554,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 2 KRG"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.banyak} aiValue={aiItem?.banyak} /></div>
</td>
<td className="py-2 px-1">
<input
@@ -449,6 +564,7 @@ export default function ManualLabelPage() {
placeholder="e.g. 40 PC"
className="w-full bg-transparent border-0 text-sm text-slate-100 placeholder-slate-700 focus:outline-none focus:bg-slate-900/70 p-1.5 rounded"
/>
<div className="px-1.5"><AiNote current={item.jumlah} aiValue={aiItem?.jumlah} /></div>
</td>
<td className="py-2 px-1 text-center">
<button
@@ -459,7 +575,8 @@ export default function ManualLabelPage() {
</button>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
+13 -4
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { getErrorMessage } from "@/utils/client-error";
interface HistoryItem {
id: number;
@@ -32,6 +33,9 @@ export default function Home() {
if (res.ok) {
const data = await res.json();
setHistory(data.history || []);
} else {
const data = await res.json().catch(() => undefined);
console.error("Failed to fetch history:", getErrorMessage(null, data, `HTTP ${res.status}`));
}
} catch (err) {
console.error("Failed to fetch history:", err);
@@ -57,7 +61,8 @@ export default function Home() {
const detailsData = await detailsRes.json();
setSelectedDetails(detailsData);
} else {
throw new Error("Failed to load document details");
const data = await detailsRes.json().catch(() => undefined);
throw new Error(getErrorMessage(null, data, "Failed to load document details"));
}
// 2. Fetch processing logs
@@ -68,7 +73,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Error loading document:", err);
setErrorMsg(err.message || "Error loading document details");
setErrorMsg(getErrorMessage(err));
}
};
@@ -114,7 +119,7 @@ export default function Home() {
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to process image");
throw new Error(getErrorMessage(null, data, "Failed to process image"));
}
const data = await res.json();
@@ -137,7 +142,7 @@ export default function Home() {
}
} catch (err: any) {
console.error("Upload failed:", err);
setErrorMsg(err.message || "Failed to upload and process file.");
setErrorMsg(getErrorMessage(err, undefined, "Failed to upload and process file."));
} finally {
setUploading(false);
}
@@ -165,9 +170,13 @@ export default function Home() {
setSelectedLogs(null);
}
fetchHistory();
} else {
const data = await res.json().catch(() => undefined);
setErrorMsg(getErrorMessage(null, data, "Failed to delete document"));
}
} catch (err) {
console.error("Failed to delete document:", err);
setErrorMsg(getErrorMessage(err, undefined, "Failed to delete document"));
}
};
File diff suppressed because it is too large. Load diff
+20 -1
View File
@@ -1,4 +1,4 @@
import { Pool } from "pg";
import { Pool, PoolClient } from "pg";
import { initDb } from "./init";
const pool = new Pool({
@@ -35,6 +35,25 @@ export async function query(text: string, params?: unknown[]) {
return p.query(text, params);
}
/** Runs `fn` inside a BEGIN/COMMIT transaction on a single held connection, rolling back and rethrowing on any failure. */
export async function withTransaction<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const p = await getPool();
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
export async function cleanupAndReindexItems(docId: number) {
// 1. Delete rows where kode_barang is blank/null or doesn't match an 8-digit number
await query(
+36
View File
@@ -1,6 +1,7 @@
import { Pool } from "pg";
import fs from "fs";
import path from "path";
import bcrypt from "bcryptjs";
import { parseDOMetadata } from "../utils/parser";
const UPLOADS_DIR = "/uploads";
@@ -103,6 +104,41 @@ export async function initDb(pool: Pool) {
);
`);
// Create accounts table - each account is assigned one store (kode_toko),
// so store name/address never need to be OCR-detected: whichever account
// uploads a document, that account's assigned store is used directly.
await pool.query(`
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
kode_toko VARCHAR(255) REFERENCES store_master(kode_toko),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
`);
// Seed the demo account, assigned to the head office store. Hashed here
// (not as a SQL literal) since bcrypt can't run inside plain SQL.
await pool.query(
`INSERT INTO accounts (username, password, kode_toko)
VALUES ('admin', $1, 'WH_JOFFICE')
ON CONFLICT (username) DO NOTHING;`,
[bcrypt.hashSync("password", 10)]
);
// Migrate any plaintext passwords (pre-existing accounts from before hashing
// was added) to bcrypt hashes. Idempotent: bcrypt hashes always start with
// "$2", so already-hashed rows are skipped on every re-run.
const plaintextAccounts = await pool.query(
"SELECT id, password FROM accounts WHERE password NOT LIKE '$2%'"
);
for (const account of plaintextAccounts.rows) {
await pool.query("UPDATE accounts SET password = $1 WHERE id = $2", [
bcrypt.hashSync(account.password, 10),
account.id
]);
}
// Create arena_runs table
await pool.query(`
CREATE TABLE IF NOT EXISTS arena_runs (
@@ -0,0 +1,47 @@
// Framework-agnostic HTTP status/error helpers shared by server routes and client components.
export const HTTP_STATUS_TEXT: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
409: "Conflict",
413: "Payload Too Large",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
export function reasonPhraseForStatus(status: number): string {
return HTTP_STATUS_TEXT[status] ?? "Error";
}
export function codeForStatus(status: number): string {
const phrase = HTTP_STATUS_TEXT[status];
if (!phrase) return `HTTP_${status}`;
return phrase.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
}
export interface ApiErrorBody {
status: "error";
error: {
statusCode: number;
code: string;
message: string;
};
}
export function buildApiErrorBody(status: number, message: string, code?: string): ApiErrorBody {
return {
status: "error",
error: {
statusCode: status,
code: code ?? codeForStatus(status),
message,
},
};
}
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { buildApiErrorBody } from "@/lib/http-status";
export function errorResponse(
status: number,
message: string,
opts?: { code?: string; headers?: HeadersInit }
): NextResponse {
return NextResponse.json(buildApiErrorBody(status, message, opts?.code), {
status,
headers: opts?.headers,
});
}
+30
View File
@@ -0,0 +1,30 @@
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "dev-only-insecure-secret-change-me";
export interface AccountTokenPayload {
accountId: number;
username: string;
kodeToko: string | null;
}
export function signAccountToken(payload: AccountTokenPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "30d" });
}
/** Returns the decoded payload, or null if the token is missing/invalid/expired. */
export function verifyAccountToken(token: string | null | undefined): AccountTokenPayload | null {
if (!token) return null;
try {
return jwt.verify(token, JWT_SECRET) as AccountTokenPayload;
} catch {
return null;
}
}
/** Extracts and verifies the Bearer token from a request's Authorization header. */
export function getAccountFromAuthHeader(authHeader: string | null): AccountTokenPayload | null {
if (!authHeader?.startsWith("Bearer ")) return null;
const token = authHeader.slice("Bearer ".length).trim();
return verifyAccountToken(token);
}
@@ -0,0 +1,20 @@
import { HTTP_STATUS_TEXT } from "@/lib/http-status";
/**
* Formats an error the same way across every client component: given the
* parsed JSON body of a failed fetch (if any) and/or the caught exception,
* produce a single "404 Not Found: message" style string mirroring the
* backend's { status: "error", error: { statusCode, code, message } } envelope.
*/
export function getErrorMessage(err: unknown, data?: unknown, fallback = "Unexpected error"): string {
const errorBody = data && typeof data === "object" ? (data as Record<string, unknown>).error : undefined;
if (errorBody && typeof errorBody === "object" && typeof (errorBody as Record<string, unknown>).message === "string") {
const body = errorBody as Record<string, unknown>;
const statusCode = body.statusCode;
const label = (typeof statusCode === "number" && HTTP_STATUS_TEXT[statusCode]) || body.code;
return typeof statusCode === "number" ? `${statusCode} ${label}: ${body.message}` : (body.message as string);
}
if (err instanceof Error) return err.message;
if (err != null) return String(err);
return fallback;
}
+20 -41
View File
@@ -161,7 +161,8 @@ function cleanDateValue(raw: string): string {
export function parseDOMetadata(markdown: string) {
const metadata = {
vendorInfo: "Not Found",
customerInfo: "Not Found",
// Always PT. PRIMAFOOD INTERNATIONAL for this customer - never parsed from OCR.
customerInfo: "PT. PRIMAFOOD INTERNATIONAL",
tanggal: "Not Found",
noSO: "Not Found",
noDO: "Not Found",
@@ -198,33 +199,6 @@ export function parseDOMetadata(markdown: string) {
if (vendorMatch) metadata.vendorInfo = vendorMatch[1].trim();
}
// Extract Customer Info (e.g., Kepada Yth : PT.PRIMAFOOD INTERNATIONAL)
const customerStop = /(?:no\.?\s*(?:so|do|po)|tanggal|date|#|\d{2}:\d{2}:\d{2})/i;
let customerStartIndex = lines.findIndex(line =>
/(?:Kepada Yth|Yth|Customer|Deliver To)\s*[:\-]/i.test(line) || /PT\.\s*PRIMAFOOD/i.test(line)
);
if (customerStartIndex === -1) {
// Fallback: search for a secondary PT. line
const ptIndices = lines.map((l, idx) => l.toUpperCase().includes("PT.") ? idx : -1).filter(idx => idx !== -1);
// Ensure the secondary PT line is not the vendor line
const secondaryIndices = ptIndices.filter(idx => idx !== vendorStartIndex);
if (secondaryIndices.length > 0) {
customerStartIndex = secondaryIndices[0];
}
}
if (customerStartIndex !== -1) {
const customerLines: string[] = [lines[customerStartIndex]];
for (let i = customerStartIndex + 1; i < Math.min(lines.length, customerStartIndex + 4); i++) {
if (customerStop.test(lines[i])) break;
customerLines.push(lines[i]);
}
metadata.customerInfo = customerLines.join("\n");
} else {
const customerMatch = cleanMarkdown.match(/(?:Kepada Yth|Yth|Customer|Deliver To)[ \t]*[:\-][ \t]*([^\n]+)/i) || cleanMarkdown.match(/(PT\.[ \t]*PRIMAFOOD[^\n]*)/i);
if (customerMatch) metadata.customerInfo = customerMatch[1].trim();
}
// Extract Tanggal (Date)
const tanggalMatch = cleanMarkdown.match(/Tanggal\s*[:\-.]?\s*([^\n]{6,100})/i) ||
cleanMarkdown.match(/(?:Date|D\.O\.\s*Date)\s*[:\-.]?\s*([^\n]{6,100})/i);
@@ -404,11 +378,20 @@ export function parseDOMetadata(markdown: string) {
}
}
if (globalTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = globalTenDigits[1];
} else if (globalTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = globalTenDigits[0];
// Exclude numbers already assigned to another field, so a still-missing SO
// can't silently be backfilled with the DO/PO value that was just extracted
// correctly (e.g. SO garbled/missing but DO is a clean 16xxxxxxxx number —
// without this filter, DO's value gets duplicated into SO).
const alreadyAssigned = [metadata.noSO, metadata.noDO, metadata.noPO].filter(
(v) => v && v !== "Not Found"
);
const freeTenDigits = globalTenDigits.filter((v) => !alreadyAssigned.includes(v));
if (freeTenDigits.length >= 2) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
if (metadata.noDO === "Not Found" || !metadata.noDO) metadata.noDO = freeTenDigits[1];
} else if (freeTenDigits.length === 1) {
if (metadata.noSO === "Not Found" || !metadata.noSO) metadata.noSO = freeTenDigits[0];
}
// 3. Scan for PO number globally
@@ -437,7 +420,6 @@ export function parseDOMetadata(markdown: string) {
// Clean final values
metadata.vendorInfo = cleanFinalValue(metadata.vendorInfo, true);
metadata.customerInfo = cleanFinalValue(metadata.customerInfo, true);
metadata.tanggal = cleanDateValue(cleanFinalValue(metadata.tanggal));
metadata.noSO = cleanFinalValue(metadata.noSO);
metadata.noDO = cleanFinalValue(metadata.noDO);
@@ -729,9 +711,6 @@ export function parseDOMetadata(markdown: string) {
}
}
// Force customerInfo to always be PT.PRIMAFOOD INTERNATIONAL as requested
metadata.customerInfo = "PT.PRIMAFOOD INTERNATIONAL";
// Extract truck license plate
(metadata as any).platTruk = extractPlatTruk(cleanMarkdown);
@@ -847,12 +826,12 @@ export function sanitizeParsedMetadata(meta: ReturnType<typeof parseDOMetadata>
const currentFullYear = new Date().getFullYear();
const result = { ...meta };
// --- customerInfo / vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.PRIMAFOOD"
// instead of "PT. PRIMAFOOD"). Normalize the standard prefixes to always have one space.
// --- vendorInfo ---
// OCR typically drops the space after Indonesian business-entity prefixes ("PT.CHAROEN"
// instead of "PT. CHAROEN"). Normalize the standard prefixes to always have one space.
// (customerInfo needs no such cleanup - it's a fixed constant, never parsed from OCR.)
const normalizeEntityPrefix = (v: string) =>
v && v !== "Not Found" ? v.replace(/\b(PT|CV|UD|PD|TB)\.(?=\S)/gi, (_, p) => `${p.toUpperCase()}. `) : v;
result.customerInfo = normalizeEntityPrefix(result.customerInfo);
result.vendorInfo = normalizeEntityPrefix(result.vendorInfo);
// --- tanggal ---
+255
View File
@@ -0,0 +1,255 @@
# Next Enhancements (backend)
Working backlog driven by the `e`/`enhance` and `n`/`next` triggers defined in
[AGENTS.md](../AGENTS.md) Part B. Split out 2026-07-08 from root
`plans/next-enhancements.md` sections 5-8, renumbered 1-4 — this file now owns all
backend enhancement tracking going forward; the root file only tracks Flutter
sections from this point on. Statuses below were re-verified against the live code
at split time (not copied blind):
- **1.1** `file_hash` dedup — confirmed present, `v1/documents/upload/route.ts:59,98`.
- **1.2** `AbortSignal.timeout` on both hops — confirmed present in `parse/route.ts`
and `v1/documents/upload/route.ts`.
- **1.3** classic routes skipping auth — confirmed still true at split time; later cancelled as out-of-scope (dev-only web UI, no production benefit) — see §1 below, 1.4 shipped instead.
- **3.1** `withTransaction` — confirmed present and used in `db/index.ts`,
`api/parse/route.ts`, `api/v1/documents/[id]/route.ts`.
- **2.1** `pfm-web-app/public/produk-pfm/models/` — corrected 2026-07-08 (was
checked against the wrong path, `backend/models`, in an earlier pass this same
day): the directory does exist. Later the same day, task 2.1 itself was completed
— see §2 below — so the artifacts now exist too.
- **2.2** `m-scan-pfm/page.tsx` — confirmed still missing; desktop `scan-pfm/page.tsx`
confirmed present, **and now confirmed full feature parity** (confidence bar,
top-5 candidate list, expiry-date crop preview all present, 1169 lines) — see §2
below, migrated from `next-implementation.md` (deleted 2026-07-08, content now
lives here).
- **3.2** password hashing — confirmed plaintext at split time (2026-07-08 morning); fixed later the same day, see §3 below.
- **3.3** unique index on `documents.file_hash` — confirmed still absent (only
`filename` has a UNIQUE constraint in `db/init.ts`).
- **4.2** healthcheck for `pipeline-api`/vLLM — confirmed no `healthcheck` block in
root `docker-compose.yml`.
## Format
Tasks are grouped under a numbered section per backend module. Each section gets
exactly 3 tasks:
```
## 1. <Section / Module Name>
- **1.1** [TODO] <clear, specific description of the functional change>
- **1.2** [TODO] <...>
- **1.3** [TODO] <...>
```
When a task is picked up via `n`/`next`, its clarified acceptance criteria (from
`AGENTS.md` Part B §B2a) are appended directly under it. When complete, the status
flips to `[DONE]` and the feature is logged in
[docs/feature-list.md](../docs/feature-list.md) (this dir).
---
## 1. Backend — Next.js API Gateway
`pfm-web-app/src/app/api/`
- **1.1** [DONE] ~~Make `documents/upload/route.ts` return `201` immediately...~~ Investigated 2026-07-08: the `file_hash` dedup check is already shipped pre-existing in `v1/documents/upload/route.ts` (lines 58-89) — a duplicate upload returns the existing document instead of re-inserting/re-parsing. The "return 201 immediately, don't await OCR" half turned out to be a deliberate, already-documented tradeoff (see the route's own comment): the pipeline call stays synchronous but is now bounded by `AbortSignal.timeout(210_000)`. Not changed further this round since it's an intentional decision, not an oversight.
- **1.2** [DONE] Investigated 2026-07-08: already shipped pre-existing. Both hops of the upload → `/api/parse` → pipeline-API chain already have `AbortSignal.timeout` (210s and `PIPELINE_TIMEOUT_MS`=90s respectively), with comments cross-referencing each other.
- **1.3** [CANCELLED 2026-07-08] ~~Wire the existing JWT auth onto the classic API routes (`/api/upload`, `/api/scan-pfm`, `/api/parse`, `/api/history`, etc.)~~ — investigated after 3.2 unblocked it, found the premise was stale: those "classic" routes are used exclusively by the dev-only web UI (root DO-PFM page, `scan-pfm`, `manual-label`), which has no login screen and never sends a token — the user confirmed this web UI won't exist in production, so enforcing auth there would break it today for zero production benefit, and optional identification there is a no-op (nobody ever sends a token). Same reasoning as 2.2's cancellation. See **1.4** below for what was actually shipped instead.
- **1.4** [DONE 2026-07-08] Enforced real 401 auth on `/api/v1/documents/*` instead — this is the actual production API surface, already fully supported by the Flutter client (`lib/features/auth/auth_provider.dart` does a real login and stores the JWT; `lib/core/network/api_client.dart`'s interceptor already attaches `Authorization: Bearer <token>` to every request — root `CLAUDE.md`'s "demo-stub, no real server-side auth" claim for Flutter was itself stale). Before this, `v1/documents/route.ts` (list) and `v1/documents/[id]/route.ts` (PUT) didn't check auth at all, and `v1/documents/upload/route.ts` only optionally read the token (never rejected a missing one). Added `getAccountFromAuthHeader()` (`utils/auth.ts`, pre-existing helper) + a `401` guard to all three; `OPTIONS` (CORS preflight) on all three left untouched. Did **not** add per-account data filtering to the document list (still returns all non-sample parsed documents regardless of uploader — that's a feature change, not an auth fix; flagged as a possible future task). Did **not** add Flutter-side 401→auto-logout handling (`api_client.dart` has no interceptor for it; a 401 surfaces as a normal `ApiException` today) — a Flutter-side follow-up, not a backend task.
- Verified end-to-end via `curl`: all three endpoints return 401 with no token; after `POST /api/v1/auth/login` with `admin`/`password` to get a real token, the same three endpoints succeed with `Authorization: Bearer <token>` (`GET` 200 with real data, `PUT` reaches its normal 404-for-bad-id logic, `POST upload` reaches its normal downstream logic) — confirming the auth check itself works without touching any other behavior. `OPTIONS` on all three still returns 204 unauthenticated.
## 2. Backend — OCR Pipeline & Accuracy
`config/`, `pfm-web-app/src/utils/parser.ts`, accuracy regression harness (see `CLAUDE.md`)
**Product/SKU scan sub-feature — decisions & state** (migrated 2026-07-08 from
`next-implementation.md`, which is now deleted; this section is the sole source of
truth for it going forward):
- **Decisions made**: new pages are **standalone routes** (`scan-pfm` — done), following the same self-contained pattern as `manual-label/page.tsx` — own
header/theme, no shared chrome with the root DO-PFM page. Build to **full feature
parity** with the old `ai-ocr-pfm-2026` pages (confidence bars, top-5 SKU
candidates, visual OCR overlay, expiry-date crop preview), not a lean MVP.
- **Scope correction 2026-07-08 (later)**: `m-scan-pfm/page.tsx` (mobile web page)
is **not needed** — user clarified the web `scan-pfm` page is desktop-only,
used for testing the pipeline, not a production mobile surface. Real mobile
scanning is already handled by the Flutter app instead. Focus for this
sub-feature going forward is **backend services** (model artifacts, pipeline
correctness), not any additional frontend. See 2.2 below — cancelled, not
reassigned.
- **Already shipped, re-verified 2026-07-08** (all confirmed present, not assumed):
`config/classify_ocr_server.py` (DINOv2 + YOLO classify, SKU/expiry/name OCR),
`pfm-web-app/public/produk-pfm/index_dinov2.py` + `train_classifier.py`,
`api/scan-pfm/route.ts` (Levenshtein match against `sku_master`),
`api/produk-pfm/route.ts`, DB schema (`sku_master`/`store_master`/`arena_runs` in
`db/init.ts`), `scripts/serve-pipeline.sh` wiring, `Dockerfile`'s `ultralytics`
install in the pipeline-api stage, `nginx.conf` routes for `/scan-pfm`,
`/m-scan-pfm`, `/produk-pfm`.
- **Stale claims corrected 2026-07-08** (next-implementation.md said these didn't
exist; they now do): `pfm-web-app/public/produk-pfm/foto-kemasan-v2/` dataset —
16 SKU subfolders now present (was "❌ does not exist"); `scan-pfm/page.tsx` — now
exists at full feature parity, 1169 lines (was "❌ does not exist").
- **Newly observed, not in original doc's scope**: `/produk-pfm` has an nginx proxy
block and an API route (`api/produk-pfm/route.ts`) but no matching frontend page
(`src/app/produk-pfm/page.tsx` doesn't exist) — dead route, same class of issue as
scan-pfm/m-scan-pfm were. Not turned into a task below since it wasn't part of the
original decision record; flagging for a future `e`/`enhance` pass to pick up.
- **2.1** [DONE 2026-07-08] Built the initial model artifacts. Bare-metal
(`./scripts/install-pipeline.sh`) doesn't work in this Windows dev environment —
`paddlepaddle-gpu`'s wheel index is Linux-only — so this ran entirely through
Docker instead:
1. Added `ultralytics>=8.0` to `scripts/install-pipeline.sh` (was missing there
even though the `Dockerfile` pipeline-api stage already installed it — fixed
for bare-metal parity, though bare-metal training itself still isn't viable on
Windows).
2. `docker compose build pipeline-api` from repo root — cached layers reused, only
the final `COPY . /app` re-ran, picking up the (until-then-unbaked) 16-folder
dataset.
3. Ran a one-off `docker run --gpus all` from the fresh image with `models/`
mounted **writable** (not the live service's `:ro` mount) — the live
`pipeline-api` container's copy was stale (image predated the dataset) and
read-only either way, so training couldn't happen through it directly.
`index_dinov2.py` → indexed 118/118 images across all 16 classes →
`models/dinov2_index.pkl` (196KB). `train_classifier.py train --imgsz 224` → 100
epochs (~1.5 min), **83.3% top-1 / 90% top-5 validation accuracy** (thin dataset,
2-16 images/class — accuracy will improve as more reference photos are added) →
`models/produk-pfm-classifier-26n-100e-2026-07-08.pt` (3.2MB) + `.onnx` export
(5.9MB).
4. `docker compose restart pipeline-api` (bind-mounted `models/` is live, no
rebuild needed for the running service) — confirmed via `docker logs`: "DINOv2
model loaded successfully" and "YOLO model loaded successfully... Using
classifier weights: produk-pfm-classifier-26n-100e-2026-07-08.pt".
- Note on Git Bash + Docker on Windows: `docker run ... bash -c "/app/..."` fails
with exit 127 ("No such file or directory") because MSYS path-conversion mangles
the leading `/app/...` argument into a Windows path — fix is
`MSYS_NO_PATHCONV=1` before the `docker run`/`docker exec` invocation.
**Full browser verification, 2026-07-08 (later same day)** — ran `/scan-pfm` live
(`http://localhost:3000/scan-pfm`, plus confirmed `http://localhost:8000/scan-pfm`
through nginx) via Claude-in-Chrome, selecting the 16-image "FIESTA STIKIE"
sample and clicking through the full flow, not just checking container logs:
- AI Classification: 100.00% confidence, correct class, "Model Active" —
confirms the freshly-trained YOLO weights are actually being used, not a
fallback.
- "View Top 5 Predicted Probabilities" — expands correctly with 5 ranked
candidates + "Use" override buttons.
- AI OCR Text Extraction — expiry date `26/02/2027` extracted correctly with a
label-crop preview image.
- "Possible Match from SKU Master" — Levenshtein-ranked list, correct best match
at 71% similarity.
- Visual Grid tab, Spotting Grid tab, Raw Response (JSON) tab — all render
correctly with real data.
- **Bug found and fixed**: "Save Ground Truth" returned HTTP 200 and looked
successful in the UI, but `api/manual-label-scan/route.ts` writes to
`path.join(process.cwd(), "..", "sources", "product_manual_labels.json")` —
inside the `pfm-web-app` container this resolves to `/sources/...`, which
was **not a bind-mounted path** in root `docker-compose.yml` (only `/app`,
`/uploads`, and the Docker socket were mounted for that service). Every save
was landing in the container's ephemeral writable layer, invisible to the host,
to git, and to any other tooling — confirmed by finding an orphaned entry from
2026-07-07 (an earlier, unrelated test) sitting in the container with no trace
on disk. **Fix**: added `./backend/sources:/sources` to the `pfm-web-app`
service's volumes in root `docker-compose.yml`, recovered the orphaned entry
via `docker cp` before recreating the container, then `docker compose up -d
pfm-web-app` (recreated both `pfm-web-app` and, as its dependency,
`pipeline-api` — re-verified both models reloaded correctly after). Re-ran the
full scan + save and confirmed the entry now lands directly in
`backend/sources/product_manual_labels.json` on the host.
- **Confirmed (2026-07-08, follow-up check) the same bug pattern in
`api/manual-label/route.ts`** (DO-flow ground-truth save,
`sources/manual_labels.json`, identical `process.cwd()`-relative path) is now
also fixed by the same volume mount — verified `md5sum` matches exactly between
the container's `/sources/manual_labels.json` and the host's
`backend/sources/manual_labels.json`, with a fresh host mtime. No separate fix
needed; the one `docker-compose.yml` change covers both routes.
- Acceptance met: `models/dinov2_index.pkl` exists, "Indexed 118/118 images" log
confirmed; both models load cleanly on `pipeline-api` restart per container logs.
End-to-end `/scan-pfm` UI test (upload a real photo, confirm live top-5 result)
— done, see the full browser verification note above (2026-07-08, later still).
- **2.2** [CANCELLED 2026-07-08] ~~Port `m-scan-pfm/page.tsx` (mobile) from `ai-ocr-pfm-2026` into v2~~ — user decided this isn't needed: the web `scan-pfm` page is desktop-only (used to test the pipeline), and real mobile product scanning is handled by the Flutter app, not a web mobile page. The `/m-scan-pfm` nginx block stays dead intentionally — do not resurrect this task without an explicit ask. `docs/feature-list.md` will not get an entry for it.
- **2.3** [DONE 2026-07-08] Ran the accuracy regression harness. **Key finding:
`sources/accuracy_report.md` was badly stale** (said 75.04% overall) — the real
current baseline (confirmed via a fresh harness run at commit `3df9f6e`, matching
`sources/accuracy_history.jsonl`'s latest entry exactly) is **95.10% overall,
already at/above the 95% target**. Added a staleness banner to
`accuracy_report.md` pointing here instead of leaving it to mislead future work.
- **Investigated the worst field, `plat` (67.6%, unchanged between L1/L3 — no
sanitization ever touches it)** by pulling raw OCR text for every mismatching
image from `documents.layout_parsing_result` in Postgres. Of 13 mismatches: 11
are the plate region getting classified by the layout model as an `image_box`/
`seal_box` (never becomes text at all — e.g. `Truck No.` cell literally contains
an `<img>` tag, not digits) or the suffix OCR'd as digits instead of letters
(`"B 9536 000"` vs ground truth `"B 9536 UCO"` — `0` for `O`/`C`). **Not
fixable in `parser.ts`** — there's no text to parse in most cases; the
remaining 1-2 are single-character OCR misreads (`"VOC"` vs `"VCC"`) too
fragile to correct generically without risking false positives elsewhere.
- Cross-checked all other mismatching fields (`noSO`/`noPO`/`noDO`/`tanggal`,
12 mismatches total via `--dump-json`) the same way. Same story: mostly
garbled/missing OCR text (image regions, blank captures) or single-digit OCR
noise (`191848878` vs `1691848878` — one digit dropped;
`PO/26/0000145236` vs `...143236` — one digit substituted) — not reliably
parser-fixable without risking corrupting currently-correct extractions.
- **`do-008.jpg` looks like a ground-truth labeling error, not a parser or OCR
bug** — its `noPO`/`noSO`/`noDO` are all cleanly, unambiguously OCR'd (no
visual noise) but are completely different digit sequences from
`manual_labels.json`'s values, not a plausible misread of them. Flagged here
for human review rather than "fixed" by bending the parser to match a
ground-truth value that's plausibly just wrong for this image.
- **Found and fixed one real parser logic bug** (not an OCR-quality issue): in
`do-026.jpg`, the SO field OCR'd as garbage (`"No. SO : 06 No. 2026"`, date text
bleeding into the SO cell) so extraction correctly fell through to
`"Not Found"` — but the *global* fallback (`parser.ts`, "Global pattern
scanning fallback", `\b16\d{8}\b` scan) then backfilled it with the only
`16xxxxxxxx`-shaped number in the whole document, which was actually the
*already-correctly-extracted* `noDO` value — silently duplicating a DO number
into the SO field. Fixed by excluding values already assigned to
`noSO`/`noDO`/`noPO` from that global backfill pool. Confirmed via a direct
`parseDOMetadata()` unit call and re-running the harness:
`noSO` now correctly shows `"Not Found"` instead of the fabricated duplicate.
**This doesn't move the overall percentage** (a wrong value and "Not Found"
are both mismatches against ground truth) — it's a correctness fix, not an
accuracy-score fix: previously a plausible-looking *wrong* number could reach
the database unreviewed; now it's honestly flagged as missing instead.
- All 48 `parser.test.ts` unit tests still pass; full harness re-run shows no
regressions (`plat`/`noSO`/`noPO`/`noDO`/`tanggal`/etc. all unchanged except
the corrected `do-026.jpg` `noSO` value described above).
- **Conclusion**: the 95% target is already met at the aggregate level, and the
remaining gap is now predominantly an OCR/layout-model accuracy ceiling (garbled
text, misclassified stamp/seal regions), not a `parser.ts` logic gap — further
`parser.ts` tuning on the current 37-image set has low remaining ROI. Future
accuracy work should target the OCR/layout pipeline itself (`config/`,
PaddleOCR-VL prompt/model tuning) or expanding the test set to catch different
failure modes, not more regex tweaks.
*Not in scope for either 2.1/2.2 (per next-implementation.md's own note, still true
2026-07-08): batch/lot number extraction doesn't exist in `classify_ocr_server.py`
in either project (only SKU, product name, expiry date are extracted) — if
requested later, follow the same OCR-regex-cascade pattern already used for
expiry-date extraction.*
## 3. Backend — Postgres Data Layer
`pfm-web-app/src/db/`
- **3.1** [DONE] Wrapped the `ocr_items` delete-then-reinsert logic in both `/api/parse` and the `/api/v1/documents/[id]` PUT route inside a single DB transaction (`withTransaction` helper in `db/index.ts`), so a mid-loop failure can no longer leave a document with a correct header but silently missing items — it now rolls back to the previous item set instead. Completed 2026-07-08.
- **3.2** [DONE 2026-07-08] Hashed `accounts.password` with `bcryptjs` (not native `bcrypt` — the `pfm-web-app` Docker stage is `node:20-slim` with no build toolchain, so a native addon would break the image build). `db/init.ts` now hashes the seed password and idempotently migrates any existing plaintext rows (`WHERE password NOT LIKE '$2%'`) on every startup — safe to re-run, no double-hashing. `api/v1/auth/login/route.ts` now fetches by username only and compares with `bcrypt.compareSync`, plus a small added guard for missing username/password (previously would have fallen through to a DB query with `undefined`; now a clean 401). Installed via `docker exec paddleocr-pfm-web-app npm install bcryptjs` (container has its own anonymous `node_modules` volume, not host-synced) + `docker compose restart pfm-web-app`. Verified: `SELECT password FROM accounts` shows a `$2b$10$...` hash, `curl` login with the correct password succeeds (200 + token), wrong password and missing password both return a clean 401 (not a 500). This unblocks task 1.3.
- **3.3** [TODO] Add a unique index on `documents.file_hash` so the dedup check from task 1.1 can do an efficient existing-document lookup before insert instead of a full scan.
## 4. DevOps — Docker & Dev Tunnel
`../docker-compose.yml`, `../docker-compose.demo.yml`, `../start-dev-tunnel.ps1`
- **4.1** [TODO] Decide and document (in root `README.md`/`CLAUDE.md`) a deliberate policy for when the PoC/demo runs `docker-compose.demo.yml` (production build) vs. the dev-mode default — `docker-compose.yml` alone runs `npm run dev`, a known throughput ceiling already flagged in the reliability audit.
- **4.2** [TODO] Add a startup healthcheck/readiness gate for `pipeline-api`/vLLM in `docker-compose.yml` so the Next.js gateway doesn't accept uploads before the GPU pipeline is actually ready to serve them.
- **4.3** [TODO] Extend `start-dev-tunnel.ps1` to verify the ngrok tunnel/LAN IP is actually reachable (not just started) before reporting success, since a stale tunnel is the documented first failure point for login/upload from the Flutter app.
---
*Sections 1-4 migrated 2026-07-08 from root `plans/next-enhancements.md` sections
5-8 (originally populated the same day via a backend-scoped `e`/`enhance` run,
grounded in a prior end-to-end reliability audit and, for §2's Product/SKU scan
sub-feature, `next-implementation.md`'s status table). This file is the sole home
for backend enhancement tasks — the root copy's sections 5-8 were removed outright
from `plans/next-enhancements.md` (not just frozen) per root `AGENTS.md`'s "Scope:
excludes `backend/`" note; the already-shipped 3.1/7.1 DB-transaction fix stays
recorded in root `docs/feature-list.md` regardless, since that's a shipped-feature
log, not a backlog.*
*`next-implementation.md` itself was deleted 2026-07-08 once its full content
(decisions, verified state, action steps, verification checklist) was folded into
§2 above with corrected statuses — it's no longer a separate source of truth.*
+3
View File
@@ -24,4 +24,7 @@ echo "==> Installing PaddleX serving dependencies"
uv pip install --python "${VENV}" \
"aiohttp>=3.9" "filetype>=1.2" "fastapi>=0.110" "starlette>=0.36" "uvicorn>=0.16"
echo "==> Installing ultralytics (YOLO classifier fallback, product/SKU scan)"
uv pip install --python "${VENV}" "ultralytics>=8.0"
echo "==> Done. Start pipeline API with: ./scripts/serve-pipeline.sh"
+38
View File
@@ -5,3 +5,41 @@
{"timestamp":"2026-07-04T07:29:39.005Z","commit":"095dd4c","refreshOcr":true,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.19367588932806,"layer2Sanitized":67.19367588932806,"layer3Final":88.83553421368548},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":10.81081081081081,"layer2Sanitized":10.81081081081081,"layer3Final":94.5945945945946},"kodeBarang":{"layer1RawRegex":97.6,"layer2Sanitized":97.6,"layer3Final":97.6},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":93.60000000000001},"banyak":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999},"jumlah":{"layer1RawRegex":76,"layer2Sanitized":76,"layer3Final":93.60000000000001}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":46.15384615384615,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-04T08:29:06.624Z","commit":"095dd4c","refreshOcr":true,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-04T09:58:40.843Z","commit":"095dd4c","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T02:03:00.078Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T03:27:28.122Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":["do-001.jpg","do-002.jpg","do-003.jpg","do-004.jpg","do-005.jpg","do-006.jpg","do-007.jpg","do-008.jpg","do-009.jpg","do-010.jpg","do-011.jpg","do-012.jpg","do-013.jpg","do-014.jpg","do-015.jpg","do-016.jpg","do-017.jpg","do-018.jpg","do-019.jpg","do-020.jpg","do-021.jpg","do-022.jpg","do-023.jpg","do-024.jpg","do-025.jpg","do-026.jpg","do-027.jpg","do-028.jpg","do-029.jpg","do-030.jpg","do-031.jpg","do-032.jpg","do-033.jpg","do-034.jpg","do-035.jpg","do-036.jpg","do-037.jpg"],"overall":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":0},"fields":{},"perImage":{}}
{"timestamp":"2026-07-06T03:29:14.056Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":67.72068511198947,"layer2Sanitized":67.72068511198947,"layer3Final":89.4357743097239},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":62.16216216216216,"layer2Sanitized":62.16216216216216,"layer3Final":62.16216216216216},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":64.86486486486487},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":37.83783783783784},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":97.2972972972973},"kodeBarang":{"layer1RawRegex":98.4,"layer2Sanitized":98.4,"layer3Final":98.4},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":94.39999999999999},"banyak":{"layer1RawRegex":77.60000000000001,"layer2Sanitized":77.60000000000001,"layer3Final":95.19999999999999},"jumlah":{"layer1RawRegex":76.8,"layer2Sanitized":76.8,"layer3Final":94.39999999999999}},"perImage":{"do-001.jpg":100,"do-002.jpg":92.3076923076923,"do-003.jpg":92.3076923076923,"do-004.jpg":84.61538461538461,"do-005.jpg":87.87878787878788,"do-006.jpg":95.1219512195122,"do-007.jpg":92.3076923076923,"do-008.jpg":61.53846153846154,"do-009.jpg":69.23076923076923,"do-010.jpg":84.61538461538461,"do-011.jpg":76.92307692307693,"do-012.jpg":76.92307692307693,"do-013.jpg":100,"do-014.jpg":93.93939393939394,"do-015.jpg":92.3076923076923,"do-016.jpg":94.11764705882352,"do-017.jpg":88.23529411764706,"do-018.jpg":95.23809523809523,"do-019.jpg":95.1219512195122,"do-020.jpg":96,"do-021.jpg":95.1219512195122,"do-022.jpg":95.23809523809523,"do-023.jpg":96.55172413793103,"do-024.jpg":96,"do-025.jpg":86.48648648648648,"do-026.jpg":80,"do-027.jpg":85.36585365853658,"do-028.jpg":92,"do-029.jpg":90.47619047619048,"do-030.jpg":76.47058823529412,"do-031.jpg":88.23529411764706,"do-032.jpg":86.20689655172413,"do-033.jpg":71.42857142857143,"do-034.jpg":69.23076923076923,"do-035.jpg":90.47619047619048,"do-036.jpg":90.47619047619048,"do-037.jpg":92.3076923076923}}
{"timestamp":"2026-07-06T09:26:53.022Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-06T09:31:28.799Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:56:42.124Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:57:32.808Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:57:52.670Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:03.790Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:14.535Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:25.264Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:36.372Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:47.317Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:58:57.981Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:09.065Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:19.615Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:30.262Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:40.752Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T03:59:51.392Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:01.741Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:13.427Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:24.424Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:35.516Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:47.181Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:00:58.931Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:09.373Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:21.125Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:33.405Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:44.798Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:01:56.339Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:07.986Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:19.368Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:02:31.985Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:08:52.590Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:16:13.504Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:17:00.228Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:20:07.251Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
{"timestamp":"2026-07-08T04:20:23.707Z","commit":"3df9f6e","refreshOcr":false,"imageCount":37,"failedImages":[],"overall":{"layer1RawRegex":68.28309305373526,"layer2Sanitized":68.28309305373526,"layer3Final":95.1015531660693},"fields":{"noPO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"noSO":{"layer1RawRegex":86.48648648648648,"layer2Sanitized":86.48648648648648,"layer3Final":86.48648648648648},"noDO":{"layer1RawRegex":91.8918918918919,"layer2Sanitized":91.8918918918919,"layer3Final":91.8918918918919},"tanggal":{"layer1RawRegex":89.1891891891892,"layer2Sanitized":89.1891891891892,"layer3Final":89.1891891891892},"customer":{"layer1RawRegex":100,"layer2Sanitized":100,"layer3Final":100},"plat":{"layer1RawRegex":67.56756756756756,"layer2Sanitized":67.56756756756756,"layer3Final":67.56756756756756},"store":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"alamat":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":100},"itemCount":{"layer1RawRegex":13.513513513513514,"layer2Sanitized":13.513513513513514,"layer3Final":100},"kodeBarang":{"layer1RawRegex":99.20634920634922,"layer2Sanitized":99.20634920634922,"layer3Final":99.20634920634922},"namaBarang":{"layer1RawRegex":0,"layer2Sanitized":0,"layer3Final":95.23809523809523},"banyak":{"layer1RawRegex":78.57142857142857,"layer2Sanitized":78.57142857142857,"layer3Final":97.61904761904762},"jumlah":{"layer1RawRegex":76.98412698412699,"layer2Sanitized":76.98412698412699,"layer3Final":96.82539682539682}},"perImage":{"do-001.jpg":100,"do-002.jpg":100,"do-003.jpg":92.3076923076923,"do-004.jpg":92.3076923076923,"do-005.jpg":90.9090909090909,"do-006.jpg":97.5609756097561,"do-007.jpg":100,"do-008.jpg":69.23076923076923,"do-009.jpg":69.23076923076923,"do-010.jpg":92.3076923076923,"do-011.jpg":84.61538461538461,"do-012.jpg":84.61538461538461,"do-013.jpg":100,"do-014.jpg":96.96969696969697,"do-015.jpg":100,"do-016.jpg":100,"do-017.jpg":94.11764705882352,"do-018.jpg":100,"do-019.jpg":100,"do-020.jpg":100,"do-021.jpg":97.5609756097561,"do-022.jpg":100,"do-023.jpg":100,"do-024.jpg":100,"do-025.jpg":89.1891891891892,"do-026.jpg":88,"do-027.jpg":90.2439024390244,"do-028.jpg":96,"do-029.jpg":100,"do-030.jpg":88.23529411764706,"do-031.jpg":94.11764705882352,"do-032.jpg":93.10344827586206,"do-033.jpg":100,"do-034.jpg":92.3076923076923,"do-035.jpg":100,"do-036.jpg":95.23809523809523,"do-037.jpg":100}}
+90
View File
@@ -1,5 +1,18 @@
# AI OCR Accuracy & Performance Report
> **⚠️ Stale as of 2026-07-08.** The numbers below (75.04% overall) predate several
> parser fixes (table column-shift correction, unit normalization, date
> standardization — see git log) and are significantly out of date. The real
> current baseline, confirmed 2026-07-08 by re-running
> `node pfm-web-app/scripts/accuracy-check.mts` at commit `3df9f6e` (matches
> `sources/accuracy_history.jsonl`'s latest entry exactly): **95.10% overall
> layer3Final** — already at/above the 95% target. Worst fields now: `plat`
> (67.6%, almost entirely OCR/layout-model misses — see
> `backend/plans/next-enhancements.md` §2 task 2.3 for the full investigation),
> `noSO` (86.5%), `tanggal` (89.2%), `noPO`/`noDO` (91.9% each). Don't trust the
> prose/table below without re-running the harness first — this file isn't
> auto-regenerated on every run.
This report summarizes the comparison of **AI OCR Extraction (Layer 3 Final)** against the **Manual Ground Truth Labels** across all **37 test images**.
---
@@ -44,3 +57,80 @@ This report summarizes the comparison of **AI OCR Extraction (Layer 3 Final)** a
- *Recommendation*: Enhance `sanitizeParsedMetadata` in [parser.ts](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/pfm-web-app/src/utils/parser.ts) to parse multiple date format variations into a unified target format.
3. **Plat Nomor (56.76%)**:
- *Why it's low*: License plates on delivery orders are often stamped, handwritten, or placed in odd margins, which makes clean extraction difficult.
---
## 🧪 Parser Unit Test Results (`parser.test.ts`)
**Last run:** 2026-07-06 · **Result: 48 / 48 passed (100%)** ✅
Unit-level regression tests for `parseDOMetadata()` and `sanitizeParsedMetadata()` in [parser.ts](file:///d:/Client/Data%20Bisnis%20Solusi/app-pfm-ocr-v2/backend/pfm-web-app/src/utils/parser.ts). Run anytime with:
```
npx tsx src/utils/parser.test.ts
```
(from `backend/pfm-web-app`). Triggered this run by the **Customer Name post-processing cleanup** — removed the now-dead OCR extraction/regex logic for `customerInfo` (it was always overwritten by a hardcoded `"PT. PRIMAFOOD INTERNATIONAL"` constant anyway) and confirmed nothing else regressed.
| Status | Count |
| :--- | :---: |
| ✅ Passed | **48** |
| ❌ Failed | **0** |
| **Total** | **48** |
### `parseDOMetadata` — 26 tests
| # | Test Case | Result |
| :---: | :--- | :---: |
| 1 | PO standard PO/26/ | ✅ |
| 2 | PO misread P0/26/ on label | ✅ |
| 3 | PO label raw 10-digit, real PO in body | ✅ |
| 4 | PO misread F0/20/ — use current year NOT 20 | ✅ |
| 5 | PO body P0/26/ | ✅ |
| 6 | PO real doc: label raw, body has P0/26/ | ✅ |
| 7 | PO fused F012070000170727 | ✅ |
| 8 | PO fused PO12070000190729 | ✅ |
| 9 | PO noise digits PO120/0000170727 | ✅ |
| 10 | Date trailing noise cut | ✅ |
| 11 | Date no space 25May2020 | ✅ |
| 12 | Date standard 23 June 2026 | ✅ |
| 13 | Date Tanggal blank shifted to No.SO | ✅ |
| 14 | Date prefix timestamp noise | ✅ |
| 15 | Date (Asli/Copy) prefix noise | ✅ |
| 16 | Date junk suffix cut | ✅ |
| 17 | Date bad OCR month Hv → Not Found | ✅ |
| 18 | Date single digit 7 May 2026 → 07 May 2026 | ✅ |
| 19 | Date single digit 4 Apr 2026 → 04 April 2026 | ✅ |
| 20 | 00117709 before Tanggal must not pollute date | ✅ |
| 21 | Plate B 9427 UXT | ✅ |
| 22 | Plate B-9999-XYZ dash | ✅ |
| 23 | Plate ignore PO/SO prefix | ✅ |
| 24 | Plate B9427UXT adjacent | ✅ |
| 25 | Plate real doc B 9723 CXS | ✅ |
| 26 | Table column shift alignment correction | ✅ |
### `sanitizeParsedMetadata` — 22 tests
| # | Test Case | Result |
| :---: | :--- | :---: |
| 27 | valid tanggal 30 June 2026 passes | ✅ |
| 28 | valid tanggal 25 May 2020 passes | ✅ |
| 29 | single digit tanggal 4 April 2026 → 04 April 2026 | ✅ |
| 30 | tanggal bad month Hv → Not Found | ✅ |
| 31 | tanggal as number 0011770 → Not Found | ✅ |
| 32 | tanggal day 0 → Not Found | ✅ |
| 33 | tanggal day 32 → Not Found | ✅ |
| 34 | tanggal year 2009 (too old) → Not Found | ✅ |
| 35 | tanggal Not Found stays Not Found | ✅ |
| 36 | tanggal with noise suffix → Not Found | ✅ |
| 37 | valid noPO PO/26/0000178435 passes | ✅ |
| 38 | noPO wrong year auto-corrected to current | ✅ |
| 39 | noPO raw number → Not Found | ✅ |
| 40 | noPO Not Found stays Not Found | ✅ |
| 41 | valid noSO 1691908676 passes | ✅ |
| 42 | noSO 'abc' → Not Found | ✅ |
| 43 | noSO too short '123' → Not Found | ✅ |
| 44 | valid noDO 1659932080 passes | ✅ |
| 45 | noDO 'XYZXYZ' → Not Found | ✅ |
| 46 | valid platTruk B 9427 UXT passes | ✅ |
| 47 | platTruk XY 1234 ABC invalid prefix → empty | ✅ |
| 48 | platTruk empty stays empty | ✅ |
> Note: no test case directly exercises `customerInfo` (it wasn't covered before this change either) - passing confirms the surrounding logic (PO/date/plate/table parsing) is unaffected by the cleanup.
+1 -1
View File
@@ -46,7 +46,7 @@
| 34 | `Sample DO PFM-page-00020.jpg` | ✅ success | 0° | No | `1659889989` | `PO/26/0000135032` | 1 |
| 35 | `Sample DO PFM-page-00021.jpg` | ✅ success | 0° | No | `1659877015` | `PO/26/0000121086` | 3 |
| 36 | `Sample DO PFM-page-00022.jpg` | ✅ success | 0° | No | `1659896184` | `PO/26/0000140731` | 3 |
| 37 | `Sample DO PFM-page-00023.jpg` | ✅ success | 0.05° | No | `1659922449` | `PO/26/0000168198` | 1 |
| 37 | `Sample DO PFM-page-00023.jpg` | ✅ success | 0.05° | No | `165992/2449` | `PO/26/0000168198` | 1 |
---
File diff suppressed because it is too large. Load diff
+85 -79
View File
@@ -49,8 +49,8 @@
"noDO": "1659932080",
"tanggal": "15 May 2026",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "AYAM HITZ HO ANCOL",
"alamat": "JL. ANCOL BARAT VIII/1 KEL. ANCOL, KEC. PADEMANGAN JAKARTA UTARA, DKI JAKARTA",
"store": "PM TAKTAKAN SERANG",
"alamat": "JL. RAYA TAKTAKAN NO. 1 G, KEL. LONTAR BARU, KEC. SERANG, SERANG",
"plat": "B 9723 CXS",
"items": [
{
@@ -94,7 +94,7 @@
{
"kodeBarang": "21002601",
"namaBarang": null,
"banyak": "1",
"banyak": "1 BOX",
"jumlah": "6 PAC"
}
]
@@ -235,8 +235,8 @@
"noDO": "1659943004",
"tanggal": "25 May 2026",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1 KEL. ANCOL, KEC. PADEMANGAN JAKARTA UTARA, DKI JAKARTA",
"store": "PM MUSTIKA MEDIA",
"alamat": "RUKO GRAHA MUSTIKA MEDIA, JL. KP LUBANG BUAYA, RT.001, RW.003, KEL. LUBANG BUAYA, KEC. SETU, BEKASI 17320",
"plat": "B 9536 UCO",
"items": [
{
@@ -421,9 +421,9 @@
"noSO": "1691879196",
"noDO": "1659905109",
"tanggal": "23 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PANDU RAYA",
"alamat": "JL. PANDU RAYA/ H. ACHMAD ADNAWIJAYA NO. 68, RT. 001, RW. 015, KEL. TEGALGUNDIL, KEC. BOGOR UTARA, BOGOR",
"plat": "B 9948 VCC",
"items": [
{
@@ -440,9 +440,9 @@
"noSO": "1691871022",
"noDO": "1659897809",
"tanggal": "17 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM NEW BINTARO SEKTOR 5",
"alamat": "JL. BINTARO UTAMA RAYA 5 BLOK EA NO 17, RT.001, RW.011, KEL. JURANGMANGGU TIMUR, KEC. PONDOK AREN, KOTA TANGERANG SELATAN 15222",
"plat": "B 9599 CXS",
"items": [
{
@@ -465,9 +465,9 @@
"noSO": "1691870689",
"noDO": "1659897396",
"tanggal": "17 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TAMAN DUTA CIBODAS",
"alamat": "PERUM TAMAN CIBODAS, JL. DUTA RAYA BLOK K1 NO. 20, RT. 007, RW. 006, KEL. SANGIANG JAYA, KEC. PERIUK, TANGERANG",
"plat": "B 9254 SCG",
"items": [
{
@@ -490,9 +490,9 @@
"noSO": "1691865548",
"noDO": "1659892421",
"tanggal": "14 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM SASMITA",
"alamat": "JL. KENAIBAN RT. 002, RW. 002, KEL. PABUARAN, KEC. KARAWACI, TANGERANG",
"plat": "B 9053 VCF",
"items": [
{
@@ -521,10 +521,10 @@
"noSO": "1691859106",
"noDO": "1659886692",
"tanggal": "09 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"plat": "B 9053 VCF",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM SALAM RAYA",
"alamat": "JL.SALAM RAYA, RT.005, RW.003, KEL. SUKABUMI UTARA, KEC. KEBON JERUK, JAKARTA BARAT 11540",
"plat": "B 9260 BXT",
"items": [
{
"kodeBarang": "12010112",
@@ -582,9 +582,9 @@
"noSO": "1691868563",
"noDO": "1659895333",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM REMPOA",
"alamat": "JL. REMPOA RAYA NO. 08, RT. 004, RW. 013, KEL. BINTARO, KEC. PESANGGRAHAN, JAKARTA SELATAN",
"plat": "B 9365 AI",
"items": [
{
@@ -619,9 +619,9 @@
"noSO": "1691869400",
"noDO": "1659896184",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9310 BXT",
"items": [
{
@@ -680,9 +680,9 @@
"noSO": "1691903313",
"noDO": "1659927197",
"tanggal": "11 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM RAWA BUNTU",
"alamat": "JL. RAWA BUNTU UTARA BLOK UA NO. 16, RT. 002, RW. 006, KEL. RAWA BUNTU, KEC. SERPONG, TANGERANG SELATAN",
"plat": "B 9683 VCF",
"items": [
{
@@ -711,9 +711,9 @@
"noSO": "1691895167",
"noDO": "1659919748",
"tanggal": "05 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9042 VCC",
"items": [
{
@@ -754,9 +754,9 @@
"noSO": "1691895765",
"noDO": "1659920402",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PASAR PARUNG BOGOR",
"alamat": "JL. TULANG KUNING - PASAR PARUNG DESA WANO, RT.001, RW.005, KEL. WARU, KEC. PARUNG, BOGOR 16330",
"plat": "B 9260 VCF",
"items": [
{
@@ -791,9 +791,9 @@
"noSO": "1691896671",
"noDO": "1659921089",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KELAPA DUA KARAWACI",
"alamat": "RUKO SENTRA NIAGA KELAPA II / R 9, KEL. KELAPA DUA, KEC. KELAPA DUA, TANGERANG",
"plat": "B 9899 CXR",
"items": [
{
@@ -846,16 +846,16 @@
"noSO": "1691895813",
"noDO": "1659920441",
"tanggal": "06 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM PANORAMA BALI RESIDENCE BGR",
"alamat": "PANORAMA BALI RESIDENCE B NO 16, KEL. PUTAT NUTUG, KEC. CISEENG, KAB. BOGOR 16120",
"plat": "B 9417 VCC",
"items": [
{
"kodeBarang": "11140062",
"namaBarang": "AMPELA 1 KG FROZEN (20 PAC/KARUNG)(*)",
"banyak": "1 KRG",
"jumlah": "2 PAC"
"jumlah": "20 PAC"
},
{
"kodeBarang": "11310014",
@@ -883,9 +883,9 @@
"noSO": "1691898090",
"noDO": "1659922449",
"tanggal": "07 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KARANG BOLONG SERANG",
"alamat": "JL. RAYA KARANG BOLONG BANDULU (KP SEMPUR) NO.22, RT.004, RW.001, KEL. BANDULU, KEC. ANYAR, KAB. SERANG 42166",
"plat": "B 9259 BCU",
"items": [
{
@@ -944,9 +944,9 @@
"noSO": "1691853079",
"noDO": "1659881372",
"tanggal": "06 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM HAJI MUHI",
"alamat": "JL. H. MUHI NO. 14, RT. 001, RW. 004, KEL. PONDOK PINANG, KEC. KEBAYORAN LAMA, JAKARTA SELATAN",
"plat": "B 9260 BXT",
"items": [
{
@@ -981,10 +981,10 @@
"noSO": "1691848409",
"noDO": "1659876922",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"plat": "B 9260 VCF",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TANAH KUSIR",
"alamat": "JL. RC VETERAN NO. 17, RT. 04, RW. 012, KEL. BINTARO, KEC. PESANGGRAHAN, JAKARTA SELATAN",
"plat": "B 9260 BXT",
"items": [
{
"kodeBarang": "11110074",
@@ -1012,9 +1012,9 @@
"noSO": "1691850584",
"noDO": "1659878594",
"tanggal": "03 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI ARE",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KAMPUNG TENGAH",
"alamat": "JL. RAYA TENGAH RT. 004, RW. 009, KEL. TENGAH, KEC. KRAMAT JATI, JAKARTA TIMUR 13540",
"plat": "B 9308 BXT",
"items": [
{
@@ -1037,9 +1037,9 @@
"noSO": "1691848878",
"noDO": "1659877509",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM CIPETE RAYA",
"alamat": "JL. CIPETE RAYA NO. 15 F, RT. 006, RW. 003, KEL. CIPETE SELATAN, KEC. CILANDAK, JAKARTA SELATAN",
"plat": "B 9767 NCD",
"items": [
{
@@ -1062,9 +1062,9 @@
"noSO": "1691872559",
"noDO": "1659898844",
"tanggal": "18 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "KU - DANAU SINGKARAK DEPOK",
"alamat": "JL. DANAU SINGKARAK, RT.001, RW.008, KEL. SUKAMAJU, KEC. CILODONG, DEPOK 16415",
"plat": "B 9255 JXR",
"items": [
{
@@ -1105,9 +1105,9 @@
"noSO": "1691852095",
"noDO": "1659880471",
"tanggal": "04 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KAMPUNG SAWAH 2",
"alamat": "JL. KI HAJAR DEWANTARA KAV. 12 A, RT. 001, RW. 004, KEL. SAWAH, KEC. CIPUTAT, TANGERANG SELATAN 15413",
"plat": "B 9402 SCH",
"items": [
{
@@ -1122,6 +1122,12 @@
"banyak": "1 KRG",
"jumlah": "20 PAC"
},
{
"kodeBarang": "11310024",
"namaBarang": "AYAM SIZE A FROZEN (0.9-1)KG/PC(*)",
"banyak": "2 KRG",
"jumlah": "40 PC"
},
{
"kodeBarang": "11660050",
"namaBarang": "PAHA BAWAH (1 KG)(*)",
@@ -1136,16 +1142,16 @@
"noSO": "1691863154",
"noDO": "1659889989",
"tanggal": "11 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM GRAND CIKARANG CITY 2",
"alamat": "JL. BOULEVARD CINITY BLOK D12R -26, KEL. KARANG RAHARJA, KEC. CIKARANG UTARA, KAB. BEKASI 17534",
"plat": "B 9372 BXT",
"items": [
{
"kodeBarang": "11310013",
"namaBarang": "AYAM SIZE1 FROZEN (0.75-0.8) KG(*)",
"banyak": "1 KRG",
"jumlah": "30 PC"
"banyak": "2 KRG",
"jumlah": "60 PC"
}
]
},
@@ -1155,9 +1161,9 @@
"noSO": "1691848523",
"noDO": "1659877015",
"tanggal": "02 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM TIMBUL",
"alamat": "JL. TIMBUL NO. 01, RT. 007, RW. 004, KEL. CIPEDAK, KEC. JAGAKARSA, JAKARTA SELATAN",
"plat": "B 9726 UCT",
"items": [
{
@@ -1186,9 +1192,9 @@
"noSO": "1691869400",
"noDO": "1659896184",
"tanggal": "16 April 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "DKI AREA",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "MOTHER STORE - PM WARUNG JATI TIMUR RAYA",
"alamat": "JL. WARUNG JATI TIMUR NO.26, RT.007, RW.004, KEL. KALIBATA, KEC. PANCORAN, JAKARTA SELATAN 12740",
"plat": "B 9310 BXT",
"items": [
{
@@ -1217,9 +1223,9 @@
"noSO": "1691898090",
"noDO": "1659922449",
"tanggal": "07 May 2026",
"customer": "PT.PRIMAFOOD INTERNATIONAL",
"store": "PFM-KU",
"alamat": "JL. ANCOL BARAT VIII/1, ANCOL, PADEMANGAN, JAKARTA UTARA, 14430 JAKARTA UTARA",
"customer": "PT. PRIMAFOOD INTERNATIONAL",
"store": "PM KARANG BOLONG SERANG",
"alamat": "JL. RAYA KARANG BOLONG BANDULU (KP SEMPUR) NO.22, RT.004, RW.001, KEL. BANDULU, KEC. ANYAR, KAB. SERANG 42166",
"plat": "B 9259 BCU",
"items": [
{
@@ -0,0 +1,20 @@
[
{
"filename": "12010119 FIESTA NUGGET CHEESE 123 400 GR PER PAC/WhatsApp Image 2026-05-28 at 11.50.03.jpeg",
"no_sku": "",
"nama_item": "",
"expiry_date": "23/09/2026",
"top1_confidence": null,
"notes": "",
"saved_at": "2026-07-07T13:01:57.538Z"
},
{
"filename": "12030102 FIESTA STIKIE 200 GR-PAC/WhatsApp Image 2026-06-29 at 09.10.01 (1).jpeg",
"no_sku": "12030102",
"nama_item": "12030102 FIESTA STIKIE 200 GR-PAC",
"expiry_date": "26/02/2027",
"top1_confidence": 1,
"notes": "",
"saved_at": "2026-07-08T03:47:59.797Z"
}
]
+1 -1
View File
@@ -1,4 +1,4 @@
[
conti[
{
"noSku": "11110059",
"namaItem": "CEKER BERKUKU FROZEN PACK 1 KG(*)",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB