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:
2026-01-29 12:35:27 +00:00
parent 11ee9b946f
commit a4c6ada7de
3 changed files with 404 additions and 0 deletions

View File

@@ -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
View 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
View 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);
}