feat: generate Help Center for Onboarding (#14370)
## Manually triggering help center generation
Open a Rails console (`bundle exec rails console`):
```ruby
account = Account.find(<ACCOUNT_ID>)
user = account.users.first
# Optional: refresh brand info from the customer's website
domain = 'example.com'
result = WebsiteBrandingService.new("noreply@#{domain}").perform
account.update!(
name: result[:title].presence || account.name,
custom_attributes: account.custom_attributes.merge('website' => domain, 'brand_info' => result)
)
# Optional: wipe existing portals so a fresh one is created
account.portals.destroy_all
Onboarding::HelpCenterCreationService.new(account, user).perform
```
Sidekiq must be running — articles are written by
`Onboarding::HelpCenterArticleGenerationJob`. Avoid running on
production; generation calls the LLM provider.
### Generation flow (Happy Path)
```mermaid
sequenceDiagram
autonumber
participant Kickoff as HelpCenterCreationService
participant DB as DB
participant GenJob as HelpCenterArticleGenerationJob
participant Curator as HelpCenterCurator
participant Firecrawl as Firecrawl
participant CuratorLLM as Curation LLM
participant Redis as Redis Progress
participant WriterJob as HelpCenterArticleWriterJob
participant Builder as HelpCenterArticleBuilder
participant WriterLLM as Writer LLM
participant Cable as ActionCable
Kickoff->>DB: Create portal for account<br/>homepage_link=https://chatwoot.com
Kickoff->>DB: Attach brand logo if available
Kickoff->>GenJob: Enqueue generation job<br/>account_id, portal_id, user_id, generation_id
GenJob->>Curator: Curate help center plan
Curator->>Firecrawl: map https://chatwoot.com<br/>search: docs help support faq
Firecrawl-->>Curator: Return discovered links
Curator->>CuratorLLM: Select categories + article plans<br/>from discovered links only
CuratorLLM-->>Curator: Return categories, articles, allowed_urls
GenJob->>DB: Create portal categories
GenJob->>GenJob: Stamp articles with category_id
GenJob->>GenJob: Filter article URLs against allowed_urls
GenJob->>GenJob: Drop articles with no category<br/>or no approved source URLs
GenJob->>Redis: Start progress<br/>status=generating, total=N, finished=0
loop For each approved article
GenJob->>WriterJob: Enqueue writer job<br/>title, category_id, approved URLs
end
par Writer jobs run independently
WriterJob->>Builder: Build article from approved URLs
Builder->>Firecrawl: batch_scrape approved URLs
Firecrawl-->>Builder: Return Markdown source pages
Builder->>WriterLLM: Rewrite sources into one article
WriterLLM-->>Builder: Return title, description, Markdown content
Builder->>DB: Create draft portal article<br/>meta.source_urls
WriterJob->>Redis: Increment finished count
WriterJob->>Cable: Broadcast help_center.article_generated
end
WriterJob->>Redis: If finished >= total<br/>mark status=completed
WriterJob->>Cable: Broadcast help_center.generation_completed
```
### Redis State Management
```mermaid
stateDiagram-v2
[*] --> active_pointer_set
active_pointer_set --> generating: generation job creates valid plan
active_pointer_set --> skipped: curation skipped/failed
generating --> generating: each writer job increments finished
generating --> completed: finished == total
generating --> ignored_completion: generation_id superseded
skipped --> [*]
completed --> [*]
ignored_completion --> [*]
```
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -209,6 +209,8 @@ gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'firecrawl-sdk', '~> 1.0', require: 'firecrawl'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ GEM
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
firecrawl-sdk (1.4.1)
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
@@ -1079,6 +1080,7 @@ DEPENDENCIES
|
||||
faker
|
||||
faraday_middleware-aws-sigv4
|
||||
fcm
|
||||
firecrawl-sdk (~> 1.0)
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
class Onboarding::HelpCenterArticleGenerationJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
_account_id, _portal_id, user_id, generation_id = job.arguments
|
||||
reason = "firecrawl exhausted: #{error.message}"
|
||||
Rails.logger.warn "[HelpCenterGenerationJob] gen=#{generation_id} #{reason}"
|
||||
job.send(:skip_and_broadcast, user: User.find_by(id: user_id), generation_id: generation_id, reason: reason)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id)
|
||||
return if Onboarding::HelpCenterGenerationState.current(generation_id).present?
|
||||
|
||||
process(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: User.find(user_id),
|
||||
generation_id: generation_id
|
||||
)
|
||||
rescue Onboarding::HelpCenterErrors::CurationSkipped => e
|
||||
Rails.logger.info "[HelpCenterGenerationJob] gen=#{generation_id} skipped: #{e.message}"
|
||||
skip_and_broadcast(user: User.find_by(id: user_id), generation_id: generation_id, reason: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process(account:, portal:, user:, generation_id:)
|
||||
plan = Onboarding::HelpCenterCurator.new(account: account).perform
|
||||
articles = create_categories_and_build_article_payloads(portal, plan)
|
||||
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: articles.size)
|
||||
enqueue_writer_jobs(
|
||||
account_id: account.id,
|
||||
portal_id: portal.id,
|
||||
user_id: user.id,
|
||||
generation_id: generation_id,
|
||||
articles: articles
|
||||
)
|
||||
end
|
||||
|
||||
def create_categories_and_build_article_payloads(portal, plan)
|
||||
ActiveRecord::Base.transaction do
|
||||
categories_by_name = create_categories(portal, plan['categories'])
|
||||
articles = build_article_payloads(
|
||||
plan['articles'],
|
||||
categories_by_name,
|
||||
plan['allowed_urls']
|
||||
)
|
||||
|
||||
if articles.empty?
|
||||
raise Onboarding::HelpCenterErrors::CurationSkipped,
|
||||
'no articles after category or URL filtering'
|
||||
end
|
||||
|
||||
articles
|
||||
end
|
||||
end
|
||||
|
||||
def create_categories(portal, categories)
|
||||
locale = portal.default_locale
|
||||
Array(categories).each_with_index.with_object({}) do |(cat, idx), acc|
|
||||
name = cat['name'].to_s.strip
|
||||
next if name.blank?
|
||||
|
||||
record = portal.categories.create!(
|
||||
name: name,
|
||||
description: cat['description'].to_s.strip.presence,
|
||||
slug: "#{name.parameterize}-#{SecureRandom.hex(3)}",
|
||||
locale: locale,
|
||||
position: (idx + 1) * 10
|
||||
)
|
||||
acc[name] = record
|
||||
end
|
||||
end
|
||||
|
||||
def build_article_payloads(articles, categories_by_name, allowed_urls)
|
||||
allowed_urls = Array(allowed_urls).to_set
|
||||
Array(articles).filter_map do |article|
|
||||
category_id = categories_by_name[article['category_name'].to_s]&.id
|
||||
next if category_id.nil?
|
||||
|
||||
urls = Array(article['urls']).select { |url| allowed_urls.include?(url) }
|
||||
next if urls.empty?
|
||||
|
||||
article.merge('category_id' => category_id, 'urls' => urls)
|
||||
end
|
||||
end
|
||||
|
||||
def enqueue_writer_jobs(account_id:, portal_id:, user_id:, generation_id:, articles:)
|
||||
articles.each do |article|
|
||||
Onboarding::HelpCenterArticleWriterJob.perform_later(
|
||||
account_id, portal_id, user_id, generation_id, { article: article }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def skip_and_broadcast(user:, generation_id:, reason:)
|
||||
Onboarding::HelpCenterGenerationState.skip(generation_id, reason: reason)
|
||||
Onboarding::HelpCenterBroadcaster.completed(
|
||||
user: user, generation_id: generation_id, status: 'skipped', skip_reason: reason
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class Onboarding::HelpCenterArticleWriterJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
retry_on Firecrawl::FirecrawlError, wait: :polynomially_longer, attempts: 3 do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
discard_on Onboarding::HelpCenterErrors::ArticleBuildFailed do |job, error|
|
||||
job.send(:on_writer_failure, error)
|
||||
end
|
||||
|
||||
def perform(account_id, portal_id, user_id, generation_id, article_payload)
|
||||
user = User.find(user_id)
|
||||
payload = article_payload.with_indifferent_access
|
||||
article = Onboarding::HelpCenterArticleBuilder.new(
|
||||
account: Account.find(account_id),
|
||||
portal: Portal.find(portal_id),
|
||||
user: user,
|
||||
article: payload[:article]
|
||||
).perform
|
||||
|
||||
finalize(user: user, generation_id: generation_id, article: article)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def on_writer_failure(error)
|
||||
user, generation_id = failure_context
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} failed: #{error.class} #{error.message}"
|
||||
finalize(user: user, generation_id: generation_id, article: nil)
|
||||
end
|
||||
|
||||
def failure_context
|
||||
_account_id, _portal_id, user_id, generation_id = arguments
|
||||
[User.find_by(id: user_id), generation_id]
|
||||
end
|
||||
|
||||
def finalize(user:, generation_id:, article:)
|
||||
result = Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
|
||||
if article
|
||||
Onboarding::HelpCenterBroadcaster.article_generated(
|
||||
user: user, generation_id: generation_id, article: article, articles_finished: result[:finished]
|
||||
)
|
||||
end
|
||||
return unless result[:completed]
|
||||
|
||||
Onboarding::HelpCenterBroadcaster.completed(user: user, generation_id: generation_id, status: 'completed')
|
||||
rescue Onboarding::HelpCenterGenerationState::Missing => e
|
||||
Rails.logger.warn "[HelpCenterWriterJob] gen=#{generation_id} #{e.message}"
|
||||
end
|
||||
end
|
||||
12
enterprise/app/services/captain/llm/article_writer_schema.rb
Normal file
12
enterprise/app/services/captain/llm/article_writer_schema.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class Captain::Llm::ArticleWriterSchema < RubyLLM::Schema
|
||||
CONTENT_DESCRIPTION = 'Full article body in clean Markdown. Use headings, lists, and code fences where appropriate. ' \
|
||||
'Preserve steps, code samples, FAQs, troubleshooting detail. Strip marketing copy, navigation breadcrumbs, ' \
|
||||
'social/share footers, "edit this page" links, repeated CTAs. ' \
|
||||
'Total length must stay under 18000 characters; trim repetition and tangents before cutting substance.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars). Plain text, no markdown.'.freeze
|
||||
DESCRIPTION_DESCRIPTION = 'One-sentence summary (max 200 chars) describing what the article teaches.'.freeze
|
||||
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :description, description: DESCRIPTION_DESCRIPTION, max_length: 200
|
||||
string :content, description: CONTENT_DESCRIPTION, max_length: 18_000
|
||||
end
|
||||
102
enterprise/app/services/captain/llm/article_writer_service.rb
Normal file
102
enterprise/app/services/captain/llm/article_writer_service.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
class Captain::Llm::ArticleWriterService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::ArticleWriterSchema
|
||||
SOURCE_MAX_LENGTH = 60_000
|
||||
|
||||
# source_pages: Array<{ url: String, markdown: String }>, 1-3 entries.
|
||||
pattr_initialize [:account!, :source_pages!, { hint_title: nil }]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: writer_model, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return {} if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
{
|
||||
title: data[:title].to_s.strip,
|
||||
description: data[:description].to_s.strip,
|
||||
content: data[:content].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are rewriting web page content into a clean help-center article for a customer-support knowledge base.
|
||||
You may receive 1 to 3 source pages. When given multiple sources, merge them into ONE coherent article:
|
||||
deduplicate identical instructions, do not repeat the same step in different words, and order content
|
||||
by the natural reading flow of the merged topic. When sources contradict, prefer the more authoritative
|
||||
or detailed version. The result must read like a single article, not a stitched-together collage.
|
||||
|
||||
Preserve the substance: keep instructions, steps, code samples, configuration, troubleshooting, and FAQs intact.
|
||||
Strip marketing copy, navigation breadcrumbs, "share this page" footers, repeated CTAs, and links to unrelated pages.
|
||||
Output well-formatted Markdown — use headings, lists, and code fences where appropriate.
|
||||
The body must stay under 18000 characters. If the combined sources are longer, trim repetition and tangents
|
||||
before cutting steps or critical detail. Never invent content the sources do not support.
|
||||
|
||||
Write the title, description, and body in #{locale_name}.
|
||||
If a source page is in another language, translate as you rewrite — do not copy source-language text into the output.
|
||||
Code samples, command-line examples, API field names, and proper nouns stay in their original form.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
pages = Array(source_pages).reject { |p| p[:markdown].to_s.blank? }
|
||||
per_source_cap = pages.size.positive? ? SOURCE_MAX_LENGTH / pages.size : SOURCE_MAX_LENGTH
|
||||
|
||||
sections = pages.each_with_index.map do |page, idx|
|
||||
body = page[:markdown].to_s.truncate(per_source_cap, omission: "\n\n[source truncated for length]")
|
||||
"=== Source #{idx + 1} of #{pages.size} (#{page[:url]}) ===\n#{body}"
|
||||
end
|
||||
|
||||
parts = [
|
||||
("Suggested title (you may rewrite): #{hint_title}" if hint_title.present?),
|
||||
'Source pages (Markdown):',
|
||||
sections.join("\n\n")
|
||||
].compact
|
||||
parts.join("\n\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def event_name
|
||||
'article_writer'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Rewrite runs on the operator's OpenAI key during onboarding; should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def writer_model
|
||||
'gpt-5.2'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
class Captain::Llm::HelpCenterCurationSchema < RubyLLM::Schema
|
||||
CATEGORIES_DESCRIPTION = 'High-level categories that group the chosen articles. Use only as many ' \
|
||||
'as the content naturally breaks into. Names must be short (1-3 words) and reusable.'.freeze
|
||||
ARTICLES_DESCRIPTION = 'A curated starting set of help-center articles selected from the input URL list. ' \
|
||||
'Quality over quantity: only include pages with clear, high-value, substantive help ' \
|
||||
'content. Skip blog posts, marketing/landing pages, login, pricing, legal, careers, ' \
|
||||
'customer testimonials, press, about/company, whitepapers, support contact pages, ' \
|
||||
'terms of service, privacy policy.'.freeze
|
||||
TITLE_DESCRIPTION = 'Concise article title (max 80 chars), rewritten if the source title is too long or marketing-y.'.freeze
|
||||
CATEGORY_DESCRIPTION = 'One sentence describing what kind of articles belong in this category.'.freeze
|
||||
URLS_DESCRIPTION = '1 to 3 source URLs from the input list. Prefer grouping when pages cover related ' \
|
||||
'aspects of the same topic — overview + deep-dive, FAQ + how-to, policy + FAQ, ' \
|
||||
'parent topic + its troubleshooting page. Merged sources give the writer more ' \
|
||||
'context and produce stronger articles than several thin stubs.'.freeze
|
||||
|
||||
array :categories, description: CATEGORIES_DESCRIPTION, min_items: 1, max_items: 10 do
|
||||
object do
|
||||
string :name, description: 'Short, human-readable category name (1-3 words).', max_length: 60
|
||||
string :description, description: CATEGORY_DESCRIPTION, max_length: 200
|
||||
end
|
||||
end
|
||||
|
||||
array :articles, description: ARTICLES_DESCRIPTION, min_items: 1, max_items: 25 do
|
||||
object do
|
||||
array :urls, description: URLS_DESCRIPTION, min_items: 1, max_items: 3, of: :string
|
||||
string :title, description: TITLE_DESCRIPTION, max_length: 80
|
||||
string :category_name, description: 'Must exactly match one of the names emitted in the categories field.', max_length: 60
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
class Captain::Llm::HelpCenterCurationService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::Llm::HelpCenterCurationSchema
|
||||
MAX_LINKS_IN_PROMPT = 50
|
||||
IGNORED_URL_PATTERN = /\.(?:pdf|jpe?g|png|gif|webp|svg|ico|bmp|tiff?|avif|heic)(?:\?|#|$)/i
|
||||
# This model consistently outperforms 5.2 in generating tighter and more
|
||||
# accurate curations.
|
||||
CURATION_MODEL = 'gpt-4.1'.freeze
|
||||
|
||||
pattr_initialize [:account!, :links!]
|
||||
|
||||
def perform
|
||||
response = make_api_call(model: CURATION_MODEL, messages: messages, schema: RESPONSE_SCHEMA)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: extract_payload(response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_payload(message)
|
||||
return { categories: [], articles: [] } if message.blank?
|
||||
|
||||
data = message.is_a?(Hash) ? message.deep_symbolize_keys : {}
|
||||
articles = Array(data[:articles])
|
||||
used_names = articles.map { |a| a[:category_name].to_s }
|
||||
categories = Array(data[:categories]).select { |c| used_names.include?(c[:name].to_s) }
|
||||
{ categories: categories, articles: articles }
|
||||
end
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: user_prompt }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
<<~PROMPT
|
||||
You are curating a help center for a company's customer-support widget.
|
||||
You will be given a list of pages discovered on the company's website.
|
||||
Pick pages that would make genuinely useful help-center articles for end users —
|
||||
substantive how-to, FAQ, troubleshooting, policy, getting-started, account/billing
|
||||
help, or product guide content.
|
||||
|
||||
This is a STARTING SET for the user, not a comprehensive corpus. The user will add
|
||||
more articles later. Each article you pick costs downstream time, compute, and
|
||||
money to scrape and rewrite — be deliberate. Only include pages with clear,
|
||||
high-value, substantive help content. When unsure about a page's value, leave it
|
||||
out. 8 strong articles beat 20 padded ones, even when the input has 20+ candidates.
|
||||
|
||||
Quality over quantity: do not pad with thin, overview, or marketing-adjacent pages
|
||||
to hit a target count. If a site has only a few genuinely useful pages, return only
|
||||
those few. The schema allows up to 25 articles, but treat that as a hard ceiling,
|
||||
not a target — most sites should land well under it.
|
||||
|
||||
Skip marketing/landing pages, blog posts, login, pricing tiers, legal, careers, press, investor pages.
|
||||
Group your picks into reusable categories — use as many as the content naturally breaks into.
|
||||
Use the URL paths and page titles to judge relevance — do not invent URLs.
|
||||
|
||||
URL-path priority (preference order, not hard rules):
|
||||
- First tier — almost always pick when present. Paths containing /support, /help,
|
||||
/docs, /documentation, /faq, /faqs, /kb, /knowledge-base, /learn, /guides,
|
||||
/getting-started, /how-to, /tutorial, /troubleshoot.
|
||||
- Second tier — pick when the page carries user-relevant information a customer
|
||||
would ask support about. Paths like /features, /pricing, /plans, /shipping,
|
||||
/returns, /warranty, /security, individual product or category pages. Prefer
|
||||
these only after first-tier picks; if a topic exists in both tiers, prefer the
|
||||
first-tier URL.
|
||||
- Skip — promotional, navigational, or boilerplate paths: /blog, /news, /press,
|
||||
/careers, /jobs, /about, /team, /investors, /customers, /testimonials,
|
||||
/case-studies, /login, /signup, /register, /legal, /terms, /privacy.
|
||||
|
||||
For each article, group 1 to 3 URLs that together cover a single topic. PREFER
|
||||
grouping whenever pages overlap or complement each other — merged sources give
|
||||
the writer more context and produce a stronger article than two thin stubs.
|
||||
|
||||
Strong signals to group multiple URLs (treat any of these as a green light):
|
||||
- Same topic from different angles: overview + deep-dive, FAQ + how-to,
|
||||
policy + FAQ, feature page + feature docs.
|
||||
- Parent topic + its troubleshooting page (e.g. "Bank reconciliation" +
|
||||
"Problems with bank reconciliation"; "SSO setup" + "SSO not working").
|
||||
- Variant-specific guides on the same topic ("SSO setup" + "SSO with Okta";
|
||||
"Webhooks overview" + "Webhook payload reference").
|
||||
- A how-to split across step or platform pages (install on iOS + Android + web).
|
||||
- FAQ entries that match a deep-dive article elsewhere on the site.
|
||||
|
||||
Before finalizing your picks, scan them for merge candidates: if two URLs are
|
||||
about the same topic, they should almost always be one article, not two.
|
||||
|
||||
Don't group across distinct topics that merely share a category ("Setting up SSO"
|
||||
and "Setting up MFA" stay separate). If a URL is marketing for a feature and
|
||||
another is the feature's docs, pick the docs and skip the marketing.
|
||||
|
||||
Write all category names, category descriptions, and article titles in #{locale_name}.
|
||||
The input page titles and descriptions may be in another language; translate the labels you emit into #{locale_name}.
|
||||
Keep URLs unchanged.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def user_prompt
|
||||
parts = [
|
||||
"Company: #{account.name}",
|
||||
("Description: #{brand_info[:description]}" if brand_info[:description].present?),
|
||||
("Industries: #{industries_text}" if industries_text.present?),
|
||||
'Discovered pages (url — title — description):',
|
||||
formatted_links
|
||||
].compact
|
||||
parts.join("\n")
|
||||
end
|
||||
|
||||
def locale_name
|
||||
code = account.locale.to_s
|
||||
LANGUAGES_CONFIG.values.find { |v| v[:iso_639_1_code] == code }&.dig(:name) || code.presence || 'English (en)'
|
||||
end
|
||||
|
||||
def formatted_links
|
||||
Array(links).reject { |link| ignored_url?(link) }.first(MAX_LINKS_IN_PROMPT).map do |link|
|
||||
data = link.is_a?(Hash) ? link.deep_symbolize_keys : {}
|
||||
"- #{data[:url]} — #{data[:title].to_s.strip} — #{data[:description].to_s.strip}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def ignored_url?(link)
|
||||
url = link.is_a?(Hash) ? link.deep_symbolize_keys[:url].to_s : link.to_s
|
||||
url.match?(IGNORED_URL_PATTERN)
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def industries_text
|
||||
Array(brand_info[:industries]).filter_map { |i| i.is_a?(Hash) ? i[:industry] : i }.join(', ').presence
|
||||
end
|
||||
|
||||
def event_name
|
||||
'help_center_curation'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def captain_tasks_enabled?
|
||||
true
|
||||
end
|
||||
|
||||
# Onboarding curation runs on the operator's OpenAI key; it should not
|
||||
# debit the customer's captain_responses quota.
|
||||
def counts_toward_usage?
|
||||
false
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
31
enterprise/app/services/firecrawl/configuration.rb
Normal file
31
enterprise/app/services/firecrawl/configuration.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module Firecrawl::Configuration
|
||||
INSTALLATION_CONFIG_KEY = 'CAPTAIN_FIRECRAWL_API_KEY'.freeze
|
||||
EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
DEFAULT_SCRAPE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
module_function
|
||||
|
||||
def configured?
|
||||
api_key.present?
|
||||
end
|
||||
|
||||
def client
|
||||
key = api_key
|
||||
raise ::Firecrawl::FirecrawlError, "#{INSTALLATION_CONFIG_KEY} is not configured" if key.blank?
|
||||
|
||||
::Firecrawl::Client.new(api_key: key)
|
||||
end
|
||||
|
||||
def api_key
|
||||
InstallationConfig.find_by(name: INSTALLATION_CONFIG_KEY)&.value
|
||||
end
|
||||
|
||||
def default_scrape_options(max_age: DEFAULT_SCRAPE_MAX_AGE_MS)
|
||||
::Firecrawl::Models::ScrapeOptions.new(
|
||||
formats: ['markdown'],
|
||||
only_main_content: true,
|
||||
exclude_tags: EXCLUDE_TAGS,
|
||||
max_age: max_age
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class Onboarding::HelpCenterArticleBuilder
|
||||
BuildFailed = Onboarding::HelpCenterErrors::ArticleBuildFailed
|
||||
|
||||
def initialize(account:, portal:, user:, article:)
|
||||
@account = account
|
||||
@portal = portal
|
||||
@user = user
|
||||
|
||||
spec = article.with_indifferent_access
|
||||
@urls = Array(spec[:urls]).map(&:to_s).reject(&:blank?)
|
||||
@title = spec[:title]
|
||||
@category_id = spec[:category_id]
|
||||
end
|
||||
|
||||
def perform
|
||||
raise BuildFailed, 'no source urls supplied' if @urls.empty?
|
||||
|
||||
source_pages = scrape(@urls)
|
||||
raise BuildFailed, "scrape produced no usable pages for #{@urls.join(', ')}" if source_pages.empty?
|
||||
|
||||
payload = rewrite(source_pages)
|
||||
|
||||
@portal.articles.create!(
|
||||
title: payload[:title],
|
||||
description: payload[:description].presence,
|
||||
content: payload[:content],
|
||||
author_id: @user.id,
|
||||
category_id: @category_id,
|
||||
status: :draft,
|
||||
meta: { source_urls: source_pages.pluck(:url) }
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scrape(urls)
|
||||
job = Firecrawl::Configuration.client.batch_scrape(
|
||||
urls,
|
||||
Firecrawl::Models::BatchScrapeOptions.new(options: Firecrawl::Configuration.default_scrape_options)
|
||||
)
|
||||
Array(job.data).filter_map { |doc| normalize(doc) }
|
||||
end
|
||||
|
||||
def normalize(doc)
|
||||
metadata = doc&.metadata || {}
|
||||
status = metadata['statusCode']
|
||||
return nil if status.present? && !(200..299).cover?(status)
|
||||
return nil if doc.markdown.to_s.blank?
|
||||
|
||||
{
|
||||
url: metadata['sourceURL'] || metadata['url'],
|
||||
markdown: doc.markdown.to_s,
|
||||
page_title: metadata['title'].to_s.strip
|
||||
}
|
||||
end
|
||||
|
||||
def rewrite(source_pages)
|
||||
response = Captain::Llm::ArticleWriterService.new(
|
||||
account: @account,
|
||||
source_pages: source_pages,
|
||||
hint_title: @title.presence || source_pages.first[:page_title]
|
||||
).perform
|
||||
raise BuildFailed, "writer LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
payload = response[:message] || {}
|
||||
raise BuildFailed, 'writer returned blank content' if payload[:content].blank?
|
||||
raise BuildFailed, 'writer returned blank title' if payload[:title].blank?
|
||||
|
||||
payload
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
module Onboarding::HelpCenterBroadcaster
|
||||
ARTICLE_GENERATED = 'help_center.article_generated'.freeze
|
||||
GENERATION_COMPLETED = 'help_center.generation_completed'.freeze
|
||||
|
||||
module_function
|
||||
|
||||
def article_generated(user:, generation_id:, article:, articles_finished:)
|
||||
broadcast(user, ARTICLE_GENERATED, {
|
||||
generation_id: generation_id,
|
||||
article_id: article.id,
|
||||
articles_finished: articles_finished
|
||||
})
|
||||
end
|
||||
|
||||
def completed(user:, generation_id:, status:, skip_reason: nil)
|
||||
broadcast(user, GENERATION_COMPLETED, {
|
||||
generation_id: generation_id,
|
||||
status: status,
|
||||
skip_reason: skip_reason
|
||||
})
|
||||
end
|
||||
|
||||
def broadcast(user, event, payload)
|
||||
token = user&.pubsub_token
|
||||
return if token.blank?
|
||||
|
||||
ActionCableBroadcastJob.perform_later([token], event, payload)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
class Onboarding::HelpCenterCreationService
|
||||
DEFAULT_PORTAL_COLOR = '#1f93ff'.freeze
|
||||
LOGO_MAX_DOWNLOAD_SIZE = 5.megabytes
|
||||
|
||||
def initialize(account, user)
|
||||
@account = account
|
||||
@user = user
|
||||
end
|
||||
|
||||
def perform
|
||||
existing = existing_portal
|
||||
return reuse_existing_portal(existing) if existing
|
||||
|
||||
@account.portals.create!(portal_attributes).tap do |portal|
|
||||
attach_brand_logo(portal)
|
||||
enqueue_article_generation(portal)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def existing_portal
|
||||
@account.portals.first
|
||||
end
|
||||
|
||||
def reuse_existing_portal(portal)
|
||||
Rails.logger.info "[HelpCenterCreation] Reusing existing portal #{portal.id} for account #{@account.id}"
|
||||
portal
|
||||
end
|
||||
|
||||
def portal_attributes
|
||||
{
|
||||
name: portal_name,
|
||||
slug: generate_slug,
|
||||
color: portal_color,
|
||||
page_title: portal_name,
|
||||
header_text: header_text,
|
||||
homepage_link: homepage_link,
|
||||
channel_web_widget_id: web_widget_channel_id,
|
||||
config: { default_locale: locale, allowed_locales: [locale] }
|
||||
}.compact
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
|
||||
def portal_name
|
||||
brand_info[:title].presence || @account.name
|
||||
end
|
||||
|
||||
def portal_color
|
||||
hex = brand_info[:colors]&.first&.dig(:hex)
|
||||
hex.to_s.match?(/\A#\h{6}\z/) ? hex : DEFAULT_PORTAL_COLOR
|
||||
end
|
||||
|
||||
def header_text
|
||||
brand_info[:slogan].presence || brand_info[:description].presence
|
||||
end
|
||||
|
||||
def homepage_link
|
||||
with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def enqueue_article_generation(portal)
|
||||
return if homepage_link.blank?
|
||||
|
||||
generation_id = SecureRandom.uuid
|
||||
Onboarding::HelpCenterArticleGenerationJob.perform_later(@account.id, portal.id, @user.id, generation_id)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Failed to enqueue article generation for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def attach_brand_logo(portal)
|
||||
logo_url = brand_logo_url
|
||||
return if logo_url.blank?
|
||||
|
||||
SafeFetch.fetch(logo_url, max_bytes: LOGO_MAX_DOWNLOAD_SIZE, allowed_content_type_prefixes: ['image/']) do |logo_file|
|
||||
portal.logo.attach(
|
||||
io: logo_file.tempfile,
|
||||
filename: logo_file.original_filename,
|
||||
content_type: logo_file.content_type
|
||||
)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[HelpCenterCreation] Logo attachment failed for account #{@account.id}: #{e.class} - #{e.message}"
|
||||
end
|
||||
|
||||
def brand_logo_url
|
||||
Array(brand_info[:logos]).filter_map do |logo|
|
||||
logo.is_a?(Hash) ? logo[:url] : logo
|
||||
end.find(&:present?)
|
||||
end
|
||||
|
||||
def web_widget_channel_id
|
||||
@account.inboxes.find_by(channel_type: 'Channel::WebWidget')&.channel_id
|
||||
end
|
||||
|
||||
def locale
|
||||
@account.locale.presence || 'en'
|
||||
end
|
||||
|
||||
def generate_slug
|
||||
slug_candidates.find { |slug| !Portal.exists?(slug: slug) } || fallback_slug
|
||||
end
|
||||
|
||||
def slug_candidates
|
||||
base = @account.name.to_s.parameterize.presence
|
||||
return [] if base.blank?
|
||||
|
||||
first_token = base.split('-').first
|
||||
[base, first_token, "#{first_token}-docs", "#{first_token}-help"].uniq
|
||||
end
|
||||
|
||||
def fallback_slug
|
||||
base = @account.name.to_s.parameterize.presence || 'portal'
|
||||
"#{base}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
end
|
||||
65
enterprise/app/services/onboarding/help_center_curator.rb
Normal file
65
enterprise/app/services/onboarding/help_center_curator.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
class Onboarding::HelpCenterCurator
|
||||
MAP_LIMIT = 500
|
||||
MAP_SEARCH = 'docs help support faq'.freeze
|
||||
MIN_ARTICLES = 3
|
||||
|
||||
Skipped = Onboarding::HelpCenterErrors::CurationSkipped
|
||||
|
||||
def initialize(account:)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def perform
|
||||
raise Skipped, 'Firecrawl not configured' unless Firecrawl::Configuration.configured?
|
||||
raise Skipped, 'no website url' if website_url.blank?
|
||||
|
||||
links = discover_links
|
||||
raise Skipped, 'map returned no links' if links.empty?
|
||||
|
||||
plan = curate(links)
|
||||
raise Skipped, "only #{plan[:articles].size} articles curated (< #{MIN_ARTICLES} threshold)" if plan[:articles].size < MIN_ARTICLES
|
||||
|
||||
plan.merge(allowed_urls: extract_urls(links)).deep_stringify_keys
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def discover_links
|
||||
data = Firecrawl::Configuration.client.map(
|
||||
website_url,
|
||||
Firecrawl::Models::MapOptions.new(limit: MAP_LIMIT, search: MAP_SEARCH)
|
||||
)
|
||||
Array(data.links)
|
||||
end
|
||||
|
||||
def extract_urls(links)
|
||||
Array(links).filter_map do |link|
|
||||
link['url'].presence
|
||||
end.uniq
|
||||
end
|
||||
|
||||
def curate(links)
|
||||
response = Captain::Llm::HelpCenterCurationService.new(account: @account, links: links).perform
|
||||
raise Skipped, "curator LLM error: #{response[:error]}" if response[:error]
|
||||
|
||||
response[:message] || { categories: [], articles: [] }
|
||||
end
|
||||
|
||||
def website_url
|
||||
@website_url ||= with_scheme(custom_attributes_website.presence || brand_info[:domain].presence)
|
||||
end
|
||||
|
||||
def with_scheme(raw)
|
||||
return raw if raw.blank?
|
||||
|
||||
raw.match?(%r{\Ahttps?://}i) ? raw : "https://#{raw}"
|
||||
end
|
||||
|
||||
def custom_attributes_website
|
||||
@account.custom_attributes['website']
|
||||
end
|
||||
|
||||
def brand_info
|
||||
@brand_info ||= (@account.custom_attributes['brand_info'] || {}).deep_symbolize_keys
|
||||
end
|
||||
end
|
||||
4
enterprise/app/services/onboarding/help_center_errors.rb
Normal file
4
enterprise/app/services/onboarding/help_center_errors.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
module Onboarding::HelpCenterErrors
|
||||
class CurationSkipped < StandardError; end
|
||||
class ArticleBuildFailed < StandardError; end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
class Onboarding::HelpCenterGenerationState
|
||||
# TODO: Reduce TTL to 48 hours once the full rollout is done
|
||||
TTL = 7.days.to_i
|
||||
|
||||
class Missing < StandardError; end
|
||||
|
||||
class << self
|
||||
def start(id, total:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'generating', 'total', total.to_i, 'finished', 0)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def record_article_finished(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
total = conn.hget(key(id), 'total')
|
||||
raise Missing, "missing state for generation #{id}" if total.blank?
|
||||
|
||||
finished = conn.hincrby(key(id), 'finished', 1)
|
||||
completed = finished >= total.to_i
|
||||
conn.hset(key(id), 'status', 'completed') if completed
|
||||
conn.expire(key(id), TTL)
|
||||
{ finished: finished, completed: completed }
|
||||
end
|
||||
end
|
||||
|
||||
def skip(id, reason:)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hset(key(id), 'status', 'skipped', 'skip_reason', reason.to_s)
|
||||
conn.expire(key(id), TTL)
|
||||
end
|
||||
end
|
||||
|
||||
def current(id)
|
||||
Redis::Alfred.with do |conn|
|
||||
conn.hgetall(key(id)).presence
|
||||
end
|
||||
end
|
||||
|
||||
def key(id)
|
||||
format(Redis::Alfred::HELP_CENTER_GENERATION, id: id)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,10 @@ module Redis::Alfred
|
||||
$alfred.with { |conn| conn.get(key) }
|
||||
end
|
||||
|
||||
def with(&)
|
||||
$alfred.with(&)
|
||||
end
|
||||
|
||||
def delete(key)
|
||||
$alfred.with { |conn| conn.del(key) }
|
||||
end
|
||||
|
||||
@@ -78,6 +78,7 @@ module Redis::RedisKeys
|
||||
|
||||
## Account Onboarding
|
||||
ACCOUNT_ONBOARDING_ENRICHMENT = 'ONBOARDING_ENRICHMENT::%<account_id>d'.freeze
|
||||
HELP_CENTER_GENERATION = 'HELP_CENTER_GENERATION::%<id>s'.freeze
|
||||
|
||||
## Account Email Rate Limiting
|
||||
ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY = 'OUTBOUND_EMAIL_COUNT::%<account_id>d::%<date>s'.freeze
|
||||
|
||||
@@ -130,17 +130,27 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when the failure is permanent' do
|
||||
# `discard_on PermanentCrawlError` swallows the error in `perform_now`
|
||||
# under normal conditions, but Zeitwerk reloading in CI can break the
|
||||
# rescue_handlers chain so the error escapes. The behavioural contract
|
||||
# we care about — no retries, correct document state — holds either
|
||||
# way, so tolerate both.
|
||||
def run_job
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
rescue StandardError => e
|
||||
# discard_on may have failed to swallow it; the contract still holds.
|
||||
raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
|
||||
end
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:status_code).and_return(404)
|
||||
end
|
||||
|
||||
it 'does not retry a discovered link that was never persisted' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
it 'does not persist a discovered link that was never stored' do
|
||||
expect { run_job }.not_to change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed without raising' do
|
||||
it 'marks an existing document as available and failed' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to raise_error
|
||||
run_job
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
clear_enqueued_jobs
|
||||
curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'happy path' do
|
||||
it 'creates categories, starts state with total/finished, and fans out article payloads' do
|
||||
expect do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
end.to change { portal.categories.count }.by(1)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'generating', 'total' => '2', 'finished' => '0'
|
||||
)
|
||||
expect(enqueued_jobs).to include(
|
||||
a_hash_including(
|
||||
'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
|
||||
'arguments' => array_including(
|
||||
account.id,
|
||||
portal.id,
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'orphan article filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles whose category was not emitted alongside them' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'article URL filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles with no approved source urls before fanout' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'transaction rollback' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
|
||||
}
|
||||
end
|
||||
|
||||
it 'leaves zero categories and marks state skipped when no article can be stamped' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(portal.categories.count).to eq(0)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no articles after category or URL filtering'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'idempotency' do
|
||||
it 'no-ops when state already exists for this generation' do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to(change { portal.categories.count })
|
||||
expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'curation skipped' do
|
||||
it 'records skip_reason and transitions to skipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped', 'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'firecrawl retries' do
|
||||
it 'transitions to skipped after retries exhaust' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
perform_enqueued_jobs { described_class.perform_later(*job_args) }
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
expect(state['status']).to eq('skipped')
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'success path' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'invokes the builder and increments the Redis counter' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
|
||||
account: account,
|
||||
portal: portal,
|
||||
user: admin,
|
||||
article: article_spec
|
||||
)
|
||||
end
|
||||
|
||||
it 'flips status to completed once the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'failure handling' do
|
||||
it 'increments the counter on ArticleBuildFailed without re-raising' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
|
||||
it 're-enqueues itself on transient Firecrawl errors' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'transient'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(described_class).with(*job_args)
|
||||
end
|
||||
|
||||
it 'increments the counter when Firecrawl retries are exhausted' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'always failing'
|
||||
)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_later(*job_args)
|
||||
end
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
|
||||
describe 'source url validation' do
|
||||
it 'requires source urls' do
|
||||
article = { urls: [], title: 'X' }
|
||||
builder = described_class.new(account: account, portal: portal, user: user, article: article)
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCreationService do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
|
||||
before do
|
||||
allow(SecureRandom).to receive(:uuid).and_return(generation_id)
|
||||
end
|
||||
|
||||
describe 'article generation enqueue' do
|
||||
context 'when account has a custom_attributes website' do
|
||||
it 'enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has only a brand_info domain' do
|
||||
let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
|
||||
|
||||
it 'uses the enrichment fallback and enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no website url' do
|
||||
let(:account) { create(:account, custom_attributes: {}) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a portal already exists' do
|
||||
before { create(:portal, account_id: account.id) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal creation fails' do
|
||||
it 'raises the error' do
|
||||
allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCurator do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
|
||||
let(:links) do
|
||||
[
|
||||
{ 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
|
||||
{ url: 'https://chatwoot.com/docs/b', title: 'B' },
|
||||
'https://chatwoot.com/docs/c'
|
||||
]
|
||||
end
|
||||
let(:llm_response) do
|
||||
{
|
||||
message: {
|
||||
categories: [{ name: 'Docs', description: 'Docs' }],
|
||||
articles: [
|
||||
{ title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
|
||||
{ title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
|
||||
{ title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
|
||||
llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
|
||||
|
||||
allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
|
||||
allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
|
||||
allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
|
||||
.with(account: account, links: links)
|
||||
.and_return(llm_service)
|
||||
end
|
||||
|
||||
it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
|
||||
result = described_class.new(account: account).perform
|
||||
|
||||
expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterGenerationState do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:account_id) { 42 }
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(described_class.key(generation_id))
|
||||
end
|
||||
|
||||
describe '.start' do
|
||||
it 'stores status, total, finished, and sets a ttl' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
Redis::Alfred.with do |conn|
|
||||
expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
|
||||
expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
|
||||
expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
|
||||
expect(conn.ttl(described_class.key(generation_id))).to be_positive
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.record_article_finished' do
|
||||
it 'increments finished and keeps completed true past the final count' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
|
||||
end
|
||||
|
||||
it 'raises Missing when no state exists for the generation' do
|
||||
expect { described_class.record_article_finished(generation_id) }
|
||||
.to raise_error(described_class::Missing)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.skip' do
|
||||
it 'stores status and reason' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
described_class.skip(generation_id, reason: 'no website url')
|
||||
|
||||
expect(described_class.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.current' do
|
||||
it 'returns nil when no state exists' do
|
||||
expect(described_class.current(generation_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user