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
|
// - 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
<Button
|
||||||
onClick={onNext}
|
onClick={() => {
|
||||||
disabled={!canProceed}
|
if (canProceed) {
|
||||||
|
onNext();
|
||||||
|
} else {
|
||||||
|
setShowErrorDialog(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={
|
title={
|
||||||
!canProceed
|
!canProceed
|
||||||
? `Fix ${errorCount} validation errors before proceeding`
|
? `There are ${errorCount} validation errors`
|
||||||
: 'Continue to image upload'
|
: 'Continue to image upload'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Continue to Images
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
</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>
|
||||||
|
|||||||
@@ -105,51 +105,28 @@ 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"
|
|
||||||
checked={filters.showErrorsOnly}
|
|
||||||
onCheckedChange={setShowErrorsOnly}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="show-errors" className="text-sm cursor-pointer">
|
|
||||||
Show errors only
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<span>{rowCount} products</span>
|
|
||||||
{errorCount > 0 && (
|
|
||||||
<Badge variant="destructive">
|
|
||||||
{errorCount} errors in {rowsWithErrors} rows
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{selectedRowCount > 0 && (
|
|
||||||
<Badge variant="secondary">{selectedRowCount} selected</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom row: Actions */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Add row */}
|
{/* Add row */}
|
||||||
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
|
<Button variant="outline" size="sm" onClick={() => handleAddRow()}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Add Row
|
Add Blank Row
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Create template from existing product */}
|
{/* Create template from existing product */}
|
||||||
@@ -174,6 +151,32 @@ export const ValidationToolbar = ({
|
|||||||
onCreated={handleCategoryCreated}
|
onCreated={handleCategoryCreated}
|
||||||
/>
|
/>
|
||||||
</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">
|
||||||
|
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{errorCount} issues in {rowsWithErrors} rows
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Product Search Template Dialog */}
|
{/* Product Search Template Dialog */}
|
||||||
<SearchProductTemplateDialog
|
<SearchProductTemplateDialog
|
||||||
|
|||||||
@@ -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>
|
||||||
<div>
|
<div className="text-sm">
|
||||||
<span className="text-muted-foreground">Est. Tokens:</span>
|
<span className="text-muted-foreground">Tokens:</span>{' '}
|
||||||
<span className="ml-2 font-medium">
|
<span className="font-semibold">~{estimatedTokens.toLocaleString()}</span>
|
||||||
~{Math.round(debugData.promptLength / 4).toLocaleString()}
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
</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>
|
|
||||||
<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>
|
||||||
<span className="ml-1 font-medium">{value}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Based on {debugData.estimatedProcessingTime.sampleCount} similar
|
||||||
|
validation{debugData.estimatedProcessingTime.sampleCount !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No historical data available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* API Format */}
|
{/* Prompt Sources Badges - Only show if we have apiFormat */}
|
||||||
{debugData.apiFormat && (
|
{debugData.apiFormat && (
|
||||||
<div>
|
<Card className="py-2 mb-4 flex-shrink-0">
|
||||||
<h4 className="font-medium mb-2">API Format</h4>
|
<CardHeader className="py-2">
|
||||||
<pre className="p-3 bg-muted rounded-md text-xs overflow-x-auto whitespace-pre-wrap">
|
<CardTitle className="text-base">Prompt Sources</CardTitle>
|
||||||
{JSON.stringify(debugData.apiFormat, null, 2)}
|
</CardHeader>
|
||||||
</pre>
|
<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>
|
</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>
|
</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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user