Add anchor price support for variant products

When a user confirms a price from the modal, we now store it as an
"anchor price". On subsequent refreshes, if multiple price candidates
are found (common with variant products like different sizes/colors),
we select the candidate closest to the anchor price.

This fixes the issue where variant products would randomly switch to
a different variant's price on refresh.

Changes:
- Add anchor_price column to products table
- Save anchor price when user confirms a price selection
- Use anchor price to select correct variant on refresh
- 20% tolerance - if no candidate is within 20% of anchor, fall back to consensus

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
clucraft 2026-01-24 15:50:18 -05:00
parent bffc427fff
commit 389915a6ec
5 changed files with 70 additions and 7 deletions

View file

@ -164,6 +164,10 @@ async function runMigrations() {
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'price_candidates') THEN
ALTER TABLE products ADD COLUMN price_candidates JSONB;
END IF;
-- Anchor price: the price the user confirmed, used to select correct variant on refresh
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'anchor_price') THEN
ALTER TABLE products ADD COLUMN anchor_price DECIMAL(10,2);
END IF;
END $$;
`);

View file

@ -566,6 +566,21 @@ export const productQueries = {
);
return result.rows[0]?.preferred_extraction_method || null;
},
updateAnchorPrice: async (id: number, price: number): Promise<void> => {
await pool.query(
'UPDATE products SET anchor_price = $1 WHERE id = $2',
[price, id]
);
},
getAnchorPrice: async (id: number): Promise<number | null> => {
const result = await pool.query(
'SELECT anchor_price FROM products WHERE id = $1',
[id]
);
return result.rows[0]?.anchor_price ? parseFloat(result.rows[0].anchor_price) : null;
},
};
// Price History types and queries

View file

@ -57,6 +57,9 @@ router.post('/', async (req: AuthRequest, res: Response) => {
// Store the preferred extraction method and the user-selected price
await productQueries.updateExtractionMethod(product.id, selectedMethod);
// Store the anchor price - used on refresh to select the correct variant
await productQueries.updateAnchorPrice(product.id, selectedPrice);
// Record the user-selected price
await priceHistoryQueries.create(
product.id,

View file

@ -26,11 +26,15 @@ async function checkPrices(): Promise<void> {
// Get preferred extraction method for this product (if user previously selected one)
const preferredMethod = await productQueries.getPreferredExtractionMethod(product.id);
// Use voting scraper with preferred method if available
// Get anchor price for variant products (the price the user confirmed)
const anchorPrice = await productQueries.getAnchorPrice(product.id);
// Use voting scraper with preferred method and anchor price if available
const scrapedData = await scrapeProductWithVoting(
product.url,
product.user_id,
preferredMethod as ExtractionMethod | undefined
preferredMethod as ExtractionMethod | undefined,
anchorPrice || undefined
);
// Check for back-in-stock notification

View file

@ -1291,11 +1291,15 @@ export async function scrapeProduct(url: string, userId?: number): Promise<Scrap
/**
* Multi-strategy voting scraper with user review support.
* Runs all extraction methods, finds consensus, and flags ambiguous cases for user review.
*
* @param anchorPrice - The price the user previously confirmed. Used to select the correct
* variant on refresh when multiple prices are found.
*/
export async function scrapeProductWithVoting(
url: string,
userId?: number,
preferredMethod?: ExtractionMethod
preferredMethod?: ExtractionMethod,
anchorPrice?: number
): Promise<ScrapedProductWithCandidates> {
const result: ScrapedProductWithCandidates = {
name: null,
@ -1427,15 +1431,48 @@ export async function scrapeProductWithVoting(
// If user has a preferred method, try to use it
if (preferredMethod && allCandidates.length > 0) {
const preferredCandidate = allCandidates.find(c => c.method === preferredMethod);
if (preferredCandidate) {
console.log(`[Voting] Using preferred method ${preferredMethod}: ${preferredCandidate.price}`);
result.price = { price: preferredCandidate.price, currency: preferredCandidate.currency };
const preferredCandidates = allCandidates.filter(c => c.method === preferredMethod);
if (preferredCandidates.length > 0) {
let selectedCandidate = preferredCandidates[0];
// If we have an anchor price and multiple candidates from preferred method,
// select the one closest to the anchor price (handles variant products)
if (anchorPrice && preferredCandidates.length > 1) {
selectedCandidate = preferredCandidates.reduce((closest, candidate) => {
const closestDiff = Math.abs(closest.price - anchorPrice);
const candidateDiff = Math.abs(candidate.price - anchorPrice);
return candidateDiff < closestDiff ? candidate : closest;
});
console.log(`[Voting] Using anchor price ${anchorPrice} to select from ${preferredCandidates.length} candidates: ${selectedCandidate.price}`);
}
console.log(`[Voting] Using preferred method ${preferredMethod}: ${selectedCandidate.price}`);
result.price = { price: selectedCandidate.price, currency: selectedCandidate.currency };
result.selectedMethod = preferredMethod;
return result;
}
}
// If we have an anchor price but no preferred method, find closest candidate overall
if (anchorPrice && allCandidates.length > 0) {
const closestCandidate = allCandidates.reduce((closest, candidate) => {
const closestDiff = Math.abs(closest.price - anchorPrice);
const candidateDiff = Math.abs(candidate.price - anchorPrice);
return candidateDiff < closestDiff ? candidate : closest;
});
// Only use anchor matching if the difference is reasonable (within 20%)
const priceDiff = Math.abs(closestCandidate.price - anchorPrice) / anchorPrice;
if (priceDiff < 0.20) {
console.log(`[Voting] Using anchor price ${anchorPrice} to select: ${closestCandidate.price} (${(priceDiff * 100).toFixed(1)}% diff)`);
result.price = { price: closestCandidate.price, currency: closestCandidate.currency };
result.selectedMethod = closestCandidate.method;
return result;
} else {
console.log(`[Voting] Anchor price ${anchorPrice} too different from candidates (closest: ${closestCandidate.price}, ${(priceDiff * 100).toFixed(1)}% diff), using consensus`);
}
}
// Find consensus
const { price: consensusPrice, hasConsensus, groups } = findPriceConsensus(allCandidates);
console.log(`[Voting] Consensus: ${hasConsensus}, Groups: ${groups.length}, Winner: ${consensusPrice?.price}`);