# 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.
- **1.1** [TODO] - **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 ` 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 ` (`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 `` 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.*