Skip to content

Commit c55df0b

Browse files
committed
feat: audit cross-provider place conflicts
1 parent f591932 commit c55df0b

5 files changed

Lines changed: 184 additions & 21 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
## Fixed
5151

52+
- The tree auditor now detects genuine birth/death/etc. place disagreements across providers. It reuses the place comparison normalization, so aliases such as `TX`/`Texas`, country synonyms, and a provider's less-detailed place do not create review noise. Date mismatch checks now likewise require a disagreement between sources rather than conflicting values from one source alone.
5253
- Search results now keep their alphabetical ordering. The batch person-loader (`getPersonsBatch`) re-orders rows back to the requested order, fixing a regression where SQLite's `WHERE person_id IN (...)` returned rows in table order and silently discarded the search query's `ORDER BY display_name` (so the default, unsorted search view appeared randomly ordered).
5354
- Platform comparison now treats equivalent place spellings as matches: "Dallas, Texas, USA" vs "Dallas, Texas, United States" (and U.S.A. / United States of America / state abbreviations like TX vs Texas, UK vs United Kingdom, etc.) — no longer flagged as `different`. Place containment is now suffix-based, so "Texas" no longer falsely matches "Texarkana"
5455
- Platform comparison now treats equivalent date formats as matches (e.g., "1979-07-31" vs "31 JUL 1979")

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ For phase-by-phase implementation history, see [docs/roadmap.md](./docs/roadmap.
1010

1111
1. **Reverse god-file regression**`PersonDetail.tsx` (1360), `ProviderDataTable.tsx` (1243), `database.service.ts` (1618), `auditor-agent.service.ts` (1233 — new), `multi-platform-comparison.service.ts` (1099), `api.ts` (1249), `VerticalFamilyView.tsx` (977), `favorites.service.ts` (872), `person.routes.ts` (1119). Extract `usePersonData` / `usePersonOverrides` hooks, `PhotoThumbnail` / `ComparisonCell` / `ProviderRow` sub-components, and split `database.service.ts` along entity lines. Split `auditor-agent.service.ts` into walker + per-check modules.
1212
2. **Critical-path unit tests**`credentials.service.ts` (encryption), `validation.ts` (input sanitization), `errorHandler.ts`, `requestTimeout.ts`, `augmentation.service.ts`, `auditor-agent.service.ts` all currently have **zero** tests. (`database.service.ts` and `search.service.ts` now have integration coverage but no unit tests of internal helpers.)
13-
3. **Phase 18 remaining checks** — implement `place_mismatch`, `name_mismatch`, `missing_parents`, `duplicate_suspect`, `stale_record` checks in `auditor-agent.service.ts` (types are declared in `shared/src/index.ts:918` but not yet wired). Reuse `multi-platform-comparison.service.ts` for the `*_mismatch` family.
13+
3. **Phase 18 remaining checks** — implement `name_mismatch`, `missing_parents`, `duplicate_suspect`, `stale_record` checks in `auditor-agent.service.ts` (types are declared in `shared/src/index.ts:918` but not yet wired). `place_mismatch` now detects only genuine cross-source vital-place conflicts, reusing the comparison service's alias and detail normalization to avoid noisy results.
1414
4. ~~**Search N+1**~~ — done. `searchWithSqlite` now uses `getPersonsBatch()` (6 queries vs 7×N), the batch loader preserves caller order (was silently dropping `ORDER BY display_name` because SQLite `IN (...)` returns table order), and the two dead N+1 service methods (`quickSearch`, `searchGlobal`) were removed. _Deferred:_ `getPersonsBatch()` does not populate `externalId` the way `getPerson()` does — fine for current consumers (SearchPage doesn't render it), but restore parity (one batched `external_identity` query) if a batch consumer ever needs it.
1515
5. **Phase 19 Guided Verification** — review-session schema (`verification_session`, `person_review`, `edge_review`, `provider_match_review`, `review_decision`) and root-to-ancestor BFS review queue (19.1 + 19.2).
1616

server/src/services/auditor-agent.service.ts

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { sqliteService } from '../db/sqlite.service.js';
2424
import { resolveDbId } from './database.service.js';
2525
import { logger } from '../lib/logger.js';
2626
import { createOperationTracker } from '../utils/operationTracker.js';
27+
import { findCrossSourceMismatches } from '../utils/auditMismatches.js';
28+
import { placeContains, placesMatch } from '../utils/normalizePlace.js';
2729
import config from '../lib/config.js';
2830

2931
const tracker = createOperationTracker('auditor');
@@ -522,36 +524,53 @@ function checkDateMismatches(runId: string, personId: string, displayName: strin
522524
const events = sqliteService.queryAll<{
523525
event_type: string;
524526
date_year: number | null;
525-
source: string;
527+
source: string | null;
526528
}>(
527529
`SELECT event_type, date_year, source FROM vital_event
528530
WHERE person_id = @personId AND date_year IS NOT NULL
529531
ORDER BY event_type, source`,
530532
{ personId }
531533
);
532534

533-
const issues: AuditIssue[] = [];
534-
const byType = new Map<string, { year: number; source: string }[]>();
535+
return findCrossSourceMismatches(
536+
events.map(event => ({
537+
eventType: event.event_type,
538+
value: event.date_year as number,
539+
source: event.source,
540+
})),
541+
(left, right) => left === right,
542+
).map(({ eventType, details }) => makeIssue(runId, personId, 'date_mismatch', 'warning',
543+
`${displayName}: ${eventType} date differs across sources (${details})`,
544+
details, null));
545+
}
535546

536-
for (const e of events) {
537-
if (!e.date_year) continue;
538-
const list = byType.get(e.event_type) ?? [];
539-
list.push({ year: e.date_year, source: e.source ?? 'unknown' });
540-
byType.set(e.event_type, list);
541-
}
547+
function checkPlaceMismatches(runId: string, personId: string, displayName: string): AuditIssue[] {
548+
const events = sqliteService.queryAll<{
549+
event_type: string;
550+
place: string;
551+
source: string | null;
552+
}>(
553+
`SELECT event_type, place, source FROM vital_event
554+
WHERE person_id = @personId AND TRIM(place) != ''
555+
ORDER BY event_type, source, id`,
556+
{ personId },
557+
);
542558

543-
for (const [eventType, entries] of byType) {
544-
if (entries.length < 2) continue;
545-
const years = new Set(entries.map(e => e.year));
546-
if (years.size > 1) {
547-
const details = entries.map(e => `${e.source}: ${e.year}`).join(', ');
548-
issues.push(makeIssue(runId, personId, 'date_mismatch', 'warning',
549-
`${displayName}: ${eventType} date differs across sources (${details})`,
550-
details, null));
551-
}
552-
}
559+
const samePlace = (left: string | number, right: string | number) => {
560+
const a = String(left);
561+
const b = String(right);
562+
return placesMatch(a, b) || placeContains(a, b) || placeContains(b, a);
563+
};
553564

554-
return issues;
565+
return findCrossSourceMismatches(events.map(event => ({
566+
eventType: event.event_type,
567+
value: event.place,
568+
source: event.source,
569+
})), samePlace).map(({ eventType, details }) => makeIssue(
570+
runId, personId, 'place_mismatch', 'warning',
571+
`${displayName}: ${eventType} place differs across sources (${details})`,
572+
details, null,
573+
));
555574
}
556575

557576
// ============================================================================
@@ -787,6 +806,7 @@ const DEFAULT_CONFIG: AuditRunConfig = {
787806
'missing_gender',
788807
'orphaned_edge',
789808
'date_mismatch',
809+
'place_mismatch',
790810
],
791811
autoAccept: false,
792812
autoAcceptTypes: [],
@@ -842,6 +862,9 @@ function auditPerson(
842862
if (checksEnabled.includes('date_mismatch')) {
843863
issues.push(...checkDateMismatches(runId, vitals.personId, vitals.displayName));
844864
}
865+
if (checksEnabled.includes('place_mismatch')) {
866+
issues.push(...checkPlaceMismatches(runId, vitals.personId, vitals.displayName));
867+
}
845868

846869
return { issues, displayName: vitals.displayName, linkedSources };
847870
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Helpers for detecting disagreements between provider-sourced vital events.
3+
*
4+
* A disagreement must be between sources. Multiple values from one source are
5+
* not enough evidence to ask the user to resolve a cross-provider conflict.
6+
*/
7+
8+
export interface EventSourceValue {
9+
eventType: string;
10+
value: string | number;
11+
source: string | null;
12+
}
13+
14+
export interface EventMismatch {
15+
eventType: string;
16+
details: string;
17+
}
18+
19+
type ValuesMatch = (a: string | number, b: string | number) => boolean;
20+
21+
function sourceName(source: string | null): string {
22+
return source?.trim() || 'local';
23+
}
24+
25+
function formatSourceValues(entries: EventSourceValue[]): string {
26+
const bySource = new Map<string, Set<string>>();
27+
28+
for (const entry of entries) {
29+
const source = sourceName(entry.source);
30+
const values = bySource.get(source) ?? new Set<string>();
31+
values.add(String(entry.value));
32+
bySource.set(source, values);
33+
}
34+
35+
return [...bySource.entries()]
36+
.sort(([a], [b]) => a.localeCompare(b))
37+
.map(([source, values]) => `${source}: ${[...values].sort().join(' / ')}`)
38+
.join(', ');
39+
}
40+
41+
/**
42+
* Find event types where at least two sources have no matching value.
43+
*
44+
* A source can contain more than one value (notably legacy records with a
45+
* null source). We suppress a mismatch when the two sources share any value,
46+
* because there is no unambiguous cross-provider disagreement to resolve.
47+
*/
48+
export function findCrossSourceMismatches(
49+
entries: EventSourceValue[],
50+
valuesMatch: ValuesMatch,
51+
): EventMismatch[] {
52+
const byEventType = new Map<string, EventSourceValue[]>();
53+
54+
for (const entry of entries) {
55+
const values = byEventType.get(entry.eventType) ?? [];
56+
values.push(entry);
57+
byEventType.set(entry.eventType, values);
58+
}
59+
60+
const mismatches: EventMismatch[] = [];
61+
62+
for (const [eventType, eventEntries] of byEventType) {
63+
const bySource = new Map<string, EventSourceValue[]>();
64+
for (const entry of eventEntries) {
65+
const source = sourceName(entry.source);
66+
const values = bySource.get(source) ?? [];
67+
values.push(entry);
68+
bySource.set(source, values);
69+
}
70+
71+
const sourceValues = [...bySource.values()];
72+
const hasDisagreement = sourceValues.some((values, index) =>
73+
sourceValues.slice(index + 1).some(otherValues =>
74+
values.every(value => otherValues.every(other => !valuesMatch(value.value, other.value)))
75+
)
76+
);
77+
78+
if (hasDisagreement) {
79+
mismatches.push({ eventType, details: formatSourceValues(eventEntries) });
80+
}
81+
}
82+
83+
return mismatches;
84+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { findCrossSourceMismatches } from '../../../server/src/utils/auditMismatches.js';
3+
import { placeContains, placesMatch } from '../../../server/src/utils/normalizePlace.js';
4+
5+
describe('findCrossSourceMismatches', () => {
6+
it('reports a disagreement between two provider values', () => {
7+
const mismatches = findCrossSourceMismatches([
8+
{ eventType: 'birth', value: 1874, source: 'familysearch' },
9+
{ eventType: 'birth', value: 1875, source: 'ancestry' },
10+
], (a, b) => a === b);
11+
12+
expect(mismatches).toEqual([{
13+
eventType: 'birth',
14+
details: 'ancestry: 1875, familysearch: 1874',
15+
}]);
16+
});
17+
18+
it('does not report values that only disagree within one source', () => {
19+
const mismatches = findCrossSourceMismatches([
20+
{ eventType: 'birth', value: 1874, source: null },
21+
{ eventType: 'birth', value: 1875, source: null },
22+
], (a, b) => a === b);
23+
24+
expect(mismatches).toEqual([]);
25+
});
26+
27+
it('suppresses normalized aliases and detail-only place differences', () => {
28+
const samePlace = (a: string | number, b: string | number) => {
29+
const left = String(a);
30+
const right = String(b);
31+
return placesMatch(left, right) || placeContains(left, right) || placeContains(right, left);
32+
};
33+
34+
const mismatches = findCrossSourceMismatches([
35+
{ eventType: 'birth', value: 'Dallas, TX, USA', source: 'familysearch' },
36+
{ eventType: 'birth', value: 'Dallas, Texas, United States', source: 'ancestry' },
37+
{ eventType: 'death', value: 'Texas, USA', source: 'familysearch' },
38+
{ eventType: 'death', value: 'Austin, Texas, United States', source: 'ancestry' },
39+
], samePlace);
40+
41+
expect(mismatches).toEqual([]);
42+
});
43+
44+
it('reports genuinely different places from different sources', () => {
45+
const mismatches = findCrossSourceMismatches([
46+
{ eventType: 'birth', value: 'Dallas, Texas, USA', source: 'familysearch' },
47+
{ eventType: 'birth', value: 'Houston, Texas, USA', source: 'ancestry' },
48+
], (a, b) => placesMatch(String(a), String(b)));
49+
50+
expect(mismatches).toEqual([{
51+
eventType: 'birth',
52+
details: 'ancestry: Houston, Texas, USA, familysearch: Dallas, Texas, USA',
53+
}]);
54+
});
55+
});

0 commit comments

Comments
 (0)