Class: Assistant::ShowcaseToolBuilder
- Inherits:
-
Object
- Object
- Assistant::ShowcaseToolBuilder
- 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.
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
descriptionas 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,026td) anduare the only
elements in real content that Rails' base list omits.img/srcare KEPT here, unlikeMARKDOWN_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 anyon*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. + %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_buttonsis 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 plusbutton_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
-
.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.
-
.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.
-
.never_published?(showcase) ⇒ Boolean
Has this showcase never been public?
state == 'draft'alone doesn't answer that:unarchivereturns an archived showcase to draft, so a page that was live for a year can be sitting in draft right now. -
.normalize_description(value) ⇒ String?
showcases.descriptionis a rich-text HTML column (as: :redactor4in CRM) rendered unescaped on the public page, so Markdown lands on warmlyyours.com verbatim —### Project Summary,**bold**and pipe-tables printed as literal characters. -
.summarize(showcase) ⇒ Object
Compact summary echoed back after a write so Sunny can confirm state.
- .tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
-
.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.
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.
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) = Array(kwargs[:enabled_buttons]).compact_blank invalid = - 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 .empty? attrs[:enabled_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).
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.
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.
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.(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>
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.}") { error: 'An unexpected error occurred. Re-check the values and try again.' }.to_json end |