improve the pdf upload functionality

This commit is contained in:
Tanmay Deep Sharma
2025-07-02 19:57:18 +05:30
parent 85c2fd10e4
commit 8e87e16486
11 changed files with 700 additions and 284 deletions
-10
View File
@@ -94,13 +94,3 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
bin/
local/
cache/
gems/
specifications/
extensions/
build_info/
bin
.pnpm-store/
@@ -0,0 +1,11 @@
class AddSourceMetadataToCaptainDocuments < ActiveRecord::Migration[7.1]
def change
add_column :captain_documents, :source_type, :string, default: 'url'
add_column :captain_documents, :content_type, :string
add_column :captain_documents, :file_size, :integer
add_column :captain_documents, :processed_at, :datetime
add_index :captain_documents, :source_type
add_index :captain_documents, :content_type
end
end
+23 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
ActiveRecord::Schema[7.1].define(version: 2025_07_02_075600) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -289,9 +289,17 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "status", default: 0, null: false
t.integer "document_type", default: 0, null: false
t.string "source_type", default: "url"
t.string "content_type"
t.integer "file_size"
t.datetime "processed_at"
t.index ["account_id"], name: "index_captain_documents_on_account_id"
t.index ["assistant_id", "external_link"], name: "index_captain_documents_on_assistant_id_and_external_link", unique: true
t.index ["assistant_id"], name: "index_captain_documents_on_assistant_id"
t.index ["content_type"], name: "index_captain_documents_on_content_type"
t.index ["document_type"], name: "index_captain_documents_on_document_type"
t.index ["source_type"], name: "index_captain_documents_on_source_type"
t.index ["status"], name: "index_captain_documents_on_status"
end
@@ -443,6 +451,18 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.index ["account_id", "profile_id"], name: "index_channel_twitter_profiles_on_account_id_and_profile_id", unique: true
end
create_table "channel_voice", force: :cascade do |t|
t.string "phone_number", null: false
t.string "provider", default: "twilio", null: false
t.jsonb "provider_config", null: false
t.integer "account_id", null: false
t.jsonb "additional_attributes", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_channel_voice_on_account_id"
t.index ["phone_number"], name: "index_channel_voice_on_phone_number", unique: true
end
create_table "channel_web_widgets", id: :serial, force: :cascade do |t|
t.string "website_url"
t.integer "account_id"
@@ -813,6 +833,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "processed_message_content"
t.jsonb "sentiment", default: {}
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
t.index ["account_id"], name: "index_messages_on_account_id"
@@ -907,7 +928,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
t.text "header_text"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "config", default: {"allowed_locales"=>["en"]}
t.jsonb "config", default: {"allowed_locales" => ["en"]}
t.boolean "archived", default: false
t.bigint "channel_web_widget_id"
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
@@ -6,7 +6,12 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create, :upload_pdf]
RESULTS_PER_PAGE = 25
# Fixed PDF size limit
MAX_PDF_SIZE = 25.megabytes
ALLOWED_PDF_CONTENT_TYPES = ['application/pdf'].freeze
PDF_MAGIC_NUMBERS = ['%PDF'].freeze
def index
base_query = @documents
@@ -28,32 +33,16 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def upload_pdf
return render_could_not_create_error('Missing Assistant') if @assistant.nil?
return render_could_not_create_error('No PDF file provided') unless pdf_file_present?
# Validate PDF file
return render_could_not_create_error('Invalid file type. Only PDF files are allowed.') unless valid_pdf_file?
return render_could_not_create_error('File size too large. Maximum size is 10MB.') unless valid_pdf_size?
# Upload PDF to storage and get URL
blob = create_pdf_blob
pdf_url = Rails.application.routes.url_helpers.rails_blob_url(blob, host: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'))
# Create document with PDF URL
@document = @assistant.documents.build(
name: pdf_params[:pdf_document].original_filename.gsub('.pdf', ''),
external_link: pdf_url
)
@document.save!
render json: {
document: @document.as_json(only: [:id, :name, :status, :created_at]),
message: 'PDF uploaded successfully. Processing will begin shortly.'
}
validation_errors = validate_upload_prerequisites
return render_could_not_create_error(validation_errors) if validation_errors
process_pdf_upload
rescue Captain::Document::LimitExceededError => e
render_could_not_create_error(e.message)
handle_limit_exceeded_error(e)
rescue ActiveStorage::FileNotFoundError => e
handle_file_not_found_error(e)
rescue StandardError => e
render_could_not_create_error("PDF upload failed: #{e.message}")
handle_general_upload_error(e)
end
def destroy
@@ -96,23 +85,99 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
pdf_params[:pdf_document].present?
end
def valid_pdf_file?
return false unless pdf_params[:pdf_document].respond_to?(:content_type)
pdf_params[:pdf_document].content_type == 'application/pdf'
end
def validate_pdf_file
file = pdf_params[:pdf_document]
return { valid: false, error: 'Invalid file object' } unless file.respond_to?(:content_type) && file.respond_to?(:size)
def valid_pdf_size?
return false unless pdf_params[:pdf_document].respond_to?(:size)
pdf_params[:pdf_document].size <= 10.megabytes
return { valid: false, error: 'Invalid file type. Only PDF files are allowed.' } unless ALLOWED_PDF_CONTENT_TYPES.include?(file.content_type)
return { valid: false, error: "File size too large. Maximum size is #{MAX_PDF_SIZE / 1.megabyte}MB." } if file.size > MAX_PDF_SIZE
{ valid: true }
end
def create_pdf_blob
file = pdf_params[:pdf_document]
ActiveStorage::Blob.create_and_upload!(
io: pdf_params[:pdf_document].tempfile,
filename: pdf_params[:pdf_document].original_filename,
content_type: pdf_params[:pdf_document].content_type
io: file.tempfile,
filename: sanitize_filename(file.original_filename),
content_type: file.content_type,
metadata: {
uploaded_by: Current.user&.id,
assistant_id: @assistant.id,
account_id: Current.account.id,
original_filename: file.original_filename
}
)
rescue StandardError => e
Rails.logger.error "Failed to create PDF blob: #{e.message}"
raise ActiveStorage::FileNotFoundError, 'Failed to upload PDF file'
end
def generate_pdf_url(blob)
Rails.application.routes.url_helpers.rails_blob_url(
blob,
host: ENV.fetch('FRONTEND_URL') { Rails.application.config.action_mailer.default_url_options[:host] }
)
end
def sanitize_filename(filename)
return 'document' if filename.blank?
base_name = File.basename(filename, '.pdf')
sanitized = base_name.gsub(/[^\w\s.-]/, '').strip.squeeze(' ')
(sanitized.presence || 'document')
end
def process_pdf_upload
ActiveRecord::Base.transaction do
blob = create_pdf_blob
pdf_url = generate_pdf_url(blob)
@document = @assistant.documents.build(
name: sanitize_filename(pdf_params[:pdf_document].original_filename),
external_link: pdf_url,
source_type: 'pdf_upload',
content_type: pdf_params[:pdf_document].content_type,
file_size: pdf_params[:pdf_document].size
)
@document.save!
log_pdf_upload_success
# Use the same response structure as create action for consistency
render :create
end
end
def log_pdf_upload_success
Rails.logger.info "PDF uploaded successfully - Document ID: #{@document.id}, Assistant ID: #{@assistant.id}, Account ID: #{Current.account.id}"
end
def validate_upload_prerequisites
return 'Missing Assistant' if @assistant.nil?
return 'No PDF file provided' unless pdf_file_present?
validation_result = validate_pdf_file
return validation_result[:error] unless validation_result[:valid]
nil
end
def handle_limit_exceeded_error(error)
Rails.logger.warn "Document limit exceeded for assistant #{@assistant.id}: #{error.message}"
render_could_not_create_error(error.message)
end
def handle_file_not_found_error(error)
Rails.logger.error "PDF file not found during upload: #{error.message}"
render_could_not_create_error('PDF file could not be processed. Please try again.')
end
def handle_general_upload_error(error)
Rails.logger.error "PDF upload failed for assistant #{@assistant&.id}: #{error.message}"
Rails.logger.error error.backtrace.join("\n")
render_could_not_create_error('PDF upload failed. Please try again.')
end
end
@@ -2,59 +2,56 @@ class Captain::Documents::CrawlJob < ApplicationJob
queue_as :low
def perform(document)
if InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
if pdf_document?(document)
perform_pdf_extraction(document)
elsif InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
perform_firecrawl_crawl(document)
else
if pdf_document?(document)
perform_pdf_extraction(document)
else
perform_simple_crawl(document)
end
perform_simple_crawl(document)
end
end
def pdf_document?(document)
return false if document.nil?
pdf_by_metadata?(document) || pdf_by_url?(document)
end
private
include Captain::FirecrawlHelper
def pdf_document?(document)
# Check if the document source is a PDF
document.external_link&.downcase&.end_with?('.pdf') ||
document.content_type&.include?('application/pdf') ||
document.source_type == 'pdf_upload'
def pdf_by_metadata?(document)
return true if document.respond_to?(:source_type) && document.source_type == 'pdf_upload'
return true if document.respond_to?(:content_type) && document.content_type&.include?('application/pdf')
false
rescue StandardError
false
end
def pdf_by_url?(document)
return false if document.external_link.blank?
url = document.external_link.downcase
url.end_with?('.pdf') ||
url.include?('/rails/active_storage/blobs/') ||
(url.include?('blob') && url.include?('pdf'))
end
def perform_pdf_extraction(document)
begin
# Mark document as in progress
document.update(status: 'in_progress')
document.update(status: 'in_progress')
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(document.external_link)
result = pdf_extraction_service.perform
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(document.external_link)
result = pdf_extraction_service.perform
if result[:success] && result[:content].present?
# Process content in chunks similar to web crawling
result[:content].each do |content_chunk|
Captain::Tools::PdfExtractionParserJob.perform_later(
assistant_id: document.assistant_id,
pdf_content: content_chunk,
document_id: document.id
)
end
# Update document status to indicate processing has started
document.update(status: 'in_progress', processed_at: nil)
else
# Handle extraction failure - mark as available but log error
error_message = result[:errors]&.join(', ') || 'Failed to extract text from PDF'
document.update(status: 'available')
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error_message}"
end
rescue StandardError => e
# Handle errors gracefully - mark as available but log error
Rails.logger.error "PDF extraction failed for document #{document.id}: #{e.message}"
document.update(status: 'available')
if result[:success] && result[:content].present?
process_pdf_content_chunks(document, result[:content])
else
handle_pdf_extraction_failure(document, result)
end
rescue StandardError => e
handle_pdf_extraction_error(document, e)
end
def perform_simple_crawl(document)
@@ -92,4 +89,42 @@ class Captain::Documents::CrawlJob < ApplicationJob
"#{webhook_url}?assistant_id=#{document.assistant_id}&token=#{generate_firecrawl_token(document.assistant_id, document.account_id)}"
end
def process_pdf_content_chunks(document, content_chunks)
Rails.logger.info "PDF extraction successful for document #{document.id}: #{content_chunks.length} chunks will be processed"
content_chunks.each_with_index do |content_chunk, index|
log_chunk_queueing(document, content_chunk, index, content_chunks.length)
queue_pdf_chunk_job(document, content_chunk)
end
document.update(status: 'in_progress', processed_at: nil)
Rails.logger.info "All #{content_chunks.length} chunks queued for processing for document #{document.id}"
end
def log_chunk_queueing(document, content_chunk, index, total_chunks)
page_num = content_chunk[:page_number]
content_length = content_chunk[:content].length
Rails.logger.info "Queueing chunk #{index + 1}/#{total_chunks} for document #{document.id} " \
"(Page #{page_num}, #{content_length} chars)"
end
def queue_pdf_chunk_job(document, content_chunk)
Captain::Tools::PdfExtractionParserJob.perform_later(
assistant_id: document.assistant_id,
pdf_content: content_chunk,
document_id: document.id
)
end
def handle_pdf_extraction_failure(document, result)
error_message = result[:errors]&.join(', ') || 'Failed to extract text from PDF'
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error_message}"
document.update(status: 'available')
end
def handle_pdf_extraction_error(document, error)
Rails.logger.error "PDF extraction failed for document #{document.id}: #{error.message}"
document.update(status: 'available')
end
end
@@ -1,13 +1,16 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document)
# Skip processing if document has no content (e.g., PDF parent documents)
return if document.content.blank?
def perform(document, full_content = nil)
# Use full content for FAQ generation if provided (for PDFs), otherwise use document.content
content_for_faqs = full_content || document.content
# Skip processing if no content available
return if content_for_faqs.blank?
reset_previous_responses(document)
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
faqs = Captain::Llm::FaqGeneratorService.new(content_for_faqs).generate
faqs.each do |faq|
create_response(faq, document)
end
@@ -1,77 +1,134 @@
class Captain::Tools::PdfExtractionParserJob < ApplicationJob
queue_as :low
retry_on StandardError, wait: :exponentially_longer, attempts: 3
# Redis and content management settings
REDIS_KEY_TTL = 7200 # 2 hours - increased for larger PDFs
DB_STORAGE_LIMIT = 50_000 # Store only first 50k chars in DB for search/preview
def perform(assistant_id:, pdf_content:, document_id: nil)
assistant = Captain::Assistant.find(assistant_id)
account = assistant.account
validate_inputs!(assistant_id, pdf_content, document_id)
if limit_exceeded?(account)
Rails.logger.info("Document limit exceeded for assistant #{assistant_id}")
return
end
assistant = load_assistant(assistant_id)
return if processing_should_skip?(assistant)
return unless document_id.present?
# Find the original document
document = Captain::Document.find_by(id: document_id)
document = load_document(document_id)
return unless document
# Extract content from PDF content hash
content = pdf_content[:content] || ''
page_number = pdf_content[:page_number] || 1
chunk_index = pdf_content[:chunk_index] || 1
total_chunks = pdf_content[:total_chunks] || 1
# Append content to the original document
append_content_to_document(document, content, page_number, chunk_index, total_chunks)
content_data = extract_content_data(pdf_content)
log_chunk_processing(document_id, content_data)
append_content_to_document(document, content_data)
rescue ActiveRecord::RecordNotFound => e
handle_record_not_found_error(e, document_id)
rescue StandardError => e
Rails.logger.error "Failed to parse PDF content for assistant #{assistant_id}: #{e.message}"
# Mark the document as available even if there's an error, so it doesn't get stuck
document&.update(status: 'available') if document_id.present?
raise "Failed to parse PDF data: #{e.message}"
handle_processing_error(e, assistant_id, document_id)
end
private
def validate_inputs!(assistant_id, pdf_content, document_id)
raise ArgumentError, 'Assistant ID is required' if assistant_id.blank?
raise ArgumentError, 'PDF content is required' if pdf_content.blank?
raise ArgumentError, 'Document ID is required' if document_id.blank?
end
def extract_content_data(pdf_content)
{
content: pdf_content[:content] || '',
page_number: pdf_content[:page_number] || 1,
chunk_index: pdf_content[:chunk_index] || 1,
total_chunks: pdf_content[:total_chunks] || 1
}
end
def mark_document_as_available(document_id)
return if document_id.blank?
Captain::Document.find_by(id: document_id)&.update(status: 'available')
rescue StandardError => e
Rails.logger.error "Failed to mark document #{document_id} as available: #{e.message}"
end
def limit_exceeded?(account)
limits = account.usage_limits.dig(:captain, :documents)
return false unless limits
limits[:current_available].to_i <= 0
end
def append_content_to_document(document, content, page_number, chunk_index, total_chunks)
# Use Redis to coordinate content aggregation across multiple jobs
redis_key = "pdf_content_#{document.id}"
# Store this chunk's content in Redis with metadata
chunk_data = {
content: content,
page_number: page_number,
chunk_index: chunk_index,
total_chunks: total_chunks,
def build_redis_key(document_id)
"pdf_content_#{document_id}"
end
def build_chunk_data(content_data)
{
content: content_data[:content],
page_number: content_data[:page_number],
chunk_index: content_data[:chunk_index],
total_chunks: content_data[:total_chunks],
processed_at: Time.current.to_i
}
# Add chunk to Redis hash using Alfred
$alfred.with do |conn|
conn.hset(redis_key, "#{page_number}_#{chunk_index}", chunk_data.to_json)
conn.expire(redis_key, 3600) # Expire after 1 hour
end
# Check if all chunks are processed
all_chunks = $alfred.with { |conn| conn.hgetall(redis_key) }
total_expected_chunks = calculate_total_expected_chunks(all_chunks)
if all_chunks.size >= total_expected_chunks
# All chunks processed, aggregate content
aggregate_and_update_document(document, all_chunks, redis_key)
end
def store_chunk_in_redis(redis_key, content_data, chunk_data)
chunk_key = "#{content_data[:page_number]}_#{content_data[:chunk_index]}"
redis_connection.with do |conn|
conn.hset(redis_key, chunk_key, chunk_data.to_json)
conn.expire(redis_key, REDIS_KEY_TTL)
end
end
def retrieve_all_chunks(redis_key)
redis_connection.with { |conn| conn.hgetall(redis_key) }
end
def ready_for_aggregation?(current_chunks, expected_chunks)
current_chunks >= expected_chunks
end
def parse_and_sort_chunks(all_chunks)
parsed_chunks = all_chunks.map do |_key, chunk_json|
chunk_data = JSON.parse(chunk_json)
[chunk_data['page_number'], chunk_data['chunk_index'], chunk_data['content']]
end
parsed_chunks.sort_by { |page, chunk_idx, _| [page, chunk_idx] }
rescue JSON::ParserError => e
Rails.logger.error "Failed to parse chunk JSON: #{e.message}"
raise 'Invalid chunk data format'
end
def combine_chunks_content(sorted_chunks)
sorted_chunks.map(&:last).join("\n\n")
end
def cleanup_redis_key(redis_key)
redis_connection.with { |conn| conn.del(redis_key) }
rescue Redis::BaseError => e
Rails.logger.warn "Failed to cleanup Redis key #{redis_key}: #{e.message}"
end
def append_content_to_document(document, content_data)
redis_key = build_redis_key(document.id)
chunk_data = build_chunk_data(content_data)
store_chunk_in_redis(redis_key, content_data, chunk_data)
# Process each chunk individually with response builder
Captain::Documents::ResponseBuilderJob.perform_later(document, content_data[:content])
all_chunks = retrieve_all_chunks(redis_key)
total_expected_chunks = calculate_total_expected_chunks(all_chunks)
finalize_document_processing(document, all_chunks, redis_key) if ready_for_aggregation?(all_chunks.size, total_expected_chunks)
rescue Redis::BaseError => e
Rails.logger.error "Redis error during PDF content processing: #{e.message}"
raise 'Failed to process PDF content due to storage error'
end
def calculate_total_expected_chunks(all_chunks)
# Calculate total expected chunks based on the chunks we have
all_chunks.values.map do |chunk_json|
@@ -79,26 +136,52 @@ class Captain::Tools::PdfExtractionParserJob < ApplicationJob
chunk_data['total_chunks']
end.max || 1
end
def aggregate_and_update_document(document, all_chunks, redis_key)
# Parse and sort chunks by page and chunk index
sorted_chunks = all_chunks.map do |key, chunk_json|
def finalize_document_processing(document, all_chunks, redis_key)
sorted_chunks = parse_and_sort_chunks(all_chunks)
combined_content = combine_chunks_content(sorted_chunks)
log_document_processing_summary(document, all_chunks, combined_content)
update_document_with_content(document, combined_content)
cleanup_redis_key(redis_key)
Rails.logger.info "PDF content finalized for document #{document.id}: #{combined_content.length} characters"
rescue ActiveRecord::RecordInvalid => e
handle_document_update_error(e, document, redis_key)
end
def log_document_processing_summary(document, all_chunks, combined_content)
log_finalization_info(document, all_chunks, combined_content)
chunk_summary = all_chunks.map do |_key, chunk_json|
chunk_data = JSON.parse(chunk_json)
[chunk_data['page_number'], chunk_data['chunk_index'], chunk_data['content']]
end.sort_by { |page, chunk_idx, _| [page, chunk_idx] }
# Combine all content
combined_content = sorted_chunks.map(&:last).join("\n\n")
# Update the document with combined content
document.update!(
content: combined_content[0..199_999], # Respect the 200k character limit
status: 'available'
)
# Clean up Redis key
$alfred.with { |conn| conn.del(redis_key) }
Rails.logger.info "Successfully aggregated PDF content for document #{document.id}"
"Page #{chunk_data['page_number']} Chunk #{chunk_data['chunk_index']}"
end
Rails.logger.info "Document #{document.id} chunks processed: #{chunk_summary.join(', ')}"
end
def update_document_with_content(document, combined_content)
db_content = prepare_content_for_storage(combined_content)
ActiveRecord::Base.transaction do
document.update!(
content: db_content,
status: 'available',
processed_at: Time.current
)
Rails.logger.info "Document #{document.id} content saved: #{db_content.length} characters"
end
end
def prepare_content_for_storage(combined_content)
return combined_content if combined_content.length <= DB_STORAGE_LIMIT
"#{combined_content[0, DB_STORAGE_LIMIT]}... [Content truncated for storage - full content processed by AI]"
end
def handle_document_update_error(error, document, redis_key)
Rails.logger.error "Failed to update document #{document.id}: #{error.message}"
cleanup_redis_key(redis_key)
raise
end
end
+48 -1
View File
@@ -4,8 +4,14 @@
#
# id :bigint not null, primary key
# content :text
# content_type :string
# document_type :integer default(0), not null
# external_link :string not null
# faq_data :json
# file_size :integer
# name :string
# processed_at :datetime
# source_type :string default("url")
# status :integer default("in_progress"), not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -17,6 +23,10 @@
# index_captain_documents_on_account_id (account_id)
# index_captain_documents_on_assistant_id (assistant_id)
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
# index_captain_documents_on_content_type (content_type)
# index_captain_documents_on_document_type (document_type)
# index_captain_documents_on_faq_data (faq_data) USING gin
# index_captain_documents_on_source_type (source_type)
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
@@ -29,8 +39,10 @@ class Captain::Document < ApplicationRecord
validates :external_link, presence: true
validates :external_link, uniqueness: { scope: :assistant_id }
validates :content, length: { maximum: 200_000 }
validates :content, length: { maximum: 400_000 }
validates :source_type, inclusion: { in: %w[url pdf_upload] }
before_validation :ensure_account_id
before_validation :set_default_source_type
enum status: {
in_progress: 0,
@@ -57,6 +69,8 @@ class Captain::Document < ApplicationRecord
def enqueue_response_builder_job
return if status != 'available'
# Skip auto-enqueue for PDFs as they handle FAQ generation manually with full content
return if pdf_document?
Captain::Documents::ResponseBuilderJob.perform_later(self)
end
@@ -73,4 +87,37 @@ class Captain::Document < ApplicationRecord
limits = account.usage_limits[:captain][:documents]
raise LimitExceededError, 'Document limit exceeded' unless limits[:current_available].positive?
end
def set_default_source_type
return if source_type.present?
# Determine type without calling pdf_document? to avoid circular dependency
self.source_type = if content_type&.include?('application/pdf') || pdf_url_format?
'pdf_upload'
else
'url'
end
end
# Public method used by CrawlJob to determine if document is a PDF
def pdf_document?
pdf_upload? || pdf_content_type? || pdf_url_format?
end
def pdf_upload?
source_type == 'pdf_upload'
end
def pdf_content_type?
content_type&.include?('application/pdf')
end
def pdf_url_format?
return false if external_link.blank?
url = external_link.downcase
url.end_with?('.pdf') ||
url.include?('/rails/active_storage/blobs/') ||
(url.include?('blob') && url.include?('pdf'))
end
end
@@ -0,0 +1,89 @@
module Captain::Tools::PdfContentChunkingConcern
extend ActiveSupport::Concern
private
def chunk_content(page_contents, max_chunk_size: self.class::MAX_CHUNK_SIZE)
return [] if page_contents.blank?
all_chunks = []
total_page_chunks = 0
# First pass: calculate total chunks across all pages
page_contents.each do |page_content|
page_chunks = split_content_into_chunks(page_content[:content], max_chunk_size)
total_page_chunks += page_chunks.length
end
chunk_index = 0
page_contents.each do |page_content|
page_chunks = split_content_into_chunks(page_content[:content], max_chunk_size)
page_chunks.each do |chunk_content|
chunk_index += 1
all_chunks << build_chunk(chunk_content, page_content[:page_number], chunk_index, total_page_chunks)
end
end
all_chunks
end
def split_content_into_chunks(content, max_size)
return [content] if content.length <= max_size
paragraphs = content.split(/\n\s*\n/)
chunks = []
current_chunk = ''
paragraphs.each do |paragraph|
if paragraph.length > max_size
add_chunk_if_present(chunks, current_chunk)
chunks.concat(split_paragraph_into_chunks(paragraph, max_size))
current_chunk = ''
elsif ("#{current_chunk}\n\n#{paragraph}").length > max_size
add_chunk_if_present(chunks, current_chunk)
current_chunk = paragraph
else
current_chunk = current_chunk.blank? ? paragraph : "#{current_chunk}\n\n#{paragraph}"
end
end
add_chunk_if_present(chunks, current_chunk)
chunks.presence || [content]
end
def split_paragraph_into_chunks(paragraph, max_size)
sentences = paragraph.split(/(?<=[.!?])\s+/)
chunks = []
current_chunk = ''
sentences.each do |sentence|
if sentence.length > max_size
add_chunk_if_present(chunks, current_chunk)
chunks << sentence[0, max_size]
current_chunk = ''
elsif ("#{current_chunk} #{sentence}").length > max_size
add_chunk_if_present(chunks, current_chunk)
current_chunk = sentence
else
current_chunk = current_chunk.blank? ? sentence : "#{current_chunk} #{sentence}"
end
end
add_chunk_if_present(chunks, current_chunk)
chunks
end
def add_chunk_if_present(chunks, chunk)
chunks << chunk.strip if chunk.present?
end
def build_chunk(content, page_number, chunk_index, total_chunks)
{
content: content,
page_number: page_number,
chunk_index: chunk_index,
total_chunks: total_chunks
}
end
end
@@ -2,35 +2,43 @@ require 'pdf-reader'
class Captain::Tools::PdfExtractionService
include ActiveModel::Validations
include Captain::Tools::PdfValidationConcern
include Captain::Tools::PdfContentChunkingConcern
attr_reader :pdf_source, :errors
# Fixed PDF processing limits
MAX_PDF_SIZE = 25.megabytes
MAX_CHUNK_SIZE = 10_000
DOWNLOAD_TIMEOUT = 60 # Increased for larger file downloads
def initialize(pdf_source)
@pdf_source = pdf_source
@errors = []
end
def perform
return { success: false, errors: ['Invalid PDF source'] } if pdf_source.blank?
return failure_response(['Invalid PDF source']) if pdf_source.blank?
begin
validate_pdf_source
content = extract_text
chunked_content = chunk_content(content)
{ success: true, content: chunked_content }
rescue PDF::Reader::MalformedPDFError => e
Rails.logger.error "Malformed PDF: #{e.message}"
{ success: false, errors: ['Invalid PDF format'] }
rescue StandardError => e
Rails.logger.error "PDF extraction error: #{e.message}"
{ success: false, errors: [e.message] }
end
validation_result = validate_pdf_source
return validation_result unless validation_result[:success]
process_pdf_extraction
rescue PDF::Reader::MalformedPDFError => e
handle_malformed_pdf_error(e)
rescue PDF::Reader::UnsupportedFeatureError => e
handle_unsupported_feature_error(e)
rescue Down::TimeoutError => e
handle_timeout_error(e)
rescue StandardError => e
handle_general_error(e)
end
def extract_text
if pdf_source.is_a?(String) && pdf_source.start_with?('http')
extract_from_url
elsif pdf_source.respond_to?(:tempfile)
case determine_source_type
when :url
active_storage_blob_url? ? extract_from_active_storage_blob : extract_from_url
when :uploaded_file
extract_from_uploaded_file
else
extract_from_file_path
@@ -39,36 +47,28 @@ class Captain::Tools::PdfExtractionService
private
def validate_pdf_source
if pdf_source.is_a?(String) && pdf_source.start_with?('http')
validate_url
elsif pdf_source.respond_to?(:tempfile)
validate_uploaded_file
else
validate_file_path
end
end
def determine_source_type
return :url if pdf_source.is_a?(String) && pdf_source.start_with?('http')
return :uploaded_file if pdf_source.respond_to?(:tempfile)
def validate_url
uri = URI.parse(pdf_source)
raise StandardError, 'Invalid URL' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def validate_uploaded_file
raise StandardError, 'File too large' if pdf_source.size > 10.megabytes
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
end
def validate_file_path
raise StandardError, 'File does not exist' unless File.exist?(pdf_source)
raise StandardError, 'File too large' if File.size(pdf_source) > 10.megabytes
:file_path
end
def extract_from_url
temp_file = Down.download(pdf_source)
result = extract_from_file(temp_file.path)
temp_file.close
temp_file.unlink
temp_file = Down.download(
pdf_source,
max_size: MAX_PDF_SIZE,
open_timeout: DOWNLOAD_TIMEOUT,
read_timeout: DOWNLOAD_TIMEOUT
)
begin
result = extract_from_file(temp_file.path)
ensure
temp_file.close
temp_file.unlink
end
result
end
@@ -80,24 +80,70 @@ class Captain::Tools::PdfExtractionService
extract_from_file(pdf_source)
end
def active_storage_blob_url?
pdf_source.include?('/rails/active_storage/blobs/') ||
(pdf_source.include?('blob') && pdf_source.include?('pdf'))
end
def extract_from_active_storage_blob
blob = find_blob_from_url
return extract_blob_content(blob) if blob
Rails.logger.warn 'ActiveStorage blob not found, falling back to URL download'
extract_from_url
end
def extract_blob_content(blob)
blob.open { |file| extract_from_file(file.path) }
end
def find_blob_from_url
blob_key = extract_blob_key_from_url
return nil if blob_key.blank?
ActiveStorage::Blob.find_by(key: blob_key)
rescue StandardError => e
Rails.logger.error "Error finding blob with key '#{blob_key}': #{e.message}"
nil
end
def extract_blob_key_from_url
blob_key = if pdf_source.include?('/rails/active_storage/blobs/')
extract_key_from_rails_path
elsif pdf_source.include?('/blobs/')
extract_key_from_blob_path
end
blob_key&.split('?')&.first # Remove query parameters
end
def extract_key_from_rails_path
parts = pdf_source.split('/rails/active_storage/blobs/').last.split('/')
parts.length > 1 && parts[0] != 'redirect' ? parts[0] : parts[1]
end
def extract_key_from_blob_path
pdf_source.split('/blobs/').last.split('/').first
end
def extract_from_file(file_path)
text_content = []
PDF::Reader.open(file_path) do |reader|
reader.pages.each_with_index do |page, index|
begin
page_text = page.text
next if page_text.blank?
cleaned_text = clean_text(page_text)
page_text = page.text
next if page_text.blank?
cleaned_text = clean_text(page_text)
if cleaned_text.present?
text_content << {
page_number: index + 1,
content: cleaned_text
} if cleaned_text.present?
rescue StandardError => e
Rails.logger.warn "Failed to extract text from page #{index + 1}: #{e.message}"
next
}
end
rescue StandardError => e
Rails.logger.warn "Failed to extract text from page #{index + 1}: #{e.message}"
next
end
end
@@ -106,90 +152,74 @@ class Captain::Tools::PdfExtractionService
def clean_text(text)
# Remove form feeds and normalize whitespace
cleaned = text.gsub(/\f/, "\n")
.gsub(/\r\n/, "\n")
.gsub(/\r/, "\n")
cleaned = text.tr("\f", "\n")
.gsub("\r\n", "\n")
.tr("\r", "\n")
.gsub(/\s+/, ' ')
.gsub(/\n\s*\n\s*\n+/, "\n\n")
.strip
# Remove common PDF artifacts
cleaned = cleaned.gsub(/^\d+\s*$/, '') # Remove standalone page numbers
.gsub(/^[\s\-_=]+$/, '') # Remove separator lines
.strip
.gsub(/^[\s\-_=]+$/, '') # Remove separator lines
.strip
cleaned.present? ? cleaned : nil
(cleaned.presence)
end
def chunk_content(page_contents, max_chunk_size: 2000)
return [] if page_contents.blank?
chunks = []
page_contents.each do |page_data|
content = page_data[:content]
page_number = page_data[:page_number]
# If content is small enough, keep as single chunk
if content.length <= max_chunk_size
chunks << {
content: content,
page_number: page_number,
chunk_index: 1,
total_chunks: 1
}
else
# Split large content into smaller chunks
page_chunks = split_content_into_chunks(content, max_chunk_size)
page_chunks.each_with_index do |chunk_content, index|
chunks << {
content: chunk_content,
page_number: page_number,
chunk_index: index + 1,
total_chunks: page_chunks.length
}
end
end
def pdf_source_type
case pdf_source
when String
pdf_source.start_with?('http') ? 'URL' : 'file_path'
else
'uploaded_file'
end
chunks
end
def split_content_into_chunks(content, max_size)
# Split by paragraphs first
paragraphs = content.split(/\n\s*\n/)
chunks = []
current_chunk = ""
def log_extraction_success(chunked_content)
total_chars = chunked_content.sum { |chunk| chunk[:content].length }
Rails.logger.info "PDF extraction completed: #{chunked_content.length} chunks, #{total_chars} characters"
Rails.logger.info "PDF chunks breakdown: #{chunked_content.map do |chunk|
"Page #{chunk[:page_number]} (#{chunk[:content].length} chars)"
end.join(', ')}"
end
paragraphs.each do |paragraph|
# If adding this paragraph would exceed the limit
if (current_chunk + "\n\n" + paragraph).length > max_size
# Save current chunk if it has content
chunks << current_chunk.strip if current_chunk.present?
# If single paragraph is too large, split by sentences
if paragraph.length > max_size
sentences = paragraph.split(/(?<=[.!?])\s+/)
current_chunk = ""
sentences.each do |sentence|
if (current_chunk + " " + sentence).length > max_size
chunks << current_chunk.strip if current_chunk.present?
current_chunk = sentence
else
current_chunk += (current_chunk.present? ? " " : "") + sentence
end
end
else
current_chunk = paragraph
end
else
current_chunk += (current_chunk.present? ? "\n\n" : "") + paragraph
end
end
def process_pdf_extraction
content = extract_text
return failure_response(['No text content found in PDF']) if content.blank?
# Add the last chunk
chunks << current_chunk.strip if current_chunk.present?
chunks
chunked_content = chunk_content(content)
log_extraction_success(chunked_content)
success_response(chunked_content)
end
def success_response(content)
{ success: true, content: content }
end
def failure_response(errors)
{ success: false, errors: errors }
end
def handle_malformed_pdf_error(error)
Rails.logger.error "Malformed PDF (#{pdf_source_type}): #{error.message}"
failure_response(['Invalid or corrupted PDF format'])
end
def handle_unsupported_feature_error(error)
Rails.logger.error "Unsupported PDF feature (#{pdf_source_type}): #{error.message}"
failure_response(['PDF contains unsupported features'])
end
def handle_timeout_error(error)
Rails.logger.error "PDF download timeout (#{pdf_source_type}): #{error.message}"
failure_response(['PDF download timed out'])
end
def handle_general_error(error)
Rails.logger.error "PDF extraction error (#{pdf_source_type}): #{error.message}"
Rails.logger.error error.backtrace.join("\n")
failure_response(['Failed to process PDF'])
end
end
@@ -0,0 +1,42 @@
module Captain::Tools::PdfValidationConcern
extend ActiveSupport::Concern
private
def validate_pdf_source
case determine_source_type
when :url then validate_url
when :uploaded_file then validate_uploaded_file
else validate_file_path
end
{ success: true }
rescue StandardError => e
Rails.logger.error "PDF validation failed: #{e.message}"
{ success: false, errors: [e.message] }
end
def validate_url
uri = URI.parse(pdf_source)
raise StandardError, 'Invalid URL format' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
raise StandardError, 'URL too long' if pdf_source.length > 2048
raise StandardError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme)
rescue URI::InvalidURIError
raise StandardError, 'Malformed URL'
end
def validate_uploaded_file
raise StandardError, 'File object is invalid' unless pdf_source.respond_to?(:size) && pdf_source.respond_to?(:content_type)
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if pdf_source.size > self.class::MAX_PDF_SIZE
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
raise StandardError, 'Empty file' if pdf_source.empty?
end
def validate_file_path
raise StandardError, 'File path is blank' if pdf_source.blank?
raise StandardError, 'File does not exist' unless File.exist?(pdf_source)
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if File.size(pdf_source) > self.class::MAX_PDF_SIZE
raise StandardError, 'Empty file' if File.empty?(pdf_source)
raise StandardError, 'File is not readable' unless File.readable?(pdf_source)
end
end