From e1273f142b3b0ed0b2be8ce5b54925836aa9a853 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Sat, 18 Apr 2026 23:53:33 -0300 Subject: [PATCH] feat(captain-memory): add Captain::ContactMemory model with scopes and lifecycle methods --- .../app/models/captain/contact_memory.rb | 78 ++++++++++++++ .../models/captain/contact_memory_spec.rb | 101 ++++++++++++++++++ spec/factories/captain_contact_memories.rb | 12 +++ 3 files changed, 191 insertions(+) create mode 100644 enterprise/app/models/captain/contact_memory.rb create mode 100644 spec/enterprise/models/captain/contact_memory_spec.rb create mode 100644 spec/factories/captain_contact_memories.rb diff --git a/enterprise/app/models/captain/contact_memory.rb b/enterprise/app/models/captain/contact_memory.rb new file mode 100644 index 000000000..7bff574a7 --- /dev/null +++ b/enterprise/app/models/captain/contact_memory.rb @@ -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 diff --git a/spec/enterprise/models/captain/contact_memory_spec.rb b/spec/enterprise/models/captain/contact_memory_spec.rb new file mode 100644 index 000000000..76eb3e7bf --- /dev/null +++ b/spec/enterprise/models/captain/contact_memory_spec.rb @@ -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 diff --git a/spec/factories/captain_contact_memories.rb b/spec/factories/captain_contact_memories.rb new file mode 100644 index 000000000..95552cf1c --- /dev/null +++ b/spec/factories/captain_contact_memories.rb @@ -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