atendentepro 0.3.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.
Files changed (39) hide show
  1. atendentepro/README.md +890 -0
  2. atendentepro/__init__.py +215 -0
  3. atendentepro/agents/__init__.py +45 -0
  4. atendentepro/agents/answer.py +62 -0
  5. atendentepro/agents/confirmation.py +69 -0
  6. atendentepro/agents/flow.py +64 -0
  7. atendentepro/agents/interview.py +68 -0
  8. atendentepro/agents/knowledge.py +296 -0
  9. atendentepro/agents/onboarding.py +65 -0
  10. atendentepro/agents/triage.py +57 -0
  11. atendentepro/agents/usage.py +56 -0
  12. atendentepro/config/__init__.py +19 -0
  13. atendentepro/config/settings.py +134 -0
  14. atendentepro/guardrails/__init__.py +21 -0
  15. atendentepro/guardrails/manager.py +419 -0
  16. atendentepro/license.py +502 -0
  17. atendentepro/models/__init__.py +21 -0
  18. atendentepro/models/context.py +21 -0
  19. atendentepro/models/outputs.py +118 -0
  20. atendentepro/network.py +325 -0
  21. atendentepro/prompts/__init__.py +35 -0
  22. atendentepro/prompts/answer.py +114 -0
  23. atendentepro/prompts/confirmation.py +124 -0
  24. atendentepro/prompts/flow.py +112 -0
  25. atendentepro/prompts/interview.py +123 -0
  26. atendentepro/prompts/knowledge.py +135 -0
  27. atendentepro/prompts/onboarding.py +146 -0
  28. atendentepro/prompts/triage.py +42 -0
  29. atendentepro/templates/__init__.py +51 -0
  30. atendentepro/templates/manager.py +530 -0
  31. atendentepro/utils/__init__.py +19 -0
  32. atendentepro/utils/openai_client.py +154 -0
  33. atendentepro/utils/tracing.py +71 -0
  34. atendentepro-0.3.0.dist-info/METADATA +306 -0
  35. atendentepro-0.3.0.dist-info/RECORD +39 -0
  36. atendentepro-0.3.0.dist-info/WHEEL +5 -0
  37. atendentepro-0.3.0.dist-info/entry_points.txt +2 -0
  38. atendentepro-0.3.0.dist-info/licenses/LICENSE +25 -0
  39. atendentepro-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,419 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Guardrails Manager for AtendentePro.
4
+
5
+ Provides scope validation and content filtering for agents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import textwrap
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
15
+
16
+ import yaml
17
+ from pydantic import BaseModel
18
+
19
+ from agents import (
20
+ Agent,
21
+ GuardrailFunctionOutput,
22
+ RunContextWrapper,
23
+ Runner,
24
+ TResponseInputItem,
25
+ input_guardrail,
26
+ )
27
+
28
+ from atendentepro.models import GuardrailValidationOutput
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # Type alias for guardrail callables
34
+ GuardrailCallable = Callable[
35
+ [RunContextWrapper[None], Agent, "Union[str, List[TResponseInputItem]]"],
36
+ Awaitable[GuardrailFunctionOutput],
37
+ ]
38
+
39
+
40
+ # Cache of created guardrails by agent
41
+ _GUARDRAIL_CACHE: Dict[str, List[GuardrailCallable]] = {}
42
+
43
+ # Control of dynamically loaded template
44
+ _GUARDRAIL_TEMPLATE_ROOT: Optional[Path] = None
45
+ _GUARDRAIL_TEMPLATE_NAME: Optional[str] = None
46
+ _DEFAULT_TEMPLATE_FALLBACKS: tuple[str, ...] = ("standard",)
47
+
48
+
49
+ # Examples of out-of-scope questions
50
+ OUT_OF_SCOPE_EXAMPLES = [
51
+ "Qual é o preço do bitcoin?",
52
+ "Distância entre a Terra e a Lua.",
53
+ "Resolva a equação 2x + 5 = 11.",
54
+ "Como programar uma API em Python?",
55
+ "Conte uma piada sobre futebol.",
56
+ ]
57
+
58
+
59
+ class GuardrailManager:
60
+ """
61
+ Manages guardrail configuration and creation for agents.
62
+
63
+ This class provides a high-level interface for loading guardrail
64
+ configurations from YAML files and creating guardrail functions
65
+ for agents.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ templates_root: Optional[Path] = None,
71
+ template_name: Optional[str] = None,
72
+ ):
73
+ """
74
+ Initialize the GuardrailManager.
75
+
76
+ Args:
77
+ templates_root: Root directory for template configurations.
78
+ template_name: Name of the template to use.
79
+ """
80
+ self.templates_root = templates_root
81
+ self.template_name = template_name
82
+ self._cache: Dict[str, List[GuardrailCallable]] = {}
83
+
84
+ def load_config(self) -> Dict[str, Any]:
85
+ """Load guardrail configuration from the configured template."""
86
+ return load_guardrail_config(
87
+ templates_root=self.templates_root,
88
+ template_name=self.template_name,
89
+ )
90
+
91
+ def get_guardrails(self, agent_name: str) -> List[GuardrailCallable]:
92
+ """Get guardrails for a specific agent."""
93
+ if agent_name in self._cache:
94
+ return self._cache[agent_name]
95
+
96
+ guardrails = get_guardrails_for_agent(
97
+ agent_name,
98
+ templates_root=self.templates_root,
99
+ template_name=self.template_name,
100
+ )
101
+ self._cache[agent_name] = guardrails
102
+ return guardrails
103
+
104
+ def clear_cache(self) -> None:
105
+ """Clear the guardrail cache."""
106
+ self._cache.clear()
107
+
108
+
109
+ @lru_cache(maxsize=1)
110
+ def _load_guardrail_config_cached(
111
+ templates_root: Optional[str] = None,
112
+ template_name: Optional[str] = None,
113
+ ) -> Dict[str, Any]:
114
+ """Internal cached config loader."""
115
+ root = Path(templates_root) if templates_root else _GUARDRAIL_TEMPLATE_ROOT
116
+ name = template_name or _GUARDRAIL_TEMPLATE_NAME
117
+
118
+ candidate_paths: List[Path] = []
119
+
120
+ if root and name:
121
+ candidate_paths.append(root / name / "guardrails_config.yaml")
122
+
123
+ if root:
124
+ for folder in _DEFAULT_TEMPLATE_FALLBACKS:
125
+ candidate_paths.append(root / folder / "guardrails_config.yaml")
126
+
127
+ # Deduplicate paths
128
+ unique_candidates: List[Path] = []
129
+ seen: set[Path] = set()
130
+ for path in candidate_paths:
131
+ resolved = path.resolve()
132
+ if resolved in seen:
133
+ continue
134
+ seen.add(resolved)
135
+ unique_candidates.append(path)
136
+
137
+ for path in unique_candidates:
138
+ if path.exists():
139
+ with path.open("r", encoding="utf-8") as file:
140
+ data = yaml.safe_load(file) or {}
141
+ return data if isinstance(data, dict) else {}
142
+
143
+ return {}
144
+
145
+
146
+ def load_guardrail_config(
147
+ templates_root: Optional[Path] = None,
148
+ template_name: Optional[str] = None,
149
+ ) -> Dict[str, Any]:
150
+ """
151
+ Load guardrail configuration from templates.
152
+
153
+ Args:
154
+ templates_root: Root directory for templates.
155
+ template_name: Name of the template to load.
156
+
157
+ Returns:
158
+ Dictionary containing the guardrail configuration.
159
+ """
160
+ root_str = str(templates_root) if templates_root else None
161
+ return _load_guardrail_config_cached(root_str, template_name)
162
+
163
+
164
+ def _normalize_agent_name(agent_name: str) -> str:
165
+ """Normalize agent name for configuration lookup."""
166
+ name = agent_name.strip().lower().replace("-", " ")
167
+ return "_".join(name.split())
168
+
169
+
170
+ def _get_scope_for_agent(
171
+ agent_name: str,
172
+ templates_root: Optional[Path] = None,
173
+ template_name: Optional[str] = None,
174
+ ) -> Optional[str]:
175
+ """Retrieve the 'about' block for an agent from configuration."""
176
+ config = load_guardrail_config(templates_root, template_name)
177
+ scopes = config.get("agent_scopes", {})
178
+ normalized_name = _normalize_agent_name(agent_name)
179
+
180
+ if normalized_name in scopes:
181
+ about = scopes[normalized_name].get("about", "")
182
+ return about.strip() or None
183
+
184
+ # Fallback: try fuzzy matching
185
+ for key, value in scopes.items():
186
+ if _normalize_agent_name(key) == normalized_name:
187
+ about = value.get("about", "")
188
+ return about.strip() or None
189
+
190
+ return None
191
+
192
+
193
+ def get_out_of_scope_message(
194
+ agent_name: str,
195
+ templates_root: Optional[Path] = None,
196
+ template_name: Optional[str] = None,
197
+ ) -> str:
198
+ """
199
+ Get the appropriate 'out of scope' message for an agent.
200
+
201
+ Args:
202
+ agent_name: Name of the agent.
203
+ templates_root: Root directory for templates.
204
+ template_name: Name of the template.
205
+
206
+ Returns:
207
+ The out of scope message for the agent.
208
+ """
209
+ config = load_guardrail_config(templates_root, template_name)
210
+ out_of_scope_messages = config.get("out_of_scope_message", {})
211
+ normalized_name = _normalize_agent_name(agent_name)
212
+
213
+ # Try to find agent-specific message
214
+ if normalized_name in out_of_scope_messages:
215
+ message = out_of_scope_messages[normalized_name]
216
+ if isinstance(message, str) and message.strip():
217
+ return message.strip()
218
+
219
+ # Fall back to default message
220
+ default_message = out_of_scope_messages.get("default", "")
221
+ if isinstance(default_message, str) and default_message.strip():
222
+ return default_message.strip()
223
+
224
+ # Final fallback
225
+ return (
226
+ "Desculpe, mas a pergunta que você fez está fora do escopo dos tópicos que posso abordar. "
227
+ "Por favor, reformule sua pergunta para que ela se enquadre nos assuntos que estou autorizado a responder."
228
+ )
229
+
230
+
231
+ def _build_guardrail_agent(agent_name: str, scope_description: str) -> Agent:
232
+ """Create the agent responsible for scope validation."""
233
+ negative_examples = "\n".join(
234
+ f'- Pergunta: "{example}" -> is_in_scope: false'
235
+ for example in OUT_OF_SCOPE_EXAMPLES
236
+ )
237
+
238
+ instructions = f"""
239
+ Você é um agente de validação que decide se uma solicitação está dentro do escopo do {agent_name}.
240
+
241
+ CONTEXTO DO {agent_name.upper()}:
242
+ {scope_description.strip()}
243
+
244
+ REGRAS:
245
+ 1. Use apenas o contexto fornecido. Não invente ou busque informações externas.
246
+ 2. Considere mensagens neutras de saudação, agradecimento, confirmação ou pedidos para continuar a conversa como dentro do escopo (retorne is_in_scope: true).
247
+ 3. Retorne is_in_scope: false apenas quando a mensagem tratar claramente de assuntos que não têm relação com o escopo acima.
248
+ 4. Perguntas sobre assuntos gerais, entretenimento, ciências exatas, programação ou temas aleatórios devem ser marcadas como is_in_scope: false.
249
+ 5. Responda somente com JSON compatível com o formato {{"is_in_scope": bool, "reasoning": string}} e explique brevemente a decisão.
250
+
251
+ EXEMPLOS FORA DO ESCOPO:
252
+ {negative_examples}
253
+ """
254
+
255
+ instructions = textwrap.dedent(instructions).strip()
256
+
257
+ return Agent(
258
+ name=f"{agent_name} Guardrail",
259
+ instructions=instructions,
260
+ output_type=GuardrailValidationOutput,
261
+ )
262
+
263
+
264
+ def _create_guardrail_callable(
265
+ agent_name: str,
266
+ guardrail_agent: Agent,
267
+ ) -> GuardrailCallable:
268
+ """Build the decorated guardrail function for a specific agent."""
269
+
270
+ def _as_prompt(value: Union[str, List[TResponseInputItem]]) -> str:
271
+ if isinstance(value, str):
272
+ return value
273
+
274
+ collected: List[str] = []
275
+ for item in value:
276
+ if isinstance(item, dict):
277
+ content = item.get("content")
278
+ if isinstance(content, str):
279
+ collected.append(content.strip())
280
+ elif isinstance(content, list):
281
+ for part in content:
282
+ if isinstance(part, dict):
283
+ text_val = part.get("text")
284
+ if isinstance(text_val, str) and text_val.strip():
285
+ collected.append(text_val.strip())
286
+ elif isinstance(text_val, dict):
287
+ inner_value = text_val.get("value")
288
+ if isinstance(inner_value, str) and inner_value.strip():
289
+ collected.append(inner_value.strip())
290
+ elif isinstance(part, str) and part.strip():
291
+ collected.append(part.strip())
292
+ continue
293
+
294
+ text_attr = getattr(item, "input_text", None)
295
+ if isinstance(text_attr, str) and text_attr.strip():
296
+ collected.append(text_attr.strip())
297
+
298
+ content_attr = getattr(item, "content", None)
299
+ if isinstance(content_attr, str):
300
+ collected.append(content_attr.strip())
301
+ elif isinstance(content_attr, list):
302
+ for part in content_attr:
303
+ text_part = getattr(part, "text", None)
304
+ if not text_part:
305
+ continue
306
+ part_value = getattr(text_part, "value", None)
307
+ if isinstance(part_value, str) and part_value.strip():
308
+ collected.append(part_value.strip())
309
+ elif isinstance(text_part, str) and text_part.strip():
310
+ collected.append(text_part.strip())
311
+
312
+ if collected:
313
+ return "\n".join(collected)
314
+
315
+ return str(value)
316
+
317
+ @input_guardrail
318
+ async def guardrail(
319
+ ctx: RunContextWrapper[None],
320
+ agent: Agent,
321
+ input: Union[str, List[TResponseInputItem]],
322
+ ) -> GuardrailFunctionOutput:
323
+ prompt = _as_prompt(input)
324
+
325
+ result = await Runner.run(guardrail_agent, prompt, context=ctx.context)
326
+ output = result.final_output
327
+
328
+ if output is None:
329
+ return GuardrailFunctionOutput(
330
+ output_info=None,
331
+ tripwire_triggered=True,
332
+ )
333
+
334
+ is_in_scope = bool(getattr(output, "is_in_scope", False))
335
+ return GuardrailFunctionOutput(
336
+ output_info=output,
337
+ tripwire_triggered=not is_in_scope,
338
+ )
339
+
340
+ guardrail.__name__ = f"{_normalize_agent_name(agent_name)}_guardrail"
341
+ return guardrail
342
+
343
+
344
+ def get_guardrails_for_agent(
345
+ agent_name: str,
346
+ templates_root: Optional[Path] = None,
347
+ template_name: Optional[str] = None,
348
+ ) -> List[GuardrailCallable]:
349
+ """
350
+ Get the list of guardrails configured for an agent.
351
+
352
+ Args:
353
+ agent_name: Name of the agent (e.g., "Triage Agent", "Flow Agent").
354
+ templates_root: Root directory for templates.
355
+ template_name: Name of the template.
356
+
357
+ Returns:
358
+ List of guardrail callables for the agent.
359
+ """
360
+ cache_key = f"{agent_name}:{templates_root}:{template_name}"
361
+
362
+ if cache_key in _GUARDRAIL_CACHE:
363
+ return _GUARDRAIL_CACHE[cache_key]
364
+
365
+ scope = _get_scope_for_agent(agent_name, templates_root, template_name)
366
+ if not scope:
367
+ _GUARDRAIL_CACHE[cache_key] = []
368
+ return []
369
+
370
+ guardrail_agent = _build_guardrail_agent(agent_name, scope)
371
+ guardrail_callable = _create_guardrail_callable(agent_name, guardrail_agent)
372
+ _GUARDRAIL_CACHE[cache_key] = [guardrail_callable]
373
+ return _GUARDRAIL_CACHE[cache_key]
374
+
375
+
376
+ def set_guardrails_client(
377
+ client_key: str,
378
+ *,
379
+ template_name: Optional[str] = None,
380
+ templates_root: Optional[Path] = None,
381
+ ) -> Dict[str, Any]:
382
+ """
383
+ Update the active guardrails template and clear associated caches.
384
+
385
+ Args:
386
+ client_key: Client identifier.
387
+ template_name: Name of the template folder.
388
+ templates_root: Root directory for templates.
389
+
390
+ Returns:
391
+ The loaded configuration dictionary.
392
+
393
+ Raises:
394
+ FileNotFoundError: If the guardrail config file doesn't exist.
395
+ """
396
+ root = Path(templates_root) if templates_root else None
397
+ template_folder = template_name or client_key
398
+
399
+ if root:
400
+ config_path = root / template_folder / "guardrails_config.yaml"
401
+ if not config_path.exists():
402
+ raise FileNotFoundError(
403
+ f"Guardrail config not found for client '{client_key}' at path '{config_path}'"
404
+ )
405
+
406
+ global _GUARDRAIL_TEMPLATE_ROOT, _GUARDRAIL_TEMPLATE_NAME
407
+ _GUARDRAIL_TEMPLATE_ROOT = root
408
+ _GUARDRAIL_TEMPLATE_NAME = template_folder
409
+
410
+ clear_guardrail_cache()
411
+
412
+ return load_guardrail_config(root, template_folder)
413
+
414
+
415
+ def clear_guardrail_cache() -> None:
416
+ """Clear all guardrail caches."""
417
+ _GUARDRAIL_CACHE.clear()
418
+ _load_guardrail_config_cached.cache_clear()
419
+