Skip to content

Commit 2ab9a4c

Browse files
feat(enrichments): provider fallback cascade + hosted-key usage source
Replace each enrichment's single enrich() with an ordered providers[] fallback cascade. Providers are plain data ({ id, label, toolId, buildParams, mapOutput }) so the catalog stays client-safe; the server-only runner (run.ts) calls executeTool per provider, first non-empty result wins, misses/errors fall through, all-miss = blank cell. Wire four enrichments on the hosted-safe providers (Hunter, PDL): - Work Email (fullName, companyDomain): Hunter -> PDL - Phone Number (fullName, companyDomain): PDL - Company Domain (companyName): PDL - Company Info (domain): PDL -> Hunter Person enrichments take a single canonical fullName (Clay-style); Hunter gets first/last via splitName(), PDL takes name directly. Add 'enrichment' to usage_log_source enum (+ migration) so hosted-key tool cost from these per-row calls can be billed to the table owner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1f4d4d commit 2ab9a4c

17 files changed

Lines changed: 18240 additions & 54 deletions

File tree

apps/sim/background/workflow-column-execution.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ async function runWorkflowAndWriteTerminal(
118118
// workflow, reusing the same pickup → run → terminal-write status flow.
119119
if (group.type === 'enrichment') {
120120
const { getEnrichment } = await import('@/enrichments/registry')
121+
const { runEnrichment } = await import('@/enrichments/run')
121122
const enrichment = getEnrichment(group.enrichmentId)
122123
// `tableRowExecutions.workflowId` is an opaque id for status; use the
123124
// enrichment id for enrichment cells.
@@ -173,7 +174,7 @@ async function runWorkflowAndWriteTerminal(
173174

174175
try {
175176
if (signal?.aborted) return 'error'
176-
const result = await enrichment.enrich(enrichInputs, {
177+
const result = await runEnrichment(enrichment, enrichInputs, {
177178
tableId,
178179
rowId,
179180
workspaceId,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Globe } from 'lucide-react'
2+
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
3+
import type { EnrichmentConfig } from '@/enrichments/types'
4+
5+
/**
6+
* Company Domain enrichment. Resolves a company's website domain from its name
7+
* via a People Data Labs company match.
8+
*/
9+
export const companyDomainEnrichment: EnrichmentConfig = {
10+
id: 'company-domain',
11+
name: 'Company Domain',
12+
description: "Find a company's website domain from its name.",
13+
icon: Globe,
14+
inputs: [{ id: 'companyName', name: 'Company name', type: 'string', required: true }],
15+
outputs: [{ id: 'domain', name: 'domain', type: 'string' }],
16+
providers: [
17+
toolProvider({
18+
id: 'pdl',
19+
label: 'People Data Labs',
20+
toolId: 'pdl_company_enrich',
21+
buildParams: (inputs) => {
22+
const name = str(inputs.companyName)
23+
if (!name) return null
24+
return { name }
25+
},
26+
mapOutput: (output) => {
27+
const company = output.company as Record<string, unknown> | undefined
28+
const domain = normalizeDomain(company?.website)
29+
return domain ? { domain } : null
30+
},
31+
}),
32+
],
33+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { companyDomainEnrichment } from './company-domain'
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { filterUndefined } from '@sim/utils/object'
2+
import { Building2 } from 'lucide-react'
3+
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
4+
import type { EnrichmentConfig } from '@/enrichments/types'
5+
6+
/** Returns the value when it's a finite number, else `undefined`. */
7+
function num(value: unknown): number | undefined {
8+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
9+
}
10+
11+
/**
12+
* Company Info enrichment. Looks up firmographics for a company domain, trying
13+
* People Data Labs first (richest record, incl. employee count) then Hunter as
14+
* a fallback.
15+
*/
16+
export const companyInfoEnrichment: EnrichmentConfig = {
17+
id: 'company-info',
18+
name: 'Company Info',
19+
description:
20+
"Look up a company's industry, size, founding year, and description from its domain.",
21+
icon: Building2,
22+
inputs: [{ id: 'domain', name: 'Company domain', type: 'string', required: true }],
23+
outputs: [
24+
{ id: 'industry', name: 'industry', type: 'string' },
25+
{ id: 'employeeCount', name: 'employee count', type: 'number' },
26+
{ id: 'foundedYear', name: 'founded year', type: 'number' },
27+
{ id: 'description', name: 'description', type: 'string' },
28+
],
29+
providers: [
30+
toolProvider({
31+
id: 'pdl',
32+
label: 'People Data Labs',
33+
toolId: 'pdl_company_enrich',
34+
buildParams: (inputs) => {
35+
const website = normalizeDomain(inputs.domain)
36+
if (!website) return null
37+
return { website }
38+
},
39+
mapOutput: (output) => {
40+
const company = output.company as Record<string, unknown> | undefined
41+
return filterUndefined({
42+
industry: str(company?.industry) || undefined,
43+
employeeCount: num(company?.employee_count),
44+
foundedYear: num(company?.founded),
45+
description: str(company?.summary) || undefined,
46+
})
47+
},
48+
}),
49+
toolProvider({
50+
id: 'hunter',
51+
label: 'Hunter',
52+
toolId: 'hunter_companies_find',
53+
buildParams: (inputs) => {
54+
const domain = normalizeDomain(inputs.domain)
55+
if (!domain) return null
56+
return { domain }
57+
},
58+
mapOutput: (output) => {
59+
return filterUndefined({
60+
industry: str(output.industry) || undefined,
61+
foundedYear: num(output.founded_year),
62+
description: str(output.description) || undefined,
63+
})
64+
},
65+
}),
66+
],
67+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { companyInfoEnrichment } from './company-info'

apps/sim/enrichments/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export type {
33
EnrichmentConfig,
44
EnrichmentInputField,
55
EnrichmentOutputField,
6+
EnrichmentProvider,
67
EnrichmentRegistry,
78
EnrichmentRunContext,
89
} from './types'
Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { filterUndefined } from '@sim/utils/object'
12
import { Phone } from 'lucide-react'
3+
import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments/providers'
24
import type { EnrichmentConfig } from '@/enrichments/types'
35

46
/**
5-
* Phone Number enrichment. v1 is a stub returning no number (the value resolves
6-
* empty) — wiring a real data provider (with credentials) is a follow-up. The
7-
* inputs/outputs contract is in place so the pipeline + UI work end to end.
7+
* Phone Number enrichment. Finds a contact's phone number from their full name
8+
* and (optionally) company domain via a People Data Labs person match.
89
*/
910
export const phoneNumberEnrichment: EnrichmentConfig = {
1011
id: 'phone-number',
@@ -16,12 +17,25 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
1617
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
1718
],
1819
outputs: [{ id: 'phone', name: 'phone', type: 'string' }],
19-
async enrich(inputs) {
20-
const fullName = String(inputs.fullName ?? '').trim()
21-
if (!fullName) {
22-
throw new Error('Full name is required')
23-
}
24-
// TODO: call a real phone-lookup provider via ctx; returns empty for now.
25-
return { phone: '' }
26-
},
20+
providers: [
21+
toolProvider({
22+
id: 'pdl',
23+
label: 'People Data Labs',
24+
toolId: 'pdl_person_enrich',
25+
buildParams: (inputs) => {
26+
const name = str(inputs.fullName)
27+
if (!name) return null
28+
return filterUndefined({
29+
name,
30+
company: normalizeDomain(inputs.companyDomain) || undefined,
31+
min_likelihood: 6,
32+
})
33+
},
34+
mapOutput: (output) => {
35+
const person = output.person as Record<string, unknown> | undefined
36+
const phone = firstNonEmpty(person?.phone_numbers) ?? str(person?.mobile_phone)
37+
return phone ? { phone } : null
38+
},
39+
}),
40+
],
2741
}

apps/sim/enrichments/providers.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { EnrichmentProvider } from '@/enrichments/types'
2+
3+
/** Coerces an unknown input value to a trimmed string (`''` when nullish). */
4+
export function str(value: unknown): string {
5+
return String(value ?? '').trim()
6+
}
7+
8+
/** Strips protocol / path / leading `www.` from a domain-ish input. */
9+
export function normalizeDomain(value: unknown): string {
10+
return str(value)
11+
.toLowerCase()
12+
.replace(/^https?:\/\//, '')
13+
.replace(/^www\./, '')
14+
.replace(/\/.*$/, '')
15+
}
16+
17+
/** Returns the first non-empty string in an array (or `undefined`). */
18+
export function firstNonEmpty(value: unknown): string | undefined {
19+
if (!Array.isArray(value)) return undefined
20+
for (const item of value) {
21+
const s = str(item)
22+
if (s) return s
23+
}
24+
return undefined
25+
}
26+
27+
/**
28+
* Splits a full name into first / last for providers whose API requires both
29+
* (e.g. Hunter). Returns `null` when the name has fewer than two parts, so the
30+
* provider falls through to one that accepts a single name string.
31+
*/
32+
export function splitName(fullName: unknown): { firstName: string; lastName: string } | null {
33+
const parts = str(fullName).split(/\s+/).filter(Boolean)
34+
if (parts.length < 2) return null
35+
return { firstName: parts[0], lastName: parts.slice(1).join(' ') }
36+
}
37+
38+
/**
39+
* Declares a tool-backed enrichment provider as plain data. Keeping this free of
40+
* any `@/tools` reference (the cascade runner does the `executeTool` call) means
41+
* the enrichment catalog stays client-safe — the table UI imports it only for
42+
* metadata. Workspace scope and BYOK / hosted-key injection are handled by the
43+
* runner when it executes `toolId`.
44+
*/
45+
export function toolProvider(provider: EnrichmentProvider): EnrichmentProvider {
46+
return provider
47+
}

apps/sim/enrichments/registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import { companyDomainEnrichment } from '@/enrichments/company-domain'
2+
import { companyInfoEnrichment } from '@/enrichments/company-info'
13
import { phoneNumberEnrichment } from '@/enrichments/phone-number'
24
import type { EnrichmentConfig, EnrichmentRegistry } from '@/enrichments/types'
35
import { workEmailEnrichment } from '@/enrichments/work-email'
46

57
export const ENRICHMENT_REGISTRY: EnrichmentRegistry = {
68
[workEmailEnrichment.id]: workEmailEnrichment,
79
[phoneNumberEnrichment.id]: phoneNumberEnrichment,
10+
[companyDomainEnrichment.id]: companyDomainEnrichment,
11+
[companyInfoEnrichment.id]: companyInfoEnrichment,
812
}
913

1014
/** All enrichments, in catalog order. */

apps/sim/enrichments/run.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { EnrichmentConfig, EnrichmentRunContext } from '@/enrichments/types'
4+
import { executeTool } from '@/tools'
5+
6+
const logger = createLogger('Enrichments')
7+
8+
/** True when at least one output value in the result is non-empty. */
9+
function hasResult(result: Record<string, unknown>): boolean {
10+
return Object.values(result).some((v) => v !== undefined && v !== null && v !== '')
11+
}
12+
13+
/**
14+
* Runs an enrichment's provider cascade for one row. Tries providers in order;
15+
* the first that returns a non-empty result wins and is returned. A provider is
16+
* skipped when its `buildParams` returns `null` (insufficient inputs); a tool
17+
* failure or empty mapped result falls through to the next. When every provider
18+
* misses, returns `{}` — the caller writes a blank (not errored) cell.
19+
*
20+
* Server-only: imports `executeTool`, which pulls in DB / mailer code. Only the
21+
* background cell executor imports this module (dynamically).
22+
*/
23+
export async function runEnrichment(
24+
enrichment: EnrichmentConfig,
25+
inputs: Record<string, unknown>,
26+
ctx: EnrichmentRunContext
27+
): Promise<Record<string, unknown>> {
28+
for (const provider of enrichment.providers) {
29+
if (ctx.signal?.aborted) break
30+
const params = provider.buildParams(inputs)
31+
if (!params) continue
32+
try {
33+
const response = await executeTool(
34+
provider.toolId,
35+
{ ...params, _context: { workspaceId: ctx.workspaceId } },
36+
{ signal: ctx.signal }
37+
)
38+
if (!response.success) {
39+
throw new Error(response.error ?? `${provider.toolId} failed`)
40+
}
41+
const result = provider.mapOutput(response.output)
42+
if (result && hasResult(result)) {
43+
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
44+
return result
45+
}
46+
} catch (err) {
47+
logger.warn('Enrichment provider failed; trying next', {
48+
enrichmentId: enrichment.id,
49+
provider: provider.id,
50+
error: getErrorMessage(err),
51+
})
52+
}
53+
}
54+
return {}
55+
}

0 commit comments

Comments
 (0)