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