Commit Graph

500 Commits

Author SHA1 Message Date
Rodribm10
cf7338a6e2 chore: sync fazer-ai chatwoot 4.13.0 2026-05-06 06:24:46 -03: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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Gabriel Jablonski
72c9821270
feat(whatsapp): add emoji reactions UI (#276)
* feat(whatsapp): add emoji reactions UI

Adds end-to-end agent UI for emoji reactions on WhatsApp inboxes
(Cloud API, Baileys, Z-API). Reactions arrive as Messages with
is_reaction=true; this PR exposes them in the bubble UI and lets
agents react with toggle/replace/remove semantics.

- Add POST /reactions endpoint with toggle/replace logic that handles
  multi-device echoes from the same connected number
- Add Channel::Whatsapp#supports_reactions? capability
- Add Message.hide_removed_reactions scope and use it in conversation
  card preview / last_non_activity_message
- Enrich last_non_activity_message with in_reply_to_snippet for
  reaction previews in chat list
- Frontend: hover EmojiReactionPicker (8 quick + full picker) with
  alignment-aware positioning, single ReactionDisplay chip aggregating
  emojis with total count, conversation card preview shows "Você
  reagiu" for own/multi-device echoes

* fix: address CodeRabbit review feedback

- MessagePreview: render "Você" for outgoing reaction echoes that have no
  sender (multi-device echoes from the connected number)
- MessagesView#findCurrentUserReaction: prefer active reactions over
  deleted rows so a stale deleted echo cannot hijack the toggle target
- conversationHelper: drop removed reactions up-front so the activity
  fallback never returns null when older non-removed messages exist
- imap_import rake: wrap IMAP work in begin/ensure so the session is
  closed even when uid_search/scan_new_email_uids raises
- ReactionDisplay: include reaction.id in the user row so v-for keys
  stay stable across re-renders

* fix: address CodeRabbit round 2 feedback

- enterprise Message override of mark_pending_conversation_as_open_for_human_response
  now early-returns on reaction? so reactions can no longer auto-open Captain-pending
  conversations (matches the OSS guard)
- whatsapp incoming reaction-removal handlers (Cloud/Baileys/Z-API) look up the
  reaction Message globally by sender instead of through the inbound conversation
  scope, then operate on existing.conversation; otherwise an old/resolved thread
  could be silently no-op'd while the inbound flow created a stray empty thread
- EmojiReactionPicker: localize quick-emoji tooltip labels via i18n keys
- Message.vue: track pendingTimeouts and clear them on unmount so the cooldown
  setTimeout no longer touches state after teardown
- toggleMessageReaction action returns the API promise so callers can reconcile
  if the cable echo is delayed

* fix: address CodeRabbit round 3 feedback

- MessageFinder#page_window: pluck the 20-row window IDs before taking .min
  so the latest page honors PAGE_LIMIT (ActiveRecord's .minimum(:id) ignores
  .limit and aggregates over the whole relation)
- ReactionsController#current_user_reaction: rank active reactions ahead of
  deleted rows (same invariant as the frontend lookup) so a stale deleted
  echo can no longer hijack the toggle target and resurrect itself
- Whatsapp incoming handlers (Cloud, Baileys individual & group, Z-API) now
  branch on reaction_removal? BEFORE set_conversation / find_or_create_group_
  conversation, so a blank reaction-removal webhook can never open or create
  a stray thread just to no-op
- Message#reaction?: strict-boolean cast (via ActiveModel::Type::Boolean)
  so a stored string "false" no longer leaks through .present? as truthy

* fix: address CodeRabbit round 4 feedback

- MessageList: anchor unread divider on the filtered visibleMessages
  (firstUnreadId can land on a reaction that's filtered out, otherwise
  the separator silently disappears)
- ReactionDisplay: render the removable user row as a real <button> when
  it's the current user's reaction so keyboard users can focus/activate it
- MessagesView#findCurrentUserReaction: read sender_type from m.sender_type
  OR m.sender?.type so REST-loaded messages match the same row instead of
  spawning a duplicate optimistic reaction
- Whatsapp incoming reaction-removal lookup (Cloud, Baileys, Z-API): pick
  the newest active row first and only fall back to the newest deleted row
  when no active reaction exists, mirroring the controller invariant
- CardMessagePreview: use MESSAGE_TYPE.OUTGOING constant in place of the
  literal 1 for the multi-device reaction echo check

* fix: address CodeRabbit round 5 feedback

- ReactionsController#ensure_target_is_reactable: reject activity,
  template, failed, is_unsupported and missing-source_id targets so the
  API mirrors the client toolbar gate and refuses reactions that could
  never land on WhatsApp
- MessageList reaction aggregator: treat "agent reacted via Chatwoot"
  and "agent reacted via the connected phone" as the same self bucket
  so the chip no longer double-counts the current user when both shapes
  coexist for one target
- internalChat ReactionDisplay: render the removable user row as a real
  <button> so keyboard users can focus and trigger removal (mirrors the
  fix already applied to components-next/message/ReactionDisplay)
- EventDataPresenter#push_last_non_activity_message: reorder
  created_at: :desc before .first so the cable snapshot publishes the
  latest preview instead of the oldest row
- Z-API mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming_message?` and route the lookup by direction
  (contact sender for incoming removals, senderless outgoing row for
  multi-device removals from the connected phone). Chatwoot-originated
  echoes stay idempotent because the active-first guard finds nothing
  once the controller has flipped deleted=true locally
- spec: assert reaction removal does not change messages.count on the
  in-place Cloud path

* fix: address CodeRabbit round 6 feedback

- ReactionsController: validate the emoji payload is a single grapheme
  cluster containing a Unicode Emoji codepoint (not just <=32 bytes), so
  arbitrary short strings like "ok" or "123" can no longer be persisted
  as a reaction or enqueued as a WhatsApp reaction send
- target_unreactable_error: add the content_attributes['deleted'] guard
  to mirror the frontend picker gate on deleted messages
- IncomingMessageBaseService: move contact_processable? AFTER the
  reaction_removal? early-return so a blocked contact's removal webhook
  can still reconcile an existing reaction row instead of leaving a
  stale chip/preview
- imap_import rake: add safe_close_imap(imap) that falls back to
  disconnect when logout raises Net::IMAP::Error, mirroring
  terminate_imap_connection in BaseFetchEmailService, and replace the
  three ensure-block imap&.logout sites with it

* fix: address CodeRabbit round 7 feedback

- CardMessagePreview: resolve `lastNonActivityMessage` against the live
  `messages` array by id before rendering, so the chat-card preview
  picks up the freshest copy instead of the (possibly stale) snapshot
  that was mutated in place by a reaction toggle / multi-device echo
- Message + ReactionDisplay: thread an `inboxSupportsReactions` →
  `read-only` prop into the chip so non-supported channels (eg.
  360Dialog) render historical reactions without a clickable
  toggle/remove path that would only hit a 422
- conversations/index.js: replace the truthiness `&&` guard around the
  out-of-order MESSAGE_UPDATED check with `Number.isFinite` parsing so
  a malformed/missing `updated_at` is treated as stale instead of
  silently overwriting a fresher local row
- Baileys mark_existing_reaction_as_removed: drop the blanket
  `return unless incoming?` and split the lookup by direction
  (sender for incoming, sender-less outgoing for multi-device removals)
  to mirror the Z-API/Cloud handlers
- Whatsapp reaction-removal lookup (Cloud, Baileys, Z-API): drop the
  fallback to the newest deleted row so a Chatwoot-originated removal
  echo no-ops cleanly instead of bumping `updated_at` and dispatching
  another `conversation.updated`
- conversation jbuilder: explicit `reorder(created_at: :desc)` on
  `last_non_activity_message` so REST and cable both serialize the
  same most-recent preview

* fix: address CodeRabbit round 8 feedback

- ReactionsController#current_user_reaction: also match on
  content_attributes.in_reply_to_external_id = @target_message.source_id
  (via OR with the existing in_reply_to check), so WhatsApp-echoed
  reactions persisted by the incoming handlers — where in_reply_to could
  be blanked if the target wasn't resolvable at save time — are found and
  toggled instead of stacking a duplicate self-reaction
- Mirror the same defensive OR check in the frontend
  MessagesView#findCurrentUserReaction, and thread the target's
  source_id through the toggleReaction event from Message.vue so the
  lookup sees it

* fix: address CodeRabbit round 9 feedback

- emoji_payload_valid?: tighten the final property check from \p{Emoji}
  to \p{Extended_Pictographic} so plain "1", "#", "*" (which Unicode
  tags as Emoji because they're valid keycap bases) are rejected as
  reaction payloads
- EmojiReactionPicker: mirror the translated `title` into `aria-label`
  on the icon-only smile-plus / plus buttons so screen readers announce
  a meaningful action name
- internalChat ReactionDisplay: close the popover when the post-removal
  state would leave ≤1 reactions, so a singleton-user popover never
  lingers after removing one of a pair
- EventDataPresenter + conversation jbuilder: strip HTML before
  truncating `in_reply_to_snippet` so reactions to email/HTML bubbles
  don't surface literal "<p>..." markup in the chat-list preview

* fix: address CodeRabbit round 10 feedback

- MessageList#reactionsByMessageId: break createdAt ties with `<=` so a
  later iteration wins on second-resolution tie; two toggles in the same
  second no longer leave the chip pointing at a stale row
- MessagePreview: require a non-empty `message.attachments` array (via
  `?.length`) before taking the attachment preview branch, so a removed
  reaction with `[]` no longer renders the attachment placeholder
- MessagesView#findCurrentUserReaction: replace the sort-based pick with
  a reduce that deterministically takes the last element on tie, so a
  fast toggle can't hit a stale/deleted row with the same created_at
- Baileys group handler: guard against `@sender_contact.blank?` before
  dispatching mark_existing_reaction_as_removed, otherwise a nil sender
  would fall into the senderless-outgoing branch and match the wrong row
- WhatsApp reaction-removal lookups (Cloud, Baileys, Z-API): scope the
  base query to `inbox_id: inbox.id` so a colliding WhatsApp message id
  across inboxes can never mutate a reaction row from another inbox

* fix: address CodeRabbit round 11 feedback

- ReactionsController#emoji_payload_valid?: broaden the final property
  check to accept flag and keycap emoji. `\p{Extended_Pictographic}` by
  itself is per-codepoint, so 🇧🇷 (two Regional Indicators) and 1️⃣
  (digit + VS16 + U+20E3) failed validation. Allow any grapheme cluster
  that contains at least one pictographic codepoint, a Regional
  Indicator, or the combining keycap, while still rejecting plain
  ASCII like "ok", "1", "#"
- Message.vue#canShowReactionToolbar: hide the picker when the target
  has no provider source_id, mirroring the server guard in
  ReactionsController#target_unreactable_error instead of letting the
  click fall through to a 422
- MessageList#reactionsByMessageId: fall back to a sourceId → id
  lookup when a reaction only carries `inReplyToExternalId` (WhatsApp
  echo / phone-originated), so its chip still renders against the
  target bubble after reload
- getLastMessage: merge the fresher store fields onto the API
  snapshot instead of replacing it, so jbuilder-only enrichments like
  `in_reply_to_snippet` survive the store refresh

* fix(reactions): preserve API fields on card preview and expose a11y state on quick picker

* fix(reactions): consistent originalId resolution, natural PT-BR snippet phrasing, accurate outgoing-echo spec

* fix(reactions): reject requests missing emoji param and align zapi outgoing-echo spec fixture

* fix(reactions): activity preview fallback, camelCase event listener, EN snippet quoting, fromMe group removals, REST chat-only preview

* fix(reactions): reject non-string emoji, scope page reactions to window, exempt reactions from human_response, add cloud multi-device removal

* test(message): isolate hide_removed_reactions deleted-branch from blank-content branch

* fix(reactions): coerce in_reply_to_snippet to plain String

strip_tags returns an ActiveSupport::SafeBuffer; truncate preserves the
class. When this snippet flowed into ActionCableBroadcastJob via the
CONVERSATION_UPDATED dispatch, Sidekiq's strict-args check rejected the
non-native JSON type, raising synchronously through the dispatcher and
turning the reactions controller response into a 500 even though the row
had already persisted. The UI then surfaced the generic 'failed to update
reaction' toast despite the chip rendering correctly.

Wrap with String.new so the broadcast payload contains plain Strings.

* fix(reactions): don't auto-scroll to bottom on reaction add

ADD_MESSAGE emits SCROLL_TO_MESSAGE for every new push, which makes
sense for regular outgoing messages (the user just hit send and wants
to see it). Reactions render as chips on the parent bubble, so the
auto-scroll yanked the agent away from whichever older message they
were reacting to. Skip the emit when the incoming message is flagged
as a reaction.

* fix(reactions): skip scroll on conversation-only updates triggered by reactions

The reactions controller dispatches CONVERSATION_UPDATED so the chat list
preview can refresh in place. UPDATE_CONVERSATION mutation always emitted
SCROLL_TO_MESSAGE for the open conversation, so every toggle yanked the
viewport back to the bottom even after the previous fix in ADD_MESSAGE.
When the refreshed preview row is itself a reaction the update is
preview-only and the scroll is unwanted; for a regular incoming message
the latest non-activity row is the message itself, which still triggers
the scroll as before.

* fix(reactions): anchor compact picker to button side instead of centering

The compact picker was centered on the smile button, so half its width
always extended toward the bubble side and overflowed past the chat edge
on short messages. Anchor it to the button's outer side and nudge 4px
toward the bubble so it lines up with the trigger.

* test(reactions): regression coverage for safebuffer + scroll skip

The previous CodeRabbit rounds shipped three bugs the existing specs
didn't catch: a SafeBuffer return from `strip_tags` that 500'd the
reactions controller via Sidekiq strict-args, and two SCROLL_TO_MESSAGE
emits (one per mutation) that yanked the open conversation to the
bottom on every emoji toggle. Lock all three behaviors.

Also tighten the spec policy in AGENTS.md so new features default to
having specs instead of skipping them.

* test(baileys): align send_message_body helper with id:updated_at format

The reactions PR switched chatwootMessageId to "<id>:<updated_at>" so
toggle/replace cycles get a fresh idempotency key against baileys-api,
but the shared spec helper still merged the bare integer id. 18 baileys
provider specs were silently broken on CI as a result.

* fix(reactions): skip set_contact for unknown reaction-removal webhooks

Reaction-removal cloud webhooks were unconditionally creating a contact
even when the sender was unknown and there was nothing to remove,
because set_contact ran before the reaction_removal? short-circuit
(needed earlier so blocked-contact reconciliation works). Add a
sender-agnostic existence check on the inbox/in_reply_to scope and bail
out before set_contact when no candidate row exists.

Also realign two specs that were not updated when the chatwootMessageId
format gained an `:updated_at` suffix and when zapi reaction-removal
short-circuited instead of creating a Message.

* test(conversation): include last_non_activity_message in push_data fixture

Reactions PR added last_non_activity_message to the push_data payload
but conversation_spec's exact-match expectation wasn't updated, so the
sharded CI shard that landed on this file flipped red.
2026-04-30 21:09:12 -03:00