feat: scaffold outreach engine monorepo on the novelpad-desktop stack
Workspaces: config (copied), outreach-core (schema + actions/queries + hard gates), outreach-ai (Gemini client + embeddings copies, profiler and mission-fit-judge agent stubs), outreach-worker (DBOS executor with nightly ingest + hourly expiry workflows), outreach-review (RR7 review queue v0). Initial drizzle migration incl. pgvector extension. Stack contract: Yarn 4.5.0 + Turbo, Node 22.16, Drizzle 0.44.6 + pgvector, DBOS 4.17.6, @google/genai on Vertex, gemini-embedding-001 @1536, React Router v7. Files copied from novelpad-desktop carry provenance headers @ 62c56b87. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './insert-match.server.js';
|
||||
export * from './set-match-review.server.js';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
export type NewMatchInput = Omit<
|
||||
typeof schema.matches.$inferInsert,
|
||||
'id' | 'createdAt' | 'updatedAt' | 'reviewStatus'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Records a scored (org, grant) match. Always lands in `reviewStatus:
|
||||
* 'pending'` — the review decision is a separate step via
|
||||
* `serverSetMatchReview`.
|
||||
*/
|
||||
export async function serverInsertMatch(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
match: NewMatchInput,
|
||||
): Promise<string> {
|
||||
const [row] = await db
|
||||
.insert(schema.matches)
|
||||
.values({ ...match, reviewStatus: 'pending' })
|
||||
.returning({ id: schema.matches.id });
|
||||
|
||||
if (row == null) {
|
||||
throw new Error('serverInsertMatch: insert returned no row');
|
||||
}
|
||||
|
||||
return row.id;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
type ReviewStatus = (typeof schema.matches.$inferSelect)['reviewStatus'];
|
||||
type RejectReason = (typeof schema.matches.$inferSelect)['rejectReason'];
|
||||
|
||||
/**
|
||||
* Records a human reviewer's decision on a match. `rejectReason` is only
|
||||
* meaningful (and only ever set) when `reviewStatus` is `'rejected'` — any
|
||||
* other status clears it, so a previously-rejected match doesn't carry a
|
||||
* stale reason if it's later re-approved after editing.
|
||||
*/
|
||||
export async function serverSetMatchReview(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
{
|
||||
matchId,
|
||||
reviewStatus,
|
||||
rejectReason,
|
||||
}: {
|
||||
matchId: string;
|
||||
reviewStatus: ReviewStatus;
|
||||
rejectReason?: RejectReason;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schema.matches)
|
||||
.set({
|
||||
reviewStatus,
|
||||
rejectReason: reviewStatus === 'rejected' ? (rejectReason ?? null) : null,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where(eq(schema.matches.id, matchId));
|
||||
}
|
||||
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal file
153
packages/outreach-core/src/matches/hard-gates.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
evaluateHardGates,
|
||||
type HardGateGrantInput,
|
||||
type HardGateOrgInput,
|
||||
MIN_AWARD_CEILING,
|
||||
} from './hard-gates.js';
|
||||
|
||||
const NOW = new Date('2026-07-16T00:00:00Z');
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(NOW.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const org: HardGateOrgInput = {
|
||||
entityType: '501c3',
|
||||
state: 'NH',
|
||||
};
|
||||
|
||||
const grant: HardGateGrantInput = {
|
||||
eligibilityEntityTypes: ['501c3', 'municipality'],
|
||||
geographicScope: 'New Hampshire',
|
||||
closeDate: daysFromNow(30),
|
||||
awardCeiling: 50_000,
|
||||
applicationFormSupported: true,
|
||||
};
|
||||
|
||||
describe('evaluateHardGates', () => {
|
||||
it('passes a fully-eligible match with no failures', () => {
|
||||
const result = evaluateHardGates(org, grant, { now: NOW });
|
||||
expect(result).toEqual({ passed: true, failures: [] });
|
||||
});
|
||||
|
||||
it('treats an unrestricted entity-type list as eligible', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, eligibilityEntityTypes: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('ineligible_entity_type');
|
||||
});
|
||||
|
||||
it('fails when the org entity type is not in the eligibility list', () => {
|
||||
const result = evaluateHardGates(
|
||||
{ ...org, entityType: 'llc' },
|
||||
grant,
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('ineligible_entity_type');
|
||||
});
|
||||
|
||||
it('passes a national-scope grant regardless of org state', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, geographicScope: 'National' },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('geography_mismatch');
|
||||
});
|
||||
|
||||
it('fails when the geographic scope excludes the org state', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, geographicScope: 'California' },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('geography_mismatch');
|
||||
});
|
||||
|
||||
it('fails when the deadline is fewer than 21 days out', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: daysFromNow(20) },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('passes when the deadline is exactly 21 days out', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: daysFromNow(21) },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.failures).not.toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('fails when there is no close date at all', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, closeDate: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('deadline_too_soon');
|
||||
});
|
||||
|
||||
it('fails when the award ceiling is below the minimum', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, awardCeiling: MIN_AWARD_CEILING - 1 },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('award_ceiling_too_low');
|
||||
});
|
||||
|
||||
it('fails when the award ceiling is unknown', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, awardCeiling: null },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('award_ceiling_too_low');
|
||||
});
|
||||
|
||||
it('fails when the application form is not supported', () => {
|
||||
const result = evaluateHardGates(
|
||||
org,
|
||||
{ ...grant, applicationFormSupported: false },
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toContain('application_form_unsupported');
|
||||
});
|
||||
|
||||
it('accumulates every failing gate, not just the first', () => {
|
||||
const result = evaluateHardGates(
|
||||
{ entityType: 'llc', state: 'NH' },
|
||||
{
|
||||
eligibilityEntityTypes: ['501c3'],
|
||||
geographicScope: 'California',
|
||||
closeDate: null,
|
||||
awardCeiling: null,
|
||||
applicationFormSupported: false,
|
||||
},
|
||||
{ now: NOW },
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.failures).toEqual([
|
||||
'ineligible_entity_type',
|
||||
'geography_mismatch',
|
||||
'deadline_too_soon',
|
||||
'award_ceiling_too_low',
|
||||
'application_form_unsupported',
|
||||
]);
|
||||
});
|
||||
});
|
||||
173
packages/outreach-core/src/matches/hard-gates.ts
Normal file
173
packages/outreach-core/src/matches/hard-gates.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Deterministic SQL-equivalent hard gates for an (org, grant) match.
|
||||
*
|
||||
* These are cheap, unambiguous pass/fail checks evaluated BEFORE the
|
||||
* weighted LLM subscoring pass — a match that fails any hard gate is never
|
||||
* worth spending an LLM call on. Kept as a pure function over plain data
|
||||
* (no DB dependency) so it can be unit tested directly and reused from a
|
||||
* SQL-backed batch job, a single-match rescoring step, or a preview in the
|
||||
* review UI without threading a database handle through it.
|
||||
*/
|
||||
|
||||
export interface HardGateOrgInput {
|
||||
/** e.g. '501c3', 'municipality', 'school_district' */
|
||||
readonly entityType: string;
|
||||
/** Two-letter state code, e.g. 'NH'. */
|
||||
readonly state: string;
|
||||
}
|
||||
|
||||
export interface HardGateGrantInput {
|
||||
/** Entity types the funder will accept; null/empty means unrestricted. */
|
||||
readonly eligibilityEntityTypes: readonly string[] | null;
|
||||
/** Free-text geographic scope, e.g. 'NH', 'New England', 'National'. */
|
||||
readonly geographicScope: string | null;
|
||||
readonly closeDate: Date | null;
|
||||
readonly awardCeiling: number | null;
|
||||
readonly applicationFormSupported: boolean;
|
||||
}
|
||||
|
||||
export type HardGateFailureReason =
|
||||
| 'ineligible_entity_type'
|
||||
| 'geography_mismatch'
|
||||
| 'deadline_too_soon'
|
||||
| 'award_ceiling_too_low'
|
||||
| 'application_form_unsupported';
|
||||
|
||||
export interface HardGateResult {
|
||||
readonly passed: boolean;
|
||||
readonly failures: readonly HardGateFailureReason[];
|
||||
}
|
||||
|
||||
/** A match's runway must clear this many days before the close date. */
|
||||
export const MIN_DAYS_TO_DEADLINE = 21;
|
||||
|
||||
/** Grants below this ceiling aren't worth the outreach effort. */
|
||||
export const MIN_AWARD_CEILING = 10_000;
|
||||
|
||||
const NATIONAL_SCOPE_KEYWORDS = [
|
||||
'national',
|
||||
'nationwide',
|
||||
'united states',
|
||||
'usa',
|
||||
];
|
||||
|
||||
/** Full state names, for scopes that spell the state out ('New Hampshire'). */
|
||||
const STATE_NAMES: Record<string, string> = {
|
||||
AL: 'alabama',
|
||||
AK: 'alaska',
|
||||
AZ: 'arizona',
|
||||
AR: 'arkansas',
|
||||
CA: 'california',
|
||||
CO: 'colorado',
|
||||
CT: 'connecticut',
|
||||
DE: 'delaware',
|
||||
FL: 'florida',
|
||||
GA: 'georgia',
|
||||
HI: 'hawaii',
|
||||
ID: 'idaho',
|
||||
IL: 'illinois',
|
||||
IN: 'indiana',
|
||||
IA: 'iowa',
|
||||
KS: 'kansas',
|
||||
KY: 'kentucky',
|
||||
LA: 'louisiana',
|
||||
ME: 'maine',
|
||||
MD: 'maryland',
|
||||
MA: 'massachusetts',
|
||||
MI: 'michigan',
|
||||
MN: 'minnesota',
|
||||
MS: 'mississippi',
|
||||
MO: 'missouri',
|
||||
MT: 'montana',
|
||||
NE: 'nebraska',
|
||||
NV: 'nevada',
|
||||
NH: 'new hampshire',
|
||||
NJ: 'new jersey',
|
||||
NM: 'new mexico',
|
||||
NY: 'new york',
|
||||
NC: 'north carolina',
|
||||
ND: 'north dakota',
|
||||
OH: 'ohio',
|
||||
OK: 'oklahoma',
|
||||
OR: 'oregon',
|
||||
PA: 'pennsylvania',
|
||||
RI: 'rhode island',
|
||||
SC: 'south carolina',
|
||||
SD: 'south dakota',
|
||||
TN: 'tennessee',
|
||||
TX: 'texas',
|
||||
UT: 'utah',
|
||||
VT: 'vermont',
|
||||
VA: 'virginia',
|
||||
WA: 'washington',
|
||||
WV: 'west virginia',
|
||||
WI: 'wisconsin',
|
||||
WY: 'wyoming',
|
||||
DC: 'district of columbia',
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs every hard gate against an (org, grant) pair and returns which, if
|
||||
* any, failed. `now` is injectable for deterministic testing.
|
||||
*/
|
||||
export function evaluateHardGates(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
{ now = new Date() }: { now?: Date } = {},
|
||||
): HardGateResult {
|
||||
const failures: HardGateFailureReason[] = [];
|
||||
|
||||
if (!isEntityEligible(org, grant)) failures.push('ineligible_entity_type');
|
||||
if (!isGeographyEligible(org, grant)) failures.push('geography_mismatch');
|
||||
if (!hasSufficientRunway(grant, now)) failures.push('deadline_too_soon');
|
||||
if (!meetsAwardCeiling(grant)) failures.push('award_ceiling_too_low');
|
||||
if (!grant.applicationFormSupported) {
|
||||
failures.push('application_form_unsupported');
|
||||
}
|
||||
|
||||
return { passed: failures.length === 0, failures };
|
||||
}
|
||||
|
||||
function isEntityEligible(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
): boolean {
|
||||
if (
|
||||
grant.eligibilityEntityTypes == null ||
|
||||
grant.eligibilityEntityTypes.length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return grant.eligibilityEntityTypes.some(
|
||||
(entityType) => entityType.toLowerCase() === org.entityType.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function isGeographyEligible(
|
||||
org: HardGateOrgInput,
|
||||
grant: HardGateGrantInput,
|
||||
): boolean {
|
||||
const scope = grant.geographicScope?.trim().toLowerCase();
|
||||
if (!scope) return true;
|
||||
if (NATIONAL_SCOPE_KEYWORDS.some((keyword) => scope.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
// Match the two-letter code only on word boundaries ('NH', 'NH-only') so
|
||||
// it can't fire on letters embedded inside another word, and also accept
|
||||
// the spelled-out state name ('New Hampshire').
|
||||
const code = org.state.trim().toLowerCase();
|
||||
if (new RegExp(`\\b${code}\\b`, 'i').test(scope)) return true;
|
||||
const fullName = STATE_NAMES[org.state.trim().toUpperCase()];
|
||||
return fullName != null && scope.includes(fullName);
|
||||
}
|
||||
|
||||
function hasSufficientRunway(grant: HardGateGrantInput, now: Date): boolean {
|
||||
if (grant.closeDate == null) return false;
|
||||
const msPerDay = 24 * 60 * 60 * 1000;
|
||||
const daysRemaining = (grant.closeDate.getTime() - now.getTime()) / msPerDay;
|
||||
return daysRemaining >= MIN_DAYS_TO_DEADLINE;
|
||||
}
|
||||
|
||||
function meetsAwardCeiling(grant: HardGateGrantInput): boolean {
|
||||
return grant.awardCeiling != null && grant.awardCeiling >= MIN_AWARD_CEILING;
|
||||
}
|
||||
6
packages/outreach-core/src/matches/index.server.ts
Normal file
6
packages/outreach-core/src/matches/index.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// `hard-gates.js` is client-safe (pure, no DB) and is exported from the
|
||||
// package's root `index.js` instead — not re-exported here to avoid an
|
||||
// ambiguous `export *` collision in `index.server.ts` (which re-exports
|
||||
// both `./index.js` and this file).
|
||||
export * from './actions/index.server.js';
|
||||
export * from './queries/index.server.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './list-pending-review-matches.server.js';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import type { NpOutreachDatabase, NpOutreachTransaction } from '#~/db/db.js';
|
||||
import { schema } from '#~/db/db.js';
|
||||
|
||||
/**
|
||||
* Pending matches joined to their org and grant — the shape the review
|
||||
* queue renders. One row per pending (org, grant) match, most useful
|
||||
* columns only; the full match row (subscores, rationale) can be fetched
|
||||
* by id when a detail view lands.
|
||||
*/
|
||||
export interface PendingReviewMatch {
|
||||
id: string;
|
||||
orgName: string;
|
||||
grantTitle: string;
|
||||
funder: string;
|
||||
totalScore: number;
|
||||
easyWin: boolean;
|
||||
isHero: boolean;
|
||||
closeDate: Date | null;
|
||||
}
|
||||
|
||||
export async function serverListPendingReviewMatches(
|
||||
db: NpOutreachDatabase | NpOutreachTransaction,
|
||||
): Promise<PendingReviewMatch[]> {
|
||||
return db
|
||||
.select({
|
||||
id: schema.matches.id,
|
||||
orgName: schema.orgs.name,
|
||||
grantTitle: schema.grants.title,
|
||||
funder: schema.grants.funder,
|
||||
totalScore: schema.matches.totalScore,
|
||||
easyWin: schema.matches.easyWin,
|
||||
isHero: schema.matches.isHero,
|
||||
closeDate: schema.grants.closeDate,
|
||||
})
|
||||
.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'));
|
||||
}
|
||||
Reference in New Issue
Block a user