No data yet.
;
}
return (
No trend data.
;
}
const max = trend.reduce((m, d) => Math.max(m, d[metric] || 0), 0) || 1;
const barW = 100 / trend.length;
return (
No data — heatmap fills as the live pipeline accumulates detections.
;
}
const cell = (v) => {
const intensity = v / max;
return `rgba(0, 230, 255, ${0.05 + intensity * 0.95})`;
};
return (
⚙ ENGINE
RAG: PageIndex
Mode: Vectorless · tree-search
LLM: Groq {health ? '✓' : '…'}
Knowledge: {health ? `${health.corpora} trees ${health.trees_ready ? '✓' : '⏳'}` : '…'}
🌐 LANGUAGE
{CITIZEN_LANGS.map(l => (
setLang(l.code)}>{l.label}
))}
{lang === 'auto'
? '↳ Auto-detects your language & replies in it'
: <>↳ Replies forced in {langLabel} >}
{ if (!e.target.checked) stopSpeak(); setTtsOn(e.target.checked); }} />
🔊 Speak replies aloud
{speaking ? '⏹ STOP SPEAKING' : '🔇 Voice idle'}
📚 KNOWLEDGE BASE
{catalog && catalog.catalog ? (
{catalog.catalog.map(c => (
{c.corpus.replace(/_/g, ' ')}
{c.sections.length} sections
))}
{!catalog.ready &&
⏳ Building trees…
}
) :
Loading…
}
🤖 CITIZEN ASSISTANT
{speaking && (
⏹ STOP VOICE
)}
PAGEINDEX · GROQ
+
✕
{messages.map((m, i) => (
{m.role === 'bot' &&
{m.quota ? '⏳' : m.restricted ? '🔒' : '🤖'} }
{m.file &&
📎 {m.file}
}
$1')
.replace(/\n/g, '
') }} />
{m.ocr && m.ocr.ok && (
📄 OCR read “{m.ocr.filename}” · {m.ocr.page_count} page(s) · {Math.round(m.ocr.confidence)}% conf · {m.ocr.chars} chars
)}
{m.reasoning && m.reasoning.length > 0 && (
setOpenTrace(o => ({ ...o, [i]: !o[i] }))}>
🧠 Reasoning trace {openTrace[i] ? '▾' : '▸'}
{openTrace[i] && (
{m.reasoning.map((r, j) => {r} )}
)}
)}
{m.sources && m.sources.length > 0 && (
📎 SOURCES (traceable)
{m.sources.map((s, j) => (
s.url ? (
🌐 {s.section || s.url}
) : (
{(s.corpus || '').replace(/_/g, ' ')}
{s.section}
)
))}
)}
{(m.confidence !== undefined || m.language) && (
{m.language && 🌐 {m.language} }
{m.usedWeb && web-assisted }
{m.confidence !== undefined && {m.confidence}% confidence }
{m.bcp47 && window.speechSynthesis && (
speaking
? ⏹
: speak(m.text, m.bcp47, true)}>🔊
)}
navigator.clipboard && navigator.clipboard.writeText(m.text)}>⎘
)}
{m.suggestions && m.suggestions.length > 0 && (
{m.suggestions.map(s => send(s)}>{s} )}
)}
))}
{busy && (
🤖
Reasoning over the knowledge tree…
)}
{attach && (
📎 {attach.name} setAttach(null)}>✕
)}
{listening ? '⏺' : '🎙'}
fileRef.current && fileRef.current.click()}>📎
setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && send()}
placeholder={listening ? 'Listening…' : 'Ask in any language, or attach a document…'} />
send()} disabled={busy}
style={{ padding: '8px 20px', width: 'auto' }}>{busy ? '…' : 'SEND'}
);
};
// Curated services → each opens the live assistant with a tailored
// question (real RAG answer, not a static page).
const CZ_SERVICES = [
{ cat: 'Identity', icon: '🪪', items: [
['Aadhaar enrolment', 'How do I enrol for a new Aadhaar and which documents do I need?'],
['Aadhaar update', 'How do I update my address / mobile in Aadhaar and what is the current fee?'],
['PAN card', 'How do I apply for a PAN card and get an instant e-PAN?'],
['PAN–Aadhaar link', 'How do I link my PAN with Aadhaar and check the status?'],
['Voter ID', 'How do I register for a Voter ID / get an e-EPIC?'],
['Passport', 'How do I apply for a passport and what is Tatkaal?'],
['Driving licence', 'How do I get a learner and permanent driving licence?'],
] },
{ cat: 'Traffic & Challans', icon: '🚦', items: [
['Helmet fine', 'What is the current fine for riding without a helmet?'],
['Pay a challan', 'How do I pay a traffic e-challan online?'],
['Dispute a challan', 'How can I dispute or contest a traffic challan?'],
['Documents to carry', 'Which vehicle documents must I carry while driving?'],
] },
{ cat: 'Schemes & Welfare', icon: '🏥', items: [
['Ayushman Bharat', 'Am I eligible for Ayushman Bharat and how do I get the card?'],
['PM-KISAN', 'How do I check my PM-KISAN status and complete e-KYC?'],
['Ration card', 'How do I apply for or port a ration card?'],
['Scholarships', 'How do I apply for a scholarship on the National Scholarship Portal?'],
] },
{ cat: 'Pension & Provident', icon: '👴', items: [
['EPF balance', 'How do I check my EPF balance and withdraw?'],
['APY scheme', 'How does the Atal Pension Yojana work and how do I enrol?'],
['NPS', 'How do I open an NPS account and what tax benefit does it give?'],
] },
{ cat: 'Tax & Business', icon: '💰', items: [
['File ITR', 'How do I file an income tax return and e-verify it?'],
['GST registration', 'When and how do I register for GST?'],
['Property tax', 'How do I pay municipal property tax online?'],
['Udyam / MSME', 'How do I get a free Udyam (MSME) registration?'],
] },
{ cat: 'Documents & Certificates', icon: '📜', items: [
['Birth certificate', 'How do I get a birth certificate from the municipal registrar?'],
['Income certificate', 'How do I apply for an income certificate?'],
['DigiLocker', 'How do I use DigiLocker for my documents?'],
] },
];
const ChatbotServices = ({ onAsk }) => {
const [search, setSearch] = React.useState('');
const [kb, setKb] = React.useState(null);
React.useEffect(() => {
apiFetch('/api/citizen/knowledge').then(setKb).catch(() => {});
}, []);
const q = search.trim().toLowerCase();
const match = (s) => !q || s.toLowerCase().includes(q);
return (
Powered by the live PageIndex knowledge base — every item asks the real assistant.
{CZ_SERVICES.map((s, i) => {
const items = s.items.filter(([label]) => match(label) || match(s.cat));
if (!items.length) return null;
return (
{s.icon} {s.cat}
{items.map(([label, question]) => (
onAsk && onAsk(question)}>
{label} ASK →
))}
);
})}
{kb && kb.catalog && (
📚 LIVE KNOWLEDGE BASE — {kb.catalog.length} CORPORA {kb.ready ? '✓' : '⏳'}
{kb.catalog.flatMap(c => (c.sections || []).filter(match).map(sec => (
onAsk && onAsk(`Tell me about: ${sec}`)}
title={`From ${c.corpus.replace(/_/g, ' ')}`}>
{sec}
)))}
{kb.catalog.every(c => !(c.sections || []).some(match)) && (
No knowledge sections match “{search}”.
)}
)}
);
};
const ChatbotHistory = ({ onResume }) => {
const [sessions, setSessions] = React.useState([]);
const [search, setSearch] = React.useState('');
const reload = React.useCallback(() => setSessions(czHist.list()), []);
React.useEffect(() => {
reload();
const onFocus = () => reload();
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, [reload]);
const q = search.trim().toLowerCase();
const shown = sessions.filter(s =>
!q || (s.title || '').toLowerCase().includes(q)
|| (s.messages || []).some(m => (m.text || '').toLowerCase().includes(q)));
const exportAll = () => {
const blob = new Blob([JSON.stringify(sessions, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `citadel-chat-history-${new Date().toISOString().slice(0, 10)}.json`;
a.click(); URL.revokeObjectURL(a.href);
};
const del = (id) => { czHist.remove(id); reload(); };
const clearAll = () => { if (window.confirm('Delete ALL saved conversations?')) { czHist.clear(); reload(); } };
return (
⬇ EXPORT ALL
🗑 CLEAR ALL
{shown.length === 0 ? (
{sessions.length === 0 ? 'NO CONVERSATIONS YET' : `No matches for “${search}”`}
Your chats with the assistant are saved here automatically (on this device) and can be resumed.
) : (
{shown.map((s, i) => {
const userMsgs = (s.messages || []).filter(m => m.role === 'user').length;
return (
💬
{s.title}
{(s.messages || []).length} messages · {userMsgs} questions · {czRel(s.updated)}
{(s.lang && s.lang !== 'auto') ? s.lang : 'AUTO'}
onResume && onResume(s)}>RESUME
del(s.id)}>🗑
);
})}
)}
);
};
/* ======================================================================
CITIZEN MODULE 2 — FAKE NEWS DETECTOR
Tabs: Analyze · Bulk · History · Learn
====================================================================== */
// ======================================================================
// CITIZEN MODULE 2 — FAKE NEWS DETECTOR (live: /api/v1/fake-news/*)
// 5-layer production waterfall. No mocks — every value comes from backend.
// ======================================================================
const FN_BASE = '/api/v1/fake-news';
const fnUid = (() => {
try {
let u = localStorage.getItem('citadel_fn_uid');
if (!u) { u = 'cz-' + Math.random().toString(36).slice(2, 10); localStorage.setItem('citadel_fn_uid', u); }
return u;
} catch (e) { return 'cz-anon'; }
})();
const FN_HDR = { 'x-user-id': fnUid, 'x-user-role': 'citizen' };
const fnApi = (path, opts = {}) =>
apiFetch(`${FN_BASE}${path}`, { ...opts, headers: { ...FN_HDR, ...(opts.headers || {}) } });
const FN_VERDICT = {
FAKE: { c: 'var(--red)', fg: '#fff', icon: '✕', label: 'FAKE' },
LIKELY_FAKE: { c: 'var(--gold)', fg: '#000', icon: '⚠', label: 'LIKELY FAKE' },
UNCERTAIN: { c: 'var(--cyan)', fg: '#000', icon: '?', label: 'UNCERTAIN' },
LIKELY_REAL: { c: 'var(--green)', fg: '#000', icon: '✓', label: 'LIKELY REAL' },
REAL: { c: 'var(--green)', fg: '#000', icon: '✓', label: 'REAL' },
ERROR: { c: '#888', fg: '#fff', icon: '!', label: 'ERROR' },
};
const fnV = (v) => FN_VERDICT[v] || FN_VERDICT.UNCERTAIN;
const fnPct = (x) => Math.round((Number(x) || 0) * 100);
// Live model + concept-drift status strip (replaces the old fake "92.3%").
const FakeNewsStatusStrip = () => {
const [h, setH] = React.useState(null);
const [d, setD] = React.useState(null);
React.useEffect(() => {
const pull = () => {
fnApi('/health').then(setH).catch(() => {});
apiFetch(`${FN_BASE}/drift`).then(r => setD(r.data)).catch(() => {});
};
pull();
const t = setInterval(pull, 20000);
return () => clearInterval(t);
}, []);
const ml = (h && h.ml) || {};
const up = h && h.status === 'ok';
const drift = d && d.latest;
return (
ENGINE
{up ? 'OPERATIONAL' : 'CONNECTING…'}
/
STACK
torch {ml.torch || '—'} · transformers {ml.transformers || '—'}
/
GROQ
{h && h.groq_configured ? 'KEYED' : 'OFF'}
/
FACT-CHECK API
{h && h.google_factcheck_configured ? 'GOOGLE' : 'KEYLESS'}
/
DRIFT
{drift ? (drift.drifted ? 'SHIFT DETECTED' : 'STABLE') : 'BASELINE'}
);
};
const FakeNewsDetector = ({ onBack }) => {
const [tab, setTab] = React.useState('analyze');
const [reopen, setReopen] = React.useState(null);
const tabs = [
{ key: 'analyze', label: 'ANALYZE' },
{ key: 'bulk', label: 'BULK CHECK' },
{ key: 'history', label: 'HISTORY' },
{ key: 'review', label: 'REVIEW' },
{ key: 'learn', label: 'LEARN' },
];
return (
{tab === 'analyze' && setReopen(null)} />}
{tab === 'bulk' && }
{tab === 'history' && { setReopen(a); setTab('analyze'); }} />}
{tab === 'review' && }
{tab === 'learn' && }
);
};
// The signature centerpiece: the 4-layer waterfall, rendered brutalist.
const FakeNewsWaterfall = ({ layers, verdict }) => {
const L = layers || {};
const heur = L.heuristics || {};
const cls = L.classifier || {};
const fcl = L.fact_check || {};
const llm = L.llm || {};
const nodes = [
{ k: 'L1', t: 'HEURISTICS', on: heur.l1_risk != null,
v: heur.l1_risk != null ? `risk ${heur.l1_risk}` : '—',
sub: 'regex · domain · simhash' },
{ k: 'L2', t: 'CLASSIFIERS', on: cls.available,
v: cls.available ? `fab ${fnPct(cls.score)}%` : 'excluded',
sub: 'transformer + VADER' },
{ k: 'L3', t: 'RAG · NLI', on: (fcl.n_claims || 0) > 0,
v: `${fcl.n_claims || 0} claim(s)`,
sub: fcl.google_fc ? 'Google FC + NLI' : 'keyless + NLI' },
{ k: 'L4', t: 'LLM RATIONALE', on: llm.invoked && llm.available,
v: llm.invoked ? (llm.quota_exhausted ? 'quota ⏳' : (llm.available ? 'reasoned' : 'n/a'))
: 'skipped',
sub: 'Groq llama-3.3' },
];
return (
{nodes.map((n, i) => (
{n.k}
{n.t}
{n.v}
{n.sub}
{i < nodes.length - 1 && ▶
}
))}
);
};
const FakeNewsEvidence = ({ items, kind }) => {
if (!items || !items.length) return null;
return (
{kind === 'support' ? '↑ SUPPORTING' : '↓ CONTRADICTING'} ({items.length})
{items.slice(0, 4).map((s, i) => (
{s.publisher || 'source'}
{(s.snippet || s.title || '').slice(0, 140)}
{s.url &&
↗ }
))}
);
};
const FakeNewsAnalyze = ({ reopen, clearReopen }) => {
const [mode, setMode] = React.useState('TEXT');
const [text, setText] = React.useState('');
const [file, setFile] = React.useState(null);
const [opts, setOpts] = React.useState({
source_credibility: true, claim_by_claim: true, bias_detection: true,
deepfake_detection: true, cross_reference: true,
});
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const [result, setResult] = React.useState(null);
const [openTrace, setOpenTrace] = React.useState(false);
const [toast, toastHost] = useToast();
const fileRef = React.useRef(null);
React.useEffect(() => {
if (reopen) { setResult(reopen); setErr(null); clearReopen && clearReopen(); }
}, [reopen]);
const isMedia = mode === 'IMAGE' || mode === 'VIDEO';
const setOpt = (k) => setOpts(o => ({ ...o, [k]: !o[k] }));
const verify = async () => {
if (busy) return;
if (isMedia && !file) { setErr('Choose an image or video file to analyze.'); return; }
if (!isMedia && !text.trim()) { setErr('Paste article text or a URL first.'); return; }
setBusy(true); setErr(null); setResult(null);
try {
let data;
if (isMedia) {
const fd = new FormData();
fd.append('file', file);
fd.append('query', mode === 'TEXT' ? '' : (text || ''));
data = await fnApi('/analyze/media', { form: fd });
} else {
data = await fnApi('/analyze', {
json: {
mode, text: mode === 'TEXT' ? text : null,
url: mode === 'URL' ? text.trim() : null, options: opts,
},
});
}
setResult(data);
} catch (e) {
setErr(e.message || 'Analysis failed. Is the backend running?');
} finally { setBusy(false); }
};
const doShare = async () => {
try {
const r = await fnApi(`/analyses/${result.id}/share`, { json: {} });
const link = `${API_BASE}${r.data.url}`;
try { await navigator.clipboard.writeText(link); } catch (e) {}
toast('Share link copied (valid 7 days)', 'success');
} catch (e) { toast('Could not create share link', 'error'); }
};
const doReport = async () => {
try {
await fnApi(`/analyses/${result.id}/report`, { json: {} });
toast('Referred to PIB Fact Check', 'success');
} catch (e) { toast('Report failed', 'error'); }
};
const openReport = (ext) =>
window.open(`${API_BASE}${FN_BASE}/analyses/${result.id}/report.${ext}?uid=${encodeURIComponent(fnUid)}`, '_blank');
const v = result ? fnV(result.verdict) : null;
const sc = result && result.source_credibility;
const mn = result && result.manipulation;
const se = result && result.sentiment;
return (
{toastHost}
ANALYSIS RESULT
{busy ? (
Running 5-layer waterfall — heuristics → classifiers → RAG/NLI fact-check → LLM rationale…
) : !result ? (
) : (
<>
{result.quota_exhausted && (
⏳ LLM rationale paused — provider daily quota reached. The verdict below still stands: Layers 1–3 are local and were not affected.
)}
{v.icon}
{v.label}
Confidence {fnPct(result.confidence)}% · Risk {fnPct(result.risk_score)}%
{result.needs_review && NEEDS HUMAN REVIEW }
DETECTION WATERFALL
{(result.claims || []).length > 0 && (
<>
CLAIM-BY-CLAIM
{result.claims.length} claim(s)
{result.claims.map((c, i) => {
const cv = fnV(c.verdict === 'TRUE' ? 'REAL' : c.verdict === 'FALSE' ? 'FAKE'
: c.verdict === 'MISLEADING' ? 'LIKELY_FAKE' : 'UNCERTAIN');
return (
{c.verdict}
{c.nli_label && NLI: {c.nli_label} }
{fnPct(c.confidence)}%
“{c.text}”
{c.notes &&
{c.notes}
}
);
})}
>
)}
{sc && (
SOURCE CREDIBILITY
= 70 ? 'var(--green)' : sc.score >= 40 ? 'var(--gold)' : 'var(--red)'} />
Publisher {sc.publisher}
Domain age {sc.age_label || '—'}
Trust
{sc.trust_rating}{sc.in_allowlist ? ' · ALLOWLIST' : sc.in_blocklist ? ' · BLOCKLIST' : ''}
)}
{mn && (
MANIPULATION
{[['clickbait', mn.clickbait], ['urgency', mn.urgency],
['authority_claim', mn.authority_claim], ['emotional', mn.emotional]].map(([k, val]) => (
{k.replace('_', ' ').toUpperCase()}
70 ? 'var(--red)' : val > 40 ? 'var(--gold)' : 'var(--green)' }}>
{val}
))}
{se && (
<>
SENTIMENT
>
)}
)}
{(result.red_flags || []).length > 0 && (
<>
RED FLAGS
{result.red_flags.map((f, i) =>
⚠ {f}
)}
>
)}
{(result.related_fact_checks || []).length > 0 && (
<>
RELATED FACT-CHECKS
{result.related_fact_checks.map((a, i) => (
{a.title}
{a.publisher}{a.published_at ? ' · ' + a.published_at : ''}
{a.url &&
READ ↗ }
))}
>
)}
{(result.reasoning || []).length > 0 && (
<>
setOpenTrace(o => !o)}>
REASONING TRACE
{openTrace ? '▼ hide' : '▶ show'} · {result.reasoning.length} steps
{openTrace && (
{result.reasoning.map((s, i) => {s} )}
)}
>
)}
openReport('html')} title="Open HTML report">⬇ REPORT (HTML)
openReport('pdf')} title="Download PDF report">⬇ PDF
🔗 SHARE
⚠ REPORT TO PIB
>
)}
);
};
const FakeNewsBulk = () => {
const [urls, setUrls] = React.useState('');
const [batch, setBatch] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState(null);
const fileRef = React.useRef(null);
const pollRef = React.useRef(null);
const poll = (id) => {
pollRef.current = setInterval(async () => {
try {
const r = await fnApi(`/bulk/${id}`);
setBatch(r.data);
if (r.data.status === 'done') { clearInterval(pollRef.current); setBusy(false); }
} catch (e) { clearInterval(pollRef.current); setBusy(false); }
}, 2500);
};
React.useEffect(() => () => pollRef.current && clearInterval(pollRef.current), []);
const run = async () => {
const list = urls.split('\n').map(s => s.trim()).filter(Boolean).slice(0, 50);
if (!list.length) { setErr('Add at least one URL or text line.'); return; }
setBusy(true); setErr(null); setBatch(null);
try {
const r = await fnApi('/bulk', { json: { urls: list } });
setBatch({ id: r.data.id, total: r.data.total, completed: 0, status: 'running', items: [] });
poll(r.data.id);
} catch (e) { setErr(e.message || 'Bulk submit failed'); setBusy(false); }
};
const uploadCsv = async (f) => {
if (!f) return;
setBusy(true); setErr(null); setBatch(null);
try {
const fd = new FormData(); fd.append('file', f);
const r = await fnApi('/bulk/upload-csv', { form: fd });
setBatch({ id: r.data.id, total: r.data.total, completed: 0, status: 'running', items: [] });
poll(r.data.id);
} catch (e) { setErr(e.message || 'CSV upload failed'); setBusy(false); }
};
const pct = batch && batch.total ? Math.round(100 * batch.completed / batch.total) : 0;
return (
BATCH LIST · UP TO 50 URLs / MESSAGES
RESULTS{batch ? ` · ${batch.completed}/${batch.total}` : ''}
{!batch ?
: (
<>
{(batch.items || []).map((r, i) => {
const bv = fnV(r.verdict);
return (
);
})}
{batch.status !== 'done' &&
Analyzing…
}
>
)}
);
};
const FN_ROW = (r) => ({
id: r.id, verdict: r.verdict, confidence: r.confidence, risk_score: r.risk_score,
needs_review: r.needs_review, quota_exhausted: false, layers: r.layers || {},
red_flags: r.red_flags || [], reasoning: r.reasoning || [],
source_credibility: r.source_credibility, manipulation: r.manipulation,
sentiment: r.sentiment, related_fact_checks: [],
claims: (r.claims || []).map(c => ({
text: c.claim_text, verdict: c.verdict, confidence: c.confidence,
notes: c.notes, nli_label: c.nli_label,
supporting_evidence: c.supporting_evidence || [],
contradicting_evidence: c.contradicting_evidence || [],
})),
});
const FakeNewsHistory = ({ onReopen }) => {
const [stats, setStats] = React.useState(null);
const [rows, setRows] = React.useState([]);
const [page, setPage] = React.useState(1);
const [total, setTotal] = React.useState(0);
const [q, setQ] = React.useState('');
const [loading, setLoading] = React.useState(true);
const PS = 12;
const load = React.useCallback(() => {
setLoading(true);
fnApi('/history/stats').then(r => setStats(r.data)).catch(() => {});
fnApi('/history', { params: { page, page_size: PS, q: q || undefined } })
.then(r => { setRows(r.data || []); setTotal((r.meta && r.meta.total) || 0); })
.catch(() => setRows([]))
.finally(() => setLoading(false));
}, [page, q]);
React.useEffect(() => { load(); }, [load]);
const reopen = async (row) => {
try {
const r = await fnApi(`/analyses/${row.id}`);
onReopen(FN_ROW(r.data));
} catch (e) {}
};
const del = async (row, e) => {
e.stopPropagation();
try { await fnApi(`/history/${row.id}`, { method: 'DELETE' }); load(); } catch (er) {}
};
return (
PAST CHECKS
{ setPage(1); setQ(e.target.value); }} />
{loading ?
Loading history…
: rows.length === 0 ?
: (
<>
{(v || '').slice(0, 80) || '(media)'} },
{ key: 'verdict', label: 'VERDICT', width: 130, render: v => {
const vv = fnV(v);
return {vv.label} ; } },
{ key: 'confidence', label: 'CONF', width: 110, render: v => },
{ key: 'submitted_at', label: 'WHEN', width: 150, align: 'right',
render: v => v ? new Date(v).toLocaleString() : '' },
{ key: 'id', label: '', width: 50, sortable: false,
render: (_v, r) => del(r, e)}>✕ },
]}
rows={rows}
/>
>
)}
);
};
const FN_REVIEW_OPTS = ['REAL', 'LIKELY_REAL', 'UNCERTAIN', 'LIKELY_FAKE', 'FAKE'];
const FakeNewsReview = () => {
const [queue, setQueue] = React.useState(null);
const [meta, setMeta] = React.useState(null);
const [draft, setDraft] = React.useState({});
const [toast, toastHost] = useToast();
const [tick, setTick] = React.useState(0);
const load = React.useCallback(() => {
fnApi('/review-queue', { params: { status: 'pending' } })
.then(r => setQueue(r.data || [])).catch(() => setQueue([]));
apiFetch(`${FN_BASE}/meta/status`).then(r => setMeta(r.data)).catch(() => {});
}, []);
// real-time: poll the queue every 8s so newly-flagged analyses appear
// without leaving/re-entering the tab
React.useEffect(() => {
load();
const t = setInterval(load, 8000);
return () => clearInterval(t);
}, [load, tick]);
const decide = async (item) => {
const d = draft[item.id] || {};
if (!d.verdict) { toast('Pick a verdict first', 'warn'); return; }
try {
await fnApi(`/review/${item.id}/decide`, { json: { human_verdict: d.verdict, notes: d.notes || '' } });
toast('Decision recorded — added to training feedback', 'success');
load();
} catch (e) { toast('Decision failed', 'error'); }
};
const refit = async () => {
try {
const r = await fnApi('/meta/refit', { json: {} });
toast(r.data.trained ? `Meta-classifier refit (${r.data.n_samples} samples)`
: `Refit deferred: ${r.data.reason}`, r.data.trained ? 'success' : 'info');
load();
} catch (e) { toast('Refit failed', 'error'); }
};
return (
{toastHost}
HUMAN-IN-THE-LOOP REVIEW QUEUE{queue ? ` · ${queue.length} PENDING` : ''}
live · auto-refresh 8s
setTick(t => t + 1)} title="Refresh now">↻
Meta-classifier: {meta && meta.trained ? `trained · ${meta.n_samples} samples`
: 'not yet trained (collecting feedback)'}
REFIT FROM FEEDBACK
{!queue ?
Loading queue…
: queue.length === 0 ?
: queue.map(item => {
const mv = fnV(item.model_verdict);
const d = draft[item.id] || {};
return (
);
})}
);
};
const FN_TECHNIQUES = [
{ t: 'Loaded language', d: 'Emotionally charged words ("slammed", "destroys", "evil") to push a reaction over reason.' },
{ t: 'Cherry-picking', d: 'Real facts selectively chosen to imply a false conclusion while omitting context.' },
{ t: 'Fabricated authority', d: '"Experts agree" / fake credentials / quotes attributed to no real, checkable person.' },
{ t: 'Out-of-context media', d: 'A genuine old photo/video reused as if it shows a current, unrelated event.' },
{ t: 'AI-generated media', d: 'Synthetic images/voices/deepfakes presented as authentic footage.' },
{ t: 'Whataboutism', d: 'Deflecting a claim by pointing at an unrelated wrong instead of addressing it.' },
{ t: 'Manufactured urgency', d: '"Share before it is deleted!" — pressure to spread before you can verify.' },
{ t: 'Coordinated amplification', d: 'Many fresh/low-follower accounts pushing the same link in seconds (bot/CIB burst).' },
];
const FN_LAYERS = [
{ k: 'L1', t: 'Heuristics + adversarial', d: 'Regex/lexicon red flags, ALL-CAPS & urgency, suspicious domain age, de-obfuscation, and a SimHash match against known-debunked content.' },
{ k: 'L2', t: 'Transformer classifiers', d: 'Fast neural models score fabrication-style, clickbait and bias — plus VADER sentiment. A weak style signal, not a truth verdict.' },
{ k: 'L3', t: 'RAG fact-check + NLI', d: 'Extracts the claims, retrieves evidence (Google Fact Check, fact-check feeds, web, Wikipedia) and runs DeBERTa-v3 NLI: does the evidence support or contradict it?' },
{ k: 'L4', t: 'LLM rationale', d: 'For high-risk/ambiguous items only, a Groq Llama-3.3 model writes a plain-English rationale for a human moderator.' },
];
const FakeNewsLearn = () => {
const [sources, setSources] = React.useState(null);
React.useEffect(() => {
fnApi('/sources').then(r => setSources(r.data || [])).catch(() => setSources([]));
}, []);
const all = sources || [];
const allow = all.filter(s => s.in_allowlist).slice(0, 12);
const block = all.filter(s => s.in_blocklist).slice(0, 6);
return (
s.in_allowlist).length} color="var(--green)" />
s.in_blocklist).length} color="var(--red)" />
🚩 COMMON RED FLAGS
{[
{ t: 'EXTREME HEADLINES', d: 'ALL CAPS, excessive exclamation points, shocking claims' },
{ t: 'EMOTIONAL MANIPULATION', d: 'Fear, outrage, guilt-tripping to bypass critical thinking' },
{ t: 'NO SOURCES', d: 'Claims without citing reputable outlets, officials, or data' },
{ t: 'SUSPICIOUS DOMAINS', d: 'Unusual URLs (.info, newly registered, typo-squatting)' },
{ t: 'URGENT CALLS TO ACT', d: '"Share before it\'s deleted!", "Act now!"' },
{ t: 'ANONYMOUS QUOTES', d: '"Experts say", "Officials confirm" — no names given' },
].map((f, i) => (
))}
✅ TRUSTED SOURCES live
{sources === null ?
Loading live allowlist…
: allow.length === 0 ?
No allowlisted sources found.
: allow.map((t, i) => (
✓
{t.publisher_name || t.domain}
{t.domain}
= 70 ? 'var(--green)' : 'var(--gold)' }}>
{t.score != null ? `${t.score}` : (t.trust_rating || 'LISTED')}
))}
{block.length > 0 && (
<>
⛔ KNOWN LOW-CREDIBILITY
{block.map((t, i) => (
✕
{t.publisher_name || t.domain}
{t.domain}{t.notes ? ` · ${t.notes}` : ''}
LOW
))}
>
)}
Live from the maintained source-credibility KB · officer-editable in API
🧠 MANIPULATION & PROPAGANDA TECHNIQUES
{FN_TECHNIQUES.map((f, i) => (
))}
⚙️ HOW THIS DETECTOR WORKS — THE 5-LAYER WATERFALL
{FN_LAYERS.map((l, i) => (
{i < FN_LAYERS.length - 1 && ▶
}
))}
Truth is decided by Layer 3 (evidence + NLI), not by writing style. Unverifiable
content is marked UNCERTAIN — “no red flags” is never reported as “true”.
📚 HOW TO VERIFY NEWS — 5-STEP GUIDE
{[
{ n: 1, t: 'Check the source', d: 'Is it a known, reputable outlet? Check their About page and editorial standards.' },
{ n: 2, t: 'Read beyond headline', d: 'Headlines can mislead. Read the full article for context and nuance.' },
{ n: 3, t: 'Check author & date', d: 'Anonymous articles or old news presented as current are red flags.' },
{ n: 4, t: 'Look at sources cited', d: 'Credible news cites primary sources — officials, documents, data.' },
{ n: 5, t: 'Search other outlets', d: 'If only one source reports it, be skeptical. Check 2-3 independent outlets.' },
].map(s => (
))}
);
};
/* ======================================================================
CITIZEN MODULE 3 — SUPPORT TICKETS
Tabs: Submit · My Tickets · Community · Map
====================================================================== */
/* ---- Support Tickets: live API plumbing ------------------------------- */
const TK_BASE = '/api/v1/tickets';
// The tickets backend keys upvotes/comments on a *uuid* X-User-Id (the
// fake-news 'cz-xxxxxxxx' format is rejected by its _actor() parser), so
// tickets get their own persisted browser identity. Replaced by the real
// JWT subject in Phase 3.
const tkUid = (() => {
try {
let u = localStorage.getItem('citadel_tk_uid');
if (!u) {
u = (crypto.randomUUID && crypto.randomUUID()) ||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
localStorage.setItem('citadel_tk_uid', u);
}
return u;
} catch (e) { return null; }
})();
const TK_HDR = tkUid ? { 'x-user-id': tkUid, 'x-user-role': 'citizen' } : { 'x-user-role': 'citizen' };
const tkApi = (path, opts = {}) =>
apiFetch(`${TK_BASE}${path}`, { ...opts, headers: { ...TK_HDR, ...(opts.headers || {}) } });
const TK_SEV_COLOR = { CRITICAL: 'var(--red)', HIGH: 'var(--red)', MEDIUM: 'var(--gold)', LOW: 'var(--cyan)' };
const SupportTickets = ({ onBack }) => {
const [tab, setTab] = React.useState('submit');
const [stats, setStats] = React.useState(null);
React.useEffect(() => {
tkApi('/stats').then(setStats).catch(() => {});
}, [tab]);
const activeCount = stats ? (stats.open + stats.in_progress) : null;
const tabs = [
{ key: 'submit', label: 'SUBMIT NEW' },
{ key: 'mine', label: 'MY TICKETS', badge: activeCount || undefined },
{ key: 'community', label: 'COMMUNITY' },
{ key: 'map', label: 'MAP VIEW' },
];
return (
{tab === 'submit' && }
{tab === 'mine' && }
{tab === 'community' && }
{tab === 'map' && }
);
};
const TicketSubmit = () => {
const [subject, setSubject] = React.useState('');
const [desc, setDesc] = React.useState('');
const [category, setCategory] = React.useState('Auto-detect');
const [priority, setPriority] = React.useState('AUTO');
const [anonymous, setAnonymous] = React.useState(false);
const [attachments, setAttachments] = React.useState([]); // { kind, file?, name, geo? }
const [aiPreview, setAiPreview] = React.useState(null);
const [previewBusy, setPreviewBusy] = React.useState(false);
const [submitBusy, setSubmitBusy] = React.useState(false);
const [templates, setTemplates] = React.useState([]);
const [toast, toastHost] = useToast();
const fileRefs = { photo: React.useRef(), video: React.useRef(), voice: React.useRef(), file: React.useRef() };
React.useEffect(() => {
tkApi('/templates').then(setTemplates).catch(() => setTemplates([]));
}, []);
// One deliberate call per action (template pick / button) — never per
// keystroke, so previews don't drain the Groq daily budget.
const runPreview = (subj, body, cat, pri) => {
if (!(subj || body)) { toast('Enter a subject or description first', 'warn'); return; }
setPreviewBusy(true);
tkApi('/preview', { json: { subject: subj, description: body, category: cat === 'Auto-detect' ? null : cat, priority: pri } })
.then(p => { setAiPreview(p); if (p.quota_exhausted) toast('LLM quota drained — keyword routing used', 'warn'); })
.catch(e => toast(`Preview failed: ${e.message || e}`, 'error'))
.finally(() => setPreviewBusy(false));
};
const useTemplate = (t) => {
setSubject(t.title); setDesc(t.body); setCategory(t.display_category);
runPreview(t.title, t.body, t.display_category, priority);
};
const stageFile = (kind) => (e) => {
const f = e.target.files && e.target.files[0];
if (f) setAttachments(a => [...a, { kind, file: f, name: f.name }]);
e.target.value = '';
};
const stageGps = () => {
if (!navigator.geolocation) { toast('Geolocation not available in this browser', 'warn'); return; }
navigator.geolocation.getCurrentPosition(
pos => {
const { latitude, longitude } = pos.coords;
setAttachments(a => [...a.filter(x => x.kind !== 'location'),
{ kind: 'location', name: `${latitude.toFixed(4)}°, ${longitude.toFixed(4)}°`, geo: { lat: latitude, lng: longitude } }]);
},
() => toast('Location permission denied', 'warn'),
{ timeout: 8000 },
);
};
const submit = async () => {
if (!subject || subject.trim().length < 3) { toast('Please enter a subject (min 3 chars)', 'warn'); return; }
setSubmitBusy(true);
try {
const gps = attachments.find(a => a.kind === 'location');
const res = await tkApi('', { json: {
subject, description: desc,
category: category === 'Auto-detect' ? null : category,
priority, is_anonymous: anonymous,
geo_lat: gps ? gps.geo.lat : null, geo_lng: gps ? gps.geo.lng : null,
location_label: gps ? gps.name : null,
} });
const t = res.ticket;
const files = attachments.filter(a => a.file);
let uploaded = 0;
for (const a of files) {
try {
const form = new FormData();
form.append('kind', a.kind);
form.append('file', a.file);
await tkApi(`/${t.id}/attachments`, { form });
uploaded += 1;
} catch (e) {
toast(`Attachment "${a.name}" rejected: ${e.message || e}`, 'warn');
}
}
toast(`${t.id} submitted → ${t.department} · ${t.priority} · SLA ${res.preview.predicted_sla_label}` +
(files.length ? ` · ${uploaded}/${files.length} files attached` : ''), 'success');
setSubject(''); setDesc(''); setCategory('Auto-detect'); setPriority('AUTO');
setAnonymous(false); setAttachments([]); setAiPreview(null);
} catch (e) {
toast(`Submit failed: ${e.message || e}`, 'error');
} finally {
setSubmitBusy(false);
}
};
return (
NEW TICKET
SUBJECT *
setSubject(e.target.value)} />
DETAILED DESCRIPTION *
CATEGORY
setCategory(e.target.value)}>
{['Auto-detect', 'Roads', 'Water', 'Electric', 'Sanitation', 'Public Safety', 'Parks', 'Health', 'Other'].map(c => {c} )}
PRIORITY
setPriority(e.target.value)}>
{['AUTO', 'LOW', 'NORMAL', 'HIGH', 'CRITICAL'].map(c => {c} )}
ATTACHMENTS
fileRefs.photo.current.click()}>📷Photo
fileRefs.video.current.click()}>🎬Video
fileRefs.voice.current.click()}>🎙Voice
📍GPS
fileRefs.file.current.click()}>📎File
{attachments.length > 0 && (
{attachments.map((a, i) => (
{a.kind === 'photo' ? '📷' : a.kind === 'video' ? '🎬' : a.kind === 'voice' ? '🎙' : a.kind === 'location' ? '📍' : '📎'}
{a.name}{a.file ? ` (${(a.file.size / 1024).toFixed(0)} KB)` : ''}
setAttachments(attachments.filter((_, x) => x !== i))}>✕
))}
)}
runPreview(subject, desc, category, priority)}>
{previewBusy ? 'ANALYZING…' : '⚡ AI PRE-ANALYSIS'}
{submitBusy ? 'SUBMITTING…' : 'SUBMIT TICKET'}
{aiPreview && (
⚡ AI PRE-ANALYSIS
Category: {aiPreview.display_category}
Priority: {aiPreview.predicted_priority}
Route to: {aiPreview.predicted_department.replace(/_/g, ' ')}
Expected SLA: {aiPreview.predicted_sla_label}
Sentiment: {aiPreview.sentiment}
Confidence: {Math.round(aiPreview.confidence * 100)}%
{aiPreview.reasoning_summary}
engine: {aiPreview.source === 'groq' ? 'LLM (Groq)' : 'keyword rules'}
{aiPreview.quota_exhausted ? ' · ⏳ LLM quota drained' : ''}
)}
QUICK TEMPLATES
{templates.length === 0 && (
Templates unavailable — backend offline?
)}
{templates.map(t => (
useTemplate(t)}>
{t.title}
{t.display_category}
))}
{toastHost}
);
};
const TicketMine = () => {
const [tickets, setTickets] = React.useState(null);
const [stats, setStats] = React.useState(null);
const [expanded, setExpanded] = React.useState(null);
const [detail, setDetail] = React.useState(null); // full ticket for the expanded card
const [composer, setComposer] = React.useState(''); // ADD UPDATE text
const [busy, setBusy] = React.useState(false);
const [toast, toastHost] = useToast();
const refresh = React.useCallback(() => {
tkApi('/mine', { params: { limit: 50 } }).then(r => setTickets(r.data)).catch(() => setTickets([]));
tkApi('/stats').then(setStats).catch(() => {});
}, []);
React.useEffect(() => { refresh(); }, [refresh]);
const openDetail = (id) => {
if (expanded === id) { setExpanded(null); setDetail(null); return; }
setExpanded(id); setDetail(null); setComposer('');
tkApi(`/${id}`).then(setDetail).catch(e => toast(`Load failed: ${e.message || e}`, 'error'));
};
const act = (fn, okMsg) => {
setBusy(true);
fn()
.then(() => { toast(okMsg, 'success'); refresh(); if (expanded) tkApi(`/${expanded}`).then(setDetail).catch(() => {}); })
.catch(e => toast(`${e.message || e}`, 'warn'))
.finally(() => setBusy(false));
};
const addUpdate = () => {
const text = composer.trim();
if (!text) { toast('Write the update first', 'warn'); return; }
act(() => tkApi(`/${expanded}/updates`, { json: { text } }).then(() => setComposer('')), 'Update posted');
};
const rate = (n) => act(() => tkApi(`/${expanded}/rate`, { json: { rating: n } }), `Rated ${n}/5`);
const reopen = () => {
const reason = window.prompt('Why are you reopening this ticket?');
if (!reason) return;
act(() => tkApi(`/${expanded}/reopen`, { json: { reason } }), 'Ticket reopened');
};
const fmtTime = (iso) => iso ? iso.replace('T', ' ').slice(5, 16) : '';
return (
{tickets === null &&
Loading tickets…
}
{tickets && tickets.length === 0 && (
No tickets yet — submit your first civic issue from the SUBMIT NEW tab.
)}
{(tickets || []).map((t, i) => (
{t.id}
{t.subject}
{t.display_category}
{t.priority}
Dept: {t.department.replace(/_/g, ' ')}
Age: {t.age}
{t.sla_status !== 'on_track' && (
SLA {t.sla_status.replace('_', ' ').toUpperCase()}
)}
{t.rating && ⭐ {t.rating}/5 }
openDetail(t.id)}>
{expanded === t.id ? 'COLLAPSE' : 'DETAILS'}
{expanded === t.id && (
{!detail &&
Loading detail…
}
{detail && (
{detail.updates.length === 0 && (
No updates yet.
)}
{detail.updates.map(u => (
{u.text}
{u.actor_label} · {fmtTime(u.created_at)}
))}
{detail.attachments.length > 0 && (
{detail.attachments.map(a => (
{a.type === 'photo' ? '📷' : a.type === 'video' ? '🎬' : a.type === 'voice' ? '🎙' : '📎'}
{a.name} ({(a.size_bytes / 1024).toFixed(0)} KB)
{a.download_url && (
VIEW ↗
)}
))}
)}
setComposer(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addUpdate(); }} />
➕ POST
{['resolved', 'closed'].includes(detail.status) && !detail.rating && (
RATE:
{[1, 2, 3, 4, 5].map(n => (
rate(n)}>{n}★
))}
)}
{['resolved', 'closed'].includes(detail.status) && (
🔄 REOPEN
)}
)}
)}
))}
{toastHost}
);
};
const TicketCommunity = () => {
const [rows, setRows] = React.useState(null);
const [sort, setSort] = React.useState('TRENDING');
const [query, setQuery] = React.useState('');
const [commentsFor, setCommentsFor] = React.useState(null); // ticket id with open thread
const [thread, setThread] = React.useState([]);
const [commentText, setCommentText] = React.useState('');
const [toast, toastHost] = useToast();
const geoRef = React.useRef(null); // cached coords for NEARBY
const load = React.useCallback((s, q) => {
const params = { sort: s.toLowerCase(), limit: 25 };
if (q) params.q = q;
const run = (p) => tkApi('/community', { params: p })
.then(r => setRows(r.data))
.catch(e => { setRows([]); toast(`Feed failed: ${e.message || e}`, 'error'); });
if (s === 'NEARBY') {
if (geoRef.current) {
run({ ...params, lat: geoRef.current.lat, lng: geoRef.current.lng });
} else if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
pos => {
geoRef.current = { lat: pos.coords.latitude, lng: pos.coords.longitude };
run({ ...params, lat: geoRef.current.lat, lng: geoRef.current.lng });
},
() => { toast('Location denied — showing TRENDING instead', 'warn'); setSort('TRENDING'); run({ ...params, sort: 'trending' }); },
{ timeout: 8000 },
);
} else {
toast('Geolocation unavailable — showing TRENDING', 'warn'); setSort('TRENDING'); run({ ...params, sort: 'trending' });
}
} else {
run(params);
}
}, [toast]);
React.useEffect(() => { load(sort, query); }, [sort]); // search triggers via its own debounce
React.useEffect(() => {
const t = setTimeout(() => load(sort, query), 400);
return () => clearTimeout(t);
}, [query]);
const toggleSupport = (t) => {
const method = t.has_upvoted ? 'DELETE' : 'POST';
tkApi(`/${t.id}/upvote`, { method })
.then(r => {
setRows(rs => rs.map(x => x.id === t.id ? { ...x, upvotes: r.upvotes, has_upvoted: r.has_upvoted } : x));
if (r.priority_escalated_to) toast(`${t.id} escalated to ${r.priority_escalated_to} by community support`, 'success');
})
.catch(e => toast(`${e.message || e}`, 'warn'));
};
const openThread = (id) => {
if (commentsFor === id) { setCommentsFor(null); return; }
setCommentsFor(id); setThread([]); setCommentText('');
tkApi(`/${id}/comments`).then(setThread).catch(() => {});
};
const postComment = () => {
const text = commentText.trim();
if (!text) return;
tkApi(`/${commentsFor}/comments`, { json: { text } })
.then(c => {
setThread(th => [...th, c]);
setCommentText('');
setRows(rs => rs.map(x => x.id === commentsFor ? { ...x, response_count: x.response_count + 1 } : x));
})
.catch(e => toast(`${e.message || e}`, 'warn'));
};
return (
{rows === null &&
Loading community feed…
}
{rows && rows.length === 0 && (
No tickets match.
)}
{(rows || []).map((t, i) => (
toggleSupport(t)}>▲
{t.upvotes}
{t.has_upvoted ? 'SUPPORTED' : 'SUPPORT'}
{t.title}
{t.id}
{t.location_label && 📍 {t.location_label} }
{t.distance_km != null && {t.distance_km} km }
{t.display_category}
{t.response_count} responses
{t.age}
{commentsFor === t.id && (
{thread.map(c => (
{c.author_label}
{c.text}
{c.age}
))}
{thread.length === 0 &&
No comments yet.
}
setCommentText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') postComment(); }} />
POST
)}
toggleSupport(t)}>
{t.has_upvoted ? '✓ SUPPORTED' : '🙋 SUPPORT'}
openThread(t.id)}>
💬 {commentsFor === t.id ? 'HIDE' : 'COMMENT'}
))}
{toastHost}
);
};
const TicketMap = () => {
// Default center = the demo city (Bengaluru). Geolocation replaces it on
// permission, and any pan refetches for the new viewport, so the default
// only seeds the very first view.
const DEFAULT_CENTER = [12.9716, 77.5946];
const [radiusKm, setRadiusKm] = React.useState('5 KM');
const [data, setData] = React.useState(null);
const [toast, toastHost] = useToast();
const mapRef = React.useRef(null);
const mapObj = React.useRef(null);
const markersRef = React.useRef(null);
const debounceRef = React.useRef(null);
const radius = parseInt(radiusKm, 10);
const fetchNearby = React.useCallback((lat, lng, r) => {
tkApi('/map/nearby', { params: { lat: lat.toFixed(5), lng: lng.toFixed(5), radius_km: r } })
.then(setData)
.catch(e => toast(`Map data failed: ${e.message || e}`, 'error'));
}, [toast]);
// init Leaflet once
React.useEffect(() => {
if (!window.L || !mapRef.current || mapObj.current) return;
const map = window.L.map(mapRef.current, { zoomControl: true, attributionControl: true })
.setView(DEFAULT_CENTER, 14);
window.L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap contributors',
}).addTo(map);
markersRef.current = window.L.layerGroup().addTo(map);
mapObj.current = map;
map.on('moveend', () => {
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const c = map.getCenter();
fetchNearby(c.lat, c.lng, parseInt(localStorage.getItem('citadel_tk_radius') || '5', 10));
}, 400);
});
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
pos => map.setView([pos.coords.latitude, pos.coords.longitude], 14),
() => {}, { timeout: 6000 },
);
}
fetchNearby(DEFAULT_CENTER[0], DEFAULT_CENTER[1], 5);
return () => { map.remove(); mapObj.current = null; };
}, []);
// radius change → persist for the moveend closure + refetch
React.useEffect(() => {
try { localStorage.setItem('citadel_tk_radius', String(radius)); } catch (e) {}
if (mapObj.current) {
const c = mapObj.current.getCenter();
fetchNearby(c.lat, c.lng, radius);
}
}, [radiusKm]);
// redraw markers when data changes
React.useEffect(() => {
if (!data || !markersRef.current || !window.L) return;
const layer = markersRef.current;
layer.clearLayers();
data.pins.forEach(p => {
const color = TK_SEV_COLOR[p.severity] || 'var(--cyan)';
window.L.circleMarker([p.lat, p.lng], {
radius: 9, color: '#000', weight: 2, fillColor: color, fillOpacity: 0.9,
}).bindPopup(
`
${p.id} · ${p.severity} ${p.title}
${p.display_category} · ${p.status.replace(/_/g, ' ')} · ▲${p.upvotes} · ${p.distance_km} km
`
).addTo(layer);
});
}, [data]);
return (
{data ? `${data.count} open issues in view · ${data.clusters.length} zones` : 'Loading…'}
NEIGHBORHOOD TICKETS · {radiusKm} RADIUS · LIVE
NEARBY ISSUES
{!data &&
Loading…
}
{data && data.pins.length === 0 && (
No located open tickets within {radius} km. Pan the map or submit one with GPS attached.
)}
{(data ? data.pins.slice(0, 8) : []).map(p => (
mapObj.current && mapObj.current.setView([p.lat, p.lng], 16)}>
{p.title}
{p.id} · {p.distance_km} km · ▲{p.upvotes}
))}
{toastHost}
);
};
/* ======================================================================
CITIZEN MODULE 4 — EXPENSE CATEGORIZER
Tabs: Add · Dashboard · Budget · Receipts · Reports
====================================================================== */
/* ---- Expense Categorizer: live API plumbing --------------------------- */
const EX_BASE = '/api/v1/expenses';
// Same persisted browser identity the tickets module mints — one citizen
// uuid across citizen modules until Phase 3 JWT.
const exUid = tkUid;
const exApi = (path, opts = {}) =>
apiFetch(`${EX_BASE}${path}`, { ...opts, headers: { 'x-user-id': exUid, 'x-user-role': 'citizen', ...(opts.headers || {}) } });
const EX_CATEGORIES = ['Food & Dining', 'Groceries', 'Transport', 'Utilities', 'Healthcare', 'Shopping', 'Education', 'Entertainment', 'Housing', 'Insurance', 'Other'];
const EX_CAT_ICON = {
'Food & Dining': '🍕', 'Groceries': '🛒', 'Transport': '🚗', 'Utilities': '💡',
'Healthcare': '💊', 'Shopping': '🛍', 'Education': '📚', 'Entertainment': '🎬',
'Housing': '🏠', 'Insurance': '🛡', 'Other': '📦',
};
const EX_CAT_COLOR = {
'Food & Dining': 'var(--gold)', 'Groceries': '#7cb518', 'Transport': 'var(--cyan)',
'Utilities': 'var(--green)', 'Healthcare': '#d946ef', 'Shopping': 'var(--red)',
'Education': '#6366f1', 'Entertainment': '#f97316', 'Housing': '#8b5cf6',
'Insurance': '#0ea5e9', 'Other': '#888',
};
const exInr = (v) => '₹' + Number(v || 0).toLocaleString('en-IN', { maximumFractionDigits: 0 });
const ExpenseCategorizer = ({ onBack }) => {
const [tab, setTab] = React.useState('dashboard');
const tabs = [
{ key: 'add', label: 'ADD EXPENSE' },
{ key: 'dashboard', label: 'DASHBOARD' },
{ key: 'budget', label: 'BUDGET' },
{ key: 'receipts', label: 'RECEIPTS' },
{ key: 'reports', label: 'REPORTS' },
];
return (
{tab === 'add' && }
{tab === 'dashboard' && }
{tab === 'budget' && }
{tab === 'receipts' && }
{tab === 'reports' && }
);
};
const ExpenseAdd = () => {
const [desc, setDesc] = React.useState('');
const [amt, setAmt] = React.useState('');
const [cat, setCat] = React.useState('Auto-detect');
const [merchant, setMerchant] = React.useState('');
const [aiCat, setAiCat] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [lastOcr, setLastOcr] = React.useState(null); // last scanned receipt
const [ocrBusy, setOcrBusy] = React.useState(false);
const [importBatch, setImportBatch] = React.useState(null);
const [importBusy, setImportBusy] = React.useState(false);
const [toast, toastHost] = useToast();
const receiptRef = React.useRef();
const stmtRef = React.useRef();
// Live prediction on typing pause. The backend answers from its local
// layers only (lexicon/cache/model — never the LLM on partial text),
// so this is free to call per pause.
React.useEffect(() => {
if (!desc && !merchant) { setAiCat(null); return; }
const t = setTimeout(() => {
exApi('/categorize', { json: { description: desc, merchant: merchant || null } })
.then(setAiCat).catch(() => {});
}, 500);
return () => clearTimeout(t);
}, [desc, merchant]);
const add = () => {
if (!desc || !amt) { toast('Please fill description and amount', 'warn'); return; }
setBusy(true);
exApi('', { json: {
description: desc, merchant: merchant || null, amount_inr: Number(amt),
category: cat === 'Auto-detect' ? null : cat,
} })
.then(r => {
const e = r.expense;
toast(`Added ₹${amt} — ${e.category} [${r.classification.source}]` +
(e.is_anomaly ? ` · ⚠ ${e.anomaly_reason}` : ''), e.is_anomaly ? 'warn' : 'success');
setDesc(''); setAmt(''); setMerchant(''); setCat('Auto-detect'); setAiCat(null);
})
.catch(e => toast(`Add failed: ${e.message || e}`, 'error'))
.finally(() => setBusy(false));
};
const scanReceipt = (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f) return;
setOcrBusy(true);
const form = new FormData(); form.append('file', f);
exApi('/receipts/upload', { form })
.then(r => { setLastOcr(r); toast(`Receipt scanned — review & confirm`, 'success'); })
.catch(err => toast(`Scan failed: ${err.message || err}`, 'error'))
.finally(() => setOcrBusy(false));
};
const confirmReceipt = () => {
if (!lastOcr) return;
exApi(`/receipts/${lastOcr.id}/confirm`, { json: {} })
.then(r => {
toast(`${r.expense.description}: ₹${r.expense.amount_inr.toLocaleString()} added as ${r.expense.category}`, 'success');
setLastOcr({ ...lastOcr, status: 'confirmed', expense_id: r.expense.id });
})
.catch(err => toast(`${err.message || err}`, 'warn'));
};
const importStatement = (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f) return;
setImportBusy(true);
const form = new FormData(); form.append('file', f);
const path = f.name.toLowerCase().endsWith('.pdf') ? '/imports/pdf' : '/imports/csv';
exApi(path, { form })
.then(b => {
setImportBatch(b);
toast(`${b.parsed_rows} transactions parsed · ${b.duplicate_rows} duplicate(s) flagged`, 'success');
})
.catch(err => toast(`Import failed: ${err.message || err}`, 'error'))
.finally(() => setImportBusy(false));
};
const commitImport = () => {
if (!importBatch) return;
exApi(`/imports/${importBatch.id}/confirm`, { json: {} })
.then(r => {
toast(`${r.committed} expense(s) imported · ${r.skipped_duplicates} duplicate(s) skipped`, 'success');
setImportBatch(r.batch);
})
.catch(err => toast(`${err.message || err}`, 'warn'));
};
return (
SCAN RECEIPT
!ocrBusy && receiptRef.current.click()}>
📷
{ocrBusy ? 'Scanning… (OCR in progress)' : 'Drop receipt image or click to scan'}
JPG, PNG, WEBP, HEIC. OCR extracts merchant, amount, tax, and date.
{lastOcr && (
LAST OCR · {(lastOcr.ocr_engine || '').toUpperCase()}
Merchant: {lastOcr.merchant || '—'}
Items: {lastOcr.items.length || '—'}
Subtotal: {lastOcr.subtotal_inr != null ? exInr(lastOcr.subtotal_inr) : '—'}
Tax (GST): {lastOcr.tax_inr != null ? exInr(lastOcr.tax_inr) : '—'}
Total: {lastOcr.total_inr != null ? exInr(lastOcr.total_inr) : '—'}
Category: {lastOcr.predicted_category ? {lastOcr.predicted_category} : — }
{lastOcr.status !== 'confirmed' ? (
✓ CONFIRM → ADD EXPENSE
) : (
✓ Confirmed — expense created.
)}
)}
IMPORT SOURCES
stmtRef.current.click()}>
🏦
{importBusy ? 'Parsing…' : 'Bank Statement'}
Upload CSV / text-PDF · auto-parse + duplicate check
{importBatch && (
IMPORT · {importBatch.source_label}
Parsed: {importBatch.parsed_rows} rows
Duplicates: {importBatch.duplicate_rows}
Committed: {importBatch.committed_rows}
{(importBatch.notes || []).map((n, i) =>
{n}
)}
{importBatch.status !== 'committed' ? (
⬇ COMMIT {importBatch.parsed_rows - importBatch.duplicate_rows} NON-DUPLICATE ROWS
) : (
✓ Committed to expenses.
)}
)}
toast('UPI linking needs the RBI Account Aggregator framework — planned, not yet wired', 'warn')}>
📱
UPI Linked
GPay · PhonePe · Paytm — v2
toast('Card linking needs issuer APIs — planned, not yet wired', 'warn')}>
💳
Credit Cards
HDFC · ICICI · Axis — v2
{toastHost}
);
};
const ExpenseDashboard = () => {
const [kpis, setKpis] = React.useState(null);
const [byCat, setByCat] = React.useState(null);
const [trend, setTrend] = React.useState(null);
const [recent, setRecent] = React.useState(null);
React.useEffect(() => {
exApi('/dashboard/kpis').then(setKpis).catch(() => setKpis(false));
exApi('/dashboard/by-category').then(setByCat).catch(() => setByCat([]));
exApi('/dashboard/daily-trend').then(setTrend).catch(() => {});
exApi('/dashboard/recent', { params: { limit: 10 } }).then(setRecent).catch(() => setRecent([]));
}, []);
const donutSegments = (byCat || []).map(s => ({
label: s.label, value: s.value_inr, color: EX_CAT_COLOR[s.label] || '#888',
}));
const totalLabel = kpis ? exInr(kpis.total_month_inr) : '…';
return (
0 ? 'REVIEW' : undefined} />
BY CATEGORY · {kpis ? kpis.month_label : ''}
{donutSegments.length > 0
?
:
{byCat === null ? 'Loading…' : 'No expenses this month yet.'}
}
DAILY TREND (30D)
{trend &&
p.total_inr)} color="var(--gold)" />}
{trend && trend.highest_expense && (
Highest spend: {exInr(trend.highest_expense.amount_inr)} on {trend.highest_expense.spent_at} ({trend.highest_expense.description.slice(0, 30)})
)}
{!trend && Loading…
}
RECENT TRANSACTIONS
{recent === null &&
Loading…
}
{recent && recent.length === 0 && (
No transactions yet — add one from the ADD EXPENSE tab.
)}
{(recent || []).map(e => (
{EX_CAT_ICON[e.category] || '📦'}
{e.description}
{e.category}
{e.tax_deductible && TAX ✓ }
{e.is_anomaly && ⚠ ANOMALY }
{e.needs_review && REVIEW }
{e.when}
{e.source !== 'manual' && via {e.source.replace(/_/g, ' ')} }
{e.is_anomaly && e.anomaly_reason && (
{e.anomaly_reason}
)}
{exInr(e.amount_inr)}
))}
);
};
const ExpenseBudget = () => {
const [budgets, setBudgets] = React.useState(null);
const [showForm, setShowForm] = React.useState(false);
const [formCat, setFormCat] = React.useState(EX_CATEGORIES[0]);
const [formAmt, setFormAmt] = React.useState('');
const [toast, toastHost] = useToast();
const refresh = React.useCallback(() => {
exApi('/budgets').then(setBudgets).catch(() => setBudgets([]));
}, []);
React.useEffect(() => { refresh(); }, [refresh]);
const create = () => {
if (!formAmt || Number(formAmt) <= 0) { toast('Enter a budget amount', 'warn'); return; }
exApi('/budgets', { json: { category: formCat, amount_inr: Number(formAmt) } })
.then(() => { toast(`Budget set: ${formCat} ${exInr(formAmt)}/month`, 'success'); setShowForm(false); setFormAmt(''); refresh(); })
.catch(e => toast(`${e.message || e}`, 'warn'));
};
const remove = (b) => {
exApi(`/budgets/${b.id}`, { method: 'DELETE' })
.then(() => { toast(`${b.category} budget removed`, 'success'); refresh(); })
.catch(e => toast(`${e.message || e}`, 'warn'));
};
const monthLabel = budgets && budgets[0] ? budgets[0].period_label : '';
return (
Monthly budgets{monthLabel ? ` · ${monthLabel}` : ''} · alerts at the 80% threshold
setShowForm(s => !s)}>
{showForm ? '✕ CANCEL' : '+ NEW BUDGET'}
{showForm && (
setFormCat(e.target.value)}>
{EX_CATEGORIES.map(c => {c} )}
setFormAmt(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') create(); }} />
SET BUDGET
)}
{budgets === null &&
Loading budgets…
}
{budgets && budgets.length === 0 && (
No budgets yet — set one per category and spending tracks against it live.
)}
{(budgets || []).map((b, i) => {
const pct = b.pct_used;
const over = b.status === 'over';
const near = b.status === 'near';
return (
{EX_CAT_ICON[b.category] || '📦'}
{b.category}
{exInr(b.spent_inr)} of {exInr(b.amount_inr)}
{over &&
⚠ OVER }
{near &&
⚠ NEAR }
remove(b)}>✕
{Math.round(pct)}% used
{over ? `${exInr(-b.remaining_inr)} over` : `${exInr(b.remaining_inr)} left`}
);
})}
{toastHost}
);
};
const ExpenseReceipts = () => {
const [receipts, setReceipts] = React.useState(null);
const [query, setQuery] = React.useState('');
const [catFilter, setCatFilter] = React.useState('');
const [toast, toastHost] = useToast();
React.useEffect(() => {
const params = { limit: 60 };
if (catFilter) params.cat = catFilter;
exApi('/receipts', { params }).then(r => setReceipts(r.data)).catch(() => setReceipts([]));
}, [catFilter]);
const visible = (receipts || []).filter(r =>
!query || (r.merchant || '').toLowerCase().includes(query.toLowerCase()));
const openImage = (r) => {
exApi(`/receipts/${r.id}/image`)
.then(d => window.open(d.download_url, '_blank', 'noopener'))
.catch(e => toast(`${e.message || e}`, 'warn'));
};
return (
setCatFilter(e.target.value)}>
All categories
{EX_CATEGORIES.map(c => {c} )}
{receipts === null &&
Loading receipts…
}
{receipts && visible.length === 0 && (
{receipts.length === 0 ? 'No receipts yet — scan one from the ADD EXPENSE tab.' : 'No receipts match.'}
)}
{visible.map((r, i) => (
openImage(r)}>
🧾
{r.merchant || 'Unknown merchant'}
{r.total_inr != null ? exInr(r.total_inr) : '—'}
{r.purchase_date || String(r.created_at).slice(0, 10)}
{r.predicted_category && }
{r.tax_inr != null && r.tax_inr > 0 && }
))}
{toastHost}
);
};
const ExpenseReports = () => {
const [fy, setFy] = React.useState(null);
const [trend, setTrend] = React.useState(null);
const [tax, setTax] = React.useState(null);
const [toast, toastHost] = useToast();
React.useEffect(() => {
exApi('/reports/fy-summary').then(setFy).catch(() => {});
exApi('/reports/monthly-trend').then(setTrend).catch(() => {});
exApi('/reports/tax-summary').then(setTax).catch(() => {});
}, []);
// Authenticated Blob download: fetch with the Bearer header, then hand
// the bytes to the browser via an object URL. No identity in the query
// string — tokens or uids in URLs leak into logs and history.
const download = (format) => {
const session = czAuth.get();
fetch(`${API_BASE}${EX_BASE}/reports/export?format=${format}`, {
headers: {
'ngrok-skip-browser-warning': 'true',
...(session && session.access_token ? { Authorization: `Bearer ${session.access_token}` } : { 'x-user-id': exUid }),
},
})
.then(async r => {
if (!r.ok) throw new Error(`export failed (${r.status})`);
const blob = await r.blob();
const cd = r.headers.get('content-disposition') || '';
const name = (cd.match(/filename="([^"]+)"/) || [])[1] || `citadel-export.${format === 'tax_package' ? 'zip' : format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
})
.catch(e => toast(e.message || 'Export failed', 'error'));
};
const fyLabel = fy ? fy.fy : 'FY';
return (
Savings % and net-worth Δ need income data the platform doesn't hold — shown as — rather than invented.
MONTHLY TREND ({fyLabel})
{trend
?
p.total_inr)} color="var(--gold)" labels={[trend[0] && trend[0].label, trend[trend.length - 1] && trend[trend.length - 1].label]} />
: Loading…
}
TAX SUMMARY {fyLabel}
{tax ? (
Section 80C Investments {exInr(tax.section_80c_inr)}
Section 80D Health Insurance {exInr(tax.section_80d_inr)}
Business Expenses {exInr(tax.business_inr)}
HRA Claims {exInr(tax.hra_inr)}
{tax.other_deductible_inr > 0 && (
Other deductible (untagged) {exInr(tax.other_deductible_inr)}
)}
TOTAL DEDUCTIBLE {exInr(tax.total_deductible_inr)}
{tax.note}
) :
Loading…
}
download('csv')}>⬇ EXPORT CSV
download('xlsx')}>📊 EXPORT TO EXCEL
toast('Email delivery needs an outbound mail service — planned, not yet wired', 'warn')}>📧 EMAIL REPORT
download('tax_package')}>🗂 TAX PACKAGE
{toastHost}
);
};
/* ======================================================================
SYSTEM ADMIN CONSOLE (Phase 6) — gov_admin only
Tabs: Users · Audit Trail · Platform Stats. All live via /api/v1/admin/*.
====================================================================== */
const ADMIN_ROLES = ['gov_admin', 'gov_officer', 'gov_analyst', 'citizen'];
const adminApi = (path, opts) => apiFetch('/api/v1/admin' + path, opts);
const fmtWhen = (iso) => iso ? String(iso).replace('T', ' ').slice(0, 16) : '—';
const AdminUsers = () => {
const [rows, setRows] = React.useState(null);
const [q, setQ] = React.useState('');
const [toast, toastHost] = useToast();
const me = (czAuth.user() || {}).id;
const load = React.useCallback(() => {
adminApi('/users' + (q ? '?q=' + encodeURIComponent(q) : ''))
.then(setRows).catch(e => { setRows([]); toast(e.message || 'Load failed', 'error'); });
}, [q]);
React.useEffect(() => { const t = setTimeout(load, 250); return () => clearTimeout(t); }, [load]);
const patch = (id, body) => {
adminApi('/users/' + id, { method: 'PATCH', json: body })
.then(() => { toast('Updated', 'success'); load(); })
.catch(e => toast(e.message || 'Update failed', 'warn'));
};
return (
{rows ? rows.length + ' account(s)' : 'loading…'}
Username Name Role Status Last login Actions
{rows === null && Loading… }
{rows && rows.length === 0 && No accounts match. }
{(rows || []).map(u => (
{u.username || '—'} {u.id === me && YOU }
{u.display_name || '—'}
patch(u.id, { role: e.target.value })}>
{ADMIN_ROLES.map(r => {r} )}
{u.is_active ? ACTIVE : DISABLED }
{fmtWhen(u.last_login_at)}
{u.id !== me && (
patch(u.id, { is_active: !u.is_active })}>
{u.is_active ? 'DEACTIVATE' : 'ACTIVATE'}
)}
))}
{toastHost}
);
};
const AdminAudit = () => {
const [rows, setRows] = React.useState(null);
const [action, setAction] = React.useState('');
const ACTIONS = ['', 'login', 'login_failed', 'lockout', 'logout', 'token_reuse_detected', 'register', 'claim_anonymous', 'admin_user_update', 'fine_updated'];
React.useEffect(() => {
adminApi('/audit?limit=150' + (action ? '&action=' + action : ''))
.then(setRows).catch(() => setRows([]));
}, [action]);
const tone = (a) => /fail|lockout|reuse|reject/.test(a) ? 'var(--red)' : /login|register|claim|update|fine/.test(a) ? 'var(--gold)' : 'var(--cyan)';
return (
setAction(v === 'ALL' ? '' : v === 'LOGIN' ? 'login' : v === 'FAILED' ? 'login_failed' : 'admin_user_update')} accent="var(--gold)" />
{rows ? rows.length + ' entries' : 'loading…'} · append-only
{rows === null &&
Loading audit trail…
}
{rows && rows.length === 0 &&
No entries.
}
{(rows || []).map(r => (
{r.action}
{(r.performed_by || 'system').slice(0, 20)}
{JSON.stringify(r.details || {}).slice(0, 80)}
{fmtWhen(r.created_at)}
))}
);
};
const AdminStats = () => {
const [s, setS] = React.useState(null);
React.useEffect(() => { adminApi('/stats').then(setS).catch(() => setS(false)); }, []);
const n = v => (v == null ? '—' : String(v));
return (
{!s &&
Loading platform stats…
}
{s && (
<>
ACCOUNTS BY ROLE
{ADMIN_ROLES.map(r => (
{r}
{n((s.users_by_role || {})[r])}
))}
PLATFORM VOLUME
{[['Support tickets', s.tickets], ['Expenses logged', s.expenses], ['Fake-news analyses', s.fake_news_analyses], ['Traffic incidents', s.tv_incidents], ['Challans issued', s.tv_challans]].map(([l, v]) => (
{l} {n(v)}
))}
>
)}
);
};
const AdminPanel = ({ onBack }) => {
const [tab, setTab] = React.useState('users');
const tabs = [{ key: 'users', label: 'USERS' }, { key: 'audit', label: 'AUDIT TRAIL' }, { key: 'stats', label: 'PLATFORM STATS' }];
return (
{tab === 'users' &&
}
{tab === 'audit' &&
}
{tab === 'stats' &&
}
);
};
// Export all modules
Object.assign(window, {
DocumentIntelligence, ResumeScreening, TrafficViolations, AnomalyMonitoring,
RAGChatbot, FakeNewsDetector, SupportTickets, ExpenseCategorizer, AdminPanel
});