@startsimpli/integrations 0.1.4 → 0.1.6
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-copy.test.tsx +196 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/__tests__/corpus-storage.test.ts +301 -0
- package/src/components/Markdown.tsx +193 -0
- package/src/components/index.ts +6 -0
- package/src/components/pages/AskPage.tsx +409 -0
- package/src/components/pages/LibraryPage.tsx +620 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +303 -0
- package/src/corpus/entities.ts +495 -0
- package/src/corpus/fixtures.ts +726 -0
- package/src/corpus/index.ts +89 -0
- package/src/corpus/llm.ts +223 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./types": "./src/types/index.ts",
|
|
10
|
-
"./api": "./src/api/index.ts"
|
|
10
|
+
"./api": "./src/api/index.ts",
|
|
11
|
+
"./corpus": "./src/corpus/index.ts",
|
|
12
|
+
"./corpus-llm": "./src/corpus/llm.ts"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -19,7 +21,8 @@
|
|
|
19
21
|
"dependencies": {},
|
|
20
22
|
"peerDependencies": {
|
|
21
23
|
"react": ">=18.0.0",
|
|
22
|
-
"@tanstack/react-query": ">=5.0.0"
|
|
24
|
+
"@tanstack/react-query": ">=5.0.0",
|
|
25
|
+
"@startsimpli/llm": ">=0.4.0"
|
|
23
26
|
},
|
|
24
27
|
"peerDependenciesMeta": {
|
|
25
28
|
"react": {
|
|
@@ -27,6 +30,9 @@
|
|
|
27
30
|
},
|
|
28
31
|
"@tanstack/react-query": {
|
|
29
32
|
"optional": true
|
|
33
|
+
},
|
|
34
|
+
"@startsimpli/llm": {
|
|
35
|
+
"optional": true
|
|
30
36
|
}
|
|
31
37
|
},
|
|
32
38
|
"devDependencies": {
|
|
@@ -41,7 +47,8 @@
|
|
|
41
47
|
"react": "^19.0.0",
|
|
42
48
|
"react-dom": "^19.0.0",
|
|
43
49
|
"typescript": "^6.0.0",
|
|
44
|
-
"vitest": "^3.0.0"
|
|
50
|
+
"vitest": "^3.0.0",
|
|
51
|
+
"@startsimpli/llm": "0.4.14"
|
|
45
52
|
},
|
|
46
53
|
"scripts": {
|
|
47
54
|
"type-check": "tsc --noEmit",
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Ask pipeline: citations, batching, and the honesty rules
|
|
3
|
+
* (bd startsim-768w.38.7).
|
|
4
|
+
*
|
|
5
|
+
* THE THING THIS SUITE PROTECTS: "an answer he cannot trace is an answer he
|
|
6
|
+
* cannot trust with a contract". So the assertions are not about the answer
|
|
7
|
+
* text — a model writes that — they are about whether the surface can ever
|
|
8
|
+
* present an untraceable answer as a traceable one. Every dishonest rendering
|
|
9
|
+
* has a test that forbids it.
|
|
10
|
+
*
|
|
11
|
+
* Scope isolation has its own file (`corpus-scope.test.ts`) because it is a
|
|
12
|
+
* different failure with a different blast radius.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
askCorpus,
|
|
18
|
+
buildAnswerRequest,
|
|
19
|
+
documentText,
|
|
20
|
+
renderAnswerPrompt,
|
|
21
|
+
resolveCitations,
|
|
22
|
+
} from '../corpus/ask';
|
|
23
|
+
import {
|
|
24
|
+
createFixtureCorpusApi,
|
|
25
|
+
createFixtureLlm,
|
|
26
|
+
FIXTURE_DOCUMENTS,
|
|
27
|
+
FIXTURE_ORG,
|
|
28
|
+
} from '../corpus/fixtures';
|
|
29
|
+
import { rankDocuments } from '../corpus/retrieval';
|
|
30
|
+
import type { AnswerDraft, AskRequest, CorpusLlm, RetrievedDocument } from '../corpus/types';
|
|
31
|
+
|
|
32
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
33
|
+
const ask = (question: string): AskRequest => ({ question, mode: 'ask', scope: SCOPE });
|
|
34
|
+
|
|
35
|
+
/** A model that returns exactly what the test tells it to. */
|
|
36
|
+
function scriptedLlm(draft: AnswerDraft, calls: unknown[] = []): CorpusLlm {
|
|
37
|
+
return {
|
|
38
|
+
describe: () => ({ provider: 'scripted', model: 'scripted-1', live: false }),
|
|
39
|
+
async answer(input) {
|
|
40
|
+
calls.push(input);
|
|
41
|
+
return draft;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function retrievedFor(question: string): RetrievedDocument[] {
|
|
47
|
+
const own = FIXTURE_DOCUMENTS.filter((document) => document.scope.orgId === FIXTURE_ORG);
|
|
48
|
+
return rankDocuments(own, question, { limit: 5 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe('batching — N documents, ONE call', () => {
|
|
54
|
+
it('sends every retrieved document in a single request', async () => {
|
|
55
|
+
const calls: unknown[] = [];
|
|
56
|
+
const corpus = createFixtureCorpusApi({ scope: SCOPE });
|
|
57
|
+
const llm = scriptedLlm({ answer: 'ok', citations: [] }, calls);
|
|
58
|
+
|
|
59
|
+
await askCorpus(ask('Acme renewal rate and seats'), { corpus, llm });
|
|
60
|
+
|
|
61
|
+
expect(calls).toHaveLength(1);
|
|
62
|
+
const input = calls[0] as { documents: unknown[] };
|
|
63
|
+
expect(input.documents.length).toBeGreaterThan(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('gives the model an id per document and repeats it in the prompt', () => {
|
|
67
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
68
|
+
const request = buildAnswerRequest(ask('Acme renewal'), retrieved, 4000);
|
|
69
|
+
const prompt = renderAnswerPrompt(request);
|
|
70
|
+
|
|
71
|
+
for (const document of request.documents) {
|
|
72
|
+
// The echoed id is the whole citation mechanism. Without it in the prompt
|
|
73
|
+
// there is nothing the model could return that resolves to a record.
|
|
74
|
+
expect(prompt).toContain(`<document id="${document.id}">`);
|
|
75
|
+
}
|
|
76
|
+
expect(request.documents.map((d) => d.id)).toEqual(retrieved.map((r) => r.document.id));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('carries the SOURCE into the prompt, so the model can say where it learned it', () => {
|
|
80
|
+
const retrieved = retrievedFor('Acme renewal contract');
|
|
81
|
+
const prompt = renderAnswerPrompt(buildAnswerRequest(ask('Acme renewal contract'), retrieved, 4000));
|
|
82
|
+
expect(prompt).toMatch(/source: (Granola|Dropbox|Google Drive|Stripe)/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('truncates a long document rather than dropping it from the batch', () => {
|
|
86
|
+
const long = {
|
|
87
|
+
...FIXTURE_DOCUMENTS[0],
|
|
88
|
+
sections: [{ key: 'transcript', label: 'Transcript', text: 'x'.repeat(50_000) }],
|
|
89
|
+
};
|
|
90
|
+
const text = documentText(long, 500);
|
|
91
|
+
expect(text.length).toBeLessThan(700);
|
|
92
|
+
expect(text).toContain('[truncated]');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('citations resolve to records retrieval actually supplied', () => {
|
|
97
|
+
it('resolves a good citation to the record itself', () => {
|
|
98
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
99
|
+
const id = retrieved[0].document.id;
|
|
100
|
+
const { citations, unresolved } = resolveCitations(
|
|
101
|
+
{ citations: [{ documentId: id, quote: 'twelve months' }] },
|
|
102
|
+
retrieved,
|
|
103
|
+
);
|
|
104
|
+
expect(unresolved).toEqual([]);
|
|
105
|
+
expect(citations).toHaveLength(1);
|
|
106
|
+
expect(citations[0].document.id).toBe(id);
|
|
107
|
+
expect(citations[0].quote).toBe('twelve months');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('drops an invented id and REPORTS it', () => {
|
|
111
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
112
|
+
const { citations, unresolved } = resolveCitations(
|
|
113
|
+
{ citations: [{ documentId: 'not_this_never_existed' }] },
|
|
114
|
+
retrieved,
|
|
115
|
+
);
|
|
116
|
+
expect(citations).toHaveLength(0);
|
|
117
|
+
// Swallowing this would render a clean answer over a hallucinated source.
|
|
118
|
+
expect(unresolved).toEqual(['not_this_never_existed']);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('deduplicates a repeated citation', () => {
|
|
122
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
123
|
+
const id = retrieved[0].document.id;
|
|
124
|
+
const { citations } = resolveCitations(
|
|
125
|
+
{ citations: [{ documentId: id }, { documentId: id }] },
|
|
126
|
+
retrieved,
|
|
127
|
+
);
|
|
128
|
+
expect(citations).toHaveLength(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('substitutes the record’s own text when the model returns no quote', () => {
|
|
132
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
133
|
+
const { citations } = resolveCitations(
|
|
134
|
+
{ citations: [{ documentId: retrieved[0].document.id }] },
|
|
135
|
+
retrieved,
|
|
136
|
+
);
|
|
137
|
+
expect(citations[0].quote.length).toBeGreaterThan(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('accepts the snake_case spelling a model may emit', () => {
|
|
141
|
+
const retrieved = retrievedFor('Acme renewal');
|
|
142
|
+
const id = retrieved[0].document.id;
|
|
143
|
+
const { citations } = resolveCitations(
|
|
144
|
+
{ citations: [{ document_id: id } as unknown as { documentId: string }] },
|
|
145
|
+
retrieved,
|
|
146
|
+
);
|
|
147
|
+
expect(citations).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('the surface can never present an untraceable answer as traceable', () => {
|
|
152
|
+
const corpus = () => createFixtureCorpusApi({ scope: SCOPE });
|
|
153
|
+
|
|
154
|
+
it('marks a cited answer as answered', async () => {
|
|
155
|
+
const result = await askCorpus(ask('Acme renewal rate'), {
|
|
156
|
+
corpus: corpus(),
|
|
157
|
+
llm: createFixtureLlm(),
|
|
158
|
+
});
|
|
159
|
+
expect(result.status).toBe('answered');
|
|
160
|
+
expect(result.citations.length).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('marks an answer with NO resolvable citation as uncited, not answered', async () => {
|
|
164
|
+
const result = await askCorpus(ask('Acme renewal rate'), {
|
|
165
|
+
corpus: corpus(),
|
|
166
|
+
llm: scriptedLlm({ answer: 'You agreed four thousand a month.', citations: [] }),
|
|
167
|
+
});
|
|
168
|
+
// The text is still returned — hiding it would be its own kind of lying —
|
|
169
|
+
// but the status is what the UI keys its warning off.
|
|
170
|
+
expect(result.status).toBe('uncited');
|
|
171
|
+
expect(result.answer).toContain('four thousand');
|
|
172
|
+
expect(result.citations).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('marks an answer whose every citation was invented as uncited, and names them', async () => {
|
|
176
|
+
const result = await askCorpus(ask('Acme renewal rate'), {
|
|
177
|
+
corpus: corpus(),
|
|
178
|
+
llm: scriptedLlm({
|
|
179
|
+
answer: 'You agreed four thousand a month.',
|
|
180
|
+
citations: [{ documentId: 'doc_that_does_not_exist' }],
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
expect(result.status).toBe('uncited');
|
|
184
|
+
expect(result.unresolvedCitations).toEqual(['doc_that_does_not_exist']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('reports no_evidence when the corpus has nothing, without calling the model', async () => {
|
|
188
|
+
const calls: unknown[] = [];
|
|
189
|
+
const result = await askCorpus(ask('zzzznothingmatchesthis'), {
|
|
190
|
+
corpus: corpus(),
|
|
191
|
+
llm: scriptedLlm({ answer: 'I could make something up.', citations: [] }, calls),
|
|
192
|
+
});
|
|
193
|
+
expect(result.status).toBe('no_evidence');
|
|
194
|
+
// Not calling the model at all is the only way to guarantee it cannot
|
|
195
|
+
// improvise an answer over an empty document list.
|
|
196
|
+
expect(calls).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('names the provider that actually answered, and whether it was live', async () => {
|
|
200
|
+
const result = await askCorpus(ask('Acme renewal'), {
|
|
201
|
+
corpus: corpus(),
|
|
202
|
+
llm: createFixtureLlm(),
|
|
203
|
+
});
|
|
204
|
+
expect(result.provider.live).toBe(false);
|
|
205
|
+
expect(result.provider.provider).toBe('fixture');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('carries freshness onto the answer so a stale corpus is visible', async () => {
|
|
209
|
+
const result = await askCorpus(ask('Acme renewal'), {
|
|
210
|
+
corpus: corpus(),
|
|
211
|
+
llm: createFixtureLlm(),
|
|
212
|
+
});
|
|
213
|
+
expect(result.freshness?.some((source) => source.status !== 'ok')).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('retrieval spans documents and money, and stays generic', () => {
|
|
218
|
+
it('retrieves a Stripe money event alongside meeting notes', async () => {
|
|
219
|
+
const calls: unknown[] = [];
|
|
220
|
+
await askCorpus(ask('Acme Robotics charge refunded'), {
|
|
221
|
+
corpus: createFixtureCorpusApi({ scope: SCOPE }),
|
|
222
|
+
llm: scriptedLlm({ answer: 'ok', citations: [] }, calls),
|
|
223
|
+
});
|
|
224
|
+
const ids = (calls[0] as { documents: Array<{ id: string }> }).documents.map((d) => d.id);
|
|
225
|
+
// "What did we agree with this company" spans a meeting note and an invoice,
|
|
226
|
+
// and it cannot span them if they are ranked in separate universes.
|
|
227
|
+
expect(ids).toContain('ch_RedSuiteJpy0001');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('still answers when the tenant has NO money connector at all', async () => {
|
|
231
|
+
const corpus = createFixtureCorpusApi({ scope: SCOPE });
|
|
232
|
+
delete (corpus as { listMoneyEvents?: unknown }).listMoneyEvents;
|
|
233
|
+
|
|
234
|
+
const result = await askCorpus(ask('Acme renewal rate'), { corpus, llm: createFixtureLlm() });
|
|
235
|
+
expect(result.status).toBe('answered');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('answers a Dropbox-only question with a Dropbox citation', async () => {
|
|
239
|
+
// The standing generic test: nothing in the pipeline may be better at
|
|
240
|
+
// meeting notes than at files.
|
|
241
|
+
const result = await askCorpus(ask('master services agreement seats cap'), {
|
|
242
|
+
corpus: createFixtureCorpusApi({ scope: SCOPE }),
|
|
243
|
+
llm: createFixtureLlm(),
|
|
244
|
+
});
|
|
245
|
+
expect(result.citations.some((citation) => citation.document.source === 'dropbox')).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('survives a money endpoint that fails, rather than failing the whole answer', async () => {
|
|
249
|
+
const corpus = createFixtureCorpusApi({ scope: SCOPE });
|
|
250
|
+
corpus.listMoneyEvents = async () => { throw new Error('money service is down'); };
|
|
251
|
+
|
|
252
|
+
const result = await askCorpus(ask('Acme renewal rate'), { corpus, llm: createFixtureLlm() });
|
|
253
|
+
expect(result.status).toBe('answered');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('generate mode', () => {
|
|
258
|
+
it('asks the model for a draft and still requires citations', async () => {
|
|
259
|
+
const calls: unknown[] = [];
|
|
260
|
+
const result = await askCorpus(
|
|
261
|
+
{
|
|
262
|
+
question: 'Draft a renewal letter for Acme Robotics',
|
|
263
|
+
mode: 'generate',
|
|
264
|
+
scope: SCOPE,
|
|
265
|
+
instructions: 'Formal, one page',
|
|
266
|
+
},
|
|
267
|
+
{ corpus: createFixtureCorpusApi({ scope: SCOPE }), llm: scriptedLlm({
|
|
268
|
+
answer: 'Dear Acme…',
|
|
269
|
+
citations: [{ documentId: 'dbx_9812f0', quote: 'twelve (12) months' }],
|
|
270
|
+
}, calls) },
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const input = calls[0] as { mode: string; instructions?: string };
|
|
274
|
+
expect(input.mode).toBe('generate');
|
|
275
|
+
expect(input.instructions).toBe('Formal, one page');
|
|
276
|
+
expect(result.status).toBe('answered');
|
|
277
|
+
expect(result.citations[0].document.source).toBe('dropbox');
|
|
278
|
+
});
|
|
279
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the corpus surfaces SAY, as opposed to what they fetch.
|
|
3
|
+
*
|
|
4
|
+
* Three defects the pre-demo rehearsal (bd startsim-768w.38.10) put on a
|
|
5
|
+
* screenshot, all of them the kind that survives a green test suite because no
|
|
6
|
+
* assertion ever read the sentence:
|
|
7
|
+
*
|
|
8
|
+
* • Markdown rendered literally — `## Decisions` and `**$4,800**` as text
|
|
9
|
+
* (bd startsim-768w.38.10, MINOR);
|
|
10
|
+
* • `Answered by a none stub ((provider default))` — not English, and
|
|
11
|
+
* `(provider default)` where the model's name belongs
|
|
12
|
+
* (bd startsim-768w.38.21);
|
|
13
|
+
* • `1 more record were searched`.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
import { render, screen, within } from '@testing-library/react';
|
|
17
|
+
import userEvent from '@testing-library/user-event';
|
|
18
|
+
|
|
19
|
+
import { Markdown, stripMarkdown } from '../components/Markdown';
|
|
20
|
+
import { AskPage } from '../components/pages/AskPage';
|
|
21
|
+
import { LibraryPage } from '../components/pages/LibraryPage';
|
|
22
|
+
import { configuredModel } from '../corpus/llm';
|
|
23
|
+
import { createFixtureCorpusApi, FIXTURE_DOCUMENTS, FIXTURE_ORG } from '../corpus/fixtures';
|
|
24
|
+
import type { AskResult, CorpusAskApi, SourceDocument } from '../corpus/types';
|
|
25
|
+
|
|
26
|
+
const SCOPE = { orgId: FIXTURE_ORG };
|
|
27
|
+
|
|
28
|
+
const MARKDOWN_BODY = [
|
|
29
|
+
'## Decisions',
|
|
30
|
+
'- Globex pricing agreed at **$4,800 per month**, 20 seats included.',
|
|
31
|
+
'- Additional seats are `$95` each per month.',
|
|
32
|
+
'',
|
|
33
|
+
'See the [rate card](https://docs.example/rate-card) for the rest.',
|
|
34
|
+
].join('\n');
|
|
35
|
+
|
|
36
|
+
function resultWith(overrides: Partial<AskResult>): AskResult {
|
|
37
|
+
const document = FIXTURE_DOCUMENTS[0];
|
|
38
|
+
return {
|
|
39
|
+
status: 'answered',
|
|
40
|
+
answer: MARKDOWN_BODY,
|
|
41
|
+
citations: [{ documentId: document.id, document, quote: '**SAML SSO has not shipped**' }],
|
|
42
|
+
considered: [{ document, score: 1, matchedTerms: ['globex'], snippet: '' }],
|
|
43
|
+
unresolvedCitations: [],
|
|
44
|
+
droppedOutOfScope: 0,
|
|
45
|
+
provider: { provider: 'openai', model: 'gpt-4o-mini', live: true },
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function askApi(result: AskResult): CorpusAskApi {
|
|
51
|
+
return { ask: async () => result };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function ask(result: AskResult) {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
render(<AskPage api={askApi(result)} scope={SCOPE} />);
|
|
57
|
+
await user.type(screen.getByRole('textbox'), 'What did we agree?');
|
|
58
|
+
await user.click(screen.getByRole('button', { name: 'Ask' }));
|
|
59
|
+
return screen.findByRole('region', { name: 'Answer' });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
describe('Markdown is rendered, not printed', () => {
|
|
65
|
+
it('turns a heading into a heading rather than two hashes', () => {
|
|
66
|
+
render(<Markdown text={MARKDOWN_BODY} />);
|
|
67
|
+
expect(screen.getByText('Decisions')).toBeInTheDocument();
|
|
68
|
+
expect(screen.queryByText(/##/)).not.toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('emphasises an amount instead of wrapping it in asterisks', () => {
|
|
72
|
+
render(<Markdown text={MARKDOWN_BODY} />);
|
|
73
|
+
const strong = screen.getByText('$4,800 per month');
|
|
74
|
+
expect(strong.tagName).toBe('STRONG');
|
|
75
|
+
expect(screen.queryByText(/\*\*/)).not.toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('makes a list a list, and a link a link', () => {
|
|
79
|
+
render(<Markdown text={MARKDOWN_BODY} />);
|
|
80
|
+
expect(screen.getAllByRole('listitem')).toHaveLength(2);
|
|
81
|
+
expect(screen.getByRole('link', { name: 'rate card' })).toHaveAttribute(
|
|
82
|
+
'href',
|
|
83
|
+
'https://docs.example/rate-card',
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('keeps the label but drops the link for a scheme it does not trust', () => {
|
|
88
|
+
render(<Markdown text={'[click me](javascript:doSomething)'} />);
|
|
89
|
+
expect(screen.getByText('click me')).toBeInTheDocument();
|
|
90
|
+
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('leaves prose containing a lone asterisk exactly as written', () => {
|
|
94
|
+
// A false positive here silently edits somebody's contract.
|
|
95
|
+
render(<Markdown text={'Fees are 2.5% * the invoice total.'} />);
|
|
96
|
+
expect(screen.getByText('Fees are 2.5% * the invoice total.')).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('flattens to plain text when a string is what is needed', () => {
|
|
100
|
+
expect(stripMarkdown('## Blocker\n- **SAML SSO** has not shipped')).toBe(
|
|
101
|
+
'Blocker\nSAML SSO has not shipped',
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('LibraryPage renders a document’s own Markdown', () => {
|
|
107
|
+
it('shows the summary as prose, not as source', async () => {
|
|
108
|
+
const user = userEvent.setup();
|
|
109
|
+
const document: SourceDocument = { ...FIXTURE_DOCUMENTS[0], body: MARKDOWN_BODY };
|
|
110
|
+
const api = createFixtureCorpusApi({ scope: SCOPE, documents: [document], moneyEvents: [] });
|
|
111
|
+
render(<LibraryPage api={api} scope={SCOPE} />);
|
|
112
|
+
|
|
113
|
+
await user.click(await screen.findByText(document.title));
|
|
114
|
+
const detail = await screen.findByRole('region', { name: 'Record detail' });
|
|
115
|
+
expect(within(detail).getByText('Decisions')).toBeInTheDocument();
|
|
116
|
+
expect(within(detail).getByText('$4,800 per month').tagName).toBe('STRONG');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('AskPage says who answered, in English', () => {
|
|
121
|
+
it('names the model when one answered', async () => {
|
|
122
|
+
const panel = await ask(resultWith({}));
|
|
123
|
+
expect(within(panel).getByText('Answered by openai · gpt-4o-mini')).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('says no model is configured rather than "a none stub ((provider default))"', async () => {
|
|
127
|
+
const panel = await ask(
|
|
128
|
+
resultWith({ provider: { provider: 'none', model: '', live: false } }),
|
|
129
|
+
);
|
|
130
|
+
expect(within(panel).getByText(/No model is configured for this workspace/)).toBeInTheDocument();
|
|
131
|
+
// The honesty is kept: it still says nothing live ran.
|
|
132
|
+
expect(within(panel).getByText(/no live model was called/)).toBeInTheDocument();
|
|
133
|
+
expect(within(panel).queryByText(/\(\(/)).not.toBeInTheDocument();
|
|
134
|
+
expect(within(panel).queryByText(/none stub/)).not.toBeInTheDocument();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('still calls a stub a stub', async () => {
|
|
138
|
+
const panel = await ask(
|
|
139
|
+
resultWith({ provider: { provider: 'fixture', model: 'fixture-echo', live: false } }),
|
|
140
|
+
);
|
|
141
|
+
expect(
|
|
142
|
+
within(panel).getByText('Answered by a fixture stub (fixture-echo) — no live model was called'),
|
|
143
|
+
).toBeInTheDocument();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('renders the answer’s Markdown and flattens the citation quote', async () => {
|
|
147
|
+
const panel = await ask(resultWith({}));
|
|
148
|
+
expect(within(panel).getByText('Decisions')).toBeInTheDocument();
|
|
149
|
+
expect(within(panel).getByText('“SAML SSO has not shipped”')).toBeInTheDocument();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('agrees in number about how many records went uncited', async () => {
|
|
153
|
+
const document = FIXTURE_DOCUMENTS[1];
|
|
154
|
+
const panel = await ask(
|
|
155
|
+
resultWith({
|
|
156
|
+
considered: [
|
|
157
|
+
{ document: FIXTURE_DOCUMENTS[0], score: 1, matchedTerms: [], snippet: '' },
|
|
158
|
+
{ document, score: 0.5, matchedTerms: [], snippet: '' },
|
|
159
|
+
],
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
expect(within(panel).getByText('1 more record was searched but not cited')).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('uses the plural when more than one was passed over', async () => {
|
|
166
|
+
const panel = await ask(
|
|
167
|
+
resultWith({
|
|
168
|
+
considered: [
|
|
169
|
+
{ document: FIXTURE_DOCUMENTS[0], score: 1, matchedTerms: [], snippet: '' },
|
|
170
|
+
{ document: FIXTURE_DOCUMENTS[1], score: 0.5, matchedTerms: [], snippet: '' },
|
|
171
|
+
{ document: FIXTURE_DOCUMENTS[2], score: 0.4, matchedTerms: [], snippet: '' },
|
|
172
|
+
],
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
expect(within(panel).getByText('2 more records were searched but not cited')).toBeInTheDocument();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('the model named before the first call is the CONFIGURED one', () => {
|
|
180
|
+
it('reads the provider’s own variable', () => {
|
|
181
|
+
expect(configuredModel('openai', { OPENAI_MODEL: 'gpt-4o-mini' })).toBe('gpt-4o-mini');
|
|
182
|
+
expect(configuredModel('anthropic', { ANTHROPIC_MODEL: 'claude-x' })).toBe('claude-x');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('prefers the specific variable over the generic one', () => {
|
|
186
|
+
expect(configuredModel('openai', { OPENAI_MODEL: 'gpt-4o-mini', LLM_MODEL: 'other' })).toBe(
|
|
187
|
+
'gpt-4o-mini',
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('falls back to LLM_MODEL, and to nothing at all', () => {
|
|
192
|
+
expect(configuredModel('openai', { LLM_MODEL: 'gpt-5' })).toBe('gpt-5');
|
|
193
|
+
expect(configuredModel('openai', {})).toBe('');
|
|
194
|
+
expect(configuredModel('none', { OPENAI_MODEL: 'gpt-4o-mini' })).toBe('');
|
|
195
|
+
});
|
|
196
|
+
});
|