flowcraft 1.0.0-beta.1
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/.editorconfig +9 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/config/tsconfig.json +21 -0
- package/config/tsup.config.ts +11 -0
- package/config/vitest.config.ts +11 -0
- package/docs/.vitepress/config.ts +105 -0
- package/docs/api-reference/builder.md +158 -0
- package/docs/api-reference/fn.md +142 -0
- package/docs/api-reference/index.md +38 -0
- package/docs/api-reference/workflow.md +126 -0
- package/docs/guide/advanced-guides/cancellation.md +117 -0
- package/docs/guide/advanced-guides/composition.md +68 -0
- package/docs/guide/advanced-guides/custom-executor.md +180 -0
- package/docs/guide/advanced-guides/error-handling.md +135 -0
- package/docs/guide/advanced-guides/logging.md +106 -0
- package/docs/guide/advanced-guides/middleware.md +106 -0
- package/docs/guide/advanced-guides/observability.md +175 -0
- package/docs/guide/best-practices/debugging.md +182 -0
- package/docs/guide/best-practices/state-management.md +120 -0
- package/docs/guide/best-practices/sub-workflow-data.md +95 -0
- package/docs/guide/best-practices/testing.md +187 -0
- package/docs/guide/builders.md +157 -0
- package/docs/guide/functional-api.md +133 -0
- package/docs/guide/index.md +178 -0
- package/docs/guide/recipes/creating-a-loop.md +113 -0
- package/docs/guide/recipes/data-processing-pipeline.md +123 -0
- package/docs/guide/recipes/fan-out-fan-in.md +112 -0
- package/docs/guide/recipes/index.md +15 -0
- package/docs/guide/recipes/resilient-api-call.md +110 -0
- package/docs/guide/tooling/graph-validation.md +160 -0
- package/docs/guide/tooling/mermaid.md +156 -0
- package/docs/index.md +56 -0
- package/eslint.config.js +16 -0
- package/package.json +40 -0
- package/pnpm-workspace.yaml +2 -0
- package/sandbox/1.basic/README.md +45 -0
- package/sandbox/1.basic/package.json +16 -0
- package/sandbox/1.basic/src/flow.ts +17 -0
- package/sandbox/1.basic/src/main.ts +22 -0
- package/sandbox/1.basic/src/nodes.ts +112 -0
- package/sandbox/1.basic/src/utils.ts +35 -0
- package/sandbox/1.basic/tsconfig.json +3 -0
- package/sandbox/2.research/README.md +46 -0
- package/sandbox/2.research/package.json +16 -0
- package/sandbox/2.research/src/flow.ts +14 -0
- package/sandbox/2.research/src/main.ts +31 -0
- package/sandbox/2.research/src/nodes.ts +108 -0
- package/sandbox/2.research/src/utils.ts +45 -0
- package/sandbox/2.research/src/visualize.ts +29 -0
- package/sandbox/2.research/tsconfig.json +3 -0
- package/sandbox/3.parallel/README.md +65 -0
- package/sandbox/3.parallel/package.json +16 -0
- package/sandbox/3.parallel/src/main.ts +45 -0
- package/sandbox/3.parallel/src/nodes.ts +43 -0
- package/sandbox/3.parallel/src/utils.ts +25 -0
- package/sandbox/3.parallel/tsconfig.json +3 -0
- package/sandbox/4.dag/README.md +179 -0
- package/sandbox/4.dag/data/1.blog-post/100.json +60 -0
- package/sandbox/4.dag/data/1.blog-post/README.md +25 -0
- package/sandbox/4.dag/data/2.job-application/200.json +103 -0
- package/sandbox/4.dag/data/2.job-application/201.json +31 -0
- package/sandbox/4.dag/data/2.job-application/202.json +31 -0
- package/sandbox/4.dag/data/2.job-application/README.md +58 -0
- package/sandbox/4.dag/data/3.customer-review/300.json +141 -0
- package/sandbox/4.dag/data/3.customer-review/301.json +31 -0
- package/sandbox/4.dag/data/3.customer-review/302.json +28 -0
- package/sandbox/4.dag/data/3.customer-review/README.md +71 -0
- package/sandbox/4.dag/data/4.content-moderation/400.json +161 -0
- package/sandbox/4.dag/data/4.content-moderation/401.json +47 -0
- package/sandbox/4.dag/data/4.content-moderation/402.json +46 -0
- package/sandbox/4.dag/data/4.content-moderation/403.json +31 -0
- package/sandbox/4.dag/data/4.content-moderation/README.md +83 -0
- package/sandbox/4.dag/package.json +19 -0
- package/sandbox/4.dag/src/main.ts +73 -0
- package/sandbox/4.dag/src/nodes.ts +134 -0
- package/sandbox/4.dag/src/registry.ts +87 -0
- package/sandbox/4.dag/src/types.ts +25 -0
- package/sandbox/4.dag/src/utils.ts +42 -0
- package/sandbox/4.dag/tsconfig.json +3 -0
- package/sandbox/5.distributed/.env.example +1 -0
- package/sandbox/5.distributed/README.md +88 -0
- package/sandbox/5.distributed/data/1.blog-post/100.json +59 -0
- package/sandbox/5.distributed/data/1.blog-post/README.md +25 -0
- package/sandbox/5.distributed/data/2.job-application/200.json +103 -0
- package/sandbox/5.distributed/data/2.job-application/201.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/202.json +30 -0
- package/sandbox/5.distributed/data/2.job-application/README.md +58 -0
- package/sandbox/5.distributed/data/3.customer-review/300.json +141 -0
- package/sandbox/5.distributed/data/3.customer-review/301.json +31 -0
- package/sandbox/5.distributed/data/3.customer-review/302.json +57 -0
- package/sandbox/5.distributed/data/3.customer-review/README.md +71 -0
- package/sandbox/5.distributed/data/4.content-moderation/400.json +173 -0
- package/sandbox/5.distributed/data/4.content-moderation/401.json +47 -0
- package/sandbox/5.distributed/data/4.content-moderation/402.json +46 -0
- package/sandbox/5.distributed/data/4.content-moderation/403.json +31 -0
- package/sandbox/5.distributed/data/4.content-moderation/README.md +83 -0
- package/sandbox/5.distributed/package.json +20 -0
- package/sandbox/5.distributed/src/client.ts +124 -0
- package/sandbox/5.distributed/src/executor.ts +69 -0
- package/sandbox/5.distributed/src/nodes.ts +136 -0
- package/sandbox/5.distributed/src/registry.ts +101 -0
- package/sandbox/5.distributed/src/types.ts +45 -0
- package/sandbox/5.distributed/src/utils.ts +69 -0
- package/sandbox/5.distributed/src/worker.ts +217 -0
- package/sandbox/5.distributed/tsconfig.json +3 -0
- package/sandbox/6.rag/.env.example +1 -0
- package/sandbox/6.rag/README.md +60 -0
- package/sandbox/6.rag/data/README.md +31 -0
- package/sandbox/6.rag/data/rag.json +58 -0
- package/sandbox/6.rag/documents/sample-cascade.txt +11 -0
- package/sandbox/6.rag/package.json +18 -0
- package/sandbox/6.rag/src/main.ts +52 -0
- package/sandbox/6.rag/src/nodes/GenerateEmbeddingsNode.ts +54 -0
- package/sandbox/6.rag/src/nodes/LLMProcessNode.ts +48 -0
- package/sandbox/6.rag/src/nodes/LoadAndChunkNode.ts +40 -0
- package/sandbox/6.rag/src/nodes/StoreInVectorDBNode.ts +36 -0
- package/sandbox/6.rag/src/nodes/VectorSearchNode.ts +53 -0
- package/sandbox/6.rag/src/nodes/index.ts +28 -0
- package/sandbox/6.rag/src/registry.ts +23 -0
- package/sandbox/6.rag/src/types.ts +44 -0
- package/sandbox/6.rag/src/utils.ts +77 -0
- package/sandbox/6.rag/tsconfig.json +3 -0
- package/sandbox/tsconfig.json +13 -0
- package/src/builder/collection.test.ts +287 -0
- package/src/builder/collection.ts +269 -0
- package/src/builder/graph.test.ts +406 -0
- package/src/builder/graph.ts +336 -0
- package/src/builder/graph.types.ts +104 -0
- package/src/builder/index.ts +3 -0
- package/src/context.ts +111 -0
- package/src/errors.ts +34 -0
- package/src/executor.ts +29 -0
- package/src/executors/in-memory.test.ts +93 -0
- package/src/executors/in-memory.ts +140 -0
- package/src/functions.test.ts +191 -0
- package/src/functions.ts +117 -0
- package/src/index.ts +5 -0
- package/src/logger.ts +41 -0
- package/src/types.ts +75 -0
- package/src/utils/graph.test.ts +144 -0
- package/src/utils/graph.ts +182 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/mermaid.test.ts +239 -0
- package/src/utils/mermaid.ts +133 -0
- package/src/utils/sleep.ts +20 -0
- package/src/workflow.test.ts +622 -0
- package/src/workflow.ts +561 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NodeArgs } from 'flowcraft'
|
|
2
|
+
import type { RagNodeOptions } from '../types'
|
|
3
|
+
import { promises as fs } from 'node:fs'
|
|
4
|
+
import { Node } from 'flowcraft'
|
|
5
|
+
import { DocumentChunk } from '../types'
|
|
6
|
+
import { CHUNKS, DOCUMENT_PATH } from './index'
|
|
7
|
+
|
|
8
|
+
export class LoadAndChunkNode extends Node<string, Map<string, DocumentChunk>> {
|
|
9
|
+
private filePath: string
|
|
10
|
+
|
|
11
|
+
constructor(options: RagNodeOptions<'load-and-chunk'>) {
|
|
12
|
+
super(options)
|
|
13
|
+
this.filePath = options.data.filePath
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async exec({ ctx, logger }: NodeArgs): Promise<Map<string, DocumentChunk>> {
|
|
17
|
+
const path = ctx.get(DOCUMENT_PATH) || this.filePath
|
|
18
|
+
logger.info(`[LoadAndChunkNode] Reading and chunking file: ${path}`)
|
|
19
|
+
|
|
20
|
+
const content = await fs.readFile(path, 'utf-8')
|
|
21
|
+
const chunks = new Map<string, DocumentChunk>()
|
|
22
|
+
|
|
23
|
+
// Simple chunking strategy: split by paragraph.
|
|
24
|
+
// A real implementation might use a more sophisticated text splitter.
|
|
25
|
+
const paragraphs = content.split(/\n\s*\n/).filter(p => p.trim().length > 10)
|
|
26
|
+
|
|
27
|
+
for (const [i, paragraph] of paragraphs.entries()) {
|
|
28
|
+
const chunkId = `chunk_${i}`
|
|
29
|
+
const chunk = new DocumentChunk(chunkId, paragraph.trim(), this.filePath)
|
|
30
|
+
chunks.set(chunkId, chunk)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
logger.info(`[LoadAndChunkNode] Created ${chunks.size} chunks.`)
|
|
34
|
+
return chunks
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async post({ ctx, execRes }: NodeArgs<string, Map<string, DocumentChunk>>) {
|
|
38
|
+
ctx.set(CHUNKS, execRes)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { NodeArgs, NodeOptions } from 'flowcraft'
|
|
2
|
+
import type { DocumentChunk } from '../types'
|
|
3
|
+
import { Node } from 'flowcraft'
|
|
4
|
+
import { CHUNKS, EMBEDDINGS, VECTOR_DB } from './index'
|
|
5
|
+
|
|
6
|
+
export class StoreInVectorDBNode extends Node {
|
|
7
|
+
constructor(options?: NodeOptions) {
|
|
8
|
+
super(options)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async exec({ ctx, logger }: NodeArgs) {
|
|
12
|
+
logger.info('[StoreInVectorDBNode] Simulating storage of chunks and vectors.')
|
|
13
|
+
|
|
14
|
+
const chunks = ctx.get(CHUNKS)
|
|
15
|
+
const embeddings = ctx.get(EMBEDDINGS)
|
|
16
|
+
|
|
17
|
+
if (!chunks || !embeddings) {
|
|
18
|
+
throw new Error('Missing chunks or embeddings in context.')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const db = new Map<string, { chunk: DocumentChunk, vector: number[] }>()
|
|
22
|
+
|
|
23
|
+
for (const [chunkId, chunk] of chunks.entries()) {
|
|
24
|
+
const vector = embeddings.get(chunkId)
|
|
25
|
+
if (vector) {
|
|
26
|
+
db.set(chunkId, { chunk, vector })
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
logger.warn(`[StoreInVectorDBNode] No embedding found for chunk ID: ${chunkId}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
logger.info(`[StoreInVectorDBNode] DB is ready with ${db.size} entries.`)
|
|
34
|
+
ctx.set(VECTOR_DB, db)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { NodeArgs } from 'flowcraft'
|
|
2
|
+
import type { RagNodeOptions } from '../types'
|
|
3
|
+
import { Node } from 'flowcraft'
|
|
4
|
+
import { SearchResult } from '../types'
|
|
5
|
+
import { cosineSimilarity, getEmbedding } from '../utils'
|
|
6
|
+
import { SEARCH_RESULTS, VECTOR_DB } from './index'
|
|
7
|
+
|
|
8
|
+
export class VectorSearchNode extends Node<void, SearchResult[]> {
|
|
9
|
+
private question: string
|
|
10
|
+
private topK: number
|
|
11
|
+
|
|
12
|
+
constructor(options: RagNodeOptions<'vector-search'>) {
|
|
13
|
+
super(options)
|
|
14
|
+
this.question = options.data.question
|
|
15
|
+
this.topK = options.data.topK
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async exec({ ctx, logger }: NodeArgs): Promise<SearchResult[]> {
|
|
19
|
+
logger.info(`[VectorSearchNode] Performing vector search for question: "${this.question}"`)
|
|
20
|
+
const db = ctx.get(VECTOR_DB)
|
|
21
|
+
if (!db || db.size === 0) {
|
|
22
|
+
logger.warn('[VectorSearchNode] Vector DB is empty. Cannot perform search.')
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 1. Get the embedding for the user's question.
|
|
27
|
+
const questionVector = await getEmbedding(this.question)
|
|
28
|
+
|
|
29
|
+
// 2. Calculate similarity between the question and all document chunks.
|
|
30
|
+
const similarities: { id: string, score: number }[] = []
|
|
31
|
+
for (const [chunkId, { vector }] of db.entries()) {
|
|
32
|
+
const score = cosineSimilarity(questionVector, vector)
|
|
33
|
+
similarities.push({ id: chunkId, score })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 3. Sort by score and take the top K results.
|
|
37
|
+
similarities.sort((a, b) => b.score - a.score)
|
|
38
|
+
const topResults = similarities.slice(0, this.topK)
|
|
39
|
+
|
|
40
|
+
// 4. Create SearchResult instances.
|
|
41
|
+
const searchResults = topResults.map(({ id, score }) => {
|
|
42
|
+
const chunk = db.get(id)!.chunk
|
|
43
|
+
return new SearchResult(chunk, score)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
logger.info(`[VectorSearchNode] Found ${searchResults.length} relevant results.`)
|
|
47
|
+
return searchResults
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async post({ ctx, execRes }: NodeArgs<void, SearchResult[]>) {
|
|
51
|
+
ctx.set(SEARCH_RESULTS, execRes)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DocumentChunk, SearchResult } from '../types'
|
|
2
|
+
import { contextKey } from 'flowcraft'
|
|
3
|
+
|
|
4
|
+
// These are the type-safe keys we'll use to pass state through the workflow.
|
|
5
|
+
export const DOCUMENT_PATH = contextKey<string>('document_path')
|
|
6
|
+
export const CHUNKS = contextKey<Map<string, DocumentChunk>>('chunks')
|
|
7
|
+
export const EMBEDDINGS = contextKey<Map<string, number[]>>('embeddings')
|
|
8
|
+
export const VECTOR_DB = contextKey<Map<string, { chunk: DocumentChunk, vector: number[] }>>('vector_db')
|
|
9
|
+
export const QUESTION = contextKey<string>('question')
|
|
10
|
+
export const SEARCH_RESULTS = contextKey<SearchResult[]>('search_results')
|
|
11
|
+
export const FINAL_ANSWER = contextKey<string>('final_answer')
|
|
12
|
+
|
|
13
|
+
// A map to resolve string names from the JSON graph to our actual ContextKey symbols.
|
|
14
|
+
export const keyRegistry = new Map<string, symbol>([
|
|
15
|
+
['document_path', DOCUMENT_PATH],
|
|
16
|
+
['chunks', CHUNKS],
|
|
17
|
+
['embeddings', EMBEDDINGS],
|
|
18
|
+
['vector_db', VECTOR_DB],
|
|
19
|
+
['question', QUESTION],
|
|
20
|
+
['search_results', SEARCH_RESULTS],
|
|
21
|
+
['final_answer', FINAL_ANSWER],
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export * from './GenerateEmbeddingsNode'
|
|
25
|
+
export * from './LLMProcessNode'
|
|
26
|
+
export * from './LoadAndChunkNode'
|
|
27
|
+
export * from './StoreInVectorDBNode'
|
|
28
|
+
export * from './VectorSearchNode'
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TypedNodeRegistry } from 'flowcraft'
|
|
2
|
+
import type { RagNodeTypeMap } from './types'
|
|
3
|
+
import { createNodeRegistry, GraphBuilder } from 'flowcraft'
|
|
4
|
+
import {
|
|
5
|
+
GenerateEmbeddingsNode,
|
|
6
|
+
LLMProcessNode,
|
|
7
|
+
LoadAndChunkNode,
|
|
8
|
+
StoreInVectorDBNode,
|
|
9
|
+
VectorSearchNode,
|
|
10
|
+
} from './nodes'
|
|
11
|
+
|
|
12
|
+
// Create a type-safe registry that maps our node type strings to their classes.
|
|
13
|
+
export const nodeRegistry = createNodeRegistry({
|
|
14
|
+
'load-and-chunk': LoadAndChunkNode,
|
|
15
|
+
'generate-embeddings': GenerateEmbeddingsNode,
|
|
16
|
+
'store-in-db': StoreInVectorDBNode,
|
|
17
|
+
'vector-search': VectorSearchNode,
|
|
18
|
+
'llm-process': LLMProcessNode,
|
|
19
|
+
} as TypedNodeRegistry<RagNodeTypeMap>)
|
|
20
|
+
|
|
21
|
+
// Instantiate the GraphBuilder with our registry.
|
|
22
|
+
// This builder will be used in main.ts to construct the flow.
|
|
23
|
+
export const ragGraphBuilder = new GraphBuilder<RagNodeTypeMap>(nodeRegistry)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { NodeConstructorOptions, NodeOptions } from 'flowcraft'
|
|
2
|
+
|
|
3
|
+
// A class representing a single chunk of a document.
|
|
4
|
+
// Using a class demonstrates a common data modeling pattern.
|
|
5
|
+
export class DocumentChunk {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly id: string,
|
|
8
|
+
public readonly text: string,
|
|
9
|
+
public readonly source: string,
|
|
10
|
+
public readonly ingestedAt: Date = new Date(),
|
|
11
|
+
) { }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A class representing a search result from the vector database.
|
|
15
|
+
export class SearchResult {
|
|
16
|
+
constructor(
|
|
17
|
+
public readonly chunk: DocumentChunk,
|
|
18
|
+
public readonly score: number,
|
|
19
|
+
) { }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A type-safe mapping of our graph node types to their expected `data` payloads.
|
|
23
|
+
// This will be used by the GraphBuilder for compile-time validation.
|
|
24
|
+
export interface RagNodeTypeMap {
|
|
25
|
+
'load-and-chunk': {
|
|
26
|
+
filePath: string
|
|
27
|
+
}
|
|
28
|
+
// This node will read from context, so its data payload is minimal.
|
|
29
|
+
'generate-embeddings': object
|
|
30
|
+
// This node will read from context, so its data payload is minimal.
|
|
31
|
+
'store-in-db': object
|
|
32
|
+
'vector-search': {
|
|
33
|
+
question: string
|
|
34
|
+
topK: number
|
|
35
|
+
}
|
|
36
|
+
'llm-process': {
|
|
37
|
+
promptTemplate: string
|
|
38
|
+
inputs: Record<string, string>
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The options object passed to each node's constructor by the GraphBuilder.
|
|
43
|
+
export type RagNodeOptions<T extends keyof RagNodeTypeMap>
|
|
44
|
+
= & NodeConstructorOptions<RagNodeTypeMap[T]> & NodeOptions
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import OpenAI from 'openai'
|
|
2
|
+
import SuperJSON from 'superjson'
|
|
3
|
+
import 'dotenv/config'
|
|
4
|
+
|
|
5
|
+
const openaiClient = new OpenAI()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Calls the OpenAI Chat Completions API for generation tasks.
|
|
9
|
+
*/
|
|
10
|
+
export async function callLLM(prompt: string): Promise<string> {
|
|
11
|
+
console.log(`\n--- Sending to LLM for Generation ---\n${prompt}\n`)
|
|
12
|
+
try {
|
|
13
|
+
const response = await openaiClient.chat.completions.create({
|
|
14
|
+
model: 'gpt-4o-mini',
|
|
15
|
+
messages: [{ role: 'user', content: prompt }],
|
|
16
|
+
temperature: 0.1,
|
|
17
|
+
})
|
|
18
|
+
const result = response.choices[0].message.content || ''
|
|
19
|
+
console.log(
|
|
20
|
+
'--- Received from LLM ---',
|
|
21
|
+
'\n====================================================\n',
|
|
22
|
+
result,
|
|
23
|
+
'\n====================================================\n',
|
|
24
|
+
)
|
|
25
|
+
return result
|
|
26
|
+
}
|
|
27
|
+
catch (error: any) {
|
|
28
|
+
console.error('Error calling OpenAI API for generation:', error)
|
|
29
|
+
throw new Error(`OpenAI API call failed: ${error.message}`)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Calls the OpenAI Embeddings API.
|
|
35
|
+
*/
|
|
36
|
+
export async function getEmbedding(text: string): Promise<number[]> {
|
|
37
|
+
console.log(`[Embeddings API] Generating embedding for text: "${text.substring(0, 50)}..."`)
|
|
38
|
+
try {
|
|
39
|
+
const response = await openaiClient.embeddings.create({
|
|
40
|
+
model: 'text-embedding-3-small',
|
|
41
|
+
input: text.replace(/\n/g, ' '),
|
|
42
|
+
})
|
|
43
|
+
return response.data[0].embedding
|
|
44
|
+
}
|
|
45
|
+
catch (error: any) {
|
|
46
|
+
console.error('Error calling OpenAI Embeddings API:', error)
|
|
47
|
+
throw new Error(`OpenAI Embeddings API call failed: ${error.message}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Simulates cosine similarity between two vectors.
|
|
53
|
+
*/
|
|
54
|
+
export function cosineSimilarity(vecA: number[], vecB: number[]): number {
|
|
55
|
+
const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0)
|
|
56
|
+
const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0))
|
|
57
|
+
const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0))
|
|
58
|
+
return dotProduct / (magnitudeA * magnitudeB)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a template string by replacing {{key}} with values from a data object.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveTemplate(template: string, data: Record<string, any>): string {
|
|
65
|
+
return template.replace(/\{\{(.*?)\}\}/g, (_, key) => {
|
|
66
|
+
const value = data[key.trim()]
|
|
67
|
+
if (value === undefined || value === null) {
|
|
68
|
+
console.warn(`Template variable '{{${key.trim()}}}' not found in data.`)
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
// Use superjson to handle complex objects like our SearchResult class
|
|
72
|
+
if (typeof value === 'object')
|
|
73
|
+
return SuperJSON.stringify(value)
|
|
74
|
+
|
|
75
|
+
return String(value)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { AbstractNode, Logger, NodeArgs, RunOptions } from '../workflow'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { contextKey, Flow, Node, TypedContext } from '../workflow'
|
|
4
|
+
import {
|
|
5
|
+
BatchFlow,
|
|
6
|
+
filterCollection,
|
|
7
|
+
mapCollection,
|
|
8
|
+
ParallelBatchFlow,
|
|
9
|
+
ParallelFlow,
|
|
10
|
+
reduceCollection,
|
|
11
|
+
SequenceFlow,
|
|
12
|
+
} from './collection'
|
|
13
|
+
|
|
14
|
+
function createMockLogger(): Logger {
|
|
15
|
+
return {
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
info: vi.fn(),
|
|
18
|
+
warn: vi.fn(),
|
|
19
|
+
error: vi.fn(),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let mockLogger = createMockLogger()
|
|
24
|
+
let runOptions: RunOptions = { logger: mockLogger }
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
mockLogger = createMockLogger()
|
|
28
|
+
runOptions = { logger: mockLogger }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const PROCESSED_IDS = contextKey<number[]>('processed_ids')
|
|
32
|
+
const BATCH_RESULTS = contextKey<string[]>('batch_results')
|
|
33
|
+
const VALUE = contextKey<number>('value')
|
|
34
|
+
const PATH = contextKey<string[]>('path')
|
|
35
|
+
|
|
36
|
+
class ProcessItemNode extends Node {
|
|
37
|
+
async exec({ ctx, params }: NodeArgs) {
|
|
38
|
+
const id: number = params.id
|
|
39
|
+
const value: string = params.value
|
|
40
|
+
|
|
41
|
+
const processed = ctx.get(PROCESSED_IDS) ?? []
|
|
42
|
+
ctx.set(PROCESSED_IDS, [...processed, id])
|
|
43
|
+
|
|
44
|
+
const results = ctx.get(BATCH_RESULTS) ?? []
|
|
45
|
+
const newResult = `Item ${id}: Processed ${value}`
|
|
46
|
+
ctx.set(BATCH_RESULTS, [...results, newResult])
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class AddNode extends Node {
|
|
51
|
+
constructor(private number: number) { super() }
|
|
52
|
+
async exec({ ctx }: NodeArgs) {
|
|
53
|
+
const current = ctx.get(VALUE) ?? 0
|
|
54
|
+
ctx.set(VALUE, current + this.number)
|
|
55
|
+
const path = ctx.get(PATH) ?? []
|
|
56
|
+
ctx.set(PATH, [...path, `add${this.number}`])
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('sequenceFlow', () => {
|
|
61
|
+
it('should create and run a linear sequence of nodes', async () => {
|
|
62
|
+
const ctx = new TypedContext([[VALUE, 1]])
|
|
63
|
+
const flow = new SequenceFlow(
|
|
64
|
+
new AddNode(5), // 1 + 5 = 6
|
|
65
|
+
new AddNode(10), // 6 + 10 = 16
|
|
66
|
+
)
|
|
67
|
+
await flow.run(ctx, runOptions)
|
|
68
|
+
expect(ctx.get(VALUE)).toBe(16)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('batchFlow (Sequential)', () => {
|
|
73
|
+
class TestSequentialBatchFlow extends BatchFlow {
|
|
74
|
+
protected nodeToRun: AbstractNode = new ProcessItemNode()
|
|
75
|
+
|
|
76
|
+
constructor(private items: any[]) {
|
|
77
|
+
super()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async prep() {
|
|
81
|
+
return this.items
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
it('should process all items in the specified order', async () => {
|
|
85
|
+
const ctx = new TypedContext()
|
|
86
|
+
const flow = new TestSequentialBatchFlow([ // Use the updated test class
|
|
87
|
+
{ id: 1, value: 'A' },
|
|
88
|
+
{ id: 2, value: 'B' },
|
|
89
|
+
{ id: 3, value: 'C' },
|
|
90
|
+
])
|
|
91
|
+
await flow.run(ctx, runOptions)
|
|
92
|
+
expect(ctx.get(PROCESSED_IDS)).toEqual([1, 2, 3])
|
|
93
|
+
expect(ctx.get(BATCH_RESULTS)).toEqual([
|
|
94
|
+
'Item 1: Processed A',
|
|
95
|
+
'Item 2: Processed B',
|
|
96
|
+
'Item 3: Processed C',
|
|
97
|
+
])
|
|
98
|
+
})
|
|
99
|
+
it('should complete successfully with an empty batch', async () => {
|
|
100
|
+
const ctx = new TypedContext()
|
|
101
|
+
const flow = new TestSequentialBatchFlow([])
|
|
102
|
+
await flow.run(ctx, runOptions)
|
|
103
|
+
expect(ctx.get(PROCESSED_IDS)).toBeUndefined()
|
|
104
|
+
expect(ctx.get(BATCH_RESULTS)).toBeUndefined()
|
|
105
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
106
|
+
'[BatchFlow] Starting sequential processing of 0 items.',
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
it('should pass parent flow parameters to each batch item', async () => {
|
|
110
|
+
const ctx = new TypedContext()
|
|
111
|
+
const flow = new TestSequentialBatchFlow([
|
|
112
|
+
{ id: 1 },
|
|
113
|
+
{ id: 2 },
|
|
114
|
+
])
|
|
115
|
+
flow.withParams({ value: 'shared' })
|
|
116
|
+
await flow.run(ctx, runOptions)
|
|
117
|
+
expect(ctx.get(BATCH_RESULTS)).toEqual([
|
|
118
|
+
'Item 1: Processed shared',
|
|
119
|
+
'Item 2: Processed shared',
|
|
120
|
+
])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('parallelBatchFlow', () => {
|
|
125
|
+
class TestParallelBatchFlow extends ParallelBatchFlow {
|
|
126
|
+
protected nodeToRun: AbstractNode = new ProcessItemNode()
|
|
127
|
+
|
|
128
|
+
constructor(private items: any[]) {
|
|
129
|
+
super()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async prep() {
|
|
133
|
+
return this.items
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
it('should process all items', async () => {
|
|
137
|
+
const ctx = new TypedContext()
|
|
138
|
+
const flow = new TestParallelBatchFlow([
|
|
139
|
+
{ id: 1, value: 'A' },
|
|
140
|
+
{ id: 2, value: 'B' },
|
|
141
|
+
{ id: 3, value: 'C' },
|
|
142
|
+
])
|
|
143
|
+
await flow.run(ctx, runOptions)
|
|
144
|
+
// In parallel, order is not guaranteed, so we check for presence and size.
|
|
145
|
+
const processedIds = ctx.get(PROCESSED_IDS)
|
|
146
|
+
expect(processedIds).toHaveLength(3)
|
|
147
|
+
expect(processedIds).toContain(1)
|
|
148
|
+
expect(processedIds).toContain(2)
|
|
149
|
+
expect(processedIds).toContain(3)
|
|
150
|
+
const results = ctx.get(BATCH_RESULTS)
|
|
151
|
+
expect(results).toHaveLength(3)
|
|
152
|
+
expect(results).toContain('Item 1: Processed A')
|
|
153
|
+
expect(results).toContain('Item 2: Processed B')
|
|
154
|
+
expect(results).toContain('Item 3: Processed C')
|
|
155
|
+
})
|
|
156
|
+
it('should complete successfully with an empty batch', async () => {
|
|
157
|
+
const ctx = new TypedContext()
|
|
158
|
+
const flow = new TestParallelBatchFlow([])
|
|
159
|
+
await flow.run(ctx, runOptions)
|
|
160
|
+
expect(ctx.get(PROCESSED_IDS)).toBeUndefined()
|
|
161
|
+
expect(ctx.get(BATCH_RESULTS)).toBeUndefined()
|
|
162
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
163
|
+
'[ParallelBatchFlow] Starting parallel processing of 0 items.',
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('parallelFlow', () => {
|
|
169
|
+
it('should run all nodes in parallel and then proceed', async () => {
|
|
170
|
+
const ctx = new TypedContext([[VALUE, 0]])
|
|
171
|
+
const pFlow = new ParallelFlow([
|
|
172
|
+
new AddNode(1),
|
|
173
|
+
new AddNode(10),
|
|
174
|
+
new AddNode(100),
|
|
175
|
+
])
|
|
176
|
+
const finalNode = new AddNode(1000)
|
|
177
|
+
pFlow.next(finalNode)
|
|
178
|
+
|
|
179
|
+
await new Flow(pFlow).run(ctx, runOptions)
|
|
180
|
+
|
|
181
|
+
// The parallel AddNodes will race. The final value depends on execution order,
|
|
182
|
+
// but the path should be correct. Let's verify the final step.
|
|
183
|
+
// Expected path: add1, add10, add100 (in any order), then add1000
|
|
184
|
+
const path = ctx.get(PATH)
|
|
185
|
+
expect(path).toHaveLength(4)
|
|
186
|
+
expect(path).toContain('add1')
|
|
187
|
+
expect(path).toContain('add10')
|
|
188
|
+
expect(path).toContain('add100')
|
|
189
|
+
expect(path![3]).toBe('add1000') // Final node must be last
|
|
190
|
+
|
|
191
|
+
// Verify the final sum
|
|
192
|
+
expect(ctx.get(VALUE)).toBe(1111)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should handle an empty parallel flow', async () => {
|
|
196
|
+
const ctx = new TypedContext()
|
|
197
|
+
const pFlow = new ParallelFlow([])
|
|
198
|
+
const finalNode = new AddNode(5)
|
|
199
|
+
pFlow.next(finalNode)
|
|
200
|
+
|
|
201
|
+
await new Flow(pFlow).run(ctx, runOptions)
|
|
202
|
+
expect(ctx.get(VALUE)).toBe(5)
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('functionalHelpers', () => {
|
|
207
|
+
describe('mapCollection', () => {
|
|
208
|
+
it('should map items using a synchronous function', async () => {
|
|
209
|
+
const items = [1, 2, 3]
|
|
210
|
+
const double = (n: number) => n * 2
|
|
211
|
+
const flow = mapCollection(items, double)
|
|
212
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
213
|
+
expect(result).toEqual([2, 4, 6])
|
|
214
|
+
})
|
|
215
|
+
it('should map items using an asynchronous function', async () => {
|
|
216
|
+
const items = ['a', 'b', 'c']
|
|
217
|
+
const toUpper = async (s: string) => {
|
|
218
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
219
|
+
return s.toUpperCase()
|
|
220
|
+
}
|
|
221
|
+
const flow = mapCollection(items, toUpper)
|
|
222
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
223
|
+
expect(result).toEqual(['A', 'B', 'C'])
|
|
224
|
+
})
|
|
225
|
+
it('should handle an empty collection', async () => {
|
|
226
|
+
const items: number[] = []
|
|
227
|
+
const double = (n: number) => n * 2
|
|
228
|
+
const flow = mapCollection(items, double)
|
|
229
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
230
|
+
expect(result).toEqual([])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('filterCollection', () => {
|
|
235
|
+
it('should filter items using a synchronous predicate', async () => {
|
|
236
|
+
const items = [1, 2, 3, 4, 5]
|
|
237
|
+
const isEven = (n: number) => n % 2 === 0
|
|
238
|
+
const flow = filterCollection(items, isEven)
|
|
239
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
240
|
+
expect(result).toEqual([2, 4])
|
|
241
|
+
})
|
|
242
|
+
it('should filter items using an asynchronous predicate', async () => {
|
|
243
|
+
const items = ['short', 'long-word', 'tiny', 'another-long-word']
|
|
244
|
+
const isLong = async (s: string) => {
|
|
245
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
246
|
+
return s.length > 5
|
|
247
|
+
}
|
|
248
|
+
const flow = filterCollection(items, isLong)
|
|
249
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
250
|
+
expect(result).toEqual(['long-word', 'another-long-word'])
|
|
251
|
+
})
|
|
252
|
+
it('should handle an empty collection', async () => {
|
|
253
|
+
const items: string[] = []
|
|
254
|
+
const isLong = (s: string) => s.length > 5
|
|
255
|
+
const flow = filterCollection(items, isLong)
|
|
256
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
257
|
+
expect(result).toEqual([])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('reduceCollection', () => {
|
|
262
|
+
it('should reduce a collection using a synchronous reducer', async () => {
|
|
263
|
+
const items = [1, 2, 3, 4]
|
|
264
|
+
const sum = (acc: number, val: number) => acc + val
|
|
265
|
+
const flow = reduceCollection(items, sum, 0)
|
|
266
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
267
|
+
expect(result).toBe(10)
|
|
268
|
+
})
|
|
269
|
+
it('should reduce a collection using an asynchronous reducer', async () => {
|
|
270
|
+
const items = ['a', 'b', 'c']
|
|
271
|
+
const concatUpper = async (acc: string, val: string) => {
|
|
272
|
+
await new Promise(resolve => setTimeout(resolve, 1))
|
|
273
|
+
return acc + val.toUpperCase()
|
|
274
|
+
}
|
|
275
|
+
const flow = reduceCollection(items, concatUpper, 'start:')
|
|
276
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
277
|
+
expect(result).toBe('start:ABC')
|
|
278
|
+
})
|
|
279
|
+
it('should return the initial value for an empty collection', async () => {
|
|
280
|
+
const items: number[] = []
|
|
281
|
+
const sum = (acc: number, val: number) => acc + val
|
|
282
|
+
const flow = reduceCollection(items, sum, 100)
|
|
283
|
+
const result = await flow.run(new TypedContext(), runOptions)
|
|
284
|
+
expect(result).toBe(100)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
})
|