feat(captain-memory): add ContradictionCheckerService with LLM verification

This commit is contained in:
Rodribm10 2026-04-19 00:26:58 -03:00
parent aec796ebfd
commit 9bc6429b91
2 changed files with 109 additions and 0 deletions

View File

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

View File

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