Skip to content

Commit d0a3d4c

Browse files
munessclaude
andcommitted
feat: show similarity match signals on similar cases
SQL fused CTE now tracks which sub-queries fired (fts, vector, name, address, dob) via array_agg(DISTINCT source). BuildCaseSummary uses those sources to produce richer signals: 'similar name' when name_fuzzy fired but wasn't exact, 'text match' for FTS hits, 'semantic match' for vector hits, in addition to the existing exact-match labels. SimilarCaseItem gains a Signals field and the frontend renders them as small chips below the applicant name so reviewers can see at a glance why each case was surfaced. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0fcea17 commit d0a3d4c

5 files changed

Lines changed: 39 additions & 21 deletions

File tree

apps/web/src/pages/ReviewerDashboard.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -898,8 +898,17 @@ function ReviewDetail({
898898
{(sc.matchScore * 100).toFixed(0)}% match
899899
</span>
900900
</div>
901+
{sc.signals?.length > 0 ? (
902+
<div className="mt-1.5 flex flex-wrap gap-1">
903+
{sc.signals.map((sig) => (
904+
<span key={sig} className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs text-slate-500">
905+
{sig}
906+
</span>
907+
))}
908+
</div>
909+
) : null}
901910
{sc.summary ? (
902-
<p className="mt-1 text-xs text-slate-600 line-clamp-2">{sc.summary}</p>
911+
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{sc.summary}</p>
903912
) : null}
904913
</button>
905914
</li>

apps/web/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type SimilarCase = {
6363
templateId: string
6464
matchScore: number
6565
summary: string
66+
signals: string[]
6667
}
6768

6869
export type AuditEventItem = {

src/BuildingBlocks/Northwoods.Contracts/Models.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ public sealed record SimilarCaseItem(
5252
string ApplicantName,
5353
string TemplateId,
5454
decimal MatchScore,
55-
string Summary);
55+
string Summary,
56+
IReadOnlyList<string> Signals);
5657

5758
public sealed record AuditEventItem(
5859
string EventType,

src/Services/Northwoods.Api/ApiHelpers.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ internal sealed record SimilarCaseCandidate(
1313
string? ApplicantName,
1414
string? DateOfBirth,
1515
string? Address,
16-
decimal MatchScore);
16+
decimal MatchScore,
17+
string[] MatchSources);
1718

1819
internal sealed record CaseFieldValue(
1920
Guid IntakeId,

src/Services/Northwoods.Api/Endpoints/ReviewEndpoints.cs

Lines changed: 24 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)