@startsimpli/integrations 0.2.18 → 0.2.20

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
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",
@@ -217,6 +217,35 @@ describe('askCorpus — the withheld text never reaches the model', () => {
217
217
  expect(result.answer).not.toContain('relapsed');
218
218
  });
219
219
 
220
+ it('does not retrieve on a withheld term carried in on a LOOSER query', async () => {
221
+ // The widening in bd startsim-768w.38.36 is the obvious way to reopen this.
222
+ // A question is no longer sent as a conjunction — it is sent as a
223
+ // DISJUNCTION of its content words — so "lease" is now enough to bring this
224
+ // record back on its own, and the withheld word rides along in the same
225
+ // query. If the ranker ever scored on what the SERVER matched rather than on
226
+ // what the reader may see, that record would arrive with the withheld term
227
+ // in its matched set and the model would be invited to answer about it.
228
+ //
229
+ // The fixture server here is the leaking one and searches the withheld
230
+ // `body` too, so the row genuinely comes back on this query. Everything
231
+ // after that has to hold on its own.
232
+ const { llm, seen } = spyLlm();
233
+ const result = await askCorpus(
234
+ { question: 'was anything said about the lease or relapsed treatment?', mode: 'ask', scope: SCOPE },
235
+ { corpus: apiFor([leakingEntity()]), llm },
236
+ );
237
+
238
+ // It DID retrieve — on `lease`, which is in the summary and always was.
239
+ expect(result.status).toBe('answered');
240
+ expect(seen[0]).toContain('lease');
241
+ // ...and the withheld word reached neither the prompt, the answer, nor the
242
+ // evidence the surface will show for it.
243
+ expect(seen[0]).not.toContain('relapsed');
244
+ expect(JSON.stringify(result.citations)).not.toContain('relapsed');
245
+ expect(JSON.stringify(result.considered)).not.toContain('relapsed');
246
+ expect(result.considered.flatMap((item) => item.matchedTerms)).not.toContain('relapsed');
247
+ });
248
+
220
249
  it('answers identically whether the server leaked or behaved', async () => {
221
250
  // The falsifiable version of "the client does not depend on the server
222
251
  // getting it right": same question, one API stripping the field and one
@@ -0,0 +1,318 @@
1
+ /**
2
+ * A QUESTION IS NOT A BOOLEAN AND OF ITS WORDS (bd startsim-768w.38.36).
3
+ *
4
+ * `corpus-ask.test.ts` next door protects the honesty rules — that an untraceable
5
+ * answer can never be rendered as a traceable one. This file protects the stage
6
+ * BEFORE any of that is reachable: whether the records that would answer the
7
+ * question are still in the candidate set by the time the ranker runs.
8
+ *
9
+ * WHAT WENT WRONG, MEASURED ON THE LIVE TANDEM TENANT. `loadCandidates` sent the
10
+ * WHOLE question as `?search=`. The tenant compiles that with
11
+ * `websearch_to_tsquery('english', …)`, which joins bare words with `&` — so
12
+ * "What did we agree about the parking allocation?" only retrieved a document
13
+ * containing `agree` AND `parking` AND `allocation`, and 18 documents and 63,208
14
+ * characters of summary were discarded before ranking ever happened. A single
15
+ * rare word still answered, which is what made it look like a corpus problem;
16
+ * the surface said so out loud, blaming the connector for a query bug.
17
+ *
18
+ * SO THE FIXTURE HERE RUNS THE TENANT'S OWN SEARCH (`searchBackend:
19
+ * 'postgres_fulltext'`). Against the permissive fixture this package shipped
20
+ * with — whitespace-split substrings, OR'd — every case below passes on the
21
+ * broken client. A fixture easier to satisfy than production does not fail safe.
22
+ *
23
+ * THE TWO FAILURES ARE OPPOSITE, and both are here on purpose:
24
+ *
25
+ * TOO NARROW is the bug: an ordinary question retrieves nothing.
26
+ * TOO WIDE is the tempting fix: match everything, put the whole corpus in the
27
+ * prompt and let the model sort it out. That trades a visible failure for an
28
+ * invisible one — a confident answer assembled from whatever happened to be
29
+ * recent. So a rare single term must still retrieve exactly its own record.
30
+ */
31
+ import { describe, expect, it } from 'vitest';
32
+
33
+ import { askCorpus } from '../corpus/ask';
34
+ import { createCorpusApi } from '../corpus/client';
35
+ import type { TenantEntity } from '../corpus/entities';
36
+ import { createFixtureCorpusHttp, createFixtureLlm, FIXTURE_ORG } from '../corpus/fixtures';
37
+ import { searchTermFor } from '../corpus/retrieval';
38
+ import type { AskResult, CorpusHttp, CorpusScope } from '../corpus/types';
39
+
40
+ const SCOPE: CorpusScope = { orgId: FIXTURE_ORG };
41
+
42
+ /**
43
+ * A corpus shaped like the real one: meeting notes whose whole substance is a
44
+ * long `summary`, written the way a note-taker writes rather than the way a
45
+ * query is typed. No document contains every word of any question asked below,
46
+ * which is the entire point.
47
+ */
48
+ function meeting(
49
+ id: string,
50
+ title: string,
51
+ occurredAt: string,
52
+ summary: string,
53
+ ): TenantEntity {
54
+ return {
55
+ id,
56
+ entity_type: 'source_document',
57
+ external_id: `granola:${id}`,
58
+ name: title,
59
+ org_id: FIXTURE_ORG,
60
+ created_at: `${occurredAt.slice(0, 10)}T12:00:00Z`,
61
+ data: { source: 'granola', subtype: 'meeting', title, summary, occurred_at: occurredAt },
62
+ };
63
+ }
64
+
65
+ const CORPUS: TenantEntity[] = [
66
+ meeting(
67
+ 'doc_office',
68
+ 'Office move — landlord terms and fit-out',
69
+ '2026-07-28T09:00:00Z',
70
+ [
71
+ 'Landlord came back on the renewal. We agreed to a twelve month extension on the',
72
+ 'lease at the current rate, with a three month break clause either side.',
73
+ 'Fit-out costs are split, capped at 20k. Signature target is the 15th.',
74
+ ].join(' '),
75
+ ),
76
+ meeting(
77
+ 'doc_site',
78
+ 'Site walkthrough — Lamar building',
79
+ '2026-07-21T09:00:00Z',
80
+ [
81
+ 'Walked the Lamar building with the broker. Six parking bays come with the unit;',
82
+ 'two are currently used by the ground floor tenant and nobody has written down',
83
+ 'who gets what. Broker to send the site plan so we can settle it before signing.',
84
+ ].join(' '),
85
+ ),
86
+ meeting(
87
+ 'doc_july',
88
+ 'July financials, tenders strategy, staffing update',
89
+ '2026-07-14T09:00:00Z',
90
+ [
91
+ 'Collections are ahead of plan. On the patent dispute, a filing action tying up',
92
+ 'the IP could block the funding round, so we hold the interest notice until Jeff',
93
+ 'has reviewed it. Staffing: two offers out, one accepted.',
94
+ ].join(' '),
95
+ ),
96
+ meeting(
97
+ 'doc_insurance',
98
+ 'Property finances — loan renewals and insurance',
99
+ '2026-06-30T09:00:00Z',
100
+ [
101
+ 'The insurance renewal is with the broker and we are waiting on their revised',
102
+ 'quote before we can sign anything. Loan renewals for two of the properties are',
103
+ 'due in September; the bank wants updated valuations first.',
104
+ ].join(' '),
105
+ ),
106
+ meeting(
107
+ 'doc_rent',
108
+ 'Rent Manager Express setup and Zillow posting',
109
+ '2026-06-16T09:00:00Z',
110
+ [
111
+ 'Rent Manager Express is configured for the two smaller properties. Zillow',
112
+ 'postings go up Monday. The QUIBBLETRON migration script is still the only',
113
+ 'blocker on moving the rest of the portfolio across.',
114
+ ].join(' '),
115
+ ),
116
+ meeting(
117
+ 'doc_mcr',
118
+ 'MCR platform demo — streaming data and task automation',
119
+ '2026-05-19T09:00:00Z',
120
+ [
121
+ 'Demoed the streaming ingest. Fuga integration is behind a vendor key we do not',
122
+ 'have yet. Task automation from Granola notes is the piece they cared about.',
123
+ ].join(' '),
124
+ ),
125
+ ];
126
+
127
+ function corpusFor(entities: TenantEntity[] = CORPUS) {
128
+ const { http, requests } = createFixtureCorpusHttp({
129
+ entities,
130
+ relationships: [],
131
+ searchBackend: 'postgres_fulltext',
132
+ });
133
+ return {
134
+ requests,
135
+ api: createCorpusApi({ http: http as CorpusHttp, scope: SCOPE, supportedFilters: [] }),
136
+ };
137
+ }
138
+
139
+ function askWith(question: string, entities?: TenantEntity[]): Promise<AskResult> {
140
+ return askCorpus(
141
+ { question, mode: 'ask', scope: SCOPE },
142
+ { corpus: corpusFor(entities).api, llm: createFixtureLlm() },
143
+ );
144
+ }
145
+
146
+ const citedIds = (result: AskResult) => result.citations.map((citation) => citation.documentId);
147
+
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('an ordinary question retrieves', () => {
151
+ /**
152
+ * The three the user reproduced on prod, plus the phrasings people actually
153
+ * type. Every one of these returned `no_evidence` with zero citations against
154
+ * a corpus that answers them.
155
+ */
156
+ const cases: Array<[question: string, expected: string]> = [
157
+ ['What did we agree about the lease?', 'doc_office'],
158
+ ['what is blocked?', 'doc_july'],
159
+ ['What did we agree about the parking allocation?', 'doc_site'],
160
+ ['who are we waiting on for the insurance renewal?', 'doc_insurance'],
161
+ ['what did we decide about staffing?', 'doc_july'],
162
+ ['when are the loan renewals due?', 'doc_insurance'],
163
+ ['did we sort out the parking bays', 'doc_site'],
164
+ ['whats the story with the landlord', 'doc_office'],
165
+ ];
166
+
167
+ for (const [question, expected] of cases) {
168
+ it(`answers "${question}" and cites ${expected}`, async () => {
169
+ const result = await askWith(question);
170
+ expect(result.status).toBe('answered');
171
+ expect(result.citations.length).toBeGreaterThan(0);
172
+ expect(citedIds(result)).toContain(expected);
173
+ });
174
+ }
175
+
176
+ it('retrieves a question whose words are stemmed apart from the record’s', async () => {
177
+ // The tenant's `english` configuration folds `blocked` and `block` to one
178
+ // lexeme, so the SERVER matches. The client ranker tokenizes exactly, so it
179
+ // used to throw the row away again on the way to the prompt — two retrieval
180
+ // layers disagreeing about what a match is, which is invisible from either
181
+ // side alone.
182
+ const result = await askWith('what is blocked?');
183
+ expect(citedIds(result)).toContain('doc_july');
184
+ });
185
+
186
+ it('does not need every word of the question to appear in one record', async () => {
187
+ // No document contains `allocation` at all. Under a conjunction that is the
188
+ // end of it; under a question, `parking` is plenty.
189
+ const result = await askWith('What did we agree about the parking allocation?');
190
+ expect(result.status).toBe('answered');
191
+ });
192
+ });
193
+
194
+ describe('but a question is not an excuse to retrieve everything', () => {
195
+ it('retrieves ONLY the record holding a rare single term', async () => {
196
+ const result = await askWith('QUIBBLETRON');
197
+ expect(result.status).toBe('answered');
198
+ expect(citedIds(result)).toEqual(['doc_rent']);
199
+ });
200
+
201
+ it('keeps a rare term rare even when it is asked as a sentence', async () => {
202
+ const result = await askWith('what is the QUIBBLETRON migration waiting on?');
203
+ expect(citedIds(result)).toContain('doc_rent');
204
+ // `migration` and `waiting` are real words in this corpus, so a couple of
205
+ // other records may legitimately come along — but not the whole shelf.
206
+ expect(result.citations.length).toBeLessThan(CORPUS.length);
207
+ });
208
+
209
+ it('does not put the entire corpus in front of the model for a narrow question', async () => {
210
+ const seen: number[] = [];
211
+ await askCorpus(
212
+ { question: 'what did we agree about the lease?', mode: 'ask', scope: SCOPE },
213
+ {
214
+ corpus: corpusFor().api,
215
+ llm: {
216
+ describe: () => ({ provider: 'spy', model: 'spy', live: false }),
217
+ async answer(input) {
218
+ seen.push(input.documents.length);
219
+ return { answer: 'ok', citations: [], unanswerable: false };
220
+ },
221
+ },
222
+ },
223
+ );
224
+ expect(seen[0]).toBeGreaterThan(0);
225
+ expect(seen[0]).toBeLessThan(CORPUS.length);
226
+ });
227
+
228
+ it('still refuses when the corpus genuinely has nothing, without calling the model', async () => {
229
+ const calls: unknown[] = [];
230
+ const result = await askCorpus(
231
+ { question: 'what did the arbitration panel rule on the trademark?', mode: 'ask', scope: SCOPE },
232
+ {
233
+ corpus: corpusFor().api,
234
+ llm: {
235
+ describe: () => ({ provider: 'spy', model: 'spy', live: false }),
236
+ async answer(input) {
237
+ calls.push(input);
238
+ return { answer: 'I could improvise.', citations: [], unanswerable: false };
239
+ },
240
+ },
241
+ },
242
+ );
243
+ expect(result.status).toBe('no_evidence');
244
+ expect(calls).toHaveLength(0);
245
+ });
246
+ });
247
+
248
+ describe('the search term the tenant is actually sent', () => {
249
+ it('is a disjunction of the question’s content words, not the question', async () => {
250
+ const { api, requests } = corpusFor();
251
+ await askCorpus(
252
+ { question: 'What did we agree about the parking allocation?', mode: 'ask', scope: SCOPE },
253
+ { corpus: api, llm: createFixtureLlm() },
254
+ );
255
+ const term = String(
256
+ requests.find((request) => request.params.type === 'source_document')?.params.search ?? '',
257
+ );
258
+ expect(term).not.toContain('?');
259
+ expect(term.toLowerCase()).toContain(' or ');
260
+ expect(term.toLowerCase()).toContain('parking');
261
+ // Interrogatives and function words carry no signal and, ANDed, delete the
262
+ // question outright.
263
+ expect(term.toLowerCase().split(/\s+or\s+/)).not.toContain('what');
264
+ });
265
+
266
+ it('leaves a single rare term exactly as it was typed', () => {
267
+ // The precision case has to be untouched: one word in, one word out, no
268
+ // disjunction to widen it.
269
+ expect(searchTermFor('QUIBBLETRON')).toBe('quibbletron');
270
+ });
271
+
272
+ it('sends NO search term at all when the question is only function words', () => {
273
+ // `websearch_to_tsquery` compiles a stopword-only query to an EMPTY tsquery,
274
+ // which matches no row. Sending it would be asking the server for nothing.
275
+ expect(searchTermFor('what is it?')).toBe('');
276
+ });
277
+
278
+ it('keeps a quoted phrase quoted, and an excluded word excluded', () => {
279
+ expect(searchTermFor('"funding round"')).toBe('"funding round"');
280
+ expect(searchTermFor('parking -zillow')).toContain('-zillow');
281
+ });
282
+
283
+ it('asks the server to order by relevance, so page one is the relevant page', async () => {
284
+ const { api, requests } = corpusFor();
285
+ await askCorpus(
286
+ { question: 'what did we agree about the lease?', mode: 'ask', scope: SCOPE },
287
+ { corpus: api, llm: createFixtureLlm() },
288
+ );
289
+ const request = requests.find((entry) => entry.params.type === 'source_document');
290
+ expect(request?.params.order).toBe('relevance');
291
+ });
292
+ });
293
+
294
+ describe('a question with no content words falls back to the recent window', () => {
295
+ it('answers "what happened lately?" from the newest records', async () => {
296
+ const result = await askWith('what is going on?');
297
+ expect(result.status).toBe('answered');
298
+ expect(citedIds(result)).toContain('doc_office'); // the newest record
299
+ });
300
+ });
301
+
302
+ describe('the copy tells the truth about whose fault it is', () => {
303
+ it('does not blame the corpus when the corpus is full and the question missed', async () => {
304
+ const result = await askWith('what did the arbitration panel rule on the trademark?');
305
+ expect(result.status).toBe('no_evidence');
306
+ // The shipped sentence — "Either it has not been pulled in yet, or it
307
+ // belongs to a different workspace" — sent someone to check the connector
308
+ // for a query bug. Six records were sitting right there.
309
+ expect(result.answer).not.toMatch(/has not been pulled in/i);
310
+ expect(result.answer).toMatch(/6 record/i);
311
+ });
312
+
313
+ it('DOES say the workspace is empty when the workspace is empty', async () => {
314
+ const result = await askWith('what did we agree about the lease?', []);
315
+ expect(result.status).toBe('no_evidence');
316
+ expect(result.answer).toMatch(/nothing has been pulled in|no records/i);
317
+ });
318
+ });
@@ -80,10 +80,19 @@ describe('enforceScope — nothing is dropped silently', () => {
80
80
  });
81
81
 
82
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.
83
+ it('ranks the foreign document at the very top when the guard is removed', () => {
84
+ // Establishes that the guard is doing real work: without it, the leak is not
85
+ // some tail result the model would ignore — it is the most attractive answer
86
+ // in the set and it reaches the prompt.
87
+ //
88
+ // ASSERTED AS "in the top two", not "first", since bd startsim-768w.38.36:
89
+ // the ranker now agrees with the tenant's stemmer, so the caller's OWN Acme
90
+ // note matches `agree` against its "agreed" and covers all six terms of the
91
+ // question rather than five. It edges ahead by 3%. The bait is no less
92
+ // attractive and the guard is no less necessary; pinning an exact ordinal
93
+ // pinned an artifact of the tokenizer instead of the property.
85
94
  const unguarded = rankDocuments(FIXTURE_DOCUMENTS, BAIT, { limit: 5 });
86
- expect(unguarded[0]?.document.id).toBe('nw_contract_44');
95
+ expect(unguarded.slice(0, 2).map((item) => item.document.id)).toContain('nw_contract_44');
87
96
  });
88
97
 
89
98
  it('never returns it once the guard runs', () => {