iachat/docs/superpowers/specs/2026-04-18-captain-semantic-memory-design.md

24 KiB
Raw Blame History

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.
  • evidence presence (garantia anti-hallucination — fato sem trecho literal = rejeitado no extractor).
  • confidence entre 0 e 1.
  • content non-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 (gem neighbor)
  • Enum memory_type com os 9 valores
  • Scopes:
    • .activeWHERE 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! — seta deleted_at = now
    • supersede_by!(other) — seta superseded_at, superseded_by_id
    • recall_weight — retorna 1.0 ou 0.7 (reduz peso se expired)
  • Callbacks:
    • after_commit on: [:create, :update] if saved_change_to_content? → enfileira UpdateEmbeddingJob
    • after_create_commit → enfileira ContradictionCheckerJob

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: type válido, evidence non-empty, confidence ≥ 0.5, content dentro 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_weight ao 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 como superseded_by se 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:
    1. Checa feature flag captain_contact_memory_extraction_enabled na account. Se OFF, early return.
    2. Carrega conversation.
    3. Chama ExtractionService.
    4. Persiste os fatos válidos com source_conversation_id, source_unit_id (vem de conversation.captain_unit_id ou inferido via inbox), source_inbox_id.
    5. Cada fato criado aciona UpdateEmbeddingJob via after_commit.
  • Idempotência: se já existe ContactMemory com o mesmo source_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_job concluir (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 ContactMemory com source_conversation_id igual.
  • Enfileira ExtractFromConversationJob pra 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_at no futuro (recall já aplica ×0.7 baseado em expires_at < NOW()).
  • 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.agoDELETE.
  • 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 InstallationConfig seed (padrão já existe no projeto) — NÃO é migration. Adiciona entries captain_contact_memory_extraction_enabled (default false) e captain_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_at em metadata.
  • 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_id nã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

  • ResponseBuilderJob com 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 — default false
  • captain_contact_memory_recall_enabled — default false

Rollout sugerido:

  1. Deploy com flags OFF — nada muda em produção.
  2. Account piloto (Prime Águas Lindas): liga extraction. Observa 1 semana: custo real, qualidade dos fatos, erros.
  3. Se ok, liga recall na mesma piloto. Observa 1 semana: latência P99 agente, reação dos clientes.
  4. Expande pras outras 3 marcas em ondas de 48h cada.
  5. 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 cruzando preferencia. 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_memories no 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

  1. Revisão do spec (user) — ajustes inline se necessário.
  2. Invocar writing-plans — gera plano executável multi-step com tarefas, ordem, owners, critérios de aceitação.
  3. Execução — via executing-plans ou subagent-driven-development, conforme preferência.
  4. Deploy com flags OFFpiloto gradualrollout completo.
  5. Observar 30 dias em produção com memória ativa em todas as contas.
  6. Abrir Épico B com dados reais como input do brainstorming.