@startsimpli/integrations 0.2.18 → 0.2.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * THE CLIENT CORPUS — ingestion, term extraction, memory, isolation
3
+ * (bd startsim-ppfl, RED).
4
+ *
5
+ * The bead asks for four things and this file is all four:
6
+ *
7
+ * - client documents ingest, and terminology is extracted with the right
8
+ * candidates surfaced FOR HUMAN CONFIRMATION — "an unconfirmed
9
+ * auto-extracted term must not silently become authoritative";
10
+ * - an approved translation pair becomes a memory entry and is hit on a
11
+ * matching later segment;
12
+ * - an accepted human edit becomes a new entry;
13
+ * - one client's corpus is unreachable from another client's tenant.
14
+ *
15
+ * THE ISOLATION CASES ARE THE POINT OF THE LANE. `startsim-hopl` sells "one
16
+ * client's material never appears against another's", and a glossary term is
17
+ * the sharpest possible violation of it: short, memorable, and it would surface
18
+ * INSIDE ANOTHER CLIENT'S TRANSLATED DOCUMENT, in their own language, looking
19
+ * entirely legitimate.
20
+ *
21
+ * Every case here runs against records deliberately mixed across two orgs —
22
+ * the same posture `corpus-scope.test.ts` already takes, which assumes the query
23
+ * layer DOES NOT hold the line and proves the output is safe anyway.
24
+ */
25
+
26
+ import { describe, expect, it } from 'vitest';
27
+
28
+ import { FIXTURE_ORG, FIXTURE_OTHER_ORG } from '../corpus/fixtures';
29
+ import {
30
+ TRANSLATION_TYPE_KEYS,
31
+ applyTermVerdict,
32
+ exactMemoryHit,
33
+ extractTermCandidates,
34
+ fuzzyMemoryMatches,
35
+ glossaryForSegment,
36
+ ingestForTranslation,
37
+ memoryUnitFromReviewerEdit,
38
+ normalizeForMemory,
39
+ readAllGlossaryTerms,
40
+ scopedMemory,
41
+ scopedTerms,
42
+ type GlossaryTermRecord,
43
+ type MemoryUnitRecord,
44
+ type TranslationCorpusApi,
45
+ } from '../corpus/translation';
46
+ import type { CorpusPage, CorpusScope, SourceDocument } from '../corpus/types';
47
+
48
+ const TANDEM: CorpusScope = { orgId: FIXTURE_ORG };
49
+ const NORTHWIND: CorpusScope = { orgId: FIXTURE_OTHER_ORG };
50
+
51
+ const EN_AR = { sourceLocale: 'en', targetLocale: 'ar' };
52
+
53
+ function doc(
54
+ id: string,
55
+ body: string,
56
+ scope: CorpusScope = TANDEM,
57
+ title = 'Client document',
58
+ ): SourceDocument {
59
+ return {
60
+ id,
61
+ title,
62
+ body,
63
+ sections: [],
64
+ occurredAt: '2026-03-01T00:00:00.000Z',
65
+ source: 'upload',
66
+ sourceLabel: 'Upload',
67
+ sourceRef: id,
68
+ participants: [],
69
+ scope,
70
+ };
71
+ }
72
+
73
+ function term(
74
+ over: Partial<GlossaryTermRecord> & Pick<GlossaryTermRecord, 'sourceTerm'>,
75
+ ): GlossaryTermRecord {
76
+ return {
77
+ id: `term-${over.sourceTerm}`,
78
+ sourceLocale: 'en',
79
+ targetLocale: 'ar',
80
+ targetTerm: 'اتفاقية التسوية',
81
+ status: 'approved',
82
+ occurrences: 3,
83
+ documentIds: ['doc-1'],
84
+ scope: TANDEM,
85
+ ...over,
86
+ };
87
+ }
88
+
89
+ function unit(over: Partial<MemoryUnitRecord> & Pick<MemoryUnitRecord, 'sourceText'>): MemoryUnitRecord {
90
+ return {
91
+ id: `unit-${over.sourceText.slice(0, 12)}`,
92
+ sourceLocale: 'en',
93
+ targetLocale: 'ar',
94
+ targetText: 'نص تجريبي',
95
+ sourceHash: over.sourceText.trim().toLowerCase(),
96
+ origin: 'reviewer_edit',
97
+ approved: true,
98
+ scope: TANDEM,
99
+ ...over,
100
+ };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe('ingestion', () => {
106
+ it('writes rows of the translation types and NOTHING else', () => {
107
+ // A connector that quietly invents a sixth entity_type is a schema change
108
+ // nobody reviewed. Generic envelope, declared types, no surprises.
109
+ const writes = ingestForTranslation(doc('doc-1', 'The settlement agreement is enclosed.'), {
110
+ scope: TANDEM,
111
+ });
112
+
113
+ const types = new Set(writes.map((w) => w.entity_type));
114
+ for (const type of types) {
115
+ expect(Object.values(TRANSLATION_TYPE_KEYS)).toContain(type);
116
+ }
117
+ expect(writes.length).toBeGreaterThan(0);
118
+ });
119
+
120
+ it('carries an external_id on every row, so a re-ingest converges instead of duplicating', () => {
121
+ const writes = ingestForTranslation(doc('doc-1', 'The settlement agreement is enclosed.'), {
122
+ scope: TANDEM,
123
+ });
124
+
125
+ for (const write of writes) expect(write.external_id).toBeTruthy();
126
+ expect(new Set(writes.map((w) => w.external_id)).size).toBe(writes.length);
127
+ });
128
+ });
129
+
130
+ describe('term extraction proposes, it does not decide', () => {
131
+ // Planted terms repeat; the noise does not. That is the whole signal, and it
132
+ // is why `minOccurrences` is a parameter rather than a constant.
133
+ const documents = [
134
+ doc(
135
+ 'doc-1',
136
+ [
137
+ 'The settlement agreement was signed on Tuesday.',
138
+ 'A settlement agreement binds both parties.',
139
+ 'Northwind Logistics accepted the settlement agreement.',
140
+ 'Force majeure applies to Northwind Logistics only.',
141
+ 'The force majeure clause was invoked.',
142
+ 'Somebody left a blue umbrella in the lobby.',
143
+ ].join(' '),
144
+ ),
145
+ doc('doc-2', 'Northwind Logistics confirmed the force majeure position.'),
146
+ ];
147
+
148
+ const options = { scope: TANDEM, ...EN_AR, minOccurrences: 2 };
149
+
150
+ it('yields the planted terms', () => {
151
+ const candidates = extractTermCandidates(documents, options);
152
+ const sources = candidates.map((c) => c.sourceTerm.toLowerCase());
153
+
154
+ expect(sources).toContain('settlement agreement');
155
+ expect(sources).toContain('force majeure');
156
+ expect(sources).toContain('northwind logistics');
157
+ });
158
+
159
+ it('does NOT yield the planted noise', () => {
160
+ const candidates = extractTermCandidates(documents, options);
161
+ const sources = candidates.map((c) => c.sourceTerm.toLowerCase());
162
+
163
+ // Occurs once, and is not terminology however memorable it is.
164
+ expect(sources).not.toContain('blue umbrella');
165
+ });
166
+
167
+ it('marks every extracted term a CANDIDATE, never approved, never binding', () => {
168
+ // The rule the bead puts in capitals. A glossary that fills itself is a
169
+ // glossary the client never agreed to — and adherence is measured against it.
170
+ const candidates = extractTermCandidates(documents, options);
171
+
172
+ expect(candidates.length).toBeGreaterThan(0);
173
+ for (const candidate of candidates) {
174
+ expect(candidate.status).toBe('candidate');
175
+ expect(candidate.targetTerm).toBe('');
176
+ }
177
+ });
178
+
179
+ it('never proposes a term a human already rejected', () => {
180
+ const rejected = term({ sourceTerm: 'force majeure', status: 'rejected' });
181
+
182
+ const candidates = extractTermCandidates(documents, { ...options, known: [rejected] });
183
+
184
+ expect(candidates.map((c) => c.sourceTerm.toLowerCase())).not.toContain('force majeure');
185
+ });
186
+
187
+ it('promotes a term only through an explicit verdict', () => {
188
+ const candidate = term({ sourceTerm: 'settlement agreement', status: 'candidate', targetTerm: '' });
189
+
190
+ const approved = applyTermVerdict(candidate, {
191
+ status: 'approved',
192
+ targetTerm: 'اتفاقية التسوية',
193
+ });
194
+
195
+ expect(approved.status).toBe('approved');
196
+ expect(approved.targetTerm).toBe('اتفاقية التسوية');
197
+ });
198
+ });
199
+
200
+ describe('glossary lookup', () => {
201
+ const present = term({ sourceTerm: 'settlement agreement' });
202
+ const absent = term({ sourceTerm: 'force majeure', id: 'term-fm' });
203
+ const candidate = term({
204
+ sourceTerm: 'settlement agreement',
205
+ id: 'term-candidate',
206
+ status: 'candidate',
207
+ targetTerm: '',
208
+ });
209
+
210
+ it('returns only the terms whose source form OCCURS in the segment', () => {
211
+ // The prompt scales with the text, not with the glossary.
212
+ const got = glossaryForSegment('Please review the settlement agreement.', [present, absent]);
213
+
214
+ expect(got.map((t) => t.id)).toEqual([present.id]);
215
+ });
216
+
217
+ it('never returns an unconfirmed candidate, even when it occurs', () => {
218
+ const got = glossaryForSegment('Please review the settlement agreement.', [candidate]);
219
+
220
+ expect(got).toEqual([]);
221
+ });
222
+
223
+ it('matches case-insensitively without matching a different word', () => {
224
+ expect(
225
+ glossaryForSegment('The SETTLEMENT AGREEMENT is enclosed.', [present]).map((t) => t.id),
226
+ ).toEqual([present.id]);
227
+ expect(glossaryForSegment('The settlement is enclosed.', [present])).toEqual([]);
228
+ });
229
+ });
230
+
231
+ describe('translation memory', () => {
232
+ const stored = unit({
233
+ sourceText: 'The quarterly report is ready for your review.',
234
+ targetText: 'نص تجريبي: التقرير ربع السنوي جاهز للمراجعة.',
235
+ });
236
+
237
+ it('hits exactly after normalising whitespace and case', () => {
238
+ const hit = exactMemoryHit(
239
+ ' the QUARTERLY report is ready for your review. ',
240
+ [stored],
241
+ { scope: TANDEM, ...EN_AR },
242
+ );
243
+
244
+ expect(hit?.id).toBe(stored.id);
245
+ expect(hit?.targetText).toBe(stored.targetText);
246
+ });
247
+
248
+ it('does NOT hit after a material change', () => {
249
+ // A different number is a different sentence. Normalisation must not eat it.
250
+ const hit = exactMemoryHit(
251
+ 'The monthly report is ready for your review.',
252
+ [stored],
253
+ { scope: TANDEM, ...EN_AR },
254
+ );
255
+
256
+ expect(hit).toBeNull();
257
+ });
258
+
259
+ it('normalises whitespace and case but preserves the material content', () => {
260
+ expect(normalizeForMemory(' The Report ')).toBe(normalizeForMemory('the report'));
261
+ expect(normalizeForMemory('12 seats')).not.toBe(normalizeForMemory('13 seats'));
262
+ });
263
+
264
+ it('never serves an unapproved unit', () => {
265
+ const draft = unit({ sourceText: stored.sourceText, id: 'draft', approved: false });
266
+
267
+ expect(exactMemoryHit(stored.sourceText, [draft], { scope: TANDEM, ...EN_AR })).toBeNull();
268
+ });
269
+
270
+ it('does not hit across a language pair', () => {
271
+ const zh = unit({ sourceText: stored.sourceText, id: 'zh', targetLocale: 'zh' });
272
+
273
+ expect(
274
+ exactMemoryHit(stored.sourceText, [zh], { scope: TANDEM, sourceLocale: 'en', targetLocale: 'ar' }),
275
+ ).toBeNull();
276
+ });
277
+
278
+ it('returns fuzzy neighbours ordered by score and honours the limit', () => {
279
+ const near = unit({ sourceText: 'The quarterly report is ready for review.', id: 'near' });
280
+ const mid = unit({ sourceText: 'The quarterly report will be ready soon.', id: 'mid' });
281
+ const far = unit({ sourceText: 'Parking allocation for the London office.', id: 'far' });
282
+
283
+ const matches = fuzzyMemoryMatches(stored.sourceText, [far, mid, near], {
284
+ scope: TANDEM,
285
+ ...EN_AR,
286
+ limit: 2,
287
+ });
288
+
289
+ expect(matches).toHaveLength(2);
290
+ expect(matches[0]!.unit.id).toBe('near');
291
+ expect(matches[0]!.score).toBeGreaterThan(matches[1]!.score);
292
+ expect(matches.map((m) => m.unit.id)).not.toContain('far');
293
+ });
294
+ });
295
+
296
+ describe('a reviewer edit becomes memory', () => {
297
+ it('creates an approved unit that a later identical segment hits exactly', () => {
298
+ // THE SINGLE TEST THAT PROVES "it learns from your material". Without it the
299
+ // reviewer corrects the same term forever, which is the upkeep trap that
300
+ // makes people abandon tools.
301
+ const source = 'Please review the settlement agreement before Friday.';
302
+ const edited = 'نص تجريبي مصحَّح: اتفاقية التسوية.';
303
+
304
+ const created = memoryUnitFromReviewerEdit(
305
+ { sourceText: source, editedTargetText: edited, ...EN_AR },
306
+ TANDEM,
307
+ );
308
+
309
+ expect(created.origin).toBe('reviewer_edit');
310
+ expect(created.approved).toBe(true);
311
+
312
+ const hit = exactMemoryHit(source, [created], { scope: TANDEM, ...EN_AR });
313
+ // BYTE-IDENTICAL. A reviewer who fixed it once must never see it wrong again.
314
+ expect(hit?.targetText).toBe(edited);
315
+ });
316
+ });
317
+
318
+ describe('isolation between clients', () => {
319
+ const mine = term({ sourceTerm: 'settlement agreement', id: 'mine', scope: TANDEM });
320
+ const theirs = term({ sourceTerm: 'settlement agreement', id: 'theirs', scope: NORTHWIND });
321
+ const myUnit = unit({ sourceText: 'Shared sentence.', id: 'my-unit', scope: TANDEM });
322
+ const theirUnit = unit({ sourceText: 'Shared sentence.', id: 'their-unit', scope: NORTHWIND });
323
+
324
+ it('does not return another tenant’s glossary term, and SAYS it dropped one', () => {
325
+ const read = scopedTerms([mine, theirs], TANDEM);
326
+
327
+ expect(read.kept.map((t) => t.id)).toEqual(['mine']);
328
+ // Non-zero and surfaced — a silent zero here is how a leak stays invisible.
329
+ expect(read.droppedOutOfScope).toBe(1);
330
+ });
331
+
332
+ it('does not return another tenant’s memory unit, and SAYS it dropped one', () => {
333
+ const read = scopedMemory([myUnit, theirUnit], TANDEM);
334
+
335
+ expect(read.kept.map((u) => u.id)).toEqual(['my-unit']);
336
+ expect(read.droppedOutOfScope).toBe(1);
337
+ });
338
+
339
+ it('never serves a foreign memory unit as an exact hit', () => {
340
+ // The worst case in the lane: a fluent, confident, correctly-formatted
341
+ // translation sourced from another customer's approved wording.
342
+ const hit = exactMemoryHit('Shared sentence.', [theirUnit], { scope: TANDEM, ...EN_AR });
343
+
344
+ expect(hit).toBeNull();
345
+ });
346
+
347
+ it('never offers a foreign unit as a fuzzy example', () => {
348
+ const matches = fuzzyMemoryMatches('Shared sentence.', [theirUnit], {
349
+ scope: TANDEM,
350
+ ...EN_AR,
351
+ limit: 5,
352
+ });
353
+
354
+ expect(matches).toEqual([]);
355
+ });
356
+
357
+ it('returns NOTHING for a principal carrying no org claim', () => {
358
+ // A missing claim must read as "nothing", never as "unscoped, so everything".
359
+ const read = scopedTerms([mine, theirs], { orgId: '' });
360
+
361
+ expect(read.kept).toEqual([]);
362
+ expect(read.droppedOutOfScope).toBe(2);
363
+ });
364
+
365
+ /**
366
+ * The RLS half of bd startsim-ppfl item 7 is NOT in this repository.
367
+ *
368
+ * It belongs to `tenant-starter`, a separate Django project with its own
369
+ * runner (`DJANGO_DEBUG=true .venv/bin/python manage.py test`, sqlite, its own
370
+ * venv — NOT the main-backend Docker stack). There is no `tenant-starter`
371
+ * checkout in this worktree, so the test cannot be written here, and writing
372
+ * a TypeScript imitation of it would be worse than leaving it undone: it would
373
+ * look like coverage of a database guarantee it does not touch.
374
+ *
375
+ * `startsim-bfem` already records this as UNVERIFIED, owner Quinn.
376
+ */
377
+ it.todo(
378
+ 'reading outside org_scope() returns nothing rather than everything — tenant-starter, not this repo',
379
+ );
380
+ });
381
+
382
+ describe('paging', () => {
383
+ /** Three pages of two. A reader that stops at the first sees a third of the glossary. */
384
+ function pagedApi(): TranslationCorpusApi {
385
+ const all = [
386
+ term({ sourceTerm: 'alpha', id: 't1' }),
387
+ term({ sourceTerm: 'bravo', id: 't2' }),
388
+ term({ sourceTerm: 'charlie', id: 't3' }),
389
+ term({ sourceTerm: 'delta', id: 't4' }),
390
+ term({ sourceTerm: 'echo', id: 't5' }),
391
+ term({ sourceTerm: 'foxtrot', id: 't6' }),
392
+ ];
393
+ const PAGE = 2;
394
+ return {
395
+ async listGlossaryTerms({ page = 1 }): Promise<CorpusPage<GlossaryTermRecord>> {
396
+ const start = (page - 1) * PAGE;
397
+ const results = all.slice(start, start + PAGE);
398
+ return {
399
+ count: all.length,
400
+ // The server ignores `pageSize`, exactly as the live tenant does.
401
+ next: start + PAGE < all.length ? `?page=${page + 1}` : null,
402
+ previous: page > 1 ? `?page=${page - 1}` : null,
403
+ results,
404
+ };
405
+ },
406
+ async listMemoryUnits(): Promise<CorpusPage<MemoryUnitRecord>> {
407
+ return { count: 0, next: null, previous: null, results: [] };
408
+ },
409
+ };
410
+ }
411
+
412
+ it('reads the glossary PAST the first page', async () => {
413
+ const read = await readAllGlossaryTerms(pagedApi(), TANDEM);
414
+
415
+ expect(read.kept).toHaveLength(6);
416
+ expect(read.kept.map((t) => t.id)).toEqual(['t1', 't2', 't3', 't4', 't5', 't6']);
417
+ });
418
+
419
+ it('scope-filters every page, not just the first', async () => {
420
+ const read = await readAllGlossaryTerms(pagedApi(), NORTHWIND);
421
+
422
+ // Every fixture term belongs to tandem, so a northwind principal gets none
423
+ // — and is TOLD that six rows were dropped rather than shown an empty
424
+ // glossary that looks like a clean read.
425
+ expect(read.kept).toEqual([]);
426
+ expect(read.droppedOutOfScope).toBe(6);
427
+ });
428
+ });
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;