lex-mesh 0.4.3 → 0.4.4

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: 983bda71103db47ebf1fd1a9087afa36ac9073b108aed204f579204718f84672
4
+ data.tar.gz: 5a2a670f327317978f84f10684ba0b6f09031065e4402bece53309ccab96462e
5
5
  SHA512:
6
- metadata.gz: 7abe441618ba1fca371412adf3a2a48f16f54adc4130d060369e1fcdc7222a777fcc48e165576e74e4cf1892fdd9a42f4562242523bf2d9e8239cdb8af771b4c
7
- data.tar.gz: 61e2b0e8aba072966d10e820db3708ff2c76ecfcda9fc73e99bcd61eff051323c81ea5c1cef0e1c56ce126604369983205f32e732376eab53ad1fae4af1857f5
6
+ metadata.gz: 98a5c88d848ad732100824f8c6bcf4657413bb2fae5137a0a8d46cde3df54888596ddab738e4716a291e28cdfc6c81758d0cfdef73feaef70046e933983dd3e4
7
+ data.tar.gz: c3e8d3eec443be56de9b29817aff18ce83a7b98a5d84ceac5bf702206c33dd987990e77c31cf3c08359825b3916f44535747ad8419259201d6fcbbbff9c0309a
@@ -246,6 +246,86 @@ module Legion
246
246
  def personality_available?
247
247
  defined?(Legion::Extensions::Agentic::Self::Personality::Runners::Personality)
248
248
  end
249
+
250
+ OBSERVATION_THRESHOLD = 20
251
+
252
+ def update_from_observation(owner_id:, signals:)
253
+ @observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
254
+ @observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
255
+
256
+ key = owner_id.to_s
257
+ @observation_counts[key] = (@observation_counts[key] || 0) + 1 # rubocop:disable ThreadSafety/ClassInstanceVariable
258
+ @observation_signals[key] ||= [] # rubocop:disable ThreadSafety/ClassInstanceVariable
259
+ @observation_signals[key] << signals # rubocop:disable ThreadSafety/ClassInstanceVariable
260
+
261
+ derive_and_store_observations(owner_id: key) if @observation_counts[key] >= OBSERVATION_THRESHOLD # rubocop:disable ThreadSafety/ClassInstanceVariable
262
+
263
+ { updated: true }
264
+ rescue StandardError => _e
265
+ { updated: false }
266
+ end
267
+
268
+ def observation_counts
269
+ @observation_counts ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
270
+ end
271
+
272
+ def inferred_preferences(owner_id)
273
+ @inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
274
+ @inferred_preferences[owner_id.to_s] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
275
+ end
276
+
277
+ def clear_observations
278
+ @observation_counts = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
279
+ @observation_signals = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
280
+ @inferred_preferences = {} # rubocop:disable ThreadSafety/ClassInstanceVariable
281
+ end
282
+
283
+ def derive_and_store_observations(owner_id:)
284
+ @observation_signals ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
285
+ signals = @observation_signals[owner_id] || [] # rubocop:disable ThreadSafety/ClassInstanceVariable
286
+ return if signals.empty?
287
+
288
+ inferred = []
289
+ inferred << derive_verbosity(signals)
290
+ inferred << derive_tone(signals)
291
+ inferred << derive_format(signals)
292
+ inferred = inferred.compact
293
+
294
+ @inferred_preferences ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
295
+ @inferred_preferences[owner_id] = inferred # rubocop:disable ThreadSafety/ClassInstanceVariable
296
+
297
+ inferred.each do |pref|
298
+ store_preference(
299
+ owner_id: owner_id,
300
+ domain: pref[:domain],
301
+ value: pref[:value],
302
+ source: pref[:source]
303
+ )
304
+ end
305
+ end
306
+
307
+ def derive_verbosity(signals)
308
+ cli_count = signals.count { |s| s[:channel] == :cli }
309
+ cli_ratio = cli_count.to_f / signals.size
310
+ return nil unless cli_ratio >= 0.5
311
+
312
+ { domain: 'verbosity', value: 'concise', source: 'observation', confidence: 0.65 }
313
+ end
314
+
315
+ def derive_tone(signals)
316
+ da_count = signals.count { |s| s[:direct_address] }
317
+ da_ratio = da_count.to_f / signals.size
318
+ return nil unless da_ratio >= 0.5
319
+
320
+ { domain: 'tone', value: 'conversational', source: 'observation', confidence: 0.65 }
321
+ end
322
+
323
+ def derive_format(signals)
324
+ unique_channels = signals.map { |s| s[:channel] }.uniq
325
+ return nil unless unique_channels.size >= 3
326
+
327
+ { domain: 'format', value: 'adaptive', source: 'observation', confidence: 0.55 }
328
+ end
249
329
  end
250
330
  end
251
331
  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.4'
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 = {
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity