feat: Multi-strategy price voting system with user selection

- Add multi-strategy voting: runs JSON-LD, site-specific, generic CSS,
  and AI extraction methods in parallel
- Implement consensus voting to select the correct price when methods agree
- Add AI arbitration when extraction methods disagree
- Add PriceSelectionModal for users to select correct price when ambiguous
- Store preferred extraction method per product for faster re-checks
- Add database columns for preferred_extraction_method and needs_price_review

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-24 14:45:51 -05:00
parent 40c45b49c8
commit 4fd04cd160
10 changed files with 1259 additions and 12 deletions

View file

@ -83,6 +83,27 @@ export interface ProductWithStats extends Product {
} | null;
}
// Response when product needs price review
export interface PriceCandidate {
price: number;
currency: string;
method: string;
context?: string;
confidence: number;
}
export interface PriceReviewResponse {
needsReview: true;
name: string | null;
imageUrl: string | null;
stockStatus: string;
priceCandidates: PriceCandidate[];
suggestedPrice: { price: number; currency: string } | null;
url: string;
}
export type CreateProductResponse = Product | PriceReviewResponse;
export interface PriceHistory {
id: number;
product_id: number;
@ -96,8 +117,13 @@ export const productsApi = {
getById: (id: number) => api.get<ProductWithStats>(`/products/${id}`),
create: (url: string, refreshInterval?: number) =>
api.post<Product>('/products', { url, refresh_interval: refreshInterval }),
create: (url: string, refreshInterval?: number, selectedPrice?: number, selectedMethod?: string) =>
api.post<CreateProductResponse>('/products', {
url,
refresh_interval: refreshInterval,
selectedPrice,
selectedMethod,
}),
update: (id: number, data: {
name?: string;

View file

@ -0,0 +1,337 @@
import { useState } from 'react';
export interface PriceCandidate {
price: number;
currency: string;
method: string;
context?: string;
confidence: number;
}
interface PriceSelectionModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (price: number, method: string) => void;
productName: string | null;
imageUrl: string | null;
candidates: PriceCandidate[];
suggestedPrice: { price: number; currency: string } | null;
url: string;
}
const METHOD_LABELS: Record<string, string> = {
'json-ld': 'Structured Data',
'site-specific': 'Site Scraper',
'generic-css': 'CSS Selector',
'ai': 'AI Extraction',
};
const METHOD_DESCRIPTIONS: Record<string, string> = {
'json-ld': 'Found in page metadata (schema.org)',
'site-specific': 'Extracted using site-specific rules',
'generic-css': 'Found using general price selectors',
'ai': 'Detected by AI analysis',
};
export default function PriceSelectionModal({
isOpen,
onClose,
onSelect,
productName,
imageUrl,
candidates,
suggestedPrice,
url,
}: PriceSelectionModalProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(
suggestedPrice
? candidates.findIndex(c => c.price === suggestedPrice.price)
: 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
if (!isOpen) return null;
const handleSelect = async () => {
if (selectedIndex === null || selectedIndex < 0) return;
const selected = candidates[selectedIndex];
setIsSubmitting(true);
try {
await onSelect(selected.price, selected.method);
} finally {
setIsSubmitting(false);
}
};
const formatPrice = (price: number, currency: string) => {
const symbol = currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : currency === 'CHF' ? 'CHF ' : '$';
return `${symbol}${price.toFixed(2)}`;
};
const getConfidenceLabel = (confidence: number) => {
if (confidence >= 0.8) return 'High';
if (confidence >= 0.6) return 'Medium';
return 'Low';
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.8) return '#10b981';
if (confidence >= 0.6) return '#f59e0b';
return '#6b7280';
};
// Sort candidates by confidence (highest first)
const sortedCandidates = [...candidates].sort((a, b) => b.confidence - a.confidence);
return (
<div className="price-modal-overlay">
<style>{`
.price-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.price-modal {
background: var(--surface);
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.price-modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border);
}
.price-modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
margin: 0 0 0.5rem 0;
}
.price-modal-subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0;
}
.price-modal-product {
display: flex;
gap: 1rem;
padding: 1rem 1.5rem;
background: var(--background);
border-bottom: 1px solid var(--border);
}
.price-modal-product-image {
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 0.5rem;
background: white;
}
.price-modal-product-info {
flex: 1;
min-width: 0;
}
.price-modal-product-name {
font-weight: 500;
color: var(--text);
margin: 0 0 0.25rem 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-modal-product-url {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-modal-body {
padding: 1rem 1.5rem;
overflow-y: auto;
flex: 1;
}
.price-candidates-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.price-candidate {
border: 2px solid var(--border);
border-radius: 0.75rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.price-candidate:hover {
border-color: var(--primary);
background: var(--background);
}
.price-candidate.selected {
border-color: var(--primary);
background: rgba(99, 102, 241, 0.1);
}
.price-candidate-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.price-candidate-price {
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
}
.price-candidate-confidence {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
background: var(--background);
}
.price-candidate-method {
font-size: 0.875rem;
font-weight: 500;
color: var(--text);
margin-bottom: 0.25rem;
}
.price-candidate-context {
font-size: 0.75rem;
color: var(--text-muted);
}
.price-candidate-check {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.price-candidate.selected .price-candidate-check {
opacity: 1;
}
.price-modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.price-modal-footer .btn {
min-width: 100px;
}
`}</style>
<div className="price-modal">
<div className="price-modal-header">
<h2 className="price-modal-title">Multiple Prices Found</h2>
<p className="price-modal-subtitle">
We found different prices for this product. Please select the correct one.
</p>
</div>
<div className="price-modal-product">
{imageUrl && (
<img src={imageUrl} alt="" className="price-modal-product-image" />
)}
<div className="price-modal-product-info">
<p className="price-modal-product-name">{productName || 'Unknown Product'}</p>
<p className="price-modal-product-url">{url}</p>
</div>
</div>
<div className="price-modal-body">
<div className="price-candidates-list">
{sortedCandidates.map((candidate, index) => {
const originalIndex = candidates.indexOf(candidate);
return (
<div
key={index}
className={`price-candidate ${selectedIndex === originalIndex ? 'selected' : ''}`}
onClick={() => setSelectedIndex(originalIndex)}
>
<div className="price-candidate-check">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div className="price-candidate-header">
<span className="price-candidate-price">
{formatPrice(candidate.price, candidate.currency)}
</span>
<span
className="price-candidate-confidence"
style={{ color: getConfidenceColor(candidate.confidence) }}
>
{getConfidenceLabel(candidate.confidence)} confidence
</span>
</div>
<div className="price-candidate-method">
{METHOD_LABELS[candidate.method] || candidate.method}
</div>
<div className="price-candidate-context">
{candidate.context || METHOD_DESCRIPTIONS[candidate.method] || 'No additional context'}
</div>
</div>
);
})}
</div>
</div>
<div className="price-modal-footer">
<button className="btn btn-secondary" onClick={onClose} disabled={isSubmitting}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleSelect}
disabled={selectedIndex === null || isSubmitting}
>
{isSubmitting ? <span className="spinner" /> : 'Confirm Selection'}
</button>
</div>
</div>
</div>
);
}

View file

@ -2,7 +2,13 @@ import { useState, useEffect, useMemo } from 'react';
import Layout from '../components/Layout';
import ProductCard from '../components/ProductCard';
import ProductForm from '../components/ProductForm';
import { productsApi, pricesApi, Product } from '../api/client';
import PriceSelectionModal from '../components/PriceSelectionModal';
import { productsApi, pricesApi, Product, PriceReviewResponse } from '../api/client';
// Type guard to check if response needs review
function isPriceReviewResponse(response: Product | PriceReviewResponse): response is PriceReviewResponse {
return 'needsReview' in response && response.needsReview === true;
}
type SortOption = 'date_added' | 'name' | 'price' | 'price_change' | 'website';
type SortOrder = 'asc' | 'desc';
@ -33,6 +39,11 @@ export default function Dashboard() {
const [isSavingBulk, setIsSavingBulk] = useState(false);
const [showBulkActions, setShowBulkActions] = useState(false);
// Price selection modal state
const [showPriceModal, setShowPriceModal] = useState(false);
const [priceReviewData, setPriceReviewData] = useState<PriceReviewResponse | null>(null);
const [pendingRefreshInterval, setPendingRefreshInterval] = useState<number>(3600);
const fetchProducts = async () => {
try {
const response = await productsApi.getAll();
@ -58,7 +69,40 @@ export default function Dashboard() {
const handleAddProduct = async (url: string, refreshInterval: number) => {
const response = await productsApi.create(url, refreshInterval);
setProducts((prev) => [response.data, ...prev]);
// Check if we need user to select a price
if (isPriceReviewResponse(response.data)) {
setPriceReviewData(response.data);
setPendingRefreshInterval(refreshInterval);
setShowPriceModal(true);
return; // Don't add product yet - wait for user selection
}
// response.data is a Product at this point
setProducts((prev) => [response.data as Product, ...prev]);
};
const handlePriceSelected = async (selectedPrice: number, selectedMethod: string) => {
if (!priceReviewData) return;
const response = await productsApi.create(
priceReviewData.url,
pendingRefreshInterval,
selectedPrice,
selectedMethod
);
// When selecting a price, the API should always return a Product
if (!isPriceReviewResponse(response.data)) {
setProducts((prev) => [response.data as Product, ...prev]);
}
setShowPriceModal(false);
setPriceReviewData(null);
};
const handlePriceModalClose = () => {
setShowPriceModal(false);
setPriceReviewData(null);
};
const handleDeleteProduct = async (id: number) => {
@ -641,6 +685,18 @@ export default function Dashboard() {
<ProductForm onSubmit={handleAddProduct} />
{/* Price Selection Modal */}
<PriceSelectionModal
isOpen={showPriceModal}
onClose={handlePriceModalClose}
onSelect={handlePriceSelected}
productName={priceReviewData?.name || null}
imageUrl={priceReviewData?.imageUrl || null}
candidates={priceReviewData?.priceCandidates || []}
suggestedPrice={priceReviewData?.suggestedPrice || null}
url={priceReviewData?.url || ''}
/>
{error && <div className="alert alert-error">{error}</div>}
{/* Dashboard Summary */}