@startsimpli/integrations 0.2.18 → 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 +1 -1
- package/src/__tests__/corpus-field-gate.test.ts +29 -0
- package/src/__tests__/corpus-question.test.ts +318 -0
- package/src/__tests__/corpus-scope.test.ts +12 -3
- package/src/corpus/ask.ts +76 -8
- package/src/corpus/client.ts +3 -0
- package/src/corpus/fixtures.ts +153 -15
- package/src/corpus/index.ts +3 -1
- package/src/corpus/retrieval.ts +237 -17
- package/src/corpus/types.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
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
|
|
84
|
-
// Establishes that the guard is doing real work: without it, the leak
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
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: [],
|
package/src/corpus/client.ts
CHANGED
|
@@ -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;
|
package/src/corpus/fixtures.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
|
747
|
-
*
|
|
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
|
-
// `
|
|
807
|
-
|
|
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 });
|
package/src/corpus/index.ts
CHANGED
|
@@ -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,
|
package/src/corpus/retrieval.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
const at =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
212
|
-
const body = countHits(tokenize(document.body),
|
|
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
|
-
|
|
435
|
+
index,
|
|
216
436
|
);
|
|
217
437
|
|
|
218
438
|
// Saturating term counts (log1p) so one long transcript repeating a word
|
package/src/corpus/types.ts
CHANGED
|
@@ -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`. */
|