diff --git a/enterprise/app/services/captain/contact_memories/recall_service.rb b/enterprise/app/services/captain/contact_memories/recall_service.rb index 9068f0f58..3a7c66b0e 100644 --- a/enterprise/app/services/captain/contact_memories/recall_service.rb +++ b/enterprise/app/services/captain/contact_memories/recall_service.rb @@ -12,21 +12,41 @@ class Captain::ContactMemories::RecallService def call return [] if @contact.blank? || @query_text.blank? + # NOTE: Timeout.timeout is used here as a hard cap on both embedding API + DB query. + # It has known hazards (async exception, can't cleanly interrupt C-level I/O, can + # corrupt connection state) — tradeoff accepted because this service is non-critical: + # any failure returns [] and the agent degrades gracefully without memory. Phase 6 + # will consider refactoring the DB portion to Postgres statement_timeout for safer + # cancellation. 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 + nearest_memories(query_embedding) end rescue StandardError => e - Rails.logger.warn("[ContactMemory::RecallService] #{e.class}: #{e.message}") + log_failure(e) [] end + + private + + def nearest_memories(query_embedding) + 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 + + def log_failure(error) + Rails.logger.warn( + "[ContactMemory::RecallService] #{error.class}: #{error.message} " \ + "(contact_id=#{@contact&.id} account_id=#{@contact&.account_id})" + ) + Rails.logger.warn(error.backtrace.first(5).join("\n")) unless error.is_a?(Timeout::Error) + end end diff --git a/spec/enterprise/services/captain/contact_memories/recall_service_spec.rb b/spec/enterprise/services/captain/contact_memories/recall_service_spec.rb index 179e86821..3e00555a2 100644 --- a/spec/enterprise/services/captain/contact_memories/recall_service_spec.rb +++ b/spec/enterprise/services/captain/contact_memories/recall_service_spec.rb @@ -62,8 +62,9 @@ RSpec.describe Captain::ContactMemories::RecallService do it 'skips memories with NULL embedding' do create(:captain_contact_memory, contact: contact, account: account, embedding: nil) + valid = 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([]) + expect(result).to eq([valid]) end end end