rails_ai_admin 0.1.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 +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Assistant
|
|
7
|
+
# Tool state for one claimed turn: the freshly resolved Context, the chat
|
|
8
|
+
# and a fence that stops a worker whose lease was reassigned.
|
|
9
|
+
# conversation is the server-owned reference of a session without a chat
|
|
10
|
+
# (an MCP client); its proposals become reviews opened by URL.
|
|
11
|
+
Session = ::Data.define(:context, :chat, :fence, :conversation) do
|
|
12
|
+
def initialize(context:, chat:, fence:, conversation: nil) = super
|
|
13
|
+
# Registered resources this actor may currently expose to the model.
|
|
14
|
+
def resources
|
|
15
|
+
RailsAiAdmin.registry.resources.select { |resource| exposed?(resource) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Unknown, hidden and unexposed resources are indistinguishable.
|
|
19
|
+
def resource(key)
|
|
20
|
+
raise AccessDenied unless key.is_a?(String) && key.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
|
|
21
|
+
resource = RailsAiAdmin.registry.fetch(key)
|
|
22
|
+
raise AccessDenied unless exposed?(resource)
|
|
23
|
+
resource
|
|
24
|
+
rescue ConfigurationError, KeyError
|
|
25
|
+
raise AccessDenied
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def access(resource) = Authorization::Access.new(context, resource)
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def exposed?(resource)
|
|
33
|
+
access(resource).authorize_assistant!
|
|
34
|
+
rescue AccessDenied
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Base for admin tools. Each call checks the turn fence and current grants.
|
|
40
|
+
# Results are bounded JSON data for the model; they grant nothing.
|
|
41
|
+
class Tool < ::RubyLLM::Tool
|
|
42
|
+
MAX_RESULT_BYTES = 16_384
|
|
43
|
+
MESSAGES = {
|
|
44
|
+
"NOT_FOUND" => "Not found or not available to this user.",
|
|
45
|
+
"INVALID_QUERY" => "The request was not valid for this resource.",
|
|
46
|
+
"CONFLICT" => "The record changed. Fetch it again before proposing a change.",
|
|
47
|
+
"VALIDATION_FAILED" => "The proposed input is not valid.",
|
|
48
|
+
"UNAVAILABLE" => "This capability is not available."
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
def initialize(session)
|
|
52
|
+
super()
|
|
53
|
+
@session = session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def execute(tool_call: nil, **arguments)
|
|
57
|
+
@session.fence.call
|
|
58
|
+
bounded(perform(tool_call: tool_call, **arguments))
|
|
59
|
+
rescue ArgumentError
|
|
60
|
+
# Missing or unknown model-supplied keywords.
|
|
61
|
+
failure("INVALID_QUERY")
|
|
62
|
+
rescue AccessDenied => error
|
|
63
|
+
failure(error.code == "INVALID_QUERY" ? "INVALID_QUERY" : error.code == "CONFLICT" ? "CONFLICT" : "NOT_FOUND")
|
|
64
|
+
rescue ConfigurationError
|
|
65
|
+
failure("UNAVAILABLE")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def failure(code, **extra) = { "error" => code, "message" => MESSAGES.fetch(code, MESSAGES["UNAVAILABLE"]), **extra }
|
|
71
|
+
|
|
72
|
+
# A chat shows the review in the conversation; a session without one
|
|
73
|
+
# (MCP) records it under its conversation reference for the person to
|
|
74
|
+
# open in the admin.
|
|
75
|
+
def present_review(tool_views, resource, definition, record, proposal, tool_call)
|
|
76
|
+
common = { context: @session.context, resource: resource, definition: definition, record: record,
|
|
77
|
+
intent_ref: proposal.intent.id, input: tool_views.wire_input(proposal.values) }
|
|
78
|
+
if @session.chat
|
|
79
|
+
tool_views.present(**common, chat: @session.chat, call_id: tool_call.id)
|
|
80
|
+
else
|
|
81
|
+
tool_views.present(**common, assistant_call: { conversation: @session.conversation, call_id: tool_call.id })
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def acknowledgment(surface)
|
|
86
|
+
message = @session.chat ? "A review is shown to the user. Nothing has changed yet; a later message reports the confirmed result." :
|
|
87
|
+
"A review is waiting in the admin. Nothing has changed until the user opens review_url and confirms it."
|
|
88
|
+
{ "status" => "awaiting_human_confirmation", "surface" => surface.id, "message" => message }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def bounded(result)
|
|
92
|
+
raise ConfigurationError, "Tool result must be a Hash" unless result.is_a?(Hash)
|
|
93
|
+
return result if JSON.generate(result).bytesize <= MAX_RESULT_BYTES
|
|
94
|
+
if result["records"].is_a?(Array)
|
|
95
|
+
records = result["records"].dup
|
|
96
|
+
records.pop while records.any? && JSON.generate(result.merge("records" => records, "truncated" => true)).bytesize > MAX_RESULT_BYTES
|
|
97
|
+
return result.merge("records" => records, "truncated" => true)
|
|
98
|
+
end
|
|
99
|
+
failure("UNAVAILABLE")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def time_zone = @session.context.time_zone
|
|
103
|
+
|
|
104
|
+
# Typed JSON for the model (RAA-017): null stays null, booleans and
|
|
105
|
+
# integers keep their JSON types, decimals are exact strings padded to
|
|
106
|
+
# their scale, dates and times are ISO 8601, a select is {value, label}
|
|
107
|
+
# and a reference is {id, label, resource}. A registered field type keeps
|
|
108
|
+
# its formatted text, because its formatter may deliberately redact.
|
|
109
|
+
def typed(field, value, references)
|
|
110
|
+
return nil if value.nil?
|
|
111
|
+
return field.format(value, time_zone: time_zone).truncate(500) if field.plugin?
|
|
112
|
+
case field.scalar_type
|
|
113
|
+
when :boolean, :integer then value
|
|
114
|
+
when :decimal then field.format_decimal(value)
|
|
115
|
+
when :date then value.iso8601
|
|
116
|
+
when :datetime then value.in_time_zone(time_zone).iso8601
|
|
117
|
+
when :time then field.format(value, time_zone: time_zone)
|
|
118
|
+
when :json then JSON.generate(value).truncate(2000)
|
|
119
|
+
when :references
|
|
120
|
+
Array(value).first(50).map do |id|
|
|
121
|
+
reference = references.reference(field, id)
|
|
122
|
+
{ "id" => reference.id, "label" => reference.label.to_s.truncate(200), "resource" => reference.visible ? reference.resource_key.to_s : nil }
|
|
123
|
+
end
|
|
124
|
+
when :rich_text then field.format(value, time_zone: time_zone).truncate(2000)
|
|
125
|
+
when :files then Array(value).map { |file| { "filename" => file.filename.truncate(200), "content_type" => file.content_type, "byte_size" => file.byte_size } }
|
|
126
|
+
when :file then { "filename" => value.filename.truncate(200), "content_type" => value.content_type, "byte_size" => value.byte_size }
|
|
127
|
+
when :select then { "value" => value.to_s, "label" => field.format(value, time_zone: time_zone) }
|
|
128
|
+
when :belongs_to
|
|
129
|
+
reference = references.reference(field, value.to_s)
|
|
130
|
+
{ "id" => reference.id, "label" => reference.label.to_s.truncate(200), "resource" => reference.visible ? reference.resource_key.to_s : nil }
|
|
131
|
+
else value.to_s.truncate(500)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def values(access, record, view, references)
|
|
136
|
+
fields = access.fields(:read, record, view: view).index_by { |field| field.name.to_s }
|
|
137
|
+
access.project(record, view: view).filter_map do |name, value|
|
|
138
|
+
field = fields[name]
|
|
139
|
+
[ name, typed(field, value, references) ] if field
|
|
140
|
+
end.to_h
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def reference_resolver(access) = Fields::Association.new(access.context, access.resource)
|
|
144
|
+
|
|
145
|
+
def record_entry(access, record, view, references = reference_resolver(access))
|
|
146
|
+
{ "id" => record.id.to_s, "label" => access.record_label(record, view: view), "fields" => values(access, record, view, references) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Records the actor may list but not open are left out, as on the list
|
|
150
|
+
# page (RAA-001), and counted so the model can say so.
|
|
151
|
+
def record_entries(access, records, view)
|
|
152
|
+
resolver = reference_resolver(access)
|
|
153
|
+
access.fields(:read, nil, view: view).select { |field| field.scalar_type == :belongs_to }.each do |field|
|
|
154
|
+
resolver.prime(field, records.filter_map { |record| record.has_attribute?(field.column) ? record.read_attribute(field.column)&.to_s : nil })
|
|
155
|
+
end
|
|
156
|
+
omitted = 0
|
|
157
|
+
entries = records.filter_map do |record|
|
|
158
|
+
record_entry(access, record, view, resolver)
|
|
159
|
+
rescue AccessDenied
|
|
160
|
+
omitted += 1
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
[ entries, omitted ]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def wire_filters(filters)
|
|
167
|
+
return nil if filters.nil? || filters == {}
|
|
168
|
+
raise AccessDenied, "INVALID_QUERY" unless filters.is_a?(Hash) && filters.size <= Queries::List::MAX_FILTER_CONDITIONS
|
|
169
|
+
filters.to_h do |name, conditions|
|
|
170
|
+
raise AccessDenied, "INVALID_QUERY" unless conditions.is_a?(Hash)
|
|
171
|
+
[ name.to_s, conditions.to_h { |operator, value| [ operator.to_s, scalar(value) ] } ]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def scalar(value)
|
|
176
|
+
case value
|
|
177
|
+
when String then value
|
|
178
|
+
when Integer, Float, true, false then value.to_s
|
|
179
|
+
else raise AccessDenied, "INVALID_QUERY"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The assistant sees the whole policy relation unless it names a granted
|
|
184
|
+
# list tab; a default tab never applies silently (RAA-007).
|
|
185
|
+
def list_scope(scope)
|
|
186
|
+
return Queries::NamedScope::NONE if scope.nil?
|
|
187
|
+
raise AccessDenied, "INVALID_QUERY" unless scope.is_a?(String) && scope.match?(Queries::NamedScope::NAME)
|
|
188
|
+
scope
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
class ListResources < Tool
|
|
193
|
+
description "List the admin resources this user may ask about. Call this first to learn resource keys."
|
|
194
|
+
parameters({ "type" => "object", "properties" => {}, "additionalProperties" => false })
|
|
195
|
+
|
|
196
|
+
def self.tool_name = "list_resources"
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def perform(tool_call:)
|
|
201
|
+
{ "resources" => @session.resources.map { |resource| { "key" => resource.key.to_s, "label" => resource.label } } }
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
class DescribeResource < Tool
|
|
206
|
+
description "Describe one resource: readable fields, search, filters and the changes you may propose."
|
|
207
|
+
parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
|
|
208
|
+
"properties" => { "resource" => { "type" => "string", "description" => "A key from list_resources" } } })
|
|
209
|
+
|
|
210
|
+
def self.tool_name = "describe_resource"
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def perform(tool_call:, resource:)
|
|
215
|
+
resource = @session.resource(resource)
|
|
216
|
+
access = @session.access(resource)
|
|
217
|
+
result = {
|
|
218
|
+
"key" => resource.key.to_s, "label" => resource.label,
|
|
219
|
+
"fields" => access.fields(:read, nil, view: :show).map { |field| field_entry(field) },
|
|
220
|
+
# Match mode per field: "prefix" means "Love" does not find "Ada Lovelace" (RAA-021).
|
|
221
|
+
"search" => access.fields(:search).map { |field| { "field" => field.name.to_s, "match" => (field.options[:searchable] == true ? :prefix : field.options[:searchable]).to_s } },
|
|
222
|
+
"filters" => access.fields(:filter).map { |field| { "field" => field.name.to_s, "operators" => field.filter_operators.map(&:to_s) } },
|
|
223
|
+
"sort" => access.fields(:sort).map { |field| field.name.to_s },
|
|
224
|
+
# Search and count apply no list tab unless one is named (RAA-007).
|
|
225
|
+
"scopes" => access.named_scopes.map { |definition| { "name" => definition.name.to_s, "label" => definition.label, "default_in_ui" => definition.default? } }
|
|
226
|
+
}
|
|
227
|
+
if RailsAiAdmin.configuration.surfaces
|
|
228
|
+
result["operations"] = operations(resource, access)
|
|
229
|
+
result["changes"] = changes(resource, access)
|
|
230
|
+
end
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Native changes this resource lets the assistant propose, with the
|
|
235
|
+
# fields the user may set on create or update (never passwords or files).
|
|
236
|
+
def changes(resource, access)
|
|
237
|
+
%w[create update destroy].filter_map do |action|
|
|
238
|
+
next unless resource.assistant_change?(action)
|
|
239
|
+
entry = { "action" => action }
|
|
240
|
+
unless action == "destroy"
|
|
241
|
+
fields = access.fields(:write, action == "create" ? access.relation.new : nil, operation: action.to_sym)
|
|
242
|
+
.reject { |field| ProposeChange::UNSUPPORTED.include?(field.scalar_type) }
|
|
243
|
+
entry["fields"] = fields.map { |field| field_entry(field).merge("required" => action == "create" && field.options[:required] == true) }
|
|
244
|
+
end
|
|
245
|
+
entry
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def field_entry(field)
|
|
250
|
+
entry = { "name" => field.name.to_s, "label" => field.label, "type" => field.scalar_type.to_s }
|
|
251
|
+
# Operation inputs declare enum values; without them a model can only guess.
|
|
252
|
+
entry["choices"] = field.choice_options.map { |label, value| { "value" => value, "label" => label } } if %i[select enum].include?(field.scalar_type)
|
|
253
|
+
entry
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Declared contracts only. Member grants depend on the record, so each
|
|
257
|
+
# proposal is authorized against its record by propose_operation.
|
|
258
|
+
def operations(resource, _access)
|
|
259
|
+
RailsAiAdmin.operation_registry.custom(resource).filter_map do |definition|
|
|
260
|
+
next unless definition.scope == :member && resource.assistant_operation?(definition.key)
|
|
261
|
+
{ "key" => definition.key.to_s, "description" => definition.description.to_s.truncate(200),
|
|
262
|
+
"input" => definition.handler_class.input.map { |field| field_entry(field).merge("required" => field.options[:required] == true) } }
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
class SearchRecords < Tool
|
|
268
|
+
PAGE_LIMIT = 20
|
|
269
|
+
MAX_RECORDS = 1_000
|
|
270
|
+
description "Search one resource's records with optional text, typed filters, a list scope and a sort. Returns up to 20 records. " \
|
|
271
|
+
"Every record you may see is searched unless you pass scope. To count records, use count_records instead of paging. " \
|
|
272
|
+
"When has_more is true, pass next_offset as offset to continue, including after truncation."
|
|
273
|
+
parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
|
|
274
|
+
"properties" => {
|
|
275
|
+
"resource" => { "type" => "string" },
|
|
276
|
+
"query" => { "type" => "string", "maxLength" => 200, "description" => "Text matched against searchable fields with each field's match mode" },
|
|
277
|
+
"filters" => { "type" => "object", "description" => "Field name to {\"eq\"|\"gte\"|\"lte\": value}; use select values, not labels",
|
|
278
|
+
"additionalProperties" => { "type" => "object", "additionalProperties" => { "type" => %w[string number boolean] } } },
|
|
279
|
+
"scope" => { "type" => "string", "description" => "Optional scope name from describe_resource" },
|
|
280
|
+
"sort" => { "type" => "string", "description" => "Optional sortable field from describe_resource" },
|
|
281
|
+
"direction" => { "type" => "string", "enum" => %w[asc desc] },
|
|
282
|
+
"page" => { "type" => "integer", "minimum" => 1, "maximum" => 50 },
|
|
283
|
+
"offset" => { "type" => "integer", "minimum" => 0, "maximum" => MAX_RECORDS - 1, "description" => "The next_offset returned by the previous result" }
|
|
284
|
+
} })
|
|
285
|
+
|
|
286
|
+
def self.tool_name = "search_records"
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def perform(tool_call:, resource:, query: nil, filters: nil, scope: nil, sort: nil, direction: nil, page: 1, offset: nil)
|
|
291
|
+
resource = @session.resource(resource)
|
|
292
|
+
access = @session.access(resource)
|
|
293
|
+
raise AccessDenied, "INVALID_QUERY" unless page.is_a?(Integer) && page.between?(1, 50)
|
|
294
|
+
raise AccessDenied, "INVALID_QUERY" unless sort.nil? || sort.is_a?(String) && sort.match?(Field::NAME)
|
|
295
|
+
raise AccessDenied, "INVALID_QUERY" unless direction.nil? || %w[asc desc].include?(direction)
|
|
296
|
+
offset = (page - 1) * PAGE_LIMIT if offset.nil?
|
|
297
|
+
raise AccessDenied, "INVALID_QUERY" unless offset.is_a?(Integer) && offset.between?(0, MAX_RECORDS - 1)
|
|
298
|
+
result = Queries::List.new(@session.context, resource).call(search: query.presence, page: page.to_s, filters: wire_filters(filters),
|
|
299
|
+
scope: list_scope(scope), sort: sort, direction: direction || "asc", page_size: [ PAGE_LIMIT, MAX_RECORDS - offset ].min, offset: offset)
|
|
300
|
+
records, omitted = record_entries(access, result.records, :index)
|
|
301
|
+
{ "resource" => resource.key.to_s, "applied_scope" => result.scope,
|
|
302
|
+
"applied_sort" => result.sort ? { "field" => result.sort, "direction" => result.direction } : nil,
|
|
303
|
+
"page" => result.number, "offset" => offset, "has_more" => result.has_next,
|
|
304
|
+
"omitted" => omitted, "records" => records }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Leave room for continuation metadata before applying the byte bound.
|
|
308
|
+
# Advance by what the model actually saw, not the number queried.
|
|
309
|
+
def bounded(result)
|
|
310
|
+
return result if result["error"]
|
|
311
|
+
result = super(result.merge("next_offset" => MAX_RECORDS, "limit_reached" => false))
|
|
312
|
+
records = result.fetch("records")
|
|
313
|
+
return failure("UNAVAILABLE") if records.empty? && result["truncated"]
|
|
314
|
+
next_offset = result.fetch("offset") + records.size
|
|
315
|
+
more = result["has_more"] || result["truncated"] == true
|
|
316
|
+
result.merge("next_offset" => more && next_offset < MAX_RECORDS ? next_offset : nil,
|
|
317
|
+
"has_more" => more && next_offset < MAX_RECORDS, "limit_reached" => more && next_offset >= MAX_RECORDS)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# One bounded number, so "how many" never means paging (roadmap
|
|
322
|
+
# "aggregate tools"). Same grants, filters and scope rule as search.
|
|
323
|
+
class CountRecords < Tool
|
|
324
|
+
description "Count one resource's records matching optional text, typed filters and a list scope. " \
|
|
325
|
+
"Every record you may see is counted unless you pass scope. Counts above the cap are reported as capped."
|
|
326
|
+
parameters({ "type" => "object", "required" => [ "resource" ], "additionalProperties" => false,
|
|
327
|
+
"properties" => {
|
|
328
|
+
"resource" => { "type" => "string" },
|
|
329
|
+
"query" => { "type" => "string", "maxLength" => 200 },
|
|
330
|
+
"filters" => { "type" => "object", "description" => "Field name to {\"eq\"|\"gte\"|\"lte\": value}",
|
|
331
|
+
"additionalProperties" => { "type" => "object", "additionalProperties" => { "type" => %w[string number boolean] } } },
|
|
332
|
+
"scope" => { "type" => "string", "description" => "Optional scope name from describe_resource" }
|
|
333
|
+
} })
|
|
334
|
+
|
|
335
|
+
def self.tool_name = "count_records"
|
|
336
|
+
|
|
337
|
+
private
|
|
338
|
+
|
|
339
|
+
def perform(tool_call:, resource:, query: nil, filters: nil, scope: nil)
|
|
340
|
+
resource = @session.resource(resource)
|
|
341
|
+
result = Queries::List.new(@session.context, resource).count(search: query.presence, filters: wire_filters(filters), scope: list_scope(scope))
|
|
342
|
+
{ "resource" => resource.key.to_s, "applied_scope" => result.scope, "count" => result.count, "capped" => result.capped }
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
class GetRecord < Tool
|
|
347
|
+
description "Read one record's fields by resource key and id."
|
|
348
|
+
parameters({ "type" => "object", "required" => %w[resource id], "additionalProperties" => false,
|
|
349
|
+
"properties" => { "resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 } } })
|
|
350
|
+
|
|
351
|
+
def self.tool_name = "get_record"
|
|
352
|
+
|
|
353
|
+
private
|
|
354
|
+
|
|
355
|
+
def perform(tool_call:, resource:, id:)
|
|
356
|
+
resource = @session.resource(resource)
|
|
357
|
+
access = @session.access(resource)
|
|
358
|
+
raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
|
|
359
|
+
{ "resource" => resource.key.to_s, "records" => [ record_entry(access, access.find!(id), :show) ] }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Prepares a native intent and presents its review Surface. The human's own
|
|
364
|
+
# confirmation in that Surface is the only approval; this tool writes no
|
|
365
|
+
# business data and cannot confirm anything.
|
|
366
|
+
class ProposeOperation < Tool
|
|
367
|
+
description "Propose a change for the user to review. It does not change data: the user confirms or ignores the review shown in the conversation."
|
|
368
|
+
parameters({ "type" => "object", "required" => %w[resource id operation input], "additionalProperties" => false,
|
|
369
|
+
"properties" => {
|
|
370
|
+
"resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 },
|
|
371
|
+
"operation" => { "type" => "string", "description" => "An operation key from describe_resource" },
|
|
372
|
+
"input" => { "type" => "object", "description" => "Input values for the operation's declared fields" }
|
|
373
|
+
} })
|
|
374
|
+
|
|
375
|
+
def self.tool_name = "propose_operation"
|
|
376
|
+
|
|
377
|
+
private
|
|
378
|
+
|
|
379
|
+
def perform(tool_call:, resource:, id:, operation:, input:)
|
|
380
|
+
raise ConfigurationError, "Surfaces are disabled" unless RailsAiAdmin.configuration.surfaces
|
|
381
|
+
raise ConfigurationError, "A persisted tool call is required" unless tool_call.respond_to?(:id) && tool_call.id.is_a?(String)
|
|
382
|
+
raise AccessDenied, "INVALID_QUERY" unless input.is_a?(Hash) && operation.is_a?(String) && id.is_a?(String)
|
|
383
|
+
resource = @session.resource(resource)
|
|
384
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
|
|
385
|
+
raise AccessDenied unless definition.custom? && definition.scope == :member && resource.assistant_operation?(definition.key)
|
|
386
|
+
tool_views = Assistant.tool_views
|
|
387
|
+
replay = @session.chat && tool_views.assistant_surface(@session.context, @session.chat, tool_call.id)
|
|
388
|
+
return acknowledgment(replay) if replay
|
|
389
|
+
record = @session.access(resource).find!(id, action: definition.key)
|
|
390
|
+
proposal = Operations::Runner.new.preflight(context: @session.context, resource: resource, operation: definition.key,
|
|
391
|
+
target_id: record.id, expected_version: RecordVersion.of(record), input: input, origin: :assistant)
|
|
392
|
+
if proposal.is_a?(Operations::Result)
|
|
393
|
+
return failure(proposal.code == "VALIDATION_FAILED" ? "VALIDATION_FAILED" : proposal.code == "CONFLICT" ? "CONFLICT" : "UNAVAILABLE",
|
|
394
|
+
"fields" => proposal.errors.keys.map(&:to_s))
|
|
395
|
+
end
|
|
396
|
+
acknowledgment(present_review(tool_views, resource, definition, record, proposal, tool_call))
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Prepares a native create, update or delete intent and presents its
|
|
401
|
+
# review. Nothing changes until the human confirms that review; the write
|
|
402
|
+
# then runs as the human, with a version check, through the shared Runner.
|
|
403
|
+
class ProposeChange < Tool
|
|
404
|
+
ACTIONS = %w[create update destroy].freeze
|
|
405
|
+
# Values a model cannot sensibly supply: secrets and uploads.
|
|
406
|
+
UNSUPPORTED = %i[password file files].freeze
|
|
407
|
+
description "Propose creating, updating or deleting one record. It does not change data: the user reviews and confirms or " \
|
|
408
|
+
"dismisses it in the conversation. For update, send only the fields to change. describe_resource lists the allowed changes."
|
|
409
|
+
parameters({ "type" => "object", "required" => %w[resource action], "additionalProperties" => false,
|
|
410
|
+
"properties" => {
|
|
411
|
+
"resource" => { "type" => "string" },
|
|
412
|
+
"action" => { "type" => "string", "enum" => ACTIONS },
|
|
413
|
+
"id" => { "type" => "string", "maxLength" => 100, "description" => "The record id; required for update and delete" },
|
|
414
|
+
"values" => { "type" => "object", "description" => "Field names and new values, typed as describe_resource reports" }
|
|
415
|
+
} })
|
|
416
|
+
|
|
417
|
+
def self.tool_name = "propose_change"
|
|
418
|
+
|
|
419
|
+
private
|
|
420
|
+
|
|
421
|
+
def perform(tool_call:, resource:, action:, id: nil, values: {})
|
|
422
|
+
raise ConfigurationError, "Surfaces are disabled" unless RailsAiAdmin.configuration.surfaces
|
|
423
|
+
raise ConfigurationError, "A persisted tool call is required" unless tool_call.respond_to?(:id) && tool_call.id.is_a?(String)
|
|
424
|
+
raise AccessDenied, "INVALID_QUERY" unless ACTIONS.include?(action) && values.is_a?(Hash) && (id.nil? || id.is_a?(String))
|
|
425
|
+
raise AccessDenied, "INVALID_QUERY" if action == "create" ? !id.nil? : id.nil?
|
|
426
|
+
raise AccessDenied, "INVALID_QUERY" if action == "destroy" ? values.any? : (action == "update" && values.empty?)
|
|
427
|
+
resource = @session.resource(resource)
|
|
428
|
+
raise AccessDenied unless resource.assistant_change?(action)
|
|
429
|
+
tool_views = Assistant.tool_views
|
|
430
|
+
replay = @session.chat && tool_views.assistant_surface(@session.context, @session.chat, tool_call.id)
|
|
431
|
+
return acknowledgment(replay) if replay
|
|
432
|
+
access = @session.access(resource)
|
|
433
|
+
record = action == "create" ? access.relation.new : access.find!(id, action: action.to_sym)
|
|
434
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, action)
|
|
435
|
+
by_name = resource.fields.index_by { |field| field.name.to_s }
|
|
436
|
+
raise AccessDenied, "INVALID_QUERY" if values.keys.any? { |name| UNSUPPORTED.include?(by_name[name.to_s]&.scalar_type) }
|
|
437
|
+
proposal = Operations::Runner.new.preflight(context: @session.context, resource: resource, operation: action,
|
|
438
|
+
target_id: action == "create" ? nil : record.id, expected_version: action == "create" ? nil : RecordVersion.of(record),
|
|
439
|
+
input: values.transform_keys(&:to_s), origin: :assistant)
|
|
440
|
+
if proposal.is_a?(Operations::Result)
|
|
441
|
+
return failure(proposal.code == "VALIDATION_FAILED" ? "VALIDATION_FAILED" : proposal.code == "CONFLICT" ? "CONFLICT" : "UNAVAILABLE",
|
|
442
|
+
"fields" => proposal.errors.to_h.transform_values { |messages| Array(messages).first(3) })
|
|
443
|
+
end
|
|
444
|
+
acknowledgment(present_review(tool_views, resource, definition, record, proposal, tool_call))
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# A record's audited changes, behind the same view_history? grant as its
|
|
449
|
+
# History page. Field names follow the user's current grants.
|
|
450
|
+
class RecordHistory < Tool
|
|
451
|
+
LIMIT = 20
|
|
452
|
+
description "List the latest audited changes to one record: when, what kind, which fields and by whom. Values are never recorded."
|
|
453
|
+
parameters({ "type" => "object", "required" => %w[resource id], "additionalProperties" => false,
|
|
454
|
+
"properties" => { "resource" => { "type" => "string" }, "id" => { "type" => "string", "maxLength" => 100 } } })
|
|
455
|
+
|
|
456
|
+
def self.tool_name = "record_history"
|
|
457
|
+
|
|
458
|
+
private
|
|
459
|
+
|
|
460
|
+
def perform(tool_call:, resource:, id:)
|
|
461
|
+
resource = @session.resource(resource)
|
|
462
|
+
access = @session.access(resource)
|
|
463
|
+
raise AccessDenied unless id.is_a?(String) && id.match?(Field::REFERENCE)
|
|
464
|
+
record = access.find!(id)
|
|
465
|
+
page = Presentation::History.new(access, record).page
|
|
466
|
+
entries = page.entries.first(LIMIT).map do |entry|
|
|
467
|
+
{ "at" => entry.at.in_time_zone(time_zone).iso8601, "change" => entry.operation, "saved" => entry.status == "succeeded",
|
|
468
|
+
"outcome" => entry.code, "fields" => entry.fields, "other_fields" => entry.hidden_fields,
|
|
469
|
+
"by" => entry.actor_ref == @session.context.actor_ref ? "the current user" : entry.actor_ref, "via" => entry.origin }
|
|
470
|
+
end
|
|
471
|
+
{ "resource" => resource.key.to_s, "id" => record.id.to_s, "changes" => entries, "more" => page.entries.size > LIMIT || !page.older.nil? }
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
TOOLS = [ ListResources, DescribeResource, SearchRecords, CountRecords, GetRecord, RecordHistory ].freeze
|
|
476
|
+
end
|
|
477
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Assistant
|
|
7
|
+
# Display entries for one chat, rebuilt per request. Stored tool output is
|
|
8
|
+
# never shown as is: record references are re-read through current grants
|
|
9
|
+
# and Surfaces are restored through the admin scope and policy.
|
|
10
|
+
class Transcript
|
|
11
|
+
MESSAGE_LIMIT = 200
|
|
12
|
+
LINK_LIMIT = 20
|
|
13
|
+
Entry = ::Data.define(:kind, :text, :links, :surface_id, :at)
|
|
14
|
+
Link = ::Data.define(:resource_key, :id, :label)
|
|
15
|
+
ACTIVITY = {
|
|
16
|
+
"list_resources" => "Listed the available resources",
|
|
17
|
+
"describe_resource" => "Read the fields of %<resource>s",
|
|
18
|
+
"search_records" => "Searched %<resource>s",
|
|
19
|
+
"count_records" => "Counted records in %<resource>s",
|
|
20
|
+
"get_record" => "Opened a record in %<resource>s",
|
|
21
|
+
"propose_operation" => "Prepared a review in %<resource>s",
|
|
22
|
+
"propose_change" => "Prepared a review in %<resource>s",
|
|
23
|
+
"record_history" => "Read a record's history in %<resource>s"
|
|
24
|
+
}.freeze
|
|
25
|
+
# The line for a call whose stored result is an error (RAA-018).
|
|
26
|
+
FAILED_ACTIVITY = {
|
|
27
|
+
"list_resources" => "Tried to list the available resources",
|
|
28
|
+
"describe_resource" => "Tried to read the fields of %<resource>s",
|
|
29
|
+
"search_records" => "Tried to search %<resource>s",
|
|
30
|
+
"count_records" => "Tried to count records in %<resource>s",
|
|
31
|
+
"get_record" => "Tried to open a record in %<resource>s",
|
|
32
|
+
"propose_operation" => "Tried to prepare a review in %<resource>s",
|
|
33
|
+
"propose_change" => "Tried to prepare a review in %<resource>s",
|
|
34
|
+
"record_history" => "Tried to read a record's history in %<resource>s"
|
|
35
|
+
}.freeze
|
|
36
|
+
FAILURE_REASONS = {
|
|
37
|
+
"NOT_FOUND" => "not available", "INVALID_QUERY" => "the request was not valid", "CONFLICT" => "the record changed",
|
|
38
|
+
"VALIDATION_FAILED" => "the input was not valid", "UNAVAILABLE" => "not available"
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
def initialize(context, chat)
|
|
42
|
+
@context, @chat = context, chat
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def entries
|
|
46
|
+
continuation_ids = @chat.turns.where(kind: "continuation").where.not(user_message_id: nil).pluck(:user_message_id).to_set
|
|
47
|
+
messages = @chat.messages.where.not(role: "system").includes(:ruby_llm_tool_calls, :ruby_llm_parent_tool_call)
|
|
48
|
+
.reorder(id: :desc).limit(MESSAGE_LIMIT).to_a.reverse
|
|
49
|
+
@results = messages.select { |message| message.role == "tool" && message.ruby_llm_parent_tool_call }
|
|
50
|
+
.to_h { |message| [ message.ruby_llm_parent_tool_call.id, message.content ] }
|
|
51
|
+
persisted = messages.flat_map do |message|
|
|
52
|
+
case message.role
|
|
53
|
+
when "user"
|
|
54
|
+
[ entry(continuation_ids.include?(message.id) ? :continuation : :user, message.content, message) ]
|
|
55
|
+
when "assistant"
|
|
56
|
+
activity = message.ruby_llm_tool_calls.map { |call| entry(:activity, activity_text(call), message) }
|
|
57
|
+
message.content.present? ? activity + [ entry(:assistant, message.content, message) ] : activity
|
|
58
|
+
when "tool" then tool_entries(message)
|
|
59
|
+
else []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
persisted + waiting
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Saved turns whose human text the worker has not added to the chat yet,
|
|
68
|
+
# so a sent message shows at once instead of after the provider call.
|
|
69
|
+
def waiting
|
|
70
|
+
@chat.turns.where(status: Turn::ACTIVE, user_message_id: nil).reorder(:sequence).map do |turn|
|
|
71
|
+
Entry.new(turn.kind == "continuation" ? :continuation : :user, turn.content, [], nil, turn.created_at)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def entry(kind, text, message, links: [], surface_id: nil)
|
|
76
|
+
Entry.new(kind, text.to_s, links, surface_id, message.created_at)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Derived from the stored result, so a failed call never reads as done.
|
|
80
|
+
def activity_text(call)
|
|
81
|
+
return "Used a tool" unless ACTIVITY.key?(call.name)
|
|
82
|
+
resource = resource_label(call.arguments.is_a?(Hash) ? call.arguments["resource"] : nil)
|
|
83
|
+
code = result_error(call)
|
|
84
|
+
return format(ACTIVITY.fetch(call.name), resource: resource) unless code
|
|
85
|
+
"#{format(FAILED_ACTIVITY.fetch(call.name), resource: resource)} (#{FAILURE_REASONS.fetch(code, 'not available')})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def result_error(call)
|
|
89
|
+
data = JSON.parse(@results.fetch(call.id, nil).to_s)
|
|
90
|
+
data["error"].to_s if data.is_a?(Hash) && data["error"].is_a?(String)
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def resource_label(key)
|
|
96
|
+
resource = exposed(key)
|
|
97
|
+
resource ? resource.label : "a resource"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def exposed(key)
|
|
101
|
+
return unless key.is_a?(String)
|
|
102
|
+
resource = RailsAiAdmin.registry.fetch(key)
|
|
103
|
+
Authorization::Access.new(@context, resource).authorize_assistant!
|
|
104
|
+
resource
|
|
105
|
+
rescue AccessDenied, ConfigurationError, KeyError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def tool_entries(message)
|
|
110
|
+
data = JSON.parse(message.content.to_s)
|
|
111
|
+
return [] unless data.is_a?(Hash)
|
|
112
|
+
if data["surface"].is_a?(String) && %w[propose_operation propose_change].include?(message.ruby_llm_parent_tool_call&.name)
|
|
113
|
+
return [ entry(:surface, nil, message, surface_id: data["surface"]) ]
|
|
114
|
+
end
|
|
115
|
+
links = record_links(data["resource"], data["records"])
|
|
116
|
+
links.empty? ? [] : [ entry(:records, nil, message, links: links) ]
|
|
117
|
+
rescue JSON::ParserError
|
|
118
|
+
[]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Only records the actor can still see, labelled through current grants.
|
|
122
|
+
def record_links(key, records)
|
|
123
|
+
resource = exposed(key)
|
|
124
|
+
return [] unless resource && records.is_a?(Array)
|
|
125
|
+
ids = records.first(LINK_LIMIT).filter_map { |record| record["id"] if record.is_a?(Hash) && record["id"].is_a?(String) }
|
|
126
|
+
return [] if ids.empty?
|
|
127
|
+
access = Authorization::Access.new(@context, resource)
|
|
128
|
+
found = access.relation.where(resource.model_class.primary_key => ids).index_by { |record| record.id.to_s }
|
|
129
|
+
ids.filter_map do |id|
|
|
130
|
+
record = found[id]
|
|
131
|
+
next unless record
|
|
132
|
+
access.authorize!(:show, record)
|
|
133
|
+
Link.new(resource.key.to_s, id, access.record_label(record))
|
|
134
|
+
rescue AccessDenied
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Opt-in admin Chat on RubyLLM (ADR 0022). Loaded only when
|
|
5
|
+
# `config.assistant` is enabled, so conventional hosts never load RubyLLM.
|
|
6
|
+
module Assistant
|
|
7
|
+
class << self
|
|
8
|
+
def enabled? = RailsAiAdmin.configuration.assistant == true
|
|
9
|
+
|
|
10
|
+
# True when at least one registered resource is exposed to this actor.
|
|
11
|
+
def available?(context)
|
|
12
|
+
context.is_a?(Context) && RailsAiAdmin.registry.resources.any? do |resource|
|
|
13
|
+
Authorization::Access.new(context, resource).authorize_assistant!
|
|
14
|
+
rescue AccessDenied
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The host's per-request provider configuration. nil means the assistant
|
|
20
|
+
# is unavailable for this actor, which is a normal, recoverable state.
|
|
21
|
+
def llm_context(context)
|
|
22
|
+
adapter = RailsAiAdmin.configuration.assistant_adapter.constantize.new
|
|
23
|
+
raise ConfigurationError, "assistant_adapter must define llm_context(context:)" unless adapter.respond_to?(:llm_context)
|
|
24
|
+
value = adapter.llm_context(context: context)
|
|
25
|
+
return nil if value.nil?
|
|
26
|
+
raise ConfigurationError, "llm_context must return a RubyLLM::Context" unless value.is_a?(::RubyLLM::Context)
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create_chat!(context)
|
|
31
|
+
Chat.create!(ref: SecureRandom.uuid, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
|
|
32
|
+
security_version: context.security_version, model: RailsAiAdmin.configuration.assistant_model,
|
|
33
|
+
provider: RailsAiAdmin.configuration.assistant_provider)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_views
|
|
37
|
+
require "rails_ai_admin/integrations/tool_views"
|
|
38
|
+
Integrations::ToolViews
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
require "ruby_llm"
|
|
45
|
+
require_relative "assistant/records"
|
|
46
|
+
require_relative "assistant/tools"
|
|
47
|
+
require_relative "assistant/runtime"
|
|
48
|
+
require_relative "assistant/coordinator"
|
|
49
|
+
require_relative "assistant/continuation"
|
|
50
|
+
require_relative "assistant/transcript"
|