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
10 changes: 9 additions & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,15 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
const raw = safeJson(await c.req.raw.text());
const parsed = workspaceProfileSchema.safeParse(raw);
if (!parsed.success) {
throw ApiError.badRequest('that profile is incomplete', parsed.error.flatten().fieldErrors);
// Keyed by full path, not by `flatten().fieldErrors` — that reports the
// top-level object ("offering"), which tells someone staring at a form
// nothing about which line of which list it objected to.
const details: Record<string, string[]> = {};
for (const issue of parsed.error.issues) {
const key = issue.path.join('.') || 'profile';
(details[key] ??= []).push(issue.message);
}
throw ApiError.badRequest('that profile is incomplete', details);
}

const saved = await saveWorkspaceProfile(db, actor.workspaceId, parsed.data);
Expand Down
39 changes: 38 additions & 1 deletion apps/api/src/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@ const GOOD_DRAFT = JSON.stringify({
whereToFind: ['GitHub issues on agent frameworks — people describe failures there.'],
});

/** What a real model returns: prose in the lists, not tidy fixture terms. */
const CHATTY_DRAFT = JSON.stringify({
...JSON.parse(GOOD_DRAFT),
offering: {
...JSON.parse(GOOD_DRAFT).offering,
valuePropositions: [
'Enterprise-grade engineering discipline applied to agent reliability, so teams ship ' +
'autonomous features without discovering the regressions in production alongside their ' +
'own customers, which is the expensive way to find out.',
],
},
});

async function harness(
label: string,
options: { actor?: RequestActor | null; model?: StubModel; verified?: boolean } = {},
Expand Down Expand Up @@ -252,11 +265,35 @@ describe('PUT /onboarding/profile', () => {
expect(Number(rows.rows[0]!.n)).toBe(1);
});

test('an incomplete profile is rejected', async () => {
test('an incomplete profile is rejected, and says which field', async () => {
const { app } = await harness('onboard-invalid');

const response = await put(app, '/onboarding/profile', { offering: { name: '' } });
expect(response.status).toBe(400);

// "that profile is incomplete" on its own is unactionable in a nine-field
// form: the paths are what tell someone which box to go and fix.
const { error } = await response.json();
expect(Object.keys(error.details)).toContain('offering.name');
});

test('a drafted profile can be saved as drafted', async () => {
// The whole point of the review step: whatever comes back from POST has to
// be acceptable to PUT untouched. It once was not — a value proposition
// longer than the contract allowed failed the save with no way to correct
// it, because that field had no input on the page.
const model = new StubModel(CHATTY_DRAFT);
const { app } = await harness('onboard-roundtrip', { model });

const drafted = await (await post(app, '/onboarding/profile', { url: 'loopwright.io' })).json();
const saved = await put(app, '/onboarding/profile', {
url: drafted.url,
offering: drafted.draft.offering,
icp: drafted.draft.icp,
voice: drafted.draft.voice,
});

expect(saved.status).toBe(200);
});

test('GET reports an unconfigured workspace as unconfigured', async () => {
Expand Down
38 changes: 35 additions & 3 deletions apps/web/components/profile-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ interface Draft {
whereToFind: string[];
}

/**
* Turns the API's per-field complaints into something worth reading.
*
* A rejected save that says only "that profile is incomplete" leaves someone
* hunting through nine fields for the one the server disliked, so the paths
* come along: `offering.valuePropositions.3` means the fourth line of that box.
*/
function fieldSummary(details: unknown): string {
if (!details || typeof details !== 'object') return '';
return Object.keys(details as Record<string, unknown>).join(', ');
}

const lines = (values: string[]) => values.join('\n');
const parseLines = (value: string) =>
value
Expand All @@ -48,6 +60,9 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | undefined>();
// Separate from the read error: the save button sits at the bottom of a long
// form, and an explanation rendered up by the URL box is off-screen.
const [saveError, setSaveError] = useState<string | undefined>();
const [draft, setDraft] = useState<Draft | undefined>();
const [saved, setSaved] = useState(false);

Expand All @@ -57,6 +72,7 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {

setBusy(true);
setError(undefined);
setSaveError(undefined);
setSaved(false);

try {
Expand Down Expand Up @@ -85,7 +101,7 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {
async function save() {
if (!draft) return;
setSaving(true);
setError(undefined);
setSaveError(undefined);

try {
const response = await fetch('/api/v1/onboarding/profile', {
Expand All @@ -103,14 +119,16 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {
const payload = await response.json().catch(() => ({}));

if (!response.ok) {
setError(payload?.error?.message ?? `that failed (${response.status})`);
const message = payload?.error?.message ?? `that failed (${response.status})`;
const fields = fieldSummary(payload?.error?.details);
setSaveError(fields ? `${message}: ${fields}` : message);
return;
}

setSaved(true);
router.refresh();
} catch {
setError('could not reach the server');
setSaveError('could not reach the server');
} finally {
setSaving(false);
}
Expand Down Expand Up @@ -175,6 +193,15 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {
onChange={(description) => edit({ offering: { ...draft.offering, description } })}
/>

<ListField
label="Why they should care"
hint="The claims a message can make. Shown here because it is saved with the rest."
values={draft.offering.valuePropositions}
onChange={(valuePropositions) =>
edit({ offering: { ...draft.offering, valuePropositions } })
}
/>

<ListField
label="Problems your buyer has"
hint="In their words, not yours."
Expand Down Expand Up @@ -239,6 +266,11 @@ export function ProfileSetup({ initialUrl }: { initialUrl?: string }) {
{saving ? 'Saving…' : 'Save profile'}
</button>
{saved ? <span className="text-good text-sm">Saved.</span> : null}
{saveError ? (
<span role="alert" className="text-hot text-sm">
{saveError}
</span>
) : null}
</div>
</div>
) : null}
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ai/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.70.0",
"@outreachgraph/contracts": "workspace:*",
"@outreachgraph/domain": "workspace:*",
"@outreachgraph/db": "workspace:*"
}
Expand Down
86 changes: 86 additions & 0 deletions packages/ai/src/profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'bun:test';
import { profileLimits, workspaceProfileSchema } from '@outreachgraph/contracts';
import { StubModel } from './model';
import { draftProfile } from './profile';

const PAGE = 'Loopwright catches agent regressions before your customers do.';

/** A model that answers in full marketing prose, which is what they do. */
function chatty(): StubModel {
return new StubModel(
JSON.stringify({
offering: {
name: 'Loopwright',
category: 'developer tooling',
description: 'Reliability tooling for agent teams.',
valuePropositions: [
'Enterprise-grade engineering discipline applied to agent reliability, so teams ship ' +
'autonomous features without discovering the regressions in production alongside ' +
'their customers, which is the expensive way to find out.',
],
likelyPains: ['Agents fail silently in production'],
competitors: [],
},
icp: {
titles: ['Staff Engineer'],
seniorities: ['senior'],
industries: ['software'],
technologies: ['python'],
keywords: ['agent reliability'],
exclusions: ['students'],
},
voice: { style: 'direct and technical', instructions: 'no hype', maxWords: 120 },
whereToFind: ['GitHub issues on agent frameworks — people describe failures there.'],
}),
);
}

describe('drafting a profile', () => {
test('a long value proposition survives as a sentence rather than a term', async () => {
const result = await draftProfile(chatty(), PAGE, 'https://loopwright.io/');

const [first] = result.draft!.offering.valuePropositions;
expect(first!.length).toBeGreaterThan(profileLimits.term);
expect(first!.length).toBeLessThanOrEqual(profileLimits.sentence);
});

test('every draft can be saved', async () => {
// The regression this guards: the draft came back fine and the save then
// rejected it as "incomplete", because one line the form did not even show
// was longer than the contract allowed. A draft the contract refuses is a
// dead end for the person looking at it, so it must not be reachable.
const result = await draftProfile(chatty(), PAGE, 'https://loopwright.io/');
const { offering, icp, voice } = result.draft!;

const parsed = workspaceProfileSchema.safeParse({
url: 'https://loopwright.io/',
offering,
icp,
voice,
});

expect(parsed.success).toBe(true);
});

test('a run-on entry is cut on a word boundary and marked', async () => {
const long = 'reliability '.repeat(60).trim();
const model = new StubModel(
JSON.stringify({
offering: { name: 'Loopwright', valuePropositions: [long] },
icp: { keywords: [long] },
voice: { style: 'direct' },
}),
);

const result = await draftProfile(model, PAGE, 'https://loopwright.io/');

const proposition = result.draft!.offering.valuePropositions[0]!;
const keyword = result.draft!.icp.keywords[0]!;

expect(proposition.length).toBeLessThanOrEqual(profileLimits.sentence);
expect(keyword.length).toBeLessThanOrEqual(profileLimits.term);
// Whole words either side of the cut, and a mark that says it was cut.
expect(proposition).toEndWith('reliability…');
expect(keyword).toEndWith('reliability…');
});
});
47 changes: 36 additions & 11 deletions packages/ai/src/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* has seen it and confirmed it.
*/

import { profileLimits } from '@outreachgraph/contracts';
import type { TextModel } from './model';

export interface ProfileDraft {
Expand Down Expand Up @@ -71,6 +72,8 @@ const SYSTEM = [
'- whereToFind: concrete places these buyers are active and why — a subreddit,',
' a GitHub topic, a conference, a job-board signal. Each entry one sentence.',
'- Never mention LinkedIn automation. Research there is fine; automating is not.',
'- Keep icp entries to a term of a few words. valuePropositions, likelyPains and',
' exclusions may be a sentence, but one sentence, under 300 characters.',
].join('\n');

/** Pulls the JSON object out of a reply that may be fenced or prefaced. */
Expand All @@ -88,16 +91,38 @@ function parseReply(text: string): unknown {
}
}

function strings(value: unknown, limit = 12): string[] {
/**
* Shortens one entry to what the profile contract will accept.
*
* A draft that cannot be saved is worse than a shorter one. The model is asked
* for terms and sentences and mostly obliges, but one line over the limit used
* to fail the whole save with "that profile is incomplete" — naming no field,
* and in a form that had no box for the offending one. Cutting on a word
* boundary and marking the cut keeps the draft honest about what was lost.
*/
function clamp(value: string, max: number): string {
if (value.length <= max) return value;
const cut = value.slice(0, max - 1);
const boundary = cut.lastIndexOf(' ');
return `${(boundary > max / 2 ? cut.slice(0, boundary) : cut).trimEnd()}…`;
}

function strings(value: unknown, limit = 12, max: number = profileLimits.term): string[] {
if (!Array.isArray(value)) return [];
return value
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim())
.map((entry) => clamp(entry.trim(), max))
.slice(0, limit);
}

function text(value: unknown, fallback = ''): string {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
/** Sentences the model writes for a human to read, not terms to filter on. */
function sentences(value: unknown, limit = 12): string[] {
return strings(value, limit, profileLimits.sentence);
}

function text(value: unknown, fallback = '', max: number = profileLimits.label): string {
const trimmed = typeof value === 'string' ? value.trim() : '';
return clamp(trimmed || fallback, max);
}

/**
Expand Down Expand Up @@ -153,9 +178,9 @@ export async function draftProfile(
offering: {
name,
category: text(offering.category, 'unspecified'),
description: text(offering.description),
valuePropositions: strings(offering.valuePropositions),
likelyPains: strings(offering.likelyPains),
description: text(offering.description, '', profileLimits.description),
valuePropositions: sentences(offering.valuePropositions),
likelyPains: sentences(offering.likelyPains),
competitors: strings(offering.competitors, 8),
},
icp: {
Expand All @@ -164,15 +189,15 @@ export async function draftProfile(
industries: strings(icp.industries),
technologies: strings(icp.technologies),
keywords: strings(icp.keywords, 16),
exclusions: strings(icp.exclusions),
exclusions: sentences(icp.exclusions),
},
voice: {
style: text(voice.style, 'direct and specific'),
instructions: text(voice.instructions),
style: text(voice.style, 'direct and specific', profileLimits.style),
instructions: text(voice.instructions, '', profileLimits.instructions),
// A cap is what keeps a first message short enough to read on a phone.
maxWords: Number.isFinite(maxWords) && maxWords > 0 ? Math.min(maxWords, 400) : 120,
},
whereToFind: strings(root.whereToFind, 10),
whereToFind: sentences(root.whereToFind, 10),
},
};
}
Loading
Loading