feat(captain-memory): add Captain::ContactMemory model with scopes and lifecycle methods

This commit is contained in:
Rodribm10 2026-04-18 23:53:33 -03:00
parent 575af02aff
commit e1273f142b
3 changed files with 191 additions and 0 deletions

View File

@ -0,0 +1,78 @@
# == Schema Information
#
# Table name: captain_contact_memories
#
# id :bigint not null, primary key
# account_id :bigint not null
# contact_id :bigint not null
# memory_type :string not null
# content :text not null
# evidence :text not null
# confidence :float not null
# scope :string not null, default: 'global'
# embedding :vector(1536)
# source_conversation_id :bigint
# source_unit_id :bigint
# source_inbox_id :bigint
# expires_at :datetime
# last_verified_at :datetime not null
# superseded_at :datetime
# superseded_by_id :bigint
# deleted_at :datetime
# metadata :jsonb not null, default: {}
# created_at :datetime not null
# updated_at :datetime not null
#
class Captain::ContactMemory < ApplicationRecord
self.table_name = 'captain_contact_memories'
MEMORY_TYPES = %w[
preferencia data_comemorativa vinculo_social padrao_comportamental
reclamacao feedback_positivo restricao vinculo_comercial contexto_pessoal
].freeze
belongs_to :account
belongs_to :contact
belongs_to :source_conversation, class_name: 'Conversation', optional: true
belongs_to :source_unit, class_name: 'Captain::Unit', optional: true
belongs_to :source_inbox, class_name: 'Inbox', optional: true
belongs_to :superseded_by, class_name: 'Captain::ContactMemory', optional: true
has_neighbors :embedding, normalize: true
validates :memory_type, presence: true, inclusion: { in: MEMORY_TYPES }
validates :content, presence: true, length: { maximum: 1000 }
validates :evidence, presence: true
validates :confidence, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
validates :scope, presence: true
scope :active, lambda {
where(deleted_at: nil, superseded_at: nil)
.where('expires_at IS NULL OR expires_at > ?', Time.current)
}
scope :for_contact, ->(id) { where(contact_id: id) }
scope :by_type, ->(type) { where(memory_type: type) }
scope :scope_compatible, lambda { |unit_id|
if unit_id.present?
where(scope: ['global', "unit:#{unit_id}"])
else
where(scope: 'global')
end
}
def soft_delete!
update!(deleted_at: Time.current)
end
def supersede_by!(other)
update!(superseded_at: Time.current, superseded_by_id: other.id)
end
def recall_weight
return 0.7 if expires_at.present? && expires_at < Time.current
1.0
end
end

View File

@ -0,0 +1,101 @@
require 'rails_helper'
RSpec.describe Captain::ContactMemory, type: :model do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
describe 'validations' do
it 'requires memory_type' do
memory = build(:captain_contact_memory, account: account, contact: contact, memory_type: nil)
expect(memory).not_to be_valid
end
it 'requires content' do
memory = build(:captain_contact_memory, account: account, contact: contact, content: '')
expect(memory).not_to be_valid
end
it 'requires evidence' do
memory = build(:captain_contact_memory, account: account, contact: contact, evidence: '')
expect(memory).not_to be_valid
end
it 'requires confidence between 0 and 1' do
memory = build(:captain_contact_memory, account: account, contact: contact, confidence: 1.5)
expect(memory).not_to be_valid
end
it 'rejects invalid memory_type' do
memory = build(:captain_contact_memory, account: account, contact: contact, memory_type: 'invalid_type')
expect(memory).not_to be_valid
end
it 'limits content to 1000 chars' do
memory = build(:captain_contact_memory, account: account, contact: contact, content: 'a' * 1001)
expect(memory).not_to be_valid
end
end
describe '.active' do
# rubocop:disable RSpec/LetSetup
let!(:active) { create(:captain_contact_memory, account: account, contact: contact) }
let!(:deleted) { create(:captain_contact_memory, account: account, contact: contact, deleted_at: Time.current) }
let!(:superseded) { create(:captain_contact_memory, account: account, contact: contact, superseded_at: Time.current) }
let!(:expired) { create(:captain_contact_memory, account: account, contact: contact, expires_at: 1.day.ago) }
# rubocop:enable RSpec/LetSetup
it 'excludes deleted, superseded, and expired' do
expect(described_class.active).to contain_exactly(active)
end
end
describe '.scope_compatible' do
# rubocop:disable RSpec/LetSetup
let!(:global_fact) { create(:captain_contact_memory, account: account, contact: contact, scope: 'global') }
let!(:unit_fact) { create(:captain_contact_memory, account: account, contact: contact, scope: 'unit:5') }
let!(:other_unit_fact) { create(:captain_contact_memory, account: account, contact: contact, scope: 'unit:7') }
# rubocop:enable RSpec/LetSetup
it 'returns global + facts for requested unit' do
expect(described_class.scope_compatible(5)).to contain_exactly(global_fact, unit_fact)
end
it 'when unit_id is nil, returns only global' do
expect(described_class.scope_compatible(nil)).to contain_exactly(global_fact)
end
end
describe '#soft_delete!' do
let(:memory) { create(:captain_contact_memory, account: account, contact: contact) }
it 'sets deleted_at' do
freeze_time do
memory.soft_delete!
expect(memory.reload.deleted_at).to eq(Time.current)
end
end
end
describe '#supersede_by!' do
let(:old) { create(:captain_contact_memory, account: account, contact: contact) }
let(:new_one) { create(:captain_contact_memory, account: account, contact: contact) }
it 'links the old memory as superseded by the new one' do
old.supersede_by!(new_one)
expect(old.reload.superseded_by_id).to eq(new_one.id)
expect(old.superseded_at).not_to be_nil
end
end
describe '#recall_weight' do
it 'returns 1.0 for non-expired' do
memory = build(:captain_contact_memory, account: account, contact: contact, expires_at: 1.day.from_now)
expect(memory.recall_weight).to eq(1.0)
end
it 'returns 0.7 for expired' do
memory = build(:captain_contact_memory, account: account, contact: contact, expires_at: 1.day.ago)
expect(memory.recall_weight).to eq(0.7)
end
end
end

View File

@ -0,0 +1,12 @@
FactoryBot.define do
factory :captain_contact_memory, class: 'Captain::ContactMemory' do
account
contact
memory_type { 'preferencia' }
content { 'Prefere Stilo com hidromassagem' }
evidence { "cliente disse 'quero a Stilo com hidro de novo'" }
confidence { 0.9 }
scope { 'global' }
last_verified_at { Time.current }
end
end