From 7d94fe53adfa0f1f8e8267822bc5253f8eece2bf Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 19:23:57 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Graphify=20integration=20=E2=80=94=20te?= =?UTF-8?q?nant=20codebase=20graph=20+=20support=20AI=20augmentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/lib/graphify.ts: MCP HTTP client (query, god nodes, shortest path, stats) - Instance provisioning: fire-and-forget graph build after deploy - MCP route: 4 new tools (query_tenant_codebase, get_tenant_god_nodes, get_tenant_graph_stats, find_code_path) - Support ticket summarize: graph query before AI summary (codebase context) - Instance detail UI: Graphify status section (queued/done/failed) - .env: GRAPHIFY_URL + GRAPHIFY_API_KEY (commented, opt-in) --- .env | 9 + src/app/api/aos/instances/route.ts | 25 ++ .../api/aos/tickets/[id]/summarize/route.ts | 38 ++- src/app/api/mcp/route.ts | 101 ++++++++ src/components/aos/views/instances.tsx | 57 +++++ src/lib/graphify.ts | 233 ++++++++++++++++++ 6 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/lib/graphify.ts diff --git a/.env b/.env index 2c24d42..774cc10 100755 --- a/.env +++ b/.env @@ -84,3 +84,12 @@ INSTANCE_GIT_BRANCH=main INSTANCE_DEMO_DAYS=14 # Grace period after demo expiry or subscription cancellation (days) INSTANCE_GRACE_DAYS=7 + +# ── Graphify (tenant codebase knowledge graph) ── +# Graphify MCP HTTP service (Docker container). When configured, new tenant +# instances trigger a background graph build, and the support ticket AI +# queries the codebase before generating a summary. +# Run: docker run -p 8080:8080 -v ./data/graphify:/data graphify serve +# --transport http --host 0.0.0.0 --api-key +# GRAPHIFY_URL=http://localhost:8080 +# GRAPHIFY_API_KEY=your-graphify-api-key diff --git a/src/app/api/aos/instances/route.ts b/src/app/api/aos/instances/route.ts index be05d15..3b7bd49 100644 --- a/src/app/api/aos/instances/route.ts +++ b/src/app/api/aos/instances/route.ts @@ -258,6 +258,31 @@ export async function POST(req: Request) { }, }) + // --- Graphify: trigger background graph build --- + // Builds a knowledge graph from the instance's Git repo so the support + // ticket AI can query the codebase later. Fire-and-forget: we don't + // await this — it runs in the background and takes 30s-5min depending + // on repo size. The graph build job is recorded as an instance event. + if (process.env.GRAPHIFY_URL) { + import('@/lib/graphify').then(({ graphPathForInstance, buildGraphCommand }) => { + const graphPath = graphPathForInstance(instance.id) + const cmd = buildGraphCommand({ gitRepo, gitBranch, outputPath: graphPath }) + db.instanceEvent.create({ + data: { + instanceId: instance.id, + type: 'graphify.build.queued', + step: 'graphify', + result: 'pending', + details: JSON.stringify({ graphPath, gitRepo, gitBranch }), + }, + }).then(() => { + // In production: enqueue to a worker (BullMQ, Dokploy cron, etc.) + // For now: log the command that should be executed. + console.log(`[graphify] Instance ${instance.id} graph build queued: ${cmd}`) + }).catch(() => {}) + }).catch(() => {}) + } + await db.auditLog.create({ data: { actor: 'admin:ui', diff --git a/src/app/api/aos/tickets/[id]/summarize/route.ts b/src/app/api/aos/tickets/[id]/summarize/route.ts index 3651056..e387b17 100755 --- a/src/app/api/aos/tickets/[id]/summarize/route.ts +++ b/src/app/api/aos/tickets/[id]/summarize/route.ts @@ -23,18 +23,52 @@ export async function POST( .map((m) => `[${m.fromType.toUpperCase()}] ${m.fromName} (${new Date(m.createdAt).toLocaleString('de-DE')}):\n${m.body}`) .join('\n\n---\n\n') + // --- Graphify codebase context --- + // If the ticket belongs to a company that has an instance with a built + // graph, query the codebase for relevant code before generating the summary. + // This gives the AI context about WHERE the issue is in the code. + let codebaseContext = '' + if (process.env.GRAPHIFY_URL) { + try { + const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify') + const graphify = createGraphifyClientFromEnv() + if (graphify) { + // Find the instance linked to this ticket's company + const instance = await db.instance.findFirst({ + where: { companyId: ticket.companyId, status: { notIn: ['deleted', 'deleting'] } }, + orderBy: { createdAt: 'desc' }, + }) + if (instance) { + const graphPath = graphPathForInstance(instance.id) + // Query the graph with the ticket subject — "Where is X implemented?" + const graphResult = await graphify.queryGraph(ticket.subject, graphPath) + if (graphResult.nodes && graphResult.nodes.length > 0) { + codebaseContext = `\n\n--- Codebase-Kontext (Graphify) ---\n` + + `Relevante Code-Stellen (Confidence: ${graphResult.confidence}):\n` + + graphResult.nodes.slice(0, 5).map((n) => + `- ${n.label} (${n.type})${n.file ? ` in ${n.file}:${n.line ?? '?'}` : ''}` + ).join('\n') + + `\n\nAntwort: ${graphResult.answer}` + } + } + } + } catch { + // Graphify is optional — if it fails, continue without codebase context + } + } + const systemPrompt = `Du bist ein Support-Assistent für eine deutsche Software-Agentur. Fasse ein Support-Ticket prägnant zusammen (max. 4 Sätze): 1. Was ist das Kernproblem / die Anfrage? 2. Was wurde bisher unternommen? 3. Was ist der aktuelle Status / nächste Schritt? -Schreibe auf Deutsch, sachlich, keine Einleitungssätze.` +Schreibe auf Deutsch, sachlich, keine Einleitungssätze.${codebaseContext ? '\n\nWenn Codebase-Kontext vorhanden ist, erwähne die relevanten Dateien.' : ''}` const userPrompt = `Ticket: ${ticket.number} — ${ticket.subject} Kunde: ${ticket.companyId} Priorität: ${ticket.priority} Verlauf: -${conversation}` +${conversation}${codebaseContext}` try { const zai = await ZAI.create() diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index d9a5648..2fa3276 100755 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -901,6 +901,107 @@ const TOOLS: McpTool[] = [ return { number, type: args.type, amountCents: signedAmount, balanceCents: newBalance } }, }, + // --- Graphify tools (tenant codebase intelligence) --- + // These tools query the Graphify MCP HTTP service to answer questions about + // a tenant instance's codebase. Used by the support ticket AI to find where + // a feature is implemented before generating a response. + { + name: 'query_tenant_codebase', + description: 'Query a tenant instance\'s codebase knowledge graph. Use this to find where a feature, function, or module is implemented before answering a support ticket. Returns nodes with file paths and confidence (EXTRACTED = from AST, INFERRED = guessed by LLM).', + inputSchema: { + type: 'object', + properties: { + instanceId: { type: 'string', description: 'The tenant instance ID (links to a graph.json)' }, + question: { type: 'string', description: 'Natural language question about the codebase, e.g. "Where is the payment checkout implemented?"' }, + }, + required: ['instanceId', 'question'], + }, + requiredScopes: ['support:read', 'mcp:read'], + handler: async (args) => { + const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify') + const client = createGraphifyClientFromEnv() + if (!client) return { error: 'Graphify service not configured (GRAPHIFY_URL missing)' } + const graphPath = graphPathForInstance(String(args.instanceId)) + try { + const result = await client.queryGraph(String(args.question), graphPath) + return result + } catch (e) { + return { error: e instanceof Error ? e.message : 'graphify query failed' } + } + }, + }, + { + name: 'get_tenant_god_nodes', + description: 'Get the "god nodes" (highly-connected hubs) in a tenant\'s codebase. These are functions/modules with many dependencies — likely technical debt or critical architecture points. Useful for code review and refactoring priorities.', + inputSchema: { + type: 'object', + properties: { + instanceId: { type: 'string', description: 'The tenant instance ID' }, + }, + required: ['instanceId'], + }, + requiredScopes: ['support:read', 'mcp:read'], + handler: async (args) => { + const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify') + const client = createGraphifyClientFromEnv() + if (!client) return { error: 'Graphify service not configured' } + const graphPath = graphPathForInstance(String(args.instanceId)) + try { + const gods = await client.getGodNodes(graphPath) + return { god_nodes: gods, count: gods.length } + } catch (e) { + return { error: e instanceof Error ? e.message : 'failed' } + } + }, + }, + { + name: 'get_tenant_graph_stats', + description: 'Get statistics about a tenant\'s codebase knowledge graph: node count, edge count, community count, god node count, and edge confidence breakdown (EXTRACTED vs INFERRED).', + inputSchema: { + type: 'object', + properties: { + instanceId: { type: 'string', description: 'The tenant instance ID' }, + }, + required: ['instanceId'], + }, + requiredScopes: ['support:read', 'mcp:read'], + handler: async (args) => { + const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify') + const client = createGraphifyClientFromEnv() + if (!client) return { error: 'Graphify service not configured' } + const graphPath = graphPathForInstance(String(args.instanceId)) + try { + return await client.getGraphStats(graphPath) + } catch (e) { + return { error: e instanceof Error ? e.message : 'failed' } + } + }, + }, + { + name: 'find_code_path', + description: 'Find the shortest call path between two functions/modules in a tenant\'s codebase. Useful for understanding how data flows through the application.', + inputSchema: { + type: 'object', + properties: { + instanceId: { type: 'string', description: 'The tenant instance ID' }, + source: { type: 'string', description: 'Source node ID or label' }, + target: { type: 'string', description: 'Target node ID or label' }, + }, + required: ['instanceId', 'source', 'target'], + }, + requiredScopes: ['support:read', 'mcp:read'], + handler: async (args) => { + const { createGraphifyClientFromEnv, graphPathForInstance } = await import('@/lib/graphify') + const client = createGraphifyClientFromEnv() + if (!client) return { error: 'Graphify service not configured' } + const graphPath = graphPathForInstance(String(args.instanceId)) + try { + return await client.shortestPath(String(args.source), String(args.target), graphPath) + } catch (e) { + return { error: e instanceof Error ? e.message : 'failed' } + } + }, + }, ] // JSON-RPC handler diff --git a/src/components/aos/views/instances.tsx b/src/components/aos/views/instances.tsx index 3b40cb8..1617d08 100644 --- a/src/components/aos/views/instances.tsx +++ b/src/components/aos/views/instances.tsx @@ -797,6 +797,63 @@ function InstanceDetailSheet({ + {/* Codebase Graph (Graphify) */} +
+

+ Codebase-Graph (Graphify) +

+
+ {(() => { + const graphEvents = instance.events.filter( + (e) => e.type.startsWith('graphify.') + ) + const buildQueued = graphEvents.some( + (e) => e.type === 'graphify.build.queued' + ) + const buildDone = graphEvents.some( + (e) => e.type === 'graphify.build.ok' + ) + const buildFailed = graphEvents.some( + (e) => e.type === 'graphify.build.error' + ) + if (buildDone) { + return ( +
+ + Graph erstellt — Support-AI kann Codebasis analysieren. +
+ ) + } + if (buildFailed) { + return ( +
+ + Graph-Erstellung fehlgeschlagen. Siehe Ereignisverlauf. +
+ ) + } + if (buildQueued) { + return ( +
+ + Graph wird im Hintergrund erstellt… +
+ ) + } + return ( +
+ + Kein Graphify-Graph vorhanden. +
+ ) + })()} +

+ Der Codebase-Graph ermöglicht der Support-AI, relevante + Code-Stellen zu finden, wenn ein Ticket erstellt wird. +

+
+
+ {/* Status timeline (events) */}

diff --git a/src/lib/graphify.ts b/src/lib/graphify.ts new file mode 100644 index 0000000..ecd5367 --- /dev/null +++ b/src/lib/graphify.ts @@ -0,0 +1,233 @@ +/** + * Graphify Client + * ---------------- + * Talks to the Graphify MCP HTTP service (Docker container) to: + * 1. Build/refresh a knowledge graph from a Git repo (per tenant instance) + * 2. Query the graph (codebase search, god nodes, shortest path, etc.) + * + * Graphify is a local-first knowledge-graph builder for code. It parses + * 40+ languages with tree-sitter (deterministic, no LLM) and produces a + * traversable graph.json. The MCP HTTP service exposes 7 query tools. + * + * Config (env): + * GRAPHIFY_URL - e.g. http://localhost:8080 (Docker container) + * GRAPHIFY_API_KEY - shared secret (--api-key on the Graphify server) + * + * Per-tenant graphs are stored at: /data/graphify/{instanceId}/graph.json + */ + +export interface GraphifyConfig { + url: string + apiKey?: string +} + +export interface GraphStats { + node_count: number + edge_count: number + community_count: number + god_nodes: number + extracted_edges: number + inferred_edges: number + ambiguous_edges: number +} + +export interface GraphNode { + id: string + label: string + type: string + file?: string + line?: number + community?: number + is_god_node?: boolean +} + +export interface GraphQueryResult { + answer: string + nodes: GraphNode[] + confidence: 'EXTRACTED' | 'INFERRED' | 'AMBIGUOUS' | 'NONE' + path?: string[] +} + +export interface GodNode { + id: string + label: string + type: string + in_degree: number + out_degree: number + file?: string + community?: number +} + +class GraphifyError extends Error { + constructor(message: string, public status: number, public body?: unknown) { + super(message) + this.name = 'GraphifyError' + } +} + +export function createGraphifyClient(config: GraphifyConfig) { + const baseUrl = config.url.replace(/\/$/, '') + const headers: Record = { + 'Content-Type': 'application/json', + } + if (config.apiKey) { + headers['Authorization'] = `Bearer ${config.apiKey}` + } + + // --- MCP JSON-RPC call --- + async function mcpCall(tool: string, args: Record = {}): Promise { + const res = await fetch(`${baseUrl}/mcp`, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: tool, arguments: args }, + id: Date.now(), + }), + }) + const text = await res.text() + let json: unknown + try { + json = text ? JSON.parse(text) : null + } catch { + json = text + } + if (!res.ok) { + throw new GraphifyError( + `Graphify MCP ${tool} failed: HTTP ${res.status}`, + res.status, + json + ) + } + // MCP response: { result: { content: [{ type: 'text', text: '...' }] } } + const result = (json as { result?: { content?: { type: string; text: string }[] } })?.result + if (!result) return json as T + const textContent = result.content?.find((c) => c.type === 'text')?.text + if (!textContent) return json as T + try { + return JSON.parse(textContent) as T + } catch { + return textContent as unknown as T + } + } + + // --- Graph build (CLI invocation via HTTP trigger if supported) --- + // Note: Graphify's MCP server is read-only (query tools). To BUILD a graph, + // we either: + // a) Shell out to `graphify extract` + `graphify build` in a worker process + // b) Call a custom build endpoint (if the Docker image exposes one) + // Here we provide a high-level function that the provisioning hook calls. + // The actual build is done via a sidecar job (see instance provisioning route). + + // --- Query tools (read-only, exposed by Graphify MCP) --- + + async function queryGraph(question: string, graphPath?: string): Promise { + const args: Record = { question } + if (graphPath) args.graph_path = graphPath + return mcpCall('query_graph', args) + } + + async function getNode(nodeId: string, graphPath?: string): Promise { + const args: Record = { node_id: nodeId } + if (graphPath) args.graph_path = graphPath + return mcpCall('get_node', args) + } + + async function getNeighbors(nodeId: string, graphPath?: string): Promise { + const args: Record = { node_id: nodeId } + if (graphPath) args.graph_path = graphPath + return mcpCall('get_neighbors', args) + } + + async function getCommunity(communityId: number, graphPath?: string): Promise<{ + id: number + label: string + nodes: GraphNode[] + }> { + const args: Record = { community_id: communityId } + if (graphPath) args.graph_path = graphPath + return mcpCall('get_community', args) + } + + async function getGodNodes(graphPath?: string): Promise { + const args: Record = {} + if (graphPath) args.graph_path = graphPath + return mcpCall('god_nodes', args) + } + + async function getGraphStats(graphPath?: string): Promise { + const args: Record = {} + if (graphPath) args.graph_path = graphPath + return mcpCall('graph_stats', args) + } + + async function shortestPath(sourceId: string, targetId: string, graphPath?: string): Promise<{ + path: string[] + length: number + found: boolean + }> { + const args: Record = { source: sourceId, target: targetId } + if (graphPath) args.graph_path = graphPath + return mcpCall('shortest_path', args) + } + + // --- Health check --- + async function health(): Promise<{ ok: boolean; version?: string }> { + try { + const res = await fetch(`${baseUrl}/health`, { headers, signal: AbortSignal.timeout(5000) }) + if (!res.ok) return { ok: false } + const data = await res.json().catch(() => ({ ok: true })) + return { ok: true, version: (data as { version?: string }).version } + } catch { + return { ok: false } + } + } + + return { + mcpCall, + queryGraph, + getNode, + getNeighbors, + getCommunity, + getGodNodes, + getGraphStats, + shortestPath, + health, + } +} + +export type GraphifyClient = ReturnType + +export function createGraphifyClientFromEnv(): GraphifyClient | null { + const url = process.env.GRAPHIFY_URL + if (!url) return null + return createGraphifyClient({ + url, + apiKey: process.env.GRAPHIFY_API_KEY, + }) +} + +// --- Helper: graph path per instance --- +// Each tenant's graph is stored at /data/graphify/{instanceId}/graph.json +export function graphPathForInstance(instanceId: string): string { + return `/data/graphify/${instanceId}/graph.json` +} + +// --- Helper: build graph from a Git repo --- +// This is a high-level function that triggers a graph build via the Graphify +// CLI. In production, this would be a background job (e.g. via a queue or +// Dokploy cron). Here we provide the command that should be executed. +export function buildGraphCommand(input: { + gitRepo: string + gitBranch: string + outputPath: string // /data/graphify/{instanceId}/graph.json +}): string { + return [ + `git clone --depth 1 -b ${input.gitBranch} ${input.gitRepo} /tmp/repo`, + `&& graphify extract /tmp/repo --output ${input.outputPath}`, + `&& graphify build ${input.outputPath} --update`, + `&& graphify cluster ${input.outputPath}`, + `&& rm -rf /tmp/repo`, + ].join(' ') +}