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 { useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 {
|
import type {
|
||||||
CanonicalColumns,
|
CanonicalColumns,
|
||||||
ConnectorCatalogEntry,
|
ConnectorCatalogEntry,
|
||||||
@@ -8,6 +8,8 @@ import type {
|
|||||||
ConnectorSyncResponse,
|
ConnectorSyncResponse,
|
||||||
ConnectorSyncRun,
|
ConnectorSyncRun,
|
||||||
ConnectorTestResult,
|
ConnectorTestResult,
|
||||||
|
LoanHistoryCoverage,
|
||||||
|
LoanHistoryGenerateResult,
|
||||||
} from '../../lib/api'
|
} from '../../lib/api'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
StatusPill,
|
StatusPill,
|
||||||
inputCls,
|
inputCls,
|
||||||
} from '../../components/ui/bits'
|
} 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.
|
* Core banking / LMS integration.
|
||||||
@@ -41,7 +43,7 @@ import { CANONICAL, demoCatalog, demoProfiles, demoRuns } from '../../lib/demo/c
|
|||||||
* waits for a person.
|
* 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
|
* Whether a column name carries borrower identity — the same test the backend
|
||||||
@@ -131,6 +133,52 @@ export function IntegrationPage() {
|
|||||||
onError: (e) => setError(errMsg(e)),
|
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 connected = profiles.filter((p) => p.active).length
|
||||||
const live = profiles.filter((p) => p.active && !p.simulated).length
|
const live = profiles.filter((p) => p.active && !p.simulated).length
|
||||||
const failing = profiles.filter((p) => p.lastStatus === 'FAILED').length
|
const failing = profiles.filter((p) => p.lastStatus === 'FAILED').length
|
||||||
@@ -175,6 +223,7 @@ export function IntegrationPage() {
|
|||||||
['connections', 'Connections'],
|
['connections', 'Connections'],
|
||||||
['mapping', 'Field mapping'],
|
['mapping', 'Field mapping'],
|
||||||
['history', 'Sync history'],
|
['history', 'Sync history'],
|
||||||
|
['loanhistory', 'DPD history'],
|
||||||
] as [Tab, string][]
|
] as [Tab, string][]
|
||||||
).map(([key, label]) => (
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
@@ -228,6 +277,17 @@ export function IntegrationPage() {
|
|||||||
|
|
||||||
{tab === 'history' && <HistoryTab runs={runs} />}
|
{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)} />}
|
{testResult && <TestModal result={testResult} onClose={() => setTestResult(null)} />}
|
||||||
{syncResult && <SyncModal result={syncResult} onClose={() => setSyncResult(null)} />}
|
{syncResult && <SyncModal result={syncResult} onClose={() => setSyncResult(null)} />}
|
||||||
</div>
|
</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 ────────────────────────────────────────────────────────
|
// ── modals ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TestModal({ result, onClose }: { result: ConnectorTestResult; onClose: () => void }) {
|
function TestModal({ result, onClose }: { result: ConnectorTestResult; onClose: () => void }) {
|
||||||
|
|||||||
@@ -552,6 +552,61 @@ export interface ConnectorSyncResponse {
|
|||||||
preview?: ManualEntryPreview
|
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. */
|
/** The template a mapping targets, and the columns that may never be mapped in. */
|
||||||
export interface CanonicalColumns {
|
export interface CanonicalColumns {
|
||||||
columns: string[]
|
columns: string[]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ConnectorCatalogEntry,
|
ConnectorCatalogEntry,
|
||||||
ConnectorProfileRow,
|
ConnectorProfileRow,
|
||||||
ConnectorSyncRun,
|
ConnectorSyncRun,
|
||||||
|
LoanHistoryCoverage,
|
||||||
} from '../api'
|
} 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[] {
|
export function demoRuns(): ConnectorSyncRun[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user