Rewrite validation step part 3

This commit is contained in:
2026-01-19 01:02:20 -05:00
parent d15360a7d4
commit 9ce84fe5b9
14 changed files with 1310 additions and 239 deletions

View File

@@ -960,7 +960,7 @@ router.post("/validate", async (req, res) => {
// - max_output_tokens: 20000 ensures space for large product batches // - max_output_tokens: 20000 ensures space for large product batches
// Note: Responses API is the recommended endpoint for GPT-5 models // Note: Responses API is the recommended endpoint for GPT-5 models
const completion = await createResponsesCompletion({ const completion = await createResponsesCompletion({
model: "gpt-5", model: "gpt-5.2",
input: [ input: [
{ {
role: "developer", role: "developer",
@@ -978,7 +978,7 @@ router.post("/validate", async (req, res) => {
verbosity: "medium", verbosity: "medium",
format: AI_VALIDATION_TEXT_FORMAT, format: AI_VALIDATION_TEXT_FORMAT,
}, },
max_output_tokens: 20000, max_output_tokens: 50000,
}); });
console.log("✅ Received response from OpenAI Responses API"); console.log("✅ Received response from OpenAI Responses API");
@@ -1480,6 +1480,7 @@ function normalizeJsonResponse(text) {
if (!text || typeof text !== 'string') return text; if (!text || typeof text !== 'string') return text;
let cleaned = text.trim(); let cleaned = text.trim();
// Remove markdown code fences if present
if (cleaned.startsWith('```')) { if (cleaned.startsWith('```')) {
const firstLineBreak = cleaned.indexOf('\n'); const firstLineBreak = cleaned.indexOf('\n');
if (firstLineBreak !== -1) { if (firstLineBreak !== -1) {
@@ -1496,5 +1497,86 @@ function normalizeJsonResponse(text) {
cleaned = cleaned.trim(); 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; 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;
}
}

View File

@@ -148,11 +148,11 @@ export const FloatingSelectionBar = memo(() => {
return ( return (
<> <>
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200"> <div className="fixed bottom-[12rem] md:bottom-20 left-1/2 -translate-x-1/2 z-50 animate-in fade-in slide-in-from-bottom-4 duration-200">
<div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4"> <div className="bg-background border border-border rounded-xl shadow-xl px-4 py-3 flex items-center gap-4">
{/* Selection count badge */} {/* Selection count badge */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md"> <div className="bg-primary text-primary-foreground text-sm font-medium px-2.5 py-1 rounded-md whitespace-nowrap">
{selectedCount} selected {selectedCount} selected
</div> </div>
<Button <Button
@@ -171,7 +171,7 @@ export const FloatingSelectionBar = memo(() => {
{/* Apply template to selected */} {/* Apply template to selected */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Apply template:</span> <span className="text-sm text-muted-foreground whitespace-nowrap">Apply <span className="hidden md:inline">template:</span></span>
<SearchableTemplateSelect <SearchableTemplateSelect
templates={templates} templates={templates}
value="" value=""
@@ -196,7 +196,7 @@ export const FloatingSelectionBar = memo(() => {
className="gap-2" className="gap-2"
> >
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Save as Template <span className="hidden md:inline">Save as Template</span>
</Button> </Button>
{/* Divider */} {/* Divider */}
@@ -212,7 +212,7 @@ export const FloatingSelectionBar = memo(() => {
className="gap-2" className="gap-2"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete <span className="hidden md:inline">Delete</span>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -18,8 +18,8 @@ const phaseMessages: Record<InitPhase, string> = {
idle: 'Preparing...', idle: 'Preparing...',
'loading-options': 'Loading field options...', 'loading-options': 'Loading field options...',
'loading-templates': 'Loading templates...', 'loading-templates': 'Loading templates...',
'validating-upcs': 'Validating UPC codes...', 'validating-upcs': 'Checking UPCs and creating item numbers...',
'validating-fields': 'Running field validation...', 'validating-fields': 'Checking for field errors...',
ready: 'Ready', ready: 'Ready',
}; };

View File

@@ -136,6 +136,7 @@ export const ValidationContainer = ({
results={aiValidation.results} results={aiValidation.results}
revertedChanges={aiValidation.revertedChanges} revertedChanges={aiValidation.revertedChanges}
onRevert={aiValidation.revertChange} onRevert={aiValidation.revertChange}
onAccept={aiValidation.acceptChange}
onDismiss={aiValidation.dismissResults} onDismiss={aiValidation.dismissResults}
/> />
)} )}

View File

@@ -4,9 +4,11 @@
* Navigation footer with back/next buttons, AI validate, and summary info. * Navigation footer with back/next buttons, AI validate, and summary info.
*/ */
import { useState } from 'react';
import { Button } from '@/components/ui/button'; 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 { Protected } from '@/components/auth/Protected';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
interface ValidationFooterProps { interface ValidationFooterProps {
onBack?: () => void; onBack?: () => void;
@@ -31,13 +33,14 @@ export const ValidationFooter = ({
isAiValidating = false, isAiValidating = false,
onShowDebug, onShowDebug,
}: ValidationFooterProps) => { }: ValidationFooterProps) => {
const [showErrorDialog, setShowErrorDialog] = useState(false);
return ( return (
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4"> <div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
{/* Back button */} {/* Back button */}
<div> <div>
{canGoBack && onBack && ( {canGoBack && onBack && (
<Button variant="outline" onClick={onBack}> <Button variant="outline" onClick={onBack}>
<ChevronLeft className="h-4 w-4 mr-1" />
Back Back
</Button> </Button>
)} )}
@@ -85,18 +88,52 @@ export const ValidationFooter = ({
{/* Next button */} {/* Next button */}
{onNext && ( {onNext && (
<Button <>
onClick={onNext} <Button
disabled={!canProceed} onClick={() => {
title={ if (canProceed) {
!canProceed onNext();
? `Fix ${errorCount} validation errors before proceeding` } else {
: 'Continue to image upload' setShowErrorDialog(true);
} }
> }}
Continue to Images title={
<ChevronRight className="h-4 w-4 ml-1" /> !canProceed
</Button> ? `There are ${errorCount} validation errors`
: 'Continue to image upload'
}
>
Next
</Button>
<Dialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="pb-3">Are you sure?</DialogTitle>
<DialogDescription>
There are still {errorCount} validation error{errorCount !== 1 ? 's' : ''} in your data.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowErrorDialog(false)}
>
Cancel
</Button>
<Button
onClick={() => {
setShowErrorDialog(false);
onNext();
}}
>
Continue Anyway
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)} )}
</div> </div>
</div> </div>

View File

@@ -105,75 +105,78 @@ export const ValidationToolbar = ({
return ( return (
<div className="border-b bg-background px-4 py-3"> <div className="border-b bg-background px-4 py-3">
{/* Top row: Search and stats */} {/* Top row: Search, product count, and action buttons */}
<div className="flex items-center gap-4 mb-3"> <div className="flex items-center gap-4 mb-3">
{/* Search */} {/* Search */}
<div className="relative flex-1 max-w-sm"> <div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search products..." placeholder="Filter products..."
value={filters.searchText} value={filters.searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="pl-9" className="pl-9"
/> />
</div> </div>
{/* Error filter toggle */} {/* Product count */}
<div className="flex items-center gap-2"> <span className="text-sm text-muted-foreground">{rowCount} products</span>
<Switch
id="show-errors" {/* Action buttons */}
checked={filters.showErrorsOnly} <div className="flex items-center gap-2 ml-auto">
onCheckedChange={setShowErrorsOnly} {/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" />
Add Blank Row
</Button>
{/* Create template from existing product */}
<Button
variant="outline"
size="sm"
onClick={() => setIsSearchDialogOpen(true)}
>
<Edit3 className="h-4 w-4 mr-1" />
New Template
</Button>
{/* Create product line/subline */}
<CreateProductCategoryDialog
trigger={
<Button variant="outline" size="sm">
<FolderPlus className="h-4 w-4 mr-1" />
New Line/Subline
</Button>
}
companies={companyOptions}
onCreated={handleCategoryCreated}
/> />
<Label htmlFor="show-errors" className="text-sm cursor-pointer">
Show errors only
</Label>
</div> </div>
</div>
{/* Bottom row: Error badge and filter toggle */}
{errorCount > 0 && (
<div className="flex items-center justify-center gap-3 text-sm text-muted-foreground border border-destructive rounded-md p-2 bg-destructive/10">
<div className="flex items-center gap-12">
{/* Stats */}
<div className="flex items-center gap-3 text-sm text-muted-foreground ml-auto">
<span>{rowCount} products</span>
{errorCount > 0 && (
<Badge variant="destructive"> <Badge variant="destructive">
{errorCount} errors in {rowsWithErrors} rows {errorCount} issues in {rowsWithErrors} rows
</Badge> </Badge>
)}
{selectedRowCount > 0 && ( {/* Error filter toggle */}
<Badge variant="secondary">{selectedRowCount} selected</Badge> <div className="flex items-center gap-2">
)} <Switch
id="show-errors"
checked={filters.showErrorsOnly}
onCheckedChange={setShowErrorsOnly}
className="data-[state=checked]:bg-destructive data-[state=unchecked]:bg-destructive/20"
/>
<Label htmlFor="show-errors" className="text-sm cursor-pointer text-destructive">
Show errors only
</Label>
</div>
</div> </div>
</div> </div>
)}
{/* Bottom row: Actions */}
<div className="flex items-center gap-2">
{/* Add row */}
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
<Plus className="h-4 w-4 mr-1" />
Add Row
</Button>
{/* Create template from existing product */}
<Button
variant="outline"
size="sm"
onClick={() => setIsSearchDialogOpen(true)}
>
<Edit3 className="h-4 w-4 mr-1" />
New Template
</Button>
{/* Create product line/subline */}
<CreateProductCategoryDialog
trigger={
<Button variant="outline" size="sm">
<FolderPlus className="h-4 w-4 mr-1" />
New Line/Subline
</Button>
}
companies={companyOptions}
onCreated={handleCategoryCreated}
/>
</div>
{/* Product Search Template Dialog */} {/* Product Search Template Dialog */}
<SearchProductTemplateDialog <SearchProductTemplateDialog

View File

@@ -1,10 +1,16 @@
/** /**
* AiDebugDialog Component * AiDebugDialog Component
* *
* Shows the AI validation prompt for debugging purposes. * Shows the AI validation prompt for debugging purposes with:
* - Cost estimate with configurable cost per million tokens
* - Processing time estimation
* - Prompt sources display with colored badges
* - Content parsed by markers into visual sections
*
* Only visible to users with admin:debug permission. * Only visible to users with admin:debug permission.
*/ */
import { useState, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -13,6 +19,9 @@ import {
DialogDescription, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import type { AiDebugPromptResponse } from '../hooks/useAiValidation'; import type { AiDebugPromptResponse } from '../hooks/useAiValidation';
@@ -23,19 +32,172 @@ interface AiDebugDialogProps {
isLoading?: boolean; isLoading?: boolean;
} }
/**
* Format estimated time for display
*/
const formatTime = (seconds: number | null | undefined): string => {
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 = ({ export const AiDebugDialog = ({
open, open,
onClose, onClose,
debugData, debugData,
isLoading = false, isLoading = false,
}: AiDebugDialogProps) => { }: 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 ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader> <DialogHeader>
<DialogTitle>AI Validation Prompt</DialogTitle> <DialogTitle>Current AI Prompt</DialogTitle>
<DialogDescription> <DialogDescription>
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
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -45,74 +207,240 @@ export const AiDebugDialog = ({
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
) : debugData ? ( ) : debugData ? (
<ScrollArea className="flex-1 rounded-md border"> <>
<div className="p-4 space-y-4"> {/* Stats Cards */}
{/* Token/Character Stats */} <div className="flex-shrink-0 grid grid-cols-3 gap-4 mb-4">
{debugData.promptLength && ( {/* Prompt Length Card */}
<div className="grid grid-cols-2 gap-4 p-3 bg-muted/50 rounded-md text-sm"> <Card className="py-2">
<div> <CardHeader className="py-2">
<span className="text-muted-foreground">Prompt Length:</span> <CardTitle className="text-base">Prompt Length</CardTitle>
<span className="ml-2 font-medium"> </CardHeader>
{debugData.promptLength.toLocaleString()} chars <CardContent className="py-2">
</span> <div className="flex flex-col space-y-2">
<div className="text-sm">
<span className="text-muted-foreground">Characters:</span>{' '}
<span className="font-semibold">{promptLength.toLocaleString()}</span>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Tokens:</span>{' '}
<span className="font-semibold">~{estimatedTokens.toLocaleString()}</span>
</div>
</div> </div>
<div> </CardContent>
<span className="text-muted-foreground">Est. Tokens:</span> </Card>
<span className="ml-2 font-medium">
~{Math.round(debugData.promptLength / 4).toLocaleString()} {/* Cost Estimate Card */}
</span> <Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">Cost Estimate</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center">
<label className="text-sm text-muted-foreground">$</label>
<Input
type="number"
value={costPerMillion}
onChange={(e) => setCostPerMillion(Number(e.target.value) || 1.25)}
className="w-[50px] h-6 px-1 mx-1 text-sm"
min="0"
step="0.25"
/>
<label className="text-sm text-muted-foreground">per million input tokens</label>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Cost:</span>{' '}
<span className="font-semibold">
{calculateCost(promptLength, costPerMillion)}
</span>
</div>
</div> </div>
</div> </CardContent>
)} </Card>
{/* Base Prompt */} {/* Processing Time Card */}
{debugData.basePrompt && ( <Card className="py-2">
<div> <CardHeader className="py-2">
<h4 className="font-medium mb-2">Base Prompt</h4> <CardTitle className="text-base">Processing Time</CardTitle>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap"> </CardHeader>
{debugData.basePrompt} <CardContent className="py-2">
</pre> <div className="flex flex-col space-y-2">
</div> {debugData.estimatedProcessingTime?.seconds ? (
)} <>
<div className="text-sm">
{/* Sample Full Prompt */} <span className="text-muted-foreground">Estimated time:</span>{' '}
{debugData.sampleFullPrompt && ( <span className="font-semibold">
<div> {formatTime(debugData.estimatedProcessingTime.seconds)}
<h4 className="font-medium mb-2">Sample Full Prompt (First 5 Products)</h4> </span>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap"> </div>
{debugData.sampleFullPrompt} <div className="text-xs text-muted-foreground">
</pre> Based on {debugData.estimatedProcessingTime.sampleCount} similar
</div> validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
)} </div>
</>
{/* Taxonomy Stats */} ) : (
{debugData.taxonomyStats && ( <div className="text-sm text-muted-foreground">
<div> No historical data available
<h4 className="font-medium mb-2">Taxonomy Stats</h4>
<div className="grid grid-cols-4 gap-2 p-3 bg-muted/50 rounded-md text-sm">
{Object.entries(debugData.taxonomyStats).map(([key, value]) => (
<div key={key}>
<span className="text-muted-foreground capitalize">
{key.replace(/([A-Z])/g, ' $1').trim()}:
</span>
<span className="ml-1 font-medium">{value}</span>
</div> </div>
))} )}
</div> </div>
</div> </CardContent>
)} </Card>
{/* API Format */}
{debugData.apiFormat && (
<div>
<h4 className="font-medium mb-2">API Format</h4>
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(debugData.apiFormat, null, 2)}
</pre>
</div>
)}
</div> </div>
</ScrollArea>
{/* Prompt Sources Badges - Only show if we have apiFormat */}
{debugData.apiFormat && (
<Card className="py-2 mb-4 flex-shrink-0">
<CardHeader className="py-2">
<CardTitle className="text-base">Prompt Sources</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-wrap gap-2">
<Badge
variant="outline"
className={SECTION_STYLES.system.badge}
onClick={() => scrollToSection('system-message')}
>
System
</Badge>
<Badge
variant="outline"
className={SECTION_STYLES.general.badge}
onClick={() => scrollToSection('general-section')}
>
General
</Badge>
{companyPrompts.map((cp, idx) => (
<Badge
key={idx}
variant="outline"
className={SECTION_STYLES.company.badge}
onClick={() => scrollToSection('company-section')}
>
{cp.companyName || `Company ${cp.company}`}
</Badge>
))}
<Badge
variant="outline"
className={SECTION_STYLES.taxonomy.badge}
onClick={() => scrollToSection('taxonomy-section')}
>
Taxonomy
</Badge>
<Badge
variant="outline"
className={SECTION_STYLES.product.badge}
onClick={() => scrollToSection('product-section')}
>
Products
</Badge>
</div>
</CardContent>
</Card>
)}
{/* Prompt Content */}
<ScrollArea className="flex-1 w-full [&>div>div]:!overflow-x-hidden">
{debugData.apiFormat ? (
// Render API format with parsed sections
debugData.apiFormat.map((message, idx) => (
<div key={idx} className="border rounded-md p-2 mb-4 overflow-hidden">
<div
id={message.role === 'system' ? 'system-message' : undefined}
className={`px-6 py-2 mb-2 rounded-sm font-medium ${
message.role === 'system'
? SECTION_STYLES.system.header
: SECTION_STYLES.general.header
}`}
>
Role: {message.role}
</div>
<div
className={`p-4 overflow-hidden ${
message.role === 'system'
? SECTION_STYLES.system.content
: SECTION_STYLES.general.content
}`}
>
{message.role === 'user' ? (
// Parse user message into visual sections
<div className="w-full overflow-hidden">
{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 (
<div
key={segIdx}
className="font-mono text-xs"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{segment.content}
</div>
);
}
return (
<div
key={segIdx}
id={sectionId}
className={`border-l-4 ${style.border} pl-4 py-2 my-2`}
style={{ overflow: 'hidden' }}
>
{sectionLabel && (
<div className={`text-xs font-semibold ${style.label} mb-2`}>
{sectionLabel}
</div>
)}
<div
className="font-mono text-xs"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{segment.content}
</div>
</div>
);
})}
</div>
) : (
// System message - show as-is
<div
className="font-mono text-xs"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{message.content}
</div>
)}
</div>
</div>
))
) : debugData.sampleFullPrompt ? (
// Fallback to sample full prompt
<div
className="p-4 font-mono text-xs"
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', overflowWrap: 'anywhere' }}
>
{debugData.sampleFullPrompt}
</div>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
No prompt data available
</div>
)}
</ScrollArea>
</>
) : ( ) : (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
No debug data available No debug data available

View File

@@ -1,10 +1,11 @@
/** /**
* AiValidationResultsDialog Component * 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 { useMemo } from 'react';
import * as Diff from 'diff';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -15,20 +16,192 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Check, Undo2, Sparkles } from 'lucide-react'; import { Check, X, Sparkles, AlertTriangle, Info, Cpu, Brain } from 'lucide-react';
import type { AiValidationResults } from '../store/types'; import { Protected } from '@/components/auth/Protected';
import type { AiValidationResults, AiTokenUsage, AiValidationChange } from '../store/types';
interface AiValidationResultsDialogProps { interface AiValidationResultsDialogProps {
results: AiValidationResults; results: AiValidationResults;
revertedChanges: Set<string>; revertedChanges: Set<string>;
onRevert: (productIndex: number, fieldKey: string) => void; onRevert: (productIndex: number, fieldKey: string) => void;
onAccept?: (productIndex: number, fieldKey: string) => void;
onDismiss: () => 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 <span className="text-sm">{correctedStr}</span>;
}
// Calculate word-level diff
const diffResult = Diff.diffWords(originalStr, correctedStr);
return (
<span className="text-xs break-words">
{diffResult.map((part, index) => {
if (part.added) {
return (
<span
key={index}
className="bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 rounded px-0.5"
>
{part.value}
</span>
);
}
if (part.removed) {
return (
<span
key={index}
className="bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 line-through rounded px-0.5"
>
{part.value}
</span>
);
}
return <span key={index}>{part.value}</span>;
})}
</span>
);
};
/**
* 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 (
<div className="border rounded-lg p-3 bg-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Cpu className="h-3 w-3" />
Token Usage
</div>
<div className="grid grid-cols-5 gap-2 text-center text-xs">
<div>
<div className="font-medium">{formatTokens(prompt)}</div>
<div className="text-muted-foreground">Prompt</div>
</div>
<div>
<div className="font-medium">{formatTokens(completion)}</div>
<div className="text-muted-foreground">Completion</div>
</div>
<div>
<div className="font-medium text-primary">{formatTokens(total)}</div>
<div className="text-muted-foreground">Total</div>
</div>
<div>
<div className="font-medium">{formatTokens(reasoning)}</div>
<div className="text-muted-foreground">Reasoning</div>
</div>
<div>
<div className="font-medium">{formatTokens(cachedPrompt)}</div>
<div className="text-muted-foreground">Cached</div>
</div>
</div>
</div>
);
};
/**
* 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 (
<div className="space-y-2">
{/* Summary */}
{summary && (
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">{summary}</div>
</div>
</div>
)}
{/* Warnings */}
{warnings && warnings.length > 0 && (
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
{warnings.map((warning, index) => (
<div key={index} className="text-sm text-amber-800 dark:text-amber-200">
{warning}
</div>
))}
</div>
</div>
</div>
)}
{/* AI-generated change summaries */}
{changesSummary && changesSummary.length > 0 && (
<div className="p-3 rounded-lg bg-muted/50 border">
<div className="text-xs font-medium text-muted-foreground mb-2">Changes Made</div>
<ul className="list-disc list-inside space-y-1">
{changesSummary.slice(0, 5).map((change, index) => (
<li key={index} className="text-sm">{change}</li>
))}
{changesSummary.length > 5 && (
<li className="text-sm text-muted-foreground">
...and {changesSummary.length - 5} more
</li>
)}
</ul>
</div>
)}
</div>
);
};
export const AiValidationResultsDialog = ({ export const AiValidationResultsDialog = ({
results, results,
revertedChanges, revertedChanges,
onRevert, onRevert,
onAccept,
onDismiss, onDismiss,
}: AiValidationResultsDialogProps) => { }: AiValidationResultsDialogProps) => {
// Group changes by product // Group changes by product
@@ -48,17 +221,34 @@ export const AiValidationResultsDialog = ({
return ( return (
<Dialog open={true} onOpenChange={() => onDismiss()}> <Dialog open={true} onOpenChange={() => onDismiss()}>
<DialogContent className="sm:max-w-2xl max-h-[80vh]"> <DialogContent className="sm:max-w-2xl max-h-[85vh]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" /> <Sparkles className="h-5 w-5 text-primary" />
AI Validation Complete AI Validation Complete
{/* Model and reasoning effort badges - admin only */}
<Protected permission="admin:debug">
<div className="flex items-center gap-2 ml-auto">
{results.model && (
<Badge variant="secondary" className="text-xs">
<Cpu className="h-3 w-3 mr-1" />
{results.model}
</Badge>
)}
{results.reasoningEffort && (
<Badge variant="outline" className="text-xs">
<Brain className="h-3 w-3 mr-1" />
{results.reasoningEffort}
</Badge>
)}
</div>
</Protected>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="py-2 space-y-4">
{/* Summary */} {/* Stats Summary */}
<div className="flex items-center gap-4 mb-4 p-4 bg-muted rounded-lg"> <div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold">{results.totalProducts}</div> <div className="text-2xl font-bold">{results.totalProducts}</div>
<div className="text-xs text-muted-foreground">Products</div> <div className="text-xs text-muted-foreground">Products</div>
@@ -73,10 +263,22 @@ export const AiValidationResultsDialog = ({
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="text-2xl font-bold">{(results.processingTime / 1000).toFixed(1)}s</div> <div className="text-2xl font-bold">{(results.processingTime / 1000).toFixed(1)}s</div>
<div className="text-xs text-muted-foreground">Processing Time</div> <div className="text-xs text-muted-foreground">Time</div>
</div> </div>
</div> </div>
{/* Token Usage - Admin only */}
<Protected permission="admin:debug">
<TokenUsageDisplay tokenUsage={results.tokenUsage} />
</Protected>
{/* Summary, Warnings, and Change Summaries */}
<SummaryDisplay
summary={results.summary}
warnings={results.warnings}
changesSummary={results.changesSummary}
/>
{/* Changes list */} {/* Changes list */}
{results.changes.length === 0 ? ( {results.changes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
@@ -84,7 +286,7 @@ export const AiValidationResultsDialog = ({
<p>No corrections needed - all data looks good!</p> <p>No corrections needed - all data looks good!</p>
</div> </div>
) : ( ) : (
<ScrollArea className="h-[300px]"> <ScrollArea className="h-[250px]">
<div className="space-y-4"> <div className="space-y-4">
{Array.from(changesByProduct.entries()).map(([productIndex, changes]) => ( {Array.from(changesByProduct.entries()).map(([productIndex, changes]) => (
<div key={productIndex} className="border rounded-lg p-3"> <div key={productIndex} className="border rounded-lg p-3">
@@ -101,30 +303,46 @@ export const AiValidationResultsDialog = ({
isReverted ? 'bg-muted opacity-50' : 'bg-primary/5' isReverted ? 'bg-muted opacity-50' : 'bg-primary/5'
}`} }`}
> >
<div className="flex-1"> <div className="flex-1 min-w-0">
<div className="font-medium text-sm">{change.fieldKey}</div> <div className="font-medium text-sm">{change.fieldKey}</div>
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground">
<span className="line-through"> <DiffDisplay
{String(change.originalValue || '(empty)')} original={change.originalValue}
</span> corrected={change.correctedValue}
<span className="mx-2"></span> />
<span className="text-primary font-medium">
{String(change.correctedValue)}
</span>
</div> </div>
</div> </div>
{isReverted ? ( {/* Accept/Reject toggle buttons */}
<Badge variant="outline">Reverted</Badge> <div className="flex items-center gap-1 flex-shrink-0 ml-2">
) : (
<Button <Button
variant="ghost" variant={isReverted ? 'outline' : 'default'}
size="sm" size="sm"
onClick={() => onRevert(change.productIndex, change.fieldKey)} className={`h-7 px-2 ${!isReverted ? 'bg-green-600 hover:bg-green-700 text-white' : ''}`}
onClick={() => {
if (isReverted && onAccept) {
onAccept(change.productIndex, change.fieldKey);
}
}}
disabled={!isReverted}
title="Accept this change"
> >
<Undo2 className="h-4 w-4 mr-1" /> <Check className="h-3.5 w-3.5" />
Revert
</Button> </Button>
)} <Button
variant={isReverted ? 'destructive' : 'outline'}
size="sm"
className="h-7 px-2"
onClick={() => {
if (!isReverted) {
onRevert(change.productIndex, change.fieldKey);
}
}}
disabled={isReverted}
title="Reject this change"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
); );
})} })}
@@ -134,12 +352,6 @@ export const AiValidationResultsDialog = ({
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
{results.tokenUsage && (
<div className="mt-4 text-xs text-muted-foreground text-center">
Token usage: {results.tokenUsage.input} input, {results.tokenUsage.output} output
</div>
)}
</div> </div>
<DialogFooter> <DialogFooter>

View File

@@ -14,9 +14,11 @@ import { useValidationStore } from '../../store/validationStore';
import { useAiValidation, useIsAiValidating } from '../../store/selectors'; import { useAiValidation, useIsAiValidating } from '../../store/selectors';
import { useAiProgress } from './useAiProgress'; import { useAiProgress } from './useAiProgress';
import { useAiTransform } from './useAiTransform'; import { useAiTransform } from './useAiTransform';
import { useValidationActions } from '../useValidationActions';
import { import {
runAiValidation, runAiValidation,
getAiDebugPrompt, getAiDebugPrompt,
getAiTimeEstimate,
prepareProductsForAi, prepareProductsForAi,
extractAiSupplementalColumns, extractAiSupplementalColumns,
type AiDebugPromptResponse, type AiDebugPromptResponse,
@@ -40,9 +42,11 @@ export const useAiValidationFlow = () => {
// Sub-hooks // Sub-hooks
const { startProgress, updateProgress, completeProgress, setError, clearProgress } = useAiProgress(); const { startProgress, updateProgress, completeProgress, setError, clearProgress } = useAiProgress();
const { applyAiChanges, buildResults, saveResults } = useAiTransform(); const { applyAiChanges, buildResults, saveResults } = useAiTransform();
const { validateAllRows } = useValidationActions();
// Store actions // Store actions
const revertAiChange = useValidationStore((state) => state.revertAiChange); const revertAiChange = useValidationStore((state) => state.revertAiChange);
const acceptAiChange = useValidationStore((state) => state.acceptAiChange);
const clearAiValidation = useValidationStore((state) => state.clearAiValidation); const clearAiValidation = useValidationStore((state) => state.clearAiValidation);
// Local state for debug prompt preview // Local state for debug prompt preview
@@ -70,14 +74,25 @@ export const useAiValidationFlow = () => {
const startTime = Date.now(); const startTime = Date.now();
try { try {
// Start progress tracking // Prepare data for API first (needed for time estimate)
startProgress(rows.length);
// Prepare data for API
updateProgress(0, rows.length, 'preparing', 'Preparing data...');
const products = prepareProductsForAi(rows, fields); const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows); 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 // Call AI validation API
updateProgress(0, rows.length, 'validating', 'Running AI validation...'); updateProgress(0, rows.length, 'validating', 'Running AI validation...');
const response = await runAiValidation({ const response = await runAiValidation({
@@ -85,23 +100,54 @@ export const useAiValidationFlow = () => {
aiSupplementalColumns, aiSupplementalColumns,
}); });
if (!response.success || !response.results) { if (!response.success) {
throw new Error(response.error || 'AI validation failed'); throw new Error(response.error || 'AI validation failed');
} }
// Process results // Process results
updateProgress(rows.length, rows.length, 'processing', 'Processing 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 // Apply changes to rows
if (changes.length > 0) { 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 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); saveResults(results);
// Complete progress // Complete progress
@@ -128,6 +174,7 @@ export const useAiValidationFlow = () => {
applyAiChanges, applyAiChanges,
buildResults, buildResults,
saveResults, saveResults,
validateAllRows,
]); ]);
/** /**
@@ -141,6 +188,17 @@ export const useAiValidationFlow = () => {
[revertAiChange] [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 * Dismiss AI validation results
*/ */
@@ -157,7 +215,8 @@ export const useAiValidationFlow = () => {
const products = prepareProductsForAi(rows, fields); const products = prepareProductsForAi(rows, fields);
const aiSupplementalColumns = extractAiSupplementalColumns(rows); 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) { if (prompt) {
setDebugPrompt(prompt); setDebugPrompt(prompt);
setShowDebugDialog(true); setShowDebugDialog(true);
@@ -196,6 +255,7 @@ export const useAiValidationFlow = () => {
// Actions // Actions
validate, validate,
revertChange, revertChange,
acceptChange,
dismissResults, dismissResults,
cancel, cancel,
showPromptPreview, showPromptPreview,

View File

@@ -7,6 +7,7 @@
import config from '@/config'; import config from '@/config';
import type { RowData } from '../../store/types'; import type { RowData } from '../../store/types';
import type { Field } from '../../../../types'; import type { Field } from '../../../../types';
import { prepareDataForAiValidation } from '../../utils/aiValidationUtils';
export interface AiValidationRequest { export interface AiValidationRequest {
products: Record<string, unknown>[]; products: Record<string, unknown>[];
@@ -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 { export interface AiValidationResponse {
success: boolean; success: boolean;
results?: { results?: {
@@ -28,41 +40,96 @@ export interface AiValidationResponse {
correctedValue: unknown; correctedValue: unknown;
confidence?: number; confidence?: number;
}>; }>;
tokenUsage?: { tokenUsage?: AiTokenUsage;
input: number; };
output: number; // Top-level fields from AI response
}; correctedData?: Record<string, unknown>[];
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; error?: string;
} }
export interface TaxonomyStats {
categories: number;
themes: number;
colors: number;
taxCodes: number;
sizeCategories: number;
suppliers: number;
companies: number;
artists: number;
}
export interface AiDebugPromptResponse { export interface AiDebugPromptResponse {
prompt: string; prompt?: string;
systemPrompt: string; basePrompt?: string;
estimatedTokens: number; 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 * 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 = ( export const prepareProductsForAi = (
rows: RowData[], rows: RowData[],
fields: Field<string>[] fields: Field<string>[]
): Record<string, unknown>[] => { ): Record<string, unknown>[] => {
return rows.map((row, index) => { // Add __index metadata for tracking
const product: Record<string, unknown> = { const rowsWithIndex = rows.map((row, index) => ({
_index: index, // Track original index for applying changes ...row,
}; __index: index,
}));
// Include all field values // Use the shared utility function for base preparation
fields.forEach((field) => { const prepared = prepareDataForAiValidation(rowsWithIndex, fields as any);
const value = row[field.key];
if (value !== undefined && value !== null && value !== '') {
product[field.key] = value;
}
});
// 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)) { if (row.__aiSupplemental && Array.isArray(row.__aiSupplemental)) {
row.__aiSupplemental.forEach((col) => { row.__aiSupplemental.forEach((col) => {
if (row[col] !== undefined) { 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 ( export const getAiDebugPrompt = async (
products: Record<string, unknown>[], products: Record<string, unknown>[],
aiSupplementalColumns: string[] aiSupplementalColumns: string[],
options?: { previewOnly?: boolean }
): Promise<AiDebugPromptResponse | null> => { ): Promise<AiDebugPromptResponse | null> => {
try { 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`, { const response = await fetch(`${config.apiUrl}/ai-validation/debug`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
products: products.slice(0, 5), // Only send first 5 for preview products: productsToSend,
aiSupplementalColumns, aiSupplementalColumns,
}), }),
}); });
@@ -145,3 +220,24 @@ export const getAiDebugPrompt = async (
return null; 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<string, unknown>[],
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 };
}
};

View File

@@ -2,15 +2,16 @@
* useAiProgress - AI Validation Progress Tracking * useAiProgress - AI Validation Progress Tracking
* *
* Manages progress state and time estimation for AI validation. * 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 { useCallback, useRef, useEffect } from 'react';
import { useValidationStore } from '../../store/validationStore'; import { useValidationStore } from '../../store/validationStore';
import type { AiValidationProgress } from '../../store/types'; 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 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 * Hook for managing AI validation progress
@@ -21,13 +22,94 @@ export const useAiProgress = () => {
const timerRef = useRef<NodeJS.Timeout | null>(null); const timerRef = useRef<NodeJS.Timeout | null>(null);
const startTimeRef = useRef<number>(0); const startTimeRef = useRef<number>(0);
const estimatedSecondsRef = useRef<number | null>(null);
const promptLengthRef = useRef<number | null>(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 * Start progress tracking
*/ */
const startProgress = useCallback( const startProgress = useCallback(
(totalProducts: number) => { (totalProducts: number, estimatedSeconds?: number, promptLength?: number) => {
startTimeRef.current = Date.now(); 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 = { const initialProgress: AiValidationProgress = {
current: 0, current: 0,
@@ -35,53 +117,81 @@ export const useAiProgress = () => {
status: 'preparing', status: 'preparing',
message: 'Preparing data for AI validation...', message: 'Preparing data for AI validation...',
startTime: startTimeRef.current, startTime: startTimeRef.current,
estimatedTimeRemaining: Math.max( estimatedTimeRemaining: estimatedSeconds
totalProducts * AVG_MS_PER_PRODUCT, ? estimatedSeconds * 1000
MIN_ESTIMATED_TIME : fallbackEstimateMs,
), estimatedTotalSeconds: estimatedSeconds,
promptLength: promptLength,
elapsedSeconds: 0,
progressPercent: 0,
}; };
setAiValidationRunning(true); setAiValidationRunning(true);
setAiValidationProgress(initialProgress); setAiValidationProgress(initialProgress);
// Start the live timer
startTimer();
}, },
[setAiValidationProgress, setAiValidationRunning] [setAiValidationProgress, setAiValidationRunning, startTimer]
); );
/** /**
* Update progress * Update progress status and message
*/ */
const updateProgress = useCallback( const updateProgress = useCallback(
(current: number, total: number, status: AiValidationProgress['status'], message?: string) => { (current: number, total: number, status: AiValidationProgress['status'], message?: string) => {
const elapsed = Date.now() - startTimeRef.current; const elapsedMs = Date.now() - startTimeRef.current;
const rate = current > 0 ? elapsed / current : AVG_MS_PER_PRODUCT; const elapsedSeconds = Math.floor(elapsedMs / 1000);
const remaining = (total - current) * rate; 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, current,
total, total,
status, status,
message, message,
startTime: startTimeRef.current, 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 * Complete progress
*/ */
const completeProgress = useCallback(() => { const completeProgress = useCallback(() => {
const elapsed = Date.now() - startTimeRef.current; // Stop the timer
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setAiValidationProgress({ const elapsedMs = Date.now() - startTimeRef.current;
current: 1, const elapsedSeconds = Math.floor(elapsedMs / 1000);
total: 1,
setAiValidationProgress((prev) => ({
current: prev?.total ?? 1,
total: prev?.total ?? 1,
status: 'complete', status: 'complete',
message: `Validation complete in ${(elapsed / 1000).toFixed(1)}s`, message: `Validation complete in ${(elapsedMs / 1000).toFixed(1)}s`,
startTime: startTimeRef.current, startTime: startTimeRef.current,
estimatedTimeRemaining: 0, estimatedTimeRemaining: 0,
}); estimatedTotalSeconds: estimatedSecondsRef.current ?? undefined,
promptLength: promptLengthRef.current ?? undefined,
elapsedSeconds,
progressPercent: 100,
}));
}, [setAiValidationProgress]); }, [setAiValidationProgress]);
/** /**
@@ -89,14 +199,25 @@ export const useAiProgress = () => {
*/ */
const setError = useCallback( const setError = useCallback(
(message: string) => { (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, current: 0,
total: 0, total: prev?.total ?? 0,
status: 'error', status: 'error',
message, message,
startTime: startTimeRef.current, startTime: startTimeRef.current,
estimatedTimeRemaining: 0, estimatedTimeRemaining: 0,
}); elapsedSeconds,
progressPercent: prev?.progressPercent,
}));
}, },
[setAiValidationProgress] [setAiValidationProgress]
); );
@@ -109,6 +230,8 @@ export const useAiProgress = () => {
clearInterval(timerRef.current); clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
} }
estimatedSecondsRef.current = null;
promptLengthRef.current = null;
setAiValidationProgress(null); setAiValidationProgress(null);
setAiValidationRunning(false); setAiValidationRunning(false);
}, [setAiValidationProgress, setAiValidationRunning]); }, [setAiValidationProgress, setAiValidationRunning]);
@@ -128,5 +251,6 @@ export const useAiProgress = () => {
completeProgress, completeProgress,
setError, setError,
clearProgress, clearProgress,
setEstimate,
}; };
}; };

View File

@@ -10,8 +10,74 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useValidationStore } from '../../store/validationStore'; 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 { 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<string, unknown>;
// 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 * Coerce a value to match the expected field type
@@ -131,18 +197,33 @@ export const useAiTransform = () => {
const buildResults = useCallback( const buildResults = useCallback(
( (
changes: AiValidationChange[], changes: AiValidationChange[],
tokenUsage: { input: number; output: number } | undefined, rawTokenUsage: unknown,
processingTime: number processingTime: number,
metadata?: {
model?: string;
reasoningEffort?: string;
summary?: string;
warnings?: string[];
changesSummary?: string[];
}
): AiValidationResults => { ): AiValidationResults => {
const { rows } = useValidationStore.getState(); const { rows } = useValidationStore.getState();
const affectedProducts = new Set(changes.map((c) => c.productIndex)); const affectedProducts = new Set(changes.map((c) => c.productIndex));
// Normalize token usage from various formats
const tokenUsage = normalizeTokenUsage(rawTokenUsage);
return { return {
totalProducts: rows.length, totalProducts: rows.length,
productsWithChanges: affectedProducts.size, productsWithChanges: affectedProducts.size,
changes, changes,
tokenUsage, tokenUsage,
processingTime, processingTime,
model: metadata?.model,
reasoningEffort: normalizeReasoningEffort(metadata?.reasoningEffort),
summary: metadata?.summary,
warnings: metadata?.warnings,
changesSummary: metadata?.changesSummary,
}; };
}, },
[] []

View File

@@ -172,6 +172,10 @@ export interface AiValidationProgress {
message?: string; message?: string;
startTime: number; startTime: number;
estimatedTimeRemaining?: 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 { export interface AiValidationChange {
@@ -182,15 +186,29 @@ export interface AiValidationChange {
confidence?: number; 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 { export interface AiValidationResults {
totalProducts: number; totalProducts: number;
productsWithChanges: number; productsWithChanges: number;
changes: AiValidationChange[]; changes: AiValidationChange[];
tokenUsage?: { tokenUsage?: AiTokenUsage;
input: number;
output: number;
};
processingTime: number; processingTime: number;
// Additional metadata from AI response
model?: string;
reasoningEffort?: string;
summary?: string;
warnings?: string[];
changesSummary?: string[]; // High-level change descriptions
} }
export interface AiValidationState { export interface AiValidationState {
@@ -369,9 +387,12 @@ export interface ValidationActions {
// === AI Validation === // === AI Validation ===
setAiValidationRunning: (running: boolean) => void; setAiValidationRunning: (running: boolean) => void;
setAiValidationProgress: (progress: AiValidationProgress | null) => void; setAiValidationProgress: (
progress: AiValidationProgress | null | ((prev: AiValidationProgress | null) => AiValidationProgress | null)
) => void;
setAiValidationResults: (results: AiValidationResults | null) => void; setAiValidationResults: (results: AiValidationResults | null) => void;
revertAiChange: (productIndex: number, fieldKey: string) => void; revertAiChange: (productIndex: number, fieldKey: string) => void;
acceptAiChange: (productIndex: number, fieldKey: string) => void;
clearAiValidation: () => void; clearAiValidation: () => void;
storeOriginalValues: () => void; storeOriginalValues: () => void;

View File

@@ -651,9 +651,16 @@ export const useValidationStore = create<ValidationStore>()(
}); });
}, },
setAiValidationProgress: (progress: AiValidationProgress | null) => { setAiValidationProgress: (
progressOrUpdater: AiValidationProgress | null | ((prev: AiValidationProgress | null) => AiValidationProgress | null)
) => {
set((state) => { 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<ValidationStore>()(
}); });
}, },
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: () => { clearAiValidation: () => {
set((state) => { set((state) => {
state.aiValidation = { state.aiValidation = {