@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.
@@ -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
+ }