lex-llm-ledger 0.5.0 → 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 +29 -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 +27 -21
- 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 +10 -0
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70deb6cfb3814da49ebbc893a2025e06ddcf96ee7789a65f794fa66910502f41
|
|
4
|
+
data.tar.gz: e0837642064bf0c1ee6bf480704d297f961144f5f6cfb0814b88d8de438bf0f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '024169e420f778950b01bd074b03b1e63ccc642cb6c0148a14046dc91a74426c8a4d202a70fb33b6a669fbe3248fba62940e72a3a63ba1d776cef0044e0b9e36'
|
|
7
|
+
data.tar.gz: 207e66a936b63bf8c52437d2dc1d92931afdcb12caea03976b4e293bf8e67d43f963e62e9ba336c1e80789b3d60ad960b56eb6b85081f3146509f84c760aaf6f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.0] - 2026-05-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Skills audit actor** — new queue `llm.audit.skills`, actor, and runner consuming `audit.skill.#` events from the `llm.audit` exchange. Creates `llm_skill_events` records.
|
|
7
|
+
- **Escalation audit actor** — new exchange `llm.escalation` binding, queue `llm.audit.escalations`, actor, and runner. Creates `llm_escalation_events` records.
|
|
8
|
+
- **Retention purge actor** — actively deletes expired records (hourly, batched). Enforces `session_only` and PHI TTL retention policies that were previously passive.
|
|
9
|
+
- **Reconciliation actor** — links orphaned tool calls (null `response_id`) and metering requests (null `latest_message_id`) within a 5-minute lookback window every 2 minutes.
|
|
10
|
+
- **Migration 013** — `parent_request_id` FK on requests, `schema_version` on all official tables, `pii_types_json` and `jurisdictions_json` on conversations.
|
|
11
|
+
- **Migration 014** — creates `llm_skill_events` and `llm_escalation_events` tables.
|
|
12
|
+
- **PHI field-level encryption** — `request_json`, `response_json`, `response_thinking_json` encrypted at rest via `Legion::Crypt` when `contains_phi: true`.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **LEDGER-01**: `session_only` retention maps to 0 days (immediate expiry) instead of `nil`, distinguishing it from `permanent`.
|
|
16
|
+
- **IDENTITY-05**: `CallerIdentity.normalize` recognizes transport identity headers (`x-legion-identity-canonical-name`, `x-legion-identity-kind`, `x-legion-identity-db-*`).
|
|
17
|
+
- **IDENTITY-06**: `OfficialRecordWriter.caller_identity_refs` falls back to pre-resolved AMQP header IDs.
|
|
18
|
+
- **GAP-06**: `ProviderStats#health_report` uses `recorded_at` instead of `inserted_at` for 24h window.
|
|
19
|
+
- **GAP-10**: Backfill `metering_payload` maps `provider_instance` from correct column.
|
|
20
|
+
- **GAP-11**: Backfill `registry_reason` extracts from metadata JSON instead of storing raw blob.
|
|
21
|
+
- Dead `Retention.resolve` call removed from tools runner.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- **Jurisdictions** stored as JSON array instead of comma-joined string.
|
|
25
|
+
- **Classification level** validated against controlled vocabulary (`public`/`internal`/`confidential`/`restricted`), defaults to `internal`.
|
|
26
|
+
- **Schema version** written to all official table inserts (constant `SCHEMA_VERSION = 13`).
|
|
27
|
+
- All CallerIdentity-resolved payloads now pass `__header_principal_id`/`__header_identity_id` for direct FK resolution.
|
|
28
|
+
|
|
29
|
+
### Requires
|
|
30
|
+
- legion-data >= 1.8.9 (migrations 013-014)
|
|
31
|
+
|
|
3
32
|
## [0.5.0] - 2026-05-26
|
|
4
33
|
|
|
5
34
|
### Changed
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/subscription'
|
|
4
|
+
require_relative '../helpers/subscription_actor'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Ledger
|
|
10
|
+
module Actor
|
|
11
|
+
class Escalations < Legion::Extensions::Actors::Subscription
|
|
12
|
+
include Helpers::SubscriptionActor
|
|
13
|
+
|
|
14
|
+
prefetch 1
|
|
15
|
+
|
|
16
|
+
def runner_class = Legion::Extensions::Llm::Ledger::Runners::Escalations
|
|
17
|
+
|
|
18
|
+
def runner_function
|
|
19
|
+
'write_escalation_record'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def use_runner?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def queue
|
|
27
|
+
Legion::Extensions::Llm::Ledger::Transport::Queues::AuditEscalations
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Ledger
|
|
7
|
+
module Actor
|
|
8
|
+
class Reconciliation < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
|
|
9
|
+
def runner_class
|
|
10
|
+
'Legion::Extensions::Llm::Ledger::Runners::Reconciliation'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def runner_function
|
|
14
|
+
'link_orphaned_tool_calls'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def time
|
|
18
|
+
120
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
Runners::Reconciliation.link_orphaned_tool_calls
|
|
23
|
+
Runners::Reconciliation.link_metering_messages
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
handle_exception(e, level: :warn, handled: true, operation: 'reconciliation')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def run_now?
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def use_runner?
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check_subtask?
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def generate_task?
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Ledger
|
|
7
|
+
module Actor
|
|
8
|
+
class RetentionPurge < Legion::Extensions::Actors::Every # rubocop:disable Legion/Extension/EveryActorRequiresTime
|
|
9
|
+
def runner_class
|
|
10
|
+
'Legion::Extensions::Llm::Ledger::Runners::RetentionPurge'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def runner_function
|
|
14
|
+
'purge_expired'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def time
|
|
18
|
+
3600
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
Runners::RetentionPurge.purge_expired
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
handle_exception(e, level: :warn, handled: true, operation: 'retention_purge')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_now?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def use_runner?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def check_subtask?
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def generate_task?
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/subscription'
|
|
4
|
+
require_relative '../helpers/subscription_actor'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Ledger
|
|
10
|
+
module Actor
|
|
11
|
+
class Skills < Legion::Extensions::Actors::Subscription
|
|
12
|
+
include Helpers::SubscriptionActor
|
|
13
|
+
|
|
14
|
+
prefetch 1
|
|
15
|
+
|
|
16
|
+
def runner_class = Legion::Extensions::Llm::Ledger::Runners::Skills
|
|
17
|
+
|
|
18
|
+
def runner_function
|
|
19
|
+
'write_skill_record'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def use_runner?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def queue
|
|
27
|
+
Legion::Extensions::Llm::Ledger::Transport::Queues::AuditSkills
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -110,7 +110,8 @@ module Legion
|
|
|
110
110
|
exchange_id: row[:exchange_id],
|
|
111
111
|
operation: row[:request_type],
|
|
112
112
|
provider: row[:provider],
|
|
113
|
-
provider_instance: row[:
|
|
113
|
+
provider_instance: row[:provider_instance],
|
|
114
|
+
worker_id: row[:worker_id],
|
|
114
115
|
model_id: row[:model_id],
|
|
115
116
|
tier: row[:tier],
|
|
116
117
|
input_tokens: row[:input_tokens],
|
|
@@ -161,7 +162,7 @@ module Legion
|
|
|
161
162
|
model_key: row[:model_id],
|
|
162
163
|
event_type: row[:event_type],
|
|
163
164
|
status: registry_status(row),
|
|
164
|
-
reason: row
|
|
165
|
+
reason: registry_reason(row),
|
|
165
166
|
recorded_at: row[:occurred_at],
|
|
166
167
|
inserted_at: Time.now.utc
|
|
167
168
|
}, operation: 'legacy_llm_backfill.registry_event')
|
|
@@ -197,6 +198,11 @@ module Legion
|
|
|
197
198
|
health[:status] || health['status'] || row[:event_type] || 'unknown'
|
|
198
199
|
end
|
|
199
200
|
|
|
201
|
+
def registry_reason(row)
|
|
202
|
+
metadata = json_load(row[:metadata_json])
|
|
203
|
+
metadata[:reason] || metadata['reason'] || metadata[:message] || metadata['message'] || row[:event_type]
|
|
204
|
+
end
|
|
205
|
+
|
|
200
206
|
def table_present?(table)
|
|
201
207
|
db.table_exists?(table)
|
|
202
208
|
end
|
|
@@ -18,12 +18,16 @@ module Legion
|
|
|
18
18
|
extension = hash_value(caller_hash, :extension)
|
|
19
19
|
type = first_present(
|
|
20
20
|
hash_value(identity_hash, :type),
|
|
21
|
+
hash_value(identity_hash, :kind),
|
|
22
|
+
header_value(headers, 'x-legion-identity-kind'),
|
|
21
23
|
header_value(headers, 'x-legion-caller-type'),
|
|
22
24
|
hash_value(caller, :type),
|
|
25
|
+
hash_value(caller, :kind),
|
|
23
26
|
extension && 'extension'
|
|
24
27
|
)
|
|
25
28
|
|
|
26
29
|
raw_identity = first_present(
|
|
30
|
+
header_value(headers, 'x-legion-identity-canonical-name'),
|
|
27
31
|
hash_value(identity_hash, :id),
|
|
28
32
|
hash_value(identity_hash, :canonical_name),
|
|
29
33
|
hash_value(identity_hash, :identity),
|
|
@@ -38,8 +42,10 @@ module Legion
|
|
|
38
42
|
)
|
|
39
43
|
|
|
40
44
|
{
|
|
41
|
-
identity:
|
|
42
|
-
type:
|
|
45
|
+
identity: normalize_identity_value(raw_identity, type),
|
|
46
|
+
type: type,
|
|
47
|
+
principal_id: integer_header(headers, 'x-legion-identity-db-principal-id'),
|
|
48
|
+
identity_id: integer_header(headers, 'x-legion-identity-db-identity-id')
|
|
43
49
|
}.compact
|
|
44
50
|
end
|
|
45
51
|
|
|
@@ -71,6 +77,14 @@ module Legion
|
|
|
71
77
|
headers[key] || headers[key.to_sym]
|
|
72
78
|
end
|
|
73
79
|
|
|
80
|
+
def integer_header(headers, key)
|
|
81
|
+
raw = header_value(headers, key)
|
|
82
|
+
return nil if raw.nil?
|
|
83
|
+
|
|
84
|
+
int = raw.to_i
|
|
85
|
+
int.positive? ? int : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
def present?(value)
|
|
75
89
|
!value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
76
90
|
end
|
|
@@ -0,0 +1,115 @@
|
|
|
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 Escalations
|
|
15
|
+
extend self
|
|
16
|
+
|
|
17
|
+
def write_escalation_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_escalation_record(db, body, props, headers)
|
|
26
|
+
|
|
27
|
+
Helpers::PersistenceLogging.insert_row(
|
|
28
|
+
db, :llm_escalation_events, record,
|
|
29
|
+
operation: 'write_escalation_record'
|
|
30
|
+
)
|
|
31
|
+
{ result: :ok }
|
|
32
|
+
rescue Sequel::UniqueConstraintViolation => e
|
|
33
|
+
log.warn("write_escalation_record duplicate insert ignored: #{e.message}")
|
|
34
|
+
{ result: :duplicate }
|
|
35
|
+
rescue Helpers::DecryptionUnavailable => e
|
|
36
|
+
handle_exception(e, level: :warn, handled: true, operation: 'write_escalation_record.decrypt')
|
|
37
|
+
raise
|
|
38
|
+
rescue Helpers::DecryptionFailed => e
|
|
39
|
+
handle_exception(e, level: :error, handled: true, operation: 'write_escalation_record.decrypt')
|
|
40
|
+
raise
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
handle_exception(e, level: :error, handled: true, operation: 'write_escalation_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_escalation_record(db, body, props, headers) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
53
|
+
history = Array(body[:history])
|
|
54
|
+
identity = Helpers::CallerIdentity.normalize(
|
|
55
|
+
caller_raw: body[:caller], identity: body[:identity], headers: headers
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
first_attempt = history.first || {}
|
|
59
|
+
last_attempt = history.last || {}
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
uuid: stable_uuid(props[:message_id] || body[:event_id] || SecureRandom.uuid),
|
|
63
|
+
conversation_id: resolve_conversation_id(db, body, headers),
|
|
64
|
+
request_ref: body[:request_id] || props[:correlation_id],
|
|
65
|
+
from_provider: first_attempt[:provider] || body[:from_provider],
|
|
66
|
+
from_instance: first_attempt[:instance] || body[:from_instance],
|
|
67
|
+
from_model: first_attempt[:model] || body[:from_model],
|
|
68
|
+
to_provider: last_attempt[:provider] || body[:to_provider],
|
|
69
|
+
to_instance: last_attempt[:instance] || body[:to_instance],
|
|
70
|
+
to_model: last_attempt[:model] || body[:to_model],
|
|
71
|
+
reason: body[:reason] || (first_attempt[:outcome] == 'failure' ? 'provider_failover' : nil),
|
|
72
|
+
error_category: body[:error_category] || extract_error_category(first_attempt),
|
|
73
|
+
attempt_no: history.size || (body[:attempt_no] || 1),
|
|
74
|
+
latency_ms: history.sum { |a| (a[:duration_ms] || 0).to_i } || body[:latency_ms].to_i,
|
|
75
|
+
identity_canonical_name: identity[:identity],
|
|
76
|
+
identity_principal_id: identity[:principal_id],
|
|
77
|
+
identity_id: identity[:identity_id],
|
|
78
|
+
history_json: history.any? ? Helpers::Json.dump(history) : nil,
|
|
79
|
+
outcome: body[:outcome]&.to_s,
|
|
80
|
+
total_attempts: body[:attempts] ? body[:attempts].to_i : history.size,
|
|
81
|
+
recorded_at: body[:recorded_at] || body[:timestamp] || Time.now.utc,
|
|
82
|
+
inserted_at: Time.now.utc
|
|
83
|
+
}.compact
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_conversation_id(db, body, headers)
|
|
87
|
+
conv_ref = body[:conversation_id] || headers['x-legion-llm-conversation-id']
|
|
88
|
+
return nil unless conv_ref # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
89
|
+
|
|
90
|
+
conv = db[:llm_conversations].where(uuid: stable_uuid(conv_ref)).first ||
|
|
91
|
+
db[:llm_conversations].where(uuid: conv_ref).first
|
|
92
|
+
conv&.[](:id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def extract_error_category(attempt)
|
|
96
|
+
failures = Array(attempt[:failures])
|
|
97
|
+
failures.first.is_a?(Hash) ? failures.first[:category].to_s : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stable_uuid(value)
|
|
101
|
+
raw = value.to_s
|
|
102
|
+
return raw if raw.length <= 36 # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
103
|
+
|
|
104
|
+
hex = Digest::SHA256.hexdigest(raw)[0, 32]
|
|
105
|
+
"#{hex[0, 8]}-#{hex[8, 4]}-#{hex[12, 4]}-#{hex[16, 4]}-#{hex[20, 12]}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
109
|
+
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -92,18 +92,20 @@ module Legion
|
|
|
92
92
|
caller_raw: payload[:caller], identity: payload[:identity], headers: headers
|
|
93
93
|
)
|
|
94
94
|
payload.merge(
|
|
95
|
-
message_id:
|
|
96
|
-
correlation_id:
|
|
97
|
-
conversation_id:
|
|
98
|
-
request_id:
|
|
99
|
-
exchange_id:
|
|
100
|
-
operation:
|
|
101
|
-
provider:
|
|
102
|
-
provider_instance:
|
|
103
|
-
model_id:
|
|
104
|
-
tier:
|
|
105
|
-
caller_identity:
|
|
106
|
-
caller_type:
|
|
95
|
+
message_id: props[:message_id] || payload[:message_id] || ctx[:message_id],
|
|
96
|
+
correlation_id: props[:correlation_id] || payload[:correlation_id],
|
|
97
|
+
conversation_id: ctx[:conversation_id] || payload[:conversation_id],
|
|
98
|
+
request_id: ctx[:request_id] || payload[:request_id],
|
|
99
|
+
exchange_id: ctx[:exchange_id] || payload[:exchange_id],
|
|
100
|
+
operation: payload[:operation] || payload[:request_type] || headers['x-legion-llm-request-type'],
|
|
101
|
+
provider: payload[:provider] || headers['x-legion-llm-provider'],
|
|
102
|
+
provider_instance: payload[:provider_instance] || payload[:instance],
|
|
103
|
+
model_id: payload[:model_id] || headers['x-legion-llm-model'],
|
|
104
|
+
tier: payload[:tier] || headers['x-legion-llm-tier'],
|
|
105
|
+
caller_identity: identity[:identity],
|
|
106
|
+
caller_type: identity[:type],
|
|
107
|
+
__header_principal_id: identity[:principal_id],
|
|
108
|
+
__header_identity_id: identity[:identity_id]
|
|
107
109
|
)
|
|
108
110
|
end
|
|
109
111
|
|
|
@@ -10,6 +10,8 @@ module Legion
|
|
|
10
10
|
module Ledger
|
|
11
11
|
module Runners
|
|
12
12
|
module Prompts
|
|
13
|
+
ALLOWED_CLASSIFICATION_LEVELS = %w[public internal confidential restricted].freeze
|
|
14
|
+
|
|
13
15
|
extend self
|
|
14
16
|
|
|
15
17
|
def write_prompt_record(payload = nil, metadata = {}, **message)
|
|
@@ -137,8 +139,10 @@ module Legion
|
|
|
137
139
|
caller_raw: body[:caller], identity: body[:identity], headers: headers
|
|
138
140
|
)
|
|
139
141
|
{
|
|
140
|
-
caller_identity:
|
|
141
|
-
caller_type:
|
|
142
|
+
caller_identity: identity[:identity],
|
|
143
|
+
caller_type: identity[:type],
|
|
144
|
+
__header_principal_id: identity[:principal_id],
|
|
145
|
+
__header_identity_id: identity[:identity_id]
|
|
142
146
|
}.compact
|
|
143
147
|
end
|
|
144
148
|
|
|
@@ -165,14 +169,25 @@ module Legion
|
|
|
165
169
|
end
|
|
166
170
|
|
|
167
171
|
def official_compliance_payload(body, headers, expires_at)
|
|
172
|
+
cls = body[:classification] || {}
|
|
168
173
|
{
|
|
169
174
|
retention_policy: headers['x-legion-retention'] || body[:retention_policy],
|
|
170
175
|
expires_at: expires_at,
|
|
171
|
-
contains_phi: headers['x-legion-contains-phi'] == 'true' ||
|
|
172
|
-
|
|
176
|
+
contains_phi: headers['x-legion-contains-phi'] == 'true' || cls[:contains_phi],
|
|
177
|
+
contains_pii: cls[:contains_pii] ? true : false,
|
|
178
|
+
pii_types_json: Helpers::Json.dump(Array(cls[:pii_types])),
|
|
179
|
+
classification_level: normalize_classification(cls[:level] || headers['x-legion-classification']),
|
|
180
|
+
jurisdictions: Helpers::Json.dump(Array(cls[:jurisdictions]))
|
|
173
181
|
}
|
|
174
182
|
end
|
|
175
183
|
|
|
184
|
+
def normalize_classification(level)
|
|
185
|
+
return 'internal' if level.nil? || level.to_s.empty? # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
186
|
+
|
|
187
|
+
normalized = level.to_s.downcase
|
|
188
|
+
ALLOWED_CLASSIFICATION_LEVELS.include?(normalized) ? normalized : 'internal'
|
|
189
|
+
end
|
|
190
|
+
|
|
176
191
|
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
177
192
|
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
178
193
|
end
|
|
@@ -10,7 +10,7 @@ module Legion
|
|
|
10
10
|
|
|
11
11
|
def health_report
|
|
12
12
|
ds = official_metrics
|
|
13
|
-
.where { Sequel[:llm_message_inference_metrics][:
|
|
13
|
+
.where { Sequel[:llm_message_inference_metrics][:recorded_at] >= Time.now.utc - 86_400 }
|
|
14
14
|
.select_group(
|
|
15
15
|
Sequel[:llm_message_inference_metrics][:provider],
|
|
16
16
|
Sequel[:llm_message_inference_responses][:provider_instance],
|
|
@@ -0,0 +1,96 @@
|
|
|
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 Reconciliation
|
|
11
|
+
extend self
|
|
12
|
+
extend Legion::Logging::Helper
|
|
13
|
+
|
|
14
|
+
BATCH_SIZE = 200
|
|
15
|
+
LOOKBACK_SECONDS = 300
|
|
16
|
+
|
|
17
|
+
def link_orphaned_tool_calls
|
|
18
|
+
db = ::Legion::Data.connection
|
|
19
|
+
linked = 0
|
|
20
|
+
|
|
21
|
+
orphans = db[:llm_tool_calls]
|
|
22
|
+
.where(message_inference_response_id: nil)
|
|
23
|
+
.where { inserted_at >= Time.now.utc - LOOKBACK_SECONDS }
|
|
24
|
+
.limit(BATCH_SIZE)
|
|
25
|
+
.all
|
|
26
|
+
|
|
27
|
+
orphans.each do |tool_call|
|
|
28
|
+
response = find_response_for_tool_call(db, tool_call)
|
|
29
|
+
next unless response
|
|
30
|
+
|
|
31
|
+
db[:llm_tool_calls].where(id: tool_call[:id]).update(
|
|
32
|
+
message_inference_response_id: response[:id]
|
|
33
|
+
)
|
|
34
|
+
linked += 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
log.info("[ledger] reconciliation: linked #{linked} orphaned tool calls") if linked.positive?
|
|
38
|
+
{ result: :ok, linked: linked }
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
handle_exception(e, level: :error, handled: true, operation: 'reconciliation.tool_calls')
|
|
41
|
+
{ result: :error, error: e.message }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def link_metering_messages
|
|
45
|
+
db = ::Legion::Data.connection
|
|
46
|
+
linked = 0
|
|
47
|
+
|
|
48
|
+
requests_without_messages = db[:llm_message_inference_requests]
|
|
49
|
+
.where(latest_message_id: nil)
|
|
50
|
+
.where { inserted_at >= Time.now.utc - LOOKBACK_SECONDS }
|
|
51
|
+
.limit(BATCH_SIZE)
|
|
52
|
+
.all
|
|
53
|
+
|
|
54
|
+
requests_without_messages.each do |request|
|
|
55
|
+
next unless request[:conversation_id]
|
|
56
|
+
|
|
57
|
+
message = db[:llm_messages]
|
|
58
|
+
.where(conversation_id: request[:conversation_id])
|
|
59
|
+
.order(Sequel.desc(:seq))
|
|
60
|
+
.first
|
|
61
|
+
next unless message
|
|
62
|
+
|
|
63
|
+
db[:llm_message_inference_requests]
|
|
64
|
+
.where(id: request[:id])
|
|
65
|
+
.update(latest_message_id: message[:id])
|
|
66
|
+
linked += 1
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
log.info("[ledger] reconciliation: linked #{linked} metering requests to messages") if linked.positive?
|
|
70
|
+
{ result: :ok, linked: linked }
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
handle_exception(e, level: :error, handled: true, operation: 'reconciliation.metering_messages')
|
|
73
|
+
{ result: :error, error: e.message }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def find_response_for_tool_call(db, tool_call)
|
|
79
|
+
return nil unless tool_call[:conversation_id] # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
80
|
+
|
|
81
|
+
request = db[:llm_message_inference_requests]
|
|
82
|
+
.where(conversation_id: tool_call[:conversation_id])
|
|
83
|
+
.order(Sequel.desc(:inserted_at))
|
|
84
|
+
.first
|
|
85
|
+
return nil unless request # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
86
|
+
|
|
87
|
+
db[:llm_message_inference_responses]
|
|
88
|
+
.where(message_inference_request_id: request[:id])
|
|
89
|
+
.first
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -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]
|
|
@@ -106,7 +101,7 @@ module Legion
|
|
|
106
101
|
conv&.[](:id)
|
|
107
102
|
end
|
|
108
103
|
|
|
109
|
-
def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id)
|
|
104
|
+
def find_or_create_tool_call(db, response, body, ctx, tool, headers, identity_attrs, conversation_id) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
110
105
|
tool_uuid = derive_tool_call_uuid(body, ctx, tool, headers)
|
|
111
106
|
existing = db[:llm_tool_calls].where(uuid: tool_uuid).first
|
|
112
107
|
return [existing, false] if existing # rubocop:disable Legion/Extension/RunnerReturnHash
|
|
@@ -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) # 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
|
|
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),
|
|
@@ -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
|
|
@@ -18,6 +18,10 @@ require_relative 'ledger/backfill/legacy_llm_records'
|
|
|
18
18
|
require_relative 'ledger/runners/metering'
|
|
19
19
|
require_relative 'ledger/runners/prompts'
|
|
20
20
|
require_relative 'ledger/runners/tools'
|
|
21
|
+
require_relative 'ledger/runners/skills'
|
|
22
|
+
require_relative 'ledger/runners/escalations'
|
|
23
|
+
require_relative 'ledger/runners/retention_purge'
|
|
24
|
+
require_relative 'ledger/runners/reconciliation'
|
|
21
25
|
require_relative 'ledger/runners/usage_reporter'
|
|
22
26
|
require_relative 'ledger/runners/provider_stats'
|
|
23
27
|
require_relative 'ledger/runners/registry_availability'
|
|
@@ -29,12 +33,18 @@ if defined?(Legion::Extensions) && Legion::Extensions.const_defined?(:Core, fals
|
|
|
29
33
|
require_relative 'ledger/transport/queues/metering_write'
|
|
30
34
|
require_relative 'ledger/transport/queues/audit_prompts'
|
|
31
35
|
require_relative 'ledger/transport/queues/audit_tools'
|
|
36
|
+
require_relative 'ledger/transport/queues/audit_skills'
|
|
37
|
+
require_relative 'ledger/transport/queues/audit_escalations'
|
|
32
38
|
require_relative 'ledger/transport/queues/registry_availability'
|
|
33
39
|
require_relative 'ledger/transport/transport'
|
|
34
40
|
require_relative 'ledger/actors/metering'
|
|
35
41
|
require_relative 'ledger/actors/prompts'
|
|
36
42
|
require_relative 'ledger/actors/tools'
|
|
43
|
+
require_relative 'ledger/actors/skills'
|
|
44
|
+
require_relative 'ledger/actors/escalations'
|
|
37
45
|
require_relative 'ledger/actors/registry_availability'
|
|
46
|
+
require_relative 'ledger/actors/retention_purge'
|
|
47
|
+
require_relative 'ledger/actors/reconciliation'
|
|
38
48
|
require_relative 'ledger/actors/spool_flush'
|
|
39
49
|
end
|
|
40
50
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-llm-ledger
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -191,9 +191,13 @@ files:
|
|
|
191
191
|
- Rakefile
|
|
192
192
|
- lex-llm-ledger.gemspec
|
|
193
193
|
- lib/legion/extensions/llm/ledger.rb
|
|
194
|
+
- lib/legion/extensions/llm/ledger/actors/escalations.rb
|
|
194
195
|
- lib/legion/extensions/llm/ledger/actors/metering.rb
|
|
195
196
|
- lib/legion/extensions/llm/ledger/actors/prompts.rb
|
|
197
|
+
- lib/legion/extensions/llm/ledger/actors/reconciliation.rb
|
|
196
198
|
- lib/legion/extensions/llm/ledger/actors/registry_availability.rb
|
|
199
|
+
- lib/legion/extensions/llm/ledger/actors/retention_purge.rb
|
|
200
|
+
- lib/legion/extensions/llm/ledger/actors/skills.rb
|
|
197
201
|
- lib/legion/extensions/llm/ledger/actors/spool_flush.rb
|
|
198
202
|
- lib/legion/extensions/llm/ledger/actors/tools.rb
|
|
199
203
|
- lib/legion/extensions/llm/ledger/backfill/legacy_llm_records.rb
|
|
@@ -217,16 +221,23 @@ files:
|
|
|
217
221
|
- lib/legion/extensions/llm/ledger/helpers/retention.rb
|
|
218
222
|
- lib/legion/extensions/llm/ledger/helpers/subscription_actor.rb
|
|
219
223
|
- lib/legion/extensions/llm/ledger/helpers/subscription_message.rb
|
|
224
|
+
- lib/legion/extensions/llm/ledger/runners/escalations.rb
|
|
220
225
|
- lib/legion/extensions/llm/ledger/runners/metering.rb
|
|
221
226
|
- lib/legion/extensions/llm/ledger/runners/prompts.rb
|
|
222
227
|
- lib/legion/extensions/llm/ledger/runners/provider_stats.rb
|
|
228
|
+
- lib/legion/extensions/llm/ledger/runners/reconciliation.rb
|
|
223
229
|
- lib/legion/extensions/llm/ledger/runners/registry_availability.rb
|
|
230
|
+
- lib/legion/extensions/llm/ledger/runners/retention_purge.rb
|
|
231
|
+
- lib/legion/extensions/llm/ledger/runners/skills.rb
|
|
224
232
|
- lib/legion/extensions/llm/ledger/runners/tools.rb
|
|
225
233
|
- lib/legion/extensions/llm/ledger/runners/usage_reporter.rb
|
|
226
234
|
- lib/legion/extensions/llm/ledger/transport/exchanges/audit.rb
|
|
235
|
+
- lib/legion/extensions/llm/ledger/transport/exchanges/escalation.rb
|
|
227
236
|
- lib/legion/extensions/llm/ledger/transport/exchanges/metering.rb
|
|
228
237
|
- lib/legion/extensions/llm/ledger/transport/exchanges/registry.rb
|
|
238
|
+
- lib/legion/extensions/llm/ledger/transport/queues/audit_escalations.rb
|
|
229
239
|
- lib/legion/extensions/llm/ledger/transport/queues/audit_prompts.rb
|
|
240
|
+
- lib/legion/extensions/llm/ledger/transport/queues/audit_skills.rb
|
|
230
241
|
- lib/legion/extensions/llm/ledger/transport/queues/audit_tools.rb
|
|
231
242
|
- lib/legion/extensions/llm/ledger/transport/queues/metering_write.rb
|
|
232
243
|
- lib/legion/extensions/llm/ledger/transport/queues/registry_availability.rb
|