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 =
"#{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
  ## 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-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-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-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.



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

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

  if replace
    Tagging.where(taggable_id: post.id, taggable_type: post.taggable_type_for_tagging).delete_all
  end

  return if tag_names.blank?

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

    unless Tagging.exists?(tag: tag, taggable_id: post.id, taggable_type: post.taggable_type_for_tagging)
      Tagging.create!(tag: tag, taggable_id: post.id, taggable_type: post.taggable_type_for_tagging)
    end
  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



222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'app/services/assistant/blog_tool_builder.rb', line 222

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).



187
188
189
190
191
192
193
194
195
196
197
198
# File 'app/services/assistant/blog_tool_builder.rb', line 187

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(post_id) ⇒ Object

Look up a post by ID, returning [post, nil] or [nil, error_json].



201
202
203
204
205
206
# File 'app/services/assistant/blog_tool_builder.rb', line 201

def find_post_or_error(post_id)
  post = Post.find_by(id: post_id)
  return [nil, { error: "Post ##{post_id} not found" }.to_json] unless post

  [post, nil]
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.



210
211
212
213
214
215
216
217
218
219
220
# File 'app/services/assistant/blog_tool_builder.rb', line 210

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

.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



176
177
178
179
180
181
182
# File 'app/services/assistant/blog_tool_builder.rb', line 176

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>)


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

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,
    *BlogMediaToolBuilder.tools(audit_context: audit_context),
    *BlogLinkToolBuilder.tools
  ]
end