@coldiq/mcp 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ import type { Capability } from '../registry.js'
2
+ import { providerDisplayName } from './provider-display.js'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Selection insight — a short, human-readable, DATA-QUALITY-framed reason for
6
+ // why ColdIQ routed a request to a given provider. Surfaced in `_meta` so the
7
+ // chat layer can show the "we picked the best tool" intelligence.
8
+ //
9
+ // Rules baked in here (see CLAUDE.md + the plan):
10
+ // • Data-quality reasons ONLY — coverage / specialization / freshness / region.
11
+ // NEVER price, never "tried first", never waterfall position.
12
+ // • Honest about the two ways a provider wins:
13
+ // - capability routing (others gated out) → confident "Routed to X for …"
14
+ // - fallthrough (others returned nothing) → softened "X returned the match …"
15
+ // • Never fabricate a strength: when a provider has no curated edge we emit a
16
+ // plain, true line ("X matched this …") instead of inventing specialization.
17
+ // • Display names only (no raw slugs, no "Apify") — via providerDisplayName.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type InsightCapability = Capability | 'find_emails'
21
+
22
+ export interface SelectionInsight {
23
+ insight: string
24
+ signals: string[]
25
+ }
26
+
27
+ export interface InsightContext {
28
+ /** True when the chosen provider won only because higher-ranked providers
29
+ * returned no rows (not because it was the best-suited for the inputs). */
30
+ wasFallback: boolean
31
+ /** True when the caller pinned this provider via `use_providers`. */
32
+ pinnedByUser: boolean
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Curated data-quality strengths, keyed by capability → providerId.
37
+ // Each value is a noun phrase that slots into "… for its <strength>". Phrasing
38
+ // is descriptive (deep / strong / broad / specialized), NOT superlative, so it
39
+ // stays defensible on the fallthrough path too.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const STRENGTHS: Partial<Record<InsightCapability, Record<string, string>>> = {
43
+ search_companies: {
44
+ companyenrich: 'broad firmographic coverage with full-text company matching',
45
+ apollo: 'an extensive global company database',
46
+ fullenrich: 'rich firmographic data with strong international coverage',
47
+ pdl: 'a large, structured global company dataset',
48
+ signalbase: 'fast firmographic search across keywords and industries',
49
+ blitzapi: 'LinkedIn-sourced company data',
50
+ limadata: 'LinkedIn-based company discovery',
51
+ predictleads: 'discovery scoped tightly by location and company size',
52
+ theirstack: 'deep technographic and buying-intent coverage',
53
+ sumble: 'technology- and keyword-driven company discovery',
54
+ 'limadata-prospect-filter': 'LinkedIn headcount-bucketed prospecting',
55
+ 'limadata-prospect-url': 'discovery straight from a LinkedIn Sales Navigator search',
56
+ 'linkupapi-search': 'live LinkedIn company search',
57
+ 'linkupapi-fundraising': 'a dedicated index of recently-funded companies',
58
+ 'linkupapi-hiring': 'a dedicated index of actively-hiring companies',
59
+ 'prospeo-search-company': 'keyword, industry and geo company search',
60
+ 'ai-ark-companies': 'multi-filter company discovery',
61
+ },
62
+ find_people: {
63
+ leadsfactory: 'company-scoped contact discovery by persona',
64
+ apollo: 'an extensive global contact database',
65
+ pdl: 'a large, structured global people dataset',
66
+ companyenrich: 'domain-scoped employee lookups',
67
+ 'linkupapi-search-profiles': 'live LinkedIn profile search',
68
+ 'sumble-people-find': 'org-scoped people search by job function',
69
+ 'prospeo-search-person': 'title- and company-based people search',
70
+ 'ai-ark-people': 'multi-filter people discovery',
71
+ 'fullenrich-people-search': 'domain-scoped people search',
72
+ 'findymail-search-employees': 'domain-scoped employee discovery',
73
+ },
74
+ find_email: {
75
+ prospeo: 'strong work-email coverage',
76
+ fullenrich: 'strong international email coverage, especially across Europe',
77
+ findymail: 'high-accuracy verified work emails',
78
+ icypeas: 'broad email-finding coverage',
79
+ 'limadata-work-email': 'name-and-domain work-email lookup',
80
+ blitzapi: 'LinkedIn-URL–based email finding',
81
+ 'limadata-work-email-linkedin': 'LinkedIn-URL–based work-email lookup',
82
+ linkupapi: 'LinkedIn-sourced email finding',
83
+ },
84
+ find_emails: {
85
+ prospeo: 'strong work-email coverage',
86
+ fullenrich: 'strong international email coverage, especially across Europe',
87
+ findymail: 'high-accuracy verified work emails',
88
+ icypeas: 'broad email-finding coverage',
89
+ 'limadata-work-email': 'name-and-domain work-email lookup',
90
+ blitzapi: 'LinkedIn-URL–based email finding',
91
+ 'limadata-work-email-linkedin': 'LinkedIn-URL–based work-email lookup',
92
+ linkupapi: 'LinkedIn-sourced email finding',
93
+ },
94
+ verify_email: {
95
+ findymail: 'reliable deliverability verification',
96
+ icypeas: 'broad email verification',
97
+ instantly: 'deliverability-focused verification',
98
+ 'linkupapi-validate': 'LinkedIn-aware email validation',
99
+ },
100
+ find_phone: {
101
+ findymail: 'verified mobile-number coverage',
102
+ limadata: 'phone lookup by LinkedIn URL or name + company',
103
+ 'ai-ark': 'phone lookup across multiple identifiers',
104
+ },
105
+ enrich_company: {
106
+ companyenrich: 'broad firmographic enrichment from a domain',
107
+ apollo: 'deep firmographic enrichment',
108
+ pdl: 'structured firmographic data',
109
+ findymail: 'domain-based company enrichment',
110
+ wiza: 'company enrichment from name or domain',
111
+ limadata: 'LinkedIn-based company enrichment',
112
+ prospeo: 'company enrichment',
113
+ companyenrich_props: 'company enrichment from name or LinkedIn',
114
+ blitzapi: 'LinkedIn-URL company enrichment',
115
+ icypeas: 'LinkedIn-URL company enrichment',
116
+ builtwith: 'technology-stack detection for a domain',
117
+ openmart: 'local-business firmographics',
118
+ 'linkupapi-by-domain': 'live LinkedIn company data',
119
+ 'linkupapi-by-url': 'live LinkedIn company data',
120
+ },
121
+ enrich_person: {
122
+ 'linkupapi-profile-enrich': 'live LinkedIn profile enrichment',
123
+ 'linkupapi-email-reverse': 'reverse-email lookup from LinkedIn data',
124
+ 'pdl-person-enrich': 'structured person enrichment',
125
+ 'apollo-people-match': 'deep person enrichment',
126
+ 'blitzapi-reverse-email': 'reverse-email profile lookup',
127
+ 'findymail-business-profile': 'LinkedIn profile enrichment',
128
+ 'findymail-reverse-email': 'reverse-email lookup',
129
+ 'icypeas-scrape-profile': 'LinkedIn profile scraping',
130
+ 'icypeas-url-search-profile': 'profile lookup by name and company',
131
+ 'ai-ark-reverse-lookup': 'reverse lookup from email or phone',
132
+ 'icypeas-reverse-email-lookup': 'reverse-email lookup',
133
+ 'pdl-person-identify': 'person identity resolution',
134
+ },
135
+ search_web: {
136
+ serper: 'fast Google search results',
137
+ exa: 'neural, meaning-based web search',
138
+ limadata: 'general web search',
139
+ jina: 'web search and page reading',
140
+ },
141
+ search_jobs: {
142
+ career_site_jobs: 'jobs sourced directly from company career sites',
143
+ linkedin_jobs_api: 'LinkedIn job listings',
144
+ 'theirstack-jobs': 'jobs enriched with company and tech-stack data',
145
+ },
146
+ search_ads: {
147
+ google_ads: 'Google ad-transparency data',
148
+ linkedin_ad_library: 'LinkedIn Ad Library coverage',
149
+ meta_ads: 'Meta (Facebook/Instagram) ad-library coverage',
150
+ twitter_ads: 'X ad coverage',
151
+ reddit_ads: 'Reddit ad coverage',
152
+ },
153
+ search_places: {
154
+ openmart: 'rich local-business data across the US, CA, AU, PR and NZ',
155
+ google_maps: 'broad global places coverage from Google Maps',
156
+ },
157
+ get_place_reviews: {
158
+ google_maps_reviews: 'Google Maps review data',
159
+ },
160
+ find_influencers: {
161
+ influencers_similar: 'lookalike creator discovery from a seed handle',
162
+ influencers_discovery: 'creator discovery by topic and audience',
163
+ },
164
+ search_reddit: {
165
+ reddit: 'Reddit post and comment search',
166
+ },
167
+ find_signals: {
168
+ 'signalbase-funding': 'real-time funding-round signals',
169
+ 'signalbase-acquisition': 'acquisition signals',
170
+ 'signalbase-hiring': 'hiring signals',
171
+ 'signalbase-job-change': 'job-change signals',
172
+ 'theirstack-hiring': 'hiring signals from job-posting data',
173
+ 'theirstack-intent-discovery': 'buying-intent discovery',
174
+ 'theirstack-buying-intents': 'buying-intent signals from tech and job data',
175
+ 'predictleads-financing': 'financing-event signals',
176
+ 'predictleads-news': 'company-news signals',
177
+ 'predictleads-startup-posts': 'startup-announcement signals',
178
+ },
179
+ fetch_page_content: {
180
+ 'exa-contents': 'clean page-content extraction',
181
+ },
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Per-capability noun used in the plain fallback line ("X matched this <noun>").
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const CAPABILITY_NOUN: Record<InsightCapability, string> = {
189
+ search_companies: 'company search',
190
+ find_people: 'people search',
191
+ find_email: 'email lookup',
192
+ find_emails: 'email lookup',
193
+ verify_email: 'email verification',
194
+ find_phone: 'phone lookup',
195
+ enrich_company: 'company enrichment',
196
+ enrich_person: 'person enrichment',
197
+ search_web: 'web search',
198
+ search_jobs: 'job search',
199
+ search_ads: 'ad search',
200
+ search_places: 'places search',
201
+ get_place_reviews: 'reviews lookup',
202
+ find_influencers: 'influencer search',
203
+ search_reddit: 'Reddit search',
204
+ search_seo: 'SEO lookup',
205
+ find_signals: 'signal search',
206
+ fetch_page_content: 'page fetch',
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Signal detection — which input dimensions drove routing, in human terms.
211
+ // Ordered most-distinctive-first so signals[0] is the routing-relevant one.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function arr(v: unknown): boolean {
215
+ return Array.isArray(v) && v.length > 0
216
+ }
217
+ function num(v: unknown): boolean {
218
+ return typeof v === 'number'
219
+ }
220
+ function str(v: unknown): boolean {
221
+ return typeof v === 'string' && v.length > 0
222
+ }
223
+
224
+ function detectSignals(capability: InsightCapability, input: Record<string, unknown>): string[] {
225
+ const out: string[] = []
226
+ const push = (cond: boolean, label: string) => {
227
+ if (cond && !out.includes(label)) out.push(label)
228
+ }
229
+
230
+ switch (capability) {
231
+ case 'search_companies':
232
+ push(arr(input.technologies), 'tech-stack filter')
233
+ push(
234
+ arr(input.funding_stages) ||
235
+ num(input.min_funding_amount) || num(input.max_funding_amount) ||
236
+ num(input.min_funding_year) || num(input.max_funding_year),
237
+ 'funding filter',
238
+ )
239
+ push(num(input.min_revenue) || num(input.max_revenue), 'revenue filter')
240
+ push(arr(input.exclude_domains) || arr(input.exclude_industries) || arr(input.exclude_countries), 'exclusion list')
241
+ push(num(input.min_workforce_growth_pct), 'workforce-growth filter')
242
+ push(input.is_hiring === true, 'hiring signal')
243
+ push(num(input.min_founded_year) || num(input.max_founded_year), 'founding-year filter')
244
+ push(num(input.min_employees) || num(input.max_employees), 'company-size filter')
245
+ push(str(input.linkedin_search_url), 'LinkedIn Sales Navigator URL')
246
+ push(arr(input.industries), 'industry filter')
247
+ push(arr(input.keywords), 'keyword search')
248
+ push(arr(input.countries) || arr(input.locations), 'geo filter')
249
+ break
250
+ case 'find_people':
251
+ push(arr(input.company_domains), 'company-domain filter')
252
+ push(arr(input.company_linkedin_urls), 'company LinkedIn filter')
253
+ push(arr(input.job_titles), 'job-title filter')
254
+ push(arr(input.seniorities), 'seniority filter')
255
+ push(arr(input.keywords), 'keyword search')
256
+ push(arr(input.locations), 'geo filter')
257
+ break
258
+ case 'find_email':
259
+ push(str(input.linkedin_url), 'LinkedIn URL')
260
+ push(str(input.domain) || str(input.company_name), 'name + company')
261
+ break
262
+ case 'find_emails': {
263
+ const people = (input.people as Array<Record<string, unknown>> | undefined) ?? []
264
+ push(people.some((p) => str(p.linkedin_url)), 'LinkedIn URLs')
265
+ push(people.some((p) => str(p.domain)), 'name + domain')
266
+ break
267
+ }
268
+ case 'enrich_company':
269
+ push(str(input.domain), 'company domain')
270
+ push(str(input.linkedin_url), 'LinkedIn URL')
271
+ push(str(input.name), 'company name')
272
+ break
273
+ case 'enrich_person':
274
+ push(str(input.email), 'email')
275
+ push(str(input.linkedin_url), 'LinkedIn URL')
276
+ push(str(input.first_name) && str(input.last_name), 'name + company')
277
+ break
278
+ case 'find_signals':
279
+ if (str(input.signal_type)) out.push(`${String(input.signal_type)} signals`)
280
+ push(arr(input.companies) || arr(input.domains), 'company filter')
281
+ break
282
+ case 'search_jobs':
283
+ push(arr(input.technologies), 'tech-stack filter')
284
+ push(arr(input.company_domains), 'company-domain filter')
285
+ push(arr(input.job_titles) || arr(input.keywords), 'role filter')
286
+ push(arr(input.locations), 'geo filter')
287
+ break
288
+ case 'search_ads':
289
+ push(arr(input.search_urls), 'LinkedIn Ad Library URL')
290
+ push(arr(input.domains) || arr(input.advertiser_ids), 'advertiser filter')
291
+ push(str(input.query), 'keyword search')
292
+ break
293
+ default:
294
+ // Other capabilities: no curated signal vocabulary — routing is single-source
295
+ // or non-discriminating, so leave signals empty and rely on the base strength.
296
+ break
297
+ }
298
+ return out
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Builder
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Build a data-quality-framed reason for why `providerId` was selected for
307
+ * `capability` given `input`. Returns undefined only when there is genuinely
308
+ * nothing safe to say.
309
+ */
310
+ export function buildSelectionInsight(
311
+ capability: InsightCapability,
312
+ providerId: string,
313
+ input: Record<string, unknown>,
314
+ ctx: InsightContext,
315
+ ): SelectionInsight | undefined {
316
+ const name = providerDisplayName(providerId)
317
+ const signals = detectSignals(capability, input)
318
+
319
+ // User pinned this provider — it wasn't our choice, so don't claim it was.
320
+ if (ctx.pinnedByUser) {
321
+ return { insight: `Used ${name} as requested.`, signals }
322
+ }
323
+
324
+ const strength = STRENGTHS[capability]?.[providerId]
325
+ const noun = CAPABILITY_NOUN[capability] ?? 'request'
326
+ const primary = signals[0]
327
+
328
+ // No curated edge for this provider — emit a plain, true line, never invent one.
329
+ if (!strength) {
330
+ const insight = primary
331
+ ? `${name} matched your ${primary} for this ${noun}.`
332
+ : `${name} matched this ${noun}.`
333
+ return { insight, signals }
334
+ }
335
+
336
+ // Fallthrough: higher-ranked providers returned nothing. Soften — state the
337
+ // coverage fact without implying we judged it the single best fit upfront.
338
+ if (ctx.wasFallback) {
339
+ return { insight: `${name} returned the match here, with ${strength}.`, signals }
340
+ }
341
+
342
+ // Capability routing: chosen because it is best-suited to these inputs.
343
+ const insight = primary
344
+ ? `Routed to ${name} for its ${strength}, matched to your ${primary}.`
345
+ : `Routed to ${name} for its ${strength}.`
346
+ return { insight, signals }
347
+ }
@@ -954,3 +954,80 @@ describe('executor upstream_error propagation', () => {
954
954
  }
955
955
  })
956
956
  })
957
+
958
+ // ---------------------------------------------------------------------------
959
+ // Selection insight + branded provider name in _meta
960
+ // ---------------------------------------------------------------------------
961
+
962
+ describe('executor selection insight _meta', () => {
963
+ const originalFetch = globalThis.fetch
964
+
965
+ beforeEach(() => {
966
+ initClient('http://test-api.local', 'test-key-123')
967
+ })
968
+
969
+ afterEach(() => {
970
+ globalThis.fetch = originalFetch
971
+ })
972
+
973
+ it('attaches branded provider_name + confident insight when first applicable provider wins', async () => {
974
+ stubProviders([
975
+ makeProvider({ id: 'theirstack', hasResult: (d) => (d as Record<string, unknown>).ok === true }),
976
+ ])
977
+ globalThis.fetch = vi.fn(async () =>
978
+ new Response(JSON.stringify({ ok: true }), { status: 200 }),
979
+ ) as typeof fetch
980
+
981
+ const result = await executeWithFallback('search_companies', { technologies: ['Salesforce'] })
982
+
983
+ expect('data' in result).toBe(true)
984
+ if ('data' in result) {
985
+ expect(result._meta.provider).toBe('theirstack')
986
+ expect(result._meta.provider_name).toBe('TheirStack')
987
+ expect(result._meta.selection_insight).toContain('Routed to TheirStack')
988
+ expect(result._meta.selection_insight).toContain('tech-stack filter')
989
+ expect(result._meta.selection_signals).toContain('tech-stack filter')
990
+ }
991
+ })
992
+
993
+ it('softens the insight when the winner is reached only after a provider fails', async () => {
994
+ stubProviders([
995
+ makeProvider({ id: 'companyenrich', hasResult: () => false }),
996
+ makeProvider({ id: 'fullenrich', hasResult: (d) => (d as Record<string, unknown>).ok === true }),
997
+ ])
998
+ let n = 0
999
+ globalThis.fetch = vi.fn(async () => {
1000
+ n++
1001
+ return new Response(JSON.stringify({ ok: n > 1 }), { status: 200 })
1002
+ }) as typeof fetch
1003
+
1004
+ const result = await executeWithFallback('search_companies', { keywords: ['fintech'] })
1005
+
1006
+ expect('data' in result).toBe(true)
1007
+ if ('data' in result) {
1008
+ expect(result._meta.provider_name).toBe('FullEnrich')
1009
+ expect(result._meta.selection_insight).toContain('returned the match here')
1010
+ expect(result._meta.selection_insight!.startsWith('Routed to')).toBe(false)
1011
+ }
1012
+ })
1013
+
1014
+ it('says "as requested" when the provider was pinned by the caller', async () => {
1015
+ stubProviders([
1016
+ makeProvider({ id: 'theirstack', hasResult: (d) => (d as Record<string, unknown>).ok === true }),
1017
+ ])
1018
+ globalThis.fetch = vi.fn(async () =>
1019
+ new Response(JSON.stringify({ ok: true }), { status: 200 }),
1020
+ ) as typeof fetch
1021
+
1022
+ const result = await executeWithFallback(
1023
+ 'search_companies',
1024
+ { technologies: ['Salesforce'] },
1025
+ { providers: ['theirstack'] },
1026
+ )
1027
+
1028
+ expect('data' in result).toBe(true)
1029
+ if ('data' in result) {
1030
+ expect(result._meta.selection_insight).toBe('Used TheirStack as requested.')
1031
+ }
1032
+ })
1033
+ })
@@ -43,8 +43,8 @@ describe('find_emails handler (bulk)', () => {
43
43
  expect(parsed.data.found).toBe(2)
44
44
  expect(parsed.data.total).toBe(2)
45
45
  expect(parsed.data.results).toEqual([
46
- { id: 'p1', email: 'alice@example.com', provider: 'prospeo' },
47
- { id: 'p2', email: 'bob@example.com', provider: 'prospeo' },
46
+ { id: 'p1', email: 'alice@example.com', provider: 'prospeo', provider_name: 'Prospeo' },
47
+ { id: 'p2', email: 'bob@example.com', provider: 'prospeo', provider_name: 'Prospeo' },
48
48
  ])
49
49
  })
50
50
 
@@ -77,7 +77,7 @@ describe('find_emails handler (bulk)', () => {
77
77
 
78
78
  expect(fmCalled).toBe(true)
79
79
  const parsed = JSON.parse(result.content[0].text)
80
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'findymail' })
80
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'findymail', provider_name: 'Findymail' })
81
81
  })
82
82
 
83
83
  it('falls back to IcyPeas when FindyMail also misses', async () => {
@@ -103,7 +103,7 @@ describe('find_emails handler (bulk)', () => {
103
103
  })
104
104
 
105
105
  const parsed = JSON.parse(result.content[0].text)
106
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'icypeas' })
106
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'icypeas', provider_name: 'Icypeas' })
107
107
  })
108
108
 
109
109
  it('returns email:null when all providers miss for a person', async () => {
@@ -117,7 +117,7 @@ describe('find_emails handler (bulk)', () => {
117
117
 
118
118
  const parsed = JSON.parse(result.content[0].text)
119
119
  expect(parsed.data.found).toBe(0)
120
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: null, provider: null })
120
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: null, provider: null, provider_name: null })
121
121
  })
122
122
 
123
123
  it('handles mixed results: some from prospeo, some from fallback, some missed', async () => {
@@ -241,7 +241,7 @@ describe('find_emails handler (bulk)', () => {
241
241
  const body = feCreateBody as { data: Array<Record<string, unknown>> }
242
242
  expect(body.data[0].custom_id).toBe('p1')
243
243
  const parsed = JSON.parse(result.content[0].text)
244
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich' })
244
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'alice@example.com', provider: 'fullenrich', provider_name: 'FullEnrich' })
245
245
  })
246
246
 
247
247
  it('parallel branches: faster FindyMail wins, slower FullEnrich does not overwrite', async () => {
@@ -284,7 +284,7 @@ describe('find_emails handler (bulk)', () => {
284
284
 
285
285
  const parsed = JSON.parse(result.content[0].text)
286
286
  // FindyMail won the race — its email + provider must persist
287
- expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'fm@example.com', provider: 'findymail' })
287
+ expect(parsed.data.results[0]).toEqual({ id: 'p1', email: 'fm@example.com', provider: 'findymail', provider_name: 'Findymail' })
288
288
  })
289
289
 
290
290
  it('gracefully handles prospeo bulk failure and still tries fallbacks', async () => {
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { providerDisplayName } from '../../src/utils/provider-display.js'
3
+
4
+ describe('providerDisplayName', () => {
5
+ it('maps known vendor ids to branded names', () => {
6
+ expect(providerDisplayName('fullenrich')).toBe('FullEnrich')
7
+ expect(providerDisplayName('theirstack')).toBe('TheirStack')
8
+ expect(providerDisplayName('pdl')).toBe('People Data Labs')
9
+ })
10
+
11
+ it('collapses vendor variants to one brand', () => {
12
+ expect(providerDisplayName('limadata-work-email')).toBe('LimaData')
13
+ expect(providerDisplayName('limadata-prospect-url')).toBe('LimaData')
14
+ expect(providerDisplayName('signalbase-funding')).toBe('SignalBase')
15
+ expect(providerDisplayName('linkupapi-by-domain')).toBe('LinkupAPI')
16
+ })
17
+
18
+ it('brands Apify-backed scrapers as their data source, never raw slug', () => {
19
+ expect(providerDisplayName('reddit_ads')).toBe('Reddit Ads')
20
+ expect(providerDisplayName('linkedin_ad_library')).toBe('LinkedIn Ad Library')
21
+ expect(providerDisplayName('google_maps')).toBe('Google Maps')
22
+ })
23
+
24
+ it('title-cases unmapped ids as a defensive fallback', () => {
25
+ expect(providerDisplayName('some-new-vendor')).toBe('Some New Vendor')
26
+ expect(providerDisplayName('good')).toBe('Good')
27
+ })
28
+
29
+ it('never returns a raw slug or the word "Apify"', () => {
30
+ const ids = [
31
+ 'limadata-work-email', 'reddit_ads', 'fullenrich', 'ai-ark-people',
32
+ 'linkupapi-validate', 'theirstack-buying-intents', 'completely_unknown_slug',
33
+ ]
34
+ for (const id of ids) {
35
+ const name = providerDisplayName(id)
36
+ expect(name).not.toContain('_')
37
+ expect(name.toLowerCase()).not.toContain('apify')
38
+ }
39
+ })
40
+ })
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSelectionInsight } from '../../src/utils/selection-insight.js'
3
+
4
+ describe('buildSelectionInsight', () => {
5
+ describe('capability routing (gated-in, not fallback)', () => {
6
+ it('ties the choice to the distinctive input signal', () => {
7
+ const r = buildSelectionInsight(
8
+ 'search_companies',
9
+ 'theirstack',
10
+ { technologies: ['Salesforce'], keywords: ['saas'] },
11
+ { wasFallback: false, pinnedByUser: false },
12
+ )
13
+ expect(r).toBeDefined()
14
+ expect(r!.insight).toContain('TheirStack')
15
+ expect(r!.insight).toContain('technographic')
16
+ // tech-stack is the most distinctive signal → drives the "matched to your …" clause
17
+ expect(r!.insight).toContain('tech-stack filter')
18
+ expect(r!.insight.startsWith('Routed to')).toBe(true)
19
+ expect(r!.signals[0]).toBe('tech-stack filter')
20
+ })
21
+
22
+ it('omits the signal clause when no distinctive input is present', () => {
23
+ const r = buildSelectionInsight(
24
+ 'enrich_company',
25
+ 'apollo',
26
+ {},
27
+ { wasFallback: false, pinnedByUser: false },
28
+ )
29
+ expect(r!.insight).toBe('Routed to Apollo for its deep firmographic enrichment.')
30
+ expect(r!.signals).toEqual([])
31
+ })
32
+ })
33
+
34
+ describe('fallthrough (softened wording)', () => {
35
+ it('states coverage without claiming it was the upfront best fit', () => {
36
+ const r = buildSelectionInsight(
37
+ 'find_email',
38
+ 'fullenrich',
39
+ { domain: 'coldiq.com', first_name: 'Michel' },
40
+ { wasFallback: true, pinnedByUser: false },
41
+ )
42
+ expect(r!.insight).toContain('FullEnrich')
43
+ expect(r!.insight).toContain('returned the match here')
44
+ // never the confident "Routed to" framing on a fallthrough
45
+ expect(r!.insight.startsWith('Routed to')).toBe(false)
46
+ })
47
+ })
48
+
49
+ describe('user-pinned providers', () => {
50
+ it('does not claim ColdIQ chose it', () => {
51
+ const r = buildSelectionInsight(
52
+ 'search_companies',
53
+ 'theirstack',
54
+ { technologies: ['Salesforce'] },
55
+ { wasFallback: false, pinnedByUser: true },
56
+ )
57
+ expect(r!.insight).toBe('Used TheirStack as requested.')
58
+ // signals still surfaced for UI
59
+ expect(r!.signals).toContain('tech-stack filter')
60
+ })
61
+ })
62
+
63
+ describe('no curated edge — plain true line, no fabrication', () => {
64
+ it('emits a generic line for an unmapped provider', () => {
65
+ const r = buildSelectionInsight(
66
+ 'enrich_company',
67
+ 'good', // synthetic id, not in STRENGTHS
68
+ { domain: 'coldiq.com' },
69
+ { wasFallback: false, pinnedByUser: false },
70
+ )
71
+ expect(r!.insight).toBe('Good matched your company domain for this company enrichment.')
72
+ // no invented "deep/strong/broad coverage" claim
73
+ expect(r!.insight).not.toMatch(/deep|strong|broad|coverage|specializ/)
74
+ })
75
+
76
+ it('falls back to the barest true line with no signals', () => {
77
+ const r = buildSelectionInsight(
78
+ 'search_reddit',
79
+ 'mystery_provider',
80
+ {},
81
+ { wasFallback: false, pinnedByUser: false },
82
+ )
83
+ expect(r!.insight).toBe('Mystery Provider matched this Reddit search.')
84
+ })
85
+ })
86
+
87
+ describe('find_emails batch signals', () => {
88
+ it('detects LinkedIn URLs and name+domain across people', () => {
89
+ const r = buildSelectionInsight(
90
+ 'find_emails',
91
+ 'fullenrich',
92
+ { people: [{ id: '1', first_name: 'Michel', domain: 'coldiq.com', linkedin_url: 'https://linkedin.com/in/x' }] },
93
+ { wasFallback: true, pinnedByUser: false },
94
+ )
95
+ expect(r!.insight).toContain('FullEnrich')
96
+ expect(r!.signals).toContain('LinkedIn URLs')
97
+ expect(r!.signals).toContain('name + domain')
98
+ })
99
+ })
100
+
101
+ describe('honesty — fallthrough never claims an upfront best-fit judgment', () => {
102
+ it('uses the softened framing and no superiority superlatives', () => {
103
+ const r = buildSelectionInsight(
104
+ 'find_people',
105
+ 'apollo',
106
+ { job_titles: ['CEO'] },
107
+ { wasFallback: true, pinnedByUser: false },
108
+ )
109
+ expect(r!.insight).toContain('returned the match here')
110
+ expect(r!.insight.startsWith('Routed to')).toBe(false)
111
+ expect(r!.insight.toLowerCase()).not.toMatch(/\bbest\b|deepest|largest/)
112
+ })
113
+ })
114
+ })