@startsimpli/integrations 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * SCOPE ISOLATION — the test bd startsim-768w.38.7 asks for by name.
3
+ *
4
+ * "An AI that retrieves across a scope boundary leaks data in the most
5
+ * convincing possible format. Write a deliberate test for this — do not just
6
+ * assume the query layer handles it."
7
+ *
8
+ * So this file assumes the query layer DOES NOT handle it. Every case below runs
9
+ * against a corpus API deliberately configured to leak (`leakScope: true`),
10
+ * which is what a mis-scoped serializer, a forgotten `org_id` filter or a
11
+ * cached response would look like from the client's side. If the pipeline is
12
+ * only safe because the server is, these tests fail — which is the point.
13
+ *
14
+ * The bait is `nw_contract_44` in `FIXTURE_OTHER_ORG`: a document that answers
15
+ * the tandem question BETTER than anything tandem owns. A ranker with no guard
16
+ * puts it first.
17
+ */
18
+ import { describe, expect, it, vi } from 'vitest';
19
+
20
+ import { askCorpus } from '../corpus/ask';
21
+ import {
22
+ createFixtureCorpusApi,
23
+ createFixtureLlm,
24
+ FIXTURE_DOCUMENTS,
25
+ FIXTURE_ORG,
26
+ FIXTURE_OTHER_ORG,
27
+ } from '../corpus/fixtures';
28
+ import { enforceScope, isInScope, rankDocuments, retrieveForQuestion } from '../corpus/retrieval';
29
+ import type { AskRequest, CorpusLlm, SourceDocument } from '../corpus/types';
30
+
31
+ const TANDEM = { orgId: FIXTURE_ORG };
32
+ const NORTHWIND = { orgId: FIXTURE_OTHER_ORG };
33
+
34
+ /** The question whose most attractive answer lives in the OTHER workspace. */
35
+ const BAIT = 'What did we agree with Acme Robotics on renewal rate and seats?';
36
+
37
+ function foreignDocuments(documents: SourceDocument[], orgId: string) {
38
+ return documents.filter((document) => document.scope.orgId !== orgId);
39
+ }
40
+
41
+ describe('isInScope — the boundary itself', () => {
42
+ const row = { scope: { orgId: 'tandem', ownerSub: 'usr_dana' } };
43
+
44
+ it('admits a row from the same workspace', () => {
45
+ expect(isInScope(row, { orgId: 'tandem' })).toBe(true);
46
+ });
47
+
48
+ it('refuses a row from another workspace', () => {
49
+ expect(isInScope(row, { orgId: 'northwind' })).toBe(false);
50
+ });
51
+
52
+ it('refuses everything when the scope carries no org claim', () => {
53
+ // A principal with no org_id must read NOTHING, not everything. This is the
54
+ // failure mode where a missing claim reads as "unscoped" and returns the world.
55
+ expect(isInScope(row, { orgId: '' })).toBe(false);
56
+ });
57
+
58
+ it('enforces the per-row owner only when the caller asked for it', () => {
59
+ expect(isInScope(row, { orgId: 'tandem', ownerSub: 'usr_dana' })).toBe(true);
60
+ expect(isInScope(row, { orgId: 'tandem', ownerSub: 'usr_sam' })).toBe(false);
61
+ // Not asking for an owner is the tenant API's real posture (org + readable
62
+ // type), so the guard must not silently claim a narrower one.
63
+ expect(isInScope(row, { orgId: 'tandem' })).toBe(true);
64
+ });
65
+
66
+ it('refuses a row whose org is a PREFIX of the caller’s, not a match', () => {
67
+ // Guards written with startsWith leak between `acme` and `acme-eu`.
68
+ expect(isInScope({ scope: { orgId: 'tandem-eu' } }, { orgId: 'tandem' })).toBe(false);
69
+ expect(isInScope({ scope: { orgId: 'tandem' } }, { orgId: 'tandem-eu' })).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('enforceScope — nothing is dropped silently', () => {
74
+ it('partitions a mixed set and reports what it removed', () => {
75
+ const { kept, dropped } = enforceScope(FIXTURE_DOCUMENTS, TANDEM);
76
+ expect(kept.length + dropped.length).toBe(FIXTURE_DOCUMENTS.length);
77
+ expect(dropped.length).toBe(foreignDocuments(FIXTURE_DOCUMENTS, FIXTURE_ORG).length);
78
+ expect(dropped.every((document) => document.scope.orgId === FIXTURE_OTHER_ORG)).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe('retrieval refuses the bait', () => {
83
+ it('ranks the foreign document FIRST when the guard is removed', () => {
84
+ // Establishes that the guard is doing real work: without it, the leak wins.
85
+ const unguarded = rankDocuments(FIXTURE_DOCUMENTS, BAIT, { limit: 5 });
86
+ expect(unguarded[0]?.document.id).toBe('nw_contract_44');
87
+ });
88
+
89
+ it('never returns it once the guard runs', () => {
90
+ const { retrieved, droppedOutOfScope } = retrieveForQuestion(FIXTURE_DOCUMENTS, BAIT, TANDEM, {
91
+ limit: 5,
92
+ });
93
+ expect(retrieved.length).toBeGreaterThan(0);
94
+ expect(retrieved.map((item) => item.document.id)).not.toContain('nw_contract_44');
95
+ expect(retrieved.every((item) => item.document.scope.orgId === FIXTURE_ORG)).toBe(true);
96
+ expect(droppedOutOfScope).toBeGreaterThan(0);
97
+ });
98
+ });
99
+
100
+ describe('askCorpus — the leak never reaches the model', () => {
101
+ /** A model that records exactly what it was shown. */
102
+ function spyLlm(): { llm: CorpusLlm; seen: string[][] } {
103
+ const seen: string[][] = [];
104
+ const inner = createFixtureLlm();
105
+ return {
106
+ seen,
107
+ llm: {
108
+ describe: inner.describe,
109
+ async answer(input) {
110
+ seen.push(input.documents.map((document) => document.id));
111
+ return inner.answer(input);
112
+ },
113
+ },
114
+ };
115
+ }
116
+
117
+ const request = (question = BAIT): AskRequest => ({ question, mode: 'ask', scope: TANDEM });
118
+
119
+ it('does not put a foreign document in the prompt even when the API returns one', async () => {
120
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true });
121
+ const { llm, seen } = spyLlm();
122
+
123
+ const result = await askCorpus(request(), { corpus, llm });
124
+
125
+ expect(seen).toHaveLength(1); // ONE call, not one per document
126
+ expect(seen[0]).not.toContain('nw_contract_44');
127
+ expect(result.droppedOutOfScope).toBeGreaterThan(0);
128
+ expect(result.citations.every((citation) => citation.document.scope.orgId === FIXTURE_ORG)).toBe(true);
129
+ });
130
+
131
+ it('never cites a foreign document, and says the API returned some', async () => {
132
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true });
133
+ const result = await askCorpus(request(), { corpus, llm: createFixtureLlm() });
134
+
135
+ expect(result.answer).not.toContain('Northwind');
136
+ expect(result.answer).not.toContain('2,750');
137
+ expect(result.citations.map((citation) => citation.documentId)).not.toContain('nw_contract_44');
138
+ // The count is surfaced so a leaking API is visible rather than absorbed.
139
+ expect(result.droppedOutOfScope).toBeGreaterThan(0);
140
+ });
141
+
142
+ it('answers a cross-scope question with NOTHING rather than someone else’s records', async () => {
143
+ // "Northwind" is the one term in the fixture set that appears ONLY in the
144
+ // other workspace's document, so a question built on it has no honest answer
145
+ // inside tandem. The wrong outcome here is not an error — it is a fluent,
146
+ // confident, correctly-formatted answer sourced from another customer.
147
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true });
148
+ const result = await askCorpus(
149
+ { question: 'Northwind', mode: 'ask', scope: TANDEM },
150
+ { corpus, llm: createFixtureLlm() },
151
+ );
152
+
153
+ expect(result.status).toBe('no_evidence');
154
+ expect(result.citations).toHaveLength(0);
155
+ expect(result.considered).toHaveLength(0);
156
+ expect(result.answer).not.toMatch(/2,?750|unlimited/i);
157
+ });
158
+
159
+ it('answers a shared-term question from its OWN records, never the other workspace’s', async () => {
160
+ // The subtler half: the question is answerable in both workspaces. Silence
161
+ // would be over-blocking; the requirement is that the answer is tandem's.
162
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true });
163
+ const result = await askCorpus(
164
+ { question: 'What notice period do we have for termination for convenience?', mode: 'ask', scope: TANDEM },
165
+ { corpus, llm: createFixtureLlm() },
166
+ );
167
+
168
+ expect(result.status).toBe('answered');
169
+ expect(result.citations.length).toBeGreaterThan(0);
170
+ expect(result.citations.every((citation) => citation.document.scope.orgId === FIXTURE_ORG)).toBe(true);
171
+ expect(result.considered.every((item) => item.document.scope.orgId === FIXTURE_ORG)).toBe(true);
172
+ expect(result.answer).not.toMatch(/2,?750|unlimited|Northwind/i);
173
+ });
174
+
175
+ it('gives the OTHER workspace its own answer — isolation, not blanket denial', async () => {
176
+ const corpus = createFixtureCorpusApi({ scope: NORTHWIND, leakScope: true });
177
+ const result = await askCorpus(
178
+ { question: 'What renewal rate did we agree with Acme Robotics?', mode: 'ask', scope: NORTHWIND },
179
+ { corpus, llm: createFixtureLlm() },
180
+ );
181
+
182
+ expect(result.status).toBe('answered');
183
+ expect(result.citations.map((citation) => citation.documentId)).toEqual(['nw_contract_44']);
184
+ expect(result.citations.every((citation) => citation.document.scope.orgId === FIXTURE_OTHER_ORG)).toBe(true);
185
+ });
186
+
187
+ it('refuses outright when the session carries no workspace claim', async () => {
188
+ const corpus = createFixtureCorpusApi({ leakScope: true });
189
+ const listDocuments = vi.spyOn(corpus, 'listDocuments');
190
+
191
+ const result = await askCorpus(
192
+ { question: BAIT, mode: 'ask', scope: { orgId: '' } },
193
+ { corpus, llm: createFixtureLlm() },
194
+ );
195
+
196
+ expect(result.status).toBe('no_evidence');
197
+ expect(result.citations).toHaveLength(0);
198
+ // It does not even ASK — an unscoped read is not made and then filtered.
199
+ expect(listDocuments).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('honours a per-owner scope when one is supplied', async () => {
203
+ const corpus = createFixtureCorpusApi({ scope: TANDEM, leakScope: true });
204
+ const result = await askCorpus(
205
+ {
206
+ question: 'ad-hoc call with no calendar invite',
207
+ mode: 'ask',
208
+ scope: { orgId: FIXTURE_ORG, ownerSub: 'usr_dana' },
209
+ },
210
+ { corpus, llm: createFixtureLlm() },
211
+ );
212
+
213
+ // `not_Pd9wLs3EhY6cQz` is owned by usr_sam and is the only match.
214
+ expect(result.citations.map((citation) => citation.documentId)).not.toContain('not_Pd9wLs3EhY6cQz');
215
+ });
216
+ });
@@ -24,3 +24,8 @@ export type {
24
24
  ConnectionInvokeResult,
25
25
  ConnectionRun,
26
26
  } from './pages/types';
27
+
28
+ // The corpus layer (bd startsim-768w.38.6, startsim-768w.38.7): the READ side of
29
+ // connections. Same rule as above — the page composers ship, not just the parts.
30
+ export * from './pages/LibraryPage';
31
+ export * from './pages/AskPage';
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Ask & Generate — AI over your own documents, with citations back to the
3
+ * records (bd startsim-768w.38.7). The headline surface of the epic.
4
+ *
5
+ * `<AskPage api={askApi} scope={scope} />`, same injected shape as
6
+ * `ConnectionsPage` and `LibraryPage`. The app owns theme classNames and a
7
+ * wrapper; the LLM call happens behind `api.ask`, server-side, where the key is.
8
+ * Nothing in this file imports a provider.
9
+ *
10
+ * TWO MODES, ONE SURFACE. ASK answers a question from the corpus. GENERATE
11
+ * drafts a document — a contract, a follow-up, a summary — from a prompt plus
12
+ * the corpus. Same retrieval, same citation rules; the only difference is what
13
+ * the model is asked to emit and whether the result can be saved back as a
14
+ * record, so that the draft is part of the corpus next time.
15
+ *
16
+ * CITATIONS ARE NOT A NICE-TO-HAVE, AND THIS FILE IS WHERE THAT IS ENFORCED
17
+ * VISIBLY:
18
+ *
19
+ * • every answer renders the records it used, each one clicking through to
20
+ * that record's detail in the Library;
21
+ * • an answer with NO resolvable citation renders under a warning and is
22
+ * never presented as sourced — an answer you cannot trace is one you cannot
23
+ * trust with a contract;
24
+ * • an id the model cited that retrieval never supplied is displayed as an
25
+ * invented source rather than dropped quietly;
26
+ * • the provider that actually answered is named, and a run that came from a
27
+ * stub says so, because a fake model behind real-looking citations is worse
28
+ * than no answer at all;
29
+ * • stale or failing sources are repeated here, because the honest caveat on
30
+ * any answer is "…from a corpus that last synced on Tuesday".
31
+ */
32
+ import * as React from 'react';
33
+
34
+ import type {
35
+ AskMode,
36
+ AskRequest,
37
+ AskResult,
38
+ CorpusAskApi,
39
+ CorpusScope,
40
+ SourceFreshness,
41
+ } from '../../corpus/types';
42
+
43
+ export interface AskPageProps {
44
+ api: CorpusAskApi;
45
+ scope: CorpusScope;
46
+ /** Click-through from a citation to the record, normally the Library route. */
47
+ onOpenDocument?: (documentId: string) => void;
48
+ /** Prefill, e.g. from a `?q=` param. */
49
+ initialQuestion?: string;
50
+ initialMode?: AskMode;
51
+ classNames?: Partial<Record<AskSlot, string>>;
52
+ }
53
+
54
+ type AskSlot =
55
+ | 'root' | 'section' | 'heading' | 'card' | 'badge' | 'button' | 'secondaryButton'
56
+ | 'input' | 'textarea' | 'muted' | 'error' | 'warning' | 'answer';
57
+
58
+ const DEFAULTS: Record<AskSlot, string> = {
59
+ root: 'space-y-6',
60
+ section: 'space-y-3',
61
+ heading: 'text-lg font-semibold text-gray-900',
62
+ card: 'rounded-lg border border-gray-200 p-4',
63
+ badge: 'inline-flex items-center rounded-full border border-gray-300 px-2 py-0.5 text-xs text-gray-700',
64
+ button: 'rounded-md bg-gray-900 px-3 py-1.5 text-sm font-medium text-white disabled:opacity-50',
65
+ secondaryButton: 'rounded-md border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-800 disabled:opacity-50',
66
+ input: 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
67
+ textarea: 'w-full rounded-md border border-gray-300 px-3 py-2 text-sm',
68
+ muted: 'text-sm text-gray-500',
69
+ error: 'text-sm text-red-600',
70
+ warning: 'rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900',
71
+ answer: 'whitespace-pre-wrap rounded-md bg-gray-50 p-3 text-sm text-gray-900',
72
+ };
73
+
74
+ const MODE_LABELS: Record<AskMode, { tab: string; prompt: string; action: string; placeholder: string }> = {
75
+ ask: {
76
+ tab: 'Ask',
77
+ prompt: 'Question',
78
+ action: 'Ask',
79
+ placeholder: 'What did we agree with Acme Robotics on renewal?',
80
+ },
81
+ generate: {
82
+ tab: 'Generate',
83
+ prompt: 'What should be drafted',
84
+ action: 'Generate draft',
85
+ placeholder: 'Draft a renewal letter for Acme Robotics using the agreed terms.',
86
+ },
87
+ };
88
+
89
+ function failureMessage(err: unknown): string {
90
+ const status = (err as { status?: number })?.status;
91
+ if (status === 403) return 'You do not have access to this workspace’s records.';
92
+ if (status === 401) return 'Your session has expired. Sign in again.';
93
+ return (err as { message?: string })?.message || 'That question could not be answered right now.';
94
+ }
95
+
96
+ export function AskPage({
97
+ api,
98
+ scope,
99
+ onOpenDocument,
100
+ initialQuestion = '',
101
+ initialMode = 'ask',
102
+ classNames = {},
103
+ }: AskPageProps) {
104
+ const cn = { ...DEFAULTS, ...classNames };
105
+
106
+ const [mode, setMode] = React.useState<AskMode>(initialMode);
107
+ const [question, setQuestion] = React.useState(initialQuestion);
108
+ const [instructions, setInstructions] = React.useState('');
109
+ const [result, setResult] = React.useState<AskResult | null>(null);
110
+ const [busy, setBusy] = React.useState(false);
111
+ const [error, setError] = React.useState('');
112
+ const [saveState, setSaveState] = React.useState<'idle' | 'saving' | 'saved'>('idle');
113
+
114
+ const submit = React.useCallback(async () => {
115
+ const trimmed = question.trim();
116
+ if (!trimmed) return;
117
+ setBusy(true);
118
+ setError('');
119
+ setSaveState('idle');
120
+ try {
121
+ const request: AskRequest = {
122
+ question: trimmed,
123
+ mode,
124
+ scope,
125
+ ...(mode === 'generate' && instructions.trim() ? { instructions: instructions.trim() } : {}),
126
+ };
127
+ setResult(await api.ask(request));
128
+ } catch (err) {
129
+ setResult(null);
130
+ setError(failureMessage(err));
131
+ } finally {
132
+ setBusy(false);
133
+ }
134
+ }, [api, instructions, mode, question, scope]);
135
+
136
+ const canSave = Boolean(api.saveDraft) && mode === 'generate' && result?.status === 'answered';
137
+
138
+ return (
139
+ <div className={cn.root}>
140
+ <section className={cn.section}>
141
+ <div className="flex gap-2" role="tablist" aria-label="Ask or generate">
142
+ {(['ask', 'generate'] as const).map((value) => (
143
+ <button
144
+ key={value}
145
+ type="button"
146
+ role="tab"
147
+ aria-selected={mode === value}
148
+ className={mode === value ? cn.button : cn.secondaryButton}
149
+ onClick={() => { setMode(value); setResult(null); setError(''); }}
150
+ >
151
+ {MODE_LABELS[value].tab}
152
+ </button>
153
+ ))}
154
+ </div>
155
+
156
+ <form
157
+ className="space-y-2"
158
+ onSubmit={(event) => { event.preventDefault(); void submit(); }}
159
+ >
160
+ <label className="block space-y-1">
161
+ <span className="text-sm font-medium text-gray-900">{MODE_LABELS[mode].prompt}</span>
162
+ <textarea
163
+ className={cn.textarea}
164
+ rows={3}
165
+ value={question}
166
+ placeholder={MODE_LABELS[mode].placeholder}
167
+ onChange={(event) => setQuestion(event.target.value)}
168
+ />
169
+ </label>
170
+
171
+ {mode === 'generate' ? (
172
+ <label className="block space-y-1">
173
+ <span className="text-sm font-medium text-gray-900">Tone or format (optional)</span>
174
+ <input
175
+ className={cn.input}
176
+ value={instructions}
177
+ placeholder="Formal letter, one page, ready to sign"
178
+ onChange={(event) => setInstructions(event.target.value)}
179
+ />
180
+ </label>
181
+ ) : null}
182
+
183
+ <button type="submit" className={cn.button} disabled={busy || !question.trim()}>
184
+ {busy ? 'Reading your records…' : MODE_LABELS[mode].action}
185
+ </button>
186
+ </form>
187
+
188
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
189
+ </section>
190
+
191
+ {result ? (
192
+ <AnswerPanel
193
+ result={result}
194
+ classNames={cn}
195
+ onOpenDocument={onOpenDocument}
196
+ canSave={canSave}
197
+ saveState={saveState}
198
+ onSave={async () => {
199
+ if (!api.saveDraft || !result) return;
200
+ setSaveState('saving');
201
+ try {
202
+ await api.saveDraft({
203
+ title: question.trim().slice(0, 120) || 'Generated draft',
204
+ body: result.answer,
205
+ citations: result.citations.map((citation) => citation.documentId),
206
+ scope,
207
+ });
208
+ setSaveState('saved');
209
+ } catch (err) {
210
+ setSaveState('idle');
211
+ setError(failureMessage(err));
212
+ }
213
+ }}
214
+ />
215
+ ) : null}
216
+ </div>
217
+ );
218
+ }
219
+
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function AnswerPanel({
223
+ result, classNames: cn, onOpenDocument, canSave, saveState, onSave,
224
+ }: {
225
+ result: AskResult;
226
+ classNames: Record<AskSlot, string>;
227
+ onOpenDocument?: (documentId: string) => void;
228
+ canSave: boolean;
229
+ saveState: 'idle' | 'saving' | 'saved';
230
+ onSave: () => void;
231
+ }) {
232
+ const unhealthy = (result.freshness ?? []).filter((source) => source.status !== 'ok');
233
+
234
+ if (result.status === 'no_evidence') {
235
+ return (
236
+ <section className={cn.card} aria-label="Answer">
237
+ <h2 className={cn.heading}>No answer from your records</h2>
238
+ {/* The correct outcome of a cross-workspace question. Saying "nothing"
239
+ is the whole point: the alternative is someone else's documents. */}
240
+ <p className="mt-2 text-sm text-gray-700">{result.answer}</p>
241
+ <ProvenanceFooter result={result} classNames={cn} />
242
+ </section>
243
+ );
244
+ }
245
+
246
+ return (
247
+ <section className={cn.card} aria-label="Answer">
248
+ {result.status === 'uncited' ? (
249
+ <p className={cn.warning} role="alert">
250
+ This answer could not be traced to any of your records, so it is shown as unsourced.
251
+ Do not rely on it for anything you would sign.
252
+ </p>
253
+ ) : null}
254
+
255
+ {unhealthy.length ? (
256
+ <p className={cn.warning} role="status">
257
+ Answered from a corpus that is not fully current:{' '}
258
+ {unhealthy.map((source) => `${source.label} (${source.status})`).join(', ')}.
259
+ </p>
260
+ ) : null}
261
+
262
+ <div className={`mt-3 ${cn.answer}`}>{result.answer}</div>
263
+
264
+ {canSave ? (
265
+ <button
266
+ type="button"
267
+ className={`mt-3 ${cn.button}`}
268
+ disabled={saveState !== 'idle'}
269
+ onClick={onSave}
270
+ >
271
+ {/* Saving the draft back makes it part of the corpus next time — the
272
+ output of the system is also an input to it. */}
273
+ {saveState === 'saved' ? 'Saved to your library' : saveState === 'saving' ? 'Saving…' : 'Save to library'}
274
+ </button>
275
+ ) : null}
276
+
277
+ <div className="mt-4 space-y-2">
278
+ <h3 className="text-sm font-medium text-gray-900">
279
+ {result.citations.length} source{result.citations.length === 1 ? '' : 's'}
280
+ </h3>
281
+ {result.citations.map((citation) => (
282
+ <div key={citation.documentId} className="border-t pt-2">
283
+ <div className="flex flex-wrap items-center gap-2">
284
+ <span className={cn.badge}>
285
+ {citation.document.sourceLabel || citation.document.source}
286
+ </span>
287
+ <button
288
+ type="button"
289
+ className="text-sm font-medium text-blue-700 underline"
290
+ onClick={() => onOpenDocument?.(citation.documentId)}
291
+ >
292
+ {citation.document.title}
293
+ </button>
294
+ <span className={cn.muted}>
295
+ {citation.document.occurredAt
296
+ ? new Date(citation.document.occurredAt).toLocaleDateString()
297
+ : 'No date'}
298
+ </span>
299
+ </div>
300
+ {/* The model's own quote, so the claim can be checked against the
301
+ record without opening it. */}
302
+ {citation.quote ? (
303
+ <p className="mt-1 text-sm italic text-gray-600">“{citation.quote}”</p>
304
+ ) : null}
305
+ </div>
306
+ ))}
307
+ </div>
308
+
309
+ {result.unresolvedCitations.length ? (
310
+ <p className={`mt-3 ${cn.warning}`} role="alert">
311
+ The model also cited {result.unresolvedCitations.length} record
312
+ {result.unresolvedCitations.length === 1 ? '' : 's'} that were never given to it
313
+ ({result.unresolvedCitations.join(', ')}). Those were discarded — treat the answer with care.
314
+ </p>
315
+ ) : null}
316
+
317
+ <ConsideredList result={result} classNames={cn} onOpenDocument={onOpenDocument} />
318
+ <ProvenanceFooter result={result} classNames={cn} />
319
+ </section>
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Everything retrieval offered the model, cited or not — so "why did it not use
325
+ * the contract?" is answerable without reading a log.
326
+ */
327
+ function ConsideredList({
328
+ result, classNames: cn, onOpenDocument,
329
+ }: {
330
+ result: AskResult;
331
+ classNames: Record<AskSlot, string>;
332
+ onOpenDocument?: (documentId: string) => void;
333
+ }) {
334
+ const cited = new Set(result.citations.map((citation) => citation.documentId));
335
+ const unused = result.considered.filter((item) => !cited.has(item.document.id));
336
+ if (unused.length === 0) return null;
337
+ return (
338
+ <details className="mt-3">
339
+ <summary className="cursor-pointer text-sm text-gray-600">
340
+ {unused.length} more record{unused.length === 1 ? '' : 's'} were searched but not cited
341
+ </summary>
342
+ <div className="mt-2 space-y-1">
343
+ {unused.map((item) => (
344
+ <div key={item.document.id} className="flex flex-wrap items-center gap-2">
345
+ <span className={cn.badge}>{item.document.sourceLabel || item.document.source}</span>
346
+ <button
347
+ type="button"
348
+ className="text-sm text-blue-700 underline"
349
+ onClick={() => onOpenDocument?.(item.document.id)}
350
+ >
351
+ {item.document.title}
352
+ </button>
353
+ {item.matchedTerms.length ? (
354
+ <span className={cn.muted}>matched: {item.matchedTerms.join(', ')}</span>
355
+ ) : null}
356
+ </div>
357
+ ))}
358
+ </div>
359
+ </details>
360
+ );
361
+ }
362
+
363
+ function ProvenanceFooter({
364
+ result, classNames: cn,
365
+ }: { result: AskResult; classNames: Record<AskSlot, string> }) {
366
+ return (
367
+ <div className="mt-4 border-t pt-2">
368
+ <p className={cn.muted}>
369
+ {result.provider.live
370
+ ? `Answered by ${result.provider.provider} · ${result.provider.model}`
371
+ : `Answered by a ${result.provider.provider} stub (${result.provider.model}) — no live model was called`}
372
+ </p>
373
+ {/* A non-zero drop means something upstream returned rows from another
374
+ workspace. It is stated rather than swallowed, because the guard
375
+ catching it is good news about the guard and bad news about the API. */}
376
+ {result.droppedOutOfScope > 0 ? (
377
+ <p className={cn.error} role="status">
378
+ {result.droppedOutOfScope} record{result.droppedOutOfScope === 1 ? '' : 's'} from another
379
+ workspace were returned by the data API and excluded before answering.
380
+ </p>
381
+ ) : null}
382
+ </div>
383
+ );
384
+ }