From e67a25c0bde8e6b9aecac95f86bf0ef1e0f98da7 Mon Sep 17 00:00:00 2001 From: ian_Cin Date: Wed, 3 Apr 2024 14:52:40 +0700 Subject: [PATCH 1/3] Feat/add multimodal loader (#5) * Add Adobe reader as the multimodal loader * Allow FullQAPipeline to reasoning on figures * fix: move the adobe import to avoid ImportError, notify users whenever they run the AdobeReader --------- Co-authored-by: cin-albert --- .../kotaemon/indices/ingests/files.py | 5 +- libs/kotaemon/kotaemon/loaders/__init__.py | 2 + .../kotaemon/kotaemon/loaders/adobe_loader.py | 187 +++++++++++++ libs/kotaemon/kotaemon/loaders/utils/adobe.py | 248 ++++++++++++++++++ libs/kotaemon/kotaemon/loaders/utils/gpt4v.py | 96 +++++++ libs/kotaemon/pyproject.toml | 2 + .../kotaemon/tests/_test_multimodal_reader.py | 21 ++ libs/kotaemon/tests/resources/multimodal.pdf | Bin 0 -> 121892 bytes libs/ktem/flowsettings.py | 5 + libs/ktem/ktem/index/file/pipelines.py | 1 + libs/ktem/ktem/reasoning/simple.py | 119 ++++++--- 11 files changed, 654 insertions(+), 32 deletions(-) create mode 100644 libs/kotaemon/kotaemon/loaders/adobe_loader.py create mode 100644 libs/kotaemon/kotaemon/loaders/utils/adobe.py create mode 100644 libs/kotaemon/kotaemon/loaders/utils/gpt4v.py create mode 100644 libs/kotaemon/tests/_test_multimodal_reader.py create mode 100644 libs/kotaemon/tests/resources/multimodal.pdf diff --git a/libs/kotaemon/kotaemon/indices/ingests/files.py b/libs/kotaemon/kotaemon/indices/ingests/files.py index ed00e5cb7..75f944e4d 100644 --- a/libs/kotaemon/kotaemon/indices/ingests/files.py +++ b/libs/kotaemon/kotaemon/indices/ingests/files.py @@ -7,6 +7,7 @@ from kotaemon.indices.extractors import BaseDocParser from kotaemon.indices.splitters import BaseSplitter, TokenSplitter from kotaemon.loaders import ( + AdobeReader, DirectoryReader, MathpixPDFReader, OCRReader, @@ -41,7 +42,7 @@ class DocumentIngestor(BaseComponent): The default file extractors are stored in `KH_DEFAULT_FILE_EXTRACTORS` """ - pdf_mode: str = "normal" # "normal", "mathpix", "ocr" + pdf_mode: str = "normal" # "normal", "mathpix", "ocr", "multimodal" doc_parsers: list[BaseDocParser] = Param(default_callback=lambda _: []) text_splitter: BaseSplitter = TokenSplitter.withx( chunk_size=1024, @@ -61,6 +62,8 @@ def _get_reader(self, input_files: list[str | Path]): pass # use default loader of llama-index which is pypdf elif self.pdf_mode == "ocr": file_extractors[".pdf"] = OCRReader() + elif self.pdf_mode == "multimodal": + file_extractors[".pdf"] = AdobeReader() else: file_extractors[".pdf"] = MathpixPDFReader() diff --git a/libs/kotaemon/kotaemon/loaders/__init__.py b/libs/kotaemon/kotaemon/loaders/__init__.py index d742b52f2..28cb5f319 100644 --- a/libs/kotaemon/kotaemon/loaders/__init__.py +++ b/libs/kotaemon/kotaemon/loaders/__init__.py @@ -1,3 +1,4 @@ +from .adobe_loader import AdobeReader from .base import AutoReader, BaseReader from .composite_loader import DirectoryReader from .docx_loader import DocxReader @@ -17,4 +18,5 @@ "UnstructuredReader", "DocxReader", "HtmlReader", + "AdobeReader", ] diff --git a/libs/kotaemon/kotaemon/loaders/adobe_loader.py b/libs/kotaemon/kotaemon/loaders/adobe_loader.py new file mode 100644 index 000000000..dd8cbc910 --- /dev/null +++ b/libs/kotaemon/kotaemon/loaders/adobe_loader.py @@ -0,0 +1,187 @@ +import logging +import os +import re +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional + +from decouple import config +from llama_index.readers.base import BaseReader + +from kotaemon.base import Document + +from .utils.adobe import ( + generate_figure_captions, + load_json, + parse_figure_paths, + parse_table_paths, + request_adobe_service, +) + +logger = logging.getLogger(__name__) + +DEFAULT_VLM_ENDPOINT = ( + "{0}openai/deployments/{1}/chat/completions?api-version={2}".format( + config("AZURE_OPENAI_ENDPOINT", default=""), + "gpt-4-vision", + config("OPENAI_API_VERSION", default=""), + ) +) + + +class AdobeReader(BaseReader): + """Read PDF using the Adobe's PDF Services. + Be able to extract text, table, and figure with high accuracy + + Example: + ```python + >> from kotaemon.loaders import AdobeReader + >> reader = AdobeReader() + >> documents = reader.load_data("path/to/pdf") + ``` + Args: + endpoint: URL to the Vision Language Model endpoint. If not provided, + will use the default `kotaemon.loaders.adobe_loader.DEFAULT_VLM_ENDPOINT` + + max_figures_to_caption: an int decides how many figured will be captioned. + The rest will be ignored (are indexed without captions). + """ + + def __init__( + self, + vlm_endpoint: Optional[str] = None, + max_figures_to_caption: int = 100, + *args: Any, + **kwargs: Any, + ) -> None: + """Init params""" + super().__init__(*args) + self.table_regex = r"/Table(\[\d+\])?$" + self.figure_regex = r"/Figure(\[\d+\])?$" + self.vlm_endpoint = vlm_endpoint or DEFAULT_VLM_ENDPOINT + self.max_figures_to_caption = max_figures_to_caption + + def load_data( + self, file: Path, extra_info: Optional[Dict] = None, **kwargs + ) -> List[Document]: + """Load data by calling to the Adobe's API + + Args: + file (Path): Path to the PDF file + + Returns: + List[Document]: list of documents extracted from the PDF file, + includes 3 types: text, table, and image + + """ + + filename = file.name + filepath = str(Path(file).resolve()) + output_path = request_adobe_service(file_path=str(file), output_path="") + results_path = os.path.join(output_path, "structuredData.json") + + if not os.path.exists(results_path): + logger.exception("Fail to parse the document.") + return [] + + data = load_json(results_path) + + texts = defaultdict(list) + tables = [] + figures = [] + + elements = data["elements"] + for item_id, item in enumerate(elements): + page_number = item.get("Page", -1) + 1 + item_path = item["Path"] + item_text = item.get("Text", "") + + file_paths = [ + Path(output_path) / path for path in item.get("filePaths", []) + ] + prev_item = elements[item_id - 1] + title = prev_item.get("Text", "") + + if re.search(self.table_regex, item_path): + table_content = parse_table_paths(file_paths) + if not table_content: + continue + table_caption = ( + table_content.replace("|", "").replace("---", "") + + f"\n(Table in Page {page_number}. {title})" + ) + tables.append((page_number, table_content, table_caption)) + + elif re.search(self.figure_regex, item_path): + figure_caption = ( + item_text + f"\n(Figure in Page {page_number}. {title})" + ) + figure_content = parse_figure_paths(file_paths) + if not figure_content: + continue + figures.append([page_number, figure_content, figure_caption]) + + else: + if item_text and "Table" not in item_path and "Figure" not in item_path: + texts[page_number].append(item_text) + + # get figure caption using GPT-4V + figure_captions = generate_figure_captions( + self.vlm_endpoint, + [item[1] for item in figures], + self.max_figures_to_caption, + ) + for item, caption in zip(figures, figure_captions): + # update figure caption + item[2] += " " + caption + + # Wrap elements with Document + documents = [] + + # join plain text elements + for page_number, txts in texts.items(): + documents.append( + Document( + text="\n".join(txts), + metadata={ + "page_label": page_number, + "file_name": filename, + "file_path": filepath, + }, + ) + ) + + # table elements + for page_number, table_content, table_caption in tables: + documents.append( + Document( + text=table_caption, + metadata={ + "table_origin": table_content, + "type": "table", + "page_label": page_number, + "file_name": filename, + "file_path": filepath, + }, + metadata_template="", + metadata_seperator="", + ) + ) + + # figure elements + for page_number, figure_content, figure_caption in figures: + documents.append( + Document( + text=figure_caption, + metadata={ + "image_origin": figure_content, + "type": "image", + "page_label": page_number, + "file_name": filename, + "file_path": filepath, + }, + metadata_template="", + metadata_seperator="", + ) + ) + return documents diff --git a/libs/kotaemon/kotaemon/loaders/utils/adobe.py b/libs/kotaemon/kotaemon/loaders/utils/adobe.py new file mode 100644 index 000000000..a780c452b --- /dev/null +++ b/libs/kotaemon/kotaemon/loaders/utils/adobe.py @@ -0,0 +1,248 @@ +# need pip install pdfservices-sdk==2.3.0 + +import base64 +import json +import logging +import os +import tempfile +import zipfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import List, Union + +import pandas as pd +from decouple import config + +from kotaemon.loaders.utils.gpt4v import generate_gpt4v + +logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) + + +def request_adobe_service(file_path: str, output_path: str = "") -> str: + """Main function to call the adobe service, and unzip the results. + Args: + file_path (str): path to the pdf file + output_path (str): path to store the results + + Returns: + output_path (str): path to the results + + """ + try: + from adobe.pdfservices.operation.auth.credentials import Credentials + from adobe.pdfservices.operation.exception.exceptions import ( + SdkException, + ServiceApiException, + ServiceUsageException, + ) + from adobe.pdfservices.operation.execution_context import ExecutionContext + from adobe.pdfservices.operation.io.file_ref import FileRef + from adobe.pdfservices.operation.pdfops.extract_pdf_operation import ( + ExtractPDFOperation, + ) + from adobe.pdfservices.operation.pdfops.options.extractpdf.extract_element_type import ( # noqa: E501 + ExtractElementType, + ) + from adobe.pdfservices.operation.pdfops.options.extractpdf.extract_pdf_options import ( # noqa: E501 + ExtractPDFOptions, + ) + from adobe.pdfservices.operation.pdfops.options.extractpdf.extract_renditions_element_type import ( # noqa: E501 + ExtractRenditionsElementType, + ) + except ImportError: + raise ImportError( + "pdfservices-sdk is not installed. " + "Please install it by running `pip install pdfservices-sdk" + "@git+https://github.com/niallcm/pdfservices-python-sdk.git" + "@bump-and-unfreeze-requirements`" + ) + + if not output_path: + output_path = tempfile.mkdtemp() + + try: + # Initial setup, create credentials instance. + credentials = ( + Credentials.service_principal_credentials_builder() + .with_client_id(config("PDF_SERVICES_CLIENT_ID", default="")) + .with_client_secret(config("PDF_SERVICES_CLIENT_SECRET", default="")) + .build() + ) + + # Create an ExecutionContext using credentials + # and create a new operation instance. + execution_context = ExecutionContext.create(credentials) + extract_pdf_operation = ExtractPDFOperation.create_new() + + # Set operation input from a source file. + source = FileRef.create_from_local_file(file_path) + extract_pdf_operation.set_input(source) + + # Build ExtractPDF options and set them into the operation + extract_pdf_options: ExtractPDFOptions = ( + ExtractPDFOptions.builder() + .with_elements_to_extract( + [ExtractElementType.TEXT, ExtractElementType.TABLES] + ) + .with_elements_to_extract_renditions( + [ + ExtractRenditionsElementType.TABLES, + ExtractRenditionsElementType.FIGURES, + ] + ) + .build() + ) + extract_pdf_operation.set_options(extract_pdf_options) + + # Execute the operation. + result: FileRef = extract_pdf_operation.execute(execution_context) + + # Save the result to the specified location. + zip_file_path = os.path.join( + output_path, "ExtractTextTableWithFigureTableRendition.zip" + ) + result.save_as(zip_file_path) + # Open the ZIP file + with zipfile.ZipFile(zip_file_path, "r") as zip_ref: + # Extract all contents to the destination folder + zip_ref.extractall(output_path) + except (ServiceApiException, ServiceUsageException, SdkException): + logging.exception("Exception encountered while executing operation") + + return output_path + + +def make_markdown_table(table_as_list: List[str]) -> str: + """ + Convert table from python list representation to markdown format. + The input list consists of rows of tables, the first row is the header. + + Args: + table_as_list: list of table rows + Example: [["Name", "Age", "Height"], + ["Jake", 20, 5'10], + ["Mary", 21, 5'7]] + Returns: + markdown representation of the table + """ + markdown = "\n" + str("| ") + + for e in table_as_list[0]: + to_add = " " + str(e) + str(" |") + markdown += to_add + markdown += "\n" + + markdown += "| " + for i in range(len(table_as_list[0])): + markdown += str("--- | ") + markdown += "\n" + + for entry in table_as_list[1:]: + markdown += str("| ") + for e in entry: + to_add = str(e) + str(" | ") + markdown += to_add + markdown += "\n" + + return markdown + "\n" + + +def load_json(input_path: Union[str | Path]) -> dict: + """Load json file""" + with open(input_path, "r") as fi: + data = json.load(fi) + + return data + + +def load_excel(input_path: Union[str | Path]) -> str: + """Load excel file and convert to markdown""" + + df = pd.read_excel(input_path).fillna("") + # Convert dataframe to a list of rows + row_list = [df.columns.values.tolist()] + df.values.tolist() + + for item_id, item in enumerate(row_list[0]): + if "Unnamed" in item: + row_list[0][item_id] = "" + + for row in row_list: + for item_id, item in enumerate(row): + row[item_id] = str(item).replace("_x000D_", " ").replace("\n", " ").strip() + + markdown_str = make_markdown_table(row_list) + return markdown_str + + +def encode_image_base64(image_path: Union[str | Path]) -> Union[bytes, str]: + """Convert image to base64""" + + with open(image_path, "rb") as image_file: + return base64.b64encode(image_file.read()).decode("utf-8") + + +def parse_table_paths(file_paths: List[Path]) -> str: + """Read the table stored in an excel file given the file path""" + + content = "" + for path in file_paths: + if path.suffix == ".xlsx": + content = load_excel(path) + break + return content + + +def parse_figure_paths(file_paths: List[Path]) -> Union[bytes, str]: + """Read and convert an image to base64 given the image path""" + + content = "" + for path in file_paths: + if path.suffix == ".png": + base64_image = encode_image_base64(path) + content = f"data:image/png;base64,{base64_image}" # type: ignore + break + return content + + +def generate_single_figure_caption(vlm_endpoint: str, figure: str) -> str: + """Summarize a single figure using GPT-4V""" + if figure: + output = generate_gpt4v( + endpoint=vlm_endpoint, + prompt="Provide a short 2 sentence summary of this image?", + images=figure, + ) + if "sorry" in output.lower(): + output = "" + else: + output = "" + return output + + +def generate_figure_captions( + vlm_endpoint: str, figures: List, max_figures_to_process: int +) -> List: + """Summarize several figures using GPT-4V. + Args: + vlm_endpoint (str): endpoint to the vision language model service + figures (List): list of base64 images + max_figures_to_process (int): the maximum number of figures will be summarized, + the rest are ignored. + + Returns: + results (List[str]): list of all figure captions and empty strings for + ignored figures. + """ + to_gen_figures = figures[:max_figures_to_process] + other_figures = figures[max_figures_to_process:] + + with ThreadPoolExecutor() as executor: + futures = [ + executor.submit( + lambda: generate_single_figure_caption(vlm_endpoint, figure) + ) + for figure in to_gen_figures + ] + + results = [future.result() for future in futures] + return results + [""] * len(other_figures) diff --git a/libs/kotaemon/kotaemon/loaders/utils/gpt4v.py b/libs/kotaemon/kotaemon/loaders/utils/gpt4v.py new file mode 100644 index 000000000..1e219d660 --- /dev/null +++ b/libs/kotaemon/kotaemon/loaders/utils/gpt4v.py @@ -0,0 +1,96 @@ +import json +from typing import Any, List + +import requests +from decouple import config + + +def generate_gpt4v( + endpoint: str, images: str | List[str], prompt: str, max_tokens: int = 512 +) -> str: + # OpenAI API Key + api_key = config("AZURE_OPENAI_API_KEY", default="") + headers = {"Content-Type": "application/json", "api-key": api_key} + + if isinstance(images, str): + images = [images] + + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + ] + + [ + { + "type": "image_url", + "image_url": {"url": image}, + } + for image in images + ], + } + ], + "max_tokens": max_tokens, + } + + try: + response = requests.post(endpoint, headers=headers, json=payload) + output = response.json() + output = output["choices"][0]["message"]["content"] + except Exception: + output = "" + return output + + +def stream_gpt4v( + endpoint: str, images: str | List[str], prompt: str, max_tokens: int = 512 +) -> Any: + # OpenAI API Key + api_key = config("AZURE_OPENAI_API_KEY", default="") + headers = {"Content-Type": "application/json", "api-key": api_key} + + if isinstance(images, str): + images = [images] + + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + ] + + [ + { + "type": "image_url", + "image_url": {"url": image}, + } + for image in images + ], + } + ], + "max_tokens": max_tokens, + "stream": True, + } + try: + response = requests.post(endpoint, headers=headers, json=payload, stream=True) + assert response.status_code == 200, str(response.content) + output = "" + for line in response.iter_lines(): + if line: + if line.startswith(b"\xef\xbb\xbf"): + line = line[9:] + else: + line = line[6:] + try: + if line == "[DONE]": + break + line = json.loads(line.decode("utf-8")) + except Exception: + break + if len(line["choices"]): + output += line["choices"][0]["delta"].get("content", "") + yield line["choices"][0]["delta"].get("content", "") + except Exception: + output = "" + return output diff --git a/libs/kotaemon/pyproject.toml b/libs/kotaemon/pyproject.toml index e1e30280d..73c3e8ab7 100644 --- a/libs/kotaemon/pyproject.toml +++ b/libs/kotaemon/pyproject.toml @@ -60,6 +60,7 @@ adv = [ "cohere", "elasticsearch", "llama-cpp-python", + "pdfservices-sdk @ git+https://github.com/niallcm/pdfservices-python-sdk.git@bump-and-unfreeze-requirements", ] dev = [ "ipython", @@ -69,6 +70,7 @@ dev = [ "flake8", "sphinx", "coverage", + "python-decouple" ] all = ["kotaemon[adv,dev]"] diff --git a/libs/kotaemon/tests/_test_multimodal_reader.py b/libs/kotaemon/tests/_test_multimodal_reader.py new file mode 100644 index 000000000..b07786f03 --- /dev/null +++ b/libs/kotaemon/tests/_test_multimodal_reader.py @@ -0,0 +1,21 @@ +# TODO: This test is broken and should be rewritten +from pathlib import Path + +from kotaemon.loaders import AdobeReader + +# from dotenv import load_dotenv + + +input_file = Path(__file__).parent / "resources" / "multimodal.pdf" + +# load_dotenv() + + +def test_adobe_reader(): + reader = AdobeReader() + documents = reader.load_data(input_file) + table_docs = [doc for doc in documents if doc.metadata.get("type", "") == "table"] + assert len(table_docs) == 2 + + figure_docs = [doc for doc in documents if doc.metadata.get("type", "") == "image"] + assert len(figure_docs) == 2 diff --git a/libs/kotaemon/tests/resources/multimodal.pdf b/libs/kotaemon/tests/resources/multimodal.pdf new file mode 100644 index 0000000000000000000000000000000000000000..29c2bdc961a6b0378819f394099e6fc4b5be2bb3 GIT binary patch literal 121892 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 literal 0 HcmV?d00001 diff --git a/libs/ktem/flowsettings.py b/libs/ktem/flowsettings.py index a3589fec9..33ba88f66 100644 --- a/libs/ktem/flowsettings.py +++ b/libs/ktem/flowsettings.py @@ -124,6 +124,11 @@ KH_REASONINGS = ["ktem.reasoning.simple.FullQAPipeline"] +KH_VLM_ENDPOINT = "{0}/openai/deployments/{1}/chat/completions?api-version={2}".format( + config("AZURE_OPENAI_ENDPOINT", default=""), + config("OPENAI_VISION_DEPLOYMENT_NAME", default="gpt-4-vision"), + config("OPENAI_API_VERSION", default=""), +) SETTINGS_APP = { diff --git a/libs/ktem/ktem/index/file/pipelines.py b/libs/ktem/ktem/index/file/pipelines.py index 1d813f508..68b3a4d21 100644 --- a/libs/ktem/ktem/index/file/pipelines.py +++ b/libs/ktem/ktem/index/file/pipelines.py @@ -378,6 +378,7 @@ def get_user_settings(cls) -> dict: ("PDF text parser", "normal"), ("Mathpix", "mathpix"), ("Advanced ocr", "ocr"), + ("Multimodal parser", "multimodal"), ], "component": "dropdown", }, diff --git a/libs/ktem/ktem/reasoning/simple.py b/libs/ktem/ktem/reasoning/simple.py index 47f7c92d5..1627522d4 100644 --- a/libs/ktem/ktem/reasoning/simple.py +++ b/libs/ktem/ktem/reasoning/simple.py @@ -1,11 +1,14 @@ import asyncio +import html import logging +import re from collections import defaultdict from functools import partial import tiktoken from ktem.components import llms from ktem.reasoning.base import BaseReasoning +from theflow.settings import settings as flowsettings from kotaemon.base import ( BaseComponent, @@ -18,9 +21,15 @@ from kotaemon.indices.qa.citation import CitationPipeline from kotaemon.indices.splitters import TokenSplitter from kotaemon.llms import ChatLLM, PromptTemplate +from kotaemon.loaders.utils.gpt4v import stream_gpt4v logger = logging.getLogger(__name__) +EVIDENCE_MODE_TEXT = 0 +EVIDENCE_MODE_TABLE = 1 +EVIDENCE_MODE_CHATBOT = 2 +EVIDENCE_MODE_FIGURE = 3 + class PrepareEvidencePipeline(BaseComponent): """Prepare the evidence text from the list of retrieved documents @@ -46,7 +55,7 @@ class PrepareEvidencePipeline(BaseComponent): def run(self, docs: list[RetrievedDocument]) -> Document: evidence = "" table_found = 0 - evidence_mode = 0 + evidence_mode = EVIDENCE_MODE_TEXT for _id, retrieved_item in enumerate(docs): retrieved_content = "" @@ -55,7 +64,7 @@ def run(self, docs: list[RetrievedDocument]) -> Document: if page: source += f" (Page {page})" if retrieved_item.metadata.get("type", "") == "table": - evidence_mode = 1 # table + evidence_mode = EVIDENCE_MODE_TABLE if table_found < 5: retrieved_content = retrieved_item.metadata.get("table_origin", "") if retrieved_content not in evidence: @@ -66,13 +75,23 @@ def run(self, docs: list[RetrievedDocument]) -> Document: + "\n
" ) elif retrieved_item.metadata.get("type", "") == "chatbot": - evidence_mode = 2 # chatbot + evidence_mode = EVIDENCE_MODE_CHATBOT retrieved_content = retrieved_item.metadata["window"] evidence += ( f"
Chatbot scenario from {filename} (Row {page})\n" + retrieved_content + "\n
" ) + elif retrieved_item.metadata.get("type", "") == "image": + evidence_mode = EVIDENCE_MODE_FIGURE + retrieved_content = retrieved_item.metadata.get("image_origin", "") + retrieved_caption = html.escape(retrieved_item.get_content()) + evidence += ( + f"
Figure from {source}\n" + + f"" + + "\n
" + ) else: if "window" in retrieved_item.metadata: retrieved_content = retrieved_item.metadata["window"] @@ -90,12 +109,13 @@ def run(self, docs: list[RetrievedDocument]) -> Document: print(retrieved_item.metadata) print("Score", retrieved_item.metadata.get("relevance_score", None)) - # trim context by trim_len - print("len (original)", len(evidence)) - if evidence: - texts = self.trim_func([Document(text=evidence)]) - evidence = texts[0].text - print("len (trimmed)", len(evidence)) + if evidence_mode != EVIDENCE_MODE_FIGURE: + # trim context by trim_len + print("len (original)", len(evidence)) + if evidence: + texts = self.trim_func([Document(text=evidence)]) + evidence = texts[0].text + print("len (trimmed)", len(evidence)) print(f"PrepareEvidence with input {docs}\nOutput: {evidence}\n") @@ -134,6 +154,16 @@ def run(self, docs: list[RetrievedDocument]) -> Document: "Answer:" ) +DEFAULT_QA_FIGURE_PROMPT = ( + "Use the given context: texts, tables, and figures below to answer the question. " + "If you don't know the answer, just say that you don't know. " + "Give answer in {lang}.\n\n" + "Context: \n" + "{context}\n" + "Question: {question}\n" + "Answer: " +) + class AnswerWithContextPipeline(BaseComponent): """Answer the question based on the evidence @@ -151,6 +181,7 @@ class AnswerWithContextPipeline(BaseComponent): """ llm: ChatLLM = Node(default_callback=lambda _: llms.get_highest_accuracy()) + vlm_endpoint: str = flowsettings.KH_VLM_ENDPOINT citation_pipeline: CitationPipeline = Node( default_callback=lambda _: CitationPipeline(llm=llms.get_lowest_cost()) ) @@ -158,6 +189,7 @@ class AnswerWithContextPipeline(BaseComponent): qa_template: str = DEFAULT_QA_TEXT_PROMPT qa_table_template: str = DEFAULT_QA_TABLE_PROMPT qa_chatbot_template: str = DEFAULT_QA_CHATBOT_PROMPT + qa_figure_template: str = DEFAULT_QA_FIGURE_PROMPT enable_citation: bool = False system_prompt: str = "" @@ -188,18 +220,30 @@ async def run( # type: ignore (determined by retrieval pipeline) evidence_mode: the mode of evidence, 0 for text, 1 for table, 2 for chatbot """ - if evidence_mode == 0: + if evidence_mode == EVIDENCE_MODE_TEXT: prompt_template = PromptTemplate(self.qa_template) - elif evidence_mode == 1: + elif evidence_mode == EVIDENCE_MODE_TABLE: prompt_template = PromptTemplate(self.qa_table_template) + elif evidence_mode == EVIDENCE_MODE_FIGURE: + prompt_template = PromptTemplate(self.qa_figure_template) else: prompt_template = PromptTemplate(self.qa_chatbot_template) - prompt = prompt_template.populate( - context=evidence, - question=question, - lang=self.lang, - ) + images = [] + if evidence_mode == EVIDENCE_MODE_FIGURE: + # isolate image from evidence + evidence, images = self.extract_evidence_images(evidence) + prompt = prompt_template.populate( + context=evidence, + question=question, + lang=self.lang, + ) + else: + prompt = prompt_template.populate( + context=evidence, + question=question, + lang=self.lang, + ) citation_task = None if evidence and self.enable_citation: @@ -208,23 +252,29 @@ async def run( # type: ignore ) print("Citation task created") - messages = [] - if self.system_prompt: - messages.append(SystemMessage(content=self.system_prompt)) - messages.append(HumanMessage(content=prompt)) - output = "" - try: - # try streaming first - print("Trying LLM streaming") - for text in self.llm.stream(messages): - output += text.text - self.report_output({"output": text.text}) + if evidence_mode == EVIDENCE_MODE_FIGURE: + for text in stream_gpt4v(self.vlm_endpoint, images, prompt, max_tokens=768): + output += text + self.report_output({"output": text}) await asyncio.sleep(0) - except NotImplementedError: - print("Streaming is not supported, falling back to normal processing") - output = self.llm(messages).text - self.report_output({"output": output}) + else: + messages = [] + if self.system_prompt: + messages.append(SystemMessage(content=self.system_prompt)) + messages.append(HumanMessage(content=prompt)) + + try: + # try streaming first + print("Trying LLM streaming") + for text in self.llm.stream(messages): + output += text.text + self.report_output({"output": text.text}) + await asyncio.sleep(0) + except NotImplementedError: + print("Streaming is not supported, falling back to normal processing") + output = self.llm(messages).text + self.report_output({"output": output}) # retrieve the citation print("Waiting for citation task") @@ -237,6 +287,13 @@ async def run( # type: ignore return answer + def extract_evidence_images(self, evidence: str): + """Util function to extract and isolate images from context/evidence""" + image_pattern = r"src='(data:image\/[^;]+;base64[^']+)'" + matches = re.findall(image_pattern, evidence) + context = re.sub(image_pattern, "", evidence) + return context, matches + class FullQAPipeline(BaseReasoning): """Question answering pipeline. Handle from question to answer""" From 43a18ba07096aee4136578d0564940f1631f472f Mon Sep 17 00:00:00 2001 From: ian_Cin Date: Wed, 3 Apr 2024 15:37:55 +0700 Subject: [PATCH 2/3] Feat/regenerate answer (#7) * Add regen button and repharasing question on regen * Stop appending regen messages to history, allow only one * Add dynamic conversation state * Allow reasoning pipeline to manipulate state --------- Co-authored-by: albert Co-authored-by: Duc Nguyen (john) --- libs/kotaemon/kotaemon/loaders/utils/adobe.py | 2 - libs/ktem/ktem/app.py | 1 + libs/ktem/ktem/pages/chat/__init__.py | 89 ++++++++++++++++--- libs/ktem/ktem/pages/chat/chat_panel.py | 1 + libs/ktem/ktem/pages/chat/common.py | 4 + libs/ktem/ktem/pages/chat/control.py | 6 +- libs/ktem/ktem/pages/chat/report.py | 2 + libs/ktem/ktem/reasoning/simple.py | 70 +++++++++++++-- 8 files changed, 151 insertions(+), 24 deletions(-) create mode 100644 libs/ktem/ktem/pages/chat/common.py diff --git a/libs/kotaemon/kotaemon/loaders/utils/adobe.py b/libs/kotaemon/kotaemon/loaders/utils/adobe.py index a780c452b..f1adcd5a7 100644 --- a/libs/kotaemon/kotaemon/loaders/utils/adobe.py +++ b/libs/kotaemon/kotaemon/loaders/utils/adobe.py @@ -15,8 +15,6 @@ from kotaemon.loaders.utils.gpt4v import generate_gpt4v -logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) - def request_adobe_service(file_path: str, output_path: str = "") -> str: """Main function to call the adobe service, and unzip the results. diff --git a/libs/ktem/ktem/app.py b/libs/ktem/ktem/app.py index 0a39fa60b..9bac90408 100644 --- a/libs/ktem/ktem/app.py +++ b/libs/ktem/ktem/app.py @@ -17,6 +17,7 @@ class BaseApp: The main application contains app-level information: - setting state + - dynamic conversation state - user id Also contains registering methods for: diff --git a/libs/ktem/ktem/pages/chat/__init__.py b/libs/ktem/ktem/pages/chat/__init__.py index 6648c2fbc..d2bba877b 100644 --- a/libs/ktem/ktem/pages/chat/__init__.py +++ b/libs/ktem/ktem/pages/chat/__init__.py @@ -9,6 +9,7 @@ from sqlmodel import Session, select from .chat_panel import ChatPanel +from .common import STATE from .control import ConversationControl from .report import ReportIssue @@ -21,6 +22,7 @@ def __init__(self, app): def on_building_ui(self): with gr.Row(): + self.chat_state = gr.State(STATE) with gr.Column(scale=1): self.chat_control = ConversationControl(self._app) @@ -62,12 +64,13 @@ def on_register_events(self): self.chat_control.conversation_id, self.chat_panel.chatbot, self._app.settings_state, + self.chat_state, ] + self._indices_input, outputs=[ - self.chat_panel.text_input, self.chat_panel.chatbot, self.info_panel, + self.chat_state, ], show_progress="minimal", ).then( @@ -75,6 +78,33 @@ def on_register_events(self): inputs=[ self.chat_control.conversation_id, self.chat_panel.chatbot, + self.chat_state, + ] + + self._indices_input, + outputs=None, + ) + + self.chat_panel.regen_btn.click( + fn=self.regen_fn, + inputs=[ + self.chat_control.conversation_id, + self.chat_panel.chatbot, + self._app.settings_state, + self.chat_state, + ] + + self._indices_input, + outputs=[ + self.chat_panel.chatbot, + self.info_panel, + self.chat_state, + ], + show_progress="minimal", + ).then( + fn=self.update_data_source, + inputs=[ + self.chat_control.conversation_id, + self.chat_panel.chatbot, + self.chat_state, ] + self._indices_input, outputs=None, @@ -94,6 +124,7 @@ def on_register_events(self): self.chat_control.conversation, self.chat_control.conversation_rn, self.chat_panel.chatbot, + self.chat_state, ] + self._indices_input, show_progress="hidden", @@ -109,12 +140,13 @@ def on_register_events(self): self.chat_panel.chatbot, self._app.settings_state, self._app.user_id, + self.chat_state, ] + self._indices_input, outputs=None, ) - def update_data_source(self, convo_id, messages, *selecteds): + def update_data_source(self, convo_id, messages, state, *selecteds): """Update the data source""" if not convo_id: gr.Warning("No conversation selected") @@ -133,6 +165,7 @@ def update_data_source(self, convo_id, messages, *selecteds): result.data_source = { "selected": selecteds_, "messages": messages, + "state": state, "likes": deepcopy(data_source.get("likes", [])), } session.add(result) @@ -152,17 +185,22 @@ def is_liked(self, convo_id, liked: gr.LikeData): session.add(result) session.commit() - def create_pipeline(self, settings: dict, *selecteds): + def create_pipeline(self, settings: dict, state: dict, *selecteds): """Create the pipeline from settings Args: settings: the settings of the app + is_regen: whether the regen button is clicked selected: the list of file ids that will be served as context. If None, then consider using all files Returns: - the pipeline objects + - the pipeline objects """ + reasoning_mode = settings["reasoning.use"] + reasoning_cls = reasonings[reasoning_mode] + reasoning_id = reasoning_cls.get_info()["id"] + # get retrievers retrievers = [] for index in self._app.index_manager.indices: @@ -172,13 +210,17 @@ def create_pipeline(self, settings: dict, *selecteds): iretrievers = index.get_retriever_pipelines(settings, index_selected) retrievers += iretrievers - reasoning_mode = settings["reasoning.use"] - reasoning_cls = reasonings[reasoning_mode] - pipeline = reasoning_cls.get_pipeline(settings, retrievers) + # prepare states + reasoning_state = { + "app": deepcopy(state["app"]), + "pipeline": deepcopy(state.get(reasoning_id, {})), + } - return pipeline + pipeline = reasoning_cls.get_pipeline(settings, reasoning_state, retrievers) - async def chat_fn(self, conversation_id, chat_history, settings, *selecteds): + return pipeline, reasoning_state + + async def chat_fn(self, conversation_id, chat_history, settings, state, *selecteds): """Chat function""" chat_input = chat_history[-1][0] chat_history = chat_history[:-1] @@ -186,7 +228,7 @@ async def chat_fn(self, conversation_id, chat_history, settings, *selecteds): queue: asyncio.Queue[Optional[dict]] = asyncio.Queue() # construct the pipeline - pipeline = self.create_pipeline(settings, *selecteds) + pipeline, reasoning_state = self.create_pipeline(settings, state, *selecteds) pipeline.set_output_queue(queue) asyncio.create_task(pipeline(chat_input, conversation_id, chat_history)) @@ -198,7 +240,8 @@ async def chat_fn(self, conversation_id, chat_history, settings, *selecteds): try: response = queue.get_nowait() except Exception: - yield "", chat_history + [(chat_input, text or "Thinking ...")], refs + state[pipeline.get_info()["id"]] = reasoning_state["pipeline"] + yield chat_history + [(chat_input, text or "Thinking ...")], refs, state continue if response is None: @@ -208,6 +251,7 @@ async def chat_fn(self, conversation_id, chat_history, settings, *selecteds): if "output" in response: text += response["output"] + if "evidence" in response: if response["evidence"] is None: refs = "" @@ -218,4 +262,25 @@ async def chat_fn(self, conversation_id, chat_history, settings, *selecteds): print(f"Len refs: {len(refs)}") len_ref = len(refs) - yield "", chat_history + [(chat_input, text)], refs + state[pipeline.get_info()["id"]] = reasoning_state["pipeline"] + yield chat_history + [(chat_input, text)], refs, state + + async def regen_fn( + self, conversation_id, chat_history, settings, state, *selecteds + ): + """Regen function""" + if not chat_history: + gr.Warning("Empty chat") + yield chat_history, "", state + return + + state["app"]["regen"] = True + async for chat, refs, state in self.chat_fn( + conversation_id, chat_history, settings, state, *selecteds + ): + new_state = deepcopy(state) + new_state["app"]["regen"] = False + yield chat, refs, new_state + else: + state["app"]["regen"] = False + yield chat_history, "", state diff --git a/libs/ktem/ktem/pages/chat/chat_panel.py b/libs/ktem/ktem/pages/chat/chat_panel.py index f4cfc5bbe..55b9258e9 100644 --- a/libs/ktem/ktem/pages/chat/chat_panel.py +++ b/libs/ktem/ktem/pages/chat/chat_panel.py @@ -19,6 +19,7 @@ def on_building_ui(self): placeholder="Chat input", scale=15, container=False ) self.submit_btn = gr.Button(value="Send", scale=1, min_width=10) + self.regen_btn = gr.Button(value="Regen", scale=1, min_width=10) def submit_msg(self, chat_input, chat_history): """Submit a message to the chatbot""" diff --git a/libs/ktem/ktem/pages/chat/common.py b/libs/ktem/ktem/pages/chat/common.py new file mode 100644 index 000000000..a2fc0dcee --- /dev/null +++ b/libs/ktem/ktem/pages/chat/common.py @@ -0,0 +1,4 @@ +DEFAULT_APPLICATION_STATE = {"regen": False} +STATE = { + "app": DEFAULT_APPLICATION_STATE, +} diff --git a/libs/ktem/ktem/pages/chat/control.py b/libs/ktem/ktem/pages/chat/control.py index a0b256125..e71411260 100644 --- a/libs/ktem/ktem/pages/chat/control.py +++ b/libs/ktem/ktem/pages/chat/control.py @@ -5,6 +5,8 @@ from ktem.db.models import Conversation, engine from sqlmodel import Session, select +from .common import STATE + logger = logging.getLogger(__name__) @@ -159,12 +161,14 @@ def select_conv(self, conversation_id): name = result.name selected = result.data_source.get("selected", {}) chats = result.data_source.get("messages", []) + state = result.data_source.get("state", STATE) except Exception as e: logger.warning(e) id_ = "" name = "" selected = {} chats = [] + state = STATE indices = [] for index in self._app.index_manager.indices: @@ -173,7 +177,7 @@ def select_conv(self, conversation_id): continue indices.append(selected.get(str(index.id), [])) - return id_, id_, name, chats, *indices + return id_, id_, name, chats, state, *indices def rename_conv(self, conversation_id, new_name, user_id): """Rename the conversation""" diff --git a/libs/ktem/ktem/pages/chat/report.py b/libs/ktem/ktem/pages/chat/report.py index 46d9e3c84..25d83f844 100644 --- a/libs/ktem/ktem/pages/chat/report.py +++ b/libs/ktem/ktem/pages/chat/report.py @@ -48,6 +48,7 @@ def report( chat_history: list, settings: dict, user_id: Optional[int], + chat_state: dict, *selecteds ): selecteds_ = {} @@ -65,6 +66,7 @@ def report( chat={ "conv_id": conv_id, "chat_history": chat_history, + "chat_state": chat_state, "selecteds": selecteds_, }, settings=settings, diff --git a/libs/ktem/ktem/reasoning/simple.py b/libs/ktem/ktem/reasoning/simple.py index 1627522d4..23d8363bb 100644 --- a/libs/ktem/ktem/reasoning/simple.py +++ b/libs/ktem/ktem/reasoning/simple.py @@ -7,7 +7,6 @@ import tiktoken from ktem.components import llms -from ktem.reasoning.base import BaseReasoning from theflow.settings import settings as flowsettings from kotaemon.base import ( @@ -164,6 +163,15 @@ def run(self, docs: list[RetrievedDocument]) -> Document: "Answer: " ) +DEFAULT_REWRITE_PROMPT = ( + "Given the following question, rephrase and expand it " + "to help you do better answering. Maintain all information " + "in the original question. Keep the question as concise as possible. " + "Give answer in {lang}\n" + "Original question: {question}\n" + "Rephrased question: " +) + class AnswerWithContextPipeline(BaseComponent): """Answer the question based on the evidence @@ -287,15 +295,48 @@ async def run( # type: ignore return answer - def extract_evidence_images(self, evidence: str): - """Util function to extract and isolate images from context/evidence""" - image_pattern = r"src='(data:image\/[^;]+;base64[^']+)'" - matches = re.findall(image_pattern, evidence) - context = re.sub(image_pattern, "", evidence) - return context, matches +def extract_evidence_images(self, evidence: str): + """Util function to extract and isolate images from context/evidence""" + image_pattern = r"src='(data:image\/[^;]+;base64[^']+)'" + matches = re.findall(image_pattern, evidence) + context = re.sub(image_pattern, "", evidence) + return context, matches + + +class RewriteQuestionPipeline(BaseComponent): + """Rewrite user question + + Args: + llm: the language model to rewrite question + rewrite_template: the prompt template for llm to paraphrase a text input + lang: the language of the answer. Currently support English and Japanese + """ + + llm: ChatLLM = Node(default_callback=lambda _: llms.get_lowest_cost()) + rewrite_template: str = DEFAULT_REWRITE_PROMPT + + lang: str = "English" + + async def run(self, question: str) -> Document: # type: ignore + prompt_template = PromptTemplate(self.rewrite_template) + prompt = prompt_template.populate(question=question, lang=self.lang) + messages = [ + SystemMessage(content="You are a helpful assistant"), + HumanMessage(content=prompt), + ] + output = "" + for text in self.llm(messages): + if "content" in text: + output += text[1] + self.report_output({"chat_input": text[1]}) + break + await asyncio.sleep(0) + + return Document(text=output) -class FullQAPipeline(BaseReasoning): + +class FullQAPipeline(BaseComponent): """Question answering pipeline. Handle from question to answer""" class Config: @@ -305,12 +346,18 @@ class Config: evidence_pipeline: PrepareEvidencePipeline = PrepareEvidencePipeline.withx() answering_pipeline: AnswerWithContextPipeline = AnswerWithContextPipeline.withx() + rewrite_pipeline: RewriteQuestionPipeline = RewriteQuestionPipeline.withx() + use_rewrite: bool = False async def run( # type: ignore self, message: str, conv_id: str, history: list, **kwargs # type: ignore ) -> Document: # type: ignore docs = [] doc_ids = [] + if self.use_rewrite: + rewrite = await self.rewrite_pipeline(question=message) + message = rewrite.text + for retriever in self.retrievers: for doc in retriever(text=message): if doc.doc_id not in doc_ids: @@ -402,7 +449,7 @@ async def run( # type: ignore return answer @classmethod - def get_pipeline(cls, settings, retrievers): + def get_pipeline(cls, settings, states, retrievers): """Get the reasoning pipeline Args: @@ -430,6 +477,11 @@ def get_pipeline(cls, settings, retrievers): pipeline.answering_pipeline.qa_template = settings[ f"reasoning.options.{_id}.qa_prompt" ] + pipeline.use_rewrite = states.get("app", {}).get("regen", False) + pipeline.rewrite_pipeline.llm = llms.get_lowest_cost() + pipeline.rewrite_pipeline.lang = {"en": "English", "ja": "Japanese"}.get( + settings["reasoning.lang"], "English" + ) return pipeline @classmethod From ecf09b275fc6bd6a4348f63906be7c7d7e99595b Mon Sep 17 00:00:00 2001 From: ian_Cin Date: Wed, 3 Apr 2024 16:33:54 +0700 Subject: [PATCH 3/3] Fix UI bugs (#8) * Auto create conversation when the user starts * Add conversation rename rule check * Fix empty name during save * Confirm deleting conversation * Show warning if users don't select file when upload files in the File Index * Feedback when user uploads duplicated file * Limit the file types * Fix valid username * Allow login when username with leading and trailing whitespaces * Improve the user * Disable admin panel for non-admnin user * Refresh user lists after creating/deleting users * Auto logging in * Clear admin information upon signing out * Fix unable to receive uploaded filename that include special characters, like !@#$%^&*().pdf * Set upload validation for FileIndex * Improve user management UI/UIX * Show extraction error when indexing file * Return selected user -1 when signing out * Fix default supported file types in file index * Validate changing password * Allow the selector to contain mulitple gradio components * A more tolerable placeholder screen * Allow chat suggestion box * Increase concurrency limit * Make adobe loader optional * Use BaseReasoning --------- Co-authored-by: trducng --- .gitignore | 1 + .pre-commit-config.yaml | 7 +- libs/kotaemon/kotaemon/indices/qa/citation.py | 14 +- libs/kotaemon/kotaemon/loaders/__init__.py | 3 +- .../kotaemon/kotaemon/loaders/adobe_loader.py | 15 +- libs/kotaemon/kotaemon/loaders/ocr_loader.py | 67 ++++++ libs/ktem/ktem/app.py | 4 +- libs/ktem/ktem/index/base.py | 6 +- libs/ktem/ktem/index/file/base.py | 8 + libs/ktem/ktem/index/file/index.py | 140 +++++++++++-- libs/ktem/ktem/index/file/pipelines.py | 63 +++++- libs/ktem/ktem/index/file/ui.py | 140 ++++++++++--- libs/ktem/ktem/index/manager.py | 32 ++- libs/ktem/ktem/main.py | 38 +++- libs/ktem/ktem/pages/admin/user.py | 187 +++++++++++------ libs/ktem/ktem/pages/chat/__init__.py | 198 ++++++++++++++++-- libs/ktem/ktem/pages/chat/chat_suggestion.py | 26 +++ libs/ktem/ktem/pages/chat/control.py | 97 +++++---- libs/ktem/ktem/pages/chat/report.py | 13 +- libs/ktem/ktem/pages/login.py | 51 ++++- libs/ktem/ktem/pages/settings.py | 17 +- libs/ktem/ktem/reasoning/base.py | 6 +- libs/ktem/ktem/reasoning/simple.py | 58 +++-- 23 files changed, 936 insertions(+), 255 deletions(-) create mode 100644 libs/ktem/ktem/pages/chat/chat_suggestion.py diff --git a/.gitignore b/.gitignore index 01142788f..5c91c3e90 100644 --- a/.gitignore +++ b/.gitignore @@ -452,6 +452,7 @@ $RECYCLE.BIN/ .theflow/ # End of https://www.toptal.com/developers/gitignore/api/python,linux,macos,windows,vim,emacs,visualstudiocode,pycharm +*.py[coid] logs/ .gitsecret/keys/random_seed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 21356cef6..3f68b56f3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,7 +52,12 @@ repos: hooks: - id: mypy additional_dependencies: - [types-PyYAML==6.0.12.11, "types-requests", "sqlmodel"] + [ + types-PyYAML==6.0.12.11, + "types-requests", + "sqlmodel", + "types-Markdown", + ] args: ["--check-untyped-defs", "--ignore-missing-imports"] exclude: "^templates/" - repo: https://github.com/codespell-project/codespell diff --git a/libs/kotaemon/kotaemon/indices/qa/citation.py b/libs/kotaemon/kotaemon/indices/qa/citation.py index f1a53c797..3192a07fa 100644 --- a/libs/kotaemon/kotaemon/indices/qa/citation.py +++ b/libs/kotaemon/kotaemon/indices/qa/citation.py @@ -104,18 +104,16 @@ def invoke(self, context: str, question: str): print("CitationPipeline: invoking LLM") llm_output = self.get_from_path("llm").invoke(messages, **llm_kwargs) print("CitationPipeline: finish invoking LLM") + if not llm_output.messages: + return None + function_output = llm_output.messages[0].additional_kwargs["function_call"][ + "arguments" + ] + output = QuestionAnswer.parse_raw(function_output) except Exception as e: print(e) return None - if not llm_output.messages: - return None - - function_output = llm_output.messages[0].additional_kwargs["function_call"][ - "arguments" - ] - output = QuestionAnswer.parse_raw(function_output) - return output async def ainvoke(self, context: str, question: str): diff --git a/libs/kotaemon/kotaemon/loaders/__init__.py b/libs/kotaemon/kotaemon/loaders/__init__.py index 28cb5f319..a59d71315 100644 --- a/libs/kotaemon/kotaemon/loaders/__init__.py +++ b/libs/kotaemon/kotaemon/loaders/__init__.py @@ -5,7 +5,7 @@ from .excel_loader import PandasExcelReader from .html_loader import HtmlReader from .mathpix_loader import MathpixPDFReader -from .ocr_loader import OCRReader +from .ocr_loader import ImageReader, OCRReader from .unstructured_loader import UnstructuredReader __all__ = [ @@ -13,6 +13,7 @@ "BaseReader", "PandasExcelReader", "MathpixPDFReader", + "ImageReader", "OCRReader", "DirectoryReader", "UnstructuredReader", diff --git a/libs/kotaemon/kotaemon/loaders/adobe_loader.py b/libs/kotaemon/kotaemon/loaders/adobe_loader.py index dd8cbc910..09a802c37 100644 --- a/libs/kotaemon/kotaemon/loaders/adobe_loader.py +++ b/libs/kotaemon/kotaemon/loaders/adobe_loader.py @@ -10,14 +10,6 @@ from kotaemon.base import Document -from .utils.adobe import ( - generate_figure_captions, - load_json, - parse_figure_paths, - parse_table_paths, - request_adobe_service, -) - logger = logging.getLogger(__name__) DEFAULT_VLM_ENDPOINT = ( @@ -74,6 +66,13 @@ def load_data( includes 3 types: text, table, and image """ + from .utils.adobe import ( + generate_figure_captions, + load_json, + parse_figure_paths, + parse_table_paths, + request_adobe_service, + ) filename = file.name filepath = str(Path(file).resolve()) diff --git a/libs/kotaemon/kotaemon/loaders/ocr_loader.py b/libs/kotaemon/kotaemon/loaders/ocr_loader.py index e68971768..bb1ac5dca 100644 --- a/libs/kotaemon/kotaemon/loaders/ocr_loader.py +++ b/libs/kotaemon/kotaemon/loaders/ocr_loader.py @@ -125,3 +125,70 @@ def load_data( ) return documents + + +class ImageReader(BaseReader): + """Read PDF using OCR, with high focus on table extraction + + Example: + ```python + >> from knowledgehub.loaders import OCRReader + >> reader = OCRReader() + >> documents = reader.load_data("path/to/pdf") + ``` + + Args: + endpoint: URL to FullOCR endpoint. If not provided, will look for + environment variable `OCR_READER_ENDPOINT` or use the default + `knowledgehub.loaders.ocr_loader.DEFAULT_OCR_ENDPOINT` + (http://127.0.0.1:8000/v2/ai/infer/) + use_ocr: whether to use OCR to read text (e.g: from images, tables) in the PDF + If False, only the table and text within table cells will be extracted. + """ + + def __init__(self, endpoint: Optional[str] = None): + """Init the OCR reader with OCR endpoint (FullOCR pipeline)""" + super().__init__() + self.ocr_endpoint = endpoint or os.getenv( + "OCR_READER_ENDPOINT", DEFAULT_OCR_ENDPOINT + ) + + def load_data( + self, file_path: Path, extra_info: Optional[dict] = None, **kwargs + ) -> List[Document]: + """Load data using OCR reader + + Args: + file_path (Path): Path to PDF file + debug_path (Path): Path to store debug image output + artifact_path (Path): Path to OCR endpoints artifacts directory + + Returns: + List[Document]: list of documents extracted from the PDF file + """ + file_path = Path(file_path).resolve() + + with file_path.open("rb") as content: + files = {"input": content} + data = {"job_id": uuid4(), "table_only": False} + + # call the API from FullOCR endpoint + if "response_content" in kwargs: + # overriding response content if specified + ocr_results = kwargs["response_content"] + else: + # call original API + resp = tenacious_api_post(url=self.ocr_endpoint, files=files, data=data) + ocr_results = resp.json()["result"] + + extra_info = extra_info or {} + result = [] + for ocr_result in ocr_results: + result.append( + Document( + content=ocr_result["csv_string"], + metadata=extra_info, + ) + ) + + return result diff --git a/libs/ktem/ktem/app.py b/libs/ktem/ktem/app.py index 9bac90408..64e8a9da0 100644 --- a/libs/ktem/ktem/app.py +++ b/libs/ktem/ktem/app.py @@ -229,7 +229,9 @@ def on_register_events(self): def _on_app_created(self): """Called when the app is created""" - def as_gradio_component(self) -> Optional[gr.components.Component]: + def as_gradio_component( + self, + ) -> Optional[gr.components.Component | list[gr.components.Component]]: """Return the gradio components responsible for events Note: in ideal scenario, this method shouldn't be necessary. diff --git a/libs/ktem/ktem/index/base.py b/libs/ktem/ktem/index/base.py index 50bdd9e44..518376264 100644 --- a/libs/ktem/ktem/index/base.py +++ b/libs/ktem/ktem/index/base.py @@ -1,6 +1,6 @@ import abc import logging -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from ktem.app import BasePage @@ -57,7 +57,7 @@ def __init__(self, app, id, name, config): self._app = app self.id = id self.name = name - self._config = config # admin settings + self.config = config # admin settings def on_create(self): """Create the index for the first time""" @@ -121,7 +121,7 @@ def get_indexing_pipeline(self, settings: dict) -> "BaseComponent": ... def get_retriever_pipelines( - self, settings: dict, selected: Optional[list] + self, settings: dict, selected: Any = None ) -> list["BaseComponent"]: """Return the retriever pipelines to retrieve the entity from the index""" return [] diff --git a/libs/ktem/ktem/index/file/base.py b/libs/ktem/ktem/index/file/base.py index 5f8e6f4fa..4f28f51ac 100644 --- a/libs/ktem/ktem/index/file/base.py +++ b/libs/ktem/ktem/index/file/base.py @@ -127,3 +127,11 @@ def get_filestorage_path(self, rel_paths: str | list[str]) -> list[str]: the absolute file storage path to the file """ raise NotImplementedError + + def warning(self, msg): + """Log a warning message + + Args: + msg: the message to log + """ + print(msg) diff --git a/libs/ktem/ktem/index/file/index.py b/libs/ktem/ktem/index/file/index.py index ab1f35a3a..5fe395596 100644 --- a/libs/ktem/ktem/index/file/index.py +++ b/libs/ktem/ktem/index/file/index.py @@ -13,7 +13,6 @@ from kotaemon.storages import BaseDocumentStore, BaseVectorStore from .base import BaseFileIndexIndexing, BaseFileIndexRetriever -from .ui import FileIndexPage, FileSelector class FileIndex(BaseIndex): @@ -77,9 +76,15 @@ def __init__(self, app, id: int, name: str, config: dict): self._indexing_pipeline_cls: Type[BaseFileIndexIndexing] self._retriever_pipeline_cls: list[Type[BaseFileIndexRetriever]] + self._selector_ui_cls: Type + self._selector_ui: Any = None + self._index_ui_cls: Type + self._index_ui: Any = None self._setup_indexing_cls() self._setup_retriever_cls() + self._setup_file_index_ui_cls() + self._setup_file_selector_ui_cls() self._default_settings: dict[str, dict] = {} self._setting_mappings: dict[str, dict] = {} @@ -91,14 +96,14 @@ def _setup_indexing_cls(self): The indexing class will is retrieved from the following order. Stop at the first order found: - - `FILE_INDEX_PIPELINE` in self._config + - `FILE_INDEX_PIPELINE` in self.config - `FILE_INDEX_{id}_PIPELINE` in the flowsettings - `FILE_INDEX_PIPELINE` in the flowsettings - The default .pipelines.IndexDocumentPipeline """ - if "FILE_INDEX_PIPELINE" in self._config: + if "FILE_INDEX_PIPELINE" in self.config: self._indexing_pipeline_cls = import_dotted_string( - self._config["FILE_INDEX_PIPELINE"], safe=False + self.config["FILE_INDEX_PIPELINE"], safe=False ) return @@ -125,15 +130,15 @@ def _setup_retriever_cls(self): The retriever classes will is retrieved from the following order. Stop at the first order found: - - `FILE_INDEX_RETRIEVER_PIPELINES` in self._config + - `FILE_INDEX_RETRIEVER_PIPELINES` in self.config - `FILE_INDEX_{id}_RETRIEVER_PIPELINES` in the flowsettings - `FILE_INDEX_RETRIEVER_PIPELINES` in the flowsettings - The default .pipelines.DocumentRetrievalPipeline """ - if "FILE_INDEX_RETRIEVER_PIPELINES" in self._config: + if "FILE_INDEX_RETRIEVER_PIPELINES" in self.config: self._retriever_pipeline_cls = [ import_dotted_string(each, safe=False) - for each in self._config["FILE_INDEX_RETRIEVER_PIPELINES"] + for each in self.config["FILE_INDEX_RETRIEVER_PIPELINES"] ] return @@ -157,6 +162,76 @@ def _setup_retriever_cls(self): self._retriever_pipeline_cls = [DocumentRetrievalPipeline] + def _setup_file_selector_ui_cls(self): + """Retrieve the file selector UI for the file index + + There can be multiple retriever classes. + + The retriever classes will is retrieved from the following order. Stop at the + first order found: + - `FILE_INDEX_SELECTOR_UI` in self.config + - `FILE_INDEX_{id}_SELECTOR_UI` in the flowsettings + - `FILE_INDEX_SELECTOR_UI` in the flowsettings + - The default .ui.FileSelector + """ + if "FILE_INDEX_SELECTOR_UI" in self.config: + self._selector_ui_cls = import_dotted_string( + self.config["FILE_INDEX_SELECTOR_UI"], safe=False + ) + return + + if hasattr(flowsettings, f"FILE_INDEX_{self.id}_SELECTOR_UI"): + self._selector_ui_cls = import_dotted_string( + getattr(flowsettings, f"FILE_INDEX_{self.id}_SELECTOR_UI"), + safe=False, + ) + return + + if hasattr(flowsettings, "FILE_INDEX_SELECTOR_UI"): + self._selector_ui_cls = import_dotted_string( + getattr(flowsettings, "FILE_INDEX_SELECTOR_UI"), safe=False + ) + return + + from .ui import FileSelector + + self._selector_ui_cls = FileSelector + + def _setup_file_index_ui_cls(self): + """Retrieve the Index UI class + + There can be multiple retriever classes. + + The retriever classes will is retrieved from the following order. Stop at the + first order found: + - `FILE_INDEX_UI` in self.config + - `FILE_INDEX_{id}_UI` in the flowsettings + - `FILE_INDEX_UI` in the flowsettings + - The default .ui.FileIndexPage + """ + if "FILE_INDEX_UI" in self.config: + self._index_ui_cls = import_dotted_string( + self.config["FILE_INDEX_UI"], safe=False + ) + return + + if hasattr(flowsettings, f"FILE_INDEX_{self.id}_UI"): + self._index_ui_cls = import_dotted_string( + getattr(flowsettings, f"FILE_INDEX_{self.id}_UI"), + safe=False, + ) + return + + if hasattr(flowsettings, "FILE_INDEX_UI"): + self._index_ui_cls = import_dotted_string( + getattr(flowsettings, "FILE_INDEX_UI"), safe=False + ) + return + + from .ui import FileIndexPage + + self._index_ui_cls = FileIndexPage + def on_create(self): """Create the index for the first time @@ -165,6 +240,13 @@ def on_create(self): 2. Create the vectorstore 3. Create the docstore """ + file_types_str = self.config.get( + "supported_file_types", + self.get_admin_settings()["supported_file_types"]["value"], + ) + file_types = [each.strip() for each in file_types_str.split(",")] + self.config["supported_file_types"] = file_types + self._resources["Source"].metadata.create_all(engine) # type: ignore self._resources["Index"].metadata.create_all(engine) # type: ignore self._fs_path.mkdir(parents=True, exist_ok=True) @@ -180,10 +262,14 @@ def on_delete(self): shutil.rmtree(self._fs_path) def get_selector_component_ui(self): - return FileSelector(self._app, self) + if self._selector_ui is None: + self._selector_ui = self._selector_ui_cls(self._app, self) + return self._selector_ui def get_index_page_ui(self): - return FileIndexPage(self._app, self) + if self._index_ui is None: + self._index_ui = self._index_ui_cls(self._app, self) + return self._index_ui def get_user_settings(self): if self._default_settings: @@ -210,7 +296,31 @@ def get_admin_settings(cls): "value": embedding_default, "component": "dropdown", "choices": embedding_choices, - } + }, + "supported_file_types": { + "name": "Supported file types", + "value": ( + "image, .pdf, .txt, .csv, .xlsx, .doc, .docx, .pptx, .html, .zip" + ), + "component": "text", + }, + "max_file_size": { + "name": "Max file size (MB) - set 0 to disable", + "value": 1000, + "component": "number", + }, + "max_number_of_files": { + "name": "Max number of files that can be indexed - set 0 to disable", + "value": 0, + "component": "number", + }, + "max_number_of_text_length": { + "name": ( + "Max amount of characters that can be indexed - set 0 to disable" + ), + "value": 0, + "component": "number", + }, } def get_indexing_pipeline(self, settings) -> BaseFileIndexIndexing: @@ -224,14 +334,15 @@ def get_indexing_pipeline(self, settings) -> BaseFileIndexIndexing: else: stripped_settings[key] = value - obj = self._indexing_pipeline_cls.get_pipeline(stripped_settings, self._config) + obj = self._indexing_pipeline_cls.get_pipeline(stripped_settings, self.config) obj.set_resources(resources=self._resources) return obj def get_retriever_pipelines( - self, settings: dict, selected: Optional[list] = None + self, settings: dict, selected: Any = None ) -> list["BaseFileIndexRetriever"]: + # retrieval settings prefix = f"index.options.{self.id}." stripped_settings = {} for key, value in settings.items(): @@ -240,9 +351,12 @@ def get_retriever_pipelines( else: stripped_settings[key] = value + # transform selected id + selected_ids: Optional[list[str]] = self._selector_ui.get_selected_ids(selected) + retrievers = [] for cls in self._retriever_pipeline_cls: - obj = cls.get_pipeline(stripped_settings, self._config, selected) + obj = cls.get_pipeline(stripped_settings, self.config, selected_ids) if obj is None: continue obj.set_resources(self._resources) diff --git a/libs/ktem/ktem/index/file/pipelines.py b/libs/ktem/ktem/index/file/pipelines.py index 68b3a4d21..b63d89c0b 100644 --- a/libs/ktem/ktem/index/file/pipelines.py +++ b/libs/ktem/ktem/index/file/pipelines.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Optional +import gradio as gr from ktem.components import embeddings, filestorage_path from ktem.db.models import engine from llama_index.vector_stores import ( @@ -18,7 +19,7 @@ MetadataFilters, ) from llama_index.vector_stores.types import VectorStoreQueryMode -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.orm import Session from theflow.settings import settings from theflow.utils.modules import import_dotted_string @@ -279,6 +280,7 @@ def run( to_index: list[str] = [] file_to_hash: dict[str, str] = {} errors = [] + to_update = [] for file_path in file_paths: abs_path = str(Path(file_path).resolve()) @@ -291,16 +293,26 @@ def run( statement = select(Source).where(Source.name == Path(abs_path).name) item = session.execute(statement).first() - if item and not reindex: - errors.append(Path(abs_path).name) - continue + if item: + if not reindex: + errors.append(Path(abs_path).name) + continue + else: + to_update.append(Path(abs_path).name) to_index.append(abs_path) if errors: + error_files = ", ".join(errors) + if len(error_files) > 100: + error_files = error_files[:80] + "..." print( - "Files already exist. Please rename/remove them or enable reindex.\n" - f"{errors}" + "Skip these files already exist. Please rename/remove them or " + f"enable reindex:\n{errors}" + ) + self.warning( + "Skip these files already exist. Please rename/remove them or " + f"enable reindex:\n{error_files}" ) if not to_index: @@ -310,9 +322,19 @@ def run( for path in to_index: shutil.copy(path, filestorage_path / file_to_hash[path]) - # prepare record info + # extract the file & prepare record info file_to_source: dict = {} + extraction_errors = [] + nodes = [] for file_path, file_hash in file_to_hash.items(): + if str(Path(file_path).resolve()) not in to_index: + continue + + extraction_result = self.file_ingestor(file_path) + if not extraction_result: + extraction_errors.append(Path(file_path).name) + continue + nodes.extend(extraction_result) source = Source( name=Path(file_path).name, path=file_hash, @@ -320,9 +342,23 @@ def run( ) file_to_source[file_path] = source - # extract the files - nodes = self.file_ingestor(to_index) - print("Extracted", len(to_index), "files into", len(nodes), "nodes") + if extraction_errors: + msg = "Failed to extract these files: {}".format( + ", ".join(extraction_errors) + ) + print(msg) + self.warning(msg) + + if not nodes: + return [], [] + + print( + "Extracted", + len(to_index) - len(extraction_errors), + "files into", + len(nodes), + "nodes", + ) # index the files print("Indexing the files into vector store") @@ -332,7 +368,11 @@ def run( # persist to the index print("Persisting the vector and the document into index") file_ids = [] + to_update = list(set(to_update)) with Session(engine) as session: + if to_update: + session.execute(delete(Source).where(Source.name.in_(to_update))) + for source in file_to_source.values(): session.add(source) session.commit() @@ -404,3 +444,6 @@ def set_resources(self, resources: dict): super().set_resources(resources) self.indexing_vector_pipeline.vector_store = self._VS self.indexing_vector_pipeline.doc_store = self._DS + + def warning(self, msg): + gr.Warning(msg) diff --git a/libs/ktem/ktem/index/file/ui.py b/libs/ktem/ktem/index/file/ui.py index 9da2b4a14..11d491f03 100644 --- a/libs/ktem/ktem/index/file/ui.py +++ b/libs/ktem/ktem/index/file/ui.py @@ -1,29 +1,48 @@ import os import tempfile +from pathlib import Path import gradio as gr import pandas as pd +from gradio.data_classes import FileData +from gradio.utils import NamedString from ktem.app import BasePage from ktem.db.engine import engine from sqlalchemy import select from sqlalchemy.orm import Session +class File(gr.File): + """Subclass from gr.File to maintain the original filename + + The issue happens when user uploads file with name like: !@#$%%^&*().pdf + """ + + def _process_single_file(self, f: FileData) -> NamedString | bytes: + file_name = f.path + if self.type == "filepath": + if f.orig_name and Path(file_name).name != f.orig_name: + file_name = str(Path(file_name).parent / f.orig_name) + os.rename(f.path, file_name) + file = tempfile.NamedTemporaryFile(delete=False, dir=self.GRADIO_CACHE) + file.name = file_name + return NamedString(file_name) + elif self.type == "binary": + with open(file_name, "rb") as file_data: + return file_data.read() + else: + raise ValueError( + "Unknown type: " + + str(type) + + ". Please choose from: 'filepath', 'binary'." + ) + + class DirectoryUpload(BasePage): - def __init__(self, app): - self._app = app - self._supported_file_types = [ - "image", - ".pdf", - ".txt", - ".csv", - ".xlsx", - ".doc", - ".docx", - ".pptx", - ".html", - ".zip", - ] + def __init__(self, app, index): + super().__init__(app) + self._index = index + self._supported_file_types = self._index.config.get("supported_file_types", []) self.on_building_ui() def on_building_ui(self): @@ -50,18 +69,7 @@ class FileIndexPage(BasePage): def __init__(self, app, index): super().__init__(app) self._index = index - self._supported_file_types = [ - "image", - ".pdf", - ".txt", - ".csv", - ".xlsx", - ".doc", - ".docx", - ".pptx", - ".html", - ".zip", - ] + self._supported_file_types = self._index.config.get("supported_file_types", []) self.selected_panel_false = "Selected file: (please select above)" self.selected_panel_true = "Selected file: {name}" # TODO: on_building_ui is not correctly named if it's always called in @@ -69,13 +77,32 @@ def __init__(self, app, index): self.public_events = [f"onFileIndex{index.id}Changed"] self.on_building_ui() + def upload_instruction(self) -> str: + msgs = [] + if self._supported_file_types: + msgs.append( + f"- Supported file types: {', '.join(self._supported_file_types)}" + ) + + if max_file_size := self._index.config.get("max_file_size", 0): + msgs.append(f"- Maximum file size: {max_file_size} MB") + + if max_number_of_files := self._index.config.get("max_number_of_files", 0): + msgs.append(f"- The index can have maximum {max_number_of_files} files") + + if msgs: + return "\n".join(msgs) + + return "" + def on_building_ui(self): """Build the UI of the app""" - with gr.Accordion(label="File upload", open=False): - gr.Markdown( - f"Supported file types: {', '.join(self._supported_file_types)}", - ) - self.files = gr.File( + with gr.Accordion(label="File upload", open=True) as self.upload: + msg = self.upload_instruction() + if msg: + gr.Markdown(msg) + + self.files = File( file_types=self._supported_file_types, file_count="multiple", container=False, @@ -98,18 +125,20 @@ def on_building_ui(self): interactive=False, ) - with gr.Row(): + with gr.Row() as self.selection_info: self.selected_file_id = gr.State(value=None) self.selected_panel = gr.Markdown(self.selected_panel_false) self.deselect_button = gr.Button("Deselect", visible=False) - with gr.Row(): + with gr.Row() as self.tools: with gr.Column(): self.view_button = gr.Button("View Text (WIP)") with gr.Column(): self.delete_button = gr.Button("Delete") with gr.Row(): - self.delete_yes = gr.Button("Confirm Delete", visible=False) + self.delete_yes = gr.Button( + "Confirm Delete", variant="primary", visible=False + ) self.delete_no = gr.Button("Cancel", visible=False) def on_subscribe_public_events(self): @@ -242,10 +271,12 @@ def on_register_events(self): self._app.settings_state, ], outputs=[self.file_output], + concurrency_limit=20, ).then( fn=self.list_file, inputs=None, outputs=[self.file_list_state, self.file_list], + concurrency_limit=20, ) for event in self._app.get_event(f"onFileIndex{self._index.id}Changed"): onUploaded = onUploaded.then(**event) @@ -274,6 +305,15 @@ def index_fn(self, files, reindex: bool, settings): selected_files: the list of files already selected settings: the settings of the app """ + if not files: + gr.Info("No uploaded file") + return gr.update() + + errors = self.validate(files) + if errors: + gr.Warning(", ".join(errors)) + return gr.update() + gr.Info(f"Start indexing {len(files)} files...") # get the pipeline @@ -409,6 +449,35 @@ def interact_file_list(self, list_files, ev: gr.SelectData): name=list_files["name"][ev.index[0]] ) + def validate(self, files: list[str]): + """Validate if the files are valid""" + paths = [Path(file) for file in files] + errors = [] + if max_file_size := self._index.config.get("max_file_size", 0): + errors_max_size = [] + for path in paths: + if path.stat().st_size > max_file_size * 1e6: + errors_max_size.append(path.name) + if errors_max_size: + str_errors = ", ".join(errors_max_size) + if len(str_errors) > 60: + str_errors = str_errors[:55] + "..." + errors.append( + f"Maximum file size ({max_file_size} MB) exceeded: {str_errors}" + ) + + if max_number_of_files := self._index.config.get("max_number_of_files", 0): + with Session(engine) as session: + current_num_files = session.query( + self._index._db_tables["Source"].id + ).count() + if len(paths) + current_num_files > max_number_of_files: + errors.append( + f"Maximum number of files ({max_number_of_files}) will be exceeded" + ) + + return errors + class FileSelector(BasePage): """File selector UI in the Chat page""" @@ -430,6 +499,9 @@ def on_building_ui(self): def as_gradio_component(self): return self.selector + def get_selected_ids(self, selected): + return selected + def load_files(self, selected_files): options = [] available_ids = [] diff --git a/libs/ktem/ktem/index/manager.py b/libs/ktem/ktem/index/manager.py index 72c4f9948..af1c8d484 100644 --- a/libs/ktem/ktem/index/manager.py +++ b/libs/ktem/ktem/index/manager.py @@ -1,4 +1,4 @@ -from typing import Type +from typing import Optional, Type from ktem.db.models import engine from sqlmodel import Session, select @@ -49,15 +49,19 @@ def build_index(self, name: str, config: dict, index_type: str, id=None): Returns: BaseIndex: the index object """ + index_cls = import_dotted_string(index_type, safe=False) + index = index_cls(app=self._app, id=id, name=name, config=config) + index.on_create() + with Session(engine) as session: - index_entry = Index(id=id, name=name, config=config, index_type=index_type) + index_entry = Index( + id=index.id, name=index.name, config=index.config, index_type=index_type + ) session.add(index_entry) session.commit() session.refresh(index_entry) - index_cls = import_dotted_string(index_type, safe=False) - index = index_cls(app=self._app, id=id, name=name, config=config) - index.on_create() + index.id = index_entry.id return index @@ -77,7 +81,7 @@ def start_index(self, id: int, name: str, config: dict, index_type: str): self._indices.append(index) return index - def exists(self, id: int) -> bool: + def exists(self, id: Optional[int] = None, name: Optional[str] = None) -> bool: """Check if the index exists Args: @@ -86,9 +90,19 @@ def exists(self, id: int) -> bool: Returns: bool: True if the index exists, False otherwise """ - with Session(engine) as session: - index = session.get(Index, id) - return index is not None + if id: + with Session(engine) as session: + index = session.get(Index, id) + return index is not None + + if name: + with Session(engine) as session: + index = session.exec( + select(Index).where(Index.name == name) + ).one_or_none() + return index is not None + + return False def on_application_startup(self): """This method is called by the base application when the application starts diff --git a/libs/ktem/ktem/main.py b/libs/ktem/ktem/main.py index c375ed7ed..1d76d0498 100644 --- a/libs/ktem/ktem/main.py +++ b/libs/ktem/ktem/main.py @@ -27,7 +27,7 @@ def ui(self): if self.f_user_management: from ktem.pages.login import LoginPage - with gr.Tab("Login", elem_id="login-tab") as self._tabs["login-tab"]: + with gr.Tab("Welcome", elem_id="login-tab") as self._tabs["login-tab"]: self.login_page = LoginPage(self) with gr.Tab( @@ -62,6 +62,9 @@ def ui(self): def on_subscribe_public_events(self): if self.f_user_management: + from ktem.db.engine import engine + from ktem.db.models import User + from sqlmodel import Session, select def signed_in_out(user_id): if not user_id: @@ -73,14 +76,31 @@ def signed_in_out(user_id): ) for k in self._tabs.keys() ) - return list( - ( - gr.update(visible=True) - if k != "login-tab" - else gr.update(visible=False) - ) - for k in self._tabs.keys() - ) + + with Session(engine) as session: + user = session.exec(select(User).where(User.id == user_id)).first() + if user is None: + return list( + ( + gr.update(visible=True) + if k == "login-tab" + else gr.update(visible=False) + ) + for k in self._tabs.keys() + ) + + is_admin = user.admin + + tabs_update = [] + for k in self._tabs.keys(): + if k == "login-tab": + tabs_update.append(gr.update(visible=False)) + elif k == "admin-tab": + tabs_update.append(gr.update(visible=is_admin)) + else: + tabs_update.append(gr.update(visible=True)) + + return tabs_update self.subscribe_event( name="onSignIn", diff --git a/libs/ktem/ktem/pages/admin/user.py b/libs/ktem/ktem/pages/admin/user.py index 519fb0fd7..6411b30d7 100644 --- a/libs/ktem/ktem/pages/admin/user.py +++ b/libs/ktem/ktem/pages/admin/user.py @@ -40,7 +40,7 @@ def validate_username(usn): if len(usn) > 32: errors.append("Username must be at most 32 characters long") - if not usn.strip("_").isalnum(): + if not usn.replace("_", "").isalnum(): errors.append( "Username must contain only alphanumeric characters and underscores" ) @@ -97,8 +97,6 @@ def validate_password(pwd, pwd_cnf): class UserManagement(BasePage): def __init__(self, app): self._app = app - self.selected_panel_false = "Selected user: (please select above)" - self.selected_panel_true = "Selected user: {name}" self.on_building_ui() if hasattr(flowsettings, "KH_FEATURE_USER_MANAGEMENT_ADMIN") and hasattr( @@ -126,7 +124,38 @@ def __init__(self, app): gr.Info(f'User "{usn}" created successfully') def on_building_ui(self): - with gr.Accordion(label="Create user", open=False): + with gr.Tab(label="User list"): + self.state_user_list = gr.State(value=None) + self.user_list = gr.DataFrame( + headers=["id", "name", "admin"], + interactive=False, + ) + + with gr.Group(visible=False) as self._selected_panel: + self.selected_user_id = gr.Number(value=-1, visible=False) + self.usn_edit = gr.Textbox(label="Username") + with gr.Row(): + self.pwd_edit = gr.Textbox(label="Change password", type="password") + self.pwd_cnf_edit = gr.Textbox( + label="Confirm change password", + type="password", + ) + self.admin_edit = gr.Checkbox(label="Admin") + + with gr.Row() as self._selected_panel_btn: + with gr.Column(): + self.btn_edit_save = gr.Button("Save") + with gr.Column(): + self.btn_delete = gr.Button("Delete") + with gr.Row(): + self.btn_delete_yes = gr.Button( + "Confirm delete", variant="primary", visible=False + ) + self.btn_delete_no = gr.Button("Cancel", visible=False) + with gr.Column(): + self.btn_close = gr.Button("Close") + + with gr.Tab(label="Create user"): self.usn_new = gr.Textbox(label="Username", interactive=True) self.pwd_new = gr.Textbox( label="Password", type="password", interactive=True @@ -139,52 +168,28 @@ def on_building_ui(self): gr.Markdown(PASSWORD_RULE) self.btn_new = gr.Button("Create user") - gr.Markdown("## User list") - self.btn_list_user = gr.Button("Refresh user list") - self.state_user_list = gr.State(value=None) - self.user_list = gr.DataFrame( - headers=["id", "name", "admin"], - interactive=False, - ) - - with gr.Row(): - self.selected_user_id = gr.State(value=None) - self.selected_panel = gr.Markdown(self.selected_panel_false) - self.deselect_button = gr.Button("Deselect", visible=False) - - with gr.Group(): - self.btn_delete = gr.Button("Delete user") - with gr.Row(): - self.btn_delete_yes = gr.Button("Confirm", visible=False) - self.btn_delete_no = gr.Button("Cancel", visible=False) - - gr.Markdown("## User details") - self.usn_edit = gr.Textbox(label="Username") - self.pwd_edit = gr.Textbox(label="Password", type="password") - self.pwd_cnf_edit = gr.Textbox(label="Confirm password", type="password") - self.admin_edit = gr.Checkbox(label="Admin") - self.btn_edit_save = gr.Button("Save") - def on_register_events(self): self.btn_new.click( self.create_user, inputs=[self.usn_new, self.pwd_new, self.pwd_cnf_new], - outputs=None, - ) - self.btn_list_user.click( - self.list_users, inputs=None, outputs=[self.state_user_list, self.user_list] + outputs=[self.usn_new, self.pwd_new, self.pwd_cnf_new], + ).then( + self.list_users, + inputs=self._app.user_id, + outputs=[self.state_user_list, self.user_list], ) self.user_list.select( self.select_user, inputs=self.user_list, - outputs=[self.selected_user_id, self.selected_panel], + outputs=[self.selected_user_id], show_progress="hidden", ) - self.selected_panel.change( + self.selected_user_id.change( self.on_selected_user_change, inputs=[self.selected_user_id], outputs=[ - self.deselect_button, + self._selected_panel, + self._selected_panel_btn, # delete section self.btn_delete, self.btn_delete_yes, @@ -197,12 +202,6 @@ def on_register_events(self): ], show_progress="hidden", ) - self.deselect_button.click( - lambda: (None, self.selected_panel_false), - inputs=None, - outputs=[self.selected_user_id, self.selected_panel], - show_progress="hidden", - ) self.btn_delete.click( self.on_btn_delete_click, inputs=[self.selected_user_id], @@ -211,9 +210,13 @@ def on_register_events(self): ) self.btn_delete_yes.click( self.delete_user, - inputs=[self.selected_user_id], - outputs=[self.selected_user_id, self.selected_panel], + inputs=[self._app.user_id, self.selected_user_id], + outputs=[self.selected_user_id], show_progress="hidden", + ).then( + self.list_users, + inputs=self._app.user_id, + outputs=[self.state_user_list, self.user_list], ) self.btn_delete_no.click( lambda: ( @@ -234,21 +237,53 @@ def on_register_events(self): self.pwd_cnf_edit, self.admin_edit, ], - outputs=None, + outputs=[self.pwd_edit, self.pwd_cnf_edit], show_progress="hidden", + ).then( + self.list_users, + inputs=self._app.user_id, + outputs=[self.state_user_list, self.user_list], + ) + self.btn_close.click( + lambda: -1, + outputs=[self.selected_user_id], + ) + + def on_subscribe_public_events(self): + self._app.subscribe_event( + name="onSignIn", + definition={ + "fn": self.list_users, + "inputs": [self._app.user_id], + "outputs": [self.state_user_list, self.user_list], + }, + ) + self._app.subscribe_event( + name="onSignOut", + definition={ + "fn": lambda: ("", "", "", None, None, -1), + "outputs": [ + self.usn_new, + self.pwd_new, + self.pwd_cnf_new, + self.state_user_list, + self.user_list, + self.selected_user_id, + ], + }, ) def create_user(self, usn, pwd, pwd_cnf): errors = validate_username(usn) if errors: gr.Warning(errors) - return + return usn, pwd, pwd_cnf errors = validate_password(pwd, pwd_cnf) print(errors) if errors: gr.Warning(errors) - return + return usn, pwd, pwd_cnf with Session(engine) as session: statement = select(User).where(User.username_lower == usn.lower()) @@ -265,8 +300,22 @@ def create_user(self, usn, pwd, pwd_cnf): session.commit() gr.Info(f'User "{usn}" created successfully') - def list_users(self): + return "", "", "" + + def list_users(self, user_id): + if user_id is None: + return [], pd.DataFrame.from_records( + [{"id": "-", "username": "-", "admin": "-"}] + ) + with Session(engine) as session: + statement = select(User).where(User.id == user_id) + user = session.exec(statement).one() + if not user.admin: + return [], pd.DataFrame.from_records( + [{"id": "-", "username": "-", "admin": "-"}] + ) + statement = select(User) results = [ {"id": user.id, "username": user.username, "admin": user.admin} @@ -284,18 +333,17 @@ def list_users(self): def select_user(self, user_list, ev: gr.SelectData): if ev.value == "-" and ev.index[0] == 0: gr.Info("No user is loaded. Please refresh the user list") - return None, self.selected_panel_false + return -1 if not ev.selected: - return None, self.selected_panel_false + return -1 - return user_list["id"][ev.index[0]], self.selected_panel_true.format( - name=user_list["username"][ev.index[0]] - ) + return user_list["id"][ev.index[0]] def on_selected_user_change(self, selected_user_id): - if selected_user_id is None: - deselect_button = gr.update(visible=False) + if selected_user_id == -1: + _selected_panel = gr.update(visible=False) + _selected_panel_btn = gr.update(visible=False) btn_delete = gr.update(visible=True) btn_delete_yes = gr.update(visible=False) btn_delete_no = gr.update(visible=False) @@ -304,7 +352,8 @@ def on_selected_user_change(self, selected_user_id): pwd_cnf_edit = gr.update(value="") admin_edit = gr.update(value=False) else: - deselect_button = gr.update(visible=True) + _selected_panel = gr.update(visible=True) + _selected_panel_btn = gr.update(visible=True) btn_delete = gr.update(visible=True) btn_delete_yes = gr.update(visible=False) btn_delete_no = gr.update(visible=False) @@ -319,7 +368,8 @@ def on_selected_user_change(self, selected_user_id): admin_edit = gr.update(value=user.admin) return ( - deselect_button, + _selected_panel, + _selected_panel_btn, btn_delete, btn_delete_yes, btn_delete_no, @@ -344,17 +394,16 @@ def on_btn_delete_click(self, selected_user_id): return btn_delete, btn_delete_yes, btn_delete_no def save_user(self, selected_user_id, usn, pwd, pwd_cnf, admin): - if usn: - errors = validate_username(usn) - if errors: - gr.Warning(errors) - return + errors = validate_username(usn) + if errors: + gr.Warning(errors) + return pwd, pwd_cnf if pwd: errors = validate_password(pwd, pwd_cnf) if errors: gr.Warning(errors) - return + return pwd, pwd_cnf with Session(engine) as session: statement = select(User).where(User.id == int(selected_user_id)) @@ -367,11 +416,17 @@ def save_user(self, selected_user_id, usn, pwd, pwd_cnf, admin): session.commit() gr.Info(f'User "{usn}" updated successfully') - def delete_user(self, selected_user_id): + return "", "" + + def delete_user(self, current_user, selected_user_id): + if current_user == selected_user_id: + gr.Warning("You cannot delete yourself") + return selected_user_id + with Session(engine) as session: statement = select(User).where(User.id == int(selected_user_id)) user = session.exec(statement).one() session.delete(user) session.commit() gr.Info(f'User "{user.username}" deleted successfully') - return None, self.selected_panel_false + return -1 diff --git a/libs/ktem/ktem/pages/chat/__init__.py b/libs/ktem/ktem/pages/chat/__init__.py index d2bba877b..a83bd837d 100644 --- a/libs/ktem/ktem/pages/chat/__init__.py +++ b/libs/ktem/ktem/pages/chat/__init__.py @@ -7,8 +7,10 @@ from ktem.components import reasonings from ktem.db.models import Conversation, engine from sqlmodel import Session, select +from theflow.settings import settings as flowsettings from .chat_panel import ChatPanel +from .chat_suggestion import ChatSuggestion from .common import STATE from .control import ConversationControl from .report import ReportIssue @@ -26,24 +28,39 @@ def on_building_ui(self): with gr.Column(scale=1): self.chat_control = ConversationControl(self._app) + if getattr(flowsettings, "KH_FEATURE_CHAT_SUGGESTION", False): + self.chat_suggestion = ChatSuggestion(self._app) + for index in self._app.index_manager.indices: - index.selector = -1 + index.selector = None index_ui = index.get_selector_component_ui() if not index_ui: + # the index doesn't have a selector UI component continue - index_ui.unrender() + index_ui.unrender() # need to rerender later within Accordion with gr.Accordion(label=f"{index.name} Index", open=False): index_ui.render() gr_index = index_ui.as_gradio_component() if gr_index: - index.selector = len(self._indices_input) - self._indices_input.append(gr_index) + if isinstance(gr_index, list): + index.selector = tuple( + range( + len(self._indices_input), + len(self._indices_input) + len(gr_index), + ) + ) + self._indices_input.extend(gr_index) + else: + index.selector = len(self._indices_input) + self._indices_input.append(gr_index) setattr(self, f"_index_{index.id}", index_ui) self.report_issue = ReportIssue(self._app) + with gr.Column(scale=6): self.chat_panel = ChatPanel(self._app) + with gr.Column(scale=3): with gr.Accordion(label="Information panel", open=True): self.info_panel = gr.HTML(elem_id="chat-info-panel") @@ -54,11 +71,24 @@ def on_register_events(self): self.chat_panel.text_input.submit, self.chat_panel.submit_btn.click, ], - fn=self.chat_panel.submit_msg, - inputs=[self.chat_panel.text_input, self.chat_panel.chatbot], - outputs=[self.chat_panel.text_input, self.chat_panel.chatbot], + fn=self.submit_msg, + inputs=[ + self.chat_panel.text_input, + self.chat_panel.chatbot, + self._app.user_id, + self.chat_control.conversation_id, + self.chat_control.conversation_rn, + ], + outputs=[ + self.chat_panel.text_input, + self.chat_panel.chatbot, + self.chat_control.conversation_id, + self.chat_control.conversation, + self.chat_control.conversation_rn, + ], + concurrency_limit=20, show_progress="hidden", - ).then( + ).success( fn=self.chat_fn, inputs=[ self.chat_control.conversation_id, @@ -72,6 +102,7 @@ def on_register_events(self): self.info_panel, self.chat_state, ], + concurrency_limit=20, show_progress="minimal", ).then( fn=self.update_data_source, @@ -82,6 +113,7 @@ def on_register_events(self): ] + self._indices_input, outputs=None, + concurrency_limit=20, ) self.chat_panel.regen_btn.click( @@ -98,6 +130,7 @@ def on_register_events(self): self.info_panel, self.chat_state, ], + concurrency_limit=20, show_progress="minimal", ).then( fn=self.update_data_source, @@ -108,6 +141,7 @@ def on_register_events(self): ] + self._indices_input, outputs=None, + concurrency_limit=20, ) self.chat_panel.chatbot.like( @@ -116,7 +150,12 @@ def on_register_events(self): outputs=None, ) - self.chat_control.conversation.change( + self.chat_control.btn_new.click( + self.chat_control.new_conv, + inputs=self._app.user_id, + outputs=[self.chat_control.conversation_id, self.chat_control.conversation], + show_progress="hidden", + ).then( self.chat_control.select_conv, inputs=[self.chat_control.conversation], outputs=[ @@ -124,12 +163,71 @@ def on_register_events(self): self.chat_control.conversation, self.chat_control.conversation_rn, self.chat_panel.chatbot, + self.info_panel, self.chat_state, ] + self._indices_input, show_progress="hidden", ) + self.chat_control.btn_del.click( + lambda id: self.toggle_delete(id), + inputs=[self.chat_control.conversation_id], + outputs=[self.chat_control._new_delete, self.chat_control._delete_confirm], + ) + self.chat_control.btn_del_conf.click( + self.chat_control.delete_conv, + inputs=[self.chat_control.conversation_id, self._app.user_id], + outputs=[self.chat_control.conversation_id, self.chat_control.conversation], + show_progress="hidden", + ).then( + self.chat_control.select_conv, + inputs=[self.chat_control.conversation], + outputs=[ + self.chat_control.conversation_id, + self.chat_control.conversation, + self.chat_control.conversation_rn, + self.chat_panel.chatbot, + self.info_panel, + ] + + self._indices_input, + show_progress="hidden", + ).then( + lambda: self.toggle_delete(""), + outputs=[self.chat_control._new_delete, self.chat_control._delete_confirm], + ) + self.chat_control.btn_del_cnl.click( + lambda: self.toggle_delete(""), + outputs=[self.chat_control._new_delete, self.chat_control._delete_confirm], + ) + self.chat_control.conversation_rn_btn.click( + self.chat_control.rename_conv, + inputs=[ + self.chat_control.conversation_id, + self.chat_control.conversation_rn, + self._app.user_id, + ], + outputs=[self.chat_control.conversation, self.chat_control.conversation], + show_progress="hidden", + ) + + self.chat_control.conversation.select( + self.chat_control.select_conv, + inputs=[self.chat_control.conversation], + outputs=[ + self.chat_control.conversation_id, + self.chat_control.conversation, + self.chat_control.conversation_rn, + self.chat_panel.chatbot, + self.info_panel, + ] + + self._indices_input, + show_progress="hidden", + ).then( + lambda: self.toggle_delete(""), + outputs=[self.chat_control._new_delete, self.chat_control._delete_confirm], + ) + self.report_issue.report_btn.click( self.report_issue.report, inputs=[ @@ -140,11 +238,77 @@ def on_register_events(self): self.chat_panel.chatbot, self._app.settings_state, self._app.user_id, + self.info_panel, self.chat_state, ] + self._indices_input, outputs=None, ) + if getattr(flowsettings, "KH_FEATURE_CHAT_SUGGESTION", False): + self.chat_suggestion.example.select( + self.chat_suggestion.select_example, + outputs=[self.chat_panel.text_input], + show_progress="hidden", + ) + + def submit_msg(self, chat_input, chat_history, user_id, conv_id, conv_name): + """Submit a message to the chatbot""" + if not chat_input: + raise ValueError("Input is empty") + + if not conv_id: + id_, update = self.chat_control.new_conv(user_id) + with Session(engine) as session: + statement = select(Conversation).where(Conversation.id == id_) + name = session.exec(statement).one().name + new_conv_id = id_ + conv_update = update + new_conv_name = name + else: + new_conv_id = conv_id + conv_update = gr.update() + new_conv_name = conv_name + + return ( + "", + chat_history + [(chat_input, None)], + new_conv_id, + conv_update, + new_conv_name, + ) + + def toggle_delete(self, conv_id): + if conv_id: + return gr.update(visible=False), gr.update(visible=True) + else: + return gr.update(visible=True), gr.update(visible=False) + + def on_subscribe_public_events(self): + if self._app.f_user_management: + self._app.subscribe_event( + name="onSignIn", + definition={ + "fn": self.chat_control.reload_conv, + "inputs": [self._app.user_id], + "outputs": [self.chat_control.conversation], + "show_progress": "hidden", + }, + ) + + self._app.subscribe_event( + name="onSignOut", + definition={ + "fn": lambda: self.chat_control.select_conv(""), + "outputs": [ + self.chat_control.conversation_id, + self.chat_control.conversation, + self.chat_control.conversation_rn, + self.chat_panel.chatbot, + ] + + self._indices_input, + "show_progress": "hidden", + }, + ) def update_data_source(self, convo_id, messages, state, *selecteds): """Update the data source""" @@ -154,8 +318,12 @@ def update_data_source(self, convo_id, messages, state, *selecteds): selecteds_ = {} for index in self._app.index_manager.indices: - if index.selector != -1: + if index.selector is None: + continue + if isinstance(index.selector, int): selecteds_[str(index.id)] = selecteds[index.selector] + else: + selecteds_[str(index.id)] = [selecteds[i] for i in index.selector] with Session(engine) as session: statement = select(Conversation).where(Conversation.id == convo_id) @@ -205,8 +373,11 @@ def create_pipeline(self, settings: dict, state: dict, *selecteds): retrievers = [] for index in self._app.index_manager.indices: index_selected = [] - if index.selector != -1: + if isinstance(index.selector, int): index_selected = selecteds[index.selector] + if isinstance(index.selector, tuple): + for i in index.selector: + index_selected.append(selecteds[i]) iretrievers = index.get_retriever_pipelines(settings, index_selected) retrievers += iretrievers @@ -250,7 +421,10 @@ async def chat_fn(self, conversation_id, chat_history, settings, state, *selecte break if "output" in response: - text += response["output"] + if response["output"] is None: + text = "" + else: + text += response["output"] if "evidence" in response: if response["evidence"] is None: diff --git a/libs/ktem/ktem/pages/chat/chat_suggestion.py b/libs/ktem/ktem/pages/chat/chat_suggestion.py new file mode 100644 index 000000000..23332c034 --- /dev/null +++ b/libs/ktem/ktem/pages/chat/chat_suggestion.py @@ -0,0 +1,26 @@ +import gradio as gr +from ktem.app import BasePage +from theflow.settings import settings as flowsettings + + +class ChatSuggestion(BasePage): + def __init__(self, app): + self._app = app + self.on_building_ui() + + def on_building_ui(self): + chat_samples = getattr(flowsettings, "KH_FEATURE_CHAT_SUGGESTION_SAMPLES", []) + chat_samples = [[each] for each in chat_samples] + with gr.Accordion(label="Chat Suggestion", open=False) as self.accordion: + self.example = gr.DataFrame( + value=chat_samples, + headers=["Sample"], + interactive=False, + wrap=True, + ) + + def as_gradio_component(self): + return self.example + + def select_example(self, ev: gr.SelectData): + return ev.value diff --git a/libs/ktem/ktem/pages/chat/control.py b/libs/ktem/ktem/pages/chat/control.py index e71411260..f2ed99bb1 100644 --- a/libs/ktem/ktem/pages/chat/control.py +++ b/libs/ktem/ktem/pages/chat/control.py @@ -10,6 +10,17 @@ logger = logging.getLogger(__name__) +def is_conv_name_valid(name): + """Check if the conversation name is valid""" + errors = [] + if len(name) == 0: + errors.append("Name cannot be empty") + elif len(name) > 40: + errors.append("Name cannot be longer than 40 characters") + + return "; ".join(errors) + + class ConversationControl(BasePage): """Manage conversation""" @@ -28,9 +39,17 @@ def on_building_ui(self): interactive=True, ) - with gr.Row(): - self.conversation_new_btn = gr.Button(value="New", min_width=10) - self.conversation_del_btn = gr.Button(value="Delete", min_width=10) + with gr.Row() as self._new_delete: + self.btn_new = gr.Button(value="New", min_width=10) + self.btn_del = gr.Button(value="Delete", min_width=10) + + with gr.Row(visible=False) as self._delete_confirm: + self.btn_del_conf = gr.Button( + value="Delete", + variant="primary", + min_width=10, + ) + self.btn_del_cnl = gr.Button(value="Cancel", min_width=10) with gr.Row(): self.conversation_rn = gr.Text( @@ -52,48 +71,6 @@ def on_building_ui(self): # outputs=[current_state], # ) - def on_subscribe_public_events(self): - if self._app.f_user_management: - self._app.subscribe_event( - name="onSignIn", - definition={ - "fn": self.reload_conv, - "inputs": [self._app.user_id], - "outputs": [self.conversation], - "show_progress": "hidden", - }, - ) - - self._app.subscribe_event( - name="onSignOut", - definition={ - "fn": self.reload_conv, - "inputs": [self._app.user_id], - "outputs": [self.conversation], - "show_progress": "hidden", - }, - ) - - def on_register_events(self): - self.conversation_new_btn.click( - self.new_conv, - inputs=self._app.user_id, - outputs=[self.conversation_id, self.conversation], - show_progress="hidden", - ) - self.conversation_del_btn.click( - self.delete_conv, - inputs=[self.conversation_id, self._app.user_id], - outputs=[self.conversation_id, self.conversation], - show_progress="hidden", - ) - self.conversation_rn_btn.click( - self.rename_conv, - inputs=[self.conversation_id, self.conversation_rn, self._app.user_id], - outputs=[self.conversation, self.conversation], - show_progress="hidden", - ) - def load_chat_history(self, user_id): """Reload chat history""" options = [] @@ -112,7 +89,7 @@ def load_chat_history(self, user_id): def reload_conv(self, user_id): conv_list = self.load_chat_history(user_id) if conv_list: - return gr.update(value=conv_list[0][1], choices=conv_list) + return gr.update(value=None, choices=conv_list) else: return gr.update(value=None, choices=[]) @@ -133,10 +110,15 @@ def new_conv(self, user_id): return id_, gr.update(value=id_, choices=history) def delete_conv(self, conversation_id, user_id): - """Create new chat""" + """Delete the selected conversation""" + if not conversation_id: + gr.Warning("No conversation selected.") + return None, gr.update() + if user_id is None: gr.Warning("Please sign in first (Settings → User Settings)") return None, gr.update() + with Session(engine) as session: statement = select(Conversation).where(Conversation.id == conversation_id) result = session.exec(statement).one() @@ -161,6 +143,7 @@ def select_conv(self, conversation_id): name = result.name selected = result.data_source.get("selected", {}) chats = result.data_source.get("messages", []) + info_panel = "" state = result.data_source.get("state", STATE) except Exception as e: logger.warning(e) @@ -168,22 +151,36 @@ def select_conv(self, conversation_id): name = "" selected = {} chats = [] + info_panel = "" state = STATE indices = [] for index in self._app.index_manager.indices: # assume that the index has selector - if index.selector == -1: + if index.selector is None: continue - indices.append(selected.get(str(index.id), [])) + if isinstance(index.selector, int): + indices.append(selected.get(str(index.id), [])) + if isinstance(index.selector, tuple): + indices.extend(selected.get(str(index.id), [[]] * len(index.selector))) - return id_, id_, name, chats, state, *indices + return id_, id_, name, chats, info_panel, state, *indices def rename_conv(self, conversation_id, new_name, user_id): """Rename the conversation""" if user_id is None: gr.Warning("Please sign in first (Settings → User Settings)") return gr.update(), "" + + if not conversation_id: + gr.Warning("No conversation selected.") + return gr.update(), "" + + errors = is_conv_name_valid(new_name) + if errors: + gr.Warning(errors) + return gr.update(), conversation_id + with Session(engine) as session: statement = select(Conversation).where(Conversation.id == conversation_id) result = session.exec(statement).one() diff --git a/libs/ktem/ktem/pages/chat/report.py b/libs/ktem/ktem/pages/chat/report.py index 25d83f844..dfe030146 100644 --- a/libs/ktem/ktem/pages/chat/report.py +++ b/libs/ktem/ktem/pages/chat/report.py @@ -48,13 +48,19 @@ def report( chat_history: list, settings: dict, user_id: Optional[int], + info_panel: str, chat_state: dict, - *selecteds + *selecteds, ): selecteds_ = {} for index in self._app.index_manager.indices: - if index.selector != -1: - selecteds_[str(index.id)] = selecteds[index.selector] + if index.selector is not None: + if isinstance(index.selector, int): + selecteds_[str(index.id)] = selecteds[index.selector] + elif isinstance(index.selector, tuple): + selecteds_[str(index.id)] = [selecteds[_] for _ in index.selector] + else: + print(f"Unknown selector type: {index.selector}") with Session(engine) as session: issue = IssueReport( @@ -66,6 +72,7 @@ def report( chat={ "conv_id": conv_id, "chat_history": chat_history, + "info_panel": info_panel, "chat_state": chat_state, "selecteds": selecteds_, }, diff --git a/libs/ktem/ktem/pages/login.py b/libs/ktem/ktem/pages/login.py index 6fe15d0c3..d5c57e5a4 100644 --- a/libs/ktem/ktem/pages/login.py +++ b/libs/ktem/ktem/pages/login.py @@ -31,11 +31,10 @@ def __init__(self, app): self.on_building_ui() def on_building_ui(self): - gr.Markdown("Welcome to Kotaemon") - self.usn = gr.Textbox(label="Username") - self.pwd = gr.Textbox(label="Password", type="password") - self.btn_login = gr.Button("Login") - self._dummy = gr.State() + gr.Markdown("# Welcome to Kotaemon") + self.usn = gr.Textbox(label="Username", visible=False) + self.pwd = gr.Textbox(label="Password", type="password", visible=False) + self.btn_login = gr.Button("Login", visible=False) def on_register_events(self): onSignIn = gr.on( @@ -45,24 +44,56 @@ def on_register_events(self): outputs=[self._app.user_id, self.usn, self.pwd], show_progress="hidden", js=signin_js, + ).then( + self.toggle_login_visibility, + inputs=[self._app.user_id], + outputs=[self.usn, self.pwd, self.btn_login], ) for event in self._app.get_event("onSignIn"): onSignIn = onSignIn.success(**event) + def toggle_login_visibility(self, user_id): + return ( + gr.update(visible=user_id is None), + gr.update(visible=user_id is None), + gr.update(visible=user_id is None), + ) + def _on_app_created(self): - self._app.app.load( - None, - inputs=None, - outputs=[self.usn, self.pwd], + onSignIn = self._app.app.load( + self.login, + inputs=[self.usn, self.pwd], + outputs=[self._app.user_id, self.usn, self.pwd], + show_progress="hidden", js=fetch_creds, + ).then( + self.toggle_login_visibility, + inputs=[self._app.user_id], + outputs=[self.usn, self.pwd, self.btn_login], + ) + for event in self._app.get_event("onSignIn"): + onSignIn = onSignIn.success(**event) + + def on_subscribe_public_events(self): + self._app.subscribe_event( + name="onSignOut", + definition={ + "fn": self.toggle_login_visibility, + "inputs": [self._app.user_id], + "outputs": [self.usn, self.pwd, self.btn_login], + "show_progress": "hidden", + }, ) def login(self, usn, pwd): + if not usn or not pwd: + return None, usn, pwd hashed_password = hashlib.sha256(pwd.encode()).hexdigest() with Session(engine) as session: stmt = select(User).where( - User.username_lower == usn.lower(), User.password == hashed_password + User.username_lower == usn.lower().strip(), + User.password == hashed_password, ) result = session.exec(stmt).all() if result: diff --git a/libs/ktem/ktem/pages/settings.py b/libs/ktem/ktem/pages/settings.py index 0fce2e8f7..20912cb44 100644 --- a/libs/ktem/ktem/pages/settings.py +++ b/libs/ktem/ktem/pages/settings.py @@ -164,9 +164,14 @@ def on_register_events(self): show_progress="hidden", ) onSignOutClick = self.signout.click( - lambda: (None, "Current user: ___"), + lambda: (None, "Current user: ___", "", ""), inputs=None, - outputs=[self._user_id, self.current_name], + outputs=[ + self._user_id, + self.current_name, + self.password_change, + self.password_change_confirm, + ], show_progress="hidden", js=signout_js, ).then( @@ -192,8 +197,12 @@ def user_tab(self): self.password_change_btn = gr.Button("Change password", interactive=True) def change_password(self, user_id, password, password_confirm): - if password != password_confirm: - gr.Warning("Password does not match") + from ktem.pages.admin.user import validate_password + + errors = validate_password(password, password_confirm) + if errors: + print(errors) + gr.Warning(errors) return password, password_confirm with Session(engine) as session: diff --git a/libs/ktem/ktem/reasoning/base.py b/libs/ktem/ktem/reasoning/base.py index 80cf01698..6d6e48648 100644 --- a/libs/ktem/ktem/reasoning/base.py +++ b/libs/ktem/ktem/reasoning/base.py @@ -34,12 +34,16 @@ def get_user_settings(cls) -> dict: @classmethod def get_pipeline( - cls, user_settings: dict, retrievers: Optional[list["BaseComponent"]] = None + cls, + user_settings: dict, + state: dict, + retrievers: Optional[list["BaseComponent"]] = None, ) -> "BaseReasoning": """Get the reasoning pipeline for the app to execute Args: user_setting: user settings + state: conversation state retrievers (list): List of retrievers """ return cls() diff --git a/libs/ktem/ktem/reasoning/simple.py b/libs/ktem/ktem/reasoning/simple.py index 23d8363bb..082c20f54 100644 --- a/libs/ktem/ktem/reasoning/simple.py +++ b/libs/ktem/ktem/reasoning/simple.py @@ -22,6 +22,8 @@ from kotaemon.llms import ChatLLM, PromptTemplate from kotaemon.loaders.utils.gpt4v import stream_gpt4v +from .base import BaseReasoning + logger = logging.getLogger(__name__) EVIDENCE_MODE_TEXT = 0 @@ -204,7 +206,7 @@ class AnswerWithContextPipeline(BaseComponent): lang: str = "English" # support English and Japanese async def run( # type: ignore - self, question: str, evidence: str, evidence_mode: int = 0 + self, question: str, evidence: str, evidence_mode: int = 0, **kwargs ) -> Document: """Answer the question based on the evidence @@ -336,7 +338,7 @@ async def run(self, question: str) -> Document: # type: ignore return Document(text=output) -class FullQAPipeline(BaseComponent): +class FullQAPipeline(BaseReasoning): """Question answering pipeline. Handle from question to answer""" class Config: @@ -352,6 +354,8 @@ class Config: async def run( # type: ignore self, message: str, conv_id: str, history: list, **kwargs # type: ignore ) -> Document: # type: ignore + import markdown + docs = [] doc_ids = [] if self.use_rewrite: @@ -364,12 +368,16 @@ async def run( # type: ignore docs.append(doc) doc_ids.append(doc.doc_id) for doc in docs: + # TODO: a better approach to show the information + text = markdown.markdown( + doc.text, extensions=["markdown.extensions.tables"] + ) self.report_output( { "evidence": ( "

" f"{doc.metadata['file_name']}" - f"{doc.text}" + f"{text}" "

" ) } @@ -378,7 +386,12 @@ async def run( # type: ignore evidence_mode, evidence = self.evidence_pipeline(docs).content answer = await self.answering_pipeline( - question=message, evidence=evidence, evidence_mode=evidence_mode + question=message, + history=history, + evidence=evidence, + evidence_mode=evidence_mode, + conv_id=conv_id, + **kwargs, ) # prepare citation @@ -388,14 +401,29 @@ async def run( # type: ignore for quote in fact_with_evidence.substring_quote: for doc in docs: start_idx = doc.text.find(quote) - if start_idx >= 0: + if start_idx == -1: + continue + + end_idx = start_idx + len(quote) + + current_idx = start_idx + if "|" not in doc.text[start_idx:end_idx]: spans[doc.doc_id].append( - { - "start": start_idx, - "end": start_idx + len(quote), - } + {"start": start_idx, "end": end_idx} ) - break + else: + while doc.text[current_idx:end_idx].find("|") != -1: + match_idx = doc.text[current_idx:end_idx].find("|") + spans[doc.doc_id].append( + { + "start": current_idx, + "end": current_idx + match_idx, + } + ) + current_idx += match_idx + 2 + if current_idx > end_idx: + break + break id2docs = {doc.doc_id: doc for doc in docs} lack_evidence = True @@ -414,12 +442,15 @@ async def run( # type: ignore if idx < len(ss) - 1: text += id2docs[id].text[span["end"] : ss[idx + 1]["start"]] text += id2docs[id].text[ss[-1]["end"] :] + text_out = markdown.markdown( + text, extensions=["markdown.extensions.tables"] + ) self.report_output( { "evidence": ( "
" f"{id2docs[id].metadata['file_name']}" - f"{text}" + f"{text_out}" "

" ) } @@ -434,12 +465,15 @@ async def run( # type: ignore {"evidence": "Retrieved segments without matching evidence:\n"} ) for id in list(not_detected): + text_out = markdown.markdown( + id2docs[id].text, extensions=["markdown.extensions.tables"] + ) self.report_output( { "evidence": ( "
" f"{id2docs[id].metadata['file_name']}" - f"{id2docs[id].text}" + f"{text_out}" "

" ) }