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 +4 -4
- data/CHANGELOG.md +32 -0
- data/CLAUDE.md +25 -18
- data/lib/legion/llm/conversation_store.rb +182 -0
- data/lib/legion/llm/errors.rb +43 -0
- data/lib/legion/llm/pipeline/audit_publisher.rb +60 -0
- data/lib/legion/llm/pipeline/enrichment_injector.rb +31 -0
- data/lib/legion/llm/pipeline/executor.rb +136 -19
- data/lib/legion/llm/pipeline/gaia_caller.rb +58 -0
- data/lib/legion/llm/pipeline/steps/gaia_advisory.rb +64 -0
- data/lib/legion/llm/pipeline/steps/mcp_discovery.rb +59 -0
- data/lib/legion/llm/pipeline/steps/post_response.rb +59 -0
- data/lib/legion/llm/pipeline/steps/rag_context.rb +85 -0
- data/lib/legion/llm/pipeline/steps/rag_guard.rb +37 -0
- data/lib/legion/llm/pipeline/steps/tool_calls.rb +63 -0
- data/lib/legion/llm/pipeline/steps.rb +18 -0
- data/lib/legion/llm/pipeline/tool_dispatcher.rb +81 -0
- data/lib/legion/llm/pipeline.rb +5 -1
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +18 -53
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7d783c05981cd0272f10826212088d4054d5d001f06c0ab1d813ac8a0d40a3ca
|
|
4
|
+
data.tar.gz: 5eac65f6ad91f5f7296b05b5c4b1bf89af789176a3775cb8864553bec5c924d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
│
|
|
62
|
-
│
|
|
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
|
-
|
|
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` (
|
|
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/
|
|
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/
|
|
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 #
|
|
448
|
-
bundle exec rubocop #
|
|
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
|