@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
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
|