atendentepro 0.6.5__py3-none-any.whl → 0.6.7__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.
atendentepro/__init__.py CHANGED
@@ -158,6 +158,13 @@ from atendentepro.utils import (
158
158
  run_with_monkai_tracking,
159
159
  # Application Insights
160
160
  configure_application_insights,
161
+ # User Loader
162
+ create_user_loader,
163
+ run_with_user_context,
164
+ extract_phone_from_messages,
165
+ extract_email_from_messages,
166
+ extract_user_id_from_messages,
167
+ load_user_from_csv,
161
168
  )
162
169
 
163
170
  __all__ = [
@@ -253,5 +260,12 @@ __all__ = [
253
260
  "run_with_monkai_tracking",
254
261
  # Application Insights
255
262
  "configure_application_insights",
263
+ # User Loader
264
+ "create_user_loader",
265
+ "run_with_user_context",
266
+ "extract_phone_from_messages",
267
+ "extract_email_from_messages",
268
+ "extract_user_id_from_messages",
269
+ "load_user_from_csv",
256
270
  ]
257
271
 
@@ -47,6 +47,13 @@ DEFAULT_BUSINESS_HOURS = {
47
47
  "days": [0, 1, 2, 3, 4], # Seg-Sex
48
48
  }
49
49
 
50
+ # Configurable business hours (None = use DEFAULT_BUSINESS_HOURS)
51
+ _configured_business_hours: Optional[Dict[str, Any]] = None
52
+
53
+ # Configurable priority keywords (None = use defaults)
54
+ _priority_keywords_urgent: Optional[List[str]] = None
55
+ _priority_keywords_high: Optional[List[str]] = None
56
+
50
57
 
51
58
  # =============================================================================
52
59
  # Storage de Escalações (em memória - substituir por DB em produção)
@@ -139,22 +146,46 @@ def _notificar_equipe(escalation: Escalation) -> bool:
139
146
 
140
147
  def _verificar_disponibilidade() -> Dict[str, Any]:
141
148
  """Verifica se atendimento humano está disponível."""
149
+ global _configured_business_hours
142
150
  agora = datetime.now()
143
151
  hora = agora.hour
144
152
  dia = agora.weekday()
145
153
 
146
- # Verificar variáveis de ambiente para horário customizado
147
- hora_inicio = int(os.getenv("ESCALATION_HOUR_START", DEFAULT_BUSINESS_HOURS["start"]))
148
- hora_fim = int(os.getenv("ESCALATION_HOUR_END", DEFAULT_BUSINESS_HOURS["end"]))
154
+ # Use configured business hours or fall back to defaults
155
+ if _configured_business_hours:
156
+ business_hours = _configured_business_hours
157
+ hora_inicio = business_hours.get("start", DEFAULT_BUSINESS_HOURS["start"])
158
+ hora_fim = business_hours.get("end", DEFAULT_BUSINESS_HOURS["end"])
159
+ days = business_hours.get("days", DEFAULT_BUSINESS_HOURS["days"])
160
+ else:
161
+ # Verificar variáveis de ambiente para horário customizado
162
+ hora_inicio = int(os.getenv("ESCALATION_HOUR_START", DEFAULT_BUSINESS_HOURS["start"]))
163
+ hora_fim = int(os.getenv("ESCALATION_HOUR_END", DEFAULT_BUSINESS_HOURS["end"]))
164
+ days = DEFAULT_BUSINESS_HOURS["days"]
165
+
166
+ # Ensure days is a list of integers
167
+ if isinstance(days, list):
168
+ days_int = [d if isinstance(d, int) else int(d) for d in days]
169
+ else:
170
+ days_int = DEFAULT_BUSINESS_HOURS["days"]
171
+
172
+ disponivel = dia in days_int and hora_inicio <= hora < hora_fim
149
173
 
150
- disponivel = dia in DEFAULT_BUSINESS_HOURS["days"] and hora_inicio <= hora < hora_fim
174
+ # Format days for display
175
+ day_names = ["Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado", "Domingo"]
176
+ if len(days_int) == 1:
177
+ dias_atendimento = day_names[days_int[0]]
178
+ elif len(days_int) == 5 and days_int == [0, 1, 2, 3, 4]:
179
+ dias_atendimento = "Segunda a Sexta"
180
+ else:
181
+ dias_atendimento = ", ".join(day_names[d] for d in sorted(days_int))
151
182
 
152
183
  return {
153
184
  "disponivel": disponivel,
154
185
  "hora_atual": agora.strftime("%H:%M"),
155
- "dia_semana": ["Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado", "Domingo"][dia],
186
+ "dia_semana": day_names[dia],
156
187
  "horario_atendimento": f"{hora_inicio:02d}:00 - {hora_fim:02d}:00",
157
- "dias_atendimento": "Segunda a Sexta",
188
+ "dias_atendimento": dias_atendimento,
158
189
  }
159
190
 
160
191
 
@@ -166,15 +197,16 @@ def _classificar_prioridade(motivo: str, categoria: str) -> str:
166
197
  """
167
198
  Classifica automaticamente a prioridade baseado no motivo e categoria.
168
199
  """
200
+ global _priority_keywords_urgent, _priority_keywords_high
169
201
  motivo_lower = motivo.lower()
170
202
 
171
- # Palavras que indicam urgência
172
- palavras_urgentes = [
203
+ # Use configured keywords or defaults
204
+ palavras_urgentes = _priority_keywords_urgent or [
173
205
  "urgente", "emergência", "emergencia", "crítico", "critico",
174
206
  "não funciona", "parou", "bloqueado", "cancelar", "prejuízo"
175
207
  ]
176
208
 
177
- palavras_alta = [
209
+ palavras_alta = _priority_keywords_high or [
178
210
  "reclamação", "reclamacao", "insatisfeito", "problema grave",
179
211
  "já tentei", "terceira vez", "não resolve"
180
212
  ]
@@ -449,6 +481,9 @@ ESCALATION_TOOLS = [
449
481
  def create_escalation_agent(
450
482
  escalation_triggers: str = "",
451
483
  escalation_channels: str = "",
484
+ business_hours: Optional[Dict[str, Any]] = None,
485
+ priority_keywords_urgent: Optional[List[str]] = None,
486
+ priority_keywords_high: Optional[List[str]] = None,
452
487
  handoffs: Optional[List] = None,
453
488
  tools: Optional[List] = None,
454
489
  guardrails: Optional[List["GuardrailCallable"]] = None,
@@ -472,6 +507,9 @@ def create_escalation_agent(
472
507
  Args:
473
508
  escalation_triggers: Custom triggers for escalation (keywords, situations).
474
509
  escalation_channels: Available contact channels description.
510
+ business_hours: Optional dict with "start", "end", and "days" (list of weekday numbers 0-6 or day names).
511
+ priority_keywords_urgent: Optional list of keywords that indicate urgent priority.
512
+ priority_keywords_high: Optional list of keywords that indicate high priority.
475
513
  handoffs: List of agents to hand off to (usually triage to return).
476
514
  tools: Additional tools (custom notifications, integrations).
477
515
  guardrails: List of input guardrails.
@@ -492,6 +530,27 @@ def create_escalation_agent(
492
530
  >>> triage.handoffs.append(escalation)
493
531
  >>> flow.handoffs.append(escalation)
494
532
  """
533
+ global _configured_business_hours, _priority_keywords_urgent, _priority_keywords_high
534
+
535
+ # Configure business hours
536
+ if business_hours:
537
+ # Convert day names to weekday numbers if needed
538
+ days = business_hours.get("days", [])
539
+ if days and isinstance(days[0], str):
540
+ day_map = {
541
+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
542
+ "friday": 4, "saturday": 5, "sunday": 6,
543
+ }
544
+ days_int = [day_map.get(day.lower(), day) for day in days if day.lower() in day_map]
545
+ business_hours = {**business_hours, "days": days_int}
546
+ _configured_business_hours = business_hours
547
+ else:
548
+ _configured_business_hours = None
549
+
550
+ # Configure priority keywords
551
+ _priority_keywords_urgent = priority_keywords_urgent
552
+ _priority_keywords_high = priority_keywords_high
553
+
495
554
  if custom_instructions:
496
555
  instructions = f"{RECOMMENDED_PROMPT_PREFIX} {custom_instructions}"
497
556
  else:
@@ -30,6 +30,7 @@ from email.mime.multipart import MIMEMultipart
30
30
  from typing import Optional, List, Dict, Any, TYPE_CHECKING, Callable
31
31
  from dataclasses import dataclass, field
32
32
  from enum import Enum
33
+ from pathlib import Path
33
34
 
34
35
  from agents import Agent, function_tool
35
36
 
@@ -116,14 +117,22 @@ class Ticket:
116
117
 
117
118
 
118
119
  # =============================================================================
119
- # Storage (in-memory, replace with DB in production)
120
+ # Storage (in-memory + JSON file persistence)
120
121
  # =============================================================================
121
122
 
123
+ import json
124
+
122
125
  _tickets_storage: Dict[str, Ticket] = {}
123
126
 
124
127
  # Protocol prefix (can be customized per client)
125
128
  _protocol_prefix: str = "TKT"
126
129
 
130
+ # Allowed ticket types (None = accept any type)
131
+ _allowed_ticket_types: Optional[List[str]] = None
132
+
133
+ # Storage path for persistence
134
+ _storage_path: Optional[Path] = None
135
+
127
136
  # Email configuration
128
137
  _email_config: Dict[str, Any] = {
129
138
  "enabled": True,
@@ -138,6 +147,8 @@ def configure_feedback_storage(
138
147
  email_brand_color: str = "#4A90D9",
139
148
  email_brand_name: str = "Atendimento",
140
149
  email_sla_message: str = "Retornaremos em até 24h úteis.",
150
+ allowed_ticket_types: Optional[List[str]] = None,
151
+ storage_path: Optional[Path] = None,
141
152
  ) -> None:
142
153
  """
143
154
  Configure feedback storage settings.
@@ -147,14 +158,102 @@ def configure_feedback_storage(
147
158
  email_brand_color: Hex color for email template branding
148
159
  email_brand_name: Brand name for email template
149
160
  email_sla_message: SLA message shown in confirmation email
161
+ allowed_ticket_types: List of allowed ticket types (None = accept any)
162
+ storage_path: Path to JSON file for persistence (None = use default or env var)
150
163
  """
151
- global _protocol_prefix, _email_config
164
+ global _protocol_prefix, _email_config, _allowed_ticket_types, _storage_path
152
165
  _protocol_prefix = protocol_prefix
153
166
  _email_config.update({
154
167
  "brand_color": email_brand_color,
155
168
  "brand_name": email_brand_name,
156
169
  "sla_message": email_sla_message,
157
170
  })
171
+ _allowed_ticket_types = allowed_ticket_types
172
+
173
+ # Set storage path from parameter, env var, or default
174
+ if storage_path:
175
+ _storage_path = Path(storage_path)
176
+ else:
177
+ env_path = os.getenv("FEEDBACK_STORAGE_PATH")
178
+ if env_path:
179
+ _storage_path = Path(env_path)
180
+ else:
181
+ # Default: current working directory
182
+ _storage_path = Path.cwd() / "feedback_tickets.json"
183
+
184
+
185
+ def _get_storage_path() -> Path:
186
+ """Get the storage path, initializing default if needed."""
187
+ global _storage_path
188
+ if _storage_path is None:
189
+ env_path = os.getenv("FEEDBACK_STORAGE_PATH")
190
+ if env_path:
191
+ _storage_path = Path(env_path)
192
+ else:
193
+ _storage_path = Path.cwd() / "feedback_tickets.json"
194
+ return _storage_path
195
+
196
+
197
+ def _load_tickets_from_file() -> None:
198
+ """Load tickets from JSON file into memory."""
199
+ global _tickets_storage
200
+ storage_path = _get_storage_path()
201
+
202
+ if not storage_path.exists():
203
+ return
204
+
205
+ try:
206
+ with open(storage_path, "r", encoding="utf-8") as f:
207
+ data = json.load(f)
208
+
209
+ tickets_data = data.get("tickets", {})
210
+ _tickets_storage = {}
211
+
212
+ for protocolo, ticket_data in tickets_data.items():
213
+ # Convert dict back to Ticket dataclass
214
+ ticket_data["data_criacao"] = datetime.fromisoformat(ticket_data["data_criacao"])
215
+ ticket_data["data_atualizacao"] = datetime.fromisoformat(ticket_data["data_atualizacao"])
216
+ _tickets_storage[protocolo.upper()] = Ticket(**ticket_data)
217
+ except Exception as e:
218
+ print(f"[Feedback] ⚠️ Erro ao carregar tickets do arquivo: {e}")
219
+
220
+
221
+ def _save_tickets_to_file() -> None:
222
+ """Save tickets from memory to JSON file."""
223
+ global _tickets_storage
224
+ storage_path = _get_storage_path()
225
+
226
+ try:
227
+ # Convert tickets to dict for JSON serialization
228
+ tickets_data = {}
229
+ for protocolo, ticket in _tickets_storage.items():
230
+ ticket_dict = {
231
+ "protocolo": ticket.protocolo,
232
+ "tipo": ticket.tipo,
233
+ "descricao": ticket.descricao,
234
+ "email_usuario": ticket.email_usuario,
235
+ "nome_usuario": ticket.nome_usuario,
236
+ "telefone_usuario": ticket.telefone_usuario,
237
+ "prioridade": ticket.prioridade,
238
+ "status": ticket.status,
239
+ "categoria": ticket.categoria,
240
+ "data_criacao": ticket.data_criacao.isoformat(),
241
+ "data_atualizacao": ticket.data_atualizacao.isoformat(),
242
+ "resposta": ticket.resposta,
243
+ "atendente": ticket.atendente,
244
+ "historico": ticket.historico,
245
+ }
246
+ tickets_data[protocolo.upper()] = ticket_dict
247
+
248
+ data = {"tickets": tickets_data}
249
+
250
+ # Ensure directory exists
251
+ storage_path.parent.mkdir(parents=True, exist_ok=True)
252
+
253
+ with open(storage_path, "w", encoding="utf-8") as f:
254
+ json.dump(data, f, indent=2, ensure_ascii=False)
255
+ except Exception as e:
256
+ print(f"[Feedback] ⚠️ Erro ao salvar tickets no arquivo: {e}")
158
257
 
159
258
 
160
259
  def _gerar_protocolo() -> str:
@@ -165,17 +264,25 @@ def _gerar_protocolo() -> str:
165
264
 
166
265
 
167
266
  def _salvar_ticket(ticket: Ticket) -> None:
168
- """Save ticket to storage."""
267
+ """Save ticket to storage (memory + file)."""
169
268
  _tickets_storage[ticket.protocolo.upper()] = ticket
269
+ _save_tickets_to_file()
170
270
 
171
271
 
172
272
  def _buscar_ticket(protocolo: str) -> Optional[Ticket]:
173
273
  """Find ticket by protocol."""
274
+ # Load from file if storage is empty
275
+ if not _tickets_storage:
276
+ _load_tickets_from_file()
174
277
  return _tickets_storage.get(protocolo.upper())
175
278
 
176
279
 
177
280
  def _listar_tickets(email: Optional[str] = None, status: Optional[str] = None) -> List[Ticket]:
178
281
  """List tickets, optionally filtered."""
282
+ # Load from file if storage is empty
283
+ if not _tickets_storage:
284
+ _load_tickets_from_file()
285
+
179
286
  tickets = list(_tickets_storage.values())
180
287
 
181
288
  if email:
@@ -456,20 +563,17 @@ def criar_ticket(
456
563
  Returns:
457
564
  Confirmação com número do protocolo e detalhes
458
565
  """
459
- # Validar tipo
460
- tipos_validos = ["duvida", "feedback", "reclamacao", "sugestao", "elogio", "problema", "outro"]
566
+ # Validar tipo (se allowed_ticket_types estiver configurado)
461
567
  tipo_norm = tipo.lower().strip().replace("ã", "a").replace("ç", "c")
462
568
 
463
- if tipo_norm not in tipos_validos:
569
+ if _allowed_ticket_types is not None and tipo_norm not in _allowed_ticket_types:
570
+ tipos_list = "\n".join(f"- `{t}`" for t in _allowed_ticket_types)
464
571
  return f"""❌ **Tipo inválido:** {tipo}
465
572
 
466
573
  📋 **Tipos aceitos:**
467
- - `duvida` - Pergunta que precisa de pesquisa
468
- - `feedback` - Opinião sobre produto/serviço
469
- - `reclamacao` - Reclamação formal
470
- - `sugestao` - Sugestão de melhoria
471
- - `elogio` - Elogio ou agradecimento
472
- - `problema` - Problema técnico"""
574
+ {tipos_list}
575
+
576
+ Por favor, escolha um dos tipos acima."""
473
577
 
474
578
  # Validar descrição
475
579
  if not descricao or len(descricao.strip()) < 10:
@@ -940,6 +1044,7 @@ def create_feedback_agent(
940
1044
  email_brand_color=email_brand_color,
941
1045
  email_brand_name=email_brand_name,
942
1046
  email_sla_message=email_sla_message,
1047
+ allowed_ticket_types=ticket_types,
943
1048
  )
944
1049
 
945
1050
  # Build instructions using prompt builder
atendentepro/network.py CHANGED
@@ -123,6 +123,9 @@ from atendentepro.templates import (
123
123
  load_confirmation_config,
124
124
  load_onboarding_config,
125
125
  load_access_config,
126
+ load_feedback_config,
127
+ load_escalation_config,
128
+ load_answer_config,
126
129
  AccessConfig,
127
130
  AccessFilterConfig,
128
131
  )
@@ -157,6 +160,10 @@ class AgentNetwork:
157
160
  templates_root: Optional[Path] = None
158
161
  current_client: str = "standard"
159
162
 
163
+ # User loading
164
+ user_loader: Optional[Callable[[List], Optional["UserContext"]]] = None
165
+ loaded_user_context: Optional["UserContext"] = None
166
+
160
167
  def get_all_agents(self) -> List:
161
168
  """Get list of all configured agents."""
162
169
  agents = []
@@ -216,6 +223,9 @@ def create_standard_network(
216
223
  agent_filters: Optional[Dict[str, AccessFilter]] = None,
217
224
  conditional_prompts: Optional[Dict[str, List[FilteredPromptSection]]] = None,
218
225
  filtered_tools: Optional[Dict[str, List[FilteredTool]]] = None,
226
+ # User loading
227
+ user_loader: Optional[Callable[[List], Optional[UserContext]]] = None,
228
+ auto_load_user: bool = False,
219
229
  ) -> AgentNetwork:
220
230
  """
221
231
  Create a standard agent network with proper handoff configuration.
@@ -259,6 +269,10 @@ def create_standard_network(
259
269
  agent_filters: Dict mapping agent names to AccessFilter (controls agent access).
260
270
  conditional_prompts: Dict mapping agent names to list of FilteredPromptSection.
261
271
  filtered_tools: Dict mapping agent names to list of FilteredTool.
272
+ user_loader: Optional function to load user data from messages. Receives list of
273
+ messages and returns UserContext or None. See create_user_loader().
274
+ auto_load_user: If True, automatically loads user context before agent execution.
275
+ Requires user_loader to be configured.
262
276
 
263
277
  Returns:
264
278
  Configured AgentNetwork instance.
@@ -438,6 +452,39 @@ def create_standard_network(
438
452
  confirmation_template = ""
439
453
  confirmation_format = ""
440
454
 
455
+ # Load feedback config
456
+ try:
457
+ feedback_config = load_feedback_config(client)
458
+ feedback_protocol_prefix = feedback_config.protocol_prefix
459
+ feedback_email_config = feedback_config.email
460
+ feedback_ticket_types = feedback_config.get_ticket_type_names()
461
+ except FileNotFoundError:
462
+ feedback_protocol_prefix = feedback_protocol_prefix # Use parameter default
463
+ feedback_email_config = {}
464
+ feedback_ticket_types = None
465
+
466
+ # Load escalation config
467
+ escalation_config_loaded = False
468
+ try:
469
+ escalation_config = load_escalation_config(client)
470
+ escalation_business_hours = escalation_config.business_hours
471
+ escalation_priority_keywords = escalation_config.priority
472
+ escalation_triggers = escalation_config.triggers
473
+ escalation_channels_config = escalation_config.channels
474
+ escalation_config_loaded = True
475
+ except FileNotFoundError:
476
+ escalation_business_hours = None
477
+ escalation_priority_keywords = {}
478
+ escalation_triggers = {}
479
+ escalation_channels_config = None
480
+
481
+ # Load answer config
482
+ try:
483
+ answer_config = load_answer_config(client)
484
+ answer_template = answer_config.get_answer_template()
485
+ except FileNotFoundError:
486
+ answer_template = ""
487
+
441
488
  # Load guardrails
442
489
  try:
443
490
  set_guardrails_client(client, templates_root=templates_root)
@@ -507,7 +554,7 @@ def create_standard_network(
507
554
  answer = None
508
555
  if include_answer and is_agent_allowed("answer"):
509
556
  answer = create_answer_agent(
510
- answer_template="",
557
+ answer_template=answer_template,
511
558
  guardrails=get_guardrails_for_agent("Answer Agent", templates_root),
512
559
  style_instructions=get_full_instructions("answer"),
513
560
  single_reply=get_single_reply_for_agent("answer"),
@@ -550,8 +597,44 @@ def create_standard_network(
550
597
  # Create escalation agent if requested and allowed
551
598
  escalation = None
552
599
  if include_escalation and is_agent_allowed("escalation"):
600
+ # Format escalation triggers from config
601
+ triggers_text = ""
602
+ if escalation_triggers:
603
+ trigger_parts = []
604
+ if escalation_triggers.get("explicit_request"):
605
+ trigger_parts.append(f"Pedidos explícitos: {', '.join(escalation_triggers['explicit_request'])}")
606
+ if escalation_triggers.get("frustration"):
607
+ trigger_parts.append(f"Indicadores de frustração: {', '.join(escalation_triggers['frustration'])}")
608
+ if escalation_triggers.get("topics_requiring_human"):
609
+ trigger_parts.append(f"Tópicos que requerem humano: {', '.join(escalation_triggers['topics_requiring_human'])}")
610
+ triggers_text = "\n".join(trigger_parts) if trigger_parts else ""
611
+
612
+ # Format escalation channels from config or use parameter
613
+ if escalation_config_loaded and escalation_channels_config:
614
+ # Format from config dict
615
+ channel_parts = []
616
+ if escalation_channels_config.get("phone", {}).get("enabled"):
617
+ phone = escalation_channels_config["phone"]
618
+ channel_parts.append(f"Telefone: {phone.get('number', '')} ({phone.get('hours', '')})")
619
+ if escalation_channels_config.get("email", {}).get("enabled"):
620
+ email = escalation_channels_config["email"]
621
+ channel_parts.append(f"Email: {email.get('address', '')} ({email.get('sla', '')})")
622
+ if escalation_channels_config.get("whatsapp", {}).get("enabled"):
623
+ whatsapp = escalation_channels_config["whatsapp"]
624
+ channel_parts.append(f"WhatsApp: {whatsapp.get('number', '')} ({whatsapp.get('hours', '')})")
625
+ if channel_parts:
626
+ channels_text = " | ".join(channel_parts)
627
+ else:
628
+ channels_text = escalation_channels # Use parameter default
629
+ else:
630
+ channels_text = escalation_channels # Use parameter default
631
+
553
632
  escalation = create_escalation_agent(
554
- escalation_channels=escalation_channels,
633
+ escalation_triggers=triggers_text,
634
+ escalation_channels=channels_text if isinstance(channels_text, str) else escalation_channels,
635
+ business_hours=escalation_business_hours,
636
+ priority_keywords_urgent=escalation_priority_keywords.get("urgent"),
637
+ priority_keywords_high=escalation_priority_keywords.get("high"),
555
638
  tools=get_filtered_tools_for_agent("escalation", tools.get("escalation", [])),
556
639
  guardrails=get_guardrails_for_agent("Escalation Agent", templates_root),
557
640
  style_instructions=get_full_instructions("escalation"),
@@ -561,10 +644,17 @@ def create_standard_network(
561
644
  # Create feedback agent if requested and allowed
562
645
  feedback = None
563
646
  if include_feedback and is_agent_allowed("feedback"):
647
+ # Use email config from YAML if available, otherwise use parameters
648
+ email_color = feedback_email_config.get("brand_color", feedback_brand_color)
649
+ email_name = feedback_email_config.get("brand_name", feedback_brand_name)
650
+ email_sla = feedback_email_config.get("sla_message", "Retornaremos em até 24h úteis.")
651
+
564
652
  feedback = create_feedback_agent(
565
653
  protocol_prefix=feedback_protocol_prefix,
566
- email_brand_color=feedback_brand_color,
567
- email_brand_name=feedback_brand_name,
654
+ email_brand_color=email_color,
655
+ email_brand_name=email_name,
656
+ email_sla_message=email_sla,
657
+ ticket_types=feedback_ticket_types,
568
658
  tools=get_filtered_tools_for_agent("feedback", tools.get("feedback", [])),
569
659
  guardrails=get_guardrails_for_agent("Feedback Agent", templates_root),
570
660
  style_instructions=get_full_instructions("feedback"),
@@ -686,6 +776,9 @@ def create_standard_network(
686
776
  network.onboarding = onboarding
687
777
  onboarding.handoffs = [triage]
688
778
 
779
+ # Store user_loader in network for later use
780
+ network.user_loader = user_loader
781
+
689
782
  return network
690
783
 
691
784
 
@@ -561,6 +561,112 @@ class AccessConfig(BaseModel):
561
561
  return self.tool_access.get(tool_name)
562
562
 
563
563
 
564
+ class FeedbackConfig(BaseModel):
565
+ """Configuration model for Feedback Agent."""
566
+
567
+ protocol_prefix: str = "TKT"
568
+ ticket_types: List[Dict[str, Any]] = Field(default_factory=list)
569
+ priorities: List[Dict[str, Any]] = Field(default_factory=list)
570
+ email: Dict[str, Any] = Field(default_factory=dict)
571
+ messages: Dict[str, str] = Field(default_factory=dict)
572
+ auto_priority: Dict[str, Any] = Field(default_factory=dict)
573
+ categories: List[str] = Field(default_factory=list)
574
+
575
+ @classmethod
576
+ @lru_cache(maxsize=4)
577
+ def load(cls, path: Path) -> "FeedbackConfig":
578
+ """Load feedback configuration from YAML file."""
579
+ if not path.exists():
580
+ raise FileNotFoundError(f"Feedback config not found at {path}")
581
+
582
+ with open(path, "r", encoding="utf-8") as f:
583
+ data = yaml.safe_load(f) or {}
584
+
585
+ return cls(
586
+ protocol_prefix=data.get("protocol_prefix", "TKT"),
587
+ ticket_types=data.get("ticket_types", []),
588
+ priorities=data.get("priorities", []),
589
+ email=data.get("email", {}),
590
+ messages=data.get("messages", {}),
591
+ auto_priority=data.get("auto_priority", {}),
592
+ categories=data.get("categories", []),
593
+ )
594
+
595
+ def get_ticket_type_names(self) -> List[str]:
596
+ """Get list of allowed ticket type names."""
597
+ return [t.get("name", "") for t in self.ticket_types if t.get("name")]
598
+
599
+
600
+ class EscalationConfig(BaseModel):
601
+ """Configuration model for Escalation Agent."""
602
+
603
+ triggers: Dict[str, Any] = Field(default_factory=dict)
604
+ channels: Dict[str, Any] = Field(default_factory=dict)
605
+ business_hours: Dict[str, Any] = Field(default_factory=dict)
606
+ priority: Dict[str, Any] = Field(default_factory=dict)
607
+ messages: Dict[str, str] = Field(default_factory=dict)
608
+ notifications: Dict[str, Any] = Field(default_factory=dict)
609
+
610
+ @classmethod
611
+ @lru_cache(maxsize=4)
612
+ def load(cls, path: Path) -> "EscalationConfig":
613
+ """Load escalation configuration from YAML file."""
614
+ if not path.exists():
615
+ raise FileNotFoundError(f"Escalation config not found at {path}")
616
+
617
+ with open(path, "r", encoding="utf-8") as f:
618
+ data = yaml.safe_load(f) or {}
619
+
620
+ return cls(
621
+ triggers=data.get("triggers", {}),
622
+ channels=data.get("channels", {}),
623
+ business_hours=data.get("business_hours", {}),
624
+ priority=data.get("priority", {}),
625
+ messages=data.get("messages", {}),
626
+ notifications=data.get("notifications", {}),
627
+ )
628
+
629
+ def get_business_hours_days(self) -> List[int]:
630
+ """Convert YAML day names to weekday numbers (0=Monday, 6=Sunday)."""
631
+ day_map = {
632
+ "monday": 0,
633
+ "tuesday": 1,
634
+ "wednesday": 2,
635
+ "thursday": 3,
636
+ "friday": 4,
637
+ "saturday": 5,
638
+ "sunday": 6,
639
+ }
640
+ days = self.business_hours.get("days", [])
641
+ return [day_map.get(day.lower(), day) for day in days if isinstance(day, str) and day.lower() in day_map]
642
+
643
+
644
+ class AnswerConfig(BaseModel):
645
+ """Configuration model for Answer Agent."""
646
+
647
+ instructions: str = ""
648
+ response_format: Dict[str, Any] = Field(default_factory=dict)
649
+
650
+ @classmethod
651
+ @lru_cache(maxsize=4)
652
+ def load(cls, path: Path) -> "AnswerConfig":
653
+ """Load answer configuration from YAML file."""
654
+ if not path.exists():
655
+ raise FileNotFoundError(f"Answer config not found at {path}")
656
+
657
+ with open(path, "r", encoding="utf-8") as f:
658
+ data = yaml.safe_load(f) or {}
659
+
660
+ return cls(
661
+ instructions=data.get("instructions", ""),
662
+ response_format=data.get("response_format", {}),
663
+ )
664
+
665
+ def get_answer_template(self) -> str:
666
+ """Get answer template from instructions."""
667
+ return self.instructions
668
+
669
+
564
670
  class OnboardingConfig(BaseModel):
565
671
  """Configuration model for Onboarding Agent."""
566
672
 
@@ -694,6 +800,21 @@ class TemplateManager:
694
800
  folder = self.get_template_folder(client)
695
801
  return AccessConfig.load(folder / "access_config.yaml")
696
802
 
803
+ def load_feedback_config(self, client: Optional[str] = None) -> FeedbackConfig:
804
+ """Load feedback configuration for the specified client."""
805
+ folder = self.get_template_folder(client)
806
+ return FeedbackConfig.load(folder / "feedback_config.yaml")
807
+
808
+ def load_escalation_config(self, client: Optional[str] = None) -> EscalationConfig:
809
+ """Load escalation configuration for the specified client."""
810
+ folder = self.get_template_folder(client)
811
+ return EscalationConfig.load(folder / "escalation_config.yaml")
812
+
813
+ def load_answer_config(self, client: Optional[str] = None) -> AnswerConfig:
814
+ """Load answer configuration for the specified client."""
815
+ folder = self.get_template_folder(client)
816
+ return AnswerConfig.load(folder / "answer_config.yaml")
817
+
697
818
  def clear_caches(self) -> None:
698
819
  """Clear all configuration caches."""
699
820
  FlowConfig.load.cache_clear()
@@ -705,6 +826,9 @@ class TemplateManager:
705
826
  StyleConfig.load.cache_clear()
706
827
  SingleReplyConfig.load.cache_clear()
707
828
  AccessConfig.load.cache_clear()
829
+ FeedbackConfig.load.cache_clear()
830
+ EscalationConfig.load.cache_clear()
831
+ AnswerConfig.load.cache_clear()
708
832
 
709
833
 
710
834
  def get_template_manager() -> TemplateManager:
@@ -808,3 +932,18 @@ def load_access_config(client: Optional[str] = None) -> AccessConfig:
808
932
  """Load access configuration for the specified client."""
809
933
  return get_template_manager().load_access_config(client)
810
934
 
935
+
936
+ def load_feedback_config(client: Optional[str] = None) -> FeedbackConfig:
937
+ """Load feedback configuration for the specified client."""
938
+ return get_template_manager().load_feedback_config(client)
939
+
940
+
941
+ def load_escalation_config(client: Optional[str] = None) -> EscalationConfig:
942
+ """Load escalation configuration for the specified client."""
943
+ return get_template_manager().load_escalation_config(client)
944
+
945
+
946
+ def load_answer_config(client: Optional[str] = None) -> AnswerConfig:
947
+ """Load answer configuration for the specified client."""
948
+ return get_template_manager().load_answer_config(client)
949
+
@@ -19,6 +19,14 @@ from .tracing import (
19
19
  # Legacy
20
20
  configure_tracing,
21
21
  )
22
+ from .user_loader import (
23
+ create_user_loader,
24
+ run_with_user_context,
25
+ extract_phone_from_messages,
26
+ extract_email_from_messages,
27
+ extract_user_id_from_messages,
28
+ load_user_from_csv,
29
+ )
22
30
 
23
31
  __all__ = [
24
32
  # OpenAI Client
@@ -36,5 +44,12 @@ __all__ = [
36
44
  "configure_application_insights",
37
45
  # Legacy
38
46
  "configure_tracing",
47
+ # User Loader
48
+ "create_user_loader",
49
+ "run_with_user_context",
50
+ "extract_phone_from_messages",
51
+ "extract_email_from_messages",
52
+ "extract_user_id_from_messages",
53
+ "load_user_from_csv",
39
54
  ]
40
55
 
@@ -0,0 +1,335 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ User Loader utilities for AtendentePro.
4
+
5
+ Provides functions to load user data from various sources and create UserContext
6
+ objects for enriching agent conversations with user information.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Dict, List, Optional
15
+
16
+ from atendentepro.models import UserContext
17
+
18
+
19
+ def extract_phone_from_messages(messages: List[Dict[str, Any]]) -> Optional[str]:
20
+ """
21
+ Extract phone number from user messages.
22
+
23
+ Searches for phone patterns in the first user message.
24
+ Supports formats: (11) 99999-9999, 11999999999, +55 11 99999-9999, etc.
25
+
26
+ Args:
27
+ messages: List of message dictionaries with 'role' and 'content' keys.
28
+
29
+ Returns:
30
+ Phone number string if found, None otherwise.
31
+
32
+ Example:
33
+ >>> messages = [{"role": "user", "content": "Meu telefone é (11) 99999-8888"}]
34
+ >>> extract_phone_from_messages(messages)
35
+ '11999998888'
36
+ """
37
+ if not messages:
38
+ return None
39
+
40
+ # Find first user message
41
+ user_message = None
42
+ for msg in messages:
43
+ if msg.get("role") == "user" and msg.get("content"):
44
+ user_message = msg["content"]
45
+ break
46
+
47
+ if not user_message:
48
+ return None
49
+
50
+ # Phone patterns: (XX) XXXXX-XXXX, XX XXXXXXXX, +55 XX XXXXXXXX, etc.
51
+ phone_patterns = [
52
+ r'\(?(\d{2})\)?\s*(\d{4,5})-?(\d{4})', # (11) 99999-8888 or 11 99999-8888
53
+ r'\+?55\s*(\d{2})\s*(\d{4,5})-?(\d{4})', # +55 11 99999-8888
54
+ r'(\d{10,11})', # 11999998888
55
+ ]
56
+
57
+ for pattern in phone_patterns:
58
+ match = re.search(pattern, user_message)
59
+ if match:
60
+ # Extract digits only
61
+ digits = re.sub(r'\D', '', match.group(0))
62
+ # Normalize to 10 or 11 digits (with or without area code)
63
+ if len(digits) >= 10:
64
+ return digits[-10:] if len(digits) > 10 else digits
65
+
66
+ return None
67
+
68
+
69
+ def extract_email_from_messages(messages: List[Dict[str, Any]]) -> Optional[str]:
70
+ """
71
+ Extract email address from user messages.
72
+
73
+ Searches for email patterns in user messages.
74
+
75
+ Args:
76
+ messages: List of message dictionaries with 'role' and 'content' keys.
77
+
78
+ Returns:
79
+ Email address string if found, None otherwise.
80
+
81
+ Example:
82
+ >>> messages = [{"role": "user", "content": "Meu email é joao@example.com"}]
83
+ >>> extract_email_from_messages(messages)
84
+ 'joao@example.com'
85
+ """
86
+ if not messages:
87
+ return None
88
+
89
+ # Find first user message
90
+ user_message = None
91
+ for msg in messages:
92
+ if msg.get("role") == "user" and msg.get("content"):
93
+ user_message = msg["content"]
94
+ break
95
+
96
+ if not user_message:
97
+ return None
98
+
99
+ # Email pattern
100
+ email_pattern = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b'
101
+ match = re.search(email_pattern, user_message)
102
+
103
+ if match:
104
+ return match.group(0).lower()
105
+
106
+ return None
107
+
108
+
109
+ def extract_user_id_from_messages(messages: List[Dict[str, Any]]) -> Optional[str]:
110
+ """
111
+ Extract user ID from user messages.
112
+
113
+ Looks for common patterns like "user_id:", "id:", "CPF:", etc.
114
+
115
+ Args:
116
+ messages: List of message dictionaries with 'role' and 'content' keys.
117
+
118
+ Returns:
119
+ User ID string if found, None otherwise.
120
+
121
+ Example:
122
+ >>> messages = [{"role": "user", "content": "Meu CPF é 123.456.789-00"}]
123
+ >>> extract_user_id_from_messages(messages)
124
+ '12345678900'
125
+ """
126
+ if not messages:
127
+ return None
128
+
129
+ # Find first user message
130
+ user_message = None
131
+ for msg in messages:
132
+ if msg.get("role") == "user" and msg.get("content"):
133
+ user_message = msg["content"]
134
+ break
135
+
136
+ if not user_message:
137
+ return None
138
+
139
+ # Patterns for user IDs
140
+ patterns = [
141
+ (r'(?:user[_\s]?id|id[_\s]?usuario|cpf|documento)[:\s]+([\d\.\-\/]+)', lambda m: re.sub(r'\D', '', m.group(1))),
142
+ (r'([\d]{11})', lambda m: m.group(1)), # CPF-like (11 digits)
143
+ (r'([\d]{14})', lambda m: m.group(1)), # CNPJ-like (14 digits)
144
+ ]
145
+
146
+ for pattern, extractor in patterns:
147
+ match = re.search(pattern, user_message, re.IGNORECASE)
148
+ if match:
149
+ return extractor(match)
150
+
151
+ return None
152
+
153
+
154
+ def load_user_from_csv(
155
+ csv_path: Path,
156
+ identifier_field: str,
157
+ identifier_value: str,
158
+ ) -> Optional[Dict[str, Any]]:
159
+ """
160
+ Load user data from a CSV file.
161
+
162
+ Args:
163
+ csv_path: Path to the CSV file.
164
+ identifier_field: Name of the column to search (e.g., "email", "telefone", "cpf").
165
+ identifier_value: Value to search for in the identifier_field column.
166
+
167
+ Returns:
168
+ Dictionary with user data if found, None otherwise.
169
+
170
+ Example:
171
+ >>> from pathlib import Path
172
+ >>> user_data = load_user_from_csv(
173
+ ... Path("users.csv"),
174
+ ... "email",
175
+ ... "joao@example.com"
176
+ ... )
177
+ >>> if user_data:
178
+ ... print(user_data["nome"])
179
+ """
180
+ if not csv_path.exists():
181
+ return None
182
+
183
+ try:
184
+ with open(csv_path, 'r', encoding='utf-8') as f:
185
+ reader = csv.DictReader(f)
186
+
187
+ # Normalize identifier value (remove formatting)
188
+ normalized_value = re.sub(r'\D', '', str(identifier_value).lower())
189
+
190
+ for row in reader:
191
+ # Get value from identifier field
192
+ field_value = row.get(identifier_field, '')
193
+ # Normalize for comparison
194
+ normalized_field = re.sub(r'\D', '', str(field_value).lower())
195
+
196
+ if normalized_field == normalized_value:
197
+ # Return all row data as dict
198
+ return dict(row)
199
+
200
+ except Exception:
201
+ return None
202
+
203
+ return None
204
+
205
+
206
+ def create_user_loader(
207
+ loader_func: Callable[[str], Optional[Dict[str, Any]]],
208
+ identifier_extractor: Optional[Callable[[List[Dict[str, Any]]], Optional[str]]] = None,
209
+ ) -> Callable[[List[Dict[str, Any]]], Optional[UserContext]]:
210
+ """
211
+ Create a user loader function.
212
+
213
+ Factory function that creates a callable that extracts user identifier from
214
+ messages and loads user data using the provided loader function.
215
+
216
+ Args:
217
+ loader_func: Function that receives an identifier (str) and returns
218
+ user data dict or None if not found.
219
+ identifier_extractor: Optional function to extract identifier from messages.
220
+ If None, tries common extractors (phone, email, user_id).
221
+
222
+ Returns:
223
+ Function that receives messages and returns UserContext or None.
224
+
225
+ Example:
226
+ >>> def load_from_db(identifier: str) -> Optional[Dict]:
227
+ ... # Your database lookup logic
228
+ ... return {"user_id": "123", "role": "cliente", "nome": "João"}
229
+ ...
230
+ >>> loader = create_user_loader(
231
+ ... loader_func=load_from_db,
232
+ ... identifier_extractor=extract_email_from_messages
233
+ ... )
234
+ >>>
235
+ >>> messages = [{"role": "user", "content": "joao@example.com"}]
236
+ >>> user_context = loader(messages)
237
+ >>> if user_context:
238
+ ... print(user_context.user_id)
239
+ """
240
+ def load_user(messages: List[Dict[str, Any]]) -> Optional[UserContext]:
241
+ """
242
+ Load user context from messages.
243
+
244
+ Args:
245
+ messages: List of message dictionaries.
246
+
247
+ Returns:
248
+ UserContext if user found, None otherwise.
249
+ """
250
+ # Extract identifier
251
+ identifier = None
252
+ if identifier_extractor:
253
+ identifier = identifier_extractor(messages)
254
+ else:
255
+ # Try common extractors in order
256
+ identifier = (
257
+ extract_phone_from_messages(messages) or
258
+ extract_email_from_messages(messages) or
259
+ extract_user_id_from_messages(messages)
260
+ )
261
+
262
+ if not identifier:
263
+ return None
264
+
265
+ # Load user data
266
+ user_data = loader_func(identifier)
267
+ if not user_data:
268
+ return None
269
+
270
+ # Create UserContext
271
+ return UserContext(
272
+ user_id=user_data.get("user_id") or identifier,
273
+ role=user_data.get("role"),
274
+ metadata={
275
+ **user_data, # Include all user data in metadata
276
+ "loaded_from": "user_loader",
277
+ }
278
+ )
279
+
280
+ return load_user
281
+
282
+
283
+ async def run_with_user_context(
284
+ network: Any,
285
+ agent: Any,
286
+ messages: List[Dict[str, Any]],
287
+ ) -> Any:
288
+ """
289
+ Run agent with automatic user context loading.
290
+
291
+ This function automatically loads user data if a user_loader is configured
292
+ in the network, enriches the network's loaded_user_context, and then runs
293
+ the agent normally.
294
+
295
+ Args:
296
+ network: AgentNetwork instance with optional user_loader configured.
297
+ agent: Agent instance to run.
298
+ messages: List of message dictionaries.
299
+
300
+ Returns:
301
+ RunResult from the agent execution.
302
+
303
+ Example:
304
+ >>> from agents import Runner
305
+ >>> from atendentepro import create_standard_network, create_user_loader
306
+ >>> from pathlib import Path
307
+ >>>
308
+ >>> def load_user(identifier: str):
309
+ ... # Your loading logic
310
+ ... return {"user_id": identifier, "role": "cliente"}
311
+ >>>
312
+ >>> loader = create_user_loader(load_user)
313
+ >>> network = create_standard_network(
314
+ ... templates_root=Path("templates"),
315
+ ... user_loader=loader
316
+ ... )
317
+ >>>
318
+ >>> messages = [{"role": "user", "content": "Olá!"}]
319
+ >>> result = await run_with_user_context(network, network.triage, messages)
320
+ >>> print(result.final_output)
321
+ """
322
+ from agents import Runner
323
+
324
+ # Load user if loader is configured
325
+ if hasattr(network, 'user_loader') and network.user_loader:
326
+ try:
327
+ user_context = network.user_loader(messages)
328
+ if user_context:
329
+ network.loaded_user_context = user_context
330
+ except Exception:
331
+ # Silently fail if loading fails - don't break the conversation
332
+ pass
333
+
334
+ # Run agent normally
335
+ return await Runner.run(agent, messages)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atendentepro
3
- Version: 0.6.5
3
+ Version: 0.6.7
4
4
  Summary: Framework de orquestração de agentes IA com tom e estilo customizáveis. Integra documentos (RAG), APIs e bancos de dados em uma plataforma inteligente multi-agente.
5
5
  Author-email: BeMonkAI <contato@monkai.com.br>
6
6
  Maintainer-email: BeMonkAI <contato@monkai.com.br>
@@ -96,6 +96,7 @@ Plataforma que unifica múltiplos agentes especializados para resolver demandas
96
96
  - [Estilo de Comunicação](#-estilo-de-comunicação-agentstyle)
97
97
  - [Single Reply Mode](#-single-reply-mode)
98
98
  - [Filtros de Acesso](#-filtros-de-acesso-roleuser)
99
+ - [Carregamento de Usuários](#-carregamento-de-usuários-user-loader)
99
100
  - [Múltiplos Agentes](#-múltiplos-agentes-multi-interview--knowledge)
100
101
  - [Tracing e Monitoramento](#-tracing-e-monitoramento)
101
102
  - [Suporte](#-suporte)
@@ -1040,6 +1041,181 @@ tool_access:
1040
1041
 
1041
1042
  ---
1042
1043
 
1044
+ ## 👤 Carregamento de Usuários (User Loader)
1045
+
1046
+ O **User Loader** identifica automaticamente usuários cadastrados nas conversas e carrega suas informações para enriquecer o contexto, permitindo personalização e evitando onboarding desnecessário.
1047
+
1048
+ 📂 **Exemplos completos**: [docs/examples/user_loader/](docs/examples/user_loader/)
1049
+
1050
+ ### Quando Usar
1051
+
1052
+ | Cenário | Solução |
1053
+ |---------|---------|
1054
+ | **Usuário existente** | Identifica automaticamente e pula onboarding |
1055
+ | **Personalização** | Carrega dados do usuário para respostas personalizadas |
1056
+ | **Contexto enriquecido** | Todos os agentes têm acesso a informações do usuário |
1057
+ | **Múltiplas fontes** | Suporta CSV, banco de dados, APIs REST, etc. |
1058
+
1059
+ ### Funcionalidades
1060
+
1061
+ 1. **Extração automática** de identificadores (telefone, email, CPF, etc.)
1062
+ 2. **Carregamento de dados** de múltiplas fontes
1063
+ 3. **Criação automática** de `UserContext`
1064
+ 4. **Integração transparente** com a rede de agentes
1065
+
1066
+ ### Exemplo 1: Carregamento de CSV
1067
+
1068
+ ```python
1069
+ from pathlib import Path
1070
+ from atendentepro import (
1071
+ create_standard_network,
1072
+ create_user_loader,
1073
+ load_user_from_csv,
1074
+ extract_email_from_messages,
1075
+ run_with_user_context,
1076
+ )
1077
+
1078
+ # Função para carregar do CSV
1079
+ def load_user(identifier: str):
1080
+ return load_user_from_csv(
1081
+ csv_path=Path("users.csv"),
1082
+ identifier_field="email",
1083
+ identifier_value=identifier
1084
+ )
1085
+
1086
+ # Criar loader
1087
+ loader = create_user_loader(
1088
+ loader_func=load_user,
1089
+ identifier_extractor=extract_email_from_messages
1090
+ )
1091
+
1092
+ # Criar network com loader
1093
+ network = create_standard_network(
1094
+ templates_root=Path("./templates"),
1095
+ user_loader=loader,
1096
+ include_onboarding=True,
1097
+ )
1098
+
1099
+ # Executar com carregamento automático
1100
+ messages = [{"role": "user", "content": "Meu email é joao@example.com"}]
1101
+ result = await run_with_user_context(network, network.triage, messages)
1102
+
1103
+ # Verificar se usuário foi carregado
1104
+ if network.loaded_user_context:
1105
+ print(f"Usuário: {network.loaded_user_context.metadata.get('nome')}")
1106
+ ```
1107
+
1108
+ ### Exemplo 2: Carregamento de Banco de Dados
1109
+
1110
+ ```python
1111
+ import sqlite3
1112
+ from atendentepro import create_user_loader, extract_email_from_messages
1113
+
1114
+ def load_from_db(identifier: str):
1115
+ conn = sqlite3.connect("users.db")
1116
+ cursor = conn.cursor()
1117
+ cursor.execute("SELECT * FROM users WHERE email = ?", (identifier,))
1118
+ row = cursor.fetchone()
1119
+ conn.close()
1120
+
1121
+ if row:
1122
+ return {
1123
+ "user_id": row[0],
1124
+ "role": row[1],
1125
+ "nome": row[2],
1126
+ "email": row[3],
1127
+ }
1128
+ return None
1129
+
1130
+ loader = create_user_loader(load_from_db, extract_email_from_messages)
1131
+
1132
+ network = create_standard_network(
1133
+ templates_root=Path("./templates"),
1134
+ user_loader=loader,
1135
+ )
1136
+ ```
1137
+
1138
+ ### Exemplo 3: Múltiplos Identificadores
1139
+
1140
+ ```python
1141
+ from atendentepro import (
1142
+ create_user_loader,
1143
+ extract_email_from_messages,
1144
+ extract_phone_from_messages,
1145
+ )
1146
+
1147
+ def extract_identifier(messages):
1148
+ # Tenta email primeiro
1149
+ email = extract_email_from_messages(messages)
1150
+ if email:
1151
+ return email
1152
+
1153
+ # Se não encontrou, tenta telefone
1154
+ phone = extract_phone_from_messages(messages)
1155
+ if phone:
1156
+ return phone
1157
+
1158
+ return None
1159
+
1160
+ loader = create_user_loader(
1161
+ loader_func=load_user,
1162
+ identifier_extractor=extract_identifier
1163
+ )
1164
+ ```
1165
+
1166
+ ### Funções Disponíveis
1167
+
1168
+ #### Extratores de Identificador
1169
+
1170
+ ```python
1171
+ from atendentepro import (
1172
+ extract_phone_from_messages, # Extrai telefone
1173
+ extract_email_from_messages, # Extrai email
1174
+ extract_user_id_from_messages, # Extrai CPF/user_id
1175
+ )
1176
+ ```
1177
+
1178
+ #### Criar Loader
1179
+
1180
+ ```python
1181
+ from atendentepro import create_user_loader
1182
+
1183
+ loader = create_user_loader(
1184
+ loader_func=load_user_function,
1185
+ identifier_extractor=extract_email_from_messages # Opcional
1186
+ )
1187
+ ```
1188
+
1189
+ #### Executar com Contexto
1190
+
1191
+ ```python
1192
+ from atendentepro import run_with_user_context
1193
+
1194
+ result = await run_with_user_context(
1195
+ network,
1196
+ network.triage,
1197
+ messages
1198
+ )
1199
+ ```
1200
+
1201
+ ### Integração com Onboarding
1202
+
1203
+ Quando um `user_loader` está configurado:
1204
+
1205
+ - ✅ **Usuário encontrado**: Vai direto para o triage, sem passar pelo onboarding
1206
+ - ✅ **Usuário não encontrado**: É direcionado para o onboarding normalmente
1207
+ - ✅ **Contexto disponível**: Todos os agentes têm acesso a `network.loaded_user_context`
1208
+
1209
+ ### Benefícios
1210
+
1211
+ 1. ✅ **Experiência personalizada** - Respostas baseadas em dados do usuário
1212
+ 2. ✅ **Menos fricção** - Usuários conhecidos não precisam fazer onboarding
1213
+ 3. ✅ **Contexto rico** - Todos os agentes têm acesso a informações do usuário
1214
+ 4. ✅ **Flexível** - Suporta múltiplas fontes de dados
1215
+ 5. ✅ **Automático** - Funciona transparentemente durante a conversa
1216
+
1217
+ ---
1218
+
1043
1219
  ## 🔀 Múltiplos Agentes (Multi Interview + Knowledge)
1044
1220
 
1045
1221
  O AtendentePro suporta criar **múltiplas instâncias** de Interview e Knowledge agents, cada um especializado em um domínio diferente.
@@ -1,12 +1,12 @@
1
1
  atendentepro/README.md,sha256=TAXl5GRjhSwz_I-Dx_eN5JOIcUxuE_dz31iMZ_-OnRY,45390
2
- atendentepro/__init__.py,sha256=ZIN7Nrrx04b5mq4YovL3wk6O6oTY659uffZOI9rYNbE,5820
2
+ atendentepro/__init__.py,sha256=VtJoW9tQ-n-hipu0Lp4gPNWJir1QATouA_CoIl_uY3A,6220
3
3
  atendentepro/license.py,sha256=rlPtysXNqAzEQkP2VjUAVu_nMndhPgfKv1yN2ruUYVI,17570
4
- atendentepro/network.py,sha256=RmNrdf-78qWjlzxB7B8Awa-fJ7ZPQi5iQHZuHBPgVpo,28900
4
+ atendentepro/network.py,sha256=292buwWLglU7ISv5UL9sOXMBU2MObZc31TUtPZZQYuY,33719
5
5
  atendentepro/agents/__init__.py,sha256=OcPhG1Dp6xe49B5YIti4HVmaZDoDIrFLfRa8GmI4jpQ,1638
6
6
  atendentepro/agents/answer.py,sha256=S6wTchNSTMc0h6d89A4jAzoqecFPVqHUrr55-rCM-p4,2494
7
7
  atendentepro/agents/confirmation.py,sha256=bQmIiDaxaCDIWJ3Fxz8h3AHW4kHhwbWSmyLX4y3dtls,2900
8
- atendentepro/agents/escalation.py,sha256=tnFhHaV8VnrMMh_p8Xt9JTMe4bkWcGb6mChECm8aAvI,17779
9
- atendentepro/agents/feedback.py,sha256=0cU6LVcIp_2ZBB-Cdprnwgw1C5IKYC_ydQ8HJnlheqo,33585
8
+ atendentepro/agents/escalation.py,sha256=pAS1HANW1jMCBZHekVxvuN9NZ_z9IpAYWbfUzH0Leuw,20540
9
+ atendentepro/agents/feedback.py,sha256=cQd35y2-u93GRmu36ErAMyQEinxvNUaDGblGTGrWCfw,37486
10
10
  atendentepro/agents/flow.py,sha256=8SsAQ_f-daOM25EVBTMKGSUI9ywG3y4B0bFcCnrUfo0,2645
11
11
  atendentepro/agents/interview.py,sha256=3eWHUw63OJuVpHH-QYtdL8SadRgevNLv2N8Esa4Za2c,2722
12
12
  atendentepro/agents/knowledge.py,sha256=Xew_qhAdBrm3oVRvHhKh2oi6aURyeIKU5CtgfAHBYEI,11054
@@ -31,13 +31,14 @@ atendentepro/prompts/knowledge.py,sha256=B3BOyAvzQlwAkR51gc-B6XbQLtwIZlwGP1ofhZ4
31
31
  atendentepro/prompts/onboarding.py,sha256=78fSIh2ifsGeoav8DV41_jnyU157c0dtggJujcDvW4U,6093
32
32
  atendentepro/prompts/triage.py,sha256=bSdEVheGy03r5P6MQuv7NwhN2_wrt0mK80F9f_LskRU,1283
33
33
  atendentepro/templates/__init__.py,sha256=zV1CP2K7_WD219NXl-daTC3Iq8P9sQ7XLmxPEVI2NZg,1575
34
- atendentepro/templates/manager.py,sha256=s2ezeyEboeMxdcb6oOADQRAm0ikB8Ru4fYC87gfctU0,28819
35
- atendentepro/utils/__init__.py,sha256=WCJ6_btsLaI6xxHXvNHNue-nKrXWTKscNZGTToQiJ8A,833
34
+ atendentepro/templates/manager.py,sha256=gPj8zPgN3iSHNACaonJU_aSFdevBJ1gpnlHvaiewAHg,34351
35
+ atendentepro/utils/__init__.py,sha256=x2yMUueBilWmI2qASSGbFREeyu0a65r3TuDt0euxNAU,1244
36
36
  atendentepro/utils/openai_client.py,sha256=R0ns7SU36vTgploq14-QJMTke1pPxcAXlENDeoHU0L4,4552
37
37
  atendentepro/utils/tracing.py,sha256=kpTPw1PF4rR1qq1RyBnAaPIQIJRka4RF8MfG_JrRJ7U,8486
38
- atendentepro-0.6.5.dist-info/licenses/LICENSE,sha256=TF6CdXxePoT9DXtPnCejiU5mUwWzrFzd1iyWJyoMauA,983
39
- atendentepro-0.6.5.dist-info/METADATA,sha256=kLTh75smJ0t4HHAGVY7Bcj4XoOzKtrLWL_gcu1OvLEA,40666
40
- atendentepro-0.6.5.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
41
- atendentepro-0.6.5.dist-info/entry_points.txt,sha256=OP0upzqJF3MLS6VX-M-5BfUwx5YLJO2sJ3YBAp4e6yI,89
42
- atendentepro-0.6.5.dist-info/top_level.txt,sha256=BFasD4SMmgDUmWKlTIZ1PeuukoRBhyiMIz8umKWVCcs,13
43
- atendentepro-0.6.5.dist-info/RECORD,,
38
+ atendentepro/utils/user_loader.py,sha256=J8wd-XF2PZg_i1ped8FI8nmGjmUafXROWJa1tYxSDMI,10623
39
+ atendentepro-0.6.7.dist-info/licenses/LICENSE,sha256=TF6CdXxePoT9DXtPnCejiU5mUwWzrFzd1iyWJyoMauA,983
40
+ atendentepro-0.6.7.dist-info/METADATA,sha256=pmB1348o76S-IbRxfu3i3j1MESOWG6exCcEfq9-l6xE,45468
41
+ atendentepro-0.6.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
42
+ atendentepro-0.6.7.dist-info/entry_points.txt,sha256=OP0upzqJF3MLS6VX-M-5BfUwx5YLJO2sJ3YBAp4e6yI,89
43
+ atendentepro-0.6.7.dist-info/top_level.txt,sha256=BFasD4SMmgDUmWKlTIZ1PeuukoRBhyiMIz8umKWVCcs,13
44
+ atendentepro-0.6.7.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5