Files
chatwoot/enterprise/app/services/captain/llm/article_writer_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

99 lines
3.5 KiB
Ruby

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(feature: 'help_center_article_generation', 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 build_follow_up_context?
false
end
end