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:
1 parent
ada029d500
commit
7d94fe53ad
6 files changed
+461
-2
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(' ')
|
||||
}
|
||||
Reference in new issue
Block a user