refactor: restructure the AI assessment and activity blocks

Assessment now reads verdict -> checks -> evidence -> reasoning. AI Reasoning
moved below the PO/GR/contract records it narrates, so it lands as the
conclusion rather than the preamble. Each part is a SubBlock: small-caps title,
optional badge, hairline above, no nested cards. Evidence rows get an aligned
label column and a red wash on the discrepancy row.

Activity gains a run summary (decisions, wall time, tokens, cost, model),
numbered decisions on a connected timeline, and honest confidence: the figure
the model acted on, colour-banded, annotated when the self-check revised it,
plus a chip when reflexion asked to escalate. Plan/reasoning/self-check sit
behind accent rules, knowledge sources render as chips, and the trace
waterfall carries per-step tokens, indents child spans and reddens failures.

Total run time sums only top-level spans; nested ones would double-count.
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-07-29 20:13:55 +05:30
parent a52ff32eb4
commit fe6c257ace
2 changed files with 299 additions and 183 deletions

View File

@ -10,9 +10,18 @@
// token/cost roll-up are the logic that came with the endpoints. // token/cost roll-up are the logic that came with the endpoints.
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { AlertTriangle, ChevronDown, Coins, Hash, Sparkles } from "lucide-react"; import {
AlertTriangle,
ChevronDown,
Clock,
Coins,
Cpu,
Hash,
Sparkles,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { AIDecision, AIEscalation, AITrace } from "@/api/viewService"; import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "@/api/viewService";
import { activityLabel, fmtDuration } from "@/lib/workflowMeta"; import { activityLabel, fmtDuration } from "@/lib/workflowMeta";
import { formatDate, formatTime } from "@/lib/format"; import { formatDate, formatTime } from "@/lib/format";
@ -24,6 +33,10 @@ const STATUS_TONE: Record<string, string> = {
error: "bg-state-rejected/12 text-state-rejected", error: "bg-state-rejected/12 text-state-rejected",
}; };
/** Same banding as the assessment block: 90 auto-accept, 60 re-check. */
const confidenceTone = (pct: number) =>
pct >= 90 ? "text-state-approved" : pct >= 60 ? "text-state-clarification" : "text-state-rejected";
interface Props { interface Props {
decisions: AIDecision[]; decisions: AIDecision[];
escalations: AIEscalation[]; escalations: AIEscalation[];
@ -54,71 +67,84 @@ export default function AiActivity({ decisions, escalations, traces, unavailable
const totals = useMemo(() => { const totals = useMemo(() => {
let tokens = 0; let tokens = 0;
let cost = 0; let cost = 0;
let ms = 0;
for (const t of traces) for (const t of traces)
for (const e of t.events) { for (const e of t.events) {
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0); tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
cost += e.cost ?? 0; cost += e.cost ?? 0;
// Only top-level steps, or nested spans would be counted twice.
if (e.parent_seq == null) ms += e.duration_ms ?? 0;
} }
return { tokens, cost }; return { tokens, cost, ms };
}, [traces]); }, [traces]);
if (unavailable) { if (unavailable) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="flex items-start gap-2 text-sm text-muted-foreground">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
The AI monitoring service isnt reachable, so decisions and traces cant be shown. The AI monitoring service isnt reachable, so decisions and traces cant be shown.
</p> </p>
); );
} }
if (ordered.length === 0 && escalations.length === 0) { if (ordered.length === 0 && escalations.length === 0) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">The AI employee hasnt acted on this invoice.</p>
The AI employee hasnt acted on this invoice.
</p>
); );
} }
const models = [...new Set(ordered.map((d) => d.model).filter(Boolean))];
return ( return (
<div> <div>
{(totals.tokens > 0 || totals.cost > 0) && ( {/* Run summary — the cost of the automation, before the detail of it. */}
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-muted-foreground"> {(ordered.length > 0 || totals.tokens > 0) && (
<dl className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-2xs text-muted-foreground">
<Stat icon={Sparkles}>
{ordered.length} {ordered.length === 1 ? "decision" : "decisions"}
</Stat>
{totals.ms > 0 && <Stat icon={Clock}>{fmtDuration(totals.ms)}</Stat>}
{totals.tokens > 0 && ( {totals.tokens > 0 && (
<span className="flex items-center gap-1"> <Stat icon={Hash}>{totals.tokens.toLocaleString("en-IN")} tokens</Stat>
<Hash className="size-2.5" aria-hidden />
{totals.tokens.toLocaleString("en-IN")} tokens
</span>
)} )}
{totals.cost > 0 && ( {totals.cost > 0 && <Stat icon={Coins}>${totals.cost.toFixed(4)}</Stat>}
<span className="flex items-center gap-1"> {models.length > 0 && <Stat icon={Cpu}>{models.join(", ")}</Stat>}
<Coins className="size-2.5" aria-hidden />${totals.cost.toFixed(4)} </dl>
</span>
)}
</div>
)} )}
<ol className="space-y-3"> <ol>
{ordered.map((d) => ( {ordered.map((d, i) => (
<Decision key={d.id} decision={d} trace={traceByDecision.get(d.id)} /> <Decision
key={d.id}
decision={d}
trace={traceByDecision.get(d.id)}
index={i + 1}
last={i === ordered.length - 1 && escalations.length === 0}
/>
))} ))}
</ol> </ol>
{escalations.length > 0 && ( {escalations.length > 0 && (
<div className="mt-4 space-y-2 border-t border-border/60 pt-3"> <div className="space-y-2.5">
{escalations.map((e) => ( {escalations.map((e) => (
<div key={e.id} className="flex items-start gap-2"> <div
<AlertTriangle key={e.id}
className="mt-0.5 size-3.5 shrink-0 text-state-clarification" className="border-l-2 border-state-clarification/50 pl-3"
aria-hidden >
/> <p className="flex flex-wrap items-center gap-1.5 text-base font-semibold text-state-clarification">
<div className="min-w-0"> <AlertTriangle className="size-3.5 shrink-0" aria-hidden />
<p className="text-base font-semibold text-state-clarification">
Escalated to a human Escalated to a human
{e.recommended_action && ` · ${e.recommended_action}`} {e.recommended_action && (
<span className="text-2xs font-bold uppercase tracking-wider">
· {e.recommended_action}
</span>
)}
</p> </p>
{(e.reasoning || e.reason) && ( {(e.reasoning || e.reason) && (
<p className="mt-0.5 text-sm text-muted-foreground">{e.reasoning || e.reason}</p> <p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
{e.reasoning || e.reason}
</p>
)} )}
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -126,15 +152,49 @@ export default function AiActivity({ decisions, escalations, traces, unavailable
); );
} }
function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrace }) { function Stat({ icon: Icon, children }: { icon: typeof Hash; children: React.ReactNode }) {
return (
<dd className="flex items-center gap-1 tabular-nums">
<Icon className="size-2.5 shrink-0 text-primary" aria-hidden />
{children}
</dd>
);
}
function Decision({
decision: d,
trace,
index,
last,
}: {
decision: AIDecision;
trace?: AITrace;
index: number;
last: boolean;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pct = d.reflexion?.adjusted_confidence ?? d.confidence; // The self-check can revise the model's own confidence down; show the number
// it ended up acting on, and say so when it changed.
const raw = d.confidence != null ? d.confidence * 100 : null;
const adjusted =
d.reflexion?.adjusted_confidence != null ? d.reflexion.adjusted_confidence * 100 : null;
const pct = adjusted ?? raw;
const time = formatTime(d.created_at); const time = formatTime(d.created_at);
const hasDetail = !!(d.plan || d.reasoning || d.reflexion?.concerns || trace);
return ( return (
<li> <li className={cn("relative flex gap-3", last ? "pb-0" : "pb-4")}>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1"> {/* Connector — makes a multi-run sequence read as one process. */}
<Sparkles className="size-3 shrink-0 translate-y-px text-primary" aria-hidden /> {!last && (
<span aria-hidden className="absolute bottom-0 left-3.5 top-8 w-px bg-primary/15" />
)}
<span className="relative flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/12 text-2xs font-bold tabular-nums text-primary">
{index}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-base font-semibold text-foreground"> <span className="text-base font-semibold text-foreground">
{activityLabel(d.activity_id)} {activityLabel(d.activity_id)}
</span> </span>
@ -147,8 +207,18 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
{d.status} {d.status}
</span> </span>
{pct != null && ( {pct != null && (
<span className="text-xs font-semibold tabular-nums text-muted-foreground"> <span className={cn("text-sm font-bold tabular-nums", confidenceTone(pct))}>
{Math.round(pct * 100)}% confidence {Math.round(pct)}%
{adjusted != null && raw != null && Math.round(adjusted) !== Math.round(raw) && (
<span className="ml-1 font-normal text-muted-foreground">
(self-checked from {Math.round(raw)}%)
</span>
)}
</span>
)}
{d.reflexion?.should_escalate && (
<span className="rounded-full bg-state-clarification/12 px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-clarification">
flagged for escalation
</span> </span>
)} )}
</div> </div>
@ -156,16 +226,17 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70"> <p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
{formatDate(d.created_at)} {formatDate(d.created_at)}
{time && ` · ${time}`} {time && ` · ${time}`}
{d.model && ` · ${d.model}`}
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`} {d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`} {d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
</p> </p>
{d.error_message && ( {d.error_message && (
<p className="mt-1 text-sm text-destructive">{d.error_message}</p> <p className="mt-1.5 border-l-2 border-destructive/50 pl-2.5 text-sm text-destructive">
{d.error_message}
</p>
)} )}
{(d.plan || d.reasoning || d.reflexion?.concerns || trace) && ( {hasDetail && (
<> <>
<button <button
type="button" type="button"
@ -181,7 +252,7 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
</button> </button>
{open && ( {open && (
<div className="mt-2 space-y-2.5 border-l-2 border-primary/20 pl-3"> <div className="mt-2.5 space-y-3">
{d.plan && <Passage label="Plan" text={d.plan} />} {d.plan && <Passage label="Plan" text={d.plan} />}
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />} {d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
{d.reflexion?.concerns && ( {d.reflexion?.concerns && (
@ -192,20 +263,46 @@ function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrac
/> />
)} )}
{d.knowledge_sources && d.knowledge_sources.length > 0 && ( {d.knowledge_sources && d.knowledge_sources.length > 0 && (
<Passage label="Knowledge used" text={d.knowledge_sources.join(", ")} /> <div>
<Label>Knowledge used</Label>
<ul className="mt-1 flex flex-wrap gap-1.5">
{d.knowledge_sources.map((s) => (
<li
key={s}
className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-muted-foreground"
>
{s}
</li>
))}
</ul>
</div>
)} )}
{trace && <Waterfall trace={trace} />} {trace && <Waterfall trace={trace} />}
</div> </div>
)} )}
</> </>
)} )}
</div>
</li> </li>
); );
} }
function Label({ children }: { children: React.ReactNode }) {
return (
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</p>
);
}
function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) { function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) {
return ( return (
<div> <div
className={cn(
"border-l-2 pl-2.5",
tone === "warn" ? "border-state-clarification/50" : "border-primary/20",
)}
>
<p <p
className={cn( className={cn(
"text-2xs font-semibold uppercase tracking-wider", "text-2xs font-semibold uppercase tracking-wider",
@ -228,33 +325,43 @@ function Waterfall({ trace }: { trace: AITrace }) {
return ( return (
<div> <div>
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground"> <Label>Run trace</Label>
Run trace <ul className="mt-1.5 space-y-1">
</p>
<ul className="mt-1 space-y-1">
{events.map((e) => ( {events.map((e) => (
<li key={e.seq} className="flex items-center gap-2 text-2xs"> <TraceRow key={e.seq} event={e} max={max} />
<span
className="min-w-0 flex-1 truncate text-muted-foreground"
style={{ paddingLeft: e.parent_seq != null ? 10 : 0 }}
>
{e.name || e.type}
</span>
<span className="h-1.5 w-24 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn(
"block h-full rounded-full",
e.error || e.status === "error" ? "bg-state-rejected" : "bg-primary/60",
)}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-12 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
))} ))}
</ul> </ul>
</div> </div>
); );
} }
function TraceRow({ event: e, max }: { event: AITraceEvent; max: number }) {
const failed = !!e.error || e.status === "error" || e.status === "failed";
const tokens = (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
return (
<li className="flex items-center gap-2 text-2xs">
<span
className={cn("min-w-0 flex-1 truncate", failed ? "text-destructive" : "text-muted-foreground")}
style={{ paddingLeft: e.parent_seq != null ? 12 : 0 }}
title={e.error || e.name || e.type}
>
{e.name || e.type}
</span>
{tokens > 0 && (
<span className="w-20 shrink-0 text-right tabular-nums text-muted-foreground/60">
{tokens.toLocaleString("en-IN")} tok
</span>
)}
<span className="h-1.5 w-20 shrink-0 overflow-hidden rounded-full bg-muted">
<span
className={cn("block h-full rounded-full", failed ? "bg-state-rejected" : "bg-primary/60")}
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
/>
</span>
<span className="w-11 shrink-0 text-right tabular-nums text-muted-foreground">
{fmtDuration(e.duration_ms)}
</span>
</li>
);
}

View File

@ -406,7 +406,7 @@ function AiAssessment({
return ( return (
<section className="bg-primary/[0.02] px-6 py-5"> <section className="bg-primary/[0.02] px-6 py-5">
<div className="mb-3.5 flex flex-wrap items-center justify-between gap-2"> <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<SectionLabel icon={Sparkles} className="mb-0"> <SectionLabel icon={Sparkles} className="mb-0">
Automated 3-Way Match Automated 3-Way Match
</SectionLabel> </SectionLabel>
@ -416,16 +416,12 @@ function AiAssessment({
</span> </span>
</div> </div>
{/* Verdict first: what the agent concluded, at what amount, how sure. */}
{(verdict || pct !== null || recommended !== null) && ( {(verdict || pct !== null || recommended !== null) && (
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-3"> <div className="mb-4 flex flex-wrap items-center gap-x-5 gap-y-2">
{verdict && ( {verdict && (
<span <span className={cn("inline-flex items-center gap-2 text-lg font-bold", verdict.text)}>
className={cn( <verdict.icon className="size-5" strokeWidth={2.5} aria-hidden />
"inline-flex items-center gap-1.5 text-md font-semibold",
verdict.text,
)}
>
<verdict.icon className="size-4" aria-hidden />
{verdict.label} {verdict.label}
</span> </span>
)} )}
@ -441,15 +437,12 @@ function AiAssessment({
</div> </div>
)} )}
{/* Then the checks that produced it… */}
{checks.length > 0 && ( {checks.length > 0 && (
<div className="mb-4"> <SubBlock
<div className="mb-2.5 flex items-center gap-2"> title={byKey.get("match_summary")?.output_label ?? "Match Summary"}
<p className="text-xs font-medium text-muted-foreground"> badge={
{byKey.get("match_summary")?.output_label ?? "Match Summary"} failed > 0 ? (
</p>
{/* Headline count first the reason to read the list at all is to
find out whether anything failed. */}
{failed > 0 ? (
<span <span
className={cn( className={cn(
"rounded-full px-2 py-0.5 text-2xs font-bold uppercase tracking-wider", "rounded-full px-2 py-0.5 text-2xs font-bold uppercase tracking-wider",
@ -464,42 +457,33 @@ function AiAssessment({
<span className="rounded-full bg-state-approved/12 px-2 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-approved"> <span className="rounded-full bg-state-approved/12 px-2 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-approved">
All {checks.length} checks pass All {checks.length} checks pass
</span> </span>
)} )
</div> }
>
<ul className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2"> <ul className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
{ordered.map((c) => ( {ordered.map((c) => (
<MatchRow key={c.label} check={c} /> <MatchRow key={c.label} check={c} />
))} ))}
</ul> </ul>
</div> </SubBlock>
)} )}
{has(row, "ai_reasoning") && ( {/* then the underlying records those checks were run against PO,
<Prose goods receipt, contract, payment history and the discrepancy. */}
icon={Bot}
label={byKey.get("ai_reasoning")?.output_label ?? "AI Reasoning"}
text={text(row, "ai_reasoning")}
/>
)}
{/* The gathered evidence PO, goods receipt, contract, payment history
and the specific discrepancy. Parsed into rows so the verdict above is
auditable instead of a wall of prose. */}
{evidence.length > 0 && ( {evidence.length > 0 && (
<details className="group mt-3" open> <SubBlock title="Evidence gathered">
<summary className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-primary hover:underline"> <dl className="space-y-1.5">
<ChevronDown
className="size-3 transition-transform group-open:rotate-180"
aria-hidden
/>
Evidence gathered
</summary>
<dl className="mt-2.5 space-y-1.5">
{evidence.map((e) => ( {evidence.map((e) => (
<div key={e.label} className="flex flex-wrap gap-x-2 text-base"> <div
key={e.label}
className={cn(
"flex flex-col gap-x-3 sm:flex-row",
e.isIssue && "rounded-md bg-state-rejected/[0.06] px-2 py-1",
)}
>
<dt <dt
className={cn( className={cn(
"shrink-0 font-semibold", "text-base font-semibold sm:w-40 sm:shrink-0",
e.isIssue ? "text-state-rejected" : "text-foreground", e.isIssue ? "text-state-rejected" : "text-foreground",
)} )}
> >
@ -507,7 +491,7 @@ function AiAssessment({
</dt> </dt>
<dd <dd
className={cn( className={cn(
"min-w-0 flex-1", "min-w-0 flex-1 text-base",
e.isIssue ? "text-state-rejected" : "text-muted-foreground", e.isIssue ? "text-state-rejected" : "text-muted-foreground",
)} )}
> >
@ -516,12 +500,49 @@ function AiAssessment({
</div> </div>
))} ))}
</dl> </dl>
</details> </SubBlock>
)}
{/* Reasoning last: it narrates everything above, so it reads as the
conclusion rather than the preamble. */}
{has(row, "ai_reasoning") && (
<SubBlock title={byKey.get("ai_reasoning")?.output_label ?? "AI Reasoning"} icon={Bot}>
<ClampedText text={text(row, "ai_reasoning")} />
</SubBlock>
)} )}
</section> </section>
); );
} }
/**
* A labelled part of the AI block. The hairline above each one gives the
* section an internal rhythm without nesting cards inside a card.
*/
function SubBlock({
title,
badge,
icon: Icon,
children,
}: {
title: string;
badge?: React.ReactNode;
icon?: LucideIcon;
children: React.ReactNode;
}) {
return (
<div className="mt-4 border-t border-primary/10 pt-3.5 first:mt-0 first:border-0 first:pt-0">
<div className="mb-2.5 flex flex-wrap items-center gap-2">
{Icon && <Icon className="size-3.5 shrink-0 text-primary" aria-hidden />}
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
{title}
</p>
{badge}
</div>
{children}
</div>
);
}
/** /**
* The clarification exchange, rendered as the conversation it is: the AI asks a * The clarification exchange, rendered as the conversation it is: the AI asks a
* department a question, the department answers. Previously both halves sat * department a question, the department answers. Previously both halves sat
@ -960,15 +981,3 @@ function ClampedText({ text: value, muted }: { text: string; muted?: boolean })
</div> </div>
); );
} }
function Prose({ icon: Icon, label, text: value }: { icon: LucideIcon; label: string; text: string }) {
return (
<div>
<div className="mb-1.5 flex items-center gap-1.5">
<Icon className="size-3 shrink-0 text-primary" aria-hidden />
<span className="text-xs font-medium text-muted-foreground">{formatLabel(label)}</span>
</div>
<ClampedText text={value} />
</div>
);
}