Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bd569eb35 | ||
|
|
a38fe59446 | ||
|
|
4355a72c15 | ||
|
|
91cb159ef0 | ||
|
|
45f9aeec10 | ||
|
|
305b40100f | ||
|
|
d77654be11 | ||
|
|
553fcbc5e9 | ||
|
|
ba65a5a534 | ||
|
|
6656b042b8 | ||
|
|
9370d21612 | ||
|
|
8a3b4edac8 | ||
|
|
6681561864 | ||
|
|
bbc51d687d | ||
|
|
6222f9b492 | ||
|
|
dc48d44b51 | ||
|
|
3765720849 | ||
|
|
4fd8f4de30 | ||
|
|
2f59d34e50 | ||
|
|
ec107a2d94 | ||
|
|
235ce3479c | ||
|
|
8e87e16486 | ||
|
|
8f27ba081c | ||
|
|
6c45d56567 | ||
|
|
85c2fd10e4 | ||
|
|
165a74229e | ||
|
|
de6717e7f1 | ||
|
|
7d39f890e4 | ||
|
|
a92394f446 | ||
|
|
ea3a84c930 |
@@ -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
@@ -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();
|
||||
|
||||
+26
-11
@@ -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"
|
||||
|
||||
+227
-31
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Vendored
+53
@@ -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
|
||||
Vendored
+2
@@ -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.
|
||||
Vendored
+62
@@ -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
|
||||
Reference in New Issue
Block a user