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:
@@ -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 }) {
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user