MicProtocol / apps /api /src /network /graph.service.ts
Benaah's picture
Upload folder using huggingface_hub
6abfd9d verified
Raw History Blame Contribute Delete
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
*/
@Injectable()
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,
};
}
}