import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../prisma/prisma.service"; import type { EntryGraphResponse, GraphNode, GraphEdge, FullGraphResponse, GraphStatsResponse, } from "../entities/graph.entity"; import { KnowledgeCacheService } from "./cache.service"; import { Prisma } from "@prisma/client"; interface GraphFilterOptions { tags?: string[]; status?: string; limit?: number; } /** * Service for knowledge graph operations */ @Injectable() export class GraphService { constructor( private readonly prisma: PrismaService, private readonly cache: KnowledgeCacheService ) {} /** * Get entry-centered graph view by slug * Helper method that looks up the entry ID first */ async getEntryGraphBySlug( workspaceId: string, slug: string, maxDepth = 1 ): Promise { // Find entry by slug const entry = await this.prisma.knowledgeEntry.findUnique({ where: { workspaceId_slug: { workspaceId, slug, }, }, }); if (!entry) { throw new NotFoundException("Entry not found"); } return this.getEntryGraph(workspaceId, entry.id, maxDepth); } /** * Get entry-centered graph view * Returns the entry and all connected nodes up to specified depth */ async getEntryGraph( workspaceId: string, entryId: string, maxDepth = 1 ): Promise { // Check cache first const cached = await this.cache.getGraph(workspaceId, entryId, maxDepth); if (cached) { return cached; } // Verify entry exists const centerEntry = await this.prisma.knowledgeEntry.findUnique({ where: { id: entryId }, include: { tags: { include: { tag: true, }, }, }, }); if (!centerEntry || centerEntry.workspaceId !== workspaceId) { throw new NotFoundException("Entry not found"); } // Build graph using BFS const visitedNodes = new Set(); const nodes: GraphNode[] = []; const edges: GraphEdge[] = []; const nodeDepths = new Map(); // Queue: [entryId, depth] const queue: [string, number][] = [[entryId, 0]]; visitedNodes.add(entryId); nodeDepths.set(entryId, 0); while (queue.length > 0) { const item = queue.shift(); if (!item) break; // Should never happen, but satisfy TypeScript const [currentId, depth] = item; // Fetch current entry with related data const currentEntry = await this.prisma.knowledgeEntry.findUnique({ where: { id: currentId }, include: { tags: { include: { tag: true, }, }, outgoingLinks: { include: { target: { select: { id: true, slug: true, title: true, summary: true, }, }, }, }, incomingLinks: { include: { source: { select: { id: true, slug: true, title: true, summary: true, }, }, }, }, }, }); if (!currentEntry) continue; // Add current node const graphNode: GraphNode = { id: currentEntry.id, slug: currentEntry.slug, title: currentEntry.title, summary: currentEntry.summary, tags: currentEntry.tags.map((et) => ({ id: et.tag.id, name: et.tag.name, slug: et.tag.slug, color: et.tag.color, })), depth, }; nodes.push(graphNode); // Continue BFS if not at max depth if (depth < maxDepth) { // Process outgoing links (only resolved ones) for (const link of currentEntry.outgoingLinks) { // Skip unresolved links if (!link.targetId || !link.resolved) continue; // Add edge edges.push({ id: link.id, sourceId: link.sourceId, targetId: link.targetId, linkText: link.linkText, }); // Add target to queue if not visited if (!visitedNodes.has(link.targetId)) { visitedNodes.add(link.targetId); nodeDepths.set(link.targetId, depth + 1); queue.push([link.targetId, depth + 1]); } } // Process incoming links (only resolved ones) for (const link of currentEntry.incomingLinks) { // Skip unresolved links if (!link.targetId || !link.resolved) continue; // Add edge const edgeExists = edges.some( (e) => e.sourceId === link.sourceId && e.targetId === link.targetId ); if (!edgeExists) { edges.push({ id: link.id, sourceId: link.sourceId, targetId: link.targetId, linkText: link.linkText, }); } // Add source to queue if not visited if (!visitedNodes.has(link.sourceId)) { visitedNodes.add(link.sourceId); nodeDepths.set(link.sourceId, depth + 1); queue.push([link.sourceId, depth + 1]); } } } } // Find center node const centerNode = nodes.find((n) => n.id === entryId); if (!centerNode) { throw new Error(`Center node ${entryId} not found in graph`); } const result: EntryGraphResponse = { centerNode, nodes, edges, stats: { totalNodes: nodes.length, totalEdges: edges.length, maxDepth, }, }; // Cache the result await this.cache.setGraph(workspaceId, entryId, maxDepth, result); return result; } /** * Get full knowledge graph with optional filtering * Returns all entries and links in the workspace */ async getFullGraph( workspaceId: string, filters?: GraphFilterOptions ): Promise { // Build where clause for entries const where: Prisma.KnowledgeEntryWhereInput = { workspaceId, }; if (filters?.status) { where.status = filters.status as Prisma.EnumEntryStatusFilter; } if (filters?.tags && filters.tags.length > 0) { where.tags = { some: { tag: { slug: { in: filters.tags, }, }, }, }; } // Build query options const queryOptions: { where: Prisma.KnowledgeEntryWhereInput; include: { tags: { include: { tag: true; }; }; }; take?: number; orderBy: { updatedAt: "desc"; }; } = { where, include: { tags: { include: { tag: true, }, }, }, orderBy: { updatedAt: "desc", }, }; if (filters?.limit !== undefined) { queryOptions.take = filters.limit; } // Fetch entries const entries = await this.prisma.knowledgeEntry.findMany(queryOptions); // Get entry IDs for link filtering const entryIds = entries.map((e) => e.id); // Fetch all links between these entries const links = await this.prisma.knowledgeLink.findMany({ where: { sourceId: { in: entryIds }, targetId: { in: entryIds }, resolved: true, }, }); // Build nodes const nodes: GraphNode[] = entries.map((entry) => ({ id: entry.id, slug: entry.slug, title: entry.title, summary: entry.summary, status: entry.status, tags: entry.tags.map( (et: { tag: { id: string; name: string; slug: string; color: string | null } }) => ({ id: et.tag.id, name: et.tag.name, slug: et.tag.slug, color: et.tag.color, }) ), depth: 0, // Full graph has no depth concept isOrphan: false, // Will be calculated next })); // Build edges const edges: GraphEdge[] = links.map((link) => ({ id: link.id, sourceId: link.sourceId, targetId: link.targetId, linkText: link.linkText, })); // Detect orphans (entries with no incoming or outgoing links) const connectedIds = new Set(); for (const edge of edges) { connectedIds.add(edge.sourceId); connectedIds.add(edge.targetId); } let orphanCount = 0; for (const node of nodes) { if (!connectedIds.has(node.id)) { node.isOrphan = true; orphanCount++; } } return { nodes, edges, stats: { totalNodes: nodes.length, totalEdges: edges.length, orphanCount, }, }; } /** * Get graph statistics including orphan detection */ async getGraphStats(workspaceId: string): Promise { // Get total counts const [totalEntries, totalLinks] = await Promise.all([ this.prisma.knowledgeEntry.count({ where: { workspaceId }, }), this.prisma.knowledgeLink.count({ where: { source: { workspaceId }, resolved: true, }, }), ]); // Calculate average links per entry const averageLinks = totalEntries > 0 ? totalLinks / totalEntries : 0; // Find most connected entries using raw query for better performance const mostConnected = await this.prisma.$queryRaw< { id: string; slug: string; title: string; link_count: string; }[] >` SELECT e.id, e.slug, e.title, COUNT(DISTINCT l.id) as link_count FROM knowledge_entries e LEFT JOIN knowledge_links l ON (l.source_id = e.id OR l.target_id = e.id) WHERE e.workspace_id = ${workspaceId}::uuid AND (l.resolved = true OR l.id IS NULL) GROUP BY e.id, e.slug, e.title ORDER BY link_count DESC LIMIT 10 `; const mostConnectedEntries = mostConnected.map((entry) => ({ id: entry.id, slug: entry.slug, title: entry.title, linkCount: parseInt(entry.link_count, 10), })); // Find orphan entries (entries with no links) const orphanEntries = await this.prisma.knowledgeEntry.findMany({ where: { workspaceId, AND: [ { outgoingLinks: { none: { resolved: true, }, }, }, { incomingLinks: { none: { resolved: true, }, }, }, ], }, select: { id: true, }, }); // Get tag distribution const tagGroups = await this.prisma.$queryRaw< { tag_id: string; tag_name: string; entry_count: string; }[] >` SELECT t.id as tag_id, t.name as tag_name, COUNT(DISTINCT et.entry_id) as entry_count FROM knowledge_tags t LEFT JOIN knowledge_entry_tags et ON et.tag_id = t.id WHERE t.workspace_id = ${workspaceId}::uuid GROUP BY t.id, t.name ORDER BY entry_count DESC LIMIT 20 `; const tagDistribution = tagGroups.map((tag) => ({ tagId: tag.tag_id, tagName: tag.tag_name, entryCount: parseInt(tag.entry_count, 10), })); return { totalEntries, totalLinks, orphanEntries: orphanEntries.length, averageLinks, mostConnectedEntries, tagDistribution, }; } }