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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08ccdbe9c1f4187acdcae49df633370d7fecb9866fa441aa1e95a3f932d5c9e6'
4
- data.tar.gz: df48f1ba0ef83a0fb26ba67865bf0f69ee415a349f2fc0e4ec40dd70f7f70815
3
+ metadata.gz: 9bfeb8ca0bacd82ef217f76275e20d8df7a15f83a324bcf9f090232ed7374837
4
+ data.tar.gz: cb4ceb07ae6004670a9e582a1fad6d3d2bc9a87e5a123116ff1ef995e5576744
5
5
  SHA512:
6
- metadata.gz: 9237a7a67d3b843bef628817cbd679fdce2f690e35b35f817e38b39c2e46918cb87d1c81b0b3c5a48589f54a163cbd7f3ef5956e73ba3c96f4f0ee3c1f803c9d
7
- data.tar.gz: 996f35c9bb47ff5046bfd10fe48440a783480b81956b6e0b7f1f10bd860fd4aaab9dd9fcda3378761ac2942ea7f9c886d0bd19722cee9b46936f7ae8c662aa9d
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
- result = ollama_embed_request(model: model, input: texts)
190
- vectors = result['embeddings'] || []
191
- vectors.each_with_index.map do |vec, i|
192
- build_batch_entry(vec, model, :ollama, i)
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
@@ -6,6 +6,7 @@ require 'legion/llm/hooks/metering'
6
6
  require 'legion/llm/hooks/cost_tracking'
7
7
  require 'legion/llm/hooks/budget_guard'
8
8
  require 'legion/llm/hooks/reflection'
9
+ require 'legion/llm/hooks/reciprocity'
9
10
 
10
11
  module Legion
11
12
  module LLM
@@ -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
@@ -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[nomic-embed-text mxbai-embed-large bge-large snowflake-arctic-embed]
153
+ ollama_preferred: %w[mxbai-embed-large nomic-embed-text bge-large snowflake-arctic-embed]
154
154
  }
155
155
  end
156
156
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.5.21'
5
+ VERSION = '0.5.23'
6
6
  end
7
7
  end
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.21
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