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:
54
packages/outreach-ai/README.md
Normal file
54
packages/outreach-ai/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# @novelpad/outreach-ai
|
||||
|
||||
LLM access layer for the HelmDocs grant-match outreach engine. Modeled on
|
||||
`novelpad-desktop`'s `packages/ai`, trimmed to what this engine needs: a
|
||||
Vertex/Gemini client, an embeddings helper, model-tier constants, and two
|
||||
structured-output agents (org profiler, mission-fit judge).
|
||||
|
||||
## Provenance
|
||||
|
||||
`src/gemini.ts` and `src/embeddings.ts` are copied near-verbatim from
|
||||
`novelpad-desktop` at commit `62c56b87` (see the header comment in each file
|
||||
for the exact source path). They are kept as close to the original as
|
||||
compiles standalone in this workspace — do not "clean up" drift between them
|
||||
and the source without checking whether the source has moved on too.
|
||||
|
||||
`src/agents/*/run.ts` follow the structured-JSON-output pattern from
|
||||
`novelpad-desktop`'s `packages/ai/src/agents/grant/section-drafter/run.ts`
|
||||
(prompt builder + `generateContent` with `responseMimeType: 'application/json'`
|
||||
and a hand-written `responseSchema`), but are new code for this repo's domain,
|
||||
not copies — both are currently `NOT IMPLEMENTED` stubs pending the
|
||||
source-fetching / hard-gate scoring infrastructure they depend on. Each stub's
|
||||
doc comment shows the intended call shape.
|
||||
|
||||
## Never mix embedding models
|
||||
|
||||
`generateQueryEmbedding` / `generateDocumentEmbedding` / `generateDocumentEmbeddings`
|
||||
are pinned to `gemini-embedding-001` at `outputDimensionality: 1536`. **Do not
|
||||
change either value**, and do not add a second embedding model/dimensionality
|
||||
into this package. Every vector this package produces has to remain
|
||||
comparable (same model, same dimensionality) against every other vector
|
||||
already stored in the HelmDocs RAG index — switching models or dimensions
|
||||
silently corrupts similarity search for anything embedded before the switch,
|
||||
with no error at write time. If a better embedding model becomes available,
|
||||
that's a deliberate, full-reindex migration, not a constant change here.
|
||||
|
||||
## Model tiering
|
||||
|
||||
See `src/models.ts`: `BULK_MODEL` (`gemini-2.5-flash`) for high-volume
|
||||
mechanical work (classification, effort estimates, first-pass org-profile
|
||||
extraction); `JUDGE_MODEL` (`gemini-2.5-pro`) for anything a wrong answer
|
||||
could put in front of a real prospect (mission-fit judge, email
|
||||
personalization QA).
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/gemini.ts` — `getAi()` Vertex client singleton + `CHAT_MODEL`.
|
||||
- `src/embeddings.ts` — query/document embedding helpers.
|
||||
- `src/models.ts` — `BULK_MODEL` / `JUDGE_MODEL` constants.
|
||||
- `src/agents/org-profiler/` — `OrgProfileSchema` (per-field source URL +
|
||||
confidence) and the profiler agent stub.
|
||||
- `src/agents/mission-fit-judge/` — `MissionFitVerdictSchema` (`fit`,
|
||||
`citedOrgProgram`, `citedGrantPriority`, `reasoning`) and the judge agent
|
||||
stub.
|
||||
- `src/index.ts` — barrel export.
|
||||
32
packages/outreach-ai/package.json
Normal file
32
packages/outreach-ai/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@novelpad/outreach-ai",
|
||||
"version": "1.0.0",
|
||||
"packageManager": "yarn@4.5.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"imports": {
|
||||
"#~/*": "./dist/*"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.41.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@novelpad/config": "workspace:^",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
64
packages/outreach-ai/src/agents/mission-fit-judge/run.ts
Normal file
64
packages/outreach-ai/src/agents/mission-fit-judge/run.ts
Normal 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');
|
||||
}
|
||||
20
packages/outreach-ai/src/agents/mission-fit-judge/schema.ts
Normal file
20
packages/outreach-ai/src/agents/mission-fit-judge/schema.ts
Normal 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>;
|
||||
52
packages/outreach-ai/src/agents/org-profiler/run.ts
Normal file
52
packages/outreach-ai/src/agents/org-profiler/run.ts
Normal 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');
|
||||
}
|
||||
56
packages/outreach-ai/src/agents/org-profiler/schema.test.ts
Normal file
56
packages/outreach-ai/src/agents/org-profiler/schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
packages/outreach-ai/src/agents/org-profiler/schema.ts
Normal file
36
packages/outreach-ai/src/agents/org-profiler/schema.ts
Normal 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>;
|
||||
61
packages/outreach-ai/src/embeddings.ts
Normal file
61
packages/outreach-ai/src/embeddings.ts
Normal 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;
|
||||
}
|
||||
67
packages/outreach-ai/src/gemini.ts
Normal file
67
packages/outreach-ai/src/gemini.ts
Normal 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';
|
||||
7
packages/outreach-ai/src/index.ts
Normal file
7
packages/outreach-ai/src/index.ts
Normal 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';
|
||||
18
packages/outreach-ai/src/models.ts
Normal file
18
packages/outreach-ai/src/models.ts
Normal 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';
|
||||
21
packages/outreach-ai/tsconfig.json
Normal file
21
packages/outreach-ai/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@novelpad/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"paths": {
|
||||
"#~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user