Class: Assistant::BlogToolBuilder
- Inherits:
-
Object
- Object
- Assistant::BlogToolBuilder
- 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
-
.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).
- .build_faq_widget_data(faq_assets) ⇒ Object
-
.ensure_block_ids!(post) ⇒ Object
Lazy backfill of data-block-id markers on save.
-
.find_post_or_error(post_id) ⇒ Object
Look up a post by ID, returning [post, nil] or [nil, error_json].
-
.guard_content(body_args: nil, ref_args: {}) ⇒ Object
Validate solution body and asset references in one call.
-
.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.
-
.tools(audit_context: {}) ⇒ Array<RubyLLM::Tool>
Build all blog management tools.
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).
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 (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.}") 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 (faq_assets) faq_assets.group_by(&:uuid).map do |, records| faq_articles = records.map do || faq_article = .asset { faq_id: .asset_id, subject: faq_article&.subject } end { 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.}") 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.
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.
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_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 |