Skip to content

Commit 130914a

Browse files
committed
fix(enrichments): preserve single-part names and output contracts
1 parent ead11a7 commit 130914a

13 files changed

Lines changed: 157 additions & 58 deletions

File tree

‎apps/docs/content/docs/integrations/firecrawl.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ Retrieve the remaining and allocated Firecrawl credits for the team.
456456

457457
### Firecrawl Enrich
458458

459-
Find work emails, mobile numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company and name-based profile lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.
459+
Find work emails, phone numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company, name-based profile, and single-part-name contact lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.
460460

461461
<ReferenceDetails>
462462

@@ -478,13 +478,13 @@ Find work emails, mobile numbers, LinkedIn profiles, company domains, and compan
478478
| --------- | ---- | ----------- |
479479
| `email` | string | Work email, when found |
480480
| `emailVerified` | boolean | True only when FullEnrich reports DELIVERABLE; catch-all and high-probability addresses remain unverified |
481-
| `phone` | string | Mobile number, when found |
481+
| `phone` | string | Phone number, preferring mobile when available |
482482
| `linkedinUrl` | string | LinkedIn profile URL, when found |
483483
| `domain` | string | Company website domain, when found |
484484
| `employeeCount` | string | Company headcount or size range |
485485
| `description` | string | Company description |
486486
| `creditsUsed` | number | Firecrawl credits charged for this lookup |
487-
| `status` | number | Provider or HTTP response status |
487+
| `statusCode` | number | Provider or HTTP response status code |
488488

489489
</ReferenceDetails>
490490

‎apps/sim/blocks/blocks/firecrawl.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const FirecrawlBlock: BlockConfig = {
8686
type: 'dropdown',
8787
options: [
8888
{ label: 'Work Email', id: 'work_email' },
89-
{ label: 'Mobile Number', id: 'phone' },
89+
{ label: 'Phone Number', id: 'phone' },
9090
{ label: 'LinkedIn Profile', id: 'linkedin' },
9191
{ label: 'Company Information', id: 'company' },
9292
{ label: 'Company Domain', id: 'company_domain' },
@@ -111,6 +111,11 @@ export const FirecrawlBlock: BlockConfig = {
111111
title: 'Company Domain',
112112
type: 'short-input',
113113
placeholder: 'example.com',
114+
required: {
115+
field: 'operation',
116+
value: 'enrich',
117+
and: { field: 'enrichmentType', value: 'company' },
118+
},
114119
condition: {
115120
field: 'operation',
116121
value: 'enrich',
@@ -122,6 +127,11 @@ export const FirecrawlBlock: BlockConfig = {
122127
title: 'Company Name',
123128
type: 'short-input',
124129
placeholder: 'Company name',
130+
required: {
131+
field: 'operation',
132+
value: 'enrich',
133+
and: { field: 'enrichmentType', value: 'company_domain' },
134+
},
125135
condition: {
126136
field: 'operation',
127137
value: 'enrich',
@@ -870,11 +880,12 @@ Example 2 - Product Data:
870880
outputs: {
871881
email: { type: 'string', description: 'Work email, when found' },
872882
emailVerified: { type: 'boolean', description: 'Whether the work email is mailbox-verified' },
873-
phone: { type: 'string', description: 'Mobile number, when found' },
883+
phone: { type: 'string', description: 'Phone number, preferring mobile when available' },
874884
linkedinUrl: { type: 'string', description: 'LinkedIn profile URL, when found' },
875885
domain: { type: 'string', description: 'Company website domain, when found' },
876886
employeeCount: { type: 'string', description: 'Company headcount or size range' },
877887
description: { type: 'string', description: 'Company description' },
888+
statusCode: { type: 'number', description: 'Enrichment provider or HTTP response status code' },
878889
// Scrape output
879890
markdown: { type: 'string', description: 'Page content markdown' },
880891
html: { type: 'string', description: 'Raw HTML content' },

‎apps/sim/enrichments/phone-number/phone-number.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Phone } from '@sim/emcn/icons'
2-
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
2+
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
33
import type { EnrichmentConfig } from '@/enrichments/types'
44

55
export const phoneNumberEnrichment: EnrichmentConfig = {
@@ -22,7 +22,7 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
2222
const fullName = str(inputs.fullName)
2323
const companyDomain = normalizeDomain(inputs.companyDomain)
2424
const linkedinUrl = str(inputs.linkedinUrl)
25-
if (!linkedinUrl && !(splitName(fullName) && companyDomain)) return null
25+
if (!linkedinUrl && !(fullName && companyDomain)) return null
2626
return { enrichmentType: 'phone', fullName, companyDomain, linkedinUrl }
2727
},
2828
mapOutput: (output) => {

‎apps/sim/enrichments/providers.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ export function splitName(fullName: unknown): { firstName: string; lastName: str
5252
export function projectEnrichmentProviderFailure(
5353
failure: EnrichmentProviderFailure
5454
): EnrichmentProviderFailureProjection {
55-
if (isRecordLike(failure.output) && failure.output.status === 404) {
55+
if (
56+
isRecordLike(failure.output) &&
57+
(failure.output.statusCode ?? failure.output.status) === 404
58+
) {
5659
return { status: 'no_match' }
5760
}
5861
return { status: 'error', error: failure.error }

‎apps/sim/enrichments/run.test.ts‎

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -50,35 +50,36 @@ beforeEach(() => {
5050
})
5151

5252
describe('runEnrichment cascade detail', () => {
53-
it('records the first match and stops the cascade', async () => {
54-
mockExecuteTool.mockImplementation((toolId: string) => {
55-
if (toolId === 'tool_a') return { success: false, output: { status: 404 } }
56-
if (toolId === 'tool_b')
57-
return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } }
58-
throw new Error('tool_c should never run after a match')
59-
})
60-
61-
const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx)
62-
63-
expect(outcome.result).toEqual({ email: 'j@acme.com' })
64-
expect(outcome.cost).toBe(0.05)
65-
expect(outcome.error).toBeNull()
66-
expect(outcome.provider).toBe('B')
67-
68-
expect(outcome.detail.matchedProvider).toBe('b')
69-
expect(outcome.detail.totalCost).toBe(0.05)
70-
// The full cascade is recorded; the provider after the match is `not_run`.
71-
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
72-
expect(outcome.detail.providers.map((p) => p.status)).toEqual([
73-
'no_match',
74-
'matched',
75-
'not_run',
76-
])
77-
expect(outcome.detail.providers[1]?.cost).toBe(0.05)
78-
expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true)
79-
// The tool is never called for the matched-past provider.
80-
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
81-
})
53+
it.each(['status', 'statusCode'])(
54+
'records a %s miss before the first match',
55+
async (statusField) => {
56+
mockExecuteTool.mockImplementation((toolId: string) => {
57+
if (toolId === 'tool_a') return { success: false, output: { [statusField]: 404 } }
58+
if (toolId === 'tool_b')
59+
return { success: true, output: { email: 'j@acme.com', cost: { total: 0.05 } } }
60+
throw new Error('tool_c should never run after a match')
61+
})
62+
63+
const outcome = await runEnrichment(config([prov('a'), prov('b'), prov('c')]), {}, ctx)
64+
65+
expect(outcome.result).toEqual({ email: 'j@acme.com' })
66+
expect(outcome.cost).toBe(0.05)
67+
expect(outcome.error).toBeNull()
68+
expect(outcome.provider).toBe('B')
69+
70+
expect(outcome.detail.matchedProvider).toBe('b')
71+
expect(outcome.detail.totalCost).toBe(0.05)
72+
expect(outcome.detail.providers.map((p) => p.id)).toEqual(['a', 'b', 'c'])
73+
expect(outcome.detail.providers.map((p) => p.status)).toEqual([
74+
'no_match',
75+
'matched',
76+
'not_run',
77+
])
78+
expect(outcome.detail.providers[1]?.cost).toBe(0.05)
79+
expect(outcome.detail.providers.every((p) => typeof p.durationMs === 'number')).toBe(true)
80+
expect(mockExecuteTool).toHaveBeenCalledTimes(2)
81+
}
82+
)
8283

8384
it('marks providers with insufficient inputs as skipped without calling the tool', async () => {
8485
mockExecuteTool.mockImplementation(() => ({

‎apps/sim/enrichments/work-email/work-email.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Mail } from '@sim/emcn/icons'
2-
import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/providers'
2+
import { normalizeDomain, str, toolProvider } from '@/enrichments/providers'
33
import type { EnrichmentConfig } from '@/enrichments/types'
44

55
export const workEmailEnrichment: EnrichmentConfig = {
@@ -22,7 +22,7 @@ export const workEmailEnrichment: EnrichmentConfig = {
2222
const fullName = str(inputs.fullName)
2323
const companyDomain = normalizeDomain(inputs.companyDomain)
2424
const linkedinUrl = str(inputs.linkedinUrl)
25-
if (!linkedinUrl && !(splitName(fullName) && companyDomain)) return null
25+
if (!linkedinUrl && !(fullName && companyDomain)) return null
2626
return { enrichmentType: 'work_email', fullName, companyDomain, linkedinUrl }
2727
},
2828
mapOutput: (output) => {

‎apps/sim/scripts/test-firecrawl-enrichment-e2e.ts‎

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,38 @@ const person = {
2828
linkedinUrl: requiredEnv('FIRECRAWL_TEST_LINKEDIN_URL'),
2929
email: requiredEnv('FIRECRAWL_TEST_EMAIL'),
3030
}
31+
const profileUrl = new URL(
32+
/^https?:\/\//i.test(person.linkedinUrl)
33+
? person.linkedinUrl
34+
: `https://${person.linkedinUrl.replace(/^\/+/, '')}`
35+
)
36+
const [profileKind, profileSlug] = profileUrl.pathname.split('/').filter(Boolean)
37+
assert.match(profileUrl.hostname, /(^|\.)linkedin\.com$/i)
38+
assert.equal(profileKind?.toLowerCase(), 'in')
39+
assert.ok(profileSlug)
40+
const expectedLinkedinUrl = `https://www.linkedin.com/in/${profileSlug}`
3141

3242
interface LiveCheck {
3343
name: string
3444
config: EnrichmentConfig
3545
inputs: Record<string, string>
36-
verify: (output: Record<string, unknown>) => void
46+
verify?: (output: Record<string, unknown>) => void
47+
expectNoMatch?: boolean
3748
}
3849

3950
const checks: LiveCheck[] = [
51+
{
52+
name: 'single-part-name email lookup returns a clean miss',
53+
config: workEmailEnrichment,
54+
inputs: { fullName: 'AlexandriaNoMatchFixture', companyDomain: 'example.com' },
55+
expectNoMatch: true,
56+
},
57+
{
58+
name: 'single-part-name phone lookup returns a clean miss',
59+
config: phoneNumberEnrichment,
60+
inputs: { fullName: 'AlexandriaNoMatchFixture', companyDomain: 'example.com' },
61+
expectNoMatch: true,
62+
},
4063
{
4164
name: 'work email from name and company',
4265
config: workEmailEnrichment,
@@ -54,13 +77,13 @@ const checks: LiveCheck[] = [
5477
name: 'LinkedIn from email',
5578
config: linkedinProfileEnrichment,
5679
inputs: { fullName: person.fullName, email: person.email },
57-
verify: (output) => assert.equal(output.linkedinUrl, person.linkedinUrl),
80+
verify: (output) => assert.equal(output.linkedinUrl, expectedLinkedinUrl),
5881
},
5982
{
6083
name: 'LinkedIn from name and company',
6184
config: linkedinProfileEnrichment,
6285
inputs: { fullName: person.fullName, companyDomain: person.companyDomain },
63-
verify: (output) => assert.equal(output.linkedinUrl, person.linkedinUrl),
86+
verify: (output) => assert.equal(output.linkedinUrl, expectedLinkedinUrl),
6487
},
6588
{
6689
name: 'company information',
@@ -112,10 +135,21 @@ try {
112135
signal: AbortSignal.timeout(180_000),
113136
})
114137
const result = await transform(response, params)
115-
assert.ok(result.success, result.error)
116-
const mapped = provider.mapOutput(result.output)
117-
assert.ok(mapped, 'Expected a matching record')
118-
check.verify(mapped)
138+
if (check.expectNoMatch) {
139+
assert.equal(result.success, false)
140+
const failure = provider.projectFailure({
141+
error: result.error ?? 'Lookup failed',
142+
output: result.output,
143+
})
144+
assert.equal(failure.status, 'no_match')
145+
assert.equal(result.output.creditsUsed, 0)
146+
} else {
147+
assert.ok(result.success, result.error)
148+
const mapped = provider.mapOutput(result.output)
149+
assert.ok(mapped, 'Expected a matching record')
150+
assert.ok(check.verify)
151+
check.verify(mapped)
152+
}
119153
assert.ok(Number.isFinite(result.output.creditsUsed))
120154
assert.equal(pricing?.type, 'custom')
121155
const cost = pricing?.type === 'custom' ? pricing.getCost(params, result.output) : undefined

‎apps/sim/tools/firecrawl/enrich.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ describe('Alexandria enrichment wire contract', () => {
2626
})
2727
expect(result.success).toBe(false)
2828
expect(result.error).toContain('Accept provider terms')
29-
expect(result.output.status).toBe(403)
29+
expect(result.output.statusCode).toBe(403)
3030
})
3131

3232
it('preserves a provider 404 for the enrichment runner to classify as no match', async () => {
@@ -35,7 +35,7 @@ describe('Alexandria enrichment wire contract', () => {
3535
data: { creditsCost: 0, alexandria: [{ error: { message: 'No match', status: 404 } }] },
3636
})
3737
expect(result.success).toBe(false)
38-
expect(result.output.status).toBe(404)
38+
expect(result.output.statusCode).toBe(404)
3939
})
4040

4141
it('rejects malformed success envelopes instead of reporting a clean miss', async () => {
@@ -126,4 +126,27 @@ describe('Alexandria enrichment wire contract', () => {
126126
expect(result.output.email).toBeNull()
127127
expect(result.output.creditsUsed).toBe(0)
128128
})
129+
130+
it('maps PDL contact records without claiming mailbox verification', async () => {
131+
const result = await transform({
132+
success: true,
133+
data: {
134+
creditsCost: 60,
135+
alexandria: [
136+
{
137+
provider: 'people-data-labs',
138+
capability: 'person/enrich',
139+
data: {
140+
data: { work_email: 'person@example.com', mobile_phone: '+12025550123' },
141+
},
142+
},
143+
],
144+
},
145+
})
146+
expect(result.output).toMatchObject({
147+
email: 'person@example.com',
148+
emailVerified: false,
149+
phone: '+12025550123',
150+
})
151+
})
129152
})

‎apps/sim/tools/firecrawl/enrich.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,25 @@ function enrichmentCall(params: FirecrawlEnrichParams) {
1616
case 'work_email':
1717
case 'phone': {
1818
const name = splitName(fullName)
19-
if (!linkedinUrl && !(name && (companyDomain || companyName))) {
19+
const company = companyDomain || companyName
20+
if (!linkedinUrl && !(fullName && company)) {
2021
throw new Error('Provide a LinkedIn URL or a full name and company for contact enrichment')
2122
}
23+
if (!linkedinUrl && !name) {
24+
const fields =
25+
params.enrichmentType === 'phone' ? ['mobile_phone', 'phone_numbers'] : ['work_email']
26+
return {
27+
provider: 'people-data-labs',
28+
capability: 'person/enrich',
29+
options: {
30+
name: fullName,
31+
company,
32+
min_likelihood: 6,
33+
required: fields.join(' OR '),
34+
data_include: fields.join(','),
35+
},
36+
}
37+
}
2238
return {
2339
provider: 'fullenrich',
2440
capability: params.enrichmentType === 'phone' ? 'contacts/mobile' : 'contacts/work-email',
@@ -82,7 +98,7 @@ export const firecrawlEnrichTool: ToolConfig<FirecrawlEnrichParams, FirecrawlEnr
8298
id: 'firecrawl_enrich',
8399
name: 'Firecrawl Enrich',
84100
description:
85-
'Find work emails, mobile numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company and name-based profile lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.',
101+
'Find work emails, phone numbers, LinkedIn profiles, company domains, and company information through Firecrawl Alexandria. Uses FullEnrich for contact lookups and People Data Labs for company, name-based profile, and single-part-name contact lookups. Provider terms must be enabled in Firecrawl. People Data Labs has a 50,000-record annual team limit.',
86102
version: '1.0.0',
87103
hosting: firecrawlHosting<FirecrawlEnrichParams>(),
88104
params: {
@@ -153,15 +169,15 @@ export const firecrawlEnrichTool: ToolConfig<FirecrawlEnrichParams, FirecrawlEnr
153169
employeeCount: null,
154170
description: null,
155171
creditsUsed: 0,
156-
status: typeof error.status === 'number' ? error.status : response.status,
172+
statusCode: typeof error.status === 'number' ? error.status : response.status,
157173
}
158174
if (!response.ok || envelope.success !== true || result.error) {
159175
return {
160176
success: false,
161177
error:
162178
toStringOrNull(error.message) ??
163179
toStringOrNull(envelope.error) ??
164-
`Firecrawl enrichment failed (${output.status})`,
180+
`Firecrawl enrichment failed (${output.statusCode})`,
165181
output,
166182
}
167183
}
@@ -190,6 +206,13 @@ export const firecrawlEnrichTool: ToolConfig<FirecrawlEnrichParams, FirecrawlEnr
190206
output.linkedinUrl = toStringOrNull(social.url)
191207
} else if (result.provider === 'people-data-labs') {
192208
const person = toRecord(record.data)
209+
output.email = toStringOrNull(person.work_email)
210+
output.phone =
211+
toStringOrNull(person.mobile_phone) ||
212+
toArray(person.phone_numbers)
213+
.map(toStringOrNull)
214+
.find((phone) => phone?.trim()) ||
215+
null
193216
output.linkedinUrl = toStringOrNull(person.linkedin_url)
194217
output.domain = toStringOrNull(record.website)
195218
output.employeeCount =
@@ -209,7 +232,11 @@ export const firecrawlEnrichTool: ToolConfig<FirecrawlEnrichParams, FirecrawlEnr
209232
description:
210233
'True only when FullEnrich reports DELIVERABLE; catch-all and high-probability addresses remain unverified',
211234
},
212-
phone: { type: 'string', description: 'Mobile number, when found', optional: true },
235+
phone: {
236+
type: 'string',
237+
description: 'Phone number, preferring mobile when available',
238+
optional: true,
239+
},
213240
linkedinUrl: {
214241
type: 'string',
215242
description: 'LinkedIn profile URL, when found',
@@ -223,6 +250,6 @@ export const firecrawlEnrichTool: ToolConfig<FirecrawlEnrichParams, FirecrawlEnr
223250
},
224251
description: { type: 'string', description: 'Company description', optional: true },
225252
creditsUsed: { type: 'number', description: 'Firecrawl credits charged for this lookup' },
226-
status: { type: 'number', description: 'Provider or HTTP response status' },
253+
statusCode: { type: 'number', description: 'Provider or HTTP response status code' },
227254
},
228255
}

0 commit comments

Comments
 (0)