feat(scoring): v4 — peer precedent, mission-fit floor, LLM match judge

Fixes the federal mismatch class (boys' camp × NIH research center):

- Peer precedent: federalPrecedent paginates USASpending (≤500 awards/
  program) and name-matches every recipient against primary-ICP NH
  registry orgs (shared normalizeOrgNameForMatching, also used by the
  self-match gate). The 25-pt precedent tiers now key off
  program_state_peer_award_count — Dartmouth renewals and SBIR LLCs no
  longer grant precedent to community nonprofits. Raw count + peer-
  annotated award list stay as review evidence (peer badges, peers-first).
- Mission-fit floor (12/30, grants_gov only): below it a match is stored
  with fit_viable=false and hidden from the pending queue, hero selection,
  and easy-win. Foundation-synthesized grants exempt (generic synopses).
- Mission-fit judge live (judgeMatches, 06:15, 200/night best-first):
  JUDGE_MODEL reads the synopsis against the org profile with an explicit
  ignore-eligibility-breadth instruction; graded verdict with required
  citations; deterministic verdict→points map (27/18/8/0) sets missionFit,
  total, easy-win, and viability. Verdicts survive nightly re-scores via
  an upsert splice and re-enter the judge queue when the org profile is
  re-researched (org_profiles.updated_at).

First sweep: 81/149 programs have NH history, only 6 have peer history;
queue-head judging zeroes the research-mechanism garbage (mismatch) while
surfacing genuine strong fits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-17 10:52:50 -04:00
parent 7cefbbbfa1
commit cbc4512ffa
37 changed files with 4379 additions and 163 deletions

View File

@@ -50,6 +50,13 @@ const SUBSCORE_MAX: Record<string, number> = {
runway: 5,
};
const JUDGE_VERDICT_STYLE: Record<string, string> = {
strong_fit: 'bg-green-100 text-green-800',
plausible: 'bg-blue-100 text-blue-800',
weak: 'bg-amber-100 text-amber-800',
mismatch: 'bg-red-100 text-red-800',
};
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
const { detail } = loaderData ?? {};
if (detail == null) return null;
@@ -95,6 +102,12 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
{match.easyWin ? ' · Easy win' : ''}
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
</p>
{!match.fitViable && (
<p className="mt-1 text-sm font-medium text-red-700">
Hidden from the pending queue mission fit below floor or
judged non-viable.
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
<Form method="post">
@@ -289,6 +302,26 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
)}
</section>
{match.judgeVerdict != null && (
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">
Mission-fit judge{' '}
<span
className={`ml-1 rounded px-2 py-0.5 text-sm font-medium ${JUDGE_VERDICT_STYLE[match.judgeVerdict] ?? ''}`}
>
{match.judgeVerdict.replace('_', ' ')}
</span>
</h2>
<p className="whitespace-pre-wrap text-sm">{match.judgeRationale}</p>
{match.judgedAt != null && (
<p className="mt-2 text-xs text-gray-500">
Judged {new Date(match.judgedAt).toLocaleDateString('en-US')}
the verdict replaces the embedding mission-fit score above.
</p>
)}
</section>
)}
{applyInfo != null && (
<section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold">
@@ -346,7 +379,7 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
The concrete evidence behind the precedent score —{' '}
{grant.source === 'irs_990pf'
? 'who this funder actually paid, sorted in-state first.'
: 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'}
: 'who this federal program actually funded in NH recently, peers first. Only "peer" recipients (registered NH nonprofits our size) count toward the precedent score universities, hospital systems, and companies do not.'}
</p>
<table className="w-full text-sm">
<thead>
@@ -361,7 +394,14 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
<tbody>
{nhHistory.map((g, i) => (
<tr key={i} className="border-b align-top">
<td className="py-1 pr-2">{g.recipientName}</td>
<td className="py-1 pr-2">
{g.recipientName}
{g.isPeer === true && (
<span className="ml-1 rounded bg-green-100 px-1 text-xs font-medium text-green-800">
peer
</span>
)}
</td>
<td className="py-1 pr-2">{g.recipientCity ?? ''}</td>
<td className="py-1 pr-2 text-right tabular-nums">
{dollars(g.amount)}

View File

@@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
import { setJudgeMatchesDeps } from './workflows/judge-matches.js';
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
import { setMatchGrantsDeps } from './workflows/match-grants.js';
@@ -75,6 +76,7 @@ async function main() {
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
setJudgeMatchesDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -44,6 +44,10 @@ import {
runIngestNhdojOrgsNow,
setIngestNhdojOrgsDeps,
} from './workflows/ingest-nhdoj-orgs.js';
import {
runJudgeMatchesNow,
setJudgeMatchesDeps,
} from './workflows/judge-matches.js';
import {
runMatchGrantsNow,
setMatchGrantsDeps,
@@ -68,6 +72,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow,
profileOrgs: runProfileOrgsNow,
judgeMatches: runJudgeMatchesNow,
};
const FIRST_RUN_ORDER = [
@@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [
'embedGrants',
'matchGrants',
'profileOrgs',
'judgeMatches',
];
if (process.env.DATABASE_URL == null) {
@@ -120,6 +126,7 @@ async function main() {
setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db });
setJudgeMatchesDeps({ db });
DBOS.setConfig({
name: 'helmdocs-outreach-worker',

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { fetchProgramStateAwards } from './client.js';
function pageResponse(
names: string[],
total: number,
hasNext: boolean,
): Response {
return new Response(
JSON.stringify({
results: names.map((n) => ({
'Recipient Name': n,
'Award Amount': 1000.4,
'Start Date': '2024-01-01',
})),
page_metadata: { total, hasNext },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('fetchProgramStateAwards', () => {
it('paginates until hasNext is false and keeps every recipient', async () => {
const pages = [
pageResponse(Array.from({ length: 100 }, (_, i) => `ORG ${i}`), 130, true),
pageResponse(Array.from({ length: 30 }, (_, i) => `ORG ${100 + i}`), 130, false),
];
const bodies: number[] = [];
const fetchImpl: typeof fetch = async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { page: number };
bodies.push(body.page);
return pages[body.page - 1]!;
};
const result = await fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0 },
);
expect(bodies).toEqual([1, 2]);
expect(result.awardCount).toBe(130);
expect(result.samples).toHaveLength(130);
expect(result.samples[0]!.amount).toBe(1000);
expect(result.samples.at(-1)!.recipientName).toBe('ORG 129');
});
it('stops at the fetch cap even when more pages exist', async () => {
const fetchImpl: typeof fetch = async (_url, init) => {
const body = JSON.parse(String(init?.body)) as { page: number };
return pageResponse(
Array.from({ length: 100 }, (_, i) => `ORG ${(body.page - 1) * 100 + i}`),
900,
true,
);
};
const result = await fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0, maxAwardsFetched: 250 },
);
expect(result.samples).toHaveLength(300); // 3 full pages, cap crossed on page 3
expect(result.awardCount).toBe(900);
});
it('throws on a failed page', async () => {
const fetchImpl: typeof fetch = async () =>
new Response('nope', { status: 502, statusText: 'Bad Gateway' });
await expect(
fetchProgramStateAwards(
'93.847',
'NH',
{ startDate: '2023-01-01', endDate: '2026-01-01' },
{ fetchImpl, politenessDelayMs: 0 },
),
).rejects.toThrow(/502/);
});
});

View File

@@ -12,6 +12,15 @@ const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05'];
const DEFAULT_POLITENESS_DELAY_MS = 300;
const PAGE_SIZE = 100;
/**
* Recipient-list fetch cap. Peer classification runs over the fetched
* window, so a program with >500 NH awards gets its peer count computed
* on the first 500 — an undercount for the very largest programs, which
* is the conservative direction (see classifyPeerAwards in outreach-core).
*/
const MAX_AWARDS_FETCHED = 500;
export interface ProgramStateAwardSample {
readonly recipientName: string;
readonly amount: number | null;
@@ -20,24 +29,38 @@ export interface ProgramStateAwardSample {
export interface ProgramStateAwards {
readonly aln: string;
/** Total matching awards per the API (may exceed `samples.length`). */
readonly awardCount: number;
/** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */
readonly samples: ProgramStateAwardSample[];
}
export interface UsaSpendingClientOptions {
readonly fetchImpl?: typeof fetch;
readonly politenessDelayMs?: number;
/** Test hook; production uses MAX_AWARDS_FETCHED. */
readonly maxAwardsFetched?: number;
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface SpendingByAwardResponse {
results?: Array<{
'Recipient Name'?: string | null;
'Award Amount'?: number | null;
'Start Date'?: string | null;
}>;
page_metadata?: { total?: number | null; hasNext?: boolean | null };
}
/**
* Recent grant awards under one Assistance Listing to recipients in one
* state: total count plus a handful of sample recipients (review-page
* evidence — lets a human spot "one incumbent's renewals" vs "spread
* across orgs like ours").
* state: total count plus the full fetched recipient list. The list feeds
* two consumers — the review-page evidence table, and peer classification
* ("did orgs like ours win this, or just Dartmouth?"), which needs every
* recipient, hence pagination instead of the old single 10-row page.
*/
export async function fetchProgramStateAwards(
aln: string,
@@ -46,48 +69,52 @@ export async function fetchProgramStateAwards(
options: UsaSpendingClientOptions = {},
): Promise<ProgramStateAwards> {
const fetchImpl = options.fetchImpl ?? fetch;
const maxAwards = options.maxAwardsFetched ?? MAX_AWARDS_FETCHED;
const res = await fetchImpl(SEARCH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters: {
award_type_codes: GRANT_AWARD_TYPE_CODES,
recipient_locations: [{ country: 'USA', state }],
time_period: [{ start_date: startDate, end_date: endDate }],
program_numbers: [aln],
},
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: 10,
page: 1,
}),
});
if (!res.ok) {
throw new Error(
`USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`,
);
const samples: ProgramStateAwardSample[] = [];
let total = 0;
let page = 1;
for (;;) {
const res = await fetchImpl(SEARCH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filters: {
award_type_codes: GRANT_AWARD_TYPE_CODES,
recipient_locations: [{ country: 'USA', state }],
time_period: [{ start_date: startDate, end_date: endDate }],
program_numbers: [aln],
},
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: PAGE_SIZE,
page,
}),
});
if (!res.ok) {
throw new Error(
`USASpending search failed for ALN ${aln} (${state}) page ${page}: ${res.status} ${res.statusText}`,
);
}
const body = (await res.json()) as SpendingByAwardResponse;
const results = body.results ?? [];
for (const r of results) {
samples.push({
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null,
});
}
total = Math.max(body.page_metadata?.total ?? 0, samples.length);
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
const hasNext =
body.page_metadata?.hasNext ?? results.length === PAGE_SIZE;
if (!hasNext || results.length === 0 || samples.length >= maxAwards) break;
page++;
}
const body = (await res.json()) as {
results?: Array<{
'Recipient Name'?: string | null;
'Award Amount'?: number | null;
'Start Date'?: string | null;
}>;
page_metadata?: { total?: number | null };
};
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null,
}));
return {
aln,
awardCount: body.page_metadata?.total ?? samples.length,
samples,
};
return { aln, awardCount: total, samples };
}

View File

@@ -2,20 +2,28 @@
* Nightly federal-precedent workflow — USASpending award history per
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
*
* For each distinct ALN across open federal grants: count grant awards to
* recipients in the target state over the last ~3 fiscal years, stamp the
* count + sample recipients onto every open grant carrying that ALN.
* Match retrieval feeds the count into the same 25-point precedent
* subscore foundations use — "this program funded N NH orgs recently" and
* For each distinct ALN across open federal grants: fetch grant awards to
* recipients in the target state over the last ~3 fiscal years, classify
* each recipient as peer/non-peer against the primary-ICP NH registry
* (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not
* precedent for a community nonprofit), and stamp raw count, PEER count,
* and the annotated award list onto every open grant carrying that ALN.
* Match retrieval feeds the peer count into the same 25-point precedent
* subscore foundations use — "this program funded N orgs like ours" and
* "this foundation funded N NH orgs recently" are the same signal.
*
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core';
import {
buildPeerNameSet,
classifyPeerAwards,
type schema,
} from '@novelpad/outreach-core';
import {
serverListOpenFederalAlns,
serverListPeerOrgNames,
serverResetFederalPrecedent,
serverSetFederalPrecedent,
} from '@novelpad/outreach-core/server';
@@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, {
maxAttempts: 3,
});
async function listPeerNames(db: OutreachDb): Promise<string[]> {
return serverListPeerOrgNames(db);
}
const listPeerNamesStep = DBOS.registerStep(listPeerNames, {
name: 'listPeerOrgNames',
retriesAllowed: true,
maxAttempts: 3,
});
async function fetchAlnAwards(
aln: string,
startDate: string,
@@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
async function applyPrecedent(
db: OutreachDb,
precedent: ProgramStateAwards,
precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown },
): Promise<void> {
await serverSetFederalPrecedent(db, {
aln: precedent.aln,
awardCount: precedent.awardCount,
peerAwardCount: precedent.peerAwardCount,
samples: precedent.samples,
});
}
@@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise<void> {
return;
}
const peerNames = buildPeerNameSet(await listPeerNamesStep(db));
console.log(
`[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`,
);
const end = new Date();
const start = new Date(end);
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
@@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise<void> {
await resetPrecedentStep(db);
let programsWithHistory = 0;
let programsWithPeers = 0;
let failed = 0;
for (const aln of alns) {
try {
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate);
if (precedent.awardCount > 0) {
await applyPrecedentStep(db, precedent);
const fetched = await fetchAlnAwardsStep(aln, startDate, endDate);
if (fetched.awardCount > 0) {
// Pure classification over step outputs — deterministic on replay,
// so it doesn't need to be a step itself.
const { peerAwardCount, awards } = classifyPeerAwards(
fetched.samples,
peerNames,
);
await applyPrecedentStep(db, {
...fetched,
peerAwardCount,
samples: awards,
});
programsWithHistory++;
if (peerAwardCount > 0) programsWithPeers++;
}
} catch (err) {
failed++;
@@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise<void> {
}
console.log(
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`,
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} withPeerHistory=${programsWithPeers} failed=${failed}`,
);
if (failed > 0 && failed / alns.length > 0.2) {
throw new Error(

View File

@@ -0,0 +1,230 @@
/**
* Nightly mission-fit judge workflow — the LLM verification layer over
* the embedding-ranked federal queue.
*
* Embedding similarity + deterministic subscores get a match NEAR the
* truth; they cannot read an NIH synopsis and notice that "any nonprofit
* may apply" is hiding a research-center program no summer camp will ever
* run. The judge reads the actual synopsis against the org's (researched
* or stub) profile and issues a graded verdict; deterministic code maps
* verdict → mission-fit points, recomputes total/easy-win, and flips
* queue viability (see apply-match-judgment). Judged rows keep their
* verdict across nightly re-scores (see upsert-match-score) and re-enter
* this queue only when their org profile is re-researched.
*
* Cost bound: `JUDGE_MATCHES_PER_RUN` (default 200) × one JUDGE_MODEL
* call, best-scoring matches first — the head of the human review queue
* is always verified before a human reads it.
*
* Runs at 06:15, after matchGrants (05:15) and profileOrgs (05:45).
* Registration follows `ingest-grants.ts` exactly.
*/
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import {
missionFitFromVerdict,
runMatchJudge,
verdictIsFitViable,
type MatchJudgeVerdict,
} from '@novelpad/outreach-ai';
import type { schema } from '@novelpad/outreach-core';
import {
serverApplyMatchJudgment,
serverListMatchesNeedingJudgment,
type MatchNeedingJudgment,
} from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
export type OutreachDb = NodePgDatabase<typeof schema>;
const DEFAULT_MATCHES_PER_RUN = 200;
export interface JudgeMatchesDeps {
readonly db: OutreachDb;
}
let registeredDeps: JudgeMatchesDeps | null = null;
export function setJudgeMatchesDeps(deps: JudgeMatchesDeps): void {
registeredDeps = deps;
}
function getJudgeMatchesDeps(): JudgeMatchesDeps {
if (registeredDeps == null) {
throw new Error(
'JudgeMatchesDeps not registered. Call setJudgeMatchesDeps() before DBOS.launch().',
);
}
return registeredDeps;
}
/** Coerces the org_profiles.programs jsonb (Stage 4 shape) into judge input. */
export function programsFromProfileJson(
programs: unknown,
): Array<{ name: string; description: string | null; populationServed: string | null }> {
if (!Array.isArray(programs)) return [];
const out: Array<{
name: string;
description: string | null;
populationServed: string | null;
}> = [];
for (const entry of programs) {
if (entry == null || typeof entry !== 'object') continue;
const p = entry as Record<string, unknown>;
if (typeof p.name !== 'string' || p.name === '') continue;
out.push({
name: p.name,
description: typeof p.description === 'string' ? p.description : null,
populationServed:
typeof p.populationServed === 'string' ? p.populationServed : null,
});
}
return out;
}
async function listCandidates(
db: OutreachDb,
limit: number,
): Promise<MatchNeedingJudgment[]> {
return serverListMatchesNeedingJudgment(db, { limit });
}
const listCandidatesStep = DBOS.registerStep(listCandidates, {
name: 'listMatchesNeedingJudgment',
retriesAllowed: true,
maxAttempts: 3,
});
async function judgeOne(
candidate: MatchNeedingJudgment,
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
return runMatchJudge({
org: {
name: candidate.orgName,
city: candidate.orgCity,
missionStatement: candidate.missionStatement,
programs: programsFromProfileJson(candidate.programs),
serviceGeography: candidate.serviceGeography,
profileConfidence: candidate.profileConfidence,
},
grant: {
title: candidate.grantTitle,
funder: candidate.grantFunder,
synopsis: candidate.grantSynopsis,
},
});
}
const judgeOneStep = DBOS.registerStep(judgeOne, {
name: 'judgeMatch',
retriesAllowed: true,
maxAttempts: 2,
});
async function applyJudgment(
db: OutreachDb,
matchId: string,
judged: { verdict: MatchJudgeVerdict; model: string },
): Promise<void> {
await serverApplyMatchJudgment(db, {
matchId,
verdict: judged.verdict.verdict,
judgedMissionFit: missionFitFromVerdict(judged.verdict.verdict),
fitViable: verdictIsFitViable(judged.verdict.verdict),
rationale: [
judged.verdict.reasoning,
`Org evidence: ${judged.verdict.citedOrgEvidence}`,
`Grant evidence: ${judged.verdict.citedGrantEvidence}`,
].join('\n'),
model: judged.model,
});
}
const applyJudgmentStep = DBOS.registerStep(applyJudgment, {
name: 'applyMatchJudgment',
retriesAllowed: true,
maxAttempts: 3,
});
async function runJudgeMatches(): Promise<void> {
const { db } = getJudgeMatchesDeps();
const limit = Number(
process.env.JUDGE_MATCHES_PER_RUN ?? DEFAULT_MATCHES_PER_RUN,
);
const candidates = await listCandidatesStep(db, limit);
if (candidates.length === 0) {
console.log('[judge-matches] queue head fully judged');
return;
}
const byVerdict: Record<string, number> = {};
let failed = 0;
for (const candidate of candidates) {
try {
const judged = await judgeOneStep(candidate);
await applyJudgmentStep(db, candidate.matchId, judged);
byVerdict[judged.verdict.verdict] =
(byVerdict[judged.verdict.verdict] ?? 0) + 1;
} catch (err) {
failed++;
console.error(
`[judge-matches] "${candidate.orgName}" × "${candidate.grantTitle}" failed:`,
err,
);
}
}
console.log(
`[judge-matches] judged=${candidates.length - failed} failed=${failed} verdicts=${JSON.stringify(byVerdict)}`,
);
if (failed > 0 && failed / candidates.length > 0.2) {
throw new Error(
`[judge-matches] systemic failure: ${failed}/${candidates.length} matches failed`,
);
}
}
const g = globalThis as unknown as {
__outreachJudgeMatchesRegistered?: boolean;
__outreachJudgeMatchesHandle?: (
scheduledTime: Date,
startedAt: Date,
) => Promise<void>;
};
if (!g.__outreachJudgeMatchesRegistered) {
g.__outreachJudgeMatchesRegistered = true;
const judgeMatches = async (_scheduledTime: Date, _startedAt: Date) => {
try {
await runJudgeMatches();
} catch (err) {
console.error('[judge-matches] 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.__outreachJudgeMatchesHandle = DBOS.registerWorkflow(judgeMatches, {
name: 'judgeMatches',
});
DBOS.registerScheduled(judgeMatches, {
crontab: '15 6 * * *',
name: 'judgeMatches',
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 runJudgeMatchesNow(): Promise<void> {
const handle = g.__outreachJudgeMatchesHandle;
if (handle == null) {
throw new Error(
'judgeMatches is not registered; was this module imported before DBOS.launch()?',
);
}
return handle(new Date(), new Date());
}

View File

@@ -27,6 +27,7 @@ import { generateQueryEmbedding } from '@novelpad/outreach-ai';
import {
buildOrgMissionText,
evaluateHardGates,
normalizeOrgNameForMatching,
scoreMatch,
type HardGateFailureReason,
type schema,
@@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
maxAttempts: 3,
});
/** Case/punctuation/suffix-insensitive equality for self-match detection. */
function normalizeSelfMatchName(name: string): string {
return name
.toUpperCase()
.replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '')
.replace(/[^A-Z0-9]/g, '');
}
async function scoreAndStoreOrgMatches(
db: OutreachDb,
org: MatchCandidateOrg,
@@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches(
grant.funderEin === org.ein;
const isSelfByName =
grant.funderEin != null &&
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name);
normalizeOrgNameForMatching(grant.funder) ===
normalizeOrgNameForMatching(org.name);
if (isSelfByEin || isSelfByName) {
gated++;
continue;
@@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches(
applicationEffortEstimate: grant.applicationEffortEstimate,
closeDate: grant.closeDate,
now,
grantSource: grant.source,
});
await serverUpsertMatchScore(db, {
@@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches(
subscores: scored.subscores,
hardGatesPassed,
easyWin: scored.easyWin,
fitViable: scored.fitViable,
rationale: {
similarity: grant.similarity,
gateFailures: gates.failures,
ignoredGates: [...IGNORED_GATES],
scoredAt: now.toISOString(),
scoringVersion: 'v2-state-precedent',
scoringVersion: 'v3-peer-precedent',
},
});
stored++;