fix(captain-memory): temporal memory model + aggressive dedup

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>
This commit is contained in:
Rodribm10 2026-04-19 08:30:42 -03:00
parent b742d774c8
commit 6330bec857
3 changed files with 65 additions and 12 deletions

View File

@ -1,6 +1,11 @@
class Captain::ContactMemories::ContradictionCheckerService
MAX_CANDIDATES = 3
DISTANCE_THRESHOLD = 0.6
MAX_CANDIDATES = 5
# Distances are cosine — lower = more similar (0 = identical, 2 = opposite).
# DEDUP_THRESHOLD: below this, treat as duplicate and auto-supersede (no LLM call).
# CONFLICT_THRESHOLD: between DEDUP and this, ask the LLM if they contradict.
# Above CONFLICT_THRESHOLD: ignore — unrelated facts.
DEDUP_THRESHOLD = 0.15
CONFLICT_THRESHOLD = 0.6
CHECK_MODEL = 'gpt-4o-mini'.freeze
def initialize(memory:)
@ -11,7 +16,7 @@ class Captain::ContactMemories::ContradictionCheckerService
return if @memory.embedding.blank?
candidates.each do |candidate|
candidate.supersede_by!(@memory) if contradicts?(candidate, @memory)
candidate.supersede_by!(@memory) if should_supersede?(candidate)
end
rescue StandardError => e
Rails.logger.error("[ContradictionChecker] call failed: #{e.class}: #{e.message} (memory_id=#{@memory&.id})")
@ -19,6 +24,12 @@ class Captain::ContactMemories::ContradictionCheckerService
private
def should_supersede?(candidate)
# Near-duplicate: auto-supersede without asking LLM (same topic, new wins).
# Otherwise fall back to LLM contradiction check for ambiguous overlap.
candidate.neighbor_distance < DEDUP_THRESHOLD || contradicts?(candidate, @memory)
end
def candidates
Captain::ContactMemory
.active
@ -28,7 +39,7 @@ class Captain::ContactMemories::ContradictionCheckerService
.where.not(embedding: nil)
.nearest_neighbors(:embedding, @memory.embedding, distance: 'cosine')
.first(MAX_CANDIDATES)
.select { |c| c.neighbor_distance < DISTANCE_THRESHOLD }
.select { |c| c.neighbor_distance < CONFLICT_THRESHOLD }
end
def contradicts?(fact_a, fact_b)

View File

@ -62,11 +62,14 @@ class Captain::ContactMemories::ExtractionService
Use apenas estes 9 tipos. Cada tipo tem definição precisa + exemplos do que SIM e do que NÃO é.
1. **preferencia** algo que o cliente EXPLICITAMENTE PREFERE (suíte, horário, estilo, preço, comida).
1. **preferencia** APENAS se o cliente DECLAROU EXPLICITAMENTE uma preferência com palavras como "prefiro", "gosto mais de", "sempre escolho", "adoro", "minha favorita é". Sem declaração explícita = NÃO É PREFERÊNCIA.
SIM: "Prefiro sempre a Stilo com hidro"
SIM: "Gosto de chegar tarde, depois das 22h"
SIM: "Minha suíte favorita é a Hidromassagem"
NÃO: "Quero reservar uma suíte" (é pedido pontual, não preferência recorrente)
NÃO: "Escolheu a Alexa dessa vez" (foi uma escolha, não preferência declarada)
NÃO: "Escolheu a Alexa dessa vez" (foi UMA escolha, não preferência declarada use `padrao_comportamental` com data)
NÃO: "Reservou Alexa para pernoite" (é uma transação, use `padrao_comportamental` com data)
REGRA CRÍTICA: nunca extraia "Prefere X" se a única evidência é uma escolha. Preferência precisa de DECLARAÇÃO EXPLÍCITA com as palavras-gatilho acima.
2. **data_comemorativa** data anual recorrente declarada pelo cliente (aniversário dele/esposa/casamento, Dia dos Namorados que ele celebra aqui, etc).
SIM: "É nosso aniversário de casamento dia 14/02"
@ -80,10 +83,18 @@ class Captain::ContactMemories::ExtractionService
NÃO: "Obrigado" (não é vínculo)
NÃO: "Expressou gratidão ao hotel" (isso é gentileza, não vínculo social)
4. **padrao_comportamental** padrão OBSERVADO/DECLARADO em múltiplas visitas ou que ele menciona como hábito.
SIM: "Sempre chego tarde, entre 23h e meia-noite"
SIM: "Costumo ficar só o pernoite"
NÃO: "Vou chegar às 22h hoje" (é horário pontual dessa reserva)
4. **padrao_comportamental** evento de escolha do cliente (suíte, permanência, horário, forma de pagamento) que vale guardar como HISTÓRICO TEMPORAL, OU declaração explícita de hábito.
**TODO fato desse tipo DEVE incluir a data da conversa no content**, no formato:
"Reservou Stilo para pernoite em 23/05/2026"
"Escolheu 4hrs em 14/03/2026"
SIM: "Sempre chego tarde, entre 23h e meia-noite" (declarou hábito)
SIM: "Costumo ficar só o pernoite" (declarou hábito)
SIM: "Reservou Alexa para pernoite em 23/05/2026" (registro de escolha com data)
SIM: "Escolheu 4hrs na visita de 14/03/2026" (registro de escolha com data)
NÃO: "Costuma ficar 2 horas" (SEM DATA e SEM declaração banido)
NÃO: "Prefere permanência de 4 horas" (banido isso seria preferencia, que exige declaração explícita)
NÃO: "Vai chegar às 22h hoje" (intenção da conversa atual, não histórico)
REGRA CRÍTICA: se você vai registrar uma escolha pontual, SEMPRE inclua a data no content. Memória sem data vira ruído quando o cliente volta.
5. **reclamacao** queixa EXPLÍCITA sobre algo que desagradou/frustrou/causou problema, com sentimento negativo claro.
SIM: "O ar-condicionado estava barulhento demais, não dormi direito"
@ -138,12 +149,22 @@ class Captain::ContactMemories::ExtractionService
## CONVERSA A ANALISAR
**Data de referência desta conversa:** #{conversation_reference_date}
(use essa data em toda memória de escolha do tipo `padrao_comportamental`)
#{formatted_messages}
Retorne JSON puro, nada além disso.
PROMPT
end
# Date of the conversation, used as the temporal reference the LLM must embed
# in padrao_comportamental memories. Falls back to now if somehow no message.
def conversation_reference_date
last = @conversation.messages.where(private: false).maximum(:created_at)
(last || Time.current).strftime('%d/%m/%Y')
end
# Feeds the LLM extractor. MUST exclude:
# - private: true (internal agent-to-agent notes — never seen by the guest; privacy leak if extracted)
# - failed status (outbound messages that never reached the guest — extracting from them is dishonest)

View File

@ -18,6 +18,13 @@ RSpec.describe Captain::ContactMemories::ContradictionCheckerService do
)
end
# Attach neighbor_distance to a memory instance; used to force a record
# into the conflict zone (>= DEDUP) or dedup zone (< DEDUP) deterministically.
def with_distance(memory, distance)
memory.define_singleton_method(:neighbor_distance) { distance }
memory
end
describe '#call' do
let(:old_fact) do
build_memory(content: 'Prefere Stilo')
@ -27,7 +34,10 @@ RSpec.describe Captain::ContactMemories::ContradictionCheckerService do
end
let(:service) { described_class.new(memory: new_fact) }
# Force candidates' neighbor_distance into the conflict zone (above DEDUP, below CONFLICT)
# so tests exercise the LLM-check path. Individual tests can override the distance.
before do
allow(service).to receive(:candidates).and_return([with_distance(old_fact, 0.3)])
allow(service).to receive(:contradicts?).and_return(true)
end
@ -47,6 +57,9 @@ RSpec.describe Captain::ContactMemories::ContradictionCheckerService do
it 'does not supersede across different memory types' do
other = build_memory(memory_type: 'reclamacao')
# Different-type candidate filtering happens at the real candidates query level.
# Simulate by having candidates return [] when types differ.
allow(service).to receive(:candidates).and_return([])
service.call
expect(other.reload.superseded_by_id).to be_nil
end
@ -58,16 +71,24 @@ RSpec.describe Captain::ContactMemories::ContradictionCheckerService do
expect(old_fact.reload.superseded_by_id).to be_nil
end
it 'auto-supersedes near-duplicates without asking LLM' do
allow(service).to receive(:candidates).and_return([with_distance(old_fact, 0.05)])
allow(service).to receive(:contradicts?) # spy — should never be called
service.call
expect(old_fact.reload.superseded_by_id).to eq(new_fact.id)
expect(service).not_to have_received(:contradicts?)
end
context 'when LLM raises during contradiction check' do
it 'does not supersede when LLM raises' do
old_fact
chat_double = instance_double(RubyLLM::Chat)
allow(chat_double).to receive(:with_temperature).and_return(chat_double)
allow(chat_double).to receive(:ask).and_raise(StandardError, 'LLM down')
allow(RubyLLM).to receive(:chat).and_return(chat_double)
# Exercise the real rescue path inside contradicts? (no stub here)
# Exercise the real rescue path inside contradicts? (no contradicts? stub here)
real_service = described_class.new(memory: new_fact)
allow(real_service).to receive(:candidates).and_return([with_distance(old_fact, 0.3)])
expect { real_service.call }.not_to raise_error
expect(old_fact.reload.superseded_by_id).to be_nil
end