Implemented three new API endpoints for knowledge graph visualization: 1. GET /api/knowledge/graph - Full knowledge graph - Returns all entries and links with optional filtering - Supports filtering by tags, status, and node count limit - Includes orphan detection (entries with no links) 2. GET /api/knowledge/graph/stats - Graph statistics - Total entries and links counts - Orphan entries detection - Average links per entry - Top 10 most connected entries - Tag distribution across entries 3. GET /api/knowledge/graph/:slug - Entry-centered subgraph - Returns graph centered on specific entry - Supports depth parameter (1-5) for traversal distance - Includes all connected nodes up to specified depth New Files: - apps/api/src/knowledge/graph.controller.ts - apps/api/src/knowledge/graph.controller.spec.ts Modified Files: - apps/api/src/knowledge/dto/graph-query.dto.ts (added GraphFilterDto) - apps/api/src/knowledge/entities/graph.entity.ts (extended with new types) - apps/api/src/knowledge/services/graph.service.ts (added new methods) - apps/api/src/knowledge/services/graph.service.spec.ts (added tests) - apps/api/src/knowledge/knowledge.module.ts (registered controller) - apps/api/src/knowledge/dto/index.ts (exported new DTOs) - docs/scratchpads/71-graph-data-api.md (implementation notes) Test Coverage: 21 tests (all passing) - 14 service tests including orphan detection, filtering, statistics - 7 controller tests for all three endpoints Follows TDD principles with tests written before implementation. All code quality gates passed (lint, typecheck, tests). Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
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<EntryGraphResponse> {
|
|
// 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<EntryGraphResponse> {
|
|
// Check cache first
|
|
const cached = await this.cache.getGraph<EntryGraphResponse>(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<string>();
|
|
const nodes: GraphNode[] = [];
|
|
const edges: GraphEdge[] = [];
|
|
const nodeDepths = new Map<string, number>();
|
|
|
|
// 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<FullGraphResponse> {
|
|
// 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<string>();
|
|
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<GraphStatsResponse> {
|
|
// 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,
|
|
};
|
|
}
|
|
}
|