lex-apollo 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98c5141f161198efe7dbdd37179e46bf36ec1c58404efa645897bc256975d3f8
4
- data.tar.gz: c2ac6728af5ada2ab45eb8fff0a0e80d6bceac5464305bca18bd836c81fc0ffb
3
+ metadata.gz: 9a1c9042626e5d9e09a783b8c703b11f261573fe1658498a4fa48ad023306baf
4
+ data.tar.gz: 2f3e561b6851825d9d5c67325007802eafea63c10c93a00e37a40170202a0bdc
5
5
  SHA512:
6
- metadata.gz: 45328d181484cabb6a17aceb9d6d1a816a623fbe1dc8f5c80972b9151a2250f244d88ba88551cf0cecac70229732546a35abe32189a2ff75a4026010279a810e
7
- data.tar.gz: 1eab2286abce601fe8a95b75c0da2edb0017bebb39e09abe05a1c7dbf72658e7955147b2ab1e1b6ed743788ed61a86c4871919e7ab53762ef3e2e40906eea00f
6
+ metadata.gz: d2dae8784e591deeb1608671dbabbd313813a6f94214b77de0539d2316d6ef3a34e21d5002eb28a262e98c4c4d57773793be4ba006d185e0fd3afcf6816902c5
7
+ data.tar.gz: 41e11ae62a001fab8e79b514d30c3c28374d38d1d4379926e00752df74e35ccfa79c61b3ac75e7e60341de0420287635413b265eca5fa0cd7fe4e7f02a5fad0f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.6] - 2026-03-25
4
+
5
+ ### Added
6
+ - Apollo-specific embedding provider/model settings: `apollo.embedding.provider` and `apollo.embedding.model` override LLM defaults
7
+ - `embedding_opts` helper reads Apollo settings and passes `provider:`/`model:` to `Legion::LLM.embed`
8
+ - Local-first embedding: `detect_local_model` checks Ollama for pulled 1024-dim models (`mxbai-embed-large`, `bge-large`, `snowflake-arctic-embed`) before falling back to cloud provider
9
+
10
+ ### Changed
11
+ - `DEFAULT_DIMENSION` changed from 1536 to 1024 for cross-provider compatibility (Bedrock Titan v2, OpenAI with dimensions:, Ollama models)
12
+ - `Helpers::Embedding.generate` now passes provider/model from Apollo settings, falling back to LLM defaults when not configured
13
+
14
+ ## [0.4.5] - 2026-03-25
15
+
16
+ ### Added
17
+ - `handle_traverse` method in Knowledge runner: executes recursive CTE graph traversal SQL, returns formatted entries with depth and activation scores (closes #3)
18
+ - `Runners::Request` client-tier runner with `data_required? false`: routes `query`, `retrieve`, `ingest`, `traverse` locally when DB is available or via transport when it isn't (closes #1)
19
+ - REST API (`Api` Sinatra app) with 8 endpoints: health, query, ingest, traverse, retrieve, deprecate, expertise, stats (closes #5)
20
+ - Conditional `require 'legion/extensions/apollo/api'` when Sinatra is available
21
+
22
+ ### Fixed
23
+ - Contradiction detection now orders by embedding distance (`<=>` pgvector operator) before LIMIT, scanning the most semantically similar entries instead of arbitrary rows (closes #4)
24
+ - Whitelisted `relation_types` in `handle_traverse` against `RELATION_TYPES` constant to prevent SQL injection
25
+
26
+ ### Changed
27
+ - GAIA phase wiring updated: `knowledge_retrieval` now targets `Request#retrieve` instead of `Knowledge#retrieve_relevant` (in legion-gaia)
28
+
3
29
  ## [0.4.4] - 2026-03-24
4
30
 
5
31
  ### Changed
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sinatra/base' unless defined?(Sinatra)
4
+ require 'json'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Apollo
9
+ class Api < Sinatra::Base
10
+ set :host_authorization, permitted: :any
11
+
12
+ before do
13
+ content_type :json
14
+ end
15
+
16
+ helpers do
17
+ def json_body
18
+ body = request.body.read
19
+ return {} if body.empty?
20
+
21
+ ::JSON.parse(body, symbolize_names: true)
22
+ rescue ::JSON::ParserError
23
+ halt 400, { error: 'invalid JSON' }.to_json
24
+ end
25
+
26
+ def runner
27
+ @runner ||= begin
28
+ obj = Object.new
29
+ obj.extend(Runners::Knowledge)
30
+ obj
31
+ end
32
+ end
33
+
34
+ def expertise_runner
35
+ @expertise_runner ||= begin
36
+ obj = Object.new
37
+ obj.extend(Runners::Expertise)
38
+ obj
39
+ end
40
+ end
41
+ end
42
+
43
+ # Health check
44
+ get '/api/apollo/health' do
45
+ available = defined?(Legion::Data::Model::ApolloEntry) ? true : false
46
+ { status: available ? 'ok' : 'degraded', data_available: available }.to_json
47
+ end
48
+
49
+ # Query knowledge (semantic search)
50
+ post '/api/apollo/query' do
51
+ req = json_body
52
+ halt 400, { error: 'query is required' }.to_json unless req[:query]
53
+
54
+ result = runner.handle_query(
55
+ query: req[:query],
56
+ limit: req[:limit] || 10,
57
+ min_confidence: req[:min_confidence] || 0.3,
58
+ status: req[:status] || [:confirmed],
59
+ tags: req[:tags],
60
+ domain: req[:domain],
61
+ agent_id: req[:agent_id] || 'api'
62
+ )
63
+ status result[:success] ? 200 : 500
64
+ result.to_json
65
+ end
66
+
67
+ # Ingest knowledge
68
+ post '/api/apollo/ingest' do
69
+ req = json_body
70
+ halt 400, { error: 'content is required' }.to_json unless req[:content]
71
+ halt 400, { error: 'content_type is required' }.to_json unless req[:content_type]
72
+
73
+ result = runner.handle_ingest(
74
+ content: req[:content],
75
+ content_type: req[:content_type],
76
+ tags: req[:tags] || [],
77
+ source_agent: req[:source_agent] || 'api',
78
+ source_provider: req[:source_provider],
79
+ source_channel: req[:source_channel],
80
+ knowledge_domain: req[:knowledge_domain],
81
+ context: req[:context] || {}
82
+ )
83
+ status result[:success] ? 201 : 500
84
+ result.to_json
85
+ end
86
+
87
+ # Graph traversal
88
+ post '/api/apollo/traverse' do
89
+ req = json_body
90
+ halt 400, { error: 'entry_id is required' }.to_json unless req[:entry_id]
91
+
92
+ result = runner.handle_traverse(
93
+ entry_id: req[:entry_id],
94
+ depth: req[:depth] || 2,
95
+ relation_types: req[:relation_types],
96
+ agent_id: req[:agent_id] || 'api'
97
+ )
98
+ status result[:success] ? 200 : 500
99
+ result.to_json
100
+ end
101
+
102
+ # Retrieve relevant (GAIA-compatible)
103
+ post '/api/apollo/retrieve' do
104
+ req = json_body
105
+ halt 400, { error: 'query is required' }.to_json unless req[:query]
106
+
107
+ result = runner.retrieve_relevant(
108
+ query: req[:query],
109
+ limit: req[:limit] || 5,
110
+ min_confidence: req[:min_confidence] || 0.3,
111
+ tags: req[:tags],
112
+ domain: req[:domain]
113
+ )
114
+ status result[:success] ? 200 : 500
115
+ result.to_json
116
+ end
117
+
118
+ # Deprecate entry
119
+ post '/api/apollo/entries/:id/deprecate' do
120
+ result = runner.deprecate_entry(
121
+ entry_id: params[:id],
122
+ reason: json_body[:reason] || 'deprecated via API'
123
+ )
124
+ result.to_json
125
+ end
126
+
127
+ # Domains at risk — must be declared before /:agent_id to avoid routing conflict
128
+ get '/api/apollo/expertise/at-risk' do
129
+ result = expertise_runner.domains_at_risk
130
+ result.to_json
131
+ end
132
+
133
+ # Expertise for an agent
134
+ get '/api/apollo/expertise/:agent_id' do
135
+ result = expertise_runner.agent_profile(agent_id: params[:agent_id])
136
+ result.to_json
137
+ end
138
+
139
+ # Statistics
140
+ get '/api/apollo/stats' do
141
+ stats = {}
142
+ if defined?(Legion::Data::Model::ApolloEntry)
143
+ stats[:total_entries] = Legion::Data::Model::ApolloEntry.count
144
+ stats[:by_status] = Legion::Data::Model::ApolloEntry.group_and_count(:status).all
145
+ .to_h { |r| [r[:status], r[:count]] }
146
+ stats[:by_content_type] = Legion::Data::Model::ApolloEntry.group_and_count(:content_type).all
147
+ .to_h { |r| [r[:content_type], r[:count]] }
148
+ stats[:total_relations] = Legion::Data::Model::ApolloRelation.count if defined?(Legion::Data::Model::ApolloRelation)
149
+ else
150
+ stats[:error] = 'apollo_data_not_available'
151
+ end
152
+ stats.to_json
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -5,7 +5,9 @@ module Legion
5
5
  module Apollo
6
6
  module Helpers
7
7
  module Embedding
8
- DEFAULT_DIMENSION = 1536
8
+ DEFAULT_DIMENSION = 1024
9
+
10
+ LOCAL_EMBEDDING_MODELS = %w[mxbai-embed-large bge-large snowflake-arctic-embed].freeze
9
11
 
10
12
  module_function
11
13
 
@@ -15,8 +17,15 @@ module Legion
15
17
  return zero_vector
16
18
  end
17
19
 
18
- result = Legion::LLM.embed(text)
19
- vector = result.is_a?(Hash) ? result[:vector] : result
20
+ local_model = detect_local_model
21
+ vector = if local_model
22
+ ollama_embed(text, local_model)
23
+ else
24
+ opts = cloud_embedding_opts
25
+ result = Legion::LLM.embed(text, **opts)
26
+ result.is_a?(Hash) ? result[:vector] : result
27
+ end
28
+
20
29
  if vector.is_a?(Array) && vector.any?
21
30
  @dimension = vector.size
22
31
  vector
@@ -38,6 +47,53 @@ module Legion
38
47
  DEFAULT_DIMENSION
39
48
  end
40
49
 
50
+ def ollama_embed(text, model)
51
+ require 'faraday'
52
+ base_url = ollama_base_url
53
+ Legion::Logging.debug("[apollo] embedding via local Ollama: #{model}") if defined?(Legion::Logging)
54
+ conn = Faraday.new(url: base_url) { |f| f.options.timeout = 10 }
55
+ response = conn.post('/api/embed', { model: model, input: text }.to_json,
56
+ 'Content-Type' => 'application/json')
57
+ return nil unless response.success?
58
+
59
+ parsed = ::JSON.parse(response.body)
60
+ parsed['embeddings']&.first
61
+ rescue StandardError => e
62
+ Legion::Logging.warn("[apollo] local Ollama embed failed: #{e.message}") if defined?(Legion::Logging)
63
+ nil
64
+ end
65
+
66
+ def ollama_base_url
67
+ return 'http://localhost:11434' unless defined?(Legion::Settings)
68
+
69
+ Legion::Settings[:llm].dig(:providers, :ollama, :base_url) || 'http://localhost:11434'
70
+ rescue StandardError
71
+ 'http://localhost:11434'
72
+ end
73
+
74
+ def cloud_embedding_opts
75
+ return {} unless defined?(Legion::Settings) && !Legion::Settings[:apollo].nil?
76
+
77
+ embedding = Legion::Settings[:apollo][:embedding] || {}
78
+ opts = {}
79
+ opts[:provider] = embedding[:provider].to_sym if embedding[:provider]
80
+ opts[:model] = embedding[:model] if embedding[:model]
81
+ opts
82
+ rescue StandardError
83
+ {}
84
+ end
85
+
86
+ def detect_local_model
87
+ return nil unless defined?(Legion::LLM::Discovery::Ollama)
88
+
89
+ LOCAL_EMBEDDING_MODELS.find do |m|
90
+ Legion::LLM::Discovery::Ollama.model_available?(m) ||
91
+ Legion::LLM::Discovery::Ollama.model_available?("#{m}:latest")
92
+ end
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
41
97
  def zero_vector
42
98
  Array.new(dimension, 0.0)
43
99
  end
@@ -32,7 +32,7 @@ module Legion
32
32
  SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
33
33
  0 AS depth, 1.0::float AS activation
34
34
  FROM apollo_entries e
35
- WHERE e.id = $entry_id
35
+ WHERE e.id = :entry_id
36
36
 
37
37
  UNION ALL
38
38
 
@@ -72,11 +72,11 @@ module Legion
72
72
  <<~SQL
73
73
  SELECT e.id, e.content, e.content_type, e.confidence, e.tags, e.source_agent,
74
74
  e.access_count, e.created_at, e.knowledge_domain,
75
- (e.embedding <=> $embedding) AS distance
75
+ (e.embedding <=> :embedding) AS distance
76
76
  FROM apollo_entries e
77
77
  WHERE #{where_clause}
78
78
  AND e.embedding IS NOT NULL
79
- ORDER BY e.embedding <=> $embedding
79
+ ORDER BY e.embedding <=> :embedding
80
80
  LIMIT #{limit}
81
81
  SQL
82
82
  end
@@ -10,6 +10,10 @@ module Legion
10
10
  module_function
11
11
 
12
12
  def cosine_similarity(vec_a:, vec_b:, **)
13
+ vec_a = parse_vector(vec_a)
14
+ vec_b = parse_vector(vec_b)
15
+ return 0.0 unless vec_a.is_a?(Array) && vec_b.is_a?(Array)
16
+
13
17
  dot = vec_a.zip(vec_b).sum { |x, y| x * y }
14
18
  mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
15
19
  mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
@@ -18,6 +22,15 @@ module Legion
18
22
  dot / (mag_a * mag_b)
19
23
  end
20
24
 
25
+ def parse_vector(vec)
26
+ return vec if vec.is_a?(Array)
27
+ return nil unless vec.is_a?(String)
28
+
29
+ ::JSON.parse(vec)
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
21
34
  def above_corroboration_threshold?(similarity:, **)
22
35
  similarity >= Confidence::CORROBORATION_SIMILARITY_THRESHOLD
23
36
  end
@@ -112,6 +112,8 @@ module Legion
112
112
  db = Legion::Data::Model::ApolloEntry.db
113
113
  entries = db.fetch(sql, embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")).all
114
114
 
115
+ entries = entries.reject { |e| e[:distance].respond_to?(:nan?) && e[:distance].nan? }
116
+
115
117
  entries.each do |entry|
116
118
  Legion::Data::Model::ApolloEntry.where(id: entry[:id]).update(
117
119
  access_count: Sequel.expr(:access_count) + 1,
@@ -130,7 +132,7 @@ module Legion
130
132
 
131
133
  formatted = entries.map do |entry|
132
134
  { id: entry[:id], content: entry[:content], content_type: entry[:content_type],
133
- confidence: entry[:confidence], distance: entry[:distance],
135
+ confidence: entry[:confidence], distance: entry[:distance]&.to_f,
134
136
  tags: entry[:tags], source_agent: entry[:source_agent],
135
137
  knowledge_domain: entry[:knowledge_domain] }
136
138
  end
@@ -140,6 +142,36 @@ module Legion
140
142
  { success: false, error: e.message }
141
143
  end
142
144
 
145
+ def handle_traverse(entry_id:, depth: Helpers::GraphQuery.default_depth, relation_types: nil, agent_id: 'unknown', **)
146
+ return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
147
+
148
+ # Whitelist relation_types to prevent SQL injection (they are string-interpolated in build_traversal_sql)
149
+ if relation_types
150
+ allowed = Helpers::Confidence::RELATION_TYPES
151
+ relation_types = relation_types.select { |t| allowed.include?(t.to_s) }
152
+ end
153
+
154
+ sql = Helpers::GraphQuery.build_traversal_sql(depth: depth, relation_types: relation_types)
155
+ db = Legion::Data::Model::ApolloEntry.db
156
+ entries = db.fetch(sql, entry_id: entry_id).all
157
+
158
+ if entries.any? && agent_id != 'unknown'
159
+ Legion::Data::Model::ApolloAccessLog.create(
160
+ entry_id: entry_id, agent_id: agent_id, action: 'query'
161
+ )
162
+ end
163
+
164
+ formatted = entries.map do |entry|
165
+ { id: entry[:id], content: entry[:content], content_type: entry[:content_type],
166
+ confidence: entry[:confidence], tags: entry[:tags], source_agent: entry[:source_agent],
167
+ depth: entry[:depth], activation: entry[:activation] }
168
+ end
169
+
170
+ { success: true, entries: formatted, count: formatted.size }
171
+ rescue Sequel::Error => e
172
+ { success: false, error: e.message }
173
+ end
174
+
143
175
  def redistribute_knowledge(agent_id:, min_confidence: Helpers::Confidence.apollo_setting(:query, :redistribute_min_confidence, default: 0.5), **)
144
176
  return { success: false, error: 'apollo_data_not_available' } unless defined?(Legion::Data::Model::ApolloEntry)
145
177
 
@@ -188,6 +220,7 @@ module Legion
188
220
 
189
221
  db = Legion::Data::Model::ApolloEntry.db
190
222
  entries = db.fetch(sql, embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")).all
223
+ entries = entries.reject { |e| e[:distance].respond_to?(:nan?) && e[:distance].nan? }
191
224
 
192
225
  entries.each do |entry|
193
226
  Legion::Data::Model::ApolloEntry.where(id: entry[:id]).update(
@@ -198,7 +231,7 @@ module Legion
198
231
 
199
232
  formatted = entries.map do |entry|
200
233
  { id: entry[:id], content: entry[:content], content_type: entry[:content_type],
201
- confidence: entry[:confidence], distance: entry[:distance],
234
+ confidence: entry[:confidence], distance: entry[:distance]&.to_f,
202
235
  tags: entry[:tags], source_agent: entry[:source_agent],
203
236
  knowledge_domain: entry[:knowledge_domain] }
204
237
  end
@@ -281,25 +314,30 @@ module Legion
281
314
  sim_threshold = Helpers::Confidence.apollo_setting(:contradiction, :similarity_threshold, default: 0.7)
282
315
  rel_weight = Helpers::Confidence.apollo_setting(:contradiction, :relation_weight, default: 0.8)
283
316
 
284
- similar = Legion::Data::Model::ApolloEntry
285
- .exclude(id: entry_id)
286
- .exclude(embedding: nil)
287
- .limit(sim_limit).all
317
+ db = Legion::Data::Model::ApolloEntry.db
318
+ similar = db.fetch(
319
+ "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
320
+ entry_id: entry_id,
321
+ embedding: Sequel.lit("'[#{embedding.join(',')}]'::vector")
322
+ ).all
288
323
 
289
324
  contradictions = []
290
325
  similar.each do |existing|
291
- sim = Helpers::Similarity.cosine_similarity(vec_a: embedding, vec_b: existing.embedding)
326
+ existing_embedding = existing[:embedding]
327
+ next unless existing_embedding
328
+
329
+ sim = Helpers::Similarity.cosine_similarity(vec_a: embedding, vec_b: existing_embedding)
292
330
  next unless sim > sim_threshold
293
- next unless llm_detects_conflict?(content, existing.content)
331
+ next unless llm_detects_conflict?(content, existing[:content])
294
332
 
295
333
  Legion::Data::Model::ApolloRelation.create(
296
- from_entry_id: entry_id, to_entry_id: existing.id,
334
+ from_entry_id: entry_id, to_entry_id: existing[:id],
297
335
  relation_type: 'contradicts', source_agent: 'system:contradiction',
298
336
  weight: rel_weight
299
337
  )
300
338
 
301
- Legion::Data::Model::ApolloEntry.where(id: [entry_id, existing.id]).update(status: 'disputed')
302
- contradictions << existing.id
339
+ Legion::Data::Model::ApolloEntry.where(id: [entry_id, existing[:id]]).update(status: 'disputed')
340
+ contradictions << existing[:id]
303
341
  end
304
342
  contradictions
305
343
  rescue Sequel::Error
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Apollo
6
+ module Runners
7
+ module Request
8
+ extend self
9
+
10
+ def self.data_required?
11
+ false
12
+ end
13
+
14
+ def query(text:, limit: Helpers::GraphQuery.default_query_limit, min_confidence: Helpers::GraphQuery.default_query_min_confidence, tags: nil, # rubocop:disable Metrics/ParameterLists
15
+ domain: nil, agent_id: 'unknown', **)
16
+ if local_service_available?
17
+ knowledge_host.handle_query(query: text, limit: limit, min_confidence: min_confidence,
18
+ tags: tags, domain: domain, agent_id: agent_id)
19
+ elsif transport_available?
20
+ publish_query(action: :query, query: text, limit: limit, min_confidence: min_confidence,
21
+ tags: tags, domain: domain)
22
+ else
23
+ { success: false, error: :no_path_available }
24
+ end
25
+ end
26
+
27
+ def retrieve(text:, limit: 5, min_confidence: Helpers::GraphQuery.default_query_min_confidence, tags: nil, domain: nil, **)
28
+ if local_service_available?
29
+ knowledge_host.retrieve_relevant(query: text, limit: limit, min_confidence: min_confidence,
30
+ tags: tags, domain: domain)
31
+ elsif transport_available?
32
+ publish_query(action: :query, query: text, limit: limit, min_confidence: min_confidence,
33
+ tags: tags, domain: domain)
34
+ else
35
+ { success: false, error: :no_path_available }
36
+ end
37
+ end
38
+
39
+ def ingest(content:, content_type:, tags: [], source_agent: 'unknown', **)
40
+ if local_service_available?
41
+ knowledge_host.handle_ingest(content: content, content_type: content_type,
42
+ tags: tags, source_agent: source_agent, **)
43
+ elsif transport_available?
44
+ publish_ingest(content: content, content_type: content_type,
45
+ tags: tags, source_agent: source_agent, **)
46
+ else
47
+ { success: false, error: :no_path_available }
48
+ end
49
+ end
50
+
51
+ def traverse(entry_id:, depth: Helpers::GraphQuery.default_depth, relation_types: nil, agent_id: 'unknown', **)
52
+ if local_service_available?
53
+ knowledge_host.handle_traverse(entry_id: entry_id, depth: depth,
54
+ relation_types: relation_types, agent_id: agent_id)
55
+ elsif transport_available?
56
+ publish_query(action: :traverse, entry_id: entry_id, depth: depth,
57
+ relation_types: relation_types)
58
+ else
59
+ { success: false, error: :no_path_available }
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def knowledge_host
66
+ @knowledge_host ||= Object.new.extend(Knowledge)
67
+ end
68
+
69
+ def local_service_available?
70
+ defined?(Legion::Data::Model::ApolloEntry) &&
71
+ defined?(Knowledge)
72
+ end
73
+
74
+ def transport_available?
75
+ defined?(Legion::Transport) &&
76
+ Legion::Transport.respond_to?(:connected?) &&
77
+ Legion::Transport.connected?
78
+ end
79
+
80
+ def publish_query(**payload)
81
+ Transport::Messages::Query.new(payload).publish
82
+ { success: true, dispatched: :transport, payload: payload }
83
+ rescue StandardError => e
84
+ { success: false, error: e.message }
85
+ end
86
+
87
+ def publish_ingest(**payload)
88
+ Transport::Messages::Ingest.new(payload).publish
89
+ { success: true, dispatched: :transport, payload: payload }
90
+ rescue StandardError => e
91
+ { success: false, error: e.message }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Apollo
6
- VERSION = '0.4.4'
6
+ VERSION = '0.4.6'
7
7
  end
8
8
  end
9
9
  end
@@ -9,6 +9,9 @@ require 'legion/extensions/apollo/runners/expertise'
9
9
  require 'legion/extensions/apollo/runners/maintenance'
10
10
  require 'legion/extensions/apollo/runners/entity_extractor'
11
11
  require 'legion/extensions/apollo/runners/gas'
12
+ require 'legion/extensions/apollo/runners/request'
13
+
14
+ require 'legion/extensions/apollo/api' if defined?(Sinatra)
12
15
 
13
16
  if defined?(Legion::Transport)
14
17
  require 'legion/extensions/apollo/transport/exchanges/apollo'
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub Sinatra before requiring api.rb so the guard `unless defined?(Sinatra)` fires.
4
+ # This avoids a LoadError when sinatra is not in the bundle.
5
+ unless defined?(Sinatra)
6
+ module Sinatra
7
+ class Base
8
+ class << self
9
+ def set(*, **); end
10
+ def before(*, &); end
11
+ def helpers(*, &); end
12
+ def get(*, &); end
13
+ def post(*, &); end
14
+ def put(*, &); end
15
+ def delete(*, &); end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ require 'spec_helper'
22
+ require 'legion/extensions/apollo/api'
23
+
24
+ RSpec.describe Legion::Extensions::Apollo::Api do
25
+ it 'is defined as a Sinatra app' do
26
+ expect(described_class.superclass).to eq(Sinatra::Base)
27
+ end
28
+ end
@@ -15,5 +15,37 @@ RSpec.describe 'Apollo Contradiction Detection' do
15
15
  it 'returns empty when ApolloEntry model unavailable' do
16
16
  expect(knowledge.send(:detect_contradictions, 1, nil, 'test')).to eq([])
17
17
  end
18
+
19
+ it 'returns empty when embedding is nil' do
20
+ expect(knowledge.send(:detect_contradictions, 'uuid-1', nil, 'test')).to eq([])
21
+ end
22
+
23
+ context 'when entries exist' do
24
+ let(:mock_entry_class) { double('ApolloEntry') }
25
+ let(:mock_relation_class) { double('ApolloRelation') }
26
+ let(:mock_db) { double('db') }
27
+ let(:embedding) { Array.new(1536, 0.1) }
28
+
29
+ before do
30
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
31
+ stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
32
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
33
+ end
34
+
35
+ it 'queries with ORDER BY embedding distance' do
36
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
37
+ knowledge.send(:detect_contradictions, 'uuid-1', embedding, 'test')
38
+ expect(mock_db).to have_received(:fetch).with(
39
+ a_string_including('ORDER BY embedding <=> :embedding'),
40
+ hash_including(:entry_id, :embedding)
41
+ )
42
+ end
43
+
44
+ it 'returns empty when no similar entries found' do
45
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
46
+ result = knowledge.send(:detect_contradictions, 'uuid-1', embedding, 'test')
47
+ expect(result).to eq([])
48
+ end
49
+ end
18
50
  end
19
51
  end
@@ -12,8 +12,8 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Embedding do
12
12
 
13
13
  it 'returns a zero vector of the correct dimension' do
14
14
  result = described_class.generate(text: 'hello world')
15
- expect(result).to eq(Array.new(1536, 0.0))
16
- expect(result.size).to eq(1536)
15
+ expect(result).to eq(Array.new(1024, 0.0))
16
+ expect(result.size).to eq(1024)
17
17
  end
18
18
  end
19
19
 
@@ -24,12 +24,12 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Embedding do
24
24
 
25
25
  it 'returns a zero vector' do
26
26
  result = described_class.generate(text: 'hello world')
27
- expect(result).to eq(Array.new(1536, 0.0))
27
+ expect(result).to eq(Array.new(1024, 0.0))
28
28
  end
29
29
  end
30
30
 
31
31
  context 'when Legion::LLM is available and started' do
32
- let(:mock_vector) { Array.new(1536) { rand(-1.0..1.0) } }
32
+ let(:mock_vector) { Array.new(1024) { rand(-1.0..1.0) } }
33
33
 
34
34
  before do
35
35
  llm = Module.new do
@@ -44,7 +44,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Embedding do
44
44
  it 'returns the vector from the LLM response hash' do
45
45
  result = described_class.generate(text: 'hello world')
46
46
  expect(result).to eq(mock_vector)
47
- expect(Legion::LLM).to have_received(:embed).with('hello world')
47
+ expect(Legion::LLM).to have_received(:embed).with('hello world', **{})
48
48
  end
49
49
  end
50
50
 
@@ -120,7 +120,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::Embedding do
120
120
  end
121
121
 
122
122
  it 'returns the default dimension' do
123
- expect(described_class.configured_dimension).to eq(1536)
123
+ expect(described_class.configured_dimension).to eq(1024)
124
124
  end
125
125
  end
126
126
  end
@@ -24,7 +24,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::GraphQuery do
24
24
  expect(sql).to include('apollo_entries')
25
25
  expect(sql).to include('apollo_relations')
26
26
  expect(sql).to include('WITH RECURSIVE')
27
- expect(sql).to include('$entry_id')
27
+ expect(sql).to include(':entry_id')
28
28
  end
29
29
 
30
30
  it 'includes relation type filter when specified' do
@@ -49,7 +49,7 @@ RSpec.describe Legion::Extensions::Apollo::Helpers::GraphQuery do
49
49
  it 'returns SQL with vector placeholder' do
50
50
  sql = described_class.build_semantic_search_sql(limit: 5, min_confidence: 0.3)
51
51
  expect(sql).to include('apollo_entries')
52
- expect(sql).to include('$embedding')
52
+ expect(sql).to include(':embedding')
53
53
  expect(sql).to include('<=>')
54
54
  expect(sql).to include('LIMIT 5')
55
55
  end
@@ -110,6 +110,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
110
110
  let(:mock_entry) { double('entry', id: 'uuid-123', embedding: nil) }
111
111
  let(:empty_dataset) { double('dataset', each: nil) }
112
112
 
113
+ let(:mock_db) { double('db') }
114
+
113
115
  before do
114
116
  stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
115
117
  stub_const('Legion::Data::Model::ApolloRelation', mock_relation_class)
@@ -119,8 +121,8 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
119
121
  .and_return(Array.new(1536, 0.0))
120
122
 
121
123
  allow(mock_entry_class).to receive(:where).and_return(double(exclude: double(limit: empty_dataset)))
122
- allow(mock_entry_class).to receive(:exclude)
123
- .and_return(double(exclude: double(limit: double(all: []))))
124
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
125
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
124
126
  allow(mock_entry_class).to receive(:create).and_return(mock_entry)
125
127
  allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
126
128
  allow(mock_expertise_class).to receive(:create)
@@ -346,6 +348,83 @@ RSpec.describe Legion::Extensions::Apollo::Runners::Knowledge do
346
348
  end
347
349
  end
348
350
 
351
+ describe '#handle_traverse' do
352
+ let(:host) { Object.new.extend(described_class) }
353
+
354
+ context 'when Apollo data is not available' do
355
+ before { hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry) }
356
+
357
+ it 'returns a structured error' do
358
+ result = host.handle_traverse(entry_id: 'uuid-123')
359
+ expect(result[:success]).to be false
360
+ expect(result[:error]).to eq('apollo_data_not_available')
361
+ end
362
+ end
363
+
364
+ context 'when Apollo data is available' do
365
+ let(:mock_entry_class) { double('ApolloEntry') }
366
+ let(:mock_access_log_class) { double('ApolloAccessLog') }
367
+ let(:mock_db) { double('db') }
368
+ let(:traversal_results) do
369
+ [{ id: 'uuid-1', content: 'root', content_type: 'fact',
370
+ confidence: 0.8, tags: ['ruby'], source_agent: 'agent-1',
371
+ depth: 0, activation: 1.0 },
372
+ { id: 'uuid-2', content: 'related', content_type: 'concept',
373
+ confidence: 0.6, tags: ['ruby'], source_agent: 'agent-2',
374
+ depth: 1, activation: 0.48 }]
375
+ end
376
+
377
+ before do
378
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
379
+ stub_const('Legion::Data::Model::ApolloAccessLog', mock_access_log_class)
380
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
381
+ allow(mock_db).to receive(:fetch).and_return(double(all: traversal_results))
382
+ allow(mock_access_log_class).to receive(:create)
383
+ end
384
+
385
+ it 'executes traversal SQL and returns formatted entries' do
386
+ result = host.handle_traverse(entry_id: 'uuid-1', agent_id: 'agent-x')
387
+ expect(result[:success]).to be true
388
+ expect(result[:count]).to eq(2)
389
+ expect(result[:entries].first[:depth]).to eq(0)
390
+ expect(result[:entries].last[:activation]).to eq(0.48)
391
+ end
392
+
393
+ it 'logs access for known agents' do
394
+ expect(mock_access_log_class).to receive(:create).with(
395
+ hash_including(agent_id: 'agent-x', action: 'query')
396
+ )
397
+ host.handle_traverse(entry_id: 'uuid-1', agent_id: 'agent-x')
398
+ end
399
+
400
+ it 'skips access logging for unknown agents' do
401
+ expect(mock_access_log_class).not_to receive(:create)
402
+ host.handle_traverse(entry_id: 'uuid-1')
403
+ end
404
+
405
+ it 'filters invalid relation_types' do
406
+ expect(Legion::Extensions::Apollo::Helpers::GraphQuery).to receive(:build_traversal_sql).with(
407
+ hash_including(relation_types: ['causes'])
408
+ ).and_call_original
409
+ host.handle_traverse(entry_id: 'uuid-1', relation_types: %w[causes invalid_type])
410
+ end
411
+ end
412
+
413
+ context 'when Sequel raises an error' do
414
+ before do
415
+ stub_const('Legion::Data::Model::ApolloEntry', Class.new)
416
+ allow(Legion::Data::Model::ApolloEntry).to receive(:db)
417
+ .and_raise(Sequel::Error, 'connection lost')
418
+ end
419
+
420
+ it 'returns a structured error' do
421
+ result = host.handle_traverse(entry_id: 'uuid-1')
422
+ expect(result[:success]).to be false
423
+ expect(result[:error]).to eq('connection lost')
424
+ end
425
+ end
426
+ end
427
+
349
428
  describe '#redistribute_knowledge' do
350
429
  let(:host) { Object.new.extend(described_class) }
351
430
 
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/apollo/helpers/confidence'
5
+ require 'legion/extensions/apollo/helpers/similarity'
6
+ require 'legion/extensions/apollo/helpers/embedding'
7
+ require 'legion/extensions/apollo/helpers/graph_query'
8
+ require 'legion/extensions/apollo/runners/knowledge'
9
+ require 'legion/extensions/apollo/runners/request'
10
+
11
+ RSpec.describe Legion::Extensions::Apollo::Runners::Request do
12
+ before do
13
+ # Clear cached knowledge_host between examples
14
+ described_class.instance_variable_set(:@knowledge_host, nil)
15
+ end
16
+
17
+ describe '.data_required?' do
18
+ it 'returns false' do
19
+ expect(described_class.data_required?).to be false
20
+ end
21
+ end
22
+
23
+ describe '.query' do
24
+ context 'when local service is available' do
25
+ let(:mock_entry_class) { double('ApolloEntry') }
26
+ let(:mock_access_log_class) { double('ApolloAccessLog') }
27
+ let(:mock_db) { double('db') }
28
+ let(:sample_entries) do
29
+ [{ id: 'uuid-1', content: 'test', content_type: 'fact',
30
+ confidence: 0.8, distance: 0.15, tags: ['ruby'], source_agent: 'agent-1' }]
31
+ end
32
+
33
+ before do
34
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
35
+ stub_const('Legion::Data::Model::ApolloAccessLog', mock_access_log_class)
36
+ allow(Legion::Extensions::Apollo::Helpers::Embedding).to receive(:generate)
37
+ .and_return(Array.new(1536, 0.0))
38
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
39
+ allow(mock_db).to receive(:fetch).and_return(double(all: sample_entries))
40
+ allow(mock_entry_class).to receive(:where).and_return(double(update: true))
41
+ allow(mock_access_log_class).to receive(:create)
42
+ end
43
+
44
+ it 'delegates to Knowledge.handle_query' do
45
+ result = described_class.query(text: 'test query')
46
+ expect(result[:success]).to be true
47
+ expect(result[:entries]).to be_an(Array)
48
+ end
49
+ end
50
+
51
+ context 'when only transport is available' do
52
+ let(:mock_transport) do
53
+ Module.new do
54
+ def self.connected?
55
+ true
56
+ end
57
+
58
+ def self.respond_to?(method, *args)
59
+ method == :connected? || super
60
+ end
61
+ end
62
+ end
63
+ let(:mock_message) { double('message', publish: true) }
64
+
65
+ before do
66
+ hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
67
+ stub_const('Legion::Transport', mock_transport)
68
+ stub_const('Legion::Extensions::Apollo::Transport::Messages::Query',
69
+ double('QueryMsg', new: mock_message))
70
+ end
71
+
72
+ it 'publishes via transport' do
73
+ result = described_class.query(text: 'test query')
74
+ expect(result[:success]).to be true
75
+ expect(result[:dispatched]).to eq(:transport)
76
+ end
77
+ end
78
+
79
+ context 'when neither local nor transport is available' do
80
+ before do
81
+ hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
82
+ hide_const('Legion::Transport') if defined?(Legion::Transport)
83
+ end
84
+
85
+ it 'returns no_path_available error' do
86
+ result = described_class.query(text: 'test query')
87
+ expect(result[:success]).to be false
88
+ expect(result[:error]).to eq(:no_path_available)
89
+ end
90
+ end
91
+ end
92
+
93
+ describe '.retrieve' do
94
+ context 'when local service is available' do
95
+ let(:mock_entry_class) { double('ApolloEntry') }
96
+ let(:mock_db) { double('db') }
97
+
98
+ before do
99
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
100
+ allow(Legion::Extensions::Apollo::Helpers::Embedding).to receive(:generate)
101
+ .and_return(Array.new(1536, 0.0))
102
+ allow(mock_entry_class).to receive(:db).and_return(mock_db)
103
+ allow(mock_db).to receive(:fetch).and_return(double(all: []))
104
+ allow(mock_entry_class).to receive(:where).and_return(double(update: true))
105
+ end
106
+
107
+ it 'delegates to Knowledge.retrieve_relevant' do
108
+ result = described_class.retrieve(text: 'test query')
109
+ expect(result[:success]).to be true
110
+ expect(result[:entries]).to eq([])
111
+ end
112
+ end
113
+
114
+ context 'when neither path is available' do
115
+ before do
116
+ hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
117
+ hide_const('Legion::Transport') if defined?(Legion::Transport)
118
+ end
119
+
120
+ it 'returns no_path_available' do
121
+ result = described_class.retrieve(text: 'test')
122
+ expect(result[:success]).to be false
123
+ expect(result[:error]).to eq(:no_path_available)
124
+ end
125
+ end
126
+ end
127
+
128
+ describe '.ingest' do
129
+ context 'when local service is available' do
130
+ let(:mock_entry_class) { double('ApolloEntry') }
131
+ let(:mock_expertise_class) { double('ApolloExpertise') }
132
+ let(:mock_access_log_class) { double('ApolloAccessLog') }
133
+ let(:mock_entry) { double('entry', id: 'uuid-123', embedding: nil) }
134
+
135
+ before do
136
+ stub_const('Legion::Data::Model::ApolloEntry', mock_entry_class)
137
+ stub_const('Legion::Data::Model::ApolloExpertise', mock_expertise_class)
138
+ stub_const('Legion::Data::Model::ApolloAccessLog', mock_access_log_class)
139
+ allow(Legion::Extensions::Apollo::Helpers::Embedding).to receive(:generate)
140
+ .and_return(Array.new(1536, 0.0))
141
+ allow(mock_entry_class).to receive(:where).and_return(double(exclude: double(limit: double(each: nil))))
142
+ allow(mock_entry_class).to receive(:exclude)
143
+ .and_return(double(exclude: double(limit: double(all: []))))
144
+ allow(mock_entry_class).to receive(:db).and_return(double(fetch: double(all: [])))
145
+ allow(mock_entry_class).to receive(:create).and_return(mock_entry)
146
+ allow(mock_expertise_class).to receive(:where).and_return(double(first: nil))
147
+ allow(mock_expertise_class).to receive(:create)
148
+ allow(mock_access_log_class).to receive(:create)
149
+ end
150
+
151
+ it 'delegates to Knowledge.handle_ingest' do
152
+ result = described_class.ingest(content: 'test fact', content_type: 'fact', source_agent: 'agent-1')
153
+ expect(result[:success]).to be true
154
+ expect(result[:entry_id]).to eq('uuid-123')
155
+ end
156
+ end
157
+
158
+ context 'when neither path is available' do
159
+ before do
160
+ hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
161
+ hide_const('Legion::Transport') if defined?(Legion::Transport)
162
+ end
163
+
164
+ it 'returns no_path_available' do
165
+ result = described_class.ingest(content: 'test', content_type: 'fact')
166
+ expect(result[:success]).to be false
167
+ expect(result[:error]).to eq(:no_path_available)
168
+ end
169
+ end
170
+ end
171
+
172
+ describe '.traverse' do
173
+ context 'when neither path is available' do
174
+ before do
175
+ hide_const('Legion::Data::Model::ApolloEntry') if defined?(Legion::Data::Model::ApolloEntry)
176
+ hide_const('Legion::Transport') if defined?(Legion::Transport)
177
+ end
178
+
179
+ it 'returns no_path_available' do
180
+ result = described_class.traverse(entry_id: 'uuid-1')
181
+ expect(result[:success]).to be false
182
+ expect(result[:error]).to eq(:no_path_available)
183
+ end
184
+ end
185
+ end
186
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-apollo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.4
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -153,6 +153,7 @@ files:
153
153
  - lib/legion/extensions/apollo/actors/gas_subscriber.rb
154
154
  - lib/legion/extensions/apollo/actors/ingest.rb
155
155
  - lib/legion/extensions/apollo/actors/query_responder.rb
156
+ - lib/legion/extensions/apollo/api.rb
156
157
  - lib/legion/extensions/apollo/client.rb
157
158
  - lib/legion/extensions/apollo/gaia_integration.rb
158
159
  - lib/legion/extensions/apollo/helpers/confidence.rb
@@ -165,6 +166,7 @@ files:
165
166
  - lib/legion/extensions/apollo/runners/gas.rb
166
167
  - lib/legion/extensions/apollo/runners/knowledge.rb
167
168
  - lib/legion/extensions/apollo/runners/maintenance.rb
169
+ - lib/legion/extensions/apollo/runners/request.rb
168
170
  - lib/legion/extensions/apollo/transport.rb
169
171
  - lib/legion/extensions/apollo/transport/exchanges/apollo.rb
170
172
  - lib/legion/extensions/apollo/transport/exchanges/llm_audit.rb
@@ -179,6 +181,7 @@ files:
179
181
  - spec/legion/extensions/apollo/actors/expertise_aggregator_spec.rb
180
182
  - spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb
181
183
  - spec/legion/extensions/apollo/actors/ingest_spec.rb
184
+ - spec/legion/extensions/apollo/api_spec.rb
182
185
  - spec/legion/extensions/apollo/client_spec.rb
183
186
  - spec/legion/extensions/apollo/contradiction_spec.rb
184
187
  - spec/legion/extensions/apollo/gaia_integration_spec.rb
@@ -196,6 +199,7 @@ files:
196
199
  - spec/legion/extensions/apollo/runners/gas_synthesize_spec.rb
197
200
  - spec/legion/extensions/apollo/runners/knowledge_spec.rb
198
201
  - spec/legion/extensions/apollo/runners/maintenance_spec.rb
202
+ - spec/legion/extensions/apollo/runners/request_spec.rb
199
203
  - spec/legion/extensions/apollo/transport/messages/ingest_spec.rb
200
204
  - spec/legion/extensions/apollo/transport/messages/query_spec.rb
201
205
  - spec/spec_helper.rb