Frontend Changes

This commit is contained in:
sriram
2026-09-07 17:54:24 +05:30
parent d4845c27f7
commit a24113f1e4
3 changed files with 432 additions and 4 deletions

View File

@@ -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.

View File

@@ -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' && (

View 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>
);
}