lex-apollo 0.4.30 → 0.4.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/README.md +42 -5
  4. data/lib/legion/extensions/apollo/actors/contradiction_scanner.rb +14 -11
  5. data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +4 -6
  6. data/lib/legion/extensions/apollo/api.rb +19 -9
  7. data/lib/legion/extensions/apollo/helpers/capability.rb +8 -20
  8. data/lib/legion/extensions/apollo/helpers/confidence.rb +1 -1
  9. data/lib/legion/extensions/apollo/helpers/graph_query.rb +30 -46
  10. data/lib/legion/extensions/apollo/helpers/writeback.rb +9 -13
  11. data/lib/legion/extensions/apollo/runners/gas.rb +90 -20
  12. data/lib/legion/extensions/apollo/runners/knowledge.rb +152 -104
  13. data/lib/legion/extensions/apollo/runners/maintenance.rb +133 -34
  14. data/lib/legion/extensions/apollo/runners/request.rb +4 -5
  15. data/lib/legion/extensions/apollo/transport.rb +20 -0
  16. data/lib/legion/extensions/apollo/version.rb +1 -1
  17. data/spec/legion/extensions/apollo/actors/contradiction_scanner_spec.rb +38 -0
  18. data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +4 -14
  19. data/spec/legion/extensions/apollo/api_spec.rb +11 -21
  20. data/spec/legion/extensions/apollo/contradiction_spec.rb +0 -9
  21. data/spec/legion/extensions/apollo/helpers/capability_spec.rb +2 -2
  22. data/spec/legion/extensions/apollo/helpers/writeback_spec.rb +29 -1
  23. data/spec/legion/extensions/apollo/runners/corroboration_integration_spec.rb +191 -0
  24. data/spec/legion/extensions/apollo/runners/gas_deposit_integration_spec.rb +92 -0
  25. data/spec/legion/extensions/apollo/runners/gas_spec.rb +27 -33
  26. data/spec/legion/extensions/apollo/runners/ingest_transaction_integration_spec.rb +114 -0
  27. data/spec/legion/extensions/apollo/runners/knowledge_spec.rb +47 -57
  28. data/spec/legion/extensions/apollo/runners/lifecycle_integration_spec.rb +165 -0
  29. data/spec/legion/extensions/apollo/runners/maintenance_spec.rb +22 -108
  30. data/spec/legion/extensions/apollo/runners/request_spec.rb +43 -26
  31. metadata +34 -2
  32. data/spec/legion/extensions/apollo/helpers/graph_query_spec.rb +0 -100
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: facad4c6e5cf4da1497d5d6de01daa13310bc8762274e227e9ff7ead72c761a6
4
- data.tar.gz: 37a7098b002770c827173135a5510eeb7651f0705ef6f80ded0f8433a012bee1
3
+ metadata.gz: b3132a716f6bac3837f9532c674d4d10aaa20326dae47bb2465ad8c3339702ce
4
+ data.tar.gz: 467ad8bb7e6a6851197d5b4f6ef9bf09b24b60d497e57eba821cabf47763f2e4
5
5
  SHA512:
6
- metadata.gz: 6f45fbaf1de91fd1676d94063f6b8d34663207d0f81de84805eca6203789f6864fe48f54137a0cd73178fc7972f309dec9a3ef8bc956070ec09fd5bbac74c4e9
7
- data.tar.gz: ff8f92d7ffe8f69d040d00237227c14696453abe76b3bdd90163ae2ffdc681bd0059ca119c6216a2f454f862fdd4db80196c2befa8c1e07b0c73a430fee5a06e
6
+ metadata.gz: 006cd9c4e3523f36e0077347546fd8a13934076209773bb8df796f8c04d5cdc2904d0e3431e1856bbb0a186eaaca8335eda9a3ba92f946e44eb7d88cea94a9a9
7
+ data.tar.gz: c067bfb41d203f00d75cd92e7c64017f77e0065f76fcdbf8dc08621ed0a5d14bc10d0a5c338ab47df3e665bd8471162e88cbfa6d888c4427995d66941e90e083
data/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.44] - 2026-10-30
4
+
5
+ ### Fixed
6
+ - Preflight required Entry, Expertise, and AccessLog models before ingestion mutates data; unavailable deployments now log the missing components and return `apollo_data_not_available` without creating partial entries (#42).
7
+ - Refuse semantic queries that cannot be audited before embedding, lookup, or confidence boosts; preserve non-audited traversal only for the `unknown` agent.
8
+ - Require Relation persistence before bounded corroboration promotion; keep relation statistics optional when the relation model is unavailable.
9
+
10
+ ## [0.4.43] - 2026-10-30
11
+
12
+ ### Fixed
13
+ - Commit entry creation or corroboration, expertise bookkeeping, and ingest access logging as one transaction; queue contradiction work only after commit (#41).
14
+ - Roll back complete ingest state when any synchronous persistence side effect fails, including a corroboration confidence update.
15
+ - Recover content-hash races through a nested savepoint only when a committed winner exists; otherwise return a truthful failure without partial data.
16
+
17
+ ## [0.4.42] - 2026-10-30
18
+
19
+ ### Fixed
20
+ - Retain independently sourced candidate evidence during ingest and bootstrap exactly one fresh-store confirmation through the bounded corroboration checker (#40).
21
+ - Snapshot pre-existing confirmations so a bootstrap-derived confirmation cannot promote its paired candidate on a later scan or duplicate the relation.
22
+ - Preserve domain, type, access scope, provider, source-channel, private-owner, and team-membership corroboration boundaries during bootstrap.
23
+
24
+ ## [0.4.41] - 2026-10-30
25
+
26
+ ### Fixed
27
+ - Execute service-side deprecation, force decay, stale archival, and dispute resolution while retaining no-data client action envelopes (#39).
28
+ - Archive deprecated entries with retained reason/source metadata; make identical repeats idempotent and return truthful HTTP success, missing, unavailable, and persistence-failure responses.
29
+ - Apply the approved dispute policy: `accept`/`keep` confirms, `reject` archives, and invalid or non-disputed decisions leave state unchanged. Persist lifecycle status and decision metadata atomically.
30
+ - Keep force decay limited to confidence multiplication and use last-update-or-creation time for stale archival.
31
+ - Handle bodyless API requests and pass explicit payload hashes to the JSON helper so health, validation, and unavailable-data responses remain JSON.
32
+
33
+ ## [0.4.40] - 2026-10-30
34
+
35
+ ### Fixed
36
+ - Persist GAS synthesis through normal ingestion and report separate successful entry, failed entry, persisted relation, failed relation, and skipped relation counts (#38).
37
+ - Resolve graph sources from successful fact IDs, including deduplicated entries; reject missing endpoints, self-links, cross-scope links, weak/unsupported edges, and duplicate edges within a batch.
38
+ - Retain synthesis provenance for successfully stored sources and continue processing independent relations after a write failure.
39
+ - Include the existing GAS `supports_by` and `extends` relation types in the traversal vocabulary.
40
+
41
+ ## [0.4.39] - 2026-10-29
42
+
43
+ ### Fixed
44
+ - Load the GAS runner explicitly and return its module as the subscription receiver, so audit events call `Gas.process` instead of attempting to dispatch on a String (#37).
45
+
46
+ ## [0.4.38] - 2026-10-29
47
+
48
+ ### Fixed
49
+ - Recognize research tools in canonical `Response.tools`, including typed tool objects and symbol/string-keyed hashes, when deciding whether to capture writeback (#36).
50
+ - Preserve existing `tool_calls` and enrichment-based research detection; Apollo-only and non-research responses remain ineligible.
51
+
52
+ ## [0.4.37] - 2026-10-29
53
+
54
+ ### Fixed
55
+ - Dispatch the contradiction scanner through its actual actor entry point and deliberately invoke the internal private detector (#35).
56
+ - Snapshot pending work safely, continue after individual failures, and retain only failed items for a later scan.
57
+ - Commit contradiction relations and disputed statuses atomically after LLM checks; database failures roll back and remain retryable.
58
+
59
+ ## [0.4.36] - 2026-10-29
60
+
61
+ ### Fixed
62
+ - Declare all five Apollo exchange-to-queue bindings explicitly for the two-exchange topology instead of relying on single-exchange inference (#34).
63
+ - Bind the GAS subscription even when its queue class is already loaded; repeated startup remains idempotent.
64
+
65
+ ## [0.4.35] - 2026-10-29
66
+
67
+ ### Fixed
68
+ - Construct request query and ingest messages with keyword payloads so Ruby 3.4+ can publish them through the real transport classes (#33).
69
+ - Check the transport connection's actual session state instead of calling an unavailable top-level `connected?` API.
70
+
71
+ ## [0.4.34] - 2026-10-29
72
+
73
+ ### Fixed
74
+ - Scope ingestion and maintenance corroboration to matching domains, content types, access scopes, and eligible lifecycle states (#32).
75
+ - Reuse query visibility rules for private ownership and shared-team membership; unresolved ownership cannot corroborate restricted knowledge.
76
+ - Search past dependent sources instead of allowing the first same-source match to hide eligible independent evidence.
77
+
78
+ ## [0.4.33] - 2026-10-29
79
+
80
+ ### Fixed
81
+ - Bind semantic-search and graph-traversal values through Sequel, including tags, statuses, domains, ownership, limits, confidence thresholds, and traversal depth (#31).
82
+ - Bind vector literals and contradiction-query limits instead of interpolating values into SQL.
83
+
84
+ ## [0.4.32] - 2026-10-29
85
+
86
+ ### Fixed
87
+ - Preserve usable writeback embeddings during ingestion instead of generating them again, allowing storage-only nodes to persist vectors without an embedding provider (#30).
88
+ - Reject zero, nonnumeric, and nonfinite supplied vectors before persistence; retain the existing embedding fallback when no usable vector is supplied.
89
+
90
+ ## [0.4.31] - 2026-10-29
91
+
92
+ ### Fixed
93
+ - Detect embedding capability through the provider-agnostic SSOT router lane rather than an Ollama-specific availability check.
94
+
3
95
  ## [0.4.30] - 2026-07-31
4
96
 
5
97
  ### Removed
data/README.md CHANGED
@@ -69,25 +69,38 @@ client.agent_profile(agent_id: 'my-agent-001')
69
69
  ### Maintenance
70
70
 
71
71
  ```ruby
72
- # Force confidence decay cycle (factor multiplied against each entry's confidence)
72
+ # Multiply each non-archived entry's confidence by the supplied factor
73
73
  client.force_decay(factor: 0.5)
74
74
 
75
- # Archive entries older than N days
75
+ # Archive non-archived entries untouched for N days
76
76
  client.archive_stale(days: 90)
77
77
 
78
78
  # Resolve a corroboration dispute
79
79
  client.resolve_dispute(entry_id: 'entry-uuid', resolution: :accept)
80
80
  ```
81
81
 
82
+ With a loaded Entry model, these methods execute against the service database and return persisted outcomes. Without local data, Ruby callers retain the existing action envelopes; these envelopes do not claim a successful mutation.
83
+
84
+ - `deprecate_entry` archives the entry and preserves its other source metadata, storing the supplied reason as `source_context.deprecation_reason`. An identical repeat is a no-op.
85
+ - `force_decay` accepts a finite factor from 0 through 1 and changes confidence only; it does not implicitly archive entries.
86
+ - `archive_stale` accepts nonnegative days and uses `COALESCE(updated_at, created_at)` to determine staleness.
87
+ - `resolve_dispute` accepts `:accept` or `:keep` to confirm a disputed entry, or `:reject` to archive it. It records `source_context.dispute_resolution`, sets `confirmed_at` when confirming, and preserves confidence. Unknown resolutions and non-disputed entries fail without mutation.
88
+
89
+ The standalone deprecation endpoint returns HTTP 200 after success, 404 for a missing entry, 503 when local data is unavailable, and 500 when persistence fails. Lifecycle status and metadata changes commit together.
90
+
82
91
  ## Architecture
83
92
 
84
93
  ### Client Mode
85
94
 
86
- Runners build structured payloads and publish to the `apollo` exchange via RabbitMQ. No PostgreSQL or pgvector dependency is needed in the calling agent. Transport requires `Legion::Transport` to be loaded (the `if defined?(Legion::Transport)` guard in the entry point handles this automatically).
95
+ Runners build structured payloads and publish to the `legion.apollo` exchange via RabbitMQ. No PostgreSQL or pgvector dependency is needed in the calling agent. Transport requires `Legion::Transport` to be loaded.
96
+
97
+ Startup explicitly binds the ingest, query, writeback-store, and writeback-vectorize queues to `legion.apollo`, using each queue's name as its routing key. The `legion.apollo.gas` queue binds separately to `llm.audit` with routing key `llm.audit.complete`. Repeated startup preserves these bindings.
98
+
99
+ Automatic writeback checks research tool calls in `Legion::LLM::Inference::Response#tools`, accepting typed tools and symbol/string-keyed hashes. Existing response `tool_calls` and enrichment-based tool history remain supported. Responses must meet the configured minimum content length; Apollo context alone does not qualify a response for capture.
87
100
 
88
101
  ### Service Mode
89
102
 
90
- Five actors run in the dedicated Apollo service process:
103
+ Service actors include:
91
104
 
92
105
  | Actor | Type | Interval | Purpose |
93
106
  |---|---|---|---|
@@ -96,6 +109,29 @@ Five actors run in the dedicated Apollo service process:
96
109
  | `Decay` | Interval | 3600s | Confidence decay cycle across all entries |
97
110
  | `ExpertiseAggregator` | Interval | 1800s | Recalculate domain proficiency scores |
98
111
  | `CorroborationChecker` | Interval | 900s | Scan pending entries for auto-confirm threshold |
112
+ | `GasSubscriber` | Subscription | on-message | Process `llm.audit.complete` events and persist accepted GAS outputs |
113
+
114
+ ### Atomic Ingestion
115
+
116
+ After text embedding is complete, an ingest commits its Entry change (including a corroboration boost), expertise bookkeeping, and ingest access log in one database transaction. Contradiction scanning is queued only after that transaction commits. Failed entry, expertise, access-log, or corroboration-side-effect writes leave no partial row, confidence boost, or queued contradiction work.
117
+
118
+ Content-hash uniqueness conflicts use a nested savepoint. If another transaction committed the winner, Apollo records against that winner; if no winner can be read after rollback, the ingest fails rather than reporting a nil or partial success.
119
+
120
+ Apollo preflights Entry, Expertise, and AccessLog before ingestion writes anything; a missing required model rejects the request without a partial entry. Semantic queries require AccessLog before embedding, lookup, or confidence boosts so reads remain auditable. Traversal requires it only when a non-`unknown` agent causes an audit write. Relation remains optional for statistics, but corroboration does not promote a candidate unless it can persist the matching relation.
121
+
122
+ ### GAS Persistence
123
+
124
+ GAS deposits facts and synthesis through normal Knowledge ingestion. New synthesis entries remain candidates and record their original source indices plus successfully stored source entry IDs in `source_context`; existing deduplicated entries retain their metadata.
125
+
126
+ The `process` result's top-level counts describe generated phase outputs. Its `deposited` object reports storage outcomes:
127
+
128
+ - `deposited`, `facts`, and `synthesis` count successful ingest results with entry IDs, including deduplication—not just newly inserted rows.
129
+ - `failed` counts rejected or failed entry writes.
130
+ - `relations`, `relations_failed`, and `relations_skipped` distinguish persisted edges, failed writes or missing endpoints, and intentionally skipped edges.
131
+
132
+ Relations must meet the configured confidence gate, use a supported type, and connect an unambiguous successful fact deposit to an existing target in the same access scope. Self-links and duplicate edges within the batch are skipped. Independent relation failures do not discard successful entry deposits or prevent later edges from being processed. Existing GAS types `supports_by` and `extends` are also accepted by graph traversal.
133
+
134
+ Relation deduplication is batch-local; repeated audit events are not an exactly-once delivery contract.
99
135
 
100
136
  ### GAIA Tick Integration
101
137
 
@@ -106,7 +142,8 @@ Apollo is wired into the GAIA tick cycle at the `knowledge_retrieval` phase (pha
106
142
  Entries have a confidence score between 0.0 and 1.0:
107
143
 
108
144
  - New entries start at `INITIAL_CONFIDENCE` (0.5) with status `candidate`
109
- - Corroboration from a semantically similar entry (cosine > 0.9) boosts confidence by 0.3 and promotes to `confirmed`
145
+ - Ingestion corroborates confirmed evidence. The bounded periodic checker can bootstrap one confirmation from a pair of independently sourced candidates; it marks that confirmation as bootstrap-derived so its paired candidate does not confirm solely by seeing the new status on a later scan.
146
+ - Corroboration stays within the same knowledge domain, content type, and access scope. Equal known providers and equal source channels are not independent. Private evidence requires the same resolved owner; team evidence requires shared identity-group membership. Unresolved private/team ownership does not corroborate.
110
147
  - Each retrieval adds a small boost (+0.02, capped at 1.0)
111
148
  - Confidence decays hourly by factor 0.998; entries below 0.1 are archived
112
149
  - The GAIA write gate (`meets_write_gate?`) requires confidence > 0.6 and novelty > 0.3 for the tick write-back path
@@ -26,13 +26,11 @@ module Legion
26
26
  end
27
27
 
28
28
  def drain
29
- items = []
30
- items << queue.pop until queue.empty?
31
- items
29
+ mutex.synchronize { Array.new(queue.size) { queue.pop(true) } }
32
30
  end
33
31
  end
34
32
 
35
- def runner_class = Legion::Extensions::Apollo::Runners::Knowledge
33
+ def runner_class = self.class
36
34
  def runner_function = 'scan_pending_contradictions'
37
35
  def time = settings[:actors][:contradiction_interval]
38
36
  def run_now? = false
@@ -40,18 +38,23 @@ module Legion
40
38
  def check_subtask? = false
41
39
  def generate_task? = false
42
40
 
43
- def every
41
+ def scan_pending_contradictions(**)
44
42
  items = self.class.drain
45
- return if items.empty?
43
+ return { processed: 0, failed: 0 } if items.empty?
46
44
 
47
- log.debug("Apollo ContradictionScanner.every processing=#{items.size}")
48
45
  runner = Object.new.extend(Legion::Extensions::Apollo::Runners::Knowledge)
46
+ processed = 0
47
+ failed = 0
49
48
  items.each do |item|
50
- runner.detect_contradictions(item[:entry_id], item[:embedding], item[:content])
49
+ runner.send(:detect_contradictions, item[:entry_id], item[:embedding], item[:content])
50
+ processed += 1
51
+ rescue StandardError => e
52
+ self.class.enqueue(**item)
53
+ failed += 1
54
+ handle_exception(e, level: :error, operation: 'apollo.actor.contradiction_scanner', entry_id: item[:entry_id])
51
55
  end
52
- log.info("Apollo ContradictionScanner.every completed=#{items.size}")
53
- rescue StandardError => e
54
- handle_exception(e, level: :error, operation: 'apollo.actor.contradiction_scanner')
56
+ log.info("Apollo ContradictionScanner.scan_pending_contradictions processed=#{processed} failed=#{failed}")
57
+ { processed: processed, failed: failed }
55
58
  end
56
59
  end
57
60
  end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/actors/subscription'
4
+ require_relative '../runners/gas'
4
5
 
5
6
  module Legion
6
7
  module Extensions
7
8
  module Apollo
8
9
  module Actor
9
10
  class GasSubscriber < Legion::Extensions::Actors::Subscription
10
- def runner_class = 'Legion::Extensions::Apollo::Runners::Gas'
11
+ def runner_class = Legion::Extensions::Apollo::Runners::Gas
11
12
  def runner_function = 'process'
12
13
  def check_subtask? = false
13
14
  def generate_task? = false
@@ -22,11 +23,8 @@ module Legion
22
23
  end
23
24
 
24
25
  def create_queue
25
- return if queues.const_defined?(:GasSubscriber, false)
26
-
27
- queues.const_set(:GasSubscriber, Apollo::Transport::Queues::GasSubscriber) unless queues.const_defined?(:GasSubscriber, false)
28
- exchange_object = Apollo::Transport::Exchanges::LlmAudit.new
29
- queue_object = Apollo::Transport::Queues::GasSubscriber.new
26
+ exchange_object = Legion::Extensions::Apollo::Transport::Exchanges::LlmAudit.new
27
+ queue_object = Legion::Extensions::Apollo::Transport::Queues::GasSubscriber.new
30
28
  queue_object.bind(exchange_object, routing_key: 'llm.audit.complete')
31
29
  end
32
30
  end
@@ -51,13 +51,13 @@ module Legion
51
51
  include Legion::JSON::Helper
52
52
 
53
53
  def json_body
54
- body = request.body.read
55
- return {} if body.empty?
54
+ body = request.body&.read
55
+ return {} if body.nil? || body.empty?
56
56
 
57
57
  json_parse(body)
58
58
  rescue Legion::JSON::ParseError => e
59
59
  handle_exception(e, level: :warn, operation: 'apollo.api.json_body')
60
- halt 400, json_dump(error: "invalid JSON: #{e.message}")
60
+ halt 400, json_dump({ error: "invalid JSON: #{e.message}" })
61
61
  end
62
62
 
63
63
  def runner
@@ -80,13 +80,13 @@ module Legion
80
80
  # Health check
81
81
  get '/api/apollo/health' do
82
82
  available = Helpers::DataModels.apollo_entry_available?
83
- json_dump(status: available ? 'ok' : 'degraded', data_available: available)
83
+ json_dump({ status: available ? 'ok' : 'degraded', data_available: available })
84
84
  end
85
85
 
86
86
  # Query knowledge (semantic search)
87
87
  post '/api/apollo/query' do
88
88
  req = json_body
89
- halt 400, json_dump(error: 'query is required') unless req[:query]
89
+ halt 400, json_dump({ error: 'query is required' }) unless req[:query]
90
90
 
91
91
  query_options = {
92
92
  query: req[:query],
@@ -106,8 +106,8 @@ module Legion
106
106
  # Ingest knowledge
107
107
  post '/api/apollo/ingest' do
108
108
  req = json_body
109
- halt 400, json_dump(error: 'content is required') unless req[:content]
110
- halt 400, json_dump(error: 'content_type is required') unless req[:content_type]
109
+ halt 400, json_dump({ error: 'content is required' }) unless req[:content]
110
+ halt 400, json_dump({ error: 'content_type is required' }) unless req[:content_type]
111
111
 
112
112
  result = runner.handle_ingest(
113
113
  content: req[:content],
@@ -126,7 +126,7 @@ module Legion
126
126
  # Graph traversal
127
127
  post '/api/apollo/traverse' do
128
128
  req = json_body
129
- halt 400, json_dump(error: 'entry_id is required') unless req[:entry_id]
129
+ halt 400, json_dump({ error: 'entry_id is required' }) unless req[:entry_id]
130
130
 
131
131
  result = runner.handle_traverse(
132
132
  entry_id: req[:entry_id],
@@ -141,7 +141,7 @@ module Legion
141
141
  # Retrieve relevant (GAIA-compatible)
142
142
  post '/api/apollo/retrieve' do
143
143
  req = json_body
144
- halt 400, json_dump(error: 'query is required') unless req[:query]
144
+ halt 400, json_dump({ error: 'query is required' }) unless req[:query]
145
145
 
146
146
  result = runner.retrieve_relevant(
147
147
  query: req[:query],
@@ -160,6 +160,16 @@ module Legion
160
160
  entry_id: params[:id],
161
161
  reason: json_body[:reason] || 'deprecated via API'
162
162
  )
163
+ halt 503, json_dump({ success: false, error: 'apollo_data_not_available' }) if result[:action] == :deprecate
164
+
165
+ status_code = if result[:success]
166
+ 200
167
+ elsif result[:error] == 'entry_not_found'
168
+ 404
169
+ else
170
+ 500
171
+ end
172
+ status status_code
163
173
  json_dump(result)
164
174
  end
165
175
 
@@ -8,15 +8,21 @@ module Legion
8
8
  extend Legion::Logging::Helper
9
9
  extend Legion::Settings::Helper
10
10
 
11
- EMBEDDING_MODELS = %w[mxbai-embed-large bge-large snowflake-arctic-embed].freeze
12
11
  PRIVILEGE_MUTEX = Mutex.new
13
12
 
14
13
  module_function
15
14
 
15
+ # SSOT gate: the sole selection authority for embeddings is the
16
+ # router (Call::Embeddings.generate with no provider arg →
17
+ # Router.next_lane). can_embed? asks the one capability fact that
18
+ # matches that selection — is there an embedding-type lane the
19
+ # router can select? — against the same registry lanes the router
20
+ # reads. Provider-agnostic (no ollama pin, no hardcoded model
21
+ # list); no parallel settings-based "second domain".
16
22
  def can_embed?
17
23
  return false unless defined?(Legion::LLM) && Legion::LLM.started?
18
24
 
19
- ollama_embedding_available? || cloud_embedding_configured?
25
+ Legion::LLM.can_embed?
20
26
  rescue StandardError => e
21
27
  handle_exception(e, level: :warn, operation: 'apollo.capability.can_embed')
22
28
  false
@@ -39,24 +45,6 @@ module Legion
39
45
  false
40
46
  end
41
47
 
42
- def ollama_embedding_available?
43
- return false unless defined?(Legion::LLM::Discovery::Ollama)
44
-
45
- EMBEDDING_MODELS.any? { |m| Legion::LLM::Discovery::Ollama.model_available?(m) }
46
- rescue StandardError => e
47
- handle_exception(e, level: :warn, operation: 'apollo.capability.ollama_embedding_available')
48
- false
49
- end
50
-
51
- def cloud_embedding_configured?
52
- provider = settings[:embedding][:provider]
53
- model = settings[:embedding][:model]
54
- !provider.nil? && !model.nil?
55
- rescue StandardError => e
56
- handle_exception(e, level: :warn, operation: 'apollo.capability.cloud_embedding_configured')
57
- false
58
- end
59
-
60
48
  def check_db_write_privilege
61
49
  PRIVILEGE_MUTEX.synchronize do
62
50
  return @apollo_write_privilege unless @apollo_write_privilege.nil?
@@ -20,7 +20,7 @@ module Legion
20
20
  DECAY_MIN_AGE_HOURS = 168
21
21
  CONTENT_TYPES = %i[fact concept procedure association observation].freeze
22
22
  STATUSES = %w[candidate confirmed disputed decayed archived].freeze
23
- RELATION_TYPES = %w[is_a has_a part_of causes similar_to contradicts supersedes depends_on].freeze
23
+ RELATION_TYPES = %w[is_a has_a part_of causes similar_to contradicts supersedes depends_on supports_by extends].freeze
24
24
 
25
25
  module_function
26
26
 
@@ -20,14 +20,8 @@ module Legion
20
20
  def default_query_limit = settings[:query][:default_limit]
21
21
  def default_query_min_confidence = settings[:query][:default_min_confidence]
22
22
 
23
- def build_traversal_sql(depth: default_depth, relation_types: nil, min_activation: self.min_activation, **)
24
- sf = spread_factor
25
- type_filter = if relation_types&.any?
26
- types = relation_types.map { |t| "'#{t}'" }.join(', ')
27
- "AND r.relation_type IN (#{types})"
28
- else
29
- ''
30
- end
23
+ def build_traversal_sql(relation_types: nil, **)
24
+ type_filter = relation_types&.any? ? 'AND r.relation_type IN :relation_types' : ''
31
25
 
32
26
  <<~SQL
33
27
  WITH RECURSIVE graph AS (
@@ -40,12 +34,12 @@ module Legion
40
34
 
41
35
  SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
42
36
  g.depth + 1,
43
- (g.activation * #{sf} * r.weight)::float
37
+ (g.activation * :spread_factor * r.weight)::float
44
38
  FROM graph g
45
39
  JOIN apollo_relations r ON r.from_entry_id = g.id #{type_filter}
46
40
  JOIN apollo_entries e ON e.id = r.to_entry_id
47
- WHERE g.depth < #{depth}
48
- AND g.activation * #{sf} * r.weight > #{min_activation}
41
+ WHERE g.depth < :depth
42
+ AND g.activation * :spread_factor * r.weight > :min_activation
49
43
  )
50
44
  SELECT DISTINCT ON (id) id, content, content_type, confidence, tags, source_agent,
51
45
  depth, activation
@@ -54,51 +48,41 @@ module Legion
54
48
  SQL
55
49
  end
56
50
 
57
- def build_semantic_search_sql(limit: default_query_limit, min_confidence: default_query_min_confidence,
58
- statuses: nil, tags: nil, domain: nil,
59
- requesting_principal_id: nil, **)
60
- conditions = ["e.confidence >= #{min_confidence}"]
51
+ def build_semantic_search_sql(statuses: nil, tags: nil, domain: nil, requesting_principal_id: nil, **)
52
+ conditions = ['e.confidence >= :min_confidence']
53
+ conditions << 'e.status IN :statuses' if statuses&.any?
54
+ conditions << 'e.tags && CAST(:tags AS text[])' if tags&.any?
55
+ conditions << 'e.knowledge_domain = :domain' if domain
61
56
 
62
- if statuses&.any?
63
- status_list = statuses.map { |s| "'#{s}'" }.join(', ')
64
- conditions << "e.status IN (#{status_list})"
65
- end
66
-
67
- if tags&.any?
68
- tag_list = tags.map { |t| "'#{t}'" }.join(', ')
69
- conditions << "e.tags && ARRAY[#{tag_list}]::text[]"
70
- end
71
-
72
- conditions << "e.knowledge_domain = '#{domain}'" if domain
73
-
74
- if requesting_principal_id
75
- pid = requesting_principal_id.to_i
76
- conditions << <<~SCOPE_SQL.strip
77
- (e.access_scope = 'global'
78
- OR (e.access_scope = 'private'
79
- AND (e.identity_principal_id = #{pid}
80
- OR e.identity_id IN (SELECT id FROM identities WHERE principal_id = #{pid})))
81
- OR (e.access_scope = 'team'
82
- AND EXISTS (
83
- SELECT 1 FROM identity_group_memberships igm1
84
- JOIN identity_group_memberships igm2 ON igm1.group_id = igm2.group_id
85
- WHERE igm1.principal_id = #{pid}
86
- AND igm2.principal_id = e.identity_principal_id
87
- )))
88
- SCOPE_SQL
89
- end
57
+ conditions << access_scope_sql if requesting_principal_id
90
58
 
91
59
  where_clause = conditions.join(' AND ')
92
60
 
93
61
  <<~SQL
94
62
  SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
95
63
  e.access_count, e.created_at, e.knowledge_domain,
96
- (e.embedding <=> :embedding) AS distance
64
+ (e.embedding <=> CAST(:embedding AS vector)) AS distance
97
65
  FROM apollo_entries e
98
66
  WHERE #{where_clause}
99
67
  AND e.embedding IS NOT NULL
100
- ORDER BY e.embedding <=> :embedding
101
- LIMIT #{limit}
68
+ ORDER BY e.embedding <=> CAST(:embedding AS vector)
69
+ LIMIT :limit
70
+ SQL
71
+ end
72
+
73
+ def access_scope_sql
74
+ <<~SQL
75
+ (access_scope = 'global'
76
+ OR (access_scope = 'private'
77
+ AND (identity_principal_id = :requesting_principal_id
78
+ OR identity_id IN (SELECT id FROM identities WHERE principal_id = :requesting_principal_id)))
79
+ OR (access_scope = 'team'
80
+ AND EXISTS (
81
+ SELECT 1 FROM identity_group_memberships igm1
82
+ JOIN identity_group_memberships igm2 ON igm1.group_id = igm2.group_id
83
+ WHERE igm1.principal_id = :requesting_principal_id
84
+ AND igm2.principal_id = identity_principal_id
85
+ )))
102
86
  SQL
103
87
  end
104
88
  end
@@ -31,16 +31,10 @@ module Legion
31
31
  content = response_content(response)
32
32
  return false if content.nil? || content.length < min_content_length
33
33
 
34
- tool_calls = extract_tool_calls(response, enrichments)
35
- research_calls = tool_calls.select { |tc| RESEARCH_TOOLS.include?(tc[:name] || tc['name']) }
36
-
37
- return false if research_calls.empty?
38
-
39
- apollo_results = enrichments['rag_context:apollo_results']
40
- return true if apollo_results.nil? || (apollo_results[:count] || 0).zero?
41
-
42
- # Apollo had results — only capture if LLM also did additional research
43
- research_calls.any?
34
+ extract_tool_calls(response, enrichments).any? do |call|
35
+ name = call.respond_to?(:name) ? call.name : call[:name] || call['name']
36
+ RESEARCH_TOOLS.include?(name)
37
+ end
44
38
  end
45
39
 
46
40
  def build_payload(request:, response:, source_channel: nil)
@@ -147,9 +141,11 @@ module Legion
147
141
 
148
142
  def extract_tool_calls(response, enrichments)
149
143
  calls = []
150
- calls += Array(response.tool_calls) if response.respond_to?(:tool_calls)
151
- calls += Array(enrichments['tool_calls']) if enrichments['tool_calls']
152
- calls.uniq { |tc| tc[:name] || tc['name'] }
144
+ calls.concat(Array(response.tools)) if response.respond_to?(:tools)
145
+ calls.concat(Array(response.tool_calls)) if response.respond_to?(:tool_calls)
146
+ calls.concat(Array(enrichments['tool_calls'])) if enrichments['tool_calls']
147
+ calls.uniq! { |call| call.respond_to?(:name) ? call.name : call[:name] || call['name'] }
148
+ calls
153
149
  end
154
150
 
155
151
  def derive_tags(query)