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

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