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,
|
runway: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const JUDGE_VERDICT_STYLE: Record<string, string> = {
|
||||||
|
strong_fit: 'bg-green-100 text-green-800',
|
||||||
|
plausible: 'bg-blue-100 text-blue-800',
|
||||||
|
weak: 'bg-amber-100 text-amber-800',
|
||||||
|
mismatch: 'bg-red-100 text-red-800',
|
||||||
|
};
|
||||||
|
|
||||||
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||||||
const { detail } = loaderData ?? {};
|
const { detail } = loaderData ?? {};
|
||||||
if (detail == null) return null;
|
if (detail == null) return null;
|
||||||
@@ -95,6 +102,12 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
{match.easyWin ? ' · Easy win' : ''}
|
{match.easyWin ? ' · Easy win' : ''}
|
||||||
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
|
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
|
||||||
</p>
|
</p>
|
||||||
|
{!match.fitViable && (
|
||||||
|
<p className="mt-1 text-sm font-medium text-red-700">
|
||||||
|
Hidden from the pending queue — mission fit below floor or
|
||||||
|
judged non-viable.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Form method="post">
|
<Form method="post">
|
||||||
@@ -289,6 +302,26 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{match.judgeVerdict != null && (
|
||||||
|
<section className="rounded border p-4">
|
||||||
|
<h2 className="mb-2 text-lg font-semibold">
|
||||||
|
Mission-fit judge{' '}
|
||||||
|
<span
|
||||||
|
className={`ml-1 rounded px-2 py-0.5 text-sm font-medium ${JUDGE_VERDICT_STYLE[match.judgeVerdict] ?? ''}`}
|
||||||
|
>
|
||||||
|
{match.judgeVerdict.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{match.judgeRationale}</p>
|
||||||
|
{match.judgedAt != null && (
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
Judged {new Date(match.judgedAt).toLocaleDateString('en-US')} —
|
||||||
|
the verdict replaces the embedding mission-fit score above.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{applyInfo != null && (
|
{applyInfo != null && (
|
||||||
<section className="rounded border p-4">
|
<section className="rounded border p-4">
|
||||||
<h2 className="mb-2 text-lg font-semibold">
|
<h2 className="mb-2 text-lg font-semibold">
|
||||||
@@ -346,7 +379,7 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
The concrete evidence behind the precedent score —{' '}
|
The concrete evidence behind the precedent score —{' '}
|
||||||
{grant.source === 'irs_990pf'
|
{grant.source === 'irs_990pf'
|
||||||
? 'who this funder actually paid, sorted in-state first.'
|
? 'who this funder actually paid, sorted in-state first.'
|
||||||
: 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'}
|
: 'who this federal program actually funded in NH recently, peers first. Only "peer" recipients (registered NH nonprofits our size) count toward the precedent score — universities, hospital systems, and companies do not.'}
|
||||||
</p>
|
</p>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -361,7 +394,14 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{nhHistory.map((g, i) => (
|
{nhHistory.map((g, i) => (
|
||||||
<tr key={i} className="border-b align-top">
|
<tr key={i} className="border-b align-top">
|
||||||
<td className="py-1 pr-2">{g.recipientName}</td>
|
<td className="py-1 pr-2">
|
||||||
|
{g.recipientName}
|
||||||
|
{g.isPeer === true && (
|
||||||
|
<span className="ml-1 rounded bg-green-100 px-1 text-xs font-medium text-green-800">
|
||||||
|
peer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-1 pr-2">{g.recipientCity ?? '—'}</td>
|
<td className="py-1 pr-2">{g.recipientCity ?? '—'}</td>
|
||||||
<td className="py-1 pr-2 text-right tabular-nums">
|
<td className="py-1 pr-2 text-right tabular-nums">
|
||||||
{dollars(g.amount)}
|
{dollars(g.amount)}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
|
|||||||
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
|
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
|
||||||
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
|
import { setFederalPrecedentDeps } from './workflows/federal-precedent.js';
|
||||||
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
|
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
|
||||||
|
import { setJudgeMatchesDeps } from './workflows/judge-matches.js';
|
||||||
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
|
import { setIngest990pfDeps } from './workflows/ingest-990pf.js';
|
||||||
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
|
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
|
||||||
import { setMatchGrantsDeps } from './workflows/match-grants.js';
|
import { setMatchGrantsDeps } from './workflows/match-grants.js';
|
||||||
@@ -75,6 +76,7 @@ async function main() {
|
|||||||
setIngest990pfDeps({ db });
|
setIngest990pfDeps({ db });
|
||||||
setFederalPrecedentDeps({ db });
|
setFederalPrecedentDeps({ db });
|
||||||
setProfileOrgsDeps({ db });
|
setProfileOrgsDeps({ db });
|
||||||
|
setJudgeMatchesDeps({ db });
|
||||||
|
|
||||||
DBOS.setConfig({
|
DBOS.setConfig({
|
||||||
name: 'helmdocs-outreach-worker',
|
name: 'helmdocs-outreach-worker',
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
runIngestNhdojOrgsNow,
|
runIngestNhdojOrgsNow,
|
||||||
setIngestNhdojOrgsDeps,
|
setIngestNhdojOrgsDeps,
|
||||||
} from './workflows/ingest-nhdoj-orgs.js';
|
} from './workflows/ingest-nhdoj-orgs.js';
|
||||||
|
import {
|
||||||
|
runJudgeMatchesNow,
|
||||||
|
setJudgeMatchesDeps,
|
||||||
|
} from './workflows/judge-matches.js';
|
||||||
import {
|
import {
|
||||||
runMatchGrantsNow,
|
runMatchGrantsNow,
|
||||||
setMatchGrantsDeps,
|
setMatchGrantsDeps,
|
||||||
@@ -68,6 +72,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
|
|||||||
ingest990pf: runIngest990PfNow,
|
ingest990pf: runIngest990PfNow,
|
||||||
federalPrecedent: runFederalPrecedentNow,
|
federalPrecedent: runFederalPrecedentNow,
|
||||||
profileOrgs: runProfileOrgsNow,
|
profileOrgs: runProfileOrgsNow,
|
||||||
|
judgeMatches: runJudgeMatchesNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIRST_RUN_ORDER = [
|
const FIRST_RUN_ORDER = [
|
||||||
@@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [
|
|||||||
'embedGrants',
|
'embedGrants',
|
||||||
'matchGrants',
|
'matchGrants',
|
||||||
'profileOrgs',
|
'profileOrgs',
|
||||||
|
'judgeMatches',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.DATABASE_URL == null) {
|
if (process.env.DATABASE_URL == null) {
|
||||||
@@ -120,6 +126,7 @@ async function main() {
|
|||||||
setIngest990pfDeps({ db });
|
setIngest990pfDeps({ db });
|
||||||
setFederalPrecedentDeps({ db });
|
setFederalPrecedentDeps({ db });
|
||||||
setProfileOrgsDeps({ db });
|
setProfileOrgsDeps({ db });
|
||||||
|
setJudgeMatchesDeps({ db });
|
||||||
|
|
||||||
DBOS.setConfig({
|
DBOS.setConfig({
|
||||||
name: 'helmdocs-outreach-worker',
|
name: 'helmdocs-outreach-worker',
|
||||||
|
|||||||
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 DEFAULT_POLITENESS_DELAY_MS = 300;
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
/**
|
||||||
|
* Recipient-list fetch cap. Peer classification runs over the fetched
|
||||||
|
* window, so a program with >500 NH awards gets its peer count computed
|
||||||
|
* on the first 500 — an undercount for the very largest programs, which
|
||||||
|
* is the conservative direction (see classifyPeerAwards in outreach-core).
|
||||||
|
*/
|
||||||
|
const MAX_AWARDS_FETCHED = 500;
|
||||||
|
|
||||||
export interface ProgramStateAwardSample {
|
export interface ProgramStateAwardSample {
|
||||||
readonly recipientName: string;
|
readonly recipientName: string;
|
||||||
readonly amount: number | null;
|
readonly amount: number | null;
|
||||||
@@ -20,24 +29,38 @@ export interface ProgramStateAwardSample {
|
|||||||
|
|
||||||
export interface ProgramStateAwards {
|
export interface ProgramStateAwards {
|
||||||
readonly aln: string;
|
readonly aln: string;
|
||||||
|
/** Total matching awards per the API (may exceed `samples.length`). */
|
||||||
readonly awardCount: number;
|
readonly awardCount: number;
|
||||||
|
/** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */
|
||||||
readonly samples: ProgramStateAwardSample[];
|
readonly samples: ProgramStateAwardSample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsaSpendingClientOptions {
|
export interface UsaSpendingClientOptions {
|
||||||
readonly fetchImpl?: typeof fetch;
|
readonly fetchImpl?: typeof fetch;
|
||||||
readonly politenessDelayMs?: number;
|
readonly politenessDelayMs?: number;
|
||||||
|
/** Test hook; production uses MAX_AWARDS_FETCHED. */
|
||||||
|
readonly maxAwardsFetched?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wait(ms: number): Promise<void> {
|
function wait(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpendingByAwardResponse {
|
||||||
|
results?: Array<{
|
||||||
|
'Recipient Name'?: string | null;
|
||||||
|
'Award Amount'?: number | null;
|
||||||
|
'Start Date'?: string | null;
|
||||||
|
}>;
|
||||||
|
page_metadata?: { total?: number | null; hasNext?: boolean | null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recent grant awards under one Assistance Listing to recipients in one
|
* Recent grant awards under one Assistance Listing to recipients in one
|
||||||
* state: total count plus a handful of sample recipients (review-page
|
* state: total count plus the full fetched recipient list. The list feeds
|
||||||
* evidence — lets a human spot "one incumbent's renewals" vs "spread
|
* two consumers — the review-page evidence table, and peer classification
|
||||||
* across orgs like ours").
|
* ("did orgs like ours win this, or just Dartmouth?"), which needs every
|
||||||
|
* recipient, hence pagination instead of the old single 10-row page.
|
||||||
*/
|
*/
|
||||||
export async function fetchProgramStateAwards(
|
export async function fetchProgramStateAwards(
|
||||||
aln: string,
|
aln: string,
|
||||||
@@ -46,48 +69,52 @@ export async function fetchProgramStateAwards(
|
|||||||
options: UsaSpendingClientOptions = {},
|
options: UsaSpendingClientOptions = {},
|
||||||
): Promise<ProgramStateAwards> {
|
): Promise<ProgramStateAwards> {
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const maxAwards = options.maxAwardsFetched ?? MAX_AWARDS_FETCHED;
|
||||||
|
|
||||||
const res = await fetchImpl(SEARCH_URL, {
|
const samples: ProgramStateAwardSample[] = [];
|
||||||
method: 'POST',
|
let total = 0;
|
||||||
headers: { 'Content-Type': 'application/json' },
|
let page = 1;
|
||||||
body: JSON.stringify({
|
|
||||||
filters: {
|
for (;;) {
|
||||||
award_type_codes: GRANT_AWARD_TYPE_CODES,
|
const res = await fetchImpl(SEARCH_URL, {
|
||||||
recipient_locations: [{ country: 'USA', state }],
|
method: 'POST',
|
||||||
time_period: [{ start_date: startDate, end_date: endDate }],
|
headers: { 'Content-Type': 'application/json' },
|
||||||
program_numbers: [aln],
|
body: JSON.stringify({
|
||||||
},
|
filters: {
|
||||||
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
|
award_type_codes: GRANT_AWARD_TYPE_CODES,
|
||||||
limit: 10,
|
recipient_locations: [{ country: 'USA', state }],
|
||||||
page: 1,
|
time_period: [{ start_date: startDate, end_date: endDate }],
|
||||||
}),
|
program_numbers: [aln],
|
||||||
});
|
},
|
||||||
if (!res.ok) {
|
fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'],
|
||||||
throw new Error(
|
limit: PAGE_SIZE,
|
||||||
`USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`,
|
page,
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`USASpending search failed for ALN ${aln} (${state}) page ${page}: ${res.status} ${res.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as SpendingByAwardResponse;
|
||||||
|
const results = body.results ?? [];
|
||||||
|
for (const r of results) {
|
||||||
|
samples.push({
|
||||||
|
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
|
||||||
|
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
|
||||||
|
startDate: r['Start Date'] ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
total = Math.max(body.page_metadata?.total ?? 0, samples.length);
|
||||||
|
|
||||||
|
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
|
||||||
|
|
||||||
|
const hasNext =
|
||||||
|
body.page_metadata?.hasNext ?? results.length === PAGE_SIZE;
|
||||||
|
if (!hasNext || results.length === 0 || samples.length >= maxAwards) break;
|
||||||
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (await res.json()) as {
|
return { aln, awardCount: total, samples };
|
||||||
results?: Array<{
|
|
||||||
'Recipient Name'?: string | null;
|
|
||||||
'Award Amount'?: number | null;
|
|
||||||
'Start Date'?: string | null;
|
|
||||||
}>;
|
|
||||||
page_metadata?: { total?: number | null };
|
|
||||||
};
|
|
||||||
|
|
||||||
await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS);
|
|
||||||
|
|
||||||
const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({
|
|
||||||
recipientName: r['Recipient Name'] ?? 'Unknown recipient',
|
|
||||||
amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']),
|
|
||||||
startDate: r['Start Date'] ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
aln,
|
|
||||||
awardCount: body.page_metadata?.total ?? samples.length,
|
|
||||||
samples,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,28 @@
|
|||||||
* Nightly federal-precedent workflow — USASpending award history per
|
* Nightly federal-precedent workflow — USASpending award history per
|
||||||
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
|
* Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index.
|
||||||
*
|
*
|
||||||
* For each distinct ALN across open federal grants: count grant awards to
|
* For each distinct ALN across open federal grants: fetch grant awards to
|
||||||
* recipients in the target state over the last ~3 fiscal years, stamp the
|
* recipients in the target state over the last ~3 fiscal years, classify
|
||||||
* count + sample recipients onto every open grant carrying that ALN.
|
* each recipient as peer/non-peer against the primary-ICP NH registry
|
||||||
* Match retrieval feeds the count into the same 25-point precedent
|
* (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not
|
||||||
* subscore foundations use — "this program funded N NH orgs recently" and
|
* precedent for a community nonprofit), and stamp raw count, PEER count,
|
||||||
|
* and the annotated award list onto every open grant carrying that ALN.
|
||||||
|
* Match retrieval feeds the peer count into the same 25-point precedent
|
||||||
|
* subscore foundations use — "this program funded N orgs like ours" and
|
||||||
* "this foundation funded N NH orgs recently" are the same signal.
|
* "this foundation funded N NH orgs recently" are the same signal.
|
||||||
*
|
*
|
||||||
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
|
* Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before
|
||||||
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
|
* matching (05:15). Registration follows `ingest-grants.ts` exactly.
|
||||||
*/
|
*/
|
||||||
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
|
import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
|
||||||
import type { schema } from '@novelpad/outreach-core';
|
import {
|
||||||
|
buildPeerNameSet,
|
||||||
|
classifyPeerAwards,
|
||||||
|
type schema,
|
||||||
|
} from '@novelpad/outreach-core';
|
||||||
import {
|
import {
|
||||||
serverListOpenFederalAlns,
|
serverListOpenFederalAlns,
|
||||||
|
serverListPeerOrgNames,
|
||||||
serverResetFederalPrecedent,
|
serverResetFederalPrecedent,
|
||||||
serverSetFederalPrecedent,
|
serverSetFederalPrecedent,
|
||||||
} from '@novelpad/outreach-core/server';
|
} from '@novelpad/outreach-core/server';
|
||||||
@@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, {
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function listPeerNames(db: OutreachDb): Promise<string[]> {
|
||||||
|
return serverListPeerOrgNames(db);
|
||||||
|
}
|
||||||
|
const listPeerNamesStep = DBOS.registerStep(listPeerNames, {
|
||||||
|
name: 'listPeerOrgNames',
|
||||||
|
retriesAllowed: true,
|
||||||
|
maxAttempts: 3,
|
||||||
|
});
|
||||||
|
|
||||||
async function fetchAlnAwards(
|
async function fetchAlnAwards(
|
||||||
aln: string,
|
aln: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
@@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, {
|
|||||||
|
|
||||||
async function applyPrecedent(
|
async function applyPrecedent(
|
||||||
db: OutreachDb,
|
db: OutreachDb,
|
||||||
precedent: ProgramStateAwards,
|
precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await serverSetFederalPrecedent(db, {
|
await serverSetFederalPrecedent(db, {
|
||||||
aln: precedent.aln,
|
aln: precedent.aln,
|
||||||
awardCount: precedent.awardCount,
|
awardCount: precedent.awardCount,
|
||||||
|
peerAwardCount: precedent.peerAwardCount,
|
||||||
samples: precedent.samples,
|
samples: precedent.samples,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const peerNames = buildPeerNameSet(await listPeerNamesStep(db));
|
||||||
|
console.log(
|
||||||
|
`[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`,
|
||||||
|
);
|
||||||
|
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date(end);
|
const start = new Date(end);
|
||||||
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
|
start.setFullYear(start.getFullYear() - LOOKBACK_YEARS);
|
||||||
@@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise<void> {
|
|||||||
await resetPrecedentStep(db);
|
await resetPrecedentStep(db);
|
||||||
|
|
||||||
let programsWithHistory = 0;
|
let programsWithHistory = 0;
|
||||||
|
let programsWithPeers = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const aln of alns) {
|
for (const aln of alns) {
|
||||||
try {
|
try {
|
||||||
const precedent = await fetchAlnAwardsStep(aln, startDate, endDate);
|
const fetched = await fetchAlnAwardsStep(aln, startDate, endDate);
|
||||||
if (precedent.awardCount > 0) {
|
if (fetched.awardCount > 0) {
|
||||||
await applyPrecedentStep(db, precedent);
|
// Pure classification over step outputs — deterministic on replay,
|
||||||
|
// so it doesn't need to be a step itself.
|
||||||
|
const { peerAwardCount, awards } = classifyPeerAwards(
|
||||||
|
fetched.samples,
|
||||||
|
peerNames,
|
||||||
|
);
|
||||||
|
await applyPrecedentStep(db, {
|
||||||
|
...fetched,
|
||||||
|
peerAwardCount,
|
||||||
|
samples: awards,
|
||||||
|
});
|
||||||
programsWithHistory++;
|
programsWithHistory++;
|
||||||
|
if (peerAwardCount > 0) programsWithPeers++;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
failed++;
|
||||||
@@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`,
|
`[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} withPeerHistory=${programsWithPeers} failed=${failed}`,
|
||||||
);
|
);
|
||||||
if (failed > 0 && failed / alns.length > 0.2) {
|
if (failed > 0 && failed / alns.length > 0.2) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
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 {
|
import {
|
||||||
buildOrgMissionText,
|
buildOrgMissionText,
|
||||||
evaluateHardGates,
|
evaluateHardGates,
|
||||||
|
normalizeOrgNameForMatching,
|
||||||
scoreMatch,
|
scoreMatch,
|
||||||
type HardGateFailureReason,
|
type HardGateFailureReason,
|
||||||
type schema,
|
type schema,
|
||||||
@@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, {
|
|||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Case/punctuation/suffix-insensitive equality for self-match detection. */
|
|
||||||
function normalizeSelfMatchName(name: string): string {
|
|
||||||
return name
|
|
||||||
.toUpperCase()
|
|
||||||
.replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '')
|
|
||||||
.replace(/[^A-Z0-9]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scoreAndStoreOrgMatches(
|
async function scoreAndStoreOrgMatches(
|
||||||
db: OutreachDb,
|
db: OutreachDb,
|
||||||
org: MatchCandidateOrg,
|
org: MatchCandidateOrg,
|
||||||
@@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches(
|
|||||||
grant.funderEin === org.ein;
|
grant.funderEin === org.ein;
|
||||||
const isSelfByName =
|
const isSelfByName =
|
||||||
grant.funderEin != null &&
|
grant.funderEin != null &&
|
||||||
normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name);
|
normalizeOrgNameForMatching(grant.funder) ===
|
||||||
|
normalizeOrgNameForMatching(org.name);
|
||||||
if (isSelfByEin || isSelfByName) {
|
if (isSelfByEin || isSelfByName) {
|
||||||
gated++;
|
gated++;
|
||||||
continue;
|
continue;
|
||||||
@@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches(
|
|||||||
applicationEffortEstimate: grant.applicationEffortEstimate,
|
applicationEffortEstimate: grant.applicationEffortEstimate,
|
||||||
closeDate: grant.closeDate,
|
closeDate: grant.closeDate,
|
||||||
now,
|
now,
|
||||||
|
grantSource: grant.source,
|
||||||
});
|
});
|
||||||
|
|
||||||
await serverUpsertMatchScore(db, {
|
await serverUpsertMatchScore(db, {
|
||||||
@@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches(
|
|||||||
subscores: scored.subscores,
|
subscores: scored.subscores,
|
||||||
hardGatesPassed,
|
hardGatesPassed,
|
||||||
easyWin: scored.easyWin,
|
easyWin: scored.easyWin,
|
||||||
|
fitViable: scored.fitViable,
|
||||||
rationale: {
|
rationale: {
|
||||||
similarity: grant.similarity,
|
similarity: grant.similarity,
|
||||||
gateFailures: gates.failures,
|
gateFailures: gates.failures,
|
||||||
ignoredGates: [...IGNORED_GATES],
|
ignoredGates: [...IGNORED_GATES],
|
||||||
scoredAt: now.toISOString(),
|
scoredAt: now.toISOString(),
|
||||||
scoringVersion: 'v2-state-precedent',
|
scoringVersion: 'v3-peer-precedent',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
stored++;
|
stored++;
|
||||||
|
|||||||
@@ -29,3 +29,11 @@ Funder precedent now covers federal grants at the **program (ALN/CFDA) level**
|
|||||||
Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs.
|
Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs.
|
||||||
|
|
||||||
First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline).
|
First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline).
|
||||||
|
|
||||||
|
## v4 (2026-07-17): peer precedent + mission-fit floor + LLM match judge
|
||||||
|
|
||||||
|
The v3 sweep exposed two structural failures: ALN-level counts were **recipient-blind** (NORC P30 carried 25/25 precedent because ten NH awards existed — all to Dartmouth entities and two biotech LLCs), and **mission fit couldn't veto** (non-mission subscores sum to 50, so a boys' camp scored 60+ on NIH research-center grants). Three fixes, `scoringVersion: v3-peer-precedent`:
|
||||||
|
|
||||||
|
1. **Peer precedent** — `federalPrecedent` now paginates USASpending (up to 500 awards/program, not a 10-row sample) and classifies every recipient against the primary-ICP NH registry by normalized name (`classifyPeerAwards` + `normalizeOrgNameForMatching`, the same normalizer the self-match gate uses; LLC/LTD deliberately not stripped). The 25-point precedent tiers key off `program_state_peer_award_count` — awards won by orgs shaped like our candidates — while the raw count and the peer-annotated award list stay on the grant as review evidence (peers get a green badge, sorted first). Conservative by construction: unmatched/unenriched recipients count as non-peer; missed precedent demotes a real match rather than pitching a false one.
|
||||||
|
2. **Mission-fit floor** (`MISSION_FIT_VIABLE_MIN = 12/30`, federal sources only) — below it a match is stored (`fit_viable = false`) but hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants are exempt: their synopses are generic by construction, so embedding fit carries no signal and precedent evidence is the case for the match.
|
||||||
|
3. **Mission-fit judge** (`judgeMatches`, 06:15, `JUDGE_MATCHES_PER_RUN` default 200) — JUDGE_MODEL reads the actual grant synopsis against the org's (researched or stub) profile, with the explicit instruction to **ignore eligibility breadth** and judge what the program funds and who realistically performs that work (NIH R/P/U mechanisms fit only research performers). Graded verdict with required citations from both sides; deterministic code maps verdict → mission-fit points (strong_fit 27 / plausible 18 / weak 8 / mismatch 0), recomputes total + easy-win, and sets `fit_viable` (weak/mismatch → hidden). Judged rows keep their verdict across nightly re-scores (the upsert splices `judge_mission_fit` back in) and re-enter the judge queue only when the org profile is re-researched (`org_profiles.updated_at > judged_at`). Best-scoring first, so the head of the human queue is always LLM-verified before a human reads it. The detail page shows the verdict, rationale, and citations.
|
||||||
|
|||||||
@@ -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 { getAi } from '../../gemini.js';
|
||||||
import type { OrgProfile } from '../org-profiler/schema.js';
|
import { JUDGE_MODEL } from '../../models.js';
|
||||||
|
import {
|
||||||
|
MATCH_JUDGE_VERDICT_JSON_SCHEMA,
|
||||||
|
MatchJudgeVerdictSchema,
|
||||||
|
type MatchJudgeVerdict,
|
||||||
|
} from './schema.js';
|
||||||
|
|
||||||
export interface RunMissionFitJudgeInput {
|
export interface JudgeOrgSide {
|
||||||
/** Extracted profile of the candidate org (from the Org Profiler). */
|
readonly name: string;
|
||||||
orgProfile: OrgProfile;
|
readonly city: string | null;
|
||||||
/** Grant program name / title, as scored against by the deterministic SQL gates. */
|
/** Researched or NTEE-stub mission text (whatever the profile holds). */
|
||||||
grantProgramName: string;
|
readonly missionStatement: string | null;
|
||||||
/** Funding priorities / eligible-use language pulled from the grant's own source text. */
|
/** Researched programs, if the Stage 4 profiler has run for this org. */
|
||||||
grantPriorities: string[];
|
readonly programs: Array<{
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
populationServed: string | null;
|
||||||
|
}>;
|
||||||
|
readonly serviceGeography: string | null;
|
||||||
|
/** Profile confidence — low means the org side is mostly an NTEE guess. */
|
||||||
|
readonly profileConfidence: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JudgeGrantSide {
|
||||||
|
readonly title: string;
|
||||||
|
readonly funder: string;
|
||||||
|
readonly synopsis: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunMatchJudgeInput {
|
||||||
|
readonly org: JudgeOrgSide;
|
||||||
|
readonly grant: JudgeGrantSide;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SYNOPSIS_CHARS = 12_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the judge prompt for one (org, grant) match candidate. Kept separate
|
* Build the judge prompt for one (org, grant) candidate. Kept separate
|
||||||
* from `runMissionFitJudge` so it's independently unit-testable once wired up.
|
* from `runMatchJudge` so it's independently unit-testable.
|
||||||
|
*
|
||||||
|
* The core instruction exists because of a concrete failure mode:
|
||||||
|
* federal (especially NIH) synopses declare near-universal *eligibility*
|
||||||
|
* while the funded work is highly specific — "nonprofits may apply" put a
|
||||||
|
* boys' summer camp on an obesity-research center grant. Eligibility
|
||||||
|
* breadth is therefore explicitly out of scope; the judge reads what the
|
||||||
|
* program FUNDS and who realistically performs that work.
|
||||||
*/
|
*/
|
||||||
export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string {
|
export function buildMatchJudgePrompt(input: RunMatchJudgeInput): string {
|
||||||
|
const { org, grant } = input;
|
||||||
|
|
||||||
|
const programLines =
|
||||||
|
org.programs.length > 0
|
||||||
|
? org.programs
|
||||||
|
.map((p) =>
|
||||||
|
[
|
||||||
|
`- ${p.name}`,
|
||||||
|
p.description,
|
||||||
|
p.populationServed == null ? null : `serves ${p.populationServed}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' — '),
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
: '(no researched program list — judge from the mission text alone)';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'You are the final mission-fit judge for a candidate (org, grant) match',
|
'You judge whether a specific nonprofit is a credible fit for a specific',
|
||||||
'that has already passed deterministic SQL hard gates (eligibility,',
|
'grant program. The pair already passed automated eligibility, geography,',
|
||||||
'geography, award range). Decide whether the org\'s actual programs',
|
'and deadline gates; your ONLY question is programmatic mission fit:',
|
||||||
'plausibly fit the grant\'s funding priorities. You MUST cite one',
|
'does the work this org actually does match what this grant actually funds?',
|
||||||
'concrete org program and one concrete grant priority your verdict is',
|
|
||||||
'grounded in — a verdict without both citations is invalid. A wrong',
|
|
||||||
'"fit: true" here can put a real NH nonprofit in front of a funder that',
|
|
||||||
'will never fund them, so when the fit is unclear, prefer `fit: false`.',
|
|
||||||
'',
|
'',
|
||||||
`Grant program: ${input.grantProgramName}`,
|
'Rules:',
|
||||||
`Grant priorities: ${input.grantPriorities.join('; ')}`,
|
'- IGNORE eligibility breadth entirely. Federal synopses often say any',
|
||||||
`Org legal name: ${input.orgProfile.legalName.value}`,
|
' nonprofit may apply while the funded activity is narrow, technical, or',
|
||||||
`Org mission: ${input.orgProfile.mission.value}`,
|
' institutional (research centers, clinical trials, training programs at',
|
||||||
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`,
|
' universities). Judge what gets FUNDED and who realistically performs',
|
||||||
|
' that work, not who is allowed to apply.',
|
||||||
|
'- A research-mechanism grant (center grants, clinical trials, R-series/',
|
||||||
|
' P-series/U-series NIH mechanisms) fits only orgs that conduct that kind',
|
||||||
|
' of research.',
|
||||||
|
'- Ground the verdict in one concrete org activity and one concrete piece',
|
||||||
|
' of synopsis language; a verdict without both citations is invalid.',
|
||||||
|
"- A wrong positive verdict wastes a real fundraiser's time and burns our",
|
||||||
|
' credibility with them. When genuinely torn between two verdicts, pick',
|
||||||
|
' the lower one.',
|
||||||
|
'',
|
||||||
|
'Verdicts:',
|
||||||
|
"- strong_fit: the org's core work is squarely what the program funds.",
|
||||||
|
'- plausible: real overlap; a competent grant writer could make the case.',
|
||||||
|
'- weak: tangential overlap only; the org would be an outlier applicant.',
|
||||||
|
'- mismatch: the org does not do what this program funds.',
|
||||||
|
'',
|
||||||
|
'--- GRANT ---',
|
||||||
|
`Title: ${grant.title}`,
|
||||||
|
`Funder: ${grant.funder}`,
|
||||||
|
`Synopsis: ${(grant.synopsis ?? '(none)').slice(0, MAX_SYNOPSIS_CHARS)}`,
|
||||||
|
'',
|
||||||
|
'--- ORGANIZATION ---',
|
||||||
|
`Name: ${org.name}`,
|
||||||
|
`Location: ${org.city ?? 'unknown'}, NH`,
|
||||||
|
`Mission: ${org.missionStatement ?? '(unknown)'}`,
|
||||||
|
`Service area: ${org.serviceGeography ?? '(unknown)'}`,
|
||||||
|
'Programs:',
|
||||||
|
programLines,
|
||||||
|
...(org.profileConfidence != null && org.profileConfidence < 0.5
|
||||||
|
? [
|
||||||
|
'',
|
||||||
|
'NOTE: this org profile is low-confidence (category-derived, not',
|
||||||
|
'researched). Judge from the mission category; do not invent programs.',
|
||||||
|
]
|
||||||
|
: []),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a
|
* One structured judge call. JUDGE_MODEL per the tiering rule in
|
||||||
* human reviewer (and, downstream, a real prospect via Apollo), so it should
|
* models.ts — this verdict gates matches into/out of the review queue.
|
||||||
* not go live against real matches until the deterministic hard-gate scoring
|
* `MATCH_JUDGE_MODEL` env overrides for cheap bulk experiments.
|
||||||
* this package doesn't own is wired in ahead of it. Intended production call
|
|
||||||
* shape, mirroring novelpad-desktop's
|
|
||||||
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
|
|
||||||
* responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`,
|
|
||||||
* not `BULK_MODEL`: a wrong verdict here reaches a prospect:
|
|
||||||
*
|
|
||||||
* import { getAi } from '../../gemini.js';
|
|
||||||
* import { JUDGE_MODEL } from '../../models.js';
|
|
||||||
*
|
|
||||||
* const result = await getAi().models.generateContent({
|
|
||||||
* model: JUDGE_MODEL,
|
|
||||||
* contents: buildMissionFitJudgePrompt(input),
|
|
||||||
* config: {
|
|
||||||
* responseMimeType: 'application/json',
|
|
||||||
* // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema
|
|
||||||
* // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema.
|
|
||||||
* temperature: 0.1,
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
* const raw = JSON.parse(result.text ?? '{}');
|
|
||||||
* return MissionFitVerdictSchema.parse(raw);
|
|
||||||
*/
|
*/
|
||||||
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> {
|
export async function runMatchJudge(
|
||||||
throw new Error('not implemented');
|
input: RunMatchJudgeInput,
|
||||||
|
): Promise<{ verdict: MatchJudgeVerdict; model: string }> {
|
||||||
|
const model = process.env.MATCH_JUDGE_MODEL ?? JUDGE_MODEL;
|
||||||
|
const result = await getAi().models.generateContent({
|
||||||
|
model,
|
||||||
|
contents: buildMatchJudgePrompt(input),
|
||||||
|
config: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseJsonSchema: MATCH_JUDGE_VERDICT_JSON_SCHEMA,
|
||||||
|
temperature: 0.1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const raw: unknown = JSON.parse(result.text ?? '{}');
|
||||||
|
return { verdict: MatchJudgeVerdictSchema.parse(raw), model };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,68 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mission-fit judge verdict for one (org, grant) match candidate. This is
|
* Graded mission-fit verdict for one (org, grant) match candidate that
|
||||||
* the last human-facing gate before a match is surfaced for review — the
|
* already passed the deterministic hard gates and subscore ranking.
|
||||||
* judge must ground its verdict in something concrete from each side rather
|
*
|
||||||
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not
|
* The judge names a verdict; deterministic code assigns the number
|
||||||
* optional summary fields.
|
* (missionFitFromVerdict) and decides queue viability
|
||||||
|
* (verdictIsFitViable) — the LLM never emits a score directly, so the
|
||||||
|
* mapping can be re-tuned from review data without re-judging anything.
|
||||||
|
*
|
||||||
|
* Citations are required, not optional summary fields: a verdict must be
|
||||||
|
* grounded in something concrete from each side rather than a vibe.
|
||||||
*/
|
*/
|
||||||
export const MissionFitVerdictSchema = z.object({
|
export const MatchJudgeVerdictSchema = z.object({
|
||||||
/** Whether the org's mission plausibly fits the grant's funding priorities. */
|
verdict: z.enum(['strong_fit', 'plausible', 'weak', 'mismatch']),
|
||||||
fit: z.boolean(),
|
/** The specific org program/activity the verdict is grounded in. */
|
||||||
/** The specific org program/activity the verdict is grounded in (from the org profile). */
|
citedOrgEvidence: z.string().min(1),
|
||||||
citedOrgProgram: z.string().min(1),
|
/** The specific synopsis language (purpose/priorities) the verdict is grounded in. */
|
||||||
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */
|
citedGrantEvidence: z.string().min(1),
|
||||||
citedGrantPriority: z.string().min(1),
|
/** 1–3 sentence justification tying the two citations together. */
|
||||||
/** Short human-readable justification tying the two citations together. */
|
|
||||||
reasoning: z.string().min(1),
|
reasoning: z.string().min(1),
|
||||||
});
|
});
|
||||||
export type MissionFitVerdict = z.infer<typeof MissionFitVerdictSchema>;
|
export type MatchJudgeVerdict = z.infer<typeof MatchJudgeVerdictSchema>;
|
||||||
|
|
||||||
|
/** Plain JSON Schema mirror of MatchJudgeVerdictSchema for `responseJsonSchema`. */
|
||||||
|
export const MATCH_JUDGE_VERDICT_JSON_SCHEMA = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
verdict: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['strong_fit', 'plausible', 'weak', 'mismatch'],
|
||||||
|
},
|
||||||
|
citedOrgEvidence: { type: 'string', minLength: 1 },
|
||||||
|
citedGrantEvidence: { type: 'string', minLength: 1 },
|
||||||
|
reasoning: { type: 'string', minLength: 1 },
|
||||||
|
},
|
||||||
|
required: ['verdict', 'citedOrgEvidence', 'citedGrantEvidence', 'reasoning'],
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic verdict → mission-fit subscore (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,
|
"when": 1784257140142,
|
||||||
"tag": "1784257140_federal-precedent",
|
"tag": "1784257140_federal-precedent",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784295305749,
|
||||||
|
"tag": "1784295305_match-judge-and-peer-precedent",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784295739948,
|
||||||
|
"tag": "1784295739_org-profile-updated-at",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,13 @@ export const matchReviewStatusEnum = pgEnum('match_review_status', [
|
|||||||
'edited',
|
'edited',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const matchJudgeVerdictEnum = pgEnum('match_judge_verdict', [
|
||||||
|
'strong_fit',
|
||||||
|
'plausible',
|
||||||
|
'weak',
|
||||||
|
'mismatch',
|
||||||
|
]);
|
||||||
|
|
||||||
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
|
export const matchRejectReasonEnum = pgEnum('match_reject_reason', [
|
||||||
'wrong_eligibility',
|
'wrong_eligibility',
|
||||||
'wrong_geography',
|
'wrong_geography',
|
||||||
@@ -151,7 +158,14 @@ export const grants = pgTable(
|
|||||||
// refreshed by the federalPrecedent workflow. Null for non-federal
|
// refreshed by the federalPrecedent workflow. Null for non-federal
|
||||||
// sources and never touched by ingest upserts.
|
// sources and never touched by ingest upserts.
|
||||||
programStateAwardCount: integer('program_state_award_count'),
|
programStateAwardCount: integer('program_state_award_count'),
|
||||||
// Sample recipients backing the count — review-page evidence.
|
// Awards (within the fetched window) whose recipient name-matches a
|
||||||
|
// registered NH nonprofit in our primary ICP band — "orgs like ours
|
||||||
|
// win this program", the count the precedent subscore actually uses.
|
||||||
|
// Dartmouth/UNH/hospital systems/LLCs inflate the raw count above but
|
||||||
|
// never this one.
|
||||||
|
programStatePeerAwardCount: integer('program_state_peer_award_count'),
|
||||||
|
// Sample recipients backing the count — review-page evidence. Each
|
||||||
|
// entry carries `isPeer` since the peer-precedent pass.
|
||||||
programStateAwards: jsonb('program_state_awards'),
|
programStateAwards: jsonb('program_state_awards'),
|
||||||
status: grantStatusEnum('status').notNull().default('open'),
|
status: grantStatusEnum('status').notNull().default('open'),
|
||||||
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
|
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
|
||||||
@@ -240,6 +254,9 @@ export const orgProfiles = pgTable(
|
|||||||
confidence: real('confidence'),
|
confidence: real('confidence'),
|
||||||
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
|
profileEmbedding: vector('profile_embedding', { dimensions: 1536 }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
|
// Bumped whenever the profile is re-researched — the match judge
|
||||||
|
// re-judges matches whose judged_at predates this.
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
// Latest-profile semantics: one row per org, refreshed in place (the
|
// Latest-profile semantics: one row per org, refreshed in place (the
|
||||||
@@ -304,6 +321,20 @@ export const matches = pgTable(
|
|||||||
easyWin: boolean('easy_win').notNull().default(false),
|
easyWin: boolean('easy_win').notNull().default(false),
|
||||||
// LLM-produced citations backing the score/subscores.
|
// LLM-produced citations backing the score/subscores.
|
||||||
rationale: jsonb('rationale'),
|
rationale: jsonb('rationale'),
|
||||||
|
// Embedding fit can't veto (non-mission subscores sum to 50); this
|
||||||
|
// flag can: false = below the source-aware mission-fit floor or judged
|
||||||
|
// 'weak'/'mismatch', and the pending queue hides the row. Approved/
|
||||||
|
// rejected views ignore it (a human decision always displays).
|
||||||
|
fitViable: boolean('fit_viable').notNull().default(true),
|
||||||
|
// Mission-fit judge (LLM over grant synopsis × researched org profile;
|
||||||
|
// deterministic verdict→score mapping — the judge names the verdict,
|
||||||
|
// code assigns the number). Preserved across nightly re-scores;
|
||||||
|
// invalidated when the org profile is re-researched after judged_at.
|
||||||
|
judgeVerdict: matchJudgeVerdictEnum('judge_verdict'),
|
||||||
|
judgeMissionFit: integer('judge_mission_fit'),
|
||||||
|
judgeRationale: text('judge_rationale'),
|
||||||
|
judgedAt: timestamp('judged_at', { withTimezone: true }),
|
||||||
|
judgeModel: text('judge_model'),
|
||||||
reviewStatus: matchReviewStatusEnum('review_status')
|
reviewStatus: matchReviewStatusEnum('review_status')
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('pending'),
|
.default('pending'),
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import { schema } from '#~/db/db.js';
|
|||||||
|
|
||||||
export interface AlnPrecedent {
|
export interface AlnPrecedent {
|
||||||
readonly aln: string;
|
readonly aln: string;
|
||||||
|
/** Raw NH award count for the program (context/evidence only). */
|
||||||
readonly awardCount: number;
|
readonly awardCount: number;
|
||||||
|
/** Awards won by ICP-peer nonprofits — drives the precedent subscore. */
|
||||||
|
readonly peerAwardCount: number;
|
||||||
|
/** Peer-annotated award list (see classifyPeerAwards). */
|
||||||
readonly samples: unknown;
|
readonly samples: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies per-program USASpending precedent onto every open federal grant
|
* Applies per-program USASpending precedent onto every open federal grant
|
||||||
* carrying that ALN. Grants with multiple ALNs keep the HIGHEST count
|
* carrying that ALN. Grants with multiple ALNs keep the strongest program
|
||||||
* seen (a grant reachable through any strongly-NH program inherits that
|
* seen, where "strongest" is the PEER count — ten Dartmouth renewals must
|
||||||
* program's precedent), and samples follow whichever count won.
|
* not outrank three community-nonprofit wins — with raw count and samples
|
||||||
|
* following whichever peer count won.
|
||||||
*/
|
*/
|
||||||
export async function serverSetFederalPrecedent(
|
export async function serverSetFederalPrecedent(
|
||||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||||
@@ -22,9 +27,14 @@ export async function serverSetFederalPrecedent(
|
|||||||
await db
|
await db
|
||||||
.update(schema.grants)
|
.update(schema.grants)
|
||||||
.set({
|
.set({
|
||||||
programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`,
|
programStatePeerAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStatePeerAwardCount}, 0), ${precedent.peerAwardCount})`,
|
||||||
|
programStateAwardCount: sql`CASE
|
||||||
|
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
|
||||||
|
THEN ${precedent.awardCount}
|
||||||
|
ELSE ${schema.grants.programStateAwardCount}
|
||||||
|
END`,
|
||||||
programStateAwards: sql`CASE
|
programStateAwards: sql`CASE
|
||||||
WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount}
|
WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount}
|
||||||
THEN ${JSON.stringify(precedent.samples)}::jsonb
|
THEN ${JSON.stringify(precedent.samples)}::jsonb
|
||||||
ELSE ${schema.grants.programStateAwards}
|
ELSE ${schema.grants.programStateAwards}
|
||||||
END`,
|
END`,
|
||||||
@@ -45,6 +55,10 @@ export async function serverResetFederalPrecedent(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db
|
await db
|
||||||
.update(schema.grants)
|
.update(schema.grants)
|
||||||
.set({ programStateAwardCount: null, programStateAwards: null })
|
.set({
|
||||||
|
programStateAwardCount: null,
|
||||||
|
programStatePeerAwardCount: null,
|
||||||
|
programStateAwards: null,
|
||||||
|
})
|
||||||
.where(eq(schema.grants.source, 'grants_gov'));
|
.where(eq(schema.grants.source, 'grants_gov'));
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
| 'unknown';
|
||||||
applicationFormSupported: boolean;
|
applicationFormSupported: boolean;
|
||||||
funderEin: string | null;
|
funderEin: string | null;
|
||||||
|
source: string;
|
||||||
similarity: number;
|
similarity: number;
|
||||||
/** Funder's historical grant count into the org's state (990-PF index); null when the grant has no linked funder. */
|
/**
|
||||||
|
* Precedent count for the subscore: the funder's historical grant count
|
||||||
|
* into the org's state (990-PF index) for foundation grants, or the
|
||||||
|
* program's PEER award count (USASpending recipients name-matched to
|
||||||
|
* primary-ICP NH orgs) for federal grants — never the raw state count,
|
||||||
|
* which Dartmouth renewals inflate.
|
||||||
|
*/
|
||||||
funderStateGrantCount: number | null;
|
funderStateGrantCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +68,7 @@ export async function serverListEligibleGrantsForOrg(
|
|||||||
applicationEffortEstimate: schema.grants.applicationEffortEstimate,
|
applicationEffortEstimate: schema.grants.applicationEffortEstimate,
|
||||||
applicationFormSupported: schema.grants.applicationFormSupported,
|
applicationFormSupported: schema.grants.applicationFormSupported,
|
||||||
funderEin: schema.grants.funderEin,
|
funderEin: schema.grants.funderEin,
|
||||||
|
source: schema.grants.source,
|
||||||
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
|
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
|
||||||
funderStateGrantCount: sql<number | null>`CASE
|
funderStateGrantCount: sql<number | null>`CASE
|
||||||
WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
|
WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
|
||||||
@@ -69,7 +77,7 @@ export async function serverListEligibleGrantsForOrg(
|
|||||||
WHERE f.ein = ${schema.grants.funderEin}
|
WHERE f.ein = ${schema.grants.funderEin}
|
||||||
AND fg.recipient_state = ${filters.orgState}
|
AND fg.recipient_state = ${filters.orgState}
|
||||||
)
|
)
|
||||||
ELSE ${schema.grants.programStateAwardCount}
|
ELSE ${schema.grants.programStatePeerAwardCount}
|
||||||
END`,
|
END`,
|
||||||
})
|
})
|
||||||
.from(schema.grants)
|
.from(schema.grants)
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export * from './matches/hard-gates.js';
|
|||||||
export * from './orgs/icp-band.js';
|
export * from './orgs/icp-band.js';
|
||||||
export * from './matches/scoring.js';
|
export * from './matches/scoring.js';
|
||||||
export * from './orgs/ntee.js';
|
export * from './orgs/ntee.js';
|
||||||
|
export * from './orgs/org-name.js';
|
||||||
|
export * from './grants/peer-precedent.js';
|
||||||
|
|||||||
@@ -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}
|
WHERE org_id = ${orgId}
|
||||||
AND review_status != 'rejected'
|
AND review_status != 'rejected'
|
||||||
AND hard_gates_passed = true
|
AND hard_gates_passed = true
|
||||||
|
AND fit_viable = true
|
||||||
ORDER BY easy_win DESC, total_score DESC, created_at ASC
|
ORDER BY easy_win DESC, total_score DESC, created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './insert-match.server.js';
|
|||||||
export * from './set-match-review.server.js';
|
export * from './set-match-review.server.js';
|
||||||
export * from './upsert-match-score.server.js';
|
export * from './upsert-match-score.server.js';
|
||||||
export * from './assign-hero-matches.server.js';
|
export * from './assign-hero-matches.server.js';
|
||||||
|
export * from './apply-match-judgment.server.js';
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { sql } from 'drizzle-orm';
|
|||||||
|
|
||||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||||
import { schema } from '#~/db/db.js';
|
import { schema } from '#~/db/db.js';
|
||||||
import type { MatchSubscores } from '../scoring.js';
|
import {
|
||||||
|
EASY_WIN_MIN_PRECEDENT,
|
||||||
|
EASY_WIN_THRESHOLD,
|
||||||
|
type MatchSubscores,
|
||||||
|
} from '../scoring.js';
|
||||||
|
|
||||||
export interface MatchScoreInput {
|
export interface MatchScoreInput {
|
||||||
readonly orgId: string;
|
readonly orgId: string;
|
||||||
@@ -11,6 +15,7 @@ export interface MatchScoreInput {
|
|||||||
readonly subscores: MatchSubscores;
|
readonly subscores: MatchSubscores;
|
||||||
readonly hardGatesPassed: boolean;
|
readonly hardGatesPassed: boolean;
|
||||||
readonly easyWin: boolean;
|
readonly easyWin: boolean;
|
||||||
|
readonly fitViable: boolean;
|
||||||
/** Gate failures, similarity, and any judge citations — audit trail. */
|
/** Gate failures, similarity, and any judge citations — audit trail. */
|
||||||
readonly rationale: unknown;
|
readonly rationale: unknown;
|
||||||
}
|
}
|
||||||
@@ -22,11 +27,23 @@ export interface MatchScoreInput {
|
|||||||
* nightly re-score moves the numbers — resurfacing rejected matches would
|
* nightly re-score moves the numbers — resurfacing rejected matches would
|
||||||
* erode the review queue's trust, and re-approving approved ones is
|
* erode the review queue's trust, and re-approving approved ones is
|
||||||
* pointless churn.
|
* pointless churn.
|
||||||
|
*
|
||||||
|
* Judge fields survive re-scores the same way, and while a judgment is
|
||||||
|
* on the row its mission-fit verdict KEEPS overriding the incoming
|
||||||
|
* embedding-based numbers: missionFit is spliced with judge_mission_fit,
|
||||||
|
* total/easy-win recomputed from the spliced value, and fit_viable kept
|
||||||
|
* from the verdict. Otherwise the nightly embedding re-score would revert
|
||||||
|
* every judged match until the judge's next pass re-found it. Staleness
|
||||||
|
* is handled at selection time (re-judge when the org profile is newer
|
||||||
|
* than judged_at), not by dropping the judgment here.
|
||||||
*/
|
*/
|
||||||
export async function serverUpsertMatchScore(
|
export async function serverUpsertMatchScore(
|
||||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||||
match: MatchScoreInput,
|
match: MatchScoreInput,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const judged = sql`${schema.matches.judgedAt} IS NOT NULL AND ${schema.matches.judgeMissionFit} IS NOT NULL`;
|
||||||
|
const judgedTotal = sql`excluded.total_score - COALESCE((excluded.subscores->>'missionFit')::int, 0) + ${schema.matches.judgeMissionFit}`;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(schema.matches)
|
.insert(schema.matches)
|
||||||
.values({
|
.values({
|
||||||
@@ -36,16 +53,24 @@ export async function serverUpsertMatchScore(
|
|||||||
subscores: match.subscores,
|
subscores: match.subscores,
|
||||||
hardGatesPassed: match.hardGatesPassed,
|
hardGatesPassed: match.hardGatesPassed,
|
||||||
easyWin: match.easyWin,
|
easyWin: match.easyWin,
|
||||||
|
fitViable: match.fitViable,
|
||||||
rationale: match.rationale,
|
rationale: match.rationale,
|
||||||
reviewStatus: 'pending',
|
reviewStatus: 'pending',
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: [schema.matches.orgId, schema.matches.grantId],
|
target: [schema.matches.orgId, schema.matches.grantId],
|
||||||
set: {
|
set: {
|
||||||
totalScore: sql`excluded.total_score`,
|
totalScore: sql`CASE WHEN ${judged} THEN ${judgedTotal} ELSE excluded.total_score END`,
|
||||||
subscores: sql`excluded.subscores`,
|
subscores: sql`CASE WHEN ${judged}
|
||||||
|
THEN jsonb_set(excluded.subscores, '{missionFit}', to_jsonb(${schema.matches.judgeMissionFit}))
|
||||||
|
ELSE excluded.subscores END`,
|
||||||
hardGatesPassed: sql`excluded.hard_gates_passed`,
|
hardGatesPassed: sql`excluded.hard_gates_passed`,
|
||||||
easyWin: sql`excluded.easy_win`,
|
easyWin: sql`CASE WHEN ${judged}
|
||||||
|
THEN (${schema.matches.fitViable}
|
||||||
|
AND ${judgedTotal} >= ${EASY_WIN_THRESHOLD}
|
||||||
|
AND COALESCE((excluded.subscores->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT})
|
||||||
|
ELSE excluded.easy_win END`,
|
||||||
|
fitViable: sql`CASE WHEN ${judged} THEN ${schema.matches.fitViable} ELSE excluded.fit_viable END`,
|
||||||
rationale: sql`excluded.rationale`,
|
rationale: sql`excluded.rationale`,
|
||||||
updatedAt: sql`now()`,
|
updatedAt: sql`now()`,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface MatchDetail {
|
|||||||
rationale: unknown;
|
rationale: unknown;
|
||||||
easyWin: boolean;
|
easyWin: boolean;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
|
fitViable: boolean;
|
||||||
|
judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null;
|
||||||
|
judgeRationale: string | null;
|
||||||
|
judgedAt: Date | null;
|
||||||
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
|
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||||
};
|
};
|
||||||
org: {
|
org: {
|
||||||
@@ -56,6 +60,9 @@ export interface MatchDetail {
|
|||||||
amount: number | null;
|
amount: number | null;
|
||||||
purpose: string | null;
|
purpose: string | null;
|
||||||
taxYear: number;
|
taxYear: number;
|
||||||
|
/** Federal evidence rows only: recipient name-matched a primary-ICP
|
||||||
|
* NH nonprofit (the peer-precedent basis). Null for 990-PF rows. */
|
||||||
|
isPeer: boolean | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +78,10 @@ export async function serverGetMatchDetail(
|
|||||||
rationale: schema.matches.rationale,
|
rationale: schema.matches.rationale,
|
||||||
easyWin: schema.matches.easyWin,
|
easyWin: schema.matches.easyWin,
|
||||||
isHero: schema.matches.isHero,
|
isHero: schema.matches.isHero,
|
||||||
|
fitViable: schema.matches.fitViable,
|
||||||
|
judgeVerdict: schema.matches.judgeVerdict,
|
||||||
|
judgeRationale: schema.matches.judgeRationale,
|
||||||
|
judgedAt: schema.matches.judgedAt,
|
||||||
reviewStatus: schema.matches.reviewStatus,
|
reviewStatus: schema.matches.reviewStatus,
|
||||||
orgId: schema.orgs.id,
|
orgId: schema.orgs.id,
|
||||||
orgName: schema.orgs.name,
|
orgName: schema.orgs.name,
|
||||||
@@ -123,19 +134,29 @@ export async function serverGetMatchDetail(
|
|||||||
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
|
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
|
||||||
// Federal grants: USASpending program awards fill the same evidence
|
// Federal grants: USASpending program awards fill the same evidence
|
||||||
// table (recipient/amount/year) the 990-PF history uses.
|
// table (recipient/amount/year) the 990-PF history uses.
|
||||||
|
// Peers first (the actual precedent evidence), newest within each
|
||||||
|
// group; capped so a 500-award program doesn't flood the page.
|
||||||
funderGivingHistory = (
|
funderGivingHistory = (
|
||||||
row.grantProgramAwards as Array<{
|
row.grantProgramAwards as Array<{
|
||||||
recipientName?: string;
|
recipientName?: string;
|
||||||
amount?: number | null;
|
amount?: number | null;
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
|
isPeer?: boolean;
|
||||||
}>
|
}>
|
||||||
).map((a) => ({
|
)
|
||||||
recipientName: a.recipientName ?? 'Unknown recipient',
|
.map((a) => ({
|
||||||
recipientCity: null,
|
recipientName: a.recipientName ?? 'Unknown recipient',
|
||||||
amount: a.amount ?? null,
|
recipientCity: null,
|
||||||
purpose: null,
|
amount: a.amount ?? null,
|
||||||
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
|
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) {
|
if (row.grantFunderEin != null) {
|
||||||
const funderRow = await db
|
const funderRow = await db
|
||||||
@@ -170,7 +191,7 @@ export async function serverGetMatchDetail(
|
|||||||
return aIn - bIn;
|
return aIn - bIn;
|
||||||
})
|
})
|
||||||
.slice(0, 40)
|
.slice(0, 40)
|
||||||
.map(({ recipientState: _s, ...rest }) => rest);
|
.map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +202,10 @@ export async function serverGetMatchDetail(
|
|||||||
rationale: row.rationale,
|
rationale: row.rationale,
|
||||||
easyWin: row.easyWin,
|
easyWin: row.easyWin,
|
||||||
isHero: row.isHero,
|
isHero: row.isHero,
|
||||||
|
fitViable: row.fitViable,
|
||||||
|
judgeVerdict: row.judgeVerdict,
|
||||||
|
judgeRationale: row.judgeRationale,
|
||||||
|
judgedAt: row.judgedAt,
|
||||||
reviewStatus: row.reviewStatus,
|
reviewStatus: row.reviewStatus,
|
||||||
},
|
},
|
||||||
org: {
|
org: {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './list-pending-review-matches.server.js';
|
export * from './list-pending-review-matches.server.js';
|
||||||
export * from './get-match-detail.server.js';
|
export * from './get-match-detail.server.js';
|
||||||
|
export * from './list-matches-needing-judgment.server.js';
|
||||||
|
|||||||
@@ -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,12 +44,16 @@ export async function serverListPendingReviewMatches(
|
|||||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||||
.where(
|
.where(
|
||||||
source == null
|
and(
|
||||||
? eq(schema.matches.reviewStatus, status)
|
eq(schema.matches.reviewStatus, status),
|
||||||
: and(
|
// The pending queue hides fit-non-viable rows (below the mission-
|
||||||
eq(schema.matches.reviewStatus, status),
|
// fit floor or judged weak/mismatch); a human's approve/reject
|
||||||
eq(schema.grants.source, source as never),
|
// always displays regardless.
|
||||||
),
|
...(status === 'pending' ? [eq(schema.matches.fitViable, true)] : []),
|
||||||
|
...(source == null
|
||||||
|
? []
|
||||||
|
: [eq(schema.grants.source, source as never)]),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
// Reviewers see the best candidates first: heroes, then easy wins,
|
// Reviewers see the best candidates first: heroes, then easy wins,
|
||||||
// then raw score. Capped — nightly re-scoring generates thousands of
|
// then raw score. Capped — nightly re-scoring generates thousands of
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
competitionSubscore,
|
competitionSubscore,
|
||||||
EASY_WIN_THRESHOLD,
|
EASY_WIN_THRESHOLD,
|
||||||
effortSubscore,
|
effortSubscore,
|
||||||
|
MISSION_FIT_VIABLE_MIN,
|
||||||
missionFitSubscore,
|
missionFitSubscore,
|
||||||
runwaySubscore,
|
runwaySubscore,
|
||||||
scoreMatch,
|
scoreMatch,
|
||||||
@@ -102,6 +103,7 @@ describe('scoreMatch', () => {
|
|||||||
closeDate: weeksFromNow(6),
|
closeDate: weeksFromNow(6),
|
||||||
now: NOW,
|
now: NOW,
|
||||||
funderStateGrantCount: null,
|
funderStateGrantCount: null,
|
||||||
|
grantSource: 'irs_990pf',
|
||||||
});
|
});
|
||||||
// 30+15+15+10+5 = 75 — over the threshold but no precedent floor.
|
// 30+15+15+10+5 = 75 — over the threshold but no precedent floor.
|
||||||
expect(result.totalScore).toBe(75);
|
expect(result.totalScore).toBe(75);
|
||||||
@@ -118,6 +120,7 @@ describe('scoreMatch', () => {
|
|||||||
closeDate: weeksFromNow(6),
|
closeDate: weeksFromNow(6),
|
||||||
now: NOW,
|
now: NOW,
|
||||||
funderStateGrantCount: 6,
|
funderStateGrantCount: 6,
|
||||||
|
grantSource: 'irs_990pf',
|
||||||
});
|
});
|
||||||
// 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway
|
// 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway
|
||||||
expect(result.totalScore).toBe(93);
|
expect(result.totalScore).toBe(93);
|
||||||
@@ -135,8 +138,81 @@ describe('scoreMatch', () => {
|
|||||||
closeDate: weeksFromNow(2),
|
closeDate: weeksFromNow(2),
|
||||||
now: NOW,
|
now: NOW,
|
||||||
funderStateGrantCount: null,
|
funderStateGrantCount: null,
|
||||||
|
grantSource: 'grants_gov',
|
||||||
});
|
});
|
||||||
expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD);
|
expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD);
|
||||||
expect(result.easyWin).toBe(false);
|
expect(result.easyWin).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks federal matches below the mission-fit floor non-viable', () => {
|
||||||
|
// similarity 0.55 → fit 10 < MISSION_FIT_VIABLE_MIN (12)
|
||||||
|
const result = scoreMatch({
|
||||||
|
similarity: 0.55,
|
||||||
|
orgTotalRevenue: 1_000_000,
|
||||||
|
awardCeiling: 200_000,
|
||||||
|
geographicScope: null,
|
||||||
|
applicationEffortEstimate: 'full_federal',
|
||||||
|
closeDate: weeksFromNow(6),
|
||||||
|
now: NOW,
|
||||||
|
funderStateGrantCount: 20,
|
||||||
|
grantSource: 'grants_gov',
|
||||||
|
});
|
||||||
|
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
|
||||||
|
expect(result.fitViable).toBe(false);
|
||||||
|
expect(result.easyWin).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps federal matches at/above the floor viable', () => {
|
||||||
|
// similarity 0.57 → fit 12 = floor
|
||||||
|
const result = scoreMatch({
|
||||||
|
similarity: 0.57,
|
||||||
|
orgTotalRevenue: 1_000_000,
|
||||||
|
awardCeiling: 200_000,
|
||||||
|
geographicScope: null,
|
||||||
|
applicationEffortEstimate: 'full_federal',
|
||||||
|
closeDate: weeksFromNow(6),
|
||||||
|
now: NOW,
|
||||||
|
funderStateGrantCount: 20,
|
||||||
|
grantSource: 'grants_gov',
|
||||||
|
});
|
||||||
|
expect(result.subscores.missionFit).toBeGreaterThanOrEqual(
|
||||||
|
MISSION_FIT_VIABLE_MIN,
|
||||||
|
);
|
||||||
|
expect(result.fitViable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exempts foundation-synthesized grants from the fit floor', () => {
|
||||||
|
// Generic 990-PF synopses carry no embedding signal — precedent
|
||||||
|
// evidence is the case for those matches, so low fit stays viable.
|
||||||
|
const result = scoreMatch({
|
||||||
|
similarity: 0.5,
|
||||||
|
orgTotalRevenue: 1_000_000,
|
||||||
|
awardCeiling: 200_000,
|
||||||
|
geographicScope: 'New Hampshire',
|
||||||
|
applicationEffortEstimate: 'loi_only',
|
||||||
|
closeDate: weeksFromNow(6),
|
||||||
|
now: NOW,
|
||||||
|
funderStateGrantCount: 12,
|
||||||
|
grantSource: 'irs_990pf',
|
||||||
|
});
|
||||||
|
expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN);
|
||||||
|
expect(result.fitViable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates easy-win on fit viability, not just score and precedent', () => {
|
||||||
|
const belowFloor = scoreMatch({
|
||||||
|
similarity: 0.55,
|
||||||
|
orgTotalRevenue: 1_000_000,
|
||||||
|
awardCeiling: 200_000,
|
||||||
|
geographicScope: 'New Hampshire',
|
||||||
|
applicationEffortEstimate: 'loi_only',
|
||||||
|
closeDate: weeksFromNow(6),
|
||||||
|
now: NOW,
|
||||||
|
funderStateGrantCount: 20,
|
||||||
|
grantSource: 'grants_gov',
|
||||||
|
});
|
||||||
|
// 10+25+15+15+10+5 = 80 ≥ threshold, precedent 25 ≥ floor — but not viable.
|
||||||
|
expect(belowFloor.totalScore).toBeGreaterThanOrEqual(EASY_WIN_THRESHOLD);
|
||||||
|
expect(belowFloor.easyWin).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,12 +39,15 @@ export interface ScoreMatchInput {
|
|||||||
readonly now: Date;
|
readonly now: Date;
|
||||||
/**
|
/**
|
||||||
* Historical grants this funder has paid to recipients in the org's
|
* Historical grants this funder has paid to recipients in the org's
|
||||||
* state (from the 990-PF index). Null = no precedent data for this
|
* state (990-PF index for foundations; USASpending PEER award count for
|
||||||
* grant's funder (e.g. federal agencies) — scores 0, not neutral: the
|
* federal programs — recipients name-matched to primary-ICP NH orgs).
|
||||||
* plan weights precedent as the strongest single predictor, and absence
|
* Null = no precedent data — scores 0, not neutral: the plan weights
|
||||||
* of evidence should rank below presence.
|
* precedent as the strongest single predictor, and absence of evidence
|
||||||
|
* should rank below presence.
|
||||||
*/
|
*/
|
||||||
readonly funderStateGrantCount: number | null;
|
readonly funderStateGrantCount: number | null;
|
||||||
|
/** Grant source (`grants.source`) — drives the source-aware fit floor. */
|
||||||
|
readonly grantSource: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ACHIEVABLE_MAX_SCORE = 100;
|
export const ACHIEVABLE_MAX_SCORE = 100;
|
||||||
@@ -57,6 +60,18 @@ export const ACHIEVABLE_MAX_SCORE = 100;
|
|||||||
export const EASY_WIN_THRESHOLD = 65;
|
export const EASY_WIN_THRESHOLD = 65;
|
||||||
export const EASY_WIN_MIN_PRECEDENT = 12;
|
export const EASY_WIN_MIN_PRECEDENT = 12;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mission-fit floor (federal RFPs only): below 12/30 (≈ cosine 0.57) the
|
||||||
|
* match is stored but NOT review-viable — non-mission subscores sum to 50,
|
||||||
|
* so without a floor a boys' camp scores 60 on an NIH obesity-research
|
||||||
|
* center grant purely on precedent + capacity. Foundation-synthesized
|
||||||
|
* grants are exempt: their synopses are generic by construction ("grants
|
||||||
|
* for NH nonprofits"), so embedding fit carries no signal there and the
|
||||||
|
* precedent evidence IS the case for the match.
|
||||||
|
*/
|
||||||
|
export const MISSION_FIT_VIABLE_MIN = 12;
|
||||||
|
const FIT_FLOOR_SOURCES: ReadonlySet<string> = new Set(['grants_gov']);
|
||||||
|
|
||||||
/** Similarity below this scores 0 fit; above the ceiling scores full fit. */
|
/** Similarity below this scores 0 fit; above the ceiling scores full fit. */
|
||||||
const SIMILARITY_FLOOR = 0.45;
|
const SIMILARITY_FLOOR = 0.45;
|
||||||
const SIMILARITY_CEILING = 0.75;
|
const SIMILARITY_CEILING = 0.75;
|
||||||
@@ -154,6 +169,13 @@ export interface ScoredMatch {
|
|||||||
readonly totalScore: number;
|
readonly totalScore: number;
|
||||||
readonly subscores: MatchSubscores;
|
readonly subscores: MatchSubscores;
|
||||||
readonly easyWin: boolean;
|
readonly easyWin: boolean;
|
||||||
|
/**
|
||||||
|
* False when mission fit is below the source-aware floor — the match is
|
||||||
|
* recorded (audit trail, re-scoring continuity) but hidden from the
|
||||||
|
* pending review queue. The LLM match judge may later override in
|
||||||
|
* either direction.
|
||||||
|
*/
|
||||||
|
readonly fitViable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
|
export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
|
||||||
@@ -174,11 +196,17 @@ export function scoreMatch(input: ScoreMatchInput): ScoredMatch {
|
|||||||
subscores.runway +
|
subscores.runway +
|
||||||
subscores.funderPrecedent;
|
subscores.funderPrecedent;
|
||||||
|
|
||||||
|
const fitViable =
|
||||||
|
!FIT_FLOOR_SOURCES.has(input.grantSource) ||
|
||||||
|
subscores.missionFit >= MISSION_FIT_VIABLE_MIN;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalScore,
|
totalScore,
|
||||||
subscores,
|
subscores,
|
||||||
easyWin:
|
easyWin:
|
||||||
|
fitViable &&
|
||||||
totalScore >= EASY_WIN_THRESHOLD &&
|
totalScore >= EASY_WIN_THRESHOLD &&
|
||||||
subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT,
|
subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT,
|
||||||
|
fitViable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export async function serverApplyResearchedProfile(
|
|||||||
sources: sql`excluded.sources`,
|
sources: sql`excluded.sources`,
|
||||||
confidence: sql`excluded.confidence`,
|
confidence: sql`excluded.confidence`,
|
||||||
profileEmbedding: sql`excluded.profile_embedding`,
|
profileEmbedding: sql`excluded.profile_embedding`,
|
||||||
|
// Staleness signal for the match judge: judgments older than this
|
||||||
|
// re-enter the judge queue.
|
||||||
|
updatedAt: sql`now()`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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-orgs-needing-enrichment.server.js';
|
||||||
export * from './list-match-candidate-orgs.server.js';
|
export * from './list-match-candidate-orgs.server.js';
|
||||||
export * from './list-orgs-needing-profile.server.js';
|
export * from './list-orgs-needing-profile.server.js';
|
||||||
|
export * from './list-peer-org-names.server.js';
|
||||||
|
|||||||
@@ -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