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