24 KiB
Spec — Memória Semântica Episódica do Cliente (Captain)
Data: 2026-04-18
Branch: feat/captain-semantic-memory
Épico: A (memória). Épico B (evolução do orchestrator) será spec separado após este entrar em produção.
1. Contexto e motivação
Hoje o Captain (agente de WhatsApp da fazer.ai para o Grupo 1001 Noites) tem 3 camadas de memória funcionais:
| Camada | Onde | Natureza |
|---|---|---|
| Identity | contact.custom_attributes (cpf, total_reservas, ultima_suite…) |
Key-value, determinístico |
| Working memory | últimas N mensagens da conversa (bruto) | Literal, volátil |
| Knowledge | captain_assistant_responses.embedding (FAQ) |
Semântica (guardrail) |
Falta a Camada 3 — memória episódica do cliente: fatos acumulados ao longo do relacionamento (preferências, datas comemorativas, reclamações, padrões). Sem ela, clientes recorrentes são tratados como novos; experiência é genérica; o valor de "grupo que conhece o hóspede" não existe.
Objetivo: adicionar Camada 3 ao Captain, sem alterar as outras 3, com memória semântica real (embeddings + cosine + pgvector), fatos extraídos automaticamente ao fim de cada conversa, injetados no contexto do LLM via top-K retrieval.
Fora de escopo: refatorar as 3 camadas existentes; trocar gem agents por LangChain/LangGraph; outbound proativo (campanhas) — virarão features derivadas após este épico estável.
2. Problema concreto que resolvemos
- Cliente volta em 6 meses → agente começa do zero, pede dados de novo.
- Cliente mencionou preferência por Stilo com hidro 5x → agente nunca oferece proativamente.
- Cliente reclamou de ar-condicionado → na próxima reserva, agente não sabe evitar a mesma suíte.
- Cliente passa em Prime Ceilândia, depois Prime Águas Lindas → agente da 2ª unidade não sabe quem é.
- Grupo não consegue gerar relatórios de "top reclamações por unidade nos últimos 90 dias" porque esse dado não está estruturado.
3. Decisões tomadas no brainstorming
| # | Decisão | Valor |
|---|---|---|
| 1 | Quando extrair | Ao resolver conversa OU após silêncio > 30min |
| 2 | Validação | 100% automático com salvaguardas (evidence obrigatória, confidence ≥ 0.5). Alternativas B/C/D documentadas como plano de contingência |
| 3 | Scope | Global no recall (experiência unificada no grupo), atribuição por unidade no registro (source_unit_id pra relatórios) |
| 4 | Taxonomia | 9 tipos fixos iniciais (ver §5) |
| 5 | Limites | Até 5 fatos por conversa, até 50 ativos por contato (LRU ao atingir) |
| 6 | Envelhecimento | TTL por tipo, soft-delete após expirar, redução de peso no recall antes de deletar |
| 7 | Conflitos | Supersedência automática via checker de contradição semântica + lexical |
| 8 | LGPD | Soft-delete 30d → hard-delete via cron diário. Botão "esquecer tudo" no painel |
| 9 | Feature flags | extraction_enabled e recall_enabled independentes, por account, default OFF |
| 10 | Decomposição | Épico A (este) e Épico B (evolução orchestrator) serão sequenciais, sem esperar 30 dias entre um e outro |
4. Arquitetura — 4 camadas integradas em runtime
mensagem entra
│
▼
┌─────────────────┐ key-value, ~1ms, determinístico
│ 1. IDENTITY │ ← contact.custom_attributes (hoje, inalterado)
└─────────────────┘
│
▼
┌─────────────────┐ literal, ~5ms, volátil
│ 2. WORKING MEM │ ← últimas N mensagens brutas (hoje, inalterado)
└─────────────────┘
│
▼
┌─────────────────┐ semântica, ~100-200ms, top-5 ← NOVO
│ 3. EPISODIC │ ← captain_contact_memories por cosine
└─────────────────┘
│
▼
┌─────────────────┐ semântica (guardrail pós-geração)
│ 4. KNOWLEDGE │ ← captain_assistant_responses (hoje, inalterado)
└─────────────────┘
│
▼
LLM responde
Camadas 1 e 2 rodam antes do LLM; Camada 3 também (novo); Camada 4 roda depois como guardrail. Só a Camada 3 é adicionada — nenhuma outra é modificada.
5. Taxonomia dos fatos — 9 tipos iniciais
memory_type |
Exemplo | Scope default | TTL | Ao expirar |
|---|---|---|---|---|
preferencia |
"Prefere Stilo com hidromassagem" | global | 365d | Reduz peso no recall (×0.7), não deleta |
data_comemorativa |
"Aniversário casamento 14/02" | global | Nunca | — |
vinculo_social |
"Vem com esposa Mariana" | global | 730d | Deleta |
padrao_comportamental |
"Chega entre 21h-22h" | unit | 365d | Reduz peso |
reclamacao |
"Ar-condicionado da 204 barulhento em mar/26" | unit | 180d | Deleta |
feedback_positivo |
"Elogiou a Dona Cida do café" | unit | 365d | Deleta |
restricao |
"Alérgico a amendoim" | global | Nunca | — |
vinculo_comercial |
"Funcionário Caixa, desconto corp" | global | 365d | Reduz peso |
contexto_pessoal |
"Trabalha viajando, hospedagem rápida" | global | 365d | Reduz peso |
Tipos novos podem ser adicionados depois sem migration (campo string no schema).
6. Modelo de dados
6.1 Tabela captain_contact_memories
CREATE TABLE captain_contact_memories (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id),
contact_id BIGINT NOT NULL REFERENCES contacts(id),
memory_type VARCHAR NOT NULL,
content TEXT NOT NULL,
evidence TEXT NOT NULL,
confidence FLOAT NOT NULL,
scope VARCHAR NOT NULL DEFAULT 'global',
embedding vector(1536),
source_conversation_id BIGINT,
source_unit_id BIGINT,
source_inbox_id BIGINT,
expires_at TIMESTAMP,
last_verified_at TIMESTAMP NOT NULL,
superseded_at TIMESTAMP,
superseded_by_id BIGINT,
deleted_at TIMESTAMP,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Observação — FKs secundárias: source_conversation_id, source_unit_id, source_inbox_id e superseded_by_id são BIGINT simples sem FOREIGN KEY. Manter a referência mesmo após o registro de origem ser hard-deletado é essencial pra auditoria LGPD (§10) e pra relatórios históricos por unidade (§3 decisão 3). App-layer precisa tratar referências órfãs graciosamente.
6.2 Índices
CREATE INDEX idx_ccm_recall ON captain_contact_memories (account_id, contact_id)
WHERE deleted_at IS NULL AND superseded_at IS NULL;
CREATE INDEX idx_ccm_embedding ON captain_contact_memories
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX idx_ccm_analytics ON captain_contact_memories
(source_unit_id, memory_type, created_at);
CREATE INDEX idx_ccm_hard_delete ON captain_contact_memories (deleted_at)
WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_ccm_superseded ON captain_contact_memories (superseded_by_id)
WHERE superseded_at IS NOT NULL;
6.3 Validações no model
memory_type∈ enum dos 9 tipos.evidencepresence (garantia anti-hallucination — fato sem trecho literal = rejeitado no extractor).confidenceentre 0 e 1.contentnon-empty, max 1000 chars (fatos devem ser concisos).
7. Componentes (arquivos novos)
7.1 Model
enterprise/app/models/captain/contact_memory.rb
has_neighbors :embedding(gemneighbor)- Enum
memory_typecom os 9 valores - Scopes:
.active—WHERE deleted_at IS NULL AND superseded_at IS NULL AND (expires_at IS NULL OR expires_at > NOW()).for_contact(id),.by_type(t),.scope_compatible(unit_id)
- Instance methods:
soft_delete!— setadeleted_at = nowsupersede_by!(other)— setasuperseded_at,superseded_by_idrecall_weight— retorna 1.0 ou 0.7 (reduz peso se expired)
- Callbacks:
after_commit on: [:create, :update] if saved_change_to_content?→ enfileiraUpdateEmbeddingJobafter_create_commit→ enfileiraContradictionCheckerJob
7.2 Services
enterprise/app/services/captain/contact_memories/extraction_service.rb
initialize(conversation:)#call→ retorna array de hashes dos fatos válidos. NÃO persiste (separação).- Monta prompt incluindo: histórico completo da conversa, taxonomia dos 9 tipos, exemplos de evidence bem-feito, JSON schema esperado.
- LLM:
gpt-4o-mini, JSON mode, temperature 0. - Valida cada fato retornado:
typeválido,evidencenon-empty,confidence≥ 0.5,contentdentro do limite. - Descarta silenciosamente os inválidos. Retorna no máximo 5.
enterprise/app/services/captain/contact_memories/recall_service.rb
initialize(contact:, query_text:, unit_id: nil)#call→ retorna array ordenado de top-K memories (default K=5).- Gera embedding da query.
Captain::ContactMemory.active.for_contact(contact.id).scope_compatible(unit_id).nearest_neighbors(:embedding, embedding, distance: 'cosine').limit(K).- Aplica
recall_weightao cosine score final. - Timeout de 500ms via
Timeout::timeout. Em caso de erro ou timeout, retorna[].
enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb
initialize(memory:)#call→ marca fatos anteriores comosuperseded_byse detectar contradição.- Busca candidatos:
active.for_contact.by_type(memory.memory_type).where.not(id: memory.id).where('embedding <=> ? < 0.6', memory.embedding). - Para cada candidato (max 3), chama LLM barato (gpt-4o-mini, 1 pergunta binária): "Esses 2 fatos se contradizem? sim/não".
- Se "sim":
candidate.supersede_by!(memory).
enterprise/app/services/captain/contact_memories/prompt_injection_service.rb
initialize(memories:)#call→ retorna string XML formatada pra injetar no system prompt.- Formato:
<memoria_cliente> <preferencia confidence="0.92">Prefere Stilo com hidromassagem</preferencia> <data_comemorativa>Aniversário casamento 14/02</data_comemorativa> </memoria_cliente> - Se
memories.empty?→ retorna string vazia.
7.3 Jobs
enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb
- Argumentos:
conversation_id. - Passos:
- Checa feature flag
captain_contact_memory_extraction_enabledna account. Se OFF, early return. - Carrega conversation.
- Chama
ExtractionService. - Persiste os fatos válidos com
source_conversation_id,source_unit_id(vem deconversation.captain_unit_idou inferido via inbox),source_inbox_id. - Cada fato criado aciona
UpdateEmbeddingJobviaafter_commit.
- Checa feature flag
- Idempotência: se já existe
ContactMemorycom o mesmosource_conversation_id, skip (não re-extrai).
enterprise/app/jobs/captain/contact_memories/update_embedding_job.rb
- Argumento:
memory_id. - Gera embedding via
RubyLLM.embed(content, model: 'text-embedding-3-small'). - Salva
embedding. - Retry 3x, backoff exponencial. Se todas falharem, deixa
embedding = NULL(fato fica invisível no recall).
enterprise/app/jobs/captain/contact_memories/contradiction_checker_job.rb
- Argumento:
memory_id. - Chama
ContradictionCheckerService. - Roda APÓS
update_embedding_jobconcluir (encadeado).
enterprise/app/jobs/captain/contact_memories/silence_detector_job.rb
- Cron a cada 10 min (Sidekiq-Cron).
- Busca conversas ativas cuja última mensagem foi há mais de 30 minutos E que ainda não têm
ContactMemorycomsource_conversation_idigual. - Enfileira
ExtractFromConversationJobpra cada uma.
enterprise/app/jobs/captain/contact_memories/aging_job.rb
- Cron semanal (domingo madrugada).
- Itera tipos com TTL:
- Para tipos com "Deleta":
WHERE expires_at < NOW() AND deleted_at IS NULL→ soft-delete. - Para tipos com "Reduz peso": marca
expires_atno futuro (recall já aplica ×0.7 baseado emexpires_at < NOW()).
- Para tipos com "Deleta":
- Aplica limite macio de 50 fatos ativos por contato: LRU por
last_verified_at→ soft-delete excedente.
enterprise/app/jobs/captain/contact_memories/hard_delete_expired_job.rb
- Cron diário.
WHERE deleted_at < 30.days.ago→DELETE.- Loga count por account.
7.4 Integração no AgentRunnerService (arquivo existente — alteração)
enterprise/app/services/captain/assistant/agent_runner_service.rb
Alteração mínima, antes do Agents::Runner.with_agents(...).run(message, ...):
if account.captain_contact_memory_recall_enabled?
memories = Captain::ContactMemories::RecallService.new(
contact: @conversation.contact,
query_text: message,
unit_id: @conversation.captain_unit_id
).call
memory_block = Captain::ContactMemories::PromptInjectionService.new(
memories: memories
).call
system_prompt_with_memory = [system_prompt, memory_block].compact_blank.join("\n\n")
else
system_prompt_with_memory = system_prompt
end
System prompt do orchestrator (Jasmine) e de cada scenario (Daniela etc) passam a receber system_prompt_with_memory.
Instrução adicionada ao system prompt base: "Se houver bloco <memoria_cliente>, use apenas se for natural na conversa. Nunca mencione 'memória', 'registro' ou 'lembrei'. Aja como quem já conhece o cliente."
7.5 Controllers e UI
enterprise/app/controllers/api/v1/accounts/contacts/memories_controller.rb
GET /api/v1/accounts/:aid/contacts/:cid/memories # list
PATCH /api/v1/accounts/:aid/contacts/:cid/memories/:id # edit content
DELETE /api/v1/accounts/:aid/contacts/:cid/memories/:id # soft delete
DELETE /api/v1/accounts/:aid/contacts/:cid/memories # forget all (LGPD)
Autorização via Pundit: ContactMemoryPolicy — só agentes da account, com permissão de edição de contato.
UI: nova tab "Memória" no detalhe do contato. Componente Vue:
app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue
- Tabs: "Ativas" | "Histórico" (supersedidas/expiradas)
- Lista: type badge, content, confidence bar, idade, origem (link pra conversa)
- Ações por fato: "editar" (inline), "esquecer"
- Botão master: "Esquecer tudo deste contato" (modal de confirmação com texto livre)
Configurações do Captain (página existente): nova seção "Memória do Cliente" com 2 toggles:
- "Aprender com conversas (extraction)"
- "Usar memória nas respostas (recall)"
Cada toggle mostra: status, data da última mudança, quem mudou, custo acumulado do mês (se extraction).
7.6 Migrations
db/migrate/<ts>_create_captain_contact_memories.rb— tabela + índices.- Feature flags via
InstallationConfigseed (padrão já existe no projeto) — NÃO é migration. Adiciona entriescaptain_contact_memory_extraction_enabled(default false) ecaptain_contact_memory_recall_enabled(default false).
8. Data flows
8.1 Fluxo de aprendizado (assíncrono)
conversation.resolved (Wisper event) OU silence_detector_job (cron 10min)
│
▼
ExtractFromConversationJob (Sidekiq, fila captain_heavy)
│
├── feature flag extraction_enabled OFF? → early return
│
▼
ExtractionService
│ prompt estruturado + histórico
│ LLM gpt-4o-mini, JSON mode, temp 0
│ valida evidence + confidence + tipo
│ retorna até 5 fatos válidos
▼
Captain::ContactMemory.create! (N vezes)
│
▼ after_commit
UpdateEmbeddingJob (Sidekiq, fila captain_embeddings)
│ text-embedding-3-small
│ retry 3x backoff exp
▼ after embedding salvo
ContradictionCheckerJob (Sidekiq, fila captain_heavy)
│ busca candidatos cosine ≥ 0.6 mesmo tipo/contato
│ LLM binário por candidato
│ supersede os contraditórios
8.2 Fluxo de resposta (síncrono, caminho crítico)
message recebida → ResponseBuilderJob (já existe)
│
▼
AgentRunnerService.call (alterado)
│
├── feature flag recall_enabled OFF? → pula recall
│
▼
RecallService.call
│ embedding da query
│ nearest_neighbors top-5 com filtro scope
│ timeout 500ms
▼
PromptInjectionService.call
│ monta bloco XML
▼
system_prompt += memory_block
│
▼
Agents::Runner.run (fluxo existente, inalterado)
9. Error handling
| Falha | Comportamento | Impacto no cliente |
|---|---|---|
| LLM extraction falha (timeout/rate limit) | Retry Sidekiq padrão (3x backoff), depois desiste, loga error | Zero — aprende da próxima conversa |
| LLM retorna JSON inválido | Parse tenta repair (regex simples), se falhar descarta conversa, loga warning | Zero |
| Embedding API falha | Retry 3x, depois deixa embedding = NULL |
Fato existe mas invisível no recall até próximo update |
| Recall service excede 500ms | Retorna [], loga warning com p99 |
Agente responde sem memória dessa vez |
| Recall service lança erro | rescue StandardError => e; log; return [] |
Zero — degradação graciosa |
| Contradição ambígua (>1 candidato alto) | Mantém novo, marca antigos como superseded | Zero |
| Feature flag OFF em runtime | Short-circuit no início | Zero |
| Conversation sem unit_id | source_unit_id = NULL — fato fica global |
Zero |
| Contact tem 50+ fatos ativos | Aging job aplica LRU na próxima execução semanal | Zero |
Princípio invariante: Camada 3 NUNCA bloqueia resposta do agente. Qualquer falha degrada para o comportamento atual (Camadas 1+2+4).
10. LGPD e privacidade
- Soft-delete por 30 dias permite desfazer exclusão acidental.
- Hard-delete automático após 30 dias via cron.
- Botão "Esquecer tudo" faz soft-delete em massa de todos os fatos do contato.
- Transparência: cliente pode pedir ao agente "o que vocês sabem sobre mim?" → tool nova
list_my_memories(fora do escopo deste épico, mas arquitetura permite). - Auditoria: toda mudança manual (edit, delete) loga
changed_by_user_id+changed_atemmetadata. - Retenção: fatos sem interação em 365+ dias expiram conforme TTL da tabela.
11. Testes
11.1 Model specs
- Validações: memory_type enum, evidence presence, confidence range, content length.
- Scopes: active, by_type, scope_compatible.
- Soft-delete e supersede methods.
- has_neighbors busca funcional com fixtures.
11.2 Service specs
- ExtractionService com 10+ fixtures de conversas reais (sanitizadas) cobrindo: conversa curta, longa, sem fatos, multi-tipo, com alucinação induzida (verificar que descarta).
- RecallService com base pre-populada: ordering por cosine correto, scope filter correto, timeout respeitado.
- ContradictionCheckerService com pares: idênticos, contraditórios, relacionados mas não contraditórios.
- PromptInjectionService formato XML, vazio quando sem fatos.
11.3 Job specs
- Idempotência: rodar 2x a mesma
conversation_idnão duplica fatos. - SilenceDetector detecta corretamente conversas elegíveis e ignora não-elegíveis.
- AgingJob aplica TTL correto por tipo.
- HardDeleteExpired só apaga > 30d.
11.4 Integration specs
ResponseBuilderJobcom recall ON vs OFF → apenas o ON injeta bloco<memoria_cliente>no prompt.- End-to-end: conversa resolved → fato aprendido → próxima mensagem do mesmo contato usa fato no recall.
- Feature flags: extraction OFF não chama LLM (asserção de mock).
11.5 LGPD specs
forget_all_memories!faz soft-delete em todos os fatos do contato.- Cron hard-delete remove registros > 30d.
12. Feature flags e rollout
Flags (InstallationConfig):
captain_contact_memory_extraction_enabled— defaultfalsecaptain_contact_memory_recall_enabled— defaultfalse
Rollout sugerido:
- Deploy com flags OFF — nada muda em produção.
- Account piloto (Prime Águas Lindas): liga
extraction. Observa 1 semana: custo real, qualidade dos fatos, erros. - Se ok, liga
recallna mesma piloto. Observa 1 semana: latência P99 agente, reação dos clientes. - Expande pras outras 3 marcas em ondas de 48h cada.
- Se qualquer sinal ruim (custo > previsto, reclamação, latência) → desliga só a flag problemática.
13. Observabilidade
Métricas (Prometheus/OTEL, padrão do projeto):
captain_memory_extraction_count(counter, por account, por tipo)captain_memory_extraction_cost_brl(counter, por account)captain_memory_extraction_latency_seconds(histogram)captain_memory_recall_hit_rate(gauge — % de mensagens onde recall retornou ≥1 fato)captain_memory_recall_latency_seconds(histogram, p50/p95/p99)captain_memory_active_facts_count(gauge, por account)captain_memory_supersedence_count(counter, por tipo)
Logs estruturados:
- Cada extração: conversation_id, fatos extraídos (tipo + confidence), descartados (com razão).
- Cada recall: contact_id, query length, top-5 hits com score, tempo total.
- Cada supersedência: old_id, new_id, reason.
Dashboard admin: uma view no Chatwoot admin com custo mensal por account, fatos por tipo, latência P99, taxa de supersedência.
14. Custos estimados
Baseado em ~3.000 conversas resolvidas/mês no grupo todo:
| Item | Volume | Custo unit | Total/mês |
|---|---|---|---|
| Extração LLM (gpt-4o-mini) | ~3.000 | R$ 0,015 | R$ 45,00 |
| Embedding (text-embedding-3-small) | ~15.000 | R$ 0,00005 | R$ 0,75 |
| Recall embedding (1/msg) | ~20.000 | R$ 0,00005 | R$ 1,00 |
| Checagem contradição | ~200 | R$ 0,001 | R$ 0,20 |
| Storage pgvector | 150k fatos em 1 ano | — | R$ 0 |
| Total estimado | ~R$ 47/mês |
Feature flag extraction_enabled OFF zera 95% do custo. recall_enabled OFF zera ~4%.
15. Roadmap de features derivadas (documentado, fora deste épico)
Prioridade alta — atacar após este épico estável ≥ 30 dias em produção:
- Outbound proativo por
data_comemorativa— cron diário varre fatos com data nos próximos 7 dias, dispara template de proposta personalizada cruzandopreferencia. Vira canal de vendas passivo. - Relatórios por unidade — página admin com filtros de
source_unit_id+memory_type. Top reclamações, top elogios, perfil do cliente por unidade. - Tool
list_my_memoriesno agente pra atender direito de transparência LGPD via WhatsApp.
Prioridade média — avaliar a partir de dados do épico A:
- Épico B — Evolução do orchestrator: LangGraph via microserviço Python com checkpoints persistidos, retomada exata de conversas longas, paralelismo de tools. Será spec separado. Pode ou não acontecer — depende se a memória já resolve a dor percebida.
Prioridade baixa — observar antes:
- Reclassificação manual de fatos (operador reclassifica tipo).
- Import de fatos de outras fontes (CRM, reservas Plug-Play).
- Multi-idioma (hoje assume pt-BR).
16. Riscos e mitigações
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| LLM alucinar fatos (inventar coisas) | Média | Alto | Evidence obrigatória + confidence ≥ 0.5 + alternativas B/C/D prontas pra ativar |
| Custo explodir | Baixa | Médio | Flag extraction_enabled desliga 95% do custo |
| Latência do recall piorar resposta | Baixa | Médio | Timeout 500ms com fallback vazio |
| Fatos obsoletos/conflitantes poluírem | Média | Médio | Supersedência automática + aging + limite 50 |
| Vazamento de info entre unidades | Baixa | Alto (LGPD/reputação) | Scope field per-fato pra isolar quando sensível |
| Operador confuso pela nova UI | Baixa | Baixo | Docs + toggle começa OFF, rollout gradual |
| Regressão nas Camadas 1/2/4 | Muito baixa | Alto | Princípio: Camada 3 nunca bloqueia. Todas as alterações são aditivas. |
17. Próximos passos
- Revisão do spec (user) — ajustes inline se necessário.
- Invocar
writing-plans— gera plano executável multi-step com tarefas, ordem, owners, critérios de aceitação. - Execução — via
executing-plansou subagent-driven-development, conforme preferência. - Deploy com flags OFF → piloto gradual → rollout completo.
- Observar 30 dias em produção com memória ativa em todas as contas.
- Abrir Épico B com dados reais como input do brainstorming.