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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/README.md +42 -5
- data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
- data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
- data/lib/legion/extensions/apollo/api.rb +19 -9
- data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
- data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
- data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
- data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
- data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
- data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
- data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
- data/lib/legion/extensions/apollo/runners/request.rb +4 -5
- data/lib/legion/extensions/apollo/transport.rb +20 -0
- data/lib/legion/extensions/apollo/version.rb +1 -1
- data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
- data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
- data/spec/legion/extensions/apollo/api_spec.rb +11 -21
- data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
- data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
- data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
- data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
- data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
- data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
- data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
- data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
- data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
- data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
- data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
- metadata +34 -2
- 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
|
-
|
|
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(**)
|
|
70
|
-
unless Helpers::DataModels.apollo_entry_available?
|
|
71
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 '
|
|
23
|
-
|
|
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
|
-
|
|
35
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 = {
|
|
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
|