console: every step shows what it actually decided

The trail rendered twelve fields as prose and four as money. The other
hundred-odd were invisible — so Capture Motor Risk, which writes engine
capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
add-ons, appeared as a heading and a timestamp. The only way to see what
an agent had decided was to open the Lead file and guess which values
came from which step.

Each entry now carries a folded "N fields written" list: every field that
step committed, by label, with the values already shown above it
(narrative, money, documents) excluded so nothing repeats. Folded because
most steps write a dozen and the trail has to stay readable as a story.

WHAT THIS IS NOT, and it matters: it is not the model's internal
reasoning, and it is not its tool calls. Both ARE recorded — ai_reasoning,
ai_confidence, ai_model and ai_tool_calls sit on the same audit row — but
GetAuditLogsForApp selects eight columns and none of them are those, so
an app JWT cannot read them at all. Surfacing them needs four columns
added to that query and to ActivityLogEntry, which is a view-service
change and an image build.

This is the next best thing and arguably the more useful half: not what
the agent thought, but what it committed, itemised against the step that
committed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-08 17:12:51 +05:30
parent cb94e2417f
commit c7cd5602ae
2 changed files with 120 additions and 0 deletions

View File

@ -289,3 +289,62 @@
/* A chat entry's dot takes the channel's colour, so a scan down the rail shows
where the conversation happened without reading a word. */
.tl__item--chat .tl__dot { background: #0f7b45; border-color: #0f7b45; }
/* what this step wrote
Folded by default: most steps write a dozen fields and the trail has to
stay readable as a story. Opened, the entry becomes an itemised account of
what the agent decided which is the closest an app JWT can get to its
reasoning, since ai_reasoning is on the row but not in the API. */
.tl__wrote { margin-top: 8px; }
.tl__wrote > summary {
cursor: pointer;
list-style: none;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 500;
color: var(--zk-blue);
}
.tl__wrote > summary::-webkit-details-marker { display: none; }
.tl__wrote > summary::after {
content: '';
width: 5px; height: 5px;
border-right: 1.4px solid currentColor;
border-bottom: 1.4px solid currentColor;
transform: rotate(45deg) translate(-1px, -1px);
transition: transform var(--t-fast);
}
.tl__wrote[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.tl__wrote > summary:hover { color: var(--zk-blue-dark); }
.tl__wrote dl {
margin: 8px 0 0;
padding: 10px 12px;
border: 1px solid var(--zk-line-soft);
border-radius: var(--r-sm, 6px);
background: var(--zk-tint);
display: flex;
flex-direction: column;
gap: 6px;
}
.tl__wrote dl > div { display: flex; gap: 10px; align-items: baseline; }
.tl__wrote dt {
flex: none;
width: 42%;
font-size: 0.72rem;
color: var(--zk-grey);
overflow-wrap: anywhere;
}
.tl__wrote dd {
margin: 0;
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--zk-ink);
overflow-wrap: anywhere;
}

View File

@ -42,6 +42,33 @@ const NARRATIVE = [
const MONEY = new Set(['quoted_premium', 'commission_amount', 'sme_value_at_risk', 'motor_idv'])
/**
* Fields already shown elsewhere in the entry, so the "what it wrote" list
* does not repeat them: the narrative prose above it, the money figures below
* it, the document chips, and the platform's own bookkeeping.
*/
const SHOWN_ELSEWHERE = new Set([...NARRATIVE.map(([k]) => k), ...MONEY, '_system'])
/** A value as one short line. Long prose is already in the narrative block, so
* anything here is a field value, not a paragraph. */
function short(f) {
const v = f.value
if (v === null || v === undefined || v === '') return null
if (Array.isArray(v)) {
const named = v
.map((x) => (x && typeof x === 'object' ? (x.original_name || x.uuid || '') : x))
.filter(Boolean)
return named.length ? named.join(', ') : null
}
if (typeof v === 'object') return null
const t = String(v).trim()
if (!t) return null
// A value long enough to be prose belongs in the narrative block, not in a
// list of what changed truncating it here would show half a sentence and
// teach the reader that this list is unreliable.
return t.length > 90 ? t.slice(0, 88) + '…' : t
}
/** Document slots, in the order they are worth reading. Labels are shorter than
* the workflow's own "Registration Certificate (RC)" is a chip, not a form
* label and a slot missing from here still renders under its server label. */
@ -236,6 +263,26 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
figures: [...byBase]
.filter(([base, f]) => MONEY.has(base) && f.value)
.map(([base, f]) => [base.replace(/_/g, ' '), '₹' + Number(f.value).toLocaleString('en-IN')]),
/**
* EVERYTHING THIS STEP WROTE.
*
* Twelve fields were rendered as prose and four as money; the other
* hundred-odd were invisible. So Capture Motor Risk which writes engine
* capacity, fuel, year, NCB, previous insurer, policy number, IDV and the
* add-ons showed as a heading and a timestamp, and the only way to see
* what an agent had actually decided was to open the Lead file and guess
* which values came from that step.
*
* This is not the model's internal reasoning; that is recorded on the row
* (ai_reasoning, ai_tool_calls) and the audit endpoint does not return it.
* It is the next best and arguably more useful thing: the decision it
* committed, field by field, against the step that made it.
*/
wrote: [...byBase]
.filter(([base]) => !SHOWN_ELSEWHERE.has(base) && !base.startsWith('doc_'))
.map(([base, f]) => [f.label || base.replace(/_/g, ' '), short(f)])
.filter(([, v]) => v !== null),
}
})
@ -365,6 +412,20 @@ export default function Timeline({ rows, onOpenAgent, onOpenChat }) {
))}
</div>
) : null}
{/* Folded, because most steps write a lot and the trail has to
stay readable as a story. Open it and the step becomes an
itemised account of what it decided. */}
{it.wrote.length ? (
<details className="tl__wrote">
<summary>{it.wrote.length} field{it.wrote.length === 1 ? '' : 's'} written</summary>
<dl>
{it.wrote.map(([k, v]) => (
<div key={k}><dt>{k}</dt><dd>{v}</dd></div>
))}
</dl>
</details>
) : null}
</div>
</li>
))}