diff --git a/enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb b/enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb new file mode 100644 index 000000000..dc9623160 --- /dev/null +++ b/enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb @@ -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 diff --git a/spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb b/spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb new file mode 100644 index 000000000..9d81fce43 --- /dev/null +++ b/spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb @@ -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