bfocus 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.
bfocus/_resources.py ADDED
@@ -0,0 +1,862 @@
1
+ """Recursos da API pública: ``customers``, ``products``, ``release_notes``, ``kb``, ``ai_agents``.
2
+
3
+ Convenções (iguais em todos os métodos):
4
+
5
+ * Argumentos obrigatórios são posicionais; os opcionais são **keyword-only**.
6
+ * Campos de corpo opcionais têm padrão :data:`~bfocus.types.UNSET` — não informado = não
7
+ enviado. ``None`` explícito vai como ``null`` e **limpa** o campo na API.
8
+ * Filtros de query com ``None`` são omitidos.
9
+ * Toda chamada aceita ``timeout=`` (segundos, por tentativa); as escritas aceitam
10
+ ``idempotency_key=`` (senão a SDK gera uma e a repete nas novas tentativas).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import date, datetime
16
+ from typing import (
17
+ Any,
18
+ Callable,
19
+ Dict,
20
+ Iterator,
21
+ List,
22
+ Mapping,
23
+ Optional,
24
+ Sequence,
25
+ TypeVar,
26
+ Union,
27
+ cast,
28
+ )
29
+
30
+ from ._transport import Transport, compact, path_segment
31
+ from .types import (
32
+ UNSET,
33
+ AgentPreview,
34
+ AgentTurn,
35
+ AIAgent,
36
+ Contact,
37
+ Customer,
38
+ CustomFieldInput,
39
+ Deleted,
40
+ Interaction,
41
+ KBArticle,
42
+ KBArticleSummary,
43
+ KBBatchItem,
44
+ KBBatchOutcome,
45
+ KBSearchHit,
46
+ MaybeUnset,
47
+ Page,
48
+ Product,
49
+ ProductRef,
50
+ ReleaseNote,
51
+ )
52
+
53
+ T = TypeVar("T")
54
+ DateLike = Union[datetime, date, str]
55
+
56
+ __all__ = [
57
+ "Customers",
58
+ "CustomerContacts",
59
+ "CustomerProducts",
60
+ "CustomerInteractions",
61
+ "Products",
62
+ "ReleaseNotes",
63
+ "KnowledgeBase",
64
+ "KBArticles",
65
+ "AIAgents",
66
+ ]
67
+
68
+
69
+ def _page(data: Any, pagination: Optional[Mapping[str, Any]]) -> Page[Any]:
70
+ items = list(data or [])
71
+ if not pagination: # a API sempre manda; defensivo para não quebrar a iteração
72
+ return Page(items=items, page=1, page_size=len(items), total=len(items), pages=1)
73
+ return Page(
74
+ items=items,
75
+ page=int(pagination.get("page", 1)),
76
+ page_size=int(pagination.get("page_size", len(items))),
77
+ total=int(pagination.get("total", len(items))),
78
+ pages=int(pagination.get("pages", 1)),
79
+ )
80
+
81
+
82
+ def _iterate(fetch: Callable[[int], Page[T]]) -> Iterator[T]:
83
+ page = 1
84
+ while True:
85
+ current = fetch(page)
86
+ for item in current.items:
87
+ yield item
88
+ if not current.items or current.page >= current.pages:
89
+ return
90
+ page += 1
91
+
92
+
93
+ def _dicts(value: Any) -> Any:
94
+ """Lista de mapeamentos → lista de dicts (preserva UNSET/None)."""
95
+ if value is UNSET or value is None:
96
+ return value
97
+ return [dict(item) for item in value]
98
+
99
+
100
+ class _Resource:
101
+ def __init__(self, transport: Transport) -> None:
102
+ self._t = transport
103
+
104
+
105
+ # ── clientes ────────────────────────────────────────────────────────────────────
106
+
107
+
108
+ class CustomerContacts(_Resource):
109
+ """Contatos (pessoas) de um cliente — ``client.customers.contacts``."""
110
+
111
+ def list(self, external_id: str, *, timeout: Optional[float] = None) -> List[Contact]:
112
+ """Contatos do cliente. ``GET /customers/{external_id}/contacts``"""
113
+ ext = path_segment(external_id, "external_id")
114
+ data, _ = self._t.request("GET", f"/customers/{ext}/contacts", timeout=timeout)
115
+ return cast(List[Contact], data)
116
+
117
+ def upsert(
118
+ self,
119
+ external_id: str,
120
+ contact_external_id: str,
121
+ *,
122
+ name: MaybeUnset[Optional[str]] = UNSET,
123
+ role: MaybeUnset[Optional[str]] = UNSET,
124
+ email: MaybeUnset[Optional[str]] = UNSET,
125
+ phone: MaybeUnset[Optional[str]] = UNSET,
126
+ notes: MaybeUnset[Optional[str]] = UNSET,
127
+ is_primary: MaybeUnset[Optional[bool]] = UNSET,
128
+ idempotency_key: Optional[str] = None,
129
+ timeout: Optional[float] = None,
130
+ ) -> Contact:
131
+ """Cria ou atualiza um contato pelo ``external_id`` dele. Só o que vier muda.
132
+
133
+ ``PUT /customers/{external_id}/contacts/{contact_external_id}``
134
+ """
135
+ ext = path_segment(external_id, "external_id")
136
+ cext = path_segment(contact_external_id, "contact_external_id")
137
+ body = compact(
138
+ {
139
+ "name": name,
140
+ "role": role,
141
+ "email": email,
142
+ "phone": phone,
143
+ "notes": notes,
144
+ "is_primary": is_primary,
145
+ }
146
+ )
147
+ data, _ = self._t.request(
148
+ "PUT",
149
+ f"/customers/{ext}/contacts/{cext}",
150
+ body=body,
151
+ idempotency_key=idempotency_key,
152
+ timeout=timeout,
153
+ )
154
+ return cast(Contact, data)
155
+
156
+ def delete(
157
+ self,
158
+ external_id: str,
159
+ contact_external_id: str,
160
+ *,
161
+ idempotency_key: Optional[str] = None,
162
+ timeout: Optional[float] = None,
163
+ ) -> Deleted:
164
+ """Remove um contato. ``DELETE /customers/{external_id}/contacts/{contact_external_id}``"""
165
+ ext = path_segment(external_id, "external_id")
166
+ cext = path_segment(contact_external_id, "contact_external_id")
167
+ data, _ = self._t.request(
168
+ "DELETE",
169
+ f"/customers/{ext}/contacts/{cext}",
170
+ idempotency_key=idempotency_key,
171
+ timeout=timeout,
172
+ )
173
+ return cast(Deleted, data)
174
+
175
+
176
+ class CustomerProducts(_Resource):
177
+ """Produtos vinculados a um cliente — ``client.customers.products``."""
178
+
179
+ def list(self, external_id: str, *, timeout: Optional[float] = None) -> List[ProductRef]:
180
+ """Produtos do cliente. ``GET /customers/{external_id}/products``"""
181
+ ext = path_segment(external_id, "external_id")
182
+ data, _ = self._t.request("GET", f"/customers/{ext}/products", timeout=timeout)
183
+ return cast(List[ProductRef], data)
184
+
185
+ def attach(
186
+ self,
187
+ external_id: str,
188
+ product_slug: str,
189
+ *,
190
+ idempotency_key: Optional[str] = None,
191
+ timeout: Optional[float] = None,
192
+ ) -> ProductRef:
193
+ """Vincula um produto ao cliente (idempotente). ``PUT /customers/{external_id}/products/{slug}``"""
194
+ ext = path_segment(external_id, "external_id")
195
+ slug = path_segment(product_slug, "product_slug")
196
+ data, _ = self._t.request(
197
+ "PUT",
198
+ f"/customers/{ext}/products/{slug}",
199
+ idempotency_key=idempotency_key,
200
+ timeout=timeout,
201
+ )
202
+ return cast(ProductRef, data)
203
+
204
+ def detach(
205
+ self,
206
+ external_id: str,
207
+ product_slug: str,
208
+ *,
209
+ idempotency_key: Optional[str] = None,
210
+ timeout: Optional[float] = None,
211
+ ) -> Deleted:
212
+ """Desvincula um produto. ``DELETE /customers/{external_id}/products/{slug}``"""
213
+ ext = path_segment(external_id, "external_id")
214
+ slug = path_segment(product_slug, "product_slug")
215
+ data, _ = self._t.request(
216
+ "DELETE",
217
+ f"/customers/{ext}/products/{slug}",
218
+ idempotency_key=idempotency_key,
219
+ timeout=timeout,
220
+ )
221
+ return cast(Deleted, data)
222
+
223
+
224
+ class CustomerInteractions(_Resource):
225
+ """Histórico de interações de um cliente — ``client.customers.interactions``."""
226
+
227
+ def list(
228
+ self,
229
+ external_id: str,
230
+ *,
231
+ page: Optional[int] = None,
232
+ page_size: Optional[int] = None,
233
+ timeout: Optional[float] = None,
234
+ ) -> Page[Interaction]:
235
+ """Uma página de interações. ``GET /customers/{external_id}/interactions``"""
236
+ ext = path_segment(external_id, "external_id")
237
+ data, pagination = self._t.request(
238
+ "GET",
239
+ f"/customers/{ext}/interactions",
240
+ query={"page": page, "page_size": page_size},
241
+ timeout=timeout,
242
+ )
243
+ return cast(Page[Interaction], _page(data, pagination))
244
+
245
+ def list_all(
246
+ self,
247
+ external_id: str,
248
+ *,
249
+ page_size: int = 100,
250
+ timeout: Optional[float] = None,
251
+ ) -> Iterator[Interaction]:
252
+ """Todas as interações, página a página (gerador preguiçoso)."""
253
+ path_segment(external_id, "external_id")
254
+ return _iterate(
255
+ lambda n: self.list(external_id, page=n, page_size=page_size, timeout=timeout)
256
+ )
257
+
258
+ def create(
259
+ self,
260
+ external_id: str,
261
+ content: str,
262
+ *,
263
+ is_internal: MaybeUnset[bool] = UNSET,
264
+ author_email: MaybeUnset[Optional[str]] = UNSET,
265
+ idempotency_key: Optional[str] = None,
266
+ timeout: Optional[float] = None,
267
+ ) -> Interaction:
268
+ """Registra uma interação (nota) no cliente. ``POST /customers/{external_id}/interactions``
269
+
270
+ Args:
271
+ content: Texto/HTML da interação.
272
+ is_internal: Nota interna (padrão da API: ``True``).
273
+ author_email: E-mail de um usuário do bFocus para constar como autor.
274
+ """
275
+ ext = path_segment(external_id, "external_id")
276
+ body = compact(
277
+ {"content": content, "is_internal": is_internal, "author_email": author_email}
278
+ )
279
+ data, _ = self._t.request(
280
+ "POST",
281
+ f"/customers/{ext}/interactions",
282
+ body=body,
283
+ idempotency_key=idempotency_key,
284
+ timeout=timeout,
285
+ )
286
+ return cast(Interaction, data)
287
+
288
+
289
+ class Customers(_Resource):
290
+ """Clientes (empresas) — ``client.customers``.
291
+
292
+ Sub-recursos: :attr:`contacts`, :attr:`products`, :attr:`interactions`.
293
+ """
294
+
295
+ def __init__(self, transport: Transport) -> None:
296
+ super().__init__(transport)
297
+ self.contacts = CustomerContacts(transport)
298
+ self.products = CustomerProducts(transport)
299
+ self.interactions = CustomerInteractions(transport)
300
+
301
+ def upsert(
302
+ self,
303
+ external_id: str,
304
+ *,
305
+ name: MaybeUnset[Optional[str]] = UNSET,
306
+ document: MaybeUnset[Optional[str]] = UNSET,
307
+ email: MaybeUnset[Optional[str]] = UNSET,
308
+ phone: MaybeUnset[Optional[str]] = UNSET,
309
+ website: MaybeUnset[Optional[str]] = UNSET,
310
+ notes: MaybeUnset[Optional[str]] = UNSET,
311
+ custom_fields: MaybeUnset[Optional[Sequence[CustomFieldInput]]] = UNSET,
312
+ idempotency_key: Optional[str] = None,
313
+ timeout: Optional[float] = None,
314
+ ) -> Customer:
315
+ """Cria ou atualiza um cliente pelo ``external_id`` do seu sistema.
316
+
317
+ ``PUT /customers/{external_id}``. Só os campos informados mudam; ``None`` limpa.
318
+ ``custom_fields``, quando enviado, **substitui** a lista inteira.
319
+ """
320
+ ext = path_segment(external_id, "external_id")
321
+ body = compact(
322
+ {
323
+ "name": name,
324
+ "document": document,
325
+ "email": email,
326
+ "phone": phone,
327
+ "website": website,
328
+ "notes": notes,
329
+ "custom_fields": _dicts(custom_fields),
330
+ }
331
+ )
332
+ data, _ = self._t.request(
333
+ "PUT", f"/customers/{ext}", body=body,
334
+ idempotency_key=idempotency_key, timeout=timeout,
335
+ )
336
+ return cast(Customer, data)
337
+
338
+ def get(self, external_id: str, *, timeout: Optional[float] = None) -> Customer:
339
+ """Um cliente. ``GET /customers/{external_id}``"""
340
+ ext = path_segment(external_id, "external_id")
341
+ data, _ = self._t.request("GET", f"/customers/{ext}", timeout=timeout)
342
+ return cast(Customer, data)
343
+
344
+ def list(
345
+ self,
346
+ *,
347
+ q: Optional[str] = None,
348
+ updated_since: Optional[DateLike] = None,
349
+ page: Optional[int] = None,
350
+ page_size: Optional[int] = None,
351
+ timeout: Optional[float] = None,
352
+ ) -> Page[Customer]:
353
+ """Uma página de clientes. ``GET /customers``
354
+
355
+ Args:
356
+ q: Busca por nome/documento/e-mail.
357
+ updated_since: Só os alterados a partir deste instante (``datetime`` vira
358
+ ISO 8601 UTC com ``Z``; string passa como veio).
359
+ """
360
+ data, pagination = self._t.request(
361
+ "GET",
362
+ "/customers",
363
+ query={"q": q, "updated_since": updated_since, "page": page, "page_size": page_size},
364
+ timeout=timeout,
365
+ )
366
+ return cast(Page[Customer], _page(data, pagination))
367
+
368
+ def list_all(
369
+ self,
370
+ *,
371
+ q: Optional[str] = None,
372
+ updated_since: Optional[DateLike] = None,
373
+ page_size: int = 100,
374
+ timeout: Optional[float] = None,
375
+ ) -> Iterator[Customer]:
376
+ """Todos os clientes, página a página (gerador preguiçoso).
377
+
378
+ Ideal para sincronização incremental: guarde o instante da última rodada e passe
379
+ em ``updated_since``.
380
+ """
381
+ return _iterate(
382
+ lambda n: self.list(
383
+ q=q, updated_since=updated_since, page=n, page_size=page_size, timeout=timeout
384
+ )
385
+ )
386
+
387
+ def delete(
388
+ self,
389
+ external_id: str,
390
+ *,
391
+ idempotency_key: Optional[str] = None,
392
+ timeout: Optional[float] = None,
393
+ ) -> Deleted:
394
+ """Exclui um cliente. ``DELETE /customers/{external_id}``"""
395
+ ext = path_segment(external_id, "external_id")
396
+ data, _ = self._t.request(
397
+ "DELETE", f"/customers/{ext}", idempotency_key=idempotency_key, timeout=timeout
398
+ )
399
+ return cast(Deleted, data)
400
+
401
+
402
+ # ── produtos ────────────────────────────────────────────────────────────────────
403
+
404
+
405
+ class Products(_Resource):
406
+ """Catálogo de produtos — ``client.products``."""
407
+
408
+ def list(
409
+ self, *, include_inactive: Optional[bool] = None, timeout: Optional[float] = None
410
+ ) -> List[Product]:
411
+ """Produtos do catálogo. ``GET /products``
412
+
413
+ Args:
414
+ include_inactive: ``True`` inclui os arquivados.
415
+ """
416
+ data, _ = self._t.request(
417
+ "GET", "/products", query={"include_inactive": include_inactive}, timeout=timeout
418
+ )
419
+ return cast(List[Product], data)
420
+
421
+ def get(self, slug: str, *, timeout: Optional[float] = None) -> Product:
422
+ """Um produto. ``GET /products/{slug}``"""
423
+ data, _ = self._t.request(
424
+ "GET", f"/products/{path_segment(slug, 'slug')}", timeout=timeout
425
+ )
426
+ return cast(Product, data)
427
+
428
+ def upsert(
429
+ self,
430
+ slug: str,
431
+ *,
432
+ name: MaybeUnset[Optional[str]] = UNSET,
433
+ description: MaybeUnset[Optional[str]] = UNSET,
434
+ color: MaybeUnset[Optional[str]] = UNSET,
435
+ icon: MaybeUnset[Optional[str]] = UNSET,
436
+ is_active: MaybeUnset[Optional[bool]] = UNSET,
437
+ sort_order: MaybeUnset[Optional[int]] = UNSET,
438
+ idempotency_key: Optional[str] = None,
439
+ timeout: Optional[float] = None,
440
+ ) -> Product:
441
+ """Cria ou atualiza um produto pelo ``slug``. ``PUT /products/{slug}``"""
442
+ body = compact(
443
+ {
444
+ "name": name,
445
+ "description": description,
446
+ "color": color,
447
+ "icon": icon,
448
+ "is_active": is_active,
449
+ "sort_order": sort_order,
450
+ }
451
+ )
452
+ data, _ = self._t.request(
453
+ "PUT", f"/products/{path_segment(slug, 'slug')}", body=body,
454
+ idempotency_key=idempotency_key, timeout=timeout,
455
+ )
456
+ return cast(Product, data)
457
+
458
+ def archive(
459
+ self,
460
+ slug: str,
461
+ *,
462
+ idempotency_key: Optional[str] = None,
463
+ timeout: Optional[float] = None,
464
+ ) -> Product:
465
+ """Arquiva um produto (não apaga). ``DELETE /products/{slug}``"""
466
+ data, _ = self._t.request(
467
+ "DELETE", f"/products/{path_segment(slug, 'slug')}",
468
+ idempotency_key=idempotency_key, timeout=timeout,
469
+ )
470
+ return cast(Product, data)
471
+
472
+
473
+ # ── release notes ───────────────────────────────────────────────────────────────
474
+
475
+
476
+ class ReleaseNotes(_Resource):
477
+ """Release notes por produto — ``client.release_notes``."""
478
+
479
+ @staticmethod
480
+ def _base(product_slug: str) -> str:
481
+ return f"/products/{path_segment(product_slug, 'product_slug')}/release-notes"
482
+
483
+ def list(
484
+ self,
485
+ product_slug: str,
486
+ *,
487
+ published: Optional[bool] = None,
488
+ page: Optional[int] = None,
489
+ page_size: Optional[int] = None,
490
+ timeout: Optional[float] = None,
491
+ ) -> Page[ReleaseNote]:
492
+ """Uma página de release notes. ``GET /products/{slug}/release-notes``
493
+
494
+ Args:
495
+ published: ``True`` só publicadas, ``False`` só rascunhos, ``None`` todas.
496
+ """
497
+ data, pagination = self._t.request(
498
+ "GET",
499
+ self._base(product_slug),
500
+ query={"published": published, "page": page, "page_size": page_size},
501
+ timeout=timeout,
502
+ )
503
+ return cast(Page[ReleaseNote], _page(data, pagination))
504
+
505
+ def list_all(
506
+ self,
507
+ product_slug: str,
508
+ *,
509
+ published: Optional[bool] = None,
510
+ page_size: int = 100,
511
+ timeout: Optional[float] = None,
512
+ ) -> Iterator[ReleaseNote]:
513
+ """Todas as release notes do produto, página a página (gerador preguiçoso)."""
514
+ self._base(product_slug)
515
+ return _iterate(
516
+ lambda n: self.list(
517
+ product_slug, published=published, page=n, page_size=page_size, timeout=timeout
518
+ )
519
+ )
520
+
521
+ def get(
522
+ self, product_slug: str, version: str, *, timeout: Optional[float] = None
523
+ ) -> ReleaseNote:
524
+ """Uma release note. ``GET /products/{slug}/release-notes/{version}``"""
525
+ ver = path_segment(version, "version")
526
+ data, _ = self._t.request("GET", f"{self._base(product_slug)}/{ver}", timeout=timeout)
527
+ return cast(ReleaseNote, data)
528
+
529
+ def upsert(
530
+ self,
531
+ product_slug: str,
532
+ version: str,
533
+ *,
534
+ title: MaybeUnset[Optional[str]] = UNSET,
535
+ description_html: MaybeUnset[Optional[str]] = UNSET,
536
+ description_markdown: MaybeUnset[Optional[str]] = UNSET,
537
+ audience: MaybeUnset[Optional[str]] = UNSET,
538
+ require_ack_internal: MaybeUnset[Optional[bool]] = UNSET,
539
+ require_ack_external: MaybeUnset[Optional[bool]] = UNSET,
540
+ publish: MaybeUnset[bool] = UNSET,
541
+ idempotency_key: Optional[str] = None,
542
+ timeout: Optional[float] = None,
543
+ ) -> ReleaseNote:
544
+ """Cria ou atualiza a release note de uma versão (SemVer, aceita ``v`` na frente).
545
+
546
+ ``PUT /products/{slug}/release-notes/{version}``. Com ``publish=True`` já publica —
547
+ é o caminho para publicar direto do CI.
548
+
549
+ Args:
550
+ audience: ``"internal"``, ``"external"`` ou ``"both"``.
551
+ description_markdown: Alternativa a ``description_html`` (a API converte).
552
+ """
553
+ ver = path_segment(version, "version")
554
+ body = compact(
555
+ {
556
+ "title": title,
557
+ "description_html": description_html,
558
+ "description_markdown": description_markdown,
559
+ "audience": audience,
560
+ "require_ack_internal": require_ack_internal,
561
+ "require_ack_external": require_ack_external,
562
+ "publish": publish,
563
+ }
564
+ )
565
+ data, _ = self._t.request(
566
+ "PUT", f"{self._base(product_slug)}/{ver}", body=body,
567
+ idempotency_key=idempotency_key, timeout=timeout,
568
+ )
569
+ return cast(ReleaseNote, data)
570
+
571
+ def publish(
572
+ self,
573
+ product_slug: str,
574
+ version: str,
575
+ *,
576
+ idempotency_key: Optional[str] = None,
577
+ timeout: Optional[float] = None,
578
+ ) -> ReleaseNote:
579
+ """Publica uma release note. ``POST /products/{slug}/release-notes/{version}/publish``"""
580
+ ver = path_segment(version, "version")
581
+ data, _ = self._t.request(
582
+ "POST", f"{self._base(product_slug)}/{ver}/publish",
583
+ idempotency_key=idempotency_key, timeout=timeout,
584
+ )
585
+ return cast(ReleaseNote, data)
586
+
587
+
588
+ # ── base de conhecimento ────────────────────────────────────────────────────────
589
+
590
+
591
+ def _article_id(external_id: str) -> str:
592
+ return path_segment(external_id, "external_id", allow_slash=False)
593
+
594
+
595
+ class KBArticles(_Resource):
596
+ """Artigos da base de conhecimento — ``client.kb.articles``."""
597
+
598
+ #: Limite da API por requisição de ``batch_upsert``; a SDK divide acima disso.
599
+ BATCH_SIZE = 100
600
+
601
+ def list(
602
+ self,
603
+ *,
604
+ product: Optional[str] = None,
605
+ status: Optional[str] = None,
606
+ q: Optional[str] = None,
607
+ updated_since: Optional[DateLike] = None,
608
+ page: Optional[int] = None,
609
+ page_size: Optional[int] = None,
610
+ timeout: Optional[float] = None,
611
+ ) -> Page[KBArticleSummary]:
612
+ """Uma página de artigos (resumo, sem ``body_html``). ``GET /kb/articles``
613
+
614
+ Args:
615
+ product: Slug do produto.
616
+ status: ``"draft"`` ou ``"published"``.
617
+ q: Busca no título e no texto.
618
+ updated_since: ``datetime`` (vira ISO UTC com ``Z``) ou string.
619
+ """
620
+ data, pagination = self._t.request(
621
+ "GET",
622
+ "/kb/articles",
623
+ query={
624
+ "product": product,
625
+ "status": status,
626
+ "q": q,
627
+ "updated_since": updated_since,
628
+ "page": page,
629
+ "page_size": page_size,
630
+ },
631
+ timeout=timeout,
632
+ )
633
+ return cast(Page[KBArticleSummary], _page(data, pagination))
634
+
635
+ def list_all(
636
+ self,
637
+ *,
638
+ product: Optional[str] = None,
639
+ status: Optional[str] = None,
640
+ q: Optional[str] = None,
641
+ updated_since: Optional[DateLike] = None,
642
+ page_size: int = 100,
643
+ timeout: Optional[float] = None,
644
+ ) -> Iterator[KBArticleSummary]:
645
+ """Todos os artigos (com os filtros), página a página (gerador preguiçoso)."""
646
+ return _iterate(
647
+ lambda n: self.list(
648
+ product=product, status=status, q=q, updated_since=updated_since,
649
+ page=n, page_size=page_size, timeout=timeout,
650
+ )
651
+ )
652
+
653
+ def get(self, external_id: str, *, timeout: Optional[float] = None) -> KBArticle:
654
+ """Um artigo (com ``body_html``). ``GET /kb/articles/{external_id}``"""
655
+ data, _ = self._t.request(
656
+ "GET", f"/kb/articles/{_article_id(external_id)}", timeout=timeout
657
+ )
658
+ return cast(KBArticle, data)
659
+
660
+ def upsert(
661
+ self,
662
+ external_id: str,
663
+ *,
664
+ title: MaybeUnset[Optional[str]] = UNSET,
665
+ body_html: MaybeUnset[Optional[str]] = UNSET,
666
+ body_markdown: MaybeUnset[Optional[str]] = UNSET,
667
+ product: MaybeUnset[Optional[str]] = UNSET,
668
+ status: MaybeUnset[Optional[str]] = UNSET,
669
+ idempotency_key: Optional[str] = None,
670
+ timeout: Optional[float] = None,
671
+ ) -> KBArticle:
672
+ """Cria ou atualiza um artigo pelo ``external_id`` (sem ``/``; use ``:``).
673
+
674
+ ``PUT /kb/articles/{external_id}``. ``product=None`` (explícito) torna o artigo
675
+ global; ``status="published"`` publica.
676
+ """
677
+ body = compact(
678
+ {
679
+ "title": title,
680
+ "body_html": body_html,
681
+ "body_markdown": body_markdown,
682
+ "product": product,
683
+ "status": status,
684
+ }
685
+ )
686
+ data, _ = self._t.request(
687
+ "PUT", f"/kb/articles/{_article_id(external_id)}", body=body,
688
+ idempotency_key=idempotency_key, timeout=timeout,
689
+ )
690
+ return cast(KBArticle, data)
691
+
692
+ def batch_upsert(
693
+ self,
694
+ articles: Sequence[Union[KBBatchItem, Mapping[str, Any]]],
695
+ *,
696
+ idempotency_key: Optional[str] = None,
697
+ timeout: Optional[float] = None,
698
+ ) -> KBBatchOutcome:
699
+ """Cria/atualiza **qualquer quantidade** de artigos. ``POST /kb/articles/batch``
700
+
701
+ A SDK divide em lotes de 100 (limite da API), envia em sequência e devolve UM
702
+ resultado: ``results`` na ordem enviada e contadores somados. Falha de um item
703
+ não derruba os outros (``ok=False`` + ``error`` no resultado dele).
704
+
705
+ Cada item precisa de ``external_id``; os demais campos seguem a regra do
706
+ :meth:`upsert` (ausente = não muda; ``product: None`` = global).
707
+
708
+ Se um lote falhar por inteiro (rede, 429 esgotado…), a exceção sobe e os lotes
709
+ anteriores já foram aplicados — rodar de novo é seguro (é upsert).
710
+
711
+ Args:
712
+ idempotency_key: O 1º lote usa a chave como veio; os seguintes, ``"<chave>:<n>"``
713
+ (n = 2, 3, …). Sem chave, cada lote gera a sua.
714
+ """
715
+ items: List[Dict[str, Any]] = []
716
+ for index, article in enumerate(articles):
717
+ if not isinstance(article, Mapping):
718
+ raise TypeError(f"articles[{index}] precisa ser um dict.")
719
+ ext = article.get("external_id")
720
+ if not isinstance(ext, str) or not ext:
721
+ raise ValueError(f"articles[{index}]: external_id é obrigatório.")
722
+ if "/" in ext:
723
+ raise ValueError(
724
+ f"articles[{index}]: external_id não aceita '/' — use ':' ({ext!r})"
725
+ )
726
+ items.append(dict(article))
727
+
728
+ outcome: Dict[str, Any] = {
729
+ "results": [], "created": 0, "updated": 0, "unchanged": 0, "failed": 0,
730
+ }
731
+ chunks = [items[i:i + self.BATCH_SIZE] for i in range(0, len(items), self.BATCH_SIZE)]
732
+ for number, chunk in enumerate(chunks, start=1):
733
+ key = idempotency_key
734
+ if key and number > 1:
735
+ key = f"{key}:{number}"
736
+ data, _ = self._t.request(
737
+ "POST", "/kb/articles/batch", body={"articles": chunk},
738
+ idempotency_key=key, timeout=timeout,
739
+ )
740
+ data = data or {}
741
+ for field, value in data.items():
742
+ if field == "results":
743
+ outcome["results"].extend(value or [])
744
+ elif isinstance(value, int) and not isinstance(value, bool):
745
+ outcome[field] = int(outcome.get(field) or 0) + value
746
+ else: # campo novo não numérico: preservado (o último lote vence)
747
+ outcome[field] = value
748
+ return cast(KBBatchOutcome, outcome)
749
+
750
+ def publish(
751
+ self,
752
+ external_id: str,
753
+ *,
754
+ idempotency_key: Optional[str] = None,
755
+ timeout: Optional[float] = None,
756
+ ) -> KBArticle:
757
+ """Publica um artigo. ``POST /kb/articles/{external_id}/publish``"""
758
+ data, _ = self._t.request(
759
+ "POST", f"/kb/articles/{_article_id(external_id)}/publish",
760
+ idempotency_key=idempotency_key, timeout=timeout,
761
+ )
762
+ return cast(KBArticle, data)
763
+
764
+ def unpublish(
765
+ self,
766
+ external_id: str,
767
+ *,
768
+ idempotency_key: Optional[str] = None,
769
+ timeout: Optional[float] = None,
770
+ ) -> KBArticle:
771
+ """Volta um artigo para rascunho. ``POST /kb/articles/{external_id}/unpublish``"""
772
+ data, _ = self._t.request(
773
+ "POST", f"/kb/articles/{_article_id(external_id)}/unpublish",
774
+ idempotency_key=idempotency_key, timeout=timeout,
775
+ )
776
+ return cast(KBArticle, data)
777
+
778
+ def delete(
779
+ self,
780
+ external_id: str,
781
+ *,
782
+ idempotency_key: Optional[str] = None,
783
+ timeout: Optional[float] = None,
784
+ ) -> Deleted:
785
+ """Exclui um artigo. ``DELETE /kb/articles/{external_id}``"""
786
+ data, _ = self._t.request(
787
+ "DELETE", f"/kb/articles/{_article_id(external_id)}",
788
+ idempotency_key=idempotency_key, timeout=timeout,
789
+ )
790
+ return cast(Deleted, data)
791
+
792
+
793
+ class KnowledgeBase(_Resource):
794
+ """Base de conhecimento — ``client.kb`` (artigos em :attr:`articles`)."""
795
+
796
+ def __init__(self, transport: Transport) -> None:
797
+ super().__init__(transport)
798
+ self.articles = KBArticles(transport)
799
+
800
+ def search(
801
+ self,
802
+ q: str,
803
+ *,
804
+ product: Optional[str] = None,
805
+ limit: Optional[int] = None,
806
+ timeout: Optional[float] = None,
807
+ ) -> List[KBSearchHit]:
808
+ """Busca semântica/textual nos artigos publicados. ``GET /kb/search``
809
+
810
+ Args:
811
+ q: Pergunta ou termos.
812
+ product: Slug do produto para restringir.
813
+ limit: Máximo de resultados (padrão da API: 5; máximo 20).
814
+ """
815
+ data, _ = self._t.request(
816
+ "GET", "/kb/search", query={"q": q, "product": product, "limit": limit},
817
+ timeout=timeout,
818
+ )
819
+ return cast(List[KBSearchHit], data)
820
+
821
+
822
+ # ── agentes de IA ───────────────────────────────────────────────────────────────
823
+
824
+
825
+ class AIAgents(_Resource):
826
+ """Agentes de IA — ``client.ai_agents``."""
827
+
828
+ def list(self, *, timeout: Optional[float] = None) -> List[AIAgent]:
829
+ """Agentes de IA da conta. ``GET /ai-agents``"""
830
+ data, _ = self._t.request("GET", "/ai-agents", timeout=timeout)
831
+ return cast(List[AIAgent], data)
832
+
833
+ def get(self, agent_id: str, *, timeout: Optional[float] = None) -> AIAgent:
834
+ """Um agente. ``GET /ai-agents/{agent_id}``"""
835
+ data, _ = self._t.request(
836
+ "GET", f"/ai-agents/{path_segment(agent_id, 'agent_id')}", timeout=timeout
837
+ )
838
+ return cast(AIAgent, data)
839
+
840
+ def preview(
841
+ self,
842
+ agent_id: str,
843
+ message: str,
844
+ *,
845
+ history: MaybeUnset[Sequence[AgentTurn]] = UNSET,
846
+ idempotency_key: Optional[str] = None,
847
+ timeout: Optional[float] = None,
848
+ ) -> AgentPreview:
849
+ """Testa a resposta do agente a uma mensagem (consome IA da conta).
850
+
851
+ ``POST /ai-agents/{agent_id}/preview``
852
+
853
+ Args:
854
+ history: Turnos anteriores, ``[{"role": "customer"|"bot", "content": ...}]``
855
+ (até 20).
856
+ """
857
+ body = compact({"message": message, "history": _dicts(history)})
858
+ data, _ = self._t.request(
859
+ "POST", f"/ai-agents/{path_segment(agent_id, 'agent_id')}/preview", body=body,
860
+ idempotency_key=idempotency_key, timeout=timeout,
861
+ )
862
+ return cast(AgentPreview, data)