enigma-api-client 0.3.1__py3-none-any.whl
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.
- enigma_api_client/__init__.py +16 -0
- enigma_api_client/client.py +134 -0
- enigma_api_client/client_inject_id_and_typename_helper.py +248 -0
- enigma_api_client/client_inject_urls_helper.py +26 -0
- enigma_api_client/plugins/.DS_Store +0 -0
- enigma_api_client/plugins/enigma-api/.claude-plugin/plugin.json +8 -0
- enigma_api_client/plugins/enigma-api/README.md +82 -0
- enigma_api_client/plugins/enigma-api/harness/conftest.py +42 -0
- enigma_api_client/plugins/enigma-api/harness/test_linter.py +863 -0
- enigma_api_client/plugins/enigma-api/harness/test_queries.py +143 -0
- enigma_api_client/plugins/enigma-api/harness/test_skill_rules.py +351 -0
- enigma_api_client/plugins/enigma-api/pyproject.toml +9 -0
- enigma_api_client/plugins/enigma-api/scripts/__pycache__/lint_query.cpython-313.pyc +0 -0
- enigma_api_client/plugins/enigma-api/scripts/__pycache__/query_validator.cpython-313.pyc +0 -0
- enigma_api_client/plugins/enigma-api/scripts/__pycache__/query_validator.cpython-314.pyc +0 -0
- enigma_api_client/plugins/enigma-api/scripts/generate_report.py +384 -0
- enigma_api_client/plugins/enigma-api/scripts/lint_query.py +712 -0
- enigma_api_client/plugins/enigma-api/scripts/query_validator.py +352 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/SKILL.md +620 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/brand/03_brand_profile.md +163 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/brand/13_brand_dump.md +95 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/derived/07_coalesce_ein_formation.md +146 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/discovery/01_coffee_shops_zip.md +287 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/discovery/11_ol_industry_discovery.md +116 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/export/08_segment_export.md +96 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/kyb/02_officers_lookup.md +366 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/kyb/10_kyb_verify.md +176 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/legal_entity/09_legal_entity_profile.md +97 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/locations/05_brand_locations_by_state.md +120 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/person_search/04_find_person_companies.md +160 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/revenue/06_brand_revenue.md +176 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/examples/risk/12_risk_signals.md +118 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/card_transactions.md +91 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/field_corrections.json +106 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/filter_syntax.md +203 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/naics_2022_mapping.json +184 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/references/pagination_template.py +115 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/schema.graphql +44971 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/scripts/test_examples.py +1122 -0
- enigma_api_client/plugins/enigma-api/skills/enigma-graphql/scripts/validate_query.py +177 -0
- enigma_api_client/plugins/enigma-api/test-cases/graphql-test-cases.md +196 -0
- enigma_api_client/plugins/enigma-api/tests/accuracy_cases.csv +116 -0
- enigma_api_client/plugins/enigma-api/tests/applicability_review.csv +64 -0
- enigma_api_client/plugins/enigma-api/tests/parallel-ground-truth.csv +101 -0
- enigma_api_client/plugins/enigma-api/tests/run_tests.sh +320 -0
- enigma_api_client/plugins/enigma-api/tests/test_cases.yaml +213 -0
- enigma_api_client/plugins/enigma-api/tests/validation_cases.csv +101 -0
- enigma_api_client/py.typed +0 -0
- enigma_api_client/query_router/__init__.py +3 -0
- enigma_api_client/query_router/geo.py +121 -0
- enigma_api_client/query_router/templates.py +475 -0
- enigma_api_client/strategies/__init__.py +16 -0
- enigma_api_client/strategies/agent.py +63 -0
- enigma_api_client/strategies/base.py +15 -0
- enigma_api_client/strategies/generative.py +143 -0
- enigma_api_client/strategies/llm_provider.py +137 -0
- enigma_api_client/strategies/service.py +103 -0
- enigma_api_client/strategies/template.py +189 -0
- enigma_api_client-0.3.1.dist-info/METADATA +257 -0
- enigma_api_client-0.3.1.dist-info/RECORD +63 -0
- enigma_api_client-0.3.1.dist-info/WHEEL +5 -0
- enigma_api_client-0.3.1.dist-info/licenses/LICENSE +201 -0
- enigma_api_client-0.3.1.dist-info/top_level.txt +5 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""enigma_api_client - Enigma's Public API SDK"""
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from enigma_api_client.client import Client, ClientConfig, SearchResponse, EnigmaClientException, EnigmaClientErrorCode
|
|
6
|
+
from enigma_api_client.strategies.base import QueryStrategy
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.1"
|
|
9
|
+
__all__: Sequence[str] = [
|
|
10
|
+
"Client",
|
|
11
|
+
"ClientConfig",
|
|
12
|
+
"SearchResponse",
|
|
13
|
+
"EnigmaClientException",
|
|
14
|
+
"EnigmaClientErrorCode",
|
|
15
|
+
"QueryStrategy",
|
|
16
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from enigma_api_client.client_inject_id_and_typename_helper import inject_fields_in_query
|
|
11
|
+
from enigma_api_client.client_inject_urls_helper import inject_urls
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from enigma_api_client.strategies.base import QueryStrategy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnigmaClientErrorCode(Enum):
|
|
18
|
+
INVALID_QUERY = "INVALID_GRAPHQL_QUERY"
|
|
19
|
+
INVALID_VARIABLES = "INVALID_VARIABLES"
|
|
20
|
+
GENERATE_QUERY_CALLBACK_NOT_CONFIGURED = "GENERATE_QUERY_CALLBACK_NOT_CONFIGURED"
|
|
21
|
+
|
|
22
|
+
class EnigmaClientException(Exception):
|
|
23
|
+
def __init__(self, error_code: EnigmaClientErrorCode, message: str):
|
|
24
|
+
self.error_code = error_code
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ClientConfig:
|
|
30
|
+
# Enigma API key
|
|
31
|
+
enigma_api_key: str
|
|
32
|
+
|
|
33
|
+
# Pluggable query strategy for generating GraphQL from natural language prompts.
|
|
34
|
+
# Available strategies: ServiceStrategy (default), TemplateStrategy, GenerativeStrategy, AgentStrategy
|
|
35
|
+
# Default: ServiceStrategy (delegates to the Enigma Semantic Transformation Service)
|
|
36
|
+
query_strategy: QueryStrategy | None = None
|
|
37
|
+
|
|
38
|
+
# If True, automatically downloads new skills from Enigma at client initialization and every 24 hours.
|
|
39
|
+
# If False, will only use static skills packaged with the client.
|
|
40
|
+
# Default: True
|
|
41
|
+
auto_download_new_skills: bool = True
|
|
42
|
+
|
|
43
|
+
# Enigma GraphQL API endpoint
|
|
44
|
+
url: str = "https://api.enigma.com/graphql"
|
|
45
|
+
|
|
46
|
+
# Auto Inject ID and typename in query. This is required in order to get __url fields in the response.
|
|
47
|
+
auto_inject_id_and_typename: bool = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SearchResponse:
|
|
52
|
+
status_code: int
|
|
53
|
+
errors: Any = None
|
|
54
|
+
data: dict[str, Any] | None = None
|
|
55
|
+
extensions: Any = None
|
|
56
|
+
|
|
57
|
+
def is_success(self) -> bool:
|
|
58
|
+
return 200 <= self.status_code <= 299 and not self.errors
|
|
59
|
+
|
|
60
|
+
class Client:
|
|
61
|
+
def __init__(self, config: ClientConfig):
|
|
62
|
+
self._config = config
|
|
63
|
+
|
|
64
|
+
async def search(self, *, prompt: str | None = None, query: str | None = None, variables: dict[str, Any] | None = None) -> SearchResponse:
|
|
65
|
+
"""
|
|
66
|
+
Executes a GraphQL request against Enigma GraphQL API
|
|
67
|
+
|
|
68
|
+
:param prompt: will use prompt to generate a query using the configured query_strategy.
|
|
69
|
+
If query_strategy is NOT configured on ClientConfig, an exception with the error code
|
|
70
|
+
"GENERATE_QUERY_CALLBACK_NOT_CONFIGURED" will be raised.
|
|
71
|
+
Cannot be used together with query or variables parameters.
|
|
72
|
+
:param query: A string representing the GraphQL query. Cannot be used together with the prompt parameter.
|
|
73
|
+
:param variables: A dict representing the GraphQL variables. Cannot be used together with the prompt parameter.
|
|
74
|
+
:return: `SearchResponse` containing the response of the search.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
if prompt and (query or variables):
|
|
78
|
+
raise EnigmaClientException(EnigmaClientErrorCode.INVALID_QUERY, "Cannot provide both query/variable and prompt parameters")
|
|
79
|
+
|
|
80
|
+
if not prompt and not query and not variables:
|
|
81
|
+
raise EnigmaClientException(EnigmaClientErrorCode.INVALID_QUERY, "Invalid query. Must provide either query/variable or prompt parameters")
|
|
82
|
+
|
|
83
|
+
if prompt is not None:
|
|
84
|
+
strategy = self._config.query_strategy
|
|
85
|
+
if strategy is None:
|
|
86
|
+
from enigma_api_client.strategies.service import ServiceStrategy
|
|
87
|
+
strategy = ServiceStrategy(enigma_api_key=self._config.enigma_api_key, query_strategy="template")
|
|
88
|
+
self._config.query_strategy = strategy
|
|
89
|
+
query, variables = await strategy.generate_query(prompt)
|
|
90
|
+
|
|
91
|
+
return await self._execute_graphql_request(query, variables)
|
|
92
|
+
|
|
93
|
+
async def _execute_graphql_request(self, query: str | None, variables: dict[str, Any] | None) -> SearchResponse:
|
|
94
|
+
headers = {
|
|
95
|
+
"Content-Type": "application/json",
|
|
96
|
+
"x-api-key": self._config.enigma_api_key or os.getenv("ENIGMA_API_KEY", ""),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
payload = {
|
|
100
|
+
"query": query,
|
|
101
|
+
"variables": variables or {},
|
|
102
|
+
}
|
|
103
|
+
if self._config.auto_inject_id_and_typename:
|
|
104
|
+
payload = self._inject_id_and_typename_whereever_needed(payload)
|
|
105
|
+
|
|
106
|
+
async with httpx.AsyncClient() as client:
|
|
107
|
+
response = await client.post(
|
|
108
|
+
self._config.url,
|
|
109
|
+
json=payload,
|
|
110
|
+
headers=headers,
|
|
111
|
+
timeout=30.0
|
|
112
|
+
)
|
|
113
|
+
status_code = response.status_code
|
|
114
|
+
resp_json = response.json()
|
|
115
|
+
|
|
116
|
+
return SearchResponse(
|
|
117
|
+
data=inject_urls(resp_json.get("data")),
|
|
118
|
+
errors=resp_json.get("errors"),
|
|
119
|
+
extensions=resp_json.get("extensions"),
|
|
120
|
+
status_code=status_code
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def _inject_id_and_typename_whereever_needed(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
124
|
+
query = payload.get("query")
|
|
125
|
+
if not query or not isinstance(query, str):
|
|
126
|
+
return payload
|
|
127
|
+
try:
|
|
128
|
+
new_query = inject_fields_in_query(query)
|
|
129
|
+
except Exception:
|
|
130
|
+
return payload # never break on a parse failure
|
|
131
|
+
if new_query == query:
|
|
132
|
+
return payload
|
|
133
|
+
return {**payload, "query": new_query}
|
|
134
|
+
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# ── GraphQL field injector ─────────────────────────────────────────────────────
|
|
6
|
+
#
|
|
7
|
+
# Parses the raw query string and injects `id` and `__typename` into every
|
|
8
|
+
# selection set that opens an inline fragment (`... on TypeName { … }`) or a
|
|
9
|
+
# connection node (`edges { node { … } }`), but only when those fields are not
|
|
10
|
+
# already present. All other selection sets are left untouched.
|
|
11
|
+
#
|
|
12
|
+
# Implementation is a hand-rolled tokeniser + single-pass walker so that we have
|
|
13
|
+
# zero extra dependencies. The original query string is only modified at the
|
|
14
|
+
# exact character positions that need an injection; everything else is preserved
|
|
15
|
+
# verbatim (whitespace, comments, aliases, arguments, etc.).
|
|
16
|
+
|
|
17
|
+
# Token kinds: 'ws' | 'comment' | 'string' | 'spread' | 'punct' | 'name' | 'num'
|
|
18
|
+
_Token = tuple[str, str, int] # (kind, value, end_pos) – end_pos is exclusive
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _gql_tokenize(query: str) -> list[_Token]:
|
|
22
|
+
"""Tokenise a GraphQL query string into (kind, value, end_pos) triples."""
|
|
23
|
+
tokens: list[_Token] = []
|
|
24
|
+
i = 0
|
|
25
|
+
n = len(query)
|
|
26
|
+
while i < n:
|
|
27
|
+
c = query[i]
|
|
28
|
+
# Block string """…"""
|
|
29
|
+
if query[i:i+3] == '"""':
|
|
30
|
+
j = query.find('"""', i + 3)
|
|
31
|
+
j = (j + 3) if j != -1 else n
|
|
32
|
+
tokens.append(('string', query[i:j], j)); i = j
|
|
33
|
+
# Regular string "…"
|
|
34
|
+
elif c == '"':
|
|
35
|
+
j = i + 1
|
|
36
|
+
while j < n:
|
|
37
|
+
if query[j] == '\\': j += 2
|
|
38
|
+
elif query[j] == '"': j += 1; break
|
|
39
|
+
else: j += 1
|
|
40
|
+
tokens.append(('string', query[i:j], j)); i = j
|
|
41
|
+
# Line comment #…
|
|
42
|
+
elif c == '#':
|
|
43
|
+
j = query.find('\n', i); j = j if j != -1 else n
|
|
44
|
+
tokens.append(('comment', query[i:j], j)); i = j
|
|
45
|
+
# Insignificant whitespace and commas
|
|
46
|
+
elif c in ' \t\n\r,':
|
|
47
|
+
j = i + 1
|
|
48
|
+
while j < n and query[j] in ' \t\n\r,': j += 1
|
|
49
|
+
tokens.append(('ws', query[i:j], j)); i = j
|
|
50
|
+
# Fragment spread operator
|
|
51
|
+
elif query[i:i+3] == '...':
|
|
52
|
+
tokens.append(('spread', '...', i + 3)); i += 3
|
|
53
|
+
# Single-character punctuation
|
|
54
|
+
elif c in '{}()[]!$:=@|&':
|
|
55
|
+
tokens.append(('punct', c, i + 1)); i += 1
|
|
56
|
+
# Identifiers [_A-Za-z][_A-Za-z0-9]*
|
|
57
|
+
elif c.isalpha() or c == '_':
|
|
58
|
+
j = i + 1
|
|
59
|
+
while j < n and (query[j].isalnum() or query[j] == '_'): j += 1
|
|
60
|
+
tokens.append(('name', query[i:j], j)); i = j
|
|
61
|
+
# Numbers (treated as opaque)
|
|
62
|
+
elif c.isdigit() or (c == '-' and i + 1 < n and query[i + 1].isdigit()):
|
|
63
|
+
j = i + 1
|
|
64
|
+
while j < n and (query[j].isdigit() or query[j] in '.eE+-'): j += 1
|
|
65
|
+
tokens.append(('num', query[i:j], j)); i = j
|
|
66
|
+
else:
|
|
67
|
+
tokens.append(('num', c, i + 1)); i += 1
|
|
68
|
+
return tokens
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _selection_field_names(tokens: list[_Token], brace_idx: int) -> set[str]:
|
|
72
|
+
"""
|
|
73
|
+
Return the set of field names selected at depth-1 inside the selection set
|
|
74
|
+
opened by tokens[brace_idx] (which must be a '{' punct token).
|
|
75
|
+
|
|
76
|
+
Handles: nested braces, argument lists (parens), inline fragments (skipped),
|
|
77
|
+
named fragment spreads (skipped), aliases (the alias is skipped; the actual
|
|
78
|
+
field name is collected).
|
|
79
|
+
"""
|
|
80
|
+
n = len(tokens)
|
|
81
|
+
depth = 0 # brace depth relative to the opening '{'
|
|
82
|
+
paren_depth = 0
|
|
83
|
+
fields: set[str] = set()
|
|
84
|
+
# Last two non-ws tokens seen (for pattern matching)
|
|
85
|
+
recent: list[tuple[str, str]] = []
|
|
86
|
+
|
|
87
|
+
def push(k: str, v: str) -> None:
|
|
88
|
+
recent.append((k, v))
|
|
89
|
+
if len(recent) > 2:
|
|
90
|
+
recent.pop(0)
|
|
91
|
+
|
|
92
|
+
i = brace_idx
|
|
93
|
+
while i < n:
|
|
94
|
+
kind, value, _ = tokens[i]
|
|
95
|
+
|
|
96
|
+
if kind in ('ws', 'comment'):
|
|
97
|
+
i += 1; continue
|
|
98
|
+
|
|
99
|
+
# Skip everything inside argument lists
|
|
100
|
+
if paren_depth > 0:
|
|
101
|
+
if kind == 'punct' and value == '(': paren_depth += 1
|
|
102
|
+
elif kind == 'punct' and value == ')': paren_depth -= 1
|
|
103
|
+
i += 1; continue
|
|
104
|
+
|
|
105
|
+
if kind == 'punct' and value == '(':
|
|
106
|
+
paren_depth += 1; i += 1; continue
|
|
107
|
+
|
|
108
|
+
if kind == 'punct' and value == '{':
|
|
109
|
+
depth += 1; push(kind, value); i += 1; continue
|
|
110
|
+
|
|
111
|
+
if kind == 'punct' and value == '}':
|
|
112
|
+
depth -= 1
|
|
113
|
+
if depth == 0: break
|
|
114
|
+
push(kind, value); i += 1; continue
|
|
115
|
+
|
|
116
|
+
if depth == 1 and kind == 'name':
|
|
117
|
+
prev = recent[-1] if recent else None
|
|
118
|
+
# Skip: type name after 'on' (... on TypeName)
|
|
119
|
+
if prev == ('name', 'on'):
|
|
120
|
+
push(kind, value); i += 1; continue
|
|
121
|
+
# Skip: named fragment reference ...FragmentName
|
|
122
|
+
if prev == ('spread', '...'):
|
|
123
|
+
push(kind, value); i += 1; continue
|
|
124
|
+
# Skip: alias name (alias: fieldName — collect fieldName on next iter via ':')
|
|
125
|
+
j = i + 1
|
|
126
|
+
next_tok: tuple[str, str] | None = None
|
|
127
|
+
while j < n:
|
|
128
|
+
if tokens[j][0] not in ('ws', 'comment'):
|
|
129
|
+
next_tok = (tokens[j][0], tokens[j][1]); break
|
|
130
|
+
j += 1
|
|
131
|
+
if next_tok == ('punct', ':'):
|
|
132
|
+
push(kind, value); i += 1; continue
|
|
133
|
+
# Actual field name
|
|
134
|
+
fields.add(value)
|
|
135
|
+
|
|
136
|
+
push(kind, value)
|
|
137
|
+
i += 1
|
|
138
|
+
|
|
139
|
+
return fields
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _classify_opening_brace(
|
|
143
|
+
recent_sig: list[tuple[str, str]],
|
|
144
|
+
ctx_stack: list[str],
|
|
145
|
+
) -> str:
|
|
146
|
+
"""
|
|
147
|
+
Classify the context of an opening '{' given the last few significant
|
|
148
|
+
(non-ws, non-comment) tokens that preceded it and the current context stack.
|
|
149
|
+
|
|
150
|
+
Returns one of: 'inline_fragment' | 'node_in_edges' | 'edges' | 'other'
|
|
151
|
+
"""
|
|
152
|
+
r = recent_sig # most-recent last
|
|
153
|
+
|
|
154
|
+
# Inline fragment: ... on TypeName [optional directives] {
|
|
155
|
+
# We scan backwards for (name, 'on') preceded (somewhere) by (spread, '...')
|
|
156
|
+
for idx in range(len(r) - 1, -1, -1):
|
|
157
|
+
if r[idx] == ('name', 'on'):
|
|
158
|
+
# Is there a spread '...' anywhere before this 'on' in recent?
|
|
159
|
+
if any(t == ('spread', '...') for t in r[:idx]):
|
|
160
|
+
return 'inline_fragment'
|
|
161
|
+
break # 'on' not preceded by spread — not an inline fragment
|
|
162
|
+
|
|
163
|
+
# Find the last field name in recent (ignoring structural punctuation and 'on')
|
|
164
|
+
last_field: str | None = None
|
|
165
|
+
for kind, value in reversed(r):
|
|
166
|
+
if kind == 'name' and value != 'on':
|
|
167
|
+
last_field = value
|
|
168
|
+
break
|
|
169
|
+
if kind == 'punct' and value in ('{', '}'):
|
|
170
|
+
break # structural boundary — stop looking
|
|
171
|
+
|
|
172
|
+
if last_field == 'node' and ctx_stack and ctx_stack[-1] == 'edges':
|
|
173
|
+
return 'node_in_edges'
|
|
174
|
+
if last_field == 'edges':
|
|
175
|
+
return 'edges'
|
|
176
|
+
return 'other'
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _find_injections(query: str) -> list[tuple[int, list[str]]]:
|
|
180
|
+
"""
|
|
181
|
+
Return a list of (insert_pos, fields) where insert_pos is the character
|
|
182
|
+
position immediately after a '{' that opens an injection-target selection
|
|
183
|
+
set, and fields is the subset of ['id', '__typename'] not already present.
|
|
184
|
+
"""
|
|
185
|
+
tokens = _gql_tokenize(query)
|
|
186
|
+
injections: list[tuple[int, list[str]]] = []
|
|
187
|
+
ctx_stack: list[str] = [] # context for each open '{'
|
|
188
|
+
paren_depth = 0
|
|
189
|
+
recent_sig: list[tuple[str, str]] = [] # last ≤4 significant tokens
|
|
190
|
+
|
|
191
|
+
def push_sig(k: str, v: str) -> None:
|
|
192
|
+
recent_sig.append((k, v))
|
|
193
|
+
if len(recent_sig) > 4:
|
|
194
|
+
recent_sig.pop(0)
|
|
195
|
+
|
|
196
|
+
for idx, (kind, value, end_pos) in enumerate(tokens):
|
|
197
|
+
if kind in ('ws', 'comment'):
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Inside argument lists — track depth but don't update context
|
|
201
|
+
if paren_depth > 0:
|
|
202
|
+
if kind == 'punct' and value == '(': paren_depth += 1
|
|
203
|
+
elif kind == 'punct' and value == ')': paren_depth -= 1
|
|
204
|
+
# Intentionally NOT calling push_sig here so argument content
|
|
205
|
+
# does not pollute the recent-token window used for classification.
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
if kind == 'punct' and value == '(':
|
|
209
|
+
paren_depth += 1
|
|
210
|
+
continue # don't push to recent_sig either
|
|
211
|
+
|
|
212
|
+
if kind == 'punct' and value == '{':
|
|
213
|
+
ctx = _classify_opening_brace(recent_sig, ctx_stack)
|
|
214
|
+
if ctx in ('inline_fragment', 'node_in_edges'):
|
|
215
|
+
existing = _selection_field_names(tokens, idx)
|
|
216
|
+
to_inject = [f for f in ('id', '__typename') if f not in existing]
|
|
217
|
+
if to_inject:
|
|
218
|
+
injections.append((end_pos, to_inject))
|
|
219
|
+
ctx_stack.append(ctx)
|
|
220
|
+
push_sig(kind, value)
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
if kind == 'punct' and value == '}':
|
|
224
|
+
if ctx_stack:
|
|
225
|
+
ctx_stack.pop()
|
|
226
|
+
push_sig(kind, value)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
push_sig(kind, value)
|
|
230
|
+
|
|
231
|
+
return injections
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def inject_fields_in_query(query: str) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Return a copy of *query* with `id` and `__typename` injected into every
|
|
237
|
+
inline-fragment and edge-node selection set that is missing them.
|
|
238
|
+
Injections are applied in reverse position order to keep earlier offsets valid.
|
|
239
|
+
"""
|
|
240
|
+
injections = _find_injections(query)
|
|
241
|
+
if not injections:
|
|
242
|
+
return query
|
|
243
|
+
# Sort descending so we insert from the back, preserving earlier positions.
|
|
244
|
+
injections.sort(key=lambda x: x[0], reverse=True)
|
|
245
|
+
result = query
|
|
246
|
+
for pos, fields in injections:
|
|
247
|
+
result = result[:pos] + ' ' + ' '.join(fields) + result[pos:]
|
|
248
|
+
return result
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
_PERMALINK_TYPES_TO_PATH = {
|
|
6
|
+
"OperatingLocation": "operating-location",
|
|
7
|
+
"Brand": "brand",
|
|
8
|
+
"LegalEntity": "legal-entity",
|
|
9
|
+
"Person": "person",
|
|
10
|
+
}
|
|
11
|
+
_PERMALINK_BASE = "https://console.enigma.com/explore/profile"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def inject_urls(node: Any) -> Any:
|
|
15
|
+
"""Recursively walk a GraphQL response node and add ``__url`` to any object
|
|
16
|
+
that has both ``__typename`` and ``id`` and whose type is in _PERMALINK_TYPES."""
|
|
17
|
+
if isinstance(node, dict):
|
|
18
|
+
typename = node.get("__typename")
|
|
19
|
+
entity_id = node.get("id")
|
|
20
|
+
result = {k: inject_urls(v) for k, v in node.items()}
|
|
21
|
+
if typename and entity_id is not None and (path := _PERMALINK_TYPES_TO_PATH.get(typename)):
|
|
22
|
+
result["__url"] = f"{_PERMALINK_BASE}/{path}/{entity_id}"
|
|
23
|
+
return result
|
|
24
|
+
if isinstance(node, list):
|
|
25
|
+
return [inject_urls(item) for item in node]
|
|
26
|
+
return node
|
|
Binary file
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# enigma-api
|
|
2
|
+
|
|
3
|
+
Query Enigma APIs — GraphQL business data, KYB verification, sanctions screening, and government archive records. Claude automatically uses the appropriate skill based on what you're asking about.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
/plugin install enigma-api@enigma-plugins
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Prerequisites
|
|
12
|
+
|
|
13
|
+
Set your Enigma API key in your environment:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export ENIGMA_API_KEY=your_api_key_here
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Get your API key from [console.enigma.com](https://console.enigma.com/).
|
|
20
|
+
|
|
21
|
+
## Skills
|
|
22
|
+
|
|
23
|
+
### enigma-graphql
|
|
24
|
+
|
|
25
|
+
Query the Enigma GraphQL API for business intelligence data — brands, operating locations, legal entities, corporate structure, and more.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
"Look up Starbucks in the Enigma API"
|
|
29
|
+
"Find all operating locations for Target in California"
|
|
30
|
+
"Search for the legal entity behind Acme Corp"
|
|
31
|
+
"Get the corporate structure for Nike"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### enigma-kyb
|
|
35
|
+
|
|
36
|
+
Verify businesses using the KYB (Know Your Business) API — check registration status, name verification, and due diligence.
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
"Verify Enigma Technologies Inc as a business"
|
|
40
|
+
"Run KYB on Acme Corp in New York"
|
|
41
|
+
"Check if this business is registered: Smith & Sons LLC, 123 Main St, Chicago IL"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### enigma-screen
|
|
45
|
+
|
|
46
|
+
Screen persons and organizations against sanctions, PEP, and watchlist databases.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
"Screen Vladimir Putin for sanctions"
|
|
50
|
+
"Check if Acme International Trading is on any watchlists"
|
|
51
|
+
"Run a sanctions screening on John Smith"
|
|
52
|
+
"Screen this text for sanctioned entities: 'Payment to Bank of Russia'"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### enigma-gov-archive
|
|
56
|
+
|
|
57
|
+
Search government archive public records — violations, licenses, liens, legal actions, debarments, and contracts.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
"Search government records for Blue Bottle Coffee"
|
|
61
|
+
"Find any violations or liens for Acme Corp"
|
|
62
|
+
"Check government archives for Smith Construction LLC"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Supported Entity Types (GraphQL)
|
|
66
|
+
|
|
67
|
+
| Type | What it represents |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Brand | A business brand/company — industries, locations, revenue signals |
|
|
70
|
+
| Operating Location | A physical business location — address, phone, status, reviews |
|
|
71
|
+
| Legal Entity | A registered legal entity — officers, TINs, filings, registrations |
|
|
72
|
+
| Person | A person — associated companies, officer roles, registrations |
|
|
73
|
+
|
|
74
|
+
## Documentation
|
|
75
|
+
|
|
76
|
+
- [Enigma API Docs](https://documentation.enigma.com/)
|
|
77
|
+
- [GraphQL Guide](https://documentation.enigma.com/guides/graphql)
|
|
78
|
+
- [API Playground](https://console.enigma.com/explore/graphql)
|
|
79
|
+
|
|
80
|
+
## Author
|
|
81
|
+
|
|
82
|
+
Jarrod Parker
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# ABOUTME: Shared pytest fixtures for the enigma-api test harness
|
|
2
|
+
# ABOUTME: Provides skill_path, skill_content, and graphql_blocks fixtures
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
Shared pytest fixtures for the Enigma GraphQL test harness.
|
|
6
|
+
|
|
7
|
+
Fixtures:
|
|
8
|
+
skill_path — absolute path to SKILL.md
|
|
9
|
+
skill_content — full text of SKILL.md
|
|
10
|
+
graphql_blocks — list of {query, variables, section, line_number} dicts
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
# Add scripts/ to path so we can import lint_query and query_validator
|
|
20
|
+
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
|
21
|
+
sys.path.insert(0, str(PLUGIN_ROOT / "scripts"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(scope="session")
|
|
25
|
+
def skill_path() -> Path:
|
|
26
|
+
"""Path to the SKILL.md file."""
|
|
27
|
+
p = PLUGIN_ROOT / "skills" / "enigma-graphql" / "SKILL.md"
|
|
28
|
+
assert p.exists(), f"SKILL.md not found at {p}"
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.fixture(scope="session")
|
|
33
|
+
def skill_content(skill_path: Path) -> str:
|
|
34
|
+
"""Full text content of SKILL.md."""
|
|
35
|
+
return skill_path.read_text(encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture(scope="session")
|
|
39
|
+
def graphql_blocks(skill_content: str) -> list[dict]:
|
|
40
|
+
"""All GraphQL code blocks extracted from SKILL.md with section context."""
|
|
41
|
+
from query_validator import extract_graphql_blocks_with_context
|
|
42
|
+
return extract_graphql_blocks_with_context(skill_content)
|