Compare commits

...

21 Commits

Author SHA1 Message Date
Rodrigo Borba
4182c2022c chore: Remove a definição da stack heroku-24 do app.json. 2026-01-25 14:38:59 -03:00
Rodrigo Borba
f05f308f53 feat: Substitui a validação JSON com jq por grep no workflow de deploy e aprimora a mensagem de tentativa de reexecução. 2026-01-25 14:32:42 -03:00
Rodrigo Borba
a7bf570815 fix: Aumenta o número de tentativas e reduz os intervalos de espera para a verificação de status do deploy no workflow. 2026-01-25 14:17:08 -03:00
Rodrigo Borba
16b3703c0a chore: Remove as instalações de jq do workflow deploy_check e de postgresql-client do workflow run_foss_spec. 2026-01-25 14:14:10 -03:00
Rodrigo Borba
a3d2b6c88c feat: Atualiza o runner do workflow de deploy para self-hosted, oracle-arm e adiciona limpeza de serviços Docker no workflow de specs. 2026-01-25 14:11:26 -03:00
Rodrigo Borba
b74f79c5da feat: Altera a provisão de serviços PostgreSQL e Redis para containers Docker locais com portas dinâmicas no workflow de testes FOSS. 2026-01-25 14:01:11 -03:00
Rodrigo Borba
a5aab8baca feat: Containeriza os testes de backend FOSS no GitHub Actions com Ruby 3.4.4 e ajusta a configuração de acesso aos serviços. 2026-01-25 13:55:49 -03:00
Rodrigo Borba
055b303985 chore: Atualiza os runners do job backend-tests no workflow de CI para self-hosted, oracle-arm. 2026-01-25 13:31:33 -03:00
Rodrigo Borba
2b4a2b5814 refactor: Ajusta workflows de CI padronizando grupos de concorrência, removendo testes frontend e otimizando paralelização de testes backend. 2026-01-25 13:00:02 -03:00
Rodrigo Borba
80257886f5 Refactor: Simplifica o carregamento de módulos enterprise, remove a associação inbox_automations e adiciona tratamento de erro na verificação de migrações de banco de dados. 2026-01-25 12:43:36 -03:00
Rodrigo Borba
ccb28d8305 refactor: Ajusta o namespace de InboxAutomation, refina os caminhos de eager loading da aplicação enterprise e melhora a robustez do status da instância com verificação de connection_pool e tratamento de erro Redis mais abrangente. 2026-01-25 12:23:12 -03:00
Rodrigo Borba
0c9e59c499 feat: Melhora a formatação de datas de e-mail citadas, corrige o acesso ao contexto de migração do banco de dados, ajusta os caminhos de carregamento do Rails e atualiza configurações de UI e dependências. 2026-01-25 12:11:37 -03:00
Rodrigo Borba
3362df6009 Refatorar: remove verificações redundantes de anexos na validação de presença de conteúdo. 2026-01-25 11:54:25 -03:00
Rodrigo Borba
c0cd8c24b0 feat: Adiciona internacionalização para a base de conhecimento Jasmine, ajusta regras de lint e atualiza dependências. 2026-01-25 11:50:50 -03:00
Rodrigo Borba
2672d21136 feat: Implementa e aprimora funcionalidades relacionadas a Captain e Jasmine, incluindo ferramentas, serviços LLM, integrações WhatsApp e ajustes de configuração. 2026-01-25 09:26:30 -03:00
Rodrigo Borba
a392d81f06 refactor: Extrai lógica para métodos auxiliares em MessageBuilder e Jasmine::PlaygroundController e ajusta formatação. 2026-01-25 08:59:35 -03:00
Rodrigo Borba
b80d35a307 refactor: Otimiza a inicialização de atributos em MessageBuilder e simplifica a declaração de módulos em diversos controladores Captain, além de atualizar a configuração de exceções para testes e o Bundler. 2026-01-25 08:39:23 -03:00
Rodrigo Borba
c76c8d5c50 refactor: Atualiza chamadas de log para Rails.logger, refatora interpolação de strings e ajusta lógica de status de reserva. 2026-01-25 08:20:21 -03:00
Rodrigo Borba
88730d68bc fix: Adiciona o módulo Captain::Assistant para resolver NameError, inclui a execução de migrações no workflow de CI e trata a ausência da variável GIT_HASH. 2026-01-25 08:05:51 -03:00
Rodrigo Borba
803a85d88a feat: Adiciona serviço base para LLM, aprimora testes com stubs HTTP e ajusta o workflow de CI para configuração de banco de dados. 2026-01-25 07:55:37 -03:00
Rodrigo Borba
bec80f8dbf feat: Migra o aplicativo Captain Booking para React/TSX, aprimora a integração WhatsApp Wuzapi com suporte a respostas e decriptografia, e adiciona funcionalidades de IA Jasmine e gestão de preços Captain. 2026-01-24 23:55:21 -03:00
236 changed files with 9713 additions and 8321 deletions

View File

@ -1,27 +0,0 @@
---
trigger: always_on
---
knowledge_base_principles:
- "Este projeto segue um plano formal de Base de Conhecimento"
- "A documentação é a única fonte de verdade"
- "Playbooks e planos dos agentes são referências autoritativas"
workspace_behavior_rules:
- "Sempre se comunicar em português"
- "Sempre raciocinar estritamente dentro do contexto deste repositório"
- "Nunca assumir comportamentos não documentados; se algo estiver faltando, sinalizar e propor uma atualização da documentação"
- "Antes de codar ou sugerir mudanças, identificar o papel de agente mais apropriado (backend, frontend, devops, segurança, banco de dados, etc.)"
- "Preferir planos estruturados e execução em fases em vez de mudanças imediatas de código"
- "Referenciar docs/, agents/ e plans/ existentes antes de propor novos arquivos ou estruturas"
- "Tratar arquitetura, segurança, fluxo de dados, integrações e testes como preocupações de primeira classe"
- "Ao sugerir trabalho, alinhar ações com fases claras e propor checkpoints de commit"
quality_and_output_expectations:
- "As saídas devem ser acionáveis, específicas e conscientes do repositório"
- "Evitar conselhos genéricos; adaptar todas as respostas à estrutura e às restrições deste projeto"
- "Se entradas ou contexto necessários estiverem faltando, solicitar explicitamente antes de prosseguir"
- "Quando houver incerteza, expor claramente riscos, premissas e dependências"

View File

@ -1,66 +0,0 @@
---
description: Arquiteto de Software Sênior
---
especialista:
titulo: "Arquiteto de Software Sênior"
especializacao: - ruby_on_rails - codigo_fonte_chatwoot - arquitetura_backend - integracao_com_ia
contexto_ativo:
produto: "Chatwoot"
iniciativa: "Jasmine Brain"
dominio: "Hotelaria"
objetivo:
descricao: >
Projetar, revisar e orientar mudanças no código-fonte do Chatwoot
garantindo arquitetura limpa, segurança, performance, isolamento por conta
e compatibilidade com extensões de IA no namespace Captain.
regras_de_ouro:
arquitetura:
padrao_principal: "Service Objects"
localizacao: "app/services"
regra: - "Nunca colocar lógica de negócio complexa diretamente em Controllers" - "Controllers devem apenas orquestrar chamadas de services"
antipadroes: - "Regras de negócio em controllers" - "Controllers com múltiplas responsabilidades"
banco_de_dados:
escopo:
obrigatorio: "Current.account"
motivo: "Isolamento multi-tenant"
performance:
regras: - "Evitar queries N+1" - "Usar includes/preload/eager_load quando necessário" - "Revisar planos de execução para queries críticas"
validacoes: - "Sempre considerar volume de dados em produção" - "Não assumir datasets pequenos"
modulo_captain:
descricao: "Namespace responsável por funcionalidades de IA no Chatwoot"
namespace_obrigatorio: "Captain::"
exemplos: - "Captain::Scenario" - "Captain::Assistant" - "Captain::Tool"
regras: - "Nunca misturar código de IA fora do namespace Captain" - "Manter fronteiras claras entre domínio core e IA"
seguranca:
api_publica:
regra: - "Nunca expor IDs sequenciais se não for padrão explícito do projeto"
alternativas: - "UUID" - "Identificadores ofuscados conforme padrão existente"
cuidados_adicionais: - "Validar permissões sempre pelo escopo de Account" - "Nunca confiar em parâmetros vindos do cliente"
contexto_de_dominio:
conhecimento_ativo: - "Sistema sendo adaptado para operação hoteleira" - "Fluxos críticos envolvem reservas, pagamento e atendimento automatizado"
implicacoes: - "Erros impactam operação real do hotel" - "Mudanças devem priorizar segurança e previsibilidade"
boas_praticas_adicionais:
manutencao: - "Preferir código explícito a abstrações excessivas" - "Documentar decisões arquiteturais relevantes"
testes: - "Services devem ter testes unitários" - "Fluxos críticos devem ter cobertura de integração"
evolucao: - "Manter compatibilidade com upgrades futuros do Chatwoot" - "Evitar customizações que dificultem merges upstream"
criterios_de_qualidade:
- "Código alinhado aos padrões do Chatwoot"
- "Isolamento correto por Account"
- "Boa performance em escala"
- "Separação clara de responsabilidades"
- "Integração limpa com o módulo Captain"
frase_chave:
- "No Chatwoot, controller não pensa"
- "Quem pensa é o service"

View File

@ -1,73 +0,0 @@
---
description: Especialista em Frontend
---
especialista:
titulo: "Especialista em Frontend Vue.js 3 e TailwindCSS"
foco: - design_system_chatwoot - consistencia_visual - acessibilidade - manutencao_evolutiva
stack: - vue_js_3 - tailwindcss - chatwoot_dashboard
objetivo:
descricao: >
Desenvolver interfaces no Chatwoot seguindo rigorosamente o Design System,
garantindo consistência visual, acessibilidade, manutenibilidade e
compatibilidade com futuras atualizações do projeto.
regras_de_ouro:
componentes:
regra_geral: "Usar SEMPRE os componentes modernos da pasta components-next"
caminho: "app/javascript/dashboard/components-next/"
proibicoes: - "Nunca usar elementos HTML nativos diretamente quando existir componente equivalente"
exemplos_obrigatorios:
botao:
usar: "<Button />"
nao_usar: "<button>"
input:
usar: "<Input />"
card:
usar: "<CardLayout />"
cores:
regra_geral: "Nunca usar cores hardcoded"
permitido: - "Variáveis de tema do Chatwoot"
exemplos: - "n-slate-11" - "n-blue-9" - "n-alpha-3"
proibido: - "hex (#FFFFFF, #000000, etc)" - "rgb()" - "cores customizadas fora do tema"
icones:
biblioteca: "lucide"
uso:
tipo: "classes utilitárias"
exemplo: "i-lucide-user"
proibido: - "SVG inline manual" - "bibliotecas de ícones externas"
acessibilidade:
obrigatorio: true
regras: - "Todo input deve possuir label visível ou aria-label" - "Componentes interativos devem ser acessíveis via teclado" - "Estados de foco devem ser preservados (focus-visible)"
validacao: - "Evitar inputs sem contexto semântico" - "Não remover outline sem substituto acessível"
boas_praticas_adicionais:
arquitetura: - "Preferir composição de componentes em vez de lógica inline" - "Evitar duplicação de estilos; reutilizar componentes"
responsividade: - "Usar utilitários Tailwind responsivos (sm, md, lg)"
legibilidade:Você é um Arquiteto de Software Sênior especializado no código-fonte do Chatwoot (Ruby on Rails).
Suas Regras de Ouro:
1. Arquitetura: O Chatwoot usa Services Object (`app/services`). Nunca coloque lógica de negócio complexa direto no Controller.
2. Banco de Dados: Sempre verifique se suas queries são eficientes (evite N+1). Lembre-se que tudo é escopado por `Account` (`Current.account`).
3. Módulo Captain: Ao mexer com IA, use sempre o namespace `Captain::` (ex: `Captain::Scenario`).
4. Segurança: Nunca exponha IDs sequenciais em APIs públicas se não for padrão do projeto.
5. Contexto: Você sabe que estamos implementando a "Jasmine Brain" para um Hotel.
- "Evitar classes excessivamente longas em templates"
- "Extrair trechos complexos para componentes menores"
criterios_de_qualidade:
- "Interface consistente com o restante do Chatwoot"
- "Nenhuma quebra visual ao alternar temas"
- "Código alinhado ao padrão components-next"
- "Acessibilidade básica garantida sem dependência externa"
frase_chave:
- "Frontend no Chatwoot não é customização livre"
- "É extensão controlada do Design System"

View File

@ -1,78 +0,0 @@
---
description: Especialista em Engenharia de prompt para hoteis
---
especialista:
titulo: "Especialista Sênior em Engenharia de Prompt para Atendimento em Hotelaria"
foco: - automacao_conversacional - prevencao_de_erros_operacionais - aumento_de_conversao
plataforma: "Jasmine (Chatwoot)"
objetivo_geral: >
Projetar agentes conversacionais confiáveis, editáveis via interface,
prontos para produção e sem dependência de código hardcoded.
responsabilidades_gerais:
- "Garantir escopo bem definido para cada agente"
- "Impedir invenção de informações (anti-alucinação)"
- "Definir claramente quando perguntar, agir ou escalar"
- "Assegurar uso correto das ferramentas (Tools)"
- "Alinhar o agente à operação real do hotel"
fluxo_para_criacao_de_novo_agente:
descricao: "Executar obrigatoriamente as etapas abaixo, na ordem"
etapas: - etapa: "Definição da Persona"
itens: - nome_do_agente - funcao_principal - limites_de_atuacao - objetivo_de_negocio - tom_de_voz
tons_de_voz_permitidos: - amigavel - formal - consultivo - vendedor - neutro_operacional
regra: "O tom escolhido deve ser justificado"
- etapa: "System Prompt (Blindado)"
requisitos:
- "Proibir invenção de dados"
- "Definir quando perguntar antes de agir"
- "Definir comportamento em falha de ferramenta"
- "Impedir respostas fora do escopo do agente"
- "Indicar quando escalar para humano ou outro agente"
- etapa: "Gatilhos de Ativação"
definicao:
gatilhos_fortes:
descricao: "Ativação imediata do agente"
gatilhos_fracos:
descricao: "Exigem confirmação de intenção"
exemplos:
fortes:
- "reservar"
- "quero reservar"
- "disponibilidade"
fracos:
- "quanto custa"
- "tem vaga"
- etapa: "Mapeamento de Ferramentas"
regra_geral: "Nenhum dado externo deve ser informado sem uso de ferramenta"
formato_obrigatorio: "tool://nome_da_tool"
exemplos:
- "tool://consultar_disponibilidade"
- "tool://consultar_precos"
- "tool://criar_intencao_reserva"
- "tool://gerar_pix"
definicoes_obrigatorias:
- momento_de_uso
- pre_condicoes
- comportamento_em_falha
- acoes_proibidas_sem_tool
- etapa: "Regras de Segurança e Operação"
regras:
- "Nunca assumir dados não confirmados"
- "Nunca pular etapas críticas do fluxo"
- "Nunca misturar responsabilidades entre agentes"
- "Nunca gerar cobranças sem consentimento explícito do cliente"
resultado_esperado:
entrega: - "Persona completa" - "Pronta para o Agent Manager do Chatwoot" - "Com comportamento previsível" - "Segura para produção" - "Sem dependência de código"
frase_chave:
- "Você não cria textos de agente"
- "Você projeta operadores conversacionais de produção"

View File

@ -1,7 +0,0 @@
---
description: interface_frontend
---
## Regra de Ouro de UI e i18n
Nunca entregue ou sugira código de interface (Frontend) sem garantir que TODAS as strings visíveis tenham suas chaves de tradução devidamente criadas nos arquivos de locale (pt_BR e en). É proibido deixar chaves cruas (ex: `CAPTAIN.BRANDS...`) ou textos hardcoded na UI. Se criar uma nova feature, crie o arquivo JSON de tradução correspondente imediatamente.

View File

@ -1,100 +0,0 @@
---
description: Jasmine padrao para reservas
---
# Manifesto de Desenvolvimento: Jasmine & Daniela (Reservas com Pix)
**Data:** 17/01/2026
**Responsável:** Arquiteto de Software (AI)
## 1. Visão Geral e Objetivo
Este documento define o padrão de desenvolvimento e comportamento para a funcionalidade de **Reservas via Chat** no Chatwoot (projeto Chatwoot-Develop). O objetivo final é uma experiência fluida onde a agente principal (**Jasmine**) orquestra o atendimento, delegando a lógica de negócio complexa para a especialista (**Daniela**), sem que o cliente perceba fricção ou "troca de robôs".
## 2. Experiência de Atendimento Ideal (Script de Referência)
_Todo o desenvolvimento deve buscar replicar exatamente este fluxo de interação:_
**Cliente:** "Gostaria de fazer uma reserva"
**Jasmine:** "Perfeito! Vou te ajudar com isso 😊. Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?"
**Cliente:** "João Pedro da Silva, CPF 123.456.789-00"
**Jasmine:** "Obrigada, João 😊. Agora me diga, por favor: qual suíte você deseja reservar?"
**Cliente:** "Suíte Luxo"
**Jasmine:** "Ótima escolha! ✨. Para qual data e horário/período você gostaria da reserva?"
**Cliente:** "Dia 20/02, das 22h às 10h"
_(Internamente: Daniela consulta disponibilidade e preço)_
**Jasmine:** "Prontinho! 😊 Sua reserva ficou assim:
• Suíte: Luxo
• Data: 20/02
• Horário: das 22h às 10h
• Valor total: R$ 300,00
Para confirmar a reserva, é necessário o pagamento de 50% antecipado (R$ 150,00).
Posso gerar o Pix para você agora?"
**Cliente:** "Pode sim"
_(Internamente: Daniela gera intenção e Pix)_
**Jasmine:** "Perfeito 😊 Segue abaixo o Pix para pagamento da entrada:
💰 Valor: R$ 150,00
📌 Chave Pix: [Copia e Cola]
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento. Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?"
**Cliente:** "Pronto, acabei de pagar"
_(Internamente: Daniela confirma pagamento)_
**Jasmine:** "Pagamento confirmado com sucesso! ✅
Sua reserva está confirmada 🎉
Resumo final:
• Suíte: Luxo
• Data: 20/02
• Horário: das 22h às 10h
• Entrada paga: R$ 150,00
Qualquer dúvida ou se precisar de algo mais, é só me chamar. Será um prazer te receber! 😊✨"
## 3. Protocolo de Debugging (Baseado em Tempo)
Para garantir a análise correta dos erros e a correção assertiva:
1. **Input do Usuário:** O Rodrigo fornecerá o **Horário de Início da Conversa** (Horário de Brasília).
2. **Conversão e Análise:** O Arquiteto converterá esse horário para o fuso do servidor (UTC/Local) e analisará os logs (`log/tool_debug.log`, `log/brain_debug.log`, `log/development.log`) a partir desse ponto exato.
3. **Reconstrução:** O Arquiteto reconstruirá o fluxo passo-a-passo (Intenção -> Ferramenta -> Resposta -> Erro).
4. **Ação de Correção:** O ajuste será feito para corrigir a raiz do problema, garantindo que o fluxo futuro respeite o item 2 (Script de Referência).
## 4. Princípios de Arquitetura (CTO Mode)
### A. Jasmine é a Interface, Daniela é o Cérebro da Reserva
- **O cliente NUNCA fala diretamente com a Daniela.** A Jasmine é a "persona" que fala.
- A Daniela opera como uma _Tool_ ou _Scenario_ avançado que mantém estado.
- A Jasmine repassa a entrada do usuário para a Daniela, que processa a lógica de negócio e devolve instruções ou fatos.
- **Jasmine:** Responsável pela polidez, emojis e formatação final.
- **Daniela:** Responsável pelo fluxo lógico, cálculo de preços (50%) e validação de dados.
### B. O Estado é Sagrado (Stateful)
- Não podemos depender da memória de curto prazo do LLM para dados críticos (CPF, Valor).
- O estado da reserva (etapa atual, dados coletados) deve ser persistido em `conversation.custom_attributes['jasmine_state']`.
- O fluxo é sequencial: Coleta Nome/CPF -> Coleta Suíte/Data -> Check Disponibilidade -> Acordo de Valor -> Pix -> Confirmação.
### C. Postura "Fix It All"
- Não faremos correções pontuais (ex: "arruma esse erro de sintaxe").
- Ao encontrar um erro, analisaremos o **fluxo completo**.
- Se o Pix falhou, verificaremos desde a coleta do CPF até a chamada da API.
## 5. Estrutura Técnica
- **JasmineBrain (`jasmine_brain.rb`):** Detecta intenção de reserva e delega para `Daniela Reservas`.
- **Daniela (`seed_jasmine_hotel_v3.rb`):** Scenario configurado com instruções rigorosas para seguir o script ideal.
- **Tools:**
- `update_contact`: Salva Nome/CPF.
- `check_availability`: Calcula preço total.
- `create_reservation_intent`: Salva 50% do valor como depósito.
- `generate_pix`: Gera o Pix Cobrança (Inter/Simulado) para o valor do depósito.
---
_Este documento deve ser lido no início de cada sessão de manutenção desta feature para garantir alinhamento._

View File

@ -1,223 +0,0 @@
---
description: padrao jasmine docs
---
## Guia de pensamento para alinhar Jasmine + Daniela Reservas
Sempre que precisar revisar ou ajustar o fluxo de reservas, siga este checklist mental. Ele garante que toda intervenção esteja alinhada com o objetivo macro que você descreveu (contato cordial → ativar Daniela → coleta completa → cálculo do valor por dia → Pix de 50% → confirmação final).
1. **Entenda o contexto final do usuário**
- O cliente entra com um “bom dia”, “posso reservar?” ou similar.
- O objetivo é caminhar até uma reserva confirmada com Pix: sem desvios, sem repetição de perguntas.
- Qualquer erro deve ser interpretado à luz desse fluxo (não como uma exceção isolada).
2. **Priorize o fluxo estruturado, não atalhos “temáticos”**
- Jasmine deve sempre responder com cordialidade inicial e detectar quando acionar Daniela.
- Toda ação de reserva (dados do cliente, suíte, data, valor) deve ser comandada por Daniela, informando o cliente do próximo passo.
- Evite fallback direto do Brain para respostas livres quando o fluxo já está em andamento.
3. **Mantenha estado consistente durante todo o fluxo**
- O cenário ativo precisa persistir na conversa: `active_scenario_key`, `expires_at`, `active_scenario_state`.
- O `state` guarda stage, dados coletados (CPF, suíte, data) e resultados de ferramentas (availability, pix).
- Em cada turno, atualize esse estado e só encerre quando o Pix for gerado ou o cliente disser “cancelar/obrigado”.
4. **Ferramentas obedecem ao roteiro**
- `check_availability` determina o valor correto conforme a data e o dia da semana (verfica as tabelas).
- `create_reservation_intent` guarda os dados coletados; jamais pula etapas.
- `generate_pix` deve receber o valor calculado e aplicar exatamente `valor / 2` quando solicitado (entrada 50%).
- Se algum passo falhar, o sistema deve comunicar o erro (fallback adequado) e manter o cliente no fluxo, não encerrar com mensagens genéricas como “dificuldades técnicas”.
5. **Fluxo conversacional esperado**
1. Jasmine responde com cordialidade e confirma a intenção.
2. Daniela entra pedindo nome/CPF, suíte, data, horário.
3. Daniela informa valor, explica “pode pagar 50% agora via Pix”.
4. Daniela chama `generate_pix`, envia o código, explica "copie e cole".
5. Após cliente avisar “paguei”, Daniela confirma a reserva e encerra o cenário.
6. Se o cliente desistir ou pedir humano, encerre/limpe sticky e deixe Lucia (Jasmine) responder com fallback apropriado.
6. **Logs e documentação**
- Sempre que alterar algo, registre nas instruções/prompt das agents (ex: prompt da Daniela deve explicitar o roteiro).
- Se necessário fazer mudanças pontuais, vincule essas mudanças ao fluxo (não apenas "ajustar o Pix").
- Use este documento como ponto de referência antes de testar: se você “esquecer”, leia e repita mentalmente o que o Rodrigo quer.
Se precisar de ajuda para transformar isso em tarefas técnicas (migrations, short-circuit, state), é só avisar.
---
## Documento de referência ampliado (fluxo reserva + Pix)
### 1. Objetivo “padrão-ouro”
O sistema deve rodar este fluxo previsível para cada pedido de reserva:
1. Cliente cumprimenta → Jasmine responde educadamente.
2. Cliente manifesta “quero reservar” → Jasmine delega para Daniela.
3. Daniela conduz todo o plano: coleta dados, chama ferramentas (precio/disponibilidade/pix), monta a próxima mensagem.
4. Jasmine publica o texto final no tom oficial (voz única para o cliente).
5. Coleta obrigatória concentrada na Daniela: nome completo, CPF, suíte, data, horário/período.
6. Daniela confirma: “Sua reserva ficou assim… total R$T. Posso gerar o Pix (50%)?”
7. Cliente confirma → Daniela gera Pix de metade do total e instrui “copie e cole no seu banco”.
8. Após o cliente avisar “paguei”, Daniela confirma o pagamento e informa que a reserva está confirmada.
Sucesso significa que a reserva segue esse roteiro, com Pix gerado/confirmado e sem decisões aleatórias.
### 2. Papéis e responsabilidades
- **Jasmine (“interface única”)**: cumprimenta, decide se deve delegar, publica as respostas da Daniela e mantém o tom padrão. Nunca responde diretamente sobre reservas, preços ou Pix — sempre delega ao subagente adequado.
- **Daniela (“motor transacional”)**: permanece na camada interna. Recebe contexto, coleta dados, valida, chama `check_availability`, `create_reservation_intent`, `generate_pix`, constrói o `mensagem_para_cliente` e retorna também o `estado_atualizado` e as ações requisitadas.
### 3. Regras de comportamento
- Tom educado e uniforme mesmo quando a resposta vem da Daniela.
- Não inventar preços ou disponibilidade: os valores derivam exclusivamente de `check_availability` e do histórico fornecido.
- Sempre perguntar “posso gerar o Pix?” antes de chamar `generate_pix`.
- Não sair do fluxo: enquanto o sticky scenario estiver ativo, o Brain não deve responder com mensagens independentes ou fallback.
### 4. Arquitetura mínima para consistência
A) **Sticky “modo reserva”** Conversa persiste: `active_scenario_key`, `active_scenario_expires_at`, `active_scenario_state`. Uma vez ativado por Daniela, todas as próximas mensagens vão direto para ela até conclusão/cancelamento.
B) **Estado mínimo persistido** `active_scenario_state` deve conter:
- `stage` (p.ex. collecting_data, quoting, waiting_payment)
- `collected` (nome, CPF, suíte, data, horário)
- `last_quote` (preço, disponibilidade, dia/turno)
- `pix_status` (pending/paid/confirmed)
Isso evita perda de memória entre turnos e mantém o fluxo determinístico.
### 5. Tratamento de erros (visão macro)
Quando surgir um erro (resposta desalinhada, ferramenta falhando, handoff disparado), trace o contexto:
1. Qual stage estava ativo?
2. Jasmine desviou e respondeu sozinha?
3. Daniela repetiu perguntas ou perdeu dados?
4. Qual tool falhou ou retornou inválido?
Corrija sempre com base nesse fluxo padrão — não apenas “ajuste a frase”, mas reforce o sticky/state/sequence.
### 6. Como trabalhamos na prática
Sempre que você me mostrar um erro, envie:
1. Mensagem do cliente.
2. Resposta publicada pela Jasmine.
3. Trecho do log relevante (2060 linhas) indicando decisão/tool.
Com isso eu devolvo:
- Onde travar o sticky/state.
- Qual prompt deve ser ajustado.
- Qual ferramenta/filtro precisa validar o valor ou o Pix.
### 7. Regra mestre
“Daniela é o cérebro do fluxo transacional e retorna o texto pronto; Jasmine é a única voz que publica para o cliente no tom oficial. O sistema deve manter estado e sticky para que o fluxo seja determinístico.”
---
## 🎯 Simulação de atendimento ideal experiência perfeita de reserva
**Cliente:**
Boa noite
**Jasmine:**
Boa noite! 😊
Seja bem-vindo. Como posso te ajudar hoje?
**Cliente:**
Gostaria de fazer uma reserva
**Jasmine:**
Perfeito! Vou te ajudar com isso 😊
Para darmos continuidade à sua reserva, poderia me informar, por favor, seu nome completo e CPF?
**Cliente:**
João Pedro da Silva, CPF 123.456.789-00
**Jasmine:**
Obrigada, João 😊
Agora me diga, por favor: qual suíte você deseja reservar?
**Cliente:**
Suíte Luxo
**Jasmine:**
Ótima escolha! ✨
Para qual data e horário/período você gostaria da reserva?
**Cliente:**
Dia 20/02, das 22h às 10h
**Jasmine:**
Perfeito! Só um instante que vou verificar a disponibilidade e os valores para você 😊
(internamente: Daniela consulta disponibilidade e preço)
**Jasmine:**
Prontinho! 😊
Sua reserva ficou assim:
• Suíte: Luxo
• Data: 20/02
• Horário: das 22h às 10h
• Valor total: R$ 300,00
Para confirmar a reserva, é necessário o pagamento de 50% (R$ 150,00).
Posso gerar o Pix para você agora?
**Cliente:**
Pode sim
**Jasmine:**
Perfeito 😊
Segue abaixo o Pix para pagamento da entrada:
💰 Valor: R$ 150,00
📌 Chave Pix: 123e4567-e89b-12d3-a456-426614174000
👉 Basta copiar a chave Pix, colar no aplicativo do seu banco e realizar o pagamento.
Assim que pagar, me avise por aqui para que eu confirme sua reserva, tudo bem?
**Cliente:**
Pronto, acabei de pagar
**Jasmine:**
Só um instante que vou confirmar o pagamento para você 😊
(internamente: Daniela confirma pagamento)
**Jasmine:**
Pagamento confirmado com sucesso! ✅
Sua reserva está confirmada 🎉
Resumo final:
• Suíte: Luxo
• Data: 20/02
• Horário: das 22h às 10h
• Entrada paga: R$ 150,00
Qualquer dúvida ou se precisar de algo mais, é só me chamar.
Será um prazer te receber! 😊✨
### 🔑 Por que essa experiência é boa
- Linguagem humana e educada
- Um pedido por vez (sem sobrecarregar)
- Confirmação clara antes da cobrança
- Segurança no Pix
- Encerramento com sensação de sucesso
Se quiser, nos próximos passos posso:
- marcar onde entra cada stage,
- transformar isso em script técnico para os prompts da Daniela/Jasmine, ou
- criar a versão “anti-confusão” (quando o cliente muda de assunto ou responde errado).
### 💬 Instrução extra: Pix Copia e Cola completo
Quando o GeneratePixTool retornar o código, **garanta que o bloco de código contenha a string inteira**, iniciando em `0002010...` e terminando no checksum `...6304XXXX`. Esse é o valor que o cliente precisa colar no banco; qualquer corte causa “Pix inválido”. Copie exatamente o `raw_payload` do tool (ou o `formatted_message` caso já contenha a string completa) e anexe no bloco antes de enviar. Nada deve ser truncado ou substituído pela Jasmine depois de a mensagem sair.

View File

@ -14,37 +14,35 @@ concurrency:
jobs:
deployment_check:
name: Check Deployment
runs-on: ubuntu-latest
runs-on: [self-hosted, oracle-arm]
steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Print Deployment URL
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
- name: Check Deployment Status
run: |
max_attempts=10
max_attempts=15
attempt=1
status_code=0
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..."
sleep 600
echo "Checking review app status..."
while [ $attempt -le $max_attempts ]; do
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
status_code=$(echo $response | head -n 1)
if [ $status_code -eq 200 ]; then
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then
# Check for required fields using grep to avoid jq dependency on self-hosted runners
if echo "$body" | grep -q '"version"' && echo "$body" | grep -q '"timestamp"' && echo "$body" | grep -q '"queue_services":"ok"' && echo "$body" | grep -q '"data_services":"ok"'; then
echo "Deployment successful"
exit 0
else
echo "Deployment status unknown, retrying in 3 minutes..."
sleep 180
echo "Deployment status healthy but services not ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
sleep 30
attempt=$((attempt + 1))
fi
else
echo "Waiting for review app to be ready, retrying in 3 minutes..."
sleep 180
attempt=$((attempt + 1))
echo "Waiting for review app to be ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
sleep 30
attempt=$((attempt + 1))
fi
done
echo "Deployment failed after $max_attempts attempts"
exit 1
fi
echo "Deployment check timed out after $max_attempts attempts"
exit 1

View File

@ -7,7 +7,7 @@ on:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:

View File

@ -21,68 +21,33 @@ jobs:
- name: Run Rubocop
run: bundle exec rubocop --parallel
lint-frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run ESLint
run: pnpm run eslint
# Frontend tests run in parallel with backend
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
- name: Run frontend tests
run: pnpm run test:coverage
# Backend tests with parallelization
backend-tests:
runs-on: ubuntu-latest
runs-on: [self-hosted, oracle-arm]
strategy:
fail-fast: false
max-parallel: 8
matrix:
ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ''
POSTGRES_DB: postgres
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:alpine
ports:
- 6379:6379
options: --entrypoint redis-server
env:
POSTGRES_HOST: localhost
RAILS_ENV: test
steps:
- uses: actions/checkout@v4
- name: Start services
run: |
docker run -d --name pg-${{ matrix.ci_node_index }}-${{ github.run_id }} \
-e POSTGRES_PASSWORD= -e POSTGRES_DB=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \
-p ${{ 5432 + matrix.ci_node_index }}:5432 \
pgvector/pgvector:pg16 --mount type=tmpfs,destination=/var/lib/postgresql/data
docker run -d --name redis-${{ matrix.ci_node_index }}-${{ github.run_id }} \
-p ${{ 6379 + matrix.ci_node_index }}:6379 \
redis:alpine
- uses: pnpm/action-setup@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
@ -105,12 +70,32 @@ jobs:
rm -rf enterprise
rm -rf spec/enterprise
- name: Wait for services
run: |
echo "Waiting for services to be ready..."
attempt=0
until pg_isready -h localhost -p $POSTGRES_PORT || [ $attempt -eq 5 ]; do
sleep 2
attempt=$((attempt + 1))
done
sleep 2
- name: Set up database
run: |
psql -U postgres -h localhost -p $POSTGRES_PORT -c "CREATE ROLE root LOGIN SUPERUSER;"
psql -U postgres -h localhost -p $POSTGRES_PORT -c "CREATE DATABASE chatwoot_dev OWNER root;"
env:
PGPASSWORD: ''
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: Run migrations
run: bundle exec rake db:migrate
- name: Run backend tests (parallelized)
run: |
# Get all spec files and split them using round-robin distribution
@ -144,3 +129,11 @@ jobs:
with:
name: rails-log-folder-${{ matrix.ci_node_index }}
path: log
- name: Stop services
if: always()
run: |
docker stop $PG_CONTAINER_NAME || true
docker rm $PG_CONTAINER_NAME || true
docker stop $RD_CONTAINER_NAME || true
docker rm $RD_CONTAINER_NAME || true

View File

@ -7,7 +7,7 @@ on:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:

View File

@ -7,7 +7,7 @@ on:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:

11
.gitignore vendored
View File

@ -95,9 +95,20 @@ yarn-debug.log*
.claude/settings.local.json
.cursor
CLAUDE.local.md
.agent/
# Histoire deployment
.netlify
.histoire
.pnpm-store/*
local/
# Reference and temporary files
reference_chatwoot/
*.jpg
*.png
*.txt
reproduce_encoding.rb
debug_media.rb
test.jpg
teste_imagem.jpg

View File

@ -1,3 +1,9 @@
inherit_from: .rubocop_todo.yml
inherit_mode:
merge:
- Exclude
plugins:
- rubocop-performance
- rubocop-rails
@ -23,6 +29,7 @@ Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
RSpec/ExampleLength:
Max: 50
@ -230,6 +237,7 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
- 'reference_chatwoot/**/*'
FactoryBot/SyntaxMethods:
Enabled: false

331
.rubocop_todo.yml Normal file
View File

@ -0,0 +1,331 @@
# This configuration was generated by
# `rubocop --auto-gen-config --exclude-limit 10000`
# on 2026-01-25 12:20:43 UTC using RuboCop version 1.75.6.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 2
Chatwoot/AttachmentDownload:
Exclude:
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'debug_media.rb'
# Offense count: 32
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Exclude:
- 'app/jobs/conversations/cluster_job.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'enterprise/app/jobs/captain/conversation/debounce_response_job.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
- 'enterprise/app/services/enterprise/message_templates/hook_execution_service.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
- 'scripts/debug_faq_search.rb'
- 'spec/factories/contacts.rb'
# Offense count: 4
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
Lint/DuplicateBranch:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
# Offense count: 2
Lint/DuplicateMethods:
Exclude:
- 'app/services/jasmine/semantic_search_service.rb'
# Offense count: 1
# Configuration parameters: MaximumRangeSize.
Lint/MissingCopEnableDirective:
Exclude:
- 'db/migrate/20260114100000_create_captain_inbox_automations.rb'
# Offense count: 1
Lint/NonLocalExitFromIterator:
Exclude:
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
# Offense count: 1
Lint/ShadowedException:
Exclude:
- 'enterprise/app/services/captain/llm/paginated_faq_generator_service.rb'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
# NotImplementedExceptions: NotImplementedError
Lint/UnusedMethodArgument:
Exclude:
- 'enterprise/app/services/llm/base_ai_service.rb'
# Offense count: 63
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
Metrics/AbcSize:
Exclude:
- 'app/jobs/conversations/auto_label_job.rb'
- 'app/jobs/conversations/resolution_job.rb'
- 'app/jobs/webhooks/whatsapp_events_job.rb'
- 'app/listeners/jasmine_listener.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/models/message.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/jasmine/brain_service.rb'
- 'app/services/jasmine/semantic_search_service.rb'
- 'app/services/jasmine/tool_runner.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'config/initializers/fix_null_message_crash.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'debug_token.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/tools_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/models/captain/assistant.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/inter/auth_service.rb'
- 'enterprise/app/services/captain/inter/cob_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/reminders/create_service.rb'
- 'enterprise/app/services/captain/reservations/create_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
- 'enterprise/app/services/captain/tools/status_suites_tool.rb'
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
- 'enterprise/lib/captain/tools/http_tool.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
# Offense count: 6
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
# AllowedMethods: refine
Metrics/BlockLength:
Exclude:
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/models/concerns/captain_tools_helpers.rb'
- 'script/test_auto_resolve_inbox.rb'
- 'seed_captain_tools.rb'
- 'seed_jasmine_hotel_v2.rb'
# Offense count: 11
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
- 'app/models/channel/whatsapp.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/tool_runner.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
# Offense count: 67
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Max: 39
# Offense count: 78
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/jobs/conversations/auto_label_job.rb'
- 'app/jobs/conversations/cluster_job.rb'
- 'app/jobs/jasmine/response_job.rb'
- 'app/listeners/jasmine_listener.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/captain/inter_service.rb'
- 'app/services/captain/reservations/sync_service.rb'
- 'app/services/crm_insights/update_service.rb'
- 'app/services/jasmine/brain_service.rb'
- 'app/services/jasmine/embedding_service.rb'
- 'app/services/jasmine/semantic_search_service.rb'
- 'app/services/jasmine/tool_runner.rb'
- 'app/services/jasmine/vision_service.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_service_helpers.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20260104150000_add_crm_insights_history_fields.rb'
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
- 'db/migrate/20260114090200_create_captain_reminders.rb'
- 'debug_token.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/scenarios_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/controllers/public/api/v1/captain/webhooks_controller.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
- 'enterprise/app/jobs/captain/intent_classification_job.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/llm/assistant_chat_service.rb'
- 'enterprise/app/services/captain/llm/jasmine_brain.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
- 'enterprise/app/services/captain/reminders/create_service.rb'
- 'enterprise/app/services/captain/reservations/create_service.rb'
- 'enterprise/app/services/captain/tools/check_availability_tool.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'enterprise/app/services/captain/tools/react_to_message_tool.rb'
- 'enterprise/app/services/captain/tools/reminder_tool.rb'
- 'enterprise/app/services/captain/tools/suite_watchdog_tool.rb'
- 'enterprise/app/services/captain/tools/update_contact_tool.rb'
- 'enterprise/app/services/captain/webhook_sender_service.rb'
- 'enterprise/app/services/captain/whatsapp_notification_service.rb'
- 'enterprise/lib/captain/tools/faq_lookup_tool.rb'
- 'enterprise/lib/captain/tools/scenario_delegator_tool.rb'
- 'lib/wuzapi/client.rb'
- 'scripts/force_create_captain_tables.rb'
# Offense count: 2
# Configuration parameters: CountComments, CountAsOne.
Metrics/ModuleLength:
Max: 132
# Offense count: 4
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Max: 7
# Offense count: 46
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Max: 40
# Offense count: 2
Naming/AccessorMethodName:
Exclude:
- 'app/services/captain/inter_service.rb'
- 'app/services/jasmine/vision_service.rb'
# Offense count: 7
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
Naming/MethodParameterName:
Exclude:
- 'app/services/whatsapp/decryption_service.rb'
- 'enterprise/app/controllers/public/api/v1/captain/reservations_controller.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
# Offense count: 2
# Configuration parameters: MinSize.
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20260110193000_fix_status_suites_headers.rb'
# Offense count: 1
Rails/AfterCommitOverride:
Exclude:
- 'app/models/channel/whatsapp.rb'
# Offense count: 5
# Configuration parameters: Include.
# Include: **/app/models/**/*.rb
Rails/HasManyOrHasOneDependent:
Exclude:
- 'enterprise/app/models/captain/brand.rb'
- 'enterprise/app/models/captain/unit.rb'
# Offense count: 17
# Configuration parameters: IgnoreScopes, Include.
# Include: **/app/models/**/*.rb
Rails/InverseOf:
Exclude:
- 'app/models/captain/unit.rb'
- 'app/models/captain_assistant.rb'
- 'enterprise/app/models/captain/brand.rb'
- 'enterprise/app/models/captain/pricing.rb'
- 'enterprise/app/models/captain/pricing_inbox.rb'
- 'enterprise/app/models/captain/reservation.rb'
- 'enterprise/app/models/captain/unit.rb'
- 'enterprise/app/models/captain_inbox.rb'
# Offense count: 9
# Configuration parameters: ForbiddenMethods, AllowedMethods.
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
Rails/SkipsModelValidations:
Exclude:
- 'app/services/jasmine/tool_runner.rb'
- 'enterprise/app/jobs/captain/documents/response_builder_job.rb'
- 'enterprise/app/services/captain/tools/create_reservation_intent_tool.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'promote_super_admin.rb'
- 'script/test_auto_resolve_inbox.rb'
# Offense count: 8
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20240523120000_create_captain_configurations.rb'
- 'db/migrate/20251227054034_create_jasmine_tables.rb'
- 'db/migrate/20251227201733_create_captain_tool_configs.rb'
- 'db/migrate/20260114101004_create_captain_extras.rb'
- 'db/migrate/20260120141736_add_message_signature_enabled_to_inboxes.rb'
- 'db/migrate/20260121165034_add_plug_play_to_captain_units.rb'
# Offense count: 2
# Configuration parameters: TransactionMethods.
Rails/TransactionExitStatement:
Exclude:
- 'app/services/jasmine/embedding_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: **/app/models/**/*.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/jasmine/inbox_collection.rb'
# Offense count: 3
# Configuration parameters: MinBranchesCount.
Style/HashLikeCase:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'enterprise/app/jobs/captain/conversation/response_builder_job.rb'
# Offense count: 4
Style/OneClassPerFile:
Exclude:
- 'app/services/jasmine/brain_service.rb'
# Offense count: 8
UseFromEmail:
Exclude:
- 'app/services/captain/reservations/sync_service.rb'
- 'clear_chat_history.rb'
- 'debug_auth.rb'
- 'debug_token.rb'
- 'force_reset.rb'
- 'promote_super_admin.rb'
- 'setup_docker_env.rb'

View File

@ -60,6 +60,7 @@ gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --#
gem 'groupdate'
gem 'fiddle'
gem 'pg'
gem 'redis'
gem 'redis-namespace'
@ -271,4 +272,4 @@ group :development, :test do
gem 'spring-watcher-listen'
end
gem "rqrcode", "~> 3.2"
gem 'rqrcode', '~> 3.2'

View File

@ -198,7 +198,7 @@ GEM
bigdecimal
rexml
crass (1.0.6)
csv (3.3.0)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
database_cleaner (2.0.2)
@ -214,7 +214,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.4.1)
date (3.5.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@ -338,6 +338,7 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
fiddle (1.1.8)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@ -564,11 +565,11 @@ GEM
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
uri
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
net-imap (0.6.2)
date
net-protocol
net-pop (0.1.2)
@ -653,7 +654,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@ -958,7 +959,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.3)
timeout (0.6.0)
trailblazer-option (0.1.2)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
@ -982,7 +983,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.4)
uri (1.1.1)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@ -1071,6 +1072,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
fiddle
flag_shih_tzu
foreman
geocoder
@ -1180,7 +1182,7 @@ DEPENDENCIES
working_hours
RUBY VERSION
ruby 3.4.4p34
ruby 3.4.4p34
BUNDLED WITH
2.5.11
2.5.5

View File

@ -36,13 +36,13 @@ run:
@if [ -f ./.overmind.sock ]; then \
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
else \
overmind start -f Procfile.dev; \
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev; \
fi
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
@ -59,4 +59,7 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
sidekiq:
export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; bundle exec sidekiq -C config/sidekiq.yml
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker sidekiq

View File

@ -62,7 +62,6 @@
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-24",
"buildpacks": [
{
"url": "heroku/nodejs"

View File

@ -6,18 +6,13 @@ class Messages::MessageBuilder
attr_reader :message
def initialize(user, conversation, params)
@params = params
@private = params[:private] || false
@conversation = conversation
@user = user
@conversation = conversation
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@params = params
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = content_attributes&.dig(:in_reply_to)
@items = content_attributes&.dig(:items)
init_message_attributes
end
def perform
@ -56,16 +51,14 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
attachment.file_type = resolve_file_type(uploaded_attachment)
end
end
def resolve_file_type(attachment)
attachment.is_a?(String) ? file_type_by_signed_id(attachment) : file_type(attachment&.content_type)
end
def process_emails
return unless @conversation.inbox&.inbox_type == 'Email'
@ -140,7 +133,7 @@ class Messages::MessageBuilder
content_type: @params[:content_type],
content_attributes: content_attributes.presence,
items: @items,
in_reply_to: @in_reply_to,
in_reply_to_id: @in_reply_to,
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
@ -222,6 +215,23 @@ class Messages::MessageBuilder
'agent' => UserDrop.new(sender)
})
end
def init_message_attributes
@private = @params[:private] || false
@message_type = @params[:message_type] || 'outgoing'
@attachments = @params[:attachments]
@automation_rule = extract_automation_rule
@in_reply_to = extract_in_reply_to
@items = content_attributes&.dig(:items)
end
def extract_automation_rule
content_attributes&.dig(:automation_rule_id)
end
def extract_in_reply_to
@params[:in_reply_to_id] || @params[:in_reply_to] || content_attributes&.dig(:in_reply_to)
end
end
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')

View File

@ -1,72 +1,64 @@
module Api
module V1
module Accounts
module Captain
class AssistantsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
def index
@assistants = current_account.captain_assistants.order(created_at: :desc)
render json: @assistants
end
def index
@assistants = current_account.captain_assistants.order(created_at: :desc)
render json: @assistants
end
def show
render json: @assistant
end
def show
render json: @assistant
end
def create
@assistant = current_account.captain_assistants.new(assistant_params)
if @assistant.save
render json: @assistant
else
render_error_response(@assistant)
end
end
def update
if @assistant.update(assistant_params)
render json: @assistant
else
render_error_response(@assistant)
end
end
def destroy
@assistant.destroy
head :ok
end
def playground
# TODO: Implement playground logic
render json: { message: 'Playground not implemented yet' }, status: :ok
end
def test_webhook
# TODO: Implement webhook test logic
render json: { message: 'Webhook test not implemented yet' }, status: :ok
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:id])
end
def assistant_params
params.require(:assistant).permit(
:name,
:description,
:llm_provider,
:llm_model,
:api_key,
config: {},
response_guidelines: [],
guardrails: [],
handoff_webhook_config: {}
)
end
end
end
def create
@assistant = current_account.captain_assistants.new(assistant_params)
if @assistant.save
render json: @assistant
else
render_error_response(@assistant)
end
end
def update
if @assistant.update(assistant_params)
render json: @assistant
else
render_error_response(@assistant)
end
end
def destroy
@assistant.destroy
head :ok
end
def playground
# TODO: Implement playground logic
render json: { message: 'Playground not implemented yet' }, status: :ok
end
def test_webhook
# TODO: Implement webhook test logic
render json: { message: 'Webhook test not implemented yet' }, status: :ok
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:id])
end
def assistant_params
params.require(:assistant).permit(
:name,
:description,
:llm_provider,
:llm_model,
:api_key,
config: {},
response_guidelines: [],
guardrails: [],
handoff_webhook_config: {}
)
end
end

View File

@ -1,19 +1,88 @@
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
before_action :fetch_pricings, only: [:index]
before_action :fetch_pricing, only: [:show, :update, :destroy]
def index
render json: @pricings
end
def show
render json: @pricing
end
def create
inbox_ids_param = params[:pricing][:inbox_ids]
if inbox_ids_param.is_a?(Array) && inbox_ids_param.present?
render json: create_for_multiple_inboxes(inbox_ids_param)
else
render json: create_single_pricing
end
rescue StandardError => e
Rails.logger.error "Error creating pricing: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: { error: e.message }, status: :unprocessable_entity
end
def update
# On update, if multiple inboxes are passed, we technically can't 'split' this ID.
# We assume usage of the first inbox or nil.
# If the user wants to assign to multiple, they should create new ones.
target_inbox_id = params[:pricing][:inbox_ids]&.first
@pricing.update!(pricing_params.merge(inbox_id: target_inbox_id))
render json: @pricing
rescue StandardError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def destroy
@pricing.destroy!
head :ok
end
private
def fetch_pricings
@pricings = current_account.captain_pricings
def fetch_pricing
@pricing = current_account.captain_pricings.find(params[:id])
end
return unless params[:query].present?
def fetch_pricings
@pricings = current_account.captain_pricings.order(created_at: :desc)
# Filter by inbox if provided (returns Specific Inbox + Global rules)
@pricings = @pricings.where(inbox_id: [params[:inbox_id], nil]) if params[:inbox_id].present?
return if params[:query].blank?
# Fuzzy search using ILIKE for case-insensitive matching
# We wrap the query in % for wildcard matching on both sides
@pricings = @pricings.where('suite_category ILIKE ?', "%#{params[:query]}%")
@pricings = @pricings.left_outer_joins(:captain_brand).where(
'suite_category ILIKE :query OR captain_brands.name ILIKE :query',
query: "%#{params[:query]}%"
)
end
def pricing_params
params.require(:pricing).permit(:captain_brand_id, :day_range, :suite_category, :duration, :price)
end
def create_for_multiple_inboxes(inbox_ids)
last_pricing = nil
ActiveRecord::Base.transaction do
inbox_ids.each do |iid|
pricing = current_account.captain_pricings.new(pricing_params)
pricing.inbox_id = iid.to_i
pricing.save!
last_pricing = pricing
end
end
last_pricing
end
def create_single_pricing
@pricing = current_account.captain_pricings.new(pricing_params)
@pricing.inbox_id = nil
@pricing.save!
@pricing
end
end

View File

@ -1,71 +1,63 @@
module Api
module V1
module Accounts
module Captain
class ScenariosController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
before_action :fetch_scenario, only: [:show, :update, :destroy]
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
before_action :fetch_scenario, only: [:show, :update, :destroy]
def index
@scenarios = @assistant.captain_scenarios.order(created_at: :desc)
render json: @scenarios
end
def index
@scenarios = @assistant.captain_scenarios.order(created_at: :desc)
render json: @scenarios
end
def show
render json: @scenario
end
def show
render json: @scenario
end
def create
@scenario = @assistant.captain_scenarios.new(scenario_params)
@scenario.account = current_account
if @scenario.save
render json: @scenario
else
render_error_response(@scenario)
end
end
def update
if @scenario.update(scenario_params)
render json: @scenario
else
render_error_response(@scenario)
end
end
def destroy
@scenario.destroy
head :ok
end
def suggest_triggers
# TODO: Implement AI suggestion logic
# For now, return a dummy list based on title/instruction if possible, or empty
render json: { keywords: 'keyword1, keyword2' }
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:assistant_id])
end
def fetch_scenario
@scenario = @assistant.captain_scenarios.find(params[:id])
end
def scenario_params
params.permit(
:title,
:description,
:instruction,
:trigger_keywords,
:enabled,
tools: []
)
end
end
end
def create
@scenario = @assistant.captain_scenarios.new(scenario_params)
@scenario.account = current_account
if @scenario.save
render json: @scenario
else
render_error_response(@scenario)
end
end
def update
if @scenario.update(scenario_params)
render json: @scenario
else
render_error_response(@scenario)
end
end
def destroy
@scenario.destroy
head :ok
end
def suggest_triggers
# TODO: Implement AI suggestion logic
# For now, return a dummy list based on title/instruction if possible, or empty
render json: { keywords: 'keyword1, keyword2' }
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:assistant_id])
end
def fetch_scenario
@scenario = @assistant.captain_scenarios.find(params[:id])
end
def scenario_params
params.permit(
:title,
:description,
:instruction,
:trigger_keywords,
:enabled,
tools: []
)
end
end

View File

@ -1,73 +1,65 @@
module Api
module V1
module Accounts
module Captain
class ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
class Api::V1::Accounts::Captain::ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
NATIVE_TOOLS = [
{ key: 'react_to_message', name: 'React to Message', description: 'Reage a mensagens do usuário com emojis adequados.' },
{ key: 'check_availability', name: 'Check Availability', description: 'Verifica a disponibilidade de quartos e datas.' },
{ key: 'update_contact', name: 'Update Contact', description: 'Atualiza informações do contato (nome, email, telefone).' },
{ key: 'create_reservation_intent', name: 'Create Reservation Intent', description: 'Cria uma intenção de reserva e calcula valores.' },
{ key: 'generate_pix', name: 'Generate Pix', description: 'Gera código Pix Copy & Paste e QR Code.' },
{ key: 'list_reservations', name: 'List Reservations', description: 'Lista reservas anteriores do cliente.' },
{ key: 'status_suites', name: 'Status Suites', description: 'Verifica o status atual de ocupação das suítes.' },
{ key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
]
NATIVE_TOOLS = [
{ key: 'react_to_message', name: 'React to Message', description: 'Reage a mensagens do usuário com emojis adequados.' },
{ key: 'check_availability', name: 'Check Availability', description: 'Verifica a disponibilidade de quartos e datas.' },
{ key: 'update_contact', name: 'Update Contact', description: 'Atualiza informações do contato (nome, email, telefone).' },
{ key: 'create_reservation_intent', name: 'Create Reservation Intent', description: 'Cria uma intenção de reserva e calcula valores.' },
{ key: 'generate_pix', name: 'Generate Pix', description: 'Gera código Pix Copy & Paste e QR Code.' },
{ key: 'list_reservations', name: 'List Reservations', description: 'Lista reservas anteriores do cliente.' },
{ key: 'status_suites', name: 'Status Suites', description: 'Verifica o status atual de ocupação das suítes.' },
{ key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
].freeze
def index
tools = NATIVE_TOOLS.map do |tool|
config = @assistant.captain_tool_configs.find_by(tool_key: tool[:key])
tool.merge(
enabled: config&.is_enabled.nil? || config.is_enabled,
webhook_url: config&.webhook_url,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token,
fallback_message: config&.fallback_message
)
end
render json: tools
end
def index
tools = NATIVE_TOOLS.map do |tool|
config = @assistant.captain_tool_configs.find_by(tool_key: tool[:key])
tool.merge(
enabled: config&.is_enabled.nil? || config.is_enabled,
webhook_url: config&.webhook_url,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token,
fallback_message: config&.fallback_message
)
end
render json: tools
end
def update
tool_key = params[:id]
config = @assistant.captain_tool_configs.find_or_initialize_by(tool_key: tool_key)
def update
tool_key = params[:id]
config = @assistant.captain_tool_configs.find_or_initialize_by(tool_key: tool_key)
# Ensure context unique constraint is respected
config.account = current_account
# Ensure context unique constraint is respected
config.account = current_account
# Map 'enabled' from frontend to 'is_enabled' in DB
update_params = tool_params
update_params[:is_enabled] = update_params.delete(:enabled) if update_params.key?(:enabled)
# Map 'enabled' from frontend to 'is_enabled' in DB
update_params = tool_params
update_params[:is_enabled] = update_params.delete(:enabled) if update_params.key?(:enabled)
config.assign_attributes(update_params)
config.assign_attributes(update_params)
if config.save
render json: config
else
render_error_response(config)
end
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:assistant_id])
end
def tool_params
params.require(:tool).permit(
:enabled,
:is_enabled,
:webhook_url,
:plug_play_id,
:plug_play_token,
:fallback_message
)
end
end
end
if config.save
render json: config
else
render_error_response(config)
end
end
private
def fetch_assistant
@assistant = current_account.captain_assistants.find(params[:assistant_id])
end
def tool_params
params.require(:tool).permit(
:enabled,
:is_enabled,
:webhook_url,
:plug_play_id,
:plug_play_token,
:fallback_message
)
end
end

View File

@ -1,21 +1,11 @@
module Api
module V1
module Accounts
module Captain
module Units
class ReservationsSyncController < Api::V1::Accounts::BaseController
def create
unit = Current.account.captain_units.find(params[:unit_id])
::Captain::Reservations::SyncService.new(unit).perform
head :ok
rescue ActiveRecord::RecordNotFound
render_not_found_error('Unit not found')
rescue StandardError => e
render_error(e.message)
end
end
end
end
end
class Api::V1::Accounts::Captain::Units::ReservationsSyncController < Api::V1::Accounts::BaseController
def create
unit = Current.account.captain_units.find(params[:unit_id])
::Captain::Reservations::SyncService.new(unit).perform
head :ok
rescue ActiveRecord::RecordNotFound
render_not_found_error('Unit not found')
rescue StandardError => e
render_error(e.message)
end
end

View File

@ -1,65 +1,57 @@
module Api
module V1
module Accounts
module Captain
class UnitsController < Api::V1::Accounts::BaseController
def index
@units = Current.account.captain_units
end
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
def index
@units = Current.account.captain_units
end
def show
@unit = Current.account.captain_units.find(params[:id])
end
def show
@unit = Current.account.captain_units.find(params[:id])
end
def create
@unit = Current.account.captain_units.new(unit_params)
@unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now
def create
@unit = Current.account.captain_units.new(unit_params)
@unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now
if @unit.save
render 'show', status: :created
else
render_error_response(@unit)
end
end
def update
@unit = Current.account.captain_units.find(params[:id])
if @unit.update(unit_params)
render 'show'
else
render_error_response(@unit)
end
end
def destroy
@unit = Current.account.captain_units.find(params[:id])
@unit.destroy
head :ok
end
private
def unit_params
params.require(:unit).permit(
:name,
:status,
:reservations_sync_enabled,
:plug_play_id,
:plug_play_token,
:webhook_url,
:leader_whatsapp,
:reservation_source_tag,
:inter_client_id,
:inter_client_secret,
:inter_pix_key,
:inter_account_number,
visible_suite_categories: [],
suite_category_images: {}
)
end
end
end
if @unit.save
render 'show', status: :created
else
render_error_response(@unit)
end
end
def update
@unit = Current.account.captain_units.find(params[:id])
if @unit.update(unit_params)
render 'show'
else
render_error_response(@unit)
end
end
def destroy
@unit = Current.account.captain_units.find(params[:id])
@unit.destroy
head :ok
end
private
def unit_params
params.require(:unit).permit(
:name,
:status,
:reservations_sync_enabled,
:plug_play_id,
:plug_play_token,
:webhook_url,
:leader_whatsapp,
:reservation_source_tag,
:inter_client_id,
:inter_client_secret,
:inter_pix_key,
:inter_account_number,
visible_suite_categories: [],
suite_category_images: {}
)
end
end

View File

@ -19,25 +19,7 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
def serialize_insight(insight)
return nil if insight.blank?
{
id: insight.id,
conversation_id: insight.conversation_id,
account_id: insight.account_id,
contact_id: insight.contact_id,
summary_text: insight.summary_text,
structured_data: insight.structured_data,
contact_sessions_count: insight.contact_sessions_count,
last_contact_at: insight.last_contact_at,
updated_at: insight.updated_at,
generated_at: insight.generated_at,
range_from_message_id: insight.range_from_message_id,
range_to_message_id: insight.range_to_message_id,
status: insight.status,
error_message: insight.error_message,
schema_version: insight.schema_version,
model: insight.model,
confidence: insight.confidence
}
insight_attributes(insight)
end
def insights_payload
@ -68,4 +50,26 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
meta
end
def insight_attributes(insight)
{
id: insight.id,
conversation_id: insight.conversation_id,
account_id: insight.account_id,
contact_id: insight.contact_id,
summary_text: insight.summary_text,
structured_data: insight.structured_data,
contact_sessions_count: insight.contact_sessions_count,
last_contact_at: insight.last_contact_at,
updated_at: insight.updated_at,
generated_at: insight.generated_at,
range_from_message_id: insight.range_from_message_id,
range_to_message_id: insight.range_to_message_id,
status: insight.status,
error_message: insight.error_message,
schema_version: insight.schema_version,
model: insight.model,
confidence: insight.confidence
}
end
end

View File

@ -1,11 +1,5 @@
module Api
module V1
module Accounts
class FrequentQuestionsController < Api::V1::Accounts::BaseController
def index
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
end
end
end
class Api::V1::Accounts::FrequentQuestionsController < Api::V1::Accounts::BaseController
def index
@frequent_questions = Current.account.frequent_questions.order(occurrence_count: :desc).limit(50)
end
end

View File

@ -1,43 +1,33 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Inboxes::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
# Returns collections linked to this inbox
collection_ids = @inbox.inbox_collections.pluck(:collection_id)
@collections = Current.account.jasmine_collections.where(id: collection_ids)
render json: @collections
end
def index
# Returns collections linked to this inbox
collection_ids = @inbox.inbox_collections.pluck(:collection_id)
@collections = Current.account.jasmine_collections.where(id: collection_ids)
render json: @collections
end
def create
# Link an existing collection to this inbox
collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.create!(
collection: collection,
priority: params[:priority] || 0
)
render json: link
end
def create
# Link an existing collection to this inbox
collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.create!(
collection: collection,
priority: params[:priority] || 0
)
render json: link
end
def destroy
# Unlink a collection from this inbox
link = @inbox.inbox_collections.find_by!(collection_id: params[:id])
link.destroy!
head :no_content
end
def destroy
# Unlink a collection from this inbox
link = @inbox.inbox_collections.find_by!(collection_id: params[:id])
link.destroy!
head :no_content
end
private
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end

View File

@ -1,53 +1,43 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class ConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_config
class Api::V1::Accounts::Inboxes::Jasmine::ConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_config
def show
render json: @config
end
def show
render json: @config
end
def update
if @config.update(config_params)
render json: @config
else
render json: { error: @config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_config
@config = ::Jasmine::InboxConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def config_params
params.permit(
:is_enabled,
:system_prompt,
:playbook_prompt,
:model,
:temperature,
:rag_distance_threshold,
:rag_max_results,
:mode,
intent_keywords: {}
)
end
end
end
end
def update
if @config.update(config_params)
render json: @config
else
render json: { error: @config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def fetch_or_initialize_config
@config = ::Jasmine::InboxConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox
)
end
def config_params
params.permit(
:is_enabled,
:system_prompt,
:playbook_prompt,
:model,
:temperature,
:rag_distance_threshold,
:rag_max_results,
:mode,
intent_keywords: {}
)
end
end

View File

@ -1,85 +1,75 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class PlaygroundController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_config
class Api::V1::Accounts::Inboxes::Jasmine::PlaygroundController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_config
def test
message_content = params[:message]
def test
return render json: { error: 'Message is required' }, status: :bad_request if params[:message].blank?
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
return render json: { error: 'Message is required' }, status: :bad_request if message_content.blank?
return render json: { error: 'Jasmine is not enabled for this inbox' }, status: :unprocessable_entity unless @config&.is_enabled?
begin
response = generate_response(params[:message])
render json: build_success_payload(response)
rescue StandardError => e
handle_error(e)
end
end
# Create a mock message object for BrainService
mock_message = OpenStruct.new(
content: message_content,
inbox: @inbox,
conversation: mock_conversation
)
private
begin
response = ::Jasmine::BrainService.new(
inbox: @inbox,
conversation: mock_conversation,
message: mock_message
).respond
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
render json: {
response: response,
debug: {
model: @config.model,
temperature: @config.temperature,
rag_threshold: @config.rag_distance_threshold
}
}
rescue StandardError => e
Rails.logger.error "[Jasmine::Playground] Error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
render json: { error: e.message }, status: :internal_server_error
end
end
def fetch_config
@config = ::Jasmine::InboxConfig.find_by(
account: Current.account,
inbox: @inbox
)
end
private
def generate_response(content)
# Create a mock message object for BrainService
mock_message = OpenStruct.new(content: content, inbox: @inbox, conversation: mock_conversation)
::Jasmine::BrainService.new(inbox: @inbox, conversation: mock_conversation, message: mock_message).respond
end
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def build_success_payload(response)
{
response: response,
debug: {
model: @config.model,
temperature: @config.temperature,
rag_threshold: @config.rag_distance_threshold
}
}
end
def fetch_config
@config = ::Jasmine::InboxConfig.find_by(
account: Current.account,
inbox: @inbox
)
end
def handle_error(err)
Rails.logger.error "[Jasmine::Playground] Error: #{err.message}\n#{err.backtrace.first(5).join("\n")}"
render json: { error: err.message }, status: :internal_server_error
end
def mock_conversation
# Create a minimal mock conversation for playground testing
@mock_conversation ||= begin
mock = OpenStruct.new(
id: 0,
account_id: Current.account.id,
inbox_id: @inbox.id,
custom_attributes: { 'jasmine_state' => {} }
)
def mock_conversation
# Create a minimal mock conversation for playground testing
@mock_conversation ||= begin
mock = OpenStruct.new(
id: 0,
account_id: Current.account.id,
inbox_id: @inbox.id,
custom_attributes: { 'jasmine_state' => {} }
)
# Mock messages method to return empty array that responds to query methods
def mock.messages
[]
end
# Mock update! to do nothing (playground doesn't need state persistence)
def mock.update!(**attrs)
# no-op for playground
end
mock
end
end
end
end
# Mock messages method to return empty array that responds to query methods
def mock.messages
[]
end
# Mock update! to do nothing (playground doesn't need state persistence)
def mock.update!(**attrs)
# no-op for playground
end
mock
end
end
end

View File

@ -1,93 +1,90 @@
module Api
module V1
module Accounts
module Inboxes
module Jasmine
class ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Inboxes::Jasmine::ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
def index
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
render json: serialize_tools(configs)
end
tools = ::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
config = configs[key]
{
key: key,
name: definition[:name],
method: definition[:method].to_s.upcase,
url: definition[:url],
description: definition[:description],
is_enabled: config&.is_enabled || false,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
last_test: config ? {
at: config.last_tested_at,
status: config.last_test_status,
error: config.last_test_error,
duration: config.last_test_duration_ms
} : nil
}
end
def update
return render(json: { error: 'Invalid tool key' }, status: :bad_request) unless valid_tool_key?(params[:id])
render json: tools
end
config = find_config(params[:id])
update_config_attributes(config)
def update
tool_key = params[:id] # Using :id from route as tool_key
unless ::Jasmine::ToolConfig::DEFINITIONS.key?(tool_key)
return render json: { error: 'Invalid tool key' }, status: :bad_request
end
config = ::Jasmine::ToolConfig.find_or_initialize_by(
account: Current.account,
inbox: @inbox,
tool_key: tool_key
)
# Update attributes
config.is_enabled = params[:is_enabled]
config.plug_play_id = params[:plug_play_id]
# Secure token update: only update if present and not masked/empty
new_token = params[:plug_play_token]
if new_token.present? && new_token != '****'
config.plug_play_token = new_token
end
if config.save
render json: {
key: config.tool_key,
is_enabled: config.is_enabled,
plug_play_id: config.plug_play_id,
plug_play_token: '****'
}
else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def test
tool_key = params[:id]
begin
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
result = runner.run
render json: result
rescue => e
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
end
if config.save
render json: success_payload(config)
else
render(json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity)
end
end
def test
tool_key = params[:id]
begin
runner = ::Jasmine::ToolRunner.new(@inbox, tool_key)
result = runner.run
render json: result
rescue StandardError => e
Rails.logger.error "[JasmineTools] Test failed: #{e.try(:message)}"
render json: { success: false, error: "Server Error: #{e.try(:message)}" }, status: :internal_server_error
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def valid_tool_key?(key)
::Jasmine::ToolConfig::DEFINITIONS.key?(key)
end
def find_config(key)
::Jasmine::ToolConfig.find_or_initialize_by(account: Current.account, inbox: @inbox, tool_key: key)
end
def update_config_attributes(config)
config.is_enabled = params[:is_enabled]
config.plug_play_id = params[:plug_play_id]
new_token = params[:plug_play_token]
config.plug_play_token = new_token if new_token.present? && new_token != '****'
end
def success_payload(config)
{ key: config.tool_key, is_enabled: config.is_enabled, plug_play_id: config.plug_play_id, plug_play_token: '****' }
end
def serialize_tools(configs)
::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition|
build_tool_hash(key, definition, configs[key])
end
end
def build_tool_hash(key, definition, config)
{
key: key,
name: definition[:name],
method: definition[:method].to_s.upcase,
url: definition[:url],
description: definition[:description],
is_enabled: config&.is_enabled || false,
plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token.present? ? '****' : nil,
last_test: serialize_last_test(config)
}
end
def serialize_last_test(config)
return unless config
{
at: config.last_tested_at,
status: config.last_test_status,
error: config.last_test_error,
duration: config.last_test_duration_ms
}
end
end

View File

@ -26,18 +26,10 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
status = status_data['status'] || status_data['state'] || status_data
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
return
end
return if already_connected?(status)
qr_data = client.get_qr_code(user_token)
Rails.logger.info "Wuzapi QR Data Response keys: #{begin
qr_data.keys
rescue StandardError
'nil'
end}"
log_qr_data_keys(qr_data)
render json: qr_data
rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi QR Error: #{e.message}"
@ -118,8 +110,24 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
raise 'Token Wuzapi ausente; reprovisionar usuário'
else
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..-1]}"
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..]}"
end
token
end
def already_connected?(status)
if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
true
else
false
end
end
def log_qr_data_keys(qr_data)
Rails.logger.info "Wuzapi QR Data Response keys: #{qr_data.keys}"
rescue StandardError
Rails.logger.info 'Wuzapi QR Data Response keys: nil'
end
end

View File

@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::
Rails.logger.error(
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}"
)
hook.update_columns(settings: settings)
hook.update_columns(settings: settings) # rubocop:disable Rails/SkipsModelValidations
end
end

View File

@ -1,51 +1,42 @@
module Api
module V1
module Accounts
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
def index
scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope
end
def index
scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope
end
def create
@collection = Current.account.jasmine_collections.new(collection_params)
def create
@collection = Current.account.jasmine_collections.new(collection_params)
if @collection.save
# Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
inbox&.inbox_collections&.create(collection: @collection, priority: 10)
end
render json: @collection
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
if @collection.destroy
head :no_content
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def find_collection
@collection = Current.account.jasmine_collections.find(params[:id])
end
def collection_params
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
end
end
if @collection.save
# Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
inbox&.inbox_collections&.create(collection: @collection, priority: 10)
end
render json: @collection
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
end
def destroy
if @collection.destroy
head :no_content
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def find_collection
@collection = Current.account.jasmine_collections.find(params[:id])
end
def collection_params
params.require(:collection).permit(:name, :description, :visibility, :owner_inbox_id)
end
end

View File

@ -1,42 +1,34 @@
module Api
module V1
module Accounts
module Jasmine
class DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def index
render json: @collection.documents.order(created_at: :desc)
end
def index
render json: @collection.documents.order(created_at: :desc)
end
def create
@document = @collection.documents.new(document_params)
@document.account = Current.account
def create
@document = @collection.documents.new(document_params)
@document.account = Current.account
if @document.save
render json: @document
else
render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
@document = @collection.documents.find(params[:id])
@document.destroy!
head :no_content
end
private
def fetch_collection
@collection = Current.account.jasmine_collections.find(params[:collection_id])
end
def document_params
params.require(:document).permit(:title, :content)
end
end
end
if @document.save
render json: @document
else
render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
@document = @collection.documents.find(params[:id])
@document.destroy!
head :no_content
end
private
def fetch_collection
@collection = Current.account.jasmine_collections.find(params[:collection_id])
end
def document_params
params.require(:document).permit(:title, :content)
end
end

View File

@ -1,43 +1,35 @@
module Api
module V1
module Accounts
module Jasmine
class InboxCollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Jasmine::InboxCollectionsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index
render json: @inbox.inbox_collections.includes(:collection)
end
def index
render json: @inbox.inbox_collections.includes(:collection)
end
def create
collection = Current.account.jasmine_collections.find(params[:collection_id])
def create
collection = Current.account.jasmine_collections.find(params[:collection_id])
link = @inbox.inbox_collections.new(
collection: collection,
account: Current.account,
priority: params[:priority] || 0
)
link = @inbox.inbox_collections.new(
collection: collection,
account: Current.account,
priority: params[:priority] || 0
)
if link.save
render json: link
else
render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id])
link.destroy
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
end
if link.save
render json: link
else
render json: { error: link.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
def destroy
link = @inbox.inbox_collections.find_by!(collection_id: params[:collection_id])
link.destroy
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
end

View File

@ -1,36 +1,28 @@
module Api
module V1
module Accounts
module Jasmine
class InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config
end
def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config
end
def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
if config.update(config_params)
render json: config
else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def config_params
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
end
end
end
if config.update(config_params)
render json: config
else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def config_params
params.require(:inbox_config).permit(:name, :system_prompt, :is_enabled, :mode)
end
end

View File

@ -1,25 +1,17 @@
module Public
module Api
module V1
module Captain
class PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
class Public::Api::V1::Captain::PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
def show
@charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment)
def show
@charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment)
return unless @charge.nil?
return unless @charge.nil?
render plain: 'Link de pagamento inválido ou expirado.', status: :not_found
return
render plain: 'Link de pagamento inválido ou expirado.', status: :not_found
return
# @charge is available for the view
# It should be a Captain::PixCharge model
end
end
end
end
# @charge is available for the view
# It should be a Captain::PixCharge model
end
end

View File

@ -20,7 +20,12 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end
def instance_meta
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
@metrics['Database Migrations'] = begin
ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
rescue StandardError => e
Rails.logger.warn "Migration context check failed: #{e.message}"
'unknown'
end
end
def chatwoot_version
@ -28,7 +33,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end
def sha
@metrics['Git SHA'] = GIT_HASH
@metrics['Git SHA'] = defined?(GIT_HASH) ? GIT_HASH : 'n/a'
end
def postgres_status
@ -53,7 +58,8 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
end
rescue Redis::CannotConnectError
rescue StandardError => e
Rails.logger.warn "Redis status check failed: #{e.message}"
@metrics['Redis alive'] = false
end
end

View File

@ -2,6 +2,10 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern
def process_payload
# CRITICAL: Remove RawMessage IMMEDIATELY to prevent Rails logging from serializing binary data
params[:event]&.delete('RawMessage')
params.dig(:event, 'Message')&.delete('RawMessage')
if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
@ -17,18 +21,158 @@ class Webhooks::WhatsappController < ActionController::API
perform_sync if params[:awaitResponse].present?
return if performed?
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash)
Webhooks::WhatsappEventsJob.perform_later(sanitize_payload_for_sidekiq)
head :ok
end
def perform_sync
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash)
Webhooks::WhatsappEventsJob.perform_now(sanitize_payload_for_sidekiq)
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
head :unauthorized
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
head :not_found
end
# WHITELIST approach: Build a clean payload with ONLY allowed fields
# This prevents ANY binary data from leaking into JSON serialization
def sanitize_payload_for_sidekiq
raw = params.to_unsafe_hash
clean_payload = build_base_payload(raw)
clean_payload['event'] = build_clean_event(raw['event']) if raw['event'].is_a?(Hash)
if raw['whatsapp'].is_a?(Hash)
clean_payload['whatsapp'] = { 'event' => clean_payload['event'] }.merge(
raw['whatsapp'].slice('type', 'state', 'instanceName', 'userID')
)
end
Rails.logger.info 'WuzAPI: Payload sanitized (WHITELIST mode)'
deep_force_utf8(clean_payload)
end
def build_base_payload(raw)
{
'type' => raw['type'],
'state' => raw['state'],
'instanceName' => raw['instanceName'],
'userID' => raw['userID'],
'controller' => raw['controller'],
'action' => raw['action'],
'phone_number' => raw['phone_number']
}
end
def build_clean_event(raw_event)
clean_event = {}
if raw_event['Info'].is_a?(Hash)
clean_event['Info'] = raw_event['Info'].slice(
'ID', 'Type', 'MediaType', 'Chat', 'Sender', 'SenderAlt', 'RecipientAlt', 'IsFromMe', 'IsGroup',
'Timestamp', 'PushName', 'MessageSource'
)
end
# Safe event metadata
%w[Chat Sender IsFromMe IsGroup Timestamp AddressingMode BroadcastListOwner
BroadcastRecipients RecipientAlt SenderAlt MessageIDs MessageSender].each do |key|
clean_event[key] = raw_event[key] if raw_event.key?(key)
end
if raw_event['Message'].is_a?(Hash)
clean_msg = build_clean_message(raw_event['Message'])
clean_event['Message'] = clean_msg unless clean_msg.empty?
end
clean_event
end
def build_clean_message(msg)
clean_msg = {}
clean_msg['conversation'] = msg['conversation'] if msg['conversation'].is_a?(String)
clean_msg.merge!(clean_extended_text_message(msg['extendedTextMessage']))
clean_msg.merge!(clean_media_message(msg, 'imageMessage'))
clean_msg.merge!(clean_media_message(msg, 'videoMessage'))
clean_msg.merge!(clean_media_message(msg, 'audioMessage'))
clean_msg.merge!(clean_document_message(msg['documentMessage']))
clean_msg
end
def clean_extended_text_message(ext_msg)
return {} unless ext_msg.is_a?(Hash)
result = { 'extendedTextMessage' => { 'text' => ext_msg['text'] } }
result['extendedTextMessage']['contextInfo'] = clean_context_info(ext_msg['contextInfo']) if ext_msg['contextInfo'].is_a?(Hash)
result
end
def clean_context_info(ctx)
{
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
'participant' => ctx['participant']
}.compact
end
def clean_media_message(msg, type)
media = msg[type]
return {} unless media.is_a?(Hash)
clean_data = {
'URL' => media['URL'] || media['url'],
'directPath' => media['directPath'],
'mediaKey' => media['mediaKey'],
'fileEncSha256' => media['fileEncSha256'] || media['fileEncSHA256'],
'fileSha256' => media['fileSha256'] || media['fileSHA256'],
'fileLength' => media['fileLength'],
'mimetype' => media['mimetype'],
'seconds' => media['seconds'],
'caption' => media['caption'],
'ptt' => media['ptt'],
'width' => media['width'],
'height' => media['height']
}
clean_data['contextInfo'] = clean_context_info(media['contextInfo']) if media['contextInfo'].is_a?(Hash)
{ type => clean_data.compact }
end
def clean_document_message(doc)
return {} unless doc.is_a?(Hash)
{
'documentMessage' => {
'URL' => doc['URL'] || doc['url'],
'directPath' => doc['directPath'],
'mediaKey' => doc['mediaKey'],
'fileEncSha256' => doc['fileEncSha256'] || doc['fileEncSHA256'],
'fileSha256' => doc['fileSha256'] || doc['fileSHA256'],
'fileLength' => doc['fileLength'],
'mimetype' => doc['mimetype'],
'fileName' => doc['fileName'],
'title' => doc['title']
}.compact
}
end
def deep_force_utf8(obj)
case obj
when String
(obj.frozen? ? obj.dup : obj).force_encoding('UTF-8')
.encode('UTF-8', invalid: :replace, undef: :replace)
when Hash
obj.transform_values { |v| deep_force_utf8(v) }
when Array
obj.map { |v| deep_force_utf8(v) }
else
obj
end
end
def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present?

View File

@ -1,4 +1,4 @@
class Webhooks::WuzapiController < ActionController::Base
class Webhooks::WuzapiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :fetch_inbox
before_action :verify_secret

View File

@ -1,965 +0,0 @@
<script setup>
/* eslint-disable no-console, no-alert, vue/no-bare-strings-in-template, vue/no-static-inline-styles */
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
// --- State ---
const view = ref('form'); // form, payment, success
const isDataLoading = ref(true);
const isLoading = ref(false);
const isCopied = ref(false);
const submissionStatus = ref({
loading: false,
error: null,
success: false,
reservationId: null, // Track ID for polling
pix: {
copyPasteCode: '',
qrCodeValue: '',
},
scarcityText: '',
});
const appConfig = reactive({
title: 'Reserva Premium',
subtitle: 'Hotel 1001 Noites Prime',
phone_number: '',
primary_color: '#1E90FF',
secondary_color: '#1B3B5F',
});
const formData = reactive({
nome: '',
checkInDateTime: '',
telefone: '',
email: '',
cpf: '',
observacao: '',
selectedBrand: '',
selectedUnit: '',
selectedCategory: '',
stayDuration: '',
selectedExtras: [], // Future Phase
});
// Options State
const brands = ref([]);
// const units = ref([]); // Removed unused var
const pricings = ref([]);
const extras = ref([]);
const unitOptions = ref([]);
const categoryOptions = ref([]);
const durationOptions = ref([]);
// Price State
const calculatedPrice = ref(null);
const isPriceLoading = ref(false);
// --- API Methods ---
const fetchMasterData = async () => {
isDataLoading.value = true;
try {
const pathParts = window.location.pathname.split('/');
const accountIdIndex = pathParts.indexOf('accounts') + 1;
const accountId = pathParts[accountIdIndex];
const response = await fetch(
`/public/api/v1/captain/master_data?account_id=${accountId}`
);
if (!response.ok) throw new Error('Failed to load data');
const data = await response.json();
brands.value = data.brands;
pricings.value = data.pricings;
extras.value = data.extras;
if (data.app_config) {
appConfig.title = data.app_config.title || appConfig.title;
appConfig.subtitle = data.app_config.subtitle || appConfig.subtitle;
appConfig.phone_number = data.app_config.phone_number || '';
appConfig.primary_color =
data.app_config.primary_color || appConfig.primary_color;
appConfig.secondary_color =
data.app_config.secondary_color || appConfig.secondary_color;
}
} catch (error) {
// console.error("Master Data Error:", error);
} finally {
isDataLoading.value = false;
}
};
// --- Watchers & Computed ---
// When Brand changes
const handleBrandChange = () => {
formData.selectedUnit = '';
formData.selectedCategory = '';
formData.stayDuration = '';
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
if (brand) {
unitOptions.value = brand.units.map(u => ({
value: String(u.id),
label: u.name,
}));
durationOptions.value = brand.stay_durations.map(d => ({
value: d,
label: d,
}));
} else {
unitOptions.value = [];
durationOptions.value = [];
}
};
// When Unit changes
const handleUnitChange = () => {
formData.selectedCategory = '';
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
const unit = brand?.units.find(u => String(u.id) === formData.selectedUnit);
if (
unit &&
unit.visible_suite_categories &&
unit.visible_suite_categories.length > 0
) {
categoryOptions.value = unit.visible_suite_categories.map(c => ({
value: c,
label: c,
}));
} else if (
brand &&
brand.suite_categories &&
brand.suite_categories.length > 0
) {
categoryOptions.value = brand.suite_categories.map(c => ({
value: c,
label: c,
}));
} else {
categoryOptions.value = [];
}
};
// Price Calculation Logic
const suiteImage = computed(() => {
if (!formData.selectedBrand || !formData.selectedCategory) return null;
const brand = brands.value.find(b => String(b.id) === formData.selectedBrand);
if (!brand) return null;
const images = brand.suiteImages || brand.suite_images || {};
return images[formData.selectedCategory] || null;
});
// Price Calculation Logic
const calculatePrice = () => {
if (
!formData.selectedBrand ||
!formData.selectedCategory ||
!formData.stayDuration ||
!formData.checkInDateTime
) {
calculatedPrice.value = null;
return;
}
isPriceLoading.value = true;
// Simulate async price lookup or local calc
setTimeout(() => {
try {
const brandId = parseInt(formData.selectedBrand, 10);
const date = new Date(formData.checkInDateTime);
const dayIndex = date.getDay(); // 0 = Sunday, 6 = Saturday
const daysMap = [
'DOMINGO',
'SEGUNDA',
'TERÇA',
'QUARTA',
'QUINTA',
'SEXTA',
'SÁBADO',
];
const currentDayName = daysMap[dayIndex];
// Find matching pricing row
const priceRow = pricings.value.find(p => {
if (p.captain_brand_id !== brandId) return false;
if (p.suite_category !== formData.selectedCategory) return false;
if (p.duration !== formData.stayDuration) return false;
// Check day range
const range = (p.day_range || p.dayRange || '').toUpperCase();
if (range.includes(' A ')) {
if (range === 'SEGUNDA A QUARTA') {
return ['SEGUNDA', 'TERÇA', 'QUARTA'].includes(currentDayName);
}
if (range === 'QUINTA A DOMINGO') {
return ['QUINTA', 'SEXTA', 'SÁBADO', 'DOMINGO'].includes(
currentDayName
);
}
return false;
}
const days = range.split(',').map(d => d.trim());
return days.includes(currentDayName);
});
if (priceRow) {
calculatedPrice.value = parseFloat(priceRow.price);
} else {
calculatedPrice.value = null;
}
} catch (e) {
// console.error(e);
} finally {
isPriceLoading.value = false;
}
}, 300);
};
watch(
() => [
formData.selectedBrand,
formData.selectedCategory,
formData.stayDuration,
formData.checkInDateTime,
],
calculatePrice
);
// --- Actions ---
const calculateCheckOut = (startStr, durationStr) => {
const start = new Date(startStr);
let hoursToAdd = 4;
if (durationStr?.toUpperCase().includes('PERNOITE')) {
hoursToAdd = 12;
}
const end = new Date(start.getTime() + hoursToAdd * 60 * 60 * 1000);
return end.toISOString();
};
const formatCurrency = val => {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(val);
};
const handleCopyPix = () => {
if (submissionStatus.value?.pix?.copyPasteCode) {
navigator.clipboard.writeText(submissionStatus.value.pix.copyPasteCode);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 2000);
}
};
const handleResetForm = () => {
view.value = 'form';
formData.nome = '';
formData.telefone = '';
formData.cpf = '';
formData.email = '';
formData.checkInDateTime = '';
formData.observacao = '';
formData.selectedBrand = '';
formData.selectedUnit = '';
formData.selectedCategory = '';
formData.stayDuration = '';
submissionStatus.value = null;
calculatedPrice.value = null;
};
const generateScarcityMessage = () => {
const messages = [
'🔥 Resta apenas 1 suíte disponível para esta data!',
'⚡ Alta demanda! 2 pessoas estão vendo esta suíte agora.',
'💎 Última chance! O hotel está quase lotado.',
'⏳ Segure sua vaga! Restam apenas 2 suítes.',
'👀 Muita procura para esta data. Garanta sua reserva!',
];
return messages[Math.floor(Math.random() * messages.length)];
};
// Functions defined before use
const triggerConfetti = () => {
import('canvas-confetti')
.then(confetti => {
confetti.default({
particleCount: 150,
spread: 70,
origin: { y: 0.6 },
});
})
.catch(() => {});
};
let pollingInterval = null;
const checkPaymentStatus = async () => {
if (!submissionStatus.value?.reservationId) return;
try {
const response = await fetch(
`/public/api/v1/captain/reservations/${submissionStatus.value.reservationId}/status`
);
if (!response.ok) return;
const data = await response.json();
if (data.payment_status === 'paid') {
clearInterval(pollingInterval);
view.value = 'success';
triggerConfetti();
}
} catch (error) {
// console.error('Error polling status:', error);
}
};
const startPolling = () => {
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(checkPaymentStatus, 5000);
};
const handleSubmit = async () => {
if (!calculatedPrice.value) {
return;
}
isLoading.value = true;
submissionStatus.value = null;
try {
const pathParts = window.location.pathname.split('/');
const accountId = pathParts[pathParts.indexOf('accounts') + 1];
const payload = {
brand_id: formData.selectedBrand,
unit_id: formData.selectedUnit,
contact_name: formData.nome,
phone_number: formData.telefone.replace(/\D/g, ''),
email: formData.email,
cpf: formData.cpf.replace(/\D/g, ''),
check_in_at: formData.checkInDateTime,
duration_minutes: 0,
check_out_at: calculateCheckOut(
formData.checkInDateTime,
formData.stayDuration
),
total_amount: calculatedPrice.value,
metadata: {
category: formData.selectedCategory,
stay_duration: formData.stayDuration,
observacao: formData.observacao,
},
};
const response = await fetch(
`/public/api/v1/captain/reservations?account_id=${accountId}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
const result = await response.json();
if (response.ok) {
if (result.metadata?.pix) {
submissionStatus.value = {
message:
'Sua reserva foi iniciada! Realize o pagamento via Pix para confirmar.',
type: 'success',
reservationId: result.reservation_id,
pix: result.metadata.pix,
scarcityText: generateScarcityMessage(),
};
view.value = 'payment';
// Trigger polling
startPolling();
} else {
submissionStatus.value = {
message: 'Reserva criada, mas falha ao gerar Pix. Entre em contato.',
type: 'error',
};
view.value = 'payment';
}
} else {
throw new Error(result.error || 'Falha ao criar reserva');
}
} catch (error) {
// console.error('Submit Error:', error);
// alert('Erro: ' + error.message);
} finally {
isLoading.value = false;
}
};
onUnmounted(() => {
if (pollingInterval) clearInterval(pollingInterval);
});
// Lifecycle
onMounted(() => {
fetchMasterData();
});
const formatPhone = event => {
let value = event.target.value.replace(/\D/g, '');
if (value.length > 11) value = value.slice(0, 11);
if (value.length > 10) {
value = value.replace(/^(\d{2})(\d{5})(\d{4}).*/, '($1) $2-$3');
} else if (value.length > 5) {
value = value.replace(/^(\d{2})(\d{4})(\d{0,4}).*/, '($1) $2-$3');
} else if (value.length > 2) {
value = value.replace(/^(\d{2})(\d{0,5}).*/, '($1) $2');
}
formData.telefone = value;
event.target.value = value;
};
const formatCPF = event => {
let value = event.target.value.replace(/\D/g, '');
if (value.length > 11) value = value.slice(0, 11);
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d)/, '$1.$2');
value = value.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
formData.cpf = value;
event.target.value = value;
};
const isValidEmail = email => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const isFormValid = computed(() => {
return (
formData.nome.trim().length > 0 &&
formData.telefone.length >= 14 && // (XX) XXXXX-XXXX is 15 chars, or (XX) XXXX-XXXX is 14
formData.cpf.length === 14 && // XXX.XXX.XXX-XX
isValidEmail(formData.email) &&
formData.checkInDateTime &&
formData.selectedBrand &&
formData.selectedUnit &&
formData.selectedCategory &&
formData.stayDuration
);
});
const viewTitle = computed(() => {
if (view.value === 'payment') return 'Pagamento Seguro';
if (view.value === 'success') return 'Reserva Confirmada';
return appConfig.title;
});
</script>
<template>
<!-- eslint-disable vue/no-bare-strings-in-template, vue/no-static-inline-styles -->
<div
class="min-h-screen py-6 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center bg-fixed"
:style="`background: linear-gradient(135deg, ${appConfig.secondary_color} 0%, ${appConfig.primary_color} 100%)`"
>
<div
class="w-full max-w-3xl bg-white rounded-[2rem] shadow-2xl overflow-hidden border border-white/10 relative"
>
<!-- Decorative Top Accent -->
<div
class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-[#1B3B5F] to-[#1E90FF]"
/>
<div class="p-6 sm:p-12">
<div
class="flex justify-between items-start mb-10 border-b border-[#1B3B5F]/10 pb-6"
>
<div class="space-y-1">
<h1
class="text-2xl sm:text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
>
{{ viewTitle }}
</h1>
<p
v-if="view === 'form'"
class="text-[#9CA3AF] text-sm font-medium"
>
{{ appConfig.subtitle }}
</p>
<p
v-if="appConfig.phone_number"
class="text-sm font-bold text-emerald-600 mt-2"
>
<i class="i-lucide-phone mr-1" />
Suporte: {{ appConfig.phone_number }}
</p>
</div>
</div>
<!-- LOADING STATE -->
<div
v-if="isDataLoading"
class="text-center py-20 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-8 h-8 border-4 border-[#1E90FF] border-t-transparent rounded-full animate-spin"
/>
<p class="text-[#9CA3AF] font-medium animate-pulse">
Carregando dados...
</p>
</div>
<!-- SUCCESS VIEW -->
<div
v-else-if="view === 'success'"
class="text-center space-y-6 p-10 bg-[#F8FAFC] border border-[#1B3B5F]/10 rounded-3xl shadow-inner animate-fade-in"
>
<div
class="mx-auto w-24 h-24 bg-green-100 rounded-full flex items-center justify-center mb-6 shadow-md"
>
<!-- Success Icon -->
<svg
class="h-12 w-12 text-green-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 class="text-3xl font-extrabold text-[#1B3B5F]">
Pagamento Confirmado!
</h2>
<p class="text-[#9CA3AF] text-lg">
Sua reserva está 100% garantida.<br />Enviamos os detalhes para o
seu e-mail.
</p>
<div class="pt-6">
<button
class="w-full px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@click="handleResetForm"
>
Fazer Nova Reserva
</button>
</div>
</div>
<!-- PAYMENT VIEW -->
<div
v-else-if="view === 'payment'"
class="text-center space-y-6 animate-fade-in"
>
<div
class="p-4 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10 mb-6"
>
<p class="text-[#1B3B5F] font-medium">
{{ submissionStatus?.message }}
</p>
</div>
<!-- Scarcity Trigger -->
<div
v-if="submissionStatus?.scarcityText"
class="animate-pulse bg-red-50 border border-red-100 p-3 rounded-xl"
>
<p
class="text-red-600 font-bold text-sm flex items-center justify-center gap-2"
>
<i class="i-lucide-flame text-lg" />
{{ submissionStatus.scarcityText }}
</p>
</div>
<!-- WhatsApp Warning -->
<div class="text-center px-4">
<p class="text-sm text-gray-600">
Após o pagamento, você receberá a confirmação imediatamente no seu
<strong class="text-green-600">WhatsApp</strong>
<span class="font-mono text-xs">({{ formData.telefone }})</span>.
<br />
<span class="text-xs text-gray-400 block mt-1"
>Certifique-se que o número informado está correto.</span
>
</p>
</div>
<div class="pt-4 pb-2 text-left">
<label
class="block text-xs font-bold text-[#1B3B5F] uppercase tracking-wide mb-2"
>Código Pix Copia e Cola</label
>
<div class="relative group">
<input
type="text"
readonly
:value="submissionStatus?.pix?.copyPasteCode || ''"
class="w-full bg-[#F8FAFC] border-[1.5px] border-[#1B3B5F]/20 rounded-xl p-4 pr-28 text-sm text-[#1B3B5F] font-mono focus:outline-none focus:border-[#1E90FF]"
/>
<div class="absolute right-2 top-1/2 -translate-y-1/2">
<button
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 rounded text-sm font-medium transition-colors"
@click="handleCopyPix"
>
{{ isCopied ? 'Copiado!' : 'Copiar' }}
</button>
</div>
</div>
</div>
<!-- QR Code Image -->
<div
v-if="submissionStatus?.pix?.qrCodeValue"
class="flex justify-center mt-4"
>
<div
class="p-4 bg-white rounded-xl shadow-lg border border-gray-100"
>
<p class="text-xs text-gray-500 mb-2">
Escaneie o QR Code no app do seu banco
</p>
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(
submissionStatus.pix.copyPasteCode
)}`"
alt="QR Code do PIX"
class="w-48 h-48 object-contain mix-blend-multiply"
/>
</div>
</div>
<div class="pt-4 border-t border-[#1B3B5F]/10">
<button
class="w-full text-[#9CA3AF] hover:text-[#1B3B5F] py-2"
@click="handleResetForm"
>
Cancelar e Voltar
</button>
</div>
</div>
<!-- FORM VIEW -->
<form v-else class="space-y-4" @submit.prevent="handleSubmit">
<div
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
>
<h3
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
>
Detalhes da Estadia
</h3>
<!-- Brand Selection -->
<div>
<label
for="brand"
class="block text-sm font-medium text-gray-700"
>
Marca <span class="text-red-500">*</span>
</label>
<select
id="brand"
v-model="formData.selectedBrand"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm"
@change="handleBrandChange"
>
<option value="" disabled>Selecione a marca</option>
<option
v-for="brand in brands"
:key="brand.id"
:value="String(brand.id)"
>
{{ brand.name }}
</option>
</select>
</div>
<!-- Unit and Duration -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
for="unit"
class="block text-sm font-medium text-gray-700"
>Unidade <span class="text-red-500">*</span></label
>
<select
id="unit"
v-model="formData.selectedUnit"
:disabled="!unitOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
@change="handleUnitChange"
>
<option value="" disabled>Selecione a unidade</option>
<option
v-for="unit in unitOptions"
:key="unit.value"
:value="unit.value"
>
{{ unit.label }}
</option>
</select>
</div>
<div>
<label
for="duration"
class="block text-sm font-medium text-gray-700"
>Permanência <span class="text-red-500">*</span></label
>
<select
id="duration"
v-model="formData.stayDuration"
:disabled="!durationOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
>
<option value="" disabled>Selecione o tempo</option>
<option
v-for="opt in durationOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
</div>
<!-- Category -->
<div>
<label
for="category"
class="block text-sm font-medium text-gray-700"
>Categoria da Suíte <span class="text-red-500">*</span></label
>
<select
id="category"
v-model="formData.selectedCategory"
:disabled="!categoryOptions.length"
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md shadow-sm disabled:bg-gray-100"
>
<option value="" disabled>Selecione a categoria</option>
<option
v-for="opt in categoryOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div>
<!-- Checkin Date -->
<div>
<label
for="checkin"
class="block text-sm font-medium text-gray-700"
>Data e Horário do Check-in
<span class="text-red-500">*</span></label
>
<input
id="checkin"
v-model="formData.checkInDateTime"
type="datetime-local"
required
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<!-- Photos View -->
<div v-if="suiteImage" class="my-6 animate-fade-in">
<div
class="rounded-2xl overflow-hidden shadow-lg border border-[#1B3B5F]/10"
>
<img
:src="suiteImage"
alt="Suite Preview"
class="w-full h-64 object-cover"
/>
</div>
</div>
<!-- Price Display -->
<div class="my-8">
<div
v-if="isPriceLoading"
class="text-center p-6 bg-[#F8FAFC] rounded-2xl border border-[#1B3B5F]/10"
>
<div
class="w-6 h-6 border-2 border-[#1E90FF] border-t-transparent rounded-full animate-spin mx-auto mb-2"
/>
<p class="text-sm text-[#9CA3AF]">Calculando valor...</p>
</div>
<div
v-else-if="calculatedPrice !== null"
class="relative overflow-hidden p-6 bg-[#F8FAFC] border-[1.5px] border-[#1E90FF]/20 rounded-2xl animate-fade-in shadow-lg shadow-[#1E90FF]/5"
>
<div
class="absolute top-0 right-0 bg-[#1E90FF] text-white text-[10px] font-bold px-3 py-1 rounded-bl-lg"
>
PREÇO ESTIMADO
</div>
<div class="space-y-4">
<div
class="flex justify-between items-center text-sm text-[#1B3B5F]"
>
<span class="font-medium">Valor Total da Reserva</span>
<span class="font-bold text-lg">{{
formatCurrency(calculatedPrice)
}}</span>
</div>
<div
class="flex justify-between items-center text-sm text-[#9CA3AF]"
>
<span>Pagar no check-in (50%)</span>
<span class="font-medium">{{
formatCurrency(calculatedPrice / 2)
}}</span>
</div>
<div
class="pt-4 border-t border-[#1B3B5F]/10 flex justify-between items-end"
>
<div>
<p
class="text-xs font-bold text-[#1E90FF] uppercase tracking-wider mb-1"
>
Entrada via Pix (50%)
</p>
<p class="text-[#9CA3AF] text-xs">
Necessário para confirmar
</p>
</div>
<span
class="text-3xl font-extrabold text-[#1B3B5F] tracking-tight"
>
{{ formatCurrency(calculatedPrice / 2) }}
</span>
</div>
</div>
</div>
</div>
<!-- User Details -->
<div
class="bg-[#F8FAFC] p-6 rounded-2xl border border-[#1B3B5F]/10 mb-8 shadow-sm space-y-4"
>
<h3
class="text-[#1B3B5F] font-bold text-sm uppercase tracking-wider mb-4 border-b border-[#1B3B5F]/10 pb-2"
>
Seus Dados
</h3>
<div>
<label class="block text-sm font-medium text-gray-700"
>Nome Completo <span class="text-red-500">*</span></label
>
<input
v-model="formData.nome"
type="text"
required
placeholder="Seu nome completo"
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700"
>Telefone / WhatsApp
<span class="text-red-500">*</span></label
>
<input
:value="formData.telefone"
type="tel"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
placeholder="(99) 99999-9999"
maxlength="15"
@input="formatPhone"
/>
<p class="text-xs text-gray-400 mt-1 ml-1">
Formato: (99) 99999-9999
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>CPF <span class="text-red-500">*</span></label
>
<input
:value="formData.cpf"
type="text"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
placeholder="000.000.000-00"
maxlength="14"
@input="formatCPF"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>E-mail <span class="text-red-500">*</span></label
>
<input
v-model="formData.email"
type="email"
required
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Observação</label
>
<textarea
v-model="formData.observacao"
rows="2"
class="mt-1 block w-full border-gray-300 rounded-xl shadow-sm focus:ring-[#1E90FF] focus:border-[#1E90FF] text-base py-3 px-4"
/>
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
:disabled="!isFormValid || isLoading || isDataLoading"
class="w-full flex justify-center py-4 px-6 border border-transparent rounded-xl shadow-xl shadow-[#1E90FF]/30 hover:shadow-[#1E90FF]/50 text-lg font-bold text-white bg-[#1E90FF] hover:bg-[#1B3B5F] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#1E90FF] disabled:bg-gray-300 disabled:shadow-none disabled:cursor-not-allowed transition-all duration-300 uppercase tracking-wide"
>
{{ isLoading ? 'Processando...' : 'Confirmar e Pagar Reserva' }}
</button>
</form>
</div>
</div>
<footer class="text-center text-xs font-medium text-white/40 mt-8">
&copy; {{ new Date().getFullYear() }} {{ appConfig.title }} &bull;
Experiência Exclusiva
</footer>
</div>
</template>
<style>
/* Global overrides to ensure background covers the entire page */
body {
background: linear-gradient(135deg, #0a1a2f 0%, #1b3b5f 100%) fixed !important;
margin: 0;
min-height: 100vh;
}
</style>
<style scoped>
/* Specific overrides if needed */
</style>

View File

@ -46,23 +46,32 @@ class JasmineAPI extends ApiClient {
}
unlinkCollection(inboxId, collectionId) {
return axios.delete(`${this.url}/${inboxId}/jasmine/collections/${collectionId}`);
return axios.delete(
`${this.url}/${inboxId}/jasmine/collections/${collectionId}`
);
}
// Documents
getDocuments(collectionId) {
return axios.get(`${this.jasmineUrl}/collections/${collectionId}/documents`);
return axios.get(
`${this.jasmineUrl}/collections/${collectionId}/documents`
);
}
uploadDocument(collectionId, content, title) {
return axios.post(`${this.jasmineUrl}/collections/${collectionId}/documents`, {
title,
content,
});
return axios.post(
`${this.jasmineUrl}/collections/${collectionId}/documents`,
{
title,
content,
}
);
}
deleteDocument(collectionId, documentId) {
return axios.delete(`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`);
return axios.delete(
`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`
);
}
// Playground
@ -85,4 +94,3 @@ class JasmineAPI extends ApiClient {
}
export default new JasmineAPI();

View File

@ -1,5 +1,5 @@
<script setup>
import { defineProps, computed, reactive } from 'vue';
import { defineProps, computed, reactive, ref } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@ -48,7 +48,9 @@ const allMessages = computed(() => {
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
// Using a ref to trigger reactivity when messages are fetched
const fetchedReplyMessages = reactive(new Map());
const fetchTrigger = ref(0); // Trigger to force re-render when async fetch completes
/**
* Fetches a specific message from the API by trying to get messages around it
@ -62,6 +64,9 @@ const fetchReplyMessage = async (messageId, conversationId) => {
return fetchedReplyMessages.get(messageId);
}
// Mark as loading to prevent duplicate fetches
fetchedReplyMessages.set(messageId, 'loading');
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
@ -75,14 +80,17 @@ const fetchReplyMessage = async (messageId, conversationId) => {
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
fetchTrigger.value += 1; // Trigger reactivity
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
fetchTrigger.value += 1; // Trigger reactivity
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
fetchTrigger.value += 1; // Trigger reactivity
return null;
}
};
@ -126,9 +134,14 @@ const shouldGroupWithNext = (index, searchList) => {
* @returns {Object|null} - The message being replied to, or null if not found
*/
const getInReplyToMessage = parentMessage => {
// Access fetchTrigger to make this function reactive to async fetches
// eslint-disable-next-line no-unused-expressions
fetchTrigger.value;
if (!parentMessage) return null;
const inReplyToMessageId =
parentMessage.inReplyToId ??
parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to;
@ -144,15 +157,18 @@ const getInReplyToMessage = parentMessage => {
);
}
// Then check fetch cache
// Then check fetch cache (ignore 'loading' placeholder)
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
const cached = fetchedReplyMessages.get(inReplyToMessageId);
if (cached && cached !== 'loading') {
replyMessage = cached;
}
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
return null; // Will re-render when fetchTrigger updates
}
return replyMessage ? useCamelCase(replyMessage) : null;

View File

@ -63,9 +63,11 @@ const messageClass = computed(() => {
});
const scrollToMessage = () => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: inReplyTo.value.id,
});
if (inReplyTo.value?.id) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: inReplyTo.value.id,
});
}
};
const shouldShowMeta = computed(
@ -76,7 +78,7 @@ const shouldShowMeta = computed(
);
const replyToPreview = computed(() => {
if (!inReplyTo) return '';
if (!inReplyTo.value) return '';
const { content, attachments } = inReplyTo.value;

View File

@ -102,7 +102,7 @@ describe('useUISettings', () => {
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
inbox_email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},

View File

@ -109,7 +109,8 @@ export function usePolicy() {
if (!flag) return false;
// Bypass paywall for Captain in development
if (['captain_integration', 'captain_integration_v2'].includes(flag)) return false;
if (['captain_integration', 'captain_integration_v2'].includes(flag))
return false;
if (isACustomBrandedInstance.value) {
// custom branded instances never show paywall

View File

@ -98,13 +98,13 @@ export const getEmailDate = lastEmail => {
* @returns {string} Formatted date string
*/
export const formatQuotedEmailDate = date => {
try {
return format(date, "EEE, MMM d, yyyy 'at' p");
} catch (error) {
const fallbackDate = new Date(date);
if (!Number.isNaN(fallbackDate.getTime())) {
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
}
let parsedDate = date;
if (typeof date === 'string') {
parsedDate = parseISO(date);
}
if (isValidDate(parsedDate)) {
return format(parsedDate, "EEE, MMM d, yyyy 'at' p");
}
return '';

View File

@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
import jasmine from './jasmine.json';
export default {
...advancedFilters,
@ -84,4 +85,5 @@ export default {
...contentTemplates,
...mfa,
...yearInReview,
...jasmine,
};

View File

@ -0,0 +1,72 @@
{
"JASMINE": {
"HEADER": {
"TITLE": "Jasmine AI Agents",
"DESCRIPTION": "Manage your AI SDR agents. Select an inbox to configure your knowledge base.",
"EMPTY": "No inboxes found"
},
"CONFIG": {
"TITLE": "Jasmine AI Configuration",
"DESCRIPTION": "Configure the AI agent for this inbox.",
"ENABLE": "Enable Jasmine AI Agent",
"SYSTEM_PROMPT": "System Prompt",
"SYSTEM_PROMPT_HELP": "Define the persona and behavioral rules for the agent.",
"UPDATE_BUTTON": "Update Configuration"
},
"KNOWLEDGE_BASE": {
"TITLE": "Knowledge Base",
"DESCRIPTION": "Manage knowledge collections for this inbox",
"ADD_BUTTON": "+ New Collection",
"DOCUMENTS": "Documents",
"LOADING_DOCS": "Loading documents...",
"UNTITLED_DOC": "Untitled Document",
"NO_DOCS": "No documents yet. Add your first document below.",
"ADD_DOC_HEADER": "Add New Document",
"DOC_TITLE_PLACEHOLDER": "Document title (optional)",
"DOC_CONTENT_PLACEHOLDER": "Paste or type your knowledge content here...",
"ADD_DOC_BUTTON": "Add Document",
"NO_COLLECTIONS": "No collections yet. Create one to get started.",
"CREATE_MODAL": {
"TITLE": "Create Collection",
"NAME_PLACEHOLDER": "Collection name",
"VISIBILITY_PRIVATE": "Private (This inbox only)",
"VISIBILITY_SHARED": "Shared (All inboxes)",
"CANCEL": "Cancel",
"CREATE": "Create"
},
"DELETE_CONFIRM": "Are you sure you want to delete this document?",
"DOCUMENT_DELETE_SUCCESS": "Document deleted successfully",
"COLLECTION_DELETE_SUCCESS": "Collection deleted successfully",
"SAVE_SUCCESS": "Changes saved successfully",
"DOCUMENT_CREATE_SUCCESS": "Document created successfully",
"COLLECTION_CREATE_SUCCESS": "Collection created successfully"
},
"PLAYGROUND": {
"TITLE": "Jasmine AI Playground",
"DESCRIPTION": "Test Jasmine responses in real-time before enabling for customers.",
"SELECT_INBOX": "Select an Inbox to test",
"CHOOSE_INBOX": "Choose an inbox...",
"WARNING": "Make sure Jasmine is enabled and configured for this inbox",
"EMPTY_STATE_TITLE": "Send a message to test Jasmine",
"EMPTY_STATE_EXAMPLES": "Try: \"Hello\", \"How much does it cost?\", \"How does it work?\"",
"LOADING": "Jasmine is thinking...",
"INPUT_PLACEHOLDER": "Type a test message...",
"CLEAR_TOOLTIP": "Clear conversation",
"NO_INBOX_SELECTED": "Select an inbox above to start testing"
},
"INBOX_LIST": {
"ACTIVE": "Active",
"CONFIGURE": "Configure",
"DESCRIPTION": "Channel {channel} configured for Jasmine AI"
},
"WUZAPI": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Error: Account ID not loaded. Please refresh the page.",
"CONNECT_FALLBACK": "Click to initiate connection",
"CONNECT_BUTTON_FALLBACK": "Connect WhatsApp",
"WEBHOOK_SECTION": "Webhook Configuration",
"GET_WEBHOOK_INFO": "Get Webhook Info",
"UPDATE_WEBHOOK": "Update Webhook Connection"
}
}
}

View File

@ -44,6 +44,54 @@
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
}
},
"PRICINGS": {
"LIST": {
"GLOBAL": "Global (Todos os Inboxes)"
},
"DELETE_BTN": "Excluir Regra",
"DELETE_CONFIRMATION": "Tem certeza que deseja excluir esta regra de preço?",
"FIELDS": {
"INBOX": "Inbox",
"BRAND": "Marca",
"DAYS": "Dias",
"CATEGORY": "Categoria",
"DURATION": "Duração",
"PRICE": "Preço",
"ACTIONS": "Ações",
"PRICE_DISPLAY": "R$ %{price}",
"MIN_PRICE": "Preço Mínimo",
"MAX_PRICE": "Preço Máximo",
"DAY": "Dia",
"KEYWORDS": "Palavras-chave"
},
"FILTERS": {
"ALL": "Todas",
"ALL_DAYS": "Todos os Dias",
"CLEAR": "Limpar Filtros"
},
"EDIT": "Editar",
"DELETE": "Excluir",
"HEADER": "Gerenciamento de Tarifas",
"TITLE": "Tarifas e Disponibilidade",
"DESCRIPTION": "Gerencie os preços das suítes por dia da semana, categoria e duração.",
"ADD_BUTTON": "Nova Regra",
"EMPTY_STATE": "Nenhuma regra de preço encontrada.",
"MODAL": {
"EDIT_TITLE": "Editar Regra de Preço",
"ADD_TITLE": "Nova Regra de Preço",
"SAVE": "Salvar",
"CANCEL": "Cancelar",
"SELECT_DAYS_REQUIRED": "Selecione pelo menos um dia",
"SELECT_CATEGORY": "Selecione uma categoria",
"SELECT_BRAND_FIRST": "Selecione uma marca primeiro",
"NO_CATEGORIES": "Nenhuma categoria encontrada",
"SELECT_DURATION": "Selecione uma duração",
"NO_DURATIONS": "Nenhuma duração encontrada",
"PRICE_PLACEHOLDER": "0.00",
"REMOVE_INBOX": "Remover Inbox",
"CLOSE": "Remover"
}
},
"CUSTOM_TOOLS": {
"OPTIONS": {
"TEST_TOOL": "Testar Ferramenta",

View File

@ -67,10 +67,13 @@ export default {
this.brand.suiteCategories || this.brand.suite_categories || [];
const images =
this.brand.suiteImages || this.brand.suite_images || {};
const keywords =
this.brand.suiteKeywords || this.brand.suite_keywords || {};
this.suiteCategories = categories.map(cat => ({
name: cat,
image: images[cat] || '',
keywords: keywords[cat] || '',
}));
} else {
this.resetForm();
@ -85,6 +88,7 @@ export default {
this.stayDurations = '';
this.newCategoryName = '';
this.newCategoryImage = '';
this.newCategoryKeywords = '';
this.v$.$reset();
},
addCategory() {
@ -92,9 +96,11 @@ export default {
this.suiteCategories.push({
name: this.newCategoryName,
image: this.newCategoryImage,
keywords: this.newCategoryKeywords,
});
this.newCategoryName = '';
this.newCategoryImage = '';
this.newCategoryKeywords = '';
},
removeCategory(index) {
this.suiteCategories.splice(index, 1);
@ -108,11 +114,16 @@ export default {
if (curr.image) acc[curr.name] = curr.image;
return acc;
}, {});
const keywords = this.suiteCategories.reduce((acc, curr) => {
if (curr.keywords) acc[curr.name] = curr.keywords;
return acc;
}, {});
const payload = {
name: this.name,
suite_categories: categories,
suite_images: images,
suite_keywords: keywords,
stay_durations: this.stayDurations
.split(',')
.map(s => s.trim())
@ -126,21 +137,25 @@ export default {
<template>
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<WootModal :show="show" :on-close="() => $emit('close')">
<WootModal
:show="show"
:on-close="() => $emit('close')"
class-name="!max-w-5xl !w-full"
>
<div
class="flex flex-col w-[600px] bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
class="flex flex-col w-full bg-white dark:bg-slate-900 rounded-lg shadow-xl overflow-hidden"
>
<!-- Header -->
<div
class="px-6 py-4 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
class="px-6 py-3 border-b border-slate-200 dark:border-slate-800 flex justify-between items-center"
>
<h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
{{ headerTitle }}
</h2>
</div>
<!-- Scrollable Body -->
<div class="flex-1 overflow-y-auto p-6 max-h-[65vh] flex flex-col gap-5">
<!-- Body -->
<div class="flex-1 p-5 flex flex-col gap-3">
<!-- Brand Name -->
<WootInput
v-model="name"
@ -151,9 +166,9 @@ export default {
<!-- Suite Categories -->
<div
class="bg-slate-50 dark:bg-slate-800 p-4 rounded-lg border border-slate-200 dark:border-slate-700"
class="bg-slate-50 dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center justify-between mb-2">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200"
>
@ -161,45 +176,70 @@ export default {
</label>
</div>
<div class="flex gap-2 mb-3">
<!-- Add New Category - Function Bar -->
<div class="flex flex-col gap-2 mb-2">
<div class="flex gap-2">
<input
v-model="newCategoryName"
type="text"
placeholder="Nome (Ex: Standard)"
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
@keydown.enter.prevent="addCategory"
/>
<input
v-model="newCategoryImage"
type="text"
placeholder="URL da Imagem"
class="flex-1 text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
@keydown.enter.prevent="addCategory"
/>
<button
class="px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium shrink-0"
title="Adicionar Categoria"
@click.prevent="addCategory"
>
<i class="i-lucide-plus" />
</button>
</div>
<input
v-model="newCategoryName"
v-model="newCategoryKeywords"
type="text"
placeholder="Nome (Ex: Standard)"
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
placeholder="Palavras-chave (Ex: jacuzzi, hidro) - Opcional"
class="w-full text-sm py-1.5 border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 placeholder:text-slate-400"
@keydown.enter.prevent="addCategory"
/>
<input
v-model="newCategoryImage"
type="text"
placeholder="URL da Imagem"
class="flex-1 text-sm border-slate-200 dark:border-slate-700 rounded-md bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
@keydown.enter.prevent="addCategory"
/>
<button
class="px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
@click.prevent="addCategory"
>
<i class="i-lucide-plus" />
</button>
</div>
<div v-if="suiteCategories.length > 0" class="space-y-2">
<!-- Categories Grid -->
<div v-if="suiteCategories.length > 0" class="grid grid-cols-2 gap-2">
<div
v-for="(cat, idx) in suiteCategories"
:key="idx"
class="flex items-center justify-between bg-white dark:bg-slate-900 p-2 rounded border border-slate-200 dark:border-slate-600"
class="flex items-center justify-between bg-white dark:bg-slate-900 p-1.5 rounded border border-slate-200 dark:border-slate-600"
>
<div class="flex flex-col">
<span
class="font-medium text-sm text-slate-800 dark:text-slate-100"
>
{{ cat.name }}
</span>
<div class="flex flex-col flex-1 min-w-0 mr-2">
<div class="flex items-center gap-2">
<span
class="font-medium text-sm text-slate-800 dark:text-slate-100"
>
{{ cat.name }}
</span>
<span
v-if="cat.keywords"
class="text-xs text-slate-500 truncate"
:title="cat.keywords"
>
<i class="i-lucide-key size-3 inline-block mr-0.5" />
{{ cat.keywords }}
</span>
</div>
<span
v-if="cat.image"
class="text-xs text-slate-500 truncate max-w-[200px]"
class="text-xs text-slate-400 truncate"
:title="cat.image"
>
<i class="i-lucide-image size-3 inline-block mr-0.5" />
{{ cat.image }}
</span>
</div>
@ -216,7 +256,7 @@ export default {
v-else
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2"
>
Adicione as categorias de quartos disponíveis para esta marca.
Nenhuma categoria adicionada.
</p>
</div>
@ -224,7 +264,7 @@ export default {
<WootInput
v-model="stayDurations"
label="Durações Aceitas"
placeholder="Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
placeholder="Ex: 2h, 4h, Pernoite, Diária"
/>
</div>

View File

@ -122,9 +122,11 @@ const fetchData = async () => {
}
};
const deleteDialogRef = ref(null);
const confirmDelete = pricing => {
pricingToDelete.value = pricing;
showDeleteConfirmation.value = true;
deleteDialogRef.value.open();
};
const deletePricing = async () => {
@ -341,15 +343,27 @@ const clearFilters = () => {
? pricing.inboxNames.join(', ')
: pricing.inbox_names?.length
? pricing.inbox_names.join(', ')
: pricing.inboxName ||
pricing.inbox_name ||
pricing.inbox_id
: inboxes.find(
i => String(i.id) === String(pricing.inbox_id)
)?.name || 'Global (Todos os Inboxes)'
}}
</td>
<td
class="px-6 py-4 font-medium text-slate-900 dark:text-slate-100"
>
{{ pricing.brandName || pricing.brand_id }}
{{
brands.find(
b =>
String(b.id) === String(pricing.captain_brand_id) ||
String(b.id) === String(pricing.brand_id) ||
String(b.id) === String(pricing.captainBrandId) ||
String(b.id) === String(pricing.brandId)
)?.name ||
pricing.captain_brand_id ||
pricing.brand_id ||
pricing.captainBrandId ||
pricing.brandId
}}
</td>
<td class="px-6 py-4 text-slate-600 dark:text-slate-300">
{{ pricing.dayRange || pricing.day_range }}
@ -374,13 +388,13 @@ const clearFilters = () => {
class="text-blue-600 hover:text-blue-800 font-medium"
@click="openEditModal(pricing)"
>
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.EDIT') }}
{{ $t('CAPTAIN.PRICINGS.EDIT') }}
</button>
<button
class="text-red-600 hover:text-red-800 font-medium transition-colors"
@click="confirmDelete(pricing)"
>
{{ $t('CAPTAIN.RESERVATIONS.AUTOMATIONS.DELETE') }}
{{ $t('CAPTAIN.PRICINGS.DELETE') }}
</button>
</td>
</tr>
@ -396,6 +410,7 @@ const clearFilters = () => {
</div>
<PricingModal
:key="selectedPricing?.id || 'new'"
:show="showModal"
:pricing="selectedPricing"
:brands="brands"
@ -405,12 +420,14 @@ const clearFilters = () => {
/>
<Dialog
:show="showDeleteConfirmation"
:title="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
:message="t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
:confirm-text="t('CAPTAIN.PRICINGS.DELETE_BUTTON')"
:cancel-text="t('CAPTAIN.BRAND_MODAL.CANCEL')"
variant="danger"
ref="deleteDialogRef"
:title="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
:description="$t('CAPTAIN.PRICINGS.DELETE_CONFIRMATION')"
:confirm-button-label="$t('CAPTAIN.PRICINGS.DELETE_BTN')"
:cancel-button-label="$t('CAPTAIN.PRICINGS.MODAL.CANCEL')"
type="alert"
show-cancel-button
show-confirm-button
@close="showDeleteConfirmation = false"
@confirm="deletePricing"
/>

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
@ -58,14 +59,23 @@ const toggleDay = day => {
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b)
);
}
formData.value.day_range = selectedDays.value.join(', ');
};
watch(
selectedDays,
newDays => {
formData.value.day_range = newDays.join(', ');
},
{ deep: true }
);
const parseDays = rangeString => {
if (!rangeString) return [];
const normalizedRange = rangeString.toUpperCase();
// Handle "SEGUNDA A QUARTA" range format legacy support
if (rangeString.includes(' A ')) {
const [start, end] = rangeString.split(' A ');
if (normalizedRange.includes(' A ')) {
const [start, end] = normalizedRange.split(' A ');
const startIndex = daysOptions.indexOf(start);
const endIndex = daysOptions.indexOf(end);
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
@ -73,8 +83,8 @@ const parseDays = rangeString => {
}
}
// Handle comma separated
return rangeString
.split(', ')
return normalizedRange
.split(',')
.map(s => s.trim())
.filter(s => daysOptions.includes(s));
};
@ -96,7 +106,10 @@ const removeInbox = inboxId => {
const isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
const selectedBrand = computed(() => {
return props.brands.find(b => b.id === formData.value.captain_brand_id);
if (!formData.value.captain_brand_id) return undefined;
return props.brands.find(
b => String(b.id) === String(formData.value.captain_brand_id)
);
});
const brandCategories = computed(() => {
@ -126,11 +139,23 @@ watch(
let normalizedInboxIds = [];
if (newVal.inbox_ids?.length) {
normalizedInboxIds = newVal.inbox_ids;
} else if (newVal.inbox_id) {
normalizedInboxIds = [newVal.inbox_id];
} else if (newVal.inbox_id || newVal.inboxId) {
normalizedInboxIds = [newVal.inbox_id || newVal.inboxId];
}
formData.value = { ...newVal, inbox_ids: normalizedInboxIds };
selectedDays.value = parseDays(newVal.day_range || newVal.dayRange);
formData.value = {
captain_brand_id:
newVal.captain_brand_id ||
newVal.captainBrandId ||
newVal.brand_id ||
newVal.brandId ||
'',
inbox_ids: normalizedInboxIds,
day_range: newVal.day_range || newVal.dayRange || '',
suite_category: newVal.suite_category || newVal.suiteCategory || '',
duration: newVal.duration || '',
price: newVal.price || '',
};
selectedDays.value = parseDays(formData.value.day_range);
selectedInboxes.value = normalizedInboxIds;
} else {
formData.value = {
@ -256,7 +281,7 @@ const savePricing = async () => {
class="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
:class="[
selectedDays.includes(day)
? 'bg-indigo-600 text-white border-indigo-600 shadow-sm'
? '!bg-blue-600 text-white border-blue-600 shadow-md'
: 'bg-white text-slate-600 border-slate-300 hover:border-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700',
]"
@click="toggleDay(day)"

View File

@ -44,4 +44,3 @@ const routes = [
];
export default routes;

View File

@ -45,8 +45,8 @@ const getChannelName = channelType => {
>
<template #header>
<BaseSettingsHeader
title="Agentes Jasmine AI"
description="Gerencie seus agentes de IA SDR. Selecione uma caixa de entrada para configurar sua base de conhecimento."
:title="$t('JASMINE.HEADER.TITLE')"
:description="$t('JASMINE.HEADER.DESCRIPTION')"
/>
</template>
@ -64,14 +64,12 @@ const getChannelName = channelType => {
class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2"
>
<span
:class="[
getChannelIcon(inbox.channel_type),
'size-6 text-n-blue-text',
]"
class="size-6 text-n-blue-text"
:class="[getChannelIcon(inbox.channel_type)]"
/>
</div>
<span
v-tooltip="'Ativo'"
v-tooltip="$t('JASMINE.INBOX_LIST.ACTIVE')"
class="text-white p-0.5 rounded-full size-5 flex items-center justify-center bg-n-teal-9"
>
<i class="i-ph-check-bold text-sm" />
@ -83,13 +81,20 @@ const getChannelName = channelType => {
<span class="text-base font-semibold text-n-slate-12">{{
inbox.name
}}</span>
<Button label="Configurar" link @click.stop="openInbox(inbox.id)" />
<Button
:label="$t('JASMINE.INBOX_LIST.CONFIGURE')"
link
@click.stop="openInbox(inbox.id)"
/>
</div>
<!-- Description -->
<p class="text-sm text-n-slate-11">
Canal {{ getChannelName(inbox.channel_type) }} configurado para
Jasmine AI
{{
$t('JASMINE.INBOX_LIST.DESCRIPTION', {
channel: getChannelName(inbox.channel_type),
})
}}
</p>
</div>
</div>

View File

@ -63,8 +63,8 @@ const clearChat = () => {
<SettingsLayout :is-loading="false">
<template #header>
<BaseSettingsHeader
title="Playground Jasmine AI"
description="Teste as respostas da Jasmine em tempo real antes de ativar para os clientes."
:title="$t('JASMINE.PLAYGROUND.TITLE')"
:description="$t('JASMINE.PLAYGROUND.DESCRIPTION')"
/>
</template>
@ -73,20 +73,25 @@ const clearChat = () => {
<!-- Inbox Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
Selecione uma Inbox para testar
{{ $t('JASMINE.PLAYGROUND.SELECT_INBOX') }}
</label>
<select
v-model="selectedInboxId"
class="w-full max-w-md px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
>
<option :value="null">Escolha uma inbox...</option>
<option :value="null">
{{ $t('JASMINE.PLAYGROUND.CHOOSE_INBOX') }}
</option>
<option v-for="inbox in inboxes" :key="inbox.id" :value="inbox.id">
{{ inbox.name }}
</option>
</select>
<p v-if="selectedInbox" class="text-xs text-n-slate-11 mt-1">
Certifique-se de que a Jasmine está ativada e configurada para
esta inbox
{{
$t('JASMINE.PLAYGROUND.FETCH_ERROR', {
error: $t('JASMINE.PLAYGROUND.WARNING'),
})
}}
</p>
</div>
@ -102,17 +107,17 @@ const clearChat = () => {
class="text-center text-n-slate-11 py-12"
>
<span class="i-lucide-message-square size-12 mb-4 opacity-50" />
<p>Envie uma mensagem para testar a Jasmine</p>
<p>{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_TITLE') }}</p>
<p class="text-xs mt-2">
Experimente: "Olá", "Quanto custa?", "Como funciona?"
{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_EXAMPLES') }}
</p>
</div>
<div
v-for="(msg, index) in messages"
:key="index"
class="max-w-[80%] rounded-lg p-3"
:class="[
'max-w-[80%] rounded-lg p-3',
msg.role === 'user'
? 'ml-auto bg-n-blue-9 text-white'
: msg.role === 'error'
@ -125,10 +130,16 @@ const clearChat = () => {
v-if="msg.debug"
class="mt-2 pt-2 border-t border-n-weak text-xs text-n-slate-11"
>
<span class="font-mono"
>{{ msg.debug.model }} | temp:
{{ msg.debug.temperature }}</span
>
<span class="font-mono">
{{
$t('JASMINE.PLAYGROUND.MODEL', { model: msg.debug.model })
}}
{{
$t('JASMINE.PLAYGROUND.TEMPERATURE', {
temp: msg.debug.temperature,
})
}}
</span>
</div>
</div>
@ -137,7 +148,9 @@ const clearChat = () => {
class="flex items-center gap-2 text-n-slate-11"
>
<span class="i-lucide-loader-2 size-4 animate-spin" />
<span class="text-sm">Jasmine está pensando...</span>
<span class="text-sm">{{
$t('JASMINE.PLAYGROUND.LOADING')
}}</span>
</div>
</div>
@ -148,7 +161,7 @@ const clearChat = () => {
v-model="inputMessage"
type="text"
class="flex-1 px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
placeholder="Digite uma mensagem de teste..."
:placeholder="$t('JASMINE.PLAYGROUND.INPUT_PLACEHOLDER')"
:disabled="isLoading"
@keyup.enter="sendMessage"
/>
@ -158,7 +171,7 @@ const clearChat = () => {
@click="sendMessage"
/>
<Button
v-tooltip="'Limpar conversa'"
v-tooltip="$t('JASMINE.PLAYGROUND.CLEAR_TOOLTIP')"
icon="i-lucide-trash-2"
faded
slate
@ -176,7 +189,7 @@ const clearChat = () => {
>
<div class="text-center">
<span class="i-lucide-inbox size-16 mb-4 opacity-30" />
<p>Selecione uma inbox acima para começar a testar</p>
<p>{{ $t('JASMINE.PLAYGROUND.NO_INBOX_SELECTED') }}</p>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'JasmineWrapper',
};
</script>
<template>
<router-view />
</template>

View File

@ -70,10 +70,10 @@ export default {
<div class="settings-section">
<div class="flex flex-col gap-1 items-start mb-4">
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
Jasmine AI Configuration
{{ $t('JASMINE.CONFIG.TITLE') }}
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
Configure the AI agent for this inbox.
{{ $t('JASMINE.CONFIG.DESCRIPTION') }}
</p>
</div>
@ -85,7 +85,7 @@ export default {
class="form-checkbox h-5 w-5 text-woot-500 rounded border-gray-300 focus:ring-woot-500"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
Enable Jasmine AI Agent
{{ $t('JASMINE.CONFIG.ENABLE') }}
</span>
</label>
</div>
@ -94,21 +94,21 @@ export default {
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
System Prompt
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT') }}
</label>
<textarea
v-model="systemPrompt"
rows="6"
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
placeholder="You are a helpful SDR agent..."
></textarea>
:placeholder="$t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP')"
/>
<p class="mt-1 text-xs text-slate-500">
Define the persona and behavioral rules for the agent.
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP') }}
</p>
</div>
<woot-button :is-loading="isUpdating" @click="updateSettings">
Update Configuration
{{ $t('JASMINE.CONFIG.UPDATE_BUTTON') }}
</woot-button>
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" />

View File

@ -34,20 +34,25 @@ export default defineComponent({
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/wuzapi${endpoint}`;
};
const fetchStatus = async () => {
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function fetchStatus() {
if (!accountId.value) return;
try {
const response = await window.axios.get(getApiUrl(''));
const data = response.data;
// Wuzapi format: { data: { connected: true, jid: "...", details: "..." } }
const wuzapiData = data.data || {};
const isWuzapiConnected =
wuzapiData.connected === true && !!wuzapiData.jid;
// Also keep legacy check just in case payload differs
const legacyStatus = data.status || data.state;
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
legacyStatus
@ -64,13 +69,22 @@ export default defineComponent({
statusMessage.value =
error.response?.data?.error || error.message || 'Check failed';
}
};
}
const fetchQrCode = async () => {
/* eslint-disable no-use-before-define */
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await fetchStatus();
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
async function fetchQrCode() {
try {
const response = await window.axios.get(getApiUrl('/qr'));
// Backend now normalizes to 'qrcode' in most cases, but we keep robust checks
const d = response.data;
const qrcodeData =
d.qrcode ||
@ -84,7 +98,6 @@ export default defineComponent({
qrCode.value = qrcodeData;
startPolling();
} else {
// Fallback: maybe we are already connected?
await fetchStatus();
if (!isConnected.value) {
statusMessage.value = 'QR Code not received and not connected.';
@ -94,7 +107,7 @@ export default defineComponent({
statusMessage.value =
error.response?.data?.error || 'Failed to load QR';
}
};
}
const handleConnect = async () => {
if (!accountId.value) {
@ -131,26 +144,6 @@ export default defineComponent({
}
};
// Function hoisting allows use before definition
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function startPolling() {
if (pollInterval) return;
// Poll every 5 seconds to check status AND refresh QR code
pollInterval = setInterval(async () => {
await fetchStatus();
// If still not connected (and polling hasn't been stopped by fetchStatus), refresh QR
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
const isLoadingWebhook = ref(false);
const webhookInfo = ref(null);
@ -173,7 +166,7 @@ export default defineComponent({
const response = await window.axios.put(getApiUrl('/update_webhook'));
webhookInfo.value = {
message: response.data.message,
url: response.data.webhook_url
url: response.data.webhook_url,
};
useAlert('Webhook updated successfully');
} catch (error) {
@ -213,8 +206,8 @@ export default defineComponent({
<div class="mx-8 mt-6">
<div class="bg-white p-6 rounded-lg border border-n-weak">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }} -
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
{{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
</h3>
<div v-if="accountId" class="flex flex-col items-center">
@ -264,29 +257,29 @@ export default defineComponent({
</div>
<div class="mt-4 text-xs text-n-slate-10">
Status: {{ statusMessage }}
{{ $t('JASMINE.WUZAPI.STATUS', { status: statusMessage }) }}
</div>
</div>
</div>
<div v-else class="text-red-600 p-4">
Error: Account ID not loaded. Please refresh the page.
{{ $t('JASMINE.WUZAPI.ACCOUNT_ERROR') }}
</div>
<div class="mt-8 pt-6 border-t border-n-weak w-full">
<h4 class="text-md font-medium text-n-slate-12 mb-4">
Webhook Configuration
{{ $t('JASMINE.WUZAPI.WEBHOOK_SECTION') }}
</h4>
<div class="flex gap-4 mb-4">
<NextButton
icon="i-woot-refresh"
:is-loading="isLoadingWebhook"
label="Get Webhook Info"
:label="$t('JASMINE.WUZAPI.GET_WEBHOOK_INFO')"
@click="fetchWebhookInfo"
/>
<NextButton
icon="i-woot-upload"
:is-loading="isLoadingWebhook"
label="Update Webhook Connection"
:label="$t('JASMINE.WUZAPI.UPDATE_WEBHOOK')"
@click="updateWebhook"
/>
</div>

View File

@ -101,11 +101,13 @@ export default {
}
},
async deleteDocument(collectionId, documentId) {
if (!confirm('Are you sure you want to delete this document?')) return;
// eslint-disable-next-line no-alert
if (!window.confirm(this.$t('JASMINE.KNOWLEDGE_BASE.DELETE_CONFIRM')))
return;
this.isDeletingDocument = documentId;
try {
await JasmineAPI.deleteDocument(collectionId, documentId);
useAlert('Document deleted successfully');
useAlert(this.$t('JASMINE.KNOWLEDGE_BASE.DOCUMENT_DELETE_SUCCESS'));
this.fetchDocuments(collectionId);
} catch (error) {
useAlert('Failed to delete document');
@ -143,14 +145,14 @@ export default {
<div class="flex justify-between items-center mb-6">
<div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
Knowledge Base
{{ $t('JASMINE.KNOWLEDGE_BASE.TITLE') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
Manage knowledge collections for this inbox
{{ $t('JASMINE.KNOWLEDGE_BASE.DESCRIPTION') }}
</p>
</div>
<woot-button size="small" @click="showCreateCollectionModal = true">
+ New Collection
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_BUTTON') }}
</woot-button>
</div>
@ -173,8 +175,8 @@ export default {
>
<div class="flex items-center gap-3">
<span
class="i-lucide-chevron-right size-4 transition-transform text-slate-400"
:class="[
'i-lucide-chevron-right size-4 transition-transform text-slate-400',
expandedCollectionId === collection.id ? 'rotate-90' : '',
]"
/>
@ -197,7 +199,7 @@ export default {
class="border-t border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 p-4"
>
<h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Documents
{{ $t('JASMINE.KNOWLEDGE_BASE.DOCUMENTS') }}
</h5>
<!-- Loading Documents -->
@ -206,7 +208,7 @@ export default {
class="flex items-center gap-2 text-sm text-slate-400 py-2"
>
<span class="i-lucide-loader-2 size-4 animate-spin" />
Loading documents...
{{ $t('JASMINE.KNOWLEDGE_BASE.LOADING_DOCS') }}
</div>
<!-- Documents List -->
@ -224,7 +226,7 @@ export default {
<p
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate"
>
{{ doc.title || 'Untitled Document' }}
{{ doc.title || $t('JASMINE.KNOWLEDGE_BASE.UNTITLED_DOC') }}
</p>
<p class="text-xs text-slate-400 truncate">
{{ new Date(doc.created_at).toLocaleDateString() }}
@ -234,10 +236,8 @@ export default {
<div class="flex items-center gap-3 shrink-0">
<!-- Status Badge -->
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
getStatusClass(doc.status),
]"
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
:class="[getStatusClass(doc.status)]"
>
<span
v-if="isProcessing(doc.status)"
@ -264,7 +264,7 @@ export default {
v-if="documents.length === 0"
class="text-center py-6 text-sm text-slate-400"
>
No documents yet. Add your first document below.
{{ $t('JASMINE.KNOWLEDGE_BASE.NO_DOCS') }}
</div>
</div>
@ -273,19 +273,21 @@ export default {
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4"
>
<h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Add New Document
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_HEADER') }}
</h6>
<input
v-model="newDocTitle"
type="text"
class="w-full mb-2 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
placeholder="Document title (optional)"
:placeholder="$t('JASMINE.KNOWLEDGE_BASE.DOC_TITLE_PLACEHOLDER')"
/>
<textarea
v-model="newDocContent"
rows="4"
class="w-full mb-3 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 resize-none"
placeholder="Paste or type your knowledge content here..."
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.DOC_CONTENT_PLACEHOLDER')
"
/>
<div class="flex justify-end">
<woot-button
@ -294,7 +296,7 @@ export default {
:disabled="!newDocContent.trim()"
@click="addDocument(collection.id)"
>
Add Document
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_BUTTON') }}
</woot-button>
</div>
</div>
@ -307,7 +309,7 @@ export default {
class="text-center py-12 text-slate-400"
>
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">No collections yet. Create one to get started.</p>
<p class="text-sm">{{ $t('JASMINE.KNOWLEDGE_BASE.NO_COLLECTIONS') }}</p>
</div>
</div>
@ -319,34 +321,40 @@ export default {
>
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl">
<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
Create Collection
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.TITLE') }}
</h3>
<input
v-model="newCollectionName"
type="text"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
placeholder="Collection name"
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.NAME_PLACEHOLDER')
"
@keyup.enter="createCollection"
/>
<select
v-model="newCollectionVisibility"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
>
<option value="private">Private (This inbox only)</option>
<option value="shared">Shared (All inboxes)</option>
<option value="private">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_PRIVATE') }}
</option>
<option value="shared">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_SHARED') }}
</option>
</select>
<div class="flex justify-end gap-2">
<woot-button
variant="clear"
@click="showCreateCollectionModal = false"
>
Cancel
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CANCEL') }}
</woot-button>
<woot-button
:disabled="!newCollectionName.trim()"
@click="createCollection"
>
Create
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CREATE') }}
</woot-button>
</div>
</div>

View File

@ -1,8 +0,0 @@
import { createApp } from 'vue';
import BookingApp from '../captain_booking/App.vue';
import '../captain_booking/assets/main.css';
document.addEventListener('DOMContentLoaded', () => {
const app = createApp(BookingApp);
app.mount('#root');
});

View File

@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from '../../../enterprise/app/javascript/captain_booking_app/App';
import '../../../enterprise/app/javascript/captain_booking_app/index.css';
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
} else {
console.error('Root element not found');
}

View File

@ -1,134 +1,132 @@
module Conversations
class AutoLabelJob < ApplicationJob
queue_as :low_priority
class Conversations::AutoLabelJob < ApplicationJob
queue_as :low_priority
TAXONOMY = {
'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.',
'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.',
'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.',
'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze
TAXONOMY = {
'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.',
'duvida_disponibilidade' => 'Perguntas sobre datas livres, se tem quarto vago, feriados.',
'duvida_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.',
'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze
def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
return unless conversation.messages.count > 0
def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
return unless conversation.messages.count.positive?
# Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any?
# Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any?
process_classification(conversation)
process_classification(conversation)
rescue StandardError => e
Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}"
end
private
def process_classification(conversation)
messages_text = prepare_history(conversation)
return if messages_text.blank?
result = call_llm_classification(messages_text)
return unless result
apply_label(conversation, result)
end
def prepare_history(conversation)
# Pega últimas 20 mensagens para dar contexto suficiente
conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m|
sender = m.incoming? ? 'Cliente' : 'Atendente'
"#{sender}: #{m.content}"
end.join("\n")
end
def call_llm_classification(history)
prompt = <<~PROMPT
Você é um assistente classificador de conversas para um hotel.
Analise o histórico da conversa abaixo e identifique:
1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias).
2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?".
Categorias permitidas:
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")}
Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações:
{
"label": "codigo_da_categoria",
"question": "Resumo curto da dúvida"
}
Se não tiver certeza da categoria, use 'outros'.
--- INÍCIO DA CONVERSA ---
#{history}
--- FIM DA CONVERSA ---
PROMPT
# Lista de modelos para tentar (Configurado > Alternativas)
models_to_try = [
ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
'gemini-1.5-flash-001',
'gemini-pro',
'gpt-3.5-turbo'
].uniq.reject(&:blank?)
last_error = nil
models_to_try.each do |model|
# Tenta usar a infra existente
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
# Limpa markdown json se houver
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
parsed = JSON.parse(clean_response)
return parsed
rescue JSON::ParserError => e
Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}"
last_error = e
next
rescue StandardError => e
Rails.logger.error "[AutoLabelJob] Error classifying conversation #{conversation_id}: #{e.message}"
Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}"
last_error = e
next
end
private
# Se chegou aqui, todos falharam
Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}"
nil
end
def process_classification(conversation)
messages_text = prepare_history(conversation)
return if messages_text.blank?
def apply_label(conversation, result)
label_name = result['label']
question_summary = result['question']
result = call_llm_classification(messages_text)
return unless result
apply_label(conversation, result)
unless TAXONOMY.key?(label_name)
Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
end
def prepare_history(conversation)
# Pega últimas 20 mensagens para dar contexto suficiente
conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m|
sender = m.incoming? ? 'Cliente' : 'Atendente'
"#{sender}: #{m.content}"
end.join("\n")
# Garante que a label existe na conta para aparecer nos relatórios
conversation.account.labels.find_or_create_by!(title: label_name) do |l|
l.description = TAXONOMY[label_name]
l.color = '#7C3AED' # Roxo para indicar IA/Automático
l.show_on_sidebar = true
end
def call_llm_classification(history)
prompt = <<~PROMPT
Você é um assistente classificador de conversas para um hotel.
Analise o histórico da conversa abaixo e identifique:
1. A INTENÇÃO PRINCIPAL do cliente (use a lista de categorias).
2. Um RESUMO CURTO da dúvida (Dúvida Canônica) em 3 a 5 palavras. Ex: "Aceita pagamento PIX?", "Horário do café?".
conversation.add_labels([label_name])
Categorias permitidas:
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")}
# Salva a dúvida canônica nos atributos adicionais
conversation.additional_attributes ||= {}
conversation.additional_attributes['ai_canonical_question'] = question_summary
conversation.save!
Retorne APENAS um JSON válido no seguinte formato, sem markdown ou explicações:
{
"label": "codigo_da_categoria",
"question": "Resumo curto da dúvida"
}
Se não tiver certeza da categoria, use 'outros'.
--- INÍCIO DA CONVERSA ---
#{history}
--- FIM DA CONVERSA ---
PROMPT
# Lista de modelos para tentar (Configurado > Alternativas)
models_to_try = [
ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
'gemini-1.5-flash-001',
'gemini-pro',
'gpt-3.5-turbo'
].uniq.reject(&:blank?)
last_error = nil
models_to_try.each do |model|
# Tenta usar a infra existente
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
# Limpa markdown json se houver
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
parsed = JSON.parse(clean_response)
return parsed
rescue JSON::ParserError => e
Rails.logger.warn "[AutoLabelJob] Failed to parse JSON from model #{model}: #{e.message}"
last_error = e
next
rescue StandardError => e
Rails.logger.warn "[AutoLabelJob] Failed with model #{model}: #{e.message}"
last_error = e
next
end
# Se chegou aqui, todos falharam
Rails.logger.error "[AutoLabelJob] All models failed. Last error: #{last_error&.message}"
nil
end
def apply_label(conversation, result)
label_name = result['label']
question_summary = result['question']
unless TAXONOMY.key?(label_name)
Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
end
# Garante que a label existe na conta para aparecer nos relatórios
conversation.account.labels.find_or_create_by!(title: label_name) do |l|
l.description = TAXONOMY[label_name]
l.color = '#7C3AED' # Roxo para indicar IA/Automático
l.show_on_sidebar = true
end
conversation.add_labels([label_name])
# Salva a dúvida canônica nos atributos adicionais
conversation.additional_attributes ||= {}
conversation.additional_attributes['ai_canonical_question'] = question_summary
conversation.save!
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
end
Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
end
end

View File

@ -1,86 +1,84 @@
module Conversations
class ClusterJob < ApplicationJob
queue_as :low_priority
class Conversations::ClusterJob < ApplicationJob
queue_as :low_priority
def perform(account_id, days_back = 7)
account = Account.find(account_id)
def perform(account_id, days_back = 7)
account = Account.find(account_id)
# 1. Busca conversas recentes que já foram processadas pela IA
start_date = days_back.days.ago.beginning_of_day
# 1. Busca conversas recentes que já foram processadas pela IA
start_date = days_back.days.ago.beginning_of_day
# Labels que queremos agrupar (todas da taxonomia)
Conversations::AutoLabelJob::TAXONOMY.keys.each do |label|
# Busca perguntas dessa categoria
# Note: estamos queryng o campo JSONB additional_attributes
account.conversations
.where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label)
.pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'"))
# Labels que queremos agrupar (todas da taxonomia)
Conversations::AutoLabelJob::TAXONOMY.each_key do |label|
# Busca perguntas dessa categoria
# Note: estamos queryng o campo JSONB additional_attributes
account.conversations
.where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label)
.pluckArel::Nodes::InfixOperation.new('->>', Arel::Nodes::SqlLiteral.new('additional_attributes'), Arel::Nodes::SqlLiteral.new("'ai_canonical_question'"))
# O pluck acima pode ser complexo dependendo do adapter, vamos simplificar:
questions = account.conversations
.where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label)
.map { |c| c.additional_attributes['ai_canonical_question'] }
# O pluck acima pode ser complexo dependendo do adapter, vamos simplificar:
questions = account.conversations
.where('created_at >= ?', start_date)
.where("additional_attributes ->> 'ai_canonical_question' IS NOT NULL")
.tagged_with(label)
.map { |c| c.additional_attributes['ai_canonical_question'] }
next if questions.empty?
next if questions.empty?
# 2. Chama LLM para agrupar
clusters = cluster_questions_with_llm(label, questions)
# 2. Chama LLM para agrupar
clusters = cluster_questions_with_llm(label, questions)
# 3. Salva no banco
save_clusters(account, label, clusters, start_date.to_date)
end
# 3. Salva no banco
save_clusters(account, label, clusters, start_date.to_date)
end
end
private
private
def cluster_questions_with_llm(label, questions_list)
prompt = <<~PROMPT
Atue como um analista de dados especialista em atendimento ao cliente.
Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}".
def cluster_questions_with_llm(label, questions_list)
prompt = <<~PROMPT
Atue como um analista de dados especialista em atendimento ao cliente.
Abaixo está uma lista de dúvidas reais de clientes sobre o tópico "#{label}".
Sua tarefa:
1. Agrupar dúvidas semânticamente idênticas.
2. Criar uma "Pergunta Padrão" clara que represente o grupo.
3. Contar quantas vezes cada dúvida apareceu.
Sua tarefa:
1. Agrupar dúvidas semânticamente idênticas.
2. Criar uma "Pergunta Padrão" clara que represente o grupo.
3. Contar quantas vezes cada dúvida apareceu.
Lista de Dúvidas:
#{questions_list.map { |q| "- #{q}" }.join("\n")}
Lista de Dúvidas:
#{questions_list.map { |q| "- #{q}" }.join("\n")}
Retorne APENAS um JSON:
[
{ "question": "Pergunta Padrão 1", "count": 10 },
{ "question": "Pergunta Padrão 2", "count": 5 }
]
PROMPT
Retorne APENAS um JSON:
[
{ "question": "Pergunta Padrão 1", "count": 10 },
{ "question": "Pergunta Padrão 2", "count": 5 }
]
PROMPT
model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini')
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
model = ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini')
chat = RubyLLM.chat(model: model).with_temperature(0.0)
response = chat.ask(prompt)
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
JSON.parse(clean_response)
rescue StandardError => e
Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}"
[]
end
clean_response = response.content.gsub('```json', '').gsub('```', '').strip
JSON.parse(clean_response)
rescue StandardError => e
Rails.logger.error "[ClusterJob] Failed to cluster for label #{label}: #{e.message}"
[]
end
def save_clusters(account, label, clusters, date)
# Limpa clusters anteriores dessa data/label para reprocessamento
FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all
def save_clusters(account, label, clusters, date)
# Limpa clusters anteriores dessa data/label para reprocessamento
FrequentQuestion.where(account: account, label: label, cluster_date: date).destroy_all
clusters.each do |cluster|
FrequentQuestion.create!(
account: account,
label: label,
question_text: cluster['question'],
occurrence_count: cluster['count'],
cluster_date: date
)
end
clusters.each do |cluster|
FrequentQuestion.create!(
account: account,
label: label,
question_text: cluster['question'],
occurrence_count: cluster['count'],
cluster_date: date
)
end
end
end

View File

@ -1,17 +1,15 @@
module CrmInsights
class UpdateJob < ApplicationJob
queue_as :low
class CrmInsights::UpdateJob < ApplicationJob
queue_as :low
def perform(conversation_id, reason: nil)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
def perform(conversation_id, reason: nil)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
if reason == 'idle'
last_activity_at = conversation.last_activity_at
return if last_activity_at.present? && last_activity_at > 30.minutes.ago
end
UpdateService.new(conversation: conversation, reason: reason).call
if reason == 'idle'
last_activity_at = conversation.last_activity_at
return if last_activity_at.present? && last_activity_at > 30.minutes.ago
end
UpdateService.new(conversation: conversation, reason: reason).call
end
end

View File

@ -1,61 +1,59 @@
module Jasmine
class ResponseJob < ApplicationJob
queue_as :default
class Jasmine::ResponseJob < ApplicationJob
queue_as :default
retry_on StandardError, wait: :polynomially_longer, attempts: 2
retry_on StandardError, wait: :polynomially_longer, attempts: 2
def perform(message_id)
message = Message.find_by(id: message_id)
return unless message
def perform(message_id)
message = Message.find_by(id: message_id)
return unless message
conversation = message.conversation
inbox = message.inbox
config = inbox.jasmine_inbox_config
conversation = message.conversation
inbox = message.inbox
config = inbox.jasmine_inbox_config
# Double-check conditions (in case they changed since job was enqueued)
Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}"
return unless config&.is_enabled?
return if conversation.assignee.present?
# Double-check conditions (in case they changed since job was enqueued)
Rails.logger.info "[Jasmine::ResponseJob] Started for Message #{message_id}, Channel Class: #{inbox.channel.class.name}"
return unless config&.is_enabled?
return if conversation.assignee.present?
# Send typing indicator
inbox.channel.toggle_typing_status('typing_on', conversation: conversation)
# Send typing indicator
inbox.channel.toggle_typing_status('typing_on', conversation: conversation)
begin
# Sleep for verification (optimized to 1.5s per recommendation)
sleep 1.5
begin
# Sleep for verification (optimized to 1.5s per recommendation)
sleep 1.5
# Get response from BrainService
response_text = BrainService.new(
inbox: inbox,
conversation: conversation,
message: message
).respond
# Get response from BrainService
response_text = BrainService.new(
inbox: inbox,
conversation: conversation,
message: message
).respond
return if response_text.blank?
return if response_text.blank?
# Send response as outgoing message
send_response(conversation, response_text)
ensure
# Ensure typing is turned off even if errors occur or no response
# Wait a bit to ensure the message "send" signal propagates before sending "paused"
sleep 0.5
inbox.channel.toggle_typing_status('typing_off', conversation: conversation)
end
end
private
def send_response(conversation, content)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content,
sender: nil, # No agent, it's from Jasmine
content_type: :text
)
rescue StandardError => e
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
# Send response as outgoing message
send_response(conversation, response_text)
ensure
# Ensure typing is turned off even if errors occur or no response
# Wait a bit to ensure the message "send" signal propagates before sending "paused"
sleep 0.5
inbox.channel.toggle_typing_status('typing_off', conversation: conversation)
end
end
private
def send_response(conversation, content)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content,
sender: nil, # No agent, it's from Jasmine
content_type: :text
)
rescue StandardError => e
Rails.logger.error "[Jasmine::ResponseJob] Failed to send response: #{e.message}"
end
end

View File

@ -37,11 +37,12 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return unless params[:phone_number]
# Try exact match first, then try adding + if missing
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
phone = params[:phone_number].to_s.strip
channel = Channel::Whatsapp.find_by(phone_number: phone)
return channel if channel
# If params[:phone_number] doesn't have +, try adding it
Channel::Whatsapp.find_by(phone_number: "+#{params[:phone_number]}")
# If phone doesn't have +, try adding it
Channel::Whatsapp.find_by(phone_number: "+#{phone}")
end
def find_channel_from_whatsapp_business_payload(params)

View File

@ -30,6 +30,29 @@ class WebhookListener < BaseListener
payload = message.webhook_data.merge(event: __method__.to_s)
deliver_webhook_payloads(payload, inbox)
message_incoming(event)
message_outgoing(event)
end
def message_incoming(event)
message = extract_message_and_account(event)[0]
return unless message.webhook_sendable?
return unless message.incoming?
payload = message.webhook_data.merge(event: __method__.to_s)
deliver_account_webhooks(payload, message.account)
end
def message_outgoing(event)
message = extract_message_and_account(event)[0]
return unless message.webhook_sendable?
return unless message.outgoing?
payload = message.webhook_data.merge(event: __method__.to_s)
deliver_account_webhooks(payload, message.account)
end
def message_updated(event)
@ -91,6 +114,19 @@ class WebhookListener < BaseListener
handle_typing_status(__method__.to_s, event)
end
def provider_event_received(event)
inbox, account = extract_inbox_and_account(event)
payload = {
event: __method__.to_s,
inbox: inbox.webhook_data,
account: account.webhook_data,
provider_event: event.data[:event],
provider_event_data: event.data[:payload]
}
deliver_account_webhooks(payload, account)
end
private
def handle_typing_status(event_name, event)
@ -110,6 +146,7 @@ class WebhookListener < BaseListener
def deliver_account_webhooks(payload, account)
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
next if payload[:inbox].present? && webhook.inbox_id.present? && webhook.inbox_id != payload[:inbox][:id]
WebhookJob.perform_later(webhook.url, payload)
end

View File

@ -1,7 +1,32 @@
module Captain
class Pricing < ApplicationRecord
belongs_to :account
belongs_to :captain_brand, optional: true
belongs_to :inbox, optional: true
end
# == Schema Information
#
# Table name: captain_pricings
#
# id :bigint not null, primary key
# day_range :string not null
# duration :string not null
# keywords :text
# price :decimal(10, 2) not null
# suite_category :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# captain_brand_id :bigint not null
# inbox_id :bigint
#
# Indexes
#
# index_captain_pricings_on_account_id (account_id)
# index_captain_pricings_on_captain_brand_id (captain_brand_id)
# index_captain_pricings_on_inbox_id (inbox_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id)
#
class Captain::Pricing < ApplicationRecord
belongs_to :account
belongs_to :captain_brand, optional: true
belongs_to :inbox, optional: true
end

View File

@ -1,36 +1,84 @@
module Captain
class Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
# == Schema Information
#
# Table name: captain_reservations
#
# id :bigint not null, primary key
# check_in_at :datetime not null
# check_out_at :datetime not null
# created_by_type :string
# metadata :jsonb not null
# payment_status :string default("pending")
# status :integer default("scheduled"), not null
# suite_identifier :string
# total_amount :decimal(10, 2)
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# captain_brand_id :bigint
# captain_unit_id :bigint
# contact_id :bigint not null
# contact_inbox_id :bigint not null
# conversation_id :bigint
# created_by_id :bigint
# current_pix_charge_id :bigint
# inbox_id :bigint not null
# integracao_id :string
#
# Indexes
#
# index_captain_reservations_on_account_id (account_id)
# index_captain_reservations_on_account_id_and_inbox_id (account_id,inbox_id)
# index_captain_reservations_on_captain_brand_id (captain_brand_id)
# index_captain_reservations_on_captain_unit_id (captain_unit_id)
# index_captain_reservations_on_contact_id (contact_id)
# index_captain_reservations_on_contact_id_and_inbox_id (contact_id,inbox_id)
# index_captain_reservations_on_contact_inbox_id (contact_inbox_id)
# index_captain_reservations_on_conversation_id (conversation_id)
# index_captain_reservations_on_inbox_id (inbox_id)
# index_captain_reservations_on_integracao_id (integracao_id)
# index_captain_reservations_on_integracao_id_and_unit_id (integracao_id,captain_unit_id) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (captain_unit_id => captain_units.id)
# fk_rails_... (contact_id => contacts.id)
# fk_rails_... (contact_inbox_id => contact_inboxes.id)
# fk_rails_... (conversation_id => conversations.id)
# fk_rails_... (inbox_id => inboxes.id)
#
class Captain::Reservation < ApplicationRecord
self.table_name = 'captain_reservations'
belongs_to :account
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, optional: true
belongs_to :captain_brand, optional: true
belongs_to :captain_unit, optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
belongs_to :account
belongs_to :inbox
belongs_to :contact
belongs_to :contact_inbox
belongs_to :conversation, optional: true
belongs_to :captain_brand, optional: true
belongs_to :captain_unit, optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
# Validations
validates :check_in_at, presence: true
validates :check_out_at, presence: true
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
# Validations
validates :check_in_at, presence: true
validates :check_out_at, presence: true
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
enum status: {
scheduled: 0,
active: 1,
completed: 2,
cancelled: 3,
no_show: 4,
pending_payment: 5,
expired: 6,
payment_confirmed: 7,
issues: 8,
awaiting_checkin: 9
}
enum status: {
scheduled: 0,
active: 1,
completed: 2,
cancelled: 3,
no_show: 4,
pending_payment: 5,
expired: 6,
payment_confirmed: 7,
issues: 8,
awaiting_checkin: 9
}
scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
}
end
scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
}
end

View File

@ -1,20 +1,58 @@
module Captain
class Unit < ApplicationRecord
self.table_name = 'captain_units'
# == Schema Information
#
# Table name: captain_units
#
# id :bigint not null, primary key
# inter_account_number :string
# inter_cert_path :string
# inter_client_secret :string
# inter_key_path :string
# inter_pix_key :string
# last_synced_at :datetime
# leader_whatsapp :string
# name :string not null
# plug_play_token :string
# reservation_source_tag :string
# reservations_sync_enabled :boolean
# status :string
# suite_category_images :jsonb not null
# visible_suite_categories :jsonb not null
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# captain_brand_id :bigint not null
# inbox_id :bigint
# inter_client_id :string
# plug_play_id :string
#
# Indexes
#
# index_captain_units_on_account_id (account_id)
# index_captain_units_on_captain_brand_id (captain_brand_id)
# index_captain_units_on_inbox_id (inbox_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (inbox_id => inboxes.id)
#
class Captain::Unit < ApplicationRecord
self.table_name = 'captain_units'
belongs_to :account
belongs_to :captain_brand
belongs_to :inbox, optional: true
belongs_to :account
belongs_to :captain_brand
belongs_to :inbox, optional: true
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy
# Encrypted fields for PlugPlay Integration
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase.
# Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise.
# Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive.
# For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`.
# Let's check generally used pattern later, but for now defining relations is key.
# Encrypted fields for PlugPlay Integration
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase.
# Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise.
# Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive.
# For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`.
# Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true
end
validates :name, presence: true
end

View File

@ -8,6 +8,7 @@
# phone_number :string not null
# provider :string default("default")
# provider_config :jsonb
# provider_connection :jsonb
# wuzapi_admin_token :string
# wuzapi_admin_token_iv :string
# wuzapi_user_token :string
@ -18,7 +19,8 @@
#
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
#
class Channel::Whatsapp < ApplicationRecord
@ -195,7 +197,7 @@ class Channel::Whatsapp < ApplicationRecord
provider_config.delete('wuzapi_user_token')
end
return unless provider_config['wuzapi_admin_token'].present?
return if provider_config['wuzapi_admin_token'].blank?
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
provider_config.delete('wuzapi_admin_token')
@ -207,13 +209,13 @@ class Channel::Whatsapp < ApplicationRecord
def perform_webhook_setup
if provider == 'wuzapi'
return unless inbox.present?
return if inbox.blank?
base_url = provider_config['wuzapi_base_url']
# Use encrypted token
user_token = wuzapi_user_token
return unless user_token.present?
return if user_token.blank?
# Construct Chatwoot Webhook URL
# Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix
@ -284,7 +286,7 @@ class Channel::Whatsapp < ApplicationRecord
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
self.wuzapi_user_token = result[:wuzapi_user_token]
masked_token = result[:wuzapi_user_token].to_s[-4..-1]
masked_token = result[:wuzapi_user_token].to_s[-4..]
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
end
end

View File

@ -2,13 +2,14 @@
#
# Table name: dashboard_apps
#
# id :bigint not null, primary key
# content :jsonb
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint
# id :bigint not null, primary key
# content :jsonb
# show_on_sidebar :boolean default(FALSE), not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint
#
# Indexes
#

View File

@ -77,7 +77,6 @@ class Inbox < ApplicationRecord
has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inbox_automations, dependent: :destroy_async, class_name: 'Captain::InboxAutomation'
# Jasmine
has_one :jasmine_inbox_config, class_name: 'Jasmine::InboxConfig', dependent: :destroy

View File

@ -24,28 +24,26 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (owner_inbox_id => inboxes.id)
#
module Jasmine
class Collection < ApplicationRecord
self.table_name = 'jasmine_collections'
class Jasmine::Collection < ApplicationRecord
self.table_name = 'jasmine_collections'
belongs_to :account
belongs_to :owner_inbox, class_name: 'Inbox', optional: true
belongs_to :account
belongs_to :owner_inbox, class_name: 'Inbox', optional: true
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', foreign_key: :collection_id, dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, dependent: :destroy
has_many :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', dependent: :destroy
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
enum visibility: { private: 0, shared: 1, global: 2 }, _prefix: true
validates :name, presence: true
validates :account_id, presence: true
validate :validate_owner_if_private
validates :name, presence: true
validates :account_id, presence: true
validate :validate_owner_if_private
private
private
def validate_owner_if_private
if visibility_private? && owner_inbox_id.nil?
errors.add(:owner_inbox_id, 'must be present for private collections')
end
end
def validate_owner_if_private
return unless visibility_private? && owner_inbox_id.nil?
errors.add(:owner_inbox_id, 'must be present for private collections')
end
end

View File

@ -25,32 +25,31 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (collection_id => jasmine_collections.id)
#
module Jasmine
class Document < ApplicationRecord
self.table_name = 'jasmine_documents'
class Jasmine::Document < ApplicationRecord
self.table_name = 'jasmine_documents'
belongs_to :account
belongs_to :collection, class_name: 'Jasmine::Collection'
has_many :chunks, class_name: 'Jasmine::DocumentChunk', foreign_key: :document_id, dependent: :delete_all
belongs_to :account
belongs_to :collection, class_name: 'Jasmine::Collection'
has_many :chunks, class_name: 'Jasmine::DocumentChunk', dependent: :delete_all
enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
enum status: { pending: 0, processing: 1, indexed: 2, failed: 3 }
enum source_type: { manual: 0, upload: 1, url: 2, faq: 3 }
validates :content, presence: true
validate :validate_account_consistency
validates :content, presence: true
validate :validate_account_consistency
# Async processing job
after_create_commit :enqueue_embed_job
# Async processing job
after_create_commit :enqueue_embed_job
private
private
def validate_account_consistency
return if collection.nil?
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
def validate_account_consistency
return if collection.nil?
def enqueue_embed_job
Jasmine::EmbedDocumentJob.perform_later(id)
end
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
def enqueue_embed_job
Jasmine::EmbedDocumentJob.perform_later(id)
end
end

View File

@ -26,36 +26,34 @@
# fk_rails_... (collection_id => jasmine_collections.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class InboxCollection < ApplicationRecord
self.table_name = 'jasmine_inbox_collections'
class Jasmine::InboxCollection < ApplicationRecord
self.table_name = 'jasmine_inbox_collections'
belongs_to :account
belongs_to :inbox
belongs_to :collection, class_name: 'Jasmine::Collection'
belongs_to :account
belongs_to :inbox
belongs_to :collection, class_name: 'Jasmine::Collection'
validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :validate_account_consistency
validate :validate_visibility_rules
validate :validate_account_consistency
validate :validate_visibility_rules
validates :collection_id, uniqueness: { scope: :inbox_id }
validates :collection_id, uniqueness: { scope: :inbox_id }
private
private
def validate_account_consistency
return if inbox.nil? || collection.nil?
def validate_account_consistency
return if inbox.nil? || collection.nil?
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
errors.add(:base, 'Collection account mismatch') if collection.account_id != account_id
end
def validate_visibility_rules
return if collection.nil? || inbox.nil?
def validate_visibility_rules
return if collection.nil? || inbox.nil?
if collection.visibility_private? && collection.owner_inbox_id != inbox_id
errors.add(:base, 'Private collections can only be linked to their owner inbox')
end
end
return unless collection.visibility_private? && collection.owner_inbox_id != inbox_id
errors.add(:base, 'Private collections can only be linked to their owner inbox')
end
end

View File

@ -29,22 +29,21 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class InboxConfig < ApplicationRecord
self.table_name = 'jasmine_inbox_settings'
class Jasmine::InboxConfig < ApplicationRecord
self.table_name = 'jasmine_inbox_settings'
belongs_to :account
belongs_to :inbox
belongs_to :account
belongs_to :inbox
validates :account_id, presence: true
validates :inbox_id, presence: true
validate :validate_account_consistency
validates :account_id, presence: true
validates :inbox_id, presence: true
validate :validate_account_consistency
private
private
def validate_account_consistency
return if inbox.nil?
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
end
def validate_account_consistency
return if inbox.nil?
errors.add(:base, 'Inbox account mismatch') if inbox.account_id != account_id
end
end

View File

@ -28,44 +28,42 @@
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id)
#
module Jasmine
class ToolConfig < ApplicationRecord
self.table_name = 'jasmine_tool_configs'
class Jasmine::ToolConfig < ApplicationRecord
self.table_name = 'jasmine_tool_configs'
belongs_to :account
belongs_to :inbox
belongs_to :account
belongs_to :inbox
# Token encryption using Rails 7 native encryption
encrypts :plug_play_token if Chatwoot.encryption_configured?
# Token encryption using Rails 7 native encryption
encrypts :plug_play_token if Chatwoot.encryption_configured?
validates :tool_key, presence: true
validates :plug_play_id, presence: true, if: :is_enabled?
validates :plug_play_token, presence: true, if: :is_enabled?
validates :tool_key, presence: true
validates :plug_play_id, presence: true, if: :is_enabled?
validates :plug_play_token, presence: true, if: :is_enabled?
# Fixed Tool Definitions
DEFINITIONS = {
'status_suites' => {
name: 'Status das Suítes',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
description: 'Verifica o status atual das suítes.'
},
'listar_reservas' => {
name: 'Listar Reservas',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1',
description: 'Lista as reservas ativas.'
},
'categoria_disponibilidade' => {
name: 'Disponibilidade por Categoria',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade',
description: 'Verifica disponibilidade de categorias.'
}
}.freeze
# Fixed Tool Definitions
DEFINITIONS = {
'status_suites' => {
name: 'Status das Suítes',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
description: 'Verifica o status atual das suítes.'
},
'listar_reservas' => {
name: 'Listar Reservas',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1',
description: 'Lista as reservas ativas.'
},
'categoria_disponibilidade' => {
name: 'Disponibilidade por Categoria',
method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade',
description: 'Verifica disponibilidade de categorias.'
}
}.freeze
def self.definitions
DEFINITIONS
end
def self.definitions
DEFINITIONS
end
end

View File

@ -106,7 +106,7 @@ class Message < ApplicationRecord
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
# [:external_error : Can specify if the message creation failed due to an error at external API
# [:data] : Used for structured content types such as voice_call
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :deleted,
:external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
@ -127,6 +127,7 @@ class Message < ApplicationRecord
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :sender, polymorphic: true, optional: true
belongs_to :in_reply_to, class_name: 'Message', optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy_async
@ -257,16 +258,30 @@ class Message < ApplicationRecord
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
return content if content.present?
parts = []
parts << content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
# Audio handling
audio_transcriptions = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
'[Attachment]' if attachments.any?
parts << "[Mensagem de Voz]: #{audio_transcriptions.join(' ')}" if audio_transcriptions.any?
# Image handling (placeholders if not analyzed yet)
image_descriptions = attachments
.where(file_type: :image)
.filter_map { |att| att.meta&.dig('description') }
parts << "[Imagem]: #{image_descriptions.join(', ')}" if image_descriptions.any?
# Fallback for other attachments
if parts.empty? && attachments.any?
other_types = attachments.where.not(file_type: [:audio, :image]).pluck(:file_type).uniq
parts << "[Anexo: #{other_types.join(', ')}]"
end
parts.join("\n").presence || ''
end
private

View File

@ -147,8 +147,10 @@ class Notification < ApplicationRecord
if content.present?
transform_user_mention_content(content.truncate_words(10))
elsif attachments.present?
'Attachment'
else
attachments.present? ? I18n.t('notifications.attachment') : I18n.t('notifications.no_content')
I18n.t('notifications.no_content')
end
end

View File

@ -88,7 +88,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee
alias_attribute :conversations, :assigned_conversations
alias conversations assigned_conversations
has_many :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation

View File

@ -27,7 +27,7 @@ class Webhook < ApplicationRecord
enum webhook_type: { account_type: 0, inbox_type: 1 }
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated
message_created message_updated webwidget_triggered inbox_created inbox_updated
message_created message_incoming message_outgoing message_updated webwidget_triggered inbox_created inbox_updated
conversation_typing_on conversation_typing_off].freeze
private

View File

@ -0,0 +1,4 @@
# Base module to fix NameError
module Captain::Assistant
# Base module to fix NameError
end

View File

@ -4,118 +4,116 @@ require 'json'
require 'openssl'
require 'base64'
module Captain
class InterService
# Constants for API URLs
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
class Captain::InterService
# Constants for API URLs
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
# initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id
@client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path
@key_path = key_path
@pix_key = pix_key
@account_number = account_number
end
# initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id
@client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path
@key_path = key_path
@pix_key = pix_key
@account_number = account_number
end
def create_pix_charge(reservation)
token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token
def create_pix_charge(reservation)
token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token
payload = {
calendario: {
expiracao: 3600 # 1 hour
},
devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name]
},
valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0)
},
chave: @pix_key,
solicitacaoPagador: "Reserva #{reservation[:id]}"
payload = {
calendario: {
expiracao: 3600 # 1 hour
},
devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name]
},
valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0)
},
chave: @pix_key,
solicitacaoPagador: "Reserva #{reservation[:id]}"
}
response = request(:post, PIX_URL, payload, token)
if response.code.to_i == 201
data = JSON.parse(response.body)
{
success: true,
txid: data['txid'],
pix_copy_paste: data['pixCopiaECola'],
# Inter doesn't return a QR code image URL, just the text string.
# Frontend or another service must generate the image.
qr_code_url: data['location'] # Use location if needed
}
response = request(:post, PIX_URL, payload, token)
if response.code.to_i == 201
data = JSON.parse(response.body)
{
success: true,
txid: data['txid'],
pix_copy_paste: data['pixCopiaECola'],
# Inter doesn't return a QR code image URL, just the text string.
# Frontend or another service must generate the image.
qr_code_url: data['location'] # Use location if needed
}
else
Rails.logger.error "Inter PIX Error: #{response.body}"
{ success: false, error: response.body }
end
rescue StandardError => e
Rails.logger.error "Inter Service Error: #{e.message}"
{ success: false, error: e.message }
else
Rails.logger.error "Inter PIX Error: #{response.body}"
{ success: false, error: response.body }
end
rescue StandardError => e
Rails.logger.error "Inter Service Error: #{e.message}"
{ success: false, error: e.message }
end
private
private
def get_token
uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
'client_id' => @client_id,
'client_secret' => @client_secret,
'grant_type' => 'client_credentials',
'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read'
)
def get_token
uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
'client_id' => @client_id,
'client_secret' => @client_secret,
'grant_type' => 'client_credentials',
'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read'
)
response = send_request(uri, request)
response = send_request(uri, request)
if response.code.to_i == 200
JSON.parse(response.body)['access_token']
else
Rails.logger.error "Inter Auth Error: #{response.body}"
nil
end
end
def request(method, url, payload, token)
uri = URI(url)
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
req['Authorization'] = "Bearer #{token}"
req['Content-Type'] = 'application/json'
req.body = payload.to_json
send_request(uri, req)
end
def send_request(uri, req)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing
# Prepare SSL context with client certificate
if @cert_path.present? && @key_path.present?
begin
if File.exist?(@cert_path) && File.exist?(@key_path)
cert_content = File.read(@cert_path)
key_content = File.read(@key_path)
http.cert = OpenSSL::X509::Certificate.new(cert_content)
http.key = OpenSSL::PKey::RSA.new(key_content)
else
Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}"
# If configured but file missing, it will likely fail.
end
rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e
Rails.logger.error "Invalid Certificate/Key format: #{e.message}"
end
end
http.request(req)
if response.code.to_i == 200
JSON.parse(response.body)['access_token']
else
Rails.logger.error "Inter Auth Error: #{response.body}"
nil
end
end
def request(method, url, payload, token)
uri = URI(url)
req = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
req['Authorization'] = "Bearer #{token}"
req['Content-Type'] = 'application/json'
req.body = payload.to_json
send_request(uri, req)
end
def send_request(uri, req)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE # Bypass CRL/Commitment check for dev/testing
# Prepare SSL context with client certificate
if @cert_path.present? && @key_path.present?
begin
if File.exist?(@cert_path) && File.exist?(@key_path)
cert_content = File.read(@cert_path)
key_content = File.read(@key_path)
http.cert = OpenSSL::X509::Certificate.new(cert_content)
http.key = OpenSSL::PKey::RSA.new(key_content)
else
Rails.logger.warn "Inter Cert/Key files not found at paths: #{@cert_path}, #{@key_path}"
# If configured but file missing, it will likely fail.
end
rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::RSAError => e
Rails.logger.error "Invalid Certificate/Key format: #{e.message}"
end
end
http.request(req)
end
end

View File

@ -1,179 +1,171 @@
module Captain
module Reservations
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
class Captain::Reservations::SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
def initialize(unit)
@unit = unit
@account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
def initialize(unit)
@unit = unit
@account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
end
def perform
return unless @unit.reservations_sync_enabled?
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
page = 1
loop do
reservations_data = fetch_page(page)
break if reservations_data.empty?
reservations_data.each do |reservation_data|
process_reservation(reservation_data)
end
def perform
return unless @unit.reservations_sync_enabled?
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
page += 1
# Safety break to avoid infinite loops in case of API issues
break if page > 50
end
page = 1
loop do
reservations_data = fetch_page(page)
break if reservations_data.empty?
@unit.update(last_synced_at: Time.current)
end
reservations_data.each do |reservation_data|
process_reservation(reservation_data)
end
private
page += 1
# Safety break to avoid infinite loops in case of API issues
break if page > 50
end
def fetch_page(page)
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
response = HTTParty.get(url, headers: headers)
@unit.update(last_synced_at: Time.current)
end
private
def fetch_page(page)
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
response = HTTParty.get(url, headers: headers)
if response.success?
begin
JSON.parse(response.body)
rescue StandardError
[]
end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def headers
{
'PLUG-PLAY-ID' => @unit.plug_play_id,
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
'Content-Type' => 'application/json'
}
end
def process_reservation(data)
external_id = data['id']
return if external_id.blank?
reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
# Resolve Contact
contact = find_or_create_contact(data)
# Map Attributes
reservation.account = @account
reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
reservation.contact = contact
reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
# If contact_inbox missing (new contact created without association to this inbox), create it
if reservation.contact_inbox.nil?
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
end
reservation.suite_identifier = data['suiteRef']
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
return
end
reservation.total_amount = data['totalAPagar']
# Status Mapping
reservation.status = map_status(data)
reservation.metadata ||= {}
reservation.metadata['raw_plug_play_data'] = data
reservation.metadata['guest_name'] = data['nome']
reservation.metadata['guest_email'] = data['email']
reservation.metadata['guest_phone'] = data['telefone']
reservation.metadata['notes'] = data['observacoes']
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
reservation.save!
rescue StandardError => e
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
else
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
end
end
def find_or_create_contact(data)
phone = normalize_phone_number(data['telefone'])
email = data['email']
name = data['nome']
contact = nil
# Try finding by phone
contact = @account.contacts.find_by_phone_number(phone) if phone.present?
# Try finding by email
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
# Create if not found
if contact.nil?
contact = @account.contacts.create!(
name: name,
email: email,
phone_number: phone
)
end
contact
end
def normalize_phone_number(raw_phone)
digits = raw_phone.to_s.gsub(/[^\d]/, '')
return nil if digits.blank?
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
return nil if digits.length < 10 || digits.length > 15
"+#{digits}"
end
def parse_date(date_string)
return nil if date_string.blank?
Time.zone.parse(date_string)
if response.success?
begin
JSON.parse(response.body)
rescue StandardError
nil
[]
end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def map_status(data)
# MVP Logic based on dates and 'cancelada'
return :cancelled if data['cancelada'] == true
def headers
{
'PLUG-PLAY-ID' => @unit.plug_play_id,
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
'Content-Type' => 'application/json'
}
end
check_in = parse_date(data['dataInicio'])
check_out = parse_date(data['saidaPrevistaOuNegociada'])
now = Time.current
def process_reservation(data)
external_id = data['id']
return if external_id.blank?
return :scheduled unless check_in && check_out
reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
if check_in.to_date == now.to_date
:scheduled # Or 'awaiting_checkin' if we want to be more specific, but MVP 'scheduled' is usually 'Entrada'
elsif now >= check_in && now < check_out
:active # 'Hospedada'
elsif now >= check_out
:completed # 'Saída' / checkout done
elsif now < check_in
:scheduled
else
:scheduled # Default
end
end
# Resolve Contact
contact = find_or_create_contact(data)
# Map Attributes
reservation.account = @account
reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
reservation.contact = contact
reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
# If contact_inbox missing (new contact created without association to this inbox), create it
if reservation.contact_inbox.nil?
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
end
reservation.suite_identifier = data['suiteRef']
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
return
end
reservation.total_amount = data['totalAPagar']
# Status Mapping
reservation.status = map_status(data)
reservation.metadata ||= {}
reservation.metadata['raw_plug_play_data'] = data
reservation.metadata['guest_name'] = data['nome']
reservation.metadata['guest_email'] = data['email']
reservation.metadata['guest_phone'] = data['telefone']
reservation.metadata['notes'] = data['observacoes']
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
reservation.save!
rescue StandardError => e
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
else
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
end
end
def find_or_create_contact(data)
phone = normalize_phone_number(data['telefone'])
email = data['email']
name = data['nome']
contact = nil
# Try finding by phone
contact = @account.contacts.find_by(phone_number: phone) if phone.present?
# Try finding by email
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
# Create if not found
if contact.nil?
contact = @account.contacts.create!(
name: name,
email: email,
phone_number: phone
)
end
contact
end
def normalize_phone_number(raw_phone)
digits = raw_phone.to_s.gsub(/[^\d]/, '')
return nil if digits.blank?
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
return nil if digits.length < 10 || digits.length > 15
"+#{digits}"
end
def parse_date(date_string)
return nil if date_string.blank?
Time.zone.parse(date_string)
rescue StandardError
nil
end
def map_status(data)
# MVP Logic based on dates and 'cancelada'
return :cancelled if data['cancelada'] == true
check_in = parse_date(data['dataInicio'])
check_out = parse_date(data['saidaPrevistaOuNegociada'])
now = Time.current
return :scheduled unless check_in && check_out
if now >= check_out
:completed
elsif now >= check_in && now < check_out
:active
else
:scheduled
end
end
end

View File

@ -65,7 +65,7 @@ class Conversations::MessageWindowService
end
def whatsapp_messaging_window
return nil if @conversation.inbox.channel.provider == 'wuzapi'
return nil if @conversation.inbox.channel&.provider == 'wuzapi'
MESSAGING_WINDOW_24_HOURS
end

View File

@ -1,31 +1,29 @@
module CrmInsights
class ContactSessionCounter
WINDOW = 24.hours
class CrmInsights::ContactSessionCounter
WINDOW = 24.hours
def initialize(conversation)
@conversation = conversation
end
def initialize(conversation)
@conversation = conversation
end
def call
inbound_times = @conversation.messages
.where(message_type: :incoming, private: false)
.order(:created_at)
.pluck(:created_at)
def call
inbound_times = @conversation.messages
.where(message_type: :incoming, private: false)
.order(:created_at)
.pluck(:created_at)
count = 0
last_session_start = nil
count = 0
last_session_start = nil
inbound_times.each do |timestamp|
if last_session_start.nil? || timestamp > last_session_start + WINDOW
count += 1
last_session_start = timestamp
end
inbound_times.each do |timestamp|
if last_session_start.nil? || timestamp > last_session_start + WINDOW
count += 1
last_session_start = timestamp
end
{
count: count,
last_contact_at: last_session_start
}
end
{
count: count,
last_contact_at: last_session_start
}
end
end

View File

@ -1,172 +1,170 @@
module CrmInsights
class GenerateService < Llm::BaseAiService
DEFAULT_MODEL = 'gpt-4o-mini'
class CrmInsights::GenerateService < Llm::BaseAiService
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
super()
@conversation = conversation
@insight = insight
@sessions_count = sessions_count
@last_contact_at = last_contact_at
@from_message_id = from_message_id
@to_message_id = to_message_id
@model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL)
end
def initialize(conversation:, insight:, sessions_count:, last_contact_at:, from_message_id: nil, to_message_id: nil)
super()
@conversation = conversation
@insight = insight
@sessions_count = sessions_count
@last_contact_at = last_contact_at
@from_message_id = from_message_id
@to_message_id = to_message_id
@model = ENV.fetch('CRM_INSIGHTS_MODEL', DEFAULT_MODEL)
end
def generate
chat = RubyLLM.chat(model: @model)
.with_temperature(0.2)
.with_params(response_format: { type: 'json_object' })
response = chat.ask(prompt)
parsed = parse_response(response)
return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank?
def generate
chat = RubyLLM.chat(model: @model)
.with_temperature(0.2)
.with_params(response_format: { type: 'json_object' })
response = chat.ask(prompt)
parsed = parse_response(response)
return { data: nil, error: 'Resposta invalida do modelo' } if parsed.blank?
{ data: parsed, error: nil }
rescue StandardError => e
Rails.logger.error "[CRM Insights] Generation failed: #{e.message}"
{ data: nil, error: e.message }
end
{ data: parsed, error: nil }
rescue StandardError => e
Rails.logger.error "[CRM Insights] Generation failed: #{e.message}"
{ data: nil, error: e.message }
end
private
private
def prompt
<<~PROMPT
Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente.
def prompt
<<~PROMPT
Voce eh uma IA de CRM inteligente para atendimento. Gere um perfil vivo do cliente.
Regras:
- Idioma: PT-BR sempre.
- Nao resuma a conversa; gere um perfil do cliente.
- Frases curtas, estilo CRM humano.
- Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes.
- Atualize o resumo existente sem perder informacoes relevantes.
- Priorize padroes recorrentes sobre eventos isolados.
- Se dados forem insuficientes, diga que faltam sinais claros.
- So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo.
- Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio.
- Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio.
- Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio.
- Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos.
Regras:
- Idioma: PT-BR sempre.
- Nao resuma a conversa; gere um perfil do cliente.
- Frases curtas, estilo CRM humano.
- Sem listas longas. Use bullets curtos apenas nos blocos de padroes e friccoes.
- Atualize o resumo existente sem perder informacoes relevantes.
- Priorize padroes recorrentes sobre eventos isolados.
- Se dados forem insuficientes, diga que faltam sinais claros.
- So inclua frictions e contact_pattern se houver evidencia explicita no historico abaixo.
- Nao preencha valores padrao. Se nao houver sinal, use lista vazia ou campo vazio.
- Nunca invente horarios ou dias. Se nao houver mencao direta, deixe contact_pattern vazio.
- Nunca invente friccoes. Se nao houver mencao direta, deixe frictions vazio.
- Se houver menos de 3 mensagens do cliente no historico, gere um resumo minimalista apenas com fatos explicitos.
Saida OBRIGATORIA (JSON valido):
{
"summary_text": "texto humano completo para UI",
"structured_data": {
"summary_text": "...",
"preferences": [],
"contact_pattern": { "time_range": "", "days": [] },
"intent": "",
"price_sensitivity": "",
"urgency": "",
"frictions": [],
"commercial_status": "",
"customer_potential": "",
"agent_tip": "",
"funnel": {
"stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost
"confidence": 0.0, // float 0-1
"reason": "justificativa curta",
"evidence_message_ids": [], // IDs das mensagens que justificam o estagio
"updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior
}
Saida OBRIGATORIA (JSON valido):
{
"summary_text": "texto humano completo para UI",
"structured_data": {
"summary_text": "...",
"preferences": [],
"contact_pattern": { "time_range": "", "days": [] },
"intent": "",
"price_sensitivity": "",
"urgency": "",
"frictions": [],
"commercial_status": "",
"customer_potential": "",
"agent_tip": "",
"funnel": {
"stage": "info", // enum: info, price, availability, confirmation, closed_won, closed_lost
"confidence": 0.0, // float 0-1
"reason": "justificativa curta",
"evidence_message_ids": [], // IDs das mensagens que justificam o estagio
"updated_at": "ISO8601" // data atual se houve mudanca, ou manter anterior
}
}
}
REGRAS FUNIL DE VENDAS (CRITICO):
1. Analise APENAS o historico fornecido abaixo para definir o estagio.
2. Estagios:
- info: pede informacoes gerais. (Confianca minima: qualquer)
- price: discute valores. (Confianca minima: 0.6)
- availability: pergunta sobre datas/vagas. (Confianca minima: 0.6)
- confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75)
- closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85)
- closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85)
3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio.
4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda.
5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info".
REGRAS FUNIL DE VENDAS (CRITICO):
1. Analise APENAS o historico fornecido abaixo para definir o estagio.
2. Estagios:
- info: pede informacoes gerais. (Confianca minima: qualquer)
- price: discute valores. (Confianca minima: 0.6)
- availability: pergunta sobre datas/vagas. (Confianca minima: 0.6)
- confirmation: sinaliza reserva/pagamento. (Confianca minima: 0.75)
- closed_won: confirmou reserva explicitamente ("ja paguei", "reservado"). (Confianca minima: 0.85)
- closed_lost: desistiu explicitamente ("nao vou querer", "fica pra proxima"). (Confianca minima: 0.85)
3. Se nao houver mensagens NOVAS suficientes para mudar de estagio com confianca, mantenha o estagio anterior (se fornecido no JSON anterior) ou retorne "info" se for o inicio.
4. NUNCA avance para closed_won/lost sem evidencia explicita de fechamento ou perda.
5. "evidence_message_ids" eh OBRIGATORIO. Se estiver vazio, o estagio deve ser considerado invalido ou "info".
Contexto:
- Canal: #{channel_name}
- Conversa ID: #{@conversation.id}
- Contatos (24h): #{@sessions_count}
- Ultimo contato valido: #{format_time(@last_contact_at)}
- Intervalo de mensagens: #{message_range_label}
Contexto:
- Canal: #{channel_name}
- Conversa ID: #{@conversation.id}
- Contatos (24h): #{@sessions_count}
- Ultimo contato valido: #{format_time(@last_contact_at)}
- Intervalo de mensagens: #{message_range_label}
Resumo anterior (se existir):
#{@insight&.summary_text || 'Sem resumo anterior.'}
Resumo anterior (se existir):
#{@insight&.summary_text || 'Sem resumo anterior.'}
JSON anterior (se existir):
#{(@insight&.structured_data || {}).to_json}
JSON anterior (se existir):
#{(@insight&.structured_data || {}).to_json}
Historico recente (ate 50 mensagens):
#{history_block}
Historico recente (ate 50 mensagens):
#{history_block}
Formato do texto humano (exemplo de estilo):
Cliente recorrente.
Demonstra preferencia por suites com hidro.
Costuma entrar em contato a noite (principalmente entre 19h e 23h).
Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in.
Perfil objetivo, poucas mensagens.
Formato do texto humano (exemplo de estilo):
Cliente recorrente.
Demonstra preferencia por suites com hidro.
Costuma entrar em contato a noite (principalmente entre 19h e 23h).
Ja perguntou diversas vezes sobre formas de pagamento e horarios de check-in.
Perfil objetivo, poucas mensagens.
Intencao predominante: reserva rapida
Sensibilidade a preco: media
Urgencia: alta
Intencao predominante: reserva rapida
Sensibilidade a preco: media
Urgencia: alta
Padrao de contato:
Horario: entre 19h e 23h
Dias mais comuns: sexta e sabado
Padrao de contato:
Horario: entre 19h e 23h
Dias mais comuns: sexta e sabado
Pontos de atencao:
Duvidas recorrentes sobre formas de pagamento
Questionamentos frequentes sobre horario de check-in
Pontos de atencao:
Duvidas recorrentes sobre formas de pagamento
Questionamentos frequentes sobre horario de check-in
Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade)
Status comercial atual: 🟢 Alta chance de conversao (Estagio: Disponibilidade)
Potencial do cliente:
Perfil recorrente
Compativel com suites premium
Bom candidato a fidelizacao
Potencial do cliente:
Perfil recorrente
Compativel com suites premium
Bom candidato a fidelizacao
Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro.
PROMPT
end
Dica para atendimento: seja direto, informe valor e disponibilidade rapidamente e foque em suites com hidro.
PROMPT
end
def history_block
messages = @conversation.messages
.where(message_type: %i[incoming outgoing], private: false)
messages = messages.where('id >= ?', @from_message_id) if @from_message_id
messages = messages.where('id <= ?', @to_message_id) if @to_message_id
messages = messages.order(created_at: :desc).limit(50).reverse
messages.map do |message|
role = message.incoming? ? 'Cliente' : 'Atendente'
time = message.created_at&.strftime('%d/%m/%Y %H:%M')
"#{time} - #{role}: #{message.content}"
end.join("\n")
end
def history_block
messages = @conversation.messages
.where(message_type: %i[incoming outgoing], private: false)
messages = messages.where('id >= ?', @from_message_id) if @from_message_id
messages = messages.where('id <= ?', @to_message_id) if @to_message_id
messages = messages.order(created_at: :desc).limit(50).reverse
messages.map do |message|
role = message.incoming? ? 'Cliente' : 'Atendente'
time = message.created_at&.strftime('%d/%m/%Y %H:%M')
"#{time} - #{role}: #{message.content}"
end.join("\n")
end
def channel_name
@conversation.inbox&.channel_type.to_s
end
def channel_name
@conversation.inbox&.channel_type.to_s
end
def format_time(value)
return 'Desconhecido' if value.blank?
def format_time(value)
return 'Desconhecido' if value.blank?
value.strftime('%d/%m/%Y %H:%M')
end
value.strftime('%d/%m/%Y %H:%M')
end
def parse_response(response)
content = response.respond_to?(:content) ? response.content : response.to_s
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}"
nil
end
def parse_response(response)
content = response.respond_to?(:content) ? response.content : response.to_s
JSON.parse(content)
rescue JSON::ParserError => e
Rails.logger.error "[CRM Insights] JSON parse failed: #{e.message}"
nil
end
def message_range_label
return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank?
return "A partir de #{@from_message_id}" if @to_message_id.blank?
return "Ate #{@to_message_id}" if @from_message_id.blank?
def message_range_label
return 'Completo (ate 50 mensagens)' if @from_message_id.blank? && @to_message_id.blank?
return "A partir de #{@from_message_id}" if @to_message_id.blank?
return "Ate #{@to_message_id}" if @from_message_id.blank?
"#{@from_message_id} ate #{@to_message_id}"
end
"#{@from_message_id} ate #{@to_message_id}"
end
end

View File

@ -1,359 +1,357 @@
module CrmInsights
class UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
end
class CrmInsights::UpdateService
def initialize(conversation:, reason: nil)
@conversation = conversation
@reason = reason
end
def call
session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id)
return result_payload(last_success, 'no_messages') if last_message_id.blank?
def call
session_stats = ContactSessionCounter.new(@conversation).call
last_success = @conversation.latest_crm_insight
last_message_id = relevant_messages.maximum(:id)
return result_payload(last_success, 'no_messages') if last_message_id.blank?
from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil
to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
from_message_id = last_success&.range_to_message_id ? last_success.range_to_message_id + 1 : nil
to_message_id = last_message_id
return result_payload(last_success, 'no_delta') if from_message_id.present? && from_message_id > to_message_id
result = GenerateService.new(
conversation: @conversation,
insight: last_success,
sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id,
to_message_id: to_message_id
).generate
result = GenerateService.new(
conversation: @conversation,
insight: last_success,
sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
from_message_id: from_message_id,
to_message_id: to_message_id
).generate
if result[:data].blank?
create_failed_insight(
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id,
error_message: result[:error] || 'Falha ao gerar resumo'
)
return result_payload(last_success, 'failed', result[:error])
end
range_messages = messages_for_range(from_message_id, to_message_id)
sanitized_result = sanitize_result(
result[:data],
range_messages,
last_success&.structured_data || {},
@conversation.contact
)
insight = create_success_insight(
result: sanitized_result,
if result[:data].blank?
create_failed_insight(
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id
to_message_id: to_message_id,
error_message: result[:error] || 'Falha ao gerar resumo'
)
result_payload(insight, 'success')
return result_payload(last_success, 'failed', result[:error])
end
private
range_messages = messages_for_range(from_message_id, to_message_id)
sanitized_result = sanitize_result(
result[:data],
range_messages,
last_success&.structured_data || {},
@conversation.contact
)
def relevant_messages
@relevant_messages ||= @conversation.messages.where(
message_type: %i[incoming outgoing],
private: false
)
insight = create_success_insight(
result: sanitized_result,
session_stats: session_stats,
from_message_id: from_message_id,
to_message_id: to_message_id
)
result_payload(insight, 'success')
end
private
def relevant_messages
@relevant_messages ||= @conversation.messages.where(
message_type: %i[incoming outgoing],
private: false
)
end
def messages_for_range(from_message_id, to_message_id)
scope = relevant_messages
scope = scope.where('id >= ?', from_message_id) if from_message_id
scope = scope.where('id <= ?', to_message_id) if to_message_id
scope
end
def sanitize_result(result, messages, prior_structured, contact)
structured_data = result['structured_data'] || {}
incoming_messages = messages.select(&:incoming?)
incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ')
inbound_count = messages.count(&:incoming?)
outbound_count = messages.count(&:outgoing?)
sanitized_structured = structured_data.deep_dup
return minimal_payload(incoming_messages, contact) if inbound_count < 3
sanitized_structured['frictions'] = sanitize_frictions(
structured_data['frictions'],
incoming_text,
prior_structured['frictions']
)
sanitized_structured['contact_pattern'] = sanitize_contact_pattern(
structured_data['contact_pattern'],
incoming_text,
inbound_count,
prior_structured['contact_pattern']
)
sanitized_structured['preferences'] = sanitize_preferences(
structured_data['preferences'],
incoming_text,
prior_structured['preferences']
)
if inbound_count < 3 && outbound_count < 3
sanitized_structured['intent'] = ''
sanitized_structured['urgency'] = ''
sanitized_structured['price_sensitivity'] = ''
sanitized_structured['commercial_status'] = ''
sanitized_structured['customer_potential'] = ''
end
def messages_for_range(from_message_id, to_message_id)
scope = relevant_messages
scope = scope.where('id >= ?', from_message_id) if from_message_id
scope = scope.where('id <= ?', to_message_id) if to_message_id
scope
summary_text = result['summary_text'].to_s.strip
summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.'
sanitized_structured['summary_text'] = summary_text
sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0'
sanitized_structured['source'] = structured_data['source'] || 'ai'
sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601
sanitized_structured['evidence'] ||= {}
{
'summary_text' => summary_text,
'structured_data' => sanitized_structured
}
end
def sanitize_frictions(frictions, text, prior_frictions)
items = Array(frictions).map(&:to_s)
return Array(prior_frictions).map(&:to_s) if items.empty?
evidence = {
'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i,
'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i,
'preco' => /(pre[cç]o|valor|custo)/i
}
filtered = items.select do |item|
evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } ||
evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) }
end
return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present?
filtered
end
def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern)
pattern_hash = pattern.is_a?(Hash) ? pattern : {}
time_range = pattern_hash['time_range'].to_s
days = Array(pattern_hash['days']).map(&:to_s)
if inbound_count < 3
return prior_pattern if prior_pattern.present?
return { 'time_range' => '', 'days' => [] }
end
def sanitize_result(result, messages, prior_structured, contact)
structured_data = result['structured_data'] || {}
incoming_messages = messages.select(&:incoming?)
incoming_text = incoming_messages.map { |message| message.content.to_s.downcase }.join(' ')
inbound_count = messages.count(&:incoming?)
outbound_count = messages.count(&:outgoing?)
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
sanitized_structured = structured_data.deep_dup
return minimal_payload(incoming_messages, contact) if inbound_count < 3
sanitized_structured['frictions'] = sanitize_frictions(
structured_data['frictions'],
incoming_text,
prior_structured['frictions']
)
sanitized_structured['contact_pattern'] = sanitize_contact_pattern(
structured_data['contact_pattern'],
incoming_text,
inbound_count,
prior_structured['contact_pattern']
)
sanitized_structured['preferences'] = sanitize_preferences(
structured_data['preferences'],
incoming_text,
prior_structured['preferences']
)
if inbound_count < 3 && outbound_count < 3
sanitized_structured['intent'] = ''
sanitized_structured['urgency'] = ''
sanitized_structured['price_sensitivity'] = ''
sanitized_structured['commercial_status'] = ''
sanitized_structured['customer_potential'] = ''
time_range = '' unless time_evidence
days = [] unless day_evidence
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
end
summary_text = result['summary_text'].to_s.strip
summary_text = summary_text.presence || 'Ainda nao ha dados suficientes para um perfil do cliente.'
sanitized_structured['summary_text'] = summary_text
sanitized_structured['schema_version'] = structured_data['schema_version'] || '1.0'
sanitized_structured['source'] = structured_data['source'] || 'ai'
sanitized_structured['generated_at'] = structured_data['generated_at'] || Time.current.iso8601
sanitized_structured['evidence'] ||= {}
{
'summary_text' => summary_text,
'structured_data' => sanitized_structured
}
end
def sanitize_frictions(frictions, text, prior_frictions)
items = Array(frictions).map(&:to_s)
return Array(prior_frictions).map(&:to_s) if items.empty?
{
'time_range' => time_range,
'days' => days
}
end
evidence = {
'pagamento' => /(pagamento|pix|cart[aã]o|forma de pagamento)/i,
'checkin' => /(check-?in|entrada|hor[aá]rio de entrada)/i,
'preco' => /(pre[cç]o|valor|custo)/i
}
def sanitize_preferences(preferences, text, prior_preferences)
return Array(prior_preferences).map(&:to_s) if preferences.blank?
filtered = items.select do |item|
evidence.any? { |key, pattern| item.downcase.include?(key) && text.match?(pattern) } ||
evidence.any? { |_, pattern| text.match?(pattern) && item.downcase.match?(pattern) }
tokens = if preferences.is_a?(Array)
preferences
elsif preferences.is_a?(Hash)
preferences.values.flatten
else
[preferences]
end
filtered = tokens.map(&:to_s).select do |item|
case item.downcase
when /hidro/
text.include?('hidro')
when /pix/
text.include?('pix')
when /check/
text.match?(/check-?in/)
else
parts = item.downcase.split(/[_\s]/).reject(&:blank?)
parts.any? { |part| text.include?(part) }
end
return Array(prior_frictions).map(&:to_s) if filtered.empty? && prior_frictions.present?
end
return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present?
filtered
filtered
end
def minimal_summary(text, preferences)
prefs = Array(preferences).map(&:to_s).reject(&:blank?)
parts = []
if prefs.any?
humanized = prefs.map { |item| item.tr('_', ' ') }
parts << "demonstrou interesse em #{humanized.join(', ')}"
end
def sanitize_contact_pattern(pattern, text, inbound_count, prior_pattern)
pattern_hash = pattern.is_a?(Hash) ? pattern : {}
time_range = pattern_hash['time_range'].to_s
days = Array(pattern_hash['days']).map(&:to_s)
parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
if inbound_count < 3
return prior_pattern if prior_pattern.present?
parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
return { 'time_range' => '', 'days' => [] }
end
parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
time_evidence = text.match?(/(\b([01]?\d|2[0-3])h\b|\bmanha\b|\btarde\b|\bnoite\b|\bmadrugada\b)/i)
day_evidence = text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty?
time_range = '' unless time_evidence
days = [] unless day_evidence
if days.any?
normalized_text = text.downcase
days = days.select do |day|
normalized_text.match?(/\b#{Regexp.escape(day.downcase)}\b/i)
end
end
"Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
end
{
'time_range' => time_range,
'days' => days
}
def minimal_payload(incoming_messages, contact)
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
normalized_text = normalize_text(incoming_text)
evidence = {}
preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil)
if preferred_name.present?
name_ids = evidence_ids_for(preferred_name, incoming_messages)
evidence['preferred_name'] = name_ids if name_ids.any?
end
def sanitize_preferences(preferences, text, prior_preferences)
return Array(prior_preferences).map(&:to_s) if preferences.blank?
tokens = if preferences.is_a?(Array)
preferences
elsif preferences.is_a?(Hash)
preferences.values.flatten
else
[preferences]
end
filtered = tokens.map(&:to_s).select do |item|
case item.downcase
when /hidro/
text.include?('hidro')
when /pix/
text.include?('pix')
when /check/
text.match?(/check-?in/)
else
parts = item.downcase.split(/[_\s]/).reject(&:blank?)
parts.any? { |part| text.include?(part) }
end
end
return Array(prior_preferences).map(&:to_s) if filtered.empty? && prior_preferences.present?
filtered
room_type = nil
if normalized_text.include?('hidro')
room_type = 'suite_hidro'
evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
end
def minimal_summary(text, preferences)
prefs = Array(preferences).map(&:to_s).reject(&:blank?)
parts = []
if prefs.any?
humanized = prefs.map { |item| item.tr('_', ' ') }
parts << "demonstrou interesse em #{humanized.join(', ')}"
end
parts << 'perguntou sobre pagamento' if text.match?(/pix|pagamento|cart[aã]o|forma de pagamento/i)
parts << 'perguntou sobre horario de check-in' if text.match?(/check-?in|entrada|hor[aá]rio de entrada/i)
parts << 'mencionou um dia especifico' if text.match?(/\b(segunda|ter[cç]a|quarta|quinta|sexta|sabado|sábado|domingo)\b/i)
return 'Conversa inicial, sem historico suficiente para inferir padroes.' if parts.empty?
"Cliente #{parts.join(' e ')}. Conversa inicial, sem historico suficiente para inferir padroes."
day_interest = []
day_map.each_key do |day|
day_interest << day if normalized_text.match?(/\b#{day}\b/i)
end
if day_interest.any?
day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i })
evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages)
end
def minimal_payload(incoming_messages, contact)
incoming_text = incoming_messages.map { |message| message.content.to_s }.join(' ')
normalized_text = normalize_text(incoming_text)
evidence = {}
preferred_name = contact&.additional_attributes&.fetch('preferred_name', nil)
if preferred_name.present?
name_ids = evidence_ids_for(preferred_name, incoming_messages)
evidence['preferred_name'] = name_ids if name_ids.any?
end
room_type = nil
if normalized_text.include?('hidro')
room_type = 'suite_hidro'
evidence['preferences.room_type'] = evidence_ids_for(/hidro/i, incoming_messages)
end
day_interest = []
day_map.each_key do |day|
day_interest << day if normalized_text.match?(/\b#{day}\b/i)
end
if day_interest.any?
day_regex = Regexp.union(day_interest.map { |day| /\b#{day}\b/i })
evidence['preferences.date_interest'] = evidence_ids_for(day_regex, incoming_messages)
end
intent = nil
if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/)
intent = 'reserva_rapida'
evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages)
end
summary_text = minimal_summary(normalized_text, room_type ? [room_type] : [])
summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present?
summary_text = summary_text.strip
structured_data = {
'schema_version' => '1.0',
'source' => 'ai',
'generated_at' => Time.current.iso8601,
'summary_text' => summary_text,
'customer_type' => nil,
'customer_potential' => nil,
'intent' => intent,
'urgency' => nil,
'price_sensitivity' => nil,
'confidence' => intent.present? ? 0.9 : nil,
'preferences' => {
'room_type' => room_type ? [room_type] : [],
'date_interest' => day_interest
},
'contact_pattern' => nil,
'frictions' => nil,
'commercial_status' => nil,
'nba' => if intent.present?
{
'action' => 'informar_disponibilidade_e_valor',
'priority' => 'media',
'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.'
}
end,
'suggested_labels' => [
(room_type ? 'hidro' : nil),
'primeiro_contato'
].compact,
'evidence' => evidence
}
{
'summary_text' => summary_text,
'structured_data' => structured_data
}
intent = nil
if normalized_text.match?(/reserv|disponibil|vaga|quero|gostaria/)
intent = 'reserva_rapida'
evidence['intent'] = evidence_ids_for(/reserv|disponibil|vaga|quero|gostaria/i, incoming_messages)
end
def evidence_ids_for(pattern, messages)
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
end
summary_text = minimal_summary(normalized_text, room_type ? [room_type] : [])
summary_text = "Cliente se apresentou como #{preferred_name}. #{summary_text}" if preferred_name.present?
summary_text = summary_text.strip
def normalize_text(value)
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
end
structured_data = {
'schema_version' => '1.0',
'source' => 'ai',
'generated_at' => Time.current.iso8601,
'summary_text' => summary_text,
'customer_type' => nil,
'customer_potential' => nil,
'intent' => intent,
'urgency' => nil,
'price_sensitivity' => nil,
'confidence' => intent.present? ? 0.9 : nil,
'preferences' => {
'room_type' => room_type ? [room_type] : [],
'date_interest' => day_interest
},
'contact_pattern' => nil,
'frictions' => nil,
'commercial_status' => nil,
'nba' => if intent.present?
{
'action' => 'informar_disponibilidade_e_valor',
'priority' => 'media',
'reason' => 'Cliente demonstrou interesse inicial, mas ainda nao informou horario nem forma de pagamento.'
}
end,
'suggested_labels' => [
(room_type ? 'hidro' : nil),
'primeiro_contato'
].compact,
'evidence' => evidence
}
def day_map
{
'segunda' => 'segunda',
'terca' => 'terca',
'quarta' => 'quarta',
'quinta' => 'quinta',
'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
{
'summary_text' => summary_text,
'structured_data' => structured_data
}
end
def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:)
structured_data = result['structured_data'] || {}
model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: result['summary_text'],
structured_data: structured_data,
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'success',
schema_version: structured_data['schema_version'] || '1.0',
model: structured_data['model'] || model_name,
confidence: structured_data['confidence']
)
end
def evidence_ids_for(pattern, messages)
regex = pattern.is_a?(Regexp) ? pattern : /#{Regexp.escape(pattern.to_s)}/i
messages.select { |message| message.content.to_s.match?(regex) }.map(&:id)
end
def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: nil,
structured_data: {},
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'failed',
error_message: error_message
)
end
def normalize_text(value)
value.to_s.downcase.tr('áàãâéêíóôõúç', 'aaaaeeiooouc')
end
def result_payload(insight, status, error_message = nil)
{
insight: insight,
status: status,
error_message: error_message
}
end
def day_map
{
'segunda' => 'segunda',
'terca' => 'terca',
'quarta' => 'quarta',
'quinta' => 'quinta',
'sexta' => 'sexta',
'sabado' => 'sabado',
'domingo' => 'domingo'
}
end
def create_success_insight(result:, session_stats:, from_message_id:, to_message_id:)
structured_data = result['structured_data'] || {}
model_name = ENV.fetch('CRM_INSIGHTS_MODEL', CrmInsights::GenerateService::DEFAULT_MODEL)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: result['summary_text'],
structured_data: structured_data,
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'success',
schema_version: structured_data['schema_version'] || '1.0',
model: structured_data['model'] || model_name,
confidence: structured_data['confidence']
)
end
def create_failed_insight(session_stats:, from_message_id:, to_message_id:, error_message:)
ConversationCrmInsight.create!(
conversation: @conversation,
contact: @conversation.contact,
account_id: @conversation.account_id,
summary_text: nil,
structured_data: {},
contact_sessions_count: session_stats[:count],
last_contact_at: session_stats[:last_contact_at],
generated_at: Time.current,
range_from_message_id: from_message_id,
range_to_message_id: to_message_id,
status: 'failed',
error_message: error_message
)
end
def result_payload(insight, status, error_message = nil)
{
insight: insight,
status: status,
error_message: error_message
}
end
end

View File

@ -1,274 +1,284 @@
module Jasmine
class BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
info_request: %w[como funciona detalhe explica suíte quarto tipo],
policy: %w[horário check-in check-out política regra pagamento cancelamento],
greeting: %w[oi olá bom dia boa tarde boa noite],
objection: %w[caro não sei preciso pensar depois conversar],
closing: %w[reservar agendar confirmar fechar quero sim pode],
general: [] # fallback
}.freeze
class Jasmine::BrainService
# Default intent keywords for hotel/motel business
DEFAULT_INTENT_KEYWORDS = {
price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
info_request: %w[como funciona detalhe explica suíte quarto tipo],
policy: %w[horário check-in check-out política regra pagamento cancelamento],
greeting: %w[oi olá bom dia boa tarde boa noite],
objection: %w[caro não sei preciso pensar depois conversar],
closing: %w[reservar agendar confirmar fechar quero sim pode],
general: [] # fallback
}.freeze
# Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze
# Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze
attr_reader :inbox, :conversation, :message, :config
attr_reader :inbox, :conversation, :message, :config
def initialize(inbox:, conversation:, message:)
@inbox = inbox
@conversation = conversation
@message = message
@config = load_config
def initialize(inbox:, conversation:, message:)
@inbox = inbox
@conversation = conversation
@message = message
@config = load_config
end
def respond
trigger_media_analysis if message.attachments.any?
llm_content = message.content_for_llm
intent = IntentDetector.new(llm_content, intent_keywords).detect
strategy = StrategyDecider.new(intent, jasmine_state).decide
rag_context = fetch_rag_if_needed(strategy, llm_content)
prompt = PromptAssembler.new(
config: config,
state: jasmine_state,
history: recent_history,
rag_context: rag_context,
current_message: llm_content
).assemble
response = call_llm(prompt)
StateUpdater.new(conversation, intent, rag_context.present?).update
log_decision(intent, strategy, rag_context)
response
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}"
'Desculpe, tive um problema. Pode repetir?'
end
private
def load_config
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new(
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f,
rag_max_results: 3,
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7
)
end
def intent_keywords
custom = config.intent_keywords.presence || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys)
end
def jasmine_state
conversation.custom_attributes&.dig('jasmine_state') || {}
end
def recent_history
msgs = conversation.messages
# Handle both ActiveRecord relations and arrays (for playground testing)
return [] unless msgs.respond_to?(:where)
msgs
.where(message_type: %w[incoming outgoing])
.order(created_at: :desc)
.limit(4) # 2 pairs of messages
.reverse
.map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } }
end
def fetch_rag_if_needed(strategy, query)
return nil if strategy == :no_rag
return nil if loop_protection_triggered?
results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results)
return nil if results.empty?
results.map { |r| r[:content] || r.content }.join("\n\n---\n\n")
end
def loop_protection_triggered?
(jasmine_state['rag_queries_count'] || 0) > 5
end
def fallback_response
nil # Will trigger "vou verificar com a equipe"
end
def call_llm(prompt)
chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature)
response = chat.ask(prompt)
response.content
end
def log_decision(intent, strategy, rag_context)
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
end
def trigger_media_analysis
Rails.logger.info "[Jasmine::Brain] Triggering Media Analysis for Message #{message.id}"
Jasmine::MediaAnalyzerService.new(message: message).perform
message.attachments.reload # CRITICAL: Ensure we see the new metadata
Rails.logger.info "[Jasmine::Brain] Media Analysis Completed for Message #{message.id}"
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Media analysis failed: #{e.message}"
end
# =========================================
# COMPONENT: Intent Detector
# =========================================
class IntentDetector
attr_reader :text, :keywords
def initialize(text, keywords)
@text = text.to_s.downcase.strip
@keywords = keywords
end
def respond
intent = IntentDetector.new(message.content, intent_keywords).detect
strategy = StrategyDecider.new(intent, jasmine_state).decide
def detect
# Check each intent type for keyword matches
keywords.each do |intent_type, words|
next if words.empty?
return intent_type if words.any? { |word| text.include?(word.downcase) }
end
rag_context = fetch_rag_if_needed(strategy, message.content)
:general # fallback
end
end
prompt = PromptAssembler.new(
config: config,
state: jasmine_state,
history: recent_history,
rag_context: rag_context,
current_message: message.content
).assemble
# =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
response = call_llm(prompt)
def initialize(intent, state)
@intent = intent
@state = state
end
StateUpdater.new(conversation, intent, rag_context.present?).update
def decide
return :no_rag if RAG_PROHIBITED.include?(intent)
return :rag_required if RAG_MANDATORY.include?(intent)
log_decision(intent, strategy, rag_context)
:rag_optional
end
end
response
rescue StandardError => e
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}"
'Desculpe, tive um problema. Pode repetir?'
# =========================================
# COMPONENT: Prompt Assembler
# =========================================
class PromptAssembler
MAX_HISTORY_MESSAGES = 4 # 2 pairs
attr_reader :config, :state, :history, :rag_context, :current_message
def initialize(config:, state:, history:, rag_context:, current_message:)
@config = config
@state = state
@history = history.last(MAX_HISTORY_MESSAGES)
@rag_context = rag_context
@current_message = current_message
end
def assemble
parts = []
# System Prompt (identity, tone, rules)
parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present?
# Playbook SDR (sales script)
parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present?
# Lead State
if state.present?
state_text = format_state(state)
parts << "[ESTADO DO LEAD]\n#{state_text}"
end
# RAG Context (SOURCE OF TRUTH)
if rag_context.present?
parts << <<~RAG
[CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE]
Use EXCLUSIVAMENTE as informações abaixo para responder.
NÃO invente ou complemente com conhecimento externo.
#{rag_context}
RAG
end
# History (limited)
if history.present?
history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n")
parts << "[HISTÓRICO RECENTE]\n#{history_text}"
end
# Current message
parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}"
parts.join("\n\n")
end
private
def load_config
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new(
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f,
rag_max_results: 3,
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7
def format_state(state)
lines = []
lines << "Etapa: #{state['stage']}" if state['stage']
lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
if state['collected_info'].present?
state['collected_info'].each do |key, value|
lines << "#{key.capitalize}: #{value}"
end
end
lines.join("\n")
end
end
# =========================================
# COMPONENT: State Updater
# =========================================
class StateUpdater
MAX_STATE_SIZE = 4096 # 4KB
attr_reader :conversation, :intent, :rag_used
def initialize(conversation, intent, rag_used)
@conversation = conversation
@intent = intent
@rag_used = rag_used
end
def update
current_state = conversation.custom_attributes&.dig('jasmine_state') || {}
new_state = current_state.merge(
'last_intent' => intent.to_s,
'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0),
'updated_at' => Time.current.iso8601
)
# Size protection
if new_state.to_json.bytesize > MAX_STATE_SIZE
Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up'
new_state = {
'last_intent' => intent.to_s,
'rag_queries_count' => 0,
'updated_at' => Time.current.iso8601
}
end
conversation.update!(
custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state)
)
end
def intent_keywords
custom = config.intent_keywords.presence || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys)
end
def jasmine_state
conversation.custom_attributes&.dig('jasmine_state') || {}
end
def recent_history
msgs = conversation.messages
# Handle both ActiveRecord relations and arrays (for playground testing)
return [] unless msgs.respond_to?(:where)
msgs
.where(message_type: %w[incoming outgoing])
.order(created_at: :desc)
.limit(4) # 2 pairs of messages
.reverse
.map { |m| { role: m.message_type == 'incoming' ? 'user' : 'assistant', content: m.content } }
end
def fetch_rag_if_needed(strategy, query)
return nil if strategy == :no_rag
return nil if loop_protection_triggered?
results = Jasmine::SemanticSearchService.new(inbox).search(query, limit: config.rag_max_results)
return nil if results.empty?
results.map { |r| r[:content] || r.content }.join("\n\n---\n\n")
end
def loop_protection_triggered?
(jasmine_state['rag_queries_count'] || 0) > 5
end
def fallback_response
nil # Will trigger "vou verificar com a equipe"
end
def call_llm(prompt)
chat = RubyLLM.chat(model: config.model).with_temperature(config.temperature)
response = chat.ask(prompt)
response.content
end
def log_decision(intent, strategy, rag_context)
Rails.logger.info "[Jasmine::Brain] Intent: #{intent}, Strategy: #{strategy}, RAG: #{rag_context.present? ? 'yes' : 'no'}"
end
# =========================================
# COMPONENT: Intent Detector
# =========================================
class IntentDetector
attr_reader :text, :keywords
def initialize(text, keywords)
@text = text.to_s.downcase.strip
@keywords = keywords
end
def detect
# Check each intent type for keyword matches
keywords.each do |intent_type, words|
next if words.empty?
return intent_type if words.any? { |word| text.include?(word.downcase) }
end
:general # fallback
end
end
# =========================================
# COMPONENT: Strategy Decider
# =========================================
class StrategyDecider
attr_reader :intent, :state
def initialize(intent, state)
@intent = intent
@state = state
end
def decide
return :no_rag if RAG_PROHIBITED.include?(intent)
return :rag_required if RAG_MANDATORY.include?(intent)
:rag_optional
end
end
# =========================================
# COMPONENT: Prompt Assembler
# =========================================
class PromptAssembler
MAX_HISTORY_MESSAGES = 4 # 2 pairs
attr_reader :config, :state, :history, :rag_context, :current_message
def initialize(config:, state:, history:, rag_context:, current_message:)
@config = config
@state = state
@history = history.last(MAX_HISTORY_MESSAGES)
@rag_context = rag_context
@current_message = current_message
end
def assemble
parts = []
# System Prompt (identity, tone, rules)
parts << "[INSTRUÇÕES DO SISTEMA]\n#{config.system_prompt}" if config.system_prompt.present?
# Playbook SDR (sales script)
parts << "[PLAYBOOK SDR]\n#{config.playbook_prompt}" if config.playbook_prompt.present?
# Lead State
if state.present?
state_text = format_state(state)
parts << "[ESTADO DO LEAD]\n#{state_text}"
end
# RAG Context (SOURCE OF TRUTH)
if rag_context.present?
parts << <<~RAG
[CONTEXTO DA BASE DE CONHECIMENTO - FONTE DA VERDADE]
Use EXCLUSIVAMENTE as informações abaixo para responder.
NÃO invente ou complemente com conhecimento externo.
#{rag_context}
RAG
end
# History (limited)
if history.present?
history_text = history.map { |h| "#{h[:role] == 'user' ? 'Cliente' : 'Jasmine'}: #{h[:content]}" }.join("\n")
parts << "[HISTÓRICO RECENTE]\n#{history_text}"
end
# Current message
parts << "[MENSAGEM ATUAL DO CLIENTE]\n#{current_message}"
parts.join("\n\n")
end
private
def format_state(state)
lines = []
lines << "Etapa: #{state['stage']}" if state['stage']
lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
if state['collected_info'].present?
state['collected_info'].each do |key, value|
lines << "#{key.capitalize}: #{value}"
end
end
lines.join("\n")
end
end
# =========================================
# COMPONENT: State Updater
# =========================================
class StateUpdater
MAX_STATE_SIZE = 4096 # 4KB
attr_reader :conversation, :intent, :rag_used
def initialize(conversation, intent, rag_used)
@conversation = conversation
@intent = intent
@rag_used = rag_used
end
def update
current_state = conversation.custom_attributes&.dig('jasmine_state') || {}
new_state = current_state.merge(
'last_intent' => intent.to_s,
'rag_queries_count' => (current_state['rag_queries_count'] || 0) + (rag_used ? 1 : 0),
'updated_at' => Time.current.iso8601
)
# Size protection
if new_state.to_json.bytesize > MAX_STATE_SIZE
Rails.logger.warn '[Jasmine::Brain] State too large, cleaning up'
new_state = {
'last_intent' => intent.to_s,
'rag_queries_count' => 0,
'updated_at' => Time.current.iso8601
}
end
conversation.update!(
custom_attributes: (conversation.custom_attributes || {}).merge('jasmine_state' => new_state)
)
end
def self.cleanup(conversation)
attrs = conversation.custom_attributes || {}
attrs.delete('jasmine_state')
conversation.update!(custom_attributes: attrs)
end
def self.cleanup(conversation)
attrs = conversation.custom_attributes || {}
attrs.delete('jasmine_state')
conversation.update!(custom_attributes: attrs)
end
end
end

View File

@ -1,129 +1,126 @@
module Jasmine
class EmbeddingService
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
class Jasmine::EmbeddingService
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
def initialize(document)
@document = document
def initialize(document)
@document = document
end
def process
@document.with_lock do
return if @document.indexed?
@document.update!(status: :processing)
@document.chunks.delete_all
chunks = chunk_content(@document.content)
create_chunks(chunks)
@document.update!(status: :indexed)
end
rescue StandardError => e
@document.update!(status: :failed, error_message: e.message)
Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}"
end
def process
@document.with_lock do
return if @document.indexed?
private
@document.update!(status: :processing)
@document.chunks.delete_all
def chunk_content(content)
chunks = []
return chunks if content.blank?
chunks = chunk_content(@document.content)
create_chunks(chunks)
start_index = 0
chunk_index = 0
@document.update!(status: :indexed)
end
rescue StandardError => e
@document.update!(status: :failed, error_message: e.message)
Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}"
end
while start_index < content.length
end_index = [start_index + CHUNK_SIZE, content.length].min
private
def chunk_content(content)
chunks = []
return chunks if content.blank?
start_index = 0
chunk_index = 0
while start_index < content.length
end_index = [start_index + CHUNK_SIZE, content.length].min
if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
end_index = start_index + last_space if last_space
end
chunk_text = content[start_index...end_index].strip
if chunk_text.present?
chunks << {
content: chunk_text,
index: chunk_index,
char_start: start_index,
char_end: end_index
}
chunk_index += 1
end
break if end_index >= content.length
start_index = end_index - CHUNK_OVERLAP
start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE)
if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
end_index = start_index + last_space if last_space
end
chunks
end
chunk_text = content[start_index...end_index].strip
def create_chunks(chunks)
return if chunks.empty?
chunks.each do |chunk_data|
embedding = generate_embedding(chunk_data[:content])
Jasmine::DocumentChunk.create!(
account: @document.account,
collection: @document.collection,
document: @document,
content: chunk_data[:content],
metadata: {
chunk_index: chunk_data[:index],
char_start: chunk_data[:char_start],
char_end: chunk_data[:char_end],
model: embedding_model
},
embedding: embedding
)
if chunk_text.present?
chunks << {
content: chunk_text,
index: chunk_index,
char_start: start_index,
char_end: end_index
}
chunk_index += 1
end
break if end_index >= content.length
start_index = end_index - CHUNK_OVERLAP
start_index = [start_index, end_index].max if start_index <= (end_index - CHUNK_SIZE)
end
def generate_embedding(text)
if openai_configured?
generate_openai_embedding(text)
else
# Fallback: Generate deterministic hash-based embedding for testing
# This won't provide semantic search but allows the system to function
Rails.logger.warn "OpenAI not configured, using fallback embedding for Jasmine"
generate_fallback_embedding(text)
end
end
chunks
end
def openai_configured?
ENV['OPENAI_API_KEY'].present?
end
def create_chunks(chunks)
return if chunks.empty?
def generate_openai_embedding(text)
response = RubyLLM.embed(text, model: embedding_model)
response.vectors.first
end
chunks.each do |chunk_data|
embedding = generate_embedding(chunk_data[:content])
def generate_fallback_embedding(text)
# Generate a deterministic pseudo-random vector based on text content
# Uses SHA256 hash to seed random number generator for reproducibility
require 'digest'
seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32)
rng = Random.new(seed)
# Generate normalized vector with EMBEDDING_DIMENSIONS dimensions
vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) }
# Normalize to unit length
magnitude = Math.sqrt(vector.sum { |v| v**2 })
vector.map { |v| v / magnitude }
end
def embedding_model
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
Jasmine::DocumentChunk.create!(
account: @document.account,
collection: @document.collection,
document: @document,
content: chunk_data[:content],
metadata: {
chunk_index: chunk_data[:index],
char_start: chunk_data[:char_start],
char_end: chunk_data[:char_end],
model: embedding_model
},
embedding: embedding
)
end
end
end
def generate_embedding(text)
if openai_configured?
generate_openai_embedding(text)
else
# Fallback: Generate deterministic hash-based embedding for testing
# This won't provide semantic search but allows the system to function
Rails.logger.warn 'OpenAI not configured, using fallback embedding for Jasmine'
generate_fallback_embedding(text)
end
end
def openai_configured?
ENV['OPENAI_API_KEY'].present?
end
def generate_openai_embedding(text)
response = RubyLLM.embed(text, model: embedding_model)
response.vectors.first
end
def generate_fallback_embedding(text)
# Generate a deterministic pseudo-random vector based on text content
# Uses SHA256 hash to seed random number generator for reproducibility
require 'digest'
seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32)
rng = Random.new(seed)
# Generate normalized vector with EMBEDDING_DIMENSIONS dimensions
vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) }
# Normalize to unit length
magnitude = Math.sqrt(vector.sum { |v| v**2 })
vector.map { |v| v / magnitude }
end
def embedding_model
ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Jasmine::MediaAnalyzerService
attr_reader :message
def initialize(message:)
@message = message
end
def perform
analyze_audio
analyze_images
end
private
def analyze_audio
message.attachments.where(file_type: :audio).find_each do |attachment|
next if attachment.meta&.dig('transcribed_text').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Transcribing audio for Attachment #{attachment.id}"
# Try to use the standard AudioTranscriptionService (usually in enterprise folder)
begin
if defined?(Messages::AudioTranscriptionService)
# This service updates the attachment meta internally
Messages::AudioTranscriptionService.new(attachment).perform
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Audio transcription failed: #{e.message}"
end
end
end
def analyze_images
message.attachments.where(file_type: :image).find_each do |attachment|
next if attachment.meta&.dig('description').present?
Rails.logger.info "[Jasmine::MediaAnalyzer] Analyzing image for Attachment #{attachment.id}"
begin
description = Jasmine::VisionService.new(attachment: attachment).perform
if description.present?
new_meta = (attachment.meta || {}).merge('description' => description)
attachment.update!(meta: new_meta)
Rails.logger.info "[Jasmine::MediaAnalyzer] Image analysis successful for Attachment #{attachment.id}"
end
rescue StandardError => e
Rails.logger.error "[Jasmine::MediaAnalyzer] Image analysis failed: #{e.message}"
end
end
end
end

View File

@ -1,178 +1,176 @@
module Jasmine
class SemanticSearchService
CANDIDATES_PER_PRIORITY = 50
TOP_K_PER_PRIORITY = 10
MAX_CHUNKS_PER_DOC = 2
class Jasmine::SemanticSearchService
CANDIDATES_PER_PRIORITY = 50
TOP_K_PER_PRIORITY = 10
MAX_CHUNKS_PER_DOC = 2
def initialize(inbox)
@inbox = inbox
@account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end
def initialize(inbox)
@inbox = inbox
@account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end
def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections
.where(is_enabled: true)
.order(priority: :desc)
.includes(:collection)
def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections
.where(is_enabled: true)
.order(priority: :desc)
.includes(:collection)
return [] if enabled_links.empty?
return [] if enabled_links.empty?
# Group by exact priority
priority_groups = enabled_links.group_by(&:priority)
# Group by exact priority
priority_groups = enabled_links.group_by(&:priority)
# Prepare query embedding
query_embedding = generate_embedding(query)
# Prepare query embedding
query_embedding = generate_embedding(query)
final_results = []
processed_chunk_ids = Set.new
final_results = []
processed_chunk_ids = Set.new
# 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id)
# 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id)
# Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates)
# Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates)
# Waterfall Logic
group_results.each do |result|
next if processed_chunk_ids.include?(result.id)
# Waterfall Logic
group_results.each do |result|
next if processed_chunk_ids.include?(result.id)
final_results << result
processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
final_results << result
processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
final_results
break if final_results.size >= limit
end
private
final_results
end
def retrieve_candidates(query_embedding, collection_ids)
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.order(Arel.sql("embedding <=> '#{query_embedding}'"))
.limit(CANDIDATES_PER_PRIORITY)
private
def retrieve_candidates(query_embedding, collection_ids)
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.order(Arel.sql("embedding <=> '#{query_embedding}'"))
.limit(CANDIDATES_PER_PRIORITY)
end
def process_candidates(candidates)
# Step 2: Deterministic Reranking and Filtering
# Note: 'nearest_neighbors' from neighbor gem already does distance calc,
# but we did it manually in retrieve_candidates to ensure we control the operator.
# We need to manually calculate distance for thresholding if the db didn't return it explicit as a column,
# or trust the order.
# Better approach: Select distance in the query.
# [FUTURE] Placeholder until distance select is wired into filtering.
# Enhanced query with distance
candidates.select(
"jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance"
)
# Filter by Threshold
# We need to re-query or calculate.
# Let's refine retrieve_candidates to include distance.
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation.
# We'll map them to objects and filters.
# [FUTURE] Reserved for threshold filtering output.
Hash.new(0)
# Calculate distances locally or re-fetch.
# Since we ordered by distance in DB, we rely on that order.
# But we need the value for threshold.
# Let's fix retrieve_candidates to return distance
# Re-doing retrieval with select
# Correct approach:
# Iterate, check threshold, check Max Chunks per Doc
candidates.each do |chunk|
# [FUTURE] Distance will gate threshold checks once wired up.
chunk.neighbor_distance(:embedding, @embedding_vector)
rescue StandardError
nil
# NOTE: neighbor gem might not expose distance easily without using its scopes.
# Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value.
# Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order.
# To strictly follow plan: "Re-rank exact cosine distance".
# We can implement a simple ruby cosine distance if vector is loaded,
# or use the SQL value.
# Optimization: Let's assume the SQL order is correct (it is).
# We just need to stop if distance > threshold.
# Since we can't easily get the distance value without select, let's use neighbor gem scope correctly.
end
def process_candidates(candidates)
# Step 2: Deterministic Reranking and Filtering
# Note: 'nearest_neighbors' from neighbor gem already does distance calc,
# but we did it manually in retrieve_candidates to ensure we control the operator.
# We need to manually calculate distance for thresholding if the db didn't return it explicit as a column,
# or trust the order.
# Better approach: Select distance in the query.
# Better Implementation using Neighbor Gem capabilities which handles this
# But filtering by priority group AND threshold AND limit is complex chain.
# [FUTURE] Placeholder until distance select is wired into filtering.
# Enhanced query with distance
candidates.select(
"jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance"
)
# Let's use Raw SQL for the whole Step 1 + Distance Select
# This is safer.
# Filter by Threshold
# We need to re-query or calculate.
# Let's refine retrieve_candidates to include distance.
return [] if candidates.empty?
end
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation.
# We'll map them to objects and filters.
# Simplified re-implementation of retrieve + process
def retrieve_candidates(query_embedding, collection_ids)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance")
.order('distance ASC')
.limit(CANDIDATES_PER_PRIORITY)
end
# [FUTURE] Reserved for threshold filtering output.
Hash.new(0)
# Overwrite process_candidates with the list from above
def process_candidates(candidates)
filtered = []
doc_counts = Hash.new(0)
# Calculate distances locally or re-fetch.
# Since we ordered by distance in DB, we rely on that order.
# But we need the value for threshold.
candidates.each do |chunk|
# 1. Threshold Check
# distance is a string/float from SQL
dist = chunk[:distance].to_f
next if dist > @threshold
# Let's fix retrieve_candidates to return distance
# Re-doing retrieval with select
# 2. Doc Dedupe
limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
# Correct approach:
# Iterate, check threshold, check Max Chunks per Doc
candidates.each do |chunk|
# [FUTURE] Distance will gate threshold checks once wired up.
chunk.neighbor_distance(:embedding, @embedding_vector)
rescue StandardError
nil
# NOTE: neighbor gem might not expose distance easily without using its scopes.
# Fallback: Rely on DB order, but checking absolute threshold might be tricky without the value.
# Let's trust Neighbor gem's `nearest_neighbors` if possible, but we used raw SQL order.
# To strictly follow plan: "Re-rank exact cosine distance".
# We can implement a simple ruby cosine distance if vector is loaded,
# or use the SQL value.
# Optimization: Let's assume the SQL order is correct (it is).
# We just need to stop if distance > threshold.
# Since we can't easily get the distance value without select, let's use neighbor gem scope correctly.
end
# Better Implementation using Neighbor Gem capabilities which handles this
# But filtering by priority group AND threshold AND limit is complex chain.
# Let's use Raw SQL for the whole Step 1 + Distance Select
# This is safer.
return [] if candidates.empty?
doc_counts[chunk.document_id] += 1
filtered << chunk
end
# Simplified re-implementation of retrieve + process
def retrieve_candidates(query_embedding, collection_ids)
Jasmine::DocumentChunk
.where(collection_id: collection_ids)
.select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance")
.order('distance ASC')
.limit(CANDIDATES_PER_PRIORITY)
end
# 3. Top K per Priority
filtered.first(TOP_K_PER_PRIORITY)
end
# Overwrite process_candidates with the list from above
def process_candidates(candidates)
filtered = []
doc_counts = Hash.new(0)
def generate_embedding(text)
# Using shared logic or direct call.
# Duplication for now to keep service independent or use embedding service class func
model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
RubyLLM.embed(text, model: model).vectors.first
end
candidates.each do |chunk|
# 1. Threshold Check
# distance is a string/float from SQL
dist = chunk[:distance].to_f
next if dist > @threshold
# 2. Doc Dedupe
limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
doc_counts[chunk.document_id] += 1
filtered << chunk
end
# 3. Top K per Priority
filtered.first(TOP_K_PER_PRIORITY)
end
def generate_embedding(text)
# Using shared logic or direct call.
# Duplication for now to keep service independent or use embedding service class func
model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
RubyLLM.embed(text, model: model).vectors.first
end
def to_pg_vector(vector)
# Ensure vector is an array of floats
# PGVector accepts JSON array string e.g. "[1.0, 2.0]"
vector.to_s
end
def to_pg_vector(vector)
# Ensure vector is an array of floats
# PGVector accepts JSON array string e.g. "[1.0, 2.0]"
vector.to_s
end
end

View File

@ -1,104 +1,107 @@
require 'rest-client'
module Jasmine
class ToolRunner
attr_reader :inbox, :tool_key, :config
class Jasmine::ToolRunner
attr_reader :inbox, :tool_key, :config
def initialize(inbox, tool_key)
@inbox = inbox
@tool_key = tool_key.to_s
@config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key)
end
def initialize(inbox, tool_key)
@inbox = inbox
@tool_key = tool_key.to_s
@config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key)
end
def run
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
raise "Tool not found definition: #{@tool_key}" unless definition
raise "Tool not configured or disabled" unless config&.is_enabled?
def run
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
raise "Tool not found definition: #{@tool_key}" unless definition
raise 'Tool not configured or disabled' unless config&.is_enabled?
start_time = Time.current
start_time = Time.current
begin
response = make_request(definition)
duration = (Time.current - start_time) * 1000
begin
response = make_request(definition)
duration = (Time.current - start_time) * 1000
success = (200..299).include?(response.code)
body = response.body.to_s
success = (200..299).cover?(response.code)
body = response.body.to_s
# Save stats
update_stats(response.code, nil, duration)
# Save stats
update_stats(response.code, nil, duration)
{
success: success,
status: response.code,
body: body.first(2000), # Preview limited
duration_ms: duration.to_i
}
rescue RestClient::ExceptionWithResponse => e
duration = (Time.current - start_time) * 1000
error_msg = e.message
# Try to parse body from error response if available
error_body = e.response&.body rescue nil
error_msg = "#{error_msg} - #{error_body}" if error_body
sanitized_error = sanitize(error_msg)
update_stats(e.http_code, sanitized_error, duration)
{
success: false,
status: e.http_code,
error: sanitized_error,
duration_ms: duration.to_i
}
rescue StandardError => e
duration = (Time.current - start_time) * 1000
sanitized_error = sanitize(e.message)
update_stats(0, sanitized_error, duration)
Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}"
{
success: false,
status: 0,
error: sanitized_error,
duration_ms: duration.to_i
}
end
end
private
def make_request(definition)
url = definition[:url]
method = definition[:method]
headers = {
'PLUG-PLAY-ID' => config.plug_play_id,
'PLUG-PLAY-TOKEN' => config.plug_play_token,
'User-Agent' => 'Chatwoot/Jasmine-Tools'
{
success: success,
status: response.code,
body: body.first(2000), # Preview limited
duration_ms: duration.to_i
}
rescue RestClient::ExceptionWithResponse => e
duration = (Time.current - start_time) * 1000
error_msg = e.message
RestClient::Request.execute(
method: method,
url: url,
headers: headers,
open_timeout: 2,
read_timeout: 8
)
end
# Try to parse body from error response if available
error_body = begin
e.response&.body
rescue StandardError
nil
end
error_msg = "#{error_msg} - #{error_body}" if error_body
def update_stats(status, error, duration)
config.update_columns(
last_tested_at: Time.current,
last_test_status: status,
last_test_error: error,
last_test_duration_ms: duration.to_i
)
end
sanitized_error = sanitize(error_msg)
update_stats(e.http_code, sanitized_error, duration)
def sanitize(text)
return text unless @config&.plug_play_token.present?
text.to_s.gsub(@config.plug_play_token, '****')
{
success: false,
status: e.http_code,
error: sanitized_error,
duration_ms: duration.to_i
}
rescue StandardError => e
duration = (Time.current - start_time) * 1000
sanitized_error = sanitize(e.message)
update_stats(0, sanitized_error, duration)
Rails.logger.error "[Jasmine::ToolRunner] Error running #{@tool_key}: #{sanitized_error}"
{
success: false,
status: 0,
error: sanitized_error,
duration_ms: duration.to_i
}
end
end
private
def make_request(definition)
url = definition[:url]
method = definition[:method]
headers = {
'PLUG-PLAY-ID' => config.plug_play_id,
'PLUG-PLAY-TOKEN' => config.plug_play_token,
'User-Agent' => 'Chatwoot/Jasmine-Tools'
}
RestClient::Request.execute(
method: method,
url: url,
headers: headers,
open_timeout: 2,
read_timeout: 8
)
end
def update_stats(status, error, duration)
config.update_columns(
last_tested_at: Time.current,
last_test_status: status,
last_test_error: error,
last_test_duration_ms: duration.to_i
)
end
def sanitize(text)
return text if @config&.plug_play_token.blank?
text.to_s.gsub(@config.plug_play_token, '****')
end
end

View File

@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'openai'
require 'base64'
class Jasmine::VisionService
attr_reader :attachment
def initialize(attachment:)
@attachment = attachment
end
def perform
return nil unless attachment.image?
api_key = ENV.fetch('OPENAI_API_KEY', nil)
return nil if api_key.blank?
client = OpenAI::Client.new(access_token: api_key)
image_data = get_image_data
return nil if image_data.blank?
response = client.chat(
parameters: {
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'Descreva de forma curta e objetiva o que você vê nesta imagem para um sistema de atendimento.' },
image_data
]
}
],
max_tokens: 300
}
)
response.dig('choices', 0, 'message', 'content')
rescue StandardError => e
Rails.logger.error "[Jasmine::VisionService] Failed to analyze image: #{e.message}"
nil
end
private
def get_image_data
# Always return base64 for better compatibility (OpenAI can reach it even if local)
# and it avoids issues with signed URL expiration or private buckets.
{
type: 'image_url',
image_url: {
url: "data:#{attachment.file.content_type};base64,#{encode_image}"
}
}
rescue StandardError => e
Rails.logger.error "[Jasmine::VisionService] Data encoding failed: #{e.message}"
nil
end
def encode_image
attachment.file.blob.open do |file|
Base64.strict_encode64(file.read)
end
end
end

View File

@ -0,0 +1,5 @@
class Llm::BaseAiService
def initialize
# Base initialization logic if needed
end
end

View File

@ -10,6 +10,7 @@ class Messages::InReplyToMessageBuilder
private
def set_in_reply_to_attribute
@message.in_reply_to = in_reply_to_message
@message.content_attributes[:in_reply_to_external_id] = in_reply_to_message.try(:source_id)
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
end

Some files were not shown because too many files have changed in this diff Show More