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:
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
83
apps/outreach-worker/src/sources/usaspending/client.test.ts
Normal file
83
apps/outreach-worker/src/sources/usaspending/client.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
230
apps/outreach-worker/src/workflows/judge-matches.ts
Normal file
230
apps/outreach-worker/src/workflows/judge-matches.ts
Normal 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());
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user