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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56d4a2af4184bafd3474940f42a2b4e918490470b8faa3e5334f96bf27baff02
4
- data.tar.gz: 6b4e4b7fcf9b93331de7e32214bee5415b0d6a47b20d4624894247c8350cc62c
3
+ metadata.gz: 1b3c0668c2af6ecd24eec20affa9da56b2418d4c37ffde60529f15072712213e
4
+ data.tar.gz: faac4c926f2db4c77533ed589042020e521686866334b4cb0fadf6fc9f69f71f
5
5
  SHA512:
6
- metadata.gz: 05a8fc891f57c4e9d71619c993c343753cc7674984847224957aa7f5621faa240b9d9017bdf9bd277a17ed1ef39d626229ac9058e66da55d7a477a47aa8b5c51
7
- data.tar.gz: d80855fc0f2026c2035fd2b411a2d6daaa3684125455fa46ab161dbae0e9831d00437be0faa171718cbf811d5834a1ddd138e491891c96ad87d428ae4ce2f15d
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
@@ -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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Apollo
5
- VERSION = '0.3.3'
5
+ VERSION = '0.3.5'
6
6
  end
7
7
  end
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.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