lex-llm-ledger 0.4.2 → 0.6.0

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 (25) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/lib/legion/extensions/llm/ledger/actors/escalations.rb +34 -0
  4. data/lib/legion/extensions/llm/ledger/actors/reconciliation.rb +48 -0
  5. data/lib/legion/extensions/llm/ledger/actors/retention_purge.rb +47 -0
  6. data/lib/legion/extensions/llm/ledger/actors/skills.rb +34 -0
  7. data/lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb +8 -2
  8. data/lib/legion/extensions/llm/ledger/helpers/caller_identity.rb +16 -2
  9. data/lib/legion/extensions/llm/ledger/helpers/retention.rb +1 -1
  10. data/lib/legion/extensions/llm/ledger/runners/escalations.rb +115 -0
  11. data/lib/legion/extensions/llm/ledger/runners/metering.rb +14 -12
  12. data/lib/legion/extensions/llm/ledger/runners/prompts.rb +19 -4
  13. data/lib/legion/extensions/llm/ledger/runners/provider_stats.rb +1 -1
  14. data/lib/legion/extensions/llm/ledger/runners/reconciliation.rb +96 -0
  15. data/lib/legion/extensions/llm/ledger/runners/retention_purge.rb +62 -0
  16. data/lib/legion/extensions/llm/ledger/runners/skills.rb +99 -0
  17. data/lib/legion/extensions/llm/ledger/runners/tools.rb +73 -68
  18. data/lib/legion/extensions/llm/ledger/transport/exchanges/escalation.rb +23 -0
  19. data/lib/legion/extensions/llm/ledger/transport/queues/audit_escalations.rb +23 -0
  20. data/lib/legion/extensions/llm/ledger/transport/queues/audit_skills.rb +23 -0
  21. data/lib/legion/extensions/llm/ledger/transport/transport.rb +11 -0
  22. data/lib/legion/extensions/llm/ledger/version.rb +1 -1
  23. data/lib/legion/extensions/llm/ledger/writers/official_record_writer.rb +71 -7
  24. data/lib/legion/extensions/llm/ledger.rb +15 -1
  25. metadata +12 -1
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Ledger
9
+ module Runners
10
+ module RetentionPurge
11
+ extend self
12
+ extend Legion::Logging::Helper
13
+
14
+ PURGEABLE_TABLES = %i[
15
+ llm_conversations
16
+ ].freeze
17
+
18
+ BATCH_SIZE = 500
19
+
20
+ def purge_expired
21
+ db = ::Legion::Data.connection
22
+ total_deleted = 0
23
+
24
+ PURGEABLE_TABLES.each do |table|
25
+ next unless db.table_exists?(table)
26
+
27
+ deleted = purge_table(db, table)
28
+ total_deleted += deleted
29
+ log.info("[ledger] retention_purge: deleted #{deleted} expired rows from #{table}") if deleted.positive?
30
+ end
31
+
32
+ { result: :ok, deleted: total_deleted }
33
+ rescue StandardError => e
34
+ handle_exception(e, level: :error, handled: true, operation: 'retention_purge')
35
+ { result: :error, error: e.message }
36
+ end
37
+
38
+ private
39
+
40
+ def purge_table(db, table)
41
+ deleted = 0
42
+ loop do
43
+ ids = db[table]
44
+ .where { expires_at <= Time.now.utc }
45
+ .where(Sequel.~(expires_at: nil))
46
+ .select(:id)
47
+ .limit(BATCH_SIZE)
48
+ .select_map(:id)
49
+ break if ids.empty?
50
+
51
+ db[table].where(id: ids).delete
52
+ deleted += ids.size
53
+ break if ids.size < BATCH_SIZE
54
+ end
55
+ deleted
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'securerandom'
5
+ require_relative '../helpers/caller_identity'
6
+ require_relative '../helpers/json'
7
+ require_relative '../helpers/persistence_logging'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Llm
12
+ module Ledger
13
+ module Runners
14
+ module Skills
15
+ extend self
16
+
17
+ def write_skill_record(payload = nil, metadata = {}, **message)
18
+ payload, metadata = normalize_runner_args(payload, metadata, message)
19
+ headers = Helpers::SubscriptionMessage.extract_headers(payload, metadata)
20
+ props = metadata[:properties] || {}
21
+
22
+ body = payload.is_a?(Hash) ? payload : Helpers::Decryption.decrypt_if_needed(payload, metadata)
23
+
24
+ db = ::Legion::Data.connection
25
+ record = build_skill_record(db, body, props, headers)
26
+
27
+ Helpers::PersistenceLogging.insert_row(
28
+ db, :llm_skill_events, record,
29
+ operation: 'write_skill_record'
30
+ )
31
+ { result: :ok }
32
+ rescue Sequel::UniqueConstraintViolation => e
33
+ log.warn("write_skill_record duplicate insert ignored: #{e.message}")
34
+ { result: :duplicate }
35
+ rescue Helpers::DecryptionUnavailable => e
36
+ handle_exception(e, level: :warn, handled: true, operation: 'write_skill_record.decrypt')
37
+ raise
38
+ rescue Helpers::DecryptionFailed => e
39
+ handle_exception(e, level: :error, handled: true, operation: 'write_skill_record.decrypt')
40
+ raise
41
+ rescue StandardError => e
42
+ handle_exception(e, level: :error, handled: true, operation: 'write_skill_record')
43
+ { result: :error, error: e.message }
44
+ end
45
+
46
+ private
47
+
48
+ def normalize_runner_args(payload, metadata, message)
49
+ Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
50
+ end
51
+
52
+ def build_skill_record(db, body, props, headers)
53
+ identity = Helpers::CallerIdentity.normalize(
54
+ caller_raw: body[:caller], identity: body[:identity], headers: headers
55
+ )
56
+ skill = body[:skill] || {}
57
+
58
+ {
59
+ uuid: stable_uuid(props[:message_id] || body[:event_id] || SecureRandom.uuid),
60
+ conversation_id: resolve_conversation_id(db, body, headers),
61
+ request_ref: body[:request_id] || props[:correlation_id],
62
+ skill_name: skill[:name] || body[:skill_name],
63
+ skill_version: skill[:version],
64
+ trigger: skill[:trigger] || body[:trigger],
65
+ status: body[:status] || 'completed',
66
+ duration_ms: body[:duration_ms].to_i,
67
+ identity_canonical_name: identity[:identity],
68
+ identity_principal_id: identity[:principal_id],
69
+ identity_id: identity[:identity_id],
70
+ recorded_at: body[:recorded_at] || body[:timestamp] || Time.now.utc,
71
+ inserted_at: Time.now.utc
72
+ }
73
+ end
74
+
75
+ def resolve_conversation_id(db, body, headers)
76
+ conv_ref = body[:conversation_id] || headers['x-legion-llm-conversation-id']
77
+ return nil unless conv_ref # rubocop:disable Legion/Extension/RunnerReturnHash
78
+
79
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_ref)).first ||
80
+ db[:llm_conversations].where(uuid: conv_ref).first
81
+ conv&.[](:id)
82
+ end
83
+
84
+ def stable_uuid(value)
85
+ raw = value.to_s
86
+ return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
87
+
88
+ hex = Digest::SHA256.hexdigest(raw)[0, 32]
89
+ "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
90
+ end
91
+
92
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
93
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -10,8 +10,6 @@ module Legion
10
10
  module Extensions
11
11
  module Llm
12
12
  module Ledger
13
- class ResponseNotReady < StandardError; end
14
-
15
13
  module Runners
16
14
  module Tools
17
15
  extend self
@@ -25,17 +23,14 @@ module Legion
25
23
  ctx = body[:message_context] || {}
26
24
  tool = body[:tool_call] || {}
27
25
 
28
- Helpers::Retention.resolve(
29
- retention: headers['x-legion-retention'],
30
- contains_phi: headers['x-legion-contains-phi'] == 'true'
31
- )
32
-
33
26
  db = ::Legion::Data.connection
27
+ response = find_or_resolve_response_with_retry(db, body, ctx, props, headers)
34
28
  write_result = [:ok]
35
29
  db.transaction do
36
- response = find_or_resolve_response(db, body, ctx, props, headers)
37
30
  identity_attrs = extract_identity_attrs(body, headers, db)
38
- tool_call_row, new_tool_call = find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
31
+ conversation_id = resolve_conversation_id(db, body, ctx, headers)
32
+ tool_call_row, new_tool_call = find_or_create_tool_call(db, response, body, ctx, tool, headers,
33
+ identity_attrs, conversation_id)
39
34
  if tool_call_row && !new_tool_call
40
35
  write_result[0] = :duplicate
41
36
  elsif new_tool_call
@@ -53,9 +48,6 @@ module Legion
53
48
  rescue Helpers::DecryptionFailed => e
54
49
  handle_exception(e, level: :error, handled: true, operation: 'write_tool_record.decrypt')
55
50
  raise
56
- rescue ResponseNotReady => e
57
- log.warn("[ledger] write_tool_record: parent response not yet written, dead-lettering message (#{e.message})")
58
- raise unrecoverable_message_error(e)
59
51
  rescue StandardError => e
60
52
  handle_exception(e, level: :error, handled: true, operation: 'write_tool_record')
61
53
  { result: :error, error: e.message }
@@ -67,16 +59,26 @@ module Legion
67
59
  Helpers::SubscriptionMessage.runner_args(payload, metadata, message)
68
60
  end
69
61
 
70
- def unrecoverable_message_error(error)
71
- if defined?(Legion::Extensions::Actors::UnrecoverableMessageError)
72
- Legion::Extensions::Actors::UnrecoverableMessageError.new(error.message)
73
- else
74
- error
62
+ def find_or_resolve_response_with_retry(db, body, ctx, props, headers)
63
+ response = find_or_resolve_response(db, body, ctx, props, headers)
64
+ return response if response # rubocop:disable Legion/Extension/RunnerReturnHash
65
+
66
+ retry_attempts = tool_write_setting(:response_retry_attempts, 3)
67
+ retry_delay = tool_write_setting(:response_retry_delay, 1)
68
+
69
+ retry_attempts.times do |attempt|
70
+ sleep retry_delay
71
+ response = find_or_resolve_response(db, body, ctx, props, headers)
72
+ if response
73
+ log.debug("[ledger] write_tool_record: response found on retry #{attempt + 1}")
74
+ return response # rubocop:disable Legion/Extension/RunnerReturnHash
75
+ end
75
76
  end
77
+
78
+ log.info('[ledger] write_tool_record: response not available after retries, proceeding with null response_id')
79
+ nil
76
80
  end
77
81
 
78
- # Resolve the llm_message_inference_responses row this tool call belongs to.
79
- # Returns nil if we cannot link at all.
80
82
  def find_or_resolve_response(db, body, ctx, props, headers)
81
83
  request_ref = ctx[:request_id] || body[:request_id] ||
82
84
  props[:correlation_id] || headers['x-legion-llm-request-id']
@@ -89,31 +91,53 @@ module Legion
89
91
  .where(message_inference_request_id: request[:id]).first
90
92
  end
91
93
 
92
- def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs)
94
+ def resolve_conversation_id(db, body, ctx, headers)
95
+ conv_ref = ctx[:conversation_id] || body[:conversation_id] ||
96
+ headers['x-legion-llm-conversation-id']
97
+ return nil unless conv_ref # rubocop:disable Legion/Extension/RunnerReturnHash
98
+
99
+ conv = db[:llm_conversations].where(uuid: stable_uuid(conv_ref)).first ||
100
+ db[:llm_conversations].where(uuid: conv_ref).first
101
+ conv&.[](:id)
102
+ end
103
+
104
+ def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
93
105
  tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
94
106
  existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
95
107
  return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
96
108
 
97
- response_id = resolve_response_id(db, response, body, ctx, headers, tool_uuid)
98
- return [nil, false] unless response_id # rubocop:disable Legion/Extension/RunnerReturnHash
109
+ response_id = response&.[](:id)
99
110
 
100
- next_index = db[:llm_tool_calls]
101
- .where(message_inference_response_id: response_id)
102
- .max(:tool_call_index).to_i + 1
111
+ next_index = if response_id
112
+ db[:llm_tool_calls]
113
+ .where(message_inference_response_id: response_id)
114
+ .max(:tool_call_index).to_i + 1
115
+ else
116
+ 0
117
+ end
103
118
 
104
119
  src = tool[:source] || {}
105
120
  status = tool[:status] || headers['x-legion-tool-status'] || 'success'
106
121
  ts = body[:timestamps] || {}
107
122
 
123
+ result_value = tool[:result] || body[:result]
124
+ has_result = tool[:result] || body.key?(:result)
108
125
  id = insert_with_savepoint(db, :llm_tool_calls, {
109
126
  uuid: tool_uuid,
110
127
  message_inference_response_id: response_id,
128
+ conversation_id: conversation_id,
111
129
  tool_call_index: next_index,
112
130
  provider_tool_call_ref: tool[:id],
113
131
  tool_name: tool[:name] || headers['x-legion-tool-name'],
114
132
  tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
115
133
  tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
116
134
  status: status,
135
+ tool_arguments_json: tool[:arguments] ? Helpers::Json.dump(tool[:arguments]) : nil,
136
+ tool_result_json: has_result ? Helpers::Json.dump(result_value) : nil,
137
+ tool_category: tool[:category] || tool[:tool_category],
138
+ data_handling_classification: tool[:data_handling_classification],
139
+ policy_decision: tool[:policy_decision],
140
+ requires_human_approval: tool[:requires_human_approval],
117
141
  requested_at: ts[:tool_start] || tool[:started_at],
118
142
  completed_at: ts[:tool_end] || tool[:finished_at],
119
143
  **identity_attrs,
@@ -128,35 +152,7 @@ module Legion
128
152
  [row, false]
129
153
  end
130
154
 
131
- # Extract or fall back to find a response_id for linking the tool call.
132
- def resolve_response_id(db, response, body, ctx, headers, tool_uuid)
133
- return response[:id] if response # rubocop:disable Legion/Extension/RunnerReturnHash
134
-
135
- fallback = fallback_response_for_conversation(db, body, ctx, headers)
136
- return fallback[:id] if fallback # rubocop:disable Legion/Extension/RunnerReturnHash
137
-
138
- raise ResponseNotReady, "no response row found for tool call uuid=#{tool_uuid}"
139
- end
140
-
141
- def fallback_response_for_conversation(db, body, ctx, headers)
142
- conv_id = ctx[:conversation_id] || body[:conversation_id] ||
143
- headers['x-legion-llm-conversation-id']
144
- return nil unless conv_id # rubocop:disable Legion/Extension/RunnerReturnHash
145
-
146
- conv = db[:llm_conversations].where(uuid: stable_uuid(conv_id)).first ||
147
- db[:llm_conversations].where(uuid: conv_id).first
148
- return nil unless conv # rubocop:disable Legion/Extension/RunnerReturnHash
149
-
150
- db[:llm_message_inference_responses]
151
- .join(:llm_message_inference_requests,
152
- id: :message_inference_request_id)
153
- .where(Sequel[:llm_message_inference_requests][:conversation_id] => conv[:id])
154
- .order(Sequel.desc(Sequel[:llm_message_inference_responses][:id]))
155
- .select_all(:llm_message_inference_responses)
156
- .first
157
- end
158
-
159
- def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity
155
+ def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
160
156
  return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
161
157
 
162
158
  tool_call_id = tool_call_row[:id]
@@ -174,21 +170,24 @@ module Legion
174
170
  runner_ref = body[:worker_id] || body[:runner_ref] || props[:app_id]
175
171
 
176
172
  id = insert_with_savepoint(db, :llm_tool_call_attempts, {
177
- uuid: attempt_uuid,
178
- tool_call_id: tool_call_id,
179
- attempt_no: attempt_no,
180
- runner_ref: runner_ref,
181
- status: status,
182
- error_category: error_hash[:category] || error_hash[:type],
183
- error_code: error_hash[:code],
184
- error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
185
- duration_ms: tool[:duration_ms].to_i,
186
- arguments_ref: sha256_ref(tool[:arguments]),
187
- result_ref: sha256_ref(tool[:result] || body[:result]),
188
- started_at: ts[:tool_start] || tool[:started_at],
189
- ended_at: ts[:tool_end] || tool[:finished_at],
173
+ uuid: attempt_uuid,
174
+ tool_call_id: tool_call_id,
175
+ attempt_no: attempt_no,
176
+ runner_ref: runner_ref,
177
+ status: status,
178
+ error_category: error_hash[:category] || error_hash[:type],
179
+ error_code: error_hash[:code],
180
+ error_message: error_info.is_a?(String) ? error_info : error_hash[:message],
181
+ duration_ms: tool[:duration_ms].to_i,
182
+ arguments_ref: sha256_ref(tool[:arguments]),
183
+ result_ref: sha256_ref(tool[:result] || body[:result]),
184
+ attempt_input_json: tool[:arguments] ? Helpers::Json.dump(tool[:arguments]) : nil,
185
+ attempt_output_json: Helpers::Json.dump(tool[:result] || body[:result]),
186
+ error_details_json: tool[:error] ? Helpers::Json.dump(tool[:error]) : nil,
187
+ started_at: ts[:tool_start] || tool[:started_at],
188
+ ended_at: ts[:tool_end] || tool[:finished_at],
190
189
  **identity_attrs,
191
- inserted_at: Time.now.utc
190
+ inserted_at: Time.now.utc
192
191
  }, operation: 'write_tool_record.attempt')
193
192
  db[:llm_tool_call_attempts][id: id]
194
193
  rescue Sequel::UniqueConstraintViolation => e
@@ -265,6 +264,12 @@ module Legion
265
264
  "#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
266
265
  end
267
266
 
267
+ def tool_write_setting(key, default)
268
+ ledger = Legion::Settings.dig(:extensions, :llm, :ledger) || {}
269
+ tool_write = ledger[:tool_write] || {}
270
+ (tool_write[key] || default).to_i
271
+ end
272
+
268
273
  def insert_with_savepoint(db, table, attributes, operation:)
269
274
  db.transaction(savepoint: true) do
270
275
  Helpers::PersistenceLogging.insert_row(db, table, attributes,
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Exchanges
9
+ class Escalation < ::Legion::Transport::Exchange
10
+ def exchange_name
11
+ 'llm.escalation'
12
+ end
13
+
14
+ def default_type
15
+ 'topic'
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Queues
9
+ class AuditEscalations < Legion::Transport::Queue
10
+ def queue_name
11
+ 'llm.audit.escalations'
12
+ end
13
+
14
+ def queue_options
15
+ { durable: true }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Ledger
7
+ module Transport
8
+ module Queues
9
+ class AuditSkills < Legion::Transport::Queue
10
+ def queue_name
11
+ 'llm.audit.skills'
12
+ end
13
+
14
+ def queue_options
15
+ { durable: true }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -3,6 +3,7 @@
3
3
  require 'legion/extensions/transport'
4
4
  require_relative 'exchanges/metering'
5
5
  require_relative 'exchanges/audit'
6
+ require_relative 'exchanges/escalation'
6
7
  require_relative 'exchanges/registry'
7
8
 
8
9
  module Legion
@@ -29,6 +30,16 @@ module Legion
29
30
  to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditTools,
30
31
  routing_key: 'audit.tool.#'
31
32
  },
33
+ {
34
+ from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Audit,
35
+ to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditSkills,
36
+ routing_key: 'audit.skill.#'
37
+ },
38
+ {
39
+ from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Escalation,
40
+ to: Legion::Extensions::Llm::Ledger::Transport::Queues::AuditEscalations,
41
+ routing_key: '#'
42
+ },
32
43
  {
33
44
  from: Legion::Extensions::Llm::Ledger::Transport::Exchanges::Registry,
34
45
  to: Legion::Extensions::Llm::Ledger::Transport::Queues::RegistryAvailability,
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Ledger
7
- VERSION = '0.4.2'
7
+ VERSION = '0.6.0'
8
8
  end
9
9
  end
10
10
  end
@@ -63,6 +63,8 @@ module Legion
63
63
  classification_level: classification_level(body),
64
64
  contains_phi: contains_phi?(body),
65
65
  contains_pii: contains_pii?(body),
66
+ pii_types_json: json_dump(Array(body.dig(:classification, :pii_types))),
67
+ jurisdictions_json: json_dump(Array(body.dig(:classification, :jurisdictions) || body[:jurisdictions])),
66
68
  retention_policy: body[:retention_policy] || 'default',
67
69
  expires_at: body[:expires_at],
68
70
  identity_canonical_name: identity_canonical_name(body),
@@ -110,7 +112,7 @@ module Legion
110
112
  end
111
113
  end
112
114
 
113
- def find_or_create_request(db, conversation, latest_message, body)
115
+ def find_or_create_request(db, conversation, latest_message, body) # rubocop:disable Metrics/AbcSize
114
116
  request_id = request_ref(body)
115
117
  existing = db[:llm_message_inference_requests].where(request_ref: request_id).first
116
118
  return enrich_request!(db, existing, body, latest_message) if existing
@@ -121,6 +123,7 @@ module Legion
121
123
  uuid: stable_uuid(request_id),
122
124
  conversation_id: conversation[:id],
123
125
  latest_message_id: latest_message&.dig(:id),
126
+ parent_request_id: resolve_parent_request_id(db, body),
124
127
  caller_principal_id: caller_refs[:principal_id],
125
128
  caller_identity_id: caller_refs[:identity_id],
126
129
  identity_canonical_name: identity_canonical_name(body),
@@ -137,10 +140,15 @@ module Legion
137
140
  status: 'responded',
138
141
  context_message_count: Array(body.dig(:request, :messages) || body[:messages]).size,
139
142
  request_capture_mode: 'full',
140
- request_json: json_dump(request_payload(body)),
143
+ request_json: phi_protect(json_dump(request_payload(body)), contains_phi?(body)),
141
144
  classification_level: classification_level(body),
142
145
  cost_center: billing(body)[:cost_center],
143
146
  budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
147
+ injected_tool_count: Array(body.dig(:audit, :injected_tools) || body[:injected_tools]).size,
148
+ context_tokens: resolve_context_tokens(body),
149
+ request_content_hash: compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content)),
150
+ curation_strategy: body[:curation_strategy] || body.dig(:audit, :curation_strategy),
151
+ tool_policy: body[:tool_policy] || body.dig(:audit, :tool_policy),
144
152
  requested_at: recorded_at(body),
145
153
  inserted_at: Time.now.utc
146
154
  }, operation: 'official_record_writer.inference_request')
@@ -186,14 +194,15 @@ module Legion
186
194
  end
187
195
  end
188
196
 
189
- def find_or_create_response(db, request, response_message, body)
190
- response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}")
197
+ def find_or_create_response(db, request, response_message, body) # rubocop:disable Metrics/AbcSize
198
+ response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
191
199
  existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
192
200
  if existing
193
201
  enrich_response!(db, existing, response_message, body)
194
202
  return existing
195
203
  end
196
204
 
205
+ phi = contains_phi?(body)
197
206
  id = insert_with_savepoint(db, :llm_message_inference_responses, {
198
207
  uuid: response_uuid,
199
208
  message_inference_request_id: request[:id],
@@ -209,9 +218,15 @@ module Legion
209
218
  latency_ms: integer(body[:latency_ms]),
210
219
  wall_clock_ms: integer(body[:wall_clock_ms]),
211
220
  response_capture_mode: 'full',
212
- response_json: json_dump(visible_response(body)),
213
- response_thinking_json: json_dump(thinking_response(body)),
221
+ response_json: phi_protect(json_dump(visible_response(body)), phi),
222
+ response_thinking_json: phi_protect(json_dump(thinking_response(body)), phi),
214
223
  dispatch_path: body[:dispatch_path] || body[:tier],
224
+ error_category: body[:error_category] || body.dig(:error, :category),
225
+ error_code: body[:error_code] || body.dig(:error, :code),
226
+ error_message: body[:error_message] || body.dig(:error, :message),
227
+ response_content_hash: compute_content_hash(body[:response_content] || body.dig(:audit, :response_content)),
228
+ route_attempts: (body[:route_attempts] || body.dig(:audit, :route_attempts)).to_i,
229
+ escalation_chain_ref: body[:escalation_chain_ref],
215
230
  identity_principal_id: caller_identity_refs(db, body)[:principal_id],
216
231
  identity_id: caller_identity_refs(db, body)[:identity_id],
217
232
  identity_canonical_name: identity_canonical_name(body),
@@ -377,6 +392,10 @@ module Legion
377
392
  explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
378
393
  explicit_principal_id = integer_or_nil(body[:caller_principal_id] ||
379
394
  body.dig(:caller, :requested_by, :principal_id))
395
+
396
+ explicit_identity_id ||= integer_or_nil(body[:__header_identity_id])
397
+ explicit_principal_id ||= integer_or_nil(body[:__header_principal_id])
398
+
380
399
  refs = { principal_id: explicit_principal_id, identity_id: explicit_identity_id }.compact
381
400
  unless refs[:principal_id] && refs[:identity_id]
382
401
  if explicit_identity_id && !explicit_principal_id && identity_tables_available?(db)
@@ -554,6 +573,18 @@ module Legion
554
573
  int.positive? ? int : nil
555
574
  end
556
575
 
576
+ def resolve_parent_request_id(db, body)
577
+ parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id)
578
+ return nil unless present?(parent_ref)
579
+
580
+ if parent_ref.is_a?(Integer)
581
+ parent_ref
582
+ else
583
+ parent = db[:llm_message_inference_requests].where(request_ref: parent_ref.to_s).first
584
+ parent&.dig(:id)
585
+ end
586
+ end
587
+
557
588
  def correlation_id(body)
558
589
  reference(body, :correlation_id, :correlation_ref) || body.dig(:tracing, :correlation_id)
559
590
  end
@@ -656,8 +687,14 @@ module Legion
656
687
  body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
657
688
  end
658
689
 
690
+ ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
691
+
659
692
  def classification_level(body)
660
- body[:classification_level] || body.dig(:classification, :level)
693
+ raw = body[:classification_level] || body.dig(:classification, :level)
694
+ return 'internal' if raw.nil? || raw.to_s.empty?
695
+
696
+ normalized = raw.to_s.downcase
697
+ ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
661
698
  end
662
699
 
663
700
  def contains_phi?(body)
@@ -706,6 +743,19 @@ module Legion
706
743
  json_dump(content)
707
744
  end
708
745
 
746
+ def phi_protect(json_string, is_phi)
747
+ return json_string unless is_phi && crypt_available?
748
+
749
+ Legion::Crypt.encrypt(json_string)
750
+ rescue StandardError => e
751
+ handle_exception(e, level: :warn, handled: true, operation: 'official_record_writer.phi_encrypt')
752
+ json_string
753
+ end
754
+
755
+ def crypt_available?
756
+ defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:encrypt)
757
+ end
758
+
709
759
  def json_dump(value)
710
760
  Helpers::Json.dump(value)
711
761
  end
@@ -728,6 +778,20 @@ module Legion
728
778
  def present?(value)
729
779
  !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
730
780
  end
781
+
782
+ def compute_content_hash(content)
783
+ return nil if content.nil? || content.to_s.empty?
784
+
785
+ Digest::SHA256.hexdigest(json_dump(content))[0..31]
786
+ end
787
+
788
+ def resolve_context_tokens(body)
789
+ raw = body[:tokens] || body[:audit] || body
790
+ val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
791
+ return nil unless present?(val)
792
+
793
+ val.to_i
794
+ end
731
795
  end
732
796
  end
733
797
  end