Rewrite validation step part 3
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,11 +148,11 @@ export const FloatingSelectionBar = memo(() => {
|
||||
|
||||
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">
|
||||
{/* Selection count badge */}
|
||||
<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
|
||||
</div>
|
||||
<Button
|
||||
@@ -171,7 +171,7 @@ export const FloatingSelectionBar = memo(() => {
|
||||
|
||||
{/* Apply template to selected */}
|
||||
<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
|
||||
templates={templates}
|
||||
value=""
|
||||
@@ -196,7 +196,7 @@ export const FloatingSelectionBar = memo(() => {
|
||||
className="gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Template
|
||||
<span className="hidden md:inline">Save as Template</span>
|
||||
</Button>
|
||||
|
||||
{/* Divider */}
|
||||
@@ -212,7 +212,7 @@ export const FloatingSelectionBar = memo(() => {
|
||||
className="gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
<span className="hidden md:inline">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ const phaseMessages: Record<InitPhase, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ export const ValidationContainer = ({
|
||||
results={aiValidation.results}
|
||||
revertedChanges={aiValidation.revertedChanges}
|
||||
onRevert={aiValidation.revertChange}
|
||||
onAccept={aiValidation.acceptChange}
|
||||
onDismiss={aiValidation.dismissResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
|
||||
{/* Back button */}
|
||||
<div>
|
||||
{canGoBack && onBack && (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
@@ -85,18 +88,52 @@ export const ValidationFooter = ({
|
||||
|
||||
{/* Next button */}
|
||||
{onNext && (
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!canProceed}
|
||||
title={
|
||||
!canProceed
|
||||
? `Fix ${errorCount} validation errors before proceeding`
|
||||
: 'Continue to image upload'
|
||||
}
|
||||
>
|
||||
Continue to Images
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (canProceed) {
|
||||
onNext();
|
||||
} else {
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
!canProceed
|
||||
? `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>
|
||||
|
||||
@@ -105,75 +105,78 @@ export const ValidationToolbar = ({
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Search */}
|
||||
<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" />
|
||||
<Input
|
||||
placeholder="Search products..."
|
||||
placeholder="Filter products..."
|
||||
value={filters.searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error filter toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-errors"
|
||||
checked={filters.showErrorsOnly}
|
||||
onCheckedChange={setShowErrorsOnly}
|
||||
{/* Product count */}
|
||||
<span className="text-sm text-muted-foreground">{rowCount} products</span>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* 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>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground ml-auto">
|
||||
<span>{rowCount} products</span>
|
||||
{errorCount > 0 && (
|
||||
{/* 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">
|
||||
|
||||
<Badge variant="destructive">
|
||||
{errorCount} errors in {rowsWithErrors} rows
|
||||
{errorCount} issues in {rowsWithErrors} rows
|
||||
</Badge>
|
||||
)}
|
||||
{selectedRowCount > 0 && (
|
||||
<Badge variant="secondary">{selectedRowCount} selected</Badge>
|
||||
)}
|
||||
|
||||
{/* Error filter toggle */}
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<SearchProductTemplateDialog
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,6 +19,9 @@ import {
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
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 type { AiDebugPromptResponse } from '../hooks/useAiValidation';
|
||||
|
||||
@@ -23,19 +32,172 @@ interface AiDebugDialogProps {
|
||||
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 = ({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI Validation Prompt</DialogTitle>
|
||||
<DialogTitle>Current AI Prompt</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -45,74 +207,240 @@ export const AiDebugDialog = ({
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : debugData ? (
|
||||
<ScrollArea className="flex-1 rounded-md border">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Token/Character Stats */}
|
||||
{debugData.promptLength && (
|
||||
<div className="grid grid-cols-2 gap-4 p-3 bg-muted/50 rounded-md text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Prompt Length:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{debugData.promptLength.toLocaleString()} chars
|
||||
</span>
|
||||
<>
|
||||
{/* Stats Cards */}
|
||||
<div className="flex-shrink-0 grid grid-cols-3 gap-4 mb-4">
|
||||
{/* Prompt Length Card */}
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">Prompt Length</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<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>
|
||||
<span className="text-muted-foreground">Est. Tokens:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
~{Math.round(debugData.promptLength / 4).toLocaleString()}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cost Estimate Card */}
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Base Prompt */}
|
||||
{debugData.basePrompt && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Base Prompt</h4>
|
||||
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{debugData.basePrompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sample Full Prompt */}
|
||||
{debugData.sampleFullPrompt && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Sample Full Prompt (First 5 Products)</h4>
|
||||
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
{debugData.sampleFullPrompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Taxonomy Stats */}
|
||||
{debugData.taxonomyStats && (
|
||||
<div>
|
||||
<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>
|
||||
{/* Processing Time Card */}
|
||||
<Card className="py-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-base">Processing Time</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div className="flex flex-col space-y-2">
|
||||
{debugData.estimatedProcessingTime?.seconds ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Estimated time:</span>{' '}
|
||||
<span className="font-semibold">
|
||||
{formatTime(debugData.estimatedProcessingTime.seconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Based on {debugData.estimatedProcessingTime.sampleCount} similar
|
||||
validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No historical data available
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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">
|
||||
No debug data available
|
||||
|
||||
@@ -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<string>;
|
||||
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 <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 = ({
|
||||
results,
|
||||
revertedChanges,
|
||||
onRevert,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: AiValidationResultsDialogProps) => {
|
||||
// Group changes by product
|
||||
@@ -48,17 +221,34 @@ export const AiValidationResultsDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={() => onDismiss()}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[80vh]">
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
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>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{/* Summary */}
|
||||
<div className="flex items-center gap-4 mb-4 p-4 bg-muted rounded-lg">
|
||||
<div className="py-2 space-y-4">
|
||||
{/* Stats Summary */}
|
||||
<div className="flex items-center gap-4 p-4 bg-muted rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold">{results.totalProducts}</div>
|
||||
<div className="text-xs text-muted-foreground">Products</div>
|
||||
@@ -73,10 +263,22 @@ export const AiValidationResultsDialog = ({
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
{results.changes.length === 0 ? (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[300px]">
|
||||
<ScrollArea className="h-[250px]">
|
||||
<div className="space-y-4">
|
||||
{Array.from(changesByProduct.entries()).map(([productIndex, changes]) => (
|
||||
<div key={productIndex} className="border rounded-lg p-3">
|
||||
@@ -101,30 +303,46 @@ export const AiValidationResultsDialog = ({
|
||||
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="text-xs text-muted-foreground">
|
||||
<span className="line-through">
|
||||
{String(change.originalValue || '(empty)')}
|
||||
</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="text-primary font-medium">
|
||||
{String(change.correctedValue)}
|
||||
</span>
|
||||
<div className="text-muted-foreground">
|
||||
<DiffDisplay
|
||||
original={change.originalValue}
|
||||
corrected={change.correctedValue}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isReverted ? (
|
||||
<Badge variant="outline">Reverted</Badge>
|
||||
) : (
|
||||
{/* Accept/Reject toggle buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant={isReverted ? 'outline' : 'default'}
|
||||
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" />
|
||||
Revert
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
@@ -134,12 +352,6 @@ export const AiValidationResultsDialog = ({
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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 {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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<string>[]
|
||||
): Record<string, unknown>[] => {
|
||||
return rows.map((row, index) => {
|
||||
const product: Record<string, unknown> = {
|
||||
_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<string, unknown>[],
|
||||
aiSupplementalColumns: string[]
|
||||
aiSupplementalColumns: string[],
|
||||
options?: { previewOnly?: boolean }
|
||||
): Promise<AiDebugPromptResponse | null> => {
|
||||
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<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 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<NodeJS.Timeout | null>(null);
|
||||
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
|
||||
*/
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<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
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -651,9 +651,16 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
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<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: () => {
|
||||
set((state) => {
|
||||
state.aiValidation = {
|
||||
|
||||
Reference in New Issue
Block a user