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