From 9bc6429b91cacfae7baca2fbaed7673d99935e11 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sun, 19 Apr 2026 00:26:58 -0300 Subject: [PATCH] feat(captain-memory): add ContradictionCheckerService with LLM verification --- .../contradiction_checker_service.rb | 48 +++++++++++++++ .../contradiction_checker_service_spec.rb | 61 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb create mode 100644 spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb diff --git a/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb b/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb new file mode 100644 index 000000000..72a1f8e9c --- /dev/null +++ b/enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb @@ -0,0 +1,48 @@ +class Captain::ContactMemories::ContradictionCheckerService + MAX_CANDIDATES = 3 + DISTANCE_THRESHOLD = 0.6 + CHECK_MODEL = 'gpt-4o-mini'.freeze + + def initialize(memory:) + @memory = memory + end + + def call + return if @memory.embedding.blank? + + candidates.each do |candidate| + candidate.supersede_by!(@memory) if contradicts?(candidate, @memory) + end + end + + private + + def candidates + Captain::ContactMemory + .active + .for_contact(@memory.contact_id) + .by_type(@memory.memory_type) + .where.not(id: @memory.id) + .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 + end + + def contradicts?(fact_a, fact_b) + response = RubyLLM.chat(model: CHECK_MODEL).with_temperature(0).ask(<<~PROMPT).content.to_s.downcase + 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 new file mode 100644 index 000000000..f1e07eb37 --- /dev/null +++ b/spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe Captain::ContactMemories::ContradictionCheckerService do + let(:account) { create(:account) } + let(:contact) { create(:contact, account: account) } + let(:embedding_a) { Array.new(1536, 0.1) } + let(:embedding_b) { Array.new(1536, 0.15) } + + def build_memory(attrs = {}) + create( + :captain_contact_memory, + { + account: account, + contact: contact, + memory_type: 'preferencia', + embedding: embedding_a + }.merge(attrs) + ) + end + + describe '#call' do + let(:old_fact) do + build_memory(content: 'Prefere Stilo') + end + let(:new_fact) do + build_memory(content: 'Prefere Alexa agora', embedding: embedding_b) + end + let(:service) { described_class.new(memory: new_fact) } + + before do + allow(service).to receive(:contradicts?).and_return(true) + end + + it 'supersedes contradictory older facts of same type' do + old_fact + service.call + expect(old_fact.reload.superseded_by_id).to eq(new_fact.id) + expect(old_fact.superseded_at).not_to be_nil + end + + it 'does not supersede non-contradictory facts' do + allow(service).to receive(:contradicts?).and_return(false) + old_fact + service.call + expect(old_fact.reload.superseded_by_id).to be_nil + end + + it 'does not supersede across different memory types' do + other = build_memory(memory_type: 'reclamacao') + service.call + expect(other.reload.superseded_by_id).to be_nil + end + + it 'no-ops when memory has no embedding' do + old_fact + embeddingless = build_memory(embedding: nil) + expect { described_class.new(memory: embeddingless).call }.not_to raise_error + expect(old_fact.reload.superseded_by_id).to be_nil + end + end +end