ando-ai 0.4.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.
ando_ai-0.4.0/LICENSE ADDED
@@ -0,0 +1,50 @@
1
+ Ando Platform SDK License
2
+ =========================
3
+
4
+ Copyright (c) 2026 Ando S.r.l. (ando-ai.com). All rights reserved.
5
+
6
+ THIS IS NOT AN OPEN SOURCE LICENSE. This software is proprietary and
7
+ confidential to Ando S.r.l. ("Ando").
8
+
9
+ 1. License grant. Subject to these terms, Ando grants you a limited,
10
+ non-exclusive, non-transferable, non-sublicensable, revocable license to
11
+ install and use this software (the "SDK") solely to access the Ando
12
+ Platform services under an active agreement or evaluation arrangement
13
+ with Ando.
14
+
15
+ 2. Restrictions. Except as expressly permitted above or by mandatory
16
+ applicable law, you may not: (a) copy, modify, adapt, translate or
17
+ create derivative works of the SDK; (b) distribute, sell, rent, lease,
18
+ sublicense or otherwise transfer the SDK to any third party;
19
+ (c) reverse engineer, decompile or disassemble the SDK; (d) use the SDK
20
+ to develop a product or service that competes with the Ando Platform;
21
+ (e) remove or alter any proprietary notices.
22
+
23
+ 3. Ownership. The SDK is licensed, not sold. Ando and its licensors retain
24
+ all right, title and interest in and to the SDK, including all
25
+ intellectual property rights. No rights are granted by implication,
26
+ estoppel or otherwise.
27
+
28
+ 4. Feedback. If you provide feedback about the SDK, Ando may use it
29
+ without restriction or obligation.
30
+
31
+ 5. No warranty. THE SDK IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT
32
+ WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION
33
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE
34
+ AND NON-INFRINGEMENT.
35
+
36
+ 6. Limitation of liability. TO THE MAXIMUM EXTENT PERMITTED BY LAW, ANDO
37
+ SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
38
+ CONSEQUENTIAL OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS, REVENUE,
39
+ DATA OR GOODWILL, ARISING OUT OF OR RELATED TO THE SDK, EVEN IF ADVISED
40
+ OF THE POSSIBILITY OF SUCH DAMAGES.
41
+
42
+ 7. Termination. This license terminates automatically if you breach these
43
+ terms or when your agreement with Ando ends. Upon termination you must
44
+ stop using and delete all copies of the SDK.
45
+
46
+ 8. Governing law. This license is governed by the laws of Italy, without
47
+ regard to conflict-of-law principles. Exclusive venue: the courts of
48
+ Bari, Italy.
49
+
50
+ For licensing questions: Ando S.r.l. — Bari, Italy — VAT/C.F. 09171580724 — ando-ai.com
ando_ai-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,595 @@
1
+ Metadata-Version: 2.4
2
+ Name: ando-ai
3
+ Version: 0.4.0
4
+ Summary: Official Python client SDK and CLI for the Ando Platform API. Requires an active Ando account and API key.
5
+ Author: Ando S.r.l.
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://ando-ai.com
8
+ Project-URL: Repository, https://github.com/AndreaBovinelli/ando-agent
9
+ Project-URL: Issues, https://github.com/AndreaBovinelli/ando-agent/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: httpx>=0.24
16
+ Dynamic: license-file
17
+
18
+ # Ando Platform — Python SDK
19
+
20
+ Client Python **sincrono** per la Ando Platform API v1
21
+ (`https://api.ando-ai.com/platform/v1`). Unica dipendenza: `httpx>=0.24`.
22
+
23
+ Endpoint predefinito: `https://api.ando-ai.com/platform/v1`.
24
+
25
+ ## Requisiti di accesso
26
+
27
+ Questo pacchetto contiene solo il client SDK: non include né rende
28
+ autonomamente disponibile il servizio Ando Platform. Per usarlo servono un
29
+ account Ando attivo (o un accesso di valutazione), i relativi diritti di
30
+ licenza e una API key emessa da Ando. Per richiedere l'attivazione, contatta
31
+ Ando su [ando-ai.com](https://ando-ai.com).
32
+
33
+ ## Installazione
34
+
35
+ Requisito: Python 3.9+.
36
+
37
+ ```bash
38
+ pip install ando-ai
39
+ ```
40
+
41
+ ## Uso
42
+
43
+ ```python
44
+ from ando_ai import AndoPlatform, ApiError, JobFailed
45
+
46
+ with AndoPlatform("ando_sk_live_...") as ando:
47
+ accepted = ando.upload_document("contratto.pdf")
48
+ ando.wait_for_job(accepted["job_id"])
49
+
50
+ result = ando.answer("Qual è la durata del contratto?")
51
+ print(result["answer"], result["citations"])
52
+
53
+ ando.delete_document(accepted["document_id"])
54
+ ```
55
+
56
+ Il client è un context manager; in alternativa chiama `ando.close()` a fine
57
+ lavoro.
58
+
59
+ ## Cosa puoi costruire — i tre livelli
60
+
61
+ Lo stesso stack, esposto a tre profondità crescenti. Non sono alternative:
62
+ sono quanto lavoro fa il server al posto tuo.
63
+
64
+ ### 1. `query()` — recuperi i chunk, generi tu
65
+
66
+ Per chi ha già il proprio LLM/prompt e vuole solo il retrieval (con
67
+ citazioni e score).
68
+
69
+ ```python
70
+ hits = ando.query("penali per ritardata consegna", k=8)
71
+ for hit in hits["results"]:
72
+ print(hit["score"], hit["section"], hit["text"][:120])
73
+ # hit["document_id"] / hit["chunk_id"] = la citazione da mostrare
74
+ ```
75
+
76
+ ### 2. `answer()` — risposta sintetizzata e ancorata
77
+
78
+ Il server recupera **e** sintetizza, citando i chunk usati. La risposta
79
+ rispecchia la lingua della domanda (non si forza nulla). Se il retrieval
80
+ non trova nulla: `answer` è `None` e `no_results` è `True`.
81
+
82
+ ```python
83
+ result = ando.answer("Qual è la durata del contratto?", verify=True)
84
+ print(result["answer"])
85
+ print(result["citations"]) # [{document_id, chunk_id}, ...]
86
+ print(result["verification"]) # {verified, score, notes} | None
87
+ ```
88
+
89
+ ### 3. `agentic_answer()` — l'agente completo, anche sui dati strutturati (PREVIEW)
90
+
91
+ Questa è la differenza tra «ti do dei chunk» e «ho interrogato il tuo
92
+ gestionale». Gira la stessa macchina della chat prodotto: escalation TRAMA
93
+ T1→T2→T3 e, sulle **sources strutturate pubblicate** del project,
94
+ text-to-SQL — quindi una domanda la cui risposta sta in una tabella viene
95
+ risposta *dalla tabella*, non approssimata dai documenti.
96
+
97
+ **Read-only per costruzione**: nessuna scrittura, nessuna azione, nessuna
98
+ persistenza. `steps[]` mostra cosa ha fatto l'agente (tier di retrieval,
99
+ SQL eseguito con credenziali redatte, specialist scelto).
100
+
101
+ ```python
102
+ result = ando.agentic_answer(
103
+ "Quali clienti hanno esposizione più critica e per quale importo?",
104
+ k=25,
105
+ with_diagnostics=True, # tier, timing, plan cache
106
+ )
107
+ print(result["answer"])
108
+ print(result["sources_used"]) # es. ["database", "fs"]
109
+ for step in result["steps"]:
110
+ print(step["type"], step) # tipi sconosciuti = additivi, non ramificarci sopra
111
+ ```
112
+
113
+ Una chiamata agentica è multi-step: il timeout di default è
114
+ `AGENTIC_TIMEOUT` (120s), non i 30s del client. Si sovrascrive per
115
+ chiamata con `timeout=`.
116
+
117
+ > **PREVIEW**: `POST /agentic/answer` non è ancora GA — request e response
118
+ > possono cambiare. Vedi `docs/PLATFORM_API_CONTRACT.md`.
119
+
120
+ ### Conversazioni — memoria tra turni agentici
121
+
122
+ Un turno agentico è stateless di default. Apri un thread con
123
+ `create_conversation()` e passa il suo id a `agentic_answer()`: i follow-up
124
+ si risolvono contro la storia del thread («e il secondo?» funziona) e ogni
125
+ scambio viene persistito per il turno successivo. Scope `query:read`.
126
+
127
+ ```python
128
+ conv = ando.create_conversation(title="Analisi esposizione")
129
+ ando.agentic_answer("Chi sono i primi 3 clienti per esposizione?",
130
+ conversation_id=conv["conversation_id"])
131
+ ando.agentic_answer("E il secondo?", # follow-up risolto
132
+ conversation_id=conv["conversation_id"])
133
+
134
+ ando.list_conversations() # thread della chiave, cursor-paginati
135
+ ando.get_conversation_turns(conv["conversation_id"]) # scambi, dal più vecchio
136
+ ando.delete_conversation(conv["conversation_id"]) # irreversibile
137
+ ```
138
+
139
+ ### Restringere il campo: `filters`, `sources`, `partition`
140
+
141
+ Valgono su `query()` e `answer()`. Ogni filtro può solo **restringere**: la
142
+ resource policy della chiave è sempre intersecata sopra e vince.
143
+
144
+ ```python
145
+ # scorciatoie da keyword (le due dimensioni più usate)
146
+ ando.answer("fatture scadute", sources=["fs"], partition="cliente-42")
147
+
148
+ # oggetto completo, con condizioni sui metadata di ingest
149
+ ando.query(
150
+ "resi 2025",
151
+ filters={
152
+ "sources": ["fs", "drive"],
153
+ "partition": "cliente-42",
154
+ "metadata": {
155
+ "op": "and",
156
+ "conditions": [
157
+ {"key": "doc_type", "op": "eq", "value": "invoice"},
158
+ {"key": "year", "op": "gte", "value": 2025},
159
+ ],
160
+ },
161
+ },
162
+ )
163
+ ```
164
+
165
+ `partition` e `metadata` sono esattamente quelli impostati all'ingest — il
166
+ cerchio si chiude sullo stesso client:
167
+
168
+ ```python
169
+ ando.upload_document(
170
+ "fattura-2025-11.pdf",
171
+ metadata={"doc_type": "invoice", "year": 2025}, # oggetto PIATTO, max 32 chiavi
172
+ partition="cliente-42", # A-Za-z0-9._:- , max 128 char
173
+ )
174
+ ```
175
+
176
+ Passare la stessa dimensione due volte (keyword **e** dentro `filters`) è
177
+ un `ValueError` lato client, prima di qualunque HTTP: sceglierne una in
178
+ silenzio cambierebbe quali documenti vengono cercati. I nomi di source
179
+ sconosciuti **non** vengono rifiutati dal SDK: i valori enum sono additivi
180
+ per contratto, decide il server.
181
+
182
+ ## Generazione documenti — `generate()`
183
+
184
+ Dai tuoi dati a un file `docx`/`pptx`/`xlsx`/`pdf`/`md`. Il contesto arriva da
185
+ una `query` (retrieval TRAMA, resource policy rispettata) e/o da
186
+ `document_ids` espliciti; almeno uno dei due è obbligatorio (altrimenti
187
+ `ValueError` prima di qualunque HTTP). È **sempre asincrono**: `202` con un
188
+ `generation_id` da pollare, poi si scarica il file. Il contenuto rispecchia la
189
+ lingua di `instructions`/`query`. Scope unico per tutto: `generate:write`.
190
+
191
+ ```python
192
+ accepted = ando.generate(
193
+ "docx",
194
+ "Prepara un report sul fatturato 2025 con sintesi e dettaglio.",
195
+ query="fatturato 2025",
196
+ )
197
+ gen = ando.wait_for_generation(accepted["generation_id"]) # -> completed | GenerationFailed
198
+ artifact = ando.download_generation(gen["generation_id"]) # DownloadedFile
199
+ artifact.save("report.docx") # bytes su disco
200
+ ```
201
+
202
+ `wait_for_generation` condivide identica policy di retry/backoff di
203
+ `wait_for_job` (transient `rate_limited`/5xx/rete ritentati entro la deadline);
204
+ solleva `GenerationFailed` (con `error_code`) su `failed`, `GenerationTimeout`
205
+ allo scadere.
206
+
207
+ ## CAD — analisi, DFM, conversione
208
+
209
+ Upload STEP/DXF asincroni (`202` con `job_id` + `result_id`). Analisi e DFM
210
+ tornano un **report JSON**, la conversione un **file STEP**. Scope `cad:read`
211
+ per analyze/dfm-check/similar/jobs/results, `cad:write` per convert. Un job CAD
212
+ **è un job**: `wait_for_cad_job` riusa `JobFailed`/`JobTimeout`.
213
+
214
+ ```python
215
+ job = ando.cad_analyze("bracket.step") # {result_id, job_id, kind: "analysis"}
216
+ ando.wait_for_cad_job(job["job_id"])
217
+ report = ando.get_cad_result(job["result_id"]).json() # report JSON
218
+
219
+ conv = ando.cad_convert("profilo.dxf", "extrude", params={"height_mm": 10})
220
+ ando.wait_for_cad_job(conv["job_id"])
221
+ ando.get_cad_result(conv["result_id"]).save("solido.step") # STEP su disco
222
+ ```
223
+
224
+ `cad_find_similar()` è la **ricerca per somiglianza query-by-upload** (live):
225
+ carichi uno STEP/IGES/STL, un disegno PDF o un'immagine; viene embeddato al
226
+ volo e confrontato con il corpus CAD ingerito del project. Il file di query
227
+ non viene mai salvato. La chiamata è **sincrona** — uno STEP grande può
228
+ richiedere ~10-30s di tessellazione, dimensiona il timeout di conseguenza.
229
+
230
+ ```python
231
+ simili = ando.cad_find_similar("staffa.step", top_k=5)
232
+ for hit in simili["results"]:
233
+ print(hit["filename"], hit["similarity"], hit["description"])
234
+ ```
235
+
236
+ ## Azioni — il write-loop
237
+
238
+ La metà **write** simmetrica della Push API: chiedi all'app del project di
239
+ *fare* qualcosa (aprire un ticket, spostare un ordine) e ne verifichi
240
+ l'esito. `declare` (una tantum, `sources:manage`) → `propose` → `execute`
241
+ (`actions:write`) → l'app fa `complete`. Le letture usano `actions:read`.
242
+
243
+ ```python
244
+ actions = ando.actions()
245
+
246
+ # dichiarazione una tantum (chiave sources:manage)
247
+ actions.declare(
248
+ "acme-desk",
249
+ "create_ticket",
250
+ params_schema={
251
+ "type": "object",
252
+ "properties": {"subject": {"type": "string"}},
253
+ "required": ["subject"],
254
+ },
255
+ risk="medium",
256
+ )
257
+
258
+ # per richiesta (chiave actions:write) — propose + execute in una chiamata
259
+ dispatched = actions.run(
260
+ "acme-desk",
261
+ "create_ticket",
262
+ {"subject": "Refund request"},
263
+ idempotency_key="refund-4711", # un run ritentato replay-a la proposta
264
+ )
265
+ print(dispatched["id"], dispatched["status"]) # ... "dispatched"
266
+ ```
267
+
268
+ Le azioni `high`/`critical` richiedono `confirm_risk=True` su
269
+ `run`/`execute`. `propose_action` accetta un `idempotency_key` opzionale
270
+ (header `Idempotency-Key`): una propose ritentata con la stessa chiave
271
+ replay-a il primo `201` invece di duplicare. Ogni metodo esiste anche come
272
+ funzione di modulo (`propose_action(client, ...)`) e come delega sul client
273
+ (`ando.propose_action(...)`).
274
+
275
+ ### Capability con binding HTTP — dichiara, poi `invoke`
276
+
277
+ Dichiara la capability **con un `binding` HTTP** e Ando può chiamare
278
+ direttamente il tuo sistema: `invoke_connector_action` è il fratello
279
+ sincrono di propose + execute — un round trip, esito nella risposta.
280
+
281
+ ```python
282
+ # dichiarazione CON binding (chiave sources:manage)
283
+ actions.declare(
284
+ "acme-desk",
285
+ "create_ticket",
286
+ params_schema={
287
+ "type": "object",
288
+ "properties": {"subject": {"type": "string"}},
289
+ "required": ["subject"],
290
+ },
291
+ risk="medium",
292
+ binding={
293
+ "method": "POST",
294
+ "path": "/tickets", # relativo, sul TUO sistema
295
+ "body_template": {"title": "{{subject}}"}, # placeholder {{param}}
296
+ "response": {"id_field": "id"}, # dove vive l'id creato
297
+ "timeout_seconds": 15,
298
+ },
299
+ )
300
+
301
+ # invoke sincrono (chiave actions:write)
302
+ esito = ando.invoke_connector_action(
303
+ "acme-desk", "create_ticket", {"subject": "Refund request"}
304
+ )
305
+ print(esito["ok"], esito["status_code"], esito["response"])
306
+ ```
307
+
308
+ I `params` sono validati contro il `params_schema` dichiarato e vale lo
309
+ **stesso risk gate di execute**: una capability `high`/`critical` viene
310
+ rifiutata (`invalid_request`) senza `confirm_risk=True`. Le chiavi di test
311
+ validano e gate-ano per davvero ma **non** chiamano mai l'endpoint bound —
312
+ la risposta porta `test: true`. La dichiarazione è una sostituzione
313
+ completa: ri-dichiarare senza `binding` cancella quello memorizzato.
314
+
315
+ ## Flows — orchestrazione come API
316
+
317
+ Costruisci, esegui e supervisiona gli stessi flow del builder prodotto,
318
+ white-label. Scope: `flows:read` (list/palette/stats/inbox/run detail) e
319
+ `flows:manage` (create/update/delete/run/approve/reject).
320
+
321
+ ```python
322
+ palette = ando.flows_palette() # ogni node type + campi di config
323
+ flows = ando.list_flows(page=1, page_size=20)
324
+ draft = ando.generate_flow(
325
+ "Quando arriva una fattura, valida i dati e chiedi approvazione"
326
+ ) # copilot: NL -> draft flow (costo LLM, metered)
327
+
328
+ created = ando.create_flow(draft["flow"])
329
+ ando.run_flow(created["flow_id"], dry_run=True)
330
+ ando.flow_stats(created["flow_id"])
331
+ ando.update_flow(created["flow_id"], {"is_active": False})
332
+
333
+ # run manuale seminato con un PDF caricato: il suo content_item_id finisce
334
+ # nel trigger_data del run per i nodi che bindano
335
+ # {"$ref": "trigger.content_item_id"} (es. ai.cad_analyze)
336
+ run = ando.run_flow_with_upload(created["flow_id"], "fattura.pdf")
337
+
338
+ # polling di un run: stato, trigger, context accumulato, output per nodo
339
+ detail = ando.get_flow_run(run["run_id"])
340
+ print(detail["status"], detail["node_runs"])
341
+
342
+ # HITL: gli step `logic.approval` parcheggiano il run in `pending_approval`
343
+ inbox = ando.flows_pending_approvals()
344
+ ando.approve_flow_run("run_...", edited_value="testo corretto")
345
+ ando.approve_flow_run(
346
+ "run_...",
347
+ # correzione strutturata per-campo dello STESSO output editabile — se
348
+ # arrivano entrambi, edited_fields vince sulle chiavi sovrapposte
349
+ edited_fields={"to": "acme@example.com", "subject": "Ordine 42"},
350
+ )
351
+ ando.reject_flow_run("run_...", reason="importo errato")
352
+ ```
353
+
354
+ `run_flow_with_upload` accetta solo PDF (`invalid_request` su tutto il
355
+ resto). `flow_templates()` elenca la galleria di template;
356
+ `delete_flow(flow_id)` rimuove un flow.
357
+
358
+ ## Usage — consumi e costi del project
359
+
360
+ `usage()` (`usage:read`) ritorna chiamate/token/costo aggregati del project —
361
+ mese corrente di default, `group_by="day"` per la serie di fatturazione o
362
+ `"purpose"` per capire cosa genera costo:
363
+
364
+ ```python
365
+ report = ando.usage(from_="2026-08-01", to="2026-08-07")
366
+ print(report["total_cost_usd"], report["usage"])
367
+ ```
368
+
369
+ ## Sources — cosa può interrogare questa chiave
370
+
371
+ Prima di scopare una `/query` (come fa la UI prodotto lasciando scegliere un
372
+ connettore) bisogna **scoprire** cosa la chiave può interrogare. `list_sources()`
373
+ lo dice, già intersecato con la resource policy della chiave.
374
+
375
+ ```python
376
+ for s in ando.list_sources()["sources"]:
377
+ print(s["id"], s["kind"], s["queryable"], s.get("reason"))
378
+ # id unstrutturati (fs/drive/…) -> filters.sources di /query
379
+ # id strutturati (conn_<uuid>) -> sources di /agentic/answer
380
+ ```
381
+
382
+ Registra un database read-only del cliente (es. il back office SQL di un
383
+ gestionale) come source strutturata — le credenziali sono cifrate at rest e
384
+ **mai** ritornate; la risposta è white-label (capability, mai il vendor):
385
+
386
+ ```python
387
+ source = ando.register_database_source(
388
+ name="Gestionale produzione",
389
+ connector_type="mysql", # postgresql | mysql | sqlserver | oracle | ...
390
+ credentials={"host": "db.example.com", "user": "ando_ro",
391
+ "password": "…", "database": "mexal"},
392
+ config={"schema_filter": ["mexal"], "max_rows_per_query": 500},
393
+ )
394
+ ando.test_source(source["source_id"]) # {ok, error?} — probe, non un ApiError
395
+ ```
396
+
397
+ Per una source **strutturata** lo schema profilato si cura e poi si
398
+ **pubblica** (il gate di queryabilità): finché non è pubblicato,
399
+ `queryable=false, reason="schema non pubblicato"`. Curare = `sources:manage`,
400
+ leggere lo schema = `query:read`.
401
+
402
+ ```python
403
+ ando.get_source_schema("conn_…") # stato + objects (K-Schema)
404
+ ando.curate_source_schema( # override della proposta AI
405
+ "conn_…", "public.orders",
406
+ description="Ordini di vendita",
407
+ column_descriptions={"total": "Importo totale, IVA inclusa"},
408
+ )
409
+ ando.publish_source_schema("conn_…") # apre /query + /agentic
410
+ ```
411
+
412
+ ## Webhooks
413
+
414
+ Endpoint HTTPS in uscita, firmati Standard Webhooks. Scope `keys:manage`. Il
415
+ `secret` (`whsec_…`) è mostrato **una sola volta** alla creazione.
416
+
417
+ ```python
418
+ hook = ando.create_webhook("https://example.com/hooks", ["ingestion.completed"])
419
+ print(hook["secret"]) # salvalo ora — non torna più
420
+ ando.list_webhooks() # mai il secret; cursor/limit opzionali
421
+ ando.test_webhook(hook["webhook_id"]) # test.ping sincrono
422
+
423
+ # storico deliveries + replay manuale (firma fresca, tentativo immediato)
424
+ deliveries = ando.list_webhook_deliveries(hook["webhook_id"], limit=100)
425
+ ando.replay_webhook_delivery(deliveries["deliveries"][0]["delivery_id"])
426
+
427
+ ando.delete_webhook(hook["webhook_id"])
428
+ ```
429
+
430
+ ## Riferimento
431
+
432
+ Costruttore:
433
+
434
+ ```python
435
+ AndoPlatform(
436
+ api_key, # ando_sk_live_... / ando_sk_test_...
437
+ base_url="https://api.ando-ai.com/platform/v1",
438
+ timeout=30, # secondi, per richiesta
439
+ )
440
+ ```
441
+
442
+ | Metodo | Endpoint | Scope | Note |
443
+ |---|---|---|---|
444
+ | `health()` | `GET /health` | — | liveness pubblica |
445
+ | `me()` | `GET /me` | chiave valida | identità del project |
446
+ | `upload_document(path_or_bytes, filename=None, content_type=None, metadata=None, partition=None)` | `POST /documents` | `ingest:write` | multipart; accetta un path (filename dedotto) o `bytes` (filename obbligatorio); `metadata`/`partition` opzionali diventano parti multipart solo se valorizzati; ritorna `{document_id, job_id, status}` (202) |
447
+ | `get_document(id)` | `GET /documents/{id}` | `query:read` | stato + metadati |
448
+ | `delete_document(id)` | `DELETE /documents/{id}` | `ingest:write` | rimozione indice + storage |
449
+ | `get_job(id)` | `GET /jobs/{id}` | `query:read` | `queued → processing → completed \| failed` |
450
+ | `wait_for_job(id, timeout=120, poll=2.0)` | polling su `GET /jobs/{id}` | `query:read` | ritorna il job a `completed`; solleva `JobFailed` (con `error_code`) su `failed`, `JobTimeout` allo scadere |
451
+ | `query(text, mode="retrieve", k=10, filters=None, sources=None, partition=None, include_web=False, verify=False)` | `POST /query` | `query:read` | `k` max 50 da contratto; i campi opzionali finiscono nel body solo se valorizzati |
452
+ | `answer(text, k=10, ...)` | `POST /query` | `query:read` | scorciatoia per `mode="answer"`, stessi parametri di narrowing |
453
+ | `agentic_answer(query, k=10, sources=None, max_steps=None, with_diagnostics=False, include_rows=False, conversation_id=None, timeout=120)` | `POST /agentic/answer` | `query:read` | **PREVIEW** — loop agentico read-only; ritorna `{answer, citations, sources_used, steps, diagnostics}` |
454
+ | `create_conversation(title=None)` | `POST /conversations` | `query:read` | apre un thread di memoria per i turni agentici |
455
+ | `list_conversations(cursor=None, limit=None)` | `GET /conversations` | `query:read` | thread della chiave, cursor-paginati |
456
+ | `get_conversation_turns(id, cursor=None, limit=None)` | `GET /conversations/{id}` | `query:read` | scambi del thread, dal più vecchio |
457
+ | `delete_conversation(id)` | `DELETE /conversations/{id}` | `query:read` | irreversibile |
458
+ | `connectors()` | `GET /connectors` | `sources:manage` | elenco connector del project |
459
+ | `connector(slug, **options)` | — | — | costruisce un `AndoConnector` legato a questo client (Push API) |
460
+ | `connector_kinds()` | `GET /connectors/kinds` | chiave valida | kind disponibili + contratto Record |
461
+ | `validate_records(records, connector=None)` | `POST /connectors/validate` | `ingest:write` | dry-run dei record, nessuna scrittura |
462
+ | `update_connector(slug, name=None, config=None, enabled=None)` | `PATCH /connectors/{slug}` | `sources:manage` | rename/enable/config (sostituita intera); rotazione secret per i manifest connector |
463
+ | `actions()` | — | — | costruisce un `AndoActions` legato a questo client (`run(...)` = propose + execute) |
464
+ | `declare_connector_action(connector, name, description=None, params_schema=None, risk=None, binding=None)` | `POST /connectors/{slug}/actions` | `sources:manage` | idempotente su `name`; `binding` = binding HTTP opzionale (abilita l'invoke); ri-dichiarare senza `binding` lo cancella |
465
+ | `connector_actions(connector)` | `GET /connectors/{slug}/actions` | `actions:read` | azioni dichiarate del connector |
466
+ | `delete_connector_action(connector, name)` | `DELETE /connectors/{slug}/actions/{name}` | `sources:manage` | rimuove una dichiarazione |
467
+ | `invoke_connector_action(connector, name, params=None, *, confirm_risk=False)` | `POST /connectors/{slug}/actions/{name}/invoke` | `actions:write` | invoke sincrono della capability bound: `{ok, status_code, response}`; risk gate di execute (`high`/`critical` rifiutati senza `confirm_risk=True`); chiave test = `test: true`, endpoint mai chiamato |
468
+ | `propose_action(connector, action, params=None, idempotency_key=None)` | `POST /actions` | `actions:write` | valida + preview, nessun side effect; `Idempotency-Key` opzionale |
469
+ | `execute_action(action_id, confirm_risk=False)` | `POST /actions/{id}/execute` | `actions:write` | dispatch del webhook `action.requested`; `high`/`critical` richiedono `confirm_risk` |
470
+ | `complete_action(action_id, status, result=None, error=None)` | `POST /actions/{id}/complete` | `actions:write` | l'app riporta l'esito (`completed`/`failed`) |
471
+ | `get_action(action_id)` | `GET /actions/{id}` | `actions:read` | polling di una richiesta |
472
+ | `list_actions(connector=None, limit=None, cursor=None)` | `GET /actions` | `actions:read` | richieste del project, cursor-paginate |
473
+ | `generate(format, instructions, query=None, document_ids=None, k=10)` | `POST /generate` | `generate:write` | job async; serve almeno uno tra `query`/`document_ids`; ritorna `{generation_id, document_id, status}` (202) |
474
+ | `get_generation(id)` | `GET /generate/{id}` | `generate:write` | stato; `download_url` a `completed` |
475
+ | `wait_for_generation(id, timeout=180, poll=2.0)` | polling su `GET /generate/{id}` | `generate:write` | ritorna il payload a `completed`; `GenerationFailed`/`GenerationTimeout` |
476
+ | `download_generation(id)` | `GET /generate/{id}/download` | `generate:write` | `DownloadedFile` (bytes + content type + filename) |
477
+ | `cad_analyze(path_or_bytes, ...)` | `POST /cad/analyze` | `cad:read` | STEP/DXF; job async `kind="analysis"` |
478
+ | `cad_dfm_check(path_or_bytes, ruleset_name=None, ...)` | `POST /cad/dfm-check` | `cad:read` | solo STEP; `ruleset_name` inviato solo se valorizzato |
479
+ | `cad_convert(path_or_bytes, operation, params=None, target="step", ...)` | `POST /cad/convert` | `cad:write` | DXF→STEP; `operation` `extrude`/`revolve`; params validati server-side |
480
+ | `cad_find_similar(path_or_bytes, filename=None, content_type=None, top_k=8)` | `POST /cad/similar` | `cad:read` | ricerca per somiglianza query-by-upload; sincrona, il file di query non viene salvato |
481
+ | `get_cad_job(id)` | `GET /cad/jobs/{id}` | `cad:read` | stato job CAD |
482
+ | `wait_for_cad_job(id, timeout=180, poll=2.0)` | polling su `GET /cad/jobs/{id}` | `cad:read` | `JobFailed`/`JobTimeout` (un job CAD è un job) |
483
+ | `get_cad_result(id)` | `GET /cad/results/{id}` | `cad:read` | `DownloadedFile`; `.json()` per analisi/DFM, `.save()` per STEP |
484
+ | `list_sources()` | `GET /sources` | `query:read` | cosa la chiave può interrogare (già intersecato con la policy) |
485
+ | `register_database_source(name=..., connector_type=..., credentials=..., config=None)` | `POST /sources/database` | `sources:manage` | registra un DB read-only del cliente; credenziali cifrate, mai ritornate; risposta white-label |
486
+ | `test_source(source_id)` | `POST /sources/{id}/test` | `sources:manage` | probe di connessione; un probe fallito è `{ok: false}` nel body 200, non un ApiError |
487
+ | `get_source_schema(source_id)` | `GET /sources/{id}/schema` | `query:read` | K-Schema profilato di una source strutturata |
488
+ | `curate_source_schema(source_id, object_name, description=None, column_descriptions=None, semantic_mappings=None)` | `PATCH /sources/{id}/schema/{object}` | `sources:manage` | override curazione AI; almeno un campo (altrimenti `ValueError`) |
489
+ | `publish_source_schema(source_id, published=True)` | `POST /sources/{id}/schema/publish` | `sources:manage` | gate di queryabilità; `published=False` per revertire |
490
+ | `create_webhook(url, events)` | `POST /webhooks` | `keys:manage` | il `secret` appare SOLO qui (201) |
491
+ | `list_webhooks(cursor=None, limit=None)` | `GET /webhooks` | `keys:manage` | mai il secret; cursor-paginato con `cursor`/`limit` |
492
+ | `delete_webhook(id)` | `DELETE /webhooks/{id}` | `keys:manage` | rimozione; deliveries pending scartate |
493
+ | `test_webhook(id)` | `POST /webhooks/{id}/test` | `keys:manage` | `test.ping` sincrono; esito nel body 200 |
494
+ | `list_webhook_deliveries(webhook_id, limit=50)` | `GET /webhooks/{id}/deliveries` | `keys:manage` | storico deliveries, dal più recente |
495
+ | `replay_webhook_delivery(delivery_id)` | `POST /webhooks/deliveries/{id}/replay` | `keys:manage` | re-invio immediato con firma fresca; anche su righe `delivered` |
496
+ | `flows_palette()` | `GET /flows/palette` | `flows:read` | node type costruibili + campi di config |
497
+ | `flow_templates()` | `GET /flows/templates` | `flows:read` | galleria di template |
498
+ | `list_flows(page=1, page_size=20, status=None)` | `GET /flows` | `flows:read` | flow del project, paginati |
499
+ | `create_flow(flow)` | `POST /flows` | `flows:manage` | stesso contratto `FlowCreate` del builder prodotto (o `template_id`) |
500
+ | `get_flow(flow_id)` | `GET /flows/{id}` | `flows:read` | definizione completa |
501
+ | `flow_stats(flow_id)` | `GET /flows/{id}/stats` | `flows:read` | contatori/esiti dei run |
502
+ | `update_flow(flow_id, patch)` | `PATCH /flows/{id}` | `flows:manage` | update parziale, incl. `is_active` |
503
+ | `delete_flow(flow_id)` | `DELETE /flows/{id}` | `flows:manage` | rimozione |
504
+ | `run_flow(flow_id, dry_run=False, trigger_data=None)` | `POST /flows/{id}/run` | `flows:manage` | run manuale; gli step approval parcheggiano il run |
505
+ | `run_flow_with_upload(flow_id, path_or_bytes, *, filename=None, content_type=None, dry_run=False)` | `POST /flows/{id}/run-upload` | `flows:manage` | run manuale seminato da un PDF caricato (`content_item_id` nel `trigger_data`); solo PDF |
506
+ | `get_flow_run(run_id)` | `GET /flows/runs/{id}` | `flows:read` | dettaglio completo di un run: stato, trigger, `context`, `node_runs`, campi HITL |
507
+ | `generate_flow(prompt)` | `POST /flows/generate` | `flows:manage` | copilot NL → draft flow (costo LLM, metered) |
508
+ | `flows_pending_approvals()` | `GET /flows/pending-approvals` | `flows:read` | inbox HITL dei run in `pending_approval` |
509
+ | `approve_flow_run(run_id, *, edited_value=None, edited_fields=None)` | `POST /flows/runs/{id}/approve` | `flows:manage` | riprende un run in pausa; `edited_value` = una stringa, `edited_fields` = correzione strutturata per-campo (vince sulle chiavi sovrapposte) |
510
+ | `reject_flow_run(run_id, *, reason=None)` | `POST /flows/runs/{id}/reject` | `flows:manage` | "no" terminale: il run non riprende mai |
511
+ | `usage(from_=None, to=None, group_by="day")` | `GET /usage` | `usage:read` | chiamate/token/costo aggregati; `group_by` `day`/`purpose` |
512
+ | `create_key(name, scopes, live=True, expires_at=None)` | `POST /keys` | `keys:manage` | la chiave completa appare SOLO in questa risposta |
513
+ | `list_keys()` | `GET /keys` | `keys:manage` | mai hash o chiavi complete |
514
+ | `revoke_key(id)` | `POST /keys/{id}/revoke` | `keys:manage` | revoca immediata |
515
+
516
+ Tutti i metodi ritornano il body JSON della risposta come `dict`, tranne
517
+ `download_generation` / `get_cad_result` che ritornano un `DownloadedFile`
518
+ (`content` bytes, `content_type`, `filename`, più `.json()` e `.save(path)`).
519
+
520
+ Costanti esportate: `DEFAULT_K` (10), `MAX_K` (50), `FILTER_SOURCES`,
521
+ `GENERATE_FORMATS`, `WEBHOOK_EVENTS`, `ACTION_RISK_LEVELS` (tutte
522
+ informative: i valori enum sono additivi, il SDK non li valida),
523
+ `AGENTIC_TIMEOUT` (120s), `DEFAULT_BATCH_SIZE` (200), `MAX_BATCH_SIZE`
524
+ (500), `DEFAULT_BASE_URL`. Tipi esportati: `ActionBinding` (il binding HTTP
525
+ di una capability — un normale `dict` con quelle chiavi va bene ovunque).
526
+
527
+ ## CLI (`ando`)
528
+
529
+ Il pacchetto espone lo script `ando` (o `python -m ando_ai`):
530
+
531
+ ```bash
532
+ ando kinds # kind disponibili + contratto Record
533
+ ando validate records.jsonl # dry-run dei record (nessuna scrittura)
534
+ cat records.jsonl | ando validate - # …da stdin (JSON array o JSONL)
535
+ ando sync acme-crm records.jsonl --mode full # push di un batch
536
+ ando sources # cosa può interrogare questa chiave
537
+ ando me # identità della chiave
538
+ ando mcp --client cursor # config MCP per il tuo agente
539
+ ```
540
+
541
+ Auth: `--api-key` o `$ANDO_API_KEY`; base URL: `--base-url` o `$ANDO_BASE_URL`.
542
+ `ando validate` consuma `POST /connectors/validate` e **esce con codice 1** se un
543
+ record è rifiutato — la CI di un connettore può fare da gate sul contratto.
544
+ `ando mcp` stampa l'install one-command del server MCP hosted (Claude Code /
545
+ Cursor / Claude Desktop); senza `--client` le stampa tutte.
546
+
547
+ ## Errori
548
+
549
+ Gerarchia (base comune `AndoPlatformError`):
550
+
551
+ - **`ApiError`** — errore dell'envelope `{"error": {"code", "message"}}`.
552
+ Attributi: `code` (stabile: `invalid_api_key`, `expired_api_key`,
553
+ `revoked_api_key`, `insufficient_scope`, `rate_limited`,
554
+ `invalid_request`, `not_found`, `internal_error`), `message`, `status`
555
+ e `retry_after` (secondi dall'header `Retry-After`, valorizzato sui 429).
556
+ - **`NetworkError`** — problema di trasporto (DNS, connessione, TLS,
557
+ timeout); l'eccezione `httpx` originale è in `__cause__`. Su
558
+ `agentic_answer` un read timeout arriva qui: di solito significa
559
+ «alza `timeout=`», non «il server è rotto».
560
+ - **`JobFailed`** — job di ingestion (o CAD) terminato `failed`; porta
561
+ `error_code`, `error_message` e il payload completo in `job`.
562
+ - **`JobTimeout`** — `wait_for_job` / `wait_for_cad_job` ha esaurito il
563
+ `timeout` senza uno stato terminale.
564
+ - **`GenerationFailed`** — generazione terminata `failed`; porta `error_code`,
565
+ `error_message` e il payload in `generation`.
566
+ - **`GenerationTimeout`** — `wait_for_generation` ha esaurito il `timeout`.
567
+
568
+ ```python
569
+ from ando_ai import AndoPlatform, ApiError
570
+
571
+ with AndoPlatform("ando_sk_live_...") as ando:
572
+ try:
573
+ ando.query("scadenze")
574
+ except ApiError as exc:
575
+ if exc.code == "rate_limited":
576
+ print(f"Riprova tra {exc.retry_after}s")
577
+ elif exc.code == "insufficient_scope":
578
+ print(f"Scope mancante: {exc.message}")
579
+ else:
580
+ raise
581
+ ```
582
+
583
+ ## Test
584
+
585
+ I test del SDK girano senza rete né backend (`httpx.MockTransport`):
586
+
587
+ ```bash
588
+ uv run pytest tests/unit/test_platform_sdk_client.py
589
+ ```
590
+
591
+ ## License
592
+
593
+ Proprietary — Copyright (c) 2026 Ando S.r.l. This SDK is licensed for use
594
+ solely with the Ando Platform services; it is **not** open source. See the
595
+ `LICENSE` file shipped in this package for the full terms.