feat(integration): DPD history tab — coverage, generation and backfill

A fourth tab on /officer/integration reading the loan book as a training
set. The verdict banner is the point of the screen: DPD history is the
model's label, and three thousand generated snapshots look identical on
a chart to three thousand real ones, so the split is stated in words at
the top rather than left to be inferred from a percentage.

Four tiles, and "unknown provenance" is one of them — rows written
before stamping existed are shown as their own bucket rather than
counted as real. Generation and purge sit beside a backfill-from-core
control, with the honest note that a backfill from a simulated adapter
produces simulated rows and only a bank's own core counts toward the
real total.

The demo fixture deliberately reports a book that is NOT trainable. A
fixture claiming a healthy training set would defeat the entire purpose
of the panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-07 18:58:14 +05:30
co-authored by Claude Opus 5
parent 0a76a53ceb
commit e923daf825
3 changed files with 328 additions and 3 deletions
+248 -3
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { errMsg, get, post, put } from '../../lib/api'
import { del, errMsg, get, post, put } from '../../lib/api'
import type {
CanonicalColumns,
ConnectorCatalogEntry,
@@ -8,6 +8,8 @@ import type {
ConnectorSyncResponse,
ConnectorSyncRun,
ConnectorTestResult,
LoanHistoryCoverage,
LoanHistoryGenerateResult,
} from '../../lib/api'
import {
Card,
@@ -20,7 +22,7 @@ import {
StatusPill,
inputCls,
} from '../../components/ui/bits'
import { CANONICAL, demoCatalog, demoProfiles, demoRuns } from '../../lib/demo/connectors'
import { CANONICAL, demoCatalog, demoCoverage, demoProfiles, demoRuns } from '../../lib/demo/connectors'
/**
* Core banking / LMS integration.
@@ -41,7 +43,7 @@ import { CANONICAL, demoCatalog, demoProfiles, demoRuns } from '../../lib/demo/c
* waits for a person.
*/
type Tab = 'connections' | 'mapping' | 'history'
type Tab = 'connections' | 'mapping' | 'history' | 'loanhistory'
/**
* Whether a column name carries borrower identity — the same test the backend
@@ -131,6 +133,52 @@ export function IntegrationPage() {
onError: (e) => setError(errMsg(e)),
})
const coverageQ = useQuery({
queryKey: ['loan-history-coverage'],
queryFn: () => get<LoanHistoryCoverage>('/admin/loan-history/coverage'),
})
const coverage = coverageQ.data ?? demoCoverage()
async function refreshHistory() {
await queryClient.invalidateQueries({ queryKey: ['loan-history-coverage'] })
await queryClient.invalidateQueries({ queryKey: ['connector-runs'] })
}
const generateM = useMutation({
mutationFn: (months: number) =>
post<LoanHistoryGenerateResult>(`/admin/loan-history/generate?months=${months}`),
onSuccess: async (data) => {
setError(null)
setNotice(`${data.snapshotsWritten} snapshots written across ${data.loans} loans — ${data.note}`)
await refreshHistory()
},
onError: (e) => setError(errMsg(e)),
})
const purgeM = useMutation({
mutationFn: () => del<number>('/admin/loan-history/generated'),
onSuccess: async (removed) => {
setError(null)
setNotice(`${removed} generated snapshots removed. Real extracts were left untouched.`)
await refreshHistory()
},
onError: (e) => setError(errMsg(e)),
})
const backfillM = useMutation({
mutationFn: (vars: { code: string; from: string; to: string }) =>
post<ConnectorSyncResponse>(
`/admin/connectors/profiles/${vars.code}/backfill?from=${vars.from}&to=${vars.to}`,
),
onSuccess: async (data) => {
setSyncResult(data)
setError(null)
setNotice(data.run.message)
await refreshHistory()
},
onError: (e) => setError(errMsg(e)),
})
const connected = profiles.filter((p) => p.active).length
const live = profiles.filter((p) => p.active && !p.simulated).length
const failing = profiles.filter((p) => p.lastStatus === 'FAILED').length
@@ -175,6 +223,7 @@ export function IntegrationPage() {
['connections', 'Connections'],
['mapping', 'Field mapping'],
['history', 'Sync history'],
['loanhistory', 'DPD history'],
] as [Tab, string][]
).map(([key, label]) => (
<button
@@ -228,6 +277,17 @@ export function IntegrationPage() {
{tab === 'history' && <HistoryTab runs={runs} />}
{tab === 'loanhistory' && (
<LoanHistoryTab
coverage={coverage}
profiles={profiles}
busy={generateM.isPending || purgeM.isPending || backfillM.isPending}
onGenerate={(months) => generateM.mutate(months)}
onPurge={() => purgeM.mutate()}
onBackfill={(code, from, to) => backfillM.mutate({ code, from, to })}
/>
)}
{testResult && <TestModal result={testResult} onClose={() => setTestResult(null)} />}
{syncResult && <SyncModal result={syncResult} onClose={() => setSyncResult(null)} />}
</div>
@@ -621,6 +681,191 @@ function HistoryTab({ runs }: { runs: ConnectorSyncRun[] }) {
)
}
// ── DPD history ───────────────────────────────────────────────────
/**
* The loan book read as a training set.
*
* <p><b>The verdict banner is the point of this screen.</b> DPD history is the
* model's label, and the difference between three thousand generated snapshots
* and three thousand real ones is the difference between a demonstration and a
* result. Both look identical on a chart, so the split is stated in words at the
* top rather than left to be inferred from a percentage.
*/
function LoanHistoryTab({
coverage,
profiles,
busy,
onGenerate,
onPurge,
onBackfill,
}: {
coverage: LoanHistoryCoverage
profiles: ConnectorProfileRow[]
busy: boolean
onGenerate: (months: number) => void
onPurge: () => void
onBackfill: (code: string, from: string, to: string) => void
}) {
const [months, setMonths] = useState(24)
const [profileCode, setProfileCode] = useState(profiles[0]?.code ?? '')
const [from, setFrom] = useState('2025-08-01')
const [to, setTo] = useState('2026-07-31')
const pct = (n: number) => (coverage.totalRecords ? (n / coverage.totalRecords) * 100 : 0)
return (
<div className="space-y-4">
<div
className={[
'rounded-[11px] border px-3.5 py-2.5',
coverage.trainable ? 'border-emerald-300 bg-emerald-50/60' : 'border-amber/40 bg-amber-soft/40',
].join(' ')}
>
<div className="flex items-center gap-2 mb-1">
<span className={`pill ${coverage.trainable ? 'pill-ok' : 'pill-pend'}`}>
{coverage.trainable ? 'TRAINABLE' : 'NOT TRAINABLE'}
</span>
<span className="text-[11px] font-bold text-forest">The loan book as a training set</span>
</div>
<p className="text-[11px] text-slate-700 leading-relaxed">{coverage.verdict}</p>
</div>
<div className="flex flex-wrap gap-3">
<KpiTile label="Snapshots on the book" value={coverage.totalRecords.toLocaleString('en-IN')} />
<KpiTile
label="From a real extract"
value={coverage.realRecords.toLocaleString('en-IN')}
delta={`${pct(coverage.realRecords).toFixed(0)}% of the book`}
tone={coverage.realRecords > 0 ? 'good' : 'red'}
/>
<KpiTile
label="Generated"
value={coverage.syntheticRecords.toLocaleString('en-IN')}
delta={`${pct(coverage.syntheticRecords).toFixed(0)}% — excluded from any result`}
tone="watch"
/>
<KpiTile
label="Unknown provenance"
value={coverage.unknownProvenance.toLocaleString('en-IN')}
delta="predates stamping"
tone={coverage.unknownProvenance > 0 ? 'watch' : 'neutral'}
/>
</div>
<Card title="History depth">
<div className="p-3.5 grid gap-3 md:grid-cols-3 text-[11.5px]">
<Stat
label="Enterprises with more than one snapshot"
value={`${coverage.enterprisesWithHistory} / ${coverage.enterprisesTotal}`}
note={`${coverage.historyCoveragePct}% of the portfolio`}
/>
<Stat label="Distinct extract dates" value={String(coverage.distinctAsOfDates)} note="monthly" />
<Stat
label="Window"
value={`${coverage.earliest ?? '—'} → ${coverage.latest ?? '—'}`}
note="oldest to newest extract"
/>
</div>
</Card>
<Card title="Generate history for the prototype" badge="synthetic">
<div className="p-3.5 space-y-3">
<p className="text-[11px] text-slate-500 leading-relaxed">
Writes one monthly snapshot per loan behind the extract already on the book, walking DPD
backwards the way an account actually moves — a missed instalment rolls the account forward
about thirty days, a payment cures it to zero. Every row is stamped{' '}
<b className="font-mono text-[10.5px]">SEEDED_HISTORY</b>, and an existing row is never
overwritten, so a real uploaded or connector-fetched extract survives a regeneration.
</p>
<p className="text-[11px] text-slate-600 leading-relaxed">
This exists so the feature store, the point-in-time joins and the label plumbing can be
exercised before a bank has delivered anything. <b>It is not evidence.</b> A model trained
on it would be learning this generator's arithmetic.
</p>
<div className="flex flex-wrap items-end gap-3">
<Field label="Months of history" className="w-[160px]">
<select
className={inputCls}
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
>
{[12, 18, 24, 36, 48].map((m) => (
<option key={m} value={m}>
{m} months
</option>
))}
</select>
</Field>
<PrimaryBtn onClick={() => onGenerate(months)} disabled={busy}>
{busy ? 'Working…' : 'Generate'}
</PrimaryBtn>
<GhostBtn onClick={onPurge} disabled={busy || coverage.syntheticRecords === 0}>
Purge generated ({coverage.syntheticRecords.toLocaleString('en-IN')})
</GhostBtn>
<span className="text-[10.5px] text-slate-400">
Purge removes generated rows only — real extracts are left alone.
</span>
</div>
</div>
</Card>
<Card title="Backfill from the lender's core" badge="the real path">
<div className="p-3.5 space-y-3">
<p className="text-[11px] text-slate-500 leading-relaxed">
The honest way to get history. Every core can answer <i>what did the book look like at each
month end last year</i> — it is the same query behind the bank's regulatory returns — so the
label is asked for once rather than waited for. One extract per month is pulled, mapped and
staged as a single batch for review.
</p>
<div className="flex flex-wrap items-end gap-3">
<Field label="Connection" className="min-w-[240px]">
<select
className={inputCls}
value={profileCode}
onChange={(e) => setProfileCode(e.target.value)}
>
{profiles.map((p) => (
<option key={p.code} value={p.code}>
{p.displayName}
{p.simulated ? ' — simulated' : ''}
</option>
))}
</select>
</Field>
<Field label="From" className="w-[150px]">
<input type="date" className={inputCls} value={from} onChange={(e) => setFrom(e.target.value)} />
</Field>
<Field label="To" className="w-[150px]">
<input type="date" className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} />
</Field>
<PrimaryBtn
onClick={() => onBackfill(profileCode, from, to)}
disabled={busy || !profileCode}
>
{busy ? 'Working…' : 'Backfill'}
</PrimaryBtn>
</div>
<p className="text-[10.5px] text-slate-400">
A backfill from a simulated adapter produces simulated rows, badged as such on the run. Only
an extract from a bank's own core counts toward the real total above.
</p>
</div>
</Card>
</div>
)
}
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
return (
<div>
<div className="font-mono text-[9.5px] tracking-[0.1em] uppercase text-slate-500">{label}</div>
<div className="text-[15px] font-bold text-forest mt-0.5">{value}</div>
<div className="text-[10.5px] text-slate-400">{note}</div>
</div>
)
}
// ── modals ────────────────────────────────────────────────────────
function TestModal({ result, onClose }: { result: ConnectorTestResult; onClose: () => void }) {
+55
View File
@@ -552,6 +552,61 @@ export interface ConnectorSyncResponse {
preview?: ManualEntryPreview
}
/**
* The loan book read as a training set.
*
* <p>`verdict` is a sentence, not a number, because "is this trainable" is the
* only question that matters here and it must not be left to whoever is reading
* the percentages to work out.
*/
export interface LoanHistoryCoverage {
totalRecords: number
/** Rows this system generated. Never counts toward a real-data claim. */
syntheticRecords: number
realRecords: number
/** Rows written before provenance was stamped. Never counted as real. */
unknownProvenance: number
enterprisesTotal: number
enterprisesWithAnyLoan: number
enterprisesWithHistory: number
distinctAsOfDates: number
earliest: string | null
latest: string | null
historyCoveragePct: number
trainable: boolean
verdict: string
}
export interface LoanHistoryGenerateResult {
enterprises: number
loans: number
snapshotsWritten: number
monthsRequested: number
from: string | null
to: string | null
note: string
}
/** One month of one loan, as an extract reported it. */
export interface LoanSnapshot {
id: number
accountRef: string
enterpriseId: string
product: string | null
sanctionedAmount: number | null
outstandingAmount: number | null
instalmentAmount: number | null
nextDueDate: string | null
lastPaidDate: string | null
dpd: number | null
bucket: string | null
closed: boolean
asOfDate: string
observedAt: string | null
/** SEEDED | SEEDED_HISTORY | UPLOAD | a connector profile code. */
source: string | null
}
/** The template a mapping targets, and the columns that may never be mapped in. */
export interface CanonicalColumns {
columns: string[]
+25
View File
@@ -3,6 +3,7 @@ import type {
ConnectorCatalogEntry,
ConnectorProfileRow,
ConnectorSyncRun,
LoanHistoryCoverage,
} from '../api'
/**
@@ -194,6 +195,30 @@ export function demoProfiles(): ConnectorProfileRow[] {
]
}
/**
* Deliberately shows a book that is **not** trainable. The demo's whole job on
* this panel is to make the distinction between generated and real history
* visible, and a fixture claiming a healthy training set would defeat it.
*/
export function demoCoverage(): LoanHistoryCoverage {
return {
totalRecords: 3408,
syntheticRecords: 3256,
realRecords: 0,
unknownProvenance: 152,
enterprisesTotal: 142,
enterprisesWithAnyLoan: 142,
enterprisesWithHistory: 142,
distinctAsOfDates: 24,
earliest: '2024-08-31',
latest: '2026-07-31',
historyCoveragePct: 100,
trainable: false,
verdict:
'Not trainable. No snapshot on the book came from a real lender extract — the pipeline can be exercised end to end, but any accuracy measured against this would be measuring the generator, not the model.',
}
}
export function demoRuns(): ConnectorSyncRun[] {
return [
{