Merge branch 'develop' into feat/voice-as-twilio-capability

This commit is contained in:
Muhsin Keloth
2026-04-23 11:32:13 +04:00
committed by GitHub
29 changed files with 929 additions and 135 deletions
@@ -0,0 +1,97 @@
class Captain::Documents::PerformSyncJob < MutexApplicationJob
queue_as :low
LOCK_TIMEOUT = 10.minutes
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
# random infra issues. Three attempts lets a real hiccup recover. The exhaustion block
# absorbs the final exception so Sidekiq doesn't layer its own retry policy on top, and
# is the single place we report to Sentry — handle_unexpected_failure logs but does not
# capture, so a deterministic bug emits one Sentry event instead of one per attempt.
# Goes first because retry_on handlers dispatch bottom-to-top.
retry_on StandardError, wait: 5.seconds, attempts: 3 do |job, error|
document = job.arguments.first
ChatwootExceptionTracker.new(error, account: document.account).capture_exception
job.send(:log_sync_outcome, document, result: :unexpected_retry_exhausted,
error_code: 'sync_error',
exception_class: error.class.name)
end
# Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
# Document is already marked failed by SyncService before the exception reaches here.
discard_on(Captain::Documents::SyncService::PermanentSyncError)
# TransientSyncError is raised by SyncService when the customer's site is unreachable —
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
# a chance to recover before we give up.
#
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
# site flakiness isn't an application bug.
retry_on(
Captain::Documents::SyncService::TransientSyncError,
wait: ->(executions) { [30.seconds, 2.minutes, 5.minutes][executions - 1] || 5.minutes },
attempts: 4
) do |job, error|
document = job.arguments.first
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
end
discard_on ActiveJob::DeserializationError
discard_on ActiveRecord::RecordNotFound
def perform(document)
start_time = Time.current
return if document.pdf_document?
with_lock(lock_key(document), LOCK_TIMEOUT) do
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
result = Captain::Documents::SyncService.new(document.reload).perform
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
end
rescue LockAcquisitionError
log_sync_outcome(document, result: :already_syncing)
rescue Captain::Documents::SyncService::PermanentSyncError => e
log_failure_and_raise(document, :permanent_failure, e, start_time)
rescue Captain::Documents::SyncService::TransientSyncError => e
log_failure_and_raise(document, :transient_failure, e, start_time)
rescue StandardError => e
handle_unexpected_failure(document, e, start_time)
end
private
def log_sync_outcome(document, **fields)
payload = {
document_id: document.id,
account_id: document.account_id,
assistant_id: document.assistant_id
}.merge(fields)
Rails.logger.info("[Captain::Documents::PerformSyncJob] #{payload.to_json}")
end
def log_failure_and_raise(document, result, error, start_time)
log_sync_outcome(document, result: result, error_code: error.message,
duration_ms: duration_ms_since(start_time))
raise error
end
def handle_unexpected_failure(document, error, start_time)
document.update!(
sync_status: :failed,
last_sync_error_code: 'sync_error',
last_sync_attempted_at: Time.current
)
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
exception_class: error.class.name,
duration_ms: duration_ms_since(start_time))
raise error
end
def lock_key(document)
format(::Redis::Alfred::CAPTAIN_DOCUMENT_SYNC_MUTEX, document_id: document.id)
end
def duration_ms_since(start_time)
((Time.current - start_time) * 1000).round
end
end
@@ -62,7 +62,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
end
def reset_previous_responses(response_document)
response_document.responses.destroy_all
response_document.responses.where(edited: false).destroy_all
end
def create_response(faq, document)
@@ -62,6 +62,7 @@ class Captain::Document < ApplicationRecord
def pdf_document?
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
return true if external_link&.start_with?('PDF:')
external_link&.ends_with?('.pdf')
end
@@ -90,6 +91,14 @@ class Captain::Document < ApplicationRecord
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
end
def sync_step
metadata&.dig('sync_step')
end
def store_sync_step(step)
update!(metadata: (metadata || {}).merge('sync_step' => step))
end
def openai_file_id
metadata&.dig('openai_file_id')
end
@@ -0,0 +1,80 @@
class Captain::Documents::SinglePageFetcher
Result = Struct.new(:success, :title, :content, :error_code, keyword_init: true)
CONTENT_MAX_LENGTH = 200_000
TITLE_MAX_LENGTH = 255 # captain_documents.name is a varchar(255)
def initialize(url)
@url = url
end
def fetch
result = firecrawl_configured? ? fetch_with_firecrawl : fetch_with_fallback
validate_content(result)
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ETIMEDOUT
Result.new(success: false, error_code: 'timeout')
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError
Result.new(success: false, error_code: 'fetch_failed')
end
private
def firecrawl_configured?
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
end
def fetch_with_firecrawl
response = Captain::Tools::FirecrawlService.new.scrape(@url)
handle_firecrawl_response(response)
end
def handle_firecrawl_response(response)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
data = response.parsed_response&.dig('data')
target_error = firecrawl_target_error_code(data)
return Result.new(success: false, error_code: target_error) if target_error
Result.new(
success: true,
title: data&.dig('metadata', 'title')&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: data&.dig('markdown')&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
# Firecrawl returns API 200 even when the scraped page itself failed —
# the target page's real status lives in data.metadata.statusCode.
def firecrawl_target_error_code(data)
status = data&.dig('metadata', 'statusCode')
return nil if status.blank? || (200..299).cover?(status)
http_error_code(status)
end
def fetch_with_fallback
response = HTTParty.get(@url)
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
parser = Captain::Tools::HtmlPageParser.new(response.body)
Result.new(
success: true,
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
)
end
def validate_content(result)
return result unless result.success && result.content.blank?
Result.new(success: false, error_code: 'content_empty')
end
def http_error_code(status_code)
case status_code
when 404 then 'not_found'
when 401, 403 then 'access_denied'
when 408, 504 then 'timeout'
else 'fetch_failed'
end
end
end
@@ -0,0 +1,76 @@
class Captain::Documents::SyncService
class PermanentSyncError < StandardError
end
class TransientSyncError < StandardError
end
PERMANENT_ERROR_CODES = %w[not_found access_denied content_empty].freeze
def initialize(document)
@document = document
end
def perform
@document.store_sync_step('fetching')
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
unless result.success
mark_failed(result.error_code)
raise_for_error_code(result.error_code)
end
@document.store_sync_step('comparing')
fingerprint = compute_fingerprint(result.content)
if fingerprint == @document.content_fingerprint
mark_synced
return :unchanged
end
@document.store_sync_step('updating')
update_content(result, fingerprint)
:updated
end
private
def compute_fingerprint(content)
Digest::SHA256.hexdigest(content.gsub(/\s+/, ' ').strip)
end
def mark_failed(error_code)
@document.update!(
sync_status: :failed,
last_sync_error_code: error_code,
last_sync_attempted_at: Time.current
)
end
def mark_synced
@document.update!(
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
end
def update_content(result, fingerprint)
@document.update!(
content: result.content,
name: result.title.presence || @document.name,
content_fingerprint: fingerprint,
sync_status: :synced,
last_synced_at: Time.current,
last_sync_attempted_at: Time.current,
last_sync_error_code: nil
)
end
def raise_for_error_code(error_code)
raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
raise TransientSyncError, error_code
end
end
@@ -1,4 +1,6 @@
class Captain::Tools::FirecrawlService
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
def initialize
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
raise 'Missing API key' if @api_key.empty?
@@ -6,7 +8,7 @@ class Captain::Tools::FirecrawlService
def perform(url, webhook_url, crawl_limit = 10)
HTTParty.post(
'https://api.firecrawl.dev/v1/crawl',
"#{BASE_URL}/crawl",
body: crawl_payload(url, webhook_url, crawl_limit),
headers: headers
)
@@ -14,6 +16,14 @@ class Captain::Tools::FirecrawlService
raise "Failed to crawl URL: #{e.message}"
end
def scrape(url)
HTTParty.post(
"#{BASE_URL}/scrape",
body: scrape_payload(url),
headers: headers
)
end
private
def crawl_payload(url, webhook_url, crawl_limit)
@@ -31,6 +41,10 @@ class Captain::Tools::FirecrawlService
}.to_json
end
def scrape_payload(url)
{ url: url, formats: ['markdown'], excludeTags: ['iframe'] }.to_json
end
def headers
{
'Authorization' => "Bearer #{@api_key}",
@@ -0,0 +1,15 @@
class Captain::Tools::HtmlPageParser
attr_reader :doc
def initialize(html)
@doc = Nokogiri::HTML(html)
end
def title
@doc.at_xpath('//title')&.text&.strip
end
def body_markdown
ReverseMarkdown.convert(@doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true)
end
end
@@ -3,7 +3,8 @@ class Captain::Tools::SimplePageCrawlService
def initialize(external_link)
@external_link = external_link
@doc = Nokogiri::HTML(HTTParty.get(external_link).body)
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
@doc = @parser.doc
end
def page_links
@@ -11,12 +12,11 @@ class Captain::Tools::SimplePageCrawlService
end
def page_title
title_element = @doc.at_xpath('//title')
title_element&.text&.strip
@parser.title
end
def body_text_content
ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
@parser.body_markdown
end
def meta_description
@@ -79,7 +79,12 @@ class Enterprise::Billing::TopupCheckoutService
def finalize_and_pay(invoice_id)
Stripe::Invoice.finalize_invoice(invoice_id, { auto_advance: false })
invoice = Stripe::Invoice.retrieve(invoice_id)
Stripe::Invoice.pay(invoice_id) unless invoice.status == 'paid'
return if invoice.status == 'paid'
Stripe::Invoice.pay(invoice_id)
rescue Stripe::CardError
Stripe::Invoice.void_invoice(invoice_id)
raise
end
def fulfill_credits(credits, topup_option)