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
This commit is contained in:
@@ -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
|
||||
|
||||
165
src/routes/network.ts
Normal file
165
src/routes/network.ts
Normal file
@@ -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<string, number> = {};
|
||||
for (const c of allClients) {
|
||||
if (c.industry) {
|
||||
industries[c.industry] = (industries[c.industry] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Location breakdown
|
||||
const locations: Record<string, number> = {};
|
||||
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<string, number> = {};
|
||||
for (const m of matches) {
|
||||
categories[m.category] = (categories[m.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// Most connected clients
|
||||
const connectionCount: Record<string, { name: string; count: number }> = {};
|
||||
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,
|
||||
};
|
||||
});
|
||||
237
src/services/matching.ts
Normal file
237
src/services/matching.ts
Normal file
@@ -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<string, string[]> = {
|
||||
'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<string> {
|
||||
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<MatchResult, 'introSuggestion'>[] {
|
||||
const matches: Omit<MatchResult, 'introSuggestion'>[] = [];
|
||||
|
||||
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<MatchResult, 'introSuggestion'>[] {
|
||||
const matches: Omit<MatchResult, 'introSuggestion'>[] = [];
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user