Add AI status badges to show verification status on prices

- Add ai_status column to price_history table (verified/corrected/null)
- Track AI verification status through scraper and scheduler
- Display badges next to prices:
  - ✓ AI (green): AI verified the price is correct
  -  AI (orange): AI corrected an incorrect price
- Show badges on Dashboard product cards and ProductDetail page
- Add legend explaining badges in Settings when AI Verification is enabled

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-23 09:45:45 -05:00
parent 3d91489f12
commit ccbc188487
11 changed files with 173 additions and 23 deletions

View file

@ -77,6 +77,16 @@ async function runMigrations() {
ON stock_status_history(product_id, changed_at); ON stock_status_history(product_id, changed_at);
`); `);
// Add ai_status column to price_history table
await client.query(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'price_history' AND column_name = 'ai_status') THEN
ALTER TABLE price_history ADD COLUMN ai_status VARCHAR(20);
END IF;
END $$;
`);
console.log('Database migrations completed'); console.log('Database migrations completed');
} catch (error) { } catch (error) {
console.error('Migration error:', error); console.error('Migration error:', error);

View file

@ -324,6 +324,7 @@ function getJitterSeconds(): number {
export interface ProductWithLatestPrice extends Product { export interface ProductWithLatestPrice extends Product {
current_price: number | null; current_price: number | null;
currency: string | null; currency: string | null;
ai_status: AIStatus;
} }
export interface SparklinePoint { export interface SparklinePoint {
@ -340,10 +341,10 @@ export interface ProductWithSparkline extends ProductWithLatestPrice {
export const productQueries = { export const productQueries = {
findByUserId: async (userId: number): Promise<ProductWithLatestPrice[]> => { findByUserId: async (userId: number): Promise<ProductWithLatestPrice[]> => {
const result = await pool.query( const result = await pool.query(
`SELECT p.*, ph.price as current_price, ph.currency `SELECT p.*, ph.price as current_price, ph.currency, ph.ai_status
FROM products p FROM products p
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT price, currency FROM price_history SELECT price, currency, ai_status FROM price_history
WHERE product_id = p.id WHERE product_id = p.id
ORDER BY recorded_at DESC ORDER BY recorded_at DESC
LIMIT 1 LIMIT 1
@ -358,10 +359,10 @@ export const productQueries = {
findByUserIdWithSparkline: async (userId: number): Promise<ProductWithSparkline[]> => { findByUserIdWithSparkline: async (userId: number): Promise<ProductWithSparkline[]> => {
// Get all products with current price // Get all products with current price
const productsResult = await pool.query( const productsResult = await pool.query(
`SELECT p.*, ph.price as current_price, ph.currency `SELECT p.*, ph.price as current_price, ph.currency, ph.ai_status
FROM products p FROM products p
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT price, currency FROM price_history SELECT price, currency, ai_status FROM price_history
WHERE product_id = p.id WHERE product_id = p.id
ORDER BY recorded_at DESC ORDER BY recorded_at DESC
LIMIT 1 LIMIT 1
@ -432,10 +433,10 @@ export const productQueries = {
findById: async (id: number, userId: number): Promise<ProductWithLatestPrice | null> => { findById: async (id: number, userId: number): Promise<ProductWithLatestPrice | null> => {
const result = await pool.query( const result = await pool.query(
`SELECT p.*, ph.price as current_price, ph.currency `SELECT p.*, ph.price as current_price, ph.currency, ph.ai_status
FROM products p FROM products p
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT price, currency FROM price_history SELECT price, currency, ai_status FROM price_history
WHERE product_id = p.id WHERE product_id = p.id
ORDER BY recorded_at DESC ORDER BY recorded_at DESC
LIMIT 1 LIMIT 1
@ -553,11 +554,14 @@ export const productQueries = {
}; };
// Price History types and queries // Price History types and queries
export type AIStatus = 'verified' | 'corrected' | null;
export interface PriceHistory { export interface PriceHistory {
id: number; id: number;
product_id: number; product_id: number;
price: number; price: number;
currency: string; currency: string;
ai_status: AIStatus;
recorded_at: Date; recorded_at: Date;
} }
@ -586,13 +590,14 @@ export const priceHistoryQueries = {
create: async ( create: async (
productId: number, productId: number,
price: number, price: number,
currency: string = 'USD' currency: string = 'USD',
aiStatus: AIStatus = null
): Promise<PriceHistory> => { ): Promise<PriceHistory> => {
const result = await pool.query( const result = await pool.query(
`INSERT INTO price_history (product_id, price, currency) `INSERT INTO price_history (product_id, price, currency, ai_status)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING *`, RETURNING *`,
[productId, price, currency] [productId, price, currency, aiStatus]
); );
return result.rows[0]; return result.rows[0];
}, },

View file

@ -62,8 +62,8 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => {
return; return;
} }
// Scrape product data including price and stock status // Scrape product data including price and stock status (pass userId for AI verification)
const scrapedData = await scrapeProduct(product.url); const scrapedData = await scrapeProduct(product.url, userId);
// Update stock status and record change if different // Update stock status and record change if different
if (scrapedData.stockStatus !== product.stock_status) { if (scrapedData.stockStatus !== product.stock_status) {
@ -77,7 +77,8 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => {
newPrice = await priceHistoryQueries.create( newPrice = await priceHistoryQueries.create(
productId, productId,
scrapedData.price.price, scrapedData.price.price,
scrapedData.price.currency scrapedData.price.currency,
scrapedData.aiStatus
); );
} }
@ -90,6 +91,7 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => {
: 'Price refreshed successfully', : 'Price refreshed successfully',
price: newPrice, price: newPrice,
stockStatus: scrapedData.stockStatus, stockStatus: scrapedData.stockStatus,
aiStatus: scrapedData.aiStatus,
}); });
} catch (error) { } catch (error) {
console.error('Error refreshing price:', error); console.error('Error refreshing price:', error);

View file

@ -65,7 +65,8 @@ router.post('/', async (req: AuthRequest, res: Response) => {
await priceHistoryQueries.create( await priceHistoryQueries.create(
product.id, product.id,
scrapedData.price.price, scrapedData.price.price,
scrapedData.price.currency scrapedData.price.currency,
scrapedData.aiStatus
); );
} }

View file

@ -126,10 +126,11 @@ async function checkPrices(): Promise<void> {
await priceHistoryQueries.create( await priceHistoryQueries.create(
product.id, product.id,
scrapedData.price.price, scrapedData.price.price,
scrapedData.price.currency scrapedData.price.currency,
scrapedData.aiStatus
); );
console.log( console.log(
`Recorded new price for product ${product.id}: ${scrapedData.price.currency} ${scrapedData.price.price}` `Recorded new price for product ${product.id}: ${scrapedData.price.currency} ${scrapedData.price.price}${scrapedData.aiStatus ? ` (AI: ${scrapedData.aiStatus})` : ''}`
); );
} else { } else {
console.log(`Price unchanged for product ${product.id}`); console.log(`Price unchanged for product ${product.id}`);

View file

@ -81,12 +81,15 @@ async function scrapeWithBrowser(url: string): Promise<string> {
} }
} }
export type AIStatus = 'verified' | 'corrected' | null;
export interface ScrapedProduct { export interface ScrapedProduct {
name: string | null; name: string | null;
price: ParsedPrice | null; price: ParsedPrice | null;
imageUrl: string | null; imageUrl: string | null;
url: string; url: string;
stockStatus: StockStatus; stockStatus: StockStatus;
aiStatus: AIStatus;
} }
// Site-specific scraper configurations // Site-specific scraper configurations
@ -721,6 +724,7 @@ export async function scrapeProduct(url: string, userId?: number): Promise<Scrap
imageUrl: null, imageUrl: null,
url, url,
stockStatus: 'unknown', stockStatus: 'unknown',
aiStatus: null,
}; };
let html: string = ''; let html: string = '';
@ -833,11 +837,14 @@ export async function scrapeProduct(url: string, userId?: number): Promise<Scrap
if (verifyResult) { if (verifyResult) {
if (verifyResult.isCorrect) { if (verifyResult.isCorrect) {
console.log(`[AI Verify] Confirmed price $${result.price.price} is correct (confidence: ${verifyResult.confidence})`); console.log(`[AI Verify] Confirmed price $${result.price.price} is correct (confidence: ${verifyResult.confidence})`);
result.aiStatus = 'verified';
} else if (verifyResult.suggestedPrice && verifyResult.confidence > 0.6) { } else if (verifyResult.suggestedPrice && verifyResult.confidence > 0.6) {
console.log(`[AI Verify] Price correction: $${result.price.price} -> $${verifyResult.suggestedPrice.price} (${verifyResult.reason})`); console.log(`[AI Verify] Price correction: $${result.price.price} -> $${verifyResult.suggestedPrice.price} (${verifyResult.reason})`);
result.price = verifyResult.suggestedPrice; result.price = verifyResult.suggestedPrice;
result.aiStatus = 'corrected';
} else { } else {
console.log(`[AI Verify] Price might be incorrect but no confident suggestion: ${verifyResult.reason}`); console.log(`[AI Verify] Price might be incorrect but no confident suggestion: ${verifyResult.reason}`);
// Don't set aiStatus if verification was inconclusive
} }
} }
} catch (verifyError) { } catch (verifyError) {

View file

@ -45,6 +45,7 @@ export const authApi = {
// Products API // Products API
export type StockStatus = 'in_stock' | 'out_of_stock' | 'unknown'; export type StockStatus = 'in_stock' | 'out_of_stock' | 'unknown';
export type AIStatus = 'verified' | 'corrected' | null;
export interface SparklinePoint { export interface SparklinePoint {
price: number; price: number;
@ -67,6 +68,7 @@ export interface Product {
created_at: string; created_at: string;
current_price: number | null; current_price: number | null;
currency: string | null; currency: string | null;
ai_status: AIStatus;
sparkline?: SparklinePoint[]; sparkline?: SparklinePoint[];
price_change_7d?: number | null; price_change_7d?: number | null;
min_price?: number | null; min_price?: number | null;

View file

@ -0,0 +1,60 @@
import { AIStatus } from '../api/client';
interface AIStatusBadgeProps {
status: AIStatus;
size?: 'small' | 'normal';
}
export default function AIStatusBadge({ status, size = 'normal' }: AIStatusBadgeProps) {
if (!status) return null;
const isSmall = size === 'small';
const fontSize = isSmall ? '0.65rem' : '0.75rem';
const padding = isSmall ? '0.1rem 0.3rem' : '0.15rem 0.4rem';
if (status === 'verified') {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
fontSize,
padding,
borderRadius: '0.25rem',
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: '#10b981',
fontWeight: 500,
}}
title="AI verified this price is correct"
>
<span style={{ fontSize: isSmall ? '0.7rem' : '0.8rem' }}></span>
AI
</span>
);
}
if (status === 'corrected') {
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
fontSize,
padding,
borderRadius: '0.25rem',
backgroundColor: 'rgba(245, 158, 11, 0.15)',
color: '#f59e0b',
fontWeight: 500,
}}
title="AI corrected this price (original scrape was incorrect)"
>
<span style={{ fontSize: isSmall ? '0.7rem' : '0.8rem' }}></span>
AI
</span>
);
}
return null;
}

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Product } from '../api/client'; import { Product } from '../api/client';
import Sparkline from './Sparkline'; import Sparkline from './Sparkline';
import AIStatusBadge from './AIStatusBadge';
interface ProductCardProps { interface ProductCardProps {
product: Product; product: Product;
@ -502,9 +503,12 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected,
</span> </span>
) : ( ) : (
<> <>
<span className="product-current-price"> <div style={{ display: 'flex', alignItems: 'center', gap: '0.375rem' }}>
{formatPrice(product.current_price, product.currency)} <span className="product-current-price">
</span> {formatPrice(product.current_price, product.currency)}
</span>
<AIStatusBadge status={product.ai_status} size="small" />
</div>
{product.price_change_7d !== null && product.price_change_7d !== undefined && ( {product.price_change_7d !== null && product.price_change_7d !== undefined && (
<span className={`product-price-change ${priceChangeClass}`}> <span className={`product-price-change ${priceChangeClass}`}>
{formatPriceChange(product.price_change_7d)} (7d) {formatPriceChange(product.price_change_7d)} (7d)

View file

@ -3,6 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import PriceChart from '../components/PriceChart'; import PriceChart from '../components/PriceChart';
import StockTimeline from '../components/StockTimeline'; import StockTimeline from '../components/StockTimeline';
import AIStatusBadge from '../components/AIStatusBadge';
import { useToast } from '../context/ToastContext'; import { useToast } from '../context/ToastContext';
import { import {
productsApi, productsApi,
@ -438,10 +439,15 @@ export default function ProductDetail() {
</div> </div>
) : null} ) : null}
<div className="product-detail-price"> <div className="product-detail-price" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{product.stock_status === 'out_of_stock' <span>
? 'Price unavailable' {product.stock_status === 'out_of_stock'
: formatPrice(product.current_price, product.currency)} ? 'Price unavailable'
: formatPrice(product.current_price, product.currency)}
</span>
{product.stock_status !== 'out_of_stock' && (
<AIStatusBadge status={product.ai_status} />
)}
</div> </div>
{priceChange !== null && priceChange !== 0 && ( {priceChange !== null && priceChange !== 0 && (

View file

@ -1304,6 +1304,58 @@ export default function Settings() {
/> />
</div> </div>
{aiVerificationEnabled && (
<div style={{
marginTop: '0.5rem',
padding: '0.75rem',
background: 'var(--background)',
borderRadius: '0.5rem',
fontSize: '0.8125rem',
}}>
<div style={{ fontWeight: 500, marginBottom: '0.5rem', color: 'var(--text)' }}>
Price badges explained:
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
fontSize: '0.75rem',
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
backgroundColor: 'rgba(16, 185, 129, 0.15)',
color: '#10b981',
fontWeight: 500,
}}>
<span style={{ fontSize: '0.8rem' }}></span> AI
</span>
<span style={{ color: 'var(--text-muted)' }}>
AI verified the scraped price is correct
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.2rem',
fontSize: '0.75rem',
padding: '0.15rem 0.4rem',
borderRadius: '0.25rem',
backgroundColor: 'rgba(245, 158, 11, 0.15)',
color: '#f59e0b',
fontWeight: 500,
}}>
<span style={{ fontSize: '0.8rem' }}></span> AI
</span>
<span style={{ color: 'var(--text-muted)' }}>
AI corrected an incorrect price (e.g., scraped savings amount instead of actual price)
</span>
</div>
</div>
</div>
)}
{(aiEnabled || aiVerificationEnabled) && ( {(aiEnabled || aiVerificationEnabled) && (
<> <>
<div className="settings-form-group"> <div className="settings-form-group">