@stacksjs/ai 0.70.53 → 0.70.55
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 +3 -2
- package/src/agents/claude/index.ts +310 -0
- package/src/agents/index.ts +7 -0
- package/src/buddy.ts +619 -0
- package/src/drivers/anthropic/index.ts +430 -0
- package/src/drivers/claude-agent-sdk/index.ts +370 -0
- package/src/drivers/index.ts +14 -0
- package/src/drivers/ollama/index.ts +514 -0
- package/src/drivers/openai/index.ts +529 -0
- package/src/image.ts +607 -0
- package/src/index.ts +53 -0
- package/src/mcp.ts +658 -0
- package/src/personalization.ts +490 -0
- package/src/search.ts +555 -0
- package/src/text.ts +79 -0
- package/src/types.ts +229 -0
- package/src/utils/client-bedrock-runtime.ts +51 -0
- package/src/utils/client-bedrock.ts +75 -0
- package/src/utils/model-access.ts +32 -0
- package/src/utils/retry.ts +124 -0
- package/src/utils/tokens.ts +159 -0
- package/src/utils/usage.ts +98 -0
- package/src/utils/vision.ts +119 -0
package/src/search.ts
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Search Module
|
|
3
|
+
*
|
|
4
|
+
* Embedding-based semantic search, vector similarity, and RAG (Retrieval-Augmented Generation).
|
|
5
|
+
* Supports OpenAI and Ollama embedding models.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AIResult } from './types'
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export interface EmbeddingOptions {
|
|
15
|
+
provider?: 'openai' | 'ollama'
|
|
16
|
+
model?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SearchDocument {
|
|
20
|
+
id: string
|
|
21
|
+
content: string
|
|
22
|
+
metadata?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface IndexedDocument extends SearchDocument {
|
|
26
|
+
embedding: number[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SearchResult {
|
|
30
|
+
document: SearchDocument
|
|
31
|
+
score: number
|
|
32
|
+
rank: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RAGOptions {
|
|
36
|
+
provider?: 'anthropic' | 'openai' | 'ollama'
|
|
37
|
+
embeddingProvider?: 'openai' | 'ollama'
|
|
38
|
+
embeddingModel?: string
|
|
39
|
+
model?: string
|
|
40
|
+
maxTokens?: number
|
|
41
|
+
temperature?: number
|
|
42
|
+
topK?: number
|
|
43
|
+
systemPrompt?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RAGResult extends AIResult {
|
|
47
|
+
sources: SearchResult[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Embeddings
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate embeddings for text input.
|
|
56
|
+
*/
|
|
57
|
+
export async function createEmbedding(
|
|
58
|
+
input: string | string[],
|
|
59
|
+
options: EmbeddingOptions = {},
|
|
60
|
+
): Promise<number[] | number[][]> {
|
|
61
|
+
const { provider = 'openai' } = options
|
|
62
|
+
|
|
63
|
+
if (provider === 'openai') {
|
|
64
|
+
return createEmbeddingOpenAI(input, options.model || 'text-embedding-3-small')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (provider === 'ollama') {
|
|
68
|
+
return createEmbeddingOllama(input, options.model || 'nomic-embed-text')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`Embedding provider not supported: ${provider}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function createEmbeddingOpenAI(input: string | string[], model: string): Promise<number[] | number[][]> {
|
|
75
|
+
const apiKey = process.env.OPENAI_API_KEY
|
|
76
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY environment variable is required for embeddings.')
|
|
77
|
+
|
|
78
|
+
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify({ model, input }),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const error = await response.text()
|
|
89
|
+
throw new Error(`OpenAI Embeddings API error: ${error}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = (await response.json()) as { data: Array<{ embedding: number[] }> }
|
|
93
|
+
|
|
94
|
+
if (Array.isArray(input)) {
|
|
95
|
+
return data.data.map(d => d.embedding)
|
|
96
|
+
}
|
|
97
|
+
return data.data[0].embedding
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function createEmbeddingOllama(input: string | string[], model: string): Promise<number[] | number[][]> {
|
|
101
|
+
const host = process.env.OLLAMA_HOST || 'http://localhost:11434'
|
|
102
|
+
const inputs = Array.isArray(input) ? input : [input]
|
|
103
|
+
const embeddings: number[][] = []
|
|
104
|
+
|
|
105
|
+
for (const text of inputs) {
|
|
106
|
+
const response = await fetch(`${host}/api/embeddings`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const error = await response.text()
|
|
114
|
+
throw new Error(`Ollama Embeddings API error: ${error}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = (await response.json()) as { embedding: number[] }
|
|
118
|
+
embeddings.push(data.embedding)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Array.isArray(input) ? embeddings : embeddings[0]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Vector Similarity
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Calculate cosine similarity between two vectors.
|
|
130
|
+
*/
|
|
131
|
+
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
132
|
+
if (a.length !== b.length) {
|
|
133
|
+
throw new Error(`Vector dimensions must match: ${a.length} vs ${b.length}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let dotProduct = 0
|
|
137
|
+
let normA = 0
|
|
138
|
+
let normB = 0
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < a.length; i++) {
|
|
141
|
+
dotProduct += a[i] * b[i]
|
|
142
|
+
normA += a[i] * a[i]
|
|
143
|
+
normB += b[i] * b[i]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const denominator = Math.sqrt(normA) * Math.sqrt(normB)
|
|
147
|
+
if (denominator === 0) return 0
|
|
148
|
+
|
|
149
|
+
return dotProduct / denominator
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Calculate dot product similarity between two vectors.
|
|
154
|
+
*/
|
|
155
|
+
export function dotProduct(a: number[], b: number[]): number {
|
|
156
|
+
if (a.length !== b.length) {
|
|
157
|
+
throw new Error(`Vector dimensions must match: ${a.length} vs ${b.length}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let result = 0
|
|
161
|
+
for (let i = 0; i < a.length; i++) {
|
|
162
|
+
result += a[i] * b[i]
|
|
163
|
+
}
|
|
164
|
+
return result
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Calculate Euclidean distance between two vectors.
|
|
169
|
+
*/
|
|
170
|
+
export function euclideanDistance(a: number[], b: number[]): number {
|
|
171
|
+
if (a.length !== b.length) {
|
|
172
|
+
throw new Error(`Vector dimensions must match: ${a.length} vs ${b.length}`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let sum = 0
|
|
176
|
+
for (let i = 0; i < a.length; i++) {
|
|
177
|
+
const diff = a[i] - b[i]
|
|
178
|
+
sum += diff * diff
|
|
179
|
+
}
|
|
180
|
+
return Math.sqrt(sum)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================================
|
|
184
|
+
// In-Memory Vector Index
|
|
185
|
+
// ============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Simple in-memory vector search index.
|
|
189
|
+
* For production, use a dedicated vector database.
|
|
190
|
+
*/
|
|
191
|
+
export class VectorIndex {
|
|
192
|
+
private documents: IndexedDocument[] = []
|
|
193
|
+
private embeddingOptions: EmbeddingOptions
|
|
194
|
+
|
|
195
|
+
constructor(options: EmbeddingOptions = {}) {
|
|
196
|
+
this.embeddingOptions = options
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Add documents to the index, computing embeddings automatically.
|
|
201
|
+
*/
|
|
202
|
+
async add(documents: SearchDocument[]): Promise<void> {
|
|
203
|
+
if (documents.length === 0) return
|
|
204
|
+
|
|
205
|
+
const contents = documents.map(d => d.content)
|
|
206
|
+
const embeddings = await createEmbedding(contents, this.embeddingOptions)
|
|
207
|
+
|
|
208
|
+
// createEmbedding returns number[][] for array input, number[] for single input
|
|
209
|
+
// Since we always pass an array, we get number[][]
|
|
210
|
+
const embeddingsList = contents.length === 1
|
|
211
|
+
? [embeddings as number[]]
|
|
212
|
+
: (embeddings as number[][])
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < documents.length; i++) {
|
|
215
|
+
this.documents.push({
|
|
216
|
+
...documents[i],
|
|
217
|
+
embedding: embeddingsList[i],
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Add a pre-embedded document to the index.
|
|
224
|
+
*/
|
|
225
|
+
addWithEmbedding(document: SearchDocument, embedding: number[]): void {
|
|
226
|
+
this.documents.push({ ...document, embedding })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Search for documents similar to a query.
|
|
231
|
+
*/
|
|
232
|
+
async search(query: string, topK = 5): Promise<SearchResult[]> {
|
|
233
|
+
if (this.documents.length === 0) return []
|
|
234
|
+
|
|
235
|
+
const queryEmbedding = await createEmbedding(query, this.embeddingOptions)
|
|
236
|
+
return this.searchByVector(queryEmbedding as number[], topK)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Search using a pre-computed embedding vector.
|
|
241
|
+
*/
|
|
242
|
+
searchByVector(queryEmbedding: number[], topK = 5): SearchResult[] {
|
|
243
|
+
if (this.documents.length === 0) return []
|
|
244
|
+
|
|
245
|
+
const scored = this.documents.map(doc => ({
|
|
246
|
+
document: { id: doc.id, content: doc.content, metadata: doc.metadata },
|
|
247
|
+
score: cosineSimilarity(queryEmbedding, doc.embedding),
|
|
248
|
+
}))
|
|
249
|
+
|
|
250
|
+
scored.sort((a, b) => b.score - a.score)
|
|
251
|
+
|
|
252
|
+
return scored.slice(0, topK).map((result, index) => ({
|
|
253
|
+
...result,
|
|
254
|
+
rank: index + 1,
|
|
255
|
+
}))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Remove a document by ID.
|
|
260
|
+
*/
|
|
261
|
+
remove(id: string): boolean {
|
|
262
|
+
const initialLength = this.documents.length
|
|
263
|
+
this.documents = this.documents.filter(d => d.id !== id)
|
|
264
|
+
return this.documents.length < initialLength
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Clear all documents from the index.
|
|
269
|
+
*/
|
|
270
|
+
clear(): void {
|
|
271
|
+
this.documents = []
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get the number of indexed documents.
|
|
276
|
+
*/
|
|
277
|
+
get size(): number {
|
|
278
|
+
return this.documents.length
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get all document IDs.
|
|
283
|
+
*/
|
|
284
|
+
get ids(): string[] {
|
|
285
|
+
return this.documents.map(d => d.id)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ============================================================================
|
|
290
|
+
// RAG (Retrieval-Augmented Generation)
|
|
291
|
+
// ============================================================================
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Perform RAG: search for relevant documents and use them as context for generation.
|
|
295
|
+
*/
|
|
296
|
+
export async function rag(
|
|
297
|
+
query: string,
|
|
298
|
+
index: VectorIndex,
|
|
299
|
+
options: RAGOptions = {},
|
|
300
|
+
): Promise<RAGResult> {
|
|
301
|
+
const {
|
|
302
|
+
provider = 'anthropic',
|
|
303
|
+
topK = 5,
|
|
304
|
+
maxTokens = 4096,
|
|
305
|
+
temperature,
|
|
306
|
+
systemPrompt,
|
|
307
|
+
} = options
|
|
308
|
+
|
|
309
|
+
const searchResults = await index.search(query, topK)
|
|
310
|
+
|
|
311
|
+
const contextText = searchResults
|
|
312
|
+
.map((r, i) => `[Source ${i + 1}] (score: ${r.score.toFixed(3)})\n${r.document.content}`)
|
|
313
|
+
.join('\n\n---\n\n')
|
|
314
|
+
|
|
315
|
+
const ragSystemPrompt = systemPrompt || `You are a helpful assistant. Answer the user's question based on the provided context. If the context doesn't contain relevant information, say so. Always cite your sources by referencing [Source N].`
|
|
316
|
+
|
|
317
|
+
const fullPrompt = `Context:\n${contextText}\n\nQuestion: ${query}`
|
|
318
|
+
|
|
319
|
+
if (provider === 'anthropic') {
|
|
320
|
+
return ragWithAnthropic(fullPrompt, ragSystemPrompt, searchResults, { maxTokens, temperature, model: options.model })
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (provider === 'openai') {
|
|
324
|
+
return ragWithOpenAI(fullPrompt, ragSystemPrompt, searchResults, { maxTokens, temperature, model: options.model })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (provider === 'ollama') {
|
|
328
|
+
return ragWithOllama(fullPrompt, ragSystemPrompt, searchResults, { maxTokens, temperature, model: options.model })
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
throw new Error(`RAG provider not supported: ${provider}`)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function ragWithAnthropic(
|
|
335
|
+
prompt: string,
|
|
336
|
+
systemPrompt: string,
|
|
337
|
+
sources: SearchResult[],
|
|
338
|
+
options: { maxTokens?: number; temperature?: number; model?: string },
|
|
339
|
+
): Promise<RAGResult> {
|
|
340
|
+
const apiKey = process.env.ANTHROPIC_API_KEY
|
|
341
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY required for RAG with Anthropic.')
|
|
342
|
+
|
|
343
|
+
const model = options.model || 'claude-sonnet-4-20250514'
|
|
344
|
+
|
|
345
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: {
|
|
348
|
+
'Content-Type': 'application/json',
|
|
349
|
+
'x-api-key': apiKey,
|
|
350
|
+
'anthropic-version': '2023-06-01',
|
|
351
|
+
},
|
|
352
|
+
body: JSON.stringify({
|
|
353
|
+
model,
|
|
354
|
+
max_tokens: options.maxTokens || 4096,
|
|
355
|
+
temperature: options.temperature,
|
|
356
|
+
system: systemPrompt,
|
|
357
|
+
messages: [{ role: 'user', content: prompt }],
|
|
358
|
+
}),
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
const error = await response.text()
|
|
363
|
+
throw new Error(`Claude API error: ${error}`)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const data = (await response.json()) as any
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
content: data.content[0].text,
|
|
370
|
+
model: data.model,
|
|
371
|
+
usage: {
|
|
372
|
+
promptTokens: data.usage?.input_tokens || 0,
|
|
373
|
+
completionTokens: data.usage?.output_tokens || 0,
|
|
374
|
+
totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
|
|
375
|
+
},
|
|
376
|
+
finishReason: data.stop_reason,
|
|
377
|
+
sources,
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function ragWithOpenAI(
|
|
382
|
+
prompt: string,
|
|
383
|
+
systemPrompt: string,
|
|
384
|
+
sources: SearchResult[],
|
|
385
|
+
options: { maxTokens?: number; temperature?: number; model?: string },
|
|
386
|
+
): Promise<RAGResult> {
|
|
387
|
+
const apiKey = process.env.OPENAI_API_KEY
|
|
388
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY required for RAG with OpenAI.')
|
|
389
|
+
|
|
390
|
+
const model = options.model || 'gpt-4o'
|
|
391
|
+
|
|
392
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
393
|
+
method: 'POST',
|
|
394
|
+
headers: {
|
|
395
|
+
'Content-Type': 'application/json',
|
|
396
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
397
|
+
},
|
|
398
|
+
body: JSON.stringify({
|
|
399
|
+
model,
|
|
400
|
+
max_tokens: options.maxTokens || 4096,
|
|
401
|
+
temperature: options.temperature,
|
|
402
|
+
messages: [
|
|
403
|
+
{ role: 'system', content: systemPrompt },
|
|
404
|
+
{ role: 'user', content: prompt },
|
|
405
|
+
],
|
|
406
|
+
}),
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
const error = await response.text()
|
|
411
|
+
throw new Error(`OpenAI API error: ${error}`)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const data = (await response.json()) as any
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
content: data.choices[0].message.content,
|
|
418
|
+
model: data.model,
|
|
419
|
+
usage: {
|
|
420
|
+
promptTokens: data.usage?.prompt_tokens || 0,
|
|
421
|
+
completionTokens: data.usage?.completion_tokens || 0,
|
|
422
|
+
totalTokens: data.usage?.total_tokens || 0,
|
|
423
|
+
},
|
|
424
|
+
finishReason: data.choices[0].finish_reason,
|
|
425
|
+
sources,
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function ragWithOllama(
|
|
430
|
+
prompt: string,
|
|
431
|
+
systemPrompt: string,
|
|
432
|
+
sources: SearchResult[],
|
|
433
|
+
options: { maxTokens?: number; temperature?: number; model?: string },
|
|
434
|
+
): Promise<RAGResult> {
|
|
435
|
+
const host = process.env.OLLAMA_HOST || 'http://localhost:11434'
|
|
436
|
+
const model = options.model || 'llama3.2'
|
|
437
|
+
|
|
438
|
+
const response = await fetch(`${host}/api/chat`, {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
headers: { 'Content-Type': 'application/json' },
|
|
441
|
+
body: JSON.stringify({
|
|
442
|
+
model,
|
|
443
|
+
messages: [
|
|
444
|
+
{ role: 'system', content: systemPrompt },
|
|
445
|
+
{ role: 'user', content: prompt },
|
|
446
|
+
],
|
|
447
|
+
stream: false,
|
|
448
|
+
options: {
|
|
449
|
+
temperature: options.temperature,
|
|
450
|
+
},
|
|
451
|
+
}),
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
if (!response.ok) {
|
|
455
|
+
const error = await response.text()
|
|
456
|
+
throw new Error(`Ollama API error: ${error}`)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const data = (await response.json()) as any
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
content: data.message.content,
|
|
463
|
+
model: data.model,
|
|
464
|
+
usage: {
|
|
465
|
+
promptTokens: data.prompt_eval_count || 0,
|
|
466
|
+
completionTokens: data.eval_count || 0,
|
|
467
|
+
totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
|
|
468
|
+
},
|
|
469
|
+
finishReason: data.done_reason || 'stop',
|
|
470
|
+
sources,
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ============================================================================
|
|
475
|
+
// Text Chunking Utilities
|
|
476
|
+
// ============================================================================
|
|
477
|
+
|
|
478
|
+
export interface ChunkOptions {
|
|
479
|
+
chunkSize?: number
|
|
480
|
+
chunkOverlap?: number
|
|
481
|
+
separator?: string
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Split text into overlapping chunks for embedding.
|
|
486
|
+
*/
|
|
487
|
+
export function chunkText(text: string, options: ChunkOptions = {}): string[] {
|
|
488
|
+
const {
|
|
489
|
+
chunkSize = 1000,
|
|
490
|
+
chunkOverlap = 200,
|
|
491
|
+
separator = '\n',
|
|
492
|
+
} = options
|
|
493
|
+
|
|
494
|
+
const segments = text.split(separator)
|
|
495
|
+
const chunks: string[] = []
|
|
496
|
+
let currentChunk = ''
|
|
497
|
+
|
|
498
|
+
for (const segment of segments) {
|
|
499
|
+
if (currentChunk.length + segment.length + 1 > chunkSize && currentChunk.length > 0) {
|
|
500
|
+
chunks.push(currentChunk.trim())
|
|
501
|
+
// Keep overlap from end of current chunk
|
|
502
|
+
if (chunkOverlap > 0) {
|
|
503
|
+
const overlapStart = Math.max(0, currentChunk.length - chunkOverlap)
|
|
504
|
+
currentChunk = currentChunk.slice(overlapStart) + separator + segment
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
currentChunk = segment
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
else {
|
|
511
|
+
currentChunk += (currentChunk ? separator : '') + segment
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (currentChunk.trim()) {
|
|
516
|
+
chunks.push(currentChunk.trim())
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return chunks
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Create a searchable index from a long text by chunking and embedding.
|
|
524
|
+
*/
|
|
525
|
+
export async function indexText(
|
|
526
|
+
text: string,
|
|
527
|
+
options: ChunkOptions & EmbeddingOptions & { idPrefix?: string } = {},
|
|
528
|
+
): Promise<VectorIndex> {
|
|
529
|
+
const chunks = chunkText(text, options)
|
|
530
|
+
const index = new VectorIndex(options)
|
|
531
|
+
|
|
532
|
+
const documents: SearchDocument[] = chunks.map((chunk, i) => ({
|
|
533
|
+
id: `${options.idPrefix || 'chunk'}-${i}`,
|
|
534
|
+
content: chunk,
|
|
535
|
+
metadata: { chunkIndex: i, totalChunks: chunks.length },
|
|
536
|
+
}))
|
|
537
|
+
|
|
538
|
+
await index.add(documents)
|
|
539
|
+
return index
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ============================================================================
|
|
543
|
+
// Exports
|
|
544
|
+
// ============================================================================
|
|
545
|
+
|
|
546
|
+
export const search = {
|
|
547
|
+
createEmbedding,
|
|
548
|
+
cosineSimilarity,
|
|
549
|
+
dotProduct,
|
|
550
|
+
euclideanDistance,
|
|
551
|
+
VectorIndex,
|
|
552
|
+
rag,
|
|
553
|
+
chunkText,
|
|
554
|
+
indexText,
|
|
555
|
+
}
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { invokeModel } from './utils/client-bedrock-runtime'
|
|
2
|
+
|
|
3
|
+
interface AiOptions {
|
|
4
|
+
maxTokenCount?: number
|
|
5
|
+
temperature?: number
|
|
6
|
+
topP?: number
|
|
7
|
+
/**
|
|
8
|
+
* Override the Bedrock model. Defaults to `config.ai?.bedrock?.model` →
|
|
9
|
+
* `BEDROCK_MODEL_ID` env → `amazon.titan-text-express-v1` (the prior
|
|
10
|
+
* hard-coded default). Letting callers swap models per-call lets ops
|
|
11
|
+
* pin a different model in production than in dev without code changes.
|
|
12
|
+
*/
|
|
13
|
+
modelId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SummarizeOptions extends AiOptions {}
|
|
17
|
+
export interface AskOptions extends AiOptions {}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL = 'amazon.titan-text-express-v1'
|
|
20
|
+
|
|
21
|
+
function resolveModel(override?: string): string {
|
|
22
|
+
if (override) return override
|
|
23
|
+
const cfg = (globalThis as { config?: any }).config?.ai?.bedrock?.model
|
|
24
|
+
return cfg || process.env.BEDROCK_MODEL_ID || DEFAULT_MODEL
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function summarize(text: string, options: SummarizeOptions = {}): Promise<string> {
|
|
28
|
+
const { maxTokenCount = 512, temperature = 0, topP = 0.9, modelId } = options
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const response = await invokeModel({
|
|
32
|
+
modelId: resolveModel(modelId),
|
|
33
|
+
contentType: 'application/json',
|
|
34
|
+
accept: '*/*',
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
inputText: `Summarize the following text: ${text}`,
|
|
37
|
+
textGenerationConfig: {
|
|
38
|
+
maxTokenCount,
|
|
39
|
+
stopSequences: [],
|
|
40
|
+
temperature,
|
|
41
|
+
topP,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as any
|
|
47
|
+
return responseBody.results[0].outputText
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Error(`Error summarizing text: ${(error as Error).message}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function ask(question: string, options: AskOptions = {}): Promise<string> {
|
|
55
|
+
const { maxTokenCount = 512, temperature = 0, topP = 0.9, modelId } = options
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const response = await invokeModel({
|
|
59
|
+
modelId: resolveModel(modelId),
|
|
60
|
+
contentType: 'application/json',
|
|
61
|
+
accept: '*/*',
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
inputText: question,
|
|
64
|
+
textGenerationConfig: {
|
|
65
|
+
maxTokenCount,
|
|
66
|
+
stopSequences: [],
|
|
67
|
+
temperature,
|
|
68
|
+
topP,
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const responseBody = JSON.parse(new TextDecoder().decode(response.body)) as any
|
|
74
|
+
return responseBody.results[0].outputText
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
throw new Error(`Error asking question: ${(error as Error).message}`)
|
|
78
|
+
}
|
|
79
|
+
}
|