← back to Sdcc Awards
cypressaward/backend/src/services/matching-engine.ts
382 lines
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OpenAI } from 'openai';
import { ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
interface MatchCandidate {
nonprofitId: string;
caseId?: string;
intakeId?: string;
score: number;
method: 'rules' | 'embedding' | 'hybrid';
explanation: string;
tags: string[];
}
@Injectable()
export class MatchingEngineService {
private readonly logger = new Logger(MatchingEngineService.name);
private openai: OpenAI;
private db: Pool;
constructor(
private configService: ConfigService,
) {
this.openai = new OpenAI({
apiKey: this.configService.get('OPENAI_API_KEY'),
});
this.db = new Pool({
connectionString: this.configService.get('DATABASE_URL'),
});
}
async generateEmbedding(text: string): Promise<number[]> {
try {
const response = await this.openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
encoding_format: 'float',
});
return response.data[0].embedding;
} catch (error) {
this.logger.error('Error generating embedding:', error);
throw error;
}
}
async findMatchesForCase(caseId: string): Promise<MatchCandidate[]> {
const matches: MatchCandidate[] = [];
const caseResult = await this.db.query(
`SELECT * FROM cases WHERE id = $1`,
[caseId]
);
if (caseResult.rows.length === 0) {
throw new Error('Case not found');
}
const caseData = caseResult.rows[0];
const rulesMatches = await this.findRulesBasedMatches(caseData);
matches.push(...rulesMatches);
if (caseData.embedding) {
const embeddingMatches = await this.findEmbeddingMatches(
caseData.embedding,
'nonprofit',
caseId
);
matches.push(...embeddingMatches);
}
const hybridMatches = this.mergeAndScoreMatches(rulesMatches, embeddingMatches);
matches.push(...hybridMatches);
return this.deduplicateAndRank(matches);
}
async findRulesBasedMatches(caseData: any): Promise<MatchCandidate[]> {
const matches: MatchCandidate[] = [];
const categories = caseData.category || [];
const categoryMap: Record<string, string[]> = {
'education': ['education', 'student', 'school', 'literacy', 'scholarship'],
'consumer': ['consumer', 'financial', 'debt', 'credit', 'banking'],
'privacy': ['privacy', 'data', 'security', 'cyber', 'information'],
'healthcare': ['health', 'medical', 'patient', 'wellness', 'disease'],
'labor': ['labor', 'employment', 'worker', 'wage', 'union'],
'environment': ['environment', 'climate', 'pollution', 'conservation'],
'civil-rights': ['civil', 'rights', 'justice', 'equality', 'discrimination'],
};
for (const category of categories) {
const keywords = categoryMap[category.toLowerCase()] || [category];
const query = `
SELECT DISTINCT e.id as nonprofit_id, e.name, np.programs, np.eligibility_notes
FROM entities e
JOIN nonprofit_profiles np ON e.id = np.entity_id
WHERE e.kind = 'nonprofit'
AND e.verified = true
AND (
${keywords.map((_, i) => `
e.mission ILIKE $${i + 2} OR
array_to_string(e.sectors, ',') ILIKE $${i + 2} OR
array_to_string(e.tags, ',') ILIKE $${i + 2}
`).join(' OR ')}
)
LIMIT 20
`;
const params = [caseData.id, ...keywords.map(k => `%${k}%`)];
const result = await this.db.query(query, params);
for (const row of result.rows) {
const score = this.calculateRulesScore(row, caseData, keywords);
matches.push({
nonprofitId: row.nonprofit_id,
caseId: caseData.id,
score,
method: 'rules',
explanation: `Matched based on ${category} category alignment`,
tags: [category, ...keywords.filter(k =>
row.programs?.toLowerCase().includes(k) ||
row.eligibility_notes?.toLowerCase().includes(k)
)],
});
}
}
if (caseData.jurisdiction) {
const geoMatches = await this.findGeographicMatches(caseData);
matches.push(...geoMatches);
}
return matches;
}
private calculateRulesScore(
nonprofit: any,
caseData: any,
keywords: string[]
): number {
let score = 0.5;
const text = `${nonprofit.programs || ''} ${nonprofit.eligibility_notes || ''}`.toLowerCase();
for (const keyword of keywords) {
if (text.includes(keyword)) {
score += 0.1;
}
}
if (caseData.amount && caseData.amount > 100000) {
score += 0.1;
}
if (nonprofit.verified) {
score += 0.05;
}
return Math.min(score, 1.0);
}
async findEmbeddingMatches(
embedding: number[],
targetType: 'nonprofit' | 'law_firm',
caseId?: string,
intakeId?: string
): Promise<MatchCandidate[]> {
const table = targetType === 'nonprofit' ? 'nonprofit_profiles' : 'law_firm_profiles';
const query = `
SELECT
entity_id,
1 - (embedding <=> $1::vector) as similarity
FROM ${table}
WHERE embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 20
`;
const result = await this.db.query(query, [JSON.stringify(embedding)]);
return result.rows.map(row => ({
nonprofitId: row.entity_id,
caseId,
intakeId,
score: row.similarity,
method: 'embedding' as const,
explanation: `Semantic similarity score: ${(row.similarity * 100).toFixed(1)}%`,
tags: ['ai-matched'],
}));
}
private async findGeographicMatches(caseData: any): Promise<MatchCandidate[]> {
const matches: MatchCandidate[] = [];
const jurisdictionMap: Record<string, string[]> = {
'S.D.N.Y.': ['New York', 'Northeast', 'National'],
'N.D. Cal.': ['California', 'West Coast', 'National'],
'E.D. Tex.': ['Texas', 'Southwest', 'National'],
'N.D. Ill.': ['Illinois', 'Midwest', 'National'],
};
const regions = jurisdictionMap[caseData.court] || ['National'];
const query = `
SELECT DISTINCT e.id as nonprofit_id, e.name
FROM entities e
JOIN nonprofit_profiles np ON e.id = np.entity_id
WHERE e.kind = 'nonprofit'
AND (
${regions.map((_, i) => `$${i + 2} = ANY(np.geography)`).join(' OR ')}
)
LIMIT 10
`;
const result = await this.db.query(query, [caseData.id, ...regions]);
for (const row of result.rows) {
matches.push({
nonprofitId: row.nonprofit_id,
caseId: caseData.id,
score: 0.3,
method: 'rules',
explanation: `Geographic match: serves ${regions.join(', ')}`,
tags: ['geographic-match', ...regions],
});
}
return matches;
}
private mergeAndScoreMatches(
rulesMatches: MatchCandidate[],
embeddingMatches: MatchCandidate[]
): MatchCandidate[] {
const merged: Map<string, MatchCandidate> = new Map();
for (const match of rulesMatches) {
const key = `${match.nonprofitId}-${match.caseId || match.intakeId}`;
merged.set(key, match);
}
for (const match of embeddingMatches) {
const key = `${match.nonprofitId}-${match.caseId || match.intakeId}`;
const existing = merged.get(key);
if (existing) {
const hybridScore = (existing.score * 0.4) + (match.score * 0.6);
merged.set(key, {
...existing,
score: hybridScore,
method: 'hybrid',
explanation: `Combined rules and AI matching`,
tags: [...existing.tags, ...match.tags],
});
}
}
return Array.from(merged.values())
.filter(m => m.method === 'hybrid');
}
private deduplicateAndRank(matches: MatchCandidate[]): MatchCandidate[] {
const uniqueMap = new Map<string, MatchCandidate>();
for (const match of matches) {
const key = `${match.nonprofitId}-${match.caseId || match.intakeId}`;
const existing = uniqueMap.get(key);
if (!existing || match.score > existing.score) {
uniqueMap.set(key, match);
}
}
return Array.from(uniqueMap.values())
.sort((a, b) => b.score - a.score)
.slice(0, 50);
}
async saveMatches(matches: MatchCandidate[]): Promise<void> {
for (const match of matches) {
try {
await this.db.query(
`INSERT INTO matches
(nonprofit_id, case_id, intake_id, method, score, explanation, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (nonprofit_id, COALESCE(case_id, '00000000-0000-0000-0000-000000000000'))
DO UPDATE SET
score = EXCLUDED.score,
explanation = EXCLUDED.explanation,
tags = EXCLUDED.tags,
updated_at = NOW()`,
[
match.nonprofitId,
match.caseId || null,
match.intakeId || null,
match.method,
match.score,
match.explanation,
match.tags,
]
);
} catch (error) {
this.logger.error(`Error saving match: ${error.message}`);
}
}
}
async processNewCase(caseId: string): Promise<void> {
try {
const caseResult = await this.db.query(
'SELECT title, summary, category FROM cases WHERE id = $1',
[caseId]
);
if (caseResult.rows.length === 0) return;
const caseData = caseResult.rows[0];
const text = `${caseData.title} ${caseData.summary || ''} ${(caseData.category || []).join(' ')}`;
const embedding = await this.generateEmbedding(text);
await this.db.query(
'UPDATE cases SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), caseId]
);
const matches = await this.findMatchesForCase(caseId);
await this.saveMatches(matches);
this.logger.log(`Processed case ${caseId}: found ${matches.length} matches`);
} catch (error) {
this.logger.error(`Error processing case ${caseId}:`, error);
}
}
async processNewNonprofit(nonprofitId: string): Promise<void> {
try {
const result = await this.db.query(
`SELECT e.*, np.*
FROM entities e
JOIN nonprofit_profiles np ON e.id = np.entity_id
WHERE e.id = $1`,
[nonprofitId]
);
if (result.rows.length === 0) return;
const data = result.rows[0];
const text = `${data.name} ${data.mission || ''} ${JSON.stringify(data.programs || [])} ${(data.sectors || []).join(' ')}`;
const embedding = await this.generateEmbedding(text);
await this.db.query(
'UPDATE nonprofit_profiles SET embedding = $1 WHERE entity_id = $2',
[JSON.stringify(embedding), nonprofitId]
);
const activeCases = await this.db.query(
"SELECT id FROM cases WHERE status IN ('pending', 'active', 'settled')"
);
for (const caseRow of activeCases.rows) {
const matches = await this.findMatchesForCase(caseRow.id);
const nonprofitMatches = matches.filter(m => m.nonprofitId === nonprofitId);
await this.saveMatches(nonprofitMatches);
}
this.logger.log(`Processed nonprofit ${nonprofitId}`);
} catch (error) {
this.logger.error(`Error processing nonprofit ${nonprofitId}:`, error);
}
}
}