feat(captain-memory): add ContradictionCheckerService with LLM verification
This commit is contained in:
parent
aec796ebfd
commit
9bc6429b91
@ -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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user