From a4c6ada7de3fb60cd12f8363768233aba80e69df Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 12:35:27 +0000 Subject: [PATCH] feat: network matching API - rule-based scoring + AI intro suggestions - /api/network/matches: find all client matches with scoring - /api/network/matches/:clientId: matches for a specific client - /api/network/intro: AI-generated introduction suggestions - /api/network/stats: network analytics (industries, locations, connectors) - Rule-based scoring: industry, interests, location, tags, complementary roles - Smart filtering: same company detection, related industry groups --- src/index.ts | 2 + src/routes/network.ts | 165 +++++++++++++++++++++++++++ src/services/matching.ts | 237 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 src/routes/network.ts create mode 100644 src/services/matching.ts diff --git a/src/index.ts b/src/index.ts index 0f5cde9..c4ffdb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { emailRoutes } from './routes/emails'; import { eventRoutes } from './routes/events'; import { profileRoutes } from './routes/profile'; import { adminRoutes } from './routes/admin'; +import { networkRoutes } from './routes/network'; import { inviteRoutes } from './routes/invite'; import { passwordResetRoutes } from './routes/password-reset'; import { db } from './db'; @@ -60,6 +61,7 @@ const app = new Elysia() .use(eventRoutes) .use(profileRoutes) .use(adminRoutes) + .use(networkRoutes) ) // Error handler diff --git a/src/routes/network.ts b/src/routes/network.ts new file mode 100644 index 0000000..41e5a7a --- /dev/null +++ b/src/routes/network.ts @@ -0,0 +1,165 @@ +import { Elysia, t } from 'elysia'; +import { db } from '../db'; +import { clients } from '../db/schema'; +import { eq } from 'drizzle-orm'; +import { findMatches, findMatchesForClient, generateIntroSuggestion } from '../services/matching'; +import type { ClientProfile, MatchResult } from '../services/matching'; + +function toClientProfile(c: typeof clients.$inferSelect): ClientProfile { + return { + id: c.id, + firstName: c.firstName, + lastName: c.lastName, + email: c.email, + company: c.company, + role: c.role, + industry: c.industry, + city: c.city, + state: c.state, + interests: c.interests, + tags: c.tags, + notes: c.notes, + }; +} + +export const networkRoutes = new Elysia({ prefix: '/network' }) + // Get all network matches for the user's clients + .get('/matches', async (ctx) => { + const user = (ctx as any).user; + const query = ctx.query; + const allClients = await db.select().from(clients).where(eq(clients.userId, user.id)); + + if (allClients.length < 2) { + return { matches: [] as MatchResult[], total: 0, clientCount: allClients.length }; + } + + const profiles = allClients.map(toClientProfile); + const minScore = parseInt(query.minScore || '20', 10); + const limit = parseInt(query.limit || '50', 10); + + const rawMatches = findMatches(profiles, minScore).slice(0, limit); + + const matches: MatchResult[] = rawMatches.map(m => ({ + ...m, + introSuggestion: `Consider introducing ${m.clientA.name} and ${m.clientB.name} — they share common ground.`, + })); + + return { matches, total: matches.length, clientCount: allClients.length }; + }, { + query: t.Object({ + minScore: t.Optional(t.String()), + limit: t.Optional(t.String()), + }), + }) + + // Get matches for a specific client + .get('/matches/:clientId', async (ctx) => { + const user = (ctx as any).user; + const params = ctx.params; + const query = ctx.query; + const allClients = await db.select().from(clients).where(eq(clients.userId, user.id)); + const target = allClients.find(c => c.id === params.clientId); + + if (!target) throw new Error('Client not found'); + + const profiles = allClients.map(toClientProfile); + const targetProfile = toClientProfile(target); + const minScore = parseInt(query.minScore || '15', 10); + + const rawMatches = findMatchesForClient(targetProfile, profiles, minScore); + + const matches: MatchResult[] = rawMatches.map(m => ({ + ...m, + introSuggestion: `Consider introducing ${m.clientA.name} and ${m.clientB.name} — they share common ground.`, + })); + + return { matches, client: { id: target.id, name: `${target.firstName} ${target.lastName}` } }; + }, { + params: t.Object({ + clientId: t.String({ format: 'uuid' }), + }), + query: t.Object({ + minScore: t.Optional(t.String()), + }), + }) + + // Generate AI intro suggestion for a specific match + .post('/intro', async (ctx) => { + const user = (ctx as any).user; + const body = ctx.body; + const allClients = await db.select().from(clients).where(eq(clients.userId, user.id)); + const clientA = allClients.find(c => c.id === body.clientAId); + const clientB = allClients.find(c => c.id === body.clientBId); + + if (!clientA || !clientB) throw new Error('Client not found'); + + const suggestion = await generateIntroSuggestion( + toClientProfile(clientA), + toClientProfile(clientB), + body.reasons, + (body.provider as any) || 'openai', + ); + + return { introSuggestion: suggestion }; + }, { + body: t.Object({ + clientAId: t.String({ format: 'uuid' }), + clientBId: t.String({ format: 'uuid' }), + reasons: t.Array(t.String()), + provider: t.Optional(t.String()), + }), + }) + + // Network stats + .get('/stats', async (ctx) => { + const user = (ctx as any).user; + const allClients = await db.select().from(clients).where(eq(clients.userId, user.id)); + const profiles = allClients.map(toClientProfile); + const matches = findMatches(profiles, 20); + + // Industry breakdown + const industries: Record = {}; + for (const c of allClients) { + if (c.industry) { + industries[c.industry] = (industries[c.industry] || 0) + 1; + } + } + + // Location breakdown + const locations: Record = {}; + for (const c of allClients) { + const loc = [c.city, c.state].filter(Boolean).join(', '); + if (loc) { + locations[loc] = (locations[loc] || 0) + 1; + } + } + + // Category breakdown + const categories: Record = {}; + for (const m of matches) { + categories[m.category] = (categories[m.category] || 0) + 1; + } + + // Most connected clients + const connectionCount: Record = {}; + for (const m of matches) { + if (!connectionCount[m.clientA.id]) connectionCount[m.clientA.id] = { name: m.clientA.name, count: 0 }; + if (!connectionCount[m.clientB.id]) connectionCount[m.clientB.id] = { name: m.clientB.name, count: 0 }; + connectionCount[m.clientA.id]!.count++; + connectionCount[m.clientB.id]!.count++; + } + const topConnectors = Object.entries(connectionCount) + .map(([id, { name, count }]) => ({ id, name, matchCount: count })) + .sort((a, b) => b.matchCount - a.matchCount) + .slice(0, 5); + + return { + totalClients: allClients.length, + totalMatches: matches.length, + avgScore: matches.length > 0 ? Math.round(matches.reduce((s, m) => s + m.score, 0) / matches.length) : 0, + industries: Object.entries(industries).sort((a, b) => b[1] - a[1]), + locations: Object.entries(locations).sort((a, b) => b[1] - a[1]), + categories, + topConnectors, + }; + }); diff --git a/src/services/matching.ts b/src/services/matching.ts new file mode 100644 index 0000000..4c42315 --- /dev/null +++ b/src/services/matching.ts @@ -0,0 +1,237 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatOpenAI } from '@langchain/openai'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { StringOutputParser } from '@langchain/core/output_parsers'; +import type { AIProvider } from './ai'; + +function getModel(provider: AIProvider = 'openai') { + if (provider === 'openai') { + return new ChatOpenAI({ + modelName: 'gpt-4o-mini', + openAIApiKey: process.env.OPENAI_API_KEY, + }); + } + return new ChatAnthropic({ + modelName: 'claude-sonnet-4-20250514', + anthropicApiKey: process.env.ANTHROPIC_API_KEY, + }); +} + +export interface ClientProfile { + id: string; + firstName: string; + lastName: string; + email?: string | null; + company?: string | null; + role?: string | null; + industry?: string | null; + city?: string | null; + state?: string | null; + interests?: string[] | null; + tags?: string[] | null; + notes?: string | null; +} + +export interface MatchResult { + clientA: { id: string; name: string }; + clientB: { id: string; name: string }; + score: number; // 0-100 + reasons: string[]; + introSuggestion: string; + category: 'industry' | 'interests' | 'location' | 'business' | 'social'; +} + +// Rule-based scoring for fast matching without AI +export function computeMatchScore(a: ClientProfile, b: ClientProfile): { + score: number; + reasons: string[]; + category: MatchResult['category']; +} { + let score = 0; + const reasons: string[] = []; + let primaryCategory: MatchResult['category'] = 'social'; + + // Same industry (strong signal) + if (a.industry && b.industry && a.industry.toLowerCase() === b.industry.toLowerCase()) { + score += 30; + reasons.push(`Both work in ${a.industry}`); + primaryCategory = 'industry'; + } + + // Similar industries (fuzzy) + if (a.industry && b.industry && a.industry !== b.industry) { + const relatedIndustries: Record = { + 'finance': ['banking', 'insurance', 'wealth management', 'investment', 'financial services', 'fintech'], + 'technology': ['software', 'it', 'saas', 'tech', 'engineering', 'cybersecurity'], + 'healthcare': ['medical', 'pharma', 'biotech', 'health', 'wellness'], + 'real estate': ['property', 'construction', 'architecture', 'development'], + 'legal': ['law', 'compliance', 'regulatory'], + 'education': ['training', 'coaching', 'academia', 'teaching'], + }; + + const aLower = a.industry.toLowerCase(); + const bLower = b.industry.toLowerCase(); + for (const [, group] of Object.entries(relatedIndustries)) { + const aMatch = group.some(g => aLower.includes(g)) || group.some(g => g.includes(aLower)); + const bMatch = group.some(g => bLower.includes(g)) || group.some(g => g.includes(bLower)); + if (aMatch && bMatch) { + score += 15; + reasons.push(`Related industries: ${a.industry} & ${b.industry}`); + primaryCategory = 'industry'; + break; + } + } + } + + // Shared interests + if (a.interests?.length && b.interests?.length) { + const aSet = new Set(a.interests.map(i => i.toLowerCase())); + const shared = b.interests.filter(i => aSet.has(i.toLowerCase())); + if (shared.length > 0) { + score += Math.min(shared.length * 10, 25); + reasons.push(`Shared interests: ${shared.join(', ')}`); + if (primaryCategory === 'social') primaryCategory = 'interests'; + } + } + + // Same location + if (a.city && b.city && a.city.toLowerCase() === b.city.toLowerCase()) { + score += 15; + reasons.push(`Both in ${a.city}${a.state ? `, ${a.state}` : ''}`); + if (primaryCategory === 'social') primaryCategory = 'location'; + } else if (a.state && b.state && a.state.toLowerCase() === b.state.toLowerCase()) { + score += 8; + reasons.push(`Both in ${a.state}`); + if (primaryCategory === 'social') primaryCategory = 'location'; + } + + // Shared tags + if (a.tags?.length && b.tags?.length) { + const aSet = new Set(a.tags.map(t => t.toLowerCase())); + const shared = b.tags.filter(t => aSet.has(t.toLowerCase())); + if (shared.length > 0) { + score += Math.min(shared.length * 8, 20); + reasons.push(`Shared tags: ${shared.join(', ')}`); + } + } + + // Complementary roles (could benefit from connecting) + if (a.role && b.role) { + const complementary: [string[], string[]][] = [ + [['ceo', 'founder', 'owner', 'president'], ['advisor', 'consultant', 'coach']], + [['sales', 'business development'], ['marketing', 'pr', 'communications']], + [['cto', 'engineer', 'developer'], ['product', 'design', 'ux']], + [['investor', 'venture', 'angel'], ['founder', 'startup', 'ceo', 'entrepreneur']], + ]; + + const aLower = a.role.toLowerCase(); + const bLower = b.role.toLowerCase(); + for (const [groupA, groupB] of complementary) { + const aInA = groupA.some(g => aLower.includes(g)); + const bInB = groupB.some(g => bLower.includes(g)); + const aInB = groupB.some(g => aLower.includes(g)); + const bInA = groupA.some(g => bLower.includes(g)); + if ((aInA && bInB) || (aInB && bInA)) { + score += 15; + reasons.push(`Complementary roles: ${a.role} & ${b.role}`); + primaryCategory = 'business'; + break; + } + } + } + + // Same company is NOT a match (they already know each other) + if (a.company && b.company && a.company.toLowerCase() === b.company.toLowerCase()) { + score = Math.max(0, score - 20); + // Remove industry reason if it was the only match + const idx = reasons.findIndex(r => r.includes('Both work in')); + if (idx !== -1) reasons.splice(idx, 1); + reasons.push('Same company — likely already connected'); + } + + return { score: Math.min(score, 100), reasons, category: primaryCategory }; +} + +// AI-enhanced matching for top pairs (generates intro suggestions) +const matchPrompt = ChatPromptTemplate.fromMessages([ + ['system', `You are a networking advisor for a wealth management professional. +Given two client profiles, write a brief 1-2 sentence introduction suggestion explaining how to connect them. +Be specific and practical. Focus on mutual benefit.`], + ['human', `Client A: {clientA} +Client B: {clientB} +Match reasons: {reasons} + +Write a brief intro suggestion (1-2 sentences).`], +]); + +export async function generateIntroSuggestion( + a: ClientProfile, + b: ClientProfile, + reasons: string[], + provider: AIProvider = 'openai' +): Promise { + try { + const model = getModel(provider); + const parser = new StringOutputParser(); + const chain = matchPrompt.pipe(model).pipe(parser); + + const response = await chain.invoke({ + clientA: `${a.firstName} ${a.lastName}, ${a.role || ''} at ${a.company || 'N/A'}, industry: ${a.industry || 'N/A'}, interests: ${a.interests?.join(', ') || 'N/A'}`, + clientB: `${b.firstName} ${b.lastName}, ${b.role || ''} at ${b.company || 'N/A'}, industry: ${b.industry || 'N/A'}, interests: ${b.interests?.join(', ') || 'N/A'}`, + reasons: reasons.join('; '), + }); + return response.trim(); + } catch (e) { + // Fallback if AI fails + return `Consider introducing ${a.firstName} and ${b.firstName} — they share common ground in ${reasons[0]?.toLowerCase() || 'their professional network'}.`; + } +} + +// Get all matches for a user's clients +export function findMatches(clients: ClientProfile[], minScore: number = 20): Omit[] { + const matches: Omit[] = []; + + for (let i = 0; i < clients.length; i++) { + for (let j = i + 1; j < clients.length; j++) { + const a = clients[i]!; + const b = clients[j]!; + const { score, reasons, category } = computeMatchScore(a, b); + if (score >= minScore && reasons.length > 0 && !reasons.includes('Same company — likely already connected')) { + matches.push({ + clientA: { id: a.id, name: `${a.firstName} ${a.lastName}` }, + clientB: { id: b.id, name: `${b.firstName} ${b.lastName}` }, + score, + reasons, + category, + }); + } + } + } + + return matches.sort((a, b) => b.score - a.score); +} + +// Find matches for a specific client +export function findMatchesForClient( + targetClient: ClientProfile, + allClients: ClientProfile[], + minScore: number = 15 +): Omit[] { + const matches: Omit[] = []; + + for (const other of allClients) { + if (other.id === targetClient.id) continue; + const { score, reasons, category } = computeMatchScore(targetClient, other); + if (score >= minScore && reasons.length > 0 && !reasons.includes('Same company — likely already connected')) { + matches.push({ + clientA: { id: targetClient.id, name: `${targetClient.firstName} ${targetClient.lastName}` }, + clientB: { id: other.id, name: `${other.firstName} ${other.lastName}` }, + score, + reasons, + category, + }); + } + } + + return matches.sort((a, b) => b.score - a.score); +}