answerline 0.3.0__tar.gz → 0.6.0__tar.gz
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.
- {answerline-0.3.0/src/answerline.egg-info → answerline-0.6.0}/PKG-INFO +11 -5
- {answerline-0.3.0 → answerline-0.6.0}/README.md +10 -4
- {answerline-0.3.0 → answerline-0.6.0}/pyproject.toml +1 -1
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline/__init__.py +99 -66
- {answerline-0.3.0 → answerline-0.6.0/src/answerline.egg-info}/PKG-INFO +11 -5
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline.egg-info/SOURCES.txt +0 -2
- {answerline-0.3.0 → answerline-0.6.0}/tests/test_client.py +10 -2
- answerline-0.3.0/tests/localhost-cert.pem +0 -11
- answerline-0.3.0/tests/localhost-key.pem +0 -5
- {answerline-0.3.0 → answerline-0.6.0}/LICENSE +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/setup.cfg +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline/py.typed +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline/visibility.py +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline/webhooks.py +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline.egg-info/dependency_links.txt +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline.egg-info/requires.txt +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/src/answerline.egg-info/top_level.txt +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/tests/test_visibility.py +0 -0
- {answerline-0.3.0 → answerline-0.6.0}/tests/test_webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: answerline
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Python client for the AnswerLine answer and search monitoring API.
|
|
5
5
|
Author-email: AnswerLine <support@answerline.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
|
|
24
24
|
(ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
pip install answerline
|
|
@@ -37,6 +37,10 @@ with Client("sk_...") as client:
|
|
|
37
37
|
# copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
|
|
38
38
|
answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
|
|
39
39
|
|
|
40
|
+
# A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
|
|
41
|
+
# A state implies country "US"; with another country it is refused with a 400.
|
|
42
|
+
texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
|
|
43
|
+
|
|
40
44
|
# Async tasks: create, poll, or run to a terminal status.
|
|
41
45
|
task = client.async_tasks.run(
|
|
42
46
|
"CHATGPT",
|
|
@@ -44,7 +48,8 @@ with Client("sk_...") as client:
|
|
|
44
48
|
idempotency_key="phone-compare-1",
|
|
45
49
|
)
|
|
46
50
|
|
|
47
|
-
client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
|
|
51
|
+
client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
|
|
52
|
+
client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
`AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
|
|
@@ -52,7 +57,7 @@ with Client("sk_...") as client:
|
|
|
52
57
|
the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
|
|
53
58
|
`verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
|
|
54
59
|
|
|
55
|
-
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency,
|
|
60
|
+
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
|
|
56
61
|
and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
|
|
57
62
|
failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
|
|
58
63
|
constructor options.
|
|
@@ -62,7 +67,8 @@ constructor options.
|
|
|
62
67
|
One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
|
|
63
68
|
`samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
|
|
64
69
|
`brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
|
|
65
|
-
brands you list, how many cite its own domains, and which domains get cited when it is absent.
|
|
70
|
+
brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
|
|
71
|
+
number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
|
|
66
72
|
|
|
67
73
|
```python
|
|
68
74
|
from answerline import brand_check
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
|
|
4
4
|
(ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
pip install answerline
|
|
@@ -17,6 +17,10 @@ with Client("sk_...") as client:
|
|
|
17
17
|
# copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
|
|
18
18
|
answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
|
|
19
19
|
|
|
20
|
+
# A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
|
|
21
|
+
# A state implies country "US"; with another country it is refused with a 400.
|
|
22
|
+
texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
|
|
23
|
+
|
|
20
24
|
# Async tasks: create, poll, or run to a terminal status.
|
|
21
25
|
task = client.async_tasks.run(
|
|
22
26
|
"CHATGPT",
|
|
@@ -24,7 +28,8 @@ with Client("sk_...") as client:
|
|
|
24
28
|
idempotency_key="phone-compare-1",
|
|
25
29
|
)
|
|
26
30
|
|
|
27
|
-
client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
|
|
31
|
+
client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
|
|
32
|
+
client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
|
|
28
33
|
```
|
|
29
34
|
|
|
30
35
|
`AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
|
|
@@ -32,7 +37,7 @@ with Client("sk_...") as client:
|
|
|
32
37
|
the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
|
|
33
38
|
`verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
|
|
34
39
|
|
|
35
|
-
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency,
|
|
40
|
+
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
|
|
36
41
|
and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
|
|
37
42
|
failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
|
|
38
43
|
constructor options.
|
|
@@ -42,7 +47,8 @@ constructor options.
|
|
|
42
47
|
One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
|
|
43
48
|
`samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
|
|
44
49
|
`brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
|
|
45
|
-
brands you list, how many cite its own domains, and which domains get cited when it is absent.
|
|
50
|
+
brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
|
|
51
|
+
number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
|
|
46
52
|
|
|
47
53
|
```python
|
|
48
54
|
from answerline import brand_check
|
|
@@ -43,26 +43,26 @@ import httpx
|
|
|
43
43
|
from .visibility import brand_check, wilson
|
|
44
44
|
from .webhooks import SIGNATURE_HEADER, verify_webhook
|
|
45
45
|
|
|
46
|
-
__all__ = ["ANSWER_ENGINES", "MAX_BATCH", "
|
|
46
|
+
__all__ = ["ANSWER_ENGINES", "MAX_BATCH", "ApiError", "AsyncClient", "Client", "Meta",
|
|
47
47
|
"SIGNATURE_HEADER", "brand_check", "verify_webhook", "wilson"]
|
|
48
48
|
|
|
49
49
|
# Sent as `answerline-py/<version>`; keep in step with pyproject.toml.
|
|
50
|
-
__version__ = "0.
|
|
50
|
+
__version__ = "0.6.0"
|
|
51
51
|
MAX_BATCH = 500
|
|
52
52
|
# The answer engines `visibility.measure` samples, each asked with `prompt` and `country`, and their task types.
|
|
53
53
|
ANSWER_ENGINES = {"chatgpt": "CHATGPT", "perplexity": "PERPLEXITY", "gemini": "GEMINI", "copilot": "COPILOT",
|
|
54
54
|
"grok": "GROK", "aimode": "AIMODE"}
|
|
55
|
-
MAX_SAMPLES = 100
|
|
56
55
|
DEFAULT_BASE_URL = "https://api.answerline.dev"
|
|
57
56
|
TIMEOUT_S = 30.0
|
|
58
|
-
# Sync monitor calls and task creation: the server answers a sync call within its
|
|
59
|
-
# (6.5 s), and keeps resending a creation through a failover until
|
|
57
|
+
# Sync monitor calls and task creation: the server answers a sync call within its 630 s deadline plus one result read
|
|
58
|
+
# (6.5 s), and keeps resending a creation through a failover until 630 s, so the client never gives up just as the
|
|
60
59
|
# server answers.
|
|
61
|
-
SYNC_TIMEOUT_S =
|
|
62
|
-
# The server resends a clear that did not reach its dispatcher for up to 120 s, then
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
SYNC_TIMEOUT_S = 660.0
|
|
61
|
+
# The server resends a clear that did not reach its dispatcher for up to 120 s, then answers within one 10 s call
|
|
62
|
+
# (202 `clearing` for a queue longer than that call clears), plus margin.
|
|
63
|
+
CLEAR_QUEUE_TIMEOUT_S = 150.0
|
|
64
|
+
# A task's longest run once started (10 minutes) and a wait for a slot, plus margin.
|
|
65
|
+
WAIT_TIMEOUT_S = 960.0
|
|
66
66
|
MAX_RETRIES = 2
|
|
67
67
|
_BACKOFF_S = 0.5
|
|
68
68
|
_MAX_DELAY_S = 8.0
|
|
@@ -84,7 +84,7 @@ class _Secret:
|
|
|
84
84
|
|
|
85
85
|
_IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "OPTIONS"})
|
|
86
86
|
#: POSTs that create nothing and cost nothing, so repeating one is as safe as a GET: resolving a Google
|
|
87
|
-
#: redirect link, whose contract documents transient
|
|
87
|
+
#: redirect link, whose contract documents transient failures from Google.
|
|
88
88
|
_REPLAYABLE_PATHS = frozenset({"/v1/monitor/google/goto"})
|
|
89
89
|
# No connection was made, so the request never left.
|
|
90
90
|
_NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
@@ -97,9 +97,13 @@ _LIMIT_HEADERS = {
|
|
|
97
97
|
"concurrent_limit": "X-Concurrent-Limit",
|
|
98
98
|
"concurrent_current": "X-Concurrent-Current",
|
|
99
99
|
"concurrent_remaining": "X-Concurrent-Remaining",
|
|
100
|
+
"latency_ms": "X-Latency-Ms",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# USD amount headers (up to six decimals); the integer map above refuses them.
|
|
104
|
+
_CREDIT_HEADERS = {
|
|
100
105
|
"credits_remaining": "X-Credits-Remaining",
|
|
101
106
|
"credits_charged": "X-Credits-Charged",
|
|
102
|
-
"latency_ms": "X-Latency-Ms",
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
T = TypeVar("T")
|
|
@@ -121,8 +125,9 @@ class Meta:
|
|
|
121
125
|
concurrent_limit: Optional[int] = None
|
|
122
126
|
concurrent_current: Optional[int] = None
|
|
123
127
|
concurrent_remaining: Optional[int] = None
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
# Amounts are USD and fractional (up to 6 decimals).
|
|
129
|
+
credits_remaining: Optional[float] = None
|
|
130
|
+
credits_charged: Optional[float] = None
|
|
126
131
|
latency_ms: Optional[int] = None
|
|
127
132
|
|
|
128
133
|
|
|
@@ -234,29 +239,31 @@ class _Api:
|
|
|
234
239
|
"""Visibility helpers: `client.visibility.measure(prompt=..., engines=[...], country=..., brand={...})`."""
|
|
235
240
|
return _Visibility(self)
|
|
236
241
|
|
|
237
|
-
def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
|
|
238
|
-
return self.monitor("chatgpt", prompt
|
|
242
|
+
def chatgpt(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
243
|
+
return self.monitor("chatgpt", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
239
244
|
|
|
240
|
-
def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
|
|
241
|
-
return self.monitor("perplexity", prompt
|
|
245
|
+
def perplexity(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
246
|
+
return self.monitor("perplexity", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
242
247
|
|
|
243
|
-
def gemini(self, prompt: str, country: str, **options: Any) -> Any:
|
|
244
|
-
return self.monitor("gemini", prompt
|
|
248
|
+
def gemini(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
249
|
+
return self.monitor("gemini", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
245
250
|
|
|
246
|
-
def copilot(self, prompt: str, country: str, **options: Any) -> Any:
|
|
247
|
-
return self.monitor("copilot", prompt
|
|
251
|
+
def copilot(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
252
|
+
return self.monitor("copilot", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
248
253
|
|
|
249
|
-
def grok(self, prompt: str, country: str, **options: Any) -> Any:
|
|
250
|
-
return self.monitor("grok", prompt
|
|
254
|
+
def grok(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
255
|
+
return self.monitor("grok", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
251
256
|
|
|
252
|
-
def ai_mode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None,
|
|
253
|
-
|
|
257
|
+
def ai_mode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
|
|
258
|
+
**options: Any) -> Any:
|
|
259
|
+
return self.monitor("aimode", **_geo({"prompt": prompt}, {"country": country, "gl": gl, "state": state}, options))
|
|
254
260
|
|
|
255
261
|
def google(self, **options: Any) -> Any:
|
|
256
262
|
return self.monitor("google", **options)
|
|
257
263
|
|
|
258
|
-
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None,
|
|
259
|
-
|
|
264
|
+
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
|
|
265
|
+
**options: Any) -> Any:
|
|
266
|
+
return self.monitor("google/news", **_geo({"query": query}, {"country": country, "gl": gl, "state": state}, options))
|
|
260
267
|
|
|
261
268
|
# -- async tasks ----------------------------------------------------------
|
|
262
269
|
def create_task(self, task_type: str, payload: Dict[str, Any], priority: Optional[int] = None,
|
|
@@ -295,6 +302,10 @@ class _Api:
|
|
|
295
302
|
try:
|
|
296
303
|
task, meta = yield from poller._request("GET", _task_path(task_id), None)
|
|
297
304
|
except (ApiError, httpx.TransportError) as err:
|
|
305
|
+
# A poll given the time left that ran out of it is the deadline passing, whatever the clock now says: a
|
|
306
|
+
# timer can fire a few milliseconds early, and the poll that followed could never answer in time.
|
|
307
|
+
if isinstance(err, httpx.TimeoutException) and remaining <= self.timeout:
|
|
308
|
+
raise TimeoutError(f"task {task_id} did not finish in time") from err
|
|
298
309
|
# A rate limit's Retry-After is waited out while it ends before the deadline; one past it raises now.
|
|
299
310
|
delay = _retry_delay(err, True, poll, interval, deadline - time.monotonic())
|
|
300
311
|
if delay is None:
|
|
@@ -314,14 +325,21 @@ class _Api:
|
|
|
314
325
|
return self._run(self._data("GET", "/v1/async/status"))
|
|
315
326
|
|
|
316
327
|
def clear_queue(self) -> Any:
|
|
317
|
-
"""Deletes QUEUED tasks (never charged)
|
|
318
|
-
|
|
328
|
+
"""Deletes QUEUED tasks (never charged): `{"success": true, "cleared": n}`, or `{"success": true, "clearing": true}`
|
|
329
|
+
when the queue is longer than one call clears and the rest is still being deleted (`status()` shows it shrink).
|
|
330
|
+
The server may first wait out a dispatcher failover, so it gets a longer timeout (the larger of
|
|
331
|
+
`CLEAR_QUEUE_TIMEOUT_S` and the client's `timeout`)."""
|
|
319
332
|
return self._run(self._data("DELETE", "/v1/async/queue", timeout=max(CLEAR_QUEUE_TIMEOUT_S, self.timeout)))
|
|
320
333
|
|
|
321
334
|
# -- utility ----------------------------------------------------------------
|
|
322
335
|
def credits(self) -> Any:
|
|
323
336
|
return self._run(self._data("GET", "/v1/credits"))
|
|
324
337
|
|
|
338
|
+
def engines(self) -> Any:
|
|
339
|
+
"""Each engine you can call: `status` now, and its success rate over everyone's and your own newest requests
|
|
340
|
+
(100 with none)."""
|
|
341
|
+
return self._run(self._data("GET", "/v1/engines"))
|
|
342
|
+
|
|
325
343
|
def countries(self, model: Optional[str] = None) -> Any:
|
|
326
344
|
query = f"?model={urllib.parse.quote(model, safe='')}" if model else ""
|
|
327
345
|
return self._run(self._data("GET", f"/v1/countries{query}"))
|
|
@@ -348,34 +366,33 @@ class _Monitor:
|
|
|
348
366
|
def __call__(self, engine: str, **body: Any) -> Any:
|
|
349
367
|
return self._api._run(self._api._data("POST", f"/v1/monitor/{engine}", body))
|
|
350
368
|
|
|
351
|
-
def chatgpt(self, prompt: str, country: str, **options: Any) -> Any:
|
|
352
|
-
return self("chatgpt", prompt
|
|
369
|
+
def chatgpt(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
370
|
+
return self("chatgpt", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
353
371
|
|
|
354
|
-
def perplexity(self, prompt: str, country: str, **options: Any) -> Any:
|
|
355
|
-
return self("perplexity", prompt
|
|
372
|
+
def perplexity(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
373
|
+
return self("perplexity", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
356
374
|
|
|
357
|
-
def gemini(self, prompt: str, country: str, **options: Any) -> Any:
|
|
358
|
-
return self("gemini", prompt
|
|
375
|
+
def gemini(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
376
|
+
return self("gemini", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
359
377
|
|
|
360
|
-
def copilot(self, prompt: str, country: str, **options: Any) -> Any:
|
|
361
|
-
return self("copilot", prompt
|
|
378
|
+
def copilot(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
379
|
+
return self("copilot", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
362
380
|
|
|
363
|
-
def grok(self, prompt: str, country: str, **options: Any) -> Any:
|
|
364
|
-
return self("grok", prompt
|
|
381
|
+
def grok(self, prompt: str, country: Optional[str] = None, state: Optional[str] = None, **options: Any) -> Any:
|
|
382
|
+
return self("grok", **_geo({"prompt": prompt}, {"country": country, "state": state}, options))
|
|
365
383
|
|
|
366
|
-
def aimode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None,
|
|
367
|
-
|
|
384
|
+
def aimode(self, prompt: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
|
|
385
|
+
**options: Any) -> Any:
|
|
386
|
+
return self("aimode", **_geo({"prompt": prompt}, {"country": country, "gl": gl, "state": state}, options))
|
|
368
387
|
|
|
369
|
-
def google(self, query: Optional[str] = None, country: Optional[str] = None,
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if country is not None:
|
|
374
|
-
body["country"] = country
|
|
375
|
-
return self("google", **body)
|
|
388
|
+
def google(self, query: Optional[str] = None, country: Optional[str] = None, state: Optional[str] = None,
|
|
389
|
+
**options: Any) -> Any:
|
|
390
|
+
given = {"query": query, "country": country, "state": state}
|
|
391
|
+
return self("google", **{**options, **{k: v for k, v in given.items() if v is not None}})
|
|
376
392
|
|
|
377
|
-
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None,
|
|
378
|
-
|
|
393
|
+
def google_news(self, query: str, country: Optional[str] = None, gl: Optional[str] = None, state: Optional[str] = None,
|
|
394
|
+
**options: Any) -> Any:
|
|
395
|
+
return self("google/news", **_geo({"query": query}, {"country": country, "gl": gl, "state": state}, options))
|
|
379
396
|
|
|
380
397
|
|
|
381
398
|
class _AsyncTasks:
|
|
@@ -429,8 +446,8 @@ class _Visibility:
|
|
|
429
446
|
def measure(self, prompt: str, engines: List[str], country: str, samples: int = 10,
|
|
430
447
|
include: Optional[Dict[str, Any]] = None, rounds: int = 1, spacing_seconds: float = 0.0,
|
|
431
448
|
brand: Optional[Dict[str, Any]] = None, competitors: Optional[List[Dict[str, Any]]] = None,
|
|
432
|
-
timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0) -> Any:
|
|
433
|
-
"""Asks each engine (keys of `ANSWER_ENGINES`) the same prompt `samples` times (1
|
|
449
|
+
timeout: float = WAIT_TIMEOUT_S, interval: float = 10.0, state: Optional[str] = None) -> Any:
|
|
450
|
+
"""Asks each engine (keys of `ANSWER_ENGINES`) the same prompt `samples` times (an integer, 1 or more) as
|
|
434
451
|
independent async tasks, each under its own idempotency key, waits for them and returns per engine
|
|
435
452
|
`{"requested", "completed", "failed", "results", "errors", "unfinished"}`, plus `"brand_check"` (see
|
|
436
453
|
`brand_check`) when a `brand` is given. `unfinished` lists the ids of submitted tasks whose wait failed: they may
|
|
@@ -440,7 +457,8 @@ class _Visibility:
|
|
|
440
457
|
failed and the rest go on; only cancellation propagates.
|
|
441
458
|
|
|
442
459
|
`rounds` splits each engine's samples into that many batches, `spacing_seconds` apart start to start (default:
|
|
443
|
-
one round, all at once). `include`
|
|
460
|
+
one round, all at once). `include` and `state` (a US state code: ChatGPT answers from inside it, the other
|
|
461
|
+
engines for the whole US) are sent with every sample. `timeout` is one deadline for all the waits,
|
|
444
462
|
starting once the last round is submitted: each task's wait gets the time left, and a task still unfinished
|
|
445
463
|
when it passes is a failed sample. `interval` applies to each wait, as in `wait_for_task`.
|
|
446
464
|
|
|
@@ -448,8 +466,8 @@ class _Visibility:
|
|
|
448
466
|
or hours apart measure how much the engine varies its own answer to this prompt from this country, not what
|
|
449
467
|
different users see. Small samples give wide intervals (3 of 5 is anywhere from 23% to 88%): take 10 or more
|
|
450
468
|
per engine, spread over hours with `rounds` and `spacing_seconds`."""
|
|
451
|
-
if isinstance(samples, bool) or not isinstance(samples, int) or
|
|
452
|
-
raise ValueError(f"samples is an integer
|
|
469
|
+
if isinstance(samples, bool) or not isinstance(samples, int) or samples < 1:
|
|
470
|
+
raise ValueError(f"samples is an integer of 1 or more, got {samples!r}")
|
|
453
471
|
if isinstance(rounds, bool) or not isinstance(rounds, int) or not 1 <= rounds <= samples:
|
|
454
472
|
raise ValueError(f"rounds is an integer from 1 to samples, got {rounds!r}")
|
|
455
473
|
if not spacing_seconds >= 0:
|
|
@@ -457,6 +475,8 @@ class _Visibility:
|
|
|
457
475
|
if not engines or len(set(engines)) < len(engines) or not all(e in ANSWER_ENGINES for e in engines):
|
|
458
476
|
raise ValueError(f"engines are distinct answer engines ({', '.join(ANSWER_ENGINES)}), got {engines!r}")
|
|
459
477
|
payload: Dict[str, Any] = {"prompt": prompt, "country": country}
|
|
478
|
+
if state is not None:
|
|
479
|
+
payload["state"] = state
|
|
460
480
|
if include is not None:
|
|
461
481
|
payload["include"] = include
|
|
462
482
|
api = self._api
|
|
@@ -494,10 +514,16 @@ class _Visibility:
|
|
|
494
514
|
# The tasks run concurrently on the server, so one deadline covers every wait: a stalled engine costs
|
|
495
515
|
# `timeout` once.
|
|
496
516
|
deadline = time.monotonic() + timeout
|
|
517
|
+
# A wait that timed out was given all the time left, so the deadline has passed for every task after it,
|
|
518
|
+
# whatever the clock says: a timer can fire a few milliseconds early, and a poll in that sliver never answers.
|
|
519
|
+
expired = False
|
|
497
520
|
for engine, task_id in tasks:
|
|
498
521
|
try:
|
|
522
|
+
if expired:
|
|
523
|
+
raise TimeoutError(f"task {task_id} did not finish in time")
|
|
499
524
|
done = yield from api._wait(task_id, deadline - time.monotonic(), interval)
|
|
500
525
|
except Exception as err:
|
|
526
|
+
expired = expired or isinstance(err, TimeoutError)
|
|
501
527
|
fail(engine, err)
|
|
502
528
|
out[engine]["unfinished"].append(task_id)
|
|
503
529
|
continue
|
|
@@ -570,15 +596,14 @@ class AsyncClient(_Api):
|
|
|
570
596
|
await self.aclose()
|
|
571
597
|
|
|
572
598
|
|
|
573
|
-
def _geo(body: Dict[str, Any],
|
|
574
|
-
"""The
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
raise ValueError("country or gl is required")
|
|
599
|
+
def _geo(body: Dict[str, Any], geo: Dict[str, Optional[str]], options: Dict[str, Any]) -> Dict[str, Any]:
|
|
600
|
+
"""The body with the geo fields given, over `options`; at least one of them is required. `state` is a US state code
|
|
601
|
+
(e.g. "TX") and implies country "US"; with any other country it is refused with a 400. ChatGPT answers from inside
|
|
602
|
+
the state; every other engine answers it for the whole US (https://answerline.dev/docs/coverage#states). Given both
|
|
603
|
+
`country` and `gl`, `gl` serves the engine's locale."""
|
|
604
|
+
body = {**body, **options, **{k: v for k, v in geo.items() if v is not None}}
|
|
605
|
+
if not body.keys() & geo.keys():
|
|
606
|
+
raise ValueError(f"{' or '.join(geo)} is required")
|
|
582
607
|
return body
|
|
583
608
|
|
|
584
609
|
|
|
@@ -598,7 +623,8 @@ def _parse(outcome: Union[httpx.Response, Exception]) -> Tuple[Any, Meta]:
|
|
|
598
623
|
raise outcome
|
|
599
624
|
headers = outcome.headers
|
|
600
625
|
meta = Meta(outcome.status_code, headers, headers.get("X-Request-Id"),
|
|
601
|
-
**{name: _int(headers.get(header)) for name, header in _LIMIT_HEADERS.items()}
|
|
626
|
+
**{name: _int(headers.get(header)) for name, header in _LIMIT_HEADERS.items()},
|
|
627
|
+
**{name: _float(headers.get(header)) for name, header in _CREDIT_HEADERS.items()})
|
|
602
628
|
is_json, parsed = _decode(outcome.content)
|
|
603
629
|
if not outcome.is_success or not is_json:
|
|
604
630
|
error = parsed.get("error") if isinstance(parsed, dict) else None
|
|
@@ -618,6 +644,13 @@ def _int(value: Optional[str]) -> Optional[int]:
|
|
|
618
644
|
return None
|
|
619
645
|
|
|
620
646
|
|
|
647
|
+
def _float(value: Optional[str]) -> Optional[float]:
|
|
648
|
+
try:
|
|
649
|
+
return float(value) if value is not None else None
|
|
650
|
+
except ValueError:
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
|
|
621
654
|
def _decode(raw: bytes) -> Tuple[bool, Any]:
|
|
622
655
|
"""(True, value) for an empty or JSON body, (False, text) otherwise."""
|
|
623
656
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: answerline
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Python client for the AnswerLine answer and search monitoring API.
|
|
5
5
|
Author-email: AnswerLine <support@answerline.dev>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -23,7 +23,7 @@ Dynamic: license-file
|
|
|
23
23
|
Python client for the [AnswerLine](https://answerline.dev) API — structured JSON answers from AI assistants
|
|
24
24
|
(ChatGPT, Perplexity, Gemini, Copilot, Grok) and Google surfaces (Search, News, AI Overview, AI Mode), on httpx.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
All eight engines are served. [Coverage](https://answerline.dev/docs/coverage) lists the countries each one is served from.
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
pip install answerline
|
|
@@ -37,6 +37,10 @@ with Client("sk_...") as client:
|
|
|
37
37
|
# copilot, grok, aimode, google, google_news), or client.monitor(engine, **body).
|
|
38
38
|
answer = client.monitor.chatgpt(prompt="iPhone 17 or Galaxy S26?", country="US")
|
|
39
39
|
|
|
40
|
+
# A US state: ChatGPT answers from inside Texas; every other engine answers it for the whole US.
|
|
41
|
+
# A state implies country "US"; with another country it is refused with a 400.
|
|
42
|
+
texas = client.monitor.chatgpt(prompt="Best pickup truck for towing?", state="TX")
|
|
43
|
+
|
|
40
44
|
# Async tasks: create, poll, or run to a terminal status.
|
|
41
45
|
task = client.async_tasks.run(
|
|
42
46
|
"CHATGPT",
|
|
@@ -44,7 +48,8 @@ with Client("sk_...") as client:
|
|
|
44
48
|
idempotency_key="phone-compare-1",
|
|
45
49
|
)
|
|
46
50
|
|
|
47
|
-
client.credits() # {"remaining", "perCycle", "cycleResetsAt"}
|
|
51
|
+
client.credits() # {"remaining": USD balance, "perCycle": None, "cycleResetsAt": None}
|
|
52
|
+
client.engines() # {"windowRequests": n, "engines": [{"taskType", "name", "status", "successRate", "yourSuccessRate", "yourRequests"}]}
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
`AsyncClient` has the same methods, awaitable. `async_tasks.create_batch` submits up to 500 tasks in one call;
|
|
@@ -52,7 +57,7 @@ with Client("sk_...") as client:
|
|
|
52
57
|
the queue. Prefer a webhook (`webhook_url=` on the task) over polling for many tasks — verify deliveries with
|
|
53
58
|
`verify_webhook(body, header, secret)` against the `Webhook-Signature` header.
|
|
54
59
|
|
|
55
|
-
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency,
|
|
60
|
+
Calls raise `ApiError` (`status`, stable `code`, parsed `body`, and `meta` with the rate-limit, concurrency, USD balance
|
|
56
61
|
and request-id headers); transport failures raise `TimeoutError` or `ConnectionError`. 429s and safely replayable
|
|
57
62
|
failures are retried with jittered backoff; `timeout`, `sync_timeout`, `max_retries` and `http_client` are
|
|
58
63
|
constructor options.
|
|
@@ -62,7 +67,8 @@ constructor options.
|
|
|
62
67
|
One answer is one draw from an engine that varies its answers. `visibility.measure` asks each engine the same prompt
|
|
63
68
|
`samples` times as separate async tasks and returns the results per engine; with a `brand` it also runs
|
|
64
69
|
`brand_check` over them: how many answers mention the brand (with a Wilson 95% interval), its average rank among the
|
|
65
|
-
brands you list, how many cite its own domains, and which domains get cited when it is absent.
|
|
70
|
+
brands you list, how many cite its own domains, and which domains get cited when it is absent. `samples` is any whole
|
|
71
|
+
number from 1 (default 10); a round of more than 500 tasks goes out in several batch calls.
|
|
66
72
|
|
|
67
73
|
```python
|
|
68
74
|
from answerline import brand_check
|
|
@@ -10,8 +10,6 @@ src/answerline.egg-info/SOURCES.txt
|
|
|
10
10
|
src/answerline.egg-info/dependency_links.txt
|
|
11
11
|
src/answerline.egg-info/requires.txt
|
|
12
12
|
src/answerline.egg-info/top_level.txt
|
|
13
|
-
tests/localhost-cert.pem
|
|
14
|
-
tests/localhost-key.pem
|
|
15
13
|
tests/test_client.py
|
|
16
14
|
tests/test_visibility.py
|
|
17
15
|
tests/test_webhooks.py
|
|
@@ -360,7 +360,7 @@ class ClientTest(ServerTest):
|
|
|
360
360
|
self.new_client(timeout=0.01).clear_queue()
|
|
361
361
|
self.assertEqual(self.sleep.call_count, 0, "a timed-out clear may still be running: not repeated")
|
|
362
362
|
await_seen(self, 1)
|
|
363
|
-
self.assertEqual(answerline.CLEAR_QUEUE_TIMEOUT_S,
|
|
363
|
+
self.assertEqual(answerline.CLEAR_QUEUE_TIMEOUT_S, 150.0)
|
|
364
364
|
|
|
365
365
|
def test_dropped_connections_retry_get_but_not_sync_calls(self):
|
|
366
366
|
REPLIES.extend(["reset", task_reply("QUEUED")])
|
|
@@ -546,13 +546,21 @@ class ClientTest(ServerTest):
|
|
|
546
546
|
self.assertEqual([e.code for e in out["chatgpt"]["errors"]], ["INSUFFICIENT_CREDITS", "INSUFFICIENT_CREDITS", "NOT_FOUND"])
|
|
547
547
|
|
|
548
548
|
def test_visibility_measure_refuses_bad_options_before_sending(self):
|
|
549
|
-
for options in ({"samples": 0}, {"samples":
|
|
549
|
+
for options in ({"samples": 0}, {"samples": -1}, {"samples": 2.5}, {"samples": 3, "rounds": 4}, {"rounds": 0},
|
|
550
550
|
{"spacing_seconds": -1}, {"engines": []}, {"engines": ["chatgpt", "chatgpt"]},
|
|
551
551
|
{"engines": ["google"]}):
|
|
552
552
|
with self.subTest(options=options), self.assertRaises(ValueError):
|
|
553
553
|
self.client.visibility.measure(**{"prompt": "x", "engines": ["chatgpt"], "country": "US", **options})
|
|
554
554
|
self.assertEqual(SEEN, [])
|
|
555
555
|
|
|
556
|
+
def test_visibility_measure_takes_any_number_of_samples(self):
|
|
557
|
+
# No upper bound: 501 samples, past the 100 it once allowed, go out in batches of at most MAX_BATCH.
|
|
558
|
+
self.assertFalse(hasattr(answerline, "MAX_SAMPLES"))
|
|
559
|
+
REPLIES.extend([error(403, "INSUFFICIENT_CREDITS"), error(403, "INSUFFICIENT_CREDITS")])
|
|
560
|
+
out = self.client.visibility.measure("x", ["chatgpt"], "US", samples=501)
|
|
561
|
+
self.assertEqual([len(json.loads(s[3])) for s in SEEN], [MAX_BATCH, 1])
|
|
562
|
+
self.assertEqual((out["chatgpt"]["requested"], out["chatgpt"]["failed"]), (501, 501))
|
|
563
|
+
|
|
556
564
|
|
|
557
565
|
class AsyncClientTest(ServerTest, unittest.IsolatedAsyncioTestCase):
|
|
558
566
|
"""The asyncio client runs the same calls; these check that its runner sends, waits and maps errors alike."""
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
-----BEGIN CERTIFICATE-----
|
|
2
|
-
MIIBoDCCAUagAwIBAgIUGl4E2FTh5mbDrsvR/CGSuY14tIEwCgYIKoZIzj0EAwIw
|
|
3
|
-
FDESMBAGA1UEAwwJMTI3LjAuMC4xMCAXDTI2MDkxNDIxMTAxMloYDzIxMjYwODIx
|
|
4
|
-
MjExMDEyWjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwWTATBgcqhkjOPQIBBggqhkjO
|
|
5
|
-
PQMBBwNCAAQWPixZ2ME9prdR+m7sipWKOVmIwrXlZ32aO+ySiIECSTS3+1po3bFB
|
|
6
|
-
mjKW1we39G5F1LHZfKSdP8vMc6wE4guao3QwcjAdBgNVHQ4EFgQU5FOuodtWI4IR
|
|
7
|
-
gdfnJIcIirPf+AkwHwYDVR0jBBgwFoAU5FOuodtWI4IRgdfnJIcIirPf+AkwDwYD
|
|
8
|
-
VR0TAQH/BAUwAwEB/zAPBgNVHREECDAGhwR/AAABMA4GA1UdDwEB/wQEAwIChDAK
|
|
9
|
-
BggqhkjOPQQDAgNIADBFAiEAxy0k552unmVHuD2oBV5/ZoehGRqC5M/GSJGd+smc
|
|
10
|
-
s7UCIGi3F3Y1o+ATHldxx71BjZojy+AGgYx2orlI7zhQsICg
|
|
11
|
-
-----END CERTIFICATE-----
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|