@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 CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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
+ });