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

@@ -1,3 +1,4 @@
export * from './list-open-grants.server.js';
export * from './list-grants-needing-embedding.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 { schema } from '#~/db/db.js';
@@ -14,6 +14,7 @@ export interface PendingReviewMatch {
orgName: string;
grantTitle: string;
funder: string;
source: string;
totalScore: number;
easyWin: boolean;
isHero: boolean;
@@ -22,6 +23,7 @@ export interface PendingReviewMatch {
export async function serverListPendingReviewMatches(
db: NpOutreachDatabase | NpOutreachTransaction,
{ source }: { source?: string } = {},
): Promise<PendingReviewMatch[]> {
return db
.select({
@@ -29,6 +31,7 @@ export async function serverListPendingReviewMatches(
orgName: schema.orgs.name,
grantTitle: schema.grants.title,
funder: schema.grants.funder,
source: schema.grants.source,
totalScore: schema.matches.totalScore,
easyWin: schema.matches.easyWin,
isHero: schema.matches.isHero,
@@ -37,7 +40,14 @@ export async function serverListPendingReviewMatches(
.from(schema.matches)
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.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,
// then raw score. Capped — nightly re-scoring generates thousands of
// pending pairs and the queue is worked top-down, not exhaustively.