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 +4 -4
- data/CHANGELOG.md +27 -0
- data/lib/legion/apollo/local/graph.rb +248 -0
- data/lib/legion/apollo/local/migrations/002_create_graph_tables.rb +40 -0
- data/lib/legion/apollo/local.rb +7 -2
- data/lib/legion/apollo/routes.rb +242 -0
- data/lib/legion/apollo/version.rb +1 -1
- data/lib/legion/apollo.rb +33 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1b3c0668c2af6ecd24eec20affa9da56b2418d4c37ffde60529f15072712213e
|
|
4
|
+
data.tar.gz: faac4c926f2db4c77533ed589042020e521686866334b4cb0fadf6fc9f69f71f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/legion/apollo/local.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
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.
|
|
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
|