Remove native classes from TS runtime

This commit is contained in:
elpresidank 2026-06-01 20:26:47 -05:00
parent 952daf325d
commit dca2786828
79 changed files with 7622 additions and 6703 deletions

View file

@ -14,13 +14,14 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
makeFlowProcessorProgram,
errorMessage,
type ProcessorConfig,
type FlowContext,
type FlowProcessorRuntime,
type ToolRequest,
type ToolResponse,
type EffectConfigHandler,
@ -281,41 +282,35 @@ const onMcpToolRequest = Effect.fn("McpToolService.onRequest")(function* (
});
export const makeMcpToolSpecs = (): ReadonlyArray<Spec<McpToolRuntime>> => [
new ConsumerSpec<ToolRequest, McpToolHandlerError, McpToolRuntime>(
makeConsumerSpec<ToolRequest, McpToolHandlerError, McpToolRuntime>(
"mcp-tool-request",
onMcpToolRequest,
),
new ProducerSpec<ToolResponse>("mcp-tool-response"),
makeProducerSpec<ToolResponse>("mcp-tool-response"),
];
export const makeMcpToolConfigHandlers = (): ReadonlyArray<
EffectConfigHandler<never, McpToolRuntime>
> => [onMcpConfig];
export class McpToolService extends FlowProcessor<McpToolRuntime> {
private readonly runtime = Effect.runSync(makeMcpToolRuntime);
export type McpToolService = FlowProcessorRuntime<McpToolRuntime>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeMcpToolSpecs()) {
this.registerSpecification(spec);
}
this.registerConfigHandler((config, version) =>
Effect.runPromise(onMcpConfig(config, version).pipe(
Effect.provideService(McpToolRuntime, this.runtime),
)),
);
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(McpToolRuntime, this.runtime),
);
}
export function makeMcpToolService(config: ProcessorConfig): McpToolService {
const runtime = Effect.runSync(makeMcpToolRuntime);
const service = makeFlowProcessor(config, {
specifications: makeMcpToolSpecs(),
provide: (effect) => effect.pipe(Effect.provideService(McpToolRuntime, runtime)),
});
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(onMcpConfig(pushedConfig, version).pipe(
Effect.provideService(McpToolRuntime, runtime),
)),
);
return service;
}
export const McpToolService = makeMcpToolService;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolRuntime>({
id: "mcp-tool",
specs: () => makeMcpToolSpecs(),

View file

@ -1,7 +1,7 @@
// ReAct agent -- barrel exports
export { AgentService } from "./service.js";
export { StreamingReActParser } from "./parser.js";
export { makeStreamingReActParser, type StreamingReActParser } from "./parser.js";
export { buildReActPrompt } from "./prompt.js";
export {
createKnowledgeQueryTool,

View file

@ -22,57 +22,75 @@ const MARKERS = [
// Longest marker prefix for partial-match detection
const MAX_MARKER_LEN = Math.max(...MARKERS.map((m) => m.prefix.length));
export class StreamingReActParser {
private state: ReActState = "initial";
private buffer = "";
private onThought: (text: string) => void;
private onAction: (name: string) => void;
private onActionInput: (input: string) => void;
private onFinalAnswer: (text: string) => void;
export interface StreamingReActParser {
readonly feed: (text: string) => void;
readonly flush: () => void;
}
constructor(
onThought: (text: string) => void,
onAction: (name: string) => void,
onActionInput: (input: string) => void,
onFinalAnswer: (text: string) => void,
) {
this.onThought = onThought;
this.onAction = onAction;
this.onActionInput = onActionInput;
this.onFinalAnswer = onFinalAnswer;
}
export function makeStreamingReActParser(
onThought: (text: string) => void,
onAction: (name: string) => void,
onActionInput: (input: string) => void,
onFinalAnswer: (text: string) => void,
): StreamingReActParser {
let state: ReActState = "initial";
let buffer = "";
/**
* Feed a chunk of LLM output text into the parser.
* Accumulates in a buffer and processes complete lines.
*/
feed(text: string): void {
this.buffer += text;
this.processBuffer(false);
}
const emitContent = (content: string): void => {
if (content.length === 0) return;
/**
* Flush any remaining buffered content at the end of output.
*/
flush(): void {
this.processBuffer(true);
// Emit any remaining buffer content in the current state
if (this.buffer.trim().length > 0) {
this.emitContent(this.buffer);
this.buffer = "";
switch (state) {
case "thought":
onThought(content);
break;
case "action":
onAction(content);
break;
case "action_input":
onActionInput(content);
break;
case "final_answer":
onFinalAnswer(content);
break;
case "initial":
// Content before any marker -- treat as thought
state = "thought";
onThought(content);
break;
case "complete":
break;
}
}
};
private processBuffer(isFinal: boolean): void {
const processLine = (line: string): void => {
const trimmed = line.trimStart();
// Check if this line starts a new section
for (const marker of MARKERS) {
if (trimmed.startsWith(marker.prefix)) {
const content = trimmed.slice(marker.prefix.length).trim();
state = marker.state;
emitContent(content);
return;
}
}
// Otherwise, this is continuation content for the current state
if (trimmed.length > 0) {
emitContent(trimmed);
}
};
const processBuffer = (isFinal: boolean): void => {
// Process complete lines (terminated by newline)
while (true) {
const newlineIdx = this.buffer.indexOf("\n");
const newlineIdx = buffer.indexOf("\n");
if (newlineIdx === -1) {
// No complete line yet.
// If not final, check for partial marker match at the end and wait.
if (!isFinal) {
// If the remaining buffer could be the start of a marker, wait for more input.
const trimmed = this.buffer.trimStart();
const trimmed = buffer.trimStart();
if (trimmed.length > 0 && trimmed.length < MAX_MARKER_LEN) {
const couldBeMarker = MARKERS.some((m) =>
m.prefix.startsWith(trimmed),
@ -86,54 +104,29 @@ export class StreamingReActParser {
break;
}
const line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
this.processLine(line);
const line = buffer.slice(0, newlineIdx);
buffer = buffer.slice(newlineIdx + 1);
processLine(line);
}
}
};
private processLine(line: string): void {
const trimmed = line.trimStart();
/**
* Feed a chunk of LLM output text into the parser.
* Accumulates in a buffer and processes complete lines.
*/
const feed = (text: string): void => {
buffer += text;
processBuffer(false);
};
// Check if this line starts a new section
for (const marker of MARKERS) {
if (trimmed.startsWith(marker.prefix)) {
const content = trimmed.slice(marker.prefix.length).trim();
this.state = marker.state;
this.emitContent(content);
return;
}
const flush = (): void => {
processBuffer(true);
// Emit any remaining buffer content in the current state
if (buffer.trim().length > 0) {
emitContent(buffer);
buffer = "";
}
};
// Otherwise, this is continuation content for the current state
if (trimmed.length > 0) {
this.emitContent(trimmed);
}
}
private emitContent(content: string): void {
if (content.length === 0) return;
switch (this.state) {
case "thought":
this.onThought(content);
break;
case "action":
this.onAction(content);
break;
case "action_input":
this.onActionInput(content);
break;
case "final_answer":
this.onFinalAnswer(content);
break;
case "initial":
// Content before any marker -- treat as thought
this.state = "thought";
this.onThought(content);
break;
case "complete":
break;
}
}
return { feed, flush };
}

View file

@ -17,14 +17,15 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
RequestResponseSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
makeRequestResponseSpec,
makeFlowProcessorProgram,
errorMessage,
type ProcessorConfig,
type FlowContext,
type FlowProcessorRuntime,
type AgentRequest,
type AgentResponse,
type TextCompletionRequest,
@ -488,32 +489,32 @@ const onAgentRequest = Effect.fn("AgentService.onRequest")(function* (
});
export const makeAgentSpecs = (): ReadonlyArray<Spec<AgentRuntime>> => [
new ConsumerSpec<AgentRequest, AgentHandlerError, AgentRuntime>(
makeConsumerSpec<AgentRequest, AgentHandlerError, AgentRuntime>(
"agent-request",
onAgentRequest,
),
new ProducerSpec<AgentResponse>("agent-response"),
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
makeProducerSpec<AgentResponse>("agent-response"),
makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
"text-completion-request",
"text-completion-response",
),
new RequestResponseSpec<GraphRagRequest, GraphRagResponse>(
makeRequestResponseSpec<GraphRagRequest, GraphRagResponse>(
"graph-rag",
"graph-rag-request",
"graph-rag-response",
),
new RequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
makeRequestResponseSpec<DocumentRagRequest, DocumentRagResponse>(
"doc-rag",
"document-rag-request",
"document-rag-response",
),
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
makeRequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
"triples",
"triples-request",
"triples-response",
),
new RequestResponseSpec<ToolRequest, ToolResponse>(
makeRequestResponseSpec<ToolRequest, ToolResponse>(
"mcp-tool",
"mcp-tool-request",
"mcp-tool-response",
@ -524,32 +525,25 @@ export const makeAgentConfigHandlers = (): ReadonlyArray<
EffectConfigHandler<never, AgentRuntime>
> => [onToolsConfig];
export class AgentService extends FlowProcessor<AgentRuntime> {
private readonly runtime = Effect.runSync(makeAgentRuntime);
export type AgentService = FlowProcessorRuntime<AgentRuntime>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeAgentSpecs()) {
this.registerSpecification(spec);
}
this.registerConfigHandler((config, version) =>
Effect.runPromise(onToolsConfig(config, version).pipe(
Effect.provideService(AgentRuntime, this.runtime),
)),
);
console.log("[AgentService] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(AgentRuntime, this.runtime),
);
}
export function makeAgentService(config: ProcessorConfig): AgentService {
const runtime = Effect.runSync(makeAgentRuntime);
const service = makeFlowProcessor(config, {
specifications: makeAgentSpecs(),
provide: (effect) => effect.pipe(Effect.provideService(AgentRuntime, runtime)),
});
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(onToolsConfig(pushedConfig, version).pipe(
Effect.provideService(AgentRuntime, runtime),
)),
);
console.log("[AgentService] Service initialized");
return service;
}
export const AgentService = makeAgentService;
/**
* Simple line-based parser for ReAct LLM output.
*

View file

@ -10,11 +10,12 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
ParameterSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
makeParameterSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
@ -74,28 +75,28 @@ const onChunkMessage = Effect.fn("ChunkingService.onMessage")(function* (
export const makeChunkingSpecs = (): ReadonlyArray<
Spec<never>
> => [
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
makeConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
"chunk-input",
onChunkMessage,
),
new ProducerSpec<Chunk>("chunk-output"),
new ProducerSpec<Triples>("chunk-triples"),
new ParameterSpec("chunk-size"),
new ParameterSpec("chunk-overlap"),
makeProducerSpec<Chunk>("chunk-output"),
makeProducerSpec<Triples>("chunk-triples"),
makeParameterSpec("chunk-size"),
makeParameterSpec("chunk-overlap"),
];
export class ChunkingService extends FlowProcessor {
constructor(config: ProcessorConfig) {
super(config);
export type ChunkingService = FlowProcessorRuntime;
for (const spec of makeChunkingSpecs()) {
this.registerSpecification(spec);
}
console.log("[ChunkingService] Service initialized");
}
export function makeChunkingService(config: ProcessorConfig): ChunkingService {
const service = makeFlowProcessor(config, {
specifications: makeChunkingSpecs(),
});
console.log("[ChunkingService] Service initialized");
return service;
}
export const ChunkingService = makeChunkingService;
export const program = makeFlowProcessorProgram({
id: "chunking",
specs: () => makeChunkingSpecs(),

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,9 @@
*/
import {
AsyncProcessor,
makeAsyncProcessor,
type ProcessorConfig,
type AsyncProcessorRuntime,
topics,
type KnowledgeRequest,
type KnowledgeResponse,
@ -20,7 +21,7 @@ import {
type Term,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { Effect } from "effect";
import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
@ -39,394 +40,461 @@ interface DocumentEmbeddingsCore {
[key: string]: unknown;
}
export class KnowledgeCoreService extends AsyncProcessor {
/** Keyed by `${user}:${id}` */
private cores = new Map<string, KnowledgeCore>();
private deCores = new Map<string, DocumentEmbeddingsCore[]>();
private readonly dataDir: string;
private readonly persistPath: string;
export type KnowledgeCoreService = AsyncProcessorRuntime & Record<string, any>;
private consumer: BackendConsumer<KnowledgeRequest> | null = null;
private responseProducer: BackendProducer<KnowledgeResponse> | null = null;
export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): KnowledgeCoreService {
const service = makeAsyncProcessor(config, {
run: async () => {
await service.run();
},
}) as KnowledgeCoreService;
const baseStop = service.stop;
service.cores = new Map<string, KnowledgeCore>();
service.deCores = new Map<string, DocumentEmbeddingsCore[]>();
service.consumer = null;
service.responseProducer = null;
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
service.dataDir = dataDir;
service.persistPath = joinPath(dataDir, "knowledge-state.json");
Object.assign(service, {
constructor(config: KnowledgeCoreServiceConfig) {
super(config);
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
this.dataDir = dataDir;
this.persistPath = joinPath(dataDir, "knowledge-state.json");
}
private coreKey(user: string, id: string): string {
return `${user}:${id}`;
}
coreKey: function(this: KnowledgeCoreService, user: string, id: string): string {
return `${user}:${id}`;
protected override async run(): Promise<void> {
await ensureDirectory(this.dataDir);
// Load persisted state
await this.loadFromDisk();
},
// Create producer
this.responseProducer = await this.pubsub.createProducer<KnowledgeResponse>({
topic: topics.knowledgeResponse,
});
// Create consumer
this.consumer = await this.pubsub.createConsumer<KnowledgeRequest>({
topic: topics.knowledgeRequest,
subscription: `${this.config.id}-knowledge-request`,
});
console.log(`[KnowledgeCoreService] Listening on ${topics.knowledgeRequest}`);
run: async function(this: KnowledgeCoreService): Promise<void> {
await ensureDirectory(this.dataDir);
// Load persisted state
await this.loadFromDisk();
// Main consume loop
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (msg === null) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[KnowledgeCoreService] Error in consume loop:", err);
await sleep(1000);
}
}
}
private async handleMessage(msg: Message<KnowledgeRequest>): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (requestId === undefined || requestId.length === 0) {
console.warn("[KnowledgeCoreService] Received request without id, ignoring");
return;
}
try {
await this.handleOperation(request, requestId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{ error: { type: "knowledge-error", message } },
{ id: requestId },
);
}
}
private async handleOperation(request: KnowledgeRequest, requestId: string): Promise<void> {
switch (request.operation) {
case "list-kg-cores":
return this.listKgCores(request, requestId);
case "get-kg-core":
return this.getKgCore(request, requestId);
case "delete-kg-core":
return this.deleteKgCore(request, requestId);
case "put-kg-core":
return this.putKgCore(request, requestId);
case "load-kg-core":
return this.loadKgCore(request, requestId);
case "unload-kg-core":
return this.unloadKgCore(request, requestId);
case "list-de-cores":
return this.listDeCores(request, requestId);
case "get-de-core":
return this.getDeCore(request, requestId);
case "delete-de-core":
return this.deleteDeCore(request, requestId);
case "put-de-core":
return this.putDeCore(request, requestId);
case "load-de-core":
return this.loadDeCore(request, requestId);
default:
throw new Error(`Unknown knowledge operation: ${request.operation as string}`);
}
}
private requestRecord(request: KnowledgeRequest): Record<string, unknown> {
return request as Record<string, unknown>;
}
private graphEmbeddings(request: KnowledgeRequest): { entity: Term; vectors: number[][] }[] {
const req = this.requestRecord(request);
const value = request.graphEmbeddings ?? req["graph-embeddings"];
return Array.isArray(value) ? value as { entity: Term; vectors: number[][] }[] : [];
}
private documentEmbeddings(request: KnowledgeRequest): DocumentEmbeddingsCore | undefined {
const req = this.requestRecord(request);
const value = request.documentEmbeddings ?? req["document-embeddings"];
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
return value as DocumentEmbeddingsCore;
}
private async listKgCores(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids: string[] = [];
for (const key of this.cores.keys()) {
if (prefix.length === 0 || key.startsWith(prefix)) {
// Extract the ID portion after the user prefix
const id = key.slice(prefix.length);
ids.push(id);
}
}
await this.responseProducer!.send({ ids }, { id: requestId });
}
private async getKgCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
// Send triples and embeddings in batches
const BATCH_SIZE = 100;
// Send triples in batches
for (let i = 0; i < core.triples.length; i += BATCH_SIZE) {
const batch = core.triples.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.triples.length && core.graphEmbeddings.length === 0;
await this.responseProducer!.send(
{ triples: batch, eos: isLast },
{ id: requestId },
);
}
// Send graph embeddings in batches
for (let i = 0; i < core.graphEmbeddings.length; i += BATCH_SIZE) {
const batch = core.graphEmbeddings.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
await this.responseProducer!.send(
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
{ id: requestId },
);
}
// If core was empty, send a final eos
if (core.triples.length === 0 && core.graphEmbeddings.length === 0) {
await this.responseProducer!.send({ eos: true }, { id: requestId });
}
}
private async deleteKgCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
this.cores.delete(key);
await this.persist();
console.log(`[KnowledgeCoreService] Deleted core: ${key}`);
await this.responseProducer!.send({}, { id: requestId });
}
private async putKgCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
let core = this.cores.get(key);
if (core === undefined) {
core = { triples: [], graphEmbeddings: [] };
this.cores.set(key, core);
}
// Append triples if provided
if (request.triples !== undefined && request.triples.length > 0) {
core.triples.push(...request.triples);
}
// Append graph embeddings if provided
const graphEmbeddings = this.graphEmbeddings(request);
if (graphEmbeddings.length > 0) {
core.graphEmbeddings.push(...graphEmbeddings);
}
await this.persist();
console.log(
`[KnowledgeCoreService] Updated core ${key}: triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}`,
);
await this.responseProducer!.send({}, { id: requestId });
}
private async loadKgCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
if (core.triples.length > 0) {
const producer = await this.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" });
try {
await producer.send({
metadata: {
id: coreId,
root: coreId,
user,
collection: request.collection ?? "default",
},
triples: core.triples,
// Create producer
this.responseProducer = await this.pubsub.createProducer<KnowledgeResponse>({
topic: topics.knowledgeResponse,
});
} finally {
await producer.close();
}
}
console.log(
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`,
);
await this.responseProducer!.send({}, { id: requestId });
}
// Create consumer
this.consumer = await this.pubsub.createConsumer<KnowledgeRequest>({
topic: topics.knowledgeRequest,
subscription: `${this.config.id}-knowledge-request`,
});
private async unloadKgCore(_request: KnowledgeRequest, requestId: string): Promise<void> {
await this.responseProducer!.send({}, { id: requestId });
}
console.log(`[KnowledgeCoreService] Listening on ${topics.knowledgeRequest}`);
private async listDeCores(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids = [...this.deCores.keys()]
.filter((key) => prefix.length === 0 || key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
await this.responseProducer!.send({ ids }, { id: requestId });
}
// Main consume loop
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (msg === null) continue;
private async getDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.deCores.get(key);
if (core === undefined) throw new Error(`Document embeddings core not found: ${key}`);
for (let i = 0; i < core.length; i++) {
const isLast = i === core.length - 1;
await this.responseProducer!.send(
{
documentEmbeddings: core[i],
"document-embeddings": core[i],
eos: isLast,
} as KnowledgeResponse,
{ id: requestId },
);
}
if (core.length === 0) {
await this.responseProducer!.send({ eos: true }, { id: requestId });
}
}
private async deleteDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
this.deCores.delete(this.coreKey(user, coreId));
await this.persist();
await this.responseProducer!.send({}, { id: requestId });
}
private async putDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const item = this.documentEmbeddings(request);
if (item === undefined) throw new Error("put-de-core requires document-embeddings");
const core = this.deCores.get(key) ?? [];
core.push(item);
this.deCores.set(key, core);
await this.persist();
await this.responseProducer!.send({}, { id: requestId });
}
private async loadDeCore(request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
if (!this.deCores.has(key)) throw new Error(`Document embeddings core not found: ${key}`);
await this.responseProducer!.send({}, { id: requestId });
}
// ---------- Persistence ----------
private async persist(): Promise<void> {
try {
// Serialize Map to object
const data: {
kg: Record<string, KnowledgeCore>;
de: Record<string, DocumentEmbeddingsCore[]>;
} = { kg: {}, de: {} };
for (const [key, core] of this.cores) {
data.kg[key] = core;
}
for (const [key, core] of this.deCores) {
data.de[key] = core;
}
const json = JSON.stringify(data, null, 2);
await writeTextFile(this.persistPath, json);
} catch (err) {
console.error("[KnowledgeCoreService] Failed to persist state:", err);
}
}
private async loadFromDisk(): Promise<void> {
try {
const raw = await readTextFile(this.persistPath);
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore> | {
kg?: Record<string, KnowledgeCore>;
de?: Record<string, DocumentEmbeddingsCore[]>;
};
this.cores.clear();
this.deCores.clear();
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
for (const [key, core] of Object.entries(kg)) {
this.cores.set(key, core);
}
if ("de" in parsed && parsed.de !== undefined) {
for (const [key, core] of Object.entries(parsed.de)) {
this.deCores.set(key, core);
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[KnowledgeCoreService] Error in consume loop:", err);
await sleep(1000);
}
}
}
console.log(`[KnowledgeCoreService] Loaded persisted state (kg=${this.cores.size}, de=${this.deCores.size})`);
} catch {
console.log("[KnowledgeCoreService] No persisted state found, starting fresh");
}
}
},
override async stop(): Promise<void> {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
await super.stop();
}
handleMessage: async function(this: KnowledgeCoreService, msg: Message<KnowledgeRequest>): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (requestId === undefined || requestId.length === 0) {
console.warn("[KnowledgeCoreService] Received request without id, ignoring");
return;
}
try {
await this.handleOperation(request, requestId);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{ error: { type: "knowledge-error", message } },
{ id: requestId },
);
}
},
handleOperation: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
switch (request.operation) {
case "list-kg-cores":
return this.listKgCores(request, requestId);
case "get-kg-core":
return this.getKgCore(request, requestId);
case "delete-kg-core":
return this.deleteKgCore(request, requestId);
case "put-kg-core":
return this.putKgCore(request, requestId);
case "load-kg-core":
return this.loadKgCore(request, requestId);
case "unload-kg-core":
return this.unloadKgCore(request, requestId);
case "list-de-cores":
return this.listDeCores(request, requestId);
case "get-de-core":
return this.getDeCore(request, requestId);
case "delete-de-core":
return this.deleteDeCore(request, requestId);
case "put-de-core":
return this.putDeCore(request, requestId);
case "load-de-core":
return this.loadDeCore(request, requestId);
default:
throw new Error(`Unknown knowledge operation: ${request.operation as string}`);
}
},
requestRecord: function(this: KnowledgeCoreService, request: KnowledgeRequest): Record<string, unknown> {
return request as Record<string, unknown>;
},
graphEmbeddings: function(this: KnowledgeCoreService, request: KnowledgeRequest): { entity: Term; vectors: number[][] }[] {
const req = this.requestRecord(request);
const value = request.graphEmbeddings ?? req["graph-embeddings"];
return Array.isArray(value) ? value as { entity: Term; vectors: number[][] }[] : [];
},
documentEmbeddings: function(this: KnowledgeCoreService, request: KnowledgeRequest): DocumentEmbeddingsCore | undefined {
const req = this.requestRecord(request);
const value = request.documentEmbeddings ?? req["document-embeddings"];
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
return value as DocumentEmbeddingsCore;
},
listKgCores: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids: string[] = [];
for (const key of (this.cores as Map<string, KnowledgeCore>).keys()) {
if (prefix.length === 0 || key.startsWith(prefix)) {
// Extract the ID portion after the user prefix
const id = key.slice(prefix.length);
ids.push(id);
}
}
await this.responseProducer!.send({ ids }, { id: requestId });
},
getKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
// Send triples and embeddings in batches
const BATCH_SIZE = 100;
// Send triples in batches
for (let i = 0; i < core.triples.length; i += BATCH_SIZE) {
const batch = core.triples.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.triples.length && core.graphEmbeddings.length === 0;
await this.responseProducer!.send(
{ triples: batch, eos: isLast },
{ id: requestId },
);
}
// Send graph embeddings in batches
for (let i = 0; i < core.graphEmbeddings.length; i += BATCH_SIZE) {
const batch = core.graphEmbeddings.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
await this.responseProducer!.send(
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
{ id: requestId },
);
}
// If core was empty, send a final eos
if (core.triples.length === 0 && core.graphEmbeddings.length === 0) {
await this.responseProducer!.send({ eos: true }, { id: requestId });
}
},
deleteKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
this.cores.delete(key);
await this.persist();
console.log(`[KnowledgeCoreService] Deleted core: ${key}`);
await this.responseProducer!.send({}, { id: requestId });
},
putKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
let core = this.cores.get(key);
if (core === undefined) {
core = { triples: [], graphEmbeddings: [] };
this.cores.set(key, core);
}
// Append triples if provided
if (request.triples !== undefined && request.triples.length > 0) {
core.triples.push(...request.triples);
}
// Append graph embeddings if provided
const graphEmbeddings = this.graphEmbeddings(request);
if (graphEmbeddings.length > 0) {
core.graphEmbeddings.push(...graphEmbeddings);
}
await this.persist();
console.log(
`[KnowledgeCoreService] Updated core ${key}: triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}`,
);
await this.responseProducer!.send({}, { id: requestId });
},
loadKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
if (core.triples.length > 0) {
const producer = await this.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" });
try {
await producer.send({
metadata: {
id: coreId,
root: coreId,
user,
collection: request.collection ?? "default",
},
triples: core.triples,
});
} finally {
await producer.close();
}
}
console.log(
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`,
);
await this.responseProducer!.send({}, { id: requestId });
},
unloadKgCore: async function(this: KnowledgeCoreService, _request: KnowledgeRequest, requestId: string): Promise<void> {
await this.responseProducer!.send({}, { id: requestId });
},
listDeCores: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids = [...this.deCores.keys()]
.filter((key) => prefix.length === 0 || key.startsWith(prefix))
.map((key) => key.slice(prefix.length));
await this.responseProducer!.send({ ids }, { id: requestId });
},
getDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.deCores.get(key);
if (core === undefined) throw new Error(`Document embeddings core not found: ${key}`);
for (let i = 0; i < core.length; i++) {
const isLast = i === core.length - 1;
await this.responseProducer!.send(
{
documentEmbeddings: core[i],
"document-embeddings": core[i],
eos: isLast,
} as KnowledgeResponse,
{ id: requestId },
);
}
if (core.length === 0) {
await this.responseProducer!.send({ eos: true }, { id: requestId });
}
},
deleteDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
this.deCores.delete(this.coreKey(user, coreId));
await this.persist();
await this.responseProducer!.send({}, { id: requestId });
},
putDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const item = this.documentEmbeddings(request);
if (item === undefined) throw new Error("put-de-core requires document-embeddings");
const core = this.deCores.get(key) ?? [];
core.push(item);
this.deCores.set(key, core);
await this.persist();
await this.responseProducer!.send({}, { id: requestId });
},
loadDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
if (!(this.deCores as Map<string, DocumentEmbeddingsCore[]>).has(key)) throw new Error(`Document embeddings core not found: ${key}`);
await this.responseProducer!.send({}, { id: requestId });
},
// ---------- Persistence ----------
persist: async function(this: KnowledgeCoreService): Promise<void> {
try {
// Serialize Map to object
const data: {
kg: Record<string, KnowledgeCore>;
de: Record<string, DocumentEmbeddingsCore[]>;
} = { kg: {}, de: {} };
for (const [key, core] of this.cores) {
data.kg[key] = core;
}
for (const [key, core] of this.deCores) {
data.de[key] = core;
}
const json = JSON.stringify(data, null, 2);
await writeTextFile(this.persistPath, json);
} catch (err) {
console.error("[KnowledgeCoreService] Failed to persist state:", err);
}
},
loadFromDisk: async function(this: KnowledgeCoreService): Promise<void> {
try {
const raw = await readTextFile(this.persistPath);
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore> | {
kg?: Record<string, KnowledgeCore>;
de?: Record<string, DocumentEmbeddingsCore[]>;
};
this.cores.clear();
this.deCores.clear();
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
for (const [key, core] of Object.entries(kg)) {
this.cores.set(key, core);
}
if ("de" in parsed && parsed.de !== undefined) {
for (const [key, core] of Object.entries(parsed.de)) {
this.deCores.set(key, core);
}
}
console.log(`[KnowledgeCoreService] Loaded persisted state (kg=${this.cores.size}, de=${this.deCores.size})`);
} catch {
console.log("[KnowledgeCoreService] No persisted state found, starting fresh");
}
},
stop: async function(this: KnowledgeCoreService): Promise<void> {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
await baseStop();
}
});
return service;
}
export const KnowledgeCoreService = makeKnowledgeCoreService;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({
id: "knowledge-svc",
make: (config) => new KnowledgeCoreService(config),
make: (config) => makeKnowledgeCoreService(config),
});
export async function run(): Promise<void> {

View file

@ -16,11 +16,12 @@
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import type { TextItem } from "pdfjs-dist/types/src/display/api.js";
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
RequestResponseSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
makeRequestResponseSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type Document,
@ -209,28 +210,28 @@ const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
});
export const makePdfDecoderSpecs = (): ReadonlyArray<Spec<never>> => [
new ConsumerSpec<Document, PdfDecoderHandlerError>("decode-input", onPdfDecodeMessage),
new ProducerSpec<TextDocument>("decode-output"),
new ProducerSpec<Triples>("decode-triples"),
new RequestResponseSpec<LibrarianRequest, LibrarianResponse>(
makeConsumerSpec<Document, PdfDecoderHandlerError>("decode-input", onPdfDecodeMessage),
makeProducerSpec<TextDocument>("decode-output"),
makeProducerSpec<Triples>("decode-triples"),
makeRequestResponseSpec<LibrarianRequest, LibrarianResponse>(
"librarian-client",
"librarian-request",
"librarian-response",
),
];
export class PdfDecoderService extends FlowProcessor {
constructor(config: ProcessorConfig) {
super(config);
export type PdfDecoderService = FlowProcessorRuntime;
for (const spec of makePdfDecoderSpecs()) {
this.registerSpecification(spec);
}
console.log("[PdfDecoder] Service initialized");
}
export function makePdfDecoderService(config: ProcessorConfig): PdfDecoderService {
const service = makeFlowProcessor(config, {
specifications: makePdfDecoderSpecs(),
});
console.log("[PdfDecoder] Service initialized");
return service;
}
export const PdfDecoderService = makePdfDecoderService;
function iriTerm(iri: string): Term {
return { type: "IRI", iri };
}

View file

@ -8,8 +8,8 @@ import { Effect, Layer } from "effect";
import * as S from "effect/Schema";
import {
Embeddings,
EmbeddingsService,
embeddingsError,
makeEmbeddingsService,
makeEmbeddingsSpecs,
type EmbeddingsServiceShape,
type ProcessorConfig,
@ -84,25 +84,18 @@ export function OllamaEmbeddingsLive(config: OllamaEmbeddingsConfig): Layer.Laye
);
}
export class OllamaEmbeddingsProcessor extends EmbeddingsService {
private readonly embeddings: EmbeddingsServiceShape;
export type OllamaEmbeddingsProcessor = ReturnType<typeof makeOllamaEmbeddingsProcessor>;
constructor(config: OllamaEmbeddingsConfig) {
super(config);
this.embeddings = makeOllamaEmbeddings(config);
console.log(
`[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`,
);
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(Embeddings, Embeddings.of(this.embeddings)),
);
}
export function makeOllamaEmbeddingsProcessor(config: OllamaEmbeddingsConfig) {
const embeddings = makeOllamaEmbeddings(config);
console.log(
`[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`,
);
return makeEmbeddingsService(config, embeddings);
}
export const OllamaEmbeddingsProcessor = makeOllamaEmbeddingsProcessor;
export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, never, Embeddings>({
id: "embeddings",
specs: () => makeEmbeddingsSpecs(),

View file

@ -11,12 +11,13 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
RequestResponseSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
makeRequestResponseSpec,
makeFlowProcessorProgram,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type Chunk,
type Triples,
@ -264,36 +265,36 @@ const onKnowledgeExtractMessage = Effect.fn("KnowledgeExtractService.onMessage")
});
export const makeKnowledgeExtractSpecs = (): ReadonlyArray<Spec<never>> => [
new ConsumerSpec<Chunk, KnowledgeExtractHandlerError>(
makeConsumerSpec<Chunk, KnowledgeExtractHandlerError>(
"extract-input",
onKnowledgeExtractMessage,
),
new ProducerSpec<Triples>("extract-triples"),
new ProducerSpec<EntityContexts>("extract-entity-contexts"),
new RequestResponseSpec<PromptRequest, PromptResponse>(
makeProducerSpec<Triples>("extract-triples"),
makeProducerSpec<EntityContexts>("extract-entity-contexts"),
makeRequestResponseSpec<PromptRequest, PromptResponse>(
"prompt-client",
"prompt-request",
"prompt-response",
),
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm-client",
"text-completion-request",
"text-completion-response",
),
];
export class KnowledgeExtractService extends FlowProcessor {
constructor(config: ProcessorConfig) {
super(config);
export type KnowledgeExtractService = FlowProcessorRuntime;
for (const spec of makeKnowledgeExtractSpecs()) {
this.registerSpecification(spec);
}
console.log("[KnowledgeExtract] Service initialized");
}
export function makeKnowledgeExtractService(config: ProcessorConfig): KnowledgeExtractService {
const service = makeFlowProcessor(config, {
specifications: makeKnowledgeExtractSpecs(),
});
console.log("[KnowledgeExtract] Service initialized");
return service;
}
export const KnowledgeExtractService = makeKnowledgeExtractService;
// ---------- Helpers ----------
function toEntityIri(name: string): Term {

View file

@ -15,19 +15,16 @@
*/
import {
AsyncProcessor,
makeAsyncProcessor,
type ProcessorConfig,
type AsyncProcessorRuntime,
topics,
RequestResponse,
makeRequestResponse,
type ConfigRequest,
type ConfigResponse,
} from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type {
BackendProducer,
BackendConsumer,
Message,
} from "@trustgraph/base";
import type { Message } from "@trustgraph/base";
import { Effect } from "effect";
// ---------- Internal state types ----------
@ -136,451 +133,496 @@ const DEFAULT_BLUEPRINT: Blueprint = {
// ---------- Service ----------
export class FlowManagerService extends AsyncProcessor {
private flows = new Map<string, FlowInstance>();
private blueprints = new Map<string, Blueprint>();
export type FlowManagerService = AsyncProcessorRuntime & Record<string, any>;
private consumer: BackendConsumer<Record<string, unknown>> | null = null;
private responseProducer: BackendProducer<Record<string, unknown>> | null = null;
private configClient: RequestResponse<ConfigRequest, ConfigResponse> | null = null;
export function makeFlowManagerService(config: ProcessorConfig): FlowManagerService {
const service = makeAsyncProcessor(config, {
run: async () => {
await service.run();
},
}) as FlowManagerService;
const baseStop = service.stop;
service.flows = new Map<string, FlowInstance>();
service.blueprints = new Map<string, Blueprint>();
service.consumer = null;
service.responseProducer = null;
service.configClient = null;
service.blueprints.set("default", DEFAULT_BLUEPRINT);
Object.assign(service, {
constructor(config: ProcessorConfig) {
super(config);
this.blueprints.set("default", DEFAULT_BLUEPRINT);
}
protected override async run(): Promise<void> {
// Create config client for pushing flow configs to the config service
this.configClient = new RequestResponse<ConfigRequest, ConfigResponse>({
pubsub: this.pubsub,
requestTopic: topics.configRequest,
responseTopic: topics.configResponse,
subscription: `${this.config.id}-config-client`,
});
await this.configClient.start();
await this.ensureDefaultBlueprint();
await this.refreshBlueprintsFromConfig();
run: async function(this: FlowManagerService): Promise<void> {
// Create config client for pushing flow configs to the config service
this.configClient = makeRequestResponse<ConfigRequest, ConfigResponse>({
pubsub: this.pubsub,
requestTopic: topics.configRequest,
responseTopic: topics.configResponse,
subscription: `${this.config.id}-config-client`,
});
await this.configClient.start();
await this.ensureDefaultBlueprint();
await this.refreshBlueprintsFromConfig();
// Create producer for flow-response topic
this.responseProducer = await this.pubsub.createProducer<Record<string, unknown>>({
topic: topics.flowResponse,
});
// Create producer for flow-response topic
this.responseProducer = await this.pubsub.createProducer<Record<string, unknown>>({
topic: topics.flowResponse,
});
// Create consumer for flow-request topic
this.consumer = await this.pubsub.createConsumer<Record<string, unknown>>({
topic: topics.flowRequest,
subscription: `${this.config.id}-flow-request`,
});
// Create consumer for flow-request topic
this.consumer = await this.pubsub.createConsumer<Record<string, unknown>>({
topic: topics.flowRequest,
subscription: `${this.config.id}-flow-request`,
});
console.log(`[FlowManager] Listening on ${topics.flowRequest}`);
console.log(`[FlowManager] Listening on ${topics.flowRequest}`);
// Main consume loop (same pattern as ConfigService)
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (msg === null) continue;
// Main consume loop (same pattern as ConfigService)
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (msg === null) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[FlowManager] Error in consume loop:", err);
await sleep(1000);
}
}
}
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[FlowManager] Error in consume loop:", err);
await sleep(1000);
}
}
private async handleMessage(
msg: Message<Record<string, unknown>>,
): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (requestId === undefined || requestId.length === 0) {
console.warn("[FlowManager] Received request without id, ignoring");
return;
}
try {
const response = await this.handleOperation(request);
await this.responseProducer!.send(response, { id: requestId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{
error: { type: "flow-error", message },
},
{ id: requestId },
);
}
}
private async configRequest(request: ConfigRequest): Promise<ConfigResponse> {
if (this.configClient === null) throw new Error("Config client not started");
return this.configClient.request(request);
}
private async ensureDefaultBlueprint(): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow-blueprint",
});
if (configValues(response).some((value) => value.key === "default")) {
return;
}
await this.configRequest({
operation: "put",
keys: ["flow-blueprint"],
values: {
default: JSON.stringify(DEFAULT_BLUEPRINT),
},
});
}
handleMessage: async function(this: FlowManagerService, msg: Message<Record<string, unknown>>): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
private async refreshBlueprintsFromConfig(): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow-blueprint",
});
const next = new Map<string, Blueprint>();
if (requestId === undefined || requestId.length === 0) {
console.warn("[FlowManager] Received request without id, ignoring");
return;
}
for (const item of configValues(response)) {
const parsed = parseConfigRecord(item.value);
if (parsed === undefined) continue;
next.set(item.key, parsed as Blueprint);
}
try {
const response = await this.handleOperation(request);
await this.responseProducer!.send(response, { id: requestId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{
error: { type: "flow-error", message },
},
{ id: requestId },
);
}
if (!next.has("default")) {
next.set("default", DEFAULT_BLUEPRINT);
}
this.blueprints = next;
}
},
private async refreshFlowsFromConfig(): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow",
});
const next = new Map<string, FlowInstance>();
for (const item of configValues(response)) {
const parsed = parseConfigRecord(item.value);
if (parsed === undefined) continue;
const parameters = isRecord(parsed.parameters) ? parsed.parameters : {};
next.set(item.key, {
id: item.key,
blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default",
description: optionalString(parsed.description) ?? "",
parameters,
status: "running",
});
}
if (next.size === 0) {
const flowsResponse = await this.configRequest({
operation: "getvalues",
type: "flows",
});
for (const item of configValues(flowsResponse)) {
next.set(item.key, {
id: item.key,
blueprintName: "default",
description: "",
parameters: {},
configRequest: async function(this: FlowManagerService, request: ConfigRequest): Promise<ConfigResponse> {
if (this.configClient === null) throw new Error("Config client not started");
return this.configClient.request(request);
},
ensureDefaultBlueprint: async function(this: FlowManagerService): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow-blueprint",
});
if (configValues(response).some((value) => value.key === "default")) {
return;
}
await this.configRequest({
operation: "put",
keys: ["flow-blueprint"],
values: {
default: JSON.stringify(DEFAULT_BLUEPRINT),
},
});
},
refreshBlueprintsFromConfig: async function(this: FlowManagerService): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow-blueprint",
});
const next = new Map<string, Blueprint>();
for (const item of configValues(response)) {
const parsed = parseConfigRecord(item.value);
if (parsed === undefined) continue;
next.set(item.key, parsed as Blueprint);
}
if (!next.has("default")) {
next.set("default", DEFAULT_BLUEPRINT);
}
this.blueprints = next;
},
refreshFlowsFromConfig: async function(this: FlowManagerService): Promise<void> {
const response = await this.configRequest({
operation: "getvalues",
type: "flow",
});
const next = new Map<string, FlowInstance>();
for (const item of configValues(response)) {
const parsed = parseConfigRecord(item.value);
if (parsed === undefined) continue;
const parameters = isRecord(parsed.parameters) ? parsed.parameters : {};
next.set(item.key, {
id: item.key,
blueprintName: optionalString(parsed["blueprint-name"]) ?? optionalString(parsed.blueprintName) ?? "default",
description: optionalString(parsed.description) ?? "",
parameters,
status: "running",
});
}
if (next.size === 0) {
const flowsResponse = await this.configRequest({
operation: "getvalues",
type: "flows",
});
for (const item of configValues(flowsResponse)) {
next.set(item.key, {
id: item.key,
blueprintName: "default",
description: "",
parameters: {},
status: "running",
});
}
}
this.flows = next;
},
handleOperation: async function(this: FlowManagerService, request: Record<string, unknown>): Promise<Record<string, unknown>> {
const op = request.operation as string;
await this.refreshBlueprintsFromConfig();
await this.refreshFlowsFromConfig();
switch (op) {
case "list-blueprints":
return this.handleListBlueprints();
case "put-blueprint":
return await this.handlePutBlueprint(request);
case "get-blueprint":
return this.handleGetBlueprint(request);
case "delete-blueprint":
return this.handleDeleteBlueprint(request);
case "list-flows":
return this.handleListFlows();
case "get-flow":
return this.handleGetFlow(request);
case "start-flow":
return await this.handleStartFlow(request);
case "stop-flow":
return await this.handleStopFlow(request);
default:
throw new Error(`Unknown flow operation: ${op}`);
}
},
// ---------- Blueprint operations ----------
handleListBlueprints: function(this: FlowManagerService): Record<string, unknown> {
return {
"blueprint-names": [...this.blueprints.keys()],
};
},
handleGetBlueprint: function(this: FlowManagerService, request: Record<string, unknown>): Record<string, unknown> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
const blueprint = this.blueprints.get(name);
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${name}`);
}
return {
"blueprint-definition": JSON.stringify(blueprint),
};
},
handlePutBlueprint: async function(this: FlowManagerService, request: Record<string, unknown>): Promise<Record<string, unknown>> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
const rawDefinition = request["blueprint-definition"];
if (rawDefinition === undefined) {
throw new Error("Missing blueprint-definition");
}
const definition = typeof rawDefinition === "string"
? rawDefinition
: JSON.stringify(rawDefinition);
await this.configRequest({
operation: "put",
keys: ["flow-blueprint"],
values: { [name]: definition },
});
await this.refreshBlueprintsFromConfig();
return {};
},
handleDeleteBlueprint: async function(this: FlowManagerService, request: Record<string, unknown>): Promise<Record<string, unknown>> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
if (name === "default") {
throw new Error("Cannot delete the default blueprint");
}
await this.configRequest({
operation: "delete",
keys: ["flow-blueprint", name],
});
this.blueprints.delete(name);
return {};
},
// ---------- Flow operations ----------
handleListFlows: function(this: FlowManagerService): Record<string, unknown> {
return {
"flow-ids": [...this.flows.keys()],
};
},
handleGetFlow: function(this: FlowManagerService, request: Record<string, unknown>): Record<string, unknown> {
const id = request["flow-id"] as string | undefined;
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
return {
flow: JSON.stringify({
"blueprint-name": inst.blueprintName,
description: inst.description,
parameters: inst.parameters,
}),
};
},
handleStartFlow: async function(this: FlowManagerService, request: Record<string, unknown>): Promise<Record<string, unknown>> {
const id = request["flow-id"] as string | undefined;
const blueprintName = (request["blueprint-name"] as string) ?? "default";
const description = (request["description"] as string) ?? "";
const parameters = (request["parameters"] as Record<string, unknown>) ?? {};
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
if ((this.flows as Map<string, FlowInstance>).has(id)) {
throw new Error(`Flow already exists: ${id}`);
}
const blueprint = this.blueprints.get(blueprintName);
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${blueprintName}`);
}
// Create the flow instance
const inst: FlowInstance = {
id,
blueprintName,
description,
parameters,
status: "running",
};
this.flows.set(id, inst);
console.log(
`[FlowManager] Started flow "${id}" with blueprint "${blueprintName}"`,
);
// Push updated flows config to the config service
await this.pushFlowsConfig();
return {};
},
handleStopFlow: async function(this: FlowManagerService, request: Record<string, unknown>): Promise<Record<string, unknown>> {
const id = request["flow-id"] as string | undefined;
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
this.flows.delete(id);
console.log(`[FlowManager] Stopped flow "${id}"`);
await this.deleteFlowConfig(id);
// Push updated flows config (without the removed flow)
await this.pushFlowsConfig();
return {};
},
// ---------- Config push ----------
/**
* Build the flows config object from all running flows and push it
* to the config service via a PUT operation.
*/
pushFlowsConfig: async function(this: FlowManagerService): Promise<void> {
if (this.configClient === null) return;
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
const flowRecords: Record<string, string> = {};
for (const [id, inst] of this.flows) {
const blueprint = this.blueprints.get(inst.blueprintName);
if (blueprint !== undefined) {
flowsConfig[id] = { topics: blueprint.topics };
flowRecords[id] = JSON.stringify({
"blueprint-name": inst.blueprintName,
description: inst.description,
parameters: inst.parameters,
});
}
}
try {
await this.configClient.request({
operation: "put",
keys: ["flows"],
values: flowsConfig,
});
await this.configClient.request({
operation: "put",
keys: ["flow"],
values: flowRecords,
});
console.log(
`[FlowManager] Pushed flows config (${this.flows.size} active flows)`,
);
} catch (err) {
console.error("[FlowManager] Failed to push flows config:", err);
}
},
deleteFlowConfig: async function(this: FlowManagerService, id: string): Promise<void> {
if (this.configClient === null) return;
await this.configClient.request({
operation: "delete",
keys: ["flows", id],
});
}
}
this.flows = next;
}
private async handleOperation(
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const op = request.operation as string;
await this.refreshBlueprintsFromConfig();
await this.refreshFlowsFromConfig();
switch (op) {
case "list-blueprints":
return this.handleListBlueprints();
case "put-blueprint":
return await this.handlePutBlueprint(request);
case "get-blueprint":
return this.handleGetBlueprint(request);
case "delete-blueprint":
return this.handleDeleteBlueprint(request);
case "list-flows":
return this.handleListFlows();
case "get-flow":
return this.handleGetFlow(request);
case "start-flow":
return await this.handleStartFlow(request);
case "stop-flow":
return await this.handleStopFlow(request);
default:
throw new Error(`Unknown flow operation: ${op}`);
}
}
// ---------- Blueprint operations ----------
private handleListBlueprints(): Record<string, unknown> {
return {
"blueprint-names": [...this.blueprints.keys()],
};
}
private handleGetBlueprint(
request: Record<string, unknown>,
): Record<string, unknown> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
const blueprint = this.blueprints.get(name);
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${name}`);
}
return {
"blueprint-definition": JSON.stringify(blueprint),
};
}
private async handlePutBlueprint(
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
const rawDefinition = request["blueprint-definition"];
if (rawDefinition === undefined) {
throw new Error("Missing blueprint-definition");
}
const definition = typeof rawDefinition === "string"
? rawDefinition
: JSON.stringify(rawDefinition);
await this.configRequest({
operation: "put",
keys: ["flow-blueprint"],
values: { [name]: definition },
});
await this.refreshBlueprintsFromConfig();
return {};
}
private async handleDeleteBlueprint(
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const name = request["blueprint-name"] as string | undefined;
if (name === undefined || name.length === 0) {
throw new Error("Missing blueprint-name");
}
if (name === "default") {
throw new Error("Cannot delete the default blueprint");
}
await this.configRequest({
operation: "delete",
keys: ["flow-blueprint", name],
});
this.blueprints.delete(name);
return {};
}
// ---------- Flow operations ----------
private handleListFlows(): Record<string, unknown> {
return {
"flow-ids": [...this.flows.keys()],
};
}
private handleGetFlow(
request: Record<string, unknown>,
): Record<string, unknown> {
const id = request["flow-id"] as string | undefined;
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
return {
flow: JSON.stringify({
"blueprint-name": inst.blueprintName,
description: inst.description,
parameters: inst.parameters,
}),
};
}
private async handleStartFlow(
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const id = request["flow-id"] as string | undefined;
const blueprintName = (request["blueprint-name"] as string) ?? "default";
const description = (request["description"] as string) ?? "";
const parameters = (request["parameters"] as Record<string, unknown>) ?? {};
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
if (this.flows.has(id)) {
throw new Error(`Flow already exists: ${id}`);
}
const blueprint = this.blueprints.get(blueprintName);
if (blueprint === undefined) {
throw new Error(`Blueprint not found: ${blueprintName}`);
}
// Create the flow instance
const inst: FlowInstance = {
id,
blueprintName,
description,
parameters,
status: "running",
};
this.flows.set(id, inst);
console.log(
`[FlowManager] Started flow "${id}" with blueprint "${blueprintName}"`,
);
// Push updated flows config to the config service
await this.pushFlowsConfig();
return {};
}
private async handleStopFlow(
request: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const id = request["flow-id"] as string | undefined;
if (id === undefined || id.length === 0) {
throw new Error("Missing flow-id");
}
const inst = this.flows.get(id);
if (inst === undefined) {
throw new Error(`Flow not found: ${id}`);
}
this.flows.delete(id);
console.log(`[FlowManager] Stopped flow "${id}"`);
await this.deleteFlowConfig(id);
// Push updated flows config (without the removed flow)
await this.pushFlowsConfig();
return {};
}
// ---------- Config push ----------
/**
* Build the flows config object from all running flows and push it
* to the config service via a PUT operation.
*/
private async pushFlowsConfig(): Promise<void> {
if (this.configClient === null) return;
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
const flowRecords: Record<string, string> = {};
for (const [id, inst] of this.flows) {
const blueprint = this.blueprints.get(inst.blueprintName);
if (blueprint !== undefined) {
flowsConfig[id] = { topics: blueprint.topics };
flowRecords[id] = JSON.stringify({
"blueprint-name": inst.blueprintName,
description: inst.description,
parameters: inst.parameters,
await this.configClient.request({
operation: "delete",
keys: ["flow", id],
});
}
}
try {
await this.configClient.request({
operation: "put",
keys: ["flows"],
values: flowsConfig,
});
await this.configClient.request({
operation: "put",
keys: ["flow"],
values: flowRecords,
});
console.log(
`[FlowManager] Pushed flows config (${this.flows.size} active flows)`,
);
} catch (err) {
console.error("[FlowManager] Failed to push flows config:", err);
}
}
},
private async deleteFlowConfig(id: string): Promise<void> {
if (this.configClient === null) return;
await this.configClient.request({
operation: "delete",
keys: ["flows", id],
});
await this.configClient.request({
operation: "delete",
keys: ["flow", id],
});
}
// ---------- Lifecycle ----------
override async stop(): Promise<void> {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.configClient !== null) {
await this.configClient.stop();
this.configClient = null;
}
await super.stop();
}
// ---------- Lifecycle ----------
stop: async function(this: FlowManagerService): Promise<void> {
if (this.consumer !== null) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer !== null) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.configClient !== null) {
await this.configClient.stop();
this.configClient = null;
}
await baseStop();
}
});
return service;
}
export const FlowManagerService = makeFlowManagerService;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({
id: "flow-manager",
make: (config) => new FlowManagerService(config),
make: (config) => makeFlowManagerService(config),
});
export async function run(): Promise<void> {

View file

@ -8,7 +8,7 @@
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
*/
import { NatsBackend, RequestResponse, type PubSubBackend } from "@trustgraph/base";
import { makeNatsBackend, makeRequestResponse, type PubSubBackend, type RequestResponse } from "@trustgraph/base";
import type { GatewayConfig } from "../server.js";
import { translateRequest, translateResponse } from "./serialize.js";
@ -66,38 +66,75 @@ function topicName(name: string): string {
// ---------- Manager ----------
export class DispatcherManager {
private readonly pubsub: PubSubBackend;
private requestors = new Map<string, Promise<RequestResponse<unknown, unknown>>>();
export interface DispatcherManager {
readonly start: () => Promise<void>;
readonly stop: () => Promise<void>;
readonly dispatchGlobalService: (
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchGlobalServiceStreaming: (
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly dispatchFlowService: (
flow: string,
kind: string,
request: Record<string, unknown>,
) => Promise<unknown>;
readonly dispatchFlowServiceStreaming: (
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
) => Promise<void>;
readonly publishToTopic: (
topic: string,
message: unknown,
id?: string,
) => Promise<void>;
}
constructor(config: GatewayConfig) {
this.pubsub = new NatsBackend(config.natsUrl ?? "nats://localhost:4222");
}
export const dispatcherManagerFlowServiceNames = (): readonly string[] => [
...FLOW_SERVICES.keys(),
];
async start(): Promise<void> {
export const dispatcherManagerGlobalServiceNames = (): readonly string[] => [
...GLOBAL_SERVICES.keys(),
];
export const dispatcherManagerIsStreamingService = (kind: string): boolean =>
STREAMING_SERVICES.has(kind);
export function makeDispatcherManager(config: GatewayConfig): DispatcherManager {
const pubsub: PubSubBackend = makeNatsBackend(config.natsUrl ?? "nats://localhost:4222");
const requestors = new Map<string, Promise<RequestResponse<unknown, unknown>>>();
const start = async (): Promise<void> => {
// Requestors are created on demand when first accessed
}
};
async stop(): Promise<void> {
for (const pending of this.requestors.values()) {
const stop = async (): Promise<void> => {
for (const pending of requestors.values()) {
const rr = await pending;
await rr.stop();
}
await this.pubsub.close();
}
await pubsub.close();
};
// ---------- Internal helpers ----------
private getRequestor(
const getRequestor = (
requestTopic: string,
responseTopic: string,
key: string,
): Promise<RequestResponse<unknown, unknown>> {
let pending = this.requestors.get(key);
): Promise<RequestResponse<unknown, unknown>> => {
let pending = requestors.get(key);
if (pending === undefined) {
pending = (async () => {
const rr = new RequestResponse({
pubsub: this.pubsub,
const rr = makeRequestResponse({
pubsub,
requestTopic,
responseTopic,
subscription: `gateway-${key}`,
@ -105,14 +142,14 @@ export class DispatcherManager {
await rr.start();
return rr;
})();
this.requestors.set(key, pending);
requestors.set(key, pending);
}
return pending;
}
};
private resolveGlobalTopics(
const resolveGlobalTopics = (
kind: string,
): { requestTopic: string; responseTopic: string } {
): { requestTopic: string; responseTopic: string } => {
const entry = GLOBAL_SERVICES.get(kind);
if (entry !== undefined) {
return {
@ -125,11 +162,11 @@ export class DispatcherManager {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
};
private resolveFlowTopics(
const resolveFlowTopics = (
kind: string,
): { requestTopic: string; responseTopic: string } {
): { requestTopic: string; responseTopic: string } => {
const entry = FLOW_SERVICES.get(kind);
if (entry !== undefined) {
return {
@ -142,13 +179,13 @@ export class DispatcherManager {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
};
/**
* Determine whether a response is the final one in a streaming sequence.
* Checks for various end-of-stream markers used by different services.
*/
private isComplete(response: unknown): boolean {
const isComplete = (response: unknown): boolean => {
if (typeof response !== "object" || response === null) return true;
const res = response as Record<string, unknown>;
return (
@ -162,50 +199,50 @@ export class DispatcherManager {
// error responses are always final
(res.error !== undefined && res.error !== null)
);
}
};
// ---------- Global service dispatch ----------
async dispatchGlobalService(
const dispatchGlobalService = async (
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
): Promise<unknown> => {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
};
async dispatchGlobalServiceStreaming(
const dispatchGlobalServiceStreaming = async (
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
): Promise<void> => {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
await rr.request(translated, {
recipient: async (response) => {
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
const complete = isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
};
// ---------- Flow-scoped service dispatch ----------
async dispatchFlowService(
const dispatchFlowService = async (
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
): Promise<unknown> => {
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const rr = await getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
@ -214,16 +251,16 @@ export class DispatcherManager {
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
};
async dispatchFlowServiceStreaming(
const dispatchFlowServiceStreaming = async (
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
): Promise<void> => {
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const rr = await getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
@ -233,12 +270,12 @@ export class DispatcherManager {
await rr.request(translated, {
recipient: async (response) => {
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
const complete = isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
};
// ---------- Fire-and-forget publish ----------
@ -246,24 +283,20 @@ export class DispatcherManager {
* Publish a single message to an arbitrary topic (no request/response).
* Used for injecting documents into the processing pipeline.
*/
async publishToTopic(topic: string, message: unknown, id?: string): Promise<void> {
const producer = await this.pubsub.createProducer<unknown>({ topic });
const publishToTopic = async (topic: string, message: unknown, id?: string): Promise<void> => {
const producer = await pubsub.createProducer<unknown>({ topic });
const messageId = id ?? `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await producer.send(message, { id: messageId });
await producer.close();
}
};
// ---------- Static introspection ----------
static get flowServiceNames(): readonly string[] {
return [...FLOW_SERVICES.keys()];
}
static get globalServiceNames(): readonly string[] {
return [...GLOBAL_SERVICES.keys()];
}
static isStreamingService(kind: string): boolean {
return STREAMING_SERVICES.has(kind);
}
return {
start,
stop,
dispatchGlobalService,
dispatchGlobalServiceStreaming,
dispatchFlowService,
dispatchFlowServiceStreaming,
publishToTopic,
};
}

View file

@ -1,5 +1,11 @@
export { createGateway, run, type GatewayConfig } from "./server.js";
export { DispatcherManager } from "./dispatch/manager.js";
export {
dispatcherManagerFlowServiceNames,
dispatcherManagerGlobalServiceNames,
dispatcherManagerIsStreamingService,
makeDispatcherManager,
type DispatcherManager,
} from "./dispatch/manager.js";
export {
clientTermToInternal,
clientTripleToInternal,

View file

@ -14,7 +14,7 @@ import * as O from "effect/Option";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as EffectSocket from "effect/unstable/socket/Socket";
import { optionalStringConfig, registry, toTgError } from "@trustgraph/base";
import { DispatcherManager } from "./dispatch/manager.js";
import { makeDispatcherManager } from "./dispatch/manager.js";
import { makeGatewayRpcServer } from "./rpc-server.js";
export interface GatewayConfig {
@ -28,7 +28,7 @@ export async function createGateway(config: GatewayConfig) {
const app = Fastify({ logger: true });
await app.register(websocketPlugin);
const dispatcher = new DispatcherManager(config);
const dispatcher = makeDispatcherManager(config);
await dispatcher.start();
const rpcScope = await Effect.runPromise(Scope.make());
const rpcServer = await Effect.runPromise(

View file

@ -4,39 +4,43 @@ export { createGateway, type GatewayConfig } from "./gateway/index.js";
export { OpenAIProcessor } from "./model/text-completion/openai.js";
export { ClaudeProcessor } from "./model/text-completion/claude.js";
export {
GraphRag,
GraphRagEngine,
GraphRagLive,
makeGraphRag,
makeGraphRagEngine,
normalizeGraphRagConfig,
stringToTerm,
termToString,
type GraphRag,
type GraphRagConfig,
type GraphRagClients,
type GraphRagEngineShape,
type GraphRagQueryOptions,
} from "./retrieval/graph-rag.js";
export {
DocumentRag,
DocumentRagEngine,
DocumentRagLive,
makeDocumentRag,
makeDocumentRagEngine,
type DocumentRag,
type DocumentRagClients,
type DocumentRagEngineShape,
type DocumentRagQueryOptions,
} from "./retrieval/document-rag.js";
export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
export { makeFalkorDBTriplesStore, type FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
export { makeFalkorDBTriplesQuery, type FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
// Qdrant embeddings storage
export {
QdrantDocEmbeddingsStore,
makeQdrantDocEmbeddingsStore,
type QdrantDocEmbeddingsStore,
type QdrantDocEmbeddingsConfig,
type DocEmbeddingsMessage,
type DocEmbeddingChunk,
} from "./storage/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsStore,
makeQdrantGraphEmbeddingsStore,
type QdrantGraphEmbeddingsStore,
type QdrantGraphEmbeddingsConfig,
type GraphEmbeddingsMessage,
type GraphEmbeddingEntity,
@ -44,13 +48,15 @@ export {
// Qdrant embeddings query
export {
QdrantDocEmbeddingsQuery,
makeQdrantDocEmbeddingsQuery,
type QdrantDocEmbeddingsQuery,
type QdrantDocQueryConfig,
type ChunkMatch,
type DocEmbeddingsQueryRequest,
} from "./query/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsQuery,
makeQdrantGraphEmbeddingsQuery,
type QdrantGraphEmbeddingsQuery,
type QdrantGraphQueryConfig,
type EntityMatch,
type GraphEmbeddingsQueryRequest,
@ -81,7 +87,7 @@ export { filterToolsByGroupAndState, getNextState } from "./agent/tool-filter.js
// Librarian service
export { LibrarianService, type LibrarianServiceConfig } from "./librarian/service.js";
export { CollectionManager, type CollectionEntry } from "./librarian/collection-manager.js";
export { makeCollectionManager, type CollectionEntry, type CollectionManager } from "./librarian/collection-manager.js";
// Chunking service
export { recursiveSplit } from "./chunking/recursive-splitter.js";

View file

@ -14,60 +14,66 @@ export interface CollectionEntry {
tags: string[];
}
export class CollectionManager {
/** keyed by `${user}:${collection}` */
private collections = new Map<string, CollectionEntry>();
private key(user: string, collection: string): string {
return `${user}:${collection}`;
}
listCollections(user: string): CollectionEntry[] {
const result: CollectionEntry[] = [];
for (const entry of this.collections.values()) {
if (entry.user === user) {
result.push(entry);
}
}
return result;
}
getCollection(user: string, collection: string): CollectionEntry | undefined {
return this.collections.get(this.key(user, collection));
}
updateCollection(
export interface CollectionManager {
readonly listCollections: (user: string) => CollectionEntry[];
readonly getCollection: (user: string, collection: string) => CollectionEntry | undefined;
readonly updateCollection: (
user: string,
collection: string,
name: string,
description: string,
tags: string[],
): CollectionEntry {
const entry: CollectionEntry = { user, collection, name, description, tags };
this.collections.set(this.key(user, collection), entry);
return entry;
}
deleteCollection(user: string, collection: string): boolean {
return this.collections.delete(this.key(user, collection));
}
ensureCollectionExists(user: string, collection: string): CollectionEntry {
const existing = this.getCollection(user, collection);
if (existing !== undefined) return existing;
return this.updateCollection(user, collection, collection, "", []);
}
/** Serialize to a plain array for JSON persistence. */
toJSON(): CollectionEntry[] {
return [...this.collections.values()];
}
/** Restore from a serialized array. */
loadFromJSON(entries: CollectionEntry[]): void {
this.collections.clear();
for (const entry of entries) {
this.collections.set(this.key(entry.user, entry.collection), entry);
}
}
) => CollectionEntry;
readonly deleteCollection: (user: string, collection: string) => boolean;
readonly ensureCollectionExists: (user: string, collection: string) => CollectionEntry;
readonly toJSON: () => CollectionEntry[];
readonly loadFromJSON: (entries: CollectionEntry[]) => void;
}
export function makeCollectionManager(): CollectionManager {
/** keyed by `${user}:${collection}` */
const collections = new Map<string, CollectionEntry>();
const key = (user: string, collection: string): string => `${user}:${collection}`;
const updateCollection = (
user: string,
collection: string,
name: string,
description: string,
tags: string[],
): CollectionEntry => {
const entry: CollectionEntry = { user, collection, name, description, tags };
collections.set(key(user, collection), entry);
return entry;
};
return {
listCollections: (user) => {
const result: CollectionEntry[] = [];
for (const entry of collections.values()) {
if (entry.user === user) {
result.push(entry);
}
}
return result;
},
getCollection: (user, collection) => collections.get(key(user, collection)),
updateCollection,
deleteCollection: (user, collection) => collections.delete(key(user, collection)),
ensureCollectionExists: (user, collection) => {
const existing = collections.get(key(user, collection));
if (existing !== undefined) return existing;
return updateCollection(user, collection, collection, "", []);
},
/** Serialize to a plain array for JSON persistence. */
toJSON: () => [...collections.values()],
/** Restore from a serialized array. */
loadFromJSON: (entries) => {
collections.clear();
for (const entry of entries) {
collections.set(key(entry.user, entry.collection), entry);
}
},
};
}

File diff suppressed because it is too large Load diff

View file

@ -11,10 +11,11 @@
import { AzureOpenAI } from "openai";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
@ -22,27 +23,19 @@ import {
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class AzureOpenAIProcessor extends LlmService {
private client: AzureOpenAI;
private readonly defaultModel: string;
private readonly defaultTemperature: number;
private readonly maxOutput: number;
export type AzureOpenAIProcessorConfig = ProcessorConfig & {
model?: string;
apiKey?: string;
endpoint?: string;
apiVersion?: string;
temperature?: number;
maxOutput?: number;
};
constructor(
config: ProcessorConfig & {
model?: string;
apiKey?: string;
endpoint?: string;
apiVersion?: string;
temperature?: number;
maxOutput?: number;
},
) {
super(config);
this.defaultModel = config.model ?? process.env.AZURE_MODEL ?? "gpt-4o";
this.defaultTemperature = config.temperature ?? 0.0;
this.maxOutput = config.maxOutput ?? 4096;
export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): LlmProvider {
const defaultModel = config.model ?? process.env.AZURE_MODEL ?? "gpt-4o";
const defaultTemperature = config.temperature ?? 0.0;
const maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.AZURE_TOKEN;
if (apiKey === undefined || apiKey.length === 0) {
@ -59,115 +52,122 @@ export class AzureOpenAIProcessor extends LlmService {
process.env.AZURE_API_VERSION ??
"2024-12-01-preview";
this.client = new AzureOpenAI({ apiKey, apiVersion, endpoint });
const client = new AzureOpenAI({ apiKey, apiVersion, endpoint });
console.log("[AzureOpenAI] LLM service initialized");
}
async generateContent(
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
try {
const resp = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: this.maxOutput,
});
try {
const resp = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
});
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
} catch (err) {
if ((err as any)?.status === 429) {
throw tooManyRequestsError();
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
} catch (err) {
if ((err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
throw err;
}
}
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
override supportsStreaming(): boolean {
return true;
}
try {
const stream = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
stream: true,
stream_options: { include_usage: true },
});
async *generateContentStream(
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
let totalInputTokens = 0;
let totalOutputTokens = 0;
try {
const stream = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: this.maxOutput,
stream: true,
stream_options: { include_usage: true },
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
}
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if ((err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if ((err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
}
},
};
}
export type AzureOpenAIProcessor = ReturnType<typeof makeAzureOpenAIProcessor>;
export function makeAzureOpenAIProcessor(
config: AzureOpenAIProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeAzureOpenAIProvider(config));
}
export const AzureOpenAIProcessor = makeAzureOpenAIProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new AzureOpenAIProcessor(config))),
Llm.of(makeLlmServiceShape(makeAzureOpenAIProvider(config))),
),
});

View file

@ -7,10 +7,11 @@
import Anthropic from "@anthropic-ai/sdk";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
@ -18,132 +19,130 @@ import {
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class ClaudeProcessor extends LlmService {
private client: Anthropic;
private readonly defaultModel: string;
private readonly defaultTemperature: number;
private readonly maxOutput: number;
constructor(config: ProcessorConfig & {
model?: string;
apiKey?: string;
temperature?: number;
maxOutput?: number;
}) {
super(config);
this.defaultModel = config.model ?? "claude-sonnet-4-20250514";
this.defaultTemperature = config.temperature ?? 0.0;
this.maxOutput = config.maxOutput ?? 8192;
export type ClaudeProcessorConfig = ProcessorConfig & {
model?: string;
apiKey?: string;
temperature?: number;
maxOutput?: number;
};
export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider {
const defaultModel = config.model ?? "claude-sonnet-4-20250514";
const defaultTemperature = config.temperature ?? 0.0;
const maxOutput = config.maxOutput ?? 8192;
const apiKey = config.apiKey ?? process.env.CLAUDE_KEY;
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("Claude API key not specified");
}
this.client = new Anthropic({ apiKey });
const client = new Anthropic({ apiKey });
console.log("[Claude] LLM service initialized");
}
async generateContent(
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
try {
const response = await this.client.messages.create({
model: modelName,
max_tokens: this.maxOutput,
temperature: temp,
system,
messages: [
{ role: "user", content: prompt },
],
});
try {
const response = await client.messages.create({
model: modelName,
max_tokens: maxOutput,
temperature: temp,
system,
messages: [
{ role: "user", content: prompt },
],
});
const text = response.content[0].type === "text"
? response.content[0].text
: "";
const text = response.content[0].type === "text"
? response.content[0].text
: "";
return {
text,
inToken: response.usage.input_tokens,
outToken: response.usage.output_tokens,
model: modelName,
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
}
override supportsStreaming(): boolean {
return true;
}
async *generateContentStream(
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
try {
const stream = this.client.messages.stream({
model: modelName,
max_tokens: this.maxOutput,
temperature: temp,
system,
messages: [
{ role: "user", content: prompt },
],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield {
text: event.delta.text,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
return {
text,
inToken: response.usage.input_tokens,
outToken: response.usage.output_tokens,
model: modelName,
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const finalMessage = await stream.finalMessage();
yield {
text: "",
inToken: finalMessage.usage.input_tokens,
outToken: finalMessage.usage.output_tokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw tooManyRequestsError();
try {
const stream = client.messages.stream({
model: modelName,
max_tokens: maxOutput,
temperature: temp,
system,
messages: [
{ role: "user", content: prompt },
],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield {
text: event.delta.text,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
}
const finalMessage = await stream.finalMessage();
yield {
text: "",
inToken: finalMessage.usage.input_tokens,
outToken: finalMessage.usage.output_tokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
throw err;
}
}
},
};
}
export type ClaudeProcessor = ReturnType<typeof makeClaudeProcessor>;
export function makeClaudeProcessor(config: ClaudeProcessorConfig): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeClaudeProvider(config));
}
export const ClaudeProcessor = makeClaudeProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new ClaudeProcessor(config))),
Llm.of(makeLlmServiceShape(makeClaudeProvider(config))),
),
});

View file

@ -9,10 +9,11 @@
import { Mistral } from "@mistralai/mistralai";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
@ -20,140 +21,136 @@ import {
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class MistralProcessor extends LlmService {
private client: Mistral;
private readonly defaultModel: string;
private readonly defaultTemperature: number;
private readonly maxOutput: number;
constructor(
config: ProcessorConfig & {
model?: string;
apiKey?: string;
temperature?: number;
maxOutput?: number;
},
) {
super(config);
this.defaultModel =
config.model ?? process.env.MISTRAL_MODEL ?? "ministral-8b-latest";
this.defaultTemperature = config.temperature ?? 0.0;
this.maxOutput = config.maxOutput ?? 4096;
export type MistralProcessorConfig = ProcessorConfig & {
model?: string;
apiKey?: string;
temperature?: number;
maxOutput?: number;
};
export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider {
const defaultModel =
config.model ?? process.env.MISTRAL_MODEL ?? "ministral-8b-latest";
const defaultTemperature = config.temperature ?? 0.0;
const maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.MISTRAL_TOKEN;
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("Mistral API key not specified");
}
this.client = new Mistral({ apiKey });
const client = new Mistral({ apiKey });
console.log("[Mistral] LLM service initialized");
}
async generateContent(
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
try {
const resp = await this.client.chat.complete({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: this.maxOutput,
});
try {
const resp = await client.chat.complete({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: maxOutput,
});
return {
text: (resp.choices?.[0]?.message?.content as string) ?? "",
inToken: resp.usage?.promptTokens ?? 0,
outToken: resp.usage?.completionTokens ?? 0,
model: modelName,
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw tooManyRequestsError();
return {
text: (resp.choices?.[0]?.message?.content as string) ?? "",
inToken: resp.usage?.promptTokens ?? 0,
outToken: resp.usage?.completionTokens ?? 0,
model: modelName,
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
throw err;
}
}
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
override supportsStreaming(): boolean {
return true;
}
try {
const stream = await client.chat.stream({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: maxOutput,
});
async *generateContentStream(
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
let totalInputTokens = 0;
let totalOutputTokens = 0;
try {
const stream = await this.client.chat.stream({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
maxTokens: this.maxOutput,
});
for await (const chunk of stream) {
const delta = chunk.data?.choices?.[0]?.delta;
const content = delta?.content;
if (typeof content === "string" && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
for await (const chunk of stream) {
const delta = chunk.data?.choices?.[0]?.delta;
const content = delta?.content;
if (typeof content === "string" && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
if (chunk.data?.usage !== undefined) {
totalInputTokens = chunk.data.usage.promptTokens ?? 0;
totalOutputTokens = chunk.data.usage.completionTokens ?? 0;
}
}
if (chunk.data?.usage !== undefined) {
totalInputTokens = chunk.data.usage.promptTokens ?? 0;
totalOutputTokens = chunk.data.usage.completionTokens ?? 0;
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
throw tooManyRequestsError();
}
throw err;
}
}
},
};
}
export type MistralProcessor = ReturnType<typeof makeMistralProcessor>;
export function makeMistralProcessor(config: MistralProcessorConfig): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeMistralProvider(config));
}
export const MistralProcessor = makeMistralProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new MistralProcessor(config))),
Llm.of(makeLlmServiceShape(makeMistralProvider(config))),
),
});

View file

@ -9,27 +9,24 @@
import { Ollama } from "ollama";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class OllamaProcessor extends LlmService {
private client: Ollama;
private readonly defaultModel: string;
export type OllamaProcessorConfig = ProcessorConfig & {
model?: string;
ollamaUrl?: string;
};
constructor(config: ProcessorConfig & {
model?: string;
ollamaUrl?: string;
}) {
super(config);
this.defaultModel =
export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider {
const defaultModel =
config.model ??
process.env.OLLAMA_MODEL ??
"qwen2.5:0.5b";
@ -39,96 +36,101 @@ export class OllamaProcessor extends LlmService {
process.env.OLLAMA_URL ??
"http://localhost:11434";
this.client = new Ollama({ host });
const client = new Ollama({ host });
console.log(
`[Ollama] LLM service initialized (host=${host}, model=${this.defaultModel})`,
`[Ollama] LLM service initialized (host=${host}, model=${defaultModel})`,
);
}
async generateContent(
system: string,
prompt: string,
model?: string,
_temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const fullPrompt = system + "\n\n" + prompt;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
_temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt;
const resp = await this.client.generate({
model: modelName,
prompt: fullPrompt,
stream: false,
});
const resp = await client.generate({
model: modelName,
prompt: fullPrompt,
stream: false,
});
return {
text: resp.response,
inToken: resp.prompt_eval_count ?? 0,
outToken: resp.eval_count ?? 0,
model: modelName,
};
}
return {
text: resp.response,
inToken: resp.prompt_eval_count ?? 0,
outToken: resp.eval_count ?? 0,
model: modelName,
};
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
_temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt;
override supportsStreaming(): boolean {
return true;
}
const stream = await client.generate({
model: modelName,
prompt: fullPrompt,
stream: true,
});
async *generateContentStream(
system: string,
prompt: string,
model?: string,
_temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const fullPrompt = system + "\n\n" + prompt;
let totalInputTokens = 0;
let totalOutputTokens = 0;
const stream = await this.client.generate({
model: modelName,
prompt: fullPrompt,
stream: true,
});
let totalInputTokens = 0;
let totalOutputTokens = 0;
for await (const chunk of stream) {
for await (const chunk of stream) {
// Token counts accumulate across chunks; keep the latest values
if (chunk.prompt_eval_count !== undefined) {
totalInputTokens = chunk.prompt_eval_count;
}
if (chunk.eval_count !== undefined) {
totalOutputTokens = chunk.eval_count;
}
if (chunk.prompt_eval_count !== undefined) {
totalInputTokens = chunk.prompt_eval_count;
}
if (chunk.eval_count !== undefined) {
totalOutputTokens = chunk.eval_count;
}
if (chunk.response.length > 0) {
yield {
text: chunk.response,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
if (chunk.response.length > 0) {
yield {
text: chunk.response,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
}
}
// Final chunk with accumulated token counts
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
},
};
}
export type OllamaProcessor = ReturnType<typeof makeOllamaProcessor>;
export function makeOllamaProcessor(config: OllamaProcessorConfig): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeOllamaProvider(config));
}
export const OllamaProcessor = makeOllamaProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new OllamaProcessor(config))),
Llm.of(makeLlmServiceShape(makeOllamaProvider(config))),
),
});

View file

@ -12,37 +12,32 @@
import OpenAI from "openai";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class OpenAICompatibleProcessor extends LlmService {
private client: OpenAI;
private readonly defaultModel: string;
private readonly defaultTemperature: number;
private readonly maxOutput: number;
export type OpenAICompatibleProcessorConfig = ProcessorConfig & {
model?: string;
apiKey?: string;
baseUrl?: string;
temperature?: number;
maxOutput?: number;
};
constructor(
config: ProcessorConfig & {
model?: string;
apiKey?: string;
baseUrl?: string;
temperature?: number;
maxOutput?: number;
},
) {
super(config);
this.defaultModel =
config.model ?? process.env.OPENAI_COMPAT_MODEL ?? "default";
this.defaultTemperature = config.temperature ?? 0.0;
this.maxOutput = config.maxOutput ?? 4096;
export function makeOpenAICompatibleProvider(
config: OpenAICompatibleProcessorConfig,
): LlmProvider {
const defaultModel =
config.model ?? process.env.OPENAI_COMPAT_MODEL ?? "default";
const defaultTemperature = config.temperature ?? 0.0;
const maxOutput = config.maxOutput ?? 4096;
const baseURL = config.baseUrl ?? process.env.OPENAI_COMPAT_URL;
if (baseURL === undefined || baseURL.length === 0) {
@ -54,100 +49,107 @@ export class OpenAICompatibleProcessor extends LlmService {
const apiKey =
config.apiKey ?? process.env.OPENAI_COMPAT_KEY ?? "sk-no-key-required";
this.client = new OpenAI({ baseURL, apiKey });
const client = new OpenAI({ baseURL, apiKey });
console.log("[OpenAI-Compatible] LLM service initialized");
}
async generateContent(
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
const resp = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: this.maxOutput,
});
const resp = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: maxOutput,
});
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
}
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
override supportsStreaming(): boolean {
return true;
}
const stream = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: maxOutput,
stream: true,
});
async *generateContentStream(
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
let totalInputTokens = 0;
let totalOutputTokens = 0;
const stream = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_tokens: this.maxOutput,
stream: true,
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
}
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
},
};
}
export type OpenAICompatibleProcessor = ReturnType<typeof makeOpenAICompatibleProcessor>;
export function makeOpenAICompatibleProcessor(
config: OpenAICompatibleProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeOpenAICompatibleProvider(config));
}
export const OpenAICompatibleProcessor = makeOpenAICompatibleProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new OpenAICompatibleProcessor(config))),
Llm.of(makeLlmServiceShape(makeOpenAICompatibleProvider(config))),
),
});

View file

@ -7,10 +7,11 @@
import OpenAI from "openai";
import {
Llm,
LlmService,
makeLlmService,
makeFlowProcessorProgram,
makeLlmServiceShape,
makeLlmSpecs,
type LlmProvider,
type ProcessorConfig,
type LlmResult,
type LlmChunk,
@ -18,142 +19,140 @@ import {
} from "@trustgraph/base";
import { Effect, Layer } from "effect";
export class OpenAIProcessor extends LlmService {
private client: OpenAI;
private readonly defaultModel: string;
private readonly defaultTemperature: number;
private readonly maxOutput: number;
constructor(config: ProcessorConfig & {
model?: string;
apiKey?: string;
baseUrl?: string;
temperature?: number;
maxOutput?: number;
}) {
super(config);
this.defaultModel = config.model ?? "gpt-4o";
this.defaultTemperature = config.temperature ?? 0.0;
this.maxOutput = config.maxOutput ?? 4096;
export type OpenAIProcessorConfig = ProcessorConfig & {
model?: string;
apiKey?: string;
baseUrl?: string;
temperature?: number;
maxOutput?: number;
};
export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider {
const defaultModel = config.model ?? "gpt-4o";
const defaultTemperature = config.temperature ?? 0.0;
const maxOutput = config.maxOutput ?? 4096;
const apiKey = config.apiKey ?? process.env.OPENAI_TOKEN;
if (apiKey === undefined || apiKey.length === 0) {
throw new Error("OpenAI API key not specified");
}
this.client = new OpenAI({
const client = new OpenAI({
apiKey,
baseURL: config.baseUrl ?? process.env.OPENAI_BASE_URL,
});
console.log("[OpenAI] LLM service initialized");
}
async generateContent(
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
return {
generateContent: async (
system: string,
prompt: string,
model?: string,
temperature?: number,
): Promise<LlmResult> => {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
try {
const resp = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: this.maxOutput,
});
try {
const resp = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
});
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw tooManyRequestsError();
return {
text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
throw err;
}
}
},
supportsStreaming: () => true,
generateContentStream: async function* (
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature;
override supportsStreaming(): boolean {
return true;
}
try {
const stream = await client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: maxOutput,
stream: true,
stream_options: { include_usage: true },
});
async *generateContentStream(
system: string,
prompt: string,
model?: string,
temperature?: number,
): AsyncGenerator<LlmChunk> {
const modelName = model ?? this.defaultModel;
const temp = temperature ?? this.defaultTemperature;
let totalInputTokens = 0;
let totalOutputTokens = 0;
try {
const stream = await this.client.chat.completions.create({
model: modelName,
messages: [
{ role: "system", content: system },
{ role: "user", content: prompt },
],
temperature: temp,
max_completion_tokens: this.maxOutput,
stream: true,
stream_options: { include_usage: true },
});
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
let totalInputTokens = 0;
let totalOutputTokens = 0;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content !== null && content !== undefined && content.length > 0) {
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
}
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
yield {
text: "",
inToken: totalInputTokens,
outToken: totalOutputTokens,
model: modelName,
isFinal: true,
};
} catch (err) {
if (err instanceof OpenAI.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
}
},
};
}
export type OpenAIProcessor = ReturnType<typeof makeOpenAIProcessor>;
export function makeOpenAIProcessor(config: OpenAIProcessorConfig): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeOpenAIProvider(config));
}
export const OpenAIProcessor = makeOpenAIProcessor;
export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
id: "text-completion",
specs: () => makeLlmSpecs(),
layer: (config) =>
Layer.succeed(
Llm,
Llm.of(makeLlmServiceShape(new OpenAIProcessor(config))),
Llm.of(makeLlmServiceShape(makeOpenAIProvider(config))),
),
});

View file

@ -25,12 +25,13 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
type ProcessorConfig,
type EffectConfigHandler,
type FlowContext,
type FlowProcessorRuntime,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
type PromptRequest,
@ -136,11 +137,11 @@ const makePromptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplate
return {
specs: [
new ConsumerSpec<PromptRequest, FlowResourceNotFoundError | MessagingDeliveryError>(
makeConsumerSpec<PromptRequest, FlowResourceNotFoundError | MessagingDeliveryError>(
"prompt-request",
onRequest,
),
new ProducerSpec<PromptResponse>("prompt-response"),
makeProducerSpec<PromptResponse>("prompt-response"),
],
configHandlers: [onPromptConfig],
};
@ -154,27 +155,24 @@ const promptTemplateRuntime = (config: PromptTemplateConfig): PromptTemplateRunt
return runtime;
};
export class PromptTemplateService extends FlowProcessor {
private readonly runtime: PromptTemplateRuntime;
export type PromptTemplateService = FlowProcessorRuntime;
constructor(config: PromptTemplateConfig) {
super(config);
this.runtime = makePromptTemplateRuntime(config);
for (const spec of this.runtime.specs) {
this.registerSpecification(spec);
}
for (const handler of this.runtime.configHandlers) {
this.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(handler(pushedConfig, version)),
);
}
console.log("[PromptTemplate] Service initialized");
export function makePromptTemplateService(config: PromptTemplateConfig): PromptTemplateService {
const runtime = makePromptTemplateRuntime(config);
const service = makeFlowProcessor(config, {
specifications: runtime.specs,
});
for (const handler of runtime.configHandlers) {
service.registerConfigHandler((pushedConfig, version) =>
Effect.runPromise(handler(pushedConfig, version)),
);
}
console.log("[PromptTemplate] Service initialized");
return service;
}
export const PromptTemplateService = makePromptTemplateService;
/**
* Simple template rendering: replaces {variable} placeholders with values.
* Unmatched placeholders are left as-is.

View file

@ -8,10 +8,11 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
@ -78,37 +79,34 @@ const onDocEmbeddingsQueryMessage = Effect.fn("DocEmbeddingsQueryService.onMessa
});
export const makeDocEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantDocEmbeddingsQueryService>> => [
new ConsumerSpec<
makeConsumerSpec<
DocumentEmbeddingsRequest,
FlowResourceNotFoundError | MessagingDeliveryError,
QdrantDocEmbeddingsQueryService
>("document-embeddings-request", onDocEmbeddingsQueryMessage),
new ProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
makeProducerSpec<DocumentEmbeddingsResponse>("document-embeddings-response"),
];
export class DocEmbeddingsQueryService extends FlowProcessor<QdrantDocEmbeddingsQueryService> {
private readonly query = makeQdrantDocEmbeddingsQueryService();
export type DocEmbeddingsQueryService = FlowProcessorRuntime<QdrantDocEmbeddingsQueryService>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeDocEmbeddingsQuerySpecs()) {
this.registerSpecification(spec);
}
console.log("[DocEmbeddingsQuery] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(
QdrantDocEmbeddingsQueryService,
QdrantDocEmbeddingsQueryService.of(this.query),
export function makeDocEmbeddingsQueryService(config: ProcessorConfig): DocEmbeddingsQueryService {
const query = makeQdrantDocEmbeddingsQueryService();
const service = makeFlowProcessor(config, {
specifications: makeDocEmbeddingsQuerySpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(
QdrantDocEmbeddingsQueryService,
QdrantDocEmbeddingsQueryService.of(query),
),
),
);
}
});
console.log("[DocEmbeddingsQuery] Service initialized");
return service;
}
export const DocEmbeddingsQueryService = makeDocEmbeddingsQueryService;
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantDocQueryConfig, never, QdrantDocEmbeddingsQueryService>({
id: "doc-embeddings-query",
specs: () => makeDocEmbeddingsQuerySpecs(),

View file

@ -30,22 +30,24 @@ export interface DocEmbeddingsQueryRequest {
limit: number;
}
export class QdrantDocEmbeddingsQuery {
private client: QdrantClient;
export interface QdrantDocEmbeddingsQuery {
readonly query: (request: DocEmbeddingsQueryRequest) => Promise<ChunkMatch[]>;
}
constructor(config: QdrantDocQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
export function makeQdrantDocEmbeddingsQuery(
config: QdrantDocQueryConfig = {},
): QdrantDocEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantDocQuery] Query service initialized");
}
console.log("[QdrantDocQuery] Query service initialized");
async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> {
const query = async (request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> => {
const { vector, user, collection, limit } = request;
if (vector.length === 0) {
@ -56,7 +58,7 @@ export class QdrantDocEmbeddingsQuery {
const collectionName = `d_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
const exists = await client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`,
@ -64,7 +66,7 @@ export class QdrantDocEmbeddingsQuery {
return [];
}
const searchResult = await this.client.search(collectionName, {
const searchResult = await client.search(collectionName, {
vector,
limit,
with_payload: true,
@ -84,7 +86,9 @@ export class QdrantDocEmbeddingsQuery {
}
return chunks;
}
};
return { query };
}
export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocEmbeddingsQueryError>()(
@ -119,7 +123,7 @@ const qdrantDocEmbeddingsQueryError = (operation: string, cause: unknown) =>
export const makeQdrantDocEmbeddingsQueryService = (
config: QdrantDocQueryConfig = {},
): QdrantDocEmbeddingsQueryServiceShape => {
const query = new QdrantDocEmbeddingsQuery(config);
const query = makeQdrantDocEmbeddingsQuery(config);
return {
query: Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request) {
return yield* Effect.tryPromise({

View file

@ -8,10 +8,11 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
@ -79,37 +80,34 @@ const onGraphEmbeddingsQueryMessage = Effect.fn("GraphEmbeddingsQueryService.onM
});
export const makeGraphEmbeddingsQuerySpecs = (): ReadonlyArray<Spec<QdrantGraphEmbeddingsQueryService>> => [
new ConsumerSpec<
makeConsumerSpec<
GraphEmbeddingsRequest,
FlowResourceNotFoundError | MessagingDeliveryError,
QdrantGraphEmbeddingsQueryService
>("graph-embeddings-request", onGraphEmbeddingsQueryMessage),
new ProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
makeProducerSpec<GraphEmbeddingsResponse>("graph-embeddings-response"),
];
export class GraphEmbeddingsQueryService extends FlowProcessor<QdrantGraphEmbeddingsQueryService> {
private readonly query = makeQdrantGraphEmbeddingsQueryService();
export type GraphEmbeddingsQueryService = FlowProcessorRuntime<QdrantGraphEmbeddingsQueryService>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeGraphEmbeddingsQuerySpecs()) {
this.registerSpecification(spec);
}
console.log("[GraphEmbeddingsQuery] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(
QdrantGraphEmbeddingsQueryService,
QdrantGraphEmbeddingsQueryService.of(this.query),
export function makeGraphEmbeddingsQueryService(config: ProcessorConfig): GraphEmbeddingsQueryService {
const query = makeQdrantGraphEmbeddingsQueryService();
const service = makeFlowProcessor(config, {
specifications: makeGraphEmbeddingsQuerySpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(
QdrantGraphEmbeddingsQueryService,
QdrantGraphEmbeddingsQueryService.of(query),
),
),
);
}
});
console.log("[GraphEmbeddingsQuery] Service initialized");
return service;
}
export const GraphEmbeddingsQueryService = makeGraphEmbeddingsQueryService;
export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantGraphQueryConfig, never, QdrantGraphEmbeddingsQueryService>({
id: "graph-embeddings-query",
specs: () => makeGraphEmbeddingsQuerySpecs(),

View file

@ -39,22 +39,24 @@ function createTerm(value: string): Term {
return { type: "LITERAL", value };
}
export class QdrantGraphEmbeddingsQuery {
private client: QdrantClient;
export interface QdrantGraphEmbeddingsQuery {
readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<EntityMatch[]>;
}
constructor(config: QdrantGraphQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
export function makeQdrantGraphEmbeddingsQuery(
config: QdrantGraphQueryConfig = {},
): QdrantGraphEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
console.log("[QdrantGraphQuery] Query service initialized");
}
console.log("[QdrantGraphQuery] Query service initialized");
async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> {
const query = async (request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> => {
const { vector, user, collection, limit } = request;
if (vector.length === 0) {
@ -65,7 +67,7 @@ export class QdrantGraphEmbeddingsQuery {
const collectionName = `t_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
const exists = await client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
@ -75,7 +77,7 @@ export class QdrantGraphEmbeddingsQuery {
// Query 2x the limit so we have a better chance of getting `limit`
// unique entities after deduplication (same heuristic as Python impl)
const searchResult = await this.client.search(collectionName, {
const searchResult = await client.search(collectionName, {
vector,
limit: limit * 2,
with_payload: true,
@ -104,7 +106,9 @@ export class QdrantGraphEmbeddingsQuery {
}
return entities;
}
};
return { query };
}
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
@ -139,7 +143,7 @@ const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
export const makeQdrantGraphEmbeddingsQueryService = (
config: QdrantGraphQueryConfig = {},
): QdrantGraphEmbeddingsQueryServiceShape => {
const query = new QdrantGraphEmbeddingsQuery(config);
const query = makeQdrantGraphEmbeddingsQuery(config);
return {
query: Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (request) {
return yield* Effect.tryPromise({

View file

@ -8,10 +8,11 @@
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
makeFlowProcessor,
makeConsumerSpec,
makeProducerSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
@ -65,37 +66,34 @@ const onTriplesQueryMessage = Effect.fn("TriplesQueryService.onMessage")(functio
});
export const makeTriplesQuerySpecs = (): ReadonlyArray<Spec<FalkorDBTriplesQueryService>> => [
new ConsumerSpec<
makeConsumerSpec<
TriplesQueryRequest,
FlowResourceNotFoundError | MessagingDeliveryError,
FalkorDBTriplesQueryService
>("triples-request", onTriplesQueryMessage),
new ProducerSpec<TriplesQueryResponse>("triples-response"),
makeProducerSpec<TriplesQueryResponse>("triples-response"),
];
export class TriplesQueryService extends FlowProcessor<FalkorDBTriplesQueryService> {
private readonly query = makeFalkorDBTriplesQueryService();
export type TriplesQueryService = FlowProcessorRuntime<FalkorDBTriplesQueryService>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeTriplesQuerySpecs()) {
this.registerSpecification(spec);
}
console.log("[TriplesQuery] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(
FalkorDBTriplesQueryService,
FalkorDBTriplesQueryService.of(this.query),
export function makeTriplesQueryService(config: ProcessorConfig): TriplesQueryService {
const query = makeFalkorDBTriplesQueryService();
const service = makeFlowProcessor(config, {
specifications: makeTriplesQuerySpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(
FalkorDBTriplesQueryService,
FalkorDBTriplesQueryService.of(query),
),
),
);
}
});
console.log("[TriplesQuery] Service initialized");
return service;
}
export const TriplesQueryService = makeTriplesQueryService;
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBQueryConfig, never, FalkorDBTriplesQueryService>({
id: "triples-query",
specs: () => makeTriplesQuerySpecs(),

View file

@ -41,35 +41,194 @@ function field(row: unknown, key: string): string {
return (row as Record<string, unknown>)?.[key] as string ?? "";
}
export class FalkorDBTriplesQuery {
private graph: Graph;
private connectPromise: Promise<void>;
export interface FalkorDBTriplesQuery {
readonly queryTriples: (
s?: Term,
p?: Term,
o?: Term,
limit?: number,
) => Promise<Triple[]>;
}
constructor(config: FalkorDBQueryConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
export function makeFalkorDBTriplesQuery(
config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQuery {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
this.connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesQuery] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesQuery] Connection failed:`, err);
throw err;
});
}
const client = createClient({ url });
const graph = new Graph(client, database);
const connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesQuery] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesQuery] Connection failed:`, err);
throw err;
});
private async ensureConnected(): Promise<void> {
await this.connectPromise;
}
const ensureConnected = async (): Promise<void> => {
await connectPromise;
};
async queryTriples(
const matchPattern = async (
out: [string, string, string][],
sv: string, pv: string, ov: string, limit: number,
): Promise<void> => {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const result = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of (result.data ?? [])) {
out.push([sv, pv, ov]);
}
}
};
const matchSP = async (
out: [string, string, string][],
sv: string, pv: string, limit: number,
): Promise<void> => {
// Literals
const litResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
// Nodes
const nodeResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
};
const matchSO = async (
out: [string, string, string][],
sv: string, ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([sv, field(rec, "rel"), ov]);
}
}
};
const matchPO = async (
out: [string, string, string][],
pv: string, ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), pv, ov]);
}
}
};
const matchS = async (
out: [string, string, string][],
sv: string, limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
};
const matchP = async (
out: [string, string, string][],
pv: string, limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
};
const matchO = async (
out: [string, string, string][],
ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), ov]);
}
}
};
const matchAll = async (
out: [string, string, string][],
limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
};
const queryTriples = async (
s?: Term,
p?: Term,
o?: Term,
limit = 100,
): Promise<Triple[]> {
await this.ensureConnected();
): Promise<Triple[]> => {
await ensureConnected();
const sv = termToValue(s);
const pv = termToValue(p);
const ov = termToValue(o);
@ -79,28 +238,28 @@ export class FalkorDBTriplesQuery {
// Query both Node and Literal targets for each pattern
if (sv !== null && pv !== null && ov !== null) {
// SPO — exact match
await this.matchPattern(rawTriples, sv, pv, ov, limit);
await matchPattern(rawTriples, sv, pv, ov, limit);
} else if (sv !== null && pv !== null) {
// SP — known subject + predicate
await this.matchSP(rawTriples, sv, pv, limit);
await matchSP(rawTriples, sv, pv, limit);
} else if (sv !== null && ov !== null) {
// SO — known subject + object
await this.matchSO(rawTriples, sv, ov, limit);
await matchSO(rawTriples, sv, ov, limit);
} else if (pv !== null && ov !== null) {
// PO — known predicate + object
await this.matchPO(rawTriples, pv, ov, limit);
await matchPO(rawTriples, pv, ov, limit);
} else if (sv !== null) {
// S only
await this.matchS(rawTriples, sv, limit);
await matchS(rawTriples, sv, limit);
} else if (pv !== null) {
// P only
await this.matchP(rawTriples, pv, limit);
await matchP(rawTriples, pv, limit);
} else if (ov !== null) {
// O only
await this.matchO(rawTriples, ov, limit);
await matchO(rawTriples, ov, limit);
} else {
// Wildcard — all triples
await this.matchAll(rawTriples, limit);
await matchAll(rawTriples, limit);
}
return rawTriples
@ -111,160 +270,9 @@ export class FalkorDBTriplesQuery {
p: createTerm(p),
o: createTerm(o),
}));
}
};
private async matchPattern(
out: [string, string, string][],
sv: string, pv: string, ov: string, limit: number,
): Promise<void> {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of (result.data ?? [])) {
out.push([sv, pv, ov]);
}
}
}
private async matchSP(
out: [string, string, string][],
sv: string, pv: string, limit: number,
): Promise<void> {
// Literals
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
// Nodes
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
}
private async matchSO(
out: [string, string, string][],
sv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([sv, field(rec, "rel"), ov]);
}
}
}
private async matchPO(
out: [string, string, string][],
pv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), pv, ov]);
}
}
}
private async matchS(
out: [string, string, string][],
sv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
}
private async matchP(
out: [string, string, string][],
pv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
}
private async matchO(
out: [string, string, string][],
ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), ov]);
}
}
}
private async matchAll(
out: [string, string, string][],
limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
}
return { queryTriples };
}
export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()(
@ -302,7 +310,7 @@ const falkorDBTriplesQueryError = (operation: string, cause: unknown) =>
export const makeFalkorDBTriplesQueryService = (
config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQueryServiceShape => {
const query = new FalkorDBTriplesQuery(config);
const query = makeFalkorDBTriplesQuery(config);
return {
queryTriples: Effect.fn("FalkorDBTriplesQuery.queryTriples")((
s: Term | undefined,

View file

@ -8,10 +8,10 @@
*/
import {
ConsumerSpec,
FlowProcessor,
ProducerSpec,
RequestResponseSpec,
makeConsumerSpec,
makeFlowProcessor,
makeProducerSpec,
makeRequestResponseSpec,
makeFlowProcessorProgram,
type DocumentEmbeddingsRequest,
type DocumentEmbeddingsResponse,
@ -22,6 +22,7 @@ import {
type EmbeddingsRequest,
type EmbeddingsResponse,
type FlowContext,
type FlowProcessorRuntime,
type FlowRequestOptions,
type FlowRequestor,
type FlowResourceNotFoundError,
@ -113,48 +114,47 @@ const onDocumentRagRequest = Effect.fn("DocumentRagService.onRequest")(function*
});
export const makeDocumentRagSpecs = (): ReadonlyArray<Spec<DocumentRagEngine>> => [
new ConsumerSpec<DocumentRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, DocumentRagEngine>(
makeConsumerSpec<DocumentRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, DocumentRagEngine>(
"document-rag-request",
onDocumentRagRequest,
),
new ProducerSpec<DocumentRagResponse>("document-rag-response"),
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
makeProducerSpec<DocumentRagResponse>("document-rag-response"),
makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
"text-completion-request",
"text-completion-response",
),
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
makeRequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
"embeddings",
"embeddings-request",
"embeddings-response",
),
new RequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
makeRequestResponseSpec<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>(
"doc-embeddings",
"document-embeddings-request",
"document-embeddings-response",
),
new RequestResponseSpec<PromptRequest, PromptResponse>(
makeRequestResponseSpec<PromptRequest, PromptResponse>(
"prompt",
"prompt-request",
"prompt-response",
),
];
export class DocumentRagService extends FlowProcessor<DocumentRagEngine> {
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeDocumentRagSpecs()) {
this.registerSpecification(spec);
}
}
export type DocumentRagService = FlowProcessorRuntime<DocumentRagEngine>;
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(DocumentRagEngine, DocumentRagEngine.of(makeDocumentRagEngine())),
);
}
export function makeDocumentRagService(config: ProcessorConfig): DocumentRagService {
return makeFlowProcessor(config, {
specifications: makeDocumentRagSpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(DocumentRagEngine, DocumentRagEngine.of(makeDocumentRagEngine())),
),
});
}
export const DocumentRagService = makeDocumentRagService;
export const program = makeFlowProcessorProgram({
id: "document-rag",
specs: makeDocumentRagSpecs,

View file

@ -82,20 +82,19 @@ export const DocumentRagLive: Layer.Layer<DocumentRagEngine> = Layer.succeed(
DocumentRagEngine.of(makeDocumentRagEngine()),
);
export class DocumentRag {
private readonly engine = makeDocumentRagEngine();
private readonly clients: DocumentRagClients;
constructor(clients: DocumentRagClients) {
this.clients = clients;
}
query(
export interface DocumentRag {
readonly query: (
queryText: string,
options?: DocumentRagQueryOptions,
): Promise<string> {
return Effect.runPromise(this.engine.query(this.clients, queryText, options));
}
) => Promise<string>;
}
export function makeDocumentRag(clients: DocumentRagClients): DocumentRag {
const engine = makeDocumentRagEngine();
return {
query: (queryText, options) =>
Effect.runPromise(engine.query(clients, queryText, options)),
};
}
async function queryDocumentRag(

View file

@ -8,14 +8,15 @@
*/
import {
ConsumerSpec,
FlowProcessor,
ProducerSpec,
RequestResponseSpec,
makeConsumerSpec,
makeFlowProcessor,
makeProducerSpec,
makeRequestResponseSpec,
makeFlowProcessorProgram,
type EffectRequestOptions,
type EffectRequestResponse,
type FlowContext,
type FlowProcessorRuntime,
type FlowRequestOptions,
type FlowRequestor,
type FlowResourceNotFoundError,
@ -139,53 +140,52 @@ const onGraphRagRequest = Effect.fn("GraphRagService.onRequest")(function* (
});
export const makeGraphRagSpecs = (): ReadonlyArray<Spec<GraphRagEngine>> => [
new ConsumerSpec<GraphRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, GraphRagEngine>(
makeConsumerSpec<GraphRagRequest, FlowResourceNotFoundError | MessagingDeliveryError, GraphRagEngine>(
"graph-rag-request",
onGraphRagRequest,
),
new ProducerSpec<GraphRagResponse>("graph-rag-response"),
new RequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
makeProducerSpec<GraphRagResponse>("graph-rag-response"),
makeRequestResponseSpec<TextCompletionRequest, TextCompletionResponse>(
"llm",
"text-completion-request",
"text-completion-response",
),
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
makeRequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
"embeddings",
"embeddings-request",
"embeddings-response",
),
new RequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
makeRequestResponseSpec<GraphEmbeddingsRequest, GraphEmbeddingsResponse>(
"graph-embeddings",
"graph-embeddings-request",
"graph-embeddings-response",
),
new RequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
makeRequestResponseSpec<TriplesQueryRequest, TriplesQueryResponse>(
"triples",
"triples-request",
"triples-response",
),
new RequestResponseSpec<PromptRequest, PromptResponse>(
makeRequestResponseSpec<PromptRequest, PromptResponse>(
"prompt",
"prompt-request",
"prompt-response",
),
];
export class GraphRagService extends FlowProcessor<GraphRagEngine> {
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeGraphRagSpecs()) {
this.registerSpecification(spec);
}
}
export type GraphRagService = FlowProcessorRuntime<GraphRagEngine>;
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(GraphRagEngine, GraphRagEngine.of(makeGraphRagEngine())),
);
}
export function makeGraphRagService(config: ProcessorConfig): GraphRagService {
return makeFlowProcessor(config, {
specifications: makeGraphRagSpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(GraphRagEngine, GraphRagEngine.of(makeGraphRagEngine())),
),
});
}
export const GraphRagService = makeGraphRagService;
export const program = makeFlowProcessorProgram({
id: "graph-rag",
specs: makeGraphRagSpecs,

View file

@ -124,27 +124,22 @@ export const GraphRagLive: Layer.Layer<GraphRagEngine> = Layer.succeed(
GraphRagEngine.of(makeGraphRagEngine()),
);
export class GraphRag {
private readonly engine = makeGraphRagEngine();
private readonly clients: GraphRagClients;
private readonly config: GraphRagConfig;
constructor(
clients: GraphRagClients,
config: GraphRagConfig = {},
) {
this.clients = clients;
this.config = config;
}
query(
export interface GraphRag {
readonly query: (
queryText: string,
options?: GraphRagQueryOptions,
): Promise<GraphRagResult> {
return Effect.runPromise(
this.engine.query(this.clients, queryText, options, this.config),
);
}
) => Promise<GraphRagResult>;
}
export function makeGraphRag(
clients: GraphRagClients,
config: GraphRagConfig = {},
): GraphRag {
const engine = makeGraphRagEngine();
return {
query: (queryText, options) =>
Effect.runPromise(engine.query(clients, queryText, options, config)),
};
}
async function queryGraphRag(

View file

@ -10,10 +10,11 @@
*/
import {
FlowProcessor,
ConsumerSpec,
RequestResponseSpec,
makeFlowProcessor,
makeConsumerSpec,
makeRequestResponseSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type FlowResourceNotFoundError,
type MessagingDeliveryError,
@ -77,40 +78,37 @@ const onGraphEmbeddingsStoreMessage = Effect.fn("GraphEmbeddingsStoreService.onM
});
export const makeGraphEmbeddingsStoreSpecs = (): ReadonlyArray<Spec<GraphEmbeddingsStoreRequirements>> => [
new ConsumerSpec<EntityContexts, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements>(
makeConsumerSpec<EntityContexts, GraphEmbeddingsStoreError, GraphEmbeddingsStoreRequirements>(
"store-graph-embeddings-input",
onGraphEmbeddingsStoreMessage,
),
new RequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
makeRequestResponseSpec<EmbeddingsRequest, EmbeddingsResponse>(
"embeddings-client",
"embeddings-request",
"embeddings-response",
),
];
export class GraphEmbeddingsStoreService extends FlowProcessor<GraphEmbeddingsStoreRequirements> {
private readonly store = makeQdrantGraphEmbeddingsStoreService();
export type GraphEmbeddingsStoreService = FlowProcessorRuntime<GraphEmbeddingsStoreRequirements>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeGraphEmbeddingsStoreSpecs()) {
this.registerSpecification(spec);
}
console.log("[GraphEmbeddingsStore] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(
QdrantGraphEmbeddingsStoreService,
QdrantGraphEmbeddingsStoreService.of(this.store),
export function makeGraphEmbeddingsStoreService(config: ProcessorConfig): GraphEmbeddingsStoreService {
const store = makeQdrantGraphEmbeddingsStoreService();
const service = makeFlowProcessor(config, {
specifications: makeGraphEmbeddingsStoreSpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(
QdrantGraphEmbeddingsStoreService,
QdrantGraphEmbeddingsStoreService.of(store),
),
),
);
}
});
console.log("[GraphEmbeddingsStore] Service initialized");
return service;
}
export const GraphEmbeddingsStoreService = makeGraphEmbeddingsStoreService;
export const program = makeFlowProcessorProgram<
ProcessorConfig & QdrantGraphEmbeddingsConfig,
never,

View file

@ -27,51 +27,53 @@ export interface DocEmbeddingsMessage {
chunks: DocEmbeddingChunk[];
}
export class QdrantDocEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
export interface QdrantDocEmbeddingsStore {
readonly store: (message: DocEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
}
constructor(config: QdrantDocEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
export function makeQdrantDocEmbeddingsStore(
config: QdrantDocEmbeddingsConfig = {},
): QdrantDocEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const knownCollections = new Set<string>();
console.log("[QdrantDocEmbeddings] Store initialized");
}
console.log("[QdrantDocEmbeddings] Store initialized");
private collectionName(user: string, collection: string, dim: number): string {
return `d_${user}_${collection}_${dim}`;
}
const collectionName = (user: string, collection: string, dim: number): string =>
`d_${user}_${collection}_${dim}`;
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const ensureCollection = async (name: string, dim: number): Promise<void> => {
if (knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
const exists = await client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
await client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
knownCollections.add(name);
};
async store(message: DocEmbeddingsMessage): Promise<void> {
const store = async (message: DocEmbeddingsMessage): Promise<void> => {
for (const chunk of message.chunks) {
if (chunk.chunkId.length === 0) continue;
if (chunk.vector.length === 0) continue;
const dim = chunk.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
const name = collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
await ensureCollection(name, dim);
await this.client.upsert(name, {
await client.upsert(name, {
points: [
{
id: crypto.randomUUID(),
@ -86,12 +88,12 @@ export class QdrantDocEmbeddingsStore {
],
});
}
}
};
async deleteCollection(user: string, collection: string): Promise<void> {
const deleteCollection = async (user: string, collection: string): Promise<void> => {
const prefix = `d_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const allCollections = await client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
@ -102,13 +104,15 @@ export class QdrantDocEmbeddingsStore {
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
await client.deleteCollection(coll.name);
knownCollections.delete(coll.name);
console.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
};
return { store, deleteCollection };
}

View file

@ -43,57 +43,59 @@ function getTermValue(term: Term): string | null {
}
}
export class QdrantGraphEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
export interface QdrantGraphEmbeddingsStore {
readonly store: (message: GraphEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
}
constructor(config: QdrantGraphEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
export function makeQdrantGraphEmbeddingsStore(
config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const client = new QdrantClient({
url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
});
const knownCollections = new Set<string>();
console.log("[QdrantGraphEmbeddings] Store initialized");
}
console.log("[QdrantGraphEmbeddings] Store initialized");
private collectionName(user: string, collection: string, dim: number): string {
return `t_${user}_${collection}_${dim}`;
}
const collectionName = (user: string, collection: string, dim: number): string =>
`t_${user}_${collection}_${dim}`;
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const ensureCollection = async (name: string, dim: number): Promise<void> => {
if (knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
const exists = await client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
await client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
knownCollections.add(name);
};
async store(message: GraphEmbeddingsMessage): Promise<void> {
const store = async (message: GraphEmbeddingsMessage): Promise<void> => {
for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity);
if (entityValue === null || entityValue.length === 0) continue;
if (entry.vector.length === 0) continue;
const dim = entry.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
const name = collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
await ensureCollection(name, dim);
const payload: Record<string, unknown> = { entity: entityValue };
if (entry.chunkId !== undefined && entry.chunkId.length > 0) {
payload.chunk_id = entry.chunkId;
}
await this.client.upsert(name, {
await client.upsert(name, {
points: [
{
id: crypto.randomUUID(),
@ -103,12 +105,12 @@ export class QdrantGraphEmbeddingsStore {
],
});
}
}
};
async deleteCollection(user: string, collection: string): Promise<void> {
const deleteCollection = async (user: string, collection: string): Promise<void> => {
const prefix = `t_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const allCollections = await client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
@ -119,15 +121,17 @@ export class QdrantGraphEmbeddingsStore {
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
await client.deleteCollection(coll.name);
knownCollections.delete(coll.name);
console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
};
return { store, deleteCollection };
}
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
@ -166,7 +170,7 @@ const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
export const makeQdrantGraphEmbeddingsStoreService = (
config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStoreServiceShape => {
const store = new QdrantGraphEmbeddingsStore(config);
const store = makeQdrantGraphEmbeddingsStore(config);
return {
store: Effect.fn("QdrantGraphEmbeddingsStore.store")(function* (message) {
return yield* Effect.tryPromise({

View file

@ -9,9 +9,10 @@
*/
import {
FlowProcessor,
ConsumerSpec,
makeFlowProcessor,
makeConsumerSpec,
type ProcessorConfig,
type FlowProcessorRuntime,
type FlowContext,
type Triples,
type Spec,
@ -45,35 +46,32 @@ const onStoreTriplesMessage = Effect.fn("TriplesStoreService.onMessage")(functio
});
export const makeTriplesStoreSpecs = (): ReadonlyArray<Spec<FalkorDBTriplesStoreService>> => [
new ConsumerSpec<Triples, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService>(
makeConsumerSpec<Triples, FalkorDBTriplesStoreError, FalkorDBTriplesStoreService>(
"store-triples-input",
onStoreTriplesMessage,
),
];
export class TriplesStoreService extends FlowProcessor<FalkorDBTriplesStoreService> {
private readonly store = makeFalkorDBTriplesStoreService();
export type TriplesStoreService = FlowProcessorRuntime<FalkorDBTriplesStoreService>;
constructor(config: ProcessorConfig) {
super(config);
for (const spec of makeTriplesStoreSpecs()) {
this.registerSpecification(spec);
}
console.log("[TriplesStore] Service initialized");
}
override startEffect() {
return super.startEffect().pipe(
Effect.provideService(
FalkorDBTriplesStoreService,
FalkorDBTriplesStoreService.of(this.store),
export function makeTriplesStoreService(config: ProcessorConfig): TriplesStoreService {
const store = makeFalkorDBTriplesStoreService();
const service = makeFlowProcessor(config, {
specifications: makeTriplesStoreSpecs(),
provide: (effect) =>
effect.pipe(
Effect.provideService(
FalkorDBTriplesStoreService,
FalkorDBTriplesStoreService.of(store),
),
),
);
}
});
console.log("[TriplesStore] Service initialized");
return service;
}
export const TriplesStoreService = makeTriplesStoreService;
export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBConfig, never, FalkorDBTriplesStoreService>({
id: "triples-store",
specs: () => makeTriplesStoreSpecs(),

View file

@ -30,107 +30,136 @@ function getTermValue(term: Term): string {
}
}
export class FalkorDBTriplesStore {
private graph: Graph;
private connectPromise: Promise<void>;
export interface FalkorDBTriplesStore {
readonly createNode: (uri: string, user: string, collection: string) => Promise<void>;
readonly createLiteral: (value: string, user: string, collection: string) => Promise<void>;
readonly relateNode: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Promise<void>;
readonly relateLiteral: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Promise<void>;
readonly storeTriples: (
triples: Triple[],
user?: string,
collection?: string,
) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>;
}
constructor(config: FalkorDBConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
export function makeFalkorDBTriplesStore(config: FalkorDBConfig = {}): FalkorDBTriplesStore {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
this.connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesStore] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesStore] Connection failed:`, err);
throw err;
});
}
const client = createClient({ url });
const graph = new Graph(client, database);
const connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesStore] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesStore] Connection failed:`, err);
throw err;
});
private async ensureConnected(): Promise<void> {
await this.connectPromise;
}
const ensureConnected = async (): Promise<void> => {
await connectPromise;
};
async createNode(uri: string, user: string, collection: string): Promise<void> {
await this.ensureConnected();
await this.graph.query(
const createNode = async (uri: string, user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MERGE (n:Node {uri: $uri, user: $user, collection: $collection})",
{ params: { uri, user, collection } },
);
}
};
async createLiteral(value: string, user: string, collection: string): Promise<void> {
await this.ensureConnected();
await this.graph.query(
const createLiteral = async (value: string, user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MERGE (n:Literal {value: $value, user: $user, collection: $collection})",
{ params: { value, user, collection } },
);
}
};
async relateNode(
const relateNode = async (
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.ensureConnected();
await this.graph.query(
): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
};
async relateLiteral(
const relateLiteral = async (
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.ensureConnected();
await this.graph.query(
): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
};
async storeTriples(
const storeTriples = async (
triples: Triple[],
user = "default",
collection = "default",
): Promise<void> {
): Promise<void> => {
for (const t of triples) {
const s = getTermValue(t.s);
const p = getTermValue(t.p);
const o = getTermValue(t.o);
await this.createNode(s, user, collection);
await createNode(s, user, collection);
if (t.o.type === "IRI") {
await this.createNode(o, user, collection);
await this.relateNode(s, p, o, user, collection);
await createNode(o, user, collection);
await relateNode(s, p, o, user, collection);
} else {
await this.createLiteral(o, user, collection);
await this.relateLiteral(s, p, o, user, collection);
await createLiteral(o, user, collection);
await relateLiteral(s, p, o, user, collection);
}
}
}
};
async deleteCollection(user: string, collection: string): Promise<void> {
await this.ensureConnected();
await this.graph.query(
const deleteCollection = async (user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
await graph.query(
"MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
await graph.query(
"MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c",
{ params: { user, collection } },
);
}
};
return {
createNode,
createLiteral,
relateNode,
relateLiteral,
storeTriples,
deleteCollection,
};
}
export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()(
@ -171,7 +200,7 @@ const falkorDBTriplesStoreError = (operation: string, cause: unknown) =>
export const makeFalkorDBTriplesStoreService = (
config: FalkorDBConfig = {},
): FalkorDBTriplesStoreServiceShape => {
const store = new FalkorDBTriplesStore(config);
const store = makeFalkorDBTriplesStore(config);
return {
storeTriples: Effect.fn("FalkorDBTriplesStore.storeTriples")((
triples: ReadonlyArray<Triple>,