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