File size: 7,221 Bytes
379bf78
20abf65
 
 
379bf78
8f1c2fc
 
 
 
20abf65
8f1c2fc
544bcbe
 
 
 
 
 
 
 
 
 
 
 
 
 
20abf65
 
 
544bcbe
 
8f1c2fc
379bf78
 
 
 
 
 
 
544bcbe
 
 
 
 
 
 
 
 
379bf78
 
20abf65
8f1c2fc
379bf78
 
20abf65
379bf78
 
 
 
 
 
8f1c2fc
 
 
 
 
 
 
 
 
379bf78
 
 
 
20abf65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544bcbe
20abf65
 
 
 
8f1c2fc
 
 
379bf78
8f1c2fc
379bf78
 
8f1c2fc
 
379bf78
8f1c2fc
379bf78
 
 
 
 
 
 
 
 
 
8f1c2fc
 
 
 
 
 
 
 
 
 
 
 
379bf78
8f1c2fc
 
 
20abf65
 
 
 
8f1c2fc
 
 
 
20abf65
8f1c2fc
 
379bf78
 
 
8f1c2fc
 
 
 
 
 
 
 
 
 
 
379bf78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20abf65
 
 
 
 
379bf78
 
 
 
 
8f1c2fc
 
 
379bf78
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
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>
  );
}