kaneme 1.16.1__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.
kaneme-1.16.1/PKG-INFO ADDED
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: kaneme
3
+ Version: 1.16.1
4
+ Summary: SDK Python officiel pour l'API d'identité écrite Kaneme
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://kaneme.com
7
+ Project-URL: Documentation, https://kaneme.com/developers
8
+ Keywords: kaneme,writing,voice,authorship,api
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # kaneme
20
+
21
+ ```python
22
+ from kaneme import KanemeClient
23
+
24
+ kaneme = KanemeClient(api_key="kaneme_…")
25
+ verdict = kaneme.verify_authorship(text="…", question="q1")
26
+ ```
27
+
28
+ Sur Q2, la réponse contient aussi `explanation` avec les écarts observés de
29
+ `cadence`, `registre`, `structure` et `lexique`. Ils éclairent le verdict sans
30
+ prétendre décomposer la probabilité produite par la couche profonde :
31
+
32
+ ```python
33
+ q2 = kaneme.verify_authorship(text="…", question="q2", profile_id=profile_id)
34
+ for factor in q2.get("explanation", {}).get("factors", []):
35
+ print(factor["label"], factor["status"], factor["detail"])
36
+ ```
37
+
38
+ Le SDK n’ajoute aucune dépendance. Les méthodes d’écriture acceptent
39
+ `idempotency_key`. Les erreurs lèvent `KanemeAPIError`.
40
+
41
+ ## Sécurité du transport
42
+
43
+ Depuis la version 1.16.1, le client impose HTTPS hors développement local,
44
+ refuse toute redirection vers une autre origine et coupe les réponses trop
45
+ volumineuses. Ces protections empêchent qu’une redirection malveillante reçoive
46
+ la clé API et qu’une réponse incontrôlée épuise la mémoire du processus :
47
+
48
+ ```python
49
+ kaneme = KanemeClient(
50
+ api_key="kaneme_…",
51
+ timeout=15,
52
+ max_response_bytes=4 * 1024 * 1024,
53
+ )
54
+ ```
55
+
56
+ La limite par défaut est de 8 Mio (64 Mio au maximum). Une redirection refusée
57
+ lève `KanemeRedirectError` ; un dépassement lève `KanemeResponseTooLarge`.
58
+
59
+ ## Cadence et identifiant de requête
60
+
61
+ Chaque réponse — succès compris — porte la cadence appliquée. `on_response` la
62
+ donne, sur les appels qui marchent comme sur les refus :
63
+
64
+ ```python
65
+ def tracer(meta):
66
+ if meta.rate_limit.remaining is not None and meta.rate_limit.remaining < 5:
67
+ time.sleep(1) # ralentir AVANT le refus
68
+ print(meta.request_id) # à journaliser : c'est ce qu'on te demandera
69
+
70
+ kaneme = KanemeClient(api_key="kaneme_…", on_response=tracer)
71
+ ```
72
+
73
+ `Retry-After` ne part que sur un 429, et se lit alors sur l’erreur :
74
+ `error.metadata.retry_after`. Sur un succès il vaut `None` — un ordre d’attendre
75
+ n’a pas de sens sur un appel qui a réussi.
76
+
77
+ ## Certificats et cycle de vie
78
+
79
+ Une clé API dotée explicitement de `voice:certify` peut préparer puis publier
80
+ un certificat. Les liens de page, JSON et badge sont utilisables sans ouvrir le
81
+ compte Kaneme :
82
+
83
+ ```python
84
+ preparation = kaneme.prepare_certificate(document_id, display_name="Alice")
85
+ # En OAuth, ouvrir preparation["approvalUrl"] et attendre la validation humaine.
86
+ certificate = kaneme.publish_certificate(
87
+ preparation["approvalId"],
88
+ idempotency_key=f"certificate-{preparation['approvalId']}",
89
+ )
90
+ print(certificate["certificateUrl"], certificate["verificationUrl"], certificate["badgeSvgUrl"])
91
+ ```
92
+
93
+ `list_trash`, `list_project_contexts` et `manage_lifecycle` couvrent la
94
+ corbeille, la restauration, l’archivage, l’expiration et la révocation. Il n’y a
95
+ aucune purge définitive dans le SDK ; utilisez `dry_run=True` avant une mutation
96
+ sensible et limitez chaque lot à 10 éléments.
@@ -0,0 +1,78 @@
1
+ # kaneme
2
+
3
+ ```python
4
+ from kaneme import KanemeClient
5
+
6
+ kaneme = KanemeClient(api_key="kaneme_…")
7
+ verdict = kaneme.verify_authorship(text="…", question="q1")
8
+ ```
9
+
10
+ Sur Q2, la réponse contient aussi `explanation` avec les écarts observés de
11
+ `cadence`, `registre`, `structure` et `lexique`. Ils éclairent le verdict sans
12
+ prétendre décomposer la probabilité produite par la couche profonde :
13
+
14
+ ```python
15
+ q2 = kaneme.verify_authorship(text="…", question="q2", profile_id=profile_id)
16
+ for factor in q2.get("explanation", {}).get("factors", []):
17
+ print(factor["label"], factor["status"], factor["detail"])
18
+ ```
19
+
20
+ Le SDK n’ajoute aucune dépendance. Les méthodes d’écriture acceptent
21
+ `idempotency_key`. Les erreurs lèvent `KanemeAPIError`.
22
+
23
+ ## Sécurité du transport
24
+
25
+ Depuis la version 1.16.1, le client impose HTTPS hors développement local,
26
+ refuse toute redirection vers une autre origine et coupe les réponses trop
27
+ volumineuses. Ces protections empêchent qu’une redirection malveillante reçoive
28
+ la clé API et qu’une réponse incontrôlée épuise la mémoire du processus :
29
+
30
+ ```python
31
+ kaneme = KanemeClient(
32
+ api_key="kaneme_…",
33
+ timeout=15,
34
+ max_response_bytes=4 * 1024 * 1024,
35
+ )
36
+ ```
37
+
38
+ La limite par défaut est de 8 Mio (64 Mio au maximum). Une redirection refusée
39
+ lève `KanemeRedirectError` ; un dépassement lève `KanemeResponseTooLarge`.
40
+
41
+ ## Cadence et identifiant de requête
42
+
43
+ Chaque réponse — succès compris — porte la cadence appliquée. `on_response` la
44
+ donne, sur les appels qui marchent comme sur les refus :
45
+
46
+ ```python
47
+ def tracer(meta):
48
+ if meta.rate_limit.remaining is not None and meta.rate_limit.remaining < 5:
49
+ time.sleep(1) # ralentir AVANT le refus
50
+ print(meta.request_id) # à journaliser : c'est ce qu'on te demandera
51
+
52
+ kaneme = KanemeClient(api_key="kaneme_…", on_response=tracer)
53
+ ```
54
+
55
+ `Retry-After` ne part que sur un 429, et se lit alors sur l’erreur :
56
+ `error.metadata.retry_after`. Sur un succès il vaut `None` — un ordre d’attendre
57
+ n’a pas de sens sur un appel qui a réussi.
58
+
59
+ ## Certificats et cycle de vie
60
+
61
+ Une clé API dotée explicitement de `voice:certify` peut préparer puis publier
62
+ un certificat. Les liens de page, JSON et badge sont utilisables sans ouvrir le
63
+ compte Kaneme :
64
+
65
+ ```python
66
+ preparation = kaneme.prepare_certificate(document_id, display_name="Alice")
67
+ # En OAuth, ouvrir preparation["approvalUrl"] et attendre la validation humaine.
68
+ certificate = kaneme.publish_certificate(
69
+ preparation["approvalId"],
70
+ idempotency_key=f"certificate-{preparation['approvalId']}",
71
+ )
72
+ print(certificate["certificateUrl"], certificate["verificationUrl"], certificate["badgeSvgUrl"])
73
+ ```
74
+
75
+ `list_trash`, `list_project_contexts` et `manage_lifecycle` couvrent la
76
+ corbeille, la restauration, l’archivage, l’expiration et la révocation. Il n’y a
77
+ aucune purge définitive dans le SDK ; utilisez `dry_run=True` avant une mutation
78
+ sensible et limitez chaque lot à 10 éléments.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kaneme"
7
+ version = "1.16.1"
8
+ description = "SDK Python officiel pour l'API d'identité écrite Kaneme"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ keywords = ["kaneme", "writing", "voice", "authorship", "api"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3 :: Only",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Operating System :: OS Independent",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://kaneme.com"
25
+ Documentation = "https://kaneme.com/developers"
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ kaneme = ["contract.json", "py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ from .client import (
2
+ CORE_OPERATION_IDS,
3
+ KANEME_RATE_LIMIT_FIELDS,
4
+ KANEME_RESPONSE_HEADERS,
5
+ KANEME_RESPONSE_METADATA_FIELDS,
6
+ MAX_CONFIGURABLE_RESPONSE_BYTES,
7
+ DEFAULT_MAX_RESPONSE_BYTES,
8
+ SDK_VERSION,
9
+ KanemeAPIError,
10
+ KanemeClient,
11
+ KanemeRedirectError,
12
+ KanemeRateLimit,
13
+ KanemeResponseTooLarge,
14
+ KanemeResponseMetadata,
15
+ KanemeTransportError,
16
+ )
17
+ from . import types as types
18
+
19
+ __all__ = [
20
+ "CORE_OPERATION_IDS",
21
+ "KANEME_RATE_LIMIT_FIELDS",
22
+ "KANEME_RESPONSE_HEADERS",
23
+ "KANEME_RESPONSE_METADATA_FIELDS",
24
+ "MAX_CONFIGURABLE_RESPONSE_BYTES",
25
+ "DEFAULT_MAX_RESPONSE_BYTES",
26
+ "SDK_VERSION",
27
+ "KanemeAPIError",
28
+ "KanemeClient",
29
+ "KanemeRedirectError",
30
+ "KanemeRateLimit",
31
+ "KanemeResponseTooLarge",
32
+ "KanemeResponseMetadata",
33
+ "KanemeTransportError",
34
+ "types",
35
+ ]
@@ -0,0 +1,358 @@
1
+ """Client stdlib pour le contrat Kaneme.
2
+
3
+ ⚠️ LA VERSION SUIT LE CONTRAT, PAS SON PROPRE RYTHME. Elle est restée à 1.5.0
4
+ jusqu'au 22/08 pendant que le contrat passait à 1.9.0 — et pendant que ce client
5
+ implémentait déjà la 1.8 (`X-Kaneme-Client`). Le numéro part dans cet en-tête à
6
+ chaque appel : faux, il fausse aussi ce que le serveur croit savoir des clients
7
+ qui l'appellent. Rien ne le comparait au contrat ; `sdk-contract.test.ts` le fait
8
+ maintenant, en LISANT ce fichier et `pyproject.toml`.
9
+
10
+ 🔴 CE CLIENT JETAIT LES EN-TÊTES (corrigé le 22/08). Il rendait le corps JSON et
11
+ rien d'autre : ni `X-RateLimit-*`, ni `Retry-After`, ni `X-Request-Id`. Sur ce
12
+ point il était resté au contrat 1.5, où la cadence n'était qu'un nombre écrit
13
+ dans la documentation. La conséquence est le contraire de ce que le contrat
14
+ demande : la seule façon de se cadencer était d'attendre le 429, donc de
15
+ PROVOQUER le refus qu'on veut éviter — et l'appelant refusé n'avait même pas la
16
+ durée à attendre. Il rend maintenant la même enveloppe que le SDK TypeScript,
17
+ champ pour champ (`KanemeResponseMetadata`), et `sdk-contract.test.ts` refuse
18
+ qu'un des deux dérive de l'autre.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass
24
+ from typing import Any, Callable, Dict, Optional, cast
25
+ from urllib.error import HTTPError
26
+ from urllib.parse import quote, urlencode, urljoin, urlsplit
27
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
28
+
29
+ from .types import (
30
+ KanemeAddVoiceAssetResult,
31
+ KanemeAuthorshipResult,
32
+ KanemeBeliefListResult,
33
+ KanemeCertificatePreparationResult,
34
+ KanemeCertifyResult,
35
+ KanemeComparisonResult,
36
+ KanemeCreateVoiceResult,
37
+ KanemeGenerateResult,
38
+ KanemeImproveResult,
39
+ KanemeLifecycleResult,
40
+ KanemeProjectContextListResult,
41
+ KanemeScoreResult,
42
+ KanemeSignalResult,
43
+ KanemeSubvoiceListResult,
44
+ KanemeTrashListResult,
45
+ KanemeVoiceAssetListResult,
46
+ KanemeVoiceListResult,
47
+ KanemeVoiceResult,
48
+ )
49
+
50
+ SDK_VERSION = "1.16.1"
51
+ DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
52
+ MAX_CONFIGURABLE_RESPONSE_BYTES = 64 * 1024 * 1024
53
+ CORE_OPERATION_IDS = (
54
+ "listVoices", "getVoice", "createVoice", "listVoiceAssets", "addVoiceAsset",
55
+ "listBeliefs", "listSubvoices", "scoreText", "verifyAuthorship", "compareTexts",
56
+ "certifyDocument", "prepareCertificate", "publishCertificate",
57
+ "listProjectContexts", "listTrash", "manageLifecycle",
58
+ "generateInVoice", "improveInVoice", "recordSignal",
59
+ )
60
+ KANEME_RATE_LIMIT_FIELDS = ("limit", "remaining", "window")
61
+ KANEME_RESPONSE_METADATA_FIELDS = ("status", "request_id", "retry_after", "rate_limit")
62
+ KANEME_RESPONSE_HEADERS = {
63
+ "requestId": "X-Request-Id",
64
+ "retryAfter": "Retry-After",
65
+ "rateLimitLimit": "X-RateLimit-Limit",
66
+ "rateLimitRemaining": "X-RateLimit-Remaining",
67
+ "rateLimitWindow": "X-RateLimit-Window",
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class KanemeRateLimit:
73
+ """La cadence AU PASSAGE DE LA GARDE : le jeton de cet appel est déjà décompté.
74
+
75
+ ⚠️ `window` reste le TEXTE de l'en-tête, comme dans le SDK TypeScript. Les
76
+ deux SDK décrivent le même en-tête : qu'un seul le convertisse suffirait à
77
+ ce qu'un intégrateur qui passe de l'un à l'autre écrive un test faux.
78
+ """
79
+
80
+ limit: Optional[int]
81
+ remaining: Optional[int]
82
+ window: Optional[str]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class KanemeResponseMetadata:
87
+ """L'enveloppe d'exploitation d'UNE réponse — succès compris.
88
+
89
+ `retry_after` ne part que sur un refus : sur un succès, un client bien écrit
90
+ obéirait à un ordre d'attendre qu'on n'a pas voulu donner. La bonne façon de
91
+ se cadencer est de lire `rate_limit.remaining` sur les réponses qui MARCHENT.
92
+ """
93
+
94
+ status: int
95
+ request_id: Optional[str]
96
+ retry_after: Optional[str]
97
+ rate_limit: KanemeRateLimit
98
+
99
+
100
+ def _header_number(value: Optional[str]) -> Optional[int]:
101
+ if value is None or not value.strip():
102
+ return None
103
+ try:
104
+ return int(value.strip())
105
+ except ValueError:
106
+ return None
107
+
108
+
109
+ def _metadata(status: int, headers: Any) -> KanemeResponseMetadata:
110
+ # `headers` est un `email.message.Message` des deux côtés — réponse comme
111
+ # HTTPError — donc sa lecture est insensible à la casse. Ça compte : ces
112
+ # en-têtes traversent des proxys qui les renormalisent.
113
+ return KanemeResponseMetadata(
114
+ status=status,
115
+ request_id=headers.get(KANEME_RESPONSE_HEADERS["requestId"]),
116
+ retry_after=headers.get(KANEME_RESPONSE_HEADERS["retryAfter"]),
117
+ rate_limit=KanemeRateLimit(
118
+ limit=_header_number(headers.get(KANEME_RESPONSE_HEADERS["rateLimitLimit"])),
119
+ remaining=_header_number(headers.get(KANEME_RESPONSE_HEADERS["rateLimitRemaining"])),
120
+ window=headers.get(KANEME_RESPONSE_HEADERS["rateLimitWindow"]),
121
+ ),
122
+ )
123
+
124
+
125
+ class KanemeAPIError(RuntimeError):
126
+ def __init__(self, status: int, code: Optional[str], message: str, response: Any = None,
127
+ metadata: Optional[KanemeResponseMetadata] = None):
128
+ super().__init__(message)
129
+ self.status, self.code, self.response = status, code, response
130
+ # L'enveloppe du refus. Sur un 429, `metadata.retry_after` porte les
131
+ # secondes à attendre — sans elle, un client refusé ne peut que deviner,
132
+ # et un client qui devine réessaie trop tôt.
133
+ self.metadata = metadata
134
+
135
+
136
+ class KanemeTransportError(RuntimeError):
137
+ """Échec local avant qu'une réponse Kaneme fiable soit disponible."""
138
+
139
+
140
+ class KanemeRedirectError(KanemeTransportError):
141
+ """Redirection refusée pour ne pas divulguer la clé API à un autre origin."""
142
+
143
+
144
+ class KanemeResponseTooLarge(KanemeTransportError):
145
+ """Réponse interrompue avant qu'elle n'épuise la mémoire du processus."""
146
+
147
+
148
+ def _origin(raw_url: str) -> tuple[str, str, int]:
149
+ parsed = urlsplit(raw_url)
150
+ scheme = parsed.scheme.lower()
151
+ host = (parsed.hostname or "").lower()
152
+ port = parsed.port or (443 if scheme == "https" else 80)
153
+ return scheme, host, port
154
+
155
+
156
+ def _validate_base_url(raw_url: str) -> str:
157
+ parsed = urlsplit(raw_url)
158
+ host = (parsed.hostname or "").lower()
159
+ local = host in {"localhost", "127.0.0.1", "::1"}
160
+ if parsed.scheme not in ({"http", "https"} if local else {"https"}):
161
+ raise ValueError("Kaneme base_url must use HTTPS (HTTP is allowed only for localhost)")
162
+ if not host or parsed.username is not None or parsed.password is not None:
163
+ raise ValueError("Kaneme base_url must be an absolute URL without credentials")
164
+ if parsed.query or parsed.fragment:
165
+ raise ValueError("Kaneme base_url must not contain a query or fragment")
166
+ return raw_url.rstrip("/")
167
+
168
+
169
+ class _SameOriginRedirectHandler(HTTPRedirectHandler):
170
+ """Autorise seulement les redirections qui gardent exactement le même origin."""
171
+
172
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
173
+ target = urljoin(req.full_url, newurl)
174
+ if _origin(req.full_url) != _origin(target):
175
+ raise KanemeRedirectError(
176
+ "Kaneme refused a cross-origin redirect to protect the Authorization header"
177
+ )
178
+ if urlsplit(req.full_url).scheme == "https" and urlsplit(target).scheme != "https":
179
+ raise KanemeRedirectError("Kaneme refused an HTTPS downgrade redirect")
180
+ return super().redirect_request(req, fp, code, msg, headers, target)
181
+
182
+
183
+ # Nom volontairement patchable dans les tests, comme l'ancien `urlopen`.
184
+ urlopen = build_opener(_SameOriginRedirectHandler()).open
185
+
186
+
187
+ def _read_bounded(response: Any, max_bytes: int) -> bytes:
188
+ content_length = response.headers.get("Content-Length")
189
+ if content_length:
190
+ try:
191
+ if int(content_length) > max_bytes:
192
+ raise KanemeResponseTooLarge(
193
+ f"Kaneme response exceeds the {max_bytes}-byte limit"
194
+ )
195
+ except ValueError:
196
+ pass
197
+ raw = response.read(max_bytes + 1)
198
+ if len(raw) > max_bytes:
199
+ raise KanemeResponseTooLarge(
200
+ f"Kaneme response exceeds the {max_bytes}-byte limit"
201
+ )
202
+ return raw
203
+
204
+
205
+ class KanemeClient:
206
+ def __init__(self, api_key: str, base_url: str = "https://kaneme.com", timeout: float = 30.0,
207
+ on_response: Optional[Callable[[KanemeResponseMetadata], None]] = None,
208
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES):
209
+ """`on_response` est appelé sur CHAQUE réponse, succès comme erreur.
210
+
211
+ ⚠️ Il se pose sur le client, là où le SDK TypeScript l'accepte aussi par
212
+ appel. Ce n'est pas un oubli : ici les méthodes passent leurs `**kwargs`
213
+ au corps de la requête ou à sa query, donc un paramètre réservé de plus
214
+ deviendrait un champ envoyé au serveur le jour où une opération
215
+ s'appellerait comme lui.
216
+ """
217
+ if not api_key:
218
+ raise ValueError("Kaneme api_key is required")
219
+ if timeout <= 0:
220
+ raise ValueError("Kaneme timeout must be greater than zero")
221
+ if not 1 <= max_response_bytes <= MAX_CONFIGURABLE_RESPONSE_BYTES:
222
+ raise ValueError(
223
+ f"Kaneme max_response_bytes must be between 1 and {MAX_CONFIGURABLE_RESPONSE_BYTES}"
224
+ )
225
+ self.api_key = api_key
226
+ self.base_url = _validate_base_url(base_url)
227
+ self.timeout = timeout
228
+ self.max_response_bytes = max_response_bytes
229
+ self.on_response = on_response
230
+
231
+ def _emit(self, metadata: KanemeResponseMetadata) -> None:
232
+ if self.on_response is not None:
233
+ self.on_response(metadata)
234
+
235
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None,
236
+ idempotency_key: Optional[str] = None) -> Any:
237
+ headers = {
238
+ "Authorization": f"Bearer {self.api_key}",
239
+ "Accept": "application/json",
240
+ "X-Kaneme-Client": f"kaneme-sdk-python/{SDK_VERSION}",
241
+ # urllib envoie sinon ``Python-urllib/x.y``. Les protections bot de
242
+ # Cloudflare peuvent le refuser avant même que la requête atteigne
243
+ # l'API. Une identité explicite et versionnée rend le SDK utilisable
244
+ # derrière le même edge que le client TypeScript.
245
+ "User-Agent": f"kaneme-sdk-python/{SDK_VERSION}",
246
+ }
247
+ data = None
248
+ if body is not None:
249
+ headers["Content-Type"] = "application/json"
250
+ data = json.dumps(body).encode("utf-8")
251
+ if idempotency_key:
252
+ headers["Idempotency-Key"] = idempotency_key
253
+ request = Request(self.base_url + path, data=data, headers=headers, method=method)
254
+ try:
255
+ with urlopen(request, timeout=self.timeout) as response:
256
+ self._emit(_metadata(response.status, response.headers))
257
+ raw = _read_bounded(response, self.max_response_bytes)
258
+ return json.loads(raw) if raw else None
259
+ except HTTPError as error:
260
+ metadata = _metadata(error.code, error.headers)
261
+ self._emit(metadata)
262
+ raw = _read_bounded(error, min(self.max_response_bytes, 1024 * 1024))
263
+ try:
264
+ payload = json.loads(raw) if raw else {}
265
+ except (json.JSONDecodeError, UnicodeDecodeError):
266
+ payload = {}
267
+ raise KanemeAPIError(
268
+ error.code,
269
+ payload.get("code"),
270
+ payload.get("error", f"Kaneme API error {error.code}"),
271
+ payload,
272
+ metadata,
273
+ ) from error
274
+
275
+ @staticmethod
276
+ def _query(**params: Any) -> str:
277
+ values = {
278
+ key: str(value).lower() if isinstance(value, bool) else value
279
+ for key, value in params.items()
280
+ if value is not None
281
+ }
282
+ return "?" + urlencode(values) if values else ""
283
+
284
+ def list_voices(self) -> KanemeVoiceListResult:
285
+ return cast(KanemeVoiceListResult, self._request("GET", "/api/v1/voices"))
286
+
287
+ def get_voice(self, profile_id=None) -> KanemeVoiceResult:
288
+ return cast(KanemeVoiceResult, self._request("GET", "/api/v1/voice" + self._query(profileId=profile_id)))
289
+
290
+ def create_voice(self, *, idempotency_key=None, **input) -> KanemeCreateVoiceResult:
291
+ return cast(KanemeCreateVoiceResult, self._request("POST", "/api/v1/voices", input, idempotency_key))
292
+
293
+ def list_voice_assets(self, **params) -> KanemeVoiceAssetListResult:
294
+ return cast(KanemeVoiceAssetListResult, self._request("GET", "/api/v1/voice/assets" + self._query(**params)))
295
+
296
+ def add_voice_asset(self, *, idempotency_key=None, **input) -> KanemeAddVoiceAssetResult:
297
+ return cast(KanemeAddVoiceAssetResult, self._request("POST", "/api/v1/voice/assets", input, idempotency_key))
298
+
299
+ def list_beliefs(self, **params) -> KanemeBeliefListResult:
300
+ return cast(KanemeBeliefListResult, self._request("GET", "/api/v1/voice/beliefs" + self._query(**params)))
301
+
302
+ def list_subvoices(self, profile_id=None) -> KanemeSubvoiceListResult:
303
+ return cast(KanemeSubvoiceListResult, self._request("GET", "/api/v1/voice/subvoices" + self._query(profileId=profile_id)))
304
+
305
+ def score_text(self, text, profile_id=None) -> KanemeScoreResult:
306
+ return cast(KanemeScoreResult, self._request("POST", "/api/v1/score", {"text": text, "profileId": profile_id}))
307
+
308
+ def verify_authorship(self, text, question="q2", profile_id=None) -> KanemeAuthorshipResult:
309
+ return cast(KanemeAuthorshipResult, self._request("POST", "/api/v1/verify", {"text": text, "question": question, "profileId": profile_id}))
310
+
311
+ def compare_texts(self, text, reference=None, reference_profile_id=None, question="q2") -> KanemeComparisonResult:
312
+ return cast(KanemeComparisonResult, self._request("POST", "/api/v1/compare", {"text": text, "reference": reference, "referenceProfileId": reference_profile_id, "question": question}))
313
+
314
+ def certify_document(self, document_id, display_name=None, idempotency_key=None) -> KanemeCertifyResult:
315
+ return cast(KanemeCertifyResult, self._request("POST", "/api/v1/certify", {"documentId": document_id, "displayName": display_name}, idempotency_key))
316
+
317
+ def prepare_certificate(self, document_id, display_name=None) -> KanemeCertificatePreparationResult:
318
+ return cast(KanemeCertificatePreparationResult, self._request(
319
+ "POST", "/api/v1/certificates/prepare",
320
+ {"documentId": document_id, "displayName": display_name},
321
+ ))
322
+
323
+ def publish_certificate(self, approval_id, idempotency_key=None) -> KanemeCertifyResult:
324
+ return cast(KanemeCertifyResult, self._request(
325
+ "POST", "/api/v1/certificates/publish",
326
+ {"approvalId": approval_id}, idempotency_key,
327
+ ))
328
+
329
+ def list_project_contexts(self, project_id, include_archived=None, include_trashed=None) -> KanemeProjectContextListResult:
330
+ query = self._query(includeArchived=include_archived, includeTrashed=include_trashed)
331
+ path = "/api/v1/projects/" + quote(str(project_id), safe="") + "/contexts" + query
332
+ return cast(KanemeProjectContextListResult, self._request("GET", path))
333
+
334
+ def list_trash(self) -> KanemeTrashListResult:
335
+ return cast(KanemeTrashListResult, self._request("GET", "/api/v1/trash"))
336
+
337
+ def manage_lifecycle(self, items, dry_run=None, idempotency_key=None) -> KanemeLifecycleResult:
338
+ body = {"items": items}
339
+ if dry_run is not None:
340
+ body["dryRun"] = dry_run
341
+ return cast(KanemeLifecycleResult, self._request(
342
+ "POST", "/api/v1/lifecycle", body, idempotency_key,
343
+ ))
344
+
345
+ def generate_in_voice(self, prompt, idempotency_key=None, **input) -> KanemeGenerateResult:
346
+ return cast(KanemeGenerateResult, self._request("POST", "/api/v1/generate", {"prompt": prompt, **input}, idempotency_key))
347
+
348
+ def improve_in_voice(self, text, profile_id=None, idempotency_key=None) -> KanemeImproveResult:
349
+ return cast(KanemeImproveResult, self._request("POST", "/api/v1/improve", {"text": text, "profileId": profile_id}, idempotency_key))
350
+
351
+ def record_signal(self, kind, *, idempotency_key=None, **input) -> KanemeSignalResult:
352
+ """Fait APPRENDRE la voix : gratuit, et le seul geste qui la fasse evoluer.
353
+
354
+ Passe le `format` du canal — sans lui, la sous-voix de ce canal n apprend
355
+ rien. Sur un rejet, `proposed`/`kept` sont deux TEXTES : le signal est
356
+ derive serveur, jamais une liste de termes.
357
+ """
358
+ return cast(KanemeSignalResult, self._request("POST", "/api/v1/signal", {"kind": kind, **input}, idempotency_key))