/* ReturnGuard UI: hash router, forms, live pipeline view, dashboard, evaluation, hero animation. */ 'use strict'; const $ = (s, el = document) => el.querySelector(s); const $$ = (s, el = document) => [...el.querySelectorAll(s)]; const ANGLES = ['front', 'back', 'side']; const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const pct = (x, d = 1) => (x == null || isNaN(x)) ? '—' : (x * 100).toFixed(d) + '%'; const store = { get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* storage unavailable */ } }, }; const VERDICT = { ACCEPT: 'Accepted', HUMAN_REVIEW: 'Human review', REJECT: 'Rejected', RETAKE_REQUIRED: 'Retake photos', ERROR: 'Error', PROCESSING: 'Processing', }; const FRAUD_LABEL = { substitution: 'Product substitution', label_removed: 'Label removed / unit swap', wardrobing: 'Wardrobing (worn and returned)', false_damage_claim: 'False damage claim', damage_after_delivery: 'Damaged after delivery', }; const NODES = [ ['intake', 'Intake', 'Background removal, quality gate, features'], ['policy', 'Policy agent', 'Return window and category rules'], ['history', 'Customer history agent', 'Prior returns set the cut-offs'], ['screen', 'Visual screen', 'Object-only DINOv2 + colour, no LLM'], ['triage', 'Triage router', 'Fast path, or call an agent?'], ['identity', 'Identity agent', 'Blind label read, compared in code', true], ['inspector', 'Visual inspector', 'Six photos, independent verdict', true], ['decide', 'Decision agent', 'Evidence-weighted risk score'], ]; /* ── API ─────────────────────────────────────────────────────────────── */ async function api(url, opts) { const res = await fetch(url, opts); let data = {}; try { data = await res.json(); } catch (e) { /* non-JSON error page */ } if (!res.ok || data.success === false) throw new Error(data.error || `Request failed (${res.status})`); return data; } /* ── toast / busy ────────────────────────────────────────────────────── */ let toastTimer; function toast(msg, type = '') { const t = $('#toast'); t.textContent = msg; t.className = `toast show ${type}`; clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 4500); } function busy(on, title = '', sub = '', steps = null) { $('#busy').classList.toggle('show', on); $('#busy-spinner').classList.toggle('hidden', on && !!steps); $('#busy-steps').classList.toggle('hidden', !(on && steps)); if (on) { $('#busy-title').textContent = title; $('#busy-sub').textContent = sub; } if (on && steps) { $('#busy-steps').innerHTML = steps.map(s => `
  • ${esc(s.label)}
  • `).join(''); busyStep(steps[0].key); } } // Mark every step before `key` done and `key` itself active (with an optional detail like "front view (1/3)"). function busyStep(key, detail = '') { const items = $$('#busy-steps li'); const at = items.findIndex(li => li.dataset.step === key); if (at < 0) return; items.forEach((li, i) => { li.className = i < at ? 'done' : i === at ? 'active' : ''; $('.ls-detail', li).textContent = i === at && detail ? ` · ${detail}` : ''; }); } const DELIVERY_STEPS = [ { key: 'upload', label: 'Uploading photos' }, { key: 'background', label: 'Removing backgrounds' }, { key: 'quality', label: 'Checking photo quality' }, { key: 'embeddings', label: 'Computing DINOv2 embeddings and colour' }, { key: 'save', label: 'Saving the order' }, { key: 'labels', label: 'Gemini reading labels and serials' }, ]; const newJobId = () => (crypto.randomUUID ? crypto.randomUUID() : `job-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`); /* ── theme ───────────────────────────────────────────────────────────── */ function initTheme() { const apply = t => { document.documentElement.setAttribute('data-theme', t); $('.i-sun').classList.toggle('hidden', t === 'light'); $('.i-moon').classList.toggle('hidden', t !== 'light'); }; apply(document.documentElement.getAttribute('data-theme') || 'dark'); $('#theme-toggle').addEventListener('click', () => { const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'; apply(next); store.set('rg-theme', next); }); } /* ── router ──────────────────────────────────────────────────────────── */ const PAGES = ['home', 'deliver', 'return', 'result', 'dashboard', 'evaluation', 'architecture', 'about', 'account']; let pollTimer = null; let lastPage = null; function route() { const [page, arg] = location.hash.replace(/^#/, '').split('/'); const name = PAGES.includes(page) ? page : 'home'; // after signing in, go back to the page that asked for it if (name === 'account' && ['deliver', 'return', 'dashboard'].includes(lastPage)) store.set('rg-after-signin', `#${lastPage}`); lastPage = name; $$('.page').forEach(p => p.classList.toggle('active', p.id === `page-${name}`)); $$('[data-nav]').forEach(a => a.classList.toggle('active', a.dataset.nav === (name === 'result' ? 'return' : name))); $('#mobile-nav').classList.remove('open'); window.scrollTo(0, 0); clearTimeout(pollTimer); scene.setVisible(name === 'home'); if (name === 'dashboard') loadDashboard(); if (name === 'evaluation') loadEvaluation(); if (name === 'return') prefillReturn(); if (name === 'deliver' || name === 'return') refreshMe(); if (name === 'account') loadAccount(); if (name === 'result' && /^\d+$/.test(arg || '')) openResult(Number(arg)); } /* ── uploads & samples ───────────────────────────────────────────────── */ function setPreview(zone, file) { if (!file || !file.type.startsWith('image/')) return; zone.querySelector('img.preview')?.remove(); zone.querySelector('.u-tag')?.remove(); const img = document.createElement('img'); img.className = 'preview'; img.alt = ''; img.src = URL.createObjectURL(file); zone.prepend(img); const tag = document.createElement('span'); tag.className = 'u-tag'; tag.textContent = zone.querySelector('.u-label').textContent; zone.append(tag); zone.classList.add('has-file'); } function clearPreviews(form) { $$('.upload', form).forEach(z => { z.classList.remove('has-file'); z.querySelector('img.preview')?.remove(); z.querySelector('.u-tag')?.remove(); }); } function initUploads() { $$('.upload').forEach(zone => { const input = $('input', zone); input.addEventListener('change', () => setPreview(zone, input.files[0])); zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('drag'); }); zone.addEventListener('dragleave', () => zone.classList.remove('drag')); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('drag'); const f = e.dataTransfer.files[0]; if (!f) return; const dt = new DataTransfer(); dt.items.add(f); input.files = dt.files; setPreview(zone, f); }); }); } async function loadSample(set, formId) { const form = document.getElementById(formId); try { for (const a of ANGLES) { const blob = await (await fetch(`/static/samples/${set}_${a}.jpg`)).blob(); const file = new File([blob], `${set}_${a}.jpg`, { type: 'image/jpeg' }); const input = $(`input[name="${a}_image"]`, form); const dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; setPreview(input.closest('.upload'), file); } if (formId === 'delivery-form') { $('#d-product').value = 'Portronics SoundDrum 1 speaker (blue)'; $('#d-category').value = 'electronics'; } toast('Sample photos loaded. Submit when ready.', 'success'); } catch (e) { toast('Could not load the sample photos.', 'error'); } } function initSamples() { $$('[data-load]').forEach(b => b.addEventListener('click', e => { e.preventDefault(); loadSample(b.dataset.load, b.dataset.form); })); $$('[data-sample]').forEach(a => a.addEventListener('click', () => setTimeout(() => loadSample(a.dataset.sample, 'delivery-form'), 60))); } /* ── delivery ────────────────────────────────────────────────────────── */ function initDelivery() { const form = $('#delivery-form'); form.addEventListener('submit', async e => { e.preventDefault(); if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error'); busy(true, 'Registering delivery', 'Live progress from the server:', DELIVERY_STEPS); const jobId = newJobId(); const body = new FormData(form); body.append('job_id', jobId); let polling = true; (async function poll() { while (polling) { try { const p = await api(`/api/deliveries/progress/${jobId}`); if (polling && p.stage) busyStep(p.stage, p.detail); } catch (e) { /* progress is best effort */ } await new Promise(r => setTimeout(r, 450)); } })(); try { const data = await api('/api/deliveries', { method: 'POST', body }); polling = false; $$('#busy-steps li').forEach(li => { li.className = 'done'; }); store.set('rg-last-order', data.order.order_id); renderDelivery(data.order, data.images); clearPreviews(form); form.reset(); refreshMe(); toast(`Delivery registered as order #${data.order.order_id}`, 'success'); } catch (err) { toast(err.message, 'error'); } finally { polling = false; busy(false); } }); } function renderDelivery(o, images) { const fp = o.fingerprint; const ids = fp?.identifiers?.length ? fp.identifiers.map(i => `${esc(i.text)}`).join('') : 'None visible'; const list = arr => arr?.length ? arr.map(x => esc(typeof x === 'string' ? x : `${x.description} (${x.view})`)).join('; ') : 'None'; $('#delivery-result').innerHTML = `
    Delivery fingerprint saved
    #${o.order_id}order ID · ${esc(o.category)}
    ${fp ? `

    ${esc(fp.product_description)}

    ` : `

    Gemini could not read the labels for this delivery, so the label check will be skipped for this order. Reason: ${esc(o.fingerprint_error || 'unavailable')}

    `}
    Identifiers
    ${ids}
    Marks
    ${list(fp?.distinctive_marks)}
    Accessories
    ${list(fp?.accessories)}
    Tags
    ${list(fp?.tags_attached)}
    ${ANGLES.map(a => `
    ${a}${a} cut-out
    `).join('')}
    Process a return for #${o.order_id} →`; } /* ── return ──────────────────────────────────────────────────────────── */ function prefillReturn() { const last = store.get('rg-last-order'); if (last && !$('#r-order').value) $('#r-order').value = last; } function initReturn() { const form = $('#return-form'); form.addEventListener('submit', async e => { e.preventDefault(); if (!/^#?\d+$/.test($('#r-order').value.trim())) return toast('Enter the numeric order ID from the delivery.', 'error'); if (ANGLES.some(a => !$(`input[name="${a}_image"]`, form).files.length)) return toast('Add all three photos (front, back, side).', 'error'); busy(true, 'Uploading return photos', ''); try { const data = await api('/api/returns', { method: 'POST', body: new FormData(form) }); clearPreviews(form); $$('input[type=file]', form).forEach(i => { i.value = ''; }); location.hash = `#result/${data.return_id}`; } catch (err) { toast(err.message, 'error'); } finally { busy(false); } }); } /* ── result (live) ───────────────────────────────────────────────────── */ const LEVEL_BADGE = { good: 'b-ok', ok: 'b-ok', warn: 'b-warn', bad: 'b-bad', info: 'b-dim' }; const LEVEL_ICON = { good: '✓', ok: '✓', warn: '!', bad: '✕', info: 'i' }; async function openResult(id) { ['#res-verdict', '#res-identity', '#res-inspection', '#res-llm', '#res-views'].forEach(s => { $(s).innerHTML = ''; }); $('#res-checks').innerHTML = '

    Waiting for the agents…

    '; $('#res-gemini').innerHTML = '

    Waiting for the agents…

    '; $('#res-evidence').innerHTML = ''; $('#res-report').classList.add('hidden'); $('#res-title').textContent = 'Verifying return…'; const tick = async () => { try { const data = await api(`/api/returns/${id}`); renderResult(data); if (data.ret.status === 'PROCESSING' && location.hash === `#result/${id}`) pollTimer = setTimeout(tick, 1100); } catch (err) { $('#res-title').textContent = 'Return not found'; $('#res-sub').textContent = err.message; } }; tick(); } function renderPipeline(ret) { const done = new Set(ret.status === 'PROCESSING' ? ret.progress : (ret.result?.route || ret.progress)); const times = Object.fromEntries((ret.result?.trace || []).map(t => [t.node, t.ms])); const finished = ret.status !== 'PROCESSING'; const firstPending = NODES.find(([k]) => !done.has(k) && !['identity', 'inspector', 'decide'].includes(k)); $('#res-pipeline').innerHTML = NODES.map(([k, name, desc, llm]) => { let cls = '', st = 'waiting'; if (done.has(k)) { cls = 'done'; st = times[k] != null ? `${(times[k] / 1000).toFixed(1)} s` : 'done'; } else if (finished) { cls = 'skipped'; st = 'not needed'; } else if ((firstPending && firstPending[0] === k) || (!firstPending && done.has('triage') && k === 'decide')) { cls = 'running'; st = 'running'; } return `
    ${name}${desc}${st}
    `; }).join(''); } function gauge(risk, band) { const r = Math.max(0, Math.min(100, risk ?? 0)) / 100; const a = Math.PI * (1 - r); const x = 100 + 80 * Math.cos(a), y = 100 - 80 * Math.sin(a); const color = risk == null ? 'var(--text-3)' : risk < 50 ? 'var(--ok)' : risk < 90 ? 'var(--warn)' : 'var(--bad)'; return `
    ${risk ?? '—'}
    ${band ? esc(band.label) + ' · ' : ''}risk score out of 100
    `; } function checkRow(c) { return `
    ${LEVEL_ICON[c.level] || 'i'}
    ${esc(c.name)}${c.value ? `${esc(c.value)}` : ''}
    ${esc(c.verdict)}
    ${c.detail ? `
    ${esc(c.detail)}
    ` : ''}
    `; } function geminiCard(g, returnId) { const status = { ran: 'Ran', unavailable: 'Unavailable', not_needed: 'Not needed' }[g.status] || g.status; return `
    ${g.status === 'ran' ? (LEVEL_ICON[g.level] || 'i') : '–'}
    ${esc(g.agent)}${status}
    ${g.status === 'ran' ? `
    ${esc(g.verdict)}
    ` : ''} ${g.detail ? `
    ${esc(g.detail)}
    ` : ''} ${g.bullets?.length ? `` : ''} ${g.note ? `
    ${esc(g.note)}
    ` : ''} ${g.can_request ? `` : ''}
    `; } function renderResult({ ret, order, explained: x, delivery_images: dImg, return_images: rImg, report_url }) { $('#res-eyebrow').textContent = `Return #${ret.return_id} · Order #${ret.order_id}`; $('#res-sub').textContent = `${order.product_name || order.category} · customer ${order.customer_id} · reason: ${ret.reason.replace(/_/g, ' ')}`; renderPipeline(ret); if (ret.status === 'PROCESSING') { $('#res-title').textContent = 'Verifying return…'; $('#res-verdict').innerHTML = `
    Analysing
    Agents are working through the evidence. This page updates live.
    `; return; } if (ret.status === 'ERROR') { $('#res-title').textContent = 'Pipeline error'; $('#res-verdict').innerHTML = `
    Error
    ${esc(ret.error)}
    The return was not decided. Resubmit it, or review manually.
    `; return; } const r = ret.result, d = r.decision; $('#res-title').textContent = d.headline; $('#res-report').href = report_url || `/report/${ret.return_id}`; $('#res-report').classList.remove('hidden'); $('#res-verdict').innerHTML = `
    ${VERDICT[d.decision]}
    ${esc(x.decision.sentence)}
    ${x.decision.reasons.length ? `

    Main reasons: ${x.decision.reasons.map(esc).join(' · ')}

    ` : ''} ${d.fraud_type ? `${esc(FRAUD_LABEL[d.fraud_type] || d.fraud_type)}` : ''}
    ${r.llm_call_count ? `${r.llm_call_count} Gemini call${r.llm_call_count === 1 ? '' : 's'}` : 'Decided by the free visual check, no Gemini call'}${d.thresholds ? ` · auto-approved below ${d.thresholds.accept_below}, rejected from ${d.thresholds.reject_from}` : ''}
    ${d.risk_score != null ? gauge(d.risk_score, x.decision.risk_band) : ''}
    `; $('#res-checks').innerHTML = x.checks.map(checkRow).join('') || '

    No checks ran.

    '; $('#res-gemini').innerHTML = x.gemini.map(g => geminiCard(g, ret.return_id)).join(''); $$('[data-second-opinion]').forEach(b => b.addEventListener('click', () => requestSecondOpinion(Number(b.dataset.secondOpinion), b))); // technical details $('#res-evidence').innerHTML = x.evidence.length ? x.evidence.map(e => { const w = Math.min(Math.abs(e.weight) / 6, 1) * 50; const bar = e.weight >= 0 ? `left:50%;width:${w}%;background:var(--bad)` : `left:${50 - w}%;width:${w}%;background:var(--ok)`; return `
    ${esc(e.source.replace(/_/g, ' '))} · ${esc(e.effect)}
    ${esc(e.finding)}
    ${e.weight > 0 ? '+' : ''}${e.weight.toFixed(1)}
    `; }).join('') + '

    Each finding adds (red, towards fraud) or subtracts (green, towards genuine) log-odds; the total goes through a sigmoid to give the 0–100 score.

    ' : `

    ${esc(d.summary)}

    `; const idn = r.identity; $('#res-identity').innerHTML = idn?.locations ? `
    Identity agent · raw label readout
    ${idn.locations.map(l => ``).join('')}
    LocationRecorded at deliveryRead at returnResult
    ${esc(l.location)}
    ${esc(l.view)} view
    ${(l.expected || (order.fingerprint?.identifiers || []) .filter(i => i.location === l.location && i.view === l.view).map(i => i.text)).map(esc).join('
    ') || '—'}
    ${esc(l.observed) || '—'} ${esc(l.status.replace(/_/g, ' '))}
    ` : ''; const ins = r.inspection; $('#res-inspection').innerHTML = ins ? `
    Visual inspector · raw output per view
    ${ins.findings.map(f => ``).join('')}
    ViewVerdictDifferences
    ${esc(f.view)}${esc(f.verdict.replace(/_/g, ' '))}${esc(f.differences.join('; ')) || '—'}
    ` : ''; $('#res-llm').innerHTML = `
    LLM calls (${r.llm_call_count})
    ${r.llm_calls.length ? `${r.llm_calls.map(c => ``).join('')}
    AgentTimeTokensTries
    ${esc(c.agent.replace(/_/g, ' '))}${c.ok ? '' : ' failed'}${c.latency_s}s${c.input_tokens + c.output_tokens}${c.attempts}
    ` : '

    None. The free visual check settled this return on its own.

    '}`; const q = r.quality || {}; $('#res-views').innerHTML = `
    Delivered vs returned, view by view
    ${ANGLES.map(a => { const v = x.views[a]; const retImg = d.decision === 'RETAKE_REQUIRED' ? rImg[a].photo : rImg[a].cutout; return `
    DELIVEREDDelivered ${a}
    RETURNEDReturned ${a}
    ${a}${q[a] && !q[a].ok ? `${esc(q[a].problem)}` : v ? ` shape ${esc(v.shape)} ${esc(v.colour_verdict)}` : ''}
    `; }).join('')}
    `; } async function requestSecondOpinion(returnId, btn) { btn.disabled = true; btn.textContent = 'Asking Gemini…'; try { const { opinion } = await api(`/api/returns/${returnId}/second-opinion`, { method: 'POST' }); toast(opinion.ok ? 'Gemini second opinion added.' : 'Gemini could not run right now; the reason is shown below.', opinion.ok ? 'success' : 'error'); } catch (err) { toast(err.message, 'error'); } openResult(returnId); } /* ── account ─────────────────────────────────────────────────────────── */ let me = null; async function refreshMe() { try { me = await api('/api/auth/me'); } catch (e) { me = { customer: null }; } const c = me.customer; $('#nav-account').textContent = c ? c.customer_id : 'Sign in'; $('#nav-account').classList.toggle('signed-in', !!c); $$('[data-who]').forEach(el => { el.classList.toggle('out', !c); el.innerHTML = c ? `Signed inCustomer ${esc(c.customer_id)} · ${esc(c.email)}` : `Sign in so this is linked to your customer ID and return history.Sign in`; }); const chips = $('#r-my-orders'); if (chips) { chips.innerHTML = c && me.orders?.length ? 'Your orders:' + me.orders.map(o => ``).join('') : ''; $$('[data-pick]', chips).forEach(b => b.addEventListener('click', () => { $('#r-order').value = b.dataset.pick; })); } return me; } async function loadAccount() { await refreshMe(); const c = me.customer; $('#acct-out').classList.toggle('hidden', !!c); $('#acct-in').classList.toggle('hidden', !c); if (!c) return; $('#acct-id').textContent = c.customer_id; $('#acct-details').innerHTML = `
    Email
    ${esc(c.email)}
    Mobile
    ${esc(c.phone)}
    Member since
    ${esc(c.created_at.slice(0, 10))}
    `; const p = me.profile, px = me.profile_explained; $('#acct-profile').innerHTML = historyCard(me.profile, me.profile_explained); } function historyCard(p, px) { return checkRow({ name: 'Return history', ...px }) + `

    ${p.prior_returns} earlier return(s), ${p.prior_flagged} flagged. Your next return is auto-approved below risk ${p.accept_below} (standard: 50) and rejected from ${p.reject_from}.

    `; } const AUTH_COPY = { login: ['Welcome back', 'Sign in with the email and mobile number you registered with.'], signup: ['Create your account', 'Get a customer ID that links your deliveries and returns.'], }; const DEMO_ACCOUNT = { email: 'demo@returnguard.app', phone: '9000000001' }; function showAuthTab(tab) { $$('[data-auth-tab]').forEach(b => { const on = b.dataset.authTab === tab; b.classList.toggle('on', on); b.setAttribute('aria-selected', on); }); $('#login-form').classList.toggle('hidden', tab !== 'login'); $('#signup-form').classList.toggle('hidden', tab !== 'signup'); [$('#auth-title').textContent, $('#auth-sub').textContent] = AUTH_COPY[tab]; } async function authRequest(url, email, phone) { return api(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, phone }) }); } function afterSignIn(customer, verb) { toast(`${verb} · ${customer.customer_id}`, 'success'); const next = store.get('rg-after-signin'); if (next) { store.set('rg-after-signin', ''); location.hash = next; } else { loadAccount(); } } function initAccount() { $$('[data-auth-tab]').forEach(b => b.addEventListener('click', () => showAuthTab(b.dataset.authTab))); const submit = (formId, url, verb) => $(formId).addEventListener('submit', async e => { e.preventDefault(); const f = new FormData(e.target); try { const { customer } = await authRequest(url, f.get('email'), f.get('phone')); e.target.reset(); afterSignIn(customer, verb); } catch (err) { toast(err.message, 'error'); } }); submit('#signup-form', '/api/auth/signup', 'Account created'); submit('#login-form', '/api/auth/login', 'Signed in'); $('#demo-login').addEventListener('click', async () => { try { let customer; try { ({ customer } = await authRequest('/api/auth/login', DEMO_ACCOUNT.email, DEMO_ACCOUNT.phone)); } catch (e) { ({ customer } = await authRequest('/api/auth/signup', DEMO_ACCOUNT.email, DEMO_ACCOUNT.phone)); } afterSignIn(customer, 'Signed in to the demo account'); } catch (err) { toast(err.message, 'error'); } }); $('#logout-btn').addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }); toast('Signed out.', 'success'); loadAccount(); }); } /* ── dashboard ───────────────────────────────────────────────────────── */ async function loadDashboard() { await refreshMe(); const signedIn = !!me.customer; $('#dash-signed-out').classList.toggle('hidden', signedIn); $('#dash-body').classList.toggle('hidden', !signedIn); $('#dash-eyebrow').textContent = signedIn ? `Customer ${me.customer.customer_id}` : 'Your activity'; if (!signedIn) return; try { const [{ stats: s, profile, profile_explained: px }, { returns }, { orders }] = await Promise.all([api('/api/stats'), api('/api/returns'), api('/api/orders')]); const by = s.by_status; const flagged = (by.HUMAN_REVIEW || 0) + (by.REJECT || 0); $('#dash-kpis').innerHTML = [ [s.orders, 'My deliveries'], [s.returns, 'My returns'], [by.ACCEPT || 0, 'Auto-accepted'], [flagged, 'Flagged (review + reject)'], [s.decided_without_llm, 'Decided with no LLM call'], ].map(([v, k]) => `
    ${v}
    ${k}
    `).join(''); $('#dash-history').innerHTML = historyCard(profile, px); $('#dash-returns').innerHTML = returns.length ? ` ${returns.map(r => ``).join('')}
    ReturnOrderProductReasonRiskVerdict
    #${r.return_id}#${r.order_id} ${esc(r.product_name || r.category)}${esc(r.reason.replace(/_/g, ' '))}${r.risk_score ?? '—'} ${VERDICT[r.status] || r.status}
    ` : '
    No returns yet. Register a delivery, then process a return.
    '; $('#dash-orders').innerHTML = orders.length ? ` ${orders.map(o => ``).join('')}
    OrderDeliveredProductCategoryFingerprintLast return
    #${o.order_id}${esc(o.delivered_at.slice(0, 10))}${esc(o.product_name || '—')}${esc(o.category)} ${o.has_fingerprint ? 'saved' : 'visual only'} ${o.last_return_status ? `${VERDICT[o.last_return_status] || o.last_return_status}` : '—'}
    ` : '
    No deliveries yet.
    '; $$('#dash-returns tr.click').forEach(tr => tr.addEventListener('click', () => { location.hash = tr.dataset.href; })); } catch (err) { toast(err.message, 'error'); } } /* ── evaluation ──────────────────────────────────────────────────────── */ let evalCache = null; async function getEval(pairs = false) { if (evalCache && (!pairs || evalCache.per_pair)) return evalCache; evalCache = (await api(`/api/metrics${pairs ? '?pairs=1' : ''}`)).results; return evalCache; } let stressCache; async function getStress() { if (stressCache === undefined) { try { stressCache = await api('/api/stress'); } catch (e) { stressCache = null; } } return stressCache; } const num = n => Number(n).toLocaleString('en-US'); async function loadHomeMetrics() { try { const [r, s] = await Promise.all([getEval(), getStress()]); const m = r.metrics, a = r.agentic; const p = s?.pooled; const vals = { f0_5: m.f0_5.toFixed(3), precision: pct(p ? p.precision : m.precision), recall: pct(m.recall), no_llm: a.decided_without_llm_pct + '%', calls: a.llm_calls_per_return }; $$('#home-metrics [data-m]').forEach(el => { el.textContent = vals[el.dataset.m]; }); const prec = p ? ` Precision ${pct(p.precision)}: ${num(p.tp)} of ${num(p.flags)} flags were fraud across ${p.conditions} photo conditions (95% interval ${pct(p.precision_ci[0])}–${pct(p.precision_ci[1])}); ${m.tp}/${m.tp + m.fp} on the main set.` : (m.precision_ci ? ` Precision 95% interval ${pct(m.precision_ci[0], 0)}–${pct(m.precision_ci[1], 0)}.` : ''); const held = r.held_out ? ` On items added after tuning was frozen: F0.5 ${r.held_out.f0_5.toFixed(3)} (${r.held_out.pairs} pairs).` : ''; $('#home-metrics-note').innerHTML = `F0.5, recall and LLM use measured on ${r.dataset.pairs} labelled delivery/return pairs (${r.dataset.genuine} genuine, ${r.dataset.fraud} fraud, ${r.dataset.items} physical items) with ${esc(r.model)}.${prec}${held} How these are measured →`; } catch (e) { $('#home-metrics-note').textContent = 'No evaluation results yet. Run: python scripts/evaluate.py'; } } async function loadEvaluation() { const body = $('#eval-body'); let r; try { r = await getEval(true); } catch (e) { body.innerHTML = `
    No evaluation results yet. Run python scripts/evaluate.py.
    `; return; } const s = await getStress(); const sp = s?.pooled; const m = r.metrics, a = r.agentic; const kpi = (v, k, d, hero) => `
    ${v}
    ${k}
    ${d ? `
    ${d}
    ` : ''}
    `; const cell = (lab, dec) => { const n = (r.confusion[lab] || {})[dec] || 0; const good = (lab === 'genuine' && dec === 'ACCEPT') || (lab === 'fraud' && dec !== 'ACCEPT'); return `
    ${n}${good ? 'correct' : 'error'}
    `; }; const misses = (r.per_pair || []).filter(p => (p.decision !== 'ACCEPT') !== p.is_fraud); const maxRoute = Math.max(...Object.values(a.routes)); const ci = c => c ? `95% interval ${pct(c[0], 0)}–${pct(c[1], 0)}` : ''; const DECISIONS = ['ACCEPT', 'HUMAN_REVIEW', 'REJECT', 'RETAKE_REQUIRED']; body.innerHTML = `
    ${kpi(m.f0_5.toFixed(3), 'F0.5 score', 'precision-weighted (β = 0.5)', true)} ${sp ? kpi(pct(sp.precision), 'Precision, stress-tested', `${num(sp.tp)} of ${num(sp.flags)} flags were fraud · 95% interval ${pct(sp.precision_ci[0])}–${pct(sp.precision_ci[1])} · main set ${m.tp}/${m.tp + m.fp}`) : kpi(pct(m.precision), 'Precision', `${m.tp} of ${m.tp + m.fp} flags were fraud · ${ci(m.precision_ci)}`)} ${kpi(pct(m.recall), 'Recall', `${m.tp} of ${m.tp + m.fn} frauds flagged · ${ci(m.recall_ci)}`)} ${kpi(m.f1.toFixed(3), 'F1 score')} ${kpi(pct(m.false_rejection_rate), 'Genuine returns flagged', `${m.fp} of ${m.fp + m.tn} · ${ci(m.false_rejection_rate_ci)}`)}
    ${r.held_out ? `
    Held-out test · ${r.held_out.items.map(esc).join(' + ')}
    ${kpi(r.held_out.f0_5.toFixed(3), 'F0.5 on held-out items', `${r.held_out.pairs} pairs (${r.held_out.genuine} genuine, ${r.held_out.fraud} fraud)`, true)} ${kpi(pct(r.held_out.precision), 'Precision', `${r.held_out.tp}/${r.held_out.tp + r.held_out.fp} · ${ci(r.held_out.precision_ci)}`)} ${kpi(pct(r.held_out.recall), 'Recall', `${r.held_out.tp}/${r.held_out.tp + r.held_out.fn} · ${ci(r.held_out.recall_ci)}`)} ${kpi(pct(r.held_out.false_rejection_rate), 'Genuine flagged', `${r.held_out.fp} of ${r.held_out.fp + r.held_out.tn}`)} ${kpi('frozen', 'Thresholds & weights', 'not changed after these items were added')}
    ` : ''}
    Positive class = fraud. A return is "flagged" when it is not auto-accepted (human review, reject${r.retakes?.count ? `, or one of the ${r.retakes.count} retake requests` : ''}). F0.5 weights precision twice as much as recall, because wrongly blocking a genuine customer costs trust. The 95% intervals show how much a small test set can say: a perfect score on a few dozen pairs is still compatible with a noticeably lower true rate.
    Ablation · what each stage adds (F0.5)
    ${r.ablations.map(x => `
    ${esc(x.name)}
    ${esc(x.detail)}
    ${x.f0_5.toFixed(3)}
    `).join('')}
    ${r.ablations.map(x => ``).join('')}
    SystemPrecisionRecallLLM calls
    ${esc(x.name)}${pct(x.precision)}${pct(x.recall)}${x.llm_calls_per_return ?? '—'}
    Confusion matrix · full pipeline
    Accept
    Human review
    Reject
    Retake
    Genuine (${r.dataset.genuine})
    ${DECISIONS.map(d => cell('genuine', d)).join('')}
    Fraud (${r.dataset.fraud})
    ${DECISIONS.map(d => cell('fraud', d)).join('')}
    ${Object.entries(r.by_pair_type).map(([k, v]) => `${DECISIONS.map(d => ``).join('')}`).join('')}
    Pair typeAcceptReviewRejectRetake
    ${esc(k.replace(/_/g, ' '))}${v[d] || 0}
    ${sp ? robustness(s, kpi) : ''}
    Agentic metrics
    ${kpi(a.llm_calls_per_return, 'LLM calls per return', `v1 made up to ${a.v1_llm_calls_per_return_upper_bound}`, true)} ${kpi(a.decided_without_llm_pct + '%', 'Decided with no LLM call')} ${kpi(a.schema_valid_first_try_pct + '%', 'Valid structured output, 1st try', `${a.total_retries} retries`)} ${kpi(`${a.identity_agent_accuracy.correct}/${a.identity_agent_accuracy.runs}`, 'Identity agent correct')} ${kpi(`${a.inspector_accuracy.correct}/${a.inspector_accuracy.runs}`, 'Visual inspector correct')}
    Routes through the graph
    ${Object.entries(a.routes).map(([k, v]) => `
    ${esc(k)}
    ${v}
    `).join('')}
    Cost & latency
    Tokens / return
    ${a.tokens_per_return}
    Cost / 1,000 returns
    ${a.est_cost_usd_per_1000_returns != null ? '$' + a.est_cost_usd_per_1000_returns : 'n/a'} (Gemini list price)
    Latency / return
    mean ${a.latency_s_per_return.mean}s · max ${a.latency_s_per_return.p_max}s
    ${Object.entries(a.agents).map(([k, v]) => `
    ${esc(k.replace(/_/g, ' '))}
    ${v.calls} calls · ${v.mean_latency_s}s · ${v.mean_tokens} tok
    `).join('')}
    Error analysis & honest caveats
    Misclassified pairs (${misses.length})
    ${misses.length ? `${misses.map(p => ``).join('')}
    PairTruthDecisionStrongest evidence
    ${esc(p.delivery_item)} → ${esc(p.return_item)}
    ${esc(p.pair_type.replace(/_/g, ' '))}
    ${p.is_fraud ? 'fraud' : 'genuine'}${VERDICT[p.decision]} ${p.evidence.slice().sort((x, y) => Math.abs(y.weight) - Math.abs(x.weight)).slice(0, 2).map(e => `${esc(e.finding)} (${e.weight > 0 ? '+' : ''}${e.weight.toFixed(1)})`).join('
    ')}

    Each row shows the two strongest pieces of evidence behind the wrong decision. Fixes suggested by these errors are not applied before this run: they come from the same data, so they need new items to be validated honestly. Full per-pair log: results/eval_pairs.csv.

    ` : '

    None.

    '}
    ${r.real_recapture ? `
    Real re-photographs vs simulated
    ${r.real_recapture.map(x => ``).join('')}
    Genuine pairsPairsAuto-acceptedFlagged
    ${esc(x.kind)}${x.pairs}${x.accepted}${x.flagged}

    Simulated re-captures re-use the delivery photo, so they are easy. Real ones are separate photo sessions (new place, angle and lighting) and are the honest test.

    ` : ''}
    Read these numbers with care. ${esc(r.dataset.note)} ${r.dataset.pairs} pairs is small, and the thresholds and weights were chosen by looking at the first 6 items${r.held_out ? ' (the held-out items above were added only after that)' : ''}. Reproduce with python scripts/evaluate.py. Generated ${esc(r.generated_at)} with ${esc(r.model)}.
    `; } function robustness(s, kpi) { const p = s.pooled, sum = s.summary; const byLabel = {}; s.conditions.forEach(c => { (byLabel[c.label] = byLabel[c.label] || {})[c.level] = c; }); const lvl = c => c ? `${c.fp}/${c.genuine}${pct(c.precision)}` : '——'; return `
    Robustness · precision under bad photos
    ${kpi(pct(p.precision), 'Pooled precision', `${num(p.tp)} of ${num(p.flags)} flags were fraud · 95% interval ${pct(p.precision_ci[0])}–${pct(p.precision_ci[1])}`, true)} ${kpi(pct(p.recall), 'Recall', `every fraud flagged in all ${p.conditions} conditions`)} ${kpi(`${sum.genuine_flagged_by_local_stage}/${sum.genuine_returns_tested}`, 'Genuine returns flagged', `${sum.hard_false_rejections} hard rejections · ${sum.sent_to_gemini} sent to Gemini · ${sum.retakes} retakes`)}
    Each return photo distorted ${p.conditions / 2} ways at two strengths (${num(p.pairs)} pairs)
    ${Object.entries(byLabel).map(([k, v]) => `${lvl(v.mild)}${lvl(v.strong)}`).join('')}
    ConditionMild · genuine flaggedPrecisionStrong · genuine flaggedPrecision
    ${esc(k)}

    Only the local stage (DINOv2 + colour) was run, so a genuine return that would be sent to Gemini counts as a false flag here. That makes these figures a worst case. The weak spots are strong warm or cool light, very heavy JPEG compression and photos taken from far away. Colour-cast correction and a compression quality gate are the planned fixes. Precision also depends on how common fraud is: this test has 7 fraud pairs per genuine one, while a real store might see around 15% fraud, which would give a much lower precision for the local stage alone. Reproduce with python scripts/stress_test_precision.py.

    `; } /* ── hero animation ──────────────────────────────────────────────────── */ const scene = (() => { const el = $('#scene'); if (!el) return { setVisible() {} }; const steps = [ { t: 700, cap: 'A delivery agent brings a new speaker to the customer.', day: 'Day 0 · Delivery' }, { t: 1900, cap: 'At the door, before handing it over…' }, { t: 3400, cap: 'the agent photographs it in ReturnGuard: 3 views, label read, fingerprint saved.' }, { t: 2400, cap: 'The customer receives the blue speaker.' }, { t: 2600, cap: 'The customer asks for a return and hands back… a speaker.', day: 'Day 5 · Return pickup', fade: true }, { t: 3600, cap: 'The agent scans it: same model, but the colour is off and the batch label is gone.' }, { t: 4400, cap: 'Fraud caught at the doorstep. The refund is held and the seller gets the evidence.' }, ]; let i = 0, timer = null, playing = !REDUCED_MOTION, visible = true; const dots = $('#scene-dots'); dots.innerHTML = steps.map((_, k) => ``).join(''); function show(n) { i = n; el.dataset.step = n; $('#scene-agent').classList.toggle('walking', n <= 1); $('#scene-caption').textContent = steps[n].cap; const day = steps.slice(0, n + 1).reverse().find(s => s.day); $('#scene-day').textContent = day.day; $$('button', dots).forEach((b, k) => b.classList.toggle('on', k === n)); } function schedule() { clearTimeout(timer); if (playing && visible && !document.hidden) timer = setTimeout(next, steps[i].t); } function next() { const n = (i + 1) % steps.length; if (steps[n].fade) { el.classList.add('transition'); timer = setTimeout(() => { show(n); el.classList.remove('transition'); schedule(); }, 1400); } else { show(n); schedule(); } } $$('button', dots).forEach((b, k) => b.addEventListener('click', () => { show(k); schedule(); })); $('#scene-toggle').addEventListener('click', () => { playing = !playing; $('#scene-toggle').textContent = playing ? 'Pause' : 'Play'; schedule(); }); document.addEventListener('visibilitychange', schedule); show(REDUCED_MOTION ? 6 : 0); if (REDUCED_MOTION) $('#scene-toggle').textContent = 'Play'; schedule(); return { setVisible(v) { visible = v; schedule(); } }; })(); /* ── init ────────────────────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { initTheme(); initUploads(); initSamples(); initDelivery(); initReturn(); initAccount(); refreshMe(); $('#menu-btn').addEventListener('click', () => $('#mobile-nav').classList.toggle('open')); $('#dash-refresh').addEventListener('click', loadDashboard); window.addEventListener('hashchange', route); route(); loadHomeMetrics(); });