Frontend Changes
This commit is contained in:
@@ -370,6 +370,39 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// --- Admin: brand discovery (a brand NAME -> the same 11-stage pipeline) ---
|
||||
// See app/api/routers/brand_discovery.py. `preview` discovers and writes
|
||||
// nothing; `ingest` takes back the rows the operator kept and stages them as
|
||||
// an ordinary catalog batch, so the response is a BatchOut and the Batch tab
|
||||
// can adopt it.
|
||||
//
|
||||
// NOTE the flag defaults here are ON, unlike the batch uploader above. The
|
||||
// reasoning is not inconsistent: a batch is up to twenty spreadsheets of
|
||||
// unknown size, while this is one brand the operator has just reviewed
|
||||
// row by row - and rows ingested without images stay imageless, because
|
||||
// nothing downstream goes back to fill them in.
|
||||
previewBrandDiscovery: (payload) =>
|
||||
request('/api/admin/brand-discovery/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
ingestBrandDiscovery: (
|
||||
brand,
|
||||
products,
|
||||
{ use_llm = true, fetch_images = true, acknowledge_inactive = false } = {},
|
||||
) =>
|
||||
request('/api/admin/brand-discovery/ingest', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
brand,
|
||||
products,
|
||||
use_llm,
|
||||
fetch_images,
|
||||
acknowledge_inactive,
|
||||
}),
|
||||
}),
|
||||
|
||||
// --- Admin: the review inbox (a colleague dropped files; you decide) ---
|
||||
// The uploader half is POST /api/uploads/catalog, which the browser never
|
||||
// calls - a colleague hits it with an X-API-Key from a script.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Wrench, Database, Layers, ClipboardCheck, Workflow } from 'lucide-react';
|
||||
import { Wrench, Database, Layers, ClipboardCheck, Workflow, Sparkles } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import { NavigationHeader } from '../components/NavigationHeader';
|
||||
import { BatchCatalogPanel } from './BatchCatalogPanel';
|
||||
import { BrandDiscoveryPanel } from './BrandDiscoveryPanel';
|
||||
import { UploadResultsPanel } from './UploadResultsPanel';
|
||||
import { OrchestrationPanel } from './OrchestrationPanel';
|
||||
|
||||
@@ -21,9 +22,16 @@ import { OrchestrationPanel } from './OrchestrationPanel';
|
||||
*
|
||||
* Three early sub-tabs (train/test dataset upload, stock-remaining discount
|
||||
* allocator, and a trigger for the brand ingestion pipeline) were retired from
|
||||
* the UI ONLY. Their endpoints are all still registered and still work; brand
|
||||
* ingestion in particular is reachable from
|
||||
* `python cli/ingest_brand.py "<brand>"` and from POST /api/catalog/generate.
|
||||
* the UI ONLY. Their endpoints are all still registered and still work.
|
||||
*
|
||||
* Brand ingestion has since come back, as "Brand Discovery" - but it is a
|
||||
* different thing behind the same idea. The retired tab drove
|
||||
* POST /api/catalog/generate, which does NOT run the eleven stages and mints a
|
||||
* random image_id, so running it twice produces two catalogs. The new tab
|
||||
* discovers products, shows them for approval, and then stages them as an
|
||||
* ordinary catalog batch through the same pipeline a spreadsheet goes through.
|
||||
* The old route and `python cli/ingest_brand.py "<brand>"` are both still
|
||||
* registered and untouched.
|
||||
*
|
||||
* "Store Catalog Ingestion" - a single-file uploader - went further: the tab,
|
||||
* its panel, and the /api/admin/store-catalog/* routes behind it were all
|
||||
@@ -35,6 +43,7 @@ import { OrchestrationPanel } from './OrchestrationPanel';
|
||||
*/
|
||||
const TABS = [
|
||||
{ id: 'project', label: 'Project Overview Details', icon: Database },
|
||||
{ id: 'discover', label: 'Brand Discovery', icon: Sparkles },
|
||||
{ id: 'batch', label: 'Batch Catalog Ingestion', icon: Layers },
|
||||
// The id stays 'inbox' - the badge, the render guard and the onBatchStarted
|
||||
// wiring all key off it, and the tab still owns the review inbox; it just
|
||||
@@ -139,6 +148,19 @@ export function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Discovery has no progress UI of its own: it stages an ordinary
|
||||
catalog batch and hands it to the Batch tab through the same
|
||||
`adoptBatch` prop the approval gate uses, which already draws the
|
||||
eleven stages and owns Resume and Cancel. */}
|
||||
{activeTab === 'discover' && (
|
||||
<BrandDiscoveryPanel
|
||||
onBatchStarted={(batch) => {
|
||||
setAdoptedBatch(batch);
|
||||
setActiveTab('batch');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'batch' && <BatchCatalogPanel adoptBatch={adoptedBatch} />}
|
||||
|
||||
{activeTab === 'inbox' && (
|
||||
|
||||
373
src/pages/BrandDiscoveryPanel.jsx
Normal file
373
src/pages/BrandDiscoveryPanel.jsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Search, Sparkles, Loader2, AlertTriangle, PlayCircle, Database,
|
||||
ShieldCheck, PackageSearch, Barcode,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
/*
|
||||
* A brand NAME -> the same 11-stage pipeline the spreadsheet uploader drives.
|
||||
*
|
||||
* Two steps, and the gap between them is the feature. Discover reads Open Food
|
||||
* Facts and (optionally) the local language model and shows what it found,
|
||||
* writing nothing. The operator unticks whatever looks wrong. Only then is the
|
||||
* selection staged, as an ordinary catalog batch.
|
||||
*
|
||||
* That gap is not ceremony. Open Food Facts rows are real products with real
|
||||
* GTINs, but the language model can produce a well-formed product that does not
|
||||
* exist, and nothing downstream can catch one: it resolves a category, gets a
|
||||
* price band and an SKU, and passes the validation gate. A person reading the
|
||||
* list is the only check there is, so the list is shown.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const EVIDENCE_TONE = {
|
||||
openfacts: 'bg-emerald-500/10 text-emerald-700 border-emerald-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',
|
||||
catalog: 'already in catalog',
|
||||
registry: 'known sub-brand',
|
||||
};
|
||||
|
||||
function Toggle({ checked, onChange, label, hint }) {
|
||||
return (
|
||||
<label className="flex items-start gap-2 text-xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-3.5 w-3.5 cursor-pointer accent-amber-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-ink-950">{label}</span>
|
||||
{hint && <span className="block text-[11px] text-slate-500">{hint}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ icon: Icon, value, label }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-ink-900/10 bg-white px-3 py-2">
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="text-sm font-bold text-ink-950">{value}</div>
|
||||
<div className="text-[11px] text-slate-500">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BrandDiscoveryPanel({ onBatchStarted }) {
|
||||
const [brand, setBrand] = useState('');
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState(null);
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
|
||||
const [useOpenfacts, setUseOpenfacts] = useState(true);
|
||||
const [useLlm, setUseLlm] = useState(true);
|
||||
const [requireEvidence, setRequireEvidence] = useState(true);
|
||||
const [fetchImages, setFetchImages] = useState(true);
|
||||
// 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);
|
||||
|
||||
const products = result?.products || [];
|
||||
const counts = result?.counts || {};
|
||||
|
||||
const chosen = useMemo(
|
||||
() => products.filter((_, index) => selected.has(index)),
|
||||
[products, selected],
|
||||
);
|
||||
|
||||
const discover = async () => {
|
||||
if (!brand.trim()) return;
|
||||
setDiscovering(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setAcknowledgeInactive(false);
|
||||
try {
|
||||
const body = await api.previewBrandDiscovery({
|
||||
brand: brand.trim(),
|
||||
use_openfacts: useOpenfacts,
|
||||
use_llm: useLlm,
|
||||
require_evidence: requireEvidence,
|
||||
});
|
||||
setResult(body);
|
||||
// Pre-tick what discovery judged confident. An uncorroborated row from
|
||||
// the language model arrives unticked rather than hidden, so the operator
|
||||
// decides rather than the default deciding for them.
|
||||
setSelected(
|
||||
new Set(
|
||||
(body.products || [])
|
||||
.map((p, index) => (p.selected ? index : null))
|
||||
.filter((index) => index !== null),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(e.message || 'Discovery failed.');
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ingest = async () => {
|
||||
if (!chosen.length) return;
|
||||
setStarting(true);
|
||||
setError('');
|
||||
try {
|
||||
const batch = await api.ingestBrandDiscovery(
|
||||
result.brand,
|
||||
chosen.map((p) => ({
|
||||
product_name: p.product_name,
|
||||
title: p.title,
|
||||
category: p.category,
|
||||
description: p.description,
|
||||
size_variants: p.size_variants,
|
||||
providers: p.providers,
|
||||
highlights: p.highlights,
|
||||
nutrients: p.nutrients,
|
||||
fssai_license: p.fssai_license,
|
||||
barcode: p.barcode,
|
||||
image_url: p.image_url,
|
||||
})),
|
||||
{ fetch_images: fetchImages, acknowledge_inactive: acknowledgeInactive },
|
||||
);
|
||||
onBatchStarted?.(batch);
|
||||
} catch (e) {
|
||||
// 409 is the ACTIVE_BRANDS refusal, and it is recoverable in place: the
|
||||
// message names the exact .env line, and the button below re-sends with
|
||||
// the acknowledgement.
|
||||
if (e.status === 409) setAcknowledgeInactive(true);
|
||||
setError(e.message || 'Could not start the ingestion.');
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (index) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const allSelected = products.length > 0 && selected.size === products.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* ---- the brand box ---- */}
|
||||
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
<h2 className="text-sm font-bold text-ink-950">Brand Discovery</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600 max-w-3xl">
|
||||
Enter any brand - one already in the catalog or a new one. Products are
|
||||
found in Open Food Facts first, which supplies real names, pack sizes
|
||||
and barcodes, and the local language model fills the gaps. Nothing is
|
||||
written until you review the list and press Ingest.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative flex-1 min-w-64">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={brand}
|
||||
onChange={(e) => setBrand(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && discover()}
|
||||
placeholder="Britannia"
|
||||
className="w-full rounded-xl border border-ink-900/10 bg-white py-2 pl-9 pr-3 text-sm text-ink-950 outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={discover}
|
||||
disabled={discovering || !brand.trim()}
|
||||
className="flex items-center gap-2 rounded-xl bg-amber-500 px-4 py-2 text-xs font-bold text-slate-950 disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{discovering ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageSearch className="h-4 w-4" />
|
||||
)}
|
||||
{discovering ? 'Discovering...' : 'Discover products'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Toggle
|
||||
checked={useOpenfacts}
|
||||
onChange={setUseOpenfacts}
|
||||
label="Open Food Facts"
|
||||
hint="Real products, with barcodes"
|
||||
/>
|
||||
<Toggle
|
||||
checked={useLlm}
|
||||
onChange={setUseLlm}
|
||||
label="Language model"
|
||||
hint="Adds breadth; can invent products"
|
||||
/>
|
||||
<Toggle
|
||||
checked={requireEvidence}
|
||||
onChange={setRequireEvidence}
|
||||
label="Require evidence"
|
||||
hint="Drop model rows nothing corroborates"
|
||||
/>
|
||||
<Toggle
|
||||
checked={fetchImages}
|
||||
onChange={setFetchImages}
|
||||
label="Fetch images on ingest"
|
||||
hint="Slow; nothing fills them in later"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-xl border border-maroon-500/30 bg-maroon-100 px-4 py-3 text-xs text-maroon-600">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-pre-wrap">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- what was found ---- */}
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat icon={PackageSearch} value={counts.discovered ?? 0} label="products found" />
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{(result.warnings || []).map((warning) => (
|
||||
<div
|
||||
key={warning}
|
||||
className="flex items-start gap-2 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-800"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-slate-600">
|
||||
Writing to <code className="font-mono text-ink-950">{result.table}</code>
|
||||
{' - '}
|
||||
<span className="font-semibold text-ink-950">{selected.size}</span> of{' '}
|
||||
{products.length} selected
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelected(allSelected ? new Set() : new Set(products.map((_, i) => i)))
|
||||
}
|
||||
className="ml-2 underline cursor-pointer hover:text-ink-950"
|
||||
>
|
||||
{allSelected ? 'clear all' : 'select all'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={ingest}
|
||||
disabled={starting || !chosen.length}
|
||||
className="flex items-center gap-2 rounded-xl bg-ink-950 px-4 py-2 text-xs font-bold text-white disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
{starting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
{acknowledgeInactive
|
||||
? `Ingest ${chosen.length} anyway`
|
||||
: `Ingest ${chosen.length} product${chosen.length === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-ink-900/10 bg-white">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="border-b border-ink-900/10 bg-paper-50 text-left text-slate-500">
|
||||
<tr>
|
||||
<th className="w-8 px-3 py-2" />
|
||||
<th className="px-3 py-2 font-semibold">Product</th>
|
||||
<th className="px-3 py-2 font-semibold">Pack</th>
|
||||
<th className="px-3 py-2 font-semibold">Likely category</th>
|
||||
<th className="px-3 py-2 font-semibold">Barcode</th>
|
||||
<th className="px-3 py-2 font-semibold">Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product, index) => (
|
||||
<tr
|
||||
key={`${product.product_name}-${product.size_variants?.[0] || index}`}
|
||||
className="border-b border-ink-900/5 last:border-0 hover:bg-paper-50"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(index)}
|
||||
onChange={() => toggle(index)}
|
||||
className="h-3.5 w-3.5 cursor-pointer accent-amber-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-semibold text-ink-950">{product.product_name}</div>
|
||||
{product.matches_existing && (
|
||||
<div className="text-[11px] text-sky-700">
|
||||
fills in the existing row
|
||||
</div>
|
||||
)}
|
||||
{(product.notes || []).map((note) => (
|
||||
<div key={note} className="text-[11px] text-slate-500">
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600">
|
||||
{product.size_variants?.length
|
||||
? product.size_variants.join(', ')
|
||||
: <span className="text-slate-400">stage 4 will decide</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600">{product.category_hint || '-'}</td>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-slate-600">
|
||||
{product.barcode || <span className="text-slate-400">-</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{product.evidence ? (
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${EVIDENCE_TONE[product.evidence]}`}
|
||||
>
|
||||
{EVIDENCE_LABEL[product.evidence]}
|
||||
</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">
|
||||
model only
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{products.length === 0 && (
|
||||
<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 with "Require evidence" on, any
|
||||
uncorroborated model suggestions were dropped. Try turning that off
|
||||
to see them, unticked.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user