Skip to content

Commit 0c2d4a1

Browse files
munessclaude
andcommitted
feat: failure reason, retry, audit trail, similarity signals (#110)
- Failed documents show error message from audit_events details - POST /intakes/{id}/retry resets failed docs back to uploaded - Audit trail entries include timestamp and resolved actor email - Similar cases show match signal badges (same applicant, text match, semantic match, similar name, matching DOB/address, etc.) - SQL fused CTE tracks which sub-queries fired per matched case Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 21adb33 + d0a3d4c commit 0c2d4a1

6 files changed

Lines changed: 81 additions & 30 deletions

File tree

apps/web/src/pages/DevPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,12 +1111,12 @@ export default function DevPage() {
11111111
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
11121112
<p className="text-sm font-medium text-slate-900">Audit trail</p>
11131113
<ul className="mt-3 space-y-2 text-sm text-slate-700" aria-live="polite">
1114-
{reviewDetail.auditEvents.map((eventName, eventIndex) => (
1114+
{reviewDetail.auditEvents.map((ev, eventIndex) => (
11151115
<li
1116-
key={`${eventName}-${eventIndex}`}
1116+
key={`${ev.eventType}-${eventIndex}`}
11171117
className="rounded-md border border-slate-200 bg-white px-3 py-2"
11181118
>
1119-
{formatAuditEvent(eventName)}
1119+
{formatAuditEvent(ev.eventType)}
11201120
</li>
11211121
))}
11221122
</ul>

apps/web/src/pages/ReviewerDashboard.tsx

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -679,9 +679,12 @@ type ReviewDetailProps = {
679679
finalizeError: string | null
680680
finalizeSuccess: boolean
681681
uncertainCount: number
682+
retryBusy: boolean
683+
retryError: string | null
682684
onFieldChange: (index: number, value: string) => void
683685
onNoteChange: (note: string) => void
684686
onFinalize: () => void
687+
onRetry: () => void
685688
onSelectReview: (id: string) => void
686689
}
687690

@@ -695,12 +698,16 @@ function ReviewDetail({
695698
finalizeError,
696699
finalizeSuccess,
697700
uncertainCount,
701+
retryBusy,
702+
retryError,
698703
onFieldChange,
699704
onNoteChange,
700705
onFinalize,
706+
onRetry,
701707
onSelectReview,
702708
}: ReviewDetailProps) {
703709
const isFinalized = statusLabel(review.status) === 'Finalized'
710+
const isFailed = statusLabel(review.status) === 'Failed'
704711

705712
const reviewFieldMap = useMemo(() => {
706713
const map = new Map<string, ReviewField>()
@@ -891,8 +898,17 @@ function ReviewDetail({
891898
{(sc.matchScore * 100).toFixed(0)}% match
892899
</span>
893900
</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}
894910
{sc.summary ? (
895-
<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>
896912
) : null}
897913
</button>
898914
</li>
@@ -906,20 +922,46 @@ function ReviewDetail({
906922
<div className="border-t border-slate-100 p-4">
907923
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Audit trail</p>
908924
<ul className="mt-2 space-y-1" aria-live="polite">
909-
{review.auditEvents.map((ev, i) => (
925+
{review.auditEvents.map((ev: AuditEventItem, i) => (
910926
<li
911-
key={`${ev}-${i}`}
912-
className="rounded border border-slate-100 bg-slate-50 px-3 py-1.5 text-xs text-slate-700"
927+
key={`${ev.eventType}-${i}`}
928+
className="rounded border border-slate-100 bg-slate-50 px-3 py-2 text-xs"
913929
>
914-
{formatAuditEvent(ev)}
930+
<div className="flex items-start justify-between gap-2">
931+
<span className="font-medium text-slate-700">{formatAuditEventType(ev.eventType)}</span>
932+
<span className="shrink-0 tabular-nums text-slate-400">{formatAuditTimestamp(ev.createdAt)}</span>
933+
</div>
934+
<span className="text-slate-500">{ev.actorEmail ?? 'system'}</span>
915935
</li>
916936
))}
917937
</ul>
918938
</div>
919939
) : null}
920940

921-
{/* Finalize */}
922-
{!isFinalized ? (
941+
{/* Failed state: show error and retry */}
942+
{isFailed ? (
943+
<div className="border-t border-slate-100 p-4">
944+
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2">
945+
<p className="text-xs font-medium text-rose-700">Extraction failed</p>
946+
{review.failureReason ? (
947+
<p className="mt-1 text-xs text-rose-600">{review.failureReason}</p>
948+
) : null}
949+
</div>
950+
{retryError ? (
951+
<p role="alert" aria-live="assertive" className="mt-2 text-xs text-rose-700">
952+
{retryError}
953+
</p>
954+
) : null}
955+
<button
956+
type="button"
957+
onClick={onRetry}
958+
disabled={retryBusy}
959+
className={`${btnSecondary} mt-3 w-full`}
960+
>
961+
{retryBusy ? 'Retrying\u2026' : 'Retry extraction'}
962+
</button>
963+
</div>
964+
) : !isFinalized ? (
923965
<div className="border-t border-slate-100 p-4">
924966
<label htmlFor="reviewer-note" className="block text-xs font-medium text-slate-700">
925967
Reviewer note

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)