diff --git a/backend/src/index.ts b/backend/src/index.ts index 03e2521..66169df 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -77,6 +77,16 @@ async function runMigrations() { 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'); } catch (error) { console.error('Migration error:', error); diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 3e8616c..a67cbfc 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -324,6 +324,7 @@ function getJitterSeconds(): number { export interface ProductWithLatestPrice extends Product { current_price: number | null; currency: string | null; + ai_status: AIStatus; } export interface SparklinePoint { @@ -340,10 +341,10 @@ export interface ProductWithSparkline extends ProductWithLatestPrice { export const productQueries = { findByUserId: async (userId: number): Promise => { 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 LEFT JOIN LATERAL ( - SELECT price, currency FROM price_history + SELECT price, currency, ai_status FROM price_history WHERE product_id = p.id ORDER BY recorded_at DESC LIMIT 1 @@ -358,10 +359,10 @@ export const productQueries = { findByUserIdWithSparkline: async (userId: number): Promise => { // Get all products with current price 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 LEFT JOIN LATERAL ( - SELECT price, currency FROM price_history + SELECT price, currency, ai_status FROM price_history WHERE product_id = p.id ORDER BY recorded_at DESC LIMIT 1 @@ -432,10 +433,10 @@ export const productQueries = { findById: async (id: number, userId: number): Promise => { 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 LEFT JOIN LATERAL ( - SELECT price, currency FROM price_history + SELECT price, currency, ai_status FROM price_history WHERE product_id = p.id ORDER BY recorded_at DESC LIMIT 1 @@ -553,11 +554,14 @@ export const productQueries = { }; // Price History types and queries +export type AIStatus = 'verified' | 'corrected' | null; + export interface PriceHistory { id: number; product_id: number; price: number; currency: string; + ai_status: AIStatus; recorded_at: Date; } @@ -586,13 +590,14 @@ export const priceHistoryQueries = { create: async ( productId: number, price: number, - currency: string = 'USD' + currency: string = 'USD', + aiStatus: AIStatus = null ): Promise => { const result = await pool.query( - `INSERT INTO price_history (product_id, price, currency) - VALUES ($1, $2, $3) + `INSERT INTO price_history (product_id, price, currency, ai_status) + VALUES ($1, $2, $3, $4) RETURNING *`, - [productId, price, currency] + [productId, price, currency, aiStatus] ); return result.rows[0]; }, diff --git a/backend/src/routes/prices.ts b/backend/src/routes/prices.ts index f25e11f..a2636b2 100644 --- a/backend/src/routes/prices.ts +++ b/backend/src/routes/prices.ts @@ -62,8 +62,8 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => { return; } - // Scrape product data including price and stock status - const scrapedData = await scrapeProduct(product.url); + // Scrape product data including price and stock status (pass userId for AI verification) + const scrapedData = await scrapeProduct(product.url, userId); // Update stock status and record change if different if (scrapedData.stockStatus !== product.stock_status) { @@ -77,7 +77,8 @@ router.post('/:productId/refresh', async (req: AuthRequest, res: Response) => { newPrice = await priceHistoryQueries.create( productId, 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: newPrice, stockStatus: scrapedData.stockStatus, + aiStatus: scrapedData.aiStatus, }); } catch (error) { console.error('Error refreshing price:', error); diff --git a/backend/src/routes/products.ts b/backend/src/routes/products.ts index 54f2b25..c87af38 100644 --- a/backend/src/routes/products.ts +++ b/backend/src/routes/products.ts @@ -65,7 +65,8 @@ router.post('/', async (req: AuthRequest, res: Response) => { await priceHistoryQueries.create( product.id, scrapedData.price.price, - scrapedData.price.currency + scrapedData.price.currency, + scrapedData.aiStatus ); } diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index e3cfc0d..86d3029 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -126,10 +126,11 @@ async function checkPrices(): Promise { await priceHistoryQueries.create( product.id, scrapedData.price.price, - scrapedData.price.currency + scrapedData.price.currency, + scrapedData.aiStatus ); 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 { console.log(`Price unchanged for product ${product.id}`); diff --git a/backend/src/services/scraper.ts b/backend/src/services/scraper.ts index 6727114..c1bf365 100644 --- a/backend/src/services/scraper.ts +++ b/backend/src/services/scraper.ts @@ -81,12 +81,15 @@ async function scrapeWithBrowser(url: string): Promise { } } +export type AIStatus = 'verified' | 'corrected' | null; + export interface ScrapedProduct { name: string | null; price: ParsedPrice | null; imageUrl: string | null; url: string; stockStatus: StockStatus; + aiStatus: AIStatus; } // Site-specific scraper configurations @@ -721,6 +724,7 @@ export async function scrapeProduct(url: string, userId?: number): Promise 0.6) { console.log(`[AI Verify] Price correction: $${result.price.price} -> $${verifyResult.suggestedPrice.price} (${verifyResult.reason})`); result.price = verifyResult.suggestedPrice; + result.aiStatus = 'corrected'; } else { console.log(`[AI Verify] Price might be incorrect but no confident suggestion: ${verifyResult.reason}`); + // Don't set aiStatus if verification was inconclusive } } } catch (verifyError) { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6b9be32..fcd1711 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -45,6 +45,7 @@ export const authApi = { // Products API export type StockStatus = 'in_stock' | 'out_of_stock' | 'unknown'; +export type AIStatus = 'verified' | 'corrected' | null; export interface SparklinePoint { price: number; @@ -67,6 +68,7 @@ export interface Product { created_at: string; current_price: number | null; currency: string | null; + ai_status: AIStatus; sparkline?: SparklinePoint[]; price_change_7d?: number | null; min_price?: number | null; diff --git a/frontend/src/components/AIStatusBadge.tsx b/frontend/src/components/AIStatusBadge.tsx new file mode 100644 index 0000000..f4196db --- /dev/null +++ b/frontend/src/components/AIStatusBadge.tsx @@ -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 ( + + + AI + + ); + } + + if (status === 'corrected') { + return ( + + + AI + + ); + } + + return null; +} diff --git a/frontend/src/components/ProductCard.tsx b/frontend/src/components/ProductCard.tsx index 9537087..43c8b28 100644 --- a/frontend/src/components/ProductCard.tsx +++ b/frontend/src/components/ProductCard.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Product } from '../api/client'; import Sparkline from './Sparkline'; +import AIStatusBadge from './AIStatusBadge'; interface ProductCardProps { product: Product; @@ -502,9 +503,12 @@ export default function ProductCard({ product, onDelete, onRefresh, isSelected, ) : ( <> - - {formatPrice(product.current_price, product.currency)} - +
+ + {formatPrice(product.current_price, product.currency)} + + +
{product.price_change_7d !== null && product.price_change_7d !== undefined && ( {formatPriceChange(product.price_change_7d)} (7d) diff --git a/frontend/src/pages/ProductDetail.tsx b/frontend/src/pages/ProductDetail.tsx index 782066e..e5bbd5e 100644 --- a/frontend/src/pages/ProductDetail.tsx +++ b/frontend/src/pages/ProductDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import Layout from '../components/Layout'; import PriceChart from '../components/PriceChart'; import StockTimeline from '../components/StockTimeline'; +import AIStatusBadge from '../components/AIStatusBadge'; import { useToast } from '../context/ToastContext'; import { productsApi, @@ -438,10 +439,15 @@ export default function ProductDetail() { ) : null} -
- {product.stock_status === 'out_of_stock' - ? 'Price unavailable' - : formatPrice(product.current_price, product.currency)} +
+ + {product.stock_status === 'out_of_stock' + ? 'Price unavailable' + : formatPrice(product.current_price, product.currency)} + + {product.stock_status !== 'out_of_stock' && ( + + )}
{priceChange !== null && priceChange !== 0 && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index a2e07de..24ff6b6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1304,6 +1304,58 @@ export default function Settings() { />
+ {aiVerificationEnabled && ( +
+
+ Price badges explained: +
+
+
+ + AI + + + AI verified the scraped price is correct + +
+
+ + AI + + + AI corrected an incorrect price (e.g., scraped savings amount instead of actual price) + +
+
+
+ )} + {(aiEnabled || aiVerificationEnabled) && ( <>