From 6330bec8570baac7595741c20e9e4251d3b12bf6 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sun, 19 Apr 2026 08:30:42 -0300 Subject: [PATCH] fix(captain-memory): temporal memory model + aggressive dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../contradiction_checker_service.rb | 19 ++++++++--- .../contact_memories/extraction_service.rb | 33 +++++++++++++++---- .../contradiction_checker_service_spec.rb | 25 ++++++++++++-- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb b/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb index c85c1b1db..0c4243ba6 100644 --- a/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb +++ b/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb @@ -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) diff --git a/enterprise/app/services/captain/contact_memories/extraction_service.rb b/enterprise/app/services/captain/contact_memories/extraction_service.rb index 288d861b4..c90e4198e 100644 --- a/enterprise/app/services/captain/contact_memories/extraction_service.rb +++ b/enterprise/app/services/captain/contact_memories/extraction_service.rb @@ -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) diff --git a/spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb b/spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb index b34d8e1e3..77b0d8f5c 100644 --- a/spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb +++ b/spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb @@ -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