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:
@@ -16,6 +16,7 @@ import { DBOS, SchedulerMode } from '@dbos-inc/dbos-sdk';
|
||||
import type { schema } from '@novelpad/outreach-core';
|
||||
import {
|
||||
serverInsertGrants,
|
||||
serverListGrantSourceUrls,
|
||||
type NewGrantInput,
|
||||
} from '@novelpad/outreach-core/server';
|
||||
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),
|
||||
* 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
|
||||
* run. Detail fetches are one request per grant (plus a politeness delay),
|
||||
@@ -92,14 +93,22 @@ interface HitWithDetail {
|
||||
* this run.
|
||||
*/
|
||||
async function fetchGrantDetails(
|
||||
db: OutreachDb,
|
||||
hits: ReadonlyArray<GrantsGovSearchHit>,
|
||||
): Promise<ReadonlyArray<HitWithDetail>> {
|
||||
const toFetch = hits.slice(0, DETAIL_FETCH_CAP);
|
||||
if (hits.length > toFetch.length) {
|
||||
console.warn(
|
||||
`[ingest-grants] detail-fetch cap ${DETAIL_FETCH_CAP} reached: dropping ${hits.length - toFetch.length} of ${hits.length} opportunities this run`,
|
||||
);
|
||||
}
|
||||
// Spend the per-run detail budget on NEW opportunities first — the old
|
||||
// hits.slice(0, cap) re-fetched the same head of the search results
|
||||
// every night and never drained the backlog. Known opportunities fill
|
||||
// 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));
|
||||
return toFetch.map((hit, i) => {
|
||||
@@ -147,7 +156,7 @@ async function runIngestGrants(): Promise<void> {
|
||||
const { db } = getIngestGrantsDeps();
|
||||
|
||||
const hits = await searchGrantsGovStep();
|
||||
const pairs = await fetchGrantDetailsStep(hits);
|
||||
const pairs = await fetchGrantDetailsStep(db, hits);
|
||||
const normalized = normalize(pairs);
|
||||
await upsertGrantsStep(db, normalized);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user