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