feat(scoring): federal funder precedent via USASpending (ALN-level)
Precedent was always a valid criterion for federal grants — the index just didn't cover them. Now: ingest captures ALN/CFDA numbers from fetchOpportunity (grants.alns); nightly federalPrecedent workflow (04:45) queries USASpending award search per distinct program (free official API, 3-year NH lookback) and stamps program_state_award_count + sample recipients onto open grants (multi-ALN keeps highest). Match retrieval feeds the same funderStateGrantCount input and 25-point tiers foundations use; detail page shows the recipients-evidence table with an incumbent-renewal caution. Also: detail refresh now rotates oldest-verified-first (serverMapGrantVerification) — the Set-based partition re-fetched the same 200 every pass, leaving 365/565 grants ALN-less. Live: 564/564 grants ALN-tagged, 156 programs swept, 85 with NH history, 468 grants carrying precedent, first federal easy-wins (66pts, 25/25 precedent, Aug-24 deadline). 158 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "grants" ADD COLUMN "alns" text[];--> statement-breakpoint
|
||||
ALTER TABLE "grants" ADD COLUMN "program_state_award_count" integer;--> statement-breakpoint
|
||||
ALTER TABLE "grants" ADD COLUMN "program_state_awards" jsonb;
|
||||
1478
packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json
Normal file
1478
packages/outreach-core/drizzle/server/meta/1784257140_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,13 @@
|
||||
"when": 1784236406321,
|
||||
"tag": "1784236406_funder-precedent-index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1784257140142,
|
||||
"tag": "1784257140_federal-precedent",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -143,6 +143,16 @@ export const grants = pgTable(
|
||||
// Links 990-PF-synthesized grants to their foundation for the
|
||||
// precedent subscore; null for public-RFP sources.
|
||||
funderEin: text('funder_ein'),
|
||||
// Federal Assistance Listing Numbers (CFDA), e.g. ['93.243'] — the
|
||||
// join key into USASpending award history for federal precedent.
|
||||
alns: text('alns').array(),
|
||||
// Denormalized federal precedent: recent awards this grant's
|
||||
// program(s) made to recipients in the target state (USASpending),
|
||||
// refreshed by the federalPrecedent workflow. Null for non-federal
|
||||
// sources and never touched by ingest upserts.
|
||||
programStateAwardCount: integer('program_state_award_count'),
|
||||
// Sample recipients backing the count — review-page evidence.
|
||||
programStateAwards: jsonb('program_state_awards'),
|
||||
status: grantStatusEnum('status').notNull().default('open'),
|
||||
synopsisEmbedding: vector('synopsis_embedding', { dimensions: 1536 }),
|
||||
lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }),
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './expire-closed-grants.server.js';
|
||||
export * from './insert-grants.server.js';
|
||||
export * from './set-grant-embeddings.server.js';
|
||||
export * from './close-grants-for-funders.server.js';
|
||||
export * from './set-federal-precedent.server.js';
|
||||
|
||||
@@ -47,6 +47,8 @@ export async function serverInsertGrants(
|
||||
applicationEffortEstimate: sql`excluded.application_effort_estimate`,
|
||||
applicationFormSupported: sql`excluded.application_form_supported`,
|
||||
source: sql`excluded.source`,
|
||||
alns: sql`excluded.alns`,
|
||||
funderEin: sql`excluded.funder_ein`,
|
||||
status: sql`excluded.status`,
|
||||
lastVerifiedAt: sql`excluded.last_verified_at`,
|
||||
updatedAt: sql`now()`,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export interface AlnPrecedent {
|
||||
readonly aln: string;
|
||||
readonly awardCount: number;
|
||||
readonly samples: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies per-program USASpending precedent onto every open federal grant
|
||||
* carrying that ALN. Grants with multiple ALNs keep the HIGHEST count
|
||||
* seen (a grant reachable through any strongly-NH program inherits that
|
||||
* program's precedent), and samples follow whichever count won.
|
||||
*/
|
||||
export async function serverSetFederalPrecedent(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
precedent: AlnPrecedent,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.grants)
|
||||
.set({
|
||||
programStateAwardCount: sql`GREATEST(COALESCE(${schema.grants.programStateAwardCount}, 0), ${precedent.awardCount})`,
|
||||
programStateAwards: sql`CASE
|
||||
WHEN COALESCE(${schema.grants.programStateAwardCount}, 0) <= ${precedent.awardCount}
|
||||
THEN ${JSON.stringify(precedent.samples)}::jsonb
|
||||
ELSE ${schema.grants.programStateAwards}
|
||||
END`,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.grants.source, 'grants_gov'),
|
||||
eq(schema.grants.status, 'open'),
|
||||
sql`${precedent.aln} = ANY(${schema.grants.alns})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Zeroes precedent before a refresh pass so removed programs don't keep stale counts. */
|
||||
export async function serverResetFederalPrecedent(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.grants)
|
||||
.set({ programStateAwardCount: null, programStateAwards: null })
|
||||
.where(eq(schema.grants.source, 'grants_gov'));
|
||||
}
|
||||
@@ -2,3 +2,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';
|
||||
export * from './list-federal-alns.server.js';
|
||||
|
||||
@@ -62,12 +62,15 @@ export async function serverListEligibleGrantsForOrg(
|
||||
applicationFormSupported: schema.grants.applicationFormSupported,
|
||||
funderEin: schema.grants.funderEin,
|
||||
similarity: sql<number>`1 - (${schema.grants.synopsisEmbedding} <=> ${vector}::vector)`,
|
||||
funderStateGrantCount: sql<number | null>`(
|
||||
SELECT count(*)::int FROM funder_grants fg
|
||||
JOIN funders f ON fg.funder_id = f.id
|
||||
WHERE f.ein = ${schema.grants.funderEin}
|
||||
AND fg.recipient_state = ${filters.orgState}
|
||||
)`,
|
||||
funderStateGrantCount: sql<number | null>`CASE
|
||||
WHEN ${schema.grants.funderEin} IS NOT NULL THEN (
|
||||
SELECT count(*)::int FROM funder_grants fg
|
||||
JOIN funders f ON fg.funder_id = f.id
|
||||
WHERE f.ein = ${schema.grants.funderEin}
|
||||
AND fg.recipient_state = ${filters.orgState}
|
||||
)
|
||||
ELSE ${schema.grants.programStateAwardCount}
|
||||
END`,
|
||||
})
|
||||
.from(schema.grants)
|
||||
.where(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { and, eq, isNotNull, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* Distinct ALN (CFDA) numbers across open federal grants — the work list
|
||||
* for the federalPrecedent workflow. A few hundred programs cover the
|
||||
* whole corpus, so precedent is fetched per program, not per grant.
|
||||
*/
|
||||
export async function serverListOpenFederalAlns(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ aln: sql<string>`DISTINCT unnest(${schema.grants.alns})` })
|
||||
.from(schema.grants)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.grants.source, 'grants_gov'),
|
||||
eq(schema.grants.status, 'open'),
|
||||
isNotNull(schema.grants.alns),
|
||||
),
|
||||
);
|
||||
return rows.map((r) => r.aln).sort();
|
||||
}
|
||||
@@ -4,17 +4,30 @@ 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.
|
||||
* Source URL → lastVerifiedAt for everything already ingested from one
|
||||
* source. Ingestion spends its per-run detail budget on NEW opportunities
|
||||
* first, then refreshes KNOWN ones oldest-verified-first — a plain Set
|
||||
* caused the refresh half to re-fetch the same head of the search results
|
||||
* every pass.
|
||||
*/
|
||||
export async function serverMapGrantVerification(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
source: (typeof schema.grants.$inferSelect)['source'],
|
||||
): Promise<Map<string, Date | null>> {
|
||||
const rows = await db
|
||||
.select({
|
||||
sourceUrl: schema.grants.sourceUrl,
|
||||
lastVerifiedAt: schema.grants.lastVerifiedAt,
|
||||
})
|
||||
.from(schema.grants)
|
||||
.where(eq(schema.grants.source, source));
|
||||
return new Map(rows.map((r) => [r.sourceUrl, r.lastVerifiedAt]));
|
||||
}
|
||||
|
||||
/** @deprecated superseded by serverMapGrantVerification; kept for callers needing only membership. */
|
||||
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));
|
||||
return new Set((await serverMapGrantVerification(db, source)).keys());
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ export async function serverGetMatchDetail(
|
||||
grantEligibility: schema.grants.eligibilityEntityTypes,
|
||||
grantGeo: schema.grants.geographicScope,
|
||||
grantEffort: schema.grants.applicationEffortEstimate,
|
||||
grantProgramAwards: schema.grants.programStateAwards,
|
||||
})
|
||||
.from(schema.matches)
|
||||
.innerJoin(schema.orgs, eq(schema.matches.orgId, schema.orgs.id))
|
||||
@@ -109,6 +110,23 @@ export async function serverGetMatchDetail(
|
||||
|
||||
let funderGivingHistory: MatchDetail['funderGivingHistory'] = [];
|
||||
let funderApplicationInfo: unknown = null;
|
||||
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.
|
||||
funderGivingHistory = (
|
||||
row.grantProgramAwards as Array<{
|
||||
recipientName?: string;
|
||||
amount?: number | null;
|
||||
startDate?: string | null;
|
||||
}>
|
||||
).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,
|
||||
}));
|
||||
}
|
||||
if (row.grantFunderEin != null) {
|
||||
const funderRow = await db
|
||||
.select({ applicationInfo: schema.funders.applicationInfo })
|
||||
|
||||
Reference in New Issue
Block a user