From cbc4512ffaeb09675b2bfaa601a73da8c68a2d2a Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Fri, 17 Jul 2026 10:52:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(scoring):=20v4=20=E2=80=94=20peer=20preced?= =?UTF-8?q?ent,=20mission-fit=20floor,=20LLM=20match=20judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../app/routes/matches.$matchId.tsx | 44 +- apps/outreach-worker/src/main.ts | 2 + apps/outreach-worker/src/run-once.ts | 7 + .../src/sources/usaspending/client.test.ts | 83 + .../src/sources/usaspending/client.ts | 115 +- .../src/workflows/federal-precedent.ts | 57 +- .../src/workflows/judge-matches.ts | 230 +++ .../src/workflows/match-grants.ts | 16 +- docs/features/scoring.md | 8 + .../agents/mission-fit-judge/judge.test.ts | 74 + .../src/agents/mission-fit-judge/run.ts | 174 +- .../src/agents/mission-fit-judge/schema.ts | 76 +- ...4295305_match-judge-and-peer-precedent.sql | 8 + .../1784295739_org-profile-updated-at.sql | 1 + .../server/meta/1784295305_snapshot.json | 1532 ++++++++++++++++ .../server/meta/1784295739_snapshot.json | 1539 +++++++++++++++++ .../drizzle/server/meta/_journal.json | 14 + packages/outreach-core/src/db/schema.ts | 33 +- .../actions/set-federal-precedent.server.ts | 26 +- .../src/grants/peer-precedent.test.ts | 59 + .../src/grants/peer-precedent.ts | 53 + .../list-eligible-grants-for-org.server.ts | 12 +- packages/outreach-core/src/index.ts | 2 + .../actions/apply-match-judgment.server.ts | 48 + .../actions/assign-hero-matches.server.ts | 1 + .../src/matches/actions/index.server.ts | 1 + .../actions/upsert-match-score.server.ts | 33 +- .../queries/get-match-detail.server.ts | 41 +- .../src/matches/queries/index.server.ts | 1 + .../list-matches-needing-judgment.server.ts | 74 + .../list-pending-review-matches.server.ts | 16 +- .../outreach-core/src/matches/scoring.test.ts | 76 + packages/outreach-core/src/matches/scoring.ts | 36 +- .../apply-researched-profile.server.ts | 3 + packages/outreach-core/src/orgs/org-name.ts | 22 + .../src/orgs/queries/index.server.ts | 1 + .../queries/list-peer-org-names.server.ts | 24 + 37 files changed, 4379 insertions(+), 163 deletions(-) create mode 100644 apps/outreach-worker/src/sources/usaspending/client.test.ts create mode 100644 apps/outreach-worker/src/workflows/judge-matches.ts create mode 100644 packages/outreach-ai/src/agents/mission-fit-judge/judge.test.ts create mode 100644 packages/outreach-core/drizzle/server/1784295305_match-judge-and-peer-precedent.sql create mode 100644 packages/outreach-core/drizzle/server/1784295739_org-profile-updated-at.sql create mode 100644 packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json create mode 100644 packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json create mode 100644 packages/outreach-core/src/grants/peer-precedent.test.ts create mode 100644 packages/outreach-core/src/grants/peer-precedent.ts create mode 100644 packages/outreach-core/src/matches/actions/apply-match-judgment.server.ts create mode 100644 packages/outreach-core/src/matches/queries/list-matches-needing-judgment.server.ts create mode 100644 packages/outreach-core/src/orgs/org-name.ts create mode 100644 packages/outreach-core/src/orgs/queries/list-peer-org-names.server.ts diff --git a/apps/outreach-review/app/routes/matches.$matchId.tsx b/apps/outreach-review/app/routes/matches.$matchId.tsx index 1d171a5..6f940f8 100644 --- a/apps/outreach-review/app/routes/matches.$matchId.tsx +++ b/apps/outreach-review/app/routes/matches.$matchId.tsx @@ -50,6 +50,13 @@ const SUBSCORE_MAX: Record = { runway: 5, }; +const JUDGE_VERDICT_STYLE: Record = { + strong_fit: 'bg-green-100 text-green-800', + plausible: 'bg-blue-100 text-blue-800', + weak: 'bg-amber-100 text-amber-800', + mismatch: 'bg-red-100 text-red-800', +}; + export default function MatchDetail({ loaderData }: Route.ComponentProps) { const { detail } = loaderData ?? {}; if (detail == null) return null; @@ -95,6 +102,12 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { {match.easyWin ? ' · Easy win' : ''} {match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}

+ {!match.fitViable && ( +

+ Hidden from the pending queue — mission fit below floor or + judged non-viable. +

+ )}
@@ -289,6 +302,26 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { )} + {match.judgeVerdict != null && ( +
+

+ Mission-fit judge{' '} + + {match.judgeVerdict.replace('_', ' ')} + +

+

{match.judgeRationale}

+ {match.judgedAt != null && ( +

+ Judged {new Date(match.judgedAt).toLocaleDateString('en-US')} — + the verdict replaces the embedding mission-fit score above. +

+ )} +
+ )} + {applyInfo != null && (

@@ -346,7 +379,7 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { The concrete evidence behind the precedent score —{' '} {grant.source === 'irs_990pf' ? 'who this funder actually paid, sorted in-state first.' - : 'who this federal program actually funded in NH recently. Watch for one incumbent recapturing renewals vs. genuine spread across orgs.'} + : 'who this federal program actually funded in NH recently, peers first. Only "peer" recipients (registered NH nonprofits our size) count toward the precedent score — universities, hospital systems, and companies do not.'}

@@ -361,7 +394,14 @@ export default function MatchDetail({ loaderData }: Route.ComponentProps) { {nhHistory.map((g, i) => ( - +
{g.recipientName} + {g.recipientName} + {g.isPeer === true && ( + + peer + + )} + {g.recipientCity ?? '—'} {dollars(g.amount)} diff --git a/apps/outreach-worker/src/main.ts b/apps/outreach-worker/src/main.ts index 68c37ab..ccf0371 100644 --- a/apps/outreach-worker/src/main.ts +++ b/apps/outreach-worker/src/main.ts @@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js'; import { setExpireGrantsDeps } from './workflows/expire-grants.js'; import { setFederalPrecedentDeps } from './workflows/federal-precedent.js'; import { setIngestGrantsDeps } from './workflows/ingest-grants.js'; +import { setJudgeMatchesDeps } from './workflows/judge-matches.js'; import { setIngest990pfDeps } from './workflows/ingest-990pf.js'; import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js'; import { setMatchGrantsDeps } from './workflows/match-grants.js'; @@ -75,6 +76,7 @@ async function main() { setIngest990pfDeps({ db }); setFederalPrecedentDeps({ db }); setProfileOrgsDeps({ db }); + setJudgeMatchesDeps({ db }); DBOS.setConfig({ name: 'helmdocs-outreach-worker', diff --git a/apps/outreach-worker/src/run-once.ts b/apps/outreach-worker/src/run-once.ts index bcece71..2edddb6 100644 --- a/apps/outreach-worker/src/run-once.ts +++ b/apps/outreach-worker/src/run-once.ts @@ -44,6 +44,10 @@ import { runIngestNhdojOrgsNow, setIngestNhdojOrgsDeps, } from './workflows/ingest-nhdoj-orgs.js'; +import { + runJudgeMatchesNow, + setJudgeMatchesDeps, +} from './workflows/judge-matches.js'; import { runMatchGrantsNow, setMatchGrantsDeps, @@ -68,6 +72,7 @@ const RUNNERS: Record Promise> = { ingest990pf: runIngest990PfNow, federalPrecedent: runFederalPrecedentNow, profileOrgs: runProfileOrgsNow, + judgeMatches: runJudgeMatchesNow, }; const FIRST_RUN_ORDER = [ @@ -81,6 +86,7 @@ const FIRST_RUN_ORDER = [ 'embedGrants', 'matchGrants', 'profileOrgs', + 'judgeMatches', ]; if (process.env.DATABASE_URL == null) { @@ -120,6 +126,7 @@ async function main() { setIngest990pfDeps({ db }); setFederalPrecedentDeps({ db }); setProfileOrgsDeps({ db }); + setJudgeMatchesDeps({ db }); DBOS.setConfig({ name: 'helmdocs-outreach-worker', diff --git a/apps/outreach-worker/src/sources/usaspending/client.test.ts b/apps/outreach-worker/src/sources/usaspending/client.test.ts new file mode 100644 index 0000000..8a1d3a3 --- /dev/null +++ b/apps/outreach-worker/src/sources/usaspending/client.test.ts @@ -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/); + }); +}); diff --git a/apps/outreach-worker/src/sources/usaspending/client.ts b/apps/outreach-worker/src/sources/usaspending/client.ts index 6a585ff..fc9b08e 100644 --- a/apps/outreach-worker/src/sources/usaspending/client.ts +++ b/apps/outreach-worker/src/sources/usaspending/client.ts @@ -12,6 +12,15 @@ const GRANT_AWARD_TYPE_CODES = ['02', '03', '04', '05']; const DEFAULT_POLITENESS_DELAY_MS = 300; +const PAGE_SIZE = 100; +/** + * Recipient-list fetch cap. Peer classification runs over the fetched + * window, so a program with >500 NH awards gets its peer count computed + * on the first 500 — an undercount for the very largest programs, which + * is the conservative direction (see classifyPeerAwards in outreach-core). + */ +const MAX_AWARDS_FETCHED = 500; + export interface ProgramStateAwardSample { readonly recipientName: string; readonly amount: number | null; @@ -20,24 +29,38 @@ export interface ProgramStateAwardSample { export interface ProgramStateAwards { readonly aln: string; + /** Total matching awards per the API (may exceed `samples.length`). */ readonly awardCount: number; + /** Every fetched award (up to MAX_AWARDS_FETCHED), not a 10-row teaser. */ readonly samples: ProgramStateAwardSample[]; } export interface UsaSpendingClientOptions { readonly fetchImpl?: typeof fetch; readonly politenessDelayMs?: number; + /** Test hook; production uses MAX_AWARDS_FETCHED. */ + readonly maxAwardsFetched?: number; } function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +interface SpendingByAwardResponse { + results?: Array<{ + 'Recipient Name'?: string | null; + 'Award Amount'?: number | null; + 'Start Date'?: string | null; + }>; + page_metadata?: { total?: number | null; hasNext?: boolean | null }; +} + /** * Recent grant awards under one Assistance Listing to recipients in one - * state: total count plus a handful of sample recipients (review-page - * evidence — lets a human spot "one incumbent's renewals" vs "spread - * across orgs like ours"). + * state: total count plus the full fetched recipient list. The list feeds + * two consumers — the review-page evidence table, and peer classification + * ("did orgs like ours win this, or just Dartmouth?"), which needs every + * recipient, hence pagination instead of the old single 10-row page. */ export async function fetchProgramStateAwards( aln: string, @@ -46,48 +69,52 @@ export async function fetchProgramStateAwards( options: UsaSpendingClientOptions = {}, ): Promise { const fetchImpl = options.fetchImpl ?? fetch; + const maxAwards = options.maxAwardsFetched ?? MAX_AWARDS_FETCHED; - const res = await fetchImpl(SEARCH_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filters: { - award_type_codes: GRANT_AWARD_TYPE_CODES, - recipient_locations: [{ country: 'USA', state }], - time_period: [{ start_date: startDate, end_date: endDate }], - program_numbers: [aln], - }, - fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'], - limit: 10, - page: 1, - }), - }); - if (!res.ok) { - throw new Error( - `USASpending search failed for ALN ${aln} (${state}): ${res.status} ${res.statusText}`, - ); + const samples: ProgramStateAwardSample[] = []; + let total = 0; + let page = 1; + + for (;;) { + const res = await fetchImpl(SEARCH_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filters: { + award_type_codes: GRANT_AWARD_TYPE_CODES, + recipient_locations: [{ country: 'USA', state }], + time_period: [{ start_date: startDate, end_date: endDate }], + program_numbers: [aln], + }, + fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Start Date'], + limit: PAGE_SIZE, + page, + }), + }); + if (!res.ok) { + throw new Error( + `USASpending search failed for ALN ${aln} (${state}) page ${page}: ${res.status} ${res.statusText}`, + ); + } + + const body = (await res.json()) as SpendingByAwardResponse; + const results = body.results ?? []; + for (const r of results) { + samples.push({ + recipientName: r['Recipient Name'] ?? 'Unknown recipient', + amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']), + startDate: r['Start Date'] ?? null, + }); + } + total = Math.max(body.page_metadata?.total ?? 0, samples.length); + + await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS); + + const hasNext = + body.page_metadata?.hasNext ?? results.length === PAGE_SIZE; + if (!hasNext || results.length === 0 || samples.length >= maxAwards) break; + page++; } - const body = (await res.json()) as { - results?: Array<{ - 'Recipient Name'?: string | null; - 'Award Amount'?: number | null; - 'Start Date'?: string | null; - }>; - page_metadata?: { total?: number | null }; - }; - - await wait(options.politenessDelayMs ?? DEFAULT_POLITENESS_DELAY_MS); - - const samples: ProgramStateAwardSample[] = (body.results ?? []).map((r) => ({ - recipientName: r['Recipient Name'] ?? 'Unknown recipient', - amount: r['Award Amount'] == null ? null : Math.round(r['Award Amount']), - startDate: r['Start Date'] ?? null, - })); - - return { - aln, - awardCount: body.page_metadata?.total ?? samples.length, - samples, - }; + return { aln, awardCount: total, samples }; } diff --git a/apps/outreach-worker/src/workflows/federal-precedent.ts b/apps/outreach-worker/src/workflows/federal-precedent.ts index 8b5f505..8696f0a 100644 --- a/apps/outreach-worker/src/workflows/federal-precedent.ts +++ b/apps/outreach-worker/src/workflows/federal-precedent.ts @@ -2,20 +2,28 @@ * Nightly federal-precedent workflow — USASpending award history per * Assistance Listing (ALN/CFDA), the federal analog of the 990-PF index. * - * For each distinct ALN across open federal grants: count grant awards to - * recipients in the target state over the last ~3 fiscal years, stamp the - * count + sample recipients onto every open grant carrying that ALN. - * Match retrieval feeds the count into the same 25-point precedent - * subscore foundations use — "this program funded N NH orgs recently" and + * For each distinct ALN across open federal grants: fetch grant awards to + * recipients in the target state over the last ~3 fiscal years, classify + * each recipient as peer/non-peer against the primary-ICP NH registry + * (classifyPeerAwards — Dartmouth renewals and SBIR LLCs are not + * precedent for a community nonprofit), and stamp raw count, PEER count, + * and the annotated award list onto every open grant carrying that ALN. + * Match retrieval feeds the peer count into the same 25-point precedent + * subscore foundations use — "this program funded N orgs like ours" and * "this foundation funded N NH orgs recently" are the same signal. * * Runs at 04:45 — after ingest (03:00, which refreshes ALNs) and before * matching (05:15). Registration follows `ingest-grants.ts` exactly. */ import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk'; -import type { schema } from '@novelpad/outreach-core'; +import { + buildPeerNameSet, + classifyPeerAwards, + type schema, +} from '@novelpad/outreach-core'; import { serverListOpenFederalAlns, + serverListPeerOrgNames, serverResetFederalPrecedent, serverSetFederalPrecedent, } from '@novelpad/outreach-core/server'; @@ -60,6 +68,15 @@ const listAlnsStep = DBOS.registerStep(listAlns, { maxAttempts: 3, }); +async function listPeerNames(db: OutreachDb): Promise { + return serverListPeerOrgNames(db); +} +const listPeerNamesStep = DBOS.registerStep(listPeerNames, { + name: 'listPeerOrgNames', + retriesAllowed: true, + maxAttempts: 3, +}); + async function fetchAlnAwards( aln: string, startDate: string, @@ -78,11 +95,12 @@ const fetchAlnAwardsStep = DBOS.registerStep(fetchAlnAwards, { async function applyPrecedent( db: OutreachDb, - precedent: ProgramStateAwards, + precedent: ProgramStateAwards & { peerAwardCount: number; samples: unknown }, ): Promise { await serverSetFederalPrecedent(db, { aln: precedent.aln, awardCount: precedent.awardCount, + peerAwardCount: precedent.peerAwardCount, samples: precedent.samples, }); } @@ -112,6 +130,11 @@ async function runFederalPrecedent(): Promise { return; } + const peerNames = buildPeerNameSet(await listPeerNamesStep(db)); + console.log( + `[federal-precedent] peer universe: ${peerNames.size} primary-ICP NH orgs`, + ); + const end = new Date(); const start = new Date(end); start.setFullYear(start.getFullYear() - LOOKBACK_YEARS); @@ -121,13 +144,25 @@ async function runFederalPrecedent(): Promise { await resetPrecedentStep(db); let programsWithHistory = 0; + let programsWithPeers = 0; let failed = 0; for (const aln of alns) { try { - const precedent = await fetchAlnAwardsStep(aln, startDate, endDate); - if (precedent.awardCount > 0) { - await applyPrecedentStep(db, precedent); + const fetched = await fetchAlnAwardsStep(aln, startDate, endDate); + if (fetched.awardCount > 0) { + // Pure classification over step outputs — deterministic on replay, + // so it doesn't need to be a step itself. + const { peerAwardCount, awards } = classifyPeerAwards( + fetched.samples, + peerNames, + ); + await applyPrecedentStep(db, { + ...fetched, + peerAwardCount, + samples: awards, + }); programsWithHistory++; + if (peerAwardCount > 0) programsWithPeers++; } } catch (err) { failed++; @@ -136,7 +171,7 @@ async function runFederalPrecedent(): Promise { } console.log( - `[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} failed=${failed}`, + `[federal-precedent] programs=${alns.length} withNhHistory=${programsWithHistory} withPeerHistory=${programsWithPeers} failed=${failed}`, ); if (failed > 0 && failed / alns.length > 0.2) { throw new Error( diff --git a/apps/outreach-worker/src/workflows/judge-matches.ts b/apps/outreach-worker/src/workflows/judge-matches.ts new file mode 100644 index 0000000..7294a84 --- /dev/null +++ b/apps/outreach-worker/src/workflows/judge-matches.ts @@ -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; + +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; + 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 { + 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 { + 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 { + 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 = {}; + 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; +}; + +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 { + 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()); +} diff --git a/apps/outreach-worker/src/workflows/match-grants.ts b/apps/outreach-worker/src/workflows/match-grants.ts index f26dee5..9d6e6f0 100644 --- a/apps/outreach-worker/src/workflows/match-grants.ts +++ b/apps/outreach-worker/src/workflows/match-grants.ts @@ -27,6 +27,7 @@ import { generateQueryEmbedding } from '@novelpad/outreach-ai'; import { buildOrgMissionText, evaluateHardGates, + normalizeOrgNameForMatching, scoreMatch, type HardGateFailureReason, type schema, @@ -123,14 +124,6 @@ const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, { maxAttempts: 3, }); -/** Case/punctuation/suffix-insensitive equality for self-match detection. */ -function normalizeSelfMatchName(name: string): string { - return name - .toUpperCase() - .replace(/\b(INC|INCORPORATED|TTEE|TRUSTEE|FUND|FOUNDATION|CHARITABLE|TRUST)\b/g, '') - .replace(/[^A-Z0-9]/g, ''); -} - async function scoreAndStoreOrgMatches( db: OutreachDb, org: MatchCandidateOrg, @@ -152,7 +145,8 @@ async function scoreAndStoreOrgMatches( grant.funderEin === org.ein; const isSelfByName = grant.funderEin != null && - normalizeSelfMatchName(grant.funder) === normalizeSelfMatchName(org.name); + normalizeOrgNameForMatching(grant.funder) === + normalizeOrgNameForMatching(org.name); if (isSelfByEin || isSelfByName) { gated++; continue; @@ -186,6 +180,7 @@ async function scoreAndStoreOrgMatches( applicationEffortEstimate: grant.applicationEffortEstimate, closeDate: grant.closeDate, now, + grantSource: grant.source, }); await serverUpsertMatchScore(db, { @@ -195,12 +190,13 @@ async function scoreAndStoreOrgMatches( subscores: scored.subscores, hardGatesPassed, easyWin: scored.easyWin, + fitViable: scored.fitViable, rationale: { similarity: grant.similarity, gateFailures: gates.failures, ignoredGates: [...IGNORED_GATES], scoredAt: now.toISOString(), - scoringVersion: 'v2-state-precedent', + scoringVersion: 'v3-peer-precedent', }, }); stored++; diff --git a/docs/features/scoring.md b/docs/features/scoring.md index ce3ca6e..6c98503 100644 --- a/docs/features/scoring.md +++ b/docs/features/scoring.md @@ -29,3 +29,11 @@ Funder precedent now covers federal grants at the **program (ALN/CFDA) level** Ingest refresh also now rotates oldest-verified-first (`serverMapGrantVerification`) — the plain-Set version re-fetched the same head of the search results every pass, which had left 365 of 565 grants without ALNs. First sweep: 156 distinct programs, 85 with NH history, 468/564 open federal grants carrying precedent; first federal easy-wins appeared (score 66, precedent 25/25, real Aug-24 deadline). + +## v4 (2026-07-17): peer precedent + mission-fit floor + LLM match judge + +The v3 sweep exposed two structural failures: ALN-level counts were **recipient-blind** (NORC P30 carried 25/25 precedent because ten NH awards existed — all to Dartmouth entities and two biotech LLCs), and **mission fit couldn't veto** (non-mission subscores sum to 50, so a boys' camp scored 60+ on NIH research-center grants). Three fixes, `scoringVersion: v3-peer-precedent`: + +1. **Peer precedent** — `federalPrecedent` now paginates USASpending (up to 500 awards/program, not a 10-row sample) and classifies every recipient against the primary-ICP NH registry by normalized name (`classifyPeerAwards` + `normalizeOrgNameForMatching`, the same normalizer the self-match gate uses; LLC/LTD deliberately not stripped). The 25-point precedent tiers key off `program_state_peer_award_count` — awards won by orgs shaped like our candidates — while the raw count and the peer-annotated award list stay on the grant as review evidence (peers get a green badge, sorted first). Conservative by construction: unmatched/unenriched recipients count as non-peer; missed precedent demotes a real match rather than pitching a false one. +2. **Mission-fit floor** (`MISSION_FIT_VIABLE_MIN = 12/30`, federal sources only) — below it a match is stored (`fit_viable = false`) but hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants are exempt: their synopses are generic by construction, so embedding fit carries no signal and precedent evidence is the case for the match. +3. **Mission-fit judge** (`judgeMatches`, 06:15, `JUDGE_MATCHES_PER_RUN` default 200) — JUDGE_MODEL reads the actual grant synopsis against the org's (researched or stub) profile, with the explicit instruction to **ignore eligibility breadth** and judge what the program funds and who realistically performs that work (NIH R/P/U mechanisms fit only research performers). Graded verdict with required citations from both sides; deterministic code maps verdict → mission-fit points (strong_fit 27 / plausible 18 / weak 8 / mismatch 0), recomputes total + easy-win, and sets `fit_viable` (weak/mismatch → hidden). Judged rows keep their verdict across nightly re-scores (the upsert splices `judge_mission_fit` back in) and re-enter the judge queue only when the org profile is re-researched (`org_profiles.updated_at > judged_at`). Best-scoring first, so the head of the human queue is always LLM-verified before a human reads it. The detail page shows the verdict, rationale, and citations. diff --git a/packages/outreach-ai/src/agents/mission-fit-judge/judge.test.ts b/packages/outreach-ai/src/agents/mission-fit-judge/judge.test.ts new file mode 100644 index 0000000..30f9cd0 --- /dev/null +++ b/packages/outreach-ai/src/agents/mission-fit-judge/judge.test.ts @@ -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'); + }); +}); diff --git a/packages/outreach-ai/src/agents/mission-fit-judge/run.ts b/packages/outreach-ai/src/agents/mission-fit-judge/run.ts index 12c587e..aad11d9 100644 --- a/packages/outreach-ai/src/agents/mission-fit-judge/run.ts +++ b/packages/outreach-ai/src/agents/mission-fit-judge/run.ts @@ -1,64 +1,136 @@ -import { MissionFitVerdictSchema, type MissionFitVerdict } from './schema.js'; -import type { OrgProfile } from '../org-profiler/schema.js'; +import { getAi } from '../../gemini.js'; +import { JUDGE_MODEL } from '../../models.js'; +import { + MATCH_JUDGE_VERDICT_JSON_SCHEMA, + MatchJudgeVerdictSchema, + type MatchJudgeVerdict, +} from './schema.js'; -export interface RunMissionFitJudgeInput { - /** Extracted profile of the candidate org (from the Org Profiler). */ - orgProfile: OrgProfile; - /** Grant program name / title, as scored against by the deterministic SQL gates. */ - grantProgramName: string; - /** Funding priorities / eligible-use language pulled from the grant's own source text. */ - grantPriorities: string[]; +export interface JudgeOrgSide { + readonly name: string; + readonly city: string | null; + /** Researched or NTEE-stub mission text (whatever the profile holds). */ + readonly missionStatement: string | null; + /** Researched programs, if the Stage 4 profiler has run for this org. */ + readonly programs: Array<{ + name: string; + description: string | null; + populationServed: string | null; + }>; + readonly serviceGeography: string | null; + /** Profile confidence — low means the org side is mostly an NTEE guess. */ + readonly profileConfidence: number | null; } +export interface JudgeGrantSide { + readonly title: string; + readonly funder: string; + readonly synopsis: string | null; +} + +export interface RunMatchJudgeInput { + readonly org: JudgeOrgSide; + readonly grant: JudgeGrantSide; +} + +const MAX_SYNOPSIS_CHARS = 12_000; + /** - * Build the judge prompt for one (org, grant) match candidate. Kept separate - * from `runMissionFitJudge` so it's independently unit-testable once wired up. + * Build the judge prompt for one (org, grant) candidate. Kept separate + * from `runMatchJudge` so it's independently unit-testable. + * + * The core instruction exists because of a concrete failure mode: + * federal (especially NIH) synopses declare near-universal *eligibility* + * while the funded work is highly specific — "nonprofits may apply" put a + * boys' summer camp on an obesity-research center grant. Eligibility + * breadth is therefore explicitly out of scope; the judge reads what the + * program FUNDS and who realistically performs that work. */ -export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string { +export function buildMatchJudgePrompt(input: RunMatchJudgeInput): string { + const { org, grant } = input; + + const programLines = + org.programs.length > 0 + ? org.programs + .map((p) => + [ + `- ${p.name}`, + p.description, + p.populationServed == null ? null : `serves ${p.populationServed}`, + ] + .filter(Boolean) + .join(' — '), + ) + .join('\n') + : '(no researched program list — judge from the mission text alone)'; + return [ - 'You are the final mission-fit judge for a candidate (org, grant) match', - 'that has already passed deterministic SQL hard gates (eligibility,', - 'geography, award range). Decide whether the org\'s actual programs', - 'plausibly fit the grant\'s funding priorities. You MUST cite one', - 'concrete org program and one concrete grant priority your verdict is', - 'grounded in — a verdict without both citations is invalid. A wrong', - '"fit: true" here can put a real NH nonprofit in front of a funder that', - 'will never fund them, so when the fit is unclear, prefer `fit: false`.', + 'You judge whether a specific nonprofit is a credible fit for a specific', + 'grant program. The pair already passed automated eligibility, geography,', + 'and deadline gates; your ONLY question is programmatic mission fit:', + 'does the work this org actually does match what this grant actually funds?', '', - `Grant program: ${input.grantProgramName}`, - `Grant priorities: ${input.grantPriorities.join('; ')}`, - `Org legal name: ${input.orgProfile.legalName.value}`, - `Org mission: ${input.orgProfile.mission.value}`, - `Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`, + 'Rules:', + '- IGNORE eligibility breadth entirely. Federal synopses often say any', + ' nonprofit may apply while the funded activity is narrow, technical, or', + ' institutional (research centers, clinical trials, training programs at', + ' universities). Judge what gets FUNDED and who realistically performs', + ' that work, not who is allowed to apply.', + '- A research-mechanism grant (center grants, clinical trials, R-series/', + ' P-series/U-series NIH mechanisms) fits only orgs that conduct that kind', + ' of research.', + '- Ground the verdict in one concrete org activity and one concrete piece', + ' of synopsis language; a verdict without both citations is invalid.', + "- A wrong positive verdict wastes a real fundraiser's time and burns our", + ' credibility with them. When genuinely torn between two verdicts, pick', + ' the lower one.', + '', + 'Verdicts:', + "- strong_fit: the org's core work is squarely what the program funds.", + '- plausible: real overlap; a competent grant writer could make the case.', + '- weak: tangential overlap only; the org would be an outlier applicant.', + '- mismatch: the org does not do what this program funds.', + '', + '--- GRANT ---', + `Title: ${grant.title}`, + `Funder: ${grant.funder}`, + `Synopsis: ${(grant.synopsis ?? '(none)').slice(0, MAX_SYNOPSIS_CHARS)}`, + '', + '--- ORGANIZATION ---', + `Name: ${org.name}`, + `Location: ${org.city ?? 'unknown'}, NH`, + `Mission: ${org.missionStatement ?? '(unknown)'}`, + `Service area: ${org.serviceGeography ?? '(unknown)'}`, + 'Programs:', + programLines, + ...(org.profileConfidence != null && org.profileConfidence < 0.5 + ? [ + '', + 'NOTE: this org profile is low-confidence (category-derived, not', + 'researched). Judge from the mission category; do not invent programs.', + ] + : []), ].join('\n'); } /** - * NOT IMPLEMENTED — this judge is the veto gate before a match can reach a - * human reviewer (and, downstream, a real prospect via Apollo), so it should - * not go live against real matches until the deterministic hard-gate scoring - * this package doesn't own is wired in ahead of it. Intended production call - * shape, mirroring novelpad-desktop's - * packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex + - * responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`, - * not `BULK_MODEL`: a wrong verdict here reaches a prospect: - * - * import { getAi } from '../../gemini.js'; - * import { JUDGE_MODEL } from '../../models.js'; - * - * const result = await getAi().models.generateContent({ - * model: JUDGE_MODEL, - * contents: buildMissionFitJudgePrompt(input), - * config: { - * responseMimeType: 'application/json', - * // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema - * // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema. - * temperature: 0.1, - * }, - * }); - * const raw = JSON.parse(result.text ?? '{}'); - * return MissionFitVerdictSchema.parse(raw); + * One structured judge call. JUDGE_MODEL per the tiering rule in + * models.ts — this verdict gates matches into/out of the review queue. + * `MATCH_JUDGE_MODEL` env overrides for cheap bulk experiments. */ -export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise { - throw new Error('not implemented'); +export async function runMatchJudge( + input: RunMatchJudgeInput, +): Promise<{ verdict: MatchJudgeVerdict; model: string }> { + const model = process.env.MATCH_JUDGE_MODEL ?? JUDGE_MODEL; + const result = await getAi().models.generateContent({ + model, + contents: buildMatchJudgePrompt(input), + config: { + responseMimeType: 'application/json', + responseJsonSchema: MATCH_JUDGE_VERDICT_JSON_SCHEMA, + temperature: 0.1, + }, + }); + const raw: unknown = JSON.parse(result.text ?? '{}'); + return { verdict: MatchJudgeVerdictSchema.parse(raw), model }; } diff --git a/packages/outreach-ai/src/agents/mission-fit-judge/schema.ts b/packages/outreach-ai/src/agents/mission-fit-judge/schema.ts index 0abe7d0..54bfae9 100644 --- a/packages/outreach-ai/src/agents/mission-fit-judge/schema.ts +++ b/packages/outreach-ai/src/agents/mission-fit-judge/schema.ts @@ -1,20 +1,68 @@ import { z } from 'zod'; /** - * Mission-fit judge verdict for one (org, grant) match candidate. This is - * the last human-facing gate before a match is surfaced for review — the - * judge must ground its verdict in something concrete from each side rather - * than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not - * optional summary fields. + * Graded mission-fit verdict for one (org, grant) match candidate that + * already passed the deterministic hard gates and subscore ranking. + * + * The judge names a verdict; deterministic code assigns the number + * (missionFitFromVerdict) and decides queue viability + * (verdictIsFitViable) — the LLM never emits a score directly, so the + * mapping can be re-tuned from review data without re-judging anything. + * + * Citations are required, not optional summary fields: a verdict must be + * grounded in something concrete from each side rather than a vibe. */ -export const MissionFitVerdictSchema = z.object({ - /** Whether the org's mission plausibly fits the grant's funding priorities. */ - fit: z.boolean(), - /** The specific org program/activity the verdict is grounded in (from the org profile). */ - citedOrgProgram: z.string().min(1), - /** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */ - citedGrantPriority: z.string().min(1), - /** Short human-readable justification tying the two citations together. */ +export const MatchJudgeVerdictSchema = z.object({ + verdict: z.enum(['strong_fit', 'plausible', 'weak', 'mismatch']), + /** The specific org program/activity the verdict is grounded in. */ + citedOrgEvidence: z.string().min(1), + /** The specific synopsis language (purpose/priorities) the verdict is grounded in. */ + citedGrantEvidence: z.string().min(1), + /** 1–3 sentence justification tying the two citations together. */ reasoning: z.string().min(1), }); -export type MissionFitVerdict = z.infer; +export type MatchJudgeVerdict = z.infer; + +/** 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'; +} diff --git a/packages/outreach-core/drizzle/server/1784295305_match-judge-and-peer-precedent.sql b/packages/outreach-core/drizzle/server/1784295305_match-judge-and-peer-precedent.sql new file mode 100644 index 0000000..4f0ed2a --- /dev/null +++ b/packages/outreach-core/drizzle/server/1784295305_match-judge-and-peer-precedent.sql @@ -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; \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/1784295739_org-profile-updated-at.sql b/packages/outreach-core/drizzle/server/1784295739_org-profile-updated-at.sql new file mode 100644 index 0000000..2377c63 --- /dev/null +++ b/packages/outreach-core/drizzle/server/1784295739_org-profile-updated-at.sql @@ -0,0 +1 @@ +ALTER TABLE "org_profiles" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now(); \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json b/packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json new file mode 100644 index 0000000..9aabbf6 --- /dev/null +++ b/packages/outreach-core/drizzle/server/meta/1784295305_snapshot.json @@ -0,0 +1,1532 @@ +{ + "id": "6ced384f-6dd8-4637-9136-c20129a13fea", + "prevId": "8723e7d9-ee26-468e-95b5-6d609eea60bc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_status": { + "name": "email_status", + "type": "email_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "source_provider": { + "name": "source_provider", + "type": "contact_source_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "contact_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_contacts_org": { + "name": "idx_contacts_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_email": { + "name": "idx_contacts_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_org_id_orgs_id_fk": { + "name": "contacts_org_id_orgs_id_fk", + "tableFrom": "contacts", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funder_grants": { + "name": "funder_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder_id": { + "name": "funder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_city": { + "name": "recipient_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_state": { + "name": "recipient_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_year": { + "name": "tax_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funder_grants_funder": { + "name": "idx_funder_grants_funder", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_funder_year": { + "name": "idx_funder_grants_funder_year", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tax_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_recipient_state": { + "name": "idx_funder_grants_recipient_state", + "columns": [ + { + "expression": "recipient_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funder_grants_funder_id_funders_id_fk": { + "name": "funder_grants_funder_id_funders_id_fk", + "tableFrom": "funder_grants", + "tableTo": "funders", + "columnsFrom": [ + "funder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funders": { + "name": "funders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_assets": { + "name": "total_assets", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "application_info": { + "name": "application_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "latest_tax_year": { + "name": "latest_tax_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latest_object_id": { + "name": "latest_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funders_ein": { + "name": "idx_funders_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funders_state": { + "name": "idx_funders_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grants": { + "name": "grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligibility_entity_types": { + "name": "eligibility_entity_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "geographic_scope": { + "name": "geographic_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "program_areas": { + "name": "program_areas", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "award_floor": { + "name": "award_floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "award_ceiling": { + "name": "award_ceiling", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_awards_count": { + "name": "expected_awards_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "open_date": { + "name": "open_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_date": { + "name": "close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "match_requirement": { + "name": "match_requirement", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "application_effort_estimate": { + "name": "application_effort_estimate", + "type": "application_effort_estimate", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "application_form_supported": { + "name": "application_form_supported", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "funder_ein": { + "name": "funder_ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alns": { + "name": "alns", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "program_state_award_count": { + "name": "program_state_award_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "program_state_peer_award_count": { + "name": "program_state_peer_award_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "program_state_awards": { + "name": "program_state_awards", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "synopsis_embedding": { + "name": "synopsis_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_grants_source_url": { + "name": "idx_grants_source_url", + "columns": [ + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_status": { + "name": "idx_grants_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_close_date": { + "name": "idx_grants_close_date", + "columns": [ + { + "expression": "close_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_source": { + "name": "idx_grants_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_funder_ein": { + "name": "idx_grants_funder_ein", + "columns": [ + { + "expression": "funder_ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grants_synopsis_embedding_idx": { + "name": "grants_synopsis_embedding_idx", + "columns": [ + { + "expression": "synopsis_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subscores": { + "name": "subscores", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hard_gates_passed": { + "name": "hard_gates_passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "easy_win": { + "name": "easy_win", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rationale": { + "name": "rationale", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fit_viable": { + "name": "fit_viable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "judge_verdict": { + "name": "judge_verdict", + "type": "match_judge_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "judge_mission_fit": { + "name": "judge_mission_fit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "judge_rationale": { + "name": "judge_rationale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judged_at": { + "name": "judged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "match_review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reject_reason": { + "name": "reject_reason", + "type": "match_reject_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_hero": { + "name": "is_hero", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_matches_org_grant": { + "name": "idx_matches_org_grant", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_org": { + "name": "idx_matches_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_grant": { + "name": "idx_matches_grant", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_review_status": { + "name": "idx_matches_review_status", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_org_id_orgs_id_fk": { + "name": "matches_org_id_orgs_id_fk", + "tableFrom": "matches", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_grant_id_grants_id_fk": { + "name": "matches_grant_id_grants_id_fk", + "tableFrom": "matches", + "tableTo": "grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_profiles": { + "name": "org_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mission_statement": { + "name": "mission_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "programs": { + "name": "programs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "service_geography": { + "name": "service_geography", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recent_news": { + "name": "recent_news", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "known_funders": { + "name": "known_funders", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "staff": { + "name": "staff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "budget_band": { + "name": "budget_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "profile_embedding": { + "name": "profile_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_org_profiles_org_unique": { + "name": "idx_org_profiles_org_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_profiles_org": { + "name": "idx_org_profiles_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_profiles_embedding_idx": { + "name": "org_profiles_embedding_idx", + "columns": [ + { + "expression": "profile_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "org_profiles_org_id_orgs_id_fk": { + "name": "org_profiles_org_id_orgs_id_fk", + "tableFrom": "org_profiles", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NH'" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_revenue": { + "name": "total_revenue", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fiscal_year_end": { + "name": "fiscal_year_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_number": { + "name": "registration_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "registration_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "icp_band": { + "name": "icp_band", + "type": "icp_band", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "source_registry": { + "name": "source_registry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_orgs_ein": { + "name": "idx_orgs_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"ein\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_registry_reg_no": { + "name": "idx_orgs_registry_reg_no", + "columns": [ + { + "expression": "source_registry", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"source_registry\" IS NOT NULL AND \"orgs\".\"registration_number\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_icp_band": { + "name": "idx_orgs_icp_band", + "columns": [ + { + "expression": "icp_band", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_state": { + "name": "idx_orgs_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_events": { + "name": "pipeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "pipeline_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_pipeline_events_org": { + "name": "idx_pipeline_events_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_contact": { + "name": "idx_pipeline_events_contact", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_match": { + "name": "idx_pipeline_events_match", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_type_occurred": { + "name": "idx_pipeline_events_type_occurred", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_events_org_id_orgs_id_fk": { + "name": "pipeline_events_org_id_orgs_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_contact_id_contacts_id_fk": { + "name": "pipeline_events_contact_id_contacts_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_match_id_matches_id_fk": { + "name": "pipeline_events_match_id_matches_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_effort_estimate": { + "name": "application_effort_estimate", + "schema": "public", + "values": [ + "loi_only", + "short_form", + "full_federal", + "unknown" + ] + }, + "public.contact_priority": { + "name": "contact_priority", + "schema": "public", + "values": [ + "named", + "generic" + ] + }, + "public.contact_source_provider": { + "name": "contact_source_provider", + "schema": "public", + "values": [ + "apollo", + "irs_990", + "website", + "manual" + ] + }, + "public.email_status": { + "name": "email_status", + "schema": "public", + "values": [ + "unverified", + "valid", + "risky", + "invalid" + ] + }, + "public.grant_source": { + "name": "grant_source", + "schema": "public", + "values": [ + "grants_gov", + "nh_state", + "irs_990pf", + "pnd_rss", + "candid", + "manual" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "open", + "expired", + "closed" + ] + }, + "public.icp_band": { + "name": "icp_band", + "schema": "public", + "values": [ + "below", + "primary", + "above", + "unknown" + ] + }, + "public.match_judge_verdict": { + "name": "match_judge_verdict", + "schema": "public", + "values": [ + "strong_fit", + "plausible", + "weak", + "mismatch" + ] + }, + "public.match_reject_reason": { + "name": "match_reject_reason", + "schema": "public", + "values": [ + "wrong_eligibility", + "wrong_geography", + "bad_capacity_fit", + "weak_mission_fit", + "stale_deadline", + "bad_contact", + "other" + ] + }, + "public.match_review_status": { + "name": "match_review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "edited" + ] + }, + "public.pipeline_event_type": { + "name": "pipeline_event_type", + "schema": "public", + "values": [ + "enrolled", + "sent", + "opened", + "replied", + "bounced", + "unsubscribed", + "brief_requested", + "brief_sent", + "demo_booked", + "demo_held", + "pilot_started", + "converted" + ] + }, + "public.registration_status": { + "name": "registration_status", + "schema": "public", + "values": [ + "good_standing", + "lapsed", + "suspended", + "unknown" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json b/packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json new file mode 100644 index 0000000..18730f7 --- /dev/null +++ b/packages/outreach-core/drizzle/server/meta/1784295739_snapshot.json @@ -0,0 +1,1539 @@ +{ + "id": "aa1aa35f-0938-4d81-97d0-8dedd5fac1e9", + "prevId": "6ced384f-6dd8-4637-9136-c20129a13fea", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_status": { + "name": "email_status", + "type": "email_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "source_provider": { + "name": "source_provider", + "type": "contact_source_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "contact_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_contacts_org": { + "name": "idx_contacts_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_email": { + "name": "idx_contacts_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_org_id_orgs_id_fk": { + "name": "contacts_org_id_orgs_id_fk", + "tableFrom": "contacts", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funder_grants": { + "name": "funder_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder_id": { + "name": "funder_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_city": { + "name": "recipient_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recipient_state": { + "name": "recipient_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tax_year": { + "name": "tax_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funder_grants_funder": { + "name": "idx_funder_grants_funder", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_funder_year": { + "name": "idx_funder_grants_funder_year", + "columns": [ + { + "expression": "funder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tax_year", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funder_grants_recipient_state": { + "name": "idx_funder_grants_recipient_state", + "columns": [ + { + "expression": "recipient_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "funder_grants_funder_id_funders_id_fk": { + "name": "funder_grants_funder_id_funders_id_fk", + "tableFrom": "funder_grants", + "tableTo": "funders", + "columnsFrom": [ + "funder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funders": { + "name": "funders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_assets": { + "name": "total_assets", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "application_info": { + "name": "application_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "latest_tax_year": { + "name": "latest_tax_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latest_object_id": { + "name": "latest_object_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_funders_ein": { + "name": "idx_funders_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_funders_state": { + "name": "idx_funders_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grants": { + "name": "grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "funder": { + "name": "funder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligibility_entity_types": { + "name": "eligibility_entity_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "geographic_scope": { + "name": "geographic_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "program_areas": { + "name": "program_areas", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "award_floor": { + "name": "award_floor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "award_ceiling": { + "name": "award_ceiling", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "expected_awards_count": { + "name": "expected_awards_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "open_date": { + "name": "open_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_date": { + "name": "close_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "match_requirement": { + "name": "match_requirement", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "application_effort_estimate": { + "name": "application_effort_estimate", + "type": "application_effort_estimate", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "application_form_supported": { + "name": "application_form_supported", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "grant_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "funder_ein": { + "name": "funder_ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alns": { + "name": "alns", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "program_state_award_count": { + "name": "program_state_award_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "program_state_peer_award_count": { + "name": "program_state_peer_award_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "program_state_awards": { + "name": "program_state_awards", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "synopsis_embedding": { + "name": "synopsis_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_grants_source_url": { + "name": "idx_grants_source_url", + "columns": [ + { + "expression": "source_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_status": { + "name": "idx_grants_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_close_date": { + "name": "idx_grants_close_date", + "columns": [ + { + "expression": "close_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_source": { + "name": "idx_grants_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_grants_funder_ein": { + "name": "idx_grants_funder_ein", + "columns": [ + { + "expression": "funder_ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "grants_synopsis_embedding_idx": { + "name": "grants_synopsis_embedding_idx", + "columns": [ + { + "expression": "synopsis_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.matches": { + "name": "matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_score": { + "name": "total_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subscores": { + "name": "subscores", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hard_gates_passed": { + "name": "hard_gates_passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "easy_win": { + "name": "easy_win", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rationale": { + "name": "rationale", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "fit_viable": { + "name": "fit_viable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "judge_verdict": { + "name": "judge_verdict", + "type": "match_judge_verdict", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "judge_mission_fit": { + "name": "judge_mission_fit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "judge_rationale": { + "name": "judge_rationale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "judged_at": { + "name": "judged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "judge_model": { + "name": "judge_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_status": { + "name": "review_status", + "type": "match_review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reject_reason": { + "name": "reject_reason", + "type": "match_reject_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_hero": { + "name": "is_hero", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_matches_org_grant": { + "name": "idx_matches_org_grant", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_org": { + "name": "idx_matches_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_grant": { + "name": "idx_matches_grant", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_matches_review_status": { + "name": "idx_matches_review_status", + "columns": [ + { + "expression": "review_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "matches_org_id_orgs_id_fk": { + "name": "matches_org_id_orgs_id_fk", + "tableFrom": "matches", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "matches_grant_id_grants_id_fk": { + "name": "matches_grant_id_grants_id_fk", + "tableFrom": "matches", + "tableTo": "grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_profiles": { + "name": "org_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mission_statement": { + "name": "mission_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "programs": { + "name": "programs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "service_geography": { + "name": "service_geography", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recent_news": { + "name": "recent_news", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "known_funders": { + "name": "known_funders", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "staff": { + "name": "staff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "budget_band": { + "name": "budget_band", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "profile_embedding": { + "name": "profile_embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_org_profiles_org_unique": { + "name": "idx_org_profiles_org_unique", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_org_profiles_org": { + "name": "idx_org_profiles_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_profiles_embedding_idx": { + "name": "org_profiles_embedding_idx", + "columns": [ + { + "expression": "profile_embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": {} + } + }, + "foreignKeys": { + "org_profiles_org_id_orgs_id_fk": { + "name": "org_profiles_org_id_orgs_id_fk", + "tableFrom": "org_profiles", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'NH'" + }, + "ein": { + "name": "ein", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ntee_code": { + "name": "ntee_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_revenue": { + "name": "total_revenue", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fiscal_year_end": { + "name": "fiscal_year_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_number": { + "name": "registration_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "registration_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "icp_band": { + "name": "icp_band", + "type": "icp_band", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "source_registry": { + "name": "source_registry", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_orgs_ein": { + "name": "idx_orgs_ein", + "columns": [ + { + "expression": "ein", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"ein\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_registry_reg_no": { + "name": "idx_orgs_registry_reg_no", + "columns": [ + { + "expression": "source_registry", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"orgs\".\"source_registry\" IS NOT NULL AND \"orgs\".\"registration_number\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_icp_band": { + "name": "idx_orgs_icp_band", + "columns": [ + { + "expression": "icp_band", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orgs_state": { + "name": "idx_orgs_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_events": { + "name": "pipeline_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "contact_id": { + "name": "contact_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "match_id": { + "name": "match_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "pipeline_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_pipeline_events_org": { + "name": "idx_pipeline_events_org", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_contact": { + "name": "idx_pipeline_events_contact", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_match": { + "name": "idx_pipeline_events_match", + "columns": [ + { + "expression": "match_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pipeline_events_type_occurred": { + "name": "idx_pipeline_events_type_occurred", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pipeline_events_org_id_orgs_id_fk": { + "name": "pipeline_events_org_id_orgs_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "orgs", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_contact_id_contacts_id_fk": { + "name": "pipeline_events_contact_id_contacts_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "pipeline_events_match_id_matches_id_fk": { + "name": "pipeline_events_match_id_matches_id_fk", + "tableFrom": "pipeline_events", + "tableTo": "matches", + "columnsFrom": [ + "match_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_effort_estimate": { + "name": "application_effort_estimate", + "schema": "public", + "values": [ + "loi_only", + "short_form", + "full_federal", + "unknown" + ] + }, + "public.contact_priority": { + "name": "contact_priority", + "schema": "public", + "values": [ + "named", + "generic" + ] + }, + "public.contact_source_provider": { + "name": "contact_source_provider", + "schema": "public", + "values": [ + "apollo", + "irs_990", + "website", + "manual" + ] + }, + "public.email_status": { + "name": "email_status", + "schema": "public", + "values": [ + "unverified", + "valid", + "risky", + "invalid" + ] + }, + "public.grant_source": { + "name": "grant_source", + "schema": "public", + "values": [ + "grants_gov", + "nh_state", + "irs_990pf", + "pnd_rss", + "candid", + "manual" + ] + }, + "public.grant_status": { + "name": "grant_status", + "schema": "public", + "values": [ + "open", + "expired", + "closed" + ] + }, + "public.icp_band": { + "name": "icp_band", + "schema": "public", + "values": [ + "below", + "primary", + "above", + "unknown" + ] + }, + "public.match_judge_verdict": { + "name": "match_judge_verdict", + "schema": "public", + "values": [ + "strong_fit", + "plausible", + "weak", + "mismatch" + ] + }, + "public.match_reject_reason": { + "name": "match_reject_reason", + "schema": "public", + "values": [ + "wrong_eligibility", + "wrong_geography", + "bad_capacity_fit", + "weak_mission_fit", + "stale_deadline", + "bad_contact", + "other" + ] + }, + "public.match_review_status": { + "name": "match_review_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected", + "edited" + ] + }, + "public.pipeline_event_type": { + "name": "pipeline_event_type", + "schema": "public", + "values": [ + "enrolled", + "sent", + "opened", + "replied", + "bounced", + "unsubscribed", + "brief_requested", + "brief_sent", + "demo_booked", + "demo_held", + "pilot_started", + "converted" + ] + }, + "public.registration_status": { + "name": "registration_status", + "schema": "public", + "values": [ + "good_standing", + "lapsed", + "suspended", + "unknown" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/_journal.json b/packages/outreach-core/drizzle/server/meta/_journal.json index b5f5174..bfcc86c 100644 --- a/packages/outreach-core/drizzle/server/meta/_journal.json +++ b/packages/outreach-core/drizzle/server/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1784257140142, "tag": "1784257140_federal-precedent", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1784295305749, + "tag": "1784295305_match-judge-and-peer-precedent", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784295739948, + "tag": "1784295739_org-profile-updated-at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/outreach-core/src/db/schema.ts b/packages/outreach-core/src/db/schema.ts index 174bf8f..60bfc60 100644 --- a/packages/outreach-core/src/db/schema.ts +++ b/packages/outreach-core/src/db/schema.ts @@ -81,6 +81,13 @@ export const matchReviewStatusEnum = pgEnum('match_review_status', [ 'edited', ]); +export const matchJudgeVerdictEnum = pgEnum('match_judge_verdict', [ + 'strong_fit', + 'plausible', + 'weak', + 'mismatch', +]); + export const matchRejectReasonEnum = pgEnum('match_reject_reason', [ 'wrong_eligibility', 'wrong_geography', @@ -151,7 +158,14 @@ export const grants = pgTable( // refreshed by the federalPrecedent workflow. Null for non-federal // sources and never touched by ingest upserts. programStateAwardCount: integer('program_state_award_count'), - // Sample recipients backing the count — review-page evidence. + // Awards (within the fetched window) whose recipient name-matches a + // registered NH nonprofit in our primary ICP band — "orgs like ours + // win this program", the count the precedent subscore actually uses. + // Dartmouth/UNH/hospital systems/LLCs inflate the raw count above but + // never this one. + programStatePeerAwardCount: integer('program_state_peer_award_count'), + // Sample recipients backing the count — review-page evidence. Each + // entry carries `isPeer` since the peer-precedent pass. programStateAwards: jsonb('program_state_awards'), status: grantStatusEnum('status').notNull().default('open'), synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }), @@ -240,6 +254,9 @@ export const orgProfiles = pgTable( confidence: real('confidence'), profileEmbedding: vector('profile_embedding', { dimensions: 1536 }), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + // Bumped whenever the profile is re-researched — the match judge + // re-judges matches whose judged_at predates this. + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (t) => [ // Latest-profile semantics: one row per org, refreshed in place (the @@ -304,6 +321,20 @@ export const matches = pgTable( easyWin: boolean('easy_win').notNull().default(false), // LLM-produced citations backing the score/subscores. rationale: jsonb('rationale'), + // Embedding fit can't veto (non-mission subscores sum to 50); this + // flag can: false = below the source-aware mission-fit floor or judged + // 'weak'/'mismatch', and the pending queue hides the row. Approved/ + // rejected views ignore it (a human decision always displays). + fitViable: boolean('fit_viable').notNull().default(true), + // Mission-fit judge (LLM over grant synopsis × researched org profile; + // deterministic verdict→score mapping — the judge names the verdict, + // code assigns the number). Preserved across nightly re-scores; + // invalidated when the org profile is re-researched after judged_at. + judgeVerdict: matchJudgeVerdictEnum('judge_verdict'), + judgeMissionFit: integer('judge_mission_fit'), + judgeRationale: text('judge_rationale'), + judgedAt: timestamp('judged_at', { withTimezone: true }), + judgeModel: text('judge_model'), reviewStatus: matchReviewStatusEnum('review_status') .notNull() .default('pending'), diff --git a/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts b/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts index b539fc1..a4d7c20 100644 --- a/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts +++ b/packages/outreach-core/src/grants/actions/set-federal-precedent.server.ts @@ -5,15 +5,20 @@ import { schema } from '#~/db/db.js'; export interface AlnPrecedent { readonly aln: string; + /** Raw NH award count for the program (context/evidence only). */ readonly awardCount: number; + /** Awards won by ICP-peer nonprofits — drives the precedent subscore. */ + readonly peerAwardCount: number; + /** Peer-annotated award list (see classifyPeerAwards). */ readonly samples: unknown; } /** * Applies per-program USASpending precedent onto every open federal grant - * carrying that ALN. Grants with multiple ALNs keep the HIGHEST count - * seen (a grant reachable through any strongly-NH program inherits that - * program's precedent), and samples follow whichever count won. + * carrying that ALN. Grants with multiple ALNs keep the strongest program + * seen, where "strongest" is the PEER count — ten Dartmouth renewals must + * not outrank three community-nonprofit wins — with raw count and samples + * following whichever peer count won. */ export async function serverSetFederalPrecedent( db: NpOutreachDatabase | NpOutreachTransaction, @@ -22,9 +27,14 @@ export async function serverSetFederalPrecedent( await db .update(schema.grants) .set({ - programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`, + programStatePeerAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStatePeerAwardCount}, 0), ${precedent.peerAwardCount})`, + programStateAwardCount: sql`CASE + WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount} + THEN ${precedent.awardCount} + ELSE ${schema.grants.programStateAwardCount} + END`, programStateAwards: sql`CASE - WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount} + WHEN COALESCE(${schema.grants.programStatePeerAwardCount}, 0) <= ${precedent.peerAwardCount} THEN ${JSON.stringify(precedent.samples)}::jsonb ELSE ${schema.grants.programStateAwards} END`, @@ -45,6 +55,10 @@ export async function serverResetFederalPrecedent( ): Promise { await db .update(schema.grants) - .set({ programStateAwardCount: null, programStateAwards: null }) + .set({ + programStateAwardCount: null, + programStatePeerAwardCount: null, + programStateAwards: null, + }) .where(eq(schema.grants.source, 'grants_gov')); } diff --git a/packages/outreach-core/src/grants/peer-precedent.test.ts b/packages/outreach-core/src/grants/peer-precedent.test.ts new file mode 100644 index 0000000..f7d47ef --- /dev/null +++ b/packages/outreach-core/src/grants/peer-precedent.test.ts @@ -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(), + ).peerAwardCount, + ).toBe(0); + }); +}); diff --git a/packages/outreach-core/src/grants/peer-precedent.ts b/packages/outreach-core/src/grants/peer-precedent.ts new file mode 100644 index 0000000..2ca9ed6 --- /dev/null +++ b/packages/outreach-core/src/grants/peer-precedent.ts @@ -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 { + const set = new Set(); + 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, +): PeerPrecedentResult { + const annotated = awards.map((award) => ({ + ...award, + isPeer: peerNames.has(normalizeOrgNameForMatching(award.recipientName)), + })); + return { + peerAwardCount: annotated.filter((a) => a.isPeer).length, + awards: annotated, + }; +} diff --git a/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts b/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts index 4475fb3..c07095c 100644 --- a/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts +++ b/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts @@ -18,8 +18,15 @@ export interface EligibleGrantWithSimilarity { | 'unknown'; applicationFormSupported: boolean; funderEin: string | null; + source: string; similarity: number; - /** Funder's historical grant count into the org's state (990-PF index); null when the grant has no linked funder. */ + /** + * Precedent count for the subscore: the funder's historical grant count + * into the org's state (990-PF index) for foundation grants, or the + * program's PEER award count (USASpending recipients name-matched to + * primary-ICP NH orgs) for federal grants — never the raw state count, + * which Dartmouth renewals inflate. + */ funderStateGrantCount: number | null; } @@ -61,6 +68,7 @@ export async function serverListEligibleGrantsForOrg( applicationEffortEstimate: schema.grants.applicationEffortEstimate, applicationFormSupported: schema.grants.applicationFormSupported, funderEin: schema.grants.funderEin, + source: schema.grants.source, similarity: sql`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`, funderStateGrantCount: sql`CASE WHEN ${schema.grants.funderEin} IS NOT NULL THEN ( @@ -69,7 +77,7 @@ export async function serverListEligibleGrantsForOrg( WHERE f.ein = ${schema.grants.funderEin} AND fg.recipient_state = ${filters.orgState} ) - ELSE ${schema.grants.programStateAwardCount} + ELSE ${schema.grants.programStatePeerAwardCount} END`, }) .from(schema.grants) diff --git a/packages/outreach-core/src/index.ts b/packages/outreach-core/src/index.ts index 464bd30..635b059 100644 --- a/packages/outreach-core/src/index.ts +++ b/packages/outreach-core/src/index.ts @@ -10,3 +10,5 @@ export * from './matches/hard-gates.js'; export * from './orgs/icp-band.js'; export * from './matches/scoring.js'; export * from './orgs/ntee.js'; +export * from './orgs/org-name.js'; +export * from './grants/peer-precedent.js'; diff --git a/packages/outreach-core/src/matches/actions/apply-match-judgment.server.ts b/packages/outreach-core/src/matches/actions/apply-match-judgment.server.ts new file mode 100644 index 0000000..2307636 --- /dev/null +++ b/packages/outreach-core/src/matches/actions/apply-match-judgment.server.ts @@ -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 { + 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}`); +} diff --git a/packages/outreach-core/src/matches/actions/assign-hero-matches.server.ts b/packages/outreach-core/src/matches/actions/assign-hero-matches.server.ts index 5226516..e9936bf 100644 --- a/packages/outreach-core/src/matches/actions/assign-hero-matches.server.ts +++ b/packages/outreach-core/src/matches/actions/assign-hero-matches.server.ts @@ -24,6 +24,7 @@ export async function serverAssignHeroMatch( WHERE org_id = ${orgId} AND review_status != 'rejected' AND hard_gates_passed = true + AND fit_viable = true ORDER BY easy_win DESC, total_score DESC, created_at ASC LIMIT 1 ) diff --git a/packages/outreach-core/src/matches/actions/index.server.ts b/packages/outreach-core/src/matches/actions/index.server.ts index fca0046..a0c7904 100644 --- a/packages/outreach-core/src/matches/actions/index.server.ts +++ b/packages/outreach-core/src/matches/actions/index.server.ts @@ -2,3 +2,4 @@ export * from './insert-match.server.js'; export * from './set-match-review.server.js'; export * from './upsert-match-score.server.js'; export * from './assign-hero-matches.server.js'; +export * from './apply-match-judgment.server.js'; diff --git a/packages/outreach-core/src/matches/actions/upsert-match-score.server.ts b/packages/outreach-core/src/matches/actions/upsert-match-score.server.ts index 3c34f4b..fd06775 100644 --- a/packages/outreach-core/src/matches/actions/upsert-match-score.server.ts +++ b/packages/outreach-core/src/matches/actions/upsert-match-score.server.ts @@ -2,7 +2,11 @@ import { sql } from 'drizzle-orm'; import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; import { schema } from '#~/db/db.js'; -import type { MatchSubscores } from '../scoring.js'; +import { + EASY_WIN_MIN_PRECEDENT, + EASY_WIN_THRESHOLD, + type MatchSubscores, +} from '../scoring.js'; export interface MatchScoreInput { readonly orgId: string; @@ -11,6 +15,7 @@ export interface MatchScoreInput { readonly subscores: MatchSubscores; readonly hardGatesPassed: boolean; readonly easyWin: boolean; + readonly fitViable: boolean; /** Gate failures, similarity, and any judge citations — audit trail. */ readonly rationale: unknown; } @@ -22,11 +27,23 @@ export interface MatchScoreInput { * nightly re-score moves the numbers — resurfacing rejected matches would * erode the review queue's trust, and re-approving approved ones is * pointless churn. + * + * Judge fields survive re-scores the same way, and while a judgment is + * on the row its mission-fit verdict KEEPS overriding the incoming + * embedding-based numbers: missionFit is spliced with judge_mission_fit, + * total/easy-win recomputed from the spliced value, and fit_viable kept + * from the verdict. Otherwise the nightly embedding re-score would revert + * every judged match until the judge's next pass re-found it. Staleness + * is handled at selection time (re-judge when the org profile is newer + * than judged_at), not by dropping the judgment here. */ export async function serverUpsertMatchScore( db: NpOutreachDatabase | NpOutreachTransaction, match: MatchScoreInput, ): Promise { + const judged = sql`${schema.matches.judgedAt} IS NOT NULL AND ${schema.matches.judgeMissionFit} IS NOT NULL`; + const judgedTotal = sql`excluded.total_score - COALESCE((excluded.subscores->>'missionFit')::int, 0) + ${schema.matches.judgeMissionFit}`; + await db .insert(schema.matches) .values({ @@ -36,16 +53,24 @@ export async function serverUpsertMatchScore( subscores: match.subscores, hardGatesPassed: match.hardGatesPassed, easyWin: match.easyWin, + fitViable: match.fitViable, rationale: match.rationale, reviewStatus: 'pending', }) .onConflictDoUpdate({ target: [schema.matches.orgId, schema.matches.grantId], set: { - totalScore: sql`excluded.total_score`, - subscores: sql`excluded.subscores`, + totalScore: sql`CASE WHEN ${judged} THEN ${judgedTotal} ELSE excluded.total_score END`, + subscores: sql`CASE WHEN ${judged} + THEN jsonb_set(excluded.subscores, '{missionFit}', to_jsonb(${schema.matches.judgeMissionFit})) + ELSE excluded.subscores END`, hardGatesPassed: sql`excluded.hard_gates_passed`, - easyWin: sql`excluded.easy_win`, + easyWin: sql`CASE WHEN ${judged} + THEN (${schema.matches.fitViable} + AND ${judgedTotal} >= ${EASY_WIN_THRESHOLD} + AND COALESCE((excluded.subscores->>'funderPrecedent')::int, 0) >= ${EASY_WIN_MIN_PRECEDENT}) + ELSE excluded.easy_win END`, + fitViable: sql`CASE WHEN ${judged} THEN ${schema.matches.fitViable} ELSE excluded.fit_viable END`, rationale: sql`excluded.rationale`, updatedAt: sql`now()`, }, diff --git a/packages/outreach-core/src/matches/queries/get-match-detail.server.ts b/packages/outreach-core/src/matches/queries/get-match-detail.server.ts index 85ed8e7..4161a34 100644 --- a/packages/outreach-core/src/matches/queries/get-match-detail.server.ts +++ b/packages/outreach-core/src/matches/queries/get-match-detail.server.ts @@ -11,6 +11,10 @@ export interface MatchDetail { rationale: unknown; easyWin: boolean; isHero: boolean; + fitViable: boolean; + judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null; + judgeRationale: string | null; + judgedAt: Date | null; reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited'; }; org: { @@ -56,6 +60,9 @@ export interface MatchDetail { amount: number | null; purpose: string | null; taxYear: number; + /** Federal evidence rows only: recipient name-matched a primary-ICP + * NH nonprofit (the peer-precedent basis). Null for 990-PF rows. */ + isPeer: boolean | null; }>; } @@ -71,6 +78,10 @@ export async function serverGetMatchDetail( rationale: schema.matches.rationale, easyWin: schema.matches.easyWin, isHero: schema.matches.isHero, + fitViable: schema.matches.fitViable, + judgeVerdict: schema.matches.judgeVerdict, + judgeRationale: schema.matches.judgeRationale, + judgedAt: schema.matches.judgedAt, reviewStatus: schema.matches.reviewStatus, orgId: schema.orgs.id, orgName: schema.orgs.name, @@ -123,19 +134,29 @@ export async function serverGetMatchDetail( if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) { // Federal grants: USASpending program awards fill the same evidence // table (recipient/amount/year) the 990-PF history uses. + // Peers first (the actual precedent evidence), newest within each + // group; capped so a 500-award program doesn't flood the page. funderGivingHistory = ( row.grantProgramAwards as Array<{ recipientName?: string; amount?: number | null; startDate?: string | null; + isPeer?: boolean; }> - ).map((a) => ({ - recipientName: a.recipientName ?? 'Unknown recipient', - recipientCity: null, - amount: a.amount ?? null, - purpose: null, - taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0, - })); + ) + .map((a) => ({ + recipientName: a.recipientName ?? 'Unknown recipient', + recipientCity: null, + amount: a.amount ?? null, + purpose: null, + taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0, + isPeer: a.isPeer ?? null, + })) + .sort((a, b) => { + const peerRank = Number(b.isPeer === true) - Number(a.isPeer === true); + return peerRank !== 0 ? peerRank : b.taxYear - a.taxYear; + }) + .slice(0, 40); } if (row.grantFunderEin != null) { const funderRow = await db @@ -170,7 +191,7 @@ export async function serverGetMatchDetail( return aIn - bIn; }) .slice(0, 40) - .map(({ recipientState: _s, ...rest }) => rest); + .map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null })); } return { @@ -181,6 +202,10 @@ export async function serverGetMatchDetail( rationale: row.rationale, easyWin: row.easyWin, isHero: row.isHero, + fitViable: row.fitViable, + judgeVerdict: row.judgeVerdict, + judgeRationale: row.judgeRationale, + judgedAt: row.judgedAt, reviewStatus: row.reviewStatus, }, org: { diff --git a/packages/outreach-core/src/matches/queries/index.server.ts b/packages/outreach-core/src/matches/queries/index.server.ts index c1dc6f4..4626bfa 100644 --- a/packages/outreach-core/src/matches/queries/index.server.ts +++ b/packages/outreach-core/src/matches/queries/index.server.ts @@ -1,2 +1,3 @@ export * from './list-pending-review-matches.server.js'; export * from './get-match-detail.server.js'; +export * from './list-matches-needing-judgment.server.js'; diff --git a/packages/outreach-core/src/matches/queries/list-matches-needing-judgment.server.ts b/packages/outreach-core/src/matches/queries/list-matches-needing-judgment.server.ts new file mode 100644 index 0000000..a21dd30 --- /dev/null +++ b/packages/outreach-core/src/matches/queries/list-matches-needing-judgment.server.ts @@ -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 { + 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> }; + + 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, + })); +} diff --git a/packages/outreach-core/src/matches/queries/list-pending-review-matches.server.ts b/packages/outreach-core/src/matches/queries/list-pending-review-matches.server.ts index df61566..abdafb0 100644 --- a/packages/outreach-core/src/matches/queries/list-pending-review-matches.server.ts +++ b/packages/outreach-core/src/matches/queries/list-pending-review-matches.server.ts @@ -44,12 +44,16 @@ export async function serverListPendingReviewMatches( .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) .where( - source == null - ? eq(schema.matches.reviewStatus, status) - : and( - eq(schema.matches.reviewStatus, status), - eq(schema.grants.source, source as never), - ), + and( + eq(schema.matches.reviewStatus, status), + // The pending queue hides fit-non-viable rows (below the mission- + // fit floor or judged weak/mismatch); a human's approve/reject + // always displays regardless. + ...(status === 'pending' ? [eq(schema.matches.fitViable, true)] : []), + ...(source == null + ? [] + : [eq(schema.grants.source, source as never)]), + ), ) // Reviewers see the best candidates first: heroes, then easy wins, // then raw score. Capped — nightly re-scoring generates thousands of diff --git a/packages/outreach-core/src/matches/scoring.test.ts b/packages/outreach-core/src/matches/scoring.test.ts index dad46aa..61078d0 100644 --- a/packages/outreach-core/src/matches/scoring.test.ts +++ b/packages/outreach-core/src/matches/scoring.test.ts @@ -6,6 +6,7 @@ import { competitionSubscore, EASY_WIN_THRESHOLD, effortSubscore, + MISSION_FIT_VIABLE_MIN, missionFitSubscore, runwaySubscore, scoreMatch, @@ -102,6 +103,7 @@ describe('scoreMatch', () => { closeDate: weeksFromNow(6), now: NOW, funderStateGrantCount: null, + grantSource: 'irs_990pf', }); // 30+15+15+10+5 = 75 — over the threshold but no precedent floor. expect(result.totalScore).toBe(75); @@ -118,6 +120,7 @@ describe('scoreMatch', () => { closeDate: weeksFromNow(6), now: NOW, funderStateGrantCount: 6, + grantSource: 'irs_990pf', }); // 30 fit + 20 precedent + 15 capacity + 15 competition + 8 effort + 5 runway expect(result.totalScore).toBe(93); @@ -135,8 +138,81 @@ describe('scoreMatch', () => { closeDate: weeksFromNow(2), now: NOW, funderStateGrantCount: null, + grantSource: 'grants_gov', }); expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD); expect(result.easyWin).toBe(false); }); + + it('marks federal matches below the mission-fit floor non-viable', () => { + // similarity 0.55 → fit 10 < MISSION_FIT_VIABLE_MIN (12) + const result = scoreMatch({ + similarity: 0.55, + orgTotalRevenue: 1_000_000, + awardCeiling: 200_000, + geographicScope: null, + applicationEffortEstimate: 'full_federal', + closeDate: weeksFromNow(6), + now: NOW, + funderStateGrantCount: 20, + grantSource: 'grants_gov', + }); + expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN); + expect(result.fitViable).toBe(false); + expect(result.easyWin).toBe(false); + }); + + it('keeps federal matches at/above the floor viable', () => { + // similarity 0.57 → fit 12 = floor + const result = scoreMatch({ + similarity: 0.57, + orgTotalRevenue: 1_000_000, + awardCeiling: 200_000, + geographicScope: null, + applicationEffortEstimate: 'full_federal', + closeDate: weeksFromNow(6), + now: NOW, + funderStateGrantCount: 20, + grantSource: 'grants_gov', + }); + expect(result.subscores.missionFit).toBeGreaterThanOrEqual( + MISSION_FIT_VIABLE_MIN, + ); + expect(result.fitViable).toBe(true); + }); + + it('exempts foundation-synthesized grants from the fit floor', () => { + // Generic 990-PF synopses carry no embedding signal — precedent + // evidence is the case for those matches, so low fit stays viable. + const result = scoreMatch({ + similarity: 0.5, + orgTotalRevenue: 1_000_000, + awardCeiling: 200_000, + geographicScope: 'New Hampshire', + applicationEffortEstimate: 'loi_only', + closeDate: weeksFromNow(6), + now: NOW, + funderStateGrantCount: 12, + grantSource: 'irs_990pf', + }); + expect(result.subscores.missionFit).toBeLessThan(MISSION_FIT_VIABLE_MIN); + expect(result.fitViable).toBe(true); + }); + + it('gates easy-win on fit viability, not just score and precedent', () => { + const belowFloor = scoreMatch({ + similarity: 0.55, + orgTotalRevenue: 1_000_000, + awardCeiling: 200_000, + geographicScope: 'New Hampshire', + applicationEffortEstimate: 'loi_only', + closeDate: weeksFromNow(6), + now: NOW, + funderStateGrantCount: 20, + grantSource: 'grants_gov', + }); + // 10+25+15+15+10+5 = 80 ≥ threshold, precedent 25 ≥ floor — but not viable. + expect(belowFloor.totalScore).toBeGreaterThanOrEqual(EASY_WIN_THRESHOLD); + expect(belowFloor.easyWin).toBe(false); + }); }); diff --git a/packages/outreach-core/src/matches/scoring.ts b/packages/outreach-core/src/matches/scoring.ts index d4d359e..3613abe 100644 --- a/packages/outreach-core/src/matches/scoring.ts +++ b/packages/outreach-core/src/matches/scoring.ts @@ -39,12 +39,15 @@ export interface ScoreMatchInput { readonly now: Date; /** * Historical grants this funder has paid to recipients in the org's - * state (from the 990-PF index). Null = no precedent data for this - * grant's funder (e.g. federal agencies) — scores 0, not neutral: the - * plan weights precedent as the strongest single predictor, and absence - * of evidence should rank below presence. + * state (990-PF index for foundations; USASpending PEER award count for + * federal programs — recipients name-matched to primary-ICP NH orgs). + * Null = no precedent data — scores 0, not neutral: the plan weights + * precedent as the strongest single predictor, and absence of evidence + * should rank below presence. */ readonly funderStateGrantCount: number | null; + /** Grant source (`grants.source`) — drives the source-aware fit floor. */ + readonly grantSource: string; } export const ACHIEVABLE_MAX_SCORE = 100; @@ -57,6 +60,18 @@ export const ACHIEVABLE_MAX_SCORE = 100; export const EASY_WIN_THRESHOLD = 65; export const EASY_WIN_MIN_PRECEDENT = 12; +/** + * Mission-fit floor (federal RFPs only): below 12/30 (≈ cosine 0.57) the + * match is stored but NOT review-viable — non-mission subscores sum to 50, + * so without a floor a boys' camp scores 60 on an NIH obesity-research + * center grant purely on precedent + capacity. Foundation-synthesized + * grants are exempt: their synopses are generic by construction ("grants + * for NH nonprofits"), so embedding fit carries no signal there and the + * precedent evidence IS the case for the match. + */ +export const MISSION_FIT_VIABLE_MIN = 12; +const FIT_FLOOR_SOURCES: ReadonlySet = new Set(['grants_gov']); + /** Similarity below this scores 0 fit; above the ceiling scores full fit. */ const SIMILARITY_FLOOR = 0.45; const SIMILARITY_CEILING = 0.75; @@ -154,6 +169,13 @@ export interface ScoredMatch { readonly totalScore: number; readonly subscores: MatchSubscores; readonly easyWin: boolean; + /** + * False when mission fit is below the source-aware floor — the match is + * recorded (audit trail, re-scoring continuity) but hidden from the + * pending review queue. The LLM match judge may later override in + * either direction. + */ + readonly fitViable: boolean; } export function scoreMatch(input: ScoreMatchInput): ScoredMatch { @@ -174,11 +196,17 @@ export function scoreMatch(input: ScoreMatchInput): ScoredMatch { subscores.runway + subscores.funderPrecedent; + const fitViable = + !FIT_FLOOR_SOURCES.has(input.grantSource) || + subscores.missionFit >= MISSION_FIT_VIABLE_MIN; + return { totalScore, subscores, easyWin: + fitViable && totalScore >= EASY_WIN_THRESHOLD && subscores.funderPrecedent >= EASY_WIN_MIN_PRECEDENT, + fitViable, }; } diff --git a/packages/outreach-core/src/orgs/actions/apply-researched-profile.server.ts b/packages/outreach-core/src/orgs/actions/apply-researched-profile.server.ts index 3bc1332..309248e 100644 --- a/packages/outreach-core/src/orgs/actions/apply-researched-profile.server.ts +++ b/packages/outreach-core/src/orgs/actions/apply-researched-profile.server.ts @@ -52,6 +52,9 @@ export async function serverApplyResearchedProfile( sources: sql`excluded.sources`, confidence: sql`excluded.confidence`, profileEmbedding: sql`excluded.profile_embedding`, + // Staleness signal for the match judge: judgments older than this + // re-enter the judge queue. + updatedAt: sql`now()`, }, }); } diff --git a/packages/outreach-core/src/orgs/org-name.ts b/packages/outreach-core/src/orgs/org-name.ts new file mode 100644 index 0000000..94226d3 --- /dev/null +++ b/packages/outreach-core/src/orgs/org-name.ts @@ -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, ''); +} diff --git a/packages/outreach-core/src/orgs/queries/index.server.ts b/packages/outreach-core/src/orgs/queries/index.server.ts index b014bca..351ec49 100644 --- a/packages/outreach-core/src/orgs/queries/index.server.ts +++ b/packages/outreach-core/src/orgs/queries/index.server.ts @@ -2,3 +2,4 @@ export * from './list-orgs-in-icp-band.server.js'; export * from './list-orgs-needing-enrichment.server.js'; export * from './list-match-candidate-orgs.server.js'; export * from './list-orgs-needing-profile.server.js'; +export * from './list-peer-org-names.server.js'; diff --git a/packages/outreach-core/src/orgs/queries/list-peer-org-names.server.ts b/packages/outreach-core/src/orgs/queries/list-peer-org-names.server.ts new file mode 100644 index 0000000..2cef2e5 --- /dev/null +++ b/packages/outreach-core/src/orgs/queries/list-peer-org-names.server.ts @@ -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 { + 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); +}