- | {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);
+}
|