feat: Graphify integration — tenant codebase graph + support AI augmentation

- 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)
This commit is contained in:
Z User committed 2026-10-28 19:23:57 +00:00
1 parent ada029d500
commit 7d94fe53ad
6 files changed
+461 -2

No files matched your search

+9
View File
@@ -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 <secret>
# GRAPHIFY_URL=http://localhost:8080
# GRAPHIFY_API_KEY=your-graphify-api-key
+25
View File
@@ -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',
@@ -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()
+101
View File
@@ -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
+57
View File
@@ -797,6 +797,63 @@ function InstanceDetailSheet({
</dl>
</section>
{/* Codebase Graph (Graphify) */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Codebase-Graph (Graphify)
</h3>
<div className="rounded-md border border-border bg-muted/20 p-3 text-xs">
{(() => {
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 (
<div className="flex items-center gap-2">
<CheckCircle2 className="size-3.5 text-foreground" />
<span>Graph erstellt — Support-AI kann Codebasis analysieren.</span>
</div>
)
}
if (buildFailed) {
return (
<div className="flex items-center gap-2">
<AlertTriangle className="size-3.5 text-muted-foreground" />
<span>Graph-Erstellung fehlgeschlagen. Siehe Ereignisverlauf.</span>
</div>
)
}
if (buildQueued) {
return (
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
<span>Graph wird im Hintergrund erstellt…</span>
</div>
)
}
return (
<div className="flex items-center gap-2">
<CircleDashed className="size-3.5 text-muted-foreground" />
<span>Kein Graphify-Graph vorhanden.</span>
</div>
)
})()}
<p className="mt-2 text-[10px] text-muted-foreground">
Der Codebase-Graph ermöglicht der Support-AI, relevante
Code-Stellen zu finden, wenn ein Ticket erstellt wird.
</p>
</div>
</section>
{/* Status timeline (events) */}
<section className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
+233
View File
@@ -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<string, string> = {
'Content-Type': 'application/json',
}
if (config.apiKey) {
headers['Authorization'] = `Bearer ${config.apiKey}`
}
// --- MCP JSON-RPC call ---
async function mcpCall<T = unknown>(tool: string, args: Record<string, unknown> = {}): Promise<T> {
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<GraphQueryResult> {
const args: Record<string, unknown> = { question }
if (graphPath) args.graph_path = graphPath
return mcpCall<GraphQueryResult>('query_graph', args)
}
async function getNode(nodeId: string, graphPath?: string): Promise<GraphNode> {
const args: Record<string, unknown> = { node_id: nodeId }
if (graphPath) args.graph_path = graphPath
return mcpCall<GraphNode>('get_node', args)
}
async function getNeighbors(nodeId: string, graphPath?: string): Promise<GraphNode[]> {
const args: Record<string, unknown> = { node_id: nodeId }
if (graphPath) args.graph_path = graphPath
return mcpCall<GraphNode[]>('get_neighbors', args)
}
async function getCommunity(communityId: number, graphPath?: string): Promise<{
id: number
label: string
nodes: GraphNode[]
}> {
const args: Record<string, unknown> = { community_id: communityId }
if (graphPath) args.graph_path = graphPath
return mcpCall('get_community', args)
}
async function getGodNodes(graphPath?: string): Promise<GodNode[]> {
const args: Record<string, unknown> = {}
if (graphPath) args.graph_path = graphPath
return mcpCall<GodNode[]>('god_nodes', args)
}
async function getGraphStats(graphPath?: string): Promise<GraphStats> {
const args: Record<string, unknown> = {}
if (graphPath) args.graph_path = graphPath
return mcpCall<GraphStats>('graph_stats', args)
}
async function shortestPath(sourceId: string, targetId: string, graphPath?: string): Promise<{
path: string[]
length: number
found: boolean
}> {
const args: Record<string, unknown> = { 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<typeof createGraphifyClient>
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(' ')
}