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.
- diffbot_mcp/__init__.py +3 -0
- diffbot_mcp/api.py +128 -0
- diffbot_mcp/app.py +11 -0
- diffbot_mcp/auth.py +82 -0
- diffbot_mcp/dql.py +382 -0
- diffbot_mcp/dql_builder.py +223 -0
- diffbot_mcp/schema.py +34 -0
- diffbot_mcp/server.py +22 -0
- diffbot_mcp/tools/__init__.py +1 -0
- diffbot_mcp/tools/crawl.py +140 -0
- diffbot_mcp/tools/deals.py +234 -0
- diffbot_mcp/tools/dql.py +28 -0
- diffbot_mcp/tools/dql_ontology.py +50 -0
- diffbot_mcp/tools/dql_probe.py +18 -0
- diffbot_mcp/tools/extract.py +38 -0
- diffbot_mcp/tools/news.py +121 -0
- diffbot_mcp/tools/organizations.py +191 -0
- diffbot_mcp/tools/people.py +254 -0
- diffbot_mcp/tools/places.py +109 -0
- diffbot_mcp/tools/resolve_entities.py +52 -0
- diffbot_mcp/tools/search_web.py +47 -0
- diffbot_mcp-0.2.0.dist-info/METADATA +233 -0
- diffbot_mcp-0.2.0.dist-info/RECORD +28 -0
- diffbot_mcp-0.2.0.dist-info/WHEEL +5 -0
- diffbot_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- diffbot_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- diffbot_mcp-0.2.0.dist-info/top_level.txt +2 -0
- diffbot_mcp_server.py +11 -0
diffbot_mcp/__init__.py
ADDED
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}')")
|