feat(captain-memory): add ExtractFromConversationJob with TTL + idempotency
This commit is contained in:
parent
350a420ee0
commit
9d5e4c959f
@ -0,0 +1,61 @@
|
||||
class Captain::ContactMemories::ExtractFromConversationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
TTL_BY_TYPE = {
|
||||
'preferencia' => 365.days,
|
||||
'padrao_comportamental' => 365.days,
|
||||
'reclamacao' => 180.days,
|
||||
'feedback_positivo' => 365.days,
|
||||
'vinculo_social' => 730.days,
|
||||
'vinculo_comercial' => 365.days,
|
||||
'contexto_pessoal' => 365.days
|
||||
# data_comemorativa, restricao: no TTL (nil)
|
||||
}.freeze
|
||||
|
||||
def perform(conversation_id)
|
||||
conversation = Conversation.find_by(id: conversation_id)
|
||||
return if conversation.blank?
|
||||
return unless conversation.account.captain_contact_memory_extraction_enabled?
|
||||
return if already_extracted?(conversation)
|
||||
|
||||
facts = Captain::ContactMemories::ExtractionService.new(conversation: conversation).call
|
||||
return if facts.blank?
|
||||
|
||||
facts.each { |fact| persist_fact(fact, conversation) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def already_extracted?(conversation)
|
||||
Captain::ContactMemory.exists?(source_conversation_id: conversation.id)
|
||||
end
|
||||
|
||||
def persist_fact(fact, conversation)
|
||||
memory = Captain::ContactMemory.create!(build_attributes(fact, conversation))
|
||||
Captain::ContactMemories::UpdateEmbeddingJob.perform_later(memory.id, run_contradiction_check: true)
|
||||
end
|
||||
|
||||
def build_attributes(fact, conversation)
|
||||
ttl = TTL_BY_TYPE[fact[:memory_type]]
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
contact_id: conversation.contact_id,
|
||||
memory_type: fact[:memory_type],
|
||||
content: fact[:content],
|
||||
evidence: fact[:evidence],
|
||||
confidence: fact[:confidence],
|
||||
scope: fact[:scope],
|
||||
source_conversation_id: conversation.id,
|
||||
source_unit_id: resolve_unit_id(conversation),
|
||||
source_inbox_id: conversation.inbox_id,
|
||||
last_verified_at: Time.current,
|
||||
expires_at: ttl&.from_now
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_unit_id(conversation)
|
||||
return conversation.captain_unit_id if conversation.respond_to?(:captain_unit_id) && conversation.captain_unit_id.present?
|
||||
|
||||
Captain::Unit.where(inbox_id: conversation.inbox_id).pick(:id)
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,67 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ContactMemories::ExtractFromConversationJob do
|
||||
let(:account) { create(:account, custom_attributes: { 'captain_contact_memory_extraction_enabled' => true }) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, contact: contact) }
|
||||
let(:extracted_facts) do
|
||||
[
|
||||
{ memory_type: 'preferencia', content: 'Prefere Stilo', evidence: 'disse x', confidence: 0.9, scope: 'global' }
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow_any_instance_of(Captain::ContactMemories::ExtractionService).to receive(:call).and_return(extracted_facts) # rubocop:disable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
it 'skips when extraction flag is off' do
|
||||
account.update!(custom_attributes: { 'captain_contact_memory_extraction_enabled' => false })
|
||||
expect { described_class.perform_now(conversation.id) }.not_to change(Captain::ContactMemory, :count)
|
||||
end
|
||||
|
||||
it 'persists facts with source attribution' do
|
||||
described_class.perform_now(conversation.id)
|
||||
memory = Captain::ContactMemory.last
|
||||
expect(memory.content).to eq('Prefere Stilo')
|
||||
expect(memory.source_conversation_id).to eq(conversation.id)
|
||||
expect(memory.source_inbox_id).to eq(conversation.inbox_id)
|
||||
end
|
||||
|
||||
it 'is idempotent when re-run for the same conversation' do
|
||||
described_class.perform_now(conversation.id)
|
||||
expect { described_class.perform_now(conversation.id) }.not_to change(Captain::ContactMemory, :count)
|
||||
end
|
||||
|
||||
it 'enqueues embedding job for each created memory' do
|
||||
expect { described_class.perform_now(conversation.id) }
|
||||
.to have_enqueued_job(Captain::ContactMemories::UpdateEmbeddingJob).with(anything, run_contradiction_check: true)
|
||||
end
|
||||
|
||||
it 'sets last_verified_at to now' do
|
||||
freeze_time do
|
||||
described_class.perform_now(conversation.id)
|
||||
expect(Captain::ContactMemory.last.last_verified_at).to eq(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
it 'applies TTL expires_at for types that expire' do
|
||||
allow_any_instance_of(Captain::ContactMemories::ExtractionService) # rubocop:disable RSpec/AnyInstance
|
||||
.to receive(:call).and_return(
|
||||
[{ memory_type: 'reclamacao', content: 'x', evidence: 'y', confidence: 0.9, scope: 'unit:1' }]
|
||||
)
|
||||
freeze_time do
|
||||
described_class.perform_now(conversation.id)
|
||||
memory = Captain::ContactMemory.last
|
||||
expect(memory.expires_at).to eq(180.days.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'leaves expires_at null for types without TTL' do
|
||||
allow_any_instance_of(Captain::ContactMemories::ExtractionService) # rubocop:disable RSpec/AnyInstance
|
||||
.to receive(:call).and_return(
|
||||
[{ memory_type: 'restricao', content: 'alergia', evidence: 'y', confidence: 0.9, scope: 'global' }]
|
||||
)
|
||||
described_class.perform_now(conversation.id)
|
||||
expect(Captain::ContactMemory.last.expires_at).to be_nil
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user