Files
chatwoot/enterprise/app/services/captain/llm/help_center_curation_service.rb
Sony MathewandGitHub 4e26c5b4bb feat: Route system LLM jobs (4/6) (#14843)
## Description

Routes the remaining system-only and legacy-sensitive LLM jobs through
feature-level model configuration, while preserving system credential
usage and usage-accounting behavior. This adds dedicated defaults for
help center article generation, onboarding content generation, query
translation, transcription, and search embeddings so these flows can be
configured per account without falling back to installation-wide model
settings.

Fixes https://linear.app/chatwoot/issue/CW-7425/test-new-models

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Verified the feature routing defaults and account overrides for the
touched Captain/system LLM paths, including the legacy OpenAI
transcription and paginated FAQ services.

- `eval "$(rbenv init -)" && bundle exec rspec
spec/lib/captain/base_task_service_spec.rb spec/lib/llm/models_spec.rb
spec/models/concerns/captain_featurable_spec.rb
spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb
spec/enterprise/services/captain/llm/paginated_faq_generator_service_spec.rb
spec/enterprise/services/captain/llm/pdf_processing_service_spec.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/enterprise/services/onboarding/help_center_article_builder_spec.rb
spec/enterprise/services/captain/onboarding/website_analyzer_service_spec.rb`
- `eval "$(rbenv init -)" && bundle exec rubocop
app/controllers/api/v1/accounts/captain/preferences_controller.rb
app/models/concerns/account_settings_schema.rb
lib/captain/base_task_service.rb
enterprise/app/services/captain/llm/article_translation_service.rb
enterprise/app/services/captain/llm/article_writer_service.rb
enterprise/app/services/captain/llm/embedding_service.rb
enterprise/app/services/captain/llm/help_center_curation_service.rb
enterprise/app/services/captain/llm/paginated_faq_generator_service.rb
enterprise/app/services/captain/llm/translate_query_service.rb
enterprise/app/services/captain/llm/widget_tagline_service.rb
enterprise/app/services/captain/onboarding/website_analyzer_service.rb
enterprise/app/services/messages/audio_transcription_service.rb
spec/enterprise/services/messages/audio_transcription_service_spec.rb
spec/lib/captain/base_task_service_spec.rb`
- `ruby -e "require 'yaml'; config = YAML.load_file('config/llm.yml');
%w[document_faq_generation help_center_article_generation
onboarding_content_generation help_center_query_translation
audio_transcription help_center_search].each { |feature| abort(%(missing
#{feature})) unless config.dig('features', feature) }; abort('wrong
article default') unless config.dig('features',
'help_center_article_generation', 'default') == 'gpt-5.2'; puts 'llm.yml
ok'"`
- `git diff --check`

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-06-25 17:37:45 +05:30

158 lines
6.7 KiB
Ruby

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(feature: 'onboarding_content_generation', 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