0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 00:50:34 +08:00
discourse/plugins/discourse-data-explorer/plugin.rb
Natalie Tay 5fd5946b1c
FEATURE: Add query examples for Data Explorer AI (#41288)
Data Explorer's AI query generator produces valid SQL while still
missing the intent behind community/reporting prompts. Existing schema
lookup tells the agent what columns exist, but not how Discourse queries
are usually written or which defaults matter for public activity,
regular members, PMs, staged users, and restricted categories.

This PR adds a `find_queries` tool that lets the agent look up visible
saved queries and bundled defaults before writing SQL. Those queries are
used as examples for patterns, joins, params, and filters, while the
agent still validates the generated SQL with `run_sql`.

The prompt and tool flow are also tightened so the agent submits the
exact SQL that passed validation and handles Data Explorer params more
reliably, especially plural optional filters.

```text
User prompt in Data Explorer
  "active users from jan to mar?"
        |
        v
AiQueryGenerator starts with system prompt
        |
        v
LLM call #1
  decides to call find_queries
        |
        v
find_queries(search: "active users")
  searches visible saved queries + bundled default queries
  returns matching examples with:
  - name / description
  - params
  - truncated SQL
        |
        v
LLM call #2
  uses examples to choose relevant tables
        |
        v
schema(tables: "users,posts,user_visits")
  returns real DB columns for requested tables
        |
        v
LLM call #3
  writes SQL using schema + examples
        |
        v
run_sql(sql: "...")
  runs the generated query
  returns rows/columns or errors
        |
        v
LLM call #4
  sees run_sql success
        |
        v
submit_query(name:, description:, sql:)
  submits the exact SQL from the successful run_sql call
        |
        v
Data Explorer receives final generated query
```
2026-07-01 15:07:28 +08:00

222 lines
7.9 KiB
Ruby
Vendored

# frozen_string_literal: true
# name: discourse-data-explorer
# about: Allows you to make SQL queries against your live database, allowing for up-to-the-minute stats reporting.
# meta_topic_id: 32566
# version: 0.3
# authors: Riking
# url: https://github.com/discourse/discourse/tree/main/plugins/discourse-data-explorer
enabled_site_setting :data_explorer_enabled
register_asset "stylesheets/explorer.scss"
register_svg_icon "angle-down"
register_svg_icon "angle-right"
register_svg_icon "chart-line"
register_svg_icon "angle-left"
register_svg_icon "circle-exclamation"
register_svg_icon "info"
register_svg_icon "pencil"
register_svg_icon "upload"
add_admin_route "explorer.title", "discourse-data-explorer", use_new_show_route: true
module ::DiscourseDataExplorer
PLUGIN_NAME = "discourse-data-explorer"
# This should always match the max value for the
# data_explorer_query_result_limit site setting
QUERY_RESULT_MAX_LIMIT = 10_000
end
require_relative "lib/discourse_data_explorer/engine"
after_initialize do
GlobalSetting.add_default(:max_data_explorer_api_reqs_per_10_seconds, 2)
# Available options:
# - warn
# - warn+block
# - block
GlobalSetting.add_default(:max_data_explorer_api_req_mode, "warn")
if respond_to?(:register_discourse_workflows_node)
register_svg_icon "database"
register_discourse_workflows_node do
require_relative "lib/discourse_data_explorer/workflows/sql_action/v1"
DiscourseDataExplorer::Workflows::SqlAction::V1
end
end
add_to_class(:guardian, :user_is_a_member_of_group?) do |group|
return false if !current_user
return true if current_user.admin?
current_user.group_ids.include?(group.id)
end
add_to_class(:guardian, :user_can_access_query?) do |query|
return false if !current_user
return true if current_user.admin?
query.groups.any? { |group| user_is_a_member_of_group?(group) }
end
add_to_class(:guardian, :group_and_user_can_access_query?) do |group, query|
return false if !current_user
return true if current_user.admin?
user_is_a_member_of_group?(group) && query.groups.exists?(id: group.id)
end
add_to_serializer(
:group_show,
:has_visible_data_explorer_queries,
include_condition: -> { scope.user_is_a_member_of_group?(object) },
) { DiscourseDataExplorer::Query.for_group(object).exists? }
register_bookmarkable(DiscourseDataExplorer::QueryGroupBookmarkable)
register_admin_dashboard_report_source(DiscourseDataExplorer::AdminDashboardReportProvider)
add_api_key_scope(
:data_explorer,
{
run_queries: {
actions: %w[discourse_data_explorer/query#run discourse_data_explorer/query#public_run],
params: %i[id],
},
},
)
reloadable_patch do
if defined?(DiscourseAutomation)
add_automation_scriptable("recurring_data_explorer_result_pm") do
queries =
DiscourseDataExplorer::Query
.where(hidden: false)
.map { |q| { id: q.id, translated_name: q.name } }
field :recipients, component: :email_group_user, required: true
field :query_id, component: :choices, required: true, extra: { content: queries }
field :query_params, component: :"key-value", accepts_placeholders: true
field :skip_empty, component: :boolean
field :users_from_group, component: :boolean
field :attach_csv,
component: :boolean,
validator: ->(attach_csv) do
return if !attach_csv
extensions = SiteSetting.authorized_extensions.split("|")
if (extensions & %w[csv *]).empty?
I18n.t(
"discourse_automation.scriptables.recurring_data_explorer_result_pm.no_csv_allowed",
)
end
end
version 1
triggerables [:recurring]
script do |_, fields, automation|
recipients = Array(fields.dig("recipients", "value")).uniq
query_id = fields.dig("query_id", "value")
query_params = fields.dig("query_params", "value") || {}
skip_empty = fields.dig("skip_empty", "value") || false
users_from_group = fields.dig("users_from_group", "value") || false
attach_csv = fields.dig("attach_csv", "value") || false
unless SiteSetting.data_explorer_enabled
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - plugin must be enabled to run automation #{automation.id}"
next
end
if recipients.blank?
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - couldn't find any recipients for automation #{automation.id}"
next
end
DiscourseDataExplorer::ReportGenerator
.generate(
query_id,
query_params,
recipients,
{ skip_empty:, users_from_group:, attach_csv:, render_url_columns: true },
)
.each do |pm|
utils.send_pm(pm, automation_id: automation.id)
rescue ActiveRecord::RecordNotSaved => e
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - couldn't send PM for automation #{automation.id}: #{e.message}"
end
end
end
add_automation_scriptable("recurring_data_explorer_result_topic") do
queries =
DiscourseDataExplorer::Query
.where(hidden: false)
.map { |q| { id: q.id, translated_name: q.name } }
field :topic_id, component: :text, required: true
field :query_id, component: :choices, required: true, extra: { content: queries }
field :query_params, component: :"key-value", accepts_placeholders: true
field :skip_empty, component: :boolean
field :attach_csv, component: :boolean
version 1
triggerables [:recurring]
script do |_, fields, automation|
topic_id = fields.dig("topic_id", "value")
query_id = fields.dig("query_id", "value")
query_params = fields.dig("query_params", "value") || {}
skip_empty = fields.dig("skip_empty", "value") || false
attach_csv = fields.dig("attach_csv", "value") || false
unless SiteSetting.data_explorer_enabled
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - plugin must be enabled to run automation #{automation.id}"
next
end
topic = Topic.find_by(id: topic_id)
if topic.blank?
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - couldn't find topic ID (#{topic_id}) for automation #{automation.id}"
next
end
begin
post =
DiscourseDataExplorer::ReportGenerator.generate_post(
query_id,
query_params,
{ skip_empty:, attach_csv:, render_url_columns: true },
)
next if post.empty?
PostCreator.create!(
Discourse.system_user,
topic_id: topic.id,
raw: post["raw"],
skip_validations: true,
)
rescue ActiveRecord::RecordNotSaved => e
Rails.logger.warn "#{DiscourseDataExplorer::PLUGIN_NAME} - couldn't reply to topic ID #{topic_id} for automation #{automation.id}: #{e.message}"
end
end
end
end
end
if defined?(DiscourseAi)
require_relative "lib/discourse_data_explorer/ai_query_params"
require_relative "lib/discourse_data_explorer/tools/find_queries"
require_relative "lib/discourse_data_explorer/tools/run_sql"
require_relative "lib/discourse_data_explorer/tools/submit_query"
require_relative "lib/discourse_data_explorer/ai_query_generator"
DiscourseAi.register_feature(
module_name: :data_explorer,
feature: :query_generation,
agent_klass: DiscourseDataExplorer::AiQueryGenerator,
enabled_by_setting: "data_explorer_ai_queries_enabled",
plugin: self,
)
end
end