legion-llm 0.4.0 → 0.4.2

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: 2963ec3f995c8bab5b80af82d724155eb3590473bbf04e6eec7fe22c8625435a
4
- data.tar.gz: 2fb50f72dfbe6867a388a50317eea6403e10c117e80f2f83fcb259e5601e9419
3
+ metadata.gz: 7d783c05981cd0272f10826212088d4054d5d001f06c0ab1d813ac8a0d40a3ca
4
+ data.tar.gz: 5eac65f6ad91f5f7296b05b5c4b1bf89af789176a3775cb8864553bec5c924d6
5
5
  SHA512:
6
- metadata.gz: f6009ed5907f5cfc642cac46f0b626b3e8c888549c6de480ff4c39a569fe23901a281cc2ccd249fe605f88862ffc1392a684afc9ab4a1439520edb5f83cf6734
7
- data.tar.gz: 0d5b8fcd87a585cc3da2c81ff737cabe25bcadf171ee58304f2544b4a601fc519666e6a54ff402923b39b597b41c527019fbc421eefbb6bbdf8afaab0ce75f7e
6
+ metadata.gz: d3c39286ae48876691530ba2baed4a2d047a7491e6a024baf9fba8101f1a536c19768e654bc78fdb413efa5275778ae00fc6225fbcfd8571894475b5eba775b8
7
+ data.tar.gz: f4e990dbc665730c22f212fcc643843186dd13c5dbef1c5dfe871b1a79deb79ac570f893441c64e75c498ab058e487c8a1062188b79a8e23e4c51dd10a343006
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.4.2] - 2026-03-23
4
+
5
+ ### Added
6
+ - `Pipeline::Steps::RagContext` (step 8): context strategy selector (full/rag_hybrid/rag/none) based on utilization, queries Apollo via `retrieve_relevant`
7
+ - `Pipeline::Steps::RagGuard`: post-response faithfulness check against retrieved RAG context via `Hooks::RagGuard`
8
+ - `Pipeline::EnrichmentInjector`: converts RAG and GAIA enrichments into system prompt text before provider call
9
+ - `Pipeline::GaiaCaller`: privileged helper for GAIA/GAS LLM calls with system profile (skips governance steps)
10
+ - `Pipeline::AuditPublisher`: publishes audit events to `llm.audit` exchange for GAS subscriber consumption
11
+ - RAG/GAS full cycle integration test (4 examples: enrichment, injection, degradation, feedback loop prevention)
12
+
13
+ ## [0.4.1] - 2026-03-23
14
+
15
+ ### Added
16
+ - Typed error hierarchy (`AuthError`, `RateLimitError`, `ContextOverflow`, `ProviderError`, `ProviderDown`, `UnsupportedCapability`, `PipelineError`) with `retryable?` predicate
17
+ - `ConversationStore` with in-memory LRU hot layer (256 conversations) and optional DB persistence via Sequel
18
+ - Streaming pipeline support via `Executor#call_stream` — pre/post steps run normally, chunks yielded to caller
19
+ - Pipeline steps `context_load` (Step 3) and `context_store` (Step 15) now functional
20
+ - `Pipeline::Steps::McpDiscovery` (step 9): discovers tools from all healthy MCP servers via `Legion::MCP::Client::Pool`
21
+ - `Pipeline::ToolDispatcher`: routes tool calls to MCP client, LEX extension runner, or RubyLLM builtin
22
+ - `Pipeline::Steps::ToolCalls` (step 14): dispatches non-builtin tool calls from LLM response via `ToolDispatcher`
23
+ - `pipeline/steps.rb` aggregator for all step modules
24
+
25
+ ### Changed
26
+ - Executor `step_provider_call` classifies Faraday errors into typed hierarchy
27
+ - `chat`, `embed`, and `structured` route directly without gateway delegation
28
+ - `_dispatch_embed` and `_dispatch_structured` removed; dispatch inlined
29
+
30
+ ### Removed
31
+ - `lex-llm-gateway` auto-loading (`begin/rescue LoadError` block removed)
32
+ - `gateway_loaded?` and `gateway_chat` helper methods
33
+ - `_dispatch_embed` and `_dispatch_structured` indirection methods
34
+
3
35
  ## [0.4.0] - 2026-03-23
4
36
 
5
37
  ### Added
data/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@
8
8
  Core LegionIO gem providing LLM capabilities to all extensions. Wraps ruby_llm to provide a consistent interface for chat, embeddings, tool use, and agents across multiple providers (Bedrock, Anthropic, OpenAI, Gemini, Ollama). Includes a dynamic weighted routing engine that dispatches requests across local, fleet, and cloud tiers based on caller intent, priority rules, time schedules, cost multipliers, and real-time provider health.
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/legion-llm
11
- **Version**: 0.4.0
11
+ **Version**: 0.4.1
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Architecture
@@ -33,6 +33,8 @@ Legion::LLM (lib/legion/llm.rb)
33
33
  ├── EscalationExhausted # Raised when all escalation attempts are exhausted
34
34
  ├── DaemonDeniedError # Raised when daemon returns HTTP 403
35
35
  ├── DaemonRateLimitedError # Raised when daemon returns HTTP 429
36
+ ├── LLMError / AuthError / RateLimitError / ContextOverflow / ProviderError / ProviderDown / UnsupportedCapability / PipelineError # Typed error hierarchy with retryable?
37
+ ├── ConversationStore # In-memory LRU (256 conversations) + optional DB persistence via Sequel
36
38
  ├── Settings # Default config, provider settings, routing defaults, discovery defaults
37
39
  ├── Providers # Provider configuration and Vault credential resolution (includes Azure `configure_azure`)
38
40
  ├── DaemonClient # HTTP routing to LegionIO daemon with 30s health cache
@@ -58,8 +60,9 @@ Legion::LLM (lib/legion/llm.rb)
58
60
  │ ├── Tracing # Distributed trace_id, span_id, exchange_id generation
59
61
  │ ├── Timeline # Ordered event recording with participant tracking
60
62
  │ ├── Executor # 18-step pipeline skeleton with profile-aware execution
61
- └── Steps/
62
- └── Metering # Metering event builder (absorbed from lex-llm-gateway)
63
+ ├── Steps/
64
+ └── Metering # Metering event builder (absorbed from lex-llm-gateway)
65
+ │ └── Executor#call_stream # Streaming variant: pre-provider steps, yield chunks, post-provider steps
63
66
  ├── CostEstimator # Model cost estimation with fuzzy pricing (absorbed from lex-llm-gateway)
64
67
  ├── Fleet # Fleet RPC dispatch (absorbed from lex-llm-gateway)
65
68
  │ ├── Dispatcher # Fleet dispatch with timeout and availability checks
@@ -107,16 +110,7 @@ Three-tier dispatch model. Local-first avoids unnecessary network hops; fleet of
107
110
 
108
111
  ### Gateway Integration (lex-llm-gateway)
109
112
 
110
- When `lex-llm-gateway` is installed, `chat`, `embed`, and `structured` automatically delegate to the gateway for metering and fleet dispatch. The gateway is loaded via `begin/rescue LoadError` optional, not a hard dependency.
111
-
112
- ```
113
- Caller → Legion::LLM.chat(message:)
114
- └─ gateway loaded? → Gateway::Runners::Inference.chat (meters, fleet dispatch)
115
- └─ Legion::LLM.chat_direct (routing, escalation, RubyLLM)
116
- └─ no gateway? → Legion::LLM.chat_direct (same path, no metering)
117
- ```
118
-
119
- The `_direct` variants (`chat_direct`, `embed_direct`, `structured_direct`) bypass gateway delegation. The gateway's `call_llm` uses these to avoid infinite recursion.
113
+ Gateway delegation removed in v0.4.1. `chat`, `embed`, and `structured` route directly no `begin/rescue LoadError` block, no `gateway_loaded?` check. The pipeline (when `pipeline_enabled: true`) handles metering and fleet dispatch natively. The `_direct` variants still exist as the canonical non-pipeline path for `chat_direct`, `embed_direct`, `structured_direct`.
120
114
 
121
115
  ### Integration with LegionIO
122
116
 
@@ -135,7 +129,7 @@ The `_direct` variants (`chat_direct`, `embed_direct`, `structured_direct`) bypa
135
129
  | `tzinfo` (>= 2.0) | IANA timezone conversion for schedule windows |
136
130
  | `legion-logging` | Logging |
137
131
  | `legion-settings` | Configuration |
138
- | `lex-llm-gateway` (optional) | Metering over RMQ, fleet RPC dispatch, disk spool auto-loaded if present |
132
+ | `lex-llm-gateway` (removed) | No longer auto-loaded; pipeline handles metering and fleet dispatch natively |
139
133
 
140
134
  ## Key Interfaces
141
135
 
@@ -329,7 +323,9 @@ In-memory signal consumer with pluggable handlers. Adjusts effective priorities
329
323
  | `lib/legion/llm/embeddings.rb` | Embeddings module: generate, generate_batch, default_model |
330
324
  | `lib/legion/llm/shadow_eval.rb` | Shadow evaluation: enabled?, should_sample?, evaluate, compare |
331
325
  | `lib/legion/llm/structured_output.rb` | JSON schema enforcement with native response_format and prompt fallback |
332
- | `lib/legion/llm/version.rb` | Version constant (0.3.15) |
326
+ | `lib/legion/llm/errors.rb` | Typed error hierarchy: LLMError base + AuthError, RateLimitError, ContextOverflow, ProviderError, ProviderDown, UnsupportedCapability, PipelineError |
327
+ | `lib/legion/llm/conversation_store.rb` | ConversationStore: in-memory LRU (256 slots) + optional Sequel DB persistence + spool fallback |
328
+ | `lib/legion/llm/version.rb` | Version constant (0.4.2) |
333
329
  | `lib/legion/llm/quality_checker.rb` | QualityChecker module with QualityResult struct |
334
330
  | `lib/legion/llm/escalation_history.rb` | EscalationHistory mixin: `escalation_history`, `escalated?`, `final_resolution`, `escalation_chain` |
335
331
  | `lib/legion/llm/router/escalation_chain.rb` | EscalationChain value object |
@@ -343,6 +339,9 @@ In-memory signal consumer with pluggable handlers. Adjusts effective priorities
343
339
  | `lib/legion/llm/pipeline/timeline.rb` | Pipeline::Timeline: ordered event recording |
344
340
  | `lib/legion/llm/pipeline/executor.rb` | Pipeline::Executor: 18-step skeleton with profile-aware execution |
345
341
  | `lib/legion/llm/pipeline/steps/metering.rb` | Pipeline::Steps::Metering: metering event builder |
342
+ | `lib/legion/llm/pipeline/steps/rag_context.rb` | Pipeline::Steps::RagContext: context strategy selection and Apollo retrieval (step 8) |
343
+ | `lib/legion/llm/pipeline/steps/rag_guard.rb` | Pipeline::Steps::RagGuard: faithfulness check against retrieved RAG context |
344
+ | `lib/legion/llm/pipeline/enrichment_injector.rb` | Pipeline::EnrichmentInjector: converts RAG/GAIA enrichments into system prompt |
346
345
  | `lib/legion/llm/cost_estimator.rb` | CostEstimator: model cost estimation with fuzzy pricing |
347
346
  | `lib/legion/llm/fleet.rb` | Fleet module: requires dispatcher, handler, reply_dispatcher |
348
347
  | `lib/legion/llm/fleet/dispatcher.rb` | Fleet::Dispatcher: fleet RPC dispatch |
@@ -373,7 +372,11 @@ In-memory signal consumer with pluggable handlers. Adjusts effective priorities
373
372
  | `spec/legion/llm/embeddings_spec.rb` | Embeddings tests |
374
373
  | `spec/legion/llm/shadow_eval_spec.rb` | ShadowEval tests |
375
374
  | `spec/legion/llm/structured_output_spec.rb` | StructuredOutput tests |
376
- | `spec/legion/llm/gateway_integration_spec.rb` | Tests: gateway delegation and _direct bypass |
375
+ | `spec/legion/llm/errors_spec.rb` | Tests: typed error hierarchy, retryable? predicate |
376
+ | `spec/legion/llm/conversation_store_spec.rb` | Tests: LRU eviction, append, messages, DB fallback |
377
+ | `spec/legion/llm/pipeline/executor_stream_spec.rb` | Tests: call_stream chunk yielding, pre/post steps |
378
+ | `spec/legion/llm/pipeline/streaming_integration_spec.rb` | Tests: streaming end-to-end with ConversationStore |
379
+ | `spec/legion/llm/gateway_integration_spec.rb` | Tests: gateway teardown — verifies no delegation |
377
380
  | `spec/legion/llm/cost_estimator_spec.rb` | Tests: cost estimation, fuzzy matching, pricing table |
378
381
  | `spec/legion/llm/pipeline/request_spec.rb` | Tests: Request struct builder, legacy adapter |
379
382
  | `spec/legion/llm/pipeline/response_spec.rb` | Tests: Response struct builder, RubyLLM adapter, #with |
@@ -385,6 +388,10 @@ In-memory signal consumer with pluggable handlers. Adjusts effective priorities
385
388
  | `spec/legion/llm/pipeline/steps/metering_spec.rb` | Tests: Metering event building |
386
389
  | `spec/legion/llm/fleet/dispatcher_spec.rb` | Tests: Fleet dispatch, availability, timeout |
387
390
  | `spec/legion/llm/fleet/handler_spec.rb` | Tests: Fleet handler, auth, response building |
391
+ | `spec/legion/llm/pipeline/steps/rag_context_spec.rb` | Tests: RAG context strategy selection, Apollo retrieval, graceful degradation |
392
+ | `spec/legion/llm/pipeline/steps/rag_guard_spec.rb` | Tests: RAG faithfulness checking |
393
+ | `spec/legion/llm/pipeline/enrichment_injector_spec.rb` | Tests: enrichment injection into system prompt |
394
+ | `spec/legion/llm/pipeline/rag_gas_integration_spec.rb` | Tests: RAG/GAS full cycle integration |
388
395
  | `spec/spec_helper.rb` | Stubbed Legion::Logging and Legion::Settings for testing |
389
396
 
390
397
  ## Extension Integration
@@ -444,8 +451,8 @@ The legacy `vault_path` per-provider setting was removed in v0.3.1.
444
451
  Tests run without the full LegionIO stack. `spec/spec_helper.rb` stubs `Legion::Logging` and `Legion::Settings` with in-memory implementations. Each test resets settings to defaults via `before(:each)`.
445
452
 
446
453
  ```bash
447
- bundle exec rspec # 712 examples, 0 failures
448
- bundle exec rubocop # 113 files, 0 offenses
454
+ bundle exec rspec # 794 examples, 0 failures
455
+ bundle exec rubocop # 142 files, 0 offenses
449
456
  ```
450
457
 
451
458
  ## Design Documents
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module ConversationStore
6
+ MAX_CONVERSATIONS = 256
7
+
8
+ class << self
9
+ def append(conversation_id, role:, content:, **metadata)
10
+ ensure_conversation(conversation_id)
11
+ seq = next_seq(conversation_id)
12
+ msg = { seq: seq, role: role, content: content, created_at: Time.now, **metadata }
13
+ conversations[conversation_id][:messages] << msg
14
+ touch(conversation_id)
15
+ persist_message(conversation_id, msg)
16
+ msg
17
+ end
18
+
19
+ def messages(conversation_id)
20
+ if in_memory?(conversation_id)
21
+ touch(conversation_id)
22
+ conversations[conversation_id][:messages].sort_by { |m| m[:seq] }
23
+ else
24
+ load_from_db(conversation_id)
25
+ end
26
+ end
27
+
28
+ def create_conversation(conversation_id, **metadata)
29
+ conversations[conversation_id] = { messages: [], metadata: metadata, accessed_at: Time.now }
30
+ evict_if_needed
31
+ persist_conversation(conversation_id, metadata)
32
+ end
33
+
34
+ def conversation_exists?(conversation_id)
35
+ in_memory?(conversation_id) || db_conversation_exists?(conversation_id)
36
+ end
37
+
38
+ def in_memory?(conversation_id)
39
+ conversations.key?(conversation_id)
40
+ end
41
+
42
+ def reset!
43
+ @conversations = {}
44
+ end
45
+
46
+ private
47
+
48
+ def conversations
49
+ @conversations ||= {}
50
+ end
51
+
52
+ def ensure_conversation(conversation_id)
53
+ return if in_memory?(conversation_id)
54
+
55
+ create_conversation(conversation_id)
56
+ end
57
+
58
+ def next_seq(conversation_id)
59
+ msgs = conversations[conversation_id][:messages]
60
+ msgs.empty? ? 1 : msgs.last[:seq] + 1
61
+ end
62
+
63
+ def touch(conversation_id)
64
+ return unless in_memory?(conversation_id)
65
+
66
+ conversations[conversation_id][:accessed_at] = Time.now
67
+ end
68
+
69
+ def evict_if_needed
70
+ return unless conversations.size > self::MAX_CONVERSATIONS
71
+
72
+ oldest_id = conversations.min_by { |_, v| v[:accessed_at] }&.first
73
+ conversations.delete(oldest_id) if oldest_id
74
+ end
75
+
76
+ def persist_message(conversation_id, msg)
77
+ return unless db_available?
78
+
79
+ db_append_message(conversation_id, msg)
80
+ rescue StandardError => e
81
+ spool_message(conversation_id, msg)
82
+ Legion::Logging.warn("ConversationStore persist failed, spooled: #{e.message}") if defined?(Legion::Logging)
83
+ end
84
+
85
+ def persist_conversation(conversation_id, metadata)
86
+ return unless db_available?
87
+
88
+ db_create_conversation(conversation_id, metadata)
89
+ rescue StandardError => e
90
+ Legion::Logging.warn("ConversationStore conversation persist failed: #{e.message}") if defined?(Legion::Logging)
91
+ end
92
+
93
+ def load_from_db(conversation_id)
94
+ return [] unless db_available?
95
+
96
+ db_load_messages(conversation_id)
97
+ rescue StandardError
98
+ []
99
+ end
100
+
101
+ def db_conversation_exists?(conversation_id)
102
+ return false unless db_available?
103
+
104
+ db_conversation_record?(conversation_id)
105
+ rescue StandardError
106
+ false
107
+ end
108
+
109
+ def db_available?
110
+ defined?(Legion::Data) &&
111
+ Legion::Data.respond_to?(:connection) &&
112
+ Legion::Data.connection.respond_to?(:table_exists?) &&
113
+ Legion::Data.connection.table_exists?(:conversations)
114
+ rescue StandardError
115
+ false
116
+ end
117
+
118
+ def db_create_conversation(conversation_id, metadata)
119
+ Legion::Data.connection[:conversations].insert_ignore.insert(
120
+ id: conversation_id,
121
+ caller_identity: metadata[:caller_identity],
122
+ metadata: metadata.to_json,
123
+ created_at: Time.now,
124
+ updated_at: Time.now
125
+ )
126
+ end
127
+
128
+ def db_append_message(conversation_id, msg)
129
+ Legion::Data.connection[:conversation_messages].insert(
130
+ conversation_id: conversation_id,
131
+ seq: msg[:seq],
132
+ role: msg[:role].to_s,
133
+ content: msg[:content],
134
+ provider: msg[:provider]&.to_s,
135
+ model: msg[:model]&.to_s,
136
+ input_tokens: msg[:input_tokens],
137
+ output_tokens: msg[:output_tokens],
138
+ created_at: msg[:created_at] || Time.now
139
+ )
140
+ end
141
+
142
+ def db_load_messages(conversation_id)
143
+ Legion::Data.connection[:conversation_messages]
144
+ .where(conversation_id: conversation_id)
145
+ .order(:seq)
146
+ .map { |row| symbolize_message(row) }
147
+ end
148
+
149
+ def db_conversation_record?(conversation_id)
150
+ Legion::Data.connection[:conversations].where(id: conversation_id).any?
151
+ end
152
+
153
+ def symbolize_message(row)
154
+ {
155
+ seq: row[:seq],
156
+ role: row[:role]&.to_sym,
157
+ content: row[:content],
158
+ provider: row[:provider]&.to_sym,
159
+ model: row[:model],
160
+ input_tokens: row[:input_tokens],
161
+ output_tokens: row[:output_tokens],
162
+ created_at: row[:created_at]
163
+ }
164
+ end
165
+
166
+ def spool_message(conversation_id, msg)
167
+ return unless defined?(Legion::Data::Spool)
168
+
169
+ dir = File.join(spool_root, 'conversations')
170
+ FileUtils.mkdir_p(dir)
171
+ filename = "#{Time.now.strftime('%s%9N')}-#{SecureRandom.uuid}.json"
172
+ payload = { conversation_id: conversation_id, message: msg }
173
+ File.write(File.join(dir, filename), payload.to_json)
174
+ end
175
+
176
+ def spool_root
177
+ @spool_root ||= File.expand_path('~/.legionio/data/spool/llm')
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ class LLMError < StandardError
6
+ def retryable? = false
7
+ end
8
+
9
+ class AuthError < LLMError; end
10
+
11
+ class RateLimitError < LLMError
12
+ attr_reader :retry_after
13
+
14
+ def initialize(msg = nil, retry_after: nil)
15
+ @retry_after = retry_after
16
+ super(msg)
17
+ end
18
+
19
+ def retryable? = true
20
+ end
21
+
22
+ class ContextOverflow < LLMError
23
+ def retryable? = true
24
+ end
25
+
26
+ class ProviderError < LLMError
27
+ def retryable? = true
28
+ end
29
+
30
+ class ProviderDown < LLMError; end
31
+
32
+ class UnsupportedCapability < LLMError; end
33
+
34
+ class PipelineError < LLMError
35
+ attr_reader :step
36
+
37
+ def initialize(msg = nil, step: nil)
38
+ @step = step
39
+ super(msg)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Pipeline
6
+ module AuditPublisher
7
+ EXCHANGE = 'llm.audit'
8
+ ROUTING_KEY = 'llm.audit.complete'
9
+
10
+ module_function
11
+
12
+ def build_event(request:, response:)
13
+ {
14
+ request_id: response.request_id,
15
+ conversation_id: response.conversation_id,
16
+ caller: response.caller,
17
+ routing: response.routing,
18
+ tokens: response.tokens,
19
+ cost: response.cost,
20
+ enrichments: response.enrichments,
21
+ audit: response.audit,
22
+ timeline: response.timeline,
23
+ timestamps: response.timestamps,
24
+ classification: response.classification,
25
+ tracing: response.tracing,
26
+ messages: request.messages,
27
+ response_content: response.message[:content],
28
+ tools_used: response.tools,
29
+ timestamp: Time.now
30
+ }
31
+ end
32
+
33
+ def publish(request:, response:)
34
+ event = build_event(request: request, response: response)
35
+
36
+ begin
37
+ if defined?(Legion::Transport) &&
38
+ defined?(Legion::Transport::Messages::Dynamic)
39
+ Legion::Transport::Messages::Dynamic.new(
40
+ function: 'llm_audit',
41
+ opts: event,
42
+ exchange: EXCHANGE,
43
+ routing_key: ROUTING_KEY
44
+ ).publish
45
+ elsif defined?(Legion::Logging)
46
+ Legion::Logging.debug('audit publish skipped: transport unavailable')
47
+ end
48
+ rescue StandardError => e
49
+ Legion::Logging.warn("audit publish failed: #{e.message}") if defined?(Legion::Logging)
50
+ end
51
+
52
+ event
53
+ rescue StandardError => e
54
+ Legion::Logging.warn("audit build_event failed: #{e.message}") if defined?(Legion::Logging)
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Pipeline
6
+ module EnrichmentInjector
7
+ module_function
8
+
9
+ def inject(system:, enrichments:)
10
+ parts = []
11
+
12
+ # GAIA system prompt (highest priority)
13
+ if (gaia = enrichments.dig('gaia:system_prompt', :content))
14
+ parts << gaia
15
+ end
16
+
17
+ # RAG context
18
+ if (rag = enrichments.dig('rag:context_retrieval', :data, :entries))
19
+ context_text = rag.map { |e| "[#{e[:content_type]}] #{e[:content]}" }.join("\n")
20
+ parts << "Relevant context:\n#{context_text}" unless context_text.empty?
21
+ end
22
+
23
+ return system if parts.empty?
24
+
25
+ parts << system if system
26
+ parts.join("\n\n")
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end