diffbot-python 0.2.3__tar.gz → 0.3.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.
Files changed (37) hide show
  1. diffbot_python-0.3.0/PKG-INFO +54 -0
  2. diffbot_python-0.3.0/README.md +36 -0
  3. diffbot_python-0.3.0/pyproject.toml +46 -0
  4. diffbot_python-0.3.0/src/diffbot/__init__.py +63 -0
  5. diffbot_python-0.3.0/src/diffbot/ask.py +195 -0
  6. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/__init__.py +42 -12
  7. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/client.py +68 -7
  8. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/kg.py +5 -5
  9. diffbot_python-0.2.3/PKG-INFO +0 -297
  10. diffbot_python-0.2.3/README.md +0 -261
  11. diffbot_python-0.2.3/pyproject.toml +0 -79
  12. diffbot_python-0.2.3/src/diffbot/__init__.py +0 -38
  13. diffbot_python-0.2.3/src/diffbot/ask.py +0 -48
  14. diffbot_python-0.2.3/tests/conftest.py +0 -16
  15. diffbot_python-0.2.3/tests/test_ask.py +0 -12
  16. diffbot_python-0.2.3/tests/test_async.py +0 -94
  17. diffbot_python-0.2.3/tests/test_crawl.py +0 -63
  18. diffbot_python-0.2.3/tests/test_dql.py +0 -14
  19. diffbot_python-0.2.3/tests/test_dql_cli.py +0 -118
  20. diffbot_python-0.2.3/tests/test_extract.py +0 -170
  21. diffbot_python-0.2.3/tests/test_ontology.py +0 -119
  22. diffbot_python-0.2.3/tests/test_readme_examples.py +0 -193
  23. diffbot_python-0.2.3/tests/test_web_search.py +0 -12
  24. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/.gitignore +0 -0
  25. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/LICENSE +0 -0
  26. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/_auth.py +0 -0
  27. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/__main__.py +0 -0
  28. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/_common.py +0 -0
  29. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/dql.py +0 -0
  30. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/entities.py +0 -0
  31. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/cli/ontology.py +0 -0
  32. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/crawl.py +0 -0
  33. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/errors.py +0 -0
  34. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/extract.py +0 -0
  35. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/nlp.py +0 -0
  36. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/ontology.py +0 -0
  37. {diffbot_python-0.2.3 → diffbot_python-0.3.0}/src/diffbot/web_search.py +0 -0
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: diffbot-python
3
+ Version: 0.3.0
4
+ Summary: ⚠️ LEGACY — renamed to diffbot. Final release, no further updates: pip install diffbot
5
+ Project-URL: Homepage, https://pypi.org/project/diffbot/
6
+ Project-URL: Repository, https://github.com/diffbot/diffbot-python
7
+ Author-email: Jerome Choo <jerome@diffbot.com>, Mike Tung <miket@diffbot.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 7 - Inactive
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: click>=8.1.0
15
+ Requires-Dist: httpx>=0.27.0
16
+ Requires-Dist: rich>=13.0.0
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ⚠️ LEGACY PACKAGE — `diffbot-python` is now `diffbot` ⚠️
20
+
21
+ > ## 🛑 Do not install `diffbot-python`. Install [`diffbot`](https://pypi.org/project/diffbot/) instead:
22
+ >
23
+ > ```bash
24
+ > pip uninstall diffbot-python
25
+ > pip install diffbot
26
+ > ```
27
+ >
28
+ > **This is the final release of `diffbot-python` (0.3.0). It will never be updated again**:
29
+ > no bug fixes, no new Diffbot API features, no security fixes.
30
+
31
+ ---
32
+
33
+ The Diffbot Python library and `db` CLI now live at
34
+ **[diffbot](https://pypi.org/project/diffbot/)**. The import name is unchanged
35
+ (`import diffbot`), so no code changes are needed.
36
+
37
+ This release contains the same code as `diffbot` 3.0.0 so existing installs keep
38
+ working, but it prints a warning on every import until you migrate.
39
+
40
+ ## Migrating
41
+
42
+ 1. Replace `diffbot-python` with `diffbot` in your `requirements.txt`, `pyproject.toml`, etc.
43
+ 2. Uninstall the old package **before** installing the new one:
44
+
45
+ ```bash
46
+ pip uninstall diffbot-python
47
+ pip install diffbot
48
+ ```
49
+
50
+ Both packages provide the same `diffbot` module, so uninstalling
51
+ `diffbot-python` after `diffbot` is installed deletes files that `diffbot`
52
+ needs. If that happened, run `pip install --force-reinstall diffbot`.
53
+
54
+ Source and issues: <https://github.com/diffbot/diffbot-python>.
@@ -0,0 +1,36 @@
1
+ # ⚠️ LEGACY PACKAGE — `diffbot-python` is now `diffbot` ⚠️
2
+
3
+ > ## 🛑 Do not install `diffbot-python`. Install [`diffbot`](https://pypi.org/project/diffbot/) instead:
4
+ >
5
+ > ```bash
6
+ > pip uninstall diffbot-python
7
+ > pip install diffbot
8
+ > ```
9
+ >
10
+ > **This is the final release of `diffbot-python` (0.3.0). It will never be updated again**:
11
+ > no bug fixes, no new Diffbot API features, no security fixes.
12
+
13
+ ---
14
+
15
+ The Diffbot Python library and `db` CLI now live at
16
+ **[diffbot](https://pypi.org/project/diffbot/)**. The import name is unchanged
17
+ (`import diffbot`), so no code changes are needed.
18
+
19
+ This release contains the same code as `diffbot` 3.0.0 so existing installs keep
20
+ working, but it prints a warning on every import until you migrate.
21
+
22
+ ## Migrating
23
+
24
+ 1. Replace `diffbot-python` with `diffbot` in your `requirements.txt`, `pyproject.toml`, etc.
25
+ 2. Uninstall the old package **before** installing the new one:
26
+
27
+ ```bash
28
+ pip uninstall diffbot-python
29
+ pip install diffbot
30
+ ```
31
+
32
+ Both packages provide the same `diffbot` module, so uninstalling
33
+ `diffbot-python` after `diffbot` is installed deletes files that `diffbot`
34
+ needs. If that happened, run `pip install --force-reinstall diffbot`.
35
+
36
+ Source and issues: <https://github.com/diffbot/diffbot-python>.
@@ -0,0 +1,46 @@
1
+ # Final release under the old distribution name. diffbot-python was renamed to
2
+ # diffbot (3.0.0+). This release freezes the current library code under the old
3
+ # name and warns on import, so `pip install -U diffbot-python` keeps working
4
+ # while pointing users at the new package. It deliberately does NOT depend on
5
+ # diffbot: both ship the same top-level `diffbot` module, and installing them
6
+ # together lets pip delete one's files when it uninstalls the other.
7
+ #
8
+ # `make build-legacy` copies ../../src next to this file before building, so
9
+ # keep dependencies and scripts in step with the main pyproject.toml.
10
+ # Published with `make release-legacy`.
11
+ [build-system]
12
+ requires = ["hatchling"]
13
+ build-backend = "hatchling.build"
14
+
15
+ [project]
16
+ name = "diffbot-python"
17
+ version = "0.3.0"
18
+ description = "⚠️ LEGACY — renamed to diffbot. Final release, no further updates: pip install diffbot"
19
+ readme = "README.md"
20
+ requires-python = ">=3.9"
21
+ license = "MIT"
22
+ license-files = ["LICENSE"]
23
+ authors = [
24
+ { name = "Jerome Choo", email = "jerome@diffbot.com" },
25
+ { name = "Mike Tung", email = "miket@diffbot.com" }
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 7 - Inactive",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ ]
32
+ dependencies = [
33
+ "httpx>=0.27.0",
34
+ "click>=8.1.0",
35
+ "rich>=13.0.0",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://pypi.org/project/diffbot/"
40
+ Repository = "https://github.com/diffbot/diffbot-python"
41
+
42
+ [project.scripts]
43
+ db = "diffbot.cli:main"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/diffbot"]
@@ -0,0 +1,63 @@
1
+ """
2
+ diffbot - Python client library for the Diffbot APIs.
3
+ """
4
+
5
+ import warnings as _warnings
6
+ from importlib.metadata import PackageNotFoundError, version as _version
7
+
8
+ def _installed_version(dist: str):
9
+ try:
10
+ return _version(dist)
11
+ except PackageNotFoundError:
12
+ return None
13
+
14
+
15
+ # The distribution was renamed from diffbot-python to diffbot. The final
16
+ # diffbot-python release ships this same code (see legacy/diffbot-python), so
17
+ # fall back to its version when that's what is installed.
18
+ _legacy_version = _installed_version("diffbot-python")
19
+ # "0.0.0" when not installed (e.g. running from a source tree).
20
+ __version__ = _installed_version("diffbot") or _legacy_version or "0.0.0"
21
+
22
+ # Both distributions ship this module, so a diffbot-python install (alone, or
23
+ # left over next to diffbot) should migrate. FutureWarning, unlike
24
+ # DeprecationWarning, is shown by default wherever the import happens.
25
+ if _legacy_version is not None:
26
+ _warnings.warn(
27
+ "diffbot-python has been renamed to diffbot and will receive no further "
28
+ "updates. Run `pip uninstall diffbot-python && pip install "
29
+ "--force-reinstall diffbot` and replace diffbot-python with diffbot in "
30
+ "your requirements.",
31
+ FutureWarning,
32
+ stacklevel=2,
33
+ )
34
+
35
+ from ._auth import resolve_token
36
+ from .ask import json_schema_format
37
+ from .client import Diffbot, DiffbotAsync
38
+ from .crawl import CrawlEvent, CrawlEventType
39
+ from .errors import (
40
+ APIError,
41
+ AuthError,
42
+ DiffbotError,
43
+ ExtractionError,
44
+ RateLimitError,
45
+ ValidationError,
46
+ )
47
+ from .ontology import Ontology
48
+
49
+ __all__ = [
50
+ "Diffbot",
51
+ "DiffbotAsync",
52
+ "resolve_token",
53
+ "json_schema_format",
54
+ "CrawlEvent",
55
+ "CrawlEventType",
56
+ "Ontology",
57
+ "DiffbotError",
58
+ "AuthError",
59
+ "ExtractionError",
60
+ "RateLimitError",
61
+ "APIError",
62
+ "ValidationError",
63
+ ]
@@ -0,0 +1,195 @@
1
+ """Diffbot LLM RAG API: stream a chat completion."""
2
+
3
+ import json
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterator, List, Optional
6
+
7
+ from .errors import ValidationError
8
+
9
+ if TYPE_CHECKING:
10
+ from .client import Diffbot, DiffbotAsync
11
+
12
+ MODEL = "diffbot-small-xl"
13
+
14
+ #: `response_format` types accepted by the Diffbot LLM endpoint.
15
+ RESPONSE_FORMAT_TYPES = ("text", "json_object", "json_schema")
16
+
17
+ # The RAG loop may prefix its final answer with a think block, and the model
18
+ # occasionally wraps JSON in a markdown fence despite being told not to.
19
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>", re.DOTALL)
20
+ _JSON_FENCE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE)
21
+
22
+
23
+ def json_schema_format(schema: Dict[str, Any], *, name: str = "response") -> Dict[str, Any]:
24
+ """Build a ``response_format`` value that constrains output to ``schema``.
25
+
26
+ The endpoint requires the schema nested under ``json_schema.schema``; passing
27
+ it anywhere else is ignored server-side without an error, so prefer this
28
+ helper over hand-building the dict.
29
+
30
+ Example:
31
+ >>> json_schema_format({"type": "object", "properties": {"city": {"type": "string"}}})
32
+ {'type': 'json_schema', 'json_schema': {'name': 'response', 'schema': {...}}}
33
+ """
34
+ if not isinstance(schema, dict):
35
+ raise ValidationError("schema must be a JSON Schema dict")
36
+ return {"type": "json_schema", "json_schema": {"name": name, "schema": schema}}
37
+
38
+
39
+ def _validate_response_format(response_format: Optional[Dict[str, Any]]) -> None:
40
+ """Reject shapes the endpoint would accept but silently not enforce."""
41
+ if response_format is None:
42
+ return
43
+ if not isinstance(response_format, dict):
44
+ raise ValidationError("response_format must be a dict")
45
+
46
+ fmt_type = response_format.get("type", "text")
47
+ if fmt_type not in RESPONSE_FORMAT_TYPES:
48
+ raise ValidationError(
49
+ f"response_format type must be one of {', '.join(RESPONSE_FORMAT_TYPES)}; got {fmt_type!r}"
50
+ )
51
+
52
+ if fmt_type == "json_schema":
53
+ json_schema = response_format.get("json_schema")
54
+ if not isinstance(json_schema, dict) or "schema" not in json_schema:
55
+ raise ValidationError(
56
+ 'response_format {"type": "json_schema"} requires the schema nested as '
57
+ '{"json_schema": {"schema": {...}}}. Without it the server returns 200 and '
58
+ "ignores the constraint. Use diffbot.json_schema_format(schema) to build it."
59
+ )
60
+
61
+
62
+ def _build_payload(
63
+ client: Any,
64
+ messages: List[Dict[str, str]],
65
+ *,
66
+ response_format: Optional[Dict[str, Any]] = None,
67
+ ) -> tuple:
68
+ _validate_response_format(response_format)
69
+ headers = {"Authorization": f"Bearer {client.token}"}
70
+ payload: Dict[str, Any] = {"model": MODEL, "messages": messages, "stream": True}
71
+ if response_format is not None:
72
+ payload["response_format"] = response_format
73
+ return headers, payload
74
+
75
+
76
+ def _parse_chunk(line: str):
77
+ try:
78
+ chunk = json.loads(line.replace("data: ", ""))
79
+ except json.JSONDecodeError:
80
+ return None
81
+ choices = chunk.get("choices")
82
+ if choices and choices[0].get("delta", {}).get("content"):
83
+ return choices[0]["delta"]["content"]
84
+ return None
85
+
86
+
87
+ def _extract_json(text: str) -> Any:
88
+ """Parse the model's final answer as JSON, tolerating think blocks and fences."""
89
+ cleaned = _THINK_BLOCK.sub("", text).strip()
90
+ cleaned = _JSON_FENCE.sub("", cleaned).strip()
91
+
92
+ try:
93
+ return json.loads(cleaned)
94
+ except json.JSONDecodeError:
95
+ pass
96
+
97
+ # Fall back to the outermost object or array span in the response.
98
+ spans = []
99
+ for opener, closer in (("{", "}"), ("[", "]")):
100
+ start, end = cleaned.find(opener), cleaned.rfind(closer)
101
+ if start != -1 and end > start:
102
+ spans.append((start, cleaned[start : end + 1]))
103
+ for _, span in sorted(spans):
104
+ try:
105
+ return json.loads(span)
106
+ except json.JSONDecodeError:
107
+ continue
108
+
109
+ raise ValidationError(f"could not parse JSON from the model response: {text[:200]!r}")
110
+
111
+
112
+ #: Schema used when the caller wants JSON but has no shape in mind. This goes
113
+ #: through the json_schema path rather than {"type": "json_object"} on purpose:
114
+ #: json_object applies no server-side grammar, so the RAG loop's internal
115
+ #: <functioncall> JSON satisfies it and gets returned as the final answer. That
116
+ #: is reproducible whenever the request carries a system message.
117
+ ANY_OBJECT_SCHEMA = {"type": "object"}
118
+
119
+
120
+ def _resolve_format(
121
+ schema: Optional[Dict[str, Any]],
122
+ response_format: Optional[Dict[str, Any]],
123
+ ) -> Dict[str, Any]:
124
+ if schema is not None and response_format is not None:
125
+ raise ValidationError("pass either schema or response_format, not both")
126
+ if response_format is not None:
127
+ return response_format
128
+ return json_schema_format(schema if schema is not None else ANY_OBJECT_SCHEMA)
129
+
130
+
131
+ def _check_tool_call_leak(parsed: Any) -> Any:
132
+ """Catch the internal tool call surfacing as the answer (see ANY_OBJECT_SCHEMA)."""
133
+ if isinstance(parsed, dict) and parsed.get("name") == "functioncall" and "arguments" in parsed:
134
+ raise ValidationError(
135
+ "the model returned its internal tool call instead of an answer; this happens with "
136
+ 'response_format {"type": "json_object"} because the server applies no grammar to it. '
137
+ "Pass a schema instead."
138
+ )
139
+ return parsed
140
+
141
+
142
+ def ask(
143
+ client: "Diffbot",
144
+ messages: List[Dict[str, str]],
145
+ *,
146
+ response_format: Optional[Dict[str, Any]] = None,
147
+ ) -> Iterator[str]:
148
+ headers, payload = _build_payload(client, messages, response_format=response_format)
149
+ with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response:
150
+ client._raise_for_status(response)
151
+ for line in response.iter_lines():
152
+ if line:
153
+ content = _parse_chunk(line)
154
+ if content:
155
+ yield content
156
+
157
+
158
+ async def ask_async(
159
+ client: "DiffbotAsync",
160
+ messages: List[Dict[str, str]],
161
+ *,
162
+ response_format: Optional[Dict[str, Any]] = None,
163
+ ) -> AsyncIterator[str]:
164
+ headers, payload = _build_payload(client, messages, response_format=response_format)
165
+ async with client._http.stream("POST", client.llm_url, headers=headers, json=payload) as response:
166
+ client._raise_for_status(response)
167
+ async for line in response.aiter_lines():
168
+ if line:
169
+ content = _parse_chunk(line)
170
+ if content:
171
+ yield content
172
+
173
+
174
+ def ask_json(
175
+ client: "Diffbot",
176
+ messages: List[Dict[str, str]],
177
+ schema: Optional[Dict[str, Any]] = None,
178
+ *,
179
+ response_format: Optional[Dict[str, Any]] = None,
180
+ ) -> Any:
181
+ fmt = _resolve_format(schema, response_format)
182
+ text = "".join(ask(client, messages, response_format=fmt))
183
+ return _check_tool_call_leak(_extract_json(text))
184
+
185
+
186
+ async def ask_json_async(
187
+ client: "DiffbotAsync",
188
+ messages: List[Dict[str, str]],
189
+ schema: Optional[Dict[str, Any]] = None,
190
+ *,
191
+ response_format: Optional[Dict[str, Any]] = None,
192
+ ) -> Any:
193
+ fmt = _resolve_format(schema, response_format)
194
+ chunks = [chunk async for chunk in ask_async(client, messages, response_format=fmt)]
195
+ return _check_tool_call_leak(_extract_json("".join(chunks)))
@@ -11,7 +11,15 @@ from rich.markdown import Markdown
11
11
  from rich.progress import Progress, SpinnerColumn, TextColumn
12
12
  from datetime import datetime
13
13
 
14
- from diffbot import __version__, CrawlEventType, AuthError, ExtractionError, APIError
14
+ from diffbot import (
15
+ __version__,
16
+ CrawlEventType,
17
+ AuthError,
18
+ ExtractionError,
19
+ APIError,
20
+ ValidationError,
21
+ json_schema_format,
22
+ )
15
23
 
16
24
  from ._common import get_client
17
25
 
@@ -86,10 +94,29 @@ def extract(url, output, fmt, api):
86
94
  @click.argument("prompt")
87
95
  @click.option("-o", "--output", type=click.Path(), help="Write output to file instead of stdout")
88
96
  @click.option("--json", "as_json", is_flag=True, help="Output from LLM as JSON")
89
- def ask(prompt: str, output: str = None, as_json: bool = False):
97
+ @click.option(
98
+ "--schema",
99
+ "schema_path",
100
+ type=click.Path(exists=True, dir_okay=False),
101
+ help="JSON Schema file constraining the output (implies --json)",
102
+ )
103
+ def ask(prompt: str, output: str = None, as_json: bool = False, schema_path: str = None):
90
104
  """Ask a question to the Diffbot LLM"""
91
105
  db = get_client()
92
106
  stdin_content = sys.stdin.read() if not sys.stdin.isatty() else None
107
+
108
+ response_format = None
109
+ if schema_path:
110
+ as_json = True
111
+ try:
112
+ with open(schema_path) as f:
113
+ response_format = json_schema_format(json.load(f))
114
+ except json.JSONDecodeError as e:
115
+ click.echo(f"Error: {schema_path} is not valid JSON: {e}", err=True)
116
+ raise click.Abort()
117
+
118
+ # Without --schema, response_format stays None and ask_json picks its own
119
+ # permissive default; --json alone must not become {"type": "json_object"}.
93
120
  interactive_mode = is_interactive and not output and not as_json
94
121
 
95
122
  messages = [
@@ -100,9 +127,6 @@ def ask(prompt: str, output: str = None, as_json: bool = False):
100
127
  {"role": "user", "content": prompt},
101
128
  ]
102
129
 
103
- if as_json:
104
- messages[1]["content"] += "\nReturn the output as a JSON object. Do not include any other text outside of the JSON object."
105
-
106
130
  if stdin_content:
107
131
  messages[1]["content"] = f"<input>{stdin_content}</input>\n" + messages[1]["content"]
108
132
 
@@ -137,17 +161,20 @@ def ask(prompt: str, output: str = None, as_json: bool = False):
137
161
  else:
138
162
  response = ""
139
163
  if output:
140
- for chunk in db.ask(messages):
141
- response += chunk
164
+ if as_json:
165
+ response = json.dumps(
166
+ db.ask_json(messages, response_format=response_format), indent=2
167
+ )
168
+ else:
169
+ for chunk in db.ask(messages):
170
+ response += chunk
142
171
  with open(output, "w") as f:
143
172
  f.write(response)
144
173
  click.echo(f"Output written to {output}")
145
174
  elif as_json:
146
- buffer = ""
147
- for chunk in db.ask(messages):
148
- buffer += chunk
149
- buffer = buffer[buffer.find("{") : buffer.rfind("}") + 1]
150
- sys.stdout.write(buffer)
175
+ sys.stdout.write(
176
+ json.dumps(db.ask_json(messages, response_format=response_format), indent=2)
177
+ )
151
178
  else:
152
179
  for chunk in db.ask(messages):
153
180
  sys.stdout.write(chunk)
@@ -156,6 +183,9 @@ def ask(prompt: str, output: str = None, as_json: bool = False):
156
183
  except AuthError:
157
184
  click.echo("Error: Invalid or unauthorized API token.", err=True)
158
185
  raise click.Abort()
186
+ except ValidationError as e:
187
+ click.echo(f"Error: {e}", err=True)
188
+ raise click.Abort()
159
189
  except APIError as e:
160
190
  click.echo(f"API error {e.status_code}: {e.message or e.body}", err=True)
161
191
  raise click.Abort()
@@ -9,7 +9,12 @@ import httpx
9
9
  from . import __version__
10
10
  from .errors import APIError, AuthError, RateLimitError, ValidationError
11
11
  from .extract import extract as _extract, extract_async as _extract_async
12
- from .ask import ask as _ask, ask_async as _ask_async
12
+ from .ask import (
13
+ ask as _ask,
14
+ ask_async as _ask_async,
15
+ ask_json as _ask_json,
16
+ ask_json_async as _ask_json_async,
17
+ )
13
18
  from .crawl import (
14
19
  CrawlEvent,
15
20
  crawl as _crawl,
@@ -22,6 +27,8 @@ from .crawl import (
22
27
  crawl_list_jobs_async as _crawl_list_jobs_async,
23
28
  )
24
29
  from .kg import (
30
+ KG_DQL_ENDPOINT,
31
+ KG_ONTOLOGY_ENDPOINT,
25
32
  dql as _dql,
26
33
  dql_async as _dql_async,
27
34
  dql_fetch_ontology as _dql_fetch_ontology,
@@ -67,6 +74,8 @@ class Diffbot:
67
74
  crawler_url: str = CRAWL_BASE,
68
75
  web_search_url: str = WEB_SEARCH_BASE,
69
76
  nlp_url: str = NLP_BASE,
77
+ dql_url: str = KG_DQL_ENDPOINT,
78
+ ontology_url: str = KG_ONTOLOGY_ENDPOINT,
70
79
  transport: Optional[httpx.BaseTransport] = None,
71
80
  ):
72
81
  if not token:
@@ -77,6 +86,8 @@ class Diffbot:
77
86
  self.crawler_url = crawler_url
78
87
  self.web_search_url = web_search_url
79
88
  self.nlp_url = nlp_url
89
+ self.dql_url = dql_url
90
+ self.ontology_url = ontology_url
80
91
  self._http = httpx.Client(
81
92
  timeout=timeout,
82
93
  headers={"User-Agent": f"diffbot-python/{__version__}"},
@@ -112,9 +123,32 @@ class Diffbot:
112
123
  """Extract structured content from a URL. Returns the raw Diffbot API response."""
113
124
  return _extract(self, url, api=api, fmt=fmt)
114
125
 
115
- def ask(self, messages: List[Dict[str, str]]) -> Iterator[str]:
116
- """Stream a response from the Diffbot LLM RAG API."""
117
- yield from _ask(self, messages)
126
+ def ask(
127
+ self,
128
+ messages: List[Dict[str, str]],
129
+ *,
130
+ response_format: Optional[Dict[str, Any]] = None,
131
+ ) -> Iterator[str]:
132
+ """Stream a response from the Diffbot LLM RAG API.
133
+
134
+ Pass ``response_format`` to constrain the output, e.g.
135
+ ``{"type": "json_object"}`` or ``diffbot.json_schema_format(schema)``.
136
+ """
137
+ yield from _ask(self, messages, response_format=response_format)
138
+
139
+ def ask_json(
140
+ self,
141
+ messages: List[Dict[str, str]],
142
+ schema: Optional[Dict[str, Any]] = None,
143
+ *,
144
+ response_format: Optional[Dict[str, Any]] = None,
145
+ ) -> Any:
146
+ """Ask the Diffbot LLM and return the answer parsed as JSON.
147
+
148
+ With ``schema`` (a JSON Schema dict) the model is constrained to it by
149
+ grammar; without one it falls back to unconstrained JSON output.
150
+ """
151
+ return _ask_json(self, messages, schema, response_format=response_format)
118
152
 
119
153
  def crawl(self, site: str, **kwargs: Any) -> Iterator[CrawlEvent]:
120
154
  """Start a crawl job."""
@@ -194,6 +228,8 @@ class DiffbotAsync:
194
228
  crawler_url: str = CRAWL_BASE,
195
229
  web_search_url: str = WEB_SEARCH_BASE,
196
230
  nlp_url: str = NLP_BASE,
231
+ dql_url: str = KG_DQL_ENDPOINT,
232
+ ontology_url: str = KG_ONTOLOGY_ENDPOINT,
197
233
  transport: Optional[httpx.AsyncBaseTransport] = None,
198
234
  ):
199
235
  if not token:
@@ -204,6 +240,8 @@ class DiffbotAsync:
204
240
  self.crawler_url = crawler_url
205
241
  self.web_search_url = web_search_url
206
242
  self.nlp_url = nlp_url
243
+ self.dql_url = dql_url
244
+ self.ontology_url = ontology_url
207
245
  self._http = httpx.AsyncClient(
208
246
  timeout=timeout,
209
247
  headers={"User-Agent": f"diffbot-python/{__version__}"},
@@ -239,11 +277,34 @@ class DiffbotAsync:
239
277
  """Extract structured content from a URL. Returns the raw Diffbot API response."""
240
278
  return await _extract_async(self, url, api=api, fmt=fmt)
241
279
 
242
- async def ask(self, messages: List[Dict[str, str]]) -> AsyncIterator[str]:
243
- """Stream a response from the Diffbot LLM RAG API."""
244
- async for chunk in _ask_async(self, messages):
280
+ async def ask(
281
+ self,
282
+ messages: List[Dict[str, str]],
283
+ *,
284
+ response_format: Optional[Dict[str, Any]] = None,
285
+ ) -> AsyncIterator[str]:
286
+ """Stream a response from the Diffbot LLM RAG API.
287
+
288
+ Pass ``response_format`` to constrain the output, e.g.
289
+ ``{"type": "json_object"}`` or ``diffbot.json_schema_format(schema)``.
290
+ """
291
+ async for chunk in _ask_async(self, messages, response_format=response_format):
245
292
  yield chunk
246
293
 
294
+ async def ask_json(
295
+ self,
296
+ messages: List[Dict[str, str]],
297
+ schema: Optional[Dict[str, Any]] = None,
298
+ *,
299
+ response_format: Optional[Dict[str, Any]] = None,
300
+ ) -> Any:
301
+ """Ask the Diffbot LLM and return the answer parsed as JSON.
302
+
303
+ With ``schema`` (a JSON Schema dict) the model is constrained to it by
304
+ grammar; without one it falls back to unconstrained JSON output.
305
+ """
306
+ return await _ask_json_async(self, messages, schema, response_format=response_format)
307
+
247
308
  async def crawl(self, site: str, **kwargs: Any) -> AsyncIterator[CrawlEvent]:
248
309
  """Start a crawl job. Pass watch=True to poll until completion and yield URL_PROCESSED events."""
249
310
  async for event in _crawl_async(self, site, **kwargs):
@@ -51,7 +51,7 @@ def dql(
51
51
  raw: bool = False,
52
52
  ) -> Union[Dict[str, Any], bytes]:
53
53
  params = _build_dql_params(client, query, size, from_, format, filter, exportspec, extra)
54
- response = client._http.get(KG_DQL_ENDPOINT, params=params)
54
+ response = client._http.get(client.dql_url, params=params)
55
55
  client._raise_for_status(response)
56
56
  return response.content if raw else response.json()
57
57
 
@@ -69,7 +69,7 @@ async def dql_async(
69
69
  raw: bool = False,
70
70
  ) -> Union[Dict[str, Any], bytes]:
71
71
  params = _build_dql_params(client, query, size, from_, format, filter, exportspec, extra)
72
- response = await client._http.get(KG_DQL_ENDPOINT, params=params)
72
+ response = await client._http.get(client.dql_url, params=params)
73
73
  client._raise_for_status(response)
74
74
  return response.content if raw else response.json()
75
75
 
@@ -104,7 +104,7 @@ async def dql_parallel_async(
104
104
 
105
105
 
106
106
  def dql_refresh_ontology(client: "Diffbot", dest: pathlib.Path) -> None:
107
- response = client._http.get(KG_ONTOLOGY_ENDPOINT)
107
+ response = client._http.get(client.ontology_url)
108
108
  client._raise_for_status(response)
109
109
  dest.parent.mkdir(parents=True, exist_ok=True)
110
110
  dest.write_bytes(response.content)
@@ -116,13 +116,13 @@ def dql_fetch_ontology(client: "Diffbot") -> Ontology:
116
116
  Performs no caching — the caller decides whether and where to hold onto the
117
117
  result. Use :func:`dql_refresh_ontology` instead to persist raw bytes to disk.
118
118
  """
119
- response = client._http.get(KG_ONTOLOGY_ENDPOINT)
119
+ response = client._http.get(client.ontology_url)
120
120
  client._raise_for_status(response)
121
121
  return Ontology.from_json(response.content)
122
122
 
123
123
 
124
124
  async def dql_fetch_ontology_async(client: "DiffbotAsync") -> Ontology:
125
125
  """Async variant of :func:`dql_fetch_ontology`."""
126
- response = await client._http.get(KG_ONTOLOGY_ENDPOINT)
126
+ response = await client._http.get(client.ontology_url)
127
127
  client._raise_for_status(response)
128
128
  return Ontology.from_json(response.content)