feat(scoring): v4 — peer precedent, mission-fit floor, LLM match judge
Fixes the federal mismatch class (boys' camp × NIH research center): - Peer precedent: federalPrecedent paginates USASpending (≤500 awards/ program) and name-matches every recipient against primary-ICP NH registry orgs (shared normalizeOrgNameForMatching, also used by the self-match gate). The 25-pt precedent tiers now key off program_state_peer_award_count — Dartmouth renewals and SBIR LLCs no longer grant precedent to community nonprofits. Raw count + peer- annotated award list stay as review evidence (peer badges, peers-first). - Mission-fit floor (12/30, grants_gov only): below it a match is stored with fit_viable=false and hidden from the pending queue, hero selection, and easy-win. Foundation-synthesized grants exempt (generic synopses). - Mission-fit judge live (judgeMatches, 06:15, 200/night best-first): JUDGE_MODEL reads the synopsis against the org profile with an explicit ignore-eligibility-breadth instruction; graded verdict with required citations; deterministic verdict→points map (27/18/8/0) sets missionFit, total, easy-win, and viability. Verdicts survive nightly re-scores via an upsert splice and re-enter the judge queue when the org profile is re-researched (org_profiles.updated_at). First sweep: 81/149 programs have NH history, only 6 have peer history; queue-head judging zeroes the research-mechanism garbage (mismatch) while surfacing genuine strong fits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ export interface MatchDetail {
|
||||
rationale: unknown;
|
||||
easyWin: boolean;
|
||||
isHero: boolean;
|
||||
fitViable: boolean;
|
||||
judgeVerdict: 'strong_fit' | 'plausible' | 'weak' | 'mismatch' | null;
|
||||
judgeRationale: string | null;
|
||||
judgedAt: Date | null;
|
||||
reviewStatus: 'pending' | 'approved' | 'rejected' | 'edited';
|
||||
};
|
||||
org: {
|
||||
@@ -56,6 +60,9 @@ export interface MatchDetail {
|
||||
amount: number | null;
|
||||
purpose: string | null;
|
||||
taxYear: number;
|
||||
/** Federal evidence rows only: recipient name-matched a primary-ICP
|
||||
* NH nonprofit (the peer-precedent basis). Null for 990-PF rows. */
|
||||
isPeer: boolean | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -71,6 +78,10 @@ export async function serverGetMatchDetail(
|
||||
rationale: schema.matches.rationale,
|
||||
easyWin: schema.matches.easyWin,
|
||||
isHero: schema.matches.isHero,
|
||||
fitViable: schema.matches.fitViable,
|
||||
judgeVerdict: schema.matches.judgeVerdict,
|
||||
judgeRationale: schema.matches.judgeRationale,
|
||||
judgedAt: schema.matches.judgedAt,
|
||||
reviewStatus: schema.matches.reviewStatus,
|
||||
orgId: schema.orgs.id,
|
||||
orgName: schema.orgs.name,
|
||||
@@ -123,19 +134,29 @@ export async function serverGetMatchDetail(
|
||||
if (row.grantFunderEin == null && Array.isArray(row.grantProgramAwards)) {
|
||||
// Federal grants: USASpending program awards fill the same evidence
|
||||
// table (recipient/amount/year) the 990-PF history uses.
|
||||
// Peers first (the actual precedent evidence), newest within each
|
||||
// group; capped so a 500-award program doesn't flood the page.
|
||||
funderGivingHistory = (
|
||||
row.grantProgramAwards as Array<{
|
||||
recipientName?: string;
|
||||
amount?: number | null;
|
||||
startDate?: string | null;
|
||||
isPeer?: boolean;
|
||||
}>
|
||||
).map((a) => ({
|
||||
recipientName: a.recipientName ?? 'Unknown recipient',
|
||||
recipientCity: null,
|
||||
amount: a.amount ?? null,
|
||||
purpose: null,
|
||||
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
|
||||
}));
|
||||
)
|
||||
.map((a) => ({
|
||||
recipientName: a.recipientName ?? 'Unknown recipient',
|
||||
recipientCity: null,
|
||||
amount: a.amount ?? null,
|
||||
purpose: null,
|
||||
taxYear: a.startDate != null ? Number(a.startDate.slice(0, 4)) : 0,
|
||||
isPeer: a.isPeer ?? null,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const peerRank = Number(b.isPeer === true) - Number(a.isPeer === true);
|
||||
return peerRank !== 0 ? peerRank : b.taxYear - a.taxYear;
|
||||
})
|
||||
.slice(0, 40);
|
||||
}
|
||||
if (row.grantFunderEin != null) {
|
||||
const funderRow = await db
|
||||
@@ -170,7 +191,7 @@ export async function serverGetMatchDetail(
|
||||
return aIn - bIn;
|
||||
})
|
||||
.slice(0, 40)
|
||||
.map(({ recipientState: _s, ...rest }) => rest);
|
||||
.map(({ recipientState: _s, ...rest }) => ({ ...rest, isPeer: null }));
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -181,6 +202,10 @@ export async function serverGetMatchDetail(
|
||||
rationale: row.rationale,
|
||||
easyWin: row.easyWin,
|
||||
isHero: row.isHero,
|
||||
fitViable: row.fitViable,
|
||||
judgeVerdict: row.judgeVerdict,
|
||||
judgeRationale: row.judgeRationale,
|
||||
judgedAt: row.judgedAt,
|
||||
reviewStatus: row.reviewStatus,
|
||||
},
|
||||
org: {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './list-pending-review-matches.server.js';
|
||||
export * from './get-match-detail.server.js';
|
||||
export * from './list-matches-needing-judgment.server.js';
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export interface MatchNeedingJudgment {
|
||||
matchId: string;
|
||||
orgName: string;
|
||||
orgCity: string | null;
|
||||
missionStatement: string | null;
|
||||
/** Researched programs jsonb (Stage 4 shape) — null/[] for stub profiles. */
|
||||
programs: unknown;
|
||||
serviceGeography: string | null;
|
||||
profileConfidence: number | null;
|
||||
grantTitle: string;
|
||||
grantFunder: string;
|
||||
grantSynopsis: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Federal pending matches awaiting an LLM mission-fit verdict, best-first
|
||||
* — the head of the review queue gets verified before a human ever reads
|
||||
* it. A match re-enters when its org profile has been re-researched since
|
||||
* it was judged (better profile → the verdict may flip either way).
|
||||
*
|
||||
* Federal-only (`grants_gov`) by design: foundation-synthesized grants
|
||||
* have generic synopses ("grants for NH nonprofits"), so there is no
|
||||
* grant-side text for a judge to read — precedent evidence carries those.
|
||||
* Queue-viable rows only: the mission-fit floor already buried the clear
|
||||
* garbage; judge tokens go to rows a human would otherwise see next.
|
||||
*/
|
||||
export async function serverListMatchesNeedingJudgment(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{ limit }: { limit: number },
|
||||
): Promise<MatchNeedingJudgment[]> {
|
||||
const rows = (await db.execute(sql`
|
||||
SELECT
|
||||
m.id AS match_id,
|
||||
o.name AS org_name,
|
||||
o.city AS org_city,
|
||||
p.mission_statement,
|
||||
p.programs,
|
||||
p.service_geography,
|
||||
p.confidence AS profile_confidence,
|
||||
g.title AS grant_title,
|
||||
g.funder AS grant_funder,
|
||||
g.synopsis AS grant_synopsis
|
||||
FROM matches m
|
||||
JOIN orgs o ON o.id = m.org_id
|
||||
JOIN grants g ON g.id = m.grant_id
|
||||
LEFT JOIN org_profiles p ON p.org_id = m.org_id
|
||||
WHERE m.review_status = 'pending'
|
||||
AND m.hard_gates_passed = true
|
||||
AND m.fit_viable = true
|
||||
AND g.source = 'grants_gov'
|
||||
AND g.status = 'open'
|
||||
AND (m.judged_at IS NULL OR m.judged_at < p.updated_at)
|
||||
ORDER BY m.easy_win DESC, m.total_score DESC
|
||||
LIMIT ${limit}
|
||||
`)) as unknown as { rows: Array<Record<string, unknown>> };
|
||||
|
||||
return rows.rows.map((r) => ({
|
||||
matchId: r.match_id as string,
|
||||
orgName: r.org_name as string,
|
||||
orgCity: (r.org_city as string | null) ?? null,
|
||||
missionStatement: (r.mission_statement as string | null) ?? null,
|
||||
programs: r.programs ?? null,
|
||||
serviceGeography: (r.service_geography as string | null) ?? null,
|
||||
profileConfidence: (r.profile_confidence as number | null) ?? null,
|
||||
grantTitle: r.grant_title as string,
|
||||
grantFunder: r.grant_funder as string,
|
||||
grantSynopsis: (r.grant_synopsis as string | null) ?? null,
|
||||
}));
|
||||
}
|
||||
@@ -44,12 +44,16 @@ export async function serverListPendingReviewMatches(
|
||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
|
||||
.where(
|
||||
source == null
|
||||
? eq(schema.matches.reviewStatus, status)
|
||||
: and(
|
||||
eq(schema.matches.reviewStatus, status),
|
||||
eq(schema.grants.source, source as never),
|
||||
),
|
||||
and(
|
||||
eq(schema.matches.reviewStatus, status),
|
||||
// The pending queue hides fit-non-viable rows (below the mission-
|
||||
// fit floor or judged weak/mismatch); a human's approve/reject
|
||||
// always displays regardless.
|
||||
...(status === 'pending' ? [eq(schema.matches.fitViable, true)] : []),
|
||||
...(source == null
|
||||
? []
|
||||
: [eq(schema.grants.source, source as never)]),
|
||||
),
|
||||
)
|
||||
// Reviewers see the best candidates first: heroes, then easy wins,
|
||||
// then raw score. Capped — nightly re-scoring generates thousands of
|
||||
|
||||
Reference in New Issue
Block a user