Class: Assistant::BlogToolBuilder

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

Overview

Builds RubyLLM::Tool subclasses for blog post management.
Provides 7 tools:
CRUD: create_blog_post, update_blog_post, get_blog_post
Embed: insert_image, insert_video, insert_faqs, insert_product

Embed tools call the real Oembed providers and return rendered HTML that the
LLM can include verbatim in the solution field of create/update calls.

Usage (via ChatToolBuilder):
tools = Assistant::BlogToolBuilder.tools(audit_context: { user_id: 42 })

Constant Summary collapse

CRM_POST_URL =

URL for crm post.

"#{CRM_URL}/en-US/posts".freeze
STYLE_GUIDE =

Condensed style guide injected into tool descriptions so the LLM
follows blog conventions without needing a separate system prompt.

<<~GUIDE.freeze
  ## Blog HTML Style Guide (MUST follow)

  Server-enforced content rules are checked on save; see Blog::ContentRules for the
  canonical list. Violations return a content_rule_violations payload with the
  rule name so you can fix the specific issue.

  HEADINGS: Start at H2 (H1 is reserved for article title). Use H2 for main sections, H3 for subsections.

  ICONS: Use <i class="fa-sharp fa-solid fa-icon-name"></i>. Inline <svg> is rejected server-side.

  CALLOUTS (in place of Bootstrap alerts — alerts are rejected server-side):
    <section class="callout-info"><h4><i class="fa-sharp fa-solid fa-circle-info"></i> Title</h4><p>Content</p></section>
    Types: callout-info (fa-circle-info), callout-tip (fa-lightbulb), callout-warning (fa-triangle-exclamation),
           callout-danger (fa-circle-exclamation), callout-brand (fa-fire-flame-curved). Use sparingly (2-4 per article).

  QUICK FACTS / KEY TAKEAWAYS:
    <section class="callout-facts">
      <h5><i class="fa-sharp fa-solid fa-list-check"></i> Quick Facts</h5>
      <ul><li><strong>Label:</strong> One-sentence fact.</li></ul>
    </section>
    Max 8 items (server-enforced). No nested lists inside (server-enforced).

  FEATURE BOX (statistics/key facts):
    <figure class="blog-feature-box"><h5>Title</h5><p class="feature-stat">$0.50/day</p><p>Explanation</p></figure>
    Highlighted variant: add class "blog-feature-box--highlighted"

  SIDEBAR (checklists/quick reference):
    <aside class="blog-sidebar"><h5>Title</h5><ul class="checklist"><li>Item</li></ul></aside>
    Float: blog-sidebar--left or blog-sidebar--right

  TABLES: class="table" (optionally table-striped, table-bordered). Standard <thead>/<tbody>.

  LINKS — INTERNAL (WarmlyYours):
    Use /{{locale}}/... (compact placeholder, no spaces). Hard-coded en-US / en-CA,
    spaced {{ locale }}, and missing-locale internal links are rejected server-side.
      CORRECT: <a href="/{{locale}}/floor-heating/bathroom">...</a>

    DISCOVERING SITE PAGES: use find_link_opportunities(topic:) for topical pages,
    get_page_link_profile(path:) for a specific path. Do NOT call semantic_search —
    not available in blog_management tools.

  LINKS — EXTERNAL: target="_blank" rel="noopener" (missing rel is rejected server-side).

  PARAGRAPHS: 2-4 sentences. <br> and inline style= attributes are rejected server-side.

  FAQ SECTIONS (inline Q&A in the blog body):
    FAQ answers are validated by the :faq_answer rule scope —
    single paragraph, no nested headings/lists, no widgets/calculators, max 1,200 chars.
    Target #{ArticleFaq::FAQ_TARGET_WORDS.first}-#{ArticleFaq::FAQ_TARGET_WORDS.last} words (2-3 sentences) for citability.
    If the topic needs a longer treatment, write it as a regular article section instead.

  LISTS: <ul>/<ol> with <li>. Max 2 nesting levels.

  PREVIEW IMAGE: Before creating a post, call find_images to search for a suitable featured/preview image.
  Pass the returned id as preview_image_id. This is the post's hero/thumbnail image — distinct from
  images embedded in the body. Use find_images (not insert_image) to discover image IDs.

  IMAGES/VIDEOS/FAQS/PRODUCTS (body embeds): Call insert_image, insert_video, insert_faqs, or insert_product
  to get the rendered HTML, then paste the `html` field VERBATIM into the solution at the desired location.
  Use find_faqs to discover FAQ IDs, find_images for image IDs, and list_blog_posts for post slugs.
  CRITICAL for images: insert_image is the ONLY correct source of image embed HTML.
    - NEVER construct a <figure> manually.
    - NEVER use public_url, raw_img_tag, or html_tag from find_images as a blog embed.
    - Copy the `html` returned by insert_image exactly — do not modify it.

  LIQUID TAGS (registered, use these in blog content):
    {% floor_heating_calculator %}       → interactive floor heating operating cost calculator
    {% snow_melting_calculator %}        → interactive snow melting operating cost calculator
    {% electricity_cost watts:1000 %}    → inline locale-aware operating cost (e.g. "$0.16/hr")
      Options: period:hour (default), period:day, period:month
               hours_per_day:8 (default, used for day/month)
               state:CA or province:ON  (override locale rate with regional rate)
      Examples:
        {% electricity_cost watts:500 %}                     → "$0.08/hr"
        {% electricity_cost watts:1000 period:month %}       → "$11.69/month"
        {% electricity_cost watts:1000 state:CA %}           → "$0.31/hr"
    {% partial 'press' %}                → press-release boilerplate (do NOT generate this)
    {% if canada %}…{% else %}…{% endif %} → locale conditional; USE THIS for CA/US price differences

  LIQUID VARIABLES (always available in blog content):
    {{ locale }}                          → current locale string (en-US, en-CA, etc.)
    {{ electricity_rate }}                → locale-aware $/kWh rate as a float (e.g. 0.163)
    {{ canada }} / {{ usa }}              → boolean locale conditionals
    Inline cost math (uses existing currency filter):
      {{ 1000 | electricity_cost_per_hour: electricity_rate | currency }}  → "$0.16"
      Running a 1,000W mat costs {{ 1000 | electricity_cost_per_hour: electricity_rate | currency }}/hr.

  NOTE — FAQs and videos: Use the insert_faqs and insert_video tools instead of Liquid tags.
    The {% faq %} tag has been removed; always use insert_faqs to embed FAQ sections.
    The {% cloudflare_video %} tag is legacy (1 old post); use insert_video for all new content.

  EDITING POSTS:
    get_blog_post returns a block_index (one entry per top-level block, with a
    stable b_xxxxxxxx data-block-id plus preview text) and editing_hint.
    - Structural edits (replace/delete/move/insert blocks) → use edit_blog_post
      with block_id ops. This is Kadous's "edit trick" adapted for HTML —
      address by ID, never by string match.
    - Full-body rewrites of small posts (< 8,000 chars) → use update_blog_post.
    edit_blog_post applies ordered block-ID operations to the LIVE content
    server-side — you only output the changed blocks, never the full body.

  POST VARIABLE (Liquid::PostDrop — always available in blog content):
    {{ post.subject }}            → article title (H1)
    {{ post.slug }}               → URL slug
    {{ post.url }}                → full URL with {{ locale }} placeholder
    {{ post.tags }}               → array of tag slugs
    {{ post.primary_tag }}        → first tag
    {{ post.author }}             → author full name
    {{ post.reading_time_minutes }}  → estimated reading time
    {{ post.published_at }}       → ISO-8601 publish date
    Conditionals: {% if post.tags contains "snow-melting" %}…{% endif %}
GUIDE

Class Method Summary collapse

Class Method Details

.assign_tags(post, tag_names, replace: false) ⇒ Object

Assign tags to a post, resolving names against existing Tag records only
(will never create new tags — unrecognized names are silently skipped).

Parameters:

  • post (Post)

    The persisted post to tag

  • tag_names (Array<String>)

    Tag names to assign

  • replace (Boolean) (defaults to: false)

    When true, clears all existing tags first
    so the result is exactly the supplied list.
    When false (default), only adds missing tags.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'app/services/assistant/blog_tool_builder.rb', line 144

def assign_tags(post, tag_names, replace: false)
  return unless post.persisted?

  Tagging.delete_by(taggable_id: post.id, taggable_type: post.taggable_type_for_tagging) if replace

  return if tag_names.blank?

  tag_names.each do |name|
    normalized = name.to_s.strip.downcase
    tag = Tag.find_by(name: normalized) ||
          Tag.find_by(slug: normalized.parameterize)
    next unless tag

    Tagging.create!(tag: tag, taggable_id: post.id, taggable_type: post.taggable_type_for_tagging) unless Tagging.exists?(tag: tag, taggable_id: post.id, taggable_type: post.taggable_type_for_tagging)
  rescue StandardError => e
    Rails.logger.warn("[BlogToolBuilder] Failed to assign tag '#{name}' to post ##{post.id}: #{e.message}")
  end
end

.build_faq_widget_data(faq_assets) ⇒ Object



273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'app/services/assistant/blog_tool_builder.rb', line 273

def build_faq_widget_data(faq_assets)
  faq_assets.group_by(&:uuid).map do |widget_uuid, records|
    faq_articles = records.map do |embedded_asset|
      faq_article = embedded_asset.asset
      { faq_id: embedded_asset.asset_id, subject: faq_article&.subject }
    end
    {
      widget_uuid: widget_uuid,
      title:       records.first.title,
      faq_count:   records.size,
      faqs:        faq_articles
    }
  end
end

.ensure_block_ids!(post) ⇒ Object

Lazy backfill of data-block-id markers on save. Only writes if the
serialized HTML actually changed (some posts have no addressable
top-level blocks, in which case assign_ids! is a no-op).



219
220
221
222
223
224
225
226
227
228
229
230
# File 'app/services/assistant/blog_tool_builder.rb', line 219

def ensure_block_ids!(post)
  original = post.solution.to_s
  return if original.empty?

  with_ids = Assistant::BlockAddressedEditor.assign_ids!(original)
  return if with_ids == original

  post.update_columns(solution: with_ids)
  post.reload
rescue StandardError => e
  Rails.logger.warn("[BlogToolBuilder] ensure_block_ids! failed for post ##{post.id}: #{e.message}")
end

.find_post_or_error(id_or_ref) ⇒ Array(Post, nil), Array(nil, String)

Look up a post by numeric ID, slug, or full blog URL.

Integer IDs keep the original behavior; anything non-numeric is treated
as a slug/URL and resolved through FriendlyId, which also matches a
post's historical slugs. This lets tools accept a pasted blog link
even when the post's current title no longer matches its URL slug (the
slug and title diverge whenever a post is retitled after publication).

Parameters:

  • id_or_ref (Integer, String)

    a post ID, a slug, or a blog URL

Returns:

  • (Array(Post, nil), Array(nil, String))

    [post, nil] or [nil, error_json]



242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# File 'app/services/assistant/blog_tool_builder.rb', line 242

def find_post_or_error(id_or_ref)
  # A blog URL's identifier is its last path segment (sans query/anchor).
  token = id_or_ref.to_s.split(/[?#]/, 2).first.to_s.split('/').compact_blank.last.to_s.strip

  post =
    if token.match?(/\A\d+\z/)
      Post.find_by(id: token)
    else
      Post.slug_find(token)
    end
  return [nil, { error: "Post #{id_or_ref.inspect} not found" }.to_json] unless post

  [post, nil]
rescue ActiveRecord::RecordNotFound
  [nil, { error: "Post #{id_or_ref.inspect} not found" }.to_json]
end

.guard_content(body_args: nil, ref_args: {}) ⇒ Object

Validate solution body and asset references in one call.
Returns an error JSON string on failure, nil on success.



261
262
263
264
265
266
267
268
269
270
271
# File 'app/services/assistant/blog_tool_builder.rb', line 261

def guard_content(body_args: nil, ref_args: {})
  if body_args
    body_err = Assistant::BlogContentValidator.guard_solution_body!(**body_args)
    return body_err if body_err
  end

  ref_err = Assistant::BlogContentValidator.validate_post_asset_references(**ref_args)
  return ref_err if ref_err

  nil
end

.purge_edge_cache_after_edit!(post) ⇒ void

This method returns an undefined value.

Purge the Cloudflare edge cache after an assistant edit to a PUBLISHED
post. Without this the DB is updated but www keeps serving the old HTML
indefinitely (until some unrelated purge path happens to run), and the
assistant — seeing a saved post and a stale page — invents "unpublished
draft revision" explanations for the user.

Published-only by design: Post#purge_edge_cache routes through
insert_in_sitemap -> Sitemap::SitemapGenerator#generate_single_post,
which itself opens with return unless post.published?. A draft would
therefore collect zero SiteMap rows and enqueue nothing, so the guard
here is about intent (and skipping a pointless sitemap write), not
about preventing a bad purge. Both locales are covered: that generator
writes one SiteMap row per LocaleUtility.service_locales (en-US, en-CA)
and Article#purge_edge_cache purges site_maps.map(&:url).

ORDERING: call this AFTER stabilize_post_solution_after_save!, which
mutates post.solution again (EmbeddedAssetSyncService + the
ensure_block_ids! update_columns). Purging before it lets the edge
refill with intermediate HTML that is immediately superseded.

A purge failure must not fail the tool call: the edit itself already
committed, and reporting failure on a saved edit is the exact confusion
this method exists to remove. Log and move on.

Parameters:



207
208
209
210
211
212
213
214
# File 'app/services/assistant/blog_tool_builder.rb', line 207

def purge_edge_cache_after_edit!(post)
  return unless post.respond_to?(:purge_edge_cache)
  return unless post.published?

  post.purge_edge_cache
rescue StandardError => e
  Rails.logger.warn("[BlogToolBuilder] edge-cache purge failed for post ##{post.id}: #{e.message}")
end

.stabilize_post_solution_after_save!(post) ⇒ Post

Runs Posts::EmbeddedAssetSyncService synchronously after an assistant save so UUID
backfill on embeds is visible to get_blog_post immediately. Mirrors the first step of
PostContentUpdatedHandler (which also runs async — this avoids racing the next patch).

Stage 2 of the Sunny blog editor fix plan: also assigns stable
data-block-id attributes via Assistant::BlockAddressedEditor so
subsequent edit_blog_post / get_blog_post calls operate on
addressable blocks. Idempotent — existing valid IDs are preserved.

Returns:

  • (Post)

    reloaded post



173
174
175
176
177
178
179
# File 'app/services/assistant/blog_tool_builder.rb', line 173

def stabilize_post_solution_after_save!(post)
  post.reload
  Posts::EmbeddedAssetSyncService.new(post).call
  post.reload
  ensure_block_ids!(post)
  post
end

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

Build all blog management tools.

Parameters:

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

    must include :user_id (Employee ID for revisions)

Returns:

  • (Array<RubyLLM::Tool>)


296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'app/services/assistant/blog_tool_builder.rb', line 296

def tools(audit_context: {})
  [
    build_list_blog_posts_tool,
    build_list_blog_tags_tool,
    build_create_blog_post_tool(audit_context),
    build_update_blog_post_tool(audit_context),
    build_edit_blog_post_tool(audit_context),
    build_get_blog_post_tool,
    build_get_block_html_tool,
    *BlogMediaToolBuilder.tools(audit_context: audit_context),
    *BlogLinkToolBuilder.tools
  ]
end