fix(ingestion): drain the Grants.gov backlog + source-filtered review queue

ingest-grants spent its 200-detail budget on hits.slice(0, 200) — the
same head of the search results every night; the backlog never drained.
Now new opportunities fill the budget first (serverListGrantSourceUrls
partition), remaining budget refreshes known ones; search cap raised to
2,000. Full eligible pool turns out to be 565 federal opportunities —
drained in two passes, 366 newly embedded.

Review queue gains a source badge column and All/Foundations/Federal
RFPs filter (?source=) — foundation easy-wins otherwise bury posted-RFP
matches, which score lower by design (no precedent, national pools) but
are the deadline-driven sends. Immediate proof: DOJ OVW FY2026 DV
program (closes 9/8) matched five NH domestic-violence orgs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Croissant Le Doux
2026-07-16 22:27:41 -04:00
parent a92b75b315
commit 4fa0bb1c32
5 changed files with 83 additions and 13 deletions

View File

@@ -11,8 +11,10 @@ import type { Route } from './+types/_index.js';
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
if (request.method === 'HEAD') return; if (request.method === 'HEAD') return;
const matches = await serverListPendingReviewMatches(db); const url = new URL(request.url);
return { matches }; const source = url.searchParams.get('source') ?? undefined;
const matches = await serverListPendingReviewMatches(db, { source });
return { matches, source: source ?? null };
} }
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
@@ -36,10 +38,30 @@ export async function action({ request }: Route.ActionArgs) {
export default function ReviewQueue({ loaderData }: Route.ComponentProps) { export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
const matches = loaderData?.matches ?? []; const matches = loaderData?.matches ?? [];
const activeSource = loaderData?.source ?? null;
return ( return (
<main className="container mx-auto p-6"> <main className="container mx-auto p-6">
<h1 className="mb-4 text-2xl font-semibold">Grant Match Review Queue</h1> <h1 className="mb-2 text-2xl font-semibold">Grant Match Review Queue</h1>
<nav className="mb-4 flex gap-3 text-sm" aria-label="Filter by source">
{[
[null, 'All'],
['irs_990pf', 'Foundations'],
['grants_gov', 'Federal RFPs'],
].map(([value, label]) => (
<a
key={label as string}
href={value == null ? '/' : `/?source=${value}`}
className={
activeSource === value
? 'font-semibold underline'
: 'text-blue-700 underline'
}
>
{label}
</a>
))}
</nav>
{matches.length === 0 ? ( {matches.length === 0 ? (
<p role="status" className="text-gray-600"> <p role="status" className="text-gray-600">
@@ -55,6 +77,9 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
<th scope="col" className="p-2"> <th scope="col" className="p-2">
Grant Grant
</th> </th>
<th scope="col" className="p-2">
Source
</th>
<th scope="col" className="p-2"> <th scope="col" className="p-2">
Score Score
</th> </th>
@@ -78,6 +103,11 @@ export default function ReviewQueue({ loaderData }: Route.ComponentProps) {
</Link> </Link>
</td> </td>
<td className="p-2">{match.grantTitle}</td> <td className="p-2">{match.grantTitle}</td>
<td className="p-2">
<span className="rounded bg-gray-200 px-2 py-0.5 text-xs">
{match.source === 'irs_990pf' ? 'foundation' : match.source}
</span>
</td>
<td className="p-2">{match.totalScore}</td> <td className="p-2">{match.totalScore}</td>
<td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td> <td className="p-2">{match.easyWin ? 'Yes' : 'No'}</td>
<td className="p-2"> <td className="p-2">

View File

@@ -16,6 +16,7 @@ import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
import type { schema } from '@novelpad/outreach-core'; import type { schema } from '@novelpad/outreach-core';
import { import {
serverInsertGrants, serverInsertGrants,
serverListGrantSourceUrls,
type NewGrantInput, type NewGrantInput,
} from '@novelpad/outreach-core/server'; } from '@novelpad/outreach-core/server';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -35,7 +36,7 @@ export type OutreachDb = NodePgDatabase<typeof schema>;
* endpoint per run. Search hits are cheap (one paginated request per ~100), * endpoint per run. Search hits are cheap (one paginated request per ~100),
* so this is generous headroom above the actual nightly posting volume. * so this is generous headroom above the actual nightly posting volume.
*/ */
const SEARCH_HIT_CAP = 1000; const SEARCH_HIT_CAP = 2_000;
/** /**
* Cap on how many opportunities get a `fetchOpportunity` detail call per * Cap on how many opportunities get a `fetchOpportunity` detail call per
* run. Detail fetches are one request per grant (plus a politeness delay), * run. Detail fetches are one request per grant (plus a politeness delay),
@@ -92,14 +93,22 @@ interface HitWithDetail {
* this run. * this run.
*/ */
async function fetchGrantDetails( async function fetchGrantDetails(
db: OutreachDb,
hits: ReadonlyArray<GrantsGovSearchHit>, hits: ReadonlyArray<GrantsGovSearchHit>,
): Promise<ReadonlyArray<HitWithDetail>> { ): Promise<ReadonlyArray<HitWithDetail>> {
const toFetch = hits.slice(0, DETAIL_FETCH_CAP); // Spend the per-run detail budget on NEW opportunities first — the old
if (hits.length > toFetch.length) { // hits.slice(0, cap) re-fetched the same head of the search results
console.warn( // every night and never drained the backlog. Known opportunities fill
`[ingest-grants] detail-fetch cap ${DETAIL_FETCH_CAP} reached: dropping ${hits.length - toFetch.length} of ${hits.length} opportunities this run`, // any remaining budget (refreshing close dates / lastVerifiedAt).
); const known = await serverListGrantSourceUrls(db, 'grants_gov');
} const isKnown = (hit: GrantsGovSearchHit) =>
known.has(`https://www.grants.gov/search-results-detail/${hit.id}`);
const fresh = hits.filter((h) => !isKnown(h));
const refresh = hits.filter(isKnown);
const toFetch = [...fresh, ...refresh].slice(0, DETAIL_FETCH_CAP);
console.log(
`[ingest-grants] detail budget ${DETAIL_FETCH_CAP}: ${Math.min(fresh.length, DETAIL_FETCH_CAP)} new, ${Math.max(0, Math.min(DETAIL_FETCH_CAP - fresh.length, refresh.length))} refresh, backlog remaining ${Math.max(0, fresh.length - DETAIL_FETCH_CAP)}`,
);
const details = await fetchOpportunityDetails(toFetch.map((hit) => hit.id)); const details = await fetchOpportunityDetails(toFetch.map((hit) => hit.id));
return toFetch.map((hit, i) => { return toFetch.map((hit, i) => {
@@ -147,7 +156,7 @@ async function runIngestGrants(): Promise<void> {
const { db } = getIngestGrantsDeps(); const { db } = getIngestGrantsDeps();
const hits = await searchGrantsGovStep(); const hits = await searchGrantsGovStep();
const pairs = await fetchGrantDetailsStep(hits); const pairs = await fetchGrantDetailsStep(db, hits);
const normalized = normalize(pairs); const normalized = normalize(pairs);
await upsertGrantsStep(db, normalized); await upsertGrantsStep(db, normalized);
} }

View File

@@ -1,3 +1,4 @@
export * from './list-open-grants.server.js'; export * from './list-open-grants.server.js';
export * from './list-grants-needing-embedding.server.js'; export * from './list-grants-needing-embedding.server.js';
export * from './list-eligible-grants-for-org.server.js'; export * from './list-eligible-grants-for-org.server.js';
export * from './list-grant-source-urls.server.js';

View File

@@ -0,0 +1,20 @@
import { eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js';
/**
* All source URLs already ingested for one source — lets ingestion spend
* its per-run detail-fetch budget on NEW opportunities first instead of
* re-fetching the same head of the search results every night.
*/
export async function serverListGrantSourceUrls(
db: NpOutreachDatabase | NpOutreachTransaction,
source: (typeof schema.grants.$inferSelect)['source'],
): Promise<Set<string>> {
const rows = await db
.select({ sourceUrl: schema.grants.sourceUrl })
.from(schema.grants)
.where(eq(schema.grants.source, source));
return new Set(rows.map((r) => r.sourceUrl));
}

View File

@@ -1,4 +1,4 @@
import { desc, eq } from 'drizzle-orm'; import { and, desc, eq } from 'drizzle-orm';
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js'; import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
import { schema } from '#~/db/db.js'; import { schema } from '#~/db/db.js';
@@ -14,6 +14,7 @@ export interface PendingReviewMatch {
orgName: string; orgName: string;
grantTitle: string; grantTitle: string;
funder: string; funder: string;
source: string;
totalScore: number; totalScore: number;
easyWin: boolean; easyWin: boolean;
isHero: boolean; isHero: boolean;
@@ -22,6 +23,7 @@ export interface PendingReviewMatch {
export async function serverListPendingReviewMatches( export async function serverListPendingReviewMatches(
db: NpOutreachDatabase | NpOutreachTransaction, db: NpOutreachDatabase | NpOutreachTransaction,
{ source }: { source?: string } = {},
): Promise<PendingReviewMatch[]> { ): Promise<PendingReviewMatch[]> {
return db return db
.select({ .select({
@@ -29,6 +31,7 @@ export async function serverListPendingReviewMatches(
orgName: schema.orgs.name, orgName: schema.orgs.name,
grantTitle: schema.grants.title, grantTitle: schema.grants.title,
funder: schema.grants.funder, funder: schema.grants.funder,
source: schema.grants.source,
totalScore: schema.matches.totalScore, totalScore: schema.matches.totalScore,
easyWin: schema.matches.easyWin, easyWin: schema.matches.easyWin,
isHero: schema.matches.isHero, isHero: schema.matches.isHero,
@@ -37,7 +40,14 @@ export async function serverListPendingReviewMatches(
.from(schema.matches) .from(schema.matches)
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id)) .innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
.innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id)) .innerJoin(schema.grants, eq(schema.matches.grantId, schema.grants.id))
.where(eq(schema.matches.reviewStatus, 'pending')) .where(
source == null
? eq(schema.matches.reviewStatus, 'pending')
: and(
eq(schema.matches.reviewStatus, 'pending'),
eq(schema.grants.source, source as never),
),
)
// Reviewers see the best candidates first: heroes, then easy wins, // Reviewers see the best candidates first: heroes, then easy wins,
// then raw score. Capped — nightly re-scoring generates thousands of // then raw score. Capped — nightly re-scoring generates thousands of
// pending pairs and the queue is worked top-down, not exhaustively. // pending pairs and the queue is worked top-down, not exhaustively.