Class: Tools::CreateFaqTool

Inherits:
ApplicationTool show all
Defined in:
app/mcp/tools/create_faq_tool.rb

Overview

MCP Tool for creating new FAQ articles.
Creates an ArticleFaq in draft state.

FAQs have two visibility flags that control where they appear on the website:

  • show_on_sales_pages: true → shown on product/marketing pages (product line & catalog pages)
  • show_on_support_pages: true → shown on support portal pages (/support/*)

IMPORTANT — when to set these flags:

  • Set show_on_sales_pages: true ONLY when the FAQ is scoped to a specific product line +
    product category, or to a specific SKU. These FAQs are surfaced inline on product pages.
  • Set show_on_support_pages: true ONLY when the FAQ should appear on the support portal
    for a specific product line / category or SKU context.
  • When the FAQ is intended for a particular marketing or content page (indicated by a
    "for-xxxx-page" tag), BOTH flags must be false. The FAQ is fetched solely by tag match.

Common combinations:

  • sales=true, support=true: Product + support pages for a specific product line/category or SKU
  • sales=true, support=false: Product pages only for a specific product line/category or SKU
  • sales=false, support=true: Support portal only for a specific product line/category or SKU
  • Both false (default): Tag-based pages only — use with a "for-xxxx-page" tag

Examples:

Create a FAQ scoped to a product line (appears on product + support pages)

{ question: "How much does floor heating cost?", answer: "<p>...</p>",
  show_on_sales_pages: true, show_on_support_pages: true,
  product_line_names: ["floor heating"] }

Create a support-only FAQ for a product line

{ question: "My thermostat shows E1 error", answer: "<p>...</p>",
  show_on_sales_pages: false, show_on_support_pages: true,
  product_line_names: ["thermostats"] }

Create a tag-only FAQ for a marketing landing page (sales/support must be false)

{ question: "What are the benefits of a heated driveway?", answer: "<p>...</p>",
  show_on_sales_pages: false, show_on_support_pages: false,
  tags: ["for-heated-driveway-page"] }

Constant Summary collapse

MAX_ANSWER_WORDS =
150

Class Method Summary collapse

Class Method Details

.call(question:, answer:, description: nil, show_on_sales_pages: false, show_on_support_pages: false, product_line_names: nil, tags: nil, publish: false, _server_context: nil) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'app/mcp/tools/create_faq_tool.rb', line 114

def call(question:, answer:, description: nil, show_on_sales_pages: false, show_on_support_pages: false, product_line_names: nil, tags: nil, publish: false, _server_context: nil) # rubocop:disable Lint/UnusedMethodArgument
  # Validate inputs
  return error_response('Question cannot be blank') if question.blank?
  return error_response('Answer cannot be blank') if answer.blank?

  word_count = ActionController::Base.helpers.strip_tags(answer).split.size
  if word_count > MAX_ANSWER_WORDS
    return error_response(
      "Answer is too long (#{word_count} words). FAQ answers must be 2-4 sentences, " \
      "max #{MAX_ANSWER_WORDS} words. Shorten the answer to a single concise paragraph " \
      "or a short bullet list (3-5 items). If the topic needs a longer explanation, " \
      "write it as a blog section instead of an FAQ."
    )
  end

  # Validate internal links in the answer HTML before saving
  if answer.present?
    link_validation = Seo::InternalLinkValidator.new.process(answer)
    unless link_validation.valid?
      details = link_validation.broken_links.map { |bl| "#{bl.href}#{bl.suggestion ? " → suggested: #{bl.suggestion}" : ''}" }
      return error_response(
        "Broken internal links detected — FAQ was NOT saved. Fix or remove these links: #{details.join('; ')}"
      )
    end

    # Stage 4 of the Sunny blog editor fix plan: server-enforced FAQ
    # rules (no widgets/calculators in answers, no nested headings,
    # max 1,200 chars). The same registry powers insert_faqs.
    if defined?(Blog::ContentRules)
      violations = Blog::ContentRules.validate(scope: :faq_answer, html: answer)
      if violations.any?
        return error_response(
          Blog::ContentRules.format_error(violations, scope: :faq_answer)
        )
      end
    end
  end

  # Page-specific FAQs (tagged with "for-xxxx-page") must not be shown on sales/support pages.
  # Those flags are reserved for FAQs scoped to a product line, product category, or SKU.
  if page_specific_tags?(tags) && (show_on_sales_pages || show_on_support_pages)
    return error_response(
      'FAQs with a "for-xxxx-page" tag are page-specific and must have ' \
      'show_on_sales_pages and show_on_support_pages both set to false. ' \
      'Remove the "for-xxxx-page" tag, or set both flags to false.'
    )
  end

  # Build the FAQ
  faq = ArticleFaq.new(
    subject: question.strip,
    solution: answer.strip,
    description: description.presence || generate_description(answer),
    sales: show_on_sales_pages,
    support: show_on_support_pages
  )

  # Associate product lines
  if product_line_names.present?
    product_lines = resolve_product_lines(product_line_names)
    faq.product_lines = product_lines
  end

  # Set tags
  faq.tags = normalize_tags(tags) if tags.present?

  # Save the FAQ
  unless faq.save
    return error_response("Failed to create FAQ: #{faq.errors.full_messages.join(', ')}")
  end

  # Publish if requested
  if publish
    unless faq.publish
      return error_response("FAQ created (ID: #{faq.id}) but could not be published: #{faq.errors.full_messages.join(', ')}")
    end
  end

  # Populate editorial link graph immediately (non-fatal — FAQ is already saved)
  begin
    Seo::InternalLinkValidator.upsert_editorial_links!(faq)
  rescue StandardError => e
    Rails.logger.warn "[MCP CreateFaq] Link graph upsert failed for FAQ #{faq.id}: #{e.message}"
  end

  # Generate embedding for semantic search (async-safe)
  schedule_embedding(faq)

  json_response(
    success: true,
    faq: format_faq(faq),
    message: "FAQ created successfully#{publish ? ' and published' : ' as draft'}."
  )
rescue StandardError => e
  ErrorReporting.error(e, tool: 'create_faq')
  error_response("Unexpected error creating FAQ: #{e.message}")
end