Skip to content

Commit 25fe2c9

Browse files
mpstatonclaude
andcommitted
feat(person-db-resolver, person-resolver), fix(shell-auth): multi-signal matching, observation history, additive email/bio capture
person-db-resolver is the operator tool for resolving event/contact CSVs into the canonical persons/organizations layer — the core of "Event Prep and Followup" as an Augment-It use case: import an attendee or prospect list, resolve each row to a canonical person, capture whatever context that row carries (org, role, bio, an event tie), and leave a durable trail. This batch hardens the resolver end-to-end so it holds up against messy real-world data — CSV rows with only an email, no name; bios with facts worth keeping; an operator needing to see what's already on file before adding to it. Proven out against the Aspen Institute Summer Socrates Seminars attendee list, but the improvements are general to the use case, not specific to that event. Person matching now collects from every signal (linkedin_url, email, fuzzy name) instead of short-circuiting on the first exact hit, so near-matches still surface as alternatives even after a decisive match. Email is now a first-class person field: matched additively (never overwrites a human-verified value already on file), written as a has_email observation, and used to backfill stub records — including a race-safe path — created by bulk CLI imports that only ever had an email. Bio text is captured the same way as has_bio, tied to related_event when the row came from one. A new person.observations capability (NATS subject + workspace capability wiring) exposes the append-only observation history read-only, since "editing" a fact means adding a newer observation, not mutating the old one. person-db-resolver UI: candidates now show a stub-record affordance (email or LinkedIn URL when there's no name), the observation column is editable per-row before it's written, observation history renders under a matched/ created result so the operator sees what's on file before adding to it, and the row index persists per record-set to localStorage (resume-on-remount + a manual jump-to-row input) so a mapping session survives an HMR reload or tab reopen. ColumnMapper and normalize.ts gained email/bio column mapping to feed all of this. Small but relevant: dev auto-login (shell/DidiBadge.svelte, shell/ SignInWall.svelte) — setting PUBLIC_DEV_AUTO_LOGIN_EMAIL skips the manual magic-link click on every stack restart, guarded with sessionStorage rather than a component-local flag, since sign-in's full-page reload re-mounts the component and re-nulls any local guard, which was causing an infinite magic-link loop. dev.sh now starts the sibling id-didi-sh identity service idempotently before backend/frontend come up, and exports PUBLIC_* vars from root .env so rsbuild's per-package cwd doesn't hide them from import.meta.env. Also included: a set of one-off scripts/surreal-*.mjs operator scripts that re-match already-resolved Aspen CSV rows to their persons row by identifier (email > LinkedIn > name) to backfill tags/bio/relevance/x-handle that predate this commit's resolver changes — necessary because nothing in this pipeline writes person_uuid back onto the source CSV. build-aspen-tags- relevance-report.mjs is an early, non-productized sketch of what a tag/relevance-filtered export view could look like (faceted filter bar, reuses the FreedomFest report's stylesheet) — a direction, not a shipped feature. Also filed context-v/issues/Person-DB-Resolver-Needs-Multiple- Organizations-Per-Person.md as a stub: bios routinely name multiple current/ past affiliations but the UI still only resolves one org per row — documented, not addressed here. Files changed: - apps/person-db-resolver/src/App.svelte - apps/person-db-resolver/src/app.css - apps/person-db-resolver/src/components/ColumnMapper.svelte - apps/person-db-resolver/src/components/PersonCandidateList.svelte - apps/person-db-resolver/src/lib/normalize.ts - apps/person-db-resolver/src/lib/resolver-client.ts - apps/person-db-resolver/src/lib/types.ts - services/record-surrealdb-resolver/src/person-handlers.ts - services/record-surrealdb-resolver/src/person-resolver.ts - services/workspace/src/capabilities.ts - shell/src/DidiBadge.svelte - shell/src/SignInWall.svelte - scripts/dev.sh - scripts/build-aspen-tags-relevance-report.mjs - scripts/surreal-add-person-tags.mjs - scripts/surreal-backfill-bio-observations.mjs - scripts/surreal-backfill-tags-relevance.mjs - scripts/surreal-backfill-x-handle.mjs - scripts/surreal-set-person-bio.mjs - scripts/surreal-set-person-relevance.mjs - scripts/surreal-write-event-aspen-summer-socrates.mjs - context-v/issues/Person-DB-Resolver-Needs-Multiple-Organizations-Per-Person.md Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013XVHBYaKTwCYdweGu2bnTU
1 parent 778656b commit 25fe2c9

22 files changed

Lines changed: 1817 additions & 59 deletions

apps/person-db-resolver/src/App.svelte

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
applyPerson,
2020
affiliatePerson,
2121
addPersonObservation,
22+
fetchPersonObservations,
2223
fetchOrgCandidates,
2324
searchOrgs,
2425
} from './lib/resolver-client';
@@ -27,6 +28,7 @@
2728
PersonNormRecord,
2829
PersonCandidate,
2930
PersonApplyResult,
31+
PersonObservationRow,
3032
OrgCandidate,
3133
OrgSuggestion,
3234
PersonAffiliateResult,
@@ -36,6 +38,11 @@
3638
const WS_URL = 'ws://localhost:3001/ws';
3739
const ACTIVE_RECORD_SET_KEY = 'augment-it:active-record-set';
3840
const MAPPING_KEY_PREFIX = 'augment-it:person-db-resolver:mapping:';
41+
// Per-record-set "where I left off" — restored on every selectRecordSet()
42+
// (mount, HMR remount, workspace switch, tab reopen), not just typed
43+
// navigation. Saved on every idx change so it's always current, not just
44+
// on explicit jumps.
45+
const IDX_KEY_PREFIX = 'augment-it:person-db-resolver:idx:';
3946
4047
let status = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
4148
let client = $state<string>('reach-edu');
@@ -53,13 +60,30 @@
5360
let personCandidates = $state<PersonCandidate[]>([]);
5461
let loadingPerson = $state(false);
5562
let personError = $state<string | null>(null);
63+
// Which action set personError — candidates/match/create all share the
64+
// one error slot, but a raw SurrealDB constraint message (e.g. a UNIQUE
65+
// index collision on create) reads very differently from a failed
66+
// candidate lookup. Labeled at the throw site instead of hardcoded in
67+
// the template.
68+
let personErrorLabel = $state<'candidates' | 'match' | 'create'>('candidates');
5669
let personResult = $state<PersonApplyResult | null>(null);
5770
let personBusy = $state(false);
5871
let personNameInput = $state('');
72+
// Editable mirror of the mapped Observation column — before this, the
73+
// event-tie text (e.g. "attendee at Aspen Institute: ...") was parsed and
74+
// written silently with zero operator visibility or per-row override.
75+
// Same "operator-edited value wins, falls back to the mapped column"
76+
// pattern as personNameInput.
77+
let personObservationInput = $state('');
5978
let personSearchQuery = $state('');
6079
let personSearchResults = $state<PersonCandidate[]>([]);
6180
let personSearching = $state(false);
6281
let personSkipped = $state(false);
82+
// Read-only history for the matched/created person — observations are
83+
// append-only, so this is what makes "editing" sane: see what's on file,
84+
// add a correction on top, don't blindly append with no context.
85+
let personObservations = $state<PersonObservationRow[]>([]);
86+
let personObservationsLoading = $state(false);
6387
6488
let orgCandidates = $state<OrgCandidate[]>([]);
6589
let loadingOrg = $state(false);
@@ -87,11 +111,17 @@
87111
);
88112
const source = $derived(selectedSet ? `record-set:${selectedSet.name}` : 'person-db-resolver');
89113
// The person actions (candidates/create/match) use the OPERATOR-EDITED
90-
// name, not the raw mapped column — record.name stays visible in
91-
// RecordCard as "here's what the CSV said," personNameInput is what
92-
// actually gets written. Falls back to the mapped name if cleared.
114+
// name and observation text, not the raw mapped columns — record stays
115+
// visible in RecordCard as "here's what the CSV said," these inputs are
116+
// what actually gets written. Falls back to the mapped values if cleared.
93117
const personRecord = $derived(
94-
record ? { ...record, name: personNameInput.trim() || record.name } : null,
118+
record
119+
? {
120+
...record,
121+
name: personNameInput.trim() || record.name,
122+
observation: personObservationInput.trim() || record.observation,
123+
}
124+
: null,
95125
);
96126
97127
function onActiveRecordSetChange(e: Event) {
@@ -161,11 +191,34 @@
161191
try {
162192
const r = (await workspace.invoke('row.list', { record_set_id })) as { rows: Row[] };
163193
rows = r.rows.filter((row) => !(row.fields as Record<string, unknown>).archived);
194+
// Resume where this record set was left off — mount, HMR remount,
195+
// workspace switch, or tab reopen all land here via the same path.
196+
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(`${IDX_KEY_PREFIX}${record_set_id}`) : null;
197+
if (stored != null) {
198+
const n = Number(stored);
199+
if (Number.isFinite(n)) idx = Math.min(Math.max(0, n), Math.max(0, rows.length - 1));
200+
}
164201
} catch (err) {
165202
console.error('row.list', err);
166203
}
167204
}
168205
206+
function saveIdx() {
207+
if (selectedRecordSetId && typeof localStorage !== 'undefined') {
208+
localStorage.setItem(`${IDX_KEY_PREFIX}${selectedRecordSetId}`, String(idx));
209+
}
210+
}
211+
212+
// Manual "jump to row N" — accepts 1-based row numbers (matches the
213+
// "N / total" display), clamps to the valid range.
214+
function jumpTo(raw: string | number) {
215+
const n = typeof raw === 'number' ? raw : Number(raw);
216+
if (!Number.isFinite(n) || rows.length === 0) return;
217+
idx = Math.min(Math.max(0, Math.round(n) - 1), rows.length - 1);
218+
resetRowState();
219+
saveIdx();
220+
}
221+
169222
function loadMapping(record_set_id: string) {
170223
const key = `${MAPPING_KEY_PREFIX}${record_set_id}`;
171224
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
@@ -197,6 +250,7 @@
197250
personSkipped = false;
198251
personSearchQuery = '';
199252
personSearchResults = [];
253+
personObservations = [];
200254
orgCandidates = [];
201255
orgError = null;
202256
orgResult = null;
@@ -226,6 +280,7 @@
226280
return;
227281
}
228282
personNameInput = rec.name;
283+
personObservationInput = rec.observation ?? '';
229284
void loadPersonCandidatesFor(rec);
230285
});
231286
@@ -236,6 +291,7 @@
236291
try {
237292
personCandidates = await fetchPersonCandidates(rec, client);
238293
} catch (err) {
294+
personErrorLabel = 'candidates';
239295
personError = err instanceof Error ? err.message : String(err);
240296
personCandidates = [];
241297
} finally {
@@ -287,13 +343,27 @@
287343
await loadOrgCandidatesFor(orgNameInput);
288344
}
289345
346+
async function loadPersonObservations(person_uuid: string) {
347+
personObservationsLoading = true;
348+
try {
349+
personObservations = await fetchPersonObservations(person_uuid, client);
350+
} catch (err) {
351+
console.error('person.observations', err);
352+
personObservations = [];
353+
} finally {
354+
personObservationsLoading = false;
355+
}
356+
}
357+
290358
async function doMatchPerson(c: PersonCandidate) {
291359
if (!personRecord) return;
292360
personBusy = true;
293361
personError = null;
294362
try {
295363
personResult = await applyPerson({ action: 'match', person_uuid: c.person_uuid, record: personRecord, client, source });
364+
void loadPersonObservations(personResult.person_uuid);
296365
} catch (err) {
366+
personErrorLabel = 'match';
297367
personError = err instanceof Error ? err.message : String(err);
298368
} finally {
299369
personBusy = false;
@@ -306,7 +376,9 @@
306376
personError = null;
307377
try {
308378
personResult = await applyPerson({ action: 'create', record: personRecord, client, source });
379+
void loadPersonObservations(personResult.person_uuid);
309380
} catch (err) {
381+
personErrorLabel = 'create';
310382
personError = err instanceof Error ? err.message : String(err);
311383
} finally {
312384
personBusy = false;
@@ -423,6 +495,7 @@
423495
obsSaved = true;
424496
obsPredicate = '';
425497
obsValue = '';
498+
void loadPersonObservations(personResult.person_uuid);
426499
} catch (err) {
427500
obsError = err instanceof Error ? err.message : String(err);
428501
} finally {
@@ -433,10 +506,12 @@
433506
function advance() {
434507
idx = Math.min(idx + 1, rows.length);
435508
resetRowState();
509+
saveIdx();
436510
}
437511
function back() {
438512
idx = Math.max(0, idx - 1);
439513
resetRowState();
514+
saveIdx();
440515
}
441516
function skipRow() {
442517
advance();
@@ -463,7 +538,18 @@
463538
{/each}
464539
</select>
465540
{#if rows.length}
466-
<span class="pdr-progress">{Math.min(idx + 1, rows.length)} / {rows.length}</span>
541+
<span class="pdr-progress">
542+
<input
543+
type="number"
544+
class="pdr-jump"
545+
min="1"
546+
max={rows.length}
547+
value={Math.min(idx + 1, rows.length)}
548+
title="jump to row"
549+
onchange={(e) => jumpTo(e.currentTarget.value)}
550+
onkeydown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
551+
/> / {rows.length}
552+
</span>
467553
<button type="button" class="pdr-btn" onclick={() => (showMapper = true)}>edit column mapping</button>
468554
{/if}
469555
</div>
@@ -489,13 +575,22 @@
489575
<span class="pdr-eyebrow">person</span>
490576
{#if loadingPerson}<span class="pdr-muted">finding candidates…</span>{/if}
491577
</div>
492-
{#if personError}<div class="pdr-error">candidates: {personError}</div>{/if}
578+
{#if personError}<div class="pdr-error">{personErrorLabel}: {personError}</div>{/if}
493579

494580
{#if !personResult && !personSkipped}
495581
<label class="pdr-org-name-row">
496582
<span>person name</span>
497583
<input type="text" bind:value={personNameInput} onchange={() => void loadPersonCandidates()} placeholder="Person name" />
498584
</label>
585+
<label class="pdr-org-name-row">
586+
<span>observation (event tie)</span>
587+
<input
588+
type="text"
589+
bind:value={personObservationInput}
590+
placeholder="e.g. attendee at Event Name"
591+
title="Written as a parsed event-tie observation on create/match. Edit or clear before resolving this row."
592+
/>
593+
</label>
499594
<PersonCandidateList candidates={personCandidates} busy={personBusy} onMatch={doMatchPerson} />
500595
<div class="pdr-create">
501596
<button type="button" class="pdr-btn pdr-btn-create" disabled={personBusy || !personRecord?.name} onclick={doCreatePerson}>
@@ -537,6 +632,25 @@
537632
<div class="pdr-result-head">
538633
{personResult.created ? '✓ created' : '✓ matched'} <strong>{personResult.name}</strong>
539634
</div>
635+
<details class="pdr-search" open>
636+
<summary>
637+
observation history{personObservationsLoading ? ' — loading…' : ` (${personObservations.length})`}
638+
</summary>
639+
{#if !personObservationsLoading && personObservations.length === 0}
640+
<p class="pdr-muted">nothing on file yet.</p>
641+
{:else}
642+
<ul class="pdr-search-results">
643+
{#each personObservations as o (o.predicate + String(o.observed_at) + String(o.object))}
644+
<li>
645+
<span>
646+
<strong>{o.predicate}</strong>: {String(o.object)}
647+
<span class="pdr-muted"> — {new Date(o.observed_at).toLocaleString()} · {o.source}</span>
648+
</span>
649+
</li>
650+
{/each}
651+
</ul>
652+
{/if}
653+
</details>
540654
<div class="pdr-add-obs">
541655
<label><span>predicate (optional)</span><input type="text" bind:value={obsPredicate} placeholder="defaults to 'note'" /></label>
542656
<label><span>value</span><input type="text" bind:value={obsValue} placeholder="e.g. confirmed 2026-07-07" /></label>

apps/person-db-resolver/src/app.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
.pdr-setpick { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.85rem; }
2222
.pdr-setpick label { color: var(--color-text-muted, #9aa0aa); }
2323
.pdr-setpick select { background: var(--color-surface, #1c1e25); color: inherit; border: 1px solid var(--color-border, #2a2c33); border-radius: 4px; padding: 0.3rem 0.5rem; min-width: 18rem; }
24-
.pdr-progress { margin-left: auto; color: var(--color-text-muted, #9aa0aa); font-variant-numeric: tabular-nums; }
24+
.pdr-progress { margin-left: auto; color: var(--color-text-muted, #9aa0aa); font-variant-numeric: tabular-nums; display: flex; align-items: center; gap: 0.3rem; }
25+
.pdr-jump { width: 3.5rem; background: var(--color-surface, #1c1e25); color: inherit; border: 1px solid var(--color-border, #2a2c33); border-radius: 4px; padding: 0.25rem 0.4rem; font: inherit; font-variant-numeric: tabular-nums; text-align: right; }
2526

2627
.pdr-body { margin-top: 1rem; }
2728
.pdr-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }

apps/person-db-resolver/src/components/ColumnMapper.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
{ key: 'role', label: 'Role / title', required: false },
3232
{ key: 'linkedin_url', label: 'LinkedIn URL', required: false },
3333
{ key: 'observation', label: 'Observation (event tie)', required: false },
34+
{ key: 'email', label: 'Email', required: false },
35+
{ key: 'bio', label: 'Bio', required: false },
3436
];
3537
3638
function save() {

apps/person-db-resolver/src/components/PersonCandidateList.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,15 @@
2222
<li class="pdr-candidate">
2323
<div class="pdr-candidate-head">
2424
<div class="pdr-candidate-id">
25-
<span class="pdr-candidate-name">{c.name || '(no name)'}</span>
25+
<span class="pdr-candidate-name">
26+
{c.name || c.email || c.linkedin_profile_url || '(no identifying info on this record)'}
27+
</span>
2628
{#if c.headline}<span class="pdr-candidate-headline">{c.headline}</span>{/if}
29+
{#if !c.name}
30+
<span class="pdr-candidate-headline">
31+
stub record — no name on file{c.email ? `, matched by email` : c.linkedin_profile_url ? `, matched by LinkedIn URL` : ''}
32+
</span>
33+
{/if}
2734
</div>
2835
<div class="pdr-candidate-score">
2936
<span class="pdr-score" data-tier={c.score >= 90 ? 'high' : c.score >= 60 ? 'mid' : 'low'}>{c.score}</span>

apps/person-db-resolver/src/lib/normalize.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export function normalizePersonRecord(
2424
role: get(mapping.role) || null,
2525
linkedin_url: get(mapping.linkedin_url) || null,
2626
observation: get(mapping.observation) || null,
27+
email: get(mapping.email) || null,
28+
bio: get(mapping.bio) || null,
2729
};
2830
}
2931

@@ -37,6 +39,8 @@ const GUESSES: Record<keyof FieldMapping, string[]> = {
3739
role: ['title', 'role', 'Title', 'Role'],
3840
linkedin_url: ['linkedin_url', 'linkedin', 'profile_url', 'LinkedIn'],
3941
observation: ['observation', 'Observation'],
42+
email: ['email', 'Email', 'email_address', 'Email Address'],
43+
bio: ['bio', 'Bio', 'biography', 'Biography'],
4044
};
4145

4246
export function guessMapping(columns: string[]): FieldMapping {
@@ -52,6 +56,8 @@ export function guessMapping(columns: string[]): FieldMapping {
5256
role: pick('role'),
5357
linkedin_url: pick('linkedin_url'),
5458
observation: pick('observation'),
59+
email: pick('email'),
60+
bio: pick('bio'),
5561
};
5662
}
5763

apps/person-db-resolver/src/lib/resolver-client.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
PersonCandidate,
1212
PersonApplyResult,
1313
PersonAffiliateResult,
14+
PersonObservationRow,
1415
OrgCandidate,
1516
OrgSuggestion,
1617
} from './types';
@@ -78,6 +79,19 @@ export async function addPersonObservation(args: {
7879
if (!r.ok) throw new Error(r.error || 'person.add_observation failed');
7980
}
8081

82+
export async function fetchPersonObservations(
83+
person_uuid: string,
84+
client: string,
85+
): Promise<PersonObservationRow[]> {
86+
const r = (await workspace.invoke('person.observations', { person_uuid, client })) as {
87+
ok: boolean;
88+
observations?: PersonObservationRow[];
89+
error?: string;
90+
};
91+
if (!r.ok) throw new Error(r.error || 'person.observations failed');
92+
return r.observations ?? [];
93+
}
94+
8195
// Org side — reuses record-db-resolver's own capabilities, fed a synthetic
8296
// {name: org_name} record. No url/socials, so append_preview will always be
8397
// empty; that's fine, this app never shows or uses it.

apps/person-db-resolver/src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@ export type PersonNormRecord = {
88
org_name?: string | null;
99
role?: string | null;
1010
observation?: string | null;
11+
email?: string | null;
12+
bio?: string | null;
13+
};
14+
15+
export type PersonObservationRow = {
16+
predicate: string;
17+
object: unknown;
18+
observed_at: string;
19+
source: string;
1120
};
1221

1322
export type PersonCandidate = {
1423
person_uuid: string;
1524
name: string | null;
1625
headline: string | null;
1726
linkedin_profile_url: string | null;
27+
email: string | null;
1828
score: number;
1929
match_reason: string[];
2030
};
@@ -65,4 +75,6 @@ export type FieldMapping = {
6575
role: string;
6676
linkedin_url: string;
6777
observation: string;
78+
email: string;
79+
bio: string;
6880
};

0 commit comments

Comments
 (0)