diffbot-mcp 0.2.0__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.
@@ -0,0 +1,3 @@
1
+ """Diffbot MCP server: web extraction, search, crawling, entity resolution, and Knowledge Graph tools."""
2
+
3
+ __version__ = "0.2.0"
diffbot_mcp/api.py ADDED
@@ -0,0 +1,128 @@
1
+ """Token resolution and the shared HTTP helper behind every Diffbot API call."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import pathlib
7
+ from typing import Any, Optional
8
+
9
+ import aiohttp
10
+ from fastmcp.exceptions import ToolError
11
+ from fastmcp.server.dependencies import get_access_token, get_http_request
12
+
13
+ # DIFFBOT_TOKEN is this server's historical name; DIFFBOT_API_TOKEN and the credentials
14
+ # file are what the db CLI and diffbot-skills read, so one credential serves both.
15
+ TOKEN_ENV_VARS = ("DIFFBOT_TOKEN", "DIFFBOT_API_TOKEN")
16
+ CREDENTIALS_PATH = pathlib.Path.home() / ".diffbot" / "credentials"
17
+ CREDENTIALS_KEY = "DIFFBOT_API_TOKEN"
18
+
19
+ REQUEST_TIMEOUT_SECONDS = 120
20
+
21
+ MISSING_TOKEN = (
22
+ "No Diffbot token found. On the HTTP transport pass it as 'Authorization: Bearer <token>' "
23
+ "or '?token=<token>'; otherwise set DIFFBOT_TOKEN or DIFFBOT_API_TOKEN in the environment, "
24
+ f"or write '{CREDENTIALS_KEY}=<token>' to {CREDENTIALS_PATH}. "
25
+ "Get a free token at https://app.diffbot.com/get-started"
26
+ )
27
+
28
+
29
+ def _credentials_file_token() -> str:
30
+ try:
31
+ lines = CREDENTIALS_PATH.read_text().splitlines()
32
+ except OSError:
33
+ return ""
34
+ for line in lines:
35
+ line = line.strip()
36
+ if line.startswith(f"{CREDENTIALS_KEY}="):
37
+ return line.split("=", 1)[1].strip().strip("'\"")
38
+ return ""
39
+
40
+
41
+ def resolve_token() -> str:
42
+ """Per-request credentials first, then the environment, then the credentials file."""
43
+ try:
44
+ request = get_http_request()
45
+ except RuntimeError:
46
+ request = None # stdio transport: no request in flight
47
+
48
+ if request is not None:
49
+ # Set when OAuth is on: the verified token resolves to the user's Diffbot API token
50
+ access_token = get_access_token()
51
+ if access_token is not None and access_token.claims.get("diffbot_token"):
52
+ return access_token.claims["diffbot_token"]
53
+ auth_header = request.headers.get("Authorization", "")
54
+ if auth_header.lower().startswith("bearer "):
55
+ return auth_header[7:].strip()
56
+ token = request.query_params.get("token")
57
+ if token:
58
+ return token
59
+
60
+ for var in TOKEN_ENV_VARS:
61
+ token = os.getenv(var, "").strip()
62
+ if token:
63
+ return token
64
+
65
+ token = _credentials_file_token()
66
+ if token:
67
+ return token
68
+
69
+ raise ToolError(MISSING_TOKEN)
70
+
71
+
72
+ async def _error_message(response: aiohttp.ClientResponse) -> str:
73
+ body = await response.text()
74
+ message: Optional[str] = None
75
+ try:
76
+ data = json.loads(body)
77
+ if isinstance(data, dict):
78
+ message = data.get("message") or data.get("error")
79
+ except ValueError:
80
+ pass
81
+
82
+ if response.status in (401, 403):
83
+ return "Invalid or unauthorized Diffbot token" + (f": {message}" if message else "")
84
+ if response.status == 429:
85
+ retry_after = response.headers.get("Retry-After")
86
+ return "Diffbot rate limit exceeded" + (f", retry after {retry_after}s" if retry_after else "")
87
+ if not message:
88
+ message = body[:200] + ("..." if len(body) > 200 else "")
89
+ return f"Diffbot API error {response.status}: {message}"
90
+
91
+
92
+ def clean_params(params: dict) -> dict:
93
+ """Drop None values and render booleans as the 1/0 the Diffbot APIs expect."""
94
+ cleaned = {}
95
+ for key, value in params.items():
96
+ if value is None:
97
+ continue
98
+ if isinstance(value, bool):
99
+ value = 1 if value else 0
100
+ cleaned[key] = value
101
+ return cleaned
102
+
103
+
104
+ async def request(
105
+ url: str,
106
+ *,
107
+ params: Optional[dict] = None,
108
+ json_body: Any = None,
109
+ headers: Optional[dict] = None,
110
+ as_text: bool = False,
111
+ method: str = "GET",
112
+ ) -> Any:
113
+ """One Diffbot API call. Non-2xx responses become a ToolError carrying the API's own message."""
114
+ timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
115
+ try:
116
+ async with aiohttp.ClientSession(timeout=timeout) as session:
117
+ async with session.request(
118
+ method, url, params=clean_params(params or {}), json=json_body, headers=headers
119
+ ) as response:
120
+ if response.status >= 400:
121
+ raise ToolError(await _error_message(response))
122
+ if as_text:
123
+ return await response.text()
124
+ return await response.json(content_type=None)
125
+ except asyncio.TimeoutError:
126
+ raise ToolError(f"Diffbot request timed out after {REQUEST_TIMEOUT_SECONDS}s: {url}")
127
+ except aiohttp.ClientError as error:
128
+ raise ToolError(f"Diffbot request failed: {error}")
diffbot_mcp/app.py ADDED
@@ -0,0 +1,11 @@
1
+ """The FastMCP instance. Kept in its own module so tool modules can import it without a cycle."""
2
+
3
+ from fastmcp import FastMCP
4
+ from mcp.types import ToolAnnotations
5
+
6
+ from .auth import build_auth
7
+
8
+ mcp = FastMCP(name="Diffbot MCP Server", auth=build_auth())
9
+
10
+ # Every tool except crawl only reads from Diffbot's APIs or the open web
11
+ READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True)
diffbot_mcp/auth.py ADDED
@@ -0,0 +1,82 @@
1
+ """OAuth via the Diffbot dashboard, enabled when DIFFBOT_OAUTH_ISSUER is set.
2
+
3
+ The dashboard issues dbat_-prefixed access tokens and introspects them for this server,
4
+ answering with the user's own Diffbot API token. Any other bearer token is a static
5
+ Diffbot API token and passes through for the Diffbot APIs to validate, as before OAuth.
6
+ """
7
+
8
+ import os
9
+ from typing import Optional
10
+ from urllib.parse import parse_qs
11
+
12
+ from fastmcp.server.auth import AccessToken, RemoteAuthProvider
13
+ from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier
14
+ from starlette.middleware import Middleware
15
+
16
+ OAUTH_TOKEN_PREFIX = "dbat_"
17
+ MCP_PATH = "/mcp"
18
+ SCOPE = "mcp"
19
+ INTROSPECTION_CACHE_SECONDS = 60
20
+
21
+
22
+ class DiffbotTokenVerifier(IntrospectionTokenVerifier):
23
+ """Introspects dashboard OAuth tokens and passes static Diffbot API tokens through."""
24
+
25
+ def __init__(self, *, resource_url: str, **kwargs):
26
+ super().__init__(**kwargs)
27
+ self.resource_url = resource_url.rstrip("/")
28
+
29
+ async def verify_token(self, token: str) -> Optional[AccessToken]:
30
+ if not token.startswith(OAUTH_TOKEN_PREFIX):
31
+ return AccessToken(token=token, client_id="diffbot-api-token", scopes=[SCOPE], claims={"diffbot_token": token})
32
+
33
+ access_token = await super().verify_token(token)
34
+ if access_token is None or not access_token.claims.get("diffbot_token"):
35
+ return None
36
+ # Not fastmcp's audience option: the dashboard omits aud when the client sent no
37
+ # RFC 8707 resource, and clients disagree on a trailing slash
38
+ aud = access_token.claims.get("aud") or []
39
+ audiences = [value.rstrip("/") for value in ([aud] if isinstance(aud, str) else aud)]
40
+ if audiences and self.resource_url not in audiences:
41
+ return None
42
+ return access_token
43
+
44
+
45
+ class QueryTokenMiddleware:
46
+ """Moves ?token= into the Authorization header so URL-configured clients pass the auth check."""
47
+
48
+ def __init__(self, app):
49
+ self.app = app
50
+
51
+ async def __call__(self, scope, receive, send):
52
+ if scope["type"] == "http" and not any(name == b"authorization" for name, _ in scope["headers"]):
53
+ token = parse_qs(scope.get("query_string", b"").decode()).get("token")
54
+ if token:
55
+ scope = {**scope, "headers": [*scope["headers"], (b"authorization", f"Bearer {token[0]}".encode())]}
56
+ await self.app(scope, receive, send)
57
+
58
+
59
+ class DiffbotAuthProvider(RemoteAuthProvider):
60
+ def get_middleware(self):
61
+ return [Middleware(QueryTokenMiddleware), *super().get_middleware()]
62
+
63
+
64
+ def build_auth() -> Optional[DiffbotAuthProvider]:
65
+ issuer = os.getenv("DIFFBOT_OAUTH_ISSUER", "").rstrip("/")
66
+ if not issuer:
67
+ return None
68
+ base_url = os.environ["MCP_BASE_URL"].rstrip("/")
69
+ verifier = DiffbotTokenVerifier(
70
+ introspection_url=f"{issuer}/oauth/introspect/",
71
+ client_id=os.environ["DIFFBOT_OAUTH_CLIENT_ID"],
72
+ client_secret=os.environ["DIFFBOT_OAUTH_CLIENT_SECRET"],
73
+ required_scopes=[SCOPE],
74
+ cache_ttl_seconds=INTROSPECTION_CACHE_SECONDS,
75
+ resource_url=base_url + MCP_PATH,
76
+ )
77
+ return DiffbotAuthProvider(
78
+ token_verifier=verifier,
79
+ authorization_servers=[issuer],
80
+ base_url=base_url,
81
+ resource_name="Diffbot",
82
+ )
diffbot_mcp/dql.py ADDED
@@ -0,0 +1,382 @@
1
+ """DQL execution, default column specs, and the cached ontology shared by every Knowledge Graph tool."""
2
+
3
+ import asyncio
4
+ import csv
5
+ import io
6
+ import re
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from fastmcp.exceptions import ToolError
12
+
13
+ from .api import request, resolve_token
14
+
15
+ DQL_ENDPOINT = "https://kg.diffbot.com/kg/v3/dql"
16
+ ONTOLOGY_ENDPOINT = "https://kg.diffbot.com/kg/ontology"
17
+ ONTOLOGY_TTL_SECONDS = 86400
18
+ PROBE_CONCURRENCY = 8
19
+
20
+ # A full DQL entity record is ~75kb, so rows are pulled through the csv export with a
21
+ # column spec. These are the defaults per entity type, mirroring the specs the
22
+ # diffbot-skills use; the caller can override them with the fields parameter.
23
+ PLACE_FIELDS = (
24
+ "name,Place;population,Population;placeType,Type;location.region.name,Region;"
25
+ "location.country.name,Country;importance,Importance;id,Id"
26
+ )
27
+ DEFAULT_FIELDS: Dict[str, str] = {
28
+ "Organization": (
29
+ "name,Company;descriptors,Description;nbEmployees,Employees;revenue.value,Revenue;"
30
+ "revenue.currency,Currency;location.city.name,City;location.country.name,Country;"
31
+ "homepageUri,Website;isPublic,Public;id,Id"
32
+ ),
33
+ "Person": (
34
+ "name,Name;$.employments[0].title,Title;$.employments[0].employer.name,Employer;"
35
+ "location.city.name,City;location.country.name,Country;linkedInUri,LinkedIn;id,Id"
36
+ ),
37
+ "Article": (
38
+ "date.str,Date;title,Title;siteName,Publisher;author,Author;sentiment,Sentiment;"
39
+ "pageUrl,URL;summary,Summary;id,Id"
40
+ ),
41
+ "Product": "name,Name;brand,Brand;category,Category;offerPrice,Price;summary,Summary;id,Id",
42
+ "Investment": (
43
+ "investee.name,Company;investment.series,Round;investment.amount.value,Amount;"
44
+ "investment.amount.currency,Currency;investment.date.str,Date;"
45
+ "investment.investors.name,Lead Investor;id,Id"
46
+ ),
47
+ "Transaction": (
48
+ "name,Name;payee.name,Payee;payers.name,Payer;amount.value,Amount;amount.currency,Currency;"
49
+ "date.str,Date;id,Id"
50
+ ),
51
+ "*": "id,Id;name,Name;description,Description",
52
+ }
53
+ for _place_type in ("Place", "City", "Subregion", "Region", "Country", "AdministrativeArea"):
54
+ DEFAULT_FIELDS[_place_type] = PLACE_FIELDS
55
+
56
+ TYPE_PATTERN = re.compile(r"\btype:([A-Za-z]+)")
57
+ # The csv export renders dates as d2026-07-23 and large numbers as 3.0E8.
58
+ DATE_VALUE = re.compile(r"^d(\d{4}-\d{2}-\d{2}.*)$")
59
+ SCI_NOTATION = re.compile(r"^-?\d+(\.\d+)?E[+-]?\d+$")
60
+
61
+
62
+ def query_type(query: str) -> Optional[str]:
63
+ match = TYPE_PATTERN.search(query)
64
+ return match.group(1) if match else None
65
+
66
+
67
+ def default_fields(query: str) -> str:
68
+ return DEFAULT_FIELDS.get(query_type(query) or "", DEFAULT_FIELDS["*"])
69
+
70
+
71
+ def clean_value(value: str) -> str:
72
+ match = DATE_VALUE.match(value)
73
+ if match:
74
+ return match.group(1)
75
+ if SCI_NOTATION.match(value):
76
+ number = float(value)
77
+ return str(int(number)) if number.is_integer() else str(number)
78
+ return value
79
+
80
+
81
+ def clean_row(row: Dict[str, str]) -> Dict[str, str]:
82
+ return {column: clean_value(value) for column, value in row.items() if value}
83
+
84
+
85
+ async def dql_request(params: dict, as_text: bool = False) -> Any:
86
+ return await request(DQL_ENDPOINT, params={"token": resolve_token(), **params}, as_text=as_text)
87
+
88
+
89
+ async def hits(query: str) -> int:
90
+ return (await dql_request({"query": query, "size": 0})).get("hits", 0)
91
+
92
+
93
+ async def probe(queries: List[str]) -> List[dict]:
94
+ """Hit counts for several queries in parallel; a failing query reports its error instead of failing the batch."""
95
+ semaphore = asyncio.Semaphore(PROBE_CONCURRENCY)
96
+
97
+ async def one(query: str) -> dict:
98
+ if "similarTo(" in query:
99
+ return {"query": query, "hits": None, "error": "similarTo cannot be probed: hits mirrors the requested size, so a size 0 probe always reports 0. Validate with a small export instead."}
100
+ async with semaphore:
101
+ try:
102
+ return {"query": query, "hits": await hits(query)}
103
+ except ToolError as error:
104
+ return {"query": query, "hits": None, "error": str(error)}
105
+
106
+ return list(await asyncio.gather(*(one(query) for query in queries)))
107
+
108
+
109
+ def is_facet(query: str) -> bool:
110
+ return "facet:" in query or "facet[" in query
111
+
112
+
113
+ async def run(
114
+ query: str,
115
+ *,
116
+ size: int = 10,
117
+ offset: int = 0,
118
+ fields: Optional[str] = None,
119
+ format: str = "csv",
120
+ hits_only: bool = False,
121
+ bare_type_check: bool = False,
122
+ ) -> dict:
123
+ """Execute a DQL query and shape the response: a count, facet buckets, csv rows, or json entities."""
124
+ if hits_only or size < 1:
125
+ return {"dql": query, "hits": await hits(query)}
126
+
127
+ notes: List[str] = []
128
+
129
+ # facet queries answer with aggregate buckets rather than entities, and the csv
130
+ # export is empty for them, so they are read from the json response instead
131
+ if is_facet(query):
132
+ result = await dql_request({"query": query, "size": max(size, 1)})
133
+ return {
134
+ "dql": query,
135
+ "hits": result.get("hits", 0),
136
+ "facet": True,
137
+ "results": [{
138
+ "value": bucket.get("value"),
139
+ "count": bucket.get("count"),
140
+ "query": bucket.get("callbackQuery"),
141
+ } for bucket in (result.get("data") or [])[:size]],
142
+ }
143
+
144
+ # similarTo returns exactly `size` rows ranked by similarity and reports hits equal
145
+ # to the size requested, so there is no meaningful total to fetch
146
+ similar = "similarTo(" in query
147
+ if similar:
148
+ notes.append("similarTo returns a ranked list of exactly `size` rows; there is no total hit count. Ask for a larger size to go further down the ranking.")
149
+
150
+ tasks: Dict[str, Any] = {}
151
+ if not similar:
152
+ tasks["count"] = dql_request({"query": query, "size": 0})
153
+ entity_type = query_type(query)
154
+ # an unknown field path is silently ignored by the API and returns the unfiltered
155
+ # type count, so the bare count is fetched alongside to make that visible. Article
156
+ # is skipped: an unfiltered probe over the article index times out.
157
+ if bare_type_check and not similar and entity_type and entity_type != "Article" and query.strip() != f"type:{entity_type}":
158
+ tasks["bare"] = dql_request({"query": f"type:{entity_type}", "size": 0})
159
+
160
+ page = {"query": query, "size": size}
161
+ if offset:
162
+ page["from"] = offset
163
+ if format == "json":
164
+ tasks["data"] = dql_request(page)
165
+ else:
166
+ tasks["data"] = dql_request({**page, "format": "csv", "exportspec": fields or default_fields(query)}, as_text=True)
167
+
168
+ values = await asyncio.gather(*tasks.values())
169
+ outcome = dict(zip(tasks.keys(), values))
170
+
171
+ total = None if similar else outcome["count"].get("hits", 0)
172
+ if "bare" in outcome and total is not None and total == outcome["bare"].get("hits", -1):
173
+ notes.append("Hit count equals the unfiltered type count: a filter path was probably ignored. Confirm field names with dql_ontology.")
174
+
175
+ if format == "json":
176
+ results = [record.get("entity") or record for record in (outcome["data"].get("data") or [])]
177
+ else:
178
+ results = [clean_row(row) for row in csv.DictReader(io.StringIO(outcome["data"]))]
179
+
180
+ response = {
181
+ "dql": query,
182
+ "hits": total,
183
+ "offset": offset,
184
+ "returned": len(results),
185
+ "results": results,
186
+ }
187
+ if notes:
188
+ response["notes"] = notes
189
+ return response
190
+
191
+
192
+ # --- ontology ---------------------------------------------------------------------
193
+
194
+ _ontology_cache: Dict[str, Any] = {}
195
+ _ontology_lock = asyncio.Lock()
196
+
197
+
198
+ def _ontology_fresh() -> bool:
199
+ return bool(_ontology_cache.get("data")) and time.time() - _ontology_cache.get("fetched", 0) < ONTOLOGY_TTL_SECONDS
200
+
201
+
202
+ async def get_ontology() -> dict:
203
+ """The ~700kb ontology changes rarely, so it is fetched once per process per day. The lock keeps concurrent first calls from downloading it twice."""
204
+ if _ontology_fresh():
205
+ return _ontology_cache["data"]
206
+ async with _ontology_lock:
207
+ if _ontology_fresh():
208
+ return _ontology_cache["data"]
209
+ data = await request(ONTOLOGY_ENDPOINT)
210
+ _ontology_cache["data"] = data
211
+ _ontology_cache["fetched"] = time.time()
212
+ return data
213
+
214
+
215
+ def format_field(name: str, meta: dict) -> dict:
216
+ field_type = meta.get("type", "?")
217
+ if field_type == "LinkedEntity":
218
+ linked = meta.get("leType") or []
219
+ if linked:
220
+ field_type = f"LinkedEntity ({linked[0]})"
221
+ result = {"field": name, "type": field_type}
222
+ flags = [flag for flag in ("isList", "isComposite", "isEnum") if meta.get(flag)]
223
+ if flags:
224
+ result["flags"] = flags
225
+ if meta.get("description"):
226
+ result["description"] = meta["description"]
227
+ return result
228
+
229
+
230
+ def ontology_names(ontology: dict, kind: str) -> List[str]:
231
+ return sorted(ontology.get(kind, {}).keys())
232
+
233
+
234
+ def ontology_fields(ontology: dict, name: str, pattern: Optional[re.Pattern] = None) -> List[dict]:
235
+ container = ontology.get("types", {}).get(name) or ontology.get("composites", {}).get(name)
236
+ if container is None:
237
+ raise ToolError(f"'{name}' is not a known entity type or composite. Use action 'types' or 'composites' to list the valid names.")
238
+ return [
239
+ format_field(field_name, meta)
240
+ for field_name, meta in (container.get("fields") or {}).items()
241
+ if not meta.get("isDeprecated") and (pattern is None or pattern.search(field_name))
242
+ ]
243
+
244
+
245
+ def taxonomy_values(ontology: dict, name: str, pattern: Optional[re.Pattern] = None) -> List[str]:
246
+ taxonomy = ontology.get("taxonomies", {}).get(name)
247
+ if taxonomy is None:
248
+ raise ToolError(f"'{name}' is not a known taxonomy. Use action 'taxonomies' to list the valid names.")
249
+ values: List[str] = []
250
+
251
+ def walk(node: dict) -> None:
252
+ value = node.get("name")
253
+ if value and (pattern is None or pattern.search(value)):
254
+ values.append(value)
255
+ for child in node.get("children") or []:
256
+ walk(child)
257
+
258
+ for category in taxonomy.get("categories") or []:
259
+ walk(category)
260
+ return values
261
+
262
+
263
+ def enum_values(ontology: dict, name: str) -> List[str]:
264
+ enum = ontology.get("enums", {}).get(name)
265
+ if enum is None:
266
+ raise ToolError(f"'{name}' is not a known enum. Use action 'enums' to list the valid names.")
267
+ return list(enum.get("values") or [])
268
+
269
+
270
+ def find_named(ontology: dict, pattern: re.Pattern) -> List[str]:
271
+ found = set()
272
+
273
+ def walk(node: Any) -> None:
274
+ if isinstance(node, dict):
275
+ value = node.get("name")
276
+ if isinstance(value, str) and pattern.search(value):
277
+ found.add(value)
278
+ for child in node.values():
279
+ walk(child)
280
+ elif isinstance(node, list):
281
+ for child in node:
282
+ walk(child)
283
+
284
+ walk(ontology)
285
+ return rank_names(found, pattern)
286
+
287
+
288
+ def rank_names(names, pattern: re.Pattern) -> List[str]:
289
+ """Whole-name match first, then prefix, then word-start, then substring; shorter names first within a tier."""
290
+ def key(name: str):
291
+ match = pattern.search(name)
292
+ if match is None:
293
+ return (4, len(name), name)
294
+ if match.start() == 0 and match.end() == len(name):
295
+ tier = 0
296
+ elif match.start() == 0:
297
+ tier = 1
298
+ elif not name[match.start() - 1].isalnum():
299
+ tier = 2
300
+ else:
301
+ tier = 3
302
+ return (tier, len(name), name)
303
+ return sorted(set(names), key=key)
304
+
305
+
306
+ @dataclass
307
+ class Resolved:
308
+ """Outcome of matching free text against a taxonomy or enum."""
309
+ value: Optional[str]
310
+ candidates: List[str] = field(default_factory=list)
311
+ note: Optional[str] = None
312
+
313
+
314
+ MAX_CANDIDATES = 20
315
+
316
+ # Words that carry no meaning for matching a taxonomy value: "loan companies" should match "Mortgage And Loans Lenders".
317
+ _FILLER = {"and", "or", "of", "the", "a", "an", "in", "for", "company", "companies", "corporation", "corporations",
318
+ "firm", "firms", "organization", "organizations", "business", "businesses", "industry"}
319
+
320
+
321
+ def _tokens(text: str) -> List[str]:
322
+ out = []
323
+ for token in re.split(r"[^a-z0-9]+", text.lower()):
324
+ if not token or token in _FILLER:
325
+ continue
326
+ out.append(token[:-1] if len(token) > 3 and token.endswith("s") else token)
327
+ return out
328
+
329
+
330
+ def _initials(value: str) -> str:
331
+ return "".join(word[0] for word in re.findall(r"[A-Za-z0-9]+", value)).lower()
332
+
333
+
334
+ def rank_values(values: List[str], text: str) -> List[str]:
335
+ """Taxonomy values that plausibly mean `text`, best first, from generic rules only.
336
+
337
+ Tiers: exact (case-insensitive) > substring > every content token present > acronym of the value's words
338
+ ("SaaS" -> "Software As A Service Companies"). Within a tier shorter values come first since they are the
339
+ broader category. Returns [] when nothing matches.
340
+ """
341
+ lowered = text.strip().lower()
342
+ if not lowered:
343
+ return []
344
+ exact = [v for v in values if v.lower() == lowered]
345
+ if exact:
346
+ return exact
347
+ compact = re.sub(r"[^a-z0-9]", "", lowered)
348
+ short = len(compact) <= 4 and " " not in lowered
349
+ # a short query like "EV" must match a whole word, or "DevOps" and "Beverage" would come back
350
+ word = re.compile(rf"\b{re.escape(lowered)}\b")
351
+ substring = {v for v in values if (word.search(v.lower()) if short else lowered in v.lower())}
352
+ if substring:
353
+ return sorted(substring, key=lambda v: (not v.lower().startswith(lowered), len(v), v))
354
+ wanted = _tokens(text)
355
+ if wanted:
356
+ by_tokens = []
357
+ for v in values:
358
+ have = set(_tokens(v))
359
+ if all(t in have for t in wanted):
360
+ by_tokens.append(v)
361
+ if by_tokens:
362
+ return sorted(set(by_tokens), key=lambda v: (len(_tokens(v)), len(v), v))
363
+ if 2 <= len(compact) <= 6 and " " not in lowered:
364
+ acronym = [v for v in values if _initials(v).startswith(compact)]
365
+ if acronym:
366
+ return sorted(set(acronym), key=lambda v: (len(v), v))
367
+ return []
368
+
369
+
370
+ async def resolve_value(name: str, text: str, kind: str = "taxonomy") -> Resolved:
371
+ """Match text to one taxonomy or enum value, or return ranked candidates when several could be meant."""
372
+ ontology = await get_ontology()
373
+ values = taxonomy_values(ontology, name) if kind == "taxonomy" else enum_values(ontology, name)
374
+ if text in values:
375
+ return Resolved(text)
376
+ matches = rank_values(values, text)
377
+ if len(matches) == 1:
378
+ return Resolved(matches[0], note=f"'{text}' resolved to the {name} value '{matches[0]}'")
379
+ if matches:
380
+ return Resolved(None, matches[:MAX_CANDIDATES], f"'{text}' could mean {len(matches)} {name} values; pass one of the candidates exactly")
381
+ key = (_tokens(text) or [text.strip()])[0]
382
+ return Resolved(None, [], f"'{text}' is not a {name} value; find the exact spelling with dql_ontology(action='taxonomy', name='{name}', search='{key}')")