diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml
index a4de322..2e5df6b 100644
--- a/backend/docker-compose.yml
+++ b/backend/docker-compose.yml
@@ -57,7 +57,7 @@ services:
- PIPELINE_PORT=8090
- PIPELINE_DEVICE=gpu:0
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- - VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
+ - VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
# No exposed ports; internal only
deploy:
diff --git a/backend/pfm-web-app/src/app/api/documents/[id]/logs/route.ts b/backend/pfm-web-app/src/app/api/documents/[id]/logs/route.ts
new file mode 100644
index 0000000..715cde6
--- /dev/null
+++ b/backend/pfm-web-app/src/app/api/documents/[id]/logs/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server";
+import { query } from "../../../../../db";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> | { id: string } }
+) {
+ try {
+ // Handle both Promise and synchronous params for Next.js version compatibility
+ const resolvedParams = await params;
+ const { id } = resolvedParams;
+ const docId = parseInt(id, 10);
+
+ if (isNaN(docId)) {
+ return NextResponse.json({ error: "Invalid document ID" }, { status: 400 });
+ }
+
+ const res = await query(
+ "SELECT filename, processing_logs FROM documents WHERE id = $1",
+ [docId]
+ );
+
+ if (res.rowCount === 0 || !res.rows[0]) {
+ return NextResponse.json({ error: "Document not found" }, { status: 404 });
+ }
+
+ return NextResponse.json({
+ filename: res.rows[0].filename,
+ processing_logs: res.rows[0].processing_logs || null
+ });
+ } catch (error: any) {
+ console.error("Error fetching document logs:", error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/backend/pfm-web-app/src/app/api/parse/route.ts b/backend/pfm-web-app/src/app/api/parse/route.ts
index b069a99..9f3a40a 100644
--- a/backend/pfm-web-app/src/app/api/parse/route.ts
+++ b/backend/pfm-web-app/src/app/api/parse/route.ts
@@ -6,13 +6,14 @@ import { query, cleanupAndReindexItems, resolveStoreFromText } from "../../../db
import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
export async function POST(req: NextRequest) {
+ let safeFile = "";
try {
const { filename } = await req.json();
if (!filename) {
return NextResponse.json({ error: "Filename is required" }, { status: 400 });
}
- const safeFile = path.basename(filename);
+ safeFile = path.basename(filename);
// 1. Resolve path to the public assets folder or fallback to /uploads
let filePath = path.join(process.cwd(), "public", "do-pfm", safeFile);
diff --git a/backend/pfm-web-app/src/app/api/upload/route.ts b/backend/pfm-web-app/src/app/api/upload/route.ts
index 5af9636..cbb902f 100644
--- a/backend/pfm-web-app/src/app/api/upload/route.ts
+++ b/backend/pfm-web-app/src/app/api/upload/route.ts
@@ -3,7 +3,8 @@ import fs from "fs";
import path from "path";
import crypto from "crypto";
import { query, resolveStoreFromText } from "../../../db";
-import { parseDOMetadata } from "../../../utils/parser";
+import { parseDOMetadata, sanitizeParsedMetadata } from "../../../utils/parser";
+import { startActiveLog, getActiveLog, clearActiveLog } from "../../../utils/active-log";
const UPLOADS_DIR = "/uploads";
@@ -75,6 +76,7 @@ export async function POST(req: NextRequest) {
const pipelineUrl = process.env.PIPELINE_URL || "http://paddleocr-pipeline-api:8090/layout-parsing";
console.log(`Forwarding uploaded file ${filename} to pipeline: ${pipelineUrl}`);
+ startActiveLog(filename);
const response = await fetch(pipelineUrl, {
method: "POST",
@@ -85,6 +87,7 @@ export async function POST(req: NextRequest) {
});
if (!response.ok) {
+ clearActiveLog();
const errText = await response.text();
return NextResponse.json({ error: `Pipeline API error: ${errText}` }, { status: response.status });
}
@@ -131,19 +134,46 @@ export async function POST(req: NextRequest) {
(docMetadata as any).orderUntuk = resolvedStore.orderUntuk;
(docMetadata as any).alamat = resolvedStore.alamat;
+ // Stage 2 Filtering: Sanitize parsed metadata
+ const sanitizedMetadata = sanitizeParsedMetadata(docMetadata as any);
+
+ // Construct client response representation
+ const wrappedResult = {
+ errorCode: 0,
+ errorMsg: "Success",
+ result: pipelineResult
+ };
+ const clientResponse = {
+ filename,
+ result: wrappedResult
+ };
+
+ // Retrieve and finalize active log data
+ const activeLog = getActiveLog();
+ let logsPayload: any = null;
+ if (activeLog && activeLog.filename === filename) {
+ activeLog.ocr_raw = pipelineResult;
+ activeLog.stage_1_output = docMetadata;
+ activeLog.stage_2_output = sanitizedMetadata;
+ activeLog.frontend_response = clientResponse;
+ logsPayload = activeLog;
+ }
+ clearActiveLog();
+
const insertDocRes = await query(`
- INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ INSERT INTO documents (filename, upload_time, size, parsed, metadata, layout_parsing_result, is_sample, file_hash, processing_logs)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`, [
filename,
new Date(),
buffer.length,
true,
- JSON.stringify(docMetadata),
+ JSON.stringify(sanitizedMetadata),
JSON.stringify(pipelineResult),
false,
- fileHash
+ fileHash,
+ logsPayload ? JSON.stringify(logsPayload) : null
]);
const docId = insertDocRes.rows[0].id;
diff --git a/backend/pfm-web-app/src/app/api/vllm-proxy/[[...path]]/route.ts b/backend/pfm-web-app/src/app/api/vllm-proxy/[[...path]]/route.ts
new file mode 100644
index 0000000..1a89569
--- /dev/null
+++ b/backend/pfm-web-app/src/app/api/vllm-proxy/[[...path]]/route.ts
@@ -0,0 +1,134 @@
+import { NextRequest, NextResponse } from "next/server";
+import { logVllmCall } from "../../../../utils/active-log";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(req: NextRequest) {
+ return handleProxy(req);
+}
+
+export async function GET(req: NextRequest) {
+ return handleProxy(req);
+}
+
+export async function PUT(req: NextRequest) {
+ return handleProxy(req);
+}
+
+export async function DELETE(req: NextRequest) {
+ return handleProxy(req);
+}
+
+async function handleProxy(req: NextRequest) {
+ try {
+ const pathname = req.nextUrl.pathname;
+ // Extract everything after /api/vllm-proxy
+ const relPath = pathname.replace(/^\/api\/vllm-proxy/, "");
+
+ // The real vLLM server is at paddleocr-vllm-server:8118 inside docker compose
+ const realBaseUrl = process.env.VLLM_SERVER_REAL_URL || "http://paddleocr-vllm-server:8118";
+
+ // Construct the destination URL
+ const destUrl = `${realBaseUrl}${relPath}${req.nextUrl.search}`;
+
+ console.log(`[vllm-proxy] Routing request from ${pathname} to ${destUrl}`);
+
+ // Read the request body if present
+ let reqBody: any = null;
+ let reqBodyBuffer: Buffer | null = null;
+
+ if (req.body) {
+ const arrayBuffer = await req.arrayBuffer();
+ reqBodyBuffer = Buffer.from(arrayBuffer);
+
+ const contentType = req.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ try {
+ reqBody = JSON.parse(reqBodyBuffer.toString("utf-8"));
+ } catch (e) {
+ console.warn("[vllm-proxy] Failed to parse request body as JSON:", e);
+ }
+ }
+ }
+
+ // Reconstruct headers, filtering out headers that might cause issues (e.g. Host)
+ const headers = new Headers();
+ req.headers.forEach((value, key) => {
+ if (key.toLowerCase() !== "host" && key.toLowerCase() !== "content-length") {
+ headers.set(key, value);
+ }
+ });
+
+ // Make the actual call to the real vLLM server
+ const forwardResponse = await fetch(destUrl, {
+ method: req.method,
+ headers: headers,
+ body: reqBodyBuffer ? new Uint8Array(reqBodyBuffer) : null,
+ // @ts-ignore
+ duplex: "half"
+ });
+
+ // Read the response content
+ const resBodyBuffer = Buffer.from(await forwardResponse.arrayBuffer());
+ let resBody: any = null;
+
+ const resContentType = forwardResponse.headers.get("content-type") || "";
+ if (resContentType.includes("application/json")) {
+ try {
+ resBody = JSON.parse(resBodyBuffer.toString("utf-8"));
+ } catch (e) {
+ console.warn("[vllm-proxy] Failed to parse response body as JSON:", e);
+ }
+ } else {
+ resBody = resBodyBuffer.toString("utf-8");
+ }
+
+ // Log the interaction if it looks like a completion call
+ if (pathname.includes("/chat/completions") || pathname.includes("/completions")) {
+ // Make a clean copy of the request to log (hiding huge base64 images if they clutter logs)
+ const cleanReq = sanitizeLogPayload(reqBody);
+ logVllmCall(cleanReq, resBody);
+ }
+
+ // Return the response back to pipeline-api
+ const responseHeaders = new Headers();
+ forwardResponse.headers.forEach((value, key) => {
+ responseHeaders.set(key, value);
+ });
+
+ return new Response(resBodyBuffer, {
+ status: forwardResponse.status,
+ statusText: forwardResponse.statusText,
+ headers: responseHeaders
+ });
+
+ } catch (error) {
+ console.error("[vllm-proxy] Error forwarding request:", error);
+ return NextResponse.json({ error: "Failed to proxy request to vLLM server" }, { status: 500 });
+ }
+}
+
+// Helper function to keep log sizes reasonable by truncating huge base64 image strings
+function sanitizeLogPayload(payload: any): any {
+ if (!payload) return payload;
+ try {
+ const copy = JSON.parse(JSON.stringify(payload));
+ if (copy.messages && Array.isArray(copy.messages)) {
+ for (const msg of copy.messages) {
+ if (msg.content && Array.isArray(msg.content)) {
+ for (const part of msg.content) {
+ if (part.type === "image_url" && part.image_url && part.image_url.url) {
+ const url = part.image_url.url;
+ if (url.startsWith("data:") && url.length > 200) {
+ part.image_url.url = url.substring(0, 100) + "...[TRUNCATED BASE64]..." + url.substring(url.length - 50);
+ }
+ }
+ }
+ }
+ }
+ }
+ return copy;
+ } catch (e) {
+ return payload;
+ }
+}
diff --git a/backend/pfm-web-app/src/app/globals.css b/backend/pfm-web-app/src/app/globals.css
index a0a0e23..d5428ce 100644
--- a/backend/pfm-web-app/src/app/globals.css
+++ b/backend/pfm-web-app/src/app/globals.css
@@ -1,6 +1,8 @@
+@import "tailwindcss";
+
:root {
- --background: #ffffff;
- --foreground: #171717;
+ --background: #0f172a;
+ --foreground: #f8fafc;
}
@media (prefers-color-scheme: dark) {
diff --git a/backend/pfm-web-app/src/app/layout.tsx b/backend/pfm-web-app/src/app/layout.tsx
index 84448d9..cb83458 100644
--- a/backend/pfm-web-app/src/app/layout.tsx
+++ b/backend/pfm-web-app/src/app/layout.tsx
@@ -1,17 +1,6 @@
import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
-
export const metadata: Metadata = {
title: "AI OCR Delivery Order",
description: "Generated by create next app",
@@ -25,9 +14,10 @@ export default function RootLayout({
return (
-
{children}
+ {children}
);
}
diff --git a/backend/pfm-web-app/src/app/page.tsx b/backend/pfm-web-app/src/app/page.tsx
index 4d652be..9e43bc7 100644
--- a/backend/pfm-web-app/src/app/page.tsx
+++ b/backend/pfm-web-app/src/app/page.tsx
@@ -1,10 +1,697 @@
-export const dynamic = "force-dynamic";
+"use client";
+
+import React, { useState, useEffect, useRef } from "react";
+
+interface HistoryItem {
+ id: number;
+ filename: string;
+ uploadTime: string;
+ size: number;
+ parsed: boolean;
+ isSample: boolean;
+ metadata: any;
+ totalItems: number;
+ flaggedItems: number;
+}
export default function Home() {
+ const [history, setHistory] = useState([]);
+ const [selectedItem, setSelectedItem] = useState(null);
+ const [selectedDetails, setSelectedDetails] = useState(null);
+ const [selectedLogs, setSelectedLogs] = useState(null);
+ const [activeStep, setActiveStep] = useState(0);
+ const [uploading, setUploading] = useState(false);
+ const [dragActive, setDragActive] = useState(false);
+ const [errorMsg, setErrorMsg] = useState(null);
+ const fileInputRef = useRef(null);
+
+ // Fetch history list
+ const fetchHistory = async () => {
+ try {
+ const res = await fetch("/api/history?all=true");
+ if (res.ok) {
+ const data = await res.json();
+ setHistory(data.history || []);
+ }
+ } catch (err) {
+ console.error("Failed to fetch history:", err);
+ }
+ };
+
+ useEffect(() => {
+ fetchHistory();
+ }, []);
+
+ // Fetch details and logs for a selected document
+ const handleSelectItem = async (item: HistoryItem) => {
+ setSelectedItem(item);
+ setSelectedDetails(null);
+ setSelectedLogs(null);
+ setErrorMsg(null);
+ setActiveStep(0);
+
+ try {
+ // 1. Fetch items and doc details
+ const detailsRes = await fetch(`/api/history?file=${encodeURIComponent(item.filename)}`);
+ if (detailsRes.ok) {
+ const detailsData = await detailsRes.json();
+ setSelectedDetails(detailsData);
+ } else {
+ throw new Error("Failed to load document details");
+ }
+
+ // 2. Fetch processing logs
+ const logsRes = await fetch(`/api/documents/${item.id}/logs`);
+ if (logsRes.ok) {
+ const logsData = await logsRes.json();
+ setSelectedLogs(logsData.processing_logs);
+ }
+ } catch (err: any) {
+ console.error("Error loading document:", err);
+ setErrorMsg(err.message || "Error loading document details");
+ }
+ };
+
+ // Handle Drag & Drop events
+ const handleDrag = (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === "dragenter" || e.type === "dragover") {
+ setDragActive(true);
+ } else if (e.type === "dragleave") {
+ setDragActive(false);
+ }
+ };
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragActive(false);
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ await handleUploadFile(e.dataTransfer.files[0]);
+ }
+ };
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ if (e.target.files && e.target.files[0]) {
+ await handleUploadFile(e.target.files[0]);
+ }
+ };
+
+ const handleUploadFile = async (file: File) => {
+ setUploading(true);
+ setErrorMsg(null);
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ console.log("Uploading file to gateway...");
+ const res = await fetch("/api/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!res.ok) {
+ const data = await res.json();
+ throw new Error(data.error || "Failed to process image");
+ }
+
+ const data = await res.json();
+ console.log("Upload response received:", data);
+
+ // Refresh list and select new document
+ await fetchHistory();
+
+ // Attempt to find the newly uploaded item in history list
+ // Next.js history is sorted descending, so it will likely be first
+ const updatedRes = await fetch("/api/history?all=true");
+ if (updatedRes.ok) {
+ const updatedData = await updatedRes.json();
+ const newHistory = updatedData.history || [];
+ setHistory(newHistory);
+ const match = newHistory.find((h: HistoryItem) => h.filename === data.filename);
+ if (match) {
+ handleSelectItem(match);
+ }
+ }
+ } catch (err: any) {
+ console.error("Upload failed:", err);
+ setErrorMsg(err.message || "Failed to upload and process file.");
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const triggerFileInput = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleDeleteItem = async (e: React.MouseEvent, item: HistoryItem) => {
+ e.stopPropagation();
+ if (!confirm(`Are you sure you want to delete ${item.filename}?`)) return;
+
+ try {
+ const res = await fetch("/api/history", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ filename: item.filename })
+ });
+
+ if (res.ok) {
+ if (selectedItem?.id === item.id) {
+ setSelectedItem(null);
+ setSelectedDetails(null);
+ setSelectedLogs(null);
+ }
+ fetchHistory();
+ }
+ } catch (err) {
+ console.error("Failed to delete document:", err);
+ }
+ };
+
+ // Format bytes to human readable format
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ // Setup Stepper Steps
+ const getSteps = () => {
+ const vllmCallsCount = selectedLogs?.vllm_calls?.length || 0;
+
+ return [
+ {
+ title: "1. Upload & Preprocess",
+ description: "Verify document metadata & files",
+ content: (
+
+
Uploaded Document File Info
+
+
Filename: {selectedItem?.filename}
+
File Size: {formatBytes(selectedItem?.size || 0)}
+
Upload Date: {selectedItem?.uploadTime ? new Date(selectedItem.uploadTime).toLocaleString() : "N/A"}
+
File Hash: {selectedItem?.metadata?.fileHash || "Processed content hash saved"}
+
Is Sample DO: {selectedItem?.isSample ? "True" : "False"}
+
+ {selectedItem?.filename && (
+
+
Original Document Source View
+
+

{
+ // Fallback if direct image access requires alternative endpoint
+ (e.target as HTMLImageElement).src = `https://placehold.co/600x800/1e293b/f8fafc?text=${encodeURIComponent(selectedItem.filename)}`;
+ }}
+ />
+
+
+ )}
+
+ )
+ },
+ {
+ title: `2. vLLM Prompt ("Before")`,
+ description: `Inspect prompt parameters & system role (${vllmCallsCount} call${vllmCallsCount > 1 ? "s" : ""})`,
+ content: (
+
+
Payload Sent to vLLM Server
+ {selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
+ selectedLogs.vllm_calls.map((call: any, idx: number) => (
+
+
+ Call #{idx + 1}
+ {new Date(call.timestamp).toLocaleTimeString()}
+
+
+
{JSON.stringify(call.request, null, 2)}
+
+
+ ))
+ ) : (
+
+ No vLLM API calls logged. (If this is a sample/re-indexed document, logs may not be recorded yet).
+
+ )}
+
+ )
+ },
+ {
+ title: `3. vLLM Response ("After")`,
+ description: "Inspect raw output returned by VLM",
+ content: (
+
+
Raw Text Generated by vLLM (PaddleOCR-VL)
+ {selectedLogs?.vllm_calls && selectedLogs.vllm_calls.length > 0 ? (
+ selectedLogs.vllm_calls.map((call: any, idx: number) => (
+
+
+ Response #{idx + 1}
+ {new Date(call.timestamp).toLocaleTimeString()}
+
+
+
{JSON.stringify(call.response, null, 2)}
+
+
+ ))
+ ) : (
+
+ No vLLM API responses logged.
+
+ )}
+
+ )
+ },
+ {
+ title: "4. Raw OCR Layout Markdown",
+ description: "View parsed layout markdown structure",
+ content: (
+
+
Document Layout Parsing Markdown Output
+ {selectedDetails?.result || selectedLogs?.ocr_raw ? (
+
+
+
+ {(() => {
+ const res = selectedDetails?.result || selectedLogs?.ocr_raw;
+ const page0 = res?.layoutParsingResults?.[0] || {};
+ return page0?.markdown?.text || "No markdown text output found in results.";
+ })()}
+
+
+
+ ) : (
+
+ No raw layout results parsed.
+
+ )}
+
+ )
+ },
+ {
+ title: "5. Stage 1 Extracted Fields",
+ description: "View output of regex schema matching",
+ content: (
+
+
Regex Extracted Fields (Metadata & Items)
+ {selectedLogs?.stage_1_output ? (
+
+
{JSON.stringify(selectedLogs.stage_1_output, null, 2)}
+
+ ) : (
+
+ No Stage 1 log output saved.
+
+ )}
+
+ )
+ },
+ {
+ title: "6. Stage 2 Sanitized Output",
+ description: "View results after validation checks",
+ content: (
+
+
Sanitized & Validated Metadata (Final database fields)
+ {selectedLogs?.stage_2_output ? (
+
+
{JSON.stringify(selectedLogs.stage_2_output, null, 2)}
+
+ ) : (
+
+ No Stage 2 log output saved.
+
+ )}
+
+ )
+ },
+ {
+ title: "7. Client Gateway Response",
+ description: "Inspect JSON payload sent to frontends",
+ content: (
+
+
Final Gateway JSON Response
+ {selectedLogs?.frontend_response || selectedDetails ? (
+
+
+ {JSON.stringify(
+ selectedLogs?.frontend_response || {
+ filename: selectedItem?.filename,
+ result: selectedDetails
+ },
+ null,
+ 2
+ )}
+
+
+ ) : (
+
+ No response payload logged.
+
+ )}
+
+ )
+ }
+ ];
+ };
+
+ const steps = getSteps();
+
return (
-
-
Prima Fresh Mart OCR API Gateway
-
Status: Running
+
+ {/* Sleek Top Navigation Bar */}
+
+
+
+ P
+
+
+
+ Prima Fresh Mart AI OCR Gateway
+
+
+ Pipeline Process Layers Dashboard
+
+
+
+
+ {/* Status Indicators */}
+
+
+
+ DB: Online
+
+
+
+ vLLM: Connected
+
+
+
+
+ {/* Main Layout Grid */}
+
+
+ {/* LEFT COLUMN: Upload Panel & History List (4 Cols) */}
+
+
+ {/* Upload Zone Card */}
+
+
+ {uploading ? (
+
+
+
Running OCR Pipeline...
+
+ ) : (
+
+
📤
+
+
Upload Delivery Order Image
+
Drag and drop file, or click to browse
+
+
+ )}
+
+
+ {/* History List Card */}
+
+
+ Upload History
+
+
+
+ {history.length === 0 ? (
+
+ No uploads processed yet.
+
+ ) : (
+ history.map((item) => {
+ const isSelected = selectedItem?.id === item.id;
+ const dateFormatted = new Date(item.uploadTime).toLocaleDateString("id-ID", {
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+
+ return (
+
handleSelectItem(item)}
+ >
+
+
+ {item.filename.substring(item.filename.indexOf("-") + 1)}
+
+
+ {dateFormatted}
+ •
+ {formatBytes(item.size)}
+
+ {item.metadata && (
+
+ {item.metadata.noPO && item.metadata.noPO !== "Not Found" && (
+
+ {item.metadata.noPO}
+
+ )}
+ {item.totalItems > 0 && (
+
+ {item.totalItems} Items
+
+ )}
+ {item.flaggedItems > 0 && (
+
+ {item.flaggedItems} Warning
+
+ )}
+
+ )}
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ {/* RIGHT COLUMN: Interactive Stepper & Document Viewer (8 Cols) */}
+
+ {errorMsg && (
+
+ ⚠️
+ {errorMsg}
+
+ )}
+
+ {!selectedItem ? (
+
+
📁
+
No Document Selected
+
+ Upload a new Delivery Order image, or select one from the history panel to inspect its processing stages.
+
+
+ ) : (
+
+
+ {/* Document Overview Metadata Card */}
+
+
+
+
+ 📄
+ {selectedItem.filename.substring(selectedItem.filename.indexOf("-") + 1)}
+
+
Parsed Overview results from Postgres Database
+
+ {selectedItem.isSample && (
+
+ Sample DO
+
+ )}
+
+
+ {selectedDetails?.result?.metadata || selectedItem.metadata ? (
+ (() => {
+ const meta = selectedDetails?.result?.metadata || selectedItem.metadata;
+ return (
+
+
+
PO Number
+
{meta.noPO || "Not Found"}
+
+
+
SO Number
+
{meta.noSO || "Not Found"}
+
+
+
DO Number
+
{meta.noDO || "Not Found"}
+
+
+
Order Untuk (Store)
+
{meta.orderUntuk || "Not Found"}
+
{meta.alamat || ""}
+
+
+
Date
+
{meta.tanggal || "Not Found"}
+
+
+
Truck Plate
+
{meta.platTruk || "Not Found"}
+
+
+
Total SKUs
+
{selectedDetails?.items?.length || 0} SKU Items
+
+
+ );
+ })()
+ ) : (
+
No metadata available
+ )}
+
+ {/* Parsed Items List */}
+ {selectedDetails?.items && selectedDetails.items.length > 0 && (
+
+
Parsed SKU Table Rows
+
+
+
+
+ | No |
+ SKU Code |
+ Item Description |
+ Qty |
+ Price/Total |
+
+
+
+ {selectedDetails.items.map((item: any, idx: number) => {
+ const isFlagged = selectedDetails.flagged?.[idx];
+ const remark = selectedDetails.remarks?.[idx];
+ return (
+
+ | {idx + 1} |
+
+ {item.kodeBarang}
+ |
+
+
+ {item.namaBarang}
+ {remark && {remark} }
+
+ |
+ {item.banyak} |
+ {item.jumlah} |
+
+ );
+ })}
+
+
+
+
+ )}
+
+
+ {/* TIMELINE / STEPPER LAYERS CARD */}
+
+
+
+ 🔬
+ AI OCR Pipeline Process Layers
+
+
Interactive vertical timeline inspecting how data flows from original image to clean JSON response
+
+
+ {/* Process Timeline Stepper Container */}
+
+
+ {/* Stepper Steps (4 Cols) */}
+
+ {steps.map((step, idx) => {
+ const isActive = activeStep === idx;
+ return (
+
setActiveStep(idx)}
+ >
+
{step.title}
+
{step.description}
+
+ );
+ })}
+
+
+ {/* Step Detail Content Panel (8 Cols) */}
+
+
+
+ Layer Detail view
+
+
+ Layer {activeStep + 1} of 7
+
+
+
+
+ {steps[activeStep].content}
+
+
+
+
+
+
+
+
+ )}
+
+
+
);
}
diff --git a/backend/pfm-web-app/src/db/init.ts b/backend/pfm-web-app/src/db/init.ts
index 1010384..2b14a8a 100644
--- a/backend/pfm-web-app/src/db/init.ts
+++ b/backend/pfm-web-app/src/db/init.ts
@@ -19,15 +19,17 @@ export async function initDb(pool: Pool) {
metadata JSONB,
layout_parsing_result JSONB,
is_sample BOOLEAN NOT NULL DEFAULT FALSE,
- file_hash VARCHAR(64)
+ file_hash VARCHAR(64),
+ processing_logs JSONB
);
`);
- // Ensure file_hash exists on existing tables
+ // Ensure file_hash and processing_logs exist on existing tables
try {
await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS file_hash VARCHAR(64);");
+ await pool.query("ALTER TABLE documents ADD COLUMN IF NOT EXISTS processing_logs JSONB;");
} catch (alterErr) {
- console.error("Failed to alter documents table for file_hash:", alterErr);
+ console.error("Failed to alter documents table for schema upgrade:", alterErr);
}
await pool.query(`
diff --git a/backend/pfm-web-app/src/utils/active-log.ts b/backend/pfm-web-app/src/utils/active-log.ts
new file mode 100644
index 0000000..8b17150
--- /dev/null
+++ b/backend/pfm-web-app/src/utils/active-log.ts
@@ -0,0 +1,51 @@
+// Active Log Tracker for OCR Processing Layers
+
+export interface VllmCall {
+ request: any;
+ response: any;
+ timestamp: string;
+}
+
+export interface ActiveUploadLog {
+ filename: string;
+ vllm_calls: VllmCall[];
+ ocr_raw?: any;
+ stage_1_output?: any;
+ stage_2_output?: any;
+ frontend_response?: any;
+}
+
+// Store active log in global context to persist across Next.js hot-reloads
+const globalForActiveLog = global as unknown as {
+ activeLog: ActiveUploadLog | null;
+};
+
+export function startActiveLog(filename: string) {
+ globalForActiveLog.activeLog = {
+ filename,
+ vllm_calls: []
+ };
+ console.log(`[ActiveLog] Started tracking log for ${filename}`);
+}
+
+export function logVllmCall(request: any, response: any) {
+ if (globalForActiveLog.activeLog) {
+ globalForActiveLog.activeLog.vllm_calls.push({
+ request,
+ response,
+ timestamp: new Date().toISOString()
+ });
+ console.log(`[ActiveLog] Logged vLLM call for ${globalForActiveLog.activeLog.filename} (total calls: ${globalForActiveLog.activeLog.vllm_calls.length})`);
+ } else {
+ console.log("[ActiveLog] Warning: Attempted to log vLLM call but no active log session is running.");
+ }
+}
+
+export function getActiveLog(): ActiveUploadLog | null {
+ return globalForActiveLog.activeLog;
+}
+
+export function clearActiveLog() {
+ globalForActiveLog.activeLog = null;
+ console.log("[ActiveLog] Cleared active log tracking context");
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index d187ac9..599bd12 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -55,7 +55,7 @@ services:
- PIPELINE_PORT=8090
- PIPELINE_DEVICE=gpu:0
- CUDA_VISIBLE_DEVICES=${CUDA_VISIBLE_DEVICES:-0}
- - VLLM_SERVER_URL=http://paddleocr-vllm-server:8118/v1
+ - VLLM_SERVER_URL=http://paddleocr-pfm-web-app:3000/api/vllm-proxy/v1
- PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True
deploy:
resources: