lex-apollo 0.4.30 → 0.4.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
  5. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  6. data/lib/legion/extensions/apollo/api.rb +19 -9
  7. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  8. data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
  9. data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
  10. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  11. data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
  12. data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
  13. data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
  14. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  15. data/lib/legion/extensions/apollo/transport.rb +20 -0
  16. data/lib/legion/extensions/apollo/version.rb +1 -1
  17. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  18. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  19. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  20. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  21. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  22. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
  23. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  24. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  25. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  26. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  27. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
  28. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  29. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  30. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  31. metadata +34 -2
  32. data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +0 -100
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/json'
3
4
  require_relative '../helpers/confidence'
4
5
  require_relative '../helpers/data_models'
5
6
  require_relative '../helpers/similarity'
7
+ require_relative '../helpers/graph_query'
6
8
 
7
9
  module Legion
8
10
  module Extensions
@@ -10,15 +12,67 @@ module Legion
10
12
  module Runners
11
13
  module Maintenance
12
14
  def force_decay(factor: Legion::Extensions::Apollo.settings[:maintenance][:force_decay_factor], **)
13
- { action: :force_decay, factor: factor }
15
+ return { action: :force_decay, factor: factor } unless Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry_available?
16
+ return { success: false, error: 'invalid_factor' } unless factor.is_a?(Numeric) && factor.real? && factor.finite? && factor.between?(0.0, 1.0)
17
+
18
+ decayed = Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry
19
+ .exclude(status: 'archived')
20
+ .update(confidence: Sequel[:confidence] * factor)
21
+ { success: true, decayed: decayed, factor: factor }
22
+ rescue Sequel::Error => e
23
+ handle_exception(e, level: :error, operation: 'apollo.maintenance.force_decay')
24
+ { success: false, decayed: 0, error: e.message }
14
25
  end
15
26
 
16
27
  def archive_stale(days: Helpers::Confidence.stale_days, **)
17
- { action: :archive_stale, days: days }
28
+ return { action: :archive_stale, days: days } unless Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry_available?
29
+ return { success: false, error: 'invalid_days' } unless days.is_a?(Numeric) && days.real? && days.finite? && days >= 0
30
+
31
+ now = Time.now
32
+ cutoff = now - (days * 86_400)
33
+ archived = Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry
34
+ .exclude(status: 'archived')
35
+ .where(Sequel.function(:coalesce, :updated_at, :created_at) < cutoff)
36
+ .update(status: 'archived', updated_at: now)
37
+ { success: true, archived: archived, days: days }
38
+ rescue Sequel::Error => e
39
+ handle_exception(e, level: :error, operation: 'apollo.maintenance.archive_stale')
40
+ { success: false, archived: 0, error: e.message }
18
41
  end
19
42
 
20
43
  def resolve_dispute(entry_id:, resolution:, **)
21
- { action: :resolve_dispute, entry_id: entry_id, resolution: resolution }
44
+ unless Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry_available?
45
+ return { action: :resolve_dispute, entry_id: entry_id, resolution: resolution }
46
+ end
47
+
48
+ target_status = case resolution.to_s
49
+ when 'accept', 'keep' then 'confirmed'
50
+ when 'reject' then 'archived'
51
+ end
52
+ return { success: false, error: 'invalid_resolution', entry_id: entry_id } unless target_status
53
+
54
+ entry_model = Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry
55
+ entry_model.db.transaction do
56
+ entry = entry_model.for_update.with_pk(entry_id)
57
+ next({ success: false, error: 'entry_not_found', entry_id: entry_id }) unless entry
58
+ next({ success: false, error: 'entry_not_disputed', entry_id: entry.id, status: entry.status }) unless entry.status == 'disputed'
59
+
60
+ context = entry[:source_context]
61
+ context = Legion::JSON.load(context) if context.is_a?(String)
62
+ context ||= {}
63
+ raise Sequel::Error, 'invalid_source_context' unless context.respond_to?(:transform_keys)
64
+
65
+ context = context.transform_keys(&:to_sym)
66
+ now = Time.now
67
+ updates = { status: target_status, updated_at: now,
68
+ source_context: Legion::JSON.dump(context.merge(dispute_resolution: resolution.to_s)) }
69
+ updates[:confirmed_at] = now if target_status == 'confirmed'
70
+ entry.update(updates)
71
+ { success: true, entry_id: entry.id, status: target_status, resolution: resolution }
72
+ end
73
+ rescue Sequel::Error, Legion::JSON::ParseError => e
74
+ handle_exception(e, level: :error, operation: 'apollo.maintenance.resolve_dispute')
75
+ { success: false, error: e.message, entry_id: entry_id }
22
76
  end
23
77
 
24
78
  def run_decay_cycle(alpha: nil, min_confidence: nil, **)
@@ -66,46 +120,35 @@ module Legion
66
120
  { decayed: 0, archived: 0, error: e.message }
67
121
  end
68
122
 
69
- def check_corroboration(**) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
70
- unless Helpers::DataModels.apollo_entry_available?
71
- log.warn('Apollo Maintenance.check_corroboration skipped: apollo_data_not_available')
123
+ def check_corroboration(**)
124
+ unless Helpers::DataModels.apollo_entry_available? && Helpers::DataModels.apollo_relation_available?
125
+ missing = []
126
+ missing << 'entry' unless Helpers::DataModels.apollo_entry_available?
127
+ missing << 'relation' unless Helpers::DataModels.apollo_relation_available?
128
+ log.warn("Apollo Maintenance.check_corroboration skipped: apollo_data_not_available missing=#{missing.join(',')}")
72
129
  return { success: false, error: 'apollo_data_not_available' }
73
130
  end
74
131
 
75
- candidates = Helpers::DataModels.apollo_entry.where(status: 'candidate').exclude(embedding: nil).limit(100).all
76
- confirmed = Helpers::DataModels.apollo_entry.where(status: 'confirmed').exclude(embedding: nil).limit(500).all
77
- log.debug("Apollo Maintenance.check_corroboration candidates=#{candidates.size} confirmed=#{confirmed.size}")
132
+ entry_model = Helpers::DataModels.apollo_entry
133
+ candidates = entry_model.where(status: 'candidate').exclude(embedding: nil).order(:id).limit(100).all
134
+ confirmed_ids = entry_model.where(status: 'confirmed').select_map(:id)
135
+ log.debug("Apollo Maintenance.check_corroboration candidates=#{candidates.size} confirmed=#{confirmed_ids.size}")
78
136
 
79
137
  promoted = 0
80
-
81
- candidates.each do |candidate|
82
- match = confirmed.find do |conf|
83
- next unless conf.content_type == candidate.content_type
84
-
85
- sim = Helpers::Similarity.cosine_similarity(
86
- vec_a: candidate.embedding, vec_b: conf.embedding
87
- )
88
- Helpers::Similarity.above_corroboration_threshold?(similarity: sim)
89
- end
90
-
138
+ candidates.each_with_index do |candidate, index|
139
+ match = corroboration_match_for(candidate: candidate, evidence_ids: confirmed_ids) ||
140
+ bootstrap_match_for(candidate: candidate, candidates: candidates, candidate_index: index)
91
141
  next unless match
92
142
 
93
- candidate_provider = candidate.respond_to?(:source_provider) ? candidate.source_provider : nil
94
- match_provider = match.respond_to?(:source_provider) ? match.source_provider : nil
95
- both_known = known_provider?(candidate_provider) && known_provider?(match_provider)
96
- next if both_known && candidate_provider == match_provider
97
-
98
- candidate_channel = candidate.respond_to?(:source_channel) ? candidate.source_channel : nil
99
- match_channel = match.respond_to?(:source_channel) ? match.source_channel : nil
100
- next if candidate_channel && match_channel && candidate_channel == match_channel
101
-
102
- candidate.update(
143
+ bootstrap = !confirmed_ids.include?(match.id)
144
+ updates = {
103
145
  status: 'confirmed',
104
146
  confirmed_at: Time.now,
105
147
  confidence: Helpers::Confidence.apply_corroboration_boost(confidence: candidate.confidence),
106
148
  updated_at: Time.now
107
- )
108
-
149
+ }
150
+ updates[:source_context] = bootstrap_confirmation_context(candidate) if bootstrap
151
+ candidate.update(updates)
109
152
  Helpers::DataModels.apollo_relation.create(
110
153
  from_entry_id: candidate.id,
111
154
  to_entry_id: match.id,
@@ -113,19 +156,75 @@ module Legion
113
156
  source_agent: 'system:corroboration',
114
157
  weight: Legion::Extensions::Apollo.settings[:corroboration][:relation_weight]
115
158
  )
116
-
117
159
  promoted += 1
118
160
  end
119
161
 
120
162
  { success: true, promoted: promoted, scanned: candidates.size }
121
163
  .tap { |result| log.info("Apollo Maintenance.check_corroboration scanned=#{result[:scanned]} promoted=#{result[:promoted]}") }
122
- rescue Sequel::Error => e
164
+ rescue Sequel::Error, Legion::JSON::ParseError => e
123
165
  handle_exception(e, level: :error, operation: 'apollo.maintenance.check_corroboration')
124
166
  { success: false, error: e.message }
125
167
  end
126
168
 
127
169
  private
128
170
 
171
+ def corroboration_match_for(candidate:, evidence_ids:)
172
+ return nil if evidence_ids.empty?
173
+
174
+ scope = candidate.access_scope || 'global'
175
+ return nil if scope != 'global' && candidate.identity_principal_id.nil?
176
+
177
+ evidence = Helpers::DataModels.apollo_entry
178
+ .where(id: evidence_ids, content_type: candidate.content_type,
179
+ knowledge_domain: candidate.knowledge_domain, access_scope: scope)
180
+ .exclude(embedding: nil)
181
+ if scope != 'global'
182
+ evidence = evidence.where(
183
+ Sequel.lit(Legion::Extensions::Apollo::Helpers::GraphQuery.access_scope_sql,
184
+ requesting_principal_id: candidate.identity_principal_id)
185
+ )
186
+ end
187
+
188
+ evidence.limit(500).find { |entry| corroborates?(candidate: candidate, entry: entry) }
189
+ end
190
+
191
+ def bootstrap_match_for(candidate:, candidates:, candidate_index:)
192
+ evidence_ids = candidates.drop(candidate_index + 1).map(&:id)
193
+ corroboration_match_for(candidate: candidate, evidence_ids: evidence_ids)
194
+ end
195
+
196
+ def corroborates?(candidate:, entry:)
197
+ return false if candidate.id == entry.id
198
+ return false if bootstrap_confirmation?(entry)
199
+
200
+ both_known = known_provider?(candidate.source_provider) && known_provider?(entry.source_provider)
201
+ return false if both_known && candidate.source_provider == entry.source_provider
202
+ return false if candidate.source_channel && candidate.source_channel == entry.source_channel
203
+
204
+ similarity = Helpers::Similarity.cosine_similarity(vec_a: candidate.embedding, vec_b: entry.embedding)
205
+ Helpers::Similarity.above_corroboration_threshold?(similarity: similarity)
206
+ end
207
+
208
+ def bootstrap_confirmation_context(entry)
209
+ context = entry[:source_context]
210
+ context = Legion::JSON.load(context) if context.is_a?(String)
211
+ context ||= {}
212
+ raise Sequel::Error, 'invalid_source_context' unless context.respond_to?(:transform_keys)
213
+
214
+ Legion::JSON.dump(context.transform_keys(&:to_sym).merge(corroboration_mode: 'bootstrap'))
215
+ end
216
+
217
+ def bootstrap_confirmation?(entry)
218
+ return false unless entry.status == 'confirmed'
219
+
220
+ context = entry[:source_context]
221
+ context = Legion::JSON.load(context) if context.is_a?(String)
222
+ context.respond_to?(:[]) && (context[:corroboration_mode] || context['corroboration_mode']) == 'bootstrap'
223
+ rescue Legion::JSON::ParseError => e
224
+ handle_exception(e, level: :warn, operation: 'apollo.maintenance.bootstrap_confirmation')
225
+ true
226
+ end
227
+
129
228
  def known_provider?(provider)
130
229
  !provider.nil? && !provider.to_s.empty? && provider.to_s != 'unknown'
131
230
  end
@@ -8,6 +8,7 @@ module Legion
8
8
  module Runners
9
9
  module Request
10
10
  include Legion::Logging::Helper
11
+ include Legion::Transport::Helper if defined?(Legion::Transport::Helper)
11
12
  extend Legion::Logging::Helper
12
13
  extend self
13
14
 
@@ -76,13 +77,11 @@ module Legion
76
77
  end
77
78
 
78
79
  def transport_available?
79
- Legion.const_defined?(:Transport, false) &&
80
- Legion::Transport.respond_to?(:connected?) &&
81
- Legion::Transport.connected?
80
+ defined?(Legion::Transport::Connection) && transport_session_open?
82
81
  end
83
82
 
84
83
  def publish_query(**payload)
85
- Transport::Messages::Query.new(payload).publish
84
+ Transport::Messages::Query.new(**payload).publish
86
85
  { success: true, dispatched: :transport, payload: payload }
87
86
  rescue StandardError => e
88
87
  handle_exception(e, level: :warn, operation: 'apollo.request.publish_query')
@@ -90,7 +89,7 @@ module Legion
90
89
  end
91
90
 
92
91
  def publish_ingest(**payload)
93
- Transport::Messages::Ingest.new(payload).publish
92
+ Transport::Messages::Ingest.new(**payload).publish
94
93
  { success: true, dispatched: :transport, payload: payload }
95
94
  rescue StandardError => e
96
95
  handle_exception(e, level: :warn, operation: 'apollo.request.publish_ingest')
@@ -5,6 +5,26 @@ module Legion
5
5
  module Apollo
6
6
  module Transport
7
7
  extend Legion::Extensions::Transport
8
+
9
+ def self.additional_e_to_q
10
+ [
11
+ { from: Legion::Extensions::Apollo::Transport::Exchanges::Apollo,
12
+ to: Legion::Extensions::Apollo::Transport::Queues::Ingest,
13
+ routing_key: 'legion.apollo.ingest' },
14
+ { from: Legion::Extensions::Apollo::Transport::Exchanges::Apollo,
15
+ to: Legion::Extensions::Apollo::Transport::Queues::QueryResponder,
16
+ routing_key: 'legion.apollo.query' },
17
+ { from: Legion::Extensions::Apollo::Transport::Exchanges::Apollo,
18
+ to: Legion::Extensions::Apollo::Transport::Queues::WritebackStore,
19
+ routing_key: 'legion.apollo.writeback.store' },
20
+ { from: Legion::Extensions::Apollo::Transport::Exchanges::Apollo,
21
+ to: Legion::Extensions::Apollo::Transport::Queues::WritebackVectorize,
22
+ routing_key: 'legion.apollo.writeback.vectorize' },
23
+ { from: Legion::Extensions::Apollo::Transport::Exchanges::LlmAudit,
24
+ to: Legion::Extensions::Apollo::Transport::Queues::GasSubscriber,
25
+ routing_key: 'llm.audit.complete' }
26
+ ]
27
+ end
8
28
  end
9
29
  end
10
30
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Apollo
6
- VERSION = '0.4.30'
6
+ VERSION = '0.4.44'
7
7
  end
8
8
  end
9
9
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ $LOADED_FEATURES << 'legion/extensions/actors/every' unless $LOADED_FEATURES.include?('legion/extensions/actors/every')
6
+ require 'legion/extensions/apollo/actors/contradiction_scanner'
7
+
8
+ RSpec.describe Legion::Extensions::Apollo::Actor::ContradictionScanner do
9
+ subject(:scanner) { described_class.new }
10
+
11
+ before { described_class.drain }
12
+ after { described_class.drain }
13
+
14
+ it 'processes queued work through the actual private detector' do
15
+ described_class.enqueue(entry_id: 'no-vector', embedding: nil, content: 'No vector is available.')
16
+
17
+ expect(scanner.scan_pending_contradictions).to eq(processed: 1, failed: 0)
18
+ expect(described_class.pending_count).to eq(0)
19
+ end
20
+
21
+ it 'retains a failed item, processes later work, and succeeds when the database recovers' do
22
+ entry_model = double('ApolloEntry')
23
+ stub_const('Legion::Data::Model::ApolloEntry', entry_model)
24
+ allow(entry_model).to receive(:db).and_raise(Sequel::Error, 'database unavailable')
25
+ described_class.enqueue(entry_id: 'retry-me', embedding: [1.0], content: 'Retry this item.')
26
+ described_class.enqueue(entry_id: 'completed', embedding: nil, content: 'This item needs no vector scan.')
27
+
28
+ expect(scanner.scan_pending_contradictions).to eq(processed: 1, failed: 1)
29
+ expect(described_class.pending_count).to eq(1)
30
+ retained = described_class.queue.pop(true)
31
+ expect(retained[:entry_id]).to eq('retry-me')
32
+ described_class.enqueue(**retained)
33
+
34
+ allow(entry_model).to receive(:db).and_return(double('database', fetch: double(all: [])))
35
+ expect(scanner.scan_pending_contradictions).to eq(processed: 1, failed: 0)
36
+ expect(described_class.pending_count).to eq(0)
37
+ end
38
+ end
@@ -13,26 +13,16 @@ unless defined?(Legion::Extensions::Actors::Subscription)
13
13
  end
14
14
  $LOADED_FEATURES << 'legion/extensions/actors/subscription' unless $LOADED_FEATURES.include?('legion/extensions/actors/subscription')
15
15
 
16
- require 'legion/extensions/apollo/runners/gas'
17
16
  require 'legion/extensions/apollo/actors/gas_subscriber'
18
17
 
19
18
  RSpec.describe Legion::Extensions::Apollo::Actor::GasSubscriber do
20
19
  subject(:actor) { described_class.new }
21
20
 
22
- it 'uses Gas runner_class as string' do
23
- expect(actor.runner_class).to eq('Legion::Extensions::Apollo::Runners::Gas')
24
- end
25
-
26
- it 'runs process function' do
27
- expect(actor.runner_function).to eq('process')
28
- end
29
-
30
- it 'does not generate tasks' do
31
- expect(actor.generate_task?).to be false
32
- end
21
+ it 'dispatches an audit event through the subscription receiver contract' do
22
+ result = actor.runner_class.public_send(actor.runner_function, messages: [], response_content: nil)
33
23
 
34
- it 'does not check subtasks' do
35
- expect(actor.check_subtask?).to be false
24
+ expect(result).to include(phases_completed: 0)
25
+ expect(result).not_to have_key(:error)
36
26
  end
37
27
 
38
28
  describe '#enabled?' do
@@ -1,31 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Stub Sinatra before requiring api.rb so the guard `unless defined?(Sinatra)` fires.
4
- # This avoids a LoadError when sinatra is not in the bundle.
5
- unless defined?(Sinatra)
6
- module Sinatra
7
- class Base
8
- class << self
9
- def set(*, **); end
10
- def before(*, &); end
11
- def helpers(*, &); end
12
- def get(*, &); end
13
- def post(*, &); end
14
- def put(*, &); end
15
- def delete(*, &); end
16
- end
17
- end
18
- end
19
- end
3
+ require 'sinatra/base'
20
4
 
21
5
  require 'spec_helper'
22
6
  require 'legion/extensions/apollo/api'
23
7
 
24
8
  RSpec.describe Legion::Extensions::Apollo::Api do
25
- it 'is defined as a Sinatra app' do
26
- expect(described_class.superclass).to eq(Sinatra::Base)
27
- end
28
-
29
9
  describe '.stats_payload' do
30
10
  let(:entry_model) { class_double('Legion::Data::Model::ApolloEntry') }
31
11
  let(:relation_model) { class_double('Legion::Data::Model::ApolloRelation', count: 4) }
@@ -83,6 +63,16 @@ RSpec.describe Legion::Extensions::Apollo::Api do
83
63
  )
84
64
  end
85
65
 
66
+ it 'keeps entry statistics available when the optional relation model is absent' do
67
+ hide_const('Legion::Data::Model::ApolloRelation') if defined?(Legion::Data::Model::ApolloRelation)
68
+ hide_const('Legion::Data::Model::Apollo::Relation') if defined?(Legion::Data::Model::Apollo::Relation)
69
+
70
+ payload = described_class.stats_payload(now: Time.utc(2026, 4, 28, 12, 0, 0))
71
+
72
+ expect(payload).to include(total_entries: 6, recent_24h: 2, avg_confidence: 0.812)
73
+ expect(payload).not_to have_key(:total_relations)
74
+ end
75
+
86
76
  context 'when legion-data exposes namespaced Apollo models' do
87
77
  before do
88
78
  hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
@@ -53,15 +53,6 @@ RSpec.describe 'Apollo Contradiction Detection' do
53
53
  allow(mock_entry_class).to receive(:db).and_return(mock_db)
54
54
  end
55
55
 
56
- it 'queries with ORDER BY embedding distance' do
57
- allow(mock_db).to receive(:fetch).and_return(double(all: []))
58
- knowledge.send(:detect_contradictions, 'uuid-1', embedding, 'test')
59
- expect(mock_db).to have_received(:fetch).with(
60
- a_string_including('ORDER BY embedding <=> :embedding'),
61
- hash_including(:entry_id, :embedding)
62
- )
63
- end
64
-
65
56
  it 'returns empty when no similar entries found' do
66
57
  allow(mock_db).to receive(:fetch).and_return(double(all: []))
67
58
  result = knowledge.send(:detect_contradictions, 'uuid-1', embedding, 'test')
@@ -18,9 +18,9 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Capability do
18
18
  end
19
19
 
20
20
  describe '.can_embed?' do
21
- it 'returns true when LLM is started and Ollama has a model' do
21
+ it 'returns true when LLM is started and SSOT has an embedding lane' do
22
22
  allow(Legion::LLM).to receive(:started?).and_return(true)
23
- allow(described_class).to receive(:ollama_embedding_available?).and_return(true)
23
+ allow(Legion::LLM).to receive(:can_embed?).and_return(true)
24
24
  expect(described_class.can_embed?).to be true
25
25
  end
26
26
 
@@ -29,7 +29,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Writeback do
29
29
  end
30
30
 
31
31
  it 'returns true when research tools were used' do
32
- enrichments = { 'tool_calls' => [{ name: 'read_file' }] }
32
+ enrichments = {}
33
33
  long_response = double('Response',
34
34
  message: { content: 'x' * 100 },
35
35
  model: 'claude-sonnet-4-6',
@@ -45,6 +45,34 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Writeback do
45
45
  long_response = double('Response', message: { content: 'x' * 100 }, tool_calls: [])
46
46
  expect(described_class.should_capture?(base_request, long_response, enrichments)).to be false
47
47
  end
48
+
49
+ it 'recognizes canonical research tools represented by typed objects or either hash key style' do
50
+ response_type = Data.define(:message, :tools)
51
+ tool_type = Data.define(:name)
52
+ tools = [{ name: 'read_file' }, { 'name' => 'search_files' },
53
+ tool_type.new(name: 'search_content'), tool_type.new(name: 'run_command')]
54
+ decisions = tools.map do |tool|
55
+ response = response_type.new(message: { content: 'Independent research findings. ' * 4 }, tools: [tool])
56
+ described_class.should_capture?(base_request, response, {})
57
+ end
58
+
59
+ expect(decisions).to all(be true)
60
+ end
61
+
62
+ it 'does not mistake a non-research canonical tool for new evidence' do
63
+ response = Data.define(:message, :tools).new(
64
+ message: { content: 'An answer based only on existing Apollo knowledge. ' * 4 },
65
+ tools: [Data.define(:name).new(name: 'apollo_query')]
66
+ )
67
+
68
+ expect(described_class.should_capture?(base_request, response, { 'rag_context:apollo_results' => { count: 3 } })).to be false
69
+ end
70
+
71
+ it 'retains research evidence supplied through enrichments when the final response has no tools' do
72
+ response = Data.define(:message, :tools).new(message: { content: 'Completed research findings. ' * 4 }, tools: [])
73
+
74
+ expect(described_class.should_capture?(base_request, response, { 'tool_calls' => [{ 'name' => 'read_file' }] })).to be true
75
+ end
48
76
  end
49
77
 
50
78
  describe '.build_payload' do