legion-llm 0.5.21 → 0.5.23
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 +18 -0
- data/legion-llm.gemspec +1 -0
- data/lib/legion/llm/embeddings.rb +64 -6
- data/lib/legion/llm/hooks/reciprocity.rb +45 -0
- data/lib/legion/llm/hooks.rb +1 -0
- data/lib/legion/llm/pipeline/steps/gaia_advisory.rb +103 -0
- data/lib/legion/llm/settings.rb +1 -1
- data/lib/legion/llm/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9bfeb8ca0bacd82ef217f76275e20d8df7a15f83a324bcf9f090232ed7374837
|
|
4
|
+
data.tar.gz: cb4ceb07ae6004670a9e582a1fad6d3d2bc9a87e5a123116ff1ef995e5576744
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a24a288be1a94ef846aea5c47871c7f7875e99749078a77a431af854fbdbf3582cfdf1191aa570d9ae08d77f723bb653fa4b431258c77f5e050e2bd8ec367ef
|
|
7
|
+
data.tar.gz: '0508aced0a6e1e8f09fb2e6ae2c8121f81d2b8efe765e37b1bb7d23c5ae688dcb9dc314c0a92cb6caef004c0ee8767f3b79e961728d3f278a5ceaf99bd16d844'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.5.23] - 2026-03-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Hooks::Reciprocity` — after_chat hook that records a `:given` social exchange event via `Social::Social::Client#record_exchange` when a caller with identity receives an LLM response; silently no-ops when social extension or identity is absent
|
|
9
|
+
- Partner context enrichment in `Pipeline::Steps::GaiaAdvisory` (step 7) — when the caller identity is registered as a partner in `Legion::Gaia::BondRegistry`, the advisory data is enriched with a `:partner_context` hash containing standing, compatibility, recent_sentiment, and interaction_pattern; sourced from Apollo Local `partner`-tagged entries with full graceful degradation when Apollo is unavailable
|
|
10
|
+
|
|
11
|
+
## [0.5.22] - 2026-03-31
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- Auto-chunking for oversized Ollama embedding inputs via `lex-knowledge` Chunker with character-split fallback
|
|
15
|
+
- `average_vectors` for document-level embedding from multiple chunks
|
|
16
|
+
- Per-model Ollama context limits (`OLLAMA_CONTEXT_CHARS`): mxbai-embed-large 2048, nomic-embed-text 32768
|
|
17
|
+
- `lex-knowledge` added as a dependency for semantic chunking
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `handle_embed_failure` no longer permanently mutates `@embedding_provider` — failover is per-request only
|
|
21
|
+
- `ollama_preferred` order corrected: `mxbai-embed-large` (1024 dims) first, `nomic-embed-text` (768 dims) second
|
|
22
|
+
|
|
5
23
|
## [0.5.21] - 2026-03-31
|
|
6
24
|
|
|
7
25
|
### Added
|
data/legion-llm.gemspec
CHANGED
|
@@ -33,6 +33,7 @@ Gem::Specification.new do |spec|
|
|
|
33
33
|
spec.add_dependency 'lex-bedrock'
|
|
34
34
|
spec.add_dependency 'lex-claude'
|
|
35
35
|
spec.add_dependency 'lex-gemini'
|
|
36
|
+
spec.add_dependency 'lex-knowledge'
|
|
36
37
|
spec.add_dependency 'lex-openai'
|
|
37
38
|
spec.add_dependency 'ruby_llm', '~> 1.13'
|
|
38
39
|
spec.add_dependency 'tzinfo', '>= 2.0'
|
|
@@ -16,6 +16,14 @@ module Legion
|
|
|
16
16
|
|
|
17
17
|
TARGET_DIMENSION = 1024
|
|
18
18
|
|
|
19
|
+
OLLAMA_CONTEXT_CHARS = {
|
|
20
|
+
'mxbai-embed-large' => 2048,
|
|
21
|
+
'bge-large' => 2048,
|
|
22
|
+
'snowflake-arctic-embed' => 2048,
|
|
23
|
+
'nomic-embed-text' => 32_768
|
|
24
|
+
}.freeze
|
|
25
|
+
OLLAMA_DEFAULT_CONTEXT_CHARS = 2048
|
|
26
|
+
|
|
19
27
|
class << self
|
|
20
28
|
def generate(text:, model: nil, provider: nil, dimensions: nil)
|
|
21
29
|
return { vector: nil, model: model, provider: provider, error: 'LLM not started' } unless LLM.started?
|
|
@@ -97,8 +105,6 @@ module Legion
|
|
|
97
105
|
fallback = find_fallback_provider(failed_provider)
|
|
98
106
|
if fallback
|
|
99
107
|
Legion::Logging.info "Embedding failover: #{failed_provider} -> #{fallback[:provider]}" if defined?(Legion::Logging)
|
|
100
|
-
LLM.instance_variable_set(:@embedding_provider, fallback[:provider])
|
|
101
|
-
LLM.instance_variable_set(:@embedding_model, fallback[:model])
|
|
102
108
|
generate(text: text, model: fallback[:model], provider: fallback[:provider])
|
|
103
109
|
else
|
|
104
110
|
{ vector: nil, model: failed_model, provider: failed_provider, error: error.message }
|
|
@@ -177,6 +183,9 @@ module Legion
|
|
|
177
183
|
end
|
|
178
184
|
|
|
179
185
|
def generate_ollama(text:, model:)
|
|
186
|
+
max_chars = ollama_context_chars(model)
|
|
187
|
+
return generate_ollama_chunked(text: text, model: model, max_chars: max_chars) if text.length > max_chars
|
|
188
|
+
|
|
180
189
|
result = ollama_embed_request(model: model, input: text)
|
|
181
190
|
vector = result['embeddings']&.first
|
|
182
191
|
vector = apply_dimension_enforcement(vector, :ollama) if vector
|
|
@@ -185,14 +194,63 @@ module Legion
|
|
|
185
194
|
{ vector: vector, model: model, provider: :ollama, dimensions: vector&.size || 0, tokens: 0 }
|
|
186
195
|
end
|
|
187
196
|
|
|
197
|
+
def generate_ollama_chunked(text:, model:, max_chars:)
|
|
198
|
+
chunks = chunk_text(text, max_chars: max_chars)
|
|
199
|
+
vectors = chunks.filter_map do |chunk|
|
|
200
|
+
result = ollama_embed_request(model: model, input: chunk[:content])
|
|
201
|
+
result['embeddings']&.first
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
return { vector: nil, model: model, provider: :ollama, error: 'all chunks failed embedding' } if vectors.empty?
|
|
205
|
+
|
|
206
|
+
avg = average_vectors(vectors)
|
|
207
|
+
avg = apply_dimension_enforcement(avg, :ollama)
|
|
208
|
+
return dimension_error(model, :ollama, avg) if avg.is_a?(String)
|
|
209
|
+
|
|
210
|
+
{ vector: avg, model: model, provider: :ollama, dimensions: avg.size, tokens: 0, chunks: vectors.size }
|
|
211
|
+
end
|
|
212
|
+
|
|
188
213
|
def generate_ollama_batch(texts:, model:)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
214
|
+
max_chars = ollama_context_chars(model)
|
|
215
|
+
texts.each_with_index.map do |text, i|
|
|
216
|
+
if text.length > max_chars
|
|
217
|
+
result = generate_ollama_chunked(text: text, model: model, max_chars: max_chars)
|
|
218
|
+
build_batch_entry(result[:vector], model, :ollama, i)
|
|
219
|
+
else
|
|
220
|
+
result = ollama_embed_request(model: model, input: text)
|
|
221
|
+
vec = result['embeddings']&.first
|
|
222
|
+
build_batch_entry(vec, model, :ollama, i)
|
|
223
|
+
end
|
|
193
224
|
end
|
|
194
225
|
end
|
|
195
226
|
|
|
227
|
+
def chunk_text(text, max_chars:)
|
|
228
|
+
if defined?(Legion::Extensions::Knowledge::Helpers::Chunker)
|
|
229
|
+
chunker = Legion::Extensions::Knowledge::Helpers::Chunker
|
|
230
|
+
max_tokens = max_chars / chunker::CHARS_PER_TOKEN
|
|
231
|
+
sections = [{ content: text, heading: nil, section_path: nil, source_file: nil }]
|
|
232
|
+
chunker.chunk(sections: sections, max_tokens: max_tokens)
|
|
233
|
+
else
|
|
234
|
+
text.chars.each_slice(max_chars).map { |s| { content: s.join } }
|
|
235
|
+
end
|
|
236
|
+
rescue StandardError
|
|
237
|
+
text.chars.each_slice(max_chars).map { |s| { content: s.join } }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def average_vectors(vectors)
|
|
241
|
+
return vectors.first if vectors.size == 1
|
|
242
|
+
|
|
243
|
+
dim = vectors.first.size
|
|
244
|
+
sum = Array.new(dim, 0.0)
|
|
245
|
+
vectors.each { |v| v.each_with_index { |val, i| sum[i] += val } }
|
|
246
|
+
sum.map { |s| s / vectors.size }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def ollama_context_chars(model)
|
|
250
|
+
base = model.to_s.split(':').first
|
|
251
|
+
OLLAMA_CONTEXT_CHARS[base] || OLLAMA_DEFAULT_CONTEXT_CHARS
|
|
252
|
+
end
|
|
253
|
+
|
|
196
254
|
def ollama_embed_request(model:, input:)
|
|
197
255
|
base_url = Legion::Settings.dig(:llm, :providers, :ollama, :base_url) || 'http://localhost:11434'
|
|
198
256
|
conn = Faraday.new(url: base_url) do |f|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module LLM
|
|
5
|
+
module Hooks
|
|
6
|
+
# Records a :given exchange event on the social graph whenever the LLM
|
|
7
|
+
# responds to a caller that carries an identity. Runs as an after_chat hook.
|
|
8
|
+
#
|
|
9
|
+
# The hook is intentionally lightweight — it does not block the response
|
|
10
|
+
# path and silently swallows all errors so a social-layer problem never
|
|
11
|
+
# surfaces to the caller.
|
|
12
|
+
module Reciprocity
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def install
|
|
16
|
+
Legion::LLM::Hooks.after_chat do |caller: nil, **|
|
|
17
|
+
record_reciprocity(caller: caller)
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record_reciprocity(caller:)
|
|
23
|
+
identity = caller&.dig(:requested_by, :identity)
|
|
24
|
+
return unless identity
|
|
25
|
+
|
|
26
|
+
runner = social_runner
|
|
27
|
+
return unless runner
|
|
28
|
+
|
|
29
|
+
runner.record_exchange(agent_id: identity, action: :communication, direction: :given)
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
Legion::Logging.debug "[LLM::Reciprocity] hook error: #{e.message}" if defined?(Legion::Logging)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def social_runner
|
|
35
|
+
return nil unless defined?(Legion::Extensions::Agentic::Social::Social::Client)
|
|
36
|
+
|
|
37
|
+
Legion::Extensions::Agentic::Social::Social::Client.new
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Legion::Logging.debug "[LLM::Reciprocity] social_runner error: #{e.message}" if defined?(Legion::Logging)
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/legion/llm/hooks.rb
CHANGED
|
@@ -19,6 +19,8 @@ module Legion
|
|
|
19
19
|
|
|
20
20
|
return if advisory.nil? || advisory.empty?
|
|
21
21
|
|
|
22
|
+
enrich_advisory_with_partner_context(advisory)
|
|
23
|
+
|
|
22
24
|
@enrichments['gaia:advisory'] = {
|
|
23
25
|
content: advisory_summary(advisory),
|
|
24
26
|
data: advisory,
|
|
@@ -48,6 +50,29 @@ module Legion
|
|
|
48
50
|
@warnings << "GAIA advisory error: #{e.message}"
|
|
49
51
|
end
|
|
50
52
|
|
|
53
|
+
# Exposed as a public method so specs can stub it on instances.
|
|
54
|
+
def build_partner_context(identity)
|
|
55
|
+
return default_partner_context unless apollo_local_available?
|
|
56
|
+
|
|
57
|
+
entries = ::Legion::Apollo::Local.query(
|
|
58
|
+
text: identity,
|
|
59
|
+
tags: ['partner'],
|
|
60
|
+
limit: 5
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
results = entries.is_a?(Hash) ? (entries[:results] || []) : Array(entries)
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
standing: extract_standing(results),
|
|
67
|
+
compatibility: extract_compatibility(results),
|
|
68
|
+
recent_sentiment: extract_sentiment(results),
|
|
69
|
+
interaction_pattern: extract_interaction_pattern(results)
|
|
70
|
+
}
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Legion::Logging.debug "[GaiaAdvisory] build_partner_context error: #{e.message}" if defined?(Legion::Logging)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
51
76
|
private
|
|
52
77
|
|
|
53
78
|
def advisory_summary(advisory)
|
|
@@ -57,6 +82,84 @@ module Legion
|
|
|
57
82
|
parts << "suppress:#{advisory[:suppress]&.join(',')}" if advisory[:suppress]
|
|
58
83
|
parts.empty? ? 'no enrichment' : parts.join(', ')
|
|
59
84
|
end
|
|
85
|
+
|
|
86
|
+
def enrich_advisory_with_partner_context(advisory)
|
|
87
|
+
return unless defined?(::Legion::Gaia::BondRegistry)
|
|
88
|
+
|
|
89
|
+
identity = @request.caller&.dig(:requested_by, :identity)
|
|
90
|
+
return unless identity
|
|
91
|
+
return unless ::Legion::Gaia::BondRegistry.partner?(identity)
|
|
92
|
+
|
|
93
|
+
partner_ctx = build_partner_context(identity)
|
|
94
|
+
advisory[:partner_context] = partner_ctx if partner_ctx
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
Legion::Logging.debug "[GaiaAdvisory] partner context error: #{e.message}" if defined?(Legion::Logging)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apollo_local_available?
|
|
100
|
+
defined?(::Legion::Apollo::Local) && ::Legion::Apollo::Local.started?
|
|
101
|
+
rescue StandardError
|
|
102
|
+
false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def default_partner_context
|
|
106
|
+
{
|
|
107
|
+
standing: :unknown,
|
|
108
|
+
compatibility: nil,
|
|
109
|
+
recent_sentiment: :neutral,
|
|
110
|
+
interaction_pattern: :unknown
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def extract_standing(results)
|
|
115
|
+
entry = results.find { |r| r[:content].to_s.match?(/standing/i) }
|
|
116
|
+
return :unknown unless entry
|
|
117
|
+
|
|
118
|
+
content = entry[:content].to_s
|
|
119
|
+
if content.match?(/good|trusted|positive/i)
|
|
120
|
+
:good
|
|
121
|
+
elsif content.match?(/poor|untrusted|negative/i)
|
|
122
|
+
:poor
|
|
123
|
+
else
|
|
124
|
+
:neutral
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def extract_compatibility(results)
|
|
129
|
+
entry = results.find { |r| r[:content].to_s.match?(/compat/i) }
|
|
130
|
+
return nil unless entry
|
|
131
|
+
|
|
132
|
+
match = entry[:content].to_s.match(/(\d+(?:\.\d+)?)/)
|
|
133
|
+
match ? match[1].to_f : nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_sentiment(results)
|
|
137
|
+
entry = results.find { |r| r[:content].to_s.match?(/sentiment|empathy|affect/i) }
|
|
138
|
+
return :neutral unless entry
|
|
139
|
+
|
|
140
|
+
content = entry[:content].to_s
|
|
141
|
+
if content.match?(/positive|happy|pleasant/i)
|
|
142
|
+
:positive
|
|
143
|
+
elsif content.match?(/negative|unhappy|tense/i)
|
|
144
|
+
:negative
|
|
145
|
+
else
|
|
146
|
+
:neutral
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def extract_interaction_pattern(results)
|
|
151
|
+
entry = results.find { |r| r[:content].to_s.match?(/interaction|memory|trace/i) }
|
|
152
|
+
return :unknown unless entry
|
|
153
|
+
|
|
154
|
+
content = entry[:content].to_s
|
|
155
|
+
if content.match?(/frequent|regular|daily/i)
|
|
156
|
+
:frequent
|
|
157
|
+
elsif content.match?(/occasional|sometimes/i)
|
|
158
|
+
:occasional
|
|
159
|
+
else
|
|
160
|
+
:infrequent
|
|
161
|
+
end
|
|
162
|
+
end
|
|
60
163
|
end
|
|
61
164
|
end
|
|
62
165
|
end
|
data/lib/legion/llm/settings.rb
CHANGED
|
@@ -150,7 +150,7 @@ module Legion
|
|
|
150
150
|
bedrock: 'amazon.titan-embed-text-v2:0',
|
|
151
151
|
openai: 'text-embedding-3-small'
|
|
152
152
|
},
|
|
153
|
-
ollama_preferred: %w[
|
|
153
|
+
ollama_preferred: %w[mxbai-embed-large nomic-embed-text bge-large snowflake-arctic-embed]
|
|
154
154
|
}
|
|
155
155
|
end
|
|
156
156
|
|
data/lib/legion/llm/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.23
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -121,6 +121,20 @@ dependencies:
|
|
|
121
121
|
- - ">="
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: lex-knowledge
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '0'
|
|
124
138
|
- !ruby/object:Gem::Dependency
|
|
125
139
|
name: lex-openai
|
|
126
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -239,6 +253,7 @@ files:
|
|
|
239
253
|
- lib/legion/llm/hooks/cost_tracking.rb
|
|
240
254
|
- lib/legion/llm/hooks/metering.rb
|
|
241
255
|
- lib/legion/llm/hooks/rag_guard.rb
|
|
256
|
+
- lib/legion/llm/hooks/reciprocity.rb
|
|
242
257
|
- lib/legion/llm/hooks/reflection.rb
|
|
243
258
|
- lib/legion/llm/hooks/response_guard.rb
|
|
244
259
|
- lib/legion/llm/off_peak.rb
|