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:
Croissant Le Doux
2026-07-16 11:08:24 -04:00
commit 14200edb60
80 changed files with 11637 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import { MissionFitVerdictSchema, type MissionFitVerdict } from './schema.js';
import type { OrgProfile } from '../org-profiler/schema.js';
export interface RunMissionFitJudgeInput {
/** Extracted profile of the candidate org (from the Org Profiler). */
orgProfile: OrgProfile;
/** Grant program name / title, as scored against by the deterministic SQL gates. */
grantProgramName: string;
/** Funding priorities / eligible-use language pulled from the grant's own source text. */
grantPriorities: string[];
}
/**
* Build the judge prompt for one (org, grant) match candidate. Kept separate
* from `runMissionFitJudge` so it's independently unit-testable once wired up.
*/
export function buildMissionFitJudgePrompt(input: RunMissionFitJudgeInput): string {
return [
'You are the final mission-fit judge for a candidate (org, grant) match',
'that has already passed deterministic SQL hard gates (eligibility,',
'geography, award range). Decide whether the org\'s actual programs',
'plausibly fit the grant\'s funding priorities. You MUST cite one',
'concrete org program and one concrete grant priority your verdict is',
'grounded in — a verdict without both citations is invalid. A wrong',
'"fit: true" here can put a real NH nonprofit in front of a funder that',
'will never fund them, so when the fit is unclear, prefer `fit: false`.',
'',
`Grant program: ${input.grantProgramName}`,
`Grant priorities: ${input.grantPriorities.join('; ')}`,
`Org legal name: ${input.orgProfile.legalName.value}`,
`Org mission: ${input.orgProfile.mission.value}`,
`Org program areas: ${input.orgProfile.programAreas.value.join('; ')}`,
].join('\n');
}
/**
* NOT IMPLEMENTED — this judge is the veto gate before a match can reach a
* human reviewer (and, downstream, a real prospect via Apollo), so it should
* not go live against real matches until the deterministic hard-gate scoring
* this package doesn't own is wired in ahead of it. Intended production call
* shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output) — note `JUDGE_MODEL`,
* not `BULK_MODEL`: a wrong verdict here reaches a prospect:
*
* import { getAi } from '../../gemini.js';
* import { JUDGE_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: JUDGE_MODEL,
* contents: buildMissionFitJudgePrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: MISSION_FIT_VERDICT_RESPONSE_SCHEMA — a Type/Schema
* // literal from '@google/genai' hand-mirroring MissionFitVerdictSchema.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return MissionFitVerdictSchema.parse(raw);
*/
export async function runMissionFitJudge(_input: RunMissionFitJudgeInput): Promise<MissionFitVerdict> {
throw new Error('not implemented');
}

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
/**
* Mission-fit judge verdict for one (org, grant) match candidate. This is
* the last human-facing gate before a match is surfaced for review — the
* judge must ground its verdict in something concrete from each side rather
* than a vibe, so `citedOrgProgram` / `citedGrantPriority` are required, not
* optional summary fields.
*/
export const MissionFitVerdictSchema = z.object({
/** Whether the org's mission plausibly fits the grant's funding priorities. */
fit: z.boolean(),
/** The specific org program/activity the verdict is grounded in (from the org profile). */
citedOrgProgram: z.string().min(1),
/** The specific funding priority/eligibility line the verdict is grounded in (from the grant). */
citedGrantPriority: z.string().min(1),
/** Short human-readable justification tying the two citations together. */
reasoning: z.string().min(1),
});
export type MissionFitVerdict = z.infer<typeof MissionFitVerdictSchema>;

View File

@@ -0,0 +1,52 @@
import { OrgProfileSchema, type OrgProfile } from './schema.js';
export interface RunOrgProfilerInput {
/** NH nonprofit legal or DBA name, as known so far (e.g. from a 990-PF index). */
orgName: string;
/** Candidate source URLs to ground extraction in — org website, 990-PF PDF, GuideStar/Charity Navigator page. */
sourceUrls: string[];
}
/**
* Build the producer prompt for the Org Profiler. Kept separate from
* `runOrgProfiler` so it's independently unit-testable once wired up.
*/
export function buildOrgProfilerPrompt(input: RunOrgProfilerInput): string {
return [
'You are extracting a structured profile for an NH nonprofit organization',
'from the source documents below. Every field must cite the exact source',
'URL it was read from and a 0-1 confidence. Do not fabricate a value —',
'omit or null a field you cannot ground in a source.',
'',
`Organization (working name): ${input.orgName}`,
`Sources: ${input.sourceUrls.join(', ')}`,
].join('\n');
}
/**
* NOT IMPLEMENTED — the profiler needs a source-fetching harness (web fetch /
* PDF-parse for 990-PFs, per the ingestion pipeline this package doesn't own)
* wired in before it can safely call Vertex with real source text. Intended
* production call shape, mirroring novelpad-desktop's
* packages/ai/src/agents/grant/section-drafter/run.ts (invokeVertex +
* responseSchema-constrained structured JSON output):
*
* import { getAi } from '../../gemini.js';
* import { BULK_MODEL } from '../../models.js';
*
* const result = await getAi().models.generateContent({
* model: BULK_MODEL,
* contents: buildOrgProfilerPrompt(input),
* config: {
* responseMimeType: 'application/json',
* // responseSchema: ORG_PROFILE_RESPONSE_SCHEMA — a Type/Schema literal
* // from '@google/genai' hand-mirroring OrgProfileSchema below.
* temperature: 0.1,
* },
* });
* const raw = JSON.parse(result.text ?? '{}');
* return OrgProfileSchema.parse(raw);
*/
export async function runOrgProfiler(_input: RunOrgProfilerInput): Promise<OrgProfile> {
throw new Error('not implemented');
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { OrgProfileSchema } from './schema.js';
const validProfile = {
legalName: { value: 'NH Literacy Alliance', sourceUrl: 'https://nhliteracy.org', confidence: 0.95 },
ein: { value: '02-1234567', sourceUrl: 'https://apps.irs.gov/pfft', confidence: 0.9 },
mission: { value: 'Improve literacy outcomes for NH children.', sourceUrl: 'https://nhliteracy.org/about', confidence: 0.85 },
programAreas: {
value: ['youth literacy tutoring', 'family reading nights'],
sourceUrl: 'https://nhliteracy.org/programs',
confidence: 0.75,
},
geographicScope: { value: 'Hillsborough County, NH', sourceUrl: 'https://nhliteracy.org/about', confidence: 0.6 },
annualRevenue: { value: 480_000, sourceUrl: 'https://apps.irs.gov/pfft', confidence: 0.8 },
targetPopulations: {
value: ['K-5 students', 'low-income families'],
sourceUrl: 'https://nhliteracy.org/about',
confidence: 0.55,
},
};
describe('OrgProfileSchema', () => {
it('accepts a fully-sourced valid profile fixture', () => {
const result = OrgProfileSchema.safeParse(validProfile);
expect(result.success).toBe(true);
});
it('accepts a null ein and a null annualRevenue (org not matched to a 990-PF filing)', () => {
const result = OrgProfileSchema.safeParse({ ...validProfile, ein: null, annualRevenue: null });
expect(result.success).toBe(true);
});
it('rejects an ein that does not match the ##-####### federal EIN shape', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
ein: { value: 'not-an-ein', sourceUrl: null, confidence: 0.9 },
});
expect(result.success).toBe(false);
});
it('rejects a confidence outside the 0-1 range', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
mission: { value: 'Improve literacy outcomes.', sourceUrl: null, confidence: 1.4 },
});
expect(result.success).toBe(false);
});
it('rejects an empty programAreas array (sourcedField requires at least one)', () => {
const result = OrgProfileSchema.safeParse({
...validProfile,
programAreas: { value: [], sourceUrl: null, confidence: 0.5 },
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* A single extracted org-profile field, carrying its own provenance: the URL
* the value was pulled from (a 990-PF filing, the org's website, an NH
* Secretary of State record) and the model's confidence in the extraction.
* `sourceUrl: null` means the field was inferred rather than read verbatim
* from a single cited page (e.g. synthesized across multiple sources) —
* callers should treat a null-sourced field as lower-trust regardless of the
* reported confidence.
*/
function sourcedField<Value extends z.ZodTypeAny>(valueSchema: Value) {
return z.object({
value: valueSchema,
sourceUrl: z.string().url().nullable(),
confidence: z.number().min(0).max(1),
});
}
export const OrgProfileSchema = z.object({
/** EIN-qualified legal name, as it appears on the org's IRS filings. */
legalName: sourcedField(z.string().min(1)),
/** `##-#######` federal EIN. Null when the org couldn't be matched to a filing. */
ein: sourcedField(z.string().regex(/^\d{2}-?\d{7}$/)).nullable(),
/** Verbatim or lightly-condensed mission statement. */
mission: sourcedField(z.string().min(1)),
/** Free-text program-area tags (e.g. "youth mentoring", "food security"). */
programAreas: sourcedField(z.array(z.string().min(1)).min(1)),
/** Municipality/county/region the org primarily serves within NH. */
geographicScope: sourcedField(z.string().min(1)),
/** Most recent total-revenue figure, in whole dollars, from a 990-PF. */
annualRevenue: sourcedField(z.number().nonnegative()).nullable(),
/** Populations named as beneficiaries in the org's own materials. */
targetPopulations: sourcedField(z.array(z.string().min(1))),
});
export type OrgProfile = z.infer<typeof OrgProfileSchema>;

View File

@@ -0,0 +1,61 @@
// Copied/adapted from novelpad-desktop packages/ai/src/embeddings.ts @ 62c56b87
// Kept verbatim. NEVER change EMBEDDING_MODEL/EMBEDDING_DIMENSIONS below —
// `gemini-embedding-001` @ 1536 must stay identical for HelmDocs RAG vector compat.
import { getAi } from './gemini.js';
const EMBEDDING_MODEL = 'gemini-embedding-001';
const EMBEDDING_DIMENSIONS = 1536;
const MAX_BATCH_SIZE = 100;
/**
* Generate an embedding for a single text, using RETRIEVAL_QUERY task type
* (optimized for search queries).
*/
export async function generateQueryEmbedding(
text: string,
): Promise<number[]> {
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: text,
config: { taskType: 'RETRIEVAL_QUERY', outputDimensionality: EMBEDDING_DIMENSIONS },
});
return result.embeddings![0].values!;
}
/**
* Generate an embedding for a single text, using RETRIEVAL_DOCUMENT task type
* (optimized for document indexing).
*/
export async function generateDocumentEmbedding(
text: string,
): Promise<number[]> {
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: text,
config: { taskType: 'RETRIEVAL_DOCUMENT', outputDimensionality: EMBEDDING_DIMENSIONS },
});
return result.embeddings![0].values!;
}
/**
* Generate embeddings for multiple texts in batches.
* Uses RETRIEVAL_DOCUMENT task type. Batches requests to stay within API limits.
*/
export async function generateDocumentEmbeddings(
texts: string[],
): Promise<number[][]> {
const allEmbeddings: number[][] = [];
for (let i = 0; i < texts.length; i += MAX_BATCH_SIZE) {
const batch = texts.slice(i, i + MAX_BATCH_SIZE);
const result = await getAi().models.embedContent({
model: EMBEDDING_MODEL,
contents: batch,
config: { taskType: 'RETRIEVAL_DOCUMENT', outputDimensionality: EMBEDDING_DIMENSIONS },
});
allEmbeddings.push(...result.embeddings!.map((e) => e.values!));
}
return allEmbeddings;
}

View File

@@ -0,0 +1,67 @@
// Copied/adapted from novelpad-desktop packages/ai/src/gemini.ts @ 62c56b87
// Kept verbatim, GCP service-account key resolution logic unchanged.
import { GoogleGenAI } from '@google/genai';
import fs from 'fs';
import path from 'path';
function findMonorepoRoot(from: string): string {
let dir = from;
while (dir !== path.dirname(dir)) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
if (pkg.workspaces) return dir;
} catch {}
dir = path.dirname(dir);
}
return from;
}
function resolveKeyFile() {
const keyFilePath = process.env.GCP_SERVICE_ACCOUNT_KEY_PATH;
if (!keyFilePath) return undefined;
if (path.isAbsolute(keyFilePath)) {
return JSON.parse(fs.readFileSync(keyFilePath, 'utf8'));
}
// Try resolving relative to cwd first
const fromCwd = path.resolve(process.cwd(), keyFilePath);
if (fs.existsSync(fromCwd)) {
return JSON.parse(fs.readFileSync(fromCwd, 'utf8'));
}
// Fall back to resolving relative to monorepo root
const root = findMonorepoRoot(process.cwd());
const fromRoot = path.resolve(root, keyFilePath);
if (fs.existsSync(fromRoot)) {
return JSON.parse(fs.readFileSync(fromRoot, 'utf8'));
}
// Last resort: try normalizing away excess ../ and resolve from root
const normalized = path.normalize(keyFilePath);
const basename = normalized.split(path.sep).filter(s => s !== '..').join(path.sep);
const fromRootNormalized = path.resolve(root, basename);
return JSON.parse(fs.readFileSync(fromRootNormalized, 'utf8'));
}
let _ai: GoogleGenAI | null = null;
export function getAi(): GoogleGenAI {
if (_ai == null) {
const credentials = resolveKeyFile();
_ai = new GoogleGenAI({
vertexai: true,
project: process.env.GCP_PROJECT_ID,
location: process.env.GCP_LOCATION ?? 'global',
googleAuthOptions: credentials ? { credentials } : undefined,
});
}
return _ai;
}
/**
* Default Vertex model for agent calls. Env-overridable via `CHAT_MODEL` so a
* working model (from `yarn workspace @novelpad/ai probe:vertex <model>`) can be
* set per environment without a code change. Defaults to the GA `gemini-2.5-pro`
* — NOT a `-preview` name, which can be disabled in a given GCP project and then
* fails silently (404 the SDK swallows to empty output / 0 tokens).
* Per-call override: `invokeVertex({ model })`.
*/
export const CHAT_MODEL = process.env.CHAT_MODEL ?? 'gemini-2.5-pro';

View File

@@ -0,0 +1,7 @@
export * from './gemini.js';
export * from './embeddings.js';
export * from './models.js';
export * from './agents/org-profiler/schema.js';
export * from './agents/org-profiler/run.js';
export * from './agents/mission-fit-judge/schema.js';
export * from './agents/mission-fit-judge/run.js';

View File

@@ -0,0 +1,18 @@
/**
* Model-tier constants for @novelpad/outreach-ai agent calls.
*
* Tiering rule: reach for `JUDGE_MODEL` — the frontier tier — wherever a
* wrong answer can reach a prospect. That means any call gating or shaping
* something a real NH nonprofit contact will read or that gates a match into
* outreach: the mission-fit judge's verdict (a wrong "fit: true" sends an
* off-mission email to a real org) and personalization QA on outbound Apollo
* sequence copy.
*
* Use `BULK_MODEL` for everything upstream and internal, where a wrong
* answer is still caught by a deterministic gate or a human reviewer before
* anything reaches a prospect: classification, effort estimates, and
* first-pass org-profile extraction (the profiler's output is reviewed by a
* human before a match is ever scored).
*/
export const BULK_MODEL = 'gemini-2.5-flash';
export const JUDGE_MODEL = 'gemini-2.5-pro';