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:
@@ -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 './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()`,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user