feat(profiler): Stage 4 grounded research profiler — cited profiles replace NTEE stubs
profileOrgs (nightly 05:45, 25/run, easy-win orgs best-first per the plan's coarse-match-gates-profiling rule): two-pass Gemini flash — Google Search-grounded research (groundingMetadata = citation universe) then JSON-schema extraction citing only from it. Overwrites the stub profile (mission/programs/geography/funders/staff/news/sources/ confidence); re-embeds mission fit from researched text unless identity unconfirmed or confidence <0.5 (then the NTEE embedding stays — unverified research must not steer scoring). Detail page renders researched programs w/ source links, funders, staff. Fixes: org_profiles.confidence is float4 — 0.2 stores as 0.20000000298 so 'confidence <= 0.2' excluded every stub (epsilon comparison); RR7 loader serialization turns unknown into never (hoisted casts). Live: 55 orgs profiled (41 research-grade, 2 low-confidence, 0 failed). Scoring effect verified both directions: Annie's Angels x NIH mammalian models fit 19->12; Seacoast Pathways x Foundation for Seacoast Health fit 25/30 — and the researched knownFunders independently names that funder. 161 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ export interface MatchDetail {
|
||||
registrationNumber: string | null;
|
||||
missionStatement: string | null;
|
||||
profileConfidence: number | null;
|
||||
programs: unknown;
|
||||
knownFunders: unknown;
|
||||
staff: unknown;
|
||||
serviceGeography: string | null;
|
||||
profileSources: unknown;
|
||||
};
|
||||
grant: {
|
||||
id: string;
|
||||
@@ -103,6 +108,11 @@ export async function serverGetMatchDetail(
|
||||
.select({
|
||||
missionStatement: schema.orgProfiles.missionStatement,
|
||||
confidence: schema.orgProfiles.confidence,
|
||||
programs: schema.orgProfiles.programs,
|
||||
knownFunders: schema.orgProfiles.knownFunders,
|
||||
staff: schema.orgProfiles.staff,
|
||||
serviceGeography: schema.orgProfiles.serviceGeography,
|
||||
sources: schema.orgProfiles.sources,
|
||||
})
|
||||
.from(schema.orgProfiles)
|
||||
.where(eq(schema.orgProfiles.orgId, row.orgId))
|
||||
@@ -184,6 +194,11 @@ export async function serverGetMatchDetail(
|
||||
registrationNumber: row.orgRegNo,
|
||||
missionStatement: profiles[0]?.missionStatement ?? null,
|
||||
profileConfidence: profiles[0]?.confidence ?? null,
|
||||
programs: profiles[0]?.programs ?? null,
|
||||
knownFunders: profiles[0]?.knownFunders ?? null,
|
||||
staff: profiles[0]?.staff ?? null,
|
||||
serviceGeography: profiles[0]?.serviceGeography ?? null,
|
||||
profileSources: profiles[0]?.sources ?? null,
|
||||
},
|
||||
grant: {
|
||||
id: row.grantId,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export interface ResearchedProfileInput {
|
||||
readonly orgId: string;
|
||||
readonly missionStatement: string | null;
|
||||
readonly programs: unknown;
|
||||
readonly serviceGeography: string | null;
|
||||
readonly recentNews: unknown;
|
||||
readonly knownFunders: unknown;
|
||||
readonly staff: unknown;
|
||||
readonly sources: unknown;
|
||||
readonly confidence: number;
|
||||
/** 1536-dim RETRIEVAL_QUERY embedding of the researched profile text. */
|
||||
readonly profileEmbedding: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a Stage 4 researched profile, OVERWRITING the row — this is the
|
||||
* upgrade path the v0 NTEE stub (confidence 0.2, never-overwriting) exists
|
||||
* to be replaced by. Re-running research refreshes in place.
|
||||
*/
|
||||
export async function serverApplyResearchedProfile(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
profile: ResearchedProfileInput,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.insert(schema.orgProfiles)
|
||||
.values({
|
||||
orgId: profile.orgId,
|
||||
missionStatement: profile.missionStatement,
|
||||
programs: profile.programs,
|
||||
serviceGeography: profile.serviceGeography,
|
||||
recentNews: profile.recentNews,
|
||||
knownFunders: profile.knownFunders,
|
||||
staff: profile.staff,
|
||||
sources: profile.sources,
|
||||
confidence: profile.confidence,
|
||||
profileEmbedding: profile.profileEmbedding,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.orgProfiles.orgId,
|
||||
set: {
|
||||
missionStatement: sql`excluded.mission_statement`,
|
||||
programs: sql`excluded.programs`,
|
||||
serviceGeography: sql`excluded.service_geography`,
|
||||
recentNews: sql`excluded.recent_news`,
|
||||
knownFunders: sql`excluded.known_funders`,
|
||||
staff: sql`excluded.staff`,
|
||||
sources: sql`excluded.sources`,
|
||||
confidence: sql`excluded.confidence`,
|
||||
profileEmbedding: sql`excluded.profile_embedding`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './insert-org.server.js';
|
||||
export * from './upsert-org-from-registry.server.js';
|
||||
export * from './enrich-org.server.js';
|
||||
export * from './ensure-org-profile-stub.server.js';
|
||||
export * from './apply-researched-profile.server.js';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './list-orgs-in-icp-band.server.js';
|
||||
export * from './list-orgs-needing-enrichment.server.js';
|
||||
export * from './list-match-candidate-orgs.server.js';
|
||||
export * from './list-orgs-needing-profile.server.js';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
|
||||
export interface OrgNeedingProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string | null;
|
||||
state: string;
|
||||
ein: string | null;
|
||||
nteeCode: string | null;
|
||||
bestMatchScore: number;
|
||||
}
|
||||
|
||||
/** Profiles below/at this confidence are NTEE-derived stubs, not research. */
|
||||
export const STUB_CONFIDENCE_CEILING = 0.2;
|
||||
|
||||
/**
|
||||
* The plan's cost-control rule made concrete: research only orgs that
|
||||
* already have at least one non-rejected easy-win match (a plausible
|
||||
* lead), best-scoring orgs first, and only when the current profile is a
|
||||
* stub (or missing). Re-research after review feedback comes later.
|
||||
*/
|
||||
export async function serverListOrgsNeedingProfile(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{ limit }: { limit: number },
|
||||
): Promise<OrgNeedingProfile[]> {
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
o.id, o.name, o.city, o.state, o.ein, o.ntee_code,
|
||||
max(m.total_score) AS best_match_score
|
||||
FROM orgs o
|
||||
JOIN matches m ON m.org_id = o.id
|
||||
AND m.review_status != 'rejected'
|
||||
AND m.easy_win = true
|
||||
LEFT JOIN org_profiles p ON p.org_id = o.id
|
||||
-- confidence is a float4: 0.2 stores as 0.20000000298, so a plain
|
||||
-- <= 0.2 comparison (float8) excludes every stub. Epsilon absorbs it.
|
||||
WHERE p.id IS NULL OR p.confidence IS NULL OR p.confidence <= ${STUB_CONFIDENCE_CEILING} + 0.001
|
||||
GROUP BY o.id
|
||||
ORDER BY max(m.total_score) DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
const { rows } = result as unknown as { rows: Record<string, unknown>[] };
|
||||
return rows.map((r) => ({
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
city: (r.city as string) ?? null,
|
||||
state: r.state as string,
|
||||
ein: (r.ein as string) ?? null,
|
||||
nteeCode: (r.ntee_code as string) ?? null,
|
||||
bestMatchScore: Number(r.best_match_score),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user