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 72a1f8e9c..c85c1b1db 100644 --- a/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb +++ b/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb @@ -13,6 +13,8 @@ class Captain::ContactMemories::ContradictionCheckerService candidates.each do |candidate| candidate.supersede_by!(@memory) if contradicts?(candidate, @memory) end + rescue StandardError => e + Rails.logger.error("[ContradictionChecker] call failed: #{e.class}: #{e.message} (memory_id=#{@memory&.id})") end private @@ -26,23 +28,31 @@ class Captain::ContactMemories::ContradictionCheckerService .where.not(embedding: nil) .nearest_neighbors(:embedding, @memory.embedding, distance: 'cosine') .first(MAX_CANDIDATES) - .select { |c| cosine_distance(c) < DISTANCE_THRESHOLD } - end - - def cosine_distance(other) - other.neighbor_distance + .select { |c| c.neighbor_distance < DISTANCE_THRESHOLD } end def contradicts?(fact_a, fact_b) - response = RubyLLM.chat(model: CHECK_MODEL).with_temperature(0).ask(<<~PROMPT).content.to_s.downcase + answer = query_llm_for_contradiction(fact_a, fact_b) + answer == 'sim' + rescue StandardError => e + Rails.logger.warn("[ContradictionChecker] #{e.class}: #{e.message}") + false + end + + def query_llm_for_contradiction(fact_a, fact_b) + response = RubyLLM.chat(model: CHECK_MODEL).with_temperature(0).ask(contradiction_prompt(fact_a, fact_b)).content.to_s + # Extract the first meaningful word. Expected "sim" or "nao" (or "não"). + first_word = response.strip.downcase.gsub(/[^a-zãáéíóúç]/, ' ').split.first.to_s + # Normalize "não" → "nao" for ASCII comparison + first_word.tr('ãáéíóúç', 'aaeiouc') + end + + def contradiction_prompt(fact_a, fact_b) + <<~PROMPT Estes 2 fatos sobre o mesmo cliente se contradizem? Fato A: "#{fact_a.content}" Fato B: "#{fact_b.content}" Responda apenas "sim" ou "nao". PROMPT - response.include?('sim') - rescue StandardError => e - Rails.logger.warn("[ContradictionChecker] #{e.class}: #{e.message}") - false end end 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 f1e07eb37..b34d8e1e3 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 @@ -57,5 +57,20 @@ RSpec.describe Captain::ContactMemories::ContradictionCheckerService do expect { described_class.new(memory: embeddingless).call }.not_to raise_error expect(old_fact.reload.superseded_by_id).to be_nil 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) + real_service = described_class.new(memory: new_fact) + expect { real_service.call }.not_to raise_error + expect(old_fact.reload.superseded_by_id).to be_nil + end + end end end