452 lines
15 KiB
Markdown
452 lines
15 KiB
Markdown
## Code Context
|
|
|
|
**Query:** knowledge base, portal, article, category, folder, related category, article embedding, help center, kbase search
|
|
|
|
### Entry Points
|
|
|
|
- **Onboarding::HelpCenterArticleBuilder** (class) - enterprise/app/services/onboarding/help_center_article_builder.rb:1
|
|
- **Onboarding::HelpCenterArticleWriterJob** (class) - enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:1
|
|
- **Onboarding::HelpCenterArticleGenerationJob** (class) - enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:1
|
|
|
|
### Related Symbols
|
|
|
|
- app/jobs/application_job.rb: ApplicationJob:1
|
|
- enterprise/app/services/onboarding/help_center_article_builder.rb: initialize:4, perform:15, scrape:36, normalize:44, rewrite:57
|
|
- enterprise/app/jobs/onboarding/help_center_article_writer_job.rb: perform:12, on_writer_failure:27, failure_context:33, finalize:38
|
|
|
|
### Code
|
|
|
|
#### Onboarding::HelpCenterArticleBuilder (enterprise/app/services/onboarding/help_center_article_builder.rb:1)
|
|
|
|
```ruby
|
|
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.m
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### Onboarding::HelpCenterArticleWriterJob (enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:1)
|
|
|
|
```ruby
|
|
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: resul
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### Onboarding::HelpCenterArticleGenerationJob (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:1)
|
|
|
|
```ruby
|
|
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:
|
|
// ... truncated ...
|
|
```
|
|
|
|
#### initialize (enterprise/app/services/onboarding/help_center_article_builder.rb:4)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### perform (enterprise/app/services/onboarding/help_center_article_builder.rb:15)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### scrape (enterprise/app/services/onboarding/help_center_article_builder.rb:36)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### normalize (enterprise/app/services/onboarding/help_center_article_builder.rb:44)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### rewrite (enterprise/app/services/onboarding/help_center_article_builder.rb:57)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### perform (enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:12)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### on_writer_failure (enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:27)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### failure_context (enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:33)
|
|
|
|
```ruby
|
|
def failure_context
|
|
_account_id, _portal_id, user_id, generation_id = arguments
|
|
[User.find_by(id: user_id), generation_id]
|
|
end
|
|
```
|
|
|
|
#### finalize (enterprise/app/jobs/onboarding/help_center_article_writer_job.rb:38)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### enqueue_writer_jobs (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:89)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### perform (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:11)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### process (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:27)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### create_categories_and_build_article_payloads (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:41)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### create_categories (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:59)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### build_article_payloads (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:76)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### skip_and_broadcast (enterprise/app/jobs/onboarding/help_center_article_generation_job.rb:97)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### enqueue_article_generation (enterprise/app/services/onboarding/help_center_creation_service.rb:75)
|
|
|
|
```ruby
|
|
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
|
|
```
|
|
|
|
#### ApplicationJob (app/jobs/application_job.rb:1)
|
|
|
|
```ruby
|
|
class ApplicationJob < ActiveJob::Base
|
|
# https://api.rubyonrails.org/v5.2.1/classes/ActiveJob/Exceptions/ClassMethods.html
|
|
discard_on ActiveJob::DeserializationError do |job, error|
|
|
Rails.logger.info("Skipping #{job.class} with #{
|
|
job.instance_variable_get(:@serialized_arguments)
|
|
} because of ActiveJob::DeserializationError (#{error.message})")
|
|
end
|
|
end
|
|
``` |