Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3854895a13 | ||
|
|
b9d022ab68 | ||
|
|
ed229eb1ae | ||
|
|
39e20f771a | ||
|
|
245e00d7a8 | ||
|
|
3af9e8714a | ||
|
|
df00820878 | ||
|
|
aafd99bed1 | ||
|
|
22b284b8e7 | ||
|
|
3fd054fcc8 | ||
|
|
848b833bc1 | ||
|
|
c60b47fd57 | ||
|
|
5bbda8e348 | ||
|
|
f07e171c0b | ||
|
|
36d09597a2 | ||
|
|
c3619f51a1 | ||
|
|
141285b036 | ||
|
|
f429db89b4 |
@@ -31,6 +31,14 @@ gem 'haikunator'
|
|||||||
gem 'liquid'
|
gem 'liquid'
|
||||||
# Parse Markdown to HTML
|
# Parse Markdown to HTML
|
||||||
gem 'commonmarker'
|
gem 'commonmarker'
|
||||||
|
# Convert PDF pages to images for content extraction
|
||||||
|
gem 'pdftoimage'
|
||||||
|
# Google Vision API for text extraction from images
|
||||||
|
gem 'google-cloud-vision'
|
||||||
|
# Tesseract OCR fallback when Vision API not available
|
||||||
|
gem 'rtesseract'
|
||||||
|
# Image manipulation for PDF page batching
|
||||||
|
gem 'mini_magick'
|
||||||
# Validate Data against JSON Schema
|
# Validate Data against JSON Schema
|
||||||
gem 'json_schemer'
|
gem 'json_schemer'
|
||||||
# used in swagger build
|
# used in swagger build
|
||||||
|
|||||||
+29
-9
@@ -275,14 +275,16 @@ GEM
|
|||||||
fugit (1.11.1)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
gapic-common (0.20.0)
|
gapic-common (1.0.0)
|
||||||
faraday (>= 1.9, < 3.a)
|
faraday (>= 1.9, < 3.a)
|
||||||
faraday-retry (>= 1.0, < 3.a)
|
faraday-retry (>= 1.0, < 3.a)
|
||||||
google-protobuf (~> 3.14)
|
google-cloud-env (~> 2.2)
|
||||||
googleapis-common-protos (>= 1.3.12, < 2.a)
|
google-logging-utils (~> 0.1)
|
||||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
google-protobuf (>= 3.25, < 5.a)
|
||||||
googleauth (~> 1.0)
|
googleapis-common-protos (~> 1.6)
|
||||||
grpc (~> 1.36)
|
googleapis-common-protos-types (~> 1.15)
|
||||||
|
googleauth (~> 1.12)
|
||||||
|
grpc (~> 1.66)
|
||||||
geocoder (1.8.1)
|
geocoder (1.8.1)
|
||||||
gli (2.22.2)
|
gli (2.22.2)
|
||||||
ostruct
|
ostruct
|
||||||
@@ -327,6 +329,13 @@ GEM
|
|||||||
google-cloud-translate-v3 (0.10.0)
|
google-cloud-translate-v3 (0.10.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-vision (2.0.2)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
google-cloud-vision-v1 (~> 1.2)
|
||||||
|
google-cloud-vision-v1 (1.3.0)
|
||||||
|
gapic-common (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-logging-utils (0.2.0)
|
||||||
google-protobuf (3.25.7)
|
google-protobuf (3.25.7)
|
||||||
googleapis-common-protos (1.6.0)
|
googleapis-common-protos (1.6.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
@@ -334,9 +343,10 @@ GEM
|
|||||||
grpc (~> 1.41)
|
grpc (~> 1.41)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.20.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleauth (1.11.2)
|
googleauth (1.14.0)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 2.1)
|
google-cloud-env (~> 2.2)
|
||||||
|
google-logging-utils (~> 0.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
@@ -381,6 +391,7 @@ GEM
|
|||||||
httpclient (2.8.3)
|
httpclient (2.8.3)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
iconv (1.1.0)
|
||||||
image_processing (1.12.2)
|
image_processing (1.12.2)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
@@ -550,6 +561,9 @@ GEM
|
|||||||
parser (3.3.8.0)
|
parser (3.3.8.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
|
pdftoimage (0.2.1)
|
||||||
|
iconv (~> 1.0)
|
||||||
|
shellwords (~> 0.2.2)
|
||||||
pg (1.5.3)
|
pg (1.5.3)
|
||||||
pg_search (2.3.6)
|
pg_search (2.3.6)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
@@ -672,6 +686,7 @@ GEM
|
|||||||
rspec-support (3.13.1)
|
rspec-support (3.13.1)
|
||||||
rspec_junit_formatter (0.6.0)
|
rspec_junit_formatter (0.6.0)
|
||||||
rspec-core (>= 2, < 4, != 2.12.0)
|
rspec-core (>= 2, < 4, != 2.12.0)
|
||||||
|
rtesseract (3.1.4)
|
||||||
rubocop (1.75.6)
|
rubocop (1.75.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
@@ -747,6 +762,7 @@ GEM
|
|||||||
sentry-ruby (~> 5.19.0)
|
sentry-ruby (~> 5.19.0)
|
||||||
sidekiq (>= 3.0)
|
sidekiq (>= 3.0)
|
||||||
sexp_processor (4.17.0)
|
sexp_processor (4.17.0)
|
||||||
|
shellwords (0.2.2)
|
||||||
shopify_api (14.8.0)
|
shopify_api (14.8.0)
|
||||||
activesupport
|
activesupport
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
@@ -927,6 +943,7 @@ DEPENDENCIES
|
|||||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||||
google-cloud-storage (>= 1.48.0)
|
google-cloud-storage (>= 1.48.0)
|
||||||
google-cloud-translate-v3 (>= 0.7.0)
|
google-cloud-translate-v3 (>= 0.7.0)
|
||||||
|
google-cloud-vision
|
||||||
groupdate
|
groupdate
|
||||||
grpc
|
grpc
|
||||||
haikunator
|
haikunator
|
||||||
@@ -950,6 +967,7 @@ DEPENDENCIES
|
|||||||
lograge (~> 0.14.0)
|
lograge (~> 0.14.0)
|
||||||
maxminddb
|
maxminddb
|
||||||
meta_request (>= 0.8.3)
|
meta_request (>= 0.8.3)
|
||||||
|
mini_magick
|
||||||
mock_redis
|
mock_redis
|
||||||
neighbor
|
neighbor
|
||||||
net-smtp (~> 0.3.4)
|
net-smtp (~> 0.3.4)
|
||||||
@@ -959,6 +977,7 @@ DEPENDENCIES
|
|||||||
omniauth-google-oauth2 (>= 1.1.3)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
|
pdftoimage
|
||||||
pg
|
pg
|
||||||
pg_search
|
pg_search
|
||||||
pgvector
|
pgvector
|
||||||
@@ -978,6 +997,7 @@ DEPENDENCIES
|
|||||||
reverse_markdown
|
reverse_markdown
|
||||||
rspec-rails (>= 6.1.5)
|
rspec-rails (>= 6.1.5)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
|
rtesseract
|
||||||
rubocop
|
rubocop
|
||||||
rubocop-factory_bot
|
rubocop-factory_bot
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
@@ -1020,4 +1040,4 @@ RUBY VERSION
|
|||||||
ruby 3.4.4p34
|
ruby 3.4.4p34
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.5.16
|
2.6.7
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class AddDocumentTypeAndFileToCaptainDocuments < ActiveRecord::Migration[7.0]
|
||||||
|
def change
|
||||||
|
add_column :captain_documents, :document_type, :integer, default: 0, null: false
|
||||||
|
add_index :captain_documents, :document_type
|
||||||
|
end
|
||||||
|
end
|
||||||
+4
-2
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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_06_23_120000) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -289,9 +289,11 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.integer "status", default: 0, null: false
|
t.integer "status", default: 0, null: false
|
||||||
|
t.integer "document_type", default: 0, null: false
|
||||||
t.index ["account_id"], name: "index_captain_documents_on_account_id"
|
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", "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 ["assistant_id"], name: "index_captain_documents_on_assistant_id"
|
||||||
|
t.index ["document_type"], name: "index_captain_documents_on_document_type"
|
||||||
t.index ["status"], name: "index_captain_documents_on_status"
|
t.index ["status"], name: "index_captain_documents_on_status"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -907,7 +909,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
|
|||||||
t.text "header_text"
|
t.text "header_text"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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.boolean "archived", default: false
|
||||||
t.bigint "channel_web_widget_id"
|
t.bigint "channel_web_widget_id"
|
||||||
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
|
t.index ["channel_web_widget_id"], name: "index_portals_on_channel_web_widget_id"
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ RUN apk update && apk add --no-cache \
|
|||||||
git \
|
git \
|
||||||
curl \
|
curl \
|
||||||
xz \
|
xz \
|
||||||
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
&& mkdir -p /var/app \
|
&& mkdir -p /var/app \
|
||||||
&& gem install bundler
|
&& gem install bundler
|
||||||
|
|
||||||
@@ -128,6 +130,8 @@ RUN apk update && apk add --no-cache \
|
|||||||
imagemagick \
|
imagemagick \
|
||||||
git \
|
git \
|
||||||
vips \
|
vips \
|
||||||
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
&& gem install bundler
|
&& gem install bundler
|
||||||
|
|
||||||
COPY --from=node /usr/local/bin/node /usr/local/bin/
|
COPY --from=node /usr/local/bin/node /usr/local/bin/
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
|||||||
|
|
||||||
def playground
|
def playground
|
||||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||||
params[:message_content],
|
additional_message: params[:message_content],
|
||||||
message_history
|
message_history: message_history
|
||||||
)
|
)
|
||||||
|
|
||||||
render json: response
|
render json: response
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
|
|
||||||
def generate_and_process_response
|
def generate_and_process_response
|
||||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||||
@conversation.messages.incoming.last.content,
|
message_history: collect_previous_messages
|
||||||
collect_previous_messages
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return process_action('handoff') if handoff_requested?
|
return process_action('handoff') if handoff_requested?
|
||||||
@@ -43,33 +42,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
.where(message_type: [:incoming, :outgoing])
|
.where(message_type: [:incoming, :outgoing])
|
||||||
.where(private: false)
|
.where(private: false)
|
||||||
.map do |message|
|
.map do |message|
|
||||||
{
|
{
|
||||||
content: message_content(message),
|
content: prepare_multimodal_message_content(message),
|
||||||
role: determine_role(message)
|
role: determine_role(message)
|
||||||
}
|
}
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def message_content(message)
|
|
||||||
return message.content if message.content.present?
|
|
||||||
return 'User has shared a message without content' unless message.attachments.any?
|
|
||||||
|
|
||||||
audio_transcriptions = extract_audio_transcriptions(message.attachments)
|
|
||||||
return audio_transcriptions if audio_transcriptions.present?
|
|
||||||
|
|
||||||
'User has shared an attachment'
|
|
||||||
end
|
|
||||||
|
|
||||||
def extract_audio_transcriptions(attachments)
|
|
||||||
audio_attachments = attachments.where(file_type: :audio)
|
|
||||||
return '' if audio_attachments.blank?
|
|
||||||
|
|
||||||
transcriptions = ''
|
|
||||||
audio_attachments.each do |attachment|
|
|
||||||
result = Messages::AudioTranscriptionService.new(attachment).perform
|
|
||||||
transcriptions += result[:transcriptions] if result[:success]
|
|
||||||
end
|
end
|
||||||
transcriptions
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def determine_role(message)
|
def determine_role(message)
|
||||||
@@ -78,6 +55,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
message.message_type == 'incoming' ? 'user' : 'system'
|
message.message_type == 'incoming' ? 'user' : 'system'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prepare_multimodal_message_content(message)
|
||||||
|
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||||
|
end
|
||||||
|
|
||||||
def handoff_requested?
|
def handoff_requested?
|
||||||
@response['response'] == 'conversation_handoff'
|
@response['response'] == 'conversation_handoff'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
class Captain::Documents::PdfProcessingJob < ApplicationJob
|
||||||
|
queue_as :low
|
||||||
|
|
||||||
|
def perform(document)
|
||||||
|
return unless document.pdf_document?
|
||||||
|
|
||||||
|
Rails.logger.info "Starting PDF processing for document #{document.id}"
|
||||||
|
|
||||||
|
begin
|
||||||
|
service = Captain::Tools::PdfProcessingService.new(document)
|
||||||
|
extracted_content = service.process
|
||||||
|
|
||||||
|
Rails.logger.info "Successfully processed PDF document #{document.id}, extracted #{extracted_content.length} characters"
|
||||||
|
|
||||||
|
rescue Captain::Tools::PdfProcessingService::PdfProcessingError => e
|
||||||
|
Rails.logger.error "PDF processing failed for document #{document.id}: #{e.message}"
|
||||||
|
|
||||||
|
# Mark document as failed (you may want to add a 'failed' status to the enum)
|
||||||
|
document.update!(status: :in_progress) # Keep as in_progress for potential retry
|
||||||
|
|
||||||
|
# Optionally, you could add a retry mechanism or notification
|
||||||
|
raise e
|
||||||
|
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Unexpected error processing PDF document #{document.id}: #{e.message}"
|
||||||
|
document.update!(status: :in_progress)
|
||||||
|
raise e
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# content :text
|
# content :text
|
||||||
|
# document_type :integer default("url"), not null
|
||||||
# external_link :string not null
|
# external_link :string not null
|
||||||
# name :string
|
# name :string
|
||||||
# status :integer default("in_progress"), not null
|
# status :integer default("in_progress"), not null
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
# index_captain_documents_on_account_id (account_id)
|
# index_captain_documents_on_account_id (account_id)
|
||||||
# index_captain_documents_on_assistant_id (assistant_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_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
|
||||||
|
# index_captain_documents_on_document_type (document_type)
|
||||||
# index_captain_documents_on_status (status)
|
# index_captain_documents_on_status (status)
|
||||||
#
|
#
|
||||||
class Captain::Document < ApplicationRecord
|
class Captain::Document < ApplicationRecord
|
||||||
@@ -37,7 +39,13 @@ class Captain::Document < ApplicationRecord
|
|||||||
available: 1
|
available: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum document_type: {
|
||||||
|
url: 0,
|
||||||
|
pdf: 1
|
||||||
|
}
|
||||||
|
|
||||||
before_create :ensure_within_plan_limit
|
before_create :ensure_within_plan_limit
|
||||||
|
before_create :detect_document_type
|
||||||
after_create_commit :enqueue_crawl_job
|
after_create_commit :enqueue_crawl_job
|
||||||
after_create_commit :update_document_usage
|
after_create_commit :update_document_usage
|
||||||
after_destroy :update_document_usage
|
after_destroy :update_document_usage
|
||||||
@@ -47,12 +55,38 @@ class Captain::Document < ApplicationRecord
|
|||||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||||
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
scope :for_assistant, ->(assistant_id) { where(assistant_id: assistant_id) }
|
||||||
|
|
||||||
|
def url_document?
|
||||||
|
document_type == 'url'
|
||||||
|
end
|
||||||
|
|
||||||
|
def pdf_document?
|
||||||
|
document_type == 'pdf'
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def enqueue_crawl_job
|
def enqueue_crawl_job
|
||||||
return if status != 'in_progress'
|
return if status != 'in_progress'
|
||||||
|
|
||||||
Captain::Documents::CrawlJob.perform_later(self)
|
if pdf_document?
|
||||||
|
Captain::Documents::PdfProcessingJob.perform_later(self)
|
||||||
|
else
|
||||||
|
Captain::Documents::CrawlJob.perform_later(self)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def detect_document_type
|
||||||
|
return unless external_link.present?
|
||||||
|
|
||||||
|
# Check if URL points to a PDF file
|
||||||
|
uri = URI.parse(external_link)
|
||||||
|
if uri.path.downcase.end_with?('.pdf') || external_link.downcase.include?('.pdf')
|
||||||
|
self.document_type = :pdf
|
||||||
|
else
|
||||||
|
self.document_type = :url
|
||||||
|
end
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
self.document_type = :url
|
||||||
end
|
end
|
||||||
|
|
||||||
def enqueue_response_builder_job
|
def enqueue_response_builder_job
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
|||||||
register_tools
|
register_tools
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate_response(input, previous_messages = [], role = 'user')
|
# additional_message: A single message (String) from the user that should be appended to the chat.
|
||||||
@messages += previous_messages
|
# It can be an empty String or nil when you only want to supply historical messages.
|
||||||
@messages << { role: role, content: input } if input.present?
|
# message_history: An Array of already formatted messages that provide the previous context.
|
||||||
|
# role: The role for the additional_message (defaults to `user`).
|
||||||
|
#
|
||||||
|
# NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
|
||||||
|
# positional ordering.
|
||||||
|
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
||||||
|
@messages += message_history
|
||||||
|
@messages << { role: role, content: additional_message } if additional_message.present?
|
||||||
request_chat_completion
|
request_chat_completion
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
class Captain::OpenAiMessageBuilderService
|
||||||
|
pattr_initialize [:message!]
|
||||||
|
|
||||||
|
def generate_content
|
||||||
|
parts = []
|
||||||
|
parts << text_part(@message.content) if @message.content.present?
|
||||||
|
parts.concat(attachment_parts(@message.attachments)) if @message.attachments.any?
|
||||||
|
|
||||||
|
return 'Message without content' if parts.blank?
|
||||||
|
return parts.first[:text] if parts.one? && parts.first[:type] == 'text'
|
||||||
|
|
||||||
|
parts
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def text_part(text)
|
||||||
|
{ type: 'text', text: text }
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_part(image_url)
|
||||||
|
{ type: 'image_url', image_url: { url: image_url } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def attachment_parts(attachments)
|
||||||
|
image_attachments = attachments.where(file_type: :image)
|
||||||
|
image_content = image_parts(image_attachments)
|
||||||
|
|
||||||
|
transcription = extract_audio_transcriptions(attachments)
|
||||||
|
transcription_part = text_part(transcription) if transcription.present?
|
||||||
|
|
||||||
|
attachment_part = text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
|
||||||
|
|
||||||
|
[image_content, transcription_part, attachment_part].flatten.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_parts(image_attachments)
|
||||||
|
image_attachments.each_with_object([]) do |attachment, parts|
|
||||||
|
url = get_attachment_url(attachment)
|
||||||
|
parts << image_part(url) if url.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_attachment_url(attachment)
|
||||||
|
return attachment.external_url if attachment.external_url.present?
|
||||||
|
|
||||||
|
attachment.file.attached? ? attachment.file_url : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_audio_transcriptions(attachments)
|
||||||
|
audio_attachments = attachments.where(file_type: :audio)
|
||||||
|
return '' if audio_attachments.blank?
|
||||||
|
|
||||||
|
audio_attachments.map do |attachment|
|
||||||
|
result = Messages::AudioTranscriptionService.new(attachment).perform
|
||||||
|
result[:success] ? result[:transcriptions] : ''
|
||||||
|
end.join
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
require 'mini_magick'
|
||||||
|
|
||||||
|
class Captain::Tools::PdfPageBatcherService
|
||||||
|
PAGES_PER_BATCH = 3
|
||||||
|
|
||||||
|
def initialize(temp_files_tracker)
|
||||||
|
@temp_files = temp_files_tracker
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch_pages(image_files)
|
||||||
|
return image_files if image_files.length <= PAGES_PER_BATCH
|
||||||
|
|
||||||
|
batched_images = []
|
||||||
|
|
||||||
|
image_files.each_slice(PAGES_PER_BATCH) do |batch|
|
||||||
|
if batch.length == 1
|
||||||
|
# Single page, use as-is
|
||||||
|
batched_images << batch.first
|
||||||
|
else
|
||||||
|
# Multiple pages, combine them
|
||||||
|
combined_image = combine_images(batch)
|
||||||
|
batched_images << combined_image
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
batched_images
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def combine_images(image_paths)
|
||||||
|
# Create a vertical montage of the images
|
||||||
|
output_path = create_temp_file_path('.png')
|
||||||
|
|
||||||
|
MiniMagick::Tool::Montage.new do |montage|
|
||||||
|
image_paths.each { |path| montage << path }
|
||||||
|
montage.tile('1x') # Arrange in single column (vertically)
|
||||||
|
montage.geometry('+0+10') # Add 10px spacing between images
|
||||||
|
montage.background('white')
|
||||||
|
montage << output_path
|
||||||
|
end
|
||||||
|
|
||||||
|
@temp_files << output_path
|
||||||
|
output_path
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Failed to combine images: #{e.message}"
|
||||||
|
# Fallback to first image if combining fails
|
||||||
|
image_paths.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_temp_file_path(extension)
|
||||||
|
temp_file = Tempfile.new(['combined_pages', extension])
|
||||||
|
temp_file.close
|
||||||
|
@temp_files << temp_file
|
||||||
|
temp_file.path
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
class Captain::Tools::PdfProcessingService
|
||||||
|
def initialize(document)
|
||||||
|
@document = document
|
||||||
|
@temp_files = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
|
return unless @document.pdf_document?
|
||||||
|
|
||||||
|
content = extract_text_content
|
||||||
|
|
||||||
|
# Follow the same pattern as SimplePageCrawlParserJob
|
||||||
|
@document.update!(
|
||||||
|
content: content[0..14_999], # Same limit as crawler
|
||||||
|
status: :available
|
||||||
|
)
|
||||||
|
|
||||||
|
content
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "PDF processing failed for document #{@document.id}: #{e.message}"
|
||||||
|
@document.update!(content: "Error processing PDF: #{e.message}", status: :available)
|
||||||
|
raise e
|
||||||
|
ensure
|
||||||
|
cleanup_temp_files
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def extract_text_content
|
||||||
|
require 'down'
|
||||||
|
|
||||||
|
pdf_file = Down.download(@document.external_link)
|
||||||
|
@temp_files << pdf_file
|
||||||
|
|
||||||
|
output_dir = Dir.mktmpdir
|
||||||
|
@temp_files << output_dir
|
||||||
|
|
||||||
|
# Convert PDF to images
|
||||||
|
system("pdftoimage", "-png", "-r", "150", pdf_file.path, "#{output_dir}/page")
|
||||||
|
|
||||||
|
image_files = Dir.glob("#{output_dir}/page-*.png").sort
|
||||||
|
return fallback_content if image_files.empty?
|
||||||
|
|
||||||
|
# Batch pages for efficient processing
|
||||||
|
batcher = Captain::Tools::PdfPageBatcherService.new(@temp_files)
|
||||||
|
batched_images = batcher.batch_pages(image_files)
|
||||||
|
|
||||||
|
# Extract text using Google Vision
|
||||||
|
vision_extractor = Captain::Tools::VisionTextExtractorService.new
|
||||||
|
extracted_text = vision_extractor.extract_text_from_multiple_images(batched_images)
|
||||||
|
|
||||||
|
extracted_text.present? ? extracted_text : fallback_content
|
||||||
|
end
|
||||||
|
|
||||||
|
def fallback_content
|
||||||
|
"PDF Document: #{@document.name || 'Untitled'}\nSource: #{@document.external_link}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def cleanup_temp_files
|
||||||
|
@temp_files.each do |file_or_dir|
|
||||||
|
case file_or_dir
|
||||||
|
when String # directory path
|
||||||
|
FileUtils.rm_rf(file_or_dir)
|
||||||
|
when Tempfile
|
||||||
|
file_or_dir.close
|
||||||
|
file_or_dir.unlink
|
||||||
|
else # Down::ChunkedIO or similar
|
||||||
|
file_or_dir.close if file_or_dir.respond_to?(:close)
|
||||||
|
file_or_dir.unlink if file_or_dir.respond_to?(:unlink)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn "Failed to cleanup temp file: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class Captain::Tools::VisionTextExtractorService
|
||||||
|
class TextExtractionError < StandardError; end
|
||||||
|
|
||||||
|
def initialize
|
||||||
|
@use_vision = vision_available?
|
||||||
|
@vision = Google::Cloud::Vision.image_annotator if @use_vision
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_text_from_image(image_path)
|
||||||
|
if @use_vision
|
||||||
|
extract_with_vision(image_path)
|
||||||
|
else
|
||||||
|
extract_with_tesseract(image_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_text_from_multiple_images(image_paths)
|
||||||
|
Rails.logger.info "Using #{@use_vision ? 'Google Vision' : 'Tesseract'} for text extraction"
|
||||||
|
|
||||||
|
extracted_texts = []
|
||||||
|
|
||||||
|
image_paths.each_with_index do |image_path, index|
|
||||||
|
Rails.logger.info "Extracting text from image #{index + 1}/#{image_paths.length}"
|
||||||
|
|
||||||
|
text = extract_text_from_image(image_path)
|
||||||
|
extracted_texts << text if text.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
extracted_texts.join("\n\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def vision_available?
|
||||||
|
require 'google/cloud/vision'
|
||||||
|
Google::Cloud::Vision.image_annotator
|
||||||
|
true
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.info "Google Vision not available, falling back to Tesseract: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_with_vision(image_path)
|
||||||
|
image = Google::Cloud::Vision::Image.new(image_path)
|
||||||
|
response = @vision.text_detection(image: image)
|
||||||
|
|
||||||
|
if response.error
|
||||||
|
raise TextExtractionError, "Vision API error: #{response.error.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
text_annotation = response.full_text_annotation
|
||||||
|
return "" unless text_annotation
|
||||||
|
|
||||||
|
text_annotation.text
|
||||||
|
rescue Google::Cloud::Error => e
|
||||||
|
Rails.logger.error "Google Vision API error: #{e.message}"
|
||||||
|
raise TextExtractionError, "Failed to extract text: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_with_tesseract(image_path)
|
||||||
|
require 'rtesseract'
|
||||||
|
|
||||||
|
RTesseract.new(image_path).to_s.strip
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error "Tesseract extraction error: #{e.message}"
|
||||||
|
raise TextExtractionError, "Failed to extract text with Tesseract: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@ json.assistant do
|
|||||||
end
|
end
|
||||||
json.content resource.content
|
json.content resource.content
|
||||||
json.created_at resource.created_at.to_i
|
json.created_at resource.created_at.to_i
|
||||||
|
json.document_type resource.document_type
|
||||||
json.external_link resource.external_link
|
json.external_link resource.external_link
|
||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.name resource.name
|
json.name resource.name
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(chat_service).to have_received(:generate_response).with(
|
expect(chat_service).to have_received(:generate_response).with(
|
||||||
valid_params[:message_content],
|
additional_message: valid_params[:message_content],
|
||||||
valid_params[:message_history]
|
message_history: valid_params[:message_history]
|
||||||
)
|
)
|
||||||
expect(json_response[:content]).to eq('Assistant response')
|
expect(json_response[:content]).to eq('Assistant response')
|
||||||
end
|
end
|
||||||
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(chat_service).to have_received(:generate_response).with(
|
expect(chat_service).to have_received(:generate_response).with(
|
||||||
params_without_history[:message_content],
|
additional_message: params_without_history[:message_content],
|
||||||
[]
|
message_history: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -30,5 +30,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
|||||||
account.reload
|
account.reload
|
||||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when message contains an image' do
|
||||||
|
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
|
||||||
|
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
image_attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes image URL directly in the message content for OpenAI vision analysis' do
|
||||||
|
# Expect the generate_response to receive multimodal content with image URL
|
||||||
|
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
|
||||||
|
history = kwargs[:message_history]
|
||||||
|
last_entry = history.last
|
||||||
|
expect(last_entry[:content]).to be_an(Array)
|
||||||
|
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
|
||||||
|
expect(last_entry[:content].any? do |part|
|
||||||
|
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
|
||||||
|
end).to be true
|
||||||
|
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class.perform_now(conversation, assistant)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Document, type: :model do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
|
||||||
|
describe 'document_type detection' do
|
||||||
|
it 'detects PDF documents from URL' do
|
||||||
|
document = assistant.documents.build(
|
||||||
|
name: 'Test PDF',
|
||||||
|
external_link: 'https://example.com/document.pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
document.save!
|
||||||
|
|
||||||
|
expect(document.document_type).to eq('pdf')
|
||||||
|
expect(document.pdf_document?).to be true
|
||||||
|
expect(document.url_document?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'detects URL documents for non-PDF links' do
|
||||||
|
document = assistant.documents.build(
|
||||||
|
name: 'Test Page',
|
||||||
|
external_link: 'https://example.com/page.html'
|
||||||
|
)
|
||||||
|
|
||||||
|
document.save!
|
||||||
|
|
||||||
|
expect(document.document_type).to eq('url')
|
||||||
|
expect(document.url_document?).to be true
|
||||||
|
expect(document.pdf_document?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'defaults to URL for invalid URIs' do
|
||||||
|
document = assistant.documents.build(
|
||||||
|
name: 'Invalid URL',
|
||||||
|
external_link: 'not-a-valid-url'
|
||||||
|
)
|
||||||
|
|
||||||
|
document.save!
|
||||||
|
|
||||||
|
expect(document.document_type).to eq('url')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'job enqueueing' do
|
||||||
|
it 'enqueues PDF processing job for PDF documents' do
|
||||||
|
document = assistant.documents.build(
|
||||||
|
name: 'Test PDF',
|
||||||
|
external_link: 'https://example.com/document.pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Captain::Documents::PdfProcessingJob).to receive(:perform_later).with(document)
|
||||||
|
|
||||||
|
document.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'enqueues crawl job for URL documents' do
|
||||||
|
document = assistant.documents.build(
|
||||||
|
name: 'Test Page',
|
||||||
|
external_link: 'https://example.com/page.html'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(Captain::Documents::CrawlJob).to receive(:perform_later).with(document)
|
||||||
|
|
||||||
|
document.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::OpenAiMessageBuilderService do
|
||||||
|
subject(:service) { described_class.new(message: message) }
|
||||||
|
|
||||||
|
let(:message) { create(:message, content: 'Hello world') }
|
||||||
|
|
||||||
|
describe '#generate_content' do
|
||||||
|
context 'when message has only text content' do
|
||||||
|
it 'returns the text content directly' do
|
||||||
|
expect(service.generate_content).to eq('Hello world')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when message has no content and no attachments' do
|
||||||
|
let(:message) { create(:message, content: nil) }
|
||||||
|
|
||||||
|
it 'returns default message' do
|
||||||
|
expect(service.generate_content).to eq('Message without content')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when message has text content and attachments' do
|
||||||
|
before do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns an array of content parts' do
|
||||||
|
result = service.generate_content
|
||||||
|
expect(result).to be_an(Array)
|
||||||
|
expect(result).to include({ type: 'text', text: 'Hello world' })
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when message has only non-text attachments' do
|
||||||
|
let(:message) { create(:message, content: nil) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns an array of content parts without text' do
|
||||||
|
result = service.generate_content
|
||||||
|
expect(result).to be_an(Array)
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||||
|
expect(result).not_to include(hash_including(type: 'text', text: 'Hello world'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#attachment_parts' do
|
||||||
|
let(:message) { create(:message, content: nil) }
|
||||||
|
let(:attachments) { message.attachments }
|
||||||
|
|
||||||
|
context 'with image attachments' do
|
||||||
|
before do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes image parts' do
|
||||||
|
result = service.send(:attachment_parts, attachments)
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with audio attachments' do
|
||||||
|
let(:audio_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||||
|
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio transcription text' })
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes transcription text part' do
|
||||||
|
audio_attachment # trigger creation
|
||||||
|
result = service.send(:attachment_parts, attachments)
|
||||||
|
expect(result).to include({ type: 'text', text: 'Audio transcription text' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with other file types' do
|
||||||
|
before do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes generic attachment message' do
|
||||||
|
result = service.send(:attachment_parts, attachments)
|
||||||
|
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with mixed attachment types' do
|
||||||
|
let(:image_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image.jpg')
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:audio_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:document_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :file)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||||
|
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Audio text' })
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes all relevant parts' do
|
||||||
|
image_attachment # trigger creation
|
||||||
|
audio_attachment # trigger creation
|
||||||
|
document_attachment # trigger creation
|
||||||
|
|
||||||
|
result = service.send(:attachment_parts, attachments)
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||||
|
expect(result).to include({ type: 'text', text: 'Audio text' })
|
||||||
|
expect(result).to include({ type: 'text', text: 'User has shared an attachment' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#image_parts' do
|
||||||
|
let(:message) { create(:message, content: nil) }
|
||||||
|
|
||||||
|
context 'with valid image attachments' do
|
||||||
|
let(:image1) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image1.jpg')
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:image2) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: 'https://example.com/image2.jpg')
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns image parts for all valid images' do
|
||||||
|
image1 # trigger creation
|
||||||
|
image2 # trigger creation
|
||||||
|
|
||||||
|
image_attachments = message.attachments.where(file_type: :image)
|
||||||
|
result = service.send(:image_parts, image_attachments)
|
||||||
|
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image1.jpg' } })
|
||||||
|
expect(result).to include({ type: 'image_url', image_url: { url: 'https://example.com/image2.jpg' } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with image attachments without URLs' do
|
||||||
|
let(:image_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image, external_url: nil)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(image_attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips images without valid URLs' do
|
||||||
|
image_attachment # trigger creation
|
||||||
|
|
||||||
|
image_attachments = message.attachments.where(file_type: :image)
|
||||||
|
result = service.send(:image_parts, image_attachments)
|
||||||
|
|
||||||
|
expect(result).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#get_attachment_url' do
|
||||||
|
let(:attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :image)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when attachment has external_url' do
|
||||||
|
before { attachment.update(external_url: 'https://example.com/image.jpg') }
|
||||||
|
|
||||||
|
it 'returns external_url' do
|
||||||
|
expect(service.send(:get_attachment_url, attachment)).to eq('https://example.com/image.jpg')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when attachment has attached file' do
|
||||||
|
before do
|
||||||
|
attachment.update(external_url: nil)
|
||||||
|
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: true))
|
||||||
|
allow(attachment).to receive(:file_url).and_return('https://local.com/file.jpg')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns file_url' do
|
||||||
|
expect(service.send(:get_attachment_url, attachment)).to eq('https://local.com/file.jpg')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when attachment has no URL or file' do
|
||||||
|
before do
|
||||||
|
attachment.update(external_url: nil)
|
||||||
|
allow(attachment).to receive(:file).and_return(instance_double(ActiveStorage::Attached::One, attached?: false))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns nil' do
|
||||||
|
expect(service.send(:get_attachment_url, attachment)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#extract_audio_transcriptions' do
|
||||||
|
let(:message) { create(:message, content: nil) }
|
||||||
|
|
||||||
|
context 'with no audio attachments' do
|
||||||
|
it 'returns empty string' do
|
||||||
|
result = service.send(:extract_audio_transcriptions, message.attachments)
|
||||||
|
expect(result).to eq('')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with successful audio transcriptions' do
|
||||||
|
let(:audio1) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:audio2) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio1).and_return(
|
||||||
|
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'First audio text. ' })
|
||||||
|
)
|
||||||
|
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio2).and_return(
|
||||||
|
instance_double(Messages::AudioTranscriptionService, perform: { success: true, transcriptions: 'Second audio text.' })
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'concatenates all successful transcriptions' do
|
||||||
|
audio1 # trigger creation
|
||||||
|
audio2 # trigger creation
|
||||||
|
|
||||||
|
attachments = message.attachments
|
||||||
|
result = service.send(:extract_audio_transcriptions, attachments)
|
||||||
|
expect(result).to eq('First audio text. Second audio text.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with failed audio transcriptions' do
|
||||||
|
let(:audio_attachment) do
|
||||||
|
attachment = message.attachments.build(account_id: message.account_id, file_type: :audio)
|
||||||
|
attachment.save!
|
||||||
|
attachment
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Messages::AudioTranscriptionService).to receive(:new).with(audio_attachment).and_return(
|
||||||
|
instance_double(Messages::AudioTranscriptionService, perform: { success: false, transcriptions: nil })
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns empty string for failed transcriptions' do
|
||||||
|
audio_attachment # trigger creation
|
||||||
|
|
||||||
|
attachments = message.attachments
|
||||||
|
result = service.send(:extract_audio_transcriptions, attachments)
|
||||||
|
expect(result).to eq('')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'private helper methods' do
|
||||||
|
describe '#text_part' do
|
||||||
|
it 'returns correct text part format' do
|
||||||
|
result = service.send(:text_part, 'Hello world')
|
||||||
|
expect(result).to eq({ type: 'text', text: 'Hello world' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#image_part' do
|
||||||
|
it 'returns correct image part format' do
|
||||||
|
result = service.send(:image_part, 'https://example.com/image.jpg')
|
||||||
|
expect(result).to eq({ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::PdfProcessingService do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:assistant) { create(:captain_assistant, account: account) }
|
||||||
|
let(:document) do
|
||||||
|
assistant.documents.create!(
|
||||||
|
name: 'Test PDF',
|
||||||
|
external_link: 'https://example.com/test.pdf',
|
||||||
|
document_type: :pdf
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { described_class.new(document) }
|
||||||
|
|
||||||
|
describe '#process' do
|
||||||
|
context 'when document is not a PDF' do
|
||||||
|
before { allow(document).to receive(:pdf_document?).and_return(false) }
|
||||||
|
|
||||||
|
it 'returns early without processing' do
|
||||||
|
expect(subject.process).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when document is a PDF' do
|
||||||
|
let(:mock_file) { double('file', path: '/tmp/test.pdf', close: nil, unlink: nil) }
|
||||||
|
let(:mock_batcher) { double('batcher') }
|
||||||
|
let(:mock_vision) { double('vision') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Down).to receive(:download).and_return(mock_file)
|
||||||
|
allow(Dir).to receive(:mktmpdir).and_return('/tmp/pdf_images')
|
||||||
|
allow(Dir).to receive(:glob).and_return(['/tmp/image1.png', '/tmp/image2.png'])
|
||||||
|
allow(FileUtils).to receive(:rm_rf)
|
||||||
|
|
||||||
|
allow(Captain::Tools::PdfPageBatcherService).to receive(:new).and_return(mock_batcher)
|
||||||
|
allow(mock_batcher).to receive(:batch_pages).and_return(['/tmp/batched1.png'])
|
||||||
|
|
||||||
|
allow(Captain::Tools::VisionTextExtractorService).to receive(:new).and_return(mock_vision)
|
||||||
|
allow(mock_vision).to receive(:extract_text_from_multiple_images).and_return('Extracted text content')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'processes the PDF and updates document' do
|
||||||
|
expect { subject.process }.to change { document.reload.status }.to('available')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets extracted content on the document' do
|
||||||
|
subject.process
|
||||||
|
expect(document.reload.content).to eq('Extracted text content')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses batching service for page processing' do
|
||||||
|
expect(mock_batcher).to receive(:batch_pages).with(['/tmp/image1.png', '/tmp/image2.png'])
|
||||||
|
subject.process
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses Vision API for text extraction' do
|
||||||
|
expect(mock_vision).to receive(:extract_text_from_multiple_images).with(['/tmp/batched1.png'])
|
||||||
|
subject.process
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when processing fails' do
|
||||||
|
before do
|
||||||
|
allow(Down).to receive(:download).and_raise(StandardError.new('Download failed'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs error and updates document with error message' do
|
||||||
|
expect(Rails.logger).to receive(:error).with(/PDF processing failed/)
|
||||||
|
|
||||||
|
expect { subject.process }.to raise_error(StandardError)
|
||||||
|
expect(document.reload.content).to include('Error processing PDF')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Captain::Tools::VisionTextExtractorService do
|
||||||
|
let(:image_path) { '/tmp/test_image.png' }
|
||||||
|
|
||||||
|
describe 'when Google Vision is available' do
|
||||||
|
subject { described_class.new }
|
||||||
|
|
||||||
|
let(:mock_vision) { double('vision_client') }
|
||||||
|
let(:mock_response) { double('response', error: nil) }
|
||||||
|
let(:mock_text_annotation) { double('text_annotation', text: 'Vision extracted text') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Google::Cloud::Vision).to receive(:image_annotator).and_return(mock_vision)
|
||||||
|
allow(Google::Cloud::Vision::Image).to receive(:new).with(image_path).and_return(double('image'))
|
||||||
|
allow(mock_vision).to receive(:text_detection).and_return(mock_response)
|
||||||
|
allow(mock_response).to receive(:full_text_annotation).and_return(mock_text_annotation)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#extract_text_from_image' do
|
||||||
|
it 'uses Vision API for text extraction' do
|
||||||
|
result = subject.extract_text_from_image(image_path)
|
||||||
|
expect(result).to eq('Vision extracted text')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when Vision API returns error' do
|
||||||
|
before do
|
||||||
|
allow(mock_response).to receive(:error).and_return(double('error', message: 'API Error'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises TextExtractionError' do
|
||||||
|
expect { subject.extract_text_from_image(image_path) }.to raise_error(
|
||||||
|
Captain::Tools::VisionTextExtractorService::TextExtractionError,
|
||||||
|
/Vision API error/
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'when Google Vision is not available' do
|
||||||
|
subject { described_class.new }
|
||||||
|
|
||||||
|
let(:mock_tesseract) { double('tesseract', to_s: 'Tesseract extracted text') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Google::Cloud::Vision).to receive(:image_annotator).and_raise(StandardError.new('No credentials'))
|
||||||
|
allow(RTesseract).to receive(:new).with(image_path).and_return(mock_tesseract)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#extract_text_from_image' do
|
||||||
|
it 'falls back to Tesseract for text extraction' do
|
||||||
|
result = subject.extract_text_from_image(image_path)
|
||||||
|
expect(result).to eq('Tesseract extracted text')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when Tesseract fails' do
|
||||||
|
before do
|
||||||
|
allow(RTesseract).to receive(:new).and_raise(StandardError.new('Tesseract error'))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises TextExtractionError' do
|
||||||
|
expect { subject.extract_text_from_image(image_path) }.to raise_error(
|
||||||
|
Captain::Tools::VisionTextExtractorService::TextExtractionError,
|
||||||
|
/Failed to extract text with Tesseract/
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#extract_text_from_multiple_images' do
|
||||||
|
let(:image_paths) { ['/tmp/image1.png', '/tmp/image2.png'] }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Google::Cloud::Vision).to receive(:image_annotator).and_return(double('vision'))
|
||||||
|
allow(subject).to receive(:extract_text_from_image).with('/tmp/image1.png').and_return('Text from page 1')
|
||||||
|
allow(subject).to receive(:extract_text_from_image).with('/tmp/image2.png').and_return('Text from page 2')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'extracts text from multiple images and joins them' do
|
||||||
|
result = subject.extract_text_from_multiple_images(image_paths)
|
||||||
|
expect(result).to eq("Text from page 1\n\nText from page 2")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips empty results' do
|
||||||
|
allow(subject).to receive(:extract_text_from_image).with('/tmp/image2.png').and_return('')
|
||||||
|
|
||||||
|
result = subject.extract_text_from_multiple_images(image_paths)
|
||||||
|
expect(result).to eq('Text from page 1')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'logs which extraction method is being used' do
|
||||||
|
expect(Rails.logger).to receive(:info).with(/Using (Google Vision|Tesseract) for text extraction/)
|
||||||
|
subject.extract_text_from_multiple_images(image_paths)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user