Compare commits

...
Author SHA1 Message Date
Tanmay Deep Sharma 9bd569eb35 Merge remote-tracking branch 'origin/develop' into test_pdf-support-captain 2025-07-08 10:05:51 +07:00
Tanmay Deep Sharma a38fe59446 Merge remote-tracking branch 'origin/revert-11841-revert-11730-feat/captain-image-support' into test_pdf-support-captain 2025-07-03 00:47:47 +05:30
Tanmay Deep Sharma 4355a72c15 Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen-fe' into merge 2025-07-03 00:34:12 +05:30
Tanmay Deep Sharma 91cb159ef0 Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen-fe' into feat/support-pdf-upload-faq-gen-fe 2025-07-03 00:33:50 +05:30
Tanmay Deep Sharma 45f9aeec10 update the translation file 2025-07-03 00:33:26 +05:30
Tanmay Deep Sharma 305b40100f Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen-fe' into merge 2025-07-03 00:23:20 +05:30
Tanmay Deep Sharma d77654be11 Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen' into merge 2025-07-03 00:23:03 +05:30
Tanmay Deep SharmaandGitHub 553fcbc5e9 Merge branch 'develop' into feat/support-pdf-upload-faq-gen-fe 2025-07-03 00:21:18 +05:30
Tanmay Deep Sharma ba65a5a534 refactor: fix eslint errors 2025-07-03 00:20:42 +05:30
Tanmay Deep SharmaGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
6656b042b8 Potential fix for code scanning alert no. 105: Incomplete string escaping or encoding
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-07-03 00:09:27 +05:30
Tanmay Deep Sharma 9370d21612 Merge remote-tracking branch 'origin/develop' into feat/support-pdf-upload-faq-gen 2025-07-03 00:08:43 +05:30
Tanmay Deep Sharma 8a3b4edac8 Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen' into merge 2025-07-03 00:07:35 +05:30
Tanmay Deep Sharma 6681561864 refactor: fix the codeclimate 2025-07-03 00:07:16 +05:30
Tanmay Deep Sharma bbc51d687d Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen' into merge 2025-07-03 00:03:39 +05:30
Tanmay Deep Sharma 6222f9b492 refactor: fix the codeclimate 2025-07-02 23:58:19 +05:30
Tanmay Deep Sharma dc48d44b51 Merge branch 'develop' into feat/support-pdf-upload-faq-gen 2025-07-02 23:34:09 +05:30
Tanmay Deep Sharma 3765720849 refactor: refactor PdfExtractionParserJob for better readability 2025-07-02 23:32:10 +05:30
Tanmay Deep Sharma 4fd8f4de30 Merge remote-tracking branch 'origin/feat/support-pdf-upload-faq-gen-fe' into merge 2025-07-02 22:43:24 +05:30
Tanmay Deep Sharma 2f59d34e50 update the rpec fix linting 2025-07-02 22:40:20 +05:30
Tanmay Deep Sharma ec107a2d94 update document model file 2025-07-02 20:02:49 +05:30
Tanmay Deep Sharma 235ce3479c refactor: use UPSERT for consistency in document upload 2025-07-02 19:58:34 +05:30
Tanmay Deep Sharma 8e87e16486 improve the pdf upload functionality 2025-07-02 19:57:18 +05:30
Tanmay Deep Sharma 8f27ba081c feat: add PDF upload functionality 2025-07-02 12:52:45 +05:30
Muhsin KelothandGitHub 6c45d56567 Merge branch 'develop' into revert-11841-revert-11730-feat/captain-image-support 2025-07-02 12:34:28 +05:30
Tanmay Deep Sharma 85c2fd10e4 refactor: separate frontend and backend code 2025-07-02 12:30:50 +05:30
Tanmay Deep Sharma 165a74229e refactor: separate frontend and backend code 2025-07-02 12:22:05 +05:30
de6717e7f1 fix: Add wait for image response builder (#11837)
## Linear


https://linear.app/chatwoot/issue/CW-4559/faradaybadrequesterror-the-server-responded-with-status-400#comment-e827f872

## Description

This PR adds retry logic and wait mechanisms for image processing in the
Captain conversation response builder. The fix addresses issues where
image attachments might not be immediately available when the response
builder tries to process multimodal message content, causing failures in
the AI assistant's response generation.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Captain assistant responses with image attachments
- Large image processing scenarios
-  Network timeout conditions during image loading
- Multiple retry attempts for failed image access
- Conversation flow continuation after image processing failures
<img width="1231" alt="Screenshot 2025-06-30 at 1 58 26 PM"
src="https://github.com/user-attachments/assets/41723b83-e0f0-43d6-899f-7028cf2e5b94"
/>

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-07-02 11:53:03 +05:30
Tanmay Deep SharmaandGitHub 7d39f890e4 Revert "feat(revert): "feat: captain image support" (#11841)"
This reverts commit a657b45bd1.
2025-07-02 11:48:14 +05:30
Tanmay Deep Sharma a92394f446 feat: support for pdf to text extraction 2025-06-26 02:47:52 +05:30
Tanmay Deep Sharma ea3a84c930 feat: support uploading pdf in document knowledgebase 2025-06-26 02:41:19 +05:30
38 changed files with 2886 additions and 100 deletions
+2
View File
@@ -174,6 +174,8 @@ gem 'neighbor'
gem 'pgvector'
# Convert Website HTML to Markdown
gem 'reverse_markdown'
# PDF text extraction
gem 'pdf-reader', '~> 2.0'
gem 'iso-639'
gem 'ruby-openai'
+14 -1
View File
@@ -25,6 +25,7 @@ GIT
GEM
remote: https://rubygems.org/
specs:
Ascii85 (2.0.1)
actioncable (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
@@ -126,6 +127,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
afm (0.2.2)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -365,6 +367,7 @@ GEM
hana (1.3.7)
hash_diff (1.1.1)
hashdiff (1.1.0)
hashery (2.1.2)
hashie (5.0.0)
html2text (0.4.0)
nokogiri (>= 1.0, < 2.0)
@@ -554,6 +557,12 @@ GEM
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pdf-reader (2.14.1)
Ascii85 (>= 1.0, < 3.0, != 2.0.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3)
pg_search (2.3.6)
activerecord (>= 5.2)
@@ -711,6 +720,7 @@ GEM
faraday (>= 1)
faraday-multipart (>= 1)
ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
@@ -819,6 +829,8 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -963,6 +975,7 @@ DEPENDENCIES
omniauth-google-oauth2 (>= 1.1.3)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pdf-reader (~> 2.0)
pg
pg_search
pgvector
@@ -1024,4 +1037,4 @@ RUBY VERSION
ruby 3.4.4p34
BUNDLED WITH
2.5.16
2.6.7
@@ -15,6 +15,14 @@ class CaptainDocument extends ApiClient {
},
});
}
uploadPdf(formData) {
return axios.post(`${this.url}/upload_pdf`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new CaptainDocument();
@@ -2,28 +2,43 @@
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue';
const emit = defineEmits(['close']);
const { t } = useI18n();
const emit = defineEmits(['close', 'success']);
const store = useStore();
const dialogRef = ref(null);
const documentForm = ref(null);
const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
if (newDocument.type === 'pdf') {
// Handle PDF upload
const formData = new FormData();
formData.append('pdf_document', newDocument.pdf_document);
formData.append('assistant_id', newDocument.assistant_id);
await store.dispatch('captainDocuments/uploadPdf', formData);
useAlert('PDF uploaded successfully! Processing will begin shortly.');
} else {
// Handle URL creation
await store.dispatch('captainDocuments/create', {
document: {
external_link: newDocument.external_link,
assistant_id: newDocument.assistant_id,
},
});
useAlert('Document created successfully!');
}
// Emit success event to refresh the list
emit('success');
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message || t(`${i18nKey}.ERROR_MESSAGE`);
error?.response?.data?.message ||
'Failed to create document. Please try again.';
useAlert(errorMessage);
}
};
@@ -42,8 +57,8 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.DOCUMENTS.FORM_DESCRIPTION')"
:title="$t('CAPTAIN.DOCUMENTS.CREATE.TITLE')"
:description="$t('CAPTAIN.DOCUMENTS.CREATE.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
@@ -1,6 +1,5 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { reactive, computed, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
@@ -8,27 +7,36 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import FileUpload from 'dashboard/components-next/fileUpload/FileUpload.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
};
const initialState = {
name: '',
url: '',
assistantId: null,
selectedFile: null,
sourceType: 'url', // 'url' or 'pdf'
};
const state = reactive({ ...initialState });
const uploadError = ref('');
const validationRules = {
url: { required, url, minLength: minLength(1) },
assistantId: { required },
};
const validationRules = computed(() => {
const rules = {
assistantId: { required },
};
if (state.sourceType === 'url') {
rules.url = { required, url, minLength: minLength(1) };
}
return rules;
});
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
@@ -41,70 +49,258 @@ const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.DOCUMENTS.FORM.${errorKey}.ERROR`)
: '';
const getErrorMessage = (field, errorMessage) => {
return v$.value[field]?.$error ? errorMessage : '';
};
const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
url: getErrorMessage('url', 'Please enter a valid URL'),
assistantId: getErrorMessage('assistantId', 'Please select an assistant'),
}));
const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({
external_link: state.url,
assistant_id: state.assistantId,
});
const prepareDocumentDetails = () => {
if (state.sourceType === 'pdf') {
return {
type: 'pdf',
pdf_document: state.selectedFile,
assistant_id: state.assistantId,
};
}
return {
type: 'url',
external_link: state.url,
assistant_id: state.assistantId,
};
};
const handleSubmit = async () => {
uploadError.value = '';
// Validate form
const isFormValid = await v$.value.$validate();
if (!isFormValid) {
return;
}
// For PDF uploads, check if file is selected
if (state.sourceType === 'pdf' && !state.selectedFile) {
uploadError.value = 'Please select a PDF file to upload.';
return;
}
emit('submit', prepareDocumentDetails());
};
// PDF Upload handlers
const handleFileSelected = file => {
state.selectedFile = file;
uploadError.value = '';
};
const handleFileError = error => {
uploadError.value = error;
state.selectedFile = null;
};
const clearSelectedFile = () => {
state.selectedFile = null;
uploadError.value = '';
};
const handleSourceTypeChange = type => {
state.sourceType = type;
uploadError.value = '';
state.selectedFile = null;
state.url = '';
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.url"
:label="t('CAPTAIN.DOCUMENTS.FORM.URL.LABEL')"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.URL.PLACEHOLDER')"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
<!-- Source Type Selection -->
<div class="flex flex-col gap-2">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.LABEL') }}
</label>
<div class="flex gap-2">
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-woot-500 text-woot-500 border-0':
state.sourceType === 'url',
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== 'url',
}"
@click="handleSourceTypeChange('url')"
>
<div class="flex items-center justify-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.9 12C3.9 10.29 5.29 8.9 7 8.9H11V7H7C4.24 7 2 9.24 2 12S4.24 17 7 17H11V15.1H7C5.29 15.1 3.9 13.71 3.9 12ZM8 13H16V11H8V13ZM17 7H13V8.9H17C18.71 8.9 20.1 10.29 20.1 12S18.71 15.1 17 15.1H13V17H17C19.76 17 22 14.76 22 12S19.76 7 17 7Z"
fill="currentColor"
/>
</svg>
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.WEBSITE_URL')
}}</span>
</div>
</button>
<button
type="button"
class="flex-1 p-3 rounded-lg bg-n-alpha-2 hover:border-n-slate-7 transition-colors focus:outline-none"
:class="{
'ring-2 ring-woot-500 text-woot-500 border-0':
state.sourceType === 'pdf',
'border-2 border-n-slate-7 text-n-slate-11':
state.sourceType !== 'pdf',
}"
@click="handleSourceTypeChange('pdf')"
>
<div class="flex items-center justify-center gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
<span class="text-sm font-medium">{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.SOURCE_TYPE.PDF_UPLOAD')
}}</span>
</div>
</button>
</div>
</div>
<!-- URL Input (when URL is selected) -->
<div v-if="state.sourceType === 'url'">
<Input
v-model="state.url"
label="Website URL"
placeholder="Enter the website URL to crawl"
:message="formErrors.url"
:message-type="formErrors.url ? 'error' : 'info'"
/>
</div>
<!-- PDF Upload (when PDF is selected) -->
<div v-if="state.sourceType === 'pdf'" class="flex flex-col gap-2">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.PDF_UPLOAD.LABEL') }}
</label>
<!-- File upload area -->
<div v-if="!state.selectedFile">
<FileUpload
accept="application/pdf"
:max-size-m-b="25"
placeholder="Select a PDF file"
upload-text="Click to select PDF or drag and drop"
drag-text="Drop PDF file here"
@file-selected="handleFileSelected"
@file-error="handleFileError"
/>
</div>
<!-- Selected file display -->
<div v-else class="p-3 bg-n-alpha-2 rounded-lg border border-n-slate-6">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-red-600"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
<div>
<p class="text-sm font-medium text-n-slate-12">
{{ state.selectedFile.name }}
</p>
<p class="text-xs text-n-slate-8">
{{
Math.round((state.selectedFile.size / 1024 / 1024) * 100) /
100
}}
{{ $t('GENERAL.FILE_SIZE.MB') }}
</p>
</div>
</div>
<button
type="button"
class="p-1 rounded hover:bg-n-alpha-3 text-n-slate-8 hover:text-n-slate-10"
@click="clearSelectedFile"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
fill="currentColor"
/>
</svg>
</button>
</div>
</div>
<!-- Upload error -->
<p v-if="uploadError" class="text-sm text-red-600">{{ uploadError }}</p>
</div>
<!-- Assistant Selection -->
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
{{ $t('CAPTAIN.DOCUMENTS.CREATE.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
placeholder="Choose an assistant"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-between w-full gap-3">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
label="Cancel"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="t('CAPTAIN.FORM.CREATE')"
label="Create Document"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
@@ -0,0 +1,202 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
accept: {
type: String,
default: '*',
},
multiple: {
type: Boolean,
default: false,
},
maxSizeMB: {
type: Number,
default: 10,
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
uploadText: {
type: String,
default: '',
},
dragText: {
type: String,
default: '',
},
});
const emit = defineEmits(['fileSelected', 'fileError']);
const fileInput = ref(null);
const isDragOver = ref(false);
const acceptTypes = computed(() => props.accept);
const maxSize = computed(() => props.maxSizeMB * 1024 * 1024);
const handleFileClick = () => {
if (!props.disabled) {
fileInput.value?.click();
}
};
const validateFile = file => {
// Check file size
if (file.size > maxSize.value) {
const errorMsg = `File "${file.name}" is too large. Maximum size is ${props.maxSizeMB}MB.`;
emit('fileError', errorMsg);
return false;
}
// Check file type if specified
if (
props.accept !== '*' &&
!file.type.match(new RegExp(props.accept.replace(/\*/g, '.*')))
) {
const errorMsg = `File type not supported. Only ${props.accept} files are allowed.`;
emit('fileError', errorMsg);
return false;
}
return true;
};
const handleFileChange = event => {
const files = event.target.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
// Reset input value to allow selecting the same file again
event.target.value = '';
};
const handleDragOver = event => {
event.preventDefault();
if (!props.disabled) {
isDragOver.value = true;
}
};
const handleDragLeave = event => {
event.preventDefault();
// Only set to false if we're leaving the drop zone completely
if (!event.currentTarget.contains(event.relatedTarget)) {
isDragOver.value = false;
}
};
const handleDrop = event => {
event.preventDefault();
isDragOver.value = false;
if (props.disabled) return;
const files = event.dataTransfer.files;
if (files && files.length > 0) {
const file = files[0];
if (validateFile(file)) {
emit('fileSelected', file);
}
}
};
const displayText = computed(() => {
if (isDragOver.value && props.dragText) {
return props.dragText;
}
return props.uploadText || props.placeholder;
});
</script>
<template>
<div
class="file-upload-zone"
:class="{
'drag-over': isDragOver,
disabled: disabled,
}"
@dragover="handleDragOver"
@dragleave="handleDragLeave"
@drop="handleDrop"
@click="handleFileClick"
>
<input
ref="fileInput"
type="file"
:accept="acceptTypes"
:multiple="multiple"
:disabled="disabled"
class="hidden"
@change="handleFileChange"
/>
<div class="upload-content">
<div class="upload-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-n-slate-8"
>
<path
d="M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z"
fill="currentColor"
/>
</svg>
</div>
<div class="upload-text">
<p class="text-sm font-medium text-n-slate-11">
{{ displayText }}
</p>
<p class="text-xs text-n-slate-8 mt-1">
{{
$t('CAPTAIN.DOCUMENTS.CREATE.FORM.FILE_UPLOAD.MAX_SIZE', {
size: maxSizeMB,
})
}}
</p>
</div>
</div>
</div>
</template>
<style scoped>
.file-upload-zone {
@apply border-2 border-dashed border-n-slate-6 rounded-lg p-6;
@apply bg-n-alpha-2 hover:bg-n-alpha-3 cursor-pointer;
@apply transition-all duration-200 ease-in-out;
@apply flex flex-col items-center justify-center text-center;
min-height: 120px;
}
.file-upload-zone:hover {
@apply border-n-slate-7;
}
.file-upload-zone.drag-over {
@apply border-n-blue-9 bg-n-blue-2/50;
}
.file-upload-zone.disabled {
@apply opacity-50 cursor-not-allowed;
@apply hover:bg-n-alpha-2 hover:border-n-slate-6;
}
.upload-content {
@apply flex flex-col items-center gap-3;
}
.upload-icon {
@apply p-2 rounded-full bg-n-alpha-3;
}
</style>
@@ -5,6 +5,9 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"FILE_SIZE": {
"MB": "MB"
}
}
}
@@ -501,9 +501,26 @@
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
"TITLE": "Add a document",
"TITLE": "Create New Document",
"DESCRIPTION": "Add a new document from a website URL or by uploading a PDF file",
"SUCCESS_MESSAGE": "The document has been successfully created",
"ERROR_MESSAGE": "There was an error creating the document, please try again."
"ERROR_MESSAGE": "There was an error creating the document, please try again.",
"FORM": {
"SOURCE_TYPE": {
"LABEL": "Choose Source Type",
"WEBSITE_URL": "Website URL",
"PDF_UPLOAD": "PDF Upload"
},
"PDF_UPLOAD": {
"LABEL": "Upload PDF Document"
},
"ASSISTANT": {
"LABEL": "Select Assistant"
},
"FILE_UPLOAD": {
"MAX_SIZE": "Maximum file size: {size}MB"
}
}
},
"FORM": {
"URL": {
@@ -58,6 +58,11 @@ const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const handleCreateDialogSuccess = () => {
// Document is already added to the store optimistically, no need to refetch
// The store action 'uploadPdf' or 'create' already adds the document via commit(mutationTypes.ADD)
};
const handleAction = ({ action, id }) => {
selectedDocument.value = documents.value.find(
captainDocument => id === captainDocument.id
@@ -171,6 +176,7 @@ onMounted(() => {
v-if="showCreateDialog"
ref="createDocumentDialog"
@close="handleCreateDialogClose"
@success="handleCreateDialogSuccess"
/>
<DeleteDialog
v-if="selectedDocument"
@@ -1,7 +1,25 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from './storeFactory';
const actions = mutationTypes => ({
async uploadPdf({ commit }, formData) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
try {
const response = await CaptainDocumentAPI.uploadPdf(formData);
const { data } = response;
// Use UPSERT like the create action for consistency
commit(mutationTypes.UPSERT, data);
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
return Promise.resolve(data);
} catch (error) {
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
return Promise.reject(error);
}
},
});
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
actions,
});
+5 -1
View File
@@ -63,7 +63,11 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy]
resources :documents, only: [:index, :show, :create, :destroy] do
collection do
post :upload_pdf
end
end
end
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
delete :avatar, on: :member
@@ -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
+9 -1
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_06_27_195529) 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_06_27_195529) 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
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
params[:message_content],
message_history
additional_message: params[:message_content],
message_history: message_history
)
render json: response
@@ -5,8 +5,13 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
before_action :set_current_page, only: [:index]
before_action :set_documents, except: [:create]
before_action :set_document, only: [:show, :destroy]
before_action :set_assistant, only: [:create]
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
@@ -27,6 +32,19 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
render_could_not_create_error(e.message)
end
def upload_pdf
validation_errors = validate_upload_prerequisites
return render_could_not_create_error(validation_errors) if validation_errors
process_pdf_upload
rescue Captain::Document::LimitExceededError => e
handle_limit_exceeded_error(e)
rescue ActiveStorage::FileNotFoundError => e
handle_file_not_found_error(e)
rescue StandardError => e
handle_general_upload_error(e)
end
def destroy
@document.destroy
head :no_content
@@ -43,7 +61,8 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
end
def set_assistant
@assistant = Current.account.captain_assistants.find_by(id: document_params[:assistant_id])
assistant_id = action_name == 'upload_pdf' ? pdf_params[:assistant_id] : document_params[:assistant_id]
@assistant = Current.account.captain_assistants.find_by(id: assistant_id)
end
def set_current_page
@@ -57,4 +76,130 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
def document_params
params.require(:document).permit(:name, :external_link, :assistant_id)
end
def pdf_params
params.permit(:pdf_document, :assistant_id)
end
def pdf_file_present?
pdf_params[:pdf_document].present?
end
def validate_pdf_file
file = pdf_params[:pdf_document]
file_object_error = validate_file_object(file)
return file_object_error if file_object_error
file_type_error = validate_file_type(file)
return file_type_error if file_type_error
file_size_error = validate_file_size(file)
return file_size_error if file_size_error
{ valid: true }
end
def validate_file_object(file)
return nil if file.respond_to?(:content_type) && file.respond_to?(:size)
{ valid: false, error: 'Invalid file object' }
end
def validate_file_type(file)
return nil if ALLOWED_PDF_CONTENT_TYPES.include?(file.content_type)
{ valid: false, error: 'Invalid file type. Only PDF files are allowed.' }
end
def validate_file_size(file)
return nil if file.size <= MAX_PDF_SIZE
{ valid: false, error: "File size too large. Maximum size is #{MAX_PDF_SIZE / 1.megabyte}MB." }
end
def create_pdf_blob
file = pdf_params[:pdf_document]
ActiveStorage::Blob.create_and_upload!(
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
@@ -1,6 +1,7 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
def perform(conversation, assistant)
@conversation = conversation
@@ -13,7 +14,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
handle_error(e)
ensure
@@ -26,8 +27,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
@conversation.messages.incoming.last.content,
collect_previous_messages
message_history: collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -43,39 +43,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
{
content: message_content(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]
{
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
end
transcriptions
end
def determine_role(message)
return 'system' if message.content.blank?
message.message_type == 'incoming' ? 'user' : 'assistant'
end
message.message_type == 'incoming' ? 'user' : 'system'
def prepare_multimodal_message_content(message)
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
end
def handoff_requested?
@@ -2,17 +2,63 @@ 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
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_by_metadata?(document)
pdf_by_source_type?(document) || pdf_by_content_type?(document)
rescue StandardError
false
end
def pdf_by_source_type?(document)
document.respond_to?(:source_type) && document.source_type == 'pdf_upload'
end
def pdf_by_content_type?(document)
document.respond_to?(:content_type) && document.content_type&.include?('application/pdf')
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)
document.update(status: 'in_progress')
pdf_extraction_service = Captain::Tools::PdfExtractionService.new(document.external_link)
result = pdf_extraction_service.perform
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)
page_links = Captain::Tools::SimplePageCrawlService.new(document.external_link).page_links
@@ -48,4 +94,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,10 +1,16 @@
class Captain::Documents::ResponseBuilderJob < ApplicationJob
queue_as :low
def perform(document)
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
@@ -0,0 +1,226 @@
require 'securerandom'
class Captain::Tools::PdfExtractionParserJob < ApplicationJob
queue_as :low
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(assistant_id:, pdf_content:, document_id: nil)
validate_inputs!(assistant_id, pdf_content, document_id)
assistant = load_assistant(assistant_id)
content_data = extract_content_data(pdf_content)
process_document(assistant, content_data, document_id)
rescue ActiveRecord::RecordNotFound => e
handle_record_not_found_error(e, document_id)
rescue StandardError => e
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?
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 load_assistant(assistant_id)
Captain::Assistant.find(assistant_id)
end
def processing_should_skip?(assistant)
exceeded = limit_exceeded?(assistant.account)
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}" if exceeded
exceeded
end
def load_document(document_id)
return nil if document_id.blank?
Captain::Document.find(document_id)
end
def log_chunk_processing(document_id, content_data)
Rails.logger.info "Processing PDF chunk for document #{document_id}: " \
"page #{content_data[:page_number]}, " \
"chunk #{content_data[:chunk_index]}/#{content_data[:total_chunks]}"
end
def update_document_content(document, content_data)
document.update!(
content: content_data[:content],
status: 'available',
processed_at: Time.current
)
# Trigger FAQ generation for this chunk
Captain::Documents::ResponseBuilderJob.perform_later(document)
rescue StandardError => e
Rails.logger.error "Failed to parse PDF content for document #{document.id}: #{e.message}"
raise "Failed to parse PDF data: #{e.message}"
end
def create_new_document(assistant, content_data, document_id = nil)
document_params = build_document_params(assistant, content_data, document_id)
new_document = Captain::Document.create!(document_params)
# Trigger FAQ generation for this chunk
Captain::Documents::ResponseBuilderJob.perform_later(new_document)
new_document
rescue Captain::Document::LimitExceededError
Rails.logger.info "Document limit exceeded for account #{assistant.account.id}"
return false
rescue StandardError => e
Rails.logger.error "Failed to parse PDF content for assistant #{assistant.id}: #{e.message}"
raise "Failed to parse PDF data: #{e.message}"
end
def build_document_params(assistant, content_data, document_id)
title = generate_content_title(
content_data[:content],
content_data[:page_number],
content_data[:chunk_index],
content_data[:total_chunks]
)
external_link = generate_external_link(document_id, content_data)
{
assistant: assistant,
account: assistant.account,
content: content_data[:content],
name: title,
external_link: external_link,
status: 'available',
source_type: 'pdf_upload',
content_type: 'application/pdf'
}
end
def generate_external_link(document_id, content_data)
page_number = content_data[:page_number]
chunk_index = content_data[:chunk_index]
if document_id && page_number && chunk_index
"pdf_chunk_#{document_id}_page_#{page_number}_chunk_#{chunk_index}"
elsif page_number && chunk_index
"pdf_chunk_#{SecureRandom.hex(8)}_page_#{page_number}_chunk_#{chunk_index}"
else
"pdf_chunk_#{SecureRandom.hex(8)}"
end
end
def generate_content_title(content, page_number, chunk_index, total_chunks)
return 'PDF Content' if content.blank?
base_title = extract_base_title(content)
add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
end
def extract_base_title(content)
first_line = content.split("\n").first&.strip || ''
base_title = if first_line.include?('.')
extract_sentence_title(first_line)
else
first_line
end
# Use generic title for long or blank content
base_title.length > 100 || base_title.blank? ? 'PDF Content' : base_title
end
def extract_sentence_title(first_line)
first_sentence = first_line.split('.').first&.strip || ''
# Only add period if the original content was actually a complete sentence
if first_line.split('.').length > 1 && first_line.split('.')[1].strip.present?
"#{first_sentence}."
else
first_sentence
end
end
def add_page_chunk_info(base_title, page_number, chunk_index, total_chunks)
title_with_info = build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
truncate_title(title_with_info)
end
def build_title_with_chunk_info(base_title, page_number, chunk_index, total_chunks)
return "#{base_title} (Page #{page_number}, Part #{chunk_index}/#{total_chunks})" if total_chunks > 1
return "#{base_title} (Page #{page_number})" if page_number && page_number > 1
base_title
end
def truncate_title(title)
title.length > 255 ? "#{title[0, 252]}..." : title
end
def limit_exceeded?(account)
limits = account.usage_limits.dig(:captain, :documents)
return false unless limits
limits[:current_available].to_i <= 0
end
def handle_record_not_found_error(error, _document_id)
Rails.logger.error "Record not found: #{error.message}"
raise "Failed to parse PDF data: #{error.message}"
end
def handle_processing_error(error, assistant_id, document_id)
Rails.logger.error "Failed to parse PDF content for assistant #{assistant_id}: #{error.message}"
update_document_error_status(document_id, error) if document_id.present?
raise "Failed to parse PDF data: #{error.message}"
end
def update_document_error_status(document_id, error)
return if should_skip_document_update?(error)
document = Captain::Document.find_by(id: document_id)
document&.update(status: 'in_progress')
rescue StandardError => e
Rails.logger.error "Failed to update document status: #{e.message}"
end
def should_skip_document_update?(error)
error.message.include?('Database error')
end
def process_document(assistant, content_data, document_id)
return create_new_document_with_limit_check(assistant, content_data, document_id) if document_id.blank?
process_existing_document(assistant, content_data, document_id)
end
def process_existing_document(assistant, content_data, document_id)
existing_document = Captain::Document.find_by(id: document_id)
if existing_document
log_chunk_processing(document_id, content_data)
update_document_content(existing_document, content_data)
else
create_new_document_with_limit_check(assistant, content_data, document_id)
end
end
def create_new_document_with_limit_check(assistant, content_data, document_id)
result = create_new_document(assistant, content_data, document_id)
return false if result == false # Limits exceeded
result
end
end
+46 -1
View File
@@ -4,8 +4,13 @@
#
# id :bigint not null, primary key
# content :text
# content_type :string
# document_type :integer default(0), not null
# external_link :string not null
# 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 +22,9 @@
# 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_source_type (source_type)
# index_captain_documents_on_status (status)
#
class Captain::Document < ApplicationRecord
@@ -29,8 +37,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 +67,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 +85,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
@@ -22,4 +22,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
def playground?
true
end
def upload_pdf?
@account_user.administrator?
end
end
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { role: role, content: input } if input.present?
# additional_message: A single message (String) from the user that should be appended to the chat.
# It can be an empty String or nil when you only want to supply historical messages.
# 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
end
@@ -0,0 +1,60 @@
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.download_url if attachment.download_url.present?
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,131 @@
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 = []
global_chunk_index = 0
page_contents.each do |page_content|
page_chunks = split_content_into_chunks(page_content[:content], max_chunk_size)
page_total_chunks = page_chunks.length
page_chunks.each_with_index do |chunk_content, page_chunk_index|
global_chunk_index += 1
all_chunks << build_chunk(chunk_content, page_content[:page_number], page_chunk_index + 1, page_total_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/)
process_paragraphs_into_chunks(paragraphs, max_size, content)
end
def process_paragraphs_into_chunks(paragraphs, max_size, original_content)
chunks = []
current_chunk = ''
paragraphs.each do |paragraph|
chunks, current_chunk = process_single_paragraph(chunks, current_chunk, paragraph, max_size)
end
finalize_chunks(chunks, current_chunk, original_content)
end
def process_single_paragraph(chunks, current_chunk, paragraph, max_size)
return handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size) if paragraph.length > max_size
return handle_paragraph_overflow(chunks, current_chunk, paragraph) if paragraph_causes_overflow?(current_chunk, paragraph, max_size)
combined = combine_paragraph_content(current_chunk, paragraph)
[chunks, combined]
end
def finalize_chunks(chunks, current_chunk, original_content)
add_chunk_if_present(chunks, current_chunk)
chunks.presence || [original_content]
end
def split_paragraph_into_chunks(paragraph, max_size)
sentences = paragraph.split(/(?<=[.!?])\s+/)
process_sentences_into_chunks(sentences, max_size)
end
def process_sentences_into_chunks(sentences, max_size)
chunks = []
current_chunk = ''
sentences.each do |sentence|
chunks, current_chunk = process_single_sentence(chunks, current_chunk, sentence, max_size)
end
add_chunk_if_present(chunks, current_chunk)
chunks
end
def process_single_sentence(chunks, current_chunk, sentence, max_size)
return handle_oversized_sentence(chunks, current_chunk, sentence, max_size) if sentence.length > max_size
return handle_sentence_overflow(chunks, current_chunk, sentence) if sentence_causes_overflow?(current_chunk, sentence, max_size)
combined = combine_sentence_content(current_chunk, sentence)
[chunks, combined]
end
def handle_oversized_paragraph(chunks, current_chunk, paragraph, max_size)
add_chunk_if_present(chunks, current_chunk)
chunks.concat(split_paragraph_into_chunks(paragraph, max_size))
[chunks, '']
end
def handle_paragraph_overflow(chunks, current_chunk, paragraph)
add_chunk_if_present(chunks, current_chunk)
[chunks, paragraph]
end
def paragraph_causes_overflow?(current_chunk, paragraph, max_size)
("#{current_chunk}\n\n#{paragraph}").length > max_size
end
def combine_paragraph_content(current_chunk, paragraph)
current_chunk.blank? ? paragraph : "#{current_chunk}\n\n#{paragraph}"
end
def handle_oversized_sentence(chunks, current_chunk, sentence, max_size)
add_chunk_if_present(chunks, current_chunk)
chunks << sentence[0, max_size]
[chunks, '']
end
def handle_sentence_overflow(chunks, current_chunk, sentence)
add_chunk_if_present(chunks, current_chunk)
[chunks, sentence]
end
def sentence_causes_overflow?(current_chunk, sentence, max_size)
("#{current_chunk} #{sentence}").length > max_size
end
def combine_sentence_content(current_chunk, sentence)
current_chunk.blank? ? sentence : "#{current_chunk} #{sentence}"
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
@@ -0,0 +1,228 @@
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 failure_response(['Invalid PDF source']) if pdf_source.blank?
validation_result = validate_pdf_source
return validation_result unless validation_result[:success]
extract_pdf_with_error_handling
end
def extract_pdf_with_error_handling
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
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
end
end
private
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)
:file_path
end
def extract_from_url
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
def extract_from_uploaded_file
extract_from_file(pdf_source.tempfile.path)
end
def extract_from_file_path
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|
page_content = extract_page_content(page, index)
text_content << page_content if page_content
end
end
text_content
end
def extract_page_content(page, index)
page_text = page.text
return nil if page_text.blank?
cleaned_text = clean_text(page_text)
return nil if cleaned_text.blank?
{ page_number: index + 1, content: cleaned_text }
rescue StandardError => e
Rails.logger.warn "Failed to extract text from page #{index + 1}: #{e.message}"
nil
end
def clean_text(text)
# Remove form feeds and normalize whitespace
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
(cleaned.presence)
end
def pdf_source_type
case pdf_source
when String
pdf_source.start_with?('http') ? 'URL' : 'file_path'
else
'uploaded_file'
end
end
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"
end
def process_pdf_extraction
content = extract_text
return failure_response(['No text content found in PDF']) if content.blank?
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,94 @@
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 = parse_url
validate_url_format(uri)
validate_url_length
validate_url_scheme(uri)
end
def parse_url
URI.parse(pdf_source)
rescue URI::InvalidURIError
raise StandardError, 'Malformed URL'
end
def validate_url_format(uri)
raise StandardError, 'Invalid URL format' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def validate_url_length
raise StandardError, 'URL too long' if pdf_source.length > 2048
end
def validate_url_scheme(uri)
raise StandardError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme)
end
def validate_uploaded_file
validate_file_object
validate_file_size
validate_file_type
validate_file_not_empty
end
def validate_file_object
raise StandardError, 'File object is invalid' unless pdf_source.respond_to?(:size) && pdf_source.respond_to?(:content_type)
end
def validate_file_size
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if pdf_source.size > self.class::MAX_PDF_SIZE
end
def validate_file_type
raise StandardError, 'Invalid file type' unless pdf_source.content_type == 'application/pdf'
end
def validate_file_not_empty
raise StandardError, 'Empty file' if pdf_source.respond_to?(:empty?) && pdf_source.empty?
end
def validate_file_path
validate_path_presence
validate_path_existence
validate_path_file_size
validate_path_not_empty
validate_path_readable
end
def validate_path_presence
raise StandardError, 'File path is blank' if pdf_source.blank?
end
def validate_path_existence
raise StandardError, 'File does not exist' unless File.exist?(pdf_source)
end
def validate_path_file_size
raise StandardError, "File too large (max #{self.class::MAX_PDF_SIZE / 1.megabyte}MB)" if File.size(pdf_source) > self.class::MAX_PDF_SIZE
end
def validate_path_not_empty
raise StandardError, 'Empty file' if File.empty?(pdf_source)
end
def validate_path_readable
raise StandardError, 'File is not readable' unless File.readable?(pdf_source)
end
end
@@ -4,10 +4,29 @@ module Enterprise::MessageTemplates::HookExecutionService
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active?
Captain::Conversation::ResponseBuilderJob.perform_later(
conversation,
conversation.inbox.captain_assistant
)
schedule_captain_response
end
private
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant]
if message.attachments.blank?
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
else
wait_time = calculate_attachment_wait_time
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
end
end
def calculate_attachment_wait_time
attachment_count = message.attachments.size
base_wait = 1.second
# Wait longer for more attachments or larger files
additional_wait = [attachment_count * 1, 4].min.seconds
base_wait + additional_wait
end
def should_process_captain_response?
@@ -1 +1,4 @@
json.partial! 'api/v1/models/captain/document', formats: [:json], resource: @document
# Include message for PDF uploads
json.message 'PDF uploaded successfully. Processing will begin shortly.' if @document.source_type == 'pdf_upload'
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
valid_params[:message_content],
valid_params[:message_history]
additional_message: valid_params[:message_content],
message_history: valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(response).to have_http_status(:success)
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
@@ -220,9 +220,16 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
context 'with limits exceeded' do
before do
# Create documents first (when there are no limits)
create_list(:captain_document, 5, assistant: assistant, account: account)
account.update_document_usage
# Now set up the limits configuration
config = InstallationConfig.find_or_create_by(name: 'CAPTAIN_CLOUD_PLAN_LIMITS')
config.update!(value: captain_limits.to_json)
account.reload # Reload to ensure changes are reflected
create(:installation_config, name: 'CAPTAIN_CLOUD_PLAN_LIMITS', value: captain_limits.to_json)
post "/api/v1/accounts/#{account.id}/captain/documents",
params: valid_attributes,
headers: admin.create_new_auth_token
@@ -288,4 +295,69 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
end
end
end
describe 'POST /api/v1/accounts/:account_id/captain/documents/upload_pdf' do
let(:pdf_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/sample.pdf'), 'application/pdf') }
let(:valid_pdf_params) do
{
pdf_document: pdf_file,
assistant_id: assistant.id
}
end
context 'when it is an un-authenticated user' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params
end
it 'returns unauthorized status' do
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: agent.create_new_auth_token
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an admin' do
context 'with valid PDF file' do
it 'uploads PDF and creates document' do
expect do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: admin.create_new_auth_token
end.to change(Captain::Document, :count).by(1)
end
it 'returns success status with document data' do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: valid_pdf_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
expect(json_response[:id]).to be_present
expect(json_response[:message]).to eq('PDF uploaded successfully. Processing will begin shortly.')
end
end
context 'without PDF file' do
before do
post "/api/v1/accounts/#{account.id}/captain/documents/upload_pdf",
params: { assistant_id: assistant.id },
headers: admin.create_new_auth_token
end
it 'returns unprocessable entity status' do
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
end
@@ -30,5 +30,142 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
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
describe 'retry mechanisms for image processing' do
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) }
before do
create(:message, conversation: conversation, content: 'Hello with image', message_type: :incoming)
allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service)
allow(Captain::OpenAiMessageBuilderService).to receive(:new).with(message: anything).and_return(mock_message_builder)
allow(mock_message_builder).to receive(:generate_content).and_return('Hello with image')
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Test response' })
end
context 'when ActiveStorage::FileNotFoundError occurs' do
it 'handles file errors and triggers handoff' do
allow(mock_message_builder).to receive(:generate_content)
.and_raise(ActiveStorage::FileNotFoundError, 'Image file not found')
# For retryable errors, the job should handle them and proceed with handoff
described_class.perform_now(conversation, assistant)
# Verify handoff occurred due to repeated failures
expect(conversation.reload.status).to eq('open')
end
it 'succeeds when no error occurs' do
# Don't raise any error, should succeed normally
allow(mock_message_builder).to receive(:generate_content)
.and_return('Image content processed successfully')
described_class.perform_now(conversation, assistant)
expect(conversation.messages.outgoing.count).to eq(1)
expect(conversation.messages.outgoing.last.content).to eq('Test response')
end
end
context 'when Faraday::BadRequestError occurs' do
it 'handles API errors and triggers handoff' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
it 'succeeds when no error occurs' do
# Don't raise any error, should succeed normally
allow(mock_llm_chat_service).to receive(:generate_response)
.and_return({ 'response' => 'Response after retry' })
described_class.perform_now(conversation, assistant)
expect(conversation.messages.outgoing.last.content).to eq('Response after retry')
end
end
context 'when image processing fails permanently' do
before do
allow(mock_message_builder).to receive(:generate_content)
.and_raise(ActiveStorage::FileNotFoundError, 'Image permanently unavailable')
end
it 'triggers handoff after max retries' do
# Since perform_now re-raises retryable errors, simulate the final failure after retries
allow(mock_message_builder).to receive(:generate_content)
.and_raise(StandardError, 'Max retries exceeded')
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
end
context 'when non-retryable error occurs' do
let(:standard_error) { StandardError.new('Generic error') }
before do
allow(mock_llm_chat_service).to receive(:generate_response).and_raise(standard_error)
end
it 'handles error and triggers handoff' do
expect(ChatwootExceptionTracker).to receive(:new)
.with(standard_error, account: account)
.and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
end
it 'ensures Current.executed_by is reset' do
expect(Current).to receive(:executed_by=).with(assistant)
expect(Current).to receive(:executed_by=).with(nil)
described_class.perform_now(conversation, assistant)
end
end
end
describe 'job configuration' do
it 'has retry_on configuration for retryable errors' do
expect(described_class).to respond_to(:retry_on)
end
it 'defines MAX_MESSAGE_LENGTH constant' do
expect(described_class::MAX_MESSAGE_LENGTH).to eq(10_000)
end
end
end
@@ -104,6 +104,131 @@ RSpec.describe Captain::Documents::CrawlJob, type: :job do
expect(simple_crawler).to receive(:page_links)
described_class.perform_now(document)
end
context 'when document is a PDF' do
let(:pdf_document) { create(:captain_document, external_link: 'https://example.com/document.pdf') }
let(:pdf_service) { instance_double(Captain::Tools::PdfExtractionService) }
let(:pdf_content) do
[
{ content: 'PDF page 1 content', page_number: 1, chunk_index: 1, total_chunks: 1 },
{ content: 'PDF page 2 content', page_number: 2, chunk_index: 1, total_chunks: 1 }
]
end
before do
allow(Captain::Tools::PdfExtractionService)
.to receive(:new)
.with(pdf_document.external_link)
.and_return(pdf_service)
end
it 'processes PDF using PdfExtractionService when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
expect(pdf_service).to receive(:perform)
described_class.perform_now(pdf_document)
end
it 'enqueues PdfExtractionParserJob for each content chunk when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
pdf_content.each do |chunk|
expect(Captain::Tools::PdfExtractionParserJob)
.to receive(:perform_later)
.with(
assistant_id: pdf_document.assistant_id,
pdf_content: chunk,
document_id: pdf_document.id
)
end
described_class.perform_now(pdf_document)
end
it 'updates document status to processing when extraction succeeds' do
allow(pdf_service).to receive(:perform).and_return({
success: true,
content: pdf_content
})
allow(Captain::Tools::PdfExtractionParserJob).to receive(:perform_later)
expect(pdf_document).to receive(:update).with(status: 'processing').twice
described_class.perform_now(pdf_document)
end
it 'updates document status to failed with error message when extraction fails' do
allow(pdf_service).to receive(:perform).and_return({
success: false,
errors: ['Invalid PDF format']
})
expect(pdf_document).to receive(:update).with(status: 'processing').once
expect(pdf_document).to receive(:update).with(
status: 'failed',
error_message: 'Invalid PDF format'
).once
described_class.perform_now(pdf_document)
end
it 'logs the error when extraction fails' do
allow(pdf_service).to receive(:perform).and_return({
success: false,
errors: ['Invalid PDF format']
})
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
described_class.perform_now(pdf_document)
end
it 'handles exceptions gracefully during PDF extraction' do
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
expect(pdf_document).to receive(:update).with(status: 'processing').once
expect(pdf_document).to receive(:update).with(
status: 'failed',
error_message: 'Network error'
).once
described_class.perform_now(pdf_document)
end
it 'logs exceptions during PDF extraction' do
allow(pdf_service).to receive(:perform).and_raise(StandardError, 'Network error')
expect(Rails.logger).to receive(:error).with(/PDF extraction failed/)
described_class.perform_now(pdf_document)
end
end
end
describe '#pdf_document?' do
let(:job) { described_class.new }
it 'detects PDF by file extension' do
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.pdf')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'detects PDF by content type' do
pdf_doc = build(:captain_document, content_type: 'application/pdf')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'detects PDF by source type' do
pdf_doc = build(:captain_document, source_type: 'pdf_upload')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
it 'returns false for non-PDF documents' do
web_doc = build(:captain_document, external_link: 'https://example.com/page.html')
expect(job.send(:pdf_document?, web_doc)).to be false
end
it 'is case insensitive for file extensions' do
pdf_doc = build(:captain_document, external_link: 'https://example.com/file.PDF')
expect(job.send(:pdf_document?, pdf_doc)).to be true
end
end
end
end
@@ -0,0 +1,243 @@
require 'rails_helper'
RSpec.describe Captain::Tools::PdfExtractionParserJob, type: :job do
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:pdf_content) do
{
content: 'This is sample PDF content extracted from a document. It contains useful information for FAQ generation.',
page_number: 1,
chunk_index: 1,
total_chunks: 1
}
end
let(:captain_limits) do
{
startups: { documents: 5, responses: 100 }
}.with_indifferent_access
end
before do
# Mock usage limits
allow(account).to receive(:usage_limits).and_return(
captain: {
documents: {
current_available: 3
}
}
)
end
describe '#perform' do
context 'when limits are not exceeded' do
it 'processes PDF content successfully' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end.to change(Captain::Document, :count).by(1)
end
it 'creates captain documents with correct attributes' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
document = Captain::Document.last
expect(document.assistant_id).to eq(assistant.id)
expect(document.content).to include('sample PDF content')
expect(document.name).to include('This is sample PDF content extracted from a document')
expect(document.status).to eq('available')
end
it 'generates appropriate title for single chunk' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
document = Captain::Document.last
expect(document.name).to eq('This is sample PDF content extracted from a document.')
end
it 'generates appropriate title for multiple chunks' do
multi_chunk_content = pdf_content.merge(
chunk_index: 2,
total_chunks: 3,
page_number: 2
)
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: multi_chunk_content
)
document = Captain::Document.last
expect(document.name).to include('Page 2, Part 2/3')
end
it 'creates document with unique external link' do
document_id = 123
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document_id
)
document = Captain::Document.last
expect(document.external_link).to eq("pdf_chunk_#{document_id}_page_1_chunk_1")
end
end
context 'when limits are exceeded' do
before do
# Stub the Captain::Document.create! method to raise limit error
# This is more specific than allow_any_instance_of
allow(Captain::Document).to receive(:create!).and_raise(Captain::Document::LimitExceededError, 'Document limit exceeded')
end
it 'does not create documents when limit exceeded' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end.not_to(change(Captain::Document, :count))
end
it 'logs limit exceeded message' do
expect(Rails.logger).to receive(:info).with(/Document limit exceeded/)
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
end
end
context 'when document_id is provided' do
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
it 'updates document status to available' do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
document.reload
expect(document.status).to eq('available')
end
end
context 'when an error occurs' do
let(:document) { create(:captain_document, assistant: assistant, status: 'in_progress') }
before do
allow(Captain::Document).to receive(:create!).and_raise(StandardError, 'Database error')
# Mock the specific document instance that will be found by the job
allow(Captain::Document).to receive(:find_by).with(id: document.id).and_return(document)
allow(document).to receive(:update!).and_raise(StandardError, 'Database error')
end
it 'raises an error with descriptive message' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
end.to raise_error(/Failed to parse PDF data/)
end
it 'raises error and updates main document if provided' do
expect do
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content,
document_id: document.id
)
end.to raise_error(/Failed to parse PDF data/)
# The main document should remain in its original state when an error occurs
document.reload
expect(document.status).to eq('in_progress')
end
it 'logs the error' do
expect(Rails.logger).to receive(:error).with(/Failed to parse PDF content/).at_least(:once)
begin
described_class.new.perform(
assistant_id: assistant.id,
pdf_content: pdf_content
)
rescue StandardError
# Expected to raise error
end
end
end
end
describe 'private methods' do
let(:job) { described_class.new }
describe '#generate_content_title' do
it 'uses first line as title when appropriate' do
content = "Introduction to Machine Learning\n\nThis document covers the basics..."
title = job.send(:generate_content_title, content, 1, 1, 1)
expect(title).to eq('Introduction to Machine Learning')
end
it 'uses generic title for long first lines' do
content = "#{('A' * 200)}\n\nThis is the rest of the content..."
title = job.send(:generate_content_title, content, 1, 1, 1)
expect(title).to eq('PDF Content')
end
it 'adds page information for multiple pages' do
content = 'Sample content'
title = job.send(:generate_content_title, content, 3, 1, 1)
expect(title).to eq('Sample content (Page 3)')
end
it 'adds chunk information for multiple chunks' do
content = 'Sample content'
title = job.send(:generate_content_title, content, 2, 2, 4)
expect(title).to eq('Sample content (Page 2, Part 2/4)')
end
it 'truncates long titles to database limit' do
long_content = 'A' * 300
title = job.send(:generate_content_title, long_content, 1, 1, 1)
expect(title.length).to be <= 255
end
end
describe '#limit_exceeded?' do
it 'returns true when limit is zero' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: 0 } }
)
expect(job.send(:limit_exceeded?, account)).to be true
end
it 'returns true when limit is negative' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: -1 } }
)
expect(job.send(:limit_exceeded?, account)).to be true
end
it 'returns false when limit is positive' do
allow(account).to receive(:usage_limits).and_return(
captain: { documents: { current_available: 5 } }
)
expect(job.send(:limit_exceeded?, account)).to be false
end
end
end
end
@@ -0,0 +1,310 @@
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')
allow(attachment).to receive(:download_url).and_return('')
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,247 @@
require 'rails_helper'
RSpec.describe Captain::Tools::PdfExtractionService do
let(:service) { described_class.new(pdf_source) }
let(:sample_pdf_path) { Rails.root.join('spec/fixtures/files/sample.pdf') }
describe '#initialize' do
context 'with valid PDF path' do
let(:pdf_source) { sample_pdf_path.to_s }
it 'initializes with the PDF source' do
expect(service.pdf_source).to eq(pdf_source)
end
end
context 'with URL' do
let(:pdf_source) { 'https://example.com/sample.pdf' }
it 'initializes with the PDF URL' do
expect(service.pdf_source).to eq(pdf_source)
end
end
end
describe '#perform' do
context 'with blank PDF source' do
let(:pdf_source) { nil }
it 'returns error for blank source' do
result = service.perform
expect(result[:success]).to be false
expect(result[:errors]).to include('Invalid PDF source')
end
end
context 'with valid PDF file path' do
let(:pdf_source) { sample_pdf_path.to_s }
before do
# Ensure the sample PDF exists
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
end
it 'handles PDF extraction gracefully' do
result = service.perform
expect(result).to have_key(:success)
expect(result).to have_key(:content).or have_key(:errors)
if result[:success]
expect(result[:content]).to be_an(Array)
else
expect(result[:errors]).to be_an(Array)
end
end
it 'returns structured result format' do
result = service.perform
if result[:success] && result[:content].present?
first_chunk = result[:content].first
expect(first_chunk).to have_key(:content)
expect(first_chunk).to have_key(:page_number)
expect(first_chunk).to have_key(:chunk_index)
expect(first_chunk).to have_key(:total_chunks)
else
expect(result[:errors]).to be_present
end
end
end
context 'with malformed PDF' do
let(:pdf_source) { Rails.root.join('spec/fixtures/files/sample.txt').to_s }
it 'handles malformed PDF gracefully' do
result = service.perform
expect(result[:success]).to be false
expect(result[:errors]).to be_an(Array)
end
end
context 'with HTTP URL' do
let(:pdf_source) { 'https://example.com/sample.pdf' }
it 'attempts to download and process PDF from URL' do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s, close: nil, unlink: nil)
allow(Down).to receive(:download).and_return(temp_file)
allow(File).to receive(:exist?).and_return(true)
# Mock PDF reader
mock_page = instance_double(PDF::Reader::Page, text: 'Sample PDF content')
mock_pages = [mock_page]
mock_reader = instance_double(PDF::Reader, pages: mock_pages)
allow(PDF::Reader).to receive(:open).and_yield(mock_reader)
result = service.perform
expect(result[:success]).to be true
end
end
end
describe '#extract_text' do
let(:pdf_source) { sample_pdf_path.to_s }
before do
skip 'Sample PDF file not available for testing' unless File.exist?(sample_pdf_path)
end
it 'handles text extraction gracefully' do
expect { service.extract_text }.not_to raise_error(NoMethodError)
# Should either succeed or raise a PDF::Reader error that gets caught
begin
extracted_content = service.extract_text
expect(extracted_content).to be_an(Array)
if extracted_content.any?
page_content = extracted_content.first
expect(page_content).to have_key(:page_number)
expect(page_content).to have_key(:content)
end
rescue PDF::Reader::MalformedPDFError
# This is expected for malformed PDFs
# Test passes if we reach this point
end
end
end
describe 'private methods' do
let(:pdf_source) { sample_pdf_path.to_s }
describe '#clean_text' do
it 'cleans text content properly' do
dirty_text = " \f Some text with \r\n line breaks \n\n\n and extra spaces "
cleaned = service.send(:clean_text, dirty_text)
expect(cleaned).not_to include("\f")
expect(cleaned).not_to include("\r")
expect(cleaned).to include('Some text with')
expect(cleaned.strip).to eq(cleaned)
end
it 'returns nil for blank text' do
cleaned = service.send(:clean_text, " \n\n ")
expect(cleaned).to be_nil
end
end
describe '#chunk_content' do
let(:page_contents) do
[
{ page_number: 1, content: 'Short content' },
{ page_number: 2, content: 'A' * 3000 } # Long content that needs chunking
]
end
it 'chunks content appropriately' do
chunks = service.send(:chunk_content, page_contents, max_chunk_size: 1000)
expect(chunks.length).to be >= 2 # Should have at least 2 chunks
# Check first chunk (short content)
first_chunk = chunks.first
expect(first_chunk[:page_number]).to eq(1)
expect(first_chunk[:total_chunks]).to eq(1)
# Check that long content was processed
long_content_chunks = chunks.select { |c| c[:page_number] == 2 }
expect(long_content_chunks.length).to be >= 1
# Verify long content was split appropriately
total_long_content_length = long_content_chunks.sum { |c| c[:content].length }
expect(total_long_content_length).to be > 0
end
end
describe '#split_content_into_chunks' do
it 'splits content by paragraphs first' do
content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
chunks = service.send(:split_content_into_chunks, content, 50)
expect(chunks.length).to be >= 2
expect(chunks.join(' ')).to include('First paragraph')
expect(chunks.join(' ')).to include('Second paragraph')
end
it 'splits by sentences when paragraphs are too large' do
long_paragraph = "#{('A' * 100)}. #{('B' * 100)}. #{('C' * 100)}."
chunks = service.send(:split_content_into_chunks, long_paragraph, 150)
expect(chunks.length).to be > 1
end
end
end
describe 'file validation' do
context 'with uploaded file object' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'application/pdf',
size: 1024
)
end
let(:pdf_source) { uploaded_file }
it 'validates uploaded file properties' do
expect { service.send(:validate_uploaded_file) }.not_to raise_error
end
end
context 'with oversized file' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'application/pdf',
size: 30.megabytes
)
end
let(:pdf_source) { uploaded_file }
it 'raises error for oversized file' do
expect { service.send(:validate_uploaded_file) }.to raise_error(/File too large/)
end
end
context 'with invalid content type' do
let(:uploaded_file) do
temp_file = instance_double(Tempfile, path: sample_pdf_path.to_s)
instance_double(
ActionDispatch::Http::UploadedFile,
tempfile: temp_file,
content_type: 'text/plain',
size: 1024
)
end
let(:pdf_source) { uploaded_file }
it 'raises error for invalid content type' do
expect { service.send(:validate_uploaded_file) }.to raise_error('Invalid file type')
end
end
end
end
+53
View File
@@ -0,0 +1,53 @@
%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
72 720 Td
(Sample PDF for testing) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000202 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
294
%%EOF
+2
View File
@@ -0,0 +1,2 @@
This is a sample text file for testing purposes.
It contains some basic text content that can be used in tests.
+62
View File
@@ -0,0 +1,62 @@
%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/MediaBox [0 0 612 792]
/Contents 4 0 R
/Resources <<
/Font <<
/F1 5 0 R
>>
>>
>>
endobj
4 0 obj
<<
/Length 54
>>
stream
BT
/F1 12 Tf
72 720 Td
(Hello World from PDF\!) Tj
ET
endstream
endobj
5 0 obj
<<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
endobj
xref
0 6
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000117 00000 n
0000000254 00000 n
0000000350 00000 n
trailer
<<
/Size 6
/Root 1 0 R
>>
startxref
421
%%EOF