From 55565fd3a7ffc20d108201252e77ebe3bab77cb1 Mon Sep 17 00:00:00 2001 From: Saurab-Shrestha Date: Thu, 25 Jan 2024 17:37:00 +0545 Subject: [PATCH] Added home.py file for /chat route --- Celery.pdf | 0 private_gpt/constants.py | 1 + private_gpt/home.py | 218 ++++++++++++++++++ private_gpt/launcher.py | 37 ++- private_gpt/server/chat/chat_router.py | 1 + private_gpt/server/ingest/ingest_router.py | 33 ++- private_gpt/server/ingest/ingest_service.py | 1 + private_gpt/server/ingest/model.py | 6 +- private_gpt/ui/admin_ui.py | 16 +- private_gpt/ui/common.py | 7 +- private_gpt/ui/ui.py | 3 +- private_gpt/ui/users_ui.py | 141 ----------- private_gpt/uploads/Celery.pdf | Bin 0 -> 53579 bytes .../DV-2025 Submission Confirmation.pdf | Bin 0 -> 109240 bytes private_gpt/uploads/Multimodel LLM.pdf | Bin 0 -> 538543 bytes private_gpt/uploads/Redis.pdf | Bin 0 -> 65446 bytes private_gpt/uploads/Resume.pdf | Bin 0 -> 114597 bytes private_gpt/users/api/v1/routers/auth.py | 3 + private_gpt/users/api/v1/routers/companies.py | 2 +- settings.yaml | 5 +- 20 files changed, 295 insertions(+), 179 deletions(-) create mode 100644 Celery.pdf create mode 100644 private_gpt/home.py delete mode 100644 private_gpt/ui/users_ui.py create mode 100644 private_gpt/uploads/Celery.pdf create mode 100644 private_gpt/uploads/DV-2025 Submission Confirmation.pdf create mode 100644 private_gpt/uploads/Multimodel LLM.pdf create mode 100644 private_gpt/uploads/Redis.pdf create mode 100644 private_gpt/uploads/Resume.pdf diff --git a/Celery.pdf b/Celery.pdf new file mode 100644 index 00000000..e69de29b diff --git a/private_gpt/constants.py b/private_gpt/constants.py index 3a1d6fb1..02baac16 100644 --- a/private_gpt/constants.py +++ b/private_gpt/constants.py @@ -1,3 +1,4 @@ from pathlib import Path PROJECT_ROOT_PATH: Path = Path(__file__).parents[1] +UPLOAD_DIR = rf"F:\LLM\privateGPT\private_gpt\uploads" \ No newline at end of file diff --git a/private_gpt/home.py b/private_gpt/home.py new file mode 100644 index 00000000..a7b0e631 --- /dev/null +++ b/private_gpt/home.py @@ -0,0 +1,218 @@ +"""This file should be imported only and only if you want to run the UI locally.""" +from fastapi import Request +from fastapi.responses import StreamingResponse +import itertools +import logging +from collections.abc import Iterable +from pathlib import Path +from typing import Any, List + +from fastapi import APIRouter, Depends, Request, FastAPI, Body +from fastapi.responses import JSONResponse +from gradio.themes.utils.colors import slate # type: ignore +from injector import inject, singleton +from llama_index.llms import ChatMessage, ChatResponse, MessageRole +from pydantic import BaseModel + +from private_gpt.constants import PROJECT_ROOT_PATH +from private_gpt.di import global_injector +from private_gpt.server.chat.chat_service import ChatService, CompletionGen +from private_gpt.server.chunks.chunks_service import Chunk, ChunksService +from private_gpt.server.ingest.ingest_service import IngestService +from private_gpt.settings.settings import settings +from private_gpt.ui.images import logo_svg +from private_gpt.ui.common import Source + +logger = logging.getLogger(__name__) + +THIS_DIRECTORY_RELATIVE = Path(__file__).parent.relative_to(PROJECT_ROOT_PATH) +# Should be "private_gpt/ui/avatar-bot.ico" +AVATAR_BOT = THIS_DIRECTORY_RELATIVE / "avatar-bot.ico" + +UI_TAB_TITLE = "My Private GPT" + +SOURCES_SEPARATOR = "\n\n Sources: \n" + +MODES = ["Query Docs", "Search in Docs", "LLM Chat"] +home_router = APIRouter(prefix="/v1") + + + +@singleton +class Home: + @inject + def __init__( + self, + ingest_service: IngestService, + chat_service: ChatService, + chunks_service: ChunksService, + ) -> None: + self._ingest_service = ingest_service + self._chat_service = chat_service + self._chunks_service = chunks_service + + # Initialize system prompt based on default mode + self.mode = MODES[0] + self._system_prompt = self._get_default_system_prompt(self.mode) + + def _chat(self, message: str, history: list[list[str]], mode: str, *_: Any) -> Any: + def yield_deltas(completion_gen: CompletionGen) -> Iterable[str]: + full_response: str = "" + stream = completion_gen.response + for delta in stream: + if isinstance(delta, str): + full_response += str(delta) + elif isinstance(delta, ChatResponse): + full_response += delta.delta or "" + yield full_response + + if completion_gen.sources: + full_response += SOURCES_SEPARATOR + cur_sources = Source.curate_sources(completion_gen.sources) + sources_text = "\n\n\n".join( + f'{index}. {source.file} (page {source.page})' + for index, source in enumerate(cur_sources, start=1) + ) + full_response += sources_text + print(full_response) + yield full_response + + def build_history() -> list[ChatMessage]: + history_messages: list[ChatMessage] = list( + itertools.chain( + *[ + [ + ChatMessage( + content=interaction[0], role=MessageRole.USER), + ChatMessage( + # Remove from history content the Sources information + content=interaction[1].split( + SOURCES_SEPARATOR)[0], + role=MessageRole.ASSISTANT, + ), + ] + for interaction in history + ] + ) + ) + + # max 20 messages to try to avoid context overflow + return history_messages[:20] + + new_message = ChatMessage(content=message, role=MessageRole.USER) + all_messages = [*build_history(), new_message] + # If a system prompt is set, add it as a system message + if self._system_prompt: + all_messages.insert( + 0, + ChatMessage( + content=self._system_prompt, + role=MessageRole.SYSTEM, + ), + ) + match mode: + case "Query Docs": + query_stream = self._chat_service.stream_chat( + messages=all_messages, + use_context=True, + ) + yield from yield_deltas(query_stream) + case "LLM Chat": + llm_stream = self._chat_service.stream_chat( + messages=all_messages, + use_context=False, + ) + yield from yield_deltas(llm_stream) + + case "Search in Docs": + response = self._chunks_service.retrieve_relevant( + text=message, limit=4, prev_next_chunks=0 + ) + + sources = Source.curate_sources(response) + + yield "\n\n\n".join( + f"{index}. **{source.file} (page {source.page})**\n" + f" (link: [{source.page_link}]({source.page_link}))\n{source.text}" + for index, source in enumerate(sources, start=1) + ) + + # On initialization and on mode change, this function set the system prompt + # to the default prompt based on the mode (and user settings). + @staticmethod + def _get_default_system_prompt(mode: str) -> str: + p = "" + match mode: + # For query chat mode, obtain default system prompt from settings + case "Query Docs": + p = settings().ui.default_query_system_prompt + # For chat mode, obtain default system prompt from settings + case "LLM Chat": + p = settings().ui.default_chat_system_prompt + # For any other mode, clear the system prompt + case _: + p = "" + return p + + def _set_system_prompt(self, system_prompt_input: str) -> None: + logger.info(f"Setting system prompt to: {system_prompt_input}") + self._system_prompt = system_prompt_input + + def _set_current_mode(self, mode: str) -> Any: + self.mode = mode + self._set_system_prompt(self._get_default_system_prompt(mode)) + + def _list_ingested_files(self) -> list[list[str]]: + files = set() + for ingested_document in self._ingest_service.list_ingested(): + if ingested_document.doc_metadata is None: + # Skipping documents without metadata + continue + file_name = ingested_document.doc_metadata.get( + "file_name", "[FILE NAME MISSING]" + ) + files.add(file_name) + return [[row] for row in files] + + def _upload_file(self, files: list[str]) -> None: + logger.debug("Loading count=%s files", len(files)) + paths = [Path(file) for file in files] + self._ingest_service.bulk_ingest( + [(str(path.name), path) for path in paths]) + + + +import json + +DEFAULT_MODE = MODES[0] +@home_router.post("/chat") +async def chat_endpoint(request: Request, message: str = Body(...), mode: str = Body(DEFAULT_MODE)): + home_instance = request.state.injector.get(Home) + history = [] + print("The message is: ", message) + print("The mode is: ", mode) + responses = home_instance._chat(message, history, mode) + return StreamingResponse(content=responses, media_type='text/event-stream') + + + + + + # text = ( + # "To run the Celery worker based on the provided context, you can follow these steps: " + # "1. First, make sure you have Celery installed in your project." + # "2. Create a Celery instance and configure it with your settings." + # "3. Define Celery tasks that will be executed by the worker." + # "4. Start the Celery worker using the configured Celery instance." + # "5. Your Celery worker is now running and ready to process tasks." + # ) + # import time + # async def generate_stream(): + # for i in range(len(text)): + # yield text[:i+1] # Sending part of the text in each iteration + # time.sleep(0.1) # Simulating some processing time + + # Return the responses as a StreamingResponse + # return StreamingResponse(content=responses, media_type="application/json") + + diff --git a/private_gpt/launcher.py b/private_gpt/launcher.py index 5ae70e2c..075e23af 100644 --- a/private_gpt/launcher.py +++ b/private_gpt/launcher.py @@ -14,7 +14,7 @@ from private_gpt.server.ingest.ingest_router import ingest_router from private_gpt.users.api.v1.api import api_router from private_gpt.settings.settings import Settings - +from private_gpt.home import home_router logger = logging.getLogger(__name__) @@ -25,28 +25,25 @@ def create_app(root_injector: Injector) -> FastAPI: request.state.injector = root_injector app = FastAPI(dependencies=[Depends(bind_injector_to_request)]) - - # app.include_router(completions_router) - # app.include_router(chat_router) - # app.include_router(chunks_router) - # app.include_router(ingest_router) - # app.include_router(embeddings_router) - # app.include_router(health_router) + app.include_router(completions_router) + app.include_router(chat_router) + app.include_router(chunks_router) + app.include_router(ingest_router) + app.include_router(embeddings_router) + app.include_router(health_router) app.include_router(api_router) - - + app.include_router(home_router) settings = root_injector.get(Settings) - if settings.server.cors.enabled: - logger.debug("Setting up CORS middleware") - app.add_middleware( - CORSMiddleware, - allow_credentials=settings.server.cors.allow_credentials, - allow_origins=settings.server.cors.allow_origins, - allow_origin_regex=settings.server.cors.allow_origin_regex, - allow_methods=settings.server.cors.allow_methods, - allow_headers=settings.server.cors.allow_headers, - ) + # if settings.server.cors.enabled/: + logger.debug("Setting up CORS middleware") + app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_origins=["http://localhost:5173", "http://localhost:8001"], + allow_methods=["DELETE", "GET", "POST", "PUT", "OPTIONS"], + allow_headers=["*"], + ) # if settings.ui.enabled: # logger.debug("Importing the UI module") diff --git a/private_gpt/server/chat/chat_router.py b/private_gpt/server/chat/chat_router.py index e493c348..f6ddeee7 100644 --- a/private_gpt/server/chat/chat_router.py +++ b/private_gpt/server/chat/chat_router.py @@ -106,3 +106,4 @@ def chat_completion( return to_openai_response( completion.response, completion.sources if body.include_sources else None ) + \ No newline at end of file diff --git a/private_gpt/server/ingest/ingest_router.py b/private_gpt/server/ingest/ingest_router.py index 56adba46..d52ea3c9 100644 --- a/private_gpt/server/ingest/ingest_router.py +++ b/private_gpt/server/ingest/ingest_router.py @@ -1,15 +1,17 @@ from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File +from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from private_gpt.server.ingest.ingest_service import IngestService from private_gpt.server.ingest.model import IngestedDoc from private_gpt.server.utils.auth import authenticated +from private_gpt.constants import UPLOAD_DIR +from pathlib import Path ingest_router = APIRouter(prefix="/v1", dependencies=[Depends(authenticated)]) - class IngestTextBody(BaseModel): file_name: str = Field(examples=["Avatar: The Last Airbender"]) text: str = Field( @@ -38,7 +40,7 @@ def ingest(request: Request, file: UploadFile) -> IngestResponse: @ingest_router.post("/ingest/file", tags=["Ingestion"]) -def ingest_file(request: Request, file: UploadFile) -> IngestResponse: +def ingest_file(request: Request, file: UploadFile = File(...)) -> IngestResponse: """Ingests and processes a file, storing its chunks to be used as context. The context obtained from files is later used in @@ -54,12 +56,22 @@ def ingest_file(request: Request, file: UploadFile) -> IngestResponse: can be used to filter the context used to create responses in `/chat/completions`, `/completions`, and `/chunks` APIs. """ + # try: service = request.state.injector.get(IngestService) if file.filename is None: raise HTTPException(400, "No file name provided") - ingested_documents = service.ingest_bin_data(file.filename, file.file) + upload_path = Path(f"{UPLOAD_DIR}/{file.filename}") + try: + with open(upload_path, "wb") as f: + f.write(file.file.read()) + with open(upload_path, "rb") as f: + ingested_documents = service.ingest_bin_data(file.filename, f) + except Exception as e: + return {"message": f"There was an error uploading the file(s)\n {e}"} + finally: + file.file.close() return IngestResponse(object="list", model="private-gpt", data=ingested_documents) - + @ingest_router.post("/ingest/text", tags=["Ingestion"]) def ingest_text(request: Request, body: IngestTextBody) -> IngestResponse: @@ -102,3 +114,14 @@ def delete_ingested(request: Request, doc_id: str) -> None: """ service = request.state.injector.get(IngestService) service.delete(doc_id) + + +@ingest_router.delete("/ingest", tags=["Ingestion"]) +def delete_ingested(request: Request, doc_id: str) -> None: + """Delete the specified ingested Document. + + The `doc_id` can be obtained from the `GET /ingest/list` endpoint. + The document will be effectively deleted from your storage context. + """ + service = request.state.injector.get(IngestService) + service.delete(doc_id) diff --git a/private_gpt/server/ingest/ingest_service.py b/private_gpt/server/ingest/ingest_service.py index aa2f73c3..82b7ad1d 100644 --- a/private_gpt/server/ingest/ingest_service.py +++ b/private_gpt/server/ingest/ingest_service.py @@ -130,3 +130,4 @@ class IngestService: "Deleting the ingested document=%s in the doc and index store", doc_id ) self.ingest_component.delete(doc_id) + diff --git a/private_gpt/server/ingest/model.py b/private_gpt/server/ingest/model.py index eb957ee0..3e21ef00 100644 --- a/private_gpt/server/ingest/model.py +++ b/private_gpt/server/ingest/model.py @@ -1,8 +1,9 @@ from typing import Any, Literal - +import os from llama_index import Document from pydantic import BaseModel, Field - +from private_gpt.constants import UPLOAD_DIR +from pathlib import Path class IngestedDoc(BaseModel): object: Literal["ingest.document"] @@ -30,3 +31,4 @@ class IngestedDoc(BaseModel): doc_id=document.doc_id, doc_metadata=IngestedDoc.curate_metadata(document.metadata), ) + diff --git a/private_gpt/ui/admin_ui.py b/private_gpt/ui/admin_ui.py index ce45f64e..9cf98755 100644 --- a/private_gpt/ui/admin_ui.py +++ b/private_gpt/ui/admin_ui.py @@ -33,6 +33,8 @@ SOURCES_SEPARATOR = "\n\n Sources: \n" MODES = ["Query Docs", "Search in Docs", "LLM Chat"] + +# generate @singleton class PrivateAdminGptUi: @inject @@ -67,11 +69,16 @@ class PrivateAdminGptUi: if completion_gen.sources: full_response += SOURCES_SEPARATOR cur_sources = Source.curate_sources(completion_gen.sources) + # sources_text = "\n\n\n".join( + # f"{index}. {source.file} (page {source.page}) (page_link {source.page_link})" + # for index, source in enumerate(cur_sources, start=1) + # ) sources_text = "\n\n\n".join( - f"{index}. {source.file} (page {source.page})" + f'{index}. {source.file} (page {source.page})' for index, source in enumerate(cur_sources, start=1) ) full_response += sources_text + print(full_response) yield full_response def build_history() -> list[ChatMessage]: @@ -125,11 +132,10 @@ class PrivateAdminGptUi: ) sources = Source.curate_sources(response) - + yield "\n\n\n".join( - f"{index}. **{source.file} " - f"(page {source.page})**\n " - f"{source.text}" + f"{index}. **{source.file} (page {source.page})**\n" + f" (link: [{source.page_link}]({source.page_link}))\n{source.text}" for index, source in enumerate(sources, start=1) ) diff --git a/private_gpt/ui/common.py b/private_gpt/ui/common.py index d0e48f26..cd26755f 100644 --- a/private_gpt/ui/common.py +++ b/private_gpt/ui/common.py @@ -1,10 +1,12 @@ from pydantic import BaseModel from private_gpt.server.chunks.chunks_service import Chunk, ChunksService - +from private_gpt.constants import UPLOAD_DIR +from pathlib import Path class Source(BaseModel): file: str page: str text: str + page_link: str class Config: frozen = True @@ -18,8 +20,9 @@ class Source(BaseModel): file_name = doc_metadata.get("file_name", "-") if doc_metadata else "-" page_label = doc_metadata.get("page_label", "-") if doc_metadata else "-" + page_link = str(Path(f"{UPLOAD_DIR}/{file_name}#page={page_label}")) - source = Source(file=file_name, page=page_label, text=chunk.text) + source = Source(file=file_name, page=page_label, text=chunk.text, page_link=page_link) curated_sources.add(source) return curated_sources \ No newline at end of file diff --git a/private_gpt/ui/ui.py b/private_gpt/ui/ui.py index 95a255f7..68ee528f 100644 --- a/private_gpt/ui/ui.py +++ b/private_gpt/ui/ui.py @@ -69,7 +69,7 @@ class PrivateGptUi: full_response += SOURCES_SEPARATOR cur_sources = Source.curate_sources(completion_gen.sources) sources_text = "\n\n\n".join( - f"{index}. {source.file} (page {source.page})" + f"{index}. {source.file} (page {source.page}) (page_link {source.page_link})" for index, source in enumerate(cur_sources, start=1) ) full_response += sources_text @@ -130,6 +130,7 @@ class PrivateGptUi: yield "\n\n\n".join( f"{index}. **{source.file} " f"(page {source.page})**\n " + f"(link {source.page_link})**\n " f"{source.text}" for index, source in enumerate(sources, start=1) ) diff --git a/private_gpt/ui/users_ui.py b/private_gpt/ui/users_ui.py deleted file mode 100644 index a8ced711..00000000 --- a/private_gpt/ui/users_ui.py +++ /dev/null @@ -1,141 +0,0 @@ -"""This file should be imported only and only if you want to run the UI locally.""" -import itertools -import logging -from collections.abc import Iterable -from pathlib import Path -from typing import Any - -import gradio as gr # type: ignore -from fastapi import FastAPI -from gradio.themes.utils.colors import slate # type: ignore -from injector import inject, singleton -from llama_index.llms import ChatMessage, ChatResponse, MessageRole -from pydantic import BaseModel - -from private_gpt.constants import PROJECT_ROOT_PATH -from private_gpt.di import global_injector -from private_gpt.server.chat.chat_service import ChatService, CompletionGen -from private_gpt.server.chunks.chunks_service import Chunk, ChunksService -from private_gpt.server.ingest.ingest_service import IngestService -from private_gpt.settings.settings import settings -from private_gpt.ui.images import logo_svg - -logger = logging.getLogger(__name__) - -THIS_DIRECTORY_RELATIVE = Path(__file__).parent.relative_to(PROJECT_ROOT_PATH) -# Should be "private_gpt/ui/avatar-bot.ico" -AVATAR_BOT = THIS_DIRECTORY_RELATIVE / "avatar-bot.ico" - -UI_TAB_TITLE = "My Private GPT" - -SOURCES_SEPARATOR = "\n\n Sources: \n" - -MODES = ["Query Docs", "Search in Docs", "LLM Chat"] - -from private_gpt.ui.common import PrivateGpt - -@singleton -class UsersUI(PrivateGpt): - - def __init__( - self, - ingest_service: IngestService, - chat_service: ChatService, - chunks_service: ChunksService, - ) -> None: - super().__init__(ingest_service, chat_service, chunks_service) - - def _build_ui_blocks(self) -> gr.Blocks: - logger.debug("Creating the UI blocks") - with gr.Blocks( - title=UI_TAB_TITLE, - theme=gr.themes.Soft(primary_hue=slate), - css=".logo { " - "display:flex;" - "background-color: #C7BAFF;" - "height: 80px;" - "border-radius: 8px;" - "align-content: center;" - "justify-content: center;" - "align-items: center;" - "}" - ".logo img { height: 25% }" - ".contain { display: flex !important; flex-direction: column !important; }" - "#component-0, #component-3, #component-10, #component-8 { height: 100% !important; }" - "#chatbot { flex-grow: 1 !important; overflow: auto !important;}" - "#col { height: calc(100vh - 112px - 16px) !important; }", - ) as users: - # with gr.Row(): - # gr.HTML(f"