Frontend Updates on Brand Discovery

This commit is contained in:
sriram
2026-09-29 16:44:03 +05:30
parent 5315f20ea9
commit b58b291a0c
2 changed files with 104 additions and 6 deletions

View File

@@ -387,6 +387,17 @@ export const api = {
body: JSON.stringify(payload),
}),
// Web & retail listings: a background search (minutes, not seconds) that
// the panel starts, polls, and then reads through the preview's `use_web`.
startWebDiscovery: (brand, { refresh = false } = {}) =>
request('/api/admin/brand-discovery/web-jobs', {
method: 'POST',
body: JSON.stringify({ brand, refresh }),
}),
getWebDiscoveryJob: (jobId) =>
request(`/api/admin/brand-discovery/web-jobs/${encodeURIComponent(jobId)}`),
ingestBrandDiscovery: (
brand,
products,

View File

@@ -1,7 +1,7 @@
import React, { useMemo, useState } from 'react';
import {
Search, Sparkles, Loader2, AlertTriangle, PlayCircle, Database,
ShieldCheck, PackageSearch, Barcode,
ShieldCheck, PackageSearch, Barcode, Store,
} from 'lucide-react';
import { api } from '../api/client';
@@ -25,6 +25,14 @@ import { api } from '../api/client';
* The review step stays regardless. A person reading the list is still the last
* check before anything is written, so the list is shown.
*
* WEB & RETAIL LISTINGS (optional, off by default). For brands Open Food Facts
* does not carry - non-food above all - the server can search retailer listings
* (Blinkit, Zepto, Instamart, BigBasket, JioMart, Amazon, Flipkart). It reads
* only the search engine's result titles and links, never a shop's page and
* never a language model, and keeps a product only when a real listing names
* the brand and states a pack size. That search takes minutes, so it runs as a
* job: Discover starts it, this panel polls it, then asks for the preview.
*
* There is no progress UI here on purpose. Ingest returns a BatchOut, and
* AdminPage hands it to BatchCatalogPanel through the `adoptBatch` prop it
* already has - which draws the eleven stages, and owns Resume and Cancel.
@@ -32,16 +40,38 @@ import { api } from '../api/client';
const EVIDENCE_TONE = {
openfacts: 'bg-emerald-500/10 text-emerald-700 border-emerald-500/20',
retail: 'bg-violet-500/10 text-violet-700 border-violet-500/20',
catalog: 'bg-sky-500/10 text-sky-700 border-sky-500/20',
registry: 'bg-amber-500/10 text-amber-700 border-amber-500/20',
};
const EVIDENCE_LABEL = {
openfacts: 'Open Food Facts',
retail: 'retail listing',
catalog: 'already in catalog',
registry: 'known sub-brand',
};
const WEB_POLL_MS = 2500;
const WEB_FINISHED = new Set(['done', 'partial', 'failed', 'interrupted']);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// The badge for one row. A brand-store row has no `evidence` tier (the store IS
// the source), and used to fall through to "model only" - which it is not.
function evidenceBadge(product) {
if (product.evidence === 'retail' && product.retailer_count) {
const n = product.retailer_count;
return { tone: EVIDENCE_TONE.retail, label: `retail - ${n} shop${n === 1 ? '' : 's'}` };
}
if (product.evidence) {
return { tone: EVIDENCE_TONE[product.evidence], label: EVIDENCE_LABEL[product.evidence] };
}
if ((product.sources || []).includes('store')) {
return { tone: EVIDENCE_TONE.openfacts, label: "brand's own shop" };
}
return null;
}
function Toggle({ checked, onChange, label, hint }) {
return (
<label className="flex items-start gap-2 text-xs text-slate-600 cursor-pointer">
@@ -82,6 +112,9 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
const [useOpenfacts, setUseOpenfacts] = useState(true);
const [requireEvidence, setRequireEvidence] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
// Off by default: it costs minutes of paced web searches.
const [useWeb, setUseWeb] = useState(false);
const [webJob, setWebJob] = useState(null);
// Set by the 409 the ingest route answers with when the brand sits outside
// ACTIVE_BRANDS. Staging anyway is a deliberate second press, never a default.
const [acknowledgeInactive, setAcknowledgeInactive] = useState(false);
@@ -100,10 +133,24 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
setError('');
setResult(null);
setAcknowledgeInactive(false);
setWebJob(null);
try {
let webJobId = null;
if (useWeb) {
let job = await api.startWebDiscovery(brand.trim());
setWebJob(job);
while (!WEB_FINISHED.has(job.status)) {
await sleep(WEB_POLL_MS);
job = await api.getWebDiscoveryJob(job.job_id);
setWebJob(job);
}
webJobId = job.job_id;
}
const body = await api.previewBrandDiscovery({
brand: brand.trim(),
use_openfacts: useOpenfacts,
use_web: useWeb,
web_job_id: webJobId,
// SENT, NOT OMITTED. DiscoveryPreviewRequest.use_llm defaults to True
// on the server, so leaving the key out would quietly re-enable the
// source this panel no longer exposes.
@@ -147,6 +194,10 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
fssai_license: p.fssai_license,
barcode: p.barcode,
image_url: p.image_url,
// Web-found rows only; recorded as provenance after the batch.
...(p.listings?.length
? { listings: p.listings, price_range: p.price_range, retailer_count: p.retailer_count }
: {}),
})),
{ fetch_images: fetchImages, acknowledge_inactive: acknowledgeInactive },
);
@@ -210,7 +261,11 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
) : (
<PackageSearch className="h-4 w-4" />
)}
{discovering ? 'Discovering...' : 'Discover products'}
{discovering
? webJob && !WEB_FINISHED.has(webJob.status)
? `Searching retailers ${webJob.queries_done}/${webJob.queries_total || '?'}...`
: 'Discovering...'
: 'Discover products'}
</button>
</div>
@@ -233,6 +288,12 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
label="Fetch images on ingest"
hint="Slow; nothing fills them in later"
/>
<Toggle
checked={useWeb}
onChange={setUseWeb}
label="Web & retail listings"
hint="Blinkit, Zepto, Instamart, BigBasket, JioMart, Amazon, Flipkart - for brands Open Food Facts lacks. Takes a few minutes; results are cached."
/>
</div>
</div>
@@ -251,6 +312,9 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
<Stat icon={ShieldCheck} value={counts.from_openfacts ?? 0} label="from Open Food Facts" />
<Stat icon={Barcode} value={counts.with_barcode ?? 0} label="with a real barcode" />
<Stat icon={Database} value={counts.already_in_catalog ?? 0} label="already in the catalog" />
{counts.from_web !== undefined && (
<Stat icon={Store} value={counts.from_web} label="from retail listings" />
)}
</div>
{(result.warnings || []).map((warning) => (
@@ -332,6 +396,26 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
{note}
</div>
))}
{product.listings?.length > 0 && (
<details className="text-[11px] text-slate-500">
<summary className="cursor-pointer">
{product.listings.length} listing{product.listings.length === 1 ? '' : 's'}
{product.price_range ? ` - ${product.price_range}` : ''}
</summary>
{product.listings.map((listing) => (
<a
key={listing.url}
href={listing.url}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-violet-700 underline"
title={listing.title}
>
{listing.retailer}: {listing.title}
</a>
))}
</details>
)}
</td>
<td className="px-3 py-2 text-slate-600">
{product.size_variants?.length
@@ -343,11 +427,11 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
{product.barcode || <span className="text-slate-400">-</span>}
</td>
<td className="px-3 py-2">
{product.evidence ? (
{evidenceBadge(product) ? (
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${EVIDENCE_TONE[product.evidence]}`}
className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${evidenceBadge(product).tone}`}
>
{EVIDENCE_LABEL[product.evidence]}
{evidenceBadge(product).label}
</span>
) : (
<span className="rounded-full border border-ink-900/10 bg-ink-100 px-2 py-0.5 text-[10px] font-bold text-slate-500">
@@ -365,7 +449,10 @@ export function BrandDiscoveryPanel({ onBatchStarted }) {
<p className="text-xs text-slate-500">
Nothing was found for this brand. Open Food Facts may not carry
it - non-food brands are thin there - and no verified storefront
may be registered for it. Discovery only offers products a real
may be registered for it.{' '}
{useWeb
? 'No retailer listing named the brand with a pack size either.'
: 'Tick "Web & retail listings" to search retailer listings too.'} Discovery only offers products a real
source can vouch for, so an empty list means nothing was found,
not that something was hidden.
</p>