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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -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 +96 -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 +73 -68
- 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 +71 -7
- data/lib/legion/extensions/llm/ledger.rb +15 -1
- 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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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 =
|
|
98
|
-
return [nil, false] unless response_id # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
109
|
+
response_id = response&.[](:id)
|
|
99
110
|
|
|
100
|
-
next_index =
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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:
|
|
178
|
-
tool_call_id:
|
|
179
|
-
attempt_no:
|
|
180
|
-
runner_ref:
|
|
181
|
-
status:
|
|
182
|
-
error_category:
|
|
183
|
-
error_code:
|
|
184
|
-
error_message:
|
|
185
|
-
duration_ms:
|
|
186
|
-
arguments_ref:
|
|
187
|
-
result_ref:
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
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,
|
|
@@ -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
|