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:
parent
a52ff32eb4
commit
fe6c257ace
@ -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,70 +67,83 @@ 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 isn’t reachable, so decisions and traces can’t be shown.
|
The AI monitoring service isn’t reachable, so decisions and traces can’t 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 hasn’t acted on this invoice.</p>
|
||||||
The AI employee hasn’t 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">
|
||||||
</p>
|
· {e.recommended_action}
|
||||||
{(e.reasoning || e.reason) && (
|
</span>
|
||||||
<p className="mt-0.5 text-sm text-muted-foreground">{e.reasoning || e.reason}</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</p>
|
||||||
|
{(e.reasoning || e.reason) && (
|
||||||
|
<p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{e.reasoning || e.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -126,86 +152,157 @@ 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 className="text-base font-semibold text-foreground">
|
<span aria-hidden className="absolute bottom-0 left-3.5 top-8 w-px bg-primary/15" />
|
||||||
{activityLabel(d.activity_id)}
|
)}
|
||||||
</span>
|
|
||||||
<span
|
<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">
|
||||||
className={cn(
|
{index}
|
||||||
"rounded-full px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider",
|
</span>
|
||||||
STATUS_TONE[d.status] ?? "bg-muted text-muted-foreground",
|
|
||||||
)}
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
{d.status}
|
<span className="text-base font-semibold text-foreground">
|
||||||
</span>
|
{activityLabel(d.activity_id)}
|
||||||
{pct != null && (
|
|
||||||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
|
||||||
{Math.round(pct * 100)}% confidence
|
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider",
|
||||||
|
STATUS_TONE[d.status] ?? "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
{pct != null && (
|
||||||
|
<span className={cn("text-sm font-bold tabular-nums", confidenceTone(pct))}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
|
||||||
|
{formatDate(d.created_at)}
|
||||||
|
{time && ` · ${time}`}
|
||||||
|
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
|
||||||
|
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{d.error_message && (
|
||||||
|
<p className="mt-1.5 border-l-2 border-destructive/50 pl-2.5 text-sm text-destructive">
|
||||||
|
{d.error_message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasDetail && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("size-3 transition-transform", open && "rotate-180")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{open ? "Hide" : "Show"} the model’s work
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2.5 space-y-3">
|
||||||
|
{d.plan && <Passage label="Plan" text={d.plan} />}
|
||||||
|
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
|
||||||
|
{d.reflexion?.concerns && (
|
||||||
|
<Passage
|
||||||
|
label="Self-check"
|
||||||
|
text={d.reflexion.concerns}
|
||||||
|
tone={d.reflexion.should_escalate ? "warn" : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
|
||||||
|
<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} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
|
|
||||||
{formatDate(d.created_at)}
|
|
||||||
{time && ` · ${time}`}
|
|
||||||
{d.model && ` · ${d.model}`}
|
|
||||||
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
|
|
||||||
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{d.error_message && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">{d.error_message}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(d.plan || d.reasoning || d.reflexion?.concerns || trace) && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((o) => !o)}
|
|
||||||
aria-expanded={open}
|
|
||||||
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("size-3 transition-transform", open && "rotate-180")}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{open ? "Hide" : "Show"} the model’s work
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="mt-2 space-y-2.5 border-l-2 border-primary/20 pl-3">
|
|
||||||
{d.plan && <Passage label="Plan" text={d.plan} />}
|
|
||||||
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
|
|
||||||
{d.reflexion?.concerns && (
|
|
||||||
<Passage
|
|
||||||
label="Self-check"
|
|
||||||
text={d.reflexion.concerns}
|
|
||||||
tone={d.reflexion.should_escalate ? "warn" : undefined}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
|
|
||||||
<Passage label="Knowledge used" text={d.knowledge_sources.join(", ")} />
|
|
||||||
)}
|
|
||||||
{trace && <Waterfall trace={trace} />}
|
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user