lex-mesh 0.4.3 → 0.4.5

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: b16d673e30a813f0fa38f61386eae0e9a72a35cc2b0eedc6b45ced88a4acadf7
4
- data.tar.gz: dc7a12c1de2412117ac9ce56c7803626dba16604f9cbfba57ca41e72e55dc3b5
3
+ metadata.gz: 0bd1055e60b5e6c6d2e63c08f7aab32c7f03b294b8251da6b583516060e3fdec
4
+ data.tar.gz: aaee0b1ecd57d2e8673706de83ac842476b5aba0570a38ab57bce3ca6c17cee7
5
5
  SHA512:
6
- metadata.gz: 7abe441618ba1fca371412adf3a2a48f16f54adc4130d060369e1fcdc7222a777fcc48e165576e74e4cf1892fdd9a42f4562242523bf2d9e8239cdb8af771b4c
7
- data.tar.gz: 61e2b0e8aba072966d10e820db3708ff2c76ecfcda9fc73e99bcd61eff051323c81ea5c1cef0e1c56ce126604369983205f32e732376eab53ad1fae4af1857f5
6
+ metadata.gz: 7e350395459a7499684bdc844fbd81b4a628f58e2656c2d68031e4ac3688ad954be2486e83023dbe54d2da0eca88e748133b187d6c9d6a9efc431564a0d9e4c5
7
+ data.tar.gz: 1de8d318e631a5e31c2babb44a01424a0371332c05678dce832a55ac1e895963a6a571a3e7a78cf5b65d951c3f81f5b1ddd63fbf801edb078d46d13df5de6552
@@ -22,6 +22,8 @@ module Legion
22
22
  SOURCE_CONFIDENCE = {
23
23
  'explicit' => 1.0,
24
24
  'preference_learning' => 0.75,
25
+ 'llm_inference' => 0.65,
26
+ 'observation' => 0.55,
25
27
  'personality' => 0.4,
26
28
  'defaults' => 0.0
27
29
  }.freeze
@@ -246,6 +248,86 @@ module Legion
246
248
  def personality_available?
247
249
  defined?(Legion::Extensions::Agentic::Self::Personality::Runners::Personality)
248
250
  end
251
+
252
+ OBSERVATION_THRESHOLD = 20
253
+
254
+ def update_from_observation(owner_id:, signals:)
255
+ @observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
256
+ @observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
257
+
258
+ key = owner_id.to_s
259
+ @observation_counts[key] = (@observation_counts[key] || 0) + 1 # rubocop:disable ThreadSafety/ClassInstanceVariable
260
+ @observation_signals[key] ||= [] # rubocop:disable ThreadSafety/ClassInstanceVariable
261
+ @observation_signals[key] << signals # rubocop:disable ThreadSafety/ClassInstanceVariable
262
+
263
+ derive_and_store_observations(owner_id: key) if @observation_counts[key] >= OBSERVATION_THRESHOLD # rubocop:disable ThreadSafety/ClassInstanceVariable
264
+
265
+ { updated: true }
266
+ rescue StandardError => _e
267
+ { updated: false }
268
+ end
269
+
270
+ def observation_counts
271
+ @observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
272
+ end
273
+
274
+ def inferred_preferences(owner_id)
275
+ @inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
276
+ @inferred_preferences[owner_id.to_s] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
277
+ end
278
+
279
+ def clear_observations
280
+ @observation_counts = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
281
+ @observation_signals = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
282
+ @inferred_preferences = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
283
+ end
284
+
285
+ def derive_and_store_observations(owner_id:)
286
+ @observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
287
+ signals = @observation_signals[owner_id] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
288
+ return if signals.empty?
289
+
290
+ inferred = []
291
+ inferred << derive_verbosity(signals)
292
+ inferred << derive_tone(signals)
293
+ inferred << derive_format(signals)
294
+ inferred = inferred.compact
295
+
296
+ @inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
297
+ @inferred_preferences[owner_id] = inferred # rubocop:disable ThreadSafety/ClassInstanceVariable
298
+
299
+ inferred.each do |pref|
300
+ store_preference(
301
+ owner_id: owner_id,
302
+ domain: pref[:domain],
303
+ value: pref[:value],
304
+ source: pref[:source]
305
+ )
306
+ end
307
+ end
308
+
309
+ def derive_verbosity(signals)
310
+ cli_count = signals.count { |s| s[:channel] == :cli }
311
+ cli_ratio = cli_count.to_f / signals.size
312
+ return nil unless cli_ratio >= 0.5
313
+
314
+ { domain: 'verbosity', value: 'concise', source: 'observation', confidence: 0.65 }
315
+ end
316
+
317
+ def derive_tone(signals)
318
+ da_count = signals.count { |s| s[:direct_address] }
319
+ da_ratio = da_count.to_f / signals.size
320
+ return nil unless da_ratio >= 0.5
321
+
322
+ { domain: 'tone', value: 'conversational', source: 'observation', confidence: 0.65 }
323
+ end
324
+
325
+ def derive_format(signals)
326
+ unique_channels = signals.map { |s| s[:channel] }.uniq
327
+ return nil unless unique_channels.size >= 3
328
+
329
+ { domain: 'format', value: 'adaptive', source: 'observation', confidence: 0.55 }
330
+ end
249
331
  end
250
332
  end
251
333
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Mesh
6
- VERSION = '0.4.3'
6
+ VERSION = '0.4.5'
7
7
  end
8
8
  end
9
9
  end
@@ -191,6 +191,81 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::PreferenceProfile do
191
191
  end
192
192
  end
193
193
 
194
+ describe '.update_from_observation' do
195
+ before { described_class.clear_observations }
196
+
197
+ let(:cli_signal) { { content_type: :text, channel: :cli, direct_address: false } }
198
+ let(:chat_signal) { { content_type: :text, channel: :chat, direct_address: true } }
199
+ let(:mixed_signal) { { content_type: :text, channel: :api, direct_address: false } }
200
+
201
+ it 'returns { updated: true } on each call' do
202
+ result = described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal)
203
+ expect(result).to eq({ updated: true })
204
+ end
205
+
206
+ it 'accumulates signals per owner_id' do
207
+ 5.times { described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal) }
208
+ counts = described_class.observation_counts
209
+ expect(counts['user-obs']).to eq(5)
210
+ end
211
+
212
+ it 'does not infer preferences before threshold (20)' do
213
+ 19.times { described_class.update_from_observation(owner_id: 'user-obs', signals: cli_signal) }
214
+ inferred = described_class.inferred_preferences('user-obs')
215
+ expect(inferred).to be_empty
216
+ end
217
+
218
+ it 'derives :concise verbosity for CLI-heavy usage after threshold' do
219
+ 20.times { described_class.update_from_observation(owner_id: 'user-cli', signals: cli_signal) }
220
+ inferred = described_class.inferred_preferences('user-cli')
221
+ verbosity = inferred.find { |p| p[:domain] == 'verbosity' }
222
+ expect(verbosity).not_to be_nil
223
+ expect(verbosity[:value]).to eq('concise')
224
+ expect(verbosity[:source]).to eq('observation')
225
+ end
226
+
227
+ it 'derives :conversational tone for high direct_address ratio after threshold' do
228
+ 20.times { described_class.update_from_observation(owner_id: 'user-da', signals: chat_signal) }
229
+ inferred = described_class.inferred_preferences('user-da')
230
+ tone = inferred.find { |p| p[:domain] == 'tone' }
231
+ expect(tone).not_to be_nil
232
+ expect(tone[:value]).to eq('conversational')
233
+ expect(tone[:source]).to eq('observation')
234
+ end
235
+
236
+ it 'derives :adaptive format for mixed channel usage after threshold' do
237
+ channels = %i[cli api chat rest]
238
+ 20.times.with_index do |i, _|
239
+ ch = channels[i % channels.length]
240
+ described_class.update_from_observation(
241
+ owner_id: 'user-mix',
242
+ signals: { content_type: :text, channel: ch, direct_address: false }
243
+ )
244
+ end
245
+ inferred = described_class.inferred_preferences('user-mix')
246
+ format = inferred.find { |p| p[:domain] == 'format' }
247
+ expect(format).not_to be_nil
248
+ expect(format[:value]).to eq('adaptive')
249
+ expect(format[:source]).to eq('observation')
250
+ end
251
+
252
+ it 'keeps separate observation counts per owner_id' do
253
+ 3.times { described_class.update_from_observation(owner_id: 'user-a', signals: cli_signal) }
254
+ 7.times { described_class.update_from_observation(owner_id: 'user-b', signals: cli_signal) }
255
+ counts = described_class.observation_counts
256
+ expect(counts['user-a']).to eq(3)
257
+ expect(counts['user-b']).to eq(7)
258
+ end
259
+
260
+ it 'calls store_preference for each inferred preference after threshold' do
261
+ allow(described_class).to receive(:store_preference).and_call_original
262
+ 20.times { described_class.update_from_observation(owner_id: 'user-store', signals: cli_signal) }
263
+ expect(described_class).to have_received(:store_preference).with(
264
+ hash_including(owner_id: 'user-store', source: 'observation')
265
+ ).at_least(:once)
266
+ end
267
+ end
268
+
194
269
  describe '.preference_instructions' do
195
270
  it 'generates natural language prompt instructions from profile' do
196
271
  profile = {
@@ -211,4 +286,25 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::PreferenceProfile do
211
286
  expect(instructions).to be_nil
212
287
  end
213
288
  end
289
+
290
+ describe 'SOURCE_CONFIDENCE' do
291
+ it 'includes observation source' do
292
+ expect(described_class::SOURCE_CONFIDENCE).to have_key('observation')
293
+ expect(described_class::SOURCE_CONFIDENCE['observation']).to eq(0.55)
294
+ end
295
+
296
+ it 'includes llm_inference source' do
297
+ expect(described_class::SOURCE_CONFIDENCE).to have_key('llm_inference')
298
+ expect(described_class::SOURCE_CONFIDENCE['llm_inference']).to eq(0.65)
299
+ end
300
+
301
+ it 'maintains descending confidence order for all sources' do
302
+ conf = described_class::SOURCE_CONFIDENCE
303
+ expect(conf['explicit']).to be > conf['preference_learning']
304
+ expect(conf['preference_learning']).to be > conf['llm_inference']
305
+ expect(conf['llm_inference']).to be > conf['observation']
306
+ expect(conf['observation']).to be > conf['personality']
307
+ expect(conf['personality']).to be > conf['defaults']
308
+ end
309
+ end
214
310
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-mesh
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.3
4
+ version: 0.4.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity