lex-apollo 0.3.9 → 0.4.0
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 +13 -0
- data/lib/legion/extensions/apollo/actors/gas_subscriber.rb +25 -0
- data/lib/legion/extensions/apollo/runners/gas.rb +375 -0
- data/lib/legion/extensions/apollo/transport/queues/gas.rb +23 -0
- data/lib/legion/extensions/apollo/version.rb +1 -1
- data/lib/legion/extensions/apollo.rb +2 -0
- data/spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb +49 -0
- data/spec/legion/extensions/apollo/runners/gas_anticipate_spec.rb +101 -0
- data/spec/legion/extensions/apollo/runners/gas_relate_spec.rb +115 -0
- data/spec/legion/extensions/apollo/runners/gas_spec.rb +108 -0
- data/spec/legion/extensions/apollo/runners/gas_synthesize_spec.rb +107 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8e6e95c43807f0f9a3eaaaaa0a0d4cadafdbf279acb4829fe0c0ef6ce909e6bc
|
|
4
|
+
data.tar.gz: c336ca5086ffc85995bf6a8759799d48d65edf905f12458de4aa63c24608d0fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84bfc36365061507970f0fb99c084a2729702041b416211791c90309e1f9e3ce54f1ba2fb9986ebf4e5412d327822ce82f51f83274df6cff758e4564d4786f84
|
|
7
|
+
data.tar.gz: ede777694a9e166d0ce8b8862f444eb02c231204a89c265f850503ca78a8001b89bf3430995a1fb2ca324040467831dee73a5563caa81105a67956721507b505
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.0] - 2026-03-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Runners::Gas`: 6-phase GAS (Generation-Augmented Storage) pipeline
|
|
7
|
+
- Phase 1 (Comprehend): LLM-based fact extraction via GaiaCaller, mechanical fallback
|
|
8
|
+
- Phase 2 (Extract): delegates to existing `EntityExtractor` runner
|
|
9
|
+
- Phase 3 (Relate): queries Apollo for similar entries, classifies 8 relationship types via LLM, confidence > 0.7 gate
|
|
10
|
+
- Phase 4 (Synthesize): generates derivative knowledge with geometric mean confidence, capped at 0.7
|
|
11
|
+
- Phase 5 (Deposit): atomic write of facts to Apollo via `Knowledge.handle_ingest`
|
|
12
|
+
- Phase 6 (Anticipate): generates follow-up questions, promotes to TBI PatternStore when available
|
|
13
|
+
- `Actor::GasSubscriber`: subscription actor binding to `llm.audit.complete` routing key
|
|
14
|
+
- `Transport::Queues::Gas`: durable queue for GAS audit event processing
|
|
15
|
+
|
|
3
16
|
## [0.3.9] - 2026-03-23
|
|
4
17
|
|
|
5
18
|
### Fixed
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/subscription' if defined?(Legion::Extensions::Actors::Subscription)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Apollo
|
|
8
|
+
module Actor
|
|
9
|
+
class GasSubscriber < Legion::Extensions::Actors::Subscription
|
|
10
|
+
def runner_class = 'Legion::Extensions::Apollo::Runners::Gas'
|
|
11
|
+
def runner_function = 'process'
|
|
12
|
+
def check_subtask? = false
|
|
13
|
+
def generate_task? = false
|
|
14
|
+
|
|
15
|
+
def enabled?
|
|
16
|
+
defined?(Legion::Extensions::Apollo::Runners::Gas) &&
|
|
17
|
+
defined?(Legion::Transport)
|
|
18
|
+
rescue StandardError
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Apollo
|
|
6
|
+
module Runners
|
|
7
|
+
module Gas
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def process(audit_event)
|
|
11
|
+
return { phases_completed: 0, reason: 'no content' } unless processable?(audit_event)
|
|
12
|
+
|
|
13
|
+
facts = phase_comprehend(audit_event)
|
|
14
|
+
entities = phase_extract(audit_event, facts)
|
|
15
|
+
relations = phase_relate(facts, entities)
|
|
16
|
+
synthesis = phase_synthesize(facts, relations)
|
|
17
|
+
deposit_result = phase_deposit(facts, entities, relations, synthesis, audit_event)
|
|
18
|
+
anticipations = phase_anticipate(facts, synthesis)
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
phases_completed: 6,
|
|
22
|
+
facts: facts.length,
|
|
23
|
+
entities: entities.length,
|
|
24
|
+
relations: relations.length,
|
|
25
|
+
synthesis: synthesis.length,
|
|
26
|
+
deposited: deposit_result,
|
|
27
|
+
anticipations: anticipations.length
|
|
28
|
+
}
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Legion::Logging.warn("GAS pipeline error: #{e.message}") if defined?(Legion::Logging)
|
|
31
|
+
{ phases_completed: 0, error: e.message }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def processable?(event)
|
|
35
|
+
event[:messages]&.any? == true && !event[:response_content].nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Phase 1: Comprehend - extract typed facts from the exchange
|
|
39
|
+
def phase_comprehend(audit_event)
|
|
40
|
+
messages = audit_event[:messages]
|
|
41
|
+
response = audit_event[:response_content]
|
|
42
|
+
|
|
43
|
+
if llm_available?
|
|
44
|
+
llm_comprehend(messages, response)
|
|
45
|
+
else
|
|
46
|
+
mechanical_comprehend(messages, response)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Phase 2: Extract - entity extraction (delegates to existing EntityExtractor)
|
|
51
|
+
def phase_extract(audit_event, _facts)
|
|
52
|
+
return [] unless defined?(Runners::EntityExtractor)
|
|
53
|
+
|
|
54
|
+
result = Runners::EntityExtractor.extract_entities(text: audit_event[:response_content])
|
|
55
|
+
result[:success] ? (result[:entities] || []) : []
|
|
56
|
+
rescue StandardError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
RELATION_TYPES = %w[
|
|
61
|
+
similar_to contradicts depends_on causes
|
|
62
|
+
part_of supersedes supports_by extends
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
RELATE_CONFIDENCE_GATE = 0.7
|
|
66
|
+
|
|
67
|
+
# Phase 3: Relate - classify relationships between new and existing entries
|
|
68
|
+
def phase_relate(facts, _entities)
|
|
69
|
+
return [] unless defined?(Runners::Knowledge)
|
|
70
|
+
|
|
71
|
+
existing = fetch_similar_entries(facts)
|
|
72
|
+
return [] if existing.empty?
|
|
73
|
+
|
|
74
|
+
relations = []
|
|
75
|
+
facts.each do |fact|
|
|
76
|
+
existing.each do |entry|
|
|
77
|
+
relation = classify_relation(fact, entry)
|
|
78
|
+
relations << relation if relation
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
relations
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
SYNTHESIS_CONFIDENCE_CAP = 0.7
|
|
85
|
+
|
|
86
|
+
# Phase 4: Synthesize - generate derivative knowledge
|
|
87
|
+
def phase_synthesize(facts, _relations)
|
|
88
|
+
return [] if facts.length < 2
|
|
89
|
+
return [] unless llm_available?
|
|
90
|
+
|
|
91
|
+
llm_synthesize(facts)
|
|
92
|
+
rescue StandardError
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Phase 5: Deposit - atomic write to Apollo
|
|
97
|
+
def phase_deposit(facts, _entities, _relations, _synthesis, audit_event)
|
|
98
|
+
return { deposited: 0 } unless defined?(Runners::Knowledge)
|
|
99
|
+
|
|
100
|
+
deposited = 0
|
|
101
|
+
facts.each do |fact|
|
|
102
|
+
Runners::Knowledge.handle_ingest(
|
|
103
|
+
content: fact[:content],
|
|
104
|
+
content_type: fact[:content_type].to_s,
|
|
105
|
+
tags: %w[gas auto_extracted],
|
|
106
|
+
source_agent: 'gas_pipeline',
|
|
107
|
+
source_provider: audit_event.dig(:routing, :provider)&.to_s,
|
|
108
|
+
knowledge_domain: 'general',
|
|
109
|
+
context: { source_request_id: audit_event[:request_id] }
|
|
110
|
+
)
|
|
111
|
+
deposited += 1
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
Legion::Logging.warn("GAS deposit error: #{e.message}") if defined?(Legion::Logging)
|
|
114
|
+
end
|
|
115
|
+
{ deposited: deposited }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
MAX_ANTICIPATIONS = 3
|
|
119
|
+
|
|
120
|
+
# Phase 6: Anticipate - pre-cache likely follow-up questions
|
|
121
|
+
def phase_anticipate(facts, _synthesis)
|
|
122
|
+
return [] if facts.empty?
|
|
123
|
+
return [] unless llm_available?
|
|
124
|
+
|
|
125
|
+
llm_anticipate(facts)
|
|
126
|
+
rescue StandardError
|
|
127
|
+
[]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def fetch_similar_entries(facts)
|
|
131
|
+
entries = []
|
|
132
|
+
facts.each do |fact|
|
|
133
|
+
result = Runners::Knowledge.retrieve_relevant(query: fact[:content], limit: 3, min_confidence: 0.3)
|
|
134
|
+
entries.concat(result[:entries]) if result[:success] && result[:entries]&.any?
|
|
135
|
+
rescue StandardError
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
entries.uniq { |e| e[:id] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def classify_relation(fact, entry)
|
|
142
|
+
if llm_available?
|
|
143
|
+
llm_classify_relation(fact, entry)
|
|
144
|
+
else
|
|
145
|
+
{ from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
|
|
146
|
+
end
|
|
147
|
+
rescue StandardError
|
|
148
|
+
{ from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def llm_classify_relation(fact, entry)
|
|
152
|
+
prompt = <<~PROMPT
|
|
153
|
+
Classify the relationship between these two knowledge entries.
|
|
154
|
+
Valid types: #{RELATION_TYPES.join(', ')}
|
|
155
|
+
|
|
156
|
+
Entry A (new): #{fact[:content]}
|
|
157
|
+
Entry B (existing): #{entry[:content]}
|
|
158
|
+
|
|
159
|
+
Return JSON with relation_type and confidence (0.0-1.0).
|
|
160
|
+
PROMPT
|
|
161
|
+
|
|
162
|
+
result = Legion::LLM::Pipeline::GaiaCaller.structured(
|
|
163
|
+
message: prompt.strip,
|
|
164
|
+
schema: {
|
|
165
|
+
type: :object,
|
|
166
|
+
properties: {
|
|
167
|
+
relations: {
|
|
168
|
+
type: :array,
|
|
169
|
+
items: {
|
|
170
|
+
type: :object,
|
|
171
|
+
properties: {
|
|
172
|
+
relation_type: { type: :string },
|
|
173
|
+
confidence: { type: :number }
|
|
174
|
+
},
|
|
175
|
+
required: %w[relation_type confidence]
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
required: ['relations']
|
|
180
|
+
},
|
|
181
|
+
phase: 'gas_relate'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
content = result.respond_to?(:message) ? result.message[:content] : result.to_s
|
|
185
|
+
parsed = Legion::JSON.load(content)
|
|
186
|
+
rels = parsed.is_a?(Hash) ? (parsed[:relations] || parsed['relations'] || []) : []
|
|
187
|
+
best = rels.max_by { |r| r[:confidence] || r['confidence'] || 0 }
|
|
188
|
+
|
|
189
|
+
return fallback_relation(fact, entry) unless best
|
|
190
|
+
|
|
191
|
+
conf = best[:confidence] || best['confidence'] || 0
|
|
192
|
+
rtype = best[:relation_type] || best['relation_type']
|
|
193
|
+
return fallback_relation(fact, entry) if conf < RELATE_CONFIDENCE_GATE || !RELATION_TYPES.include?(rtype)
|
|
194
|
+
|
|
195
|
+
{ from_content: fact[:content], to_id: entry[:id], relation_type: rtype, confidence: conf }
|
|
196
|
+
rescue StandardError
|
|
197
|
+
fallback_relation(fact, entry)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def fallback_relation(fact, entry)
|
|
201
|
+
{ from_content: fact[:content], to_id: entry[:id], relation_type: 'similar_to', confidence: 0.5 }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def llm_synthesize(facts)
|
|
205
|
+
facts_text = facts.each_with_index.map { |f, i| "[#{i}] (#{f[:content_type]}) #{f[:content]}" }.join("\n")
|
|
206
|
+
|
|
207
|
+
prompt = <<~PROMPT
|
|
208
|
+
Given these knowledge entries, generate derivative insights (inferences, implications, or connections).
|
|
209
|
+
Each synthesis should combine information from multiple sources.
|
|
210
|
+
|
|
211
|
+
Entries:
|
|
212
|
+
#{facts_text}
|
|
213
|
+
|
|
214
|
+
Return JSON with a "synthesis" array where each item has: content (string), content_type (inference/implication/connection), source_indices (array of entry indices used).
|
|
215
|
+
PROMPT
|
|
216
|
+
|
|
217
|
+
result = Legion::LLM::Pipeline::GaiaCaller.structured(
|
|
218
|
+
message: prompt.strip,
|
|
219
|
+
schema: {
|
|
220
|
+
type: :object,
|
|
221
|
+
properties: {
|
|
222
|
+
synthesis: {
|
|
223
|
+
type: :array,
|
|
224
|
+
items: {
|
|
225
|
+
type: :object,
|
|
226
|
+
properties: {
|
|
227
|
+
content: { type: :string },
|
|
228
|
+
content_type: { type: :string },
|
|
229
|
+
source_indices: { type: :array, items: { type: :integer } }
|
|
230
|
+
},
|
|
231
|
+
required: %w[content content_type source_indices]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
required: ['synthesis']
|
|
236
|
+
},
|
|
237
|
+
phase: 'gas_synthesize'
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
content = result.respond_to?(:message) ? result.message[:content] : result.to_s
|
|
241
|
+
parsed = Legion::JSON.load(content)
|
|
242
|
+
items = parsed.is_a?(Hash) ? (parsed[:synthesis] || parsed['synthesis'] || []) : []
|
|
243
|
+
|
|
244
|
+
items.map { |item| build_synthesis_entry(item, facts) }
|
|
245
|
+
rescue StandardError
|
|
246
|
+
[]
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def build_synthesis_entry(item, facts)
|
|
250
|
+
source_indices = item[:source_indices] || item['source_indices'] || []
|
|
251
|
+
source_confs = source_indices.filter_map { |i| facts[i]&.dig(:confidence) }
|
|
252
|
+
geo_mean = source_confs.empty? ? 0.5 : geometric_mean(source_confs)
|
|
253
|
+
|
|
254
|
+
{
|
|
255
|
+
content: item[:content] || item['content'],
|
|
256
|
+
content_type: (item[:content_type] || item['content_type'] || 'inference').to_sym,
|
|
257
|
+
status: :candidate,
|
|
258
|
+
confidence: [geo_mean, SYNTHESIS_CONFIDENCE_CAP].min,
|
|
259
|
+
source_indices: source_indices
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def geometric_mean(values)
|
|
264
|
+
return 0.0 if values.empty?
|
|
265
|
+
|
|
266
|
+
product = values.reduce(1.0) { |acc, v| acc * v }
|
|
267
|
+
product**(1.0 / values.length)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def llm_anticipate(facts)
|
|
271
|
+
facts_text = facts.map { |f| "(#{f[:content_type]}) #{f[:content]}" }.join("\n")
|
|
272
|
+
|
|
273
|
+
prompt = <<~PROMPT
|
|
274
|
+
Given these knowledge entries, generate 1-3 likely follow-up questions a user might ask.
|
|
275
|
+
|
|
276
|
+
Knowledge:
|
|
277
|
+
#{facts_text}
|
|
278
|
+
|
|
279
|
+
Return JSON with a "questions" array of question strings.
|
|
280
|
+
PROMPT
|
|
281
|
+
|
|
282
|
+
result = Legion::LLM::Pipeline::GaiaCaller.structured(
|
|
283
|
+
message: prompt.strip,
|
|
284
|
+
schema: {
|
|
285
|
+
type: :object,
|
|
286
|
+
properties: {
|
|
287
|
+
questions: { type: :array, items: { type: :string } }
|
|
288
|
+
},
|
|
289
|
+
required: ['questions']
|
|
290
|
+
},
|
|
291
|
+
phase: 'gas_anticipate'
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
content = result.respond_to?(:message) ? result.message[:content] : result.to_s
|
|
295
|
+
parsed = Legion::JSON.load(content)
|
|
296
|
+
questions = parsed.is_a?(Hash) ? (parsed[:questions] || parsed['questions'] || []) : []
|
|
297
|
+
questions = questions.first(MAX_ANTICIPATIONS)
|
|
298
|
+
|
|
299
|
+
questions.map do |q|
|
|
300
|
+
promote_to_pattern_store(question: q, facts: facts)
|
|
301
|
+
{ question: q }
|
|
302
|
+
end
|
|
303
|
+
rescue StandardError
|
|
304
|
+
[]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def promote_to_pattern_store(question:, facts:)
|
|
308
|
+
return unless defined?(Legion::Extensions::Agentic::TBI::PatternStore)
|
|
309
|
+
|
|
310
|
+
Legion::Extensions::Agentic::TBI::PatternStore.promote_candidate(
|
|
311
|
+
intent: question,
|
|
312
|
+
resolution: { source: 'gas_anticipate', facts: facts.map { |f| f[:content] } },
|
|
313
|
+
confidence: 0.5
|
|
314
|
+
)
|
|
315
|
+
rescue StandardError
|
|
316
|
+
nil
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def llm_available?
|
|
320
|
+
defined?(Legion::LLM::Pipeline::GaiaCaller)
|
|
321
|
+
rescue StandardError
|
|
322
|
+
false
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def mechanical_comprehend(_messages, response)
|
|
326
|
+
[{ content: response, content_type: :observation }]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def llm_comprehend(messages, response)
|
|
330
|
+
prompt = <<~PROMPT
|
|
331
|
+
Extract distinct facts from this exchange. Return JSON array of {content:, content_type:} where content_type is one of: fact, concept, procedure, association.
|
|
332
|
+
|
|
333
|
+
User: #{messages.last&.dig(:content)}
|
|
334
|
+
Assistant: #{response}
|
|
335
|
+
PROMPT
|
|
336
|
+
|
|
337
|
+
result = Legion::LLM::Pipeline::GaiaCaller.structured(
|
|
338
|
+
message: prompt.strip,
|
|
339
|
+
schema: {
|
|
340
|
+
type: :object,
|
|
341
|
+
properties: {
|
|
342
|
+
facts: {
|
|
343
|
+
type: :array,
|
|
344
|
+
items: {
|
|
345
|
+
type: :object,
|
|
346
|
+
properties: {
|
|
347
|
+
content: { type: :string },
|
|
348
|
+
content_type: { type: :string }
|
|
349
|
+
},
|
|
350
|
+
required: %w[content content_type]
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
required: ['facts']
|
|
355
|
+
},
|
|
356
|
+
phase: 'gas_comprehend'
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
content = result.respond_to?(:message) ? result.message[:content] : result.to_s
|
|
360
|
+
parsed = Legion::JSON.load(content)
|
|
361
|
+
facts_array = parsed.is_a?(Hash) ? (parsed[:facts] || parsed['facts'] || []) : Array(parsed)
|
|
362
|
+
facts_array.map do |f|
|
|
363
|
+
{
|
|
364
|
+
content: f[:content] || f['content'],
|
|
365
|
+
content_type: (f[:content_type] || f['content_type'] || 'fact').to_sym
|
|
366
|
+
}
|
|
367
|
+
end
|
|
368
|
+
rescue StandardError
|
|
369
|
+
mechanical_comprehend(messages, response)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/transport/queue' if defined?(Legion::Transport)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Apollo
|
|
8
|
+
module Transport
|
|
9
|
+
module Queues
|
|
10
|
+
class Gas < Legion::Transport::Queue
|
|
11
|
+
def queue_name
|
|
12
|
+
'apollo.gas'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def queue_options
|
|
16
|
+
{ manual_ack: true, durable: true, arguments: { 'x-dead-letter-exchange': 'apollo.dlx' } }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -8,11 +8,13 @@ require 'legion/extensions/apollo/runners/knowledge'
|
|
|
8
8
|
require 'legion/extensions/apollo/runners/expertise'
|
|
9
9
|
require 'legion/extensions/apollo/runners/maintenance'
|
|
10
10
|
require 'legion/extensions/apollo/runners/entity_extractor'
|
|
11
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
11
12
|
|
|
12
13
|
if defined?(Legion::Transport)
|
|
13
14
|
require 'legion/extensions/apollo/transport/exchanges/apollo'
|
|
14
15
|
require 'legion/extensions/apollo/transport/queues/ingest'
|
|
15
16
|
require 'legion/extensions/apollo/transport/queues/query'
|
|
17
|
+
require 'legion/extensions/apollo/transport/queues/gas'
|
|
16
18
|
require 'legion/extensions/apollo/transport/messages/ingest'
|
|
17
19
|
require 'legion/extensions/apollo/transport/messages/query'
|
|
18
20
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
unless defined?(Legion::Extensions::Actors::Subscription)
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Actors
|
|
9
|
+
class Subscription; end # rubocop:disable Lint/EmptyClass
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
$LOADED_FEATURES << 'legion/extensions/actors/subscription' unless $LOADED_FEATURES.include?('legion/extensions/actors/subscription')
|
|
15
|
+
|
|
16
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
17
|
+
require 'legion/extensions/apollo/actors/gas_subscriber'
|
|
18
|
+
|
|
19
|
+
RSpec.describe Legion::Extensions::Apollo::Actor::GasSubscriber do
|
|
20
|
+
subject(:actor) { described_class.new }
|
|
21
|
+
|
|
22
|
+
it 'uses Gas runner_class as string' do
|
|
23
|
+
expect(actor.runner_class).to eq('Legion::Extensions::Apollo::Runners::Gas')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'runs process function' do
|
|
27
|
+
expect(actor.runner_function).to eq('process')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'does not generate tasks' do
|
|
31
|
+
expect(actor.generate_task?).to be false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'does not check subtasks' do
|
|
35
|
+
expect(actor.check_subtask?).to be false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#enabled?' do
|
|
39
|
+
it 'returns truthy when Gas runner and Transport are defined' do
|
|
40
|
+
stub_const('Legion::Transport', Module.new)
|
|
41
|
+
expect(actor.enabled?).to be_truthy
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'returns falsey when Transport is not defined' do
|
|
45
|
+
hide_const('Legion::Transport') if defined?(Legion::Transport)
|
|
46
|
+
expect(actor.enabled?).to be_falsey
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_anticipate' do
|
|
7
|
+
let(:facts) do
|
|
8
|
+
[
|
|
9
|
+
{ content: 'pgvector uses HNSW indexes', content_type: :fact },
|
|
10
|
+
{ content: 'cosine distance measures similarity', content_type: :concept }
|
|
11
|
+
]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:synthesis) do
|
|
15
|
+
[
|
|
16
|
+
{ content: 'pgvector achieves fast search via HNSW', content_type: :inference, status: :candidate }
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'when GaiaCaller is unavailable' do
|
|
21
|
+
it 'returns empty array' do
|
|
22
|
+
result = described_class.phase_anticipate(facts, synthesis)
|
|
23
|
+
expect(result).to eq([])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
context 'when GaiaCaller is available' do
|
|
28
|
+
let(:gaia_caller) { double('GaiaCaller') }
|
|
29
|
+
let(:mock_response) do
|
|
30
|
+
double(
|
|
31
|
+
'Response',
|
|
32
|
+
message: {
|
|
33
|
+
content: '{"questions":["How fast is pgvector HNSW search?","What distance metrics does pgvector support?"]}'
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
before do
|
|
39
|
+
stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
|
|
40
|
+
allow(gaia_caller).to receive(:structured).and_return(mock_response)
|
|
41
|
+
allow(Legion::JSON).to receive(:load).and_return(
|
|
42
|
+
{ 'questions' => ['How fast is pgvector HNSW search?', 'What distance metrics does pgvector support?'] }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'generates anticipated questions' do
|
|
47
|
+
result = described_class.phase_anticipate(facts, synthesis)
|
|
48
|
+
expect(result).not_to be_empty
|
|
49
|
+
expect(result.length).to be <= 3
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'returns question strings' do
|
|
53
|
+
result = described_class.phase_anticipate(facts, synthesis)
|
|
54
|
+
result.each do |item|
|
|
55
|
+
expect(item[:question]).to be_a(String)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
context 'when PatternStore is available' do
|
|
60
|
+
let(:pattern_store) { double('PatternStore') }
|
|
61
|
+
|
|
62
|
+
before do
|
|
63
|
+
stub_const('Legion::Extensions::Agentic::TBI::PatternStore', pattern_store)
|
|
64
|
+
allow(pattern_store).to receive(:promote_candidate)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'promotes candidates to PatternStore' do
|
|
68
|
+
described_class.phase_anticipate(facts, synthesis)
|
|
69
|
+
expect(pattern_store).to have_received(:promote_candidate).at_least(:once)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context 'when PatternStore is not available' do
|
|
74
|
+
it 'still returns questions without error' do
|
|
75
|
+
result = described_class.phase_anticipate(facts, synthesis)
|
|
76
|
+
expect(result).not_to be_empty
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
context 'when LLM call fails' do
|
|
82
|
+
let(:gaia_caller) { double('GaiaCaller') }
|
|
83
|
+
|
|
84
|
+
before do
|
|
85
|
+
stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
|
|
86
|
+
allow(gaia_caller).to receive(:structured).and_raise(StandardError, 'timeout')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'returns empty array on error' do
|
|
90
|
+
result = described_class.phase_anticipate(facts, synthesis)
|
|
91
|
+
expect(result).to eq([])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
context 'with empty facts' do
|
|
96
|
+
it 'returns empty array' do
|
|
97
|
+
result = described_class.phase_anticipate([], [])
|
|
98
|
+
expect(result).to eq([])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_relate' do
|
|
7
|
+
let(:facts) do
|
|
8
|
+
[
|
|
9
|
+
{ content: 'pgvector uses HNSW indexes', content_type: :fact },
|
|
10
|
+
{ content: 'cosine distance measures similarity', content_type: :concept }
|
|
11
|
+
]
|
|
12
|
+
end
|
|
13
|
+
let(:entities) do
|
|
14
|
+
[{ name: 'pgvector', type: 'technology' }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context 'when Apollo Knowledge is unavailable' do
|
|
18
|
+
before do
|
|
19
|
+
hide_const('Legion::Extensions::Apollo::Runners::Knowledge') if defined?(Legion::Extensions::Apollo::Runners::Knowledge)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'returns empty array' do
|
|
23
|
+
result = described_class.phase_relate(facts, entities)
|
|
24
|
+
expect(result).to eq([])
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context 'when Apollo has similar entries' do
|
|
29
|
+
let(:knowledge_runner) { double('Knowledge') }
|
|
30
|
+
let(:similar_entries) do
|
|
31
|
+
{
|
|
32
|
+
success: true,
|
|
33
|
+
entries: [
|
|
34
|
+
{ id: 'e1', content: 'HNSW is an approximate nearest neighbor algorithm', content_type: 'concept', confidence: 0.85 }
|
|
35
|
+
],
|
|
36
|
+
count: 1
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
before do
|
|
41
|
+
stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_runner)
|
|
42
|
+
allow(knowledge_runner).to receive(:retrieve_relevant).and_return(similar_entries)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
context 'when GaiaCaller is unavailable' do
|
|
46
|
+
it 'falls back to similar_to relations' do
|
|
47
|
+
result = described_class.phase_relate(facts, entities)
|
|
48
|
+
expect(result).to all(include(relation_type: 'similar_to'))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'returns relations for each fact-entry pair' do
|
|
52
|
+
result = described_class.phase_relate(facts, entities)
|
|
53
|
+
expect(result).not_to be_empty
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'when GaiaCaller is available' do
|
|
58
|
+
let(:gaia_caller) { double('GaiaCaller') }
|
|
59
|
+
let(:mock_response) do
|
|
60
|
+
double(
|
|
61
|
+
'Response',
|
|
62
|
+
message: {
|
|
63
|
+
content: '{"relations":[{"relation_type":"depends_on","confidence":0.85}]}'
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
before do
|
|
69
|
+
stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
|
|
70
|
+
stub_const('Legion::JSON', double(load: { 'relations' => [{ 'relation_type' => 'depends_on', 'confidence' => 0.85 }] }))
|
|
71
|
+
allow(gaia_caller).to receive(:structured).and_return(mock_response)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'classifies relations via LLM' do
|
|
75
|
+
result = described_class.phase_relate(facts, entities)
|
|
76
|
+
expect(result).not_to be_empty
|
|
77
|
+
expect(result.first[:relation_type]).to eq('depends_on')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'gates on confidence > 0.7' do
|
|
81
|
+
low_conf_response = double(
|
|
82
|
+
'Response',
|
|
83
|
+
message: {
|
|
84
|
+
content: '{"relations":[{"relation_type":"contradicts","confidence":0.3}]}'
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
allow(gaia_caller).to receive(:structured).and_return(low_conf_response)
|
|
88
|
+
allow(Legion::JSON).to receive(:load).and_return(
|
|
89
|
+
{ 'relations' => [{ 'relation_type' => 'contradicts', 'confidence' => 0.3 }] }
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
result = described_class.phase_relate(facts, entities)
|
|
93
|
+
# Low confidence relations should fall back to similar_to
|
|
94
|
+
classified = result.select { |r| r[:relation_type] == 'contradicts' }
|
|
95
|
+
expect(classified).to be_empty
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
context 'when Apollo returns no similar entries' do
|
|
101
|
+
let(:knowledge_runner) { double('Knowledge') }
|
|
102
|
+
|
|
103
|
+
before do
|
|
104
|
+
stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_runner)
|
|
105
|
+
allow(knowledge_runner).to receive(:retrieve_relevant).and_return(
|
|
106
|
+
{ success: true, entries: [], count: 0 }
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'returns empty array' do
|
|
111
|
+
result = described_class.phase_relate(facts, entities)
|
|
112
|
+
expect(result).to eq([])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Apollo::Runners::Gas do
|
|
7
|
+
describe '.process' do
|
|
8
|
+
let(:audit_event) do
|
|
9
|
+
{
|
|
10
|
+
request_id: 'req_abc',
|
|
11
|
+
messages: [{ role: :user, content: 'How does pgvector work?' }],
|
|
12
|
+
response_content: 'pgvector uses HNSW indexes for approximate nearest neighbor search.',
|
|
13
|
+
routing: { provider: :anthropic, model: 'claude-opus-4-6' },
|
|
14
|
+
tokens: { input: 50, output: 30, total: 80 },
|
|
15
|
+
caller: { requested_by: { identity: 'user:matt', type: :user } },
|
|
16
|
+
timestamp: Time.now
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'runs all 6 phases in order' do
|
|
21
|
+
allow(described_class).to receive(:phase_comprehend).and_return([{ content: 'fact', content_type: :fact }])
|
|
22
|
+
allow(described_class).to receive(:phase_extract).and_return([{ name: 'pgvector', type: 'technology' }])
|
|
23
|
+
allow(described_class).to receive(:phase_relate).and_return([])
|
|
24
|
+
allow(described_class).to receive(:phase_synthesize).and_return([])
|
|
25
|
+
allow(described_class).to receive(:phase_deposit).and_return({ deposited: 1 })
|
|
26
|
+
allow(described_class).to receive(:phase_anticipate).and_return([])
|
|
27
|
+
|
|
28
|
+
result = described_class.process(audit_event)
|
|
29
|
+
expect(result).to be_a(Hash)
|
|
30
|
+
expect(result[:phases_completed]).to eq(6)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'skips when audit event has no content' do
|
|
34
|
+
result = described_class.process({ request_id: 'req_abc' })
|
|
35
|
+
expect(result[:phases_completed]).to eq(0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'skips when response_content is nil' do
|
|
39
|
+
result = described_class.process({ request_id: 'req_abc', messages: [{ role: :user, content: 'hi' }] })
|
|
40
|
+
expect(result[:phases_completed]).to eq(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'returns error details on failure' do
|
|
44
|
+
allow(described_class).to receive(:phase_comprehend).and_raise(StandardError, 'boom')
|
|
45
|
+
|
|
46
|
+
result = described_class.process(audit_event)
|
|
47
|
+
expect(result[:phases_completed]).to eq(0)
|
|
48
|
+
expect(result[:error]).to eq('boom')
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '.processable?' do
|
|
53
|
+
it 'returns true with messages and response_content' do
|
|
54
|
+
event = { messages: [{ role: :user, content: 'hi' }], response_content: 'hello' }
|
|
55
|
+
expect(described_class.processable?(event)).to be true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns false without messages' do
|
|
59
|
+
expect(described_class.processable?({ response_content: 'hello' })).to be false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'returns false without response_content' do
|
|
63
|
+
expect(described_class.processable?({ messages: [{ role: :user, content: 'hi' }] })).to be false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '.mechanical_comprehend' do
|
|
68
|
+
it 'wraps response as a single observation' do
|
|
69
|
+
messages = [{ role: :user, content: 'hi' }]
|
|
70
|
+
result = described_class.mechanical_comprehend(messages, 'pgvector is fast')
|
|
71
|
+
expect(result).to eq([{ content: 'pgvector is fast', content_type: :observation }])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '.phase_extract' do
|
|
76
|
+
it 'returns empty array when EntityExtractor not defined' do
|
|
77
|
+
hide_const('Legion::Extensions::Apollo::Runners::EntityExtractor') if defined?(Legion::Extensions::Apollo::Runners::EntityExtractor)
|
|
78
|
+
result = described_class.phase_extract({ response_content: 'test' }, [])
|
|
79
|
+
expect(result).to eq([])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '.phase_relate' do
|
|
84
|
+
it 'returns empty array (stub)' do
|
|
85
|
+
expect(described_class.phase_relate([], [])).to eq([])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe '.phase_synthesize' do
|
|
90
|
+
it 'returns empty array (stub)' do
|
|
91
|
+
expect(described_class.phase_synthesize([], [])).to eq([])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '.phase_anticipate' do
|
|
96
|
+
it 'returns empty array (stub)' do
|
|
97
|
+
expect(described_class.phase_anticipate([], [])).to eq([])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe '.phase_deposit' do
|
|
102
|
+
it 'returns deposited 0 when Knowledge not defined' do
|
|
103
|
+
hide_const('Legion::Extensions::Apollo::Runners::Knowledge') if defined?(Legion::Extensions::Apollo::Runners::Knowledge)
|
|
104
|
+
result = described_class.phase_deposit([], [], [], [], { request_id: 'req_1' })
|
|
105
|
+
expect(result).to eq({ deposited: 0 })
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/apollo/runners/gas'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Apollo::Runners::Gas, '.phase_synthesize' do
|
|
7
|
+
let(:facts) do
|
|
8
|
+
[
|
|
9
|
+
{ content: 'pgvector uses HNSW indexes', content_type: :fact, confidence: 0.9 },
|
|
10
|
+
{ content: 'HNSW provides logarithmic search time', content_type: :fact, confidence: 0.85 }
|
|
11
|
+
]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
let(:relations) do
|
|
15
|
+
[
|
|
16
|
+
{ from_content: 'pgvector uses HNSW indexes', to_id: 'e1', relation_type: 'depends_on', confidence: 0.8 }
|
|
17
|
+
]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
context 'when GaiaCaller is unavailable' do
|
|
21
|
+
it 'returns empty array' do
|
|
22
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
23
|
+
expect(result).to eq([])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
context 'when GaiaCaller is available' do
|
|
28
|
+
let(:gaia_caller) { double('GaiaCaller') }
|
|
29
|
+
let(:mock_response) do
|
|
30
|
+
double(
|
|
31
|
+
'Response',
|
|
32
|
+
message: {
|
|
33
|
+
content: '{"synthesis":[{"content":"pgvector achieves fast search via HNSW",' \
|
|
34
|
+
'"content_type":"inference","source_indices":[0,1]}]}'
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
before do
|
|
40
|
+
stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
|
|
41
|
+
allow(gaia_caller).to receive(:structured).and_return(mock_response)
|
|
42
|
+
allow(Legion::JSON).to receive(:load).and_return(
|
|
43
|
+
{
|
|
44
|
+
'synthesis' => [
|
|
45
|
+
{
|
|
46
|
+
'content' => 'pgvector achieves fast similarity search through HNSW logarithmic indexing',
|
|
47
|
+
'content_type' => 'inference',
|
|
48
|
+
'source_indices' => [0, 1]
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'generates derivative knowledge entries' do
|
|
56
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
57
|
+
expect(result).not_to be_empty
|
|
58
|
+
expect(result.first[:content]).to include('pgvector')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'marks entries as candidate status' do
|
|
62
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
63
|
+
expect(result.first[:status]).to eq(:candidate)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'caps confidence at 0.7' do
|
|
67
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
68
|
+
result.each do |entry|
|
|
69
|
+
expect(entry[:confidence]).to be <= 0.7
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'includes depends_on source indices' do
|
|
74
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
75
|
+
expect(result.first[:source_indices]).to eq([0, 1])
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'when LLM call fails' do
|
|
80
|
+
let(:gaia_caller) { double('GaiaCaller') }
|
|
81
|
+
|
|
82
|
+
before do
|
|
83
|
+
stub_const('Legion::LLM::Pipeline::GaiaCaller', gaia_caller)
|
|
84
|
+
allow(gaia_caller).to receive(:structured).and_raise(StandardError, 'LLM timeout')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns empty array on error' do
|
|
88
|
+
result = described_class.phase_synthesize(facts, relations)
|
|
89
|
+
expect(result).to eq([])
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context 'with empty inputs' do
|
|
94
|
+
it 'returns empty array when no facts' do
|
|
95
|
+
result = described_class.phase_synthesize([], [])
|
|
96
|
+
expect(result).to eq([])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'returns empty array when single fact (nothing to synthesize)' do
|
|
100
|
+
result = described_class.phase_synthesize(
|
|
101
|
+
[{ content: 'one fact', content_type: :fact, confidence: 0.9 }],
|
|
102
|
+
[]
|
|
103
|
+
)
|
|
104
|
+
expect(result).to eq([])
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
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
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -150,6 +150,7 @@ files:
|
|
|
150
150
|
- lib/legion/extensions/apollo/actors/decay.rb
|
|
151
151
|
- lib/legion/extensions/apollo/actors/entity_watchdog.rb
|
|
152
152
|
- lib/legion/extensions/apollo/actors/expertise_aggregator.rb
|
|
153
|
+
- lib/legion/extensions/apollo/actors/gas_subscriber.rb
|
|
153
154
|
- lib/legion/extensions/apollo/actors/ingest.rb
|
|
154
155
|
- lib/legion/extensions/apollo/actors/query_responder.rb
|
|
155
156
|
- lib/legion/extensions/apollo/client.rb
|
|
@@ -161,18 +162,21 @@ files:
|
|
|
161
162
|
- lib/legion/extensions/apollo/helpers/similarity.rb
|
|
162
163
|
- lib/legion/extensions/apollo/runners/entity_extractor.rb
|
|
163
164
|
- lib/legion/extensions/apollo/runners/expertise.rb
|
|
165
|
+
- lib/legion/extensions/apollo/runners/gas.rb
|
|
164
166
|
- lib/legion/extensions/apollo/runners/knowledge.rb
|
|
165
167
|
- lib/legion/extensions/apollo/runners/maintenance.rb
|
|
166
168
|
- lib/legion/extensions/apollo/transport.rb
|
|
167
169
|
- lib/legion/extensions/apollo/transport/exchanges/apollo.rb
|
|
168
170
|
- lib/legion/extensions/apollo/transport/messages/ingest.rb
|
|
169
171
|
- lib/legion/extensions/apollo/transport/messages/query.rb
|
|
172
|
+
- lib/legion/extensions/apollo/transport/queues/gas.rb
|
|
170
173
|
- lib/legion/extensions/apollo/transport/queues/ingest.rb
|
|
171
174
|
- lib/legion/extensions/apollo/transport/queues/query.rb
|
|
172
175
|
- lib/legion/extensions/apollo/version.rb
|
|
173
176
|
- spec/legion/extensions/apollo/actors/decay_spec.rb
|
|
174
177
|
- spec/legion/extensions/apollo/actors/entity_watchdog_spec.rb
|
|
175
178
|
- spec/legion/extensions/apollo/actors/expertise_aggregator_spec.rb
|
|
179
|
+
- spec/legion/extensions/apollo/actors/gas_subscriber_spec.rb
|
|
176
180
|
- spec/legion/extensions/apollo/actors/ingest_spec.rb
|
|
177
181
|
- spec/legion/extensions/apollo/client_spec.rb
|
|
178
182
|
- spec/legion/extensions/apollo/contradiction_spec.rb
|
|
@@ -185,6 +189,10 @@ files:
|
|
|
185
189
|
- spec/legion/extensions/apollo/runners/decay_cycle_spec.rb
|
|
186
190
|
- spec/legion/extensions/apollo/runners/entity_extractor_spec.rb
|
|
187
191
|
- spec/legion/extensions/apollo/runners/expertise_spec.rb
|
|
192
|
+
- spec/legion/extensions/apollo/runners/gas_anticipate_spec.rb
|
|
193
|
+
- spec/legion/extensions/apollo/runners/gas_relate_spec.rb
|
|
194
|
+
- spec/legion/extensions/apollo/runners/gas_spec.rb
|
|
195
|
+
- spec/legion/extensions/apollo/runners/gas_synthesize_spec.rb
|
|
188
196
|
- spec/legion/extensions/apollo/runners/knowledge_spec.rb
|
|
189
197
|
- spec/legion/extensions/apollo/runners/maintenance_spec.rb
|
|
190
198
|
- spec/legion/extensions/apollo/transport/messages/ingest_spec.rb
|