attio-mcp 1.1.1 → 1.1.2
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.
- package/CHANGELOG.md +31 -0
- package/README.md +3 -2
- package/dist/api/operations/search.d.ts +5 -2
- package/dist/api/operations/search.d.ts.map +1 -1
- package/dist/api/operations/search.js +469 -9
- package/dist/api/operations/search.js.map +1 -1
- package/dist/handlers/tool-configs/openai/index.d.ts +2 -2
- package/dist/handlers/tool-configs/openai/index.d.ts.map +1 -1
- package/dist/handlers/tool-configs/openai/index.js +13 -2
- package/dist/handlers/tool-configs/openai/index.js.map +1 -1
- package/dist/handlers/tool-configs/universal/core/crud-operations.d.ts.map +1 -1
- package/dist/handlers/tool-configs/universal/core/crud-operations.js +22 -3
- package/dist/handlers/tool-configs/universal/core/crud-operations.js.map +1 -1
- package/dist/handlers/tool-configs/universal/index.d.ts +2 -2
- package/dist/handlers/tool-configs/universal/index.d.ts.map +1 -1
- package/dist/handlers/tool-configs/universal/index.js +7 -1
- package/dist/handlers/tool-configs/universal/index.js.map +1 -1
- package/dist/handlers/tools/registry.d.ts +2 -2
- package/dist/objects/deals/index.d.ts +1 -0
- package/dist/objects/deals/index.d.ts.map +1 -1
- package/dist/objects/deals/index.js +2 -0
- package/dist/objects/deals/index.js.map +1 -1
- package/dist/objects/deals/search.d.ts +16 -0
- package/dist/objects/deals/search.d.ts.map +1 -0
- package/dist/objects/deals/search.js +59 -0
- package/dist/objects/deals/search.js.map +1 -0
- package/dist/services/CachingService.d.ts +57 -0
- package/dist/services/CachingService.d.ts.map +1 -1
- package/dist/services/CachingService.js +112 -0
- package/dist/services/CachingService.js.map +1 -1
- package/dist/services/UniversalSearchService.d.ts +0 -12
- package/dist/services/UniversalSearchService.d.ts.map +1 -1
- package/dist/services/UniversalSearchService.js +19 -133
- package/dist/services/UniversalSearchService.js.map +1 -1
- package/dist/services/search-strategies/CompanySearchStrategy.d.ts.map +1 -1
- package/dist/services/search-strategies/CompanySearchStrategy.js +13 -11
- package/dist/services/search-strategies/CompanySearchStrategy.js.map +1 -1
- package/dist/services/search-strategies/DealSearchStrategy.d.ts +38 -0
- package/dist/services/search-strategies/DealSearchStrategy.d.ts.map +1 -0
- package/dist/services/search-strategies/DealSearchStrategy.js +119 -0
- package/dist/services/search-strategies/DealSearchStrategy.js.map +1 -0
- package/dist/services/search-strategies/NoteSearchStrategy.d.ts +62 -0
- package/dist/services/search-strategies/NoteSearchStrategy.d.ts.map +1 -0
- package/dist/services/search-strategies/NoteSearchStrategy.js +224 -0
- package/dist/services/search-strategies/NoteSearchStrategy.js.map +1 -0
- package/dist/services/search-strategies/PeopleSearchStrategy.d.ts.map +1 -1
- package/dist/services/search-strategies/PeopleSearchStrategy.js +13 -15
- package/dist/services/search-strategies/PeopleSearchStrategy.js.map +1 -1
- package/dist/services/search-strategies/index.d.ts +2 -0
- package/dist/services/search-strategies/index.d.ts.map +1 -1
- package/dist/services/search-strategies/index.js +2 -0
- package/dist/services/search-strategies/index.js.map +1 -1
- package/dist/services/search-strategies/interfaces.d.ts +6 -0
- package/dist/services/search-strategies/interfaces.d.ts.map +1 -1
- package/dist/services/search-utilities/SearchScorer.d.ts +4 -0
- package/dist/services/search-utilities/SearchScorer.d.ts.map +1 -0
- package/dist/services/search-utilities/SearchScorer.js +243 -0
- package/dist/services/search-utilities/SearchScorer.js.map +1 -0
- package/dist/services/search-utilities/SearchUtilities.d.ts +5 -0
- package/dist/services/search-utilities/SearchUtilities.d.ts.map +1 -1
- package/dist/services/search-utilities/SearchUtilities.js +20 -0
- package/dist/services/search-utilities/SearchUtilities.js.map +1 -1
- package/dist/smithery.d.ts +1 -1
- package/dist/smithery.d.ts.map +1 -1
- package/dist/smithery.js +10 -8
- package/dist/smithery.js.map +1 -1
- package/dist/utils/mcp-discovery.d.ts.map +1 -1
- package/dist/utils/mcp-discovery.js +19 -1
- package/dist/utils/mcp-discovery.js.map +1 -1
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
17
|
|
|
18
18
|
### Deprecated
|
|
19
19
|
|
|
20
|
+
## [1.1.2] - 2025-10-08
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Multi-token search query logic** - Fixed fallback search to require ALL tokens in multi-word queries instead of ANY token
|
|
25
|
+
- **Problem**: Multi-word searches (e.g., "Alpha Beta Company") returned 100+ records matching ANY token ("Alpha" OR "Beta" OR "Company"), pushing exact matches out of results
|
|
26
|
+
- **Solution**: Multi-token queries now use AND-of-OR structure - each token must match somewhere (in name OR domains), but all tokens must match (cross-field flexibility with precision)
|
|
27
|
+
- **Result**: Multi-word name searches now return exact matches in top results instead of being buried at position #101+
|
|
28
|
+
- Single-token queries unchanged (still use simple `$contains`)
|
|
29
|
+
- This improves precision for company/people searches with multi-word names
|
|
30
|
+
- **records_search relevance** (#885) - Added client-side scoring with uFuzzy and LRU caching so exact domain/name/email matches rank first while repeated queries stay within the 3 s budget
|
|
31
|
+
- **Deal search fast path optimization** (#885) - Extended fast path optimization to deals with 72-80% performance improvement
|
|
32
|
+
- Sequential candidate execution: exact match ($eq) → substring match ($contains)
|
|
33
|
+
- LRU caching with 5-minute TTL delivers 8ms cached responses (99.9% faster than ~2.8s cold searches)
|
|
34
|
+
- Created `DealSearchStrategy` following same pattern as companies/people
|
|
35
|
+
- Removed obsolete `searchDeals()` and `queryDealRecords()` methods (100+ lines)
|
|
36
|
+
- Fixed original bug where deals only supported exact name matches
|
|
37
|
+
- **Notes search functionality - Partial fix with API limitation** (#888)
|
|
38
|
+
- Created `NoteSearchStrategy` following established search strategy pattern
|
|
39
|
+
- **API Limitation Discovered**: Attio Notes API `/v2/notes` endpoint returns empty array when called without filters
|
|
40
|
+
- Confirmed via direct API testing: `GET /v2/notes` returns `{"data": []}`
|
|
41
|
+
- API requires `parent_object` and/or `parent_record_id` filters to return notes
|
|
42
|
+
- This prevents searching across all notes in a workspace
|
|
43
|
+
- **What Works**: `list-notes` tool with parent record filtering (e.g., list all notes on a specific company)
|
|
44
|
+
- **What Doesn't Work**: Global note search without parent filters (e.g., "find all notes containing 'demo'")
|
|
45
|
+
- **Workaround**: Use `list-notes` with explicit parent filters to search within a specific record's notes
|
|
46
|
+
- Implemented client-side filtering for title/content when parent filters are provided
|
|
47
|
+
- Added smart caching (30s TTL) with parent-aware cache keys to prevent cross-contamination
|
|
48
|
+
- Removed obsolete `searchNotes()` fallback method (85 lines)
|
|
49
|
+
- **Recommendation**: Request Attio to add workspace-wide notes endpoint or search capability
|
|
50
|
+
|
|
20
51
|
## [1.1.0] - 2025-10-06
|
|
21
52
|
|
|
22
53
|
This release enhances developer experience with intelligent prompts, comprehensive tool standardization, and strengthens enterprise readiness with security hardening and validation improvements.
|
package/README.md
CHANGED
|
@@ -177,8 +177,8 @@ For complete prompt documentation, see [docs/prompts/v1-catalog.md](./docs/promp
|
|
|
177
177
|
### 🤝 **OpenAI MCP Compatibility**
|
|
178
178
|
|
|
179
179
|
- **Developer Mode Ready**: Every tool now publishes MCP safety annotations (`readOnlyHint`, `destructiveHint`) so OpenAI Developer Mode can auto-approve reads and request confirmation for writes.
|
|
180
|
-
- **
|
|
181
|
-
- **
|
|
180
|
+
- **Full Tool Access (Default)**: All 33 universal tools are exposed by default. Do NOT set `ATTIO_MCP_TOOL_MODE` in Smithery configuration for full access.
|
|
181
|
+
- **Search-Only Mode**: To restrict to read-only tools (`search`, `fetch`, `aaa-health-check`), explicitly configure `ATTIO_MCP_TOOL_MODE: 'search'` in Smithery dashboard when Developer Mode is unavailable.
|
|
182
182
|
- **Detailed Guide**: See [docs/chatgpt-developer-mode.md](./docs/chatgpt-developer-mode.md) for environment variables, approval flows, and validation tips.
|
|
183
183
|
- **User Documentation**: See the [ChatGPT Developer Mode docs](./docs/chatgpt-developer-mode.md) for a complete walkthrough of approval flows and setup instructions.
|
|
184
184
|
|
|
@@ -608,6 +608,7 @@ Comprehensive documentation is available in the [docs directory](./docs):
|
|
|
608
608
|
|
|
609
609
|
- [Warning Filter Configuration](./docs/configuration/warning-filters.md) - Understanding cosmetic vs semantic mismatches, ESLint budgets, and suppression strategies
|
|
610
610
|
- [Field Verification Configuration](./docs/configuration/field-verification.md) - Field persistence verification and validation settings
|
|
611
|
+
- [Search Scoring Configuration](./docs/configuration/search-scoring.md) - Environment variables for relevance scoring, caching, and operator validation
|
|
611
612
|
|
|
612
613
|
### **API Reference**
|
|
613
614
|
|
|
@@ -10,10 +10,13 @@ import { AttioRecord, ResourceType } from '../../types/attio.js';
|
|
|
10
10
|
*
|
|
11
11
|
* @param objectType - The type of object to search (people or companies)
|
|
12
12
|
* @param query - Search query string
|
|
13
|
-
* @param
|
|
13
|
+
* @param options - Optional search options (limit, retryConfig)
|
|
14
14
|
* @returns Array of matching records
|
|
15
15
|
*/
|
|
16
|
-
export declare function searchObject<T extends AttioRecord>(objectType: ResourceType, query: string,
|
|
16
|
+
export declare function searchObject<T extends AttioRecord>(objectType: ResourceType, query: string, options?: {
|
|
17
|
+
limit?: number;
|
|
18
|
+
retryConfig?: Partial<RetryConfig>;
|
|
19
|
+
}): Promise<T[]>;
|
|
17
20
|
/**
|
|
18
21
|
* Generic function to search any object type with advanced filtering capabilities
|
|
19
22
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/api/operations/search.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAK5D,OAAO,EACL,WAAW,EACX,YAAY,EAEb,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../src/api/operations/search.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAiB,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAK5D,OAAO,EACL,WAAW,EACX,YAAY,EAEb,MAAM,wBAAwB,CAAC;AAyhBhC;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAAC,CAAC,SAAS,WAAW,EACtD,UAAU,EAAE,YAAY,EACxB,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAA;CAAE,GAC/D,OAAO,CAAC,CAAC,EAAE,CAAC,CA4Ld;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,CAAC,SAAS,WAAW,EAC9D,UAAU,EAAE,YAAY,EACxB,OAAO,CAAC,EAAE,gBAAgB,EAC1B,KAAK,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACf,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CAuId;AAED;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,CAAC,SAAS,WAAW,EACrD,UAAU,EAAE,YAAY,EACxB,KAAK,CAAC,EAAE,MAAM,EACd,WAAW,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GACjC,OAAO,CAAC,CAAC,EAAE,CAAC,CA0Bd"}
|
|
@@ -9,6 +9,248 @@ import { FilterValidationError } from '../../errors/api-errors.js';
|
|
|
9
9
|
import { ErrorEnhancer } from '../../errors/enhanced-api-errors.js';
|
|
10
10
|
import { transformFiltersToApiFormat } from '../../utils/record-utils.js';
|
|
11
11
|
import { ResourceType, } from '../../types/attio.js';
|
|
12
|
+
import { LRUCache } from 'lru-cache';
|
|
13
|
+
import { scoreAndRank } from '../../services/search-utilities/SearchScorer.js';
|
|
14
|
+
import { createScopedLogger } from '../../utils/logger.js';
|
|
15
|
+
const logger = createScopedLogger('api.operations', 'search');
|
|
16
|
+
const ENABLE_SEARCH_SCORING = process.env.ENABLE_SEARCH_SCORING !== 'false';
|
|
17
|
+
const SEARCH_CACHE_TTL_MS = Number.parseInt(process.env.SEARCH_CACHE_TTL_MS ?? `${5 * 60 * 1000}`, 10);
|
|
18
|
+
const SEARCH_CACHE_MAX = Number.parseInt(process.env.SEARCH_CACHE_MAX ?? '500', 10);
|
|
19
|
+
const SEARCH_FETCH_MULTIPLIER = Number.parseInt(process.env.SEARCH_FETCH_MULTIPLIER ?? '5', 10);
|
|
20
|
+
const SEARCH_FETCH_MIN = Number.parseInt(process.env.SEARCH_FETCH_MIN ?? '50', 10);
|
|
21
|
+
const SEARCH_FAST_PATH_LIMIT = Number.parseInt(process.env.SEARCH_FAST_PATH_LIMIT ?? '5', 10);
|
|
22
|
+
const DEFAULT_FETCH_LIMIT = Number.parseInt(process.env.SEARCH_DEFAULT_LIMIT ?? '20', 10);
|
|
23
|
+
const searchCache = new LRUCache({
|
|
24
|
+
max: Number.isFinite(SEARCH_CACHE_MAX) ? SEARCH_CACHE_MAX : 500,
|
|
25
|
+
ttl: Number.isFinite(SEARCH_CACHE_TTL_MS)
|
|
26
|
+
? SEARCH_CACHE_TTL_MS
|
|
27
|
+
: 5 * 60 * 1000,
|
|
28
|
+
});
|
|
29
|
+
function getCacheKey(objectType, query, limit) {
|
|
30
|
+
return `${objectType}:${limit}:${query.toLowerCase()}`;
|
|
31
|
+
}
|
|
32
|
+
function normalizeDomainValue(value) {
|
|
33
|
+
return value
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/^https?:\/\//, '')
|
|
36
|
+
.replace(/^www\./, '')
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
function normalizePlainValue(value) {
|
|
40
|
+
return value.toLowerCase().trim();
|
|
41
|
+
}
|
|
42
|
+
function extractStringsFromField(fieldValue, keys = []) {
|
|
43
|
+
const results = [];
|
|
44
|
+
const pushString = (candidate) => {
|
|
45
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
46
|
+
results.push(candidate);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
if (!fieldValue) {
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
if (typeof fieldValue === 'string') {
|
|
53
|
+
pushString(fieldValue);
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(fieldValue)) {
|
|
57
|
+
fieldValue.forEach((item) => {
|
|
58
|
+
if (typeof item === 'string') {
|
|
59
|
+
pushString(item);
|
|
60
|
+
}
|
|
61
|
+
else if (item && typeof item === 'object') {
|
|
62
|
+
keys.forEach((key) => {
|
|
63
|
+
pushString(item[key]);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
return results;
|
|
68
|
+
}
|
|
69
|
+
if (fieldValue && typeof fieldValue === 'object') {
|
|
70
|
+
keys.forEach((key) => {
|
|
71
|
+
pushString(fieldValue[key]);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
function extractNormalizedName(record) {
|
|
77
|
+
const values = (record?.values ?? {});
|
|
78
|
+
const candidates = [];
|
|
79
|
+
if ('name' in values) {
|
|
80
|
+
candidates.push(values.name);
|
|
81
|
+
}
|
|
82
|
+
if ('full_name' in values) {
|
|
83
|
+
candidates.push(values.full_name);
|
|
84
|
+
}
|
|
85
|
+
if ('title' in values) {
|
|
86
|
+
candidates.push(values.title);
|
|
87
|
+
}
|
|
88
|
+
const normalizeCandidate = (candidate) => {
|
|
89
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
90
|
+
return normalizePlainValue(candidate);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(candidate)) {
|
|
93
|
+
for (const item of candidate) {
|
|
94
|
+
const normalized = normalizeCandidate(item);
|
|
95
|
+
if (normalized) {
|
|
96
|
+
return normalized;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (candidate && typeof candidate === 'object') {
|
|
101
|
+
const obj = candidate;
|
|
102
|
+
const nested = obj.full_name ?? obj.formatted ?? obj.value ?? obj.name;
|
|
103
|
+
if (typeof nested === 'string' && nested.trim()) {
|
|
104
|
+
return normalizePlainValue(nested);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
const normalized = normalizeCandidate(candidate);
|
|
111
|
+
if (normalized) {
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Build fast-path candidates for structured queries.
|
|
119
|
+
*
|
|
120
|
+
* Issue #885: Attio API supports $eq operator (verified 2025-10-07)
|
|
121
|
+
* Supported operators: $eq, $contains, $starts_with, $ends_with, $not_empty
|
|
122
|
+
* NOT supported: $equals, $in
|
|
123
|
+
*
|
|
124
|
+
* @see scripts/test-attio-operators.mjs for operator verification tests
|
|
125
|
+
*/
|
|
126
|
+
function buildFastPathCandidates(objectType, parsed, originalQuery) {
|
|
127
|
+
const candidates = [];
|
|
128
|
+
const trimmedQuery = originalQuery.trim();
|
|
129
|
+
if (objectType === ResourceType.COMPANIES && parsed.domains.length === 1) {
|
|
130
|
+
const normalizedDomain = normalizeDomainValue(parsed.domains[0]);
|
|
131
|
+
candidates.push({
|
|
132
|
+
filter: { domains: { $eq: normalizedDomain } },
|
|
133
|
+
kind: 'domain',
|
|
134
|
+
strategy: 'eq',
|
|
135
|
+
value: normalizedDomain,
|
|
136
|
+
});
|
|
137
|
+
candidates.push({
|
|
138
|
+
filter: { domains: { $contains: normalizedDomain } },
|
|
139
|
+
kind: 'domain',
|
|
140
|
+
strategy: 'contains',
|
|
141
|
+
value: normalizedDomain,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (parsed.emails.length === 1) {
|
|
145
|
+
const emailValue = parsed.emails[0].toLowerCase();
|
|
146
|
+
candidates.push({
|
|
147
|
+
filter: { email_addresses: { $eq: emailValue } },
|
|
148
|
+
kind: 'email',
|
|
149
|
+
strategy: 'eq',
|
|
150
|
+
value: emailValue,
|
|
151
|
+
});
|
|
152
|
+
candidates.push({
|
|
153
|
+
filter: { email_addresses: { $contains: emailValue } },
|
|
154
|
+
kind: 'email',
|
|
155
|
+
strategy: 'contains',
|
|
156
|
+
value: emailValue,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
// Try all phone variants as fast path candidates (parser creates normalized forms)
|
|
160
|
+
if (parsed.phones.length > 0) {
|
|
161
|
+
parsed.phones.forEach((phoneValue) => {
|
|
162
|
+
candidates.push({
|
|
163
|
+
filter: { phone_numbers: { $eq: phoneValue } },
|
|
164
|
+
kind: 'phone',
|
|
165
|
+
strategy: 'eq',
|
|
166
|
+
value: phoneValue,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const hasStructuredQuery = parsed.domains.length > 0 ||
|
|
171
|
+
parsed.emails.length > 0 ||
|
|
172
|
+
parsed.phones.length > 0;
|
|
173
|
+
if (trimmedQuery &&
|
|
174
|
+
!hasStructuredQuery &&
|
|
175
|
+
(objectType === ResourceType.COMPANIES ||
|
|
176
|
+
objectType === ResourceType.PEOPLE ||
|
|
177
|
+
objectType === ResourceType.DEALS)) {
|
|
178
|
+
const normalizedName = normalizePlainValue(trimmedQuery);
|
|
179
|
+
candidates.push({
|
|
180
|
+
filter: { name: { $eq: trimmedQuery } },
|
|
181
|
+
kind: 'name',
|
|
182
|
+
strategy: 'eq',
|
|
183
|
+
value: normalizedName,
|
|
184
|
+
});
|
|
185
|
+
candidates.push({
|
|
186
|
+
filter: { name: { $contains: trimmedQuery } },
|
|
187
|
+
kind: 'name',
|
|
188
|
+
strategy: 'contains',
|
|
189
|
+
value: normalizedName,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return candidates;
|
|
193
|
+
}
|
|
194
|
+
function recordMatchesFastPath(record, candidate) {
|
|
195
|
+
const values = (record?.values ?? {});
|
|
196
|
+
switch (candidate.kind) {
|
|
197
|
+
case 'domain': {
|
|
198
|
+
const domains = extractStringsFromField(values.domains, [
|
|
199
|
+
'domain',
|
|
200
|
+
'value',
|
|
201
|
+
]).map(normalizeDomainValue);
|
|
202
|
+
if (candidate.strategy === 'eq') {
|
|
203
|
+
return domains.includes(candidate.value);
|
|
204
|
+
}
|
|
205
|
+
return domains.some((domain) => domain.includes(candidate.value));
|
|
206
|
+
}
|
|
207
|
+
case 'email': {
|
|
208
|
+
const emails = extractStringsFromField(values.email_addresses, [
|
|
209
|
+
'email_address',
|
|
210
|
+
'value',
|
|
211
|
+
]).map(normalizePlainValue);
|
|
212
|
+
if (candidate.strategy === 'eq') {
|
|
213
|
+
return emails.includes(candidate.value);
|
|
214
|
+
}
|
|
215
|
+
return emails.some((email) => email.includes(candidate.value));
|
|
216
|
+
}
|
|
217
|
+
case 'phone': {
|
|
218
|
+
const phones = extractStringsFromField(values.phone_numbers, [
|
|
219
|
+
'phone_number', // Main normalized field
|
|
220
|
+
'original_phone_number', // Original input
|
|
221
|
+
'number', // Legacy fallback
|
|
222
|
+
'normalized', // Legacy fallback
|
|
223
|
+
'value', // Legacy fallback
|
|
224
|
+
]);
|
|
225
|
+
const normalizedSet = new Set();
|
|
226
|
+
phones.forEach((phone) => {
|
|
227
|
+
normalizedSet.add(phone);
|
|
228
|
+
normalizedSet.add(phone.replace(/\s+/g, ''));
|
|
229
|
+
// Also try with/without leading +
|
|
230
|
+
if (phone.startsWith('+')) {
|
|
231
|
+
normalizedSet.add(phone.substring(1));
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
normalizedSet.add(`+${phone}`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return (normalizedSet.has(candidate.value) ||
|
|
238
|
+
normalizedSet.has(candidate.value.replace(/\s+/g, '')));
|
|
239
|
+
}
|
|
240
|
+
case 'name': {
|
|
241
|
+
const recordName = extractNormalizedName(record);
|
|
242
|
+
if (!recordName) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
if (candidate.strategy === 'eq') {
|
|
246
|
+
return recordName === candidate.value;
|
|
247
|
+
}
|
|
248
|
+
return recordName.includes(candidate.value);
|
|
249
|
+
}
|
|
250
|
+
default:
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
12
254
|
function createLegacyFilter(objectType, query) {
|
|
13
255
|
if (objectType === ResourceType.PEOPLE) {
|
|
14
256
|
return {
|
|
@@ -28,12 +270,12 @@ function addCondition(collection, seen, condition) {
|
|
|
28
270
|
collection.push(condition);
|
|
29
271
|
}
|
|
30
272
|
}
|
|
31
|
-
function buildSearchFilter(objectType, query) {
|
|
273
|
+
function buildSearchFilter(objectType, query, parsedOverride) {
|
|
32
274
|
const trimmedQuery = query.trim();
|
|
33
275
|
if (!trimmedQuery) {
|
|
34
276
|
return createLegacyFilter(objectType, query);
|
|
35
277
|
}
|
|
36
|
-
const parsed = parseQuery(trimmedQuery);
|
|
278
|
+
const parsed = parsedOverride ?? parseQuery(trimmedQuery);
|
|
37
279
|
const conditions = [];
|
|
38
280
|
const seen = new Set();
|
|
39
281
|
parsed.emails.forEach((email) => {
|
|
@@ -63,6 +305,99 @@ function buildSearchFilter(objectType, query) {
|
|
|
63
305
|
tokenTargets.add('domains');
|
|
64
306
|
}
|
|
65
307
|
const uniqueTokens = Array.from(new Set(parsed.tokens.filter((token) => token.length > 1)));
|
|
308
|
+
// AND-of-OR search strategy (#885 fix):
|
|
309
|
+
// For each token, allow it to match ANY field (name OR domains OR email/phone)
|
|
310
|
+
// But ALL tokens must match SOMEWHERE
|
|
311
|
+
// This provides precision (all tokens required) + cross-field flexibility (name + domain tokens)
|
|
312
|
+
//
|
|
313
|
+
// Example: "Elite Styles Beauty Mindful Program"
|
|
314
|
+
// - "Elite" can match name OR domains
|
|
315
|
+
// - "Styles" can match name OR domains
|
|
316
|
+
// - "Mindful" can match name OR domains (will match domain mindfulbeautyprogram.com)
|
|
317
|
+
// - etc.
|
|
318
|
+
//
|
|
319
|
+
// Result: Company "Elite Styles And Beauty" (mindfulbeautyprogram.com) matches because:
|
|
320
|
+
// - "Elite" matches name ✓
|
|
321
|
+
// - "Styles" matches name ✓
|
|
322
|
+
// - "Beauty" matches name ✓
|
|
323
|
+
// - "Mindful" matches domain ✓
|
|
324
|
+
// - "Program" matches domain ✓
|
|
325
|
+
if (uniqueTokens.length > 0) {
|
|
326
|
+
if (uniqueTokens.length === 1) {
|
|
327
|
+
// Single token: create OR condition across all relevant fields
|
|
328
|
+
const token = uniqueTokens[0];
|
|
329
|
+
tokenTargets.forEach((field) => {
|
|
330
|
+
addCondition(conditions, seen, {
|
|
331
|
+
[field]: { $contains: token },
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Multiple tokens: each token must match at least one field (AND of ORs)
|
|
337
|
+
const tokenAndConditions = uniqueTokens.map((token) => {
|
|
338
|
+
const tokenFieldConditions = [];
|
|
339
|
+
tokenTargets.forEach((field) => {
|
|
340
|
+
tokenFieldConditions.push({
|
|
341
|
+
[field]: { $contains: token },
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
return {
|
|
345
|
+
$or: tokenFieldConditions,
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
// Combine all token conditions with AND
|
|
349
|
+
addCondition(conditions, seen, {
|
|
350
|
+
$and: tokenAndConditions,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (conditions.length === 0) {
|
|
355
|
+
return createLegacyFilter(objectType, trimmedQuery);
|
|
356
|
+
}
|
|
357
|
+
if (conditions.length === 1) {
|
|
358
|
+
return conditions[0];
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
$or: conditions,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Build OR-only fallback filter for when AND-of-OR returns zero results
|
|
366
|
+
* Uses the same parsed structure but allows ANY token to match (no AND requirement)
|
|
367
|
+
*/
|
|
368
|
+
function buildORFallbackFilter(objectType, parsed) {
|
|
369
|
+
const conditions = [];
|
|
370
|
+
const seen = new Set();
|
|
371
|
+
// Include all structured fields as before
|
|
372
|
+
parsed.emails.forEach((email) => {
|
|
373
|
+
addCondition(conditions, seen, {
|
|
374
|
+
email_addresses: { $contains: email },
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
parsed.phones.forEach((phone) => {
|
|
378
|
+
addCondition(conditions, seen, {
|
|
379
|
+
phone_numbers: { $contains: phone },
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
if (objectType === ResourceType.COMPANIES) {
|
|
383
|
+
parsed.domains.forEach((domain) => {
|
|
384
|
+
addCondition(conditions, seen, {
|
|
385
|
+
domains: { $contains: domain },
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const tokenTargets = new Set();
|
|
390
|
+
tokenTargets.add('name');
|
|
391
|
+
if (objectType === ResourceType.PEOPLE) {
|
|
392
|
+
tokenTargets.add('email_addresses');
|
|
393
|
+
tokenTargets.add('phone_numbers');
|
|
394
|
+
}
|
|
395
|
+
if (objectType === ResourceType.COMPANIES) {
|
|
396
|
+
tokenTargets.add('domains');
|
|
397
|
+
}
|
|
398
|
+
const uniqueTokens = Array.from(new Set(parsed.tokens.filter((token) => token.length > 1)));
|
|
399
|
+
// OR-only fallback: each token can match any field (no AND requirement)
|
|
400
|
+
// This provides maximum recall when the stricter AND-of-OR filter returns zero results
|
|
66
401
|
uniqueTokens.forEach((token) => {
|
|
67
402
|
tokenTargets.forEach((field) => {
|
|
68
403
|
addCondition(conditions, seen, {
|
|
@@ -71,7 +406,8 @@ function buildSearchFilter(objectType, query) {
|
|
|
71
406
|
});
|
|
72
407
|
});
|
|
73
408
|
if (conditions.length === 0) {
|
|
74
|
-
|
|
409
|
+
// Shouldn't happen, but return safe fallback
|
|
410
|
+
return { name: { $contains: parsed.tokens.join(' ') } };
|
|
75
411
|
}
|
|
76
412
|
if (conditions.length === 1) {
|
|
77
413
|
return conditions[0];
|
|
@@ -85,27 +421,151 @@ function buildSearchFilter(objectType, query) {
|
|
|
85
421
|
*
|
|
86
422
|
* @param objectType - The type of object to search (people or companies)
|
|
87
423
|
* @param query - Search query string
|
|
88
|
-
* @param
|
|
424
|
+
* @param options - Optional search options (limit, retryConfig)
|
|
89
425
|
* @returns Array of matching records
|
|
90
426
|
*/
|
|
91
|
-
export async function searchObject(objectType, query,
|
|
427
|
+
export async function searchObject(objectType, query, options) {
|
|
428
|
+
const retryConfig = options?.retryConfig;
|
|
92
429
|
const api = getLazyAttioClient();
|
|
93
430
|
const path = `/objects/${objectType}/records/query`;
|
|
94
|
-
const
|
|
431
|
+
const trimmedQuery = query.trim();
|
|
432
|
+
const scoringEnabled = ENABLE_SEARCH_SCORING && trimmedQuery.length > 0;
|
|
433
|
+
// Use caller's limit if provided, otherwise fall back to default
|
|
434
|
+
const requestedLimit = options?.limit;
|
|
435
|
+
const baseLimit = requestedLimit !== undefined && requestedLimit > 0
|
|
436
|
+
? requestedLimit
|
|
437
|
+
: Number.isFinite(DEFAULT_FETCH_LIMIT) && DEFAULT_FETCH_LIMIT > 0
|
|
438
|
+
? DEFAULT_FETCH_LIMIT
|
|
439
|
+
: 20;
|
|
440
|
+
const multiplier = Number.isFinite(SEARCH_FETCH_MULTIPLIER) && SEARCH_FETCH_MULTIPLIER > 0
|
|
441
|
+
? SEARCH_FETCH_MULTIPLIER
|
|
442
|
+
: 5;
|
|
443
|
+
const minimumFetch = Number.isFinite(SEARCH_FETCH_MIN) && SEARCH_FETCH_MIN > 0
|
|
444
|
+
? SEARCH_FETCH_MIN
|
|
445
|
+
: 50;
|
|
446
|
+
const fastPathLimit = Number.isFinite(SEARCH_FAST_PATH_LIMIT) && SEARCH_FAST_PATH_LIMIT > 0
|
|
447
|
+
? Math.max(baseLimit, SEARCH_FAST_PATH_LIMIT)
|
|
448
|
+
: baseLimit;
|
|
449
|
+
const parsedQuery = trimmedQuery ? parseQuery(trimmedQuery) : null;
|
|
450
|
+
const cacheKey = scoringEnabled && trimmedQuery
|
|
451
|
+
? getCacheKey(objectType, trimmedQuery, baseLimit)
|
|
452
|
+
: null;
|
|
453
|
+
if (scoringEnabled && cacheKey) {
|
|
454
|
+
const cached = searchCache.get(cacheKey);
|
|
455
|
+
if (cached) {
|
|
456
|
+
return cached;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (scoringEnabled && parsedQuery) {
|
|
460
|
+
const fastCandidates = buildFastPathCandidates(objectType, parsedQuery, trimmedQuery);
|
|
461
|
+
for (const candidate of fastCandidates) {
|
|
462
|
+
const candidateLimit = candidate.kind === 'name' && candidate.strategy === 'contains'
|
|
463
|
+
? Math.min(100, Math.max(minimumFetch, baseLimit * multiplier))
|
|
464
|
+
: candidate.kind === 'name' && candidate.strategy === 'eq'
|
|
465
|
+
? baseLimit
|
|
466
|
+
: fastPathLimit;
|
|
467
|
+
logger.debug('[FastPath] Trying candidate', {
|
|
468
|
+
kind: candidate.kind,
|
|
469
|
+
strategy: candidate.strategy,
|
|
470
|
+
filter: candidate.filter,
|
|
471
|
+
limit: candidateLimit,
|
|
472
|
+
});
|
|
473
|
+
try {
|
|
474
|
+
const fastResponse = await callWithRetry(async () => {
|
|
475
|
+
return api.post(path, {
|
|
476
|
+
filter: candidate.filter,
|
|
477
|
+
limit: candidateLimit,
|
|
478
|
+
});
|
|
479
|
+
}, retryConfig);
|
|
480
|
+
const fastData = Array.isArray(fastResponse?.data?.data)
|
|
481
|
+
? fastResponse?.data?.data
|
|
482
|
+
: [];
|
|
483
|
+
logger.debug('[FastPath] Received results', {
|
|
484
|
+
count: fastData.length,
|
|
485
|
+
});
|
|
486
|
+
if (!fastData.length) {
|
|
487
|
+
logger.debug('[FastPath] No results for candidate');
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const hasMatch = fastData.some((record) => recordMatchesFastPath(record, candidate));
|
|
491
|
+
if (!hasMatch) {
|
|
492
|
+
logger.debug('[FastPath] Validation failed', {
|
|
493
|
+
candidateKind: candidate.kind,
|
|
494
|
+
candidateStrategy: candidate.strategy,
|
|
495
|
+
candidateValue: candidate.value,
|
|
496
|
+
resultCount: fastData.length,
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
logger.debug('[FastPath] Validation passed, ranking');
|
|
501
|
+
const rankedFast = scoreAndRank(trimmedQuery, fastData, parsedQuery);
|
|
502
|
+
const truncatedFast = rankedFast.slice(0, baseLimit);
|
|
503
|
+
if (cacheKey) {
|
|
504
|
+
searchCache.set(cacheKey, truncatedFast);
|
|
505
|
+
}
|
|
506
|
+
return truncatedFast;
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
logger.debug('[FastPath] Error executing candidate', {
|
|
510
|
+
kind: candidate.kind,
|
|
511
|
+
strategy: candidate.strategy,
|
|
512
|
+
filter: candidate.filter,
|
|
513
|
+
error: error instanceof Error ? error.message : String(error),
|
|
514
|
+
});
|
|
515
|
+
void error; // Non-fatal fast-path failure; fall back to next candidate
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const filter = buildSearchFilter(objectType, query, parsedQuery ?? undefined);
|
|
520
|
+
const fetchLimit = scoringEnabled
|
|
521
|
+
? Math.max(minimumFetch, baseLimit * multiplier)
|
|
522
|
+
: baseLimit;
|
|
95
523
|
return callWithRetry(async () => {
|
|
96
524
|
try {
|
|
97
525
|
const response = await api.post(path, {
|
|
98
526
|
filter,
|
|
527
|
+
limit: fetchLimit,
|
|
99
528
|
});
|
|
100
|
-
|
|
529
|
+
const rawData = response?.data?.data;
|
|
530
|
+
let data = Array.isArray(rawData) ? rawData : [];
|
|
531
|
+
// OR-only fallback when AND-of-OR returns zero results (#885 recall fix)
|
|
532
|
+
// This handles over-constrained queries like "Beauty Glow Aesthetics Frisco"
|
|
533
|
+
// where some tokens don't exist but the record should still match
|
|
534
|
+
// Runs regardless of scoring state - scoring only affects ranking, not recall
|
|
535
|
+
if (data.length === 0 && parsedQuery) {
|
|
536
|
+
logger.debug('[Fallback] Zero results from AND-of-OR, trying OR-only', {
|
|
537
|
+
query: trimmedQuery,
|
|
538
|
+
objectType,
|
|
539
|
+
});
|
|
540
|
+
const fallbackFilter = buildORFallbackFilter(objectType, parsedQuery);
|
|
541
|
+
const fallbackResponse = await api.post(path, {
|
|
542
|
+
filter: fallbackFilter,
|
|
543
|
+
limit: fetchLimit,
|
|
544
|
+
});
|
|
545
|
+
const fallbackRawData = fallbackResponse?.data?.data;
|
|
546
|
+
data = Array.isArray(fallbackRawData)
|
|
547
|
+
? fallbackRawData
|
|
548
|
+
: [];
|
|
549
|
+
logger.debug('[Fallback] OR-only results', {
|
|
550
|
+
count: data.length,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
if (!scoringEnabled || data.length <= 1) {
|
|
554
|
+
const truncated = data.slice(0, baseLimit);
|
|
555
|
+
return truncated;
|
|
556
|
+
}
|
|
557
|
+
const ranked = scoreAndRank(trimmedQuery, data, parsedQuery ?? undefined);
|
|
558
|
+
const truncated = ranked.slice(0, baseLimit);
|
|
559
|
+
if (cacheKey) {
|
|
560
|
+
searchCache.set(cacheKey, truncated);
|
|
561
|
+
}
|
|
562
|
+
return truncated;
|
|
101
563
|
}
|
|
102
564
|
catch (error) {
|
|
103
565
|
const apiError = error;
|
|
104
|
-
// Handle 404 errors with custom message
|
|
105
566
|
if (apiError.response && apiError.response.status === 404) {
|
|
106
567
|
throw new Error(`No ${objectType} found matching '${query}'`);
|
|
107
568
|
}
|
|
108
|
-
// Let upstream handlers create specific, rich error objects from the original Axios error.
|
|
109
569
|
throw error;
|
|
110
570
|
}
|
|
111
571
|
}, retryConfig);
|