legion-apollo 0.3.2 → 0.3.5

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: 1c8e724eefe292faefec05ca4fc589ed935f4734bc5c4726b7c5d70ed3b69dbb
4
- data.tar.gz: 3fc0246858d50305bd2d956e87c9ec844c71a769c4f81955f43d2640159a175c
3
+ metadata.gz: 1b3c0668c2af6ecd24eec20affa9da56b2418d4c37ffde60529f15072712213e
4
+ data.tar.gz: faac4c926f2db4c77533ed589042020e521686866334b4cb0fadf6fc9f69f71f
5
5
  SHA512:
6
- metadata.gz: eb2815139791a2ed4abd5f09f6ed2b5cf27b89c399ed00276fc58e8ebfaceede00eb1b0cc02f43371ce5099d12467398e7ce92e58352cc854bb4b5eecc4a2c1c
7
- data.tar.gz: 763ff525cdf09284bc3a7c3337407bfcaa8a750f5dddaeb66e1a1b082742a9e158b813a39a0d606c4c1740ba26137c6b565c6152688bd6f007b601ed98dd6fd1
6
+ metadata.gz: efe1e40e94d96b16dfe997386fd4f55c2e62bb994e9bd701c0466ce630470d9f0c723d141ec52565f151d0a869c7c47beae074a5f60b4b05d57fe2fcae261ad4
7
+ data.tar.gz: a5e2d49ec89a148cfec6678fc3099839f82095904eb84e2c828aeb1dfdc424fd8dee236d23434c5e3a4ef407c7ebf49350705410e82f6f873c70e4c19dd2dec2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.5] - 2026-03-28
4
+
5
+ ### Fixed
6
+ - use `Legion::LLM.embed` instead of `Legion::LLM::Embeddings.generate` in Local store — the Embeddings module is autoloaded and not available until `embed` or `embed_direct` is called through the public API
7
+
8
+ ## [0.3.4] - 2026-03-28
9
+
10
+ ### Added
11
+ - `Legion::Apollo::Local::Graph` — entity-relationship graph layer backed by local SQLite tables
12
+ - `create_entity`, `find_entity`, `find_entities_by_type`, `find_entities_by_name`, `update_entity`, `delete_entity` — full entity CRUD
13
+ - `create_relationship`, `find_relationships`, `delete_relationship` — directional typed edge CRUD
14
+ - `traverse(entity_id:, relation_type:, depth:, direction:)` — iterative BFS graph traversal with depth limiting (max 10), relation-type and direction (:outbound/:inbound) filtering, cycle-safe visited set, no duplicate nodes or edges in result
15
+ - `delete_entity` cascades and removes associated relationships
16
+ - `find_relationships` supports `direction: :both` via SQLite UNION
17
+ - Migration `002_create_graph_tables` — `local_entities` and `local_relationships` tables with indexes and foreign keys
18
+ - `Legion::Apollo::Local.graph` accessor returning `Legion::Apollo::Local::Graph`
19
+ - `Legion::Apollo.graph_query(entity_id:, relation_type:, depth:, direction:)` — public API delegating to `Local::Graph.traverse`; returns `:local_not_started` when Local store is unavailable
20
+
21
+ ## [0.3.3] - 2026-03-28
22
+
23
+ ### Added
24
+ - `Legion::Apollo::Routes` Sinatra extension module (`lib/legion/apollo/routes.rb`): all `/api/apollo/*` route definitions extracted from `LegionIO/lib/legion/api/apollo.rb`. Self-registers with `Legion::API.register_library_routes('apollo', Legion::Apollo::Routes)` during `Legion::Apollo.start`, immediately after `@started` is set (before `Local.start` / `seed_self_knowledge`).
25
+ - `register_routes` private method on `Legion::Apollo` module.
26
+
27
+ ### Changed
28
+ - `Legion::Apollo.start` now calls `register_routes` after setting `@started = true`.
29
+
3
30
  ## [0.3.2] - 2026-03-26
4
31
 
5
32
  ### Added
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Legion
6
+ module Apollo
7
+ module Local
8
+ # Entity-relationship graph layer backed by local SQLite tables.
9
+ # Entities are schema-flexible (type + name + domain + JSON attributes).
10
+ # Relationships are directional typed edges between two entities.
11
+ # Graph traversal uses recursive SQLite CTEs (max_depth enforced).
12
+ module Graph # rubocop:disable Metrics/ModuleLength
13
+ VALID_RELATION_TYPES = %w[AFFECTS OWNED_BY DEPENDS_ON RELATED_TO].freeze
14
+
15
+ class << self # rubocop:disable Metrics/ClassLength
16
+ # --- Entity CRUD ---
17
+
18
+ def create_entity(type:, name:, domain: nil, attributes: {}) # rubocop:disable Metrics/MethodLength
19
+ now = timestamp
20
+ id = db[:local_entities].insert(
21
+ entity_type: type.to_s,
22
+ name: name.to_s,
23
+ domain: domain&.to_s,
24
+ attributes: encode(attributes),
25
+ created_at: now,
26
+ updated_at: now
27
+ )
28
+ { success: true, id: id }
29
+ rescue Sequel::Error => e
30
+ { success: false, error: e.message }
31
+ end
32
+
33
+ def find_entity(id:)
34
+ row = db[:local_entities].where(id: id).first
35
+ return { success: false, error: :not_found } unless row
36
+
37
+ { success: true, entity: decode_entity(row) }
38
+ rescue Sequel::Error => e
39
+ { success: false, error: e.message }
40
+ end
41
+
42
+ def find_entities_by_type(type:, limit: 50)
43
+ rows = db[:local_entities].where(entity_type: type.to_s).limit(limit).all
44
+ { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
45
+ rescue Sequel::Error => e
46
+ { success: false, error: e.message }
47
+ end
48
+
49
+ def find_entities_by_name(name:, limit: 50)
50
+ rows = db[:local_entities].where(name: name.to_s).limit(limit).all
51
+ { success: true, entities: rows.map { |r| decode_entity(r) }, count: rows.size }
52
+ rescue Sequel::Error => e
53
+ { success: false, error: e.message }
54
+ end
55
+
56
+ def update_entity(id:, **fields)
57
+ now = timestamp
58
+ updates = fields.slice(:entity_type, :name, :domain).transform_values(&:to_s)
59
+ updates[:attributes] = encode(fields[:attributes]) if fields.key?(:attributes)
60
+ updates[:updated_at] = now
61
+
62
+ count = db[:local_entities].where(id: id).update(updates)
63
+ return { success: false, error: :not_found } if count.zero?
64
+
65
+ { success: true, id: id }
66
+ rescue Sequel::Error => e
67
+ { success: false, error: e.message }
68
+ end
69
+
70
+ def delete_entity(id:)
71
+ db[:local_relationships].where(source_entity_id: id).delete
72
+ db[:local_relationships].where(target_entity_id: id).delete
73
+ count = db[:local_entities].where(id: id).delete
74
+ return { success: false, error: :not_found } if count.zero?
75
+
76
+ { success: true, id: id }
77
+ rescue Sequel::Error => e
78
+ { success: false, error: e.message }
79
+ end
80
+
81
+ # --- Relationship CRUD ---
82
+
83
+ def create_relationship(source_id:, target_id:, relation_type:, attributes: {}) # rubocop:disable Metrics/MethodLength
84
+ now = timestamp
85
+ id = db[:local_relationships].insert(
86
+ source_entity_id: source_id,
87
+ target_entity_id: target_id,
88
+ relation_type: relation_type.to_s.upcase,
89
+ attributes: encode(attributes),
90
+ created_at: now,
91
+ updated_at: now
92
+ )
93
+ { success: true, id: id }
94
+ rescue Sequel::Error => e
95
+ { success: false, error: e.message }
96
+ end
97
+
98
+ def find_relationships(entity_id:, relation_type: nil, direction: :outbound)
99
+ ds = case direction
100
+ when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
101
+ when :both then relationship_both_directions(entity_id)
102
+ else db[:local_relationships].where(source_entity_id: entity_id)
103
+ end
104
+ ds = ds.where(relation_type: relation_type.to_s.upcase) if relation_type
105
+ rows = ds.all
106
+ { success: true, relationships: rows.map { |r| decode_relationship(r) }, count: rows.size }
107
+ rescue Sequel::Error => e
108
+ { success: false, error: e.message }
109
+ end
110
+
111
+ def delete_relationship(id:)
112
+ count = db[:local_relationships].where(id: id).delete
113
+ return { success: false, error: :not_found } if count.zero?
114
+
115
+ { success: true, id: id }
116
+ rescue Sequel::Error => e
117
+ { success: false, error: e.message }
118
+ end
119
+
120
+ # --- Graph Traversal ---
121
+
122
+ # Traverse from an entity following edges of the given relation_type.
123
+ # Returns all reachable entities within max_depth hops using a recursive CTE.
124
+ #
125
+ # @param entity_id [Integer] starting entity id
126
+ # @param relation_type [String, nil] filter by edge type (nil = any)
127
+ # @param depth [Integer] maximum traversal depth (default 3, max 10)
128
+ # @param direction [Symbol] :outbound (default) or :inbound
129
+ # @return [Hash] { success:, nodes:, edges:, count: }
130
+ def traverse(entity_id:, relation_type: nil, depth: 3, direction: :outbound) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
131
+ max_depth = [depth.to_i.clamp(1, 10), 10].min
132
+ rel_filter = relation_type&.to_s&.upcase
133
+
134
+ visited_ids, edge_rows = run_traversal(entity_id, rel_filter, max_depth, direction)
135
+ entity_rows = visited_ids.empty? ? [] : db[:local_entities].where(id: visited_ids).all
136
+
137
+ {
138
+ success: true,
139
+ nodes: entity_rows.map { |r| decode_entity(r) },
140
+ edges: edge_rows.map { |r| decode_relationship(r) },
141
+ count: entity_rows.size
142
+ }
143
+ rescue Sequel::Error => e
144
+ { success: false, error: e.message }
145
+ end
146
+
147
+ private
148
+
149
+ def run_traversal(start_id, rel_filter, max_depth, direction) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
150
+ visited = Set.new
151
+ frontier = [start_id]
152
+ edge_rows = []
153
+
154
+ # Run max_depth + 1 passes so that depth=1 means "start node + its direct neighbors".
155
+ # Pass 0 visits the start node and pushes its neighbors into the next frontier.
156
+ # Passes 1..max_depth each follow one more hop.
157
+ (max_depth + 1).times do
158
+ break if frontier.empty?
159
+
160
+ next_frontier = []
161
+ frontier.each do |current_id|
162
+ next if visited.include?(current_id)
163
+
164
+ visited.add(current_id)
165
+ neighbors, edges = fetch_neighbors(current_id, rel_filter, direction)
166
+ edge_rows.concat(edges)
167
+ neighbors.each { |n| next_frontier << n unless visited.include?(n) }
168
+ end
169
+ frontier = next_frontier
170
+ end
171
+
172
+ [visited.to_a, edge_rows.uniq { |r| r[:id] }]
173
+ end
174
+
175
+ def fetch_neighbors(entity_id, rel_filter, direction)
176
+ ds = case direction
177
+ when :inbound then db[:local_relationships].where(target_entity_id: entity_id)
178
+ else db[:local_relationships].where(source_entity_id: entity_id)
179
+ end
180
+ ds = ds.where(relation_type: rel_filter) if rel_filter
181
+ rows = ds.all
182
+
183
+ neighbor_ids = rows.map do |r|
184
+ direction == :inbound ? r[:source_entity_id] : r[:target_entity_id]
185
+ end
186
+
187
+ [neighbor_ids, rows]
188
+ end
189
+
190
+ def relationship_both_directions(entity_id)
191
+ src = db[:local_relationships].where(source_entity_id: entity_id)
192
+ tgt = db[:local_relationships].where(target_entity_id: entity_id)
193
+ # Sequel union for SQLite
194
+ src.union(tgt)
195
+ end
196
+
197
+ def db
198
+ Legion::Data::Local.connection
199
+ end
200
+
201
+ def timestamp
202
+ Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
203
+ end
204
+
205
+ def encode(obj)
206
+ return '{}' if obj.nil? || obj.empty?
207
+
208
+ Legion::JSON.dump(obj)
209
+ rescue StandardError
210
+ '{}'
211
+ end
212
+
213
+ def decode(json_str)
214
+ return {} if json_str.nil? || json_str.strip.empty?
215
+
216
+ Legion::JSON.parse(json_str, symbolize_names: true)
217
+ rescue StandardError
218
+ {}
219
+ end
220
+
221
+ def decode_entity(row)
222
+ {
223
+ id: row[:id],
224
+ entity_type: row[:entity_type],
225
+ name: row[:name],
226
+ domain: row[:domain],
227
+ attributes: decode(row[:attributes]),
228
+ created_at: row[:created_at],
229
+ updated_at: row[:updated_at]
230
+ }
231
+ end
232
+
233
+ def decode_relationship(row)
234
+ {
235
+ id: row[:id],
236
+ source_entity_id: row[:source_entity_id],
237
+ target_entity_id: row[:target_entity_id],
238
+ relation_type: row[:relation_type],
239
+ attributes: decode(row[:attributes]),
240
+ created_at: row[:created_at],
241
+ updated_at: row[:updated_at]
242
+ }
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do # rubocop:disable Metrics/BlockLength
4
+ up do # rubocop:disable Metrics/BlockLength
5
+ create_table(:local_entities) do
6
+ primary_key :id
7
+ String :entity_type, null: false, size: 128
8
+ String :name, null: false, size: 512
9
+ String :domain, size: 256
10
+ String :attributes, text: true # JSON bag
11
+ String :created_at, null: false
12
+ String :updated_at, null: false
13
+
14
+ index :entity_type, name: :idx_local_entities_type
15
+ index :name, name: :idx_local_entities_name
16
+ index %i[entity_type name], name: :idx_local_entities_type_name
17
+ end
18
+
19
+ create_table(:local_relationships) do
20
+ primary_key :id
21
+ Integer :source_entity_id, null: false
22
+ Integer :target_entity_id, null: false
23
+ String :relation_type, null: false, size: 128
24
+ String :attributes, text: true # JSON bag
25
+ String :created_at, null: false
26
+ String :updated_at, null: false
27
+
28
+ foreign_key [:source_entity_id], :local_entities, name: :fk_rel_source
29
+ foreign_key [:target_entity_id], :local_entities, name: :fk_rel_target
30
+ index :relation_type, name: :idx_local_rel_type
31
+ index %i[source_entity_id relation_type], name: :idx_local_rel_src_type
32
+ index %i[target_entity_id relation_type], name: :idx_local_rel_tgt_type
33
+ end
34
+ end
35
+
36
+ down do
37
+ drop_table(:local_relationships) if table_exists?(:local_relationships)
38
+ drop_table(:local_entities) if table_exists?(:local_entities)
39
+ end
40
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'digest'
4
4
  require 'time'
5
+ require_relative 'local/graph'
5
6
 
6
7
  module Legion
7
8
  module Apollo
@@ -84,6 +85,10 @@ module Legion
84
85
  query(text: text, limit: limit, **)
85
86
  end
86
87
 
88
+ def graph
89
+ Legion::Apollo::Local::Graph
90
+ end
91
+
87
92
  def reset!
88
93
  @started = false
89
94
  @seeded = false
@@ -190,7 +195,7 @@ module Legion
190
195
  return [nil, nil]
191
196
  end
192
197
 
193
- result = Legion::LLM::Embeddings.generate(text: content)
198
+ result = Legion::LLM.embed(content)
194
199
  vector = result.is_a?(Hash) ? result[:vector] : result
195
200
  if vector.is_a?(Array) && vector.any?
196
201
  [vector, Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')]
@@ -256,7 +261,7 @@ module Legion
256
261
  end
257
262
 
258
263
  def cosine_rerank(text, candidates) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
259
- query_result = Legion::LLM::Embeddings.generate(text: text)
264
+ query_result = Legion::LLM.embed(text)
260
265
  query_vec = query_result.is_a?(Hash) ? query_result[:vector] : query_result
261
266
  return candidates unless query_vec.is_a?(Array) && query_vec.any?
262
267
 
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helpers/tag_normalizer'
4
+
5
+ # Self-registering route module for legion-apollo.
6
+ # All routes previously defined in LegionIO/lib/legion/api/apollo.rb now live here
7
+ # and are mounted via Legion::API.register_library_routes when legion-apollo boots.
8
+ #
9
+ # LegionIO/lib/legion/api/apollo.rb is preserved for backward compatibility but guards
10
+ # its registration with defined?(Legion::Apollo::Routes) so double-registration is avoided.
11
+
12
+ module Legion
13
+ module Apollo
14
+ # Sinatra route module for Apollo API endpoints. Self-registers at boot.
15
+ module Routes # rubocop:disable Metrics/ModuleLength
16
+ def self.registered(app)
17
+ app.helpers ApolloHelpers
18
+ register_status_route(app)
19
+ register_stats_route(app)
20
+ register_query_route(app)
21
+ register_ingest_route(app)
22
+ register_related_route(app)
23
+ register_maintenance_route(app)
24
+ register_graph_route(app)
25
+ register_expertise_route(app)
26
+ end
27
+
28
+ def self.register_status_route(app)
29
+ app.get '/api/apollo/status' do
30
+ available = apollo_runner_available?
31
+ data_connected = apollo_data_connected?
32
+ status_code = available && data_connected ? 200 : 503
33
+
34
+ json_response({ available: available, data_connected: data_connected },
35
+ status_code: status_code)
36
+ end
37
+ end
38
+
39
+ def self.register_stats_route(app)
40
+ app.get '/api/apollo/stats' do
41
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
42
+
43
+ stats = apollo_stats
44
+ if stats[:error]
45
+ halt 503, json_error('apollo_stats_unavailable', stats[:error], status_code: 503)
46
+ else
47
+ json_response(stats)
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.register_query_route(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
53
+ app.post '/api/apollo/query' do
54
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
55
+
56
+ body = parse_request_body
57
+ default_limit = defined?(Legion::Settings) ? (Legion::Settings[:apollo]&.dig(:default_limit) || 5) : 5
58
+ result = apollo_runner.handle_query(
59
+ query: body[:query],
60
+ limit: body[:limit] || default_limit,
61
+ min_confidence: body[:min_confidence] || 0.3,
62
+ status: body[:status] || [:confirmed],
63
+ tags: body[:tags],
64
+ domain: body[:domain],
65
+ agent_id: body[:agent_id] || 'api'
66
+ )
67
+ json_response(result)
68
+ end
69
+ end
70
+
71
+ def self.register_ingest_route(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
72
+ app.post '/api/apollo/ingest' do
73
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
74
+
75
+ body = parse_request_body
76
+ max_tags = defined?(Legion::Settings) ? (Legion::Settings[:apollo]&.dig(:max_tags) || 20) : 20
77
+ # TagNormalizer hard-caps to MAX_TAGS=20 internally; clamp here to make that limit explicit.
78
+ effective_max_tags = [max_tags, Legion::Apollo::Helpers::TagNormalizer::MAX_TAGS].min
79
+ tags = Legion::Apollo::Helpers::TagNormalizer.normalize(Array(body[:tags])).first(effective_max_tags)
80
+ result = apollo_runner.handle_ingest(
81
+ content: body[:content],
82
+ content_type: body[:content_type] || :observation,
83
+ tags: tags,
84
+ source_agent: body[:source_agent] || 'api',
85
+ source_provider: body[:source_provider],
86
+ source_channel: body[:source_channel] || 'rest_api',
87
+ knowledge_domain: body[:knowledge_domain],
88
+ context: body[:context] || {}
89
+ )
90
+ json_response(result, status_code: 201)
91
+ end
92
+ end
93
+
94
+ def self.register_related_route(app)
95
+ app.get '/api/apollo/entries/:id/related' do
96
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
97
+
98
+ result = apollo_runner.related_entries(
99
+ entry_id: params[:id].to_i,
100
+ relation_types: params[:relation_types]&.split(','),
101
+ depth: (params[:depth] || 2).to_i
102
+ )
103
+ json_response(result)
104
+ end
105
+ end
106
+
107
+ def self.register_maintenance_route(app) # rubocop:disable Metrics/MethodLength
108
+ app.post '/api/apollo/maintenance' do
109
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
110
+
111
+ body = parse_request_body
112
+ action_str = body[:action]
113
+ unless %w[
114
+ decay_cycle corroboration
115
+ ].include?(action_str)
116
+ halt 400,
117
+ json_error('invalid_action', 'action must be decay_cycle or corroboration', status_code: 400)
118
+ end
119
+
120
+ action = action_str.to_sym
121
+
122
+ result = run_maintenance(action)
123
+ json_response(result)
124
+ end
125
+ end
126
+
127
+ def self.register_graph_route(app)
128
+ app.get '/api/apollo/graph' do
129
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
130
+
131
+ json_response(apollo_graph_topology)
132
+ end
133
+ end
134
+
135
+ def self.register_expertise_route(app)
136
+ app.get '/api/apollo/expertise' do
137
+ halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded?
138
+
139
+ json_response(apollo_expertise_map)
140
+ end
141
+ end
142
+
143
+ class << self
144
+ private :register_status_route, :register_stats_route, :register_query_route,
145
+ :register_ingest_route, :register_related_route, :register_maintenance_route,
146
+ :register_graph_route, :register_expertise_route
147
+ end
148
+
149
+ # Helper methods mixed into the Sinatra app context
150
+ module ApolloHelpers
151
+ def apollo_runner_available?
152
+ return false unless defined?(Legion::Extensions::Apollo::Runners::Knowledge)
153
+
154
+ required = %i[handle_query handle_ingest related_entries]
155
+ required.all? { |m| Legion::Extensions::Apollo::Runners::Knowledge.respond_to?(m) }
156
+ rescue StandardError => e
157
+ if defined?(Legion::Logging)
158
+ Legion::Logging.debug("Apollo#apollo_runner_available? check failed: #{e.message}")
159
+ end
160
+ false
161
+ end
162
+
163
+ def apollo_loaded?
164
+ apollo_runner_available? && apollo_data_connected?
165
+ end
166
+
167
+ def apollo_data_connected?
168
+ defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && !Legion::Data.connection.nil?
169
+ rescue StandardError => e
170
+ Legion::Logging.debug("Apollo#apollo_data_connected? check failed: #{e.message}") if defined?(Legion::Logging)
171
+ false
172
+ end
173
+
174
+ def apollo_runner
175
+ Legion::Extensions::Apollo::Runners::Knowledge
176
+ end
177
+
178
+ def apollo_maintenance_runner # rubocop:disable Metrics/MethodLength
179
+ @apollo_maintenance_runner ||= begin
180
+ unless defined?(Legion::Extensions::Apollo::Runners::Maintenance)
181
+ halt 503, json_error('maintenance_unavailable', 'Apollo maintenance runner is not loaded')
182
+ end
183
+
184
+ runner = Object.new.extend(Legion::Extensions::Apollo::Runners::Maintenance)
185
+ required = %i[run_decay_cycle check_corroboration]
186
+ unless required.all? { |m| runner.respond_to?(m) }
187
+ halt 503, json_error('maintenance_unavailable', 'Apollo maintenance runner is missing required actions')
188
+ end
189
+
190
+ runner
191
+ end
192
+ end
193
+
194
+ def run_maintenance(action)
195
+ case action
196
+ when :decay_cycle
197
+ apollo_maintenance_runner.run_decay_cycle
198
+ when :corroboration
199
+ apollo_maintenance_runner.check_corroboration
200
+ end
201
+ end
202
+
203
+ def apollo_graph_topology
204
+ return { error: 'Apollo runner unavailable' } unless apollo_runner_available?
205
+ unless apollo_runner.respond_to?(:graph_topology)
206
+ return { error: 'Apollo graph_topology not supported by runner' }
207
+ end
208
+
209
+ apollo_runner.graph_topology
210
+ rescue StandardError => e
211
+ Legion::Logging.debug("Apollo#apollo_graph_topology failed: #{e.message}") if defined?(Legion::Logging)
212
+ { error: 'apollo_graph_topology unavailable' }
213
+ end
214
+
215
+ def apollo_expertise_map
216
+ return { error: 'Apollo runner unavailable' } unless apollo_runner_available?
217
+ unless apollo_runner.respond_to?(:expertise_map)
218
+ return { error: 'Apollo expertise_map not supported by runner' }
219
+ end
220
+
221
+ apollo_runner.expertise_map
222
+ rescue StandardError => e
223
+ Legion::Logging.debug("Apollo#apollo_expertise_map failed: #{e.message}") if defined?(Legion::Logging)
224
+ { error: 'apollo_expertise_map unavailable' }
225
+ end
226
+
227
+ def apollo_stats
228
+ return { total_entries: 0, error: 'Apollo runner unavailable' } unless apollo_runner_available?
229
+ unless apollo_runner.respond_to?(:stats)
230
+ return { total_entries: 0,
231
+ error: 'Apollo stats not supported by runner' }
232
+ end
233
+
234
+ apollo_runner.stats
235
+ rescue StandardError => e
236
+ Legion::Logging.debug("Apollo#apollo_stats failed: #{e.message}") if defined?(Legion::Logging)
237
+ { total_entries: 0, error: 'apollo_stats unavailable' }
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Apollo
5
- VERSION = '0.3.2'
5
+ VERSION = '0.3.5'
6
6
  end
7
7
  end
data/lib/legion/apollo.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'apollo/version'
5
5
  require_relative 'apollo/settings'
6
6
  require_relative 'apollo/local'
7
7
  require_relative 'apollo/runners'
8
+ require_relative 'apollo/routes'
8
9
 
9
10
  module Legion
10
11
  # Apollo client library — query, ingest, and retrieve with smart routing.
@@ -23,6 +24,7 @@ module Legion
23
24
  @started = true
24
25
  Legion::Logging.info 'Legion::Apollo started' if defined?(Legion::Logging)
25
26
 
27
+ register_routes
26
28
  Legion::Apollo::Local.start
27
29
  seed_self_knowledge
28
30
  end
@@ -87,6 +89,28 @@ module Legion
87
89
  query(text: text, limit: limit, scope: scope, **)
88
90
  end
89
91
 
92
+ # Graph traversal — delegates to Local::Graph for node-local SQLite store.
93
+ # Follows entity edges of the given relation_type up to depth hops.
94
+ #
95
+ # @param entity_id [Integer] starting entity id
96
+ # @param relation_type [String, nil] edge filter (nil = any)
97
+ # @param depth [Integer] max traversal hops (1..10)
98
+ # @param direction [Symbol] :outbound (default) or :inbound
99
+ # @return [Hash] { success:, nodes:, edges:, count: }
100
+ def graph_query(entity_id:, relation_type: nil, depth: 3, direction: :outbound)
101
+ return not_started_error unless started?
102
+ return { success: false, error: :local_not_started } unless Legion::Apollo::Local.started?
103
+
104
+ Legion::Apollo::Local::Graph.traverse(
105
+ entity_id: entity_id,
106
+ relation_type: relation_type,
107
+ depth: depth,
108
+ direction: direction
109
+ )
110
+ rescue StandardError => e
111
+ { success: false, error: e.message }
112
+ end
113
+
90
114
  def transport_available?
91
115
  @transport_available == true
92
116
  end
@@ -307,6 +331,15 @@ module Legion
307
331
  def not_started_error
308
332
  { success: false, error: :not_started }
309
333
  end
334
+
335
+ def register_routes
336
+ return unless defined?(Legion::API) && Legion::API.respond_to?(:register_library_routes)
337
+
338
+ Legion::API.register_library_routes('apollo', Legion::Apollo::Routes)
339
+ Legion::Logging.debug 'Legion::Apollo routes registered with API' if defined?(Legion::Logging)
340
+ rescue StandardError => e
341
+ Legion::Logging.warn "Legion::Apollo route registration failed: #{e.message}" if defined?(Legion::Logging)
342
+ end
310
343
  end
311
344
  end
312
345
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-apollo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -81,11 +81,14 @@ files:
81
81
  - lib/legion/apollo/helpers/similarity.rb
82
82
  - lib/legion/apollo/helpers/tag_normalizer.rb
83
83
  - lib/legion/apollo/local.rb
84
+ - lib/legion/apollo/local/graph.rb
84
85
  - lib/legion/apollo/local/migrations/001_create_local_knowledge.rb
86
+ - lib/legion/apollo/local/migrations/002_create_graph_tables.rb
85
87
  - lib/legion/apollo/messages/access_boost.rb
86
88
  - lib/legion/apollo/messages/ingest.rb
87
89
  - lib/legion/apollo/messages/query.rb
88
90
  - lib/legion/apollo/messages/writeback.rb
91
+ - lib/legion/apollo/routes.rb
89
92
  - lib/legion/apollo/runners.rb
90
93
  - lib/legion/apollo/runners/request.rb
91
94
  - lib/legion/apollo/settings.rb