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:
Croissant Le Doux
2026-07-16 16:55:10 -04:00
parent a532f0bebf
commit 0ee478ec3d
25 changed files with 2213 additions and 4 deletions

View File

@@ -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),

View File

@@ -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';

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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
)
`);
}

View File

@@ -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';

View File

@@ -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()`,
},
});
}

View File

@@ -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);
});
});

View File

@@ -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),
);
}

View File

@@ -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);
}

View 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 030', () => {
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 1075% 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 310 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);
});
});

View 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 1075%)
* competition 15 state/NH-restricted pools beat national ones
* effort 10 LOI/short-form beat full federal
* runway 5 310 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 1075% 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;
/** 310 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,
};
}

View File

@@ -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 };
}

View File

@@ -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';

View 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.');
});
});

View 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(' ')}.`;
}

View File

@@ -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';

View File

@@ -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);
}