ifrs9-ecl-copilot / app /ui /src /components /ChatPanel.jsx
Preetomsorkar's picture
Ship reasoned-route spelled-out-number guard fix
544bcbe verified
Raw
History Blame Contribute Delete
7.22 kB
import { useState } from 'preact/hooks';
import { askAgent, explainPanelQuestion } from '../api.js';
import { renderWithNums } from '../numText.jsx';
import { useExplain } from './ExplainButton.jsx';
// The live LangGraph router emits route "REFUSE" (agent/graph.py's REFUSE
// constant); the offline keyword fallback router emits "refusal" (see
// app/api/main.py). Both mean the same thing to the UI — match either,
// case-insensitively, rather than assume one contract spelling.
export const isRefusalRoute = (route) => /^refus(e|al)$/i.test(route || '');
// The REASONED route (agent/graph.py's REASONED constant): a cited,
// number-disciplined LLM interpretation of a conceptually relevant
// question no tool computes — grounded, never a fresh engine number.
// Prefer the response's own `mode === 'reasoned'` field (docs/
// api_contract.md §POST /api/agent/ask) when it is present; this route
// check is the fallback for callers that only have `route`.
export const isReasonedRoute = (route) => /^reasoned$/i.test(route || '');
// agent/graph.py's REASONED_PREFIX — every reasoned answer's wire text
// starts with this machine-readable marker (belt-and-suspenders alongside
// `mode === 'reasoned'`). The badge below already labels the bubble, so
// strip the marker from the DISPLAYED text rather than show it twice.
const REASONED_PREFIX_RE = /^\[REASONED\s*—\s*interpretation, not engine output\]\s*/;
/** Shared agent-message bubble — used by ChatPanel's own log AND by
* MiniChatDock's expanded log, so the two chat surfaces render identically. */
export function AgentMessage({ msg }) {
const isRefusal = msg.mode ? msg.mode === 'refusal' : isRefusalRoute(msg.route);
const isReasoned = msg.mode ? msg.mode === 'reasoned' : isReasonedRoute(msg.route);
if (isRefusal) {
return (
<div class="msg msg-refusal">
<span class="refusal-tag">Outside validated scope</span>
<p>{msg.answer}</p>
</div>
);
}
if (isReasoned) {
const text = (msg.answer || '').replace(REASONED_PREFIX_RE, '');
return (
<div class="msg msg-agent msg-reasoned">
<span class="reasoned-tag">Reasoned — interpretation, not engine output</span>
<p>{renderWithNums(text)}</p>
</div>
);
}
return (
<div class="msg msg-agent">
<p>{renderWithNums(msg.answer)}</p>
{msg.route && (
<div class="citations">
<span class="citations-label">via</span>
<code class="citation">⚙ {msg.route}</code>
</div>
)}
</div>
);
}
/**
* Ask-the-copilot chat. Two skins from the same component:
* - mode="dock": compact, used inside the collapsed mini-chat dock.
* - mode="full": Copilot tab, with suggestion chips.
* `contextLabel` (e.g. the current tab name) is prefixed into the question
* sent to the agent so the router has situational context; it is NOT
* invented data, just a routing hint, and is shown to the user before send.
*/
export default function ChatPanel({ mode = 'full', contextLabel, suggestions, onResult }) {
const [messages, setMessages] = useState([]);
const [question, setQuestion] = useState('');
const [busy, setBusy] = useState(false);
// Every panel heading carries the AI-explain affordance — including this
// one (operator request #3); here it recaps the session's own state
// rather than an exhibit payload.
const { button: explainBtn, strip: explainStrip } = useExplain({
label: 'Ask the copilot',
buildQuestion:
mode === 'full'
? () =>
explainPanelQuestion({
panelId: 'copilot_chat',
title: 'Ask the copilot',
recap: messages.length
? `${messages.filter((m) => m.role === 'user').length} question(s) asked this session; last route: ${
messages.filter((m) => m.role === 'agent').at(-1)?.route ?? 'none yet'
}.`
: 'no questions asked yet this session — the validated routes are six numeric/documentation tools plus a reasoned-interpretation pass for conceptually relevant questions with no fixed tool.',
})
: undefined,
});
const send = async (q) => {
const trimmed = q.trim();
if (!trimmed || busy) return;
setQuestion('');
setMessages((m) => [...m, { role: 'user', text: trimmed }]);
setBusy(true);
try {
const wire = contextLabel ? `[${contextLabel}] ${trimmed}` : trimmed;
const res = await askAgent(wire);
setMessages((m) => [...m, { role: 'agent', ...res }]);
onResult?.({ question: trimmed, ...res, at: Date.now() });
} catch (err) {
setMessages((m) => [
...m,
{ role: 'error', text: `Request failed: ${err.message}` },
]);
} finally {
setBusy(false);
}
};
const submit = (e) => {
e.preventDefault();
send(question);
};
const defaultChips =
suggestions || [
'What is the reported allowance under the downside scenario?',
'Write pandas to find the 10 largest-balance Stage 3 loans',
'What does the double-trigger LTV x UER coefficient mean?',
];
return (
<section class={`panel chat-panel chat-panel-${mode}`}>
{mode === 'full' && (
<>
<div class="panel-head">
<h2>Ask the copilot</h2>
{explainBtn}
</div>
<p class="panel-sub">
Narrated, data-grounded answers — every number traces to an engine
tool call; out-of-scope questions are refused by design.
</p>
{explainStrip}
</>
)}
<div class="chat-log">
{messages.length === 0 && (
<div class="empty-note">
{mode === 'full' ? (
<div class="chip-row">
{defaultChips.map((c) => (
<button type="button" class="chip" key={c} onClick={() => send(c)}>
{c}
</button>
))}
</div>
) : (
<>Ask about {contextLabel ? contextLabel.toLowerCase() : 'this book'}…</>
)}
</div>
)}
{messages.map((msg, i) =>
msg.role === 'user' ? (
<div class="msg msg-user" key={i}>
<p>{msg.text}</p>
</div>
) : msg.role === 'error' ? (
<div class="msg msg-error" key={i}>
<p>{msg.text}</p>
</div>
) : (
<AgentMessage msg={msg} key={i} />
),
)}
{busy && (
<div class="explain-status">
<span class="status-dot status-dot-warn pulse" /> THINKING
</div>
)}
</div>
<form class="chat-form" onSubmit={submit}>
<input
type="text"
value={question}
placeholder={
contextLabel ? `Ask about ${contextLabel}…` : 'Ask about ECL, staging, scenarios…'
}
onInput={(e) => setQuestion(e.currentTarget.value)}
disabled={busy}
/>
<button class="btn" type="submit" disabled={busy || !question.trim()}>
Ask
</button>
</form>
</section>
);
}