This commit is contained in:
elpresidank 2026-04-05 22:44:45 -05:00
parent c386f68743
commit b6536eca38
100 changed files with 17680 additions and 377 deletions

View file

@ -0,0 +1,357 @@
/**
* Config service manages system global configuration state.
*
* An AsyncProcessor (NOT FlowProcessor) that:
* 1. Listens on config-request topic
* 2. Handles operations: get, put, delete, list, config (full dump)
* 3. Stores config in-memory with a nested Map structure
* 4. On any mutation: increments version, broadcasts ConfigPush on config-push topic
* 5. Optionally persists to a JSON file for restart durability
*
* Python reference: trustgraph-flow/trustgraph/config/service/service.py
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import {
AsyncProcessor,
type ProcessorConfig,
topics,
type ConfigRequest,
type ConfigResponse,
type ConfigOperation,
} from "@trustgraph/base";
import type { PubSubBackend, BackendProducer, BackendConsumer, Message } from "@trustgraph/base";
export interface ConfigServiceConfig extends ProcessorConfig {
persistPath?: string;
}
interface ConfigPush {
version: number;
config: Record<string, unknown>;
}
export class ConfigService extends AsyncProcessor {
private store = new Map<string, Map<string, unknown>>();
private version = 0;
private persistPath: string | null;
private consumer: BackendConsumer<ConfigRequest> | null = null;
private responseProducer: BackendProducer<ConfigResponse> | null = null;
private pushProducer: BackendProducer<ConfigPush> | null = null;
constructor(config: ConfigServiceConfig) {
super(config);
this.persistPath = config.persistPath ?? process.env.CONFIG_PERSIST_PATH ?? null;
}
protected override async run(): Promise<void> {
// Optionally load persisted state
if (this.persistPath) {
await this.loadFromDisk();
}
// Create producers
this.responseProducer = await this.pubsub.createProducer<ConfigResponse>({
topic: topics.configResponse,
});
this.pushProducer = await this.pubsub.createProducer<ConfigPush>({
topic: topics.configPush,
});
// Create consumer for config requests
this.consumer = await this.pubsub.createConsumer<ConfigRequest>({
topic: topics.configRequest,
subscription: `${this.config.id}-config-request`,
});
// Push initial config
await this.pushConfig();
console.log(`[ConfigService] Listening on ${topics.configRequest}`);
// Main consume loop
while (this.running) {
try {
const msg = await this.consumer.receive(2000);
if (!msg) continue;
await this.handleMessage(msg);
await this.consumer.acknowledge(msg);
} catch (err) {
if (!this.running) break;
console.error("[ConfigService] Error in consume loop:", err);
await sleep(1000);
}
}
}
private async handleMessage(msg: Message<ConfigRequest>): Promise<void> {
const request = msg.value();
const props = msg.properties();
const requestId = props.id;
if (!requestId) {
console.warn("[ConfigService] Received request without id, ignoring");
return;
}
try {
const response = await this.handleOperation(request);
await this.responseProducer!.send(response, { id: requestId });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.responseProducer!.send(
{
error: { type: "config-error", message },
},
{ id: requestId },
);
}
}
private async handleOperation(request: ConfigRequest): Promise<ConfigResponse> {
const op: ConfigOperation = request.operation;
switch (op) {
case "get":
return this.handleGet(request.keys ?? []);
case "put":
return await this.handlePut(request.keys ?? [], request.values ?? {});
case "delete":
return await this.handleDelete(request.keys ?? []);
case "list":
return this.handleList(request.keys ?? []);
case "config":
return this.handleConfigDump();
default:
throw new Error(`Unknown config operation: ${op as string}`);
}
}
private handleGet(keys: string[]): ConfigResponse {
if (keys.length === 0) {
return { version: this.version, values: {} };
}
const values: Record<string, unknown> = {};
const namespace = keys[0];
const subMap = this.store.get(namespace);
if (subMap) {
if (keys.length === 1) {
// Return entire namespace
for (const [k, v] of subMap) {
values[k] = v;
}
} else {
// Return specific keys within namespace
for (let i = 1; i < keys.length; i++) {
const key = keys[i];
if (subMap.has(key)) {
values[key] = subMap.get(key);
}
}
}
}
return { version: this.version, values };
}
private async handlePut(
keys: string[],
values: Record<string, unknown>,
): Promise<ConfigResponse> {
if (keys.length === 0) {
throw new Error("Put requires at least one key (namespace)");
}
const namespace = keys[0];
let subMap = this.store.get(namespace);
if (!subMap) {
subMap = new Map<string, unknown>();
this.store.set(namespace, subMap);
}
for (const [k, v] of Object.entries(values)) {
subMap.set(k, v);
}
this.version++;
await this.persist();
await this.pushConfig();
return { version: this.version };
}
private async handleDelete(keys: string[]): Promise<ConfigResponse> {
if (keys.length === 0) {
throw new Error("Delete requires at least one key");
}
const namespace = keys[0];
if (keys.length === 1) {
// Delete entire namespace
this.store.delete(namespace);
} else {
// Delete specific keys within namespace
const subMap = this.store.get(namespace);
if (subMap) {
for (let i = 1; i < keys.length; i++) {
subMap.delete(keys[i]);
}
if (subMap.size === 0) {
this.store.delete(namespace);
}
}
}
this.version++;
await this.persist();
await this.pushConfig();
return { version: this.version };
}
private handleList(keys: string[]): ConfigResponse {
if (keys.length === 0) {
// List all namespaces
return {
version: this.version,
directory: [...this.store.keys()],
};
}
const namespace = keys[0];
const subMap = this.store.get(namespace);
return {
version: this.version,
directory: subMap ? [...subMap.keys()] : [],
};
}
private handleConfigDump(): ConfigResponse {
const config: Record<string, unknown> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
config[namespace] = obj;
}
return {
version: this.version,
config,
};
}
private async pushConfig(): Promise<void> {
if (!this.pushProducer) return;
const config: Record<string, unknown> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
config[namespace] = obj;
}
await this.pushProducer.send({
version: this.version,
config,
});
console.log(`[ConfigService] Pushed configuration version ${this.version}`);
}
private async persist(): Promise<void> {
if (!this.persistPath) return;
try {
const data: Record<string, Record<string, unknown>> = {};
for (const [namespace, subMap] of this.store) {
const obj: Record<string, unknown> = {};
for (const [k, v] of subMap) {
obj[k] = v;
}
data[namespace] = obj;
}
const json = JSON.stringify(
{ version: this.version, data },
null,
2,
);
await mkdir(dirname(this.persistPath), { recursive: true });
await writeFile(this.persistPath, json, "utf-8");
} catch (err) {
console.error("[ConfigService] Failed to persist config:", err);
}
}
private async loadFromDisk(): Promise<void> {
if (!this.persistPath) return;
try {
const raw = await readFile(this.persistPath, "utf-8");
const parsed = JSON.parse(raw) as {
version: number;
data: Record<string, Record<string, unknown>>;
};
this.version = parsed.version ?? 0;
this.store.clear();
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);
}
this.store.set(namespace, subMap);
}
console.log(
`[ConfigService] Loaded persisted config (version=${this.version}, namespaces=${this.store.size})`,
);
} catch {
// File doesn't exist yet or is invalid — start fresh
console.log("[ConfigService] No persisted config found, starting fresh");
}
}
override async stop(): Promise<void> {
if (this.consumer) {
await this.consumer.close();
this.consumer = null;
}
if (this.responseProducer) {
await this.responseProducer.close();
this.responseProducer = null;
}
if (this.pushProducer) {
await this.pushProducer.close();
this.pushProducer = null;
}
await super.stop();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function run(): Promise<void> {
await ConfigService.launch("config-svc");
}

View file

@ -0,0 +1,76 @@
/**
* Ollama embeddings service.
*
* Simple HTTP POST to a local Ollama instance to generate embeddings.
* Extends EmbeddingsService from @trustgraph/base so it plugs into the
* flow processor framework (consumer/producer wiring is handled by the base class).
*
* Python reference: trustgraph-flow/trustgraph/embeddings/ollama/processor.py
*/
import {
EmbeddingsService,
type ProcessorConfig,
} from "@trustgraph/base";
export interface OllamaEmbeddingsConfig extends ProcessorConfig {
model?: string;
ollamaHost?: string;
}
interface OllamaEmbedResponse {
embeddings: number[][];
}
export class OllamaEmbeddingsProcessor extends EmbeddingsService {
private defaultModel: string;
private ollamaHost: string;
constructor(config: OllamaEmbeddingsConfig) {
super(config);
this.defaultModel = config.model ?? "mxbai-embed-large";
this.ollamaHost =
config.ollamaHost ??
process.env.OLLAMA_HOST ??
"http://localhost:11434";
console.log(
`[OllamaEmbeddings] Initialized (host=${this.ollamaHost}, model=${this.defaultModel})`,
);
}
async onEmbeddings(texts: string[], model?: string): Promise<number[][]> {
if (!texts || texts.length === 0) {
return [];
}
const useModel = model ?? this.defaultModel;
const url = `${this.ollamaHost}/api/embed`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: useModel,
input: texts,
}),
});
if (!response.ok) {
const body = await response.text();
throw new Error(
`Ollama embeddings request failed (${response.status}): ${body}`,
);
}
const data = (await response.json()) as OllamaEmbedResponse;
return data.embeddings;
}
}
export async function run(): Promise<void> {
await OllamaEmbeddingsProcessor.launch("embeddings");
}

View file

@ -1,14 +1,70 @@
/**
* Dispatcher manager routes requests to backend services via pub/sub.
*
* Maintains a service registry mapping service names to NATS topic pairs.
* Applies wire format translation on requests (client internal) and
* reverse translation on responses (internal client).
*
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
*/
import { NatsBackend, RequestResponse, type PubSubBackend } from "@trustgraph/base";
import type { GatewayConfig } from "../server.js";
import { translateRequest, translateResponse } from "./serialize.js";
export type Responder = (response: unknown, complete: boolean) => Promise<void>;
// ---------- Service registry ----------
/**
* Flow-scoped request/response services.
* These are resolved within a specific flow's interface definitions.
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
*/
const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["agent", { request: "agent-request", response: "agent-response" }],
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
["prompt", { request: "prompt-request", response: "prompt-response" }],
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
["triples", { request: "triples-request", response: "triples-response" }],
]);
/**
* Global services (not flow-scoped).
* These always use fixed topics regardless of which flow is active.
*/
const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
["config", { request: "config-request", response: "config-response" }],
["flow", { request: "flow-request", response: "flow-response" }],
["librarian", { request: "librarian-request", response: "librarian-response" }],
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
]);
/**
* Services that support streaming responses (multiple messages per request).
* The completion flag is determined by checking for end-of-stream markers.
*/
const STREAMING_SERVICES = new Set([
"agent",
"text-completion",
"graph-rag",
"document-rag",
"triples",
"knowledge",
"librarian",
]);
function topicName(name: string): string {
return `tg.flow.${name}`;
}
// ---------- Manager ----------
export class DispatcherManager {
private pubsub: PubSubBackend;
private requestors = new Map<string, RequestResponse<unknown, unknown>>();
@ -18,8 +74,7 @@ export class DispatcherManager {
}
async start(): Promise<void> {
// Pre-create requestors for known global services
// Flow-specific requestors are created on demand
// Requestors are created on demand when first accessed
}
async stop(): Promise<void> {
@ -29,6 +84,8 @@ export class DispatcherManager {
await this.pubsub.close();
}
// ---------- Internal helpers ----------
private async getRequestor(
requestTopic: string,
responseTopic: string,
@ -48,25 +105,71 @@ export class DispatcherManager {
return rr;
}
private resolveGlobalTopics(
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = GLOBAL_SERVICES.get(kind);
if (entry) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
private resolveFlowTopics(
kind: string,
): { requestTopic: string; responseTopic: string } {
const entry = FLOW_SERVICES.get(kind);
if (entry) {
return {
requestTopic: topicName(entry.request),
responseTopic: topicName(entry.response),
};
}
// Fallback: derive from kind name directly
return {
requestTopic: topicName(`${kind}-request`),
responseTopic: topicName(`${kind}-response`),
};
}
/**
* Determine whether a response is the final one in a streaming sequence.
* Checks for various end-of-stream markers used by different services.
*/
private isComplete(response: unknown): boolean {
if (typeof response !== "object" || response === null) return true;
const res = response as Record<string, unknown>;
return (
!!res.complete ||
!!res.endOfStream ||
!!res.endOfSession ||
!!res.end_of_stream ||
!!res.end_of_session ||
!!res.eos ||
// error responses are always final
!!res.error
);
}
// ---------- Global service dispatch ----------
async dispatchGlobalService(
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
return rr.request(request);
}
async dispatchFlowService(
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`);
return rr.request(request);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
async dispatchGlobalServiceStreaming(
@ -74,37 +177,74 @@ export class DispatcherManager {
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const { requestTopic, responseTopic } = this.resolveGlobalTopics(kind);
const rr = await this.getRequestor(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
await rr.request(request, {
await rr.request(translated, {
recipient: async (response) => {
const res = response as Record<string, unknown>;
const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession;
await responder(res, complete);
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
// ---------- Flow-scoped service dispatch ----------
async dispatchFlowService(
flow: string,
kind: string,
request: Record<string, unknown>,
): Promise<unknown> {
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
const response = await rr.request(translated);
return translateResponse(kind, response);
}
async dispatchFlowServiceStreaming(
flow: string,
kind: string,
request: Record<string, unknown>,
responder: Responder,
): Promise<void> {
const requestTopic = `tg.flow.${kind}-request`;
const responseTopic = `tg.flow.${kind}-response`;
const rr = await this.getRequestor(requestTopic, responseTopic, `flow:${flow}:${kind}`);
const { requestTopic, responseTopic } = this.resolveFlowTopics(kind);
const rr = await this.getRequestor(
requestTopic,
responseTopic,
`flow:${flow}:${kind}`,
);
const translated = translateRequest(kind, request);
await rr.request(request, {
await rr.request(translated, {
recipient: async (response) => {
const res = response as Record<string, unknown>;
const complete = !!res.complete || !!res.endOfStream || !!res.endOfSession;
await responder(res, complete);
const translatedRes = translateResponse(kind, response);
const complete = this.isComplete(translatedRes);
await responder(translatedRes, complete);
return complete;
},
});
}
// ---------- Static introspection ----------
static get flowServiceNames(): readonly string[] {
return [...FLOW_SERVICES.keys()];
}
static get globalServiceNames(): readonly string[] {
return [...GLOBAL_SERVICES.keys()];
}
static isStreamingService(kind: string): boolean {
return STREAMING_SERVICES.has(kind);
}
}

View file

@ -0,0 +1,272 @@
/**
* Wire format serializer translates between the compact client wire format
* (used by @trustgraph/client) and the verbose internal format
* (used by @trustgraph/base services).
*
* Client wire format (compact):
* IRI: { t: "i", i: "<iri>" }
* BLANK: { t: "b", d: "<id>" }
* LITERAL: { t: "l", v: "<value>", dt?: "<datatype>", ln?: "<language>" }
* TRIPLE: { t: "t", tr?: { s, p, o, g? } }
*
* Internal format (verbose):
* IRI: { type: "IRI", iri: "<iri>" }
* BLANK: { type: "BLANK", id: "<id>" }
* LITERAL: { type: "LITERAL", value: "<value>", datatype?: "<dt>", language?: "<lang>" }
* TRIPLE: { type: "TRIPLE", triple: { s, p, o, g? } }
*
* Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py
*/
import type { Term, Triple } from "@trustgraph/base";
// ---------- Client wire format type definitions ----------
interface ClientIriTerm {
t: "i";
i: string;
}
interface ClientBlankTerm {
t: "b";
d: string;
}
interface ClientLiteralTerm {
t: "l";
v: string;
dt?: string;
ln?: string;
}
interface ClientTripleTerm {
t: "t";
tr?: ClientTriple;
}
type ClientTerm = ClientIriTerm | ClientBlankTerm | ClientLiteralTerm | ClientTripleTerm;
interface ClientTriple {
s: ClientTerm;
p: ClientTerm;
o: ClientTerm;
g?: string;
}
// ---------- Client → Internal ----------
export function clientTermToInternal(wire: ClientTerm): Term {
switch (wire.t) {
case "i":
return { type: "IRI", iri: wire.i };
case "b":
return { type: "BLANK", id: wire.d };
case "l":
return {
type: "LITERAL",
value: wire.v,
datatype: wire.dt,
language: wire.ln,
};
case "t":
return {
type: "TRIPLE",
triple: wire.tr ? clientTripleToInternal(wire.tr) : undefined!,
};
default:
// Defensive: pass through unknown term types
return wire as unknown as Term;
}
}
export function clientTripleToInternal(wire: ClientTriple): Triple {
const result: Triple = {
s: clientTermToInternal(wire.s),
p: clientTermToInternal(wire.p),
o: clientTermToInternal(wire.o),
};
if (wire.g !== undefined) {
// In the client wire format, g is a plain string.
// In the internal format, g is an optional Term (named graph).
// The Python translator treats g as a plain string passthrough,
// so we keep it as-is for compatibility.
(result as unknown as Record<string, unknown>).g = wire.g;
}
return result;
}
// ---------- Internal → Client ----------
export function internalTermToClient(term: Term): ClientTerm {
switch (term.type) {
case "IRI":
return { t: "i", i: term.iri };
case "BLANK":
return { t: "b", d: term.id };
case "LITERAL": {
const lit: ClientLiteralTerm = { t: "l", v: term.value };
if (term.datatype) lit.dt = term.datatype;
if (term.language) lit.ln = term.language;
return lit;
}
case "TRIPLE":
return {
t: "t",
tr: term.triple ? internalTripleToClient(term.triple) : undefined,
};
default:
return term as unknown as ClientTerm;
}
}
export function internalTripleToClient(triple: Triple): ClientTriple {
const result: ClientTriple = {
s: internalTermToClient(triple.s),
p: internalTermToClient(triple.p),
o: internalTermToClient(triple.o),
};
const g = (triple as unknown as Record<string, unknown>).g;
if (g !== undefined && g !== null) {
if (typeof g === "string") {
result.g = g;
} else {
// If g is a Term, convert it back to client wire format
result.g = (g as Record<string, unknown>).iri as string | undefined;
}
}
return result;
}
// ---------- Deep object translation ----------
/**
* Recursively walk an object and translate every Term-shaped value.
* A client term is detected by the presence of a `t` property
* with value "i", "b", "l", or "t".
*/
function isClientTerm(v: unknown): v is ClientTerm {
return (
typeof v === "object" &&
v !== null &&
"t" in v &&
typeof (v as Record<string, unknown>).t === "string" &&
["i", "b", "l", "t"].includes((v as Record<string, unknown>).t as string)
);
}
/**
* An internal term is detected by the presence of a `type` property
* with value "IRI", "BLANK", "LITERAL", or "TRIPLE".
*/
function isInternalTerm(v: unknown): v is Term {
return (
typeof v === "object" &&
v !== null &&
"type" in v &&
typeof (v as Record<string, unknown>).type === "string" &&
["IRI", "BLANK", "LITERAL", "TRIPLE"].includes(
(v as Record<string, unknown>).type as string,
)
);
}
/**
* Deep-translate all client Terms in a request body to internal format.
* Handles nested objects and arrays.
*/
function deepClientToInternal(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map(deepClientToInternal);
}
if (typeof value === "object") {
if (isClientTerm(value)) {
return clientTermToInternal(value);
}
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = deepClientToInternal(v);
}
return result;
}
return value;
}
/**
* Deep-translate all internal Terms in a response body to client format.
* Handles nested objects and arrays.
*/
function deepInternalToClient(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (Array.isArray(value)) {
return value.map(deepInternalToClient);
}
if (typeof value === "object") {
if (isInternalTerm(value)) {
return internalTermToClient(value);
}
const result: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
result[k] = deepInternalToClient(v);
}
return result;
}
return value;
}
// ---------- Services that contain Term fields ----------
/**
* Services whose requests contain Term/Triple fields that need translation.
* All other services pass through without term translation.
*/
const TERM_BEARING_REQUEST_SERVICES = new Set([
"triples",
"knowledge",
]);
/**
* Services whose responses contain Term/Triple fields that need translation.
*/
const TERM_BEARING_RESPONSE_SERVICES = new Set([
"triples",
"graph-embeddings",
"knowledge",
]);
// ---------- Top-level request / response translators ----------
/**
* Translate a client request body to internal format.
*
* For services that carry Term fields (triples, knowledge), this deep-walks
* the request and converts compact verbose.
* All other services pass through unchanged, since their payloads are simple
* scalar fields (query strings, limits, etc.).
*/
export function translateRequest(service: string, body: unknown): unknown {
if (TERM_BEARING_REQUEST_SERVICES.has(service)) {
return deepClientToInternal(body);
}
return body;
}
/**
* Translate an internal response body to client wire format.
*
* For services that return Term fields (triples, graph-embeddings, knowledge),
* this deep-walks the response and converts verbose compact.
* All other services pass through unchanged.
*/
export function translateResponse(service: string, response: unknown): unknown {
if (TERM_BEARING_RESPONSE_SERVICES.has(service)) {
return deepInternalToClient(response);
}
return response;
}

View file

@ -1,3 +1,11 @@
export { createGateway, run, type GatewayConfig } from "./server.js";
export { DispatcherManager } from "./dispatch/manager.js";
export { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
export {
clientTermToInternal,
clientTripleToInternal,
internalTermToClient,
internalTripleToClient,
translateRequest,
translateResponse,
} from "./dispatch/serialize.js";

View file

@ -2,13 +2,17 @@
* API Gateway HTTP + WebSocket server.
*
* Replaces the Python aiohttp gateway with Fastify.
* Uses the Mux class for WebSocket multiplexing (queue-based request
* buffering, concurrency control, proper task lifecycle).
*
* Python reference: trustgraph-flow/trustgraph/gateway/service.py
*/
import Fastify from "fastify";
import websocketPlugin from "@fastify/websocket";
import { registry } from "@trustgraph/base";
import { DispatcherManager } from "./dispatch/manager.js";
import { Mux, type MuxRequest, type MuxHandler } from "./dispatch/mux.js";
export interface GatewayConfig {
port: number;
@ -27,7 +31,7 @@ export async function createGateway(config: GatewayConfig) {
// Authentication middleware
app.addHook("onRequest", async (request, reply) => {
if (request.url === "/api/v1/metrics") return;
if (request.url === "/api/v1/socket") return; // Socket auth via query param
if (request.url.startsWith("/api/v1/socket")) return; // Socket auth via query param
if (config.secret) {
const auth = request.headers.authorization;
@ -37,7 +41,7 @@ export async function createGateway(config: GatewayConfig) {
}
});
// REST endpoint: POST /api/v1/:kind
// 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>;
@ -50,7 +54,7 @@ export async function createGateway(config: GatewayConfig) {
}
});
// REST endpoint: POST /api/v1/flow/:flow/service/:kind
// 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) => {
@ -67,6 +71,7 @@ export async function createGateway(config: GatewayConfig) {
);
// WebSocket endpoint: /api/v1/socket
// Uses Mux for queue-based request buffering and concurrency control.
app.get("/api/v1/socket", { websocket: true }, (socket, request) => {
// Auth via query param
const url = new URL(request.url, `http://${request.headers.host}`);
@ -76,26 +81,67 @@ export async function createGateway(config: GatewayConfig) {
return;
}
socket.on("message", async (data) => {
try {
const msg = JSON.parse(data.toString());
const { id, service, flow, request: req } = msg;
// Build the MuxHandler that dispatches to the DispatcherManager
const handler: MuxHandler = async (muxReq, respond) => {
if (muxReq.flow) {
await dispatcher.dispatchFlowServiceStreaming(
muxReq.flow,
muxReq.service,
muxReq.request,
respond,
);
} else {
await dispatcher.dispatchGlobalServiceStreaming(
muxReq.service,
muxReq.request,
respond,
);
}
};
const responder = async (response: unknown, complete: boolean) => {
socket.send(JSON.stringify({ id, response, complete }));
const mux = new Mux(handler);
// Start the Mux run loop — sends responses back over the socket
const runPromise = mux.run((data) => {
// Only send if the socket is still open (readyState 1 = OPEN)
if (socket.readyState === 1) {
socket.send(data);
}
});
// Incoming messages get queued into the Mux
socket.on("message", (data) => {
try {
const msg = JSON.parse(data.toString()) as {
id?: string;
service?: string;
flow?: string;
request?: Record<string, unknown>;
};
if (flow) {
await dispatcher.dispatchFlowServiceStreaming(flow, service, req, responder);
} else {
await dispatcher.dispatchGlobalServiceStreaming(service, req, responder);
if (!msg.id || !msg.service || !msg.request) {
socket.send(
JSON.stringify({
id: msg.id ?? null,
error: { type: "bad-request", message: "Missing id, service, or request" },
complete: true,
}),
);
return;
}
const muxReq: MuxRequest = {
id: msg.id,
service: msg.service,
flow: msg.flow,
request: msg.request,
};
mux.receive(muxReq);
} catch (err) {
const msg = JSON.parse(data.toString());
socket.send(
JSON.stringify({
id: msg.id,
error: { type: "internal", message: String(err) },
error: { type: "parse-error", message: String(err) },
complete: true,
}),
);
@ -103,13 +149,26 @@ export async function createGateway(config: GatewayConfig) {
});
socket.on("close", () => {
// Cleanup
mux.stop();
});
socket.on("error", () => {
mux.stop();
});
// Ensure runPromise errors don't go unhandled
runPromise.catch((err) => {
console.error("[Gateway] Mux run loop error:", err);
mux.stop();
if (socket.readyState === 1) {
socket.close(1011, "Internal server error");
}
});
});
// Metrics endpoint
app.get("/api/v1/metrics", async () => {
const { registry } = await import("@trustgraph/base");
// Metrics endpoint — returns Prometheus metrics from prom-client
app.get("/api/v1/metrics", async (_, reply) => {
reply.header("content-type", registry.contentType);
return registry.metrics();
});

View file

@ -5,3 +5,42 @@ export { OpenAIProcessor } from "./model/text-completion/openai.js";
export { ClaudeProcessor } from "./model/text-completion/claude.js";
export { GraphRag, type GraphRagConfig, type GraphRagClients } from "./retrieval/graph-rag.js";
export { DocumentRag, type DocumentRagClients } from "./retrieval/document-rag.js";
export { FalkorDBTriplesStore, type FalkorDBConfig } from "./storage/triples/falkordb.js";
export { FalkorDBTriplesQuery, type FalkorDBQueryConfig } from "./query/triples/falkordb.js";
// Qdrant embeddings storage
export {
QdrantDocEmbeddingsStore,
type QdrantDocEmbeddingsConfig,
type DocEmbeddingsMessage,
type DocEmbeddingChunk,
} from "./storage/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsStore,
type QdrantGraphEmbeddingsConfig,
type GraphEmbeddingsMessage,
type GraphEmbeddingEntity,
} from "./storage/embeddings/qdrant-graph.js";
// Qdrant embeddings query
export {
QdrantDocEmbeddingsQuery,
type QdrantDocQueryConfig,
type ChunkMatch,
type DocEmbeddingsQueryRequest,
} from "./query/embeddings/qdrant-doc.js";
export {
QdrantGraphEmbeddingsQuery,
type QdrantGraphQueryConfig,
type EntityMatch,
type GraphEmbeddingsQueryRequest,
} from "./query/embeddings/qdrant-graph.js";
// Embeddings services
export { OllamaEmbeddingsProcessor, type OllamaEmbeddingsConfig } from "./embeddings/ollama.js";
// Prompt template service
export { PromptTemplateService, type PromptTemplate, type PromptTemplateConfig } from "./prompt/template.js";
// Config service
export { ConfigService, type ConfigServiceConfig } from "./config/service.js";

View file

@ -0,0 +1,154 @@
/**
* Prompt template service.
*
* A FlowProcessor that:
* 1. Consumes prompt requests (name + variables)
* 2. Looks up template by name from an in-memory template map (loaded via config)
* 3. Renders template: replaces {variable} placeholders with values
* 4. Returns { system, prompt } strings
*
* Template config shape (received via config push):
* {
* "prompt": {
* "extract-concepts": {
* "system": "You are a helpful assistant.",
* "prompt": "Extract key concepts from: {query}"
* },
* "graph-rag-synthesize": {
* "system": "You are a knowledge graph assistant.",
* "prompt": "Given this context:\n{context}\n\nAnswer: {query}"
* }
* }
* }
*
* Python reference: trustgraph-flow/trustgraph/prompt/template/service.py
*/
import {
FlowProcessor,
ConsumerSpec,
ProducerSpec,
type ProcessorConfig,
type FlowContext,
type PromptRequest,
type PromptResponse,
} from "@trustgraph/base";
export interface PromptTemplate {
system: string;
prompt: string;
}
export interface PromptTemplateConfig extends ProcessorConfig {
configKey?: string;
}
export class PromptTemplateService extends FlowProcessor {
private templates = new Map<string, PromptTemplate>();
private configKey: string;
constructor(config: PromptTemplateConfig) {
super(config);
this.configKey = config.configKey ?? "prompt";
this.registerSpecification(
new ConsumerSpec<PromptRequest>(
"request",
this.onRequest.bind(this),
),
);
this.registerSpecification(new ProducerSpec<PromptResponse>("response"));
this.registerConfigHandler(this.onPromptConfig.bind(this));
console.log("[PromptTemplate] Service initialized");
}
private async onPromptConfig(
config: Record<string, unknown>,
version: number,
): Promise<void> {
console.log(`[PromptTemplate] Loading prompt configuration version ${version}`);
const promptConfig = config[this.configKey] as
| Record<string, { system?: string; prompt?: string }>
| undefined;
if (!promptConfig) {
console.warn(`[PromptTemplate] No key "${this.configKey}" in config`);
return;
}
try {
this.templates.clear();
for (const [name, template] of Object.entries(promptConfig)) {
this.templates.set(name, {
system: template.system ?? "",
prompt: template.prompt ?? "",
});
}
console.log(
`[PromptTemplate] Loaded ${this.templates.size} template(s): ${[...this.templates.keys()].join(", ")}`,
);
} catch (err) {
console.error("[PromptTemplate] Failed to load prompt configuration:", err);
}
}
private async onRequest(
msg: PromptRequest,
properties: Record<string, string>,
flowCtx: FlowContext,
): Promise<void> {
const requestId = properties.id;
if (!requestId) return;
const responseProducer = flowCtx.flow.producer<PromptResponse>("response");
try {
const template = this.templates.get(msg.name);
if (!template) {
throw new Error(`Unknown prompt template: "${msg.name}"`);
}
const variables = msg.variables ?? {};
const system = renderTemplate(template.system, variables);
const prompt = renderTemplate(template.prompt, variables);
await responseProducer.send(requestId, { system, prompt });
} catch (err) {
console.error(`[PromptTemplate] Error processing request:`, err);
const message = err instanceof Error ? err.message : String(err);
await responseProducer.send(requestId, {
system: "",
prompt: "",
error: { type: "prompt-error", message },
});
}
}
}
/**
* Simple template rendering: replaces {variable} placeholders with values.
* Unmatched placeholders are left as-is.
*/
function renderTemplate(
template: string,
variables: Record<string, string>,
): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => {
if (key in variables) {
return variables[key];
}
return match;
});
}
export async function run(): Promise<void> {
await PromptTemplateService.launch("prompt");
}

View file

@ -0,0 +1,80 @@
/**
* Qdrant document embeddings query service.
*
* Input: vector, user, collection, limit
* Output: list of { chunkId, score } matches
*
* Python reference: trustgraph-flow/trustgraph/query/doc_embeddings/qdrant/service.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
export interface QdrantDocQueryConfig {
url?: string;
apiKey?: string;
}
export interface ChunkMatch {
chunkId: string;
score: number;
}
export interface DocEmbeddingsQueryRequest {
vector: number[];
user: string;
collection: string;
limit: number;
}
export class QdrantDocEmbeddingsQuery {
private client: QdrantClient;
constructor(config: QdrantDocQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantDocQuery] Query service initialized");
}
async query(request: DocEmbeddingsQueryRequest): Promise<ChunkMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
return [];
}
const dim = vector.length;
const collectionName = `d_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantDocQuery] Collection ${collectionName} does not exist, returning empty results`,
);
return [];
}
const searchResult = await this.client.search(collectionName, {
vector,
limit,
with_payload: true,
});
const chunks: ChunkMatch[] = [];
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const chunkId = payload?.chunk_id as string | undefined;
if (chunkId) {
chunks.push({
chunkId,
score: point.score,
});
}
}
return chunks;
}
}

View file

@ -0,0 +1,103 @@
/**
* Qdrant graph embeddings query service.
*
* Input: vector, user, collection, limit
* Output: list of Term entities with scores, deduplicated by entity value
*
* Queries limit*2 points and deduplicates by entity value to ensure
* we return up to `limit` unique entities.
*
* Python reference: trustgraph-flow/trustgraph/query/graph_embeddings/qdrant/service.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import type { Term } from "@trustgraph/base";
export interface QdrantGraphQueryConfig {
url?: string;
apiKey?: string;
}
export interface EntityMatch {
entity: Term;
score: number;
}
export interface GraphEmbeddingsQueryRequest {
vector: number[];
user: string;
collection: string;
limit: number;
}
function createTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
return { type: "LITERAL", value };
}
export class QdrantGraphEmbeddingsQuery {
private client: QdrantClient;
constructor(config: QdrantGraphQueryConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantGraphQuery] Query service initialized");
}
async query(request: GraphEmbeddingsQueryRequest): Promise<EntityMatch[]> {
const { vector, user, collection, limit } = request;
if (!vector || vector.length === 0) {
return [];
}
const dim = vector.length;
const collectionName = `t_${user}_${collection}_${dim}`;
// Check if collection exists -- return empty if not
const exists = await this.client.collectionExists(collectionName);
if (!exists.exists) {
console.log(
`[QdrantGraphQuery] Collection ${collectionName} does not exist, returning empty results`,
);
return [];
}
// Query 2x the limit so we have a better chance of getting `limit`
// unique entities after deduplication (same heuristic as Python impl)
const searchResult = await this.client.search(collectionName, {
vector,
limit: limit * 2,
with_payload: true,
});
const entitySet = new Set<string>();
const entities: EntityMatch[] = [];
for (const point of searchResult) {
const payload = point.payload as Record<string, unknown> | undefined;
const entityValue = payload?.entity as string | undefined;
if (!entityValue) continue;
// Deduplicate by entity value, keeping the highest score (results are
// already sorted by score descending from Qdrant)
if (!entitySet.has(entityValue)) {
entitySet.add(entityValue);
entities.push({
entity: createTerm(entityValue),
score: point.score,
});
}
// Stop once we have enough unique entities
if (entities.length >= limit) break;
}
return entities;
}
}

View file

@ -0,0 +1,243 @@
/**
* FalkorDB triples query service queries RDF triples from FalkorDB.
*
* Implements all SPO query patterns (S, P, O, SP, SO, PO, SPO, *).
*
* Python reference: trustgraph-flow/trustgraph/query/triples/falkordb/service.py
*/
import { createClient, Graph } from "falkordb";
import type { Term, Triple } from "@trustgraph/base";
export interface FalkorDBQueryConfig {
url?: string;
database?: string;
}
function termToValue(term: Term | undefined): string | null {
if (!term) return null;
switch (term.type) {
case "IRI": return term.iri;
case "LITERAL": return term.value;
case "BLANK": return term.id;
default: return null;
}
}
function createTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
return { type: "LITERAL", value };
}
export class FalkorDBTriplesQuery {
private graph: Graph;
constructor(config: FalkorDBQueryConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
}
async queryTriples(
s?: Term,
p?: Term,
o?: Term,
limit = 100,
): Promise<Triple[]> {
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 && pv && ov) {
// SPO — exact match
await this.matchPattern(rawTriples, sv, pv, ov, limit);
} else if (sv && pv) {
// SP — known subject + predicate
await this.matchSP(rawTriples, sv, pv, limit);
} else if (sv && ov) {
// SO — known subject + object
await this.matchSO(rawTriples, sv, ov, limit);
} else if (pv && ov) {
// PO — known predicate + object
await this.matchPO(rawTriples, pv, ov, limit);
} else if (sv) {
// S only
await this.matchS(rawTriples, sv, limit);
} else if (pv) {
// P only
await this.matchP(rawTriples, pv, limit);
} else if (ov) {
// O only
await this.matchO(rawTriples, ov, limit);
} else {
// Wildcard — all triples
await this.matchAll(rawTriples, limit);
}
return rawTriples.slice(0, limit).map(([s, p, o]) => ({
s: createTerm(s),
p: createTerm(p),
o: createTerm(o),
}));
}
private async matchPattern(
out: [string, string, string][],
sv: string, pv: string, ov: string, limit: number,
): Promise<void> {
for (const destType of ["Literal", "Node"] as const) {
const destKey = destType === "Literal" ? "value" : "uri";
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri LIMIT ${limit}`,
{ params: { src: sv, rel: pv, dest: ov } },
);
for (const _rec of (result.data ?? []) as unknown[][]) {
out.push([sv, pv, ov]);
}
}
}
private async matchSP(
out: [string, string, string][],
sv: string, pv: string, limit: number,
): Promise<void> {
// Literals
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN dest.value as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([sv, pv, rec[0] as string]);
}
// Nodes
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv, rel: pv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([sv, pv, rec[0] as string]);
}
}
private async matchSO(
out: [string, string, string][],
sv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN rel.uri as rel LIMIT ${limit}`,
{ params: { src: sv, dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, ov]);
}
}
}
private async matchPO(
out: [string, string, string][],
pv: string, ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src LIMIT ${limit}`,
{ params: { rel: pv, dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, ov]);
}
}
}
private async matchS(
out: [string, string, string][],
sv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Literal) ` +
`RETURN rel.uri as rel, dest.value as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, rec[1] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node {uri: $src})-[rel:Rel]->(dest:Node) ` +
`RETURN rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
{ params: { src: sv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([sv, rec[0] as string, rec[1] as string]);
}
}
private async matchP(
out: [string, string, string][],
pv: string, limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Literal) ` +
`RETURN src.uri as src, dest.value as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, rec[1] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel {uri: $rel}]->(dest:Node) ` +
`RETURN src.uri as src, dest.uri as dest LIMIT ${limit}`,
{ params: { rel: pv } },
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([rec[0] as string, pv, rec[1] as string]);
}
}
private async matchO(
out: [string, string, string][],
ov: string, limit: number,
): Promise<void> {
for (const [destType, destKey] of [["Literal", "value"], ["Node", "uri"]] as const) {
const result = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:${destType} {${destKey}: $dest}) ` +
`RETURN src.uri as src, rel.uri as rel LIMIT ${limit}`,
{ params: { dest: ov } },
);
for (const rec of (result.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, ov]);
}
}
}
private async matchAll(
out: [string, string, string][],
limit: number,
): Promise<void> {
const litResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Literal) ` +
`RETURN src.uri as src, rel.uri as rel, dest.value as dest LIMIT ${limit}`,
);
for (const rec of (litResult.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, rec[2] as string]);
}
const nodeResult = await this.graph.query(
`MATCH (src:Node)-[rel:Rel]->(dest:Node) ` +
`RETURN src.uri as src, rel.uri as rel, dest.uri as dest LIMIT ${limit}`,
);
for (const rec of (nodeResult.data ?? []) as string[][]) {
out.push([rec[0] as string, rec[1] as string, rec[2] as string]);
}
}
}

View file

@ -124,26 +124,156 @@ export class GraphRag {
}
private async followEdges(entities: Term[]): Promise<Triple[]> {
// Batch triple queries for all entities
const allTriples: Triple[] = [];
// BFS multi-hop traversal up to maxPathLength
const visited = new Set<string>();
const subgraph: Triple[] = [];
const queries = entities.map((entity) =>
this.clients.triples.request({ s: entity, limit: this.config.tripleLimit }),
// Current frontier: the set of entities to expand at this depth level
let currentLevel = new Set<string>(
entities.map((e) => termToString(e)),
);
const results = await Promise.all(queries);
for (const result of results) {
allTriples.push(...(result as TriplesQueryResponse).triples);
for (let depth = 0; depth < this.config.maxPathLength; depth++) {
if (currentLevel.size === 0 || subgraph.length >= this.config.maxSubgraphSize) {
break;
}
// Filter out already-visited entities
const unvisited = [...currentLevel].filter((e) => !visited.has(e));
if (unvisited.length === 0) break;
// Batch triple queries for all unvisited entities at this depth
// Query each entity as subject to get outgoing edges
const queries = unvisited.map((entityStr) => {
const term = stringToTerm(entityStr);
return this.clients.triples.request({
s: term,
limit: this.config.tripleLimit,
});
});
const results = await Promise.all(queries);
const nextLevel = new Set<string>();
for (const result of results) {
const triples = (result as TriplesQueryResponse).triples;
for (const triple of triples) {
subgraph.push(triple);
// Collect objects as next-level entities for further expansion
// (only if we have more depth levels remaining)
if (depth < this.config.maxPathLength - 1) {
const objStr = termToString(triple.o);
if (!visited.has(objStr)) {
nextLevel.add(objStr);
}
}
if (subgraph.length >= this.config.maxSubgraphSize) {
return subgraph;
}
}
}
// Mark current level as visited and move to next
for (const e of currentLevel) {
visited.add(e);
}
currentLevel = nextLevel;
}
// TODO: Multi-hop traversal up to maxPathLength
return allTriples.slice(0, this.config.maxSubgraphSize);
return subgraph.slice(0, this.config.maxSubgraphSize);
}
private async scoreEdges(query: string, triples: Triple[]): Promise<Triple[]> {
// TODO: LLM-based edge scoring and filtering
// For now, return top N edges
return triples.slice(0, this.config.edgeLimit);
if (triples.length === 0) return [];
// If the subgraph is small enough, skip LLM scoring entirely
if (triples.length <= this.config.edgeLimit) {
return triples;
}
// Build a numbered list of edges for the LLM to score
const edgeDescriptions = triples.map((t, i) => ({
id: String(i),
s: termToString(t.s),
p: termToString(t.p),
o: termToString(t.o),
}));
// Limit how many edges we send for scoring to avoid overflowing context
const toScore = edgeDescriptions.slice(0, this.config.edgeScoreLimit);
const knowledgeJson = JSON.stringify(toScore, null, 2);
// Ask the LLM to score each edge for relevance to the query
const promptResp = await this.clients.prompt.request({
name: "kg-edge-scoring",
variables: {
query,
knowledge: knowledgeJson,
},
});
const llmResp = await this.clients.llm.request({
system: (promptResp as PromptResponse).system,
prompt: (promptResp as PromptResponse).prompt,
});
const responseText = (llmResp as TextCompletionResponse).response;
// Parse scores from LLM response
// Expected format: JSON array of { id: string, score: number }
// or newline-separated JSON objects
const scored: Array<{ id: string; score: number }> = [];
try {
// Try parsing as a JSON array first
const parsed = JSON.parse(responseText) as Array<{ id: string; score: number }>;
if (Array.isArray(parsed)) {
for (const item of parsed) {
if (item && typeof item.id === "string" && typeof item.score === "number") {
scored.push({ id: item.id, score: item.score });
}
}
}
} catch {
// Fall back to parsing line-by-line JSON objects
for (const line of responseText.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as { id?: string; score?: number };
if (obj && typeof obj.id === "string" && typeof obj.score === "number") {
scored.push({ id: obj.id, score: obj.score });
}
} catch {
// Skip unparseable lines
}
}
}
// Sort by score descending and keep top N
scored.sort((a, b) => b.score - a.score);
const topN = scored.slice(0, this.config.edgeLimit);
const selectedIds = new Set(topN.map((e) => e.id));
// Map back to triples
const result: Triple[] = [];
for (const entry of topN) {
const idx = parseInt(entry.id, 10);
if (!isNaN(idx) && idx >= 0 && idx < triples.length) {
result.push(triples[idx]);
}
}
// If scoring failed entirely, fall back to returning the first edgeLimit triples
if (result.length === 0) {
return triples.slice(0, this.config.edgeLimit);
}
return result;
}
private async synthesize(
@ -205,3 +335,13 @@ function termToString(term: Term): string {
return `(${termToString(term.triple.s)} ${termToString(term.triple.p)} ${termToString(term.triple.o)})`;
}
}
function stringToTerm(value: string): Term {
if (value.startsWith("http://") || value.startsWith("https://")) {
return { type: "IRI", iri: value };
}
if (value.startsWith("_:")) {
return { type: "BLANK", id: value.slice(2) };
}
return { type: "LITERAL", value };
}

View file

@ -0,0 +1,106 @@
/**
* Qdrant document embeddings write service.
*
* Stores document chunk embeddings in Qdrant for later similarity search.
* Collection naming: d_{user}_{collection}_{dimension}
* Collections are lazily created on first write with cosine distance.
*
* Python reference: trustgraph-flow/trustgraph/storage/doc_embeddings/qdrant/write.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
export interface QdrantDocEmbeddingsConfig {
url?: string;
apiKey?: string;
}
export interface DocEmbeddingChunk {
chunkId: string;
vector: number[];
}
export interface DocEmbeddingsMessage {
user: string;
collection: string;
chunks: DocEmbeddingChunk[];
}
export class QdrantDocEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
constructor(config: QdrantDocEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantDocEmbeddings] Store initialized");
}
private collectionName(user: string, collection: string, dim: number): string {
return `d_${user}_${collection}_${dim}`;
}
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantDocEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
async store(message: DocEmbeddingsMessage): Promise<void> {
for (const chunk of message.chunks) {
if (!chunk.chunkId || chunk.chunkId === "") continue;
if (!chunk.vector || chunk.vector.length === 0) continue;
const dim = chunk.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
vector: chunk.vector,
payload: { chunk_id: chunk.chunkId },
},
],
});
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
const prefix = `d_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
if (matching.length === 0) {
console.log(`[QdrantDocEmbeddings] No collections matching prefix ${prefix}`);
return;
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
console.log(`[QdrantDocEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantDocEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
}

View file

@ -0,0 +1,127 @@
/**
* Qdrant graph embeddings write service.
*
* Stores entity/vector pairs in Qdrant for graph embeddings lookup.
* Collection naming: t_{user}_{collection}_{dimension}
* Collections are lazily created on first write with cosine distance.
*
* Python reference: trustgraph-flow/trustgraph/storage/graph_embeddings/qdrant/write.py
*/
import { QdrantClient } from "@qdrant/js-client-rest";
import { randomUUID } from "node:crypto";
import type { Term } from "@trustgraph/base";
export interface QdrantGraphEmbeddingsConfig {
url?: string;
apiKey?: string;
}
export interface GraphEmbeddingEntity {
entity: Term;
vector: number[];
chunkId?: string;
}
export interface GraphEmbeddingsMessage {
user: string;
collection: string;
entities: GraphEmbeddingEntity[];
}
function getTermValue(term: Term): string | null {
switch (term.type) {
case "IRI":
return term.iri;
case "LITERAL":
return term.value;
case "BLANK":
return term.id;
case "TRIPLE":
return null;
}
}
export class QdrantGraphEmbeddingsStore {
private client: QdrantClient;
private knownCollections = new Set<string>();
constructor(config: QdrantGraphEmbeddingsConfig = {}) {
const url = config.url ?? process.env.QDRANT_URL ?? "http://localhost:6333";
const apiKey = config.apiKey ?? process.env.QDRANT_API_KEY;
this.client = new QdrantClient({ url, apiKey });
console.log("[QdrantGraphEmbeddings] Store initialized");
}
private collectionName(user: string, collection: string, dim: number): string {
return `t_${user}_${collection}_${dim}`;
}
private async ensureCollection(name: string, dim: number): Promise<void> {
if (this.knownCollections.has(name)) return;
const exists = await this.client.collectionExists(name);
if (!exists.exists) {
console.log(`[QdrantGraphEmbeddings] Creating collection ${name} (dim=${dim})`);
await this.client.createCollection(name, {
vectors: { size: dim, distance: "Cosine" },
});
}
this.knownCollections.add(name);
}
async store(message: GraphEmbeddingsMessage): Promise<void> {
for (const entry of message.entities) {
const entityValue = getTermValue(entry.entity);
if (!entityValue || entityValue === "") continue;
if (!entry.vector || entry.vector.length === 0) continue;
const dim = entry.vector.length;
const name = this.collectionName(message.user, message.collection, dim);
await this.ensureCollection(name, dim);
const payload: Record<string, unknown> = { entity: entityValue };
if (entry.chunkId) {
payload.chunk_id = entry.chunkId;
}
await this.client.upsert(name, {
points: [
{
id: randomUUID(),
vector: entry.vector,
payload,
},
],
});
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
const prefix = `t_${user}_${collection}_`;
const allCollections = await this.client.getCollections();
const matching = allCollections.collections.filter((c) =>
c.name.startsWith(prefix),
);
if (matching.length === 0) {
console.log(`[QdrantGraphEmbeddings] No collections matching prefix ${prefix}`);
return;
}
for (const coll of matching) {
await this.client.deleteCollection(coll.name);
this.knownCollections.delete(coll.name);
console.log(`[QdrantGraphEmbeddings] Deleted collection: ${coll.name}`);
}
console.log(
`[QdrantGraphEmbeddings] Deleted ${matching.length} collection(s) for ${user}/${collection}`,
);
}
}

View file

@ -0,0 +1,116 @@
/**
* FalkorDB triples store writes RDF triples to a FalkorDB graph.
*
* FalkorDB is Redis-based and uses Cypher queries, same as the Python impl.
* Pairs well with Graphiti which also uses FalkorDB as its backend.
*
* Python reference: trustgraph-flow/trustgraph/storage/triples/falkordb/write.py
*/
import { createClient, Graph } from "falkordb";
import type { Term, Triple } from "@trustgraph/base";
export interface FalkorDBConfig {
url?: string;
database?: string;
}
function getTermValue(term: Term): string {
switch (term.type) {
case "IRI":
return term.iri;
case "LITERAL":
return term.value;
case "BLANK":
return term.id;
case "TRIPLE":
return getTermValue(term.triple.s); // fallback
}
}
export class FalkorDBTriplesStore {
private graph: Graph;
constructor(config: FalkorDBConfig = {}) {
const url = config.url ?? process.env.FALKORDB_URL ?? "redis://localhost:6379";
const database = config.database ?? "falkordb";
const client = createClient({ url });
this.graph = new Graph(client, database);
}
async createNode(uri: string, user: string, collection: string): Promise<void> {
await this.graph.query(
"MERGE (n:Node {uri: $uri, user: $user, collection: $collection})",
{ params: { uri, user, collection } },
);
}
async createLiteral(value: string, user: string, collection: string): Promise<void> {
await this.graph.query(
"MERGE (n:Literal {value: $value, user: $user, collection: $collection})",
{ params: { value, user, collection } },
);
}
async relateNode(
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Node {uri: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
async relateLiteral(
src: string, uri: string, dest: string,
user: string, collection: string,
): Promise<void> {
await this.graph.query(
"MATCH (src:Node {uri: $src, user: $user, collection: $collection}) " +
"MATCH (dest:Literal {value: $dest, user: $user, collection: $collection}) " +
"MERGE (src)-[:Rel {uri: $uri, user: $user, collection: $collection}]->(dest)",
{ params: { src, dest, uri, user, collection } },
);
}
async storeTriples(
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 this.createNode(s, user, collection);
if (t.o.type === "IRI") {
await this.createNode(o, user, collection);
await this.relateNode(s, p, o, user, collection);
} else {
await this.createLiteral(o, user, collection);
await this.relateLiteral(s, p, o, user, collection);
}
}
}
async deleteCollection(user: string, collection: string): Promise<void> {
await this.graph.query(
"MATCH (n:Node {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
"MATCH (n:Literal {user: $user, collection: $collection}) DETACH DELETE n",
{ params: { user, collection } },
);
await this.graph.query(
"MATCH (c:CollectionMetadata {user: $user, collection: $collection}) DELETE c",
{ params: { user, collection } },
);
}
}