lex-microsoft_teams 0.6.51 → 0.6.53

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -0
  3. data/CHANGELOG.md +10 -1
  4. data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +3 -3
  5. data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +3 -3
  6. data/lib/legion/extensions/microsoft_teams/absorbers/meeting.rb +2 -2
  7. data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +2 -2
  8. data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +2 -2
  9. data/lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb +2 -2
  10. data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +16 -6
  11. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +1 -1
  12. data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +1 -1
  13. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +1 -1
  14. data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +1 -1
  15. data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +14 -6
  16. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +1 -1
  17. data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +14 -5
  18. data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +2 -2
  19. data/lib/legion/extensions/microsoft_teams/faraday/retry_after.rb +1 -1
  20. data/lib/legion/extensions/microsoft_teams/helpers/client.rb +4 -4
  21. data/lib/legion/extensions/microsoft_teams/helpers/graph_cache.rb +3 -3
  22. data/lib/legion/extensions/microsoft_teams/helpers/graph_client.rb +1 -1
  23. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +1 -1
  24. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +1 -1
  25. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +1 -1
  26. data/lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb +185 -0
  27. data/lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb +1 -1
  28. data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +1 -1
  29. data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +91 -19
  30. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +2 -2
  31. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +2 -2
  32. data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +89 -35
  33. data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
  34. data/lib/legion/extensions/microsoft_teams.rb +3 -1
  35. metadata +2 -1
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'digest'
5
+ require 'legion/extensions/microsoft_teams/errors'
4
6
  require 'legion/extensions/microsoft_teams/helpers/client'
5
7
  require 'legion/extensions/microsoft_teams/helpers/graph_cache'
6
8
  require 'legion/extensions/microsoft_teams/helpers/permission_guard'
@@ -36,37 +38,36 @@ module Legion
36
38
  teams_result = ingest_teams_and_meetings(token: token)
37
39
 
38
40
  { self: self_result, people: people_result, conversations: conv_result, teams: teams_result }
41
+ rescue Errors::Throttled => e
42
+ # Once Graph throttles us, the shared circuit is open and every
43
+ # remaining stage would just raise Throttled(attempts: 0)
44
+ # instantly — a burst of identical errors that ingests nothing.
45
+ # Stop the fan-out at the first throttled stage and report it.
46
+ log.warn('[microsoft_teams][profile_ingest] throttled mid-ingest; ' \
47
+ 'aborting remaining stages (retry_after=' \
48
+ "#{e.retry_after_known? ? format('%.1f', e.retry_after) : 'unknown'} path=#{e.request})")
49
+ { throttled: true, retry_after: e.retry_after, request: e.request }
39
50
  end
40
51
 
41
52
  def ingest_self(token:, **)
42
53
  conn = graph_connection(token: token)
43
54
  profile = conn.get('me').body
44
55
 
45
- memory_runner.store_trace(
46
- type: :identity,
47
- content_payload: ::JSON.dump(profile),
48
- domain_tags: %w[teams self owner],
49
- confidence: 1.0,
50
- origin: :direct_experience
51
- )
52
-
53
56
  presence = begin
54
57
  conn.get('me/presence').body
55
58
  rescue StandardError => e
56
- handle_exception(e, level: :debug, operation: 'ProfileIngest#ingest_self presence') if defined?(handle_exception)
59
+ handle_exception(e, level: :warn, operation: 'ProfileIngest#ingest_self presence') if defined?(handle_exception)
57
60
  {}
58
61
  end
59
- unless presence.empty?
60
- memory_runner.store_trace(
61
- type: :sensory,
62
- content_payload: ::JSON.dump(presence),
63
- domain_tags: %w[teams presence self],
64
- confidence: 0.8,
65
- origin: :direct_experience
66
- )
67
- end
68
62
 
69
63
  { profile: profile, presence: presence }
64
+ # rubocop:disable Legion/RescueLogging/NoCapture
65
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
66
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
67
+ # logging it here too would just duplicate the line per stage.
68
+ rescue Errors::Throttled
69
+ raise
70
+ # rubocop:enable Legion/RescueLogging/NoCapture
70
71
  rescue StandardError => e
71
72
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_self')
72
73
  { error: e.message }
@@ -86,19 +87,14 @@ module Legion
86
87
  people = (resp.body || {}).fetch('value', [])
87
88
  people.sort_by! { |p| -(p.dig('scoredEmailAddresses', 0, 'relevanceScore') || 0) }
88
89
 
89
- people.each do |person|
90
- name = person['displayName'] || 'Unknown'
91
- memory_runner.store_trace(
92
- type: :semantic,
93
- content_payload: ::JSON.dump(person.slice('displayName', 'jobTitle', 'department',
94
- 'officeLocation', 'scoredEmailAddresses')),
95
- domain_tags: ['teams', 'peer', "peer:#{name}"],
96
- confidence: 0.7,
97
- origin: :direct_experience
98
- )
99
- end
100
-
101
90
  { people: people, count: people.length }
91
+ # rubocop:disable Legion/RescueLogging/NoCapture
92
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
93
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
94
+ # logging it here too would just duplicate the line per stage.
95
+ rescue Errors::Throttled
96
+ raise
97
+ # rubocop:enable Legion/RescueLogging/NoCapture
102
98
  rescue StandardError => e
103
99
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_people')
104
100
  { error: e.message, skipped: false }
@@ -148,6 +144,13 @@ module Legion
148
144
  end
149
145
 
150
146
  { ingested: ingested }
147
+ # rubocop:disable Legion/RescueLogging/NoCapture
148
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
149
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
150
+ # logging it here too would just duplicate the line per stage.
151
+ rescue Errors::Throttled
152
+ raise
153
+ # rubocop:enable Legion/RescueLogging/NoCapture
151
154
  rescue StandardError => e
152
155
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_conversations')
153
156
  { error: e.message, ingested: ingested || 0 }
@@ -194,6 +197,13 @@ module Legion
194
197
  end
195
198
 
196
199
  { teams: teams_count, meetings: meetings_count }
200
+ # rubocop:disable Legion/RescueLogging/NoCapture
201
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
202
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
203
+ # logging it here too would just duplicate the line per stage.
204
+ rescue Errors::Throttled
205
+ raise
206
+ # rubocop:enable Legion/RescueLogging/NoCapture
197
207
  rescue StandardError => e
198
208
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_teams_and_meetings')
199
209
  { error: e.message }
@@ -210,13 +220,16 @@ module Legion
210
220
  top_people: top_people, message_depth: message_depth)
211
221
  end
212
222
 
223
+ CHAT_TYPE_PRIORITY = { 'oneOnOne' => 0, 'group' => 1, 'meeting' => 2 }.freeze
224
+
213
225
  private
214
226
 
215
227
  def build_chat_member_index(conn:, chats:)
216
228
  index = {}
217
- chats.each do |chat|
229
+ sorted = chats.sort_by { |c| CHAT_TYPE_PRIORITY[c['chatType']] || 99 }
230
+ sorted.each do |chat|
218
231
  members = cached_graph_get(conn: conn, path: "chats/#{chat['id']}/members",
219
- shared: true)
232
+ shared: true, ttl: members_cache_ttl)
220
233
  .then { |body| (body || {}).fetch('value', []) }
221
234
  members.each do |m|
222
235
  email = m['email']&.downcase
@@ -224,24 +237,65 @@ module Legion
224
237
  end
225
238
  end
226
239
  index
240
+ # rubocop:disable Legion/RescueLogging/NoCapture
241
+ # Re-raise unlogged: propagate to full_ingest so the per-chat
242
+ # fan-out stops on the first throttle; full_ingest logs it once.
243
+ rescue Errors::Throttled
244
+ raise
245
+ # rubocop:enable Legion/RescueLogging/NoCapture
227
246
  rescue StandardError => e
228
247
  handle_exception(e, level: :warn, operation: 'ProfileIngest#build_chat_member_index')
229
248
  {}
230
249
  end
231
250
 
251
+ def members_cache_ttl
252
+ return @members_cache_ttl if defined?(@members_cache_ttl)
253
+
254
+ @members_cache_ttl = if respond_to?(:settings, true) && settings.respond_to?(:dig)
255
+ settings.dig(:cache, :members_ttl) || 86_400
256
+ else
257
+ 86_400
258
+ end
259
+ end
260
+
232
261
  def fetch_new_messages(conn:, chat_id:, depth: 50)
233
262
  hwm = get_extended_hwm(chat_id: chat_id)
234
263
  params = { '$top' => depth, '$orderby' => 'createdDateTime desc' }
235
- params['$filter'] = "createdDateTime gt #{hwm[:last_message_at]}" if hwm&.dig(:last_message_at)
236
264
 
237
265
  resp = conn.get("chats/#{chat_id}/messages", params)
238
- (resp.body || {}).fetch('value', [])
266
+
267
+ unless (200..299).cover?(resp.status)
268
+ error_code = resp.body&.dig('error', 'code')
269
+ log.warn('[microsoft_teams][profile_ingest] fetch_new_messages non-2xx: ' \
270
+ "chat_id=#{chat_id} status=#{resp.status} error_code=#{error_code}")
271
+ @fetch_failures = (@fetch_failures || 0) + 1
272
+ return []
273
+ end
274
+
275
+ messages = (resp.body || {}).fetch('value', [])
276
+ hwm_client_side_cut(messages: messages, hwm: hwm)
277
+ # rubocop:disable Legion/RescueLogging/NoCapture
278
+ # Re-raise unlogged: propagate to full_ingest so the per-chat
279
+ # fan-out stops on the first throttle; full_ingest logs it once.
280
+ rescue Errors::Throttled
281
+ raise
282
+ # rubocop:enable Legion/RescueLogging/NoCapture
239
283
  rescue StandardError => e
240
284
  handle_exception(e, level: :warn, operation: 'ProfileIngest#fetch_new_messages',
241
285
  chat_id: chat_id)
286
+ @fetch_failures = (@fetch_failures || 0) + 1
242
287
  []
243
288
  end
244
289
 
290
+ def hwm_client_side_cut(messages:, hwm:)
291
+ return messages unless hwm&.dig(:last_message_at)
292
+
293
+ cutoff = hwm[:last_message_at]
294
+ seen = Set.new
295
+ messages.take_while { |m| m['createdDateTime'] && m['createdDateTime'] > cutoff }
296
+ .select { |m| seen.add?(m['id'] || Digest::SHA256.hexdigest(m.dig('body', 'content').to_s)[0, 16]) }
297
+ end
298
+
245
299
  def extract_conversation(messages:, peer_name:)
246
300
  return nil if messages.empty?
247
301
 
@@ -265,7 +319,7 @@ module Legion
265
319
  llm_ask(message: "#{definition[:prompt]}\n\nConversation with #{peer_name}:\n#{text}")
266
320
  end
267
321
  rescue StandardError => e
268
- handle_exception(e, level: :debug, operation: 'ProfileIngest#extract_conversation')
322
+ handle_exception(e, level: :warn, operation: 'ProfileIngest#extract_conversation')
269
323
  nil
270
324
  end
271
325
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.6.51'
6
+ VERSION = '0.6.53'
7
7
  end
8
8
  end
9
9
  end
@@ -4,6 +4,7 @@ require 'legion/extensions/identity/entra/helpers/token_manager'
4
4
  require 'legion/extensions/microsoft_teams/version'
5
5
  require 'legion/extensions/microsoft_teams/errors'
6
6
  require 'legion/extensions/microsoft_teams/faraday/retry_after'
7
+ require 'legion/extensions/microsoft_teams/helpers/throttle_aware'
7
8
  require 'legion/extensions/microsoft_teams/helpers/client'
8
9
  require 'legion/extensions/microsoft_teams/runners/auth'
9
10
  require 'legion/extensions/microsoft_teams/runners/teams'
@@ -106,7 +107,8 @@ module Legion
106
107
  interval: 60
107
108
  },
108
109
  cache: {
109
- graph_ttl: 300
110
+ graph_ttl: 300,
111
+ members_ttl: 86_400
110
112
  },
111
113
  client: {
112
114
  throttle_circuit: {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-microsoft_teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.51
4
+ version: 0.6.53
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -218,6 +218,7 @@ files:
218
218
  - lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb
219
219
  - lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
220
220
  - lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
221
+ - lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb
221
222
  - lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb
222
223
  - lib/legion/extensions/microsoft_teams/helpers/transform_definitions.rb
223
224
  - lib/legion/extensions/microsoft_teams/local_cache/extractor.rb