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

@@ -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 type { OrgProfile } from '../org-profiler/schema.js';
import { getAi } from '../../gemini.js';
import { JUDGE_MODEL } from '../../models.js';
import {
MATCH_JUDGE_VERDICT_JSON_SCHEMA,
MatchJudgeVerdictSchema,
type MatchJudgeVerdict,
} from './schema.js';
export interface RunMissionFitJudgeInput {
/** Extracted profile of the candidate org (from the Org Profiler). */
orgProfile: OrgProfile;
/** Grant program name / title, as scored against by the deterministic SQL gates. */
grantProgramName: string;
/** Funding priorities / eligible-use language pulled from the grant's own source text. */
grantPriorities: string[];
export interface JudgeOrgSide {
readonly name: string;
readonly city: string | null;
/** Researched or NTEE-stub mission text (whatever the profile holds). */
readonly missionStatement: string | null;
/** Researched programs, if the Stage 4 profiler has run for this org. */
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
* from `runMissionFitJudge` so it's independently unit-testable once wired up.
* Build the judge prompt for one (org, grant) candidate. Kept separate
* 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 [
'You are the final mission-fit judge for a candidate (org, grant) match',
'that has already passed deterministic SQL hard gates (eligibility,',
'geography, award range). Decide whether the org\'s actual programs',
'plausibly fit the grant\'s funding priorities. You MUST cite one',
'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`.',
'You judge whether a specific nonprofit is a credible fit for a specific',
'grant program. The pair already passed automated eligibility, geography,',
'and deadline gates; your ONLY question is programmatic mission fit:',
'does the work this org actually does match what this grant actually funds?',
'',
`Grant program: ${input.grantProgramName}`,
`Grant priorities: ${input.grantPriorities.join('; ')}`,
`Org legal name: ${input.orgProfile.legalName.value}`,
`Org mission: ${input.orgProfile.mission.value}`,
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`,
'Rules:',
'- IGNORE eligibility breadth entirely. Federal synopses often say any',
' nonprofit may apply while the funded activity is narrow, technical, or',
' institutional (research centers, clinical trials, training programs at',
' 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');
}
/**
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a
* human reviewer (and, downstream, a real prospect via Apollo), so it should
* not go live against real matches until the deterministic hard-gate scoring
* 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);
* One structured judge call. JUDGE_MODEL per the tiering rule in
* models.ts — this verdict gates matches into/out of the review queue.
* `MATCH_JUDGE_MODEL` env overrides for cheap bulk experiments.
*/
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> {
throw new Error('not implemented');
export async function runMatchJudge(
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';
/**
* Mission-fit judge verdict for one (org, grant) match candidate. This is
* the last human-facing gate before a match is surfaced for review — the
* judge must ground its verdict in something concrete from each side rather
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not
* optional summary fields.
* Graded mission-fit verdict for one (org, grant) match candidate that
* already passed the deterministic hard gates and subscore ranking.
*
* The judge names a verdict; deterministic code assigns the number
* (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({
/** Whether the org's mission plausibly fits the grant's funding priorities. */
fit: z.boolean(),
/** The specific org program/activity the verdict is grounded in (from the org profile). */
citedOrgProgram: z.string().min(1),
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */
citedGrantPriority: z.string().min(1),
/** Short human-readable justification tying the two citations together. */
export const MatchJudgeVerdictSchema = z.object({
verdict: z.enum(['strong_fit', 'plausible', 'weak', 'mismatch']),
/** The specific org program/activity the verdict is grounded in. */
citedOrgEvidence: z.string().min(1),
/** The specific synopsis language (purpose/priorities) the verdict is grounded in. */
citedGrantEvidence: z.string().min(1),
/** 13 sentence justification tying the two citations together. */
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';
}