From c55df0b8614c5dd7cd915a798b52ce815c36088a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 1 Aug 2026 10:03:49 +0000 Subject: [PATCH 1/4] feat: audit cross-provider place conflicts --- .changelog/NEXT.md | 1 + PLAN.md | 2 +- server/src/services/auditor-agent.service.ts | 63 ++++++++++----- server/src/utils/auditMismatches.ts | 84 ++++++++++++++++++++ tests/unit/utils/auditMismatches.spec.ts | 55 +++++++++++++ 5 files changed, 184 insertions(+), 21 deletions(-) create mode 100644 server/src/utils/auditMismatches.ts create mode 100644 tests/unit/utils/auditMismatches.spec.ts diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 77a969d7..58c5ea7e 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -49,6 +49,7 @@ ## Fixed +- 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. - 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). - 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" - Platform comparison now treats equivalent date formats as matches (e.g., "1979-07-31" vs "31 JUL 1979") diff --git a/PLAN.md b/PLAN.md index 8400aab8..79f81b08 100644 --- a/PLAN.md +++ b/PLAN.md @@ -10,7 +10,7 @@ For phase-by-phase implementation history, see [docs/roadmap.md](./docs/roadmap. 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. 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.) -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. +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. 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. 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). diff --git a/server/src/services/auditor-agent.service.ts b/server/src/services/auditor-agent.service.ts index 60908496..a276a364 100644 --- a/server/src/services/auditor-agent.service.ts +++ b/server/src/services/auditor-agent.service.ts @@ -24,6 +24,8 @@ import { sqliteService } from '../db/sqlite.service.js'; import { resolveDbId } from './database.service.js'; import { logger } from '../lib/logger.js'; import { createOperationTracker } from '../utils/operationTracker.js'; +import { findCrossSourceMismatches } from '../utils/auditMismatches.js'; +import { placeContains, placesMatch } from '../utils/normalizePlace.js'; import config from '../lib/config.js'; const tracker = createOperationTracker('auditor'); @@ -522,7 +524,7 @@ function checkDateMismatches(runId: string, personId: string, displayName: strin const events = sqliteService.queryAll<{ event_type: string; date_year: number | null; - source: string; + source: string | null; }>( `SELECT event_type, date_year, source FROM vital_event WHERE person_id = @personId AND date_year IS NOT NULL @@ -530,28 +532,45 @@ function checkDateMismatches(runId: string, personId: string, displayName: strin { personId } ); - const issues: AuditIssue[] = []; - const byType = new Map(); + return findCrossSourceMismatches( + events.map(event => ({ + eventType: event.event_type, + value: event.date_year as number, + source: event.source, + })), + (left, right) => left === right, + ).map(({ eventType, details }) => makeIssue(runId, personId, 'date_mismatch', 'warning', + `${displayName}: ${eventType} date differs across sources (${details})`, + details, null)); +} - for (const e of events) { - if (!e.date_year) continue; - const list = byType.get(e.event_type) ?? []; - list.push({ year: e.date_year, source: e.source ?? 'unknown' }); - byType.set(e.event_type, list); - } +function checkPlaceMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { + const events = sqliteService.queryAll<{ + event_type: string; + place: string; + source: string | null; + }>( + `SELECT event_type, place, source FROM vital_event + WHERE person_id = @personId AND TRIM(place) != '' + ORDER BY event_type, source, id`, + { personId }, + ); - for (const [eventType, entries] of byType) { - if (entries.length < 2) continue; - const years = new Set(entries.map(e => e.year)); - if (years.size > 1) { - const details = entries.map(e => `${e.source}: ${e.year}`).join(', '); - issues.push(makeIssue(runId, personId, 'date_mismatch', 'warning', - `${displayName}: ${eventType} date differs across sources (${details})`, - details, null)); - } - } + const samePlace = (left: string | number, right: string | number) => { + const a = String(left); + const b = String(right); + return placesMatch(a, b) || placeContains(a, b) || placeContains(b, a); + }; - return issues; + return findCrossSourceMismatches(events.map(event => ({ + eventType: event.event_type, + value: event.place, + source: event.source, + })), samePlace).map(({ eventType, details }) => makeIssue( + runId, personId, 'place_mismatch', 'warning', + `${displayName}: ${eventType} place differs across sources (${details})`, + details, null, + )); } // ============================================================================ @@ -787,6 +806,7 @@ const DEFAULT_CONFIG: AuditRunConfig = { 'missing_gender', 'orphaned_edge', 'date_mismatch', + 'place_mismatch', ], autoAccept: false, autoAcceptTypes: [], @@ -842,6 +862,9 @@ function auditPerson( if (checksEnabled.includes('date_mismatch')) { issues.push(...checkDateMismatches(runId, vitals.personId, vitals.displayName)); } + if (checksEnabled.includes('place_mismatch')) { + issues.push(...checkPlaceMismatches(runId, vitals.personId, vitals.displayName)); + } return { issues, displayName: vitals.displayName, linkedSources }; } diff --git a/server/src/utils/auditMismatches.ts b/server/src/utils/auditMismatches.ts new file mode 100644 index 00000000..e60f252a --- /dev/null +++ b/server/src/utils/auditMismatches.ts @@ -0,0 +1,84 @@ +/** + * Helpers for detecting disagreements between provider-sourced vital events. + * + * A disagreement must be between sources. Multiple values from one source are + * not enough evidence to ask the user to resolve a cross-provider conflict. + */ + +export interface EventSourceValue { + eventType: string; + value: string | number; + source: string | null; +} + +export interface EventMismatch { + eventType: string; + details: string; +} + +type ValuesMatch = (a: string | number, b: string | number) => boolean; + +function sourceName(source: string | null): string { + return source?.trim() || 'local'; +} + +function formatSourceValues(entries: EventSourceValue[]): string { + const bySource = new Map>(); + + for (const entry of entries) { + const source = sourceName(entry.source); + const values = bySource.get(source) ?? new Set(); + values.add(String(entry.value)); + bySource.set(source, values); + } + + return [...bySource.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([source, values]) => `${source}: ${[...values].sort().join(' / ')}`) + .join(', '); +} + +/** + * Find event types where at least two sources have no matching value. + * + * A source can contain more than one value (notably legacy records with a + * null source). We suppress a mismatch when the two sources share any value, + * because there is no unambiguous cross-provider disagreement to resolve. + */ +export function findCrossSourceMismatches( + entries: EventSourceValue[], + valuesMatch: ValuesMatch, +): EventMismatch[] { + const byEventType = new Map(); + + for (const entry of entries) { + const values = byEventType.get(entry.eventType) ?? []; + values.push(entry); + byEventType.set(entry.eventType, values); + } + + const mismatches: EventMismatch[] = []; + + for (const [eventType, eventEntries] of byEventType) { + const bySource = new Map(); + for (const entry of eventEntries) { + const source = sourceName(entry.source); + const values = bySource.get(source) ?? []; + values.push(entry); + bySource.set(source, values); + } + + const sourceValues = [...bySource.values()]; + const hasDisagreement = sourceValues.some((values, index) => + sourceValues.slice(index + 1).some(otherValues => + values.every(value => otherValues.every(other => !valuesMatch(value.value, other.value))) + ) + ); + + if (hasDisagreement) { + mismatches.push({ eventType, details: formatSourceValues(eventEntries) }); + } + } + + return mismatches; +} diff --git a/tests/unit/utils/auditMismatches.spec.ts b/tests/unit/utils/auditMismatches.spec.ts new file mode 100644 index 00000000..1543f631 --- /dev/null +++ b/tests/unit/utils/auditMismatches.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { findCrossSourceMismatches } from '../../../server/src/utils/auditMismatches.js'; +import { placeContains, placesMatch } from '../../../server/src/utils/normalizePlace.js'; + +describe('findCrossSourceMismatches', () => { + it('reports a disagreement between two provider values', () => { + const mismatches = findCrossSourceMismatches([ + { eventType: 'birth', value: 1874, source: 'familysearch' }, + { eventType: 'birth', value: 1875, source: 'ancestry' }, + ], (a, b) => a === b); + + expect(mismatches).toEqual([{ + eventType: 'birth', + details: 'ancestry: 1875, familysearch: 1874', + }]); + }); + + it('does not report values that only disagree within one source', () => { + const mismatches = findCrossSourceMismatches([ + { eventType: 'birth', value: 1874, source: null }, + { eventType: 'birth', value: 1875, source: null }, + ], (a, b) => a === b); + + expect(mismatches).toEqual([]); + }); + + it('suppresses normalized aliases and detail-only place differences', () => { + const samePlace = (a: string | number, b: string | number) => { + const left = String(a); + const right = String(b); + return placesMatch(left, right) || placeContains(left, right) || placeContains(right, left); + }; + + const mismatches = findCrossSourceMismatches([ + { eventType: 'birth', value: 'Dallas, TX, USA', source: 'familysearch' }, + { eventType: 'birth', value: 'Dallas, Texas, United States', source: 'ancestry' }, + { eventType: 'death', value: 'Texas, USA', source: 'familysearch' }, + { eventType: 'death', value: 'Austin, Texas, United States', source: 'ancestry' }, + ], samePlace); + + expect(mismatches).toEqual([]); + }); + + it('reports genuinely different places from different sources', () => { + const mismatches = findCrossSourceMismatches([ + { eventType: 'birth', value: 'Dallas, Texas, USA', source: 'familysearch' }, + { eventType: 'birth', value: 'Houston, Texas, USA', source: 'ancestry' }, + ], (a, b) => placesMatch(String(a), String(b))); + + expect(mismatches).toEqual([{ + eventType: 'birth', + details: 'ancestry: Houston, Texas, USA, familysearch: Dallas, Texas, USA', + }]); + }); +}); From 4f8f7e2b422cf2ba9459b25d0e79561bd8e44e20 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 1 Aug 2026 11:09:18 +0000 Subject: [PATCH 2/4] fix: audit cached provider vital conflicts --- server/src/services/auditor-agent.service.ts | 62 +++++++++++--------- server/src/utils/auditMismatches.ts | 33 +++++++++++ server/src/utils/auditProviderVitals.ts | 32 ++++++++++ tests/unit/utils/auditMismatches.spec.ts | 16 ++++- 4 files changed, 113 insertions(+), 30 deletions(-) create mode 100644 server/src/utils/auditProviderVitals.ts diff --git a/server/src/services/auditor-agent.service.ts b/server/src/services/auditor-agent.service.ts index a276a364..d877ba19 100644 --- a/server/src/services/auditor-agent.service.ts +++ b/server/src/services/auditor-agent.service.ts @@ -24,8 +24,12 @@ import { sqliteService } from '../db/sqlite.service.js'; import { resolveDbId } from './database.service.js'; import { logger } from '../lib/logger.js'; import { createOperationTracker } from '../utils/operationTracker.js'; -import { findCrossSourceMismatches } from '../utils/auditMismatches.js'; +import { + findCrossSourceMismatches, + type EventSourceValue, +} from '../utils/auditMismatches.js'; import { placeContains, placesMatch } from '../utils/normalizePlace.js'; +import { getCachedProviderVitalValues } from '../utils/auditProviderVitals.js'; import config from '../lib/config.js'; const tracker = createOperationTracker('auditor'); @@ -520,41 +524,45 @@ function checkUnlinkedProviders( return { issues, linkedSources }; } -function checkDateMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { +function getCrossSourceVitalValues(personId: string): EventSourceValue[] { const events = sqliteService.queryAll<{ event_type: string; date_year: number | null; + place: string | null; source: string | null; }>( - `SELECT event_type, date_year, source FROM vital_event - WHERE person_id = @personId AND date_year IS NOT NULL + `SELECT event_type, date_year, place, source FROM vital_event + WHERE person_id = @personId ORDER BY event_type, source`, { personId } ); - return findCrossSourceMismatches( - events.map(event => ({ - eventType: event.event_type, - value: event.date_year as number, - source: event.source, - })), - (left, right) => left === right, - ).map(({ eventType, details }) => makeIssue(runId, personId, 'date_mismatch', 'warning', - `${displayName}: ${eventType} date differs across sources (${details})`, - details, null)); + const values: EventSourceValue[] = events.flatMap(event => { + const eventValues: EventSourceValue[] = []; + if (event.date_year !== null) { + eventValues.push({ eventType: event.event_type, value: event.date_year, source: event.source }); + } + const place = event.place?.trim(); + if (place) eventValues.push({ eventType: event.event_type, value: place, source: event.source }); + return eventValues; + }); + + return [...values, ...getCachedProviderVitalValues(personId)]; +} + +function checkDateMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { + const events = getCrossSourceVitalValues(personId) + .filter((event): event is EventSourceValue & { value: number } => typeof event.value === 'number'); + + return findCrossSourceMismatches(events, (left, right) => left === right) + .map(({ eventType, details }) => makeIssue(runId, personId, 'date_mismatch', 'warning', + `${displayName}: ${eventType} date differs across sources (${details})`, + details, null)); } function checkPlaceMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { - const events = sqliteService.queryAll<{ - event_type: string; - place: string; - source: string | null; - }>( - `SELECT event_type, place, source FROM vital_event - WHERE person_id = @personId AND TRIM(place) != '' - ORDER BY event_type, source, id`, - { personId }, - ); + const events = getCrossSourceVitalValues(personId) + .filter((event): event is EventSourceValue & { value: string } => typeof event.value === 'string'); const samePlace = (left: string | number, right: string | number) => { const a = String(left); @@ -562,11 +570,7 @@ function checkPlaceMismatches(runId: string, personId: string, displayName: stri return placesMatch(a, b) || placeContains(a, b) || placeContains(b, a); }; - return findCrossSourceMismatches(events.map(event => ({ - eventType: event.event_type, - value: event.place, - source: event.source, - })), samePlace).map(({ eventType, details }) => makeIssue( + return findCrossSourceMismatches(events, samePlace).map(({ eventType, details }) => makeIssue( runId, personId, 'place_mismatch', 'warning', `${displayName}: ${eventType} place differs across sources (${details})`, details, null, diff --git a/server/src/utils/auditMismatches.ts b/server/src/utils/auditMismatches.ts index e60f252a..35fdf623 100644 --- a/server/src/utils/auditMismatches.ts +++ b/server/src/utils/auditMismatches.ts @@ -5,6 +5,8 @@ * not enough evidence to ask the user to resolve a cross-provider conflict. */ +import { parseYear } from './parseYear.js'; + export interface EventSourceValue { eventType: string; value: string | number; @@ -16,8 +18,39 @@ export interface EventMismatch { details: string; } +type CachedProviderVitalData = { + provider: string; + scrapedData?: { + birth?: { date?: string; place?: string }; + death?: { date?: string; place?: string }; + }; +}; + type ValuesMatch = (a: string | number, b: string | number) => boolean; +/** + * Convert a cached provider record into the vital values used by the auditor. + * Cached scrapes are deliberately kept separate from the local vital_event + * table until a user explicitly applies them, but they are still evidence for + * a read-only cross-provider audit. + */ +export function cachedProviderVitalValues(cache: CachedProviderVitalData): EventSourceValue[] { + const values: EventSourceValue[] = []; + + for (const [eventType, event] of [ + ['birth', cache.scrapedData?.birth], + ['death', cache.scrapedData?.death], + ] as const) { + const year = parseYear(event?.date); + if (year !== null) values.push({ eventType, value: year, source: cache.provider }); + + const place = event?.place?.trim(); + if (place) values.push({ eventType, value: place, source: cache.provider }); + } + + return values; +} + function sourceName(source: string | null): string { return source?.trim() || 'local'; } diff --git a/server/src/utils/auditProviderVitals.ts b/server/src/utils/auditProviderVitals.ts new file mode 100644 index 00000000..3e02a31b --- /dev/null +++ b/server/src/utils/auditProviderVitals.ts @@ -0,0 +1,32 @@ +import type { BuiltInProvider } from '@fsf/shared'; +import { sqliteService } from '../db/sqlite.service.js'; +import { logger } from '../lib/logger.js'; +import { getCachedProviderData } from './providerCache.js'; +import { cachedProviderVitalValues, type EventSourceValue } from './auditMismatches.js'; + +const BUILT_IN_PROVIDERS = new Set([ + 'familysearch', 'ancestry', 'wikitree', '23andme', +]); + +function isBuiltInProvider(source: string): source is BuiltInProvider { + return BUILT_IN_PROVIDERS.has(source as BuiltInProvider); +} + +/** Read provider-cache vitals for the linked external identities of a person. */ +export function getCachedProviderVitalValues(personId: string): EventSourceValue[] { + const links = sqliteService.queryAll<{ source: string; external_id: string }>( + 'SELECT source, external_id FROM external_identity WHERE person_id = @personId', + { personId }, + ); + + return links.flatMap(link => { + if (!isBuiltInProvider(link.source)) return []; + try { + const cache = getCachedProviderData(link.source, link.external_id); + return cache ? cachedProviderVitalValues(cache) : []; + } catch (error) { + logger.warn('auditor', `Ignoring unreadable ${link.source} cache for ${personId}: ${String(error)}`); + return []; + } + }); +} diff --git a/tests/unit/utils/auditMismatches.spec.ts b/tests/unit/utils/auditMismatches.spec.ts index 1543f631..ca6d4072 100644 --- a/tests/unit/utils/auditMismatches.spec.ts +++ b/tests/unit/utils/auditMismatches.spec.ts @@ -1,8 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { findCrossSourceMismatches } from '../../../server/src/utils/auditMismatches.js'; +import { cachedProviderVitalValues, findCrossSourceMismatches } from '../../../server/src/utils/auditMismatches.js'; import { placeContains, placesMatch } from '../../../server/src/utils/normalizePlace.js'; describe('findCrossSourceMismatches', () => { + it('turns cached scraped provider vitals into source-labelled audit values', () => { + expect(cachedProviderVitalValues({ + provider: 'ancestry', + scrapedData: { + birth: { date: 'about 1875', place: 'Houston, Texas' }, + death: { date: '1930' }, + }, + })).toEqual([ + { eventType: 'birth', value: 1875, source: 'ancestry' }, + { eventType: 'birth', value: 'Houston, Texas', source: 'ancestry' }, + { eventType: 'death', value: 1930, source: 'ancestry' }, + ]); + }); + it('reports a disagreement between two provider values', () => { const mismatches = findCrossSourceMismatches([ { eventType: 'birth', value: 1874, source: 'familysearch' }, From 64d3db385d491678c9ff9f90075ed173f8d2a015 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 1 Aug 2026 11:12:57 +0000 Subject: [PATCH 3/4] fix: read raw FamilySearch audit vitals --- server/src/utils/auditProviderVitals.ts | 18 +++++++++++++++++- tests/unit/utils/auditMismatches.spec.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/server/src/utils/auditProviderVitals.ts b/server/src/utils/auditProviderVitals.ts index 3e02a31b..26d55b9a 100644 --- a/server/src/utils/auditProviderVitals.ts +++ b/server/src/utils/auditProviderVitals.ts @@ -1,6 +1,7 @@ import type { BuiltInProvider } from '@fsf/shared'; import { sqliteService } from '../db/sqlite.service.js'; import { logger } from '../lib/logger.js'; +import { json2person } from '../lib/familysearch/index.js'; import { getCachedProviderData } from './providerCache.js'; import { cachedProviderVitalValues, type EventSourceValue } from './auditMismatches.js'; @@ -12,6 +13,21 @@ function isBuiltInProvider(source: string): source is BuiltInProvider { return BUILT_IN_PROVIDERS.has(source as BuiltInProvider); } +export function providerCacheVitalValues(source: BuiltInProvider, cache: unknown): EventSourceValue[] { + const record = cache as { scrapedData?: unknown }; + if (record.scrapedData) return cachedProviderVitalValues(cache as Parameters[0]); + if (source !== 'familysearch') return []; + + const person = json2person(cache); + return cachedProviderVitalValues({ + provider: source, + scrapedData: { + birth: person?.birth, + death: person?.death, + }, + }); +} + /** Read provider-cache vitals for the linked external identities of a person. */ export function getCachedProviderVitalValues(personId: string): EventSourceValue[] { const links = sqliteService.queryAll<{ source: string; external_id: string }>( @@ -23,7 +39,7 @@ export function getCachedProviderVitalValues(personId: string): EventSourceValue if (!isBuiltInProvider(link.source)) return []; try { const cache = getCachedProviderData(link.source, link.external_id); - return cache ? cachedProviderVitalValues(cache) : []; + return cache ? providerCacheVitalValues(link.source, cache) : []; } catch (error) { logger.warn('auditor', `Ignoring unreadable ${link.source} cache for ${personId}: ${String(error)}`); return []; diff --git a/tests/unit/utils/auditMismatches.spec.ts b/tests/unit/utils/auditMismatches.spec.ts index ca6d4072..f02ec8bd 100644 --- a/tests/unit/utils/auditMismatches.spec.ts +++ b/tests/unit/utils/auditMismatches.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { cachedProviderVitalValues, findCrossSourceMismatches } from '../../../server/src/utils/auditMismatches.js'; +import { providerCacheVitalValues } from '../../../server/src/utils/auditProviderVitals.js'; import { placeContains, placesMatch } from '../../../server/src/utils/normalizePlace.js'; describe('findCrossSourceMismatches', () => { @@ -17,6 +18,22 @@ describe('findCrossSourceMismatches', () => { ]); }); + it('reads raw FamilySearch refresh caches before comparing providers', () => { + expect(providerCacheVitalValues('familysearch', { + persons: [{ + display: { name: 'Ada Example' }, + facts: [{ + type: 'http://gedcomx.org/Birth', + date: { original: '1874' }, + place: { original: 'Dallas, Texas' }, + }], + }], + })).toEqual([ + { eventType: 'birth', value: 1874, source: 'familysearch' }, + { eventType: 'birth', value: 'Dallas, Texas', source: 'familysearch' }, + ]); + }); + it('reports a disagreement between two provider values', () => { const mismatches = findCrossSourceMismatches([ { eventType: 'birth', value: 1874, source: 'familysearch' }, From d7d5d8e6162e03db4c715593db015a8492b8944a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 1 Aug 2026 13:06:39 +0000 Subject: [PATCH 4/4] fix: reuse audit vital values per person --- server/src/services/auditor-agent.service.ts | 29 ++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/server/src/services/auditor-agent.service.ts b/server/src/services/auditor-agent.service.ts index d877ba19..f15b217c 100644 --- a/server/src/services/auditor-agent.service.ts +++ b/server/src/services/auditor-agent.service.ts @@ -550,18 +550,28 @@ function getCrossSourceVitalValues(personId: string): EventSourceValue[] { return [...values, ...getCachedProviderVitalValues(personId)]; } -function checkDateMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { - const events = getCrossSourceVitalValues(personId) +function checkDateMismatches( + runId: string, + personId: string, + displayName: string, + events: EventSourceValue[], +): AuditIssue[] { + const dateEvents = events .filter((event): event is EventSourceValue & { value: number } => typeof event.value === 'number'); - return findCrossSourceMismatches(events, (left, right) => left === right) + return findCrossSourceMismatches(dateEvents, (left, right) => left === right) .map(({ eventType, details }) => makeIssue(runId, personId, 'date_mismatch', 'warning', `${displayName}: ${eventType} date differs across sources (${details})`, details, null)); } -function checkPlaceMismatches(runId: string, personId: string, displayName: string): AuditIssue[] { - const events = getCrossSourceVitalValues(personId) +function checkPlaceMismatches( + runId: string, + personId: string, + displayName: string, + events: EventSourceValue[], +): AuditIssue[] { + const placeEvents = events .filter((event): event is EventSourceValue & { value: string } => typeof event.value === 'string'); const samePlace = (left: string | number, right: string | number) => { @@ -570,7 +580,7 @@ function checkPlaceMismatches(runId: string, personId: string, displayName: stri return placesMatch(a, b) || placeContains(a, b) || placeContains(b, a); }; - return findCrossSourceMismatches(events, samePlace).map(({ eventType, details }) => makeIssue( + return findCrossSourceMismatches(placeEvents, samePlace).map(({ eventType, details }) => makeIssue( runId, personId, 'place_mismatch', 'warning', `${displayName}: ${eventType} place differs across sources (${details})`, details, null, @@ -835,6 +845,9 @@ function auditPerson( const issues: AuditIssue[] = []; let linkedSources = new Set(); + const crossSourceVitalValues = checksEnabled.includes('date_mismatch') || checksEnabled.includes('place_mismatch') + ? getCrossSourceVitalValues(vitals.personId) + : []; if (checksEnabled.includes('impossible_date')) { issues.push(...checkImpossibleDates(runId, vitals)); @@ -864,10 +877,10 @@ function auditPerson( linkedSources = new Set(linked.map(l => l.source)); } if (checksEnabled.includes('date_mismatch')) { - issues.push(...checkDateMismatches(runId, vitals.personId, vitals.displayName)); + issues.push(...checkDateMismatches(runId, vitals.personId, vitals.displayName, crossSourceVitalValues)); } if (checksEnabled.includes('place_mismatch')) { - issues.push(...checkPlaceMismatches(runId, vitals.personId, vitals.displayName)); + issues.push(...checkPlaceMismatches(runId, vitals.personId, vitals.displayName, crossSourceVitalValues)); } return { issues, displayName: vitals.displayName, linkedSources };