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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +63 -0
  5. data/lib/legion/extensions/apollo/actors/entity_watchdog.rb +1 -1
  6. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  7. data/lib/legion/extensions/apollo/api.rb +19 -9
  8. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  9. data/lib/legion/extensions/apollo/helpers/confidence.rb +11 -18
  10. data/lib/legion/extensions/apollo/helpers/entity_watchdog.rb +1 -1
  11. data/lib/legion/extensions/apollo/helpers/graph_query.rb +37 -51
  12. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  13. data/lib/legion/extensions/apollo/runners/entity_extractor.rb +1 -1
  14. data/lib/legion/extensions/apollo/runners/expertise.rb +3 -3
  15. data/lib/legion/extensions/apollo/runners/gas.rb +95 -25
  16. data/lib/legion/extensions/apollo/runners/knowledge.rb +177 -113
  17. data/lib/legion/extensions/apollo/runners/maintenance.rb +135 -36
  18. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  19. data/lib/legion/extensions/apollo/transport.rb +20 -0
  20. data/lib/legion/extensions/apollo/version.rb +1 -1
  21. data/lib/legion/extensions/apollo.rb +2 -0
  22. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  23. data/spec/legion/extensions/apollo/actors/entity_watchdog_spec.rb +1 -1
  24. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  25. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  26. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  27. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  28. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +31 -3
  29. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  30. data/spec/legion/extensions/apollo/runners/entity_extractor_spec.rb +2 -2
  31. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  32. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  33. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  34. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +129 -57
  35. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  36. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  37. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  38. metadata +35 -2
  39. 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
- action: :deprecate,
80
- entry_id: entry_id,
81
- reason: reason
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
- corroborated, existing_id = find_corroboration(
117
- embedding, content_type_sym, metadata[:source_agent], metadata[:source_channel]
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
- if corroborated
121
- log.info("Apollo Knowledge.handle_ingest corroborated entry_id=#{existing_id} source_agent=#{metadata[:source_agent]}")
122
- else
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
- upsert_expertise(source_agent: metadata[:source_agent], domain: metadata[:domain])
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 mode=#{browse_query?(query) ? 'browse' : 'semantic'} query_length=#{query.length} limit=#{limit} statuses=#{Array(requested_status).join(',')} status_defaulted=#{status_defaulted} tags=#{Array(tags).size} domain=#{domain || 'nil'} agent_id=#{agent_id}") # rubocop:disable Layout/LineLength
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
- sql = Helpers::GraphQuery.build_semantic_search_sql(
193
+ query_options = {
172
194
  limit: limit, min_confidence: min_confidence,
173
- statuses: Array(requested_status).map(&:to_s), tags: tags, domain: domain,
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: Sequel.lit("'[#{embedding.join(',')}]'::vector")).all
201
+ entries = db.fetch(sql, **query_options, embedding: "[#{embedding.join(',')}]").all
179
202
 
180
- entries = entries.reject { |e| e[:distance].respond_to?(:nan?) && e[:distance].nan? }
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
- # Whitelist relation_types to prevent SQL injection (they are string-interpolated in build_traversal_sql)
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 { |t| allowed.include?(t.to_s) }
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).all
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: Helpers::Confidence.apollo_setting(:query, :redistribute_min_confidence, default: 0.5), **)
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: Helpers::Confidence.apollo_setting(:query, :retrieval_limit, default: 5),
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 || 'nil'}") # rubocop:disable Layout/LineLength
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
- sql = Helpers::GraphQuery.build_semantic_search_sql(
318
+ query_options = {
289
319
  limit: limit, min_confidence: min_confidence,
290
- statuses: %w[confirmed candidate], tags: tags, domain: domain,
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: Sequel.lit("'[#{embedding.join(',')}]'::vector")).all
296
- entries = entries.reject { |e| e[:distance].respond_to?(:nan?) && e[:distance].nan? }
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: Helpers::Confidence.apollo_setting(:query, :mesh_export_min_confidence, default: 0.5), limit: Helpers::Confidence.apollo_setting(:query, :mesh_export_limit, default: 100), **) # rubocop:disable Layout/LineLength
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
- return nil if Helpers::DataModels.apollo_entry_available?
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 vector.is_a?(Array) && vector.any? && vector.any? { |v| v != 0.0 }
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
- new_entry = Helpers::DataModels.apollo_entry.create(
490
- content: content,
491
- content_type: content_type,
492
- confidence: Helpers::Confidence.initial_confidence,
493
- source_agent: metadata[:source_agent],
494
- source_provider: metadata[:source_provider],
495
- source_channel: metadata[:source_channel],
496
- source_context: json_dump(context.is_a?(Hash) ? context : {}),
497
- tags: Sequel.pg_array(metadata[:tags]),
498
- status: 'candidate',
499
- knowledge_domain: metadata[:domain],
500
- submitted_by: metadata[:submitted_by],
501
- submitted_from: metadata[:submitted_from],
502
- content_hash: content_hash,
503
- access_scope: metadata[:access_scope] || 'global',
504
- identity_principal_id: metadata[:identity_principal_id],
505
- identity_id: metadata[:identity_id],
506
- identity_canonical_name: metadata[:identity_canonical_name],
507
- embedding: embedding ? Sequel.lit("'[#{embedding.join(',')}]'::vector") : nil
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
- # Race condition: another thread/process inserted the same content_hash between our
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} source_agent=#{metadata[:source_agent]}") # rubocop:disable Layout/LineLength
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
- "(access_scope = 'global' " \
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 = Helpers::Confidence.apollo_setting(:contradiction, :similar_limit, default: 10)
603
- sim_threshold = Helpers::Confidence.apollo_setting(:contradiction, :similarity_threshold, default: 0.7)
604
- rel_weight = Helpers::Confidence.apollo_setting(:contradiction, :relation_weight, default: 0.8)
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
- "SELECT id, content, embedding FROM apollo_entries WHERE id != :entry_id AND embedding IS NOT NULL ORDER BY embedding <=> :embedding LIMIT #{sim_limit}", # rubocop:disable Layout/LineLength
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("'[#{embedding.join(',')}]'::vector")
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, content_type_sym, source_agent, source_channel = nil)
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
- scan_limit = Helpers::Confidence.apollo_setting(:corroboration, :scan_limit, default: 50)
662
- log.debug("Apollo Knowledge.find_corroboration content_type=#{content_type_sym} source_agent=#{source_agent} source_channel=#{source_channel || 'nil'} scan_limit=#{scan_limit}") # rubocop:disable Layout/LineLength
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: content_type_sym)
721
+ .where(content_type: content_type, knowledge_domain: metadata[:domain],
722
+ access_scope: metadata[:access_scope], status: 'confirmed')
665
723
  .exclude(embedding: nil)
666
- .limit(scan_limit)
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 = Helpers::Confidence.apollo_setting(:corroboration, :same_provider_weight, default: 0.5)
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: Helpers::Confidence.apollo_setting(:expertise, :initial_proficiency, default: 0.0),
783
+ proficiency: settings[:expertise][:initial_proficiency],
720
784
  entry_count: 1, last_active_at: Time.now
721
785
  )
722
786
  end