Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ X_API_SECRET=
# ---------------------------------------------------------------------- LLM
# Never exposed to the browser, and never included in a model prompt alongside
# customer OAuth tokens (PRD §34).
#
# Optional. Unset, the composer is disabled: signals, resolution, scoring and
# the approval queue all still run, and the reviewer writes the message. It is
# never consulted for a policy, identity or scoring decision (PRD §1.1.8).
ANTHROPIC_API_KEY=
# Overrides the default model (claude-opus-5).
ANTHROPIC_MODEL=

# -------------------------------------------------------------------- queue
# Optional until queued jobs need durability.
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ platform, the prospect, or your own rate limits say it should not.

## Status

Foundation. The deterministic core, the API, the PWA and one live provider are
built and tested; a prospect can go from a GitHub handle to a card in the
approval queue today. The LLM layer — the outreach composer and the rest of the
§20 agent suite — is not built. See
Foundation. The deterministic core, the API, the PWA, one live provider and the
outreach composer are built and tested; a prospect can go from a GitHub handle
to a drafted, checked card in the approval queue today. The rest of the §20
agent suite is not built. See
[`docs/prd-implementation-map.md`](docs/prd-implementation-map.md) for exactly
what exists.

Expand Down Expand Up @@ -43,6 +43,7 @@ apps/
web/ Next.js 16 mobile-first PWA
worker/ background jobs: signal expiry, rescoring, privacy work
packages/
ai/ the only package that talks to a model: composer + quality gates
domain/ canonical types — depends on nothing
db/ Turso/libSQL client and migration runner
policy/ the deterministic policy engine
Expand Down Expand Up @@ -88,6 +89,15 @@ state. Suppression, a flipped feature flag, a spent rate limit, or a dropped
identity confidence all block an approval that would have been fine yesterday,
and the response names the gate that stopped it.

**A draft that fails its checks is withheld, not shown with a warning.** The
composer only ever sees stored evidence, stored facts and your own offering —
there is no path by which it learns something it may not cite. Its output then
runs deterministic gates: every specific assertion must appear in that
evidence, or the draft is rejected and rewritten once naming the exact invented
fragments. Still failing, nothing is shown. The card keeps the prospect, the
evidence and the recommended action, and you write the message. A bad draft
next to a caveat is still a bad draft someone might approve.

**Evidence combines with noisy-OR, so weak signals never reach certainty.**
Identity resolution and intent scoring both use `1 - Π(1 - eᵢ)`. Two 0.5
observations give 0.75, not 1.0. "Same name, same city" — the classic
Expand Down
3 changes: 2 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
"zod": "^3.24.1"
"zod": "^3.24.1",
"@outreachgraph/ai": "workspace:*"
}
}
101 changes: 101 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
import { StubModel } from '@outreachgraph/ai';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';
Expand Down Expand Up @@ -484,3 +485,103 @@ describe('errors', () => {
expect(response.status).toBe(400);
});
});

describe('drafting on demand (PRD §14)', () => {
/** The seed's evidence is a cross-border payouts complaint. */
const GROUNDED = 'Settlement taking days on cross-border payouts was our problem too.';

async function withModel(
label: string,
responses: string | readonly string[],
): Promise<Hono<AppEnv>> {
const seeded = await seedDatabase(label);
active = seeded;

return createApp({
db: seeded.db,
authenticate: async () => ACTOR,
model: new StubModel(responses),
});
}

test('returns 503 when no model is configured', async () => {
const { app } = await harness('draft-nomodel');
const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);

expect(response.status).toBe(503);
expect((await response.json()).error.code).toBe('composer_unavailable');
});

test('composes a grounded message and returns it', async () => {
const app = await withModel('draft-ok', GROUNDED);
const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);

expect(response.status).toBe(200);
const body = await response.json();
expect(body.drafted).toBe(true);
expect(body.body).toBe(GROUNDED);
});

test('replaces the previous draft rather than stacking a second one', async () => {
const app = await withModel('draft-replace', GROUNDED);
await post(app, `/recommendations/${SEED.recommendationId}/draft`);
await post(app, `/recommendations/${SEED.recommendationId}/draft`);

const rows = await active!.db.execute({
sql: 'SELECT count(*) AS n FROM drafts WHERE recommendation_id = ?',
args: [SEED.recommendationId],
});
expect(Number(rows.rows[0]?.n)).toBe(1);
});

test('reports a withheld draft instead of surfacing an invented one', async () => {
const app = await withModel('draft-withheld', [
'On cross-border payouts, Fluxwire solved this for us.',
'Your payouts note — we moved to Fluxwire.',
]);

const response = await post(app, `/recommendations/${SEED.recommendationId}/draft`);

// Not an error: refusing to write is a normal, expected answer.
expect(response.status).toBe(200);
const body = await response.json();
expect(body.drafted).toBe(false);
expect(body.reason).toBe('failed_checks');
expect(body.unsupported).toContain('Fluxwire');
expect(body.body).toBeUndefined();
});

test('a withheld draft is audited', async () => {
const app = await withModel('draft-audit', [
'On cross-border payouts, Fluxwire solved this for us.',
'Your payouts note — we moved to Fluxwire.',
]);
await post(app, `/recommendations/${SEED.recommendationId}/draft`);

const rows = await active!.db.execute(
"SELECT count(*) AS n FROM audit_events WHERE event_type = 'draft.withheld'",
);
expect(Number(rows.rows[0]?.n)).toBe(1);
});

test('an unknown recommendation is a 404', async () => {
const app = await withModel('draft-404', GROUNDED);
expect((await post(app, '/recommendations/rec_missing/draft')).status).toBe(404);
});

test('a draft the user edited is never discarded by a recompose', async () => {
const app = await withModel('draft-edited', GROUNDED);
await active!.db.execute({
sql: 'UPDATE drafts SET edited_by_user = 1 WHERE id = ?',
args: [SEED.draftId],
});

await post(app, `/recommendations/${SEED.recommendationId}/draft`);

const rows = await active!.db.execute({
sql: 'SELECT edited_by_user FROM drafts WHERE id = ?',
args: [SEED.draftId],
});
expect(rows.rows).toHaveLength(1);
});
});
66 changes: 66 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
workspacesForUser,
} from './auth';
import { evaluatePolicy, isExecutable, POLICY_VERSION } from '@outreachgraph/policy';
import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { ApiError, canApprove, type AppEnv, type RequestActor } from './context';
import * as repo from './repository';

Expand All @@ -50,6 +51,11 @@ export interface AppOptions {
readonly serviceToken?: string;
/** Set false for plain-HTTP local development so the cookie still sets. */
readonly secureCookies?: boolean;
/**
* Writes outreach drafts. Omit to run without a language model — every
* other route works unchanged and drafting returns 503.
*/
readonly model?: TextModel;
readonly version?: string;
readonly commitHash?: string;
}
Expand Down Expand Up @@ -451,6 +457,66 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
});
});

/**
* Compose (or recompose) the message for a recommendation.
*
* Separate from generation because a draft is optional: the pipeline places
* the card in the queue whether or not the composer produced anything, and
* the reviewer can ask for one here. A refusal to write is a normal answer,
* returned with the reason and the specific fragments that failed grounding
* — the alternative is showing an invented message, which is worse.
*/
api.post('/recommendations/:id/draft', async (c) => {
const actor = c.get('actor');
const db = c.get('db');

if (!options.model) {
throw new ApiError(
503,
'composer_unavailable',
'no language model is configured; set ANTHROPIC_API_KEY to enable drafting',
);
}

const recommendation = await repo.getRecommendation(db, actor.workspaceId, c.req.param('id'));
if (!recommendation) throw ApiError.notFound('recommendation');

// Recomposing replaces the previous draft; the writer asked for a rewrite.
await db.execute({
sql: 'DELETE FROM drafts WHERE recommendation_id = ? AND edited_by_user = 0',
args: [recommendation.id],
});

const result = await draftForRecommendation(db, options.model, recommendation.id);

if (!result.ok) {
await repo.audit(db, {
workspaceId: actor.workspaceId,
actorKind: 'user',
actorId: actor.userId,
eventType: 'draft.withheld',
entityKind: 'recommendation',
entityId: recommendation.id,
detail: { reason: result.reason, unsupported: result.unsupported ?? [] },
});

return c.json(
{
drafted: false,
reason: result.reason,
...(result.unsupported ? { unsupported: result.unsupported } : {}),
},
200,
);
}

const draft = await queryOne<{ body: string }>(db, 'SELECT body FROM drafts WHERE id = ?', [
result.draftId!,
]);

return c.json({ drafted: true, draftId: result.draftId, body: draft?.body });
});

api.post('/recommendations/:id/skip', async (c) => {
const actor = c.get('actor');
const db = c.get('db');
Expand Down
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"hono": "^4.6.14",
"zod": "^3.24.1"
"zod": "^3.24.1",
"@outreachgraph/ai": "workspace:*"
}
}
19 changes: 19 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db';
import { ClaudeModel } from '@outreachgraph/ai';
import { createApp } from '../../api/src/app';
import { pruneSessions } from '../../api/src/auth';
import { expireSignals, processDeletion } from '../../worker/src/jobs';
Expand Down Expand Up @@ -60,9 +61,27 @@ if (process.env.RUN_MIGRATIONS !== 'false') {
}
}

// -------------------------------------------------------------------- model
/**
* The composer is optional infrastructure.
*
* Without a key the product still works: signals are ingested, prospects
* resolved, recommendations scored and queued — the reviewer writes the
* message. Refusing to boot over a missing key would take the whole system
* down for a feature that is, by design, allowed to produce nothing.
*/
const model = process.env.ANTHROPIC_API_KEY
? new ClaudeModel({
...(process.env.ANTHROPIC_MODEL ? { model: process.env.ANTHROPIC_MODEL } : {}),
})
: undefined;

if (!model) console.log('no ANTHROPIC_API_KEY: drafting disabled, queue still runs');

// ---------------------------------------------------------------------- api
const api = createApp({
db,
...(model ? { model } : {}),
...(process.env.API_TOKEN ? { serviceToken: process.env.API_TOKEN } : {}),
// Cookies must not be Secure over plain HTTP, or local development can
// never hold a session.
Expand Down
Loading
Loading