Compare commits

..

278 Commits

Author SHA1 Message Date
Rodribm10
fc0105785b
feat: allow FAQ management via knowledge-base role
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Allow Captain FAQ management through the existing knowledge_base_manage custom role while keeping plain agents read-only for FAQ actions.
2026-06-10 13:45:20 -03:00
Rodribm10
cbbfccaf42 fix(captain): resolve Hermes quoted replies by internal id
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-05-24 17:22:05 -03:00
Rodribm10
572b9ccd10 fix(captain): send WhatsApp reply context to Hermes 2026-05-24 16:51:28 -03:00
Rodribm10
358114d04d fix(captain): respect report date filters 2026-05-17 14:00:22 -03:00
Rodribm10
e94cadbdf6 feat(captain): pix_mode manual_static pra Padova e Express
Adiciona caminho paralelo de PIX manual estático pra unidades sem
integração Inter (Padova, Express AL). Mudança 100% aditiva — todas as
outras unidades continuam no fluxo Inter inalterado (default
pix_mode=inter_dynamic aplicado pela migration).

Backend (sem SOUL/SKILL ainda — Frente 7 vem depois):
- Migration concurrent: pix_mode + 4 campos manual_pix_* em captain_units;
  provider + manual_proof_payload + manual_review_reason em captain_pix_charges
- Captain::Unit: enum pix_mode (prefix), validação condicional manual_*
- Captain::PixCharge: status estendido (awaiting_proof, pending_review),
  scope manual/inter, nota interna ramificada por modo
- GeneratePixTool MCP: branch manual_static (chave fixa, mensagem direta
  sem QR/Inter), preserva fluxo Inter intacto
- 4 tools MCP novas: verificar_comprovante_pix (vision gpt-5.3-codex),
  criar_nota_interna (genérica), confirmar_reserva_pix_manual (wrapper
  do ConfirmationService), marcar_reserva_pendente
- ConfirmationService: source_label cobre 'manual_pix_proof'

Próximos passos manuais (não inclusos neste commit):
1. Rodar migration em prod (entrypoint não roda no boot)
2. Seed Padova/Express com pix_mode=manual_static + chaves Stone
3. Deploy nova imagem via docker service update
4. Editar SOUL/SKILL Padova/Express na VPS Hermes + kill+boot

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:01:01 -03:00
Rodribm10
c7d5bbff99 merge: align main with hermes production branch 2026-05-05 17:39:56 -03:00
Rodribm10
4f488ca842 fix(inbox): remove captain dependencies before delete
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-05-05 17:09:08 -03:00
Rodribm10
f6a96f9d4e fix(inbox): remove captain dependencies before delete 2026-05-05 16:41:09 -03:00
Codex CLI
f50c6addc6 fix(captain): allow agents to access gallery settings 2026-05-05 14:54:18 +00:00
Codex CLI
1cbc9f1123 fix(captain): keep faq policy patch scoped 2026-05-04 13:21:38 +00:00
Codex CLI
689cc114f8 fix(captain): allow agents to manage FAQs 2026-05-04 13:16:47 +00:00
Codex CLI
d670c5644b fix(captain/reservations): prioritize confirmed bookings 2026-05-03 11:03:38 +00:00
Rodribm10
c954f0fab4 feat(profile): UI pra ligar/desligar alerta de conversa parada
Nova seção em Configurações → Perfil "Alerta de conversa parada" com:
- Checkbox principal "Ativar alerta de conversa parada" (OFF salva
  ui_settings.aggressive_alert_inbox_ids = []).
- Sub-checkbox "Aplicar em todas as caixas" (ON salva null = todas).
- Lista de inboxes (visível quando não é "todas") pra selecionar caso
  por caso (salva [id, id, ...]).
- Persiste a cada change via updateUISettings (sem botão "salvar").

Antes só dava pra mexer via Rails runner. Cada admin agora controla
sozinho sem mexer em DB.

i18n: PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.* em pt_BR e en.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:04:14 -03:00
Rodribm10
a14fd4ed83 fix(banner): clique no nome navega pra conversa (snake_case param)
O openConversation passava `conversationId` (camelCase) como param do
$router.push, mas a rota `inbox_conversation` declara `:conversation_id`
(snake_case). Vue Router ignorava o param e route.params.conversation_id
ficava undefined → tela "selecione uma conversa no painel".

Fix: passar `conversation_id` snake_case (mesmo padrão dos demais
callsites: SLAReportItem, captain/responses/Pending).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 21:56:08 -03:00
Codex CLI
f4255cff97 fix(captain/hermes): improve deterministic auto reactions 2026-05-03 00:21:45 +00:00
Rodribm10
069e464ee4 feat(construtor): SKILL.md inclui regras de parsing de payload (Valentina-style)
A skill da Valentina (referência, 353 linhas) tem regras explícitas que
faltavam nas Hermes geradas pelo Construtor (107 linhas). Sem elas, o
LLM mistura turns no mesmo payload, repete info já dada, ou alucina em
texto de outro suporte.

save_agent_spec_tool.build_skill_md agora gera bloco final com:
- "Última fala manda" (responder só a mais recente quando vier rajada)
- "Burst repetido" (mesma fala 2x → 1 resposta)
- "Texto de outro fluxo" (operadora/menu externo → frase canônica)
- "Mensagem vazia" (frase canônica)
- "Loop" (mesma entrada → mesma saída, sem variação)
- "Nunca vazar bastidor" (sem [ctx]/CONTEXT COMPACTION/meta-texto)

Aplicado também manualmente nas SKILL.md das 3 Hermes existentes
(Nina/Lara/Juliana) que tinham a versão curta. Valentina já tinha a
versão completa na skill dela.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:43:21 -03:00
Rodribm10
b561aa8451 revert(hermes): remove camadas 2/3/4 + reabilita memória
A causa raiz dos bugs de "info repetida em turns anteriores" era o
default_scope ASC do Message conflitando com .order(desc) no debounce
(ver commit f1d3a124d). Como já corrigi com .reorder, as Camadas 2, 3 e
4 viraram peso morto que adicionava latência/false positive sem ganho.

Removido:
- Camada 2 (factual sem tool → retrigger force_factual_tool)
- Camada 3 (strip de linhas repetidas com pool de outgoings anteriores)
- Camada 4 (topic gating: bloqueio quando resposta tem tópico não pedido)
- Tracker de tool calls em McpController (suportava Camada 2)
- Snapshot baseline em OutgoingJob (suportava Camada 2)
- Regra "🚨 NÃO CONFIE NA SUA MEMÓRIA" das 4 SOUL.md Hermes

Mantido:
- Camada 1: handoff intencional ("Um momento — vou verificar") +
  loop detection (Jaccard >= 0.50 ou pergunta reformulada com 3+
  keywords). Genuíno pra bot externo (Claro/Vivo) e loops óbvios.
- Label-guard em OutgoingJob (não dispatch se conv tem triagem_humana).
- Auto-react ambient (feature original).
- Reorder fix no combined_incoming_content (causa raiz).

Memory + user_profile reabilitados nos 4 Hermes (config.yaml) e no
template do hermes-provision pra futuros agentes. Sem memória, cliente
precisa repetir nome/CPF/contexto a cada turn — UX horrível.
Contaminação cross-unit que justificava desligar vinha de outro bug
(X-Captain-Assistant-Id apontando pro parent), já corrigido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:39:43 -03:00
Rodribm10
f1d3a124d5 fix(hermes): debounce agrupava msgs de turns ANTIGOS (default_scope ASC)
Bug raiz que causou todos os problemas de "Hermes repetindo info de
turns anteriores" hoje:

Message tem default_scope order(created_at: :asc). O combined_incoming_
content fazia .order(created_at: :desc) que GERA SQL com 2 orderings
em conflito ("ORDER BY created_at ASC, created_at DESC") — no Postgres
o ASC ganha quando a primeira coluna é igual. Resultado:
last_real_outgoing virava a MAIS ANTIGA outgoing (a saudação inicial),
não a mais recente. Aí o scope incoming agrupava TODAS as msgs do
cliente desde o "Oi" da Juliana, juntando wifi+pet num turn só.

Caso real conv 6064 (2026-05-02 21:18):
  T1: cliente "Preciso senha wifi" → Hermes "Prime2025"
  T2: cliente "Posso levar animais?"
  → debounce agrupou ["Preciso senha wifi", "Posso levar animais"]
    como se fossem MSGS DO MESMO TURN, mandou pro Hermes como
    "Cliente acabou de dizer: Preciso senha wifi.\nPosso levar animais?"
  → Hermes consultou faq_lookup com query "senha wifi e animais"
  → Resposta: "Senha Prime2025 e pode levar animais"

Fix: usa .reorder(created_at: :desc) que sobrescreve default_scope,
gerando SQL limpa com só "ORDER BY created_at DESC".

Camadas 3 (strip linhas) e 4 (topic gating) que adicionei antes são
paliativos válidos como defesa em profundidade, mas o problema raiz
era esse default_scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:30:12 -03:00
Rodribm10
ebf98c90f4 feat(captain/hermes): camada 4 — topic gating contra info não pedida
Camada 3 (strip de linhas repetidas) não cobre quando LLM mistura tópico
antigo + atual numa LINHA SÓ (caso real: cliente pergunta sobre pet,
Hermes responde "A senha é Prime2025 e pode levar animais sim" —
overlap baixo, strip não dispara).

Camada 4 detecta tópicos factuais (Wi-Fi/senha/pet/estacionamento/preço/
cancelamento) presentes NA RESPOSTA mas AUSENTES da última pergunta do
cliente. Quando detectado, bloqueia entrega + dispara
[SISTEMA: force_topic_focus] no Hermes mandando responder
EXCLUSIVAMENTE sobre o tópico atual. 1 retry; persistindo, entrega.

Validado: cliente "Posso levar animais?" + resposta "Senha + pet" →
detecta [:wifi] como off-topic. Cliente "Qual senha wifi?" + resposta
"Senha é X" → vazio (passa normal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:22:41 -03:00
Rodribm10
cc58805722 feat(captain/hermes): camada 3 — strip de linhas repetidas + check FAQs
LLM tende a "resumir" info de turns anteriores em toda nova resposta.
Camada 3 strip linhas onde >=70% das palavras significativas já apareceram
nas últimas 3 outgoings (filtra reactions). Saudações curtas preservadas.

Caso real Juliana 2026-05-02 (turn 3 ela ia repetir "Senha Prime2025 +
pet" mesmo cliente só dizendo "valeu"). Após strip: vira só "Imagina,
Rodrigo 😊".

Validator UI: novo check "FAQs próprias aprovadas > 0" — alerta quando
zero (faq_lookup cai no parent, risco de info desatualizada igual ao
bug do X-Captain-Assistant-Id que vazou Wi-Fi do parent hoje cedo).

Filtro SQL `content_attributes ->> 'external_source'` não casava (coluna
json, não jsonb); migrado pra filtro Ruby.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:16:39 -03:00
Rodribm10
30fc2460bb fix(hermes-provision): X-Captain-Assistant-Id usa id próprio, não do parent
Antes: header MCP era setado como PARENT_ASSISTANT_ID (Captain interno).
Tools como faq_lookup buscavam dados do parent — quando Hermes id=10
tinha FAQ "senha=Prime2025" mas parent id=1 tinha FAQ "senha presencial",
o Hermes respondia com a do parent (errada).

Agora: usa ASSISTANT_ID (id próprio do Hermes recém-criado). FAQs e
qualquer outra tool que filtra por assistant.id pegam os dados certos.

Migração manual aplicada nos 4 Hermes existentes (valentina/nina/lara_h/
juliana_qnn1) trocando 6→7, 4→8, 3→9, 1→10. Sessions e state.db dos 4
foram limpos pra evitar contaminação do histórico anterior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:04:03 -03:00
Rodribm10
ed99f67525 feat(captain/hermes): camada 2 — gating de saída factual sem tool call
Detecta alucinação de memória: se resposta do Hermes contém info
factual (preço/senha/horário/regra/política) E o LLM NÃO chamou
nenhuma tool MCP entre dispatch e callback, bloqueia entrega + dispara
system_message forçando consulta a tool. 1 retry; persistindo, escala.

Implementação:
- McpController: incrementa Rails.cache hermes_tool_calls:<conv_id>
  em cada tools/call.
- OutgoingJob: snapshot do contador como hermes_tool_calls_baseline
  ANTES de despachar pro Hermes.
- HermesCallbackController.gate_factual_no_tool!: compara baseline vs
  current; se igual + FACTUAL_PATTERNS bate, intercepta. Patterns
  cobrem R$, %, "senha", check-in/out + horário, política de
  cancelamento, "permitido", "pode levar pet/animal".

Caso real: cliente pede senha do Wi-Fi → Hermes responde de cabeça
"é passada presencialmente" sem chamar faq_lookup → callback intercepta,
não entrega pro cliente, manda [SISTEMA: force_factual_tool] pro Hermes
com instrução de chamar faq_lookup. Se faq_lookup vier vazio → frase-
âncora handoff.

Auto-react ambient: removido filtro de "?" que barrava em prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:58:17 -03:00
Rodribm10
9f2a08f478 fix(hermes-provision): substitui localização Ponta Negra/Natal pela do spec
Sem isso, todo agente novo herdava "em Ponta Negra, Natal/RN" da SOUL.md
template (Valentina é Dolce Amore — Natal). Caso real: Juliana Qnn01
respondia "em Ponta Negra, Natal/RN" sendo de Ceilândia/DF.

Adiciona campo city ao spec e sed que substitui pela localização correta
quando setado. Spec já tinha "city" no header docstring, só não era lido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:40:32 -03:00
Rodribm10
c960dc7e1e feat(captain/hermes): handoff por loop + label-guard em outgoing
Substitui o interceptor agressivo de "Um momento — vou verificar"
(que bloqueava silenciosamente) por handoff explícito + loop detection:

- HANDOFF_PATTERNS: detecta a frase-âncora ("Um momento — vou
  verificar", "Aguarde um instante") e ENTREGA pro cliente,
  marcando conv com label triagem_humana.
- looped_response?: detecta 2 outgoing similares (Jaccard >= 0.50)
  OU pergunta reformulada sobre mesmo tópico (>= 3 keywords em comum
  + ambas inquisitivas via "?" / "me confirma" / "qual prefere"
  etc). 1ª resposta passa, 2ª escala. Cobre o caso "endereço ou
  link?" → "apenas link ou link + endereço?".
- OutgoingJob: guard que pula dispatch se conv tem label
  triagem_humana ou hermes_placeholder. Hermes não responde mais →
  não gasta token + não gera loop.

Cobre 2 casos do Rodrigo:
1. Bot da Claro insistindo em menu → 2ª resposta similar escala.
2. Hermes pedindo confirmação 2x sem entregar → escala.

Tokenize normaliza acentos (transliterate) pra stopwords baterem
"voce/você", "endereco/endereço", etc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:30:58 -03:00
Rodribm10
10fec1d2cb feat(captain/hermes): auto-react ambiente — 20% de chance em msgs neutras
Cobre o gap entre saudação e despedida: quando msg do cliente não bate
nenhum dos regex determinísticos (thanks/confirm/greet/farewell), rola
dado e reage com emoji discreto (😊/💕//💯/🤗) em ~1 a cada 5 msgs.

Filtros pra não reagir em momentos errados (ambient_eligible?):
- Tamanho 6..180 chars (evita "oi" e narrativas longas).
- Sem "?" (perguntas esperam resposta de texto, não emoji).
- Sem keywords de fluxo de reserva (cpf, valor, hora, data, suite).
- Não começa com número (CPF, telefone, valor).

Determinístico continua igual — agente parece mais vivo sem virar
robozinho que reage em tudo. Atende pedido do Rodrigo de "reagir 20%
da conversa, não só nas saudações".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:20:36 -03:00
Rodribm10
42ada8100e feat(captain/mcp): tool check_suite_availability — bridge PlugPlay
Tool nova reusa as Captain::CustomTool 'custom_status_suites_<unidade>'
já cadastradas no painel (Ferramentas) que apontam pro PlugPlay
(oxpi.com.br/PlugPlay/SuitesStatus) com auth headers PLUG-PLAY-ID +
PLUG-PLAY-TOKEN específicos por unit.

Hermes consulta antes de generate_pix pra confirmar disponibilidade.
Resolve unit via Assistant.captain_unit_id; mapping unit → CustomTool é
por substring no nome (qnn/dolce/express/primeal/primevl).

Filtro opcional por suite_category. Retorno textual agrupado por classe
com contagem livre/ocupada e número das suítes livres (max 8).

Validado em Qnn01: 4 hidromassagens livres (101,102,103,108) /
15 Master livres / 5 Standard livres — bate com painel PlugPlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:10:57 -03:00
Rodribm10
c8785b999c fix(captain/hermes): intercepta placeholder e força tool call
Quando o LLM emite " Um momento — vou verificar." (ou variantes:
"deixa eu verificar", "aguarde um instante") sem chamar nenhuma tool,
o callback agora:

1. NÃO entrega a msg pro cliente (UX terrível ver "vou verificar" e
   ficar esperando indefinidamente).
2. Dispara notify_event pro Hermes com [SISTEMA: force_tool_call]
   instruindo a retomar e chamar a tool relevante (generate_pix,
   send_suite_images, faq_lookup) com base na última msg do cliente.
3. Limita 2 retries por conversation via Rails.cache (TTL 5min). Após
   esgotar, marca labels hermes_placeholder + triagem_humana e descarta.

Caso real do Rodrigo: cliente confirmou reserva ("Para hoje 23h por 4h")
e o LLM respondeu apenas o placeholder (api_calls=1 no daemon, sem
tool). Cliente ficava esperando sem resposta. Agora Captain força o
LLM a chamar a tool, ou cai pra triagem humana após 2 tentativas.

PLACEHOLDER_PATTERNS cobre as variações observadas. SKILL.md já proibia
"Um momento", mas o LLM ignorava — defesa em camadas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:01:18 -03:00
Rodribm10
bd494c424d fix(captain/mcp): generate_pix dispatcha fallback em mais cenários
Antes: fallback (link da página de reserva) só disparava quando Inter API
explodia (rescue StandardError). Erros estruturais (categoria não existe
na unit, período/dia inválido, sem credencial Inter cadastrada) caíam em
error_response e o LLM travava em " Um momento — vou verificar." sem
mandar nada pro cliente.

Agora: dispatch_no_pricing_fallback! cobre os casos onde Pix nem foi
tentado. Mesma UX do fallback existente — link pré-preenchido + label
pix_falhou_fallback pra triagem da gerência. Tool retorna isError=false
com instrução curta pro LLM ("só confirme que o link foi enviado, não
fale do problema técnico").

Caso real: Rodrigo pediu hidromassagem na inbox da Dolce Amore (unit que
não tem essa categoria). PricingTables retornou "Categoria não
reconhecida"; antes o LLM ficava no placeholder. Agora cliente recebe
link da página oficial automaticamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:56:52 -03:00
Rodribm10
28e880d7b6 feat(captain/hermes-builder): aba Verificação com 22+ checks + reparo automático
UI nova dentro do Construtor (Hermes) — TabBar com Chat e Verificação.
Verificação roda HermesBuilder::Validator (DB+runtime) e exibe resultado
agrupado por categoria, com botão Refazer inline em FAIL/WARN reparáveis.

Backend (porta dos checks DB do CLI bin/hermes-validate):
- HermesBuilder::Validator com 22+ checks: engine, profile, port,
  secret, parent, unit, Brand, CaptainInbox sync (o bug que travou
  Juliana), pricing dry-run, Inter creds, typing/response_delay,
  registry MCP completo.
- HermesBuilder::Repairer com 4 handlers automáticos: set_engine_hermes,
  sync_captain_inbox_unit, set_default_typing_delay,
  set_default_response_delay.
- Endpoints novos: GET assistants, GET validate?slug=, POST repair.

Frontend:
- builder/Index.vue: wrapper com TabBar.
- builder/BuilderChat.vue: extraído do Index original.
- builder/BuilderVerification.vue: dropdown + Conferir agora + lista
  agrupada por categoria com badges + botão Refazer inline.

i18n: keys em pt_BR e en sob CAPTAIN_HERMES_BUILDER.VERIFY.*.

Filesystem/systemd checks ficam pro CLI hermes-validate (Rails container
não enxerga /root/.hermes/profiles do host).

Validado HTTP: GET /validate?slug=juliana_qnn1 → 28 PASS / 0 FAIL / 1 WARN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:27:40 -03:00
Rodribm10
47f32b540b fix(captain/mcp): generate_pix prefere assistant.captain_unit + 3 checks runtime
resolve_unit agora prioriza Captain::Assistant.captain_unit_id sobre o
mapping legado CaptainInbox (que falha quando 2 agentes — interno e
Hermes — compartilham a mesma inbox).

Caso real: Juliana Hermes (unit Qnn01) compartilhava inbox 1 com Juliana
captain_interno (unit Recanto), mas o CaptainInbox da inbox 1 estava
mapeado pra unit Dolce Amore (id=4) por contaminação anterior. Tool
resolvia unit errada, generate_pix retornava "categoria não reconhecida"
e o agente travava em " Um momento — vou verificar." sem retomar.

bin/hermes-validate ganha 3 checks novos:
- CaptainInbox.unit == Assistant.unit (FAIL — exatamente o bug acima)
- Pricing dry-run (calcula preço da 1ª categoria sem erro)
- Credenciais Inter completas (WARN se faltar cert/key — cai no fallback)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:09:04 -03:00
Rodribm10
9cfd131dcf feat(hermes): script de validação + sed anti-vazamento Dolce Amore
- bin/hermes-validate <slug>: 44 checks de saúde (DB, filesystem, systemd,
  routing, MCP tools, humanização). Saída textual ou --json. Exit 0 sem FAIL.
- bin/hermes-provision: sed adicional substitui exemplos hardcoded de
  categorias Dolce Amore (Mini Chalé 45 etc) pelas 3 primeiras categorias
  da unidade nova; evita resíduo em descrições de tools.
- Fix bash: trocar `|| echo 0` por `|| true` em greps (evita "0\n0" quando
  grep -c não acha e ainda imprime contagem).

Validado em juliana_qnn1: 43 PASS / 0 FAIL / 1 WARN (gallery seed pendente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:53:35 -03:00
Rodribm10
bc87b496a4 fix(captain/hermes): SOUL.md vem da Valentina (template) com sed identity + Pix flow no skill 2026-05-02 14:39:36 -03:00
Rodribm10
d02cb72336 fix(captain/hermes): anti-leak entre unidades — desliga memória + guard SOUL.md
Bug: Juliana de Qnn01 oferecia categorias do Dolce Amore (Apartamento,
Mini Chalé 45, Suíte Ouro). Vazamento via:
1. Hermes daemon memory_enabled+user_profile_enabled — acumula contexto
   entre turnos no mesmo profile.
2. Codex/ChatGPT memória user-level — todos os profiles compartilham o
   mesmo OAuth (borbamachadoo@gmail.com), então ChatGPT lembra de
   conversas com Valentina e bleeda no LLM da Juliana.
3. Tool descriptions com exemplos motel-flavored (já corrigido em commit
   anterior).

Fix permanente no script hermes-provision:
- sed do config.yaml zera memory_enabled e user_profile_enabled
- append no SOUL.md adiciona "REGRA CRÍTICA — IGNORE OUTRAS UNIDADES"
  com lista explícita de categorias válidas (extraída do spec.categories)
  e instrução pro LLM ignorar memória de outras unidades.

Aplicado retroativamente em juliana_qnn1 + sessions/state.db limpos.

Próximos agentes do Construtor nascem com essa proteção por padrão.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:19:37 -03:00
Rodribm10
f0f8322cce fix(captain/mcp): tool descriptions sem exemplos motel-flavored + brand match
Bug 1: send_suite_images_tool description mencionava 'Master, Luxo, Mini
Chalé 45' como exemplos. Quando outros agentes (ex: Juliana de Qnn01)
faziam tools/list, o LLM via essas categorias e usava como referência —
respondia oferecendo Mini Chalé 45 e Suíte Ouro pra cliente do 1001
Noites Ceilândia (que não tem essas categorias). Removidos exemplos.

Bug 2: lookup_brand fazia fuzzy match permissivo demais. 'Hoteis 1001
Noites' e 'Hotel 1001 Noites Prime' ambos contêm '1001 Noites' — quem
vinha primeiro no .find ganhava (Prime, ID menor). Juliana de Qnn01
(brand 'Hoteis 1001 Noites') saiu do Construtor com SOUL.md dizendo
'Hotel 1001 Noites Prime'. Fix: prioriza brand do parent_unit (fonte
canônica), depois exact-match casecmp, depois fuzzy.

Tudo isso vale pra Construtor entregar agente certo da próxima vez —
foram os 2 erros de provisionamento da Juliana hoje.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:07:15 -03:00
Rodribm10
ed21722dc4 fix(captain/hermes): callback aceita ?slug= além de ?inbox_id=
Hermes daemon faz POST /webhooks/captain/hermes_callback?slug=<profile>
mas controller só conhecia ?inbox_id. 404 → resposta do LLM nunca chegava
ao Captain. Cliente via só auto-react.

Fix: fetch_inbox resolve via Captain::Assistant.find_by(hermes_profile_name)
quando slug está presente. Inbox é a primeira CaptainInbox associada a
esse assistant. Suporta o pattern admin de re-apontar uma inbox de teste
(ex: Angelina) entre vários agentes Hermes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:53:12 -03:00
Rodribm10
88a5adb65e fix(captain/hermes): subscription_name e secret usam profile_name (slug)
Bug: Captain dispatchava pra /webhooks/captain-inbox-<inbox.id>, mas o
script hermes-provision criava subscription com nome captain-inbox-<slug>.
Mismatch → daemon retornava 404, Sidekiq retentava, AutoReact firava
N reactions sem nunca dispatchar pro LLM.

Fix:
- subscription_name_for(inbox): se o assistant tem hermes_profile_name,
  usa "captain-inbox-<slug>" (estável por agente). Fallback pra
  "captain-inbox-<inbox.id>" só se não tiver slug.
- subscription_signing_secret(inbox): lê de
  assistant.hermes_subscription_secret primeiro (DB-driven, gravado pelo
  script). Fallback pra env var legacy CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id>.

Resultado: admin pode apontar Angelina (inbox 1) pra qualquer agente
Hermes (Valentina · Hermes / Nina · Hermes / Lara.H / Juliana · Hermes)
e o roteamento funciona — não depende mais de inbox.id no path.

Renomeei manualmente as subscriptions de Valentina e Nina nos profiles
da VPS (eram captain-inbox-1 e captain-inbox-5 legado) pra
captain-inbox-valentina e captain-inbox-nina.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:42:22 -03:00
Rodribm10
ec50496d21 fix(captain/hermes): hermes-provision usa hermes_profile_name como chave
Bug: find_or_initialize_by(name: ...) batia em captain_interno existente
com mesmo nome (ex: 'Juliana') e SOBRESCREVIA engine='hermes'. Resultado:
Juliana captain_interno virou Hermes, atendimento legado quebrou.

Fix: chave passa a ser (account_id, hermes_profile_name=slug). Auto-sufixa
nome com ' · Hermes' se colidir com captain_interno do mesmo nome (a não
ser que já tenha 'Hermes' no nome). Também grava captain_unit_id no
record (relação direta agora que existe a coluna).

Reverti manualmente Juliana id=1 + criei id=10 'Juliana · Hermes' fix in
DB. Future provisionamentos pelo Construtor usam o caminho corrigido.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:34:07 -03:00
Rodribm10
955cb824b3 fix(captain/mcp): save_agent_spec — caminho de sucesso retorna tupla [hash, errors]
Bug: expand_spec retornava [{}, errors] no erro mas só {hash} no sucesso.
Destruturação 'expanded, errors = expand_spec(...)' deixava errors=nil
→ errors.any? → undefined method any? for nil.

Fix: extraído build_expanded_hash + sucesso retorna [hash, []].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:26:25 -03:00
Rodribm10
828e2e6af3 fix(captain/mcp): save_agent_spec — passa categorias já serializadas pra build_skill_md
Bug: build_skill_md recebia AR Captain::PricingCategory objects mas o
format_pricing_block fazia cat['amounts'] (índice de hash). AR respondem
a .amounts, não a ['amounts'] → nil → undefined method 'group_by' for nil.

Fix: serializa primeiro, reusa em ambos os lugares.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:17:38 -03:00
Rodribm10
5ef59ecc12 fix(captain): captain_unit_id em captain_assistants — fim do lookup quebrado
Captain::Assistant agora aponta direto pra Captain::Unit. Antes a relação
ia via CaptainInbox, mas isso quebrou quando re-apontamos as inboxes pros
Hermes — assistants captain_interno (Juliana, Bianca, Lara, Nina,
Valentina) ficaram SEM CaptainInbox associada e o lookup
unit_for(assistant) retornava nil.

Resultado: get_assistant_pricing(3) (Lara) caía no fallback de scenario
text. Construtor reportava "veio cenário/prompt, não tabela estruturada".

Migration adiciona captain_unit_id (FK opcional). Backfill explícito:
- 1 Juliana → unit 3 (Qnn01)
- 2 Bianca → unit 2 (PrimeAL)
- 3 Lara → unit 2 (PrimeAL — mesmo brand)
- 4 Nina → unit 5 (Express)
- 6 Valentina → unit 4 (Dolce Amore)
- 9 Lara.H → unit 2 (via parent_assistant_id=3)

Tools get_assistant_pricing_tool e save_agent_spec_tool atualizados pra
usar assistant.captain_unit primeiro (nova relação direta), com fallback
pro CaptainInbox se nulo (pra retrocompatibilidade).

Validado live: tool retorna grid markdown com Stilo/Alexa/Hidromassagem
em Seg-Qua + Qui-Dom corretamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:45:53 -03:00
Rodribm10
22eab86302 fix(captain/mcp): get_assistant_pricing lê do DB + save_agent_spec expande
ROOT FIX (não paliativo) das 3 lacunas que travavam o Construtor:

1. get_assistant_pricing_tool: lia de Captain::Mcp::PricingTables::TABLES
   (hash Ruby) que NÃO EXISTE MAIS desde a migração pra DB. Caía no
   fallback de scenario raw. Refactor: lê de Captain::PricingCategory +
   Captain::PricingAmount, formata grid markdown agrupado por day_bucket.

2. save_agent_spec_tool: Construtor salvava REFERÊNCIAS
   (pricing_source.copied_from_assistant_id) mas hermes-provision script
   espera DADOS EXPANDIDOS (categories[] com amounts, soul_md+skill_md).
   Refactor: tool agora EXPANDE server-side — busca PricingCategory do
   parent, monta categories array, gera SOUL.md (template + identity +
   disclosure_policy) e SKILL.md (template + pricing + rules + identity).
   Output já é spec consumível pelo script.

3. Captain::PricingAmount::PERIODS: adicionado '1h' (Prime tem 1h).

4. Seed pras 3 units faltando: Hotel Recanto (1) + PrimeAL (2) + Qnn01
   (3). Agora os 6 units existentes têm pricing em DB.

Hot-patched ambos tools + USR1 no Puma. Construtor pronto pra criar
Bianca/Juliana etc end-to-end sem intervenção manual.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:37:53 -03:00
Rodribm10
0f39945f43 feat(captain/mcp): get_assistant_scenario tool — Construtor copia identidade de outro agente
Construtor atende 'copiar maps/endereço/telefone/wifi da Lara' sem o admin
redigitar. Tool retorna o markdown bruto do scenario (default
Daniela_Reservas) do assistant fonte; LLM extrai os campos relevantes.

Cobre o gap entre get_assistant_pricing (preços estruturados) e
get_assistant_faqs (Q&As): essa retorna prompt CRU pra LLM interpretar
campos não estruturados (contatos, links, wifi, persona).

Hot-patched + USR1 no Puma e Construtor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:55:13 -03:00
Rodribm10
dd9e11da14 fix(captain/mcp): get_assistant_faqs — não filtrar por documentable_type
Filtro original só retornava FAQs com documentable_type=NULL, mas a maior
parte das FAQs aprovadas das Jasmines (Juliana, Bianca, Lara, Nina) tem
documentable_type='User' ou 'Conversation' (origem: histórico de
conversas). Resultado: tool retornava "0 FAQs aprovados" pra todos exceto
Valentina (única com FAQs criadas direto sem origem documentável).

Removido o filtro. Status='approved' já é suficiente — admin reviewou.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:49:54 -03:00
Rodribm10
59747e5400 fix(captain/mcp): tools do Construtor — typo _context: → context:
Tool registry chama tool.call(args, context: foo) mas as 3 tools read-only
do Construtor (get_assistant_faqs, get_assistant_pricing, save_agent_spec)
estavam declaradas como def call(args, _context:), com underscore. No
Ruby isso muda o nome do parâmetro keyword — ArgumentError:
'missing keyword: :_context' quando o Construtor tentava copiar FAQs/
pricing de um assistant existente.

Corrigido pra context: (sem underscore) com rubocop disable de
Lint/UnusedMethodArgument já que essas tools não usam o context.

Hot-patched via docker cp + Puma USR1 antes do deploy pro Rodrigo seguir
testando o Construtor sem esperar build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:41:23 -03:00
Rodribm10
280d250983 feat(captain/hermes): script hermes-provision pra Construtor autônomo
Bash script em bin/hermes-provision (instalado em /usr/local/bin/ na VPS)
que recebe spec JSON via stdin e provisiona um agente Hermes ponta-a-ponta:
- Valida spec (slug regex, preços 0-5000, períodos/buckets do catálogo)
- Aloca porta livre no range 8650-8699
- Gera HMAC secret via openssl rand
- Cria Captain::Unit (find_or_create), Captain::PricingCategory/Amount,
  Captain::Assistant (engine=hermes) via docker exec rails runner
- Copia template profile da Valentina, patcheia config.yaml com porta +
  X-Captain-Assistant-Id (parent_id se setado, senão self id)
- Escreve SOUL.md/SKILL.md do spec
- Gera webhook_subscriptions.json com secret
- Cria systemd unit hermes@<slug>.service e enable+start
- rsync profile pra repo de backup git local
- Idempotente: re-rodar com mesmo slug não duplica nada (find_or_create)
- --dry-run valida sem escrever
- --rollback <slug> destrói tudo (DB + systemd + filesystem)

Construtor (Hermes daemon) chama via terminal skill nativa:
  echo '<spec>' | /usr/local/bin/hermes-provision

Próximo passo: atualizar SOUL.md/SKILL.md do Construtor pra invocar
o script ao final do fluxo socrático.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:59:30 -03:00
Rodribm10
7995bc6fe6 feat(captain): pricing em DB + colunas de provisionamento Hermes
Migra a tabela de preços do PricingTables.rb hardcoded pras tabelas
captain_pricing_categories + captain_pricing_amounts no DB. Mantém a
mesma API pública Captain::Mcp::PricingTables.calculate(...) — código
chama o banco via novos modelos Captain::PricingCategory e
Captain::PricingAmount.

Seed db/seed_pricing_tables.rb faz backfill idempotente pra Dolce Amore
(unit 4) e Express (unit 5) com a mesma estrutura que tava no Ruby.

Adiciona em captain_assistants:
- hermes_subscription_secret (gerado pelo script de provisionamento)
- hermes_port (alocado no range 8650-8699)
- parent_assistant_id (link informativo Hermes → captain_interno parent
  pra sombrear FAQs/scenarios via header X-Captain-Assistant-Id)

Adiciona em captain_units: extra_person_fee + currency.

Primeiro milestone do roadmap arquitetural pro Construtor autônomo
(decisões em memory/project_construtor_autonomo_decisions.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 09:04:50 -03:00
Rodribm10
a2bb613e68 feat(captain/hermes): debounce — inbox.typing_delay como buffer + agrupar msgs
Antes: inbox.typing_delay funcionava só pro Captain interno
(schedule_internal_response). Hermes ignorava o campo e disparava
OutgoingJob na hora — campo da UI era cosmético pra inboxes Hermes.

Agora:
- schedule_hermes_response cancela jobs OutgoingJob pendentes pra mesma
  conversa e enfileira com wait=inbox.typing_delay (debounce window).
- OutgoingJob agrupa todas msgs incoming entre a última resposta real
  do agente (ignora reactions) e a msg âncora; dispatch envia o texto
  concatenado pro Hermes via novo content_override no Client#dispatch.

Resultado: cliente que digita "Oi" + "quero pernoite Master" em segundos
vê o agente esperar até o buffer vencer e responder UMA vez cobrindo
ambas as falas, em vez de 2 respostas atropelando o pensamento.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:46:24 -03:00
Rodribm10
e662913b21 fix(captain/hermes): auto-react idempotente — bloqueia retry duplicate
OutgoingJob faz retry no DispatchError (até 3x ActiveJob + Sidekiq).
Cada retry chamava AutoReactService.maybe_react! e criava uma reaction
nova — observado em prod 02/05 quando o env var
CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_5 estava faltando, gerando
401 → 6 reações duplicadas no inbox EXPRESS.

Adiciona guard already_reacted? que checa se já existe Message outgoing
com external_source='hermes_auto_react' e in_reply_to=msg.id antes de
criar uma nova. Defesa contra futuro 5xx/timeout do Hermes daemon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 08:32:47 -03:00
Rodribm10
3182002bd9 feat(captain): engine column + DB-driven Hermes routing + Express pricing
Marca cada Captain::Assistant com engine ('captain_interno' | 'hermes')
e move o roteamento Hermes do env var pro banco — admin troca engine
re-apontando a inbox no painel, sem deploy. Mantém fallback pras env
vars antigas (CAPTAIN_HERMES_INBOX_IDS etc) durante a migração gradual,
pra não quebrar Valentina antes da re-associação.

Frontend: badge "Hermes" (âmbar) ou "Interno" (cinza) ao lado de cada
assistant no dropdown switcher e no card da listagem, com chaves i18n
em en + pt_BR.

Tabela de preço (pricing_tables.rb): adiciona unit Express (id=5) e
estende a estrutura pra aceitar preço por dia da semana
(mon_wed/thu_sun) — necessário pro Express, retrocompatível com Dolce
Amore (preço único).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 07:54:01 -03:00
Rodribm10
5f6aed05c9 fix(captain/hermes-builder): callback resolve session via last_session
Bug: Hermes nao propaga chat_id no metadata do callback. Callback
controller nao conseguia resolver qual session armazenar a resposta.
WARN "no session_key resolvable — ignorando" descartava todas as
respostas do Construtor.

Fix: HermesBuilder::Storage.remember_last_session() grava ultima
session por account quando admin chama /start ou /create. Callback
le essa key via last_session_for(account_id). MVP-safe pra 1 admin
por conta.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:53:43 -03:00
Rodribm10
f362949579 fix(captain/hermes-builder): troca componente Button por <button> HTML
Componente Button (components-next/button) provavelmente nao propaga
@click direito ou tem quirk com variant="primary" (nao esta em
VARIANT_OPTIONS que aceita solid|outline|faded|link|ghost). Botao
"Iniciar criação" nao disparava startSession.

Troca por <button> HTML simples com classe start-button estilizada.
Garante que @click vai direto pro handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:40:44 -03:00
Rodribm10
cb067e1f17 feat(captain/hermes): auto-react cobre saudacoes e despedidas
Estende AutoReactService com 2 padroes novos:
- GREETING_REGEX (bom dia, boa noite, oi, ola, hello) -> 👋
  apenas na PRIMEIRA mensagem da conversa pra nao ficar forcado
- FAREWELL_REGEX (tchau, ate logo, abraço, bjs, flw) -> ❤️

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:21:43 -03:00
Rodribm10
041766b427 fix(captain/hermes-builder): namespace controller correto + botao iniciar
Bug: controller estava em enterprise/api/v1/accounts/captain/ com namespace
Enterprise:: — convencao Chatwoot eh enterprise/app/controllers/api/v1/...
direto, classe Api::V1::Accounts::Captain::HermesBuilderController. Sem
namespace Enterprise::. 404 acontecia porque rotas registravam Captain::
sem prefixo Enterprise::.

Move controller pro path correto. Remove diretorios vazios criados.

UX: adiciona endpoint POST /start que envia comando-gatilho oculto pro
Construtor comecar fluxo socratico — admin nao precisa digitar primeira
mensagem. Vue mostra empty state com botao "Iniciar criacao" em vez de
exigir mensagem inicial.

i18n keys novas: START + EMPTY_STATE atualizado em pt_BR + en.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:06:04 -03:00
Rodribm10
bfa06597f2 feat(captain/hermes): auto-react deterministico antes do LLM
Pattern matching no Captain dispara reaction <1s sem esperar Codex
processar. Resposta de texto continua vindo do Hermes normalmente —
auto-react eh COMPLEMENTAR.

Padroes detectados (no incoming message):
- "obrigado/valeu/vlw/thanks" -> 🙏
- "ok/fechado/perfeito/blz/combinado" -> 👍
- attachment image (msg sem texto) -> 😍
- attachment audio (msg curta) -> 🙏

Conservative: só dispara em casos CLAROS. Em duvida, deixa pro LLM
decidir via react_to_message tool.

Plugado em Captain::Hermes::OutgoingJob#perform — chamado antes do
dispatch pro Hermes. Falha silenciosa (rescue) — nao bloqueia fluxo.

SOUL.md atualizado: regra de frequencia (~30% das msgs) + nota sobre
auto-react pra LLM nao duplicar reacao em casos cobertos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:59:28 -03:00
Rodribm10
c7300dfbcf fix(captain/hermes): nao enviar typing_off explicito
Antes: typing_on -> sleep delay -> create msg DB -> typing_off -> SendReplyJob
envia via wuzapi -> msg chega no celular (2-5s depois).

Resultado visual quebrado: cliente ve "digitando..." sumir antes da msg
chegar, gap visivel.

Agora: typing_on -> sleep -> create msg -> deixa rolar. WhatsApp do
cliente cancela typing automaticamente quando msg eh entregue. Sequencia
fica natural.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:29:07 -03:00
Rodribm10
35358a42b1 fix(captain/hermes-builder): i18n keys no caminho correto
Convencao Chatwoot pra features Captain novas: keys no root level
(CAPTAIN_HERMES_BUILDER, igual a CAPTAIN_ROLETA, CAPTAIN_FUNNEL etc) e
nao como sub-key de CAPTAIN.

Move CAPTAIN.HERMES_BUILDER -> CAPTAIN_HERMES_BUILDER em pt_BR e en.
Atualiza referencias t() no Vue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:17:53 -03:00
Rodribm10
40fd0c8f50 feat(captain/hermes-builder): UI Vue + endpoints pra chat com Construtor
Tela "Construtor" no painel Captain (acessivel em sidebar pra admins) que
permite criar novo agente Hermes via chat guiado com agente Construtor
(profile Hermes separado).

Backend (admin scope):
- POST /api/v1/accounts/:id/captain/hermes_builder — manda mensagem do
  admin pro gateway do Construtor (Hermes na porta 8646)
- GET — retorna historico da sessao (Rails.cache, TTL 4h)
- DELETE /reset — limpa sessao
- POST /webhooks/captain/builder_callback — recebe respostas async do
  Construtor via plugin captain-http-callback do Hermes
- HermesBuilder::Storage (Rails.cache) — persiste msgs por session_key
  (account_id + user_id) com role/content/created_at
- HermesBuilder::Dispatcher — encaminha pro webhook do Construtor com
  HMAC opcional via ENV HERMES_BUILDER_WEBHOOK_SECRET

Frontend:
- Pagina Vue HermesBuilder/Index.vue — chat simples com:
  * Lista de mensagens com bubbles user/construtor
  * Indicador "digitando..." enquanto aguarda resposta
  * Input com Enter pra enviar / Shift+Enter pra nova linha
  * Polling 2s pra novas msgs
  * Botao Limpar conversa
- API client em api/captain/hermesBuilder.js
- Rota captain_hermes_builder_index (admin only)
- Item no sidebar Captain "Construtor (Hermes)"
- i18n keys CAPTAIN.HERMES_BUILDER em pt_BR + en

UX flow:
  Admin abre tela → digita "olá" → Construtor pergunta nome → admin
  responde → marca, persona, tabela (com opcao copiar de existente),
  regras, FAQs, identidade → resumo → confirmar → Construtor chama
  save_agent_spec → JSON salvo em /tmp/agent-specs/<slug>.json pra
  revisao posterior. NAO cria filesystem do profile nem registros DB
  (etapa SEPARADA, prox sessao).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:00:41 -03:00
Rodribm10
d35084334c feat(captain/mcp): tools read-only pro Skill Construtor
4 tools novas (admin scope, read-only) usadas pelo profile Hermes
"construtor" durante criacao guiada de novos agentes:

- list_assistants: lista todos os assistants da conta com badge engine
  (Captain interno vs Hermes), counts de scenarios/FAQs, marca/unidade
- get_assistant_pricing: retorna tabela de preços parsed (Captain::Mcp::
  PricingTables se Hermes, scenario fallback se Captain interno)
- get_assistant_faqs: retorna FAQs aprovados em markdown (até 50)
- save_agent_spec: salva JSON da especificacao em /tmp/agent-specs/<slug>.json
  (NÃO cria filesystem do profile nem registros DB — só persiste spec
  pra revisao/provisionamento separado depois)

Construtor coleta perguntas socraticas, oferece "copiar de existente" pra
acelerar quando marca for igual (Prime VL/AL/ADE preços iguais), e ao
final salva spec.json pra admin revisar antes de provisionar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:24:02 -03:00
Rodribm10
48fad2977b feat(captain/hermes): payload enriquecido + humanizadores + notif Pix proativa
Captain::Hermes::Client (enterprise/app/services/captain/hermes/client.rb):
- text_for_hermes: transcreve audio via Whisper antes de enviar pro Hermes
  (reusa Captain::OpenAiMessageBuilderService)
- image_urls_for_hermes: URLs publicas de imagens da message; plugin
  captain-webhook do Hermes baixa em /tmp/ e popula event.media_urls pra
  vision multimodal (gpt-4o-mini auxiliary)
- contact_history_snapshot: dados eager pro [ctx] (last_reservation_*,
  total_conversations, ultima_suite, etc) — memoria do contato direto no
  prompt sem precisar tool call
- notify_event + build_event_payload: dispara webhook sintetico pro Hermes
  pra eventos do sistema (Pix pago etc) — Valentina manda mensagem
  espontanea sem cliente perguntar

Captain::Payments::ConfirmationService:
- Hook notify_hermes_proactively! enfileira NotifyPaymentConfirmedJob
  apos confirmacao de Pix, somente se inbox estiver no fluxo Hermes
  (Captain interno continua igual sem mudanca)

Captain::Hermes::NotifyPaymentConfirmedJob (NOVO):
- Monta system_message "[SISTEMA: pagamento_confirmado]\n..." e dispara
  webhook pro Hermes Valentina
- Valentina (via SOUL.md) interpreta como evento do Captain e manda
  mensagem celebrativa pro cliente

Captain::Hermes::DelayedReplyJob (NOVO) — humanizadores:
- Liga indicador "digitando..." (composing) via wuzapi
- Aguarda delay configuravel via Captain::Assistant.config['response_delay']
  (modos: none, fixed, typing_simulation com chars_per_second + min/max)
- Posta msg outgoing
- Desliga typing
- Fallback no HermesCallbackController posta direto se class nao carregada

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:15:50 -03:00
Rodribm10
9ed3491d55 feat(captain/mcp): suite de 9 tools MCP + pricing tables Dolce Amore
Tools novas em enterprise/app/services/captain/mcp/tools/:
- generate_pix: Pix Inter via PricingTable + fallback link reserva-1001
- check_pix_payment: consulta Inter + dispara ConfirmationService
- send_suite_images: fotos da galeria (Captain::GalleryItem) via wuzapi
- reschedule_reservation: remarca reserva (regra 3h antecedência Dolce)
- update_contact: persiste nome/CPF/email/notas no Contact
- get_contact_history: markdown do histórico do cliente on-demand
- react_to_message: reage com emoji via wuzapi (is_reaction=true)

Captain::Mcp::PricingTables: tabela hardcoded Dolce Amore (8 categorias x 4
periodos + regras de pessoa extra). Backend e fonte de verdade — LLM nao
inventa preco.

add_label_tool: cria Label oficial automaticamente se nao existir, aceita
conversation_id em arguments.

mcp_controller: aceita X-Captain-Account-Id/Assistant-Id/Inbox-Id como
fallback de contexto.

tool_registry: 9 tools ativas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:13:16 -03:00
Rodribm10
60759b955c fix(captain): força provider :openai quando há config dedicada de embedding
RubyLLM auto-detecta provider pelo prefixo do nome do modelo (ex:
`gemini-*` → provider Gemini → exige `gemini_api_key`). Quando temos
config dedicada de embedding (CAPTAIN_EMBEDDING_API_KEY) apontando pra
endpoint OpenAI-compatible (ex: Gemini OpenAI-compat em
generativelanguage.googleapis.com/v1beta/openai), queremos que o RubyLLM
mande a request via OpenAI client mesmo que o nome do modelo bata com
outro provider.

Solução: passar provider: :openai e assume_model_exists: true ao chamar
embed quando dedicated_embedding_config? retornar true. Sem isso, o
RubyLLM falha com `Missing configuration for Gemini: gemini_api_key`
mesmo com a key correta setada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:18:50 -03:00
Rodribm10
2ade066468 feat(captain): EmbeddingService aceita provider de embedding dedicado
Permite trocar provider de embedding sem afetar o provider de chat. Útil
quando OpenAI key tradicional está fora (ban, billing, etc) mas você
quer usar outro provider OpenAI-compatible só pra embeddings — exemplo
clássico: Gemini OpenAI-compatible em
https://generativelanguage.googleapis.com/v1beta/openai com modelo
gemini-embedding-001 + dimensions=1536 (pra bater com schema pgvector).

Env vars novas (com fallback pro legacy_openai_settings se não setadas):

  CAPTAIN_EMBEDDING_API_KEY     — API key dedicada pra embeddings
  CAPTAIN_EMBEDDING_ENDPOINT    — base URL sem /v1 (default herda OpenAI)
  CAPTAIN_EMBEDDING_DIMENSIONS  — força redução do vector (ex: 1536)

Quando CAPTAIN_EMBEDDING_API_KEY está vazia, comportamento é idêntico ao
de antes (legacy_openai_settings). Backward-compatible.

Também aceita as variáveis via InstallationConfig (UI) ou ENV — ENV tem
precedência (padrão Chatwoot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:08:18 -03:00
Rodribm10
8fab08ba57 fix(captain): regra de pessoa extra do Dolce Amore — taxa a partir da 3ª
Bug reportado por cliente real: "valor extra não é a partir da 3ª pessoa?
Porque aí é para casal, ou seja sempre vai ter duas pessoas".

Cliente está certo. A base do quarto pra casal JÁ INCLUI 2 pessoas; taxa
extra começa na 3ª pessoa, não na 2ª. A documentação anterior (modelo +
skill Hermes + scenario 21 em prod) tinha "a partir da 2ª pessoa", o que
fazia Valentina cobrar extra pra ambos do casal — erro.

Mudanças:
- Texto da regra: "a partir da 3ª pessoa" pra Apartamento/Suítes/Mini
  Chalé 45, com nota explícita "base do quarto já inclui 2 pessoas".
- Exemplo de cálculo refeito: 4 pessoas Suíte Master pernoite sex/sáb =
  180 + (2 × 45) = R$ 270 (era 180 + 3×45 = 315, errado).

Categorias maiores (Chalé 2 Suítes, Suíte Ouro, Chalé Master 4 Suítes)
mantidas como antes (4ª e 8ª pessoa) — Rodrigo precisa revisar essas
separadamente já que cada uma tem capacidade diferente.

Aplicado em 3 lugares:
- db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md
- /root/.hermes/profiles/valentina/skills/dolce-amore-reservas/SKILL.md
- DB do Captain prod scenario 21 (via update_columns, +363 chars)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:52:09 -03:00
Rodribm10
713bb16012 feat(captain): MCP controller aceita Bearer token além de HMAC
Hermes Agent (cliente MCP) usa `--auth header` que envia
`Authorization: Bearer <token>` — padrão MCP. Antes o Captain MCP server
só aceitava HMAC-SHA256 (X-Hub-Signature-256), incompatível com o que
Hermes manda nativamente.

Agora aceita qualquer um dos 2 modos:
- Bearer token (recomendado, padrão MCP) — Hermes envia automaticamente
- HMAC-SHA256 do body — pra clientes que preferem assinar payload

Ambos validados com ActiveSupport::SecurityUtils.secure_compare contra
o mesmo CAPTAIN_MCP_SECRET. Sem secret = validação desabilitada (PoC/dev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:42:57 -03:00
Rodribm10
23911ea878 feat(captain): MCP server (HTTP) expondo tools pro Hermes Agent
Implementa servidor MCP (Model Context Protocol) HTTP no Captain pra
o Hermes Agent invocar tools do Captain via `hermes mcp add`. Substrato
pra integração de Nível 2 onde o agente consulta tools quando precisa
executar ações reais (buscar FAQ, adicionar label, futuramente Pix etc).

Arquivos:

- app/controllers/webhooks/captain/mcp_controller.rb
  Endpoint POST /webhooks/captain/mcp. Valida HMAC (CAPTAIN_MCP_SECRET),
  parseia JSON-RPC, despacha pro Server. Extrai params._captain_context
  com multi-tenant ids (conversation_id, inbox_id, account_id, etc).

- enterprise/app/services/captain/mcp/server.rb
  Subset MCP suficiente: initialize, tools/list, tools/call, ping,
  notifications/initialized. JSON-RPC síncrono (sem SSE).

- enterprise/app/services/captain/mcp/tool_registry.rb
  Lista centralizada de tools.

- enterprise/app/services/captain/mcp/tools/base_tool.rb
  Interface base pras tools (.name, .description, .input_schema, #call).

- enterprise/app/services/captain/mcp/tools/add_label_tool.rb
  Tool 1: aplica label na conversation atual.

- enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
  Tool 2: busca semântica em FAQs (Captain::AssistantResponse via pgvector
  cosine). Reaproveita SearchReplyDocumentationService pra paridade com
  o caminho legado do Captain.

- config/routes.rb
  Rota POST /webhooks/captain/mcp.

Conexão pelo Hermes:
  hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp

Auth: HMAC X-Hub-Signature-256 quando CAPTAIN_MCP_SECRET setado.

TODO próxima sprint: generate_pix_tool, send_suite_images_tool. Handoff
fica via automation hoje (UI Chatwoot).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:32:38 -03:00
Rodribm10
cd519a73c4 fix(captain): converte markdown bold pra formato WhatsApp no callback Hermes
Hermes (e LLMs default em geral) emitem **negrito** no formato markdown
padrão. WhatsApp usa formato próprio: *negrito* (single asterisk). Sem
conversão, o cliente vê asteriscos literais no WhatsApp, parecendo bug.

Defesa em camadas:
1. SOUL.md da Valentina foi atualizado com regra explícita de formato
   WhatsApp (single asterisk pra bold, underscore pra itálico, etc).
2. Este controller faz normalização defensiva no callback recebido do
   Hermes: regex `**texto**` -> `*texto*` antes de criar a mensagem
   outgoing. Não afeta o resto do conteúdo.

normalize_for_whatsapp() é trivialmente reversível e idempotente
(executar 2x é igual a 1x).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:24:57 -03:00
Rodribm10
d781f4a048 feat(hermes): plugin captain-webhook (stable session_chat_id)
Plugin pro Hermes Agent que SUBSTITUI o WebhookAdapter built-in pra
suportar session_chat_id estável derivado de campo no payload.

Por que existe
--------------
O WebhookAdapter built-in monta a chave de sessão como:

    session_chat_id = f"webhook:{route}:{delivery_id}"

delivery_id é único por POST → cada msg cria sessão nova no Hermes. OK pra
webhooks one-shot, ERRADO pra integração de chat onde múltiplas mensagens
da mesma conversa precisam compartilhar memória de sessão.

Como funciona
-------------
Quando o caller (Captain) inclui `conversation_id` ou `hermes_session_id`
no payload, o plugin reescreve chat_id pra:

    session_chat_id = f"webhook:{route}:session:{conversation_id}"

Mesma conversation_id em múltiplas POSTs → mesma sessão Hermes →
contexto e memória preservados. Sem o campo, fallback ao comportamento
default (session nova por POST). 100% backward-compatible.

Implementação
-------------
- kind: platform — registra com name="webhook" pra substituir built-in
  (Hermes prioriza platform_registry sobre código built-in em
  gateway/run.py:_create_adapter)
- Herda WebhookAdapter — só override `handle_message` (rewrite chat_id)
  e `connect` (recupera gateway_runner via _gateway_runner_ref pq o
  plugin path não seta isso explicitamente)
- Outros adapters (HMAC, rate limit, idempotency, parsing, deliver
  dispatch) — herdados sem cópia

Validado end-to-end na VPS (profile valentina):
- POST com conversation_id=99999 (msg 1) → session:99999 criada
- POST com conversation_id=99999 (msg 2) → MESMA session reutilizada
- Hermes responde via Codex em ~10s (2 turnos cumulativos)
- http_callback faz POST de volta no Captain (HTTP 200)
- Logs mostram: [captain-webhook] Stable session: ... -> session:99999

Combinado com captain-http-callback, completa o ciclo Captain ↔ Hermes:
Captain manda webhook com conversation_id → Hermes processa em sessão
estável → http_callback POSTa resposta de volta → Captain envia ao
WhatsApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:16:05 -03:00
Rodribm10
35de8b7fde feat(captain): cliente Captain ↔ Hermes (outgoing job + callback endpoint)
Implementa o lado Captain da integração Nível 2 (Hermes como cérebro).
Ativação por inbox via env var CAPTAIN_HERMES_INBOX_IDS — inboxes não
listadas seguem usando o orquestrador interno do Captain (Daniela_Reservas
etc) sem mudança alguma. Princípio "só adiciona, não retira".

Componentes:

- enterprise/app/services/captain/hermes.rb
  Módulo helper de config (env vars, URLs, secrets per-inbox).

- enterprise/app/services/captain/hermes/client.rb
  Service que monta payload (msg + contexto da conversa/inbox/contato) e
  faz POST autenticado via HMAC-SHA256 (X-Hub-Signature-256) no webhook
  do Hermes Agent (porta 8644). DispatchError em falha de rede/HTTP.

- enterprise/app/jobs/captain/hermes/outgoing_job.rb
  Wrapper Sidekiq do Client. Retry 3x em DispatchError.

- app/controllers/webhooks/captain/hermes_callback_controller.rb
  Recebe callback do plugin captain-http-callback do Hermes. Valida HMAC
  se CAPTAIN_HERMES_CALLBACK_SECRET setado, identifica conversation pela
  última pending da inbox (janela 5min) e cria mensagem outgoing.

- config/routes.rb
  Rota POST /webhooks/captain/hermes_callback (fora de /api/v1/accounts).

- enterprise/app/services/enterprise/message_templates/hook_execution_service.rb
  Branch novo no schedule_captain_response: se Hermes habilitado pra inbox,
  dispara HermesOutgoingJob; senão, fluxo Captain interno como antes.

Env vars (todas opcionais; sem set = Hermes desabilitado em todas inboxes):
- CAPTAIN_HERMES_INBOX_IDS (CSV de inbox.id)
- CAPTAIN_HERMES_WEBHOOK_BASE_URL (default http://172.17.0.1:8644)
- CAPTAIN_HERMES_CALLBACK_SECRET (HMAC validar callbacks de entrada)
- CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id> (HMAC assinar saídas)

Limitação: identificação da conversation no callback usa última pending
da inbox dentro de 5min. OK pra PoC com 1 conversa de teste por vez. Em
produção, melhorar mapeando delivery_id ↔ conversation_id em Redis.

Próximo passo manual (admin VPS): criar subscription no Hermes:
  hermes webhook subscribe captain-inbox-1 \\
    --prompt 'Cliente disse: {message}. Responda como Daniela ...' \\
    --deliver http_callback \\
    --deliver-chat-id 'http://CAPTAIN_HOST/webhooks/captain/hermes_callback?inbox_id=1'

Depois set CAPTAIN_HERMES_INBOX_IDS=1 + CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_1
no stack do Captain e testar pela inbox Angelina.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:22:22 -03:00
Rodribm10
89b471831d feat(hermes): plugin captain-http-callback (HTTP delivery adapter)
Adiciona plugin externo pro Hermes Agent que entrega a resposta do agente
como POST HTTP a uma URL configurável — em vez de empurrar pra plataforma
de mensageria (Telegram, Slack, etc) como o Hermes faz por default.

Por quê:
O Hermes nativamente entrega respostas em plataformas conhecidas. Quando
integramos o Hermes como cérebro de outro backend (Captain / Chatwoot),
precisamos da resposta de volta via HTTP pro backend continuar o fluxo
(mandar pro cliente WhatsApp, atualizar conversa, etc). O Hermes não tem
deliver type "http_callback" built-in, então criamos via API de plugin
oficial deles (kind: platform).

Arquivos:
- plugin.yaml — manifest (kind=platform)
- __init__.py — entrypoint (re-exporta register)
- adapter.py — HttpCallbackAdapter implementando BasePlatformAdapter
- README.md — uso, formato do POST, signing HMAC opcional

Como funciona:
1. Backend (Captain) → POST /webhooks/<rota> no Hermes (entrada)
2. Hermes processa via Codex/Anthropic/Gemini conforme config dele
3. Hermes invoca este plugin (deliver=http_callback)
4. Plugin POSTa resposta na URL configurada via --deliver-chat-id
5. Backend recebe e roteia pro destinatário real

Validado end-to-end no Hermes da VPS com:
- Subscription criada via `hermes webhook subscribe ... --deliver http_callback`
- POST simulando msg do cliente → resposta chegou no servidor de teste
  em ~11s (tempo de processamento via subscription Codex)
- Plugin enabled e descoberto via `hermes plugins list`

Próximo passo (separado, em outro PR): cliente Captain (outgoing + incoming
endpoint) que conecta o Captain ao Hermes via este plugin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:13:15 -03:00
Rodribm10
7700afd508 feat(captain): adiciona Hermes Gateway como 3ª opção de LLM provider
Acrescenta valor 'openai_hermes_gateway' ao CAPTAIN_LLM_PROVIDER, sem mexer
nas opções existentes (openai_api e openai_codex_oauth continuam intactos).

Quando ativado, o Captain chama o Hermes Agent rodando em modo gateway HTTP
local (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877).
O Hermes faz o roteamento multi-modelo (Codex/Anthropic/Gemini) usando o
OAuth dele em ~/.hermes/auth.json — o Captain não precisa fazer OAuth direto.

Configs novas em installation_config.yml:
- CAPTAIN_HERMES_GATEWAY_URL — URL do gateway (default host.docker.internal:9877)
- CAPTAIN_HERMES_GATEWAY_MODEL — modelo no formato <provider>/<model>
- CAPTAIN_HERMES_GATEWAY_API_KEY — opcional, dummy se gateway local não exige

Embeddings e Files API continuam apontando pra OpenAI tradicional via
legacy_openai_settings — Hermes Gateway não expõe esses endpoints.

Specs cobrem: dummy key, custom api_key override, custom model, defaults,
trailing slash strip, light_model por provider, hermes_gateway? predicate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:24:31 -03:00
Rodribm10
9b8ef5a828 feat(captain): Dolce Amore como nova unidade + handoff silencioso PrimeAL/Dolce
Dolce Amore (Valentina, assistant 6):
- Novo assistant principal + 5 cenários (daniela_reservas, disponibilidade_suites,
  maria_fotos, outras_unidades, reclamacoes_ouvidoria) com tabela de preços
  motel-first (Apartamento, Master, Luxo, Temática, Mini Chalé 45, Chalé 2 Suítes,
  Chalé Master 4 Suítes, Suíte Ouro), regras Inter Pix, e tom adequado a motelaria.
- Cross-reference da Dolce nas 4 outras unidades (Express, PrimeAL, PrimeVL, Qnn01)
  no scenario outras_unidades, com aviso "use só se cliente perguntar por Natal".

Handoff silencioso PrimeAL + Dolce (Bianca + Valentina):
- Mensagem ÚNICA "Um momento." substitui as antigas frases robotizadas
  ("vou te encaminhar pra atendente local...").
- Nova regra "ROTEIE PRO CENÁRIO PRIMEIRO": orquestradora deve sempre tentar
  rotear pra cenário (handoff_to_daniela_reservas, handoff_to_maria_fotos, etc)
  antes de considerar handoff humano.
- Nova regra "NA DÚVIDA, TRANSFERE": após descartar todos os 6 cenários, se a
  pergunta não cabe em nenhum deles, handoff silencioso pra humano.
- Proibição explícita de mencionar nomes de cenário (Daniela, Maria) pro cliente.
- maria_fotos: REGRA #0 — se foto pedida não está na galeria (numeração inexistente,
  característica fora do mapa, área não-suíte), responde "Um momento." + handoff
  em vez de oferecer alternativa.
- daniela_reservas: novo gatilho de handoff "pergunta sobre reserva fora do prompt".

Rollback documentado em docs/captain/rollbacks/2026-05-01_handoff_silencioso.md
com snapshot completo dos prompts ANTES da mudança em prompts_snapshot.json,
permitindo reversão via update_columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:08 -03:00
Rodribm10
1b31e88934 chore(captain): ajustes de unit + migration + schema + seed README
Pequenos ajustes em Captain::Unit (app + enterprise), migration de seed
inicial dos prompts Jasmine/Daniela, schema regenerado, e atualização do
README de seed_prompts pra refletir o estado atual dos modelos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:38 -03:00
Rodribm10
d12d8bc0b6 feat(reports): mantém filtros do BotReports na URL sem trocar de rota
BotReports agora carrega inboxes no mount e passa navigate-on-entity-filter=false
pro ReportFilters, que ganha prop pra atualizar a URL com os filtros aplicados
sem disparar router.push pra outra rota. Permite compartilhar/recuperar visão
filtrada do BotReports via URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:10 -03:00
Rodribm10
4e798944cf fix(captain): provision unit via RPC SECURITY DEFINER (RLS bypass)
Anon key não tinha permissão de INSERT em reserva_hotel.unidades — RLS
exige authenticated + tenant_member, não atendido. POST direto falhava
sem feedback útil.

Solução: RPC reserva_hotel.provision_unidade(...) com SECURITY DEFINER
que faz upsert idempotente bypassando RLS, com validações de tenant +
marca dentro da função. EXECUTE granted to anon.

Service agora chama /rpc/provision_unidade em vez de POST /unidades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 09:11:57 -03:00
Rodribm10
c5cd15665e feat(captain): provisionamento automático de Captain::Unit em reserva_hotel.unidades
Hook after_commit on:create no Captain::Unit dispara
ProvisionUnitInSupabaseJob, que upserta a unit em reserva_hotel.unidades
via Supabase REST (UNIQUE on tenant_id+chatwoot_unit_id) e grava IDs no
Captain::Unit (supabase_unit_id, supabase_tenant_id, supabase_marca_id).

Sem isso, criar nova unidade no painel Pix não habilitava roleta — a row
no Supabase ficava ausente e OfferService caía em "tenant não resolvido".

Inclui rake captain:reprovision_unit_in_supabase[id] + provision_all
pra reconciliação manual e migration retroativa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:21:20 -03:00
Rodribm10
e7f3723938 fix(captain): Accept-Encoding identity nos clients Supabase (gzip silencioso)
Supabase REST manda response gzip por default. Faraday default não tem
middleware de descompressão, então JSON.parse(gzip_bytes) explodia em
JSON::ParserError, capturado pelo rescue → array vazio silencioso.

OfferService#fetch_unidade retornava [] mesmo com row presente,
caindo em "Sem unidade vinculada — tenant não resolvido".

Fix em offer_service, weekly_report_service, notify_revealed_job e
notify_revealed_scheduler_job. (get_reserva_preco_tool já tinha o fix.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:21:10 -03:00
Rodribm10
f0db8b0361 fix(captain): captain_inbox.unit -> captain_unit (bug bloqueante roleta + tools)
CaptainInbox.belongs_to :captain_unit (não existe método .unit).
OfferService quebrava antes de criar draw — roleta nunca disparava
em prod mesmo após pix confirmado. Mesmo bug em get_reserva_preco_tool
e create_reservation_intent_tool (silenciosamente caíam em fallback nil).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 23:11:00 -03:00
Rodribm10
f8709ab7a5
fix(captain): evita callback de embedding ao excluir FAQ (#13)
Co-authored-by: Kilo-Oracle <kilo-oracle@kilo-stack.local>
2026-04-30 13:14:48 -03:00
Rodribm10
c8574d2d7c
chore: adiciona comentário de status no README (#12) 2026-04-29 22:23:30 -03:00
Rodribm10
4f31e11b86
Merge pull request #11 from Rodribm10/kilo/auto-fix-erro-adicionar-faq
fix(captain): evita erro ao adicionar FAQ em conversa
2026-04-27 14:52:06 -03:00
Kilo-Oracle
60079a1b9e fix(captain): evita erro ao adicionar FAQ com pergunta longa
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-04-27 15:12:09 +00:00
Rodribm10
21f5fcce6a fix(retention): cohort endpoint 500 — Pundit policy + SQL binding
Dois bugs que faziam o cohort retornar 500 e a página de Retenção mostrar
"Falha ao carregar cohort":

1. `Captain::AssistantPolicy` não tinha `cohort?` → Pundit batia em
   NoMethodError no `check_authorization`. Adicionado como leitura pública
   da assistente, igual `show?`/`playground?`.

2. `RetentionCohortService#cohort_activity` chamava `exec_query(sql, name, [@account.id])`
   passando array de valores onde a API espera bind objects. A SQL ainda
   interpolava `account_id = $1` direto na string (sem placeholder ligado).
   Migrado pra `ActiveRecord::Base.sanitize_sql_array` com `?`, igual ao
   resto da base. Mantém parametrização e remove acoplamento com posicional.

Validado em prod via hot-patch (USR2): GET retention/cohort agora 200 com
3 cohorts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:46:20 -03:00
Rodribm10
d831ee4d33 feat(reports): Painel Diretoria — Onda 1A (leitura)
Primeira onda do roadmap de indicadores executivos do Grupo Nova. Mede
ADOÇÃO DO CANAL DIGITAL, não a operação total — banner explícito alerta
que reservas fechadas manualmente na recepção ainda não estão capturadas
(Onda 1B vai adicionar marcação manual via botão na conversa).

Backend:
- V2::Reports::ConversionFunnelBuilder — leads (novo/retorno/total),
  reservas (criadas != draft, pagas in active/completed/confirmed),
  taxas de conversão. Filtro opcional por inbox.
- V2::Reports::InboxBenchmarkingBuilder — uma linha por inbox com
  brand_name (via Captain::UnitInbox -> Unit -> Brand)
- Endpoints GET /reports/conversion_funnel e /reports/inbox_benchmarking
- RSpec do ConversionFunnelBuilder

Frontend:
- Rota top-level Reports → Painel Diretoria
- DirectoryDashboard.vue: banner de adoção + filtros + cards + funil + tabela
  benchmarking agrupada por marca com variação vs média
- API client getConversionFunnel + getInboxBenchmarking
- i18n EN + PT

Memórias suporte: feedback_metricas_adocao_canal.md + project_painel_diretoria_roadmap.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:44:59 -03:00
Rodribm10
617eadbfe4 feat(reports): breakdown auto/manual no BotReports + nomes corretos
Engrenagem fechada e nomes que casam com o que a métrica mede:

Cards superiores (taxas):
- "Tempo de resolução" -> "Resolvidas pelo bot %"
  Tooltip: bot resolveu sozinho (sem humano via Chatwoot ou WhatsApp) / total
- "Taxa de entrega" -> "Transferidas pra humano %"
  Tooltip: agora soma auto (Jasmine chamou) + manual (humano respondeu sem
  handoff explícito). Junto com a resolução, fecha ~100%.

Cards de detalhe (segunda linha, contagem absoluta):
- "Resolvidas pelo bot" — quantas o bot fechou sozinho
- "Transferência automática (Jasmine)" — bot_handoff explícito (loop, timeout,
  max turns, intent)
- "Tomada manual (agente)" — humano respondeu (UI ou WhatsApp echo) SEM a
  Jasmine ter chamado bot_handoff. Era o "bucket fantasma" antes.

Backend:
- BotMetricsBuilder.metrics inclui bot_resolutions_count, auto_handoffs_count,
  manual_takeovers_count
- handoff_rate agora é (auto + manual) / total — daí a engrenagem fechar
- manual_takeovers_count: conversas com mensagem outgoing humana
  (sender_type='User' OR NULL) MENOS as que tiveram conversation_bot_handoff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 12:01:50 -03:00
Rodribm10
e9b8b6e587 feat(reports): filtro por inbox no Relatório do Bot
Hoje as métricas e séries do BotReports agregam toda a conta — não dá pra ver
"a Jasmine da PrimeAL está errando mais que a do Qnn01". Cada unidade tem
prompt próprio, então um sintoma localizado precisa de medição localizada.

Backend:
- Inbox#has_many :reporting_events (relação inversa que faltava)
- BotMetricsBuilder aceita inbox_id e filtra bot_conversations + base_reporting_events
- bot_metrics endpoint passa inbox_id pelos params permitidos
- count_report_builder já suporta scope=inbox; agora funciona pra
  bot_resolutions_count e bot_handoffs_count graças à relação acima

Frontend:
- BotReports.vue: ReportFilters com filter-type='inboxes' e dropdown ativo
- Quando uma inbox é escolhida, requestPayload inclui inboxId/type/id e os
  fetches (BotMetrics + ReportContainer) passam o filtro
- API client getBotMetrics aceita inboxId; getBotSummary aceita type+id
- Sem inbox selecionada: comportamento antigo (agregação da conta)

Bonus na rake task de retroativo:
- rebuild_bot_resolved.rake: Message.unscope(:order) pra evitar conflito
  PG::InvalidColumnReference (DISTINCT + ORDER BY default scope)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:47:46 -03:00
Rodribm10
7cd2ea1258 fix(reports): tratar resposta humana via WhatsApp como interação humana
Bug do BotReports descoberto pelo Rodrigo:
- A regra de "conversation_bot_resolved" só desqualificava conversas com
  outgoing.sender_type='User' (atendente respondendo pelo Chatwoot UI)
- Mas mensagem outgoing vinda do webhook WhatsApp com IsFromMe=true (atendente
  respondeu direto pelo celular do hotel) é gravada com sender=nil
- Resultado: a Jasmine ganhava crédito mesmo quando humano respondia fora
  do Chatwoot. Taxa de resolução pelo bot inflada.

Fix prospectivo:
- ReportingEventListener#create_bot_resolved_event agora desqualifica via
  human_outgoing_messages? (sender_type='User' OU sender_type IS NULL)
- Captain::Assistant (a Jasmine) usa sender_type='Captain::Assistant' e segue
  fora do filtro, como antes
- Spec novo cobrindo o caso WhatsApp echo

Retroativo:
- lib/tasks/rebuild_bot_resolved.rake — task idempotente que purga
  reporting_events de conversation_bot_resolved gerados sob a regra antiga.
- DRY-RUN por padrão, APPLY=true pra deletar, ACCOUNT_ID pra restringir,
  SNAPSHOT_PATH pra trilha de auditoria

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 11:35:08 -03:00
Rodribm10
429567495f fix(reports): inbox_id reativo + nome da inbox visível na tab Novas × Retorno
Bugs originais (tabela Reports → Inbox → unidade específica → Novas × Retorno):
1. Backend recebia sempre inbox_id=1 — useFunctionGetter('inboxes/getInboxById', route.params.id)
   passava string crua, não Ref reativa, então o getter ficava travado no ID inicial
2. UX: tab não mostrava qual unidade estava sendo filtrada

Correções:
- InboxReportsShow.vue: passa inboxIdParam computed pra useFunctionGetter (reativo agora)
- Passa inbox.name como prop pro InboxLeadsReport
- InboxLeadsReport.vue: header com título + label "Caixa de entrada: <nome>" no topo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:14:44 -03:00
Rodribm10
3897db325e feat(reports): aba "Novas × Retorno" no Inbox Report
Mede por inbox/período: leads novos (1ª conversa do contato em qualquer
inbox da rede), retorno (conversa anterior resolved há >24h) e outras
(conversa anterior open ou resolved <24h). Categorias somadas batem com
o conversations_count nativo do report — bucket "outras" garante o
fechamento.

- Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única
- Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary
- Tabs no InboxReportsShow (Visão Geral | Novas × Retorno)
- Componente InboxLeadsReport com 3 metric cards + barras empilhadas
- API client + Pinia (state/getters/actions/mutations)
- i18n en + pt_BR
- RSpec do builder cobrindo classificação e isolamento por inbox

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:22:43 -03:00
Rodribm10
3336c68ee3 docs(precos): renomeia arquivos pra refletir escopo POR MARCA
- 1001_noites/precos_qnn01.md → precos_marca_1001_noites.md
- express/precos_express_al.md → precos_marca_express.md
- prime/precos_marca_prime.md (já estava OK)

Conteúdo reorganizado pra deixar explícito:
- Preços são iguais entre unidades da mesma marca (per feedback_prompt_scope_by_brand)
- Variação por unidade é só na DISPONIBILIDADE de categorias (ex: Pole Dance só em algumas 1001 Noites; Qnn01 não tem)
- Lista unidades cadastradas vs pendentes no Captain
- Aponta sync espelhado no Obsidian (Grupo 1001 Innova/Tabelas de Preço/)

README.md atualizado refletindo nova estrutura.
Não muda comportamento da Jasmine — só doc.
2026-04-25 20:02:55 -03:00
Rodribm10
ffeb1aa65a docs(captain): histórico de fixes pra evitar retrabalho do Reviewer
Cria docs/captain/historico_fixes.md com registro estruturado (YAML) das
correções aplicadas nos prompts/infra do Captain. Pré-popula com os 2 fixes
de hoje (v99 - preços Express/Qnn01 + sync automático; v100 - comportamento
humano + valores curto).

Pra que serve:
- Auditoria humana (saber o que mudou quando e por quê)
- Defesa contra Captain Reviewer redescobrir bug já corrigido (comparar
  data da conversa-fonte com data do fix correspondente)
- Base pra eventual Nível 3 do Reviewer (filtrar conversas anteriores ao
  último commit em _modelos/ via git log)

Não muda comportamento da Jasmine — é doc + infra passiva.
2026-04-25 17:13:35 -03:00
Rodribm10
6eb7f99ea4 feat(captain): comportamento humano + fix "valores curto" nas 4 Jasmines
Bug raiz observado em conversa real (Rayssa Lorranny / PrimeAL, 25/04 12:44):
- Jasmine alucinou "não tenho a tabela exata por horas aqui neste momento"
- Pediu "qual dia?" quando cliente disse só "Valores" (deveria mostrar tabela completa)
- Mencionou "tabela qui-dom/feriado" entre parênteses na resposta (entrega que é IA)

3 mudanças aplicadas nos 4 daniela_reservas (PrimeAL, PrimeVL, Qnn01, Express):

1. Nova seção "🤖➡️👤 SE COMPORTE COMO HUMANA" no topo, com lista de frases
   proibidas (todas que entregam IA) e exemplos de substituição humana
2. Nova "🚨 REGRA DE OURO — VALORES CURTO" antes de B): cliente disse só
   "valor"/"valores"/"preço" sem especificar → manda tabela completa direto,
   nunca pergunta dia/suíte primeiro
3. 3 proibições novas em "🚫 Proibições": dizer que não tem tabela,
   mencionar "tabela qui-dom/seg-qua" na resposta, responder pergunta com pergunta

Inclui também o questionario_nova_unidade.md já postado no Mattermost top-team.

Refs: análise da conversa Rayssa Lorranny / Operacoes 2026-04-25
2026-04-25 13:52:34 -03:00
Rodribm10
fc2663be2c feat(captain): tabelas de preço corrigidas + sync automático em deploy
- Express: adiciona Singles/Família/Singles Duplo, Master qui-dom 5h, diárias preço único
- Qnn01: renomeia Master→Luxo, remove Pole Dance e 12h, Hidromassagem preço único
- PrimeAL/PrimeVL: estrutura seg-qua + qui-dom, Pernoite Especial Prime, hora excedente
- Adiciona docs/precos/ com tabelas oficiais por marca pra consulta humana
- Implementa rake task captain:sync_prompts lendo de _modelos/
- Liga a task no chatwoot_prepare → sync automático em todo deploy

Refs ROD-14 (Captain Review 2026-04-25)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:31:55 -03:00
Rodribm10
a682668f4f fix(wuzapi): força data URI octet-stream pra send_file
Wuzapi rejeita data URI com mime real (application/pdf) e exige
'data:application/octet-stream;base64,'. O tipo é inferido do FileName.
Imagens continuam usando o mime original.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 06:45:26 -03:00
Rodribm10
b97a56d43c fix(wuzapi): payload de /chat/send/document espera Document, não Body
Wuzapi retornou 400 'missing Document in Payload'. O campo correto pra
PDF é Document + FileName. Mantém Body/Filename pra fallback em versões
antigas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 06:38:11 -03:00
Rodribm10
5e7447f1d8 fix(wuzapi): troca /chat/send/file por /chat/send/document pra PDF
Wuzapi (asternic) atual usa /chat/send/document pra arquivos. O endpoint
/chat/send/file não existe (404), então PDF nunca chegava — propagado
como ECONNRESET pelo proxy. Mantém os antigos como fallback.

Também trata Errno::ECONNRESET como ConnectionError no http handler pra
ativar a cadeia de fallback se voltar a acontecer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 06:27:28 -03:00
Rodribm10
74aea5e2d5 fix(agents): converte Parameters pra Hash antes de chamar any?
ActionController::Parameters não tem .any?; chamar sem to_h quebra
com NoMethodError (500) ao salvar agente. Bug introduzido em b69fa21e5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:27:11 -03:00
Rodribm10
b69fa21e53 feat(aggressive-alert): filtro por inbox configurável por admin
Admin configura em Account Settings → Agents → Editar quais inboxes
disparam o banner agressivo (reopened + inatividade) pra cada agente.

- user.ui_settings.aggressive_alert_inbox_ids: null (todas) | [] (nenhuma) | [1,2,3]
- Filtro aplicado no actionCable.feedInactivityTracker, maybeTriggerAggressiveAlert
  e no hydrate do AggressiveConversationBanner
- Backend aceita ui_settings no agents#update e serializa em _agent.json.jbuilder
- UI no EditAgent com toggle "todas inboxes" + multiselect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:00 -03:00
Rodribm10
49a21c845b fix(aggressive-alert): usa last_non_activity_message no hidrate + debug flag
O hidrate do inactivityAlertTracker lia conv.messages pra achar a última
mensagem do cliente. Mas o serializer da listagem só expõe 1 mensagem nesse
array (e pode ser uma activity, que é filtrada), então conversas em 'open'
com cliente esperando resposta não entravam no tracker e o banner de 5/15/28
minutos nunca disparava.

Fix: findLastNonActivityMessage agora usa conv.last_non_activity_message
primeiro (campo dedicado do payload, já pré-filtrado pelo backend) e só
cai em conv.messages como fallback.

Também adicionada flag de debug opt-in em window.__AGGRESSIVE_DEBUG__ pra
facilitar diagnóstico futuro do tracker direto do DevTools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:41:00 -03:00
Rodribm10
6aa328e329 fix(aggressive-alert): hidrata tracker de inatividade no boot
Bug reportado em 2026-04-24: só o banner vermelho status→open
funcionava. 5/15/28min nunca disparavam.

Causa: o tracker só era alimentado via websocket ao vivo
(message.created). Se a msg do cliente chegou ANTES da aba carregar
(ou depois de F5), o tracker ficava vazio, setInterval nunca começava,
thresholds nunca disparavam.

Fix:
- Nova função `hydrateFromConversations(convs)` no tracker. Varre
  conversas em 'open', pega a última msg não-activity, se for de
  Contact registra com timestamp REAL (msg.created_at), não Date.now().
  Isso fecha o gap de tempo: se o cliente falou 7min atrás, o YELLOW
  já dispara na hora.
- AggressiveConversationBanner.vue tem agora `watch: allConversations`
  chamando hydrate toda vez que a lista muda (boot + F5 + navegação).
- parseCreatedAt() suporta Unix seconds + ISO.
- findLastNonActivityMessage() ignora mensagens de sistema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:00:38 -03:00
Rodribm10
d1fa5335e1 fix(whatsapp): remove janela de 24h pra providers Web (wuzapi, evolution)
A regra de 24h é da Meta Cloud API. Providers que conectam via
WhatsApp Web (baileys, zapi — já isentos; wuzapi, evolution — agora
isentos) permitem mensagem livre a qualquer momento.

Antes: agente via "Você só pode responder a esta conversa usando um
modelo de mensagem devido a Restrições de janela de mensagem de 24 horas"
e ficava bloqueado de digitar.

Agora: MessageWindowService.messaging_window retorna nil pros 4
providers Web, o que faz can_reply? retornar true incondicionalmente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 07:27:53 -03:00
Rodribm10
4b0e8c314e feat(aggressive-alert): escalada amarelo/laranja/vermelho + toggles
Banner agressivo passa de uma notificação só ("status→open") pra
um sistema de escalada baseado em inatividade quando o cliente é
o último a falar.

Níveis:
- 5 min sem resposta  → AMARELO, sem som
- 15 min sem resposta → LARANJA, beep 1x + notificação do SO
- 28 min sem resposta → VERMELHO pulsante + som em loop infinito
- status→open (reabertura) → VERMELHO imediato

Por conversa, o banner mostra um item com nome do contato, inbox
e contexto ("reabriu agora" / "15 min sem resposta"). Headline
grande e explicação clara sobre como limpa.

Comportamento do × dismiss:
- Antes: apagava o alerta de vez. Agente podia "fingir que viu".
- Agora: esconde temporariamente. Volta quando escalar (próximo
  threshold) ou nova mensagem. A única forma de LIMPAR de vez é
  responder o cliente (tracker detecta msg outgoing do User ou
  AgentBot e chama dismissForReply).

Permissões:
- account.settings.aggressive_alert_enabled (master switch admin)
- user.ui_settings.aggressive_alert_enabled (toggle do próprio agente)
- Default true pros dois; um false em qualquer bloqueia alertas.

Settings UI:
- Conta → General: novo card "Alerta agressivo (master switch)"
- Perfil do usuário: novo card "Receber alertas agressivos"

Arquivos:
- helper/aggressiveAlert.js: multi-level state, hide vs dismiss-for-reply
- helper/inactivityAlertTracker.js: timer único, thresholds declarativos
- helper/actionCable.js: hook em onMessageCreated (feed tracker) +
  isAggressiveAlertEnabled() + limpa tracker em status_changed != open
- components/app/AggressiveConversationBanner.vue: variantes de cor,
  headline grande, explanation, × temp-hide
- account.rb + accounts_controller.rb: store_accessor + permitted
- settings UI components (account + profile): switches auto-persist

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:49:24 -03:00
Rodribm10
f35c3ea821 fix(aggressive-alert): dispara em qualquer transição pra 'open'
Race condition: quando o próprio usuário reabre a conversa, o dispatch
HTTP (toggleStatus) comita CHANGE_CONVERSATION_STATUS no Vuex ANTES do
broadcast actionCable chegar. Aí o check previousStatus === 'open'
bloqueava o alerta porque o store já estava em status=open.

Broadcast conversation.status_changed (app/listeners/action_cable_listener.rb
linha 103) só é emitido em transição real. Conversa nova entra via
onConversationCreated, não por status_changed. Não precisa do lookup.

Removido: getConversationById + guarda early-return por previousStatus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:13:57 -03:00
Rodribm10
2b9ada259e feat(dashboard): aggressive alert on conversation reopening
Banner persistente + som em loop + OS notification + title flash
+ vibração mobile quando conversa transiciona pra 'open' vindo de
pending/snoozed/resolved. Exige interação pra dismissar — atendente
não perde evento de reabertura.

- AggressiveConversationBanner.vue: banner full-width no topo,
  dismissable, mostra nome do contato + inbox + status anterior
- aggressiveAlert.js: manager do som (loop infinito), title flash
  (intervalo 1s), Notification API (requireInteraction: true),
  navigator.vibrate (padrão 500-200-500-200-500)
- actionCable.onStatusChange: detecta transição pra 'open' e dispara
  trigger via BUS_EVENTS (só se status anterior ≠ open, pra não
  alertar conversa nova criada já em open)
- i18n pt_BR + en: chaves de notificação (title/body/dismiss)
- busEvents: AGGRESSIVE_ALERT_TRIGGER + AGGRESSIVE_ALERT_DISMISS

Camada 1 da feature. Camada 2 (escalation SMS/WhatsApp se não
dismissar em X segundos) fica pra outro PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:03:26 -03:00
Rodribm10
34d42dfbbd feat: remove Notificações Automáticas + ajusta assinatura WhatsApp
1. Captain — remove feature Notificações Automáticas (captain_notification_templates)
   Feature órfã: 0 registros em prod, substituída pela Jornada do Cliente
   (Lifecycle). Sem dependências fora dela própria. Removido:
   - Vue: routes/settings/captain/notifications/ + entry no captain.routes.js
   - Sidebar: item "Notifications" do Captain menu
   - Store: modules captainNotificationTemplates + import no store/index
   - API: api/captain/notificationTemplates.js
   - Controller: api/v1/accounts/captain/notification_templates_controller
   - Model: Captain::NotificationTemplate
   - Job: enterprise/app/jobs/captain/notifications/
   - Routes: resources :notification_templates no config/routes.rb
   - i18n: chaves CAPTAIN_SETTINGS.NOTIFICATIONS + SIDEBAR.CAPTAIN_NOTIFICATIONS
     em pt_BR e en (captain.json + settings.json)

   Tabela captain_notification_templates mantida (0 rows, sem consumidores).
   Se quiser drop, criar migration separada depois.

2. WhatsApp — tira colchetes do prefixo de assinatura
   Era: *[ Jasmine(PrimeAL) ]*\ncontent
   Agora: *Jasmine(PrimeAL)*\ncontent
   Afeta wuzapi_service (outgoing) + incoming_message_wuzapi_service (echo)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:34:45 -03:00
Rodribm10
c0b54c6783 feat(prompts): modelos de Qnn01, PrimeVL e Express (3 assistants + 15 scenarios)
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Gerados usando o modelo validado do PrimeAL como base, adaptando:
- Nome do hotel, suítes, links (WhatsApp/Maps), saudação por unidade
- Tabela de preços específica de cada unidade
- Lista de outras unidades (exclui a própria, inclui as outras 8)
- Observação de atendimento exclusivo por unidade

Particularidades por unidade:
- Qnn01: 4 suítes (Standard/Master/Pole Dance/Hidromassagem), tabela seg-qua + qui-dom, tem 12h
- PrimeVL: 3 suítes (Stilo/Alexa/Hidromassagem), tabela seg-qua + qui-dom-feriado, tem 1h e hora excedente
- Express: 2 suítes (Standard/Master), tabela seg-qua + qui-dom, redireciona pra Prime quando cliente pede hidro

reclamacoes_ouvidoria.md é idêntico nas 4 unidades (framework LAST é universal).

Testado em staging: aplicado nos 3 assistants respectivos, scenarios novos criados (outras_unidades + Reclamacoes_Ouvidoria), FAQs de blocos de prompt deletados, FAQs de preço duplicados removidos. Aguardando validação via WhatsApp real.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:18:13 -03:00
Rodribm10
86bee38474 chore(prompts): reorganiza pastas (_prod_snapshot→_producao_atual, _staging_current→_modelos) e prefixa arquivos por unidade
- Renomeia _prod_snapshot → _producao_atual (refletindo melhor o papel: snapshot do que está rodando hoje em prod, só leitura)
- Renomeia _staging_current → _modelos (modelos aperfeiçoados que vão virar nova prod)
- Todos os arquivos em _modelos/ agora usam o prefixo jasmine_<slug>__ (ex: jasmine_primeal.md), seguindo a mesma convenção já usada em _producao_atual/
- Atualiza README com a nova convenção e checklist de validação por unidade

Isso prepara a estrutura pra adicionar modelos das outras 3 unidades (Qnn01, PrimeVL, Express).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:17:33 -03:00
Rodribm10
0ecfce5c27 fix(captain): translate response_format to text.format on Codex proxy
Sem isso o Codex devolvia texto puro e o reaction_emoji do JSON
estruturado nunca chegava ao ResponseBuilderJob — quebrava a
ferramenta de reagir mensagens com emoji.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:47:09 -03:00
Rodribm10
9e8550dd45 feat(captain): CAPTAIN_CODEX_MODEL_OVERRIDE pra usar modelos fora do catalog do RubyLLM
Adiciona sobrescrita de modelo no proxy. Motivação: o RubyLLM valida o modelo
contra um catalog interno antes de enviar a call. Modelos novos (gpt-5.4,
gpt-5.3-codex) ainda não estão nesse catalog e geram RubyLLM::ModelNotFoundError.

Com CAPTAIN_CODEX_MODEL_OVERRIDE definida, o Translator substitui o modelo do
body antes de enviar ao Codex. Captain continua passando um modelo reconhecido
(gpt-5.2), mas o Codex recebe o modelo real (gpt-5.4).

Exemplo:
  InstallationConfig.find_or_initialize_by(name: "CAPTAIN_CODEX_MODEL_OVERRIDE")
    .update!(value: "gpt-5.4", locked: false)

Validado: curl → proxy → Codex retorna "model":"gpt-5.4" no response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:55:22 -03:00
Rodribm10
b457e84c2f fix(captain): route embeddings to legacy OpenAI + retry transient errors
Resolve duas camadas de problema identificadas em teste end-to-end:

1. Embeddings falhavam com HTTP 404 (/codex/v1/embeddings não existe).
   Solução: Captain::Llm::EmbeddingService sempre usa OpenAI tradicional
   via Llm::Config.with_api_key(legacy_settings). ProviderConfig expõe
   legacy_openai_settings pra isso.

2. Servidor Codex ocasionalmente responde com response.failed +
   code=server_error (instabilidade transitória). Client agora retenta
   até 2x com backoff exponencial (0.5s, 1.5s) em erros retryable:
   HTTP 5xx, server_error no response.failed, ou stream inacabado.

Outras correções nesta etapa:
- Scenario#agent_model: em modo Codex, ignora CAPTAIN_OPEN_AI_MODEL_SCENARIO
  (que pode ter gpt-4o legado) e usa ProviderConfig.model.
- ExtractionService/ContradictionCheckerService/TranslateQueryService:
  trocam constantes hardcoded gpt-4o-mini/gpt-4.1-nano por
  ProviderConfig.light_model (respeitando o provider ativo).
- ProviderConfig.DEFAULT_CODEX_MODEL agora é gpt-5.2 (reconhecido pelo
  RubyLLM; gpt-5.4 não está no catalog do gem).

Validado ponta-a-ponta: WhatsApp → Chatwoot → Jasmine → handoff Daniela
→ faq_lookup com embedding OK → resposta com preços corretos.

Docs em docs/captain-codex-oauth.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:42:31 -03:00
Rodribm10
26290c34a7 feat(captain): feature flag CAPTAIN_LLM_PROVIDER + ProviderConfig central
Adiciona o toggle openai_api | openai_codex_oauth. Por padrão mantém
comportamento legado (API key OpenAI tradicional). Quando mudamos pra
openai_codex_oauth, os clientes (RubyLLM + Agents gem) passam a
apontar para o proxy interno em http://localhost:3000/codex,
configurável via CAPTAIN_CODEX_PROXY_URL.

- Captain::Llm::ProviderConfig: single source of truth de api_key,
  api_base e model, baseado em CAPTAIN_LLM_PROVIDER
- config/initializers/ai_agents.rb refatorado
- lib/llm/config.rb refatorado
- 8 specs do ProviderConfig passando
- Fallback seguro: api_key dummy ('codex-oauth') quando usando proxy
  (o proxy ignora Authorization e usa OAuth interno)

NÃO mexe no Llm::LegacyBaseOpenAiService (PDF/Files API). Esse
continua sempre na API tradicional porque o endpoint Codex não
expõe Files API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:29:52 -03:00
Rodribm10
d53c86df94 fix(captain): always include instructions in Codex responses body
Codex endpoint retorna HTTP 400 "Instructions are required" quando o
campo vem ausente. Agora sempre incluímos o campo — string com espaço
quando não há system message no request.

Validado end-to-end: curl → /codex/v1/chat/completions → proxy traduz
→ Codex devolve streaming SSE → proxy agrega → JSON Chat Completions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:27:37 -03:00
Rodribm10
928b1ec6b9 feat(captain): Codex OAuth auth module + proxy controller
Implementa Fases 1+2 do plano Captain Codex OAuth.

Fase 1 — Auth módulo:
- Migration captain_codex_credentials (tokens AR-encrypted)
- Model Captain::CodexCredential (singleton-ish com .current)
- Captain::Codex::AuthService com device flow completo:
  start_device_login, poll_once, exchange_for_credential,
  valid_access_token (auto-refresh), refresh!
- Rake task captain:codex:{login,status,refresh}
- Sidekiq job Captain::Codex::RefreshTokensJob rodando a cada 30min

Fase 2 — Proxy Chat Completions → Responses:
- Captain::Codex::Translator (chat ↔ responses, tools, tool_calls)
- Captain::Codex::Client (streaming SSE → agregado)
- Api::Internal::CodexProxyController expondo
  POST /codex/v1/chat/completions
- 10 specs do Translator passando

Próximo: Fase 3 (feature flag + fallback) e reconfiguração dos
clientes RubyLLM/Agents/ruby-openai pra apontarem pro proxy quando
CAPTAIN_LLM_PROVIDER=openai_codex_oauth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:07:01 -03:00
Rodribm10
df56ee8115 chore(captain): PoC Codex OAuth device flow + Responses streaming
PoC validado com conta ChatGPT Plus e client_id do Hermes. Device flow
OAuth funciona, gera access_token + refresh_token auto-refresh. Chat e
function calling funcionaram em gpt-5.4, gpt-5.4-mini, gpt-5.2 e
gpt-5.3-codex.

Descobertas pro adapter final:
- Endpoint: /responses (não /chat/completions)
- Streaming obrigatório (stream: true)
- store: false obrigatório
- Sem temperature/top_p (modelos reasoning)
- input[] no lugar de messages[]
- instructions top-level no lugar de system role
- Tools sem wrapping function: {}
- Output via events response.output_item.done (não response.completed)

Pasta scripts/captain_codex_poc/ está excluída do Rubocop (scripts
standalone, não rodam em contexto Rails).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:56:57 -03:00
Rodribm10
c512e3e5f6 chore(prompts): split prod snapshot from staging from target
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Reorganized db/seed_prompts/ into three clear bins:

  _prod_snapshot/   — 16 prompts pulled from iachat_production
                      (4 Jasmines + 12 scenarios). Read-only baseline.

  _staging_current/ — 6 prompts active in iachat-v2 right now
                      (Jasmine + 5 scenarios, including
                      outras_unidades and Reclamacoes_Ouvidoria
                      which were created on this branch).

  target/           — empty for now. Source of truth: the seed
                      migration only writes from here. Files we
                      review and approve land here, then deploy
                      pushes them to prod.

Updated the seed migration to walk target/ and to support both
generic scenarios (apply to every unit) and unit-scoped scenarios
(file prefixed with assistant slug, only that unit). Empty files
are skipped — useful for staged rollouts.

This guarantees no prompt ships to prod by accident: only what
ends up in target/ is applied.
2026-04-22 11:31:42 -03:00
Rodribm10
d0a2688dd2 chore(prompts): snapshot 16 production prompts + dynamic seed migration 2026-04-22 11:24:41 -03:00
Rodribm10
95d3e99652 feat(retention): version the Jasmine + Daniela prompts as seed files
The orchestrator prompt (Jasmine) and scenario instruction (Daniela)
live in the database. When we merge this branch to main and deploy to
production, the prod DB will keep its OLD prompts — the new ones would
only exist in staging. That defeats the point of merging.

Fix: commit the current staging prompts as .md files under
db/seed_prompts/ and add a data migration that syncs them into the DB
on deploy. Idempotent (no-ops when content already matches).

From now on, prompt changes follow the same workflow as code: edit the
.md file, migration resyncs on deploy. The DB row becomes a mirror of
the file, not the source of truth.
2026-04-22 11:00:06 -03:00
Rodribm10
6fa2f621fa feat(retention): UI layer — badge, filters, cohort matrix, KPI dashboard
- RetentionSummaryBadge in the "Previous conversations" sidebar:
  tiered status (First contact / Active / Recurring / Sleeping /
  At risk / Inactive) + counts of interactions, one-shots, Pix.

- Retention tab in Captain Reports: KpiCards, FlowCard, CohortMatrix
  (12x13 heatmap with CSV export).

- Five new filters on the contacts list: recurring, last interaction,
  days since, interactions count, reservations paid.

- Full pt_BR + en i18n under CAPTAIN_REPORTS.RETENTION.*

- Spec for InteractionCalculatorService covering gap behavior,
  one-shot classification, internal-label exclusion, multi-conversation
  grouping across the 30h window.

- Docs: docs/captain-retention-indicators.md with business rules,
  column reference, endpoint shape, and backup SQL queries.
2026-04-22 10:30:19 -03:00
Rodribm10
aed6d62640 feat(retention): summary KPIs + cohort endpoints
Exposes two JSON endpoints under /api/v1/accounts/:id/captain/reports:
- GET /retention — aggregate KPIs (active/recurring/sleeping/at-risk/
  churned, new vs returned in period, Pix generated/paid/conversion,
  retention rates at 30d and 90d)
- GET /retention/cohort — monthly cohort matrix, 12 months lookback,
  12 months of offset. Each cell is % of the cohort that interacted in
  month M+N. SQL-aggregated with DATE_TRUNC + DISTINCT so it is a
  single query even on large histories.
2026-04-22 09:59:21 -03:00
Rodribm10
f6488ce2de feat(retention): foundation for customer retention metrics
Lays the data + job foundation for tracking customer interactions,
recurrence, and Pix conversion on Contact. Design decisions negotiated
with Rodrigo (see docs to come):

Rules:
- Gap of 30h from last message defines separate interactions
- Qualified interaction = >=2 customer msgs + >=2 attendant msgs,
  both with textual content (>= 2 letters)
- One-shot consultation = >=1+1 but below the qualified threshold
  (tracked as secondary KPI)
- Excludes contacts labeled `equipe_interna`
- is_recurring = interactions_count >= 2
- pix_generated_count counts all PixCharges; reservations_paid_count
  only counts those with status = paid

Surface area:
- Migration adds denormalized stats to contacts + indexes for fast filtering
- Captain::ContactStats::InteractionCalculatorService computes the stats
  for a single contact (pure, no persistence)
- Captain::Retention::RecalculateContactStatsJob persists them for one
  contact (idempotent)
- Captain::Retention::RecalculateAllContactStatsJob runs daily at 3am BRT,
  enqueues per-contact jobs for everyone active in the last 120 days
- Event-driven refresh: CaptainListener#conversation_resolved enqueues
  recalc; Captain::PixCharge after_create/after_update enqueues recalc
  on status change

No UI yet — that's the next layer.
2026-04-22 09:50:23 -03:00
Rodribm10
08a06c6528 fix(captain): memory allows 'Solicitou Pix ..., aguardando pagamento'
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Previous commit made the extractor reject any reservation-shaped fact
without a literal payment confirmation in the conversation. That
killed the useful middle ground: a customer who requests a Pix and
hasn't paid yet is still a concrete signal worth remembering (for
follow-up, interest mapping, CRM). We were going from "hallucinated
reservation" to "nothing remembered".

Add the intermediate pattern:
- Payment confirmed → "Reservou X para Y em DD/MM/AAAA"
- Pix generated, no payment yet → "Solicitou Pix para X em DD/MM/AAAA, aguardando pagamento"
- Just a price quote → nothing

The "aguardando pagamento" suffix is required so the downstream recall
never confuses it with a closed reservation.
2026-04-22 05:01:24 -03:00
Rodribm10
d2c2c6b7fe fix(captain): pre-reservation semantics + no duplicate pix links
Three UX bugs from staging testing:

1. Duplicate Pix link in WhatsApp — the tool's formatted_message
   embedded the full link + instructions, so the LLM copied it into
   its own response on top of the dedicated link message sent by
   dispatch_direct_link_message. The tool now returns a short
   summary with no URL; dispatch is the single source of the link.

2. "Reserva confirmada!" sent before payment — the scenario prompt
   used the word "confirmação" loosely, which the LLM read as the
   reservation being closed. Now the prompt forces "pré-reserva /
   aguardando pagamento" until the Pix is actually paid, and the
   dispatched link message explains that the reservation is only
   secured after payment clears.

3. Memory extraction wrote "Reservou Hidromassagem para pernoite
   em 22/04/2026" when the customer only received a Pix link and
   replied "obrigado". Tightened the extraction prompt so
   padrao_comportamental of a reservation requires a literal
   payment confirmation — Pix generated alone no longer qualifies.
2026-04-22 04:19:39 -03:00
Rodribm10
6c9d12559d fix(captain): generate_pix returns success=false on real errors
When Inter integration fails ("Unit not configured for Pix", missing
certs, etc.), the tool was returning success=true with the error
message as formatted_message. The LLM interpreted that as success and
hallucinated "Pix generated" to the customer — and never triggered the
generate_reservation_link fallback.

Switch the rescue path from tool_feedback_response (success=true) to
error_response (success=false) so the Daniela scenario correctly falls
back to the reservation-link tool as documented in her prompt.
2026-04-21 18:59:45 -03:00
Rodribm10
ee2aae3958 fix(captain): generate_pix asks nome+CPF together, hydrates bare name
Root cause of the staging test failure:
- Tool asked for CPF then name separately, two back-and-forth turns.
- When the user replied with just "Rodrigo Borba Machado" (no "nome:"
  prefix), NAME_WITH_LABEL_REGEX didn't match, so the contact.name
  stayed as the emoji "😅‼️". The tool kept returning missing_name and
  the LLM eventually hallucinated success without another generate_pix
  call.

Changes:
- missing_identity_response combines nome + CPF into one prompt when
  both are missing.
- extract_name_from_qa_pattern finds the last outgoing message asking
  for "nome completo" and takes the next incoming message as the name
  candidate.
- extract_name_run_from_text pulls the leading alphabetic run from the
  message so "Rodrigo Borba Machado, 00251938131" parses the name
  correctly alongside the CPF.
2026-04-21 18:35:44 -03:00
Rodribm10
cfffea9c16 feat(captain): semantic memory fixes + roleta + reclamações + analytics
Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra
testar em staging antes do merge pra main.

## Correções de memória semântica
- ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção).
- Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora).

## Roleta da Sorte (end-to-end)
- Schema Supabase + 7 RPCs atômicas (server-side, idempotentes).
- Services: Offer, Redeem, WeeklyReport.
- Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago),
  NotifyRevealed + Scheduler de fallback.
- Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify.
- Dashboard /captain/roleta com Resgate + Relatório + anomaly detection.

## Cenário Reclamacoes_Ouvidoria
- Triagem P1-P4, framework LAST, Three-level listening, Self-check.
- Sem compensação material, detecção de cliente frustrado eleva prioridade.

## Analytics
- Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM.
- Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT).

## Trabalho pré-existente incluído
- Captain Executive Reports (ceo_digest, mattermost_delivery).
- get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos.

## Outros
- .gitignore: patterns pra credenciais.
- Migrations de scenarios idempotentes.
- i18n completa pt_BR+en pra roleta/funnel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:36:25 -03:00
Rodribm10
978ccbbdfb fix(captain): wrap runner.run in Timeout to guard HTTP hangs
Observed incident 2026-04-19 14:34: ResponseBuilderJob sat 156s
'Performing' in Sidekiq without ever emitting [Captain V2] Agent result,
while the client waited on WhatsApp. The runner.run() call never
returned — presumably an HTTP hang on the LLM side (OpenAI slow,
network flake, or retry storm inside ruby-llm).

Post-hoc protections (tool_loop_detected, max_turns) can't fire because
they only inspect result after run() returns. Adding a 45s hard timeout
on the run() block guarantees we bail out, trigger bot_handoff, and
respond to the client instead of hanging forever.

Rescue Timeout::Error separately so the log message is specific and
the user-facing message says "demorou mais do que o esperado".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:40:59 -03:00
Rodribm10
aa7da915e3 fix(captain): remove scenario->orchestrator back-handoff (ping-pong)
Problema observado em teste real 2026-04-19 11:24:
usuário forneceu suíte+data+hora pra Daniela. Em vez de chamar
generate_pix, Daniela chamou handoff_to_jasmine. Jasmine respondeu
"Vou te transferir pra Daniela..." — mentira, a conversa ficou
parada com a Jasmine.

Sequência dentro de UM único run:
  jasmine.handoff_to_daniela_reservas_agent
  -> daniela.handoff_to_jasmine (!)
  -> jasmine responde "vou te transferir..."

O prompt da Daniela tem "🚨 NUNCA FAÇA HANDOFF DE VOLTA PRA JASMINE"
mas o LLM ignora a proibição quando a ferramenta está registrada.
A única solução robusta é não registrar a ferramenta.

Historicamente tivemos medo de remover a back-edge porque sem ela
a Daniela (quando confusa) ficava em loop chamando faq_lookup —
incidente que queimou créditos reais. Esse medo não vale mais:
commit f3f8a8d5c adicionou TOOL_LOOP_THRESHOLD=3 +
MAX_TURNS_PER_MESSAGE=15 que disparam bot_handoff automático em
qualquer loop de tool. A proteção contra runaway existe por
OUTRA via agora, então podemos remover a back-edge com segurança.

Efeito esperado:
- scenario termina a resposta sozinho (sem ping-pong)
- scenario confuso/em loop -> rate limit corta -> humano recebe

Memory: atualizado feedback_never_touch_captain_without_safety_caps.md
refletindo a nova invariante.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:30:19 -03:00
Rodribm10
f3f8a8d5c1 feat(captain): rate limiting with runaway loop detection + bot_handoff
Três camadas de proteção contra runaway token burn no AgentRunnerService:

1. MAX_TURNS_PER_MESSAGE = 15
   Cap dentro de uma única chamada run(). Já estava aplicado;
   agora extraído como constante nomeada.

2. MAX_TURNS_PER_CONVERSATION = 30
   Cap ao longo da vida da conversa. Contador em
   conversation.custom_attributes['captain_turn_count']. Ao atingir,
   dispara bot_handoff automático e responde com mensagem de
   transferência pra humano.

3. TOOL_LOOP_THRESHOLD = 3
   Detecta a mesma (tool_name, args) invocada 3+ vezes no resultado
   de um único run (sintoma do loop faq_lookup que queimou tokens
   em 2026-04-19). Ao detectar: dispara bot_handoff e aborta o turno.

trigger_bot_handoff! aciona conversation.bot_handoff! quando
disponível, removendo a conversa do pipeline automático.

Motivação: dois incidentes reais de queima de crédito OpenAI em
2026-04-19. Ver memory/feedback_never_touch_captain_without_safety_caps.md
pras invariantes completas.

Tests atualizados: mock_result agora stuba :messages (usado pelo
novo tool_loop_detected?) e max_turns esperado é 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:16:54 -03:00
Rodribm10
7bc5103541 fix(captain): cap max_turns at 15 + restore scenario->orchestrator handoff
Runaway incident: Daniela (reservation scenario) entered a tool-calling
loop, invoking faq_lookup with the same query dozens of times per
second, stuck at 'Performing' in Sidekiq for minutes with 1-of-12 busy.
Root cause was two interacting factors:

1. The previous commit removed scenario_agent.register_handoffs(
   assistant_agent) to prevent ping-pong. In practice, the scenario LLM
   uses handoff_to_orchestrator as a safety valve when it cannot
   advance. Without it, the LLM kept calling other available tools
   (faq_lookup) indefinitely.

2. max_turns was 100. A runaway loop could burn 100 LLM + tool cycles
   before Sidekiq's timeout fired, which meant real token spend in a
   single bad turn could blow a day's budget.

Both restored/fixed:
- max_turns: 100 -> 15. Plenty for normal flows; hard ceiling on any
  runaway. The LLM simply ran out of turns and had to emit a final
  response instead of looping further.
- scenario -> orchestrator handoff: re-registered. Ping-pong risk is
  contained by max_turns AND by explicit prompt rules in the scenario
  instruction forbidding gratuitous handoffs (added to Daniela prompt
  in earlier commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:03:22 -03:00
Rodribm10
e5d186c689 fix(captain): stop scenario->orchestrator handoff + narrow FAQ guardrail
Two behavioural regressions caught in live testing with a real customer
conversation:

1. Ping-pong scenario -> orchestrator -> scenario

   build_and_wire_agents was calling scenario_agents.register_handoffs(
   assistant_agent), which exposed handoff_to_jasmine as a tool INSIDE
   every scenario. Daniela (reservation scenario) kept calling it mid
   flow, the orchestrator resumed the turn, and customers got messages
   like "Vou te encaminhar para a Daniela..." after ALREADY being with
   Daniela. The back-edge is removed. When a customer legitimately
   changes topic mid-scenario, pick_starting_agent on the next turn
   already routes back to the orchestrator based on conversation state,
   so no manual handoff from the scenario side is needed.

2. FAQ_PRICE_PATTERNS was hijacking legitimate routing responses

   The previous regex matched the bare words "pernoite", "sinal",
   "diaria" WITHOUT requiring a numeric price nearby. A legitimate
   handoff response like "Vou transferir para a Daniela para confirmar
   a Stilo para pernoite" tripped the guardrail, which then substituted
   the response with raw FAQ content about rates. Narrowed to: R$
   values, numbers followed by "reais", and the explicit price-noun
   variants (preco/preço/valor/preços/valores/custo/custa). Incidental
   mentions of stay types no longer trigger.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:51:45 -03:00
Rodribm10
fa758e4848 feat(captain): hierarchical model routing + conversation-level memory cache
Two orthogonal cost optimizations to the Captain agent pipeline:

1. Hierarchical model routing (optimization A)

Captain::Scenario now overrides agent_model to read a dedicated
InstallationConfig CAPTAIN_OPEN_AI_MODEL_SCENARIO, falling back to the
global CAPTAIN_OPEN_AI_MODEL used by the orchestrator (Assistant).

Rationale: the orchestrator (Jasmine) does cheap triage (is this a
reservation intent? a greeting? escalate to human?) — a smaller model
handles this well. Scenarios (Daniela — reserva) run complex flows with
tool calling, strict taxonomies, and JSON schema output — they benefit
from a stronger model.

Config in this install: CAPTAIN_OPEN_AI_MODEL=gpt-4o-mini (orchestrator)
and CAPTAIN_OPEN_AI_MODEL_SCENARIO=gpt-4o (scenarios). Estimated ~60%
cost reduction vs everything on gpt-4o, preserving quality where it
matters for the business flow.

2. Conversation-level memory cache (optimization B)

MemoryPromptInjector now persists the computed memory block on
conversation.custom_attributes[captain_cached_memory_block]. First turn
computes once (embedding + pgvector query + XML formatting); subsequent
turns reuse. The customer's profile does not change during an open
conversation, so re-running the pipeline on every turn was pure waste.

Graceful fallbacks:
- Cache write failure → per-service-instance in-memory fallback still
  applies.
- Cache read failure → fresh recall runs (no regression).
- Contact mismatch → invalidates cache, fresh recall runs.

When a new conversation starts, custom_attributes is empty → fresh
recall populates the cache for that conversation's lifetime.

Estimated ~80% reduction in embedding + pgvector calls during
multi-turn conversations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:47:15 -03:00
Rodribm10
bcf41ad15f fix(captain-memory): guard memory recall from blocking agent worker
Real-world test triggered a Sidekiq worker hang on conv 67 after a
message was routed through Daniela: two ResponseBuilderJobs (msg 1318
and 1319) started, emitted typing_on, then never returned. Sidekiq
showed 2/12 workers stuck for 10+ minutes — indefinite.

Root cause likely: Agents::Runner evaluates the orchestrator
instructions lambda multiple times per turn, and our wrapped lambda
calls MemoryPromptInjector#append_memory_block each time. Inside,
RecallService invokes OpenAI embedding API (2s timeout) and pgvector.
Ruby's Timeout.timeout has documented holes on net/http syscalls — if
the embedding API stalls at the socket level, the worker hangs forever
even though the timeout "fired".

Two fixes:

1. Per-message cache in the injector instance: the same
   message_text is embedded + queried once, not N times per turn.
   Dramatic reduction in network calls + DB queries during a single
   agent run. Every call after the first returns the cached block
   instantly.

2. Absolute rescue at append_memory_block top level:
   rescue StandardError => e; return base_prompt. Even if the whole
   memory pipeline throws, the base system prompt passes through and
   the agent keeps responding. Memory is NEVER allowed to block a
   response — that was already the design intent but the lambda caller
   path didn't honor it rigorously enough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:06:35 -03:00
Rodribm10
6330bec857 fix(captain-memory): temporal memory model + aggressive dedup
User feedback revealed a fundamental design issue: the memory model was
accumulating contradictory "Prefere X" facts because a single choice was
being treated as a permanent preference. Result: 3 different
"Prefere suite X" entries coexisting, all at 90% confidence, with
reservation patterns over time (2hrs, 4hrs, pernoite) all claiming to be
the customer's "preferred" duration.

Corrections:

1. ExtractionService prompt — preferencia now requires EXPLICIT
  declaration words ("prefiro", "gosto mais de", "sempre escolho",
  "adoro", "favorita"). A mere choice in one conversation is NO LONGER
  extracted as preferencia — instead it goes to padrao_comportamental
  WITH THE DATE in the content (e.g. "Reservou Alexa para pernoite em
  23/05/2026"). This makes memory temporal and auditable instead of
  imposing fake consistency.

2. Reference date is passed to the LLM prompt via the latest message
  timestamp, used as the anchor date the LLM must embed in every
  padrao_comportamental content.

3. ContradictionCheckerService — dual threshold:
  - cosine < 0.15 → auto-supersede without LLM (pure duplicate)
  - 0.15 to 0.6 → ask LLM if contradicts, supersede if yes
  - > 0.6 → ignore, unrelated facts
  Previously only the middle band existed, so near-duplicate facts like
  two "aniversário 23/05" entries or three "prefere suite X" entries
  were never cleaned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:30:42 -03:00
Rodribm10
b742d774c8 fix(captain-memory): block suite hallucinations + hardcode cadastral data exclusion
Real test revealed gpt-4o-mini was still:
- Hallucinating suite names ("Aluba" doesn't exist — we only have
  Alexa, Stilo, Hidromassagem)
- Extracting cadastral data as memory ("Rodrigo has a CPF", "Name is X")
  despite the per-type NÃO examples

Added two sections at the top of the prompt:
1. Business canonical data — explicit whitelist of suite names (Alexa,
  Stilo, Hidromassagem) and stay types. Anything else = discard, NO auto-
  normalization. LLM must not guess.
2. Cadastral data absolute rule — explicit list of fields that are
  profile data, not memory: name, CPF/RG/passport, email/phone/address,
  birth date. Plus 5 concrete  examples of what was being wrongly
  extracted in the wild.

Existing 9 specs still pass (stub at call_llm; prompt change is
semantic, not structural).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:06:31 -03:00
Rodribm10
4becfd0a57 fix(captain-memory): strict taxonomy definitions in ExtractionService prompt
Real-world test revealed the LLM extractor (gpt-4o-mini) was using type
labels too loosely: a customer's QUESTION about parking ("tem
estacionamento?") was classified as 'reclamacao'. Similarly cortesia
generica ("obrigado") was becoming 'feedback_positivo', and transactional
events (CPF informed, reservation made) were becoming memories when they
should be ignored.

Rewrote build_prompt with:
- Per-type strict definition (what it IS)
- YES/NO examples for each of the 9 types, with the most common pitfalls
  explicitly shown as NO
- 7 absolute rules, including: questions are never complaints, generic
  courtesy is never feedback, agent actions are never customer memory,
  transactional events are not long-term facts
- Confidence threshold guidance (>=0.9 only if totally explicit, 0.7-0.89
  for strong inference, <0.7 discard)
- "If in doubt, discard — quality > quantity. Most transactional
  conversations should return empty facts list"

Existing 9 specs still pass (stub call_llm, so prompt changes don't
affect unit test assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:44:26 -03:00
Rodribm10
6ecafd30c6 feat(captain-memory): redesign Contact Memories UI with type badges + relative time + fix i18n keys 2026-04-19 07:38:50 -03:00
Rodribm10
b07486c430 feat(captain-memory): wire Contact Memories section into conversation sidebar 2026-04-19 07:30:30 -03:00
Rodribm10
5874029a03 fix(captain-memory): raise RecallService timeout 0.5s -> 2.0s
Real-world observation: OpenAI embedding API takes 200-400ms typical,
plus pgvector query overhead, the 500ms budget was being exceeded
frequently, silently dropping memory recall. Agent typing delay is
already 2-15s humanized, so a 2s recall budget is well within UX
tolerance and gives ~4-5x margin over typical embedding latency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:25:19 -03:00
Rodribm10
1ce07cc78c docs(captain-memory): add operator guide for enabling Contact Memory flags (UI toggles deferred)
Documents the Rails console procedure to toggle
captain_contact_memory_extraction_enabled and
captain_contact_memory_recall_enabled on Account#custom_attributes,
including rollout phasing (extraction-first, then recall), rollback,
bulk enablement, and post-activation verification queries.

The UI toggles in Captain Settings are deferred: the existing
FeatureToggle component is coupled to the captain_features hash and
cannot be reused for custom_attributes-backed flags without a new
component and a new account-update store action. Scope and
implementation notes for that follow-up are included at the end of the
document.

Task 5.4 of Captain Semantic Memory epic (Phase 5).
2026-04-19 01:52:13 -03:00
Rodribm10
2f7d8edd92 feat(captain-memory): add Contact Memory UI component + API client + i18n
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 01:47:56 -03:00
Rodribm10
8444209952 fix(captain-memory): always authorize index even when list is empty 2026-04-19 01:43:57 -03:00
Rodribm10
f7d4c41d07 feat(captain-memory): add MemoriesController with index/update/destroy/bulk_destroy 2026-04-19 01:41:09 -03:00
Rodribm10
638e84752d feat(captain-memory): add ContactMemoryPolicy (Pundit) 2026-04-19 01:37:13 -03:00
Rodribm10
9c035722de test(captain-memory): end-to-end learning and recall integration test 2026-04-19 01:35:09 -03:00
Rodribm10
1cf9531741 fix(captain-memory): use Agent#clone instead of ivar mutation + unify test path with runtime 2026-04-19 01:32:56 -03:00
Rodribm10
85324f594d feat(captain-memory): inject semantic memory into AgentRunnerService system prompt 2026-04-19 01:23:03 -03:00
Rodribm10
e89b96d09b feat(captain-memory): enqueue extraction on conversation.resolved 2026-04-19 01:13:26 -03:00
Rodribm10
2261b09b25 feat(captain-memory): add HardDeleteExpiredJob with daily cron (LGPD) 2026-04-19 01:09:28 -03:00
Rodribm10
b3077b2b26 feat(captain-memory): add AgingJob with TTL + LRU cap, weekly cron 2026-04-19 01:05:02 -03:00
Rodribm10
fb6673664a fix(captain-memory): isolate per-account failures in SilenceDetectorJob + fix typo 2026-04-19 01:01:28 -03:00
Rodribm10
833e76856e feat(captain-memory): add SilenceDetectorJob with 10min cron 2026-04-19 00:55:15 -03:00
Rodribm10
1646f66a97 fix(captain-memory): wrap ExtractFromConversationJob persistence in transaction + hoist unit lookup 2026-04-19 00:50:08 -03:00
Rodribm10
9d5e4c959f feat(captain-memory): add ExtractFromConversationJob with TTL + idempotency 2026-04-19 00:45:14 -03:00
Rodribm10
350a420ee0 feat(captain-memory): add ContradictionCheckerJob 2026-04-19 00:39:52 -03:00
Rodribm10
dc366433bb feat(captain-memory): add UpdateEmbeddingJob 2026-04-19 00:35:06 -03:00
Rodribm10
6723473fdc fix(captain-memory): ContradictionChecker exact-match parsing + rescue wrap + LLM failure test 2026-04-19 00:31:54 -03:00
Rodribm10
9bc6429b91 feat(captain-memory): add ContradictionCheckerService with LLM verification 2026-04-19 00:26:58 -03:00
Rodribm10
aec796ebfd fix(captain-memory): cap ExtractionService input, validate scope, filter failed msgs 2026-04-19 00:24:09 -03:00
Rodribm10
9d593757df feat(captain-memory): add ExtractionService with evidence+confidence guardrails 2026-04-19 00:18:32 -03:00
Rodribm10
0fee1b3c2f fix(captain-memory): strengthen RecallService logging context and document timeout tradeoff 2026-04-19 00:14:06 -03:00
Rodribm10
502c3d1698 feat(captain-memory): add RecallService with timeout and graceful degradation 2026-04-19 00:09:31 -03:00
Rodribm10
5d15f55a29 feat(captain-memory): add PromptInjectionService formatting memories as XML 2026-04-19 00:05:11 -03:00
Rodribm10
e1273f142b feat(captain-memory): add Captain::ContactMemory model with scopes and lifecycle methods 2026-04-18 23:53:33 -03:00
Rodribm10
575af02aff feat(captain-memory): cascade delete memories on account/contact removal (LGPD) 2026-04-18 23:50:14 -03:00
Rodribm10
ca662a528c docs(captain-memory): document intentional omission of secondary FKs 2026-04-18 23:49:21 -03:00
Rodribm10
cae1ae36d6 feat(captain-memory): create captain_contact_memories table with pgvector index 2026-04-18 23:38:23 -03:00
Rodribm10
2bf68e5be8 feat(captain-memory): add feature flag helpers on Account 2026-04-18 22:10:10 -03:00
Rodribm10
effe6018e0 docs(plan): executable plan for Captain semantic memory epic
Plano multi-step com ~27 tasks divididas em 7 fases:
- Phase 0: feature flags foundation (Account helpers)
- Phase 1: migration + Captain::ContactMemory model
- Phase 2: 4 services (PromptInjection, Recall, Extraction, Contradiction)
- Phase 3: 6 jobs (Embedding, ContradictionChecker, ExtractFromConversation,
  SilenceDetector, Aging, HardDelete) + 3 cron schedules
- Phase 4: integracao no AgentRunnerService + listener conversation.resolved
- Phase 5: Controller + Policy + Vue component + i18n + settings toggles
- Phase 6: observabilidade (OTEL metrics + logs estruturados)
- Phase 7: docs operacionais + smoke test E2E + final review

TDD em todas as tasks. Frequent commits. Sem placeholders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:44:52 -03:00
Rodribm10
719448120a docs(spec): Captain semantic memory (episodic contact layer)
Spec do Epico A - adiciona Camada 3 (memoria semantica episodica do contato)
ao Captain AI, mantendo as 3 camadas existentes inalteradas.

Decisoes fechadas no brainstorming:
- Extracao ao resolver conversa OU silencio > 30min (100% automatico)
- Validacao: evidence obrigatoria, confidence >= 0.5 (alternativas B/C/D
  documentadas como fallback)
- Scope global no recall, atribuicao por source_unit_id pra relatorios
- 9 tipos iniciais, limite 5 fatos/conversa, 50 ativos/contato
- TTL por tipo + supersedencia automatica por contradicao
- LGPD soft-30d -> hard-delete via cron
- 2 feature flags independentes, default OFF
- Epico B (LangGraph/inteligencia) sera spec separado pos-producao

Custo estimado: ~R$ 47/mes no grupo todo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:37:32 -03:00
Rodribm10
6a5ba17bfc fix(captain): aceita DD/MM sem ano e amplia tratamento de requires_input no generate_pix
Problema observado: Daniela chamou generate_pix com arguments vazios apos
cliente informar "27/4". Tool retornou missing_fields=[check_in, amount] e
LLM caiu no fallback silenciosamente.

Correcoes:
- DDMMYYYY_REGEX agora aceita "DD/MM" sem ano (assume ano corrente, empurra
  pro proximo ano se a data ja passou)
- parse_date_without_year com fallback explicito
- Instruction da scenario Daniela_Reservas (DB, scenario_id=2) atualizada
  para listar todos os 4 parametros obrigatorios de generate_pix e
  distinguir requires_input (erro do LLM) de success=false (erro tecnico)

Backup da instruction anterior: /tmp/daniela_instruction_backup_20260418.txt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:37:17 -03:00
Rodribm10
1c21b8d815 fix: guard landing host sync when inbox has no portal
Inboxes without portal_id were crashing with NoMethodError on save,
blocking landing host creation via UI for any inbox without a portal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 22:49:35 -03:00
Rodribm10
8ea87027d1 fix: move captain_unit_factory_spec out of factories/ (was breaking rails runner boot) 2026-04-15 22:19:48 -03:00
Rodribm10
2e9551a0f3 feat(lifecycle): rules tab with templates, wizard and variable autocomplete
Implements Task 15: Rules.vue (template grid + rules table), RuleWizardDialog.vue
(4-step wizard: Quando/Pra quem/O quê/Revisão) and MessageEditor.vue (textarea with
{{ variable }} autocomplete). Adds WIZARD.CANCEL, OFFSET_UNIT_LABEL, STEP_LABELS and
REVIEW i18n keys in en and pt_BR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:15:00 -03:00
Rodribm10
94fdb5c318 feat(lifecycle): settings tab with guards form and concierge per unit
Replaces stub Settings.vue with full implementation: anti-spam guard
form (quiet hours, interval, pause-on-reply, opt-out label) and a
collapsible ConciergeUnitCard per unit (inbox selector, persona name,
knowledge base, key-value variables). Adds CONCIERGE_CONFIGURED /
CONCIERGE_NOT_CONFIGURED i18n keys to en + pt_BR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 11:05:40 -03:00
Rodribm10
ae4647d1c2 feat(lifecycle): history tab with paginated list and preview modal
Implements Task 13 — replaces the stub History.vue with a real paginated
table filtered by status, and adds DeliveryPreviewModal to show rendered_body.
Also extends i18n keys (TOTAL, PAGINATION, MODAL labels) in en + pt_BR.
2026-04-15 10:57:56 -03:00
Rodribm10
ad2255aba4 feat(lifecycle): sidebar entry for Jornada do Cliente 2026-04-15 10:53:33 -03:00
Rodribm10
65a76ed59d feat(lifecycle): parent view with TabBar + 3 stub children routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:51:09 -03:00
Rodribm10
bc85ec0a67 feat(lifecycle): Pinia/Vuex stores for rules/config/deliveries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:46:20 -03:00
Rodribm10
b69053ae62 feat(lifecycle): API clients for rules/config/deliveries + concierge update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:42:51 -03:00
Rodribm10
1459655243 feat(lifecycle): i18n keys for Jornada do Cliente UI
Adds CAPTAIN_LIFECYCLE block (en + pt_BR) to captain.json with full
key set for Rules, Wizard, Settings, History and sidebar entry.

Also stages pre-existing uncommitted additions to captain.json from
prior work (KPI, PILLS, QUICK_DATE, CARD, ACTIONS extras for
CAPTAIN_RESERVATIONS) — those were already in the working tree and
belong to the same feature branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:39:50 -03:00
Rodribm10
fa1dd8b6cb feat(lifecycle): expose concierge config update on UnitsController
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:35:03 -03:00
Rodribm10
0b195781c5 feat(lifecycle): REST endpoint for lifecycle deliveries audit log 2026-04-15 10:29:24 -03:00
Rodribm10
8690a49971 feat(lifecycle): REST endpoint for lifecycle config singleton 2026-04-15 10:23:42 -03:00
Rodribm10
7c17a7cb96 feat(lifecycle): REST endpoint for lifecycle rules CRUD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:17:59 -03:00
Rodribm10
cb67a1063d fix(lifecycle): move stub controllers from non-enterprise to enterprise path
Os stubs de lifecycle criados na task anterior estavam em app/controllers/
causando futura colisão de redefinição de classe quando os controllers reais
forem implementados em enterprise/app/controllers/ (tasks 4-6). Move os 3
stubs para o enterprise path onde vivem todos os controllers Captain.

Routing spec: 7 examples, 0 failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:13:33 -03:00
Rodribm10
fbc91e2fa8 feat(lifecycle): add REST routes for rules, config, deliveries, concierge
Wires 3 new captain namespace resources (lifecycle_rules, lifecycle_config,
lifecycle_deliveries) and a member action `patch :concierge` on units.
Includes stub controllers (to be expanded in Tasks 4-7) and passing routing spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 10:11:39 -03:00
Rodribm10
7d21530bc7 feat(lifecycle): add Pundit policies for rule/config/delivery 2026-04-15 10:06:47 -03:00
Rodribm10
b29b35465b feat(lifecycle): add Account associations for lifecycle models 2026-04-15 10:03:01 -03:00
Rodribm10
7da16f7722 docs(lifecycle): add backend handoff with gotchas and next-session prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:41:52 -03:00
Rodribm10
325f05c3eb fix(spec): captain_unit factory now auto-creates brand in matching account
Replaced broken `association :brand, factory: :captain_brand, account: account`
(FactoryBot cannot evaluate `account` lazily that way) with a transient block
that does `Captain::Brand.find_by(account_id: account.id) || association(...)`,
ensuring the brand always belongs to the same account as the unit.
Adds factory spec (6 examples) confirming standalone create, account override,
and brand reuse all work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:36:52 -03:00
Rodribm10
5639c3ae1d chore(lifecycle): add demo seed script for manual pipeline testing 2026-04-15 09:31:11 -03:00
Rodribm10
f302726d9b test(lifecycle): add end-to-end integration spec for scheduler→dispatch→send flow
Also fixes double-scheduling bug in scheduler_spec and delivery_spec caused by
after_create_commit hook firing while rules already exist — reservation is now
created before rules in setup so the hook finds nothing to schedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:29:52 -03:00
Rodribm10
7b009cf47f feat(lifecycle): inject concierge context into Captain orchestrator prompt
Adds concierge.* and reservation.* Liquid variables to agent_instructions
so Sofia's orchestrator_prompt receives unit persona/knowledge/variables
and reservation data resolved from conversation.custom_attributes.current_unit_id.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:25:16 -03:00
Rodribm10
d0d08ed662 feat(lifecycle): implement DispatcherJob
Replace no-op stub with full perform body: find delivery by id, skip if
blank, delegate to Captain::Lifecycle::Dispatcher#call. Add retry_on
with polynomially_longer backoff (3 attempts). Spec covers dispatcher
delegation and graceful skip for missing records.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 09:20:32 -03:00
Rodribm10
0d4583a21a feat(lifecycle): add Dispatcher service with guards→render→send pipeline
Orchestrates guards → render (Liquid) → send pipeline for one delivery.
Handles skip, reschedule, sent, failed states and re-enqueues on reschedule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:53:01 -03:00
Rodribm10
6d84a7586b feat(lifecycle): add MinInterval and CustomerReplied guards
Implement guards following the same pass/reschedule/too_stale pattern as QuietHours.
Also fix belongs_to :conversation on Delivery to use class_name: '::Conversation' to avoid namespace resolution failure inside Captain::Lifecycle module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:49:22 -03:00
Rodribm10
fcdc2054b5 feat(lifecycle): add QuietHours guard with 2h staleness limit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:44:39 -03:00
Rodribm10
823008a1cd feat(lifecycle): add Guards::Base e 3 guards simples (ReservationActive, OptOutLabel, MaxPerReservation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:42:10 -03:00
Rodribm10
f6aa39921a feat(lifecycle): add ContextBuilder for Liquid render variables
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:39:35 -03:00
Rodribm10
8e0a06246b feat(lifecycle): wire Captain::Reservation lifecycle hooks
Add after_commit callbacks to call Captain::Lifecycle::Scheduler on
create, status change (cancelled/no_show), and check_in_at change.
Each handler wraps in rescue StandardError to preserve existing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:37:23 -03:00
Rodribm10
bb4631f427 feat(lifecycle): add Scheduler service and DispatcherJob stub
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:35:31 -03:00
Rodribm10
4a88f7f517 feat(lifecycle): add EventResolver service
Pure function mapping reservation events to timestamps; used by Scheduler (T9) to compute fire_at.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:31:47 -03:00
Rodribm10
23a17599c4 feat(wuzapi): dispatch interactive messages (buttons/list/url_button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:30:31 -03:00
Rodribm10
7a203ccb6d feat(wuzapi): add send_buttons, send_list, send_url_button methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:28:00 -03:00
Rodribm10
a4472b80b9 feat(lifecycle): add concierge_* accessors to Captain::Unit 2026-04-15 01:23:40 -03:00
Rodribm10
41bbf14d57 feat(lifecycle): add Captain::Lifecycle::Delivery model with state helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:21:11 -03:00
Rodribm10
ffc5ac7fb8 feat(lifecycle): add Captain::Lifecycle::Rule model with filter matching
TDD: 16 examples passing. Adds EVENTS constant, active/for_event scopes,
and matches_reservation? with unit_ids/categorias/permanencias filters.
Also adds captain_reservation factory used by the spec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:18:17 -03:00
Rodribm10
6ee3fcd4ef feat(lifecycle): add Captain::Lifecycle::Config model 2026-04-15 01:14:19 -03:00
Rodribm10
1c89ef73ff feat(lifecycle): add captain_lifecycle_* tables and concierge columns on captain_units
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:10:49 -03:00
Rodribm10
13070bd197 docs: add Jornada do Cliente backend (Fases A+C) implementation plan
Plano de 20 tasks em TDD cobrindo: migrations, models (Rule/Delivery/Config),
extensões em Captain::Unit, 3 métodos interativos em Wuzapi::Client,
EventResolver, Scheduler event-driven, hooks em Captain::Reservation,
ContextBuilder, 6 guards (Opção C quiet hours, max-5, opt-out, etc),
Dispatcher pipeline, DispatcherJob, injeção Liquid de concierge.* no
orchestrator prompt e spec de integração end-to-end.

Out of scope: UI (Fase B) será plano separado após backend validado.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 01:05:00 -03:00
Rodribm10
af5e2723aa docs: add Jornada do Cliente (lifecycle automation) design spec
Design para a feature de automação de mensagens WhatsApp baseada em
eventos do ciclo de vida de reserva — 4 componentes isolados (rules
engine, scheduler event-driven, dispatcher pipeline, concierge AI
Sofia), multi-tenant desde o dia 1, com guards anti-ban e injeção
dinâmica de knowledge por unidade via Liquid.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:53:08 -03:00
Rodribm10
69f0b124bb docs: plano Fase 5 - polish visual (hero, carrossel, skeletons, confetti) 2026-04-14 22:05:19 -03:00
Rodribm10
e55f793ffc docs: plano Fase 4 - multi-tenant SaaS + admin CRUD 2026-04-14 20:50:17 -03:00
Rodribm10
ea8ff83034 feat: Captain::PixCharge posta nota interna quando PIX eh gerado
Antes so existiam 2 notas automaticas:
  1. 'Nova reserva criada' (from Captain::Reservation after_create_commit)
  2. 'Pagamento confirmado' (from Captain::Payments::ConfirmationService)

Adiciona uma terceira entre elas: 'PIX enviado, aguardando pagamento'
(from Captain::PixCharge after_create_commit). A atendente ve no
timeline: reserva -> pix enviado -> pix pago.
2026-04-14 20:09:20 -03:00
Rodribm10
a892e65300 fix(ui): dropdown Vincular Unidade Pix mostra unit vinculada em cascata
Existem 3 mecanismos de vinculo inbox-unit no fork:
  1. captain_inboxes.captain_unit_id (fluxo Captain)
  2. captain_unit_inboxes (tabela join pura, usada pela UI nova)
  3. captain_units.inbox_id (legado direto)

O serializer lia so o #1 e retornava null nas outras, fazendo a UI
mostrar 'Nenhuma unidade vinculada' mesmo quando havia vinculo via
#2 ou #3. Agora o jbuilder cai em cascata nas 3 fontes.
2026-04-14 20:07:33 -03:00
Rodribm10
996704350b feat: Captain::Reservation callback cria nota interna automaticamente
Cobre ambos os caminhos (generate_pix_tool e PublicReservationsController):
toda reserva criada recebe um after_create_commit que posta uma mensagem
privada na conversa com os detalhes (suite, check-in, valores, ID).

Remove a criacao duplicada do PublicReservationsController.
2026-04-14 19:53:21 -03:00
Rodribm10
f8d64b6992 feat: link enviado como mensagem direta + email extraction + contact metadata auto-persist
- GeneratePixTool: envia payment_link como mensagem outgoing direta (bypassa
  hallucination de [Link do Pix] placeholder pela LLM)
- GeneratePixTool: extrai email das mensagens recentes via regex e persiste
  em contact.email
- GenerateReservationLinkTool: mesmo padrao de envio direto do link
- Captain::Reservation: after_create_commit callback atualiza
  ultima_suite/permanencia/reserva_em/total_reservas em contact.custom_attributes
  (aparece no painel lateral)
2026-04-14 13:44:13 -03:00
Rodribm10
7c9411a0b0 feat: persiste metadados do cliente em custom_attributes + tool blindada
- Controller grava cpf/ultima_suite/ultima_permanencia/ultima_reserva_em/total_reservas
  em contact.custom_attributes (aparece no painel lateral do Chatwoot)
- GenerateReservationLinkTool exige marca/unidade/categoria/permanencia/checkin_at;
  retorna erro se Jasmine chamar sem esses dados
2026-04-14 13:26:02 -03:00
Rodribm10
37480b1fc5 feat: GenerateReservationLinkTool le contato da conversa automaticamente
Mirrors CheckPixPaymentTool resolve_conversation helpers. No fallback,
Jasmine so precisa passar categoria/permanencia/checkin_at - a tool
preenche nome/telefone/cpf/email a partir do contato.
2026-04-14 12:33:40 -03:00
Rodribm10
8ec1b652fa feat: tool GenerateReservationLink para jasmine gerar links prefill
Cria Captain::Tools::GenerateReservationLinkTool que constrói URL
pré-preenchida do reserva-1001 com dados coletados em conversa.
Registra entrada generate_reservation_link em tools.yml e documenta
RESERVA_1001_BASE_URL no .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:35:43 -03:00
Rodribm10
6e1b80002e feat: adiciona label aguardando_pagamento ao criar reserva (fecha fase 2+3) 2026-04-14 10:24:37 -03:00
Rodribm10
f578600877 docs: plano fase 3.5 (angelina prefill + fechamento fase 2+3) 2026-04-14 10:22:49 -03:00
Rodribm10
9dabaaa505 fix: usa phone digits como source_id (whatsapp inbox exige E.164 sem +)
Smoke test revelou que o inbox do tipo whatsapp valida source_id com
regex ^\d{1,15}\z. Trocar UUID por telefone em digitos (phone_digits)
e normalizar phone_number pra +phone_digits antes de criar o contato.
2026-04-14 10:01:50 -03:00
Rodribm10
5ff3a70474 feat: implementa POST create (contact + conversa + reserva + pix) e GET status 2026-04-13 23:50:26 -03:00
Rodribm10
e9a5e734ff feat: rota + controller esqueleto PublicReservations com token auth 2026-04-13 23:49:11 -03:00
Rodribm10
17f1c8165e test: spec para auth do PublicReservationsController (red) 2026-04-13 23:47:00 -03:00
Rodribm10
49c907ac3e chore: documenta RESERVA_1001_API_TOKEN no .env.example 2026-04-13 23:42:19 -03:00
Rodribm10
2216e082c1 docs: plano Fase 2+3 (backend Chatwoot + fluxo publico completo)
Plano combinado com 19 tasks bite-sized:
- Parte A: seed de dados de teste em reserva_hotel
- Parte B (Fase 2): controller publico Chatwoot com 2 endpoints,
  auth por token, 8 specs RSpec, smoke test via curl
- Parte C (Fase 3): client HTTP, formatadores, catalogoService,
  useReservationForm, StayDetailsStep, ImageGallery, PriceSummary,
  CustomerForm, PixCheckout com polling, SuccessScreen, ReservationFlow

Usa Captain::Unit id=4 (Hotel 1001 Aguas Lindas, inbox_id=2)
como unidade de teste (ja configurada com credenciais Inter).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:37:43 -03:00
Rodribm10
b37e96465a docs: adapta plano da Fase 1 para reusar schema reserva_hotel
Em vez de criar schema novo reserva_1001, reaproveita o schema
reserva_hotel existente no projeto Supabase acdvblhzzaneddlxqyst
(InAudit Hotel). Migration aditiva (3 tabelas + 4 colunas Chatwoot)
ja aplicada via MCP antes do plano iniciar.

Adaptacoes:
- Credenciais reais do projeto em .env.local
- Cliente Supabase com db.schema = reserva_hotel
- Tipos gerados com --schema reserva_hotel
- App.tsx le tabela 'marcas' (pt-br) em vez de 'brands'
- Mock do Vitest atualizado
- Task 5 vira "documenta migration aplicada" (sem db push)
- Task 6 usa supabase link + gen types --schema

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:53:28 -03:00
Rodribm10
7606b336a3 docs: adiciona plano de implementacao da Fase 1 (Fundacao)
Plano detalhado em 13 tasks bite-sized para construir a fundacao
do novo app reserva-1001: Vite + React 19 + TS + Tailwind v4 +
Supabase + shadcn/ui base, com paleta premium aplicada e schema
novo aplicado no banco. Entrega: app rodando com as 4 marcas
vindas do Supabase.

Fases subsequentes (backend Chatwoot, fluxo publico, admin,
polish visual, deploy) viram planos separados.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:31:32 -03:00
Rodribm10
944cec7136 docs: adiciona spec de design da Reserva Rede 1001
Novo app publico de reserva (Vite + React + Supabase) separado do
Chatwoot, que reusa toda a tubulacao de PIX (CobService, PixCharge,
webhook Inter, ConfirmationService) via um endpoint novo no Chatwoot.

Cobre: arquitetura, paleta premium, modelo de dados reformado
(corrige bug de preco nos domingos), contrato da API nova, fluxo
do cliente, plano de entrega em 6 fases e riscos.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:16:58 -03:00
Rodrigo Borba
cdc5149866 Automate landing promotion sync to captain docs/faqs with cascade cleanup 2026-03-04 19:30:05 -03:00
Rodrigo Borba
46806fa635 fix(landing-page): resolve rubocop offenses for ai syncable 2026-03-03 22:25:19 -03:00
Rodrigo Borba
7e23e59782 feat: Implementa a sincronização automática de promoções do LandingHost para artigos de FAQ, permitindo a criação, atualização e arquivamento de conteúdo baseado em configurações de promoções. 2026-03-03 22:24:30 -03:00
Rodrigo Borba
c16194eff9 feat: Adiciona o script e o iframe do Google Tag Manager às landing pages. 2026-03-03 17:39:32 -03:00
Rodrigo Borba
70bc4dae99 feat: Implementa páginas de destino dinâmicas e configuráveis com rastreamento de cliques. 2026-03-03 16:54:54 -03:00
Rodrigo Borba
06ffb93d9c fix(landing-page): update model/schema attributes and eslint warnings
- Add missing visual fields to LandingHosts table
- Add custom_config to permitted landing_host_params
- Fix ESLint warnings causing commit block in LandingHostsConfig.vue
2026-03-03 14:30:06 -03:00
Rodrigo Borba
e5e2ed762e docs: add relatorio de progresso de generalizacao de galeria 2026-03-03 11:35:20 -03:00
Rodrigo Borba
fe24d381cd feat: configuração de landing pages por domínio e generalização da galeria 2026-03-03 11:19:41 -03:00
Rodrigo Borba
8d33289a67 fix(landing-stats): qualify created_at in daily breakdown query 2026-03-02 21:54:18 -03:00
Rodrigo Borba
6d61b9b286 fix(reports): use local date boundaries for landing page stats filters 2026-03-02 21:41:35 -03:00
Rodrigo Borba
a0fcf37e33 feat(landing): add public LP flow, attribution labels, and report filters 2026-03-02 18:57:22 -03:00
Rodrigo Borba
98252e968a feat: add landing click attribution tracking and stats endpoint 2026-03-02 17:37:28 -03:00
Rodrigo Borba
a73689dce4 fix: aumenta janela de atribuição para 30min e sanitiza hostname no frontend
- attribution_matcher_service: window 10min → 30min (mais realista para jornada do lead)
- LandingHostsConfig.vue: strip automático de https://, www e trailing slash antes de salvar
2026-03-02 15:35:05 -03:00
Rodrigo Borba
118f52e239 feat: lead attribution tracking - landing page origin detection
- Cria modelo LeadClick para registrar cliques das landing pages
- Cria modelo LandingHost para mapear hostname → inbox_id
- Endpoint público POST /track/click para receber eventos de clique
- Leads::AttributionMatcherService para correlacionar clique com conversa
- Integração com IncomingMessageWuzapiService para atribuição automática
- API REST para gerenciar LandingHosts por inbox (index/create/destroy)
- UI: nova aba 'Landing Pages' nas configurações da caixa de entrada
- Dashboard API client dedicado (landingHosts.js)
- RuboCop: refatora shift_signature_name, TrackingController, AttributionMatcherService e WuzapiService
2026-03-02 14:40:35 -03:00
Rodrigo Borba
7a84cb3433 fix(captain): Move NOTIFICATIONS key settings to correct correct namespace in i18n. 2026-03-01 22:51:33 -03:00
Rodrigo Borba
162e8e15be fix: corrige filtro de inboxes na página de notificações
- Remove filtro por captain_assistant_id (campo não existe no payload)
- Mostra todas as inboxes da conta para o usuário escolher em qual
  caixa de entrada configurar os templates de notificação
2026-03-01 22:25:41 -03:00
Rodrigo Borba
203b1e3cc3 fix: adiciona 'Notificações Automáticas' no menu lateral do Captain
- Sidebar.vue: novo item Captain > Notificações Automáticas
  apontando para a rota captain_settings_notifications
- pt_BR/settings.json: CAPTAIN_NOTIFICATIONS key adicionada
- en/settings.json: CAPTAIN_REPORTS e CAPTAIN_NOTIFICATIONS adicionadas
2026-03-01 22:21:22 -03:00
Rodrigo Borba
84fff38d94 refactor: move notification templates de units para inboxes
- Arquitetura corrigida: templates agora pertencem à inbox (WhatsApp),
  não à unidade PIX (que é uma config financeira, não de mensagens)
- Migration: troca FK captain_unit_id -> inbox_id (up/down explícito)
- Model: belongs_to :inbox; scope for_inbox
- Controller: escopo via account.inboxes.find(inbox_id)
- Rotas: move de captain/units/:id → inboxes/:id/notification_templates
- Scanner job: joins(:conversation).where(conversations: {inbox_id:})
- UI: página /captain/notifications com seletor de inbox no topo
  (chips clicáveis, templates carregam por watch no selectedInboxId)
- i18n PT/EN: novas keys INBOX_LABEL, SELECT_INBOX_HINT, EMPTY
2026-03-01 22:17:27 -03:00
Rodrigo Borba
ce2904e57f Revert "fix: adiciona botão de notificações na listagem de unidades"
This reverts commit de62e7d68c.
2026-03-01 22:09:45 -03:00
Rodrigo Borba
de62e7d68c fix: adiciona botão de notificações na listagem de unidades
Sem o botão na tela de Units, não havia como chegar até a página
de templates de notificação (captain/units/:unitId/notifications).
Adiciona ícone de sino com rota correta + chave i18n PT/EN.
2026-03-01 22:04:16 -03:00
Rodrigo Borba
44908f32d1 feat: sistema de notificações de reserva com templates configuráveis
- Adiciona check_in_at/duration_hours ao schema do tool CreateReservationIntent
  para que a IA capture o horário EXATO de chegada informado pelo cliente
- Cria captain_notification_templates: label, content, timing_minutes,
  timing_direction (before/after), active, position
- Implementa SendNotificationService com interpolação de variáveis
  (guest_name, check_in_time, check_out_time, suite_name, unit_name)
- Implementa NotificationScannerJob (Sidekiq-cron a cada 5min) com
  janela de tolerância de ±5min e idempotência via metadata JSONB
- API REST: /captain/units/:unit_id/notification_templates (CRUD)
- Store Vuex captainNotificationTemplates + API client
- UI: página de gestão de templates com editor inline e botão '+'
- Configura rota captain_settings_notifications
- i18n PT/EN para todas as strings novas
- Rubocop e ESLint: zero offenses
2026-03-01 21:53:11 -03:00
Rodrigo Borba
9a7599d971 Traduz e corrige os relatórios de insights do Capitão: centraliza traduções, corrige fuso horário na data e adiciona polling automático 2026-03-01 18:28:46 -03:00
Rodrigo Borba
dc3d1bbcf7 Fix(Captain): Correção na geração de relatórios de IA e adição do status Confirmada nas Reservas 2026-03-01 15:40:10 -03:00
Rodrigo Borba
e8b51109cb feat(captain): add status field to manual reservation modal 2026-03-01 03:40:46 -03:00
Rodrigo Borba
adcadcf12c refactor(captain): remove manual unit selection from reservation modal 2026-03-01 03:37:19 -03:00
Rodrigo Borba
8896482b1d fix(captain): force dialog open modal on mounted 2026-03-01 03:19:43 -03:00
Rodrigo Borba
7108bb135e feat(captain): permite criacao manual de reserva via painel e conversa 2026-03-01 03:07:44 -03:00
Rodrigo Borba
cbb39a4db5 fix(i18n): remove unsupported liquid curly braces from pt_BR translations causing vue render syntax error 2026-03-01 02:22:30 -03:00
Rodrigo Borba
96cf48d893 feat(ai): implement dynamic debounce with sidekiq and concurrency locking with redis 2026-03-01 02:04:55 -03:00
Rodrigo Borba
5fdee0c7e6 Merge branch 'main' of https://github.com/rodribm10/iachat 2026-02-28 22:21:11 -03:00
Rodrigo Borba
ebed1caeb4 feat: Adiciona a opção "Criar FAQ" no menu de contexto da mensagem, permitindo criar uma resposta com seleção de assistente. 2026-02-28 22:20:43 -03:00
Rodribm10
a2085d5068
Merge pull request #10 from Rodribm10/fix-media-audio-v2
Fix media audio v2
2026-02-28 20:04:56 -03:00
Rodrigo Borba
fa061dc08c docs: enriquece troubleshooting de mídia WhatsApp com bugs 4-6 e diagnóstico rápido
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
- Bug 4: ngrok interstitial (URL absoluta → relativa com rails_storage_proxy_path)
- Bug 5: refactoring removeu text_content e attachment_params do PayloadParser
- Bug 6: content-type audio/opus → audio/ogg
- Seção de diagnóstico rápido com tabela de interpretação
- Checklist expandido com comandos Rails runner prontos para usar
- Notas sobre perigos de refactoring nos contratos públicos do PayloadParser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:37:59 -03:00
Rodrigo Borba
cfa2dc71bd fix(media): usa URL relativa para arquivos em desenvolvimento
Substitui rails_storage_proxy_url (URL absoluta com host ngrok) por
rails_storage_proxy_path (URL relativa) em file_url e thumb_url.

Problema: ngrok mostra página de interstitial HTML para sub-recursos
carregados pelo browser (img/audio) sem cookie ngrok válido.
O browser recebia HTML em vez da mídia → imagem 'não disponível' e
áudio '00:00/00:00'.

Solução: URL relativa (/rails/active_storage/blobs/proxy/...) resolve
para o servidor atual sem passar pelo ngrok, eliminando o interstitial.
Funciona tanto em localhost:3000 quanto acessando via ngrok no browser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:32:54 -03:00
Rodribm10
ff52cf23e7
Merge pull request #9 from Rodribm10/fix-media-audio-v2
Fix media audio v2
2026-02-28 18:46:37 -03:00
Rodrigo Borba
5d3ce4e565 fix(whatsapp): corrige content-type audio/opus e extensão para OGG
- MediaHandler: adiciona sanitize_content_type que normaliza audio/opus → audio/ogg
- MediaHandler: detect_extension retorna .ogg (não .mp3) para áudios WhatsApp
- MediaHandler: final_filename força extensão .ogg em áudios que chegam com .mp3
- Attachment: normalize_opus_blob_content_type! agora verifica apenas content_type
  (remove checagem de extensão de filename que impedia normalização de blobs .mp3)
- Attachment: audio_metadata chama normalize_opus_blob_content_type! para corrigir
  blobs existentes na primeira vez que são acessados (lazy fix)

WhatsApp envia áudio como container OGG/Opus (bytes OggS = 4f 67 67 53),
mas declarava mimetype audio/opus. Browsers não conseguem reproduzir container
OGG via MIME audio/opus — precisam de audio/ogg.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:40:06 -03:00
Rodrigo Borba
ec6cfc317d fix(whatsapp): restaura attachment_params no PayloadParser para mídia funcionar
O refactoring c48047ba5 removeu attachment_params acidentalmente sem mover
para outro lugar, quebrando o download de áudio, imagem, vídeo e documento.
O método é chamado por incoming_message_wuzapi_service.rb#attach_files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:47:33 -03:00
Rodribm10
8d247fbee0
Merge pull request #8 from Rodribm10/fix-media-audio-v2
fix(whatsapp): adiciona método text_content ao PayloadParser e ignora…
2026-02-28 17:23:17 -03:00
Rodrigo Borba
e6e4c36525 fix(whatsapp): adiciona método text_content ao PayloadParser e ignora UndecryptableMessage
Corrige NoMethodError que impedia todas as mensagens de texto de chegarem
ao front. O método text_content era chamado mas não existia na classe.

Também adiciona UndecryptableMessage à lista de eventos ignorados para
evitar tentativa de processar mensagens sem conteúdo descriptografável.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:00:52 -03:00
Rodribm10
309618113e
Merge pull request #7 from Rodribm10/fix-media-audio-v2
Fix media audio v2
2026-02-28 16:10:48 -03:00
Rodrigo Borba
c48047ba50 refactor(whatsapp): modulariza processamento de mídias e payloads para conformidade com RuboCop 2026-02-28 15:42:50 -03:00
Rodrigo Borba
6b214b38db feat: Adiciona configuração Active Storage proxy, refatora serviço de decriptografia WhatsApp para processar bytes diretamente e ajusta componentes de mídia. 2026-02-28 12:48:17 -03:00
Rodribm10
c9a2f9ed4b
Merge pull request #6 from Rodribm10/feature/humanized-typing-adjustments
fix: Resolve erros de linting e conclui as correções de prompt custom…
2026-02-27 13:42:41 -03:00
Rodribm10
7daf3e8695
Merge pull request #5 from Rodribm10/feature/humanized-typing-adjustments
Feature/humanized typing adjustments
2026-02-27 12:00:55 -03:00
Rodribm10
ef3706cb6f
Merge pull request #4 from Rodribm10/feature/humanized-typing-adjustments
Feature/humanized typing adjustments
2026-02-27 10:21:44 -03:00
Rodribm10
c22db0ccf9
Merge pull request #3 from Rodribm10/feature/humanized-typing-adjustments
feat(units): allow one Pix unit to link to multiple inboxes (N:N)
2026-02-26 21:56:42 -03:00
Rodribm10
d4cd1a76d1
Merge pull request #2 from Rodribm10/feature/humanized-typing-adjustments
Feature/humanized typing adjustments
2026-02-26 19:30:08 -03:00
Rodribm10
c3d35e3ba7
Merge pull request #1 from Rodribm10/feature/humanized-typing-adjustments
feat: ajuste galeria de imagens
2026-02-26 16:56:54 -03:00
550 changed files with 60665 additions and 1674 deletions

View File

@ -99,3 +99,16 @@ AIOS_VERSION=2.2.0
# Custom Configuration
# --------------------------------------------
# Add your custom API keys below
# Reserva Rede 1001 — public reservations API (Fase 2)
# Token used to authenticate calls from the reserva-1001 app to the public
# reservation endpoint. Generate via `openssl rand -hex 32` in production.
RESERVA_1001_API_TOKEN=
# Reserva Rede 1001 — URL base do app publico (usada pela Jasmine pra gerar links prefill)
RESERVA_1001_BASE_URL=http://localhost:5180
# Reserva Rede 1001 — Supabase credentials para consultas de catalogo (preco, unidade)
RESERVA_1001_SUPABASE_URL=
RESERVA_1001_SUPABASE_ANON_KEY=
RESERVA_1001_SUPABASE_SCHEMA=reserva_hotel

7
.gitignore vendored
View File

@ -129,3 +129,10 @@ Thumbs.db
.env.aios
.env.backup*
reference/chatwoot-develop
# Credentials / secrets — NUNCA commitar
docs/acessos_vps.md
docs/acessos*.md
**/acessos_vps*
**/*_secrets.md
**/*.credentials

View File

@ -89,12 +89,14 @@ Metrics/BlockLength:
- spec/**/*
- '**/routes.rb'
- 'config/environments/*'
- 'lib/tasks/**/*.rake'
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
- app/models/concerns/landing_host_ai_syncable.rb
Rails/HelperInstanceVariable:
Exclude:
@ -185,6 +187,7 @@ Metrics/AbcSize:
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
- 'app/models/concerns/landing_host_ai_syncable.rb'
Rails/RenderInline:
Exclude:
@ -238,6 +241,7 @@ AllCops:
- 'reference/**/*'
- '.aios-core/**/*'
- '.claude/**/*'
- 'scripts/captain_codex_poc/**/*'
FactoryBot/SyntaxMethods:
Enabled: false

View File

@ -26,6 +26,10 @@ Layout/LineLength:
# Offense count: 2
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
Lint/DuplicateRescueException:
Exclude:
- 'app/models/concerns/landing_host_ai_syncable.rb'
Lint/DuplicateBranch:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
@ -44,6 +48,7 @@ Lint/UnusedMethodArgument:
Metrics/AbcSize:
Exclude:
- 'app/models/channel/whatsapp.rb'
- 'app/models/concerns/landing_host_ai_syncable.rb'
- 'app/services/evolution_api/client.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
@ -59,6 +64,10 @@ Metrics/AbcSize:
# Offense count: 6
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ModuleLength:
Exclude:
- 'app/models/concerns/landing_host_ai_syncable.rb'
Metrics/ClassLength:
Exclude:
- 'app/models/channel/whatsapp.rb'

188
AGENTS.md
View File

@ -4,56 +4,49 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a **Chatwoot** customer engagement platform (open-source alternative to Intercom/Zendesk), customized for **fazer.ai**. It includes the **Synkra AIOS** framework overlay for AI-orchestrated development workflows.
**Chatwoot** customizado para **fazer.ai** — plataforma de atendimento ao cliente multi-canal com IA (Captain). Multi-tenant SaaS para hotelaria, com integração de PIX/Inter, reservas e WhatsApp.
**Tech Stack:**
- Backend: Ruby 3.4.4 + Rails 7.1
- Frontend: Vue 3 + Vite
- Database: PostgreSQL with pgvector
- Background Jobs: Sidekiq
- Package Manager: **pnpm** (required, not npm/yarn)
- Frontend: Vue 3 + Vite + Pinia
- Database: PostgreSQL + pgvector
- Background Jobs: Sidekiq (com sidekiq-cron)
- Package Manager: **pnpm** (obrigatório — nunca npm/yarn)
- Testing: RSpec (backend), Vitest (frontend)
- Event system: Wisper (pub/sub)
- Authorization: Pundit
## Development Commands
### Starting the Application
### Iniciar aplicação
```bash
# Development server (Rails backend + Sidekiq + Vite)
pnpm run dev
# Individual processes:
# - Rails backend: http://localhost:3001
# - Sidekiq: background worker
# - Vite: frontend dev server
pnpm run dev # overmind: Rails :3000 + Sidekiq + Vite
pnpm run start:dev # foreman (alternativo)
```
### Testing
### Testes
```bash
# Frontend (Vitest) - CRITICAL: NO -- flag with pnpm test!
pnpm test # Run all tests
pnpm test <file> # Run specific file (NOT pnpm test -- <file>)
pnpm test:watch # Watch mode
pnpm test:coverage # Coverage report
# Frontend (Vitest) — CRÍTICO: sem -- com pnpm!
pnpm test # todos
pnpm test app/javascript/path # arquivo específico (NÃO use pnpm test -- <file>)
pnpm test:watch
pnpm test:coverage
# Backend (RSpec)
bundle exec rspec # All specs
bundle exec rspec spec/models/user_spec.rb # Specific file
bundle exec rspec spec/models/user_spec.rb:42 # Specific line
bundle exec rspec # todos
bundle exec rspec spec/models/contact_spec.rb # arquivo
bundle exec rspec spec/models/contact_spec.rb:42 # linha específica
```
### Code Quality
### Qualidade de código
```bash
# JavaScript/Vue linting
pnpm run eslint # Check
pnpm run eslint:fix # Auto-fix
# Ruby linting
bundle exec rubocop # Check
bundle exec rubocop -a # Auto-fix
pnpm run ruby:prettier # Same as rubocop -a
pnpm run eslint # lint JS/Vue
pnpm run eslint:fix # auto-fix
bundle exec rubocop # lint Ruby
bundle exec rubocop -a # auto-fix Ruby
```
### Database
@ -61,72 +54,102 @@ pnpm run ruby:prettier # Same as rubocop -a
```bash
bin/rails db:migrate
bin/rails db:rollback
bin/rails db:reset
bin/rails db:seed
bin/rails db:reset && bin/rails db:seed
```
## Architecture Overview
### i18n
### Backend Structure
```
app/
├── controllers/ # API endpoints (API::V1::Accounts::*)
├── models/ # ActiveRecord models
├── services/ # Business logic (Whatsapp::Providers::*, etc.)
├── jobs/ # Sidekiq background jobs
├── listeners/ # Wisper event subscribers (pub/sub)
├── builders/ # Complex object construction
├── finders/ # Query objects
├── policies/ # Pundit authorization
└── javascript/ # Vue.js frontend
enterprise/app/ # Enterprise features (Captain AI, billing)
```bash
pnpm run sync:i18n # sincroniza arquivo de tradução
```
**Key Patterns:**
- **Services:** Business logic extracted from models
- **Builders:** Construct complex objects
- **Finders:** Encapsulate complex queries
- **Listeners:** Event-driven using Wisper
- **Policies:** Pundit for authorization
- **Jobs:** All async work in Sidekiq
## Arquitetura Backend
### Frontend Structure
### Modelo de dados central
```
Account (tenant raiz)
├── Inbox (canal: WhatsApp, Email, Facebook, Instagram, Twilio...)
│ └── Contact (cliente) via ContactInbox
├── Conversation (central: status, priority, SLA, custom_attributes)
│ ├── Message (conteúdo, attachments, sender)
│ ├── Agent (assignee)
│ └── Label
├── AutomationRule
├── Campaign
└── Article (help center)
```
### Padrões Rails usados
| Padrão | Onde | Função |
|--------|------|--------|
| **Services** | `app/services/` | Toda lógica de negócio fora dos models |
| **Builders** | `app/builders/` | Construção de objetos complexos (ex: criar conversa + contato) |
| **Finders** | `app/finders/` | Query objects encapsulados |
| **Listeners** | `app/listeners/` | Subscribers Wisper para eventos de domínio |
| **Policies** | `app/policies/` | Autorização Pundit por recurso |
| **Jobs** | `app/jobs/` | Todo trabalho assíncrono via Sidekiq |
### Estrutura de controllers
```
app/controllers/
├── api/v1/accounts/{account_id}/ # Endpoints principais (Conversations, Contacts, Inboxes...)
├── api/v1/widget/ # Chat widget público
└── enterprise/api/v1/accounts/captain/ # Captain AI (enterprise)
```
### Enterprise — Captain AI (`enterprise/app/`)
Camada fazer.ai sobre o Chatwoot base:
- **Models chave:** `Captain::Unit` (multi-unidade hoteleira), `Captain::Assistant`, `Captain::Reservation`, `Captain::PixCharge`, `Captain::Document`, `Captain::ConversationInsight`
- **Integrações:** Inter API (pagamento PIX), WhatsApp, sincronização de reservas, webhooks
- **AI features:** LLM (OpenAI), copilot, audio transcription, label suggestion, help center search
- **Feature flags por account:** `captain_features` (Editor, Assistant, Copilot, LabelSuggestion, AudioTranscription, HelpCenterSearch)
## Arquitetura Frontend
```
app/javascript/
├── dashboard/ # Agent dashboard (Vue 3 + Vue Router + Vuex)
│ ├── routes/ # Page components
│ ├── store/ # Vuex state
│ ├── components/ # Reusable components
│ ├── api/ # API clients
│ └── i18n/ # Translations (en, pt_BR required!)
├── widget/ # Customer chat widget
├── sdk/ # Embeddable JavaScript SDK
├── portal/ # Public help center
└── shared/ # Shared utilities
├── dashboard/ # Dashboard do agente (Vue 3 + Vue Router + Pinia)
│ ├── routes/ # Componentes de página
│ ├── store/ # Pinia stores (55+ módulos: conversations, contacts, captain*)
│ ├── components/ # Componentes reutilizáveis
│ ├── api/ # Clientes HTTP por recurso
│ └── i18n/locale/ # Traduções (en + pt_BR SEMPRE)
├── widget/ # Widget de chat embeddable
├── sdk/ # SDK JS (build separado: pnpm run build:sdk)
├── portal/ # Help center público
└── shared/ # Utilities compartilhados
```
**Vite Import Aliases:**
**Aliases Vite:**
- `components``app/javascript/dashboard/components`
- `dashboard``app/javascript/dashboard`
- `helpers``app/javascript/shared/helpers`
- `shared`, `widget`, `survey`, `v3` → respective directories
- `shared`, `widget`, `survey`, `v3`diretórios equivalentes
## Critical Conventions
**Bibliotecas chave:** ProseMirror (rich text), ActionCable (real-time), Chart.js, Twilio Voice SDK
### fazer.ai Branding
**ALWAYS** style as `fazer.ai` (lowercase with dot), **NEVER** `Fazer.ai` or `FAZER.AI`
## Convenções críticas
### Internationalization
**ALWAYS include pt_BR translations** for any new user-facing text
- Location: `app/javascript/dashboard/i18n/locale/{en,pt_BR}/`
### Branding
`fazer.ai` — sempre minúsculo com ponto. Nunca `Fazer.ai` ou `FAZER.AI`.
### Testing Philosophy
- Add specs when modifying code (use judgment)
- Test behavior, not implementation
- Consider cross-stack impacts (backend ↔ frontend)
### Internacionalização
Qualquer texto visível ao usuário **exige** tradução em `en` e `pt_BR`:
```
app/javascript/dashboard/i18n/locale/en/
app/javascript/dashboard/i18n/locale/pt_BR/
```
### Novos canais / integrações
Siga o padrão existente em `app/services/whatsapp/` ou `app/services/instagram/` — nunca coloque lógica de canal no controller.
### Background jobs
Toda operação demorada vai para Sidekiq. Jobs em `app/jobs/`, enterprise em `enterprise/app/jobs/`.
---
@ -146,9 +169,10 @@ This repository includes **Synkra AIOS** - an AI-orchestrated development system
<!-- AIOS-MANAGED-START: quality -->
## Quality Gates
- Rode `npm run lint`
- Rode `npm run typecheck`
- Rode `npm test`
- Rode `pnpm run eslint`
- Rode `bundle exec rubocop`
- Rode `pnpm test`
- Rode `bundle exec rspec`
- Atualize checklist e file list da story antes de concluir
<!-- AIOS-MANAGED-END: quality -->

View File

@ -1,6 +1,6 @@
source 'https://rubygems.org'
ruby '3.4.4'
ruby '>= 3.4.4'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'

View File

@ -1,4 +1,4 @@
backend: bin/rails s -p 3001
backend: bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev

View File

@ -137,3 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
<!-- Status: integração Mattermost ativa -->

View File

@ -12,14 +12,24 @@ class V2::Reports::BotMetricsBuilder
conversation_count: bot_conversations.count,
message_count: bot_messages.count,
resolution_rate: bot_resolution_rate.to_i,
handoff_rate: bot_handoff_rate.to_i
handoff_rate: total_handoff_rate.to_i,
bot_resolutions_count: bot_resolutions_count,
auto_handoffs_count: auto_handoffs_count,
manual_takeovers_count: manual_takeovers_count
}
end
private
def filter_inbox_id
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
end
def bot_activated_inbox_ids
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
@bot_activated_inbox_ids ||= begin
ids = account.inboxes.filter(&:active_bot?).map(&:id)
filter_inbox_id ? ids & [filter_inbox_id] : ids
end
end
def bot_conversations
@ -30,14 +40,47 @@ class V2::Reports::BotMetricsBuilder
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
end
def bot_resolutions_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
def base_reporting_events
scope = account.reporting_events.where(account_id: account.id, created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
def bot_resolutions_count
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_resolved)
.distinct.count
end
# Auto handoff = Jasmine called bot_handoff! explicitly (loop, timeout, max_turns, intent)
def auto_handoffs_count
@auto_handoffs_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_handoff)
.distinct.count
end
# Manual takeover = a human replied (via Chatwoot UI or WhatsApp echo) WITHOUT a bot_handoff
# event being emitted for the same conversation. The bot itself uses sender_type 'Captain::Assistant',
# so it's never counted here.
def manual_takeovers_count
@manual_takeovers_count ||= begin
conv_ids_with_human_reply = bot_conversations
.joins(:messages)
.where(messages: { message_type: :outgoing })
.where('messages.sender_type = ? OR messages.sender_type IS NULL', 'User')
.distinct
.pluck(:id)
conv_ids_with_auto_handoff = ReportingEvent.unscope(:order)
.where(name: 'conversation_bot_handoff',
conversation_id: conv_ids_with_human_reply)
.distinct
.pluck(:conversation_id)
(conv_ids_with_human_reply - conv_ids_with_auto_handoff).count
end
end
def bot_resolution_rate
@ -46,9 +89,10 @@ class V2::Reports::BotMetricsBuilder
bot_resolutions_count.to_f / bot_conversations.count * 100
end
def bot_handoff_rate
# Total handoff = auto + manual (the gear that closes the math now)
def total_handoff_rate
return 0 if bot_conversations.count.zero?
bot_handoffs_count.to_f / bot_conversations.count * 100
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
end
end

View File

@ -0,0 +1,97 @@
class V2::Reports::ConversionFunnelBuilder
include DateRangeHelper
# Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the
# reception, card on arrival, etc. Anything that means the booking went through.
PAID_STATUSES = %w[active completed confirmed].freeze
# Statuses we ignore from "created" (drafts are pre-save, never went live)
IGNORED_CREATED_STATUSES = %w[draft].freeze
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def metrics
{
leads: leads_breakdown,
reservations: reservations_breakdown,
conversion_rates: conversion_rates_breakdown
}
end
private
def filter_inbox_id
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
end
def conversations_in_period
@conversations_in_period ||= begin
scope = account.conversations.where(created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope
end
end
def reservations_in_period
@reservations_in_period ||= begin
scope = account.captain_reservations.where(created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope.where.not(status: IGNORED_CREATED_STATUSES)
end
end
# Leads classified using the same logic as the "Novas × Retorno" tab:
# new = no prior conversation in any inbox of the account
# returning = had a prior conversation
def leads_breakdown
total = conversations_in_period.count
return { total: 0, new: 0, returning: 0 } if total.zero?
conv_with_prior_ids = conversations_in_period
.joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \
'AND prev.account_id = conversations.account_id ' \
'AND prev.id < conversations.id')
.distinct
.pluck(:id)
returning = conv_with_prior_ids.size
{
total: total,
new: total - returning,
returning: returning
}
end
def reservations_breakdown
created = reservations_in_period.count
paid = reservations_in_period.where(status: PAID_STATUSES).count
{
created: created,
paid: paid
}
end
def conversion_rates_breakdown
leads_total = conversations_in_period.count
reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count
reservations_created = reservations_in_period.count
{
lead_to_paid_reservation: percent(reservations_paid, leads_total),
lead_to_any_reservation: percent(reservations_created, leads_total),
created_to_paid: percent(reservations_paid, reservations_created)
}
end
def percent(numerator, denominator)
return 0 if denominator.to_i.zero?
(numerator.to_f / denominator * 100).round(1)
end
end

View File

@ -0,0 +1,79 @@
class V2::Reports::InboxBenchmarkingBuilder
include DateRangeHelper
PAID_STATUSES = V2::Reports::ConversionFunnelBuilder::PAID_STATUSES
IGNORED_CREATED_STATUSES = V2::Reports::ConversionFunnelBuilder::IGNORED_CREATED_STATUSES
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
# Returns one row per inbox of the account, with leads + reservations + rate,
# plus the brand name so the frontend can group by brand for benchmarking.
def build
return [] if range.blank?
inbox_brand_lookup = build_inbox_brand_lookup
account.inboxes.map do |inbox|
brand_name = inbox_brand_lookup[inbox.id]
leads_total = leads_count_by_inbox[inbox.id] || 0
reservations_created = reservations_created_by_inbox[inbox.id] || 0
reservations_paid = reservations_paid_by_inbox[inbox.id] || 0
{
inbox_id: inbox.id,
inbox_name: inbox.name,
brand_name: brand_name,
leads_total: leads_total,
reservations_created: reservations_created,
reservations_paid: reservations_paid,
conversion_rate: percent(reservations_paid, leads_total)
}
end
end
private
def leads_count_by_inbox
@leads_count_by_inbox ||= account.conversations
.where(created_at: range)
.group(:inbox_id)
.count
end
def reservations_created_by_inbox
@reservations_created_by_inbox ||= account.captain_reservations
.where(created_at: range)
.where.not(status: IGNORED_CREATED_STATUSES)
.group(:inbox_id)
.count
end
def reservations_paid_by_inbox
@reservations_paid_by_inbox ||= account.captain_reservations
.where(created_at: range, status: PAID_STATUSES)
.group(:inbox_id)
.count
end
# inbox_id => brand_name (or nil when there is no brand mapped for this inbox)
def build_inbox_brand_lookup
rows = Captain::UnitInbox
.joins(captain_unit: :brand)
.where(inbox_id: account.inboxes.select(:id))
.pluck('captain_unit_inboxes.inbox_id, captain_brands.name')
rows.to_h
end
def percent(numerator, denominator)
return 0 if denominator.to_i.zero?
(numerator.to_f / denominator * 100).round(1)
end
end

View File

@ -0,0 +1,111 @@
class V2::Reports::InboxLeadsSummaryBuilder
include DateRangeHelper
ALLOWED_GROUP_BY = %w[day week month].freeze
RETURN_THRESHOLD = '24 hours'.freeze
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
return [] if range.blank? || inbox.blank?
rows = ActiveRecord::Base.connection.exec_query(
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
)
rows.map do |row|
{
period: row['period'].iso8601,
new_leads: row['new_leads'].to_i,
returning: row['returning'].to_i,
others: row['others'].to_i
}
end
end
private
def inbox
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
end
def group_by
value = params[:group_by].to_s
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
end
def sql_bindings
{
account_id: account.id,
inbox_id: inbox.id,
since: range.begin,
until_t: range.end,
group_by: group_by,
return_threshold: RETURN_THRESHOLD
}
end
# Single CTE to classify each conversation in the period as:
# * new_leads: contact has no prior conversation in any inbox of the account
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
# event occurred more than 24h before the new conversation
# * others: prior conversation existed but was not resolved or was resolved <24h ago
# rubocop:disable Metrics/MethodLength
def sql
<<~SQL.squish
WITH period_conversations AS (
SELECT id, contact_id, created_at
FROM conversations
WHERE account_id = :account_id
AND inbox_id = :inbox_id
AND created_at >= :since
AND created_at < :until_t
),
classified AS (
SELECT
c.id,
c.created_at,
EXISTS (
SELECT 1 FROM conversations prev
WHERE prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS has_prior,
(
SELECT MAX(re.created_at)
FROM reporting_events re
INNER JOIN conversations prev ON prev.id = re.conversation_id
WHERE re.name = 'conversation_resolved'
AND prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS latest_prior_resolution_at
FROM period_conversations c
)
SELECT
date_trunc(:group_by, created_at) AS period,
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
COUNT(*) FILTER (
WHERE has_prior
AND latest_prior_resolution_at IS NOT NULL
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
) AS returning,
COUNT(*) FILTER (
WHERE has_prior
AND (
latest_prior_resolution_at IS NULL
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
)
) AS others
FROM classified
GROUP BY period
ORDER BY period
SQL
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -0,0 +1,57 @@
# Proxy interno que traduz OpenAI Chat Completions ↔ OpenAI Responses (Codex).
#
# Recebe requests no formato Chat Completions (o que RubyLLM, Agents gem e
# ruby-openai geram) e encaminha para a Responses API do ChatGPT Plus (Codex)
# usando OAuth interno via Captain::Codex::AuthService.
#
# Rota: POST /codex/v1/chat/completions
#
# Acesso: interno (não autenticado — localhost-only via Docker network).
# Em produção, o Nginx NÃO expõe /codex/* publicamente.
class Api::Internal::CodexProxyController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def chat_completions
chat_body = request.request_parameters.presence || parse_body
return render_error('Empty request body', status: 400) if chat_body.blank?
render json: proxy_call(chat_body)
rescue Captain::Codex::AuthService::AuthError, Captain::Codex::Client::Error, StandardError => e
handle_proxy_error(e)
end
private
def handle_proxy_error(error)
case error
when Captain::Codex::AuthService::AuthError
Rails.logger.error("[Codex Proxy] Auth error: #{error.message}")
render_error("Codex auth error: #{error.message}", status: 401)
when Captain::Codex::Client::Error
Rails.logger.error("[Codex Proxy] Upstream error: #{error.message}")
render_error("Upstream error: #{error.message}", status: error.http_status || 502)
else
Rails.logger.error("[Codex Proxy] Unexpected: #{error.class} #{error.message}\n#{error.backtrace.first(5).join("\n")}")
render_error("Internal error: #{error.message}", status: 500)
end
end
def proxy_call(chat_body)
responses_body = Captain::Codex::Translator.chat_to_responses(chat_body)
aggregated = Captain::Codex::Client.new.responses(responses_body)
Captain::Codex::Translator.responses_to_chat(aggregated)
end
def parse_body
raw = request.raw_post
return {} if raw.blank?
JSON.parse(raw)
rescue JSON::ParserError
{}
end
def render_error(message, status:)
render json: { error: { message: message, type: 'codex_proxy_error' } }, status: status
end
end

View File

@ -23,7 +23,9 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def update
@agent.update!(agent_params.slice(:name).compact)
user_attrs = agent_params.slice(:name).to_h.compact.symbolize_keys
user_attrs[:ui_settings] = merged_ui_settings if agent_params[:ui_settings].present?
@agent.update!(user_attrs) if user_attrs.any?
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end
@ -72,13 +74,19 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline]
[:name, :email, :role, :availability, :auto_offline, { ui_settings: [:aggressive_alert_inbox_ids_mode, { aggressive_alert_inbox_ids: [] }] }]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
end
def merged_ui_settings
existing = @agent.ui_settings || {}
incoming = agent_params[:ui_settings].to_h.deep_stringify_keys
existing.merge(incoming)
end
def new_agent_params
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
end

View File

@ -0,0 +1,113 @@
# Fornece o CEO Digest (via Captain::Reports::CeoDigestService) para a UI
# + endpoint de drill-down (busca conversas que contêm um texto) + disparo on-demand
# do envio ao Mattermost.
class Api::V1::Accounts::Captain::Reports::ExecutiveController < Api::V1::Accounts::BaseController
# GET /api/v1/accounts/:account_id/captain/reports/executive
# Params: period_start, period_end
def show
period_end = parse_date(params[:period_end], Time.zone.today - 1)
period_start = parse_date(params[:period_start], period_end - 6.days)
digest = Captain::Reports::CeoDigestService.new(
account: Current.account,
period_start: period_start,
period_end: period_end
).call
render json: digest
end
# GET /api/v1/accounts/:account_id/captain/reports/executive/drilldown
# Params: query (texto a procurar), period_start, period_end, inbox_id (opcional)
# Retorna: conversas que contêm o texto, com link para abrir no Chatwoot.
def drilldown
query = params[:query].to_s.strip
return render json: { conversations: [] } if query.blank?
period_end = parse_date(params[:period_end], Time.zone.today - 1)
period_start = parse_date(params[:period_start], period_end - 6.days)
inbox_id = params[:inbox_id].presence&.to_i
conversations = search_conversations(query, period_start, period_end, inbox_id)
tokens = extract_tokens(query)
render json: { query: query, conversations: conversations, tokens: tokens }
end
# POST /api/v1/accounts/:account_id/captain/reports/executive/deliver
# Dispara entrega do digest ao Mattermost agora (usa config da conta).
def deliver
period_end = parse_date(params[:period_end], Time.zone.today - 1)
period_start = parse_date(params[:period_start], period_end - 6.days)
Captain::Reports::CeoDigestJob.perform_later(Current.account.id, period_start, period_end)
render json: { status: 'queued', period_start: period_start, period_end: period_end }, status: :accepted
end
private
def search_conversations(query, period_start, period_end, inbox_id)
tokens = extract_tokens(query)
return [] if tokens.empty?
scope = Current.account.conversations
.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
.joins(:messages)
scope = scope.where(inbox_id: inbox_id) if inbox_id
scope = apply_token_filter(scope, tokens)
scope.distinct.includes(:contact, :inbox).limit(10).map { |c| format_conversation(c) }
end
# Quebra a descrição do insight em palavras relevantes (4+ chars, sem stopwords)
# e retorna só os tokens mais distintivos. Assim "Facilidade no acesso ao link
# de pagamento" vira ["facilidade", "acesso", "link", "pagamento"].
STOPWORDS = %w[
para com que uma esse essa esta isso aqui ali mais menos sobre entre
sem nao sim the and for that this with from into have need want
sobre quando onde como porque qual quais nesse nessa dessa desse dele
dela dos das pelo pela pelos pelas deste desta disto isto foi ser
].freeze
def extract_tokens(query)
query.to_s.downcase
.scan(/[[:alnum:]áéíóúâêôãõçàü]+/)
.reject { |w| w.length < 4 || STOPWORDS.include?(w) }
.uniq
.first(8)
end
def apply_token_filter(scope, tokens)
conditions = Array.new(tokens.size, 'messages.content ILIKE ?').join(' OR ')
values = tokens.map { |t| "%#{sanitize_like(t)}%" }
scope.where(conditions, *values)
end
def sanitize_like(str)
str.gsub('\\', '\\\\').gsub('%', '\\%').gsub('_', '\\_')
end
def format_conversation(conv)
{
id: conv.display_id,
internal_id: conv.id,
status: conv.status,
inbox_id: conv.inbox_id,
inbox_name: conv.inbox&.name,
contact_name: conv.contact&.name,
created_at: conv.created_at,
updated_at: conv.updated_at,
url: conversation_url(conv)
}
end
def conversation_url(conv)
"/app/accounts/#{Current.account.id}/conversations/#{conv.display_id}"
end
def parse_date(param, default)
return default if param.blank?
Date.parse(param.to_s)
rescue ArgumentError, TypeError
default
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class Api::V1::Accounts::Captain::Reports::FunnelController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
def show
days = params[:period_days].to_i
report = Captain::Reports::ConversionFunnelService.new(
account: current_account,
period_days: days
).call
render json: report
end
end

View File

@ -1,19 +1,9 @@
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
# rubocop:disable Metrics/AbcSize
def index
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: unit_id) if unit_id
scope = scope.where(inbox_id: inbox_id) if inbox_id
scope = scope.where(captain_unit_id: nil, inbox_id: nil) if !unit_id && !inbox_id
insights = scope.order(period_start: :desc).limit(12)
insights = filtered_insights.order(period_start: :desc).limit(12)
render json: insights.map { |i| format_insight(i) }
end
# rubocop:enable Metrics/AbcSize
def show
insight = Captain::ConversationInsight.find_by!(
@ -32,12 +22,32 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
# Log parameters to help debugging
Rails.logger.info '[Captain::Reports::InsightsController] Generating insight ' \
"for Unit: #{unit_id}, Inbox: #{inbox_id}, Period: #{period_start} to #{period_end}"
enqueue_insight(unit_id, inbox_id, period_start, period_end)
end
# rubocop:enable Metrics/AbcSize
private
def filtered_insights
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope = scope.for_period(*requested_period) if requested_period
scope
end
def filter_unit_id
params[:unit_id].presence&.to_i
end
def filter_inbox_id
params[:inbox_id].presence&.to_i
end
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end)
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
@ -69,11 +79,21 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
end
def parse_date(param, default)
param.present? ? Date.parse(param) : default
rescue ArgumentError
return default if param.blank?
Date.parse(param.to_s)
rescue ArgumentError, TypeError
default
end
def requested_period
return nil if params[:period_start].blank? || params[:period_end].blank?
[Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)]
rescue ArgumentError, TypeError
nil
end
def format_insight(insight)
{
id: insight.id,

View File

@ -2,30 +2,43 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
def show
period_start = parse_date(params[:period_start], Time.zone.today.beginning_of_month)
period_end = parse_date(params[:period_end], Time.zone.today)
unit = params[:unit_id].present? ? Current.account.captain_units.find_by(id: params[:unit_id]) : nil
render json: build_operational_report(unit, period_start, period_end)
render json: build_operational_report(find_unit, find_inbox, period_start, period_end)
end
private
def find_unit
return nil if params[:unit_id].blank?
Current.account.captain_units.find_by(id: params[:unit_id])
end
def find_inbox
return nil if params[:inbox_id].blank?
Current.account.inboxes.find_by(id: params[:inbox_id])
end
def parse_date(param, default)
param.present? ? Date.parse(param) : default
rescue ArgumentError
default
end
def build_operational_report(unit, period_start, period_end)
conversations = scoped_conversations(unit, period_start, period_end)
def build_operational_report(unit, inbox, period_start, period_end)
conversations = scoped_conversations(unit, inbox, period_start, period_end)
{
period: { start: period_start, end: period_end },
unit_id: unit&.id,
unit_name: unit&.name,
inbox_id: inbox&.id,
inbox_name: inbox&.name,
conversations: conversation_metrics(conversations),
reservations: reservation_metrics(unit, period_start, period_end),
reservations: reservation_metrics(unit, inbox, period_start, period_end),
hourly_distribution: hourly_distribution(conversations),
daily_distribution: daily_distribution(conversations, period_start, period_end)
daily_distribution: daily_distribution(conversations, period_start, period_end),
by_inbox: inbox.nil? ? by_inbox_breakdown(conversations) : []
}
end
@ -42,8 +55,8 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
}
end
def reservation_metrics(unit, period_start, period_end)
reservations = scoped_reservations(unit, period_start, period_end)
def reservation_metrics(unit, inbox, period_start, period_end)
reservations = scoped_reservations(unit, inbox, period_start, period_end)
paid = reservations.where(status: 'paid')
expired = reservations.where(status: 'expired')
@ -73,21 +86,54 @@ class Api::V1::Accounts::Captain::Reports::OperationalController < Api::V1::Acco
end
end
def scoped_conversations(unit, period_start, period_end)
def scoped_conversations(unit, inbox, period_start, period_end)
scope = Current.account.conversations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
if unit
if inbox
scope = scope.where(inbox_id: inbox.id)
elsif unit
inbox_ids = unit.inboxes.pluck(:id)
scope = scope.where(inbox_id: inbox_ids) if inbox_ids.any?
end
scope
end
def scoped_reservations(unit, period_start, period_end)
def scoped_reservations(unit, inbox, period_start, period_end)
scope = Current.account.captain_reservations.where(created_at: period_start.beginning_of_day..period_end.end_of_day)
scope = scope.where(captain_unit_id: unit.id) if unit
if inbox
conversation_ids = Current.account.conversations.where(inbox_id: inbox.id).pluck(:id)
scope = scope.where(conversation_id: conversation_ids)
elsif unit
scope = scope.where(captain_unit_id: unit.id)
end
scope
end
def by_inbox_breakdown(conversations)
resolved_int = Conversation.statuses['resolved']
open_int = Conversation.statuses['open']
inbox_data = conversations.group(:inbox_id).pluck(
:inbox_id,
Arel.sql('COUNT(*)'),
Arel.sql("COUNT(*) FILTER (WHERE status = #{resolved_int})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{open_int})")
)
inbox_names = Current.account.inboxes.where(id: inbox_data.map(&:first)).pluck(:id, :name).to_h
rows = inbox_data.map { |inbox_id, total, resolved, open| build_inbox_row(inbox_id, total, resolved, open, inbox_names) }
rows.sort_by { |row| -row[:total] }
end
def build_inbox_row(inbox_id, total, resolved, open, inbox_names)
{
inbox_id: inbox_id,
inbox_name: inbox_names[inbox_id] || "Canal ##{inbox_id}",
total: total,
resolved: resolved,
open: open,
resolution_rate: safe_rate(resolved, total)
}
end
def avg_resolution_minutes(conversations)
return 0 if conversations.none?

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
# Endpoints de retenção e recorrência pra Relatórios > Retenção.
# GET /api/v1/accounts/:account_id/captain/reports/retention → summary (KPIs)
# GET /api/v1/accounts/:account_id/captain/reports/retention/cohort → matriz cohort
class Api::V1::Accounts::Captain::Reports::RetentionController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
def show
summary = Captain::Reports::RetentionSummaryService.new(
account: current_account,
period_start: params[:start_date],
period_end: params[:end_date]
).call
render json: summary
end
def cohort
months = params[:history_months].presence&.to_i
data = Captain::Reports::RetentionCohortService.new(
account: current_account,
history_months: months || Captain::Reports::RetentionCohortService::DEFAULT_HISTORY_MONTHS
).call
render json: data
end
end

View File

@ -1,9 +1,10 @@
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
before_action :ensure_captain_enabled
before_action :set_unit, only: [:show, :update, :destroy]
before_action :set_unit, only: [:show, :update, :destroy, :update_concierge]
def index
@units = Current.account.captain_units.includes(:inboxes)
@units = filter_units_by_user_access(@units)
render json: @units.map { |u| format_unit(u) }
end
@ -38,12 +39,32 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
head :no_content
end
def update_concierge
return render json: { error: 'Unauthorized' }, status: :unauthorized unless Current.user.administrator?
@unit.update!(concierge_params)
render json: format_unit(@unit)
rescue ActiveRecord::RecordInvalid
render json: { errors: @unit.errors.full_messages }, status: :unprocessable_entity
end
private
def ensure_captain_enabled
# Dependendo da regra de negócio, pode-se verificar as features da conta aqui original
end
# Administrador vê todas as unidades; agente só vê unidades vinculadas a
# alguma caixa de entrada a qual ele tem acesso.
def filter_units_by_user_access(scope)
return scope if Current.user.administrator?
accessible_inbox_ids = Current.user.assigned_inboxes.pluck(:id)
return scope.none if accessible_inbox_ids.empty?
scope.joins(:inboxes).where(inboxes: { id: accessible_inbox_ids }).distinct
end
def default_brand
@default_brand ||= Captain::Brand.where(account_id: Current.account.id).first ||
Captain::Brand.create!(
@ -69,6 +90,13 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
)
end
def concierge_params
params.require(:captain_unit).permit(
:concierge_inbox_id,
concierge_config: [:persona_name, :knowledge, { variables: {} }]
)
end
def inbox_ids_param
return [] unless params[:captain_unit].key?(:inbox_ids)
@ -90,7 +118,7 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
end
end
def format_unit(unit)
def format_unit(unit) # rubocop:disable Metrics/AbcSize
inboxes = unit.inboxes.to_a
{
id: unit.id,
@ -107,6 +135,13 @@ class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseContr
has_key: unit.inter_key_content.present? || unit.resolved_inter_key_path.present?,
has_client_secret: unit.inter_client_secret.present?,
proactive_pix_polling_enabled: unit.proactive_pix_polling_enabled
}.merge(format_concierge(unit))
end
def format_concierge(unit)
{
concierge_inbox_id: unit.concierge_inbox_id,
concierge_config: unit.concierge_config || {}
}
end
end

View File

@ -215,6 +215,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay,
:message_signature_enabled, :message_signature_default_name, :message_signature_day_name,
:message_signature_night_even_name, :message_signature_night_odd_name,
:message_signature_night_shift_start, :message_signature_night_shift_end,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,

View File

@ -0,0 +1,58 @@
class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox, only: [:index, :create]
before_action :fetch_landing_host, only: [:update, :destroy]
def index
@landing_hosts = LandingHost.where(inbox_id: @inbox.id)
render json: @landing_hosts
end
def create
@landing_host = LandingHost.new(landing_host_params.merge(inbox_id: @inbox.id, active: true))
if @landing_host.save
render json: @landing_host, status: :created
else
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @landing_host.update(landing_host_params)
render json: @landing_host
else
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@landing_host.destroy!
head :no_content
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Inbox not found' }, status: :not_found
end
def fetch_landing_host
# Garantimos que a pessoa só possa acessar/apagar LandingHosts de Inboxes que pertencem a ela
valid_inbox_ids = Current.account.inboxes.pluck(:id)
@landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Landing Host not found' }, status: :not_found
end
def landing_host_params
params.require(:landing_host).permit(
:hostname, :unit_code, :active, :auto_label,
:page_title, :page_subtitle, :button_text, :logo_url,
:suite_image_url, :theme_color, :whatsapp_number,
:initial_message, :default_source, :default_campanha,
custom_config: {}
)
end
end

View File

@ -0,0 +1,83 @@
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
def show
clicks = account_clicks
render json: stats_payload(clicks)
end
private
def account_clicks
scope = LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
scope = scope.where(inbox_id: params[:inbox_id]) if params[:inbox_id].present?
apply_period_filter(scope)
end
def apply_period_filter(scope)
return scope unless params[:period_start].present? && params[:period_end].present?
start_at = Time.zone.parse(params[:period_start].to_s)&.beginning_of_day
end_at = Time.zone.parse(params[:period_end].to_s)&.end_of_day
return scope unless start_at && end_at
scope.where(created_at: start_at..end_at)
end
def stats_payload(clicks)
total_clicks = clicks.count
total_conversions = clicks.where.not(conversation_id: nil).count
total_non_converted = total_clicks - total_conversions
{
total_clicks: total_clicks,
total_conversions: total_conversions,
total_non_converted: total_non_converted,
drop_off_rate: percentage(total_non_converted, total_clicks),
conversion_rate: percentage(total_conversions, total_clicks),
unique_click_ids: clicks.where.not(click_id: [nil, '']).distinct.count(:click_id),
unique_converted_contacts: clicks.where.not(contact_id: nil).distinct.count(:contact_id),
daily: daily_breakdown(clicks),
by_source: group_by(clicks, :source),
by_campaign: group_by(clicks, :campanha),
by_hostname: group_by(clicks, :hostname)
}
end
def percentage(part, total)
return 0 unless total.positive?
(part.to_f / total * 100).round(1)
end
def group_by(clicks, column)
rows = clicks
.group(column)
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
grouped_rows = rows.map do |row|
{
label: row.public_send(column).presence || '(sem nome)',
clicks: row.clicks,
conversions: row.conversions,
rate: row.clicks.positive? ? (row.conversions.to_f / row.clicks * 100).round(1) : 0
}
end
grouped_rows.sort_by { |row| -row[:clicks] }
end
def daily_breakdown(clicks)
rows = clicks
.group('DATE(lead_clicks.created_at)')
.select('DATE(lead_clicks.created_at) AS day, COUNT(*) AS clicks, COUNT(lead_clicks.conversation_id) AS conversions')
.order('day ASC')
rows.map do |row|
{
day: row.day.to_s,
clicks: row.clicks.to_i,
conversions: row.conversions.to_i,
non_converted: row.clicks.to_i - row.conversions.to_i
}
end
end
end

View File

@ -96,7 +96,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
:aggressive_alert_enabled]
end
def check_signup_enabled

View File

@ -0,0 +1,44 @@
class Api::V1::TrackingController < ActionController::API
def click
LeadClick.create!(click_params)
head :no_content
rescue StandardError => e
Rails.logger.error("Error processing tracking click: #{e.message}")
head :no_content
end
private
def resolved_inbox_id
host = params[:hostname].to_s.strip.sub(%r{^https?://}, '')
LandingHost.find_by(hostname: host, active: true)&.inbox_id
end
def click_params
base_params = {
inbox_id: resolved_inbox_id,
ip: params[:ip].presence || request.remote_ip,
user_agent: request.user_agent || params[:user_agent],
hostname: params[:hostname].to_s.strip,
source: params[:source],
campanha: params[:campanha],
lp: params[:lp],
click_id: params[:click_id],
status: :clicked
}
# Se 'lp' for fornecido, extraímos os UTMs se fonte ou campanha estiverem vazios
if base_params[:lp].present?
begin
uri = URI.parse(base_params[:lp])
query = Rack::Utils.parse_nested_query(uri.query)
base_params[:source] ||= query['utm_source']
base_params[:campanha] ||= query['utm_campaign']
rescue StandardError => e
Rails.logger.warn("Error parsing LP URL for UTMs: #{e.message}")
end
end
base_params
end
end

View File

@ -1,3 +1,4 @@
# rubocop:disable Metrics/ClassLength
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
@ -58,7 +59,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def bot_metrics
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, bot_metrics_params).metrics
render json: bot_metrics
end
@ -87,6 +88,23 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: builder.build
end
def inbox_leads_summary
return head :unprocessable_entity if params[:inbox_id].blank?
builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params)
render json: builder.build
end
def conversion_funnel
builder = V2::Reports::ConversionFunnelBuilder.new(Current.account, conversion_funnel_params)
render json: builder.metrics
end
def inbox_benchmarking
builder = V2::Reports::InboxBenchmarkingBuilder.new(Current.account, inbox_benchmarking_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@ -188,4 +206,37 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
until: params[:until]
}
end
def bot_metrics_params
{
inbox_id: params[:inbox_id],
since: params[:since],
until: params[:until]
}
end
def inbox_leads_summary_params
{
inbox_id: params[:inbox_id],
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
def conversion_funnel_params
{
inbox_id: params[:inbox_id],
since: params[:since],
until: params[:until]
}
end
def inbox_benchmarking_params
{
since: params[:since],
until: params[:until]
}
end
end
# rubocop:enable Metrics/ClassLength

View File

@ -0,0 +1,193 @@
# frozen_string_literal: true
# Endpoint público para criação de reservas via React app externo (Reserva 1001).
# Autenticado por token estático via header X-Reserva-Token.
class Public::Api::V1::Captain::PublicReservationsController < ActionController::API
before_action :authenticate_reserva_token!
def create # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
unit = Captain::Unit.find_by(id: params[:chatwoot_unit_id])
return render(json: { error: 'unit_not_found' }, status: :not_found) if unit.nil?
return render(json: { error: 'unit_has_no_inbox' }, status: :unprocessable_entity) if unit.inbox_id.blank?
return render(json: { error: 'unit_missing_inter_credentials' }, status: :unprocessable_entity) unless unit.inter_credentials_present?
customer = params[:customer] || {}
return render(json: { error: 'customer_required' }, status: :unprocessable_entity) if customer[:name].blank?
return render(json: { error: 'customer_phone_required' }, status: :unprocessable_entity) if customer[:phone].blank?
account = unit.account
inbox = Inbox.find(unit.inbox_id)
# WhatsApp inbox exige source_id com apenas digitos (padrao E.164 sem o +)
phone_digits = customer[:phone].to_s.gsub(/\D/, '')
return render(json: { error: 'customer_phone_invalid' }, status: :unprocessable_entity) if phone_digits.empty? || phone_digits.length > 15
normalized_phone = "+#{phone_digits}"
contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: phone_digits,
inbox: inbox,
contact_attributes: {
name: customer[:name],
phone_number: normalized_phone,
email: customer[:email].presence,
custom_attributes: {
cpf: customer[:cpf].presence
}.compact,
additional_attributes: {
origem: 'reserva-1001'
}
}
).perform
# Atualiza campos visiveis do contato (alem do ContactInboxBuilder, que so
# preenche no create e nao mexe em contato ja existente).
persist_customer_metadata!(contact_inbox.contact, customer, params)
conversation = ConversationBuilder.new(
params: ActionController::Parameters.new(
additional_attributes: {
source: 'reserva-1001',
reserva_category: params[:category],
reserva_stay_type: params[:stay_type],
reserva_checkin_at: params[:checkin_at]
}
),
contact_inbox: contact_inbox
).perform
# Nota: o model Captain::Reservation#post_internal_reservation_note ja cria
# a nota interna automaticamente via after_create_commit. Nao duplicamos aqui.
reservation = Captain::Reservation.create!(
account: account,
inbox: inbox,
contact: contact_inbox.contact,
contact_inbox: contact_inbox,
conversation: conversation,
unit: unit,
suite_identifier: "#{params[:category]} · #{params[:stay_type]}",
check_in_at: params[:checkin_at],
check_out_at: checkout_from(params[:checkin_at], params[:stay_type]),
status: :draft,
payment_status: 'pending',
total_amount: (params[:total_cents].to_i / 100.0),
metadata: {
origem: 'reserva-1001',
category: params[:category],
stay_type: params[:stay_type],
deposit_cents: params[:deposit_cents].to_i,
notes: params[:notes]
}
)
mark_conversation_as_awaiting_payment(conversation)
deposit_amount = (params[:deposit_cents].to_i / 100.0)
charge = Captain::Inter::CobService.new(reservation, amount: deposit_amount).call
reservation.update!(status: :pending_payment)
render json: {
reservation_id: reservation.id,
conversation_id: conversation.id,
pix: {
txid: charge.txid,
copia_e_cola: charge.pix_copia_e_cola,
qrcode_base64: nil,
expires_at: (Time.current + Captain::PixCharge::EXPIRATION_SECONDS.seconds).iso8601
}
}, status: :created
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error("[PublicReservations] validation error: #{e.message}")
render json: { error: 'validation_failed', details: e.record.errors.full_messages }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error("[PublicReservations] unexpected error: #{e.class} - #{e.message}")
render json: { error: 'internal_error', message: e.message }, status: :internal_server_error
end
def status
reservation = Captain::Reservation.find_by(id: params[:id])
return render(json: { error: 'not_found' }, status: :not_found) if reservation.nil?
render json: {
reservation_id: reservation.id,
status: reservation.payment_status
}
end
private
def authenticate_reserva_token!
expected = ENV.fetch('RESERVA_1001_API_TOKEN', nil)
provided = request.headers['X-Reserva-Token']
if expected.blank?
Rails.logger.error('[PublicReservations] RESERVA_1001_API_TOKEN not configured')
render json: { error: 'service_unavailable' }, status: :service_unavailable and return
end
return if provided.present? && ActiveSupport::SecurityUtils.secure_compare(provided, expected)
render json: { error: 'unauthorized' }, status: :unauthorized
end
# Persiste CPF, email, ultima suite/permanencia, data e total de reservas
# no contact.custom_attributes para aparecer no painel lateral do Chatwoot
# e pra facilitar reservas futuras (cliente recorrente).
def persist_customer_metadata!(contact, customer, payload) # rubocop:disable Metrics/AbcSize
return if contact.blank?
current_custom = contact.custom_attributes || {}
current_custom = current_custom.dup
current_custom['cpf'] = customer[:cpf] if customer[:cpf].present?
current_custom['ultima_suite'] = payload[:category] if payload[:category].present?
current_custom['ultima_permanencia'] = payload[:stay_type] if payload[:stay_type].present?
current_custom['ultima_reserva_em'] = Time.current.iso8601
current_custom['total_reservas'] = (current_custom['total_reservas'].to_i + 1)
updates = { custom_attributes: current_custom }
updates[:email] = customer[:email] if customer[:email].present? && contact.email.blank?
updates[:name] = customer[:name] if customer[:name].present? && contact.name.blank?
contact.update!(updates)
rescue StandardError => e
Rails.logger.error("[PublicReservations] persist_customer_metadata failed: #{e.message}")
end
# Espelha Captain::Tools::GeneratePixTool#mark_conversation_as_awaiting_payment
# (enterprise/app/services/captain/tools/generate_pix_tool.rb:713-721)
def mark_conversation_as_awaiting_payment(conversation)
current = conversation.label_list
merged = (current + ['aguardando_pagamento']).uniq
merged -= %w[pagamento_confirmado reserva_feita]
conversation.update_labels(merged)
rescue StandardError => e
Rails.logger.error("[PublicReservations] label update failed: #{e.message}")
# Não falha a request por causa disso
end
def build_initial_note(payload)
<<~NOTE.strip
Nova reserva via reserva.1001
Categoria: #{payload[:category]}
Permanencia: #{payload[:stay_type]}
Check-in: #{payload[:checkin_at]}
Total: R$ #{format('%.2f', payload[:total_cents].to_i / 100.0)}
Entrada (PIX 50%): R$ #{format('%.2f', payload[:deposit_cents].to_i / 100.0)}
Observacao: #{payload[:notes].presence || '-'}
NOTE
end
def checkout_from(checkin_iso, stay_type)
checkin = Time.zone.parse(checkin_iso.to_s)
hours = case stay_type.to_s.downcase
when '2hrs' then 2
when '3hrs' then 3
when 'pernoite' then 12
when 'diaria', 'diária' then 24
else 4 # default: 4hrs (inclui '4hrs' e qualquer outro valor)
end
checkin + hours.hours
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Endpoint público disparado pelo frontend (reserva-1001 /roleta/:token) assim que
# o prêmio é revelado. Só enfileira o job — todo o trabalho (validação, claim atômico,
# envio de msg) acontece em Captain::Roleta::NotifyRevealedJob.
class Public::Api::V1::Captain::RouletteNotificationsController < ActionController::API
def create
token = params[:token].to_s.strip
if token.blank?
render json: { error: 'token ausente' }, status: :bad_request
return
end
Captain::Roleta::NotifyRevealedJob.perform_later(token)
render json: { enqueued: true }, status: :accepted
rescue StandardError => e
Rails.logger.error "[RouletteNotifications] erro: #{e.class} - #{e.message}"
render json: { error: 'Internal error' }, status: :unprocessable_entity
end
end

View File

@ -0,0 +1,23 @@
class Public::LandingPagesController < PublicController
layout false
def show
host = request.host.to_s.sub(/^www\./, '')
@landing_host = LandingHost.find_by(hostname: host, active: true)
# Fallback local para testes
return unless Rails.env.development? && @landing_host.nil?
@landing_host = LandingHost.first || LandingHost.new(
page_title: 'Atendimento Express',
page_subtitle: 'Clique e fale direto com a recepcao agora',
whatsapp_number: '556136131003',
initial_message: 'Ola! Tenho interesse.',
theme_color: '#27c15b',
logo_url: 'https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg',
unit_code: 'express',
default_source: 'direto',
default_campanha: 'site'
)
end
end

View File

@ -0,0 +1,43 @@
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
#
# Construtor responde async via POST pra esta URL com:
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
#
# Este controller identifica a sessão do admin (por session_id no metadata
# OU pelo cache key derivado de account_id que veio na query string) e
# armazena a resposta no Rails.cache pra UI poder ler via polling.
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def process_payload
content = params[:content].to_s.strip
return head :bad_request if content.blank?
session_key = resolve_session_key
if session_key.blank?
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
return head :ok
end
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
head :ok
rescue StandardError => e
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
head :internal_server_error
end
private
# Hermes nao propaga chat_id no metadata da resposta de callback, entao
# usamos a ultima sessao ativa do account (gravada por
# HermesBuilder::Storage.remember_last_session no /start e /create).
# MVP-safe pra 1 admin por vez por conta.
def resolve_session_key
account_id = params[:account_id]
return nil if account_id.blank?
HermesBuilder::Storage.last_session_for(account_id)
end
end

View File

@ -0,0 +1,226 @@
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
#
# Fluxo:
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
# 2. Hermes processa via subscription Codex/etc dele.
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
# 4. Este controller cria a mensagem outgoing na conversation correta.
#
# Identificação da conversation: como o Hermes não preserva metadata customizado
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
class Webhooks::Captain::HermesCallbackController < ApplicationController
RECENT_WINDOW = 5.minutes
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
# (quando o agente não sabe responder e quer escalar pra humano). NÃO
# bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
# próximas msgs não dispararem Hermes.
HANDOFF_PATTERNS = [
/\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
/\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
/\A\s*aguarde\s+um\s+instante/i
].freeze
# Loop detection: 2 sinais combinados.
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
# repetiu pergunta sobre o mesmo tópico.
LOOP_SIMILARITY_THRESHOLD = 0.50
LOOP_TOPIC_KEYWORD_OVERLAP = 3
LOOP_STOPWORDS = %w[
voce voces para por pra como mas isso esse essa estou esta este aqui ali
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
obrigado obrigada valeu vlw thanks por favor please
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
em com sem sob sobre antes apos depois entre meio tudo todo toda
perfeito otimo certinho confirma confirme
].freeze
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
before_action :fetch_inbox
def process_payload
content = extract_content
return head :bad_request if content.blank?
conversation = recent_conversation_for(@inbox)
return log_no_conversation_and_ack if conversation.blank?
log_reply(conversation, content)
detect_handoff_or_loop(conversation, content)
deliver_outgoing(conversation, content)
head :ok
rescue StandardError => e
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
head :internal_server_error
end
private
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
# pergunta reformulada) e escala.
def detect_handoff_or_loop(conversation, content)
if handoff_response?(content)
mark_for_human_triage(conversation, reason: 'handoff_intencional')
elsif looped_response?(conversation, content)
mark_for_human_triage(conversation, reason: 'loop_detectado')
end
end
def deliver_outgoing(conversation, content)
if defined?(Captain::Hermes::DelayedReplyJob)
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
else
create_outgoing_message(conversation, content)
end
end
def handoff_response?(content)
return false if content.blank?
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
end
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
# outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
# agente está repetindo pergunta/resposta sem progredir — geralmente
# cliente fora do escopo (operadora telefonia, banco, suporte de outro
# app, etc) OU fluxo travado.
def looped_response?(conversation, content)
prev = conversation.messages
.where(message_type: :outgoing)
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
.reorder(created_at: :desc)
.limit(1)
.pick(:content)
return false if prev.blank?
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
repeated_question?(content, prev)
end
def similarity(text_a, text_b)
set_a = tokenize(text_a)
set_b = tokenize(text_b)
return 0.0 if set_a.empty? || set_b.empty?
intersection = (set_a & set_b).size
union = (set_a | set_b).size
intersection.to_f / union
end
# Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
def repeated_question?(text_a, text_b)
return false unless inquisitive?(text_a) && inquisitive?(text_b)
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
end
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
def inquisitive?(text)
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
end
def tokenize(text)
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
end
def mark_for_human_triage(conversation, reason: nil)
current = conversation.label_list
conversation.update_labels((current + %w[triagem_humana]).uniq)
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
end
def fetch_inbox
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
if inbox_id.present?
@inbox = Inbox.find_by(id: inbox_id)
elsif (slug = params[:slug].presence)
# Resolve via slug (hermes_profile_name) — admin pode re-apontar a
# inbox pra qualquer agente Hermes sem mexer em URL de callback.
asst = Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
ci = asst&.captain_inboxes&.first
@inbox = ci&.inbox
end
head :not_found if @inbox.blank?
end
def verify_signature
secret = Captain::Hermes.callback_signing_secret
return true if secret.blank? # validação desabilitada (PoC sem secret)
signature = request.headers['X-Hermes-Callback-Signature'].to_s
return head :unauthorized if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
true
end
def recent_conversation_for(inbox)
inbox.conversations
.where('updated_at >= ?', RECENT_WINDOW.ago)
.where(status: %w[pending open])
.reorder(updated_at: :desc)
.first
end
def log_no_conversation_and_ack
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
head :ok
end
def extract_content
normalize_for_whatsapp(params[:content].to_s.strip)
end
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
# **negrito** -> *negrito*
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
def normalize_for_whatsapp(content)
return content if content.blank?
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
end
def log_reply(conversation, content)
Rails.logger.info(
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
)
end
def create_outgoing_message(conversation, content)
assistant = conversation.inbox.captain_assistant
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: sender,
content: content,
content_attributes: {
external_source: 'hermes_callback'
}
)
end
end

View File

@ -0,0 +1,110 @@
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
#
# POST /webhooks/captain/mcp
#
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
# Captain (add_label, faq_lookup, generate_pix, etc).
#
# Conexão pelo Hermes:
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
#
# Auth: aceita 2 modos (qualquer um basta):
# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer <CAPTAIN_MCP_SECRET>`
# É o que `hermes mcp add --auth header` usa nativamente.
# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=<hex>`
# Para clientes que preferem assinar o body inteiro.
# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio,
# validação é desabilitada (PoC/dev).
#
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
# esse contexto pra resolver a conversa correta.
class Webhooks::Captain::McpController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
def process_payload
request_body = parse_request_body
return head :bad_request if request_body.blank?
response = Captain::Mcp::Server.handle(
request_body,
context: extract_context(request_body)
)
return head :ok if response.nil? # MCP notifications
render json: response
rescue StandardError => e
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
end
private
def parse_request_body
JSON.parse(request.raw_post)
rescue JSON::ParserError
nil
end
def verify_signature
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
return true if secret.blank?
return true if bearer_token_matches?(secret)
return true if hmac_signature_matches?(secret)
head :unauthorized
end
def bearer_token_matches?(secret)
auth_header = request.headers['Authorization'].to_s
return false unless auth_header.start_with?('Bearer ')
token = auth_header.delete_prefix('Bearer ').strip
ActiveSupport::SecurityUtils.secure_compare(token, secret)
end
def hmac_signature_matches?(secret)
signature = request.headers['X-Hub-Signature-256'].to_s
return false if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
# é (já que MCP em si é stateless entre client/server).
#
# Fallback: cada profile do Hermes está atrelado a uma unidade
# (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos
# contexto via headers HTTP fixos no config.yaml do profile:
# X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id.
# Body wins se houver conflito (override por chamada).
def extract_context(request_body)
params = request_body['params'] || {}
body_ctx = params['_captain_context'] || {}
body_ctx = {} unless body_ctx.is_a?(Hash)
extract_header_context.merge(body_ctx.symbolize_keys)
end
def extract_header_context
{
account_id: header_int('X-Captain-Account-Id'),
assistant_id: header_int('X-Captain-Assistant-Id'),
inbox_id: header_int('X-Captain-Inbox-Id')
}.compact
end
def header_int(name)
value = request.headers[name].to_s
return nil if value.blank?
value.to_i
end
end

View File

@ -0,0 +1,2 @@
module Api::V1::Accounts::LandingHostsHelper
end

View File

@ -5,6 +5,7 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import AggressiveConversationBanner from './components/app/AggressiveConversationBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@ -30,6 +31,7 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
AggressiveConversationBanner,
},
setup() {
const router = useRouter();
@ -134,6 +136,7 @@ export default {
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<AggressiveConversationBanner />
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />

View File

@ -0,0 +1,34 @@
/* global axios */
import ApiClient from '../ApiClient';
class ContactMemoriesAPI extends ApiClient {
constructor() {
super('memories', { accountScoped: true });
}
get url() {
return `${this.baseUrl()}/contacts/${this.contactId}/memories`;
}
list(contactId) {
this.contactId = contactId;
return axios.get(this.url);
}
update(contactId, id, payload) {
this.contactId = contactId;
return axios.patch(`${this.url}/${id}`, payload);
}
destroy(contactId, id) {
this.contactId = contactId;
return axios.delete(`${this.url}/${id}`);
}
forgetAll(contactId) {
this.contactId = contactId;
return axios.delete(this.url);
}
}
export default new ContactMemoriesAPI();

View File

@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainFunnel extends ApiClient {
constructor() {
super('captain/reports/funnel', { accountScoped: true });
}
get(periodDays = 30) {
return axios.get(this.url, { params: { period_days: periodDays } });
}
}
export default new CaptainFunnel();

View File

@ -0,0 +1,38 @@
/* global axios */
import ApiClient from '../ApiClient';
class HermesBuilder extends ApiClient {
constructor() {
super('captain/hermes_builder', { accountScoped: true });
}
fetchMessages() {
return axios.get(this.url);
}
sendMessage(text) {
return axios.post(this.url, { text });
}
start() {
return axios.post(`${this.url}/start`);
}
reset() {
return axios.delete(`${this.url}/reset`);
}
fetchAssistants() {
return axios.get(`${this.url}/assistants`);
}
validate(slug) {
return axios.get(`${this.url}/validate`, { params: { slug } });
}
repair(slug, repairId) {
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
}
}
export default new HermesBuilder();

View File

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleConfig extends ApiClient {
constructor() {
super('captain/lifecycle_config', { accountScoped: true });
}
show() {
return axios.get(this.url);
}
update(data) {
return axios.patch(this.url, { config: data });
}
}
export default new CaptainLifecycleConfig();

View File

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleDeliveries extends ApiClient {
constructor() {
super('captain/lifecycle_deliveries', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
}
export default new CaptainLifecycleDeliveries();

View File

@ -0,0 +1,30 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleRules extends ApiClient {
constructor() {
super('captain/lifecycle_rules', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data) {
return axios.post(this.url, { rule: data });
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, { rule: data });
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainLifecycleRules();

View File

@ -21,6 +21,26 @@ class CaptainReportsAPI extends ApiClient {
generateInsight(data) {
return axios.post(`${this.url}/insights/generate`, data);
}
getExecutive(params = {}) {
return axios.get(`${this.url}/executive`, { params });
}
drilldown(params = {}) {
return axios.get(`${this.url}/executive/drilldown`, { params });
}
deliverExecutive(params = {}) {
return axios.post(`${this.url}/executive/deliver`, params);
}
getRetention(params = {}) {
return axios.get(`${this.url}/retention`, { params });
}
getRetentionCohort(params = {}) {
return axios.get(`${this.url}/retention/cohort`, { params });
}
}
export default new CaptainReportsAPI();

View File

@ -10,6 +10,10 @@ class CaptainReservations extends ApiClient {
return axios.get(this.url, { params });
}
create(data) {
return axios.post(this.url, { reservation: data });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
@ -21,6 +25,18 @@ class CaptainReservations extends ApiClient {
pix(id) {
return axios.get(`${this.url}/${id}/pix`);
}
cancel(id, reason = '') {
return axios.post(`${this.url}/${id}/cancel`, { reason });
}
markAsPaid(id, note = '') {
return axios.post(`${this.url}/${id}/mark_as_paid`, { note });
}
regeneratePix(id) {
return axios.post(`${this.url}/${id}/regenerate_pix`, {});
}
}
export default new CaptainReservations();

View File

@ -0,0 +1,24 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainRoleta extends ApiClient {
constructor() {
super('captain/roleta', { accountScoped: true });
}
pending(params = {}) {
return axios.get(`${this.url}/pending`, { params });
}
redeem(code, notes = '') {
return axios.post(`${this.url}/redeem`, { code, notes });
}
weeklyReport(periodDays = 7) {
return axios.get(`${this.url}/weekly_report`, {
params: { period_days: periodDays },
});
}
}
export default new CaptainRoleta();

View File

@ -1,3 +1,4 @@
/* global axios */
import ApiClient from '../../api/ApiClient';
class CaptainUnitsAPI extends ApiClient {
@ -24,6 +25,12 @@ class CaptainUnitsAPI extends ApiClient {
deleteUnit(id) {
return this.delete(id);
}
updateConcierge(id, payload) {
return axios.patch(`${this.url}/${id}/concierge`, {
captain_unit: payload,
});
}
}
export default new CaptainUnitsAPI();

View File

@ -0,0 +1,27 @@
// API client para LandingHosts da caixa de entrada
/* global axios */
export default {
getHosts(accountId, inboxId) {
return axios.get(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`
);
},
createHost(accountId, inboxId, data) {
return axios.post(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts`,
{ landing_host: data }
);
},
updateHost(accountId, inboxId, id, data) {
return axios.patch(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`,
{ landing_host: data }
);
},
deleteHost(accountId, inboxId, id) {
return axios.delete(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`
);
},
};

View File

@ -91,23 +91,57 @@ class ReportsAPI extends ApiClient {
});
}
getBotMetrics({ from, to } = {}) {
getBotMetrics({ from, to, inboxId } = {}) {
return axios.get(`${this.url}/bot_metrics`, {
params: { since: from, until: to },
params: { since: from, until: to, inbox_id: inboxId },
});
}
getBotSummary({ from, to, groupBy, businessHours } = {}) {
getBotSummary({ from, to, groupBy, businessHours, type, id } = {}) {
return axios.get(`${this.url}/bot_summary`, {
params: {
since: from,
until: to,
type: 'account',
type: type || 'account',
id,
group_by: groupBy,
business_hours: businessHours,
},
});
}
getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) {
return axios.get(`${this.url}/inbox_leads_summary`, {
params: {
inbox_id: inboxId,
since: from,
until: to,
group_by: groupBy,
timezone_offset: getTimeOffset(),
},
});
}
getConversionFunnel({ inboxId, from, to } = {}) {
return axios.get(`${this.url}/conversion_funnel`, {
params: {
inbox_id: inboxId,
since: from,
until: to,
timezone_offset: getTimeOffset(),
},
});
}
getInboxBenchmarking({ from, to } = {}) {
return axios.get(`${this.url}/inbox_benchmarking`, {
params: {
since: from,
until: to,
timezone_offset: getTimeOffset(),
},
});
}
}
export default new ReportsAPI();

View File

@ -21,6 +21,7 @@ const state = reactive({
id: '',
title: '',
description: '',
trigger_keywords: '',
instruction: '',
});
@ -55,6 +56,7 @@ const resetState = () => {
id: '',
title: '',
description: '',
trigger_keywords: '',
instruction: '',
});
};
@ -119,6 +121,24 @@ const onClickCancel = () => {
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<TextArea
v-model="state.trigger_keywords"
:label="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.LABEL'
)
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.PLACEHOLDER'
)
"
:message="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.HELP')
"
message-type="info"
rows="8"
/>
<Editor
v-model="state.instruction"
:label="

View File

@ -26,6 +26,10 @@ const props = defineProps({
type: Number,
required: true,
},
engine: {
type: String,
default: 'captain_interno',
},
});
const emit = defineEmits(['action']);
@ -76,11 +80,27 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<div class="flex items-center gap-2 min-w-0">
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<span
v-if="engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
</div>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"

View File

@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
const props = defineProps({
id: {
@ -71,6 +72,7 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const modelValue = computed({
get: () => props.isSelected,
@ -142,7 +144,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="responseManagePermissions"
class="relative flex items-center group"
>
<Button
@ -168,7 +170,7 @@ const handleDocumentableClick = () => {
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<Policy v-if="showActions" :permissions="responseManagePermissions">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"

View File

@ -26,6 +26,10 @@ const props = defineProps({
type: String,
required: true,
},
triggerKeywords: {
type: String,
default: '',
},
instruction: {
type: String,
required: true,
@ -58,6 +62,7 @@ const state = reactive({
id: '',
title: '',
description: '',
trigger_keywords: '',
instruction: '',
});
@ -74,6 +79,7 @@ const startEdit = () => {
id: props.id,
title: props.title,
description: props.description,
trigger_keywords: props.triggerKeywords || '',
instruction: props.instruction,
tools: props.tools,
});
@ -223,6 +229,22 @@ const renderInstruction = instruction => () =>
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<TextArea
v-model="state.trigger_keywords"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.PLACEHOLDER'
)
"
:message="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TRIGGER_KEYWORDS.HELP')
"
message-type="info"
rows="8"
/>
<Editor
v-model="state.instruction"
:label="

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, watch } from 'vue';
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
@ -12,21 +12,52 @@ const props = defineProps({
const emit = defineEmits(['submit']);
// Delimitador que separa o prompt base do sistema das instruções do assistente.
// Este comentário fica na string salva no banco, mas é invisível para o usuário na UI.
const SECTION_DELIMITER = '\n# ---SECAO-ASSISTENTE---\n';
const { t } = useI18n();
const promptText = ref('');
const originalText = ref('');
const isDirty = ref(false);
const systemText = ref('');
const assistantText = ref('');
const originalSystem = ref('');
const originalAssistant = ref('');
// true quando o prompt salvo não tem delimitador avisa o usuário antes de salvar
const missingDelimiter = ref(false);
function splitPrompt(fullText) {
const idx = fullText.indexOf(SECTION_DELIMITER);
if (idx === -1) {
missingDelimiter.value = true;
return { system: fullText, assistant: '' };
}
missingDelimiter.value = false;
return {
system: fullText.substring(0, idx),
assistant: fullText.substring(idx + SECTION_DELIMITER.length),
};
}
function joinPrompt() {
return systemText.value + SECTION_DELIMITER + assistantText.value;
}
const isDirty = computed(
() =>
systemText.value !== originalSystem.value ||
assistantText.value !== originalAssistant.value
);
const updateStateFromAssistant = assistant => {
// Pré-popula com o prompt customizado salvo, ou com o .liquid padrão como ponto de partida
const initialValue =
const fullText =
assistant.orchestrator_prompt ||
assistant.default_orchestrator_prompt ||
'';
promptText.value = initialValue;
originalText.value = initialValue;
isDirty.value = false;
const { system, assistant: assistantPart } = splitPrompt(fullText);
systemText.value = system;
assistantText.value = assistantPart;
originalSystem.value = system;
originalAssistant.value = assistantPart;
};
watch(
@ -37,33 +68,30 @@ watch(
{ immediate: true }
);
watch(promptText, newVal => {
isDirty.value = newVal !== originalText.value;
});
const handleSave = () => {
if (!promptText.value.trim()) {
const full = joinPrompt();
if (!full.trim()) {
useAlert(t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.VALIDATION_ERROR'));
return;
}
emit('submit', { orchestrator_prompt: promptText.value });
originalText.value = promptText.value;
isDirty.value = false;
emit('submit', { orchestrator_prompt: full });
originalSystem.value = systemText.value;
originalAssistant.value = assistantText.value;
};
const handleReset = () => {
// Envia null para limpar o banco e voltar ao .liquid padrão
emit('submit', { orchestrator_prompt: null });
// Restaura a textarea para mostrar o conteúdo padrão novamente
const defaultPrompt = props.assistant?.default_orchestrator_prompt || '';
promptText.value = defaultPrompt;
originalText.value = defaultPrompt;
isDirty.value = false;
const defaultFull = props.assistant?.default_orchestrator_prompt || '';
const { system, assistant: assistantPart } = splitPrompt(defaultFull);
systemText.value = system;
assistantText.value = assistantPart;
originalSystem.value = system;
originalAssistant.value = assistantPart;
};
</script>
<template>
<div class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-6 w-full">
<!-- Aviso de risco -->
<div
class="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-200 text-yellow-800 w-full"
@ -74,23 +102,74 @@ const handleReset = () => {
</p>
</div>
<!-- Textarea do prompt -->
<!-- Aviso: prompt sem delimitador (versão antiga) -->
<div
v-if="missingDelimiter"
class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 border border-blue-200 text-blue-800 w-full"
>
<span class="i-lucide-info mt-0.5 shrink-0 text-blue-500" />
<p class="text-sm leading-relaxed">
{{ t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_PREFIX') }}
<strong>{{
t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_BUTTON')
}}</strong>
{{ t('CAPTAIN_ORCHESTRATOR_EDITOR.MISSING_DELIMITER_SUFFIX') }}
</p>
</div>
<!-- Seção 1: Prompt Base do Sistema -->
<div class="flex flex-col gap-2 w-full">
<div class="flex flex-col">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.LABEL') }}
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DESCRIPTION') }}
</p>
<div class="flex items-center gap-2">
<span class="i-lucide-shield text-n-slate-10 text-sm" />
<div class="flex flex-col">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_LABEL') }}
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_DESCRIPTION') }}
</p>
</div>
</div>
<div class="w-full">
<textarea
v-model="promptText"
:placeholder="t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.PLACEHOLDER')"
class="w-full min-h-[500px] rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
/>
<textarea
v-model="systemText"
:placeholder="
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.SYSTEM_PLACEHOLDER')
"
class="w-full min-h-[500px] rounded-lg border border-n-weak bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
/>
</div>
<!-- Divisor visual -->
<div class="flex items-center gap-3">
<div class="flex-1 border-t border-dashed border-n-weak" />
<span class="text-xs text-n-slate-9 whitespace-nowrap">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.DIVIDER_LABEL') }}
</span>
<div class="flex-1 border-t border-dashed border-n-weak" />
</div>
<!-- Seção 2: Instruções do Assistente -->
<div class="flex flex-col gap-2 w-full">
<div class="flex items-center gap-2">
<span class="i-lucide-pencil text-n-brand text-sm" />
<div class="flex flex-col">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_LABEL') }}
</label>
<p class="text-xs text-n-slate-11">
{{
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_DESCRIPTION')
}}
</p>
</div>
</div>
<textarea
v-model="assistantText"
:placeholder="
t('CAPTAIN.ASSISTANTS.ORCHESTRATOR_PROMPT.ASSISTANT_PLACEHOLDER')
"
class="w-full min-h-[500px] rounded-lg border border-n-brand/30 bg-n-alpha-1 px-3 py-2.5 text-sm text-n-slate-12 placeholder:text-n-slate-9 focus:outline-none focus:ring-2 focus:ring-n-brand resize-y font-mono"
/>
</div>
<!-- Botões -->

View File

@ -6,6 +6,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { computed } from 'vue';
@ -28,6 +29,7 @@ const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const onClick = () => {
emit('click');
@ -56,7 +58,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:action-perms="responseManagePermissions"
:show-backdrop="isApproved"
>
<template v-if="isApproved" #empty-state-item>

View File

@ -1,6 +1,6 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
@ -27,6 +27,22 @@ const route = useRoute();
const dialogRef = ref(null);
const responseForm = ref(null);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => {
if (route.params.assistantId) return [];
return assistants.value.map(assistant => ({
label: assistant.name,
value: assistant.id,
}));
});
onMounted(() => {
if (!route.params.assistantId && !assistants.value.length) {
store.dispatch('captainAssistants/get');
}
});
const updateResponse = responseDetails =>
store.dispatch('captainResponses/update', {
id: props.selectedResponse.id,
@ -40,15 +56,17 @@ const createResponse = responseDetails =>
const handleSubmit = async updatedResponse => {
try {
const assistantId =
route.params.assistantId || updatedResponse.assistant_id;
if (props.type === 'edit') {
await updateResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
assistant_id: assistantId,
});
} else {
await createResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
assistant_id: assistantId,
});
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
@ -84,6 +102,7 @@ defineExpose({ dialogRef });
ref="responseForm"
:mode="type"
:response="selectedResponse"
:assistants="assistantOptions"
@submit="handleSubmit"
@cancel="handleCancel"
/>

View File

@ -2,12 +2,13 @@
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { required, minLength, maxLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
mode: {
@ -19,6 +20,10 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
assistants: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['submit', 'cancel']);
@ -31,14 +36,29 @@ const formState = {
const initialState = {
question: '',
answer: '',
assistant_id: '',
};
const QUESTION_MAX_LENGTH = 255;
const state = reactive({ ...initialState });
const validationRules = {
question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) },
};
const validationRules = computed(() => {
const rules = {
question: {
required,
minLength: minLength(1),
maxLength: maxLength(QUESTION_MAX_LENGTH),
},
answer: { required, minLength: minLength(1) },
};
if (props.assistants && props.assistants.length > 0) {
rules.assistant_id = { required };
}
return rules;
});
const v$ = useVuelidate(validationRules, state);
@ -53,6 +73,12 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'),
assistant_id: v$.value.assistant_id?.$error
? t(
'CAPTAIN.RESPONSES.FORM.ASSISTANT.ERROR',
'Por favor, selecione um assistente.'
)
: '',
}));
const handleCancel = () => emit('cancel');
@ -60,6 +86,7 @@ const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
question: state.question,
answer: state.answer,
...(state.assistant_id ? { assistant_id: state.assistant_id } : {}),
});
const handleSubmit = async () => {
@ -74,18 +101,19 @@ const handleSubmit = async () => {
const updateStateFromResponse = response => {
if (!response) return;
const { question, answer } = response;
const { question, answer, assistant_id } = response;
Object.assign(state, {
question,
answer,
assistant_id: assistant_id || '',
});
};
watch(
() => props.response,
newResponse => {
if (props.mode === 'edit' && newResponse) {
if (newResponse) {
updateStateFromResponse(newResponse);
}
},
@ -101,6 +129,7 @@ watch(
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
:message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'"
:maxlength="QUESTION_MAX_LENGTH"
/>
<Editor
v-model="state.answer"
@ -110,7 +139,27 @@ watch(
:max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<div
v-if="assistants && assistants.length > 0"
class="flex flex-col w-full gap-2"
>
<label class="text-sm font-medium text-n-slate-11">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL', 'Assistente') }}
</label>
<ComboBox
v-model="state.assistant_id"
:options="assistants"
:has-error="!!formErrors.assistant_id"
:message="formErrors.assistant_id"
:placeholder="
t(
'CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER',
'Por favor selecione o Assistente'
)
"
/>
</div>
<div class="flex items-center justify-between w-full gap-3 mt-2">
<Button
type="button"
variant="faded"

View File

@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Policy from 'dashboard/components/policy.vue';
const emit = defineEmits(['close', 'createAssistant']);
@ -105,14 +106,16 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
<Policy :permissions="['administrator']">
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</Policy>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
@ -130,6 +133,20 @@ const openCreateAssistantDialog = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<span
v-if="assistant.engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"

View File

@ -199,6 +199,61 @@ export function useContactFilterContext() {
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
// --- Retenção / recorrência ---
{
attributeKey: CONTACT_ATTRIBUTES.IS_RECURRING,
value: CONTACT_ATTRIBUTES.IS_RECURRING,
attributeName: 'Cliente recorrente',
label: 'Cliente recorrente',
inputType: 'searchSelect',
options: [
{ id: 'true', name: 'Sim' },
{ id: 'false', name: 'Não' },
],
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
value: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT,
attributeName: 'Última interação',
label: 'Última interação',
inputType: 'date',
dataType: 'text',
filterOperators: dateOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
value: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION,
attributeName: 'Dias sem interagir',
label: 'Dias sem interagir',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
value: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT,
attributeName: 'Nº de interações',
label: 'Nº de interações',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
{
attributeKey: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
value: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT,
attributeName: 'Reservas pagas',
label: 'Reservas pagas',
inputType: 'plainText',
dataType: 'number',
filterOperators: equalityOperators.value,
attributeModel: 'standard',
},
...customFilterTypes.value,
]);

View File

@ -28,6 +28,12 @@ export const CONTACT_ATTRIBUTES = {
REFERER: 'referer',
BLOCKED: 'blocked',
LABELS: 'labels',
// Retenção / recorrência (stats desnormalizados em contacts)
IS_RECURRING: 'is_recurring',
LAST_INTERACTION_AT: 'last_interaction_at',
DAYS_SINCE_LAST_INTERACTION: 'days_since_last_interaction',
INTERACTIONS_COUNT: 'interactions_count',
RESERVATIONS_PAID_COUNT: 'reservations_paid_count',
};
/**

View File

@ -379,7 +379,7 @@ const contextMenuEnabledOptions = computed(() => {
(hasText || hasAttachments) &&
!isFailedOrProcessing &&
!isMessageDeleted.value,
cannedResponse: isOutgoing && hasText && !isMessageDeleted.value,
cannedResponse: !isMessageDeleted.value,
copyLink: !isFailedOrProcessing,
translate: !isFailedOrProcessing && !isMessageDeleted.value && hasText,
replyTo:

View File

@ -1,8 +1,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import BaseBubble from './Base.vue';
import Button from 'next/button/Button.vue';
import Icon from 'next/icon/Icon.vue';
@ -20,19 +19,11 @@ const attachment = computed(() => {
return attachments.value[0];
});
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'image',
});
const hasError = ref(false);
const showGallery = ref(false);
const isDownloading = ref(false);
onMounted(() => {
if (attachment.value?.dataUrl) {
loadWithRetry(attachment.value.dataUrl);
}
});
const downloadAttachment = async () => {
const { fileType, dataUrl, extension } = attachment.value;
try {
@ -62,12 +53,13 @@ const handleImageError = () => {
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
</p>
</div>
<div v-else-if="isLoaded" class="relative group rounded-lg overflow-hidden">
<div v-else class="relative group rounded-lg overflow-hidden">
<img
class="skip-context-menu"
:src="attachment.dataUrl"
:width="attachment.width"
:height="attachment.height"
@error="handleImageError"
/>
<div
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"

View File

@ -1,13 +1,6 @@
<script setup>
import {
computed,
onMounted,
useTemplateRef,
ref,
getCurrentInstance,
} from 'vue';
import { computed, useTemplateRef, ref, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n';
import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
import Icon from 'next/icon/Icon.vue';
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
import { downloadFile } from '@chatwoot/utils';
@ -30,9 +23,7 @@ defineOptions({
});
const { t } = useI18n();
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry({
type: 'audio',
});
const hasError = ref(false);
const timeStampURL = computed(() => {
return timeStampAppendedURL(attachment.dataUrl);
@ -59,11 +50,9 @@ const playbackSpeedLabel = computed(() => {
return `${playbackSpeed.value}x`;
});
onMounted(() => {
if (attachment.dataUrl) {
loadWithRetry(attachment.dataUrl);
}
});
const handleAudioError = () => {
hasError.value = true;
};
// Listen for global audio play events and pause if it's not this audio
useEmitter('pause_playing_audio', currentPlayingId => {
@ -143,7 +132,7 @@ const downloadAudio = async () => {
{{ t('COMPONENTS.MEDIA.AUDIO_UNAVAILABLE') }}
</p>
</div>
<template v-else-if="isLoaded">
<template v-else>
<audio
ref="audioPlayer"
controls
@ -152,6 +141,7 @@ const downloadAudio = async () => {
@loadedmetadata="onLoadedMetadata"
@timeupdate="onTimeUpdate"
@ended="onEnd"
@error="handleAudioError"
>
<source :src="timeStampURL" />
</audio>

View File

@ -402,12 +402,40 @@ const menuItems = computed(() => {
],
to: accountScopedRoute('captain_settings_gallery'),
},
{
name: 'Lifecycle',
label: t('SIDEBAR.CAPTAIN_LIFECYCLE'),
activeOn: [
'captain_lifecycle_rules',
'captain_lifecycle_settings',
'captain_lifecycle_history',
],
to: accountScopedRoute('captain_lifecycle_rules'),
},
{
name: 'Reservations',
label: t('SIDEBAR.CAPTAIN_RESERVATIONS'),
activeOn: ['captain_reservations_index'],
to: accountScopedRoute('captain_reservations_index'),
},
{
name: 'Roleta',
label: t('SIDEBAR.CAPTAIN_ROLETA'),
activeOn: ['captain_roleta_index'],
to: accountScopedRoute('captain_roleta_index'),
},
{
name: 'HermesBuilder',
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
activeOn: ['captain_hermes_builder_index'],
to: accountScopedRoute('captain_hermes_builder_index'),
},
{
name: 'Funnel',
label: t('SIDEBAR.CAPTAIN_FUNNEL'),
activeOn: ['captain_funnel_index'],
to: accountScopedRoute('captain_funnel_index'),
},
{
name: 'Reports',
label: t('SIDEBAR.CAPTAIN_REPORTS'),
@ -528,6 +556,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Directory Dashboard',
label: t('SIDEBAR.REPORTS_DIRECTORY_DASHBOARD'),
to: accountScopedRoute('directory_dashboard_reports'),
},
],
},
{

View File

@ -0,0 +1,340 @@
<script>
import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import aggressiveAlert from 'dashboard/helper/aggressiveAlert';
import inactivityAlertTracker from 'dashboard/helper/inactivityAlertTracker';
export default {
name: 'AggressiveConversationBanner',
data() {
return {
alerts: [],
maxLevel: null,
};
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
allConversations: 'getAllConversations',
currentUser: 'getCurrentUser',
}),
allowedInboxIds() {
// null sem filtro (todas); array só essas.
const raw =
this.currentUser &&
this.currentUser.ui_settings &&
this.currentUser.ui_settings.aggressive_alert_inbox_ids;
if (raw == null) return null;
if (!Array.isArray(raw)) return null;
return raw.map(id => Number(id));
},
hasAlerts() {
return this.alerts.length > 0;
},
bannerClass() {
return [
'aggressive-banner',
this.maxLevel ? `aggressive-banner--${this.maxLevel}` : '',
];
},
bannerHeadline() {
const count = this.alerts.length;
if (count === 1) {
const a = this.alerts[0];
if (a.kind === 'reopened') {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_REOPENED',
'Conversa reaberta — responda agora'
);
}
// inactivity mostra tempo
if (a.minutes >= 28) {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_28',
{ minutes: a.minutes },
`🚨 ${a.minutes} MIN SEM RESPOSTA — conversa fecha em breve`
);
}
if (a.minutes >= 15) {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_15',
{ minutes: a.minutes },
`⚠️ ${a.minutes} MIN SEM RESPOSTA`
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_5',
{ minutes: a.minutes },
`${a.minutes} min sem resposta`
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_MULTIPLE',
{ count },
`🚨 ${count} conversas aguardando resposta`
);
},
explanation() {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.EXPLANATION',
'Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente.'
);
},
},
watch: {
// Rehidrata o tracker de inatividade toda vez que a lista de conversas
// muda (inclusive no boot). Dessa forma, conversas que já estão em
// 'open' com o cliente esperando resposta entram no tracker mesmo
// quando o usuário só abriu a aba sem receber mensagem ao vivo.
allConversations: {
handler(conversations) {
const allowed = this.allowedInboxIds;
const filtered =
allowed === null
? conversations
: (conversations || []).filter(c =>
allowed.includes(Number(c && c.inbox_id))
);
inactivityAlertTracker.hydrateFromConversations(filtered);
},
immediate: true,
},
},
mounted() {
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
// Rehidrata se alertas foram disparados antes do componente montar
this.refreshAlerts();
},
beforeUnmount() {
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
},
methods: {
refreshAlerts() {
this.alerts = aggressiveAlert.getActiveConversations();
this.maxLevel = aggressiveAlert.getMaxLevel();
},
openConversation(alert) {
// Clica no item abre conversa E esconde o alerta dela (mas se
// não responder, volta a aparecer no próximo threshold).
// Param tem que ser `conversation_id` (snake_case, como
// declarado no path da rota); camelCase faz Vue Router não casar
// e cair em "selecione uma conversa".
aggressiveAlert.dismiss(alert.id);
if (!this.currentAccountId) return;
this.$router.push({
name: 'inbox_conversation',
params: {
accountId: this.currentAccountId,
conversation_id: alert.id,
},
});
},
dismissOne(alert) {
aggressiveAlert.dismiss(alert.id);
},
alertItemClass(alert) {
return [
'aggressive-banner__item',
`aggressive-banner__item--${alert.level}`,
];
},
alertContextLabel(alert) {
if (alert.kind === 'reopened') {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.KIND_REOPENED',
'reabriu'
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.KIND_WAITING',
{ minutes: alert.minutes || '?' },
`${alert.minutes || '?'} min sem resposta`
);
},
},
};
</script>
<template>
<div v-if="hasAlerts" :class="bannerClass" role="alert" aria-live="assertive">
<div class="aggressive-banner__headline">
{{ bannerHeadline }}
</div>
<div class="aggressive-banner__explanation">
{{ explanation }}
</div>
<ul class="aggressive-banner__list">
<li
v-for="alert in alerts"
:key="alert.id"
:class="alertItemClass(alert)"
>
<button
type="button"
class="aggressive-banner__open"
@click="openConversation(alert)"
>
<span class="aggressive-banner__contact">{{
alert.contactName || '—'
}}</span>
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
· {{ alert.inboxName }}
</span>
<span class="aggressive-banner__context">
· {{ alertContextLabel(alert) }}
</span>
</button>
<button
type="button"
class="aggressive-banner__close"
:aria-label="
$t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE', 'Esconder')
"
:title="
$t(
'AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE_TITLE',
'Esconde temporariamente — volta se não responder'
)
"
@click="dismissOne(alert)"
>
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ICON', '×') }}
</button>
</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
@keyframes aggressive-pulse-yellow {
0%,
100% {
background-color: #eab308;
}
50% {
background-color: #fbbf24;
}
}
@keyframes aggressive-pulse-orange {
0%,
100% {
background-color: #c2410c;
}
50% {
background-color: #f97316;
}
}
@keyframes aggressive-pulse-red {
0%,
100% {
background-color: #991b1b;
}
50% {
background-color: #ef4444;
}
}
.aggressive-banner {
position: sticky;
top: 0;
z-index: 9999;
width: 100%;
color: #ffffff;
padding: 14px 20px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
font-weight: 700;
}
.aggressive-banner--yellow {
background-color: #eab308;
color: #1f2937;
}
.aggressive-banner--orange {
background-color: #c2410c;
animation: aggressive-pulse-orange 1.4s ease-in-out infinite;
}
.aggressive-banner--red {
background-color: #991b1b;
animation: aggressive-pulse-red 0.9s ease-in-out infinite;
}
.aggressive-banner__headline {
font-size: 22px;
line-height: 1.2;
margin-bottom: 4px;
letter-spacing: 0.5px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.aggressive-banner__explanation {
font-size: 13px;
font-weight: 500;
opacity: 0.92;
margin-bottom: 10px;
}
.aggressive-banner__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aggressive-banner__item {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
overflow: hidden;
font-size: 14px;
}
.aggressive-banner__item--yellow {
background: rgba(0, 0, 0, 0.18);
}
.aggressive-banner__open {
background: transparent;
color: inherit;
border: none;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
text-align: left;
display: flex;
align-items: center;
gap: 4px;
}
.aggressive-banner__open:hover {
background: rgba(255, 255, 255, 0.15);
}
.aggressive-banner__contact {
font-weight: 800;
}
.aggressive-banner__inbox,
.aggressive-banner__context {
opacity: 0.9;
font-weight: 500;
}
.aggressive-banner__close {
background: transparent;
color: inherit;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.25);
padding: 8px 12px;
cursor: pointer;
font-size: 20px;
line-height: 1;
font-weight: 800;
}
.aggressive-banner__close:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@ -366,6 +366,9 @@ export default {
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
},
scrollToBottom() {
if (!this.conversationPanel) {
return;
}
this.isProgrammaticScroll = true;
let relevantMessages = [];

View File

@ -0,0 +1,130 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const store = useStore();
const contact = computed(() =>
store.getters['contacts/getContact'](props.contactId)
);
const summary = computed(() => {
if (!contact.value) return null;
return {
interactions: contact.value.interactions_count ?? 0,
oneShots: contact.value.one_shot_consultations_count ?? 0,
pixGenerated: contact.value.pix_generated_count ?? 0,
reservationsPaid: contact.value.reservations_paid_count ?? 0,
lastInteractionAt: contact.value.last_interaction_at ?? null,
daysSince: contact.value.days_since_last_interaction ?? null,
isRecurring: contact.value.is_recurring ?? false,
};
});
const status = computed(() => {
const s = summary.value;
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
if (!s || s.interactions === 0)
return { label: t(`${B}.STATUS_FIRST`), tone: 'slate' };
if (s.daysSince !== null && s.daysSince > 180)
return { label: t(`${B}.STATUS_INACTIVE`), tone: 'rose' };
if (s.daysSince !== null && s.daysSince > 90)
return { label: t(`${B}.STATUS_AT_RISK`), tone: 'orange' };
if (s.daysSince !== null && s.daysSince > 30)
return { label: t(`${B}.STATUS_SLEEPING`), tone: 'amber' };
if (s.isRecurring)
return { label: t(`${B}.STATUS_RECURRING`), tone: 'emerald' };
return { label: t(`${B}.STATUS_ACTIVE`), tone: 'sky' };
});
const toneClass = {
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-200',
emerald:
'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
sky: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
orange:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200',
rose: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
};
function formatDaysSince(days) {
const B = 'CAPTAIN_REPORTS.RETENTION.BADGE';
if (days === null || days === undefined) return '';
if (days === 0) return t(`${B}.DAYS_TODAY`);
if (days === 1) return t(`${B}.DAYS_YESTERDAY`);
if (days < 30) return t(`${B}.DAYS_RECENT`, { days });
if (days < 60) return t(`${B}.DAYS_ONE_MONTH`);
if (days < 365)
return t(`${B}.DAYS_MONTHS`, { months: Math.round(days / 30) });
return t(`${B}.DAYS_YEARS`, { years: Math.round(days / 365) });
}
const interactionsLabel = computed(() => {
const s = summary.value;
if (!s) return '';
const parts = t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_LABEL').split(
' | '
);
return s.interactions === 1 ? parts[0] : parts[1] || parts[0];
});
</script>
<template>
<div
v-if="summary"
class="mb-3 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-2.5 dark:border-slate-700 dark:bg-slate-800/40"
>
<div class="mb-1.5 flex items-center justify-between">
<span
:class="toneClass[status.tone]"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
>
{{ status.label }}
</span>
<span v-if="summary.lastInteractionAt" class="text-[10px] text-slate-500">
{{
$t('CAPTAIN_REPORTS.RETENTION.BADGE.LAST_INTERACTION', {
days: formatDaysSince(summary.daysSince),
})
}}
</span>
</div>
<div
class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-600 dark:text-slate-300"
>
<span :title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.INTERACTIONS_TITLE')">
<strong class="text-slate-900 dark:text-slate-100">{{
summary.interactions
}}</strong>
{{ interactionsLabel }}
</span>
<span
v-if="summary.oneShots > 0"
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_TITLE')"
>
<strong class="text-slate-900 dark:text-slate-100">{{
summary.oneShots
}}</strong>
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.ONE_SHOT_LABEL') }}
</span>
<span
v-if="summary.pixGenerated > 0"
:title="$t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_TITLE')"
>
<strong class="text-slate-900 dark:text-slate-100">{{
summary.reservationsPaid
}}</strong
>/{{ summary.pixGenerated }}
{{ $t('CAPTAIN_REPORTS.RETENTION.BADGE.PIX_LABEL') }}
</span>
</div>
</div>
<div v-else />
</template>

View File

@ -9,6 +9,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'reservation_summary' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'contact_memories' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },

View File

@ -110,12 +110,16 @@ export const hasValidAvatarUrl = avatarUrl => {
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
try {
const url = new URL(dataUrl, window.location.origin);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
} catch (e) {
const connector = dataUrl.includes('?') ? '&' : '?';
return `${dataUrl}${connector}t=${Date.now()}`;
}
return url.toString();
};
export const getHostNameFromURL = url => {

View File

@ -1,6 +1,8 @@
import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import aggressiveAlert from './aggressiveAlert';
import inactivityAlertTracker from './inactivityAlertTracker';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
@ -107,16 +109,127 @@ class ActionCableConnector extends BaseActionCableConnector {
lastActivityAt,
conversationId,
});
this.feedInactivityTracker(data);
};
// Alimenta o tracker de inatividade:
// - Cliente (Contact) mandou mensagem em conversa open → começa a contar
// - Agente (User/AgentBot/Captain) mandou mensagem → limpa (agente respondeu)
// - Status deixou de ser open → trata como "resolvido", limpa
feedInactivityTracker = data => {
if (!this.isAggressiveAlertEnabled()) return;
const {
conversation_id: conversationId,
message_type: messageType,
sender_type: senderType,
conversation,
} = data;
// message_type: 0=incoming, 1=outgoing, 2=activity, 3=template
// Activity = evento do sistema (status mudou, etc). Ignora.
if (messageType === 2 || messageType === 'activity') return;
// Incoming (cliente) e conversa aberta → começa/renova tracker
const isIncoming = messageType === 0 || messageType === 'incoming';
const conversationStatus = conversation && conversation.status;
if (isIncoming && conversationStatus === 'open') {
const inboxId = conversation && conversation.inbox_id;
if (!this.isInboxAllowedForUser(inboxId)) return;
const contactName =
conversation && conversation.meta && conversation.meta.sender
? conversation.meta.sender.name
: '';
const inbox = this.app.$store.getters['inboxes/getInbox']
? this.app.$store.getters['inboxes/getInbox'](inboxId)
: null;
const inboxName = inbox && inbox.name ? inbox.name : '';
inactivityAlertTracker.onClientMessage({
conversationId,
contactName,
inboxName,
});
return;
}
// Qualquer mensagem do agente/bot → limpa tracker
if (senderType === 'User' || senderType === 'AgentBot') {
inactivityAlertTracker.onAgentReplyOrResolved(conversationId);
}
};
// Lê account.settings.aggressive_alert_enabled + user.ui_settings
isAggressiveAlertEnabled = () => {
const store = this.app.$store;
const account = store.getters.getCurrentAccount;
const user = store.getters.getCurrentUser;
// Default true se settings não vieram ainda (não bloqueia no boot).
const accountEnabled =
!account ||
!account.settings ||
account.settings.aggressive_alert_enabled !== false;
const userEnabled =
!user ||
!user.ui_settings ||
user.ui_settings.aggressive_alert_enabled !== false;
return accountEnabled && userEnabled;
};
// Filtra alertas por inbox conforme a preferência do user.
// ui_settings.aggressive_alert_inbox_ids:
// - null/undefined → todas as inboxes (default, legado)
// - [] (vazio) → nenhuma inbox (silenciou tudo)
// - [1, 2, 3] → só essas inboxes
isInboxAllowedForUser = inboxId => {
if (inboxId == null) return true;
const user = this.app.$store.getters.getCurrentUser;
const allowed =
user && user.ui_settings && user.ui_settings.aggressive_alert_inbox_ids;
if (allowed == null) return true;
if (!Array.isArray(allowed)) return true;
// Inbox ids podem vir como number no evento e string no ui_settings.
return allowed.some(id => Number(id) === Number(inboxId));
};
// eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload();
onStatusChange = data => {
this.maybeTriggerAggressiveAlert(data);
// Se saiu de 'open' (resolvida/snoozada/pending), limpa qualquer alerta
// pendente pra essa conversa.
if (data && data.id && data.status && data.status !== 'open') {
inactivityAlertTracker.onAgentReplyOrResolved(data.id);
}
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
// Dispara banner RED toda vez que a conversa transita pra 'open'.
// Broadcast `conversation.status_changed` só chega em mudança real,
// então confiar no evento é suficiente.
maybeTriggerAggressiveAlert = data => {
if (!data || data.status !== 'open') return;
if (!this.isAggressiveAlertEnabled()) return;
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
const store = this.app.$store;
const contactName =
data.meta && data.meta.sender ? data.meta.sender.name : '';
const inbox = store.getters['inboxes/getInbox']
? store.getters['inboxes/getInbox'](data.inbox_id)
: null;
const inboxName = inbox && inbox.name ? inbox.name : '';
aggressiveAlert.trigger({
conversationId: data.id,
level: 'red',
kind: 'reopened',
contactName,
inboxName,
});
};
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();

View File

@ -0,0 +1,303 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const ALERT_AUDIO_PATH = '/audio/dashboard/bell.mp3';
const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
const TITLE_FLASH_INTERVAL_MS = 1000;
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
// Níveis de severidade — ordem numérica cresce com a urgência.
export const LEVEL = {
YELLOW: 'yellow',
ORANGE: 'orange',
RED: 'red',
};
const LEVEL_SEVERITY = {
[LEVEL.YELLOW]: 1,
[LEVEL.ORANGE]: 2,
[LEVEL.RED]: 3,
};
const showOSNotification = (title, body) => {
if (typeof window === 'undefined' || !('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
try {
// eslint-disable-next-line no-new
new Notification(title, {
body,
tag: NOTIFICATION_TAG,
requireInteraction: true,
renotify: true,
});
} catch (e) {
// Safari iOS lança TypeError no construtor; banner visual + som cobrem.
}
};
const vibrateDevice = () => {
if (
typeof navigator !== 'undefined' &&
typeof navigator.vibrate === 'function'
) {
navigator.vibrate(VIBRATION_PATTERN);
}
};
class AggressiveAlertManager {
constructor() {
this.audio = null;
this.titleInterval = null;
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
// Map<conversationId, { level, kind, contactName, inboxName, minutes, triggeredAt, temporarilyHidden }>
this.activeConversations = new Map();
}
ensureAudio() {
if (this.audio) return;
this.audio = new Audio(ALERT_AUDIO_PATH);
}
// Som em loop infinito (usado pro nível RED — urgência máxima)
playLoopSound() {
this.ensureAudio();
this.audio.loop = true;
const playPromise = this.audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// Autoplay bloqueado pelo browser — banner visual permanece.
});
}
}
// Som 1x (usado pro ORANGE — chama atenção mas não satura)
playOnceSound() {
// Se já está tocando em loop pra outro alerta, não interfere.
if (this.hasLoopSound()) return;
this.ensureAudio();
this.audio.loop = false;
const playPromise = this.audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {});
}
}
hasLoopSound() {
// Loop está ativo se algum alerta no map tem level === RED e não está hidden.
return Array.from(this.activeConversations.values()).some(
entry => entry.level === LEVEL.RED && !entry.temporarilyHidden
);
}
stopSound() {
if (!this.audio) return;
this.audio.pause();
this.audio.currentTime = 0;
this.audio.loop = false;
}
// O título pisca se existir pelo menos 1 alerta visível com level ORANGE ou RED.
shouldFlashTitle() {
return Array.from(this.activeConversations.values()).some(
entry =>
!entry.temporarilyHidden &&
(entry.level === LEVEL.ORANGE || entry.level === LEVEL.RED)
);
}
countVisibleAlerts() {
return Array.from(this.activeConversations.values()).filter(
entry => !entry.temporarilyHidden
).length;
}
updateTitleTick(toggle) {
if (!this.shouldFlashTitle()) {
document.title = this.originalTitle;
return;
}
const count = this.countVisibleAlerts();
document.title = toggle
? `🚨 (${count}) CONVERSA ABERTA`
: this.originalTitle;
}
startTitleFlash() {
if (this.titleInterval) return;
if (!this.shouldFlashTitle()) return;
let toggle = false;
this.updateTitleTick(true);
this.titleInterval = setInterval(() => {
toggle = !toggle;
this.updateTitleTick(toggle);
}, TITLE_FLASH_INTERVAL_MS);
}
stopTitleFlash() {
if (this.titleInterval) {
clearInterval(this.titleInterval);
this.titleInterval = null;
}
document.title = this.originalTitle;
}
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
refreshOutputs() {
const hasLoop = this.hasLoopSound();
const shouldFlash = this.shouldFlashTitle();
if (hasLoop) {
this.playLoopSound();
} else {
this.stopSound();
}
if (shouldFlash) {
this.startTitleFlash();
} else {
this.stopTitleFlash();
}
}
/**
* Dispara ou escala um alerta.
* @param {Object} opts
* @param {number|string} opts.conversationId
* @param {string} opts.level - LEVEL.YELLOW | LEVEL.ORANGE | LEVEL.RED
* @param {string} opts.kind - 'reopened' | 'inactivity'
* @param {string} [opts.contactName]
* @param {string} [opts.inboxName]
* @param {number} [opts.minutes] - pra inactivity (5/15/28)
*/
trigger({
conversationId,
level = LEVEL.RED,
kind = 'reopened',
contactName,
inboxName,
minutes,
}) {
if (!conversationId) return;
const existing = this.activeConversations.get(conversationId);
// Escalada: se já existe e o novo level é MENOS severo, ignora.
// Se for mais severo, atualiza (ex: yellow → orange, inactivity).
if (existing) {
const currentSev = LEVEL_SEVERITY[existing.level] || 0;
const incomingSev = LEVEL_SEVERITY[level] || 0;
// Se o alerta tá "escondido temporariamente" e chegou novo, desesconde.
if (incomingSev >= currentSev || existing.temporarilyHidden) {
this.activeConversations.set(conversationId, {
...existing,
level: incomingSev > currentSev ? level : existing.level,
kind: incomingSev > currentSev ? kind : existing.kind,
minutes: incomingSev > currentSev ? minutes : existing.minutes,
contactName: contactName || existing.contactName,
inboxName: inboxName || existing.inboxName,
temporarilyHidden: false,
triggeredAt: Date.now(),
});
}
} else {
this.activeConversations.set(conversationId, {
level,
kind,
contactName: contactName || '—',
inboxName: inboxName || '',
minutes: minutes || null,
triggeredAt: Date.now(),
temporarilyHidden: false,
});
}
// Som por nível
if (level === LEVEL.RED) {
this.playLoopSound();
} else if (level === LEVEL.ORANGE) {
this.playOnceSound();
}
// YELLOW: sem som
if (level === LEVEL.ORANGE || level === LEVEL.RED) {
showOSNotification(
'🚨 Conversa aguardando resposta',
`${contactName || 'Cliente'}${inboxName || ''}`.trim()
);
vibrateDevice();
}
this.startTitleFlash();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, conversationId);
}
/**
* × dismiss temporário. Remove do visual mas mantém no map como "hidden".
* Volta a aparecer se escalar (receber mais severo) ou receber nova mensagem.
* Pra limpar de verdade, o agente tem que responder (então o tracker chama
* dismissForReply).
*/
hide(conversationId) {
const entry = this.activeConversations.get(conversationId);
if (!entry) return;
this.activeConversations.set(conversationId, {
...entry,
temporarilyHidden: true,
});
this.refreshOutputs();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
}
/**
* Dismiss definitivo chamado quando o agente respondeu ou o tracker
* detectou que o cliente não é mais o último a mandar.
*/
dismissForReply(conversationId) {
if (!this.activeConversations.has(conversationId)) return;
this.activeConversations.delete(conversationId);
this.refreshOutputs();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
}
// Mesmo que hide, mas pra API pública (botão × do banner)
dismiss(conversationId) {
this.hide(conversationId);
}
dismissAll() {
if (this.activeConversations.size === 0) return;
this.activeConversations.clear();
this.stopSound();
this.stopTitleFlash();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, null);
}
emitBusEvent(event, conversationId) {
emitter.emit(event, {
conversationId,
total: this.countVisibleAlerts(),
});
}
getActiveConversations() {
return Array.from(this.activeConversations.entries())
.filter(([, data]) => !data.temporarilyHidden)
.map(([id, data]) => ({ id, ...data }));
}
// Level mais alto entre os alertas visíveis — o banner usa pra cor do wrapper.
getMaxLevel() {
const visible = Array.from(this.activeConversations.values()).filter(
entry => !entry.temporarilyHidden
);
if (visible.length === 0) return null;
return visible.reduce((winner, entry) => {
const sevWinner = LEVEL_SEVERITY[winner] || 0;
const sevEntry = LEVEL_SEVERITY[entry.level] || 0;
return sevEntry > sevWinner ? entry.level : winner;
}, null);
}
}
const aggressiveAlert = new AggressiveAlertManager();
export default aggressiveAlert;

View File

@ -0,0 +1,245 @@
import aggressiveAlert, { LEVEL } from './aggressiveAlert';
// Thresholds de inatividade. Cada um dispara UMA vez por conversa (enquanto
// o cliente segue sendo o último a falar). Ordem: do menos urgente ao mais.
const THRESHOLDS = [
{ minutes: 5, level: LEVEL.YELLOW },
{ minutes: 15, level: LEVEL.ORANGE },
{ minutes: 28, level: LEVEL.RED },
];
// Checa o estado dos alertas a cada 20s — granularidade suficiente pra
// não perder threshold (a menor janela entre thresholds é 5min = 300s).
const CHECK_INTERVAL_MS = 20_000;
// Logs opt-in. Ativar no DevTools: window.__AGGRESSIVE_DEBUG__ = true
// Serve pra investigar porque o banner de inatividade não dispara numa
// conversa específica sem ter que tornar logs permanentes em prod.
const debug = (...args) => {
if (
typeof window !== 'undefined' &&
// eslint-disable-next-line no-underscore-dangle
window.__AGGRESSIVE_DEBUG__
) {
// eslint-disable-next-line no-console
console.info('[aggressive-alert]', ...args);
}
};
function findLastNonActivityMessage(conv) {
// 1) Preferir o campo dedicado do payload da listagem — o serializer
// já filtra atividades (`non_activity_messages`) antes de setar aqui.
if (conv.last_non_activity_message) return conv.last_non_activity_message;
// 2) Fallback pro array `messages` (só tem a última mensagem, e pode ser
// uma activity — filtra pra garantir).
if (conv.messages && conv.messages.length) {
const nonActivity = conv.messages.filter(
m => m && m.message_type !== 2 && m.message_type !== 'activity'
);
if (nonActivity.length) return nonActivity[nonActivity.length - 1];
}
return null;
}
// Chatwoot usa Unix timestamp (segundos) na maior parte dos endpoints e
// ISO em alguns. Suporta os dois.
function parseCreatedAt(value) {
if (value == null) return null;
if (typeof value === 'number') {
return value < 10_000_000_000 ? value * 1000 : value;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
class InactivityAlertTracker {
constructor() {
// Map<conversationId, { lastClientAt, firedMinutes: Set<number>, contactName, inboxName }>
this.conversations = new Map();
this.interval = null;
this.enabledGetter = () => true; // injetado pelo actionCable com o store
}
setEnabledGetter(fn) {
this.enabledGetter = fn;
}
start() {
if (this.interval) return;
this.interval = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
}
stop() {
if (!this.interval) return;
clearInterval(this.interval);
this.interval = null;
}
/**
* Registra ou atualiza que o CLIENTE mandou mensagem em uma conversa aberta.
* Zera os thresholds se existia (porque o relógio recomeça).
*/
onClientMessage({ conversationId, contactName, inboxName }) {
if (!conversationId) return;
this.conversations.set(conversationId, {
lastClientAt: Date.now(),
firedMinutes: new Set(),
contactName: contactName || '—',
inboxName: inboxName || '',
});
debug('onClientMessage', { conversationId, contactName, inboxName });
this.start();
}
/**
* Limpa a conversa agente respondeu ou cenário não mais aplicável.
* Também dismiss no banner pra parar som.
*/
onAgentReplyOrResolved(conversationId) {
if (!conversationId) return;
if (this.conversations.has(conversationId)) {
this.conversations.delete(conversationId);
}
aggressiveAlert.dismissForReply(conversationId);
if (this.conversations.size === 0) this.stop();
}
tick() {
if (!this.enabledGetter()) {
debug('tick skip: disabled');
return;
}
if (this.conversations.size === 0) {
debug('tick: empty map, stopping interval');
this.stop();
return;
}
const now = Date.now();
debug('tick', { size: this.conversations.size });
Array.from(this.conversations.entries()).forEach(
([conversationId, entry]) => {
const elapsedMin = (now - entry.lastClientAt) / 60000;
debug('tick entry', {
conversationId,
elapsedMin: elapsedMin.toFixed(2),
fired: Array.from(entry.firedMinutes),
});
THRESHOLDS.forEach(t => {
if (elapsedMin < t.minutes) return;
if (entry.firedMinutes.has(t.minutes)) return;
entry.firedMinutes.add(t.minutes);
debug('THRESHOLD HIT', {
conversationId,
minutes: t.minutes,
level: t.level,
});
aggressiveAlert.trigger({
conversationId,
level: t.level,
kind: 'inactivity',
contactName: entry.contactName,
inboxName: entry.inboxName,
minutes: t.minutes,
});
});
}
);
}
/**
* Varre a lista de conversas do store e popula o tracker com aquelas
* que estão em 'open' e tiveram o cliente como último remetente.
* Usa o `created_at` da última msg como âncora de tempo (não Date.now()),
* pra fechar o gap dos thresholds perdidos enquanto a aba estava fechada.
*
* Se a conversa está no tracker com timestamp ao recém-lido, ignora
* (mantém o estado dos firedMinutes evita re-trigger em re-hidratação).
*/
hydrateFromConversations(conversations) {
if (!this.enabledGetter()) {
debug('hydrate skip: disabled');
return;
}
if (!Array.isArray(conversations) || conversations.length === 0) {
debug('hydrate skip: empty list');
return;
}
debug('hydrate start', { total: conversations.length });
let hydrated = 0;
let skippedNotOpen = 0;
let skippedNoMsg = 0;
let skippedAgentLast = 0;
let skippedNoTs = 0;
conversations.forEach(conv => {
if (!conv || conv.status !== 'open') {
skippedNotOpen += 1;
return;
}
const lastMsg = findLastNonActivityMessage(conv);
if (!lastMsg) {
skippedNoMsg += 1;
debug('hydrate skip (no last msg)', { id: conv.id });
return;
}
const isClient =
lastMsg.sender_type === 'Contact' ||
lastMsg.message_type === 0 ||
lastMsg.message_type === 'incoming';
if (!isClient) {
// Última msg foi do agente/bot — garante que não está no tracker
if (this.conversations.has(conv.id)) {
this.conversations.delete(conv.id);
}
skippedAgentLast += 1;
return;
}
const lastClientAt = parseCreatedAt(lastMsg.created_at);
if (!lastClientAt) {
skippedNoTs += 1;
debug('hydrate skip (bad ts)', {
id: conv.id,
raw: lastMsg.created_at,
});
return;
}
const existing = this.conversations.get(conv.id);
if (existing && existing.lastClientAt >= lastClientAt) return;
const contactName =
(conv.meta && conv.meta.sender && conv.meta.sender.name) || '';
const inboxName = (conv.inbox && conv.inbox.name) || '';
this.conversations.set(conv.id, {
lastClientAt,
firedMinutes: new Set(),
contactName,
inboxName,
});
hydrated += 1;
});
debug('hydrate done', {
hydrated,
skippedNotOpen,
skippedNoMsg,
skippedAgentLast,
skippedNoTs,
mapSize: this.conversations.size,
});
if (hydrated > 0) {
this.start();
// Dispara imediatamente — se já passou de algum threshold, o tick
// seguinte (20s) detectaria. Mas rodar aqui antecipa em até 20s.
this.tick();
}
}
}
const inactivityAlertTracker = new InactivityAlertTracker();
export default inactivityAlertTracker;

View File

@ -94,6 +94,13 @@
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
"SUCCESS_MESSAGE": "Agent password reset successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Aggressive alert — inboxes",
"DESCRIPTION": "Choose which inboxes will trigger the reopened/inactivity banner for this agent.",
"ALL_INBOXES": "All inboxes",
"PICK_INBOXES": "Select inboxes",
"NONE_WARNING": "No inbox selected — this agent will not see the aggressive alert."
}
},
"SEARCH": {

View File

@ -0,0 +1,15 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"HEADLINE_REOPENED": "🚨 Conversation reopened — reply now",
"HEADLINE_5": "⏰ {minutes} min without reply",
"HEADLINE_15": "⚠️ {minutes} MIN WITHOUT REPLY — respond!",
"HEADLINE_28": "🚨 {minutes} MIN WITHOUT REPLY — conversation will auto-close!",
"HEADLINE_MULTIPLE": "🚨 {count} conversations awaiting reply",
"EXPLANATION": "This alert only clears when you REPLY to the conversation. Clicking × hides temporarily — it comes back if you do not reply.",
"KIND_REOPENED": "just reopened",
"KIND_WAITING": "{minutes} min without reply",
"HIDE_ONE": "Hide",
"HIDE_ONE_TITLE": "Hide temporarily — comes back if you do not reply",
"HIDE_ICON": "×"
}
}

View File

@ -2,6 +2,25 @@
"CAPTAIN_RESERVATIONS": {
"HEADER": "Reservations",
"EMPTY": "No reservations found.",
"CREATE_SUCCESS": "Reservation successfully created.",
"CREATE_ERROR": "Failed to create reservation.",
"NEW_RESERVATION_MODAL": {
"TITLE": "New Reservation",
"CONFIRM": "Create Reservation",
"CANCEL": "Cancel",
"FIELDS": {
"CONTACT_ID": "Contact ID",
"CONTACT_ID_PLACEHOLDER": "Enter the contact ID",
"INBOX": "Inbox",
"INBOX_PLACEHOLDER": "Select an inbox",
"STATUS": "Reservation Status",
"STATUS_PLACEHOLDER": "Select status",
"SUITE_IDENTIFIER": "Suite Identifier (Ex: 101, Master Suite)",
"CHECK_IN": "Check-in",
"CHECK_OUT": "Check-out",
"TOTAL_AMOUNT": "Total Amount"
}
},
"VIEW": {
"LIST": "List",
"KANBAN": "Kanban",
@ -22,7 +41,37 @@
"SORT_UPDATED": "Last update",
"SORT_CREATED": "Created at",
"APPLY": "Apply filters",
"CLEAR": "Clear filters"
"CLEAR": "Clear filters",
"HIDE": "Hide filters"
},
"KPI": {
"TOTAL": "Total on page",
"PENDING_PIX": "Awaiting PIX",
"CHECKIN_TODAY": "Check-in today",
"REVENUE_TODAY": "Revenue today"
},
"PILLS": {
"ALL": "All",
"DRAFT": "Draft",
"PENDING_PAYMENT": "Awaiting PIX",
"CONFIRMED": "Confirmed",
"CANCELLED": "Cancelled"
},
"QUICK_DATE": {
"TODAY": "Today",
"TOMORROW": "Tomorrow",
"WEEK": "7 days",
"ALL": "All"
},
"CARD": {
"CHECK_IN": "Check-in",
"AMOUNT": "Amount",
"TODAY": "Today",
"TOMORROW": "Tomorrow",
"YESTERDAY": "Yesterday",
"PIX_EXPIRED": "Expired",
"PIX_EXPIRES_IN_MIN": "Expires in {minutes}min",
"PIX_EXPIRES_IN_HR": "Expires in {hours}h"
},
"TABLE": {
"CUSTOMER": "Customer",
@ -35,14 +84,29 @@
"ACTIONS": "Actions"
},
"STATUS": {
"DRAFT": "Draft",
"SCHEDULED": "Scheduled",
"PENDING_PAYMENT": "Awaiting payment",
"ACTIVE": "Active",
"CONFIRMED": "Confirmed",
"CANCELLED": "Cancelled"
"COMPLETED": "Completed",
"CANCELLED": "Cancelled",
"DRAFT": "Draft"
},
"ACTIONS": {
"OPEN_CONVERSATION": "Open conversation",
"COPY_PIX": "Copy Pix"
"COPY_PIX": "Copy Pix",
"MORE": "More",
"REGENERATE_PIX": "Resend PIX",
"MARK_AS_PAID": "Mark as paid",
"MARK_AS_PAID_CONFIRM": "Mark this reservation as manually paid?",
"MARKED_AS_PAID": "Reservation marked as paid.",
"MARK_AS_PAID_FAILED": "Failed to mark as paid.",
"CANCEL": "Cancel reservation",
"CANCEL_REASON_PROMPT": "Cancellation reason (optional):",
"CANCELLED": "Reservation cancelled.",
"CANCEL_FAILED": "Failed to cancel.",
"PIX_REGENERATED": "New PIX generated and sent.",
"PIX_REGENERATE_FAILED": "Failed to resend PIX."
},
"KANBAN": {
"EMPTY_COLUMN": "No reservations in this status."
@ -79,6 +143,99 @@
"PIX_COPY_FAILED": "Unable to copy Pix."
}
},
"CAPTAIN_ORCHESTRATOR_EDITOR": {
"MISSING_DELIMITER_PREFIX": "This prompt was saved before the sections split. All content appears in the \"System Base Prompt\" field. Click",
"MISSING_DELIMITER_BUTTON": "Restore Default",
"MISSING_DELIMITER_SUFFIX": "to get automatic splitting, or manually move assistant content to the correct field."
},
"CAPTAIN_ROLETA": {
"HEADER": "Roulette — Redeem",
"TAB_REDEEM": "Redeem",
"TAB_REPORT": "Report",
"REDEEM": {
"TITLE": "Deliver prize to guest",
"DESC": "Type the code the guest showed on WhatsApp and confirm the redemption. Jasmine automatically sends a confirmation to the guest.",
"CODE_LABEL": "Coupon code",
"CODE_PLACEHOLDER": "Ex: ABC123",
"NOTES_LABEL": "Note (optional)",
"NOTES_PLACEHOLDER": "Any detail about the redemption",
"SUBMIT": "Confirm redemption",
"SUBMITTING": "Registering...",
"SUCCESS_PREFIX": "{prize} delivered to ",
"SUCCESS_FULL": "✅ {prize} delivered to {name}.",
"ERROR_FULL": "⚠️ {message}",
"FALLBACK_CLIENT": "guest",
"ERROR_EMPTY_CODE": "Type the code printed on the guest's coupon.",
"ERROR_NOT_FOUND": "Code not found. Check the spelling.",
"ERROR_ALREADY_REDEEMED": "This coupon has already been redeemed.",
"ERROR_NO_PRIZE": "This coupon landed on \"No luck\" — nothing to deliver.",
"ERROR_NO_RECEPTIONIST": "Log in again and try once more.",
"ERROR_RPC_FAILED": "Error calling the reservations server.",
"ERROR_EXCEPTION": "Something broke. Tell the tech team.",
"ERROR_DEFAULT": "Not registered."
},
"HISTORY": {
"TITLE": "Active coupons (last 7 days)",
"LOADING": "Loading...",
"EMPTY": "No active coupons in recent days.",
"LOAD_ERROR": "Error loading pending coupons",
"COL_CODE": "Code",
"COL_PRIZE": "Prize",
"COL_CLIENT": "Guest",
"COL_GENERATED": "Generated",
"COL_STATUS": "Redemption",
"STATUS_REDEEMED_PREFIX": "✅ ",
"STATUS_PENDING": "⏳ Pending"
},
"REPORT": {
"TITLE": "Redemptions by receptionist",
"DESC": "Anti-fraud: flags those redeeming well above team average.",
"PERIOD_7": "Last 7 days",
"PERIOD_14": "Last 14 days",
"PERIOD_30": "Last 30 days",
"LOADING": "Loading report...",
"EMPTY": "No redemptions in this period.",
"LOAD_ERROR": "Error loading report",
"KPI_TOTAL": "Total redemptions",
"KPI_AVG": "Average per person",
"KPI_COUNT": "Active receptionists",
"KPI_THRESHOLD": "Alert threshold",
"KPI_THRESHOLD_PREFIX": "≥ ",
"COL_RECEPTIONIST": "Receptionist",
"COL_TOTAL": "Total",
"COL_BRINDES": "Gifts",
"COL_DESCONTOS": "Discounts",
"COL_SUM_DISCOUNT": "Σ % discount",
"COL_STATUS": "Status",
"STATUS_ANOMALY": "⚠️ Above average",
"STATUS_NORMAL": "Normal",
"FOOTER_HINT": "Alert triggers when a receptionist has ≥ {threshold} redemptions (minimum 5, or 2.5× team average). Verify guest conversations on WhatsApp to confirm."
}
},
"CAPTAIN_FUNNEL": {
"HEADER": "Conversion Funnel",
"DESC": "Track the customer journey from price inquiry to paid Pix. Identifies where customers drop off.",
"PERIOD_7": "7 days",
"PERIOD_30": "30 days",
"PERIOD_60": "60 days",
"PERIOD_90": "90 days",
"LOADING": "Loading funnel...",
"EMPTY": "No Captain conversations in this period. Try a different range.",
"LOAD_ERROR": "Error loading funnel",
"INSIGHT_LABEL": "Biggest drop-off point",
"INSIGHT_FULL": "{lost} customers dropped between \"{from}\" and \"{to}\" — that's {pct} of those who reached the previous stage.",
"FUNNEL_TITLE": "Overall funnel ({count} conversations analyzed)",
"BY_SUITE_TITLE": "By suite category",
"BY_SUITE_HEADER": "Suite",
"BY_SUITE_FOOTER": "Category detected by mention in conversation content. Conversations without specific mention are not shown in this breakdown.",
"STAGES": {
"price_inquiry": "Asked price",
"price_answered": "Received quote",
"reservation_drafted": "Reservation started",
"pix_generated": "Pix generated",
"pix_paid": "Pix paid"
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"UNITS": {
@ -277,5 +434,506 @@
}
}
}
},
"CAPTAIN": {
"BANNER": {
"RESPONSES": "You have used more than 80% of your responses limit. To continue using Captain AI, please upgrade.",
"DOCUMENTS": "Documents limit reached. Please upgrade to continue using Captain AI."
},
"FORM": {
"CANCEL": "Cancel",
"CREATE": "Create",
"EDIT": "Update"
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "An error occurred while approving FAQs. Try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "An error occurred while deleting FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure you want to delete this FAQ?",
"DESCRIPTION": "",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "FAQ deleted successfully",
"ERROR_MESSAGE": "An error occurred while deleting FAQ, please try again."
}
}
},
"CAPTAIN_REPORTS": {
"TITLE": "AI Reports",
"DESC": "Weekly AI-generated insights based on each unit's conversations.",
"LOADING": "Loading reports...",
"ALL_UNITS": "All units",
"ALL_INBOXES": "All inboxes",
"UNITS_GROUP": "Pix Units",
"INBOXES_GROUP": "Inboxes",
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational",
"EXECUTIVE": "Executive",
"LANDING_PAGES": "Landing Pages",
"RETENTION": "Retention"
},
"RETENTION": {
"PERIOD_LABEL": "Period",
"PERIOD_THIS_MONTH": "This month",
"PERIOD_LAST_30": "Last 30 days",
"PERIOD_LAST_90": "Last 90 days",
"PERIOD_CUSTOM": "Custom",
"APPLY": "Apply",
"NO_DATA": "No data.",
"KPI": {
"ACTIVE": "Active customers",
"ACTIVE_HINT": "last interaction within 30 days",
"RECURRING": "Recurring",
"RECURRING_HINT": "≥2 qualified interactions in 90 days",
"RETURN_30D": "30-day return rate",
"RETURN_30D_HINT": "returned to interact within 7 days",
"PIX_CONVERSION": "Pix conversion",
"PIX_CONVERSION_HINT": "{paid} paid out of {generated} generated"
},
"FLOW": {
"TITLE": "Period flow",
"NEW_IN_PERIOD": "new in period",
"RETURNED_IN_PERIOD": "returned in period",
"TOTAL_TOUCHES": "total interactions",
"BASE_STATUS": "Current base status",
"SLEEPING": "{count} sleeping",
"SLEEPING_HINT": "30-90d without contact",
"AT_RISK": "{count} at risk",
"AT_RISK_HINT": "90-180d without contact",
"CHURNED": "{count} inactive",
"CHURNED_HINT": "180d+ without contact"
},
"COHORT": {
"TITLE": "Cohort matrix",
"SUBTITLE": "% of each cohort's customers who returned to interact in M+N months.",
"EXPORT_CSV": "Export CSV",
"EMPTY": "No cohort data yet.",
"COL_COHORT": "Cohort",
"COL_SIZE": "Size",
"CELL_TITLE": "{count} active contacts ({rate}%)"
},
"BADGE": {
"STATUS_FIRST": "First contact",
"STATUS_INACTIVE": "Inactive",
"STATUS_AT_RISK": "At risk",
"STATUS_SLEEPING": "Sleeping",
"STATUS_RECURRING": "Recurring",
"STATUS_ACTIVE": "Active",
"LAST_INTERACTION": "last {days}",
"INTERACTIONS_LABEL": "interaction | interactions",
"INTERACTIONS_TITLE": "Qualified interactions (≥2+2 messages)",
"ONE_SHOT_LABEL": "one-shot",
"ONE_SHOT_TITLE": "One-shot consultations (≥1+1)",
"PIX_LABEL": "Pix paid",
"PIX_TITLE": "Pix generated / reservations paid",
"DAYS_TODAY": "today",
"DAYS_YESTERDAY": "yesterday",
"DAYS_RECENT": "{days} days ago",
"DAYS_ONE_MONTH": "about 1 month ago",
"DAYS_MONTHS": "{months} months ago",
"DAYS_YEARS": "{years} years ago"
},
"ERRORS": {
"SUMMARY": "Failed to load retention KPIs",
"COHORT": "Failed to load cohort"
}
},
"EXECUTIVE": {
"LOADING": "Loading executive digest...",
"NO_DATA": "No insights for the period. Run the weekly analysis to see data here.",
"TITLE": "Executive Digest",
"SUBTITLE": "Same report sent to Mattermost, with drill-down and filters.",
"DELIVER_BUTTON": "Send to Mattermost now",
"DELIVER_SUCCESS": "Digest queued. It will arrive in Mattermost shortly.",
"DELIVER_ERROR": "Failed to dispatch the digest. Check Rails log.",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages",
"UNITS_ANALYZED": "Units analyzed",
"INSIGHTS_COUNT": "Insights generated",
"UNIT_TABLE": "Comparative by unit",
"COL_UNIT": "Unit",
"COL_CONVS": "Conversations",
"COL_DELTA": "vs previous week",
"COL_AI_RATE": "AI success rate",
"COL_FAILURES": "Failures",
"AI_FAILURES": "Where Angelina failed (click to see conversations)",
"OPPORTUNITIES": "Opportunities — what customers asked for",
"OPPORTUNITIES_HINT": "Click an opportunity to see real conversations where it was mentioned.",
"COMPLAINTS": "Recurring complaints",
"PRAISES": "Recurring praises",
"RECOMMENDATIONS": "AI Recommendations",
"DRILLDOWN_TITLE": "Related conversations",
"NO_CONVERSATIONS_FOUND": "No conversations found with those keywords in the period.",
"NO_CONVERSATIONS_HINT": "The insight description is an AI abstraction. If no keyword matches the literal conversation text, nothing is returned. Try clicking a more specific item.",
"SEARCH_TOKENS": "Keywords searched",
"OPEN_CONVERSATION": "Open in Chatwoot"
},
"LP": {
"LOADING": "Loading data...",
"NO_DATA": "No clicks recorded yet. Integrate the pixel on your landing page to see data here.",
"TOTAL_CLICKS": "Total Clicks",
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
"CONVERSION_RATE": "Conversion Rate",
"TOTAL_DROPOFF": "Drop-off (No conversation)",
"DROPOFF_RATE": "Drop-off Rate",
"UNIQUE_CONTACTS": "Unique contacts converted",
"FUNNEL_TITLE": "Landing page funnel",
"DAILY_TREND": "Daily click vs conversion trend",
"BY_SOURCE": "Clicks by Source",
"BY_CAMPAIGN": "Clicks by Campaign",
"BY_HOSTNAME": "Clicks by Landing Page",
"CLICKS": "clicks",
"CONV": "conv",
"REFRESH": "Refresh",
"LEGEND_CLICKS": "Clicks",
"LEGEND_CONVERSIONS": "Conversions"
},
"FILTER_DATE": {
"LABEL": "Period:",
"START": "Start Date",
"END": "End Date",
"TODAY": "Today",
"YESTERDAY": "Yesterday",
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"THIS_WEEK": "This week",
"LAST_WEEK": "Last Week",
"CURRENT_MONTH": "Current Month",
"LAST_MONTH": "Last Month",
"CUSTOM": "Custom",
"SEPARATOR": "-"
},
"INSIGHT": {
"CONVERSATIONS": "conversations",
"MESSAGES": "messages",
"TOP_TOPICS": "Top Topics",
"AI_FAILURES": "AI Failures",
"BULLET": "•",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"TIMES": "x",
"SENTIMENT": "Sentiment",
"SENTIMENT_POSITIVE": "Positive",
"SENTIMENT_NEGATIVE": "Negative",
"SENTIMENT_NEUTRAL": "Neutral",
"PRAISES": "Customer Praises",
"COMPLAINTS": "Complaints",
"FAQ_GAPS": "FAQ Gaps",
"FAQ_GAPS_HINT": "Questions customers ask that the agent doesn't cover",
"MOST_REQUESTED_SUITES": "Most Requested Suites",
"PRICE_REACTIONS": "Price Reactions",
"PRICE_OBJECTIONS": "price objections",
"RECOMMENDATIONS": "Recommendations",
"SHOW_DETAILS": "View full analysis",
"HIDE_DETAILS": "Hide details"
},
"EMPTY": {
"TITLE": "No reports generated",
"MESSAGE": "Generate a new report to analyze support performance."
},
"GENERATE": {
"BUTTON": "Generate Analysis",
"SUCCESS": "Report requested successfully! It may take a few minutes.",
"ERROR": "Error requesting report generation.",
"DATE_REQUIRED": "Please select start and end dates."
},
"STATUS": {
"PENDING": "Pending",
"PROCESSING": "Processing",
"DONE": "Completed",
"FAILED": "Failed"
},
"OPERATIONAL": {
"LOADING": "Loading operational data...",
"NO_DATA": "No operational data for the selected period.",
"CONVERSATIONS_SECTION": "Conversations",
"RESERVATIONS_SECTION": "Reservations",
"TOTAL": "Total",
"RESOLVED": "Resolved",
"OPEN": "Open",
"AVG_RESOLUTION": "Avg resolution time",
"RES_TOTAL": "Total reservations",
"RES_PAID": "Paid",
"RES_EXPIRED": "Expired",
"RES_REVENUE": "Paid revenue",
"BY_INBOX": "Volume by inbox",
"RESOLUTION_RATE_TOOLTIP": "Resolution rate",
"DAILY_DIST": "Daily distribution",
"HOURLY_DIST": "Hourly distribution",
"PEAK": "Peak"
},
"DASHBOARD": {
"TOTAL_CONVERSATIONS": "Analyzed conversations",
"AVG_SENTIMENT": "Avg. positive sentiment",
"FAQ_GAPS_TOTAL": "FAQ gaps identified",
"WEEKS_ANALYZED": "weeks analyzed",
"NO_DATA": "Not enough data. Generate more AI reports to see the dashboard.",
"SENTIMENT_TREND": "Sentiment trend by week",
"FAILURES_RANKING": "Agent failure ranking",
"FAILURES_RANKING_HINT": "Most frequent situations where the AI couldn't respond well",
"FAQ_PRIORITY": "Priority FAQs to create",
"FAQ_PRIORITY_HINT": "Questions customers ask most that aren't in the FAQ yet",
"CUSTOMER_BEHAVIOR": "Customer behavior",
"TOP_TOPICS_TITLE": "Most discussed topics",
"SUITES_TITLE": "Most requested suites",
"COMPLAINTS_TREND": "Complaints volume by week",
"HANDOFFS_TITLE": "Estimated handoffs to human",
"HANDOFFS_HINT": "Based on AI failure frequency. Direct handoff tracking coming soon.",
"TREND_UP": "rising",
"TREND_DOWN": "falling",
"TREND_STABLE": "stable",
"WEEKS": "weeks"
},
"FAQ_QUICK_ADD": {
"BUTTON": "Create FAQ",
"TITLE": "Create FAQ from AI suggestion",
"QUESTION_LABEL": "Question (suggested by AI)",
"ANSWER_LABEL": "Answer",
"ANSWER_PLACEHOLDER": "Write the answer to this question...",
"ASSISTANT_LABEL": "AI Agent",
"ASSISTANT_PLACEHOLDER": "Select the agent",
"CANCEL": "Cancel",
"SAVE": "Save FAQ",
"SUCCESS": "FAQ created successfully!",
"ERROR": "Error creating FAQ. Please try again."
}
},
"CAPTAIN_LIFECYCLE": {
"HEADER": "Customer Journey",
"SUBTITLE": "Automated WhatsApp messages along the reservation lifecycle",
"TABS": {
"RULES": "Rules",
"SETTINGS": "Settings",
"HISTORY": "History"
},
"RULES": {
"EMPTY": "No rules configured yet.",
"CREATE": "New rule",
"TEMPLATES_TITLE": "Ready-made templates",
"COLUMNS": {
"NAME": "Name",
"EVENT": "Event",
"OFFSET": "Offset",
"FILTER": "Filter",
"STATUS": "Status",
"ACTIONS": "Actions"
},
"STATUS": {
"ENABLED": "Active",
"DISABLED": "Disabled"
},
"ACTIONS": {
"EDIT": "Edit",
"DUPLICATE": "Duplicate",
"TOGGLE": "Enable/Disable",
"DELETE": "Delete"
},
"DELETE_CONFIRM": "Are you sure you want to delete this rule?",
"TOAST": {
"CREATED": "Rule created successfully.",
"UPDATED": "Rule updated.",
"DELETED": "Rule deleted."
},
"WIZARD": {
"TITLE_CREATE": "New rule",
"TITLE_EDIT": "Edit rule",
"STEP_WHEN": "When?",
"STEP_WHO": "Who?",
"STEP_WHAT": "What?",
"STEP_REVIEW": "Review",
"NEXT": "Next",
"BACK": "Back",
"SAVE": "Save",
"CANCEL": "Cancel",
"OFFSET_UNIT_LABEL": "min",
"STEP_LABELS": {
"WHEN": "1. When?",
"WHO": "2. Who?",
"WHAT": "3. What?",
"REVIEW_TAB": "4. Review"
},
"REVIEW": {
"NAME": "Name:",
"EVENT": "Event:",
"OFFSET": "Offset (min):",
"UNITS": "Units:",
"MESSAGE": "Message:"
},
"FIELDS": {
"NAME": "Rule name",
"DESCRIPTION": "Description",
"EVENT": "Trigger event",
"OFFSET_VALUE": "Value",
"OFFSET_UNIT": "Unit",
"OFFSET_DIRECTION": "Direction",
"UNITS": "Units",
"CATEGORIAS": "Suite categories",
"PERMANENCIAS": "Stay types",
"MESSAGE_TYPE": "Message type",
"MESSAGE_BODY": "Message body",
"PRIORITY": "Priority",
"ENABLED": "Rule active"
},
"OFFSET_UNITS": {
"MINUTES": "Minutes",
"HOURS": "Hours",
"DAYS": "Days"
},
"OFFSET_DIRECTIONS": {
"BEFORE": "Before",
"AFTER": "After"
},
"EVENTS": {
"RESERVATION_CONFIRMED": "Reservation confirmed (Pix paid)",
"CHECKIN_SCHEDULED_AT": "Check-in time",
"CHECKOUT_SCHEDULED_AT": "Check-out time",
"RESERVATION_CANCELLED": "Reservation cancelled",
"RESERVATION_NO_SHOW": "No-show"
},
"MESSAGE_TYPES": {
"TEXT": "Plain text",
"BUTTONS": "Text with buttons",
"LIST": "List menu",
"URL_BUTTON": "Link button"
}
}
},
"SETTINGS": {
"GUARDS_TITLE": "Anti-spam guards",
"QUIET_HOURS_ENABLED": "Enable quiet hours",
"QUIET_HOURS_FROM": "From",
"QUIET_HOURS_TO": "To",
"MIN_INTERVAL": "Minimum interval between messages (min)",
"MIN_INTERVAL_HELP": "0 disables",
"PAUSE_ON_REPLY": "Pause if customer replied",
"PAUSE_ON_REPLY_WINDOW": "Window (min)",
"OPT_OUT_LABEL": "Opt-out label",
"MAX_PER_RESERVATION_INFO": "Maximum of 5 messages per reservation (not configurable)",
"CONCIERGE_TITLE": "Concierge (Sofia) per Unit",
"CONCIERGE_INBOX": "WhatsApp Inbox",
"CONCIERGE_PERSONA": "Persona name",
"CONCIERGE_KNOWLEDGE": "Knowledge base (markdown)",
"CONCIERGE_VARIABLES": "Unit variables",
"CONCIERGE_VARIABLE_KEY": "Key",
"CONCIERGE_VARIABLE_VALUE": "Value",
"CONCIERGE_ADD_VARIABLE": "Add variable",
"CONCIERGE_CONFIGURED": "Configured",
"CONCIERGE_NOT_CONFIGURED": "Not configured",
"SAVE": "Save changes",
"TOAST": {
"SAVED": "Settings saved.",
"CONCIERGE_SAVED": "Unit concierge updated."
}
},
"HISTORY": {
"EMPTY": "No deliveries recorded.",
"COLUMNS": {
"RULE": "Rule",
"CUSTOMER": "Customer",
"RESERVATION": "Reservation",
"STATUS": "Status",
"FIRE_AT": "Fired at",
"REASON": "Reason",
"ACTIONS": ""
},
"STATUS": {
"SCHEDULED": "Scheduled",
"SENT": "Sent",
"SKIPPED": "Skipped",
"FAILED": "Failed",
"CANCELLED": "Cancelled"
},
"FILTERS": {
"STATUS": "Status",
"RULE": "Rule",
"FROM": "From",
"TO": "To",
"ALL": "All"
},
"PREVIEW": "Preview",
"TOTAL": "total",
"PAGINATION": {
"PREV": "Prev",
"NEXT": "Next"
},
"MODAL": {
"TITLE": "Message preview",
"CLOSE": "Close",
"RULE": "Rule",
"STATUS": "Status",
"REASON": "Reason",
"ERROR": "Error",
"FIRE_AT": "Fire at",
"SENT_AT": "Sent at",
"RENDERED": "Rendered",
"RESERVATION_ID": "Reservation #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Agent Builder",
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
"HEADER_TITLE": "Agent Builder",
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
"RESET": "Clear conversation",
"RESET_CONFIRM": "Clear current conversation with the Builder?",
"EMPTY_STATE": "Ready to create a new Hermes agent? Click \"Start creation\" and the Builder will guide you.",
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
"SEND": "Send",
"SESSION_LABEL": "Session:",
"SEND_FAILED": "Send failed: {message}",
"RESET_FAILED": "Failed to clear session.",
"START": "Start creation",
"TAB_CHAT": "Chat (Builder)",
"TAB_VERIFY": "Verification",
"VERIFY": {
"TITLE": "Agent verification",
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
"NO_ASSISTANTS": "No Hermes agents registered",
"RUN": "Run check",
"RUNNING": "Checking...",
"REPAIR": "Repair",
"REPAIRING": "Repairing...",
"OK_LABEL": "OK",
"FAILS_LABEL": "failures",
"WARN_LABEL": "warnings",
"OF_TOTAL": "of {total} checks",
"VERDICT_PASS": "Ready to ship",
"VERDICT_FAIL": "Critical failures — fix first",
"EMPTY": "Select an agent and click Run check to start verification.",
"EMPTY_RESULTS": "No checks returned — agent removed?",
"REPAIR_FAILED": "Failed: {message}",
"REPAIR_OK": "Repaired: {message}",
"FETCH_FAILED": "Error loading assistants: {message}",
"VALIDATE_FAILED": "Validation failed: {message}",
"CATEGORY_DB": "Database",
"CATEGORY_PRICING": "Pricing",
"CATEGORY_ROUTING": "Captain → Hermes routing",
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
"CATEGORY_MCP": "Registered MCP tools",
"CATEGORY_OTHER": "Other"
}
}
}
}

View File

@ -381,6 +381,7 @@
"CONVERSATION_LABELS": "Conversation Labels",
"CONVERSATION_INFO": "Conversation Information",
"CONTACT_NOTES": "Contact Notes",
"CONTACT_MEMORIES": "Customer Memories",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"RESERVATION": "Reservation",
@ -406,6 +407,28 @@
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
},
"CONTACT_MEMORIES": {
"LOADING": "Loading memories…",
"EMPTY_TITLE": "No memories yet",
"EMPTY_HINT": "Memories appear here as the customer chats over time.",
"FORGET": "Forget",
"FORGET_ALL": "Forget all memories",
"CONFIRM_DELETE": "Forget this memory?",
"CONFIRM_FORGET_ALL": "Forget ALL memories for this customer? This cannot be undone after 30 days.",
"ERROR_LOADING": "Failed to load memories",
"CONFIDENCE": "Confidence",
"TYPE_LABELS": {
"preferencia": "Preference",
"data_comemorativa": "Date",
"vinculo_social": "Social",
"padrao_comportamental": "Pattern",
"reclamacao": "Complaint",
"feedback_positivo": "Praise",
"restricao": "Restriction",
"vinculo_comercial": "Commercial",
"contexto_pessoal": "Personal"
}
}
},
"SCHEDULED_MESSAGES": {

View File

@ -104,6 +104,14 @@
"ERROR": "Failed to update audio transcription setting"
}
},
"AGGRESSIVE_ALERT": {
"TITLE": "Aggressive conversation alert (master switch)",
"NOTE": "When on, agents receive a banner + sound + OS notification when a conversation is reopened and at 5/15/28 min without reply. Each agent can still turn it off in their profile — this is the account-wide master. Off here = nobody receives.",
"API": {
"SUCCESS": "Aggressive alert setting updated",
"ERROR": "Failed to update aggressive alert setting"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a conversation should auto resolve if there is no activity",

View File

@ -1,4 +1,5 @@
import advancedFilters from './advancedFilters.json';
import aggressiveBanner from './aggressiveBanner.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@ -44,6 +45,7 @@ import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
...aggressiveBanner,
...agentBots,
...agentMgmt,
...attributesMgmt,

View File

@ -385,7 +385,11 @@
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"EMPTY_LIST": "No assistants found, please create one to get started",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
"ENGINE_INTERNO": "Internal",
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
},
"COPILOT": {
"TITLE": "Copilot",
@ -689,6 +693,11 @@
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"TRIGGER_KEYWORDS": {
"LABEL": "When to use this scenario (triggers)",
"PLACEHOLDER": "USE WHEN:\n- Customer wants to book\n- Customer asks about price\n\nEXAMPLE TRIGGERS:\n- \"I want to book saturday\"\n- \"how much is it?\"\n\nDO NOT USE WHEN:\n- Customer just wants to see photos",
"HELP": "Optional but recommended. Use this field to teach the AI WHEN to route to this scenario. Clearer examples mean better routing accuracy."
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"NAME": "Resolved by bot",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Handoff Count",
"NAME": "Transferred to human",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -281,6 +281,35 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"TABS": {
"OVERVIEW": "Overview",
"LEADS": "New × Returning"
},
"LEADS": {
"TITLE": "New × Returning",
"INBOX_LABEL": "Inbox:",
"EMPTY": "No conversations in this period.",
"TOTAL": "Total conversations in the period: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "New leads",
"INFO": "Conversations from contacts who never spoke to any inbox of the network before."
},
"RETURNING": {
"LABEL": "Returning",
"INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago."
},
"OTHERS": {
"LABEL": "Others",
"INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago."
}
},
"CHART": {
"NEW_LEADS": "New",
"RETURNING": "Returning",
"OTHERS": "Others"
}
},
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"
@ -505,20 +534,32 @@
"HEADER": "Bot Reports",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "No. of Conversations",
"TOOLTIP": "Total number of conversations handled by the bot"
"LABEL": "Conversations",
"TOOLTIP": "Total number of conversations handled by the bot in the period"
},
"TOTAL_RESPONSES": {
"LABEL": "Total Responses",
"TOOLTIP": "Total number of responses sent by the bot"
"LABEL": "Outgoing messages",
"TOOLTIP": "Total number of outgoing messages — includes the bot AND humans (Chatwoot UI or WhatsApp echo)"
},
"RESOLUTION_RATE": {
"LABEL": "Resolution Rate",
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
"LABEL": "Resolved by bot %",
"TOOLTIP": "Conversations the bot resolved alone (no human reply, via UI or WhatsApp) ÷ total conversations × 100"
},
"HANDOFF_RATE": {
"LABEL": "Handoff Rate",
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
"LABEL": "Transferred to human %",
"TOOLTIP": "Conversations transferred to human (auto by Jasmine + manual takeover) ÷ total conversations × 100. Together with the resolution rate, the gear closes the math (the rest are still open, snoozed, or abandoned)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolved by bot",
"TOOLTIP": "Absolute count: conversations the bot closed alone, with no human reply (UI or WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Auto handoff (Jasmine)",
"TOOLTIP": "Conversations where Jasmine explicitly called bot_handoff! — typically tool loop, timeout, max turns reached or LLM intent classified as handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Manual takeover (agent)",
"TOOLTIP": "Conversations where a human replied (Chatwoot UI or WhatsApp echo) without Jasmine triggering bot_handoff! first — the agent took over silently"
}
}
},
@ -646,5 +687,47 @@
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
"RESOLUTION_COUNT": "Resolution Count",
"CONVERSATIONS": "No. of conversations"
},
"DIRECTORY_DASHBOARD": {
"HEADER": "Directory Dashboard",
"BANNER": {
"TITLE": "Channel adoption — not the full picture.",
"BODY": "These numbers measure the digital channel only (Jasmine + reservations created via app). Conversations attended manually that closed at the reception are not yet captured (manual marking is in progress)."
},
"HEADLINE_NUMBERS": "Headline numbers",
"METRICS": {
"LEADS_TOTAL": {
"LABEL": "Leads (total)",
"TOOLTIP": "All conversations created in the period (new + returning)"
},
"LEADS_NEW": {
"LABEL": "New leads",
"TOOLTIP": "First-ever conversation of the contact in any inbox of the network"
},
"LEADS_RETURNING": {
"LABEL": "Returning leads",
"TOOLTIP": "Contact had at least one prior conversation"
},
"CONVERSION_RATE": {
"LABEL": "Lead → Paid reservation",
"TOOLTIP": "Paid reservations ÷ total leads × 100. Adoption proxy, not full operation."
}
},
"FUNNEL": {
"TITLE": "Funnel",
"STAGE_LEADS": "Leads",
"STAGE_RESERVATIONS": "Reservations created",
"STAGE_PAID": "Paid"
},
"BENCHMARK": {
"TITLE": "Inbox benchmarking by brand",
"BRAND_AVG": "brand avg.",
"COL_INBOX": "Inbox",
"COL_LEADS": "Leads",
"COL_CREATED": "Created",
"COL_PAID": "Paid",
"COL_RATE": "Conv. rate",
"COL_VS_BRAND": "vs brand"
}
}
}

View File

@ -35,6 +35,12 @@
}
}
},
"AGGRESSIVE_ALERT": {
"SECTION_TITLE": "Aggressive conversation alert",
"SECTION_NOTE": "Triggers a banner, sound and OS notification when a conversation is reopened and every 5/15/28 minutes without reply. Only clears when YOU reply. Turn off for a silent shift — but own the risk.",
"TITLE": "Receive aggressive alerts",
"NOTE": "When on, you get a banner + sound + notification when a conversation reopens or goes X minutes without reply. Turn off at your own risk."
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
@ -115,6 +121,15 @@
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Stalled conversation alert",
"NOTE": "Red banner that appears at the top of the panel when a conversation has been waiting for a reply for 5+ minutes.",
"DESCRIPTION": "Red banner shown when a conversation has no reply for 5+ minutes. Useful to avoid losing customers, but can be intrusive if you don't handle every inbox.",
"ENABLED": "Enable stalled conversation alert",
"APPLY_TO_ALL": "Apply to all inboxes",
"INBOX_HINT": "Pick the inboxes where you want to receive the alert:",
"NO_INBOXES": "No inboxes registered."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
@ -343,6 +358,11 @@
"CAPTAIN_PIX_UNITS": "Pix Units",
"CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_ROLETA": "Roulette — Redeem",
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
"CAPTAIN_FUNNEL": "Conversion Funnel",
"CAPTAIN_LIFECYCLE": "Customer Journey",
"CAPTAIN_REPORTS": "AI Reports",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@ -378,6 +398,7 @@
"ONE_OFF": "One off",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Bot",
"REPORTS_DIRECTORY_DASHBOARD": "Directory Dashboard",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",
@ -1021,44 +1042,5 @@
"CONFIRM_BUTTON_LABEL": "Delete",
"CANCEL_BUTTON_LABEL": "Cancel"
}
},
"CAPTAIN_REPORTS": {
"TITLE": "AI Reports",
"DESC": "Weekly AI-generated insights based on conversations from each inbox.",
"LOADING": "Loading reports...",
"ALL_UNITS": "All units",
"ALL_INBOXES": "All inboxes",
"TABS": {
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational"
},
"STATUS": {
"PENDING": "Pending",
"PROCESSING": "Processing",
"DONE": "Completed",
"FAILED": "Failed"
},
"GENERATE": {
"BUTTON": "Generate Analysis",
"SUCCESS": "Analysis queued successfully! It will be available shortly.",
"ERROR": "Error requesting analysis. Please try again."
},
"INSIGHT": {
"CONVERSATIONS": "conversations",
"MESSAGES": "messages",
"TOP_TOPICS": "Top Topics",
"AI_FAILURES": "AI Improvement Points",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"BULLET": "•"
},
"EMPTY": {
"TITLE": "No analysis available",
"MESSAGE": "Click Generate Analysis to create the first weekly report with conversation insights."
},
"OPERATIONAL": {
"COMING_SOON": "Coming Soon",
"COMING_SOON_DESC": "Real-time operational data (reservations, Pix charges, etc.) will be available here soon."
}
}
}

View File

@ -94,6 +94,13 @@
"ADMIN_SUCCESS_MESSAGE": "Um e-mail com instruções de redefinição de senha foi enviado para o agente",
"SUCCESS_MESSAGE": "Senha do agente redefinida com sucesso",
"ERROR_MESSAGE": "Não foi possível conectar ao servidor Woot, por favor tente novamente mais tarde"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Alerta agressivo — caixas de entrada",
"DESCRIPTION": "Escolha em quais caixas de entrada este agente verá o banner de conversa reaberta e de inatividade.",
"ALL_INBOXES": "Em todas as caixas de entrada",
"PICK_INBOXES": "Selecione as caixas de entrada",
"NONE_WARNING": "Nenhuma caixa selecionada — este agente não verá o alerta agressivo."
}
},
"SEARCH": {

View File

@ -0,0 +1,15 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"HEADLINE_REOPENED": "🚨 Conversa reaberta — responda agora",
"HEADLINE_5": "⏰ {minutes} min sem resposta",
"HEADLINE_15": "⚠️ {minutes} MIN SEM RESPOSTA — responda!",
"HEADLINE_28": "🚨 {minutes} MIN SEM RESPOSTA — conversa vai fechar!",
"HEADLINE_MULTIPLE": "🚨 {count} conversas aguardando resposta",
"EXPLANATION": "Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente — volta se não responder.",
"KIND_REOPENED": "reabriu agora",
"KIND_WAITING": "{minutes} min sem resposta",
"HIDE_ONE": "Esconder",
"HIDE_ONE_TITLE": "Esconde temporariamente — volta se não responder",
"HIDE_ICON": "×"
}
}

View File

@ -2,6 +2,25 @@
"CAPTAIN_RESERVATIONS": {
"HEADER": "Reservas",
"EMPTY": "Nenhuma reserva encontrada.",
"CREATE_SUCCESS": "Reserva criada com sucesso.",
"CREATE_ERROR": "Erro ao criar reserva.",
"NEW_RESERVATION_MODAL": {
"TITLE": "Nova Reserva",
"CONFIRM": "Criar Reserva",
"CANCEL": "Cancelar",
"FIELDS": {
"CONTACT_ID": "ID do Contato",
"CONTACT_ID_PLACEHOLDER": "Digite o ID do contato",
"INBOX": "Canal (Caixa de Entrada)",
"INBOX_PLACEHOLDER": "Selecione a caixa de entrada",
"STATUS": "Status da Reserva",
"STATUS_PLACEHOLDER": "Selecione o status",
"SUITE_IDENTIFIER": "Identificador da Suíte (Ex: 101, Suíte Master)",
"CHECK_IN": "Check-in",
"CHECK_OUT": "Check-out",
"TOTAL_AMOUNT": "Valor Total"
}
},
"VIEW": {
"LIST": "Lista",
"KANBAN": "Kanban",
@ -22,7 +41,37 @@
"SORT_UPDATED": "Última atualização",
"SORT_CREATED": "Data de criação",
"APPLY": "Aplicar filtros",
"CLEAR": "Limpar filtros"
"CLEAR": "Limpar filtros",
"HIDE": "Ocultar filtros"
},
"KPI": {
"TOTAL": "Total filtrado",
"PENDING_PIX": "Aguardando PIX",
"CHECKIN_TODAY": "Check-in hoje",
"REVENUE_TODAY": "Receita hoje"
},
"PILLS": {
"ALL": "Todas",
"DRAFT": "Rascunho",
"PENDING_PAYMENT": "Aguardando PIX",
"CONFIRMED": "Confirmadas",
"CANCELLED": "Canceladas"
},
"QUICK_DATE": {
"TODAY": "Hoje",
"TOMORROW": "Amanhã",
"WEEK": "7 dias",
"ALL": "Tudo"
},
"CARD": {
"CHECK_IN": "Check-in",
"AMOUNT": "Valor",
"TODAY": "Hoje",
"TOMORROW": "Amanhã",
"YESTERDAY": "Ontem",
"PIX_EXPIRED": "Expirado",
"PIX_EXPIRES_IN_MIN": "Expira em {minutes}min",
"PIX_EXPIRES_IN_HR": "Expira em {hours}h"
},
"TABLE": {
"CUSTOMER": "Cliente",
@ -35,14 +84,29 @@
"ACTIONS": "Ações"
},
"STATUS": {
"DRAFT": "Rascunho",
"SCHEDULED": "Agendado",
"PENDING_PAYMENT": "Aguardando pagamento",
"ACTIVE": "Ativa",
"CONFIRMED": "Confirmada",
"CANCELLED": "Cancelada"
"COMPLETED": "Concluída",
"CANCELLED": "Cancelada",
"DRAFT": "Rascunho"
},
"ACTIONS": {
"OPEN_CONVERSATION": "Abrir conversa",
"COPY_PIX": "Copiar Pix"
"COPY_PIX": "Copiar Pix",
"MORE": "Mais",
"REGENERATE_PIX": "Reenviar PIX",
"MARK_AS_PAID": "Marcar como paga",
"MARK_AS_PAID_CONFIRM": "Marcar esta reserva como paga manualmente?",
"MARKED_AS_PAID": "Reserva marcada como paga.",
"MARK_AS_PAID_FAILED": "Falha ao marcar como paga.",
"CANCEL": "Cancelar reserva",
"CANCEL_REASON_PROMPT": "Motivo do cancelamento (opcional):",
"CANCELLED": "Reserva cancelada.",
"CANCEL_FAILED": "Falha ao cancelar.",
"PIX_REGENERATED": "Novo PIX gerado e enviado.",
"PIX_REGENERATE_FAILED": "Falha ao reenviar PIX."
},
"KANBAN": {
"EMPTY_COLUMN": "Nenhuma reserva neste status."
@ -79,6 +143,99 @@
"PIX_COPY_FAILED": "Não foi possível copiar o Pix."
}
},
"CAPTAIN_ORCHESTRATOR_EDITOR": {
"MISSING_DELIMITER_PREFIX": "Este prompt foi salvo antes da separação em seções. Todo o conteúdo aparece no campo \"Prompt Base do Sistema\". Clique em",
"MISSING_DELIMITER_BUTTON": "Restaurar Padrão",
"MISSING_DELIMITER_SUFFIX": "para obter a separação automática, ou reorganize manualmente movendo o conteúdo do assistente para o campo correto."
},
"CAPTAIN_ROLETA": {
"HEADER": "Roleta da Sorte — Resgate",
"TAB_REDEEM": "Resgate",
"TAB_REPORT": "Relatório",
"REDEEM": {
"TITLE": "Entregar prêmio ao cliente",
"DESC": "Digite o código que o cliente mostrou no WhatsApp e confirme o resgate. A Jasmine manda automaticamente uma confirmação pro cliente.",
"CODE_LABEL": "Código do cupom",
"CODE_PLACEHOLDER": "Ex: ABC123",
"NOTES_LABEL": "Observação (opcional)",
"NOTES_PLACEHOLDER": "Qualquer detalhe sobre o resgate",
"SUBMIT": "Confirmar resgate",
"SUBMITTING": "Registrando...",
"SUCCESS_PREFIX": "{prize} entregue para ",
"SUCCESS_FULL": "✅ {prize} entregue para {name}.",
"ERROR_FULL": "⚠️ {message}",
"FALLBACK_CLIENT": "cliente",
"ERROR_EMPTY_CODE": "Digite o código impresso no cupom do cliente.",
"ERROR_NOT_FOUND": "Código não encontrado. Confere se digitou direito.",
"ERROR_ALREADY_REDEEMED": "Esse cupom já foi resgatado antes.",
"ERROR_NO_PRIZE": "Esse cupom caiu em \"Sem sorte\" — não tem nada pra entregar.",
"ERROR_NO_RECEPTIONIST": "Faz login de novo e tenta outra vez.",
"ERROR_RPC_FAILED": "Erro ao chamar o servidor de reservas.",
"ERROR_EXCEPTION": "Algo quebrou. Avisa o time técnico.",
"ERROR_DEFAULT": "Não foi registrado."
},
"HISTORY": {
"TITLE": "Cupons ativos (últimos 7 dias)",
"LOADING": "Carregando...",
"EMPTY": "Nenhum cupom ativo nos últimos dias.",
"LOAD_ERROR": "Erro ao carregar cupons pendentes",
"COL_CODE": "Código",
"COL_PRIZE": "Prêmio",
"COL_CLIENT": "Cliente",
"COL_GENERATED": "Gerado",
"COL_STATUS": "Resgate",
"STATUS_REDEEMED_PREFIX": "✅ ",
"STATUS_PENDING": "⏳ Aguardando"
},
"REPORT": {
"TITLE": "Relatório de resgates por recepcionista",
"DESC": "Anti-fraude: flaga quem resgatou muito acima da média da equipe.",
"PERIOD_7": "Últimos 7 dias",
"PERIOD_14": "Últimos 14 dias",
"PERIOD_30": "Últimos 30 dias",
"LOADING": "Carregando relatório...",
"EMPTY": "Nenhum resgate registrado nesse período.",
"LOAD_ERROR": "Erro ao carregar relatório",
"KPI_TOTAL": "Total resgates",
"KPI_AVG": "Média por pessoa",
"KPI_COUNT": "Recepcionistas ativas",
"KPI_THRESHOLD": "Limite de alerta",
"KPI_THRESHOLD_PREFIX": "≥ ",
"COL_RECEPTIONIST": "Recepcionista",
"COL_TOTAL": "Total",
"COL_BRINDES": "Brindes",
"COL_DESCONTOS": "Descontos",
"COL_SUM_DISCOUNT": "Σ % desconto",
"COL_STATUS": "Status",
"STATUS_ANOMALY": "⚠️ Acima da média",
"STATUS_NORMAL": "Normal",
"FOOTER_HINT": "Alerta dispara quando a recepcionista tem ≥ {threshold} resgates (mínimo 5, ou 2,5× a média da equipe). Investigue conversas do cliente no WhatsApp pra confirmar."
}
},
"CAPTAIN_FUNNEL": {
"HEADER": "Funil de Conversão",
"DESC": "Acompanhe a jornada do cliente da pergunta de preço até o Pix pago. Identifica onde os clientes mais desistem.",
"PERIOD_7": "7 dias",
"PERIOD_30": "30 dias",
"PERIOD_60": "60 dias",
"PERIOD_90": "90 dias",
"LOADING": "Carregando funil...",
"EMPTY": "Nenhuma conversa do Captain nesse período. Ajuste o intervalo.",
"LOAD_ERROR": "Erro ao carregar funil",
"INSIGHT_LABEL": "Maior ponto de abandono",
"INSIGHT_FULL": "{lost} clientes caíram entre \"{from}\" e \"{to}\" — isso é {pct} dos que chegaram na etapa anterior.",
"FUNNEL_TITLE": "Funil geral ({count} conversas analisadas)",
"BY_SUITE_TITLE": "Por categoria de suíte",
"BY_SUITE_HEADER": "Suíte",
"BY_SUITE_FOOTER": "Categoria detectada por menção no conteúdo da conversa. Conversas sem menção específica não aparecem nesse breakdown.",
"STAGES": {
"price_inquiry": "Perguntou preço",
"price_answered": "Recebeu cotação",
"reservation_drafted": "Reserva iniciada",
"pix_generated": "Pix gerado",
"pix_paid": "Pix pago"
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Configurações do Captain",
"UNITS": {
@ -212,7 +369,8 @@
"Ações"
],
"ADD_NEW_ITEM": "Adicione fotos na galeria",
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes."
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes.",
"VIEW_URL": "URL da Imagem"
},
"DELETE": {
"CONFIRM": {
@ -252,14 +410,14 @@
"SPECIFIC_HELP": "Essas fotos serão usadas somente na caixa de entrada {inbox}."
},
"SUITE_CATEGORY": {
"LABEL": "Categoria da suíte",
"LABEL": "Categoria",
"PLACEHOLDER": "Ex: Hidromassagem",
"ERROR": "A categoria é obrigatória"
},
"SUITE_NUMBER": {
"LABEL": "Número/identificador da suíte",
"LABEL": "Nome/identificador",
"PLACEHOLDER": "Ex: 101",
"ERROR": "O identificador da suíte é obrigatório"
"ERROR": "O identificador é obrigatório"
},
"DESCRIPTION": {
"LABEL": "Descrição da foto",
@ -277,5 +435,506 @@
}
}
}
},
"CAPTAIN": {
"BANNER": {
"RESPONSES": "Você usou mais de 80% do seu limite de respostas. Para continuar usando o Capitão IA, faça um upgrade.",
"DOCUMENTS": "Limite de documentos atingido. Faça um upgrade para continuar usando o Capitão IA."
},
"FORM": {
"CANCEL": "Cancelar",
"CREATE": "Criar",
"EDIT": "Atualizar"
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "FAQs Pendentes",
"ADD_NEW": "Criar nova FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversação #{id}"
},
"SELECTED": "{count} selecionado",
"SELECT_ALL": "Selecionar todos ({count})",
"UNSELECT_ALL": "Desmarcar todos ({count})",
"SEARCH_PLACEHOLDER": "Pesquisar FAQs...",
"BULK_APPROVE_BUTTON": "Aprovar",
"BULK_DELETE_BUTTON": "Excluir",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "Perguntas Frequentes aprovadas com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao aproveitar as Perguntas Frequentes. Tente novamente."
},
"BULK_DELETE": {
"TITLE": "Excluir as Perguntas Frequentes?",
"DESCRIPTION": "Tem certeza que deseja excluir as Perguntas Frequentes selecionadas? Esta ação não pode ser desfeita.",
"CONFIRM": "Sim, excluir todas",
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao excluir as Perguntas Frequentes, por favor tente novamente."
},
"DELETE": {
"TITLE": "Tem certeza que deseja excluir o FAQ?",
"DESCRIPTION": "",
"CONFIRM": "Sim, excluir",
"SUCCESS_MESSAGE": "FAQ excluída com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao excluir a FAQ, por favor tente novamente."
}
}
},
"CAPTAIN_REPORTS": {
"TITLE": "Relatórios IA",
"DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.",
"LOADING": "Carregando relatórios...",
"ALL_UNITS": "Todas as unidades",
"ALL_INBOXES": "Todas as caixas de entrada",
"UNITS_GROUP": "Unidades Pix",
"INBOXES_GROUP": "Caixas de Entrada",
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional",
"EXECUTIVE": "Executivo",
"LANDING_PAGES": "Landing Pages",
"RETENTION": "Retenção"
},
"RETENTION": {
"PERIOD_LABEL": "Período",
"PERIOD_THIS_MONTH": "Este mês",
"PERIOD_LAST_30": "Últimos 30 dias",
"PERIOD_LAST_90": "Últimos 90 dias",
"PERIOD_CUSTOM": "Personalizado",
"APPLY": "Aplicar",
"NO_DATA": "Sem dados.",
"KPI": {
"ACTIVE": "Clientes ativos",
"ACTIVE_HINT": "última interação nos últimos 30 dias",
"RECURRING": "Recorrentes",
"RECURRING_HINT": "≥2 interações qualificadas em 90 dias",
"RETURN_30D": "Taxa de retorno 30d",
"RETURN_30D_HINT": "voltaram a interagir em 7 dias",
"PIX_CONVERSION": "Conversão Pix",
"PIX_CONVERSION_HINT": "{paid} pagos de {generated} gerados"
},
"FLOW": {
"TITLE": "Fluxo do período",
"NEW_IN_PERIOD": "novos no período",
"RETURNED_IN_PERIOD": "retornaram no período",
"TOTAL_TOUCHES": "interações totais",
"BASE_STATUS": "Situação atual da base",
"SLEEPING": "{count} adormecidos",
"SLEEPING_HINT": "30-90d sem contato",
"AT_RISK": "{count} em risco",
"AT_RISK_HINT": "90-180d sem contato",
"CHURNED": "{count} inativos",
"CHURNED_HINT": "180d+ sem contato"
},
"COHORT": {
"TITLE": "Matriz de cohort",
"SUBTITLE": "% de clientes de cada cohort que voltaram a interagir em M+N meses.",
"EXPORT_CSV": "Exportar CSV",
"EMPTY": "Ainda não há cohorts com dados.",
"COL_COHORT": "Cohort",
"COL_SIZE": "Tamanho",
"CELL_TITLE": "{count} contatos ativos ({rate}%)"
},
"BADGE": {
"STATUS_FIRST": "Primeiro contato",
"STATUS_INACTIVE": "Inativo",
"STATUS_AT_RISK": "Em risco",
"STATUS_SLEEPING": "Adormecido",
"STATUS_RECURRING": "Recorrente",
"STATUS_ACTIVE": "Ativo",
"LAST_INTERACTION": "última {days}",
"INTERACTIONS_LABEL": "interação | interações",
"INTERACTIONS_TITLE": "Interações qualificadas (≥2+2 mensagens)",
"ONE_SHOT_LABEL": "one-shot",
"ONE_SHOT_TITLE": "Consultas one-shot (≥1+1)",
"PIX_LABEL": "Pix pagos",
"PIX_TITLE": "Pix gerados / reservas pagas",
"DAYS_TODAY": "hoje",
"DAYS_YESTERDAY": "ontem",
"DAYS_RECENT": "há {days} dias",
"DAYS_ONE_MONTH": "há cerca de 1 mês",
"DAYS_MONTHS": "há {months} meses",
"DAYS_YEARS": "há {years} anos"
},
"ERRORS": {
"SUMMARY": "Falha ao carregar KPIs de retenção",
"COHORT": "Falha ao carregar cohort"
}
},
"EXECUTIVE": {
"LOADING": "Carregando digest executivo...",
"NO_DATA": "Sem insights gerados para o período. Rode a análise semanal pra ver os dados aqui.",
"TITLE": "Digest Executivo",
"SUBTITLE": "Mesmo relatório enviado ao Mattermost, com drill-down e filtros.",
"DELIVER_BUTTON": "Enviar ao Mattermost agora",
"DELIVER_SUCCESS": "Digest enfileirado. Vai chegar no Mattermost em instantes.",
"DELIVER_ERROR": "Falha ao disparar o digest. Veja o log do Rails.",
"CONVERSATIONS": "Conversas",
"MESSAGES": "Mensagens",
"UNITS_ANALYZED": "Unidades analisadas",
"INSIGHTS_COUNT": "Insights gerados",
"UNIT_TABLE": "Comparativo por unidade",
"COL_UNIT": "Unidade",
"COL_CONVS": "Conversas",
"COL_DELTA": "vs semana anterior",
"COL_AI_RATE": "Taxa de acerto IA",
"COL_FAILURES": "Falhas",
"AI_FAILURES": "Onde a Angelina errou (clique pra ver conversas)",
"OPPORTUNITIES": "Oportunidades — o que clientes pediram",
"OPPORTUNITIES_HINT": "Clique em uma oportunidade pra ver as conversas reais onde foi mencionada.",
"COMPLAINTS": "Reclamações recorrentes",
"PRAISES": "Elogios recorrentes",
"RECOMMENDATIONS": "Recomendações da IA",
"DRILLDOWN_TITLE": "Conversas relacionadas",
"NO_CONVERSATIONS_FOUND": "Nenhuma conversa encontrada com essas palavras no período.",
"NO_CONVERSATIONS_HINT": "A descrição do insight é uma abstração da IA. Se nenhuma palavra-chave bateu com o texto literal das conversas, nada é retornado. Tente clicar em outro item mais específico.",
"SEARCH_TOKENS": "Palavras buscadas",
"OPEN_CONVERSATION": "Abrir no Chatwoot"
},
"LP": {
"LOADING": "Carregando dados...",
"NO_DATA": "Nenhum clique registrado ainda. Integre o pixel na landing page para ver os dados aqui.",
"TOTAL_CLICKS": "Total de Cliques",
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
"CONVERSION_RATE": "Taxa de Conversão",
"TOTAL_DROPOFF": "Perdas (sem conversa)",
"DROPOFF_RATE": "Taxa de Perda",
"UNIQUE_CONTACTS": "Contatos únicos convertidos",
"FUNNEL_TITLE": "Funil da landing page",
"DAILY_TREND": "Tendência diária de cliques vs conversões",
"BY_SOURCE": "Cliques por Origem",
"BY_CAMPAIGN": "Cliques por Campanha",
"BY_HOSTNAME": "Cliques por Landing Page",
"CLICKS": "cliques",
"CONV": "conv",
"REFRESH": "Atualizar",
"LEGEND_CLICKS": "Cliques",
"LEGEND_CONVERSIONS": "Conversões"
},
"FILTER_DATE": {
"LABEL": "Período:",
"START": "Data Início",
"END": "Data Fim",
"TODAY": "Hoje",
"YESTERDAY": "Ontem",
"LAST_7_DAYS": "Últimos 7 dias",
"LAST_30_DAYS": "Últimos 30 dias",
"THIS_WEEK": "Esta semana",
"LAST_WEEK": "Semana Passada",
"CURRENT_MONTH": "Mês Atual",
"LAST_MONTH": "Mês Passado",
"CUSTOM": "Personalizado",
"SEPARATOR": "-"
},
"INSIGHT": {
"CONVERSATIONS": "conversas",
"MESSAGES": "mensagens",
"TOP_TOPICS": "Principais tópicos",
"AI_FAILURES": "Falhas da IA",
"BULLET": "•",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"TIMES": "x",
"SENTIMENT": "Sentimento",
"SENTIMENT_POSITIVE": "Positivo",
"SENTIMENT_NEGATIVE": "Negativo",
"SENTIMENT_NEUTRAL": "Neutro",
"PRAISES": "Elogios dos clientes",
"COMPLAINTS": "Reclamações",
"FAQ_GAPS": "Lacunas no FAQ",
"FAQ_GAPS_HINT": "Perguntas que os clientes fazem mas o agente não cobre",
"MOST_REQUESTED_SUITES": "Suítes mais pedidas",
"PRICE_REACTIONS": "Reação a preços",
"PRICE_OBJECTIONS": "objeções de preço",
"RECOMMENDATIONS": "Recomendações",
"SHOW_DETAILS": "Ver análise completa",
"HIDE_DETAILS": "Ocultar detalhes"
},
"EMPTY": {
"TITLE": "Nenhum relatório gerado",
"MESSAGE": "Gere um novo relatório para analisar o desempenho do atendimento."
},
"GENERATE": {
"BUTTON": "Gerar Análise",
"SUCCESS": "Relatório solicitado com sucesso! Pode levar alguns minutos.",
"ERROR": "Erro ao solicitar geração do relatório.",
"DATE_REQUIRED": "Por favor, selecione as datas de início e fim."
},
"STATUS": {
"PENDING": "Pendente",
"PROCESSING": "Processando",
"DONE": "Concluído",
"FAILED": "Falhou"
},
"OPERATIONAL": {
"LOADING": "Carregando dados operacionais...",
"NO_DATA": "Sem dados operacionais para o período selecionado.",
"CONVERSATIONS_SECTION": "Conversas",
"RESERVATIONS_SECTION": "Reservas",
"TOTAL": "Total",
"RESOLVED": "Resolvidas",
"OPEN": "Em aberto",
"AVG_RESOLUTION": "Tempo médio de resolução",
"RES_TOTAL": "Total de reservas",
"RES_PAID": "Pagas",
"RES_EXPIRED": "Expiradas",
"RES_REVENUE": "Receita paga",
"BY_INBOX": "Volume por canal",
"RESOLUTION_RATE_TOOLTIP": "Taxa de resolução",
"DAILY_DIST": "Distribuição por dia",
"HOURLY_DIST": "Distribuição por hora",
"PEAK": "Pico"
},
"DASHBOARD": {
"TOTAL_CONVERSATIONS": "Conversas analisadas",
"AVG_SENTIMENT": "Sentimento positivo médio",
"FAQ_GAPS_TOTAL": "Lacunas de FAQ identificadas",
"WEEKS_ANALYZED": "semanas analisadas",
"NO_DATA": "Dados insuficientes. Gere mais relatórios de IA para ver o dashboard.",
"SENTIMENT_TREND": "Tendência de sentimento por semana",
"FAILURES_RANKING": "Ranking de falhas do agente",
"FAILURES_RANKING_HINT": "Situações mais frequentes em que a IA não conseguiu responder bem",
"FAQ_PRIORITY": "FAQ prioritário para criar",
"FAQ_PRIORITY_HINT": "Perguntas que os clientes mais fazem e ainda não estão no FAQ",
"CUSTOMER_BEHAVIOR": "Comportamento dos clientes",
"TOP_TOPICS_TITLE": "Assuntos mais discutidos",
"SUITES_TITLE": "Suítes mais solicitadas",
"COMPLAINTS_TREND": "Volume de reclamações por semana",
"HANDOFFS_TITLE": "Estimativa de transferências para humano",
"HANDOFFS_HINT": "Baseado na frequência de falhas do agente. Rastreamento direto de transferências em breve.",
"TREND_UP": "em alta",
"TREND_DOWN": "em queda",
"TREND_STABLE": "estável",
"WEEKS": "semanas"
},
"FAQ_QUICK_ADD": {
"BUTTON": "Criar FAQ",
"TITLE": "Criar FAQ a partir da sugestão da IA",
"QUESTION_LABEL": "Pergunta (sugerida pela IA)",
"ANSWER_LABEL": "Resposta",
"ANSWER_PLACEHOLDER": "Escreva a resposta para esta pergunta...",
"ASSISTANT_LABEL": "Agente de IA",
"ASSISTANT_PLACEHOLDER": "Selecione o agente",
"CANCEL": "Cancelar",
"SAVE": "Salvar FAQ",
"SUCCESS": "FAQ criado com sucesso!",
"ERROR": "Erro ao criar FAQ. Tente novamente."
}
},
"CAPTAIN_LIFECYCLE": {
"HEADER": "Jornada do Cliente",
"SUBTITLE": "Automação de mensagens WhatsApp no ciclo de vida da reserva",
"TABS": {
"RULES": "Regras",
"SETTINGS": "Configurações",
"HISTORY": "Histórico"
},
"RULES": {
"EMPTY": "Nenhuma regra configurada ainda.",
"CREATE": "Nova regra",
"TEMPLATES_TITLE": "Templates prontos",
"COLUMNS": {
"NAME": "Nome",
"EVENT": "Evento",
"OFFSET": "Offset",
"FILTER": "Filtro",
"STATUS": "Status",
"ACTIONS": "Ações"
},
"STATUS": {
"ENABLED": "Ativo",
"DISABLED": "Desativado"
},
"ACTIONS": {
"EDIT": "Editar",
"DUPLICATE": "Duplicar",
"TOGGLE": "Ativar/Desativar",
"DELETE": "Excluir"
},
"DELETE_CONFIRM": "Tem certeza que deseja excluir esta regra?",
"TOAST": {
"CREATED": "Regra criada com sucesso.",
"UPDATED": "Regra atualizada.",
"DELETED": "Regra excluída."
},
"WIZARD": {
"TITLE_CREATE": "Nova regra",
"TITLE_EDIT": "Editar regra",
"STEP_WHEN": "Quando?",
"STEP_WHO": "Pra quem?",
"STEP_WHAT": "O quê?",
"STEP_REVIEW": "Revisão",
"NEXT": "Próximo",
"BACK": "Voltar",
"SAVE": "Salvar",
"CANCEL": "Cancelar",
"OFFSET_UNIT_LABEL": "min",
"STEP_LABELS": {
"WHEN": "1. Quando?",
"WHO": "2. Pra quem?",
"WHAT": "3. O quê?",
"REVIEW_TAB": "4. Revisão"
},
"REVIEW": {
"NAME": "Nome:",
"EVENT": "Evento:",
"OFFSET": "Offset (min):",
"UNITS": "Unidades:",
"MESSAGE": "Mensagem:"
},
"FIELDS": {
"NAME": "Nome da regra",
"DESCRIPTION": "Descrição",
"EVENT": "Evento gatilho",
"OFFSET_VALUE": "Valor",
"OFFSET_UNIT": "Unidade",
"OFFSET_DIRECTION": "Direção",
"UNITS": "Unidades",
"CATEGORIAS": "Categorias de suíte",
"PERMANENCIAS": "Tipos de permanência",
"MESSAGE_TYPE": "Tipo de mensagem",
"MESSAGE_BODY": "Texto da mensagem",
"PRIORITY": "Prioridade",
"ENABLED": "Regra ativa"
},
"OFFSET_UNITS": {
"MINUTES": "Minutos",
"HOURS": "Horas",
"DAYS": "Dias"
},
"OFFSET_DIRECTIONS": {
"BEFORE": "Antes",
"AFTER": "Depois"
},
"EVENTS": {
"RESERVATION_CONFIRMED": "Reserva confirmada (Pix pago)",
"CHECKIN_SCHEDULED_AT": "Horário de check-in",
"CHECKOUT_SCHEDULED_AT": "Horário de check-out",
"RESERVATION_CANCELLED": "Reserva cancelada",
"RESERVATION_NO_SHOW": "No-show"
},
"MESSAGE_TYPES": {
"TEXT": "Texto simples",
"BUTTONS": "Texto com botões",
"LIST": "Menu de lista",
"URL_BUTTON": "Botão de link"
}
}
},
"SETTINGS": {
"GUARDS_TITLE": "Guards anti-spam",
"QUIET_HOURS_ENABLED": "Ativar quiet hours",
"QUIET_HOURS_FROM": "De",
"QUIET_HOURS_TO": "Até",
"MIN_INTERVAL": "Intervalo mínimo entre mensagens (min)",
"MIN_INTERVAL_HELP": "0 desativa",
"PAUSE_ON_REPLY": "Pausar se o cliente respondeu",
"PAUSE_ON_REPLY_WINDOW": "Janela (min)",
"OPT_OUT_LABEL": "Label de opt-out",
"MAX_PER_RESERVATION_INFO": "Máximo de 5 mensagens por reserva (não configurável)",
"CONCIERGE_TITLE": "Concierge (Sofia) por Unidade",
"CONCIERGE_INBOX": "Inbox WhatsApp",
"CONCIERGE_PERSONA": "Nome da persona",
"CONCIERGE_KNOWLEDGE": "Base de conhecimento (markdown)",
"CONCIERGE_VARIABLES": "Variáveis da unidade",
"CONCIERGE_VARIABLE_KEY": "Chave",
"CONCIERGE_VARIABLE_VALUE": "Valor",
"CONCIERGE_ADD_VARIABLE": "Adicionar variável",
"CONCIERGE_CONFIGURED": "Configurado",
"CONCIERGE_NOT_CONFIGURED": "Não configurado",
"SAVE": "Salvar alterações",
"TOAST": {
"SAVED": "Configurações salvas.",
"CONCIERGE_SAVED": "Concierge da unidade atualizado."
}
},
"HISTORY": {
"EMPTY": "Nenhuma entrega registrada.",
"COLUMNS": {
"RULE": "Regra",
"CUSTOMER": "Cliente",
"RESERVATION": "Reserva",
"STATUS": "Status",
"FIRE_AT": "Disparado em",
"REASON": "Motivo",
"ACTIONS": ""
},
"STATUS": {
"SCHEDULED": "Agendada",
"SENT": "Enviada",
"SKIPPED": "Pulada",
"FAILED": "Falhou",
"CANCELLED": "Cancelada"
},
"FILTERS": {
"STATUS": "Status",
"RULE": "Regra",
"FROM": "De",
"TO": "Até",
"ALL": "Todas"
},
"PREVIEW": "Preview",
"TOTAL": "total",
"PAGINATION": {
"PREV": "Anterior",
"NEXT": "Próxima"
},
"MODAL": {
"TITLE": "Preview da mensagem",
"CLOSE": "Fechar",
"RULE": "Regra",
"STATUS": "Status",
"REASON": "Motivo",
"ERROR": "Erro",
"FIRE_AT": "Fire at",
"SENT_AT": "Sent at",
"RENDERED": "Rendered",
"RESERVATION_ID": "Reserva #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Construtor de Agentes",
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
"HEADER_TITLE": "Construtor de Agentes",
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
"RESET": "Limpar conversa",
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
"EMPTY_STATE": "Pronto pra criar um novo agente Hermes? Clica em \"Iniciar criação\" e o Construtor te guia.",
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
"SEND": "Enviar",
"SESSION_LABEL": "Sessão:",
"SEND_FAILED": "Erro ao enviar: {message}",
"RESET_FAILED": "Falha ao limpar sessão.",
"START": "Iniciar criação",
"TAB_CHAT": "Chat (Construtor)",
"TAB_VERIFY": "Verificação",
"VERIFY": {
"TITLE": "Verificação de agente",
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
"RUN": "Conferir agora",
"RUNNING": "Conferindo...",
"REPAIR": "Refazer",
"REPAIRING": "Reparando...",
"OK_LABEL": "OK",
"FAILS_LABEL": "falhas",
"WARN_LABEL": "atenção",
"OF_TOTAL": "de {total} checks",
"VERDICT_PASS": "Pode soltar",
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
"REPAIR_FAILED": "Falha: {message}",
"REPAIR_OK": "Reparado: {message}",
"FETCH_FAILED": "Erro carregando assistentes: {message}",
"VALIDATE_FAILED": "Falha ao validar: {message}",
"CATEGORY_DB": "Banco de dados",
"CATEGORY_PRICING": "Preços",
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
"CATEGORY_MCP": "Tools MCP registradas",
"CATEGORY_OTHER": "Outros"
}
}
}

View File

@ -370,6 +370,7 @@
"CONVERSATION_LABELS": "Etiquetas da conversa",
"CONVERSATION_INFO": "Informação da conversa",
"CONTACT_NOTES": "Notas do contato",
"CONTACT_MEMORIES": "Memórias do Cliente",
"CONTACT_ATTRIBUTES": "Atributos do contato",
"PREVIOUS_CONVERSATION": "Conversas anteriores",
"RESERVATION": "Reserva",
@ -395,6 +396,28 @@
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
},
"CONTACT_MEMORIES": {
"LOADING": "Carregando memórias…",
"EMPTY_TITLE": "Nenhuma memória ainda",
"EMPTY_HINT": "Memórias aparecem aqui conforme o cliente conversa.",
"FORGET": "Esquecer",
"FORGET_ALL": "Esquecer todas",
"CONFIRM_DELETE": "Esquecer esta memória?",
"CONFIRM_FORGET_ALL": "Esquecer TODAS as memórias deste cliente? Não pode ser desfeito após 30 dias.",
"ERROR_LOADING": "Falha ao carregar memórias",
"CONFIDENCE": "Confiança",
"TYPE_LABELS": {
"preferencia": "Preferência",
"data_comemorativa": "Data especial",
"vinculo_social": "Vínculo social",
"padrao_comportamental": "Padrão",
"reclamacao": "Reclamação",
"feedback_positivo": "Elogio",
"restricao": "Restrição",
"vinculo_comercial": "Vínculo comercial",
"contexto_pessoal": "Pessoal"
}
}
},
"SCHEDULED_MESSAGES": {

View File

@ -104,6 +104,14 @@
"ERROR": "Falha ao atualizar configuração de transcrição de áudio"
}
},
"AGGRESSIVE_ALERT": {
"TITLE": "Alerta agressivo de conversa (master switch)",
"NOTE": "Quando ligado, atendentes recebem banner + som + notificação do SO quando uma conversa é reaberta e a cada 5/15/28 min sem resposta. Cada agente ainda pode desligar pra si no próprio perfil — este toggle é o mestre da conta. Desligar aqui = ninguém recebe.",
"API": {
"SUCCESS": "Alerta agressivo atualizado",
"ERROR": "Falha ao atualizar o alerta agressivo"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Tempo de inatividade para resolução",
"HELP": "Tempo de inatividade após o qual a conversa deve ser encerrada automaticamente",

View File

@ -1,4 +1,5 @@
import advancedFilters from './advancedFilters.json';
import aggressiveBanner from './aggressiveBanner.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@ -40,6 +41,7 @@ import whatsappTemplates from './whatsappTemplates.json';
export default {
...advancedFilters,
...aggressiveBanner,
...agentBots,
...agentMgmt,
...attributesMgmt,

View File

@ -361,12 +361,16 @@
},
"CAPTAIN": {
"NAME": "Capitão",
"HEADER_KNOW_MORE": "Know more",
"HEADER_KNOW_MORE": "Saiba mais",
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistentes",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"SWITCH_ASSISTANT": "Alternar entre assistentes",
"NEW_ASSISTANT": "Criar Assistente",
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
"ENGINE_INTERNO": "Interno",
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
},
"COPILOT": {
"TITLE": "Copiloto",
@ -541,13 +545,20 @@
"SECTION_DESCRIPTION": "Edite o prompt base que define o comportamento central da IA. Deixe vazio para usar o prompt padrão do sistema.",
"LABEL": "Prompt do Orquestrador",
"DESCRIPTION": "Este prompt controla como a IA toma decisões, classifica pedidos e faz handoff para agentes especializados.",
"PLACEHOLDER": "Digite o prompt customizado aqui. Formatação Liquid ({{ variavel }}) é suportada.",
"PLACEHOLDER": "Digite o prompt customizado aqui. Formatação Liquid é suportada.",
"WARNING": "⚠️ Atenção: Alterar o prompt pode impactar todo o comportamento da IA. Sempre valide as mudanças antes de salvar. Use \"Restaurar Padrão\" para voltar ao comportamento original.",
"SAVE_BUTTON": "Salvar Prompt",
"RESET_BUTTON": "Restaurar Padrão",
"USING_DEFAULT": "Usando prompt padrão do sistema",
"USING_CUSTOM": "Usando prompt customizado",
"VALIDATION_ERROR": "O prompt não pode ficar em branco. Use 'Restaurar Padrão' para voltar ao padrão do sistema."
"VALIDATION_ERROR": "O prompt não pode ficar em branco. Use 'Restaurar Padrão' para voltar ao padrão do sistema.",
"SYSTEM_LABEL": "Prompt Base do Sistema",
"SYSTEM_DESCRIPTION": "Estrutura técnica do Captain — contexto, identidade e handoff. Altere só se souber o que está fazendo.",
"SYSTEM_PLACEHOLDER": "Prompt base do sistema (contexto, identidade, handoff)...",
"ASSISTANT_LABEL": "Instruções do Assistente",
"ASSISTANT_DESCRIPTION": "Configure aqui o comportamento, regras, tom e personalidade específicos deste assistente.",
"ASSISTANT_PLACEHOLDER": "Defina as instruções específicas do assistente: tom de voz, regras de negócio, restrições, fluxos de atendimento...",
"DIVIDER_LABEL": "✏️ início das configurações do assistente"
},
"OPTIONS": {
"EDIT_ASSISTANT": "Editar Assistente",
@ -558,8 +569,8 @@
"TITLE": "Não há assistentes disponíveis",
"SUBTITLE": "Crie um assistente para fornecer respostas rápidas e precisas aos seus usuários. Ele pode aprender com seus artigos de ajuda e conversas passadas.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Assistant",
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
"TITLE": "Assistente Capitão",
"NOTE": "O Assistente Capitão interage diretamente com os clientes, aprende com seus documentos de ajuda e conversas passadas, e entrega respostas instantâneas e precisas. Ele lida com as perguntas iniciais, fornecendo resoluções rápidas antes de transferir para um agente quando necessário."
}
},
"GUARDRAILS": {
@ -682,6 +693,11 @@
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
"ERROR": "Descrição do cenário é obrigatória"
},
"TRIGGER_KEYWORDS": {
"LABEL": "Quando usar este cenário (gatilhos)",
"PLACEHOLDER": "USE QUANDO:\n- Cliente quer reservar\n- Cliente pergunta preço\n\nEXEMPLOS DE GATILHOS:\n- \"quero reservar sábado\"\n- \"quanto custa?\"\n\nNÃO USE QUANDO:\n- Cliente só quer ver fotos",
"HELP": "Opcional, mas recomendado. Use este campo para ensinar a IA QUANDO rotear para este cenário. Quanto mais claro e com exemplos, melhor a precisão do roteamento."
},
"INSTRUCTION": {
"LABEL": "Como lidar",
"PLACEHOLDER": "Descreva como e onde este cenário será utilizado",
@ -769,8 +785,8 @@
"TITLE": "Nenhum documento disponível",
"SUBTITLE": "Os documentos são usados pelo seu assistente para gerar perguntas frequentes. Pode importar documentos para fornecer um contexto para seu assistente.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
"TITLE": "Documento do Capitão",
"NOTE": "Um documento no Capitão serve como um recurso de conhecimento para o assistente. Ao conectar sua central de ajuda ou guias, o Capitão pode analisar o conteúdo e fornecer respostas precisas para as consultas dos clientes."
}
}
},
@ -782,110 +798,118 @@
"SUBTITLE": "Crie ferramentas personalizadas para conectar com APIs e serviços externos, permitindo obter dados e agir por você.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Ferramentas Personalizadas",
"NOTE": "Ferramentas personalizadas permitem seu assistente interagir com APIs e serviços externos. Crie ferramentas para obter dados, realizar ações ou integre com seus sistemas existentes para melhorar as capacidades do seu assistente."
"NOTE": "As ferramentas personalizadas permitem que seu assistente interaja com APIs e serviços externos. Crie ferramentas para buscar dados, realizar ações ou integrar-se aos seus sistemas existentes para aprimorar as capacidades do seu assistente."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"FORM_DESCRIPTION": "Configure sua ferramenta personalizada para conectar-se a APIs externas",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
"EDIT_TOOL": "Editar ferramenta",
"DELETE_TOOL": "Excluir ferramenta"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
"TITLE": "Criar Ferramenta Personalizada",
"SUCCESS_MESSAGE": "Ferramenta personalizada criada com sucesso",
"ERROR_MESSAGE": "Falha ao criar ferramenta personalizada"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
"TITLE": "Editar Ferramenta Personalizada",
"SUCCESS_MESSAGE": "Ferramenta personalizada atualizada com sucesso",
"ERROR_MESSAGE": "Falha ao atualizar ferramenta personalizada"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"TITLE": "Excluir Ferramenta Personalizada",
"DESCRIPTION": "Tem certeza que deseja excluir está ferramenta customizável? Está ação não pode ser desfeita.",
"CONFIRM": "Sim, excluir",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
"SUCCESS_MESSAGE": "Ferramenta personalizada excluída com sucesso",
"ERROR_MESSAGE": "Falha ao excluir ferramenta personalizada"
},
"FORM": {
"TITLE": {
"LABEL": "Nome da Ferramenta",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Nome da ferramente obrigatória"
"PLACEHOLDER": "Ex: Busca de Pedidos",
"ERROR": "O nome da ferramenta é obrigatório"
},
"DESCRIPTION": {
"LABEL": "Descrição",
"PLACEHOLDER": "Looks up order details by order ID"
"PLACEHOLDER": "Descreva o que a ferramenta faz (ex: Busca detalhes do pedido pelo ID)"
},
"HTTP_METHOD": {
"LABEL": "Method"
"LABEL": "Método HTTP"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
"LABEL": "URL do Endpoint",
"PLACEHOLDER": "https://api.exemplo.com/pedidos/{'{{'} order_id {'}}'}",
"ERROR": "Uma URL válida é obrigatória"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
"LABEL": "Tipo de Autenticação"
},
"AUTH_TYPES": {
"NONE": "Nenhuma",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "Chave API"
"BEARER": "Token Bearer",
"BASIC": "Autenticação Básica (Basic Auth)",
"API_KEY": "Chave de API",
"CUSTOM_HEADERS": "Cabeçalhos Personalizados"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"BEARER_TOKEN": "Token Bearer",
"BEARER_TOKEN_PLACEHOLDER": "Digite seu token bearer",
"USERNAME": "Usuário",
"USERNAME_PLACEHOLDER": "Digite o usuário",
"PASSWORD": "Senha",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"PASSWORD_PLACEHOLDER": "Digite a senha",
"API_KEY": "Nome do Cabeçalho",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
"API_VALUE": "Valor do Cabeçalho",
"API_VALUE_PLACEHOLDER": "Digite o valor da chave de API",
"CUSTOM_HEADERS": "Cabeçalhos Personalizados",
"CUSTOM_HEADERS_HELP": "Adicione um ou mais cabeçalhos HTTP enviados em cada solicitação (ex: IDs de API, tokens).",
"HEADER_NAME": "Nome do Cabeçalho",
"HEADER_NAME_PLACEHOLDER": "ex: PLUG-PLAY-ID",
"HEADER_VALUE": "Valor do Cabeçalho",
"HEADER_VALUE_PLACEHOLDER": "ex: 198",
"ADD_HEADER": "Adicionar Cabeçalho"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
"LABEL": "Parâmetros",
"HELP_TEXT": "Defina os parâmetros que serão extraídos das perguntas dos usuários"
},
"ADD_PARAMETER": "Add Parameter",
"ADD_PARAMETER": "Adicionar Parâmetro",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
"PLACEHOLDER": "Nome do parâmetro (ex: order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Tipo"
},
"PARAM_TYPES": {
"STRING": "String",
"STRING": "Texto (String)",
"NUMBER": "Número",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
"BOOLEAN": "Booleano",
"ARRAY": "Lista (Array)",
"OBJECT": "Objeto"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
"PLACEHOLDER": "Descrição do parâmetro"
},
"PARAM_REQUIRED": {
"LABEL": "Obrigatório"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"LABEL": "Modelo do Corpo da Requisição (Opcional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
"LABEL": "Modelo de Resposta (Opcional)",
"PLACEHOLDER": "Status do pedido {'{{'} order_id {'}}'}: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
"PARAM_NAME_REQUIRED": "O nome do parâmetro é obrigatório"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"HEADER": "Perguntas Frequentes (FAQs)",
"PENDING_FAQS": "FAQs Pendentes",
"ADD_NEW": "Criar nova FAQ",
"DOCUMENTABLE": {
"CONVERSATION": "Conversação #{id}"
@ -904,11 +928,11 @@
"TITLE": "Excluir as Perguntas Frequentes?",
"DESCRIPTION": "Tem certeza que deseja excluir as Perguntas Frequentes selecionadas? Esta ação não pode ser desfeita.",
"CONFIRM": "Sim, excluir todas",
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso/",
"SUCCESS_MESSAGE": "Perguntas Frequentes excluídas com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao excluir as Perguntas Frequentes, por favor tente novamente."
},
"DELETE": {
"TITLE": "Tem certeza que deseja excluir o FAQ?",
"TITLE": "Tem certeza que deseja excluir a FAQ?",
"DESCRIPTION": "",
"CONFIRM": "Sim, excluir",
"SUCCESS_MESSAGE": "FAQ excluída com sucesso",
@ -922,12 +946,12 @@
"STATUS": {
"TITLE": "Status",
"PENDING": "Pendentes",
"APPROVED": "Aceito",
"APPROVED": "Aprovadas",
"ALL": "Todos"
},
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
"TITLE": "O Capitão encontrou algumas FAQs que seus clientes estavam procurando.",
"ACTION": "Clique aqui para revisar"
},
"FORM_DESCRIPTION": "Adicione uma pergunta e sua resposta correspondente à base de conhecimento e selecione o assistente ao qual deve estar associado.",
"CREATE": {
@ -942,13 +966,13 @@
"ERROR": "Por favor, forneça uma pergunta válida."
},
"ANSWER": {
"LABEL": "Responder",
"LABEL": "Resposta",
"PLACEHOLDER": "Digite a resposta aqui",
"ERROR": "Por favor, forneça uma resposta válida."
}
},
"EDIT": {
"TITLE": "Atualizar as Perguntas Frequentes",
"TITLE": "Atualizar a FAQ",
"SUCCESS_MESSAGE": "As Perguntas Frequentes foram atualizadas com sucesso",
"ERROR_MESSAGE": "Ocorreu um erro ao atualizar as Perguntas Frequentes, por favor tente novamente",
"APPROVE_SUCCESS_MESSAGE": "As Perguntas Frequentes foram marcadas como aprovadas"
@ -960,12 +984,12 @@
},
"EMPTY_STATE": {
"TITLE": "Nenhuma FAQ encontrada",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "Perguntas Frequentes ajudam seu assistente a fornecer respostas rápidas e precisas para perguntas de seus clientes. Eles podem ser gerados automaticamente a partir do seu conteúdo ou podem ser adicionados manualmente.",
"CLEAR_SEARCH": "Clear active filters",
"NO_PENDING_TITLE": "Não há mais FAQs pendentes para revisar",
"SUBTITLE": "As Perguntas Frequentes ajudam seu assistente a fornecer respostas rápidas e precisas para as perguntas de seus clientes. Elas podem ser geradas automaticamente a partir do seu conteúdo ou adicionadas manualmente.",
"CLEAR_SEARCH": "Limpar filtros ativos",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
"TITLE": "FAQ do Capitão",
"NOTE": "O Capitão FAQ detecta perguntas comuns dos clientes — sejam elas ausentes na sua base de conhecimento ou frequentemente perguntadas — e gera FAQs relevantes para melhorar o suporte. Você pode revisar cada sugestão e decidir se a aprova ou rejeita."
}
}
},
@ -1002,3 +1026,4 @@
}
}
}

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Contagem de Resolução",
"NAME": "Resolvidas pelo bot",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Contagem de transferências",
"NAME": "Transferidas para humano",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -269,8 +269,37 @@
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"ALL_INBOXES": "Todas as caixas",
"SEARCH_INBOX": "Buscar caixa",
"TABS": {
"OVERVIEW": "Visão Geral",
"LEADS": "Novas × Retorno"
},
"LEADS": {
"TITLE": "Novas × Retorno",
"INBOX_LABEL": "Caixa de entrada:",
"EMPTY": "Sem conversas no período.",
"TOTAL": "Total de conversas no período: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "Leads novos",
"INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes."
},
"RETURNING": {
"LABEL": "Retorno",
"INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h."
},
"OTHERS": {
"LABEL": "Outras",
"INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h."
}
},
"CHART": {
"NEW_LEADS": "Novas",
"RETURNING": "Retorno",
"OTHERS": "Outras"
}
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversas",
@ -437,20 +466,32 @@
"HEADER": "Relatórios do Bot",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "Nº de Conversas",
"TOOLTIP": "Número total de conversas tratadas pelo bot"
"LABEL": "Conversas",
"TOOLTIP": "Total de conversas atendidas pelo bot no período"
},
"TOTAL_RESPONSES": {
"LABEL": "Total de respostas",
"TOOLTIP": "Número total de respostas enviadas pelo bot"
"LABEL": "Mensagens enviadas",
"TOOLTIP": "Total de mensagens enviadas — inclui o bot E humanos (via Chatwoot ou eco do WhatsApp)"
},
"RESOLUTION_RATE": {
"LABEL": "Tempo de resolução",
"TOOLTIP": "Número total de conversas resolvidas pelo bot / número total de conversas manipuladas pelo bot * 100"
"LABEL": "Resolvidas pelo bot %",
"TOOLTIP": "Conversas que o bot resolveu sozinho (sem humano respondendo, via Chatwoot ou WhatsApp) ÷ total de conversas × 100"
},
"HANDOFF_RATE": {
"LABEL": "Taxa de entrega",
"TOOLTIP": "Número total de conversas entregues a agentes / número total de conversas mantidas pelo bot * 100"
"LABEL": "Transferidas pra humano %",
"TOOLTIP": "Conversas transferidas pra humano (auto pela Jasmine + tomada manual) ÷ total de conversas × 100. Junto com a taxa de resolução fecha a engrenagem (o resto está aberto, em snooze ou abandonado)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolvidas pelo bot",
"TOOLTIP": "Contagem absoluta: conversas que o bot fechou sozinho, sem humano respondendo (via Chatwoot ou WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Transferência automática (Jasmine)",
"TOOLTIP": "Conversas em que a Jasmine chamou bot_handoff! explicitamente — geralmente loop de ferramenta, timeout, limite de turnos ou intent do LLM classificado como handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Tomada manual (agente)",
"TOOLTIP": "Conversas em que um humano respondeu (Chatwoot ou eco do WhatsApp) SEM a Jasmine ter chamado bot_handoff! antes — o agente assumiu silenciosamente"
}
}
},
@ -578,5 +619,47 @@
"AVG_REPLY_TIME": "Tempo Médio de Espera do Cliente",
"RESOLUTION_COUNT": "Contagem de Resolução",
"CONVERSATIONS": "Nº de Conversas"
},
"DIRECTORY_DASHBOARD": {
"HEADER": "Painel Diretoria",
"BANNER": {
"TITLE": "Adoção do canal digital — não é a operação completa.",
"BODY": "Esses números medem só o canal digital (Jasmine + reservas via app). Conversas atendidas manualmente que fecharam na recepção ainda não estão capturadas (marcação manual em construção)."
},
"HEADLINE_NUMBERS": "Números principais",
"METRICS": {
"LEADS_TOTAL": {
"LABEL": "Leads (total)",
"TOOLTIP": "Todas as conversas criadas no período (novos + retorno)"
},
"LEADS_NEW": {
"LABEL": "Leads novos",
"TOOLTIP": "Primeira conversa do contato em qualquer caixa da rede"
},
"LEADS_RETURNING": {
"LABEL": "Leads de retorno",
"TOOLTIP": "Contato com pelo menos uma conversa anterior"
},
"CONVERSION_RATE": {
"LABEL": "Lead → Reserva paga",
"TOOLTIP": "Reservas pagas ÷ total de leads × 100. Proxy de adoção, não retrato da operação."
}
},
"FUNNEL": {
"TITLE": "Funil",
"STAGE_LEADS": "Leads",
"STAGE_RESERVATIONS": "Reservas criadas",
"STAGE_PAID": "Pagas"
},
"BENCHMARK": {
"TITLE": "Comparativo entre unidades por marca",
"BRAND_AVG": "média da marca",
"COL_INBOX": "Caixa de Entrada",
"COL_LEADS": "Leads",
"COL_CREATED": "Criadas",
"COL_PAID": "Pagas",
"COL_RATE": "Taxa conv.",
"COL_VS_BRAND": "vs marca"
}
}
}

View File

@ -35,6 +35,12 @@
}
}
},
"AGGRESSIVE_ALERT": {
"SECTION_TITLE": "Alerta agressivo de conversa",
"SECTION_NOTE": "Ativa banner, som e notificação do SO quando uma conversa é reaberta e a cada 5/15/28 minutos sem resposta. Só some quando VOCÊ responder. Desativa se quiser turno silencioso — mas a casa cai se esquecer.",
"TITLE": "Receber alertas agressivos",
"NOTE": "Se ligado, você recebe banner + som + notificação quando uma conversa é reaberta ou fica X minutos sem resposta. Só desliga se souber o que está fazendo."
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Personalize a aparência do seu painel do Chatwoot.",
@ -115,6 +121,15 @@
"RESET_SUCCESS": "Token de acesso gerado novamente com sucesso",
"RESET_ERROR": "Não foi possível regerar o token de acesso. Por favor, tente novamente"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Alerta de conversa parada",
"NOTE": "Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.",
"DESCRIPTION": "Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.",
"ENABLED": "Ativar alerta de conversa parada",
"APPLY_TO_ALL": "Aplicar em todas as caixas de entrada",
"INBOX_HINT": "Selecione as caixas onde você quer receber o alerta:",
"NO_INBOXES": "Nenhuma caixa de entrada cadastrada."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Alertas de áudio",
"NOTE": "Habilitar notificações de áudio no painel para novas mensagens e conversas.",
@ -342,6 +357,10 @@
"CAPTAIN_PIX_UNITS": "Unidades Pix",
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_ROLETA": "Roleta — Resgate",
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
"CAPTAIN_FUNNEL": "Funil de Conversão",
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
"CAPTAIN_REPORTS": "Relatórios IA",
"HOME": "Principal",
"AGENTS": "Agentes",
@ -378,6 +397,7 @@
"ONE_OFF": "Única",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Robôs",
"REPORTS_DIRECTORY_DASHBOARD": "Painel Diretoria",
"REPORTS_AGENT": "Agentes",
"REPORTS_LABEL": "Etiquetas",
"REPORTS_INBOX": "Caixa de Entrada",
@ -818,46 +838,5 @@
"CONFIRM_BUTTON_LABEL": "Excluir",
"CANCEL_BUTTON_LABEL": "Cancelar"
}
},
"CAPTAIN_REPORTS": {
"TITLE": "Relatórios IA",
"DESC": "Análises semanais geradas por IA com base nas conversas de cada unidade.",
"LOADING": "Carregando relatórios...",
"ALL_UNITS": "Todas as unidades",
"ALL_INBOXES": "Todas as caixas de entrada",
"TABS": {
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional"
},
"UNITS_GROUP": "Unidades Pix",
"INBOXES_GROUP": "Caixas de Entrada",
"STATUS": {
"PENDING": "Pendente",
"PROCESSING": "Processando",
"DONE": "Concluído",
"FAILED": "Falhou"
},
"GENERATE": {
"BUTTON": "Gerar Análise",
"SUCCESS": "Análise enfileirada com sucesso! Em breve estará disponível.",
"ERROR": "Erro ao solicitar análise. Tente novamente."
},
"INSIGHT": {
"CONVERSATIONS": "conversas",
"MESSAGES": "mensagens",
"TOP_TOPICS": "Principais Tópicos",
"AI_FAILURES": "Pontos de Melhoria da IA",
"COUNT_PREFIX": "(",
"COUNT_SUFFIX": ")",
"BULLET": "•"
},
"EMPTY": {
"TITLE": "Nenhuma análise disponível",
"MESSAGE": "Clique em Gerar Análise para criar o primeiro relatório semanal com insights das conversas."
},
"OPERATIONAL": {
"COMING_SOON": "Em breve",
"COMING_SOON_DESC": "Os dados operacionais em tempo real (reservas, cobranças Pix, etc.) estarão disponíveis aqui em breve."
}
}
}

View File

@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
@ -17,9 +18,12 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
const FAQ_QUESTION_MAX_LENGTH = 255;
export default {
components: {
AddCannedModal,
CreateResponseDialog,
MenuItem,
ContextMenu,
NextButton,
@ -60,6 +64,7 @@ export default {
data() {
return {
isCannedResponseModalOpen: false,
isFaqModalOpen: false,
showDeleteModal: false,
showEditModal: false,
editedContent: '',
@ -71,6 +76,7 @@ export default {
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
getUISettings: 'getUISettings',
copilotAssistant: 'getCopilotAssistant',
}),
plainTextContent() {
return this.getPlainText(this.messageContent);
@ -89,6 +95,11 @@ export default {
this.message.content_attributes ?? this.message.contentAttributes
);
},
faqQuestion() {
return (this.plainTextContent || '')
.trim()
.slice(0, FAQ_QUESTION_MAX_LENGTH);
},
},
methods: {
handleEnterKey(e) {
@ -125,10 +136,29 @@ export default {
showCannedResponseModal() {
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
this.isCannedResponseModalOpen = true;
this.handleClose();
},
hideCannedResponseModal() {
this.isCannedResponseModalOpen = false;
},
async showFaqModal() {
useTrack(ACCOUNT_EVENTS.ADDED_TO_CANNED_RESPONSE);
try {
await this.$store.dispatch(
'getInboxCaptainAssistantById',
this.conversationId
);
} catch (error) {
// Silence error, we can still open the modal
}
this.isFaqModalOpen = true;
this.handleClose();
this.$nextTick(() => {
this.$refs.faqDialog?.dialogRef?.open();
});
},
hideFaqModal() {
this.isFaqModalOpen = false;
},
handleOpen(e) {
this.$emit('open', e);
@ -163,7 +193,7 @@ export default {
messageId: this.messageId,
});
useAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.handleClose();
this.showDeleteModal = false;
} catch (error) {
useAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
@ -213,7 +243,7 @@ export default {
<div class="context-menu">
<!-- Add To Canned Responses -->
<woot-modal
v-if="isCannedResponseModalOpen && enabledOptions['cannedResponse']"
v-if="isCannedResponseModalOpen"
v-model:show="isCannedResponseModalOpen"
:on-close="hideCannedResponseModal"
>
@ -222,9 +252,20 @@ export default {
:on-close="hideCannedResponseModal"
/>
</woot-modal>
<!-- Add To FAQ -->
<CreateResponseDialog
v-if="isFaqModalOpen"
ref="faqDialog"
type="create"
:selected-response="{
question: faqQuestion,
assistant_id: copilotAssistant?.id,
}"
@close="hideFaqModal"
/>
<!-- Confirm Deletion -->
<woot-delete-modal
v-if="showDeleteModal && enabledOptions['delete']"
v-if="showDeleteModal"
v-model:show="showDeleteModal"
class="context-menu--delete-modal"
:on-close="closeDeleteModal"
@ -280,6 +321,7 @@ export default {
</form>
</div>
</woot-modal>
<NextButton
v-if="!hideButton"
ghost
@ -289,8 +331,9 @@ export default {
class="invisible group-hover/context-menu:visible"
@click="handleOpen"
/>
<ContextMenu
v-if="isOpen && !isCannedResponseModalOpen"
v-if="isOpen"
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
@close="handleClose"
@ -337,10 +380,10 @@ export default {
v-if="enabledOptions['cannedResponse']"
:option="{
icon: 'comment-add',
label: $t('CONVERSATION.CONTEXT_MENU.CREATE_A_CANNED_RESPONSE'),
label: 'Criar FAQ',
}"
variant="icon"
@click.stop="showCannedResponseModal"
@click.stop="showFaqModal"
/>
<MenuItem
v-if="enabledOptions['edit']"

View File

@ -284,6 +284,7 @@ onMounted(() => {
:key="scenario.id"
:title="scenario.title"
:description="scenario.description"
:trigger-keywords="scenario.trigger_keywords"
:instruction="scenario.instruction"
:tools="scenario.tools"
:is-selected="bulkSelectedIds.has(scenario.id)"

View File

@ -0,0 +1,362 @@
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const messages = ref([]);
const input = ref('');
const sending = ref(false);
const polling = ref(null);
const scrollContainer = ref(null);
const sessionId = ref(null);
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
const isWaiting = computed(
() => sending.value || lastMessageRole.value === 'user'
);
const scrollToBottom = () => {
const el = scrollContainer.value;
if (el) el.scrollTop = el.scrollHeight;
};
const fetchMessages = async () => {
try {
const { data } = await hermesBuilderApi.fetchMessages();
messages.value = data.messages || [];
sessionId.value = data.session_id;
await nextTick();
scrollToBottom();
} catch (e) {
// silencioso polling repete
}
};
const sendMessage = async () => {
const text = input.value.trim();
if (!text || sending.value) return;
sending.value = true;
messages.value.push({
role: 'user',
content: text,
created_at: new Date().toISOString(),
});
input.value = '';
await nextTick();
scrollToBottom();
try {
await hermesBuilderApi.sendMessage(text);
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const handleKeydown = e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const resetSession = async () => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
try {
await hermesBuilderApi.reset();
messages.value = [];
} catch (e) {
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
}
};
const startSession = async () => {
if (sending.value) return;
sending.value = true;
try {
await hermesBuilderApi.start();
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const formatTime = iso => {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
};
onMounted(() => {
fetchMessages();
polling.value = setInterval(fetchMessages, 2000);
});
onBeforeUnmount(() => {
if (polling.value) clearInterval(polling.value);
});
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
</script>
<template>
<div class="builder-wrapper">
<header class="builder-header">
<div>
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
</div>
<Button variant="ghost" size="sm" @click="resetSession">
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
</Button>
</header>
<section ref="scrollContainer" class="messages">
<div v-if="!messages.length" class="empty-state">
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
<button
type="button"
class="start-button"
:disabled="sending"
@click="startSession"
>
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
</button>
</div>
<div
v-for="(msg, idx) in messages"
:key="idx"
class="msg"
:class="[`msg--${msg.role}`]"
>
<div class="msg__bubble">
<div class="msg__content">{{ msg.content }}</div>
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<div v-if="isWaiting" class="msg msg--construtor">
<div class="msg__bubble msg__bubble--typing">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</section>
<footer class="composer">
<textarea
v-model="input"
rows="2"
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
:disabled="sending"
@keydown="handleKeydown"
/>
<Button
variant="primary"
:disabled="!input.trim() || sending"
@click="sendMessage"
>
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
</Button>
</footer>
<p v-if="sessionId" class="session-debug">
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
</p>
</div>
</template>
<style scoped lang="scss">
.builder-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100vh - 260px);
max-width: 900px;
margin: 0 auto;
}
.builder-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
margin: auto;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
p {
margin: 0;
}
}
.start-button {
background: var(--color-woot-500, #1f93ff);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-600, #1976d2);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.msg {
display: flex;
&--user {
justify-content: flex-end;
}
&--construtor {
justify-content: flex-start;
}
}
.msg__bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: 14px;
background: var(--color-background-light, #f3f4f6);
font-size: 14px;
.msg--user & {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
}
.msg__content {
white-space: pre-wrap;
word-break: break-word;
}
.msg__meta {
font-size: 11px;
margin-top: 4px;
opacity: 0.7;
}
.msg__bubble--typing {
display: flex;
gap: 4px;
padding: 12px 16px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-light, #6b7280);
animation: typing 1.4s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
textarea {
flex: 1;
border: none;
resize: none;
outline: none;
font: inherit;
background: transparent;
color: inherit;
}
}
.session-debug {
font-size: 11px;
color: var(--color-text-light, #9ca3af);
text-align: right;
margin: 0;
}
</style>

View File

@ -0,0 +1,443 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const assistants = ref([]);
const selectedSlug = ref('');
const checks = ref([]);
const summary = ref(null);
const loading = ref(false);
const repairing = ref({});
const groupedChecks = computed(() => {
const groups = {};
checks.value.forEach(c => {
const cat = c.category || 'outros';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
return groups;
});
const categoryLabel = cat => {
const map = {
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
};
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
};
const fetchAssistants = async () => {
try {
const { data } = await hermesBuilderApi.fetchAssistants();
assistants.value = data.assistants || [];
if (assistants.value.length && !selectedSlug.value) {
selectedSlug.value = assistants.value[0].slug;
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
message: e.message || 'unknown',
})
);
}
};
const runValidation = async () => {
if (!selectedSlug.value || loading.value) return;
loading.value = true;
checks.value = [];
summary.value = null;
try {
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
checks.value = data.results || [];
summary.value = data;
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
loading.value = false;
}
};
const runRepair = async check => {
if (!check.repair_id) return;
repairing.value[check.repair_id] = true;
try {
const { data } = await hermesBuilderApi.repair(
selectedSlug.value,
check.repair_id
);
if (data.ok) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
message: data.message || 'OK',
})
);
await runValidation();
} else {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: data.error || 'unknown',
})
);
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
repairing.value[check.repair_id] = false;
}
};
const statusIcon = status => {
if (status === 'PASS') return '✓';
if (status === 'FAIL') return '✗';
if (status === 'WARN') return '⚠';
return '?';
};
const statusClass = status => {
if (status === 'PASS') return 'badge--pass';
if (status === 'FAIL') return 'badge--fail';
if (status === 'WARN') return 'badge--warn';
return 'badge--unknown';
};
onMounted(() => {
fetchAssistants();
});
</script>
<template>
<div class="verification-wrapper">
<header class="verification-header">
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
</header>
<div class="controls">
<select
v-model="selectedSlug"
class="select"
:disabled="!assistants.length || loading"
>
<option v-if="!assistants.length" value="">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
</option>
<option v-for="a in assistants" :key="a.id" :value="a.slug">
{{ a.name }} {{ a.slug }}
</option>
</select>
<Button
variant="primary"
:disabled="!selectedSlug || loading"
@click="runValidation"
>
{{
loading
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
}}
</Button>
</div>
<div v-if="summary" class="summary">
<span class="summary__item summary__item--pass">
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
</span>
<span v-if="summary.fail" class="summary__item summary__item--fail">
{{ summary.fail }}
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
</span>
<span v-if="summary.warn" class="summary__item summary__item--warn">
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
</span>
<span class="summary__total">
{{
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
}}
</span>
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
</span>
<span v-else class="summary__verdict summary__verdict--fail">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
</span>
</div>
<section v-if="checks.length" class="checks-section">
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
<h3 class="check-group__title">
{{ categoryLabel(cat) }}
</h3>
<ul class="check-list">
<li
v-for="(check, idx) in items"
:key="idx"
class="check-item"
:class="`check-item--${check.status.toLowerCase()}`"
>
<span class="check-item__badge" :class="statusClass(check.status)">
{{ statusIcon(check.status) }}
</span>
<div class="check-item__body">
<div class="check-item__label">{{ check.label }}</div>
<div v-if="check.detail" class="check-item__detail">
{{ check.detail }}
</div>
</div>
<button
v-if="
check.repair_id &&
(check.status === 'FAIL' || check.status === 'WARN')
"
type="button"
class="repair-btn"
:disabled="repairing[check.repair_id]"
@click="runRepair(check)"
>
{{
repairing[check.repair_id]
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
}}
</button>
</li>
</ul>
</div>
</section>
<p v-else-if="!loading && summary" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
</p>
<p v-else-if="!loading" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
</p>
</div>
</template>
<style scoped lang="scss">
.verification-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1000px;
margin: 0 auto;
height: calc(100vh - 260px);
overflow-y: auto;
padding-right: 8px;
}
.verification-header {
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
line-height: 1.5;
}
}
.controls {
display: flex;
gap: 12px;
align-items: center;
.select {
flex: 1;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-background, #fff);
font-size: 14px;
outline: none;
&:focus {
border-color: var(--color-woot-500, #1f93ff);
}
}
}
.summary {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 16px;
background: var(--color-background, #fff);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
font-size: 13px;
flex-wrap: wrap;
&__item {
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
&--warn {
color: #d97706;
}
}
&__total {
color: var(--color-text-light, #6b7280);
}
&__verdict {
margin-left: auto;
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
}
}
.checks-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.check-group {
background: var(--color-background, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 12px 16px;
&__title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-light, #6b7280);
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
font-size: 13px;
&--fail {
background: #fef2f2;
}
&--warn {
background: #fffbeb;
}
}
.check-item__badge {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: #fff;
&.badge--pass {
background: #16a34a;
}
&.badge--fail {
background: #dc2626;
}
&.badge--warn {
background: #d97706;
}
}
.check-item__body {
flex: 1;
min-width: 0;
}
.check-item__label {
font-weight: 500;
}
.check-item__detail {
margin-top: 2px;
color: var(--color-text-light, #6b7280);
font-size: 12px;
word-break: break-word;
}
.repair-btn {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--color-woot-500, #1f93ff);
background: var(--color-background, #fff);
color: var(--color-woot-500, #1f93ff);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.empty-state {
text-align: center;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
padding: 32px;
margin: 0;
}
</style>

View File

@ -0,0 +1,51 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import BuilderChat from './BuilderChat.vue';
import BuilderVerification from './BuilderVerification.vue';
const { t } = useI18n();
const tabs = computed(() => [
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
]);
const activeIndex = ref(0);
const handleTabChanged = tab => {
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
</script>
<template>
<PageLayout
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
>
<div class="builder-tabs">
<TabBar
:tabs="tabs"
:initial-active-tab="activeIndex"
@tab-changed="handleTabChanged"
/>
</div>
<div class="builder-panels">
<BuilderChat v-show="activeIndex === 0" />
<BuilderVerification v-show="activeIndex === 1" />
</div>
</PageLayout>
</template>
<style scoped lang="scss">
.builder-tabs {
margin-bottom: 16px;
}
.builder-panels {
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,5 +1,6 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@ -17,6 +18,13 @@ import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue';
import RoletaIndex from './roleta/Index.vue';
import HermesBuilderIndex from './builder/Index.vue';
import FunnelIndex from './funnel/Index.vue';
import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue';
import LifecycleSettings from './lifecycle/Settings.vue';
import LifecycleHistory from './lifecycle/History.vue';
const meta = {
permissions: ['administrator', 'agent'],
@ -24,6 +32,11 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const knowledgeBaseMeta = {
...meta,
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@ -35,13 +48,13 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -71,7 +84,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta,
meta: knowledgeBaseMeta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -112,7 +125,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
name: 'captain_assistants_index',
meta,
meta: knowledgeBaseMeta,
},
];
@ -137,4 +150,55 @@ export const routes = [
name: 'captain_reservations_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/roleta'),
component: RoletaIndex,
name: 'captain_roleta_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
component: HermesBuilderIndex,
name: 'captain_hermes_builder_index',
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/funnel'),
component: FunnelIndex,
name: 'captain_funnel_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/lifecycle'),
component: LifecycleIndex,
meta,
redirect: { name: 'captain_lifecycle_rules' },
children: [
{
path: 'rules',
component: LifecycleRules,
name: 'captain_lifecycle_rules',
meta,
},
{
path: 'settings',
component: LifecycleSettings,
name: 'captain_lifecycle_settings',
meta,
},
{
path: 'history',
component: LifecycleHistory,
name: 'captain_lifecycle_history',
meta,
},
],
},
];

View File

@ -0,0 +1,223 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import funnelApi from 'dashboard/api/captain/funnel';
const { t } = useI18n();
const periodDays = ref(30);
const report = ref(null);
const loading = ref(false);
const SUITE_ORDER = ['Alexa', 'Stilo', 'Hidromassagem'];
async function load() {
loading.value = true;
try {
const { data } = await funnelApi.get(periodDays.value);
report.value = data;
} catch (err) {
useAlert(t('CAPTAIN_FUNNEL.LOAD_ERROR'));
} finally {
loading.value = false;
}
}
const byDataSuite = computed(() => {
if (!report.value?.by_suite) return [];
return SUITE_ORDER.filter(s => report.value.by_suite[s]).map(s => ({
name: s,
stages: report.value.by_suite[s],
}));
});
const topDropOff = computed(() => {
const d = report.value?.top_drop_off;
if (!d) return null;
return {
...d,
from_label: t(`CAPTAIN_FUNNEL.STAGES.${d.from}`),
to_label: t(`CAPTAIN_FUNNEL.STAGES.${d.to}`),
};
});
function fmtPct(v) {
if (v === null || v === undefined) return '—';
return `${(v * 100).toFixed(1)}%`;
}
function barWidth(count, maxCount) {
if (!maxCount) return '0%';
return `${Math.max(4, (count / maxCount) * 100)}%`;
}
function stageLabel(key) {
return t(`CAPTAIN_FUNNEL.STAGES.${key}`);
}
onMounted(load);
</script>
<template>
<PageLayout
:header-title="t('CAPTAIN_FUNNEL.HEADER')"
:show-assistant-switcher="false"
:show-pagination-footer="false"
:is-empty="false"
:is-fetching="false"
>
<template #body>
<div class="flex flex-col gap-6 py-4">
<div class="flex items-center justify-between gap-3">
<p class="text-sm text-n-slate-11 max-w-xl">
{{ t('CAPTAIN_FUNNEL.DESC') }}
</p>
<div class="flex items-center gap-2">
<select
v-model.number="periodDays"
class="rounded-md border border-n-weak bg-transparent text-sm px-2 py-1"
@change="load"
>
<option :value="7">{{ t('CAPTAIN_FUNNEL.PERIOD_7') }}</option>
<option :value="30">{{ t('CAPTAIN_FUNNEL.PERIOD_30') }}</option>
<option :value="60">{{ t('CAPTAIN_FUNNEL.PERIOD_60') }}</option>
<option :value="90">{{ t('CAPTAIN_FUNNEL.PERIOD_90') }}</option>
</select>
<Button
variant="ghost"
icon="i-lucide-refresh-cw"
size="xs"
:is-loading="loading"
@click="load"
/>
</div>
</div>
<div v-if="loading" class="text-sm text-n-slate-11 py-6">
{{ t('CAPTAIN_FUNNEL.LOADING') }}
</div>
<template v-else-if="report && report.total_conversations_analyzed > 0">
<div
v-if="topDropOff && topDropOff.lost > 0"
class="rounded-xl border border-n-amber-7 bg-n-amber-3 p-4"
>
<div class="text-xs uppercase tracking-wide text-n-amber-11 mb-1">
{{ t('CAPTAIN_FUNNEL.INSIGHT_LABEL') }}
</div>
<div class="text-n-amber-12">
{{
t('CAPTAIN_FUNNEL.INSIGHT_FULL', {
lost: topDropOff.lost,
from: topDropOff.from_label,
to: topDropOff.to_label,
pct: fmtPct(topDropOff.drop_pct),
})
}}
</div>
</div>
<div
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
>
<h2 class="text-lg font-semibold text-n-slate-12 mb-4">
{{
t('CAPTAIN_FUNNEL.FUNNEL_TITLE').replace(
'{count}',
report.total_conversations_analyzed
)
}}
</h2>
<div class="flex flex-col gap-3">
<div
v-for="(stage, idx) in report.funnel"
:key="stage.key"
class="flex items-center gap-3"
>
<div class="w-40 text-sm text-n-slate-11">
{{ stageLabel(stage.key) }}
</div>
<div class="flex-1 relative h-8 bg-n-alpha-black1 rounded">
<div
class="absolute left-0 top-0 h-full rounded bg-gradient-to-r from-n-brand/70 to-n-brand transition-all"
:style="{
width: barWidth(stage.count, report.funnel[0].count),
}"
/>
<div
class="relative h-full flex items-center px-3 text-sm font-semibold text-n-slate-12"
>
{{ stage.count }}
</div>
</div>
<div class="w-20 text-right text-xs text-n-slate-11">
<template v-if="idx > 0">
{{ fmtPct(stage.conversion) }}
</template>
</div>
</div>
</div>
</div>
<div
v-if="byDataSuite.length > 0"
class="rounded-xl border border-n-weak bg-n-alpha-black2 p-6 shadow-sm"
>
<h2 class="text-lg font-semibold text-n-slate-12 mb-4">
{{ t('CAPTAIN_FUNNEL.BY_SUITE_TITLE') }}
</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-xs text-n-slate-11 uppercase tracking-wide">
<tr class="border-b border-n-weak">
<th class="text-left py-2 px-2">
{{ t('CAPTAIN_FUNNEL.BY_SUITE_HEADER') }}
</th>
<th
v-for="stage in report.funnel"
:key="stage.key"
class="text-right py-2 px-2"
>
{{ stageLabel(stage.key) }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in byDataSuite"
:key="s.name"
class="border-b border-n-weak last:border-b-0"
>
<td class="py-2 px-2 font-medium text-n-slate-12">
{{ s.name }}
</td>
<td
v-for="stage in s.stages"
:key="stage.key"
class="py-2 px-2 text-right text-n-slate-11"
>
{{ stage.count }}
</td>
</tr>
</tbody>
</table>
</div>
<p class="text-xs text-n-slate-11 mt-3">
{{ t('CAPTAIN_FUNNEL.BY_SUITE_FOOTER') }}
</p>
</div>
</template>
<div
v-else-if="report"
class="text-sm text-n-slate-11 py-6 text-center"
>
{{ t('CAPTAIN_FUNNEL.EMPTY') }}
</div>
</div>
</template>
</PageLayout>
</template>

View File

@ -0,0 +1,135 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import DeliveryPreviewModal from './components/DeliveryPreviewModal.vue';
const store = useStore();
const { t } = useI18n();
const deliveries = useMapGetter('captainLifecycleDeliveries/getRecords');
const meta = useMapGetter('captainLifecycleDeliveries/getMeta');
const uiFlags = useMapGetter('captainLifecycleDeliveries/getUIFlags');
const status = ref('');
const page = ref(1);
const selectedDelivery = ref(null);
const STATUS_OPTIONS = [
{ value: '', key: 'ALL' },
{ value: 'scheduled', key: 'SCHEDULED' },
{ value: 'sent', key: 'SENT' },
{ value: 'skipped', key: 'SKIPPED' },
{ value: 'failed', key: 'FAILED' },
{ value: 'cancelled', key: 'CANCELLED' },
];
const fetchDeliveries = () => {
store.dispatch('captainLifecycleDeliveries/get', {
page: page.value,
...(status.value ? { status: status.value } : {}),
});
};
onMounted(fetchDeliveries);
watch([status, page], fetchDeliveries);
const isLoading = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.total_count || 0);
</script>
<template>
<div class="p-6">
<div class="flex items-center gap-4 mb-4">
<label class="flex items-center gap-2 text-sm">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.STATUS') }}:
<select v-model="status" class="border rounded px-2 py-1">
<option
v-for="opt in STATUS_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{
opt.value
? t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${opt.key}`)
: t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.ALL')
}}
</option>
</select>
</label>
<span class="text-sm text-n-slate-11">
{{ totalCount }} {{ t('CAPTAIN_LIFECYCLE.HISTORY.TOTAL') }}
</span>
</div>
<div v-if="isLoading" class="flex justify-center py-8">
<Spinner />
</div>
<div
v-else-if="deliveries.length === 0"
class="text-center py-8 text-n-slate-11"
>
{{ t('CAPTAIN_LIFECYCLE.HISTORY.EMPTY') }}
</div>
<table v-else class="w-full text-sm">
<thead class="text-left text-n-slate-11">
<tr>
<th class="py-2">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RULE') }}
</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.CUSTOMER') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RESERVATION') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.STATUS') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.FIRE_AT') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.REASON') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="d in deliveries"
:key="d.id"
class="border-t border-n-slate-4"
>
<td class="py-2">{{ d.lifecycle_rule_name || '—' }}</td>
<td>{{ d.reservation?.customer_name || '—' }}</td>
<td>
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RESERVATION_ID')
}}{{ d.captain_reservation_id }}
</td>
<td>
{{
t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${d.status.toUpperCase()}`)
}}
</td>
<td>{{ new Date(d.fire_at).toLocaleString('pt-BR') }}</td>
<td>{{ d.skip_reason || d.failure_reason || '' }}</td>
<td>
<Button size="sm" variant="ghost" @click="selectedDelivery = d">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PREVIEW') }}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-center gap-2 mt-4">
<Button :disabled="page <= 1" @click="page -= 1">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PAGINATION.PREV') }}
</Button>
<span class="text-sm self-center">{{ page }}</span>
<Button :disabled="deliveries.length < 25" @click="page += 1">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PAGINATION.NEXT') }}
</Button>
</div>
<DeliveryPreviewModal
:delivery="selectedDelivery"
@close="selectedDelivery = null"
/>
</div>
</template>

View File

@ -0,0 +1,52 @@
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const tabs = computed(() => [
{ name: 'captain_lifecycle_rules', label: t('CAPTAIN_LIFECYCLE.TABS.RULES') },
{
name: 'captain_lifecycle_settings',
label: t('CAPTAIN_LIFECYCLE.TABS.SETTINGS'),
},
{
name: 'captain_lifecycle_history',
label: t('CAPTAIN_LIFECYCLE.TABS.HISTORY'),
},
]);
const activeIndex = computed(() =>
Math.max(
0,
tabs.value.findIndex(tab => tab.name === route.name)
)
);
const handleTabChanged = tab => {
router.push({ name: tab.name, params: route.params });
};
</script>
<template>
<PageLayout
:header-title="t('CAPTAIN_LIFECYCLE.HEADER')"
:show-assistant-switcher="false"
:show-pagination-footer="false"
:show-know-more="false"
>
<div class="flex flex-col gap-4">
<TabBar
:tabs="tabs"
:initial-active-tab="activeIndex"
@tab-changed="handleTabChanged"
/>
<router-view />
</div>
</PageLayout>
</template>

View File

@ -0,0 +1,156 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import RuleWizardDialog from './components/RuleWizardDialog.vue';
import { RULE_TEMPLATES } from './constants';
const store = useStore();
const { t } = useI18n();
const rules = useMapGetter('captainLifecycleRules/getRecords');
const uiFlags = useMapGetter('captainLifecycleRules/getUIFlags');
const showWizard = ref(false);
const editing = ref(null);
onMounted(() => {
store.dispatch('captainLifecycleRules/get');
store.dispatch('captainUnits/get');
});
const openCreate = () => {
editing.value = null;
showWizard.value = true;
};
const openEdit = rule => {
editing.value = rule;
showWizard.value = true;
};
const openFromTemplate = tpl => {
editing.value = {
id: null,
name: tpl.name,
event: tpl.event,
offset_minutes: tpl.offset_minutes,
message_type: tpl.message_type,
message_body: tpl.message_body,
enabled: true,
filters: {},
priority: 50,
};
showWizard.value = true;
};
const onSaved = () => {
showWizard.value = false;
store.dispatch('captainLifecycleRules/get');
};
const toggle = async rule => {
await store.dispatch('captainLifecycleRules/update', {
id: rule.id,
enabled: !rule.enabled,
});
};
const remove = async rule => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN_LIFECYCLE.RULES.DELETE_CONFIRM'))) return;
await store.dispatch('captainLifecycleRules/delete', rule.id);
useAlert(t('CAPTAIN_LIFECYCLE.RULES.TOAST.DELETED'));
};
const isLoading = computed(() => uiFlags.value.fetchingList);
const formatOffset = offsetMinutes =>
`${offsetMinutes >= 0 ? '+' : ''}${offsetMinutes}${t('CAPTAIN_LIFECYCLE.RULES.WIZARD.OFFSET_UNIT_LABEL')}`;
</script>
<template>
<div class="p-6 space-y-6">
<section>
<h3 class="text-sm font-semibold mb-3 text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.RULES.TEMPLATES_TITLE') }}
</h3>
<div class="grid grid-cols-3 gap-3">
<button
v-for="tpl in RULE_TEMPLATES"
:key="tpl.id"
type="button"
class="text-left p-3 border border-n-slate-4 rounded-lg hover:border-n-iris-9"
@click="openFromTemplate(tpl)"
>
<div class="font-medium text-sm">{{ tpl.name }}</div>
<div class="text-xs text-n-slate-11 mt-1">
{{ tpl.event }} {{ formatOffset(tpl.offset_minutes) }}
</div>
</button>
</div>
</section>
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="text-base font-semibold">
{{ t('CAPTAIN_LIFECYCLE.TABS.RULES') }}
</h3>
<Button @click="openCreate">
{{ t('CAPTAIN_LIFECYCLE.RULES.CREATE') }}
</Button>
</div>
<div v-if="isLoading"><Spinner /></div>
<div
v-else-if="rules.length === 0"
class="text-center py-8 text-n-slate-11"
>
{{ t('CAPTAIN_LIFECYCLE.RULES.EMPTY') }}
</div>
<table v-else class="w-full text-sm">
<thead class="text-left text-n-slate-11">
<tr>
<th class="py-2">
{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.NAME') }}
</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.EVENT') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.OFFSET') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.STATUS') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.ACTIONS') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rules" :key="r.id" class="border-t border-n-slate-4">
<td class="py-2">{{ r.name }}</td>
<td>{{ r.event }}</td>
<td>{{ formatOffset(r.offset_minutes) }}</td>
<td>
{{
r.enabled
? t('CAPTAIN_LIFECYCLE.RULES.STATUS.ENABLED')
: t('CAPTAIN_LIFECYCLE.RULES.STATUS.DISABLED')
}}
</td>
<td class="flex gap-2">
<Button size="sm" variant="ghost" @click="openEdit(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.EDIT') }}
</Button>
<Button size="sm" variant="ghost" @click="toggle(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.TOGGLE') }}
</Button>
<Button size="sm" variant="ghost" @click="remove(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.DELETE') }}
</Button>
</td>
</tr>
</tbody>
</table>
</section>
<RuleWizardDialog
v-if="showWizard"
:rule="editing"
@close="showWizard = false"
@saved="onSaved"
/>
</div>
</template>

View File

@ -0,0 +1,143 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConciergeUnitCard from './components/ConciergeUnitCard.vue';
const store = useStore();
const { t } = useI18n();
const config = useMapGetter('captainLifecycleConfig/getConfig');
const uiFlags = useMapGetter('captainLifecycleConfig/getUIFlags');
const units = useMapGetter('captainUnits/getUnits');
const labels = useMapGetter('labels/getLabels');
const form = ref({});
const syncForm = () => {
form.value = { ...config.value };
};
watch(config, syncForm, { immediate: true });
onMounted(() => {
store.dispatch('captainLifecycleConfig/fetch');
store.dispatch('captainUnits/get');
store.dispatch('labels/get');
});
const save = async () => {
try {
await store.dispatch('captainLifecycleConfig/update', {
quiet_hours_enabled: form.value.quiet_hours_enabled,
quiet_hours_from: form.value.quiet_hours_from,
quiet_hours_to: form.value.quiet_hours_to,
min_interval_minutes: Number(form.value.min_interval_minutes),
pause_on_customer_reply: form.value.pause_on_customer_reply,
pause_on_customer_reply_within_minutes: Number(
form.value.pause_on_customer_reply_within_minutes
),
opt_out_label_id: form.value.opt_out_label_id || null,
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.SAVED'));
} catch (e) {
useAlert(e.message || 'Error');
}
};
</script>
<template>
<div class="p-6 space-y-8">
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.GUARDS_TITLE') }}
</h3>
<div v-if="uiFlags.fetching">
<Spinner />
</div>
<div v-else class="space-y-3 max-w-xl">
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="form.quiet_hours_enabled" />
<span class="text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_ENABLED') }}
</span>
</label>
<div v-if="form.quiet_hours_enabled" class="flex gap-3">
<label class="flex-1 text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_FROM') }}
<input
v-model="form.quiet_hours_from"
type="time"
class="w-full border rounded px-2 py-1"
/>
</label>
<label class="flex-1 text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_TO') }}
<input
v-model="form.quiet_hours_to"
type="time"
class="w-full border rounded px-2 py-1"
/>
</label>
</div>
<Input
v-model="form.min_interval_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL')"
:message="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL_HELP')"
/>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="form.pause_on_customer_reply" />
<span class="text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY') }}
</span>
</label>
<Input
v-if="form.pause_on_customer_reply"
v-model="form.pause_on_customer_reply_within_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY_WINDOW')"
/>
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.OPT_OUT_LABEL') }}
<select
v-model="form.opt_out_label_id"
class="w-full border rounded px-2 py-1"
>
<option :value="null"></option>
<option v-for="label in labels" :key="label.id" :value="label.id">
{{ label.title }}
</option>
</select>
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.MAX_PER_RESERVATION_INFO') }}
</p>
<Button :disabled="uiFlags.updating" @click="save">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
</Button>
</div>
</section>
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_TITLE') }}
</h3>
<div class="space-y-3">
<ConciergeUnitCard v-for="u in units" :key="u.id" :unit="u" />
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,144 @@
<script setup>
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CaptainUnitsAPI from 'dashboard/api/captain/units';
const props = defineProps({
unit: { type: Object, required: true },
});
const { t } = useI18n();
const inboxes = useMapGetter('inboxes/getWhatsAppInboxes');
const expanded = ref(false);
const conciergeInboxId = ref(props.unit.concierge_inbox_id || null);
const personaName = ref(props.unit.concierge_config?.persona_name || 'Sofia');
const knowledge = ref(props.unit.concierge_config?.knowledge || '');
const variables = ref(
Object.entries(props.unit.concierge_config?.variables || {}).map(
([k, v]) => ({
k,
v,
})
)
);
const addVariable = () => variables.value.push({ k: '', v: '' });
const removeVariable = i => variables.value.splice(i, 1);
const save = async () => {
try {
const varsObj = Object.fromEntries(
variables.value.filter(x => x.k).map(x => [x.k, x.v])
);
await CaptainUnitsAPI.updateConcierge(props.unit.id, {
concierge_inbox_id: conciergeInboxId.value,
concierge_config: {
persona_name: personaName.value,
knowledge: knowledge.value,
variables: varsObj,
},
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.CONCIERGE_SAVED'));
} catch (e) {
useAlert(e.message || 'Error saving concierge');
}
};
</script>
<template>
<div class="border border-n-slate-4 rounded-lg p-4">
<div
class="flex justify-between items-center cursor-pointer"
@click="expanded = !expanded"
>
<div>
<div class="font-medium">{{ unit.name }}</div>
<div class="text-xs text-n-slate-11">
{{
unit.concierge_inbox_id
? t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_CONFIGURED')
: t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_NOT_CONFIGURED')
}}
</div>
</div>
<span>{{ expanded ? '▾' : '▸' }}</span>
</div>
<div v-if="expanded" class="mt-4 space-y-3">
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_INBOX') }}
<select
v-model="conciergeInboxId"
class="w-full border rounded px-2 py-1"
>
<option :value="null"></option>
<option v-for="ib in inboxes" :key="ib.id" :value="ib.id">
{{ ib.name }}
</option>
</select>
</label>
<Input
v-model="personaName"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_PERSONA')"
/>
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_KNOWLEDGE') }}
<textarea
v-model="knowledge"
rows="8"
class="w-full border rounded p-2 font-mono text-xs"
/>
</label>
<div>
<div class="text-sm font-medium mb-2">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLES') }}
</div>
<div
v-for="(variable, i) in variables"
:key="i"
class="flex gap-2 mb-2"
>
<input
v-model="variable.k"
:placeholder="
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_KEY')
"
class="border rounded px-2 py-1 w-1/3"
/>
<input
v-model="variable.v"
:placeholder="
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_VALUE')
"
class="border rounded px-2 py-1 flex-1"
/>
<Button
variant="ghost"
size="sm"
icon="i-lucide-x"
@click="removeVariable(i)"
/>
</div>
<Button
variant="outline"
size="sm"
icon="i-lucide-plus"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_ADD_VARIABLE')"
@click="addVariable"
/>
</div>
<Button @click="save">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
</Button>
</div>
</div>
</template>

View File

@ -0,0 +1,67 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
delivery: { type: Object, default: null },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
</script>
<template>
<Teleport to="body">
<div
v-if="delivery"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="emit('close')"
>
<div
class="bg-n-solid-1 rounded-xl p-6 w-[560px] max-h-[80vh] overflow-auto shadow-xl"
>
<h3 class="text-lg font-semibold mb-4">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.TITLE') }}
</h3>
<div class="space-y-3 text-sm">
<div>
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RULE') }}:</strong>
{{ delivery.lifecycle_rule_name || '—' }}
</div>
<div>
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.STATUS') }}:</strong>
{{ delivery.status }}
</div>
<div v-if="delivery.skip_reason">
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.REASON') }}:</strong>
{{ delivery.skip_reason }}
</div>
<div v-if="delivery.failure_reason">
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.ERROR') }}:</strong>
{{ delivery.failure_reason }}
</div>
<div>
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.FIRE_AT') }}:</strong>
{{ delivery.fire_at }}
</div>
<div v-if="delivery.sent_at">
<strong>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.SENT_AT') }}:</strong>
{{ delivery.sent_at }}
</div>
<div>
<strong
>{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.RENDERED') }}:</strong
>
<pre class="mt-1 p-3 bg-n-alpha-2 rounded whitespace-pre-wrap">{{
delivery.rendered_body || '—'
}}</pre>
</div>
</div>
<div class="flex justify-end mt-6">
<Button variant="outline" @click="emit('close')">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.CLOSE') }}
</Button>
</div>
</div>
</div>
</Teleport>
</template>

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