legion-apollo 0.3.3 → 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 +18 -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/version.rb +1 -1
- data/lib/legion/apollo.rb +22 -0
- metadata +3 -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,23 @@
|
|
|
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
|
+
|
|
3
21
|
## [0.3.3] - 2026-03-28
|
|
4
22
|
|
|
5
23
|
### 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
|
|
data/lib/legion/apollo.rb
CHANGED
|
@@ -89,6 +89,28 @@ module Legion
|
|
|
89
89
|
query(text: text, limit: limit, scope: scope, **)
|
|
90
90
|
end
|
|
91
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
|
+
|
|
92
114
|
def transport_available?
|
|
93
115
|
@transport_available == true
|
|
94
116
|
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,7 +81,9 @@ 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
|