mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 17:39:39 +02:00
saving
This commit is contained in:
parent
e8c7a4f6e0
commit
ffd97375a8
160 changed files with 6704 additions and 1895 deletions
230
ts/packages/flow/src/__tests__/chunking-service.test.ts
Normal file
230
ts/packages/flow/src/__tests__/chunking-service.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { ConfigProvider, Effect, Fiber } from "effect";
|
||||
import {
|
||||
MessagingRuntimeLive,
|
||||
PubSub,
|
||||
runProcessorScoped,
|
||||
topics,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type Chunk,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type Message,
|
||||
type PubSubBackend,
|
||||
type TextDocument,
|
||||
} from "@trustgraph/base";
|
||||
import { ChunkingService } from "../chunking/service.js";
|
||||
import { recursiveSplit } from "../chunking/recursive-splitter.js";
|
||||
|
||||
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
|
||||
return {
|
||||
value: () => value,
|
||||
properties: () => properties,
|
||||
};
|
||||
}
|
||||
|
||||
const waitFor = (condition: () => boolean, label: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const deadline = Date.now() + 1000;
|
||||
const check = () => {
|
||||
if (condition()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 5);
|
||||
};
|
||||
check();
|
||||
}),
|
||||
catch: (error) => error,
|
||||
});
|
||||
|
||||
class RecordingProducer<T> implements BackendProducer<T> {
|
||||
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||
closeCount = 0;
|
||||
flushCount = 0;
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class PushConsumer<T> implements BackendConsumer<T> {
|
||||
readonly acknowledged: Array<Message<T>> = [];
|
||||
readonly nacked: Array<Message<T>> = [];
|
||||
closeCount = 0;
|
||||
private readonly messages: Array<Message<T>> = [];
|
||||
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
|
||||
private closed = false;
|
||||
|
||||
push(message: Message<T>): void {
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter !== undefined) {
|
||||
waiter(message);
|
||||
return;
|
||||
}
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined || this.closed) {
|
||||
return message ?? null;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async acknowledge(message: Message<T>): Promise<void> {
|
||||
this.acknowledged.push(message);
|
||||
}
|
||||
|
||||
async negativeAcknowledge(message: Message<T>): Promise<void> {
|
||||
this.nacked.push(message);
|
||||
}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
for (const waiter of this.waiters.splice(0)) {
|
||||
waiter(null);
|
||||
}
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class ChunkingBackend implements PubSubBackend {
|
||||
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
|
||||
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
|
||||
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
|
||||
readonly producerOptions: Array<CreateProducerOptions> = [];
|
||||
readonly consumerOptions: Array<CreateConsumerOptions> = [];
|
||||
closeCount = 0;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions.push(options);
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producersByTopic.set(options.topic, producer);
|
||||
return producer as BackendProducer<T>;
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions.push(options);
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
const consumer = new PushConsumer<unknown>();
|
||||
this.consumersByTopic.set(options.topic, consumer);
|
||||
return consumer as BackendConsumer<T>;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
|
||||
pushConfig(): void {
|
||||
this.configConsumer.push(
|
||||
createMessage({
|
||||
version: 1,
|
||||
config: {
|
||||
flows: {
|
||||
default: {
|
||||
topics: {
|
||||
"chunk-input": "chunk-input-topic",
|
||||
"chunk-output": "chunk-output-topic",
|
||||
"chunk-triples": "chunk-triples-topic",
|
||||
},
|
||||
parameters: {
|
||||
"chunk-size": 18,
|
||||
"chunk-overlap": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fastMessagingConfig = ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({
|
||||
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
|
||||
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
|
||||
TG_RATE_LIMIT_RETRY_MS: "1",
|
||||
TG_REQUEST_TIMEOUT_MS: "250",
|
||||
}),
|
||||
);
|
||||
|
||||
describe("ChunkingService", () => {
|
||||
it.effect(
|
||||
"handles chunk-input with native Effect flow resources",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new ChunkingBackend();
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runProcessorScoped(
|
||||
{
|
||||
id: "chunking",
|
||||
pubsubUrl: "nats://unused:4222",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new ChunkingService(config),
|
||||
).pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
Effect.forkChild,
|
||||
);
|
||||
|
||||
backend.pushConfig();
|
||||
yield* waitFor(() => backend.consumersByTopic.has("chunk-input-topic"), "chunk consumer");
|
||||
yield* waitFor(() => backend.producersByTopic.has("chunk-output-topic"), "chunk producer");
|
||||
|
||||
const document: TextDocument = {
|
||||
documentId: "doc-1",
|
||||
metadata: {
|
||||
id: "pipeline-1",
|
||||
root: "root-1",
|
||||
user: "user-1",
|
||||
collection: "collection-1",
|
||||
},
|
||||
text: "alpha beta gamma delta epsilon zeta eta theta",
|
||||
};
|
||||
const inputConsumer = backend.consumersByTopic.get("chunk-input-topic") as PushConsumer<TextDocument>;
|
||||
inputConsumer.push(createMessage(document, { id: "request-1" }));
|
||||
|
||||
const outputProducer = backend.producersByTopic.get("chunk-output-topic") as RecordingProducer<Chunk>;
|
||||
const expectedChunks = recursiveSplit(document.text, 18, 0);
|
||||
yield* waitFor(() => outputProducer.sent.length === expectedChunks.length, "chunk outputs");
|
||||
|
||||
expect(inputConsumer.acknowledged.length).toBe(1);
|
||||
expect(inputConsumer.nacked).toEqual([]);
|
||||
expect(outputProducer.sent.map(({ message }) => message.chunk)).toEqual(expectedChunks);
|
||||
expect(outputProducer.sent.every(({ properties }) => properties?.id === "request-1")).toBe(true);
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
82
ts/packages/flow/src/__tests__/ollama-embeddings.test.ts
Normal file
82
ts/packages/flow/src/__tests__/ollama-embeddings.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect } from "effect";
|
||||
import { makeOllamaEmbeddings } from "../embeddings/ollama.js";
|
||||
|
||||
describe("Ollama embeddings provider", () => {
|
||||
it.effect(
|
||||
"posts embedding requests to Ollama",
|
||||
Effect.fnUntraced(function* () {
|
||||
const calls: Array<{ readonly input: RequestInfo | URL; readonly init?: RequestInit }> = [];
|
||||
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push(init === undefined ? { input } : { input, init });
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ embeddings: [[1, 2, 3]] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const embeddings = makeOllamaEmbeddings({
|
||||
id: "embeddings",
|
||||
model: "default-model",
|
||||
ollamaHost: "http://ollama.local",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
|
||||
const vectors = yield* embeddings.embed(["alpha"], "override-model");
|
||||
|
||||
expect(vectors).toEqual([[1, 2, 3]]);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(String(calls[0]?.input)).toBe("http://ollama.local/api/embed");
|
||||
expect(calls[0]?.init?.method).toBe("POST");
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({
|
||||
model: "override-model",
|
||||
input: ["alpha"],
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"does not call Ollama for empty requests",
|
||||
Effect.fnUntraced(function* () {
|
||||
const calls: Array<RequestInfo | URL> = [];
|
||||
const fetchImpl = ((input: RequestInfo | URL) => {
|
||||
calls.push(input);
|
||||
return Promise.resolve(new Response(JSON.stringify({ embeddings: [] })));
|
||||
}) as typeof fetch;
|
||||
const embeddings = makeOllamaEmbeddings({
|
||||
id: "embeddings",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
|
||||
const vectors = yield* embeddings.embed([]);
|
||||
|
||||
expect(vectors).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"maps failed Ollama responses to EmbeddingsError",
|
||||
Effect.fnUntraced(function* () {
|
||||
const fetchImpl = (() =>
|
||||
Promise.resolve(
|
||||
new Response("not found", {
|
||||
status: 404,
|
||||
}),
|
||||
)) as typeof fetch;
|
||||
const embeddings = makeOllamaEmbeddings({
|
||||
id: "embeddings",
|
||||
ollamaHost: "http://ollama.local",
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
|
||||
const error = yield* embeddings.embed(["alpha"]).pipe(Effect.flip);
|
||||
|
||||
expect(error._tag).toBe("EmbeddingsError");
|
||||
expect(error.operation).toBe("ollama.embed");
|
||||
expect(error.provider).toBe("ollama");
|
||||
expect(error.message).toContain("Ollama embeddings request failed (404): not found");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
interface McpServiceConfig {
|
||||
url: string;
|
||||
|
|
@ -36,7 +37,7 @@ export class McpToolService extends FlowProcessor {
|
|||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
|
||||
ConsumerSpec.fromPromise<ToolRequest>("mcp-tool-request", this.onRequest.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<ToolResponse>("mcp-tool-response"));
|
||||
|
||||
|
|
@ -77,14 +78,16 @@ export class McpToolService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<ToolResponse>("mcp-tool-response");
|
||||
|
||||
try {
|
||||
const result = await this.invokeTool(
|
||||
msg.name,
|
||||
msg.parameters ? JSON.parse(msg.parameters) : {},
|
||||
msg.parameters !== undefined && msg.parameters.length > 0
|
||||
? JSON.parse(msg.parameters) as Record<string, unknown>
|
||||
: {},
|
||||
);
|
||||
|
||||
if (typeof result === "string") {
|
||||
|
|
@ -110,7 +113,7 @@ export class McpToolService extends FlowProcessor {
|
|||
}
|
||||
|
||||
const svcConfig = this.mcpServices[name];
|
||||
if (!svcConfig.url) {
|
||||
if (svcConfig.url.length === 0) {
|
||||
throw new Error(`MCP service "${name}" URL not defined`);
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +121,7 @@ export class McpToolService extends FlowProcessor {
|
|||
|
||||
// Build headers with optional bearer token
|
||||
const headers: Record<string, string> = {};
|
||||
if (svcConfig["auth-token"]) {
|
||||
if (svcConfig["auth-token"] !== undefined && svcConfig["auth-token"].length > 0) {
|
||||
headers["Authorization"] = `Bearer ${svcConfig["auth-token"]}`;
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +136,7 @@ export class McpToolService extends FlowProcessor {
|
|||
const client = new Client({ name: "trustgraph-mcp-client", version: "1.0.0" });
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: remoteName,
|
||||
|
|
@ -141,11 +144,11 @@ export class McpToolService extends FlowProcessor {
|
|||
});
|
||||
|
||||
// Extract response — prefer structured content, fall back to text
|
||||
if (result.structuredContent) {
|
||||
if (result.structuredContent !== undefined && result.structuredContent !== null) {
|
||||
return result.structuredContent;
|
||||
}
|
||||
|
||||
if (result.content && Array.isArray(result.content)) {
|
||||
if (result.content !== undefined && Array.isArray(result.content)) {
|
||||
return result.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
|
|
@ -158,3 +161,8 @@ export class McpToolService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "mcp-tool",
|
||||
make: (config) => new McpToolService(config),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,13 +25,22 @@ 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;
|
||||
|
||||
constructor(
|
||||
private onThought: (text: string) => void,
|
||||
private onAction: (name: string) => void,
|
||||
private onActionInput: (input: string) => void,
|
||||
private onFinalAnswer: (text: string) => void,
|
||||
) {}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a chunk of LLM output text into the parser.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type ToolRequest,
|
||||
type ToolResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
import {
|
||||
createKnowledgeQueryTool,
|
||||
|
|
@ -45,7 +46,7 @@ import {
|
|||
type ExplainData,
|
||||
} from "./tools.js";
|
||||
import { buildReActPrompt } from "./prompt.js";
|
||||
import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js";
|
||||
import { filterToolsByGroupAndState } from "../tool-filter.js";
|
||||
import type { AgentTool, ToolArg } from "./types.js";
|
||||
|
||||
const MAX_ITERATIONS = 10;
|
||||
|
|
@ -59,7 +60,7 @@ export class AgentService extends FlowProcessor {
|
|||
|
||||
// Consumer: agent requests
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<AgentRequest>("agent-request", this.onRequest.bind(this)),
|
||||
ConsumerSpec.fromPromise<AgentRequest>("agent-request", this.onRequest.bind(this)),
|
||||
);
|
||||
|
||||
// Producer: agent responses (streaming chunks)
|
||||
|
|
@ -132,11 +133,12 @@ export class AgentService extends FlowProcessor {
|
|||
for (const [_toolId, toolValue] of Object.entries(toolConfig)) {
|
||||
try {
|
||||
const data = JSON.parse(toolValue) as Record<string, unknown>;
|
||||
const implType = data["type"] as string;
|
||||
const name = data["name"] as string;
|
||||
const description = data["description"] as string ?? "";
|
||||
const implType = typeof data["type"] === "string" ? data["type"] : "";
|
||||
const name = typeof data["name"] === "string" ? data["name"] : "";
|
||||
const description =
|
||||
typeof data["description"] === "string" ? data["description"] : "";
|
||||
|
||||
if (!name) {
|
||||
if (name.length === 0) {
|
||||
console.warn(`[AgentService] Skipping tool with no name: ${_toolId}`);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -148,7 +150,10 @@ export class AgentService extends FlowProcessor {
|
|||
// Will be wired to requestor at request time
|
||||
tool = {
|
||||
name,
|
||||
description: description || "Query the knowledge graph for information about entities and their relationships.",
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query the knowledge graph for information about entities and their relationships.",
|
||||
args: [{ name: "question", type: "string", description: "The question to ask" }],
|
||||
config: data,
|
||||
execute: async () => "", // placeholder — wired at request time
|
||||
|
|
@ -158,7 +163,10 @@ export class AgentService extends FlowProcessor {
|
|||
case "document-query":
|
||||
tool = {
|
||||
name,
|
||||
description: description || "Search documents for relevant information.",
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Search documents for relevant information.",
|
||||
args: [{ name: "question", type: "string", description: "The question to search for" }],
|
||||
config: data,
|
||||
execute: async () => "",
|
||||
|
|
@ -168,7 +176,10 @@ export class AgentService extends FlowProcessor {
|
|||
case "triples-query":
|
||||
tool = {
|
||||
name,
|
||||
description: description || "Query for specific triples in the knowledge graph.",
|
||||
description:
|
||||
description.length > 0
|
||||
? description
|
||||
: "Query for specific triples in the knowledge graph.",
|
||||
args: [
|
||||
{ name: "subject", type: "string", description: "Subject entity (optional)" },
|
||||
{ name: "predicate", type: "string", description: "Predicate/relationship (optional)" },
|
||||
|
|
@ -203,7 +214,7 @@ export class AgentService extends FlowProcessor {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (tool) {
|
||||
if (tool !== null) {
|
||||
tools.push(tool);
|
||||
console.log(`[AgentService] Registered tool: ${name} (${implType})`);
|
||||
}
|
||||
|
|
@ -276,7 +287,7 @@ export class AgentService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
|
||||
|
||||
|
|
@ -290,7 +301,7 @@ export class AgentService extends FlowProcessor {
|
|||
// Build tools — config-driven or hardcoded fallback
|
||||
let tools: AgentTool[];
|
||||
|
||||
if (this.configuredTools) {
|
||||
if (this.configuredTools !== null) {
|
||||
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
|
||||
} else {
|
||||
// Hardcoded fallback (backward compat)
|
||||
|
|
@ -339,7 +350,7 @@ export class AgentService extends FlowProcessor {
|
|||
prompt: conversation,
|
||||
});
|
||||
|
||||
if (llmResponse.error) {
|
||||
if (llmResponse.error !== undefined) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "error",
|
||||
content: `LLM error: ${llmResponse.error.message}`,
|
||||
|
|
@ -354,7 +365,7 @@ export class AgentService extends FlowProcessor {
|
|||
const parsed = parseReActResponse(text);
|
||||
|
||||
// Send thought chunk
|
||||
if (parsed.thought) {
|
||||
if (parsed.thought.length > 0) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "thought",
|
||||
content: parsed.thought,
|
||||
|
|
@ -363,7 +374,7 @@ export class AgentService extends FlowProcessor {
|
|||
}
|
||||
|
||||
// If we got a final answer, emit explain events then send the answer
|
||||
if (parsed.finalAnswer) {
|
||||
if (parsed.finalAnswer.length > 0) {
|
||||
// Emit explain events collected from tool calls
|
||||
for (const explain of explainEvents) {
|
||||
await responseProducer.send(requestId, {
|
||||
|
|
@ -384,11 +395,11 @@ export class AgentService extends FlowProcessor {
|
|||
}
|
||||
|
||||
// Execute tool if action was specified
|
||||
if (parsed.action && parsed.actionInput) {
|
||||
if (parsed.action.length > 0 && parsed.actionInput.length > 0) {
|
||||
const tool = tools.find((t) => t.name === parsed.action);
|
||||
let observation: string;
|
||||
|
||||
if (tool) {
|
||||
if (tool !== undefined) {
|
||||
try {
|
||||
observation = await tool.execute(parsed.actionInput);
|
||||
} catch (err) {
|
||||
|
|
@ -407,7 +418,7 @@ export class AgentService extends FlowProcessor {
|
|||
|
||||
// Append the full exchange to conversation for the next iteration
|
||||
conversation += `\n${text}\nObservation: ${observation}\n`;
|
||||
} else if (!parsed.finalAnswer) {
|
||||
} else if (parsed.finalAnswer.length === 0) {
|
||||
// LLM didn't produce a valid action or final answer -- nudge it
|
||||
conversation += `\n${text}\nObservation: You must either use a tool (Action + Action Input) or provide a Final Answer.\n`;
|
||||
}
|
||||
|
|
@ -464,30 +475,31 @@ function parseReActResponse(text: string): {
|
|||
// Everything from "Final Answer:" to end of text is the answer
|
||||
const firstLine = trimmed.slice("Final Answer:".length).trim();
|
||||
const remainingLines = lines.slice(i + 1).join("\n").trim();
|
||||
finalAnswer = firstLine + (remainingLines ? "\n" + remainingLines : "");
|
||||
finalAnswer =
|
||||
firstLine + (remainingLines.length > 0 ? "\n" + remainingLines : "");
|
||||
break;
|
||||
} else if (trimmed.startsWith("Thought:")) {
|
||||
currentSection = "thought";
|
||||
const content = trimmed.slice("Thought:".length).trim();
|
||||
if (content) {
|
||||
thought += (thought ? "\n" : "") + content;
|
||||
if (content.length > 0) {
|
||||
thought += (thought.length > 0 ? "\n" : "") + content;
|
||||
}
|
||||
} else if (trimmed.startsWith("Action Input:")) {
|
||||
currentSection = "action_input";
|
||||
const content = trimmed.slice("Action Input:".length).trim();
|
||||
if (content) {
|
||||
if (content.length > 0) {
|
||||
actionInput += content;
|
||||
}
|
||||
} else if (trimmed.startsWith("Action:")) {
|
||||
currentSection = "action";
|
||||
const content = trimmed.slice("Action:".length).trim();
|
||||
if (content) {
|
||||
if (content.length > 0) {
|
||||
action = content;
|
||||
}
|
||||
} else if (trimmed.startsWith("Observation:")) {
|
||||
// Stop processing -- observations are injected by us, not the LLM
|
||||
currentSection = null;
|
||||
} else if (trimmed.length > 0 && currentSection) {
|
||||
} else if (trimmed.length > 0 && currentSection !== null) {
|
||||
// Continuation line for current section
|
||||
switch (currentSection) {
|
||||
case "thought":
|
||||
|
|
@ -512,6 +524,11 @@ function parseReActResponse(text: string): {
|
|||
};
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "agent",
|
||||
make: (config) => new AgentService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AgentService.launch("agent");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
RequestResponse,
|
||||
FlowRequestor,
|
||||
GraphRagRequest,
|
||||
GraphRagResponse,
|
||||
DocumentRagRequest,
|
||||
|
|
@ -68,7 +68,7 @@ export interface ExplainData {
|
|||
* Query the knowledge graph for information about entities and their relationships.
|
||||
*/
|
||||
export function createKnowledgeQueryTool(
|
||||
client: RequestResponse<GraphRagRequest, GraphRagResponse>,
|
||||
client: FlowRequestor<GraphRagRequest, GraphRagResponse>,
|
||||
collection?: string,
|
||||
onExplain?: (data: ExplainData) => void,
|
||||
): AgentTool {
|
||||
|
|
@ -86,19 +86,27 @@ export function createKnowledgeQueryTool(
|
|||
async execute(input: string): Promise<string> {
|
||||
const question = parseQuestion(input);
|
||||
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
|
||||
const res = await client.request({ query: question, collection });
|
||||
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
|
||||
const request: GraphRagRequest = {
|
||||
query: question,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
const res = await client.request(request);
|
||||
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
|
||||
|
||||
// Extract explain data if embedded in the response
|
||||
const rawRes = res as Record<string, unknown>;
|
||||
if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) {
|
||||
if (
|
||||
rawRes.message_type === "explain" &&
|
||||
rawRes.explain_triples !== undefined &&
|
||||
onExplain !== undefined
|
||||
) {
|
||||
onExplain({
|
||||
explainId: (rawRes.explain_id as string) ?? "",
|
||||
triples: rawRes.explain_triples as Triple[],
|
||||
});
|
||||
}
|
||||
|
||||
if (res.error) return `Error: ${res.error.message}`;
|
||||
if (res.error !== undefined) return `Error: ${res.error.message}`;
|
||||
return res.response;
|
||||
},
|
||||
};
|
||||
|
|
@ -108,7 +116,7 @@ export function createKnowledgeQueryTool(
|
|||
* Search documents for relevant information.
|
||||
*/
|
||||
export function createDocumentQueryTool(
|
||||
client: RequestResponse<DocumentRagRequest, DocumentRagResponse>,
|
||||
client: FlowRequestor<DocumentRagRequest, DocumentRagResponse>,
|
||||
collection?: string,
|
||||
): AgentTool {
|
||||
return {
|
||||
|
|
@ -124,8 +132,12 @@ export function createDocumentQueryTool(
|
|||
],
|
||||
async execute(input: string): Promise<string> {
|
||||
const question = parseQuestion(input);
|
||||
const res = await client.request({ query: question, collection });
|
||||
if (res.error) return `Error: ${res.error.message}`;
|
||||
const request: DocumentRagRequest = {
|
||||
query: question,
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
const res = await client.request(request);
|
||||
if (res.error !== undefined) return `Error: ${res.error.message}`;
|
||||
return res.response;
|
||||
},
|
||||
};
|
||||
|
|
@ -153,13 +165,20 @@ function parseTriplesInput(input: string): {
|
|||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
s: toTerm(parsed.subject ?? parsed.s),
|
||||
p: toTerm(parsed.predicate ?? parsed.p),
|
||||
o: toTerm(parsed.object ?? parsed.o),
|
||||
limit:
|
||||
typeof parsed.limit === "number" ? parsed.limit : undefined,
|
||||
};
|
||||
const result: {
|
||||
s?: Term;
|
||||
p?: Term;
|
||||
o?: Term;
|
||||
limit?: number;
|
||||
} = {};
|
||||
const s = toTerm(parsed.subject ?? parsed.s);
|
||||
const p = toTerm(parsed.predicate ?? parsed.p);
|
||||
const o = toTerm(parsed.object ?? parsed.o);
|
||||
if (s !== undefined) result.s = s;
|
||||
if (p !== undefined) result.p = p;
|
||||
if (o !== undefined) result.o = o;
|
||||
if (typeof parsed.limit === "number") result.limit = parsed.limit;
|
||||
return result;
|
||||
} catch {
|
||||
// If not valid JSON, treat as a subject search
|
||||
return {
|
||||
|
|
@ -172,7 +191,7 @@ function parseTriplesInput(input: string): {
|
|||
* Query for specific triples (subject-predicate-object relationships) in the knowledge graph.
|
||||
*/
|
||||
export function createTriplesQueryTool(
|
||||
client: RequestResponse<TriplesQueryRequest, TriplesQueryResponse>,
|
||||
client: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>,
|
||||
collection?: string,
|
||||
): AgentTool {
|
||||
return {
|
||||
|
|
@ -199,17 +218,18 @@ export function createTriplesQueryTool(
|
|||
],
|
||||
async execute(input: string): Promise<string> {
|
||||
const { s, p, o, limit } = parseTriplesInput(input);
|
||||
const res = await client.request({
|
||||
s,
|
||||
p,
|
||||
o,
|
||||
collection,
|
||||
const request: TriplesQueryRequest = {
|
||||
limit: limit ?? 20,
|
||||
});
|
||||
...(s !== undefined ? { s } : {}),
|
||||
...(p !== undefined ? { p } : {}),
|
||||
...(o !== undefined ? { o } : {}),
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
const res = await client.request(request);
|
||||
|
||||
if (res.error) return `Error: ${res.error.message}`;
|
||||
if (res.error !== undefined) return `Error: ${res.error.message}`;
|
||||
|
||||
if (!res.triples || res.triples.length === 0) {
|
||||
if (res.triples === undefined || res.triples.length === 0) {
|
||||
return "No triples found matching the query.";
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +249,7 @@ export function createTriplesQueryTool(
|
|||
* this function just wraps it as an AgentTool the ReAct agent can invoke.
|
||||
*/
|
||||
export function createMcpTool(
|
||||
client: RequestResponse<ToolRequest, ToolResponse>,
|
||||
client: FlowRequestor<ToolRequest, ToolResponse>,
|
||||
toolName: string,
|
||||
description: string,
|
||||
args: ToolArg[],
|
||||
|
|
@ -240,9 +260,9 @@ export function createMcpTool(
|
|||
args,
|
||||
async execute(input: string): Promise<string> {
|
||||
const res = await client.request({ name: toolName, parameters: input });
|
||||
if (res.error) return `Error: ${res.error.message}`;
|
||||
if (res.text) return res.text;
|
||||
if (res.object) return res.object;
|
||||
if (res.error !== undefined) return `Error: ${res.error.message}`;
|
||||
if (res.text !== undefined) return res.text;
|
||||
if (res.object !== undefined) return res.object;
|
||||
return "No content";
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function filterToolsByGroupAndState(
|
|||
currentState?: string,
|
||||
): AgentTool[] {
|
||||
const groups = requestedGroups ?? ["default"];
|
||||
const state = currentState || "undefined";
|
||||
const state = currentState ?? "undefined";
|
||||
|
||||
return tools.filter((tool) => isToolAvailable(tool, groups, state));
|
||||
}
|
||||
|
|
@ -31,12 +31,12 @@ function isToolAvailable(
|
|||
|
||||
// Get tool groups (default to ["default"])
|
||||
let toolGroups = config["group"] as string[] | string | undefined;
|
||||
if (!toolGroups) toolGroups = ["default"];
|
||||
if (toolGroups === undefined) toolGroups = ["default"];
|
||||
if (!Array.isArray(toolGroups)) toolGroups = [toolGroups];
|
||||
|
||||
// Get tool applicable states (default to ["*"] = all states)
|
||||
let applicableStates = config["applicable-states"] as string[] | string | undefined;
|
||||
if (!applicableStates) applicableStates = ["*"];
|
||||
if (applicableStates === undefined) applicableStates = ["*"];
|
||||
if (!Array.isArray(applicableStates)) applicableStates = [applicableStates];
|
||||
|
||||
// Group match: wildcard in requested groups, or intersection non-empty
|
||||
|
|
@ -57,5 +57,5 @@ function isToolAvailable(
|
|||
*/
|
||||
export function getNextState(tool: AgentTool, currentState: string): string {
|
||||
const nextState = tool.config?.["state"] as string | undefined;
|
||||
return nextState || currentState;
|
||||
return nextState ?? currentState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@ import {
|
|||
ParameterSpec,
|
||||
type ProcessorConfig,
|
||||
type FlowContext,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
type TextDocument,
|
||||
type Chunk,
|
||||
type Triples,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { Effect } from "effect";
|
||||
import { recursiveSplit } from "./recursive-splitter.js";
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 2000;
|
||||
|
|
@ -30,7 +34,10 @@ export class ChunkingService extends FlowProcessor {
|
|||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<TextDocument>("chunk-input", this.onMessage.bind(this)),
|
||||
new ConsumerSpec<TextDocument, FlowResourceNotFoundError | MessagingDeliveryError>(
|
||||
"chunk-input",
|
||||
this.onMessageEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Chunk>("chunk-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("chunk-triples"));
|
||||
|
|
@ -40,55 +47,55 @@ export class ChunkingService extends FlowProcessor {
|
|||
console.log("[ChunkingService] Service initialized");
|
||||
}
|
||||
|
||||
private async onMessage(
|
||||
private onMessageEffect(
|
||||
msg: TextDocument,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
let chunkSize: number;
|
||||
let chunkOverlap: number;
|
||||
const chunkSize = yield* flowCtx.flow.parameterEffect<number>("chunk-size").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_SIZE)),
|
||||
);
|
||||
const chunkOverlap = yield* flowCtx.flow.parameterEffect<number>("chunk-overlap").pipe(
|
||||
Effect.catch(() => Effect.succeed(DEFAULT_CHUNK_OVERLAP)),
|
||||
);
|
||||
|
||||
try {
|
||||
chunkSize = flowCtx.flow.parameter<number>("chunk-size");
|
||||
} catch {
|
||||
chunkSize = DEFAULT_CHUNK_SIZE;
|
||||
}
|
||||
const text = msg.text;
|
||||
if (text.trim().length === 0) {
|
||||
yield* Effect.logWarning(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
chunkOverlap = flowCtx.flow.parameter<number>("chunk-overlap");
|
||||
} catch {
|
||||
chunkOverlap = DEFAULT_CHUNK_OVERLAP;
|
||||
}
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
|
||||
const text = msg.text;
|
||||
if (!text || text.trim().length === 0) {
|
||||
console.warn(`[ChunkingService] Empty text received for document ${msg.documentId}`);
|
||||
return;
|
||||
}
|
||||
yield* Effect.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const chunks = recursiveSplit(text, chunkSize, chunkOverlap);
|
||||
const outputProducer = yield* flowCtx.flow.producerEffect<Chunk>("chunk-output");
|
||||
|
||||
console.log(
|
||||
`[ChunkingService] Split document ${msg.documentId} into ${chunks.length} chunks (size=${chunkSize}, overlap=${chunkOverlap})`,
|
||||
);
|
||||
|
||||
const outputProducer = flowCtx.flow.producer<Chunk>("chunk-output");
|
||||
|
||||
for (const chunkText of chunks) {
|
||||
const chunk: Chunk = {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
};
|
||||
|
||||
await outputProducer.send(requestId, chunk);
|
||||
}
|
||||
yield* Effect.forEach(
|
||||
chunks,
|
||||
(chunkText) =>
|
||||
outputProducer.send(requestId, {
|
||||
metadata: msg.metadata,
|
||||
chunk: chunkText,
|
||||
documentId: msg.documentId,
|
||||
}),
|
||||
{ discard: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "chunking",
|
||||
make: (config) => new ChunkingService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ChunkingService.launch("chunking");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,17 +11,24 @@
|
|||
* Python reference: trustgraph-flow/trustgraph/config/service/service.py
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
type ProcessorConfig,
|
||||
topics,
|
||||
ConfigRequest as ConfigRequestSchema,
|
||||
ConfigResponse as ConfigResponseSchema,
|
||||
type ConfigRequest,
|
||||
type ConfigResponse,
|
||||
type ConfigOperation,
|
||||
errorMessage,
|
||||
loadProcessorRuntimeConfig,
|
||||
makeProcessorProgram,
|
||||
optionalStringConfig,
|
||||
} from "@trustgraph/base";
|
||||
import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
|
||||
export interface ConfigServiceConfig extends ProcessorConfig {
|
||||
persistPath?: string;
|
||||
|
|
@ -32,6 +39,11 @@ interface ConfigPush {
|
|||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ConfigPushSchema = S.Struct({
|
||||
version: S.Number,
|
||||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
export class ConfigService extends AsyncProcessor {
|
||||
private store = new Map<string, Map<string, unknown>>();
|
||||
private version = 0;
|
||||
|
|
@ -42,27 +54,30 @@ export class ConfigService extends AsyncProcessor {
|
|||
|
||||
constructor(config: ConfigServiceConfig) {
|
||||
super(config);
|
||||
this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null;
|
||||
this.persistPath = config.persistPath ?? null;
|
||||
}
|
||||
|
||||
protected override async run(): Promise<void> {
|
||||
// Optionally load persisted state
|
||||
if (this.persistPath) {
|
||||
if (this.persistPath !== null) {
|
||||
await this.loadFromDisk();
|
||||
}
|
||||
|
||||
// Create producers
|
||||
this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({
|
||||
topic: topics.configResponse,
|
||||
schema: ConfigResponseSchema,
|
||||
});
|
||||
this.pushProducer = await this.pubsub.createProducer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
// Create consumer for config requests
|
||||
this.consumer = await this.pubsub.createConsumer<ConfigRequest>({
|
||||
topic: topics.configRequest,
|
||||
subscription: `${this.config.id}-config-request`,
|
||||
schema: ConfigRequestSchema,
|
||||
});
|
||||
|
||||
// Push initial config
|
||||
|
|
@ -73,11 +88,14 @@ export class ConfigService extends AsyncProcessor {
|
|||
// Main consume loop
|
||||
while (this.running) {
|
||||
try {
|
||||
const msg = await this.consumer.receive(2000);
|
||||
if (!msg) continue;
|
||||
const consumer = this.consumer;
|
||||
if (consumer === null) throw new Error("Config consumer not started");
|
||||
|
||||
const msg = await consumer.receive(2000);
|
||||
if (msg === null) continue;
|
||||
|
||||
await this.handleMessage(msg);
|
||||
await this.consumer.acknowledge(msg);
|
||||
await consumer.acknowledge(msg);
|
||||
} catch (err) {
|
||||
if (!this.running) break;
|
||||
console.error("[ConfigService] Error in consume loop:", err);
|
||||
|
|
@ -87,21 +105,25 @@ export class ConfigService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private async handleMessage(msg: Message<ConfigRequest>): Promise<void> {
|
||||
const request = msg.value();
|
||||
const request = await Effect.runPromise(S.decodeUnknownEffect(ConfigRequestSchema)(msg.value()));
|
||||
const props = msg.properties();
|
||||
const requestId = props.id;
|
||||
|
||||
if (!requestId) {
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
console.warn("[ConfigService] Received request without id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.handleOperation(request);
|
||||
await this.responseProducer!.send(response, { id: requestId });
|
||||
const responseProducer = this.responseProducer;
|
||||
if (responseProducer === null) throw new Error("Config response producer not started");
|
||||
await responseProducer.send(response, { id: requestId });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await this.responseProducer!.send(
|
||||
const message = errorMessage(err);
|
||||
const responseProducer = this.responseProducer;
|
||||
if (responseProducer === null) throw new Error("Config response producer not started");
|
||||
await responseProducer.send(
|
||||
{
|
||||
error: { type: "config-error", message },
|
||||
},
|
||||
|
|
@ -146,7 +168,7 @@ export class ConfigService extends AsyncProcessor {
|
|||
const namespace = keys[0];
|
||||
const subMap = this.store.get(namespace);
|
||||
|
||||
if (subMap) {
|
||||
if (subMap !== undefined) {
|
||||
if (keys.length === 1) {
|
||||
// Return entire namespace
|
||||
for (const [k, v] of subMap) {
|
||||
|
|
@ -176,7 +198,7 @@ export class ConfigService extends AsyncProcessor {
|
|||
|
||||
const namespace = keys[0];
|
||||
let subMap = this.store.get(namespace);
|
||||
if (!subMap) {
|
||||
if (subMap === undefined) {
|
||||
subMap = new Map<string, unknown>();
|
||||
this.store.set(namespace, subMap);
|
||||
}
|
||||
|
|
@ -205,7 +227,7 @@ export class ConfigService extends AsyncProcessor {
|
|||
} else {
|
||||
// Delete specific keys within namespace
|
||||
const subMap = this.store.get(namespace);
|
||||
if (subMap) {
|
||||
if (subMap !== undefined) {
|
||||
for (let i = 1; i < keys.length; i++) {
|
||||
subMap.delete(keys[i]);
|
||||
}
|
||||
|
|
@ -236,7 +258,7 @@ export class ConfigService extends AsyncProcessor {
|
|||
|
||||
return {
|
||||
version: this.version,
|
||||
directory: subMap ? [...subMap.keys()] : [],
|
||||
directory: subMap !== undefined ? [...subMap.keys()] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +268,12 @@ export class ConfigService extends AsyncProcessor {
|
|||
const values: { key: string; value: unknown }[] = [];
|
||||
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
if (!type || namespace === type || namespace.startsWith(`${type}.`) || namespace.startsWith(`${type}/`)) {
|
||||
if (
|
||||
type.length === 0 ||
|
||||
namespace === type ||
|
||||
namespace.startsWith(`${type}.`) ||
|
||||
namespace.startsWith(`${type}/`)
|
||||
) {
|
||||
for (const [k, v] of subMap) {
|
||||
values.push({ key: `${namespace}.${k}`, value: v });
|
||||
}
|
||||
|
|
@ -274,7 +301,8 @@ export class ConfigService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private async pushConfig(): Promise<void> {
|
||||
if (!this.pushProducer) return;
|
||||
const pushProducer = this.pushProducer;
|
||||
if (pushProducer === null) return;
|
||||
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const [namespace, subMap] of this.store) {
|
||||
|
|
@ -285,7 +313,7 @@ export class ConfigService extends AsyncProcessor {
|
|||
config[namespace] = obj;
|
||||
}
|
||||
|
||||
await this.pushProducer.send({
|
||||
await pushProducer.send({
|
||||
version: this.version,
|
||||
config,
|
||||
});
|
||||
|
|
@ -294,7 +322,8 @@ export class ConfigService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
if (!this.persistPath) return;
|
||||
const persistPath = this.persistPath;
|
||||
if (persistPath === null) return;
|
||||
|
||||
try {
|
||||
const data: Record<string, Record<string, unknown>> = {};
|
||||
|
|
@ -313,18 +342,18 @@ export class ConfigService extends AsyncProcessor {
|
|||
2,
|
||||
);
|
||||
|
||||
await mkdir(dirname(this.persistPath), { recursive: true });
|
||||
await writeFile(this.persistPath, json, "utf-8");
|
||||
await writeTextFile(persistPath, json);
|
||||
} catch (err) {
|
||||
console.error("[ConfigService] Failed to persist config:", err);
|
||||
await Effect.runPromise(Effect.logError("[ConfigService] Failed to persist config", { error: errorMessage(err) }));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromDisk(): Promise<void> {
|
||||
if (!this.persistPath) return;
|
||||
const persistPath = this.persistPath;
|
||||
if (persistPath === null) return;
|
||||
|
||||
try {
|
||||
const raw = await readFile(this.persistPath, "utf-8");
|
||||
const raw = await readTextFile(persistPath);
|
||||
const parsed = JSON.parse(raw) as {
|
||||
version: number;
|
||||
data: Record<string, Record<string, unknown>>;
|
||||
|
|
@ -346,20 +375,20 @@ export class ConfigService extends AsyncProcessor {
|
|||
);
|
||||
} catch {
|
||||
// File doesn't exist yet or is invalid — start fresh
|
||||
console.log("[ConfigService] No persisted config found, starting fresh");
|
||||
await Effect.runPromise(Effect.log("[ConfigService] No persisted config found, starting fresh"));
|
||||
}
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
if (this.consumer) {
|
||||
if (this.consumer !== null) {
|
||||
await this.consumer.close();
|
||||
this.consumer = null;
|
||||
}
|
||||
if (this.responseProducer) {
|
||||
if (this.responseProducer !== null) {
|
||||
await this.responseProducer.close();
|
||||
this.responseProducer = null;
|
||||
}
|
||||
if (this.pushProducer) {
|
||||
if (this.pushProducer !== null) {
|
||||
await this.pushProducer.close();
|
||||
this.pushProducer = null;
|
||||
}
|
||||
|
|
@ -371,6 +400,23 @@ function sleep(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const loadConfigServiceRuntimeConfig = Effect.fn("loadConfigServiceRuntimeConfig")(function* () {
|
||||
const processorConfig = yield* loadProcessorRuntimeConfig("config-svc", {
|
||||
manageProcessSignals: false,
|
||||
});
|
||||
const persistPath = yield* optionalStringConfig("CONFIG_PERSIST_PATH");
|
||||
return {
|
||||
...processorConfig,
|
||||
...(persistPath !== undefined ? { persistPath } : {}),
|
||||
} satisfies ConfigServiceConfig;
|
||||
});
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "config-svc",
|
||||
loadConfig: loadConfigServiceRuntimeConfig(),
|
||||
make: (config) => new ConfigService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ConfigService.launch("config-svc");
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@
|
|||
* Python reference: trustgraph-flow/trustgraph/knowledge/service/service.py
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
type ProcessorConfig,
|
||||
|
|
@ -21,7 +19,9 @@ import {
|
|||
type Triple,
|
||||
type Term,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
|
||||
|
||||
export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
|
||||
dataDir?: string;
|
||||
|
|
@ -43,7 +43,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
constructor(config: KnowledgeCoreServiceConfig) {
|
||||
super(config);
|
||||
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge";
|
||||
this.persistPath = join(dataDir, "knowledge-state.json");
|
||||
this.persistPath = joinPath(dataDir, "knowledge-state.json");
|
||||
}
|
||||
|
||||
private coreKey(user: string, id: string): string {
|
||||
|
|
@ -71,7 +71,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
while (this.running) {
|
||||
try {
|
||||
const msg = await this.consumer.receive(2000);
|
||||
if (!msg) continue;
|
||||
if (msg === null) continue;
|
||||
|
||||
await this.handleMessage(msg);
|
||||
await this.consumer.acknowledge(msg);
|
||||
|
|
@ -88,7 +88,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const props = msg.properties();
|
||||
const requestId = props.id;
|
||||
|
||||
if (!requestId) {
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
console.warn("[KnowledgeCoreService] Received request without id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
|
@ -123,11 +123,11 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
|
||||
private async listKgCores(request: KnowledgeRequest, requestId: string): Promise<void> {
|
||||
const user = request.user ?? "";
|
||||
const prefix = user ? `${user}:` : "";
|
||||
const prefix = user.length > 0 ? `${user}:` : "";
|
||||
|
||||
const ids: string[] = [];
|
||||
for (const key of this.cores.keys()) {
|
||||
if (!prefix || key.startsWith(prefix)) {
|
||||
if (prefix.length === 0 || key.startsWith(prefix)) {
|
||||
// Extract the ID portion after the user prefix
|
||||
const id = key.slice(prefix.length);
|
||||
ids.push(id);
|
||||
|
|
@ -143,7 +143,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const key = this.coreKey(user, coreId);
|
||||
|
||||
const core = this.cores.get(key);
|
||||
if (!core) {
|
||||
if (core === undefined) {
|
||||
throw new Error(`Knowledge core not found: ${key}`);
|
||||
}
|
||||
|
||||
|
|
@ -196,18 +196,18 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const key = this.coreKey(user, coreId);
|
||||
|
||||
let core = this.cores.get(key);
|
||||
if (!core) {
|
||||
if (core === undefined) {
|
||||
core = { triples: [], graphEmbeddings: [] };
|
||||
this.cores.set(key, core);
|
||||
}
|
||||
|
||||
// Append triples if provided
|
||||
if (request.triples && request.triples.length > 0) {
|
||||
if (request.triples !== undefined && request.triples.length > 0) {
|
||||
core.triples.push(...request.triples);
|
||||
}
|
||||
|
||||
// Append graph embeddings if provided
|
||||
if (request.graphEmbeddings && request.graphEmbeddings.length > 0) {
|
||||
if (request.graphEmbeddings !== undefined && request.graphEmbeddings.length > 0) {
|
||||
core.graphEmbeddings.push(...request.graphEmbeddings);
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
const key = this.coreKey(user, coreId);
|
||||
|
||||
const core = this.cores.get(key);
|
||||
if (!core) {
|
||||
if (core === undefined) {
|
||||
throw new Error(`Knowledge core not found: ${key}`);
|
||||
}
|
||||
|
||||
|
|
@ -248,8 +248,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await mkdir(dirname(this.persistPath), { recursive: true });
|
||||
await writeFile(this.persistPath, json, "utf-8");
|
||||
await writeTextFile(this.persistPath, json);
|
||||
} catch (err) {
|
||||
console.error("[KnowledgeCoreService] Failed to persist state:", err);
|
||||
}
|
||||
|
|
@ -257,7 +256,7 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
|
||||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
const raw = await readFile(this.persistPath, "utf-8");
|
||||
const raw = await readTextFile(this.persistPath);
|
||||
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore>;
|
||||
|
||||
this.cores.clear();
|
||||
|
|
@ -272,11 +271,11 @@ export class KnowledgeCoreService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
if (this.consumer) {
|
||||
if (this.consumer !== null) {
|
||||
await this.consumer.close();
|
||||
this.consumer = null;
|
||||
}
|
||||
if (this.responseProducer) {
|
||||
if (this.responseProducer !== null) {
|
||||
await this.responseProducer.close();
|
||||
this.responseProducer = null;
|
||||
}
|
||||
|
|
@ -288,6 +287,11 @@ 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),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeCoreService.launch("knowledge-svc");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,13 +30,14 @@ import {
|
|||
type LibrarianRequest,
|
||||
type LibrarianResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class PdfDecoderService extends FlowProcessor {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<Document>("decode-input", this.onMessage.bind(this)),
|
||||
ConsumerSpec.fromPromise<Document>("decode-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TextDocument>("decode-output"));
|
||||
this.registerSpecification(new ProducerSpec<Triples>("decode-triples"));
|
||||
|
|
@ -57,7 +58,7 @@ export class PdfDecoderService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const { documentId } = msg;
|
||||
const user = msg.metadata.user;
|
||||
|
|
@ -73,7 +74,7 @@ export class PdfDecoderService extends FlowProcessor {
|
|||
user,
|
||||
});
|
||||
|
||||
if (metadataResp.error) {
|
||||
if (metadataResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get metadata for ${documentId}:`,
|
||||
metadataResp.error.message,
|
||||
|
|
@ -96,7 +97,11 @@ export class PdfDecoderService extends FlowProcessor {
|
|||
user,
|
||||
});
|
||||
|
||||
if (contentResp.error || !contentResp.content) {
|
||||
if (
|
||||
contentResp.error !== undefined ||
|
||||
contentResp.content === undefined ||
|
||||
contentResp.content.length === 0
|
||||
) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to get content for ${documentId}:`,
|
||||
contentResp.error?.message ?? "no content",
|
||||
|
|
@ -123,7 +128,7 @@ export class PdfDecoderService extends FlowProcessor {
|
|||
.map((item) => item.str)
|
||||
.join(" ");
|
||||
|
||||
if (!pageText.trim()) {
|
||||
if (pageText.trim().length === 0) {
|
||||
console.log(
|
||||
`[PdfDecoder] Skipping empty page ${i} of document ${documentId}`,
|
||||
);
|
||||
|
|
@ -147,7 +152,7 @@ export class PdfDecoderService extends FlowProcessor {
|
|||
content: Buffer.from(pageText).toString("base64"),
|
||||
});
|
||||
|
||||
if (childResp.error) {
|
||||
if (childResp.error !== undefined) {
|
||||
console.error(
|
||||
`[PdfDecoder] Failed to save page ${i} of ${documentId}:`,
|
||||
childResp.error.message,
|
||||
|
|
@ -198,6 +203,11 @@ function literalTerm(value: string): Term {
|
|||
return { type: "LITERAL", value };
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "pdf-decoder",
|
||||
make: (config) => new PdfDecoderService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PdfDecoderService.launch("pdf-decoder");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,112 @@
|
|||
/**
|
||||
* Ollama embeddings service.
|
||||
*
|
||||
* Simple HTTP POST to a local Ollama instance to generate embeddings.
|
||||
* Extends EmbeddingsService from @trustgraph/base so it plugs into the
|
||||
* flow processor framework (consumer/producer wiring is handled by the base class).
|
||||
* Ollama embeddings provider.
|
||||
*
|
||||
* Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py
|
||||
*/
|
||||
|
||||
import { Effect, Layer } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import {
|
||||
Embeddings,
|
||||
EmbeddingsService,
|
||||
embeddingsError,
|
||||
type EmbeddingsServiceShape,
|
||||
type ProcessorConfig,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
|
||||
model?: string;
|
||||
ollamaHost?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
interface OllamaEmbedResponse {
|
||||
embeddings: number[][];
|
||||
}
|
||||
|
||||
export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): EmbeddingsServiceShape {
|
||||
const defaultModel = config.model ?? "mxbai-embed-large";
|
||||
const ollamaHost =
|
||||
config.ollamaHost ??
|
||||
process.env.OLLAMA_URL ??
|
||||
process.env.OLLAMA_HOST ??
|
||||
"http://localhost:11434";
|
||||
const fetchImpl = config.fetch ?? globalThis.fetch;
|
||||
|
||||
return {
|
||||
embed: Effect.fn("OllamaEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => {
|
||||
if (texts.length === 0) {
|
||||
return Effect.succeed([]);
|
||||
}
|
||||
|
||||
const useModel = model ?? defaultModel;
|
||||
const url = `${ollamaHost}/api/embed`;
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const body = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)({
|
||||
model: useModel,
|
||||
input: Array.from(texts),
|
||||
}).pipe(
|
||||
Effect.mapError((error) => embeddingsError("ollama.encode-request", error, "ollama")),
|
||||
);
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`Ollama embeddings request failed (${response.status}): ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OllamaEmbedResponse;
|
||||
return data.embeddings;
|
||||
},
|
||||
catch: (error) => embeddingsError("ollama.embed", error, "ollama"),
|
||||
});
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function OllamaEmbeddingsLive(config: OllamaEmbeddingsConfig): Layer.Layer<Embeddings> {
|
||||
return Layer.succeed(
|
||||
Embeddings,
|
||||
Embeddings.of(makeOllamaEmbeddings(config)),
|
||||
);
|
||||
}
|
||||
|
||||
export class OllamaEmbeddingsProcessor extends EmbeddingsService {
|
||||
private defaultModel: string;
|
||||
private ollamaHost: string;
|
||||
private readonly embeddings: EmbeddingsServiceShape;
|
||||
|
||||
constructor(config: OllamaEmbeddingsConfig) {
|
||||
super(config);
|
||||
|
||||
this.defaultModel = config.model ?? "mxbai-embed-large";
|
||||
this.ollamaHost =
|
||||
config.ollamaHost ??
|
||||
process.env.OLLAMA_URL ??
|
||||
process.env.OLLAMA_HOST ??
|
||||
"http://localhost:11434";
|
||||
this.embeddings = makeOllamaEmbeddings(config);
|
||||
|
||||
console.log(
|
||||
`[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`,
|
||||
`[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`,
|
||||
);
|
||||
}
|
||||
|
||||
async onEmbeddings(texts: string[], model?: string): Promise<number[][]> {
|
||||
if (!texts || texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const useModel = model ?? this.defaultModel;
|
||||
|
||||
const url = `${this.ollamaHost}/api/embed`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: useModel,
|
||||
input: texts,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(
|
||||
`Ollama embeddings request failed (${response.status}): ${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as OllamaEmbedResponse;
|
||||
|
||||
return data.embeddings;
|
||||
override startEffect() {
|
||||
return super.startEffect().pipe(
|
||||
Effect.provideService(Embeddings, Embeddings.of(this.embeddings)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "embeddings",
|
||||
make: (config) => new OllamaEmbeddingsProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaEmbeddingsProcessor.launch("embeddings");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type Triple,
|
||||
type Term,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
// Well-known RDF/SKOS IRIs
|
||||
const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label";
|
||||
|
|
@ -49,7 +50,7 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<Chunk>("extract-input", this.onMessage.bind(this)),
|
||||
ConsumerSpec.fromPromise<Chunk>("extract-input", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<Triples>("extract-triples"));
|
||||
this.registerSpecification(new ProducerSpec<EntityContexts>("extract-entity-contexts"));
|
||||
|
|
@ -78,10 +79,10 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const text = msg.chunk;
|
||||
if (!text || text.trim().length === 0) return;
|
||||
if (text.trim().length === 0) return;
|
||||
|
||||
const promptClient = flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt-client");
|
||||
const llmClient = flowCtx.flow.requestor<TextCompletionRequest, TextCompletionResponse>("llm-client");
|
||||
|
|
@ -98,7 +99,7 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (!relPrompt.error) {
|
||||
if (relPrompt.error === undefined) {
|
||||
let relationships: ExtractedRelationship[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const relCompletion = await llmClient.request(
|
||||
|
|
@ -106,18 +107,27 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (!relCompletion.error && relCompletion.response) {
|
||||
if (
|
||||
relCompletion.error === undefined &&
|
||||
relCompletion.response.length > 0
|
||||
) {
|
||||
relationships = parseJsonResponse<ExtractedRelationship[]>(relCompletion.response);
|
||||
if (relationships) break;
|
||||
if (relationships !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Relationship parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (relationships) {
|
||||
if (relationships !== null) {
|
||||
for (const rel of relationships) {
|
||||
if (!rel.subject || !rel.predicate || !rel.object) continue;
|
||||
if (
|
||||
rel.subject.length === 0 ||
|
||||
rel.predicate.length === 0 ||
|
||||
rel.object.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subjectIri = toEntityIri(rel.subject);
|
||||
const predicateIri = toEntityIri(rel.predicate);
|
||||
|
|
@ -170,7 +180,7 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
{ timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (!defPrompt.error) {
|
||||
if (defPrompt.error === undefined) {
|
||||
let definitions: ExtractedDefinition[] | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const defCompletion = await llmClient.request(
|
||||
|
|
@ -178,18 +188,21 @@ export class KnowledgeExtractService extends FlowProcessor {
|
|||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
|
||||
if (!defCompletion.error && defCompletion.response) {
|
||||
if (
|
||||
defCompletion.error === undefined &&
|
||||
defCompletion.response.length > 0
|
||||
) {
|
||||
definitions = parseJsonResponse<ExtractedDefinition[]>(defCompletion.response);
|
||||
if (definitions) break;
|
||||
if (definitions !== null) break;
|
||||
console.warn(`[KnowledgeExtract] Definition parse failed, attempt ${attempt + 1}/3`);
|
||||
} else {
|
||||
break; // LLM error, don't retry
|
||||
}
|
||||
}
|
||||
|
||||
if (definitions) {
|
||||
if (definitions !== null) {
|
||||
for (const def of definitions) {
|
||||
if (!def.entity || !def.definition) continue;
|
||||
if (def.entity.length === 0 || def.definition.length === 0) continue;
|
||||
|
||||
const entityIri = toEntityIri(def.entity);
|
||||
|
||||
|
|
@ -265,8 +278,8 @@ export function parseJsonResponse<T>(raw: string): T | null {
|
|||
// Attempt 1: direct parse after stripping fences
|
||||
let cleaned = raw.trim();
|
||||
const fenceMatch = cleaned.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch) {
|
||||
cleaned = fenceMatch[1].trim();
|
||||
if (fenceMatch !== null) {
|
||||
cleaned = (fenceMatch[1] ?? "").trim();
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -275,7 +288,7 @@ export function parseJsonResponse<T>(raw: string): T | null {
|
|||
|
||||
// Attempt 2: extract first JSON array from the text
|
||||
const arrayMatch = cleaned.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch) {
|
||||
if (arrayMatch !== null) {
|
||||
try {
|
||||
return JSON.parse(arrayMatch[0]) as T;
|
||||
} catch { /* fall through */ }
|
||||
|
|
@ -293,7 +306,7 @@ export function parseJsonResponse<T>(raw: string): T | null {
|
|||
|
||||
// Attempt 4: extract first JSON object, wrap in array
|
||||
const objMatch = cleaned.match(/\{[\s\S]*?\}/);
|
||||
if (objMatch) {
|
||||
if (objMatch !== null) {
|
||||
try {
|
||||
const obj = JSON.parse(objMatch[0]);
|
||||
return [obj] as unknown as T;
|
||||
|
|
@ -304,6 +317,11 @@ export function parseJsonResponse<T>(raw: string): T | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "knowledge-extract",
|
||||
make: (config) => new KnowledgeExtractService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await KnowledgeExtractService.launch("knowledge-extract");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type ConfigRequest,
|
||||
type ConfigResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type {
|
||||
BackendProducer,
|
||||
BackendConsumer,
|
||||
|
|
@ -136,7 +137,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
while (this.running) {
|
||||
try {
|
||||
const msg = await this.consumer.receive(2000);
|
||||
if (!msg) continue;
|
||||
if (msg === null) continue;
|
||||
|
||||
await this.handleMessage(msg);
|
||||
await this.consumer.acknowledge(msg);
|
||||
|
|
@ -155,7 +156,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
const props = msg.properties();
|
||||
const requestId = props.id;
|
||||
|
||||
if (!requestId) {
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
console.warn("[FlowManager] Received request without id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
|
@ -218,12 +219,12 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
request: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (!name) {
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
}
|
||||
|
||||
const blueprint = this.blueprints.get(name);
|
||||
if (!blueprint) {
|
||||
if (blueprint === undefined) {
|
||||
throw new Error(`Blueprint not found: ${name}`);
|
||||
}
|
||||
|
||||
|
|
@ -236,7 +237,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
request: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const name = request["blueprint-name"] as string | undefined;
|
||||
if (!name) {
|
||||
if (name === undefined || name.length === 0) {
|
||||
throw new Error("Missing blueprint-name");
|
||||
}
|
||||
|
||||
|
|
@ -264,12 +265,12 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
request: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const id = request["flow-id"] as string | undefined;
|
||||
if (!id) {
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("Missing flow-id");
|
||||
}
|
||||
|
||||
const inst = this.flows.get(id);
|
||||
if (!inst) {
|
||||
if (inst === undefined) {
|
||||
throw new Error(`Flow not found: ${id}`);
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +291,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
const description = (request["description"] as string) ?? "";
|
||||
const parameters = (request["parameters"] as Record<string, string>) ?? {};
|
||||
|
||||
if (!id) {
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("Missing flow-id");
|
||||
}
|
||||
|
||||
|
|
@ -299,7 +300,7 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
const blueprint = this.blueprints.get(blueprintName);
|
||||
if (!blueprint) {
|
||||
if (blueprint === undefined) {
|
||||
throw new Error(`Blueprint not found: ${blueprintName}`);
|
||||
}
|
||||
|
||||
|
|
@ -327,12 +328,12 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
request: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const id = request["flow-id"] as string | undefined;
|
||||
if (!id) {
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("Missing flow-id");
|
||||
}
|
||||
|
||||
const inst = this.flows.get(id);
|
||||
if (!inst) {
|
||||
if (inst === undefined) {
|
||||
throw new Error(`Flow not found: ${id}`);
|
||||
}
|
||||
|
||||
|
|
@ -353,12 +354,12 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
* to the config service via a PUT operation.
|
||||
*/
|
||||
private async pushFlowsConfig(): Promise<void> {
|
||||
if (!this.configClient) return;
|
||||
if (this.configClient === null) return;
|
||||
|
||||
const flowsConfig: Record<string, { topics: Record<string, string> }> = {};
|
||||
for (const [id, inst] of this.flows) {
|
||||
const blueprint = this.blueprints.get(inst.blueprintName);
|
||||
if (blueprint) {
|
||||
if (blueprint !== undefined) {
|
||||
flowsConfig[id] = { topics: blueprint.topics };
|
||||
}
|
||||
}
|
||||
|
|
@ -380,15 +381,15 @@ export class FlowManagerService extends AsyncProcessor {
|
|||
// ---------- Lifecycle ----------
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
if (this.consumer) {
|
||||
if (this.consumer !== null) {
|
||||
await this.consumer.close();
|
||||
this.consumer = null;
|
||||
}
|
||||
if (this.responseProducer) {
|
||||
if (this.responseProducer !== null) {
|
||||
await this.responseProducer.close();
|
||||
this.responseProducer = null;
|
||||
}
|
||||
if (this.configClient) {
|
||||
if (this.configClient !== null) {
|
||||
await this.configClient.stop();
|
||||
this.configClient = null;
|
||||
}
|
||||
|
|
@ -400,6 +401,11 @@ 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),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await FlowManagerService.launch("flow-manager");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export class DispatcherManager {
|
|||
key: string,
|
||||
): Promise<RequestResponse<unknown, unknown>> {
|
||||
let pending = this.requestors.get(key);
|
||||
if (!pending) {
|
||||
if (pending === undefined) {
|
||||
pending = (async () => {
|
||||
const rr = new RequestResponse({
|
||||
pubsub: this.pubsub,
|
||||
|
|
@ -114,7 +114,7 @@ export class DispatcherManager {
|
|||
kind: string,
|
||||
): { requestTopic: string; responseTopic: string } {
|
||||
const entry = GLOBAL_SERVICES.get(kind);
|
||||
if (entry) {
|
||||
if (entry !== undefined) {
|
||||
return {
|
||||
requestTopic: topicName(entry.request),
|
||||
responseTopic: topicName(entry.response),
|
||||
|
|
@ -131,7 +131,7 @@ export class DispatcherManager {
|
|||
kind: string,
|
||||
): { requestTopic: string; responseTopic: string } {
|
||||
const entry = FLOW_SERVICES.get(kind);
|
||||
if (entry) {
|
||||
if (entry !== undefined) {
|
||||
return {
|
||||
requestTopic: topicName(entry.request),
|
||||
responseTopic: topicName(entry.response),
|
||||
|
|
@ -152,15 +152,15 @@ export class DispatcherManager {
|
|||
if (typeof response !== "object" || response === null) return true;
|
||||
const res = response as Record<string, unknown>;
|
||||
return (
|
||||
!!res.complete ||
|
||||
!!res.endOfStream ||
|
||||
!!res.endOfSession ||
|
||||
!!res.end_of_stream ||
|
||||
!!res.end_of_session ||
|
||||
!!res.end_of_dialog ||
|
||||
!!res.eos ||
|
||||
res.complete === true ||
|
||||
res.endOfStream === true ||
|
||||
res.endOfSession === true ||
|
||||
res.end_of_stream === true ||
|
||||
res.end_of_session === true ||
|
||||
res.end_of_dialog === true ||
|
||||
res.eos === true ||
|
||||
// error responses are always final
|
||||
!!res.error
|
||||
(res.error !== undefined && res.error !== null)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,11 @@ export class Mux {
|
|||
private queue = new AsyncQueue<MuxRequest>();
|
||||
private outstanding = 0;
|
||||
private running = true;
|
||||
private readonly handler: MuxHandler;
|
||||
|
||||
constructor(private readonly handler: MuxHandler) {}
|
||||
constructor(handler: MuxHandler) {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
receive(request: MuxRequest): void {
|
||||
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
||||
|
|
|
|||
|
|
@ -65,14 +65,18 @@ export function clientTermToInternal(wire: ClientTerm): Term {
|
|||
return {
|
||||
type: "LITERAL",
|
||||
value: wire.v,
|
||||
datatype: wire.dt,
|
||||
language: wire.ln,
|
||||
...(wire.dt !== undefined ? { datatype: wire.dt } : {}),
|
||||
...(wire.ln !== undefined ? { language: wire.ln } : {}),
|
||||
};
|
||||
case "t":
|
||||
case "t": {
|
||||
if (wire.tr === undefined) {
|
||||
throw new Error("Client triple term is missing tr");
|
||||
}
|
||||
return {
|
||||
type: "TRIPLE",
|
||||
triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!,
|
||||
triple: clientTripleToInternal(wire.tr),
|
||||
};
|
||||
}
|
||||
default:
|
||||
// Defensive: pass through unknown term types
|
||||
return wire as unknown as Term;
|
||||
|
|
@ -105,14 +109,14 @@ export function internalTermToClient(term: Term): ClientTerm {
|
|||
return { t: "b", d: term.id };
|
||||
case "LITERAL": {
|
||||
const lit: ClientLiteralTerm = { t: "l", v: term.value };
|
||||
if (term.datatype) lit.dt = term.datatype;
|
||||
if (term.language) lit.ln = term.language;
|
||||
if (term.datatype !== undefined) lit.dt = term.datatype;
|
||||
if (term.language !== undefined) lit.ln = term.language;
|
||||
return lit;
|
||||
}
|
||||
case "TRIPLE":
|
||||
return {
|
||||
t: "t",
|
||||
tr: term.triple ? internalTripleToClient(term.triple) : undefined,
|
||||
tr: internalTripleToClient(term.triple),
|
||||
};
|
||||
default:
|
||||
return term as unknown as ClientTerm;
|
||||
|
|
@ -131,7 +135,10 @@ export function internalTripleToClient(triple: Triple): ClientTriple {
|
|||
result.g = g;
|
||||
} else {
|
||||
// If g is a Term, convert it back to client wire format
|
||||
result.g = (g as Record<string, unknown>).iri as string | undefined;
|
||||
const iri = (g as Record<string, unknown>).iri;
|
||||
if (typeof iri === "string") {
|
||||
result.g = iri;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
|
||||
import Fastify from "fastify";
|
||||
import websocketPlugin from "@fastify/websocket";
|
||||
import { registry } from "@trustgraph/base";
|
||||
import { Config, Effect } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import { errorMessage, optionalStringConfig, registry, toTgError } from "@trustgraph/base";
|
||||
import { DispatcherManager } from "./dispatch/manager.js";
|
||||
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
|
||||
|
||||
|
|
@ -33,9 +35,9 @@ export async function createGateway(config: GatewayConfig) {
|
|||
if (request.url === "/api/v1/metrics") return;
|
||||
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
|
||||
|
||||
if (config.secret) {
|
||||
if (config.secret !== undefined && config.secret.length > 0) {
|
||||
const auth = request.headers.authorization;
|
||||
if (!auth || auth !== `Bearer ${config.secret}`) {
|
||||
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
|
||||
reply.code(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
}
|
||||
|
|
@ -49,13 +51,13 @@ export async function createGateway(config: GatewayConfig) {
|
|||
try {
|
||||
const result = await dispatcher.dispatchGlobalService(kind, body) as Record<string, unknown>;
|
||||
const err = result?.error as { type?: string; message?: string } | undefined;
|
||||
if (err) {
|
||||
if (err !== undefined) {
|
||||
const statusCode = err.type === "not-found" ? 404 : 400;
|
||||
return reply.code(statusCode).send(result);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: { type: "internal", message: String(err) } });
|
||||
reply.code(500).send({ error: toTgError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -69,13 +71,13 @@ export async function createGateway(config: GatewayConfig) {
|
|||
try {
|
||||
const result = await dispatcher.dispatchFlowService(flow, kind, body) as Record<string, unknown>;
|
||||
const err = result?.error as { type?: string; message?: string } | undefined;
|
||||
if (err) {
|
||||
if (err !== undefined) {
|
||||
const statusCode = err.type === "not-found" ? 404 : 400;
|
||||
return reply.code(statusCode).send(result);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
reply.code(500).send({ error: { type: "internal", message: String(err) } });
|
||||
reply.code(500).send({ error: toTgError(err) });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -91,7 +93,7 @@ export async function createGateway(config: GatewayConfig) {
|
|||
collection?: string;
|
||||
};
|
||||
|
||||
if (!body.documentId) {
|
||||
if (body.documentId === undefined || body.documentId.length === 0) {
|
||||
return reply.code(400).send({
|
||||
error: { type: "bad-request", message: "documentId is required" },
|
||||
});
|
||||
|
|
@ -116,7 +118,7 @@ export async function createGateway(config: GatewayConfig) {
|
|||
return { status: "processing", documentId, flow };
|
||||
} catch (err) {
|
||||
reply.code(500).send({
|
||||
error: { type: "internal", message: String(err) },
|
||||
error: toTgError(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -128,14 +130,14 @@ export async function createGateway(config: GatewayConfig) {
|
|||
// Auth via query param
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
const token = url.searchParams.get("token");
|
||||
if (config.secret && token !== config.secret) {
|
||||
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
|
||||
socket.close(4001, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the MuxHandler that dispatches to the DispatcherManager
|
||||
const handler: MuxHandler = async (muxReq, respond) => {
|
||||
if (muxReq.flow) {
|
||||
if (muxReq.flow !== undefined && muxReq.flow.length > 0) {
|
||||
await dispatcher.dispatchFlowServiceStreaming(
|
||||
muxReq.flow,
|
||||
muxReq.service,
|
||||
|
|
@ -171,7 +173,13 @@ export async function createGateway(config: GatewayConfig) {
|
|||
request?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!msg.id || !msg.service || !msg.request) {
|
||||
if (
|
||||
msg.id === undefined ||
|
||||
msg.id.length === 0 ||
|
||||
msg.service === undefined ||
|
||||
msg.service.length === 0 ||
|
||||
msg.request === undefined
|
||||
) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: msg.id ?? null,
|
||||
|
|
@ -185,15 +193,15 @@ export async function createGateway(config: GatewayConfig) {
|
|||
const muxReq: MuxRequest = {
|
||||
id: msg.id,
|
||||
service: msg.service,
|
||||
flow: msg.flow,
|
||||
request: msg.request,
|
||||
...(msg.flow !== undefined ? { flow: msg.flow } : {}),
|
||||
};
|
||||
|
||||
mux.receive(muxReq);
|
||||
} catch (err) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
error: { type: "parse-error", message: String(err) },
|
||||
error: { type: "parse-error", message: errorMessage(err) },
|
||||
complete: true,
|
||||
}),
|
||||
);
|
||||
|
|
@ -234,14 +242,36 @@ export async function createGateway(config: GatewayConfig) {
|
|||
}
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const config: GatewayConfig = {
|
||||
port: parseInt(process.env.GATEWAY_PORT ?? "8088", 10),
|
||||
metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10),
|
||||
secret: process.env.GATEWAY_SECRET,
|
||||
natsUrl: process.env.NATS_URL,
|
||||
};
|
||||
|
||||
const gateway = await createGateway(config);
|
||||
await gateway.start();
|
||||
console.log(`[Gateway] Listening on port ${config.port}`);
|
||||
await Effect.runPromise(program);
|
||||
}
|
||||
|
||||
export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () {
|
||||
const secret = O.getOrUndefined(yield* Config.string("GATEWAY_SECRET").pipe(Config.option));
|
||||
const natsUrl = yield* optionalStringConfig("NATS_URL");
|
||||
const port = yield* Config.number("GATEWAY_PORT").pipe(Config.withDefault(8088));
|
||||
const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000));
|
||||
return {
|
||||
port,
|
||||
metricsPort,
|
||||
...(secret !== undefined ? { secret } : {}),
|
||||
...(natsUrl !== undefined ? { natsUrl } : {}),
|
||||
} satisfies GatewayConfig;
|
||||
});
|
||||
|
||||
export const program = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* loadGatewayConfig();
|
||||
const gateway = yield* Effect.promise(() => createGateway(config)).pipe(Effect.orDie);
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => gateway.stop()).pipe(Effect.orDie));
|
||||
yield* Effect.promise(() => gateway.start()).pipe(
|
||||
Effect.orDie,
|
||||
Effect.withSpan("trustgraph.gateway.start", {
|
||||
attributes: {
|
||||
"trustgraph.gateway.port": config.port,
|
||||
},
|
||||
}),
|
||||
);
|
||||
yield* Effect.log(`[Gateway] Listening on port ${config.port}`);
|
||||
return yield* Effect.never;
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,12 @@ export {
|
|||
} from "./query/embeddings/qdrant-graph.js";
|
||||
|
||||
// Embeddings services
|
||||
export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js";
|
||||
export {
|
||||
OllamaEmbeddingsLive,
|
||||
OllamaEmbeddingsProcessor,
|
||||
makeOllamaEmbeddings,
|
||||
type OllamaEmbeddingsConfig,
|
||||
} from "./embeddings/ollama.js";
|
||||
|
||||
// Prompt template service
|
||||
export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js";
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export class CollectionManager {
|
|||
|
||||
ensureCollectionExists(user: string, collection: string): CollectionEntry {
|
||||
const existing = this.getCollection(user, collection);
|
||||
if (existing) return existing;
|
||||
if (existing !== undefined) return existing;
|
||||
return this.updateCollection(user, collection, collection, "", []);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@
|
|||
* Python reference: trustgraph-flow/trustgraph/librarian/service/service.py
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
type ProcessorConfig,
|
||||
|
|
@ -24,8 +21,18 @@ import {
|
|||
type DocumentMetadata,
|
||||
type ProcessingMetadata,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import type { BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
|
||||
import { CollectionManager } from "./collection-manager.js";
|
||||
import {
|
||||
ensureDirectory,
|
||||
joinPath,
|
||||
readBinaryFile,
|
||||
readTextFile,
|
||||
removePath,
|
||||
writeBinaryFile,
|
||||
writeTextFile,
|
||||
} from "../runtime/effect-files.js";
|
||||
|
||||
export interface LibrarianServiceConfig extends ProcessorConfig {
|
||||
dataDir?: string;
|
||||
|
|
@ -49,12 +56,12 @@ export class LibrarianService extends AsyncProcessor {
|
|||
constructor(config: LibrarianServiceConfig) {
|
||||
super(config);
|
||||
this.dataDir = config.dataDir ?? process.env.LIBRARIAN_DATA_DIR ?? "./data/librarian";
|
||||
this.persistPath = join(this.dataDir, "librarian-state.json");
|
||||
this.persistPath = joinPath(this.dataDir, "librarian-state.json");
|
||||
}
|
||||
|
||||
protected override async run(): Promise<void> {
|
||||
// Ensure directories exist
|
||||
await mkdir(join(this.dataDir, "docs"), { recursive: true });
|
||||
await ensureDirectory(joinPath(this.dataDir, "docs"));
|
||||
|
||||
// Load persisted state
|
||||
await this.loadFromDisk();
|
||||
|
|
@ -84,14 +91,14 @@ export class LibrarianService extends AsyncProcessor {
|
|||
try {
|
||||
// Poll librarian requests
|
||||
const libMsg = await this.libConsumer.receive(2000);
|
||||
if (libMsg) {
|
||||
if (libMsg !== null) {
|
||||
await this.handleLibrarianMessage(libMsg);
|
||||
await this.libConsumer.acknowledge(libMsg);
|
||||
}
|
||||
|
||||
// Poll collection management requests
|
||||
const colMsg = await this.colConsumer.receive(2000);
|
||||
if (colMsg) {
|
||||
if (colMsg !== null) {
|
||||
await this.handleCollectionMessage(colMsg);
|
||||
await this.colConsumer.acknowledge(colMsg);
|
||||
}
|
||||
|
|
@ -110,7 +117,7 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const props = msg.properties();
|
||||
const requestId = props.id;
|
||||
|
||||
if (!requestId) {
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
console.warn("[LibrarianService] Received request without id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
|
@ -156,9 +163,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async addDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
if (!meta) throw new Error("add-document requires documentMetadata");
|
||||
if (meta === undefined) throw new Error("add-document requires documentMetadata");
|
||||
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
|
|
@ -170,10 +177,10 @@ export class LibrarianService extends AsyncProcessor {
|
|||
this.documents.set(id, doc);
|
||||
|
||||
// Store file content if provided
|
||||
if (request.content) {
|
||||
const filePath = join(this.dataDir, "docs", `${id}.bin`);
|
||||
if (request.content !== undefined && request.content.length > 0) {
|
||||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = Buffer.from(request.content, "base64");
|
||||
await writeFile(filePath, buf);
|
||||
await writeBinaryFile(filePath, buf);
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
|
|
@ -184,14 +191,16 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async removeDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
if (!id) throw new Error("remove-document requires documentId");
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-document requires documentId");
|
||||
}
|
||||
|
||||
// Remove the document itself
|
||||
this.documents.delete(id);
|
||||
|
||||
// Remove the file
|
||||
try {
|
||||
await unlink(join(this.dataDir, "docs", `${id}.bin`));
|
||||
await removePath(joinPath(this.dataDir, "docs", `${id}.bin`));
|
||||
} catch {
|
||||
// File may not exist — that's fine
|
||||
}
|
||||
|
|
@ -204,7 +213,7 @@ export class LibrarianService extends AsyncProcessor {
|
|||
for (const childId of childIds) {
|
||||
this.documents.delete(childId);
|
||||
try {
|
||||
await unlink(join(this.dataDir, "docs", `${childId}.bin`));
|
||||
await removePath(joinPath(this.dataDir, "docs", `${childId}.bin`));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
|
@ -231,9 +240,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
for (const doc of this.documents.values()) {
|
||||
// Filter by user
|
||||
if (user && doc.user !== user) continue;
|
||||
if (user.length > 0 && doc.user !== user) continue;
|
||||
// Exclude children (only top-level documents) unless explicitly requested
|
||||
if (doc.parentId) continue;
|
||||
if (doc.parentId !== undefined && doc.parentId.length > 0) continue;
|
||||
docs.push(doc);
|
||||
}
|
||||
|
||||
|
|
@ -242,25 +251,29 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private getDocumentMetadata(request: LibrarianRequest): LibrarianResponse {
|
||||
const id = request.documentId;
|
||||
if (!id) throw new Error("get-document-metadata requires documentId");
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-metadata requires documentId");
|
||||
}
|
||||
|
||||
const doc = this.documents.get(id);
|
||||
if (!doc) throw new Error(`Document not found: ${id}`);
|
||||
if (doc === undefined) throw new Error(`Document not found: ${id}`);
|
||||
|
||||
return { documentMetadata: doc };
|
||||
}
|
||||
|
||||
private async getDocumentContent(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.documentId;
|
||||
if (!id) throw new Error("get-document-content requires documentId");
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("get-document-content requires documentId");
|
||||
}
|
||||
|
||||
const doc = this.documents.get(id);
|
||||
if (!doc) throw new Error(`Document not found: ${id}`);
|
||||
if (doc === undefined) throw new Error(`Document not found: ${id}`);
|
||||
|
||||
try {
|
||||
const filePath = join(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readFile(filePath);
|
||||
const content = buf.toString("base64");
|
||||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = await readBinaryFile(filePath);
|
||||
const content = Buffer.from(buf).toString("base64");
|
||||
return { documentMetadata: doc, content };
|
||||
} catch {
|
||||
throw new Error(`Document content not found on disk: ${id}`);
|
||||
|
|
@ -269,15 +282,19 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async addChildDocument(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const meta = request.documentMetadata;
|
||||
if (!meta) throw new Error("add-child-document requires documentMetadata");
|
||||
if (!meta.parentId) throw new Error("add-child-document requires parentId in metadata");
|
||||
if (meta === undefined) {
|
||||
throw new Error("add-child-document requires documentMetadata");
|
||||
}
|
||||
if (meta.parentId === undefined || meta.parentId.length === 0) {
|
||||
throw new Error("add-child-document requires parentId in metadata");
|
||||
}
|
||||
|
||||
// Verify parent exists
|
||||
if (!this.documents.has(meta.parentId)) {
|
||||
throw new Error(`Parent document not found: ${meta.parentId}`);
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const doc: DocumentMetadata = {
|
||||
|
|
@ -289,10 +306,10 @@ export class LibrarianService extends AsyncProcessor {
|
|||
this.documents.set(id, doc);
|
||||
|
||||
// Store file content if provided
|
||||
if (request.content) {
|
||||
const filePath = join(this.dataDir, "docs", `${id}.bin`);
|
||||
if (request.content !== undefined && request.content.length > 0) {
|
||||
const filePath = joinPath(this.dataDir, "docs", `${id}.bin`);
|
||||
const buf = Buffer.from(request.content, "base64");
|
||||
await writeFile(filePath, buf);
|
||||
await writeBinaryFile(filePath, buf);
|
||||
}
|
||||
|
||||
await this.persist();
|
||||
|
|
@ -303,7 +320,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private listChildren(request: LibrarianRequest): LibrarianResponse {
|
||||
const parentId = request.documentId;
|
||||
if (!parentId) throw new Error("list-children requires documentId");
|
||||
if (parentId === undefined || parentId.length === 0) {
|
||||
throw new Error("list-children requires documentId");
|
||||
}
|
||||
|
||||
const children: DocumentMetadata[] = [];
|
||||
for (const doc of this.documents.values()) {
|
||||
|
|
@ -317,9 +336,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async addProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const proc = request.processingMetadata;
|
||||
if (!proc) throw new Error("add-processing requires processingMetadata");
|
||||
if (proc === undefined) throw new Error("add-processing requires processingMetadata");
|
||||
|
||||
const id = randomUUID();
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
const record: ProcessingMetadata = {
|
||||
|
|
@ -337,7 +356,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async removeProcessing(request: LibrarianRequest): Promise<LibrarianResponse> {
|
||||
const id = request.processingId;
|
||||
if (!id) throw new Error("remove-processing requires processingId");
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new Error("remove-processing requires processingId");
|
||||
}
|
||||
|
||||
this.processing.delete(id);
|
||||
await this.persist();
|
||||
|
|
@ -350,7 +371,9 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const records: ProcessingMetadata[] = [];
|
||||
|
||||
for (const proc of this.processing.values()) {
|
||||
if (documentId && proc.documentId !== documentId) continue;
|
||||
if (documentId !== undefined && documentId.length > 0 && proc.documentId !== documentId) {
|
||||
continue;
|
||||
}
|
||||
records.push(proc);
|
||||
}
|
||||
|
||||
|
|
@ -364,7 +387,7 @@ export class LibrarianService extends AsyncProcessor {
|
|||
const props = msg.properties();
|
||||
const requestId = props.id;
|
||||
|
||||
if (!requestId) {
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
console.warn("[LibrarianService] Received collection request without id, ignoring");
|
||||
return;
|
||||
}
|
||||
|
|
@ -430,8 +453,7 @@ export class LibrarianService extends AsyncProcessor {
|
|||
};
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await mkdir(dirname(this.persistPath), { recursive: true });
|
||||
await writeFile(this.persistPath, json, "utf-8");
|
||||
await writeTextFile(this.persistPath, json);
|
||||
} catch (err) {
|
||||
console.error("[LibrarianService] Failed to persist state:", err);
|
||||
}
|
||||
|
|
@ -439,7 +461,7 @@ export class LibrarianService extends AsyncProcessor {
|
|||
|
||||
private async loadFromDisk(): Promise<void> {
|
||||
try {
|
||||
const raw = await readFile(this.persistPath, "utf-8");
|
||||
const raw = await readTextFile(this.persistPath);
|
||||
const parsed = JSON.parse(raw) as {
|
||||
documents?: Record<string, DocumentMetadata>;
|
||||
processing?: Record<string, ProcessingMetadata>;
|
||||
|
|
@ -447,20 +469,20 @@ export class LibrarianService extends AsyncProcessor {
|
|||
};
|
||||
|
||||
this.documents.clear();
|
||||
if (parsed.documents) {
|
||||
if (parsed.documents !== undefined) {
|
||||
for (const [id, doc] of Object.entries(parsed.documents)) {
|
||||
this.documents.set(id, doc);
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.clear();
|
||||
if (parsed.processing) {
|
||||
if (parsed.processing !== undefined) {
|
||||
for (const [id, proc] of Object.entries(parsed.processing)) {
|
||||
this.processing.set(id, proc);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.collections) {
|
||||
if (parsed.collections !== undefined) {
|
||||
this.collectionManager.loadFromJSON(parsed.collections);
|
||||
}
|
||||
|
||||
|
|
@ -473,19 +495,19 @@ export class LibrarianService extends AsyncProcessor {
|
|||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
if (this.libConsumer) {
|
||||
if (this.libConsumer !== null) {
|
||||
await this.libConsumer.close();
|
||||
this.libConsumer = null;
|
||||
}
|
||||
if (this.libProducer) {
|
||||
if (this.libProducer !== null) {
|
||||
await this.libProducer.close();
|
||||
this.libProducer = null;
|
||||
}
|
||||
if (this.colConsumer) {
|
||||
if (this.colConsumer !== null) {
|
||||
await this.colConsumer.close();
|
||||
this.colConsumer = null;
|
||||
}
|
||||
if (this.colProducer) {
|
||||
if (this.colProducer !== null) {
|
||||
await this.colProducer.close();
|
||||
this.colProducer = null;
|
||||
}
|
||||
|
|
@ -497,6 +519,11 @@ function sleep(ms: number): Promise<void> {
|
|||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "librarian-svc",
|
||||
make: (config) => new LibrarianService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await LibrarianService.launch("librarian-svc");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import {
|
|||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
TooManyRequestsError,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class AzureOpenAIProcessor extends LlmService {
|
||||
private client: AzureOpenAI;
|
||||
|
|
@ -40,10 +41,14 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
this.maxOutput = config.maxOutput ?? 4096;
|
||||
|
||||
const apiKey = config.apiKey ?? process.env.AZURE_TOKEN;
|
||||
if (!apiKey) throw new Error("Azure OpenAI API key not specified");
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error("Azure OpenAI API key not specified");
|
||||
}
|
||||
|
||||
const endpoint = config.endpoint ?? process.env.AZURE_ENDPOINT;
|
||||
if (!endpoint) throw new Error("Azure OpenAI endpoint not specified");
|
||||
if (endpoint === undefined || endpoint.length === 0) {
|
||||
throw new Error("Azure OpenAI endpoint not specified");
|
||||
}
|
||||
|
||||
const apiVersion =
|
||||
config.apiVersion ??
|
||||
|
|
@ -83,7 +88,7 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if ((err as any)?.status === 429) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -119,9 +124,10 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
let totalOutputTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.choices?.[0]?.delta?.content) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (content !== null && content !== undefined && content.length > 0) {
|
||||
yield {
|
||||
text: chunk.choices[0].delta.content,
|
||||
text: content,
|
||||
inToken: null,
|
||||
outToken: null,
|
||||
model: modelName,
|
||||
|
|
@ -129,7 +135,7 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
};
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
if (chunk.usage !== null && chunk.usage !== undefined) {
|
||||
totalInputTokens = chunk.usage.prompt_tokens;
|
||||
totalOutputTokens = chunk.usage.completion_tokens;
|
||||
}
|
||||
|
|
@ -144,13 +150,18 @@ export class AzureOpenAIProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if ((err as any)?.status === 429) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new AzureOpenAIProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await AzureOpenAIProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class ClaudeProcessor extends LlmService {
|
||||
private client: Anthropic;
|
||||
|
|
@ -26,7 +27,9 @@ export class ClaudeProcessor extends LlmService {
|
|||
this.maxOutput = config.maxOutput ?? 8192;
|
||||
|
||||
const apiKey = config.apiKey ?? process.env.CLAUDE_KEY;
|
||||
if (!apiKey) throw new Error("Claude API key not specified");
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error("Claude API key not specified");
|
||||
}
|
||||
|
||||
this.client = new Anthropic({ apiKey });
|
||||
|
||||
|
|
@ -65,7 +68,7 @@ export class ClaudeProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.RateLimitError) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -117,13 +120,18 @@ export class ClaudeProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.RateLimitError) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new ClaudeProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await ClaudeProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import {
|
|||
type ProcessorConfig,
|
||||
type LlmResult,
|
||||
type LlmChunk,
|
||||
TooManyRequestsError,
|
||||
tooManyRequestsError,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class MistralProcessor extends LlmService {
|
||||
private client: Mistral;
|
||||
|
|
@ -37,7 +38,9 @@ export class MistralProcessor extends LlmService {
|
|||
this.maxOutput = config.maxOutput ?? 4096;
|
||||
|
||||
const apiKey = config.apiKey ?? process.env.MISTRAL_TOKEN;
|
||||
if (!apiKey) throw new Error("Mistral API key not specified");
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error("Mistral API key not specified");
|
||||
}
|
||||
|
||||
this.client = new Mistral({ apiKey });
|
||||
|
||||
|
|
@ -72,7 +75,7 @@ export class MistralProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -107,9 +110,10 @@ export class MistralProcessor extends LlmService {
|
|||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.data?.choices?.[0]?.delta;
|
||||
if (delta?.content) {
|
||||
const content = delta?.content;
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
yield {
|
||||
text: delta.content as string,
|
||||
text: content,
|
||||
inToken: null,
|
||||
outToken: null,
|
||||
model: modelName,
|
||||
|
|
@ -117,7 +121,7 @@ export class MistralProcessor extends LlmService {
|
|||
};
|
||||
}
|
||||
|
||||
if (chunk.data?.usage) {
|
||||
if (chunk.data?.usage !== undefined) {
|
||||
totalInputTokens = chunk.data.usage.promptTokens ?? 0;
|
||||
totalOutputTokens = chunk.data.usage.completionTokens ?? 0;
|
||||
}
|
||||
|
|
@ -132,13 +136,18 @@ export class MistralProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new MistralProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await MistralProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import { Ollama } from "ollama";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class OllamaProcessor extends LlmService {
|
||||
private client: Ollama;
|
||||
|
|
@ -90,7 +91,7 @@ export class OllamaProcessor extends LlmService {
|
|||
totalOutputTokens = chunk.eval_count;
|
||||
}
|
||||
|
||||
if (chunk.response) {
|
||||
if (chunk.response.length > 0) {
|
||||
yield {
|
||||
text: chunk.response,
|
||||
inToken: null,
|
||||
|
|
@ -112,6 +113,11 @@ export class OllamaProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new OllamaProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OllamaProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type LlmResult,
|
||||
type LlmChunk,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class OpenAICompatibleProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -40,10 +41,11 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
this.maxOutput = config.maxOutput ?? 4096;
|
||||
|
||||
const baseURL = config.baseUrl ?? process.env.OPENAI_COMPAT_URL;
|
||||
if (!baseURL)
|
||||
if (baseURL === undefined || baseURL.length === 0) {
|
||||
throw new Error(
|
||||
"OpenAI-compatible server URL not specified (set OPENAI_COMPAT_URL)",
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
config.apiKey ?? process.env.OPENAI_COMPAT_KEY ?? "sk-no-key-required";
|
||||
|
|
@ -108,9 +110,10 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
let totalOutputTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.choices?.[0]?.delta?.content) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (content !== null && content !== undefined && content.length > 0) {
|
||||
yield {
|
||||
text: chunk.choices[0].delta.content,
|
||||
text: content,
|
||||
inToken: null,
|
||||
outToken: null,
|
||||
model: modelName,
|
||||
|
|
@ -118,7 +121,7 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
};
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
if (chunk.usage !== null && chunk.usage !== undefined) {
|
||||
totalInputTokens = chunk.usage.prompt_tokens;
|
||||
totalOutputTokens = chunk.usage.completion_tokens;
|
||||
}
|
||||
|
|
@ -134,6 +137,11 @@ export class OpenAICompatibleProcessor extends LlmService {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAICompatibleProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAICompatibleProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
*/
|
||||
|
||||
import OpenAI from "openai";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, TooManyRequestsError } from "@trustgraph/base";
|
||||
import { LlmService, type ProcessorConfig, type LlmResult, type LlmChunk, tooManyRequestsError } from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export class OpenAIProcessor extends LlmService {
|
||||
private client: OpenAI;
|
||||
|
|
@ -27,7 +28,9 @@ export class OpenAIProcessor extends LlmService {
|
|||
this.maxOutput = config.maxOutput ?? 4096;
|
||||
|
||||
const apiKey = config.apiKey ?? process.env.OPENAI_TOKEN;
|
||||
if (!apiKey) throw new Error("OpenAI API key not specified");
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
throw new Error("OpenAI API key not specified");
|
||||
}
|
||||
|
||||
this.client = new OpenAI({
|
||||
apiKey,
|
||||
|
|
@ -65,7 +68,7 @@ export class OpenAIProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if (err instanceof OpenAI.RateLimitError) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
|
@ -101,9 +104,10 @@ export class OpenAIProcessor extends LlmService {
|
|||
let totalOutputTokens = 0;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.choices?.[0]?.delta?.content) {
|
||||
const content = chunk.choices[0]?.delta?.content;
|
||||
if (content !== null && content !== undefined && content.length > 0) {
|
||||
yield {
|
||||
text: chunk.choices[0].delta.content,
|
||||
text: content,
|
||||
inToken: null,
|
||||
outToken: null,
|
||||
model: modelName,
|
||||
|
|
@ -111,7 +115,7 @@ export class OpenAIProcessor extends LlmService {
|
|||
};
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
if (chunk.usage !== null && chunk.usage !== undefined) {
|
||||
totalInputTokens = chunk.usage.prompt_tokens;
|
||||
totalOutputTokens = chunk.usage.completion_tokens;
|
||||
}
|
||||
|
|
@ -126,13 +130,18 @@ export class OpenAIProcessor extends LlmService {
|
|||
};
|
||||
} catch (err) {
|
||||
if (err instanceof OpenAI.RateLimitError) {
|
||||
throw new TooManyRequestsError();
|
||||
throw tooManyRequestsError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "text-completion",
|
||||
make: (config) => new OpenAIProcessor(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await OpenAIProcessor.launch("text-completion");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
|
||||
export interface PromptTemplate {
|
||||
system: string;
|
||||
|
|
@ -53,7 +54,7 @@ export class PromptTemplateService extends FlowProcessor {
|
|||
this.configKey = config.configKey ?? "prompt";
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<PromptRequest>(
|
||||
ConsumerSpec.fromPromise<PromptRequest>(
|
||||
"prompt-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
|
|
@ -75,7 +76,7 @@ export class PromptTemplateService extends FlowProcessor {
|
|||
| Record<string, { system?: string; prompt?: string }>
|
||||
| undefined;
|
||||
|
||||
if (!promptConfig) {
|
||||
if (promptConfig === undefined) {
|
||||
console.warn(`[PromptTemplate] No key "${this.configKey}" in config`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -104,13 +105,13 @@ export class PromptTemplateService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<PromptResponse>("prompt-response");
|
||||
|
||||
try {
|
||||
const template = this.templates.get(msg.name);
|
||||
if (!template) {
|
||||
if (template === undefined) {
|
||||
throw new Error(`Unknown prompt template: "${msg.name}"`);
|
||||
}
|
||||
|
||||
|
|
@ -149,6 +150,11 @@ function renderTemplate(
|
|||
});
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "prompt",
|
||||
make: (config) => new PromptTemplateService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await PromptTemplateService.launch("prompt");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type DocumentEmbeddingsRequest,
|
||||
type DocumentEmbeddingsResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantDocEmbeddingsQuery } from "./qdrant-doc.js";
|
||||
|
||||
export class DocEmbeddingsQueryService extends FlowProcessor {
|
||||
|
|
@ -26,7 +27,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
|
|||
this.query = new QdrantDocEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<DocumentEmbeddingsRequest>(
|
||||
ConsumerSpec.fromPromise<DocumentEmbeddingsRequest>(
|
||||
"document-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
|
|
@ -44,7 +45,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentEmbeddingsResponse>("document-embeddings-response");
|
||||
const collection = msg.collection ?? "default";
|
||||
|
|
@ -64,7 +65,7 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
|
|||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
content: match.content,
|
||||
...(match.content !== undefined ? { content: match.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +81,11 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "doc-embeddings-query",
|
||||
make: (config) => new DocEmbeddingsQueryService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocEmbeddingsQueryService.launch("doc-embeddings-query");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ export class 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 });
|
||||
this.client = new QdrantClient({
|
||||
url,
|
||||
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
|
||||
});
|
||||
|
||||
console.log("[QdrantDocQuery] Query service initialized");
|
||||
}
|
||||
|
|
@ -42,7 +45,7 @@ export class QdrantDocEmbeddingsQuery {
|
|||
async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> {
|
||||
const { vector, user, collection, limit } = request;
|
||||
|
||||
if (!vector || vector.length === 0) {
|
||||
if (vector.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -68,11 +71,11 @@ export class QdrantDocEmbeddingsQuery {
|
|||
for (const point of searchResult) {
|
||||
const payload = point.payload as Record<string, unknown> | undefined;
|
||||
const chunkId = payload?.chunk_id as string | undefined;
|
||||
if (chunkId) {
|
||||
if (chunkId !== undefined && chunkId.length > 0) {
|
||||
chunks.push({
|
||||
chunkId,
|
||||
score: point.score,
|
||||
content: (payload?.content as string) ?? undefined,
|
||||
...(typeof payload?.content === "string" ? { content: payload.content } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type GraphEmbeddingsRequest,
|
||||
type GraphEmbeddingsResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsQuery } from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsQueryService extends FlowProcessor {
|
||||
|
|
@ -26,7 +27,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
|
|||
this.query = new QdrantGraphEmbeddingsQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<GraphEmbeddingsRequest>(
|
||||
ConsumerSpec.fromPromise<GraphEmbeddingsRequest>(
|
||||
"graph-embeddings-request",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
|
|
@ -44,7 +45,7 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const user = msg.user ?? "default";
|
||||
|
|
@ -79,6 +80,11 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "graph-embeddings-query",
|
||||
make: (config) => new GraphEmbeddingsQueryService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsQueryService.launch("graph-embeddings-query");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ export class 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 });
|
||||
this.client = new QdrantClient({
|
||||
url,
|
||||
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
|
||||
});
|
||||
|
||||
console.log("[QdrantGraphQuery] Query service initialized");
|
||||
}
|
||||
|
|
@ -52,7 +55,7 @@ export class QdrantGraphEmbeddingsQuery {
|
|||
async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> {
|
||||
const { vector, user, collection, limit } = request;
|
||||
|
||||
if (!vector || vector.length === 0) {
|
||||
if (vector.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +85,7 @@ export class QdrantGraphEmbeddingsQuery {
|
|||
for (const point of searchResult) {
|
||||
const payload = point.payload as Record<string, unknown> | undefined;
|
||||
const entityValue = payload?.entity as string | undefined;
|
||||
if (!entityValue) continue;
|
||||
if (entityValue === undefined || entityValue.length === 0) continue;
|
||||
|
||||
// Deduplicate by entity value, keeping the highest score (results are
|
||||
// already sorted by score descending from Qdrant)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type TriplesQueryRequest,
|
||||
type TriplesQueryResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesQuery } from "./falkordb.js";
|
||||
|
||||
export class TriplesQueryService extends FlowProcessor {
|
||||
|
|
@ -26,7 +27,7 @@ export class TriplesQueryService extends FlowProcessor {
|
|||
this.query = new FalkorDBTriplesQuery();
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
|
||||
ConsumerSpec.fromPromise<TriplesQueryRequest>("triples-request", this.onMessage.bind(this)),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<TriplesQueryResponse>("triples-response"));
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ export class TriplesQueryService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<TriplesQueryResponse>("triples-response");
|
||||
|
||||
|
|
@ -62,6 +63,11 @@ export class TriplesQueryService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "triples-query",
|
||||
make: (config) => new TriplesQueryService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesQueryService.launch("triples-query");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export interface FalkorDBQueryConfig {
|
|||
}
|
||||
|
||||
function termToValue(term: Term | undefined): string | null {
|
||||
if (!term) return null;
|
||||
if (term === undefined) return null;
|
||||
switch (term.type) {
|
||||
case "IRI": return term.iri;
|
||||
case "LITERAL": return term.value;
|
||||
|
|
@ -25,7 +25,7 @@ function termToValue(term: Term | undefined): string | null {
|
|||
}
|
||||
|
||||
function createTerm(value: string): Term {
|
||||
if (!value) {
|
||||
if (value.length === 0) {
|
||||
return { type: "LITERAL", value: "" };
|
||||
}
|
||||
if (value.startsWith("http://") || value.startsWith("https://")) {
|
||||
|
|
@ -75,25 +75,25 @@ export class FalkorDBTriplesQuery {
|
|||
const rawTriples: [string, string, string][] = [];
|
||||
|
||||
// Query both Node and Literal targets for each pattern
|
||||
if (sv && pv && ov) {
|
||||
if (sv !== null && pv !== null && ov !== null) {
|
||||
// SPO — exact match
|
||||
await this.matchPattern(rawTriples, sv, pv, ov, limit);
|
||||
} else if (sv && pv) {
|
||||
} else if (sv !== null && pv !== null) {
|
||||
// SP — known subject + predicate
|
||||
await this.matchSP(rawTriples, sv, pv, limit);
|
||||
} else if (sv && ov) {
|
||||
} else if (sv !== null && ov !== null) {
|
||||
// SO — known subject + object
|
||||
await this.matchSO(rawTriples, sv, ov, limit);
|
||||
} else if (pv && ov) {
|
||||
} else if (pv !== null && ov !== null) {
|
||||
// PO — known predicate + object
|
||||
await this.matchPO(rawTriples, pv, ov, limit);
|
||||
} else if (sv) {
|
||||
} else if (sv !== null) {
|
||||
// S only
|
||||
await this.matchS(rawTriples, sv, limit);
|
||||
} else if (pv) {
|
||||
} else if (pv !== null) {
|
||||
// P only
|
||||
await this.matchP(rawTriples, pv, limit);
|
||||
} else if (ov) {
|
||||
} else if (ov !== null) {
|
||||
// O only
|
||||
await this.matchO(rawTriples, ov, limit);
|
||||
} else {
|
||||
|
|
@ -102,7 +102,7 @@ export class FalkorDBTriplesQuery {
|
|||
}
|
||||
|
||||
return rawTriples
|
||||
.filter(([s, p, o]) => s != null && p != null && o != null)
|
||||
.filter(([s, p, o]) => s !== null && p !== null && o !== null)
|
||||
.slice(0, limit)
|
||||
.map(([s, p, o]) => ({
|
||||
s: createTerm(s),
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { DocumentRag } from "./document-rag.js";
|
||||
|
||||
export class DocumentRagService extends FlowProcessor {
|
||||
|
|
@ -35,7 +36,7 @@ export class DocumentRagService extends FlowProcessor {
|
|||
|
||||
// Consumer: document RAG requests
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
|
||||
ConsumerSpec.fromPromise<DocumentRagRequest>("document-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
|
||||
// Producer: document RAG responses
|
||||
|
|
@ -80,7 +81,7 @@ export class DocumentRagService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<DocumentRagResponse>("document-rag-response");
|
||||
|
||||
|
|
@ -93,7 +94,7 @@ export class DocumentRagService extends FlowProcessor {
|
|||
});
|
||||
|
||||
const response = await documentRag.query(msg.query, {
|
||||
collection: msg.collection,
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
await producer.send(requestId, { response, endOfStream: true });
|
||||
|
|
@ -107,6 +108,11 @@ export class DocumentRagService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "document-rag",
|
||||
make: (config) => new DocumentRagService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await DocumentRagService.launch("document-rag");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
RequestResponse,
|
||||
FlowRequestor,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
EmbeddingsRequest,
|
||||
|
|
@ -20,16 +20,20 @@ import type {
|
|||
} from "@trustgraph/base";
|
||||
|
||||
export interface DocumentRagClients {
|
||||
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
|
||||
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
|
||||
docEmbeddings: RequestResponse<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
|
||||
prompt: RequestResponse<PromptRequest, PromptResponse>;
|
||||
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
|
||||
embeddings: FlowRequestor<EmbeddingsRequest, EmbeddingsResponse>;
|
||||
docEmbeddings: FlowRequestor<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
|
||||
prompt: FlowRequestor<PromptRequest, PromptResponse>;
|
||||
}
|
||||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export class DocumentRag {
|
||||
constructor(private readonly clients: DocumentRagClients) {}
|
||||
private readonly clients: DocumentRagClients;
|
||||
|
||||
constructor(clients: DocumentRagClients) {
|
||||
this.clients = clients;
|
||||
}
|
||||
|
||||
async query(
|
||||
queryText: string,
|
||||
|
|
@ -57,8 +61,9 @@ export class DocumentRag {
|
|||
|
||||
// Step 3: Build context from chunks
|
||||
const context = chunks
|
||||
.filter((c) => c.content)
|
||||
.map((c) => c.content)
|
||||
.flatMap((c) =>
|
||||
c.content !== undefined && c.content.length > 0 ? [c.content] : [],
|
||||
)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
// Step 4: Synthesize answer
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
type PromptRequest,
|
||||
type PromptResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { GraphRag } from "./graph-rag.js";
|
||||
|
||||
export class GraphRagService extends FlowProcessor {
|
||||
|
|
@ -39,7 +40,7 @@ export class GraphRagService extends FlowProcessor {
|
|||
|
||||
// Consumer: graph RAG requests
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
|
||||
ConsumerSpec.fromPromise<GraphRagRequest>("graph-rag-request", this.onRequest.bind(this)),
|
||||
);
|
||||
|
||||
// Producer: graph RAG responses
|
||||
|
|
@ -91,7 +92,7 @@ export class GraphRagService extends FlowProcessor {
|
|||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
|
||||
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
|
|
@ -107,15 +108,17 @@ export class GraphRagService extends FlowProcessor {
|
|||
prompt: flowCtx.flow.requestor<PromptRequest, PromptResponse>("prompt"),
|
||||
},
|
||||
{
|
||||
entityLimit: msg.entityLimit,
|
||||
tripleLimit: msg.tripleLimit,
|
||||
maxSubgraphSize: msg.maxSubgraphSize,
|
||||
maxPathLength: msg.maxPathLength,
|
||||
...(msg.entityLimit !== undefined ? { entityLimit: msg.entityLimit } : {}),
|
||||
...(msg.tripleLimit !== undefined ? { tripleLimit: msg.tripleLimit } : {}),
|
||||
...(msg.maxSubgraphSize !== undefined
|
||||
? { maxSubgraphSize: msg.maxSubgraphSize }
|
||||
: {}),
|
||||
...(msg.maxPathLength !== undefined ? { maxPathLength: msg.maxPathLength } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
const result = await graphRag.query(msg.query, {
|
||||
collection: msg.collection,
|
||||
...(msg.collection !== undefined ? { collection: msg.collection } : {}),
|
||||
});
|
||||
|
||||
// Send answer with explain data embedded in a SINGLE message.
|
||||
|
|
@ -145,6 +148,11 @@ export class GraphRagService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "graph-rag",
|
||||
make: (config) => new GraphRagService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphRagService.launch("graph-rag");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import type {
|
|||
EmbeddingsResponse,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
FlowRequestor,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
RequestResponse,
|
||||
Term,
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
|
|
@ -37,11 +37,11 @@ export interface GraphRagConfig {
|
|||
}
|
||||
|
||||
export interface GraphRagClients {
|
||||
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
|
||||
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
|
||||
graphEmbeddings: RequestResponse<GraphEmbeddingsRequest, GraphEmbeddingsResponse>;
|
||||
triples: RequestResponse<TriplesQueryRequest, TriplesQueryResponse>;
|
||||
prompt: RequestResponse<PromptRequest, PromptResponse>;
|
||||
llm: FlowRequestor<TextCompletionRequest, TextCompletionResponse>;
|
||||
embeddings: FlowRequestor<EmbeddingsRequest, EmbeddingsResponse>;
|
||||
graphEmbeddings: FlowRequestor<GraphEmbeddingsRequest, GraphEmbeddingsResponse>;
|
||||
triples: FlowRequestor<TriplesQueryRequest, TriplesQueryResponse>;
|
||||
prompt: FlowRequestor<PromptRequest, PromptResponse>;
|
||||
}
|
||||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
|
@ -52,12 +52,14 @@ export interface GraphRagResult {
|
|||
}
|
||||
|
||||
export class GraphRag {
|
||||
private readonly clients: GraphRagClients;
|
||||
private config: Required<GraphRagConfig>;
|
||||
|
||||
constructor(
|
||||
private readonly clients: GraphRagClients,
|
||||
clients: GraphRagClients,
|
||||
config: GraphRagConfig = {},
|
||||
) {
|
||||
this.clients = clients;
|
||||
this.config = {
|
||||
entityLimit: config.entityLimit ?? 50,
|
||||
tripleLimit: config.tripleLimit ?? 30,
|
||||
|
|
@ -125,7 +127,7 @@ export class GraphRag {
|
|||
return (llmResp as TextCompletionResponse).response
|
||||
.split("\n")
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
.filter((c) => c.length > 0);
|
||||
}
|
||||
|
||||
private async getVectors(concepts: string[]): Promise<number[][]> {
|
||||
|
|
@ -166,11 +168,12 @@ export class GraphRag {
|
|||
// Query each entity as subject to get outgoing edges
|
||||
const queries = unvisited.map((entityStr) => {
|
||||
const term = stringToTerm(entityStr);
|
||||
return this.clients.triples.request({
|
||||
const request: TriplesQueryRequest = {
|
||||
s: term,
|
||||
collection,
|
||||
limit: this.config.tripleLimit,
|
||||
});
|
||||
...(collection !== undefined ? { collection } : {}),
|
||||
};
|
||||
return this.clients.triples.request(request);
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
|
@ -257,7 +260,12 @@ export class GraphRag {
|
|||
const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>;
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (item && typeof item.id === "string" && typeof item.score === "number") {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
typeof item.id === "string" &&
|
||||
typeof item.score === "number"
|
||||
) {
|
||||
scored.push({ id: item.id, score: item.score });
|
||||
}
|
||||
}
|
||||
|
|
@ -266,10 +274,15 @@ export class GraphRag {
|
|||
// Fall back to parsing line-by-line JSON objects
|
||||
for (const line of responseText.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.length === 0) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
|
||||
if (obj && typeof obj.id === "string" && typeof obj.score === "number") {
|
||||
if (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
typeof obj.id === "string" &&
|
||||
typeof obj.score === "number"
|
||||
) {
|
||||
scored.push({ id: obj.id, score: obj.score });
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -281,8 +294,6 @@ export class GraphRag {
|
|||
// Sort by score descending and keep top N
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
const topN = scored.slice(0, this.config.edgeLimit);
|
||||
const selectedIds = new Set(topN.map((e) => e.id));
|
||||
|
||||
// Map back to triples
|
||||
const result: Triple[] = [];
|
||||
for (const entry of topN) {
|
||||
|
|
@ -317,7 +328,7 @@ export class GraphRag {
|
|||
variables: { query, context },
|
||||
});
|
||||
|
||||
if (chunkCallback) {
|
||||
if (chunkCallback !== undefined) {
|
||||
// Streaming response
|
||||
let fullText = "";
|
||||
await this.clients.llm.request(
|
||||
|
|
@ -329,11 +340,11 @@ export class GraphRag {
|
|||
{
|
||||
recipient: async (resp) => {
|
||||
const r = resp as TextCompletionResponse;
|
||||
if (r.response) {
|
||||
if (r.response.length > 0) {
|
||||
fullText += r.response;
|
||||
await chunkCallback(r.response, !!r.endOfStream);
|
||||
await chunkCallback(r.response, r.endOfStream === true);
|
||||
}
|
||||
return !!r.endOfStream;
|
||||
return r.endOfStream === true;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
40
ts/packages/flow/src/runtime/effect-files.ts
Normal file
40
ts/packages/flow/src/runtime/effect-files.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export function joinPath(...segments: string[]): string {
|
||||
const joined = segments
|
||||
.filter((segment) => segment.length > 0)
|
||||
.join("/");
|
||||
|
||||
return joined.replace(/\/+/g, "/");
|
||||
}
|
||||
|
||||
export function dirnamePath(path: string): string {
|
||||
const normalized = path.replace(/\/+$/, "");
|
||||
const index = normalized.lastIndexOf("/");
|
||||
|
||||
if (index < 0) return ".";
|
||||
if (index === 0) return "/";
|
||||
return normalized.slice(0, index);
|
||||
}
|
||||
|
||||
export function ensureDirectory(path: string): Promise<void> {
|
||||
return Bun.$`mkdir -p ${path}`.quiet().then(() => undefined);
|
||||
}
|
||||
|
||||
export function readTextFile(path: string): Promise<string> {
|
||||
return Bun.file(path).text();
|
||||
}
|
||||
|
||||
export async function readBinaryFile(path: string): Promise<Uint8Array> {
|
||||
return new Uint8Array(await Bun.file(path).arrayBuffer());
|
||||
}
|
||||
|
||||
export function writeTextFile(path: string, data: string): Promise<void> {
|
||||
return Bun.write(path, data).then(() => undefined);
|
||||
}
|
||||
|
||||
export function writeBinaryFile(path: string, data: Uint8Array): Promise<void> {
|
||||
return Bun.write(path, data).then(() => undefined);
|
||||
}
|
||||
|
||||
export function removePath(path: string): Promise<void> {
|
||||
return Bun.file(path).delete();
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { QdrantGraphEmbeddingsStore } from "./qdrant-graph.js";
|
||||
|
||||
export class GraphEmbeddingsStoreService extends FlowProcessor {
|
||||
|
|
@ -29,7 +30,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
|
|||
this.store = new QdrantGraphEmbeddingsStore();
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<EntityContexts>(
|
||||
ConsumerSpec.fromPromise<EntityContexts>(
|
||||
"store-graph-embeddings-input",
|
||||
this.onMessage.bind(this),
|
||||
),
|
||||
|
|
@ -47,10 +48,10 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
|
|||
|
||||
private async onMessage(
|
||||
msg: EntityContexts,
|
||||
properties: Record<string, string>,
|
||||
_properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (!msg.entities || msg.entities.length === 0) return;
|
||||
if (msg.entities.length === 0) return;
|
||||
|
||||
const embeddingsClient =
|
||||
flowCtx.flow.requestor<EmbeddingsRequest, EmbeddingsResponse>("embeddings-client");
|
||||
|
|
@ -63,7 +64,7 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
|
|||
|
||||
// Call embeddings service
|
||||
const embResponse = await embeddingsClient.request({ text: texts });
|
||||
if (embResponse.error) {
|
||||
if (embResponse.error !== undefined) {
|
||||
console.error(
|
||||
"[GraphEmbeddingsStore] Embeddings error:",
|
||||
embResponse.error.message,
|
||||
|
|
@ -86,6 +87,11 @@ export class GraphEmbeddingsStoreService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "graph-embeddings-store",
|
||||
make: (config) => new GraphEmbeddingsStoreService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await GraphEmbeddingsStoreService.launch("graph-embeddings-store");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface QdrantDocEmbeddingsConfig {
|
||||
url?: string;
|
||||
|
|
@ -36,7 +35,10 @@ export class 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 });
|
||||
this.client = new QdrantClient({
|
||||
url,
|
||||
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
|
||||
});
|
||||
|
||||
console.log("[QdrantDocEmbeddings] Store initialized");
|
||||
}
|
||||
|
|
@ -61,8 +63,8 @@ export class QdrantDocEmbeddingsStore {
|
|||
|
||||
async store(message: DocEmbeddingsMessage): Promise<void> {
|
||||
for (const chunk of message.chunks) {
|
||||
if (!chunk.chunkId || chunk.chunkId === "") continue;
|
||||
if (!chunk.vector || chunk.vector.length === 0) continue;
|
||||
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);
|
||||
|
|
@ -72,11 +74,13 @@ export class QdrantDocEmbeddingsStore {
|
|||
await this.client.upsert(name, {
|
||||
points: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
vector: chunk.vector,
|
||||
payload: {
|
||||
chunk_id: chunk.chunkId,
|
||||
...(chunk.content ? { content: chunk.content } : {}),
|
||||
...(chunk.content !== undefined && chunk.content.length > 0
|
||||
? { content: chunk.content }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
*/
|
||||
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Term } from "@trustgraph/base";
|
||||
|
||||
export interface QdrantGraphEmbeddingsConfig {
|
||||
|
|
@ -50,7 +49,10 @@ export class 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 });
|
||||
this.client = new QdrantClient({
|
||||
url,
|
||||
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
|
||||
});
|
||||
|
||||
console.log("[QdrantGraphEmbeddings] Store initialized");
|
||||
}
|
||||
|
|
@ -76,8 +78,8 @@ export class QdrantGraphEmbeddingsStore {
|
|||
async store(message: GraphEmbeddingsMessage): Promise<void> {
|
||||
for (const entry of message.entities) {
|
||||
const entityValue = getTermValue(entry.entity);
|
||||
if (!entityValue || entityValue === "") continue;
|
||||
if (!entry.vector || entry.vector.length === 0) continue;
|
||||
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);
|
||||
|
|
@ -85,14 +87,14 @@ export class QdrantGraphEmbeddingsStore {
|
|||
await this.ensureCollection(name, dim);
|
||||
|
||||
const payload: Record<string, unknown> = { entity: entityValue };
|
||||
if (entry.chunkId) {
|
||||
if (entry.chunkId !== undefined && entry.chunkId.length > 0) {
|
||||
payload.chunk_id = entry.chunkId;
|
||||
}
|
||||
|
||||
await this.client.upsert(name, {
|
||||
points: [
|
||||
{
|
||||
id: randomUUID(),
|
||||
id: crypto.randomUUID(),
|
||||
vector: entry.vector,
|
||||
payload,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
type FlowContext,
|
||||
type Triples,
|
||||
} from "@trustgraph/base";
|
||||
import { makeProcessorProgram } from "@trustgraph/base";
|
||||
import { FalkorDBTriplesStore } from "./falkordb.js";
|
||||
|
||||
export class TriplesStoreService extends FlowProcessor {
|
||||
|
|
@ -25,7 +26,7 @@ export class TriplesStoreService extends FlowProcessor {
|
|||
this.store = new FalkorDBTriplesStore();
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<Triples>("store-triples-input", this.onMessage.bind(this)),
|
||||
ConsumerSpec.fromPromise<Triples>("store-triples-input", this.onMessage.bind(this)),
|
||||
);
|
||||
|
||||
console.log("[TriplesStore] Service initialized");
|
||||
|
|
@ -33,10 +34,10 @@ export class TriplesStoreService extends FlowProcessor {
|
|||
|
||||
private async onMessage(
|
||||
msg: Triples,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
_properties: Record<string, string>,
|
||||
_flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
if (!msg.triples || msg.triples.length === 0) return;
|
||||
if (msg.triples.length === 0) return;
|
||||
|
||||
const user = msg.metadata?.user ?? "default";
|
||||
const collection = msg.metadata?.collection ?? "default";
|
||||
|
|
@ -49,6 +50,11 @@ export class TriplesStoreService extends FlowProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
export const program = makeProcessorProgram({
|
||||
id: "triples-store",
|
||||
make: (config) => new TriplesStoreService(config),
|
||||
});
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
await TriplesStoreService.launch("triples-store");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue