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

+
+ Original Document { + // 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

+
+ + + + + + + + + + + + {selectedDetails.items.map((item: any, idx: number) => { + const isFlagged = selectedDetails.flagged?.[idx]; + const remark = selectedDetails.remarks?.[idx]; + return ( + + + + + + + + ); + })} + +
NoSKU CodeItem DescriptionQtyPrice/Total
{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: