@startsimpli/integrations 0.1.4 → 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.
- package/package.json +11 -4
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/AskPage.tsx +384 -0
- package/src/components/pages/LibraryPage.tsx +612 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +196 -0
- package/src/corpus/entities.ts +346 -0
- package/src/corpus/fixtures.ts +486 -0
- package/src/corpus/index.ts +79 -0
- package/src/corpus/llm.ts +197 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -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
|
+
});
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
+
}
|