Skip to content

Commit 71e9772

Browse files
mpstatonclaude
andcommitted
feat(mega-gifts-ingest): 84 gift rows land in the canonical layer — funders ensured, corpora registered, uuids stamped home
The mega-gifts-by-topic CSV files in three guarantees per row: the funder exists (alias-aware match first — caught the new-york-times and wrong-department prefix traps in dry-run; 44 clean mints incl. the operator-ruled vehicles Yield Giving, Declaration Partners, David and Ruth Gottesman, Stryker Johnston Foundation, and CFAT with a parent edge to carnegie-foundation), the article registers on each funder's org corpus (multi-funder consortium cells split to components via an operator-reviewed OVERRIDES table — the ten-foundation pledge registers on all ten), and on every strategy corpus named in strategy_slugs (validated against the live domain list, never fabricated). Article bodies stay where the capture session put them: the script stamps source_uuid / content_uuid / org_slugs into each inbox file's frontmatter (79 stamped, 2 urls uncaptured) so the triage merge becomes a lookup — and merged sources are never re-fetched. Run: 103 org-corpus adds · 132 strategy source adds · 0 failures · 1 deliberate skip (John Overdeck — person, no ruled vehicle). Files changed: - scripts/ingest-mega-gifts-by-topic.mjs (new) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent 272c333 commit 71e9772

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
#!/usr/bin/env node
2+
// ============================================================================
3+
// ingest-mega-gifts-by-topic.mjs
4+
//
5+
// Files the mega-gifts-by-topic CSV (operator ask 2026-07-28) into the
6+
// canonical layer, three guarantees per row:
7+
// 1. the funder EXISTS as an organization (alias-aware match first — the
8+
// bhef/donorstrust lesson — mint only on a true miss, long-form slug)
9+
// 2. the source article is registered on the funder's org corpus
10+
// (organization.corpus.add — dedup-by-URL server-side)
11+
// 3. the source article is registered on each strategy corpus named in
12+
// strategy_slugs (source.add — slugs VALIDATED against the live
13+
// domain list, never fabricated)
14+
//
15+
// DB-side only, deliberately: another session captured the article bodies
16+
// into the inbox; the disk merge into folders is the triage lane's job
17+
// (and merged sources must never be source.fetch'd — registry gotcha).
18+
//
19+
// Usage:
20+
// set -a; source ./.env; set +a
21+
// node scripts/ingest-mega-gifts-by-topic.mjs [--csv <path>] [--live]
22+
// ============================================================================
23+
24+
import { createRequire } from 'node:module';
25+
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
26+
import { resolve, join } from 'node:path';
27+
28+
const requireScripts = createRequire(new URL('./package.json', import.meta.url));
29+
const requireServices = createRequire(new URL('../services/social-search/package.json', import.meta.url));
30+
const { Surreal } = requireScripts('surrealdb');
31+
const { connect } = requireServices('@nats-io/transport-node');
32+
33+
const args = { csv: 'clients/reach-edu/outputs/2026-07-28_mega-gifts-by-topic/mega-gifts-by-topic.csv', client: 'reach-edu', live: false };
34+
for (let i = 2; i < process.argv.length; i += 1) {
35+
const k = process.argv[i];
36+
if (k === '--csv') args.csv = process.argv[++i];
37+
else if (k === '--client') args.client = process.argv[++i];
38+
else if (k === '--live') args.live = true;
39+
}
40+
41+
// ---- csv parse ----------------------------------------------------------------
42+
function parseCsv(path) {
43+
const text = readFileSync(path, 'utf8');
44+
const rows = [];
45+
let row = [], cell = '', inQ = false;
46+
for (let i = 0; i < text.length; i += 1) {
47+
const c = text[i];
48+
if (inQ) {
49+
if (c === '"' && text[i + 1] === '"') { cell += '"'; i += 1; }
50+
else if (c === '"') inQ = false;
51+
else cell += c;
52+
} else if (c === '"') inQ = true;
53+
else if (c === ',') { row.push(cell); cell = ''; }
54+
else if (c === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; }
55+
else if (c !== '\r') cell += c;
56+
}
57+
if (row.length > 1) rows.push(row);
58+
const [h, ...data] = rows;
59+
return data.filter((r) => r.length > 1).map((r) => Object.fromEntries(h.map((c, i) => [c, (r[i] ?? '').trim()])));
60+
}
61+
const rows = parseCsv(resolve(args.csv));
62+
console.log(`rows: ${rows.length}`);
63+
64+
// ---- org matching (the export script's alias-aware family) --------------------
65+
const normName = (s) => String(s ?? '').replace(/\(.*?\)/g, ' ').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
66+
67+
const db = new Surreal();
68+
await db.connect(process.env.SURREAL_URL);
69+
await db.signin({ username: process.env.SURREAL_USER, password: process.env.SURREAL_PASS });
70+
await db.use({ namespace: process.env.SURREAL_NS, database: process.env.SURREAL_DB });
71+
const orgs = (await db.query(
72+
`SELECT slug, complete_name, conventional_name, aliases FROM organizations WHERE client_access CONTAINS $client;`,
73+
{ client: args.client },
74+
))?.[0] ?? [];
75+
await db.close();
76+
77+
const byNorm = new Map();
78+
for (const o of orgs) {
79+
for (const cand of [o.complete_name, o.conventional_name, o.slug.replace(/-/g, ' '), ...(o.aliases ?? [])]) {
80+
const n = normName(cand);
81+
if (n && !byNorm.has(n)) byNorm.set(n, o.slug);
82+
}
83+
}
84+
const orgNorms = Array.from(byNorm.keys());
85+
function matchOrg(name) {
86+
const variants = new Set([normName(name)]);
87+
for (const part of String(name).split('/')) variants.add(normName(part));
88+
for (const v of Array.from(variants)) {
89+
const toks = v.split(' ');
90+
for (let d = 1; d <= 4 && toks.length - d >= 1; d += 1) variants.add(toks.slice(0, toks.length - d).join(' '));
91+
}
92+
for (const v of variants) if (byNorm.has(v)) return byNorm.get(v);
93+
for (const v of variants) {
94+
const minLen = v.includes(' ') ? 5 : 8;
95+
if (v.length < minLen) continue;
96+
const hits = orgNorms.filter((n) => n.startsWith(v + ' '));
97+
if (hits.length === 1) return byNorm.get(hits[0]);
98+
}
99+
return null;
100+
}
101+
102+
// ---- NATS ---------------------------------------------------------------------
103+
const nc = await connect({ servers: process.env.NATS_URL ?? 'nats://localhost:4222' });
104+
const req = async (s, b, t = 30_000) =>
105+
JSON.parse(new TextDecoder().decode((await nc.request(s, JSON.stringify(b), { timeout: t })).data));
106+
107+
// ---- strategy validation (never fabricate a domain slug) ----------------------
108+
const dl = await req('domain.list.requested', { client_slug: args.client });
109+
const liveStrategies = new Set((dl.domains ?? []).filter((d) => (d.domain_type ?? d.type) === 'strategy').map((d) => d.domain_slug ?? d.slug));
110+
111+
// ---- overrides — operator-ruled resolutions for every tricky cell (multi-
112+
// funder consortia split to components; person-anchored giving mapped to
113+
// the ruled vehicle orgs; false-match traps pinned). Reviewed 2026-07-28.
114+
const OVERRIDES = {
115+
'New York State': [{ mint: 'New York State' }], // the new-york-times prefix trap
116+
'U.S. Department of Labor': [{ slug: 'us-department-of-labor' }],
117+
'Bill & Melinda Gates Foundation': [{ slug: 'the-gates-foundation' }],
118+
'Carnegie Foundation for the Advancement of Teaching and College Board': [
119+
{ slug: 'carnegie-foundation-for-the-advancement-of-teaching' }, { slug: 'college-board' }],
120+
'Connect Humanity / Microsoft / Appalachian Community Capital': [
121+
{ mint: 'Connect Humanity' }, { slug: 'microsoft' }, { mint: 'Appalachian Community Capital' }],
122+
'Microsoft, OpenAI, Anthropic': [{ slug: 'microsoft' }, { mint: 'OpenAI' }, { mint: 'Anthropic' }],
123+
'Ballmer Group, Gates Foundation, Stand Together, Valhalla Foundation, John Overdeck': [
124+
{ slug: 'ballmer-group' }, { slug: 'the-gates-foundation' }, { mint: 'Stand Together' },
125+
{ mint: 'Valhalla Foundation' }, { skip: 'John Overdeck — person, no ruled vehicle yet' }],
126+
'Ford, MacArthur, Mellon, Omidyar, Lumina, Doris Duke, Kapor, Mozilla, Packard, Siegel Family Endowment': [
127+
{ mint: 'Ford Foundation' }, { mint: 'MacArthur Foundation' }, { mint: 'Mellon Foundation' },
128+
{ mint: 'Omidyar Network' }, { slug: 'lumina-foundation' }, { mint: 'Doris Duke Foundation' },
129+
{ mint: 'Kapor Foundation' }, { mint: 'Mozilla Foundation' }, { mint: 'Packard Foundation' },
130+
{ mint: 'Siegel Family Endowment' }],
131+
'ADQ + Gates Foundation': [{ mint: 'ADQ' }, { slug: 'the-gates-foundation' }],
132+
'Wells Fargo / Wells Fargo Foundation': [{ mint: 'Wells Fargo Foundation' }],
133+
'David M. Rubenstein / Library of Congress': [{ slug: 'declaration-partners' }],
134+
'Ballmer Group and Ralph C. Wilson Jr. Foundation': [
135+
{ slug: 'ballmer-group' }, { mint: 'Ralph C. Wilson Jr. Foundation' }],
136+
'Commonwealth of Pennsylvania (Shapiro Administration)': [{ mint: 'Commonwealth of Pennsylvania' }],
137+
'ProLiteracy (Nora Roberts Foundation-backed)': [{ mint: 'ProLiteracy' }],
138+
'Goldman Sachs (10,000 Small Businesses)': [{ mint: 'Goldman Sachs' }],
139+
'Invest Appalachia (multiple foundation LPs)': [{ mint: 'Invest Appalachia' }],
140+
};
141+
142+
// ---- census -------------------------------------------------------------------
143+
const knownSlugs = new Set(orgs.map((o) => o.slug));
144+
const funders = new Map(); // funder cell -> { targets: [{slug}|{mint}|{skip}], rows: [] }
145+
const badStrategies = new Set();
146+
const badOverrideSlugs = new Set();
147+
for (const r of rows) {
148+
let f = funders.get(r.funder);
149+
if (!f) {
150+
let targets;
151+
if (OVERRIDES[r.funder]) {
152+
targets = OVERRIDES[r.funder];
153+
for (const t of targets) if (t.slug && !knownSlugs.has(t.slug)) badOverrideSlugs.add(t.slug);
154+
} else {
155+
const m = matchOrg(r.funder);
156+
targets = m ? [{ slug: m }] : [{ mint: r.funder }];
157+
}
158+
f = { targets, rows: [] };
159+
funders.set(r.funder, f);
160+
}
161+
f.rows.push(r);
162+
for (const s of r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)) {
163+
if (!liveStrategies.has(s)) badStrategies.add(s);
164+
}
165+
}
166+
if (badOverrideSlugs.size) {
167+
console.log(`✗ override references unknown slugs (aborting): ${[...badOverrideSlugs].join(', ')}`);
168+
await nc.drain();
169+
process.exit(1);
170+
}
171+
const mintNames = new Set();
172+
let matchedCount = 0, overrideCount = 0, skipCount = 0;
173+
for (const [name, f] of funders) {
174+
const via = OVERRIDES[name] ? 'override' : 'match';
175+
if (OVERRIDES[name]) overrideCount += 1;
176+
for (const t of f.targets) {
177+
if (t.mint) mintNames.add(t.mint);
178+
else if (t.skip) skipCount += 1;
179+
else matchedCount += 1;
180+
}
181+
console.log(` ${via === 'override' ? '⊙' : '='} ${name}${f.targets.map((t) => t.slug ?? (t.mint ? 'MINT:' + t.mint : 'SKIP')).join(' + ')}`);
182+
}
183+
console.log(`funders: ${funders.size} distinct cells — ${matchedCount} slug targets, ${mintNames.size} distinct mints, ${skipCount} skips, ${overrideCount} override cells`);
184+
if (badStrategies.size) {
185+
console.log(`✗ UNKNOWN strategy slugs (aborting — never fabricate): ${[...badStrategies].join(', ')}`);
186+
await nc.drain();
187+
process.exit(1);
188+
}
189+
console.log(`strategies referenced: all valid (${[...new Set(rows.flatMap((r) => r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)))].join(', ')})`);
190+
console.log(`distinct source urls: ${new Set(rows.map((r) => r.source_url)).size}`);
191+
192+
if (!args.live) {
193+
console.log('\nDRY-RUN — nothing written. Re-run with --live.');
194+
await nc.drain();
195+
process.exit(0);
196+
}
197+
198+
// ---- execute ------------------------------------------------------------------
199+
// 1. Mint missing funders once each (deduped across cells).
200+
const mintedSlug = new Map();
201+
for (const name of mintNames) {
202+
const r = await req('person.affiliate.requested', { org_action: 'create', org_name: name, client: args.client, source: 'mega-gifts-by-topic' });
203+
if (r.ok) { mintedSlug.set(name, r.org_slug); console.log(` minted ${name}${r.org_slug}`); }
204+
else console.log(` ✗ mint ${name}: ${r.error}`);
205+
}
206+
const slugsFor = (cell) =>
207+
(funders.get(cell)?.targets ?? [])
208+
.map((t) => t.slug ?? (t.mint ? mintedSlug.get(t.mint) : null))
209+
.filter(Boolean);
210+
211+
// Inbox index — the other session captured article bodies here; after
212+
// registering, the Surreal uuids get stamped into each file's frontmatter
213+
// (operator ask 2026-07-28) so the triage merge becomes a lookup.
214+
const INBOX = resolve(`clients/${args.client}/corpus/inbox`);
215+
const normUrl = (u) => String(u ?? '').toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '').replace(/#.*$/, '');
216+
const inboxByUrl = new Map();
217+
for (const dir of [INBOX, join(INBOX, 'gated')]) {
218+
if (!existsSync(dir)) continue;
219+
for (const f of readdirSync(dir)) {
220+
if (!f.endsWith('.md')) continue;
221+
const path = join(dir, f);
222+
const head = readFileSync(path, 'utf8').slice(0, 2000);
223+
const url = /^(?:exact_url|url): *"?([^"\n]+)"?/m.exec(head)?.[1];
224+
if (url) inboxByUrl.set(normUrl(url), path);
225+
}
226+
}
227+
console.log(`inbox files indexed by url: ${inboxByUrl.size}`);
228+
function stampFrontmatter(path, sets) {
229+
let text = readFileSync(path, 'utf8');
230+
const end = text.indexOf('\n---', 4);
231+
if (!text.startsWith('---') || end === -1) return false;
232+
let fm = text.slice(0, end + 1);
233+
const body = text.slice(end + 1);
234+
for (const [key, value] of Object.entries(sets)) {
235+
if (value === undefined || value === null || value === '') continue;
236+
const line = `${key}: ${JSON.stringify(String(value))}`;
237+
const re = new RegExp(`^${key}: .*$`, 'm');
238+
fm = re.test(fm) ? fm.replace(re, line) : fm + line + '\n';
239+
}
240+
writeFileSync(path, fm + body);
241+
return true;
242+
}
243+
244+
// 2 + 3. Per row: org corpus + strategy corpora, then frontmatter stamps.
245+
let orgAdds = 0, stratAdds = 0, fails = 0, stamped = 0, noFile = 0;
246+
const stampsByUrl = new Map(); // url -> { source_uuid, org_slugs: [], content_uuid }
247+
for (const r of rows) {
248+
const stamp = stampsByUrl.get(r.source_url) ?? { org_slugs: [] };
249+
for (const slug of slugsFor(r.funder)) {
250+
if (!r.source_url) continue;
251+
const a = await req('organization.corpus.add.requested', { org_slug: slug, url: r.source_url, client: args.client });
252+
if (a.ok) {
253+
orgAdds += 1;
254+
stamp.org_slugs.push(slug);
255+
const cid = /u"([0-9a-f-]+)"/.exec(String(a.entry?.content_id ?? ''))?.[1];
256+
if (cid && !stamp.content_uuid) stamp.content_uuid = cid;
257+
} else { fails += 1; console.log(` ✗ corpus ${slug}${r.source_url.slice(0, 60)}: ${a.error}`); }
258+
}
259+
for (const s of r.strategy_slugs.split(/[|;,]/).map((x) => x.trim()).filter(Boolean)) {
260+
const sa = await req('source.add.requested', { url: r.source_url, domain_type: 'strategy', domain_slug: s, client_slug: args.client });
261+
if (sa.ok) {
262+
stratAdds += 1;
263+
if (sa.source?.source_uuid && !stamp.source_uuid) stamp.source_uuid = sa.source.source_uuid;
264+
} else { fails += 1; console.log(` ✗ source.add ${s}${r.source_url.slice(0, 60)}: ${sa.error}`); }
265+
}
266+
stampsByUrl.set(r.source_url, stamp);
267+
}
268+
for (const [url, s] of stampsByUrl) {
269+
const file = inboxByUrl.get(normUrl(url));
270+
if (!file) { noFile += 1; continue; }
271+
const ok = stampFrontmatter(file, {
272+
source_uuid: s.source_uuid,
273+
content_uuid: s.content_uuid,
274+
org_slugs: s.org_slugs.length ? [...new Set(s.org_slugs)].join(', ') : undefined,
275+
});
276+
if (ok) stamped += 1;
277+
}
278+
console.log(`\norg corpus adds: ${orgAdds} · strategy source adds: ${stratAdds} · failures: ${fails}`);
279+
console.log(`frontmatter stamped: ${stamped} inbox files · ${noFile} urls with no inbox capture (gated or never fetched)`);
280+
console.log('NOTE: DB registration + uuid stamps only. The disk merge into folders is the triage lane (and merged sources must NOT be re-fetched).');
281+
await nc.drain();

0 commit comments

Comments
 (0)