fix(captain-memory): ContradictionChecker exact-match parsing + rescue wrap + LLM failure test

This commit is contained in:
Rodribm10 2026-04-19 00:31:54 -03:00
parent 9bc6429b91
commit 6723473fdc
2 changed files with 35 additions and 10 deletions

View File

@ -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

View File

@ -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