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