feat(scoring): v4 — peer precedent, mission-fit floor, LLM match judge
Fixes the federal mismatch class (boys' camp × NIH research center): - Peer precedent: federalPrecedent paginates USASpending (≤500 awards/ program) and name-matches every recipient against primary-ICP NH registry orgs (shared normalizeOrgNameForMatching, also used by the self-match gate). The 25-pt precedent tiers now key off program_state_peer_award_count — Dartmouth renewals and SBIR LLCs no longer grant precedent to community nonprofits. Raw count + peer- annotated award list stay as review evidence (peer badges, peers-first). - Mission-fit floor (12/30, grants_gov only): below it a match is stored with fit_viable=false and hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants exempt (generic synopses). - Mission-fit judge live (judgeMatches, 06:15, 200/night best-first): JUDGE_MODEL reads the synopsis against the org profile with an explicit ignore-eligibility-breadth instruction; graded verdict with required citations; deterministic verdict→points map (27/18/8/0) sets missionFit, total, easy-win, and viability. Verdicts survive nightly re-scores via an upsert splice and re-enter the judge queue when the org profile is re-researched (org_profiles.updated_at). First sweep: 81/149 programs have NH history, only 6 have peer history; queue-head judging zeroes the research-mechanism garbage (mismatch) while surfacing genuine strong fits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,6 +50,13 @@ const SUBSCORE_MAX: Record<string, number> = {
|
||||
runway: 5,
|
||||
};
|
||||
|
||||
const JUDGE_VERDICT_STYLE: Record<string, string> = {
|
||||
strong_fit: 'bg-green-100 text-green-800',
|
||||
plausible: 'bg-blue-100 text-blue-800',
|
||||
weak: 'bg-amber-100 text-amber-800',
|
||||
mismatch: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { detail } = loaderData ?? {};
|
||||
if (detail == null) return null;
|
||||
@@ -95,6 +102,12 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||
{match.easyWin ? ' · Easy win' : ''}
|
||||
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
|
||||
</p>
|
||||
{!match.fitViable && (
|
||||
<p className="mt-1 text-sm font-medium text-red-700">
|
||||
Hidden from the pending queue — mission fit below floor or
|
||||
judged non-viable.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Form method="post">
|
||||
@@ -289,6 +302,26 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{match.judgeVerdict != null && (
|
||||
<section className="rounded border p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">
|
||||
Mission-fit judge{' '}
|
||||
<span
|
||||
className={`ml-1 rounded px-2 py-0.5 text-sm font-medium ${JUDGE_VERDICT_STYLE[match.judgeVerdict] ?? ''}`}
|
||||
>
|
||||
{match.judgeVerdict.replace('_', ' ')}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="whitespace-pre-wrap text-sm">{match.judgeRationale}</p>
|
||||
{match.judgedAt != null && (
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
Judged {new Date(match.judgedAt).toLocaleDateString('en-US')} —
|
||||
the verdict replaces the embedding mission-fit score above.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applyInfo != null && (
|
||||
<section className="rounded border p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">
|
||||
@@ -346,7 +379,7 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||
The concrete evidence behind the precedent score —{' '}
|
||||
{grant.source === 'irs_990pf'
|
||||
? 'who this funder actually paid, sorted in-state first.'
|
||||
: 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'}
|
||||
: 'who this federal program actually funded in NH recently, peers first. Only "peer" recipients (registered NH nonprofits our size) count toward the precedent score — universities, hospital systems, and companies do not.'}
|
||||
</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -361,7 +394,14 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||
<tbody>
|
||||
{nhHistory.map((g, i) => (
|
||||
<tr key={i} className="border-b align-top">
|
||||
<td className="py-1 pr-2">{g.recipientName}</td>
|
||||
<td className="py-1 pr-2">
|
||||
{g.recipientName}
|
||||
{g.isPeer === true && (
|
||||
<span className="ml-1 rounded bg-green-100 px-1 text-xs font-medium text-green-800">
|
||||
peer
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1 pr-2">{g.recipientCity ?? '—'}</td>
|
||||
<td className="py-1 pr-2 text-right tabular-nums">
|
||||
{dollars(g.amount)}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
|
||||
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
|
||||
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
|
||||
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
|
||||
import { setJudgeMatchesDeps } from './workflows/judge-matches.js';
|
||||
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
|
||||
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
|
||||
import { setMatchGrantsDeps } from './workflows/match-grants.js';
|
||||
@@ -75,6 +76,7 @@ async function main() {
|
||||
setIngest990pfDeps({ db });
|
||||
setFederalPrecedentDeps({ db });
|
||||
setProfileOrgsDeps({ db });
|
||||
setJudgeMatchesDeps({ db });
|
||||
|
||||
DBOS.setConfig({
|
||||
name: 'helmdocs-outreach-worker',
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
runIngestNhdojOrgsNow,
|
||||
setIngestNhdojOrgsDeps,
|
||||
} from './workflows/ingest-nhdoj-orgs.js';
|
||||
import {
|
||||
runJudgeMatchesNow,
|
||||
setJudgeMatchesDeps,
|
||||
} from './workflows/judge-matches.js';
|
||||
import {
|
||||
runMatchGrantsNow,
|
||||
setMatchGrantsDeps,
|
||||
@@ -68,6 +72,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
|
||||
ingest990pf: runIngest990PfNow,
|
||||
federalPrecedent: runFederalPrecedentNow,
|
||||
profileOrgs: runProfileOrgsNow,
|
||||
judgeMatches: runJudgeMatchesNow,
|
||||
};
|
||||
|
||||
const FIRST_RUN_ORDER = [
|
||||
@@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [
|
||||
'embedGrants',
|
||||
'matchGrants',
|
||||
'profileOrgs',
|
||||
'judgeMatches',
|
||||
];
|
||||
|
||||
if (process.env.DATABASE_URL == null) {
|
||||
@@ -120,6 +126,7 @@ async function main() {
|
||||
setIngest990pfDeps({ db });
|
||||
setFederalPrecedentDeps({ db });
|
||||
setProfileOrgsDeps({ db });
|
||||
setJudgeMatchesDeps({ db });
|
||||
|
||||
DBOS.setConfig({
|
||||
name: 'helmdocs-outreach-worker',
|
||||
|
||||
83
apps/outreach-worker/src/sources/usaspending/client.test.ts
Normal file
83
apps/outreach-worker/src/sources/usaspending/client.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { fetchProgramStateAwards } from './client.js';
|
||||
|
||||
function pageResponse(
|
||||
names: string[],
|
||||
total: number,
|
||||
hasNext: boolean,
|
||||
): Response {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: names.map((n) => ({
|
||||
'Recipient Name': n,
|
||||
'Award Amount': 1000.4,
|
||||
'Start Date': '2024-01-01',
|
||||
})),
|
||||
page_metadata: { total, hasNext },
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
describe('fetchProgramStateAwards', () => {
|
||||
it('paginates until hasNext is false and keeps every recipient', async () => {
|
||||
const pages = [
|
||||
pageResponse(Array.from({ length: 100 }, (_, i) => `ORG ${i}`), 130, true),
|
||||
pageResponse(Array.from({ length: 30 }, (_, i) => `ORG ${100 + i}`), 130, false),
|
||||
];
|
||||
const bodies: number[] = [];
|
||||
const fetchImpl: typeof fetch = async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as { page: number };
|
||||
bodies.push(body.page);
|
||||
return pages[body.page - 1]!;
|
||||
};
|
||||
|
||||
const result = await fetchProgramStateAwards(
|
||||
'93.847',
|
||||
'NH',
|
||||
{ startDate: '2023-01-01', endDate: '2026-01-01' },
|
||||
{ fetchImpl, politenessDelayMs: 0 },
|
||||
);
|
||||
|
||||
expect(bodies).toEqual([1, 2]);
|
||||
expect(result.awardCount).toBe(130);
|
||||
expect(result.samples).toHaveLength(130);
|
||||
expect(result.samples[0]!.amount).toBe(1000);
|
||||
expect(result.samples.at(-1)!.recipientName).toBe('ORG 129');
|
||||
});
|
||||
|
||||
it('stops at the fetch cap even when more pages exist', async () => {
|
||||
const fetchImpl: typeof fetch = async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as { page: number };
|
||||
return pageResponse(
|
||||
Array.from({ length: 100 }, (_, i) => `ORG ${(body.page - 1) * 100 + i}`),
|
||||
900,
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
const result = await fetchProgramStateAwards(
|
||||
'93.847',
|
||||
'NH',
|
||||
{ startDate: '2023-01-01', endDate: '2026-01-01' },
|
||||
{ fetchImpl, politenessDelayMs: 0, maxAwardsFetched: 250 },
|
||||
);
|
||||
|
||||
expect(result.samples).toHaveLength(300); // 3 full pages, cap crossed on page 3
|
||||
expect(result.awardCount).toBe(900);
|
||||
});
|
||||
|
||||
it('throws on a failed page', async () => {
|
||||
const fetchImpl: typeof fetch = async () =>
|
||||
new Response('nope', { status: 502, statusText: 'Bad Gateway' });
|
||||
await expect(
|
||||
fetchProgramStateAwards(
|
||||
'93.847',
|
||||
'NH',
|
||||
{ startDate: '2023-01-01', endDate: '2026-01-01' },
|
||||
{ fetchImpl, politenessDelayMs: 0 },
|
||||
),
|
||||
).rejects.toThrow(/502/);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,15 @@ const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05'];
|
||||
|
||||
const DEFAULT_POLITENESS_DELAY_MS = 300;
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
/**
|
||||
* Recipient-list fetch cap. Peer classification runs over the fetched
|
||||
* window, so a program with >500 NH awards gets its peer count computed
|
||||
* on the first 500 — an undercount for the very largest programs, which
|
||||
* is the conservative direction (see classifyPeerAwards in outreach-core).
|
||||
*/
|
||||
const MAX_AWARDS_FETCHED = 500;
|
||||
|
||||
export interface ProgramStateAwardSample {
|
||||
readonly recipientName: string;
|
||||
readonly amount: number | null;
|
||||
@@ -20,24 +29,38 @@ export interface ProgramStateAwardSample {
|
||||
|
||||
export interface ProgramStateAwards {
|
||||
readonly aln: string;
|
||||
/** Total matching awards per the API (may exceed `samples.length`). */
|
||||
readonly awardCount: number;
|
||||
/** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */
|
||||
readonly samples: ProgramStateAwardSample[];
|
||||
}
|
||||
|
||||
export interface UsaSpendingClientOptions {
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly politenessDelayMs?: number;
|
||||
/** Test hook; production uses MAX_AWARDS_FETCHED. */
|
||||
readonly maxAwardsFetched?: number;
|
||||
}
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
interface SpendingByAwardResponse {
|
||||
results?: Array<{
|
||||
'Recipient Name'?: string | null;
|
||||
'Award Amount'?: number | null;
|
||||
'Start Date'?: string | null;
|
||||
}>;
|
||||
page_metadata?: { total?: number | null; hasNext?: boolean | null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent grant awards under one Assistance Listing to recipients in one
|
||||
* state: total count plus a handful of sample recipients (review-page
|
||||
* evidence — lets a human spot "one incumbent's renewals" vs "spread
|
||||
* across orgs like ours").
|
||||
* state: total count plus the full fetched recipient list. The list feeds
|
||||
* two consumers — the review-page evidence table, and peer classification
|
||||
* ("did orgs like ours win this, or just Dartmouth?"), which needs every
|
||||
* recipient, hence pagination instead of the old single 10-row page.
|
||||
*/
|
||||
export async function fetchProgramStateAwards(
|
||||
aln: string,
|
||||
@@ -46,7 +69,13 @@ export async function fetchProgramStateAwards(
|
||||
options: UsaSpendingClientOptions = {},
|
||||
): Promise<ProgramStateAwards> {
|
||||
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, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -58,36 +87,34 @@ export async function fetchProgramStateAwards(
|
||||
program_numbers: [aln],
|
||||
},
|
||||
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
|
||||
limit: 10,
|
||||
page: 1,
|
||||
limit: PAGE_SIZE,
|
||||
page,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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 {
|
||||
results?: Array<{
|
||||
'Recipient Name'?: string | null;
|
||||
'Award Amount'?: number | null;
|
||||
'Start Date'?: string | null;
|
||||
}>;
|
||||
page_metadata?: { total?: number | null };
|
||||
};
|
||||
|
||||
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
|
||||
|
||||
const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({
|
||||
const body = (await res.json()) as SpendingByAwardResponse;
|
||||
const results = body.results ?? [];
|
||||
for (const r of results) {
|
||||
samples.push({
|
||||
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
|
||||
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
|
||||
startDate: r['Start Date'] ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
aln,
|
||||
awardCount: body.page_metadata?.total ?? samples.length,
|
||||
samples,
|
||||
};
|
||||
});
|
||||
}
|
||||
total = Math.max(body.page_metadata?.total ?? 0, samples.length);
|
||||
|
||||
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
|
||||
|
||||
const hasNext =
|
||||
body.page_metadata?.hasNext ?? results.length === PAGE_SIZE;
|
||||
if (!hasNext || results.length === 0 || samples.length >= maxAwards) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
return { aln, awardCount: total, samples };
|
||||
}
|
||||
|
||||
@@ -2,20 +2,28 @@
|
||||
* Nightly federal-precedent workflow — USASpending award history per
|
||||
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
|
||||
*
|
||||
* For each distinct ALN across open federal grants: count grant awards to
|
||||
* recipients in the target state over the last ~3 fiscal years, stamp the
|
||||
* count + sample recipients onto every open grant carrying that ALN.
|
||||
* Match retrieval feeds the count into the same 25-point precedent
|
||||
* subscore foundations use — "this program funded N NH orgs recently" and
|
||||
* For each distinct ALN across open federal grants: fetch grant awards to
|
||||
* recipients in the target state over the last ~3 fiscal years, classify
|
||||
* each recipient as peer/non-peer against the primary-ICP NH registry
|
||||
* (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not
|
||||
* precedent for a community nonprofit), and stamp raw count, PEER count,
|
||||
* and the annotated award list onto every open grant carrying that ALN.
|
||||
* Match retrieval feeds the peer count into the same 25-point precedent
|
||||
* subscore foundations use — "this program funded N orgs like ours" and
|
||||
* "this foundation funded N NH orgs recently" are the same signal.
|
||||
*
|
||||
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
|
||||
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
|
||||
*/
|
||||
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
|
||||
import type { schema } from '@novelpad/outreach-core';
|
||||
import {
|
||||
buildPeerNameSet,
|
||||
classifyPeerAwards,
|
||||
type schema,
|
||||
} from '@novelpad/outreach-core';
|
||||
import {
|
||||
serverListOpenFederalAlns,
|
||||
serverListPeerOrgNames,
|
||||
serverResetFederalPrecedent,
|
||||
serverSetFederalPrecedent,
|
||||
} from '@novelpad/outreach-core/server';
|
||||
@@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, {
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
async function listPeerNames(db: OutreachDb): Promise<string[]> {
|
||||
return serverListPeerOrgNames(db);
|
||||
}
|
||||
const listPeerNamesStep = DBOS.registerStep(listPeerNames, {
|
||||
name: 'listPeerOrgNames',
|
||||
retriesAllowed: true,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
async function fetchAlnAwards(
|
||||
aln: string,
|
||||
startDate: string,
|
||||
@@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
|
||||
|
||||
async function applyPrecedent(
|
||||
db: OutreachDb,
|
||||
precedent: ProgramStateAwards,
|
||||
precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown },
|
||||
): Promise<void> {
|
||||
await serverSetFederalPrecedent(db, {
|
||||
aln: precedent.aln,
|
||||
awardCount: precedent.awardCount,
|
||||
peerAwardCount: precedent.peerAwardCount,
|
||||
samples: precedent.samples,
|
||||
});
|
||||
}
|
||||
@@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const peerNames = buildPeerNameSet(await listPeerNamesStep(db));
|
||||
console.log(
|
||||
`[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`,
|
||||
);
|
||||
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
|
||||
@@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise<void> {
|
||||
await resetPrecedentStep(db);
|
||||
|
||||
let programsWithHistory = 0;
|
||||
let programsWithPeers = 0;
|
||||
let failed = 0;
|
||||
for (const aln of alns) {
|
||||
try {
|
||||
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate);
|
||||
if (precedent.awardCount > 0) {
|
||||
await applyPrecedentStep(db, precedent);
|
||||
const fetched = await fetchAlnAwardsStep(aln, startDate, endDate);
|
||||
if (fetched.awardCount > 0) {
|
||||
// Pure classification over step outputs — deterministic on replay,
|
||||
// so it doesn't need to be a step itself.
|
||||
const { peerAwardCount, awards } = classifyPeerAwards(
|
||||
fetched.samples,
|
||||
peerNames,
|
||||
);
|
||||
await applyPrecedentStep(db, {
|
||||
...fetched,
|
||||
peerAwardCount,
|
||||
samples: awards,
|
||||
});
|
||||
programsWithHistory++;
|
||||
if (peerAwardCount > 0) programsWithPeers++;
|
||||
}
|
||||
} catch (err) {
|
||||
failed++;
|
||||
@@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`,
|
||||
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} withPeerHistory=${programsWithPeers} failed=${failed}`,
|
||||
);
|
||||
if (failed > 0 && failed / alns.length > 0.2) {
|
||||
throw new Error(
|
||||
|
||||
230
apps/outreach-worker/src/workflows/judge-matches.ts
Normal file
230
apps/outreach-worker/src/workflows/judge-matches.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Nightly mission-fit judge workflow — the LLM verification layer over
|
||||
* the embedding-ranked federal queue.
|
||||
*
|
||||
* Embedding similarity + deterministic subscores get a match NEAR the
|
||||
* truth; they cannot read an NIH synopsis and notice that "any nonprofit
|
||||
* may apply" is hiding a research-center program no summer camp will ever
|
||||
* run. The judge reads the actual synopsis against the org's (researched
|
||||
* or stub) profile and issues a graded verdict; deterministic code maps
|
||||
* verdict → mission-fit points, recomputes total/easy-win, and flips
|
||||
* queue viability (see apply-match-judgment). Judged rows keep their
|
||||
* verdict across nightly re-scores (see upsert-match-score) and re-enter
|
||||
* this queue only when their org profile is re-researched.
|
||||
*
|
||||
* Cost bound: `JUDGE_MATCHES_PER_RUN` (default 200) × one JUDGE_MODEL
|
||||
* call, best-scoring matches first — the head of the human review queue
|
||||
* is always verified before a human reads it.
|
||||
*
|
||||
* Runs at 06:15, after matchGrants (05:15) and profileOrgs (05:45).
|
||||
* Registration follows `ingest-grants.ts` exactly.
|
||||
*/
|
||||
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
|
||||
import {
|
||||
missionFitFromVerdict,
|
||||
runMatchJudge,
|
||||
verdictIsFitViable,
|
||||
type MatchJudgeVerdict,
|
||||
} from '@novelpad/outreach-ai';
|
||||
import type { schema } from '@novelpad/outreach-core';
|
||||
import {
|
||||
serverApplyMatchJudgment,
|
||||
serverListMatchesNeedingJudgment,
|
||||
type MatchNeedingJudgment,
|
||||
} from '@novelpad/outreach-core/server';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
export type OutreachDb = NodePgDatabase<typeof schema>;
|
||||
|
||||
const DEFAULT_MATCHES_PER_RUN = 200;
|
||||
|
||||
export interface JudgeMatchesDeps {
|
||||
readonly db: OutreachDb;
|
||||
}
|
||||
|
||||
let registeredDeps: JudgeMatchesDeps | null = null;
|
||||
|
||||
export function setJudgeMatchesDeps(deps: JudgeMatchesDeps): void {
|
||||
registeredDeps = deps;
|
||||
}
|
||||
|
||||
function getJudgeMatchesDeps(): JudgeMatchesDeps {
|
||||
if (registeredDeps == null) {
|
||||
throw new Error(
|
||||
'JudgeMatchesDeps not registered. Call setJudgeMatchesDeps() before DBOS.launch().',
|
||||
);
|
||||
}
|
||||
return registeredDeps;
|
||||
}
|
||||
|
||||
/** Coerces the org_profiles.programs jsonb (Stage 4 shape) into judge input. */
|
||||
export function programsFromProfileJson(
|
||||
programs: unknown,
|
||||
): Array<{ name: string; description: string | null; populationServed: string | null }> {
|
||||
if (!Array.isArray(programs)) return [];
|
||||
const out: Array<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
populationServed: string | null;
|
||||
}> = [];
|
||||
for (const entry of programs) {
|
||||
if (entry == null || typeof entry !== 'object') continue;
|
||||
const p = entry as Record<string, unknown>;
|
||||
if (typeof p.name !== 'string' || p.name === '') continue;
|
||||
out.push({
|
||||
name: p.name,
|
||||
description: typeof p.description === 'string' ? p.description : null,
|
||||
populationServed:
|
||||
typeof p.populationServed === 'string' ? p.populationServed : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function listCandidates(
|
||||
db: OutreachDb,
|
||||
limit: number,
|
||||
): Promise<MatchNeedingJudgment[]> {
|
||||
return serverListMatchesNeedingJudgment(db, { limit });
|
||||
}
|
||||
const listCandidatesStep = DBOS.registerStep(listCandidates, {
|
||||
name: 'listMatchesNeedingJudgment',
|
||||
retriesAllowed: true,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
async function judgeOne(
|
||||
candidate: MatchNeedingJudgment,
|
||||
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
|
||||
return runMatchJudge({
|
||||
org: {
|
||||
name: candidate.orgName,
|
||||
city: candidate.orgCity,
|
||||
missionStatement: candidate.missionStatement,
|
||||
programs: programsFromProfileJson(candidate.programs),
|
||||
serviceGeography: candidate.serviceGeography,
|
||||
profileConfidence: candidate.profileConfidence,
|
||||
},
|
||||
grant: {
|
||||
title: candidate.grantTitle,
|
||||
funder: candidate.grantFunder,
|
||||
synopsis: candidate.grantSynopsis,
|
||||
},
|
||||
});
|
||||
}
|
||||
const judgeOneStep = DBOS.registerStep(judgeOne, {
|
||||
name: 'judgeMatch',
|
||||
retriesAllowed: true,
|
||||
maxAttempts: 2,
|
||||
});
|
||||
|
||||
async function applyJudgment(
|
||||
db: OutreachDb,
|
||||
matchId: string,
|
||||
judged: { verdict: MatchJudgeVerdict; model: string },
|
||||
): Promise<void> {
|
||||
await serverApplyMatchJudgment(db, {
|
||||
matchId,
|
||||
verdict: judged.verdict.verdict,
|
||||
judgedMissionFit: missionFitFromVerdict(judged.verdict.verdict),
|
||||
fitViable: verdictIsFitViable(judged.verdict.verdict),
|
||||
rationale: [
|
||||
judged.verdict.reasoning,
|
||||
`Org evidence: ${judged.verdict.citedOrgEvidence}`,
|
||||
`Grant evidence: ${judged.verdict.citedGrantEvidence}`,
|
||||
].join('\n'),
|
||||
model: judged.model,
|
||||
});
|
||||
}
|
||||
const applyJudgmentStep = DBOS.registerStep(applyJudgment, {
|
||||
name: 'applyMatchJudgment',
|
||||
retriesAllowed: true,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
async function runJudgeMatches(): Promise<void> {
|
||||
const { db } = getJudgeMatchesDeps();
|
||||
|
||||
const limit = Number(
|
||||
process.env.JUDGE_MATCHES_PER_RUN ?? DEFAULT_MATCHES_PER_RUN,
|
||||
);
|
||||
const candidates = await listCandidatesStep(db, limit);
|
||||
if (candidates.length === 0) {
|
||||
console.log('[judge-matches] queue head fully judged');
|
||||
return;
|
||||
}
|
||||
|
||||
const byVerdict: Record<string, number> = {};
|
||||
let failed = 0;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const judged = await judgeOneStep(candidate);
|
||||
await applyJudgmentStep(db, candidate.matchId, judged);
|
||||
byVerdict[judged.verdict.verdict] =
|
||||
(byVerdict[judged.verdict.verdict] ?? 0) + 1;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(
|
||||
`[judge-matches] "${candidate.orgName}" × "${candidate.grantTitle}" failed:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[judge-matches] judged=${candidates.length - failed} failed=${failed} verdicts=${JSON.stringify(byVerdict)}`,
|
||||
);
|
||||
if (failed > 0 && failed / candidates.length > 0.2) {
|
||||
throw new Error(
|
||||
`[judge-matches] systemic failure: ${failed}/${candidates.length} matches failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const g = globalThis as unknown as {
|
||||
__outreachJudgeMatchesRegistered?: boolean;
|
||||
__outreachJudgeMatchesHandle?: (
|
||||
scheduledTime: Date,
|
||||
startedAt: Date,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
if (!g.__outreachJudgeMatchesRegistered) {
|
||||
g.__outreachJudgeMatchesRegistered = true;
|
||||
|
||||
const judgeMatches = async (_scheduledTime: Date, _startedAt: Date) => {
|
||||
try {
|
||||
await runJudgeMatches();
|
||||
} catch (err) {
|
||||
console.error('[judge-matches] pass failed:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Must be registered as BOTH a workflow and a scheduled function,
|
||||
// referencing the same function object — see ingest-grants.ts.
|
||||
g.__outreachJudgeMatchesHandle = DBOS.registerWorkflow(judgeMatches, {
|
||||
name: 'judgeMatches',
|
||||
});
|
||||
DBOS.registerScheduled(judgeMatches, {
|
||||
crontab: '15 6 * * *',
|
||||
name: 'judgeMatches',
|
||||
mode: SchedulerMode.ExactlyOncePerInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts one durable run of this workflow immediately through DBOS —
|
||||
* the exact production path (workflow + checkpointed steps), used by
|
||||
* `run-once.ts` for supervised/manual passes. Requires deps injected and
|
||||
* `DBOS.launch()` completed.
|
||||
*/
|
||||
export function runJudgeMatchesNow(): Promise<void> {
|
||||
const handle = g.__outreachJudgeMatchesHandle;
|
||||
if (handle == null) {
|
||||
throw new Error(
|
||||
'judgeMatches is not registered; was this module imported before DBOS.launch()?',
|
||||
);
|
||||
}
|
||||
return handle(new Date(), new Date());
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { generateQueryEmbedding } from '@novelpad/outreach-ai';
|
||||
import {
|
||||
buildOrgMissionText,
|
||||
evaluateHardGates,
|
||||
normalizeOrgNameForMatching,
|
||||
scoreMatch,
|
||||
type HardGateFailureReason,
|
||||
type schema,
|
||||
@@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
|
||||
maxAttempts: 3,
|
||||
});
|
||||
|
||||
/** Case/punctuation/suffix-insensitive equality for self-match detection. */
|
||||
function normalizeSelfMatchName(name: string): string {
|
||||
return name
|
||||
.toUpperCase()
|
||||
.replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '')
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
async function scoreAndStoreOrgMatches(
|
||||
db: OutreachDb,
|
||||
org: MatchCandidateOrg,
|
||||
@@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches(
|
||||
grant.funderEin === org.ein;
|
||||
const isSelfByName =
|
||||
grant.funderEin != null &&
|
||||
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name);
|
||||
normalizeOrgNameForMatching(grant.funder) ===
|
||||
normalizeOrgNameForMatching(org.name);
|
||||
if (isSelfByEin || isSelfByName) {
|
||||
gated++;
|
||||
continue;
|
||||
@@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches(
|
||||
applicationEffortEstimate: grant.applicationEffortEstimate,
|
||||
closeDate: grant.closeDate,
|
||||
now,
|
||||
grantSource: grant.source,
|
||||
});
|
||||
|
||||
await serverUpsertMatchScore(db, {
|
||||
@@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches(
|
||||
subscores: scored.subscores,
|
||||
hardGatesPassed,
|
||||
easyWin: scored.easyWin,
|
||||
fitViable: scored.fitViable,
|
||||
rationale: {
|
||||
similarity: grant.similarity,
|
||||
gateFailures: gates.failures,
|
||||
ignoredGates: [...IGNORED_GATES],
|
||||
scoredAt: now.toISOString(),
|
||||
scoringVersion: 'v2-state-precedent',
|
||||
scoringVersion: 'v3-peer-precedent',
|
||||
},
|
||||
});
|
||||
stored++;
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
/** 1–3 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 (0–30 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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "org_profiles" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();
|
||||
1532
packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json
Normal file
1532
packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1539
packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json
Normal file
1539
packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,20 @@
|
||||
"when": 1784257140142,
|
||||
"tag": "1784257140_federal-precedent",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -81,6 +81,13 @@ export const matchReviewStatusEnum = pgEnum('match_review_status', [
|
||||
'edited',
|
||||
]);
|
||||
|
||||
export const matchJudgeVerdictEnum = pgEnum('match_judge_verdict', [
|
||||
'strong_fit',
|
||||
'plausible',
|
||||
'weak',
|
||||
'mismatch',
|
||||
]);
|
||||
|
||||
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
|
||||
'wrong_eligibility',
|
||||
'wrong_geography',
|
||||
@@ -151,7 +158,14 @@ export const grants = pgTable(
|
||||
// refreshed by the federalPrecedent workflow. Null for non-federal
|
||||
// sources and never touched by ingest upserts.
|
||||
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'),
|
||||
status: grantStatusEnum('status').notNull().default('open'),
|
||||
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
|
||||
@@ -240,6 +254,9 @@ export const orgProfiles = pgTable(
|
||||
confidence: real('confidence'),
|
||||
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
|
||||
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) => [
|
||||
// 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),
|
||||
// LLM-produced citations backing the score/subscores.
|
||||
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')
|
||||
.notNull()
|
||||
.default('pending'),
|
||||
|
||||
@@ -5,15 +5,20 @@ import { schema } from '#~/db/db.js';
|
||||
|
||||
export interface AlnPrecedent {
|
||||
readonly aln: string;
|
||||
/** Raw NH award count for the program (context/evidence only). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies per-program USASpending precedent onto every open federal grant
|
||||
* carrying that ALN. Grants with multiple ALNs keep the HIGHEST count
|
||||
* seen (a grant reachable through any strongly-NH program inherits that
|
||||
* program's precedent), and samples follow whichever count won.
|
||||
* carrying that ALN. Grants with multiple ALNs keep the strongest program
|
||||
* seen, where "strongest" is the PEER count — ten Dartmouth renewals must
|
||||
* not outrank three community-nonprofit wins — with raw count and samples
|
||||
* following whichever peer count won.
|
||||
*/
|
||||
export async function serverSetFederalPrecedent(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
@@ -22,9 +27,14 @@ export async function serverSetFederalPrecedent(
|
||||
await db
|
||||
.update(schema.grants)
|
||||
.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
|
||||
WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount}
|
||||
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
|
||||
THEN ${JSON.stringify(precedent.samples)}::jsonb
|
||||
ELSE ${schema.grants.programStateAwards}
|
||||
END`,
|
||||
@@ -45,6 +55,10 @@ export async function serverResetFederalPrecedent(
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.grants)
|
||||
.set({ programStateAwardCount: null, programStateAwards: null })
|
||||
.set({
|
||||
programStateAwardCount: null,
|
||||
programStatePeerAwardCount: null,
|
||||
programStateAwards: null,
|
||||
})
|
||||
.where(eq(schema.grants.source, 'grants_gov'));
|
||||
}
|
||||
|
||||
59
packages/outreach-core/src/grants/peer-precedent.test.ts
Normal file
59
packages/outreach-core/src/grants/peer-precedent.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
53
packages/outreach-core/src/grants/peer-precedent.ts
Normal file
53
packages/outreach-core/src/grants/peer-precedent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -18,8 +18,15 @@ export interface EligibleGrantWithSimilarity {
|
||||
| 'unknown';
|
||||
applicationFormSupported: boolean;
|
||||
funderEin: string | null;
|
||||
source: string;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,6 +68,7 @@ export async function serverListEligibleGrantsForOrg(
|
||||
applicationEffortEstimate: schema.grants.applicationEffortEstimate,
|
||||
applicationFormSupported: schema.grants.applicationFormSupported,
|
||||
funderEin: schema.grants.funderEin,
|
||||
source: schema.grants.source,
|
||||
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
|
||||
funderStateGrantCount: sql<number | null>`CASE
|
||||
WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
|
||||
@@ -69,7 +77,7 @@ export async function serverListEligibleGrantsForOrg(
|
||||
WHERE f.ein = ${schema.grants.funderEin}
|
||||
AND fg.recipient_state = ${filters.orgState}
|
||||
)
|
||||
ELSE ${schema.grants.programStateAwardCount}
|
||||
ELSE ${schema.grants.programStatePeerAwardCount}
|
||||
END`,
|
||||
})
|
||||
.from(schema.grants)
|
||||
|
||||
@@ -10,3 +10,5 @@ export * from './matches/hard-gates.js';
|
||||
export * from './orgs/icp-band.js';
|
||||
export * from './matches/scoring.js';
|
||||
export * from './orgs/ntee.js';
|
||||
export * from './orgs/org-name.js';
|
||||
export * from './grants/peer-precedent.js';
|
||||
|
||||
@@ -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 0–30 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}`);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export async function serverAssignHeroMatch(
|
||||
WHERE org_id = ${orgId}
|
||||
AND review_status != 'rejected'
|
||||
AND hard_gates_passed = true
|
||||
AND fit_viable = true
|
||||
ORDER BY easy_win DESC, total_score DESC, created_at ASC
|
||||
LIMIT 1
|
||||
)
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './insert-match.server.js';
|
||||
export * from './set-match-review.server.js';
|
||||
export * from './upsert-match-score.server.js';
|
||||
export * from './assign-hero-matches.server.js';
|
||||
export * from './apply-match-judgment.server.js';
|
||||
|
||||
@@ -2,7 +2,11 @@ import { sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } 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 {
|
||||
readonly orgId: string;
|
||||
@@ -11,6 +15,7 @@ export interface MatchScoreInput {
|
||||
readonly subscores: MatchSubscores;
|
||||
readonly hardGatesPassed: boolean;
|
||||
readonly easyWin: boolean;
|
||||
readonly fitViable: boolean;
|
||||
/** Gate failures, similarity, and any judge citations — audit trail. */
|
||||
readonly rationale: unknown;
|
||||
}
|
||||
@@ -22,11 +27,23 @@ export interface MatchScoreInput {
|
||||
* nightly re-score moves the numbers — resurfacing rejected matches would
|
||||
* erode the review queue's trust, and re-approving approved ones is
|
||||
* 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(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
match: MatchScoreInput,
|
||||
): 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
|
||||
.insert(schema.matches)
|
||||
.values({
|
||||
@@ -36,16 +53,24 @@ export async function serverUpsertMatchScore(
|
||||
subscores: match.subscores,
|
||||
hardGatesPassed: match.hardGatesPassed,
|
||||
easyWin: match.easyWin,
|
||||
fitViable: match.fitViable,
|
||||
rationale: match.rationale,
|
||||
reviewStatus: 'pending',
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.matches.orgId, schema.matches.grantId],
|
||||
set: {
|
||||
totalScore: sql`excluded.total_score`,
|
||||
subscores: sql`excluded.subscores`,
|
||||
totalScore: sql`CASE WHEN ${judged} THEN ${judgedTotal} ELSE excluded.total_score END`,
|
||||
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`,
|
||||
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`,
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface MatchDetail {
|
||||
rationale: unknown;
|
||||
easyWin: boolean;
|
||||
isHero: boolean;
|
||||
fitViable: boolean;
|
||||
judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null;
|
||||
judgeRationale: string | null;
|
||||
judgedAt: Date | null;
|
||||
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||
};
|
||||
org: {
|
||||
@@ -56,6 +60,9 @@ export interface MatchDetail {
|
||||
amount: number | null;
|
||||
purpose: string | null;
|
||||
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,
|
||||
easyWin: schema.matches.easyWin,
|
||||
isHero: schema.matches.isHero,
|
||||
fitViable: schema.matches.fitViable,
|
||||
judgeVerdict: schema.matches.judgeVerdict,
|
||||
judgeRationale: schema.matches.judgeRationale,
|
||||
judgedAt: schema.matches.judgedAt,
|
||||
reviewStatus: schema.matches.reviewStatus,
|
||||
orgId: schema.orgs.id,
|
||||
orgName: schema.orgs.name,
|
||||
@@ -123,19 +134,29 @@ export async function serverGetMatchDetail(
|
||||
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
|
||||
// Federal grants: USASpending program awards fill the same evidence
|
||||
// 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 = (
|
||||
row.grantProgramAwards as Array<{
|
||||
recipientName?: string;
|
||||
amount?: number | null;
|
||||
startDate?: string | null;
|
||||
isPeer?: boolean;
|
||||
}>
|
||||
).map((a) => ({
|
||||
)
|
||||
.map((a) => ({
|
||||
recipientName: a.recipientName ?? 'Unknown recipient',
|
||||
recipientCity: null,
|
||||
amount: a.amount ?? null,
|
||||
purpose: null,
|
||||
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) {
|
||||
const funderRow = await db
|
||||
@@ -170,7 +191,7 @@ export async function serverGetMatchDetail(
|
||||
return aIn - bIn;
|
||||
})
|
||||
.slice(0, 40)
|
||||
.map(({ recipientState: _s, ...rest }) => rest);
|
||||
.map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null }));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +202,10 @@ export async function serverGetMatchDetail(
|
||||
rationale: row.rationale,
|
||||
easyWin: row.easyWin,
|
||||
isHero: row.isHero,
|
||||
fitViable: row.fitViable,
|
||||
judgeVerdict: row.judgeVerdict,
|
||||
judgeRationale: row.judgeRationale,
|
||||
judgedAt: row.judgedAt,
|
||||
reviewStatus: row.reviewStatus,
|
||||
},
|
||||
org: {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './list-pending-review-matches.server.js';
|
||||
export * from './get-match-detail.server.js';
|
||||
export * from './list-matches-needing-judgment.server.js';
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -44,11 +44,15 @@ export async function serverListPendingReviewMatches(
|
||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||
.where(
|
||||
source == null
|
||||
? eq(schema.matches.reviewStatus, status)
|
||||
: and(
|
||||
and(
|
||||
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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
competitionSubscore,
|
||||
EASY_WIN_THRESHOLD,
|
||||
effortSubscore,
|
||||
MISSION_FIT_VIABLE_MIN,
|
||||
missionFitSubscore,
|
||||
runwaySubscore,
|
||||
scoreMatch,
|
||||
@@ -102,6 +103,7 @@ describe('scoreMatch', () => {
|
||||
closeDate: weeksFromNow(6),
|
||||
now: NOW,
|
||||
funderStateGrantCount: null,
|
||||
grantSource: 'irs_990pf',
|
||||
});
|
||||
// 30+15+15+10+5 = 75 — over the threshold but no precedent floor.
|
||||
expect(result.totalScore).toBe(75);
|
||||
@@ -118,6 +120,7 @@ describe('scoreMatch', () => {
|
||||
closeDate: weeksFromNow(6),
|
||||
now: NOW,
|
||||
funderStateGrantCount: 6,
|
||||
grantSource: 'irs_990pf',
|
||||
});
|
||||
// 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway
|
||||
expect(result.totalScore).toBe(93);
|
||||
@@ -135,8 +138,81 @@ describe('scoreMatch', () => {
|
||||
closeDate: weeksFromNow(2),
|
||||
now: NOW,
|
||||
funderStateGrantCount: null,
|
||||
grantSource: 'grants_gov',
|
||||
});
|
||||
expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,12 +39,15 @@ export interface ScoreMatchInput {
|
||||
readonly now: Date;
|
||||
/**
|
||||
* Historical grants this funder has paid to recipients in the org's
|
||||
* state (from the 990-PF index). Null = no precedent data for this
|
||||
* grant's funder (e.g. federal agencies) — scores 0, not neutral: the
|
||||
* plan weights precedent as the strongest single predictor, and absence
|
||||
* of evidence should rank below presence.
|
||||
* state (990-PF index for foundations; USASpending PEER award count for
|
||||
* federal programs — recipients name-matched to primary-ICP NH orgs).
|
||||
* Null = no precedent data — scores 0, not neutral: the plan weights
|
||||
* precedent as the strongest single predictor, and absence of evidence
|
||||
* should rank below presence.
|
||||
*/
|
||||
readonly funderStateGrantCount: number | null;
|
||||
/** Grant source (`grants.source`) — drives the source-aware fit floor. */
|
||||
readonly grantSource: string;
|
||||
}
|
||||
|
||||
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_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. */
|
||||
const SIMILARITY_FLOOR = 0.45;
|
||||
const SIMILARITY_CEILING = 0.75;
|
||||
@@ -154,6 +169,13 @@ export interface ScoredMatch {
|
||||
readonly totalScore: number;
|
||||
readonly subscores: MatchSubscores;
|
||||
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 {
|
||||
@@ -174,11 +196,17 @@ export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
|
||||
subscores.runway +
|
||||
subscores.funderPrecedent;
|
||||
|
||||
const fitViable =
|
||||
!FIT_FLOOR_SOURCES.has(input.grantSource) ||
|
||||
subscores.missionFit >= MISSION_FIT_VIABLE_MIN;
|
||||
|
||||
return {
|
||||
totalScore,
|
||||
subscores,
|
||||
easyWin:
|
||||
fitViable &&
|
||||
totalScore >= EASY_WIN_THRESHOLD &&
|
||||
subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT,
|
||||
fitViable,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export async function serverApplyResearchedProfile(
|
||||
sources: sql`excluded.sources`,
|
||||
confidence: sql`excluded.confidence`,
|
||||
profileEmbedding: sql`excluded.profile_embedding`,
|
||||
// Staleness signal for the match judge: judgments older than this
|
||||
// re-enter the judge queue.
|
||||
updatedAt: sql`now()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
22
packages/outreach-core/src/orgs/org-name.ts
Normal file
22
packages/outreach-core/src/orgs/org-name.ts
Normal 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, '');
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './list-orgs-in-icp-band.server.js';
|
||||
export * from './list-orgs-needing-enrichment.server.js';
|
||||
export * from './list-match-candidate-orgs.server.js';
|
||||
export * from './list-orgs-needing-profile.server.js';
|
||||
export * from './list-peer-org-names.server.js';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user