@startsimpli/integrations 0.1.3 → 0.1.5
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 +11 -4
- package/src/__tests__/connections.test.tsx +116 -1
- package/src/__tests__/corpus-ask.test.ts +279 -0
- package/src/__tests__/corpus-pages.test.tsx +479 -0
- package/src/__tests__/corpus-scope.test.ts +216 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/AskPage.tsx +384 -0
- package/src/components/pages/ConnectionsPage.tsx +186 -2
- package/src/components/pages/LibraryPage.tsx +612 -0
- package/src/components/pages/types.ts +21 -0
- package/src/corpus/ask.ts +321 -0
- package/src/corpus/client.ts +196 -0
- package/src/corpus/entities.ts +346 -0
- package/src/corpus/fixtures.ts +486 -0
- package/src/corpus/index.ts +79 -0
- package/src/corpus/llm.ts +197 -0
- package/src/corpus/retrieval.ts +237 -0
- package/src/corpus/types.ts +354 -0
- package/src/index.ts +9 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a real model is called (bd startsim-768w.38.7).
|
|
3
|
+
*
|
|
4
|
+
* SERVER-ONLY. This module is reachable at `@startsimpli/integrations/corpus-llm`
|
|
5
|
+
* and is deliberately NOT re-exported from the package index, so importing the
|
|
6
|
+
* composers can never drag a provider client — or a provider key — into a
|
|
7
|
+
* browser bundle.
|
|
8
|
+
*
|
|
9
|
+
* IT ADDS NO PROVIDER CLIENT. `@startsimpli/llm` already owns provider
|
|
10
|
+
* selection, retries, JSON extraction and the OpenAI/Anthropic HTTP shapes.
|
|
11
|
+
* Everything below is an adapter from `LLMService` to the `CorpusLlm` seam.
|
|
12
|
+
*
|
|
13
|
+
* ONE CALL, N DOCUMENTS. `CorpusLlm.answer` takes the whole retrieved set and
|
|
14
|
+
* makes a single request with echoed ids (feedback_batch_llm_calls). The only
|
|
15
|
+
* second request that can ever happen is a JSON-repair retry of that same call,
|
|
16
|
+
* which is a correction, not a per-document loop.
|
|
17
|
+
*/
|
|
18
|
+
import { extractJson, LLMService } from '@startsimpli/llm';
|
|
19
|
+
|
|
20
|
+
import { answerSystemPrompt, renderAnswerPrompt } from './ask';
|
|
21
|
+
import type { AnswerDraft, AnswerRequest, CorpusLlm, LlmDescription } from './types';
|
|
22
|
+
|
|
23
|
+
export interface CorpusLlmOptions {
|
|
24
|
+
/** Reuse an already-configured service; otherwise one is constructed. */
|
|
25
|
+
service?: Pick<LLMService<unknown>, 'chat' | 'isAvailable' | 'getAvailableProviders'>;
|
|
26
|
+
preferredProvider?: 'openai' | 'anthropic' | 'mock';
|
|
27
|
+
model?: string;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a provider key looks like, so a misfiled one is reported instead of
|
|
34
|
+
* failing at request time with an opaque 401.
|
|
35
|
+
*
|
|
36
|
+
* This exists because of a real incident (reference_prod_anthropic_key_is_openai):
|
|
37
|
+
* the value deployed as `ANTHROPIC_API_KEY` in prod is an OpenAI `sk-proj` key.
|
|
38
|
+
* Anything built on "ANTHROPIC_API_KEY is set, so Anthropic is reachable" is
|
|
39
|
+
* wrong there, so the assumption is checked rather than made.
|
|
40
|
+
*/
|
|
41
|
+
export interface ProviderDiagnosis {
|
|
42
|
+
provider: 'openai' | 'anthropic';
|
|
43
|
+
envVar: string;
|
|
44
|
+
present: boolean;
|
|
45
|
+
/** True when the key's own prefix contradicts the variable it is filed under. */
|
|
46
|
+
looksMisfiled: boolean;
|
|
47
|
+
detail: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function diagnoseProviders(
|
|
51
|
+
env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
|
|
52
|
+
): ProviderDiagnosis[] {
|
|
53
|
+
const openaiKey = env.OPENAI_API_KEY ?? '';
|
|
54
|
+
const anthropicKey = env.ANTHROPIC_API_KEY ?? '';
|
|
55
|
+
|
|
56
|
+
const anthropicLooksOpenAI = anthropicKey.startsWith('sk-proj') || anthropicKey.startsWith('sk-None');
|
|
57
|
+
const openaiLooksAnthropic = openaiKey.startsWith('sk-ant');
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
{
|
|
61
|
+
provider: 'openai',
|
|
62
|
+
envVar: 'OPENAI_API_KEY',
|
|
63
|
+
present: Boolean(openaiKey),
|
|
64
|
+
looksMisfiled: openaiLooksAnthropic,
|
|
65
|
+
detail: !openaiKey
|
|
66
|
+
? 'not set'
|
|
67
|
+
: openaiLooksAnthropic
|
|
68
|
+
? 'value starts with sk-ant — this is an Anthropic key filed under OPENAI_API_KEY'
|
|
69
|
+
: 'set',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
provider: 'anthropic',
|
|
73
|
+
envVar: 'ANTHROPIC_API_KEY',
|
|
74
|
+
present: Boolean(anthropicKey),
|
|
75
|
+
looksMisfiled: anthropicLooksOpenAI,
|
|
76
|
+
detail: !anthropicKey
|
|
77
|
+
? 'not set'
|
|
78
|
+
: anthropicLooksOpenAI
|
|
79
|
+
? 'value starts with sk-proj — this is an OpenAI key filed under ANTHROPIC_API_KEY; Anthropic calls will 401'
|
|
80
|
+
: 'set',
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Which provider will ACTUALLY answer, given what is configured and what the
|
|
87
|
+
* keys really are. A key whose prefix contradicts its variable is treated as
|
|
88
|
+
* absent for that provider — pretending otherwise just moves the failure to
|
|
89
|
+
* request time.
|
|
90
|
+
*/
|
|
91
|
+
export function reachableProviders(
|
|
92
|
+
env: Record<string, string | undefined> = typeof process !== 'undefined' ? process.env : {},
|
|
93
|
+
): string[] {
|
|
94
|
+
if (env.LLM_PROVIDER === 'mock') return ['mock'];
|
|
95
|
+
return diagnoseProviders(env)
|
|
96
|
+
.filter((diagnosis) => diagnosis.present && !diagnosis.looksMisfiled)
|
|
97
|
+
.map((diagnosis) => diagnosis.provider);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Parse + shape-check the model's JSON. Anything unusable returns null. */
|
|
101
|
+
export function parseAnswerDraft(raw: string): AnswerDraft | null {
|
|
102
|
+
const extraction = extractJson(raw);
|
|
103
|
+
if (!extraction.success || !extraction.json || typeof extraction.json !== 'object') return null;
|
|
104
|
+
const json = extraction.json as Record<string, unknown>;
|
|
105
|
+
const answer = typeof json.answer === 'string' ? json.answer : '';
|
|
106
|
+
if (!answer && json.unanswerable !== true) return null;
|
|
107
|
+
const citations = Array.isArray(json.citations)
|
|
108
|
+
? json.citations
|
|
109
|
+
.map((entry) => {
|
|
110
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
111
|
+
const record = entry as Record<string, unknown>;
|
|
112
|
+
const documentId = String(record.documentId ?? record.document_id ?? '').trim();
|
|
113
|
+
if (!documentId) return null;
|
|
114
|
+
return { documentId, quote: typeof record.quote === 'string' ? record.quote : '' };
|
|
115
|
+
})
|
|
116
|
+
.filter((entry): entry is { documentId: string; quote: string } => entry !== null)
|
|
117
|
+
: [];
|
|
118
|
+
return { answer, citations, unanswerable: json.unanswerable === true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A `CorpusLlm` backed by `@startsimpli/llm`.
|
|
123
|
+
*
|
|
124
|
+
* `describe()` reports what really answered rather than what was requested, so
|
|
125
|
+
* the surface can state the model by name and can say `live: false` when the run
|
|
126
|
+
* came from the mock provider. Claiming a model that did not answer is exactly
|
|
127
|
+
* the kind of small lie that makes the citations unbelievable too.
|
|
128
|
+
*/
|
|
129
|
+
export function createCorpusLlm(options: CorpusLlmOptions = {}): CorpusLlm {
|
|
130
|
+
const service =
|
|
131
|
+
options.service ??
|
|
132
|
+
new LLMService<unknown>({
|
|
133
|
+
preferredProvider: options.preferredProvider ?? 'openai',
|
|
134
|
+
defaultModel: options.model ?? '',
|
|
135
|
+
// Low temperature: this is extraction over the user's own records, not
|
|
136
|
+
// creative writing. A model that improvises here invents a contract term.
|
|
137
|
+
defaultTemperature: options.temperature ?? 0.2,
|
|
138
|
+
defaultMaxTokens: options.maxTokens ?? 4096,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let lastModel = options.model ?? '';
|
|
142
|
+
|
|
143
|
+
const describe = (): LlmDescription => {
|
|
144
|
+
const available = service.getAvailableProviders();
|
|
145
|
+
const provider = available[0] ?? 'none';
|
|
146
|
+
return {
|
|
147
|
+
provider,
|
|
148
|
+
model: lastModel || options.model || '(provider default)',
|
|
149
|
+
live: provider !== 'none' && provider !== 'mock',
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
describe,
|
|
155
|
+
async answer(input: AnswerRequest): Promise<AnswerDraft> {
|
|
156
|
+
if (!service.isAvailable()) {
|
|
157
|
+
// Say what is wrong rather than returning an empty answer that reads
|
|
158
|
+
// like "your corpus has nothing about that".
|
|
159
|
+
throw new Error(
|
|
160
|
+
'No LLM provider is reachable. Configure OPENAI_API_KEY or ANTHROPIC_API_KEY for this tenant.',
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const system = answerSystemPrompt(input.mode);
|
|
165
|
+
const user = renderAnswerPrompt(input);
|
|
166
|
+
|
|
167
|
+
const first = await service.chat(user, system);
|
|
168
|
+
lastModel = first.model || lastModel;
|
|
169
|
+
const parsed = parseAnswerDraft(first.content);
|
|
170
|
+
if (parsed) return parsed;
|
|
171
|
+
|
|
172
|
+
// ONE repair attempt on the SAME call. Not a per-document loop — the
|
|
173
|
+
// documents were all in the first request and are all in this one.
|
|
174
|
+
const repair = await service.chat(
|
|
175
|
+
[
|
|
176
|
+
'Your previous response was not valid JSON in the required shape.',
|
|
177
|
+
'Return ONLY the JSON object, with keys "answer", "citations" and "unanswerable".',
|
|
178
|
+
'',
|
|
179
|
+
'Your previous response was:',
|
|
180
|
+
first.content,
|
|
181
|
+
].join('\n'),
|
|
182
|
+
system,
|
|
183
|
+
);
|
|
184
|
+
lastModel = repair.model || lastModel;
|
|
185
|
+
const repaired = parseAnswerDraft(repair.content);
|
|
186
|
+
if (repaired) return repaired;
|
|
187
|
+
|
|
188
|
+
// A model that will not produce a citable shape has produced nothing that
|
|
189
|
+
// can be presented as sourced. Refuse rather than degrade to uncited prose.
|
|
190
|
+
return {
|
|
191
|
+
answer: '',
|
|
192
|
+
citations: [],
|
|
193
|
+
unanswerable: true,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope enforcement and retrieval for the Ask surface (bd startsim-768w.38.7).
|
|
3
|
+
*
|
|
4
|
+
* WHAT WAS CHOSEN, AND WHY, since the bead demands this be written down:
|
|
5
|
+
*
|
|
6
|
+
* SCOPED KEYWORD + RECENCY over a few hundred documents. No embeddings, no
|
|
7
|
+
* vector store, no index to keep warm. The corpus is one tenant's own
|
|
8
|
+
* documents; the questions are about named companies, named people and named
|
|
9
|
+
* deals; and every term the user types appears verbatim in the records they
|
|
10
|
+
* mean. A BM25-ish term score with a recency prior answers those honestly, is
|
|
11
|
+
* deterministic, is explainable ("it matched these terms"), and needs no
|
|
12
|
+
* pipeline to be running before an answer is possible.
|
|
13
|
+
*
|
|
14
|
+
* WHAT BREAKS IT AT 10x: (a) synonyms and paraphrase — "what did we promise
|
|
15
|
+
* about pricing" will miss a note that says "rate card"; (b) recall over
|
|
16
|
+
* ~5k documents, where the page of candidates the server returns stops
|
|
17
|
+
* containing the right record at all; (c) long documents, where a single
|
|
18
|
+
* term hit in a 40-minute transcript outranks a whole-document match. The
|
|
19
|
+
* upgrade — embeddings + chunking, retrieved server-side — is filed rather
|
|
20
|
+
* than pre-built, because pre-building it before there is a corpus to measure
|
|
21
|
+
* is guessing.
|
|
22
|
+
*
|
|
23
|
+
* THE SCOPE GUARD IS NOT A CONVENIENCE. The tenant data API scopes reads by
|
|
24
|
+
* `org_id` and by readable type, and Postgres RLS enforces the same boundary at
|
|
25
|
+
* the DB. This is a THIRD check, immediately before the prompt is built, for one
|
|
26
|
+
* reason: an AI that retrieves across a scope boundary leaks data in the most
|
|
27
|
+
* convincing possible format. If any of the layers above ever hands back a row
|
|
28
|
+
* it should not have, the answer must still not contain it — and the count of
|
|
29
|
+
* what was dropped is surfaced rather than swallowed, because a non-zero drop is
|
|
30
|
+
* a bug somewhere upstream that someone needs to see.
|
|
31
|
+
*/
|
|
32
|
+
import type { CorpusScope, RetrievedDocument, SourceDocument } from './types';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Terms too common to carry meaning. Deliberately small: an aggressive stoplist
|
|
36
|
+
* silently deletes the user's question. `contract`, `invoice` and every domain
|
|
37
|
+
* word stay in, because they are exactly what distinguishes one record here.
|
|
38
|
+
*/
|
|
39
|
+
const STOPWORDS = new Set([
|
|
40
|
+
'a', 'about', 'all', 'am', 'an', 'and', 'any', 'are', 'as', 'at', 'be', 'been',
|
|
41
|
+
'but', 'by', 'can', 'did', 'do', 'does', 'for', 'from', 'had', 'has', 'have',
|
|
42
|
+
'he', 'her', 'his', 'how', 'i', 'if', 'in', 'is', 'it', 'its', 'me', 'my', 'of',
|
|
43
|
+
'on', 'or', 'our', 'she', 'so', 'that', 'the', 'their', 'them', 'then', 'there',
|
|
44
|
+
'these', 'they', 'this', 'to', 'us', 'was', 'we', 'were', 'what', 'when',
|
|
45
|
+
'where', 'which', 'who', 'why', 'will', 'with', 'you', 'your',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** Lowercase word tokens, 2+ chars, stopwords removed. */
|
|
49
|
+
export function tokenize(text: string): string[] {
|
|
50
|
+
return (text.toLowerCase().match(/[a-z0-9][a-z0-9'&.-]*/g) ?? [])
|
|
51
|
+
.map((token) => token.replace(/^[.'-]+|[.'-]+$/g, ''))
|
|
52
|
+
.filter((token) => token.length >= 2 && !STOPWORDS.has(token));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Is this record inside the asking principal's scope?
|
|
57
|
+
*
|
|
58
|
+
* `orgId` must match EXACTLY — it is the customer-workspace boundary (bd
|
|
59
|
+
* startsim-768w.34.4) and there is no hierarchy to walk here; a foundry
|
|
60
|
+
* founder wanting to read across customers does so by acting AS that org, which
|
|
61
|
+
* changes the claim and therefore changes this comparison.
|
|
62
|
+
*
|
|
63
|
+
* `ownerSub` is only enforced when the caller asked for it. That mirrors what
|
|
64
|
+
* the tenant API actually does today (org + readable-type, not per-owner), so
|
|
65
|
+
* this guard never claims a narrower posture than the server enforces — and
|
|
66
|
+
* when a caller does want per-owner, it is one field, not a new code path.
|
|
67
|
+
*/
|
|
68
|
+
export function isInScope(record: { scope: CorpusScope }, scope: CorpusScope): boolean {
|
|
69
|
+
if (!scope.orgId) return false; // an empty scope grants nothing, ever
|
|
70
|
+
if (record.scope?.orgId !== scope.orgId) return false;
|
|
71
|
+
if (scope.ownerSub && record.scope?.ownerSub !== scope.ownerSub) return false;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ScopeFilterResult<T> {
|
|
76
|
+
kept: T[];
|
|
77
|
+
dropped: T[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Partition a candidate set by the scope guard. Nothing is dropped silently. */
|
|
81
|
+
export function enforceScope<T extends { scope: CorpusScope }>(
|
|
82
|
+
records: T[],
|
|
83
|
+
scope: CorpusScope,
|
|
84
|
+
): ScopeFilterResult<T> {
|
|
85
|
+
const kept: T[] = [];
|
|
86
|
+
const dropped: T[] = [];
|
|
87
|
+
for (const record of records) {
|
|
88
|
+
(isInScope(record, scope) ? kept : dropped).push(record);
|
|
89
|
+
}
|
|
90
|
+
return { kept, dropped };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// ranking
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** Half-life of the recency prior, in days. */
|
|
98
|
+
const RECENCY_HALF_LIFE_DAYS = 120;
|
|
99
|
+
|
|
100
|
+
/** Fields are weighted because a title hit means more than a transcript hit. */
|
|
101
|
+
const FIELD_WEIGHTS = { title: 3, participants: 2, body: 1, sections: 0.6 } as const;
|
|
102
|
+
|
|
103
|
+
function recencyWeight(occurredAt: string | null | undefined, now: number): number {
|
|
104
|
+
if (!occurredAt) return 0.5; // undated: neither fresh nor stale, just less certain
|
|
105
|
+
const at = Date.parse(occurredAt);
|
|
106
|
+
if (Number.isNaN(at)) return 0.5;
|
|
107
|
+
const ageDays = Math.max(0, (now - at) / 86_400_000);
|
|
108
|
+
return 2 ** (-ageDays / RECENCY_HALF_LIFE_DAYS);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function countHits(tokens: string[], terms: Set<string>): { hits: number; matched: string[] } {
|
|
112
|
+
let hits = 0;
|
|
113
|
+
const matched = new Set<string>();
|
|
114
|
+
for (const token of tokens) {
|
|
115
|
+
if (terms.has(token)) {
|
|
116
|
+
hits += 1;
|
|
117
|
+
matched.add(token);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { hits, matched: [...matched] };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A passage around the first matching term, so the citation card shows the user
|
|
125
|
+
* WHY this record came back rather than making them open it to find out.
|
|
126
|
+
*/
|
|
127
|
+
export function snippetFor(document: SourceDocument, terms: Set<string>, length = 220): string {
|
|
128
|
+
const haystacks = [document.body, ...document.sections.map((section) => section.text)];
|
|
129
|
+
for (const text of haystacks) {
|
|
130
|
+
if (!text) continue;
|
|
131
|
+
const lower = text.toLowerCase();
|
|
132
|
+
for (const term of terms) {
|
|
133
|
+
const at = lower.indexOf(term);
|
|
134
|
+
if (at === -1) continue;
|
|
135
|
+
const start = Math.max(0, at - Math.floor(length / 3));
|
|
136
|
+
const slice = text.slice(start, start + length).trim();
|
|
137
|
+
return `${start > 0 ? '…' : ''}${slice}${start + length < text.length ? '…' : ''}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const fallback = document.body || document.sections[0]?.text || '';
|
|
141
|
+
return fallback.length > length ? `${fallback.slice(0, length).trim()}…` : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RetrieveOptions {
|
|
145
|
+
/** How many records the model is allowed to see. */
|
|
146
|
+
limit?: number;
|
|
147
|
+
/** Injectable clock, so recency is testable. */
|
|
148
|
+
now?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Score floor. A record that matched nothing is never included: padding the
|
|
151
|
+
* prompt with irrelevant documents is how a model ends up citing one.
|
|
152
|
+
*/
|
|
153
|
+
minScore?: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Rank an ALREADY SCOPE-FILTERED candidate set against a question.
|
|
158
|
+
*
|
|
159
|
+
* Takes scope-filtered input by construction rather than filtering internally,
|
|
160
|
+
* so a caller cannot accidentally reach the ranker with unfiltered records —
|
|
161
|
+
* `retrieveForQuestion` below is the entry point that does both in the right
|
|
162
|
+
* order.
|
|
163
|
+
*/
|
|
164
|
+
export function rankDocuments(
|
|
165
|
+
documents: SourceDocument[],
|
|
166
|
+
question: string,
|
|
167
|
+
options: RetrieveOptions = {},
|
|
168
|
+
): RetrievedDocument[] {
|
|
169
|
+
const { limit = 8, now = Date.now(), minScore = 0.0001 } = options;
|
|
170
|
+
const terms = new Set(tokenize(question));
|
|
171
|
+
if (terms.size === 0) {
|
|
172
|
+
// No usable terms — fall back to pure recency rather than returning
|
|
173
|
+
// nothing. "What happened lately" is a real question.
|
|
174
|
+
return [...documents]
|
|
175
|
+
.sort((a, b) => recencyWeight(b.occurredAt, now) - recencyWeight(a.occurredAt, now))
|
|
176
|
+
.slice(0, limit)
|
|
177
|
+
.map((document) => ({
|
|
178
|
+
document,
|
|
179
|
+
score: recencyWeight(document.occurredAt, now),
|
|
180
|
+
matchedTerms: [],
|
|
181
|
+
snippet: snippetFor(document, terms),
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const scored: RetrievedDocument[] = [];
|
|
186
|
+
for (const document of documents) {
|
|
187
|
+
const title = countHits(tokenize(document.title), terms);
|
|
188
|
+
const participants = countHits(
|
|
189
|
+
tokenize(document.participants.map((party) => `${party.name} ${party.email ?? ''}`).join(' ')),
|
|
190
|
+
terms,
|
|
191
|
+
);
|
|
192
|
+
const body = countHits(tokenize(document.body), terms);
|
|
193
|
+
const sections = countHits(
|
|
194
|
+
tokenize(document.sections.map((section) => section.text).join(' ')),
|
|
195
|
+
terms,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Saturating term counts (log1p) so one long transcript repeating a word
|
|
199
|
+
// cannot outrank a document that is actually about it.
|
|
200
|
+
const termScore =
|
|
201
|
+
FIELD_WEIGHTS.title * Math.log1p(title.hits) +
|
|
202
|
+
FIELD_WEIGHTS.participants * Math.log1p(participants.hits) +
|
|
203
|
+
FIELD_WEIGHTS.body * Math.log1p(body.hits) +
|
|
204
|
+
FIELD_WEIGHTS.sections * Math.log1p(sections.hits);
|
|
205
|
+
|
|
206
|
+
if (termScore <= 0) continue;
|
|
207
|
+
|
|
208
|
+
const matched = [
|
|
209
|
+
...new Set([...title.matched, ...participants.matched, ...body.matched, ...sections.matched]),
|
|
210
|
+
];
|
|
211
|
+
// Coverage rewards matching MORE OF the question rather than one term often.
|
|
212
|
+
const coverage = matched.length / terms.size;
|
|
213
|
+
const score = termScore * (0.5 + 0.5 * coverage) * (0.6 + 0.4 * recencyWeight(document.occurredAt, now));
|
|
214
|
+
|
|
215
|
+
if (score < minScore) continue;
|
|
216
|
+
scored.push({ document, score, matchedTerms: matched, snippet: snippetFor(document, terms) });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return scored
|
|
220
|
+
.sort((a, b) => b.score - a.score || (b.document.occurredAt ?? '').localeCompare(a.document.occurredAt ?? ''))
|
|
221
|
+
.slice(0, limit);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The entry point: guard, THEN rank. Returns the drop count so the caller can
|
|
226
|
+
* surface it — see the note at the top of this file about why a non-zero drop
|
|
227
|
+
* is worth someone's attention rather than a silent success.
|
|
228
|
+
*/
|
|
229
|
+
export function retrieveForQuestion(
|
|
230
|
+
candidates: SourceDocument[],
|
|
231
|
+
question: string,
|
|
232
|
+
scope: CorpusScope,
|
|
233
|
+
options: RetrieveOptions = {},
|
|
234
|
+
): { retrieved: RetrievedDocument[]; droppedOutOfScope: number } {
|
|
235
|
+
const { kept, dropped } = enforceScope(candidates, scope);
|
|
236
|
+
return { retrieved: rankDocuments(kept, question, options), droppedOutOfScope: dropped.length };
|
|
237
|
+
}
|