lex-apollo 0.4.28 → 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 +107 -0
- data/README.md +42 -5
- data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +63 -0
- data/lib/legion/extensions/apollo/actors/entity_watchdog.rb +1 -1
- 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 +11 -18
- data/lib/legion/extensions/apollo/helpers/entity_watchdog.rb +1 -1
- data/lib/legion/extensions/apollo/helpers/graph_query.rb +37 -51
- data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
- data/lib/legion/extensions/apollo/runners/entity_extractor.rb +1 -1
- data/lib/legion/extensions/apollo/runners/expertise.rb +3 -3
- data/lib/legion/extensions/apollo/runners/gas.rb +95 -25
- data/lib/legion/extensions/apollo/runners/knowledge.rb +177 -113
- data/lib/legion/extensions/apollo/runners/maintenance.rb +135 -36
- 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/lib/legion/extensions/apollo.rb +2 -0
- data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
- data/spec/legion/extensions/apollo/actors/entity_watchdog_spec.rb +1 -1
- 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 +31 -3
- data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
- data/spec/legion/extensions/apollo/runners/entity_extractor_spec.rb +2 -2
- 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 +129 -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 +35 -2
- data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +0 -100
|
@@ -75,17 +75,35 @@ module Legion
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
def deprecate_entry(entry_id:, reason:, **)
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
78
|
+
return { action: :deprecate, entry_id: entry_id, reason: reason } unless Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry_available?
|
|
79
|
+
|
|
80
|
+
entry_model = Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry
|
|
81
|
+
entry_model.db.transaction do
|
|
82
|
+
entry = entry_model.for_update.with_pk(entry_id)
|
|
83
|
+
next({ success: false, error: 'entry_not_found', entry_id: entry_id }) unless entry
|
|
84
|
+
|
|
85
|
+
context = entry[:source_context]
|
|
86
|
+
context = Legion::JSON.load(context) if context.is_a?(String)
|
|
87
|
+
context ||= {}
|
|
88
|
+
raise Sequel::Error, 'invalid_source_context' unless context.respond_to?(:transform_keys)
|
|
89
|
+
|
|
90
|
+
context = context.transform_keys(&:to_sym)
|
|
91
|
+
unchanged = entry.status == 'archived' && context[:deprecation_reason] == reason
|
|
92
|
+
unless unchanged
|
|
93
|
+
entry.update(status: 'archived', source_context: Legion::JSON.dump(context.merge(deprecation_reason: reason)),
|
|
94
|
+
updated_at: Time.now)
|
|
95
|
+
end
|
|
96
|
+
{ success: true, entry_id: entry.id, status: 'archived', updated: !unchanged, reason: reason }
|
|
97
|
+
end
|
|
98
|
+
rescue Sequel::Error, Legion::JSON::ParseError => e
|
|
99
|
+
handle_exception(e, level: :error, operation: 'apollo.knowledge.deprecate_entry')
|
|
100
|
+
{ success: false, error: e.message, entry_id: entry_id }
|
|
83
101
|
end
|
|
84
102
|
|
|
85
103
|
def handle_ingest(content: nil, content_type: nil, tags: [], source_agent: 'unknown', # rubocop:disable Metrics/ParameterLists
|
|
86
104
|
source_provider: nil, source_channel: nil, knowledge_domain: nil,
|
|
87
105
|
submitted_by: nil, submitted_from: nil, content_hash: nil, context: {},
|
|
88
|
-
skip: false, access_scope: 'global', **)
|
|
106
|
+
skip: false, access_scope: 'global', embedding: nil, **)
|
|
89
107
|
return { status: :skipped } if skip
|
|
90
108
|
|
|
91
109
|
identity = resolve_process_identity
|
|
@@ -103,7 +121,7 @@ module Legion
|
|
|
103
121
|
return { success: true, entry_id: existing.id, deduped: true }
|
|
104
122
|
end
|
|
105
123
|
|
|
106
|
-
embedding = embed_text(content)
|
|
124
|
+
embedding = embed_text(content) unless usable_embedding?(embedding)
|
|
107
125
|
content_type_sym = content_type.to_s
|
|
108
126
|
metadata = ingest_metadata(tags: tags, knowledge_domain: knowledge_domain, source_agent: source_agent,
|
|
109
127
|
source_provider: source_provider, source_channel: source_channel,
|
|
@@ -113,36 +131,35 @@ module Legion
|
|
|
113
131
|
identity_id: identity[:identity_id],
|
|
114
132
|
identity_canonical_name: identity[:canonical_name])
|
|
115
133
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
entry_model = Helpers::DataModels.apollo_entry
|
|
135
|
+
result = entry_model.db.transaction do
|
|
136
|
+
corroborated, existing_id = find_corroboration(
|
|
137
|
+
embedding: embedding, content_type: content_type_sym, metadata: metadata
|
|
138
|
+
)
|
|
139
|
+
unless corroborated
|
|
140
|
+
existing_id = create_candidate_entry(
|
|
141
|
+
content: content, content_type: content_type_sym, context: context,
|
|
142
|
+
metadata: metadata, content_hash: hash, embedding: embedding
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
raise Sequel::Error, 'entry_persistence_failed' unless existing_id
|
|
119
146
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
existing_id = create_candidate_entry(
|
|
124
|
-
content: content, content_type: content_type_sym, context: context,
|
|
125
|
-
metadata: metadata, content_hash: hash, embedding: embedding
|
|
147
|
+
upsert_expertise(source_agent: metadata[:source_agent], domain: metadata[:domain])
|
|
148
|
+
Helpers::DataModels.apollo_access_log.create(
|
|
149
|
+
entry_id: existing_id, agent_id: metadata[:source_agent], action: 'ingest'
|
|
126
150
|
)
|
|
151
|
+
{ success: true, entry_id: existing_id, status: corroborated ? 'corroborated' : 'candidate',
|
|
152
|
+
corroborated: corroborated, contradictions: [] }
|
|
127
153
|
end
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
Helpers::DataModels.apollo_access_log.create(
|
|
132
|
-
entry_id: existing_id, agent_id: metadata[:source_agent], action: 'ingest'
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
contradictions = detect_contradictions(existing_id, embedding, content)
|
|
136
|
-
log.debug("Apollo Knowledge.handle_ingest complete entry_id=#{existing_id} corroborated=#{corroborated} contradictions=#{contradictions.size}")
|
|
137
|
-
|
|
138
|
-
{ success: true, entry_id: existing_id, status: corroborated ? 'corroborated' : 'candidate',
|
|
139
|
-
corroborated: corroborated, contradictions: contradictions }
|
|
154
|
+
schedule_contradiction_detection(entry_id: result[:entry_id], embedding: embedding, content: content)
|
|
155
|
+
log.debug("Apollo Knowledge.handle_ingest complete entry_id=#{result[:entry_id]} corroborated=#{result[:corroborated]}")
|
|
156
|
+
result
|
|
140
157
|
rescue Sequel::Error => e
|
|
141
158
|
handle_exception(e, level: :error, operation: 'apollo.knowledge.handle_ingest')
|
|
142
159
|
{ success: false, error: e.message }
|
|
143
160
|
end
|
|
144
161
|
|
|
145
|
-
def handle_query(query:, limit: Helpers::GraphQuery.default_query_limit, # rubocop:disable Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
|
162
|
+
def handle_query(query:, limit: Helpers::GraphQuery.default_query_limit, # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/ParameterLists
|
|
146
163
|
min_confidence: Helpers::GraphQuery.default_query_min_confidence,
|
|
147
164
|
status: UNSET, tags: nil, domain: nil, agent_id: 'unknown',
|
|
148
165
|
requesting_principal_id: nil, **)
|
|
@@ -153,13 +170,18 @@ module Legion
|
|
|
153
170
|
query = normalize_text_input(query)
|
|
154
171
|
status_defaulted = status.equal?(UNSET)
|
|
155
172
|
requested_status = status_defaulted ? DEFAULT_QUERY_STATUS : status
|
|
156
|
-
log.debug("Apollo Knowledge.handle_query
|
|
173
|
+
log.debug("Apollo Knowledge.handle_query query_length=#{query.length} limit=#{limit} statuses=#{requested_status.inspect} status_defaulted=#{status_defaulted} domain=#{domain.inspect} agent_id=#{agent_id}") # rubocop:disable Layout/LineLength
|
|
157
174
|
if browse_query?(query)
|
|
158
175
|
return list_entries_chronologically(query: query, limit: limit, status: requested_status,
|
|
159
176
|
status_defaulted: status_defaulted, tags: tags, domain: domain,
|
|
160
177
|
requesting_principal_id: requesting_principal_id)
|
|
161
178
|
end
|
|
162
179
|
|
|
180
|
+
unless Helpers::DataModels.apollo_access_log_available?
|
|
181
|
+
log.warn('Apollo Knowledge.handle_query skipped: apollo_data_not_available missing=access_log')
|
|
182
|
+
return { success: false, error: 'apollo_data_not_available' }
|
|
183
|
+
end
|
|
184
|
+
|
|
163
185
|
embedding = embed_text(query)
|
|
164
186
|
if embedding.nil?
|
|
165
187
|
log.warn('Apollo Knowledge.handle_query embedding unavailable; falling back to browse query')
|
|
@@ -168,16 +190,17 @@ module Legion
|
|
|
168
190
|
requesting_principal_id: requesting_principal_id)
|
|
169
191
|
end
|
|
170
192
|
|
|
171
|
-
|
|
193
|
+
query_options = {
|
|
172
194
|
limit: limit, min_confidence: min_confidence,
|
|
173
|
-
statuses: Array(requested_status).map(&:to_s), tags: tags,
|
|
174
|
-
requesting_principal_id: requesting_principal_id
|
|
175
|
-
|
|
195
|
+
statuses: Array(requested_status).map(&:to_s), tags: Sequel.pg_array(Array(tags).map(&:to_s)),
|
|
196
|
+
domain: domain&.to_s, requesting_principal_id: requesting_principal_id&.to_i
|
|
197
|
+
}
|
|
198
|
+
sql = Helpers::GraphQuery.build_semantic_search_sql(**query_options)
|
|
176
199
|
|
|
177
200
|
db = entry_model.db
|
|
178
|
-
entries = db.fetch(sql, embedding:
|
|
201
|
+
entries = db.fetch(sql, **query_options, embedding: "[#{embedding.join(',')}]").all
|
|
179
202
|
|
|
180
|
-
entries
|
|
203
|
+
entries.reject! { |entry| entry[:distance].to_f.nan? }
|
|
181
204
|
|
|
182
205
|
entries.each do |entry|
|
|
183
206
|
boost_entry_after_query(entry_model, entry)
|
|
@@ -202,16 +225,23 @@ module Legion
|
|
|
202
225
|
def handle_traverse(entry_id:, depth: Helpers::GraphQuery.default_depth, relation_types: nil, agent_id: 'unknown', **)
|
|
203
226
|
return { success: false, error: 'apollo_data_not_available' } unless Helpers::DataModels.apollo_entry_available?
|
|
204
227
|
|
|
228
|
+
if agent_id != 'unknown' && !Helpers::DataModels.apollo_access_log_available?
|
|
229
|
+
log.warn('Apollo Knowledge.handle_traverse skipped: apollo_data_not_available missing=access_log')
|
|
230
|
+
return { success: false, error: 'apollo_data_not_available' }
|
|
231
|
+
end
|
|
232
|
+
|
|
205
233
|
log.debug("Apollo Knowledge.handle_traverse entry_id=#{entry_id} depth=#{depth} relation_types=#{Array(relation_types).join(',')} agent_id=#{agent_id}") # rubocop:disable Layout/LineLength
|
|
206
|
-
#
|
|
234
|
+
# Keep traversal limited to the supported relation vocabulary.
|
|
207
235
|
if relation_types
|
|
208
236
|
allowed = Helpers::Confidence::RELATION_TYPES
|
|
209
|
-
relation_types = relation_types.select { |
|
|
237
|
+
relation_types = relation_types.map(&:to_s).select { |type| allowed.include?(type) }
|
|
210
238
|
end
|
|
211
239
|
|
|
212
240
|
sql = Helpers::GraphQuery.build_traversal_sql(depth: depth, relation_types: relation_types)
|
|
213
241
|
db = Helpers::DataModels.apollo_entry.db
|
|
214
|
-
entries = db.fetch(sql, entry_id: entry_id
|
|
242
|
+
entries = db.fetch(sql, entry_id: entry_id, depth: depth, relation_types: relation_types,
|
|
243
|
+
spread_factor: Helpers::GraphQuery.spread_factor,
|
|
244
|
+
min_activation: Helpers::GraphQuery.min_activation).all
|
|
215
245
|
|
|
216
246
|
if entries.any? && agent_id != 'unknown'
|
|
217
247
|
Helpers::DataModels.apollo_access_log.create(
|
|
@@ -232,7 +262,7 @@ module Legion
|
|
|
232
262
|
{ success: false, error: e.message }
|
|
233
263
|
end
|
|
234
264
|
|
|
235
|
-
def redistribute_knowledge(agent_id:, min_confidence:
|
|
265
|
+
def redistribute_knowledge(agent_id:, min_confidence: settings[:query][:redistribute_min_confidence], **)
|
|
236
266
|
return { success: false, error: 'apollo_data_not_available' } unless Helpers::DataModels.apollo_entry_available?
|
|
237
267
|
|
|
238
268
|
log.debug("Apollo Knowledge.redistribute_knowledge agent_id=#{agent_id} min_confidence=#{min_confidence}")
|
|
@@ -267,7 +297,7 @@ module Legion
|
|
|
267
297
|
{ success: false, error: e.message }
|
|
268
298
|
end
|
|
269
299
|
|
|
270
|
-
def retrieve_relevant(query: nil, limit:
|
|
300
|
+
def retrieve_relevant(query: nil, limit: settings[:query][:retrieval_limit],
|
|
271
301
|
min_confidence: Helpers::GraphQuery.default_query_min_confidence,
|
|
272
302
|
tags: nil, domain: nil, skip: false, requesting_principal_id: nil, **)
|
|
273
303
|
return { status: :skipped } if skip
|
|
@@ -276,7 +306,7 @@ module Legion
|
|
|
276
306
|
|
|
277
307
|
requesting_principal_id = resolve_requesting_principal_id(requesting_principal_id)
|
|
278
308
|
query = normalize_text_input(query)
|
|
279
|
-
log.debug("Apollo Knowledge.retrieve_relevant query_length=#{query.length} limit=#{limit} min_confidence=#{min_confidence} tags=#{Array(tags).size} domain=#{domain
|
|
309
|
+
log.debug("Apollo Knowledge.retrieve_relevant query_length=#{query.length} limit=#{limit} min_confidence=#{min_confidence} tags=#{Array(tags).size} domain=#{domain.inspect}") # rubocop:disable Layout/LineLength
|
|
280
310
|
return { success: true, entries: [], count: 0 } if query.nil? || query.to_s.strip.empty?
|
|
281
311
|
|
|
282
312
|
embedding = embed_text(query)
|
|
@@ -285,15 +315,16 @@ module Legion
|
|
|
285
315
|
return { success: true, entries: [], count: 0, degraded: :no_embedding }
|
|
286
316
|
end
|
|
287
317
|
|
|
288
|
-
|
|
318
|
+
query_options = {
|
|
289
319
|
limit: limit, min_confidence: min_confidence,
|
|
290
|
-
statuses: %w[confirmed candidate], tags: tags,
|
|
291
|
-
requesting_principal_id: requesting_principal_id
|
|
292
|
-
|
|
320
|
+
statuses: %w[confirmed candidate], tags: Sequel.pg_array(Array(tags).map(&:to_s)),
|
|
321
|
+
domain: domain&.to_s, requesting_principal_id: requesting_principal_id&.to_i
|
|
322
|
+
}
|
|
323
|
+
sql = Helpers::GraphQuery.build_semantic_search_sql(**query_options)
|
|
293
324
|
|
|
294
325
|
db = Helpers::DataModels.apollo_entry.db
|
|
295
|
-
entries = db.fetch(sql, embedding:
|
|
296
|
-
entries
|
|
326
|
+
entries = db.fetch(sql, **query_options, embedding: "[#{embedding.join(',')}]").all
|
|
327
|
+
entries.reject! { |entry| entry[:distance].to_f.nan? }
|
|
297
328
|
|
|
298
329
|
entries.each do |entry|
|
|
299
330
|
Helpers::DataModels.apollo_entry.where(id: entry[:id]).update(
|
|
@@ -316,7 +347,7 @@ module Legion
|
|
|
316
347
|
{ success: false, error: e.message }
|
|
317
348
|
end
|
|
318
349
|
|
|
319
|
-
def prepare_mesh_export(target_domain:, min_confidence:
|
|
350
|
+
def prepare_mesh_export(target_domain:, min_confidence: settings[:query][:mesh_export_min_confidence], limit: settings[:query][:mesh_export_limit], **) # rubocop:disable Layout/LineLength
|
|
320
351
|
unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
321
352
|
return { success: false, error: 'apollo_data_not_available' }
|
|
322
353
|
end
|
|
@@ -391,10 +422,14 @@ module Legion
|
|
|
391
422
|
return { success: false, error: 'content is required' }
|
|
392
423
|
end
|
|
393
424
|
|
|
394
|
-
|
|
425
|
+
missing = []
|
|
426
|
+
missing << 'entry' unless Helpers::DataModels.apollo_entry_available?
|
|
427
|
+
missing << 'expertise' unless Helpers::DataModels.apollo_expertise_available?
|
|
428
|
+
missing << 'access_log' unless Helpers::DataModels.apollo_access_log_available?
|
|
429
|
+
return nil if missing.empty?
|
|
395
430
|
|
|
396
431
|
log.warn('[apollo][handle_ingest] early-return: apollo_data_not_available ' \
|
|
397
|
-
"content_type=#{content_type}")
|
|
432
|
+
"content_type=#{content_type} missing=#{missing.join(',')}")
|
|
398
433
|
{ success: false, error: 'apollo_data_not_available' }
|
|
399
434
|
end
|
|
400
435
|
|
|
@@ -409,7 +444,7 @@ module Legion
|
|
|
409
444
|
log.debug("Apollo Knowledge.embed_text text_length=#{text.length}")
|
|
410
445
|
result = Legion::LLM::Call::Embeddings.generate(text: text)
|
|
411
446
|
vector = result.is_a?(Hash) ? result[:vector] : result
|
|
412
|
-
if
|
|
447
|
+
if usable_embedding?(vector)
|
|
413
448
|
log.debug("Apollo Knowledge.embed_text vector_dimensions=#{vector.length}")
|
|
414
449
|
vector
|
|
415
450
|
else
|
|
@@ -421,6 +456,11 @@ module Legion
|
|
|
421
456
|
nil
|
|
422
457
|
end
|
|
423
458
|
|
|
459
|
+
def usable_embedding?(vector)
|
|
460
|
+
vector.is_a?(Array) && vector.any? { |value| value != 0.0 } &&
|
|
461
|
+
vector.all? { |value| value.is_a?(Numeric) && value.real? && value.finite? }
|
|
462
|
+
end
|
|
463
|
+
|
|
424
464
|
def normalize_text_input(value)
|
|
425
465
|
result = if defined?(Legion::Apollo) && Legion::Apollo.respond_to?(:normalize_text_input, true)
|
|
426
466
|
Legion::Apollo.send(:normalize_text_input, value)
|
|
@@ -479,51 +519,47 @@ module Legion
|
|
|
479
519
|
source_channel: truncate_for_column(source_channel, 100),
|
|
480
520
|
submitted_by: truncate_for_column(submitted_by, 255),
|
|
481
521
|
submitted_from: truncate_for_column(submitted_from, 255),
|
|
482
|
-
access_scope: access_scope || 'global',
|
|
522
|
+
access_scope: (access_scope || 'global').to_s,
|
|
483
523
|
identity_principal_id: identity_principal_id.is_a?(Integer) ? identity_principal_id : nil,
|
|
484
524
|
identity_id: identity_id.is_a?(Integer) ? identity_id : nil,
|
|
485
525
|
identity_canonical_name: identity_canonical_name&.to_s&.slice(0, 255) }
|
|
486
526
|
end
|
|
487
527
|
|
|
488
528
|
def create_candidate_entry(content:, content_type:, context:, metadata:, content_hash:, embedding:)
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
529
|
+
entry_model = Helpers::DataModels.apollo_entry
|
|
530
|
+
new_entry = entry_model.db.transaction(savepoint: true) do
|
|
531
|
+
entry_model.create(
|
|
532
|
+
content: content,
|
|
533
|
+
content_type: content_type,
|
|
534
|
+
confidence: Helpers::Confidence.initial_confidence,
|
|
535
|
+
source_agent: metadata[:source_agent],
|
|
536
|
+
source_provider: metadata[:source_provider],
|
|
537
|
+
source_channel: metadata[:source_channel],
|
|
538
|
+
source_context: json_dump(context.is_a?(Hash) ? context : {}),
|
|
539
|
+
tags: Sequel.pg_array(metadata[:tags]),
|
|
540
|
+
status: 'candidate',
|
|
541
|
+
knowledge_domain: metadata[:domain],
|
|
542
|
+
submitted_by: metadata[:submitted_by],
|
|
543
|
+
submitted_from: metadata[:submitted_from],
|
|
544
|
+
content_hash: content_hash,
|
|
545
|
+
access_scope: metadata[:access_scope] || 'global',
|
|
546
|
+
identity_principal_id: metadata[:identity_principal_id],
|
|
547
|
+
identity_id: metadata[:identity_id],
|
|
548
|
+
identity_canonical_name: metadata[:identity_canonical_name],
|
|
549
|
+
embedding: embedding ? Sequel.lit('?::vector', "[#{embedding.join(',')}]") : nil
|
|
550
|
+
)
|
|
551
|
+
end
|
|
509
552
|
log.info("Apollo Knowledge.handle_ingest created entry_id=#{new_entry.id} status=candidate domain=#{metadata[:domain]} source_agent=#{metadata[:source_agent]} access_scope=#{metadata[:access_scope]}") # rubocop:disable Layout/LineLength
|
|
510
553
|
new_entry.id
|
|
511
554
|
rescue Sequel::UniqueConstraintViolation => e
|
|
512
|
-
|
|
513
|
-
# dedup check and this insert. Fetch and return the winner's id so the caller can
|
|
514
|
-
# continue normally (access log, contradiction detection, etc.).
|
|
515
|
-
winner = Helpers::DataModels.apollo_entry
|
|
516
|
-
.where(content_hash: content_hash)
|
|
517
|
-
.exclude(status: 'archived')
|
|
518
|
-
.first
|
|
555
|
+
winner = entry_model.where(content_hash: content_hash).exclude(status: 'archived').first
|
|
519
556
|
if winner
|
|
520
|
-
log.warn("Apollo Knowledge.create_candidate_entry race_dedup entry_id=#{winner.id} content_hash=#{content_hash}
|
|
521
|
-
winner.id
|
|
522
|
-
else
|
|
523
|
-
handle_exception(e, level: :warn, handled: true, operation: 'apollo.knowledge.create_candidate_entry',
|
|
524
|
-
content_hash: content_hash)
|
|
525
|
-
nil
|
|
557
|
+
log.warn("Apollo Knowledge.create_candidate_entry race_dedup entry_id=#{winner.id} content_hash=#{content_hash} error=#{e.message}")
|
|
558
|
+
return winner.id
|
|
526
559
|
end
|
|
560
|
+
|
|
561
|
+
handle_exception(e, level: :error, operation: 'apollo.knowledge.create_candidate_entry', content_hash: content_hash)
|
|
562
|
+
raise
|
|
527
563
|
end
|
|
528
564
|
|
|
529
565
|
def browse_query?(query)
|
|
@@ -539,14 +575,9 @@ module Legion
|
|
|
539
575
|
dataset = dataset.where(knowledge_domain: domain) if domain && !domain.to_s.empty?
|
|
540
576
|
|
|
541
577
|
if requesting_principal_id
|
|
542
|
-
pid = requesting_principal_id.to_i
|
|
543
578
|
dataset = dataset.where(
|
|
544
|
-
Sequel.lit(
|
|
545
|
-
|
|
546
|
-
"OR (access_scope = 'private' AND (identity_principal_id = ? OR identity_id IN (SELECT id FROM identities WHERE principal_id = ?))) " \
|
|
547
|
-
"OR (access_scope = 'team' AND EXISTS (SELECT 1 FROM identity_group_memberships igm1 JOIN identity_group_memberships igm2 ON igm1.group_id = igm2.group_id WHERE igm1.principal_id = ? AND igm2.principal_id = identity_principal_id)))", # rubocop:disable Layout/LineLength
|
|
548
|
-
pid, pid, pid
|
|
549
|
-
)
|
|
579
|
+
Sequel.lit(Legion::Extensions::Apollo::Helpers::GraphQuery.access_scope_sql,
|
|
580
|
+
requesting_principal_id: requesting_principal_id.to_i)
|
|
550
581
|
)
|
|
551
582
|
end
|
|
552
583
|
|
|
@@ -596,19 +627,37 @@ module Legion
|
|
|
596
627
|
Array(allowed)
|
|
597
628
|
end
|
|
598
629
|
|
|
630
|
+
def contradiction_detection_enabled?
|
|
631
|
+
settings[:contradiction][:enabled] == true
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def schedule_contradiction_detection(entry_id:, embedding:, content:)
|
|
635
|
+
return unless contradiction_detection_enabled?
|
|
636
|
+
return unless embedding && Helpers::DataModels.apollo_entry_available?
|
|
637
|
+
|
|
638
|
+
Legion::Extensions::Apollo::Actor::ContradictionScanner.enqueue(
|
|
639
|
+
entry_id: entry_id, embedding: embedding, content: content
|
|
640
|
+
)
|
|
641
|
+
log.debug("Apollo Knowledge.schedule_contradiction_detection enqueued entry_id=#{entry_id}")
|
|
642
|
+
rescue StandardError => e
|
|
643
|
+
handle_exception(e, level: :warn, operation: 'apollo.knowledge.schedule_contradiction_detection')
|
|
644
|
+
end
|
|
645
|
+
|
|
599
646
|
def detect_contradictions(entry_id, embedding, content)
|
|
600
647
|
return [] unless embedding && Helpers::DataModels.apollo_entry_available?
|
|
601
648
|
|
|
602
|
-
sim_limit =
|
|
603
|
-
sim_threshold =
|
|
604
|
-
rel_weight =
|
|
649
|
+
sim_limit = settings[:contradiction][:similar_limit]
|
|
650
|
+
sim_threshold = settings[:contradiction][:similarity_threshold]
|
|
651
|
+
rel_weight = settings[:contradiction][:relation_weight]
|
|
605
652
|
|
|
606
653
|
db = Helpers::DataModels.apollo_entry.db
|
|
607
654
|
log.debug("Apollo Knowledge.detect_contradictions entry_id=#{entry_id} similar_limit=#{sim_limit} threshold=#{sim_threshold}")
|
|
608
655
|
similar = db.fetch(
|
|
609
|
-
|
|
656
|
+
'SELECT id, content, embedding FROM apollo_entries WHERE id != :entry_id AND embedding IS NOT NULL ' \
|
|
657
|
+
'ORDER BY embedding <=> :embedding LIMIT :limit',
|
|
610
658
|
entry_id: entry_id,
|
|
611
|
-
embedding: Sequel.lit("
|
|
659
|
+
embedding: Sequel.lit('?::vector', "[#{embedding.join(',')}]"),
|
|
660
|
+
limit: sim_limit
|
|
612
661
|
).all
|
|
613
662
|
|
|
614
663
|
contradictions = []
|
|
@@ -620,20 +669,25 @@ module Legion
|
|
|
620
669
|
next unless sim > sim_threshold
|
|
621
670
|
next unless llm_detects_conflict?(content, existing[:content])
|
|
622
671
|
|
|
623
|
-
Helpers::DataModels.apollo_relation.create(
|
|
624
|
-
from_entry_id: entry_id, to_entry_id: existing[:id],
|
|
625
|
-
relation_type: 'contradicts', source_agent: 'system:contradiction',
|
|
626
|
-
weight: rel_weight
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
Helpers::DataModels.apollo_entry.where(id: [entry_id, existing[:id]]).update(status: 'disputed')
|
|
630
672
|
contradictions << existing[:id]
|
|
631
673
|
end
|
|
674
|
+
unless contradictions.empty?
|
|
675
|
+
db.transaction do
|
|
676
|
+
contradictions.each do |contradicted_id|
|
|
677
|
+
Legion::Extensions::Apollo::Helpers::DataModels.apollo_relation.create(
|
|
678
|
+
from_entry_id: entry_id, to_entry_id: contradicted_id,
|
|
679
|
+
relation_type: 'contradicts', source_agent: 'system:contradiction', weight: rel_weight
|
|
680
|
+
)
|
|
681
|
+
end
|
|
682
|
+
Legion::Extensions::Apollo::Helpers::DataModels.apollo_entry
|
|
683
|
+
.where(id: [entry_id, *contradictions]).update(status: 'disputed')
|
|
684
|
+
end
|
|
685
|
+
end
|
|
632
686
|
log.info("Apollo Knowledge.detect_contradictions entry_id=#{entry_id} contradictions=#{contradictions.size}") if contradictions.any?
|
|
633
687
|
contradictions
|
|
634
688
|
rescue Sequel::Error => e
|
|
635
689
|
handle_exception(e, level: :error, operation: 'apollo.knowledge.detect_contradictions')
|
|
636
|
-
|
|
690
|
+
raise
|
|
637
691
|
end
|
|
638
692
|
|
|
639
693
|
def llm_detects_conflict?(content_a, content_b)
|
|
@@ -655,15 +709,25 @@ module Legion
|
|
|
655
709
|
false
|
|
656
710
|
end
|
|
657
711
|
|
|
658
|
-
def find_corroboration(embedding
|
|
712
|
+
def find_corroboration(embedding:, content_type:, metadata:)
|
|
659
713
|
return [false, nil] unless embedding
|
|
714
|
+
return [false, nil] if metadata[:access_scope] != 'global' && metadata[:identity_principal_id].nil?
|
|
660
715
|
|
|
661
|
-
|
|
662
|
-
|
|
716
|
+
source_agent = metadata[:source_agent]
|
|
717
|
+
source_channel = metadata[:source_channel]
|
|
718
|
+
scan_limit = settings[:corroboration][:scan_limit]
|
|
719
|
+
# Candidate evidence is retained for the bounded maintenance bootstrap scan.
|
|
663
720
|
existing = Helpers::DataModels.apollo_entry
|
|
664
|
-
.where(content_type:
|
|
721
|
+
.where(content_type: content_type, knowledge_domain: metadata[:domain],
|
|
722
|
+
access_scope: metadata[:access_scope], status: 'confirmed')
|
|
665
723
|
.exclude(embedding: nil)
|
|
666
|
-
|
|
724
|
+
if metadata[:access_scope] != 'global'
|
|
725
|
+
existing = existing.where(
|
|
726
|
+
Sequel.lit(Legion::Extensions::Apollo::Helpers::GraphQuery.access_scope_sql,
|
|
727
|
+
requesting_principal_id: metadata[:identity_principal_id])
|
|
728
|
+
)
|
|
729
|
+
end
|
|
730
|
+
existing = existing.limit(scan_limit)
|
|
667
731
|
|
|
668
732
|
existing.each do |entry|
|
|
669
733
|
next unless entry.embedding
|
|
@@ -671,7 +735,7 @@ module Legion
|
|
|
671
735
|
sim = Helpers::Similarity.cosine_similarity(vec_a: embedding, vec_b: entry.embedding)
|
|
672
736
|
next unless Helpers::Similarity.above_corroboration_threshold?(similarity: sim)
|
|
673
737
|
|
|
674
|
-
same_provider_wt =
|
|
738
|
+
same_provider_wt = settings[:corroboration][:same_provider_weight]
|
|
675
739
|
weight = same_source_provider?(source_agent, entry) ? same_provider_wt : 1.0
|
|
676
740
|
|
|
677
741
|
# Reject corroboration entirely if same channel (same data source)
|
|
@@ -716,7 +780,7 @@ module Legion
|
|
|
716
780
|
else
|
|
717
781
|
Helpers::DataModels.apollo_expertise.create(
|
|
718
782
|
agent_id: source_agent, domain: domain,
|
|
719
|
-
proficiency:
|
|
783
|
+
proficiency: settings[:expertise][:initial_proficiency],
|
|
720
784
|
entry_count: 1, last_active_at: Time.now
|
|
721
785
|
)
|
|
722
786
|
end
|