77 KiB
Captain Semantic Memory — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add Layer 3 (episodic contact memory via semantic embeddings) to the Captain AI, with automatic extraction at end of conversations and semantic recall before each response, without altering the existing 3 layers.
Architecture: New captain_contact_memories table with pgvector embeddings (1536d). Extraction runs async after conversation resolved OR 30-min silence. Recall runs sync (500ms timeout) before LLM call in AgentRunnerService. Two independent per-account feature flags stored in account.custom_attributes, default OFF. Graceful degradation: any failure in Layer 3 falls back to existing layers (never blocks response).
Tech Stack: Ruby 3.4.4, Rails 7.1, Sidekiq + sidekiq-cron, PostgreSQL + pgvector, gem neighbor, RubyLLM, Pundit, Vue 3, RSpec.
Spec: docs/superpowers/specs/2026-04-18-captain-semantic-memory-design.md
File Structure
New files
enterprise/app/models/captain/contact_memory.rb
enterprise/app/services/captain/contact_memories/extraction_service.rb
enterprise/app/services/captain/contact_memories/recall_service.rb
enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb
enterprise/app/services/captain/contact_memories/prompt_injection_service.rb
enterprise/app/jobs/captain/contact_memories/update_embedding_job.rb
enterprise/app/jobs/captain/contact_memories/contradiction_checker_job.rb
enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb
enterprise/app/jobs/captain/contact_memories/silence_detector_job.rb
enterprise/app/jobs/captain/contact_memories/aging_job.rb
enterprise/app/jobs/captain/contact_memories/hard_delete_expired_job.rb
enterprise/app/controllers/api/v1/accounts/contacts/memories_controller.rb
enterprise/app/policies/captain/contact_memory_policy.rb
app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue
app/javascript/dashboard/api/captain/contactMemories.js
db/migrate/<ts>_create_captain_contact_memories.rb
spec/enterprise/models/captain/contact_memory_spec.rb
spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb
spec/enterprise/services/captain/contact_memories/recall_service_spec.rb
spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb
spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb
spec/enterprise/jobs/captain/contact_memories/update_embedding_job_spec.rb
spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb
spec/enterprise/jobs/captain/contact_memories/silence_detector_job_spec.rb
spec/enterprise/jobs/captain/contact_memories/aging_job_spec.rb
spec/enterprise/jobs/captain/contact_memories/hard_delete_expired_job_spec.rb
spec/enterprise/controllers/api/v1/accounts/contacts/memories_controller_spec.rb
spec/enterprise/policies/captain/contact_memory_policy_spec.rb
spec/enterprise/integration/captain_semantic_memory_spec.rb
Modified files
app/models/concerns/captain_featurable.rb # add flag helpers
enterprise/app/services/captain/assistant/agent_runner_service.rb # inject recall
config/routes.rb # memories endpoints
config/schedule.yml # 3 new crons
enterprise/app/listeners/captain_listener.rb # (ou novo) hook de conversation.resolved
Phase 0 — Feature flags foundation
Task 0.1: Feature flag helpers on Account
Files:
-
Modify:
app/models/concerns/captain_featurable.rb -
Test:
spec/models/concerns/captain_featurable_spec.rb -
Step 1: Write failing tests
Add to spec/models/concerns/captain_featurable_spec.rb:
describe '#captain_contact_memory_extraction_enabled?' do
let(:account) { create(:account) }
it 'returns false by default' do
expect(account.captain_contact_memory_extraction_enabled?).to be(false)
end
it 'returns true when flag set in custom_attributes' do
account.custom_attributes = { 'captain_contact_memory_extraction_enabled' => true }
account.save!
expect(account.captain_contact_memory_extraction_enabled?).to be(true)
end
end
describe '#captain_contact_memory_recall_enabled?' do
let(:account) { create(:account) }
it 'returns false by default' do
expect(account.captain_contact_memory_recall_enabled?).to be(false)
end
it 'returns true when flag set in custom_attributes' do
account.custom_attributes = { 'captain_contact_memory_recall_enabled' => true }
account.save!
expect(account.captain_contact_memory_recall_enabled?).to be(true)
end
end
- Step 2: Run tests, expect fail
Run: bundle exec rspec spec/models/concerns/captain_featurable_spec.rb -e "captain_contact_memory"
Expected: FAIL with NoMethodError.
- Step 3: Implement helpers
Add to app/models/concerns/captain_featurable.rb inside the module:
def captain_contact_memory_extraction_enabled?
custom_attributes.fetch('captain_contact_memory_extraction_enabled', false) == true
end
def captain_contact_memory_recall_enabled?
custom_attributes.fetch('captain_contact_memory_recall_enabled', false) == true
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/models/concerns/captain_featurable_spec.rb -e "captain_contact_memory"
Expected: PASS.
- Step 5: Commit
git add app/models/concerns/captain_featurable.rb spec/models/concerns/captain_featurable_spec.rb
git commit -m "feat(captain-memory): add feature flag helpers on Account"
Phase 1 — Migration + Model
Task 1.1: Create captain_contact_memories table
Files:
-
Create:
db/migrate/<ts>_create_captain_contact_memories.rb -
Step 1: Generate migration
bin/rails g migration CreateCaptainContactMemories
This produces db/migrate/<timestamp>_create_captain_contact_memories.rb.
- Step 2: Write migration content
Replace the generated file contents with:
class CreateCaptainContactMemories < ActiveRecord::Migration[7.1]
def change
create_table :captain_contact_memories do |t|
t.references :account, null: false, foreign_key: true
t.references :contact, null: false, foreign_key: true
t.string :memory_type, null: false
t.text :content, null: false
t.text :evidence, null: false
t.float :confidence, null: false
t.string :scope, null: false, default: 'global'
t.vector :embedding, limit: 1536
t.bigint :source_conversation_id
t.bigint :source_unit_id
t.bigint :source_inbox_id
t.datetime :expires_at
t.datetime :last_verified_at, null: false
t.datetime :superseded_at
t.bigint :superseded_by_id
t.datetime :deleted_at
t.jsonb :metadata, null: false, default: {}
t.timestamps
end
add_index :captain_contact_memories,
[:account_id, :contact_id],
where: 'deleted_at IS NULL AND superseded_at IS NULL',
name: 'idx_ccm_recall'
add_index :captain_contact_memories,
[:source_unit_id, :memory_type, :created_at],
name: 'idx_ccm_analytics'
add_index :captain_contact_memories,
:deleted_at,
where: 'deleted_at IS NOT NULL',
name: 'idx_ccm_hard_delete'
add_index :captain_contact_memories,
:superseded_by_id,
where: 'superseded_at IS NOT NULL',
name: 'idx_ccm_superseded'
add_index :captain_contact_memories,
:source_conversation_id,
name: 'idx_ccm_source_conversation'
execute <<~SQL.squish
CREATE INDEX idx_ccm_embedding
ON captain_contact_memories
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
SQL
end
end
- Step 3: Run migration
Run: bin/rails db:migrate
Expected: migration runs, table created.
- Step 4: Verify schema
Run: bin/rails dbconsole -p -- -c '\d captain_contact_memories'
Expected: all columns and indexes present.
- Step 5: Commit
git add db/migrate/*_create_captain_contact_memories.rb db/schema.rb
git commit -m "feat(captain-memory): create captain_contact_memories table with pgvector index"
Task 1.2: Create Captain::ContactMemory model
Files:
-
Create:
enterprise/app/models/captain/contact_memory.rb -
Test:
spec/enterprise/models/captain/contact_memory_spec.rb -
Create:
spec/factories/captain_contact_memories.rb -
Step 1: Write failing model tests
Create spec/enterprise/models/captain/contact_memory_spec.rb:
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
expect {
build(:captain_contact_memory, memory_type: 'invalid_type')
}.to raise_error(ArgumentError)
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
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) }
it 'excludes deleted, superseded, and expired' do
expect(described_class.active).to contain_exactly(active)
end
end
describe '.scope_compatible' do
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') }
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
- Step 2: Write factory
Create spec/factories/captain_contact_memories.rb:
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
- Step 3: Run tests, expect fail
Run: bundle exec rspec spec/enterprise/models/captain/contact_memory_spec.rb
Expected: FAIL (class not defined).
- Step 4: Implement model
Create enterprise/app/models/captain/contact_memory.rb:
# == 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
- Step 5: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/models/captain/contact_memory_spec.rb
Expected: PASS all.
- Step 6: Commit
git add enterprise/app/models/captain/contact_memory.rb spec/enterprise/models/captain/contact_memory_spec.rb spec/factories/captain_contact_memories.rb
git commit -m "feat(captain-memory): add Captain::ContactMemory model with scopes and lifecycle methods"
Phase 2 — Services
Task 2.1: PromptInjectionService
Files:
-
Create:
enterprise/app/services/captain/contact_memories/prompt_injection_service.rb -
Test:
spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb -
Step 1: Write failing test
Create spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb:
require 'rails_helper'
RSpec.describe Captain::ContactMemories::PromptInjectionService do
describe '#call' do
it 'returns empty string when memories empty' do
result = described_class.new(memories: []).call
expect(result).to eq('')
end
it 'formats memories as XML block' do
memories = [
build_stubbed(:captain_contact_memory, memory_type: 'preferencia', content: 'Prefere Stilo', confidence: 0.92),
build_stubbed(:captain_contact_memory, memory_type: 'data_comemorativa', content: 'Aniversário 14/02', confidence: 0.88)
]
result = described_class.new(memories: memories).call
expect(result).to include('<memoria_cliente>')
expect(result).to include('<preferencia confidence="0.92">Prefere Stilo</preferencia>')
expect(result).to include('<data_comemorativa confidence="0.88">Aniversário 14/02</data_comemorativa>')
expect(result).to include('</memoria_cliente>')
end
it 'escapes XML-unsafe chars in content' do
memories = [build_stubbed(:captain_contact_memory, content: 'texto com <tag> & ampersand')]
result = described_class.new(memories: memories).call
expect(result).to include('texto com <tag> & ampersand')
end
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb
Expected: FAIL (class not defined).
- Step 3: Implement service
Create enterprise/app/services/captain/contact_memories/prompt_injection_service.rb:
class Captain::ContactMemories::PromptInjectionService
def initialize(memories:)
@memories = memories
end
def call
return '' if @memories.blank?
lines = @memories.map do |memory|
%( <#{memory.memory_type} confidence="#{format('%.2f', memory.confidence)}">#{escape(memory.content)}</#{memory.memory_type}>)
end
"<memoria_cliente>\n#{lines.join("\n")}\n</memoria_cliente>"
end
private
def escape(text)
text.to_s.gsub('&', '&').gsub('<', '<').gsub('>', '>')
end
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb
Expected: PASS.
- Step 5: Commit
git add enterprise/app/services/captain/contact_memories/prompt_injection_service.rb spec/enterprise/services/captain/contact_memories/prompt_injection_service_spec.rb
git commit -m "feat(captain-memory): add PromptInjectionService formatting memories as XML"
Task 2.2: RecallService
Files:
-
Create:
enterprise/app/services/captain/contact_memories/recall_service.rb -
Test:
spec/enterprise/services/captain/contact_memories/recall_service_spec.rb -
Step 1: Write failing tests
Create spec/enterprise/services/captain/contact_memories/recall_service_spec.rb:
require 'rails_helper'
RSpec.describe Captain::ContactMemories::RecallService do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1))
end
describe '#call' do
it 'returns empty array when contact has no memories' do
result = described_class.new(contact: contact, query_text: 'olá').call
expect(result).to eq([])
end
it 'returns top-K active memories ordered by neighbor distance' do
fact_a = create(:captain_contact_memory, contact: contact, account: account, content: 'Prefere Stilo', embedding: Array.new(1536, 0.1))
fact_b = create(:captain_contact_memory, contact: contact, account: account, content: 'Aniversário 14/02', embedding: Array.new(1536, 0.2))
create(:captain_contact_memory, contact: contact, account: account, deleted_at: Time.current, embedding: Array.new(1536, 0.1))
result = described_class.new(contact: contact, query_text: 'quero reservar').call
expect(result).to include(fact_a, fact_b)
expect(result.size).to eq(2)
end
it 'respects unit scope filtering' do
global_fact = create(:captain_contact_memory, contact: contact, account: account, scope: 'global', embedding: Array.new(1536, 0.1))
unit5_fact = create(:captain_contact_memory, contact: contact, account: account, scope: 'unit:5', embedding: Array.new(1536, 0.1))
unit7_fact = create(:captain_contact_memory, contact: contact, account: account, scope: 'unit:7', embedding: Array.new(1536, 0.1))
result = described_class.new(contact: contact, query_text: 'x', unit_id: 5).call
expect(result).to include(global_fact, unit5_fact)
expect(result).not_to include(unit7_fact)
end
it 'respects top_k limit (default 5)' do
7.times { create(:captain_contact_memory, contact: contact, account: account, embedding: Array.new(1536, rand)) }
result = described_class.new(contact: contact, query_text: 'x').call
expect(result.size).to eq(5)
end
it 'returns empty array when embedding_service raises' do
allow(embedding_service).to receive(:get_embedding).and_raise(Captain::Llm::EmbeddingService::EmbeddingsError)
create(:captain_contact_memory, contact: contact, account: account, embedding: Array.new(1536, 0.1))
result = described_class.new(contact: contact, query_text: 'x').call
expect(result).to eq([])
end
it 'returns empty array when timeout exceeded' do
allow(Timeout).to receive(:timeout).and_raise(Timeout::Error)
create(:captain_contact_memory, contact: contact, account: account, embedding: Array.new(1536, 0.1))
result = described_class.new(contact: contact, query_text: 'x').call
expect(result).to eq([])
end
it 'skips memories with NULL embedding' do
create(:captain_contact_memory, contact: contact, account: account, embedding: nil)
result = described_class.new(contact: contact, query_text: 'x').call
expect(result).to eq([])
end
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/recall_service_spec.rb
Expected: FAIL.
- Step 3: Implement service
Create enterprise/app/services/captain/contact_memories/recall_service.rb:
class Captain::ContactMemories::RecallService
TIMEOUT_SECONDS = 0.5
DEFAULT_TOP_K = 5
def initialize(contact:, query_text:, unit_id: nil, top_k: DEFAULT_TOP_K)
@contact = contact
@query_text = query_text
@unit_id = unit_id
@top_k = top_k
end
def call
return [] if @contact.blank? || @query_text.blank?
Timeout.timeout(TIMEOUT_SECONDS) do
query_embedding = Captain::Llm::EmbeddingService.new(account_id: @contact.account_id).get_embedding(@query_text)
return [] if query_embedding.blank?
Captain::ContactMemory
.active
.for_contact(@contact.id)
.scope_compatible(@unit_id)
.where.not(embedding: nil)
.nearest_neighbors(:embedding, query_embedding, distance: 'cosine')
.limit(@top_k)
.to_a
end
rescue Timeout::Error, Captain::Llm::EmbeddingService::EmbeddingsError, StandardError => e
Rails.logger.warn("[ContactMemory::RecallService] #{e.class}: #{e.message}")
[]
end
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/recall_service_spec.rb
Expected: PASS all.
- Step 5: Commit
git add enterprise/app/services/captain/contact_memories/recall_service.rb spec/enterprise/services/captain/contact_memories/recall_service_spec.rb
git commit -m "feat(captain-memory): add RecallService with timeout and graceful degradation"
Task 2.3: ExtractionService
Files:
-
Create:
enterprise/app/services/captain/contact_memories/extraction_service.rb -
Test:
spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb -
Step 1: Write failing tests
Create spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb:
require 'rails_helper'
RSpec.describe Captain::ContactMemories::ExtractionService do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Oi, quero reservar a Stilo com hidro de novo pro meu aniversário dia 14/02')
create(:message, conversation: conversation, message_type: :outgoing, content: 'Claro! Confirmando então...')
end
describe '#call' do
let(:llm_response) do
{
'facts' => [
{
'memory_type' => 'preferencia',
'content' => 'Prefere Stilo com hidromassagem',
'evidence' => "disse 'quero a Stilo com hidro de novo'",
'confidence' => 0.92,
'scope' => 'global'
},
{
'memory_type' => 'data_comemorativa',
'content' => 'Aniversário dia 14/02',
'evidence' => "disse 'meu aniversário dia 14/02'",
'confidence' => 0.88,
'scope' => 'global'
}
]
}.to_json
end
before do
allow_any_instance_of(described_class).to receive(:call_llm).and_return(llm_response)
end
it 'returns array of valid facts' do
result = described_class.new(conversation: conversation).call
expect(result.size).to eq(2)
expect(result.first[:memory_type]).to eq('preferencia')
end
it 'drops facts with missing evidence' do
bad_response = { 'facts' => [{ 'memory_type' => 'preferencia', 'content' => 'x', 'evidence' => '', 'confidence' => 0.9 }] }.to_json
allow_any_instance_of(described_class).to receive(:call_llm).and_return(bad_response)
expect(described_class.new(conversation: conversation).call).to eq([])
end
it 'drops facts with confidence < 0.5' do
bad_response = { 'facts' => [{ 'memory_type' => 'preferencia', 'content' => 'x', 'evidence' => 'y', 'confidence' => 0.3 }] }.to_json
allow_any_instance_of(described_class).to receive(:call_llm).and_return(bad_response)
expect(described_class.new(conversation: conversation).call).to eq([])
end
it 'drops facts with invalid type' do
bad_response = { 'facts' => [{ 'memory_type' => 'invalid', 'content' => 'x', 'evidence' => 'y', 'confidence' => 0.9 }] }.to_json
allow_any_instance_of(described_class).to receive(:call_llm).and_return(bad_response)
expect(described_class.new(conversation: conversation).call).to eq([])
end
it 'limits to 5 facts even if LLM returns more' do
many = { 'facts' => Array.new(10) { { 'memory_type' => 'preferencia', 'content' => 'x', 'evidence' => 'y', 'confidence' => 0.9 } } }.to_json
allow_any_instance_of(described_class).to receive(:call_llm).and_return(many)
expect(described_class.new(conversation: conversation).call.size).to eq(5)
end
it 'returns empty on LLM JSON parse error' do
allow_any_instance_of(described_class).to receive(:call_llm).and_return('not json')
expect(described_class.new(conversation: conversation).call).to eq([])
end
it 'returns empty on LLM error' do
allow_any_instance_of(described_class).to receive(:call_llm).and_raise(StandardError)
expect(described_class.new(conversation: conversation).call).to eq([])
end
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb
Expected: FAIL.
- Step 3: Implement service
Create enterprise/app/services/captain/contact_memories/extraction_service.rb:
class Captain::ContactMemories::ExtractionService
MAX_FACTS = 5
MIN_CONFIDENCE = 0.5
EXTRACTION_MODEL = 'gpt-4o-mini'.freeze
def initialize(conversation:)
@conversation = conversation
end
def call
raw = call_llm
parsed = JSON.parse(raw)
facts = parsed.fetch('facts', [])
facts.filter_map { |f| normalize(f) }.take(MAX_FACTS)
rescue JSON::ParserError => e
Rails.logger.warn("[ContactMemory::ExtractionService] JSON parse: #{e.message}")
[]
rescue StandardError => e
Rails.logger.error("[ContactMemory::ExtractionService] #{e.class}: #{e.message}")
[]
end
private
def call_llm
RubyLLM.chat(model: EXTRACTION_MODEL)
.with_temperature(0)
.with_params(response_format: { type: 'json_object' })
.ask(build_prompt)
.content.to_s
end
def build_prompt
<<~PROMPT
Você é um analista que extrai FATOS MEMORÁVEIS de uma conversa de WhatsApp entre um hóspede e um hotel.
Taxonomia (SÓ use estes tipos, caso contrário descarte o fato):
#{Captain::ContactMemory::MEMORY_TYPES.join(', ')}
Para cada fato, retorne JSON com:
- memory_type (um dos tipos acima)
- content (frase curta, português, max 1000 chars)
- evidence (trecho LITERAL da conversa que sustenta o fato — obrigatório)
- confidence (0.0 a 1.0)
- scope ('global' na maioria dos casos; 'unit:<id>' só se o fato for operacional de uma unidade específica)
Regras INVIOLÁVEIS:
1. Se não houver evidência textual clara, NÃO extraia o fato.
2. Máximo 5 fatos por conversa. Extraia só os realmente memoráveis.
3. Se a conversa não tem nada memorável, retorne {"facts": []}.
4. Nunca invente fatos. Se em dúvida, descarte.
Conversa:
#{formatted_messages}
Retorne JSON no formato: {"facts": [{...}, ...]}
PROMPT
end
def formatted_messages
@conversation.messages
.where(message_type: [:incoming, :outgoing], private: false)
.order(created_at: :asc)
.map { |m| "[#{m.message_type}] #{m.content}" }
.join("\n")
end
def normalize(raw_fact)
type = raw_fact['memory_type'].to_s
content = raw_fact['content'].to_s.strip
evidence = raw_fact['evidence'].to_s.strip
confidence = raw_fact['confidence'].to_f
scope = raw_fact['scope'].to_s.presence || 'global'
return nil unless Captain::ContactMemory::MEMORY_TYPES.include?(type)
return nil if content.blank? || evidence.blank?
return nil if confidence < MIN_CONFIDENCE
{
memory_type: type,
content: content.truncate(1000),
evidence: evidence,
confidence: confidence,
scope: scope
}
end
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb
Expected: PASS all.
- Step 5: Commit
git add enterprise/app/services/captain/contact_memories/extraction_service.rb spec/enterprise/services/captain/contact_memories/extraction_service_spec.rb
git commit -m "feat(captain-memory): add ExtractionService with evidence+confidence guardrails"
Task 2.4: ContradictionCheckerService
Files:
-
Create:
enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb -
Test:
spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb -
Step 1: Write failing tests
Create spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb:
require 'rails_helper'
RSpec.describe Captain::ContactMemories::ContradictionCheckerService do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
describe '#call' do
let(:old_fact) { create(:captain_contact_memory, account: account, contact: contact, memory_type: 'preferencia', content: 'Prefere Stilo', embedding: Array.new(1536, 0.1)) }
let(:new_fact) { create(:captain_contact_memory, account: account, contact: contact, memory_type: 'preferencia', content: 'Prefere Alexa agora', embedding: Array.new(1536, 0.15)) }
before do
allow_any_instance_of(described_class).to receive(:contradicts?).and_return(true)
end
it 'supersedes contradictory older facts of same type' do
old_fact
described_class.new(memory: new_fact).call
expect(old_fact.reload.superseded_by_id).to eq(new_fact.id)
expect(old_fact.superseded_at).not_to be_nil
end
it 'does not supersede non-contradictory facts' do
allow_any_instance_of(described_class).to receive(:contradicts?).and_return(false)
old_fact
described_class.new(memory: new_fact).call
expect(old_fact.reload.superseded_by_id).to be_nil
end
it 'does not supersede across different memory types' do
other = create(:captain_contact_memory, account: account, contact: contact, memory_type: 'reclamacao', embedding: Array.new(1536, 0.1))
described_class.new(memory: new_fact).call
expect(other.reload.superseded_by_id).to be_nil
end
it 'no-ops when memory has no embedding' do
old_fact
embeddingless = create(:captain_contact_memory, account: account, contact: contact, memory_type: 'preferencia', embedding: nil)
expect { described_class.new(memory: embeddingless).call }.not_to raise_error
expect(old_fact.reload.superseded_by_id).to be_nil
end
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb
Expected: FAIL.
- Step 3: Implement service
Create enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb:
class Captain::ContactMemories::ContradictionCheckerService
MAX_CANDIDATES = 3
DISTANCE_THRESHOLD = 0.6
CHECK_MODEL = 'gpt-4o-mini'.freeze
def initialize(memory:)
@memory = memory
end
def call
return if @memory.embedding.blank?
candidates.each do |candidate|
candidate.supersede_by!(@memory) if contradicts?(candidate, @memory)
end
end
private
def candidates
Captain::ContactMemory
.active
.for_contact(@memory.contact_id)
.by_type(@memory.memory_type)
.where.not(id: @memory.id)
.where.not(embedding: nil)
.nearest_neighbors(:embedding, @memory.embedding, distance: 'cosine')
.first(MAX_CANDIDATES)
.select { |c| cosine_distance(c) < DISTANCE_THRESHOLD }
end
def cosine_distance(other)
other.neighbor_distance
end
def contradicts?(a, b)
response = RubyLLM.chat(model: CHECK_MODEL).with_temperature(0).ask(<<~Q).content.to_s.downcase
Estes 2 fatos sobre o mesmo cliente se contradizem?
Fato A: "#{a.content}"
Fato B: "#{b.content}"
Responda apenas "sim" ou "nao".
Q
response.include?('sim')
rescue StandardError => e
Rails.logger.warn("[ContradictionChecker] #{e.class}: #{e.message}")
false
end
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb
Expected: PASS.
- Step 5: Commit
git add enterprise/app/services/captain/contact_memories/contradiction_checker_service.rb spec/enterprise/services/captain/contact_memories/contradiction_checker_service_spec.rb
git commit -m "feat(captain-memory): add ContradictionCheckerService with LLM verification"
Phase 3 — Jobs
Task 3.1: UpdateEmbeddingJob
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/update_embedding_job.rb -
Test:
spec/enterprise/jobs/captain/contact_memories/update_embedding_job_spec.rb -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Captain::ContactMemories::UpdateEmbeddingJob do
let(:memory) { create(:captain_contact_memory) }
let(:embedding_service) { instance_double(Captain::Llm::EmbeddingService) }
before do
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service)
allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.5))
end
it 'sets embedding on the memory' do
described_class.perform_now(memory.id)
expect(memory.reload.embedding).to eq(Array.new(1536, 0.5))
end
it 'does not enqueue ContradictionCheckerJob inside itself' do
expect { described_class.perform_now(memory.id) }
.not_to have_enqueued_job(Captain::ContactMemories::ContradictionCheckerJob)
end
it 'enqueues ContradictionCheckerJob after setting embedding when configured' do
expect { described_class.perform_now(memory.id, run_contradiction_check: true) }
.to have_enqueued_job(Captain::ContactMemories::ContradictionCheckerJob).with(memory.id)
end
it 'tolerates a missing memory silently' do
expect { described_class.perform_now(99_999_999) }.not_to raise_error
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/jobs/captain/contact_memories/update_embedding_job_spec.rb
- Step 3: Implement job
class Captain::ContactMemories::UpdateEmbeddingJob < ApplicationJob
queue_as :low
retry_on Captain::Llm::EmbeddingService::EmbeddingsError, wait: :exponentially_longer, attempts: 3
def perform(memory_id, run_contradiction_check: false)
memory = Captain::ContactMemory.find_by(id: memory_id)
return if memory.blank?
embedding = Captain::Llm::EmbeddingService.new(account_id: memory.account_id).get_embedding(memory.content)
return if embedding.blank?
memory.update!(embedding: embedding)
Captain::ContactMemories::ContradictionCheckerJob.perform_later(memory.id) if run_contradiction_check
end
end
- Step 4: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/jobs/captain/contact_memories/update_embedding_job_spec.rb
- Step 5: Commit
git add enterprise/app/jobs/captain/contact_memories/update_embedding_job.rb spec/enterprise/jobs/captain/contact_memories/update_embedding_job_spec.rb
git commit -m "feat(captain-memory): add UpdateEmbeddingJob"
Task 3.2: ContradictionCheckerJob
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/contradiction_checker_job.rb -
Step 1: Write failing test
spec/enterprise/jobs/captain/contact_memories/contradiction_checker_job_spec.rb:
require 'rails_helper'
RSpec.describe Captain::ContactMemories::ContradictionCheckerJob do
let(:memory) { create(:captain_contact_memory) }
it 'delegates to ContradictionCheckerService' do
service = instance_double(Captain::ContactMemories::ContradictionCheckerService, call: nil)
expect(Captain::ContactMemories::ContradictionCheckerService).to receive(:new).with(memory: memory).and_return(service)
described_class.perform_now(memory.id)
end
it 'no-ops on missing memory' do
expect { described_class.perform_now(99_999_999) }.not_to raise_error
end
end
- Step 2: Run, expect fail
Run: bundle exec rspec spec/enterprise/jobs/captain/contact_memories/contradiction_checker_job_spec.rb
- Step 3: Implement job
class Captain::ContactMemories::ContradictionCheckerJob < ApplicationJob
queue_as :low
def perform(memory_id)
memory = Captain::ContactMemory.find_by(id: memory_id)
return if memory.blank?
Captain::ContactMemories::ContradictionCheckerService.new(memory: memory).call
end
end
-
Step 4: Run tests, expect pass
-
Step 5: Commit
git add enterprise/app/jobs/captain/contact_memories/contradiction_checker_job.rb spec/enterprise/jobs/captain/contact_memories/contradiction_checker_job_spec.rb
git commit -m "feat(captain-memory): add ContradictionCheckerJob"
Task 3.3: ExtractFromConversationJob
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb -
Test:
spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb -
Step 1: Write failing test
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)
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).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).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
-
Step 2: Run, expect fail
-
Step 3: Implement job
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.where(source_conversation_id: conversation.id).exists?
end
def persist_fact(fact, conversation)
ttl = TTL_BY_TYPE[fact[:memory_type]]
memory = Captain::ContactMemory.create!(
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 ? ttl.from_now : nil
)
Captain::ContactMemories::UpdateEmbeddingJob.perform_later(memory.id, run_contradiction_check: true)
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
-
Step 4: Run tests, expect pass
-
Step 5: Commit
git add enterprise/app/jobs/captain/contact_memories/extract_from_conversation_job.rb spec/enterprise/jobs/captain/contact_memories/extract_from_conversation_job_spec.rb
git commit -m "feat(captain-memory): add ExtractFromConversationJob with TTL + idempotency"
Task 3.4: SilenceDetectorJob (cron)
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/silence_detector_job.rb -
Modify:
config/schedule.yml -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Captain::ContactMemories::SilenceDetectorJob do
let(:account) { create(:account, custom_attributes: { 'captain_contact_memory_extraction_enabled' => true }) }
let(:contact) { create(:contact, account: account) }
it 'enqueues extraction for conversations silent > 30 minutes' do
conv = create(:conversation, account: account, contact: contact)
create(:message, conversation: conv, created_at: 35.minutes.ago)
expect { described_class.perform_now }
.to have_enqueued_job(Captain::ContactMemories::ExtractFromConversationJob).with(conv.id)
end
it 'ignores conversations with recent activity' do
conv = create(:conversation, account: account, contact: contact)
create(:message, conversation: conv, created_at: 5.minutes.ago)
expect { described_class.perform_now }
.not_to have_enqueued_job(Captain::ContactMemories::ExtractFromConversationJob)
end
it 'ignores conversations already extracted' do
conv = create(:conversation, account: account, contact: contact)
create(:message, conversation: conv, created_at: 35.minutes.ago)
create(:captain_contact_memory, account: account, contact: contact, source_conversation_id: conv.id)
expect { described_class.perform_now }
.not_to have_enqueued_job(Captain::ContactMemories::ExtractFromConversationJob)
end
it 'ignores accounts with flag off' do
account.update!(custom_attributes: { 'captain_contact_memory_extraction_enabled' => false })
conv = create(:conversation, account: account, contact: contact)
create(:message, conversation: conv, created_at: 35.minutes.ago)
expect { described_class.perform_now }
.not_to have_enqueued_job(Captain::ContactMemories::ExtractFromConversationJob)
end
end
-
Step 2: Run, expect fail
-
Step 3: Implement job
class Captain::ContactMemories::SilenceDetectorJob < ApplicationJob
queue_as :scheduled_jobs
SILENCE_THRESHOLD = 30.minutes
def perform
Account.where("custom_attributes->>'captain_contact_memory_extraction_enabled' = 'true'").find_each do |account|
elegible_conversation_ids(account).each do |conv_id|
Captain::ContactMemories::ExtractFromConversationJob.perform_later(conv_id)
end
end
end
private
def elegible_conversation_ids(account)
Conversation
.where(account_id: account.id)
.joins(:messages)
.where('messages.created_at < ?', SILENCE_THRESHOLD.ago)
.where.not(id: already_extracted_ids(account))
.group('conversations.id')
.having('MAX(messages.created_at) < ?', SILENCE_THRESHOLD.ago)
.pluck(:id)
end
def already_extracted_ids(account)
Captain::ContactMemory
.where(account_id: account.id)
.where.not(source_conversation_id: nil)
.distinct
.pluck(:source_conversation_id)
end
end
- Step 4: Add cron schedule
Append to config/schedule.yml:
# every 10 minutes - detects silent conversations for memory extraction
captain_contact_memory_silence_detector_job:
cron: '*/10 * * * *'
class: 'Captain::ContactMemories::SilenceDetectorJob'
queue: scheduled_jobs
- Step 5: Run tests, expect pass
Run: bundle exec rspec spec/enterprise/jobs/captain/contact_memories/silence_detector_job_spec.rb spec/configs/schedule_spec.rb
- Step 6: Commit
git add enterprise/app/jobs/captain/contact_memories/silence_detector_job.rb spec/enterprise/jobs/captain/contact_memories/silence_detector_job_spec.rb config/schedule.yml
git commit -m "feat(captain-memory): add SilenceDetectorJob with 10min cron"
Task 3.5: AgingJob (weekly cron)
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/aging_job.rb -
Modify:
config/schedule.yml -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Captain::ContactMemories::AgingJob do
let(:account) { create(:account) }
let(:contact) { create(:contact, account: account) }
it 'soft-deletes expired reclamacao (delete on expire type)' do
mem = create(:captain_contact_memory, account: account, contact: contact, memory_type: 'reclamacao', expires_at: 1.day.ago)
described_class.perform_now
expect(mem.reload.deleted_at).not_to be_nil
end
it 'does NOT soft-delete expired preferencia (reduce-weight type)' do
mem = create(:captain_contact_memory, account: account, contact: contact, memory_type: 'preferencia', expires_at: 1.day.ago)
described_class.perform_now
expect(mem.reload.deleted_at).to be_nil
end
it 'applies LRU soft-delete when contact exceeds 50 active facts' do
55.times.with_index do |i|
create(:captain_contact_memory, account: account, contact: contact, last_verified_at: i.days.ago)
end
described_class.perform_now
expect(Captain::ContactMemory.where(contact: contact).active.count).to eq(50)
end
end
-
Step 2: Run, expect fail
-
Step 3: Implement job
class Captain::ContactMemories::AgingJob < ApplicationJob
queue_as :scheduled_jobs
DELETE_ON_EXPIRE = %w[reclamacao feedback_positivo vinculo_social].freeze
MAX_ACTIVE_PER_CONTACT = 50
def perform
soft_delete_expired_deletable
prune_per_contact_lru
end
private
def soft_delete_expired_deletable
Captain::ContactMemory
.where(memory_type: DELETE_ON_EXPIRE)
.where('expires_at < ?', Time.current)
.where(deleted_at: nil)
.update_all(deleted_at: Time.current, updated_at: Time.current)
end
def prune_per_contact_lru
Captain::ContactMemory.active.group(:contact_id).having('COUNT(*) > ?', MAX_ACTIVE_PER_CONTACT).pluck(:contact_id).each do |contact_id|
excess = Captain::ContactMemory.active.for_contact(contact_id).count - MAX_ACTIVE_PER_CONTACT
next if excess <= 0
to_delete = Captain::ContactMemory.active.for_contact(contact_id).order(last_verified_at: :asc).limit(excess)
to_delete.update_all(deleted_at: Time.current, updated_at: Time.current)
end
end
end
- Step 4: Add cron schedule
Append to config/schedule.yml:
# weekly - Sundays at 03:00 UTC - TTL aging + LRU cap of contact memories
captain_contact_memory_aging_job:
cron: '0 3 * * 0'
class: 'Captain::ContactMemories::AgingJob'
queue: scheduled_jobs
-
Step 5: Run tests, expect pass
-
Step 6: Commit
git add enterprise/app/jobs/captain/contact_memories/aging_job.rb spec/enterprise/jobs/captain/contact_memories/aging_job_spec.rb config/schedule.yml
git commit -m "feat(captain-memory): add AgingJob with TTL + LRU cap, weekly cron"
Task 3.6: HardDeleteExpiredJob (daily cron)
Files:
-
Create:
enterprise/app/jobs/captain/contact_memories/hard_delete_expired_job.rb -
Modify:
config/schedule.yml -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Captain::ContactMemories::HardDeleteExpiredJob do
it 'destroys records soft-deleted more than 30 days ago' do
old = create(:captain_contact_memory, deleted_at: 40.days.ago)
recent = create(:captain_contact_memory, deleted_at: 10.days.ago)
active = create(:captain_contact_memory, deleted_at: nil)
described_class.perform_now
expect(Captain::ContactMemory.exists?(old.id)).to be(false)
expect(Captain::ContactMemory.exists?(recent.id)).to be(true)
expect(Captain::ContactMemory.exists?(active.id)).to be(true)
end
end
-
Step 2: Run, expect fail
-
Step 3: Implement job
class Captain::ContactMemories::HardDeleteExpiredJob < ApplicationJob
queue_as :scheduled_jobs
RETENTION_DAYS = 30
def perform
count = Captain::ContactMemory.where('deleted_at < ?', RETENTION_DAYS.days.ago).delete_all
Rails.logger.info("[ContactMemory::HardDeleteExpiredJob] hard-deleted #{count} records")
end
end
- Step 4: Add cron schedule
Append to config/schedule.yml:
# daily at 03:30 UTC - hard-delete soft-deleted contact memories older than 30 days (LGPD)
captain_contact_memory_hard_delete_job:
cron: '30 3 * * *'
class: 'Captain::ContactMemories::HardDeleteExpiredJob'
queue: scheduled_jobs
-
Step 5: Run tests, expect pass
-
Step 6: Commit
git add enterprise/app/jobs/captain/contact_memories/hard_delete_expired_job.rb spec/enterprise/jobs/captain/contact_memories/hard_delete_expired_job_spec.rb config/schedule.yml
git commit -m "feat(captain-memory): add HardDeleteExpiredJob with daily cron (LGPD)"
Phase 4 — Integration with AgentRunnerService
Task 4.1: Hook conversation.resolved to enqueue extraction
Files:
-
Modify:
enterprise/app/listeners/captain_listener.rb(or create if missing) -
Step 1: Check listener location
Run: ls enterprise/app/listeners/
If captain_listener.rb exists, modify it. If not, check app/listeners/ pattern and create in enterprise/.
- Step 2: Write failing test
spec/enterprise/listeners/captain_listener_spec.rb:
require 'rails_helper'
RSpec.describe CaptainListener do
let(:listener) { described_class.instance }
let(:account) { create(:account, custom_attributes: { 'captain_contact_memory_extraction_enabled' => true }) }
let(:conversation) { create(:conversation, account: account, status: :resolved) }
let(:event) { Events::Base.new('conversation.resolved', Time.current, conversation: conversation) }
it 'enqueues extraction job when conversation resolves' do
expect { listener.conversation_resolved(event) }
.to have_enqueued_job(Captain::ContactMemories::ExtractFromConversationJob).with(conversation.id)
end
end
-
Step 3: Run, expect fail
-
Step 4: Implement handler
Add to enterprise/app/listeners/captain_listener.rb:
def conversation_resolved(event)
conversation = extract_conversation(event)
return if conversation.blank?
Captain::ContactMemories::ExtractFromConversationJob.perform_later(conversation.id)
end
private
def extract_conversation(event)
event.data[:conversation] || event.data['conversation']
end
Ensure it's registered in the listener module chain (check config/initializers/02_subscribers.rb or similar).
-
Step 5: Run tests, expect pass
-
Step 6: Commit
git add enterprise/app/listeners/captain_listener.rb spec/enterprise/listeners/captain_listener_spec.rb
git commit -m "feat(captain-memory): enqueue extraction on conversation.resolved"
Task 4.2: Inject recall into AgentRunnerService
Files:
-
Modify:
enterprise/app/services/captain/assistant/agent_runner_service.rb -
Step 1: Write failing integration test
spec/enterprise/integration/captain_semantic_memory_spec.rb:
require 'rails_helper'
RSpec.describe 'Captain semantic memory integration' do
let(:account) { create(:account, custom_attributes: attrs) }
let(:attrs) { { 'captain_contact_memory_recall_enabled' => true } }
let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
let(:runner) { Captain::Assistant::AgentRunnerService.new(assistant: assistant, conversation: conversation) }
before do
create(:captain_contact_memory, account: account, contact: contact, content: 'Prefere Stilo', embedding: Array.new(1536, 0.1))
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(
instance_double(Captain::Llm::EmbeddingService, get_embedding: Array.new(1536, 0.1))
)
allow_any_instance_of(Captain::Assistant::AgentRunnerService).to receive(:run_agent).and_call_original
end
it 'injects <memoria_cliente> block into system prompt when recall flag is on' do
expect(runner.send(:build_system_prompt_with_memory, 'Hi there')).to include('<memoria_cliente>')
end
it 'skips injection when recall flag is off' do
account.update!(custom_attributes: {})
expect(runner.send(:build_system_prompt_with_memory, 'Hi there')).not_to include('<memoria_cliente>')
end
end
-
Step 2: Run, expect fail
-
Step 3: Modify AgentRunnerService
In enterprise/app/services/captain/assistant/agent_runner_service.rb, find the method that constructs the system prompt (e.g., build_system_prompt or direct call to SystemPromptsService). Add a new private method that wraps it:
# Inside Captain::Assistant::AgentRunnerService
def build_system_prompt_with_memory(message_text)
base_prompt = build_system_prompt # or whatever existing method produces the base
return base_prompt unless @conversation&.account&.captain_contact_memory_recall_enabled?
memories = Captain::ContactMemories::RecallService.new(
contact: @conversation.contact,
query_text: message_text,
unit_id: resolve_unit_id
).call
memory_block = Captain::ContactMemories::PromptInjectionService.new(memories: memories).call
return base_prompt if memory_block.blank?
[base_prompt, memory_block].join("\n\n")
end
def resolve_unit_id
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
Then replace the existing call site that assigns system_prompt to use build_system_prompt_with_memory(message).
Exact location: look for @assistant.agent definition around line ~55-80 of the file. Before building the agent, take the message (message parameter of run method) and pass through.
-
Step 4: Run tests, expect pass
-
Step 5: Commit
git add enterprise/app/services/captain/assistant/agent_runner_service.rb spec/enterprise/integration/captain_semantic_memory_spec.rb
git commit -m "feat(captain-memory): inject semantic memory into AgentRunnerService system prompt"
Task 4.3: End-to-end integration test
Files:
-
Test:
spec/enterprise/integration/captain_semantic_memory_spec.rb(extend) -
Step 1: Add end-to-end test
Append to the existing integration spec:
describe 'end-to-end learning and recall' do
let(:account) { create(:account, custom_attributes: { 'captain_contact_memory_extraction_enabled' => true, 'captain_contact_memory_recall_enabled' => true }) }
it 'learns from a resolved conversation and recalls in a new one' do
# 1. Old conversation - learning
old_conv = create(:conversation, account: account, contact: contact)
create(:message, conversation: old_conv, message_type: :incoming, content: 'quero sempre a Stilo com hidro')
create(:message, conversation: old_conv, message_type: :outgoing, content: 'combinado')
allow_any_instance_of(Captain::ContactMemories::ExtractionService).to receive(:call).and_return([
{ memory_type: 'preferencia', content: 'Prefere Stilo com hidro', evidence: 'disse quero sempre a Stilo', confidence: 0.95, scope: 'global' }
])
allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(
instance_double(Captain::Llm::EmbeddingService, get_embedding: Array.new(1536, 0.1))
)
# Simulate conversation.resolved
Captain::ContactMemories::ExtractFromConversationJob.perform_now(old_conv.id)
# Simulate embedding job
last_memory = Captain::ContactMemory.last
last_memory.update!(embedding: Array.new(1536, 0.1))
# 2. New conversation - recall
new_conv = create(:conversation, account: account, contact: contact)
runner = Captain::Assistant::AgentRunnerService.new(assistant: assistant, conversation: new_conv)
result = runner.send(:build_system_prompt_with_memory, 'oi, quero reservar')
expect(result).to include('Prefere Stilo com hidro')
end
end
-
Step 2: Run, expect pass
-
Step 3: Commit
git add spec/enterprise/integration/captain_semantic_memory_spec.rb
git commit -m "test(captain-memory): end-to-end learning and recall integration test"
Phase 5 — API + UI
Task 5.1: ContactMemoryPolicy (Pundit)
Files:
-
Create:
enterprise/app/policies/captain/contact_memory_policy.rb -
Test:
spec/enterprise/policies/captain/contact_memory_policy_spec.rb -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Captain::ContactMemoryPolicy do
subject { described_class.new(pundit_user, memory) }
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:memory) { create(:captain_contact_memory, account: account) }
let(:pundit_user) { build(:pundit_user, user: user, account: account) }
context 'as administrator' do
let(:user) { admin }
it { is_expected.to permit_actions(%i[index update destroy bulk_destroy]) }
end
context 'as agent' do
let(:user) { agent }
it { is_expected.to permit_action(:index) }
it { is_expected.to forbid_actions(%i[update destroy bulk_destroy]) }
end
end
- Step 2: Implement policy
class Captain::ContactMemoryPolicy < ApplicationPolicy
def index?
@account_user.present?
end
def update?
@account_user&.administrator?
end
def destroy?
@account_user&.administrator?
end
def bulk_destroy?
@account_user&.administrator?
end
end
- Step 3: Run tests
- Step 4: Commit
Task 5.2: MemoriesController
Files:
-
Create:
enterprise/app/controllers/api/v1/accounts/contacts/memories_controller.rb -
Modify:
config/routes.rb -
Test:
spec/enterprise/controllers/api/v1/accounts/contacts/memories_controller_spec.rb -
Step 1: Write failing test
require 'rails_helper'
RSpec.describe Api::V1::Accounts::Contacts::MemoriesController, type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:contact) { create(:contact, account: account) }
let!(:memory) { create(:captain_contact_memory, account: account, contact: contact) }
describe 'GET #index' do
it 'returns memories for the contact' do
get "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/memories",
headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.parsed_body['data'].size).to eq(1)
end
end
describe 'PATCH #update' do
it 'updates content and re-embeds' do
expect {
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/memories/#{memory.id}",
params: { content: 'updated content' },
headers: admin.create_new_auth_token
}.to have_enqueued_job(Captain::ContactMemories::UpdateEmbeddingJob)
expect(memory.reload.content).to eq('updated content')
end
end
describe 'DELETE #destroy' do
it 'soft-deletes' do
delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/memories/#{memory.id}",
headers: admin.create_new_auth_token
expect(memory.reload.deleted_at).not_to be_nil
end
end
describe 'DELETE collection (forget all)' do
it 'soft-deletes all memories for the contact' do
create_list(:captain_contact_memory, 3, account: account, contact: contact)
delete "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/memories",
headers: admin.create_new_auth_token
expect(Captain::ContactMemory.for_contact(contact.id).where(deleted_at: nil).count).to eq(0)
end
end
end
- Step 2: Implement controller
class Api::V1::Accounts::Contacts::MemoriesController < Api::V1::Accounts::BaseController
before_action :set_contact
before_action :set_memory, only: %i[update destroy]
def index
@memories = Captain::ContactMemory.active.for_contact(@contact.id).order(created_at: :desc)
authorize(@memories.first || Captain::ContactMemory.new(account: @contact.account)) if @memories.any?
render json: { data: @memories.as_json(except: :embedding) }
end
def update
authorize(@memory)
@memory.update!(memory_params)
Captain::ContactMemories::UpdateEmbeddingJob.perform_later(@memory.id) if @memory.saved_change_to_content?
render json: @memory.as_json(except: :embedding)
end
def destroy
authorize(@memory)
@memory.soft_delete!
head :no_content
end
def bulk_destroy
authorize(Captain::ContactMemory.new(account: @contact.account))
Captain::ContactMemory.active.for_contact(@contact.id).update_all(deleted_at: Time.current, updated_at: Time.current)
head :no_content
end
private
def set_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
def set_memory
@memory = Captain::ContactMemory.for_contact(@contact.id).find(params[:id])
end
def memory_params
params.permit(:content, :memory_type, :scope)
end
end
- Step 3: Add routes
Modify config/routes.rb — inside the existing resources :contacts block of api/v1/accounts, add:
resources :memories, only: %i[index update destroy], controller: 'contacts/memories' do
collection do
delete :bulk_destroy, path: ''
end
end
- Step 4: Run tests
- Step 5: Commit
Task 5.3: Vue component ContactMemories.vue
Files:
-
Create:
app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue -
Create:
app/javascript/dashboard/api/captain/contactMemories.js -
Step 1: Create API client
app/javascript/dashboard/api/captain/contactMemories.js:
import ApiClient from '../ApiClient';
class ContactMemoriesAPI extends ApiClient {
constructor() {
super('memories', { accountScoped: true });
}
get url() {
return `${this.baseUrl()}/contacts/${this.contactId}/memories`;
}
list(contactId) {
this.contactId = contactId;
return axios.get(this.url);
}
update(contactId, id, payload) {
this.contactId = contactId;
return axios.patch(`${this.url}/${id}`, payload);
}
destroy(contactId, id) {
this.contactId = contactId;
return axios.delete(`${this.url}/${id}`);
}
forgetAll(contactId) {
this.contactId = contactId;
return axios.delete(this.url);
}
}
export default new ContactMemoriesAPI();
- Step 2: Create Vue component
app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue:
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import ContactMemoriesAPI from 'dashboard/api/captain/contactMemories';
const props = defineProps({ contactId: { type: [String, Number], required: true } });
const { t } = useI18n();
const memories = ref([]);
const loading = ref(true);
const error = ref(null);
async function load() {
loading.value = true;
error.value = null;
try {
const { data } = await ContactMemoriesAPI.list(props.contactId);
memories.value = data.data;
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
}
async function deleteMemory(id) {
if (!confirm(t('CAPTAIN.MEMORY.CONFIRM_DELETE'))) return;
await ContactMemoriesAPI.destroy(props.contactId, id);
await load();
}
async function forgetAll() {
if (!confirm(t('CAPTAIN.MEMORY.CONFIRM_FORGET_ALL'))) return;
await ContactMemoriesAPI.forgetAll(props.contactId);
await load();
}
onMounted(load);
</script>
<template>
<div class="contact-memories">
<div v-if="loading">{{ t('CAPTAIN.MEMORY.LOADING') }}</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="!memories.length" class="empty">{{ t('CAPTAIN.MEMORY.EMPTY') }}</div>
<ul v-else>
<li v-for="m in memories" :key="m.id" class="memory-row">
<span class="type">{{ m.memory_type }}</span>
<span class="content">{{ m.content }}</span>
<span class="confidence">{{ Math.round(m.confidence * 100) }}%</span>
<button @click="deleteMemory(m.id)">{{ t('CAPTAIN.MEMORY.FORGET') }}</button>
</li>
</ul>
<button class="danger" @click="forgetAll">{{ t('CAPTAIN.MEMORY.FORGET_ALL') }}</button>
</div>
</template>
- Step 3: Register component in contact detail route
Find the existing contact detail component (e.g., app/javascript/dashboard/routes/dashboard/contact/ContactsDashboard.vue or similar). Add a new tab "Memória" pointing to the new component.
- Step 4: Add translations (EN + pt_BR)
app/javascript/dashboard/i18n/locale/en/captain.json:
{
"MEMORY": {
"LOADING": "Loading…",
"EMPTY": "No memories for this contact yet.",
"CONFIRM_DELETE": "Forget this memory?",
"CONFIRM_FORGET_ALL": "Forget ALL memories for this contact? This cannot be undone after 30 days.",
"FORGET": "Forget",
"FORGET_ALL": "Forget all"
}
}
app/javascript/dashboard/i18n/locale/pt_BR/captain.json:
{
"MEMORY": {
"LOADING": "Carregando…",
"EMPTY": "Sem memórias para este contato ainda.",
"CONFIRM_DELETE": "Esquecer esta memória?",
"CONFIRM_FORGET_ALL": "Esquecer TODAS as memórias deste contato? Após 30 dias não é possível desfazer.",
"FORGET": "Esquecer",
"FORGET_ALL": "Esquecer tudo"
}
}
- Step 5: Smoke test manually
Run: pnpm run dev and open a contact detail page. Confirm new tab appears and empty state shows.
- Step 6: Commit
git add app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue app/javascript/dashboard/api/captain/contactMemories.js app/javascript/dashboard/i18n/locale/en/captain.json app/javascript/dashboard/i18n/locale/pt_BR/captain.json
git commit -m "feat(captain-memory): add Contact Memory tab UI with forget controls"
Task 5.4: Feature flag toggles in Captain settings
Files:
-
Modify: existing Captain settings Vue page (
app/javascript/dashboard/routes/dashboard/settings/captain/...) -
Modify:
app/controllers/api/v1/accounts/captain/...or wherever Captain settings are persisted -
Step 1: Add 2 toggles
In the existing Captain settings page, add a new section "Memória do Cliente" with 2 toggle switches bound to account.custom_attributes. Follow existing pattern used for other account-level Captain flags (grep for captain_features in the settings page).
- Step 2: Wire save endpoint
Account update endpoint already accepts custom_attributes. Ensure the 2 keys (captain_contact_memory_extraction_enabled and captain_contact_memory_recall_enabled) persist.
- Step 3: Commit
git add app/javascript/dashboard/routes/dashboard/settings/captain/
git commit -m "feat(captain-memory): add feature flag toggles in Captain settings UI"
Phase 6 — Observability
Task 6.1: Instrumentation metrics
Files:
-
Modify: services and jobs to emit OTEL metrics following existing
Integrations::LlmInstrumentationpattern -
Step 1: Review existing instrumentation
Read lib/integrations/llm_instrumentation.rb to understand how existing services emit metrics.
- Step 2: Emit metrics from ExtractionService, RecallService, UpdateEmbeddingJob
Wrap LLM calls in each service with instrument_* blocks:
-
extraction:captain_memory_extractionspan with model, account_id, latency, facts_count -
recall:captain_memory_recallspan with contact_id, hit_count, latency -
embedding: already instrumented via EmbeddingService -
Step 3: Verify metrics appear
Manual: trigger extraction locally, check OTEL collector logs.
- Step 4: Commit
git add enterprise/app/services/captain/contact_memories/ enterprise/app/jobs/captain/contact_memories/
git commit -m "feat(captain-memory): add OTEL instrumentation to memory pipeline"
Task 6.2: Structured logging
Files:
-
Modify: each service/job to log structured events
-
Step 1: Add logs for key events
Ensure each service/job logs a structured line containing: event name, conversation_id (when relevant), memory_id, memory_type, confidence, decision (saved/dropped/superseded), latency.
Example in ExtractFromConversationJob:
Rails.logger.info({
event: 'captain_memory_extraction_completed',
conversation_id: conversation.id,
facts_extracted: facts.size,
account_id: conversation.account_id
}.to_json)
- Step 2: Commit
git add enterprise/app/services/captain/contact_memories/ enterprise/app/jobs/captain/contact_memories/
git commit -m "feat(captain-memory): add structured logs for memory pipeline"
Phase 7 — Rollout prep
Task 7.1: Operator docs
Files:
-
Create:
docs/captain/semantic-memory.md -
Step 1: Write operator-facing doc
Contents:
-
What the feature does in plain Portuguese
-
How to enable/disable via Settings → Captain → Memória do Cliente
-
How to view/edit/forget memories per contact
-
How to "forget all" for LGPD compliance
-
Expected monthly cost per account
-
Warning signs (cost spike, quality drop) and what to do
-
Step 2: Commit
git add docs/captain/semantic-memory.md
git commit -m "docs(captain-memory): operator guide for semantic memory feature"
Task 7.2: End-to-end smoke test manual
- Step 1: Run full stack locally
pnpm run dev
- Step 2: Enable both flags on test account
Via Rails console:
Account.find(1).update!(custom_attributes: {
'captain_contact_memory_extraction_enabled' => true,
'captain_contact_memory_recall_enabled' => true
})
-
Step 3: Drive a conversation end-to-end
-
Send message in WhatsApp test conversation.
-
Resolve conversation.
-
Verify fact appears in
Captain::ContactMemory.last. -
Verify embedding populated (wait ~5s for async).
-
Start new conversation with same contact.
-
Verify agent response references learned fact naturally.
-
Step 4: Document findings in a note
If anything unexpected, file issue. Otherwise proceed to next task.
Task 7.3: Final branch review
- Step 1: Run full test suite
bundle exec rspec spec/enterprise/models/captain/contact_memory_spec.rb \
spec/enterprise/services/captain/contact_memories/ \
spec/enterprise/jobs/captain/contact_memories/ \
spec/enterprise/controllers/api/v1/accounts/contacts/memories_controller_spec.rb \
spec/enterprise/policies/captain/contact_memory_policy_spec.rb \
spec/enterprise/integration/captain_semantic_memory_spec.rb
Expected: all green.
- Step 2: Rubocop + eslint
bundle exec rubocop enterprise/app/services/captain/contact_memories/ \
enterprise/app/jobs/captain/contact_memories/ \
enterprise/app/models/captain/contact_memory.rb
pnpm run eslint app/javascript/dashboard/routes/dashboard/contact/components/ContactMemories.vue
- Step 3: Review
git log
git log --oneline fix/captain-pix-tuner..HEAD
Expected: ~25 commits, each focused on one task, conventional messages.
-
Step 4: Update spec if anything changed during implementation
-
Step 5: Tag branch ready for review
# push handled by @devops exclusively per agent authority
echo "Branch ready for review and @devops push"
Self-review checklist (run after plan complete)
- Each spec requirement mapped to a task
- No TBD/TODO placeholders
- Each task shows full code (not "similar to X")
- Type consistency: model name, method names, job class names match across all tasks
- All testing tasks include real test code and expected failure message
- File paths are exact and absolute from repo root
- Cron schedules match spec (silence: 10min, aging: weekly, hard-delete: daily)
- Feature flag checks in every entry point (SilenceDetectorJob, ExtractFromConversationJob, AgentRunnerService)
- LGPD retention (30 days) correct in HardDeleteExpiredJob
- TTLs per type match spec (preferencia 365d, reclamacao 180d, restricao nil…)
- Top-K default 5, timeout 500ms, confidence threshold 0.5 — all match spec