← 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);
    }
  }
}