feat(captain-memory): add ExtractFromConversationJob with TTL + idempotency

This commit is contained in:
Rodribm10 2026-04-19 00:45:14 -03:00
parent 350a420ee0
commit 9d5e4c959f
2 changed files with 128 additions and 0 deletions

View File

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

View File

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