mirror of
https://github.com/clucraft/PriceGhost.git
synced 2026-06-29 15:59:39 +02:00
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:
parent
3d91489f12
commit
ccbc188487
11 changed files with 173 additions and 23 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
60
frontend/src/components/AIStatusBadge.tsx
Normal file
60
frontend/src/components/AIStatusBadge.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue