import React, { useMemo, useState } from 'react'; import { ScoredData } from '../lib/scoring'; import { fmtCr } from '../lib/format'; interface Props { data: ScoredData[]; // sorted ascending by year (as App provides) userApiKey?: string; // optional — enables the AI summary button } /* ── text helpers ─────────────────────────────────────────────────────────── */ function norm(s: string): string { return s.toLowerCase().replace(/^\s*[•\-*\d.)]+\s*/, '').replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim(); } function tokenSet(s: string): Set { return new Set(norm(s).split(' ').filter(w => w.length > 2)); } function jaccard(a: string, b: string): number { const A = tokenSet(a), B = tokenSet(b); if (!A.size || !B.size) return 0; let inter = 0; A.forEach(x => { if (B.has(x)) inter++; }); return inter / (A.size + B.size - inter); } function matches(a: string, b: string): boolean { const na = norm(a), nb = norm(b); if (na === nb) return true; if (na.length > 15 && (nb.includes(na) || na.includes(nb))) return true; return jaccard(a, b) >= 0.6; } function splitItems(t?: string): string[] { if (!t) return []; return t .split(/\n+/) .flatMap(line => (line.length > 180 ? line.split(/(?<=\.)\s+(?=[A-Z0-9])/) : [line])) .map(s => s.replace(/^\s*[•\-*\d.)]+\s*/, '').trim()) .filter(s => s.length >= 8); } function diffText(oldT?: string, newT?: string): { added: string[]; removed: string[] } { const O = splitItems(oldT), N = splitItems(newT); return { added: N.filter(n => !O.some(o => matches(o, n))), removed: O.filter(o => !N.some(n => matches(o, n))), }; } /* ── tiny safe markdown (bold + bullets) for the optional AI summary ───────── */ function renderLines(text: string): React.ReactNode[] { return text.split(/\n/).map((line, i) => { const bullet = /^\s*[-•*]\s+/.test(line); const clean = line.replace(/^\s*[-•*]\s+/, ''); const parts = clean.split(/(\*\*[^*]+\*\*)/g).map((p, j) => p.startsWith('**') && p.endsWith('**') ? {p.slice(2, -2)} : {p}); return (
{bullet && } {parts}
); }); } const QUAL: { key: keyof ScoredData; label: string }[] = [ { key: 'keyRisks', label: 'Key Risks' }, { key: 'opportunities', label: 'Opportunities' }, { key: 'mgmtOutlook', label: 'Management Outlook' }, { key: 'kams', label: 'Key Audit Matters' }, { key: 'accountingPolicyChanges', label: 'Accounting Policy Changes' }, ]; export default function NarrativeDiffPanel({ data, userApiKey }: Props) { // Build consecutive transitions: [FY(n-1) -> FY(n)] const transitions = useMemo( () => data.slice(1).map((to, i) => ({ from: data[i], to })), [data] ); const [idx, setIdx] = useState(transitions.length - 1); const [aiText, setAiText] = useState(''); const [aiLoading, setAiLoading] = useState(false); const [aiError, setAiError] = useState(''); if (data.length < 2) { return (
Load at least two years of annual reports to compare what changed between them.
); } const t = transitions[Math.max(0, Math.min(idx, transitions.length - 1))]; const { from, to } = t; // Structured governance changes const structured: { label: string; from: string; to: string; changed: boolean }[] = [ row('Auditor', from.auditorName, to.auditorName), row('Audit Opinion', from.auditorOpinion, to.auditorOpinion), row('Going Concern', boolStr(from.hasGoingConcern), boolStr(to.hasGoingConcern)), row('Promoter Pledge %', pctStr(from.promoterPledgePercent), pctStr(to.promoterPledgePercent)), row('Related Party Txns', fmtCr(from.relatedPartyTransactions), fmtCr(to.relatedPartyTransactions)), row('Contingent Liabilities', fmtCr(from.contingentLiabilities), fmtCr(to.contingentLiabilities)), ]; const generateAI = async () => { if (!userApiKey && true) { /* server key may still exist; attempt anyway */ } setAiLoading(true); setAiError(''); setAiText(''); try { const ctx: any = { companyName: to.companyName, sector: to.sector }; ctx[`FY${from.year}`] = pick(from); ctx[`FY${to.year}`] = pick(to); const res = await fetch('/api/discuss', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: `Compare the FY${from.year} and FY${to.year} qualitative disclosures in the context. Summarise only what MATERIALLY CHANGED, grouped under bold headings: New / intensified risks, Dropped or softened risks, Outlook shift, Governance & accounting changes. Be concise and specific; ignore cosmetic wording changes.`, history: [], context: ctx, userApiKey: userApiKey || '', }), }); const json = await res.json(); if (json.error) throw new Error(json.error); setAiText(json.text || ''); } catch (e: any) { setAiError(e.message || 'Failed to generate summary.'); } finally { setAiLoading(false); } }; return (
{/* Transition selector */}
Compare: {transitions.map((tr, i) => ( ))}
{/* Optional AI summary */} {(aiText || aiError) && (
AI summary · FY{from.year} → FY{to.year}
{aiError ?
{aiError}
:
{renderLines(aiText)}
}
)} {/* Structured changes */}
Governance & Disclosure Changes
ItemFY{from.year}FY{to.year} {structured.map((s, i) => ( {s.label} {s.from} {s.to} ))}
{/* Per-field text diffs */} {QUAL.map(({ key, label }) => { const { added, removed } = diffText(from[key] as string, to[key] as string); const hasOld = !!(from[key]), hasNew = !!(to[key]); if (!hasOld && !hasNew) return null; const nochange = added.length === 0 && removed.length === 0; return (
{label}
{nochange ? (

No material change detected.

) : (
{added.map((a, i) => )} {removed.map((r, i) => )}
)}
); })}

Added (green) appears in FY{to.year} but not FY{from.year}; removed (red) was in FY{from.year} but is gone in FY{to.year}. Matching is fuzzy, so minor rewording is ignored — verify against the reports.

); } /* ── small presentational helpers ─────────────────────────────────────────── */ function DiffItem({ sign, color, text }: { sign: string; color: string; text: string }) { return (
{sign} {text}
); } function Head({ children, right }: { children: React.ReactNode; right?: boolean }) { return
{children}
; } function Cell({ children, right, muted, strong, color }: { children: React.ReactNode; right?: boolean; muted?: boolean; strong?: boolean; color?: string }) { return
{children}
; } function row(label: string, a?: string | null, b?: string | null) { const from = a == null || a === '' ? '—' : String(a); const to = b == null || b === '' ? '—' : String(b); return { label, from, to, changed: from !== to && to !== '—' }; } function boolStr(v?: boolean | null) { return v == null ? '—' : v ? 'Yes' : 'No'; } function pctStr(v?: number | null) { return v == null ? '—' : `${Number(v).toFixed(1)}%`; } function pick(d: ScoredData) { return { keyRisks: d.keyRisks, opportunities: d.opportunities, mgmtOutlook: d.mgmtOutlook, kams: d.kams, accountingPolicyChanges: d.accountingPolicyChanges, auditorName: d.auditorName, auditorOpinion: d.auditorOpinion, hasGoingConcern: d.hasGoingConcern, }; }