From 282fa0e3f83f7c6fc8c84ca95f8f4ced4ed34b78 Mon Sep 17 00:00:00 2001 From: Stan Girard Date: Wed, 18 Sep 2024 12:30:48 +0200 Subject: [PATCH] feat(assistants): mock api (#3195) # Description Please include a summary of the changes and the related issue. Please also include relevant motivation and context. ## Checklist before requesting a review Please delete options that are not relevant. - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented hard-to-understand areas - [ ] I have ideally added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged ## Screenshots (if appropriate): --- .gitignore | 1 + backend/api/pyproject.toml | 1 - backend/api/quivr_api/logger.py | 2 +- .../middlewares/auth/jwt_token_handler.py | 1 + .../models/brains_subscription_invitations.py | 1 + .../analytics/controller/analytics_routes.py | 1 + .../modules/analytics/entity/analytics.py | 8 +- .../analytics/service/analytics_service.py | 1 - .../api_key/controller/api_key_routes.py | 1 + .../api_key/service/api_key_service.py | 1 + .../modules/assistant/controller/__init__.py | 5 + .../assistant/controller/assistant_routes.py | 195 +++++++++++++---- .../controller/assistants_definition.py | 201 ++++++++++++++++++ .../quivr_api/modules/assistant/dto/inputs.py | 21 +- .../modules/assistant/dto/outputs.py | 26 +-- .../modules/assistant/entity/__init__.py | 1 - .../modules/assistant/entity/assistant.py | 11 - .../assistant/entity/assistant_entity.py | 33 +++ .../modules/assistant/entity/task_entity.py | 34 +++ .../modules/assistant/ito/crawler.py | 73 ------- .../modules/assistant/ito/difference.py | 171 --------------- .../quivr_api/modules/assistant/ito/ito.py | 196 ----------------- .../modules/assistant/ito/summary.py | 196 ----------------- .../modules/assistant/ito/utils/logo.png | Bin 24005 -> 0 bytes .../modules/assistant/repository/__init__.py | 1 - .../repository/assistant_interface.py | 16 -- .../interfaces}/__init__.py | 0 .../repository/interfaces/task_interface.py | 32 +++ .../modules/assistant/repository/tasks.py | 82 +++++++ .../modules/assistant/service/assistant.py | 32 --- .../{ito/utils => services}/__init__.py | 0 .../assistant/services/tasks_service.py | 32 +++ .../modules/brain/controller/__init__.py | 4 + .../api/quivr_api/modules/brain/dto/inputs.py | 1 + .../modules/brain/integrations/Big/Brain.py | 1 + .../brain/integrations/Claude/Brain.py | 1 + .../modules/brain/integrations/GPT4/Brain.py | 1 + .../modules/brain/integrations/Proxy/Brain.py | 1 + .../modules/brain/integrations/SQL/Brain.py | 2 +- .../modules/brain/integrations/Self/Brain.py | 5 +- .../brain/repository/interfaces/__init__.py | 6 +- .../integration_brains_interface.py | 1 - .../service/brain_authorization_service.py | 3 +- .../brain/service/brain_user_service.py | 1 + .../service/utils/format_chat_history.py | 1 + .../service/utils/get_prompt_to_use_id.py | 4 +- .../brain/service/utils/validate_brain.py | 2 - .../api/quivr_api/modules/chat/dto/chats.py | 1 + .../api/quivr_api/modules/chat/entity/chat.py | 8 +- .../modules/knowledge/controller/__init__.py | 2 +- .../modules/knowledge/repository/storage.py | 2 + .../modules/models/controller/model_routes.py | 1 + .../modules/notification/dto/__init__.py | 2 +- .../prompt/controller/prompt_routes.py | 1 + .../modules/prompt/entity/__init__.py | 8 +- .../sync/controller/azure_sync_routes.py | 1 + .../sync/controller/github_sync_routes.py | 1 + .../sync/controller/successfull_connection.py | 2 +- .../modules/sync/entity/notion_page.py | 1 + .../sync/repository/sync_repository.py | 18 +- .../api/quivr_api/modules/tools/__init__.py | 4 +- .../api/quivr_api/modules/tools/url_reader.py | 2 +- .../api/quivr_api/modules/tools/web_search.py | 1 + .../service/generate_file_signed_url.py | 7 +- .../modules/upload/service/list_files.py | 3 +- .../api/quivr_api/modules/user/dto/inputs.py | 1 - .../modules/user/service/__init__.py | 2 +- .../quivr_api/modules/vector/entity/vector.py | 3 +- .../quivr_api/routes/subscription_routes.py | 3 +- .../utils/handle_request_validation_error.py | 1 + backend/api/tests/settings/test_settings.py | 30 ++- backend/core/examples/pdf_parsing_tika.py | 7 +- backend/core/quivr_core/chat.py | 3 +- backend/core/quivr_core/chat_llm.py | 2 +- .../implementations/megaparse_processor.py | 13 +- backend/core/quivr_core/utils.py | 9 +- backend/core/tests/fixture_chunks.py | 1 - .../community/test_markdown_processor.py | 1 - .../core/tests/processor/docx/test_docx.py | 1 - .../processor/epub/test_epub_processor.py | 1 - backend/core/tests/processor/odt/test_odt.py | 1 - .../pdf/test_unstructured_pdf_processor.py | 1 - .../processor/test_default_implementations.py | 2 - .../processor/test_simple_txt_processor.py | 1 - .../tests/processor/test_tika_processor.py | 1 - backend/core/tests/test_chat_history.py | 1 - backend/core/tests/test_chat_llm.py | 1 - backend/core/tests/test_llm_endpoint.py | 1 - backend/core/tests/test_utils.py | 1 - backend/requirements-dev.lock | 7 +- backend/requirements.lock | 7 +- .../migrations/20240911145305_gtasks.sql | 79 +++++++ .../migrations/20240918094405_assistants.sql | 11 + backend/worker/pyproject.toml | 3 +- .../quivr_worker/assistants}/__init__.py | 0 .../quivr_worker/assistants/assistants.py | 40 ++++ backend/worker/quivr_worker/celery_worker.py | 66 +++++- backend/worker/quivr_worker/files.py | 2 +- backend/worker/quivr_worker/utils/__init__.py | 0 .../utils/pdf_generator/__init__.py | 0 .../font/DejaVuSansCondensed-Bold.ttf | Bin .../font/DejaVuSansCondensed-Oblique.ttf | Bin .../font/DejaVuSansCondensed.ttf | Bin .../quivr_worker/utils/pdf_generator/logo.png | Bin 0 -> 66854 bytes .../utils/pdf_generator}/pdf_generator.py | 22 +- .../worker/quivr_worker/{ => utils}/utils.py | 0 backend/worker/tests/conftest.py | 1 - backend/worker/tests/test_process_url_task.py | 2 +- backend/worker/tests/test_utils.py | 3 +- 109 files changed, 923 insertions(+), 883 deletions(-) create mode 100644 backend/api/quivr_api/modules/assistant/controller/assistants_definition.py delete mode 100644 backend/api/quivr_api/modules/assistant/entity/assistant.py create mode 100644 backend/api/quivr_api/modules/assistant/entity/assistant_entity.py create mode 100644 backend/api/quivr_api/modules/assistant/entity/task_entity.py delete mode 100644 backend/api/quivr_api/modules/assistant/ito/crawler.py delete mode 100644 backend/api/quivr_api/modules/assistant/ito/difference.py delete mode 100644 backend/api/quivr_api/modules/assistant/ito/ito.py delete mode 100644 backend/api/quivr_api/modules/assistant/ito/summary.py delete mode 100644 backend/api/quivr_api/modules/assistant/ito/utils/logo.png delete mode 100644 backend/api/quivr_api/modules/assistant/repository/assistant_interface.py rename backend/api/quivr_api/modules/assistant/{ito => repository/interfaces}/__init__.py (100%) create mode 100644 backend/api/quivr_api/modules/assistant/repository/interfaces/task_interface.py create mode 100644 backend/api/quivr_api/modules/assistant/repository/tasks.py delete mode 100644 backend/api/quivr_api/modules/assistant/service/assistant.py rename backend/api/quivr_api/modules/assistant/{ito/utils => services}/__init__.py (100%) create mode 100644 backend/api/quivr_api/modules/assistant/services/tasks_service.py create mode 100644 backend/supabase/migrations/20240911145305_gtasks.sql create mode 100644 backend/supabase/migrations/20240918094405_assistants.sql rename backend/{api/quivr_api/modules/assistant/service => worker/quivr_worker/assistants}/__init__.py (100%) create mode 100644 backend/worker/quivr_worker/assistants/assistants.py create mode 100644 backend/worker/quivr_worker/utils/__init__.py create mode 100644 backend/worker/quivr_worker/utils/pdf_generator/__init__.py rename backend/{api/quivr_api/modules/assistant/ito/utils => worker/quivr_worker/utils/pdf_generator}/font/DejaVuSansCondensed-Bold.ttf (100%) rename backend/{api/quivr_api/modules/assistant/ito/utils => worker/quivr_worker/utils/pdf_generator}/font/DejaVuSansCondensed-Oblique.ttf (100%) rename backend/{api/quivr_api/modules/assistant/ito/utils => worker/quivr_worker/utils/pdf_generator}/font/DejaVuSansCondensed.ttf (100%) create mode 100644 backend/worker/quivr_worker/utils/pdf_generator/logo.png rename backend/{api/quivr_api/modules/assistant/ito/utils => worker/quivr_worker/utils/pdf_generator}/pdf_generator.py (62%) rename backend/worker/quivr_worker/{ => utils}/utils.py (100%) diff --git a/.gitignore b/.gitignore index 112174048..c034793f4 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ backend/core/examples/chatbot/.chainlit/translations/en-US.json # Tox .tox Pipfile +*.pkl diff --git a/backend/api/pyproject.toml b/backend/api/pyproject.toml index 62ce71e6f..f8873a15a 100644 --- a/backend/api/pyproject.toml +++ b/backend/api/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ "pydantic-settings>=2.4.0", "python-dotenv>=1.0.1", "unidecode>=1.3.8", - "fpdf>=1.7.2", "colorlog>=6.8.2", "posthog>=3.5.0", "pyinstrument>=4.7.2", diff --git a/backend/api/quivr_api/logger.py b/backend/api/quivr_api/logger.py index 0fc69cfce..b839e9aef 100644 --- a/backend/api/quivr_api/logger.py +++ b/backend/api/quivr_api/logger.py @@ -4,7 +4,7 @@ from logging.handlers import RotatingFileHandler from colorlog import ( ColoredFormatter, -) # You need to install this package: pip install colorlog +) def get_logger(logger_name, log_file="application.log"): diff --git a/backend/api/quivr_api/middlewares/auth/jwt_token_handler.py b/backend/api/quivr_api/middlewares/auth/jwt_token_handler.py index 4e412b41d..e6438d2ac 100644 --- a/backend/api/quivr_api/middlewares/auth/jwt_token_handler.py +++ b/backend/api/quivr_api/middlewares/auth/jwt_token_handler.py @@ -4,6 +4,7 @@ from typing import Optional from jose import jwt from jose.exceptions import JWTError + from quivr_api.modules.user.entity.user_identity import UserIdentity SECRET_KEY = os.environ.get("JWT_SECRET_KEY") diff --git a/backend/api/quivr_api/models/brains_subscription_invitations.py b/backend/api/quivr_api/models/brains_subscription_invitations.py index bbe474c22..fc0ddf048 100644 --- a/backend/api/quivr_api/models/brains_subscription_invitations.py +++ b/backend/api/quivr_api/models/brains_subscription_invitations.py @@ -1,6 +1,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict + from quivr_api.logger import get_logger logger = get_logger(__name__) diff --git a/backend/api/quivr_api/modules/analytics/controller/analytics_routes.py b/backend/api/quivr_api/modules/analytics/controller/analytics_routes.py index dfb889b38..4dc924032 100644 --- a/backend/api/quivr_api/modules/analytics/controller/analytics_routes.py +++ b/backend/api/quivr_api/modules/analytics/controller/analytics_routes.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query + from quivr_api.middlewares.auth.auth_bearer import AuthBearer, get_current_user from quivr_api.modules.analytics.entity.analytics import Range from quivr_api.modules.analytics.service.analytics_service import AnalyticsService diff --git a/backend/api/quivr_api/modules/analytics/entity/analytics.py b/backend/api/quivr_api/modules/analytics/entity/analytics.py index 4b3424b02..6d8dced41 100644 --- a/backend/api/quivr_api/modules/analytics/entity/analytics.py +++ b/backend/api/quivr_api/modules/analytics/entity/analytics.py @@ -1,16 +1,20 @@ +from datetime import date from enum import IntEnum from typing import List + from pydantic import BaseModel -from datetime import date + class Range(IntEnum): WEEK = 7 MONTH = 30 QUARTER = 90 + class Usage(BaseModel): date: date usage_count: int + class BrainsUsages(BaseModel): - usages: List[Usage] \ No newline at end of file + usages: List[Usage] diff --git a/backend/api/quivr_api/modules/analytics/service/analytics_service.py b/backend/api/quivr_api/modules/analytics/service/analytics_service.py index c7a60eaf3..34a90f325 100644 --- a/backend/api/quivr_api/modules/analytics/service/analytics_service.py +++ b/backend/api/quivr_api/modules/analytics/service/analytics_service.py @@ -11,5 +11,4 @@ class AnalyticsService: self.repository = Analytics() def get_brains_usages(self, user_id, graph_range, brain_id=None): - return self.repository.get_brains_usages(user_id, graph_range, brain_id) diff --git a/backend/api/quivr_api/modules/api_key/controller/api_key_routes.py b/backend/api/quivr_api/modules/api_key/controller/api_key_routes.py index dedf9f704..7215ae76d 100644 --- a/backend/api/quivr_api/modules/api_key/controller/api_key_routes.py +++ b/backend/api/quivr_api/modules/api_key/controller/api_key_routes.py @@ -3,6 +3,7 @@ from typing import List from uuid import uuid4 from fastapi import APIRouter, Depends + from quivr_api.logger import get_logger from quivr_api.middlewares.auth import AuthBearer, get_current_user from quivr_api.modules.api_key.dto.outputs import ApiKeyInfo diff --git a/backend/api/quivr_api/modules/api_key/service/api_key_service.py b/backend/api/quivr_api/modules/api_key/service/api_key_service.py index f459eff38..9290a51e4 100644 --- a/backend/api/quivr_api/modules/api_key/service/api_key_service.py +++ b/backend/api/quivr_api/modules/api_key/service/api_key_service.py @@ -1,6 +1,7 @@ from datetime import datetime from fastapi import HTTPException + from quivr_api.logger import get_logger from quivr_api.modules.api_key.repository.api_key_interface import ApiKeysInterface from quivr_api.modules.api_key.repository.api_keys import ApiKeys diff --git a/backend/api/quivr_api/modules/assistant/controller/__init__.py b/backend/api/quivr_api/modules/assistant/controller/__init__.py index 64ec4d89f..cc8eb3907 100644 --- a/backend/api/quivr_api/modules/assistant/controller/__init__.py +++ b/backend/api/quivr_api/modules/assistant/controller/__init__.py @@ -1 +1,6 @@ +# noqa: from .assistant_routes import assistant_router + +__all__ = [ + "assistant_router", +] diff --git a/backend/api/quivr_api/modules/assistant/controller/assistant_routes.py b/backend/api/quivr_api/modules/assistant/controller/assistant_routes.py index 3f5605a14..9d2e303bb 100644 --- a/backend/api/quivr_api/modules/assistant/controller/assistant_routes.py +++ b/backend/api/quivr_api/modules/assistant/controller/assistant_routes.py @@ -1,63 +1,176 @@ -from typing import List +import io +from typing import Annotated, List +from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile + +from quivr_api.celery_config import celery from quivr_api.logger import get_logger -from quivr_api.middlewares.auth import AuthBearer, get_current_user -from quivr_api.modules.assistant.dto.inputs import InputAssistant +from quivr_api.middlewares.auth.auth_bearer import AuthBearer, get_current_user +from quivr_api.modules.assistant.controller.assistants_definition import ( + assistants, + validate_assistant_input, +) +from quivr_api.modules.assistant.dto.inputs import CreateTask, InputAssistant from quivr_api.modules.assistant.dto.outputs import AssistantOutput -from quivr_api.modules.assistant.ito.difference import DifferenceAssistant -from quivr_api.modules.assistant.ito.summary import SummaryAssistant, summary_inputs -from quivr_api.modules.assistant.service.assistant import Assistant +from quivr_api.modules.assistant.entity.assistant_entity import ( + AssistantSettings, +) +from quivr_api.modules.assistant.services.tasks_service import TasksService +from quivr_api.modules.dependencies import get_service +from quivr_api.modules.upload.service.upload_file import ( + upload_file_storage, +) from quivr_api.modules.user.entity.user_identity import UserIdentity -assistant_router = APIRouter() logger = get_logger(__name__) -assistant_service = Assistant() + +assistant_router = APIRouter() + + +TasksServiceDep = Annotated[TasksService, Depends(get_service(TasksService))] +UserIdentityDep = Annotated[UserIdentity, Depends(get_current_user)] @assistant_router.get( "/assistants", dependencies=[Depends(AuthBearer())], tags=["Assistant"] ) -async def list_assistants( +async def get_assistants( + request: Request, current_user: UserIdentity = Depends(get_current_user), ) -> List[AssistantOutput]: - """ - Retrieve and list all the knowledge in a brain. - """ + logger.info("Getting assistants") - summary = summary_inputs() - # difference = difference_inputs() - # crawler = crawler_inputs() - return [summary] + return assistants + + +@assistant_router.get( + "/assistants/tasks", dependencies=[Depends(AuthBearer())], tags=["Assistant"] +) +async def get_tasks( + request: Request, + current_user: UserIdentityDep, + tasks_service: TasksServiceDep, +): + logger.info("Getting tasks") + return await tasks_service.get_tasks_by_user_id(current_user.id) @assistant_router.post( - "/assistant/process", + "/assistants/task", dependencies=[Depends(AuthBearer())], tags=["Assistant"] +) +async def create_task( + current_user: UserIdentityDep, + tasks_service: TasksServiceDep, + request: Request, + input: InputAssistant, + files: List[UploadFile] = None, +): + assistant = next( + (assistant for assistant in assistants if assistant.id == input.id), None + ) + if assistant is None: + raise HTTPException(status_code=404, detail="Assistant not found") + + is_valid, validation_errors = validate_assistant_input(input, assistant) + if not is_valid: + for error in validation_errors: + print(error) + raise HTTPException(status_code=400, detail=error) + else: + print("Assistant input is valid.") + notification_uuid = uuid4() + + # Process files dynamically + for upload_file in files: + file_name_path = f"{input.id}/{notification_uuid}/{upload_file.filename}" + buff_reader = io.BufferedReader(upload_file.file) # type: ignore + try: + await upload_file_storage(buff_reader, file_name_path) + except Exception as e: + logger.exception(f"Exception in upload_route {e}") + raise HTTPException( + status_code=500, detail=f"Failed to upload file to storage. {e}" + ) + + task = CreateTask( + assistant_id=input.id, + pretty_id=str(notification_uuid), + settings=input.model_dump(mode="json"), + ) + + task_created = await tasks_service.create_task(task, current_user.id) + + celery.send_task( + "process_assistant_task", + kwargs={ + "assistant_id": input.id, + "notification_uuid": notification_uuid, + "task_id": task_created.id, + "user_id": str(current_user.id), + }, + ) + return task_created + + +@assistant_router.get( + "/assistants/task/{task_id}", dependencies=[Depends(AuthBearer())], tags=["Assistant"], ) -async def process_assistant( - input: InputAssistant, - files: List[UploadFile] = None, - current_user: UserIdentity = Depends(get_current_user), +async def get_task( + request: Request, + task_id: str, + current_user: UserIdentityDep, + tasks_service: TasksServiceDep, ): - if input.name.lower() == "summary": - summary_assistant = SummaryAssistant( - input=input, files=files, current_user=current_user - ) - try: - summary_assistant.check_input() - return await summary_assistant.process_assistant() - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - elif input.name.lower() == "difference": - difference_assistant = DifferenceAssistant( - input=input, files=files, current_user=current_user - ) - try: - difference_assistant.check_input() - return await difference_assistant.process_assistant() - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - return {"message": "Assistant not found"} + return await tasks_service.get_task_by_id(task_id, current_user.id) # type: ignore + + +@assistant_router.delete( + "/assistants/task/{task_id}", + dependencies=[Depends(AuthBearer())], + tags=["Assistant"], +) +async def delete_task( + request: Request, + task_id: int, + current_user: UserIdentityDep, + tasks_service: TasksServiceDep, +): + return await tasks_service.delete_task(task_id, current_user.id) + + +@assistant_router.get( + "/assistants/task/{task_id}/download", + dependencies=[Depends(AuthBearer())], + tags=["Assistant"], +) +async def get_download_link_task( + request: Request, + task_id: int, + current_user: UserIdentityDep, + tasks_service: TasksServiceDep, +): + return await tasks_service.get_download_link_task(task_id, current_user.id) + + +@assistant_router.get( + "/assistants/{assistant_id}/config", + dependencies=[Depends(AuthBearer())], + tags=["Assistant"], + response_model=AssistantSettings, + summary="Retrieve assistant configuration", + description="Get the settings and file requirements for the specified assistant.", +) +async def get_assistant_config( + assistant_id: int, + current_user: UserIdentityDep, +): + assistant = next( + (assistant for assistant in assistants if assistant.id == assistant_id), None + ) + if assistant is None: + raise HTTPException(status_code=404, detail="Assistant not found") + return assistant.settings diff --git a/backend/api/quivr_api/modules/assistant/controller/assistants_definition.py b/backend/api/quivr_api/modules/assistant/controller/assistants_definition.py new file mode 100644 index 000000000..0ade87168 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/controller/assistants_definition.py @@ -0,0 +1,201 @@ +from quivr_api.modules.assistant.dto.inputs import InputAssistant +from quivr_api.modules.assistant.dto.outputs import ( + AssistantOutput, + InputFile, + Inputs, + Pricing, +) + + +def validate_assistant_input( + assistant_input: InputAssistant, assistant_output: AssistantOutput +): + errors = [] + + # Validate files + if assistant_output.inputs.files: + required_files = [ + file for file in assistant_output.inputs.files if file.required + ] + input_files = { + file_input.key for file_input in (assistant_input.inputs.files or []) + } + for req_file in required_files: + if req_file.key not in input_files: + errors.append(f"Missing required file input: {req_file.key}") + + # Validate URLs + if assistant_output.inputs.urls: + required_urls = [url for url in assistant_output.inputs.urls if url.required] + input_urls = { + url_input.key for url_input in (assistant_input.inputs.urls or []) + } + for req_url in required_urls: + if req_url.key not in input_urls: + errors.append(f"Missing required URL input: {req_url.key}") + + # Validate texts + if assistant_output.inputs.texts: + required_texts = [ + text for text in assistant_output.inputs.texts if text.required + ] + input_texts = { + text_input.key for text_input in (assistant_input.inputs.texts or []) + } + for req_text in required_texts: + if req_text.key not in input_texts: + errors.append(f"Missing required text input: {req_text.key}") + else: + # Validate regex if applicable + req_text_val = next( + (t for t in assistant_output.inputs.texts if t.key == req_text.key), + None, + ) + if req_text_val and req_text_val.validation_regex: + import re + + input_value = next( + ( + t.value + for t in assistant_input.inputs.texts + if t.key == req_text.key + ), + "", + ) + if not re.match(req_text_val.validation_regex, input_value): + errors.append( + f"Text input '{req_text.key}' does not match the required format." + ) + + # Validate booleans + if assistant_output.inputs.booleans: + required_booleans = [b for b in assistant_output.inputs.booleans if b.required] + input_booleans = { + b_input.key for b_input in (assistant_input.inputs.booleans or []) + } + for req_bool in required_booleans: + if req_bool.key not in input_booleans: + errors.append(f"Missing required boolean input: {req_bool.key}") + + # Validate numbers + if assistant_output.inputs.numbers: + required_numbers = [n for n in assistant_output.inputs.numbers if n.required] + input_numbers = { + n_input.key for n_input in (assistant_input.inputs.numbers or []) + } + for req_number in required_numbers: + if req_number.key not in input_numbers: + errors.append(f"Missing required number input: {req_number.key}") + else: + # Validate min and max + input_value = next( + ( + n.value + for n in assistant_input.inputs.numbers + if n.key == req_number.key + ), + None, + ) + if req_number.min is not None and input_value < req_number.min: + errors.append( + f"Number input '{req_number.key}' is below minimum value." + ) + if req_number.max is not None and input_value > req_number.max: + errors.append( + f"Number input '{req_number.key}' exceeds maximum value." + ) + + # Validate select_texts + if assistant_output.inputs.select_texts: + required_select_texts = [ + st for st in assistant_output.inputs.select_texts if st.required + ] + input_select_texts = { + st_input.key for st_input in (assistant_input.inputs.select_texts or []) + } + for req_select in required_select_texts: + if req_select.key not in input_select_texts: + errors.append(f"Missing required select text input: {req_select.key}") + else: + input_value = next( + ( + st.value + for st in assistant_input.inputs.select_texts + if st.key == req_select.key + ), + None, + ) + if input_value not in req_select.options: + errors.append(f"Invalid option for select text '{req_select.key}'.") + + # Validate select_numbers + if assistant_output.inputs.select_numbers: + required_select_numbers = [ + sn for sn in assistant_output.inputs.select_numbers if sn.required + ] + input_select_numbers = { + sn_input.key for sn_input in (assistant_input.inputs.select_numbers or []) + } + for req_select in required_select_numbers: + if req_select.key not in input_select_numbers: + errors.append(f"Missing required select number input: {req_select.key}") + else: + input_value = next( + ( + sn.value + for sn in assistant_input.inputs.select_numbers + if sn.key == req_select.key + ), + None, + ) + if input_value not in req_select.options: + errors.append( + f"Invalid option for select number '{req_select.key}'." + ) + + # Validate brain input + if assistant_output.inputs.brain and assistant_output.inputs.brain.required: + if not assistant_input.inputs.brain or not assistant_input.inputs.brain.value: + errors.append("Missing required brain input.") + + if errors: + return False, errors + else: + return True, None + + +assistant1 = AssistantOutput( + id=1, + name="Assistant 1", + description="Assistant 1 description", + pricing=Pricing(), + tags=["tag1", "tag2"], + input_description="Input description", + output_description="Output description", + inputs=Inputs( + files=[ + InputFile(key="file_1", description="File description"), + InputFile(key="file_2", description="File description"), + ], + ), + icon_url="https://example.com/icon.png", +) + +assistant2 = AssistantOutput( + id=2, + name="Assistant 2", + description="Assistant 2 description", + pricing=Pricing(), + tags=["tag1", "tag2"], + input_description="Input description", + output_description="Output description", + icon_url="https://example.com/icon.png", + inputs=Inputs( + files=[ + InputFile(key="file_1", description="File description"), + InputFile(key="file_2", description="File description"), + ], + ), +) + +assistants = [assistant1, assistant2] diff --git a/backend/api/quivr_api/modules/assistant/dto/inputs.py b/backend/api/quivr_api/modules/assistant/dto/inputs.py index 631f3e4fe..929f95535 100644 --- a/backend/api/quivr_api/modules/assistant/dto/inputs.py +++ b/backend/api/quivr_api/modules/assistant/dto/inputs.py @@ -1,16 +1,16 @@ -import json from typing import List, Optional from uuid import UUID -from pydantic import BaseModel, model_validator, root_validator +from pydantic import BaseModel, root_validator -class EmailInput(BaseModel): - activated: bool +class CreateTask(BaseModel): + pretty_id: str + assistant_id: int + settings: dict class BrainInput(BaseModel): - activated: Optional[bool] = False value: Optional[UUID] = None @root_validator(pre=True) @@ -64,19 +64,10 @@ class Inputs(BaseModel): numbers: Optional[List[InputNumber]] = None select_texts: Optional[List[InputSelectText]] = None select_numbers: Optional[List[InputSelectNumber]] = None - - -class Outputs(BaseModel): - email: Optional[EmailInput] = None brain: Optional[BrainInput] = None class InputAssistant(BaseModel): + id: int name: str inputs: Inputs - outputs: Outputs - - @model_validator(mode="before") - @classmethod - def to_py_dict(cls, data): - return json.loads(data) diff --git a/backend/api/quivr_api/modules/assistant/dto/outputs.py b/backend/api/quivr_api/modules/assistant/dto/outputs.py index cf6398ad8..40574e5bf 100644 --- a/backend/api/quivr_api/modules/assistant/dto/outputs.py +++ b/backend/api/quivr_api/modules/assistant/dto/outputs.py @@ -3,6 +3,12 @@ from typing import List, Optional from pydantic import BaseModel +class BrainInput(BaseModel): + required: Optional[bool] = True + description: str + type: str + + class InputFile(BaseModel): key: str allowed_extensions: Optional[List[str]] = ["pdf"] @@ -63,23 +69,7 @@ class Inputs(BaseModel): numbers: Optional[List[InputNumber]] = None select_texts: Optional[List[InputSelectText]] = None select_numbers: Optional[List[InputSelectNumber]] = None - - -class OutputEmail(BaseModel): - required: Optional[bool] = True - description: str - type: str - - -class OutputBrain(BaseModel): - required: Optional[bool] = True - description: str - type: str - - -class Outputs(BaseModel): - email: Optional[OutputEmail] = None - brain: Optional[OutputBrain] = None + brain: Optional[BrainInput] = None class Pricing(BaseModel): @@ -88,6 +78,7 @@ class Pricing(BaseModel): class AssistantOutput(BaseModel): + id: int name: str description: str pricing: Optional[Pricing] = Pricing() @@ -95,5 +86,4 @@ class AssistantOutput(BaseModel): input_description: str output_description: str inputs: Inputs - outputs: Outputs icon_url: Optional[str] = None diff --git a/backend/api/quivr_api/modules/assistant/entity/__init__.py b/backend/api/quivr_api/modules/assistant/entity/__init__.py index b848b1efc..e69de29bb 100644 --- a/backend/api/quivr_api/modules/assistant/entity/__init__.py +++ b/backend/api/quivr_api/modules/assistant/entity/__init__.py @@ -1 +0,0 @@ -from .assistant import AssistantEntity diff --git a/backend/api/quivr_api/modules/assistant/entity/assistant.py b/backend/api/quivr_api/modules/assistant/entity/assistant.py deleted file mode 100644 index 00bcac691..000000000 --- a/backend/api/quivr_api/modules/assistant/entity/assistant.py +++ /dev/null @@ -1,11 +0,0 @@ -from uuid import UUID - -from pydantic import BaseModel - - -class AssistantEntity(BaseModel): - id: UUID - name: str - brain_id_required: bool - file_1_required: bool - url_required: bool diff --git a/backend/api/quivr_api/modules/assistant/entity/assistant_entity.py b/backend/api/quivr_api/modules/assistant/entity/assistant_entity.py new file mode 100644 index 000000000..6321ff1f4 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/entity/assistant_entity.py @@ -0,0 +1,33 @@ +from typing import Any, List, Optional + +from pydantic import BaseModel + + +class AssistantFileRequirement(BaseModel): + name: str + description: Optional[str] = None + required: bool = True + accepted_types: Optional[List[str]] = None # e.g., ['text/csv', 'application/json'] + + +class AssistantInput(BaseModel): + name: str + description: str + type: str # e.g., 'boolean', 'uuid', 'string' + required: bool = True + regex: Optional[str] = None + options: Optional[List[Any]] = None # For predefined choices + default: Optional[Any] = None + + +class AssistantSettings(BaseModel): + inputs: List[AssistantInput] + files: Optional[List[AssistantFileRequirement]] = None + + +class Assistant(BaseModel): + id: int + name: str + description: str + settings: AssistantSettings + required_files: Optional[List[str]] = None # List of required file names diff --git a/backend/api/quivr_api/modules/assistant/entity/task_entity.py b/backend/api/quivr_api/modules/assistant/entity/task_entity.py new file mode 100644 index 000000000..01d5f33b2 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/entity/task_entity.py @@ -0,0 +1,34 @@ +from datetime import datetime +from typing import Dict +from uuid import UUID + +from sqlmodel import JSON, TIMESTAMP, BigInteger, Column, Field, SQLModel, text + + +class Task(SQLModel, table=True): + __tablename__ = "tasks" # type: ignore + + id: int | None = Field( + default=None, + sa_column=Column( + BigInteger, + primary_key=True, + autoincrement=True, + ), + ) + assistant_id: int + pretty_id: str + user_id: UUID + status: str = Field(default="pending") + creation_time: datetime | None = Field( + default=None, + sa_column=Column( + TIMESTAMP(timezone=False), + server_default=text("CURRENT_TIMESTAMP"), + ), + ) + settings: Dict = Field(default_factory=dict, sa_column=Column(JSON)) + answer: str | None = Field(default=None) + + class Config: + arbitrary_types_allowed = True diff --git a/backend/api/quivr_api/modules/assistant/ito/crawler.py b/backend/api/quivr_api/modules/assistant/ito/crawler.py deleted file mode 100644 index c944c0895..000000000 --- a/backend/api/quivr_api/modules/assistant/ito/crawler.py +++ /dev/null @@ -1,73 +0,0 @@ -from bs4 import BeautifulSoup as Soup -from langchain_community.document_loaders.recursive_url_loader import RecursiveUrlLoader -from quivr_api.logger import get_logger -from quivr_api.modules.assistant.dto.outputs import ( - AssistantOutput, - Inputs, - InputUrl, - OutputBrain, - OutputEmail, - Outputs, -) -from quivr_api.modules.assistant.ito.ito import ITO - -logger = get_logger(__name__) - - -class CrawlerAssistant(ITO): - - def __init__( - self, - **kwargs, - ): - super().__init__( - **kwargs, - ) - - async def process_assistant(self): - - url = self.url - loader = RecursiveUrlLoader( - url=url, max_depth=2, extractor=lambda x: Soup(x, "html.parser").text - ) - docs = loader.load() - - nice_url = url.split("://")[1].replace("/", "_").replace(".", "_") - nice_url += ".txt" - - for docs in docs: - await self.create_and_upload_processed_file( - docs.page_content, nice_url, "Crawler" - ) - - -def crawler_inputs(): - output = AssistantOutput( - name="Crawler", - description="Crawls a website and extracts the text from the pages", - tags=["new"], - input_description="One URL to crawl", - output_description="Text extracted from the pages", - inputs=Inputs( - urls=[ - InputUrl( - key="url", - required=True, - description="The URL to crawl", - ) - ], - ), - outputs=Outputs( - brain=OutputBrain( - required=True, - description="The brain to which upload the document", - type="uuid", - ), - email=OutputEmail( - required=True, - description="Send the document by email", - type="str", - ), - ), - ) - return output diff --git a/backend/api/quivr_api/modules/assistant/ito/difference.py b/backend/api/quivr_api/modules/assistant/ito/difference.py deleted file mode 100644 index fd19d0648..000000000 --- a/backend/api/quivr_api/modules/assistant/ito/difference.py +++ /dev/null @@ -1,171 +0,0 @@ -import os -import tempfile -from typing import List - -from fastapi import UploadFile -from langchain.prompts import HumanMessagePromptTemplate, SystemMessagePromptTemplate -from langchain_community.chat_models import ChatLiteLLM -from langchain_core.output_parsers import StrOutputParser -from langchain_core.prompts import ChatPromptTemplate, PromptTemplate -from llama_parse import LlamaParse -from quivr_api.logger import get_logger -from quivr_api.modules.assistant.dto.inputs import InputAssistant -from quivr_api.modules.assistant.dto.outputs import ( - AssistantOutput, - InputFile, - Inputs, - OutputBrain, - OutputEmail, - Outputs, -) -from quivr_api.modules.assistant.ito.ito import ITO -from quivr_api.modules.user.entity.user_identity import UserIdentity - -logger = get_logger(__name__) - - -class DifferenceAssistant(ITO): - - def __init__( - self, - input: InputAssistant, - files: List[UploadFile] = None, - current_user: UserIdentity = None, - **kwargs, - ): - super().__init__( - input=input, - files=files, - current_user=current_user, - **kwargs, - ) - - def check_input(self): - if not self.files: - raise ValueError("No file was uploaded") - if len(self.files) != 2: - raise ValueError("Only two files can be uploaded") - if not self.input.inputs.files: - raise ValueError("No files key were given in the input") - if len(self.input.inputs.files) != 2: - raise ValueError("Only two files can be uploaded") - if not self.input.inputs.files[0].key == "doc_1": - raise ValueError("The key of the first file should be doc_1") - if not self.input.inputs.files[1].key == "doc_2": - raise ValueError("The key of the second file should be doc_2") - if not self.input.inputs.files[0].value: - raise ValueError("No file was uploaded") - if not ( - self.input.outputs.brain.activated or self.input.outputs.email.activated - ): - raise ValueError("No output was selected") - return True - - async def process_assistant(self): - - document_1 = self.files[0] - document_2 = self.files[1] - - # Get the file extensions - document_1_ext = os.path.splitext(document_1.filename)[1] - document_2_ext = os.path.splitext(document_2.filename)[1] - - # Create temporary files with the same extension as the original files - document_1_tmp = tempfile.NamedTemporaryFile( - suffix=document_1_ext, delete=False - ) - document_2_tmp = tempfile.NamedTemporaryFile( - suffix=document_2_ext, delete=False - ) - - document_1_tmp.write(document_1.file.read()) - document_2_tmp.write(document_2.file.read()) - - parser = LlamaParse( - result_type="markdown" # "markdown" and "text" are available - ) - - document_1_llama_parsed = parser.load_data(document_1_tmp.name) - document_2_llama_parsed = parser.load_data(document_2_tmp.name) - - document_1_tmp.close() - document_2_tmp.close() - - document_1_to_langchain = document_1_llama_parsed[0].to_langchain_format() - document_2_to_langchain = document_2_llama_parsed[0].to_langchain_format() - - llm = ChatLiteLLM(model="gpt-4o") - - human_prompt = """Given the following two documents, find the difference between them: - - Document 1: - {document_1} - Document 2: - {document_2} - Difference: - """ - CONDENSE_QUESTION_PROMPT = PromptTemplate.from_template(human_prompt) - - system_message_template = """ - You are an expert in finding the difference between two documents. You look deeply into what makes the two documents different and provide a detailed analysis if needed of the differences between the two documents. - If no differences are found, simply say that there are no differences. - """ - - ANSWER_PROMPT = ChatPromptTemplate.from_messages( - [ - SystemMessagePromptTemplate.from_template(system_message_template), - HumanMessagePromptTemplate.from_template(human_prompt), - ] - ) - - final_inputs = { - "document_1": document_1_to_langchain.page_content, - "document_2": document_2_to_langchain.page_content, - } - - output_parser = StrOutputParser() - - chain = ANSWER_PROMPT | llm | output_parser - result = chain.invoke(final_inputs) - - return result - - -def difference_inputs(): - output = AssistantOutput( - name="difference", - description="Finds the difference between two sets of documents", - tags=["new"], - input_description="Two documents to compare", - output_description="The difference between the two documents", - icon_url="https://quivr-cms.s3.eu-west-3.amazonaws.com/report_94bea8b918.png", - inputs=Inputs( - files=[ - InputFile( - key="doc_1", - allowed_extensions=["pdf"], - required=True, - description="The first document to compare", - ), - InputFile( - key="doc_2", - allowed_extensions=["pdf"], - required=True, - description="The second document to compare", - ), - ] - ), - outputs=Outputs( - brain=OutputBrain( - required=True, - description="The brain to which upload the document", - type="uuid", - ), - email=OutputEmail( - required=True, - description="Send the document by email", - type="str", - ), - ), - ) - return output diff --git a/backend/api/quivr_api/modules/assistant/ito/ito.py b/backend/api/quivr_api/modules/assistant/ito/ito.py deleted file mode 100644 index 39d4d02f6..000000000 --- a/backend/api/quivr_api/modules/assistant/ito/ito.py +++ /dev/null @@ -1,196 +0,0 @@ -import os -import random -import re -import string -from abc import abstractmethod -from io import BytesIO -from tempfile import NamedTemporaryFile -from typing import List, Optional - -from fastapi import UploadFile -from pydantic import BaseModel -from unidecode import unidecode - -from quivr_api.logger import get_logger -from quivr_api.models.settings import SendEmailSettings -from quivr_api.modules.assistant.dto.inputs import InputAssistant -from quivr_api.modules.assistant.ito.utils.pdf_generator import PDFGenerator, PDFModel -from quivr_api.modules.chat.controller.chat.utils import update_user_usage -from quivr_api.modules.upload.controller.upload_routes import upload_file -from quivr_api.modules.user.entity.user_identity import UserIdentity -from quivr_api.modules.user.service.user_usage import UserUsage -from quivr_api.utils.send_email import send_email - -logger = get_logger(__name__) - - -class ITO(BaseModel): - input: InputAssistant - files: List[UploadFile] - current_user: UserIdentity - user_usage: Optional[UserUsage] = None - user_settings: Optional[dict] = None - - def __init__( - self, - input: InputAssistant, - files: List[UploadFile] = None, - current_user: UserIdentity = None, - **kwargs, - ): - super().__init__( - input=input, - files=files, - current_user=current_user, - **kwargs, - ) - self.user_usage = UserUsage( - id=current_user.id, - email=current_user.email, - ) - self.user_settings = self.user_usage.get_user_settings() - self.increase_usage_user() - - def increase_usage_user(self): - # Raises an error if the user has consumed all of of his credits - - update_user_usage( - usage=self.user_usage, - user_settings=self.user_settings, - cost=self.calculate_pricing(), - ) - - def calculate_pricing(self): - return 20 - - def generate_pdf(self, filename: str, title: str, content: str): - pdf_model = PDFModel(title=title, content=content) - pdf = PDFGenerator(pdf_model) - pdf.print_pdf() - pdf.output(filename, "F") - - @abstractmethod - async def process_assistant(self): - pass - - async def send_output_by_email( - self, - file: UploadFile, - filename: str, - task_name: str, - custom_message: str, - brain_id: str = None, - ): - settings = SendEmailSettings() - file = await self.uploadfile_to_file(file) - domain_quivr = os.getenv("QUIVR_DOMAIN", "https://chat.quivr.app/") - - with open(file.name, "rb") as f: - mail_from = settings.resend_contact_sales_from - mail_to = self.current_user.email - body = f""" -
- Quivr Logo - -

Quivr's ingestion process has been completed. The processed file is attached.

- -

Task: {task_name}

- -

Output: {custom_message}

-
- - -
- """ - if brain_id: - body += f"
You can find the file here.

" - body += """ -
-

Please let us know if you have any questions or need further assistance.

- -

The Quivr Team

-
- """ - params = { - "from": mail_from, - "to": [mail_to], - "subject": "Quivr Ingestion Processed", - "reply_to": "no-reply@quivr.app", - "html": body, - "attachments": [{"filename": filename, "content": list(f.read())}], - } - logger.info(f"Sending email to {mail_to} with file {filename}") - send_email(params) - - async def uploadfile_to_file(self, uploadFile: UploadFile): - # Transform the UploadFile object to a file object with same name and content - tmp_file = NamedTemporaryFile(delete=False) - tmp_file.write(uploadFile.file.read()) - tmp_file.flush() # Make sure all data is written to disk - return tmp_file - - async def create_and_upload_processed_file( - self, processed_content: str, original_filename: str, file_description: str - ) -> dict: - """Handles creation and uploading of the processed file.""" - # remove any special characters from the filename that aren't http safe - - new_filename = ( - original_filename.split(".")[0] - + "_" - + file_description.lower().replace(" ", "_") - + "_" - + str(random.randint(1000, 9999)) - + ".pdf" - ) - new_filename = unidecode(new_filename) - new_filename = re.sub( - "[^{}0-9a-zA-Z]".format(re.escape(string.punctuation)), "", new_filename - ) - - self.generate_pdf( - new_filename, - f"{file_description} of {original_filename}", - processed_content, - ) - - content_io = BytesIO() - with open(new_filename, "rb") as f: - content_io.write(f.read()) - content_io.seek(0) - - file_to_upload = UploadFile( - filename=new_filename, - file=content_io, - headers={"content-type": "application/pdf"}, - ) - - if self.input.outputs.email.activated: - await self.send_output_by_email( - file_to_upload, - new_filename, - "Summary", - f"{file_description} of {original_filename}", - brain_id=( - self.input.outputs.brain.value - if ( - self.input.outputs.brain.activated - and self.input.outputs.brain.value - ) - else None - ), - ) - - # Reset to start of file before upload - file_to_upload.file.seek(0) - if self.input.outputs.brain.activated: - await upload_file( - uploadFile=file_to_upload, - brain_id=self.input.outputs.brain.value, - current_user=self.current_user, - chat_id=None, - ) - - os.remove(new_filename) - - return {"message": f"{file_description} generated successfully"} diff --git a/backend/api/quivr_api/modules/assistant/ito/summary.py b/backend/api/quivr_api/modules/assistant/ito/summary.py deleted file mode 100644 index 5ef1ef356..000000000 --- a/backend/api/quivr_api/modules/assistant/ito/summary.py +++ /dev/null @@ -1,196 +0,0 @@ -import tempfile -from typing import List - -from fastapi import UploadFile -from langchain.chains import ( - MapReduceDocumentsChain, - ReduceDocumentsChain, - StuffDocumentsChain, -) -from langchain.chains.llm import LLMChain -from langchain_community.chat_models import ChatLiteLLM -from langchain_community.document_loaders import UnstructuredPDFLoader -from langchain_core.prompts import PromptTemplate -from langchain_text_splitters import CharacterTextSplitter -from quivr_api.logger import get_logger -from quivr_api.modules.assistant.dto.inputs import InputAssistant -from quivr_api.modules.assistant.dto.outputs import ( - AssistantOutput, - InputFile, - Inputs, - OutputBrain, - OutputEmail, - Outputs, -) -from quivr_api.modules.assistant.ito.ito import ITO -from quivr_api.modules.user.entity.user_identity import UserIdentity - -logger = get_logger(__name__) - - -class SummaryAssistant(ITO): - - def __init__( - self, - input: InputAssistant, - files: List[UploadFile] = None, - current_user: UserIdentity = None, - **kwargs, - ): - super().__init__( - input=input, - files=files, - current_user=current_user, - **kwargs, - ) - - def check_input(self): - if not self.files: - raise ValueError("No file was uploaded") - if len(self.files) > 1: - raise ValueError("Only one file can be uploaded") - if not self.input.inputs.files: - raise ValueError("No files key were given in the input") - if len(self.input.inputs.files) > 1: - raise ValueError("Only one file can be uploaded") - if not self.input.inputs.files[0].key == "doc_to_summarize": - raise ValueError("The key of the file should be doc_to_summarize") - if not self.input.inputs.files[0].value: - raise ValueError("No file was uploaded") - # Check if name of file is same as the key - if not self.input.inputs.files[0].value == self.files[0].filename: - raise ValueError( - "The key of the file should be the same as the name of the file" - ) - if not ( - self.input.outputs.brain.activated or self.input.outputs.email.activated - ): - raise ValueError("No output was selected") - return True - - async def process_assistant(self): - - try: - self.increase_usage_user() - except Exception as e: - logger.error(f"Error increasing usage: {e}") - return {"error": str(e)} - - # Create a temporary file with the uploaded file as a temporary file and then pass it to the loader - tmp_file = tempfile.NamedTemporaryFile(delete=False) - - # Write the file to the temporary file - tmp_file.write(self.files[0].file.read()) - - # Now pass the path of the temporary file to the loader - - loader = UnstructuredPDFLoader(tmp_file.name) - - tmp_file.close() - - data = loader.load() - - llm = ChatLiteLLM(model="gpt-4o", max_tokens=2000) - - map_template = """The following is a document that has been divided into multiple sections: - {docs} - - Please carefully analyze each section and identify the following: - - 1. Main Themes: What are the overarching ideas or topics in this section? - 2. Key Points: What are the most important facts, arguments, or ideas presented in this section? - 3. Important Information: Are there any crucial details that stand out? This could include data, quotes, specific events, entity, or other relevant information. - 4. People: Who are the key individuals mentioned in this section? What roles do they play? - 5. Reasoning: What logic or arguments are used to support the key points? - 6. Chapters: If the document is divided into chapters, what is the main focus of each chapter? - - Remember to consider the language and context of the document. This will help in understanding the nuances and subtleties of the text.""" - map_prompt = PromptTemplate.from_template(map_template) - map_chain = LLMChain(llm=llm, prompt=map_prompt) - - # Reduce - reduce_template = """The following is a set of summaries for parts of the document: - {docs} - Take these and distill it into a final, consolidated summary of the document. Make sure to include the main themes, key points, and important information such as data, quotes,people and specific events. - Use markdown such as bold, italics, underlined. For example, **bold**, *italics*, and _underlined_ to highlight key points. - Please provide the final summary with sections using bold headers. - Sections should always be Summary and Key Points, but feel free to add more sections as needed. - Always use bold text for the sections headers. - Keep the same language as the documents. - Answer:""" - reduce_prompt = PromptTemplate.from_template(reduce_template) - - # Run chain - reduce_chain = LLMChain(llm=llm, prompt=reduce_prompt) - - # Takes a list of documents, combines them into a single string, and passes this to an LLMChain - combine_documents_chain = StuffDocumentsChain( - llm_chain=reduce_chain, document_variable_name="docs" - ) - - # Combines and iteratively reduces the mapped documents - reduce_documents_chain = ReduceDocumentsChain( - # This is final chain that is called. - combine_documents_chain=combine_documents_chain, - # If documents exceed context for `StuffDocumentsChain` - collapse_documents_chain=combine_documents_chain, - # The maximum number of tokens to group documents into. - token_max=4000, - ) - - # Combining documents by mapping a chain over them, then combining results - map_reduce_chain = MapReduceDocumentsChain( - # Map chain - llm_chain=map_chain, - # Reduce chain - reduce_documents_chain=reduce_documents_chain, - # The variable name in the llm_chain to put the documents in - document_variable_name="docs", - # Return the results of the map steps in the output - return_intermediate_steps=False, - ) - - text_splitter = CharacterTextSplitter.from_tiktoken_encoder( - chunk_size=1000, chunk_overlap=100 - ) - split_docs = text_splitter.split_documents(data) - - content = map_reduce_chain.run(split_docs) - - return await self.create_and_upload_processed_file( - content, self.files[0].filename, "Summary" - ) - - -def summary_inputs(): - output = AssistantOutput( - name="Summary", - description="Summarize a set of documents", - tags=["new"], - input_description="One document to summarize", - output_description="A summary of the document with key points and main themes", - icon_url="https://quivr-cms.s3.eu-west-3.amazonaws.com/report_94bea8b918.png", - inputs=Inputs( - files=[ - InputFile( - key="doc_to_summarize", - allowed_extensions=["pdf"], - required=True, - description="The document to summarize", - ) - ] - ), - outputs=Outputs( - brain=OutputBrain( - required=True, - description="The brain to which upload the document", - type="uuid", - ), - email=OutputEmail( - required=True, - description="Send the document by email", - type="str", - ), - ), - ) - return output diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/logo.png b/backend/api/quivr_api/modules/assistant/ito/utils/logo.png deleted file mode 100644 index 5e5672d8c2f7137dd5c2f126d4c8354fe2649428..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24005 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 List[AssistantEntity]: - """ - Get all the knowledge in a brain - Args: - brain_id (UUID): The id of the brain - """ - pass diff --git a/backend/api/quivr_api/modules/assistant/ito/__init__.py b/backend/api/quivr_api/modules/assistant/repository/interfaces/__init__.py similarity index 100% rename from backend/api/quivr_api/modules/assistant/ito/__init__.py rename to backend/api/quivr_api/modules/assistant/repository/interfaces/__init__.py diff --git a/backend/api/quivr_api/modules/assistant/repository/interfaces/task_interface.py b/backend/api/quivr_api/modules/assistant/repository/interfaces/task_interface.py new file mode 100644 index 000000000..74f2046c6 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/repository/interfaces/task_interface.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from typing import List +from uuid import UUID + +from quivr_api.modules.assistant.dto.inputs import CreateTask +from quivr_api.modules.assistant.entity.task_entity import Task + + +class TasksInterface(ABC): + @abstractmethod + def create_task(self, task: CreateTask) -> Task: + pass + + @abstractmethod + def get_task_by_id(self, task_id: UUID, user_id: UUID) -> Task: + pass + + @abstractmethod + def delete_task(self, task_id: UUID, user_id: UUID) -> None: + pass + + @abstractmethod + def get_tasks_by_user_id(self, user_id: UUID) -> List[Task]: + pass + + @abstractmethod + def update_task(self, task_id: int, task: dict) -> None: + pass + + @abstractmethod + def get_download_link_task(self, task_id: int, user_id: UUID) -> str: + pass diff --git a/backend/api/quivr_api/modules/assistant/repository/tasks.py b/backend/api/quivr_api/modules/assistant/repository/tasks.py new file mode 100644 index 000000000..7977a2f56 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/repository/tasks.py @@ -0,0 +1,82 @@ +from typing import Sequence +from uuid import UUID + +from sqlalchemy import exc +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import select + +from quivr_api.modules.assistant.dto.inputs import CreateTask +from quivr_api.modules.assistant.entity.task_entity import Task +from quivr_api.modules.dependencies import BaseRepository +from quivr_api.modules.upload.service.generate_file_signed_url import ( + generate_file_signed_url, +) + + +class TasksRepository(BaseRepository): + def __init__(self, session: AsyncSession): + super().__init__(session) + + async def create_task(self, task: CreateTask, user_id: UUID) -> Task: + try: + task_to_create = Task( + assistant_id=task.assistant_id, + pretty_id=task.pretty_id, + user_id=user_id, + settings=task.settings, + ) + self.session.add(task_to_create) + await self.session.commit() + except exc.IntegrityError: + await self.session.rollback() + raise Exception() + + await self.session.refresh(task_to_create) + return task_to_create + + async def get_task_by_id(self, task_id: UUID, user_id: UUID) -> Task: + query = select(Task).where(Task.id == task_id, Task.user_id == user_id) + response = await self.session.exec(query) + return response.one() + + async def get_tasks_by_user_id(self, user_id: UUID) -> Sequence[Task]: + query = select(Task).where(Task.user_id == user_id) + response = await self.session.exec(query) + return response.all() + + async def delete_task(self, task_id: int, user_id: UUID) -> None: + query = select(Task).where(Task.id == task_id, Task.user_id == user_id) + response = await self.session.exec(query) + task = response.one() + if task: + await self.session.delete(task) + await self.session.commit() + else: + raise Exception() + + async def update_task(self, task_id: int, task_updates: dict) -> None: + query = select(Task).where(Task.id == task_id) + response = await self.session.exec(query) + task = response.one() + if task: + for key, value in task_updates.items(): + setattr(task, key, value) + await self.session.commit() + else: + raise Exception("Task not found") + + async def get_download_link_task(self, task_id: int, user_id: UUID) -> str: + query = select(Task).where(Task.id == task_id, Task.user_id == user_id) + response = await self.session.exec(query) + task = response.one() + + path = f"{task.assistant_id}/{task.pretty_id}/output.pdf" + + try: + signed_url = generate_file_signed_url(path) + if signed_url and "signedURL" in signed_url: + return signed_url["signedURL"] + else: + raise Exception() + except Exception: + return "error" diff --git a/backend/api/quivr_api/modules/assistant/service/assistant.py b/backend/api/quivr_api/modules/assistant/service/assistant.py deleted file mode 100644 index e4c013d6f..000000000 --- a/backend/api/quivr_api/modules/assistant/service/assistant.py +++ /dev/null @@ -1,32 +0,0 @@ -from quivr_api.modules.assistant.entity.assistant import AssistantEntity -from quivr_api.modules.assistant.repository.assistant_interface import ( - AssistantInterface, -) -from quivr_api.modules.dependencies import get_supabase_client - - -class Assistant(AssistantInterface): - def __init__(self): - supabase_client = get_supabase_client() - self.db = supabase_client - - def get_all_assistants(self): - response = self.db.from_("assistants").select("*").execute() - - if response.data: - return response.data - - return [] - - def get_assistant_by_id(self, ingestion_id) -> AssistantEntity: - response = ( - self.db.from_("assistants") - .select("*") - .filter("id", "eq", ingestion_id) - .execute() - ) - - if response.data: - return AssistantEntity(**response.data[0]) - - return None diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/__init__.py b/backend/api/quivr_api/modules/assistant/services/__init__.py similarity index 100% rename from backend/api/quivr_api/modules/assistant/ito/utils/__init__.py rename to backend/api/quivr_api/modules/assistant/services/__init__.py diff --git a/backend/api/quivr_api/modules/assistant/services/tasks_service.py b/backend/api/quivr_api/modules/assistant/services/tasks_service.py new file mode 100644 index 000000000..e7df1f3a6 --- /dev/null +++ b/backend/api/quivr_api/modules/assistant/services/tasks_service.py @@ -0,0 +1,32 @@ +from typing import Sequence +from uuid import UUID + +from quivr_api.modules.assistant.dto.inputs import CreateTask +from quivr_api.modules.assistant.entity.task_entity import Task +from quivr_api.modules.assistant.repository.tasks import TasksRepository +from quivr_api.modules.dependencies import BaseService + + +class TasksService(BaseService[TasksRepository]): + repository_cls = TasksRepository + + def __init__(self, repository: TasksRepository): + self.repository = repository + + async def create_task(self, task: CreateTask, user_id: UUID) -> Task: + return await self.repository.create_task(task, user_id) + + async def get_task_by_id(self, task_id: UUID, user_id: UUID) -> Task: + return await self.repository.get_task_by_id(task_id, user_id) + + async def get_tasks_by_user_id(self, user_id: UUID) -> Sequence[Task]: + return await self.repository.get_tasks_by_user_id(user_id) + + async def delete_task(self, task_id: int, user_id: UUID) -> None: + return await self.repository.delete_task(task_id, user_id) + + async def update_task(self, task_id: int, task: dict) -> None: + return await self.repository.update_task(task_id, task) + + async def get_download_link_task(self, task_id: int, user_id: UUID) -> str: + return await self.repository.get_download_link_task(task_id, user_id) diff --git a/backend/api/quivr_api/modules/brain/controller/__init__.py b/backend/api/quivr_api/modules/brain/controller/__init__.py index 7e54fbb96..98f5cd9dc 100644 --- a/backend/api/quivr_api/modules/brain/controller/__init__.py +++ b/backend/api/quivr_api/modules/brain/controller/__init__.py @@ -1 +1,5 @@ from .brain_routes import brain_router + +__all__ = [ + "brain_router", +] diff --git a/backend/api/quivr_api/modules/brain/dto/inputs.py b/backend/api/quivr_api/modules/brain/dto/inputs.py index 632cd9794..bbdbb1801 100644 --- a/backend/api/quivr_api/modules/brain/dto/inputs.py +++ b/backend/api/quivr_api/modules/brain/dto/inputs.py @@ -2,6 +2,7 @@ from typing import Optional from uuid import UUID from pydantic import BaseModel + from quivr_api.logger import get_logger from quivr_api.modules.brain.entity.brain_entity import BrainType from quivr_api.modules.brain.entity.integration_brain import IntegrationType diff --git a/backend/api/quivr_api/modules/brain/integrations/Big/Brain.py b/backend/api/quivr_api/modules/brain/integrations/Big/Brain.py index 0c7c61297..141f7de7c 100644 --- a/backend/api/quivr_api/modules/brain/integrations/Big/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/Big/Brain.py @@ -11,6 +11,7 @@ from langchain_core.prompts.chat import ( SystemMessagePromptTemplate, ) from langchain_core.prompts.prompt import PromptTemplate + from quivr_api.logger import get_logger from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.chat.dto.chats import ChatQuestion diff --git a/backend/api/quivr_api/modules/brain/integrations/Claude/Brain.py b/backend/api/quivr_api/modules/brain/integrations/Claude/Brain.py index 14cf00236..25732779e 100644 --- a/backend/api/quivr_api/modules/brain/integrations/Claude/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/Claude/Brain.py @@ -4,6 +4,7 @@ from uuid import UUID from langchain_community.chat_models import ChatLiteLLM from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.chat.dto.chats import ChatQuestion diff --git a/backend/api/quivr_api/modules/brain/integrations/GPT4/Brain.py b/backend/api/quivr_api/modules/brain/integrations/GPT4/Brain.py index f643de065..0083b48cc 100644 --- a/backend/api/quivr_api/modules/brain/integrations/GPT4/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/GPT4/Brain.py @@ -10,6 +10,7 @@ from langchain_core.tools import BaseTool from langchain_openai import ChatOpenAI from langgraph.graph import END, StateGraph from langgraph.prebuilt import ToolExecutor, ToolInvocation + from quivr_api.logger import get_logger from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.chat.dto.chats import ChatQuestion diff --git a/backend/api/quivr_api/modules/brain/integrations/Proxy/Brain.py b/backend/api/quivr_api/modules/brain/integrations/Proxy/Brain.py index 2816d6e57..4d5baa142 100644 --- a/backend/api/quivr_api/modules/brain/integrations/Proxy/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/Proxy/Brain.py @@ -4,6 +4,7 @@ from uuid import UUID from langchain_community.chat_models import ChatLiteLLM from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + from quivr_api.logger import get_logger from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.chat.dto.chats import ChatQuestion diff --git a/backend/api/quivr_api/modules/brain/integrations/SQL/Brain.py b/backend/api/quivr_api/modules/brain/integrations/SQL/Brain.py index 37509f44a..12a01d4fb 100644 --- a/backend/api/quivr_api/modules/brain/integrations/SQL/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/SQL/Brain.py @@ -7,6 +7,7 @@ from langchain_community.utilities import SQLDatabase from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate from langchain_core.runnables import RunnablePassthrough + from quivr_api.modules.brain.integrations.SQL.SQL_connector import SQLConnector from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.brain.repository.integration_brains import IntegrationBrain @@ -85,7 +86,6 @@ class SQLBrain(KnowledgeBrainQA, IntegrationBrain): async def generate_stream( self, chat_id: UUID, question: ChatQuestion, save_answer: bool = True ) -> AsyncIterable: - conversational_qa_chain = self.get_chain() transformed_history, streamed_chat_history = ( self.initialize_streamed_chat_history(chat_id, question) diff --git a/backend/api/quivr_api/modules/brain/integrations/Self/Brain.py b/backend/api/quivr_api/modules/brain/integrations/Self/Brain.py index 867447653..6a992f687 100644 --- a/backend/api/quivr_api/modules/brain/integrations/Self/Brain.py +++ b/backend/api/quivr_api/modules/brain/integrations/Self/Brain.py @@ -12,13 +12,14 @@ from langchain_core.pydantic_v1 import BaseModel as BaseModelV1 from langchain_core.pydantic_v1 import Field as FieldV1 from langchain_openai import ChatOpenAI from langgraph.graph import END, StateGraph +from typing_extensions import TypedDict + from quivr_api.logger import get_logger from quivr_api.modules.brain.knowledge_brain_qa import KnowledgeBrainQA from quivr_api.modules.chat.dto.chats import ChatQuestion from quivr_api.modules.chat.dto.outputs import GetChatHistoryOutput from quivr_api.modules.chat.service.chat_service import ChatService from quivr_api.modules.dependencies import get_service -from typing_extensions import TypedDict # Post-processing @@ -210,13 +211,11 @@ class SelfBrain(KnowledgeBrainQA): return question_rewriter def get_chain(self): - graph = self.create_graph() return graph def create_graph(self): - workflow = StateGraph(GraphState) # Define the nodes diff --git a/backend/api/quivr_api/modules/brain/repository/interfaces/__init__.py b/backend/api/quivr_api/modules/brain/repository/interfaces/__init__.py index aab7d31bb..15163d6c6 100644 --- a/backend/api/quivr_api/modules/brain/repository/interfaces/__init__.py +++ b/backend/api/quivr_api/modules/brain/repository/interfaces/__init__.py @@ -2,5 +2,7 @@ from .brains_interface import BrainsInterface from .brains_users_interface import BrainsUsersInterface from .brains_vectors_interface import BrainsVectorsInterface -from .integration_brains_interface import (IntegrationBrainInterface, - IntegrationDescriptionInterface) +from .integration_brains_interface import ( + IntegrationBrainInterface, + IntegrationDescriptionInterface, +) diff --git a/backend/api/quivr_api/modules/brain/repository/interfaces/integration_brains_interface.py b/backend/api/quivr_api/modules/brain/repository/interfaces/integration_brains_interface.py index 60e187488..8f6867514 100644 --- a/backend/api/quivr_api/modules/brain/repository/interfaces/integration_brains_interface.py +++ b/backend/api/quivr_api/modules/brain/repository/interfaces/integration_brains_interface.py @@ -38,7 +38,6 @@ class IntegrationBrainInterface(ABC): class IntegrationDescriptionInterface(ABC): - @abstractmethod def get_integration_description( self, integration_id: UUID diff --git a/backend/api/quivr_api/modules/brain/service/brain_authorization_service.py b/backend/api/quivr_api/modules/brain/service/brain_authorization_service.py index e9a69290e..9583c1239 100644 --- a/backend/api/quivr_api/modules/brain/service/brain_authorization_service.py +++ b/backend/api/quivr_api/modules/brain/service/brain_authorization_service.py @@ -2,6 +2,7 @@ from typing import List, Optional, Union from uuid import UUID from fastapi import Depends, HTTPException, status + from quivr_api.middlewares.auth.auth_bearer import get_current_user from quivr_api.modules.brain.entity.brain_entity import RoleEnum from quivr_api.modules.brain.service.brain_service import BrainService @@ -13,7 +14,7 @@ brain_service = BrainService() def has_brain_authorization( - required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner + required_roles: Optional[Union[RoleEnum, List[RoleEnum]]] = RoleEnum.Owner, ): """ Decorator to check if the user has the required role(s) for the brain diff --git a/backend/api/quivr_api/modules/brain/service/brain_user_service.py b/backend/api/quivr_api/modules/brain/service/brain_user_service.py index b1bf15038..031cfb8a3 100644 --- a/backend/api/quivr_api/modules/brain/service/brain_user_service.py +++ b/backend/api/quivr_api/modules/brain/service/brain_user_service.py @@ -2,6 +2,7 @@ from typing import List from uuid import UUID from fastapi import HTTPException + from quivr_api.logger import get_logger from quivr_api.modules.brain.entity.brain_entity import ( BrainEntity, diff --git a/backend/api/quivr_api/modules/brain/service/utils/format_chat_history.py b/backend/api/quivr_api/modules/brain/service/utils/format_chat_history.py index a66cfab5e..0b3d3c795 100644 --- a/backend/api/quivr_api/modules/brain/service/utils/format_chat_history.py +++ b/backend/api/quivr_api/modules/brain/service/utils/format_chat_history.py @@ -1,6 +1,7 @@ from typing import List, Tuple from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage + from quivr_api.modules.chat.dto.outputs import GetChatHistoryOutput diff --git a/backend/api/quivr_api/modules/brain/service/utils/get_prompt_to_use_id.py b/backend/api/quivr_api/modules/brain/service/utils/get_prompt_to_use_id.py index 51614b53e..e5d118bf0 100644 --- a/backend/api/quivr_api/modules/brain/service/utils/get_prompt_to_use_id.py +++ b/backend/api/quivr_api/modules/brain/service/utils/get_prompt_to_use_id.py @@ -15,5 +15,7 @@ def get_prompt_to_use_id( return ( prompt_id if prompt_id - else brain_service.get_brain_prompt_id(brain_id) if brain_id else None + else brain_service.get_brain_prompt_id(brain_id) + if brain_id + else None ) diff --git a/backend/api/quivr_api/modules/brain/service/utils/validate_brain.py b/backend/api/quivr_api/modules/brain/service/utils/validate_brain.py index 43ec8e025..e69de29bb 100644 --- a/backend/api/quivr_api/modules/brain/service/utils/validate_brain.py +++ b/backend/api/quivr_api/modules/brain/service/utils/validate_brain.py @@ -1,2 +0,0 @@ -from fastapi import HTTPException -from quivr_api.modules.brain.dto.inputs import CreateBrainProperties diff --git a/backend/api/quivr_api/modules/chat/dto/chats.py b/backend/api/quivr_api/modules/chat/dto/chats.py index e900602d1..a04a6dcc7 100644 --- a/backend/api/quivr_api/modules/chat/dto/chats.py +++ b/backend/api/quivr_api/modules/chat/dto/chats.py @@ -3,6 +3,7 @@ from typing import List, Optional, Tuple, Union from uuid import UUID from pydantic import BaseModel + from quivr_api.modules.chat.dto.outputs import GetChatHistoryOutput from quivr_api.modules.notification.entity.notification import Notification diff --git a/backend/api/quivr_api/modules/chat/entity/chat.py b/backend/api/quivr_api/modules/chat/entity/chat.py index f989f37f3..965b38da8 100644 --- a/backend/api/quivr_api/modules/chat/entity/chat.py +++ b/backend/api/quivr_api/modules/chat/entity/chat.py @@ -2,12 +2,12 @@ from datetime import datetime from typing import List from uuid import UUID +from sqlalchemy.ext.asyncio import AsyncAttrs +from sqlmodel import JSON, TIMESTAMP, Column, Field, Relationship, SQLModel, text +from sqlmodel import UUID as PGUUID + from quivr_api.modules.brain.entity.brain_entity import Brain from quivr_api.modules.user.entity.user_identity import User -from sqlalchemy.ext.asyncio import AsyncAttrs -from sqlmodel import JSON, TIMESTAMP -from sqlmodel import UUID as PGUUID -from sqlmodel import Column, Field, Relationship, SQLModel, text class Chat(SQLModel, table=True): diff --git a/backend/api/quivr_api/modules/knowledge/controller/__init__.py b/backend/api/quivr_api/modules/knowledge/controller/__init__.py index 23c692c11..911883cdc 100644 --- a/backend/api/quivr_api/modules/knowledge/controller/__init__.py +++ b/backend/api/quivr_api/modules/knowledge/controller/__init__.py @@ -1 +1 @@ -from .knowledge_routes import knowledge_router \ No newline at end of file +from .knowledge_routes import knowledge_router diff --git a/backend/api/quivr_api/modules/knowledge/repository/storage.py b/backend/api/quivr_api/modules/knowledge/repository/storage.py index 0e58e25d9..e53165e22 100644 --- a/backend/api/quivr_api/modules/knowledge/repository/storage.py +++ b/backend/api/quivr_api/modules/knowledge/repository/storage.py @@ -85,3 +85,5 @@ class SupabaseS3Storage(StorageInterface): return response except Exception as e: logger.error(e) + raise e + diff --git a/backend/api/quivr_api/modules/models/controller/model_routes.py b/backend/api/quivr_api/modules/models/controller/model_routes.py index a5370c90f..75b649a33 100644 --- a/backend/api/quivr_api/modules/models/controller/model_routes.py +++ b/backend/api/quivr_api/modules/models/controller/model_routes.py @@ -1,6 +1,7 @@ from typing import Annotated, List from fastapi import APIRouter, Depends + from quivr_api.logger import get_logger from quivr_api.middlewares.auth import AuthBearer, get_current_user from quivr_api.modules.dependencies import get_service diff --git a/backend/api/quivr_api/modules/notification/dto/__init__.py b/backend/api/quivr_api/modules/notification/dto/__init__.py index 726ac989c..2d81927d4 100644 --- a/backend/api/quivr_api/modules/notification/dto/__init__.py +++ b/backend/api/quivr_api/modules/notification/dto/__init__.py @@ -1 +1 @@ -from .inputs import NotificationUpdatableProperties \ No newline at end of file +from .inputs import NotificationUpdatableProperties diff --git a/backend/api/quivr_api/modules/prompt/controller/prompt_routes.py b/backend/api/quivr_api/modules/prompt/controller/prompt_routes.py index 3aa5b6c75..82e25a4bf 100644 --- a/backend/api/quivr_api/modules/prompt/controller/prompt_routes.py +++ b/backend/api/quivr_api/modules/prompt/controller/prompt_routes.py @@ -1,6 +1,7 @@ from uuid import UUID from fastapi import APIRouter, Depends + from quivr_api.middlewares.auth import AuthBearer from quivr_api.modules.prompt.entity.prompt import ( CreatePromptProperties, diff --git a/backend/api/quivr_api/modules/prompt/entity/__init__.py b/backend/api/quivr_api/modules/prompt/entity/__init__.py index f3437a0ca..324aeee09 100644 --- a/backend/api/quivr_api/modules/prompt/entity/__init__.py +++ b/backend/api/quivr_api/modules/prompt/entity/__init__.py @@ -1 +1,7 @@ -from .prompt import Prompt, PromptStatusEnum, CreatePromptProperties, PromptUpdatableProperties, DeletePromptResponse \ No newline at end of file +from .prompt import ( + CreatePromptProperties, + DeletePromptResponse, + Prompt, + PromptStatusEnum, + PromptUpdatableProperties, +) diff --git a/backend/api/quivr_api/modules/sync/controller/azure_sync_routes.py b/backend/api/quivr_api/modules/sync/controller/azure_sync_routes.py index c905fb5ba..2f40c140c 100644 --- a/backend/api/quivr_api/modules/sync/controller/azure_sync_routes.py +++ b/backend/api/quivr_api/modules/sync/controller/azure_sync_routes.py @@ -4,6 +4,7 @@ import requests from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from msal import ConfidentialClientApplication + from quivr_api.logger import get_logger from quivr_api.middlewares.auth import AuthBearer, get_current_user from quivr_api.modules.sync.dto.inputs import SyncsUserInput, SyncUserUpdateInput diff --git a/backend/api/quivr_api/modules/sync/controller/github_sync_routes.py b/backend/api/quivr_api/modules/sync/controller/github_sync_routes.py index ecc88a5b3..84599965c 100644 --- a/backend/api/quivr_api/modules/sync/controller/github_sync_routes.py +++ b/backend/api/quivr_api/modules/sync/controller/github_sync_routes.py @@ -3,6 +3,7 @@ import os import requests from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse + from quivr_api.logger import get_logger from quivr_api.middlewares.auth import AuthBearer, get_current_user from quivr_api.modules.sync.dto.inputs import SyncsUserInput, SyncUserUpdateInput diff --git a/backend/api/quivr_api/modules/sync/controller/successfull_connection.py b/backend/api/quivr_api/modules/sync/controller/successfull_connection.py index ffdb877e8..0e9f00852 100644 --- a/backend/api/quivr_api/modules/sync/controller/successfull_connection.py +++ b/backend/api/quivr_api/modules/sync/controller/successfull_connection.py @@ -50,4 +50,4 @@ successfullConnectionPage = """ -""" \ No newline at end of file +""" diff --git a/backend/api/quivr_api/modules/sync/entity/notion_page.py b/backend/api/quivr_api/modules/sync/entity/notion_page.py index f84f89fd2..7a42f1902 100644 --- a/backend/api/quivr_api/modules/sync/entity/notion_page.py +++ b/backend/api/quivr_api/modules/sync/entity/notion_page.py @@ -3,6 +3,7 @@ from typing import Any, List, Literal, Union from uuid import UUID from pydantic import BaseModel, ConfigDict, Field, field_validator + from quivr_api.modules.sync.entity.sync_models import NotionSyncFile diff --git a/backend/api/quivr_api/modules/sync/repository/sync_repository.py b/backend/api/quivr_api/modules/sync/repository/sync_repository.py index 59e013b22..998e71d7a 100644 --- a/backend/api/quivr_api/modules/sync/repository/sync_repository.py +++ b/backend/api/quivr_api/modules/sync/repository/sync_repository.py @@ -2,20 +2,20 @@ from datetime import datetime, timedelta from typing import List, Sequence from uuid import UUID -from quivr_api.logger import get_logger -from quivr_api.modules.dependencies import (BaseRepository, get_supabase_client) -from quivr_api.modules.notification.service.notification_service import \ - NotificationService -from quivr_api.modules.sync.dto.inputs import (SyncsActiveInput, - SyncsActiveUpdateInput) -from quivr_api.modules.sync.entity.sync_models import (NotionSyncFile, - SyncsActive) -from quivr_api.modules.sync.repository.sync_interfaces import SyncInterface from sqlalchemy import or_ from sqlalchemy.exc import IntegrityError from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession +from quivr_api.logger import get_logger +from quivr_api.modules.dependencies import BaseRepository, get_supabase_client +from quivr_api.modules.notification.service.notification_service import ( + NotificationService, +) +from quivr_api.modules.sync.dto.inputs import SyncsActiveInput, SyncsActiveUpdateInput +from quivr_api.modules.sync.entity.sync_models import NotionSyncFile, SyncsActive +from quivr_api.modules.sync.repository.sync_interfaces import SyncInterface + notification_service = NotificationService() logger = get_logger(__name__) diff --git a/backend/api/quivr_api/modules/tools/__init__.py b/backend/api/quivr_api/modules/tools/__init__.py index 753df27d5..adb3f9601 100644 --- a/backend/api/quivr_api/modules/tools/__init__.py +++ b/backend/api/quivr_api/modules/tools/__init__.py @@ -1,4 +1,4 @@ +from .email_sender import EmailSenderTool from .image_generator import ImageGeneratorTool -from .web_search import WebSearchTool from .url_reader import URLReaderTool -from .email_sender import EmailSenderTool \ No newline at end of file +from .web_search import WebSearchTool diff --git a/backend/api/quivr_api/modules/tools/url_reader.py b/backend/api/quivr_api/modules/tools/url_reader.py index 2b9e9c88a..e1b1086f8 100644 --- a/backend/api/quivr_api/modules/tools/url_reader.py +++ b/backend/api/quivr_api/modules/tools/url_reader.py @@ -11,6 +11,7 @@ from langchain.pydantic_v1 import Field as FieldV1 from langchain_community.document_loaders import PlaywrightURLLoader from langchain_core.tools import BaseTool from pydantic import BaseModel + from quivr_api.logger import get_logger logger = get_logger(__name__) @@ -29,7 +30,6 @@ class URLReaderTool(BaseTool): def _run( self, url: str, run_manager: Optional[CallbackManagerForToolRun] = None ) -> Dict: - loader = PlaywrightURLLoader(urls=[url], remove_selectors=["header", "footer"]) data = loader.load() diff --git a/backend/api/quivr_api/modules/tools/web_search.py b/backend/api/quivr_api/modules/tools/web_search.py index a7357069e..2c2d004bd 100644 --- a/backend/api/quivr_api/modules/tools/web_search.py +++ b/backend/api/quivr_api/modules/tools/web_search.py @@ -10,6 +10,7 @@ from langchain.pydantic_v1 import BaseModel as BaseModelV1 from langchain.pydantic_v1 import Field as FieldV1 from langchain_core.tools import BaseTool from pydantic import BaseModel + from quivr_api.logger import get_logger logger = get_logger(__name__) diff --git a/backend/api/quivr_api/modules/upload/service/generate_file_signed_url.py b/backend/api/quivr_api/modules/upload/service/generate_file_signed_url.py index 3a3cb6f56..089e509b9 100644 --- a/backend/api/quivr_api/modules/upload/service/generate_file_signed_url.py +++ b/backend/api/quivr_api/modules/upload/service/generate_file_signed_url.py @@ -1,8 +1,8 @@ +import os from multiprocessing import get_logger from quivr_api.modules.dependencies import get_supabase_client from supabase.client import Client -import os logger = get_logger() @@ -10,6 +10,7 @@ SIGNED_URL_EXPIRATION_PERIOD_IN_SECONDS = 3600 EXTERNAL_SUPABASE_URL = os.getenv("EXTERNAL_SUPABASE_URL", None) SUPABASE_URL = os.getenv("SUPABASE_URL", None) + def generate_file_signed_url(path): supabase_client: Client = get_supabase_client() @@ -25,7 +26,9 @@ def generate_file_signed_url(path): logger.info("RESPONSE SIGNED URL", response) # Replace in the response the supabase url by the external supabase url in the object signedURL if EXTERNAL_SUPABASE_URL and SUPABASE_URL: - response["signedURL"] = response["signedURL"].replace(SUPABASE_URL, EXTERNAL_SUPABASE_URL) + response["signedURL"] = response["signedURL"].replace( + SUPABASE_URL, EXTERNAL_SUPABASE_URL + ) return response except Exception as e: logger.error(e) diff --git a/backend/api/quivr_api/modules/upload/service/list_files.py b/backend/api/quivr_api/modules/upload/service/list_files.py index bf03756e9..b6a4abd8f 100644 --- a/backend/api/quivr_api/modules/upload/service/list_files.py +++ b/backend/api/quivr_api/modules/upload/service/list_files.py @@ -1,8 +1,7 @@ from multiprocessing import get_logger -from supabase.client import Client - from quivr_api.modules.dependencies import get_supabase_client +from supabase.client import Client logger = get_logger() diff --git a/backend/api/quivr_api/modules/user/dto/inputs.py b/backend/api/quivr_api/modules/user/dto/inputs.py index 78d837d08..348e99af6 100644 --- a/backend/api/quivr_api/modules/user/dto/inputs.py +++ b/backend/api/quivr_api/modules/user/dto/inputs.py @@ -10,4 +10,3 @@ class UserUpdatableProperties(BaseModel): onboarded: Optional[bool] = None company_size: Optional[str] = None usage_purpose: Optional[str] = None - diff --git a/backend/api/quivr_api/modules/user/service/__init__.py b/backend/api/quivr_api/modules/user/service/__init__.py index 19fe88af7..254962a48 100644 --- a/backend/api/quivr_api/modules/user/service/__init__.py +++ b/backend/api/quivr_api/modules/user/service/__init__.py @@ -1 +1 @@ -from .user_service import UserService \ No newline at end of file +from .user_service import UserService diff --git a/backend/api/quivr_api/modules/vector/entity/vector.py b/backend/api/quivr_api/modules/vector/entity/vector.py index a0d46baa4..b0583f640 100644 --- a/backend/api/quivr_api/modules/vector/entity/vector.py +++ b/backend/api/quivr_api/modules/vector/entity/vector.py @@ -3,12 +3,11 @@ from uuid import UUID from pgvector.sqlalchemy import Vector as PGVector from pydantic import BaseModel +from quivr_api.models.settings import settings from sqlalchemy import Column from sqlmodel import JSON, Column, Field, SQLModel, text from sqlmodel import UUID as PGUUID -from quivr_api.models.settings import settings - class Vector(SQLModel, table=True): __tablename__ = "vectors" # type: ignore diff --git a/backend/api/quivr_api/routes/subscription_routes.py b/backend/api/quivr_api/routes/subscription_routes.py index 9cb0ff662..cecf227c3 100644 --- a/backend/api/quivr_api/routes/subscription_routes.py +++ b/backend/api/quivr_api/routes/subscription_routes.py @@ -3,6 +3,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel + from quivr_api.logger import get_logger from quivr_api.middlewares.auth.auth_bearer import AuthBearer, get_current_user from quivr_api.models.brains_subscription_invitations import BrainSubscription @@ -253,7 +254,7 @@ async def accept_invitation( is_default_brain=False, ) shared_brain = brain_service.get_brain_by_id(brain_id) - + except Exception as e: logger.error(f"Error adding user to brain: {e}") raise HTTPException(status_code=400, detail=f"Error adding user to brain: {e}") diff --git a/backend/api/quivr_api/utils/handle_request_validation_error.py b/backend/api/quivr_api/utils/handle_request_validation_error.py index 3c33da4e0..d539c7885 100644 --- a/backend/api/quivr_api/utils/handle_request_validation_error.py +++ b/backend/api/quivr_api/utils/handle_request_validation_error.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Request, status from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse + from quivr_api.logger import get_logger logger = get_logger(__name__) diff --git a/backend/api/tests/settings/test_settings.py b/backend/api/tests/settings/test_settings.py index 9125590dc..6a89d5b78 100644 --- a/backend/api/tests/settings/test_settings.py +++ b/backend/api/tests/settings/test_settings.py @@ -1,38 +1,46 @@ -from unittest.mock import patch, MagicMock -from quivr_api.modules.dependencies import get_embedding_client +from unittest.mock import MagicMock, patch + from langchain_community.embeddings.ollama import OllamaEmbeddings from langchain_openai import AzureOpenAIEmbeddings +from quivr_api.modules.dependencies import get_embedding_client + def test_ollama_embedding(): with patch("quivr_api.modules.dependencies.settings") as mock_settings: mock_settings.ollama_api_base_url = "http://ollama.example.com" mock_settings.azure_openai_embeddings_url = None - + embedding_client = get_embedding_client() - + assert isinstance(embedding_client, OllamaEmbeddings) assert embedding_client.base_url == "http://ollama.example.com" + def test_azure_embedding(): with patch("quivr_api.modules.dependencies.settings") as mock_settings: mock_settings.ollama_api_base_url = None mock_settings.azure_openai_embeddings_url = "https://quivr-test.openai.azure.com/openai/deployments/embedding/embeddings?api-version=2023-05-15" - + embedding_client = get_embedding_client() - + assert isinstance(embedding_client, AzureOpenAIEmbeddings) assert embedding_client.azure_endpoint == "https://quivr-test.openai.azure.com" + def test_openai_embedding(): - with patch("quivr_api.modules.dependencies.settings") as mock_settings, \ - patch("quivr_api.modules.dependencies.OpenAIEmbeddings") as mock_openai_embeddings: + with ( + patch("quivr_api.modules.dependencies.settings") as mock_settings, + patch( + "quivr_api.modules.dependencies.OpenAIEmbeddings" + ) as mock_openai_embeddings, + ): mock_settings.ollama_api_base_url = None mock_settings.azure_openai_embeddings_url = None - + # Create a mock instance for OpenAIEmbeddings mock_openai_instance = MagicMock() mock_openai_embeddings.return_value = mock_openai_instance - + embedding_client = get_embedding_client() - + assert embedding_client == mock_openai_instance diff --git a/backend/core/examples/pdf_parsing_tika.py b/backend/core/examples/pdf_parsing_tika.py index c84f27f78..b86a232a2 100644 --- a/backend/core/examples/pdf_parsing_tika.py +++ b/backend/core/examples/pdf_parsing_tika.py @@ -1,12 +1,11 @@ from langchain_core.embeddings import DeterministicFakeEmbedding from langchain_core.language_models import FakeListChatModel -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Prompt - from quivr_core import Brain from quivr_core.config import LLMEndpointConfig from quivr_core.llm.llm_endpoint import LLMEndpoint +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Prompt if __name__ == "__main__": brain = Brain.from_files( diff --git a/backend/core/quivr_core/chat.py b/backend/core/quivr_core/chat.py index 01ab56fe0..90697eecc 100644 --- a/backend/core/quivr_core/chat.py +++ b/backend/core/quivr_core/chat.py @@ -3,6 +3,7 @@ from typing import Any, Generator, Tuple from uuid import UUID, uuid4 from langchain_core.messages import AIMessage, HumanMessage + from quivr_core.models import ChatMessage @@ -54,7 +55,7 @@ class ChatHistory: """ # Reverse the chat_history, newest first it = iter(self.get_chat_history(newest_first=True)) - for ai_message, human_message in zip(it, it): + for ai_message, human_message in zip(it, it, strict=False): assert isinstance( human_message.msg, HumanMessage ), f"msg {human_message} is not HumanMessage" diff --git a/backend/core/quivr_core/chat_llm.py b/backend/core/quivr_core/chat_llm.py index 564b7bcb2..4824b3bed 100644 --- a/backend/core/quivr_core/chat_llm.py +++ b/backend/core/quivr_core/chat_llm.py @@ -55,7 +55,7 @@ class ChatLLM: filtered_chat_history.append(ai_message) total_tokens += message_tokens total_pairs += 1 - + return filtered_chat_history def build_chain(self): diff --git a/backend/core/quivr_core/processor/implementations/megaparse_processor.py b/backend/core/quivr_core/processor/implementations/megaparse_processor.py index 5d7a3dc62..d4dbf7e05 100644 --- a/backend/core/quivr_core/processor/implementations/megaparse_processor.py +++ b/backend/core/quivr_core/processor/implementations/megaparse_processor.py @@ -14,19 +14,20 @@ logger = logging.getLogger("quivr_core") class MegaparseProcessor(ProcessorBase): - ''' + """ Megaparse processor for PDF files. - + It can be used to parse PDF files and split them into chunks. - + It comes from the megaparse library. - + ## Installation ```bash pip install megaparse ``` - - ''' + + """ + supported_extensions = [FileExtension.pdf] def __init__( diff --git a/backend/core/quivr_core/utils.py b/backend/core/quivr_core/utils.py index c19c1ca14..38f8c51c5 100644 --- a/backend/core/quivr_core/utils.py +++ b/backend/core/quivr_core/utils.py @@ -37,6 +37,7 @@ def model_supports_function_calling(model_name: str): ] return model_name in models_supporting_function_calls + def format_history_to_openai_mesages( tuple_history: List[Tuple[str, str]], system_message: str, question: str ) -> List[BaseMessage]: @@ -125,7 +126,11 @@ def parse_response(raw_response: RawRAGResponse, model_name: str) -> ParsedRAGRe ) if model_supports_function_calling(model_name): - if 'tool_calls' in raw_response["answer"] and raw_response["answer"].tool_calls and "citations" in raw_response["answer"].tool_calls[-1]["args"]: + if ( + "tool_calls" in raw_response["answer"] + and raw_response["answer"].tool_calls + and "citations" in raw_response["answer"].tool_calls[-1]["args"] + ): citations = raw_response["answer"].tool_calls[-1]["args"]["citations"] metadata.citations = citations followup_questions = raw_response["answer"].tool_calls[-1]["args"][ @@ -147,7 +152,7 @@ def combine_documents( docs, document_prompt=DEFAULT_DOCUMENT_PROMPT, document_separator="\n\n" ): # for each docs, add an index in the metadata to be able to cite the sources - for doc, index in zip(docs, range(len(docs))): + for doc, index in zip(docs, range(len(docs)), strict=False): doc.metadata["index"] = index doc_strings = [format_document(doc, document_prompt) for doc in docs] return document_separator.join(doc_strings) diff --git a/backend/core/tests/fixture_chunks.py b/backend/core/tests/fixture_chunks.py index 9481462d0..47f0e28d2 100644 --- a/backend/core/tests/fixture_chunks.py +++ b/backend/core/tests/fixture_chunks.py @@ -5,7 +5,6 @@ from uuid import uuid4 from langchain_core.embeddings import DeterministicFakeEmbedding from langchain_core.messages.ai import AIMessageChunk from langchain_core.vectorstores import InMemoryVectorStore - from quivr_core.chat import ChatHistory from quivr_core.config import LLMEndpointConfig, RAGConfig from quivr_core.llm import LLMEndpoint diff --git a/backend/core/tests/processor/community/test_markdown_processor.py b/backend/core/tests/processor/community/test_markdown_processor.py index 063b07c0f..8e06b5602 100644 --- a/backend/core/tests/processor/community/test_markdown_processor.py +++ b/backend/core/tests/processor/community/test_markdown_processor.py @@ -2,7 +2,6 @@ from pathlib import Path from uuid import uuid4 import pytest - from quivr_core.files.file import FileExtension, QuivrFile from quivr_core.processor.implementations.default import MarkdownProcessor diff --git a/backend/core/tests/processor/docx/test_docx.py b/backend/core/tests/processor/docx/test_docx.py index ecbbeef7e..a4d445a37 100644 --- a/backend/core/tests/processor/docx/test_docx.py +++ b/backend/core/tests/processor/docx/test_docx.py @@ -2,7 +2,6 @@ from pathlib import Path from uuid import uuid4 import pytest - from quivr_core.files.file import FileExtension, QuivrFile from quivr_core.processor.implementations.default import DOCXProcessor diff --git a/backend/core/tests/processor/epub/test_epub_processor.py b/backend/core/tests/processor/epub/test_epub_processor.py index ae9afc986..a81c569bf 100644 --- a/backend/core/tests/processor/epub/test_epub_processor.py +++ b/backend/core/tests/processor/epub/test_epub_processor.py @@ -2,7 +2,6 @@ from pathlib import Path from uuid import uuid4 import pytest - from quivr_core.files.file import FileExtension, QuivrFile from quivr_core.processor.implementations.default import EpubProcessor diff --git a/backend/core/tests/processor/odt/test_odt.py b/backend/core/tests/processor/odt/test_odt.py index 899b4fd39..bc7b565ac 100644 --- a/backend/core/tests/processor/odt/test_odt.py +++ b/backend/core/tests/processor/odt/test_odt.py @@ -2,7 +2,6 @@ from pathlib import Path from uuid import uuid4 import pytest - from quivr_core.files.file import FileExtension, QuivrFile from quivr_core.processor.implementations.default import ODTProcessor diff --git a/backend/core/tests/processor/pdf/test_unstructured_pdf_processor.py b/backend/core/tests/processor/pdf/test_unstructured_pdf_processor.py index bd03f9b12..a9d3ed237 100644 --- a/backend/core/tests/processor/pdf/test_unstructured_pdf_processor.py +++ b/backend/core/tests/processor/pdf/test_unstructured_pdf_processor.py @@ -2,7 +2,6 @@ from pathlib import Path from uuid import uuid4 import pytest - from quivr_core.files.file import FileExtension, QuivrFile from quivr_core.processor.implementations.default import UnstructuredPDFProcessor diff --git a/backend/core/tests/processor/test_default_implementations.py b/backend/core/tests/processor/test_default_implementations.py index 9248b57d2..62489b347 100644 --- a/backend/core/tests/processor/test_default_implementations.py +++ b/backend/core/tests/processor/test_default_implementations.py @@ -1,5 +1,4 @@ import pytest - from quivr_core.files.file import FileExtension from quivr_core.processor.processor_base import ProcessorBase @@ -7,7 +6,6 @@ from quivr_core.processor.processor_base import ProcessorBase @pytest.mark.base def test___build_processor(): from langchain_community.document_loaders.base import BaseLoader - from quivr_core.processor.implementations.default import _build_processor cls = _build_processor("TestCLS", BaseLoader, [FileExtension.txt]) diff --git a/backend/core/tests/processor/test_simple_txt_processor.py b/backend/core/tests/processor/test_simple_txt_processor.py index 126ee1cac..cf075b47d 100644 --- a/backend/core/tests/processor/test_simple_txt_processor.py +++ b/backend/core/tests/processor/test_simple_txt_processor.py @@ -1,6 +1,5 @@ import pytest from langchain_core.documents import Document - from quivr_core.files.file import FileExtension from quivr_core.processor.implementations.simple_txt_processor import ( SimpleTxtProcessor, diff --git a/backend/core/tests/processor/test_tika_processor.py b/backend/core/tests/processor/test_tika_processor.py index 8b99f10e8..c1a69cd30 100644 --- a/backend/core/tests/processor/test_tika_processor.py +++ b/backend/core/tests/processor/test_tika_processor.py @@ -1,5 +1,4 @@ import pytest - from quivr_core.processor.implementations.tika_processor import TikaProcessor # TODO: TIKA server should be set diff --git a/backend/core/tests/test_chat_history.py b/backend/core/tests/test_chat_history.py index 8cb89e7c8..b5af198a6 100644 --- a/backend/core/tests/test_chat_history.py +++ b/backend/core/tests/test_chat_history.py @@ -3,7 +3,6 @@ from uuid import uuid4 import pytest from langchain_core.messages import AIMessage, HumanMessage - from quivr_core.chat import ChatHistory diff --git a/backend/core/tests/test_chat_llm.py b/backend/core/tests/test_chat_llm.py index 7eeeb9730..0af319294 100644 --- a/backend/core/tests/test_chat_llm.py +++ b/backend/core/tests/test_chat_llm.py @@ -1,5 +1,4 @@ import pytest - from quivr_core import ChatLLM diff --git a/backend/core/tests/test_llm_endpoint.py b/backend/core/tests/test_llm_endpoint.py index ba5fb79c5..d50f60222 100644 --- a/backend/core/tests/test_llm_endpoint.py +++ b/backend/core/tests/test_llm_endpoint.py @@ -3,7 +3,6 @@ import os import pytest from langchain_core.language_models import FakeListChatModel from pydantic.v1.error_wrappers import ValidationError - from quivr_core.config import LLMEndpointConfig from quivr_core.llm import LLMEndpoint diff --git a/backend/core/tests/test_utils.py b/backend/core/tests/test_utils.py index 66ef21126..7847f94e1 100644 --- a/backend/core/tests/test_utils.py +++ b/backend/core/tests/test_utils.py @@ -3,7 +3,6 @@ from uuid import uuid4 import pytest from langchain_core.messages.ai import AIMessageChunk from langchain_core.messages.tool import ToolCall - from quivr_core.utils import ( get_prev_message_str, model_supports_function_calling, diff --git a/backend/requirements-dev.lock b/backend/requirements-dev.lock index 9ceae7d3b..c6dec2b92 100644 --- a/backend/requirements-dev.lock +++ b/backend/requirements-dev.lock @@ -173,6 +173,7 @@ debugpy==1.8.5 decorator==5.1.1 # via ipython defusedxml==0.7.1 + # via fpdf2 # via langchain-anthropic # via nbconvert deprecated==1.2.14 @@ -238,10 +239,11 @@ flatbuffers==24.3.25 flower==2.0.1 # via quivr-worker fonttools==4.53.1 + # via fpdf2 # via matplotlib # via pdf2docx -fpdf==1.7.2 - # via quivr-api +fpdf2==2.7.9 + # via quivr-worker frozenlist==1.4.1 # via aiohttp # via aiosignal @@ -747,6 +749,7 @@ pgvector==0.3.2 pikepdf==9.1.1 # via unstructured pillow==10.2.0 + # via fpdf2 # via layoutparser # via llama-index-core # via matplotlib diff --git a/backend/requirements.lock b/backend/requirements.lock index 96ee9ada7..23de6bdc3 100644 --- a/backend/requirements.lock +++ b/backend/requirements.lock @@ -150,6 +150,7 @@ debugpy==1.8.5 decorator==5.1.1 # via ipython defusedxml==0.7.1 + # via fpdf2 # via langchain-anthropic # via nbconvert deprecated==1.2.14 @@ -200,10 +201,11 @@ flatbuffers==24.3.25 flower==2.0.1 # via quivr-worker fonttools==4.53.1 + # via fpdf2 # via matplotlib # via pdf2docx -fpdf==1.7.2 - # via quivr-api +fpdf2==2.7.9 + # via quivr-worker frozenlist==1.4.1 # via aiohttp # via aiosignal @@ -650,6 +652,7 @@ pgvector==0.3.2 pikepdf==9.1.1 # via unstructured pillow==10.2.0 + # via fpdf2 # via layoutparser # via llama-index-core # via matplotlib diff --git a/backend/supabase/migrations/20240911145305_gtasks.sql b/backend/supabase/migrations/20240911145305_gtasks.sql new file mode 100644 index 000000000..2ea2e30b2 --- /dev/null +++ b/backend/supabase/migrations/20240911145305_gtasks.sql @@ -0,0 +1,79 @@ +create table "public"."tasks" ( + "id" bigint generated by default as identity not null, + "pretty_id" text, + "user_id" uuid not null default auth.uid(), + "status" text, + "creation_time" timestamp with time zone default (now() AT TIME ZONE 'utc'::text), + "answer_raw" jsonb, + "answer_pretty" text +); + + +alter table "public"."tasks" enable row level security; + +CREATE UNIQUE INDEX tasks_pkey ON public.tasks USING btree (id); + +alter table "public"."tasks" add constraint "tasks_pkey" PRIMARY KEY using index "tasks_pkey"; + +alter table "public"."tasks" add constraint "tasks_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid; + +alter table "public"."tasks" validate constraint "tasks_user_id_fkey"; + +grant delete on table "public"."tasks" to "anon"; + +grant insert on table "public"."tasks" to "anon"; + +grant references on table "public"."tasks" to "anon"; + +grant select on table "public"."tasks" to "anon"; + +grant trigger on table "public"."tasks" to "anon"; + +grant truncate on table "public"."tasks" to "anon"; + +grant update on table "public"."tasks" to "anon"; + +grant delete on table "public"."tasks" to "authenticated"; + +grant insert on table "public"."tasks" to "authenticated"; + +grant references on table "public"."tasks" to "authenticated"; + +grant select on table "public"."tasks" to "authenticated"; + +grant trigger on table "public"."tasks" to "authenticated"; + +grant truncate on table "public"."tasks" to "authenticated"; + +grant update on table "public"."tasks" to "authenticated"; + +grant delete on table "public"."tasks" to "service_role"; + +grant insert on table "public"."tasks" to "service_role"; + +grant references on table "public"."tasks" to "service_role"; + +grant select on table "public"."tasks" to "service_role"; + +grant trigger on table "public"."tasks" to "service_role"; + +grant truncate on table "public"."tasks" to "service_role"; + +grant update on table "public"."tasks" to "service_role"; + +create policy "allow_user_all_tasks" +on "public"."tasks" +as permissive +for all +to public +using ((user_id = ( SELECT auth.uid() AS uid))); + + +create policy "tasks" +on "public"."tasks" +as permissive +for all +to service_role; + + + diff --git a/backend/supabase/migrations/20240918094405_assistants.sql b/backend/supabase/migrations/20240918094405_assistants.sql new file mode 100644 index 000000000..22b4c885f --- /dev/null +++ b/backend/supabase/migrations/20240918094405_assistants.sql @@ -0,0 +1,11 @@ +alter table "public"."tasks" drop column "answer_pretty"; + +alter table "public"."tasks" drop column "answer_raw"; + +alter table "public"."tasks" add column "answer" text; + +alter table "public"."tasks" add column "assistant_id" bigint not null; + +alter table "public"."tasks" add column "settings" jsonb; + + diff --git a/backend/worker/pyproject.toml b/backend/worker/pyproject.toml index 36ef4a40f..eff842739 100644 --- a/backend/worker/pyproject.toml +++ b/backend/worker/pyproject.toml @@ -13,10 +13,11 @@ dependencies = [ "playwright>=1.0.0", "openai>=1.0.0", "flower>=2.0.1", - "torch==2.4.0; platform_machine != 'x86_64'" , + "torch==2.4.0; platform_machine != 'x86_64'", "torch==2.4.0+cpu; platform_machine == 'x86_64'", "torchvision==0.19.0; platform_machine != 'x86_64'", "torchvision==0.19.0+cpu; platform_machine == 'x86_64'", + "fpdf2>=2.7.9", ] readme = "README.md" requires-python = ">= 3.11" diff --git a/backend/api/quivr_api/modules/assistant/service/__init__.py b/backend/worker/quivr_worker/assistants/__init__.py similarity index 100% rename from backend/api/quivr_api/modules/assistant/service/__init__.py rename to backend/worker/quivr_worker/assistants/__init__.py diff --git a/backend/worker/quivr_worker/assistants/assistants.py b/backend/worker/quivr_worker/assistants/assistants.py new file mode 100644 index 000000000..b44f7273e --- /dev/null +++ b/backend/worker/quivr_worker/assistants/assistants.py @@ -0,0 +1,40 @@ +import os + +from quivr_api.modules.assistant.services.tasks_service import TasksService +from quivr_api.modules.upload.service.upload_file import ( + upload_file_storage, +) + +from quivr_worker.utils.pdf_generator.pdf_generator import PDFGenerator, PDFModel + + +async def process_assistant( + assistant_id: str, + notification_uuid: str, + task_id: int, + tasks_service: TasksService, + user_id: str, +): + task = await tasks_service.get_task_by_id(task_id, user_id) # type: ignore + + await tasks_service.update_task(task_id, {"status": "in_progress"}) + + print(task) + + task_result = {"status": "completed", "answer": "#### Assistant answer"} + + output_dir = f"{assistant_id}/{notification_uuid}" + os.makedirs(output_dir, exist_ok=True) + output_path = f"{output_dir}/output.pdf" + + generated_pdf = PDFGenerator(PDFModel(title="Test", content="Test")) + generated_pdf.print_pdf() + generated_pdf.output(output_path) + + with open(output_path, "rb") as file: + await upload_file_storage(file, output_path) + + # Now delete the file + os.remove(output_path) + + await tasks_service.update_task(task_id, task_result) diff --git a/backend/worker/quivr_worker/celery_worker.py b/backend/worker/quivr_worker/celery_worker.py index ceb1632c8..c6f06330a 100644 --- a/backend/worker/quivr_worker/celery_worker.py +++ b/backend/worker/quivr_worker/celery_worker.py @@ -8,6 +8,8 @@ from dotenv import load_dotenv from quivr_api.celery_config import celery from quivr_api.logger import get_logger from quivr_api.models.settings import settings +from quivr_api.modules.assistant.repository.tasks import TasksRepository +from quivr_api.modules.assistant.services.tasks_service import TasksService from quivr_api.modules.brain.integrations.Notion.Notion_connector import NotionConnector from quivr_api.modules.brain.repository.brains_vectors import BrainsVectors from quivr_api.modules.brain.service.brain_service import BrainService @@ -29,6 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from sqlmodel import Session, text from sqlmodel.ext.asyncio.session import AsyncSession +from quivr_worker.assistants.assistants import process_assistant from quivr_worker.check_premium import check_is_premium from quivr_worker.process.process_s3_file import process_uploaded_file from quivr_worker.process.process_url import process_url_func @@ -39,7 +42,7 @@ from quivr_worker.syncs.process_active_syncs import ( process_sync, ) from quivr_worker.syncs.store_notion import fetch_and_store_notion_files_async -from quivr_worker.utils import _patch_json +from quivr_worker.utils.utils import _patch_json load_dotenv() @@ -91,6 +94,63 @@ def init_worker(**kwargs): ) +@celery.task( + retries=3, + default_retry_delay=1, + name="process_assistant_task", + autoretry_for=(Exception,), +) +def process_assistant_task( + assistant_id: str, + notification_uuid: str, + task_id: int, + user_id: str, +): + logger.info( + f"process_assistant_task started for assistant_id={assistant_id}, notification_uuid={notification_uuid}, task_id={task_id}" + ) + print("process_assistant_task") + + loop = asyncio.get_event_loop() + loop.run_until_complete( + aprocess_assistant_task( + assistant_id, + notification_uuid, + task_id, + user_id, + ) + ) + + +async def aprocess_assistant_task( + assistant_id: str, + notification_uuid: str, + task_id: int, + user_id: str, +): + async with AsyncSession(async_engine) as async_session: + try: + await async_session.execute( + text("SET SESSION idle_in_transaction_session_timeout = '5min';") + ) + tasks_repository = TasksRepository(async_session) + tasks_service = TasksService(tasks_repository) + + await process_assistant( + assistant_id, + notification_uuid, + task_id, + tasks_service, + user_id, + ) + + except Exception as e: + await async_session.rollback() + raise e + finally: + await async_session.close() + + @celery.task( retries=3, default_retry_delay=1, @@ -111,10 +171,6 @@ def process_file_task( if async_engine is None: init_worker() - logger.info( - f"Task process_file started for file_name={file_name}, knowledge_id={knowledge_id}, brain_id={brain_id}, notification_id={notification_id}" - ) - loop = asyncio.get_event_loop() loop.run_until_complete( aprocess_file_task( diff --git a/backend/worker/quivr_worker/files.py b/backend/worker/quivr_worker/files.py index 8aefe51d9..8648c7ba9 100644 --- a/backend/worker/quivr_worker/files.py +++ b/backend/worker/quivr_worker/files.py @@ -9,7 +9,7 @@ from uuid import UUID from quivr_api.logger import get_logger from quivr_core.files.file import FileExtension, QuivrFile -from quivr_worker.utils import get_tmp_name +from quivr_worker.utils.utils import get_tmp_name logger = get_logger("celery_worker") diff --git a/backend/worker/quivr_worker/utils/__init__.py b/backend/worker/quivr_worker/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/worker/quivr_worker/utils/pdf_generator/__init__.py b/backend/worker/quivr_worker/utils/pdf_generator/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed-Bold.ttf b/backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed-Bold.ttf similarity index 100% rename from backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed-Bold.ttf rename to backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed-Bold.ttf diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed-Oblique.ttf b/backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed-Oblique.ttf similarity index 100% rename from backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed-Oblique.ttf rename to backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed-Oblique.ttf diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed.ttf b/backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed.ttf similarity index 100% rename from backend/api/quivr_api/modules/assistant/ito/utils/font/DejaVuSansCondensed.ttf rename to backend/worker/quivr_worker/utils/pdf_generator/font/DejaVuSansCondensed.ttf diff --git a/backend/worker/quivr_worker/utils/pdf_generator/logo.png b/backend/worker/quivr_worker/utils/pdf_generator/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b7e5e0e86f264ee7a032c7d5d91682870131cb9 GIT binary patch literal 66854 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 AsQ>@~ literal 0 HcmV?d00001 diff --git a/backend/api/quivr_api/modules/assistant/ito/utils/pdf_generator.py b/backend/worker/quivr_worker/utils/pdf_generator/pdf_generator.py similarity index 62% rename from backend/api/quivr_api/modules/assistant/ito/utils/pdf_generator.py rename to backend/worker/quivr_worker/utils/pdf_generator/pdf_generator.py index 68de9a3e9..13bdfcc83 100644 --- a/backend/api/quivr_api/modules/assistant/ito/utils/pdf_generator.py +++ b/backend/worker/quivr_worker/utils/pdf_generator/pdf_generator.py @@ -1,6 +1,6 @@ import os -from fpdf import FPDF +from fpdf import FPDF, XPos, YPos from pydantic import BaseModel @@ -17,7 +17,6 @@ class PDFGenerator(FPDF): "DejaVu", "", os.path.join(os.path.dirname(__file__), "font/DejaVuSansCondensed.ttf"), - uni=True, ) self.add_font( "DejaVu", @@ -25,7 +24,6 @@ class PDFGenerator(FPDF): os.path.join( os.path.dirname(__file__), "font/DejaVuSansCondensed-Bold.ttf" ), - uni=True, ) self.add_font( "DejaVu", @@ -60,9 +58,15 @@ class PDFGenerator(FPDF): self.cell(0, 10, "Github", 0, 1, "C", link="https://github.com/quivrhq/quivr") def chapter_body(self): - self.set_font("DejaVu", "", 12) - self.multi_cell(0, 10, self.pdf_model.content, markdown=True) + self.multi_cell( + 0, + 10, + self.pdf_model.content, + markdown=True, + new_x=XPos.RIGHT, + new_y=YPos.TOP, + ) self.ln() def print_pdf(self): @@ -75,14 +79,6 @@ if __name__ == "__main__": title="Summary of Legal Services Rendered by Orrick", content=""" **Summary:** -The document is an invoice from Quivr Technologies, Inc. for legal services provided to client YC W24, related to initial corporate work. The total fees and disbursements amount to $8,345.00 for services rendered through February 29, 2024. The invoice includes specific instructions for payment remittance and contact information for inquiries. Online payment through e-billexpress.com is also an option. - -**Key Points:** -- Quivr Technologies, Inc., based in France and represented by Stanislas Girard, provided legal services to client YC W24. -- Services included preparing and completing forms, drafting instructions, reviewing and responding to emails, filing 83(b) elections, and finalizing documents for submission to YC. -- The timekeepers involved in providing these services were Julien Barbey, Maria T. Coladonato, Michael LaBlanc, Jessy K. Parker, Marisol Sandoval Villasenor, Alexis A. Smith, and Serena Tibrewala. -- The total hours billed for the services provided was 16.20, with a total cost of $8,345.00. -- Instructions for payment remittance, contact information, and online payment options through e-billex """, ) pdf = PDFGenerator(pdf_model) diff --git a/backend/worker/quivr_worker/utils.py b/backend/worker/quivr_worker/utils/utils.py similarity index 100% rename from backend/worker/quivr_worker/utils.py rename to backend/worker/quivr_worker/utils/utils.py diff --git a/backend/worker/tests/conftest.py b/backend/worker/tests/conftest.py index 596b81c06..7d9828a36 100644 --- a/backend/worker/tests/conftest.py +++ b/backend/worker/tests/conftest.py @@ -2,7 +2,6 @@ import os from uuid import uuid4 import pytest - from quivr_worker.files import File diff --git a/backend/worker/tests/test_process_url_task.py b/backend/worker/tests/test_process_url_task.py index 74b9a254c..a34501b52 100644 --- a/backend/worker/tests/test_process_url_task.py +++ b/backend/worker/tests/test_process_url_task.py @@ -122,4 +122,4 @@ def test_process_crawl_task(test_data: TestData): "notification_id": uuid4(), }, ) - result = task.wait() + result = task.wait() # noqa: F841 diff --git a/backend/worker/tests/test_utils.py b/backend/worker/tests/test_utils.py index 3b0cfdab5..492da7a1e 100644 --- a/backend/worker/tests/test_utils.py +++ b/backend/worker/tests/test_utils.py @@ -4,8 +4,7 @@ from uuid import UUID import pytest from langchain_core.documents import Document - -from quivr_worker.utils import _patch_json +from quivr_worker.utils.utils import _patch_json def test_patch_json():