lex-apollo 0.3.5 → 0.3.6
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 +13 -0
- data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
- data/lib/legion/extensions/apollo/helpers/graph_query.rb +4 -2
- data/lib/legion/extensions/apollo/runners/knowledge.rb +102 -19
- data/lib/legion/extensions/apollo/runners/maintenance.rb +25 -9
- data/lib/legion/extensions/apollo/version.rb +1 -1
- data/spec/legion/extensions/apollo/helpers/confidence_spec.rb +2 -2
- data/spec/legion/extensions/apollo/runners/decay_cycle_spec.rb +3 -4
- data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +169 -0
- data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +80 -0
- data/spec/spec_helper.rb +6 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98960e6e7d1261ea36708f77b302dfa5011a3f7ea5ce4a1db66198887656fd4e
|
|
4
|
+
data.tar.gz: 9263a29e03175cfcdf7431138d05ec4bb362f29113ccb4f9d5d35e1f28d0fe85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d99ea64a8ead8d08d4915607fc31d999f2000d5934c0519428c6a6eea6e4c13d9981050472e5ebbbdcd9771ab796a85f608e0c0d6b1949d28bece52f26c7a963
|
|
7
|
+
data.tar.gz: 884ec51ff3eb5850ae19a68c3eec31f6931bfccc9283a93c705cd4ba771faf1c1db2136958e4628b54f563ba7dfc6d19e79807176f39da28995181678c19ce52
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.6] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Time-aware power-law decay in batch cycle (alpha=0.5 per Murre & Dros 2015)
|
|
7
|
+
- Source channel diversity enforcement in corroboration paths
|
|
8
|
+
- Right-to-erasure propagation via handle_erasure_request
|
|
9
|
+
- Knowledge domain namespaces with query filtering
|
|
10
|
+
- Domain-aware mesh propagation filtering (prepare_mesh_export)
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- POWER_LAW_ALPHA updated from 0.1 to 0.5
|
|
14
|
+
- run_decay_cycle uses time-aware SQL instead of flat multiplier
|
|
15
|
+
|
|
3
16
|
## [0.3.4] - 2026-03-20
|
|
4
17
|
|
|
5
18
|
### Added
|
|
@@ -44,7 +44,7 @@ module Legion
|
|
|
44
44
|
SQL
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def build_semantic_search_sql(limit: 10, min_confidence: 0.3, statuses: nil, tags: nil, **)
|
|
47
|
+
def build_semantic_search_sql(limit: 10, min_confidence: 0.3, statuses: nil, tags: nil, domain: nil, **)
|
|
48
48
|
conditions = ["e.confidence >= #{min_confidence}"]
|
|
49
49
|
|
|
50
50
|
if statuses&.any?
|
|
@@ -57,11 +57,13 @@ module Legion
|
|
|
57
57
|
conditions << "e.tags && ARRAY[#{tag_list}]::text[]"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
+
conditions << "e.knowledge_domain = '#{domain}'" if domain
|
|
61
|
+
|
|
60
62
|
where_clause = conditions.join(' AND ')
|
|
61
63
|
|
|
62
64
|
<<~SQL
|
|
63
65
|
SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
|
|
64
|
-
e.access_count, e.created_at,
|
|
66
|
+
e.access_count, e.created_at, e.knowledge_domain,
|
|
65
67
|
(e.embedding <=> $embedding) AS distance
|
|
66
68
|
FROM apollo_entries e
|
|
67
69
|
WHERE #{where_clause}
|
|
@@ -9,6 +9,12 @@ module Legion
|
|
|
9
9
|
module Apollo
|
|
10
10
|
module Runners
|
|
11
11
|
module Knowledge
|
|
12
|
+
DOMAIN_ISOLATION = {
|
|
13
|
+
'claims_optimization' => ['claims_optimization'],
|
|
14
|
+
'clinical_care' => %w[clinical_care general],
|
|
15
|
+
'general' => :all
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
12
18
|
def store_knowledge(content:, content_type:, tags: [], source_agent: nil, context: {}, **)
|
|
13
19
|
content_type = content_type.to_sym
|
|
14
20
|
unless Helpers::Confidence::CONTENT_TYPES.include?(content_type)
|
|
@@ -53,31 +59,34 @@ module Legion
|
|
|
53
59
|
}
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
def handle_ingest(content:, content_type:, tags: [], source_agent: 'unknown', source_provider: nil, context: {}, **) # rubocop:disable Metrics/ParameterLists
|
|
62
|
+
def handle_ingest(content:, content_type:, tags: [], source_agent: 'unknown', source_provider: nil, source_channel: nil, knowledge_domain: nil, context: {}, **) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
57
63
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
58
64
|
|
|
59
65
|
embedding = Helpers::Embedding.generate(text: content)
|
|
60
66
|
content_type_sym = content_type.to_s
|
|
61
67
|
tag_array = Array(tags)
|
|
68
|
+
domain = knowledge_domain || tag_array.first || 'general'
|
|
62
69
|
|
|
63
|
-
corroborated, existing_id = find_corroboration(embedding, content_type_sym, source_agent)
|
|
70
|
+
corroborated, existing_id = find_corroboration(embedding, content_type_sym, source_agent, source_channel)
|
|
64
71
|
|
|
65
72
|
unless corroborated
|
|
66
73
|
new_entry = Legion::Data::Model::ApolloEntry.create(
|
|
67
|
-
content:
|
|
68
|
-
content_type:
|
|
69
|
-
confidence:
|
|
70
|
-
source_agent:
|
|
71
|
-
source_provider:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
content: content,
|
|
75
|
+
content_type: content_type_sym,
|
|
76
|
+
confidence: Helpers::Confidence::INITIAL_CONFIDENCE,
|
|
77
|
+
source_agent: source_agent,
|
|
78
|
+
source_provider: source_provider || derive_provider_from_agent(source_agent),
|
|
79
|
+
source_channel: source_channel,
|
|
80
|
+
source_context: ::JSON.dump(context.is_a?(Hash) ? context : {}),
|
|
81
|
+
tags: Sequel.pg_array(tag_array),
|
|
82
|
+
status: 'candidate',
|
|
83
|
+
knowledge_domain: domain,
|
|
84
|
+
embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")
|
|
76
85
|
)
|
|
77
86
|
existing_id = new_entry.id
|
|
78
87
|
end
|
|
79
88
|
|
|
80
|
-
upsert_expertise(source_agent: source_agent, domain:
|
|
89
|
+
upsert_expertise(source_agent: source_agent, domain: domain)
|
|
81
90
|
|
|
82
91
|
Legion::Data::Model::ApolloAccessLog.create(
|
|
83
92
|
entry_id: existing_id, agent_id: source_agent, action: 'ingest'
|
|
@@ -91,13 +100,13 @@ module Legion
|
|
|
91
100
|
{ success: false, error: e.message }
|
|
92
101
|
end
|
|
93
102
|
|
|
94
|
-
def handle_query(query:, limit: 10, min_confidence: 0.3, status: [:confirmed], tags: nil, agent_id: 'unknown', **) # rubocop:disable Metrics/ParameterLists
|
|
103
|
+
def handle_query(query:, limit: 10, min_confidence: 0.3, status: [:confirmed], tags: nil, domain: nil, agent_id: 'unknown', **) # rubocop:disable Metrics/ParameterLists
|
|
95
104
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
96
105
|
|
|
97
106
|
embedding = Helpers::Embedding.generate(text: query)
|
|
98
107
|
sql = Helpers::GraphQuery.build_semantic_search_sql(
|
|
99
108
|
limit: limit, min_confidence: min_confidence,
|
|
100
|
-
statuses: Array(status).map(&:to_s), tags: tags
|
|
109
|
+
statuses: Array(status).map(&:to_s), tags: tags, domain: domain
|
|
101
110
|
)
|
|
102
111
|
|
|
103
112
|
db = Legion::Data::Model::ApolloEntry.db
|
|
@@ -122,7 +131,8 @@ module Legion
|
|
|
122
131
|
formatted = entries.map do |entry|
|
|
123
132
|
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
124
133
|
confidence: entry[:confidence], distance: entry[:distance],
|
|
125
|
-
tags: entry[:tags], source_agent: entry[:source_agent]
|
|
134
|
+
tags: entry[:tags], source_agent: entry[:source_agent],
|
|
135
|
+
knowledge_domain: entry[:knowledge_domain] }
|
|
126
136
|
end
|
|
127
137
|
|
|
128
138
|
{ success: true, entries: formatted, count: formatted.size }
|
|
@@ -163,7 +173,7 @@ module Legion
|
|
|
163
173
|
{ success: false, error: e.message }
|
|
164
174
|
end
|
|
165
175
|
|
|
166
|
-
def retrieve_relevant(query: nil, limit: 5, min_confidence: 0.3, tags: nil, skip: false, **)
|
|
176
|
+
def retrieve_relevant(query: nil, limit: 5, min_confidence: 0.3, tags: nil, domain: nil, skip: false, **) # rubocop:disable Metrics/ParameterLists
|
|
167
177
|
return { status: :skipped } if skip
|
|
168
178
|
|
|
169
179
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
@@ -173,7 +183,7 @@ module Legion
|
|
|
173
183
|
embedding = Helpers::Embedding.generate(text: query.to_s)
|
|
174
184
|
sql = Helpers::GraphQuery.build_semantic_search_sql(
|
|
175
185
|
limit: limit, min_confidence: min_confidence,
|
|
176
|
-
statuses: ['confirmed'], tags: tags
|
|
186
|
+
statuses: ['confirmed'], tags: tags, domain: domain
|
|
177
187
|
)
|
|
178
188
|
|
|
179
189
|
db = Legion::Data::Model::ApolloEntry.db
|
|
@@ -189,7 +199,8 @@ module Legion
|
|
|
189
199
|
formatted = entries.map do |entry|
|
|
190
200
|
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
191
201
|
confidence: entry[:confidence], distance: entry[:distance],
|
|
192
|
-
tags: entry[:tags], source_agent: entry[:source_agent]
|
|
202
|
+
tags: entry[:tags], source_agent: entry[:source_agent],
|
|
203
|
+
knowledge_domain: entry[:knowledge_domain] }
|
|
193
204
|
end
|
|
194
205
|
|
|
195
206
|
{ success: true, entries: formatted, count: formatted.size }
|
|
@@ -197,8 +208,72 @@ module Legion
|
|
|
197
208
|
{ success: false, error: e.message }
|
|
198
209
|
end
|
|
199
210
|
|
|
211
|
+
def prepare_mesh_export(target_domain:, min_confidence: 0.5, limit: 100, **)
|
|
212
|
+
unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
213
|
+
return { success: false, error: 'apollo_data_not_available' }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
conn = Legion::Data.connection
|
|
217
|
+
allowed = allowed_domains_for(target_domain)
|
|
218
|
+
|
|
219
|
+
dataset = conn[:apollo_entries]
|
|
220
|
+
.where(status: 'confirmed')
|
|
221
|
+
.where { confidence >= min_confidence }
|
|
222
|
+
.limit(limit)
|
|
223
|
+
|
|
224
|
+
dataset = dataset.where(knowledge_domain: allowed) unless allowed == :all
|
|
225
|
+
|
|
226
|
+
entries = dataset.all
|
|
227
|
+
|
|
228
|
+
formatted = entries.map do |entry|
|
|
229
|
+
{ id: entry[:id], content: entry[:content], content_type: entry[:content_type],
|
|
230
|
+
confidence: entry[:confidence], knowledge_domain: entry[:knowledge_domain],
|
|
231
|
+
tags: entry[:tags], source_agent: entry[:source_agent] }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
{ success: true, entries: formatted, count: formatted.size, target_domain: target_domain }
|
|
235
|
+
rescue Sequel::Error => e
|
|
236
|
+
{ success: false, error: e.message }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def handle_erasure_request(agent_id:, **)
|
|
240
|
+
unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
241
|
+
return { deleted: 0, redacted: 0, error: 'apollo_data_not_available' }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
conn = Legion::Data.connection
|
|
245
|
+
|
|
246
|
+
# Delete entries solely from dead agent (not confirmed by others)
|
|
247
|
+
deleted = conn[:apollo_entries]
|
|
248
|
+
.where(source_agent: agent_id)
|
|
249
|
+
.exclude(status: 'confirmed')
|
|
250
|
+
.delete
|
|
251
|
+
|
|
252
|
+
# Redact attribution on confirmed entries (corroborated, retain knowledge)
|
|
253
|
+
redacted = conn[:apollo_entries]
|
|
254
|
+
.where(source_agent: agent_id, status: 'confirmed')
|
|
255
|
+
.update(source_agent: 'redacted', source_provider: nil, source_channel: nil)
|
|
256
|
+
|
|
257
|
+
{ deleted: deleted, redacted: redacted, agent_id: agent_id }
|
|
258
|
+
rescue Sequel::Error => e
|
|
259
|
+
{ deleted: 0, redacted: 0, error: e.message }
|
|
260
|
+
end
|
|
261
|
+
|
|
200
262
|
private
|
|
201
263
|
|
|
264
|
+
def allowed_domains_for(target_domain)
|
|
265
|
+
rules = if defined?(Legion::Settings) && Legion::Settings.dig(:apollo, :domain_isolation)
|
|
266
|
+
Legion::Settings.dig(:apollo, :domain_isolation)
|
|
267
|
+
else
|
|
268
|
+
DOMAIN_ISOLATION
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
allowed = rules[target_domain]
|
|
272
|
+
return :all if allowed == :all || allowed.nil?
|
|
273
|
+
|
|
274
|
+
Array(allowed)
|
|
275
|
+
end
|
|
276
|
+
|
|
202
277
|
def detect_contradictions(entry_id, embedding, content)
|
|
203
278
|
return [] unless embedding && defined?(Legion::Data::Model::ApolloEntry)
|
|
204
279
|
|
|
@@ -242,7 +317,7 @@ module Legion
|
|
|
242
317
|
false
|
|
243
318
|
end
|
|
244
319
|
|
|
245
|
-
def find_corroboration(embedding, content_type_sym, source_agent)
|
|
320
|
+
def find_corroboration(embedding, content_type_sym, source_agent, source_channel = nil)
|
|
246
321
|
existing = Legion::Data::Model::ApolloEntry
|
|
247
322
|
.where(content_type: content_type_sym)
|
|
248
323
|
.exclude(embedding: nil)
|
|
@@ -255,6 +330,14 @@ module Legion
|
|
|
255
330
|
next unless Helpers::Similarity.above_corroboration_threshold?(similarity: sim)
|
|
256
331
|
|
|
257
332
|
weight = same_source_provider?(source_agent, entry) ? 0.5 : 1.0
|
|
333
|
+
|
|
334
|
+
# Reject corroboration entirely if same channel (same data source)
|
|
335
|
+
if source_channel && entry.respond_to?(:source_channel)
|
|
336
|
+
existing_channel = entry.source_channel
|
|
337
|
+
weight = 0.0 if existing_channel && existing_channel == source_channel
|
|
338
|
+
end
|
|
339
|
+
next if weight.zero?
|
|
340
|
+
|
|
258
341
|
entry.update(
|
|
259
342
|
confidence: Helpers::Confidence.apply_corroboration_boost(confidence: entry.confidence, weight: weight),
|
|
260
343
|
updated_at: Time.now
|
|
@@ -21,28 +21,40 @@ module Legion
|
|
|
21
21
|
{ action: :resolve_dispute, entry_id: entry_id, resolution: resolution }
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def run_decay_cycle(
|
|
25
|
-
|
|
24
|
+
def run_decay_cycle(alpha: nil, min_confidence: nil, **)
|
|
25
|
+
alpha ||= decay_alpha
|
|
26
26
|
min_confidence ||= decay_threshold
|
|
27
27
|
|
|
28
28
|
return { decayed: 0, archived: 0 } unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
29
29
|
|
|
30
30
|
conn = Legion::Data.connection
|
|
31
|
+
|
|
32
|
+
# Power-law: per-cycle decay factor decreases as entries age
|
|
33
|
+
# Factor = (hours_old / (hours_old + 1)) ^ alpha
|
|
34
|
+
# Recent entries (small hours_old) get a factor closer to 0 (more decay)
|
|
35
|
+
# Old entries (large hours_old) get a factor closer to 1 (less decay)
|
|
36
|
+
hours_expr = Sequel.lit(
|
|
37
|
+
'GREATEST(EXTRACT(EPOCH FROM (NOW() - COALESCE(updated_at, created_at))) / 3600.0, 1.0)'
|
|
38
|
+
)
|
|
39
|
+
decay_factor = Sequel.lit(
|
|
40
|
+
'POWER(CAST(? AS double precision) / (CAST(? AS double precision) + 1.0), ?)', hours_expr, hours_expr, alpha
|
|
41
|
+
)
|
|
42
|
+
|
|
31
43
|
decayed = conn[:apollo_entries]
|
|
32
44
|
.exclude(status: 'archived')
|
|
33
|
-
.update(confidence: Sequel[:confidence] *
|
|
45
|
+
.update(confidence: Sequel[:confidence] * decay_factor)
|
|
34
46
|
|
|
35
47
|
archived = conn[:apollo_entries]
|
|
36
48
|
.where { confidence < min_confidence }
|
|
37
49
|
.exclude(status: 'archived')
|
|
38
50
|
.update(status: 'archived')
|
|
39
51
|
|
|
40
|
-
{ decayed: decayed, archived: archived,
|
|
52
|
+
{ decayed: decayed, archived: archived, alpha: alpha, threshold: min_confidence }
|
|
41
53
|
rescue Sequel::Error => e
|
|
42
54
|
{ decayed: 0, archived: 0, error: e.message }
|
|
43
55
|
end
|
|
44
56
|
|
|
45
|
-
def check_corroboration(**)
|
|
57
|
+
def check_corroboration(**) # rubocop:disable Metrics/CyclomaticComplexity
|
|
46
58
|
return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
|
|
47
59
|
|
|
48
60
|
candidates = Legion::Data::Model::ApolloEntry.where(status: 'candidate').exclude(embedding: nil).all
|
|
@@ -67,6 +79,11 @@ module Legion
|
|
|
67
79
|
both_known = known_provider?(candidate_provider) && known_provider?(match_provider)
|
|
68
80
|
next if both_known && candidate_provider == match_provider
|
|
69
81
|
|
|
82
|
+
# Also reject if same source_channel (same data pipeline)
|
|
83
|
+
candidate_channel = candidate.respond_to?(:source_channel) ? candidate.source_channel : nil
|
|
84
|
+
match_channel = match.respond_to?(:source_channel) ? match.source_channel : nil
|
|
85
|
+
next if candidate_channel && match_channel && candidate_channel == match_channel
|
|
86
|
+
|
|
70
87
|
candidate.update(
|
|
71
88
|
status: 'confirmed',
|
|
72
89
|
confirmed_at: Time.now,
|
|
@@ -92,10 +109,9 @@ module Legion
|
|
|
92
109
|
|
|
93
110
|
private
|
|
94
111
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
1.0 / (1.0 + alpha)
|
|
112
|
+
def decay_alpha
|
|
113
|
+
(defined?(Legion::Settings) && Legion::Settings.dig(:apollo, :power_law_alpha)) ||
|
|
114
|
+
Helpers::Confidence::POWER_LAW_ALPHA
|
|
99
115
|
end
|
|
100
116
|
|
|
101
117
|
def known_provider?(provider)
|
|
@@ -18,7 +18,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Confidence do
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
it 'defines POWER_LAW_ALPHA' do
|
|
21
|
-
expect(described_class::POWER_LAW_ALPHA).to eq(0.
|
|
21
|
+
expect(described_class::POWER_LAW_ALPHA).to eq(0.5)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
it 'defines DECAY_THRESHOLD' do
|
|
@@ -45,7 +45,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Confidence do
|
|
|
45
45
|
describe '.apply_decay' do
|
|
46
46
|
it 'applies power-law decay with default alpha when no age given' do
|
|
47
47
|
result = described_class.apply_decay(confidence: 1.0)
|
|
48
|
-
expected = 1.0 / (1.0 + 0.
|
|
48
|
+
expected = 1.0 / (1.0 + 0.5) # ~0.6667
|
|
49
49
|
expect(result).to be_within(0.0001).of(expected)
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -12,10 +12,9 @@ RSpec.describe 'Apollo Decay Cycle' do
|
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
describe '#
|
|
16
|
-
it 'returns
|
|
17
|
-
|
|
18
|
-
expect(maintenance.send(:decay_rate)).to be_within(0.0001).of(expected)
|
|
15
|
+
describe '#decay_alpha' do
|
|
16
|
+
it 'returns POWER_LAW_ALPHA when settings unavailable' do
|
|
17
|
+
expect(maintenance.send(:decay_alpha)).to eq(0.5)
|
|
19
18
|
end
|
|
20
19
|
end
|
|
21
20
|
|
|
@@ -157,6 +157,37 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
157
157
|
)
|
|
158
158
|
host.handle_ingest(content: 'test', content_type: 'fact', source_agent: 'agent-1')
|
|
159
159
|
end
|
|
160
|
+
|
|
161
|
+
it 'passes source_channel to create' do
|
|
162
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
163
|
+
hash_including(source_channel: 'slack-alerts')
|
|
164
|
+
).and_return(mock_entry)
|
|
165
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
166
|
+
source_agent: 'agent-1', source_channel: 'slack-alerts')
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'passes knowledge_domain to create from explicit param' do
|
|
170
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
171
|
+
hash_including(knowledge_domain: 'clinical')
|
|
172
|
+
).and_return(mock_entry)
|
|
173
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
174
|
+
source_agent: 'agent-1', knowledge_domain: 'clinical')
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it 'defaults knowledge_domain to first tag' do
|
|
178
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
179
|
+
hash_including(knowledge_domain: 'cardiology')
|
|
180
|
+
).and_return(mock_entry)
|
|
181
|
+
host.handle_ingest(content: 'test', content_type: 'fact',
|
|
182
|
+
tags: %w[cardiology treatment], source_agent: 'agent-1')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'defaults knowledge_domain to general when no tags and no explicit domain' do
|
|
186
|
+
expect(mock_entry_class).to receive(:create).with(
|
|
187
|
+
hash_including(knowledge_domain: 'general')
|
|
188
|
+
).and_return(mock_entry)
|
|
189
|
+
host.handle_ingest(content: 'test', content_type: 'fact', source_agent: 'agent-1')
|
|
190
|
+
end
|
|
160
191
|
end
|
|
161
192
|
|
|
162
193
|
context 'when Sequel raises an error' do
|
|
@@ -305,6 +336,13 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
305
336
|
expect(result[:success]).to be true
|
|
306
337
|
expect(result[:count]).to eq(1)
|
|
307
338
|
end
|
|
339
|
+
|
|
340
|
+
it 'passes domain to graph query builder' do
|
|
341
|
+
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
342
|
+
hash_including(domain: 'clinical')
|
|
343
|
+
).and_call_original
|
|
344
|
+
host.retrieve_relevant(query: 'treatment', domain: 'clinical')
|
|
345
|
+
end
|
|
308
346
|
end
|
|
309
347
|
end
|
|
310
348
|
|
|
@@ -391,4 +429,135 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
391
429
|
end
|
|
392
430
|
end
|
|
393
431
|
end
|
|
432
|
+
|
|
433
|
+
describe '#prepare_mesh_export' do
|
|
434
|
+
let(:host) { Object.new.extend(described_class) }
|
|
435
|
+
|
|
436
|
+
context 'when Legion::Data is not available' do
|
|
437
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
438
|
+
|
|
439
|
+
it 'returns a structured error' do
|
|
440
|
+
result = host.prepare_mesh_export(target_domain: 'clinical_care')
|
|
441
|
+
expect(result[:success]).to be false
|
|
442
|
+
expect(result[:error]).to eq('apollo_data_not_available')
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
context 'when data is available' do
|
|
447
|
+
let(:mock_conn) { double('connection') }
|
|
448
|
+
let(:mock_dataset) { double('dataset') }
|
|
449
|
+
let(:clinical_entry) do
|
|
450
|
+
{ id: 'e1', content: 'treatment', content_type: 'fact',
|
|
451
|
+
confidence: 0.8, knowledge_domain: 'clinical_care',
|
|
452
|
+
tags: ['clinical'], source_agent: 'agent-1' }
|
|
453
|
+
end
|
|
454
|
+
let(:claims_entry) do
|
|
455
|
+
{ id: 'e2', content: 'denial', content_type: 'fact',
|
|
456
|
+
confidence: 0.7, knowledge_domain: 'claims_optimization',
|
|
457
|
+
tags: ['claims'], source_agent: 'agent-2' }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
before do
|
|
461
|
+
data_mod = Module.new do
|
|
462
|
+
def self.connection; end
|
|
463
|
+
|
|
464
|
+
def self.respond_to?(method, *args)
|
|
465
|
+
method == :connection || super
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
stub_const('Legion::Data', data_mod)
|
|
469
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
470
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
471
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
472
|
+
allow(mock_dataset).to receive(:limit).and_return(mock_dataset)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
it 'filters entries by domain compatibility for clinical_care' do
|
|
476
|
+
allow(mock_dataset).to receive(:all).and_return([clinical_entry])
|
|
477
|
+
result = host.prepare_mesh_export(target_domain: 'clinical_care')
|
|
478
|
+
expect(result[:success]).to be true
|
|
479
|
+
expect(result[:target_domain]).to eq('clinical_care')
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it 'allows all domains when target is general' do
|
|
483
|
+
allow(mock_dataset).to receive(:all).and_return([clinical_entry, claims_entry])
|
|
484
|
+
result = host.prepare_mesh_export(target_domain: 'general')
|
|
485
|
+
expect(result[:success]).to be true
|
|
486
|
+
expect(result[:count]).to eq(2)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
it 'restricts claims_optimization to only claims entries' do
|
|
490
|
+
allow(mock_dataset).to receive(:all).and_return([claims_entry])
|
|
491
|
+
result = host.prepare_mesh_export(target_domain: 'claims_optimization')
|
|
492
|
+
expect(result[:success]).to be true
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
describe '#handle_erasure_request' do
|
|
498
|
+
let(:host) { Object.new.extend(described_class) }
|
|
499
|
+
|
|
500
|
+
context 'when Legion::Data is not available' do
|
|
501
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
502
|
+
|
|
503
|
+
it 'returns zero counts with error' do
|
|
504
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
505
|
+
expect(result[:deleted]).to eq(0)
|
|
506
|
+
expect(result[:redacted]).to eq(0)
|
|
507
|
+
expect(result[:error]).to eq('apollo_data_not_available')
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
context 'when data is available' do
|
|
512
|
+
let(:mock_conn) { double('connection') }
|
|
513
|
+
let(:mock_dataset) { double('dataset') }
|
|
514
|
+
|
|
515
|
+
before do
|
|
516
|
+
data_mod = Module.new do
|
|
517
|
+
def self.connection; end
|
|
518
|
+
|
|
519
|
+
def self.respond_to?(method, *args)
|
|
520
|
+
method == :connection || super
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
stub_const('Legion::Data', data_mod)
|
|
524
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
525
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
526
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
527
|
+
allow(mock_dataset).to receive(:exclude).and_return(mock_dataset)
|
|
528
|
+
allow(mock_dataset).to receive(:delete).and_return(3)
|
|
529
|
+
allow(mock_dataset).to receive(:update).and_return(2)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
it 'deletes non-confirmed entries and redacts confirmed ones' do
|
|
533
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
534
|
+
expect(result[:deleted]).to eq(3)
|
|
535
|
+
expect(result[:redacted]).to eq(2)
|
|
536
|
+
expect(result[:agent_id]).to eq('agent-dead')
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
context 'when Sequel raises an error' do
|
|
541
|
+
before do
|
|
542
|
+
data_mod = Module.new do
|
|
543
|
+
def self.connection; end
|
|
544
|
+
|
|
545
|
+
def self.respond_to?(method, *args)
|
|
546
|
+
method == :connection || super
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
stub_const('Legion::Data', data_mod)
|
|
550
|
+
allow(Legion::Data).to receive(:connection).and_return(double('conn').tap do |c|
|
|
551
|
+
allow(c).to receive(:[]).and_raise(Sequel::Error, 'db gone')
|
|
552
|
+
end)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
it 'returns zero counts with the error message' do
|
|
556
|
+
result = host.handle_erasure_request(agent_id: 'agent-dead')
|
|
557
|
+
expect(result[:deleted]).to eq(0)
|
|
558
|
+
expect(result[:redacted]).to eq(0)
|
|
559
|
+
expect(result[:error]).to eq('db gone')
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
|
394
563
|
end
|
|
@@ -37,6 +37,58 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
describe '#run_decay_cycle' do
|
|
41
|
+
let(:host) { Object.new.extend(described_class) }
|
|
42
|
+
|
|
43
|
+
context 'when Legion::Data is not available' do
|
|
44
|
+
before { hide_const('Legion::Data') if defined?(Legion::Data) }
|
|
45
|
+
|
|
46
|
+
it 'returns zero counts' do
|
|
47
|
+
result = host.run_decay_cycle
|
|
48
|
+
expect(result[:decayed]).to eq(0)
|
|
49
|
+
expect(result[:archived]).to eq(0)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
context 'when data is available' do
|
|
54
|
+
let(:mock_conn) { double('connection') }
|
|
55
|
+
let(:mock_dataset) { double('dataset') }
|
|
56
|
+
|
|
57
|
+
before do
|
|
58
|
+
data_mod = Module.new do
|
|
59
|
+
def self.connection; end
|
|
60
|
+
|
|
61
|
+
def self.respond_to?(method, *args)
|
|
62
|
+
method == :connection || super
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
stub_const('Legion::Data', data_mod)
|
|
66
|
+
allow(Legion::Data).to receive(:connection).and_return(mock_conn)
|
|
67
|
+
allow(mock_conn).to receive(:[]).with(:apollo_entries).and_return(mock_dataset)
|
|
68
|
+
allow(mock_dataset).to receive(:exclude).and_return(mock_dataset)
|
|
69
|
+
allow(mock_dataset).to receive(:where).and_return(mock_dataset)
|
|
70
|
+
allow(mock_dataset).to receive(:update).and_return(5)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'returns alpha in result hash' do
|
|
74
|
+
result = host.run_decay_cycle
|
|
75
|
+
expect(result[:alpha]).to eq(0.5)
|
|
76
|
+
expect(result).not_to have_key(:rate)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns decayed and archived counts' do
|
|
80
|
+
result = host.run_decay_cycle
|
|
81
|
+
expect(result[:decayed]).to eq(5)
|
|
82
|
+
expect(result[:archived]).to eq(5)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'accepts custom alpha parameter' do
|
|
86
|
+
result = host.run_decay_cycle(alpha: 0.3)
|
|
87
|
+
expect(result[:alpha]).to eq(0.3)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
40
92
|
describe '#check_corroboration' do
|
|
41
93
|
let(:host) { Object.new.extend(described_class) }
|
|
42
94
|
|
|
@@ -106,6 +158,34 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
106
158
|
end
|
|
107
159
|
end
|
|
108
160
|
|
|
161
|
+
context 'when candidate and confirmed share the same source_channel' do
|
|
162
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
163
|
+
let(:mock_relation_class) { double('ApolloRelation') }
|
|
164
|
+
let(:embedding) { Array.new(1536, 0.5) }
|
|
165
|
+
let(:candidate) do
|
|
166
|
+
double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
|
|
167
|
+
confidence: 0.5, source_provider: 'openai', source_channel: 'slack-alerts')
|
|
168
|
+
end
|
|
169
|
+
let(:confirmed_entry) do
|
|
170
|
+
double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding,
|
|
171
|
+
source_provider: 'claude', source_channel: 'slack-alerts')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
before do
|
|
175
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
176
|
+
stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
|
|
177
|
+
allow(mock_entry_class).to receive(:where).with(status: 'candidate')
|
|
178
|
+
.and_return(double(exclude: double(all: [candidate])))
|
|
179
|
+
allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
|
|
180
|
+
.and_return(double(exclude: double(all: [confirmed_entry])))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'does not promote even with different providers' do
|
|
184
|
+
result = host.check_corroboration
|
|
185
|
+
expect(result[:promoted]).to eq(0)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
109
189
|
context 'when candidate has different content_type than confirmed' do
|
|
110
190
|
let(:mock_entry_class) { double('ApolloEntry') }
|
|
111
191
|
let(:embedding) { Array.new(1536, 0.5) }
|
data/spec/spec_helper.rb
CHANGED
|
@@ -17,9 +17,13 @@ unless defined?(Sequel)
|
|
|
17
17
|
class Error < StandardError; end
|
|
18
18
|
|
|
19
19
|
def self.pg_array(arr) = arr
|
|
20
|
-
def self.lit(str) = str
|
|
21
|
-
Expr = Struct.new(:value)
|
|
20
|
+
def self.lit(str, *) = str
|
|
21
|
+
Expr = Struct.new(:value) do
|
|
22
|
+
def +(other) = "#{value} + #{other}"
|
|
23
|
+
def *(other) = "#{value} * #{other}"
|
|
24
|
+
end
|
|
22
25
|
def self.expr(sym) = Expr.new(sym)
|
|
26
|
+
def self.[](sym) = Expr.new(sym)
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
|