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>
420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
import { nteeDescription } from '@novelpad/outreach-core';
|
||
import {
|
||
serverGetMatchDetail,
|
||
serverSetMatchReview,
|
||
} from '@novelpad/outreach-core/server';
|
||
import { Form, Link, redirect } from 'react-router';
|
||
|
||
import { db } from '#~/db.server.js';
|
||
|
||
import type { Route } from './+types/matches.$matchId.js';
|
||
|
||
export async function loader({ request, params }: Route.LoaderArgs) {
|
||
if (request.method === 'HEAD') return;
|
||
|
||
const detail = await serverGetMatchDetail(db, params.matchId);
|
||
if (detail == null) {
|
||
throw new Response('Match not found', { status: 404 });
|
||
}
|
||
return { detail };
|
||
}
|
||
|
||
export async function action({ request, params }: Route.ActionArgs) {
|
||
if (request.method === 'HEAD') return;
|
||
|
||
const formData = await request.formData();
|
||
const decision = formData.get('decision');
|
||
if (decision !== 'approved' && decision !== 'rejected') {
|
||
throw new Response('Invalid review submission', { status: 400 });
|
||
}
|
||
|
||
await serverSetMatchReview(db, {
|
||
matchId: params.matchId,
|
||
reviewStatus: decision,
|
||
rejectReason: decision === 'rejected' ? 'other' : undefined,
|
||
});
|
||
return redirect('/');
|
||
}
|
||
|
||
function dollars(n: number | null): string {
|
||
if (n == null) return '—';
|
||
return `$${n.toLocaleString('en-US')}`;
|
||
}
|
||
|
||
const SUBSCORE_MAX: Record<string, number> = {
|
||
missionFit: 30,
|
||
funderPrecedent: 25,
|
||
capacityFit: 15,
|
||
competition: 15,
|
||
effort: 10,
|
||
runway: 5,
|
||
};
|
||
|
||
const JUDGE_VERDICT_STYLE: Record<string, string> = {
|
||
strong_fit: 'bg-green-100 text-green-800',
|
||
plausible: 'bg-blue-100 text-blue-800',
|
||
weak: 'bg-amber-100 text-amber-800',
|
||
mismatch: 'bg-red-100 text-red-800',
|
||
};
|
||
|
||
export default function MatchDetail({ loaderData }: Route.ComponentProps) {
|
||
const { detail } = loaderData ?? {};
|
||
if (detail == null) return null;
|
||
const { match, org, grant, funderGivingHistory, funderApplicationInfo } =
|
||
detail;
|
||
const applyInfo = (funderApplicationInfo ?? null) as {
|
||
preselectedOnly?: boolean;
|
||
recipientName?: string;
|
||
formAndInfoAndMaterials?: string;
|
||
submissionDeadlines?: string;
|
||
restrictionsOnAwards?: string;
|
||
} | null;
|
||
const subscores = (match.subscores ?? {}) as Record<string, number>;
|
||
const orgPrograms = (org.programs ?? null) as Array<{
|
||
name: string;
|
||
description?: string | null;
|
||
populationServed?: string | null;
|
||
sourceUrl?: string | null;
|
||
}> | null;
|
||
const orgFunders = (org.knownFunders ?? null) as Array<{ name: string }> | null;
|
||
const orgStaff = (org.staff ?? null) as Array<{
|
||
name: string;
|
||
role?: string | null;
|
||
}> | null;
|
||
const rationale = (match.rationale ?? {}) as Record<string, unknown>;
|
||
const nhHistory = funderGivingHistory;
|
||
|
||
return (
|
||
<main className="container mx-auto max-w-4xl space-y-6 p-6">
|
||
<nav>
|
||
<Link to="/" className="text-blue-700 underline">
|
||
← Back to queue
|
||
</Link>
|
||
</nav>
|
||
|
||
<header className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold">
|
||
{org.name} ↔ {grant.funder}
|
||
</h1>
|
||
<p className="text-gray-600">
|
||
Score {match.totalScore}/100
|
||
{match.easyWin ? ' · Easy win' : ''}
|
||
{match.isHero ? ' · Hero' : ''} · Status: {match.reviewStatus}
|
||
</p>
|
||
{!match.fitViable && (
|
||
<p className="mt-1 text-sm font-medium text-red-700">
|
||
Hidden from the pending queue — mission fit below floor or
|
||
judged non-viable.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 gap-2">
|
||
<Form method="post">
|
||
<input type="hidden" name="decision" value="approved" />
|
||
<button
|
||
type="submit"
|
||
className="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||
>
|
||
Approve
|
||
</button>
|
||
</Form>
|
||
<Form method="post">
|
||
<input type="hidden" name="decision" value="rejected" />
|
||
<button
|
||
type="submit"
|
||
className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
|
||
>
|
||
Reject
|
||
</button>
|
||
</Form>
|
||
</div>
|
||
</header>
|
||
|
||
<section className="grid gap-6 md:grid-cols-2">
|
||
<div className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">Organization</h2>
|
||
<dl className="space-y-1 text-sm">
|
||
<div>
|
||
<dt className="inline font-medium">Location: </dt>
|
||
<dd className="inline">
|
||
{[org.city, org.state].filter(Boolean).join(', ')}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Focus (NTEE): </dt>
|
||
<dd className="inline">
|
||
{org.nteeCode ?? '—'}
|
||
{nteeDescription(org.nteeCode) != null &&
|
||
` — ${nteeDescription(org.nteeCode)}`}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Annual revenue: </dt>
|
||
<dd className="inline">{dollars(org.totalRevenue)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Mission (profile): </dt>
|
||
<dd className="inline">
|
||
{org.missionStatement ?? '—'}
|
||
{org.profileConfidence != null &&
|
||
org.profileConfidence <= 0.2 && (
|
||
<span className="text-amber-700">
|
||
{' '}
|
||
(auto-generated stub — verify on their website)
|
||
</span>
|
||
)}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
{orgPrograms != null && orgPrograms.length > 0 && (
|
||
<div className="mt-3 text-sm">
|
||
<h3 className="font-medium">Programs (researched)</h3>
|
||
<ul className="ml-4 list-disc">
|
||
{orgPrograms.map(
|
||
(p, i) => (
|
||
<li key={i}>
|
||
{p.name}
|
||
{p.populationServed != null && ` — serving ${p.populationServed}`}
|
||
{p.sourceUrl != null && (
|
||
<>
|
||
{' '}
|
||
<a className="text-blue-700 underline" href={p.sourceUrl} target="_blank" rel="noreferrer">
|
||
src
|
||
</a>
|
||
</>
|
||
)}
|
||
</li>
|
||
),
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{orgFunders != null && orgFunders.length > 0 && (
|
||
<p className="mt-2 text-sm">
|
||
<span className="font-medium">Known funders: </span>
|
||
{orgFunders.map((f) => f.name).join(', ')}
|
||
</p>
|
||
)}
|
||
{orgStaff != null && orgStaff.length > 0 && (
|
||
<p className="mt-2 text-sm">
|
||
<span className="font-medium">Staff: </span>
|
||
{orgStaff
|
||
.map((s) => (s.role != null ? `${s.name} (${s.role})` : s.name))
|
||
.join(', ')}
|
||
</p>
|
||
)}
|
||
<div className="mt-3 space-x-3 text-sm">
|
||
{org.ein != null && (
|
||
<a
|
||
className="text-blue-700 underline"
|
||
href={`https://projects.propublica.org/nonprofits/organizations/${org.ein.replace(/^0+/, '')}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
ProPublica filings ↗
|
||
</a>
|
||
)}
|
||
<a
|
||
className="text-blue-700 underline"
|
||
href={`https://www.google.com/search?q=${encodeURIComponent(`"${org.name}" ${org.city ?? ''} NH nonprofit`)}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
Search their site ↗
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">Grant</h2>
|
||
<dl className="space-y-1 text-sm">
|
||
<div>
|
||
<dt className="inline font-medium">Title: </dt>
|
||
<dd className="inline">{grant.title}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Award: </dt>
|
||
<dd className="inline">
|
||
{dollars(grant.awardFloor)} – {dollars(grant.awardCeiling)}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Closes: </dt>
|
||
<dd className="inline">
|
||
{grant.closeDate == null
|
||
? 'Rolling / no stated deadline'
|
||
: new Date(grant.closeDate).toLocaleDateString('en-US')}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-medium">Source: </dt>
|
||
<dd className="inline">{grant.source}</dd>
|
||
</div>
|
||
{grant.eligibilityEntityTypes != null &&
|
||
grant.eligibilityEntityTypes.length > 0 && (
|
||
<div>
|
||
<dt className="inline font-medium">Eligibility: </dt>
|
||
<dd className="inline">
|
||
{grant.eligibilityEntityTypes.join('; ')}
|
||
</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
<div className="mt-3 text-sm">
|
||
<a
|
||
className="text-blue-700 underline"
|
||
href={grant.sourceUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
{grant.source === 'irs_990pf'
|
||
? 'Funder on ProPublica ↗'
|
||
: 'Grant listing ↗'}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">Score breakdown</h2>
|
||
<table className="w-full max-w-md text-sm">
|
||
<tbody>
|
||
{Object.entries(SUBSCORE_MAX).map(([key, max]) => (
|
||
<tr key={key} className="border-b">
|
||
<td className="py-1 capitalize">
|
||
{key.replace(/([A-Z])/g, ' $1').toLowerCase()}
|
||
</td>
|
||
<td className="py-1 text-right tabular-nums">
|
||
{subscores[key] ?? 0} / {max}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{typeof rationale.similarity === 'number' && (
|
||
<p className="mt-2 text-sm text-gray-600">
|
||
Embedding similarity: {(rationale.similarity as number).toFixed(3)}
|
||
{Array.isArray(rationale.gateFailures) &&
|
||
(rationale.gateFailures as string[]).length > 0 &&
|
||
` · Ignored gate flags: ${(rationale.gateFailures as string[]).join(', ')}`}
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
{match.judgeVerdict != null && (
|
||
<section className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">
|
||
Mission-fit judge{' '}
|
||
<span
|
||
className={`ml-1 rounded px-2 py-0.5 text-sm font-medium ${JUDGE_VERDICT_STYLE[match.judgeVerdict] ?? ''}`}
|
||
>
|
||
{match.judgeVerdict.replace('_', ' ')}
|
||
</span>
|
||
</h2>
|
||
<p className="whitespace-pre-wrap text-sm">{match.judgeRationale}</p>
|
||
{match.judgedAt != null && (
|
||
<p className="mt-2 text-xs text-gray-500">
|
||
Judged {new Date(match.judgedAt).toLocaleDateString('en-US')} —
|
||
the verdict replaces the embedding mission-fit score above.
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{applyInfo != null && (
|
||
<section className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">
|
||
How to apply (funder's own 990-PF, Part XV)
|
||
</h2>
|
||
{applyInfo.preselectedOnly === true && (
|
||
<p className="mb-2 rounded bg-red-100 p-2 text-sm font-medium text-red-800">
|
||
This funder states it only contributes to preselected
|
||
organizations and does not accept unsolicited requests — do
|
||
not pitch.
|
||
</p>
|
||
)}
|
||
<dl className="space-y-1 text-sm">
|
||
{applyInfo.formAndInfoAndMaterials != null && (
|
||
<div>
|
||
<dt className="inline font-medium">Application form: </dt>
|
||
<dd className="inline">{applyInfo.formAndInfoAndMaterials}</dd>
|
||
</div>
|
||
)}
|
||
{applyInfo.submissionDeadlines != null && (
|
||
<div>
|
||
<dt className="inline font-medium">Deadlines: </dt>
|
||
<dd className="inline">{applyInfo.submissionDeadlines}</dd>
|
||
</div>
|
||
)}
|
||
{applyInfo.restrictionsOnAwards != null && (
|
||
<div>
|
||
<dt className="inline font-medium">Restrictions: </dt>
|
||
<dd className="inline">{applyInfo.restrictionsOnAwards}</dd>
|
||
</div>
|
||
)}
|
||
{applyInfo.recipientName != null && (
|
||
<div>
|
||
<dt className="inline font-medium">Applications to: </dt>
|
||
<dd className="inline">{applyInfo.recipientName}</dd>
|
||
</div>
|
||
)}
|
||
</dl>
|
||
</section>
|
||
)}
|
||
|
||
<section className="rounded border p-4">
|
||
<h2 className="mb-2 text-lg font-semibold">Grant synopsis</h2>
|
||
<p className="whitespace-pre-wrap text-sm">{grant.synopsis ?? '—'}</p>
|
||
</section>
|
||
|
||
{nhHistory.length > 0 && (
|
||
<section className="rounded border p-4">
|
||
<h2 className="mb-1 text-lg font-semibold">
|
||
{grant.source === 'irs_990pf'
|
||
? 'Funder giving history (from 990-PF filings)'
|
||
: 'Recent program awards to NH recipients (USASpending)'}
|
||
</h2>
|
||
<p className="mb-3 text-sm text-gray-600">
|
||
The concrete evidence behind the precedent score —{' '}
|
||
{grant.source === 'irs_990pf'
|
||
? 'who this funder actually paid, sorted in-state first.'
|
||
: 'who this federal program actually funded in NH recently, peers first. Only "peer" recipients (registered NH nonprofits our size) count toward the precedent score — universities, hospital systems, and companies do not.'}
|
||
</p>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-left">
|
||
<th className="py-1 pr-2">Recipient</th>
|
||
<th className="py-1 pr-2">City</th>
|
||
<th className="py-1 pr-2 text-right">Amount</th>
|
||
<th className="py-1 pr-2">Year</th>
|
||
<th className="py-1">Purpose</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{nhHistory.map((g, i) => (
|
||
<tr key={i} className="border-b align-top">
|
||
<td className="py-1 pr-2">
|
||
{g.recipientName}
|
||
{g.isPeer === true && (
|
||
<span className="ml-1 rounded bg-green-100 px-1 text-xs font-medium text-green-800">
|
||
peer
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-1 pr-2">{g.recipientCity ?? '—'}</td>
|
||
<td className="py-1 pr-2 text-right tabular-nums">
|
||
{dollars(g.amount)}
|
||
</td>
|
||
<td className="py-1 pr-2">{g.taxYear}</td>
|
||
<td className="py-1">{g.purpose ?? '—'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|