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 ADDED
@@ -0,0 +1,6 @@
1
+ from ._client import Doconda, DocondaError, verify_webhook
2
+ from ._tools import Tool
3
+
4
+ # ===== Public API =====
5
+
6
+ __all__ = ["Doconda", "DocondaError", "Tool", "verify_webhook"]
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 ![caption](img:1), 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.