feat(scoring): match-generation workflow — scoring engine v1 (Stage 2, step 2)
matchGrants (nightly 05:15): per candidate org (NH + good standing + primary ICP) ensure a v0 NTEE-derived profile embedding (RETRIEVAL_QUERY, confidence 0.2 stub the Stage 4 profiler upgrades in place), retrieve top-50 open grants by pgvector cosine with SQL-enforced cheap gates (deadline >=21d, ceiling >=10K), run entity/geography gates in TS, score deterministically, upsert pair-keyed matches, reassign hero. Scoring: pure scoreMatch (mission fit 30 / capacity 15 / competition 15 / effort 10 / runway 5; precedent's 25 reserved until the 990-PF index; easy win >= 50/75). Eligibility gate now pattern-matches Grants.gov applicantTypes prose, conservatively (ambiguous entries do not admit). application_form_supported ignored for pass/fail per the manual-first decision, still recorded in rationale. Schema: unique (org_id, grant_id) on matches; unique org_id on org_profiles (latest-profile semantics). Review queue query now ordered hero > easy-win > score and capped at 100. Live run: 64 orgs -> 3,200 matches in 28s, 0 easy wins / max 39 — the honest result of an NIH-heavy 200-grant corpus vs NH service nonprofits; engine mechanics verified, corpus breadth is the next lever. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { setEnrichOrgsDeps } from './workflows/enrich-orgs.js';
|
|||||||
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
|
import { setExpireGrantsDeps } from './workflows/expire-grants.js';
|
||||||
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
|
import { setIngestGrantsDeps } from './workflows/ingest-grants.js';
|
||||||
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
|
import { setIngestNhdojOrgsDeps } from './workflows/ingest-nhdoj-orgs.js';
|
||||||
|
import { setMatchGrantsDeps } from './workflows/match-grants.js';
|
||||||
import { setIngestPndRssDeps } from './workflows/ingest-pnd-rss.js';
|
import { setIngestPndRssDeps } from './workflows/ingest-pnd-rss.js';
|
||||||
|
|
||||||
if (process.env.DATABASE_URL == null) {
|
if (process.env.DATABASE_URL == null) {
|
||||||
@@ -67,6 +68,7 @@ async function main() {
|
|||||||
setIngestNhdojOrgsDeps({ db });
|
setIngestNhdojOrgsDeps({ db });
|
||||||
setEnrichOrgsDeps({ db });
|
setEnrichOrgsDeps({ db });
|
||||||
setEmbedGrantsDeps({ db });
|
setEmbedGrantsDeps({ db });
|
||||||
|
setMatchGrantsDeps({ db });
|
||||||
|
|
||||||
DBOS.setConfig({
|
DBOS.setConfig({
|
||||||
name: 'helmdocs-outreach-worker',
|
name: 'helmdocs-outreach-worker',
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import {
|
|||||||
runIngestNhdojOrgsNow,
|
runIngestNhdojOrgsNow,
|
||||||
setIngestNhdojOrgsDeps,
|
setIngestNhdojOrgsDeps,
|
||||||
} from './workflows/ingest-nhdoj-orgs.js';
|
} from './workflows/ingest-nhdoj-orgs.js';
|
||||||
|
import {
|
||||||
|
runMatchGrantsNow,
|
||||||
|
setMatchGrantsDeps,
|
||||||
|
} from './workflows/match-grants.js';
|
||||||
import {
|
import {
|
||||||
runIngestPndRssNow,
|
runIngestPndRssNow,
|
||||||
setIngestPndRssDeps,
|
setIngestPndRssDeps,
|
||||||
@@ -48,6 +52,7 @@ const RUNNERS: Record<string, () => Promise<void>> = {
|
|||||||
enrichOrgs: runEnrichOrgsNow,
|
enrichOrgs: runEnrichOrgsNow,
|
||||||
expireGrants: runExpireGrantsNow,
|
expireGrants: runExpireGrantsNow,
|
||||||
embedGrants: runEmbedGrantsNow,
|
embedGrants: runEmbedGrantsNow,
|
||||||
|
matchGrants: runMatchGrantsNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIRST_RUN_ORDER = [
|
const FIRST_RUN_ORDER = [
|
||||||
@@ -57,6 +62,7 @@ const FIRST_RUN_ORDER = [
|
|||||||
'expireGrants',
|
'expireGrants',
|
||||||
'enrichOrgs',
|
'enrichOrgs',
|
||||||
'embedGrants',
|
'embedGrants',
|
||||||
|
'matchGrants',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (process.env.DATABASE_URL == null) {
|
if (process.env.DATABASE_URL == null) {
|
||||||
@@ -92,6 +98,7 @@ async function main() {
|
|||||||
setIngestNhdojOrgsDeps({ db });
|
setIngestNhdojOrgsDeps({ db });
|
||||||
setEnrichOrgsDeps({ db });
|
setEnrichOrgsDeps({ db });
|
||||||
setEmbedGrantsDeps({ db });
|
setEmbedGrantsDeps({ db });
|
||||||
|
setMatchGrantsDeps({ db });
|
||||||
|
|
||||||
DBOS.setConfig({
|
DBOS.setConfig({
|
||||||
name: 'helmdocs-outreach-worker',
|
name: 'helmdocs-outreach-worker',
|
||||||
|
|||||||
276
apps/outreach-worker/src/workflows/match-grants.ts
Normal file
276
apps/outreach-worker/src/workflows/match-grants.ts
Normal file
@@ -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<typeof schema>;
|
||||||
|
|
||||||
|
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<HardGateFailureReason> = 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<MatchCandidateOrg[]> {
|
||||||
|
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<number[]> {
|
||||||
|
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<EligibleGrantWithSimilarity[]> {
|
||||||
|
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<void> {
|
||||||
|
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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
15
docs/features/scoring.md
Normal file
15
docs/features/scoring.md
Normal file
@@ -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.
|
||||||
@@ -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");
|
||||||
1185
packages/outreach-core/drizzle/server/meta/1784235098_snapshot.json
Normal file
1185
packages/outreach-core/drizzle/server/meta/1784235098_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1784231856865,
|
"when": 1784231856865,
|
||||||
"tag": "1784231856_nhdoj-registry-fields",
|
"tag": "1784231856_nhdoj-registry-fields",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784235098035,
|
||||||
|
"tag": "1784235098_match-uniques",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -227,6 +227,9 @@ export const orgProfiles = pgTable(
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(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('idx_org_profiles_org').on(t.orgId),
|
||||||
index('org_profiles_embedding_idx').using(
|
index('org_profiles_embedding_idx').using(
|
||||||
'hnsw',
|
'hnsw',
|
||||||
@@ -295,6 +298,8 @@ export const matches = pgTable(
|
|||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [
|
(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_org').on(t.orgId),
|
||||||
index('idx_matches_grant').on(t.grantId),
|
index('idx_matches_grant').on(t.grantId),
|
||||||
index('idx_matches_review_status').on(t.reviewStatus),
|
index('idx_matches_review_status').on(t.reviewStatus),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './list-open-grants.server.js';
|
export * from './list-open-grants.server.js';
|
||||||
export * from './list-grants-needing-embedding.server.js';
|
export * from './list-grants-needing-embedding.server.js';
|
||||||
|
export * from './list-eligible-grants-for-org.server.js';
|
||||||
|
|||||||
@@ -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<EligibleGrantWithSimilarity[]> {
|
||||||
|
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<number>`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);
|
||||||
|
}
|
||||||
@@ -8,3 +8,5 @@
|
|||||||
export * from './db/index.js';
|
export * from './db/index.js';
|
||||||
export * from './matches/hard-gates.js';
|
export * from './matches/hard-gates.js';
|
||||||
export * from './orgs/icp-band.js';
|
export * from './orgs/icp-band.js';
|
||||||
|
export * from './matches/scoring.js';
|
||||||
|
export * from './orgs/ntee.js';
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './insert-match.server.js';
|
export * from './insert-match.server.js';
|
||||||
export * from './set-match-review.server.js';
|
export * from './set-match-review.server.js';
|
||||||
|
export * from './upsert-match-score.server.js';
|
||||||
|
export * from './assign-hero-matches.server.js';
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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()`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
entryAdmitsEntity,
|
||||||
evaluateHardGates,
|
evaluateHardGates,
|
||||||
type HardGateGrantInput,
|
type HardGateGrantInput,
|
||||||
type HardGateOrgInput,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,6 +128,38 @@ export function evaluateHardGates(
|
|||||||
return { passed: failures.length === 0, failures };
|
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(
|
function isEntityEligible(
|
||||||
org: HardGateOrgInput,
|
org: HardGateOrgInput,
|
||||||
grant: HardGateGrantInput,
|
grant: HardGateGrantInput,
|
||||||
@@ -138,8 +170,8 @@ function isEntityEligible(
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return grant.eligibilityEntityTypes.some(
|
return grant.eligibilityEntityTypes.some((entry) =>
|
||||||
(entityType) => entityType.toLowerCase() === org.entityType.toLowerCase(),
|
entryAdmitsEntity(entry, org.entityType),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||||
import { schema } from '#~/db/db.js';
|
import { schema } from '#~/db/db.js';
|
||||||
@@ -37,5 +37,14 @@ export async function serverListPendingReviewMatches(
|
|||||||
.from(schema.matches)
|
.from(schema.matches)
|
||||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||||
.where(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);
|
||||||
}
|
}
|
||||||
|
|||||||
112
packages/outreach-core/src/matches/scoring.test.ts
Normal file
112
packages/outreach-core/src/matches/scoring.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
158
packages/outreach-core/src/matches/scoring.ts
Normal file
158
packages/outreach-core/src/matches/scoring.ts
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './insert-org.server.js';
|
export * from './insert-org.server.js';
|
||||||
export * from './upsert-org-from-registry.server.js';
|
export * from './upsert-org-from-registry.server.js';
|
||||||
export * from './enrich-org.server.js';
|
export * from './enrich-org.server.js';
|
||||||
|
export * from './ensure-org-profile-stub.server.js';
|
||||||
|
|||||||
38
packages/outreach-core/src/orgs/ntee.test.ts
Normal file
38
packages/outreach-core/src/orgs/ntee.test.ts
Normal file
@@ -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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
76
packages/outreach-core/src/orgs/ntee.ts
Normal file
76
packages/outreach-core/src/orgs/ntee.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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(' ')}.`;
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './list-orgs-in-icp-band.server.js';
|
export * from './list-orgs-in-icp-band.server.js';
|
||||||
export * from './list-orgs-needing-enrichment.server.js';
|
export * from './list-orgs-needing-enrichment.server.js';
|
||||||
|
export * from './list-match-candidate-orgs.server.js';
|
||||||
|
|||||||
@@ -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<MatchCandidateOrg[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user