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: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
runs-on: ubuntu-latest runs-on: [self-hosted, oracle-arm]
steps: steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Print Deployment URL - name: Print Deployment URL
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com" run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
- name: Check Deployment Status - name: Check Deployment Status
run: | run: |
max_attempts=10 max_attempts=15
attempt=1 attempt=1
status_code=0 status_code=0
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..." echo "Checking review app status..."
sleep 600
while [ $attempt -le $max_attempts ]; do 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) 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) status_code=$(echo $response | head -n 1)
if [ $status_code -eq 200 ]; then if [ $status_code -eq 200 ]; then
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api) 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" echo "Deployment successful"
exit 0 exit 0
else else
echo "Deployment status unknown, retrying in 3 minutes..." echo "Deployment status healthy but services not ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
sleep 180 sleep 30
attempt=$((attempt + 1))
fi fi
else else
echo "Waiting for review app to be ready, retrying in 3 minutes..." echo "Waiting for review app to be ready, retrying in 30 seconds... (Attempt $attempt/$max_attempts)"
sleep 180 sleep 30
attempt=$((attempt + 1)) attempt=$((attempt + 1))
fi fi
done done
echo "Deployment failed after $max_attempts attempts" echo "Deployment check timed out after $max_attempts attempts"
exit 1 exit 1
fi

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

View File

@ -21,68 +21,33 @@ jobs:
- name: Run Rubocop - name: Run Rubocop
run: bundle exec rubocop --parallel 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 with parallelization
backend-tests: backend-tests:
runs-on: ubuntu-latest runs-on: [self-hosted, oracle-arm]
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 8
matrix: matrix:
ci_node_total: [16] ci_node_total: [16]
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
services: env:
postgres: POSTGRES_HOST: localhost
image: pgvector/pgvector:pg16 RAILS_ENV: test
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
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: pnpm/action-setup@v4
with: with:
ref: ${{ github.event.pull_request.head.ref }} ref: ${{ github.event.pull_request.head.ref }}
@ -105,12 +70,32 @@ jobs:
rm -rf enterprise rm -rf enterprise
rm -rf spec/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 - name: Create database
run: bundle exec rake db:create run: bundle exec rake db:create
- name: Seed database - name: Seed database
run: bundle exec rake db:schema:load run: bundle exec rake db:schema:load
- name: Run migrations
run: bundle exec rake db:migrate
- name: Run backend tests (parallelized) - name: Run backend tests (parallelized)
run: | run: |
# Get all spec files and split them using round-robin distribution # Get all spec files and split them using round-robin distribution
@ -144,3 +129,11 @@ jobs:
with: with:
name: rails-log-folder-${{ matrix.ci_node_index }} name: rails-log-folder-${{ matrix.ci_node_index }}
path: log 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 # If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency: concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: 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 # If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency: concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:

11
.gitignore vendored
View File

@ -95,9 +95,20 @@ yarn-debug.log*
.claude/settings.local.json .claude/settings.local.json
.cursor .cursor
CLAUDE.local.md CLAUDE.local.md
.agent/
# Histoire deployment # Histoire deployment
.netlify .netlify
.histoire .histoire
.pnpm-store/* .pnpm-store/*
local/ 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: plugins:
- rubocop-performance - rubocop-performance
- rubocop-rails - rubocop-rails
@ -23,6 +29,7 @@ Metrics/MethodLength:
Max: 19 Max: 19
Exclude: Exclude:
- 'enterprise/lib/captain/agent.rb' - 'enterprise/lib/captain/agent.rb'
- 'enterprise/app/services/captain/llm/system_prompts_service.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 50
@ -230,6 +237,7 @@ AllCops:
- 'tmp/**/*' - 'tmp/**/*'
- 'storage/**/*' - 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb' - 'db/migrate/20230426130150_init_schema.rb'
- 'reference_chatwoot/**/*'
FactoryBot/SyntaxMethods: FactoryBot/SyntaxMethods:
Enabled: false 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 --# ##-- gems for database --#
gem 'groupdate' gem 'groupdate'
gem 'fiddle'
gem 'pg' gem 'pg'
gem 'redis' gem 'redis'
gem 'redis-namespace' gem 'redis-namespace'
@ -271,4 +272,4 @@ group :development, :test do
gem 'spring-watcher-listen' gem 'spring-watcher-listen'
end end
gem "rqrcode", "~> 3.2" gem 'rqrcode', '~> 3.2'

View File

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

View File

@ -36,13 +36,13 @@ run:
@if [ -f ./.overmind.sock ]; then \ @if [ -f ./.overmind.sock ]; then \
echo "Overmind is already running. Use 'make force_run' to start a new instance."; \ echo "Overmind is already running. Use 'make force_run' to start a new instance."; \
else \ else \
overmind start -f Procfile.dev; \ export PATH="$$HOME/.rbenv/bin:$$HOME/.rbenv/shims:$$PATH"; eval "$$(rbenv init -)"; overmind start -f Procfile.dev; \
fi fi
force_run: force_run:
rm -f ./.overmind.sock rm -f ./.overmind.sock
rm -f tmp/pids/*.pid 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: force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true lsof -ti:3000 | xargs kill -9 2>/dev/null || true
@ -59,4 +59,7 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . 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" "plan": "heroku-postgresql:essential-0"
} }
], ],
"stack": "heroku-24",
"buildpacks": [ "buildpacks": [
{ {
"url": "heroku/nodejs" "url": "heroku/nodejs"

View File

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

View File

@ -1,72 +1,64 @@
module Api class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
module Accounts
module Captain
class AssistantsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant, only: [:show, :update, :destroy, :playground, :test_webhook]
def index def index
@assistants = current_account.captain_assistants.order(created_at: :desc) @assistants = current_account.captain_assistants.order(created_at: :desc)
render json: @assistants render json: @assistants
end end
def show def show
render json: @assistant render json: @assistant
end end
def create def create
@assistant = current_account.captain_assistants.new(assistant_params) @assistant = current_account.captain_assistants.new(assistant_params)
if @assistant.save if @assistant.save
render json: @assistant render json: @assistant
else else
render_error_response(@assistant) 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
end end
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

View File

@ -1,19 +1,88 @@
class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::PricingsController < Api::V1::Accounts::BaseController
before_action :fetch_pricings, only: [:index] before_action :fetch_pricings, only: [:index]
before_action :fetch_pricing, only: [:show, :update, :destroy]
def index def index
render json: @pricings render json: @pricings
end 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 private
def fetch_pricings def fetch_pricing
@pricings = current_account.captain_pricings @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 # Fuzzy search using ILIKE for case-insensitive matching
# We wrap the query in % for wildcard matching on both sides @pricings = @pricings.left_outer_joins(:captain_brand).where(
@pricings = @pricings.where('suite_category ILIKE ?', "%#{params[:query]}%") '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
end end

View File

@ -1,71 +1,63 @@
module Api class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_assistant
module Accounts before_action :fetch_scenario, only: [:show, :update, :destroy]
module Captain
class ScenariosController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
before_action :fetch_scenario, only: [:show, :update, :destroy]
def index def index
@scenarios = @assistant.captain_scenarios.order(created_at: :desc) @scenarios = @assistant.captain_scenarios.order(created_at: :desc)
render json: @scenarios render json: @scenarios
end end
def show def show
render json: @scenario render json: @scenario
end end
def create def create
@scenario = @assistant.captain_scenarios.new(scenario_params) @scenario = @assistant.captain_scenarios.new(scenario_params)
@scenario.account = current_account @scenario.account = current_account
if @scenario.save if @scenario.save
render json: @scenario render json: @scenario
else else
render_error_response(@scenario) 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
end end
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

View File

@ -1,73 +1,65 @@
module Api class Api::V1::Accounts::Captain::ToolsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_assistant
module Accounts
module Captain
class ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_assistant
NATIVE_TOOLS = [ NATIVE_TOOLS = [
{ key: 'react_to_message', name: 'React to Message', description: 'Reage a mensagens do usuário com emojis adequados.' }, { 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: '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: '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: '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: '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: '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: '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.' } { key: 'suite_watchdog', name: 'Suite Watchdog', description: 'Monitoramento automático de status de suítes.' }
] ].freeze
def index def index
tools = NATIVE_TOOLS.map do |tool| tools = NATIVE_TOOLS.map do |tool|
config = @assistant.captain_tool_configs.find_by(tool_key: tool[:key]) config = @assistant.captain_tool_configs.find_by(tool_key: tool[:key])
tool.merge( tool.merge(
enabled: config&.is_enabled.nil? || config.is_enabled, enabled: config&.is_enabled.nil? || config.is_enabled,
webhook_url: config&.webhook_url, webhook_url: config&.webhook_url,
plug_play_id: config&.plug_play_id, plug_play_id: config&.plug_play_id,
plug_play_token: config&.plug_play_token, plug_play_token: config&.plug_play_token,
fallback_message: config&.fallback_message fallback_message: config&.fallback_message
) )
end end
render json: tools render json: tools
end end
def update def update
tool_key = params[:id] tool_key = params[:id]
config = @assistant.captain_tool_configs.find_or_initialize_by(tool_key: tool_key) config = @assistant.captain_tool_configs.find_or_initialize_by(tool_key: tool_key)
# Ensure context unique constraint is respected # Ensure context unique constraint is respected
config.account = current_account config.account = current_account
# Map 'enabled' from frontend to 'is_enabled' in DB # Map 'enabled' from frontend to 'is_enabled' in DB
update_params = tool_params update_params = tool_params
update_params[:is_enabled] = update_params.delete(:enabled) if update_params.key?(:enabled) 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 if config.save
render json: config render json: config
else else
render_error_response(config) 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
end end
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

View File

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

View File

@ -1,65 +1,57 @@
module Api class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
module V1 def index
module Accounts @units = Current.account.captain_units
module Captain end
class UnitsController < Api::V1::Accounts::BaseController
def index
@units = Current.account.captain_units
end
def show def show
@unit = Current.account.captain_units.find(params[:id]) @unit = Current.account.captain_units.find(params[:id])
end end
def create def create
@unit = Current.account.captain_units.new(unit_params) @unit = Current.account.captain_units.new(unit_params)
@unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now @unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now
if @unit.save if @unit.save
render 'show', status: :created render 'show', status: :created
else else
render_error_response(@unit) 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
end end
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

View File

@ -19,25 +19,7 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
def serialize_insight(insight) def serialize_insight(insight)
return nil if insight.blank? return nil if insight.blank?
{ 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
def insights_payload def insights_payload
@ -68,4 +50,26 @@ class Api::V1::Accounts::Conversations::CrmInsightsController < Api::V1::Account
meta meta
end 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 end

View File

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

View File

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

View File

@ -1,53 +1,43 @@
module Api class Api::V1::Accounts::Inboxes::Jasmine::ConfigsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts before_action :fetch_or_initialize_config
module Inboxes
module Jasmine
class ConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :fetch_or_initialize_config
def show def show
render json: @config render json: @config
end end
def update def update
if @config.update(config_params) if @config.update(config_params)
render json: @config render json: @config
else else
render json: { error: @config.errors.full_messages.join(', ') }, status: :unprocessable_entity 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
end end
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

View File

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

View File

@ -1,93 +1,90 @@
module Api class Api::V1::Accounts::Inboxes::Jasmine::ToolsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts
module Inboxes
module Jasmine
class ToolsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def index def index
configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key) configs = ::Jasmine::ToolConfig.where(inbox: @inbox).index_by(&:tool_key)
render json: serialize_tools(configs)
end
tools = ::Jasmine::ToolConfig::DEFINITIONS.map do |key, definition| def update
config = configs[key] return render(json: { error: 'Invalid tool key' }, status: :bad_request) unless valid_tool_key?(params[:id])
{
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
render json: tools config = find_config(params[:id])
end update_config_attributes(config)
def update if config.save
tool_key = params[:id] # Using :id from route as tool_key render json: success_payload(config)
else
unless ::Jasmine::ToolConfig::DEFINITIONS.key?(tool_key) render(json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity)
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
end end
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 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 status = status_data['status'] || status_data['state'] || status_data
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}" Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
if %w[CONNECTED inChat success].include?(status) return if already_connected?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
return
end
qr_data = client.get_qr_code(user_token) qr_data = client.get_qr_code(user_token)
Rails.logger.info "Wuzapi QR Data Response keys: #{begin log_qr_data_keys(qr_data)
qr_data.keys
rescue StandardError
'nil'
end}"
render json: qr_data render json: qr_data
rescue Wuzapi::Client::Error => e rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi QR Error: #{e.message}" 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}" Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
raise 'Token Wuzapi ausente; reprovisionar usuário' raise 'Token Wuzapi ausente; reprovisionar usuário'
else 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 end
token token
end 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 end

View File

@ -54,6 +54,6 @@ class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::
Rails.logger.error( Rails.logger.error(
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}" "[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
end end

View File

@ -1,51 +1,42 @@
module Api class Api::V1::Accounts::Jasmine::CollectionsController < Api::V1::Accounts::BaseController
module V1 before_action :find_collection, only: [:destroy]
module Accounts
module Jasmine
class CollectionsController < Api::V1::Accounts::BaseController
before_action :find_collection, only: [:destroy]
def index def index
scope = Current.account.jasmine_collections scope = Current.account.jasmine_collections
scope = scope.where(visibility: params[:visibility]) if params[:visibility] scope = scope.where(visibility: params[:visibility]) if params[:visibility]
render json: scope render json: scope
end end
def create def create
@collection = Current.account.jasmine_collections.new(collection_params) @collection = Current.account.jasmine_collections.new(collection_params)
if @collection.save if @collection.save
# Auto-link to inbox if owner_inbox_id provided # Auto-link to inbox if owner_inbox_id provided
if @collection.owner_inbox_id if @collection.owner_inbox_id
inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id) inbox = Current.account.inboxes.find_by(id: @collection.owner_inbox_id)
inbox&.inbox_collections&.create(collection: @collection, priority: 10) 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
end end
render json: @collection
else
render json: { error: @collection.errors.full_messages.join(', ') }, status: :unprocessable_entity
end end
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 class Api::V1::Accounts::Jasmine::DocumentsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_collection
module Accounts
module Jasmine
class DocumentsController < Api::V1::Accounts::BaseController
before_action :fetch_collection
def index def index
render json: @collection.documents.order(created_at: :desc) render json: @collection.documents.order(created_at: :desc)
end end
def create def create
@document = @collection.documents.new(document_params) @document = @collection.documents.new(document_params)
@document.account = Current.account @document.account = Current.account
if @document.save if @document.save
render json: @document render json: @document
else else
render json: { error: @document.errors.full_messages.join(', ') }, status: :unprocessable_entity 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
end end
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

View File

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

View File

@ -1,36 +1,28 @@
module Api class Api::V1::Accounts::Jasmine::InboxConfigsController < Api::V1::Accounts::BaseController
module V1 before_action :fetch_inbox
module Accounts
module Jasmine
class InboxConfigsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
def show def show
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
render json: config render json: config
end end
def update def update
config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account) config = @inbox.jasmine_inbox_config || @inbox.build_jasmine_inbox_config(account: Current.account)
if config.update(config_params) if config.update(config_params)
render json: config render json: config
else else
render json: { error: config.errors.full_messages.join(', ') }, status: :unprocessable_entity 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
end end
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

View File

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

View File

@ -20,7 +20,12 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end end
def instance_meta 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 end
def chatwoot_version def chatwoot_version
@ -28,7 +33,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
end end
def sha def sha
@metrics['Git SHA'] = GIT_HASH @metrics['Git SHA'] = defined?(GIT_HASH) ? GIT_HASH : 'n/a'
end end
def postgres_status def postgres_status
@ -53,7 +58,8 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
@metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory'] @metrics["Redis 'maxmemory' setting"] = redis_server['maxmemory']
@metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy'] @metrics["Redis 'maxmemory_policy' setting"] = redis_server['maxmemory_policy']
end end
rescue Redis::CannotConnectError rescue StandardError => e
Rails.logger.warn "Redis status check failed: #{e.message}"
@metrics['Redis alive'] = false @metrics['Redis alive'] = false
end end
end end

View File

@ -2,6 +2,10 @@ class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern include MetaTokenVerifyConcern
def process_payload 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? if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity render json: { error: 'Inactive WhatsApp number' }, status: :unprocessable_entity
@ -17,18 +21,158 @@ class Webhooks::WhatsappController < ActionController::API
perform_sync if params[:awaitResponse].present? perform_sync if params[:awaitResponse].present?
return if performed? return if performed?
Webhooks::WhatsappEventsJob.perform_later(params.to_unsafe_hash) Webhooks::WhatsappEventsJob.perform_later(sanitize_payload_for_sidekiq)
head :ok head :ok
end end
def perform_sync def perform_sync
Webhooks::WhatsappEventsJob.perform_now(params.to_unsafe_hash) Webhooks::WhatsappEventsJob.perform_now(sanitize_payload_for_sidekiq)
rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken rescue Whatsapp::IncomingMessageBaileysService::InvalidWebhookVerifyToken
head :unauthorized head :unauthorized
rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError rescue Whatsapp::IncomingMessageBaileysService::MessageNotFoundError
head :not_found head :not_found
end 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) def valid_token?(token)
channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number]) channel = Channel::Whatsapp.find_by(phone_number: params[:phone_number])
whatsapp_webhook_verify_token = channel.provider_config['webhook_verify_token'] if channel.present? 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 skip_before_action :verify_authenticity_token
before_action :fetch_inbox before_action :fetch_inbox
before_action :verify_secret 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) { unlinkCollection(inboxId, collectionId) {
return axios.delete(`${this.url}/${inboxId}/jasmine/collections/${collectionId}`); return axios.delete(
`${this.url}/${inboxId}/jasmine/collections/${collectionId}`
);
} }
// Documents // Documents
getDocuments(collectionId) { getDocuments(collectionId) {
return axios.get(`${this.jasmineUrl}/collections/${collectionId}/documents`); return axios.get(
`${this.jasmineUrl}/collections/${collectionId}/documents`
);
} }
uploadDocument(collectionId, content, title) { uploadDocument(collectionId, content, title) {
return axios.post(`${this.jasmineUrl}/collections/${collectionId}/documents`, { return axios.post(
title, `${this.jasmineUrl}/collections/${collectionId}/documents`,
content, {
}); title,
content,
}
);
} }
deleteDocument(collectionId, documentId) { deleteDocument(collectionId, documentId) {
return axios.delete(`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`); return axios.delete(
`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`
);
} }
// Playground // Playground
@ -85,4 +94,3 @@ class JasmineAPI extends ApiClient {
} }
export default new JasmineAPI(); export default new JasmineAPI();

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { defineProps, computed, reactive } from 'vue'; import { defineProps, computed, reactive, ref } from 'vue';
import Message from './Message.vue'; import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js'; import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys'; import { useCamelCase } from 'dashboard/composables/useTransformKeys';
@ -48,7 +48,9 @@ const allMessages = computed(() => {
const currentChat = useMapGetter('getSelectedChat'); const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls // 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 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 * 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); return fetchedReplyMessages.get(messageId);
} }
// Mark as loading to prevent duplicate fetches
fetchedReplyMessages.set(messageId, 'loading');
try { try {
const response = await MessageApi.getPreviousMessages({ const response = await MessageApi.getPreviousMessages({
conversationId, conversationId,
@ -75,14 +80,17 @@ const fetchReplyMessage = async (messageId, conversationId) => {
if (targetMessage) { if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage); const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage); fetchedReplyMessages.set(messageId, camelCaseMessage);
fetchTrigger.value += 1; // Trigger reactivity
return camelCaseMessage; return camelCaseMessage;
} }
// Cache null result to avoid repeated API calls // Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null); fetchedReplyMessages.set(messageId, null);
fetchTrigger.value += 1; // Trigger reactivity
return null; return null;
} catch (error) { } catch (error) {
fetchedReplyMessages.set(messageId, null); fetchedReplyMessages.set(messageId, null);
fetchTrigger.value += 1; // Trigger reactivity
return null; return null;
} }
}; };
@ -126,9 +134,14 @@ const shouldGroupWithNext = (index, searchList) => {
* @returns {Object|null} - The message being replied to, or null if not found * @returns {Object|null} - The message being replied to, or null if not found
*/ */
const getInReplyToMessage = parentMessage => { 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; if (!parentMessage) return null;
const inReplyToMessageId = const inReplyToMessageId =
parentMessage.inReplyToId ??
parentMessage.contentAttributes?.inReplyTo ?? parentMessage.contentAttributes?.inReplyTo ??
parentMessage.content_attributes?.in_reply_to; 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)) { 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 still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) { if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, 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; return replyMessage ? useCamelCase(replyMessage) : null;

View File

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

View File

@ -102,7 +102,7 @@ describe('useUISettings', () => {
conversation_sidebar_items_order: conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER, DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_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', editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true, channel_email_quoted_reply_enabled: true,
}, },

View File

@ -109,7 +109,8 @@ export function usePolicy() {
if (!flag) return false; if (!flag) return false;
// Bypass paywall for Captain in development // 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) { if (isACustomBrandedInstance.value) {
// custom branded instances never show paywall // custom branded instances never show paywall

View File

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

View File

@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json'; import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json'; import mfa from './mfa.json';
import yearInReview from './yearInReview.json'; import yearInReview from './yearInReview.json';
import jasmine from './jasmine.json';
export default { export default {
...advancedFilters, ...advancedFilters,
@ -84,4 +85,5 @@ export default {
...contentTemplates, ...contentTemplates,
...mfa, ...mfa,
...yearInReview, ...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)" "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": { "CUSTOM_TOOLS": {
"OPTIONS": { "OPTIONS": {
"TEST_TOOL": "Testar Ferramenta", "TEST_TOOL": "Testar Ferramenta",

View File

@ -67,10 +67,13 @@ export default {
this.brand.suiteCategories || this.brand.suite_categories || []; this.brand.suiteCategories || this.brand.suite_categories || [];
const images = const images =
this.brand.suiteImages || this.brand.suite_images || {}; this.brand.suiteImages || this.brand.suite_images || {};
const keywords =
this.brand.suiteKeywords || this.brand.suite_keywords || {};
this.suiteCategories = categories.map(cat => ({ this.suiteCategories = categories.map(cat => ({
name: cat, name: cat,
image: images[cat] || '', image: images[cat] || '',
keywords: keywords[cat] || '',
})); }));
} else { } else {
this.resetForm(); this.resetForm();
@ -85,6 +88,7 @@ export default {
this.stayDurations = ''; this.stayDurations = '';
this.newCategoryName = ''; this.newCategoryName = '';
this.newCategoryImage = ''; this.newCategoryImage = '';
this.newCategoryKeywords = '';
this.v$.$reset(); this.v$.$reset();
}, },
addCategory() { addCategory() {
@ -92,9 +96,11 @@ export default {
this.suiteCategories.push({ this.suiteCategories.push({
name: this.newCategoryName, name: this.newCategoryName,
image: this.newCategoryImage, image: this.newCategoryImage,
keywords: this.newCategoryKeywords,
}); });
this.newCategoryName = ''; this.newCategoryName = '';
this.newCategoryImage = ''; this.newCategoryImage = '';
this.newCategoryKeywords = '';
}, },
removeCategory(index) { removeCategory(index) {
this.suiteCategories.splice(index, 1); this.suiteCategories.splice(index, 1);
@ -108,11 +114,16 @@ export default {
if (curr.image) acc[curr.name] = curr.image; if (curr.image) acc[curr.name] = curr.image;
return acc; return acc;
}, {}); }, {});
const keywords = this.suiteCategories.reduce((acc, curr) => {
if (curr.keywords) acc[curr.name] = curr.keywords;
return acc;
}, {});
const payload = { const payload = {
name: this.name, name: this.name,
suite_categories: categories, suite_categories: categories,
suite_images: images, suite_images: images,
suite_keywords: keywords,
stay_durations: this.stayDurations stay_durations: this.stayDurations
.split(',') .split(',')
.map(s => s.trim()) .map(s => s.trim())
@ -126,21 +137,25 @@ export default {
<template> <template>
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text --> <!-- 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 <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 --> <!-- Header -->
<div <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"> <h2 class="text-lg font-semibold text-slate-800 dark:text-slate-100">
{{ headerTitle }} {{ headerTitle }}
</h2> </h2>
</div> </div>
<!-- Scrollable Body --> <!-- Body -->
<div class="flex-1 overflow-y-auto p-6 max-h-[65vh] flex flex-col gap-5"> <div class="flex-1 p-5 flex flex-col gap-3">
<!-- Brand Name --> <!-- Brand Name -->
<WootInput <WootInput
v-model="name" v-model="name"
@ -151,9 +166,9 @@ export default {
<!-- Suite Categories --> <!-- Suite Categories -->
<div <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 <label
class="block text-sm font-medium text-slate-700 dark:text-slate-200" class="block text-sm font-medium text-slate-700 dark:text-slate-200"
> >
@ -161,45 +176,70 @@ export default {
</label> </label>
</div> </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 <input
v-model="newCategoryName" v-model="newCategoryKeywords"
type="text" type="text"
placeholder="Nome (Ex: Standard)" placeholder="Palavras-chave (Ex: jacuzzi, hidro) - Opcional"
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" 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" @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>
<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 <div
v-for="(cat, idx) in suiteCategories" v-for="(cat, idx) in suiteCategories"
:key="idx" :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"> <div class="flex flex-col flex-1 min-w-0 mr-2">
<span <div class="flex items-center gap-2">
class="font-medium text-sm text-slate-800 dark:text-slate-100" <span
> class="font-medium text-sm text-slate-800 dark:text-slate-100"
{{ cat.name }} >
</span> {{ 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 <span
v-if="cat.image" 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 }} {{ cat.image }}
</span> </span>
</div> </div>
@ -216,7 +256,7 @@ export default {
v-else v-else
class="text-sm text-slate-500 dark:text-slate-400 italic text-center py-2" 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> </p>
</div> </div>
@ -224,7 +264,7 @@ export default {
<WootInput <WootInput
v-model="stayDurations" v-model="stayDurations"
label="Durações Aceitas" label="Durações Aceitas"
placeholder="Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)" placeholder="Ex: 2h, 4h, Pernoite, Diária"
/> />
</div> </div>

View File

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

View File

@ -1,3 +1,4 @@
<!-- eslint-disable vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<script setup> <script setup>
import { ref, watch, computed } from 'vue'; import { ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -58,14 +59,23 @@ const toggleDay = day => {
(a, b) => daysOptions.indexOf(a) - daysOptions.indexOf(b) (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 => { const parseDays = rangeString => {
if (!rangeString) return []; if (!rangeString) return [];
const normalizedRange = rangeString.toUpperCase();
// Handle "SEGUNDA A QUARTA" range format legacy support // Handle "SEGUNDA A QUARTA" range format legacy support
if (rangeString.includes(' A ')) { if (normalizedRange.includes(' A ')) {
const [start, end] = rangeString.split(' A '); const [start, end] = normalizedRange.split(' A ');
const startIndex = daysOptions.indexOf(start); const startIndex = daysOptions.indexOf(start);
const endIndex = daysOptions.indexOf(end); const endIndex = daysOptions.indexOf(end);
if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) { if (startIndex !== -1 && endIndex !== -1 && startIndex <= endIndex) {
@ -73,8 +83,8 @@ const parseDays = rangeString => {
} }
} }
// Handle comma separated // Handle comma separated
return rangeString return normalizedRange
.split(', ') .split(',')
.map(s => s.trim()) .map(s => s.trim())
.filter(s => daysOptions.includes(s)); .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 isEditing = computed(() => !!props.pricing?.id); // Changed to check for pricing.id to correctly identify editing mode
const selectedBrand = computed(() => { 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(() => { const brandCategories = computed(() => {
@ -126,11 +139,23 @@ watch(
let normalizedInboxIds = []; let normalizedInboxIds = [];
if (newVal.inbox_ids?.length) { if (newVal.inbox_ids?.length) {
normalizedInboxIds = newVal.inbox_ids; normalizedInboxIds = newVal.inbox_ids;
} else if (newVal.inbox_id) { } else if (newVal.inbox_id || newVal.inboxId) {
normalizedInboxIds = [newVal.inbox_id]; normalizedInboxIds = [newVal.inbox_id || newVal.inboxId];
} }
formData.value = { ...newVal, inbox_ids: normalizedInboxIds }; formData.value = {
selectedDays.value = parseDays(newVal.day_range || newVal.dayRange); 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; selectedInboxes.value = normalizedInboxIds;
} else { } else {
formData.value = { 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="px-3 py-1.5 text-xs font-medium rounded-full border transition-colors"
:class="[ :class="[
selectedDays.includes(day) 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', : '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)" @click="toggleDay(day)"

View File

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

View File

@ -45,8 +45,8 @@ const getChannelName = channelType => {
> >
<template #header> <template #header>
<BaseSettingsHeader <BaseSettingsHeader
title="Agentes Jasmine AI" :title="$t('JASMINE.HEADER.TITLE')"
description="Gerencie seus agentes de IA SDR. Selecione uma caixa de entrada para configurar sua base de conhecimento." :description="$t('JASMINE.HEADER.DESCRIPTION')"
/> />
</template> </template>
@ -64,14 +64,12 @@ const getChannelName = channelType => {
class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2" class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2"
> >
<span <span
:class="[ class="size-6 text-n-blue-text"
getChannelIcon(inbox.channel_type), :class="[getChannelIcon(inbox.channel_type)]"
'size-6 text-n-blue-text',
]"
/> />
</div> </div>
<span <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" 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" /> <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">{{ <span class="text-base font-semibold text-n-slate-12">{{
inbox.name inbox.name
}}</span> }}</span>
<Button label="Configurar" link @click.stop="openInbox(inbox.id)" /> <Button
:label="$t('JASMINE.INBOX_LIST.CONFIGURE')"
link
@click.stop="openInbox(inbox.id)"
/>
</div> </div>
<!-- Description --> <!-- Description -->
<p class="text-sm text-n-slate-11"> <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> </p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -70,10 +70,10 @@ export default {
<div class="settings-section"> <div class="settings-section">
<div class="flex flex-col gap-1 items-start mb-4"> <div class="flex flex-col gap-1 items-start mb-4">
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100"> <h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
Jasmine AI Configuration {{ $t('JASMINE.CONFIG.TITLE') }}
</h2> </h2>
<p class="text-sm text-slate-600 dark:text-slate-400"> <p class="text-sm text-slate-600 dark:text-slate-400">
Configure the AI agent for this inbox. {{ $t('JASMINE.CONFIG.DESCRIPTION') }}
</p> </p>
</div> </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" 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"> <span class="text-sm font-medium text-slate-700 dark:text-slate-200">
Enable Jasmine AI Agent {{ $t('JASMINE.CONFIG.ENABLE') }}
</span> </span>
</label> </label>
</div> </div>
@ -94,21 +94,21 @@ export default {
<label <label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2" class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
> >
System Prompt {{ $t('JASMINE.CONFIG.SYSTEM_PROMPT') }}
</label> </label>
<textarea <textarea
v-model="systemPrompt" v-model="systemPrompt"
rows="6" 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" 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..." :placeholder="$t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP')"
></textarea> />
<p class="mt-1 text-xs text-slate-500"> <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> </p>
</div> </div>
<woot-button :is-loading="isUpdating" @click="updateSettings"> <woot-button :is-loading="isUpdating" @click="updateSettings">
Update Configuration {{ $t('JASMINE.CONFIG.UPDATE_BUTTON') }}
</woot-button> </woot-button>
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" /> <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}`; 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; if (!accountId.value) return;
try { try {
const response = await window.axios.get(getApiUrl('')); const response = await window.axios.get(getApiUrl(''));
const data = response.data; const data = response.data;
// Wuzapi format: { data: { connected: true, jid: "...", details: "..." } }
const wuzapiData = data.data || {}; const wuzapiData = data.data || {};
const isWuzapiConnected = const isWuzapiConnected =
wuzapiData.connected === true && !!wuzapiData.jid; wuzapiData.connected === true && !!wuzapiData.jid;
// Also keep legacy check just in case payload differs
const legacyStatus = data.status || data.state; const legacyStatus = data.status || data.state;
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes( const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
legacyStatus legacyStatus
@ -64,13 +69,22 @@ export default defineComponent({
statusMessage.value = statusMessage.value =
error.response?.data?.error || error.message || 'Check failed'; 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 { try {
const response = await window.axios.get(getApiUrl('/qr')); 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 d = response.data;
const qrcodeData = const qrcodeData =
d.qrcode || d.qrcode ||
@ -84,7 +98,6 @@ export default defineComponent({
qrCode.value = qrcodeData; qrCode.value = qrcodeData;
startPolling(); startPolling();
} else { } else {
// Fallback: maybe we are already connected?
await fetchStatus(); await fetchStatus();
if (!isConnected.value) { if (!isConnected.value) {
statusMessage.value = 'QR Code not received and not connected.'; statusMessage.value = 'QR Code not received and not connected.';
@ -94,7 +107,7 @@ export default defineComponent({
statusMessage.value = statusMessage.value =
error.response?.data?.error || 'Failed to load QR'; error.response?.data?.error || 'Failed to load QR';
} }
}; }
const handleConnect = async () => { const handleConnect = async () => {
if (!accountId.value) { 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 isLoadingWebhook = ref(false);
const webhookInfo = ref(null); const webhookInfo = ref(null);
@ -173,7 +166,7 @@ export default defineComponent({
const response = await window.axios.put(getApiUrl('/update_webhook')); const response = await window.axios.put(getApiUrl('/update_webhook'));
webhookInfo.value = { webhookInfo.value = {
message: response.data.message, message: response.data.message,
url: response.data.webhook_url url: response.data.webhook_url,
}; };
useAlert('Webhook updated successfully'); useAlert('Webhook updated successfully');
} catch (error) { } catch (error) {
@ -213,8 +206,8 @@ export default defineComponent({
<div class="mx-8 mt-6"> <div class="mx-8 mt-6">
<div class="bg-white p-6 rounded-lg border border-n-weak"> <div class="bg-white p-6 rounded-lg border border-n-weak">
<h3 class="text-lg font-medium text-n-slate-12 mb-4"> <h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }} - {{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG') }} {{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
</h3> </h3>
<div v-if="accountId" class="flex flex-col items-center"> <div v-if="accountId" class="flex flex-col items-center">
@ -264,29 +257,29 @@ export default defineComponent({
</div> </div>
<div class="mt-4 text-xs text-n-slate-10"> <div class="mt-4 text-xs text-n-slate-10">
Status: {{ statusMessage }} {{ $t('JASMINE.WUZAPI.STATUS', { status: statusMessage }) }}
</div> </div>
</div> </div>
</div> </div>
<div v-else class="text-red-600 p-4"> <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>
<div class="mt-8 pt-6 border-t border-n-weak w-full"> <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"> <h4 class="text-md font-medium text-n-slate-12 mb-4">
Webhook Configuration {{ $t('JASMINE.WUZAPI.WEBHOOK_SECTION') }}
</h4> </h4>
<div class="flex gap-4 mb-4"> <div class="flex gap-4 mb-4">
<NextButton <NextButton
icon="i-woot-refresh" icon="i-woot-refresh"
:is-loading="isLoadingWebhook" :is-loading="isLoadingWebhook"
label="Get Webhook Info" :label="$t('JASMINE.WUZAPI.GET_WEBHOOK_INFO')"
@click="fetchWebhookInfo" @click="fetchWebhookInfo"
/> />
<NextButton <NextButton
icon="i-woot-upload" icon="i-woot-upload"
:is-loading="isLoadingWebhook" :is-loading="isLoadingWebhook"
label="Update Webhook Connection" :label="$t('JASMINE.WUZAPI.UPDATE_WEBHOOK')"
@click="updateWebhook" @click="updateWebhook"
/> />
</div> </div>

View File

@ -101,11 +101,13 @@ export default {
} }
}, },
async deleteDocument(collectionId, documentId) { 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; this.isDeletingDocument = documentId;
try { try {
await JasmineAPI.deleteDocument(collectionId, documentId); await JasmineAPI.deleteDocument(collectionId, documentId);
useAlert('Document deleted successfully'); useAlert(this.$t('JASMINE.KNOWLEDGE_BASE.DOCUMENT_DELETE_SUCCESS'));
this.fetchDocuments(collectionId); this.fetchDocuments(collectionId);
} catch (error) { } catch (error) {
useAlert('Failed to delete document'); useAlert('Failed to delete document');
@ -143,14 +145,14 @@ export default {
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<div> <div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100"> <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
Knowledge Base {{ $t('JASMINE.KNOWLEDGE_BASE.TITLE') }}
</h3> </h3>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
Manage knowledge collections for this inbox {{ $t('JASMINE.KNOWLEDGE_BASE.DESCRIPTION') }}
</p> </p>
</div> </div>
<woot-button size="small" @click="showCreateCollectionModal = true"> <woot-button size="small" @click="showCreateCollectionModal = true">
+ New Collection {{ $t('JASMINE.KNOWLEDGE_BASE.ADD_BUTTON') }}
</woot-button> </woot-button>
</div> </div>
@ -173,8 +175,8 @@ export default {
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
class="i-lucide-chevron-right size-4 transition-transform text-slate-400"
:class="[ :class="[
'i-lucide-chevron-right size-4 transition-transform text-slate-400',
expandedCollectionId === collection.id ? 'rotate-90' : '', 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" 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"> <h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Documents {{ $t('JASMINE.KNOWLEDGE_BASE.DOCUMENTS') }}
</h5> </h5>
<!-- Loading Documents --> <!-- Loading Documents -->
@ -206,7 +208,7 @@ export default {
class="flex items-center gap-2 text-sm text-slate-400 py-2" class="flex items-center gap-2 text-sm text-slate-400 py-2"
> >
<span class="i-lucide-loader-2 size-4 animate-spin" /> <span class="i-lucide-loader-2 size-4 animate-spin" />
Loading documents... {{ $t('JASMINE.KNOWLEDGE_BASE.LOADING_DOCS') }}
</div> </div>
<!-- Documents List --> <!-- Documents List -->
@ -224,7 +226,7 @@ export default {
<p <p
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate" 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>
<p class="text-xs text-slate-400 truncate"> <p class="text-xs text-slate-400 truncate">
{{ new Date(doc.created_at).toLocaleDateString() }} {{ new Date(doc.created_at).toLocaleDateString() }}
@ -234,10 +236,8 @@ export default {
<div class="flex items-center gap-3 shrink-0"> <div class="flex items-center gap-3 shrink-0">
<!-- Status Badge --> <!-- Status Badge -->
<span <span
:class="[ class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full', :class="[getStatusClass(doc.status)]"
getStatusClass(doc.status),
]"
> >
<span <span
v-if="isProcessing(doc.status)" v-if="isProcessing(doc.status)"
@ -264,7 +264,7 @@ export default {
v-if="documents.length === 0" v-if="documents.length === 0"
class="text-center py-6 text-sm text-slate-400" 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>
</div> </div>
@ -273,19 +273,21 @@ export default {
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4" 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"> <h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Add New Document {{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_HEADER') }}
</h6> </h6>
<input <input
v-model="newDocTitle" v-model="newDocTitle"
type="text" 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" 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 <textarea
v-model="newDocContent" v-model="newDocContent"
rows="4" 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" 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"> <div class="flex justify-end">
<woot-button <woot-button
@ -294,7 +296,7 @@ export default {
:disabled="!newDocContent.trim()" :disabled="!newDocContent.trim()"
@click="addDocument(collection.id)" @click="addDocument(collection.id)"
> >
Add Document {{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_BUTTON') }}
</woot-button> </woot-button>
</div> </div>
</div> </div>
@ -307,7 +309,7 @@ export default {
class="text-center py-12 text-slate-400" class="text-center py-12 text-slate-400"
> >
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" /> <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>
</div> </div>
@ -319,34 +321,40 @@ export default {
> >
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl"> <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"> <h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
Create Collection {{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.TITLE') }}
</h3> </h3>
<input <input
v-model="newCollectionName" v-model="newCollectionName"
type="text" 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" 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" @keyup.enter="createCollection"
/> />
<select <select
v-model="newCollectionVisibility" 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" 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="private">
<option value="shared">Shared (All inboxes)</option> {{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_PRIVATE') }}
</option>
<option value="shared">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_SHARED') }}
</option>
</select> </select>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<woot-button <woot-button
variant="clear" variant="clear"
@click="showCreateCollectionModal = false" @click="showCreateCollectionModal = false"
> >
Cancel {{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CANCEL') }}
</woot-button> </woot-button>
<woot-button <woot-button
:disabled="!newCollectionName.trim()" :disabled="!newCollectionName.trim()"
@click="createCollection" @click="createCollection"
> >
Create {{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CREATE') }}
</woot-button> </woot-button>
</div> </div>
</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 Conversations::AutoLabelJob < ApplicationJob
class AutoLabelJob < ApplicationJob queue_as :low_priority
queue_as :low_priority
TAXONOMY = { TAXONOMY = {
'duvida_valores' => 'Perguntas sobre preços, cotações, tarifas e valores de diárias.', '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_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_cafe_da_manha' => 'Perguntas específicas sobre itens ou horário do café.',
'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.', 'duvida_evento' => 'Perguntas sobre festas, casamentos, reuniões corporativas.',
'duvida_pet' => 'Perguntas sobre aceitar animais, taxas de pet.', '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.', 'duvida_checkin_checkout' => 'Horários de entrada e saída, early check-in, late check-out.',
'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.', 'reclamacao' => 'Cliente insatisfeito, relatando problema ou erro.',
'cancelamento' => 'Solicitação de cancelamento de reserva.', 'cancelamento' => 'Solicitação de cancelamento de reserva.',
'outros' => 'Assuntos que não se encaixam nas categorias acima.' 'outros' => 'Assuntos que não se encaixam nas categorias acima.'
}.freeze }.freeze
def perform(conversation_id) def perform(conversation_id)
conversation = Conversation.find_by(id: conversation_id) conversation = Conversation.find_by(id: conversation_id)
return unless conversation return unless conversation
return unless conversation.messages.count > 0 return unless conversation.messages.count.positive?
# Evita re-classificar se já tiver alguma label de IA (opcional) # Evita re-classificar se já tiver alguma label de IA (opcional)
# return if (conversation.label_list & TAXONOMY.keys).any? # 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 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 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) def apply_label(conversation, result)
messages_text = prepare_history(conversation) label_name = result['label']
return if messages_text.blank? question_summary = result['question']
result = call_llm_classification(messages_text) unless TAXONOMY.key?(label_name)
return unless result Rails.logger.warn "[AutoLabelJob] LLM returned invalid label: #{label_name}"
return
apply_label(conversation, result)
end end
def prepare_history(conversation) # Garante que a label existe na conta para aparecer nos relatórios
# Pega últimas 20 mensagens para dar contexto suficiente conversation.account.labels.find_or_create_by!(title: label_name) do |l|
conversation.messages.chat.order(created_at: :desc).limit(20).reverse.map do |m| l.description = TAXONOMY[label_name]
sender = m.incoming? ? 'Cliente' : 'Atendente' l.color = '#7C3AED' # Roxo para indicar IA/Automático
"#{sender}: #{m.content}" l.show_on_sidebar = true
end.join("\n")
end end
def call_llm_classification(history) conversation.add_labels([label_name])
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: # Salva a dúvida canônica nos atributos adicionais
#{TAXONOMY.map { |k, v| "- #{k}: #{v}" }.join("\n")} 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: Rails.logger.info "[AutoLabelJob] Applied label #{label_name} and saved reason '#{question_summary}' to conversation #{conversation.id}"
{
"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
end end
end end

View File

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

View File

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

View File

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

View File

@ -37,11 +37,12 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
return unless params[:phone_number] return unless params[:phone_number]
# Try exact match first, then try adding + if missing # 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 return channel if channel
# If params[:phone_number] doesn't have +, try adding it # If phone doesn't have +, try adding it
Channel::Whatsapp.find_by(phone_number: "+#{params[:phone_number]}") Channel::Whatsapp.find_by(phone_number: "+#{phone}")
end end
def find_channel_from_whatsapp_business_payload(params) 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) payload = message.webhook_data.merge(event: __method__.to_s)
deliver_webhook_payloads(payload, inbox) 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 end
def message_updated(event) def message_updated(event)
@ -91,6 +114,19 @@ class WebhookListener < BaseListener
handle_typing_status(__method__.to_s, event) handle_typing_status(__method__.to_s, event)
end 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 private
def handle_typing_status(event_name, event) def handle_typing_status(event_name, event)
@ -110,6 +146,7 @@ class WebhookListener < BaseListener
def deliver_account_webhooks(payload, account) def deliver_account_webhooks(payload, account)
account.webhooks.account_type.each do |webhook| account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event]) 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) WebhookJob.perform_later(webhook.url, payload)
end end

View File

@ -1,7 +1,32 @@
module Captain # == Schema Information
class Pricing < ApplicationRecord #
belongs_to :account # Table name: captain_pricings
belongs_to :captain_brand, optional: true #
belongs_to :inbox, optional: true # id :bigint not null, primary key
end # 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 end

View File

@ -1,36 +1,84 @@
module Captain # == Schema Information
class Reservation < ApplicationRecord #
self.table_name = 'captain_reservations' # 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 :account
belongs_to :inbox belongs_to :inbox
belongs_to :contact belongs_to :contact
belongs_to :contact_inbox belongs_to :contact_inbox
belongs_to :conversation, optional: true belongs_to :conversation, optional: true
belongs_to :captain_brand, optional: true belongs_to :captain_brand, optional: true
belongs_to :captain_unit, optional: true belongs_to :captain_unit, optional: true
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
# Validations # Validations
validates :check_in_at, presence: true validates :check_in_at, presence: true
validates :check_out_at, presence: true validates :check_out_at, presence: true
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
enum status: { enum status: {
scheduled: 0, scheduled: 0,
active: 1, active: 1,
completed: 2, completed: 2,
cancelled: 3, cancelled: 3,
no_show: 4, no_show: 4,
pending_payment: 5, pending_payment: 5,
expired: 6, expired: 6,
payment_confirmed: 7, payment_confirmed: 7,
issues: 8, issues: 8,
awaiting_checkin: 9 awaiting_checkin: 9
} }
scope :active_in_date_range, lambda { |start_date, end_date| scope :active_in_date_range, lambda { |start_date, end_date|
where('check_in_at < ? AND check_out_at > ?', end_date, start_date) where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
} }
end
end end

View File

@ -1,20 +1,58 @@
module Captain # == Schema Information
class Unit < ApplicationRecord #
self.table_name = 'captain_units' # 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 :account
belongs_to :captain_brand belongs_to :captain_brand
belongs_to :inbox, optional: true 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 # Encrypted fields for PlugPlay Integration
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase. # 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. # 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. # 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`. # 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. # Let's check generally used pattern later, but for now defining relations is key.
validates :name, presence: true validates :name, presence: true
end
end end

View File

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

View File

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

View File

@ -77,7 +77,6 @@ class Inbox < ApplicationRecord
has_one :agent_bot, through: :agent_bot_inbox has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook' has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inbox_automations, dependent: :destroy_async, class_name: 'Captain::InboxAutomation'
# Jasmine # Jasmine
has_one :jasmine_inbox_config, class_name: 'Jasmine::InboxConfig', dependent: :destroy 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_... (account_id => accounts.id)
# fk_rails_... (owner_inbox_id => inboxes.id) # fk_rails_... (owner_inbox_id => inboxes.id)
# #
module Jasmine class Jasmine::Collection < ApplicationRecord
class Collection < ApplicationRecord self.table_name = 'jasmine_collections'
self.table_name = 'jasmine_collections'
belongs_to :account belongs_to :account
belongs_to :owner_inbox, class_name: 'Inbox', optional: true 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 :inbox_collections, class_name: 'Jasmine::InboxCollection', dependent: :destroy
has_many :documents, class_name: 'Jasmine::Document', foreign_key: :collection_id, 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 :name, presence: true
validates :account_id, presence: true validates :account_id, presence: true
validate :validate_owner_if_private validate :validate_owner_if_private
private private
def validate_owner_if_private def validate_owner_if_private
if visibility_private? && owner_inbox_id.nil? return unless visibility_private? && owner_inbox_id.nil?
errors.add(:owner_inbox_id, 'must be present for private collections')
end errors.add(:owner_inbox_id, 'must be present for private collections')
end
end end
end end

View File

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

View File

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

View File

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

View File

@ -28,44 +28,42 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (inbox_id => inboxes.id) # fk_rails_... (inbox_id => inboxes.id)
# #
module Jasmine class Jasmine::ToolConfig < ApplicationRecord
class ToolConfig < ApplicationRecord self.table_name = 'jasmine_tool_configs'
self.table_name = 'jasmine_tool_configs'
belongs_to :account belongs_to :account
belongs_to :inbox belongs_to :inbox
# Token encryption using Rails 7 native encryption # Token encryption using Rails 7 native encryption
encrypts :plug_play_token if Chatwoot.encryption_configured? encrypts :plug_play_token if Chatwoot.encryption_configured?
validates :tool_key, presence: true validates :tool_key, presence: true
validates :plug_play_id, presence: true, if: :is_enabled? validates :plug_play_id, presence: true, if: :is_enabled?
validates :plug_play_token, presence: true, if: :is_enabled? validates :plug_play_token, presence: true, if: :is_enabled?
# Fixed Tool Definitions # Fixed Tool Definitions
DEFINITIONS = { DEFINITIONS = {
'status_suites' => { 'status_suites' => {
name: 'Status das Suítes', name: 'Status das Suítes',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus', url: 'https://oxpi.com.br/api/PlugPlay/api/SuitesStatus',
description: 'Verifica o status atual das suítes.' description: 'Verifica o status atual das suítes.'
}, },
'listar_reservas' => { 'listar_reservas' => {
name: 'Listar Reservas', name: 'Listar Reservas',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1', url: 'https://oxpi.com.br/api/PlugPlay/api/Reserva?exibicao=0&pagina=1',
description: 'Lista as reservas ativas.' description: 'Lista as reservas ativas.'
}, },
'categoria_disponibilidade' => { 'categoria_disponibilidade' => {
name: 'Disponibilidade por Categoria', name: 'Disponibilidade por Categoria',
method: :get, method: :get,
url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade', url: 'https://oxpi.com.br/api/PlugPlay/api/CategoriaDisponibilidade',
description: 'Verifica disponibilidade de categorias.' description: 'Verifica disponibilidade de categorias.'
} }
}.freeze }.freeze
def self.definitions def self.definitions
DEFINITIONS DEFINITIONS
end
end end
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_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 # [: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 # [: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, :external_created_at, :story_sender, :story_id, :external_error,
:translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON :translations, :in_reply_to_external_id, :is_unsupported, :data], coder: JSON
@ -127,6 +127,7 @@ class Message < ApplicationRecord
belongs_to :inbox belongs_to :inbox
belongs_to :conversation, touch: true belongs_to :conversation, touch: true
belongs_to :sender, polymorphic: true, optional: 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_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
has_one :csat_survey_response, dependent: :destroy_async has_one :csat_survey_response, dependent: :destroy_async
@ -257,16 +258,30 @@ class Message < ApplicationRecord
# Returns message content suitable for LLM consumption # Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil # Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm def content_for_llm
return content if content.present? parts = []
parts << content if content.present?
audio_transcription = attachments # Audio handling
.where(file_type: :audio) audio_transcriptions = attachments
.filter_map { |att| att.meta&.dig('transcribed_text') } .where(file_type: :audio)
.join(' ') .filter_map { |att| att.meta&.dig('transcribed_text') }
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
'[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 end
private private

View File

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

View File

@ -88,7 +88,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :account_users accepts_nested_attributes_for :account_users
has_many :assigned_conversations, foreign_key: 'assignee_id', class_name: 'Conversation', dependent: :nullify, inverse_of: :assignee 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 :csat_survey_responses, foreign_key: 'assigned_agent_id', dependent: :nullify, inverse_of: :assigned_agent
has_many :conversation_participants, dependent: :destroy_async has_many :conversation_participants, dependent: :destroy_async
has_many :participating_conversations, through: :conversation_participants, source: :conversation 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 } enum webhook_type: { account_type: 0, inbox_type: 1 }
ALLOWED_WEBHOOK_EVENTS = %w[conversation_status_changed conversation_updated conversation_created contact_created contact_updated 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 conversation_typing_on conversation_typing_off].freeze
private 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 'openssl'
require 'base64' require 'base64'
module Captain class Captain::InterService
class InterService # Constants for API URLs
# Constants for API URLs AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze
AUTH_URL = 'https://cdpj.partners.bancointer.com.br/oauth/v2/token'.freeze PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
PIX_URL = 'https://cdpj.partners.bancointer.com.br/pix/v2/cob'.freeze
# initialize accepts credentials dynamically # initialize accepts credentials dynamically
def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil) def initialize(client_id:, client_secret:, cert_path:, key_path:, pix_key: nil, account_number: nil)
@client_id = client_id @client_id = client_id
@client_secret = client_secret @client_secret = client_secret
# If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage. # If paths are URLs or relative, handle them. Assuming absolute paths for now as per previous ENV usage.
@cert_path = cert_path @cert_path = cert_path
@key_path = key_path @key_path = key_path
@pix_key = pix_key @pix_key = pix_key
@account_number = account_number @account_number = account_number
end end
def create_pix_charge(reservation) def create_pix_charge(reservation)
token = get_token token = get_token
return { success: false, error: 'Failed to authenticate with Inter' } unless token return { success: false, error: 'Failed to authenticate with Inter' } unless token
payload = { payload = {
calendario: { calendario: {
expiracao: 3600 # 1 hour expiracao: 3600 # 1 hour
}, },
devedor: { devedor: {
cpf: reservation[:cpf].gsub(/\D/, ''), cpf: reservation[:cpf].gsub(/\D/, ''),
nome: reservation[:contact_name] nome: reservation[:contact_name]
}, },
valor: { valor: {
original: format('%.2f', reservation[:total_amount].to_f / 2.0) original: format('%.2f', reservation[:total_amount].to_f / 2.0)
}, },
chave: @pix_key, chave: @pix_key,
solicitacaoPagador: "Reserva #{reservation[:id]}" 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
} }
else
response = request(:post, PIX_URL, payload, token) Rails.logger.error "Inter PIX Error: #{response.body}"
{ success: false, error: response.body }
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 }
end end
rescue StandardError => e
Rails.logger.error "Inter Service Error: #{e.message}"
{ success: false, error: e.message }
end
private private
def get_token def get_token
uri = URI(AUTH_URL) uri = URI(AUTH_URL)
request = Net::HTTP::Post.new(uri) request = Net::HTTP::Post.new(uri)
request.set_form_data( request.set_form_data(
'client_id' => @client_id, 'client_id' => @client_id,
'client_secret' => @client_secret, 'client_secret' => @client_secret,
'grant_type' => 'client_credentials', 'grant_type' => 'client_credentials',
'scope' => 'cob.write cob.read webhook.write webhook.read extrato.read' '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 if response.code.to_i == 200
JSON.parse(response.body)['access_token'] JSON.parse(response.body)['access_token']
else else
Rails.logger.error "Inter Auth Error: #{response.body}" Rails.logger.error "Inter Auth Error: #{response.body}"
nil 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
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 end

View File

@ -1,179 +1,171 @@
module Captain class Captain::Reservations::SyncService
module Reservations PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'.freeze
class SyncService
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
def initialize(unit) def initialize(unit)
@unit = unit @unit = unit
@account = unit.account @account = unit.account
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback @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 end
def perform page += 1
return unless @unit.reservations_sync_enabled? # Safety break to avoid infinite loops in case of API issues
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present? break if page > 50
end
page = 1 @unit.update(last_synced_at: Time.current)
loop do end
reservations_data = fetch_page(page)
break if reservations_data.empty?
reservations_data.each do |reservation_data| private
process_reservation(reservation_data)
end
page += 1 def fetch_page(page)
# Safety break to avoid infinite loops in case of API issues url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
break if page > 50 response = HTTParty.get(url, headers: headers)
end
@unit.update(last_synced_at: Time.current) if response.success?
end begin
JSON.parse(response.body)
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)
rescue StandardError rescue StandardError
nil []
end end
else
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
[]
end
end
def map_status(data) def headers
# MVP Logic based on dates and 'cancelada' {
return :cancelled if data['cancelada'] == true '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']) def process_reservation(data)
check_out = parse_date(data['saidaPrevistaOuNegociada']) external_id = data['id']
now = Time.current 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 # Resolve Contact
:scheduled # Or 'awaiting_checkin' if we want to be more specific, but MVP 'scheduled' is usually 'Entrada' contact = find_or_create_contact(data)
elsif now >= check_in && now < check_out
:active # 'Hospedada' # Map Attributes
elsif now >= check_out reservation.account = @account
:completed # 'Saída' / checkout done reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
elsif now < check_in reservation.contact = contact
:scheduled reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
else
:scheduled # Default # If contact_inbox missing (new contact created without association to this inbox), create it
end if reservation.contact_inbox.nil?
end 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 end
end end

View File

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

View File

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

View File

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

View File

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

View File

@ -1,274 +1,284 @@
module Jasmine class Jasmine::BrainService
class BrainService # Default intent keywords for hotel/motel business
# Default intent keywords for hotel/motel business DEFAULT_INTENT_KEYWORDS = {
DEFAULT_INTENT_KEYWORDS = { price_question: %w[preço valor quanto custa fica tabela promoção pernoite diária],
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],
info_request: %w[como funciona detalhe explica suíte quarto tipo], policy: %w[horário check-in check-out política regra pagamento cancelamento],
policy: %w[horário check-in check-out política regra pagamento cancelamento], greeting: %w[oi olá bom dia boa tarde boa noite],
greeting: %w[oi olá bom dia boa tarde boa noite], objection: %w[caro não sei preciso pensar depois conversar],
objection: %w[caro não sei preciso pensar depois conversar], closing: %w[reservar agendar confirmar fechar quero sim pode],
closing: %w[reservar agendar confirmar fechar quero sim pode], general: [] # fallback
general: [] # fallback }.freeze
}.freeze
# Strategies for handling intents # Strategies for handling intents
RAG_MANDATORY = %i[price_question info_request policy].freeze RAG_MANDATORY = %i[price_question info_request policy].freeze
RAG_OPTIONAL = %i[objection general].freeze RAG_OPTIONAL = %i[objection general].freeze
RAG_PROHIBITED = %i[greeting closing].freeze RAG_PROHIBITED = %i[greeting closing].freeze
attr_reader :inbox, :conversation, :message, :config attr_reader :inbox, :conversation, :message, :config
def initialize(inbox:, conversation:, message:) def initialize(inbox:, conversation:, message:)
@inbox = inbox @inbox = inbox
@conversation = conversation @conversation = conversation
@message = message @message = message
@config = load_config @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 end
def respond def detect
intent = IntentDetector.new(message.content, intent_keywords).detect # Check each intent type for keyword matches
strategy = StrategyDecider.new(intent, jasmine_state).decide 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, # COMPONENT: Strategy Decider
state: jasmine_state, # =========================================
history: recent_history, class StrategyDecider
rag_context: rag_context, attr_reader :intent, :state
current_message: message.content
).assemble
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 # COMPONENT: Prompt Assembler
Rails.logger.error "[Jasmine::Brain] Error: #{e.message}" # =========================================
'Desculpe, tive um problema. Pode repetir?' 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 end
private private
def load_config def format_state(state)
inbox.jasmine_inbox_config || Jasmine::InboxConfig.new( lines = []
rag_distance_threshold: ENV.fetch('DEFAULT_JASMINE_DISTANCE_THRESHOLD', 0.35).to_f, lines << "Etapa: #{state['stage']}" if state['stage']
rag_max_results: 3, lines << "Qualificado: #{state['qualified'] ? 'Sim' : 'Não'}" if state.key?('qualified')
model: ENV.fetch('JASMINE_LLM_MODEL', 'gpt-4o-mini'),
temperature: 0.7 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 end
def intent_keywords def self.cleanup(conversation)
custom = config.intent_keywords.presence || {} attrs = conversation.custom_attributes || {}
DEFAULT_INTENT_KEYWORDS.merge(custom.deep_symbolize_keys) attrs.delete('jasmine_state')
end conversation.update!(custom_attributes: attrs)
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
end end
end end
end end

View File

@ -1,129 +1,126 @@
module Jasmine class Jasmine::EmbeddingService
class EmbeddingService CHUNK_SIZE = 1000
CHUNK_SIZE = 1000 CHUNK_OVERLAP = 200
CHUNK_OVERLAP = 200 EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
EMBEDDING_DIMENSIONS = 1536 # OpenAI text-embedding-3-small dimensions
def initialize(document) def initialize(document)
@document = 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 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 private
@document.with_lock do
return if @document.indexed?
@document.update!(status: :processing) def chunk_content(content)
@document.chunks.delete_all chunks = []
return chunks if content.blank?
chunks = chunk_content(@document.content) start_index = 0
create_chunks(chunks) chunk_index = 0
@document.update!(status: :indexed) while start_index < content.length
end end_index = [start_index + CHUNK_SIZE, content.length].min
rescue StandardError => e
@document.update!(status: :failed, error_message: e.message)
Rails.logger.error "Embedding failed for Doc ID #{@document.id}: #{e.message}"
end
private if end_index < content.length
last_space = content[start_index...end_index].rindex(' ')
def chunk_content(content) end_index = start_index + last_space if last_space
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)
end end
chunks chunk_text = content[start_index...end_index].strip
end
def create_chunks(chunks) if chunk_text.present?
return if chunks.empty? chunks << {
content: chunk_text,
chunks.each do |chunk_data| index: chunk_index,
embedding = generate_embedding(chunk_data[:content]) char_start: start_index,
char_end: end_index
Jasmine::DocumentChunk.create!( }
account: @document.account, chunk_index += 1
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
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 end
def generate_embedding(text) chunks
if openai_configured? end
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? def create_chunks(chunks)
ENV['OPENAI_API_KEY'].present? return if chunks.empty?
end
def generate_openai_embedding(text) chunks.each do |chunk_data|
response = RubyLLM.embed(text, model: embedding_model) embedding = generate_embedding(chunk_data[:content])
response.vectors.first
end
def generate_fallback_embedding(text) Jasmine::DocumentChunk.create!(
# Generate a deterministic pseudo-random vector based on text content account: @document.account,
# Uses SHA256 hash to seed random number generator for reproducibility collection: @document.collection,
require 'digest' document: @document,
content: chunk_data[:content],
seed = Digest::SHA256.hexdigest(text.downcase.gsub(/\s+/, ' ').strip).to_i(16) % (2**32) metadata: {
rng = Random.new(seed) chunk_index: chunk_data[:index],
char_start: chunk_data[:char_start],
# Generate normalized vector with EMBEDDING_DIMENSIONS dimensions char_end: chunk_data[:char_end],
vector = Array.new(EMBEDDING_DIMENSIONS) { rng.rand(-1.0..1.0) } model: embedding_model
},
# Normalize to unit length embedding: embedding
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
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 Jasmine::SemanticSearchService
class SemanticSearchService CANDIDATES_PER_PRIORITY = 50
CANDIDATES_PER_PRIORITY = 50 TOP_K_PER_PRIORITY = 10
TOP_K_PER_PRIORITY = 10 MAX_CHUNKS_PER_DOC = 2
MAX_CHUNKS_PER_DOC = 2
def initialize(inbox) def initialize(inbox)
@inbox = inbox @inbox = inbox
@account_id = inbox.account_id @account_id = inbox.account_id
@threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f @threshold = ENV.fetch('JASMINE_DISTANCE_THRESHOLD', '0.35').to_f
end end
def search(query, limit: 10) def search(query, limit: 10)
# 1. Get enabled collections sorted by priority DESC # 1. Get enabled collections sorted by priority DESC
enabled_links = @inbox.inbox_collections enabled_links = @inbox.inbox_collections
.where(is_enabled: true) .where(is_enabled: true)
.order(priority: :desc) .order(priority: :desc)
.includes(:collection) .includes(:collection)
return [] if enabled_links.empty? return [] if enabled_links.empty?
# Group by exact priority # Group by exact priority
priority_groups = enabled_links.group_by(&:priority) priority_groups = enabled_links.group_by(&:priority)
# Prepare query embedding # Prepare query embedding
query_embedding = generate_embedding(query) query_embedding = generate_embedding(query)
final_results = [] final_results = []
processed_chunk_ids = Set.new processed_chunk_ids = Set.new
# 2. Iterate Priority Groups (Waterfall) # 2. Iterate Priority Groups (Waterfall)
priority_groups.keys.sort.reverse_each do |priority| priority_groups.keys.sort.reverse_each do |priority|
collection_ids = priority_groups[priority].map(&:collection_id) collection_ids = priority_groups[priority].map(&:collection_id)
# Step 1: ANN/HNSW Candidate Retrieval # Step 1: ANN/HNSW Candidate Retrieval
# Find candidates across all collections in this priority group # Find candidates across all collections in this priority group
# Using raw SQL for precise control over pgvector operator # Using raw SQL for precise control over pgvector operator
candidates = retrieve_candidates(query_embedding, collection_ids) candidates = retrieve_candidates(query_embedding, collection_ids)
# Step 2: Rerank, Filter (Threshold), and Dedupe # Step 2: Rerank, Filter (Threshold), and Dedupe
group_results = process_candidates(candidates) group_results = process_candidates(candidates)
# Waterfall Logic # Waterfall Logic
group_results.each do |result| group_results.each do |result|
next if processed_chunk_ids.include?(result.id) next if processed_chunk_ids.include?(result.id)
final_results << result final_results << result
processed_chunk_ids.add(result.id) processed_chunk_ids.add(result.id)
break if final_results.size >= limit
end
break if final_results.size >= limit break if final_results.size >= limit
end end
final_results break if final_results.size >= limit
end end
private final_results
end
def retrieve_candidates(query_embedding, collection_ids) private
# Step 1: Broad search for candidates using HNSW index
# We order by cosine distance (<=>) def retrieve_candidates(query_embedding, collection_ids)
Jasmine::DocumentChunk # Step 1: Broad search for candidates using HNSW index
.where(collection_id: collection_ids) # We order by cosine distance (<=>)
.order(Arel.sql("embedding <=> '#{query_embedding}'")) Jasmine::DocumentChunk
.limit(CANDIDATES_PER_PRIORITY) .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 end
def process_candidates(candidates) # Better Implementation using Neighbor Gem capabilities which handles this
# Step 2: Deterministic Reranking and Filtering # But filtering by priority group AND threshold AND limit is complex chain.
# 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. # Let's use Raw SQL for the whole Step 1 + Distance Select
# Enhanced query with distance # This is safer.
candidates.select(
"jasmine_document_chunks.*, (embedding <=> '#{to_pg_vector(candidates.first&.embedding || [])}') as distance"
)
# Filter by Threshold return [] if candidates.empty?
# We need to re-query or calculate. end
# Let's refine retrieve_candidates to include distance.
# Since we are iterating logic here, let's assume retrieve_candidates returns ActiveRecord::Relation. # Simplified re-implementation of retrieve + process
# We'll map them to objects and filters. 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. # Overwrite process_candidates with the list from above
Hash.new(0) def process_candidates(candidates)
filtered = []
doc_counts = Hash.new(0)
# Calculate distances locally or re-fetch. candidates.each do |chunk|
# Since we ordered by distance in DB, we rely on that order. # 1. Threshold Check
# But we need the value for threshold. # distance is a string/float from SQL
dist = chunk[:distance].to_f
next if dist > @threshold
# Let's fix retrieve_candidates to return distance # 2. Doc Dedupe
# Re-doing retrieval with select limit = MAX_CHUNKS_PER_DOC
next if doc_counts[chunk.document_id] >= limit
# Correct approach: doc_counts[chunk.document_id] += 1
# Iterate, check threshold, check Max Chunks per Doc filtered << chunk
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?
end end
# Simplified re-implementation of retrieve + process # 3. Top K per Priority
def retrieve_candidates(query_embedding, collection_ids) filtered.first(TOP_K_PER_PRIORITY)
Jasmine::DocumentChunk end
.where(collection_id: collection_ids)
.select("jasmine_document_chunks.*, (embedding <=> '#{query_embedding}') as distance")
.order('distance ASC')
.limit(CANDIDATES_PER_PRIORITY)
end
# Overwrite process_candidates with the list from above def generate_embedding(text)
def process_candidates(candidates) # Using shared logic or direct call.
filtered = [] # Duplication for now to keep service independent or use embedding service class func
doc_counts = Hash.new(0) model = ENV.fetch('JASMINE_EMBEDDING_MODEL', 'text-embedding-3-small')
RubyLLM.embed(text, model: model).vectors.first
end
candidates.each do |chunk| def to_pg_vector(vector)
# 1. Threshold Check # Ensure vector is an array of floats
# distance is a string/float from SQL # PGVector accepts JSON array string e.g. "[1.0, 2.0]"
dist = chunk[:distance].to_f vector.to_s
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
end end
end end

View File

@ -1,104 +1,107 @@
require 'rest-client' require 'rest-client'
module Jasmine class Jasmine::ToolRunner
class ToolRunner attr_reader :inbox, :tool_key, :config
attr_reader :inbox, :tool_key, :config
def initialize(inbox, tool_key) def initialize(inbox, tool_key)
@inbox = inbox @inbox = inbox
@tool_key = tool_key.to_s @tool_key = tool_key.to_s
@config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key) @config = Jasmine::ToolConfig.find_by(inbox: inbox, tool_key: @tool_key)
end end
def run def run
definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key] definition = Jasmine::ToolConfig::DEFINITIONS[@tool_key]
raise "Tool not found definition: #{@tool_key}" unless definition raise "Tool not found definition: #{@tool_key}" unless definition
raise "Tool not configured or disabled" unless config&.is_enabled? raise 'Tool not configured or disabled' unless config&.is_enabled?
start_time = Time.current start_time = Time.current
begin begin
response = make_request(definition) response = make_request(definition)
duration = (Time.current - start_time) * 1000 duration = (Time.current - start_time) * 1000
success = (200..299).include?(response.code) success = (200..299).cover?(response.code)
body = response.body.to_s body = response.body.to_s
# Save stats # Save stats
update_stats(response.code, nil, duration) update_stats(response.code, nil, duration)
{ {
success: success, success: success,
status: response.code, status: response.code,
body: body.first(2000), # Preview limited body: body.first(2000), # Preview limited
duration_ms: duration.to_i 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'
} }
rescue RestClient::ExceptionWithResponse => e
duration = (Time.current - start_time) * 1000
error_msg = e.message
RestClient::Request.execute( # Try to parse body from error response if available
method: method, error_body = begin
url: url, e.response&.body
headers: headers, rescue StandardError
open_timeout: 2, nil
read_timeout: 8 end
) error_msg = "#{error_msg} - #{error_body}" if error_body
end
def update_stats(status, error, duration) sanitized_error = sanitize(error_msg)
config.update_columns( update_stats(e.http_code, sanitized_error, duration)
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 unless @config&.plug_play_token.present? success: false,
text.to_s.gsub(@config.plug_play_token, '****') 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
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 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 private
def set_in_reply_to_attribute 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_external_id] = in_reply_to_message.try(:source_id)
@message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id) @message.content_attributes[:in_reply_to] = in_reply_to_message.try(:id)
end end

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