@@ -458,17 +458,19 @@ AND t.date_of_birth IS NOT NULL
458458 AND cp.date_of_birth = t.date_of_birth
459459 ),
460460 fused AS (
461- SELECT document_id, SUM(1.0 / (60.0 + rank)) AS match_score
461+ SELECT document_id,
462+ SUM(1.0 / (60.0 + rank)) AS match_score,
463+ array_agg(DISTINCT source) AS match_sources
462464 FROM (
463- SELECT document_id, rank FROM fts
465+ SELECT document_id, rank, 'fts' AS source FROM fts
464466 UNION ALL
465- SELECT document_id, rank FROM vector
467+ SELECT document_id, rank, 'vector' AS source FROM vector
466468 UNION ALL
467- SELECT document_id, rank FROM name_fuzzy
469+ SELECT document_id, rank, 'name' AS source FROM name_fuzzy
468470 UNION ALL
469- SELECT document_id, rank FROM address_fuzzy
471+ SELECT document_id, rank, 'address' AS source FROM address_fuzzy
470472 UNION ALL
471- SELECT document_id, rank FROM dob_exact
473+ SELECT document_id, rank, 'dob' AS source FROM dob_exact
472474 ) x
473475 GROUP BY document_id
474476 )
@@ -478,7 +480,8 @@ GROUP BY document_id
478480 cp.applicant_name AS ApplicantName,
479481 cp.date_of_birth AS DateOfBirth,
480482 cp.address AS Address,
481- ROUND(f.match_score::numeric, 4) AS MatchScore
483+ ROUND(f.match_score::numeric, 4) AS MatchScore,
484+ f.match_sources AS MatchSources
482485 FROM fused f
483486 JOIN case_profiles cp ON cp.document_id = f.document_id
484487 ORDER BY f.match_score DESC, cp.applicant_name NULLS LAST
@@ -526,7 +529,7 @@ FROM extracted_fields
526529 }
527530 }
528531
529- result . Add ( new SimilarCaseItem ( caseItem . IntakeId , caseItem . IntakeId , caseItem . ApplicantName ?? "Unknown applicant" , caseItem . TemplateId , caseItem . MatchScore , summary ) ) ;
532+ result . Add ( new SimilarCaseItem ( caseItem . IntakeId , caseItem . IntakeId , caseItem . ApplicantName ?? "Unknown applicant" , caseItem . TemplateId , caseItem . MatchScore , summary , signals ) ) ;
530533 }
531534
532535 return result ;
@@ -541,35 +544,38 @@ private static (string Summary, IReadOnlyList<string> Signals) BuildCaseSummary(
541544 IReadOnlyDictionary < string , string > candidateFields )
542545 {
543546 var signals = new List < string > ( ) ;
547+ var sources = candidate . MatchSources ?? [ ] ;
544548
549+ // Field-level exact/fuzzy matches
545550 var candidateApplicant = candidate . ApplicantName ;
546551 if ( ! string . IsNullOrWhiteSpace ( candidateApplicant ) && string . Equals ( candidateApplicant , sourceApplicant , StringComparison . OrdinalIgnoreCase ) )
547- {
548552 signals . Add ( "same applicant" ) ;
549- }
553+ else if ( sources . Contains ( "name" ) )
554+ signals . Add ( "similar name" ) ;
550555
551556 var candidateDob = candidate . DateOfBirth ;
552557 if ( ! string . IsNullOrWhiteSpace ( candidateDob ) && ! string . IsNullOrWhiteSpace ( sourceDob ) && candidateDob == sourceDob )
553- {
554558 signals . Add ( "matching DOB" ) ;
555- }
556559
557560 var candidateAddress = candidate . Address ;
558561 if ( ! string . IsNullOrWhiteSpace ( candidateAddress ) && ! string . IsNullOrWhiteSpace ( sourceAddress ) &&
559562 string . Equals ( candidateAddress , sourceAddress , StringComparison . OrdinalIgnoreCase ) )
560- {
561563 signals . Add ( "matching address" ) ;
562- }
564+ else if ( sources . Contains ( "address" ) )
565+ signals . Add ( "similar address" ) ;
563566
564567 if ( string . Equals ( candidate . TemplateId , sourceTemplateId , StringComparison . OrdinalIgnoreCase ) )
565- {
566568 signals . Add ( "same template" ) ;
567- }
569+
570+ // Query-level signals not captured by field comparison
571+ if ( sources . Contains ( "fts" ) )
572+ signals . Add ( "text match" ) ;
573+
574+ if ( sources . Contains ( "vector" ) )
575+ signals . Add ( "semantic match" ) ;
568576
569577 if ( signals . Count == 0 )
570- {
571578 signals . Add ( "cross-field lexical overlap" ) ;
572- }
573579
574580 var requestedServices = candidateFields . TryGetValue ( "requestedServices" , out var services ) && ! string . IsNullOrWhiteSpace ( services )
575581 ? services
0 commit comments