Frontend Updates on Brand Discovery
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user