diff --git a/inventory-server/src/routes/ai-validation.js b/inventory-server/src/routes/ai-validation.js
index a4abf75..2e7ce46 100644
--- a/inventory-server/src/routes/ai-validation.js
+++ b/inventory-server/src/routes/ai-validation.js
@@ -960,7 +960,7 @@ router.post("/validate", async (req, res) => {
// - max_output_tokens: 20000 ensures space for large product batches
// Note: Responses API is the recommended endpoint for GPT-5 models
const completion = await createResponsesCompletion({
- model: "gpt-5",
+ model: "gpt-5.2",
input: [
{
role: "developer",
@@ -978,7 +978,7 @@ router.post("/validate", async (req, res) => {
verbosity: "medium",
format: AI_VALIDATION_TEXT_FORMAT,
},
- max_output_tokens: 20000,
+ max_output_tokens: 50000,
});
console.log("✅ Received response from OpenAI Responses API");
@@ -1480,6 +1480,7 @@ function normalizeJsonResponse(text) {
if (!text || typeof text !== 'string') return text;
let cleaned = text.trim();
+ // Remove markdown code fences if present
if (cleaned.startsWith('```')) {
const firstLineBreak = cleaned.indexOf('\n');
if (firstLineBreak !== -1) {
@@ -1496,5 +1497,86 @@ function normalizeJsonResponse(text) {
cleaned = cleaned.trim();
}
+ // Attempt to repair truncated JSON
+ // This handles cases where the AI response was cut off mid-response
+ cleaned = repairTruncatedJson(cleaned);
+
return cleaned;
}
+
+/**
+ * Attempt to repair truncated JSON by adding missing closing brackets/braces
+ * This is a common issue when AI responses hit token limits
+ */
+function repairTruncatedJson(text) {
+ if (!text || typeof text !== 'string') return text;
+
+ // First, try parsing as-is
+ try {
+ JSON.parse(text);
+ return text; // Valid JSON, no repair needed
+ } catch (e) {
+ // JSON is invalid, try to repair
+ }
+
+ let repaired = text.trim();
+
+ // Count opening and closing brackets/braces
+ let braceCount = 0; // {}
+ let bracketCount = 0; // []
+ let inString = false;
+ let escapeNext = false;
+
+ for (let i = 0; i < repaired.length; i++) {
+ const char = repaired[i];
+
+ if (escapeNext) {
+ escapeNext = false;
+ continue;
+ }
+
+ if (char === '\\' && inString) {
+ escapeNext = true;
+ continue;
+ }
+
+ if (char === '"') {
+ inString = !inString;
+ continue;
+ }
+
+ if (!inString) {
+ if (char === '{') braceCount++;
+ else if (char === '}') braceCount--;
+ else if (char === '[') bracketCount++;
+ else if (char === ']') bracketCount--;
+ }
+ }
+
+ // If we're still inside a string, close it
+ if (inString) {
+ repaired += '"';
+ }
+
+ // Add missing closing brackets and braces
+ // Close arrays first, then objects (reverse of typical nesting)
+ while (bracketCount > 0) {
+ repaired += ']';
+ bracketCount--;
+ }
+ while (braceCount > 0) {
+ repaired += '}';
+ braceCount--;
+ }
+
+ // Try parsing the repaired JSON
+ try {
+ JSON.parse(repaired);
+ console.log('✅ Successfully repaired truncated JSON');
+ return repaired;
+ } catch (e) {
+ // Repair failed, return original and let the caller handle the error
+ console.log('⚠️ JSON repair attempt failed:', e.message);
+ return text;
+ }
+}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx
index 4ff3bbe..77894e6 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/FloatingSelectionBar.tsx
@@ -148,11 +148,11 @@ export const FloatingSelectionBar = memo(() => {
return (
<>
-
+
{/* Selection count badge */}
-
+
{selectedCount} selected
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx
index 275fcea..85fc744 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/InitializingOverlay.tsx
@@ -18,8 +18,8 @@ const phaseMessages: Record
= {
idle: 'Preparing...',
'loading-options': 'Loading field options...',
'loading-templates': 'Loading templates...',
- 'validating-upcs': 'Validating UPC codes...',
- 'validating-fields': 'Running field validation...',
+ 'validating-upcs': 'Checking UPCs and creating item numbers...',
+ 'validating-fields': 'Checking for field errors...',
ready: 'Ready',
};
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx
index d02676f..76ca3ae 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx
@@ -136,6 +136,7 @@ export const ValidationContainer = ({
results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange}
+ onAccept={aiValidation.acceptChange}
onDismiss={aiValidation.dismissResults}
/>
)}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
index 6007111..c2dea88 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx
@@ -4,9 +4,11 @@
* Navigation footer with back/next buttons, AI validate, and summary info.
*/
+import { useState } from 'react';
import { Button } from '@/components/ui/button';
-import { ChevronLeft, ChevronRight, CheckCircle, Wand2, FileText } from 'lucide-react';
+import { CheckCircle, Wand2, FileText } from 'lucide-react';
import { Protected } from '@/components/auth/Protected';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
interface ValidationFooterProps {
onBack?: () => void;
@@ -31,13 +33,14 @@ export const ValidationFooter = ({
isAiValidating = false,
onShowDebug,
}: ValidationFooterProps) => {
+ const [showErrorDialog, setShowErrorDialog] = useState(false);
+
return (
{/* Back button */}
{canGoBack && onBack && (
)}
@@ -85,18 +88,52 @@ export const ValidationFooter = ({
{/* Next button */}
{onNext && (
-
+ <>
+
+
+
+ >
)}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx
index 36fba71..1205c3e 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationToolbar.tsx
@@ -105,75 +105,78 @@ export const ValidationToolbar = ({
return (
- {/* Top row: Search and stats */}
+ {/* Top row: Search, product count, and action buttons */}
{/* Search */}
setSearchText(e.target.value)}
className="pl-9"
/>
- {/* Error filter toggle */}
-
-
{rowCount} products
+
+ {/* Action buttons */}
+
+ {/* Add row */}
+
+
+ {/* Create template from existing product */}
+
+
+ {/* Create product line/subline */}
+
+
+ New Line/Subline
+
+ }
+ companies={companyOptions}
+ onCreated={handleCategoryCreated}
/>
-
+
- {/* Stats */}
-
-
{rowCount} products
- {errorCount > 0 && (
+ {/* Bottom row: Error badge and filter toggle */}
+ {errorCount > 0 && (
+
+
+
- {errorCount} errors in {rowsWithErrors} rows
+ {errorCount} issues in {rowsWithErrors} rows
- )}
- {selectedRowCount > 0 && (
-
{selectedRowCount} selected
- )}
+
+ {/* Error filter toggle */}
+
+
+
+
-
- {/* Bottom row: Actions */}
-
- {/* Add row */}
-
-
- {/* Create template from existing product */}
-
-
- {/* Create product line/subline */}
-
-
- New Line/Subline
-
- }
- companies={companyOptions}
- onCreated={handleCategoryCreated}
- />
-
+ )}
{/* Product Search Template Dialog */}
{
+ if (!seconds) return 'Unknown';
+ if (seconds < 60) return `~${Math.round(seconds)}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.round(seconds % 60);
+ return `~${minutes}m ${remainingSeconds}s`;
+};
+
+/**
+ * Calculate cost estimate in cents
+ */
+const calculateCost = (promptLength: number, costPerMillion: number): string => {
+ const estimatedTokens = Math.round(promptLength / 4);
+ const costCents = (estimatedTokens / 1_000_000) * costPerMillion * 100;
+ return costCents < 1 ? '<1¢' : `${costCents.toFixed(1)}¢`;
+};
+
+/**
+ * Parse user message content into visual sections based on text markers
+ */
+const parseUserContent = (content: string) => {
+ // Find section boundaries by looking for specific markers
+ const companySpecificStartIndex = content.indexOf('--- COMPANY-SPECIFIC INSTRUCTIONS ---');
+ const companySpecificEndIndex = content.indexOf('--- END COMPANY-SPECIFIC INSTRUCTIONS ---');
+
+ const taxonomyStartIndex = content.indexOf('All Available Categories:');
+ const taxonomyFallbackStartIndex = content.indexOf('Available Categories:');
+ const actualTaxonomyStartIndex =
+ taxonomyStartIndex >= 0 ? taxonomyStartIndex : taxonomyFallbackStartIndex;
+
+ const productDataStartIndex = content.indexOf(
+ '----------Here is the product data to validate----------'
+ );
+
+ // If we can't find any markers, just return the content as-is
+ if (actualTaxonomyStartIndex < 0 && productDataStartIndex < 0 && companySpecificStartIndex < 0) {
+ return [{ type: 'default', content }];
+ }
+
+ // Determine section indices
+ let generalEndIndex = content.length;
+
+ if (companySpecificStartIndex >= 0) {
+ generalEndIndex = companySpecificStartIndex;
+ } else if (actualTaxonomyStartIndex >= 0) {
+ generalEndIndex = actualTaxonomyStartIndex;
+ } else if (productDataStartIndex >= 0) {
+ generalEndIndex = productDataStartIndex;
+ }
+
+ // Determine where taxonomy ends
+ let taxonomyEndIndex = content.length;
+ if (productDataStartIndex >= 0) {
+ taxonomyEndIndex = productDataStartIndex;
+ }
+
+ const segments: Array<{ type: string; content: string }> = [];
+
+ // General section (beginning to company/taxonomy/product)
+ if (generalEndIndex > 0) {
+ segments.push({
+ type: 'general',
+ content: content.substring(0, generalEndIndex),
+ });
+ }
+
+ // Company-specific section if present
+ if (companySpecificStartIndex >= 0 && companySpecificEndIndex >= 0) {
+ segments.push({
+ type: 'company',
+ content: content.substring(
+ companySpecificStartIndex,
+ companySpecificEndIndex + '--- END COMPANY-SPECIFIC INSTRUCTIONS ---'.length
+ ),
+ });
+ }
+
+ // Taxonomy section
+ if (actualTaxonomyStartIndex >= 0) {
+ segments.push({
+ type: 'taxonomy',
+ content: content.substring(actualTaxonomyStartIndex, taxonomyEndIndex),
+ });
+ }
+
+ // Product data section
+ if (productDataStartIndex >= 0) {
+ segments.push({
+ type: 'product',
+ content: content.substring(productDataStartIndex),
+ });
+ }
+
+ return segments;
+};
+
+// Section styling configurations
+const SECTION_STYLES = {
+ system: {
+ badge: 'bg-purple-100 hover:bg-purple-200 cursor-pointer',
+ header: 'bg-purple-50 text-purple-800',
+ content: 'bg-purple-50/30',
+ border: 'border-purple-500',
+ label: 'text-purple-700',
+ },
+ general: {
+ badge: 'bg-green-100 hover:bg-green-200 cursor-pointer',
+ header: 'bg-green-50 text-green-800',
+ content: 'bg-green-50/30',
+ border: 'border-green-500',
+ label: 'text-green-700',
+ },
+ company: {
+ badge: 'bg-blue-100 hover:bg-blue-200 cursor-pointer',
+ header: 'bg-blue-50 text-blue-800',
+ content: 'bg-blue-50/30',
+ border: 'border-blue-500',
+ label: 'text-blue-700',
+ },
+ taxonomy: {
+ badge: 'bg-amber-100 hover:bg-amber-200 cursor-pointer',
+ header: 'bg-amber-50 text-amber-800',
+ content: 'bg-amber-50/30',
+ border: 'border-amber-500',
+ label: 'text-amber-700',
+ },
+ product: {
+ badge: 'bg-pink-100 hover:bg-pink-200 cursor-pointer',
+ header: 'bg-pink-50 text-pink-800',
+ content: 'bg-pink-50/30',
+ border: 'border-pink-500',
+ label: 'text-pink-700',
+ },
+};
+
export const AiDebugDialog = ({
open,
onClose,
debugData,
isLoading = false,
}: AiDebugDialogProps) => {
+ // Editable cost per million tokens (default to $1.25 for Claude)
+ const [costPerMillion, setCostPerMillion] = useState(1.25);
+
+ // Calculate prompt length and tokens
+ const promptLength = debugData?.promptLength ?? 0;
+ const estimatedTokens = Math.round(promptLength / 4);
+
+ // Check if we have company prompts for badges
+ const companyPrompts = debugData?.promptSources?.companyPrompts ?? [];
+
+ // Scroll to section helper
+ const scrollToSection = (id: string) => {
+ document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
+ };
+
return (
) : debugData ? (
-
-
- {/* Token/Character Stats */}
- {debugData.promptLength && (
-
-
-
Prompt Length:
-
- {debugData.promptLength.toLocaleString()} chars
-
+ <>
+ {/* Stats Cards */}
+
+ {/* Prompt Length Card */}
+
+
+ Prompt Length
+
+
+
+
+ Characters:{' '}
+ {promptLength.toLocaleString()}
+
+
+ Tokens:{' '}
+ ~{estimatedTokens.toLocaleString()}
+
-
-
Est. Tokens:
-
- ~{Math.round(debugData.promptLength / 4).toLocaleString()}
-
+
+
+
+ {/* Cost Estimate Card */}
+
+
+ Cost Estimate
+
+
+
+
+
+ setCostPerMillion(Number(e.target.value) || 1.25)}
+ className="w-[50px] h-6 px-1 mx-1 text-sm"
+ min="0"
+ step="0.25"
+ />
+
+
+
+ Cost:{' '}
+
+ {calculateCost(promptLength, costPerMillion)}
+
+
-
- )}
+
+
- {/* Base Prompt */}
- {debugData.basePrompt && (
-
-
Base Prompt
-
- {debugData.basePrompt}
-
-
- )}
-
- {/* Sample Full Prompt */}
- {debugData.sampleFullPrompt && (
-
-
Sample Full Prompt (First 5 Products)
-
- {debugData.sampleFullPrompt}
-
-
- )}
-
- {/* Taxonomy Stats */}
- {debugData.taxonomyStats && (
-
-
Taxonomy Stats
-
- {Object.entries(debugData.taxonomyStats).map(([key, value]) => (
-
-
- {key.replace(/([A-Z])/g, ' $1').trim()}:
-
-
{value}
+ {/* Processing Time Card */}
+
+
+ Processing Time
+
+
+
+ {debugData.estimatedProcessingTime?.seconds ? (
+ <>
+
+ Estimated time:{' '}
+
+ {formatTime(debugData.estimatedProcessingTime.seconds)}
+
+
+
+ Based on {debugData.estimatedProcessingTime.sampleCount} similar
+ validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
+
+ >
+ ) : (
+
+ No historical data available
- ))}
+ )}
-
- )}
-
- {/* API Format */}
- {debugData.apiFormat && (
-
-
API Format
-
- {JSON.stringify(debugData.apiFormat, null, 2)}
-
-
- )}
+
+
-
+
+ {/* Prompt Sources Badges - Only show if we have apiFormat */}
+ {debugData.apiFormat && (
+
+
+ Prompt Sources
+
+
+
+ scrollToSection('system-message')}
+ >
+ System
+
+ scrollToSection('general-section')}
+ >
+ General
+
+ {companyPrompts.map((cp, idx) => (
+ scrollToSection('company-section')}
+ >
+ {cp.companyName || `Company ${cp.company}`}
+
+ ))}
+ scrollToSection('taxonomy-section')}
+ >
+ Taxonomy
+
+ scrollToSection('product-section')}
+ >
+ Products
+
+
+
+
+ )}
+
+ {/* Prompt Content */}
+
+ {debugData.apiFormat ? (
+ // Render API format with parsed sections
+ debugData.apiFormat.map((message, idx) => (
+
+
+ Role: {message.role}
+
+
+
+ {message.role === 'user' ? (
+ // Parse user message into visual sections
+
+ {parseUserContent(message.content).map((segment, segIdx) => {
+ const style = SECTION_STYLES[segment.type as keyof typeof SECTION_STYLES] || SECTION_STYLES.general;
+ const sectionId = segment.type === 'general' ? 'general-section'
+ : segment.type === 'company' ? 'company-section'
+ : segment.type === 'taxonomy' ? 'taxonomy-section'
+ : segment.type === 'product' ? 'product-section'
+ : undefined;
+
+ const sectionLabel = segment.type === 'general' ? 'General Prompt'
+ : segment.type === 'company' ? 'Company-Specific Instructions'
+ : segment.type === 'taxonomy' ? 'Taxonomy Data'
+ : segment.type === 'product' ? 'Product Data'
+ : undefined;
+
+ if (segment.type === 'default') {
+ return (
+
+ {segment.content}
+
+ );
+ }
+
+ return (
+
+ {sectionLabel && (
+
+ {sectionLabel}
+
+ )}
+
+ {segment.content}
+
+
+ );
+ })}
+
+ ) : (
+ // System message - show as-is
+
+ {message.content}
+
+ )}
+
+
+ ))
+ ) : debugData.sampleFullPrompt ? (
+ // Fallback to sample full prompt
+
+ {debugData.sampleFullPrompt}
+
+ ) : (
+
+ No prompt data available
+
+ )}
+
+ >
) : (
No debug data available
diff --git a/inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationResults.tsx b/inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationResults.tsx
index 3c189fe..8ed000d 100644
--- a/inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationResults.tsx
+++ b/inventory/src/components/product-import/steps/ValidationStep/dialogs/AiValidationResults.tsx
@@ -1,10 +1,11 @@
/**
* AiValidationResultsDialog Component
*
- * Shows AI validation results and allows reverting changes.
+ * Shows AI validation results with detailed token usage, summary, and change management.
*/
import { useMemo } from 'react';
+import * as Diff from 'diff';
import {
Dialog,
DialogContent,
@@ -15,20 +16,192 @@ import {
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
-import { Check, Undo2, Sparkles } from 'lucide-react';
-import type { AiValidationResults } from '../store/types';
+import { Check, X, Sparkles, AlertTriangle, Info, Cpu, Brain } from 'lucide-react';
+import { Protected } from '@/components/auth/Protected';
+import type { AiValidationResults, AiTokenUsage, AiValidationChange } from '../store/types';
interface AiValidationResultsDialogProps {
results: AiValidationResults;
revertedChanges: Set
;
onRevert: (productIndex: number, fieldKey: string) => void;
+ onAccept?: (productIndex: number, fieldKey: string) => void;
onDismiss: () => void;
}
+/**
+ * Format token count for display
+ */
+const formatTokens = (value: number | null | undefined): string => {
+ if (value === null || value === undefined) return '-';
+ return value.toLocaleString();
+};
+
+/**
+ * Convert a value to string for diff comparison
+ */
+const valueToString = (value: unknown): string => {
+ if (value === null || value === undefined) return '';
+ if (Array.isArray(value)) return value.join(', ');
+ return String(value);
+};
+
+/**
+ * Display a diff between two values with highlighting
+ */
+const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: unknown }) => {
+ const originalStr = valueToString(original) || '(empty)';
+ const correctedStr = valueToString(corrected);
+
+ // If values are identical, just show the value
+ if (originalStr === correctedStr) {
+ return {correctedStr};
+ }
+
+ // Calculate word-level diff
+ const diffResult = Diff.diffWords(originalStr, correctedStr);
+
+ return (
+
+ {diffResult.map((part, index) => {
+ if (part.added) {
+ return (
+
+ {part.value}
+
+ );
+ }
+ if (part.removed) {
+ return (
+
+ {part.value}
+
+ );
+ }
+ return {part.value};
+ })}
+
+ );
+};
+
+/**
+ * Token Usage Display Component
+ */
+const TokenUsageDisplay = ({ tokenUsage }: { tokenUsage?: AiTokenUsage }) => {
+ if (!tokenUsage) return null;
+
+ const { prompt, completion, total, reasoning, cachedPrompt } = tokenUsage;
+
+ // Check if we have any data to show
+ const hasData = prompt !== null || completion !== null || total !== null;
+ if (!hasData) return null;
+
+ return (
+
+
+
+ Token Usage
+
+
+
+
{formatTokens(prompt)}
+
Prompt
+
+
+
{formatTokens(completion)}
+
Completion
+
+
+
{formatTokens(total)}
+
Total
+
+
+
{formatTokens(reasoning)}
+
Reasoning
+
+
+
{formatTokens(cachedPrompt)}
+
Cached
+
+
+
+ );
+};
+
+/**
+ * Summary and Warnings Display Component
+ */
+const SummaryDisplay = ({
+ summary,
+ warnings,
+ changesSummary,
+}: {
+ summary?: string;
+ warnings?: string[];
+ changesSummary?: string[];
+}) => {
+ if (!summary && (!warnings || warnings.length === 0) && (!changesSummary || changesSummary.length === 0)) {
+ return null;
+ }
+
+ return (
+
+ {/* Summary */}
+ {summary && (
+
+ )}
+
+ {/* Warnings */}
+ {warnings && warnings.length > 0 && (
+
+
+
+
+ {warnings.map((warning, index) => (
+
+ {warning}
+
+ ))}
+
+
+
+ )}
+
+ {/* AI-generated change summaries */}
+ {changesSummary && changesSummary.length > 0 && (
+
+
Changes Made
+
+ {changesSummary.slice(0, 5).map((change, index) => (
+ - {change}
+ ))}
+ {changesSummary.length > 5 && (
+ -
+ ...and {changesSummary.length - 5} more
+
+ )}
+
+
+ )}
+
+ );
+};
+
export const AiValidationResultsDialog = ({
results,
revertedChanges,
onRevert,
+ onAccept,
onDismiss,
}: AiValidationResultsDialogProps) => {
// Group changes by product
@@ -48,17 +221,34 @@ export const AiValidationResultsDialog = ({
return (