doconda 0.1.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.
- doconda/__init__.py +6 -0
- doconda/_client.py +320 -0
- doconda/_tools.py +283 -0
- doconda/py.typed +0 -0
- doconda-0.1.0.dist-info/METADATA +83 -0
- doconda-0.1.0.dist-info/RECORD +8 -0
- doconda-0.1.0.dist-info/WHEEL +4 -0
- doconda-0.1.0.dist-info/licenses/LICENSE +21 -0
doconda/__init__.py
ADDED
doconda/_client.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator, Mapping
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import IO, Any, Union
|
|
14
|
+
from urllib.parse import quote
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
# ===== Types =====
|
|
19
|
+
|
|
20
|
+
FileLike = Union[str, bytes, os.PathLike, IO[bytes]]
|
|
21
|
+
|
|
22
|
+
# ===== Constants =====
|
|
23
|
+
|
|
24
|
+
TERMINAL = {"ready", "ready_with_warnings", "needs_review", "failed", "canceled"}
|
|
25
|
+
TERMINAL_EVENTS = {"document.ready", "document.failed", "document.canceled"}
|
|
26
|
+
TIMEOUT = httpx.Timeout(60.0, read=600.0)
|
|
27
|
+
STREAM_TIMEOUT = httpx.Timeout(60.0, read=None)
|
|
28
|
+
|
|
29
|
+
# ===== Helpers =====
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _sse(response: httpx.Response) -> Iterator[dict]:
|
|
33
|
+
data: list[str] = []
|
|
34
|
+
for line in response.iter_lines():
|
|
35
|
+
if not line:
|
|
36
|
+
if data:
|
|
37
|
+
yield json.loads("\n".join(data))
|
|
38
|
+
data = []
|
|
39
|
+
elif line.startswith("data:"):
|
|
40
|
+
data.append(line[5:].removeprefix(" "))
|
|
41
|
+
if data:
|
|
42
|
+
yield json.loads("\n".join(data))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read(file: FileLike, filename: str | None) -> tuple[bytes, str]:
|
|
46
|
+
if isinstance(file, bytes):
|
|
47
|
+
if not filename:
|
|
48
|
+
raise ValueError("Doconda: pass filename (with its extension) when uploading bytes.")
|
|
49
|
+
return file, filename
|
|
50
|
+
if isinstance(file, (str, os.PathLike)):
|
|
51
|
+
path = Path(file)
|
|
52
|
+
return path.read_bytes(), filename or path.name
|
|
53
|
+
name = filename or Path(getattr(file, "name", "") or "").name
|
|
54
|
+
if not name:
|
|
55
|
+
raise ValueError("Doconda: pass filename (with its extension) for this file object.")
|
|
56
|
+
return file.read(), name
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _file_input(file: FileLike | Mapping[str, str], filename: str | None) -> Any:
|
|
60
|
+
if isinstance(file, Mapping):
|
|
61
|
+
return dict(file)
|
|
62
|
+
if isinstance(file, str) and re.match(r"^(file|doc)_", file):
|
|
63
|
+
return file
|
|
64
|
+
if isinstance(file, str) and re.match(r"^https?://", file):
|
|
65
|
+
return {"url": file, **({"filename": filename} if filename else {})}
|
|
66
|
+
data, name = _read(file, filename)
|
|
67
|
+
return {"data": base64.b64encode(data).decode(), "filename": name}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _query(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
71
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ===== Public API =====
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DocondaError(Exception):
|
|
78
|
+
def __init__(self, status: int, body: Any):
|
|
79
|
+
body = body if isinstance(body, dict) else {}
|
|
80
|
+
super().__init__(body.get("title") or f"Doconda API answered {status}")
|
|
81
|
+
self.status = status
|
|
82
|
+
self.code = body.get("code") or "http_error"
|
|
83
|
+
self.request_id = body.get("request_id")
|
|
84
|
+
self.errors = body.get("errors") or []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Doconda:
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
api_key: str | None = None,
|
|
91
|
+
*,
|
|
92
|
+
base_url: str | None = None,
|
|
93
|
+
max_retries: int = 2,
|
|
94
|
+
http_client: httpx.Client | None = None,
|
|
95
|
+
):
|
|
96
|
+
key = api_key or os.environ.get("DOCONDA_API_KEY")
|
|
97
|
+
if not key:
|
|
98
|
+
raise ValueError("Doconda: pass api_key or set DOCONDA_API_KEY.")
|
|
99
|
+
self.base_url = (
|
|
100
|
+
base_url or os.environ.get("DOCONDA_BASE_URL") or "https://api.eu.doconda.com/v1"
|
|
101
|
+
).rstrip("/")
|
|
102
|
+
self.max_retries = max_retries
|
|
103
|
+
self._key = key
|
|
104
|
+
self._http = http_client or httpx.Client(timeout=TIMEOUT)
|
|
105
|
+
self.documents = Documents(self)
|
|
106
|
+
self.files = Files(self)
|
|
107
|
+
self.webhooks = Webhooks(self)
|
|
108
|
+
|
|
109
|
+
def tools(self, *, local_files: bool = False) -> list:
|
|
110
|
+
from ._tools import doconda_tools
|
|
111
|
+
|
|
112
|
+
return doconda_tools(self, local_files=local_files)
|
|
113
|
+
|
|
114
|
+
def extract(self, file: FileLike | Mapping[str, str], *, filename: str | None = None) -> dict:
|
|
115
|
+
result = self.json("POST", "/extract", json={"file": _file_input(file, filename)})
|
|
116
|
+
while "markdown" not in result:
|
|
117
|
+
time.sleep(2)
|
|
118
|
+
result = self.json("GET", f"/extract/{result['id']}")
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
def request(
|
|
122
|
+
self,
|
|
123
|
+
method: str,
|
|
124
|
+
path: str,
|
|
125
|
+
*,
|
|
126
|
+
json: Any = None,
|
|
127
|
+
files: Any = None,
|
|
128
|
+
params: Mapping[str, Any] | None = None,
|
|
129
|
+
headers: Mapping[str, str] | None = None,
|
|
130
|
+
stream: bool = False,
|
|
131
|
+
) -> httpx.Response:
|
|
132
|
+
all_headers = {"authorization": f"Bearer {self._key}", **(headers or {})}
|
|
133
|
+
for attempt in range(self.max_retries + 1):
|
|
134
|
+
try:
|
|
135
|
+
request = self._http.build_request(
|
|
136
|
+
method,
|
|
137
|
+
f"{self.base_url}{path}",
|
|
138
|
+
json=json,
|
|
139
|
+
files=files,
|
|
140
|
+
params=params,
|
|
141
|
+
headers=all_headers,
|
|
142
|
+
timeout=STREAM_TIMEOUT if stream else self._http.timeout,
|
|
143
|
+
)
|
|
144
|
+
response = self._http.send(request, stream=stream)
|
|
145
|
+
except httpx.TransportError:
|
|
146
|
+
if attempt >= self.max_retries:
|
|
147
|
+
raise
|
|
148
|
+
time.sleep(0.5 * 2**attempt)
|
|
149
|
+
continue
|
|
150
|
+
if response.is_success:
|
|
151
|
+
return response
|
|
152
|
+
if stream:
|
|
153
|
+
response.read()
|
|
154
|
+
retryable = response.status_code == 429 or response.status_code >= 500
|
|
155
|
+
if not retryable or attempt >= self.max_retries:
|
|
156
|
+
try:
|
|
157
|
+
body = response.json()
|
|
158
|
+
except ValueError:
|
|
159
|
+
body = None
|
|
160
|
+
raise DocondaError(response.status_code, body)
|
|
161
|
+
wait = response.headers.get("retry-after")
|
|
162
|
+
time.sleep(float(wait) if wait and wait.isdigit() else 0.5 * 2**attempt)
|
|
163
|
+
raise AssertionError("unreachable")
|
|
164
|
+
|
|
165
|
+
def json(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
166
|
+
response = self.request(method, path, **kwargs)
|
|
167
|
+
return None if response.status_code == 204 else response.json()
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self._http.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, *_: object) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Documents:
|
|
180
|
+
def __init__(self, client: Doconda):
|
|
181
|
+
self._c = client
|
|
182
|
+
|
|
183
|
+
def create(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
|
|
184
|
+
doc = self._post(body, idempotency_key)
|
|
185
|
+
return doc if doc["status"] in TERMINAL else self.wait(doc["id"])
|
|
186
|
+
|
|
187
|
+
def create_in_background(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
|
|
188
|
+
return self._post({**body, "background": True}, idempotency_key)
|
|
189
|
+
|
|
190
|
+
def stream(self, *, idempotency_key: str | None = None, **body: Any) -> Iterator[dict]:
|
|
191
|
+
response = self._c.request(
|
|
192
|
+
"POST",
|
|
193
|
+
"/documents",
|
|
194
|
+
json={**body, "stream": True},
|
|
195
|
+
headers={"idempotency-key": idempotency_key or str(uuid.uuid4()), "accept": "text/event-stream"},
|
|
196
|
+
stream=True,
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
yield from _sse(response)
|
|
200
|
+
finally:
|
|
201
|
+
response.close()
|
|
202
|
+
|
|
203
|
+
def get(self, document_id: str) -> dict:
|
|
204
|
+
return self._c.json("GET", f"/documents/{document_id}")
|
|
205
|
+
|
|
206
|
+
def page(self, *, limit: int | None = None, cursor: str | None = None, status: str | None = None) -> dict:
|
|
207
|
+
return self._c.json(
|
|
208
|
+
"GET", "/documents", params=_query({"limit": limit, "cursor": cursor, "status": status})
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def list(self, *, status: str | None = None) -> Iterator[dict]:
|
|
212
|
+
cursor = None
|
|
213
|
+
while True:
|
|
214
|
+
page = self.page(limit=100, cursor=cursor, status=status)
|
|
215
|
+
yield from page["data"]
|
|
216
|
+
cursor = page.get("next_cursor")
|
|
217
|
+
if not cursor:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
def cancel(self, document_id: str) -> dict:
|
|
221
|
+
return self._c.json(
|
|
222
|
+
"POST", f"/documents/{document_id}/cancel", headers={"idempotency-key": str(uuid.uuid4())}
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def delete(self, document_id: str) -> None:
|
|
226
|
+
self._c.json("DELETE", f"/documents/{document_id}")
|
|
227
|
+
|
|
228
|
+
def events(self, document_id: str, *, after: int = 0) -> Iterator[dict]:
|
|
229
|
+
response = self._c.request(
|
|
230
|
+
"GET", f"/documents/{document_id}/events", params=_query({"after": after or None}), stream=True
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
yield from _sse(response)
|
|
234
|
+
finally:
|
|
235
|
+
response.close()
|
|
236
|
+
|
|
237
|
+
def wait(self, document_id: str) -> dict:
|
|
238
|
+
after = 0
|
|
239
|
+
while True:
|
|
240
|
+
for event in self.events(document_id, after=after):
|
|
241
|
+
after = event["sequence"]
|
|
242
|
+
if event["type"] in TERMINAL_EVENTS:
|
|
243
|
+
return self.get(document_id)
|
|
244
|
+
doc = self.get(document_id)
|
|
245
|
+
if doc["status"] in TERMINAL:
|
|
246
|
+
return doc
|
|
247
|
+
|
|
248
|
+
def outputs(self, document_id: str) -> list:
|
|
249
|
+
return self._c.json("GET", f"/documents/{document_id}/outputs")["data"]
|
|
250
|
+
|
|
251
|
+
def download(self, document_id: str, kind: str) -> bytes:
|
|
252
|
+
output = next((o for o in self.outputs(document_id) if o["kind"] == kind), None)
|
|
253
|
+
if output is None:
|
|
254
|
+
raise ValueError(f"Doconda: document {document_id} has no {kind}.")
|
|
255
|
+
response = httpx.get(output["url"], timeout=TIMEOUT, follow_redirects=True)
|
|
256
|
+
response.raise_for_status()
|
|
257
|
+
return response.content
|
|
258
|
+
|
|
259
|
+
def report(self, document_id: str) -> dict:
|
|
260
|
+
return self._c.json("GET", f"/documents/{document_id}/report")
|
|
261
|
+
|
|
262
|
+
def data(self, document_id: str, collection: str | None = None) -> list:
|
|
263
|
+
path = f"/documents/{document_id}/data" + (f"/{quote(collection, safe='')}" if collection else "")
|
|
264
|
+
return self._c.json("GET", path)["data"]
|
|
265
|
+
|
|
266
|
+
def _post(self, body: dict, idempotency_key: str | None) -> dict:
|
|
267
|
+
return self._c.json(
|
|
268
|
+
"POST", "/documents", json=body, headers={"idempotency-key": idempotency_key or str(uuid.uuid4())}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Files:
|
|
273
|
+
def __init__(self, client: Doconda):
|
|
274
|
+
self._c = client
|
|
275
|
+
|
|
276
|
+
def upload(self, file: FileLike, filename: str | None = None) -> dict:
|
|
277
|
+
data, name = _read(file, filename)
|
|
278
|
+
return self._c.json("POST", "/files", files={"file": (name, data)})
|
|
279
|
+
|
|
280
|
+
def get(self, file_id: str) -> dict:
|
|
281
|
+
return self._c.json("GET", f"/files/{file_id}")
|
|
282
|
+
|
|
283
|
+
def delete(self, file_id: str) -> None:
|
|
284
|
+
self._c.json("DELETE", f"/files/{file_id}")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class Webhooks:
|
|
288
|
+
def __init__(self, client: Doconda):
|
|
289
|
+
self._c = client
|
|
290
|
+
|
|
291
|
+
def create(self, url: str, *, event_types: list[str] | None = None) -> dict:
|
|
292
|
+
return self._c.json(
|
|
293
|
+
"POST", "/webhook-endpoints", json=_query({"url": url, "event_types": event_types})
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def list(self) -> list:
|
|
297
|
+
return self._c.json("GET", "/webhook-endpoints")["data"]
|
|
298
|
+
|
|
299
|
+
def delete(self, endpoint_id: str) -> None:
|
|
300
|
+
self._c.json("DELETE", f"/webhook-endpoints/{endpoint_id}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def verify_webhook(
|
|
304
|
+
body: str | bytes, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300
|
|
305
|
+
) -> dict:
|
|
306
|
+
lower = {k.lower(): v for k, v in headers.items()}
|
|
307
|
+
msg_id, timestamp, signatures = (
|
|
308
|
+
lower.get(h) for h in ("webhook-id", "webhook-timestamp", "webhook-signature")
|
|
309
|
+
)
|
|
310
|
+
if not msg_id or not timestamp or not signatures:
|
|
311
|
+
raise ValueError("Doconda webhook: missing webhook-id, -timestamp or -signature.")
|
|
312
|
+
if abs(time.time() - int(timestamp)) > tolerance_seconds:
|
|
313
|
+
raise ValueError("Doconda webhook: the timestamp is too old or in the future.")
|
|
314
|
+
text = body.decode() if isinstance(body, bytes) else body
|
|
315
|
+
key = base64.b64decode(secret.removeprefix("whsec_"))
|
|
316
|
+
mac = hmac.new(key, f"{msg_id}.{timestamp}.{text}".encode(), hashlib.sha256).digest()
|
|
317
|
+
expected = base64.b64encode(mac).decode()
|
|
318
|
+
if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures.split(" ")):
|
|
319
|
+
raise ValueError("Doconda webhook: the signature does not match.")
|
|
320
|
+
return json.loads(text)
|
doconda/_tools.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._client import Doconda
|
|
13
|
+
|
|
14
|
+
# ===== Types =====
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Tool:
|
|
19
|
+
name: str
|
|
20
|
+
description: str
|
|
21
|
+
input_schema: dict
|
|
22
|
+
run: Callable[[dict], Any]
|
|
23
|
+
|
|
24
|
+
def __call__(self, **kwargs: Any) -> Any:
|
|
25
|
+
return self.run(kwargs)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ===== Constants =====
|
|
29
|
+
|
|
30
|
+
FORMATS = ["docx", "pdf", "pptx", "xlsx", "artifact"]
|
|
31
|
+
|
|
32
|
+
# ===== Helpers =====
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _text(description: str) -> dict:
|
|
36
|
+
return {"type": "string", "description": description}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _object(properties: dict, required: list[str] | None = None) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"type": "object",
|
|
42
|
+
"properties": properties,
|
|
43
|
+
**({"required": required} if required else {}),
|
|
44
|
+
"additionalProperties": False,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _summary(doc: dict) -> dict:
|
|
49
|
+
hosted = doc.get("retention") != "none"
|
|
50
|
+
links = {
|
|
51
|
+
("page" if o["kind"] == "html" and hosted else o["kind"]): o["url"]
|
|
52
|
+
for o in doc.get("outputs") or []
|
|
53
|
+
if o["kind"] != "preview"
|
|
54
|
+
}
|
|
55
|
+
out: dict[str, Any] = {"id": doc["id"]}
|
|
56
|
+
if doc.get("name"):
|
|
57
|
+
out["name"] = doc["name"]
|
|
58
|
+
out["format"] = doc.get("format")
|
|
59
|
+
out["status"] = doc["status"]
|
|
60
|
+
if doc.get("error"):
|
|
61
|
+
out["error"] = doc["error"]["message"]
|
|
62
|
+
if doc.get("pages"):
|
|
63
|
+
out["pages"] = doc["pages"]
|
|
64
|
+
if links:
|
|
65
|
+
out["links"] = links
|
|
66
|
+
out["links_note"] = (
|
|
67
|
+
"The page works until the document expires (`expires_at`); file links expire in 5 minutes: "
|
|
68
|
+
"call get_document for fresh ones."
|
|
69
|
+
if "page" in links
|
|
70
|
+
else "With retention `none`: `html` is the page for the user to download (deleted in 15 minutes); "
|
|
71
|
+
"it keeps data in each visitor's browser."
|
|
72
|
+
if "html" in links
|
|
73
|
+
else "Links expire in 5 minutes: call get_document for fresh ones."
|
|
74
|
+
)
|
|
75
|
+
if doc.get("style_unsupported"):
|
|
76
|
+
out["style_not_applied"] = doc["style_unsupported"]
|
|
77
|
+
summary = doc.get("validation_summary") or {}
|
|
78
|
+
if summary.get("errors") or summary.get("warnings"):
|
|
79
|
+
out["review"] = (
|
|
80
|
+
f"{summary['errors']} problems and {summary['warnings']} warnings left; see get_report"
|
|
81
|
+
)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ===== Public API =====
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def doconda_tools(doconda: Doconda, *, local_files: bool = False) -> list[Tool]:
|
|
89
|
+
def create_document(a: dict) -> dict:
|
|
90
|
+
body: dict[str, Any] = {}
|
|
91
|
+
for key in ("format", "prompt", "style", "name", "retention"):
|
|
92
|
+
if a.get(key):
|
|
93
|
+
body[key] = a[key]
|
|
94
|
+
if a.get("markdown"):
|
|
95
|
+
body["content"] = {"markdown": a["markdown"]}
|
|
96
|
+
if a.get("source_file_ids"):
|
|
97
|
+
body["sources"] = [str(i) for i in a["source_file_ids"]]
|
|
98
|
+
if a.get("data"):
|
|
99
|
+
body["artifact"] = {"storage": a["data"]}
|
|
100
|
+
return _summary(doconda.documents.create(**body))
|
|
101
|
+
|
|
102
|
+
def upload_file(a: dict) -> dict:
|
|
103
|
+
filename = a.get("filename") or ""
|
|
104
|
+
if a.get("path") and local_files:
|
|
105
|
+
path = Path(a["path"])
|
|
106
|
+
data, filename = path.read_bytes(), filename or path.name
|
|
107
|
+
elif a.get("url"):
|
|
108
|
+
response = httpx.get(a["url"], follow_redirects=True, timeout=60)
|
|
109
|
+
if not response.is_success:
|
|
110
|
+
raise ValueError(f"Could not download {a['url']} ({response.status_code}).")
|
|
111
|
+
data, filename = response.content, filename or Path(urlparse(a["url"]).path).name or "file"
|
|
112
|
+
elif a.get("content_base64"):
|
|
113
|
+
data = base64.b64decode(a["content_base64"])
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Give path, url or content_base64." if local_files else "Give url or content_base64."
|
|
117
|
+
)
|
|
118
|
+
if not filename:
|
|
119
|
+
raise ValueError("Give filename (with its extension).")
|
|
120
|
+
file = doconda.files.upload(data, filename)
|
|
121
|
+
return {"file_id": file["id"], "filename": file["filename"], "pages": file.get("pages")}
|
|
122
|
+
|
|
123
|
+
def list_documents(a: dict) -> list:
|
|
124
|
+
page = doconda.documents.page(limit=min(100, max(1, int(a.get("limit") or 20))))
|
|
125
|
+
return [
|
|
126
|
+
{
|
|
127
|
+
"id": d["id"],
|
|
128
|
+
**({"name": d["name"]} if d.get("name") else {}),
|
|
129
|
+
"operation": d.get("operation"),
|
|
130
|
+
"type": d.get("type"),
|
|
131
|
+
"status": d["status"],
|
|
132
|
+
"created_at": d.get("created_at"),
|
|
133
|
+
}
|
|
134
|
+
for d in page["data"]
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
def get_report(a: dict) -> dict:
|
|
138
|
+
r = doconda.documents.report(a["document_id"])
|
|
139
|
+
out = {
|
|
140
|
+
"problems_left": [{"severity": i["severity"], "message": i["message"]} for i in r["issues"]],
|
|
141
|
+
"fixes": len(r.get("ledger") or []),
|
|
142
|
+
}
|
|
143
|
+
if r.get("edits"):
|
|
144
|
+
out["edits"] = r["edits"]
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
upload_properties = {
|
|
148
|
+
**({"path": _text("Absolute path of the file on this computer.")} if local_files else {}),
|
|
149
|
+
"url": _text("A public URL to download the file from."),
|
|
150
|
+
"content_base64": _text("The file's bytes in base64 (for small files)."),
|
|
151
|
+
"filename": _text("Its name with extension, e.g. contract.docx. Needed with content_base64."),
|
|
152
|
+
}
|
|
153
|
+
file_id = _text("From upload_file, or the id of a document Doconda made (list_documents).")
|
|
154
|
+
return [
|
|
155
|
+
Tool(
|
|
156
|
+
"create_document",
|
|
157
|
+
"Create a finished document: Word, PDF, PowerPoint, Excel or an artifact (a web page with its own "
|
|
158
|
+
"shared database, e.g. a poll or a team checklist). Choose only the format: Doconda works out what kind "
|
|
159
|
+
"of document it is (contract, letter, report…) from the request. Give `prompt` to have it written, or "
|
|
160
|
+
"`markdown` with the content already written. Waits until it is ready and returns download links.",
|
|
161
|
+
_object(
|
|
162
|
+
{
|
|
163
|
+
"format": {
|
|
164
|
+
"type": "string",
|
|
165
|
+
"enum": FORMATS,
|
|
166
|
+
"description": "docx → Word (+ PDF); pdf → only the PDF; pptx → PowerPoint (+ PDF); xlsx → "
|
|
167
|
+
"Excel (+ PDF); artifact → web page. If the user didn't say, leave it out and Doconda decides "
|
|
168
|
+
"from the prompt.",
|
|
169
|
+
},
|
|
170
|
+
"prompt": _text(
|
|
171
|
+
"What to write, in the user's words (topic, audience, length, tone). Formatting wishes too, "
|
|
172
|
+
"e.g. 'blue centred headings, with a cover page'."
|
|
173
|
+
),
|
|
174
|
+
"markdown": _text(
|
|
175
|
+
"Content already written, in Markdown (instead of prompt). For an artifact, the content its "
|
|
176
|
+
"page shows."
|
|
177
|
+
),
|
|
178
|
+
"style": _text(
|
|
179
|
+
"Optional: the look in plain words, e.g. 'blue centred headings, Lora font, page numbers, "
|
|
180
|
+
"table of contents'."
|
|
181
|
+
),
|
|
182
|
+
"name": _text(
|
|
183
|
+
"Optional: a name to find it later, e.g. 'Contrato piso Malasaña'. It also names the files."
|
|
184
|
+
),
|
|
185
|
+
"source_file_ids": {
|
|
186
|
+
"type": "array",
|
|
187
|
+
"items": {"type": "string"},
|
|
188
|
+
"description": "Uploaded files (upload_file) or documents already made (their id, from "
|
|
189
|
+
"list_documents): documents to base the text on (up to 5, with prompt) and pictures to put in "
|
|
190
|
+
"the document (up to 10; with markdown, refer to them as , the first picture).",
|
|
191
|
+
},
|
|
192
|
+
"data": {
|
|
193
|
+
"type": "string",
|
|
194
|
+
"enum": ["shared", "local"],
|
|
195
|
+
"description": "Artifacts only. shared (default): everyone with the link sees the same data "
|
|
196
|
+
"(polls, team lists). local: each visitor keeps their own in their browser (calculators, "
|
|
197
|
+
"personal checklists). With retention none, only local.",
|
|
198
|
+
},
|
|
199
|
+
"retention": {
|
|
200
|
+
"type": "string",
|
|
201
|
+
"enum": ["none", "1d", "7d", "30d", "90d"],
|
|
202
|
+
"description": "Optional: how long Doconda keeps it once finished (none = 15 minutes). Without "
|
|
203
|
+
"it, the project's retention (30 days unless changed). The user should save the file before "
|
|
204
|
+
"then.",
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
),
|
|
208
|
+
create_document,
|
|
209
|
+
),
|
|
210
|
+
Tool(
|
|
211
|
+
"upload_file",
|
|
212
|
+
"Upload a file (Word, PDF, PowerPoint, Excel — also the old .doc, .ppt and .xls —, TXT, Markdown, or a "
|
|
213
|
+
"PNG, JPEG, GIF or WebP picture; up to 20 MB) to review or edit it, or to use it as a source. Returns its "
|
|
214
|
+
"file_id."
|
|
215
|
+
+ (
|
|
216
|
+
" Give `path` for a file on this computer."
|
|
217
|
+
if local_files
|
|
218
|
+
else " Give `url` (public) or `content_base64`."
|
|
219
|
+
),
|
|
220
|
+
_object(upload_properties),
|
|
221
|
+
upload_file,
|
|
222
|
+
),
|
|
223
|
+
Tool(
|
|
224
|
+
"review_document",
|
|
225
|
+
"Check an uploaded Word, PowerPoint, Excel or PDF file (also .doc, .ppt, .xls) for problems and fix the "
|
|
226
|
+
"safe ones without touching the content, no AI: tables off the page, split signatures and broken "
|
|
227
|
+
"numbering in Word; text overflowing its box or shapes off the slide in PowerPoint; formula errors, "
|
|
228
|
+
"numbers stored as text or sheets too wide to print in Excel; scanned pages without text, missing title "
|
|
229
|
+
"or language in PDF; amounts in words that don't match the figures in all of them. Files with macros are "
|
|
230
|
+
"rejected; other dangerous content (external links and pictures, embedded files, automatic actions…) is "
|
|
231
|
+
"removed. Returns the fixed file (old formats come back as .docx/.pptx/.xlsx) and what was found.",
|
|
232
|
+
_object({"file_id": file_id}, ["file_id"]),
|
|
233
|
+
lambda a: _summary(doconda.documents.create(operation="review", file=a["file_id"])),
|
|
234
|
+
),
|
|
235
|
+
Tool(
|
|
236
|
+
"edit_document",
|
|
237
|
+
"Change an uploaded Word, PowerPoint, Excel or PDF file as asked (e.g. 'blue headings and add a "
|
|
238
|
+
"confidentiality clause'). Only what is asked changes; the rest stays identical. Returns the new file "
|
|
239
|
+
"and a link to the report of every change.",
|
|
240
|
+
_object(
|
|
241
|
+
{"file_id": file_id, "instructions": _text("What to change, in the user's words.")},
|
|
242
|
+
["file_id", "instructions"],
|
|
243
|
+
),
|
|
244
|
+
lambda a: _summary(
|
|
245
|
+
doconda.documents.create(operation="edit", file=a["file_id"], prompt=a["instructions"])
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
Tool(
|
|
249
|
+
"list_documents",
|
|
250
|
+
"The latest documents made in this project (newest first), with their id and name. Use an id as file_id "
|
|
251
|
+
"to review, edit or read one, or in source_file_ids to create from it.",
|
|
252
|
+
_object({"limit": {"type": "number", "description": "How many, 1–100 (default 20)."}}),
|
|
253
|
+
list_documents,
|
|
254
|
+
),
|
|
255
|
+
Tool(
|
|
256
|
+
"get_document",
|
|
257
|
+
"A document's status and fresh download links (they expire after 5 minutes).",
|
|
258
|
+
_object({"document_id": _text("doc_…")}, ["document_id"]),
|
|
259
|
+
lambda a: _summary(doconda.documents.get(a["document_id"])),
|
|
260
|
+
),
|
|
261
|
+
Tool(
|
|
262
|
+
"get_report",
|
|
263
|
+
"What a review or edit found and did: problems left, fixes applied or rejected (and why), and for edits "
|
|
264
|
+
"every change with the text before and after.",
|
|
265
|
+
_object({"document_id": _text("doc_…")}, ["document_id"]),
|
|
266
|
+
get_report,
|
|
267
|
+
),
|
|
268
|
+
Tool(
|
|
269
|
+
"read_artifact_data",
|
|
270
|
+
"What people have saved in an artifact's page (e.g. poll votes): its collections, or one collection's "
|
|
271
|
+
"records, newest first.",
|
|
272
|
+
_object(
|
|
273
|
+
{
|
|
274
|
+
"document_id": _text("The artifact's doc_… id."),
|
|
275
|
+
"collection": _text(
|
|
276
|
+
"Optional: one collection's records instead of the list of collections."
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
["document_id"],
|
|
280
|
+
),
|
|
281
|
+
lambda a: doconda.documents.data(a["document_id"], a.get("collection")),
|
|
282
|
+
),
|
|
283
|
+
]
|
doconda/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doconda
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Doconda: generate, review and edit Word, PowerPoint, Excel and PDF documents from AI agents (Amazon Bedrock, Vertex AI, OpenAI, Claude, LangChain).
|
|
5
|
+
Project-URL: Homepage, https://doconda.com
|
|
6
|
+
Project-URL: Documentation, https://docs.doconda.com/en/sdk/python
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai-agents,anthropic,bedrock,document-generation,docx,excel,langchain,llm,mcp,openai,pdf,powerpoint,pptx,tools,vertex-ai,word,xlsx
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Office/Business
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# doconda
|
|
19
|
+
|
|
20
|
+
Generate, review and edit **Word (DOCX), PowerPoint (PPTX), Excel (XLSX) and PDF** documents from your AI agent or chatbot, with one call. Editable files with real Word styles, reviewed before they are delivered.
|
|
21
|
+
|
|
22
|
+
Use it when your agent has to hand the user a document and you don't want to maintain python-docx, python-pptx, openpyxl or templates. Works with Amazon Bedrock, Vertex AI, OpenAI, Claude, Gemini, LangChain, CrewAI and any model with tool calling.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install doconda
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from doconda import Doconda
|
|
30
|
+
|
|
31
|
+
doconda = Doconda() # reads DOCONDA_API_KEY
|
|
32
|
+
doc = doconda.documents.create(format="docx", prompt="Report on the housing law")
|
|
33
|
+
print(doc["outputs"]) # links to the Word file and the PDF
|
|
34
|
+
|
|
35
|
+
open("report.docx", "wb").write(doconda.documents.download(doc["id"], "docx"))
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## As tools for your agent
|
|
39
|
+
|
|
40
|
+
`doconda.tools()` returns ready-made tools (name, description, JSON Schema, `run`) to create, review and edit documents. With Amazon Bedrock (Converse API):
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import json
|
|
44
|
+
import boto3
|
|
45
|
+
from doconda import Doconda
|
|
46
|
+
|
|
47
|
+
bedrock = boto3.client("bedrock-runtime")
|
|
48
|
+
tools = Doconda().tools()
|
|
49
|
+
tool_config = {
|
|
50
|
+
"tools": [
|
|
51
|
+
{"toolSpec": {"name": t.name, "description": t.description, "inputSchema": {"json": t.input_schema}}}
|
|
52
|
+
for t in tools
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
messages = [{"role": "user", "content": [{"text": "Make an Excel with the event budget"}]}]
|
|
56
|
+
|
|
57
|
+
while True:
|
|
58
|
+
res = bedrock.converse(modelId=MODEL_ID, messages=messages, toolConfig=tool_config)
|
|
59
|
+
message = res["output"]["message"]
|
|
60
|
+
messages.append(message)
|
|
61
|
+
if res["stopReason"] != "tool_use":
|
|
62
|
+
break
|
|
63
|
+
results = []
|
|
64
|
+
for block in message["content"]:
|
|
65
|
+
if "toolUse" in block:
|
|
66
|
+
use = block["toolUse"]
|
|
67
|
+
out = next(t for t in tools if t.name == use["name"]).run(use["input"])
|
|
68
|
+
results.append(
|
|
69
|
+
{"toolResult": {"toolUseId": use["toolUseId"], "content": [{"text": json.dumps(out)}]}}
|
|
70
|
+
)
|
|
71
|
+
messages.append({"role": "user", "content": results})
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The same tools plug into Vertex AI / Gemini, OpenAI, Claude, LangChain or CrewAI: see the guides.
|
|
75
|
+
|
|
76
|
+
## What else
|
|
77
|
+
|
|
78
|
+
- `documents.create(operation="review", file=…)` and `operation="edit"`: review and fix, or change, an existing Word, PowerPoint, Excel or PDF.
|
|
79
|
+
- `documents.stream(…)`: the document's events as they happen.
|
|
80
|
+
- `extract(file)`: any file to Markdown for your model (free).
|
|
81
|
+
- `files.upload(path_or_bytes)`, `webhooks.create(url)` and `verify_webhook(body, headers, secret)`.
|
|
82
|
+
|
|
83
|
+
Docs: https://docs.doconda.com/en/sdk/python · Guides per stack: https://docs.doconda.com/en/agents/introduction · Get an API key: https://doconda.com
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
doconda/__init__.py,sha256=R4JP9f2XWAeTXWmPFQcZQkdassvSgQUS-tilFw3cf88,175
|
|
2
|
+
doconda/_client.py,sha256=RnhN3TUs0C4XJX4nkjoIcJTChg8btB_CumVkD2dMkTw,11779
|
|
3
|
+
doconda/_tools.py,sha256=VJhFAu7uzvOcQcjU1YM88VMDyvLQEP0vkY5WE8JO4XE,12692
|
|
4
|
+
doconda/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
doconda-0.1.0.dist-info/METADATA,sha256=Rb3hJr_cFPyaAJnvpNM05ucU2awj2JF2Oc6yfmXLr5U,3448
|
|
6
|
+
doconda-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
doconda-0.1.0.dist-info/licenses/LICENSE,sha256=t2QYZmJ9o6N4MsUtIaa9WO1rw7Lx80BoHcEwRLGNqrk,1064
|
|
8
|
+
doconda-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Doconda
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|