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>
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>
* 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.
* fix(featurable): backport feature_flag_value helper from chatwoot-pro-main
Adds the two's-complement-aware helper that returns a signed bigint-safe
value for SQL queries against the feature_flags column. Mirrors the
existing helper in chatwoot-pro-main so future backports of pro features
that reference it (e.g. kanban filters) compile cleanly on main.
Note: the helper does NOT fix FlagShihTzu's write path; new account-level
toggles should use account.settings jsonb instead of feature_flags
(see AGENTS.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(super-admin): toggle to hide assignee tabs for basic agents
Adds two account-level settings, configurable from the super admin
dashboard, that hide the "Unassigned" and "All" tabs of the conversation
list for users with the basic agent role (admins and custom roles are
unaffected). Hiding "Unassigned" implicitly hides "All", since seeing
the full queue without the unassigned subset is incoherent. The
constraint is enforced both in the backend (before_validation forces
hide_agent_all_tab=true when hide_agent_unassigned_tab is on) and in
the super admin form (the "All" checkbox is disabled and auto-checked
when "Unassigned" is checked).
Storage uses account.settings (jsonb) instead of feature_flags to
sidestep the bigint bit-position overflow that happens once features.yml
crosses 64 entries, and to keep keys stable across the main and
chatwoot-pro-main forks where feature bit positions diverge. AGENTS.md
documents the rationale and the recipe to add future toggles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-list): guard activeAssigneeTabCount against missing tab
When the visibility settings hide the currently selected tab, the
fallback watch resets activeAssigneeTab to ME, but activeAssigneeTabCount
re-evaluates in the same reactive cycle and can read .count on undefined
before the watch flushes. Use optional chaining + nullish fallback so
the count safely returns 0 during the brief inconsistency.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Primeira onda do roadmap de indicadores executivos do Grupo Nova. Mede
ADOÇÃO DO CANAL DIGITAL, não a operação total — banner explícito alerta
que reservas fechadas manualmente na recepção ainda não estão capturadas
(Onda 1B vai adicionar marcação manual via botão na conversa).
Backend:
- V2::Reports::ConversionFunnelBuilder — leads (novo/retorno/total),
reservas (criadas != draft, pagas in active/completed/confirmed),
taxas de conversão. Filtro opcional por inbox.
- V2::Reports::InboxBenchmarkingBuilder — uma linha por inbox com
brand_name (via Captain::UnitInbox -> Unit -> Brand)
- Endpoints GET /reports/conversion_funnel e /reports/inbox_benchmarking
- RSpec do ConversionFunnelBuilder
Frontend:
- Rota top-level Reports → Painel Diretoria
- DirectoryDashboard.vue: banner de adoção + filtros + cards + funil + tabela
benchmarking agrupada por marca com variação vs média
- API client getConversionFunnel + getInboxBenchmarking
- i18n EN + PT
Memórias suporte: feedback_metricas_adocao_canal.md + project_painel_diretoria_roadmap.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bug do BotReports descoberto pelo Rodrigo:
- A regra de "conversation_bot_resolved" só desqualificava conversas com
outgoing.sender_type='User' (atendente respondendo pelo Chatwoot UI)
- Mas mensagem outgoing vinda do webhook WhatsApp com IsFromMe=true (atendente
respondeu direto pelo celular do hotel) é gravada com sender=nil
- Resultado: a Jasmine ganhava crédito mesmo quando humano respondia fora
do Chatwoot. Taxa de resolução pelo bot inflada.
Fix prospectivo:
- ReportingEventListener#create_bot_resolved_event agora desqualifica via
human_outgoing_messages? (sender_type='User' OU sender_type IS NULL)
- Captain::Assistant (a Jasmine) usa sender_type='Captain::Assistant' e segue
fora do filtro, como antes
- Spec novo cobrindo o caso WhatsApp echo
Retroativo:
- lib/tasks/rebuild_bot_resolved.rake — task idempotente que purga
reporting_events de conversation_bot_resolved gerados sob a regra antiga.
- DRY-RUN por padrão, APPLY=true pra deletar, ACCOUNT_ID pra restringir,
SNAPSHOT_PATH pra trilha de auditoria
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mede por inbox/período: leads novos (1ª conversa do contato em qualquer
inbox da rede), retorno (conversa anterior resolved há >24h) e outras
(conversa anterior open ou resolved <24h). Categorias somadas batem com
o conversations_count nativo do report — bucket "outras" garante o
fechamento.
- Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única
- Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary
- Tabs no InboxReportsShow (Visão Geral | Novas × Retorno)
- Componente InboxLeadsReport com 3 metric cards + barras empilhadas
- API client + Pinia (state/getters/actions/mutations)
- i18n en + pt_BR
- RSpec do builder cobrindo classificação e isolamento por inbox
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Aligns reaction handling with the Baileys/Zapi providers so that
reaction webhooks from the official WhatsApp Cloud API create a
message flagged as is_reaction and linked to the original wamid,
instead of being silently dropped.
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>
* fix(conversations): enforce NOT NULL + FK on contact_id
Conversations had contact_id nullable with no FK to contacts. Combined
with dependent: :destroy_async on Contact#conversations, deleting a
contact could leave conversations pointing to a missing contact,
breaking the conversations#index API with
"undefined method 'additional_attributes' for nil" from the contact
partial.
Changes:
- Migration cleans up existing orphans, sets contact_id NOT NULL and
adds a FK with ON DELETE CASCADE so the invariant is enforced at the
DB level (complements the existing Rails presence validation).
- ContactMergeAction uses update_all for conversations/messages/notes/
contact_inboxes so a failing callback cannot silently leave records
pointing to the mergee contact before it is destroyed.
- Drop the now-redundant orphan filter in Conversations::ResolutionJob
and its spec; the invariant is enforced at the schema level.
* fix: address review feedback
- Drop the ON DELETE CASCADE FK on conversations.contact_id. Several
conversation-owned tables (messages, mentions, conversation_participants,
reporting_events, csat_survey_responses, calls, applied_slas, sla_events,
and polymorphic notifications) still have plain conversation_id references
without FK cascades. The DB-level cascade would skip Conversation's
dependent: cleanup and replace the NULL-contact bug with orphan children,
and would also conflict with the existing non-cascade FKs on
scheduled_messages/recurring_scheduled_messages. Keep the invariant at the
Rails layer (NOT NULL + presence validation + dependent: :destroy_async).
- Clean up orphan conversations in the migration via Rails destroy so
dependent associations are propagated correctly, instead of a raw
DELETE FROM conversations that would orphan all child rows.
- Revert ContactMergeAction.merge_* methods back to per-record update! so
Conversation#after_update_commit still fires (notify_status_change /
CONVERSATION_CONTACT_CHANGED) for contact_id changes. The bang form
still removes the silent-failure risk of the original .update call.
- RetentionSummaryBadge in the "Previous conversations" sidebar:
tiered status (First contact / Active / Recurring / Sleeping /
At risk / Inactive) + counts of interactions, one-shots, Pix.
- Retention tab in Captain Reports: KpiCards, FlowCard, CohortMatrix
(12x13 heatmap with CSV export).
- Five new filters on the contacts list: recurring, last interaction,
days since, interactions count, reservations paid.
- Full pt_BR + en i18n under CAPTAIN_REPORTS.RETENTION.*
- Spec for InteractionCalculatorService covering gap behavior,
one-shot classification, internal-label exclusion, multi-conversation
grouping across the 30h window.
- Docs: docs/captain-retention-indicators.md with business rules,
column reference, endpoint shape, and backup SQL queries.
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>
Backend broadcast payloads for internal_chat.message.deleted and
internal_chat.reaction.deleted used channel_id as the key, but the
frontend ActionCable handlers (and all other internal-chat events)
expect internal_chat_channel_id. This caused deleted messages and
removed reactions to stay visible on screen until a manual refresh.
Rename the key on the backend so the payloads match the convention
shared with message.created/updated and reaction.created, and drop the
defensive fallback on the frontend reaction-deleted handler.
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>
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>
* feat(whatsapp): allow converting inbox between WhatsApp providers
Adds a Convert flow to switch a WhatsApp inbox between the four
supported providers (default/360dialog, whatsapp_cloud, baileys, zapi)
without losing conversations, agents, or history.
- Channel::Whatsapp#convert_provider! runs inside a transaction:
disconnects the old provider, clears provider_connection and
message_templates, assigns the new provider/config, and triggers
webhook setup plus template resync on the new service.
- New POST /api/v1/accounts/:id/inboxes/:id/convert_provider endpoint
guarded by InboxPolicy#convert_provider? (admin only).
- UI adds a Convert button on the inbox Settings page with a
type-to-confirm ConvertInboxModal that lists the effects before
redirecting to a dedicated route reusing the WhatsApp provider
wizard in convert mode (phone number locked, current provider
hidden from the picker).
* chore(whatsapp): polish convert UI colors and expand specs
- Settings: use slate for the Convert trigger and ruby for the modal
confirm to mirror the delete gate instead of the less conventional
amber variant.
- Drop the redundant "current provider is hidden from the list"
sentence from the convert wizard description.
- Add specs for the post-conversion webhook setup path (triggered and
skipped branches) and the sync_templates error-rescue behaviour.
* fix: address CodeRabbit review on convert-provider flow
- Whitelist provider_config keys in the convert endpoint via permit
rather than permit!, and default to an empty hash when omitted so
the request no longer crashes.
- Pre-validate the new provider config before disconnecting the old
session so a bad target config no longer terminates the existing
provider; also keep the disconnect bound to the old provider_url.
- Guard ConvertInboxModal's submit handler so pressing Enter cannot
bypass the type-to-confirm gate, and migrate it to <script setup>.
- Reject invalid ?provider= query values in convert mode so hidden
providers (Twilio, the current provider) cannot be reached via URL.
- Await the inbox fetch in InboxConvert before running the route guard
so directly opening the route for a non-WhatsApp inbox redirects.
- Remove the unreachable second CloudWhatsapp branch in Whatsapp.vue.
* fix: address second CodeRabbit round on convert-provider flow
- Unify provider picker validation so create mode also rejects
unknown ?provider= values, with a single helper that accepts
available providers plus the whatsapp_manual fallback.
- Simplify the pre-validation rollback in convert_provider!: the
errors snapshot/merge dance was redundant because assign_attributes
does not clear errors.
- Follow the repo convention of asserting on error.class.name so the
rollback spec stays stable under reloading/parallel environments.
- Strengthen the controller success spec with provider_connection and
message_templates cleanup invariants, and set Content-Type on the
templates stub so HTTParty parses the empty data array correctly.
* fix: address third CodeRabbit round on convert-provider flow
- Add 360Dialog entry to the Whatsapp provider catalog, keep it hidden
from the create picker (preserving the existing fork behavior) but
expose it in the convert picker where it is a valid target. Restore
URL reachability for ?provider=360dialog in create mode.
- Scope the WHATSAPP_MANUAL allowance to create mode only: the manual
fallback flow is not reachable in convert mode.
- Redirect to the inboxes list in InboxConvert when the inbox is still
absent after the store fetch, so the page no longer stays blank.
- Use an explicit allowlist of WhatsApp providers to gate the Convert
button instead of negating Twilio, so adding a new WhatsApp channel
type will not silently expose the flow.
- Bind the disabled provider display field with :value instead of
v-model, since the underlying computed is getter-only.
- Add Content-Type: application/json to the templates stub in the
model spec so HTTParty parses the empty data array.
* fix: address fourth CodeRabbit round on convert-provider flow
- Reject no-op conversions that target the same provider as the one
already configured, so the endpoint no longer wipes provider
connection and message templates on a request that changes nothing.
- Call the provider service's disconnect directly so failures abort
the conversion instead of being silently swallowed; otherwise the
old external session could remain live while the inbox flips to
the new provider.
- Cover both behaviors with specs.
* fix: address fifth CodeRabbit round on convert-provider flow
- Reset the Vuelidate state when closing ConvertInboxModal so reopening
the gate does not surface stale validation errors.
- Call teardown_webhooks before converting away from whatsapp_cloud so
the Meta webhook subscription is removed for embedded_signup channels,
mirroring the destroy-time cleanup (manual-setup channels keep the
existing no-op behavior). Swallow teardown failures so a flaky Meta
call does not abort the swap.
- Switch the rollback specs to compare message_templates counts instead
of the boolean be_present matcher so they remain meaningful if the
fixture happens to have an empty templates list.
* fix: address sixth CodeRabbit round on convert-provider flow
- Derive the convert header's current-provider label from the shared
PROVIDER_CATALOG so the picker and header stay in sync.
- Assert the full Cloud provider_config payload and the absence of the
Baileys-only provider_url key on both the controller success spec
and the model atomic-swap spec.
- In the sync-error spec, reload and assert that the record was
actually flipped to the new provider before the sync rescue fires,
so the test can't pass on a pre-save failure.
* test: pin 422 error payload on convert_provider negative paths
The unsupported-conversion and invalid-config specs only checked the
status code, so they would have stayed green if the 422 started coming
from a different branch. Pin the response body so each example actually
covers the failure case it names.
* fix(baileys): save custom host as provider_url, not url
The Baileys form was writing the custom endpoint to
provider_config['url'] while the backend reads
provider_config['provider_url']. That silently broke the custom-host
feature for newly created or converted Baileys inboxes: they always
fell back to BAILEYS_PROVIDER_DEFAULT_URL. Align the key on both ends.
* fix(whatsapp): skip second validation pass in convert_provider!
The transaction's save! was re-running validate_provider_config after
the old provider's session had already been disconnected, so a transient
Graph API failure on the second check could roll back the swap while
leaving the external session terminated — the exact inconsistency the
pre-flight valid? was meant to rule out.
Capture the validated provider_config snapshot after valid? (so fields
populated by before_validation callbacks like webhook_verify_token are
preserved) and switch the final persist to save!(validate: false) so the
earlier check stays authoritative.
* fix: normalize provider-conversion failures and pass accountId
- The convert_provider action only rescued ActiveRecord::RecordInvalid,
so disconnect/teardown failures bubbled up as 500 with no stable
payload. Catch StandardError, log the class + message, and return a
422 with a generic user-facing message so the dashboard can surface
the error consistently.
- Nested settings routes live under /accounts/:accountId, so the
router push from Settings.vue must include accountId alongside
inboxId. Mirrors how sibling pages navigate to settings_inbox_show.
* fix: report missing :provider as 400 and sync modal v-model
- The generic rescue StandardError on convert_provider was masking
ActionController::ParameterMissing behind a misleading
provider-conversion error message. Catch it explicitly before the
generic rescue and return 400 with the parameter-missing message.
- ConvertInboxModal's closeModal now drives localShow to false so
parents using v-model:show stay in sync on every close path,
not only when the explicit onClose listener flips the flag.
* fix(whatsapp): serialize concurrent convert_provider calls with_lock
Without a per-record lock, two admin requests against the same inbox
could both pass the pre-flight validation, race the disconnect/save,
and then run setup_webhooks/sync_templates in arbitrary order, leaving
the persisted provider out of sync with the external configuration.
Wrap the whole convert flow in with_lock so the loser blocks until the
winner commits; the subsequent no-op guard then rejects a second
conversion request targeting the provider the first one just set.
* test: harden convert_provider policy + controller failure specs
- Pass accountId explicitly in InboxConvert redirects so the route
navigation mirrors how Settings.vue reaches settings_inbox_convert.
- Add a spec that assigns the agent to the inbox and still expects 401,
so a future regression in InboxPolicy#convert_provider? can no longer
slip past on the show policy alone.
- Add a spec that stubs convert_provider! to raise StandardError and
asserts the controller's generic-failure 422 payload, pinning the
dashboard contract for provider-side failures.
* test: pin convert_provider success response payload
Parse the rendered body and assert provider + provider_config so the
spec catches regressions where the DB is updated correctly but the
serialized response drifts (dashboard store commits response.data).
* fix(whatsapp): reset teardown guard after pre-conversion webhook cleanup
teardown_webhooks memoizes @webhook_teardown_initiated = true to prevent
double execution during destroy. Calling it from convert_provider!
leaves that flag set, so a subsequent destroy! or follow-up conversion
on the same instance would skip webhook removal silently. Reset the
flag in an ensure block so the destroy-time guard stays scoped to
destroy only.
* fix: include accountId in post-conversion redirect params
* test: pin same-provider convert returns 422
* fix(whatsapp): reset template columns when post-conversion sync fails
* fix(convert): enforce provider allowlist in InboxConvert route guard
* test: broaden Cloud templates stub to match account-scoped path
* test(whatsapp): cover cloud to baileys conversion branch