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:
parent
b742d774c8
commit
6330bec857
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user