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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>