feat: Implementação da Fase 3 - Agentes de Triagem e Especialista
Porque foi feita essa alteração? - Nova funcionalidade: Sistema de processamento de tickets com IA - TriageAgent (1B): Extração de entidades via LLM com fallback regex - SpecialistAgent (8B): Diagnóstico técnico com contexto Zabbix/RAG - TicketPipeline: Orquestração completa do fluxo de tickets Quais testes foram feitos? - 89 testes unitários com pytest (todos passando) - Testes de extração de entidades (hostname, IP, serviço) - Testes de classificação de prioridade e urgência - Testes do pipeline completo com mocks async A alteração gerou um novo teste que precisa ser implementado no pipeline? - Sim. Novos arquivos: test_triage_agent.py, test_pipeline.py
This commit is contained in:
parent
ce3e256550
commit
d7ce7d061f
|
|
@ -2,45 +2,51 @@
|
||||||
|
|
||||||
Este documento serve como o roteiro técnico detalhado para a implementação do Agente Arthur. O foco é soberania (local-only), otimização de CPU e integração auditável via e-mail.
|
Este documento serve como o roteiro técnico detalhado para a implementação do Agente Arthur. O foco é soberania (local-only), otimização de CPU e integração auditável via e-mail.
|
||||||
|
|
||||||
## Fase 1: Planejamento e Arquitetura de Dados
|
## Fase 1: Planejamento e Arquitetura de Dados ✅
|
||||||
- [x] **Consolidação do PRD N2:** Definição de escopo, hardware e lógica de atendimento.
|
- [x] **Consolidação do PRD N2:** Definição de escopo, hardware e lógica de atendimento.
|
||||||
- [ ] **Mapeamento do Tenant Resolver (Financeiro):**
|
- [x] **Mapeamento do Tenant Resolver (Financeiro):**
|
||||||
- Definir endpoints/queries no Sistema Financeiro de Produção para buscar: `ID`, `Nome`, `Domínios de E-mail` e `Status`.
|
- MockFinancialClient implementado em `src/clients/mock_financial.py`
|
||||||
- Criar esquema Pydantic para o objeto `TenantContext`.
|
- Esquema Pydantic `TenantContext` em `src/models/tenant.py`
|
||||||
- [ ] **Design do Schema de Auditoria:**
|
- [x] **Design do Schema de Auditoria:**
|
||||||
- Criar modelo de banco de dados (PostgreSQL) para registrar: `TicketID`, `Remetente`, `Contexto Coletado`, `Pensamento do Modelo`, `Resposta Enviada` e `Status de Resolução`.
|
- Modelo `AuditLog` em `src/models/audit.py` (PostgreSQL)
|
||||||
- [ ] **Mapeamento de Segredos:**
|
- Migrations em `src/database/migrations.py`
|
||||||
- Listar todas as credenciais necessárias (Zabbix, DB, E-mail) e definir variáveis p/ Docker Secrets.
|
- [x] **Mapeamento de Segredos:**
|
||||||
|
- `SecretsManager` em `src/security/secrets_manager.py`
|
||||||
|
- Suporte a Docker Secrets + variáveis de ambiente
|
||||||
|
|
||||||
## Fase 2: Infraestrutura e Conectores Core (Músculos)
|
## Fase 2: Infraestrutura e Conectores Core ✅
|
||||||
- [ ] **Ambiente de Inferência Local:**
|
- [x] **Ambiente de Inferência Local:**
|
||||||
- Configurar Ollama ou llama.cpp para rodar Llama 3.2 1B (Triagem) e Llama 3.1 8B (Especialista) em CPU.
|
- `OllamaClient` em `src/clients/ollama_client.py`
|
||||||
- Otimizar threads (Xeon v3) para evitar contenção.
|
- Suporte a Llama 3.2 1B (Triagem) e Llama 3.1 8B (Especialista)
|
||||||
- [ ] **Configuração do Qdrant Multitenant:**
|
- [x] **Configuração do Qdrant Multitenant:**
|
||||||
- Inicializar Qdrant com persistência `on_disk: true`.
|
- `QdrantMultitenant` em `src/clients/qdrant_client.py`
|
||||||
- Definir coleção com suporte a `payload filtering` por `customer_id`.
|
- Persistência `on_disk: true` + filtro por `tenant_id`
|
||||||
|
- [x] **Conector Zabbix API:**
|
||||||
|
- `ZabbixConnector` em `src/clients/zabbix_connector.py`
|
||||||
|
- Funções: `get_host_status`, `get_active_problems`, `get_neighbor_alerts`
|
||||||
|
- [x] **Segurança de Infraestrutura:**
|
||||||
|
- Docker Secrets configurado
|
||||||
|
- `DLPFilter` em `src/security/dlp_filter.py` (redação de CPF/CNPJ/senhas)
|
||||||
- [ ] **Módulo de Comunicação (Mail Client):**
|
- [ ] **Módulo de Comunicação (Mail Client):**
|
||||||
- Implementar `MailListener` (IMAP) para `mail.itguys.com.br`.
|
- ⏳ Aguardando senha do email `arthur.servicedesk@itguys.com.br`
|
||||||
- Implementar `MailResponder` (SMTP) com suporte a threads de e-mail (Headers Message-ID/References).
|
|
||||||
- [ ] **Conector Zabbix API:**
|
|
||||||
- Implementar wrapper usando `zabbix_utils`.
|
|
||||||
- Criar funções específicas: `get_host_status`, `get_active_problems`, `get_neighbor_alerts` (Causa Raiz).
|
|
||||||
- [ ] **Segurança de Infraestrutura:**
|
|
||||||
- Configurar suporte a **Docker Secrets** no orquestrador.
|
|
||||||
- Garantir usuário de API do Zabbix com permissão **Read-Only**.
|
|
||||||
- Implementar **Regex Redaction Filter** (DLP) no módulo de input para sanitizar logs e anexos.
|
|
||||||
|
|
||||||
## Fase 3: Orquestração e Raciocínio (Cérebro)
|
## Fase 3: Orquestração e Raciocínio (Cérebro) 🔄
|
||||||
|
- [x] **Implementação do Agente de Triagem (1B):**
|
||||||
|
- `TriageAgent` em `src/agents/triage_agent.py`
|
||||||
|
- Prompt Engineering para extração de entidades + fallback regex
|
||||||
|
- Classificação de prioridade/categoria
|
||||||
|
- [x] **Implementação do Agente Especialista (8B):**
|
||||||
|
- `SpecialistAgent` em `src/agents/specialist_agent.py`
|
||||||
|
- Coleta de contexto Zabbix + RAG
|
||||||
|
- Geração de diagnóstico e resposta
|
||||||
|
- [x] **Pipeline de Processamento:**
|
||||||
|
- `TicketPipeline` em `src/agents/pipeline.py`
|
||||||
|
- Orquestração triage → specialist → audit
|
||||||
- [ ] **Desenvolvimento do Multi-Agent Dispatcher:**
|
- [ ] **Desenvolvimento do Multi-Agent Dispatcher:**
|
||||||
- Criar o orquestrador (LangGraph) que gerencia o estado do chamado.
|
- Criar o orquestrador (LangGraph) que gerencia o estado do chamado.
|
||||||
- Incluir lógica de `RateLimiter` por Tenant na entrada do Dispatcher (Fila de Prioridade/Descarte).
|
- Incluir lógica de `RateLimiter` por Tenant na entrada do Dispatcher (Fila de Prioridade/Descarte).
|
||||||
- [ ] **Implementação do Agente de Triagem (1B):**
|
|
||||||
- Prompt Engineering para extração de entidades (Cliente, Tecnologia, Problema).
|
|
||||||
- Lógica de decisão de ferramentas (Single Dispatcher).
|
|
||||||
- [ ] **Desenvolvimento do Analista de Causa Raiz:**
|
- [ ] **Desenvolvimento do Analista de Causa Raiz:**
|
||||||
- Código Python para comparar alertas do host atual com alertas de sub-rede/vizinhança no Zabbix.
|
- Código Python para comparar alertas do host atual com alertas de sub-rede/vizinhança no Zabbix.
|
||||||
- [ ] **Implementação do Agente Especialista (8B):**
|
|
||||||
- Prompt de Resolução N2: Recebe o "Contexto Enriquecido" (Zabbix + Histórico 24h + Manuais RAG) e gera a resposta técnica final.
|
|
||||||
- [ ] **Camada de Validação e Segurança (Self-Correction):**
|
- [ ] **Camada de Validação e Segurança (Self-Correction):**
|
||||||
- Implementar Schemas Pydantic rigorosos para todas as saídas de ferramentas.
|
- Implementar Schemas Pydantic rigorosos para todas as saídas de ferramentas.
|
||||||
- Criar nó de "Crítico/Reflexão" no LangGraph para validar a existência de hosts/serviços antes de sugerir ações.
|
- Criar nó de "Crítico/Reflexão" no LangGraph para validar a existência de hosts/serviços antes de sugerir ações.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Agents Module for Arthur (AI Reasoning)
|
||||||
|
from .triage_agent import (
|
||||||
|
TriageAgent, TriageResult, ExtractedEntities,
|
||||||
|
Priority, Category, get_triage_agent
|
||||||
|
)
|
||||||
|
from .specialist_agent import (
|
||||||
|
SpecialistAgent, SpecialistResponse, EnrichedContext,
|
||||||
|
get_specialist_agent
|
||||||
|
)
|
||||||
|
from .pipeline import TicketPipeline, PipelineResult, get_ticket_pipeline
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Triage
|
||||||
|
"TriageAgent",
|
||||||
|
"TriageResult",
|
||||||
|
"ExtractedEntities",
|
||||||
|
"Priority",
|
||||||
|
"Category",
|
||||||
|
"get_triage_agent",
|
||||||
|
# Specialist
|
||||||
|
"SpecialistAgent",
|
||||||
|
"SpecialistResponse",
|
||||||
|
"EnrichedContext",
|
||||||
|
"get_specialist_agent",
|
||||||
|
# Pipeline
|
||||||
|
"TicketPipeline",
|
||||||
|
"PipelineResult",
|
||||||
|
"get_ticket_pipeline",
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""
|
||||||
|
Ticket Pipeline for Arthur Agent.
|
||||||
|
|
||||||
|
Orchestrates the complete ticket processing flow from
|
||||||
|
triage to response generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.agents.triage_agent import TriageAgent, TriageResult, get_triage_agent
|
||||||
|
from src.agents.specialist_agent import SpecialistAgent, SpecialistResponse, get_specialist_agent
|
||||||
|
from src.models import AuditLog, ResolutionStatus, TicketContext
|
||||||
|
from src.security import sanitize_text
|
||||||
|
|
||||||
|
logger = logging.getLogger("ArthurPipeline")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineResult:
|
||||||
|
"""Complete result from ticket pipeline."""
|
||||||
|
ticket_id: str
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
# Results from stages
|
||||||
|
triage: Optional[TriageResult] = None
|
||||||
|
specialist: Optional[SpecialistResponse] = None
|
||||||
|
|
||||||
|
# Generated response
|
||||||
|
email_response: Optional[str] = None
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
audit_log: Optional[AuditLog] = None
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
processing_time_ms: int = 0
|
||||||
|
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TicketPipeline:
|
||||||
|
"""
|
||||||
|
Orchestrates the complete ticket processing pipeline.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Receive email/ticket
|
||||||
|
2. Triage (1B) - Extract entities, classify, resolve tenant
|
||||||
|
3. Specialist (8B) - Collect context, analyze, generate response
|
||||||
|
4. Audit - Log everything for compliance
|
||||||
|
5. Respond - Send email response
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize pipeline components."""
|
||||||
|
self._triage = get_triage_agent()
|
||||||
|
self._specialist = get_specialist_agent()
|
||||||
|
|
||||||
|
async def process_email(
|
||||||
|
self,
|
||||||
|
sender_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
message_id: Optional[str] = None
|
||||||
|
) -> PipelineResult:
|
||||||
|
"""
|
||||||
|
Process incoming email through complete pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sender_email: Email sender address
|
||||||
|
subject: Email subject
|
||||||
|
body: Email body content
|
||||||
|
message_id: Email Message-ID header (for threading)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PipelineResult with complete processing info
|
||||||
|
"""
|
||||||
|
start_time = datetime.now(timezone.utc)
|
||||||
|
ticket_id = self._generate_ticket_id()
|
||||||
|
|
||||||
|
logger.info(f"Pipeline started for ticket {ticket_id}")
|
||||||
|
|
||||||
|
result = PipelineResult(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
success=False
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Stage 1: Triage
|
||||||
|
logger.info(f"[{ticket_id}] Stage 1: Triage")
|
||||||
|
triage_result = await self._triage.process_ticket(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
sender_email=sender_email,
|
||||||
|
subject=subject,
|
||||||
|
body=body
|
||||||
|
)
|
||||||
|
result.triage = triage_result
|
||||||
|
|
||||||
|
if not triage_result.success:
|
||||||
|
result.error = triage_result.error or "Triage failed"
|
||||||
|
logger.error(f"[{ticket_id}] Triage failed: {result.error}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Stage 2: Specialist Analysis
|
||||||
|
logger.info(f"[{ticket_id}] Stage 2: Specialist")
|
||||||
|
specialist_result = await self._specialist.process(triage_result)
|
||||||
|
result.specialist = specialist_result
|
||||||
|
|
||||||
|
if not specialist_result.success:
|
||||||
|
result.error = specialist_result.error or "Specialist failed"
|
||||||
|
logger.error(f"[{ticket_id}] Specialist failed: {result.error}")
|
||||||
|
# Continue to create audit even on failure
|
||||||
|
|
||||||
|
# Stage 3: Build email response
|
||||||
|
if specialist_result.success:
|
||||||
|
result.email_response = self._build_email_response(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
triage=triage_result,
|
||||||
|
specialist=specialist_result,
|
||||||
|
original_subject=subject
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stage 4: Create audit log
|
||||||
|
result.audit_log = self._create_audit_log(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
sender_email=sender_email,
|
||||||
|
subject=subject,
|
||||||
|
triage=triage_result,
|
||||||
|
specialist=specialist_result
|
||||||
|
)
|
||||||
|
|
||||||
|
result.success = specialist_result.success
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"[{ticket_id}] Pipeline error: {e}")
|
||||||
|
result.error = str(e)
|
||||||
|
|
||||||
|
# Calculate processing time
|
||||||
|
end_time = datetime.now(timezone.utc)
|
||||||
|
result.processing_time_ms = int((end_time - start_time).total_seconds() * 1000)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[{ticket_id}] Pipeline completed in {result.processing_time_ms}ms. "
|
||||||
|
f"Success: {result.success}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate_ticket_id(self) -> str:
|
||||||
|
"""Generate unique ticket ID."""
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
|
short_uuid = str(uuid.uuid4())[:8].upper()
|
||||||
|
return f"TKT-{timestamp}-{short_uuid}"
|
||||||
|
|
||||||
|
def _build_email_response(
|
||||||
|
self,
|
||||||
|
ticket_id: str,
|
||||||
|
triage: TriageResult,
|
||||||
|
specialist: SpecialistResponse,
|
||||||
|
original_subject: str
|
||||||
|
) -> str:
|
||||||
|
"""Build formatted email response."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.append(f"Chamado: {ticket_id}")
|
||||||
|
lines.append(f"Cliente: {triage.tenant.name if triage.tenant else 'N/A'}")
|
||||||
|
lines.append(f"Prioridade: {triage.entities.priority.value.upper()}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("-" * 50)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Specialist response
|
||||||
|
lines.append(specialist.response_to_client)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Recommended actions
|
||||||
|
if specialist.recommended_actions:
|
||||||
|
lines.append("AÇÕES RECOMENDADAS:")
|
||||||
|
for i, action in enumerate(specialist.recommended_actions, 1):
|
||||||
|
lines.append(f" {i}. {action}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Escalation notice
|
||||||
|
if specialist.requires_escalation:
|
||||||
|
lines.append("⚠️ ATENÇÃO: Este chamado será escalado para análise adicional.")
|
||||||
|
if specialist.escalation_reason:
|
||||||
|
lines.append(f" Motivo: {specialist.escalation_reason}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
lines.append("-" * 50)
|
||||||
|
lines.append("Arthur - Suporte Técnico N2 | iT Guys")
|
||||||
|
lines.append(f"Referência: {ticket_id}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _create_audit_log(
|
||||||
|
self,
|
||||||
|
ticket_id: str,
|
||||||
|
sender_email: str,
|
||||||
|
subject: str,
|
||||||
|
triage: TriageResult,
|
||||||
|
specialist: SpecialistResponse
|
||||||
|
) -> AuditLog:
|
||||||
|
"""Create audit log entry for compliance."""
|
||||||
|
# Build context
|
||||||
|
context = TicketContext()
|
||||||
|
|
||||||
|
if triage.entities.hostname:
|
||||||
|
context.extracted_entities["hostname"] = triage.entities.hostname
|
||||||
|
if triage.entities.ip_address:
|
||||||
|
context.extracted_entities["ip_address"] = triage.entities.ip_address
|
||||||
|
if triage.entities.service_name:
|
||||||
|
context.extracted_entities["service_name"] = triage.entities.service_name
|
||||||
|
|
||||||
|
# Add Zabbix data if available
|
||||||
|
if specialist and specialist.context_used.host_status:
|
||||||
|
context.zabbix_data = {
|
||||||
|
"host_id": specialist.context_used.host_status.host_id,
|
||||||
|
"hostname": specialist.context_used.host_status.hostname,
|
||||||
|
"status": specialist.context_used.host_status.status,
|
||||||
|
"availability": specialist.context_used.host_status.availability
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine resolution status
|
||||||
|
if specialist and specialist.requires_escalation:
|
||||||
|
status = ResolutionStatus.ESCALATED
|
||||||
|
elif specialist and specialist.success:
|
||||||
|
status = ResolutionStatus.PENDING # Waiting for client feedback
|
||||||
|
else:
|
||||||
|
status = ResolutionStatus.PENDING
|
||||||
|
|
||||||
|
# Create log
|
||||||
|
return AuditLog(
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
tenant_id=triage.tenant.id if triage.tenant else "UNKNOWN",
|
||||||
|
sender_email=sanitize_text(sender_email),
|
||||||
|
subject=sanitize_text(subject),
|
||||||
|
priority=triage.entities.priority.value,
|
||||||
|
category=triage.entities.category.value,
|
||||||
|
context_collected=context,
|
||||||
|
model_reasoning=specialist.model_reasoning if specialist else "",
|
||||||
|
response_sent=specialist.response_to_client if specialist else "",
|
||||||
|
tools_called=triage.recommended_tools,
|
||||||
|
resolution_status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_pipeline: Optional[TicketPipeline] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket_pipeline() -> TicketPipeline:
|
||||||
|
"""Get global ticket pipeline instance."""
|
||||||
|
global _pipeline
|
||||||
|
if _pipeline is None:
|
||||||
|
_pipeline = TicketPipeline()
|
||||||
|
return _pipeline
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
"""
|
||||||
|
Specialist Agent for Arthur (8B Model).
|
||||||
|
|
||||||
|
Performs deep reasoning and generates technical responses
|
||||||
|
using the larger 8B model with enriched context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from src.clients import (
|
||||||
|
get_ollama_client,
|
||||||
|
get_zabbix_connector,
|
||||||
|
get_qdrant_client,
|
||||||
|
HostStatus,
|
||||||
|
Problem,
|
||||||
|
SearchResult
|
||||||
|
)
|
||||||
|
from src.models import TenantContext
|
||||||
|
from src.agents.triage_agent import TriageResult, ExtractedEntities
|
||||||
|
|
||||||
|
logger = logging.getLogger("ArthurSpecialist")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnrichedContext:
|
||||||
|
"""Context enriched with external data sources."""
|
||||||
|
# From Zabbix
|
||||||
|
host_status: Optional[HostStatus] = None
|
||||||
|
active_problems: list[Problem] = None
|
||||||
|
neighbor_problems: list[Problem] = None
|
||||||
|
|
||||||
|
# From RAG (Qdrant)
|
||||||
|
knowledge_docs: list[SearchResult] = None
|
||||||
|
historical_tickets: list[SearchResult] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.active_problems = self.active_problems or []
|
||||||
|
self.neighbor_problems = self.neighbor_problems or []
|
||||||
|
self.knowledge_docs = self.knowledge_docs or []
|
||||||
|
self.historical_tickets = self.historical_tickets or []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpecialistResponse:
|
||||||
|
"""Response generated by specialist agent."""
|
||||||
|
success: bool
|
||||||
|
ticket_id: str
|
||||||
|
|
||||||
|
# Generated content
|
||||||
|
diagnosis: str
|
||||||
|
recommended_actions: list[str]
|
||||||
|
response_to_client: str
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
context_used: EnrichedContext
|
||||||
|
model_reasoning: str
|
||||||
|
confidence_score: float
|
||||||
|
|
||||||
|
# Flags
|
||||||
|
requires_escalation: bool = False
|
||||||
|
escalation_reason: Optional[str] = None
|
||||||
|
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# System prompt for specialist response
|
||||||
|
SPECIALIST_SYSTEM_PROMPT = """Você é o Arthur, um especialista de suporte técnico N2 da iT Guys.
|
||||||
|
Sua função é analisar problemas técnicos e fornecer diagnósticos e soluções.
|
||||||
|
|
||||||
|
CONTEXTO DISPONÍVEL:
|
||||||
|
- Dados do Zabbix (status do host, alertas ativos, alertas de vizinhança)
|
||||||
|
- Base de conhecimento (manuais técnicos)
|
||||||
|
- Histórico de tickets similares
|
||||||
|
|
||||||
|
SUAS RESPONSABILIDADES:
|
||||||
|
1. Analisar o contexto técnico fornecido
|
||||||
|
2. Identificar a causa raiz mais provável
|
||||||
|
3. Sugerir ações de resolução ordenadas por prioridade
|
||||||
|
4. Gerar uma resposta clara e profissional para o cliente
|
||||||
|
|
||||||
|
FORMATO DE RESPOSTA (JSON):
|
||||||
|
{
|
||||||
|
"diagnosis": "Explicação técnica da causa raiz identificada",
|
||||||
|
"recommended_actions": [
|
||||||
|
"Ação 1 - mais urgente",
|
||||||
|
"Ação 2 - próximo passo",
|
||||||
|
"Ação 3 - verificação adicional"
|
||||||
|
],
|
||||||
|
"response_to_client": "Texto da resposta para enviar ao cliente (formal, técnico, claro)",
|
||||||
|
"confidence_score": 0.0-1.0,
|
||||||
|
"requires_escalation": true/false,
|
||||||
|
"escalation_reason": "Motivo se requer escalação ou null"
|
||||||
|
}
|
||||||
|
|
||||||
|
REGRAS:
|
||||||
|
- Seja técnico mas acessível
|
||||||
|
- Use linguagem formal
|
||||||
|
- Inclua passos específicos quando possível
|
||||||
|
- Se não tiver certeza, indique que requer escalação
|
||||||
|
- NUNCA invente dados que não estão no contexto"""
|
||||||
|
|
||||||
|
|
||||||
|
class SpecialistAgent:
|
||||||
|
"""
|
||||||
|
Specialist Agent using 8B model for deep reasoning.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Collect enriched context (Zabbix, RAG, History)
|
||||||
|
- Perform root cause analysis
|
||||||
|
- Generate technical response
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize specialist agent."""
|
||||||
|
self._ollama = get_ollama_client()
|
||||||
|
self._zabbix = get_zabbix_connector()
|
||||||
|
self._qdrant = get_qdrant_client()
|
||||||
|
|
||||||
|
async def process(self, triage: TriageResult) -> SpecialistResponse:
|
||||||
|
"""
|
||||||
|
Process triaged ticket and generate specialist response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
triage: Result from triage agent
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SpecialistResponse with diagnosis and recommendations
|
||||||
|
"""
|
||||||
|
if not triage.success or not triage.tenant:
|
||||||
|
return SpecialistResponse(
|
||||||
|
success=False,
|
||||||
|
ticket_id=triage.ticket_id,
|
||||||
|
diagnosis="",
|
||||||
|
recommended_actions=[],
|
||||||
|
response_to_client="",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="",
|
||||||
|
confidence_score=0.0,
|
||||||
|
error=triage.error or "Triage failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Specialist processing ticket {triage.ticket_id}")
|
||||||
|
|
||||||
|
# Step 1: Collect enriched context
|
||||||
|
context = await self._collect_context(triage)
|
||||||
|
|
||||||
|
# Step 2: Build prompt with context
|
||||||
|
prompt = self._build_prompt(triage, context)
|
||||||
|
|
||||||
|
# Step 3: Generate response with 8B model
|
||||||
|
response = await self._generate_response(prompt, triage.ticket_id)
|
||||||
|
response.context_used = context
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def _collect_context(self, triage: TriageResult) -> EnrichedContext:
|
||||||
|
"""Collect context from Zabbix and RAG."""
|
||||||
|
context = EnrichedContext()
|
||||||
|
entities = triage.entities
|
||||||
|
|
||||||
|
# Zabbix data
|
||||||
|
if entities.hostname:
|
||||||
|
try:
|
||||||
|
context.host_status = self._zabbix.get_host_status(entities.hostname)
|
||||||
|
|
||||||
|
if context.host_status:
|
||||||
|
# Get active problems for this host
|
||||||
|
context.active_problems = self._zabbix.get_active_problems(
|
||||||
|
host_id=context.host_status.host_id,
|
||||||
|
limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get neighbor problems for root cause analysis
|
||||||
|
context.neighbor_problems = self._zabbix.get_neighbor_alerts(
|
||||||
|
host_id=context.host_status.host_id,
|
||||||
|
time_window_minutes=30
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Zabbix context collection failed: {e}")
|
||||||
|
|
||||||
|
elif entities.ip_address:
|
||||||
|
try:
|
||||||
|
context.host_status = self._zabbix.get_host_by_ip(entities.ip_address)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Zabbix IP lookup failed: {e}")
|
||||||
|
|
||||||
|
# RAG search - Knowledge base
|
||||||
|
if entities.technology or entities.service_name:
|
||||||
|
search_term = entities.technology or entities.service_name
|
||||||
|
try:
|
||||||
|
# This would need embeddings - simplified for now
|
||||||
|
# context.knowledge_docs = await self._search_knowledge(
|
||||||
|
# query=search_term,
|
||||||
|
# tenant_id=triage.tenant.id
|
||||||
|
# )
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Knowledge search failed: {e}")
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def _build_prompt(self, triage: TriageResult, context: EnrichedContext) -> str:
|
||||||
|
"""Build comprehensive prompt for specialist model."""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Ticket info
|
||||||
|
parts.append("## TICKET DE SUPORTE")
|
||||||
|
parts.append(f"ID: {triage.ticket_id}")
|
||||||
|
parts.append(f"Cliente: {triage.tenant.name}")
|
||||||
|
parts.append(f"Categoria: {triage.entities.category.value}")
|
||||||
|
parts.append(f"Prioridade: {triage.entities.priority.value}")
|
||||||
|
parts.append("")
|
||||||
|
parts.append("### Conteúdo do Chamado:")
|
||||||
|
parts.append(triage.sanitized_content)
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Entities extracted
|
||||||
|
parts.append("### Entidades Identificadas:")
|
||||||
|
if triage.entities.hostname:
|
||||||
|
parts.append(f"- Host: {triage.entities.hostname}")
|
||||||
|
if triage.entities.ip_address:
|
||||||
|
parts.append(f"- IP: {triage.entities.ip_address}")
|
||||||
|
if triage.entities.service_name:
|
||||||
|
parts.append(f"- Serviço: {triage.entities.service_name}")
|
||||||
|
if triage.entities.technology:
|
||||||
|
parts.append(f"- Tecnologia: {triage.entities.technology}")
|
||||||
|
if triage.entities.error_message:
|
||||||
|
parts.append(f"- Erro: {triage.entities.error_message}")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Zabbix context
|
||||||
|
if context.host_status:
|
||||||
|
parts.append("### Status do Host (Zabbix):")
|
||||||
|
parts.append(f"- Nome: {context.host_status.name}")
|
||||||
|
parts.append(f"- Status: {context.host_status.status}")
|
||||||
|
parts.append(f"- Disponibilidade: {context.host_status.availability}")
|
||||||
|
parts.append(f"- Grupos: {', '.join(context.host_status.groups)}")
|
||||||
|
if context.host_status.last_problem:
|
||||||
|
parts.append(f"- Último Problema: {context.host_status.last_problem}")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Active problems
|
||||||
|
if context.active_problems:
|
||||||
|
parts.append("### Alertas Ativos no Host:")
|
||||||
|
for p in context.active_problems[:5]:
|
||||||
|
severity_map = {0: "Info", 1: "Info", 2: "Warning", 3: "Average", 4: "High", 5: "Disaster"}
|
||||||
|
sev = severity_map.get(p.severity, "Unknown")
|
||||||
|
parts.append(f"- [{sev}] {p.name}")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Neighbor problems (root cause analysis)
|
||||||
|
if context.neighbor_problems:
|
||||||
|
parts.append("### Alertas em Hosts Vizinhos (Análise de Causa Raiz):")
|
||||||
|
for p in context.neighbor_problems[:5]:
|
||||||
|
parts.append(f"- {p.hostname}: {p.name}")
|
||||||
|
parts.append("")
|
||||||
|
parts.append("⚠️ Alertas em vizinhos podem indicar problema de infraestrutura compartilhada.")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
# Knowledge docs
|
||||||
|
if context.knowledge_docs:
|
||||||
|
parts.append("### Base de Conhecimento Relevante:")
|
||||||
|
for doc in context.knowledge_docs[:3]:
|
||||||
|
parts.append(f"- {doc.content[:200]}...")
|
||||||
|
parts.append("")
|
||||||
|
|
||||||
|
parts.append("---")
|
||||||
|
parts.append("Com base no contexto acima, forneça diagnóstico e recomendações.")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
async def _generate_response(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
ticket_id: str
|
||||||
|
) -> SpecialistResponse:
|
||||||
|
"""Generate specialist response using 8B model."""
|
||||||
|
try:
|
||||||
|
response = await self._ollama.generate_specialist(
|
||||||
|
prompt=prompt,
|
||||||
|
system_prompt=SPECIALIST_SYSTEM_PROMPT
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response or not response.content:
|
||||||
|
return SpecialistResponse(
|
||||||
|
success=False,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
diagnosis="",
|
||||||
|
recommended_actions=[],
|
||||||
|
response_to_client="",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="LLM não retornou resposta",
|
||||||
|
confidence_score=0.0,
|
||||||
|
error="No response from LLM"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
return self._parse_response(response.content, ticket_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Specialist generation failed: {e}")
|
||||||
|
return SpecialistResponse(
|
||||||
|
success=False,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
diagnosis="",
|
||||||
|
recommended_actions=[],
|
||||||
|
response_to_client="",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="",
|
||||||
|
confidence_score=0.0,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_response(self, content: str, ticket_id: str) -> SpecialistResponse:
|
||||||
|
"""Parse LLM response into SpecialistResponse."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract JSON
|
||||||
|
json_match = re.search(r'\{[^{}]*\}', content, re.DOTALL)
|
||||||
|
if json_match:
|
||||||
|
data = json.loads(json_match.group())
|
||||||
|
|
||||||
|
return SpecialistResponse(
|
||||||
|
success=True,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
diagnosis=data.get("diagnosis", ""),
|
||||||
|
recommended_actions=data.get("recommended_actions", []),
|
||||||
|
response_to_client=data.get("response_to_client", ""),
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning=content,
|
||||||
|
confidence_score=float(data.get("confidence_score", 0.5)),
|
||||||
|
requires_escalation=data.get("requires_escalation", False),
|
||||||
|
escalation_reason=data.get("escalation_reason")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse specialist response: {e}")
|
||||||
|
|
||||||
|
# Fallback: use raw content
|
||||||
|
return SpecialistResponse(
|
||||||
|
success=True,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
diagnosis="Análise realizada",
|
||||||
|
recommended_actions=[],
|
||||||
|
response_to_client=content,
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning=content,
|
||||||
|
confidence_score=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_specialist_agent: Optional[SpecialistAgent] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_specialist_agent() -> SpecialistAgent:
|
||||||
|
"""Get global specialist agent instance."""
|
||||||
|
global _specialist_agent
|
||||||
|
if _specialist_agent is None:
|
||||||
|
_specialist_agent = SpecialistAgent()
|
||||||
|
return _specialist_agent
|
||||||
|
|
@ -0,0 +1,395 @@
|
||||||
|
"""
|
||||||
|
Triage Agent for Arthur (1B Model).
|
||||||
|
|
||||||
|
Performs fast extraction of entities and initial classification
|
||||||
|
using the lightweight 1B model for efficiency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from src.clients import get_ollama_client, get_financial_client
|
||||||
|
from src.models import TenantContext
|
||||||
|
from src.security import sanitize_text
|
||||||
|
|
||||||
|
logger = logging.getLogger("ArthurTriage")
|
||||||
|
|
||||||
|
|
||||||
|
class Priority(str, Enum):
|
||||||
|
"""Ticket priority levels."""
|
||||||
|
CRITICAL = "critical" # Production down, multiple users affected
|
||||||
|
HIGH = "high" # Major functionality impaired
|
||||||
|
MEDIUM = "medium" # Partial functionality impaired
|
||||||
|
LOW = "low" # Minor issue, workaround available
|
||||||
|
|
||||||
|
|
||||||
|
class Category(str, Enum):
|
||||||
|
"""Ticket category for routing."""
|
||||||
|
INFRASTRUCTURE = "infrastructure" # Servers, network, hardware
|
||||||
|
APPLICATION = "application" # Software, services
|
||||||
|
SECURITY = "security" # Access, permissions, threats
|
||||||
|
DATABASE = "database" # DB issues, queries, backups
|
||||||
|
NETWORK = "network" # Connectivity, DNS, firewall
|
||||||
|
MONITORING = "monitoring" # Zabbix, alerts, metrics
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedEntities:
|
||||||
|
"""Entities extracted from the support ticket."""
|
||||||
|
# Required
|
||||||
|
problem_summary: str = ""
|
||||||
|
|
||||||
|
# Identified from text
|
||||||
|
hostname: Optional[str] = None
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
service_name: Optional[str] = None
|
||||||
|
technology: Optional[str] = None
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
category: Category = Category.OTHER
|
||||||
|
priority: Priority = Priority.MEDIUM
|
||||||
|
|
||||||
|
# Indicators
|
||||||
|
is_urgent: bool = False
|
||||||
|
affects_production: bool = False
|
||||||
|
multiple_users_affected: bool = False
|
||||||
|
|
||||||
|
# Raw entities list
|
||||||
|
raw_entities: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TriageResult:
|
||||||
|
"""Complete result from triage process."""
|
||||||
|
success: bool
|
||||||
|
ticket_id: str
|
||||||
|
tenant: Optional[TenantContext]
|
||||||
|
entities: ExtractedEntities
|
||||||
|
sanitized_content: str
|
||||||
|
recommended_tools: list[str]
|
||||||
|
reasoning: str
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# System prompt for triage extraction
|
||||||
|
TRIAGE_SYSTEM_PROMPT = """Você é um assistente de triagem para tickets de suporte técnico N2.
|
||||||
|
Sua função é extrair informações-chave de e-mails de suporte de forma estruturada.
|
||||||
|
|
||||||
|
EXTRAIA as seguintes informações do texto:
|
||||||
|
1. hostname: Nome do servidor/host mencionado (ex: srv-app01, db-prod-01)
|
||||||
|
2. ip_address: Endereço IP se mencionado
|
||||||
|
3. service_name: Nome do serviço afetado (ex: nginx, mysql, zabbix)
|
||||||
|
4. technology: Tecnologia principal (ex: Linux, Windows, Docker, PostgreSQL)
|
||||||
|
5. error_message: Mensagem de erro específica se houver
|
||||||
|
6. problem_summary: Resumo do problema em uma frase
|
||||||
|
|
||||||
|
CLASSIFIQUE a prioridade:
|
||||||
|
- critical: Sistema em produção parado, múltiplos usuários afetados
|
||||||
|
- high: Funcionalidade principal comprometida
|
||||||
|
- medium: Funcionalidade parcialmente comprometida
|
||||||
|
- low: Problema menor, existe workaround
|
||||||
|
|
||||||
|
CLASSIFIQUE a categoria:
|
||||||
|
- infrastructure: Servidores, hardware, recursos
|
||||||
|
- application: Software, serviços, aplicativos
|
||||||
|
- security: Acessos, permissões, ameaças
|
||||||
|
- database: Banco de dados, queries, backups
|
||||||
|
- network: Conectividade, DNS, firewall
|
||||||
|
- monitoring: Monitoramento, alertas
|
||||||
|
- other: Outros
|
||||||
|
|
||||||
|
Responda APENAS em JSON válido no formato:
|
||||||
|
{
|
||||||
|
"hostname": "string ou null",
|
||||||
|
"ip_address": "string ou null",
|
||||||
|
"service_name": "string ou null",
|
||||||
|
"technology": "string ou null",
|
||||||
|
"error_message": "string ou null",
|
||||||
|
"problem_summary": "string",
|
||||||
|
"category": "infrastructure|application|security|database|network|monitoring|other",
|
||||||
|
"priority": "critical|high|medium|low",
|
||||||
|
"is_urgent": true/false,
|
||||||
|
"affects_production": true/false,
|
||||||
|
"recommended_tools": ["tool1", "tool2"]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
class TriageAgent:
|
||||||
|
"""
|
||||||
|
Triage Agent using 1B model for fast entity extraction.
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Extract entities from email content
|
||||||
|
- Classify priority and category
|
||||||
|
- Resolve tenant from sender email
|
||||||
|
- Recommend tools for resolution
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize triage agent."""
|
||||||
|
self._ollama = get_ollama_client()
|
||||||
|
self._financial = get_financial_client()
|
||||||
|
|
||||||
|
# Regex patterns for fallback extraction
|
||||||
|
self._patterns = {
|
||||||
|
"hostname": re.compile(r'\b(srv|db|app|web|api|prod|dev|hom|stg)[-_]?[a-zA-Z0-9]+[-_]?[0-9]*\b', re.I),
|
||||||
|
"ip_address": re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'),
|
||||||
|
"service": re.compile(r'\b(nginx|apache|mysql|postgres|docker|zabbix|grafana|redis|mongodb)\b', re.I),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def process_ticket(
|
||||||
|
self,
|
||||||
|
ticket_id: str,
|
||||||
|
sender_email: str,
|
||||||
|
subject: str,
|
||||||
|
body: str
|
||||||
|
) -> TriageResult:
|
||||||
|
"""
|
||||||
|
Process incoming ticket through triage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket_id: Unique ticket identifier
|
||||||
|
sender_email: Email address of sender
|
||||||
|
subject: Email subject
|
||||||
|
body: Email body content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TriageResult with extracted entities and classification
|
||||||
|
"""
|
||||||
|
logger.info(f"Processing ticket {ticket_id} from {sender_email}")
|
||||||
|
|
||||||
|
# Step 1: Sanitize content (DLP)
|
||||||
|
sanitized_body = sanitize_text(body)
|
||||||
|
sanitized_subject = sanitize_text(subject)
|
||||||
|
combined_content = f"Assunto: {sanitized_subject}\n\n{sanitized_body}"
|
||||||
|
|
||||||
|
# Step 2: Resolve tenant from email domain
|
||||||
|
tenant = await self._resolve_tenant(sender_email)
|
||||||
|
|
||||||
|
if tenant is None:
|
||||||
|
logger.warning(f"Could not resolve tenant for {sender_email}")
|
||||||
|
return TriageResult(
|
||||||
|
success=False,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
tenant=None,
|
||||||
|
entities=ExtractedEntities(),
|
||||||
|
sanitized_content=combined_content,
|
||||||
|
recommended_tools=[],
|
||||||
|
reasoning="",
|
||||||
|
error=f"Tenant não identificado para o domínio do email {sender_email}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Extract entities using LLM
|
||||||
|
entities = await self._extract_entities(combined_content)
|
||||||
|
|
||||||
|
# Step 4: Fallback regex extraction if LLM missed something
|
||||||
|
entities = self._fallback_extraction(combined_content, entities)
|
||||||
|
|
||||||
|
# Step 5: Determine recommended tools
|
||||||
|
tools = self._recommend_tools(entities, tenant)
|
||||||
|
|
||||||
|
# Build reasoning
|
||||||
|
reasoning = self._build_reasoning(entities, tenant)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Triage complete for {ticket_id}: "
|
||||||
|
f"priority={entities.priority.value}, category={entities.category.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return TriageResult(
|
||||||
|
success=True,
|
||||||
|
ticket_id=ticket_id,
|
||||||
|
tenant=tenant,
|
||||||
|
entities=entities,
|
||||||
|
sanitized_content=combined_content,
|
||||||
|
recommended_tools=tools,
|
||||||
|
reasoning=reasoning
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _resolve_tenant(self, email: str) -> Optional[TenantContext]:
|
||||||
|
"""Resolve tenant from email address."""
|
||||||
|
return await self._financial.get_tenant_by_email(email)
|
||||||
|
|
||||||
|
async def _extract_entities(self, content: str) -> ExtractedEntities:
|
||||||
|
"""Extract entities using 1B model."""
|
||||||
|
entities = ExtractedEntities()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._ollama.generate_triage(
|
||||||
|
prompt=f"Analise este ticket de suporte e extraia as informações:\n\n{content}",
|
||||||
|
system_prompt=TRIAGE_SYSTEM_PROMPT
|
||||||
|
)
|
||||||
|
|
||||||
|
if response and response.content:
|
||||||
|
# Parse JSON response
|
||||||
|
entities = self._parse_llm_response(response.content, entities)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM extraction failed: {e}")
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def _parse_llm_response(
|
||||||
|
self,
|
||||||
|
response: str,
|
||||||
|
entities: ExtractedEntities
|
||||||
|
) -> ExtractedEntities:
|
||||||
|
"""Parse LLM JSON response into entities."""
|
||||||
|
try:
|
||||||
|
# Try to extract JSON from response
|
||||||
|
json_match = re.search(r'\{[^{}]*\}', response, re.DOTALL)
|
||||||
|
if not json_match:
|
||||||
|
return entities
|
||||||
|
|
||||||
|
data = json.loads(json_match.group())
|
||||||
|
|
||||||
|
# Map fields
|
||||||
|
entities.hostname = data.get("hostname")
|
||||||
|
entities.ip_address = data.get("ip_address")
|
||||||
|
entities.service_name = data.get("service_name")
|
||||||
|
entities.technology = data.get("technology")
|
||||||
|
entities.error_message = data.get("error_message")
|
||||||
|
entities.problem_summary = data.get("problem_summary", "")
|
||||||
|
|
||||||
|
# Category
|
||||||
|
cat = data.get("category", "other").lower()
|
||||||
|
try:
|
||||||
|
entities.category = Category(cat)
|
||||||
|
except ValueError:
|
||||||
|
entities.category = Category.OTHER
|
||||||
|
|
||||||
|
# Priority
|
||||||
|
prio = data.get("priority", "medium").lower()
|
||||||
|
try:
|
||||||
|
entities.priority = Priority(prio)
|
||||||
|
except ValueError:
|
||||||
|
entities.priority = Priority.MEDIUM
|
||||||
|
|
||||||
|
# Flags
|
||||||
|
entities.is_urgent = data.get("is_urgent", False)
|
||||||
|
entities.affects_production = data.get("affects_production", False)
|
||||||
|
|
||||||
|
# Raw recommended tools
|
||||||
|
entities.raw_entities = data.get("recommended_tools", [])
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.warning(f"Failed to parse LLM JSON: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing LLM response: {e}")
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def _fallback_extraction(
|
||||||
|
self,
|
||||||
|
content: str,
|
||||||
|
entities: ExtractedEntities
|
||||||
|
) -> ExtractedEntities:
|
||||||
|
"""Use regex patterns for fallback extraction."""
|
||||||
|
# Hostname
|
||||||
|
if not entities.hostname:
|
||||||
|
match = self._patterns["hostname"].search(content)
|
||||||
|
if match:
|
||||||
|
entities.hostname = match.group()
|
||||||
|
|
||||||
|
# IP Address
|
||||||
|
if not entities.ip_address:
|
||||||
|
match = self._patterns["ip_address"].search(content)
|
||||||
|
if match:
|
||||||
|
entities.ip_address = match.group()
|
||||||
|
|
||||||
|
# Service
|
||||||
|
if not entities.service_name:
|
||||||
|
match = self._patterns["service"].search(content)
|
||||||
|
if match:
|
||||||
|
entities.service_name = match.group()
|
||||||
|
|
||||||
|
# Urgency indicators
|
||||||
|
urgency_words = ["urgente", "crítico", "parado", "caiu", "down", "fora do ar"]
|
||||||
|
if any(word in content.lower() for word in urgency_words):
|
||||||
|
entities.is_urgent = True
|
||||||
|
if entities.priority == Priority.MEDIUM:
|
||||||
|
entities.priority = Priority.HIGH
|
||||||
|
|
||||||
|
# Production indicators
|
||||||
|
prod_words = ["produção", "prod", "production", "clientes afetados"]
|
||||||
|
if any(word in content.lower() for word in prod_words):
|
||||||
|
entities.affects_production = True
|
||||||
|
if entities.priority in (Priority.MEDIUM, Priority.LOW):
|
||||||
|
entities.priority = Priority.HIGH
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
def _recommend_tools(
|
||||||
|
self,
|
||||||
|
entities: ExtractedEntities,
|
||||||
|
tenant: TenantContext
|
||||||
|
) -> list[str]:
|
||||||
|
"""Recommend tools based on entities and category."""
|
||||||
|
tools = []
|
||||||
|
|
||||||
|
# Always recommend Zabbix for monitoring data
|
||||||
|
if entities.hostname or entities.ip_address:
|
||||||
|
tools.append("zabbix_get_host_status")
|
||||||
|
tools.append("zabbix_get_problems")
|
||||||
|
|
||||||
|
# Category-specific tools
|
||||||
|
if entities.category == Category.INFRASTRUCTURE:
|
||||||
|
tools.append("zabbix_get_neighbor_alerts")
|
||||||
|
|
||||||
|
if entities.category == Category.DATABASE:
|
||||||
|
tools.append("rag_search_database_docs")
|
||||||
|
|
||||||
|
if entities.category == Category.NETWORK:
|
||||||
|
tools.append("zabbix_get_neighbor_alerts")
|
||||||
|
|
||||||
|
# Always search knowledge base
|
||||||
|
tools.append("rag_search_manuals")
|
||||||
|
|
||||||
|
# Search historical tickets
|
||||||
|
tools.append("search_ticket_history")
|
||||||
|
|
||||||
|
return tools
|
||||||
|
|
||||||
|
def _build_reasoning(
|
||||||
|
self,
|
||||||
|
entities: ExtractedEntities,
|
||||||
|
tenant: TenantContext
|
||||||
|
) -> str:
|
||||||
|
"""Build reasoning explanation."""
|
||||||
|
parts = [
|
||||||
|
f"Ticket do cliente {tenant.name}.",
|
||||||
|
f"Categoria: {entities.category.value}.",
|
||||||
|
f"Prioridade: {entities.priority.value}."
|
||||||
|
]
|
||||||
|
|
||||||
|
if entities.hostname:
|
||||||
|
parts.append(f"Host identificado: {entities.hostname}.")
|
||||||
|
|
||||||
|
if entities.is_urgent:
|
||||||
|
parts.append("Indicadores de URGÊNCIA detectados.")
|
||||||
|
|
||||||
|
if entities.affects_production:
|
||||||
|
parts.append("Ambiente de PRODUÇÃO afetado.")
|
||||||
|
|
||||||
|
if entities.problem_summary:
|
||||||
|
parts.append(f"Problema: {entities.problem_summary}")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_triage_agent: Optional[TriageAgent] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_triage_agent() -> TriageAgent:
|
||||||
|
"""Get global triage agent instance."""
|
||||||
|
global _triage_agent
|
||||||
|
if _triage_agent is None:
|
||||||
|
_triage_agent = TriageAgent()
|
||||||
|
return _triage_agent
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
"""
|
||||||
|
Tests for Ticket Pipeline.
|
||||||
|
|
||||||
|
Tests the complete orchestration flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock
|
||||||
|
|
||||||
|
from src.agents.pipeline import (
|
||||||
|
TicketPipeline,
|
||||||
|
PipelineResult,
|
||||||
|
get_ticket_pipeline
|
||||||
|
)
|
||||||
|
from src.agents.triage_agent import TriageResult, ExtractedEntities, Priority, Category
|
||||||
|
from src.agents.specialist_agent import SpecialistResponse, EnrichedContext
|
||||||
|
|
||||||
|
|
||||||
|
class TestTicketPipeline:
|
||||||
|
"""Tests for TicketPipeline class."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipeline(self):
|
||||||
|
"""Create pipeline with mocked agents."""
|
||||||
|
patcher1 = patch('src.agents.pipeline.get_triage_agent')
|
||||||
|
patcher2 = patch('src.agents.pipeline.get_specialist_agent')
|
||||||
|
|
||||||
|
mock_triage = patcher1.start()
|
||||||
|
mock_specialist = patcher2.start()
|
||||||
|
|
||||||
|
mock_triage.return_value = Mock()
|
||||||
|
mock_specialist.return_value = Mock()
|
||||||
|
|
||||||
|
pipeline = TicketPipeline()
|
||||||
|
|
||||||
|
yield pipeline
|
||||||
|
|
||||||
|
patcher1.stop()
|
||||||
|
patcher2.stop()
|
||||||
|
|
||||||
|
def test_generate_ticket_id(self, pipeline):
|
||||||
|
"""Test ticket ID generation."""
|
||||||
|
ticket_id = pipeline._generate_ticket_id()
|
||||||
|
|
||||||
|
assert ticket_id.startswith("TKT-")
|
||||||
|
assert len(ticket_id) == 21 # TKT-YYYYMMDD-XXXXXXXX
|
||||||
|
|
||||||
|
def test_build_email_response(self, pipeline):
|
||||||
|
"""Test email response formatting."""
|
||||||
|
tenant = Mock()
|
||||||
|
tenant.name = "OESTEPAN"
|
||||||
|
|
||||||
|
triage = TriageResult(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
tenant=tenant,
|
||||||
|
entities=ExtractedEntities(priority=Priority.HIGH),
|
||||||
|
sanitized_content="",
|
||||||
|
recommended_tools=[],
|
||||||
|
reasoning=""
|
||||||
|
)
|
||||||
|
|
||||||
|
specialist = SpecialistResponse(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
diagnosis="Problema de memória",
|
||||||
|
recommended_actions=["Reiniciar serviço", "Verificar logs"],
|
||||||
|
response_to_client="O problema foi identificado como falta de memória.",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="",
|
||||||
|
confidence_score=0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
response = pipeline._build_email_response(
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
triage=triage,
|
||||||
|
specialist=specialist,
|
||||||
|
original_subject="Problema servidor"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "TKT-001" in response
|
||||||
|
assert "OESTEPAN" in response
|
||||||
|
assert "HIGH" in response
|
||||||
|
assert "Reiniciar serviço" in response
|
||||||
|
assert "Arthur" in response
|
||||||
|
|
||||||
|
def test_build_email_with_escalation(self, pipeline):
|
||||||
|
"""Test email response with escalation notice."""
|
||||||
|
tenant = Mock()
|
||||||
|
tenant.name = "ENSEG"
|
||||||
|
|
||||||
|
triage = TriageResult(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-002",
|
||||||
|
tenant=tenant,
|
||||||
|
entities=ExtractedEntities(priority=Priority.CRITICAL),
|
||||||
|
sanitized_content="",
|
||||||
|
recommended_tools=[],
|
||||||
|
reasoning=""
|
||||||
|
)
|
||||||
|
|
||||||
|
specialist = SpecialistResponse(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-002",
|
||||||
|
diagnosis="Problema complexo",
|
||||||
|
recommended_actions=[],
|
||||||
|
response_to_client="Estamos analisando o problema.",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="",
|
||||||
|
confidence_score=0.3,
|
||||||
|
requires_escalation=True,
|
||||||
|
escalation_reason="Requer acesso ao datacenter"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = pipeline._build_email_response(
|
||||||
|
ticket_id="TKT-002",
|
||||||
|
triage=triage,
|
||||||
|
specialist=specialist,
|
||||||
|
original_subject="Emergência"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "escalado" in response.lower()
|
||||||
|
assert "datacenter" in response
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_email_success(self, pipeline):
|
||||||
|
"""Test successful email processing."""
|
||||||
|
# Mock tenant
|
||||||
|
tenant = Mock()
|
||||||
|
tenant.id = "tenant-001"
|
||||||
|
tenant.name = "OESTEPAN"
|
||||||
|
|
||||||
|
# Mock triage result
|
||||||
|
triage_result = TriageResult(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
tenant=tenant,
|
||||||
|
entities=ExtractedEntities(
|
||||||
|
hostname="srv-app01",
|
||||||
|
priority=Priority.HIGH,
|
||||||
|
category=Category.INFRASTRUCTURE
|
||||||
|
),
|
||||||
|
sanitized_content="Conteúdo sanitizado",
|
||||||
|
recommended_tools=["zabbix_get_status"],
|
||||||
|
reasoning="Ticket urgente"
|
||||||
|
)
|
||||||
|
pipeline._triage.process_ticket = AsyncMock(return_value=triage_result)
|
||||||
|
|
||||||
|
# Mock specialist result
|
||||||
|
specialist_result = SpecialistResponse(
|
||||||
|
success=True,
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
diagnosis="Servidor sobrecarregado",
|
||||||
|
recommended_actions=["Reiniciar"],
|
||||||
|
response_to_client="Identificamos sobrecarga no servidor.",
|
||||||
|
context_used=EnrichedContext(),
|
||||||
|
model_reasoning="Análise completa",
|
||||||
|
confidence_score=0.85
|
||||||
|
)
|
||||||
|
pipeline._specialist.process = AsyncMock(return_value=specialist_result)
|
||||||
|
|
||||||
|
# Mock audit log creation to avoid dependency issues
|
||||||
|
pipeline._create_audit_log = Mock(return_value=Mock())
|
||||||
|
|
||||||
|
result = await pipeline.process_email(
|
||||||
|
sender_email="joao@oestepan.com.br",
|
||||||
|
subject="Servidor lento",
|
||||||
|
body="O servidor srv-app01 está muito lento"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.triage is not None
|
||||||
|
assert result.specialist is not None
|
||||||
|
assert result.email_response is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_email_triage_failure(self, pipeline):
|
||||||
|
"""Test email processing with triage failure."""
|
||||||
|
triage_result = TriageResult(
|
||||||
|
success=False,
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
tenant=None,
|
||||||
|
entities=ExtractedEntities(),
|
||||||
|
sanitized_content="",
|
||||||
|
recommended_tools=[],
|
||||||
|
reasoning="",
|
||||||
|
error="Tenant não encontrado"
|
||||||
|
)
|
||||||
|
pipeline._triage.process_ticket = AsyncMock(return_value=triage_result)
|
||||||
|
|
||||||
|
result = await pipeline.process_email(
|
||||||
|
sender_email="unknown@test.com",
|
||||||
|
subject="Teste",
|
||||||
|
body="Teste"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "Tenant" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipelineSingleton:
|
||||||
|
"""Tests for singleton."""
|
||||||
|
|
||||||
|
def test_singleton(self):
|
||||||
|
"""Test singleton pattern."""
|
||||||
|
import src.agents.pipeline as module
|
||||||
|
module._pipeline = None
|
||||||
|
|
||||||
|
with patch('src.agents.pipeline.get_triage_agent'), \
|
||||||
|
patch('src.agents.pipeline.get_specialist_agent'):
|
||||||
|
|
||||||
|
p1 = get_ticket_pipeline()
|
||||||
|
p2 = get_ticket_pipeline()
|
||||||
|
|
||||||
|
assert p1 is p2
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""
|
||||||
|
Tests for Triage Agent.
|
||||||
|
|
||||||
|
Tests entity extraction and classification logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, AsyncMock
|
||||||
|
|
||||||
|
from src.agents.triage_agent import (
|
||||||
|
TriageAgent,
|
||||||
|
TriageResult,
|
||||||
|
ExtractedEntities,
|
||||||
|
Priority,
|
||||||
|
Category,
|
||||||
|
get_triage_agent
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractedEntities:
|
||||||
|
"""Tests for ExtractedEntities dataclass."""
|
||||||
|
|
||||||
|
def test_default_values(self):
|
||||||
|
"""Test default entity values."""
|
||||||
|
entities = ExtractedEntities()
|
||||||
|
|
||||||
|
assert entities.problem_summary == ""
|
||||||
|
assert entities.hostname is None
|
||||||
|
assert entities.category == Category.OTHER
|
||||||
|
assert entities.priority == Priority.MEDIUM
|
||||||
|
assert entities.is_urgent is False
|
||||||
|
|
||||||
|
def test_with_values(self):
|
||||||
|
"""Test entity with values."""
|
||||||
|
entities = ExtractedEntities(
|
||||||
|
hostname="srv-app01",
|
||||||
|
ip_address="192.168.1.10",
|
||||||
|
category=Category.INFRASTRUCTURE,
|
||||||
|
priority=Priority.HIGH,
|
||||||
|
is_urgent=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert entities.hostname == "srv-app01"
|
||||||
|
assert entities.priority == Priority.HIGH
|
||||||
|
assert entities.is_urgent is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestTriageAgent:
|
||||||
|
"""Tests for TriageAgent class."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent(self):
|
||||||
|
"""Create a triage agent with mocked dependencies."""
|
||||||
|
# Patch at module level before creating agent
|
||||||
|
patcher1 = patch('src.agents.triage_agent.get_ollama_client')
|
||||||
|
patcher2 = patch('src.agents.triage_agent.get_financial_client')
|
||||||
|
|
||||||
|
mock_ollama = patcher1.start()
|
||||||
|
mock_financial = patcher2.start()
|
||||||
|
|
||||||
|
mock_ollama.return_value = Mock()
|
||||||
|
mock_financial.return_value = Mock()
|
||||||
|
|
||||||
|
agent = TriageAgent()
|
||||||
|
|
||||||
|
yield agent
|
||||||
|
|
||||||
|
patcher1.stop()
|
||||||
|
patcher2.stop()
|
||||||
|
|
||||||
|
def test_fallback_hostname_extraction(self, agent):
|
||||||
|
"""Test regex fallback for hostname extraction."""
|
||||||
|
content = "O servidor srv-app01 está com problema de memória"
|
||||||
|
entities = ExtractedEntities()
|
||||||
|
|
||||||
|
result = agent._fallback_extraction(content, entities)
|
||||||
|
|
||||||
|
assert result.hostname == "srv-app01"
|
||||||
|
|
||||||
|
def test_fallback_ip_extraction(self, agent):
|
||||||
|
"""Test regex fallback for IP extraction."""
|
||||||
|
content = "O servidor 192.168.1.50 não está respondendo"
|
||||||
|
entities = ExtractedEntities()
|
||||||
|
|
||||||
|
result = agent._fallback_extraction(content, entities)
|
||||||
|
|
||||||
|
assert result.ip_address == "192.168.1.50"
|
||||||
|
|
||||||
|
def test_fallback_service_extraction(self, agent):
|
||||||
|
"""Test regex fallback for service extraction."""
|
||||||
|
content = "O serviço nginx está retornando erro 502"
|
||||||
|
entities = ExtractedEntities()
|
||||||
|
|
||||||
|
result = agent._fallback_extraction(content, entities)
|
||||||
|
|
||||||
|
assert result.service_name.lower() == "nginx"
|
||||||
|
|
||||||
|
def test_urgency_detection(self, agent):
|
||||||
|
"""Test urgency keyword detection."""
|
||||||
|
content = "URGENTE: Sistema caiu e precisa de atenção imediata"
|
||||||
|
entities = ExtractedEntities(priority=Priority.MEDIUM)
|
||||||
|
|
||||||
|
result = agent._fallback_extraction(content, entities)
|
||||||
|
|
||||||
|
assert result.is_urgent is True
|
||||||
|
assert result.priority == Priority.HIGH
|
||||||
|
|
||||||
|
def test_production_detection(self, agent):
|
||||||
|
"""Test production keyword detection."""
|
||||||
|
content = "Servidor de produção com clientes afetados"
|
||||||
|
entities = ExtractedEntities(priority=Priority.MEDIUM)
|
||||||
|
|
||||||
|
result = agent._fallback_extraction(content, entities)
|
||||||
|
|
||||||
|
assert result.affects_production is True
|
||||||
|
assert result.priority == Priority.HIGH
|
||||||
|
|
||||||
|
def test_recommend_tools_with_host(self, agent):
|
||||||
|
"""Test tool recommendation with hostname."""
|
||||||
|
entities = ExtractedEntities(
|
||||||
|
hostname="srv-db01",
|
||||||
|
category=Category.DATABASE
|
||||||
|
)
|
||||||
|
tenant = Mock()
|
||||||
|
tenant.id = "tenant-001"
|
||||||
|
|
||||||
|
tools = agent._recommend_tools(entities, tenant)
|
||||||
|
|
||||||
|
assert "zabbix_get_host_status" in tools
|
||||||
|
assert "zabbix_get_problems" in tools
|
||||||
|
assert "rag_search_manuals" in tools
|
||||||
|
|
||||||
|
def test_build_reasoning(self, agent):
|
||||||
|
"""Test reasoning generation."""
|
||||||
|
entities = ExtractedEntities(
|
||||||
|
hostname="srv-app01",
|
||||||
|
category=Category.INFRASTRUCTURE,
|
||||||
|
priority=Priority.HIGH,
|
||||||
|
is_urgent=True,
|
||||||
|
affects_production=True,
|
||||||
|
problem_summary="Servidor não responde"
|
||||||
|
)
|
||||||
|
tenant = Mock()
|
||||||
|
tenant.name = "OESTEPAN"
|
||||||
|
|
||||||
|
reasoning = agent._build_reasoning(entities, tenant)
|
||||||
|
|
||||||
|
assert "OESTEPAN" in reasoning
|
||||||
|
assert "infrastructure" in reasoning
|
||||||
|
assert "high" in reasoning
|
||||||
|
assert "URGÊNCIA" in reasoning
|
||||||
|
assert "PRODUÇÃO" in reasoning
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_ticket_without_tenant(self, agent):
|
||||||
|
"""Test ticket processing when tenant cannot be resolved."""
|
||||||
|
agent._financial.get_tenant_by_email = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
result = await agent.process_ticket(
|
||||||
|
ticket_id="TKT-001",
|
||||||
|
sender_email="unknown@unknowndomain.com",
|
||||||
|
subject="Problema",
|
||||||
|
body="Teste"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "Tenant não identificado" in result.error
|
||||||
|
|
||||||
|
|
||||||
|
class TestTriageAgentSingleton:
|
||||||
|
"""Tests for singleton."""
|
||||||
|
|
||||||
|
def test_singleton(self):
|
||||||
|
"""Test singleton returns same instance."""
|
||||||
|
import src.agents.triage_agent as module
|
||||||
|
module._triage_agent = None
|
||||||
|
|
||||||
|
with patch('src.agents.triage_agent.get_ollama_client'), \
|
||||||
|
patch('src.agents.triage_agent.get_financial_client'):
|
||||||
|
|
||||||
|
agent1 = get_triage_agent()
|
||||||
|
agent2 = get_triage_agent()
|
||||||
|
|
||||||
|
assert agent1 is agent2
|
||||||
Loading…
Reference in New Issue