From a24113f1e400aaf13a3b5c30f0475cbefda2d241 Mon Sep 17 00:00:00 2001 From: sriram Date: Mon, 7 Sep 2026 17:54:24 +0530 Subject: [PATCH] Frontend Changes --- src/api/client.js | 33 +++ src/pages/AdminPage.jsx | 30 ++- src/pages/BrandDiscoveryPanel.jsx | 373 ++++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 src/pages/BrandDiscoveryPanel.jsx diff --git a/src/api/client.js b/src/api/client.js index 02c7f56..fab7d70 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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. diff --git a/src/pages/AdminPage.jsx b/src/pages/AdminPage.jsx index 8aa16c6..e6bfed0 100644 --- a/src/pages/AdminPage.jsx +++ b/src/pages/AdminPage.jsx @@ -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 ""` 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 ""` 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() { ))} + {/* 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' && ( + { + setAdoptedBatch(batch); + setActiveTab('batch'); + }} + /> + )} + {activeTab === 'batch' && } {activeTab === 'inbox' && ( diff --git a/src/pages/BrandDiscoveryPanel.jsx b/src/pages/BrandDiscoveryPanel.jsx new file mode 100644 index 0000000..efaf024 --- /dev/null +++ b/src/pages/BrandDiscoveryPanel.jsx @@ -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 ( + + ); +} + +function Stat({ icon: Icon, value, label }) { + return ( +
+ +
+
{value}
+
{label}
+
+
+ ); +} + +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 ( +
+ {/* ---- the brand box ---- */} +
+
+ +

Brand Discovery

+
+

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

+ +
+
+ + 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" + /> +
+ +
+ +
+ + + + +
+
+ + {error && ( +
+ + {error} +
+ )} + + {/* ---- what was found ---- */} + {result && ( +
+
+ + + + +
+ + {(result.warnings || []).map((warning) => ( +
+ + {warning} +
+ ))} + +
+
+ Writing to {result.table} + {' - '} + {selected.size} of{' '} + {products.length} selected + +
+ +
+ +
+ + + + + + + + + + + + {products.map((product, index) => ( + + + + + + + + + ))} + +
+ ProductPackLikely categoryBarcodeEvidence
+ toggle(index)} + className="h-3.5 w-3.5 cursor-pointer accent-amber-500" + /> + +
{product.product_name}
+ {product.matches_existing && ( +
+ fills in the existing row +
+ )} + {(product.notes || []).map((note) => ( +
+ {note} +
+ ))} +
+ {product.size_variants?.length + ? product.size_variants.join(', ') + : stage 4 will decide} + {product.category_hint || '-'} + {product.barcode || -} + + {product.evidence ? ( + + {EVIDENCE_LABEL[product.evidence]} + + ) : ( + + model only + + )} +
+
+ + {products.length === 0 && ( +

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

+ )} +
+ )} +
+ ); +}