lex-llm-ledger 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/lib/legion/extensions/llm/ledger/actors/escalations.rb +34 -0
- data/lib/legion/extensions/llm/ledger/actors/reconciliation.rb +48 -0
- data/lib/legion/extensions/llm/ledger/actors/retention_purge.rb +47 -0
- data/lib/legion/extensions/llm/ledger/actors/skills.rb +34 -0
- data/lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb +8 -2
- data/lib/legion/extensions/llm/ledger/helpers/caller_identity.rb +16 -2
- data/lib/legion/extensions/llm/ledger/helpers/retention.rb +1 -1
- data/lib/legion/extensions/llm/ledger/runners/escalations.rb +115 -0
- data/lib/legion/extensions/llm/ledger/runners/metering.rb +14 -12
- data/lib/legion/extensions/llm/ledger/runners/prompts.rb +19 -4
- data/lib/legion/extensions/llm/ledger/runners/provider_stats.rb +1 -1
- data/lib/legion/extensions/llm/ledger/runners/reconciliation.rb +104 -0
- data/lib/legion/extensions/llm/ledger/runners/retention_purge.rb +62 -0
- data/lib/legion/extensions/llm/ledger/runners/skills.rb +99 -0
- data/lib/legion/extensions/llm/ledger/runners/tools.rb +26 -20
- data/lib/legion/extensions/llm/ledger/transport/exchanges/escalation.rb +23 -0
- data/lib/legion/extensions/llm/ledger/transport/queues/audit_escalations.rb +23 -0
- data/lib/legion/extensions/llm/ledger/transport/queues/audit_skills.rb +23 -0
- data/lib/legion/extensions/llm/ledger/transport/transport.rb +11 -0
- data/lib/legion/extensions/llm/ledger/version.rb +1 -1
- data/lib/legion/extensions/llm/ledger/writers/official_record_writer.rb +134 -21
- data/lib/legion/extensions/llm/ledger.rb +10 -0
- 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
|
|
@@ -23,11 +23,6 @@ module Legion
|
|
|
23
23
|
ctx = body[:message_context] || {}
|
|
24
24
|
tool = body[:tool_call] || {}
|
|
25
25
|
|
|
26
|
-
Helpers::Retention.resolve(
|
|
27
|
-
retention: headers['x-legion-retention'],
|
|
28
|
-
contains_phi: headers['x-legion-contains-phi'] == 'true'
|
|
29
|
-
)
|
|
30
|
-
|
|
31
26
|
db = ::Legion::Data.connection
|
|
32
27
|
response = find_or_resolve_response_with_retry(db, body, ctx, props, headers)
|
|
33
28
|
write_result = [:ok]
|
|
@@ -125,6 +120,8 @@ module Legion
|
|
|
125
120
|
status = tool[:status] || headers['x-legion-tool-status'] || 'success'
|
|
126
121
|
ts = body[:timestamps] || {}
|
|
127
122
|
|
|
123
|
+
result_value = tool[:result] || body[:result]
|
|
124
|
+
has_result = tool[:result] || body.key?(:result)
|
|
128
125
|
id = insert_with_savepoint(db, :llm_tool_calls, {
|
|
129
126
|
uuid: tool_uuid,
|
|
130
127
|
message_inference_response_id: response_id,
|
|
@@ -135,6 +132,12 @@ module Legion
|
|
|
135
132
|
tool_source_type: src[:type] || headers['x-legion-tool-source-type'],
|
|
136
133
|
tool_source_server: src[:server] || headers['x-legion-tool-source-server'],
|
|
137
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],
|
|
138
141
|
requested_at: ts[:tool_start] || tool[:started_at],
|
|
139
142
|
completed_at: ts[:tool_end] || tool[:finished_at],
|
|
140
143
|
**identity_attrs,
|
|
@@ -149,7 +152,7 @@ module Legion
|
|
|
149
152
|
[row, false]
|
|
150
153
|
end
|
|
151
154
|
|
|
152
|
-
def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
|
|
155
|
+
def find_or_create_tool_call_attempt(db, tool_call_row, tool, body, props, headers, identity_attrs)
|
|
153
156
|
return nil unless tool_call_row # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
154
157
|
|
|
155
158
|
tool_call_id = tool_call_row[:id]
|
|
@@ -167,21 +170,24 @@ module Legion
|
|
|
167
170
|
runner_ref = body[:worker_id] || body[:runner_ref] || props[:app_id]
|
|
168
171
|
|
|
169
172
|
id = insert_with_savepoint(db, :llm_tool_call_attempts, {
|
|
170
|
-
uuid:
|
|
171
|
-
tool_call_id:
|
|
172
|
-
attempt_no:
|
|
173
|
-
runner_ref:
|
|
174
|
-
status:
|
|
175
|
-
error_category:
|
|
176
|
-
error_code:
|
|
177
|
-
error_message:
|
|
178
|
-
duration_ms:
|
|
179
|
-
arguments_ref:
|
|
180
|
-
result_ref:
|
|
181
|
-
|
|
182
|
-
|
|
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],
|
|
183
189
|
**identity_attrs,
|
|
184
|
-
inserted_at:
|
|
190
|
+
inserted_at: Time.now.utc
|
|
185
191
|
}, operation: 'write_tool_record.attempt')
|
|
186
192
|
db[:llm_tool_call_attempts][id: id]
|
|
187
193
|
rescue Sequel::UniqueConstraintViolation => e
|
|
@@ -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,
|
|
@@ -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),
|
|
@@ -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,18 @@ 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:
|
|
143
|
+
request_json: if request_payload(body)
|
|
144
|
+
phi_protect(json_dump(request_payload(body)),
|
|
145
|
+
contains_phi?(body))
|
|
146
|
+
end,
|
|
141
147
|
classification_level: classification_level(body),
|
|
142
148
|
cost_center: billing(body)[:cost_center],
|
|
143
149
|
budget_key: billing(body)[:budget_id] || billing(body)[:budget_key],
|
|
150
|
+
injected_tool_count: Array(body.dig(:audit, :injected_tools) || body[:injected_tools]).size,
|
|
151
|
+
context_tokens: resolve_context_tokens(body),
|
|
152
|
+
request_content_hash: compute_content_hash(body.dig(:request, :content) || body.dig(:audit, :request_content)),
|
|
153
|
+
curation_strategy: body[:curation_strategy] || body.dig(:audit, :curation_strategy),
|
|
154
|
+
tool_policy: body[:tool_policy] || body.dig(:audit, :tool_policy),
|
|
144
155
|
requested_at: recorded_at(body),
|
|
145
156
|
inserted_at: Time.now.utc
|
|
146
157
|
}, operation: 'official_record_writer.inference_request')
|
|
@@ -187,13 +198,28 @@ module Legion
|
|
|
187
198
|
end
|
|
188
199
|
|
|
189
200
|
def find_or_create_response(db, request, response_message, body)
|
|
190
|
-
response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}")
|
|
201
|
+
response_uuid = stable_uuid(reference(body, :provider_response_ref) || "response:#{request_ref(body)}:#{body[:provider] || 'unknown'}")
|
|
191
202
|
existing = db[:llm_message_inference_responses].where(uuid: response_uuid).first
|
|
203
|
+
|
|
204
|
+
# Fallback: if we couldn't find a response by UUID, check if a response
|
|
205
|
+
# already exists for this request (e.g., metering arrived first and created
|
|
206
|
+
# a response with a different UUID). Enrich it instead of creating a duplicate.
|
|
207
|
+
unless existing
|
|
208
|
+
existing = db[:llm_message_inference_responses]
|
|
209
|
+
.where(message_inference_request_id: request[:id])
|
|
210
|
+
.first
|
|
211
|
+
log.debug("[ledger] response fallback: found existing response id=#{existing[:id]} for request_id=#{request[:id]}") if existing
|
|
212
|
+
end
|
|
213
|
+
|
|
192
214
|
if existing
|
|
193
215
|
enrich_response!(db, existing, response_message, body)
|
|
194
216
|
return existing
|
|
195
217
|
end
|
|
196
218
|
|
|
219
|
+
vis_resp = visible_response(body)
|
|
220
|
+
think_resp = thinking_response(body)
|
|
221
|
+
phi = contains_phi?(body)
|
|
222
|
+
|
|
197
223
|
id = insert_with_savepoint(db, :llm_message_inference_responses, {
|
|
198
224
|
uuid: response_uuid,
|
|
199
225
|
message_inference_request_id: request[:id],
|
|
@@ -209,9 +235,15 @@ module Legion
|
|
|
209
235
|
latency_ms: integer(body[:latency_ms]),
|
|
210
236
|
wall_clock_ms: integer(body[:wall_clock_ms]),
|
|
211
237
|
response_capture_mode: 'full',
|
|
212
|
-
response_json: json_dump(
|
|
213
|
-
response_thinking_json: json_dump(
|
|
238
|
+
response_json: vis_resp ? phi_protect(json_dump(vis_resp), phi) : nil,
|
|
239
|
+
response_thinking_json: think_resp ? phi_protect(json_dump(think_resp), phi) : nil,
|
|
214
240
|
dispatch_path: body[:dispatch_path] || body[:tier],
|
|
241
|
+
error_category: body[:error_category] || body.dig(:error, :category),
|
|
242
|
+
error_code: body[:error_code] || body.dig(:error, :code),
|
|
243
|
+
error_message: body[:error_message] || body.dig(:error, :message),
|
|
244
|
+
response_content_hash: compute_content_hash(body[:response_content] || body.dig(:audit, :response_content)),
|
|
245
|
+
route_attempts: (body[:route_attempts] || body.dig(:audit, :route_attempts)).to_i,
|
|
246
|
+
escalation_chain_ref: body[:escalation_chain_ref],
|
|
215
247
|
identity_principal_id: caller_identity_refs(db, body)[:principal_id],
|
|
216
248
|
identity_id: caller_identity_refs(db, body)[:identity_id],
|
|
217
249
|
identity_canonical_name: identity_canonical_name(body),
|
|
@@ -239,11 +271,21 @@ module Legion
|
|
|
239
271
|
update_if_missing(updates, existing, :dispatch_path, body[:dispatch_path] || body[:tier])
|
|
240
272
|
update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
|
|
241
273
|
|
|
242
|
-
|
|
243
|
-
|
|
274
|
+
vis = visible_response(body)
|
|
275
|
+
if vis
|
|
276
|
+
response_json = json_dump(vis)
|
|
277
|
+
update_if_placeholder(updates, existing, :response_json, response_json)
|
|
278
|
+
elsif existing[:response_json].nil?
|
|
279
|
+
# Nothing to add, but also don't overwrite existing data with nil
|
|
280
|
+
end
|
|
244
281
|
|
|
245
|
-
|
|
246
|
-
|
|
282
|
+
think = thinking_response(body)
|
|
283
|
+
if think
|
|
284
|
+
thinking_json = json_dump(think)
|
|
285
|
+
update_if_placeholder(updates, existing, :response_thinking_json, thinking_json)
|
|
286
|
+
elsif existing[:response_thinking_json].nil?
|
|
287
|
+
# Nothing to add
|
|
288
|
+
end
|
|
247
289
|
|
|
248
290
|
return if updates.empty?
|
|
249
291
|
|
|
@@ -251,12 +293,27 @@ module Legion
|
|
|
251
293
|
log.info("[ledger] enriched response id=#{existing[:id]} fields=#{updates.keys.join(',')}")
|
|
252
294
|
end
|
|
253
295
|
|
|
296
|
+
# Core guard: never upsert a value that is nil, empty string, or empty JSON object.
|
|
297
|
+
# This prevents a leaner message (e.g., metering) from overwriting valid data
|
|
298
|
+
# written by a richer message (e.g., prompt audit).
|
|
299
|
+
def upsert_guard?(value)
|
|
300
|
+
return false if value.nil?
|
|
301
|
+
return false if value.is_a?(String) && value.strip.empty?
|
|
302
|
+
return false if value.to_s == '{}'
|
|
303
|
+
|
|
304
|
+
true
|
|
305
|
+
end
|
|
306
|
+
|
|
254
307
|
def update_if_missing(updates, existing, key, value)
|
|
255
|
-
updates[key] = value if existing[key].nil? &&
|
|
308
|
+
updates[key] = value if existing[key].nil? && upsert_guard?(value)
|
|
256
309
|
end
|
|
257
310
|
|
|
258
311
|
def update_if_placeholder(updates, existing, key, value)
|
|
259
|
-
|
|
312
|
+
return unless upsert_guard?(value)
|
|
313
|
+
|
|
314
|
+
existing_val = existing[key]
|
|
315
|
+
is_placeholder = ['{}', 'null'].include?(existing_val.to_s)
|
|
316
|
+
updates[key] = value if is_placeholder
|
|
260
317
|
end
|
|
261
318
|
|
|
262
319
|
def find_or_create_metric(db, request, response, body)
|
|
@@ -324,15 +381,19 @@ module Legion
|
|
|
324
381
|
updates = {}
|
|
325
382
|
update_if_missing(updates, existing, :latest_message_id, latest_message&.dig(:id))
|
|
326
383
|
caller_refs = caller_identity_refs(db, body)
|
|
327
|
-
updates
|
|
328
|
-
updates
|
|
329
|
-
updates
|
|
384
|
+
update_if_missing(updates, existing, :caller_identity_id, caller_refs[:identity_id])
|
|
385
|
+
update_if_missing(updates, existing, :caller_principal_id, caller_refs[:principal_id])
|
|
386
|
+
update_if_missing(updates, existing, :runtime_caller_type, caller_type(body))
|
|
330
387
|
update_if_missing(updates, existing, :runtime_caller_class, runtime_caller_class(body))
|
|
331
388
|
update_if_missing(updates, existing, :runtime_caller_client, runtime_caller_client(body))
|
|
332
389
|
update_if_missing(updates, existing, :identity_canonical_name, identity_canonical_name(body))
|
|
333
390
|
|
|
334
|
-
request_json = json_dump(request_payload(body))
|
|
335
|
-
|
|
391
|
+
request_json = request_payload(body) ? json_dump(request_payload(body)) : nil
|
|
392
|
+
if request_json
|
|
393
|
+
update_if_placeholder(updates, existing, :request_json, request_json)
|
|
394
|
+
elsif existing[:request_json].nil?
|
|
395
|
+
# Nothing to add
|
|
396
|
+
end
|
|
336
397
|
|
|
337
398
|
msg_count = Array(body.dig(:request, :messages) || body[:messages]).size
|
|
338
399
|
updates[:context_message_count] = msg_count if existing[:context_message_count].to_i.zero? && msg_count.positive?
|
|
@@ -377,6 +438,10 @@ module Legion
|
|
|
377
438
|
explicit_identity_id = integer_or_nil(body[:caller_identity_id] || body.dig(:caller, :requested_by, :id))
|
|
378
439
|
explicit_principal_id = integer_or_nil(body[:caller_principal_id] ||
|
|
379
440
|
body.dig(:caller, :requested_by, :principal_id))
|
|
441
|
+
|
|
442
|
+
explicit_identity_id ||= integer_or_nil(body[:__header_identity_id])
|
|
443
|
+
explicit_principal_id ||= integer_or_nil(body[:__header_principal_id])
|
|
444
|
+
|
|
380
445
|
refs = { principal_id: explicit_principal_id, identity_id: explicit_identity_id }.compact
|
|
381
446
|
unless refs[:principal_id] && refs[:identity_id]
|
|
382
447
|
if explicit_identity_id && !explicit_principal_id && identity_tables_available?(db)
|
|
@@ -554,6 +619,18 @@ module Legion
|
|
|
554
619
|
int.positive? ? int : nil
|
|
555
620
|
end
|
|
556
621
|
|
|
622
|
+
def resolve_parent_request_id(db, body)
|
|
623
|
+
parent_ref = body[:parent_request_id] || body.dig(:context, :parent_request_id) || body.dig(:caller, :parent_request_ref)
|
|
624
|
+
return nil unless present?(parent_ref)
|
|
625
|
+
|
|
626
|
+
if parent_ref.is_a?(Integer)
|
|
627
|
+
parent_ref
|
|
628
|
+
else
|
|
629
|
+
parent = db[:llm_message_inference_requests].where(request_ref: parent_ref.to_s).first
|
|
630
|
+
parent&.dig(:id)
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
557
634
|
def correlation_id(body)
|
|
558
635
|
reference(body, :correlation_id, :correlation_ref) || body.dig(:tracing, :correlation_id)
|
|
559
636
|
end
|
|
@@ -599,7 +676,7 @@ module Legion
|
|
|
599
676
|
end
|
|
600
677
|
|
|
601
678
|
def request_payload(body)
|
|
602
|
-
body[:request] || body[:messages]
|
|
679
|
+
body[:request] || body[:messages]
|
|
603
680
|
end
|
|
604
681
|
|
|
605
682
|
def request_content(body)
|
|
@@ -610,7 +687,9 @@ module Legion
|
|
|
610
687
|
end
|
|
611
688
|
|
|
612
689
|
def visible_response(body)
|
|
613
|
-
response = body[:response] || body[:response_content] || body[:content]
|
|
690
|
+
response = body[:response] || body[:response_content] || body[:content]
|
|
691
|
+
return nil if response.nil? || (response.is_a?(Hash) && response.empty?)
|
|
692
|
+
|
|
614
693
|
if response.is_a?(String)
|
|
615
694
|
clean, _thinking = extract_inline_thinking(response)
|
|
616
695
|
return { content: clean }
|
|
@@ -630,10 +709,10 @@ module Legion
|
|
|
630
709
|
end
|
|
631
710
|
|
|
632
711
|
content_str = body[:response_content] || body[:response] || body[:content]
|
|
633
|
-
return
|
|
712
|
+
return nil unless content_str.is_a?(String)
|
|
634
713
|
|
|
635
714
|
_clean, extracted = extract_inline_thinking(content_str)
|
|
636
|
-
extracted ? { content: extracted } :
|
|
715
|
+
extracted ? { content: extracted } : nil
|
|
637
716
|
end
|
|
638
717
|
|
|
639
718
|
def extract_inline_thinking(text)
|
|
@@ -646,7 +725,10 @@ module Legion
|
|
|
646
725
|
end
|
|
647
726
|
|
|
648
727
|
def response_content(body)
|
|
649
|
-
|
|
728
|
+
vis = visible_response(body)
|
|
729
|
+
return nil unless vis
|
|
730
|
+
|
|
731
|
+
stringify_content(vis[:content] || vis.dig(:message, :content))
|
|
650
732
|
end
|
|
651
733
|
|
|
652
734
|
def finish_reason(body)
|
|
@@ -656,8 +738,14 @@ module Legion
|
|
|
656
738
|
body.dig(:response, :finish_reason) || body.dig(:response, :stop, :reason)
|
|
657
739
|
end
|
|
658
740
|
|
|
741
|
+
ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
|
|
742
|
+
|
|
659
743
|
def classification_level(body)
|
|
660
|
-
body[:classification_level] || body.dig(:classification, :level)
|
|
744
|
+
raw = body[:classification_level] || body.dig(:classification, :level)
|
|
745
|
+
return 'internal' if raw.nil? || raw.to_s.empty?
|
|
746
|
+
|
|
747
|
+
normalized = raw.to_s.downcase
|
|
748
|
+
ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
|
|
661
749
|
end
|
|
662
750
|
|
|
663
751
|
def contains_phi?(body)
|
|
@@ -706,6 +794,19 @@ module Legion
|
|
|
706
794
|
json_dump(content)
|
|
707
795
|
end
|
|
708
796
|
|
|
797
|
+
def phi_protect(json_string, is_phi)
|
|
798
|
+
return json_string unless is_phi && crypt_available?
|
|
799
|
+
|
|
800
|
+
Legion::Crypt.encrypt(json_string)
|
|
801
|
+
rescue StandardError => e
|
|
802
|
+
handle_exception(e, level: :warn, handled: true, operation: 'official_record_writer.phi_encrypt')
|
|
803
|
+
json_string
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def crypt_available?
|
|
807
|
+
defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:encrypt)
|
|
808
|
+
end
|
|
809
|
+
|
|
709
810
|
def json_dump(value)
|
|
710
811
|
Helpers::Json.dump(value)
|
|
711
812
|
end
|
|
@@ -728,6 +829,18 @@ module Legion
|
|
|
728
829
|
def present?(value)
|
|
729
830
|
!value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
730
831
|
end
|
|
832
|
+
|
|
833
|
+
def compute_content_hash(content)
|
|
834
|
+
return nil if content.nil? || content.to_s.empty?
|
|
835
|
+
|
|
836
|
+
Digest::SHA256.hexdigest(json_dump(content))[0..31]
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
def resolve_context_tokens(body)
|
|
840
|
+
raw = body[:tokens] || body[:audit] || body
|
|
841
|
+
val = raw[:input_tokens] || raw[:input] || raw[:context_tokens] || raw[:prompt_tokens]
|
|
842
|
+
present?(val) ? val.to_i : 0
|
|
843
|
+
end
|
|
731
844
|
end
|
|
732
845
|
end
|
|
733
846
|
end
|