- Zapier Document Refresh action (create) added
This commit is contained in:
@@ -660,3 +660,48 @@ def json_to_pattern_list(json_content: str) -> list:
|
|||||||
return patterns
|
return patterns
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
raise ValueError(f"Invalid JSON format: {e}")
|
raise ValueError(f"Invalid JSON format: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_json_field(value: str | dict | None, field_name: str = "JSON field") -> dict:
|
||||||
|
"""
|
||||||
|
Normalize a JSON field value to ensure it's a valid dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The input value which can be:
|
||||||
|
- None (will return empty dict)
|
||||||
|
- String (will be parsed as JSON)
|
||||||
|
- Dict (will be validated and returned)
|
||||||
|
field_name: Name of the field for error messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The normalized JSON data as a Python dictionary
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the input string is not valid JSON or the input dict contains invalid types
|
||||||
|
"""
|
||||||
|
# Handle None case
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Handle dictionary case
|
||||||
|
if isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
# Validate all values are JSON serializable
|
||||||
|
import json
|
||||||
|
json.dumps(value)
|
||||||
|
return value
|
||||||
|
except TypeError as e:
|
||||||
|
raise ValueError(f"{field_name} contains invalid types: {str(e)}")
|
||||||
|
|
||||||
|
# Handle string case
|
||||||
|
if isinstance(value, str):
|
||||||
|
if not value.strip():
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
return json.loads(value)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"{field_name} contains invalid JSON: {str(e)}")
|
||||||
|
|
||||||
|
raise ValueError(f"{field_name} must be a string, dictionary, or None (got {type(value)})")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from flask_security import current_user
|
|||||||
import requests
|
import requests
|
||||||
from urllib.parse import urlparse, unquote, urlunparse
|
from urllib.parse import urlparse, unquote, urlunparse
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from .config_field_types import normalize_json_field
|
||||||
from .eveai_exceptions import (EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType,
|
from .eveai_exceptions import (EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType,
|
||||||
EveAIInvalidCatalog, EveAIInvalidDocument, EveAIInvalidDocumentVersion, EveAIException)
|
EveAIInvalidCatalog, EveAIInvalidDocument, EveAIInvalidDocumentVersion, EveAIException)
|
||||||
from ..models.user import Tenant
|
from ..models.user import Tenant
|
||||||
@@ -88,10 +90,10 @@ def create_version_for_document(document, tenant_id, url, sub_file_type, langua
|
|||||||
new_doc_vers.user_context = user_context
|
new_doc_vers.user_context = user_context
|
||||||
|
|
||||||
if user_metadata != '' and user_metadata is not None:
|
if user_metadata != '' and user_metadata is not None:
|
||||||
new_doc_vers.user_metadata = user_metadata
|
new_doc_vers.user_metadata = normalize_json_field(user_metadata, "user_metadata")
|
||||||
|
|
||||||
if catalog_properties != '' and catalog_properties is not None:
|
if catalog_properties != '' and catalog_properties is not None:
|
||||||
new_doc_vers.catalog_properties = catalog_properties
|
new_doc_vers.catalog_properties = normalize_json_field(catalog_properties, "catalog_properties")
|
||||||
|
|
||||||
if sub_file_type != '':
|
if sub_file_type != '':
|
||||||
new_doc_vers.sub_file_type = sub_file_type
|
new_doc_vers.sub_file_type = sub_file_type
|
||||||
@@ -262,7 +264,8 @@ def edit_document_version(tenant_id, version_id, user_context, catalog_propertie
|
|||||||
if not doc_vers:
|
if not doc_vers:
|
||||||
raise EveAIInvalidDocumentVersion(tenant_id, version_id)
|
raise EveAIInvalidDocumentVersion(tenant_id, version_id)
|
||||||
doc_vers.user_context = user_context
|
doc_vers.user_context = user_context
|
||||||
doc_vers.catalog_properties = catalog_properties
|
doc_vers.catalog_properties = normalize_json_field(catalog_properties, "catalog_properties")
|
||||||
|
|
||||||
update_logging_information(doc_vers, dt.now(tz.utc))
|
update_logging_information(doc_vers, dt.now(tz.utc))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -319,6 +322,56 @@ def refresh_document_with_info(doc_id, tenant_id, api_input):
|
|||||||
return new_doc_vers, task.id
|
return new_doc_vers, task.id
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_document_with_content(doc_id: int, tenant_id: int, file_content: bytes, api_input: dict) -> tuple:
|
||||||
|
"""
|
||||||
|
Refresh document with new content
|
||||||
|
|
||||||
|
Args:
|
||||||
|
doc_id: Document ID
|
||||||
|
tenant_id: Tenant ID
|
||||||
|
file_content: New file content
|
||||||
|
api_input: Additional document information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (new_version, task_id)
|
||||||
|
"""
|
||||||
|
doc = Document.query.get(doc_id)
|
||||||
|
if not doc:
|
||||||
|
raise EveAIInvalidDocument(tenant_id, doc_id)
|
||||||
|
|
||||||
|
old_doc_vers = DocumentVersion.query.filter_by(doc_id=doc_id).order_by(desc(DocumentVersion.id)).first()
|
||||||
|
|
||||||
|
# Create new version with same file type as original
|
||||||
|
extension = old_doc_vers.file_type
|
||||||
|
|
||||||
|
new_doc_vers = create_version_for_document(
|
||||||
|
doc, tenant_id,
|
||||||
|
'', # No URL for content-based updates
|
||||||
|
old_doc_vers.sub_file_type,
|
||||||
|
api_input.get('language', old_doc_vers.language),
|
||||||
|
api_input.get('user_context', old_doc_vers.user_context),
|
||||||
|
api_input.get('user_metadata', old_doc_vers.user_metadata),
|
||||||
|
api_input.get('catalog_properties', old_doc_vers.catalog_properties),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.session.add(new_doc_vers)
|
||||||
|
db.session.commit()
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# Upload new content
|
||||||
|
upload_file_for_version(new_doc_vers, file_content, extension, tenant_id)
|
||||||
|
|
||||||
|
# Start embedding task
|
||||||
|
task = current_celery.send_task('create_embeddings', args=[tenant_id, new_doc_vers.id], queue='embeddings')
|
||||||
|
current_app.logger.info(f'Embedding creation started for document {doc_id} on version {new_doc_vers.id} '
|
||||||
|
f'with task id: {task.id}.')
|
||||||
|
|
||||||
|
return new_doc_vers, task.id
|
||||||
|
|
||||||
|
|
||||||
# Update the existing refresh_document function to use the new refresh_document_with_info
|
# Update the existing refresh_document function to use the new refresh_document_with_info
|
||||||
def refresh_document(doc_id, tenant_id):
|
def refresh_document(doc_id, tenant_id):
|
||||||
current_app.logger.info(f'Refreshing document {doc_id}')
|
current_app.logger.info(f'Refreshing document {doc_id}')
|
||||||
@@ -388,6 +441,10 @@ def lookup_document(tenant_id: int, lookup_criteria: dict, metadata_type: str) -
|
|||||||
for key, value in lookup_criteria.items():
|
for key, value in lookup_criteria.items():
|
||||||
query = query.filter(metadata_field[key].astext == str(value))
|
query = query.filter(metadata_field[key].astext == str(value))
|
||||||
|
|
||||||
|
# Log the final SQL query
|
||||||
|
current_app.logger.debug(
|
||||||
|
f"Final SQL query: {query.statement.compile(compile_kwargs={'literal_binds': True})}")
|
||||||
|
|
||||||
# Get first result
|
# Get first result
|
||||||
result = query.first()
|
result = query.first()
|
||||||
|
|
||||||
@@ -411,55 +468,3 @@ def lookup_document(tenant_id: int, lookup_criteria: dict, metadata_type: str) -
|
|||||||
"Error during document lookup",
|
"Error during document lookup",
|
||||||
status_code=500
|
status_code=500
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Add to common/utils/document_utils.py
|
|
||||||
|
|
||||||
def refresh_document_with_content(doc_id: int, tenant_id: int, file_content: bytes, api_input: dict) -> tuple:
|
|
||||||
"""
|
|
||||||
Refresh document with new content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
doc_id: Document ID
|
|
||||||
tenant_id: Tenant ID
|
|
||||||
file_content: New file content
|
|
||||||
api_input: Additional document information
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (new_version, task_id)
|
|
||||||
"""
|
|
||||||
doc = Document.query.get(doc_id)
|
|
||||||
if not doc:
|
|
||||||
raise EveAIInvalidDocument(tenant_id, doc_id)
|
|
||||||
|
|
||||||
old_doc_vers = DocumentVersion.query.filter_by(doc_id=doc_id).order_by(desc(DocumentVersion.id)).first()
|
|
||||||
|
|
||||||
# Create new version with same file type as original
|
|
||||||
extension = old_doc_vers.file_type
|
|
||||||
|
|
||||||
new_doc_vers = create_version_for_document(
|
|
||||||
doc, tenant_id,
|
|
||||||
'', # No URL for content-based updates
|
|
||||||
old_doc_vers.sub_file_type,
|
|
||||||
api_input.get('language', old_doc_vers.language),
|
|
||||||
api_input.get('user_context', old_doc_vers.user_context),
|
|
||||||
api_input.get('user_metadata', old_doc_vers.user_metadata),
|
|
||||||
api_input.get('catalog_properties', old_doc_vers.catalog_properties),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
db.session.add(new_doc_vers)
|
|
||||||
db.session.commit()
|
|
||||||
except SQLAlchemyError as e:
|
|
||||||
db.session.rollback()
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
# Upload new content
|
|
||||||
upload_file_for_version(new_doc_vers, file_content, extension, tenant_id)
|
|
||||||
|
|
||||||
# Start embedding task
|
|
||||||
task = current_celery.send_task('create_embeddings', args=[tenant_id, new_doc_vers.id], queue='embeddings')
|
|
||||||
current_app.logger.info(f'Embedding creation started for document {doc_id} on version {new_doc_vers.id} '
|
|
||||||
f'with task id: {task.id}.')
|
|
||||||
|
|
||||||
return new_doc_vers, task.id
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request, redirect
|
||||||
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
|
from flask_jwt_extended import get_jwt_identity, verify_jwt_in_request
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
@@ -103,14 +103,19 @@ def create_app(config_file=None):
|
|||||||
|
|
||||||
@app.route('/api/v1')
|
@app.route('/api/v1')
|
||||||
def swagger():
|
def swagger():
|
||||||
return api_rest.render_doc()
|
return redirect('/api/v1/')
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def register_extensions(app):
|
def register_extensions(app):
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
api_rest.init_app(app, title='EveAI API', version='1.0', description='EveAI API')
|
api_rest.init_app(app,
|
||||||
|
title='EveAI API',
|
||||||
|
version='1.0',
|
||||||
|
description='EveAI API',
|
||||||
|
doc='/api/v1/',
|
||||||
|
prefix='/api/v1'),
|
||||||
jwt.init_app(app)
|
jwt.init_app(app)
|
||||||
minio_client.init_app(app)
|
minio_client.init_app(app)
|
||||||
simple_encryption.init_app(app)
|
simple_encryption.init_app(app)
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Tuple, Any
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from flask import current_app, request
|
from flask import current_app, request
|
||||||
from flask_restx import Namespace, Resource, fields, reqparse
|
from flask_restx import Namespace, Resource, fields, reqparse
|
||||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from sqlalchemy import desc
|
||||||
from werkzeug.datastructures import FileStorage
|
from werkzeug.datastructures import FileStorage
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
from common.models.document import DocumentVersion
|
||||||
from common.utils.document_utils import (
|
from common.utils.document_utils import (
|
||||||
create_document_stack, process_url, start_embedding_task,
|
create_document_stack, process_url, start_embedding_task,
|
||||||
EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType,
|
EveAIInvalidLanguageException, EveAIDoubleURLException, EveAIUnsupportedFileType,
|
||||||
@@ -37,7 +41,8 @@ document_ns = Namespace('documents', description='Document related operations')
|
|||||||
|
|
||||||
# Define models for request parsing and response serialization
|
# Define models for request parsing and response serialization
|
||||||
upload_parser = reqparse.RequestParser()
|
upload_parser = reqparse.RequestParser()
|
||||||
upload_parser.add_argument('catalog_id', location='form', type=int, required=True, help='The catalog to add the file to')
|
upload_parser.add_argument('catalog_id', location='form', type=int, required=True,
|
||||||
|
help='The catalog to add the file to')
|
||||||
upload_parser.add_argument('file', location='files', type=FileStorage, required=True, help='The file to upload')
|
upload_parser.add_argument('file', location='files', type=FileStorage, required=True, help='The file to upload')
|
||||||
upload_parser.add_argument('name', location='form', type=str, required=False, help='Name of the document')
|
upload_parser.add_argument('name', location='form', type=str, required=False, help='Name of the document')
|
||||||
upload_parser.add_argument('language', location='form', type=str, required=True, help='Language of the document')
|
upload_parser.add_argument('language', location='form', type=str, required=True, help='Language of the document')
|
||||||
@@ -69,7 +74,11 @@ class AddDocument(Resource):
|
|||||||
@document_ns.response(500, 'Internal Server Error')
|
@document_ns.response(500, 'Internal Server Error')
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""
|
||||||
Add a new document by providing the content of a file (Multipart/form-data).
|
Upload a new document to EveAI by directly providing the file content.
|
||||||
|
|
||||||
|
This endpoint accepts multipart/form-data with the file content and metadata. It processes
|
||||||
|
the file, creates a new document in the specified catalog, and initiates the embedding
|
||||||
|
process.
|
||||||
"""
|
"""
|
||||||
tenant_id = get_jwt_identity()
|
tenant_id = get_jwt_identity()
|
||||||
current_app.logger.info(f'Adding document for tenant {tenant_id}')
|
current_app.logger.info(f'Adding document for tenant {tenant_id}')
|
||||||
@@ -126,7 +135,8 @@ add_document_through_url = document_ns.model('AddDocumentThroughURL', {
|
|||||||
'valid_from': fields.String(required=False, description='Valid from date for the document'),
|
'valid_from': fields.String(required=False, description='Valid from date for the document'),
|
||||||
'user_metadata': fields.String(required=False, description='User metadata for the document'),
|
'user_metadata': fields.String(required=False, description='User metadata for the document'),
|
||||||
'system_metadata': fields.String(required=False, description='System metadata for the document'),
|
'system_metadata': fields.String(required=False, description='System metadata for the document'),
|
||||||
'catalog_properties': fields.String(required=False, description='The catalog configuration to be passed along (JSON '
|
'catalog_properties': fields.String(required=False,
|
||||||
|
description='The catalog configuration to be passed along (JSON '
|
||||||
'format). Validity is against catalog requirements '
|
'format). Validity is against catalog requirements '
|
||||||
'is not checked, and is the responsibility of the '
|
'is not checked, and is the responsibility of the '
|
||||||
'calling client.'),
|
'calling client.'),
|
||||||
@@ -139,6 +149,7 @@ add_document_through_url_response = document_ns.model('AddDocumentThroughURLResp
|
|||||||
'task_id': fields.String(description='ID of the embedding task')
|
'task_id': fields.String(description='ID of the embedding task')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@document_ns.route('/add_document_through_url')
|
@document_ns.route('/add_document_through_url')
|
||||||
class AddDocumentThroughURL(Resource):
|
class AddDocumentThroughURL(Resource):
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@@ -150,8 +161,10 @@ class AddDocumentThroughURL(Resource):
|
|||||||
@document_ns.response(500, 'Internal Server Error')
|
@document_ns.response(500, 'Internal Server Error')
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""
|
||||||
Add a new document using a URL. The URL can be temporary, and will not be stored.
|
Add a new document to EveAI using a temporary URL.
|
||||||
Mainly used for passing temporary URLs like used in e.g. Zapier
|
|
||||||
|
This endpoint is primarily used for integration with services that provide temporary URLs
|
||||||
|
(like Zapier). The URL content is downloaded and processed as a new document.
|
||||||
"""
|
"""
|
||||||
tenant_id = get_jwt_identity()
|
tenant_id = get_jwt_identity()
|
||||||
current_app.logger.info(f'Adding document through url for tenant {tenant_id}')
|
current_app.logger.info(f'Adding document through url for tenant {tenant_id}')
|
||||||
@@ -164,29 +177,19 @@ class AddDocumentThroughURL(Resource):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Download from stashed URL
|
user_metadata = json.loads(args.get('user_metadata', '{}'))
|
||||||
stashed_url = args['temp_url']
|
actual_file_content, actual_file_content_type = download_file_content(args['temp_url'], user_metadata)
|
||||||
current_app.logger.info(f"Downloading stashed file from URL: {stashed_url}")
|
|
||||||
response = requests.get(stashed_url, stream=True)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
hydration_url = response.text.strip()
|
|
||||||
current_app.logger.info(f"Downloading actual file from URL: {hydration_url}")
|
|
||||||
# Step 2: Download from hydration URL
|
|
||||||
actual_file_response = requests.get(hydration_url, stream=True)
|
|
||||||
actual_file_response.raise_for_status()
|
|
||||||
hydrated_file_content = actual_file_response.content
|
|
||||||
|
|
||||||
# Get filename from URL or use provided name
|
# Get filename from URL or use provided name
|
||||||
filename = secure_filename(args.get('name'))
|
filename = secure_filename(args.get('name'))
|
||||||
extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
# Create FileStorage object from downloaded content
|
# Create FileStorage object from downloaded content
|
||||||
file_content = io.BytesIO(hydrated_file_content)
|
file_content = io.BytesIO(actual_file_content)
|
||||||
file = FileStorage(
|
file = FileStorage(
|
||||||
stream=file_content,
|
stream=file_content,
|
||||||
filename=filename,
|
filename=filename,
|
||||||
content_type=response.headers.get('content-type', 'application/octet-stream')
|
content_type=actual_file_content_type
|
||||||
)
|
)
|
||||||
|
|
||||||
current_app.logger.info(f"Successfully downloaded file: {filename}")
|
current_app.logger.info(f"Successfully downloaded file: {filename}")
|
||||||
@@ -233,7 +236,8 @@ add_url_model = document_ns.model('AddURL', {
|
|||||||
'valid_from': fields.String(required=False, description='Valid from date for the document'),
|
'valid_from': fields.String(required=False, description='Valid from date for the document'),
|
||||||
'user_metadata': fields.String(required=False, description='User metadata for the document'),
|
'user_metadata': fields.String(required=False, description='User metadata for the document'),
|
||||||
'system_metadata': fields.String(required=False, description='System metadata for the document'),
|
'system_metadata': fields.String(required=False, description='System metadata for the document'),
|
||||||
'catalog_properties': fields.String(required=False, description='The catalog configuration to be passed along (JSON '
|
'catalog_properties': fields.String(required=False,
|
||||||
|
description='The catalog configuration to be passed along (JSON '
|
||||||
'format). Validity is against catalog requirements '
|
'format). Validity is against catalog requirements '
|
||||||
'is not checked, and is the responsibility of the '
|
'is not checked, and is the responsibility of the '
|
||||||
'calling client.'),
|
'calling client.'),
|
||||||
@@ -257,8 +261,10 @@ class AddURL(Resource):
|
|||||||
@document_ns.response(500, 'Internal Server Error')
|
@document_ns.response(500, 'Internal Server Error')
|
||||||
def post(self):
|
def post(self):
|
||||||
"""
|
"""
|
||||||
Add a new document from URL. The URL in this case is stored and can be used to refresh the document.
|
Add a new document to EveAI from a permanent URL.
|
||||||
As a consequence, this must be a permanent and accessible URL.
|
|
||||||
|
This endpoint is used for URLs that will remain accessible. The URL is stored and can
|
||||||
|
be used to refresh the document's content later.
|
||||||
"""
|
"""
|
||||||
tenant_id = get_jwt_identity()
|
tenant_id = get_jwt_identity()
|
||||||
current_app.logger.info(f'Adding document from URL for tenant {tenant_id}')
|
current_app.logger.info(f'Adding document from URL for tenant {tenant_id}')
|
||||||
@@ -383,7 +389,8 @@ class DocumentVersionResource(Resource):
|
|||||||
"""Edit a document version"""
|
"""Edit a document version"""
|
||||||
data = request.json
|
data = request.json
|
||||||
tenant_id = get_jwt_identity()
|
tenant_id = get_jwt_identity()
|
||||||
updated_version, error = edit_document_version(tenant_id, version_id, data['user_context'], data.get('catalog_properties'))
|
updated_version, error = edit_document_version(tenant_id, version_id, data['user_context'],
|
||||||
|
data.get('catalog_properties'))
|
||||||
if updated_version:
|
if updated_version:
|
||||||
return {'message': f'Document Version {updated_version.id} updated successfully'}, 200
|
return {'message': f'Document Version {updated_version.id} updated successfully'}, 200
|
||||||
else:
|
else:
|
||||||
@@ -518,50 +525,49 @@ class DocumentLookup(Resource):
|
|||||||
return {'message': f'Missing required field: {str(e)}'}, 400
|
return {'message': f'Missing required field: {str(e)}'}, 400
|
||||||
|
|
||||||
|
|
||||||
refresh_content_model = document_ns.model('RefreshDocumentContent', {
|
refresh_url_model = document_ns.model('RefreshDocumentThroughURL', {
|
||||||
'file_content': fields.Raw(required=True, description='The new file content'),
|
'temp_url': fields.String(required=True, description='Temporary URL of the updated document content'),
|
||||||
'language': fields.String(required=False, description='Language of the document'),
|
'language': fields.String(required=False, description='Language of the document'),
|
||||||
'user_context': fields.String(required=False, description='User context for the document'),
|
'user_context': fields.String(required=False, description='User context for the document'),
|
||||||
'user_metadata': fields.Raw(required=False, description='Custom metadata fields'),
|
'user_metadata': fields.Raw(required=False, description='Custom metadata fields'),
|
||||||
'catalog_properties': fields.Raw(required=False, description='Catalog-specific properties'),
|
'catalog_properties': fields.Raw(required=False, description='Catalog-specific properties'),
|
||||||
'trigger_service': fields.String(required=False, description='Service that triggered the update')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@document_ns.route('/<int:document_id>/refresh_content')
|
@document_ns.route('/<int:document_id>/refresh_through_url')
|
||||||
class RefreshDocumentContent(Resource):
|
class RefreshDocumentThroughURL(Resource):
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@requires_service('DOCAPI')
|
@requires_service('DOCAPI')
|
||||||
@document_ns.expect(refresh_content_model)
|
@document_ns.expect(refresh_url_model)
|
||||||
@document_ns.response(200, 'Document refreshed successfully')
|
@document_ns.response(200, 'Document refreshed successfully')
|
||||||
def post(self, document_id):
|
def post(self, document_id):
|
||||||
"""Refresh a document with new content"""
|
"""Refresh a document using content from a URL"""
|
||||||
tenant_id = get_jwt_identity()
|
tenant_id = get_jwt_identity()
|
||||||
|
current_app.logger.info(f'Refreshing document {document_id} through URL for tenant {tenant_id}')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = request.json
|
# Get filename from the existing version
|
||||||
file_content = data['file_content']
|
old_doc_vers = (DocumentVersion.query.filter_by(doc_id=document_id).
|
||||||
|
order_by(desc(DocumentVersion.id)).first())
|
||||||
|
filename = f"{old_doc_vers.id}.{old_doc_vers.file_type}"
|
||||||
|
|
||||||
# Build user_metadata by merging:
|
args = request.json
|
||||||
# 1. Existing metadata (if any)
|
user_metadata = json.loads(args.get('user_metadata', '{}'))
|
||||||
# 2. New metadata from request
|
|
||||||
# 3. Zapier-specific fields
|
|
||||||
user_metadata = data.get('user_metadata', {})
|
|
||||||
user_metadata.update({
|
|
||||||
'source': 'zapier',
|
|
||||||
'trigger_service': data.get('trigger_service')
|
|
||||||
})
|
|
||||||
data['user_metadata'] = user_metadata
|
|
||||||
|
|
||||||
# Keep catalog_properties separate
|
try:
|
||||||
if 'catalog_properties' in data:
|
actual_file_content, actual_file_content_type = download_file_content(args['temp_url'], user_metadata)
|
||||||
# We could add validation here against catalog configuration
|
file_content = io.BytesIO(actual_file_content)
|
||||||
data['catalog_properties'] = data['catalog_properties']
|
file = FileStorage(
|
||||||
|
stream=file_content,
|
||||||
|
filename=filename,
|
||||||
|
content_type=actual_file_content_type
|
||||||
|
)
|
||||||
|
|
||||||
new_version, task_id = refresh_document_with_content(
|
new_version, task_id = refresh_document_with_content(
|
||||||
document_id,
|
document_id,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
file_content,
|
actual_file_content,
|
||||||
data
|
args
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -571,5 +577,35 @@ class RefreshDocumentContent(Resource):
|
|||||||
'task_id': task_id
|
'task_id': task_id
|
||||||
}, 200
|
}, 200
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
current_app.logger.error(f"Error downloading file: {str(e)}")
|
||||||
|
return {'message': f'Error downloading file: {str(e)}'}, 422
|
||||||
|
|
||||||
except EveAIException as e:
|
except EveAIException as e:
|
||||||
return e.to_dict(), e.status_code
|
return e.to_dict(), e.status_code
|
||||||
|
|
||||||
|
|
||||||
|
def download_file_content(url: str, user_metadata: dict) -> tuple[Any, Any]:
|
||||||
|
if user_metadata and 'service' in user_metadata and 'Zapier' in user_metadata['service']:
|
||||||
|
# Zapier uses a system of Stashed URLs
|
||||||
|
# Step 1: Download from stashed URL
|
||||||
|
stashed_url = url
|
||||||
|
current_app.logger.info(f"Downloading stashed file from URL: {stashed_url}")
|
||||||
|
response = requests.get(stashed_url, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
hydration_url = response.text.strip()
|
||||||
|
current_app.logger.info(f"Downloading actual file from URL: {hydration_url}")
|
||||||
|
# Step 2: Download from hydration URL
|
||||||
|
actual_file_response = requests.get(hydration_url, stream=True)
|
||||||
|
actual_file_response.raise_for_status()
|
||||||
|
actual_file_content = actual_file_response.content
|
||||||
|
else:
|
||||||
|
actual_url = url
|
||||||
|
actual_file_response = requests.get(actual_url, stream=True)
|
||||||
|
actual_file_response.raise_for_status()
|
||||||
|
actual_file_content = actual_file_response.content
|
||||||
|
|
||||||
|
actual_file_content_type = actual_file_response.headers.get('content-type', 'application/octet-stream')
|
||||||
|
|
||||||
|
return actual_file_content, actual_file_content_type
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def ping():
|
|||||||
|
|
||||||
@current_celery.task(name='create_embeddings', queue='embeddings')
|
@current_celery.task(name='create_embeddings', queue='embeddings')
|
||||||
def create_embeddings(tenant_id, document_version_id):
|
def create_embeddings(tenant_id, document_version_id):
|
||||||
|
document_version = None
|
||||||
try:
|
try:
|
||||||
# Retrieve Tenant for which we are processing
|
# Retrieve Tenant for which we are processing
|
||||||
tenant = Tenant.query.get(tenant_id)
|
tenant = Tenant.query.get(tenant_id)
|
||||||
@@ -66,6 +67,8 @@ def create_embeddings(tenant_id, document_version_id):
|
|||||||
f'for badly configured document version {document_version_id} '
|
f'for badly configured document version {document_version_id} '
|
||||||
f'for tenant {tenant_id}, '
|
f'for tenant {tenant_id}, '
|
||||||
f'error: {e}')
|
f'error: {e}')
|
||||||
|
if document_version:
|
||||||
|
document_version.processing_error = str(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# BusinessEvent creates a context, which is why we need to use it with a with block
|
# BusinessEvent creates a context, which is why we need to use it with a with block
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ module.exports = {
|
|||||||
|
|
||||||
// Get the file URL from Zapier
|
// Get the file URL from Zapier
|
||||||
const tempFileUrl = await z.stashFile(bundle.inputData.file);
|
const tempFileUrl = await z.stashFile(bundle.inputData.file);
|
||||||
// Log the temporary URL for debugging
|
|
||||||
z.console.log('Temporary URL created:', tempFileUrl);
|
z.console.log('Temporary URL created:', tempFileUrl);
|
||||||
|
|
||||||
// Create request data as an object
|
// Create request data as an object
|
||||||
@@ -190,73 +189,5 @@ module.exports = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// perform: async (z, bundle) => {
|
|
||||||
// try {
|
|
||||||
// z.console.log("Starting New Log Trace for add_document")
|
|
||||||
// z.console.log("=======================================")
|
|
||||||
//
|
|
||||||
// // Prepare base metadata
|
|
||||||
// const baseMetadata = {
|
|
||||||
// service: bundle.inputData.metadata_service || 'Zapier',
|
|
||||||
// source: bundle.inputData.metadata_source,
|
|
||||||
// unique_id: bundle.inputData.metadata_unique_id,
|
|
||||||
// unique_url: bundle.inputData.metadata_unique_url,
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// // If there's additional metadata, merge it with the base metadata
|
|
||||||
// if (bundle.inputData.additional_metadata) {
|
|
||||||
// Object.assign(baseMetadata, bundle.inputData.additional_metadata);
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const requestData = {
|
|
||||||
//
|
|
||||||
// catalog_id: bundle.inputData.catalog_id,
|
|
||||||
// language: bundle.inputData.language,
|
|
||||||
//
|
|
||||||
// // Add optional fields if they exist
|
|
||||||
// name: bundle.inputData.name || undefined,
|
|
||||||
// user_context: bundle.inputData.user_context || undefined,
|
|
||||||
// valid_from: bundle.inputData.valid_from || undefined,
|
|
||||||
// user_metadata: JSON.stringify(baseMetadata),
|
|
||||||
// catalog_properties: JSON.stringify(bundle.inputData.catalog_properties) || undefined,
|
|
||||||
// file: z.stashFile(bundle.inputData.file),
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Make request to your API
|
|
||||||
// const response = await z.request({
|
|
||||||
// url: 'https://evie.askeveai.com/api/api/v1/documents/add_document',
|
|
||||||
// method: 'POST',
|
|
||||||
// body: requestData,
|
|
||||||
// headers: {
|
|
||||||
// 'Authorization': `Bearer ${bundle.authData.access_token}`,
|
|
||||||
// 'Content-Type': 'multipart/form-data',
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // Log the response for debugging
|
|
||||||
// z.console.log('API Response:', {
|
|
||||||
// status: response.status,
|
|
||||||
// body: response.data
|
|
||||||
// });
|
|
||||||
// // Return the parsed response
|
|
||||||
// return response.json;
|
|
||||||
// } catch (error) {
|
|
||||||
// // Enhanced error logging
|
|
||||||
// z.console.error('Error details:', {
|
|
||||||
// message: error.message,
|
|
||||||
// response: error.response ? {
|
|
||||||
// status: error.response.status,
|
|
||||||
// headers: error.response.headers,
|
|
||||||
// data: error.response.data
|
|
||||||
// } : 'No response',
|
|
||||||
// request: error.request ? {
|
|
||||||
// method: error.request.method,
|
|
||||||
// url: error.request.url,
|
|
||||||
// headers: error.request.headers
|
|
||||||
// } : 'No request'
|
|
||||||
// });
|
|
||||||
// throw error;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const EveAIApiClient = require('../api_client');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
display: {
|
||||||
|
description: "Refresh an existing document in Evie's Library with new content",
|
||||||
|
hidden: false,
|
||||||
|
label: 'Refresh Document in Evie',
|
||||||
|
},
|
||||||
|
key: 'refresh_document',
|
||||||
|
noun: 'Document',
|
||||||
|
operation: {
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'file',
|
||||||
|
label: 'The Updated File',
|
||||||
|
type: 'file',
|
||||||
|
helpText: "The new content to replace the existing document",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'language',
|
||||||
|
label: 'Document Language',
|
||||||
|
type: 'string',
|
||||||
|
default: 'en',
|
||||||
|
helpText: 'Two-letter-code of the language the document is written in.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'user_context',
|
||||||
|
label: 'User Context',
|
||||||
|
type: 'text',
|
||||||
|
helpText:
|
||||||
|
'Contextual information you want to add to the Document. If you have structured information to be shared, you can better add this information to the User Metadata, which allows for json to be uploaded.',
|
||||||
|
required: false,
|
||||||
|
list: false,
|
||||||
|
altersDynamicFields: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metadata_service',
|
||||||
|
label: 'Service',
|
||||||
|
type: 'string',
|
||||||
|
default: 'Zapier',
|
||||||
|
helpText: "By default we use 'Zapier' as service name. However, if you need to change that to e.g. give an indication of the Zapier flow, you can change this value.",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metadata_source',
|
||||||
|
label: 'Source App',
|
||||||
|
type: 'string',
|
||||||
|
helpText: "The source app of the document's origin. e.g. 'Dropbox' if the document is provided through Dropbox, or 'Google Docs' if that happens to be the origin of the document.",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metadata_unique_id',
|
||||||
|
label: 'Unique ID',
|
||||||
|
type: 'string',
|
||||||
|
helpText: 'An unique identifier, provided by the source system, if that is available.',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'metadata_unique_url',
|
||||||
|
label: 'Unique URL',
|
||||||
|
type: 'string',
|
||||||
|
helpText: "A unique URL that is provided by the source system, if that's available",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'additional_metadata',
|
||||||
|
label: 'Additional Metadata',
|
||||||
|
helpText: "Extra metadata you'd like to add to the document",
|
||||||
|
dict: true,
|
||||||
|
required: false,
|
||||||
|
altersDynamicFields: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'catalog_properties',
|
||||||
|
label: 'Catalog Properties',
|
||||||
|
helpText:
|
||||||
|
'Depending on the Catalog ID provided, you can add the required key-value pairs here.',
|
||||||
|
dict: true,
|
||||||
|
required: false,
|
||||||
|
altersDynamicFields: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perform: async (z, bundle) => {
|
||||||
|
try {
|
||||||
|
z.console.log("Starting New Log Trace for refresh_document");
|
||||||
|
z.console.log("===========================================");
|
||||||
|
|
||||||
|
const client = new EveAIApiClient(z, bundle);
|
||||||
|
|
||||||
|
// Prepare base metadata
|
||||||
|
const baseMetadata = {
|
||||||
|
service: bundle.inputData.metadata_service || 'Zapier',
|
||||||
|
source: bundle.inputData.metadata_source || '',
|
||||||
|
unique_id: bundle.inputData.metadata_unique_id || '',
|
||||||
|
unique_url: bundle.inputData.metadata_unique_url || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// If there's additional metadata, merge it
|
||||||
|
if (bundle.inputData.additional_metadata) {
|
||||||
|
Object.assign(baseMetadata, bundle.inputData.additional_metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, lookup the document by unique_id
|
||||||
|
const lookupResponse = await client.make_request('POST', '/documents/lookup', {
|
||||||
|
lookup_criteria: { unique_id: bundle.inputData.metadata_unique_id },
|
||||||
|
metadata_type: 'user_metadata'
|
||||||
|
});
|
||||||
|
|
||||||
|
const documentId = lookupResponse.document_id;
|
||||||
|
z.console.log("Found Document ID: ", documentId)
|
||||||
|
|
||||||
|
// Get the temporary URL from Zapier's file storage
|
||||||
|
const tempFileUrl = await z.stashFile(bundle.inputData.file);
|
||||||
|
z.console.log('Temporary URL created:', tempFileUrl);
|
||||||
|
|
||||||
|
// Prepare the refresh request
|
||||||
|
const requestData = {
|
||||||
|
temp_url: tempFileUrl,
|
||||||
|
language: bundle.inputData.language,
|
||||||
|
user_metadata: JSON.stringify(baseMetadata),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user_context property if it exists
|
||||||
|
if (bundle.inputData.user_context) {
|
||||||
|
requestData.user_context = bundle.inputData.user_context;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add catalog properties if they exist
|
||||||
|
if (bundle.inputData.catalog_properties) {
|
||||||
|
requestData.catalog_properties = JSON.stringify(bundle.inputData.catalog_properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make the refresh request
|
||||||
|
return await client.make_request(
|
||||||
|
'POST',
|
||||||
|
`/documents/${documentId}/refresh_through_url`,
|
||||||
|
requestData
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
z.console.error('Error details:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response ? {
|
||||||
|
status: error.response.status,
|
||||||
|
headers: error.response.headers,
|
||||||
|
data: error.response.data
|
||||||
|
} : 'No response',
|
||||||
|
request: error.request ? {
|
||||||
|
method: error.request.method,
|
||||||
|
url: error.request.url,
|
||||||
|
headers: error.request.headers
|
||||||
|
} : 'No request'
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const authentication = require('./authentication');
|
const authentication = require('./authentication');
|
||||||
const addDocument = require('./creates/add_document');
|
const addDocument = require('./creates/add_document');
|
||||||
|
const refreshDocument = require('./creates/refresh_document'); // Add this line
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// This is just shorthand to reference the installed dependencies you have.
|
// This is just shorthand to reference the installed dependencies you have.
|
||||||
@@ -18,7 +19,8 @@ module.exports = {
|
|||||||
|
|
||||||
// If you want your creates to show up, you better include it here!
|
// If you want your creates to show up, you better include it here!
|
||||||
creates: {
|
creates: {
|
||||||
[addDocument.key]: addDocument
|
[addDocument.key]: addDocument,
|
||||||
|
[refreshDocument.key]: refreshDocument,
|
||||||
},
|
},
|
||||||
|
|
||||||
resources: {},
|
resources: {},
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "eveai_integration",
|
"name": "eveai_integration",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest --testTimeout 10000"
|
"test": "jest --testTimeout 10000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zapier-platform-core": "15.19.0"
|
"zapier-platform-core": "16.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.6.0"
|
"jest": "^29.6.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user