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, 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) { export default function MatchDetail({ loaderData }: Route.ComponentProps) {
const { detail } = loaderData ?? {}; const { detail } = loaderData ?? {};
if (detail == null) return null; if (detail == null) return null;
@@ -95,6 +102,12 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
{match.easyWin ? ' · Easy win' : ''} {match.easyWin ? ' · Easy win' : ''}
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus} {match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
</p> </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>
<div className="flex shrink-0 gap-2"> <div className="flex shrink-0 gap-2">
<Form method="post"> <Form method="post">
@@ -289,6 +302,26 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
)} )}
</section> </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 && ( {applyInfo != null && (
<section className="rounded border p-4"> <section className="rounded border p-4">
<h2 className="mb-2 text-lg font-semibold"> <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 —{' '} The concrete evidence behind the precedent score —{' '}
{grant.source === 'irs_990pf' {grant.source === 'irs_990pf'
? 'who this funder actually paid, sorted in-state first.' ? '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> </p>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -361,7 +394,14 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
<tbody> <tbody>
{nhHistory.map((g, i) => ( {nhHistory.map((g, i) => (
<tr key={i} className="border-b align-top"> <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">{g.recipientCity ?? ''}</td>
<td className="py-1 pr-2 text-right tabular-nums"> <td className="py-1 pr-2 text-right tabular-nums">
{dollars(g.amount)} {dollars(g.amount)}

View File

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

View File

@@ -44,6 +44,10 @@ import {
runIngestNhdojOrgsNow, runIngestNhdojOrgsNow,
setIngestNhdojOrgsDeps, setIngestNhdojOrgsDeps,
} from './workflows/ingest-nhdoj-orgs.js'; } from './workflows/ingest-nhdoj-orgs.js';
import {
runJudgeMatchesNow,
setJudgeMatchesDeps,
} from './workflows/judge-matches.js';
import { import {
runMatchGrantsNow, runMatchGrantsNow,
setMatchGrantsDeps, setMatchGrantsDeps,
@@ -68,6 +72,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
ingest990pf: runIngest990PfNow, ingest990pf: runIngest990PfNow,
federalPrecedent: runFederalPrecedentNow, federalPrecedent: runFederalPrecedentNow,
profileOrgs: runProfileOrgsNow, profileOrgs: runProfileOrgsNow,
judgeMatches: runJudgeMatchesNow,
}; };
const FIRST_RUN_ORDER = [ const FIRST_RUN_ORDER = [
@@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [
'embedGrants', 'embedGrants',
'matchGrants', 'matchGrants',
'profileOrgs', 'profileOrgs',
'judgeMatches',
]; ];
if (process.env.DATABASE_URL == null) { if (process.env.DATABASE_URL == null) {
@@ -120,6 +126,7 @@ async function main() {
setIngest990pfDeps({ db }); setIngest990pfDeps({ db });
setFederalPrecedentDeps({ db }); setFederalPrecedentDeps({ db });
setProfileOrgsDeps({ db }); setProfileOrgsDeps({ db });
setJudgeMatchesDeps({ db });
DBOS.setConfig({ DBOS.setConfig({
name: 'helmdocs-outreach-worker', 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 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 { export interface ProgramStateAwardSample {
readonly recipientName: string; readonly recipientName: string;
readonly amount: number | null; readonly amount: number | null;
@@ -20,24 +29,38 @@ export interface ProgramStateAwardSample {
export interface ProgramStateAwards { export interface ProgramStateAwards {
readonly aln: string; readonly aln: string;
/** Total matching awards per the API (may exceed `samples.length`). */
readonly awardCount: number; readonly awardCount: number;
/** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */
readonly samples: ProgramStateAwardSample[]; readonly samples: ProgramStateAwardSample[];
} }
export interface UsaSpendingClientOptions { export interface UsaSpendingClientOptions {
readonly fetchImpl?: typeof fetch; readonly fetchImpl?: typeof fetch;
readonly politenessDelayMs?: number; readonly politenessDelayMs?: number;
/** Test hook; production uses MAX_AWARDS_FETCHED. */
readonly maxAwardsFetched?: number;
} }
function wait(ms: number): Promise<void> { function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); 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 * Recent grant awards under one Assistance Listing to recipients in one
* state: total count plus a handful of sample recipients (review-page * state: total count plus the full fetched recipient list. The list feeds
* evidence — lets a human spot "one incumbent's renewals" vs "spread * two consumers — the review-page evidence table, and peer classification
* across orgs like ours"). * ("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( export async function fetchProgramStateAwards(
aln: string, aln: string,
@@ -46,7 +69,13 @@ export async function fetchProgramStateAwards(
options: UsaSpendingClientOptions = {}, options: UsaSpendingClientOptions = {},
): Promise<ProgramStateAwards> { ): Promise<ProgramStateAwards> {
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const maxAwards = options.maxAwardsFetched ?? MAX_AWARDS_FETCHED;
const samples: ProgramStateAwardSample[] = [];
let total = 0;
let page = 1;
for (;;) {
const res = await fetchImpl(SEARCH_URL, { const res = await fetchImpl(SEARCH_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -58,36 +87,34 @@ export async function fetchProgramStateAwards(
program_numbers: [aln], program_numbers: [aln],
}, },
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'], fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
limit: 10, limit: PAGE_SIZE,
page: 1, page,
}), }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(
`USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`, `USASpending search failed for ALN ${aln} (${state}) page ${page}: ${res.status} ${res.statusText}`,
); );
} }
const body = (await res.json()) as { const body = (await res.json()) as SpendingByAwardResponse;
results?: Array<{ const results = body.results ?? [];
'Recipient Name'?: string | null; for (const r of results) {
'Award Amount'?: number | null; samples.push({
'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', recipientName: r['Recipient Name'] ?? 'Unknown recipient',
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']), amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
startDate: r['Start Date'] ?? null, startDate: r['Start Date'] ?? null,
})); });
}
total = Math.max(body.page_metadata?.total ?? 0, samples.length);
return { await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
aln,
awardCount: body.page_metadata?.total ?? samples.length, const hasNext =
samples, body.page_metadata?.hasNext ?? results.length === PAGE_SIZE;
}; if (!hasNext || results.length === 0 || samples.length >= maxAwards) break;
page++;
}
return { aln, awardCount: total, samples };
} }

View File

@@ -2,20 +2,28 @@
* Nightly federal-precedent workflow — USASpending award history per * Nightly federal-precedent workflow — USASpending award history per
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index. * Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
* *
* For each distinct ALN across open federal grants: count grant awards to * For each distinct ALN across open federal grants: fetch grant awards to
* recipients in the target state over the last ~3 fiscal years, stamp the * recipients in the target state over the last ~3 fiscal years, classify
* count + sample recipients onto every open grant carrying that ALN. * each recipient as peer/non-peer against the primary-ICP NH registry
* Match retrieval feeds the count into the same 25-point precedent * (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not
* subscore foundations use — "this program funded N NH orgs recently" and * 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. * "this foundation funded N NH orgs recently" are the same signal.
* *
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before * Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
* matching (05:15). Registration follows `ingest-grants.ts` exactly. * matching (05:15). Registration follows `ingest-grants.ts` exactly.
*/ */
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk'; 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 { import {
serverListOpenFederalAlns, serverListOpenFederalAlns,
serverListPeerOrgNames,
serverResetFederalPrecedent, serverResetFederalPrecedent,
serverSetFederalPrecedent, serverSetFederalPrecedent,
} from '@novelpad/outreach-core/server'; } from '@novelpad/outreach-core/server';
@@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, {
maxAttempts: 3, 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( async function fetchAlnAwards(
aln: string, aln: string,
startDate: string, startDate: string,
@@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
async function applyPrecedent( async function applyPrecedent(
db: OutreachDb, db: OutreachDb,
precedent: ProgramStateAwards, precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown },
): Promise<void> { ): Promise<void> {
await serverSetFederalPrecedent(db, { await serverSetFederalPrecedent(db, {
aln: precedent.aln, aln: precedent.aln,
awardCount: precedent.awardCount, awardCount: precedent.awardCount,
peerAwardCount: precedent.peerAwardCount,
samples: precedent.samples, samples: precedent.samples,
}); });
} }
@@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise<void> {
return; return;
} }
const peerNames = buildPeerNameSet(await listPeerNamesStep(db));
console.log(
`[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`,
);
const end = new Date(); const end = new Date();
const start = new Date(end); const start = new Date(end);
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS); start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
@@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise<void> {
await resetPrecedentStep(db); await resetPrecedentStep(db);
let programsWithHistory = 0; let programsWithHistory = 0;
let programsWithPeers = 0;
let failed = 0; let failed = 0;
for (const aln of alns) { for (const aln of alns) {
try { try {
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate); const fetched = await fetchAlnAwardsStep(aln, startDate, endDate);
if (precedent.awardCount > 0) { if (fetched.awardCount > 0) {
await applyPrecedentStep(db, precedent); // 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++; programsWithHistory++;
if (peerAwardCount > 0) programsWithPeers++;
} }
} catch (err) { } catch (err) {
failed++; failed++;
@@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise<void> {
} }
console.log( 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) { if (failed > 0 && failed / alns.length > 0.2) {
throw new Error( 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 { import {
buildOrgMissionText, buildOrgMissionText,
evaluateHardGates, evaluateHardGates,
normalizeOrgNameForMatching,
scoreMatch, scoreMatch,
type HardGateFailureReason, type HardGateFailureReason,
type schema, type schema,
@@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
maxAttempts: 3, 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( async function scoreAndStoreOrgMatches(
db: OutreachDb, db: OutreachDb,
org: MatchCandidateOrg, org: MatchCandidateOrg,
@@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches(
grant.funderEin === org.ein; grant.funderEin === org.ein;
const isSelfByName = const isSelfByName =
grant.funderEin != null && grant.funderEin != null &&
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name); normalizeOrgNameForMatching(grant.funder) ===
normalizeOrgNameForMatching(org.name);
if (isSelfByEin || isSelfByName) { if (isSelfByEin || isSelfByName) {
gated++; gated++;
continue; continue;
@@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches(
applicationEffortEstimate: grant.applicationEffortEstimate, applicationEffortEstimate: grant.applicationEffortEstimate,
closeDate: grant.closeDate, closeDate: grant.closeDate,
now, now,
grantSource: grant.source,
}); });
await serverUpsertMatchScore(db, { await serverUpsertMatchScore(db, {
@@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches(
subscores: scored.subscores, subscores: scored.subscores,
hardGatesPassed, hardGatesPassed,
easyWin: scored.easyWin, easyWin: scored.easyWin,
fitViable: scored.fitViable,
rationale: { rationale: {
similarity: grant.similarity, similarity: grant.similarity,
gateFailures: gates.failures, gateFailures: gates.failures,
ignoredGates: [...IGNORED_GATES], ignoredGates: [...IGNORED_GATES],
scoredAt: now.toISOString(), scoredAt: now.toISOString(),
scoringVersion: 'v2-state-precedent', scoringVersion: 'v3-peer-precedent',
}, },
}); });
stored++; stored++;

View File

@@ -29,3 +29,11 @@ Funder precedent now covers federal grants at the **program (ALN/CFDA) level**
Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs. Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs.
First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline). First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline).
## v4 (2026-07-17): peer precedent + mission-fit floor + LLM match judge
The v3 sweep exposed two structural failures: ALN-level counts were **recipient-blind** (NORC P30 carried 25/25 precedent because ten NH awards existed — all to Dartmouth entities and two biotech LLCs), and **mission fit couldn't veto** (non-mission subscores sum to 50, so a boys' camp scored 60+ on NIH research-center grants). Three fixes, `scoringVersion: v3-peer-precedent`:
1. **Peer precedent**`federalPrecedent` now paginates USASpending (up to 500 awards/program, not a 10-row sample) and classifies every recipient against the primary-ICP NH registry by normalized name (`classifyPeerAwards` + `normalizeOrgNameForMatching`, the same normalizer the self-match gate uses; LLC/LTD deliberately not stripped). The 25-point precedent tiers key off `program_state_peer_award_count` — awards won by orgs shaped like our candidates — while the raw count and the peer-annotated award list stay on the grant as review evidence (peers get a green badge, sorted first). Conservative by construction: unmatched/unenriched recipients count as non-peer; missed precedent demotes a real match rather than pitching a false one.
2. **Mission-fit floor** (`MISSION_FIT_VIABLE_MIN = 12/30`, federal sources only) — below it a match is stored (`fit_viable = false`) but hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants are exempt: their synopses are generic by construction, so embedding fit carries no signal and precedent evidence is the case for the match.
3. **Mission-fit judge** (`judgeMatches`, 06:15, `JUDGE_MATCHES_PER_RUN` default 200) — JUDGE_MODEL reads the actual grant synopsis against the org's (researched or stub) profile, with the explicit instruction to **ignore eligibility breadth** and judge what the program funds and who realistically performs that work (NIH R/P/U mechanisms fit only research performers). Graded verdict with required citations from both sides; deterministic code maps verdict → mission-fit points (strong_fit 27 / plausible 18 / weak 8 / mismatch 0), recomputes total + easy-win, and sets `fit_viable` (weak/mismatch → hidden). Judged rows keep their verdict across nightly re-scores (the upsert splices `judge_mission_fit` back in) and re-enter the judge queue only when the org profile is re-researched (`org_profiles.updated_at > judged_at`). Best-scoring first, so the head of the human queue is always LLM-verified before a human reads it. The detail page shows the verdict, rationale, and citations.

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { buildMatchJudgePrompt } from './run.js';
import {
MatchJudgeVerdictSchema,
missionFitFromVerdict,
verdictIsFitViable,
} from './schema.js';
describe('missionFitFromVerdict', () => {
it('maps verdicts deterministically, capped under the embedding max', () => {
expect(missionFitFromVerdict('strong_fit')).toBe(27);
expect(missionFitFromVerdict('plausible')).toBe(18);
expect(missionFitFromVerdict('weak')).toBe(8);
expect(missionFitFromVerdict('mismatch')).toBe(0);
});
it('keeps only strong/plausible queue-viable', () => {
expect(verdictIsFitViable('strong_fit')).toBe(true);
expect(verdictIsFitViable('plausible')).toBe(true);
expect(verdictIsFitViable('weak')).toBe(false);
expect(verdictIsFitViable('mismatch')).toBe(false);
});
});
describe('MatchJudgeVerdictSchema', () => {
it('requires grounding citations', () => {
expect(() =>
MatchJudgeVerdictSchema.parse({
verdict: 'mismatch',
citedOrgEvidence: '',
citedGrantEvidence: 'x',
reasoning: 'y',
}),
).toThrow();
});
});
describe('buildMatchJudgePrompt', () => {
const base = {
org: {
name: 'Camp Tecumseh',
city: 'Moultonborough',
missionStatement: 'Residential summer camp for boys',
programs: [
{ name: 'Summer camp', description: 'sports and outdoors', populationServed: 'boys 8-15' },
],
serviceGeography: 'Lakes Region NH',
profileConfidence: 0.8,
},
grant: {
title: 'Nutrition Obesity Research Centers (NORCs)',
funder: 'NIH',
synopsis: 'Supports research center infrastructure...',
},
};
it('instructs the judge to ignore eligibility breadth', () => {
const prompt = buildMatchJudgePrompt(base);
expect(prompt).toContain('IGNORE eligibility breadth');
expect(prompt).toContain('Camp Tecumseh');
expect(prompt).toContain('Nutrition Obesity Research Centers');
expect(prompt).toContain('- Summer camp — sports and outdoors — serves boys 8-15');
});
it('flags low-confidence stub profiles', () => {
const prompt = buildMatchJudgePrompt({
...base,
org: { ...base.org, programs: [], profileConfidence: 0.2 },
});
expect(prompt).toContain('low-confidence');
expect(prompt).toContain('no researched program list');
});
});

View File

@@ -1,64 +1,136 @@
import { MissionFitVerdictSchema, type MissionFitVerdict } from './schema.js'; import { getAi } from '../../gemini.js';
import type { OrgProfile } from '../org-profiler/schema.js'; import { JUDGE_MODEL } from '../../models.js';
import {
MATCH_JUDGE_VERDICT_JSON_SCHEMA,
MatchJudgeVerdictSchema,
type MatchJudgeVerdict,
} from './schema.js';
export interface RunMissionFitJudgeInput { export interface JudgeOrgSide {
/** Extracted profile of the candidate org (from the Org Profiler). */ readonly name: string;
orgProfile: OrgProfile; readonly city: string | null;
/** Grant program name / title, as scored against by the deterministic SQL gates. */ /** Researched or NTEE-stub mission text (whatever the profile holds). */
grantProgramName: string; readonly missionStatement: string | null;
/** Funding priorities / eligible-use language pulled from the grant's own source text. */ /** Researched programs, if the Stage 4 profiler has run for this org. */
grantPriorities: string[]; readonly programs: Array<{
name: string;
description: string | null;
populationServed: string | null;
}>;
readonly serviceGeography: string | null;
/** Profile confidence — low means the org side is mostly an NTEE guess. */
readonly profileConfidence: number | null;
} }
export interface JudgeGrantSide {
readonly title: string;
readonly funder: string;
readonly synopsis: string | null;
}
export interface RunMatchJudgeInput {
readonly org: JudgeOrgSide;
readonly grant: JudgeGrantSide;
}
const MAX_SYNOPSIS_CHARS = 12_000;
/** /**
* Build the judge prompt for one (org, grant) match candidate. Kept separate * Build the judge prompt for one (org, grant) candidate. Kept separate
* from `runMissionFitJudge` so it's independently unit-testable once wired up. * from `runMatchJudge` so it's independently unit-testable.
*
* The core instruction exists because of a concrete failure mode:
* federal (especially NIH) synopses declare near-universal *eligibility*
* while the funded work is highly specific — "nonprofits may apply" put a
* boys' summer camp on an obesity-research center grant. Eligibility
* breadth is therefore explicitly out of scope; the judge reads what the
* program FUNDS and who realistically performs that work.
*/ */
export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string { export function buildMatchJudgePrompt(input: RunMatchJudgeInput): string {
const { org, grant } = input;
const programLines =
org.programs.length > 0
? org.programs
.map((p) =>
[
`- ${p.name}`,
p.description,
p.populationServed == null ? null : `serves ${p.populationServed}`,
]
.filter(Boolean)
.join(' — '),
)
.join('\n')
: '(no researched program list — judge from the mission text alone)';
return [ return [
'You are the final mission-fit judge for a candidate (org, grant) match', 'You judge whether a specific nonprofit is a credible fit for a specific',
'that has already passed deterministic SQL hard gates (eligibility,', 'grant program. The pair already passed automated eligibility, geography,',
'geography, award range). Decide whether the org\'s actual programs', 'and deadline gates; your ONLY question is programmatic mission fit:',
'plausibly fit the grant\'s funding priorities. You MUST cite one', 'does the work this org actually does match what this grant actually funds?',
'concrete org program and one concrete grant priority your verdict is',
'grounded in — a verdict without both citations is invalid. A wrong',
'"fit: true" here can put a real NH nonprofit in front of a funder that',
'will never fund them, so when the fit is unclear, prefer `fit: false`.',
'', '',
`Grant program: ${input.grantProgramName}`, 'Rules:',
`Grant priorities: ${input.grantPriorities.join('; ')}`, '- IGNORE eligibility breadth entirely. Federal synopses often say any',
`Org legal name: ${input.orgProfile.legalName.value}`, ' nonprofit may apply while the funded activity is narrow, technical, or',
`Org mission: ${input.orgProfile.mission.value}`, ' institutional (research centers, clinical trials, training programs at',
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`, ' universities). Judge what gets FUNDED and who realistically performs',
' that work, not who is allowed to apply.',
'- A research-mechanism grant (center grants, clinical trials, R-series/',
' P-series/U-series NIH mechanisms) fits only orgs that conduct that kind',
' of research.',
'- Ground the verdict in one concrete org activity and one concrete piece',
' of synopsis language; a verdict without both citations is invalid.',
"- A wrong positive verdict wastes a real fundraiser's time and burns our",
' credibility with them. When genuinely torn between two verdicts, pick',
' the lower one.',
'',
'Verdicts:',
"- strong_fit: the org's core work is squarely what the program funds.",
'- plausible: real overlap; a competent grant writer could make the case.',
'- weak: tangential overlap only; the org would be an outlier applicant.',
'- mismatch: the org does not do what this program funds.',
'',
'--- GRANT ---',
`Title: ${grant.title}`,
`Funder: ${grant.funder}`,
`Synopsis: ${(grant.synopsis ?? '(none)').slice(0, MAX_SYNOPSIS_CHARS)}`,
'',
'--- ORGANIZATION ---',
`Name: ${org.name}`,
`Location: ${org.city ?? 'unknown'}, NH`,
`Mission: ${org.missionStatement ?? '(unknown)'}`,
`Service area: ${org.serviceGeography ?? '(unknown)'}`,
'Programs:',
programLines,
...(org.profileConfidence != null && org.profileConfidence < 0.5
? [
'',
'NOTE: this org profile is low-confidence (category-derived, not',
'researched). Judge from the mission category; do not invent programs.',
]
: []),
].join('\n'); ].join('\n');
} }
/** /**
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a * One structured judge call. JUDGE_MODEL per the tiering rule in
* human reviewer (and, downstream, a real prospect via Apollo), so it should * models.ts — this verdict gates matches into/out of the review queue.
* not go live against real matches until the deterministic hard-gate scoring * `MATCH_JUDGE_MODEL` env overrides for cheap bulk experiments.
* this package doesn't own is wired in ahead of it. Intended production call
* shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`,
* not `BULK_MODEL`: a wrong verdict here reaches a prospect:
*
* import { getAi } from '../../gemini.js';
* import { JUDGE_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: JUDGE_MODEL,
* contents: buildMissionFitJudgePrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema
* // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return MissionFitVerdictSchema.parse(raw);
*/ */
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> { export async function runMatchJudge(
throw new Error('not implemented'); input: RunMatchJudgeInput,
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
const model = process.env.MATCH_JUDGE_MODEL ?? JUDGE_MODEL;
const result = await getAi().models.generateContent({
model,
contents: buildMatchJudgePrompt(input),
config: {
responseMimeType: 'application/json',
responseJsonSchema: MATCH_JUDGE_VERDICT_JSON_SCHEMA,
temperature: 0.1,
},
});
const raw: unknown = JSON.parse(result.text ?? '{}');
return { verdict: MatchJudgeVerdictSchema.parse(raw), model };
} }

View File

@@ -1,20 +1,68 @@
import { z } from 'zod'; import { z } from 'zod';
/** /**
* Mission-fit judge verdict for one (org, grant) match candidate. This is * Graded mission-fit verdict for one (org, grant) match candidate that
* the last human-facing gate before a match is surfaced for review — the * already passed the deterministic hard gates and subscore ranking.
* judge must ground its verdict in something concrete from each side rather *
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not * The judge names a verdict; deterministic code assigns the number
* optional summary fields. * (missionFitFromVerdict) and decides queue viability
* (verdictIsFitViable) — the LLM never emits a score directly, so the
* mapping can be re-tuned from review data without re-judging anything.
*
* Citations are required, not optional summary fields: a verdict must be
* grounded in something concrete from each side rather than a vibe.
*/ */
export const MissionFitVerdictSchema = z.object({ export const MatchJudgeVerdictSchema = z.object({
/** Whether the org's mission plausibly fits the grant's funding priorities. */ verdict: z.enum(['strong_fit', 'plausible', 'weak', 'mismatch']),
fit: z.boolean(), /** The specific org program/activity the verdict is grounded in. */
/** The specific org program/activity the verdict is grounded in (from the org profile). */ citedOrgEvidence: z.string().min(1),
citedOrgProgram: z.string().min(1), /** The specific synopsis language (purpose/priorities) the verdict is grounded in. */
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */ citedGrantEvidence: z.string().min(1),
citedGrantPriority: z.string().min(1), /** 13 sentence justification tying the two citations together. */
/** Short human-readable justification tying the two citations together. */
reasoning: z.string().min(1), reasoning: z.string().min(1),
}); });
export type MissionFitVerdict = z.infer<typeof MissionFitVerdictSchema>; export type MatchJudgeVerdict = z.infer<typeof MatchJudgeVerdictSchema>;
/** Plain JSON Schema mirror of MatchJudgeVerdictSchema for `responseJsonSchema`. */
export const MATCH_JUDGE_VERDICT_JSON_SCHEMA = {
type: 'object',
properties: {
verdict: {
type: 'string',
enum: ['strong_fit', 'plausible', 'weak', 'mismatch'],
},
citedOrgEvidence: { type: 'string', minLength: 1 },
citedGrantEvidence: { type: 'string', minLength: 1 },
reasoning: { type: 'string', minLength: 1 },
},
required: ['verdict', 'citedOrgEvidence', 'citedGrantEvidence', 'reasoning'],
additionalProperties: false,
} as const;
/**
* Deterministic verdict → mission-fit subscore (030 scale, replacing the
* embedding-band value on judged matches). `strong_fit` lands just under
* the embedding maximum — a judge can rescue a good match the embeddings
* missed, but only corroborated similarity reaches 30.
*/
export function missionFitFromVerdict(
verdict: MatchJudgeVerdict['verdict'],
): number {
switch (verdict) {
case 'strong_fit':
return 27;
case 'plausible':
return 18;
case 'weak':
return 8;
case 'mismatch':
return 0;
}
}
/** Whether a judged match stays visible in the pending review queue. */
export function verdictIsFitViable(
verdict: MatchJudgeVerdict['verdict'],
): boolean {
return verdict === 'strong_fit' || verdict === 'plausible';
}

View File

@@ -0,0 +1,8 @@
CREATE TYPE "public"."match_judge_verdict" AS ENUM('strong_fit', 'plausible', 'weak', 'mismatch');--> statement-breakpoint
ALTER TABLE "grants" ADD COLUMN "program_state_peer_award_count" integer;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "fit_viable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_verdict" "match_judge_verdict";--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_mission_fit" integer;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_rationale" text;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judged_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "matches" ADD COLUMN "judge_model" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "org_profiles" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,6 +36,20 @@
"when": 1784257140142, "when": 1784257140142,
"tag": "1784257140_federal-precedent", "tag": "1784257140_federal-precedent",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1784295305749,
"tag": "1784295305_match-judge-and-peer-precedent",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1784295739948,
"tag": "1784295739_org-profile-updated-at",
"breakpoints": true
} }
] ]
} }

View File

@@ -81,6 +81,13 @@ export const matchReviewStatusEnum = pgEnum('match_review_status', [
'edited', 'edited',
]); ]);
export const matchJudgeVerdictEnum = pgEnum('match_judge_verdict', [
'strong_fit',
'plausible',
'weak',
'mismatch',
]);
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [ export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
'wrong_eligibility', 'wrong_eligibility',
'wrong_geography', 'wrong_geography',
@@ -151,7 +158,14 @@ export const grants = pgTable(
// refreshed by the federalPrecedent workflow. Null for non-federal // refreshed by the federalPrecedent workflow. Null for non-federal
// sources and never touched by ingest upserts. // sources and never touched by ingest upserts.
programStateAwardCount: integer('program_state_award_count'), programStateAwardCount: integer('program_state_award_count'),
// Sample recipients backing the count — review-page evidence. // Awards (within the fetched window) whose recipient name-matches a
// registered NH nonprofit in our primary ICP band — "orgs like ours
// win this program", the count the precedent subscore actually uses.
// Dartmouth/UNH/hospital systems/LLCs inflate the raw count above but
// never this one.
programStatePeerAwardCount: integer('program_state_peer_award_count'),
// Sample recipients backing the count — review-page evidence. Each
// entry carries `isPeer` since the peer-precedent pass.
programStateAwards: jsonb('program_state_awards'), programStateAwards: jsonb('program_state_awards'),
status: grantStatusEnum('status').notNull().default('open'), status: grantStatusEnum('status').notNull().default('open'),
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }), synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
@@ -240,6 +254,9 @@ export const orgProfiles = pgTable(
confidence: real('confidence'), confidence: real('confidence'),
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }), profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
// Bumped whenever the profile is re-researched — the match judge
// re-judges matches whose judged_at predates this.
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
}, },
(t) => [ (t) => [
// Latest-profile semantics: one row per org, refreshed in place (the // Latest-profile semantics: one row per org, refreshed in place (the
@@ -304,6 +321,20 @@ export const matches = pgTable(
easyWin: boolean('easy_win').notNull().default(false), easyWin: boolean('easy_win').notNull().default(false),
// LLM-produced citations backing the score/subscores. // LLM-produced citations backing the score/subscores.
rationale: jsonb('rationale'), rationale: jsonb('rationale'),
// Embedding fit can't veto (non-mission subscores sum to 50); this
// flag can: false = below the source-aware mission-fit floor or judged
// 'weak'/'mismatch', and the pending queue hides the row. Approved/
// rejected views ignore it (a human decision always displays).
fitViable: boolean('fit_viable').notNull().default(true),
// Mission-fit judge (LLM over grant synopsis × researched org profile;
// deterministic verdict→score mapping — the judge names the verdict,
// code assigns the number). Preserved across nightly re-scores;
// invalidated when the org profile is re-researched after judged_at.
judgeVerdict: matchJudgeVerdictEnum('judge_verdict'),
judgeMissionFit: integer('judge_mission_fit'),
judgeRationale: text('judge_rationale'),
judgedAt: timestamp('judged_at', { withTimezone: true }),
judgeModel: text('judge_model'),
reviewStatus: matchReviewStatusEnum('review_status') reviewStatus: matchReviewStatusEnum('review_status')
.notNull() .notNull()
.default('pending'), .default('pending'),

View File

@@ -5,15 +5,20 @@ import { schema } from '#~/db/db.js';
export interface AlnPrecedent { export interface AlnPrecedent {
readonly aln: string; readonly aln: string;
/** Raw NH award count for the program (context/evidence only). */
readonly awardCount: number; readonly awardCount: number;
/** Awards won by ICP-peer nonprofits — drives the precedent subscore. */
readonly peerAwardCount: number;
/** Peer-annotated award list (see classifyPeerAwards). */
readonly samples: unknown; readonly samples: unknown;
} }
/** /**
* Applies per-program USASpending precedent onto every open federal grant * Applies per-program USASpending precedent onto every open federal grant
* carrying that ALN. Grants with multiple ALNs keep the HIGHEST count * carrying that ALN. Grants with multiple ALNs keep the strongest program
* seen (a grant reachable through any strongly-NH program inherits that * seen, where "strongest" is the PEER count — ten Dartmouth renewals must
* program's precedent), and samples follow whichever count won. * not outrank three community-nonprofit wins — with raw count and samples
* following whichever peer count won.
*/ */
export async function serverSetFederalPrecedent( export async function serverSetFederalPrecedent(
db: NpOutreachDatabase | NpOutreachTransaction, db: NpOutreachDatabase | NpOutreachTransaction,
@@ -22,9 +27,14 @@ export async function serverSetFederalPrecedent(
await db await db
.update(schema.grants) .update(schema.grants)
.set({ .set({
programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`, programStatePeerAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStatePeerAwardCount}, 0), ${precedent.peerAwardCount})`,
programStateAwardCount: sql`CASE
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
THEN ${precedent.awardCount}
ELSE ${schema.grants.programStateAwardCount}
END`,
programStateAwards: sql`CASE programStateAwards: sql`CASE
WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount} WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
THEN ${JSON.stringify(precedent.samples)}::jsonb THEN ${JSON.stringify(precedent.samples)}::jsonb
ELSE ${schema.grants.programStateAwards} ELSE ${schema.grants.programStateAwards}
END`, END`,
@@ -45,6 +55,10 @@ export async function serverResetFederalPrecedent(
): Promise<void> { ): Promise<void> {
await db await db
.update(schema.grants) .update(schema.grants)
.set({ programStateAwardCount: null, programStateAwards: null }) .set({
programStateAwardCount: null,
programStatePeerAwardCount: null,
programStateAwards: null,
})
.where(eq(schema.grants.source, 'grants_gov')); .where(eq(schema.grants.source, 'grants_gov'));
} }

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { normalizeOrgNameForMatching } from '../orgs/org-name.js';
import { buildPeerNameSet, classifyPeerAwards } from './peer-precedent.js';
describe('normalizeOrgNameForMatching', () => {
it('collapses case, punctuation, and org-form suffixes', () => {
expect(normalizeOrgNameForMatching('The Community Kitchen, Inc.')).toBe(
normalizeOrgNameForMatching('COMMUNITY KITCHEN INC'),
);
expect(normalizeOrgNameForMatching('Smith Charitable Trust')).toBe(
normalizeOrgNameForMatching('SMITH TRUST'),
);
});
it('does not strip for-profit suffixes', () => {
expect(normalizeOrgNameForMatching('CELDARA MEDICAL, LLC')).not.toBe(
normalizeOrgNameForMatching('CELDARA MEDICAL'),
);
});
it('only strips suffixes as whole words', () => {
// TRUSTEES must not lose an embedded TRUSTEE/TRUST.
expect(
normalizeOrgNameForMatching('TRUSTEES OF DARTMOUTH COLLEGE'),
).toContain('TRUSTEES');
});
});
describe('classifyPeerAwards', () => {
const peers = buildPeerNameSet([
'The Community Kitchen, Inc.',
'Granite Backcountry Alliance',
]);
it('counts only registry-matched recipients as peers', () => {
const { peerAwardCount, awards } = classifyPeerAwards(
[
{ recipientName: 'COMMUNITY KITCHEN INC', amount: 50_000, startDate: '2024-01-01' },
{ recipientName: 'TRUSTEES OF DARTMOUTH COLLEGE', amount: 3_000_000, startDate: '2024-05-01' },
{ recipientName: 'CELDARA MEDICAL, LLC', amount: 2_000_000, startDate: '2022-09-22' },
{ recipientName: 'GRANITE BACKCOUNTRY ALLIANCE', amount: 25_000, startDate: '2023-06-01' },
],
peers,
);
expect(peerAwardCount).toBe(2);
expect(awards.map((a) => a.isPeer)).toEqual([true, false, false, true]);
});
it('is empty-safe', () => {
expect(classifyPeerAwards([], peers).peerAwardCount).toBe(0);
expect(
classifyPeerAwards(
[{ recipientName: 'ANYONE', amount: null, startDate: null }],
new Set<string>(),
).peerAwardCount,
).toBe(0);
});
});

View File

@@ -0,0 +1,53 @@
import { normalizeOrgNameForMatching } from '../orgs/org-name.js';
/** One USASpending award row as stored in `grants.program_state_awards`. */
export interface StateAwardSample {
readonly recipientName: string;
readonly amount: number | null;
readonly startDate: string | null;
}
export interface PeerAnnotatedAward extends StateAwardSample {
/** Recipient name-matches a registered NH nonprofit in the primary ICP band. */
readonly isPeer: boolean;
}
export interface PeerPrecedentResult {
/** Awards (within the fetched window) won by ICP-peer nonprofits. */
readonly peerAwardCount: number;
readonly awards: PeerAnnotatedAward[];
}
/** Builds the normalized peer-name set from registry org names. */
export function buildPeerNameSet(peerOrgNames: readonly string[]): Set<string> {
const set = new Set<string>();
for (const name of peerOrgNames) {
const normalized = normalizeOrgNameForMatching(name);
if (normalized !== '') set.add(normalized);
}
return set;
}
/**
* Splits a program's state award history into peer and non-peer awards.
* "10 NH awards" is meaningless when all ten went to Dartmouth and two
* biotech LLCs; the precedent subscore keys off `peerAwardCount` — awards
* to orgs shaped like our candidates — and the annotated list becomes the
* review-page evidence table so a human can see WHY precedent is high or
* low. Conservative by construction: an unmatched recipient (out-of-
* registry, unenriched, or name drift) counts as non-peer, and missed
* precedent ranks a real match lower rather than pitching a false one.
*/
export function classifyPeerAwards(
awards: readonly StateAwardSample[],
peerNames: ReadonlySet<string>,
): PeerPrecedentResult {
const annotated = awards.map((award) => ({
...award,
isPeer: peerNames.has(normalizeOrgNameForMatching(award.recipientName)),
}));
return {
peerAwardCount: annotated.filter((a) => a.isPeer).length,
awards: annotated,
};
}

View File

@@ -18,8 +18,15 @@ export interface EligibleGrantWithSimilarity {
| 'unknown'; | 'unknown';
applicationFormSupported: boolean; applicationFormSupported: boolean;
funderEin: string | null; funderEin: string | null;
source: string;
similarity: number; similarity: number;
/** Funder's historical grant count into the org's state (990-PF index); null when the grant has no linked funder. */ /**
* Precedent count for the subscore: the funder's historical grant count
* into the org's state (990-PF index) for foundation grants, or the
* program's PEER award count (USASpending recipients name-matched to
* primary-ICP NH orgs) for federal grants — never the raw state count,
* which Dartmouth renewals inflate.
*/
funderStateGrantCount: number | null; funderStateGrantCount: number | null;
} }
@@ -61,6 +68,7 @@ export async function serverListEligibleGrantsForOrg(
applicationEffortEstimate: schema.grants.applicationEffortEstimate, applicationEffortEstimate: schema.grants.applicationEffortEstimate,
applicationFormSupported: schema.grants.applicationFormSupported, applicationFormSupported: schema.grants.applicationFormSupported,
funderEin: schema.grants.funderEin, funderEin: schema.grants.funderEin,
source: schema.grants.source,
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`, similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
funderStateGrantCount: sql<number | null>`CASE funderStateGrantCount: sql<number | null>`CASE
WHEN ${schema.grants.funderEin} IS NOT NULL THEN ( WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
@@ -69,7 +77,7 @@ export async function serverListEligibleGrantsForOrg(
WHERE f.ein = ${schema.grants.funderEin} WHERE f.ein = ${schema.grants.funderEin}
AND fg.recipient_state = ${filters.orgState} AND fg.recipient_state = ${filters.orgState}
) )
ELSE ${schema.grants.programStateAwardCount} ELSE ${schema.grants.programStatePeerAwardCount}
END`, END`,
}) })
.from(schema.grants) .from(schema.grants)

View File

@@ -10,3 +10,5 @@ export * from './matches/hard-gates.js';
export * from './orgs/icp-band.js'; export * from './orgs/icp-band.js';
export * from './matches/scoring.js'; export * from './matches/scoring.js';
export * from './orgs/ntee.js'; export * from './orgs/ntee.js';
export * from './orgs/org-name.js';
export * from './grants/peer-precedent.js';

View File

@@ -0,0 +1,48 @@
import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
import { EASY_WIN_MIN_PRECEDENT, EASY_WIN_THRESHOLD } from '../scoring.js';
export interface MatchJudgmentInput {
readonly matchId: string;
readonly verdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch';
/** Deterministic 030 mapping of the verdict (missionFitFromVerdict). */
readonly judgedMissionFit: number;
readonly fitViable: boolean;
readonly rationale: string;
readonly model: string;
}
/**
* Applies a judge verdict to one match in a single UPDATE: stores the
* judgment, splices the judged mission fit into `subscores`, and
* recomputes total score, easy-win, and queue viability from it. All SET
* expressions read the OLD row (Postgres semantics), so the splice math
* is safe on re-judgment too — the old missionFit (embedding-band or a
* previous verdict's) is subtracted, the new one added.
*/
export async function serverApplyMatchJudgment(
db: NpOutreachDatabase | NpOutreachTransaction,
judgment: MatchJudgmentInput,
): Promise<void> {
const newTotal = sql`${schema.matches.totalScore} - COALESCE((${schema.matches.subscores}->>'missionFit')::int, 0) + ${judgment.judgedMissionFit}`;
await db
.update(schema.matches)
.set({
judgeVerdict: judgment.verdict,
judgeMissionFit: judgment.judgedMissionFit,
judgeRationale: judgment.rationale,
judgeModel: judgment.model,
judgedAt: sql`now()`,
totalScore: newTotal,
subscores: sql`jsonb_set(COALESCE(${schema.matches.subscores}, '{}'::jsonb), '{missionFit}', to_jsonb(${judgment.judgedMissionFit}::int))`,
fitViable: judgment.fitViable,
easyWin: sql`(${judgment.fitViable}
AND ${newTotal} >= ${EASY_WIN_THRESHOLD}
AND COALESCE((${schema.matches.subscores}->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT})`,
updatedAt: sql`now()`,
})
.where(sql`${schema.matches.id} = ${judgment.matchId}`);
}

View File

@@ -24,6 +24,7 @@ export async function serverAssignHeroMatch(
WHERE org_id = ${orgId} WHERE org_id = ${orgId}
AND review_status != 'rejected' AND review_status != 'rejected'
AND hard_gates_passed = true AND hard_gates_passed = true
AND fit_viable = true
ORDER BY easy_win DESC, total_score DESC, created_at ASC ORDER BY easy_win DESC, total_score DESC, created_at ASC
LIMIT 1 LIMIT 1
) )

View File

@@ -2,3 +2,4 @@ export * from './insert-match.server.js';
export * from './set-match-review.server.js'; export * from './set-match-review.server.js';
export * from './upsert-match-score.server.js'; export * from './upsert-match-score.server.js';
export * from './assign-hero-matches.server.js'; export * from './assign-hero-matches.server.js';
export * from './apply-match-judgment.server.js';

View File

@@ -2,7 +2,11 @@ import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js'; import { schema } from '#~/db/db.js';
import type { MatchSubscores } from '../scoring.js'; import {
EASY_WIN_MIN_PRECEDENT,
EASY_WIN_THRESHOLD,
type MatchSubscores,
} from '../scoring.js';
export interface MatchScoreInput { export interface MatchScoreInput {
readonly orgId: string; readonly orgId: string;
@@ -11,6 +15,7 @@ export interface MatchScoreInput {
readonly subscores: MatchSubscores; readonly subscores: MatchSubscores;
readonly hardGatesPassed: boolean; readonly hardGatesPassed: boolean;
readonly easyWin: boolean; readonly easyWin: boolean;
readonly fitViable: boolean;
/** Gate failures, similarity, and any judge citations — audit trail. */ /** Gate failures, similarity, and any judge citations — audit trail. */
readonly rationale: unknown; readonly rationale: unknown;
} }
@@ -22,11 +27,23 @@ export interface MatchScoreInput {
* nightly re-score moves the numbers — resurfacing rejected matches would * nightly re-score moves the numbers — resurfacing rejected matches would
* erode the review queue's trust, and re-approving approved ones is * erode the review queue's trust, and re-approving approved ones is
* pointless churn. * pointless churn.
*
* Judge fields survive re-scores the same way, and while a judgment is
* on the row its mission-fit verdict KEEPS overriding the incoming
* embedding-based numbers: missionFit is spliced with judge_mission_fit,
* total/easy-win recomputed from the spliced value, and fit_viable kept
* from the verdict. Otherwise the nightly embedding re-score would revert
* every judged match until the judge's next pass re-found it. Staleness
* is handled at selection time (re-judge when the org profile is newer
* than judged_at), not by dropping the judgment here.
*/ */
export async function serverUpsertMatchScore( export async function serverUpsertMatchScore(
db: NpOutreachDatabase | NpOutreachTransaction, db: NpOutreachDatabase | NpOutreachTransaction,
match: MatchScoreInput, match: MatchScoreInput,
): Promise<void> { ): Promise<void> {
const judged = sql`${schema.matches.judgedAt} IS NOT NULL AND ${schema.matches.judgeMissionFit} IS NOT NULL`;
const judgedTotal = sql`excluded.total_score - COALESCE((excluded.subscores->>'missionFit')::int, 0) + ${schema.matches.judgeMissionFit}`;
await db await db
.insert(schema.matches) .insert(schema.matches)
.values({ .values({
@@ -36,16 +53,24 @@ export async function serverUpsertMatchScore(
subscores: match.subscores, subscores: match.subscores,
hardGatesPassed: match.hardGatesPassed, hardGatesPassed: match.hardGatesPassed,
easyWin: match.easyWin, easyWin: match.easyWin,
fitViable: match.fitViable,
rationale: match.rationale, rationale: match.rationale,
reviewStatus: 'pending', reviewStatus: 'pending',
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [schema.matches.orgId, schema.matches.grantId], target: [schema.matches.orgId, schema.matches.grantId],
set: { set: {
totalScore: sql`excluded.total_score`, totalScore: sql`CASE WHEN ${judged} THEN ${judgedTotal} ELSE excluded.total_score END`,
subscores: sql`excluded.subscores`, subscores: sql`CASE WHEN ${judged}
THEN jsonb_set(excluded.subscores, '{missionFit}', to_jsonb(${schema.matches.judgeMissionFit}))
ELSE excluded.subscores END`,
hardGatesPassed: sql`excluded.hard_gates_passed`, hardGatesPassed: sql`excluded.hard_gates_passed`,
easyWin: sql`excluded.easy_win`, easyWin: sql`CASE WHEN ${judged}
THEN (${schema.matches.fitViable}
AND ${judgedTotal} >= ${EASY_WIN_THRESHOLD}
AND COALESCE((excluded.subscores->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT})
ELSE excluded.easy_win END`,
fitViable: sql`CASE WHEN ${judged} THEN ${schema.matches.fitViable} ELSE excluded.fit_viable END`,
rationale: sql`excluded.rationale`, rationale: sql`excluded.rationale`,
updatedAt: sql`now()`, updatedAt: sql`now()`,
}, },

View File

@@ -11,6 +11,10 @@ export interface MatchDetail {
rationale: unknown; rationale: unknown;
easyWin: boolean; easyWin: boolean;
isHero: boolean; isHero: boolean;
fitViable: boolean;
judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null;
judgeRationale: string | null;
judgedAt: Date | null;
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited'; reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
}; };
org: { org: {
@@ -56,6 +60,9 @@ export interface MatchDetail {
amount: number | null; amount: number | null;
purpose: string | null; purpose: string | null;
taxYear: number; taxYear: number;
/** Federal evidence rows only: recipient name-matched a primary-ICP
* NH nonprofit (the peer-precedent basis). Null for 990-PF rows. */
isPeer: boolean | null;
}>; }>;
} }
@@ -71,6 +78,10 @@ export async function serverGetMatchDetail(
rationale: schema.matches.rationale, rationale: schema.matches.rationale,
easyWin: schema.matches.easyWin, easyWin: schema.matches.easyWin,
isHero: schema.matches.isHero, isHero: schema.matches.isHero,
fitViable: schema.matches.fitViable,
judgeVerdict: schema.matches.judgeVerdict,
judgeRationale: schema.matches.judgeRationale,
judgedAt: schema.matches.judgedAt,
reviewStatus: schema.matches.reviewStatus, reviewStatus: schema.matches.reviewStatus,
orgId: schema.orgs.id, orgId: schema.orgs.id,
orgName: schema.orgs.name, orgName: schema.orgs.name,
@@ -123,19 +134,29 @@ export async function serverGetMatchDetail(
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) { if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
// Federal grants: USASpending program awards fill the same evidence // Federal grants: USASpending program awards fill the same evidence
// table (recipient/amount/year) the 990-PF history uses. // table (recipient/amount/year) the 990-PF history uses.
// Peers first (the actual precedent evidence), newest within each
// group; capped so a 500-award program doesn't flood the page.
funderGivingHistory = ( funderGivingHistory = (
row.grantProgramAwards as Array<{ row.grantProgramAwards as Array<{
recipientName?: string; recipientName?: string;
amount?: number | null; amount?: number | null;
startDate?: string | null; startDate?: string | null;
isPeer?: boolean;
}> }>
).map((a) => ({ )
.map((a) => ({
recipientName: a.recipientName ?? 'Unknown recipient', recipientName: a.recipientName ?? 'Unknown recipient',
recipientCity: null, recipientCity: null,
amount: a.amount ?? null, amount: a.amount ?? null,
purpose: null, purpose: null,
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0, taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
})); isPeer: a.isPeer ?? null,
}))
.sort((a, b) => {
const peerRank = Number(b.isPeer === true) - Number(a.isPeer === true);
return peerRank !== 0 ? peerRank : b.taxYear - a.taxYear;
})
.slice(0, 40);
} }
if (row.grantFunderEin != null) { if (row.grantFunderEin != null) {
const funderRow = await db const funderRow = await db
@@ -170,7 +191,7 @@ export async function serverGetMatchDetail(
return aIn - bIn; return aIn - bIn;
}) })
.slice(0, 40) .slice(0, 40)
.map(({ recipientState: _s, ...rest }) => rest); .map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null }));
} }
return { return {
@@ -181,6 +202,10 @@ export async function serverGetMatchDetail(
rationale: row.rationale, rationale: row.rationale,
easyWin: row.easyWin, easyWin: row.easyWin,
isHero: row.isHero, isHero: row.isHero,
fitViable: row.fitViable,
judgeVerdict: row.judgeVerdict,
judgeRationale: row.judgeRationale,
judgedAt: row.judgedAt,
reviewStatus: row.reviewStatus, reviewStatus: row.reviewStatus,
}, },
org: { org: {

View File

@@ -1,2 +1,3 @@
export * from './list-pending-review-matches.server.js'; export * from './list-pending-review-matches.server.js';
export * from './get-match-detail.server.js'; export * from './get-match-detail.server.js';
export * from './list-matches-needing-judgment.server.js';

View File

@@ -0,0 +1,74 @@
import { sql } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
export interface MatchNeedingJudgment {
matchId: string;
orgName: string;
orgCity: string | null;
missionStatement: string | null;
/** Researched programs jsonb (Stage 4 shape) — null/[] for stub profiles. */
programs: unknown;
serviceGeography: string | null;
profileConfidence: number | null;
grantTitle: string;
grantFunder: string;
grantSynopsis: string | null;
}
/**
* Federal pending matches awaiting an LLM mission-fit verdict, best-first
* — the head of the review queue gets verified before a human ever reads
* it. A match re-enters when its org profile has been re-researched since
* it was judged (better profile → the verdict may flip either way).
*
* Federal-only (`grants_gov`) by design: foundation-synthesized grants
* have generic synopses ("grants for NH nonprofits"), so there is no
* grant-side text for a judge to read — precedent evidence carries those.
* Queue-viable rows only: the mission-fit floor already buried the clear
* garbage; judge tokens go to rows a human would otherwise see next.
*/
export async function serverListMatchesNeedingJudgment(
db: NpOutreachDatabase | NpOutreachTransaction,
{ limit }: { limit: number },
): Promise<MatchNeedingJudgment[]> {
const rows = (await db.execute(sql`
SELECT
m.id AS match_id,
o.name AS org_name,
o.city AS org_city,
p.mission_statement,
p.programs,
p.service_geography,
p.confidence AS profile_confidence,
g.title AS grant_title,
g.funder AS grant_funder,
g.synopsis AS grant_synopsis
FROM matches m
JOIN orgs o ON o.id = m.org_id
JOIN grants g ON g.id = m.grant_id
LEFT JOIN org_profiles p ON p.org_id = m.org_id
WHERE m.review_status = 'pending'
AND m.hard_gates_passed = true
AND m.fit_viable = true
AND g.source = 'grants_gov'
AND g.status = 'open'
AND (m.judged_at IS NULL OR m.judged_at < p.updated_at)
ORDER BY m.easy_win DESC, m.total_score DESC
LIMIT ${limit}
`)) as unknown as { rows: Array<Record<string, unknown>> };
return rows.rows.map((r) => ({
matchId: r.match_id as string,
orgName: r.org_name as string,
orgCity: (r.org_city as string | null) ?? null,
missionStatement: (r.mission_statement as string | null) ?? null,
programs: r.programs ?? null,
serviceGeography: (r.service_geography as string | null) ?? null,
profileConfidence: (r.profile_confidence as number | null) ?? null,
grantTitle: r.grant_title as string,
grantFunder: r.grant_funder as string,
grantSynopsis: (r.grant_synopsis as string | null) ?? null,
}));
}

View File

@@ -44,11 +44,15 @@ export async function serverListPendingReviewMatches(
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
.where( .where(
source == null and(
? eq(schema.matches.reviewStatus, status)
: and(
eq(schema.matches.reviewStatus, status), eq(schema.matches.reviewStatus, status),
eq(schema.grants.source, source as never), // The pending queue hides fit-non-viable rows (below the mission-
// fit floor or judged weak/mismatch); a human's approve/reject
// always displays regardless.
...(status === 'pending' ? [eq(schema.matches.fitViable, true)] : []),
...(source == null
? []
: [eq(schema.grants.source, source as never)]),
), ),
) )
// Reviewers see the best candidates first: heroes, then easy wins, // Reviewers see the best candidates first: heroes, then easy wins,

View File

@@ -6,6 +6,7 @@ import {
competitionSubscore, competitionSubscore,
EASY_WIN_THRESHOLD, EASY_WIN_THRESHOLD,
effortSubscore, effortSubscore,
MISSION_FIT_VIABLE_MIN,
missionFitSubscore, missionFitSubscore,
runwaySubscore, runwaySubscore,
scoreMatch, scoreMatch,
@@ -102,6 +103,7 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(6), closeDate: weeksFromNow(6),
now: NOW, now: NOW,
funderStateGrantCount: null, funderStateGrantCount: null,
grantSource: 'irs_990pf',
}); });
// 30+15+15+10+5 = 75 — over the threshold but no precedent floor. // 30+15+15+10+5 = 75 — over the threshold but no precedent floor.
expect(result.totalScore).toBe(75); expect(result.totalScore).toBe(75);
@@ -118,6 +120,7 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(6), closeDate: weeksFromNow(6),
now: NOW, now: NOW,
funderStateGrantCount: 6, funderStateGrantCount: 6,
grantSource: 'irs_990pf',
}); });
// 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway // 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway
expect(result.totalScore).toBe(93); expect(result.totalScore).toBe(93);
@@ -135,8 +138,81 @@ describe('scoreMatch', () => {
closeDate: weeksFromNow(2), closeDate: weeksFromNow(2),
now: NOW, now: NOW,
funderStateGrantCount: null, funderStateGrantCount: null,
grantSource: 'grants_gov',
}); });
expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD); expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD);
expect(result.easyWin).toBe(false); expect(result.easyWin).toBe(false);
}); });
it('marks federal matches below the mission-fit floor non-viable', () => {
// similarity 0.55 → fit 10 < MISSION_FIT_VIABLE_MIN (12)
const result = scoreMatch({
similarity: 0.55,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: null,
applicationEffortEstimate: 'full_federal',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
expect(result.fitViable).toBe(false);
expect(result.easyWin).toBe(false);
});
it('keeps federal matches at/above the floor viable', () => {
// similarity 0.57 → fit 12 = floor
const result = scoreMatch({
similarity: 0.57,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: null,
applicationEffortEstimate: 'full_federal',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
expect(result.subscores.missionFit).toBeGreaterThanOrEqual(
MISSION_FIT_VIABLE_MIN,
);
expect(result.fitViable).toBe(true);
});
it('exempts foundation-synthesized grants from the fit floor', () => {
// Generic 990-PF synopses carry no embedding signal — precedent
// evidence is the case for those matches, so low fit stays viable.
const result = scoreMatch({
similarity: 0.5,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: 'New Hampshire',
applicationEffortEstimate: 'loi_only',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 12,
grantSource: 'irs_990pf',
});
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
expect(result.fitViable).toBe(true);
});
it('gates easy-win on fit viability, not just score and precedent', () => {
const belowFloor = scoreMatch({
similarity: 0.55,
orgTotalRevenue: 1_000_000,
awardCeiling: 200_000,
geographicScope: 'New Hampshire',
applicationEffortEstimate: 'loi_only',
closeDate: weeksFromNow(6),
now: NOW,
funderStateGrantCount: 20,
grantSource: 'grants_gov',
});
// 10+25+15+15+10+5 = 80 ≥ threshold, precedent 25 ≥ floor — but not viable.
expect(belowFloor.totalScore).toBeGreaterThanOrEqual(EASY_WIN_THRESHOLD);
expect(belowFloor.easyWin).toBe(false);
});
}); });

View File

@@ -39,12 +39,15 @@ export interface ScoreMatchInput {
readonly now: Date; readonly now: Date;
/** /**
* Historical grants this funder has paid to recipients in the org's * Historical grants this funder has paid to recipients in the org's
* state (from the 990-PF index). Null = no precedent data for this * state (990-PF index for foundations; USASpending PEER award count for
* grant's funder (e.g. federal agencies) — scores 0, not neutral: the * federal programs — recipients name-matched to primary-ICP NH orgs).
* plan weights precedent as the strongest single predictor, and absence * Null = no precedent data — scores 0, not neutral: the plan weights
* of evidence should rank below presence. * precedent as the strongest single predictor, and absence of evidence
* should rank below presence.
*/ */
readonly funderStateGrantCount: number | null; readonly funderStateGrantCount: number | null;
/** Grant source (`grants.source`) — drives the source-aware fit floor. */
readonly grantSource: string;
} }
export const ACHIEVABLE_MAX_SCORE = 100; export const ACHIEVABLE_MAX_SCORE = 100;
@@ -57,6 +60,18 @@ export const ACHIEVABLE_MAX_SCORE = 100;
export const EASY_WIN_THRESHOLD = 65; export const EASY_WIN_THRESHOLD = 65;
export const EASY_WIN_MIN_PRECEDENT = 12; export const EASY_WIN_MIN_PRECEDENT = 12;
/**
* Mission-fit floor (federal RFPs only): below 12/30 (≈ cosine 0.57) the
* match is stored but NOT review-viable — non-mission subscores sum to 50,
* so without a floor a boys' camp scores 60 on an NIH obesity-research
* center grant purely on precedent + capacity. Foundation-synthesized
* grants are exempt: their synopses are generic by construction ("grants
* for NH nonprofits"), so embedding fit carries no signal there and the
* precedent evidence IS the case for the match.
*/
export const MISSION_FIT_VIABLE_MIN = 12;
const FIT_FLOOR_SOURCES: ReadonlySet<string> = new Set(['grants_gov']);
/** Similarity below this scores 0 fit; above the ceiling scores full fit. */ /** Similarity below this scores 0 fit; above the ceiling scores full fit. */
const SIMILARITY_FLOOR = 0.45; const SIMILARITY_FLOOR = 0.45;
const SIMILARITY_CEILING = 0.75; const SIMILARITY_CEILING = 0.75;
@@ -154,6 +169,13 @@ export interface ScoredMatch {
readonly totalScore: number; readonly totalScore: number;
readonly subscores: MatchSubscores; readonly subscores: MatchSubscores;
readonly easyWin: boolean; readonly easyWin: boolean;
/**
* False when mission fit is below the source-aware floor — the match is
* recorded (audit trail, re-scoring continuity) but hidden from the
* pending review queue. The LLM match judge may later override in
* either direction.
*/
readonly fitViable: boolean;
} }
export function scoreMatch(input: ScoreMatchInput): ScoredMatch { export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
@@ -174,11 +196,17 @@ export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
subscores.runway + subscores.runway +
subscores.funderPrecedent; subscores.funderPrecedent;
const fitViable =
!FIT_FLOOR_SOURCES.has(input.grantSource) ||
subscores.missionFit >= MISSION_FIT_VIABLE_MIN;
return { return {
totalScore, totalScore,
subscores, subscores,
easyWin: easyWin:
fitViable &&
totalScore >= EASY_WIN_THRESHOLD && totalScore >= EASY_WIN_THRESHOLD &&
subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT, subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT,
fitViable,
}; };
} }

View File

@@ -52,6 +52,9 @@ export async function serverApplyResearchedProfile(
sources: sql`excluded.sources`, sources: sql`excluded.sources`,
confidence: sql`excluded.confidence`, confidence: sql`excluded.confidence`,
profileEmbedding: sql`excluded.profile_embedding`, profileEmbedding: sql`excluded.profile_embedding`,
// Staleness signal for the match judge: judgments older than this
// re-enter the judge queue.
updatedAt: sql`now()`,
}, },
}); });
} }

View File

@@ -0,0 +1,22 @@
/**
* Case/punctuation/suffix-insensitive org-name equality, shared by the
* self-match gate (a foundation's synthesized grant must never match the
* foundation's own org row) and USASpending peer matching (a federal
* award recipient counts as a "peer" only if it name-matches a registered
* NH nonprofit). Both sides of any comparison must go through this same
* function — the guarantees are only as good as the normalization being
* identical.
*
* Deliberately does NOT strip LLC/LTD/CORP: a for-profit "ACME LLC"
* collapsing onto a nonprofit "ACME" would grant peer precedent to
* exactly the recipient type this matching exists to exclude.
*/
export function normalizeOrgNameForMatching(name: string): string {
return name
.toUpperCase()
.replace(
/\b(THE|INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g,
'',
)
.replace(/[^A-Z0-9]/g, '');
}

View File

@@ -2,3 +2,4 @@ export * from './list-orgs-in-icp-band.server.js';
export * from './list-orgs-needing-enrichment.server.js'; export * from './list-orgs-needing-enrichment.server.js';
export * from './list-match-candidate-orgs.server.js'; export * from './list-match-candidate-orgs.server.js';
export * from './list-orgs-needing-profile.server.js'; export * from './list-orgs-needing-profile.server.js';
export * from './list-peer-org-names.server.js';

View File

@@ -0,0 +1,24 @@
import { and, eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* Names of registered NH nonprofits in the primary ICP band ($100K$5M
* revenue, enriched) — the "orgs like ours" universe that USASpending
* award recipients are matched against for federal peer precedent.
*
* Primary-band-only is deliberate: registry membership alone would count
* Dartmouth College (registered, revenue in the billions) as a peer, and
* unenriched rows can't prove they're in band. Under-counting demotes;
* over-counting pitches a camp on an NIH center grant.
*/
export async function serverListPeerOrgNames(
db: NpOutreachDatabase | NpOutreachTransaction,
): Promise<string[]> {
const rows = await db
.select({ name: schema.orgs.name })
.from(schema.orgs)
.where(and(eq(schema.orgs.state, 'NH'), eq(schema.orgs.icpBand, 'primary')));
return rows.map((r) => r.name);
}