@startsimpli/integrations 0.2.17 → 0.2.19

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.17",
3
+ "version": "0.2.19",
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', () => {
package/src/corpus/ask.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * given has invented a source, and the only safe rendering of that is to say so.
20
20
  */
21
21
  import { moneyEventAsDocument } from './entities';
22
- import { retrieveForQuestion } from './retrieval';
22
+ import { retrieveForQuestion, searchTermFor } from './retrieval';
23
23
  import { documentText } from './text';
24
24
  import type {
25
25
  AnswerRequest,
@@ -209,6 +209,20 @@ function snippetOf(document: SourceDocument): string {
209
209
  * Pull the candidate set. Two reads, in PARALLEL, not one read per document —
210
210
  * the server pages and filters, this only decides how wide a net to cast.
211
211
  *
212
+ * THE QUESTION IS NOT THE QUERY (bd startsim-768w.38.36). It used to be: the
213
+ * whole question went out as `?search=`, the tenant compiled it with
214
+ * `websearch_to_tsquery`, and the resulting conjunction of every content word
215
+ * matched no record at all. `searchTermFor` turns it into a disjunction of the
216
+ * content words instead — see the argument for that shape in `./retrieval`.
217
+ *
218
+ * `order: 'relevance'` is what makes the widening safe at scale. Once the query
219
+ * is a disjunction, more records match than fit on a page, and an unordered page
220
+ * one is a random sample of them. Asking the server to sort by its own
221
+ * `ts_rank_cd` means the page this pulls is the page worth ranking.
222
+ *
223
+ * A question with no content words sends NO search term (see `searchTermFor`),
224
+ * which pulls the recent window — the honest reading of "what is going on?".
225
+ *
212
226
  * Money events are folded in as documents so one ranking pass spans meetings and
213
227
  * revenue; "what did we agree with this company" is a question about both.
214
228
  */
@@ -216,9 +230,10 @@ async function loadCandidates(
216
230
  corpus: CorpusApi,
217
231
  request: AskRequest,
218
232
  candidateLimit: number,
219
- ): Promise<SourceDocument[]> {
233
+ ): Promise<{ candidates: SourceDocument[]; searchTerm: string; matchedCount: number }> {
234
+ const searchTerm = searchTermFor(request.question);
220
235
  const documentQuery = {
221
- search: request.question,
236
+ ...(searchTerm ? { search: searchTerm, order: 'relevance' as const } : {}),
222
237
  ...request.filters,
223
238
  page: 1,
224
239
  pageSize: candidateLimit,
@@ -229,7 +244,7 @@ async function loadCandidates(
229
244
  corpus.listMoneyEvents
230
245
  ? corpus
231
246
  .listMoneyEvents({
232
- search: request.question,
247
+ ...(searchTerm ? { search: searchTerm } : {}),
233
248
  partyId: request.filters?.partyId,
234
249
  page: 1,
235
250
  pageSize: Math.ceil(candidateLimit / 4),
@@ -240,7 +255,54 @@ async function loadCandidates(
240
255
  : Promise.resolve({ count: 0, next: null, previous: null, results: [] }),
241
256
  ]);
242
257
 
243
- return [...documents.results, ...money.results.map(moneyEventAsDocument)];
258
+ return {
259
+ candidates: [...documents.results, ...money.results.map(moneyEventAsDocument)],
260
+ searchTerm,
261
+ matchedCount: documents.count ?? documents.results.length,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * How many records this workspace holds AT ALL — asked only when the answer is
267
+ * going to be "nothing matched", and only to decide WHICH "nothing".
268
+ *
269
+ * "Your corpus has not been pulled in yet" and "none of your 18 records mention
270
+ * that" send a person to two different places, and the surface shipped the first
271
+ * sentence for both. Somebody went to check a connector that was working.
272
+ */
273
+ async function corpusSize(corpus: CorpusApi, filters: AskRequest['filters']): Promise<number> {
274
+ return corpus
275
+ .listDocuments({ ...filters, search: undefined, page: 1, pageSize: 1 })
276
+ .then((page) => page.count ?? page.results.length)
277
+ .catch(() => -1);
278
+ }
279
+
280
+ /**
281
+ * What to say when retrieval came back empty, without blaming the wrong thing.
282
+ *
283
+ * THE OLD SENTENCE WAS A LIE OF ATTRIBUTION. "Nothing in your corpus matches
284
+ * that. Either it has not been pulled in yet, or it belongs to a different
285
+ * workspace" was returned for a query-construction bug, over a workspace holding
286
+ * eighteen documents that were pulled in perfectly. It reads as a data problem
287
+ * and sends someone to check a connector that is working.
288
+ *
289
+ * THE QUESTION IS NOT REPEATED BACK. An earlier draft named the terms it had
290
+ * searched for, which reads well and is wrong here: the corpus-field-gate suite
291
+ * asks a question made entirely of a withheld word and requires the answer not
292
+ * to contain it. Echoing the query would have made the ONE sentence this branch
293
+ * exists to produce the one place that word appears on screen — and a reader
294
+ * cannot tell an echo from a quote.
295
+ */
296
+ function noEvidenceCopy(total: number): string {
297
+ if (total === 0) {
298
+ return 'Nothing has been pulled into this workspace yet, so there are no records to search. Connect a source, or check that the last sync succeeded.';
299
+ }
300
+ if (total < 0) {
301
+ // The count read failed. Say what IS known — the search found nothing — and
302
+ // claim nothing about the corpus.
303
+ return 'The search came back empty. Try naming the company, person or document you mean.';
304
+ }
305
+ return `Searched all ${total} record${total === 1 ? '' : 's'} in this workspace and none of them matched. Try naming the company, person or document you mean — or ask about something they actually discussed.`;
244
306
  }
245
307
 
246
308
  /**
@@ -264,7 +326,11 @@ export async function askCorpus(
264
326
  return emptyResult(request, llm, 'This session has no tenant scope, so there is nothing to search.');
265
327
  }
266
328
 
267
- const candidates = await loadCandidates(corpus, request, candidateLimit);
329
+ const { candidates, searchTerm, matchedCount } = await loadCandidates(
330
+ corpus,
331
+ request,
332
+ candidateLimit,
333
+ );
268
334
  const { retrieved, droppedOutOfScope } = retrieveForQuestion(
269
335
  candidates,
270
336
  request.question,
@@ -275,10 +341,12 @@ export async function askCorpus(
275
341
  const freshness = await corpus.listSources().catch(() => [] as SourceFreshness[]);
276
342
 
277
343
  if (retrieved.length === 0) {
344
+ // An unfiltered pull already tells us the size, so the extra read only
345
+ // happens on the path that is about to make a claim about the corpus.
346
+ const total = searchTerm ? await corpusSize(corpus, request.filters) : matchedCount;
278
347
  return {
279
348
  status: 'no_evidence',
280
- answer:
281
- 'Nothing in your corpus matches that. Either it has not been pulled in yet, or it belongs to a different workspace.',
349
+ answer: noEvidenceCopy(total),
282
350
  citations: [],
283
351
  considered: [],
284
352
  unresolvedCitations: [],
@@ -228,6 +228,9 @@ function params(query: DocumentQuery, type: string) {
228
228
  const out: Record<string, unknown> = { type, page: query.page ?? 1 };
229
229
  if (query.pageSize) out.page_size = query.pageSize;
230
230
  if (query.search) out.search = query.search;
231
+ // Only alongside a term: `order=relevance` with nothing to be relevant TO is a
232
+ // parameter the server has to reject or ignore, and neither is worth asking.
233
+ if (query.order && (query.order !== 'relevance' || query.search)) out.order = query.order;
231
234
  if (query.source) out.source = query.source;
232
235
  if (query.partyId) out.party = query.partyId;
233
236
  if (query.occurredAfter) out.occurred_after = query.occurredAfter;
@@ -43,7 +43,7 @@ import {
43
43
  toSourceDocument,
44
44
  } from './entities';
45
45
  import type { TenantEntity, TenantRelationship } from './entities';
46
- import { splitSections } from './text';
46
+ import { splitSections, stem } from './text';
47
47
  import type {
48
48
  CorpusApi,
49
49
  CorpusPage,
@@ -571,11 +571,125 @@ export const FIXTURE_SOURCES: SourceFreshness[] = [
571
571
  },
572
572
  ];
573
573
 
574
+ // ---------------------------------------------------------------------------
575
+ // the tenant's OWN search semantics
576
+ // ---------------------------------------------------------------------------
577
+
578
+ /**
579
+ * `websearch_to_tsquery('english', term) @@ to_tsvector('english', text)`, close
580
+ * enough to fail the same way (bd startsim-768w.38.36).
581
+ *
582
+ * WHY A FIXTURE HAS TO MODEL THIS AT ALL. Both fixtures below used to be MORE
583
+ * PERMISSIVE than the tenant — one OR'd whitespace-split substrings, the other
584
+ * ignored `?search=` entirely — so a client that sent a whole English question
585
+ * as the search term passed every test in this package and retrieved NOTHING on
586
+ * the live tenant, where the same term is a conjunction of stemmed words. A
587
+ * fixture server that is easier to satisfy than the real one does not fail
588
+ * safe; it fails silently, and then it ships.
589
+ *
590
+ * The two properties it exists to reproduce, because they are the two that
591
+ * broke:
592
+ *
593
+ * AND, not OR. `websearch_to_tsquery` joins bare words with `&`, so a
594
+ * document must contain EVERY content word of the query. Adding a word to a
595
+ * question narrows; it never widens.
596
+ *
597
+ * STEMMING AND STOPWORDS. The `english` configuration folds `blocked` and
598
+ * `block` to one lexeme and deletes `what`/`is`/`the` outright — so a query
599
+ * made only of stopwords compiles to an EMPTY tsquery, which matches no row
600
+ * at all rather than every row.
601
+ *
602
+ * It also honours the syntax `websearch_to_tsquery` gives a person: `"quoted
603
+ * phrases"`, a bare `or` for disjunction, and a leading `-` to exclude.
604
+ *
605
+ * THE STEMMER IS THE PACKAGE'S OWN (`./text`), the same Porter implementation
606
+ * the ranker and the extraction dedupe use. Postgres's `english` is snowball,
607
+ * which is Porter2; using a third approximation here would let the fixture
608
+ * disagree with production for reasons that have nothing to do with the code
609
+ * under test — which is exactly the class of bug this file now exists to catch.
610
+ */
611
+
612
+ /** The `english` configuration's stoplist, near enough. */
613
+ const TSQUERY_STOPWORDS = new Set(
614
+ ('a about all am an and any are as at be been but by can did do does for from had has have he her his how i if in is it its me my not of on or our she so that the their them then there these they this to us was we were what when where which who why will with you your')
615
+ .split(' ')
616
+ .map((word) => stem(word)),
617
+ );
618
+
619
+ interface TsQueryItem {
620
+ /** One or more folded lexemes; more than one means a quoted phrase. */
621
+ lexemes: string[];
622
+ negated: boolean;
623
+ }
624
+
625
+ /** Parse the term the way `websearch_to_tsquery` does: AND groups, `or` between. */
626
+ function parseTsQuery(term: string): TsQueryItem[][] {
627
+ const groups: TsQueryItem[][] = [[]];
628
+ const tokens = term.match(/"[^"]*"|\S+/g) ?? [];
629
+ for (const raw of tokens) {
630
+ if (/^-?or$/i.test(raw)) {
631
+ groups.push([]);
632
+ continue;
633
+ }
634
+ const negated = raw.startsWith('-');
635
+ const value = (negated ? raw.slice(1) : raw).replace(/^"|"$/g, '');
636
+ const lexemes = (value.match(/[a-z0-9]+/gi) ?? [])
637
+ .map((word) => stem(word))
638
+ .filter((lexeme) => lexeme && !TSQUERY_STOPWORDS.has(lexeme));
639
+ if (lexemes.length === 0) continue; // a stopword compiles to nothing at all
640
+ groups.at(-1)!.push({ lexemes, negated });
641
+ }
642
+ return groups.filter((group) => group.length > 0);
643
+ }
644
+
645
+ /** Does `text` satisfy the tsquery `term` compiles to? */
646
+ export function matchesTenantSearch(term: string, text: string): boolean {
647
+ const groups = parseTsQuery(term);
648
+ // An EMPTY tsquery matches no row in Postgres. It does not match every row,
649
+ // which is the assumption a client makes right up until it ships.
650
+ if (groups.length === 0) return false;
651
+ const lexemes = (text.match(/[a-z0-9]+/gi) ?? []).map((word) => stem(word));
652
+ const present = new Set(lexemes);
653
+ const hasPhrase = (phrase: string[]) =>
654
+ phrase.length === 1
655
+ ? present.has(phrase[0])
656
+ : lexemes.some((_, index) => phrase.every((lexeme, offset) => lexemes[index + offset] === lexeme));
657
+ return groups.some((group) =>
658
+ group.every((item) => (item.negated ? !hasPhrase(item.lexemes) : hasPhrase(item.lexemes))),
659
+ );
660
+ }
661
+
662
+ /**
663
+ * Which search a fixture "server" performs.
664
+ *
665
+ * `postgres_fulltext` is what the live tenant runs and is what a retrieval test
666
+ * has to be written against. The permissive default is kept only so suites that
667
+ * predate the tenant learning to search keep testing what they were written to
668
+ * test; new retrieval work should ask for the real one.
669
+ */
670
+ export type FixtureSearchBackend = 'permissive' | 'postgres_fulltext';
671
+
672
+ /**
673
+ * Every string VALUE in a `data` blob, the way `jsonb_to_tsvector(cfg, data,
674
+ * '["string"]')` indexes it — values, never keys, so `?search=title` does not
675
+ * match every record in the tenant.
676
+ */
677
+ function jsonStrings(value: unknown): string {
678
+ if (typeof value === 'string') return value;
679
+ if (Array.isArray(value)) return value.map(jsonStrings).join(' ');
680
+ if (value && typeof value === 'object') {
681
+ return Object.values(value as Record<string, unknown>).map(jsonStrings).join(' ');
682
+ }
683
+ return '';
684
+ }
685
+
574
686
  // ---------------------------------------------------------------------------
575
687
  // a CorpusApi over the fixtures
576
688
  // ---------------------------------------------------------------------------
577
689
 
578
690
  export interface FixtureCorpusOptions {
691
+ /** Which `?search=` semantics this "server" applies. See `FixtureSearchBackend`. */
692
+ searchBackend?: FixtureSearchBackend;
579
693
  documents?: SourceDocument[];
580
694
  moneyEvents?: MoneyEvent[];
581
695
  parties?: Party[];
@@ -632,9 +746,19 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
632
746
  defaultPageSize = 5,
633
747
  scope: serverScope,
634
748
  leakScope = false,
749
+ searchBackend = 'permissive',
635
750
  onQuery,
636
751
  } = options;
637
752
 
753
+ const matchesSearch = (term: string, text: string) =>
754
+ searchBackend === 'postgres_fulltext'
755
+ ? matchesTenantSearch(term, text)
756
+ : term
757
+ .toLowerCase()
758
+ .split(/\s+/)
759
+ .filter(Boolean)
760
+ .some((needle) => text.toLowerCase().includes(needle));
761
+
638
762
  const inScope = <T extends { scope: CorpusScope }>(rows: T[]): T[] => {
639
763
  if (leakScope || !serverScope) return rows;
640
764
  return rows.filter(
@@ -670,11 +794,7 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
670
794
  }
671
795
  if (query.search) {
672
796
  applied.push('search');
673
- const needles = query.search.toLowerCase().split(/\s+/).filter(Boolean);
674
- rows = rows.filter((document) => {
675
- const text = haystack(document);
676
- return needles.some((needle) => text.includes(needle));
677
- });
797
+ rows = rows.filter((document) => matchesSearch(query.search!, haystack(document)));
678
798
  }
679
799
 
680
800
  rows = [...rows].sort((a, b) => (b.occurredAt ?? '').localeCompare(a.occurredAt ?? ''));
@@ -715,11 +835,12 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
715
835
  }
716
836
  if (query.search) {
717
837
  applied.push('search');
718
- const needles = query.search.toLowerCase().split(/\s+/).filter(Boolean);
719
- rows = rows.filter((event) => {
720
- const text = `${event.subtype} ${event.status} ${event.description ?? ''} ${event.party?.name ?? ''}`.toLowerCase();
721
- return needles.some((needle) => text.includes(needle));
722
- });
838
+ rows = rows.filter((event) =>
839
+ matchesSearch(
840
+ query.search!,
841
+ `${event.subtype} ${event.status} ${event.description ?? ''} ${event.party?.name ?? ''}`,
842
+ ),
843
+ );
723
844
  }
724
845
  return paginate(rows, query.page ?? 1, query.pageSize ?? 20, applied);
725
846
  },
@@ -743,8 +864,12 @@ export function createFixtureCorpusApi(options: FixtureCorpusOptions = {}): Corp
743
864
  * block was lost (bd startsim-768w.38.22). It reproduces the tenant's real
744
865
  * behaviour, including the parts that are inconvenient:
745
866
  *
746
- * • `?type=` is the ONLY filter honoured — `search`, `source` and `party` are
747
- * accepted and ignored, exactly as `EntityViewSet` does today;
867
+ * • `?type=` is honoured, and `?search=` is honoured ONLY when the caller asks
868
+ * for `searchBackend: 'postgres_fulltext'` — the tenant has learned to
869
+ * search since this was written (bd startsim-768w.38.12) and a retrieval
870
+ * test must be written against the real semantics, but the suites that
871
+ * predate it are still testing what they were written to test. `source` and
872
+ * `party` are still accepted and ignored, exactly as `EntityViewSet` does;
748
873
  * • `page_size` is IGNORED (`PageNumberPagination` with no
749
874
  * `page_size_query_param`), so a caller asking for 200 gets `pageSize`;
750
875
  * • `/api/v1/relationships/` returns every edge in the org, unfiltered,
@@ -760,6 +885,8 @@ export function createFixtureCorpusHttp(options: {
760
885
  pageSize?: number;
761
886
  /** Endpoints to fail, so the fail-soft paths are exercised. */
762
887
  missing?: string[];
888
+ /** Which `?search=` semantics this "tenant" applies. See `FixtureSearchBackend`. */
889
+ searchBackend?: FixtureSearchBackend;
763
890
  onRequest?: (path: string, params: Record<string, unknown>) => void;
764
891
  } = {}) {
765
892
  const entities = options.entities ?? [
@@ -803,8 +930,19 @@ export function createFixtureCorpusHttp(options: {
803
930
  return found as T;
804
931
  }
805
932
  const type = params.type ? String(params.type) : '';
806
- // `search`/`source`/`party` are deliberately NOT applied.
807
- const rows = type ? entities.filter((entity) => entity.entity_type === type) : entities;
933
+ // `source`/`party` are deliberately NOT applied. `search` is, but only
934
+ // for a caller that asked for the tenant's real backend.
935
+ let rows = type ? entities.filter((entity) => entity.entity_type === type) : entities;
936
+ const term = params.search ? String(params.search) : '';
937
+ if (term && options.searchBackend === 'postgres_fulltext') {
938
+ // The vector is built from `name` and every STRING VALUE in `data`,
939
+ // exactly like the generated column — and from the data the server
940
+ // WOULD send, so a leaking fixture's search leaks too. The client is
941
+ // not allowed to be safe only because the server was.
942
+ rows = rows.filter((entity) =>
943
+ matchesTenantSearch(term, `${entity.name ?? ''} ${jsonStrings(entity.data)}`),
944
+ );
945
+ }
808
946
  return slice(rows, params) as T;
809
947
  }
810
948
  throw Object.assign(new Error(`${path} responded 404`), { status: 404 });
@@ -73,12 +73,14 @@ export type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entitie
73
73
  export {
74
74
  enforceScope,
75
75
  isInScope,
76
+ questionTerms,
76
77
  rankDocuments,
77
78
  retrieveForQuestion,
79
+ searchTermFor,
78
80
  snippetFor,
79
81
  tokenize,
80
82
  } from './retrieval';
81
- export type { RetrieveOptions, ScopeFilterResult } from './retrieval';
83
+ export type { QuestionTerms, RetrieveOptions, ScopeFilterResult } from './retrieval';
82
84
 
83
85
  export {
84
86
  answerSystemPrompt,
@@ -29,12 +29,21 @@
29
29
  * what was dropped is surfaced rather than swallowed, because a non-zero drop is
30
30
  * a bug somewhere upstream that someone needs to see.
31
31
  */
32
+ import { stem } from './text';
32
33
  import type { CorpusScope, RetrievedDocument, SourceDocument } from './types';
33
34
 
34
35
  /**
35
36
  * Terms too common to carry meaning. Deliberately small: an aggressive stoplist
36
37
  * silently deletes the user's question. `contract`, `invoice` and every domain
37
38
  * word stay in, because they are exactly what distinguishes one record here.
39
+ *
40
+ * The second block is the QUESTION vocabulary — the interrogatives, auxiliaries
41
+ * and discourse verbs that appear because somebody asked rather than because
42
+ * they meant something ("what did we decide", "can you tell me", "should we").
43
+ * They matter for the same reason as the rest: a term in this list is a term the
44
+ * server is not asked to match, and every word sent is a word that has to be
45
+ * found. Domain nouns are never in here — `decision` stays, `decide` goes,
46
+ * because one is a thing a record is about and the other is how a person asks.
38
47
  */
39
48
  const STOPWORDS = new Set([
40
49
  'a', 'about', 'all', 'am', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been',
@@ -43,6 +52,16 @@ const STOPWORDS = new Set([
43
52
  'on', 'or', 'our', 'she', 'so', 'that', 'the', 'their', 'them', 'then', 'there',
44
53
  'these', 'they', 'this', 'to', 'us', 'was', 'we', 'were', 'what', 'when',
45
54
  'where', 'which', 'who', 'why', 'will', 'with', 'you', 'your',
55
+ // asked-not-meant
56
+ 'again', 'also', 'anything', 'been', 'being', 'could', 'currently', 'decide',
57
+ 'decided', 'doing', 'done', 'ever', 'everything', 'get', 'give', 'going',
58
+ 'gonna', 'got', 'hey', 'im', 'into', 'just', 'know', 'lately', 'let', 'like',
59
+ 'll', 'look', 'many', 'may', 'might', 'much', 'must', 'need', 'now', 'ok',
60
+ 'okay', 'only', 'out', 'over', 'own', 'please', 'pull', 'really', 'remind',
61
+ 're', 'said', 'same', 'say', 'says', 'see', 'shall', 'should', 'show', 'some',
62
+ 'something', 'still', 'such', 'sure', 'tell', 'thing', 'things', 'think',
63
+ 'told', 'up', 've', 've', 'very', 'want', 'wanna', 'whats', 'whom', 'whose',
64
+ 'would', 'yet',
46
65
  ]);
47
66
 
48
67
  /** Lowercase word tokens, 2+ chars, stopwords removed. */
@@ -52,6 +71,113 @@ export function tokenize(text: string): string[] {
52
71
  .filter((token) => token.length >= 2 && !STOPWORDS.has(token));
53
72
  }
54
73
 
74
+ // ---------------------------------------------------------------------------
75
+ // a question -> a query
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * THE FIX FOR bd startsim-768w.38.36, and the argument for its shape.
80
+ *
81
+ * The tenant compiles `?search=` with `websearch_to_tsquery('english', …)`,
82
+ * which joins bare words with `&`. Handing it a whole English question therefore
83
+ * asks for a document containing EVERY content word of that question — and on a
84
+ * corpus of a few thousand records, essentially no such document exists. The
85
+ * live measurement: `?search=parking` returned 2 records, `?search=parking
86
+ * allocation` returned 0, and every natural-language question returned 0 while a
87
+ * single rare word still answered.
88
+ *
89
+ * WHAT WAS CHOSEN: ONE request, a DISJUNCTION of the question's content words,
90
+ * ordered by the server's own `ts_rank_cd`, narrowed by the ranker below.
91
+ *
92
+ * Why not tier it — conjunction first, disjunction only when that returns
93
+ * nothing? It is one more round trip on exactly the questions that are already
94
+ * failing, and it puts a discontinuity in the middle of the product: adding one
95
+ * word to a question would silently move you between two different rankings for
96
+ * reasons no user can see. The deeper reason is that the two stages have
97
+ * different jobs. RETRIEVAL'S JOB IS RECALL — get the plausible records into the
98
+ * process. RANKING'S JOB IS PRECISION — decide which of them the model sees. A
99
+ * candidate set that is too narrow can never be rescued by a better ranker; one
100
+ * that is too broad always can, and the ranker for it already exists.
101
+ *
102
+ * Why not drop `?search=` and rank a recent window instead? Because that is the
103
+ * failure mode in the other direction: it answers every question from whatever
104
+ * happened to be recent, and it stops being possible at all the moment the
105
+ * corpus outgrows one page. The disjunction keeps the server doing the work it
106
+ * is good at — a stemmed, indexed, ranked scan of the whole corpus.
107
+ *
108
+ * PRECISION IS NOT TRADED AWAY. A one-word question compiles to exactly that one
109
+ * word, unchanged, so the rare-term case behaves identically to before. The
110
+ * widening only ever applies where a conjunction was going to return nothing.
111
+ *
112
+ * The syntax a person may already be typing survives: a `"quoted phrase"` stays
113
+ * quoted (the server phrase-matches it) and a `-word` stays excluded.
114
+ */
115
+ export interface QuestionTerms {
116
+ /** Content words to match on, lowercased and deduplicated. */
117
+ include: string[];
118
+ /** Words the asker explicitly excluded with a leading `-`. */
119
+ exclude: string[];
120
+ /** Quoted phrases, kept verbatim so the server can phrase-match them. */
121
+ phrases: string[];
122
+ }
123
+
124
+ /**
125
+ * How many terms are worth sending. A question longer than this is a paragraph,
126
+ * and every extra disjunct widens the candidate set without adding signal —
127
+ * the ranker's coverage factor is what separates them once they are here.
128
+ */
129
+ const MAX_QUERY_TERMS = 16;
130
+
131
+ export function questionTerms(question: string): QuestionTerms {
132
+ const include: string[] = [];
133
+ const exclude: string[] = [];
134
+ const phrases: string[] = [];
135
+ const seen = new Set<string>();
136
+
137
+ for (const raw of question.match(/-?"[^"]*"|\S+/g) ?? []) {
138
+ const negated = raw.startsWith('-');
139
+ const value = negated ? raw.slice(1) : raw;
140
+ const quoted = value.startsWith('"') && value.endsWith('"') && value.length > 2;
141
+ const words = tokenize(quoted ? value.slice(1, -1) : value);
142
+ if (words.length === 0) continue;
143
+ if (quoted && !negated && words.length > 1) phrases.push(words.join(' '));
144
+ for (const word of words) {
145
+ const bucket = negated ? exclude : include;
146
+ const key = `${negated ? '-' : ''}${word}`;
147
+ if (seen.has(key)) continue;
148
+ seen.add(key);
149
+ bucket.push(word);
150
+ }
151
+ }
152
+ return { include: include.slice(0, MAX_QUERY_TERMS), exclude, phrases };
153
+ }
154
+
155
+ /**
156
+ * The `?search=` value for a question — `''` when there is nothing worth asking.
157
+ *
158
+ * An EMPTY term is returned rather than a term made of stopwords, because
159
+ * `websearch_to_tsquery` compiles a stopword-only query to an EMPTY tsquery and
160
+ * an empty tsquery matches NO row. "What is going on?" would ask the server for
161
+ * nothing and be told, accurately, that there is nothing. Omitting the parameter
162
+ * instead pulls the recent window, which is what that question means.
163
+ *
164
+ * Exclusions are sent AND re-applied by the ranker. `websearch_to_tsquery` has
165
+ * no parentheses, so `a or b -c` compiles to `a | b & !c` — the exclusion binds
166
+ * to the last disjunct only. That is a superset of what was asked for, never a
167
+ * subset, so the server can only over-return; `rankDocuments` makes it exact.
168
+ */
169
+ export function searchTermFor(question: string): string {
170
+ const { include, exclude, phrases } = questionTerms(question);
171
+ if (include.length === 0) return '';
172
+ const phraseSet = new Set(phrases.flatMap((phrase) => phrase.split(' ')));
173
+ const disjuncts = [
174
+ ...phrases.map((phrase) => `"${phrase}"`),
175
+ ...include.filter((term) => !phraseSet.has(term)),
176
+ ];
177
+ const positive = disjuncts.join(' or ');
178
+ return [positive, ...exclude.map((term) => `-${term}`)].filter(Boolean).join(' ');
179
+ }
180
+
55
181
  /**
56
182
  * Is this record inside the asking principal's scope?
57
183
  *
@@ -97,6 +223,17 @@ export function enforceScope<T extends { scope: CorpusScope }>(
97
223
  /** Half-life of the recency prior, in days. */
98
224
  const RECENCY_HALF_LIFE_DAYS = 120;
99
225
 
226
+ /** Every piece of a record the reader is allowed to see, as one string. */
227
+ function documentHaystack(document: SourceDocument): string {
228
+ return [
229
+ document.title,
230
+ document.summary ?? '',
231
+ document.body,
232
+ ...document.sections.map((section) => section.text),
233
+ ...document.participants.map((party) => `${party.name} ${party.email ?? ''}`),
234
+ ].join(' ');
235
+ }
236
+
100
237
  /**
101
238
  * Fields are weighted because a title hit means more than a transcript hit.
102
239
  *
@@ -116,14 +253,81 @@ function recencyWeight(occurredAt: string | null | undefined, now: number): numb
116
253
  return 2 ** (-ageDays / RECENCY_HALF_LIFE_DAYS);
117
254
  }
118
255
 
119
- function countHits(tokens: string[], terms: Set<string>): { hits: number; matched: string[] } {
256
+ /**
257
+ * TWO RETRIEVAL LAYERS THAT DISAGREE ABOUT WHAT A MATCH IS RETRIEVE NOTHING.
258
+ *
259
+ * The tenant indexes with `to_tsvector('english', …)`, so `blocked` and `block`
260
+ * are one lexeme there. This ranker compared raw tokens, so a record the server
261
+ * had correctly matched scored zero here and was thrown away again on the way to
262
+ * the prompt — invisible from either side, because each layer was behaving
263
+ * perfectly on its own. Live: `?search=blocked` returned the July record (its
264
+ * summary says "could block the funding round") and Ask still answered "nothing
265
+ * in your corpus matches that", having considered exactly zero documents.
266
+ *
267
+ * SO BOTH SIDES ARE STEMMED, with the Porter implementation this package
268
+ * ALREADY has in `./text` — the one the extraction pipeline dedupes commitments
269
+ * with. Postgres's `english` configuration is snowball, which is Porter2, and
270
+ * on the vocabulary that matters the two agree: `blocked`/`block`,
271
+ * `agreed`/`agree`, `filing`/`file`, `lease`/`leases`, `payments`/`payment`,
272
+ * `renewal`/`renewals` all collapse; `relapsed`/`relationship` and
273
+ * `block`/`blockchain` do not. Writing a second, weaker approximation next to a
274
+ * working stemmer would have been a third spelling of "what is a match", which
275
+ * is the whole shape of this bug.
276
+ *
277
+ * THE AGREEMENT IS MADE OVER TEXT THE READER MAY SEE, never by trusting the
278
+ * server's verdict. Scoring a record because the SERVER matched it would undo
279
+ * the field read gate: on a tenant that is leaking, the term that matched could
280
+ * be one from a withheld attribute. A record whose only hit is inside withheld
281
+ * text still scores zero here, because that text never reaches this function.
282
+ */
283
+
284
+ /**
285
+ * Stems, memoized for the pass.
286
+ *
287
+ * One `stem()` call per distinct WORD rather than per token: a candidate set is
288
+ * up to 200 records and a record may be a 40-minute transcript, so the token
289
+ * count runs to millions while the vocabulary stays in the thousands.
290
+ */
291
+ const stemCache = new Map<string, string>();
292
+
293
+ function cachedStem(word: string): string {
294
+ const hit = stemCache.get(word);
295
+ if (hit !== undefined) return hit;
296
+ const value = stem(word);
297
+ // Bounded, because this module is long-lived in a server process and a corpus
298
+ // is not: an unbounded cache here is a slow leak, not a fast lookup.
299
+ if (stemCache.size > 20_000) stemCache.clear();
300
+ stemCache.set(word, value);
301
+ return value;
302
+ }
303
+
304
+ /**
305
+ * The query terms, indexed by stem for one pass per document, and mapped back to
306
+ * the word the user actually typed so `matchedTerms` can say what matched in
307
+ * their words rather than in Porter's.
308
+ */
309
+ interface TermIndex {
310
+ terms: string[];
311
+ byStem: Map<string, string>;
312
+ }
313
+
314
+ function indexTerms(terms: string[]): TermIndex {
315
+ const byStem = new Map<string, string>();
316
+ for (const term of terms) {
317
+ const key = cachedStem(term);
318
+ if (!byStem.has(key)) byStem.set(key, term);
319
+ }
320
+ return { terms, byStem };
321
+ }
322
+
323
+ function countHits(tokens: string[], index: TermIndex): { hits: number; matched: string[] } {
120
324
  let hits = 0;
121
325
  const matched = new Set<string>();
122
326
  for (const token of tokens) {
123
- if (terms.has(token)) {
124
- hits += 1;
125
- matched.add(token);
126
- }
327
+ const term = index.byStem.get(cachedStem(token));
328
+ if (term === undefined) continue;
329
+ hits += 1;
330
+ matched.add(term);
127
331
  }
128
332
  return { hits, matched: [...matched] };
129
333
  }
@@ -132,7 +336,11 @@ function countHits(tokens: string[], terms: Set<string>): { hits: number; matche
132
336
  * A passage around the first matching term, so the citation card shows the user
133
337
  * WHY this record came back rather than making them open it to find out.
134
338
  */
135
- export function snippetFor(document: SourceDocument, terms: Set<string>, length = 220): string {
339
+ export function snippetFor(
340
+ document: SourceDocument,
341
+ terms: Set<string> | Iterable<string>,
342
+ length = 220,
343
+ ): string {
136
344
  // The summary is searched FIRST, so a citation card quotes the curated
137
345
  // sentence rather than the moment somebody said the word out loud.
138
346
  const haystacks = [
@@ -140,12 +348,17 @@ export function snippetFor(document: SourceDocument, terms: Set<string>, length
140
348
  document.body,
141
349
  ...document.sections.map((section) => section.text),
142
350
  ];
143
- for (const text of haystacks) {
351
+ // A term the ranker matched by STEM ("blocked" against "block") does not
352
+ // appear verbatim, so the passage is found by walking words and comparing
353
+ // stems rather than by `indexOf`. Otherwise the citation card falls back to
354
+ // the document's opening line and the reader is not shown WHY it came back —
355
+ // which is the one job of a snippet.
356
+ const wanted = new Set([...terms].map((term) => cachedStem(term.toLowerCase())));
357
+ for (const text of wanted.size ? haystacks : []) {
144
358
  if (!text) continue;
145
- const lower = text.toLowerCase();
146
- for (const term of terms) {
147
- const at = lower.indexOf(term);
148
- if (at === -1) continue;
359
+ for (const match of text.toLowerCase().matchAll(/[a-z0-9][a-z0-9'&.-]*/g)) {
360
+ if (!wanted.has(cachedStem(match[0]))) continue;
361
+ const at = match.index ?? 0;
149
362
  const start = Math.max(0, at - Math.floor(length / 3));
150
363
  const slice = text.slice(start, start + length).trim();
151
364
  return `${start > 0 ? '…' : ''}${slice}${start + length < text.length ? '…' : ''}`;
@@ -181,7 +394,8 @@ export function rankDocuments(
181
394
  options: RetrieveOptions = {},
182
395
  ): RetrievedDocument[] {
183
396
  const { limit = 8, now = Date.now(), minScore = 0.0001 } = options;
184
- const terms = new Set(tokenize(question));
397
+ const { include, exclude } = questionTerms(question);
398
+ const terms = new Set(include);
185
399
  if (terms.size === 0) {
186
400
  // No usable terms — fall back to pure recency rather than returning
187
401
  // nothing. "What happened lately" is a real question.
@@ -196,23 +410,29 @@ export function rankDocuments(
196
410
  }));
197
411
  }
198
412
 
413
+ const index = indexTerms(include);
414
+ // `websearch_to_tsquery` has no parentheses, so the server can only approximate
415
+ // an exclusion inside a disjunction. It is made exact here.
416
+ const excluded = exclude.length ? indexTerms(exclude) : null;
417
+
199
418
  const scored: RetrievedDocument[] = [];
200
419
  for (const document of documents) {
201
- const title = countHits(tokenize(document.title), terms);
420
+ if (excluded && countHits(tokenize(documentHaystack(document)), excluded).hits > 0) continue;
421
+ const title = countHits(tokenize(document.title), index);
202
422
  const participants = countHits(
203
423
  tokenize(document.participants.map((party) => `${party.name} ${party.email ?? ''}`).join(' ')),
204
- terms,
424
+ index,
205
425
  );
206
426
  // Scored SEPARATELY from `body`, and only when they are actually different
207
427
  // strings — `body` falls back to the summary on a corpus that has never
208
428
  // delivered a full text, and counting the same words twice there would
209
429
  // silently re-weight every document on the tenant.
210
430
  const summaryText = document.summary && document.summary !== document.body ? document.summary : '';
211
- const summary = countHits(tokenize(summaryText), terms);
212
- const body = countHits(tokenize(document.body), terms);
431
+ const summary = countHits(tokenize(summaryText), index);
432
+ const body = countHits(tokenize(document.body), index);
213
433
  const sections = countHits(
214
434
  tokenize(document.sections.map((section) => section.text).join(' ')),
215
- terms,
435
+ index,
216
436
  );
217
437
 
218
438
  // Saturating term counts (log1p) so one long transcript repeating a word
@@ -210,6 +210,14 @@ export interface CorpusPage<T> {
210
210
  */
211
211
  export interface DocumentQuery {
212
212
  search?: string;
213
+ /**
214
+ * Server-side ordering. `relevance` is only meaningful alongside `search` —
215
+ * it sorts by the tenant's own `ts_rank_cd` over the same term, which is what
216
+ * makes page one of a broad query the page worth ranking (bd
217
+ * startsim-768w.38.36). A deployment that does not understand the parameter
218
+ * reports it under `ignored_filters` and orders as it always did.
219
+ */
220
+ order?: 'relevance' | 'recent' | 'oldest';
213
221
  source?: SourceKey;
214
222
  partyId?: string;
215
223
  /** ISO instants, filtering on `occurredAt`. */