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
2 changes: 1 addition & 1 deletion apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,7 +1683,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
const limit = clampLimit(c.req.query('limit'));

const rows = await c.get('db').execute({
sql: `SELECT p.id, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
sql: `SELECT p.id, p.kind, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
p.status,
co.name AS current_company,
cp.status AS prospect_status, cp.interaction_state,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export async function listPendingRecommendations(

return queryAll(
db,
`SELECT r.*, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
`SELECT r.*, p.kind, p.display_name, p.current_title, p.avatar_url, p.identity_confidence,
s.summary AS signal_summary, s.source_url AS signal_url,
s.source_timestamp AS signal_at,
d.body AS draft_body, d.subject AS draft_subject,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/components/approval-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ export function ApprovalCard({ card }: { card: Card }) {
<Avatar name={card.display_name} src={card.avatar_url} size="md" />
<div className="min-w-0">
<h2 className="truncate text-base font-semibold">{card.display_name}</h2>
<p className="text-ink-muted truncate text-sm">{card.current_title ?? '—'}</p>
<p className="text-ink-muted truncate text-sm">
{card.kind === 'company_inbox' ? 'Company inbox' : (card.current_title ?? '—')}
</p>
</div>
</div>
<div className="shrink-0 text-right">
Expand Down
6 changes: 6 additions & 0 deletions apps/web/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface ApprovalCard {
display_name: string;
current_title: string | null;
avatar_url?: string | null;
/** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */
kind?: 'person' | 'company_inbox';
action: string;
network: string;
priority: number;
Expand Down Expand Up @@ -156,6 +158,8 @@ export interface ProspectRow {
display_name: string;
current_title: string | null;
avatar_url?: string | null;
/** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */
kind?: 'person' | 'company_inbox';
current_company: string | null;
identity_confidence: number;
prospect_status: string;
Expand Down Expand Up @@ -199,6 +203,8 @@ export interface ProspectDetail {
display_name: string;
current_title: string | null;
avatar_url?: string | null;
/** Absent or `person` is a named human; `company_inbox` is a shared mailbox lead. */
kind?: 'person' | 'company_inbox';
identity_confidence: number;
status: string;
};
Expand Down
12 changes: 3 additions & 9 deletions apps/web/public/.well-known/openaccess.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
"name": "OutreachGraph",
"url": "https://outreachgraph.com",
"operator": "https://logicsrc.com/.well-known/openprofile.md",
"redirect_uris": [
"https://outreachgraph.com/api/v1/openaccess/callback"
],
"redirect_uris": ["https://outreachgraph.com/api/v1/openaccess/callback"],
"jwks": {
"keys": [
{
Expand All @@ -19,11 +17,7 @@
]
},
"scopes": {},
"honours": [
"profullstack.com/all-access"
],
"honours": ["profullstack.com/all-access"],
"webhooks": "https://outreachgraph.com/api/v1/openaccess/events",
"hubs": [
"https://openaccess.logicsrc.com"
]
"hubs": ["https://openaccess.logicsrc.com"]
}
11 changes: 11 additions & 0 deletions migrations/0034_company_inbox_leads.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- A company's published inbox as a lead in its own right.
--
-- A crawl that names nobody still finds a company and, usually, a support@ or
-- info@ address. Until now that address was recorded and then unreachable: a
-- recommendation only ever hangs off a person, so a small store whose site
-- says "family-owned" and publishes one shared mailbox produced a company row
-- and an empty queue. `kind` marks the person row that stands in for that
-- inbox so the rest of the product can treat it honestly: greet the team
-- rather than a first name, never propose or enrich a personal address for
-- it, never look up a photo of it.
ALTER TABLE people ADD COLUMN kind TEXT NOT NULL DEFAULT 'person';
52 changes: 52 additions & 0 deletions packages/ai/src/composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,55 @@ describe('determinism of the checked surface', () => {
expect(stripVolatile(a.calls[0]!)).toEqual(stripVolatile(b.calls[0]!));
});
});

describe('a company inbox as the recipient', () => {
test('is written to as the team, about what the site says', async () => {
const model = new StubModel(
'Shipping research supplies the same day is a lot of parcels to reconcile by hand.',
);

const result = await composeDraft(
model,
input({
action: 'send_email',
network: 'email',
prospect: {
kind: 'company_inbox',
displayName: 'Family Shop',
companyName: 'Family Shop',
identityConfidence: 0.9,
},
trigger: {
id: 'sig_inbox',
summary:
'Publishes hello@familyshop.example as the contact address on the company website.',
evidence:
'A family-owned store shipping research supplies the same day. ' +
'Contact: hello@familyshop.example',
sourceUrl: 'https://familyshop.example',
network: 'website',
ageDescription: 'today',
},
}),
);

expect(result.ok).toBe(true);

// Nobody is named, so the prompt must not pretend someone is.
const prompt = model.calls[0]!.user;
expect(prompt).toContain('the Family Shop team');
expect(prompt).toContain('Nobody specific is named');
expect(prompt).toContain('Greet the team, never a first name');
expect(prompt).not.toContain('What they did');
expect(prompt).not.toContain('Person:');
});

test('a named person is still written to by name', async () => {
const model = new StubModel(GOOD_DRAFT);
await composeDraft(model, input());

const prompt = model.calls[0]!.user;
expect(prompt).toContain('Write a message to Jane');
expect(prompt).not.toContain('team');
});
});
48 changes: 34 additions & 14 deletions packages/ai/src/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* rejected — including on a retry — rather than shown with a warning.
*/

import type { ActionKind, Network, OutreachStyle } from '@outreachgraph/domain';
import type { ActionKind, Network, OutreachStyle, PersonKind } from '@outreachgraph/domain';
import { runChecks, type CheckReport, type GroundingContext } from './checks';
import type { TextModel } from './model';

Expand All @@ -29,6 +29,8 @@ export interface OfferingContext {
}

export interface ProspectContext {
/** Absent means a person. A `company_inbox` is written to as a team. */
readonly kind?: PersonKind;
readonly displayName: string;
readonly firstName?: string;
readonly title?: string;
Expand Down Expand Up @@ -250,19 +252,37 @@ function buildSystem(input: ComposeInput): string {

function buildUser(input: ComposeInput, failed?: CheckReport): string {
const trigger = input.trigger!;
const name = input.prospect.firstName ?? input.prospect.displayName;

const sections = [
'CONTEXT — the only facts you may use:',
`Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${
input.prospect.companyName ? ` at ${input.prospect.companyName}` : ''
}`,
`What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`,
`Their exact words:\n"""\n${trigger.evidence}\n"""`,
'',
`Write a message to ${name} responding to what they said.`,
'Reference their words specifically enough that it could not have been sent to anyone else.',
];
const inbox = input.prospect.kind === 'company_inbox';
const company = input.prospect.companyName ?? input.prospect.displayName;
const name = inbox
? `the ${company} team`
: (input.prospect.firstName ?? input.prospect.displayName);

// A shared inbox has no first name to greet and nobody's words to quote.
// What it has is a company that published a way to be reached and, often,
// a line about itself. So the message is to the team, about the company,
// and grounded in what the site says rather than in what a person said.
const sections = inbox
? [
'CONTEXT — the only facts you may use:',
`Recipient: the shared inbox of ${company}. Nobody specific is named; whoever handles the company's mail will read this.`,
`What their site says: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`,
`The site's exact words:\n"""\n${trigger.evidence}\n"""`,
'',
`Write a message to ${name}. Greet the team, never a first name.`,
'Reference what the site says specifically enough that it could not have been sent to any other company.',
]
: [
'CONTEXT — the only facts you may use:',
`Person: ${input.prospect.displayName}${input.prospect.title ? `, ${input.prospect.title}` : ''}${
input.prospect.companyName ? ` at ${input.prospect.companyName}` : ''
}`,
`What they did: ${trigger.summary} (${trigger.network}, ${trigger.ageDescription})`,
`Their exact words:\n"""\n${trigger.evidence}\n"""`,
'',
`Write a message to ${name} responding to what they said.`,
'Reference their words specifically enough that it could not have been sent to anyone else.',
];

if (failed) {
// Naming the exact rejected fragments works far better than repeating the
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/src/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,16 @@ export async function draftForRecommendation(
if (!signal?.evidence) return { ok: false, reason: 'no_evidence' };

const person = await queryOne<{
kind: string;
display_name: string;
first_name: string | null;
current_title: string | null;
current_company_id: string | null;
identity_confidence: number;
}>(
db,
`SELECT display_name, first_name, current_title, current_company_id, identity_confidence
`SELECT kind, display_name, first_name, current_title, current_company_id,
identity_confidence
FROM people WHERE id = ?`,
[recommendation.person_id],
);
Expand Down Expand Up @@ -149,6 +151,7 @@ export async function draftForRecommendation(
competitors: parseArray(offering.competitors),
},
prospect: {
...(person.kind === 'company_inbox' ? { kind: 'company_inbox' as const } : {}),
displayName: person.display_name,
...(person.first_name ? { firstName: person.first_name } : {}),
...(person.current_title ? { title: person.current_title } : {}),
Expand Down
14 changes: 14 additions & 0 deletions packages/domain/src/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,22 @@ export interface Company {
export const PERSON_STATUS = ['active', 'suppressed', 'deleted'] as const;
export type PersonStatus = (typeof PERSON_STATUS)[number];

/**
* What a `people` row stands for.
*
* `person` is a named human. `company_inbox` is a company's shared mailbox
* (`support@`, `info@`) standing in for the nobody its site named, so a
* company that publishes a way to reach it can be reached through the same
* queue, policy and approval as everyone else. The kind is set deliberately by
* the crawl, never inferred from a scraped name: `isLikelyRoleAccount` still
* rejects "webmaster" and "admin" as people.
*/
export const PERSON_KINDS = ['person', 'company_inbox'] as const;
export type PersonKind = (typeof PERSON_KINDS)[number];

export interface Person {
readonly id: PrefixedId<'person'>;
readonly kind: PersonKind;
readonly displayName: string;
readonly firstName?: string;
readonly lastName?: string;
Expand Down
Loading
Loading