From fe6c257ace00984b4da78fdfef38ec209078d3cc Mon Sep 17 00:00:00 2001 From: Bhanu Prakash Sai Potteri Date: Wed, 29 Jul 2026 20:13:55 +0530 Subject: [PATCH] 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. --- src/components/AiActivity.tsx | 361 +++++++++++++++++++---------- src/components/DetailViewPanel.tsx | 121 +++++----- 2 files changed, 299 insertions(+), 183 deletions(-) diff --git a/src/components/AiActivity.tsx b/src/components/AiActivity.tsx index f5e9c5e..35a6757 100644 --- a/src/components/AiActivity.tsx +++ b/src/components/AiActivity.tsx @@ -10,9 +10,18 @@ // token/cost roll-up are the logic that came with the endpoints. 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 type { AIDecision, AIEscalation, AITrace } from "@/api/viewService"; +import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "@/api/viewService"; import { activityLabel, fmtDuration } from "@/lib/workflowMeta"; import { formatDate, formatTime } from "@/lib/format"; @@ -24,6 +33,10 @@ const STATUS_TONE: Record = { 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 { decisions: AIDecision[]; escalations: AIEscalation[]; @@ -54,70 +67,83 @@ export default function AiActivity({ decisions, escalations, traces, unavailable const totals = useMemo(() => { let tokens = 0; let cost = 0; + let ms = 0; for (const t of traces) for (const e of t.events) { tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 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]); if (unavailable) { return ( -

+

+ The AI monitoring service isn’t reachable, so decisions and traces can’t be shown.

); } if (ordered.length === 0 && escalations.length === 0) { return ( -

- The AI employee hasn’t acted on this invoice. -

+

The AI employee hasn’t acted on this invoice.

); } + const models = [...new Set(ordered.map((d) => d.model).filter(Boolean))]; + return (
- {(totals.tokens > 0 || totals.cost > 0) && ( -
+ {/* Run summary — the cost of the automation, before the detail of it. */} + {(ordered.length > 0 || totals.tokens > 0) && ( +
+ + {ordered.length} {ordered.length === 1 ? "decision" : "decisions"} + + {totals.ms > 0 && {fmtDuration(totals.ms)}} {totals.tokens > 0 && ( - - - {totals.tokens.toLocaleString("en-IN")} tokens - + {totals.tokens.toLocaleString("en-IN")} tokens )} - {totals.cost > 0 && ( - - ${totals.cost.toFixed(4)} - - )} -
+ {totals.cost > 0 && ${totals.cost.toFixed(4)}} + {models.length > 0 && {models.join(", ")}} + )} -
    - {ordered.map((d) => ( - +
      + {ordered.map((d, i) => ( + ))}
    {escalations.length > 0 && ( -
    +
    {escalations.map((e) => ( -
    - -
    -

    - Escalated to a human - {e.recommended_action && ` · ${e.recommended_action}`} -

    - {(e.reasoning || e.reason) && ( -

    {e.reasoning || e.reason}

    +
    +

    + + Escalated to a human + {e.recommended_action && ( + + · {e.recommended_action} + )} -

    +

    + {(e.reasoning || e.reason) && ( +

    + {e.reasoning || e.reason} +

    + )}
    ))}
    @@ -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 ( +
    + + {children} +
    + ); +} + +function Decision({ + decision: d, + trace, + index, + last, +}: { + decision: AIDecision; + trace?: AITrace; + index: number; + last: boolean; +}) { 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 hasDetail = !!(d.plan || d.reasoning || d.reflexion?.concerns || trace); return ( -
  1. -
    - - - {activityLabel(d.activity_id)} - - - {d.status} - - {pct != null && ( - - {Math.round(pct * 100)}% confidence +
  2. + {/* Connector — makes a multi-run sequence read as one process. */} + {!last && ( + + )} + + + {index} + + +
    +
    + + {activityLabel(d.activity_id)} + + {d.status} + + {pct != null && ( + + {Math.round(pct)}% + {adjusted != null && raw != null && Math.round(adjusted) !== Math.round(raw) && ( + + (self-checked from {Math.round(raw)}%) + + )} + + )} + {d.reflexion?.should_escalate && ( + + flagged for escalation + + )} +
    + +

    + {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`} +

    + + {d.error_message && ( +

    + {d.error_message} +

    + )} + + {hasDetail && ( + <> + + + {open && ( +
    + {d.plan && } + {d.reasoning && } + {d.reflexion?.concerns && ( + + )} + {d.knowledge_sources && d.knowledge_sources.length > 0 && ( +
    + +
      + {d.knowledge_sources.map((s) => ( +
    • + {s} +
    • + ))} +
    +
    + )} + {trace && } +
    + )} + )}
    - -

    - {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`} -

    - - {d.error_message && ( -

    {d.error_message}

    - )} - - {(d.plan || d.reasoning || d.reflexion?.concerns || trace) && ( - <> - - - {open && ( -
    - {d.plan && } - {d.reasoning && } - {d.reflexion?.concerns && ( - - )} - {d.knowledge_sources && d.knowledge_sources.length > 0 && ( - - )} - {trace && } -
    - )} - - )}
  3. ); } +function Label({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} + function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) { return ( -
    +

    -

    - Run trace -

    -
      + +
        {events.map((e) => ( -
      • - - {e.name || e.type} - - - - - - {fmtDuration(e.duration_ms)} - -
      • + ))}
    ); } + +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 ( +
  4. + + {e.name || e.type} + + {tokens > 0 && ( + + {tokens.toLocaleString("en-IN")} tok + + )} + + + + + {fmtDuration(e.duration_ms)} + +
  5. + ); +} diff --git a/src/components/DetailViewPanel.tsx b/src/components/DetailViewPanel.tsx index a91feeb..30b6153 100644 --- a/src/components/DetailViewPanel.tsx +++ b/src/components/DetailViewPanel.tsx @@ -406,7 +406,7 @@ function AiAssessment({ return (
    -
    +
    Automated 3-Way Match @@ -416,16 +416,12 @@ function AiAssessment({
    + {/* Verdict first: what the agent concluded, at what amount, how sure. */} {(verdict || pct !== null || recommended !== null) && ( -
    +
    {verdict && ( - - + + {verdict.label} )} @@ -441,15 +437,12 @@ function AiAssessment({
    )} + {/* Then the checks that produced it… */} {checks.length > 0 && ( -
    -
    -

    - {byKey.get("match_summary")?.output_label ?? "Match Summary"} -

    - {/* Headline count first — the reason to read the list at all is to - find out whether anything failed. */} - {failed > 0 ? ( + 0 ? ( All {checks.length} checks pass - )} -
    + ) + } + >
      {ordered.map((c) => ( ))}
    -
    + )} - {has(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. */} + {/* …then the underlying records those checks were run against — PO, + goods receipt, contract, payment history and the discrepancy. */} {evidence.length > 0 && ( -
    - - - Evidence gathered - -
    + +
    {evidence.map((e) => ( -
    +
    @@ -507,7 +491,7 @@ function AiAssessment({
    @@ -516,12 +500,49 @@ function AiAssessment({
    ))}
    -
    + + )} + + {/* Reasoning last: it narrates everything above, so it reads as the + conclusion rather than the preamble. */} + {has(row, "ai_reasoning") && ( + + + )}
    ); } +/** + * 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 ( +
    +
    + {Icon && } +

    + {title} +

    + {badge} +
    + {children} +
    + ); +} + /** * The clarification exchange, rendered as the conversation it is: the AI asks a * department a question, the department answers. Previously both halves sat @@ -960,15 +981,3 @@ function ClampedText({ text: value, muted }: { text: string; muted?: boolean })
    ); } - -function Prose({ icon: Icon, label, text: value }: { icon: LucideIcon; label: string; text: string }) { - return ( -
    -
    - - {formatLabel(label)} -
    - -
    - ); -}