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
|
@@ -122,11 +122,10 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
describe '#deprecate_entry' do
|
|
125
|
-
it '
|
|
125
|
+
it 'retains the client action envelope without claiming a completed service mutation' do
|
|
126
126
|
result = runner.deprecate_entry(entry_id: 'uuid-123', reason: 'outdated')
|
|
127
127
|
expect(result[:action]).to eq(:deprecate)
|
|
128
|
-
expect(result
|
|
129
|
-
expect(result[:reason]).to eq('outdated')
|
|
128
|
+
expect(result).not_to have_key(:success)
|
|
130
129
|
end
|
|
131
130
|
end
|
|
132
131
|
|
|
@@ -169,6 +168,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
169
168
|
allow(dedup_chain).to receive(:exclude).with(status: 'archived').and_return(double(first: nil))
|
|
170
169
|
allow(mock_entry_class).to receive(:db).and_return(mock_db)
|
|
171
170
|
allow(mock_db).to receive(:fetch).and_return(double(all: []))
|
|
171
|
+
allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
|
|
172
172
|
allow(mock_entry_class).to receive(:create).and_return(mock_entry)
|
|
173
173
|
allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
|
|
174
174
|
allow(mock_expertise_class).to receive(:create)
|
|
@@ -345,12 +345,28 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
345
345
|
expect(result[:success]).to be true
|
|
346
346
|
expect(result[:entry_id]).to eq('uuid-race-winner')
|
|
347
347
|
end
|
|
348
|
+
|
|
349
|
+
it 'returns a failure when a rolled-back uniqueness conflict has no winner' do
|
|
350
|
+
allow(mock_entry_class).to receive(:create)
|
|
351
|
+
.and_raise(Sequel::UniqueConstraintViolation, 'duplicate key value violates unique constraint')
|
|
352
|
+
collision_dataset = double('collision_dataset')
|
|
353
|
+
allow(mock_entry_class).to receive(:where).with(content_hash: anything).and_return(collision_dataset)
|
|
354
|
+
allow(collision_dataset).to receive(:exclude).with(status: 'archived').and_return(collision_dataset)
|
|
355
|
+
allow(collision_dataset).to receive(:first).and_return(nil, nil)
|
|
356
|
+
|
|
357
|
+
result = host.handle_ingest(content: 'lost race content', content_type: 'fact',
|
|
358
|
+
source_agent: 'agent-1', content_hash: 'no-winner')
|
|
359
|
+
|
|
360
|
+
expect(result).to include(success: false, error: 'duplicate key value violates unique constraint')
|
|
361
|
+
end
|
|
348
362
|
end
|
|
349
363
|
end
|
|
350
364
|
|
|
351
365
|
context 'when Sequel raises an error' do
|
|
352
366
|
before do
|
|
353
367
|
stub_const('Legion::Data::Model::ApolloEntry', Class.new)
|
|
368
|
+
stub_const('Legion::Data::Model::ApolloExpertise', Class.new)
|
|
369
|
+
stub_const('Legion::Data::Model::ApolloAccessLog', Class.new)
|
|
354
370
|
allow(Legion::LLM::Call::Embeddings).to receive(:generate)
|
|
355
371
|
.and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
|
|
356
372
|
allow(Legion::Data::Model::ApolloEntry).to receive(:where)
|
|
@@ -376,6 +392,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
376
392
|
let(:mock_expertise_class2) { double('ApolloExpertise2') }
|
|
377
393
|
let(:mock_access_log_class2) { double('ApolloAccessLog2') }
|
|
378
394
|
let(:mock_entry2) { double('entry2', id: 99, embedding: nil) }
|
|
395
|
+
let(:mock_db2) { double('db2') }
|
|
379
396
|
|
|
380
397
|
before do
|
|
381
398
|
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class2)
|
|
@@ -384,6 +401,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
384
401
|
scoped_ds = double('scoped_ds2', first: nil, where: double('scoped_ds2b', first: nil))
|
|
385
402
|
allow(mock_entry_class2).to receive(:where).and_return(scoped_ds)
|
|
386
403
|
allow(scoped_ds).to receive(:exclude).and_return(scoped_ds)
|
|
404
|
+
allow(mock_entry_class2).to receive(:db).and_return(mock_db2)
|
|
405
|
+
allow(mock_db2).to receive(:transaction) { |**_options, &block| block.call }
|
|
387
406
|
allow(mock_expertise_class2).to receive(:where).and_return(double(first: nil))
|
|
388
407
|
allow(mock_expertise_class2).to receive(:create)
|
|
389
408
|
allow(mock_access_log_class2).to receive(:create)
|
|
@@ -433,6 +452,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
433
452
|
let(:mock_access_log_class3) { double('ApolloAccessLog3') }
|
|
434
453
|
let(:mock_entry_a) { double('entry_a', id: 7, embedding: nil) }
|
|
435
454
|
let(:mock_entry_b) { double('entry_b', id: 8, embedding: nil) }
|
|
455
|
+
let(:mock_db3) { double('db3') }
|
|
436
456
|
|
|
437
457
|
before do
|
|
438
458
|
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class3)
|
|
@@ -449,6 +469,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
449
469
|
allow(scoped_ds3).to receive(:where).and_return(double('scoped_ds3b', first: nil))
|
|
450
470
|
allow(scoped_ds3).to receive(:exclude).and_return(scoped_ds3)
|
|
451
471
|
allow(mock_entry_class3).to receive(:where).and_return(scoped_ds3)
|
|
472
|
+
allow(mock_entry_class3).to receive(:db).and_return(mock_db3)
|
|
473
|
+
allow(mock_db3).to receive(:transaction) { |**_options, &block| block.call }
|
|
452
474
|
# two different principals each get their own entry
|
|
453
475
|
allow(mock_entry_class3).to receive(:create).and_return(mock_entry_a, mock_entry_b)
|
|
454
476
|
end
|
|
@@ -492,6 +514,89 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
492
514
|
end
|
|
493
515
|
end
|
|
494
516
|
end
|
|
517
|
+
|
|
518
|
+
context 'contradiction detection flag' do
|
|
519
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
520
|
+
let(:mock_relation_class) { double('ApolloRelation') }
|
|
521
|
+
let(:mock_expertise_class) { double('ApolloExpertise') }
|
|
522
|
+
let(:mock_access_log_class) { double('ApolloAccessLog') }
|
|
523
|
+
let(:mock_entry) { double('entry', id: 'uuid-new', embedding: nil) }
|
|
524
|
+
let(:empty_dataset) { double('dataset', each: nil) }
|
|
525
|
+
let(:mock_db) { double('db') }
|
|
526
|
+
|
|
527
|
+
before do
|
|
528
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
529
|
+
stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
|
|
530
|
+
stub_const('Legion::Data::Model::ApolloExpertise', mock_expertise_class)
|
|
531
|
+
stub_const('Legion::Data::Model::ApolloAccessLog', mock_access_log_class)
|
|
532
|
+
allow(Legion::LLM::Call::Embeddings).to receive(:generate)
|
|
533
|
+
.and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
|
|
534
|
+
allow(mock_entry_class).to receive(:where).and_return(double(exclude: double(limit: empty_dataset)))
|
|
535
|
+
dedup_chain = double('dedup_chain')
|
|
536
|
+
allow(mock_entry_class).to receive(:where).with(content_hash: anything).and_return(dedup_chain)
|
|
537
|
+
allow(dedup_chain).to receive(:exclude).with(status: 'archived').and_return(double(first: nil))
|
|
538
|
+
allow(mock_entry_class).to receive(:db).and_return(mock_db)
|
|
539
|
+
allow(mock_db).to receive(:fetch).and_return(double(all: []))
|
|
540
|
+
allow(mock_db).to receive(:transaction) { |**_options, &block| block.call }
|
|
541
|
+
allow(mock_entry_class).to receive(:create).and_return(mock_entry)
|
|
542
|
+
allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
|
|
543
|
+
allow(mock_expertise_class).to receive(:create)
|
|
544
|
+
allow(mock_access_log_class).to receive(:create)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
it 'defaults contradiction detection to disabled' do
|
|
548
|
+
expect(Legion::Extensions::Apollo.settings[:contradiction][:enabled]).to be false
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
it 'does not call detect_contradictions when flag is false' do
|
|
552
|
+
expect(host).not_to receive(:detect_contradictions)
|
|
553
|
+
result = host.handle_ingest(content: 'test fact', content_type: 'fact', source_agent: 'agent-1')
|
|
554
|
+
expect(result[:success]).to be true
|
|
555
|
+
expect(result[:contradictions]).to eq([])
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
it 'returns stable contradictions key as empty array when disabled' do
|
|
559
|
+
result = host.handle_ingest(content: 'test content', content_type: 'fact', source_agent: 'agent-1')
|
|
560
|
+
expect(result).to have_key(:contradictions)
|
|
561
|
+
expect(result[:contradictions]).to eq([])
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
context 'when enabled' do
|
|
565
|
+
before do
|
|
566
|
+
Legion::Settings[:extensions][:apollo][:contradiction][:enabled] = true
|
|
567
|
+
stub_const('Legion::Extensions::Apollo::Actor::ContradictionScanner',
|
|
568
|
+
Class.new do
|
|
569
|
+
@queue = Queue.new
|
|
570
|
+
class << self
|
|
571
|
+
attr_reader :queue
|
|
572
|
+
|
|
573
|
+
def enqueue(entry_id:, embedding:, content:)
|
|
574
|
+
queue.push({ entry_id: entry_id, embedding: embedding, content: content })
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def pending_count = queue.size
|
|
578
|
+
|
|
579
|
+
def drain
|
|
580
|
+
items = []
|
|
581
|
+
items << queue.pop until queue.empty?
|
|
582
|
+
items
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end)
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
after do
|
|
589
|
+
Legion::Settings[:extensions][:apollo][:contradiction][:enabled] = false
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
it 'enqueues contradiction work to the actor instead of running inline' do
|
|
593
|
+
result = host.handle_ingest(content: 'enabled test', content_type: 'fact', source_agent: 'agent-1')
|
|
594
|
+
expect(result[:success]).to be true
|
|
595
|
+
expect(result[:contradictions]).to eq([])
|
|
596
|
+
expect(Legion::Extensions::Apollo::Actor::ContradictionScanner.pending_count).to eq(1)
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
end
|
|
495
600
|
end
|
|
496
601
|
|
|
497
602
|
describe '#handle_query' do
|
|
@@ -507,6 +612,25 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
507
612
|
end
|
|
508
613
|
end
|
|
509
614
|
|
|
615
|
+
context 'when semantic query access logging is unavailable' do
|
|
616
|
+
let(:mock_entry_class) { double('ApolloEntry') }
|
|
617
|
+
|
|
618
|
+
before do
|
|
619
|
+
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
620
|
+
hide_const('Legion::Data::Model::ApolloAccessLog') if defined?(Legion::Data::Model::ApolloAccessLog)
|
|
621
|
+
hide_const('Legion::Data::Model::Apollo::AccessLog') if defined?(Legion::Data::Model::Apollo::AccessLog)
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
it 'fails before embedding, lookup, or confidence mutation' do
|
|
625
|
+
expect(mock_entry_class).not_to receive(:db)
|
|
626
|
+
expect(Legion::LLM::Call::Embeddings).not_to receive(:generate)
|
|
627
|
+
|
|
628
|
+
expect(host.handle_query(query: 'semantic query')).to include(
|
|
629
|
+
success: false, error: 'apollo_data_not_available'
|
|
630
|
+
)
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
510
634
|
context 'when Apollo data is available' do
|
|
511
635
|
let(:mock_entry_class) { double('ApolloEntry') }
|
|
512
636
|
let(:mock_access_log_class) { double('ApolloAccessLog') }
|
|
@@ -555,6 +679,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
555
679
|
|
|
556
680
|
before do
|
|
557
681
|
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
682
|
+
stub_const('Legion::Data::Model::ApolloAccessLog', Class.new)
|
|
558
683
|
allow(Legion::LLM::Call::Embeddings).to receive(:generate)
|
|
559
684
|
.and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
|
|
560
685
|
allow(mock_entry_class).to receive(:db).and_return(mock_db)
|
|
@@ -612,37 +737,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
612
737
|
expect(dataset).to have_received(:where).with(knowledge_domain: 'general')
|
|
613
738
|
end
|
|
614
739
|
end
|
|
615
|
-
|
|
616
|
-
context 'access scope forwarding' do
|
|
617
|
-
let(:mock_entry_class) { double('ApolloEntry') }
|
|
618
|
-
let(:mock_db) { double('db') }
|
|
619
|
-
|
|
620
|
-
before do
|
|
621
|
-
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
622
|
-
allow(Legion::LLM::Call::Embeddings).to receive(:generate)
|
|
623
|
-
.and_return({ vector: Array.new(1024, 0.1), model: 'test', provider: :ollama, dimensions: 1024, tokens: 0 })
|
|
624
|
-
allow(mock_entry_class).to receive(:db).and_return(mock_db)
|
|
625
|
-
allow(mock_db).to receive(:fetch).and_return(double(all: []))
|
|
626
|
-
allow(mock_entry_class).to receive(:where).and_return(double(update: true))
|
|
627
|
-
end
|
|
628
|
-
|
|
629
|
-
it 'forwards requesting_principal_id to build_semantic_search_sql' do
|
|
630
|
-
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
631
|
-
hash_including(requesting_principal_id: 42)
|
|
632
|
-
).and_return('SELECT 1')
|
|
633
|
-
allow(mock_db).to receive(:fetch).and_return(double(all: []))
|
|
634
|
-
|
|
635
|
-
host.handle_query(query: 'test', requesting_principal_id: 42)
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
it 'passes nil requesting_principal_id when not provided (no filter)' do
|
|
639
|
-
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
640
|
-
hash_including(requesting_principal_id: nil)
|
|
641
|
-
).and_return('SELECT 1')
|
|
642
|
-
|
|
643
|
-
host.handle_query(query: 'test')
|
|
644
|
-
end
|
|
645
|
-
end
|
|
646
740
|
end
|
|
647
741
|
|
|
648
742
|
describe '#normalize_text_input' do
|
|
@@ -712,21 +806,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
712
806
|
expect(result[:success]).to be true
|
|
713
807
|
expect(result[:count]).to eq(1)
|
|
714
808
|
end
|
|
715
|
-
|
|
716
|
-
it 'passes domain to graph query builder' do
|
|
717
|
-
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
718
|
-
hash_including(domain: 'clinical')
|
|
719
|
-
).and_call_original
|
|
720
|
-
host.retrieve_relevant(query: 'treatment', domain: 'clinical')
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
it 'forwards requesting_principal_id to build_semantic_search_sql' do
|
|
724
|
-
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_semantic_search_sql).with(
|
|
725
|
-
hash_including(requesting_principal_id: 7)
|
|
726
|
-
).and_return('SELECT 1')
|
|
727
|
-
|
|
728
|
-
host.retrieve_relevant(query: 'test', requesting_principal_id: 7)
|
|
729
|
-
end
|
|
730
809
|
end
|
|
731
810
|
end
|
|
732
811
|
|
|
@@ -783,13 +862,6 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
783
862
|
expect(mock_access_log_class).not_to receive(:create)
|
|
784
863
|
host.handle_traverse(entry_id: 'uuid-1')
|
|
785
864
|
end
|
|
786
|
-
|
|
787
|
-
it 'filters invalid relation_types' do
|
|
788
|
-
expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_traversal_sql).with(
|
|
789
|
-
hash_including(relation_types: ['causes'])
|
|
790
|
-
).and_call_original
|
|
791
|
-
host.handle_traverse(entry_id: 'uuid-1', relation_types: %w[causes invalid_type])
|
|
792
|
-
end
|
|
793
865
|
end
|
|
794
866
|
|
|
795
867
|
context 'when Sequel raises an error' do
|
|
@@ -861,7 +933,7 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
|
|
|
861
933
|
it 'stores into trace shared_store when Memory::Trace is available' do
|
|
862
934
|
mock_store = double('store')
|
|
863
935
|
mock_trace_helpers = Module.new do
|
|
864
|
-
def self.new_trace(type:,
|
|
936
|
+
def self.new_trace(type:, **kwargs)
|
|
865
937
|
{ trace_id: 'trace-abc', trace_type: type, strength: kwargs[:strength] || 0.5 }
|
|
866
938
|
end
|
|
867
939
|
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'rbconfig'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
RSpec.describe 'Apollo service lifecycle mutations' do
|
|
9
|
+
let(:lifecycle_script) do
|
|
10
|
+
<<~'RUBY'
|
|
11
|
+
require 'sequel'
|
|
12
|
+
require 'sinatra/base'
|
|
13
|
+
require 'rack/mock'
|
|
14
|
+
require 'rspec'
|
|
15
|
+
require 'spec_helper'
|
|
16
|
+
require 'json'
|
|
17
|
+
require 'legion/extensions/apollo/api'
|
|
18
|
+
db = Sequel.sqlite
|
|
19
|
+
db.create_table(:apollo_entries) do
|
|
20
|
+
primary_key :id
|
|
21
|
+
String :content
|
|
22
|
+
String :content_type
|
|
23
|
+
String :status
|
|
24
|
+
Float :confidence
|
|
25
|
+
String :source_context
|
|
26
|
+
DateTime :created_at
|
|
27
|
+
DateTime :updated_at
|
|
28
|
+
DateTime :confirmed_at
|
|
29
|
+
check(Sequel.lit("content != 'protected' OR status != 'archived'"))
|
|
30
|
+
end
|
|
31
|
+
Legion::Data.const_set(:Model, Module.new) unless defined?(Legion::Data::Model)
|
|
32
|
+
Legion::Data::Model.const_set(:Apollo, Module.new)
|
|
33
|
+
entry_model = Class.new(Sequel::Model(db[:apollo_entries]))
|
|
34
|
+
Legion::Data::Model::Apollo.const_set(:Entry, entry_model)
|
|
35
|
+
now = Time.now
|
|
36
|
+
old = now - (100 * 86_400)
|
|
37
|
+
attributes = { content_type: 'fact', status: 'candidate', confidence: 0.8,
|
|
38
|
+
source_context: ::JSON.generate(origin: 'preserve'), created_at: now, updated_at: nil }
|
|
39
|
+
db[:apollo_entries].insert(attributes.merge(id: 1, content: 'target'))
|
|
40
|
+
db[:apollo_entries].insert(attributes.merge(id: 2, content: 'protected'))
|
|
41
|
+
db[:apollo_entries].insert(attributes.merge(id: 3, content: 'stale', confidence: 0.04, created_at: old))
|
|
42
|
+
db[:apollo_entries].insert(attributes.merge(id: 4, content: 'touched', status: 'confirmed', confidence: 0.6,
|
|
43
|
+
created_at: old, updated_at: now))
|
|
44
|
+
db[:apollo_entries].insert(attributes.merge(id: 5, content: 'already archived', status: 'archived', confidence: 0.7,
|
|
45
|
+
created_at: old))
|
|
46
|
+
input = ::JSON.parse($stdin.read, symbolize_names: true)
|
|
47
|
+
maintenance = Object.new.extend(Legion::Extensions::Apollo::Runners::Maintenance)
|
|
48
|
+
case input[:operation]
|
|
49
|
+
when 'deprecate'
|
|
50
|
+
api = Rack::MockRequest.new(Legion::Extensions::Apollo::Api)
|
|
51
|
+
payload = ::JSON.generate(reason: 'Verified replacement exists.')
|
|
52
|
+
first = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
|
|
53
|
+
first_updated_at = entry_model[1].updated_at
|
|
54
|
+
repeated = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
|
|
55
|
+
repeat_preserved_time = entry_model[1].updated_at == first_updated_at
|
|
56
|
+
missing = api.post('/api/apollo/entries/999/deprecate')
|
|
57
|
+
failed = api.post('/api/apollo/entries/2/deprecate', 'CONTENT_TYPE' => 'application/json', input: payload)
|
|
58
|
+
malformed = api.post('/api/apollo/entries/1/deprecate', 'CONTENT_TYPE' => 'application/json', input: '{')
|
|
59
|
+
Legion::Data::Model::Apollo.send(:remove_const, :Entry)
|
|
60
|
+
unavailable = api.post('/api/apollo/entries/1/deprecate')
|
|
61
|
+
Legion::Data::Model::Apollo.const_set(:Entry, entry_model)
|
|
62
|
+
responses = { first: first, repeated: repeated, missing: missing, failed: failed,
|
|
63
|
+
malformed: malformed, unavailable: unavailable }.transform_values do |response|
|
|
64
|
+
{ status: response.status, body: ::JSON.parse(response.body) }
|
|
65
|
+
end
|
|
66
|
+
result = { responses: responses, repeat_preserved_time: repeat_preserved_time }
|
|
67
|
+
when 'maintenance'
|
|
68
|
+
decay = maintenance.force_decay(factor: 0.5)
|
|
69
|
+
after_decay = db[:apollo_entries].order(:id).all
|
|
70
|
+
invalid_factor = maintenance.force_decay(factor: -0.1)
|
|
71
|
+
invalid_factor_unchanged = after_decay == db[:apollo_entries].order(:id).all
|
|
72
|
+
archive = maintenance.archive_stale(days: 90)
|
|
73
|
+
repeated = maintenance.archive_stale(days: 90)
|
|
74
|
+
before_invalid_days = db[:apollo_entries].order(:id).all
|
|
75
|
+
invalid_days = maintenance.archive_stale(days: -1)
|
|
76
|
+
result = { decay: decay, after_decay: after_decay, invalid_factor: invalid_factor,
|
|
77
|
+
invalid_factor_unchanged: invalid_factor_unchanged, archive: archive, repeated: repeated,
|
|
78
|
+
invalid_days: invalid_days, invalid_days_unchanged: before_invalid_days == db[:apollo_entries].order(:id).all }
|
|
79
|
+
when 'dispute'
|
|
80
|
+
db[:apollo_entries].where(id: [1, 2, 3]).update(status: 'disputed')
|
|
81
|
+
db[:apollo_entries].insert(attributes.merge(id: 6, content: 'kept dispute', status: 'disputed'))
|
|
82
|
+
before_unknown = entry_model[6].values
|
|
83
|
+
unknown = maintenance.resolve_dispute(entry_id: 6, resolution: :unexpected)
|
|
84
|
+
unknown_unchanged = before_unknown == entry_model[6].values
|
|
85
|
+
accepted = maintenance.resolve_dispute(entry_id: 1, resolution: :accept)
|
|
86
|
+
rejected = maintenance.resolve_dispute(entry_id: 3, resolution: :reject)
|
|
87
|
+
kept = maintenance.resolve_dispute(entry_id: 6, resolution: :keep)
|
|
88
|
+
failed = maintenance.resolve_dispute(entry_id: 2, resolution: :reject)
|
|
89
|
+
missing = maintenance.resolve_dispute(entry_id: 999, resolution: :accept)
|
|
90
|
+
not_disputed = maintenance.resolve_dispute(entry_id: 4, resolution: :reject)
|
|
91
|
+
repeated = maintenance.resolve_dispute(entry_id: 1, resolution: :accept)
|
|
92
|
+
result = { accepted: accepted, rejected: rejected, kept: kept, failed: failed, missing: missing,
|
|
93
|
+
not_disputed: not_disputed, repeated: repeated, unknown: unknown, unknown_unchanged: unknown_unchanged }
|
|
94
|
+
end
|
|
95
|
+
puts "APOLLO_RESULT=#{::JSON.generate(result.merge(entries: db[:apollo_entries].order(:id).all))}"
|
|
96
|
+
RUBY
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def lifecycle_state(operation:)
|
|
100
|
+
stdout, stderr, status = Open3.capture3(
|
|
101
|
+
RbConfig.ruby, '-Ilib', '-Ispec', '-e', lifecycle_script,
|
|
102
|
+
chdir: File.expand_path('../../../../..', __dir__),
|
|
103
|
+
stdin_data: JSON.generate(operation: operation)
|
|
104
|
+
)
|
|
105
|
+
line = stdout.lines.find { |output| output.start_with?('APOLLO_RESULT=') }
|
|
106
|
+
raise "Lifecycle process failed: #{stderr}\n#{stdout}" unless status.success? && line
|
|
107
|
+
|
|
108
|
+
JSON.parse(line.delete_prefix('APOLLO_RESULT='), symbolize_names: true)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'archives through HTTP with retained metadata and truthful repeat, missing, unavailable, and rollback outcomes' do
|
|
112
|
+
state = lifecycle_state(operation: 'deprecate')
|
|
113
|
+
responses = state[:responses]
|
|
114
|
+
|
|
115
|
+
expect(responses[:first]).to include(status: 200, body: hash_including(success: true, updated: true, status: 'archived'))
|
|
116
|
+
expect(responses[:repeated]).to include(status: 200, body: hash_including(success: true, updated: false))
|
|
117
|
+
expect(state[:repeat_preserved_time]).to be true
|
|
118
|
+
expect(responses[:missing]).to include(status: 404, body: hash_including(error: 'entry_not_found'))
|
|
119
|
+
expect(responses[:unavailable]).to include(status: 503, body: hash_including(error: 'apollo_data_not_available'))
|
|
120
|
+
expect(responses[:failed]).to include(status: 500, body: hash_including(success: false))
|
|
121
|
+
expect(responses[:malformed][:status]).to eq(400)
|
|
122
|
+
expect(state[:entries].first[:status]).to eq('archived')
|
|
123
|
+
expect(JSON.parse(state[:entries].first[:source_context], symbolize_names: true))
|
|
124
|
+
.to include(origin: 'preserve', deprecation_reason: 'Verified replacement exists.')
|
|
125
|
+
expect(state[:entries][1]).to include(status: 'candidate', source_context: JSON.generate(origin: 'preserve'))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'multiplies active confidence without implicit archival and archives only stale, untouched entries' do
|
|
129
|
+
state = lifecycle_state(operation: 'maintenance')
|
|
130
|
+
|
|
131
|
+
expect(state[:decay]).to include(success: true, decayed: 4)
|
|
132
|
+
expect(state[:after_decay].map { |entry| entry[:confidence] }).to eq([0.4, 0.4, 0.02, 0.3, 0.7])
|
|
133
|
+
expect(state[:after_decay][2][:status]).to eq('candidate')
|
|
134
|
+
expect(state[:invalid_factor]).to include(success: false, error: 'invalid_factor')
|
|
135
|
+
expect(state[:invalid_factor_unchanged]).to be true
|
|
136
|
+
expect(state[:archive]).to include(success: true, archived: 1)
|
|
137
|
+
expect(state[:repeated]).to include(success: true, archived: 0)
|
|
138
|
+
expect(state[:entries][2][:status]).to eq('archived')
|
|
139
|
+
expect(state[:entries][3][:status]).to eq('confirmed')
|
|
140
|
+
expect(state[:invalid_days]).to include(success: false, error: 'invalid_days')
|
|
141
|
+
expect(state[:invalid_days_unchanged]).to be true
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'applies the approved dispute transitions and preserves state for invalid, ineligible, or failed decisions' do
|
|
145
|
+
state = lifecycle_state(operation: 'dispute')
|
|
146
|
+
|
|
147
|
+
expect(state[:accepted]).to include(success: true, status: 'confirmed')
|
|
148
|
+
expect(state[:kept]).to include(success: true, status: 'confirmed')
|
|
149
|
+
expect(state[:rejected]).to include(success: true, status: 'archived')
|
|
150
|
+
expect(state[:failed]).to include(success: false)
|
|
151
|
+
expect(state[:missing]).to include(success: false, error: 'entry_not_found')
|
|
152
|
+
expect(state[:not_disputed]).to include(success: false, error: 'entry_not_disputed')
|
|
153
|
+
expect(state[:repeated]).to include(success: false, error: 'entry_not_disputed')
|
|
154
|
+
expect(state[:unknown]).to include(success: false, error: 'invalid_resolution')
|
|
155
|
+
expect(state[:unknown_unchanged]).to be true
|
|
156
|
+
expect(state[:entries].first).to include(status: 'confirmed', confidence: 0.8)
|
|
157
|
+
expect(state[:entries].first[:confirmed_at]).not_to be_nil
|
|
158
|
+
expect(JSON.parse(state[:entries].first[:source_context], symbolize_names: true))
|
|
159
|
+
.to include(origin: 'preserve', dispute_resolution: 'accept')
|
|
160
|
+
expect(state[:entries][1]).to include(status: 'disputed', source_context: JSON.generate(origin: 'preserve'))
|
|
161
|
+
expect(state[:entries][2][:status]).to eq('archived')
|
|
162
|
+
expect(state[:entries][3][:status]).to eq('confirmed')
|
|
163
|
+
expect(state[:entries].last[:status]).to eq('confirmed')
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -12,28 +12,12 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
12
12
|
obj
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
expect(result[:action]).to eq(:force_decay)
|
|
19
|
-
expect(result[:factor]).to eq(0.5)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
describe '#archive_stale' do
|
|
24
|
-
it 'returns an archive payload with default days' do
|
|
25
|
-
result = runner.archive_stale
|
|
26
|
-
expect(result[:action]).to eq(:archive_stale)
|
|
27
|
-
expect(result[:days]).to eq(90)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
15
|
+
it 'retains client action envelopes without claiming completed service mutations when data is unavailable' do
|
|
16
|
+
results = [runner.force_decay(factor: 0.5), runner.archive_stale(days: 90),
|
|
17
|
+
runner.resolve_dispute(entry_id: 'uuid-123', resolution: :keep)]
|
|
30
18
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
result = runner.resolve_dispute(entry_id: 'uuid-123', resolution: :keep)
|
|
34
|
-
expect(result[:action]).to eq(:resolve_dispute)
|
|
35
|
-
expect(result[:resolution]).to eq(:keep)
|
|
36
|
-
end
|
|
19
|
+
expect(results.map { |result| result[:action] }).to eq(%i[force_decay archive_stale resolve_dispute])
|
|
20
|
+
expect(results).to all(satisfy { |result| !result.key?(:success) })
|
|
37
21
|
end
|
|
38
22
|
|
|
39
23
|
describe '#run_decay_cycle' do
|
|
@@ -101,111 +85,41 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Maintenance do
|
|
|
101
85
|
end
|
|
102
86
|
end
|
|
103
87
|
|
|
104
|
-
context 'when
|
|
88
|
+
context 'when relation persistence is unavailable' do
|
|
105
89
|
let(:mock_entry_class) { double('ApolloEntry') }
|
|
106
90
|
|
|
107
91
|
before do
|
|
108
92
|
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
|
|
112
|
-
.and_return(double(exclude: double(limit: double(all: []))))
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
it 'returns zero promoted and scanned' do
|
|
116
|
-
result = host.check_corroboration
|
|
117
|
-
expect(result[:success]).to be true
|
|
118
|
-
expect(result[:promoted]).to eq(0)
|
|
119
|
-
expect(result[:scanned]).to eq(0)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
context 'when a candidate matches a confirmed entry' do
|
|
124
|
-
let(:mock_entry_class) { double('ApolloEntry') }
|
|
125
|
-
let(:mock_relation_class) { double('ApolloRelation') }
|
|
126
|
-
let(:embedding) { Array.new(1536, 0.5) }
|
|
127
|
-
let(:candidate) do
|
|
128
|
-
double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
|
|
129
|
-
confidence: 0.5, update: true)
|
|
130
|
-
end
|
|
131
|
-
let(:confirmed_entry) do
|
|
132
|
-
double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
before do
|
|
136
|
-
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
137
|
-
stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
|
|
138
|
-
allow(mock_entry_class).to receive(:where).with(status: 'candidate')
|
|
139
|
-
.and_return(double(exclude: double(limit: double(all: [candidate]))))
|
|
140
|
-
allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
|
|
141
|
-
.and_return(double(exclude: double(limit: double(all: [confirmed_entry]))))
|
|
142
|
-
allow(mock_relation_class).to receive(:create)
|
|
93
|
+
hide_const('Legion::Data::Model::ApolloRelation') if defined?(Legion::Data::Model::ApolloRelation)
|
|
94
|
+
hide_const('Legion::Data::Model::Apollo::Relation') if defined?(Legion::Data::Model::Apollo::Relation)
|
|
143
95
|
end
|
|
144
96
|
|
|
145
|
-
it '
|
|
146
|
-
expect(
|
|
147
|
-
result = host.check_corroboration
|
|
148
|
-
expect(result[:promoted]).to eq(1)
|
|
149
|
-
expect(result[:scanned]).to eq(1)
|
|
150
|
-
end
|
|
97
|
+
it 'fails before scanning or promoting candidates' do
|
|
98
|
+
expect(mock_entry_class).not_to receive(:where)
|
|
151
99
|
|
|
152
|
-
|
|
153
|
-
expect(mock_relation_class).to receive(:create).with(
|
|
154
|
-
hash_including(from_entry_id: 'c-1', to_entry_id: 'f-1', relation_type: 'similar_to')
|
|
155
|
-
)
|
|
156
|
-
host.check_corroboration
|
|
100
|
+
expect(host.check_corroboration).to include(success: false, error: 'apollo_data_not_available')
|
|
157
101
|
end
|
|
158
102
|
end
|
|
159
103
|
|
|
160
|
-
context 'when
|
|
161
|
-
let(:mock_entry_class) { double('ApolloEntry') }
|
|
162
|
-
let(:mock_relation_class) { double('ApolloRelation') }
|
|
163
|
-
let(:embedding) { Array.new(1536, 0.5) }
|
|
164
|
-
let(:candidate) do
|
|
165
|
-
double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding,
|
|
166
|
-
confidence: 0.5, source_provider: 'openai', source_channel: 'slack-alerts')
|
|
167
|
-
end
|
|
168
|
-
let(:confirmed_entry) do
|
|
169
|
-
double('confirmed', id: 'f-1', content_type: 'fact', embedding: embedding,
|
|
170
|
-
source_provider: 'claude', source_channel: 'slack-alerts')
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
before do
|
|
174
|
-
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
175
|
-
stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
|
|
176
|
-
allow(mock_entry_class).to receive(:where).with(status: 'candidate')
|
|
177
|
-
.and_return(double(exclude: double(limit: double(all: [candidate]))))
|
|
178
|
-
allow(mock_entry_class).to receive(:where).with(status: 'confirmed')
|
|
179
|
-
.and_return(double(exclude: double(limit: double(all: [confirmed_entry]))))
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
it 'does not promote even with different providers' do
|
|
183
|
-
result = host.check_corroboration
|
|
184
|
-
expect(result[:promoted]).to eq(0)
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
context 'when candidate has different content_type than confirmed' do
|
|
104
|
+
context 'when no candidates exist' do
|
|
189
105
|
let(:mock_entry_class) { double('ApolloEntry') }
|
|
190
|
-
let(:embedding) { Array.new(1536, 0.5) }
|
|
191
|
-
let(:candidate) do
|
|
192
|
-
double('candidate', id: 'c-1', content_type: 'fact', embedding: embedding, confidence: 0.5)
|
|
193
|
-
end
|
|
194
|
-
let(:confirmed_entry) do
|
|
195
|
-
double('confirmed', id: 'f-1', content_type: 'concept', embedding: embedding)
|
|
196
|
-
end
|
|
197
106
|
|
|
198
107
|
before do
|
|
199
108
|
stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
109
|
+
stub_const('Legion::Data::Model::ApolloRelation', Class.new)
|
|
110
|
+
candidate_dataset = double(
|
|
111
|
+
exclude: double(order: double(limit: double(all: [])))
|
|
112
|
+
)
|
|
113
|
+
confirmed_dataset = double(select_map: [])
|
|
114
|
+
allow(mock_entry_class).to receive(:where).with(status: 'candidate').and_return(candidate_dataset)
|
|
115
|
+
allow(mock_entry_class).to receive(:where).with(status: 'confirmed').and_return(confirmed_dataset)
|
|
204
116
|
end
|
|
205
117
|
|
|
206
|
-
it '
|
|
118
|
+
it 'returns zero promoted and scanned' do
|
|
207
119
|
result = host.check_corroboration
|
|
120
|
+
expect(result[:success]).to be true
|
|
208
121
|
expect(result[:promoted]).to eq(0)
|
|
122
|
+
expect(result[:scanned]).to eq(0)
|
|
209
123
|
end
|
|
210
124
|
end
|
|
211
125
|
end
|