Class: Assistant::ShowcaseToolBuilder

Inherits:
Object
  • Object
show all
Defined in:
app/services/assistant/showcase_tool_builder.rb

Overview

Builds the RubyLLM write tools for the showcase_management service —
letting Sunny edit an existing project Showcase's fields, linked images,
and FAQs. Reads go through the separate get_showcase content tool
(app/mcp/tools/get_showcase_tool.rb); this builder is writes only.

Audit trail is handled ambiently: Showcase includes Models::Auditable, and
ChatService wraps the whole tool loop in
PaperTrail.request(whodunnit:) { CurrentScope.with_user_id(sender_id) { … } },
so a plain showcase.update! stamps creator/updater and writes a versioned
record attributed to the Sunny user + conversation. No manual whodunnit here.

Examples:

tools = Assistant::ShowcaseToolBuilder.tools(audit_context: { user_id: 42 })

Constant Summary collapse

SCALAR_FIELDS =

Scalar columns that map straight to update!(field: value).

%w[
  name description short_description seo_title seo_description
  city state_code project_type customer_id
  room_configuration_id floor_type_id
  room_size wattage volts amps connection_type surface breaker_size
  operating_cost operating_cost_explanation
].freeze
ARRAY_FIELDS =

Postgres array columns — assigned a Ruby array, blanks stripped.

%w[
  product_line_ids post_ids item_ids quote_ids
  room_types flooring_surface_types
].freeze
HTML_BLOCK_TAG =

Block-level HTML that marks a description as already-rendered markup.
Anything without one of these is treated as Markdown and converted.

%r{<(?:p|div|h[1-6]|ul|ol|table|br|img|section|article|blockquote)[\s>/]}i
SHOWCASE_ALLOWED_TAGS =

Rails' safe list plus what showcase stories actually use. Derived from all
326 production descriptions: table markup (4,026 td) and u are the only
elements in real content that Rails' base list omits.

img/src are KEPT here, unlike MARKDOWN_ALLOWED_TAGS — that list drops
them because publication text comes from third-party PDFs where a remote
image would leak a reader's IP. A showcase story is our own content served
off our own CDN, so the rationale doesn't carry over.

Neither list admits script, style, or any on* handler, which is the
point: this text is written by an LLM and rendered with <%== on a public
page (app/views/shared/_showcase.html.erb:191).

(
  Rails::HTML5::SafeListSanitizer.allowed_tags + %w[table thead tbody tfoot tr th td u]
).freeze
SHOWCASE_ALLOWED_ATTRIBUTES =
(
  Rails::HTML5::SafeListSanitizer.allowed_attributes + %w[colspan rowspan align]
).freeze
DEFAULT_ENABLED_BUTTONS =

enabled_buttons is presence-validated, so a create that omits it fails.
This trio is the floor-heating default — 146 of 326 production showcases
carry exactly it, and another 101 carry it plus button_floor_heating.
Snow-melting projects want a different set and must pass one explicitly.

%w[
  button_design_room button_floor_heating_quote button_customize_floor_plan
].freeze

Class Method Summary collapse

Class Method Details

.build_attributes(kwargs) ⇒ Array(Hash, nil), Array(nil, String)

Shared by create and update so both get the same coercion, the same
enabled_buttons guard, and — the reason this is extracted — the same
Markdown-to-HTML normalization. A second attribute path is how the
Markdown bug would come back on the create side.

Parameters:

  • kwargs (Hash)

    tool arguments, already symbol-keyed; any
    SCALAR_FIELDS or ARRAY_FIELDS key is copied through

Options Hash (kwargs):

  • :description (String)

    Markdown description, normalized to HTML

  • :enabled_buttons (Array<String>)

    subset of Showcase::AVAILABLE_BUTTONS; must be non-empty

  • :tags (Array<String>)

    tag names

Returns:

  • (Array(Hash, nil), Array(nil, String))

    [attrs, nil] or [nil, error_json]



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'app/services/assistant/showcase_tool_builder.rb', line 140

def build_attributes(kwargs)
  attrs = {}
  SCALAR_FIELDS.each do |f|
    key = f.to_sym
    attrs[key] = kwargs[key] if kwargs.key?(key)
  end
  ARRAY_FIELDS.each do |f|
    key = f.to_sym
    attrs[key] = Array(kwargs[key]).compact_blank.uniq if kwargs.key?(key)
  end

  attrs[:description] = normalize_description(attrs[:description]) if attrs.key?(:description)

  if kwargs.key?(:enabled_buttons)
    buttons = Array(kwargs[:enabled_buttons]).compact_blank
    invalid = buttons - Showcase::AVAILABLE_BUTTONS
    if invalid.any?
      return [nil, { error: "Invalid enabled_buttons: #{invalid.join(', ')}. " \
                            "Allowed: #{Showcase::AVAILABLE_BUTTONS.join(', ')}" }.to_json]
    end
    # enabled_buttons is presence-validated — refuse to blank it out.
    return [nil, { error: 'enabled_buttons cannot be empty.' }.to_json] if buttons.empty?

    attrs[:enabled_buttons] = buttons
  end

  attrs[:tags] = Array(kwargs[:tags]).compact_blank if kwargs.key?(:tags)

  [attrs, nil]
end

.find_showcase_or_error(id_or_url) ⇒ Array(Showcase, nil), Array(nil, String)

Resolve a showcase by numeric ID, custom_slug, or full showcase URL.
Mirrors get_showcase / find_post_or_error: take the last path segment
and resolve via FriendlyId (slug-first, primary-key fallback).

Returns:

  • (Array(Showcase, nil), Array(nil, String))

    [showcase, nil] or [nil, error_json]



84
85
86
87
88
89
90
91
# File 'app/services/assistant/showcase_tool_builder.rb', line 84

def find_showcase_or_error(id_or_url)
  token = id_or_url.to_s.split(/[?#]/, 2).first.to_s.split('/').compact_blank.last.to_s.strip
  return [nil, { error: 'Provide a showcase ID, slug, or URL.' }.to_json] if token.blank?

  [Showcase.friendly.find(token), nil]
rescue ActiveRecord::RecordNotFound
  [nil, { error: "Showcase #{id_or_url.inspect} not found" }.to_json]
end

.never_published?(showcase) ⇒ Boolean

Has this showcase never been public? state == 'draft' alone doesn't
answer that: unarchive returns an archived showcase to draft, so a page
that was live for a year can be sitting in draft right now.

SiteMap rows are the durable evidence. One is written per locale when a
showcase is published (Sitemap::SitemapGenerator#generate_single_showcase),
and unpublishing archives rather than deletes them — deliberately, to
preserve accumulated SEO data (ShowcaseUnpublishedHandler). So a row in
any state means "this had a public URL once", which is exactly the
question, and it's one indexed query on the primary.

Parameters:

Returns:

  • (Boolean)


184
185
186
# File 'app/services/assistant/showcase_tool_builder.rb', line 184

def never_published?(showcase)
  showcase.draft? && !showcase.site_maps.exists?
end

.normalize_description(value) ⇒ String?

showcases.description is a rich-text HTML column (as: :redactor4 in
CRM) rendered unescaped on the public page, so Markdown lands on
warmlyyours.com verbatim — ### Project Summary, **bold** and
pipe-tables printed as literal characters. Sunny reaches for Markdown
because the brain rules ask for "structured Markdown tables" for AEO,
and the schema alone won't hold it: the same tool wrote clean HTML from
the chat loop on 2026-07-28 and Markdown from a plan sub-agent on
2026-08-03. Convert rather than reject — a hard error costs the user a
regenerate cycle to fix formatting we can fix ourselves.

BOTH branches are sanitized. The model's "HTML" is no more trustworthy
than its Markdown — it can hallucinate a <script> or an onerror=, or
be talked into one by injected content it read earlier in the
conversation, and this lands in <%== on a public page.

ponytail: a tag sniff, not a parse. Content mixing HTML blocks with a
stray Markdown table stays mixed — parse properly if that shows up.

Parameters:

  • value (String, nil)

Returns:

  • (String, nil)

    sanitized HTML



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/services/assistant/showcase_tool_builder.rb', line 113

def normalize_description(value)
  text = value.to_s
  return value if text.blank?

  helpers = ApplicationController.helpers
  html = if text.match?(HTML_BLOCK_TAG)
           helpers.sanitize(text, tags: SHOWCASE_ALLOWED_TAGS, attributes: SHOWCASE_ALLOWED_ATTRIBUTES).to_s
         else
           helpers.markdown_to_html(text, tags: SHOWCASE_ALLOWED_TAGS, attributes: SHOWCASE_ALLOWED_ATTRIBUTES).to_s
         end
  # Kramdown (and the model itself) emit bare <table>, which Bootstrap 5
  # leaves unstyled. Class it at the source so the stored HTML is what
  # the page renders — existing rows were backfilled the same way.
  helpers.bootstrap_bare_tables(html)
end

.summarize(showcase) ⇒ Object

Compact summary echoed back after a write so Sunny can confirm state.



197
198
199
200
201
202
203
204
205
206
207
208
# File 'app/services/assistant/showcase_tool_builder.rb', line 197

def summarize(showcase)
  {
    id: showcase.id,
    state: showcase.state,
    name: showcase.name,
    slug: showcase.custom_slug,
    url: "#{WEB_URL}/showcases/#{showcase.custom_slug}",
    product_line_ids: showcase.product_line_ids,
    image_ids: showcase.showcase_digital_assets.order(:position).pluck(:digital_asset_id),
    faq_count: showcase.faqs.size
  }
end

.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>

Parameters:

  • audit_context (Hash) (defaults to: {})

    accepted for symmetry with BlogToolBuilder;
    unused because Showcase auditing is ambient (see class docs).

Returns:

  • (Array<RubyLLM::Tool>)


68
69
70
71
72
73
74
75
76
77
# File 'app/services/assistant/showcase_tool_builder.rb', line 68

def tools(audit_context: {})
  _ = audit_context
  [
    build_create_showcase_tool,
    build_update_showcase_tool,
    build_delete_showcase_tool,
    build_set_showcase_images_tool,
    build_set_showcase_faqs_tool
  ]
end

.unexpected_error(exception, context) ⇒ Object

Generic error for an unexpected exception — the real message is logged
server-side, not returned, since tool output is sent to the LLM provider
and raw exception text can leak SQL fragments / constraint names.



191
192
193
194
# File 'app/services/assistant/showcase_tool_builder.rb', line 191

def unexpected_error(exception, context)
  Rails.logger.error("[ShowcaseToolBuilder] #{context} failed: #{exception.class}: #{exception.message}")
  { error: 'An unexpected error occurred. Re-check the values and try again.' }.to_json
end