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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ PDL_API_KEY=
GITHUB_TOKEN=

# Finds a lead's photo (and their LinkedIn profile URL, as research) by
# searching their name, title and company. One image search per person,
# roughly a quarter of a cent each. Unset, only Gravatar supplies pictures.
# searching their name, title and company. One paid image search per person.
# Optional: crawls, GitHub, social profiles and Gravatar also supply pictures
# without image-search credits. LinkedIn image search requires this key.
VALUESERP_API_KEY=
# Ceiling on photo lookups per workspace per day. Default 300.
PHOTO_LOOKUPS_PER_DAY=
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ typically yields three linked identities before any paid provider is touched.
Each stage persists before the next runs, so a crash resumes rather than
restarting, and a half-enriched prospect is still inspectable.

## Public profile photos

Prospects, approval cards and the daily digest show a photo when a public source
supplies one. Crawls retain JSON-LD `Person.image` portraits and team images whose
alt text names the person, including lazy-loaded images and relative URLs. GitHub
enrichment keeps the public account's avatar. Photos are stored with the source
page in field provenance; existing photos are kept, and a later crawl or enrichment
can fill an empty photo even after image search previously missed.

These paths use the pages and public API responses already being read. Gravatar
and social profile intake remain available, and `VALUESERP_API_KEY` optionally
enables the bounded image-search fallback for LinkedIn and company team pages.
Nothing signs into LinkedIn or bypasses a blocked page. Missing or unavailable
photos show initials. Previously imported people gain these photos on their next
crawl or enrichment; this change does not run a production backfill.

## The three ideas worth knowing

**The policy engine is arithmetic, not judgement.** Every outbound action
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ if (!encryptionKey) {
/**
* Pictures of leads, by search.
*
* Optional, like every provider: unset, Gravatar is the only source and most
* people stay as initials. Each lookup is a paid request, so the sweep is
* Optional: crawls, public profile enrichment and Gravatar also supply photos.
* Each search lookup is a paid request, so the sweep is
* capped per workspace per day as well as per tick.
*/
const photoFinder = process.env.VALUESERP_API_KEY
Expand All @@ -257,7 +257,8 @@ const photoFinder = process.env.VALUESERP_API_KEY

const photoLookupsPerDay = Number(process.env.PHOTO_LOOKUPS_PER_DAY ?? 300);

if (!photoFinder) console.log('no VALUESERP_API_KEY: lead photos come from Gravatar only');
if (!photoFinder)
console.log('no VALUESERP_API_KEY: lead photos come from public profiles and crawls');

/**
* The feed clients for one campaign's own targets.
Expand Down
8 changes: 7 additions & 1 deletion apps/web/components/avatar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
'use client';

import { useState } from 'react';

/**
* A face, or the initials standing in for one.
*
Expand Down Expand Up @@ -25,16 +29,18 @@ export function Avatar({
size?: keyof typeof SIZES;
className?: string;
}) {
const [failedSrc, setFailedSrc] = useState<string>();
const box = `${SIZES[size]} shrink-0 rounded-full ${className}`;

if (src) {
if (src && src !== failedSrc) {
return (
// eslint-disable-next-line @next/next/no-img-element -- remote hosts are not known ahead of time
<img
src={src}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailedSrc(src)}
className={`${box} bg-surface object-cover`}
/>
);
Expand Down
21 changes: 18 additions & 3 deletions packages/pipeline/src/crawl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const COMPANY_HTML = `<!doctype html><html><head>
</script>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Person","name":"Alex Chen",
"jobTitle":"Staff Engineer","sameAs":["https://github.com/alexchen"]}
"jobTitle":"Staff Engineer","sameAs":["https://github.com/alexchen"],"image":"/alex.jpg"}
</script>
</head><body>
<footer>
Expand Down Expand Up @@ -112,13 +112,28 @@ describe('URL to approval card', () => {
expect(summary.processed).toBe(1);
expect(summary.succeeded).toBe(1);

const person = await queryOne<{ id: string; display_name: string; current_title: string }>(
const person = await queryOne<{
id: string;
display_name: string;
current_title: string;
avatar_url: string;
avatar_source: string;
}>(
db,
'SELECT id, display_name, current_title FROM people WHERE display_name = ?',
'SELECT id, display_name, current_title, avatar_url, avatar_source FROM people WHERE display_name = ?',
['Alex Chen'],
);
expect(person?.display_name).toBe('Alex Chen');
expect(person?.current_title).toBe('Staff Engineer');
expect(person?.avatar_url).toBe('https://loopwright.io/alex.jpg');
expect(person?.avatar_source).toBe('site');
const photoSource = await queryOne<{ source_record_id: string; provider: string }>(
db,
"SELECT source_record_id, provider FROM field_provenance WHERE entity_id = ? AND field = 'avatar_url'",
[person!.id],
);
expect(photoSource?.source_record_id).toBe('https://loopwright.io/');
expect(photoSource?.provider).toBe('site');

// Filed into the workspace's campaign, or the card has nowhere to appear.
const membership = await queryOne<{ status: string }>(
Expand Down
25 changes: 24 additions & 1 deletion packages/pipeline/src/photos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { queryOne } from '@outreachgraph/db';
import type { ProfilePhoto, ProfilePhotoFinder, ProfilePhotoQuery } from '@outreachgraph/providers';
import { SiteProvider } from '@outreachgraph/providers';
import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed';
import { sweepProfilePhotos, workspacesAwaitingPhotos } from './photos';
import { storeDiscoveredPhoto, sweepProfilePhotos, workspacesAwaitingPhotos } from './photos';

let seeded: SeededDatabase | undefined;

Expand Down Expand Up @@ -42,6 +43,28 @@ const LINKEDIN_HIT: ProfilePhoto = {
};

describe('sweepProfilePhotos', () => {
test('public crawl photos fill a previous search miss without spending another credit', async () => {
seeded = await seedDatabase('photos-later-crawl');
const { db } = seeded;
const { asked, finder: missed } = finder(undefined);
await sweepProfilePhotos({ db, finder: missed }, { workspaceId: SEED.workspaceId });
await storeDiscoveredPhoto(
db,
SEED.personId,
{ url: 'https://acme.com/jane.jpg', pageUrl: 'https://acme.com/team' },
new SiteProvider().capabilities(),
new Date().toISOString(),
);
const person = await queryOne<{ avatar_url: string; photo_looked_up_at: string }>(
db,
'SELECT avatar_url, photo_looked_up_at FROM people WHERE id = ?',
[SEED.personId],
);
expect(person?.avatar_url).toBe('https://acme.com/jane.jpg');
expect(person?.photo_looked_up_at).toBeTruthy();
expect(asked).toHaveLength(1);
expect(await workspacesAwaitingPhotos(db)).toEqual([]);
});
test('asks with everything known and records the picture and the profile', async () => {
seeded = await seedDatabase('photos-hit');
const { db } = seeded;
Expand Down
48 changes: 46 additions & 2 deletions packages/pipeline/src/photos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,59 @@
*/

import { newId } from '@outreachgraph/domain';
import type { ProfilePhotoFinder } from '@outreachgraph/providers';
import { isLinkedInProfile } from '@outreachgraph/providers';
import type {
CandidatePhoto,
ProfilePhotoFinder,
ProviderCapabilities,
} from '@outreachgraph/providers';
import { isLinkedInProfile, publicPhotoUrl } from '@outreachgraph/providers';
import { now, queryAll, queryOne, type Client } from '@outreachgraph/db';

/** Lookups per workspace per tick. */
const SWEEP_SIZE = 20;
/** Lookups per workspace per day, absent a configured ceiling. */
const DEFAULT_DAILY_CAP = 300;

/** Fill a missing portrait, keeping the public source in the same transaction. */
export async function storeDiscoveredPhoto(
db: Client,
personId: string,
photo: CandidatePhoto,
provider: ProviderCapabilities,
stamp: string,
): Promise<void> {
const url = publicPhotoUrl(photo.url);
const pageUrl = publicPhotoUrl(photo.pageUrl);
if (!url || !pageUrl) return;
await db.batch(
[
{
sql: `INSERT INTO field_provenance (id, entity_kind, entity_id, field, value, source_type,
provider, source_record_id, license_class, confidence, observed_at, created_at)
SELECT ?, 'person', id, 'avatar_url', ?, ?, ?, ?, ?, 1.0, ?, ?
FROM people WHERE id = ? AND avatar_url IS NULL AND kind = 'person' AND status = 'active'`,
args: [
newId('fieldProvenance'),
url,
provider.sourceType,
provider.slug,
pageUrl,
provider.licenseClass,
stamp,
stamp,
personId,
],
},
{
sql: `UPDATE people SET avatar_url = ?, avatar_source = ?, updated_at = ?
WHERE id = ? AND avatar_url IS NULL AND kind = 'person' AND status = 'active'`,
args: [url, provider.slug, stamp, personId],
},
],
'write',
);
}

/**
* How much to believe a LinkedIn URL found by search.
*
Expand Down
76 changes: 74 additions & 2 deletions packages/pipeline/src/pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { GitHubProvider } from '@outreachgraph/providers';
import { GitHubProvider, SiteProvider } from '@outreachgraph/providers';
import { seedDatabase, SEED, type SeededDatabase } from '../../../apps/api/src/test-seed';
import { runPipeline } from './pipeline';
import { runPipeline, runPipelineForCandidate } from './pipeline';

/** A GitHub profile with the self-declared cross-links the resolver needs. */
const PROFILE = {
Expand All @@ -17,6 +17,7 @@ const PROFILE = {
public_repos: 30,
followers: 500,
html_url: 'https://github.com/alexchen',
avatar_url: 'https://avatars.githubusercontent.com/u/4242?v=4',
created_at: '2015-01-01T00:00:00Z',
updated_at: '2026-08-01T00:00:00Z',
};
Expand Down Expand Up @@ -120,6 +121,68 @@ function options(db: SeededDatabase['db']) {
}

describe('end to end', () => {
test('a crawled GitHub handle gains its public avatar with GitHub attribution', async () => {
const { db } = await fixture('portrait-fanout');
const result = await runPipelineForCandidate(
{ ...options(db), providers: [stubGitHub()] },
{
fullName: 'Alex Chen',
title: 'Staff Engineer',
companyName: 'Loopwright',
identities: [{ network: 'github', handle: 'alexchen' }],
observedAt: new Date().toISOString(),
},
{ capabilities: new SiteProvider().capabilities(), sourceUrl: 'https://loopwright.io/team' },
);
const person = await db.execute({
sql: 'SELECT avatar_url, avatar_source FROM people WHERE id = ?',
args: [result.personId!],
});
expect(person.rows[0]?.avatar_url).toBe(PROFILE.avatar_url);
expect(person.rows[0]?.avatar_source).toBe('github');
const provenance = await db.execute({
sql: "SELECT provider, source_type, source_record_id FROM field_provenance WHERE entity_id = ? AND field = 'avatar_url'",
args: [result.personId!],
});
expect(provenance.rows[0]).toMatchObject({
provider: 'github',
source_type: 'official_api',
source_record_id: PROFILE.html_url,
});
});
test('stores a GitHub portrait and provenance once, and keeps an existing photo', async () => {
const { db } = await fixture('portrait');
const result = await runPipeline(options(db), 'alexchen');
const person = await db.execute({
sql: 'SELECT avatar_url, avatar_source FROM people WHERE id = ?',
args: [result.personId!],
});
expect(person.rows[0]?.avatar_url).toBe(PROFILE.avatar_url);
expect(person.rows[0]?.avatar_source).toBe('github');
await runPipeline(options(db), 'alexchen');
const provenance = await db.execute({
sql: "SELECT value, provider, source_record_id, license_class FROM field_provenance WHERE entity_id = ? AND field = 'avatar_url'",
args: [result.personId!],
});
expect(provenance.rows).toHaveLength(1);
expect(provenance.rows[0]).toMatchObject({
value: PROFILE.avatar_url,
provider: 'github',
source_record_id: PROFILE.html_url,
license_class: 'public_api',
});
await db.execute({
sql: "UPDATE people SET avatar_url = 'https://example.com/chosen.jpg', avatar_source = 'profile' WHERE id = ?",
args: [result.personId!],
});
await runPipeline(options(db), 'alexchen');
const kept = await db.execute({
sql: 'SELECT avatar_url, avatar_source FROM people WHERE id = ?',
args: [result.personId!],
});
expect(kept.rows[0]?.avatar_url).toBe('https://example.com/chosen.jpg');
expect(kept.rows[0]?.avatar_source).toBe('profile');
});
test('takes a bare handle all the way to the approval queue', async () => {
const { db } = await fixture('happy');

Expand Down Expand Up @@ -292,11 +355,20 @@ describe('refusals', () => {
sql: 'DELETE FROM recommendations WHERE person_id = ?',
args: [first.personId!],
});
await db.execute({
sql: 'UPDATE people SET avatar_url = NULL WHERE id = ?',
args: [first.personId!],
});

const second = await runPipeline(options(db), 'alexchen');

expect(second.stage).toBe('stopped');
expect(second.stoppedBecause).toBe('suppressed');
const photo = await db.execute({
sql: 'SELECT avatar_url FROM people WHERE id = ?',
args: [first.personId!],
});
expect(photo.rows[0]?.avatar_url).toBeNull();

const recommendations = await db.execute({
sql: 'SELECT count(*) AS n FROM recommendations WHERE person_id = ?',
Expand Down
8 changes: 8 additions & 0 deletions packages/pipeline/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { generateRecommendation, type CandidateSignal } from '@outreachgraph/rec
import { draftForRecommendation, type TextModel } from '@outreachgraph/ai';
import { rescoreProspect } from './jobs';
import { recordDiscovered, recordStatus } from './stages';
import { storeDiscoveredPhoto } from './photos';

export interface PipelineOptions {
readonly db: Client;
Expand Down Expand Up @@ -187,6 +188,10 @@ export async function runPipelineForCandidate(
};
}

if (candidate.photo) {
await storeDiscoveredPhoto(db, personId, candidate.photo, origin.capabilities, stamp);
}

// -------------------------------------------------------------- resolve
//
// Ask the other configured providers what else they can vouch for before
Expand All @@ -196,6 +201,9 @@ export async function runPipelineForCandidate(
options.providers.length > 0 ? await findIdentities(candidate, options.providers) : undefined;

const enriched = fanned?.candidate ?? candidate;
if (fanned?.photo) {
await storeDiscoveredPhoto(db, personId, fanned.photo.value, fanned.photo.capabilities, stamp);
}

// The page they were named on is itself an identity for them.
//
Expand Down
Loading
Loading