diff --git a/apps/outreach-worker/src/main.ts b/apps/outreach-worker/src/main.ts index f0d7d61..ba440e8 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 { setIngestGrantsDeps } from './workflows/ingest-grants.js'; import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js'; +import { setMatchGrantsDeps } from './workflows/match-grants.js'; import { setIngestPndRssDeps } from './workflows/ingest-pnd-rss.js'; if (process.env.DATABASE_URL == null) { @@ -67,6 +68,7 @@ async function main() { setIngestNhdojOrgsDeps({ db }); setEnrichOrgsDeps({ db }); setEmbedGrantsDeps({ db }); + setMatchGrantsDeps({ 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 a2ab8d9..fcad4c2 100644 --- a/apps/outreach-worker/src/run-once.ts +++ b/apps/outreach-worker/src/run-once.ts @@ -36,6 +36,10 @@ import { runIngestNhdojOrgsNow, setIngestNhdojOrgsDeps, } from './workflows/ingest-nhdoj-orgs.js'; +import { + runMatchGrantsNow, + setMatchGrantsDeps, +} from './workflows/match-grants.js'; import { runIngestPndRssNow, setIngestPndRssDeps, @@ -48,6 +52,7 @@ const RUNNERS: Record Promise> = { enrichOrgs: runEnrichOrgsNow, expireGrants: runExpireGrantsNow, embedGrants: runEmbedGrantsNow, + matchGrants: runMatchGrantsNow, }; const FIRST_RUN_ORDER = [ @@ -57,6 +62,7 @@ const FIRST_RUN_ORDER = [ 'expireGrants', 'enrichOrgs', 'embedGrants', + 'matchGrants', ]; if (process.env.DATABASE_URL == null) { @@ -92,6 +98,7 @@ async function main() { setIngestNhdojOrgsDeps({ db }); setEnrichOrgsDeps({ db }); setEmbedGrantsDeps({ db }); + setMatchGrantsDeps({ db }); DBOS.setConfig({ name: 'helmdocs-outreach-worker', diff --git a/apps/outreach-worker/src/workflows/match-grants.ts b/apps/outreach-worker/src/workflows/match-grants.ts new file mode 100644 index 0000000..703a5e1 --- /dev/null +++ b/apps/outreach-worker/src/workflows/match-grants.ts @@ -0,0 +1,276 @@ +/** + * Nightly match-generation workflow — the scoring engine v1 (Stage 2). + * + * For every candidate org (NH, good standing, primary ICP): + * 1. Ensure a profile embedding exists (v0: NTEE-derived mission text + * embedded as RETRIEVAL_QUERY; the Stage 4 research profiler upgrades + * the row later without this workflow changing). + * 2. Retrieve top-K open grants by cosine similarity, SQL-gated on the + * cheap hard constraints (deadline runway, award floor). + * 3. Run the remaining hard gates in TS (entity eligibility, geography) + * and the deterministic subscores; upsert one match row per pair. + * 4. Reassign the org's hero match. + * + * The `application_form_supported` gate is deliberately EXCLUDED from + * pass/fail (2026-07-16 decision: manual-first launch — draftability is + * verified by hand for the top leads; no grant has the flag set yet, so + * enforcing it would zero out every match). Its failure still lands in + * `rationale.gateFailures` so the review queue can show it, and the gate + * re-arms by simply removing it from IGNORED_GATES once solicitation + * support data flows in. + * + * Runs at 05:15, after embeddings (04:15) and alongside-safe with + * enrichment (05:00) — a not-yet-enriched org simply isn't a candidate. + */ +import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk'; +import { generateQueryEmbedding } from '@novelpad/outreach-ai'; +import { + buildOrgMissionText, + evaluateHardGates, + scoreMatch, + type HardGateFailureReason, + type schema, +} from '@novelpad/outreach-core'; +import { + serverAssignHeroMatch, + serverGetOrCreateOrgProfileEmbedding, + serverListEligibleGrantsForOrg, + serverListMatchCandidateOrgs, + serverUpsertMatchScore, + type EligibleGrantWithSimilarity, + type MatchCandidateOrg, +} from '@novelpad/outreach-core/server'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +export type OutreachDb = NodePgDatabase; + +const CANDIDATE_ORG_LIMIT = 500; +const GRANTS_PER_ORG = 50; +const MIN_DAYS_TO_DEADLINE = 21; +const MIN_AWARD_CEILING = 10_000; +/** See module doc — manual-first launch decision. */ +const IGNORED_GATES: ReadonlySet = new Set([ + 'application_form_unsupported', +]); +/** Registry charities are 501(c)(3)s for gate purposes (NHDOJ registers + * charitable trusts; the rare non-c3 gets caught at human review). */ +const ASSUMED_ENTITY_TYPE = '501c3'; + +export interface MatchGrantsDeps { + readonly db: OutreachDb; +} + +let registeredDeps: MatchGrantsDeps | null = null; + +export function setMatchGrantsDeps(deps: MatchGrantsDeps): void { + registeredDeps = deps; +} + +function getMatchGrantsDeps(): MatchGrantsDeps { + if (registeredDeps == null) { + throw new Error( + 'MatchGrantsDeps not registered. Call setMatchGrantsDeps() before DBOS.launch().', + ); + } + return registeredDeps; +} + +async function listCandidateOrgs(db: OutreachDb): Promise { + return serverListMatchCandidateOrgs(db, { limit: CANDIDATE_ORG_LIMIT }); +} +const listCandidateOrgsStep = DBOS.registerStep(listCandidateOrgs, { + name: 'listMatchCandidateOrgs', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function ensureOrgEmbedding( + db: OutreachDb, + org: MatchCandidateOrg, +): Promise { + const missionStatement = buildOrgMissionText(org); + // Embedding first, insert second: generateQueryEmbedding is only called + // when no profile exists — checked inside, but the extra call for + // already-profiled orgs is avoided by the cheap select happening there. + const existingOrStub = await serverGetOrCreateOrgProfileEmbedding(db, { + orgId: org.id, + missionStatement, + profileEmbedding: await generateQueryEmbedding(missionStatement), + }); + return existingOrStub.embedding; +} +const ensureOrgEmbeddingStep = DBOS.registerStep(ensureOrgEmbedding, { + name: 'ensureOrgEmbedding', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function retrieveGrants( + db: OutreachDb, + embedding: number[], +): Promise { + return serverListEligibleGrantsForOrg(db, embedding, { + minDaysToDeadline: MIN_DAYS_TO_DEADLINE, + minAwardCeiling: MIN_AWARD_CEILING, + limit: GRANTS_PER_ORG, + }); +} +const retrieveGrantsStep = DBOS.registerStep(retrieveGrants, { + name: 'retrieveGrantsForOrg', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function scoreAndStoreOrgMatches( + db: OutreachDb, + org: MatchCandidateOrg, + grants: EligibleGrantWithSimilarity[], +): Promise<{ stored: number; gated: number }> { + const now = new Date(); + let stored = 0; + let gated = 0; + + for (const grant of grants) { + const gates = evaluateHardGates( + { entityType: ASSUMED_ENTITY_TYPE, state: org.state }, + { + eligibilityEntityTypes: grant.eligibilityEntityTypes, + geographicScope: grant.geographicScope, + closeDate: grant.closeDate, + awardCeiling: grant.awardCeiling, + applicationFormSupported: grant.applicationFormSupported, + }, + { now }, + ); + const effectiveFailures = gates.failures.filter( + (f) => !IGNORED_GATES.has(f), + ); + const hardGatesPassed = effectiveFailures.length === 0; + if (!hardGatesPassed) { + gated++; + continue; // Failed pairs aren't stored — the queue shows real candidates only. + } + + const scored = scoreMatch({ + similarity: grant.similarity, + orgTotalRevenue: org.totalRevenue, + awardCeiling: grant.awardCeiling, + geographicScope: grant.geographicScope, + applicationEffortEstimate: grant.applicationEffortEstimate, + closeDate: grant.closeDate, + now, + }); + + await serverUpsertMatchScore(db, { + orgId: org.id, + grantId: grant.id, + totalScore: scored.totalScore, + subscores: scored.subscores, + hardGatesPassed, + easyWin: scored.easyWin, + rationale: { + similarity: grant.similarity, + gateFailures: gates.failures, + ignoredGates: [...IGNORED_GATES], + scoredAt: now.toISOString(), + scoringVersion: 'v1-no-precedent', + }, + }); + stored++; + } + + await serverAssignHeroMatch(db, org.id); + return { stored, gated }; +} +const scoreAndStoreOrgMatchesStep = DBOS.registerStep(scoreAndStoreOrgMatches, { + name: 'scoreAndStoreOrgMatches', + retriesAllowed: true, + maxAttempts: 3, +}); + +async function runMatchGrants(): Promise { + const { db } = getMatchGrantsDeps(); + + const orgs = await listCandidateOrgsStep(db); + if (orgs.length === 0) { + console.log('[match-grants] no candidate orgs (enrichment backlog?)'); + return; + } + + let totalStored = 0; + let totalGated = 0; + let failedOrgs = 0; + for (const org of orgs) { + try { + const embedding = await ensureOrgEmbeddingStep(db, org); + const grants = await retrieveGrantsStep(db, embedding); + const { stored, gated } = await scoreAndStoreOrgMatchesStep( + db, + org, + grants, + ); + totalStored += stored; + totalGated += gated; + } catch (err) { + failedOrgs++; + console.error(`[match-grants] org "${org.name}" failed:`, err); + } + } + + console.log( + `[match-grants] orgs=${orgs.length} matchesStored=${totalStored} gatedOut=${totalGated} failedOrgs=${failedOrgs}`, + ); + if (failedOrgs > 0 && failedOrgs / orgs.length > 0.2) { + throw new Error( + `[match-grants] systemic failure: ${failedOrgs}/${orgs.length} orgs failed`, + ); + } +} + +const g = globalThis as unknown as { + __outreachMatchGrantsRegistered?: boolean; + __outreachMatchGrantsHandle?: ( + scheduledTime: Date, + startedAt: Date, + ) => Promise; +}; + +if (!g.__outreachMatchGrantsRegistered) { + g.__outreachMatchGrantsRegistered = true; + + const matchGrants = async (_scheduledTime: Date, _startedAt: Date) => { + try { + await runMatchGrants(); + } catch (err) { + console.error('[match-grants] 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.__outreachMatchGrantsHandle = DBOS.registerWorkflow(matchGrants, { + name: 'matchGrants', + }); + DBOS.registerScheduled(matchGrants, { + crontab: '15 5 * * *', + name: 'matchGrants', + 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 runMatchGrantsNow(): Promise { + const handle = g.__outreachMatchGrantsHandle; + if (handle == null) { + throw new Error( + 'matchGrants is not registered; was this module imported before DBOS.launch()?', + ); + } + return handle(new Date(), new Date()); +} diff --git a/docs/features/scoring.md b/docs/features/scoring.md new file mode 100644 index 0000000..74e4520 --- /dev/null +++ b/docs/features/scoring.md @@ -0,0 +1,15 @@ +# Scoring engine v1 (Stage 2) + +Nightly `matchGrants` workflow (05:15 UTC, after embeddings) — the plan's "SQL gates, vectors rank" hybrid. + +## Flow, per candidate org (NH + good standing + primary ICP) + +1. **Profile embedding** — v0 stub: NTEE-derived mission text (`buildOrgMissionText`) embedded as `RETRIEVAL_QUERY`, stored in `org_profiles` at confidence 0.2. The Stage 4 research profiler upgrades the row in place; this workflow doesn't change. +2. **Retrieval** — `serverListEligibleGrantsForOrg`: one SQL statement enforcing the cheap hard gates (status open, embedded, deadline ≥ 21 days, ceiling ≥ $10K) with pgvector cosine ranking; top 50 per org. +3. **Remaining gates in TS** — entity eligibility (`entryAdmitsEntity`: conservative pattern matching over Grants.gov applicantTypes prose; ambiguous entries do NOT admit) and geography (word-boundary state code + full state name). `application_form_supported` is **deliberately ignored** for pass/fail (2026-07-16 manual-first decision — draftability verified by hand for top leads); its failure still lands in `rationale.gateFailures`. Failed pairs are not stored. +4. **Deterministic subscores** (`scoreMatch`, pure, tested): mission fit 30 (similarity 0.45–0.75 → 0–30) · capacity 15 (award 10–75% of revenue = sweet spot) · competition 15 (state-restricted ≫ national) · effort 10 · runway 5 (3–10 weeks ideal). **Funder precedent (25) not yet awarded** — achievable max is 75 until the 990-PF index lands; `subscores` jsonb keeps the full breakdown for reweighting. Easy win = total ≥ 50. +5. **Upsert + hero** — pair-keyed upsert that never touches review fields (a human's reject stands even when scores move); `serverAssignHeroMatch` marks the org's top non-rejected gate-passing match. + +## First live run (2026-07-16) + +64 orgs × top-50 grants → 3,200 matches, 0 gate failures (corpus was pre-filtered to nonprofit-eligible, federal = geography-unrestricted), **0 easy wins, max 39/75**. That's the system being honest: the current corpus is 200 NIH-dominated federal research grants — wrong pond for $100K–$5M NH service nonprofits (similarity ceiling ~0.58). The engine's next real gains are corpus-side: NH state agency sources, 990-PF foundation ingestion, full Grants.gov detail backlog, real effort estimates. diff --git a/packages/outreach-core/drizzle/server/1784235098_match-uniques.sql b/packages/outreach-core/drizzle/server/1784235098_match-uniques.sql new file mode 100644 index 0000000..ef9307c --- /dev/null +++ b/packages/outreach-core/drizzle/server/1784235098_match-uniques.sql @@ -0,0 +1,2 @@ +CREATE UNIQUE INDEX "idx_matches_org_grant" ON "matches" USING btree ("org_id","grant_id");--> statement-breakpoint +CREATE UNIQUE INDEX "idx_org_profiles_org_unique" ON "org_profiles" USING btree ("org_id"); \ No newline at end of file diff --git a/packages/outreach-core/drizzle/server/meta/1784235098_snapshot.json b/packages/outreach-core/drizzle/server/meta/1784235098_snapshot.json new file mode 100644 index 0000000..48cb7d2 --- /dev/null +++ b/packages/outreach-core/drizzle/server/meta/1784235098_snapshot.json @@ -0,0 +1,1185 @@ +{ + "id": "4e00eb42-105f-461a-abd0-f7ff237267cf", + "prevId": "97041ebb-09e1-40dd-9042-c10af09a02c6", + "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.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 + }, + "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": {} + }, + "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 + }, + "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_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 11dff56..eb29057 100644 --- a/packages/outreach-core/drizzle/server/meta/_journal.json +++ b/packages/outreach-core/drizzle/server/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1784231856865, "tag": "1784231856_nhdoj-registry-fields", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1784235098035, + "tag": "1784235098_match-uniques", + "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 d30486a..b3b4555 100644 --- a/packages/outreach-core/src/db/schema.ts +++ b/packages/outreach-core/src/db/schema.ts @@ -227,6 +227,9 @@ export const orgProfiles = pgTable( createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), }, (t) => [ + // Latest-profile semantics: one row per org, refreshed in place (the + // Stage 4 profiler and the v0 NTEE-derived stub share this row). + uniqueIndex('idx_org_profiles_org_unique').on(t.orgId), index('idx_org_profiles_org').on(t.orgId), index('org_profiles_embedding_idx').using( 'hnsw', @@ -295,6 +298,8 @@ export const matches = pgTable( updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (t) => [ + // Re-scoring refreshes the same (org, grant) pair in place. + uniqueIndex('idx_matches_org_grant').on(t.orgId, t.grantId), index('idx_matches_org').on(t.orgId), index('idx_matches_grant').on(t.grantId), index('idx_matches_review_status').on(t.reviewStatus), diff --git a/packages/outreach-core/src/grants/queries/index.server.ts b/packages/outreach-core/src/grants/queries/index.server.ts index 5577826..9872dd7 100644 --- a/packages/outreach-core/src/grants/queries/index.server.ts +++ b/packages/outreach-core/src/grants/queries/index.server.ts @@ -1,2 +1,3 @@ export * from './list-open-grants.server.js'; export * from './list-grants-needing-embedding.server.js'; +export * from './list-eligible-grants-for-org.server.js'; 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 new file mode 100644 index 0000000..e0171db --- /dev/null +++ b/packages/outreach-core/src/grants/queries/list-eligible-grants-for-org.server.ts @@ -0,0 +1,69 @@ +import { sql } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +export interface EligibleGrantWithSimilarity { + id: string; + title: string; + funder: string; + eligibilityEntityTypes: string[] | null; + geographicScope: string | null; + closeDate: Date | null; + awardCeiling: number | null; + applicationEffortEstimate: + | 'loi_only' + | 'short_form' + | 'full_federal' + | 'unknown'; + applicationFormSupported: boolean; + similarity: number; +} + +export interface EligibleGrantFilters { + /** Days of runway the deadline must clear (hard gate: 21). */ + readonly minDaysToDeadline: number; + /** Minimum award ceiling in dollars (hard gate: 10_000). */ + readonly minAwardCeiling: number; + /** Top-K by similarity per org. */ + readonly limit: number; +} + +/** + * The scoring engine's retrieval query — the plan's "SQL gates, vectors + * rank" hybrid in one statement. Deterministic WHERE clauses enforce the + * cheap hard constraints (open, deadline runway, award floor, embedded); + * cosine distance against the org's profile embedding ranks what + * survives. Entity/geography gates run in TS afterwards where their + * pattern logic lives. + */ +export async function serverListEligibleGrantsForOrg( + db: NpOutreachDatabase | NpOutreachTransaction, + orgEmbedding: number[], + filters: EligibleGrantFilters, +): Promise { + const vector = JSON.stringify(orgEmbedding); + + return db + .select({ + id: schema.grants.id, + title: schema.grants.title, + funder: schema.grants.funder, + eligibilityEntityTypes: schema.grants.eligibilityEntityTypes, + geographicScope: schema.grants.geographicScope, + closeDate: schema.grants.closeDate, + awardCeiling: schema.grants.awardCeiling, + applicationEffortEstimate: schema.grants.applicationEffortEstimate, + applicationFormSupported: schema.grants.applicationFormSupported, + similarity: sql`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`, + }) + .from(schema.grants) + .where( + sql`${schema.grants.status} = 'open' + AND ${schema.grants.synopsisEmbedding} IS NOT NULL + AND ${schema.grants.closeDate} >= now() + make_interval(days => ${filters.minDaysToDeadline}) + AND ${schema.grants.awardCeiling} >= ${filters.minAwardCeiling}`, + ) + .orderBy(sql`${schema.grants.synopsisEmbedding} <=> ${vector}::vector`) + .limit(filters.limit); +} diff --git a/packages/outreach-core/src/index.ts b/packages/outreach-core/src/index.ts index 9aa80ce..464bd30 100644 --- a/packages/outreach-core/src/index.ts +++ b/packages/outreach-core/src/index.ts @@ -8,3 +8,5 @@ export * from './db/index.js'; export * from './matches/hard-gates.js'; export * from './orgs/icp-band.js'; +export * from './matches/scoring.js'; +export * from './orgs/ntee.js'; 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 new file mode 100644 index 0000000..5226516 --- /dev/null +++ b/packages/outreach-core/src/matches/actions/assign-hero-matches.server.ts @@ -0,0 +1,31 @@ +import { eq, sql } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +/** + * Recomputes the org's hero match: its single top-ranked non-rejected + * match, easy wins first, then total score. Every org gets exactly one + * hero (its Email 1 grant); runners-up stay ranked for Email 2. + */ +export async function serverAssignHeroMatch( + db: NpOutreachDatabase | NpOutreachTransaction, + orgId: string, +): Promise { + await db + .update(schema.matches) + .set({ isHero: false, updatedAt: sql`now()` }) + .where(eq(schema.matches.orgId, orgId)); + + await db.execute(sql` + UPDATE matches SET is_hero = true, updated_at = now() + WHERE id = ( + SELECT id FROM matches + WHERE org_id = ${orgId} + AND review_status != 'rejected' + AND hard_gates_passed = 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 356c71b..fca0046 100644 --- a/packages/outreach-core/src/matches/actions/index.server.ts +++ b/packages/outreach-core/src/matches/actions/index.server.ts @@ -1,2 +1,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'; 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 new file mode 100644 index 0000000..3c34f4b --- /dev/null +++ b/packages/outreach-core/src/matches/actions/upsert-match-score.server.ts @@ -0,0 +1,53 @@ +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'; + +export interface MatchScoreInput { + readonly orgId: string; + readonly grantId: string; + readonly totalScore: number; + readonly subscores: MatchSubscores; + readonly hardGatesPassed: boolean; + readonly easyWin: boolean; + /** Gate failures, similarity, and any judge citations — audit trail. */ + readonly rationale: unknown; +} + +/** + * Records a scored (org, grant) match, refreshing scores in place on + * re-runs (unique on org+grant). Review fields are deliberately NOT + * touched on conflict: a human's approve/reject stands even when the + * nightly re-score moves the numbers — resurfacing rejected matches would + * erode the review queue's trust, and re-approving approved ones is + * pointless churn. + */ +export async function serverUpsertMatchScore( + db: NpOutreachDatabase | NpOutreachTransaction, + match: MatchScoreInput, +): Promise { + await db + .insert(schema.matches) + .values({ + orgId: match.orgId, + grantId: match.grantId, + totalScore: match.totalScore, + subscores: match.subscores, + hardGatesPassed: match.hardGatesPassed, + easyWin: match.easyWin, + rationale: match.rationale, + reviewStatus: 'pending', + }) + .onConflictDoUpdate({ + target: [schema.matches.orgId, schema.matches.grantId], + set: { + totalScore: sql`excluded.total_score`, + subscores: sql`excluded.subscores`, + hardGatesPassed: sql`excluded.hard_gates_passed`, + easyWin: sql`excluded.easy_win`, + rationale: sql`excluded.rationale`, + updatedAt: sql`now()`, + }, + }); +} diff --git a/packages/outreach-core/src/matches/hard-gates.test.ts b/packages/outreach-core/src/matches/hard-gates.test.ts index 2f71d58..6c2d019 100644 --- a/packages/outreach-core/src/matches/hard-gates.test.ts +++ b/packages/outreach-core/src/matches/hard-gates.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + entryAdmitsEntity, evaluateHardGates, type HardGateGrantInput, type HardGateOrgInput, @@ -151,3 +152,28 @@ describe('evaluateHardGates', () => { ]); }); }); + +describe('entryAdmitsEntity', () => { + it('admits 501c3 orgs on Grants.gov prose entries', () => { + expect( + entryAdmitsEntity( + 'Nonprofits having a 501(c)(3) status with the IRS, other than institutions of higher education', + '501c3', + ), + ).toBe(true); + expect(entryAdmitsEntity('501c3', '501c3')).toBe(true); + expect(entryAdmitsEntity('Nonprofit organizations', '501c3')).toBe(true); + }); + + it('rejects negated and ambiguous entries', () => { + expect( + entryAdmitsEntity( + 'Nonprofits that do not have a 501(c)(3) status with the IRS, other than institutions of higher education', + '501c3', + ), + ).toBe(false); + expect(entryAdmitsEntity('Others (see text field entitled "Additional Information on Eligibility")', '501c3')).toBe(false); + expect(entryAdmitsEntity('County governments', '501c3')).toBe(false); + expect(entryAdmitsEntity('Nonprofits other than faith-based organizations', '501c3')).toBe(false); + }); +}); diff --git a/packages/outreach-core/src/matches/hard-gates.ts b/packages/outreach-core/src/matches/hard-gates.ts index ada1d43..ec2ab00 100644 --- a/packages/outreach-core/src/matches/hard-gates.ts +++ b/packages/outreach-core/src/matches/hard-gates.ts @@ -128,6 +128,38 @@ export function evaluateHardGates( return { passed: failures.length === 0, failures }; } +const P501C3_PATTERN = /501\s*\(?\s*c\s*\)?\s*\(?\s*3\s*\)?/i; +/** "Nonprofits that do not have / without a 501(c)(3) status..." */ +const P501C3_NEGATED_PATTERN = /(?:do not have|without)\s+(?:a\s+)?501/i; +const GENERIC_NEGATION_PATTERN = /other than|do not have|without|excluding/i; + +/** + * Does one eligibility-list entry admit this org? Entries arrive two ways: + * short codes from curated sources ('501c3'), matched exactly, and prose + * descriptions from Grants.gov applicantTypes ("Nonprofits having a + * 501(c)(3) status with the IRS, other than institutions of higher + * education"), matched by pattern. Deliberately conservative — an + * ambiguous entry ("Others: see text field") does NOT admit; a wrongly + * claimed eligibility in an outbound email is the failure mode this whole + * engine exists to prevent. + */ +export function entryAdmitsEntity(entry: string, entityType: string): boolean { + const e = entry.trim(); + if (e.toLowerCase() === entityType.toLowerCase()) return true; + + if (entityType.toLowerCase() === '501c3') { + // Entry names the 501(c)(3) code: admits unless it names it only to + // exclude it ("nonprofits that do not have a 501(c)(3) status"). + if (P501C3_PATTERN.test(e)) return !P501C3_NEGATED_PATTERN.test(e); + // Generic nonprofit entry with no 501-qualifier and no carve-out. + if (/\bnon-?profits?\b/i.test(e) && !GENERIC_NEGATION_PATTERN.test(e)) { + return true; + } + } + + return false; +} + function isEntityEligible( org: HardGateOrgInput, grant: HardGateGrantInput, @@ -138,8 +170,8 @@ function isEntityEligible( ) { return true; } - return grant.eligibilityEntityTypes.some( - (entityType) => entityType.toLowerCase() === org.entityType.toLowerCase(), + return grant.eligibilityEntityTypes.some((entry) => + entryAdmitsEntity(entry, org.entityType), ); } 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 38df478..80500f7 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 @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { desc, eq } from 'drizzle-orm'; import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; import { schema } from '#~/db/db.js'; @@ -37,5 +37,14 @@ export async function serverListPendingReviewMatches( .from(schema.matches) .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) - .where(eq(schema.matches.reviewStatus, 'pending')); + .where(eq(schema.matches.reviewStatus, 'pending')) + // Reviewers see the best candidates first: heroes, then easy wins, + // then raw score. Capped — nightly re-scoring generates thousands of + // pending pairs and the queue is worked top-down, not exhaustively. + .orderBy( + desc(schema.matches.isHero), + desc(schema.matches.easyWin), + desc(schema.matches.totalScore), + ) + .limit(100); } diff --git a/packages/outreach-core/src/matches/scoring.test.ts b/packages/outreach-core/src/matches/scoring.test.ts new file mode 100644 index 0000000..6d67b5c --- /dev/null +++ b/packages/outreach-core/src/matches/scoring.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; + +import { + capacityFitSubscore, + competitionSubscore, + EASY_WIN_THRESHOLD, + effortSubscore, + missionFitSubscore, + runwaySubscore, + scoreMatch, +} from './scoring.js'; + +const NOW = new Date('2026-07-16T00:00:00Z'); + +function weeksFromNow(weeks: number): Date { + return new Date(NOW.getTime() + weeks * 7 * 24 * 60 * 60 * 1000); +} + +describe('missionFitSubscore', () => { + it('scales similarity between floor and ceiling to 0–30', () => { + expect(missionFitSubscore(0.45)).toBe(0); + expect(missionFitSubscore(0.6)).toBe(15); + expect(missionFitSubscore(0.75)).toBe(30); + }); + + it('clamps outside the band', () => { + expect(missionFitSubscore(0.1)).toBe(0); + expect(missionFitSubscore(0.95)).toBe(30); + }); +}); + +describe('capacityFitSubscore', () => { + it('gives full marks in the 10–75% sweet spot', () => { + expect(capacityFitSubscore(100_000, 1_000_000)).toBe(15); + expect(capacityFitSubscore(750_000, 1_000_000)).toBe(15); + }); + + it('penalizes awards dwarfing the org', () => { + expect(capacityFitSubscore(480_000, 150_000)).toBe(2); + expect(capacityFitSubscore(1_200_000, 1_000_000)).toBe(8); + }); + + it('penalizes trivially small awards', () => { + expect(capacityFitSubscore(20_000, 1_000_000)).toBe(4); + }); + + it('is neutral on unknown revenue, zero on unknown award', () => { + expect(capacityFitSubscore(100_000, null)).toBe(7); + expect(capacityFitSubscore(null, 1_000_000)).toBe(0); + }); +}); + +describe('competitionSubscore', () => { + it('scores restricted pools high, national low', () => { + expect(competitionSubscore('New Hampshire')).toBe(15); + expect(competitionSubscore('Statewide - NH')).toBe(15); + expect(competitionSubscore('New England')).toBe(10); + expect(competitionSubscore(null)).toBe(3); + expect(competitionSubscore('National')).toBe(3); + }); +}); + +describe('effortSubscore', () => { + it('orders loi > short form > unknown > full federal', () => { + expect(effortSubscore('loi_only')).toBe(10); + expect(effortSubscore('short_form')).toBe(8); + expect(effortSubscore('unknown')).toBe(4); + expect(effortSubscore('full_federal')).toBe(2); + }); +}); + +describe('runwaySubscore', () => { + it('peaks in the 3–10 week window', () => { + expect(runwaySubscore(weeksFromNow(2), NOW)).toBe(0); + expect(runwaySubscore(weeksFromNow(5), NOW)).toBe(5); + expect(runwaySubscore(weeksFromNow(15), NOW)).toBe(3); + expect(runwaySubscore(weeksFromNow(30), NOW)).toBe(1); + expect(runwaySubscore(null, NOW)).toBe(2); + }); +}); + +describe('scoreMatch', () => { + it('sums subscores and flags easy wins', () => { + const result = scoreMatch({ + similarity: 0.75, + orgTotalRevenue: 1_000_000, + awardCeiling: 200_000, + geographicScope: 'New Hampshire', + applicationEffortEstimate: 'short_form', + closeDate: weeksFromNow(6), + now: NOW, + }); + // 30 fit + 15 capacity + 15 competition + 8 effort + 5 runway + expect(result.totalScore).toBe(73); + expect(result.easyWin).toBe(true); + expect(result.subscores.funderPrecedent).toBe(0); + }); + + it('keeps weak matches under the easy-win line', () => { + const result = scoreMatch({ + similarity: 0.5, + orgTotalRevenue: 150_000, + awardCeiling: 480_000, + geographicScope: null, + applicationEffortEstimate: 'full_federal', + closeDate: weeksFromNow(2), + now: NOW, + }); + expect(result.totalScore).toBeLessThan(EASY_WIN_THRESHOLD); + expect(result.easyWin).toBe(false); + }); +}); diff --git a/packages/outreach-core/src/matches/scoring.ts b/packages/outreach-core/src/matches/scoring.ts new file mode 100644 index 0000000..731987d --- /dev/null +++ b/packages/outreach-core/src/matches/scoring.ts @@ -0,0 +1,158 @@ +/** + * Deterministic weighted subscores for an (org, grant) pair — Stage 2 of + * the scoring engine (docs/plan.md). Pure functions over plain inputs; + * the match workflow supplies the embedding similarity, everything else + * derives from columns. + * + * v1 weights (funder precedent's 25 points are NOT yet awarded — the + * 990-PF index is a later deliverable, so the achievable maximum is 75, + * not 100). `subscores` records each component so weights can be re-tuned + * from review/booking data without re-deriving inputs. + * + * mission fit 30 embedding cosine similarity, scaled + * capacity fit 15 award ceiling vs org revenue (sweet spot 10–75%) + * competition 15 state/NH-restricted pools beat national ones + * effort 10 LOI/short-form beat full federal + * runway 5 3–10 weeks to deadline is ideal + */ + +export interface MatchSubscores { + readonly missionFit: number; + readonly capacityFit: number; + readonly competition: number; + readonly effort: number; + readonly runway: number; + /** Not yet computed — reserved so the jsonb shape is stable. */ + readonly funderPrecedent: 0; +} + +export interface ScoreMatchInput { + /** Cosine similarity in [-1, 1] between org mission and grant synopsis. */ + readonly similarity: number; + readonly orgTotalRevenue: number | null; + readonly awardCeiling: number | null; + readonly geographicScope: string | null; + readonly applicationEffortEstimate: + | 'loi_only' + | 'short_form' + | 'full_federal' + | 'unknown'; + readonly closeDate: Date | null; + readonly now: Date; +} + +export const ACHIEVABLE_MAX_SCORE = 75; +/** + * "Easy win" threshold, v1: two-thirds of the achievable maximum. The + * plan's full definition also requires a funder-precedent floor — that + * gate returns when the 990-PF index lands; thresholds re-tune on review + * and demo-booking data regardless. + */ +export const EASY_WIN_THRESHOLD = 50; + +/** Similarity below this scores 0 fit; above the ceiling scores full fit. */ +const SIMILARITY_FLOOR = 0.45; +const SIMILARITY_CEILING = 0.75; + +export function missionFitSubscore(similarity: number): number { + const clamped = Math.min( + Math.max(similarity, SIMILARITY_FLOOR), + SIMILARITY_CEILING, + ); + return Math.round( + ((clamped - SIMILARITY_FLOOR) / (SIMILARITY_CEILING - SIMILARITY_FLOOR)) * 30, + ); +} + +/** + * Sweet spot: award is 10–75% of annual revenue (docs/plan.md). A grant + * dwarfing the org's budget is a capacity red flag to federal funders; a + * tiny one isn't worth the email. Unknown revenue scores a neutral 7. + */ +export function capacityFitSubscore( + awardCeiling: number | null, + orgTotalRevenue: number | null, +): number { + if (awardCeiling == null || orgTotalRevenue == null || orgTotalRevenue <= 0) { + return awardCeiling == null ? 0 : 7; + } + const ratio = awardCeiling / orgTotalRevenue; + if (ratio >= 0.1 && ratio <= 0.75) return 15; + if (ratio >= 0.05 && ratio < 0.1) return 10; + if (ratio > 0.75 && ratio <= 1.5) return 8; + if (ratio < 0.05) return 4; + return 2; // > 150% of revenue: real capacity red flag. +} + +const STATE_RESTRICTED_PATTERN = + /new hampshire|\bnh\b|state of|statewide|county|municipal/i; +const REGIONAL_PATTERN = /new england|northeast|regional/i; + +/** + * Competition proxy until expected-applicant-pool modeling exists: + * geographically restricted pools are dramatically less competitive than + * national ones. Null scope (typical for federal) = national = low score. + */ +export function competitionSubscore(geographicScope: string | null): number { + if (geographicScope == null || geographicScope.trim() === '') return 3; + if (STATE_RESTRICTED_PATTERN.test(geographicScope)) return 15; + if (REGIONAL_PATTERN.test(geographicScope)) return 10; + return 3; +} + +export function effortSubscore( + estimate: ScoreMatchInput['applicationEffortEstimate'], +): number { + switch (estimate) { + case 'loi_only': + return 10; + case 'short_form': + return 8; + case 'unknown': + return 4; + case 'full_federal': + return 2; + } +} + +const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; + +/** 3–10 weeks out is ideal: urgent enough to act on, long enough to apply. */ +export function runwaySubscore(closeDate: Date | null, now: Date): number { + if (closeDate == null) return 2; // rolling/unknown deadline: usable, not urgent. + const weeks = (closeDate.getTime() - now.getTime()) / MS_PER_WEEK; + if (weeks < 3) return 0; + if (weeks <= 10) return 5; + if (weeks <= 20) return 3; + return 1; +} + +export interface ScoredMatch { + readonly totalScore: number; + readonly subscores: MatchSubscores; + readonly easyWin: boolean; +} + +export function scoreMatch(input: ScoreMatchInput): ScoredMatch { + const subscores: MatchSubscores = { + missionFit: missionFitSubscore(input.similarity), + capacityFit: capacityFitSubscore(input.awardCeiling, input.orgTotalRevenue), + competition: competitionSubscore(input.geographicScope), + effort: effortSubscore(input.applicationEffortEstimate), + runway: runwaySubscore(input.closeDate, input.now), + funderPrecedent: 0, + }; + + const totalScore = + subscores.missionFit + + subscores.capacityFit + + subscores.competition + + subscores.effort + + subscores.runway; + + return { + totalScore, + subscores, + easyWin: totalScore >= EASY_WIN_THRESHOLD, + }; +} diff --git a/packages/outreach-core/src/orgs/actions/ensure-org-profile-stub.server.ts b/packages/outreach-core/src/orgs/actions/ensure-org-profile-stub.server.ts new file mode 100644 index 0000000..e345667 --- /dev/null +++ b/packages/outreach-core/src/orgs/actions/ensure-org-profile-stub.server.ts @@ -0,0 +1,57 @@ +import { eq } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +/** Confidence marker distinguishing NTEE-derived stubs from researched profiles. */ +export const STUB_PROFILE_CONFIDENCE = 0.2; + +export interface OrgProfileStubInput { + readonly orgId: string; + readonly missionStatement: string; + /** 1536-dim RETRIEVAL_QUERY embedding of the mission statement. */ + readonly profileEmbedding: number[]; +} + +/** + * Returns the org's existing profile embedding, or inserts a v0 stub + * (NTEE-derived mission text + its embedding, confidence 0.2) when the + * org has never been profiled. Never overwrites — the Stage 4 research + * profiler owns upgrades to this row. + */ +export async function serverGetOrCreateOrgProfileEmbedding( + db: NpOutreachDatabase | NpOutreachTransaction, + stub: OrgProfileStubInput, +): Promise<{ embedding: number[]; wasCreated: boolean }> { + const existing = await db + .select({ embedding: schema.orgProfiles.profileEmbedding }) + .from(schema.orgProfiles) + .where(eq(schema.orgProfiles.orgId, stub.orgId)) + .limit(1); + + const found = existing[0]; + if (found?.embedding != null) { + return { embedding: found.embedding, wasCreated: false }; + } + + await db + .insert(schema.orgProfiles) + .values({ + orgId: stub.orgId, + missionStatement: stub.missionStatement, + profileEmbedding: stub.profileEmbedding, + confidence: STUB_PROFILE_CONFIDENCE, + sources: [], + }) + .onConflictDoUpdate({ + target: schema.orgProfiles.orgId, + // Profile row exists but was never embedded (shouldn't happen from + // this code path; defensive against a half-written Stage 4 row): + // fill only the embedding-adjacent fields. + set: { + profileEmbedding: stub.profileEmbedding, + }, + }); + + return { embedding: stub.profileEmbedding, wasCreated: true }; +} diff --git a/packages/outreach-core/src/orgs/actions/index.server.ts b/packages/outreach-core/src/orgs/actions/index.server.ts index bda5dfb..8f7fc2a 100644 --- a/packages/outreach-core/src/orgs/actions/index.server.ts +++ b/packages/outreach-core/src/orgs/actions/index.server.ts @@ -1,3 +1,4 @@ export * from './insert-org.server.js'; export * from './upsert-org-from-registry.server.js'; export * from './enrich-org.server.js'; +export * from './ensure-org-profile-stub.server.js'; diff --git a/packages/outreach-core/src/orgs/ntee.test.ts b/packages/outreach-core/src/orgs/ntee.test.ts new file mode 100644 index 0000000..a48b029 --- /dev/null +++ b/packages/outreach-core/src/orgs/ntee.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildOrgMissionText, nteeDescription } from './ntee.js'; + +describe('nteeDescription', () => { + it('maps major group letters', () => { + expect(nteeDescription('B25')).toBe('education'); + expect(nteeDescription('c300')).toBe('environment and conservation'); + expect(nteeDescription('P20')).toBe('human services'); + }); + + it('returns null for missing or unknown codes', () => { + expect(nteeDescription(null)).toBeNull(); + expect(nteeDescription('')).toBeNull(); + expect(nteeDescription('9X')).toBeNull(); + }); +}); + +describe('buildOrgMissionText', () => { + it('composes a description-shaped mission text', () => { + expect( + buildOrgMissionText({ + name: 'Audubon Society of NH', + city: 'Concord', + state: 'NH', + nteeCode: 'C300', + }), + ).toBe( + 'Audubon Society of NH — a nonprofit organization working in environment and conservation (NTEE C300) based in Concord, NH.', + ); + }); + + it('degrades gracefully without NTEE or location', () => { + expect( + buildOrgMissionText({ name: 'X', city: null, state: null, nteeCode: null }), + ).toBe('X — a nonprofit organization.'); + }); +}); diff --git a/packages/outreach-core/src/orgs/ntee.ts b/packages/outreach-core/src/orgs/ntee.ts new file mode 100644 index 0000000..baaf092 --- /dev/null +++ b/packages/outreach-core/src/orgs/ntee.ts @@ -0,0 +1,76 @@ +/** + * NTEE (National Taxonomy of Exempt Entities) major-group descriptions. + * Used to compose a v0 mission text for orgs that haven't been through the + * Stage 4 research profiler yet — the IRS NTEE code is the only program + * signal ProPublica enrichment gives us. + */ + +const NTEE_MAJOR_GROUPS: Record = { + A: 'arts, culture, and humanities', + B: 'education', + C: 'environment and conservation', + D: 'animal welfare', + E: 'health care', + F: 'mental health and crisis intervention', + G: 'disease and disorder research and support', + H: 'medical research', + I: 'crime and legal services', + J: 'employment and job training', + K: 'food, agriculture, and nutrition', + L: 'housing and shelter', + M: 'public safety and disaster relief', + N: 'recreation and sports', + O: 'youth development', + P: 'human services', + Q: 'international affairs and development', + R: 'civil rights and advocacy', + S: 'community improvement and economic development', + T: 'philanthropy and grantmaking', + U: 'science and technology research', + V: 'social science research', + W: 'public benefit and civic institutions', + X: 'religion and spiritual development', + Y: 'mutual benefit organizations', + Z: 'unclassified', +}; + +/** + * Human phrase for an NTEE code ('B25' → 'education'), null when the code + * is missing or unrecognizable. Only the major group letter is mapped — + * decimal-level precision isn't worth a 600-entry table for a v0 mission + * text the profiler will replace. + */ +export function nteeDescription(code: string | null | undefined): string | null { + if (code == null) return null; + const letter = code.trim().charAt(0).toUpperCase(); + return NTEE_MAJOR_GROUPS[letter] ?? null; +} + +export interface OrgMissionTextInput { + readonly name: string; + readonly city: string | null; + readonly state: string | null; + readonly nteeCode: string | null; +} + +/** + * v0 mission text for embedding as a RETRIEVAL_QUERY against grant + * synopses. Deliberately description-shaped ("a nonprofit working in + * education, based in Nashua, NH") rather than a key-value dump, to live + * in the same embedding space as grant prose. + */ +export function buildOrgMissionText(org: OrgMissionTextInput): string { + const focus = nteeDescription(org.nteeCode); + const where = [org.city, org.state].filter(Boolean).join(', '); + + const parts = [org.name]; + parts.push( + focus != null + ? `— a nonprofit organization working in ${focus}` + : '— a nonprofit organization', + ); + if (org.nteeCode != null) parts.push(`(NTEE ${org.nteeCode.trim()})`); + if (where !== '') parts.push(`based in ${where}`); + + return `${parts.join(' ')}.`; +} diff --git a/packages/outreach-core/src/orgs/queries/index.server.ts b/packages/outreach-core/src/orgs/queries/index.server.ts index efee2e1..95e35e5 100644 --- a/packages/outreach-core/src/orgs/queries/index.server.ts +++ b/packages/outreach-core/src/orgs/queries/index.server.ts @@ -1,2 +1,3 @@ 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'; diff --git a/packages/outreach-core/src/orgs/queries/list-match-candidate-orgs.server.ts b/packages/outreach-core/src/orgs/queries/list-match-candidate-orgs.server.ts new file mode 100644 index 0000000..3cb7390 --- /dev/null +++ b/packages/outreach-core/src/orgs/queries/list-match-candidate-orgs.server.ts @@ -0,0 +1,42 @@ +import { and, eq } from 'drizzle-orm'; + +import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; +import { schema } from '#~/db/db.js'; + +export interface MatchCandidateOrg { + id: string; + name: string; + city: string | null; + state: string; + nteeCode: string | null; + totalRevenue: number | null; +} + +/** + * Orgs eligible to enter match scoring: the primary ICP band ($100K–$5M + * revenue), NH-based, in good standing with the registry. Everything else + * is either not the customer (yet) or would fail the standing gate anyway. + */ +export async function serverListMatchCandidateOrgs( + db: NpOutreachDatabase | NpOutreachTransaction, + { limit }: { limit: number }, +): Promise { + return db + .select({ + id: schema.orgs.id, + name: schema.orgs.name, + city: schema.orgs.city, + state: schema.orgs.state, + nteeCode: schema.orgs.nteeCode, + totalRevenue: schema.orgs.totalRevenue, + }) + .from(schema.orgs) + .where( + and( + eq(schema.orgs.state, 'NH'), + eq(schema.orgs.registrationStatus, 'good_standing'), + eq(schema.orgs.icpBand, 'primary'), + ), + ) + .limit(limit); +}