@startsimpli/integrations 0.2.18 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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/__tests__/translation-corpus.test.ts +428 -0
- 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/translation.ts +321 -0
- package/src/corpus/types.ts +8 -0
|
@@ -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:
|
|
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;
|