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 { eventRoutes } from './routes/events';
|
||||||
import { profileRoutes } from './routes/profile';
|
import { profileRoutes } from './routes/profile';
|
||||||
import { adminRoutes } from './routes/admin';
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { networkRoutes } from './routes/network';
|
||||||
import { inviteRoutes } from './routes/invite';
|
import { inviteRoutes } from './routes/invite';
|
||||||
import { passwordResetRoutes } from './routes/password-reset';
|
import { passwordResetRoutes } from './routes/password-reset';
|
||||||
import { db } from './db';
|
import { db } from './db';
|
||||||
@@ -60,6 +61,7 @@ const app = new Elysia()
|
|||||||
.use(eventRoutes)
|
.use(eventRoutes)
|
||||||
.use(profileRoutes)
|
.use(profileRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
|
.use(networkRoutes)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error handler
|
// 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