Merge branch 'develop' into feat/app-store-reviews

This commit is contained in:
Muhsin Keloth
2026-05-22 09:03:48 +04:00
committed by GitHub
37 changed files with 1528 additions and 35 deletions
+2
View File
@@ -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 ###
##############################################################
+2
View File
@@ -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
@@ -36,11 +36,18 @@ export function useConfig() {
*/
const enterprisePlanName = config.enterprisePlanName;
/**
* Indicates whether inbox webhook events (ENABLE_INBOX_EVENTS) are enabled.
* @type {boolean}
*/
const inboxEventsEnabled = config.inboxEventsEnabled === 'true';
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
inboxEventsEnabled,
};
}
@@ -57,7 +57,8 @@
"CONTACT_CREATED": "Contact created",
"CONTACT_UPDATED": "Contact updated",
"CONVERSATION_TYPING_ON": "Conversation Typing On",
"CONVERSATION_TYPING_OFF": "Conversation Typing Off"
"CONVERSATION_TYPING_OFF": "Conversation Typing Off",
"INBOX_UPDATED": "Inbox updated"
}
},
"NAME": {
@@ -5,6 +5,7 @@ import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -55,12 +56,15 @@ export default {
},
},
data() {
const { inboxEventsEnabled } = useConfig();
return {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
supportedWebhookEvents: inboxEventsEnabled
? [...SUPPORTED_WEBHOOK_EVENTS, 'inbox_updated']
: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
+34 -2
View File
@@ -1,21 +1,53 @@
class AutoAssignment::AssignmentJob < ApplicationJob
queue_as :default
def perform(inbox_id:)
IN_FLIGHT_TTL = 5.minutes
# Coalesce per inbox: at most one AssignmentJob per inbox is in-flight
# (queued or running) at any time. The marker carries a token so a job only
# releases its own claim (a newer job may have taken it after a TTL lapse).
def self.enqueue_for_inbox(inbox_id)
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
token = SecureRandom.uuid
return false unless ::Redis::Alfred.set(key, token, nx: true, ex: IN_FLIGHT_TTL)
return true if perform_later(inbox_id: inbox_id, token: token)
# Enqueue was halted; release our own claim so the inbox isn't gated until the TTL.
::Redis::Alfred.delete_if_equals(key, token)
false
rescue StandardError
# Enqueue raised after we claimed the gate; release our own claim, then re-raise.
::Redis::Alfred.delete_if_equals(key, token)
raise
end
def perform(inbox_id:, token: nil)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
service = AutoAssignment::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment(limit: bulk_assignment_limit)
Rails.logger.info "Assigned #{assigned_count} conversations for inbox #{inbox.id}"
rescue StandardError => e
Rails.logger.error "Bulk assignment failed for inbox #{inbox_id}: #{e.message}"
raise e if Rails.env.test?
ensure
release_in_flight(inbox_id, token)
end
private
# Release the in-flight marker only if we still own it. The atomic
# compare-and-delete ensures a job whose TTL lapsed can't delete a newer
# job's claim. Tokenless (pre-deploy) jobs never claimed a key, so skip.
def release_in_flight(inbox_id, token)
return if token.nil?
key = format(::Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox_id)
::Redis::Alfred.delete_if_equals(key, token)
end
def bulk_assignment_limit
ENV.fetch('AUTO_ASSIGNMENT_BULK_LIMIT', 100).to_i
end
@@ -10,7 +10,7 @@ class AutoAssignment::PeriodicAssignmentJob < ApplicationJob
inboxes.each do |inbox|
next unless inbox.auto_assignment_v2_enabled?
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
end
end
end
@@ -15,8 +15,10 @@ module AutoAssignmentHandler
return unless should_run_auto_assignment?
if inbox.auto_assignment_v2_enabled?
# Use new assignment system
AutoAssignment::AssignmentJob.perform_later(inbox_id: inbox.id)
# Coalesces bursts of triggers per inbox. Fine if the job runs even when the
# surrounding save rolls back: it only scans the inbox's current unassigned
# conversations, so running it for an uncommitted change is harmless.
AutoAssignment::AssignmentJob.enqueue_for_inbox(inbox.id)
else
# Use legacy assignment system
# If conversation has a team, only consider team members for assignment
+13
View File
@@ -37,11 +37,14 @@ module Reauthorizable
# Performed automatically if error threshold is breached
# could used to manually prompt reauthorization if auth scope changes
def prompt_reauthorization!
state_changed = !reauthorization_required?
::Redis::Alfred.set(reauthorization_required_key, true)
reauthorization_handlers[self.class.name]&.call(self)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(true) if state_changed
end
def process_integration_hook_reauthorization_emails
@@ -63,14 +66,24 @@ module Reauthorizable
# call this after you successfully Reauthorized the object in UI
def reauthorized!
state_changed = reauthorization_required?
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
invalidate_inbox_cache unless instance_of?(::AutomationRule)
dispatch_inbox_reauthorization_event(false) if state_changed
end
private
def dispatch_inbox_reauthorization_event(reauthorization_required)
return unless respond_to?(:inbox)
return if inbox.blank?
inbox.dispatch_reauthorization_event(reauthorization_required)
end
def reauthorization_handlers
{
'Integrations::Hook' => ->(obj) { obj.process_integration_hook_reauthorization_emails },
+9
View File
@@ -211,6 +211,15 @@ class Inbox < ApplicationRecord
account.feature_enabled?('assignment_v2')
end
# Callers (Reauthorizable) only invoke this on a real transition, so the previous
# value is always the inverse of the new boolean value.
def dispatch_reauthorization_event(reauthorization_required)
return if ENV['ENABLE_INBOX_EVENTS'].blank?
changed_attributes = { reauthorization_required: [!reauthorization_required, reauthorization_required] }
Rails.configuration.dispatcher.dispatch(INBOX_UPDATED, Time.zone.now, inbox: self, changed_attributes: changed_attributes)
end
private
def default_name_for_blank_name
+1 -1
View File
@@ -23,7 +23,7 @@ class Inbox::EventDataPresenter < SimpleDelegator
timezone: timezone,
out_of_office_message: out_of_office_message,
working_hours_enabled: working_hours_enabled,
working_hours: working_hours,
working_hours: working_hours.as_json,
created_at: created_at,
updated_at: updated_at,
@@ -72,15 +72,32 @@ class AutoAssignment::AssignmentService
end
def assign_conversation(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
conversation.update!(assignee: agent)
Current.executed_by = nil
return false unless claim_and_assign(conversation, agent)
conversation.reload
rate_limiter = build_rate_limiter(agent)
rate_limiter.track_assignment(conversation)
dispatch_assignment_event(conversation, agent)
true
end
# Atomically claim the row so two bulk runs that overlap (the in-flight gate
# is best-effort and can lapse on TTL) can't both assign the same conversation.
def claim_and_assign(conversation, agent)
Current.executed_by = inbox.assignment_policy || inbox
Conversation.transaction do
locked = inbox.conversations
.where(id: conversation.id, assignee_id: nil)
.lock('FOR UPDATE SKIP LOCKED')
.first
next false unless locked
locked.update!(assignee: agent)
true
end
ensure
Current.executed_by = nil
end
+1
View File
@@ -55,6 +55,7 @@
<% end %>
enabledLanguages: <%= available_locales_with_name.to_json.html_safe %>,
helpUrls: <%= feature_help_urls.to_json.html_safe %>,
inboxEventsEnabled: '<%= ENV['ENABLE_INBOX_EVENTS'].present? %>',
selectedLocale: '<%= I18n.locale %>'
}
window.globalConfig = <%= raw @global_config.to_json %>
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+19 -7
View File
@@ -21,10 +21,26 @@ 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
# atomic compare-and-delete (release a lock only if you still own it); WATCH/MULTI
# aborts the delete if the key changes between the check and the delete.
def delete_if_equals(key, expected_value)
$alfred.with do |conn|
conn.watch(key) do
next conn.unwatch unless conn.get(key) == expected_value
conn.multi { |transaction| transaction.del(key) }
end
end
end
# increment a key by 1. throws error if key value is incompatible
# sets key to 0 before operation if key doesn't exist
def incr(key)
@@ -111,13 +127,9 @@ module Redis::Alfred
# add score and value for a key
# Modern Redis syntax: zadd(key, [[score, member], ...])
def zadd(key, score, value = nil)
if value.nil? && score.is_a?(Array)
# New syntax: score is actually an array of [score, member] pairs
$alfred.with { |conn| conn.zadd(key, score) }
else
# Support old syntax for backward compatibility
$alfred.with { |conn| conn.zadd(key, [[score, value]]) }
end
# New syntax: score is an array of [score, member] pairs; old syntax: discrete score/value
pairs = value.nil? && score.is_a?(Array) ? score : [[score, value]]
$alfred.with { |conn| conn.zadd(key, pairs) }
end
# get score of a value for key
+3
View File
@@ -73,9 +73,12 @@ module Redis::RedisKeys
# Track conversation assignments to agents for rate limiting
ASSIGNMENT_KEY = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::CONVERSATION::%<conversation_id>d'.freeze
ASSIGNMENT_KEY_PATTERN = 'ASSIGNMENT::%<inbox_id>d::AGENT::%<agent_id>d::*'.freeze
# At-most-one AssignmentJob per inbox in-flight (queued or running); further enqueues are skipped
AUTO_ASSIGNMENT_IN_FLIGHT_KEY = 'AUTO_ASSIGNMENT_IN_FLIGHT::%<inbox_id>d'.freeze
## 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
@@ -24,10 +24,11 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_return(3)
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
allow(Rails.logger).to receive(:info)
described_class.new.perform(inbox_id: inbox.id)
expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
end
it 'uses custom bulk limit from environment' do
@@ -67,16 +68,40 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
service = instance_double(AutoAssignment::AssignmentService)
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
allow(Rails.logger).to receive(:error)
expect do
described_class.new.perform(inbox_id: inbox.id)
end.to raise_error(StandardError, 'Something went wrong')
expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
end
end
end
describe '.enqueue_for_inbox' do
after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
it 'enqueues one run per inbox and coalesces concurrent triggers' do
allow(described_class).to receive(:perform_later).and_return(true)
expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
expect(described_class).to have_received(:perform_later).once
end
it 'does not release a newer run marker when its own token is stale' do
key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
Redis::Alfred.set(key, 'newer-token', ex: 300)
allow(AutoAssignment::AssignmentService).to receive(:new)
.and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
expect(Redis::Alfred.get(key)).to eq('newer-token')
end
end
describe 'job configuration' do
it 'is queued in the default queue' do
expect(described_class.queue_name).to eq('default')
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
it 'queues assignment job for eligible inboxes' do
inbox_assignment_policy # ensure it exists
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
described_class.new.perform
end
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
described_class.new.perform
end
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not queue assignment job' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
end
it 'does not process the account' do
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
described_class.new.perform
end