mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
init
This commit is contained in:
parent
c386f68743
commit
b6536eca38
100 changed files with 17680 additions and 377 deletions
357
ts/packages/flow/src/config/service.ts
Normal file
357
ts/packages/flow/src/config/service.ts
Normal 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");
|
||||
}
|
||||
76
ts/packages/flow/src/embeddings/ollama.ts
Normal file
76
ts/packages/flow/src/embeddings/ollama.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
272
ts/packages/flow/src/gateway/dispatch/serialize.ts
Normal file
272
ts/packages/flow/src/gateway/dispatch/serialize.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
154
ts/packages/flow/src/prompt/template.ts
Normal file
154
ts/packages/flow/src/prompt/template.ts
Normal 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");
|
||||
}
|
||||
80
ts/packages/flow/src/query/embeddings/qdrant-doc.ts
Normal file
80
ts/packages/flow/src/query/embeddings/qdrant-doc.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
103
ts/packages/flow/src/query/embeddings/qdrant-graph.ts
Normal file
103
ts/packages/flow/src/query/embeddings/qdrant-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
243
ts/packages/flow/src/query/triples/falkordb.ts
Normal file
243
ts/packages/flow/src/query/triples/falkordb.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
106
ts/packages/flow/src/storage/embeddings/qdrant-doc.ts
Normal file
106
ts/packages/flow/src/storage/embeddings/qdrant-doc.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
127
ts/packages/flow/src/storage/embeddings/qdrant-graph.ts
Normal file
127
ts/packages/flow/src/storage/embeddings/qdrant-graph.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
116
ts/packages/flow/src/storage/triples/falkordb.ts
Normal file
116
ts/packages/flow/src/storage/triples/falkordb.ts
Normal 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 } },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue