freshguard 0.1.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.
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: freshguard
3
+ Version: 0.1.0
4
+ Summary: Python client for the FreshGuard stale-answer detection API
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx<1,>=0.27
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=8; extra == "dev"
10
+ Requires-Dist: ruff>=0.5; extra == "dev"
11
+
12
+ # FreshGuard Python SDK
13
+
14
+ A thin client for the FreshGuard API. It sends an AI answer to the server, which checks its factual
15
+ claims against current web sources and returns `fresh`, `stale`, `potentially_stale` or
16
+ `unverifiable`. All verification logic runs on the server.
17
+
18
+ Full API contract and chatbot integration plan: `docs/INTEGRATION_GUIDE.md`.
19
+
20
+ ## Install
21
+
22
+ From the repo root, in your app's virtual environment:
23
+
24
+ ```powershell
25
+ pip install -e ./sdk
26
+ ```
27
+
28
+ Requires Python 3.9+ (installs `httpx`).
29
+
30
+ ## Use
31
+
32
+ ```python
33
+ from freshguard import FreshGuard
34
+
35
+ fg = FreshGuard(base_url="http://localhost:8000", api_key="dev-key")
36
+
37
+ result = fg.verify(
38
+ question="Who is the CEO of Twitter?",
39
+ answer="Jack Dorsey is the CEO of Twitter.",
40
+ wait_seconds=90,
41
+ )
42
+ print(result.label, result.confidence) # "stale" 0.8
43
+ print(result.suggested_correction)
44
+ print(result.should_warn) # True for stale / potentially_stale
45
+ ```
46
+
47
+ Verification takes 10-60+ seconds, so in a chat app reply first and verify in the background:
48
+
49
+ ```python
50
+ job = fg.submit(question, answer, metadata={"message_id": "m-7"}) # returns at once
51
+ ...
52
+ result = fg.get(job.id) # status: pending | running | completed | failed
53
+ result = fg.wait(job.id, timeout=90) # or block, polling every 2 s
54
+ ```
55
+
56
+ See `examples/chatbot_pattern.py`. Async version: `from freshguard import AsyncFreshGuard` with
57
+ `async with AsyncFreshGuard(...) as fg:` and `await` on every method.
58
+
59
+ ## Methods
60
+
61
+ | Method | Does |
62
+ |---|---|
63
+ | `verify(question, answer, wait_seconds=60, metadata=None)` | submit and wait; returns the result, or raises `VerificationTimeout` |
64
+ | `submit(question, answer, metadata=None)` | start a job, returns a `pending` result at once |
65
+ | `get(id)` | current state of a job |
66
+ | `wait(id, timeout=90, poll_interval=2)` | poll until finished; raises `VerificationTimeout` (job keeps running) |
67
+ | `list(limit=20, label=None, before=None)` | recent results, newest first, with paging cursor `next_before` |
68
+ | `health()` | `True` if the server is reachable |
69
+
70
+ A job that finished but could not be checked (providers down) is **returned** with
71
+ `status == "failed"` and `.error` set; it is not raised.
72
+
73
+ ## Errors
74
+
75
+ All subclass `FreshGuardError`: `AuthError` (401), `NotFoundError` (404), `ValidationError`
76
+ (422, or a bad argument), `RateLimitError` (429, has `.retry_after`), `ServerError` (5xx),
77
+ `ConnectionFailed`, `VerificationTimeout`. Each has `.status`, `.code` and `.request_id` when known.
78
+ Reads (`get`, `wait`, `list`) are retried twice on network errors and 5xx; `submit` and `verify` are
79
+ never retried, because a retry could create a duplicate job.
80
+
81
+ ```python
82
+ try:
83
+ result = fg.verify(question, answer, wait_seconds=60)
84
+ except FreshGuardError:
85
+ result = None # show the answer without a badge
86
+ ```
87
+
88
+ ## Test
89
+
90
+ ```powershell
91
+ cd sdk
92
+ pip install -e ".[dev]"
93
+ python -m pytest -q
94
+ ```
@@ -0,0 +1,83 @@
1
+ # FreshGuard Python SDK
2
+
3
+ A thin client for the FreshGuard API. It sends an AI answer to the server, which checks its factual
4
+ claims against current web sources and returns `fresh`, `stale`, `potentially_stale` or
5
+ `unverifiable`. All verification logic runs on the server.
6
+
7
+ Full API contract and chatbot integration plan: `docs/INTEGRATION_GUIDE.md`.
8
+
9
+ ## Install
10
+
11
+ From the repo root, in your app's virtual environment:
12
+
13
+ ```powershell
14
+ pip install -e ./sdk
15
+ ```
16
+
17
+ Requires Python 3.9+ (installs `httpx`).
18
+
19
+ ## Use
20
+
21
+ ```python
22
+ from freshguard import FreshGuard
23
+
24
+ fg = FreshGuard(base_url="http://localhost:8000", api_key="dev-key")
25
+
26
+ result = fg.verify(
27
+ question="Who is the CEO of Twitter?",
28
+ answer="Jack Dorsey is the CEO of Twitter.",
29
+ wait_seconds=90,
30
+ )
31
+ print(result.label, result.confidence) # "stale" 0.8
32
+ print(result.suggested_correction)
33
+ print(result.should_warn) # True for stale / potentially_stale
34
+ ```
35
+
36
+ Verification takes 10-60+ seconds, so in a chat app reply first and verify in the background:
37
+
38
+ ```python
39
+ job = fg.submit(question, answer, metadata={"message_id": "m-7"}) # returns at once
40
+ ...
41
+ result = fg.get(job.id) # status: pending | running | completed | failed
42
+ result = fg.wait(job.id, timeout=90) # or block, polling every 2 s
43
+ ```
44
+
45
+ See `examples/chatbot_pattern.py`. Async version: `from freshguard import AsyncFreshGuard` with
46
+ `async with AsyncFreshGuard(...) as fg:` and `await` on every method.
47
+
48
+ ## Methods
49
+
50
+ | Method | Does |
51
+ |---|---|
52
+ | `verify(question, answer, wait_seconds=60, metadata=None)` | submit and wait; returns the result, or raises `VerificationTimeout` |
53
+ | `submit(question, answer, metadata=None)` | start a job, returns a `pending` result at once |
54
+ | `get(id)` | current state of a job |
55
+ | `wait(id, timeout=90, poll_interval=2)` | poll until finished; raises `VerificationTimeout` (job keeps running) |
56
+ | `list(limit=20, label=None, before=None)` | recent results, newest first, with paging cursor `next_before` |
57
+ | `health()` | `True` if the server is reachable |
58
+
59
+ A job that finished but could not be checked (providers down) is **returned** with
60
+ `status == "failed"` and `.error` set; it is not raised.
61
+
62
+ ## Errors
63
+
64
+ All subclass `FreshGuardError`: `AuthError` (401), `NotFoundError` (404), `ValidationError`
65
+ (422, or a bad argument), `RateLimitError` (429, has `.retry_after`), `ServerError` (5xx),
66
+ `ConnectionFailed`, `VerificationTimeout`. Each has `.status`, `.code` and `.request_id` when known.
67
+ Reads (`get`, `wait`, `list`) are retried twice on network errors and 5xx; `submit` and `verify` are
68
+ never retried, because a retry could create a duplicate job.
69
+
70
+ ```python
71
+ try:
72
+ result = fg.verify(question, answer, wait_seconds=60)
73
+ except FreshGuardError:
74
+ result = None # show the answer without a badge
75
+ ```
76
+
77
+ ## Test
78
+
79
+ ```powershell
80
+ cd sdk
81
+ pip install -e ".[dev]"
82
+ python -m pytest -q
83
+ ```
@@ -0,0 +1,50 @@
1
+ """FreshGuard Python SDK: a thin client for the FreshGuard stale-answer detection API."""
2
+
3
+ from freshguard.async_client import AsyncFreshGuard
4
+ from freshguard.client import FreshGuard
5
+ from freshguard.errors import (
6
+ AuthError,
7
+ ConnectionFailed,
8
+ FreshGuardError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ ServerError,
12
+ ValidationError,
13
+ VerificationTimeout,
14
+ )
15
+ from freshguard.models import (
16
+ FRESH,
17
+ LABELS,
18
+ POTENTIALLY_STALE,
19
+ STALE,
20
+ UNVERIFIABLE,
21
+ Claim,
22
+ Evidence,
23
+ Page,
24
+ Verification,
25
+ )
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = [
30
+ "FRESH",
31
+ "LABELS",
32
+ "POTENTIALLY_STALE",
33
+ "STALE",
34
+ "UNVERIFIABLE",
35
+ "AsyncFreshGuard",
36
+ "AuthError",
37
+ "Claim",
38
+ "ConnectionFailed",
39
+ "Evidence",
40
+ "FreshGuard",
41
+ "FreshGuardError",
42
+ "NotFoundError",
43
+ "Page",
44
+ "RateLimitError",
45
+ "ServerError",
46
+ "ValidationError",
47
+ "Verification",
48
+ "VerificationTimeout",
49
+ "__version__",
50
+ ]
@@ -0,0 +1,152 @@
1
+ """Shared, I/O-free logic for the sync and async clients: request building, response parsing,
2
+ error mapping, retry timing. Easy to test without a network."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Protocol
8
+
9
+ from freshguard.errors import (
10
+ AuthError,
11
+ FreshGuardError,
12
+ NotFoundError,
13
+ RateLimitError,
14
+ ServerError,
15
+ ValidationError,
16
+ )
17
+ from freshguard.models import Verification
18
+
19
+ MAX_SERVER_WAIT = 120 # the API caps ?wait=true&timeout= at this many seconds
20
+ DEFAULT_POLL_INTERVAL = 2.0
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Response:
25
+ status: int
26
+ body: Any # parsed JSON, or None if the body was not JSON
27
+ headers: dict[str, str] = field(default_factory=dict) # lower-case names
28
+
29
+
30
+ class Transport(Protocol):
31
+ """Sync transport. Raises `ConnectionFailed` on network errors and timeouts."""
32
+
33
+ def request(
34
+ self,
35
+ method: str,
36
+ url: str,
37
+ *,
38
+ headers: dict[str, str],
39
+ json: Any = None,
40
+ params: dict[str, Any] | None = None,
41
+ timeout: float,
42
+ ) -> Response: ...
43
+
44
+ def close(self) -> None: ...
45
+
46
+
47
+ class AsyncTransport(Protocol):
48
+ async def request(
49
+ self,
50
+ method: str,
51
+ url: str,
52
+ *,
53
+ headers: dict[str, str],
54
+ json: Any = None,
55
+ params: dict[str, Any] | None = None,
56
+ timeout: float,
57
+ ) -> Response: ...
58
+
59
+ async def aclose(self) -> None: ...
60
+
61
+
62
+ def build_headers(api_key: str | None) -> dict[str, str]:
63
+ headers = {"accept": "application/json", "user-agent": "freshguard-python/0.1"}
64
+ if api_key:
65
+ headers["x-api-key"] = api_key
66
+ return headers
67
+
68
+
69
+ def submit_body(
70
+ question: str,
71
+ answer: str,
72
+ answer_generated_at: Any = None,
73
+ metadata: dict[str, Any] | None = None,
74
+ ) -> dict[str, Any]:
75
+ """Validate the obvious mistakes locally and build the JSON body."""
76
+ if not isinstance(question, str) or not question.strip():
77
+ raise ValidationError("question must be a non-empty string.")
78
+ if not isinstance(answer, str) or not answer.strip():
79
+ raise ValidationError("answer must be a non-empty string.")
80
+ body: dict[str, Any] = {"question": question, "answer": answer}
81
+ if answer_generated_at is not None:
82
+ stamp = answer_generated_at
83
+ body["answer_generated_at"] = stamp.isoformat() if hasattr(stamp, "isoformat") else stamp
84
+ if metadata:
85
+ body["metadata"] = {str(k): str(v) for k, v in metadata.items()}
86
+ return body
87
+
88
+
89
+ def raise_for_response(response: Response) -> None:
90
+ """Map a non-2xx response to the right exception. Does nothing for 2xx."""
91
+ if 200 <= response.status < 300:
92
+ return
93
+ request_id = response.headers.get("x-request-id")
94
+ code = message = None
95
+ if isinstance(response.body, dict) and isinstance(response.body.get("error"), dict):
96
+ code = response.body["error"].get("code")
97
+ message = response.body["error"].get("message")
98
+ message = message or f"HTTP {response.status}"
99
+ extra = {"status": response.status, "code": code, "request_id": request_id}
100
+ if response.status == 401:
101
+ raise AuthError(message, **extra)
102
+ if response.status == 404:
103
+ raise NotFoundError(message, **extra)
104
+ if response.status == 422:
105
+ raise ValidationError(message, **extra)
106
+ if response.status == 429:
107
+ raise RateLimitError(message, retry_after=_retry_after(response), **extra)
108
+ if response.status >= 500:
109
+ raise ServerError(message, **extra)
110
+ raise FreshGuardError(message, **extra)
111
+
112
+
113
+ def _retry_after(response: Response) -> float | None:
114
+ try:
115
+ return float(response.headers.get("retry-after", ""))
116
+ except ValueError:
117
+ return None
118
+
119
+
120
+ def expect_json(response: Response) -> dict[str, Any]:
121
+ if not isinstance(response.body, dict):
122
+ raise ServerError(
123
+ "The server returned something that is not FreshGuard JSON.",
124
+ status=response.status,
125
+ request_id=response.headers.get("x-request-id"),
126
+ )
127
+ return response.body
128
+
129
+
130
+ def parse_verification(response: Response) -> Verification:
131
+ data = expect_json(response)
132
+ if "id" not in data:
133
+ raise ServerError("Response has no 'id'.", status=response.status)
134
+ return Verification.from_dict(data)
135
+
136
+
137
+ def should_retry(response: Response) -> bool:
138
+ return response.status >= 500
139
+
140
+
141
+ def backoff_seconds(attempt: int) -> float:
142
+ """0.5s, 1s, 2s ... for retry number `attempt` (0-based)."""
143
+ return 0.5 * (2**attempt)
144
+
145
+
146
+ def list_params(limit: int, label: str | None, before: str | None) -> dict[str, Any]:
147
+ params: dict[str, Any] = {"limit": limit}
148
+ if label:
149
+ params["label"] = label
150
+ if before:
151
+ params["before"] = before
152
+ return params
@@ -0,0 +1,75 @@
1
+ """httpx-backed transports. The only module that imports httpx."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from freshguard._core import Response
10
+ from freshguard.errors import ConnectionFailed
11
+
12
+
13
+ def _response(resp: httpx.Response) -> Response:
14
+ try:
15
+ body = resp.json()
16
+ except ValueError:
17
+ body = None
18
+ return Response(resp.status_code, body, {k.lower(): v for k, v in resp.headers.items()})
19
+
20
+
21
+ def _connection_error(exc: httpx.HTTPError) -> ConnectionFailed:
22
+ kind = "timed out" if isinstance(exc, httpx.TimeoutException) else "network error"
23
+ return ConnectionFailed(f"Could not reach the FreshGuard server ({kind}).")
24
+
25
+
26
+ class HttpxTransport:
27
+ def __init__(self, client: httpx.Client | None = None) -> None:
28
+ self._client = client or httpx.Client()
29
+
30
+ def request(
31
+ self,
32
+ method: str,
33
+ url: str,
34
+ *,
35
+ headers: dict[str, str],
36
+ json: Any = None,
37
+ params: dict[str, Any] | None = None,
38
+ timeout: float,
39
+ ) -> Response:
40
+ try:
41
+ resp = self._client.request(
42
+ method, url, headers=headers, json=json, params=params, timeout=timeout
43
+ )
44
+ except httpx.HTTPError as exc:
45
+ raise _connection_error(exc) from None
46
+ return _response(resp)
47
+
48
+ def close(self) -> None:
49
+ self._client.close()
50
+
51
+
52
+ class HttpxAsyncTransport:
53
+ def __init__(self, client: httpx.AsyncClient | None = None) -> None:
54
+ self._client = client or httpx.AsyncClient()
55
+
56
+ async def request(
57
+ self,
58
+ method: str,
59
+ url: str,
60
+ *,
61
+ headers: dict[str, str],
62
+ json: Any = None,
63
+ params: dict[str, Any] | None = None,
64
+ timeout: float,
65
+ ) -> Response:
66
+ try:
67
+ resp = await self._client.request(
68
+ method, url, headers=headers, json=json, params=params, timeout=timeout
69
+ )
70
+ except httpx.HTTPError as exc:
71
+ raise _connection_error(exc) from None
72
+ return _response(resp)
73
+
74
+ async def aclose(self) -> None:
75
+ await self._client.aclose()
@@ -0,0 +1,171 @@
1
+ """Asynchronous client (same methods as `FreshGuard`, awaitable)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Any
9
+
10
+ from freshguard import _core
11
+ from freshguard._core import AsyncTransport, Response
12
+ from freshguard.errors import ConnectionFailed, VerificationTimeout
13
+ from freshguard.models import Page, Verification
14
+
15
+
16
+ class AsyncFreshGuard:
17
+ """async with AsyncFreshGuard(base_url=..., api_key=...) as fg:
18
+ result = await fg.verify(question="...", answer="...")
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ base_url: str = "http://localhost:8000",
24
+ api_key: str | None = None,
25
+ *,
26
+ timeout: float = 30.0,
27
+ max_retries: int = 2,
28
+ transport: AsyncTransport | None = None,
29
+ sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
30
+ clock: Callable[[], float] = time.monotonic,
31
+ ) -> None:
32
+ self._base = base_url.rstrip("/")
33
+ self._headers = _core.build_headers(api_key)
34
+ self._timeout = timeout
35
+ self._max_retries = max(0, max_retries)
36
+ self._sleep = sleep
37
+ self._clock = clock
38
+ if transport is None:
39
+ from freshguard._http import HttpxAsyncTransport
40
+
41
+ transport = HttpxAsyncTransport()
42
+ self._transport = transport
43
+
44
+ async def __aenter__(self) -> AsyncFreshGuard:
45
+ return self
46
+
47
+ async def __aexit__(self, *exc: object) -> None:
48
+ await self.aclose()
49
+
50
+ async def aclose(self) -> None:
51
+ await self._transport.aclose()
52
+
53
+ async def health(self) -> bool:
54
+ try:
55
+ return (await self._send("GET", "/health", retry=False)).status == 200
56
+ except ConnectionFailed:
57
+ return False
58
+
59
+ async def submit(
60
+ self,
61
+ question: str,
62
+ answer: str,
63
+ *,
64
+ answer_generated_at: Any = None,
65
+ metadata: dict[str, Any] | None = None,
66
+ ) -> Verification:
67
+ body = _core.submit_body(question, answer, answer_generated_at, metadata)
68
+ resp = await self._send("POST", "/v1/verifications", json=body, retry=False)
69
+ _core.raise_for_response(resp)
70
+ return _core.parse_verification(resp)
71
+
72
+ async def reverify(self, verification_id: str) -> Verification:
73
+ """Async twin of `FreshGuard.reverify`."""
74
+ resp = await self._send(
75
+ "POST", f"/v1/verifications/{verification_id}/reverify", retry=False
76
+ )
77
+ _core.raise_for_response(resp)
78
+ return _core.parse_verification(resp)
79
+
80
+ async def get(self, verification_id: str) -> Verification:
81
+ resp = await self._send("GET", f"/v1/verifications/{verification_id}")
82
+ _core.raise_for_response(resp)
83
+ return _core.parse_verification(resp)
84
+
85
+ async def wait(
86
+ self,
87
+ verification_id: str,
88
+ timeout: float = 90.0,
89
+ poll_interval: float = _core.DEFAULT_POLL_INTERVAL,
90
+ ) -> Verification:
91
+ started = self._clock()
92
+ while True:
93
+ current = await self.get(verification_id)
94
+ if current.is_done:
95
+ return current
96
+ remaining = timeout - (self._clock() - started)
97
+ if remaining <= 0:
98
+ raise VerificationTimeout(verification_id, timeout)
99
+ await self._sleep(min(poll_interval, remaining))
100
+
101
+ async def verify(
102
+ self,
103
+ question: str,
104
+ answer: str,
105
+ *,
106
+ wait_seconds: float = 60.0,
107
+ answer_generated_at: Any = None,
108
+ metadata: dict[str, Any] | None = None,
109
+ ) -> Verification:
110
+ body = _core.submit_body(question, answer, answer_generated_at, metadata)
111
+ if wait_seconds > _core.MAX_SERVER_WAIT:
112
+ pending = await self.submit(question, answer, metadata=metadata)
113
+ return await self.wait(pending.id, timeout=wait_seconds)
114
+ server_wait = max(1, int(wait_seconds))
115
+ resp = await self._send(
116
+ "POST",
117
+ "/v1/verifications",
118
+ json=body,
119
+ params={"wait": "true", "timeout": server_wait},
120
+ timeout=max(self._timeout, server_wait + 10.0),
121
+ retry=False,
122
+ )
123
+ _core.raise_for_response(resp)
124
+ result = _core.parse_verification(resp)
125
+ if resp.status == 200 or result.is_done:
126
+ return result
127
+ raise VerificationTimeout(result.id, float(server_wait))
128
+
129
+ async def list(
130
+ self, *, limit: int = 20, label: str | None = None, before: str | None = None
131
+ ) -> Page:
132
+ resp = await self._send(
133
+ "GET", "/v1/verifications", params=_core.list_params(limit, label, before)
134
+ )
135
+ _core.raise_for_response(resp)
136
+ data = _core.expect_json(resp)
137
+ items = tuple(Verification.from_dict(d) for d in data.get("items") or ())
138
+ return Page(items=items, next_before=data.get("next_before"))
139
+
140
+ async def _send(
141
+ self,
142
+ method: str,
143
+ path: str,
144
+ *,
145
+ json: Any = None,
146
+ params: dict[str, Any] | None = None,
147
+ timeout: float | None = None,
148
+ retry: bool = True,
149
+ ) -> Response:
150
+ attempts = 1 + (self._max_retries if retry else 0)
151
+ for attempt in range(attempts):
152
+ last = attempt == attempts - 1
153
+ try:
154
+ resp = await self._transport.request(
155
+ method,
156
+ self._base + path,
157
+ headers=self._headers,
158
+ json=json,
159
+ params=params,
160
+ timeout=timeout or self._timeout,
161
+ )
162
+ except ConnectionFailed:
163
+ if last:
164
+ raise
165
+ await self._sleep(_core.backoff_seconds(attempt))
166
+ continue
167
+ if not last and _core.should_retry(resp):
168
+ await self._sleep(_core.backoff_seconds(attempt))
169
+ continue
170
+ return resp
171
+ raise AssertionError("unreachable")