mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
feat: fix RAG pipelines, Beep Graph branding, PWA, and ambient glow UI
Pipeline fixes: - Fix agent getting empty response from graph-rag by combining answer + explain data in single message (RequestResponse returns first msg) - Fix Doc RAG pipeline: add content field to Qdrant doc payload, seed 10 document chunks, fix type mismatches across base/flow/client - Forward explainability events from agent's KnowledgeQuery to client - Add "agent" to TERM_BEARING_RESPONSE_SERVICES for triple translation - Fix embeddings env var (OLLAMA_URL), user/collection threading, edge scoring threshold, and various protocol mismatches Branding: - Rename TrustGraph → Beep Graph (title, sidebar, settings, about) - Custom lambda + ThugLife pixel glasses SVG logo component - Forest green color palette (brand-50 through brand-900) - SVG favicon + PNG icons (16/32/180/192/512) - PWA manifest with service worker for offline shell caching - Splash screen with animated logo pulse on app load - Ambient glow background with drifting green radial blobs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87f6e5eb05
commit
ee45cb4850
42 changed files with 1690 additions and 153 deletions
|
|
@ -22,7 +22,7 @@ export function buildReActPrompt(
|
|||
|
||||
const toolNames = tools.map((t) => t.name).join(", ");
|
||||
|
||||
const system = `You are a helpful AI assistant that answers questions using available tools.
|
||||
const system = `You are a knowledge graph assistant that answers questions ONLY using data retrieved from available tools. You must NEVER use your own training knowledge to answer — only information returned by tools.
|
||||
|
||||
You have access to the following tools:
|
||||
|
||||
|
|
@ -36,15 +36,17 @@ Action Input: {"argument_name": "value"}
|
|||
Observation: [tool result will be inserted here]
|
||||
... (repeat Thought/Action/Action Input/Observation as needed)
|
||||
Thought: I now have enough information to answer.
|
||||
Final Answer: [your comprehensive answer]
|
||||
Final Answer: [your comprehensive answer based ONLY on tool observations]
|
||||
|
||||
Important:
|
||||
- Always start with a Thought.
|
||||
- Action must be one of: ${toolNames}
|
||||
- Action Input must be valid JSON.
|
||||
- After receiving an Observation, continue with another Thought.
|
||||
- When you have enough information, provide a Final Answer.
|
||||
- Do NOT make up observations. Wait for the tool result.`;
|
||||
- When you have enough information from tool results, provide a Final Answer.
|
||||
- Do NOT make up observations. Wait for the tool result.
|
||||
- Your Final Answer must be grounded ONLY in data from tool observations. If the tools did not return relevant information, your Final Answer MUST state: "The available data sources do not contain specific information about this query, so I cannot provide a grounded answer."
|
||||
- NEVER supplement tool results with your own knowledge. If tool results are incomplete, say so.`;
|
||||
|
||||
return { system, prompt: question };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
createDocumentQueryTool,
|
||||
createTriplesQueryTool,
|
||||
createMcpTool,
|
||||
type ExplainData,
|
||||
} from "./tools.js";
|
||||
import { buildReActPrompt } from "./prompt.js";
|
||||
import { filterToolsByGroupAndState, getNextState } from "../tool-filter.js";
|
||||
|
|
@ -222,7 +223,12 @@ export class AgentService extends FlowProcessor {
|
|||
* Wire up tool execute functions with live requestors from the flow context.
|
||||
* Config-driven tools store placeholders; this replaces them with real impls.
|
||||
*/
|
||||
private wireTools(tools: AgentTool[], flowCtx: FlowContext, collection?: string): AgentTool[] {
|
||||
private wireTools(
|
||||
tools: AgentTool[],
|
||||
flowCtx: FlowContext,
|
||||
collection?: string,
|
||||
onExplain?: (data: ExplainData) => void,
|
||||
): AgentTool[] {
|
||||
return tools.map((tool) => {
|
||||
const implType = tool.config?.["type"] as string | undefined;
|
||||
|
||||
|
|
@ -231,6 +237,7 @@ export class AgentService extends FlowProcessor {
|
|||
const live = createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
collection,
|
||||
onExplain,
|
||||
);
|
||||
return { ...tool, execute: live.execute };
|
||||
}
|
||||
|
|
@ -274,17 +281,24 @@ export class AgentService extends FlowProcessor {
|
|||
const responseProducer = flowCtx.flow.producer<AgentResponse>("agent-response");
|
||||
|
||||
try {
|
||||
// Accumulate explain data from tool calls for emission after completion
|
||||
const explainEvents: ExplainData[] = [];
|
||||
const onExplain = (data: ExplainData) => {
|
||||
explainEvents.push(data);
|
||||
};
|
||||
|
||||
// Build tools — config-driven or hardcoded fallback
|
||||
let tools: AgentTool[];
|
||||
|
||||
if (this.configuredTools) {
|
||||
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection);
|
||||
tools = this.wireTools(this.configuredTools, flowCtx, msg.collection, onExplain);
|
||||
} else {
|
||||
// Hardcoded fallback (backward compat)
|
||||
tools = [
|
||||
createKnowledgeQueryTool(
|
||||
flowCtx.flow.requestor<GraphRagRequest, GraphRagResponse>("graph-rag"),
|
||||
msg.collection,
|
||||
onExplain,
|
||||
),
|
||||
createDocumentQueryTool(
|
||||
flowCtx.flow.requestor<DocumentRagRequest, DocumentRagResponse>("doc-rag"),
|
||||
|
|
@ -348,8 +362,18 @@ export class AgentService extends FlowProcessor {
|
|||
});
|
||||
}
|
||||
|
||||
// If we got a final answer, send it and return
|
||||
// If we got a final answer, emit explain events then send the answer
|
||||
if (parsed.finalAnswer) {
|
||||
// Emit explain events collected from tool calls
|
||||
for (const explain of explainEvents) {
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "explain",
|
||||
content: "",
|
||||
explain_id: explain.explainId,
|
||||
explain_triples: explain.triples,
|
||||
} as AgentResponse);
|
||||
}
|
||||
|
||||
await responseProducer.send(requestId, {
|
||||
chunk_type: "answer",
|
||||
content: parsed.finalAnswer,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import type {
|
|||
ToolRequest,
|
||||
ToolResponse,
|
||||
Term,
|
||||
Triple,
|
||||
} from "@trustgraph/base";
|
||||
|
||||
import type { AgentTool, ToolArg } from "./types.js";
|
||||
|
|
@ -55,12 +56,21 @@ function parseQuestion(input: string): string {
|
|||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain data extracted from a graph-rag response.
|
||||
*/
|
||||
export interface ExplainData {
|
||||
explainId: string;
|
||||
triples: Triple[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the knowledge graph for information about entities and their relationships.
|
||||
*/
|
||||
export function createKnowledgeQueryTool(
|
||||
client: RequestResponse<GraphRagRequest, GraphRagResponse>,
|
||||
collection?: string,
|
||||
onExplain?: (data: ExplainData) => void,
|
||||
): AgentTool {
|
||||
return {
|
||||
name: "KnowledgeQuery",
|
||||
|
|
@ -75,7 +85,19 @@ export function createKnowledgeQueryTool(
|
|||
],
|
||||
async execute(input: string): Promise<string> {
|
||||
const question = parseQuestion(input);
|
||||
console.log(`[KnowledgeQuery] Executing: "${question.slice(0, 60)}..." collection=${collection}`);
|
||||
const res = await client.request({ query: question, collection });
|
||||
console.log(`[KnowledgeQuery] Response (${res.response?.length ?? 0} chars): ${res.error ? `ERROR: ${res.error.message}` : `${res.response?.slice(0, 300)}...`}`);
|
||||
|
||||
// Extract explain data if embedded in the response
|
||||
const rawRes = res as Record<string, unknown>;
|
||||
if (rawRes.message_type === "explain" && rawRes.explain_triples && onExplain) {
|
||||
onExplain({
|
||||
explainId: (rawRes.explain_id as string) ?? "",
|
||||
triples: rawRes.explain_triples as Triple[],
|
||||
});
|
||||
}
|
||||
|
||||
if (res.error) return `Error: ${res.error.message}`;
|
||||
return res.response;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export class OllamaEmbeddingsProcessor extends EmbeddingsService {
|
|||
this.defaultModel = config.model ?? "mxbai-embed-large";
|
||||
this.ollamaHost =
|
||||
config.ollamaHost ??
|
||||
process.env.OLLAMA_URL ??
|
||||
process.env.OLLAMA_HOST ??
|
||||
"http://localhost:11434";
|
||||
|
||||
|
|
|
|||
|
|
@ -240,6 +240,8 @@ const TERM_BEARING_RESPONSE_SERVICES = new Set([
|
|||
"graph-embeddings",
|
||||
"knowledge",
|
||||
"librarian",
|
||||
"graph-rag",
|
||||
"agent",
|
||||
]);
|
||||
|
||||
// ---------- Top-level request / response translators ----------
|
||||
|
|
|
|||
|
|
@ -55,13 +55,17 @@ export class DocEmbeddingsQueryService extends FlowProcessor {
|
|||
for (const vector of msg.vectors ?? []) {
|
||||
const matches = await this.query.query({
|
||||
vector,
|
||||
user: "default",
|
||||
user: msg.user ?? "default",
|
||||
collection,
|
||||
limit: msg.limit ?? 10,
|
||||
});
|
||||
|
||||
for (const match of matches) {
|
||||
allChunks.push({ chunkId: match.chunkId, score: match.score });
|
||||
allChunks.push({
|
||||
chunkId: match.chunkId,
|
||||
score: match.score,
|
||||
content: match.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export interface QdrantDocQueryConfig {
|
|||
export interface ChunkMatch {
|
||||
chunkId: string;
|
||||
score: number;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface DocEmbeddingsQueryRequest {
|
||||
|
|
@ -71,6 +72,7 @@ export class QdrantDocEmbeddingsQuery {
|
|||
chunks.push({
|
||||
chunkId,
|
||||
score: point.score,
|
||||
content: (payload?.content as string) ?? undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,8 +47,9 @@ export class GraphEmbeddingsQueryService extends FlowProcessor {
|
|||
if (!requestId) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphEmbeddingsResponse>("graph-embeddings-response");
|
||||
const user = msg.collection ?? "default";
|
||||
const user = msg.user ?? "default";
|
||||
const collection = msg.collection ?? "default";
|
||||
console.log(`[GraphEmbeddingsQuery] Request: user=${user}, collection=${collection}, vectors=${msg.vectors?.length ?? 0}, limit=${msg.limit}`);
|
||||
|
||||
try {
|
||||
// Query for each vector and aggregate results
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ export class DocumentRagService extends FlowProcessor {
|
|||
collection: msg.collection,
|
||||
});
|
||||
|
||||
await producer.send(requestId, { response });
|
||||
await producer.send(requestId, { response, endOfStream: true });
|
||||
} catch (err) {
|
||||
console.error("[DocumentRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import type {
|
|||
TextCompletionResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
} from "@trustgraph/base";
|
||||
|
|
@ -20,7 +22,7 @@ import type {
|
|||
export interface DocumentRagClients {
|
||||
llm: RequestResponse<TextCompletionRequest, TextCompletionResponse>;
|
||||
embeddings: RequestResponse<EmbeddingsRequest, EmbeddingsResponse>;
|
||||
docEmbeddings: RequestResponse<unknown, unknown>; // Doc embedding query
|
||||
docEmbeddings: RequestResponse<DocumentEmbeddingsRequest, DocumentEmbeddingsResponse>;
|
||||
prompt: RequestResponse<PromptRequest, PromptResponse>;
|
||||
}
|
||||
|
||||
|
|
@ -31,22 +33,31 @@ export class DocumentRag {
|
|||
|
||||
async query(
|
||||
queryText: string,
|
||||
_options?: {
|
||||
options?: {
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
): Promise<string> {
|
||||
const collection = options?.collection ?? "default";
|
||||
|
||||
// Step 1: Embed the query
|
||||
const embResp = await this.clients.embeddings.request({ text: [queryText] });
|
||||
const vectors = (embResp as EmbeddingsResponse).vectors;
|
||||
|
||||
// Step 2: Find similar document chunks
|
||||
const docResp = await this.clients.docEmbeddings.request({ vectors, limit: 10 });
|
||||
const chunks = docResp as { chunks: Array<{ content: string; document: string }> };
|
||||
const docResp = await this.clients.docEmbeddings.request({
|
||||
vectors,
|
||||
limit: 10,
|
||||
collection,
|
||||
user: "default",
|
||||
});
|
||||
const chunks = (docResp as DocumentEmbeddingsResponse).chunks ?? [];
|
||||
console.log(`[DocumentRag] Found ${chunks.length} matching chunks`);
|
||||
|
||||
// Step 3: Build context from chunks
|
||||
const context = (chunks.chunks ?? [])
|
||||
const context = chunks
|
||||
.filter((c) => c.content)
|
||||
.map((c) => c.content)
|
||||
.join("\n\n---\n\n");
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export class GraphRagService extends FlowProcessor {
|
|||
if (!requestId) return;
|
||||
|
||||
const producer = flowCtx.flow.producer<GraphRagResponse>("graph-rag-response");
|
||||
console.log(`[GraphRagService] Received request ${requestId}: "${msg.query?.slice(0, 60)}..." collection=${msg.collection}`);
|
||||
|
||||
try {
|
||||
// Create a per-request GraphRag instance with flow clients
|
||||
|
|
@ -113,11 +114,27 @@ export class GraphRagService extends FlowProcessor {
|
|||
},
|
||||
);
|
||||
|
||||
const response = await graphRag.query(msg.query, {
|
||||
const result = await graphRag.query(msg.query, {
|
||||
collection: msg.collection,
|
||||
});
|
||||
|
||||
await producer.send(requestId, { response });
|
||||
// Send answer with explain data embedded in a SINGLE message.
|
||||
// Non-streaming callers (agent's RequestResponse) return the first
|
||||
// response — so the answer must be in that first (and only) message.
|
||||
// Streaming callers (gateway) extract explain data + answer from
|
||||
// the same message.
|
||||
const response: GraphRagResponse = {
|
||||
response: result.answer,
|
||||
endOfStream: true,
|
||||
};
|
||||
|
||||
if (result.subgraph.length > 0) {
|
||||
(response as Record<string, unknown>).message_type = "explain";
|
||||
(response as Record<string, unknown>).explain_id = `explain-${requestId}`;
|
||||
(response as Record<string, unknown>).explain_triples = result.subgraph;
|
||||
}
|
||||
|
||||
await producer.send(requestId, response);
|
||||
} catch (err) {
|
||||
console.error("[GraphRag] Query failed:", err);
|
||||
await producer.send(requestId, {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ export interface GraphRagClients {
|
|||
|
||||
export type ChunkCallback = (text: string, endOfStream: boolean) => Promise<void>;
|
||||
|
||||
export interface GraphRagResult {
|
||||
answer: string;
|
||||
subgraph: Triple[];
|
||||
}
|
||||
|
||||
export class GraphRag {
|
||||
private config: Required<GraphRagConfig>;
|
||||
|
||||
|
|
@ -58,7 +63,7 @@ export class GraphRag {
|
|||
tripleLimit: config.tripleLimit ?? 30,
|
||||
maxSubgraphSize: config.maxSubgraphSize ?? 1000,
|
||||
maxPathLength: config.maxPathLength ?? 2,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 30,
|
||||
edgeScoreLimit: config.edgeScoreLimit ?? 50,
|
||||
edgeLimit: config.edgeLimit ?? 25,
|
||||
};
|
||||
}
|
||||
|
|
@ -70,28 +75,39 @@ export class GraphRag {
|
|||
streaming?: boolean;
|
||||
chunkCallback?: ChunkCallback;
|
||||
},
|
||||
): Promise<string> {
|
||||
): Promise<GraphRagResult> {
|
||||
console.log(`[GraphRag] Query: "${queryText.slice(0, 80)}..."`);
|
||||
|
||||
// Step 1: Extract concepts from the query via prompt + LLM
|
||||
const concepts = await this.extractConcepts(queryText);
|
||||
console.log(`[GraphRag] Step 1: extracted ${concepts.length} concepts: ${concepts.slice(0, 5).join(", ")}`);
|
||||
|
||||
// Step 2: Embed concepts concurrently
|
||||
const vectors = await this.getVectors(concepts);
|
||||
console.log(`[GraphRag] Step 2: got ${vectors.length} vectors (dim=${vectors[0]?.length ?? 0})`);
|
||||
|
||||
// Step 3: Find matching entities via graph embeddings
|
||||
const entities = await this.getEntities(vectors);
|
||||
const entities = await this.getEntities(vectors, options?.collection);
|
||||
console.log(`[GraphRag] Step 3: found ${entities.length} matching entities`);
|
||||
|
||||
// Step 4: Traverse the knowledge graph from entities
|
||||
const subgraph = await this.followEdges(entities);
|
||||
const subgraph = await this.followEdges(entities, options?.collection);
|
||||
console.log(`[GraphRag] Step 4: traversed graph, ${subgraph.length} triples in subgraph`);
|
||||
|
||||
// Step 5: Score and filter edges via LLM
|
||||
const scoredEdges = await this.scoreEdges(queryText, subgraph);
|
||||
console.log(`[GraphRag] Step 5: scored down to ${scoredEdges.length} edges`);
|
||||
|
||||
// Step 6: Synthesize answer
|
||||
return await this.synthesize(
|
||||
console.log(`[GraphRag] Step 6: synthesizing answer from ${scoredEdges.length} edges...`);
|
||||
const answer = await this.synthesize(
|
||||
queryText,
|
||||
scoredEdges,
|
||||
options?.chunkCallback
|
||||
options?.chunkCallback,
|
||||
);
|
||||
console.log(`[GraphRag] Step 6: done (${answer.length} chars)`);
|
||||
|
||||
return { answer, subgraph: scoredEdges };
|
||||
}
|
||||
|
||||
private async extractConcepts(query: string): Promise<string[]> {
|
||||
|
|
@ -117,15 +133,17 @@ export class GraphRag {
|
|||
return (resp as EmbeddingsResponse).vectors;
|
||||
}
|
||||
|
||||
private async getEntities(vectors: number[][]): Promise<Term[]> {
|
||||
private async getEntities(vectors: number[][], collection?: string): Promise<Term[]> {
|
||||
const resp = await this.clients.graphEmbeddings.request({
|
||||
vectors,
|
||||
user: "default",
|
||||
collection: collection ?? "default",
|
||||
limit: this.config.entityLimit,
|
||||
});
|
||||
return (resp as GraphEmbeddingsResponse).entities;
|
||||
}
|
||||
|
||||
private async followEdges(entities: Term[]): Promise<Triple[]> {
|
||||
private async followEdges(entities: Term[], collection?: string): Promise<Triple[]> {
|
||||
// BFS multi-hop traversal up to maxPathLength
|
||||
const visited = new Set<string>();
|
||||
const subgraph: Triple[] = [];
|
||||
|
|
@ -150,6 +168,7 @@ export class GraphRag {
|
|||
const term = stringToTerm(entityStr);
|
||||
return this.clients.triples.request({
|
||||
s: term,
|
||||
collection,
|
||||
limit: this.config.tripleLimit,
|
||||
});
|
||||
});
|
||||
|
|
@ -192,7 +211,9 @@ export class GraphRag {
|
|||
if (triples.length === 0) return [];
|
||||
|
||||
// If the subgraph is small enough, skip LLM scoring entirely
|
||||
if (triples.length <= this.config.edgeLimit) {
|
||||
// 500 triples is well within LLM context limits and avoids lossy scoring
|
||||
if (triples.length <= 500) {
|
||||
console.log(`[GraphRag] Skipping edge scoring — ${triples.length} triples fits in context directly`);
|
||||
return triples;
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +245,7 @@ export class GraphRag {
|
|||
});
|
||||
|
||||
const responseText = (llmResp as TextCompletionResponse).response;
|
||||
console.log(`[GraphRag] Edge scoring LLM response (first 500 chars): ${responseText.slice(0, 500)}`);
|
||||
|
||||
// Parse scores from LLM response
|
||||
// Expected format: JSON array of { id: string, score: number }
|
||||
|
|
@ -270,6 +292,8 @@ export class GraphRag {
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphRag] Edge scoring: LLM returned ${scored.length} scores, keeping top ${topN.length}, mapped ${result.length} triples`);
|
||||
|
||||
// If scoring failed entirely, fall back to returning the first edgeLimit triples
|
||||
if (result.length === 0) {
|
||||
return triples.slice(0, this.config.edgeLimit);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface QdrantDocEmbeddingsConfig {
|
|||
export interface DocEmbeddingChunk {
|
||||
chunkId: string;
|
||||
vector: number[];
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface DocEmbeddingsMessage {
|
||||
|
|
@ -73,7 +74,10 @@ export class QdrantDocEmbeddingsStore {
|
|||
{
|
||||
id: randomUUID(),
|
||||
vector: chunk.vector,
|
||||
payload: { chunk_id: chunk.chunkId },
|
||||
payload: {
|
||||
chunk_id: chunk.chunkId,
|
||||
...(chunk.content ? { content: chunk.content } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue