From 9ce84fe5b98e688104c1aca60a75f6188f860216 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 19 Jan 2026 01:02:20 -0500 Subject: [PATCH] Rewrite validation step part 3 --- inventory-server/src/routes/ai-validation.js | 86 +++- .../components/FloatingSelectionBar.tsx | 10 +- .../components/InitializingOverlay.tsx | 4 +- .../components/ValidationContainer.tsx | 1 + .../components/ValidationFooter.tsx | 65 ++- .../components/ValidationToolbar.tsx | 105 ++-- .../ValidationStep/dialogs/AiDebugDialog.tsx | 458 +++++++++++++++--- .../dialogs/AiValidationResults.tsx | 276 +++++++++-- .../hooks/useAiValidation/index.ts | 82 +++- .../hooks/useAiValidation/useAiApi.ts | 140 +++++- .../hooks/useAiValidation/useAiProgress.ts | 174 ++++++- .../hooks/useAiValidation/useAiTransform.ts | 87 +++- .../steps/ValidationStep/store/types.ts | 31 +- .../ValidationStep/store/validationStore.ts | 30 +- 14 files changed, 1310 insertions(+), 239 deletions(-) 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
{/* Divider */} @@ -212,7 +212,7 @@ export const FloatingSelectionBar = memo(() => { className="gap-2" > - Delete + Delete
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 && ( - + <> + + + + + + Are you sure? + + There are still {errorCount} validation error{errorCount !== 1 ? 's' : ''} in your data. + Are you sure you want to continue? + + + + + + + + + )}
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 ( !isOpen && onClose()}> - AI Validation Prompt + Current AI Prompt - Debug view of the prompt that will be sent to the AI for validation + This is the current prompt that would be sent to the AI for validation @@ -45,74 +207,240 @@ export const AiDebugDialog = ({
) : 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 && ( +
+
+ +
{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 ( onDismiss()}> - + AI Validation Complete + {/* Model and reasoning effort badges - admin only */} + +
+ {results.model && ( + + + {results.model} + + )} + {results.reasoningEffort && ( + + + {results.reasoningEffort} + + )} +
+
-
- {/* Summary */} -
+
+ {/* Stats Summary */} +
{results.totalProducts}
Products
@@ -73,10 +263,22 @@ export const AiValidationResultsDialog = ({
{(results.processingTime / 1000).toFixed(1)}s
-
Processing Time
+
Time
+ {/* Token Usage - Admin only */} + + + + + {/* Summary, Warnings, and Change Summaries */} + + {/* Changes list */} {results.changes.length === 0 ? (
@@ -84,7 +286,7 @@ export const AiValidationResultsDialog = ({

No corrections needed - all data looks good!

) : ( - +
{Array.from(changesByProduct.entries()).map(([productIndex, changes]) => (
@@ -101,30 +303,46 @@ export const AiValidationResultsDialog = ({ isReverted ? 'bg-muted opacity-50' : 'bg-primary/5' }`} > -
+
{change.fieldKey}
-
- - {String(change.originalValue || '(empty)')} - - - - {String(change.correctedValue)} - +
+
- {isReverted ? ( - Reverted - ) : ( + {/* Accept/Reject toggle buttons */} +
- )} + +
); })} @@ -134,12 +352,6 @@ export const AiValidationResultsDialog = ({
)} - - {results.tokenUsage && ( -
- Token usage: {results.tokenUsage.input} input, {results.tokenUsage.output} output -
- )}
diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/index.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/index.ts index c5e91fb..999b683 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/index.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/index.ts @@ -14,9 +14,11 @@ import { useValidationStore } from '../../store/validationStore'; import { useAiValidation, useIsAiValidating } from '../../store/selectors'; import { useAiProgress } from './useAiProgress'; import { useAiTransform } from './useAiTransform'; +import { useValidationActions } from '../useValidationActions'; import { runAiValidation, getAiDebugPrompt, + getAiTimeEstimate, prepareProductsForAi, extractAiSupplementalColumns, type AiDebugPromptResponse, @@ -40,9 +42,11 @@ export const useAiValidationFlow = () => { // Sub-hooks const { startProgress, updateProgress, completeProgress, setError, clearProgress } = useAiProgress(); const { applyAiChanges, buildResults, saveResults } = useAiTransform(); + const { validateAllRows } = useValidationActions(); // Store actions const revertAiChange = useValidationStore((state) => state.revertAiChange); + const acceptAiChange = useValidationStore((state) => state.acceptAiChange); const clearAiValidation = useValidationStore((state) => state.clearAiValidation); // Local state for debug prompt preview @@ -70,14 +74,25 @@ export const useAiValidationFlow = () => { const startTime = Date.now(); try { - // Start progress tracking - startProgress(rows.length); - - // Prepare data for API - updateProgress(0, rows.length, 'preparing', 'Preparing data...'); + // Prepare data for API first (needed for time estimate) const products = prepareProductsForAi(rows, fields); const aiSupplementalColumns = extractAiSupplementalColumns(rows); + // Fetch time estimate from server before starting + // This provides accurate "time remaining" based on historical data + const { estimatedSeconds, promptLength } = await getAiTimeEstimate( + products, + aiSupplementalColumns + ); + + console.log('AI validation time estimate:', { estimatedSeconds, promptLength }); + + // Start progress tracking with server estimate + startProgress(rows.length, estimatedSeconds ?? undefined, promptLength ?? undefined); + + // Update status + updateProgress(0, rows.length, 'preparing', 'Preparing data...'); + // Call AI validation API updateProgress(0, rows.length, 'validating', 'Running AI validation...'); const response = await runAiValidation({ @@ -85,23 +100,54 @@ export const useAiValidationFlow = () => { aiSupplementalColumns, }); - if (!response.success || !response.results) { + if (!response.success) { throw new Error(response.error || 'AI validation failed'); } // Process results updateProgress(rows.length, rows.length, 'processing', 'Processing results...'); - const changes: AiValidationChange[] = response.results.changes || []; + // Handle both response formats: + // - Nested: response.results.products, response.results.changes + // - Top-level: response.correctedData, response.changeDetails + const aiProducts = response.results?.products || response.correctedData || []; + const rawChanges = response.results?.changes || response.changeDetails || []; + + // Normalize changes to AiValidationChange format + const changes: AiValidationChange[] = rawChanges.map((change: any) => ({ + productIndex: change.productIndex, + fieldKey: change.fieldKey, + originalValue: change.originalValue, + correctedValue: change.correctedValue, + confidence: change.confidence, + })); // Apply changes to rows if (changes.length > 0) { - applyAiChanges(response.results.products, changes); + applyAiChanges(aiProducts, changes); } - // Build and save results + // Revalidate all rows after AI changes to update error state + // This ensures validation errors are refreshed based on the new data + updateProgress(rows.length, rows.length, 'processing', 'Revalidating fields...'); + await validateAllRows(); + + // Build and save results with all metadata const processingTime = Date.now() - startTime; - const results = buildResults(changes, response.results.tokenUsage, processingTime); + + // Extract token usage from response - could be in results or top-level or performanceMetrics + const tokenUsageSource = + response.results?.tokenUsage || + response.tokenUsage || + response.performanceMetrics?.tokenUsage; + + const results = buildResults(changes, tokenUsageSource, processingTime, { + model: response.model || response.performanceMetrics?.model, + reasoningEffort: response.reasoningEffort || response.performanceMetrics?.reasoningEffort, + summary: response.summary, + warnings: response.warnings, + changesSummary: response.changes, // Top-level changes array is human-readable summaries + }); saveResults(results); // Complete progress @@ -128,6 +174,7 @@ export const useAiValidationFlow = () => { applyAiChanges, buildResults, saveResults, + validateAllRows, ]); /** @@ -141,6 +188,17 @@ export const useAiValidationFlow = () => { [revertAiChange] ); + /** + * Accept (re-apply) a previously reverted AI change + */ + const acceptChange = useCallback( + (productIndex: number, fieldKey: string) => { + acceptAiChange(productIndex, fieldKey); + toast.success('Change accepted'); + }, + [acceptAiChange] + ); + /** * Dismiss AI validation results */ @@ -157,7 +215,8 @@ export const useAiValidationFlow = () => { const products = prepareProductsForAi(rows, fields); const aiSupplementalColumns = extractAiSupplementalColumns(rows); - const prompt = await getAiDebugPrompt(products, aiSupplementalColumns); + // Use previewOnly to only send first 5 products for the preview dialog + const prompt = await getAiDebugPrompt(products, aiSupplementalColumns, { previewOnly: true }); if (prompt) { setDebugPrompt(prompt); setShowDebugDialog(true); @@ -196,6 +255,7 @@ export const useAiValidationFlow = () => { // Actions validate, revertChange, + acceptChange, dismissResults, cancel, showPromptPreview, diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiApi.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiApi.ts index 54e8b8d..3922320 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiApi.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiApi.ts @@ -7,6 +7,7 @@ import config from '@/config'; import type { RowData } from '../../store/types'; import type { Field } from '../../../../types'; +import { prepareDataForAiValidation } from '../../utils/aiValidationUtils'; export interface AiValidationRequest { products: Record[]; @@ -17,6 +18,17 @@ export interface AiValidationRequest { }; } +/** + * Token usage from AI validation with all metrics + */ +export interface AiTokenUsage { + prompt: number | null; + completion: number | null; + total: number | null; + reasoning?: number | null; + cachedPrompt?: number | null; +} + export interface AiValidationResponse { success: boolean; results?: { @@ -28,41 +40,96 @@ export interface AiValidationResponse { correctedValue: unknown; confidence?: number; }>; - tokenUsage?: { - input: number; - output: number; - }; + tokenUsage?: AiTokenUsage; + }; + // Top-level fields from AI response + correctedData?: Record[]; + changes?: string[]; // Human-readable change summaries + changeDetails?: Array<{ + productIndex: number; + fieldKey: string; + originalValue: unknown; + correctedValue: unknown; + confidence?: number; + }>; + warnings?: string[]; + summary?: string; + model?: string; + reasoningEffort?: string; + tokenUsage?: AiTokenUsage; + performanceMetrics?: { + model?: string; + reasoningEffort?: string; + tokenUsage?: AiTokenUsage; + processingTimeSeconds?: number; + promptLength?: number; }; error?: string; } +export interface TaxonomyStats { + categories: number; + themes: number; + colors: number; + taxCodes: number; + sizeCategories: number; + suppliers: number; + companies: number; + artists: number; +} + export interface AiDebugPromptResponse { - prompt: string; - systemPrompt: string; - estimatedTokens: number; + prompt?: string; + basePrompt?: string; + sampleFullPrompt?: string; + promptLength?: number; + estimatedTokens?: number; + taxonomyStats?: TaxonomyStats; + apiFormat?: Array<{ role: string; content: string }>; + promptSources?: { + systemPrompt?: { id: number; prompt_text: string }; + generalPrompt?: { id: number; prompt_text: string }; + companyPrompts?: Array<{ + id: number; + company: string; + companyName?: string; + prompt_text: string; + }>; + }; + estimatedProcessingTime?: { + seconds: number | null; + sampleCount: number; + }; } /** * Prepare products data for AI validation + * + * Uses the shared utility function to prepare data, ensuring all fields are present + * and converting undefined values to empty strings. Also adds index tracking and + * handles AI supplemental columns. */ export const prepareProductsForAi = ( rows: RowData[], fields: Field[] ): Record[] => { - return rows.map((row, index) => { - const product: Record = { - _index: index, // Track original index for applying changes - }; + // Add __index metadata for tracking + const rowsWithIndex = rows.map((row, index) => ({ + ...row, + __index: index, + })); - // Include all field values - fields.forEach((field) => { - const value = row[field.key]; - if (value !== undefined && value !== null && value !== '') { - product[field.key] = value; - } - }); + // Use the shared utility function for base preparation + const prepared = prepareDataForAiValidation(rowsWithIndex, fields as any); - // Include AI supplemental columns if present + // Add supplemental columns with different naming to distinguish from regular fields + return prepared.map((product, index) => { + const row = rows[index]; + + // Add _index for change tracking + product._index = index; + + // Add supplemental columns as _supplemental_ prefixed keys if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) { row.__aiSupplemental.forEach((col) => { if (row[col] !== undefined) { @@ -119,18 +186,26 @@ export const runAiValidation = async ( }; /** - * Get AI debug prompt (for preview) + * Get AI debug prompt (for preview or time estimation) + * @param products - Products to include in the prompt + * @param aiSupplementalColumns - Supplemental columns to include + * @param options.previewOnly - If true, only send first 5 products for preview */ export const getAiDebugPrompt = async ( products: Record[], - aiSupplementalColumns: string[] + aiSupplementalColumns: string[], + options?: { previewOnly?: boolean } ): Promise => { try { + // For preview dialog, only send first 5 products + // For time estimation before validation, send all products + const productsToSend = options?.previewOnly ? products.slice(0, 5) : products; + const response = await fetch(`${config.apiUrl}/ai-validation/debug`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - products: products.slice(0, 5), // Only send first 5 for preview + products: productsToSend, aiSupplementalColumns, }), }); @@ -145,3 +220,24 @@ export const getAiDebugPrompt = async ( return null; } }; + +/** + * Get time estimation for AI validation + * Fetches from debug endpoint with all products to get accurate estimate + */ +export const getAiTimeEstimate = async ( + products: Record[], + aiSupplementalColumns: string[] +): Promise<{ estimatedSeconds: number | null; promptLength: number | null }> => { + try { + const debugData = await getAiDebugPrompt(products, aiSupplementalColumns, { previewOnly: false }); + + return { + estimatedSeconds: debugData?.estimatedProcessingTime?.seconds ?? null, + promptLength: debugData?.promptLength ?? null, + }; + } catch (error) { + console.error('Error getting time estimate:', error); + return { estimatedSeconds: null, promptLength: null }; + } +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiProgress.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiProgress.ts index d5e68d1..d3a0992 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiProgress.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiProgress.ts @@ -2,15 +2,16 @@ * useAiProgress - AI Validation Progress Tracking * * Manages progress state and time estimation for AI validation. + * Supports server-based time estimation and live timer updates. */ import { useCallback, useRef, useEffect } from 'react'; import { useValidationStore } from '../../store/validationStore'; import type { AiValidationProgress } from '../../store/types'; -// Average time per product (based on historical data) +// Fallback estimate when server doesn't provide one const AVG_MS_PER_PRODUCT = 150; -const MIN_ESTIMATED_TIME = 2000; // Minimum 2 seconds +const MIN_ESTIMATED_TIME_MS = 2000; // Minimum 2 seconds /** * Hook for managing AI validation progress @@ -21,13 +22,94 @@ export const useAiProgress = () => { const timerRef = useRef(null); const startTimeRef = useRef(0); + const estimatedSecondsRef = useRef(null); + const promptLengthRef = useRef(null); + + /** + * Calculate progress percentage based on elapsed time and estimate + */ + const calculateProgress = useCallback((elapsedMs: number): number => { + if (estimatedSecondsRef.current && estimatedSecondsRef.current > 0) { + const estimatedMs = estimatedSecondsRef.current * 1000; + // Cap at 95% until complete + return Math.min(95, (elapsedMs / estimatedMs) * 100); + } + // Fallback: slow progress without estimate + return Math.min(95, (elapsedMs / 30000) * 50); + }, []); + + /** + * Start the live progress timer + */ + const startTimer = useCallback(() => { + // Clear any existing timer + if (timerRef.current) { + clearInterval(timerRef.current); + } + + timerRef.current = setInterval(() => { + const elapsedMs = Date.now() - startTimeRef.current; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const progressPercent = calculateProgress(elapsedMs); + + // Calculate remaining time + let estimatedTimeRemaining: number | undefined; + if (estimatedSecondsRef.current && estimatedSecondsRef.current > 0) { + const remainingSeconds = Math.max(0, estimatedSecondsRef.current - elapsedSeconds); + estimatedTimeRemaining = remainingSeconds * 1000; + } + + // Update progress state with new elapsed time + setAiValidationProgress((prev) => { + if (!prev || prev.status === 'complete' || prev.status === 'error') { + return prev; + } + return { + ...prev, + elapsedSeconds, + progressPercent, + estimatedTimeRemaining, + }; + }); + }, 1000); + }, [calculateProgress, setAiValidationProgress]); + + /** + * Set the server-provided time estimate + */ + const setEstimate = useCallback( + (estimatedSeconds: number | null, promptLength: number | null) => { + estimatedSecondsRef.current = estimatedSeconds; + promptLengthRef.current = promptLength; + + // Update progress state with estimate + setAiValidationProgress((prev) => { + if (!prev) return prev; + return { + ...prev, + estimatedTotalSeconds: estimatedSeconds ?? undefined, + promptLength: promptLength ?? undefined, + estimatedTimeRemaining: estimatedSeconds ? estimatedSeconds * 1000 : prev.estimatedTimeRemaining, + }; + }); + }, + [setAiValidationProgress] + ); /** * Start progress tracking */ const startProgress = useCallback( - (totalProducts: number) => { + (totalProducts: number, estimatedSeconds?: number, promptLength?: number) => { startTimeRef.current = Date.now(); + estimatedSecondsRef.current = estimatedSeconds ?? null; + promptLengthRef.current = promptLength ?? null; + + // Calculate fallback estimate if server didn't provide one + const fallbackEstimateMs = Math.max( + totalProducts * AVG_MS_PER_PRODUCT, + MIN_ESTIMATED_TIME_MS + ); const initialProgress: AiValidationProgress = { current: 0, @@ -35,53 +117,81 @@ export const useAiProgress = () => { status: 'preparing', message: 'Preparing data for AI validation...', startTime: startTimeRef.current, - estimatedTimeRemaining: Math.max( - totalProducts * AVG_MS_PER_PRODUCT, - MIN_ESTIMATED_TIME - ), + estimatedTimeRemaining: estimatedSeconds + ? estimatedSeconds * 1000 + : fallbackEstimateMs, + estimatedTotalSeconds: estimatedSeconds, + promptLength: promptLength, + elapsedSeconds: 0, + progressPercent: 0, }; setAiValidationRunning(true); setAiValidationProgress(initialProgress); + + // Start the live timer + startTimer(); }, - [setAiValidationProgress, setAiValidationRunning] + [setAiValidationProgress, setAiValidationRunning, startTimer] ); /** - * Update progress + * Update progress status and message */ const updateProgress = useCallback( (current: number, total: number, status: AiValidationProgress['status'], message?: string) => { - const elapsed = Date.now() - startTimeRef.current; - const rate = current > 0 ? elapsed / current : AVG_MS_PER_PRODUCT; - const remaining = (total - current) * rate; + const elapsedMs = Date.now() - startTimeRef.current; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const progressPercent = calculateProgress(elapsedMs); - setAiValidationProgress({ + // Calculate remaining time + let estimatedTimeRemaining: number | undefined; + if (estimatedSecondsRef.current && estimatedSecondsRef.current > 0) { + const remainingSeconds = Math.max(0, estimatedSecondsRef.current - elapsedSeconds); + estimatedTimeRemaining = remainingSeconds * 1000; + } + + setAiValidationProgress((prev) => ({ current, total, status, message, startTime: startTimeRef.current, - estimatedTimeRemaining: Math.max(remaining, 0), - }); + estimatedTimeRemaining, + estimatedTotalSeconds: estimatedSecondsRef.current ?? prev?.estimatedTotalSeconds, + promptLength: promptLengthRef.current ?? prev?.promptLength, + elapsedSeconds, + progressPercent, + })); }, - [setAiValidationProgress] + [calculateProgress, setAiValidationProgress] ); /** * Complete progress */ const completeProgress = useCallback(() => { - const elapsed = Date.now() - startTimeRef.current; + // Stop the timer + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } - setAiValidationProgress({ - current: 1, - total: 1, + const elapsedMs = Date.now() - startTimeRef.current; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + + setAiValidationProgress((prev) => ({ + current: prev?.total ?? 1, + total: prev?.total ?? 1, status: 'complete', - message: `Validation complete in ${(elapsed / 1000).toFixed(1)}s`, + message: `Validation complete in ${(elapsedMs / 1000).toFixed(1)}s`, startTime: startTimeRef.current, estimatedTimeRemaining: 0, - }); + estimatedTotalSeconds: estimatedSecondsRef.current ?? undefined, + promptLength: promptLengthRef.current ?? undefined, + elapsedSeconds, + progressPercent: 100, + })); }, [setAiValidationProgress]); /** @@ -89,14 +199,25 @@ export const useAiProgress = () => { */ const setError = useCallback( (message: string) => { - setAiValidationProgress({ + // Stop the timer + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + + const elapsedMs = Date.now() - startTimeRef.current; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + + setAiValidationProgress((prev) => ({ current: 0, - total: 0, + total: prev?.total ?? 0, status: 'error', message, startTime: startTimeRef.current, estimatedTimeRemaining: 0, - }); + elapsedSeconds, + progressPercent: prev?.progressPercent, + })); }, [setAiValidationProgress] ); @@ -109,6 +230,8 @@ export const useAiProgress = () => { clearInterval(timerRef.current); timerRef.current = null; } + estimatedSecondsRef.current = null; + promptLengthRef.current = null; setAiValidationProgress(null); setAiValidationRunning(false); }, [setAiValidationProgress, setAiValidationRunning]); @@ -128,5 +251,6 @@ export const useAiProgress = () => { completeProgress, setError, clearProgress, + setEstimate, }; }; diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiTransform.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiTransform.ts index 57645f8..1bb8d11 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiTransform.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/useAiTransform.ts @@ -10,8 +10,74 @@ import { useCallback } from 'react'; import { useValidationStore } from '../../store/validationStore'; -import type { AiValidationChange, AiValidationResults } from '../../store/types'; +import type { AiValidationChange, AiValidationResults, AiTokenUsage } from '../../store/types'; import type { Field, SelectOption } from '../../../../types'; +import type { AiValidationResponse, AiTokenUsage as ApiTokenUsage } from './useAiApi'; + +/** + * Helper to convert a value to number or null + */ +const toNumberOrNull = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +/** + * Normalize token usage from various API response formats + * Different AI providers return token usage in different formats + */ +export const normalizeTokenUsage = (usageSource: unknown): AiTokenUsage | undefined => { + if (!usageSource || typeof usageSource !== 'object') return undefined; + + const source = usageSource as Record; + + // Handle various naming conventions from different APIs + const promptTokensRaw = + source.prompt ?? source.promptTokens ?? source.prompt_tokens ?? source.input; + const completionTokensRaw = + source.completion ?? source.completionTokens ?? source.completion_tokens ?? source.output; + const totalTokensRaw = + source.total ?? source.totalTokens ?? source.total_tokens ?? source.tokenTotal; + const reasoningTokensRaw = + source.reasoning ?? source.reasoningTokens ?? source.reasoning_tokens; + const cachedPromptRaw = + source.cachedPrompt ?? source.cachedTokens ?? source.cached_prompt ?? source.cached_tokens ?? source.cached; + + const prompt = toNumberOrNull(promptTokensRaw); + const completion = toNumberOrNull(completionTokensRaw); + let total = toNumberOrNull(totalTokensRaw); + + // Calculate total if not provided + if (total === null && prompt !== null && completion !== null) { + total = prompt + completion; + } + + const reasoning = toNumberOrNull(reasoningTokensRaw); + const cachedPrompt = toNumberOrNull(cachedPromptRaw); + + // Only return if we have at least some data + if (prompt !== null || completion !== null || total !== null || reasoning !== null || cachedPrompt !== null) { + return { prompt, completion, total, reasoning, cachedPrompt }; + } + + return undefined; +}; + +/** + * Normalize reasoning effort level + */ +const normalizeReasoningEffort = (value: unknown): string | undefined => { + if (typeof value !== 'string') return undefined; + const normalized = value.toLowerCase(); + if (['minimal', 'low', 'medium', 'high'].includes(normalized)) { + return normalized.charAt(0).toUpperCase() + normalized.slice(1); + } + return undefined; +}; /** * Coerce a value to match the expected field type @@ -131,18 +197,33 @@ export const useAiTransform = () => { const buildResults = useCallback( ( changes: AiValidationChange[], - tokenUsage: { input: number; output: number } | undefined, - processingTime: number + rawTokenUsage: unknown, + processingTime: number, + metadata?: { + model?: string; + reasoningEffort?: string; + summary?: string; + warnings?: string[]; + changesSummary?: string[]; + } ): AiValidationResults => { const { rows } = useValidationStore.getState(); const affectedProducts = new Set(changes.map((c) => c.productIndex)); + // Normalize token usage from various formats + const tokenUsage = normalizeTokenUsage(rawTokenUsage); + return { totalProducts: rows.length, productsWithChanges: affectedProducts.size, changes, tokenUsage, processingTime, + model: metadata?.model, + reasoningEffort: normalizeReasoningEffort(metadata?.reasoningEffort), + summary: metadata?.summary, + warnings: metadata?.warnings, + changesSummary: metadata?.changesSummary, }; }, [] diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts index cbe446c..7f41b34 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -172,6 +172,10 @@ export interface AiValidationProgress { message?: string; startTime: number; estimatedTimeRemaining?: number; + estimatedTotalSeconds?: number; // Server-provided estimate + promptLength?: number; // For cost calculation + elapsedSeconds?: number; // Tracked by timer + progressPercent?: number; // Calculated progress } export interface AiValidationChange { @@ -182,15 +186,29 @@ export interface AiValidationChange { confidence?: number; } +/** + * Token usage with all available metrics + */ +export interface AiTokenUsage { + prompt: number | null; + completion: number | null; + total: number | null; + reasoning?: number | null; + cachedPrompt?: number | null; +} + export interface AiValidationResults { totalProducts: number; productsWithChanges: number; changes: AiValidationChange[]; - tokenUsage?: { - input: number; - output: number; - }; + tokenUsage?: AiTokenUsage; processingTime: number; + // Additional metadata from AI response + model?: string; + reasoningEffort?: string; + summary?: string; + warnings?: string[]; + changesSummary?: string[]; // High-level change descriptions } export interface AiValidationState { @@ -369,9 +387,12 @@ export interface ValidationActions { // === AI Validation === setAiValidationRunning: (running: boolean) => void; - setAiValidationProgress: (progress: AiValidationProgress | null) => void; + setAiValidationProgress: ( + progress: AiValidationProgress | null | ((prev: AiValidationProgress | null) => AiValidationProgress | null) + ) => void; setAiValidationResults: (results: AiValidationResults | null) => void; revertAiChange: (productIndex: number, fieldKey: string) => void; + acceptAiChange: (productIndex: number, fieldKey: string) => void; clearAiValidation: () => void; storeOriginalValues: () => void; diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index 33ea360..10c67d6 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -651,9 +651,16 @@ export const useValidationStore = create()( }); }, - setAiValidationProgress: (progress: AiValidationProgress | null) => { + setAiValidationProgress: ( + progressOrUpdater: AiValidationProgress | null | ((prev: AiValidationProgress | null) => AiValidationProgress | null) + ) => { set((state) => { - state.aiValidation.progress = progress; + if (typeof progressOrUpdater === 'function') { + // Support callback pattern for updates based on previous state + state.aiValidation.progress = progressOrUpdater(state.aiValidation.progress); + } else { + state.aiValidation.progress = progressOrUpdater; + } }); }, @@ -681,6 +688,25 @@ export const useValidationStore = create()( }); }, + acceptAiChange: (productIndex: number, fieldKey: string) => { + set((state) => { + const key = `${productIndex}:${fieldKey}`; + const row = state.rows[productIndex]; + + if (row && row.__corrected && fieldKey in row.__corrected) { + // Re-apply the corrected value + row[fieldKey] = row.__corrected[fieldKey]; + // Remove from reverted set + state.aiValidation.revertedChanges.delete(key); + // Re-mark as changed + if (!row.__changes) { + row.__changes = {}; + } + row.__changes[fieldKey] = true; + } + }); + }, + clearAiValidation: () => { set((state) => { state.aiValidation = {