feat(profiler): Stage 4 grounded research profiler — cited profiles replace NTEE stubs

profileOrgs (nightly 05:45, 25/run, easy-win orgs best-first per the
plan's coarse-match-gates-profiling rule): two-pass Gemini flash —
Google Search-grounded research (groundingMetadata = citation universe)
then JSON-schema extraction citing only from it. Overwrites the stub
profile (mission/programs/geography/funders/staff/news/sources/
confidence); re-embeds mission fit from researched text unless identity
unconfirmed or confidence <0.5 (then the NTEE embedding stays —
unverified research must not steer scoring). Detail page renders
researched programs w/ source links, funders, staff.

Fixes: org_profiles.confidence is float4 — 0.2 stores as 0.20000000298
so 'confidence <= 0.2' excluded every stub (epsilon comparison); RR7
loader serialization turns unknown into never (hoisted casts).

Live: 55 orgs profiled (41 research-grade, 2 low-confidence, 0 failed).
Scoring effect verified both directions: Annie's Angels x NIH mammalian
models fit 19->12; Seacoast Pathways x Foundation for Seacoast Health
fit 25/30 — and the researched knownFunders independently names that
funder. 161 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-17 00:08:12 -04:00
parent 88affbeb4f
commit 4f35dcdb8d
14 changed files with 765 additions and 0 deletions

View File

@@ -63,6 +63,17 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
restrictionsOnAwards?: string;
} | null;
const subscores = (match.subscores ?? {}) as Record<string, number>;
const orgPrograms = (org.programs ?? null) as Array<{
name: string;
description?: string | null;
populationServed?: string | null;
sourceUrl?: string | null;
}> | null;
const orgFunders = (org.knownFunders ?? null) as Array<{ name: string }> | null;
const orgStaff = (org.staff ?? null) as Array<{
name: string;
role?: string | null;
}> | null;
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
const nhHistory = funderGivingHistory;
@@ -143,6 +154,43 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
</dd>
</div>
</dl>
{orgPrograms != null && orgPrograms.length > 0 && (
<div className="mt-3 text-sm">
<h3 className="font-medium">Programs (researched)</h3>
<ul className="ml-4 list-disc">
{orgPrograms.map(
(p, i) => (
<li key={i}>
{p.name}
{p.populationServed != null && ` — serving ${p.populationServed}`}
{p.sourceUrl != null && (
<>
{' '}
<a className="text-blue-700 underline" href={p.sourceUrl} target="_blank" rel="noreferrer">
src
</a>
</>
)}
</li>
),
)}
</ul>
</div>
)}
{orgFunders != null && orgFunders.length > 0 && (
<p className="mt-2 text-sm">
<span className="font-medium">Known funders: </span>
{orgFunders.map((f) => f.name).join(', ')}
</p>
)}
{orgStaff != null && orgStaff.length > 0 && (
<p className="mt-2 text-sm">
<span className="font-medium">Staff: </span>
{orgStaff
.map((s) => (s.role != null ? `${s.name} (${s.role})` : s.name))
.join(', ')}
</p>
)}
<div className="mt-3 space-x-3 text-sm">
{org.ein != null && (
<a

View File

@@ -38,6 +38,7 @@ import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
import { setMatchGrantsDeps } from './workflows/match-grants.js';
import { setProfileOrgsDeps } from './workflows/profile-orgs.js';
import { setIngestPndRssDeps } from './workflows/ingest-pnd-rss.js';
if (process.env.DATABASE_URL == null) {
@@ -73,6 +74,7 @@ async function main() {
setMatchGrantsDeps({ db });
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -48,6 +48,10 @@ import {
runMatchGrantsNow,
setMatchGrantsDeps,
} from './workflows/match-grants.js';
import {
runProfileOrgsNow,
setProfileOrgsDeps,
} from './workflows/profile-orgs.js';
import {
runIngestPndRssNow,
setIngestPndRssDeps,
@@ -63,6 +67,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
matchGrants: runMatchGrantsNow,
ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow,
profileOrgs: runProfileOrgsNow,
};
const FIRST_RUN_ORDER = [
@@ -75,6 +80,7 @@ const FIRST_RUN_ORDER = [
'federalPrecedent',
'embedGrants',
'matchGrants',
'profileOrgs',
];
if (process.env.DATABASE_URL == null) {
@@ -113,6 +119,7 @@ async function main() {
setMatchGrantsDeps({ db });
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -0,0 +1,208 @@
/**
* Nightly Stage 4 profiling workflow — grounded web research for orgs
* that already have a plausible lead (>=1 non-rejected easy-win match),
* best-scoring first, replacing their NTEE-stub profiles with cited,
* researched ones. Better profiles → better mission-fit embeddings on the
* NEXT match run → better personalization at review time.
*
* The nightly batch cap bounds LLM spend (two Flash calls + one embedding
* per org, ≈ cents each); env `PROFILE_ORGS_PER_RUN` overrides.
*
* Low-confidence results (identity not confirmed, or the model reports
* <= 0.4) are STILL stored — with their low confidence — so the review UI
* can warn, but they do NOT overwrite... actually they do overwrite the
* stub, which is correct: "we looked and couldn't confirm" is more
* information than an NTEE guess, and the confidence field carries it.
* The profile embedding, however, keeps mission-fit honest: identity-
* unconfirmed profiles are embedded from the stub text, not the
* possibly-wrong research.
*
* Runs at 05:45, after matchGrants (05:15). Registration follows
* `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import {
buildProfileEmbeddingText,
generateQueryEmbedding,
researchOrgProfile,
type ResearchedProfileResult,
} from '@novelpad/outreach-ai';
import { buildOrgMissionText, type schema } from '@novelpad/outreach-core';
import {
serverApplyResearchedProfile,
serverListOrgsNeedingProfile,
type OrgNeedingProfile,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
export type OutreachDb = NodePgDatabase<typeof schema>;
const DEFAULT_ORGS_PER_RUN = 25;
/** Below this, the research embedding is not trusted for mission fit. */
const EMBEDDING_TRUST_FLOOR = 0.5;
export interface ProfileOrgsDeps {
readonly db: OutreachDb;
}
let registeredDeps: ProfileOrgsDeps | null = null;
export function setProfileOrgsDeps(deps: ProfileOrgsDeps): void {
registeredDeps = deps;
}
function getProfileOrgsDeps(): ProfileOrgsDeps {
if (registeredDeps == null) {
throw new Error(
'ProfileOrgsDeps not registered. Call setProfileOrgsDeps() before DBOS.launch().',
);
}
return registeredDeps;
}
async function listOrgsNeedingProfile(
db: OutreachDb,
limit: number,
): Promise<OrgNeedingProfile[]> {
return serverListOrgsNeedingProfile(db, { limit });
}
const listOrgsNeedingProfileStep = DBOS.registerStep(listOrgsNeedingProfile, {
name: 'listOrgsNeedingProfile',
retriesAllowed: true,
maxAttempts: 3,
});
async function researchOrg(
org: OrgNeedingProfile,
): Promise<ResearchedProfileResult> {
return researchOrgProfile(org);
}
const researchOrgStep = DBOS.registerStep(researchOrg, {
name: 'researchOrgProfile',
retriesAllowed: true,
maxAttempts: 2,
});
async function storeProfile(
db: OutreachDb,
org: OrgNeedingProfile,
result: ResearchedProfileResult,
): Promise<void> {
const { profile, sources } = result;
const trustworthy =
profile.identityConfirmed &&
profile.overallConfidence >= EMBEDDING_TRUST_FLOOR;
const embeddingText = trustworthy
? buildProfileEmbeddingText(org, profile)
: buildOrgMissionText(org);
const embedding = await generateQueryEmbedding(embeddingText);
await serverApplyResearchedProfile(db, {
orgId: org.id,
missionStatement: profile.mission?.value ?? null,
programs: profile.programs,
serviceGeography: profile.serviceGeography?.value ?? null,
recentNews: profile.recentNews,
knownFunders: profile.knownFunders,
staff: profile.staff,
sources,
confidence: profile.identityConfirmed
? profile.overallConfidence
: Math.min(profile.overallConfidence, 0.3),
profileEmbedding: embedding,
});
}
const storeProfileStep = DBOS.registerStep(storeProfile, {
name: 'storeResearchedProfile',
retriesAllowed: true,
maxAttempts: 3,
});
async function runProfileOrgs(): Promise<void> {
const { db } = getProfileOrgsDeps();
const limit = Number(process.env.PROFILE_ORGS_PER_RUN ?? DEFAULT_ORGS_PER_RUN);
const orgs = await listOrgsNeedingProfileStep(db, limit);
if (orgs.length === 0) {
console.log('[profile-orgs] no easy-win orgs awaiting research');
return;
}
let profiled = 0;
let lowConfidence = 0;
let failed = 0;
for (const org of orgs) {
try {
const result = await researchOrgStep(org);
await storeProfileStep(db, org, result);
profiled++;
if (
!result.profile.identityConfirmed ||
result.profile.overallConfidence < EMBEDDING_TRUST_FLOOR
) {
lowConfidence++;
}
} catch (err) {
failed++;
console.error(`[profile-orgs] org "${org.name}" failed:`, err);
}
}
console.log(
`[profile-orgs] attempted=${orgs.length} profiled=${profiled} lowConfidence=${lowConfidence} failed=${failed}`,
);
if (failed > 0 && failed / orgs.length > 0.2) {
throw new Error(
`[profile-orgs] systemic failure: ${failed}/${orgs.length} orgs failed`,
);
}
}
const g = globalThis as unknown as {
__outreachProfileOrgsRegistered?: boolean;
__outreachProfileOrgsHandle?: (
scheduledTime: Date,
startedAt: Date,
) => Promise<void>;
};
if (!g.__outreachProfileOrgsRegistered) {
g.__outreachProfileOrgsRegistered = true;
const profileOrgs = async (_scheduledTime: Date, _startedAt: Date) => {
try {
await runProfileOrgs();
} catch (err) {
console.error('[profile-orgs] pass failed:', err);
throw err;
}
};
// Must be registered as BOTH a workflow and a scheduled function,
// referencing the same function object — see ingest-grants.ts.
g.__outreachProfileOrgsHandle = DBOS.registerWorkflow(profileOrgs, {
name: 'profileOrgs',
});
DBOS.registerScheduled(profileOrgs, {
crontab: '45 5 * * *',
name: 'profileOrgs',
mode: SchedulerMode.ExactlyOncePerInterval,
});
}
/**
* Starts one durable run of this workflow immediately through DBOS —
* the exact production path (workflow + checkpointed steps), used by
* `run-once.ts` for supervised/manual passes. Requires deps injected and
* `DBOS.launch()` completed.
*/
export function runProfileOrgsNow(): Promise<void> {
const handle = g.__outreachProfileOrgsHandle;
if (handle == null) {
throw new Error(
'profileOrgs is not registered; was this module imported before DBOS.launch()?',
);
}
return handle(new Date(), new Date());
}