Spaces:
Running
Running
Download apps/api/src/network/graph.service.ts from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 18.3 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/graph.service.ts
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/api/src/network/graph.service.ts
-
curl -L -o graph.service.ts https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/api/src/network/graph.service.ts
18.3 kB
| import { Injectable, Logger, OnModuleInit } from "@nestjs/common"; | |
| import { PrismaService } from "../prisma/prisma.service"; | |
| /** | |
| * Graph Node Types | |
| */ | |
| type GraphNode = | |
| | { type: "guest"; id: string; data: any } | |
| | { type: "podcast"; id: string; data: any } | |
| | { type: "host"; id: string; data: any }; | |
| type GraphEdge = { | |
| source: string; | |
| target: string; | |
| type: "appeared_on" | "hosted" | "booked" | "knows" | "similar_to"; | |
| weight: number; | |
| metadata?: Record<string, any>; | |
| }; | |
| /** | |
| * Social Graph Service | |
| * | |
| * Manages the "who knows who" graph for the Verified Guest Network. | |
| * Enables features like: | |
| * - "Podcasters who booked X also booked Y" | |
| * - Guest recommendations based on network proximity | |
| * - Trust paths between hosts and guests | |
| * | |
| * African Context: Emphasizes community connections and tech hub networks | |
| */ | |
| () | |
| export class GraphService implements OnModuleInit { | |
| private readonly logger = new Logger(GraphService.name); | |
| // In-memory adjacency list for fast traversal | |
| // Backed by the graph_edges table for persistence across restarts | |
| private adjacencyList: Map<string, Map<string, GraphEdge>> = new Map(); | |
| constructor(private readonly prisma: PrismaService) {} | |
| async onModuleInit() { | |
| await this.loadEdgesFromDatabase(); | |
| } | |
| /** | |
| * Load all graph edges from the database into memory on startup. | |
| */ | |
| private async loadEdgesFromDatabase(): Promise<void> { | |
| try { | |
| const edges = await this.prisma.graphEdge.findMany(); | |
| for (const edge of edges) { | |
| const sourceId = edge.source; | |
| const targetId = edge.target; | |
| const storedEdge: GraphEdge = { | |
| source: edge.source, | |
| target: edge.target, | |
| type: edge.type as GraphEdge["type"], | |
| weight: edge.weight, | |
| metadata: edge.metadata as Record<string, any>, | |
| }; | |
| if (!this.adjacencyList.has(sourceId)) { | |
| this.adjacencyList.set(sourceId, new Map()); | |
| } | |
| this.adjacencyList.get(sourceId)!.set(targetId, storedEdge); | |
| // Restore reverse edges for bidirectional types | |
| if (edge.type === "knows" || edge.type === "similar_to" || edge.type === "appeared_on") { | |
| const reverseEdge = { ...storedEdge, source: edge.target, target: edge.source }; | |
| if (!this.adjacencyList.has(edge.target)) { | |
| this.adjacencyList.set(edge.target, new Map()); | |
| } | |
| this.adjacencyList.get(edge.target)!.set(edge.source, reverseEdge); | |
| } | |
| // Restore node data from metadata if available | |
| if (edge.source.startsWith("guest:") || edge.source.startsWith("podcast:")) { | |
| const edges = this.adjacencyList.get(edge.source)!; | |
| if (edge.metadata && typeof edge.metadata === "object") { | |
| const meta = edge.metadata as Record<string, any>; | |
| if (meta.nodeData) { | |
| (edges as any).nodeData = meta.nodeData; | |
| } | |
| } | |
| } | |
| } | |
| this.logger.log(`Loaded ${edges.length} graph edges from database`); | |
| } catch (error) { | |
| this.logger.warn("Failed to load graph edges from database, starting with empty graph", error); | |
| } | |
| } | |
| /** | |
| * Ingest a guest profile into the graph | |
| */ | |
| async ingestProfile(profileId: string): Promise<void> { | |
| this.logger.log(`Ingesting profile ${profileId} into graph`); | |
| const profile = await this.prisma.guestPublicProfile.findUnique({ | |
| where: { id: profileId }, | |
| include: { | |
| expertiseTags: true, | |
| bookingHistory: true, | |
| }, | |
| }); | |
| if (!profile) { | |
| this.logger.warn(`Profile ${profileId} not found`); | |
| return; | |
| } | |
| // Add guest node | |
| this.addNode({ | |
| type: "guest", | |
| id: `guest:${profileId}`, | |
| data: { | |
| name: profile.headline, | |
| country: profile.country, | |
| expertise: profile.expertiseTags.map((t) => t.name), | |
| tier: profile.tier, | |
| }, | |
| }); | |
| // Add appeared_on edges from booking history | |
| for (const booking of profile.bookingHistory) { | |
| const podcastNodeId = `podcast:${booking.podcastName}`; | |
| // Add podcast node if not exists | |
| this.addNode({ | |
| type: "podcast", | |
| id: podcastNodeId, | |
| data: { name: booking.podcastName }, | |
| }); | |
| // Add appeared_on edge | |
| this.addEdge({ | |
| source: `guest:${profileId}`, | |
| target: podcastNodeId, | |
| type: "appeared_on", | |
| weight: 1, | |
| metadata: { | |
| date: booking.appearedAt, | |
| topics: booking.topicsDiscussed, | |
| }, | |
| }); | |
| } | |
| // Calculate similarity edges to other guests | |
| await this.calculateSimilarityEdges(profileId, profile.expertiseTags); | |
| this.logger.log(`Profile ${profileId} ingested successfully`); | |
| } | |
| /** | |
| * Add a node to the graph | |
| */ | |
| private addNode(node: GraphNode): void { | |
| if (!this.adjacencyList.has(node.id)) { | |
| this.adjacencyList.set(node.id, new Map()); | |
| } | |
| // Store node data in the adjacency list entry | |
| const edges = this.adjacencyList.get(node.id)!; | |
| (edges as any).nodeData = node.data; | |
| } | |
| /** | |
| * Add an edge to the graph | |
| */ | |
| private addEdge(edge: GraphEdge): void { | |
| // Add forward edge | |
| if (!this.adjacencyList.has(edge.source)) { | |
| this.adjacencyList.set(edge.source, new Map()); | |
| } | |
| this.adjacencyList.get(edge.source)!.set(edge.target, edge); | |
| // Add reverse edge for undirected relationships (including appeared_on) | |
| if (edge.type === "knows" || edge.type === "similar_to" || edge.type === "appeared_on") { | |
| const reverseEdge = { ...edge, source: edge.target, target: edge.source }; | |
| if (!this.adjacencyList.has(edge.target)) { | |
| this.adjacencyList.set(edge.target, new Map()); | |
| } | |
| this.adjacencyList.get(edge.target)!.set(edge.source, reverseEdge); | |
| } | |
| // Persist to database | |
| this.persistEdge(edge).catch(err => | |
| this.logger.error(`Failed to persist graph edge`, err), | |
| ); | |
| } | |
| /** | |
| * Persist a graph edge to the database. | |
| */ | |
| private async persistEdge(edge: GraphEdge): Promise<void> { | |
| try { | |
| await this.prisma.graphEdge.upsert({ | |
| where: { | |
| source_target_type: { | |
| source: edge.source, | |
| target: edge.target, | |
| type: edge.type, | |
| }, | |
| }, | |
| create: { | |
| source: edge.source, | |
| target: edge.target, | |
| type: edge.type, | |
| weight: edge.weight, | |
| metadata: (edge.metadata || {}) as any, | |
| }, | |
| update: { | |
| weight: edge.weight, | |
| metadata: (edge.metadata || {}) as any, | |
| }, | |
| }); | |
| } catch (error) { | |
| this.logger.error(`Failed to persist edge ${edge.source} -> ${edge.target} (${edge.type})`, error); | |
| } | |
| } | |
| /** | |
| * Calculate similarity edges based on shared expertise and geography | |
| */ | |
| private async calculateSimilarityEdges( | |
| profileId: string, | |
| expertiseTags: { name: string }[] | |
| ): Promise<void> { | |
| const expertiseNames = expertiseTags.map((t) => t.name); | |
| if (expertiseNames.length === 0) return; | |
| // Find other guests with similar expertise | |
| const similarGuests = await this.prisma.guestPublicProfile.findMany({ | |
| where: { | |
| id: { not: profileId }, | |
| isPublic: true, | |
| expertiseTags: { | |
| some: { | |
| name: { in: expertiseNames }, | |
| }, | |
| }, | |
| }, | |
| include: { | |
| expertiseTags: true, | |
| }, | |
| take: 20, | |
| }); | |
| for (const other of similarGuests) { | |
| const otherExpertise = other.expertiseTags.map((t) => t.name); | |
| const sharedExpertise = expertiseNames.filter((e) => | |
| otherExpertise.includes(e) | |
| ); | |
| if (sharedExpertise.length > 0) { | |
| const similarityScore = sharedExpertise.length / | |
| Math.max(expertiseNames.length, otherExpertise.length); | |
| this.addEdge({ | |
| source: `guest:${profileId}`, | |
| target: `guest:${other.id}`, | |
| type: "similar_to", | |
| weight: similarityScore, | |
| metadata: { | |
| sharedExpertise, | |
| score: similarityScore, | |
| }, | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Get recommendations based on graph proximity | |
| * "Podcasters who booked X also booked Y" | |
| */ | |
| async getRecommendations( | |
| profileId: string, | |
| limit = 5 | |
| ): Promise<Array<{ | |
| guestId: string; | |
| reason: string; | |
| score: number; | |
| }>> { | |
| const nodeId = `guest:${profileId}`; | |
| const scores = new Map<string, number>(); | |
| const reasons = new Map<string, string[]>(); | |
| // Get guests who appeared on the same podcasts | |
| const appearedOn = this.getNeighbors(nodeId, "appeared_on"); | |
| for (const podcastId of appearedOn) { | |
| const coGuests = this.getNeighbors(podcastId, "appeared_on") | |
| .filter((g) => g !== nodeId); | |
| for (const coGuest of coGuests) { | |
| const guestId = coGuest.replace("guest:", ""); | |
| const currentScore = scores.get(guestId) || 0; | |
| scores.set(guestId, currentScore + 1); | |
| if (!reasons.has(guestId)) { | |
| reasons.set(guestId, []); | |
| } | |
| const podcastName = this.getNodeData(podcastId)?.name || "a podcast"; | |
| reasons.get(guestId)!.push(`Appeared on ${podcastName}`); | |
| } | |
| } | |
| // Get similar guests | |
| const similar = this.getNeighbors(nodeId, "similar_to"); | |
| for (const simGuest of similar) { | |
| const guestId = simGuest.replace("guest:", ""); | |
| const edge = this.getEdge(nodeId, simGuest); | |
| const currentScore = scores.get(guestId) || 0; | |
| const weight = edge?.weight || 0.5; | |
| scores.set(guestId, currentScore + weight); | |
| if (!reasons.has(guestId)) { | |
| reasons.set(guestId, []); | |
| } | |
| const shared = edge?.metadata?.sharedExpertise || ["similar expertise"]; | |
| reasons.get(guestId)!.push(`Shares expertise in ${shared.slice(0, 2).join(", ")}`); | |
| } | |
| // Sort by score and return top recommendations | |
| const sorted = Array.from(scores.entries()) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, limit); | |
| return sorted.map(([guestId, score]) => ({ | |
| guestId, | |
| reason: [...new Set(reasons.get(guestId) || [])].join("; "), | |
| score, | |
| })); | |
| } | |
| /** | |
| * Find the shortest path between two guests (trust path) | |
| */ | |
| async findTrustPath( | |
| fromProfileId: string, | |
| toProfileId: string | |
| ): Promise<string[] | null> { | |
| const start = `guest:${fromProfileId}`; | |
| const end = `guest:${toProfileId}`; | |
| if (!this.adjacencyList.has(start) || !this.adjacencyList.has(end)) { | |
| return null; | |
| } | |
| // BFS for shortest path | |
| const queue: Array<{ node: string; path: string[] }> = [{ node: start, path: [start] }]; | |
| const visited = new Set<string>(); | |
| while (queue.length > 0) { | |
| const { node, path } = queue.shift()!; | |
| if (node === end) { | |
| return path.map((n) => n.replace("guest:", "").replace("podcast:", "")); | |
| } | |
| if (visited.has(node)) continue; | |
| visited.add(node); | |
| const neighbors = this.adjacencyList.get(node); | |
| if (neighbors) { | |
| for (const [neighbor] of Array.from(neighbors.entries())) { | |
| if (!visited.has(neighbor)) { | |
| queue.push({ node: neighbor, path: [...path, neighbor] }); | |
| } | |
| } | |
| } | |
| } | |
| return null; // No path found | |
| } | |
| /** | |
| * Get network statistics for a profile | |
| */ | |
| async getNetworkStats(profileId: string): Promise<{ | |
| connections: number; | |
| reach: number; | |
| centrality: number; | |
| communities: string[]; | |
| }> { | |
| const nodeId = `guest:${profileId}`; | |
| if (!this.adjacencyList.has(nodeId)) { | |
| return { connections: 0, reach: 0, centrality: 0, communities: [] }; | |
| } | |
| const directConnections = this.adjacencyList.get(nodeId)!.size; | |
| // Calculate 2-hop reach | |
| const visited = new Set<string>(); | |
| visited.add(nodeId); | |
| const neighbors = this.getAllNeighbors(nodeId); | |
| for (const n of Array.from(neighbors)) { | |
| visited.add(n); | |
| } | |
| for (const n of Array.from(neighbors)) { | |
| const secondHop = this.getAllNeighbors(n); | |
| for (const sh of Array.from(secondHop)) { | |
| visited.add(sh); | |
| } | |
| } | |
| // Identify communities (tech hubs, countries, expertise areas) | |
| const communities = this.identifyCommunities(nodeId); | |
| return { | |
| connections: directConnections, | |
| reach: visited.size - 1, // exclude self | |
| centrality: this.calculateCentrality(nodeId), | |
| communities, | |
| }; | |
| } | |
| /** | |
| * Get guests in the same network/community | |
| */ | |
| async getCommunityMembers( | |
| communityType: "country" | "expertise" | "hub", | |
| value: string, | |
| limit = 20 | |
| ): Promise<string[]> { | |
| const members: string[] = []; | |
| for (const [nodeId, edges] of Array.from(this.adjacencyList.entries())) { | |
| if (!nodeId.startsWith("guest:")) continue; | |
| const data = (edges as any).nodeData; | |
| if (!data) continue; | |
| if (communityType === "country" && data.country === value) { | |
| members.push(nodeId.replace("guest:", "")); | |
| } else if (communityType === "expertise" && data.expertise?.includes(value)) { | |
| members.push(nodeId.replace("guest:", "")); | |
| } | |
| } | |
| return members.slice(0, limit); | |
| } | |
| /** | |
| * Get neighbors by edge type | |
| */ | |
| private getNeighbors(nodeId: string, edgeType: string): string[] { | |
| const edges = this.adjacencyList.get(nodeId); | |
| if (!edges) return []; | |
| return Array.from(edges.entries()) | |
| .filter(([, edge]) => edge.type === edgeType) | |
| .map(([target]) => target); | |
| } | |
| /** | |
| * Get all neighbors regardless of edge type | |
| */ | |
| private getAllNeighbors(nodeId: string): string[] { | |
| const edges = this.adjacencyList.get(nodeId); | |
| if (!edges) return []; | |
| return Array.from(edges.keys()); | |
| } | |
| /** | |
| * Get edge between two nodes | |
| */ | |
| private getEdge(source: string, target: string): GraphEdge | undefined { | |
| return this.adjacencyList.get(source)?.get(target); | |
| } | |
| /** | |
| * Get node data | |
| */ | |
| private getNodeData(nodeId: string): any { | |
| const edges = this.adjacencyList.get(nodeId); | |
| if (!edges) return null; | |
| return (edges as any).nodeData; | |
| } | |
| /** | |
| * Identify communities the node belongs to | |
| */ | |
| private identifyCommunities(nodeId: string): string[] { | |
| const data = this.getNodeData(nodeId); | |
| if (!data) return []; | |
| const communities: string[] = []; | |
| if (data.country) { | |
| communities.push(`${data.country} Community`); | |
| } | |
| if (data.expertise) { | |
| // Group by top expertise areas | |
| communities.push(...data.expertise.slice(0, 2).map((e: string) => `${e} Network`)); | |
| } | |
| return communities; | |
| } | |
| /** | |
| * Calculate a simple centrality score | |
| */ | |
| private calculateCentrality(nodeId: string): number { | |
| const direct = this.adjacencyList.get(nodeId)?.size || 0; | |
| // Count unique 2-hop connections | |
| const twoHop = new Set<string>(); | |
| const neighbors = this.getAllNeighbors(nodeId); | |
| for (const n of neighbors) { | |
| const secondHop = this.getAllNeighbors(n); | |
| for (const sh of secondHop) { | |
| if (sh !== nodeId) { | |
| twoHop.add(sh); | |
| } | |
| } | |
| } | |
| // Simple centrality: combination of direct and 2-hop | |
| return direct + twoHop.size * 0.5; | |
| } | |
| /** | |
| * Get graph health stats | |
| */ | |
| getStats(): { | |
| nodes: number; | |
| edges: number; | |
| guests: number; | |
| podcasts: number; | |
| } { | |
| let edges = 0; | |
| let guests = 0; | |
| let podcasts = 0; | |
| for (const [nodeId] of Array.from(this.adjacencyList.entries())) { | |
| if (nodeId.startsWith("guest:")) guests++; | |
| if (nodeId.startsWith("podcast:")) podcasts++; | |
| edges += this.adjacencyList.get(nodeId)!.size; | |
| } | |
| return { | |
| nodes: this.adjacencyList.size, | |
| edges: edges / 2, // undirected | |
| guests, | |
| podcasts, | |
| }; | |
| } | |
| } | |