Enforce strict Effect tsgo migrations

This commit is contained in:
elpresidank 2026-06-01 23:19:54 -05:00
parent 64fb23e7d0
commit f6878d4dd7
49 changed files with 5547 additions and 3250 deletions

View file

@ -13,6 +13,7 @@ Verify these paths at the start of every audit run:
- Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4` - Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4`
- Installed Effect fallback: `ts/node_modules/effect` - Installed Effect fallback: `ts/node_modules/effect`
- Installed Atom React fallback: `ts/packages/workbench/node_modules/@effect/atom-react` - Installed Atom React fallback: `ts/packages/workbench/node_modules/@effect/atom-react`
- Native TypeScript checker: `ts/node_modules/.bin/tsgo`
The prompt typo path `~/YeeBois/projecects/...` is not valid on this machine. The prompt typo path `~/YeeBois/projecects/...` is not valid on this machine.
Use `~/YeeBois/projects/...`. Use `~/YeeBois/projects/...`.
@ -29,6 +30,23 @@ resolves many beta APIs through `effect/unstable/*` import paths. Prefer the
installed TrustGraph import path when it exists; use the subtree package path as installed TrustGraph import path when it exists; use the subtree package path as
source proof, not as an automatic import recommendation. source proof, not as an automatic import recommendation.
TrustGraph's TypeScript check must use `@typescript/native-preview` patched by
`@effect/tsgo`. The expected local version line is currently:
```sh
cd ts && ./node_modules/.bin/tsgo --version
# Version 7.0.0-dev+effect-tsgo.0.13.0
```
The root `ts/tsconfig.base.json` language-service plugin should stay aligned
with `/home/elpresidank/YeeBois/projects/beep-effect/tsconfig.base.json`,
using the same diagnostic severities and no test-only diagnostic downgrades.
The repo-specific `namespaceImportPackages` entry should use `@trustgraph/*`.
The root `check` script should delegate to `check:tsgo`, and `check:tsgo` must
run `effect-tsgo patch` before `tsgo -b` so agents get Effect diagnostics
instead of plain TypeScript. Treat every `effect(...)` diagnostic as a hard
blocker.
## Primitive Map ## Primitive Map
Use this map as the starting baseline. A finding is only valid when it cites the Use this map as the starting baseline. A finding is only valid when it cites the
@ -84,18 +102,25 @@ Known concrete exports useful to scouts:
especially `.idea/effect.intellij.xml`. especially `.idea/effect.intellij.xml`.
2. Refresh the source baseline above. If a path moved, record the corrected path 2. Refresh the source baseline above. If a path moved, record the corrected path
in the report before making any recommendations. in the report before making any recommendations.
3. Run quick signal scans: 3. Verify the tsgo baseline:
```sh
cd ts && ./node_modules/.bin/tsgo --version
bun run check
```
4. Run quick signal scans:
```sh ```sh
rg -n "try \\{|new Error|new Promise|setTimeout|while \\(|receive\\(|Effect\\.runPromise|toPromiseRequestor|makeAsyncProcessor|process\\.env|JSON\\.parse|JSON\\.stringify|localStorage|new Map|WebSocket" ts/packages --glob '*.ts' --glob '*.tsx' rg -n "try \\{|new Error|new Promise|setTimeout|while \\(|receive\\(|Effect\\.runPromise|toPromiseRequestor|makeAsyncProcessor|process\\.env|JSON\\.parse|JSON\\.stringify|localStorage|new Map|WebSocket" ts/packages --glob '*.ts' --glob '*.tsx'
``` ```
4. Split scouts by lane. If the thread cannot spawn every scout in parallel, 5. Split scouts by lane. If the thread cannot spawn every scout in parallel,
run them in batches using the same report schema. run them in batches using the same report schema.
5. Every finding must include both: 6. Every finding must include both:
- Evidence of the handrolled TrustGraph pattern. - Evidence of the handrolled TrustGraph pattern.
- Evidence of the exact Effect primitive that could replace it. - Evidence of the exact Effect primitive that could replace it.
6. Do not rewrite code in this audit. The output is a ranked opportunity map and 7. Do not rewrite code in this audit. The output is a ranked opportunity map and
a recommended PR order. a recommended PR order.
## Agent Lanes ## Agent Lanes
@ -186,7 +211,7 @@ git diff --check -- ts/EFFECT_NATIVE_REWRITE_PLAYBOOK.md ts/EFFECT_NATIVE_REWRIT
For any later implementation PR, rerun the relevant package tests and at least: For any later implementation PR, rerun the relevant package tests and at least:
```sh ```sh
cd ts && bun run check:tsgo cd ts && bun run check
cd ts && bun run build cd ts && bun run build
cd ts && bun run test cd ts && bun run test
``` ```

View file

@ -13,7 +13,7 @@
"@effect/vitest": "4.0.0-beta.75", "@effect/vitest": "4.0.0-beta.75",
"@types/bun": "^1.3.13", "@types/bun": "^1.3.13",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@typescript/native-preview": "^7.0.0-dev.20260511.1", "@typescript/native-preview": "7.0.0-dev.20260511.1",
"falkordb": "^5.0.0", "falkordb": "^5.0.0",
"nats": "^2.29.0", "nats": "^2.29.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",

View file

@ -6,7 +6,8 @@
"dev": "bunx --bun turbo dev", "dev": "bunx --bun turbo dev",
"lint": "bunx --bun turbo lint", "lint": "bunx --bun turbo lint",
"test": "bunx --bun turbo test", "test": "bunx --bun turbo test",
"check:tsgo": "tsgo -b tsconfig.json", "check": "bun run check:tsgo",
"check:tsgo": "effect-tsgo patch && tsgo -b tsconfig.json",
"inventory:classes": "bun scripts/inventory-native-classes.ts", "inventory:classes": "bun scripts/inventory-native-classes.ts",
"workbench:qa": "bun run --cwd packages/workbench qa:browser", "workbench:qa": "bun run --cwd packages/workbench qa:browser",
"prepare": "effect-tsgo patch", "prepare": "effect-tsgo patch",
@ -48,7 +49,7 @@
"@effect/vitest": "4.0.0-beta.75", "@effect/vitest": "4.0.0-beta.75",
"@types/bun": "^1.3.13", "@types/bun": "^1.3.13",
"@types/node": "^25.7.0", "@types/node": "^25.7.0",
"@typescript/native-preview": "^7.0.0-dev.20260511.1", "@typescript/native-preview": "7.0.0-dev.20260511.1",
"falkordb": "^5.0.0", "falkordb": "^5.0.0",
"nats": "^2.29.0", "nats": "^2.29.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",

View file

@ -0,0 +1,113 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
ConfigServiceError,
makeConfigService,
} from "../config/service.js";
import type {
BackendConsumer,
BackendProducer,
ConfigRequest,
CreateConsumerOptions,
CreateProducerOptions,
PubSubBackend,
} from "@trustgraph/base";
class NoopPubSub implements PubSubBackend {
async createProducer<T>(_options: CreateProducerOptions): Promise<BackendProducer<T>> {
return {
send: async () => undefined,
flush: async () => undefined,
close: async () => undefined,
};
}
async createConsumer<T>(_options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
return {
receive: async () => null,
acknowledge: async () => undefined,
negativeAcknowledge: async () => undefined,
unsubscribe: async () => undefined,
close: async () => undefined,
};
}
async close(): Promise<void> {}
}
const makeService = (persistPath?: string) =>
makeConfigService({
id: "config-test",
manageProcessSignals: false,
pubsub: new NoopPubSub(),
...(persistPath === undefined ? {} : { persistPath }),
});
describe("ConfigService operations", () => {
it("uses tagged errors for invalid mutations", async () => {
const service = makeService();
const putError = await service.handlePut({ operation: "put" } as ConfigRequest)
.catch((caught: unknown) => caught);
const deleteError = await service.handleDelete({ operation: "delete" } as ConfigRequest)
.catch((caught: unknown) => caught);
expect(putError).toBeInstanceOf(ConfigServiceError);
expect(putError).toMatchObject({ _tag: "ConfigServiceError", operation: "put" });
expect(deleteError).toBeInstanceOf(ConfigServiceError);
expect(deleteError).toMatchObject({ _tag: "ConfigServiceError", operation: "delete" });
});
it("persists the workspace-aware config shape through Effect.tryPromise", async () => {
const dir = await mkdtemp(join(tmpdir(), "trustgraph-config-service-"));
const persistPath = join(dir, "config.json");
const service = makeService(persistPath);
await service.handlePut({
operation: "put",
values: [
{ workspace: "alpha", type: "prompt", key: "system", value: "hello" },
],
} as ConfigRequest);
const persisted = await Bun.file(persistPath).json();
await rm(dir, { recursive: true, force: true });
expect(persisted).toEqual({
version: 1,
workspaces: {
alpha: {
prompt: {
system: "hello",
},
},
},
});
});
it("loads the legacy persisted data shape without try/catch", async () => {
const dir = await mkdtemp(join(tmpdir(), "trustgraph-config-service-"));
const persistPath = join(dir, "config.json");
await Bun.write(
persistPath,
`{"version":7,"data":{"prompt":{"system":"legacy"}}}`,
);
const service = makeService(persistPath);
await service.loadFromDisk();
const response = service.handleGet({
operation: "get",
keys: ["prompt", "system"],
} as ConfigRequest);
await rm(dir, { recursive: true, force: true });
expect(response).toEqual({
version: 7,
values: {
system: "legacy",
},
});
});
});

View file

@ -75,7 +75,7 @@ const mcpToolError = (
cause: unknown, cause: unknown,
tool?: string, tool?: string,
): McpToolError => ): McpToolError =>
new McpToolError({ McpToolError.make({
operation, operation,
message: errorMessage(cause), message: errorMessage(cause),
...(tool === undefined ? {} : { tool }), ...(tool === undefined ? {} : { tool }),
@ -166,12 +166,9 @@ const invokeConfiguredTool = Effect.fn("McpToolRuntime.invokeTool")(function* (
const result = yield* Effect.acquireUseRelease( const result = yield* Effect.acquireUseRelease(
Effect.tryPromise({ Effect.tryPromise({
try: async () => { try: () => client.connect(transport as unknown as Parameters<Client["connect"]>[0]),
await client.connect(transport as unknown as Parameters<Client["connect"]>[0]);
return client;
},
catch: (cause) => mcpToolError("connect", cause, name), catch: (cause) => mcpToolError("connect", cause, name),
}), }).pipe(Effect.as(client)),
(connectedClient) => (connectedClient) =>
Effect.tryPromise({ Effect.tryPromise({
try: () => try: () =>
@ -318,6 +315,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, McpToolR
layer: () => McpToolRuntimeLive, layer: () => McpToolRuntimeLive,
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -132,79 +132,80 @@ const toPromiseRequestor = <TReq, TRes>(
const buildConfiguredTool = ( const buildConfiguredTool = (
toolId: string, toolId: string,
data: ToolConfigEntry, data: ToolConfigEntry,
): AgentTool | null => { ): Effect.Effect<AgentTool | null> =>
const implType = data.type ?? ""; Effect.gen(function* () {
const name = data.name ?? ""; const implType = data.type ?? "";
const description = data.description ?? ""; const name = data.name ?? "";
const config = { ...data } as Record<string, unknown>; const description = data.description ?? "";
const config = { ...data } as Record<string, unknown>;
if (name.length === 0) { if (name.length === 0) {
console.warn(`[AgentService] Skipping tool with no name: ${toolId}`); yield* Effect.logWarning(`[AgentService] Skipping tool with no name: ${toolId}`);
return null; return null;
}
switch (implType) {
case "knowledge-query":
return {
name,
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,
execute: async () => "",
};
case "document-query":
return {
name,
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config,
execute: async () => "",
};
case "triples-query":
return {
name,
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)" },
{ name: "object", type: "string", description: "Object entity (optional)" },
],
config,
execute: async () => "",
};
case "mcp-tool": {
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
name: arg.name ?? "",
type: arg.type ?? "string",
description: arg.description ?? "",
}));
return {
name,
description,
args,
config,
execute: async () => "",
};
} }
default: switch (implType) {
console.warn(`[AgentService] Unknown tool type "${implType}" for ${name}`); case "knowledge-query":
return null; return {
} name,
}; 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,
execute: () => Promise.resolve(""),
};
case "document-query":
return {
name,
description:
description.length > 0
? description
: "Search documents for relevant information.",
args: [{ name: "question", type: "string", description: "The question to search for" }],
config,
execute: () => Promise.resolve(""),
};
case "triples-query":
return {
name,
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)" },
{ name: "object", type: "string", description: "Object entity (optional)" },
],
config,
execute: () => Promise.resolve(""),
};
case "mcp-tool": {
const args: ToolArg[] = (data.arguments ?? []).map((arg) => ({
name: arg.name ?? "",
type: arg.type ?? "string",
description: arg.description ?? "",
}));
return {
name,
description,
args,
config,
execute: () => Promise.resolve(""),
};
}
default:
yield* Effect.logWarning(`[AgentService] Unknown tool type "${implType}" for ${name}`);
return null;
}
});
const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* ( const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(function* (
config: Record<string, unknown>, config: Record<string, unknown>,
@ -231,7 +232,7 @@ const loadConfiguredTools = Effect.fn("AgentRuntime.loadConfiguredTools")(functi
continue; continue;
} }
const tool = buildConfiguredTool(toolId, decoded.value); const tool = yield* buildConfiguredTool(toolId, decoded.value);
if (tool === null) continue; if (tool === null) continue;
tools.push(tool); tools.push(tool);
@ -348,7 +349,7 @@ const executeTool = (
): Effect.Effect<string> => ): Effect.Effect<string> =>
Effect.tryPromise({ Effect.tryPromise({
try: () => tool.execute(input), try: () => tool.execute(input),
catch: (cause) => new AgentToolExecutionError({ message: errorMessage(cause) }), catch: (cause) => AgentToolExecutionError.make({ message: errorMessage(cause) }),
}).pipe( }).pipe(
Effect.catch((error: AgentToolExecutionError) => Effect.catch((error: AgentToolExecutionError) =>
Effect.succeed(`Error executing tool: ${error.message}`), Effect.succeed(`Error executing tool: ${error.message}`),
@ -473,12 +474,12 @@ const onAgentRequest = Effect.fn("AgentService.onRequest")(function* (
}).pipe( }).pipe(
Effect.catch((error: unknown) => Effect.catch((error: unknown) =>
Effect.logError(`[AgentService] Error processing request ${requestId}`, { Effect.logError(`[AgentService] Error processing request ${requestId}`, {
error: error instanceof Error ? error.message : String(error), error: errorMessage(error),
}).pipe( }).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
responseProducer.send(requestId, { responseProducer.send(requestId, {
chunk_type: "error", chunk_type: "error",
content: `Agent error: ${error instanceof Error ? error.message : String(error)}`, content: `Agent error: ${errorMessage(error)}`,
end_of_message: true, end_of_message: true,
end_of_dialog: true, end_of_dialog: true,
}), }),
@ -538,7 +539,7 @@ export function makeAgentService(config: ProcessorConfig): AgentService {
Effect.provideService(AgentRuntime, runtime), Effect.provideService(AgentRuntime, runtime),
)), )),
); );
console.log("[AgentService] Service initialized"); Effect.runSync(Effect.log("[AgentService] Service initialized"));
return service; return service;
} }
@ -629,6 +630,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, AgentRun
layer: () => AgentRuntimeLive, layer: () => AgentRuntimeLive,
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -18,9 +18,14 @@ import type {
Term, Term,
Triple, Triple,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
import type { AgentTool, ToolArg } from "./types.js"; import type { AgentTool, ToolArg } from "./types.js";
const decodeJsonUnknown = S.decodeUnknownOption(S.UnknownFromJsonString);
/** /**
* Format a Term to a human-readable string. * Format a Term to a human-readable string.
*/ */
@ -41,17 +46,15 @@ function termToString(term: Term): string {
* Parse tool input -- accepts either raw JSON or a plain string question. * Parse tool input -- accepts either raw JSON or a plain string question.
*/ */
function parseQuestion(input: string): string { function parseQuestion(input: string): string {
try { const decoded = decodeJsonUnknown(input);
const parsed = JSON.parse(input) as Record<string, unknown>; if (O.isNone(decoded)) return input;
if (typeof parsed === "object" && parsed !== null && "question" in parsed) {
return String(parsed.question); const parsed = decoded.value;
} if (typeof parsed === "object" && parsed !== null && "question" in parsed) {
// If it's a string JSON value, use it directly return String(parsed.question);
if (typeof parsed === "string") { }
return parsed; if (typeof parsed === "string") {
} return parsed;
} catch {
// Not valid JSON -- treat as plain text
} }
return input; return input;
} }
@ -83,15 +86,15 @@ export function createKnowledgeQueryTool(
description: "The question to ask the knowledge graph", description: "The question to ask the knowledge graph",
}, },
], ],
async execute(input: string): Promise<string> { execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
const question = parseQuestion(input); const question = parseQuestion(input);
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`); yield* Effect.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
const request: GraphRagRequest = { const request: GraphRagRequest = {
query: question, query: question,
...(collection !== undefined ? { collection } : {}), ...(collection !== undefined ? { collection } : {}),
}; };
const res = await client.request(request); const res = yield* Effect.tryPromise(() => client.request(request));
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error !== undefined ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`); yield* Effect.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 // Extract explain data if embedded in the response
const rawRes = res as Record<string, unknown>; const rawRes = res as Record<string, unknown>;
@ -100,15 +103,15 @@ export function createKnowledgeQueryTool(
rawRes.explain_triples !== undefined && rawRes.explain_triples !== undefined &&
onExplain !== undefined onExplain !== undefined
) { ) {
onExplain({ yield* Effect.sync(() => onExplain({
explainId: (rawRes.explain_id as string) ?? "", explainId: (rawRes.explain_id as string) ?? "",
triples: rawRes.explain_triples as Triple[], triples: rawRes.explain_triples as Triple[],
}); }));
} }
if (res.error !== undefined) return `Error: ${res.error.message}`; if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response; return res.response;
}, })),
}; };
} }
@ -130,16 +133,16 @@ export function createDocumentQueryTool(
description: "The question to search documents for", description: "The question to search documents for",
}, },
], ],
async execute(input: string): Promise<string> { execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
const question = parseQuestion(input); const question = parseQuestion(input);
const request: DocumentRagRequest = { const request: DocumentRagRequest = {
query: question, query: question,
...(collection !== undefined ? { collection } : {}), ...(collection !== undefined ? { collection } : {}),
}; };
const res = await client.request(request); const res = yield* Effect.tryPromise(() => client.request(request));
if (res.error !== undefined) return `Error: ${res.error.message}`; if (res.error !== undefined) return `Error: ${res.error.message}`;
return res.response; return res.response;
}, })),
}; };
} }
@ -152,39 +155,42 @@ function parseTriplesInput(input: string): {
o?: Term; o?: Term;
limit?: number; limit?: number;
} { } {
try { const decoded = decodeJsonUnknown(input);
const parsed = JSON.parse(input) as Record<string, unknown>; if (
O.isNone(decoded) ||
const toTerm = (val: unknown): Term | undefined => { typeof decoded.value !== "object" ||
if (typeof val === "string") { decoded.value === null
return { type: "LITERAL", value: val }; ) {
}
if (typeof val === "object" && val !== null && "type" in val) {
return val as Term;
}
return 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 { return {
s: { type: "LITERAL", value: input }, s: { type: "LITERAL", value: input },
}; };
} }
const parsed = decoded.value as Record<string, unknown>;
const toTerm = (val: unknown): Term | undefined => {
if (typeof val === "string") {
return { type: "LITERAL", value: val };
}
if (typeof val === "object" && val !== null && "type" in val) {
return val as Term;
}
return 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;
} }
/** /**
@ -216,7 +222,7 @@ export function createTriplesQueryTool(
description: "The object entity to search for (optional)", description: "The object entity to search for (optional)",
}, },
], ],
async execute(input: string): Promise<string> { execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
const { s, p, o, limit } = parseTriplesInput(input); const { s, p, o, limit } = parseTriplesInput(input);
const request: TriplesQueryRequest = { const request: TriplesQueryRequest = {
limit: limit ?? 20, limit: limit ?? 20,
@ -225,7 +231,7 @@ export function createTriplesQueryTool(
...(o !== undefined ? { o } : {}), ...(o !== undefined ? { o } : {}),
...(collection !== undefined ? { collection } : {}), ...(collection !== undefined ? { collection } : {}),
}; };
const res = await client.request(request); const res = yield* Effect.tryPromise(() => client.request(request));
if (res.error !== undefined) return `Error: ${res.error.message}`; if (res.error !== undefined) return `Error: ${res.error.message}`;
@ -238,7 +244,7 @@ export function createTriplesQueryTool(
`(${termToString(t.s)}) -[${termToString(t.p)}]-> (${termToString(t.o)})`, `(${termToString(t.s)}) -[${termToString(t.p)}]-> (${termToString(t.o)})`,
); );
return lines.join("\n"); return lines.join("\n");
}, })),
}; };
} }
@ -258,12 +264,12 @@ export function createMcpTool(
name: toolName, name: toolName,
description, description,
args, args,
async execute(input: string): Promise<string> { execute: (input: string): Promise<string> => Effect.runPromise(Effect.gen(function* () {
const res = await client.request({ name: toolName, parameters: input }); const res = yield* Effect.tryPromise(() => client.request({ name: toolName, parameters: input }));
if (res.error !== undefined) return `Error: ${res.error.message}`; if (res.error !== undefined) return `Error: ${res.error.message}`;
if (res.text !== undefined) return res.text; if (res.text !== undefined) return res.text;
if (res.object !== undefined) return res.object; if (res.object !== undefined) return res.object;
return "No content"; return "No content";
}, })),
}; };
} }

View file

@ -91,7 +91,7 @@ export function makeChunkingService(config: ProcessorConfig): ChunkingService {
const service = makeFlowProcessor(config, { const service = makeFlowProcessor(config, {
specifications: makeChunkingSpecs(), specifications: makeChunkingSpecs(),
}); });
console.log("[ChunkingService] Service initialized"); Effect.runSync(Effect.log("[ChunkingService] Service initialized"));
return service; return service;
} }
@ -102,6 +102,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makeChunkingSpecs(), specs: () => makeChunkingSpecs(),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -11,7 +11,7 @@
* Python reference: trustgraph-flow/trustgraph/config/service/service.py * Python reference: trustgraph-flow/trustgraph/config/service/service.py
*/ */
import { Effect } from "effect"; import { Duration, Effect } from "effect";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
import { import {
makeAsyncProcessor, makeAsyncProcessor,
@ -45,6 +45,20 @@ const ConfigPushSchema = S.Struct({
config: S.Record(S.String, S.Unknown), config: S.Record(S.String, S.Unknown),
}); });
export class ConfigServiceError extends S.TaggedErrorClass<ConfigServiceError>()(
"ConfigServiceError",
{
message: S.String,
operation: S.String,
},
) {}
const configServiceError = (operation: string, cause: unknown): ConfigServiceError =>
ConfigServiceError.make({
operation,
message: errorMessage(cause),
});
const DEFAULT_WORKSPACE = "default"; const DEFAULT_WORKSPACE = "default";
interface ConfigKeyLike { interface ConfigKeyLike {
@ -62,6 +76,14 @@ interface ConfigValueLike {
type NamespaceStore = Map<string, unknown>; type NamespaceStore = Map<string, unknown>;
type WorkspaceStore = Map<string, NamespaceStore>; type WorkspaceStore = Map<string, NamespaceStore>;
const PersistedConfigSchema = S.Struct({
version: S.optionalKey(S.Number),
data: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Unknown))),
workspaces: S.optionalKey(S.Record(S.String, S.Record(S.String, S.Record(S.String, S.Unknown)))),
});
const PersistedConfigJsonSchema = PersistedConfigSchema.pipe(S.fromJsonString);
type PersistedConfig = typeof PersistedConfigSchema.Type;
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
@ -70,13 +92,63 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined; return typeof value === "string" && value.length > 0 ? value : undefined;
} }
function toPersistedWorkspaces(
store: Map<string, WorkspaceStore>,
): Record<string, Record<string, Record<string, unknown>>> {
const workspaces: Record<string, Record<string, Record<string, unknown>>> = {};
for (const [workspace, ws] of store) {
const workspaceData: Record<string, Record<string, unknown>> = {};
for (const [namespace, subMap] of ws) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
workspaceData[namespace] = obj;
}
workspaces[workspace] = workspaceData;
}
return workspaces;
}
function hydratePersistedConfig(
store: Map<string, WorkspaceStore>,
parsed: PersistedConfig,
): void {
store.clear();
if (parsed.workspaces !== undefined) {
for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) {
const ws = new Map<string, NamespaceStore>();
for (const [namespace, obj] of Object.entries(namespaces)) {
const subMap = new Map<string, unknown>();
for (const [k, v] of Object.entries(obj)) {
subMap.set(k, v);
}
ws.set(namespace, subMap);
}
store.set(workspace, ws);
}
return;
}
const ws = new Map<string, NamespaceStore>();
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
const subMap = new Map<string, unknown>();
for (const [k, v] of Object.entries(obj)) {
subMap.set(k, v);
}
ws.set(namespace, subMap);
}
store.set(DEFAULT_WORKSPACE, ws);
}
export type ConfigService = AsyncProcessorRuntime & Record<string, any>; export type ConfigService = AsyncProcessorRuntime & Record<string, any>;
export function makeConfigService(config: ConfigServiceConfig): ConfigService { export function makeConfigService(config: ConfigServiceConfig): ConfigService {
const service = makeAsyncProcessor(config, { const service = makeAsyncProcessor(config, {
run: async () => { run: () => service.run(),
await service.run();
},
}) as ConfigService; }) as ConfigService;
const baseStop = service.stop; const baseStop = service.stop;
service.store = new Map<string, WorkspaceStore>(); service.store = new Map<string, WorkspaceStore>();
@ -88,115 +160,183 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
Object.assign(service, { Object.assign(service, {
run: async function(this: ConfigService): Promise<void> { run: function(this: ConfigService): Promise<void> {
// Optionally load persisted state const service = this;
if (this.persistPath !== null) { return Effect.runPromise(
await this.loadFromDisk(); Effect.gen(function* () {
} // Optionally load persisted state
if (service.persistPath !== null) {
yield* Effect.tryPromise({
try: () => service.loadFromDisk(),
catch: (cause) => configServiceError("load", cause),
});
}
// Create producers // Create producers
this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({ service.responseProducer = yield* Effect.tryPromise({
topic: topics.configResponse, try: () =>
schema: ConfigResponseSchema, service.pubsub.createProducer<ConfigResponse>({
}); topic: topics.configResponse,
this.pushProducer = await this.pubsub.createProducer<ConfigPush>({ schema: ConfigResponseSchema,
topic: topics.configPush, }),
schema: ConfigPushSchema, catch: (cause) => configServiceError("response-producer", cause),
}); });
service.pushProducer = yield* Effect.tryPromise({
try: () =>
service.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
schema: ConfigPushSchema,
}),
catch: (cause) => configServiceError("push-producer", cause),
});
// Create consumer for config requests // Create consumer for config requests
this.consumer = await this.pubsub.createConsumer<ConfigRequest>({ service.consumer = yield* Effect.tryPromise({
topic: topics.configRequest, try: () =>
subscription: `${this.config.id}-config-request`, service.pubsub.createConsumer<ConfigRequest>({
schema: ConfigRequestSchema, topic: topics.configRequest,
}); subscription: `${service.config.id}-config-request`,
schema: ConfigRequestSchema,
}),
catch: (cause) => configServiceError("consumer", cause),
});
// Push initial config // Push initial config
await this.pushConfig(); yield* Effect.tryPromise({
try: () => service.pushConfig(),
catch: (cause) => configServiceError("push-initial-config", cause),
});
console.log(`[ConfigService] Listening on ${topics.configRequest}`); yield* Effect.log(`[ConfigService] Listening on ${topics.configRequest}`);
// Main consume loop // Main consume loop
while (this.running) { while (service.running) {
try { const shouldContinue = yield* Effect.gen(function* () {
const consumer = this.consumer; const consumer = service.consumer;
if (consumer === null) throw new Error("Config consumer not started"); if (consumer === null) {
return yield* configServiceError("consume", "Config consumer not started");
}
const msg = await consumer.receive(2000); const msg = yield* Effect.tryPromise({
if (msg === null) continue; try: () => consumer.receive(2000),
catch: (cause) => configServiceError("consume-receive", cause),
});
if (msg === null) return true;
await this.handleMessage(msg); yield* Effect.tryPromise({
await consumer.acknowledge(msg); try: () => service.handleMessage(msg),
} catch (err) { catch: (cause) => configServiceError("consume-handle", cause),
if (!this.running) break; });
console.error("[ConfigService] Error in consume loop:", err); yield* Effect.tryPromise({
await sleep(1000); try: () => consumer.acknowledge(msg),
} catch: (cause) => configServiceError("consume-acknowledge", cause),
} });
return true;
}).pipe(
Effect.catch((err) => {
if (!service.running) return Effect.succeed(false);
return Effect.logError("[ConfigService] Error in consume loop", { error: err.message }).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
Effect.as(true),
);
}),
);
if (!shouldContinue) break;
}
}),
);
}, },
handleMessage: async function(this: ConfigService, msg: Message<ConfigRequest>): Promise<void> { handleMessage: function(this: ConfigService, msg: Message<ConfigRequest>): Promise<void> {
const request = await Effect.runPromise(S.decodeUnknownEffect(ConfigRequestSchema)(msg.value())); const service = this;
const props = msg.properties(); return Effect.runPromise(
const requestId = props.id; Effect.gen(function* () {
const request = yield* S.decodeUnknownEffect(ConfigRequestSchema)(msg.value()).pipe(
Effect.mapError((cause) => configServiceError("decode", cause)),
);
const props = msg.properties();
const requestId = props.id;
if (requestId === undefined || requestId.length === 0) { if (requestId === undefined || requestId.length === 0) {
console.warn("[ConfigService] Received request without id, ignoring"); yield* Effect.logWarning("[ConfigService] Received request without id, ignoring");
return; return;
} }
try { const sendResponse = (response: ConfigResponse): Effect.Effect<void, ConfigServiceError> =>
const response = await this.handleOperation(request); Effect.gen(function* () {
const responseProducer = this.responseProducer; const responseProducer = service.responseProducer;
if (responseProducer === null) throw new Error("Config response producer not started"); if (responseProducer === null) {
await responseProducer.send(response, { id: requestId }); return yield* configServiceError("respond", "Config response producer not started");
} catch (err) { }
const message = errorMessage(err); yield* Effect.tryPromise({
const responseProducer = this.responseProducer; try: () => responseProducer.send(response, { id: requestId }),
if (responseProducer === null) throw new Error("Config response producer not started"); catch: (cause) => configServiceError("respond", cause),
await responseProducer.send( });
{ });
error: { type: "config-error", message },
}, yield* Effect.gen(function* () {
{ id: requestId }, const response = yield* Effect.tryPromise<ConfigResponse, ConfigServiceError>({
); try: () => service.handleOperation(request),
} catch: (cause) => configServiceError("operation", cause),
});
yield* sendResponse(response);
}).pipe(
Effect.catch((err) =>
sendResponse({
error: { type: "config-error", message: err.message },
}),
),
);
}),
);
}, },
handleOperation: async function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> { handleOperation: function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> {
const op: ConfigOperation = request.operation; const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const op: ConfigOperation = request.operation;
switch (op) { switch (op) {
case "get": case "get":
return this.handleGet(request); return service.handleGet(request);
case "put": case "put":
return await this.handlePut(request); return yield* Effect.tryPromise<ConfigResponse, ConfigServiceError>({
try: () => service.handlePut(request),
catch: (cause) => configServiceError("put", cause),
});
case "delete": case "delete":
return await this.handleDelete(request); return yield* Effect.tryPromise<ConfigResponse, ConfigServiceError>({
try: () => service.handleDelete(request),
catch: (cause) => configServiceError("delete", cause),
});
case "list": case "list":
return this.handleList(request); return service.handleList(request);
case "config": case "config":
return this.handleConfigDump(request); return service.handleConfigDump(request);
case "getvalues": case "getvalues":
return this.handleGetValues(request); return service.handleGetValues(request);
case "getvalues-all-ws": case "getvalues-all-ws":
return this.handleGetValuesAllWorkspaces(request); return service.handleGetValuesAllWorkspaces(request);
default: default:
throw new Error(`Unknown config operation: ${op as string}`); return yield* configServiceError("operation", `Unknown config operation: ${op as string}`);
} }
}),
);
}, },
@ -364,76 +504,104 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
handlePut: async function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> { handlePut: function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> {
const values = this.configValues(request); const service = this;
if (values.length === 0) throw new Error("Put requires config values"); return Effect.runPromise(
Effect.gen(function* () {
const values = service.configValues(request);
if (values.length === 0) return yield* configServiceError("put", "Put requires config values");
for (const item of values) { for (const item of values) {
this.namespaceStore(item.workspace ?? DEFAULT_WORKSPACE, item.type, true)?.set(item.key, item.value); service.namespaceStore(item.workspace ?? DEFAULT_WORKSPACE, item.type, true)?.set(item.key, item.value);
} }
this.version++; service.version++;
await this.persist(); yield* Effect.tryPromise({
await this.pushConfig(); try: () => service.persist(),
catch: (cause) => configServiceError("persist", cause),
});
yield* Effect.tryPromise({
try: () => service.pushConfig(),
catch: (cause) => configServiceError("push-config", cause),
});
return { version: this.version }; return { version: service.version };
}),
);
}, },
handleDelete: async function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> { handleDelete: function(this: ConfigService, request: ConfigRequest): Promise<ConfigResponse> {
const workspace = this.workspaceFor(request); const service = this;
const objectKeys = this.objectKeys(request); return Effect.runPromise(
if (objectKeys.length > 0) { Effect.gen(function* () {
for (const key of objectKeys) { const workspace = service.workspaceFor(request);
const ws = this.workspaceStore(workspace, false); const objectKeys = service.objectKeys(request);
if (ws === undefined) continue; if (objectKeys.length > 0) {
if (key.key === undefined) { for (const key of objectKeys) {
ws.delete(key.type); const ws = service.workspaceStore(workspace, false);
} else { if (ws === undefined) continue;
const ns = ws.get(key.type); if (key.key === undefined) {
ns?.delete(key.key); ws.delete(key.type);
if (ns !== undefined && ns.size === 0) ws.delete(key.type); } else {
const ns = ws.get(key.type);
ns?.delete(key.key);
if (ns !== undefined && ns.size === 0) ws.delete(key.type);
}
}
service.version++;
yield* Effect.tryPromise({
try: () => service.persist(),
catch: (cause) => configServiceError("persist", cause),
});
yield* Effect.tryPromise({
try: () => service.pushConfig(),
catch: (cause) => configServiceError("push-config", cause),
});
return { version: service.version };
} }
}
this.version++; const keys = service.stringKeys(request);
await this.persist(); if (keys.length === 0) {
await this.pushConfig(); return yield* configServiceError("delete", "Delete requires at least one key");
return { version: this.version };
}
const keys = this.stringKeys(request);
if (keys.length === 0) {
throw new Error("Delete requires at least one key");
}
const namespace = keys[0];
const ws = this.workspaceStore(workspace, false);
if (ws === undefined) return { version: this.version };
if (keys.length === 1) {
// Delete entire namespace
ws.delete(namespace);
} else {
// Delete specific keys within namespace
const subMap = ws.get(namespace);
if (subMap !== undefined) {
for (let i = 1; i < keys.length; i++) {
subMap.delete(keys[i]);
} }
if (subMap.size === 0) {
const namespace = keys[0];
const ws = service.workspaceStore(workspace, false);
if (ws === undefined) return { version: service.version };
if (keys.length === 1) {
// Delete entire namespace
ws.delete(namespace); ws.delete(namespace);
} else {
// Delete specific keys within namespace
const subMap = ws.get(namespace);
if (subMap !== undefined) {
for (let i = 1; i < keys.length; i++) {
subMap.delete(keys[i]);
}
if (subMap.size === 0) {
ws.delete(namespace);
}
}
} }
}
}
this.version++; service.version++;
await this.persist(); yield* Effect.tryPromise({
await this.pushConfig(); try: () => service.persist(),
catch: (cause) => configServiceError("persist", cause),
});
yield* Effect.tryPromise({
try: () => service.pushConfig(),
catch: (cause) => configServiceError("push-config", cause),
});
return { version: this.version }; return { version: service.version };
}),
);
}, },
@ -528,130 +696,140 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
pushConfig: async function(this: ConfigService): Promise<void> { pushConfig: function(this: ConfigService): Promise<void> {
const pushProducer = this.pushProducer; const service = this;
if (pushProducer === null) return; return Effect.runPromise(
Effect.gen(function* () {
const pushProducer = service.pushProducer;
if (pushProducer === null) return;
const config: Record<string, unknown> = {}; const config: Record<string, unknown> = {};
const ws = this.workspaceStore(DEFAULT_WORKSPACE, false); const ws = service.workspaceStore(DEFAULT_WORKSPACE, false);
for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) { for (const [namespace, subMap] of ws ?? new Map<string, NamespaceStore>()) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
config[namespace] = obj;
}
await pushProducer.send({
version: this.version,
config,
});
console.log(`[ConfigService] Pushed configuration version ${this.version}`);
},
persist: async function(this: ConfigService): Promise<void> {
const persistPath = this.persistPath;
if (persistPath === null) return;
try {
const workspaces: Record<string, Record<string, Record<string, unknown>>> = {};
for (const [workspace, ws] of this.store) {
const workspaceData: Record<string, Record<string, unknown>> = {};
for (const [namespace, subMap] of ws) {
const obj: Record<string, unknown> = {}; const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) { for (const [k, v] of subMap) {
obj[k] = v; obj[k] = v;
} }
workspaceData[namespace] = obj; config[namespace] = obj;
} }
workspaces[workspace] = workspaceData;
}
const json = JSON.stringify( yield* Effect.tryPromise({
{ version: this.version, workspaces }, try: () =>
null, pushProducer.send({
2, version: service.version,
); config,
}),
catch: (cause) => configServiceError("push-config", cause),
});
await writeTextFile(persistPath, json); yield* Effect.log(`[ConfigService] Pushed configuration version ${service.version}`);
} catch (err) { }),
await Effect.runPromise(Effect.logError("[ConfigService] Failed to persist config", { error: errorMessage(err) })); );
}
}, },
loadFromDisk: async function(this: ConfigService): Promise<void> { persist: function(this: ConfigService): Promise<void> {
const persistPath = this.persistPath; const service = this;
if (persistPath === null) return; return Effect.runPromise(
Effect.gen(function* () {
const persistPath = service.persistPath;
if (persistPath === null) return;
const payload = {
version: service.version,
workspaces: toPersistedWorkspaces(service.store),
};
try { const json = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(payload).pipe(
const raw = await readTextFile(persistPath); Effect.mapError((cause) => configServiceError("persist-encode", cause)),
const parsed = JSON.parse(raw) as { );
version: number;
data?: Record<string, Record<string, unknown>>;
workspaces?: Record<string, Record<string, Record<string, unknown>>>;
};
this.version = parsed.version ?? 0; yield* Effect.tryPromise({
this.store.clear(); try: () => writeTextFile(persistPath, json),
catch: (cause) => configServiceError("persist-write", cause),
if (parsed.workspaces !== undefined) { });
for (const [workspace, namespaces] of Object.entries(parsed.workspaces)) { }).pipe(
const ws = new Map<string, NamespaceStore>(); Effect.catch((err) =>
for (const [namespace, obj] of Object.entries(namespaces)) { Effect.logError("[ConfigService] Failed to persist config", { error: err.message }),
const subMap = new Map<string, unknown>(); ),
for (const [k, v] of Object.entries(obj)) { ),
subMap.set(k, v); );
}
ws.set(namespace, subMap);
}
this.store.set(workspace, ws);
}
} else {
const ws = new Map<string, NamespaceStore>();
for (const [namespace, obj] of Object.entries(parsed.data ?? {})) {
const subMap = new Map<string, unknown>();
for (const [k, v] of Object.entries(obj)) {
subMap.set(k, v);
}
ws.set(namespace, subMap);
}
this.store.set(DEFAULT_WORKSPACE, ws);
}
console.log(
`[ConfigService] Loaded persisted config (version=${this.version}, workspaces=${this.store.size})`,
);
} catch {
// File doesn't exist yet or is invalid — start fresh
await Effect.runPromise(Effect.log("[ConfigService] No persisted config found, starting fresh"));
}
}, },
stop: async function(this: ConfigService): Promise<void> { loadFromDisk: function(this: ConfigService): Promise<void> {
if (this.consumer !== null) { const service = this;
await this.consumer.close(); return Effect.runPromise(
this.consumer = null; Effect.gen(function* () {
} const persistPath = service.persistPath;
if (this.responseProducer !== null) { if (persistPath === null) return;
await this.responseProducer.close();
this.responseProducer = null; const parsed = yield* Effect.gen(function* () {
} const raw = yield* Effect.tryPromise({
if (this.pushProducer !== null) { try: () => readTextFile(persistPath),
await this.pushProducer.close(); catch: (cause) => configServiceError("persist-read", cause),
this.pushProducer = null; });
} return yield* S.decodeUnknownEffect(PersistedConfigJsonSchema)(raw).pipe(
await baseStop(); Effect.mapError((cause) => configServiceError("persist-decode", cause)),
);
}).pipe(
Effect.catch(() =>
Effect.log("[ConfigService] No persisted config found, starting fresh").pipe(
Effect.as(null as PersistedConfig | null),
),
),
);
if (parsed === null) return;
service.version = parsed.version ?? 0;
hydratePersistedConfig(service.store, parsed);
yield* Effect.log(`[ConfigService] Loaded persisted config (version=${service.version}, workspaces=${service.store.size})`);
}),
);
},
stop: function(this: ConfigService): Promise<void> {
const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const consumer = service.consumer;
if (consumer !== null) {
yield* Effect.tryPromise({
try: () => consumer.close(),
catch: (cause) => configServiceError("close-consumer", cause),
});
service.consumer = null;
}
const responseProducer = service.responseProducer;
if (responseProducer !== null) {
yield* Effect.tryPromise({
try: () => responseProducer.close(),
catch: (cause) => configServiceError("close-response-producer", cause),
});
service.responseProducer = null;
}
const pushProducer = service.pushProducer;
if (pushProducer !== null) {
yield* Effect.tryPromise({
try: () => pushProducer.close(),
catch: (cause) => configServiceError("close-push-producer", cause),
});
service.pushProducer = null;
}
yield* Effect.tryPromise({
try: () => baseStop(),
catch: (cause) => configServiceError("stop", cause),
});
}),
);
} }
}); });
@ -660,10 +838,6 @@ export function makeConfigService(config: ConfigServiceConfig): ConfigService {
export const ConfigService = makeConfigService; export const ConfigService = makeConfigService;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const loadConfigServiceRuntimeConfig = Effect.fn("loadConfigServiceRuntimeConfig")(function* () { export const loadConfigServiceRuntimeConfig = Effect.fn("loadConfigServiceRuntimeConfig")(function* () {
const processorConfig = yield* loadProcessorRuntimeConfig("config-svc", { const processorConfig = yield* loadProcessorRuntimeConfig("config-svc", {
manageProcessSignals: false, manageProcessSignals: false,
@ -681,6 +855,6 @@ export const program = makeProcessorProgram({
make: (config) => makeConfigService(config), make: (config) => makeConfigService(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -12,6 +12,7 @@
import { import {
makeAsyncProcessor, makeAsyncProcessor,
makeProcessorProgram,
type ProcessorConfig, type ProcessorConfig,
type AsyncProcessorRuntime, type AsyncProcessorRuntime,
topics, topics,
@ -19,10 +20,11 @@ import {
type KnowledgeResponse, type KnowledgeResponse,
type Triple, type Triple,
type Term, type Term,
errorMessage,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { makeProcessorProgram } from "@trustgraph/base";
import type { Message } from "@trustgraph/base"; import type { Message } from "@trustgraph/base";
import { Effect } from "effect"; import { Config, Duration, Effect } from "effect";
import * as S from "effect/Schema";
import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js"; import { ensureDirectory, joinPath, readTextFile, writeTextFile } from "../runtime/effect-files.js";
export interface KnowledgeCoreServiceConfig extends ProcessorConfig { export interface KnowledgeCoreServiceConfig extends ProcessorConfig {
@ -42,18 +44,88 @@ interface DocumentEmbeddingsCore {
export type KnowledgeCoreService = AsyncProcessorRuntime & Record<string, any>; export type KnowledgeCoreService = AsyncProcessorRuntime & Record<string, any>;
export class KnowledgeCoreServiceError extends S.TaggedErrorClass<KnowledgeCoreServiceError>()(
"KnowledgeCoreServiceError",
{
message: S.String,
operation: S.String,
},
) {}
interface KnowledgeResponseProducer {
send(response: KnowledgeResponse, properties: { id: string }): Promise<void>;
close(): Promise<void>;
}
interface CloseableResource {
close(): Promise<void>;
}
const knowledgeCoreServiceError = (operation: string, cause: unknown): KnowledgeCoreServiceError =>
KnowledgeCoreServiceError.make({
operation,
message: errorMessage(cause),
});
const tryPromise = <A>(
operation: string,
evaluate: () => Promise<A>,
): Effect.Effect<A, KnowledgeCoreServiceError> =>
Effect.tryPromise({
try: evaluate,
catch: (cause) => knowledgeCoreServiceError(operation, cause),
});
const trySync = <A>(
operation: string,
evaluate: () => A,
): Effect.Effect<A, KnowledgeCoreServiceError> =>
Effect.try({
try: evaluate,
catch: (cause) => knowledgeCoreServiceError(operation, cause),
});
const failPromise = (operation: string, cause: unknown): Promise<never> =>
Effect.runPromise(Effect.fail(knowledgeCoreServiceError(operation, cause)));
const sendResponse = (
service: KnowledgeCoreService,
response: KnowledgeResponse,
requestId: string,
operation = "respond",
): Effect.Effect<void, KnowledgeCoreServiceError> =>
Effect.gen(function* () {
const responseProducer = service.responseProducer as KnowledgeResponseProducer | null | undefined;
if (responseProducer === null || responseProducer === undefined) {
return yield* knowledgeCoreServiceError(operation, "Knowledge response producer not started");
}
yield* tryPromise(operation, () => responseProducer.send(response, { id: requestId }));
});
const closeResource = (
resource: CloseableResource,
operation: string,
): Effect.Effect<void> =>
tryPromise(operation, () => resource.close()).pipe(
Effect.catch((error) =>
Effect.logError("[KnowledgeCoreService] Failed to close resource", {
error: error.message,
operation: error.operation,
}),
),
);
export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): KnowledgeCoreService { export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): KnowledgeCoreService {
const service = makeAsyncProcessor(config, { const service = makeAsyncProcessor(config, {
run: async () => { run: () => service.run(),
await service.run();
},
}) as KnowledgeCoreService; }) as KnowledgeCoreService;
const baseStop = service.stop; const baseStop = service.stop;
service.cores = new Map<string, KnowledgeCore>(); service.cores = new Map<string, KnowledgeCore>();
service.deCores = new Map<string, DocumentEmbeddingsCore[]>(); service.deCores = new Map<string, DocumentEmbeddingsCore[]>();
service.consumer = null; service.consumer = null;
service.responseProducer = null; service.responseProducer = null;
const dataDir = config.dataDir ?? process.env.KNOWLEDGE_DATA_DIR ?? "./data/knowledge"; const dataDir = config.dataDir ?? "./data/knowledge";
service.dataDir = dataDir; service.dataDir = dataDir;
service.persistPath = joinPath(dataDir, "knowledge-state.json"); service.persistPath = joinPath(dataDir, "knowledge-state.json");
Object.assign(service, { Object.assign(service, {
@ -66,68 +138,106 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
run: async function(this: KnowledgeCoreService): Promise<void> { run: function(this: KnowledgeCoreService): Promise<void> {
await ensureDirectory(this.dataDir); const service = this;
// Load persisted state return Effect.runPromise(
await this.loadFromDisk(); Effect.gen(function* () {
if (config.dataDir === undefined) {
const configuredDataDir = yield* Config.string("KNOWLEDGE_DATA_DIR").pipe(
Config.withDefault("./data/knowledge"),
);
service.dataDir = configuredDataDir;
service.persistPath = joinPath(configuredDataDir, "knowledge-state.json");
}
// Create producer yield* tryPromise("ensure-directory", () => ensureDirectory(service.dataDir));
this.responseProducer = await this.pubsub.createProducer<KnowledgeResponse>({ // Load persisted state
topic: topics.knowledgeResponse, yield* tryPromise("load", () => service.loadFromDisk());
});
// Create consumer // Create producer
this.consumer = await this.pubsub.createConsumer<KnowledgeRequest>({ service.responseProducer = yield* tryPromise("response-producer", () =>
topic: topics.knowledgeRequest, service.pubsub.createProducer<KnowledgeResponse>({
subscription: `${this.config.id}-knowledge-request`, topic: topics.knowledgeResponse,
}); }),
);
console.log(`[KnowledgeCoreService] Listening on ${topics.knowledgeRequest}`); // Create consumer
service.consumer = yield* tryPromise("consumer", () =>
service.pubsub.createConsumer<KnowledgeRequest>({
topic: topics.knowledgeRequest,
subscription: `${service.config.id}-knowledge-request`,
}),
);
// Main consume loop yield* Effect.log(`[KnowledgeCoreService] Listening on ${topics.knowledgeRequest}`);
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (msg === null) continue;
await this.handleMessage(msg); // Main consume loop
await this.consumer.acknowledge(msg); while (service.running) {
} catch (err) { const shouldContinue = yield* Effect.gen(function* () {
if (!this.running) break; const consumer = service.consumer;
console.error("[KnowledgeCoreService] Error in consume loop:", err); if (consumer === null || consumer === undefined) {
await sleep(1000); return yield* knowledgeCoreServiceError("consume", "Knowledge request consumer not started");
} }
}
const msg = yield* tryPromise("consume-receive", () => consumer.receive(2000));
if (msg === null) return true;
yield* tryPromise("consume-handle", () => service.handleMessage(msg));
yield* tryPromise("consume-acknowledge", () => consumer.acknowledge(msg));
return true;
}).pipe(
Effect.catch((error) => {
if (!service.running) return Effect.succeed(false);
return Effect.logError("[KnowledgeCoreService] Error in consume loop", {
error: error.message,
}).pipe(
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
Effect.as(true),
);
}),
);
if (!shouldContinue) break;
}
}),
);
}, },
handleMessage: async function(this: KnowledgeCoreService, msg: Message<KnowledgeRequest>): Promise<void> { handleMessage: function(this: KnowledgeCoreService, msg: Message<KnowledgeRequest>): Promise<void> {
const request = msg.value(); const service = this;
const props = msg.properties(); return Effect.runPromise(
const requestId = props.id; Effect.gen(function* () {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (requestId === undefined || requestId.length === 0) { if (requestId === undefined || requestId.length === 0) {
console.warn("[KnowledgeCoreService] Received request without id, ignoring"); yield* Effect.logWarning("[KnowledgeCoreService] Received request without id, ignoring");
return; return;
} }
try { yield* tryPromise("operation", () => service.handleOperation(request, requestId)).pipe(
await this.handleOperation(request, requestId); Effect.catch((error) =>
} catch (err) { sendResponse(
const message = err instanceof Error ? err.message : String(err); service,
await this.responseProducer!.send( { error: { type: "knowledge-error", message: error.message } },
{ error: { type: "knowledge-error", message } }, requestId,
{ id: requestId }, "respond-error",
); ),
} ),
);
}),
);
}, },
handleOperation: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { handleOperation: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
switch (request.operation) { switch (request.operation) {
case "list-kg-cores": case "list-kg-cores":
return this.listKgCores(request, requestId); return this.listKgCores(request, requestId);
@ -152,7 +262,7 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
case "load-de-core": case "load-de-core":
return this.loadDeCore(request, requestId); return this.loadDeCore(request, requestId);
default: default:
throw new Error(`Unknown knowledge operation: ${request.operation as string}`); return failPromise("operation", `Unknown knowledge operation: ${request.operation as string}`);
} }
}, },
@ -185,231 +295,297 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
listKgCores: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { listKgCores: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const prefix = user.length > 0 ? `${user}:` : ""; return Effect.runPromise(
Effect.gen(function* () {
const user = request.user ?? "";
const prefix = user.length > 0 ? `${user}:` : "";
const ids: string[] = []; const ids: string[] = [];
for (const key of (this.cores as Map<string, KnowledgeCore>).keys()) { for (const key of (service.cores as Map<string, KnowledgeCore>).keys()) {
if (prefix.length === 0 || key.startsWith(prefix)) { if (prefix.length === 0 || key.startsWith(prefix)) {
// Extract the ID portion after the user prefix // Extract the ID portion after the user prefix
const id = key.slice(prefix.length); const id = key.slice(prefix.length);
ids.push(id); ids.push(id);
} }
} }
await this.responseProducer!.send({ ids }, { id: requestId }); yield* sendResponse(service, { ids }, requestId);
}),
},
getKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
const core = this.cores.get(key);
if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`);
}
// Send triples and embeddings in batches
const BATCH_SIZE = 100;
// Send triples in batches
for (let i = 0; i < core.triples.length; i += BATCH_SIZE) {
const batch = core.triples.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.triples.length && core.graphEmbeddings.length === 0;
await this.responseProducer!.send(
{ triples: batch, eos: isLast },
{ id: requestId },
);
}
// Send graph embeddings in batches
for (let i = 0; i < core.graphEmbeddings.length; i += BATCH_SIZE) {
const batch = core.graphEmbeddings.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
await this.responseProducer!.send(
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
{ id: requestId },
);
}
// If core was empty, send a final eos
if (core.triples.length === 0 && core.graphEmbeddings.length === 0) {
await this.responseProducer!.send({ eos: true }, { id: requestId });
}
},
deleteKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
this.cores.delete(key);
await this.persist();
console.log(`[KnowledgeCoreService] Deleted core: ${key}`);
await this.responseProducer!.send({}, { id: requestId });
},
putKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = this.coreKey(user, coreId);
let core = this.cores.get(key);
if (core === undefined) {
core = { triples: [], graphEmbeddings: [] };
this.cores.set(key, core);
}
// Append triples if provided
if (request.triples !== undefined && request.triples.length > 0) {
core.triples.push(...request.triples);
}
// Append graph embeddings if provided
const graphEmbeddings = this.graphEmbeddings(request);
if (graphEmbeddings.length > 0) {
core.graphEmbeddings.push(...graphEmbeddings);
}
await this.persist();
console.log(
`[KnowledgeCoreService] Updated core ${key}: triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}`,
); );
await this.responseProducer!.send({}, { id: requestId });
}, },
loadKgCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { getKgCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const coreId = request.id ?? ""; return Effect.runPromise(
const key = this.coreKey(user, coreId); Effect.gen(function* () {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
const core = this.cores.get(key); const core = service.cores.get(key);
if (core === undefined) { if (core === undefined) {
throw new Error(`Knowledge core not found: ${key}`); return yield* knowledgeCoreServiceError("get-kg-core", `Knowledge core not found: ${key}`);
} }
if (core.triples.length > 0) { // Send triples and embeddings in batches
const producer = await this.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" }); const BATCH_SIZE = 100;
try {
await producer.send({
metadata: {
id: coreId,
root: coreId,
user,
collection: request.collection ?? "default",
},
triples: core.triples,
});
} finally {
await producer.close();
}
}
console.log( // Send triples in batches
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`, for (let i = 0; i < core.triples.length; i += BATCH_SIZE) {
const batch = core.triples.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.triples.length && core.graphEmbeddings.length === 0;
yield* sendResponse(
service,
{ triples: batch, eos: isLast },
requestId,
"respond-kg-triples",
);
}
// Send graph embeddings in batches
for (let i = 0; i < core.graphEmbeddings.length; i += BATCH_SIZE) {
const batch = core.graphEmbeddings.slice(i, i + BATCH_SIZE);
const isLast = i + BATCH_SIZE >= core.graphEmbeddings.length;
yield* sendResponse(
service,
{ graphEmbeddings: batch, "graph-embeddings": batch, eos: isLast } as KnowledgeResponse,
requestId,
"respond-kg-embeddings",
);
}
// If core was empty, send a final eos
if (core.triples.length === 0 && core.graphEmbeddings.length === 0) {
yield* sendResponse(service, { eos: true }, requestId, "respond-kg-empty");
}
}),
); );
await this.responseProducer!.send({}, { id: requestId });
}, },
unloadKgCore: async function(this: KnowledgeCoreService, _request: KnowledgeRequest, requestId: string): Promise<void> { deleteKgCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
await this.responseProducer!.send({}, { id: requestId }); const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
service.cores.delete(key);
yield* tryPromise("persist-delete-kg-core", () => service.persist());
yield* Effect.log(`[KnowledgeCoreService] Deleted core: ${key}`);
yield* sendResponse(service, {}, requestId);
}),
);
}, },
listDeCores: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { putKgCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const prefix = user.length > 0 ? `${user}:` : ""; return Effect.runPromise(
const ids = [...this.deCores.keys()] Effect.gen(function* () {
.filter((key) => prefix.length === 0 || key.startsWith(prefix)) const user = request.user ?? "";
.map((key) => key.slice(prefix.length)); const coreId = request.id ?? "";
await this.responseProducer!.send({ ids }, { id: requestId }); const key = service.coreKey(user, coreId);
let core = service.cores.get(key);
if (core === undefined) {
core = { triples: [], graphEmbeddings: [] };
service.cores.set(key, core);
}
// Append triples if provided
if (request.triples !== undefined && request.triples.length > 0) {
core.triples.push(...request.triples);
}
// Append graph embeddings if provided
const graphEmbeddings = service.graphEmbeddings(request);
if (graphEmbeddings.length > 0) {
core.graphEmbeddings.push(...graphEmbeddings);
}
yield* tryPromise("persist-put-kg-core", () => service.persist());
yield* Effect.log(
`[KnowledgeCoreService] Updated core ${key}: triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length}`,
);
yield* sendResponse(service, {}, requestId);
}),
);
}, },
getDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { loadKgCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const coreId = request.id ?? ""; return Effect.runPromise(
const key = this.coreKey(user, coreId); Effect.gen(function* () {
const core = this.deCores.get(key); const user = request.user ?? "";
if (core === undefined) throw new Error(`Document embeddings core not found: ${key}`); const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
for (let i = 0; i < core.length; i++) { const core = service.cores.get(key);
const isLast = i === core.length - 1; if (core === undefined) {
await this.responseProducer!.send( return yield* knowledgeCoreServiceError("load-kg-core", `Knowledge core not found: ${key}`);
{ }
documentEmbeddings: core[i],
"document-embeddings": core[i], if (core.triples.length > 0) {
eos: isLast, yield* Effect.acquireUseRelease(
} as KnowledgeResponse, tryPromise("triples-producer", () =>
{ id: requestId }, service.pubsub.createProducer<unknown>({ topic: "tg.flow.triples" }),
); ),
} (producer) =>
if (core.length === 0) { tryPromise("send-triples", () =>
await this.responseProducer!.send({ eos: true }, { id: requestId }); producer.send({
} metadata: {
id: coreId,
root: coreId,
user,
collection: request.collection ?? "default",
},
triples: core.triples,
}),
),
(producer) => closeResource(producer, "close-triples-producer"),
);
}
yield* Effect.log(
`[KnowledgeCoreService] Loaded core ${key} (triples=${core.triples.length}, embeddings=${core.graphEmbeddings.length})`,
);
yield* sendResponse(service, {}, requestId);
}),
);
}, },
deleteDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { unloadKgCore: function(this: KnowledgeCoreService, _request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; return Effect.runPromise(sendResponse(this, {}, requestId));
const coreId = request.id ?? "";
this.deCores.delete(this.coreKey(user, coreId));
await this.persist();
await this.responseProducer!.send({}, { id: requestId });
}, },
putDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { listDeCores: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const coreId = request.id ?? ""; return Effect.runPromise(
const key = this.coreKey(user, coreId); Effect.gen(function* () {
const item = this.documentEmbeddings(request); const user = request.user ?? "";
if (item === undefined) throw new Error("put-de-core requires document-embeddings"); const prefix = user.length > 0 ? `${user}:` : "";
const core = this.deCores.get(key) ?? []; const ids = [...service.deCores.keys()]
core.push(item); .filter((key) => prefix.length === 0 || key.startsWith(prefix))
this.deCores.set(key, core); .map((key) => key.slice(prefix.length));
await this.persist(); yield* sendResponse(service, { ids }, requestId);
await this.responseProducer!.send({}, { id: requestId }); }),
);
}, },
loadDeCore: async function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> { getDeCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const user = request.user ?? ""; const service = this;
const coreId = request.id ?? ""; return Effect.runPromise(
const key = this.coreKey(user, coreId); Effect.gen(function* () {
if (!(this.deCores as Map<string, DocumentEmbeddingsCore[]>).has(key)) throw new Error(`Document embeddings core not found: ${key}`); const user = request.user ?? "";
await this.responseProducer!.send({}, { id: requestId }); const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
const core = service.deCores.get(key);
if (core === undefined) {
return yield* knowledgeCoreServiceError("get-de-core", `Document embeddings core not found: ${key}`);
}
for (let i = 0; i < core.length; i++) {
const isLast = i === core.length - 1;
yield* sendResponse(
service,
{
documentEmbeddings: core[i],
"document-embeddings": core[i],
eos: isLast,
} as KnowledgeResponse,
requestId,
"respond-de-core",
);
}
if (core.length === 0) {
yield* sendResponse(service, { eos: true }, requestId, "respond-de-empty");
}
}),
);
},
deleteDeCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const user = request.user ?? "";
const coreId = request.id ?? "";
service.deCores.delete(service.coreKey(user, coreId));
yield* tryPromise("persist-delete-de-core", () => service.persist());
yield* sendResponse(service, {}, requestId);
}),
);
},
putDeCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
const item = service.documentEmbeddings(request);
if (item === undefined) {
return yield* knowledgeCoreServiceError("put-de-core", "put-de-core requires document-embeddings");
}
const core = service.deCores.get(key) ?? [];
core.push(item);
service.deCores.set(key, core);
yield* tryPromise("persist-put-de-core", () => service.persist());
yield* sendResponse(service, {}, requestId);
}),
);
},
loadDeCore: function(this: KnowledgeCoreService, request: KnowledgeRequest, requestId: string): Promise<void> {
const service = this;
return Effect.runPromise(
Effect.gen(function* () {
const user = request.user ?? "";
const coreId = request.id ?? "";
const key = service.coreKey(user, coreId);
if (!(service.deCores as Map<string, DocumentEmbeddingsCore[]>).has(key)) {
return yield* knowledgeCoreServiceError("load-de-core", `Document embeddings core not found: ${key}`);
}
yield* sendResponse(service, {}, requestId);
}),
);
}, },
@ -417,69 +593,88 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
// ---------- Persistence ---------- // ---------- Persistence ----------
persist: async function(this: KnowledgeCoreService): Promise<void> { persist: function(this: KnowledgeCoreService): Promise<void> {
try { const service = this;
// Serialize Map to object return Effect.runPromise(
const data: { Effect.gen(function* () {
kg: Record<string, KnowledgeCore>; // Serialize Map to object
de: Record<string, DocumentEmbeddingsCore[]>; const data: {
} = { kg: {}, de: {} }; kg: Record<string, KnowledgeCore>;
for (const [key, core] of this.cores) { de: Record<string, DocumentEmbeddingsCore[]>;
data.kg[key] = core; } = { kg: {}, de: {} };
} for (const [key, core] of service.cores) {
for (const [key, core] of this.deCores) { data.kg[key] = core;
data.de[key] = core; }
} for (const [key, core] of service.deCores) {
data.de[key] = core;
const json = JSON.stringify(data, null, 2);
await writeTextFile(this.persistPath, json);
} catch (err) {
console.error("[KnowledgeCoreService] Failed to persist state:", err);
}
},
loadFromDisk: async function(this: KnowledgeCoreService): Promise<void> {
try {
const raw = await readTextFile(this.persistPath);
const parsed = JSON.parse(raw) as Record<string, KnowledgeCore> | {
kg?: Record<string, KnowledgeCore>;
de?: Record<string, DocumentEmbeddingsCore[]>;
};
this.cores.clear();
this.deCores.clear();
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
for (const [key, core] of Object.entries(kg)) {
this.cores.set(key, core);
}
if ("de" in parsed && parsed.de !== undefined) {
for (const [key, core] of Object.entries(parsed.de)) {
this.deCores.set(key, core);
} }
}
console.log(`[KnowledgeCoreService] Loaded persisted state (kg=${this.cores.size}, de=${this.deCores.size})`); const json = yield* trySync("persist-serialize", () => JSON.stringify(data, null, 2));
} catch { yield* tryPromise("persist-write", () => writeTextFile(service.persistPath, json));
console.log("[KnowledgeCoreService] No persisted state found, starting fresh"); }).pipe(
} Effect.catch((error) =>
Effect.logError("[KnowledgeCoreService] Failed to persist state", {
error: error.message,
}),
),
),
);
}, },
stop: async function(this: KnowledgeCoreService): Promise<void> { loadFromDisk: function(this: KnowledgeCoreService): Promise<void> {
if (this.consumer !== null) { const service = this;
await this.consumer.close(); return Effect.runPromise(
this.consumer = null; Effect.gen(function* () {
} const raw = yield* tryPromise("load-read", () => readTextFile(service.persistPath));
if (this.responseProducer !== null) { const parsed = yield* trySync("load-parse", () =>
await this.responseProducer.close(); JSON.parse(raw) as Record<string, KnowledgeCore> | {
this.responseProducer = null; kg?: Record<string, KnowledgeCore>;
} de?: Record<string, DocumentEmbeddingsCore[]>;
await baseStop(); },
);
service.cores.clear();
service.deCores.clear();
const kg = "kg" in parsed && parsed.kg !== undefined ? parsed.kg : parsed as Record<string, KnowledgeCore>;
for (const [key, core] of Object.entries(kg)) {
service.cores.set(key, core);
}
if ("de" in parsed && parsed.de !== undefined) {
for (const [key, core] of Object.entries(parsed.de)) {
service.deCores.set(key, core);
}
}
yield* Effect.log(`[KnowledgeCoreService] Loaded persisted state (kg=${service.cores.size}, de=${service.deCores.size})`);
}).pipe(
Effect.catch(() =>
Effect.log("[KnowledgeCoreService] No persisted state found, starting fresh"),
),
),
);
},
stop: function(this: KnowledgeCoreService): Promise<void> {
const service = this;
return Effect.runPromise(
Effect.gen(function* () {
if (service.consumer !== null) {
yield* tryPromise("close-consumer", () => service.consumer.close());
service.consumer = null;
}
if (service.responseProducer !== null) {
yield* tryPromise("close-response-producer", () => service.responseProducer.close());
service.responseProducer = null;
}
yield* tryPromise("base-stop", () => baseStop());
}),
);
} }
}); });
@ -488,15 +683,11 @@ export function makeKnowledgeCoreService(config: KnowledgeCoreServiceConfig): Kn
export const KnowledgeCoreService = makeKnowledgeCoreService; export const KnowledgeCoreService = makeKnowledgeCoreService;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const program = makeProcessorProgram({ export const program = makeProcessorProgram({
id: "knowledge-svc", id: "knowledge-svc",
make: (config) => makeKnowledgeCoreService(config), make: (config) => makeKnowledgeCoreService(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -37,7 +37,7 @@ import {
errorMessage, errorMessage,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { makeFlowProcessorProgram } from "@trustgraph/base"; import { makeFlowProcessorProgram } from "@trustgraph/base";
import { Effect } from "effect"; import { Clock, Effect } from "effect";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()( export class PdfDecoderError extends S.TaggedErrorClass<PdfDecoderError>()(
@ -63,7 +63,7 @@ const pdfDecoderError = (
documentId: string, documentId: string,
cause: unknown, cause: unknown,
) => ) =>
new PdfDecoderError({ PdfDecoderError.make({
operation, operation,
documentId, documentId,
message: errorMessage(cause), message: errorMessage(cause),
@ -76,18 +76,24 @@ const loadPdf = (documentId: string, pdfBuffer: Buffer) =>
catch: (cause) => pdfDecoderError("load-pdf", documentId, cause), catch: (cause) => pdfDecoderError("load-pdf", documentId, cause),
}); });
const loadPageText = (documentId: string, pageNumber: number, pdf: PdfDocument) => const loadPageText = Effect.fn("loadPageText")(function*(
Effect.tryPromise({ documentId: string,
try: async () => { pageNumber: number,
const page = await pdf.getPage(pageNumber); pdf: PdfDocument,
const textContent = await page.getTextContent(); ) {
return textContent.items const page = yield* Effect.tryPromise({
.filter((item): item is TextItem => "str" in item) try: () => pdf.getPage(pageNumber),
.map((item) => item.str) catch: (cause) => pdfDecoderError("load-page", documentId, cause),
.join(" "); });
}, const textContent = yield* Effect.tryPromise({
catch: (cause) => pdfDecoderError("load-page-text", documentId, cause), try: () => page.getTextContent(),
}); catch: (cause) => pdfDecoderError("load-page-text", documentId, cause),
});
return textContent.items
.filter((item): item is TextItem => "str" in item)
.map((item) => item.str)
.join(" ");
});
const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* ( const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
msg: Document, msg: Document,
@ -156,6 +162,7 @@ const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
continue; continue;
} }
const timestamp = yield* Clock.currentTimeMillis;
const childResp = yield* librarian.request({ const childResp = yield* librarian.request({
operation: "add-child-document", operation: "add-child-document",
documentMetadata: { documentMetadata: {
@ -165,7 +172,7 @@ const onPdfDecodeMessage = Effect.fn("PdfDecoderService.onMessage")(function* (
title: `Page ${i}`, title: `Page ${i}`,
parentId: documentId, parentId: documentId,
documentType: "page", documentType: "page",
time: Date.now(), time: timestamp,
comments: "", comments: "",
tags: [], tags: [],
}, },
@ -226,7 +233,7 @@ export function makePdfDecoderService(config: ProcessorConfig): PdfDecoderServic
const service = makeFlowProcessor(config, { const service = makeFlowProcessor(config, {
specifications: makePdfDecoderSpecs(), specifications: makePdfDecoderSpecs(),
}); });
console.log("[PdfDecoder] Service initialized"); Effect.runSync(Effect.log("[PdfDecoder] Service initialized"));
return service; return service;
} }
@ -245,6 +252,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makePdfDecoderSpecs(), specs: () => makePdfDecoderSpecs(),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -4,11 +4,13 @@
* Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py * Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py
*/ */
import { Effect, Layer } from "effect"; import { Config, Effect, Layer } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
import { import {
Embeddings, Embeddings,
embeddingsError, EmbeddingsError,
errorMessage,
makeEmbeddingsService, makeEmbeddingsService,
makeEmbeddingsSpecs, makeEmbeddingsSpecs,
type EmbeddingsServiceShape, type EmbeddingsServiceShape,
@ -22,18 +24,58 @@ export interface OllamaEmbeddingsConfig extends ProcessorConfig {
fetch?: typeof fetch; fetch?: typeof fetch;
} }
interface OllamaEmbedResponse { const EmbeddingVector = S.Array(S.Number);
embeddings: number[][];
const OllamaEmbedResponse = S.Struct({
embeddings: S.Array(EmbeddingVector),
});
type OllamaEmbedResponse = typeof OllamaEmbedResponse.Type;
interface ResolvedOllamaEmbeddingsConfig {
readonly defaultModel: string;
readonly ollamaHost: string;
readonly fetchImpl: typeof fetch;
} }
const ollamaEmbeddingsError = (operation: string, cause: unknown): EmbeddingsError =>
EmbeddingsError.make({
operation,
message: errorMessage(cause),
provider: "ollama",
});
const ollamaEmbeddingsMessageError = (operation: string, message: string): EmbeddingsError =>
EmbeddingsError.make({
operation,
message,
provider: "ollama",
});
const optionalStringConfig = Effect.fn("OllamaEmbeddings.optionalStringConfig")(function*(name: string) {
return O.getOrUndefined(yield* Config.string(name).pipe(Config.option));
});
const loadOllamaEmbeddingsConfig = Effect.fn("OllamaEmbeddings.loadConfig")(function*(
config: OllamaEmbeddingsConfig,
) {
return {
defaultModel: config.model ?? "mxbai-embed-large",
ollamaHost:
config.ollamaHost ??
(yield* optionalStringConfig("OLLAMA_URL")) ??
(yield* optionalStringConfig("OLLAMA_HOST")) ??
"http://localhost:11434",
fetchImpl: config.fetch ?? globalThis.fetch,
} satisfies ResolvedOllamaEmbeddingsConfig;
});
export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): EmbeddingsServiceShape { export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): EmbeddingsServiceShape {
const defaultModel = config.model ?? "mxbai-embed-large"; const {
const ollamaHost = defaultModel,
config.ollamaHost ?? ollamaHost,
process.env.OLLAMA_URL ?? fetchImpl,
process.env.OLLAMA_HOST ?? } = Effect.runSync(loadOllamaEmbeddingsConfig(config)) satisfies ResolvedOllamaEmbeddingsConfig;
"http://localhost:11434";
const fetchImpl = config.fetch ?? globalThis.fetch;
return { return {
embed: Effect.fn("OllamaEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => { embed: Effect.fn("OllamaEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => {
@ -49,29 +91,38 @@ export function makeOllamaEmbeddings(config: OllamaEmbeddingsConfig): Embeddings
model: useModel, model: useModel,
input: Array.from(texts), input: Array.from(texts),
}).pipe( }).pipe(
Effect.mapError((error) => embeddingsError("ollama.encode-request", error, "ollama")), Effect.mapError((error) => ollamaEmbeddingsError("ollama.encode-request", error))
); );
return yield* Effect.tryPromise({ const response = yield* Effect.tryPromise({
try: async () => { try: () =>
const response = await fetchImpl(url, { fetchImpl(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body, body,
}); }),
catch: (error) => ollamaEmbeddingsError("ollama.fetch", error),
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"),
}); });
if (!response.ok) {
const errorBody = yield* Effect.tryPromise({
try: () => response.text(),
catch: (error) => ollamaEmbeddingsError("ollama.error-body", error),
});
return yield* ollamaEmbeddingsMessageError(
"ollama.embed",
`Ollama embeddings request failed (${response.status}): ${errorBody}`,
);
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<unknown>,
catch: (error) => ollamaEmbeddingsError("ollama.response-json", error),
});
const decoded = yield* S.decodeUnknownEffect(OllamaEmbedResponse)(data).pipe(
Effect.mapError((error) => ollamaEmbeddingsError("ollama.decode-response", error))
);
return Array.from(decoded.embeddings, (vector) => Array.from(vector));
}); });
}), }),
}; };
@ -88,9 +139,10 @@ export type OllamaEmbeddingsProcessor = ReturnType<typeof makeOllamaEmbeddingsPr
export function makeOllamaEmbeddingsProcessor(config: OllamaEmbeddingsConfig) { export function makeOllamaEmbeddingsProcessor(config: OllamaEmbeddingsConfig) {
const embeddings = makeOllamaEmbeddings(config); const embeddings = makeOllamaEmbeddings(config);
console.log( const resolved = Effect.runSync(loadOllamaEmbeddingsConfig(config)) satisfies ResolvedOllamaEmbeddingsConfig;
`[OllamaEmbeddings] Initialized (host=${config.ollamaHost ?? process.env.OLLAMA_URL ?? process.env.OLLAMA_HOST ?? "http://localhost:11434"}, model=${config.model ?? "mxbai-embed-large"})`, Effect.runSync(Effect.log(
); `[OllamaEmbeddings] Initialized (host=${resolved.ollamaHost}, model=${resolved.defaultModel})`,
));
return makeEmbeddingsService(config, embeddings); return makeEmbeddingsService(config, embeddings);
} }
@ -102,6 +154,6 @@ export const program = makeFlowProcessorProgram<OllamaEmbeddingsConfig, never, E
layer: (config) => OllamaEmbeddingsLive(config), layer: (config) => OllamaEmbeddingsLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -289,7 +289,7 @@ export function makeKnowledgeExtractService(config: ProcessorConfig): KnowledgeE
const service = makeFlowProcessor(config, { const service = makeFlowProcessor(config, {
specifications: makeKnowledgeExtractSpecs(), specifications: makeKnowledgeExtractSpecs(),
}); });
console.log("[KnowledgeExtract] Service initialized"); Effect.runSync(Effect.log("[KnowledgeExtract] Service initialized"));
return service; return service;
} }
@ -324,7 +324,9 @@ export function parseJsonResponse<T>(raw: string): T | null {
if (O.isSome(decoded)) return decoded.value as T; if (O.isSome(decoded)) return decoded.value as T;
} }
console.warn("[KnowledgeExtract] Failed to parse JSON from LLM response:", raw.slice(0, 300)); Effect.runSync(Effect.logWarning("[KnowledgeExtract] Failed to parse JSON from LLM response", {
response: raw.slice(0, 300),
}));
return null; return null;
} }
@ -333,7 +335,9 @@ function parseRelationshipsResponse(raw: string): ReadonlyArray<ExtractedRelatio
const decoded = decodeExtractedRelationships(candidate); const decoded = decodeExtractedRelationships(candidate);
if (O.isSome(decoded)) return decoded.value; if (O.isSome(decoded)) return decoded.value;
} }
console.warn("[KnowledgeExtract] Failed to parse relationships from LLM response:", raw.slice(0, 300)); Effect.runSync(Effect.logWarning("[KnowledgeExtract] Failed to parse relationships from LLM response", {
response: raw.slice(0, 300),
}));
return null; return null;
} }
@ -342,7 +346,9 @@ function parseDefinitionsResponse(raw: string): ReadonlyArray<ExtractedDefinitio
const decoded = decodeExtractedDefinitions(candidate); const decoded = decodeExtractedDefinitions(candidate);
if (O.isSome(decoded)) return decoded.value; if (O.isSome(decoded)) return decoded.value;
} }
console.warn("[KnowledgeExtract] Failed to parse definitions from LLM response:", raw.slice(0, 300)); Effect.runSync(Effect.logWarning("[KnowledgeExtract] Failed to parse definitions from LLM response", {
response: raw.slice(0, 300),
}));
return null; return null;
} }
@ -380,6 +386,6 @@ export const program = makeFlowProcessorProgram({
specs: () => makeKnowledgeExtractSpecs(), specs: () => makeKnowledgeExtractSpecs(),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py * Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
*/ */
import { Effect, Exit, Scope, SynchronizedRef } from "effect"; import { Clock, Effect, Exit, Random, Scope, SynchronizedRef } from "effect";
import { import {
loadMessagingRuntimeConfig, loadMessagingRuntimeConfig,
makeNatsBackend, makeNatsBackend,
@ -146,13 +146,13 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
const pubsub: PubSubBackend = config.pubsub ?? makeNatsBackend(config.natsUrl ?? "nats://localhost:4222"); const pubsub: PubSubBackend = config.pubsub ?? makeNatsBackend(config.natsUrl ?? "nats://localhost:4222");
let runtime: DispatcherRuntime | null = null; let runtime: DispatcherRuntime | null = null;
const start = async (): Promise<void> => { const start = (): Promise<void> => {
if (runtime !== null) return; if (runtime !== null) return Promise.resolve();
runtime = await Effect.runPromise( return Effect.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const scope = yield* Scope.make(); const scope = yield* Scope.make();
return yield* Effect.gen(function* () { const nextRuntime = yield* Effect.gen(function* () {
const messagingConfig = yield* loadMessagingRuntimeConfig(); const messagingConfig = yield* loadMessagingRuntimeConfig();
const requestors = yield* SynchronizedRef.make<RequestorMap>(new Map()); const requestors = yield* SynchronizedRef.make<RequestorMap>(new Map());
return { return {
@ -163,62 +163,79 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
}).pipe( }).pipe(
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
); );
runtime = nextRuntime;
}), }),
); );
}; };
const stop = async (): Promise<void> => { const stop = (): Promise<void> =>
const current = runtime; Effect.runPromise(
runtime = null; Effect.gen(function* () {
const current = runtime;
runtime = null;
if (current !== null) { if (current !== null) {
await Effect.runPromise(Scope.close(current.scope, Exit.void)); yield* Scope.close(current.scope, Exit.void);
} }
await pubsub.close(); yield* Effect.tryPromise({
}; try: () => pubsub.close(),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause),
});
}),
);
// ---------- Internal helpers ---------- // ---------- Internal helpers ----------
const ensureRuntime = async (): Promise<DispatcherRuntime> => { const ensureRuntime = (): Promise<DispatcherRuntime> =>
if (runtime === null) { Effect.runPromise(
await start(); Effect.gen(function* () {
} if (runtime === null) {
if (runtime === null) { yield* Effect.tryPromise({
throw messagingLifecycleError("gateway-dispatcher", "start", "Dispatcher manager failed to start"); try: () => start(),
} catch: (cause) => messagingLifecycleError("gateway-dispatcher", "start", cause),
return runtime; });
}; }
if (runtime === null) {
return yield* messagingLifecycleError("gateway-dispatcher", "start", "Dispatcher manager failed to start");
}
return runtime;
}),
);
const getRequestor = async ( const getRequestor = (
requestTopic: string, requestTopic: string,
responseTopic: string, responseTopic: string,
key: string, key: string,
): Promise<EffectRequestResponse<unknown, unknown>> => { ): Promise<EffectRequestResponse<unknown, unknown>> =>
const current = await ensureRuntime(); Effect.runPromise(
Effect.gen(function* () {
const current = yield* Effect.tryPromise({
try: () => ensureRuntime(),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "ensure-runtime", cause),
});
return await Effect.runPromise( return yield* SynchronizedRef.modifyEffect(current.requestors, (requestors) => {
SynchronizedRef.modifyEffect(current.requestors, (requestors) => { const cached = requestors.get(key);
const cached = requestors.get(key); if (cached !== undefined) {
if (cached !== undefined) { return Effect.succeed([cached, requestors] as const);
return Effect.succeed([cached, requestors] as const); }
}
return current.factory.make<unknown, unknown>({ return current.factory.make<unknown, unknown>({
requestTopic, requestTopic,
responseTopic, responseTopic,
subscription: `gateway-${key}`, subscription: `gateway-${key}`,
}).pipe( }).pipe(
Scope.provide(current.scope), Scope.provide(current.scope),
Effect.map((requestor) => { Effect.map((requestor) => {
const next = new Map(requestors); const next = new Map(requestors);
next.set(key, requestor); next.set(key, requestor);
return [requestor, next] as const; return [requestor, next] as const;
}), }),
); );
});
}), }),
); );
};
const resolveGlobalTopics = ( const resolveGlobalTopics = (
kind: string, kind: string,
@ -256,93 +273,107 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
// ---------- Global service dispatch ---------- // ---------- Global service dispatch ----------
const dispatchGlobalService = async ( const dispatchGlobalService = (
kind: string, kind: string,
request: Record<string, unknown>, request: Record<string, unknown>,
): Promise<unknown> => { ): Promise<unknown> =>
const { requestTopic, responseTopic } = resolveGlobalTopics(kind); Effect.runPromise(
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`); Effect.gen(function* () {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = yield* Effect.tryPromise({
try: () => getRequestor(requestTopic, responseTopic, `global:${kind}`),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
});
const translated = translateRequest(kind, request); const translated = translateRequest(kind, request);
const response = await Effect.runPromise(rr.request(translated)); const response = yield* rr.request(translated);
return translateResponse(kind, response); return translateResponse(kind, response);
}; }),
);
const dispatchGlobalServiceStreaming = async ( const dispatchGlobalServiceStreaming = (
kind: string, kind: string,
request: Record<string, unknown>, request: Record<string, unknown>,
responder: Responder, responder: Responder,
): Promise<void> => { ): Promise<void> =>
const { requestTopic, responseTopic } = resolveGlobalTopics(kind); Effect.runPromise(
const rr = await getRequestor(requestTopic, responseTopic, `global:${kind}`); Effect.gen(function* () {
const translated = translateRequest(kind, request); const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const rr = yield* Effect.tryPromise({
try: () => getRequestor(requestTopic, responseTopic, `global:${kind}`),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
});
const translated = translateRequest(kind, request);
await Effect.runPromise( yield* rr.request(translated, {
rr.request(translated, { recipient: (response) => {
recipient: (response) => { const translatedRes = translateResponse(kind, response);
const translatedRes = translateResponse(kind, response); const complete = dispatcherManagerIsCompleteResponse(translatedRes);
const complete = dispatcherManagerIsCompleteResponse(translatedRes); return Effect.tryPromise({
return Effect.tryPromise({ try: () => responder(translatedRes, complete).then(() => complete),
try: async () => { catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
await responder(translatedRes, complete); });
return complete; },
}, });
catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
});
},
}), }),
); );
};
// ---------- Flow-scoped service dispatch ---------- // ---------- Flow-scoped service dispatch ----------
const dispatchFlowService = async ( const dispatchFlowService = (
flow: string, flow: string,
kind: string, kind: string,
request: Record<string, unknown>, request: Record<string, unknown>,
): Promise<unknown> => { ): Promise<unknown> =>
const { requestTopic, responseTopic } = resolveFlowTopics(kind); Effect.runPromise(
const rr = await getRequestor( Effect.gen(function* () {
requestTopic, const { requestTopic, responseTopic } = resolveFlowTopics(kind);
responseTopic, const rr = yield* Effect.tryPromise({
`flow:${flow}:${kind}`, try: () => getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
});
const translated = translateRequest(kind, request);
const response = yield* rr.request(translated);
return translateResponse(kind, response);
}),
); );
const translated = translateRequest(kind, request); const dispatchFlowServiceStreaming = (
const response = await Effect.runPromise(rr.request(translated));
return translateResponse(kind, response);
};
const dispatchFlowServiceStreaming = async (
flow: string, flow: string,
kind: string, kind: string,
request: Record<string, unknown>, request: Record<string, unknown>,
responder: Responder, responder: Responder,
): Promise<void> => { ): Promise<void> =>
const { requestTopic, responseTopic } = resolveFlowTopics(kind); Effect.runPromise(
const rr = await getRequestor( Effect.gen(function* () {
requestTopic, const { requestTopic, responseTopic } = resolveFlowTopics(kind);
responseTopic, const rr = yield* Effect.tryPromise({
`flow:${flow}:${kind}`, try: () => getRequestor(
); requestTopic,
const translated = translateRequest(kind, request); responseTopic,
`flow:${flow}:${kind}`,
),
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
});
const translated = translateRequest(kind, request);
await Effect.runPromise( yield* rr.request(translated, {
rr.request(translated, { recipient: (response) => {
recipient: (response) => { const translatedRes = translateResponse(kind, response);
const translatedRes = translateResponse(kind, response); const complete = dispatcherManagerIsCompleteResponse(translatedRes);
const complete = dispatcherManagerIsCompleteResponse(translatedRes); return Effect.tryPromise({
return Effect.tryPromise({ try: () => responder(translatedRes, complete).then(() => complete),
try: async () => { catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
await responder(translatedRes, complete); });
return complete; },
}, });
catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
});
},
}), }),
); );
};
// ---------- Fire-and-forget publish ---------- // ---------- Fire-and-forget publish ----------
@ -350,12 +381,27 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
* Publish a single message to an arbitrary topic (no request/response). * Publish a single message to an arbitrary topic (no request/response).
* Used for injecting documents into the processing pipeline. * Used for injecting documents into the processing pipeline.
*/ */
const publishToTopic = async (topic: string, message: unknown, id?: string): Promise<void> => { const publishToTopic = (topic: string, message: unknown, id?: string): Promise<void> =>
const producer = await pubsub.createProducer<unknown>({ topic }); Effect.runPromise(
const messageId = id ?? `pub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; Effect.gen(function* () {
await producer.send(message, { id: messageId }); const producer = yield* Effect.tryPromise({
await producer.close(); try: () => pubsub.createProducer<unknown>({ topic }),
}; catch: (cause) => messagingDeliveryError(topic, "create-producer", cause),
});
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`;
yield* Effect.tryPromise({
try: () => producer.send(message, { id: messageId }),
catch: (cause) => messagingDeliveryError(topic, "send", cause),
});
yield* Effect.tryPromise({
try: () => producer.close(),
catch: (cause) => messagingDeliveryError(topic, "close-producer", cause),
});
}),
);
return { return {
start, start,

View file

@ -19,6 +19,7 @@
*/ */
import type { Term, Triple } from "@trustgraph/base"; import type { Term, Triple } from "@trustgraph/base";
import * as S from "effect/Schema";
// ---------- Client wire format type definitions ---------- // ---------- Client wire format type definitions ----------
@ -46,6 +47,14 @@ interface ClientTripleTerm {
type ClientTerm = ClientIriTerm | ClientBlankTerm | ClientLiteralTerm | ClientTripleTerm; type ClientTerm = ClientIriTerm | ClientBlankTerm | ClientLiteralTerm | ClientTripleTerm;
export class DispatchSerializationError extends S.TaggedErrorClass<DispatchSerializationError>()(
"DispatchSerializationError",
{
message: S.String,
operation: S.String,
},
) {}
interface ClientTriple { interface ClientTriple {
s: ClientTerm; s: ClientTerm;
p: ClientTerm; p: ClientTerm;
@ -70,7 +79,10 @@ export function clientTermToInternal(wire: ClientTerm): Term {
}; };
case "t": { case "t": {
if (wire.tr === undefined) { if (wire.tr === undefined) {
throw new Error("Client triple term is missing tr"); throw DispatchSerializationError.make({
operation: "client-term-to-internal",
message: "Client triple term is missing tr",
});
} }
return { return {
type: "TRIPLE", type: "TRIPLE",

View file

@ -34,37 +34,44 @@ export const makeSocketRpcProtocol = Effect.gen(function* () {
}); });
const writeRaw = yield* socket.writer; const writeRaw = yield* socket.writer;
const write = (response: RpcMessage.FromServerEncoded) => { const encodeDefect = (cause: unknown) =>
try { Effect.sync(() => parser.encode(RpcMessage.ResponseDefectEncoded(cause))!);
const encoded = parser.encode(response); const write = (response: RpcMessage.FromServerEncoded) =>
if (encoded === undefined) return Effect.void; Effect.sync(() => parser.encode(response)).pipe(
return Effect.orDie(writeRaw(encoded)); Effect.flatMap((encoded) =>
} catch (cause) { encoded === undefined ? Effect.void : Effect.orDie(writeRaw(encoded)),
return Effect.orDie( ),
writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!), Effect.catchDefect((cause: unknown) =>
); encodeDefect(cause).pipe(
} Effect.flatMap((encoded) => Effect.orDie(writeRaw(encoded))),
}; Effect.orDie,
),
),
);
clients.set(clientId, { write }); clients.set(clientId, { write });
clientIds.add(clientId); clientIds.add(clientId);
yield* socket.runRaw((data) => { yield* socket.runRaw((data) =>
try { Effect.sync(() => parser.decode(data) as ReadonlyArray<RpcMessage.FromClientEncoded>).pipe(
const decoded = parser.decode(data) as ReadonlyArray<RpcMessage.FromClientEncoded>; Effect.flatMap((decoded) =>
return Effect.forEach(decoded, (message) => { Effect.forEach(decoded, (message) => {
if (message._tag === "Request" && headers !== undefined) { if (message._tag === "Request" && headers !== undefined) {
return writeRequest(clientId, { return writeRequest(clientId, {
...message, ...message,
headers: headers.concat(message.headers), headers: headers.concat(message.headers),
}); });
} }
return writeRequest(clientId, message); return writeRequest(clientId, message);
}, { discard: true }); }, { discard: true }),
} catch (cause) { ),
return writeRaw(parser.encode(RpcMessage.ResponseDefectEncoded(cause))!); Effect.catchDefect((cause: unknown) =>
} encodeDefect(cause).pipe(
}).pipe( Effect.flatMap((encoded) => writeRaw(encoded)),
),
),
)
).pipe(
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.orDie, Effect.orDie,
); );

View file

@ -42,7 +42,7 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
Dispatch: (payload) => Dispatch: (payload) =>
Effect.tryPromise({ Effect.tryPromise({
try: () => dispatchOne(dispatcher, payload), try: () => dispatchOne(dispatcher, payload),
catch: (cause) => new DispatchError({ message: errorMessage(cause) }), catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
}), }),
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) { DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
const context = yield* Effect.context<never>(); const context = yield* Effect.context<never>();
@ -52,11 +52,10 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
yield* Effect.tryPromise({ yield* Effect.tryPromise({
try: () => try: () =>
dispatchStream(dispatcher, payload, async (response, complete) => { dispatchStream(dispatcher, payload, (response, complete) =>
await runPromise(Queue.offer(queue, new DispatchStreamChunk({ response, complete }))); runPromise(Queue.offer(queue, DispatchStreamChunk.make({ response, complete }))).then(() => complete),
return complete; ),
}), catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
catch: (cause) => new DispatchError({ message: errorMessage(cause) }),
}).pipe( }).pipe(
Effect.flatMap(() => Queue.end(queue)), Effect.flatMap(() => Queue.end(queue)),
Effect.catch((error) => Queue.fail(queue, error)), Effect.catch((error) => Queue.fail(queue, error)),
@ -82,26 +81,24 @@ function dispatchOne(
return dispatcher.dispatchGlobalService(payload.service, payload.request); return dispatcher.dispatchGlobalService(payload.service, payload.request);
} }
async function dispatchStream( function dispatchStream(
dispatcher: DispatcherManager, dispatcher: DispatcherManager,
payload: DispatchPayload, payload: DispatchPayload,
responder: (response: unknown, complete: boolean) => Promise<boolean>, responder: (response: unknown, complete: boolean) => Promise<boolean>,
): Promise<void> { ): Promise<void> {
const send = async (response: unknown, complete: boolean) => { const send = (response: unknown, complete: boolean): Promise<void> =>
await responder(response, complete); responder(response, complete).then(() => undefined);
};
if (payload.scope === "flow") { if (payload.scope === "flow") {
await dispatcher.dispatchFlowServiceStreaming( return dispatcher.dispatchFlowServiceStreaming(
payload.flow ?? "default", payload.flow ?? "default",
payload.service, payload.service,
payload.request, payload.request,
send, send,
); );
return;
} }
await dispatcher.dispatchGlobalServiceStreaming( return dispatcher.dispatchGlobalServiceStreaming(
payload.service, payload.service,
payload.request, payload.request,
send, send,

View file

@ -7,13 +7,13 @@
* Python reference: trustgraph-flow/trustgraph/gateway/service.py * Python reference: trustgraph-flow/trustgraph/gateway/service.py
*/ */
import Fastify from "fastify"; import Fastify, { type FastifyReply } from "fastify";
import websocketPlugin from "@fastify/websocket"; import websocketPlugin from "@fastify/websocket";
import { Config, Effect, Exit, Scope } from "effect"; import { Clock, Config, Effect, Exit, Random, Scope } from "effect";
import * as O from "effect/Option"; import * as O from "effect/Option";
import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
import * as EffectSocket from "effect/unstable/socket/Socket"; import * as EffectSocket from "effect/unstable/socket/Socket";
import { optionalStringConfig, registry, toTgError, type PubSubBackend } from "@trustgraph/base"; import { messagingLifecycleError, optionalStringConfig, registry, toTgError, type PubSubBackend } from "@trustgraph/base";
import { makeDispatcherManager } from "./dispatch/manager.js"; import { makeDispatcherManager } from "./dispatch/manager.js";
import { makeGatewayRpcServer } from "./rpc-server.js"; import { makeGatewayRpcServer } from "./rpc-server.js";
@ -25,187 +25,227 @@ export interface GatewayConfig {
pubsub?: PubSubBackend; pubsub?: PubSubBackend;
} }
export async function createGateway(config: GatewayConfig) { export function createGateway(config: GatewayConfig) {
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(websocketPlugin);
const dispatcher = makeDispatcherManager(config); const dispatcher = makeDispatcherManager(config);
await dispatcher.start();
const rpcScope = await Effect.runPromise(Scope.make());
const rpcServer = await Effect.runPromise(
makeGatewayRpcServer(dispatcher).pipe(
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
Scope.provide(rpcScope),
),
);
// Authentication middleware const sendDispatchResult = (reply: FastifyReply, result: unknown): unknown => {
app.addHook("onRequest", async (request, reply) => { const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined;
if (request.url === "/api/v1/metrics") return; if (err !== undefined) {
if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
if (config.secret !== undefined && config.secret.length > 0) {
const auth = request.headers.authorization;
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
reply.code(401).send({ error: "Unauthorized" });
}
} }
}); return result;
app.post<{
Body: {
scope?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
};
}>("/api/v1/workbench/dispatch", async (request, reply) => {
const body = request.body;
const service = body.service;
const payload = body.request;
if (service === undefined || service.length === 0 || payload === undefined) {
return reply.code(400).send({
error: { type: "bad-request", message: "service and request are required" },
});
}
try {
const result = body.scope === "flow"
? await dispatcher.dispatchFlowService(body.flow ?? "default", service, payload)
: await dispatcher.dispatchGlobalService(service, payload);
const err = (result as Record<string, unknown>)?.error as { type?: string; message?: string } | undefined;
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: toTgError(err) });
}
});
// REST endpoint: POST /api/v1/:kind (global services)
app.post<{ Params: { kind: string } }>("/api/v1/:kind", async (request, reply) => {
const { kind } = request.params;
const body = request.body as Record<string, unknown>;
try {
const result = await dispatcher.dispatchGlobalService(kind, body) as Record<string, unknown>;
const err = result?.error as { type?: string; message?: string } | undefined;
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: toTgError(err) });
}
});
// REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services)
app.post<{ Params: { flow: string; kind: string } }>(
"/api/v1/flow/:flow/service/:kind",
async (request, reply) => {
const { flow, kind } = request.params;
const body = request.body as Record<string, unknown>;
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 !== undefined) {
const statusCode = err.type === "not-found" ? 404 : 400;
return reply.code(statusCode).send(result);
}
return result;
} catch (err) {
reply.code(500).send({ error: toTgError(err) });
}
},
);
// REST endpoint: POST /api/v1/flow/:flow/load (trigger document processing)
app.post<{ Params: { flow: string } }>(
"/api/v1/flow/:flow/load",
async (request, reply) => {
const { flow } = request.params;
const body = request.body as {
documentId?: string;
user?: string;
collection?: string;
};
if (body.documentId === undefined || body.documentId.length === 0) {
return reply.code(400).send({
error: { type: "bad-request", message: "documentId is required" },
});
}
try {
const user = body.user ?? "default";
const collection = body.collection ?? "default";
const documentId = body.documentId;
// Publish Document message to the decode-input topic
const topic = "tg.flow.document";
const metadata = {
id: `load-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
root: documentId,
user,
collection,
};
await dispatcher.publishToTopic(topic, { metadata, documentId });
return { status: "processing", documentId, flow };
} catch (err) {
reply.code(500).send({
error: toTgError(err),
});
}
},
);
// Effect RPC WebSocket endpoint: /api/v1/rpc
app.get("/api/v1/rpc", { websocket: true }, (socket, request) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
socket.close(4001, "Unauthorized");
return;
}
const program = Effect.scoped(
Effect.gen(function* () {
const effectSocket = yield* EffectSocket.fromWebSocket(
Effect.succeed(socket as unknown as globalThis.WebSocket),
{ closeCodeIsError: (code) => code !== 1000 },
);
yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers));
}),
);
Effect.runPromise(program.pipe(Scope.provide(rpcScope))).catch((err) => {
console.error("[Gateway] RPC WebSocket error:", err);
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
});
});
// Metrics endpoint — returns Prometheus metrics from prom-client
app.get("/api/v1/metrics", async (_, reply) => {
reply.header("content-type", registry.contentType);
return registry.metrics();
});
return {
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
stop: async () => {
await app.close();
await Effect.runPromise(Scope.close(rpcScope, Exit.void));
await dispatcher.stop();
},
}; };
const sendDispatchError = (reply: FastifyReply, error: unknown): unknown =>
reply.code(500).send({ error: toTgError(error) });
return Effect.runPromise(
Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => app.register(websocketPlugin),
catch: (cause) => messagingLifecycleError("gateway", "register-websocket", cause),
});
yield* Effect.tryPromise({
try: () => dispatcher.start(),
catch: (cause) => messagingLifecycleError("gateway", "dispatcher-start", cause),
});
const rpcScope = yield* Scope.make();
const rpcServer = yield* makeGatewayRpcServer(dispatcher).pipe(
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.ndjson),
Scope.provide(rpcScope),
);
return { rpcScope, rpcServer };
}),
).then(({ rpcScope, rpcServer }) => {
// Authentication middleware
app.addHook("onRequest", (request, reply) => {
if (request.url === "/api/v1/metrics") return;
if (request.url.startsWith("/api/v1/rpc")) return; // RPC socket auth via query param
if (config.secret !== undefined && config.secret.length > 0) {
const auth = request.headers.authorization;
if (auth === undefined || auth !== `Bearer ${config.secret}`) {
reply.code(401).send({ error: "Unauthorized" });
}
}
});
app.post<{
Body: {
scope?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
};
}>("/api/v1/workbench/dispatch", (request, reply) => {
const body = request.body;
const service = body.service;
const payload = body.request;
if (service === undefined || service.length === 0 || payload === undefined) {
return reply.code(400).send({
error: { type: "bad-request", message: "service and request are required" },
});
}
return Effect.runPromise(
Effect.tryPromise({
try: () =>
body.scope === "flow"
? dispatcher.dispatchFlowService(body.flow ?? "default", service, payload)
: dispatcher.dispatchGlobalService(service, payload),
catch: (cause) => messagingLifecycleError("gateway", "workbench-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
});
// REST endpoint: POST /api/v1/:kind (global services)
app.post<{ Params: { kind: string } }>("/api/v1/:kind", (request, reply) => {
const { kind } = request.params;
const body = request.body as Record<string, unknown>;
return Effect.runPromise(
Effect.tryPromise({
try: () => dispatcher.dispatchGlobalService(kind, body),
catch: (cause) => messagingLifecycleError("gateway", "global-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
});
// REST endpoint: POST /api/v1/flow/:flow/service/:kind (flow-scoped services)
app.post<{ Params: { flow: string; kind: string } }>(
"/api/v1/flow/:flow/service/:kind",
(request, reply) => {
const { flow, kind } = request.params;
const body = request.body as Record<string, unknown>;
return Effect.runPromise(
Effect.tryPromise({
try: () => dispatcher.dispatchFlowService(flow, kind, body),
catch: (cause) => messagingLifecycleError("gateway", "flow-dispatch", cause),
}).pipe(
Effect.map((result) => sendDispatchResult(reply, result)),
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
},
);
// REST endpoint: POST /api/v1/flow/:flow/load (trigger document processing)
app.post<{ Params: { flow: string } }>(
"/api/v1/flow/:flow/load",
(request, reply) => {
const { flow } = request.params;
const body = request.body as {
documentId?: string;
user?: string;
collection?: string;
};
if (body.documentId === undefined || body.documentId.length === 0) {
return reply.code(400).send({
error: { type: "bad-request", message: "documentId is required" },
});
}
return Effect.runPromise(
Effect.gen(function* () {
const user = body.user ?? "default";
const collection = body.collection ?? "default";
const documentId = body.documentId;
const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
// Publish Document message to the decode-input topic
const topic = "tg.flow.document";
const metadata = {
id: `load-${timestamp}-${suffix.toString(36).padStart(6, "0")}`,
root: documentId,
user,
collection,
};
yield* Effect.tryPromise({
try: () => dispatcher.publishToTopic(topic, { metadata, documentId }),
catch: (cause) => messagingLifecycleError("gateway", "publish-load", cause),
});
return { status: "processing", documentId, flow };
}).pipe(
Effect.catch((error) => Effect.succeed(sendDispatchError(reply, error))),
),
);
},
);
// Effect RPC WebSocket endpoint: /api/v1/rpc
app.get("/api/v1/rpc", { websocket: true }, (socket, request) => {
const url = new URL(request.url, `http://${request.headers.host}`);
const token = url.searchParams.get("token");
if (config.secret !== undefined && config.secret.length > 0 && token !== config.secret) {
socket.close(4001, "Unauthorized");
return;
}
const program = Effect.scoped(
Effect.gen(function* () {
const effectSocket = yield* EffectSocket.fromWebSocket(
Effect.succeed(socket as unknown as globalThis.WebSocket),
{ closeCodeIsError: (code) => code !== 1000 },
);
yield* rpcServer.onSocket(effectSocket, headersFrom(request.headers));
}),
);
void Effect.runPromise(program.pipe(Scope.provide(rpcScope))).catch((error) => {
void Effect.runPromise(
Effect.logError("[Gateway] RPC WebSocket error", { error: toTgError(error).message }).pipe(
Effect.flatMap(() =>
Effect.sync(() => {
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
}),
),
),
);
});
});
// Metrics endpoint — returns Prometheus metrics from prom-client
app.get("/api/v1/metrics", (_, reply) => {
reply.header("content-type", registry.contentType);
return registry.metrics();
});
return {
start: () => app.listen({ port: config.port, host: "0.0.0.0" }),
stop: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => app.close(),
catch: (cause) => messagingLifecycleError("gateway", "app-close", cause),
});
yield* Scope.close(rpcScope, Exit.void);
yield* Effect.tryPromise({
try: () => dispatcher.stop(),
catch: (cause) => messagingLifecycleError("gateway", "dispatcher-stop", cause),
});
}),
),
};
});
} }
function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> { function headersFrom(headers: Record<string, string | string[] | number | undefined>): ReadonlyArray<[string, string]> {
@ -217,8 +257,8 @@ function headersFrom(headers: Record<string, string | string[] | number | undefi
}); });
} }
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }
export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () { export const loadGatewayConfig = Effect.fn("loadGatewayConfig")(function* () {

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,15 @@ import {
type ProcessorConfig, type ProcessorConfig,
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
tooManyRequestsError,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerStatusError,
requiredString,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type AzureOpenAIProcessorConfig = ProcessorConfig & { export type AzureOpenAIProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
@ -32,32 +38,65 @@ export type AzureOpenAIProcessorConfig = ProcessorConfig & {
maxOutput?: number; maxOutput?: number;
}; };
export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): LlmProvider { type ResolvedAzureOpenAIConfig = {
const defaultModel = config.model ?? process.env.AZURE_MODEL ?? "gpt-4o"; readonly defaultModel: string;
const defaultTemperature = config.temperature ?? 0.0; readonly defaultTemperature: number;
const maxOutput = config.maxOutput ?? 4096; readonly maxOutput: number;
readonly apiKey: string;
const apiKey = config.apiKey ?? process.env.AZURE_TOKEN; readonly endpoint: string;
if (apiKey === undefined || apiKey.length === 0) { readonly apiVersion: string;
throw new Error("Azure OpenAI API key not specified"); };
}
const endpoint = config.endpoint ?? process.env.AZURE_ENDPOINT;
if (endpoint === undefined || endpoint.length === 0) {
throw new Error("Azure OpenAI endpoint not specified");
}
const loadAzureOpenAIConfig = Effect.fn("loadAzureOpenAIConfig")(function* (
config: AzureOpenAIProcessorConfig,
) {
const defaultModel =
config.model ?? (yield* optionalStringConfig("AzureOpenAI", "AZURE_MODEL")) ?? "gpt-4o";
const apiKey = yield* requiredString(
config.apiKey ?? (yield* optionalStringConfig("AzureOpenAI", "AZURE_TOKEN")),
"AzureOpenAI",
"AZURE_TOKEN",
"Azure OpenAI API key not specified",
);
const endpoint = yield* requiredString(
config.endpoint ?? (yield* optionalStringConfig("AzureOpenAI", "AZURE_ENDPOINT")),
"AzureOpenAI",
"AZURE_ENDPOINT",
"Azure OpenAI endpoint not specified",
);
const apiVersion = const apiVersion =
config.apiVersion ?? config.apiVersion ??
process.env.AZURE_API_VERSION ?? (yield* optionalStringConfig("AzureOpenAI", "AZURE_API_VERSION")) ??
"2024-12-01-preview"; "2024-12-01-preview";
return {
defaultModel,
defaultTemperature: config.temperature ?? 0.0,
maxOutput: config.maxOutput ?? 4096,
apiKey,
endpoint,
apiVersion,
};
});
const mapAzureOpenAIError = (error: unknown): TextCompletionRuntimeError =>
providerStatusError("AzureOpenAI", error);
export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): LlmProvider {
const {
defaultModel,
defaultTemperature,
maxOutput,
apiKey,
endpoint,
apiVersion,
} = Effect.runSync(loadAzureOpenAIConfig(config)) satisfies ResolvedAzureOpenAIConfig;
const client = new AzureOpenAI({ apiKey, apiVersion, endpoint }); const client = new AzureOpenAI({ apiKey, apiVersion, endpoint });
console.log("[AzureOpenAI] LLM service initialized"); Effect.runSync(Effect.log("[AzureOpenAI] LLM service initialized"));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -66,87 +105,106 @@ export function makeAzureOpenAIProvider(config: AzureOpenAIProcessorConfig): Llm
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { return Effect.runPromise(
const resp = await client.chat.completions.create({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.completions.create({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
max_completion_tokens: maxOutput, ],
}); temperature: temp,
max_completion_tokens: maxOutput,
return { }),
text: resp.choices[0].message.content ?? "", catch: mapAzureOpenAIError,
inToken: resp.usage?.prompt_tokens ?? 0, }).pipe(
outToken: resp.usage?.completion_tokens ?? 0, Effect.map((resp): LlmResult => ({
model: modelName, text: resp.choices[0].message.content ?? "",
}; inToken: resp.usage?.prompt_tokens ?? 0,
} catch (err) { outToken: resp.usage?.completion_tokens ?? 0,
if ((err as any)?.status === 429) { model: modelName,
throw tooManyRequestsError(); })),
} ),
throw err; );
}
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { const stream = Stream.fromEffect(
const stream = await client.chat.completions.create({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.completions.create({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
max_completion_tokens: maxOutput, ],
stream: true, temperature: temp,
stream_options: { include_usage: true }, max_completion_tokens: maxOutput,
}); stream: true,
stream_options: { include_usage: true },
}),
catch: mapAzureOpenAIError,
}),
).pipe(
Stream.flatMap((openAIStream) => {
const iterator = openAIStream[Symbol.asyncIterator]();
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
for await (const chunk of stream) { return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
const content = chunk.choices[0]?.delta?.content; "pulling",
if (content !== null && content !== undefined && content.length > 0) { (state) => {
yield { if (state === "done") return Effect.void as Effect.Effect<undefined>;
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
if (chunk.usage !== null && chunk.usage !== undefined) { return Effect.gen(function* () {
totalInputTokens = chunk.usage.prompt_tokens; while (true) {
totalOutputTokens = chunk.usage.completion_tokens; const next = yield* Effect.tryPromise({
} try: () => iterator.next(),
} catch: mapAzureOpenAIError,
});
yield { if (next.done === true) {
text: "", return [{
inToken: totalInputTokens, text: "",
outToken: totalOutputTokens, inToken: totalInputTokens,
model: modelName, outToken: totalOutputTokens,
isFinal: true, model: modelName,
}; isFinal: true,
} catch (err) { }, "done"] as const;
if ((err as any)?.status === 429) { }
throw tooManyRequestsError();
} const chunk = next.value;
throw err; const content = chunk.choices[0]?.delta?.content;
} if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
if (content !== null && content !== undefined && content.length > 0) {
return [{
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapAzureOpenAIError);
}, },
}; };
} }
@ -171,6 +229,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -15,9 +15,15 @@ import {
type ProcessorConfig, type ProcessorConfig,
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
tooManyRequestsError,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerStatusError,
requiredString,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type ClaudeProcessorConfig = ProcessorConfig & { export type ClaudeProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
@ -26,21 +32,46 @@ export type ClaudeProcessorConfig = ProcessorConfig & {
maxOutput?: number; maxOutput?: number;
}; };
type ResolvedClaudeConfig = {
readonly defaultModel: string;
readonly defaultTemperature: number;
readonly maxOutput: number;
readonly apiKey: string;
};
const loadClaudeConfig = Effect.fn("loadClaudeConfig")(function*(config: ClaudeProcessorConfig) {
const apiKey = yield* requiredString(
config.apiKey ?? (yield* optionalStringConfig("Claude", "CLAUDE_KEY")),
"Claude",
"CLAUDE_KEY",
"Claude API key not specified",
);
return {
defaultModel: config.model ?? "claude-sonnet-4-20250514",
defaultTemperature: config.temperature ?? 0.0,
maxOutput: config.maxOutput ?? 8192,
apiKey,
} satisfies ResolvedClaudeConfig;
});
const mapClaudeError = (error: unknown): TextCompletionRuntimeError =>
providerStatusError("Claude", error);
export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider { export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider {
const defaultModel = config.model ?? "claude-sonnet-4-20250514"; const {
const defaultTemperature = config.temperature ?? 0.0; defaultModel,
const maxOutput = config.maxOutput ?? 8192; defaultTemperature,
const apiKey = config.apiKey ?? process.env.CLAUDE_KEY; maxOutput,
if (apiKey === undefined || apiKey.length === 0) { apiKey,
throw new Error("Claude API key not specified"); } = Effect.runSync(loadClaudeConfig(config)) satisfies ResolvedClaudeConfig;
}
const client = new Anthropic({ apiKey }); const client = new Anthropic({ apiKey });
console.log("[Claude] LLM service initialized"); Effect.runSync(Effect.log("[Claude] LLM service initialized"));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -49,88 +80,120 @@ export function makeClaudeProvider(config: ClaudeProcessorConfig): LlmProvider {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { return Effect.runPromise(
const response = await client.messages.create({ Effect.tryPromise({
model: modelName, try: () =>
max_tokens: maxOutput, client.messages.create({
temperature: temp, model: modelName,
system, max_tokens: maxOutput,
messages: [ temperature: temp,
{ role: "user", content: prompt }, system,
], messages: [
}); { role: "user", content: prompt },
],
}),
catch: mapClaudeError,
}).pipe(
Effect.map((response): LlmResult => {
const firstContent = response.content[0];
const text = firstContent?.type === "text"
? firstContent.text
: "";
const text = response.content[0].type === "text" return {
? response.content[0].text text,
: ""; inToken: response.usage.input_tokens,
outToken: response.usage.output_tokens,
return { model: modelName,
text, };
inToken: response.usage.input_tokens, }),
outToken: response.usage.output_tokens, ),
model: modelName, );
};
} catch (err) {
if (err instanceof Anthropic.RateLimitError) {
throw tooManyRequestsError();
}
throw err;
}
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { const stream = Stream.fromEffect(
const stream = client.messages.stream({ Effect.try({
model: modelName, try: () =>
max_tokens: maxOutput, client.messages.stream({
temperature: temp,
system,
messages: [
{ role: "user", content: prompt },
],
});
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
yield {
text: event.delta.text,
inToken: null,
outToken: null,
model: modelName, model: modelName,
isFinal: false, max_tokens: maxOutput,
}; temperature: temp,
} system,
} messages: [
{ role: "user", content: prompt },
],
}),
catch: mapClaudeError,
}),
).pipe(
Stream.flatMap((anthropicStream) => {
const iterator = anthropicStream[Symbol.asyncIterator]();
const finalMessage = await stream.finalMessage(); return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
yield { "pulling",
text: "", (state) => {
inToken: finalMessage.usage.input_tokens, if (state === "done") return Effect.void as Effect.Effect<undefined>;
outToken: finalMessage.usage.output_tokens,
model: modelName, return Effect.gen(function* () {
isFinal: true, while (true) {
}; const next = yield* Effect.tryPromise({
} catch (err) { try: () => iterator.next(),
if (err instanceof Anthropic.RateLimitError) { catch: mapClaudeError,
throw tooManyRequestsError(); });
}
throw err; if (next.done === true) {
} const finalMessage = yield* Effect.tryPromise({
try: () => anthropicStream.finalMessage(),
catch: mapClaudeError,
});
return [{
text: "",
inToken: finalMessage.usage.input_tokens,
outToken: finalMessage.usage.output_tokens,
model: modelName,
isFinal: true,
}, "done"] as const;
}
const event = next.value;
if (
event.type === "content_block_delta" &&
event.delta.type === "text_delta"
) {
return [{
text: event.delta.text,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapClaudeError);
}, },
}; };
} }
export type ClaudeProcessor = ReturnType<typeof makeClaudeProcessor>; export type ClaudeProcessor = ReturnType<typeof makeClaudeProcessor>;
export function makeClaudeProcessor(config: ClaudeProcessorConfig): ReturnType<typeof makeLlmService> { export function makeClaudeProcessor(
config: ClaudeProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeClaudeProvider(config)); return makeLlmService(config, makeClaudeProvider(config));
} }
@ -146,6 +209,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -0,0 +1,101 @@
import {
TooManyRequestsError,
errorMessage,
type LlmChunk,
} from "@trustgraph/base";
import { Config, Effect } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
export class TextCompletionConfigError extends S.TaggedErrorClass<TextCompletionConfigError>()(
"TextCompletionConfigError",
{
message: S.String,
provider: S.String,
key: S.String,
},
) {}
export class TextCompletionProviderError extends S.TaggedErrorClass<TextCompletionProviderError>()(
"TextCompletionProviderError",
{
message: S.String,
provider: S.String,
},
) {}
export type TextCompletionRuntimeError =
| TextCompletionProviderError
| TooManyRequestsError;
export const optionalStringConfig = Effect.fn("TextCompletion.optionalStringConfig")(function*(
provider: string,
name: string,
) {
const value = yield* Config.string(name).pipe(
Config.option,
Effect.mapError((cause) =>
TextCompletionConfigError.make({
provider,
key: name,
message: errorMessage(cause),
})
),
);
return O.getOrUndefined(value);
});
export const requiredString = (
value: string | undefined,
provider: string,
key: string,
message: string,
) =>
value !== undefined && value.length > 0
? Effect.succeed(value)
: Effect.fail(TextCompletionConfigError.make({ provider, key, message }));
export const providerRuntimeError = (
provider: string,
error: unknown,
): TextCompletionRuntimeError =>
TextCompletionProviderError.make({
provider,
message: errorMessage(error),
});
export const providerStatusError = (
provider: string,
error: unknown,
): TextCompletionRuntimeError => {
const status = typeof error === "object" && error !== null && "status" in error
? (error as { readonly status?: unknown }).status
: undefined;
const statusCode = typeof error === "object" && error !== null && "statusCode" in error
? (error as { readonly statusCode?: unknown }).statusCode
: undefined;
return status === 429 || statusCode === 429
? TooManyRequestsError.make({ message: "Rate limit exceeded" })
: providerRuntimeError(provider, error);
};
export const toAsyncGenerator = (
iterable: AsyncIterable<LlmChunk>,
mapError: (error: unknown) => TextCompletionRuntimeError,
): AsyncGenerator<LlmChunk> => {
const iterator = iterable[Symbol.asyncIterator]();
let generator: AsyncGenerator<LlmChunk>;
generator = {
next: (value?: unknown) => iterator.next(value as never),
return: (value?: unknown) =>
iterator.return === undefined
? Promise.resolve({ done: true, value: value as LlmChunk })
: iterator.return(value as never) as Promise<IteratorResult<LlmChunk>>,
throw: (error?: unknown) =>
iterator.throw === undefined
? Effect.runPromise(Effect.fail(mapError(error))) as Promise<IteratorResult<LlmChunk>>
: iterator.throw(error) as Promise<IteratorResult<LlmChunk>>,
[Symbol.asyncIterator]: () => generator,
} as AsyncGenerator<LlmChunk>;
return generator;
};

View file

@ -17,9 +17,15 @@ import {
type ProcessorConfig, type ProcessorConfig,
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
tooManyRequestsError,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerStatusError,
requiredString,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type MistralProcessorConfig = ProcessorConfig & { export type MistralProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
@ -28,22 +34,49 @@ export type MistralProcessorConfig = ProcessorConfig & {
maxOutput?: number; maxOutput?: number;
}; };
type ResolvedMistralConfig = {
readonly defaultModel: string;
readonly defaultTemperature: number;
readonly maxOutput: number;
readonly apiKey: string;
};
const loadMistralConfig = Effect.fn("loadMistralConfig")(function*(config: MistralProcessorConfig) {
const apiKey = yield* requiredString(
config.apiKey ?? (yield* optionalStringConfig("Mistral", "MISTRAL_TOKEN")),
"Mistral",
"MISTRAL_TOKEN",
"Mistral API key not specified",
);
return {
defaultModel:
config.model ??
(yield* optionalStringConfig("Mistral", "MISTRAL_MODEL")) ??
"ministral-8b-latest",
defaultTemperature: config.temperature ?? 0.0,
maxOutput: config.maxOutput ?? 4096,
apiKey,
} satisfies ResolvedMistralConfig;
});
const mapMistralError = (error: unknown): TextCompletionRuntimeError =>
providerStatusError("Mistral", error);
export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider { export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider {
const defaultModel = const {
config.model ?? process.env.MISTRAL_MODEL ?? "ministral-8b-latest"; defaultModel,
const defaultTemperature = config.temperature ?? 0.0; defaultTemperature,
const maxOutput = config.maxOutput ?? 4096; maxOutput,
const apiKey = config.apiKey ?? process.env.MISTRAL_TOKEN; apiKey,
if (apiKey === undefined || apiKey.length === 0) { } = Effect.runSync(loadMistralConfig(config)) satisfies ResolvedMistralConfig;
throw new Error("Mistral API key not specified");
}
const client = new Mistral({ apiKey }); const client = new Mistral({ apiKey });
console.log("[Mistral] LLM service initialized"); Effect.runSync(Effect.log("[Mistral] LLM service initialized"));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -52,93 +85,114 @@ export function makeMistralProvider(config: MistralProcessorConfig): LlmProvider
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { return Effect.runPromise(
const resp = await client.chat.complete({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.complete({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
maxTokens: maxOutput, ],
}); temperature: temp,
maxTokens: maxOutput,
return { }),
text: (resp.choices?.[0]?.message?.content as string) ?? "", catch: mapMistralError,
inToken: resp.usage?.promptTokens ?? 0, }).pipe(
outToken: resp.usage?.completionTokens ?? 0, Effect.map((resp): LlmResult => ({
model: modelName, text: (resp.choices?.[0]?.message?.content as string) ?? "",
}; inToken: resp.usage?.promptTokens ?? 0,
} catch (err) { outToken: resp.usage?.completionTokens ?? 0,
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) { model: modelName,
throw tooManyRequestsError(); })),
} ),
throw err; );
}
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { const stream = Stream.fromEffect(
const stream = await client.chat.stream({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.stream({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
maxTokens: maxOutput, ],
}); temperature: temp,
maxTokens: maxOutput,
}),
catch: mapMistralError,
}),
).pipe(
Stream.flatMap((mistralStream) => {
const iterator = mistralStream[Symbol.asyncIterator]();
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
for await (const chunk of stream) { return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
const delta = chunk.data?.choices?.[0]?.delta; "pulling",
const content = delta?.content; (state) => {
if (typeof content === "string" && content.length > 0) { if (state === "done") return Effect.void as Effect.Effect<undefined>;
yield {
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
if (chunk.data?.usage !== undefined) { return Effect.gen(function* () {
totalInputTokens = chunk.data.usage.promptTokens ?? 0; while (true) {
totalOutputTokens = chunk.data.usage.completionTokens ?? 0; const next = yield* Effect.tryPromise({
} try: () => iterator.next(),
} catch: mapMistralError,
});
yield { if (next.done === true) {
text: "", return [{
inToken: totalInputTokens, text: "",
outToken: totalOutputTokens, inToken: totalInputTokens,
model: modelName, outToken: totalOutputTokens,
isFinal: true, model: modelName,
}; isFinal: true,
} catch (err) { }, "done"] as const;
if ((err as any)?.statusCode === 429 || (err as any)?.status === 429) { }
throw tooManyRequestsError();
} const chunk = next.value;
throw err; const delta = chunk.data?.choices?.[0]?.delta;
} const content = delta?.content;
if (chunk.data?.usage !== undefined) {
totalInputTokens = chunk.data.usage.promptTokens ?? 0;
totalOutputTokens = chunk.data.usage.completionTokens ?? 0;
}
if (typeof content === "string" && content.length > 0) {
return [{
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapMistralError);
}, },
}; };
} }
export type MistralProcessor = ReturnType<typeof makeMistralProcessor>; export type MistralProcessor = ReturnType<typeof makeMistralProcessor>;
export function makeMistralProcessor(config: MistralProcessorConfig): ReturnType<typeof makeLlmService> { export function makeMistralProcessor(
config: MistralProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeMistralProvider(config)); return makeLlmService(config, makeMistralProvider(config));
} }
@ -154,6 +208,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -18,32 +18,51 @@ import {
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerRuntimeError,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type OllamaProcessorConfig = ProcessorConfig & { export type OllamaProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
ollamaUrl?: string; ollamaUrl?: string;
}; };
export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider { type ResolvedOllamaConfig = {
const defaultModel = readonly defaultModel: string;
config.model ?? readonly host: string;
process.env.OLLAMA_MODEL ?? };
"qwen2.5:0.5b";
const host = const loadOllamaConfig = Effect.fn("loadOllamaConfig")(function*(config: OllamaProcessorConfig) {
config.ollamaUrl ?? return {
process.env.OLLAMA_URL ?? defaultModel:
"http://localhost:11434"; config.model ??
(yield* optionalStringConfig("Ollama", "OLLAMA_MODEL")) ??
"qwen2.5:0.5b",
host:
config.ollamaUrl ??
(yield* optionalStringConfig("Ollama", "OLLAMA_URL")) ??
"http://localhost:11434",
} satisfies ResolvedOllamaConfig;
});
const mapOllamaError = (error: unknown): TextCompletionRuntimeError =>
providerRuntimeError("Ollama", error);
export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider {
const { defaultModel, host } = Effect.runSync(loadOllamaConfig(config)) satisfies ResolvedOllamaConfig;
const client = new Ollama({ host }); const client = new Ollama({ host });
console.log( Effect.runSync(Effect.log(
`[Ollama] LLM service initialized (host=${host}, model=${defaultModel})`, `[Ollama] LLM service initialized (host=${host}, model=${defaultModel})`,
); ));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -52,73 +71,107 @@ export function makeOllamaProvider(config: OllamaProcessorConfig): LlmProvider {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt; const fullPrompt = system + "\n\n" + prompt;
const resp = await client.generate({ return Effect.runPromise(
model: modelName, Effect.tryPromise({
prompt: fullPrompt, try: () =>
stream: false, client.generate({
}); model: modelName,
prompt: fullPrompt,
return { stream: false,
text: resp.response, }),
inToken: resp.prompt_eval_count ?? 0, catch: mapOllamaError,
outToken: resp.eval_count ?? 0, }).pipe(
model: modelName, Effect.map((resp): LlmResult => ({
}; text: resp.response,
inToken: resp.prompt_eval_count ?? 0,
outToken: resp.eval_count ?? 0,
model: modelName,
})),
),
);
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
_temperature?: number, _temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const fullPrompt = system + "\n\n" + prompt; const fullPrompt = system + "\n\n" + prompt;
const stream = await client.generate({ const stream = Stream.fromEffect(
model: modelName, Effect.tryPromise({
prompt: fullPrompt, try: () =>
stream: true, client.generate({
}); model: modelName,
prompt: fullPrompt,
stream: true,
}),
catch: mapOllamaError,
}),
).pipe(
Stream.flatMap((ollamaStream) => {
const iterator = ollamaStream[Symbol.asyncIterator]();
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
for await (const chunk of stream) { return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
// Token counts accumulate across chunks; keep the latest values "pulling",
if (chunk.prompt_eval_count !== undefined) { (state) => {
totalInputTokens = chunk.prompt_eval_count; if (state === "done") return Effect.void as Effect.Effect<undefined>;
}
if (chunk.eval_count !== undefined) {
totalOutputTokens = chunk.eval_count;
}
if (chunk.response.length > 0) { return Effect.gen(function* () {
yield { while (true) {
text: chunk.response, const next = yield* Effect.tryPromise({
inToken: null, try: () => iterator.next(),
outToken: null, catch: mapOllamaError,
model: modelName, });
isFinal: false,
};
}
}
// Final chunk with accumulated token counts if (next.done === true) {
yield { return [{
text: "", text: "",
inToken: totalInputTokens, inToken: totalInputTokens,
outToken: totalOutputTokens, outToken: totalOutputTokens,
model: modelName, model: modelName,
isFinal: true, isFinal: true,
}; }, "done"] as const;
}
const chunk = next.value;
if (chunk.prompt_eval_count !== undefined) {
totalInputTokens = chunk.prompt_eval_count;
}
if (chunk.eval_count !== undefined) {
totalOutputTokens = chunk.eval_count;
}
if (chunk.response.length > 0) {
return [{
text: chunk.response,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOllamaError);
}, },
}; };
} }
export type OllamaProcessor = ReturnType<typeof makeOllamaProcessor>; export type OllamaProcessor = ReturnType<typeof makeOllamaProcessor>;
export function makeOllamaProcessor(config: OllamaProcessorConfig): ReturnType<typeof makeLlmService> { export function makeOllamaProcessor(
config: OllamaProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeOllamaProvider(config)); return makeLlmService(config, makeOllamaProvider(config));
} }
@ -134,6 +187,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -21,7 +21,14 @@ import {
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerStatusError,
requiredString,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type OpenAICompatibleProcessorConfig = ProcessorConfig & { export type OpenAICompatibleProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
@ -31,30 +38,57 @@ export type OpenAICompatibleProcessorConfig = ProcessorConfig & {
maxOutput?: number; maxOutput?: number;
}; };
type ResolvedOpenAICompatibleConfig = {
readonly defaultModel: string;
readonly defaultTemperature: number;
readonly maxOutput: number;
readonly apiKey: string;
readonly baseURL: string;
};
const loadOpenAICompatibleConfig = Effect.fn("loadOpenAICompatibleConfig")(function*(
config: OpenAICompatibleProcessorConfig,
) {
const defaultModel =
config.model ?? (yield* optionalStringConfig("OpenAI-Compatible", "OPENAI_COMPAT_MODEL")) ?? "default";
const baseURL = yield* requiredString(
config.baseUrl ?? (yield* optionalStringConfig("OpenAI-Compatible", "OPENAI_COMPAT_URL")),
"OpenAI-Compatible",
"OPENAI_COMPAT_URL",
"OpenAI-compatible server URL not specified (set OPENAI_COMPAT_URL)",
);
const apiKey =
config.apiKey ?? (yield* optionalStringConfig("OpenAI-Compatible", "OPENAI_COMPAT_KEY")) ?? "sk-no-key-required";
return {
defaultModel,
defaultTemperature: config.temperature ?? 0.0,
maxOutput: config.maxOutput ?? 4096,
apiKey,
baseURL,
} satisfies ResolvedOpenAICompatibleConfig;
});
const mapOpenAICompatibleError = (error: unknown): TextCompletionRuntimeError =>
providerStatusError("OpenAI-Compatible", error);
export function makeOpenAICompatibleProvider( export function makeOpenAICompatibleProvider(
config: OpenAICompatibleProcessorConfig, config: OpenAICompatibleProcessorConfig,
): LlmProvider { ): LlmProvider {
const defaultModel = const {
config.model ?? process.env.OPENAI_COMPAT_MODEL ?? "default"; defaultModel,
const defaultTemperature = config.temperature ?? 0.0; defaultTemperature,
const maxOutput = config.maxOutput ?? 4096; maxOutput,
apiKey,
const baseURL = config.baseUrl ?? process.env.OPENAI_COMPAT_URL; baseURL,
if (baseURL === undefined || baseURL.length === 0) { } = Effect.runSync(loadOpenAICompatibleConfig(config)) satisfies ResolvedOpenAICompatibleConfig;
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";
const client = new OpenAI({ baseURL, apiKey }); const client = new OpenAI({ baseURL, apiKey });
console.log("[OpenAI-Compatible] LLM service initialized"); Effect.runSync(Effect.log("[OpenAI-Compatible] LLM service initialized"));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -63,72 +97,105 @@ export function makeOpenAICompatibleProvider(
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
const resp = await client.chat.completions.create({ return Effect.runPromise(
model: modelName, Effect.tryPromise({
messages: [ try: () =>
{ role: "system", content: system }, client.chat.completions.create({
{ role: "user", content: prompt }, model: modelName,
], messages: [
temperature: temp, { role: "system", content: system },
max_tokens: maxOutput, { role: "user", content: prompt },
}); ],
temperature: temp,
return { max_tokens: maxOutput,
text: resp.choices[0].message.content ?? "", }),
inToken: resp.usage?.prompt_tokens ?? 0, catch: mapOpenAICompatibleError,
outToken: resp.usage?.completion_tokens ?? 0, }).pipe(
model: modelName, Effect.map((resp): LlmResult => ({
}; text: resp.choices[0].message.content ?? "",
inToken: resp.usage?.prompt_tokens ?? 0,
outToken: resp.usage?.completion_tokens ?? 0,
model: modelName,
})),
),
);
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
const stream = await client.chat.completions.create({ const stream = Stream.fromEffect(
model: modelName, Effect.tryPromise({
messages: [ try: () =>
{ role: "system", content: system }, client.chat.completions.create({
{ role: "user", content: prompt }, model: modelName,
], messages: [
temperature: temp, { role: "system", content: system },
max_tokens: maxOutput, { role: "user", content: prompt },
stream: true, ],
}); temperature: temp,
max_tokens: maxOutput,
stream: true,
}),
catch: mapOpenAICompatibleError,
}),
).pipe(
Stream.flatMap((openAIStream) => {
const iterator = openAIStream[Symbol.asyncIterator]();
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
for await (const chunk of stream) { return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
const content = chunk.choices[0]?.delta?.content; "pulling",
if (content !== null && content !== undefined && content.length > 0) { (state) => {
yield { if (state === "done") return Effect.void as Effect.Effect<undefined>;
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
if (chunk.usage !== null && chunk.usage !== undefined) { return Effect.gen(function* () {
totalInputTokens = chunk.usage.prompt_tokens; while (true) {
totalOutputTokens = chunk.usage.completion_tokens; const next = yield* Effect.tryPromise({
} try: () => iterator.next(),
} catch: mapOpenAICompatibleError,
});
yield { if (next.done === true) {
text: "", return [{
inToken: totalInputTokens, text: "",
outToken: totalOutputTokens, inToken: totalInputTokens,
model: modelName, outToken: totalOutputTokens,
isFinal: true, model: modelName,
}; isFinal: true,
}, "done"] as const;
}
const chunk = next.value;
const content = chunk.choices[0]?.delta?.content;
if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
if (content !== null && content !== undefined && content.length > 0) {
return [{
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAICompatibleError);
}, },
}; };
} }
@ -153,6 +220,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -15,9 +15,15 @@ import {
type ProcessorConfig, type ProcessorConfig,
type LlmResult, type LlmResult,
type LlmChunk, type LlmChunk,
tooManyRequestsError,
} from "@trustgraph/base"; } from "@trustgraph/base";
import { Effect, Layer } from "effect"; import { Effect, Layer, Stream } from "effect";
import {
optionalStringConfig,
providerStatusError,
requiredString,
toAsyncGenerator,
type TextCompletionRuntimeError,
} from "./common.ts";
export type OpenAIProcessorConfig = ProcessorConfig & { export type OpenAIProcessorConfig = ProcessorConfig & {
model?: string; model?: string;
@ -27,24 +33,52 @@ export type OpenAIProcessorConfig = ProcessorConfig & {
maxOutput?: number; maxOutput?: number;
}; };
type ResolvedOpenAIConfig = {
readonly defaultModel: string;
readonly defaultTemperature: number;
readonly maxOutput: number;
readonly apiKey: string;
readonly baseURL: string | undefined;
};
const loadOpenAIConfig = Effect.fn("loadOpenAIConfig")(function*(config: OpenAIProcessorConfig) {
const apiKey = yield* requiredString(
config.apiKey ?? (yield* optionalStringConfig("OpenAI", "OPENAI_TOKEN")),
"OpenAI",
"OPENAI_TOKEN",
"OpenAI API key not specified",
);
return {
defaultModel: config.model ?? "gpt-4o",
defaultTemperature: config.temperature ?? 0.0,
maxOutput: config.maxOutput ?? 4096,
apiKey,
baseURL: config.baseUrl ?? (yield* optionalStringConfig("OpenAI", "OPENAI_BASE_URL")),
} satisfies ResolvedOpenAIConfig;
});
const mapOpenAIError = (error: unknown): TextCompletionRuntimeError =>
providerStatusError("OpenAI", error);
export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider { export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider {
const defaultModel = config.model ?? "gpt-4o"; const {
const defaultTemperature = config.temperature ?? 0.0; defaultModel,
const maxOutput = config.maxOutput ?? 4096; defaultTemperature,
const apiKey = config.apiKey ?? process.env.OPENAI_TOKEN; maxOutput,
if (apiKey === undefined || apiKey.length === 0) { apiKey,
throw new Error("OpenAI API key not specified"); baseURL,
} } = Effect.runSync(loadOpenAIConfig(config)) satisfies ResolvedOpenAIConfig;
const client = new OpenAI({ const client = new OpenAI({
apiKey, apiKey,
baseURL: config.baseUrl ?? process.env.OPENAI_BASE_URL, baseURL,
}); });
console.log("[OpenAI] LLM service initialized"); Effect.runSync(Effect.log("[OpenAI] LLM service initialized"));
return { return {
generateContent: async ( generateContent: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
@ -53,94 +87,115 @@ export function makeOpenAIProvider(config: OpenAIProcessorConfig): LlmProvider {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { return Effect.runPromise(
const resp = await client.chat.completions.create({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.completions.create({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
max_completion_tokens: maxOutput, ],
}); temperature: temp,
max_completion_tokens: maxOutput,
return { }),
text: resp.choices[0].message.content ?? "", catch: mapOpenAIError,
inToken: resp.usage?.prompt_tokens ?? 0, }).pipe(
outToken: resp.usage?.completion_tokens ?? 0, Effect.map((resp): LlmResult => ({
model: modelName, text: resp.choices[0].message.content ?? "",
}; inToken: resp.usage?.prompt_tokens ?? 0,
} catch (err) { outToken: resp.usage?.completion_tokens ?? 0,
if (err instanceof OpenAI.RateLimitError) { model: modelName,
throw tooManyRequestsError(); })),
} ),
throw err; );
}
}, },
supportsStreaming: () => true, supportsStreaming: () => true,
generateContentStream: async function* ( generateContentStream: (
system: string, system: string,
prompt: string, prompt: string,
model?: string, model?: string,
temperature?: number, temperature?: number,
): AsyncGenerator<LlmChunk> { ): AsyncGenerator<LlmChunk> => {
const modelName = model ?? defaultModel; const modelName = model ?? defaultModel;
const temp = temperature ?? defaultTemperature; const temp = temperature ?? defaultTemperature;
try { const stream = Stream.fromEffect(
const stream = await client.chat.completions.create({ Effect.tryPromise({
model: modelName, try: () =>
messages: [ client.chat.completions.create({
{ role: "system", content: system }, model: modelName,
{ role: "user", content: prompt }, messages: [
], { role: "system", content: system },
temperature: temp, { role: "user", content: prompt },
max_completion_tokens: maxOutput, ],
stream: true, temperature: temp,
stream_options: { include_usage: true }, max_completion_tokens: maxOutput,
}); stream: true,
stream_options: { include_usage: true },
}),
catch: mapOpenAIError,
}),
).pipe(
Stream.flatMap((openAIStream) => {
const iterator = openAIStream[Symbol.asyncIterator]();
let totalInputTokens = 0; let totalInputTokens = 0;
let totalOutputTokens = 0; let totalOutputTokens = 0;
for await (const chunk of stream) { return Stream.unfold<"pulling" | "done", LlmChunk, TextCompletionRuntimeError, never>(
const content = chunk.choices[0]?.delta?.content; "pulling",
if (content !== null && content !== undefined && content.length > 0) { (state) => {
yield { if (state === "done") return Effect.void as Effect.Effect<undefined>;
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
};
}
if (chunk.usage !== null && chunk.usage !== undefined) { return Effect.gen(function* () {
totalInputTokens = chunk.usage.prompt_tokens; while (true) {
totalOutputTokens = chunk.usage.completion_tokens; const next = yield* Effect.tryPromise({
} try: () => iterator.next(),
} catch: mapOpenAIError,
});
yield { if (next.done === true) {
text: "", return [{
inToken: totalInputTokens, text: "",
outToken: totalOutputTokens, inToken: totalInputTokens,
model: modelName, outToken: totalOutputTokens,
isFinal: true, model: modelName,
}; isFinal: true,
} catch (err) { }, "done"] as const;
if (err instanceof OpenAI.RateLimitError) { }
throw tooManyRequestsError();
} const chunk = next.value;
throw err; const content = chunk.choices[0]?.delta?.content;
} if (chunk.usage !== null && chunk.usage !== undefined) {
totalInputTokens = chunk.usage.prompt_tokens;
totalOutputTokens = chunk.usage.completion_tokens;
}
if (content !== null && content !== undefined && content.length > 0) {
return [{
text: content,
inToken: null,
outToken: null,
model: modelName,
isFinal: false,
}, "pulling"] as const;
}
}
});
},
);
}),
);
return toAsyncGenerator(Stream.toAsyncIterable(stream), mapOpenAIError);
}, },
}; };
} }
export type OpenAIProcessor = ReturnType<typeof makeOpenAIProcessor>; export type OpenAIProcessor = ReturnType<typeof makeOpenAIProcessor>;
export function makeOpenAIProcessor(config: OpenAIProcessorConfig): ReturnType<typeof makeLlmService> { export function makeOpenAIProcessor(
config: OpenAIProcessorConfig,
): ReturnType<typeof makeLlmService> {
return makeLlmService(config, makeOpenAIProvider(config)); return makeLlmService(config, makeOpenAIProvider(config));
} }
@ -156,6 +211,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig, never, Llm>({
), ),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -167,7 +167,7 @@ export function makePromptTemplateService(config: PromptTemplateConfig): PromptT
Effect.runPromise(handler(pushedConfig, version)), Effect.runPromise(handler(pushedConfig, version)),
); );
} }
console.log("[PromptTemplate] Service initialized"); Effect.runSync(Effect.log("[PromptTemplate] Service initialized"));
return service; return service;
} }
@ -195,6 +195,6 @@ export const program = makeFlowProcessorProgram({
configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers, configHandlers: (config: PromptTemplateConfig) => promptTemplateRuntime(config).configHandlers,
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -101,7 +101,7 @@ export function makeDocEmbeddingsQueryService(config: ProcessorConfig): DocEmbed
), ),
), ),
}); });
console.log("[DocEmbeddingsQuery] Service initialized"); Effect.runSync(Effect.log("[DocEmbeddingsQuery] Service initialized"));
return service; return service;
} }
@ -113,6 +113,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantDocQuery
layer: (config) => QdrantDocEmbeddingsQueryLive(config), layer: (config) => QdrantDocEmbeddingsQueryLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -9,7 +9,8 @@
import { QdrantClient } from "@qdrant/js-client-rest"; import { QdrantClient } from "@qdrant/js-client-rest";
import { errorMessage } from "@trustgraph/base"; import { errorMessage } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect"; import { Config, Context, Effect, Layer } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export interface QdrantDocQueryConfig { export interface QdrantDocQueryConfig {
@ -30,24 +31,58 @@ export interface DocEmbeddingsQueryRequest {
limit: number; limit: number;
} }
export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocEmbeddingsQueryError>()(
"QdrantDocEmbeddingsQueryError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
const qdrantDocEmbeddingsQueryError = (operation: string, cause: unknown) =>
QdrantDocEmbeddingsQueryError.make({
operation,
message: errorMessage(cause),
cause,
});
interface ResolvedQdrantDocQueryConfig {
readonly url: string;
readonly apiKey?: string;
}
const loadQdrantDocQueryConfig = Effect.fn("QdrantDocEmbeddingsQuery.loadConfig")(function* (
config: QdrantDocQueryConfig,
) {
const envApiKey = O.getOrUndefined(yield* Config.string("QDRANT_API_KEY").pipe(Config.option));
const apiKey = config.apiKey ?? envApiKey;
return {
url: config.url ?? (yield* Config.string("QDRANT_URL").pipe(Config.withDefault("http://localhost:6333"))),
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
} satisfies ResolvedQdrantDocQueryConfig;
});
export interface QdrantDocEmbeddingsQuery { export interface QdrantDocEmbeddingsQuery {
readonly query: (request: DocEmbeddingsQueryRequest) => Promise<ChunkMatch[]>; readonly query: (request: DocEmbeddingsQueryRequest) => Promise<ChunkMatch[]>;
readonly queryEffect: (
request: DocEmbeddingsQueryRequest,
) => Effect.Effect<ReadonlyArray<ChunkMatch>, QdrantDocEmbeddingsQueryError>;
} }
export function makeQdrantDocEmbeddingsQuery( export function makeQdrantDocEmbeddingsQuery(
config: QdrantDocQueryConfig = {}, config: QdrantDocQueryConfig = {},
): QdrantDocEmbeddingsQuery { ): QdrantDocEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const resolved = Effect.runSync(loadQdrantDocQueryConfig(config));
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
const client = new QdrantClient({ const client = new QdrantClient({
url, url: resolved.url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
}); });
console.log("[QdrantDocQuery] Query service initialized"); Effect.runSync(Effect.log("[QdrantDocQuery] Query service initialized"));
const query = async (request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> => { const queryEffect = Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request: DocEmbeddingsQueryRequest) {
const { vector, user, collection, limit } = request; const { vector, user, collection, limit } = request;
if (vector.length === 0) { if (vector.length === 0) {
@ -58,18 +93,25 @@ export function makeQdrantDocEmbeddingsQuery(
const collectionName = `d_${user}_${collection}_${dim}`; const collectionName = `d_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not // Check if collection exists -- return empty if not
const exists = await client.collectionExists(collectionName); const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(collectionName),
catch: (cause) => qdrantDocEmbeddingsQueryError("collection-exists", cause),
});
if (!exists.exists) { if (!exists.exists) {
console.log( yield* Effect.log(
`[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`, `[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`,
); );
return []; return [];
} }
const searchResult = await client.search(collectionName, { const searchResult = yield* Effect.tryPromise({
vector, try: () =>
limit, client.search(collectionName, {
with_payload: true, vector,
limit,
with_payload: true,
}),
catch: (cause) => qdrantDocEmbeddingsQueryError("search", cause),
}); });
const chunks: ChunkMatch[] = []; const chunks: ChunkMatch[] = [];
@ -86,20 +128,14 @@ export function makeQdrantDocEmbeddingsQuery(
} }
return chunks; return chunks;
});
return {
query: (request) => Effect.runPromise(queryEffect(request)),
queryEffect,
}; };
return { query };
} }
export class QdrantDocEmbeddingsQueryError extends S.TaggedErrorClass<QdrantDocEmbeddingsQueryError>()(
"QdrantDocEmbeddingsQueryError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
export interface QdrantDocEmbeddingsQueryServiceShape { export interface QdrantDocEmbeddingsQueryServiceShape {
readonly query: ( readonly query: (
request: DocEmbeddingsQueryRequest, request: DocEmbeddingsQueryRequest,
@ -113,24 +149,12 @@ export class QdrantDocEmbeddingsQueryService extends Context.Service<
"@trustgraph/flow/query/embeddings/qdrant-doc/QdrantDocEmbeddingsQueryService", "@trustgraph/flow/query/embeddings/qdrant-doc/QdrantDocEmbeddingsQueryService",
) {} ) {}
const qdrantDocEmbeddingsQueryError = (operation: string, cause: unknown) =>
new QdrantDocEmbeddingsQueryError({
operation,
message: errorMessage(cause),
cause,
});
export const makeQdrantDocEmbeddingsQueryService = ( export const makeQdrantDocEmbeddingsQueryService = (
config: QdrantDocQueryConfig = {}, config: QdrantDocQueryConfig = {},
): QdrantDocEmbeddingsQueryServiceShape => { ): QdrantDocEmbeddingsQueryServiceShape => {
const query = makeQdrantDocEmbeddingsQuery(config); const query = makeQdrantDocEmbeddingsQuery(config);
return { return {
query: Effect.fn("QdrantDocEmbeddingsQuery.query")(function* (request) { query: query.queryEffect,
return yield* Effect.tryPromise({
try: () => query.query(request),
catch: (cause) => qdrantDocEmbeddingsQueryError("query", cause),
});
}),
}; };
}; };

View file

@ -102,7 +102,7 @@ export function makeGraphEmbeddingsQueryService(config: ProcessorConfig): GraphE
), ),
), ),
}); });
console.log("[GraphEmbeddingsQuery] Service initialized"); Effect.runSync(Effect.log("[GraphEmbeddingsQuery] Service initialized"));
return service; return service;
} }
@ -114,6 +114,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig & QdrantGraphQue
layer: (config) => QdrantGraphEmbeddingsQueryLive(config), layer: (config) => QdrantGraphEmbeddingsQueryLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -12,7 +12,8 @@
import { QdrantClient } from "@qdrant/js-client-rest"; import { QdrantClient } from "@qdrant/js-client-rest";
import { errorMessage, type Term } from "@trustgraph/base"; import { errorMessage, type Term } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect"; import { Config, Context, Effect, Layer } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export interface QdrantGraphQueryConfig { export interface QdrantGraphQueryConfig {
@ -32,6 +33,38 @@ export interface GraphEmbeddingsQueryRequest {
limit: number; limit: number;
} }
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
"QdrantGraphEmbeddingsQueryError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
QdrantGraphEmbeddingsQueryError.make({
operation,
message: errorMessage(cause),
cause,
});
interface ResolvedQdrantGraphQueryConfig {
readonly url: string;
readonly apiKey?: string;
}
const loadQdrantGraphQueryConfig = Effect.fn("QdrantGraphEmbeddingsQuery.loadConfig")(function* (
config: QdrantGraphQueryConfig,
) {
const envApiKey = O.getOrUndefined(yield* Config.string("QDRANT_API_KEY").pipe(Config.option));
const apiKey = config.apiKey ?? envApiKey;
return {
url: config.url ?? (yield* Config.string("QDRANT_URL").pipe(Config.withDefault("http://localhost:6333"))),
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
} satisfies ResolvedQdrantGraphQueryConfig;
});
function createTerm(value: string): Term { function createTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) { if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value }; return { type: "IRI", iri: value };
@ -41,22 +74,26 @@ function createTerm(value: string): Term {
export interface QdrantGraphEmbeddingsQuery { export interface QdrantGraphEmbeddingsQuery {
readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<EntityMatch[]>; readonly query: (request: GraphEmbeddingsQueryRequest) => Promise<EntityMatch[]>;
readonly queryEffect: (
request: GraphEmbeddingsQueryRequest,
) => Effect.Effect<ReadonlyArray<EntityMatch>, QdrantGraphEmbeddingsQueryError>;
} }
export function makeQdrantGraphEmbeddingsQuery( export function makeQdrantGraphEmbeddingsQuery(
config: QdrantGraphQueryConfig = {}, config: QdrantGraphQueryConfig = {},
): QdrantGraphEmbeddingsQuery { ): QdrantGraphEmbeddingsQuery {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const resolved = Effect.runSync(loadQdrantGraphQueryConfig(config));
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
const client = new QdrantClient({ const client = new QdrantClient({
url, url: resolved.url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
}); });
console.log("[QdrantGraphQuery] Query service initialized"); Effect.runSync(Effect.log("[QdrantGraphQuery] Query service initialized"));
const query = async (request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> => { const queryEffect = Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (
request: GraphEmbeddingsQueryRequest,
) {
const { vector, user, collection, limit } = request; const { vector, user, collection, limit } = request;
if (vector.length === 0) { if (vector.length === 0) {
@ -67,9 +104,12 @@ export function makeQdrantGraphEmbeddingsQuery(
const collectionName = `t_${user}_${collection}_${dim}`; const collectionName = `t_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not // Check if collection exists -- return empty if not
const exists = await client.collectionExists(collectionName); const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(collectionName),
catch: (cause) => qdrantGraphEmbeddingsQueryError("collection-exists", cause),
});
if (!exists.exists) { if (!exists.exists) {
console.log( yield* Effect.log(
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`, `[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
); );
return []; return [];
@ -77,10 +117,14 @@ export function makeQdrantGraphEmbeddingsQuery(
// Query 2x the limit so we have a better chance of getting `limit` // Query 2x the limit so we have a better chance of getting `limit`
// unique entities after deduplication (same heuristic as Python impl) // unique entities after deduplication (same heuristic as Python impl)
const searchResult = await client.search(collectionName, { const searchResult = yield* Effect.tryPromise({
vector, try: () =>
limit: limit * 2, client.search(collectionName, {
with_payload: true, vector,
limit: limit * 2,
with_payload: true,
}),
catch: (cause) => qdrantGraphEmbeddingsQueryError("search", cause),
}); });
const entitySet = new Set<string>(); const entitySet = new Set<string>();
@ -106,20 +150,14 @@ export function makeQdrantGraphEmbeddingsQuery(
} }
return entities; return entities;
});
return {
query: (request) => Effect.runPromise(queryEffect(request)),
queryEffect,
}; };
return { query };
} }
export class QdrantGraphEmbeddingsQueryError extends S.TaggedErrorClass<QdrantGraphEmbeddingsQueryError>()(
"QdrantGraphEmbeddingsQueryError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
export interface QdrantGraphEmbeddingsQueryServiceShape { export interface QdrantGraphEmbeddingsQueryServiceShape {
readonly query: ( readonly query: (
request: GraphEmbeddingsQueryRequest, request: GraphEmbeddingsQueryRequest,
@ -133,24 +171,12 @@ export class QdrantGraphEmbeddingsQueryService extends Context.Service<
"@trustgraph/flow/query/embeddings/qdrant-graph/QdrantGraphEmbeddingsQueryService", "@trustgraph/flow/query/embeddings/qdrant-graph/QdrantGraphEmbeddingsQueryService",
) {} ) {}
const qdrantGraphEmbeddingsQueryError = (operation: string, cause: unknown) =>
new QdrantGraphEmbeddingsQueryError({
operation,
message: errorMessage(cause),
cause,
});
export const makeQdrantGraphEmbeddingsQueryService = ( export const makeQdrantGraphEmbeddingsQueryService = (
config: QdrantGraphQueryConfig = {}, config: QdrantGraphQueryConfig = {},
): QdrantGraphEmbeddingsQueryServiceShape => { ): QdrantGraphEmbeddingsQueryServiceShape => {
const query = makeQdrantGraphEmbeddingsQuery(config); const query = makeQdrantGraphEmbeddingsQuery(config);
return { return {
query: Effect.fn("QdrantGraphEmbeddingsQuery.query")(function* (request) { query: query.queryEffect,
return yield* Effect.tryPromise({
try: () => query.query(request),
catch: (cause) => qdrantGraphEmbeddingsQueryError("query", cause),
});
}),
}; };
}; };

View file

@ -88,7 +88,7 @@ export function makeTriplesQueryService(config: ProcessorConfig): TriplesQuerySe
), ),
), ),
}); });
console.log("[TriplesQuery] Service initialized"); void Effect.runPromise(Effect.log("[TriplesQuery] Service initialized"));
return service; return service;
} }
@ -100,6 +100,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBQueryC
layer: (config) => FalkorDBTriplesQueryLive(config), layer: (config) => FalkorDBTriplesQueryLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -1,5 +1,5 @@
/** /**
* FalkorDB triples query service queries RDF triples from FalkorDB. * FalkorDB triples query service - queries RDF triples from FalkorDB.
* *
* Implements all SPO query patterns (S, P, O, SP, SO, PO, SPO, *). * Implements all SPO query patterns (S, P, O, SP, SO, PO, SPO, *).
* *
@ -8,7 +8,7 @@
import { createClient, Graph } from "falkordb"; import { createClient, Graph } from "falkordb";
import { errorMessage, type Term, type Triple } from "@trustgraph/base"; import { errorMessage, type Term, type Triple } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect"; import { Config, Context, Effect, Layer } from "effect";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export interface FalkorDBQueryConfig { export interface FalkorDBQueryConfig {
@ -19,10 +19,14 @@ export interface FalkorDBQueryConfig {
function termToValue(term: Term | undefined): string | null { function termToValue(term: Term | undefined): string | null {
if (term === undefined) return null; if (term === undefined) return null;
switch (term.type) { switch (term.type) {
case "IRI": return term.iri; case "IRI":
case "LITERAL": return term.value; return term.iri;
case "BLANK": return term.id; case "LITERAL":
default: return null; return term.value;
case "BLANK":
return term.id;
default:
return null;
} }
} }
@ -36,7 +40,6 @@ function createTerm(value: string): Term {
return { type: "LITERAL", value }; return { type: "LITERAL", value };
} }
/** Extract a string field from a FalkorDB result row (returns object with named keys). */
function field(row: unknown, key: string): string { function field(row: unknown, key: string): string {
return (row as Record<string, unknown>)?.[key] as string ?? ""; return (row as Record<string, unknown>)?.[key] as string ?? "";
} }
@ -50,231 +53,6 @@ export interface FalkorDBTriplesQuery {
) => Promise<Triple[]>; ) => Promise<Triple[]>;
} }
export function makeFalkorDBTriplesQuery(
config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQuery {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
const graph = new Graph(client, database);
const connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesQuery] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesQuery] Connection failed:`, err);
throw err;
});
const ensureConnected = async (): Promise<void> => {
await connectPromise;
};
const matchPattern = async (
out: [string, string, string][],
sv: string, pv: string, ov: string, limit: number,
): Promise<void> => {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const result = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of (result.data ?? [])) {
out.push([sv, pv, ov]);
}
}
};
const matchSP = async (
out: [string, string, string][],
sv: string, pv: string, limit: number,
): Promise<void> => {
// Literals
const litResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
// Nodes
const nodeResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, pv, field(rec, "dest")]);
}
};
const matchSO = async (
out: [string, string, string][],
sv: string, ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([sv, field(rec, "rel"), ov]);
}
}
};
const matchPO = async (
out: [string, string, string][],
pv: string, ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), pv, ov]);
}
}
};
const matchS = async (
out: [string, string, string][],
sv: string, limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
};
const matchP = async (
out: [string, string, string][],
pv: string, limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
};
const matchO = async (
out: [string, string, string][],
ov: string, limit: number,
): Promise<void> => {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of (result.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), ov]);
}
}
};
const matchAll = async (
out: [string, string, string][],
limit: number,
): Promise<void> => {
const litResult = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of (litResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
const nodeResult = await graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of (nodeResult.data ?? [])) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
};
const queryTriples = async (
s?: Term,
p?: Term,
o?: Term,
limit = 100,
): Promise<Triple[]> => {
await ensureConnected();
const sv = termToValue(s);
const pv = termToValue(p);
const ov = termToValue(o);
const rawTriples: [string, string, string][] = [];
// Query both Node and Literal targets for each pattern
if (sv !== null && pv !== null && ov !== null) {
// SPO — exact match
await matchPattern(rawTriples, sv, pv, ov, limit);
} else if (sv !== null && pv !== null) {
// SP — known subject + predicate
await matchSP(rawTriples, sv, pv, limit);
} else if (sv !== null && ov !== null) {
// SO — known subject + object
await matchSO(rawTriples, sv, ov, limit);
} else if (pv !== null && ov !== null) {
// PO — known predicate + object
await matchPO(rawTriples, pv, ov, limit);
} else if (sv !== null) {
// S only
await matchS(rawTriples, sv, limit);
} else if (pv !== null) {
// P only
await matchP(rawTriples, pv, limit);
} else if (ov !== null) {
// O only
await matchO(rawTriples, ov, limit);
} else {
// Wildcard — all triples
await matchAll(rawTriples, limit);
}
return rawTriples
.filter(([s, p, o]) => s !== null && p !== null && o !== null)
.slice(0, limit)
.map(([s, p, o]) => ({
s: createTerm(s),
p: createTerm(p),
o: createTerm(o),
}));
};
return { queryTriples };
}
export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()( export class FalkorDBTriplesQueryError extends S.TaggedErrorClass<FalkorDBTriplesQueryError>()(
"FalkorDBTriplesQueryError", "FalkorDBTriplesQueryError",
{ {
@ -300,31 +78,358 @@ export class FalkorDBTriplesQueryService extends Context.Service<
"@trustgraph/flow/query/triples/falkordb/FalkorDBTriplesQueryService", "@trustgraph/flow/query/triples/falkordb/FalkorDBTriplesQueryService",
) {} ) {}
const falkorDBTriplesQueryError = (operation: string, cause: unknown) => const falkorDBTriplesQueryError = (operation: string, cause: unknown): FalkorDBTriplesQueryError =>
new FalkorDBTriplesQueryError({ FalkorDBTriplesQueryError.make({
operation, operation,
message: errorMessage(cause), message: errorMessage(cause),
cause, cause,
}); });
export const makeFalkorDBTriplesQueryService = ( interface FalkorDBQueryConnection {
readonly graph: Graph;
}
type FalkorDBQueryOptions = Parameters<Graph["query"]>[1];
const resolveFalkorDBQueryConfig = Effect.fn("FalkorDBTriplesQuery.resolveConfig")(function* (
config: FalkorDBQueryConfig,
) {
const url = config.url ?? (yield* Config.string("FALKORDB_URL").pipe(
Config.withDefault("redis://localhost:6379"),
Effect.mapError((cause) => falkorDBTriplesQueryError("config", cause)),
));
return {
url,
database: config.database ?? "falkordb",
};
});
const connectFalkorDBTriplesQuery = (
config: FalkorDBQueryConfig,
): Effect.Effect<FalkorDBQueryConnection, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const { url, database } = yield* resolveFalkorDBQueryConfig(config);
const { client, graph } = yield* Effect.try({
try: () => {
const client = createClient({ url });
return { client, graph: new Graph(client, database) };
},
catch: (cause) => falkorDBTriplesQueryError("create-client", cause),
});
yield* Effect.tryPromise({
try: () => client.connect(),
catch: (cause) => falkorDBTriplesQueryError("connect", cause),
}).pipe(
Effect.tapError((error) =>
Effect.logError("[FalkorDBTriplesQuery] Connection failed", {
error: error.message,
operation: error.operation,
})
),
);
yield* Effect.log(`[FalkorDBTriplesQuery] Connected to ${url}, graph: ${database}`);
return { graph };
});
const queryRows = (
graph: Graph,
operation: string,
query: string,
options?: FalkorDBQueryOptions,
): Effect.Effect<ReadonlyArray<unknown>, FalkorDBTriplesQueryError> =>
Effect.tryPromise({
try: () => graph.query<unknown>(query, options),
catch: (cause) => falkorDBTriplesQueryError(operation, cause),
}).pipe(
Effect.map((result) => result.data ?? []),
);
const matchPattern = (
graph: Graph,
out: [string, string, string][],
sv: string,
pv: string,
ov: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const rows = yield* queryRows(
graph,
"match-spo",
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of rows) {
out.push([sv, pv, ov]);
}
}
});
const matchSP = (
graph: Graph,
out: [string, string, string][],
sv: string,
pv: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const litRows = yield* queryRows(
graph,
"match-sp-literal",
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of litRows) {
out.push([sv, pv, field(rec, "dest")]);
}
const nodeRows = yield* queryRows(
graph,
"match-sp-node",
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of nodeRows) {
out.push([sv, pv, field(rec, "dest")]);
}
});
const matchSO = (
graph: Graph,
out: [string, string, string][],
sv: string,
ov: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const rows = yield* queryRows(
graph,
"match-so",
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of rows) {
out.push([sv, field(rec, "rel"), ov]);
}
}
});
const matchPO = (
graph: Graph,
out: [string, string, string][],
pv: string,
ov: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const rows = yield* queryRows(
graph,
"match-po",
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of rows) {
out.push([field(rec, "src"), pv, ov]);
}
}
});
const matchS = (
graph: Graph,
out: [string, string, string][],
sv: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const litRows = yield* queryRows(
graph,
"match-s-literal",
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of litRows) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
const nodeRows = yield* queryRows(
graph,
"match-s-node",
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of nodeRows) {
out.push([sv, field(rec, "rel"), field(rec, "dest")]);
}
});
const matchP = (
graph: Graph,
out: [string, string, string][],
pv: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const litRows = yield* queryRows(
graph,
"match-p-literal",
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of litRows) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
const nodeRows = yield* queryRows(
graph,
"match-p-node",
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of nodeRows) {
out.push([field(rec, "src"), pv, field(rec, "dest")]);
}
});
const matchO = (
graph: Graph,
out: [string, string, string][],
ov: string,
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const rows = yield* queryRows(
graph,
"match-o",
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of rows) {
out.push([field(rec, "src"), field(rec, "rel"), ov]);
}
}
});
const matchAll = (
graph: Graph,
out: [string, string, string][],
limit: number,
): Effect.Effect<void, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const litRows = yield* queryRows(
graph,
"match-all-literal",
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of litRows) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
const nodeRows = yield* queryRows(
graph,
"match-all-node",
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of nodeRows) {
out.push([field(rec, "src"), field(rec, "rel"), field(rec, "dest")]);
}
});
const queryTriplesEffect = (
getConnection: () => Effect.Effect<FalkorDBQueryConnection, FalkorDBTriplesQueryError>,
s: Term | undefined,
p: Term | undefined,
o: Term | undefined,
limit: number,
): Effect.Effect<ReadonlyArray<Triple>, FalkorDBTriplesQueryError> =>
Effect.gen(function* () {
const { graph } = yield* getConnection();
const sv = termToValue(s);
const pv = termToValue(p);
const ov = termToValue(o);
const rawTriples: [string, string, string][] = [];
if (sv !== null && pv !== null && ov !== null) {
yield* matchPattern(graph, rawTriples, sv, pv, ov, limit);
} else if (sv !== null && pv !== null) {
yield* matchSP(graph, rawTriples, sv, pv, limit);
} else if (sv !== null && ov !== null) {
yield* matchSO(graph, rawTriples, sv, ov, limit);
} else if (pv !== null && ov !== null) {
yield* matchPO(graph, rawTriples, pv, ov, limit);
} else if (sv !== null) {
yield* matchS(graph, rawTriples, sv, limit);
} else if (pv !== null) {
yield* matchP(graph, rawTriples, pv, limit);
} else if (ov !== null) {
yield* matchO(graph, rawTriples, ov, limit);
} else {
yield* matchAll(graph, rawTriples, limit);
}
return rawTriples
.slice(0, limit)
.map(([subject, predicate, object]) => ({
s: createTerm(subject),
p: createTerm(predicate),
o: createTerm(object),
}));
});
const makeFalkorDBTriplesQueryEffect = (
config: FalkorDBQueryConfig = {}, config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQueryServiceShape => { ): FalkorDBTriplesQueryServiceShape => {
const query = makeFalkorDBTriplesQuery(config); let cachedConnection: Effect.Effect<FalkorDBQueryConnection, FalkorDBTriplesQueryError> | undefined;
const getConnection = Effect.fn("FalkorDBTriplesQuery.connection")(function* () {
if (cachedConnection === undefined) {
cachedConnection = yield* Effect.cached(connectFalkorDBTriplesQuery(config));
}
return yield* cachedConnection;
});
return { return {
queryTriples: Effect.fn("FalkorDBTriplesQuery.queryTriples")(( queryTriples: Effect.fn("FalkorDBTriplesQuery.queryTriples")((
s: Term | undefined, s: Term | undefined,
p: Term | undefined, p: Term | undefined,
o: Term | undefined, o: Term | undefined,
limit: number, limit: number,
) => ) => queryTriplesEffect(getConnection, s, p, o, limit)),
Effect.tryPromise({
try: () => query.queryTriples(s, p, o, limit),
catch: (cause) => falkorDBTriplesQueryError("query-triples", cause),
})),
}; };
}; };
export function makeFalkorDBTriplesQuery(
config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQuery {
const query = makeFalkorDBTriplesQueryEffect(config);
return {
queryTriples: (s, p, o, limit = 100) =>
Effect.runPromise(query.queryTriples(s, p, o, limit)).then((triples) => Array.from(triples)),
};
}
export const makeFalkorDBTriplesQueryService = (
config: FalkorDBQueryConfig = {},
): FalkorDBTriplesQueryServiceShape => makeFalkorDBTriplesQueryEffect(config);
export const FalkorDBTriplesQueryLive = ( export const FalkorDBTriplesQueryLive = (
config: FalkorDBQueryConfig = {}, config: FalkorDBQueryConfig = {},
): Layer.Layer<FalkorDBTriplesQueryService> => ): Layer.Layer<FalkorDBTriplesQueryService> =>

View file

@ -161,6 +161,6 @@ export const program = makeFlowProcessorProgram({
layer: () => DocumentRagLive, layer: () => DocumentRagLive,
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -56,7 +56,7 @@ export class DocumentRagEngine extends Context.Service<DocumentRagEngine, Docume
) {} ) {}
const documentRagError = (operation: string, cause: unknown) => const documentRagError = (operation: string, cause: unknown) =>
new DocumentRagEngineError({ DocumentRagEngineError.make({
operation, operation,
cause, cause,
message: errorMessage(cause), message: errorMessage(cause),
@ -68,11 +68,7 @@ export function makeDocumentRagEngine(): DocumentRagEngineShape {
clients: DocumentRagClients, clients: DocumentRagClients,
queryText: string, queryText: string,
options?: DocumentRagQueryOptions, options?: DocumentRagQueryOptions,
) => ) => queryDocumentRag(clients, queryText, options),
Effect.tryPromise({
try: () => queryDocumentRag(clients, queryText, options),
catch: (cause) => documentRagError("query", cause),
}),
), ),
}; };
} }
@ -97,40 +93,54 @@ export function makeDocumentRag(clients: DocumentRagClients): DocumentRag {
}; };
} }
async function queryDocumentRag( function queryDocumentRag(
clients: DocumentRagClients, clients: DocumentRagClients,
queryText: string, queryText: string,
options?: DocumentRagQueryOptions, options?: DocumentRagQueryOptions,
): Promise<string> { ): Effect.Effect<string, DocumentRagEngineError> {
const collection = options?.collection ?? "default"; return Effect.gen(function* () {
const collection = options?.collection ?? "default";
const embResp = await clients.embeddings.request({ text: [queryText] }); const embResp = yield* Effect.tryPromise({
const vectors = embResp.vectors; try: () => clients.embeddings.request({ text: [queryText] }),
catch: (cause) => documentRagError("embeddings", cause),
});
const vectors = embResp.vectors;
const docResp = await clients.docEmbeddings.request({ const docResp = yield* Effect.tryPromise({
vectors, try: () => clients.docEmbeddings.request({
limit: 10, vectors,
collection, limit: 10,
user: "default", collection,
user: "default",
}),
catch: (cause) => documentRagError("document-embeddings", cause),
});
const chunks = docResp.chunks ?? [];
yield* Effect.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
const context = chunks
.flatMap((chunk) =>
chunk.content !== undefined && chunk.content.length > 0 ? [chunk.content] : [],
)
.join("\n\n---\n\n");
const promptResp = yield* Effect.tryPromise({
try: () => clients.prompt.request({
name: "document-rag-synthesize",
variables: { query: queryText, context },
}),
catch: (cause) => documentRagError("prompt", cause),
});
const resp = yield* Effect.tryPromise({
try: () => clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
}),
catch: (cause) => documentRagError("llm", cause),
});
return resp.response;
}); });
const chunks = docResp.chunks ?? [];
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
const context = chunks
.flatMap((chunk) =>
chunk.content !== undefined && chunk.content.length > 0 ? [chunk.content] : [],
)
.join("\n\n---\n\n");
const promptResp = await clients.prompt.request({
name: "document-rag-synthesize",
variables: { query: queryText, context },
});
const resp = await clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
});
return resp.response;
} }

View file

@ -192,6 +192,6 @@ export const program = makeFlowProcessorProgram({
layer: () => GraphRagLive, layer: () => GraphRagLive,
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -86,7 +86,7 @@ export class GraphRagEngine extends Context.Service<GraphRagEngine, GraphRagEngi
) {} ) {}
const graphRagError = (operation: string, cause: unknown) => const graphRagError = (operation: string, cause: unknown) =>
new GraphRagEngineError({ GraphRagEngineError.make({
operation, operation,
cause, cause,
message: errorMessage(cause), message: errorMessage(cause),
@ -110,11 +110,7 @@ export function makeGraphRagEngine(): GraphRagEngineShape {
queryText: string, queryText: string,
options?: GraphRagQueryOptions, options?: GraphRagQueryOptions,
config?: GraphRagConfig, config?: GraphRagConfig,
) => ) => queryGraphRag(clients, queryText, options, config),
Effect.tryPromise({
try: () => queryGraphRag(clients, queryText, options, config),
catch: (cause) => graphRagError("query", cause),
}),
), ),
}; };
} }
@ -142,239 +138,283 @@ export function makeGraphRag(
}; };
} }
async function queryGraphRag( function queryGraphRag(
clients: GraphRagClients, clients: GraphRagClients,
queryText: string, queryText: string,
options?: GraphRagQueryOptions, options?: GraphRagQueryOptions,
rawConfig?: GraphRagConfig, rawConfig?: GraphRagConfig,
): Promise<GraphRagResult> { ): Effect.Effect<GraphRagResult, GraphRagEngineError> {
const config = normalizeGraphRagConfig(rawConfig); return Effect.gen(function* () {
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`); const config = normalizeGraphRagConfig(rawConfig);
yield* Effect.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
const concepts = await extractConcepts(clients, queryText); const concepts = yield* extractConcepts(clients, queryText);
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`); yield* Effect.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
const vectors = await getVectors(clients, concepts); const vectors = yield* getVectors(clients, concepts);
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`); yield* Effect.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
const entities = await getEntities(clients, config, vectors, options?.collection); const entities = yield* getEntities(clients, config, vectors, options?.collection);
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`); yield* Effect.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
const subgraph = await followEdges(clients, config, entities, options?.collection); const subgraph = yield* followEdges(clients, config, entities, options?.collection);
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`); yield* Effect.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
const scoredEdges = await scoreEdges(clients, config, queryText, subgraph); const scoredEdges = yield* scoreEdges(clients, config, queryText, subgraph);
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`); yield* Effect.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`); yield* Effect.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
const answer = await synthesize( const answer = yield* synthesize(
clients, clients,
queryText, queryText,
scoredEdges, scoredEdges,
options?.chunkCallback, options?.chunkCallback,
); );
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`); yield* Effect.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
return { answer, subgraph: scoredEdges }; return { answer, subgraph: scoredEdges };
}
async function extractConcepts(clients: GraphRagClients, query: string): Promise<string[]> {
const promptResp = await clients.prompt.request({
name: "extract-concepts",
variables: { query },
}); });
}
const llmResp = await clients.llm.request({ function extractConcepts(clients: GraphRagClients, query: string): Effect.Effect<string[], GraphRagEngineError> {
system: promptResp.system, return Effect.gen(function* () {
prompt: promptResp.prompt, const promptResp = yield* Effect.tryPromise({
try: () => clients.prompt.request({
name: "extract-concepts",
variables: { query },
}),
catch: (cause) => graphRagError("extract-concepts-prompt", cause),
});
const llmResp = yield* Effect.tryPromise({
try: () => clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
}),
catch: (cause) => graphRagError("extract-concepts-llm", cause),
});
return llmResp.response
.split("\n")
.map((concept) => concept.trim())
.filter((concept) => concept.length > 0);
}); });
return llmResp.response
.split("\n")
.map((concept) => concept.trim())
.filter((concept) => concept.length > 0);
} }
async function getVectors(clients: GraphRagClients, concepts: string[]): Promise<number[][]> { function getVectors(clients: GraphRagClients, concepts: string[]): Effect.Effect<number[][], GraphRagEngineError> {
const resp = await clients.embeddings.request({ text: concepts }); return Effect.gen(function* () {
return resp.vectors; const resp = yield* Effect.tryPromise({
try: () => clients.embeddings.request({ text: concepts }),
catch: (cause) => graphRagError("get-vectors", cause),
});
return resp.vectors;
});
} }
async function getEntities( function getEntities(
clients: GraphRagClients, clients: GraphRagClients,
config: NormalizedGraphRagConfig, config: NormalizedGraphRagConfig,
vectors: number[][], vectors: number[][],
collection?: string, collection?: string,
): Promise<Term[]> { ): Effect.Effect<Term[], GraphRagEngineError> {
const resp = await clients.graphEmbeddings.request({ return Effect.gen(function* () {
vectors, const resp = yield* Effect.tryPromise({
user: "default", try: () => clients.graphEmbeddings.request({
collection: collection ?? "default", vectors,
limit: config.entityLimit, user: "default",
collection: collection ?? "default",
limit: config.entityLimit,
}),
catch: (cause) => graphRagError("get-entities", cause),
});
return resp.entities;
}); });
return resp.entities;
} }
async function followEdges( function followEdges(
clients: GraphRagClients, clients: GraphRagClients,
config: NormalizedGraphRagConfig, config: NormalizedGraphRagConfig,
entities: Term[], entities: Term[],
collection?: string, collection?: string,
): Promise<Triple[]> { ): Effect.Effect<Triple[], GraphRagEngineError> {
const visited = new Set<string>(); return Effect.gen(function* () {
const subgraph: Triple[] = []; const visited = new Set<string>();
let currentLevel = new Set<string>( const subgraph: Triple[] = [];
entities.map((entity) => termToString(entity)), let currentLevel = new Set<string>(
); entities.map((entity) => termToString(entity)),
);
for (let depth = 0; depth < config.maxPathLength; depth++) { for (let depth = 0; depth < config.maxPathLength; depth++) {
if (currentLevel.size === 0 || subgraph.length >= config.maxSubgraphSize) { if (currentLevel.size === 0 || subgraph.length >= config.maxSubgraphSize) {
break; break;
} }
const unvisited = [...currentLevel].filter((entity) => !visited.has(entity)); const unvisited = [...currentLevel].filter((entity) => !visited.has(entity));
if (unvisited.length === 0) break; if (unvisited.length === 0) break;
const queries = unvisited.map((entityStr) => { const queries = unvisited.map((entityStr) => {
const term = stringToTerm(entityStr); const term = stringToTerm(entityStr);
const request: TriplesQueryRequest = { const request: TriplesQueryRequest = {
s: term, s: term,
limit: config.tripleLimit, limit: config.tripleLimit,
...(collection !== undefined ? { collection } : {}), ...(collection !== undefined ? { collection } : {}),
}; };
return clients.triples.request(request); return Effect.tryPromise({
}); try: () => clients.triples.request(request),
catch: (cause) => graphRagError("follow-edges-query", cause),
});
});
const results = await Promise.all(queries); const results = yield* Effect.all(queries);
const nextLevel = new Set<string>(); const nextLevel = new Set<string>();
for (const result of results) { for (const result of results) {
for (const triple of result.triples) { for (const triple of result.triples) {
subgraph.push(triple); subgraph.push(triple);
if (depth < config.maxPathLength - 1) { if (depth < config.maxPathLength - 1) {
const objStr = termToString(triple.o); const objStr = termToString(triple.o);
if (!visited.has(objStr)) { if (!visited.has(objStr)) {
nextLevel.add(objStr); nextLevel.add(objStr);
}
}
if (subgraph.length >= config.maxSubgraphSize) {
return subgraph;
} }
} }
if (subgraph.length >= config.maxSubgraphSize) {
return subgraph;
}
} }
for (const entity of currentLevel) {
visited.add(entity);
}
currentLevel = nextLevel;
} }
for (const entity of currentLevel) { return subgraph.slice(0, config.maxSubgraphSize);
visited.add(entity); });
}
currentLevel = nextLevel;
}
return subgraph.slice(0, config.maxSubgraphSize);
} }
async function scoreEdges( function scoreEdges(
clients: GraphRagClients, clients: GraphRagClients,
config: NormalizedGraphRagConfig, config: NormalizedGraphRagConfig,
query: string, query: string,
triples: Triple[], triples: Triple[],
): Promise<Triple[]> { ): Effect.Effect<Triple[], GraphRagEngineError> {
if (triples.length === 0) return []; return Effect.gen(function* () {
if (triples.length === 0) return [];
if (triples.length <= 500) { if (triples.length <= 500) {
console.log(`[GraphRag] Skipping edge scoring - ${triples.length} triples fits in context directly`); yield* Effect.log(`[GraphRag] Skipping edge scoring - ${triples.length} triples fits in context directly`);
return triples; return triples;
}
const edgeDescriptions = triples.map((triple, index) => ({
id: String(index),
s: termToString(triple.s),
p: termToString(triple.p),
o: termToString(triple.o),
}));
const toScore = edgeDescriptions.slice(0, config.edgeScoreLimit);
const knowledgeJson = JSON.stringify(toScore, null, 2);
const promptResp = await clients.prompt.request({
name: "kg-edge-scoring",
variables: {
query,
knowledge: knowledgeJson,
},
});
const llmResp = await clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
});
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${llmResp.response.slice(0, 500)}`);
const scored = parseScoredEdges(llmResp.response);
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, config.edgeLimit);
const result: Triple[] = [];
for (const entry of topN) {
const idx = Number.parseInt(entry.id, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < triples.length) {
result.push(triples[idx]);
} }
}
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`); const edgeDescriptions = triples.map((triple, index) => ({
id: String(index),
s: termToString(triple.s),
p: termToString(triple.p),
o: termToString(triple.o),
}));
if (result.length === 0) { const toScore = edgeDescriptions.slice(0, config.edgeScoreLimit);
return triples.slice(0, config.edgeLimit); const knowledgeJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(toScore).pipe(
} Effect.mapError((cause) => graphRagError("edge-score-encode", cause)),
);
return result; const promptResp = yield* Effect.tryPromise({
try: () => clients.prompt.request({
name: "kg-edge-scoring",
variables: {
query,
knowledge: knowledgeJson,
},
}),
catch: (cause) => graphRagError("edge-score-prompt", cause),
});
const llmResp = yield* Effect.tryPromise({
try: () => clients.llm.request({
system: promptResp.system,
prompt: promptResp.prompt,
}),
catch: (cause) => graphRagError("edge-score-llm", cause),
});
yield* Effect.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${llmResp.response.slice(0, 500)}`);
const scored = parseScoredEdges(llmResp.response);
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, config.edgeLimit);
const result: Triple[] = [];
for (const entry of topN) {
const idx = Number.parseInt(entry.id, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < triples.length) {
result.push(triples[idx]);
}
}
yield* Effect.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
if (result.length === 0) {
return triples.slice(0, config.edgeLimit);
}
return result;
});
} }
async function synthesize( function synthesize(
clients: GraphRagClients, clients: GraphRagClients,
query: string, query: string,
edges: Triple[], edges: Triple[],
chunkCallback?: ChunkCallback, chunkCallback?: ChunkCallback,
): Promise<string> { ): Effect.Effect<string, GraphRagEngineError> {
const context = edges return Effect.gen(function* () {
.map((triple) => `${termToString(triple.s)} -> ${termToString(triple.p)} -> ${termToString(triple.o)}`) const context = edges
.join("\n"); .map((triple) => `${termToString(triple.s)} -> ${termToString(triple.p)} -> ${termToString(triple.o)}`)
.join("\n");
const promptResp = await clients.prompt.request({ const promptResp = yield* Effect.tryPromise({
name: "graph-rag-synthesize", try: () => clients.prompt.request({
variables: { query, context }, name: "graph-rag-synthesize",
}); variables: { query, context },
}),
catch: (cause) => graphRagError("synthesize-prompt", cause),
});
if (chunkCallback !== undefined) { if (chunkCallback !== undefined) {
let fullText = ""; let fullText = "";
await clients.llm.request( yield* Effect.tryPromise({
{ try: () => clients.llm.request(
{
system: promptResp.system,
prompt: promptResp.prompt,
streaming: true,
},
{
recipient: (resp) => {
if (resp.response.length === 0) return Promise.resolve(resp.endOfStream === true);
fullText += resp.response;
return chunkCallback(resp.response, resp.endOfStream === true).then(() => resp.endOfStream === true);
},
},
),
catch: (cause) => graphRagError("synthesize-stream", cause),
});
return fullText;
}
const resp = yield* Effect.tryPromise({
try: () => clients.llm.request({
system: promptResp.system, system: promptResp.system,
prompt: promptResp.prompt, prompt: promptResp.prompt,
streaming: true, }),
}, catch: (cause) => graphRagError("synthesize-llm", cause),
{ });
recipient: async (resp) => {
if (resp.response.length > 0) {
fullText += resp.response;
await chunkCallback(resp.response, resp.endOfStream === true);
}
return resp.endOfStream === true;
},
},
);
return fullText;
}
const resp = await clients.llm.request({ return resp.response;
system: promptResp.system,
prompt: promptResp.prompt,
}); });
return resp.response;
} }
const ScoredEdge = S.Struct({ const ScoredEdge = S.Struct({

View file

@ -23,8 +23,8 @@ export function readTextFile(path: string): Promise<string> {
return Bun.file(path).text(); return Bun.file(path).text();
} }
export async function readBinaryFile(path: string): Promise<Uint8Array> { export function readBinaryFile(path: string): Promise<Uint8Array> {
return new Uint8Array(await Bun.file(path).arrayBuffer()); return Bun.file(path).arrayBuffer().then((buffer) => new Uint8Array(buffer));
} }
export function writeTextFile(path: string, data: string): Promise<void> { export function writeTextFile(path: string, data: string): Promise<void> {

View file

@ -103,7 +103,7 @@ export function makeGraphEmbeddingsStoreService(config: ProcessorConfig): GraphE
), ),
), ),
}); });
console.log("[GraphEmbeddingsStore] Service initialized"); Effect.runSync(Effect.log("[GraphEmbeddingsStore] Service initialized"));
return service; return service;
} }
@ -119,6 +119,6 @@ export const program = makeFlowProcessorProgram<
layer: (config) => QdrantGraphEmbeddingsStoreLive(config), layer: (config) => QdrantGraphEmbeddingsStoreLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -9,6 +9,10 @@
*/ */
import { QdrantClient } from "@qdrant/js-client-rest"; import { QdrantClient } from "@qdrant/js-client-rest";
import { errorMessage } from "@trustgraph/base";
import { Config, Effect, Random } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema";
export interface QdrantDocEmbeddingsConfig { export interface QdrantDocEmbeddingsConfig {
url?: string; url?: string;
@ -27,43 +31,110 @@ export interface DocEmbeddingsMessage {
chunks: DocEmbeddingChunk[]; chunks: DocEmbeddingChunk[];
} }
export class QdrantDocEmbeddingsStoreError extends S.TaggedErrorClass<QdrantDocEmbeddingsStoreError>()(
"QdrantDocEmbeddingsStoreError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
const qdrantDocEmbeddingsStoreError = (operation: string, cause: unknown) =>
QdrantDocEmbeddingsStoreError.make({
operation,
message: errorMessage(cause),
cause,
});
interface ResolvedQdrantDocEmbeddingsConfig {
readonly url: string;
readonly apiKey?: string;
}
const loadQdrantDocEmbeddingsConfig = Effect.fn("QdrantDocEmbeddings.loadConfig")(function* (
config: QdrantDocEmbeddingsConfig,
) {
const envApiKey = O.getOrUndefined(yield* Config.string("QDRANT_API_KEY").pipe(Config.option));
const apiKey = config.apiKey ?? envApiKey;
return {
url: config.url ?? (yield* Config.string("QDRANT_URL").pipe(Config.withDefault("http://localhost:6333"))),
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
} satisfies ResolvedQdrantDocEmbeddingsConfig;
});
const randomHex = Effect.fn("QdrantDocEmbeddings.randomHex")(function* (digits: number) {
let result = "";
for (let index = 0; index < digits; index++) {
const value = yield* Random.nextIntBetween(0, 16);
result += value.toString(16);
}
return result;
});
const randomPointId = Effect.fn("QdrantDocEmbeddings.randomPointId")(function* () {
const part1 = yield* randomHex(8);
const part2 = yield* randomHex(4);
const versionRest = yield* randomHex(3);
const variant = yield* Random.nextIntBetween(8, 12);
const variantRest = yield* randomHex(3);
const part5 = yield* randomHex(12);
return `${part1}-${part2}-4${versionRest}-${variant.toString(16)}${variantRest}-${part5}`;
});
export interface QdrantDocEmbeddingsStore { export interface QdrantDocEmbeddingsStore {
readonly store: (message: DocEmbeddingsMessage) => Promise<void>; readonly store: (message: DocEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>; readonly deleteCollection: (user: string, collection: string) => Promise<void>;
readonly storeEffect: (
message: DocEmbeddingsMessage,
) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>;
readonly deleteCollectionEffect: (
user: string,
collection: string,
) => Effect.Effect<void, QdrantDocEmbeddingsStoreError>;
} }
export function makeQdrantDocEmbeddingsStore( export function makeQdrantDocEmbeddingsStore(
config: QdrantDocEmbeddingsConfig = {}, config: QdrantDocEmbeddingsConfig = {},
): QdrantDocEmbeddingsStore { ): QdrantDocEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const resolved = Effect.runSync(loadQdrantDocEmbeddingsConfig(config));
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
const client = new QdrantClient({ const client = new QdrantClient({
url, url: resolved.url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
}); });
const knownCollections = new Set<string>(); const knownCollections = new Set<string>();
console.log("[QdrantDocEmbeddings] Store initialized"); Effect.runSync(Effect.log("[QdrantDocEmbeddings] Store initialized"));
const collectionName = (user: string, collection: string, dim: number): string => const collectionName = (user: string, collection: string, dim: number): string =>
`d_${user}_${collection}_${dim}`; `d_${user}_${collection}_${dim}`;
const ensureCollection = async (name: string, dim: number): Promise<void> => { const ensureCollectionEffect = Effect.fn("QdrantDocEmbeddings.ensureCollection")(function* (
name: string,
dim: number,
) {
if (knownCollections.has(name)) return; if (knownCollections.has(name)) return;
const exists = await client.collectionExists(name); const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(name),
catch: (cause) => qdrantDocEmbeddingsStoreError("collection-exists", cause),
});
if (!exists.exists) { if (!exists.exists) {
console.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`); yield* Effect.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`);
await client.createCollection(name, { yield* Effect.tryPromise({
vectors: { size: dim, distance: "Cosine" }, try: () =>
client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
}),
catch: (cause) => qdrantDocEmbeddingsStoreError("create-collection", cause),
}); });
} }
knownCollections.add(name); knownCollections.add(name);
}; });
const store = async (message: DocEmbeddingsMessage): Promise<void> => { const storeEffect = Effect.fn("QdrantDocEmbeddings.store")(function* (message: DocEmbeddingsMessage) {
for (const chunk of message.chunks) { for (const chunk of message.chunks) {
if (chunk.chunkId.length === 0) continue; if (chunk.chunkId.length === 0) continue;
if (chunk.vector.length === 0) continue; if (chunk.vector.length === 0) continue;
@ -71,48 +142,68 @@ export function makeQdrantDocEmbeddingsStore(
const dim = chunk.vector.length; const dim = chunk.vector.length;
const name = collectionName(message.user, message.collection, dim); const name = collectionName(message.user, message.collection, dim);
await ensureCollection(name, dim); yield* ensureCollectionEffect(name, dim);
await client.upsert(name, { const id = yield* randomPointId();
points: [ yield* Effect.tryPromise({
{ try: () =>
id: crypto.randomUUID(), client.upsert(name, {
vector: chunk.vector, points: [
payload: { {
chunk_id: chunk.chunkId, id,
...(chunk.content !== undefined && chunk.content.length > 0 vector: chunk.vector,
? { content: chunk.content } payload: {
: {}), chunk_id: chunk.chunkId,
}, ...(chunk.content !== undefined && chunk.content.length > 0
}, ? { content: chunk.content }
], : {}),
},
},
],
}),
catch: (cause) => qdrantDocEmbeddingsStoreError("upsert", cause),
}); });
} }
}; });
const deleteCollection = async (user: string, collection: string): Promise<void> => { const deleteCollectionEffect = Effect.fn("QdrantDocEmbeddings.deleteCollection")(function* (
user: string,
collection: string,
) {
const prefix = `d_${user}_${collection}_`; const prefix = `d_${user}_${collection}_`;
const allCollections = await client.getCollections(); const allCollections = yield* Effect.tryPromise({
try: () => client.getCollections(),
catch: (cause) => qdrantDocEmbeddingsStoreError("get-collections", cause),
});
const matching = allCollections.collections.filter((c) => const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix), c.name.startsWith(prefix),
); );
if (matching.length === 0) { if (matching.length === 0) {
console.log(`[QdrantDocEmbeddings] No collections matching prefix ${prefix}`); yield* Effect.log(`[QdrantDocEmbeddings] No collections matching prefix ${prefix}`);
return; return;
} }
for (const coll of matching) { for (const coll of matching) {
await client.deleteCollection(coll.name); yield* Effect.tryPromise({
try: () => client.deleteCollection(coll.name),
catch: (cause) => qdrantDocEmbeddingsStoreError("delete-collection", cause),
});
knownCollections.delete(coll.name); knownCollections.delete(coll.name);
console.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`); yield* Effect.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`);
} }
console.log( yield* Effect.log(
`[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`, `[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
); );
}; });
return { store, deleteCollection }; return {
store: (message) => Effect.runPromise(storeEffect(message)),
deleteCollection: (user, collection) =>
Effect.runPromise(deleteCollectionEffect(user, collection)),
storeEffect,
deleteCollectionEffect,
};
} }

View file

@ -10,7 +10,8 @@
import { QdrantClient } from "@qdrant/js-client-rest"; import { QdrantClient } from "@qdrant/js-client-rest";
import { errorMessage, type Term } from "@trustgraph/base"; import { errorMessage, type Term } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect"; import { Config, Context, Effect, Layer, Random } from "effect";
import * as O from "effect/Option";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export interface QdrantGraphEmbeddingsConfig { export interface QdrantGraphEmbeddingsConfig {
@ -30,6 +31,57 @@ export interface GraphEmbeddingsMessage {
entities: GraphEmbeddingEntity[]; entities: GraphEmbeddingEntity[];
} }
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
"QdrantGraphEmbeddingsStoreError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
QdrantGraphEmbeddingsStoreError.make({
operation,
message: errorMessage(cause),
cause,
});
interface ResolvedQdrantGraphEmbeddingsConfig {
readonly url: string;
readonly apiKey?: string;
}
const loadQdrantGraphEmbeddingsConfig = Effect.fn("QdrantGraphEmbeddings.loadConfig")(function* (
config: QdrantGraphEmbeddingsConfig,
) {
const envApiKey = O.getOrUndefined(yield* Config.string("QDRANT_API_KEY").pipe(Config.option));
const apiKey = config.apiKey ?? envApiKey;
return {
url: config.url ?? (yield* Config.string("QDRANT_URL").pipe(Config.withDefault("http://localhost:6333"))),
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}),
} satisfies ResolvedQdrantGraphEmbeddingsConfig;
});
const randomHex = Effect.fn("QdrantGraphEmbeddings.randomHex")(function* (digits: number) {
let result = "";
for (let index = 0; index < digits; index++) {
const value = yield* Random.nextIntBetween(0, 16);
result += value.toString(16);
}
return result;
});
const randomPointId = Effect.fn("QdrantGraphEmbeddings.randomPointId")(function* () {
const part1 = yield* randomHex(8);
const part2 = yield* randomHex(4);
const versionRest = yield* randomHex(3);
const variant = yield* Random.nextIntBetween(8, 12);
const variantRest = yield* randomHex(3);
const part5 = yield* randomHex(12);
return `${part1}-${part2}-4${versionRest}-${variant.toString(16)}${variantRest}-${part5}`;
});
function getTermValue(term: Term): string | null { function getTermValue(term: Term): string | null {
switch (term.type) { switch (term.type) {
case "IRI": case "IRI":
@ -46,40 +98,56 @@ function getTermValue(term: Term): string | null {
export interface QdrantGraphEmbeddingsStore { export interface QdrantGraphEmbeddingsStore {
readonly store: (message: GraphEmbeddingsMessage) => Promise<void>; readonly store: (message: GraphEmbeddingsMessage) => Promise<void>;
readonly deleteCollection: (user: string, collection: string) => Promise<void>; readonly deleteCollection: (user: string, collection: string) => Promise<void>;
readonly storeEffect: (
message: GraphEmbeddingsMessage,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
readonly deleteCollectionEffect: (
user: string,
collection: string,
) => Effect.Effect<void, QdrantGraphEmbeddingsStoreError>;
} }
export function makeQdrantGraphEmbeddingsStore( export function makeQdrantGraphEmbeddingsStore(
config: QdrantGraphEmbeddingsConfig = {}, config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStore { ): QdrantGraphEmbeddingsStore {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333"; const resolved = Effect.runSync(loadQdrantGraphEmbeddingsConfig(config));
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
const client = new QdrantClient({ const client = new QdrantClient({
url, url: resolved.url,
...(apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}), ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
}); });
const knownCollections = new Set<string>(); const knownCollections = new Set<string>();
console.log("[QdrantGraphEmbeddings] Store initialized"); Effect.runSync(Effect.log("[QdrantGraphEmbeddings] Store initialized"));
const collectionName = (user: string, collection: string, dim: number): string => const collectionName = (user: string, collection: string, dim: number): string =>
`t_${user}_${collection}_${dim}`; `t_${user}_${collection}_${dim}`;
const ensureCollection = async (name: string, dim: number): Promise<void> => { const ensureCollectionEffect = Effect.fn("QdrantGraphEmbeddings.ensureCollection")(function* (
name: string,
dim: number,
) {
if (knownCollections.has(name)) return; if (knownCollections.has(name)) return;
const exists = await client.collectionExists(name); const exists = yield* Effect.tryPromise({
try: () => client.collectionExists(name),
catch: (cause) => qdrantGraphEmbeddingsStoreError("collection-exists", cause),
});
if (!exists.exists) { if (!exists.exists) {
console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`); yield* Effect.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
await client.createCollection(name, { yield* Effect.tryPromise({
vectors: { size: dim, distance: "Cosine" }, try: () =>
client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
}),
catch: (cause) => qdrantGraphEmbeddingsStoreError("create-collection", cause),
}); });
} }
knownCollections.add(name); knownCollections.add(name);
}; });
const store = async (message: GraphEmbeddingsMessage): Promise<void> => { const storeEffect = Effect.fn("QdrantGraphEmbeddings.store")(function* (message: GraphEmbeddingsMessage) {
for (const entry of message.entities) { for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity); const entityValue = getTermValue(entry.entity);
if (entityValue === null || entityValue.length === 0) continue; if (entityValue === null || entityValue.length === 0) continue;
@ -88,61 +156,72 @@ export function makeQdrantGraphEmbeddingsStore(
const dim = entry.vector.length; const dim = entry.vector.length;
const name = collectionName(message.user, message.collection, dim); const name = collectionName(message.user, message.collection, dim);
await ensureCollection(name, dim); yield* ensureCollectionEffect(name, dim);
const payload: Record<string, unknown> = { entity: entityValue }; const payload: Record<string, unknown> = { entity: entityValue };
if (entry.chunkId !== undefined && entry.chunkId.length > 0) { if (entry.chunkId !== undefined && entry.chunkId.length > 0) {
payload.chunk_id = entry.chunkId; payload.chunk_id = entry.chunkId;
} }
await client.upsert(name, { const id = yield* randomPointId();
points: [ yield* Effect.tryPromise({
{ try: () =>
id: crypto.randomUUID(), client.upsert(name, {
vector: entry.vector, points: [
payload, {
}, id,
], vector: entry.vector,
payload,
},
],
}),
catch: (cause) => qdrantGraphEmbeddingsStoreError("upsert", cause),
}); });
} }
}; });
const deleteCollection = async (user: string, collection: string): Promise<void> => { const deleteCollectionEffect = Effect.fn("QdrantGraphEmbeddings.deleteCollection")(function* (
user: string,
collection: string,
) {
const prefix = `t_${user}_${collection}_`; const prefix = `t_${user}_${collection}_`;
const allCollections = await client.getCollections(); const allCollections = yield* Effect.tryPromise({
try: () => client.getCollections(),
catch: (cause) => qdrantGraphEmbeddingsStoreError("get-collections", cause),
});
const matching = allCollections.collections.filter((c) => const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix), c.name.startsWith(prefix),
); );
if (matching.length === 0) { if (matching.length === 0) {
console.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`); yield* Effect.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`);
return; return;
} }
for (const coll of matching) { for (const coll of matching) {
await client.deleteCollection(coll.name); yield* Effect.tryPromise({
try: () => client.deleteCollection(coll.name),
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
});
knownCollections.delete(coll.name); knownCollections.delete(coll.name);
console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`); yield* Effect.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
} }
console.log( yield* Effect.log(
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`, `[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
); );
});
return {
store: (message) => Effect.runPromise(storeEffect(message)),
deleteCollection: (user, collection) =>
Effect.runPromise(deleteCollectionEffect(user, collection)),
storeEffect,
deleteCollectionEffect,
}; };
return { store, deleteCollection };
} }
export class QdrantGraphEmbeddingsStoreError extends S.TaggedErrorClass<QdrantGraphEmbeddingsStoreError>()(
"QdrantGraphEmbeddingsStoreError",
{
message: S.String,
operation: S.String,
cause: S.DefectWithStack,
},
) {}
export interface QdrantGraphEmbeddingsStoreServiceShape { export interface QdrantGraphEmbeddingsStoreServiceShape {
readonly store: ( readonly store: (
message: GraphEmbeddingsMessage, message: GraphEmbeddingsMessage,
@ -160,33 +239,13 @@ export class QdrantGraphEmbeddingsStoreService extends Context.Service<
"@trustgraph/flow/storage/embeddings/qdrant-graph/QdrantGraphEmbeddingsStoreService", "@trustgraph/flow/storage/embeddings/qdrant-graph/QdrantGraphEmbeddingsStoreService",
) {} ) {}
const qdrantGraphEmbeddingsStoreError = (operation: string, cause: unknown) =>
new QdrantGraphEmbeddingsStoreError({
operation,
message: errorMessage(cause),
cause,
});
export const makeQdrantGraphEmbeddingsStoreService = ( export const makeQdrantGraphEmbeddingsStoreService = (
config: QdrantGraphEmbeddingsConfig = {}, config: QdrantGraphEmbeddingsConfig = {},
): QdrantGraphEmbeddingsStoreServiceShape => { ): QdrantGraphEmbeddingsStoreServiceShape => {
const store = makeQdrantGraphEmbeddingsStore(config); const store = makeQdrantGraphEmbeddingsStore(config);
return { return {
store: Effect.fn("QdrantGraphEmbeddingsStore.store")(function* (message) { store: store.storeEffect,
return yield* Effect.tryPromise({ deleteCollection: store.deleteCollectionEffect,
try: () => store.store(message),
catch: (cause) => qdrantGraphEmbeddingsStoreError("store", cause),
});
}),
deleteCollection: Effect.fn("QdrantGraphEmbeddingsStore.deleteCollection")(function* (
user,
collection,
) {
return yield* Effect.tryPromise({
try: () => store.deleteCollection(user, collection),
catch: (cause) => qdrantGraphEmbeddingsStoreError("delete-collection", cause),
});
}),
}; };
}; };

View file

@ -66,7 +66,7 @@ export function makeTriplesStoreService(config: ProcessorConfig): TriplesStoreSe
), ),
), ),
}); });
console.log("[TriplesStore] Service initialized"); void Effect.runPromise(Effect.log("[TriplesStore] Service initialized"));
return service; return service;
} }
@ -78,6 +78,6 @@ export const program = makeFlowProcessorProgram<ProcessorConfig & FalkorDBConfig
layer: (config) => FalkorDBTriplesStoreLive(config), layer: (config) => FalkorDBTriplesStoreLive(config),
}); });
export async function run(): Promise<void> { export function run(): Promise<void> {
await Effect.runPromise(program); return Effect.runPromise(program);
} }

View file

@ -1,5 +1,5 @@
/** /**
* FalkorDB triples store writes RDF triples to a FalkorDB graph. * FalkorDB triples store - writes RDF triples to a FalkorDB graph.
* *
* FalkorDB is Redis-based and uses Cypher queries, same as the Python impl. * FalkorDB is Redis-based and uses Cypher queries, same as the Python impl.
* Pairs well with Graphiti which also uses FalkorDB as its backend. * Pairs well with Graphiti which also uses FalkorDB as its backend.
@ -9,7 +9,7 @@
import { createClient, Graph } from "falkordb"; import { createClient, Graph } from "falkordb";
import { errorMessage, type Term, type Triple } from "@trustgraph/base"; import { errorMessage, type Term, type Triple } from "@trustgraph/base";
import { Context, Effect, Layer } from "effect"; import { Config, Context, Effect, Layer } from "effect";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
export interface FalkorDBConfig { export interface FalkorDBConfig {
@ -26,7 +26,7 @@ function getTermValue(term: Term): string {
case "BLANK": case "BLANK":
return term.id; return term.id;
case "TRIPLE": case "TRIPLE":
return getTermValue(term.triple.s); // fallback return getTermValue(term.triple.s);
} }
} }
@ -55,113 +55,6 @@ export interface FalkorDBTriplesStore {
readonly deleteCollection: (user: string, collection: string) => Promise<void>; readonly deleteCollection: (user: string, collection: string) => Promise<void>;
} }
export function makeFalkorDBTriplesStore(config: FalkorDBConfig = {}): FalkorDBTriplesStore {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
const graph = new Graph(client, database);
const connectPromise = client.connect().then(() => {
console.log(`[FalkorDBTriplesStore] Connected to ${url}, graph: ${database}`);
}).catch((err) => {
console.error(`[FalkorDBTriplesStore] Connection failed:`, err);
throw err;
});
const ensureConnected = async (): Promise<void> => {
await connectPromise;
};
const createNode = async (uri: string, user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MERGE (n:Node {uri: $uri, user: $user, collection: $collection})",
{ params: { uri, user, collection } },
);
};
const createLiteral = async (value: string, user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MERGE (n:Literal {value: $value, user: $user, collection: $collection})",
{ params: { value, user, collection } },
);
};
const relateNode = async (
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
};
const relateLiteral = async (
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
};
const storeTriples = async (
triples: Triple[],
user = "default",
collection = "default",
): Promise<void> => {
for (const t of triples) {
const s = getTermValue(t.s);
const p = getTermValue(t.p);
const o = getTermValue(t.o);
await createNode(s, user, collection);
if (t.o.type === "IRI") {
await createNode(o, user, collection);
await relateNode(s, p, o, user, collection);
} else {
await createLiteral(o, user, collection);
await relateLiteral(s, p, o, user, collection);
}
}
};
const deleteCollection = async (user: string, collection: string): Promise<void> => {
await ensureConnected();
await graph.query(
"MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await graph.query(
"MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await graph.query(
"MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c",
{ params: { user, collection } },
);
};
return {
createNode,
createLiteral,
relateNode,
relateLiteral,
storeTriples,
deleteCollection,
};
}
export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()( export class FalkorDBTriplesStoreError extends S.TaggedErrorClass<FalkorDBTriplesStoreError>()(
"FalkorDBTriplesStoreError", "FalkorDBTriplesStoreError",
{ {
@ -190,35 +83,268 @@ export class FalkorDBTriplesStoreService extends Context.Service<
"@trustgraph/flow/storage/triples/falkordb/FalkorDBTriplesStoreService", "@trustgraph/flow/storage/triples/falkordb/FalkorDBTriplesStoreService",
) {} ) {}
const falkorDBTriplesStoreError = (operation: string, cause: unknown) => const falkorDBTriplesStoreError = (operation: string, cause: unknown): FalkorDBTriplesStoreError =>
new FalkorDBTriplesStoreError({ FalkorDBTriplesStoreError.make({
operation, operation,
message: errorMessage(cause), message: errorMessage(cause),
cause, cause,
}); });
interface FalkorDBStoreConnection {
readonly graph: Graph;
}
type FalkorDBQueryOptions = Parameters<Graph["query"]>[1];
interface FalkorDBTriplesStoreEffectShape {
readonly createNode: (
uri: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly createLiteral: (
value: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly relateNode: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly relateLiteral: (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly storeTriples: (
triples: ReadonlyArray<Triple>,
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
readonly deleteCollection: (
user: string,
collection: string,
) => Effect.Effect<void, FalkorDBTriplesStoreError>;
}
const resolveFalkorDBStoreConfig = Effect.fn("FalkorDBTriplesStore.resolveConfig")(function* (
config: FalkorDBConfig,
) {
const url = config.url ?? (yield* Config.string("FALKORDB_URL").pipe(
Config.withDefault("redis://localhost:6379"),
Effect.mapError((cause) => falkorDBTriplesStoreError("config", cause)),
));
return {
url,
database: config.database ?? "falkordb",
};
});
const connectFalkorDBTriplesStore = (
config: FalkorDBConfig,
): Effect.Effect<FalkorDBStoreConnection, FalkorDBTriplesStoreError> =>
Effect.gen(function* () {
const { url, database } = yield* resolveFalkorDBStoreConfig(config);
const { client, graph } = yield* Effect.try({
try: () => {
const client = createClient({ url });
return { client, graph: new Graph(client, database) };
},
catch: (cause) => falkorDBTriplesStoreError("create-client", cause),
});
yield* Effect.tryPromise({
try: () => client.connect(),
catch: (cause) => falkorDBTriplesStoreError("connect", cause),
}).pipe(
Effect.tapError((error) =>
Effect.logError("[FalkorDBTriplesStore] Connection failed", {
error: error.message,
operation: error.operation,
})
),
);
yield* Effect.log(`[FalkorDBTriplesStore] Connected to ${url}, graph: ${database}`);
return { graph };
});
const runGraphQuery = (
graph: Graph,
operation: string,
query: string,
options?: FalkorDBQueryOptions,
): Effect.Effect<void, FalkorDBTriplesStoreError> =>
Effect.tryPromise({
try: () => graph.query(query, options),
catch: (cause) => falkorDBTriplesStoreError(operation, cause),
}).pipe(
Effect.asVoid,
);
const makeFalkorDBTriplesStoreEffect = (
config: FalkorDBConfig = {},
): FalkorDBTriplesStoreEffectShape => {
let cachedConnection: Effect.Effect<FalkorDBStoreConnection, FalkorDBTriplesStoreError> | undefined;
const getConnection = Effect.fn("FalkorDBTriplesStore.connection")(function* () {
if (cachedConnection === undefined) {
cachedConnection = yield* Effect.cached(connectFalkorDBTriplesStore(config));
}
return yield* cachedConnection;
});
const createNode = Effect.fn("FalkorDBTriplesStore.createNode")(function* (
uri: string,
user: string,
collection: string,
) {
const { graph } = yield* getConnection();
yield* runGraphQuery(
graph,
"create-node",
"MERGE (n:Node {uri: $uri, user: $user, collection: $collection})",
{ params: { uri, user, collection } },
);
});
const createLiteral = Effect.fn("FalkorDBTriplesStore.createLiteral")(function* (
value: string,
user: string,
collection: string,
) {
const { graph } = yield* getConnection();
yield* runGraphQuery(
graph,
"create-literal",
"MERGE (n:Literal {value: $value, user: $user, collection: $collection})",
{ params: { value, user, collection } },
);
});
const relateNode = Effect.fn("FalkorDBTriplesStore.relateNode")(function* (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) {
const { graph } = yield* getConnection();
yield* runGraphQuery(
graph,
"relate-node",
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
});
const relateLiteral = Effect.fn("FalkorDBTriplesStore.relateLiteral")(function* (
src: string,
uri: string,
dest: string,
user: string,
collection: string,
) {
const { graph } = yield* getConnection();
yield* runGraphQuery(
graph,
"relate-literal",
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
});
const storeTriples = Effect.fn("FalkorDBTriplesStore.storeTriples")(function* (
triples: ReadonlyArray<Triple>,
user: string,
collection: string,
) {
for (const triple of triples) {
const s = getTermValue(triple.s);
const p = getTermValue(triple.p);
const o = getTermValue(triple.o);
yield* createNode(s, user, collection);
if (triple.o.type === "IRI") {
yield* createNode(o, user, collection);
yield* relateNode(s, p, o, user, collection);
} else {
yield* createLiteral(o, user, collection);
yield* relateLiteral(s, p, o, user, collection);
}
}
});
const deleteCollection = Effect.fn("FalkorDBTriplesStore.deleteCollection")(function* (
user: string,
collection: string,
) {
const { graph } = yield* getConnection();
yield* runGraphQuery(
graph,
"delete-collection-nodes",
"MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
yield* runGraphQuery(
graph,
"delete-collection-literals",
"MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
yield* runGraphQuery(
graph,
"delete-collection-metadata",
"MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c",
{ params: { user, collection } },
);
});
return {
createNode,
createLiteral,
relateNode,
relateLiteral,
storeTriples,
deleteCollection,
};
};
export function makeFalkorDBTriplesStore(config: FalkorDBConfig = {}): FalkorDBTriplesStore {
const store = makeFalkorDBTriplesStoreEffect(config);
return {
createNode: (uri, user, collection) =>
Effect.runPromise(store.createNode(uri, user, collection)),
createLiteral: (value, user, collection) =>
Effect.runPromise(store.createLiteral(value, user, collection)),
relateNode: (src, uri, dest, user, collection) =>
Effect.runPromise(store.relateNode(src, uri, dest, user, collection)),
relateLiteral: (src, uri, dest, user, collection) =>
Effect.runPromise(store.relateLiteral(src, uri, dest, user, collection)),
storeTriples: (triples, user = "default", collection = "default") =>
Effect.runPromise(store.storeTriples(triples, user, collection)),
deleteCollection: (user, collection) =>
Effect.runPromise(store.deleteCollection(user, collection)),
};
}
export const makeFalkorDBTriplesStoreService = ( export const makeFalkorDBTriplesStoreService = (
config: FalkorDBConfig = {}, config: FalkorDBConfig = {},
): FalkorDBTriplesStoreServiceShape => { ): FalkorDBTriplesStoreServiceShape => {
const store = makeFalkorDBTriplesStore(config); const store = makeFalkorDBTriplesStoreEffect(config);
return { return {
storeTriples: Effect.fn("FalkorDBTriplesStore.storeTriples")(( storeTriples: store.storeTriples,
triples: ReadonlyArray<Triple>, deleteCollection: store.deleteCollection,
user: string,
collection: string,
) =>
Effect.tryPromise({
try: () => store.storeTriples(Array.from(triples), user, collection),
catch: (cause) => falkorDBTriplesStoreError("store-triples", cause),
})),
deleteCollection: Effect.fn("FalkorDBTriplesStore.deleteCollection")((
user: string,
collection: string,
) =>
Effect.tryPromise({
try: () => store.deleteCollection(user, collection),
catch: (cause) => falkorDBTriplesStoreError("delete-collection", cause),
})),
}; };
}; };

View file

@ -2,7 +2,7 @@ import { Clipboard as BrowserClipboard } from "@effect/platform-browser";
import * as BrowserHttpClient from "@effect/platform-browser/BrowserHttpClient"; import * as BrowserHttpClient from "@effect/platform-browser/BrowserHttpClient";
import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore"; import * as BrowserKeyValueStore from "@effect/platform-browser/BrowserKeyValueStore";
import { BaseApi, type ConnectionState, type DocumentMetadata, type ExplainEvent, type StreamingMetadata, type Term, type Triple } from "@trustgraph/client"; import { BaseApi, type ConnectionState, type DocumentMetadata, type ExplainEvent, type StreamingMetadata, type Term, type Triple } from "@trustgraph/client";
import { Cause, Context, Data, Effect, Layer, Metric, Option, Schema as S } from "effect"; import { Cause, Clock, Context, Effect, Layer, Metric, Option, Random, Schema as S } from "effect";
import * as Otlp from "effect/unstable/observability/Otlp"; import * as Otlp from "effect/unstable/observability/Otlp";
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import * as Atom from "effect/unstable/reactivity/Atom"; import * as Atom from "effect/unstable/reactivity/Atom";
@ -29,23 +29,31 @@ const browserObservabilityLayer = Otlp.layerJson({
shutdownTimeout: "1 second", shutdownTimeout: "1 second",
}); });
class WorkbenchPromiseError extends Data.TaggedError("WorkbenchPromiseError")<{ class WorkbenchPromiseError extends S.TaggedErrorClass<WorkbenchPromiseError>()(
readonly cause: unknown; "WorkbenchPromiseError",
readonly message: string; {
}> {} cause: S.Unknown,
message: S.String,
},
) {}
type WorkbenchError = WorkbenchPromiseError; type WorkbenchError = WorkbenchPromiseError;
const isWorkbenchPromiseError = S.is(WorkbenchPromiseError);
function errorMessage(error: unknown): string { function errorMessage(error: unknown): string {
if (error instanceof WorkbenchPromiseError) return error.message; if (isWorkbenchPromiseError(error)) return error.message;
if (error instanceof Error) return error.message; if (typeof error === "object" && error !== null && "message" in error) {
const message = (error as { message?: unknown }).message;
if (typeof message === "string") return message;
}
return String(error); return String(error);
} }
function promiseBoundary<A>(evaluate: () => Promise<A>): Effect.Effect<A, WorkbenchPromiseError> { function promiseBoundary<A>(evaluate: () => Promise<A>): Effect.Effect<A, WorkbenchPromiseError> {
return Effect.tryPromise({ return Effect.tryPromise({
try: evaluate, try: evaluate,
catch: (cause) => new WorkbenchPromiseError({ cause, message: errorMessage(cause) }), catch: (cause) => WorkbenchPromiseError.make({ cause, message: errorMessage(cause) }),
}); });
} }
@ -73,7 +81,7 @@ export class WorkbenchFiles extends Context.Service<
Effect.flatMap((buffer) => Effect.flatMap((buffer) =>
Effect.try({ Effect.try({
try: () => base64FromArrayBuffer(buffer), try: () => base64FromArrayBuffer(buffer),
catch: (cause) => new WorkbenchPromiseError({ cause, message: errorMessage(cause) }), catch: (cause) => WorkbenchPromiseError.make({ cause, message: errorMessage(cause) }),
}) })
), ),
); );
@ -480,12 +488,12 @@ export function resultError<A, E>(result: AsyncResult.AsyncResult<A, E>): string
return resultErrorMessage(result); return resultErrorMessage(result);
} }
function nextMessageId(): string { function randomId(prefix: string): Effect.Effect<string> {
return `msg-${crypto.randomUUID()}`; return Effect.gen(function*() {
} const left = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const right = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
function nextNotificationId(): string { return `${prefix}-${left.toString(36).padStart(6, "0")}${right.toString(36).padStart(6, "0")}`;
return `notif-${crypto.randomUUID()}`; });
} }
function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined { function metadataFrom(metadata: StreamingMetadata | undefined): ChatMessage["metadata"] | undefined {
@ -520,7 +528,7 @@ function parseConfigEntries<T>(raw: unknown, label: string): T[] {
for (const item of mapConfigEntries(raw)) { for (const item of mapConfigEntries(raw)) {
const config = parseJsonUnknown(item.value); const config = parseJsonUnknown(item.value);
if (config === undefined) { if (config === undefined) {
console.warn(`[workbench-atoms] Failed to parse ${label}: ${item.key}`); Effect.runSync(Effect.logWarning(`[workbench-atoms] Failed to parse ${label}: ${item.key}`));
} else { } else {
entries.push({ key: item.key, config } as T); entries.push({ key: item.key, config } as T);
} }
@ -743,7 +751,7 @@ export const clearMessagesAtom = Atom.writable(
export const pushNotificationAtom = localCommandAtom<Omit<Notification, "id">, void>( export const pushNotificationAtom = localCommandAtom<Omit<Notification, "id">, void>(
"pushNotification", "pushNotification",
Effect.fn("trustgraph.workbench.pushNotification")(function*(input, get) { Effect.fn("trustgraph.workbench.pushNotification")(function*(input, get) {
const id = nextNotificationId(); const id = yield* randomId("notif");
const notification: Notification = { id, ...input }; const notification: Notification = { id, ...input };
get.set(notificationsAtom, [...get(notificationsAtom), notification]); get.set(notificationsAtom, [...get(notificationsAtom), notification]);
yield* Effect.sleep("5 seconds"); yield* Effect.sleep("5 seconds");
@ -1307,9 +1315,11 @@ const uploadDocumentChunkedEffect = Effect.fn("trustgraph.workbench.uploadDocume
bytesUploaded: 0, bytesUploaded: 0,
} }); } });
const lib = api.librarian(); const lib = api.librarian();
const documentId = yield* randomId("upload");
const timestamp = yield* Clock.currentTimeMillis;
const beginResp = yield* promiseBoundary(() => lib.beginUpload({ const beginResp = yield* promiseBoundary(() => lib.beginUpload({
id: crypto.randomUUID(), id: documentId,
time: Math.floor(Date.now() / 1000), time: Math.floor(timestamp / 1000),
kind: input.mimeType, kind: input.mimeType,
title: input.title, title: input.title,
comments: input.comments, comments: input.comments,
@ -1478,7 +1488,7 @@ export const deleteCollectionAtom = commandAtom<string, void>("deleteCollection"
export const submitMessageAtom = commandAtom<{ input: string }, void>( export const submitMessageAtom = commandAtom<{ input: string }, void>(
"submitMessage", "submitMessage",
Effect.fn("trustgraph.workbench.submitMessage")(({ input }, get, api) => Effect.sync(() => { Effect.fn("trustgraph.workbench.submitMessage")(function*({ input }, get, api) {
const trimmed = input.trim(); const trimmed = input.trim();
if (trimmed.length === 0) return; if (trimmed.length === 0) return;
@ -1488,7 +1498,8 @@ export const submitMessageAtom = commandAtom<{ input: string }, void>(
const chatMode = get(conversationAtom).chatMode; const chatMode = get(conversationAtom).chatMode;
const flow = api.flow(flowId); const flow = api.flow(flowId);
const explainEvents: ExplainEvent[] = []; const explainEvents: ExplainEvent[] = [];
const requestId = crypto.randomUUID(); const requestId = yield* randomId("chat");
const timestamp = yield* Clock.currentTimeMillis;
const isActiveRequest = () => get(activeChatRequestAtom) === requestId; const isActiveRequest = () => get(activeChatRequestAtom) === requestId;
const finishRequest = () => { const finishRequest = () => {
if (isActiveRequest()) { if (isActiveRequest()) {
@ -1498,16 +1509,16 @@ export const submitMessageAtom = commandAtom<{ input: string }, void>(
}; };
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: nextMessageId(), id: yield* randomId("msg"),
role: "user", role: "user",
content: input, content: input,
timestamp: Date.now(), timestamp,
}; };
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
id: nextMessageId(), id: yield* randomId("msg"),
role: "assistant", role: "assistant",
content: "", content: "",
timestamp: Date.now(), timestamp,
isStreaming: true, isStreaming: true,
...(chatMode === "agent" ...(chatMode === "agent"
? { ? {
@ -1625,7 +1636,7 @@ export const submitMessageAtom = commandAtom<{ input: string }, void>(
); );
break; break;
} }
})), }),
); );
export const cancelChatAtom = Atom.writable( export const cancelChatAtom = Atom.writable(

View file

@ -4,6 +4,7 @@ import {
type FallbackProps, type FallbackProps,
} from "react-error-boundary"; } from "react-error-boundary";
import { AlertTriangle, RefreshCw } from "lucide-react"; import { AlertTriangle, RefreshCw } from "lucide-react";
import { Effect } from "effect";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@ -12,7 +13,9 @@ interface Props {
} }
const errorMessage = (error: unknown): string => const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "An unexpected error occurred."; typeof error === "object" && error !== null && "message" in error && typeof error.message === "string"
? error.message
: "An unexpected error occurred.";
function DefaultFallback({ error, resetErrorBoundary }: FallbackProps) { function DefaultFallback({ error, resetErrorBoundary }: FallbackProps) {
return ( return (
@ -42,7 +45,7 @@ export function ErrorBoundary({ children, fallback }: Props) {
<ReactErrorBoundary <ReactErrorBoundary
fallbackRender={(props) => fallback ?? <DefaultFallback {...props} />} fallbackRender={(props) => fallback ?? <DefaultFallback {...props} />}
onError={(error, info) => { onError={(error, info) => {
console.error("[ErrorBoundary]", error, info.componentStack); Effect.runSync(Effect.logError("[ErrorBoundary]", { error, componentStack: info.componentStack }));
}} }}
> >
{children} {children}

View file

@ -36,6 +36,7 @@ import {
import { Dialog } from "@/components/ui/dialog"; import { Dialog } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import type { DocumentMetadata } from "@trustgraph/client"; import type { DocumentMetadata } from "@trustgraph/client";
import { DateTime } from "effect";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
@ -53,6 +54,10 @@ function guessKind(doc: DocumentMetadata): string {
return kind.length > 0 ? kind : "--"; return kind.length > 0 ? kind : "--";
} }
function formatUnixTimestamp(seconds: number): string {
return DateTime.formatLocal(DateTime.makeUnsafe(seconds * 1000));
}
function resetUploadForm(form: UploadForm): UploadForm { function resetUploadForm(form: UploadForm): UploadForm {
return { ...form, file: null, title: "", tags: "", comments: "", uploading: false, dragOver: false, progress: null }; return { ...form, file: null, title: "", tags: "", comments: "", uploading: false, dragOver: false, progress: null };
} }
@ -242,7 +247,7 @@ function DocumentDetailDialog() {
<h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle"> <h3 className="mb-1 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider text-fg-subtle">
<Clock className="h-3 w-3" /> Created <Clock className="h-3 w-3" /> Created
</h3> </h3>
<p className="text-sm text-fg-muted">{new Date(doc.time * 1000).toLocaleString()}</p> <p className="text-sm text-fg-muted">{formatUnixTimestamp(doc.time)}</p>
</div> </div>
)} )}
{doc.metadata !== undefined && doc.metadata.length > 0 && ( {doc.metadata !== undefined && doc.metadata.length > 0 && (

View file

@ -1,5 +1,5 @@
import { makeBaseApiWithRpc, type BaseApi, type DocumentMetadata, type ProcessingMetadata, type StreamingMetadata, type Triple } from "@trustgraph/client"; import { makeBaseApiWithRpc, type BaseApi, type DocumentMetadata, type ProcessingMetadata, type StreamingMetadata, type Triple } from "@trustgraph/client";
import { Option, Schema as S } from "effect"; import { Clock, Effect, Option, Schema as S } from "effect";
type ConfigValues = Record<string, Record<string, unknown>>; type ConfigValues = Record<string, Record<string, unknown>>;
@ -268,12 +268,13 @@ function configValues(state: MockState, type: string) {
function addDocument(state: MockState, metadata: DocumentMetadata): DocumentMetadata { function addDocument(state: MockState, metadata: DocumentMetadata): DocumentMetadata {
const id = metadata.id ?? `qa-doc-${state.library.documents.length + 1}`; const id = metadata.id ?? `qa-doc-${state.library.documents.length + 1}`;
const currentTimeSeconds = Math.floor(Effect.runSync(Clock.currentTimeMillis) / 1000);
const document = { const document = {
...metadata, ...metadata,
id, id,
title: metadata.title ?? id, title: metadata.title ?? id,
kind: metadata.kind ?? metadata["document-type"] ?? "text/plain", kind: metadata.kind ?? metadata["document-type"] ?? "text/plain",
time: metadata.time ?? Math.floor(Date.now() / 1000), time: metadata.time ?? currentTimeSeconds,
user: metadata.user ?? state.settings.user, user: metadata.user ?? state.settings.user,
tags: metadata.tags ?? [], tags: metadata.tags ?? [],
}; };
@ -526,16 +527,14 @@ export function makeMockBaseApi(fixture: MockWorkbenchFixture = {}): BaseApi {
input.flow, input.flow,
), ),
), ),
dispatchStream: async (input, receiver) => { dispatchStream: (input, receiver) =>
await dispatchStream(state, input.service, (message) => { dispatchStream(state, input.service, (message) => {
const chunk = message as { response?: unknown; complete?: boolean }; const chunk = message as { response?: unknown; complete?: boolean };
return receiver({ return receiver({
response: chunk.response, response: chunk.response,
complete: chunk.complete === true, complete: chunk.complete === true,
}); });
}); }).then(() => undefined),
return undefined;
},
subscribe: (listener) => { subscribe: (listener) => {
listener({ status: token === undefined ? "connected" : "connected" }); listener({ status: token === undefined ? "connected" : "connected" });
return () => {}; return () => {};

View file

@ -38,26 +38,13 @@
"plugins": [ "plugins": [
{ {
"name": "@effect/language-service", "name": "@effect/language-service",
"namespaceImportPackages": ["effect", "@effect/*", "@beep/*"], "namespaceImportPackages": ["effect", "@effect/*", "@trustgraph/*"],
"ignoreEffectSuggestionsInTscExitCode": false, "ignoreEffectSuggestionsInTscExitCode": false,
"ignoreEffectWarningsInTscExitCode": false, "ignoreEffectWarningsInTscExitCode": false,
"ignoreEffectErrorsInTscExitCode": false, "ignoreEffectErrorsInTscExitCode": false,
"includeSuggestionsInTsc": true, "includeSuggestionsInTsc": true,
"skipDisabledOptimization": false, "skipDisabledOptimization": false,
"effectFn": ["span", "inferred-span", "suggested-span"], "effectFn": ["span", "inferred-span", "suggested-span"],
"overrides": [
{
"include": ["**/test/**/*.ts", "**/test/**/*.tsx"],
"options": {
"diagnosticSeverity": {
"missingEffectContext": "off",
"nodeBuiltinImport": "off",
"strictEffectProvide": "off"
}
}
}
],
"importAliases": { "importAliases": {
"Array": "A", "Array": "A",
"Option": "O", "Option": "O",
@ -68,39 +55,66 @@
}, },
"diagnosticSeverity": { "diagnosticSeverity": {
"anyUnknownInErrorContext": "error", "anyUnknownInErrorContext": "error",
"asyncFunction": "error",
"catchAllToMapError": "error", "catchAllToMapError": "error",
"classSelfMismatch": "error", "catchToOrElseSucceed": "error",
"floatingEffect": "error",
"catchUnfailableEffect": "error", "catchUnfailableEffect": "error",
"classSelfMismatch": "error",
"cryptoRandomUUID": "error",
"cryptoRandomUUIDInEffect": "error",
"deterministicKeys": "error", "deterministicKeys": "error",
"duplicatePackage": "error", "duplicatePackage": "error",
"effectDoNotation": "error",
"effectFnIife": "error", "effectFnIife": "error",
"effectFnImplicitAny": "error",
"effectFnOpportunity": "error", "effectFnOpportunity": "error",
"effectGenUsesAdapter": "error", "effectGenUsesAdapter": "error",
"effectInFailure": "error", "effectInFailure": "error",
"effectInVoidSuccess": "error", "effectInVoidSuccess": "error",
"effectMapFlatten": "error",
"effectMapVoid": "error", "effectMapVoid": "error",
"effectSucceedWithVoid": "error", "effectSucceedWithVoid": "error",
"extendsNativeError": "error", "extendsNativeError": "error",
"floatingEffect": "error",
"genericEffectServices": "error", "genericEffectServices": "error",
"missingEffectContext": "error", "globalConsole": "error",
"missingEffectError": "error", "globalConsoleInEffect": "error",
"globalDate": "error",
"globalDateInEffect": "error",
"globalErrorInEffectCatch": "error", "globalErrorInEffectCatch": "error",
"globalErrorInEffectFailure": "error", "globalErrorInEffectFailure": "error",
"missingLayerContext": "error", "globalFetch": "error",
"globalFetchInEffect": "error",
"globalRandom": "error",
"globalRandomInEffect": "error",
"globalTimers": "error",
"globalTimersInEffect": "error",
"instanceOfSchema": "error", "instanceOfSchema": "error",
"missingReturnYieldStar": "error",
"missingStarInYieldEffectGen": "error",
"layerMergeAllWithDependencies": "error", "layerMergeAllWithDependencies": "error",
"overriddenSchemaConstructor": "error", "lazyEffect": "error",
"lazyPromiseInEffectSync": "error",
"leakingRequirements": "error", "leakingRequirements": "error",
"missedPipeableOpportunity": "error", "missedPipeableOpportunity": "error",
"missingEffectContext": "error",
"missingEffectError": "error",
"missingEffectServiceDependency": "error", "missingEffectServiceDependency": "error",
"missingLayerContext": "error",
"missingReturnYieldStar": "error",
"missingStarInYieldEffectGen": "error",
"multipleCatchTag": "error",
"multipleEffectProvide": "error", "multipleEffectProvide": "error",
"nestedEffectGenYield": "error",
"newPromise": "error",
"newSchemaClass": "error",
"nodeBuiltinImport": "error", "nodeBuiltinImport": "error",
"nonObjectEffectServiceType": "error",
"outdatedApi": "error", "outdatedApi": "error",
"overriddenSchemaConstructor": "error",
"preferSchemaOverJson": "error", "preferSchemaOverJson": "error",
"processEnv": "error",
"processEnvInEffect": "error",
"redundantOrDie": "error",
"redundantMapError": "error",
"redundantSchemaTagIdentifier": "error", "redundantSchemaTagIdentifier": "error",
"returnEffectInGen": "error", "returnEffectInGen": "error",
"runEffectInsideEffect": "error", "runEffectInsideEffect": "error",
@ -113,10 +127,13 @@
"strictEffectProvide": "error", "strictEffectProvide": "error",
"tryCatchInEffectGen": "error", "tryCatchInEffectGen": "error",
"unknownInEffectCatch": "error", "unknownInEffectCatch": "error",
"unnecessaryArrowBlock": "error",
"unnecessaryEffectGen": "error", "unnecessaryEffectGen": "error",
"unnecessaryFailYieldableError": "error", "unnecessaryFailYieldableError": "error",
"unnecessaryPipe": "error", "unnecessaryPipe": "error",
"unnecessaryPipeChain": "error" "unnecessaryPipeChain": "error",
"unnecessaryTypeofType": "error",
"unsafeEffectTypeAssertion": "error"
} }
} }
] ]