From 64004a4dfa8e5c5e3bfc1753a532b1c48adaab05 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 22 Oct 2024 12:08:40 -0700 Subject: [PATCH 01/15] Potential fix for #388 Refactor ollama to be lazy loaded, and moved stuff around --- .gitignore | Bin 10914 -> 10922 bytes .../Gradio_UI/Audio_ingestion_tab.py | 2 +- .../Gradio_UI/Llamafile_tab.py | 31 +- .../Gradio_UI/Website_scraping_tab.py | 1 - .../Local_LLM_Inference_Engine_Lib.py | 31 +- .../Local_LLM/Local_LLM_ollama.py | 175 ++++- App_Function_Libraries/Utils/Utils.py | 5 +- .../A Young Lady's Illustrated Primer.png | Bin Docs/{ => Design}/Ideas.md | 0 Docs/Design/RAG_Notes.md | 707 ------------------ Docs/{ => Design}/RAG_Plan.md | 0 Docs/GUI-Front_Page.PNG | Bin 215841 -> 0 bytes README.md | 2 + summarize.py | 10 +- 14 files changed, 195 insertions(+), 769 deletions(-) rename Docs/{ => Design}/A Young Lady's Illustrated Primer.png (100%) rename Docs/{ => Design}/Ideas.md (100%) delete mode 100644 Docs/Design/RAG_Notes.md rename Docs/{ => Design}/RAG_Plan.md (100%) delete mode 100644 Docs/GUI-Front_Page.PNG diff --git a/.gitignore b/.gitignore index 97a4df3bd74096475f14b697b786080463671aa7..b340f9916eab37f24676e62fc5aad2b0877a77f1 100644 GIT binary patch delta 16 XcmZ1!x+-+TA}x-@f&#sq{B$k=J6#4g delta 7 OcmZ1#x+rwRA}s(8rvq~U diff --git a/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py b/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py index 0a785631f..83f38a60b 100644 --- a/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py +++ b/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py @@ -106,7 +106,7 @@ def update_prompts(preset_name): inputs=preset_prompt, outputs=[custom_prompt_input, system_prompt_input] ) - + global_api_endpoints api_name_input = gr.Dropdown( choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM","ollama", "HuggingFace", "Custom-OpenAI-API"], diff --git a/App_Function_Libraries/Gradio_UI/Llamafile_tab.py b/App_Function_Libraries/Gradio_UI/Llamafile_tab.py index d8b256fa1..f75e968de 100644 --- a/App_Function_Libraries/Gradio_UI/Llamafile_tab.py +++ b/App_Function_Libraries/Gradio_UI/Llamafile_tab.py @@ -19,6 +19,9 @@ # # Functions: +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +MODELS_DIR = os.path.join(BASE_DIR, "Models") + def create_chat_with_llamafile_tab(): # Function to update model path based on selection def on_local_model_change(selected_model: str, search_directory: str) -> str: @@ -35,8 +38,13 @@ def update_dropdowns(search_directory: str) -> Tuple[dict, str]: logging.debug(f"Directory does not exist: {search_directory}") # Debug print for non-existing directory return gr.update(choices=[], value=None), "Directory does not exist." - logging.debug(f"Directory exists: {search_directory}, scanning for files...") # Confirm directory exists - model_files = get_gguf_llamafile_files(search_directory) + try: + logging.debug(f"Directory exists: {search_directory}, scanning for files...") # Confirm directory exists + model_files = get_gguf_llamafile_files(search_directory) + logging.debug("Completed scanning for model files.") + except Exception as e: + logging.error(f"Error scanning directory: {e}") + return gr.update(choices=[], value=None), f"Error scanning directory: {e}" if not model_files: logging.debug(f"No model files found in {search_directory}") # Debug print for no files found @@ -117,15 +125,22 @@ def download_preset_model(selected_model: str) -> Tuple[str, str]: # Option 1: Select from Local Filesystem with gr.Row(): - search_directory = gr.Textbox(label="Model Directory", - placeholder="Enter directory path(currently '.\Models')", - value=".\Models", - interactive=True) + search_directory = gr.Textbox( + label="Model Directory", + placeholder="Enter directory path (currently './Models')", + value=MODELS_DIR, + interactive=True + ) # Initial population of local models - initial_dropdown_update, _ = update_dropdowns(".\Models") + initial_dropdown_update, _ = update_dropdowns(MODELS_DIR) + logging.debug(f"Scanning directory: {MODELS_DIR}") refresh_button = gr.Button("Refresh Models") - local_model_dropdown = gr.Dropdown(label="Select Model from Directory", choices=[]) + local_model_dropdown = gr.Dropdown( + label="Select Model from Directory", + choices=initial_dropdown_update["choices"], + value=None + ) # Display selected model path model_value = gr.Textbox(label="Selected Model File Path", value="", interactive=False) diff --git a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py index a81706d0e..28e3d4fe5 100644 --- a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py +++ b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py @@ -527,7 +527,6 @@ async def scrape_and_summarize_wrapper( return convert_json_to_markdown(json.dumps({"error": f"Invalid JSON format for custom cookies: {e}"})) if scrape_method == "Individual URLs": - # FIXME modify scrape_and_summarize_multiple to accept custom_cookies result = await scrape_and_summarize_multiple(url_input, custom_prompt, api_name, api_key, keywords, custom_titles, system_prompt, summarize_checkbox, custom_cookies=custom_cookies_list) elif scrape_method == "Sitemap": diff --git a/App_Function_Libraries/Local_LLM/Local_LLM_Inference_Engine_Lib.py b/App_Function_Libraries/Local_LLM/Local_LLM_Inference_Engine_Lib.py index abb263f94..6c8e0a37a 100644 --- a/App_Function_Libraries/Local_LLM/Local_LLM_Inference_Engine_Lib.py +++ b/App_Function_Libraries/Local_LLM/Local_LLM_Inference_Engine_Lib.py @@ -14,14 +14,13 @@ #################### # Import necessary libraries #import atexit -import glob import logging import os import re import signal import subprocess import sys -import time +from pathlib import Path from typing import List, Optional # # Import 3rd-pary Libraries @@ -157,21 +156,25 @@ def get_gguf_llamafile_files(directory: str) -> List[str]: """ logging.debug(f"Scanning directory: {directory}") # Debug print for directory - # Print all files in the directory for debugging - all_files = os.listdir(directory) - logging.debug(f"All files in directory: {all_files}") - - pattern_gguf = os.path.join(directory, "*.gguf") - pattern_llamafile = os.path.join(directory, "*.llamafile") + try: + dir_path = Path(directory) + all_files = list(dir_path.iterdir()) + logging.debug(f"All files in directory: {[str(f) for f in all_files]}") + except Exception as e: + logging.error(f"Failed to list files in directory {directory}: {e}") + return [] - gguf_files = glob.glob(pattern_gguf) - llamafile_files = glob.glob(pattern_llamafile) + try: + gguf_files = list(dir_path.glob("*.gguf")) + llamafile_files = list(dir_path.glob("*.llamafile")) - # Debug: Print the files found - logging.debug(f"Found .gguf files: {gguf_files}") - logging.debug(f"Found .llamafile files: {llamafile_files}") + logging.debug(f"Found .gguf files: {[str(f) for f in gguf_files]}") + logging.debug(f"Found .llamafile files: {[str(f) for f in llamafile_files]}") - return [os.path.basename(f) for f in gguf_files + llamafile_files] + return [f.name for f in gguf_files + llamafile_files] + except Exception as e: + logging.error(f"Error during glob operations in directory {directory}: {e}") + return [] # Initialize process with type annotation diff --git a/App_Function_Libraries/Local_LLM/Local_LLM_ollama.py b/App_Function_Libraries/Local_LLM/Local_LLM_ollama.py index 99bc3d1eb..513f9df54 100644 --- a/App_Function_Libraries/Local_LLM/Local_LLM_ollama.py +++ b/App_Function_Libraries/Local_LLM/Local_LLM_ollama.py @@ -5,92 +5,197 @@ import psutil import os import signal - +import logging +import threading +import shutil + +# Configure Logging +# logging.basicConfig( +# level=logging.DEBUG, # Set to DEBUG to capture all levels of logs +# format='%(asctime)s - %(levelname)s - %(message)s', +# handlers=[ +# logging.FileHandler("app.log"), +# logging.StreamHandler() +# ] +# ) + +def is_ollama_installed(): + """ + Checks if the 'ollama' executable is available in the system's PATH. + Returns True if installed, False otherwise. + """ + return shutil.which('ollama') is not None def get_ollama_models(): + """ + Retrieves available Ollama models by executing 'ollama list'. + Returns a list of model names or an empty list if an error occurs. + """ try: - result = subprocess.run(['ollama', 'list'], capture_output=True, text=True, check=True) + result = subprocess.run(['ollama', 'list'], capture_output=True, text=True, check=True, timeout=10) models = result.stdout.strip().split('\n')[1:] # Skip header - return [model.split()[0] for model in models] - except subprocess.CalledProcessError: + model_names = [model.split()[0] for model in models if model.strip()] + logging.debug(f"Available Ollama models: {model_names}") + return model_names + except FileNotFoundError: + logging.error("Ollama executable not found. Please ensure Ollama is installed and in your PATH.") + return [] + except subprocess.TimeoutExpired: + logging.error("Ollama 'list' command timed out.") + return [] + except subprocess.CalledProcessError as e: + logging.error(f"Error executing Ollama 'list': {e}") + return [] + except Exception as e: + logging.error(f"Unexpected error in get_ollama_models: {e}") return [] - def pull_ollama_model(model_name): + """ + Pulls the specified Ollama model if Ollama is installed. + """ + if not is_ollama_installed(): + logging.error("Ollama is not installed.") + return "Failed to pull model: Ollama is not installed or not in your PATH." + try: - subprocess.run(['ollama', 'pull', model_name], check=True) + subprocess.run(['ollama', 'pull', model_name], check=True, timeout=300) # Adjust timeout as needed + logging.info(f"Successfully pulled model: {model_name}") return f"Successfully pulled model: {model_name}" + except subprocess.TimeoutExpired: + logging.error(f"Pulling model '{model_name}' timed out.") + return f"Failed to pull model '{model_name}': Operation timed out." except subprocess.CalledProcessError as e: - return f"Failed to pull model: {e}" - + logging.error(f"Failed to pull model '{model_name}': {e}") + return f"Failed to pull model '{model_name}': {e}" + except FileNotFoundError: + logging.error("Ollama executable not found. Please ensure Ollama is installed and in your PATH.") + return "Failed to pull model: Ollama executable not found." + except Exception as e: + logging.error(f"Unexpected error in pull_ollama_model: {e}") + return f"Failed to pull model '{model_name}': {e}" def serve_ollama_model(model_name, port): + """ + Serves the specified Ollama model on the given port if Ollama is installed. + """ + if not is_ollama_installed(): + logging.error("Ollama is not installed.") + return "Error: Ollama is not installed or not in your PATH." + try: # Check if a server is already running on the specified port for conn in psutil.net_connections(): if conn.laddr.port == int(port): - return f"Port {port} is already in use. Please choose a different port." + logging.warning(f"Port {port} is already in use.") + return f"Error: Port {port} is already in use. Please choose a different port." # Start the Ollama server - port = str(port) - os.environ["OLLAMA_HOST"] = port - cmd = f"ollama serve" - process = subprocess.Popen(cmd, shell=True) - return f"Started Ollama server for model {model_name} on port {port}. Process ID: {process.pid}" + cmd = ['ollama', 'serve', model_name, '--port', str(port)] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + logging.info(f"Started Ollama server for model '{model_name}' on port {port}. PID: {process.pid}") + return f"Started Ollama server for model '{model_name}' on port {port}. Process ID: {process.pid}" + except FileNotFoundError: + logging.error("Ollama executable not found.") + return "Error: Ollama executable not found. Please ensure Ollama is installed and in your PATH." except Exception as e: + logging.error(f"Error starting Ollama server: {e}") return f"Error starting Ollama server: {e}" - def stop_ollama_server(pid): + """ + Stops the Ollama server with the specified process ID if Ollama is installed. + """ + if not is_ollama_installed(): + logging.error("Ollama is not installed.") + return "Error: Ollama is not installed or not in your PATH." + try: if platform.system() == "Windows": - os.system(f"taskkill /F /PID {pid}") - return f"Stopped Ollama server with PID {pid}" - elif platform.system() == "Linux": - os.system(f"kill {pid}") - return f"Stopped Ollama server with PID {pid}" - elif platform.system() == "Darwin": - os.system("""osascript -e 'tell app "Ollama" to quit'""") - return f"(Hopefully) Stopped Ollama server using osascript..." + subprocess.run(['taskkill', '/F', '/PID', str(pid)], check=True) + elif platform.system() in ["Linux", "Darwin"]: + os.kill(pid, signal.SIGTERM) + logging.info(f"Stopped Ollama server with PID {pid}") + return f"Stopped Ollama server with PID {pid}" except ProcessLookupError: + logging.warning(f"No process found with PID {pid}") return f"No process found with PID {pid}" except Exception as e: + logging.error(f"Error stopping Ollama server: {e}") return f"Error stopping Ollama server: {e}" - def create_ollama_tab(): + """ + Creates the Ollama Model Serving tab in the Gradio interface with lazy loading. + """ + ollama_installed = is_ollama_installed() + with gr.Tab("Ollama Model Serving"): + if not ollama_installed: + gr.Markdown( + "# Ollama Model Serving\n\n" + "**Ollama is not installed or not found in your PATH. Please install Ollama to use this feature.**" + ) + return # Exit early, no need to add further components + gr.Markdown("# Ollama Model Serving") with gr.Row(): - model_list = gr.Dropdown(label="Available Models", choices=get_ollama_models()) + # Initialize Dropdowns with placeholders + model_list = gr.Dropdown( + label="Available Models", + choices=["Click 'Refresh Model List' to load models"], + value="Click 'Refresh Model List' to load models" + ) refresh_button = gr.Button("Refresh Model List") with gr.Row(): - new_model_name = gr.Textbox(label="Model to Pull") + new_model_name = gr.Textbox(label="Model to Pull", placeholder="Enter model name") pull_button = gr.Button("Pull Model") pull_output = gr.Textbox(label="Pull Status") with gr.Row(): - # FIXME - Update to update config.txt file - serve_model = gr.Dropdown(label="Model to Serve", choices=get_ollama_models()) + serve_model = gr.Dropdown( + label="Model to Serve", + choices=["Click 'Refresh Model List' to load models"], + value="Click 'Refresh Model List' to load models" + ) port = gr.Number(label="Port", value=11434, precision=0) serve_button = gr.Button("Start Server") serve_output = gr.Textbox(label="Server Status") with gr.Row(): - pid = gr.Number(label="Server Process ID", precision=0) + pid = gr.Number(label="Server Process ID (Enter the PID to stop)", precision=0) stop_button = gr.Button("Stop Server") stop_output = gr.Textbox(label="Stop Status") def update_model_lists(): + """ + Retrieves the list of available Ollama models and updates the dropdowns. + """ models = get_ollama_models() - return gr.update(choices=models), gr.update(choices=models) - - refresh_button.click(update_model_lists, outputs=[model_list, serve_model]) - pull_button.click(pull_ollama_model, inputs=[new_model_name], outputs=[pull_output]) - serve_button.click(serve_ollama_model, inputs=[serve_model, port], outputs=[serve_output]) - stop_button.click(stop_ollama_server, inputs=[pid], outputs=[stop_output]) \ No newline at end of file + if models: + return gr.update(choices=models, value=models[0]), gr.update(choices=models, value=models[0]) + else: + return gr.update(choices=["No models found"], value="No models found"), gr.update(choices=["No models found"], value="No models found") + + def async_update_model_lists(): + """ + Asynchronously updates the model lists to prevent blocking. + """ + def task(): + choices1, choices2 = update_model_lists() + model_list.update(choices=choices1['choices'], value=choices1.get('value')) + serve_model.update(choices=choices2['choices'], value=choices2.get('value')) + threading.Thread(target=task).start() + + # Bind the refresh button to the asynchronous update function + refresh_button.click(fn=async_update_model_lists, inputs=[], outputs=[]) + + # Bind the pull, serve, and stop buttons to their respective functions + pull_button.click(fn=pull_ollama_model, inputs=[new_model_name], outputs=[pull_output]) + serve_button.click(fn=serve_ollama_model, inputs=[serve_model, port], outputs=[serve_output]) + stop_button.click(fn=stop_ollama_server, inputs=[pid], outputs=[stop_output]) diff --git a/App_Function_Libraries/Utils/Utils.py b/App_Function_Libraries/Utils/Utils.py index 5e79bb6c1..4e89c93a7 100644 --- a/App_Function_Libraries/Utils/Utils.py +++ b/App_Function_Libraries/Utils/Utils.py @@ -254,6 +254,8 @@ def load_and_log_configs(): logging.debug(f"Loaded Tabby API IP: {tabby_api_IP}") logging.debug(f"Loaded VLLM API URL: {vllm_api_url}") + # Retrieve default API choices from the configuration file + default_api = config.get('API', 'default_api', fallback='openai') # Retrieve output paths from the configuration file output_path = config.get('Paths', 'output_path', fallback='results') @@ -340,7 +342,8 @@ def load_and_log_configs(): 'embedding_api_key': embedding_api_key, 'chunk_size': chunk_size, 'overlap': overlap - } + }, + 'default_api': default_api } except Exception as e: diff --git a/Docs/A Young Lady's Illustrated Primer.png b/Docs/Design/A Young Lady's Illustrated Primer.png similarity index 100% rename from Docs/A Young Lady's Illustrated Primer.png rename to Docs/Design/A Young Lady's Illustrated Primer.png diff --git a/Docs/Ideas.md b/Docs/Design/Ideas.md similarity index 100% rename from Docs/Ideas.md rename to Docs/Design/Ideas.md diff --git a/Docs/Design/RAG_Notes.md b/Docs/Design/RAG_Notes.md deleted file mode 100644 index 06de131a2..000000000 --- a/Docs/Design/RAG_Notes.md +++ /dev/null @@ -1,707 +0,0 @@ -# RAG Notes - - -### Links -- RAG 101 - * https://www.youtube.com/watch?v=nc0BupOkrhI - * https://arxiv.org/abs/2401.08406 - * https://github.com/NirDiamant/RAG_Techniques?tab=readme-ov-file - * https://github.com/jxnl/n-levels-of-rag - * https://winder.ai/llm-architecture-rag-implementation-design-patterns/ - * https://medium.com/@yufan1602/modular-rag-and-rag-flow-part-%E2%85%B0-e69b32dc13a3 - - -- RAG 201 - * https://medium.com/@cdg2718/why-your-rag-doesnt-work-9755726dd1e9 - * https://www.cazton.com/blogs/technical/advanced-rag-techniques - * https://medium.com/@krtarunsingh/advanced-rag-techniques-unlocking-the-next-level-040c205b95bc - * https://pub.towardsai.net/advanced-rag-techniques-an-illustrated-overview-04d193d8fec6 - * https://winder.ai/llm-architecture-rag-implementation-design-patterns/ - * https://towardsdatascience.com/17-advanced-rag-techniques-to-turn-your-rag-app-prototype-into-a-production-ready-solution-5a048e36cdc8 - * https://medium.com/@samarrana407/mastering-rag-advanced-methods-to-enhance-retrieval-augmented-generation-4b611f6ca99a - * https://generativeai.pub/advanced-rag-retrieval-strategy-query-rewriting-a1dd61815ff0 - * https://medium.com/@yufan1602/modular-rag-and-rag-flow-part-%E2%85%B0-e69b32dc13a3 - * https://pub.towardsai.net/rag-architecture-advanced-rag-3fea83e0d189?gi=47c0b76dbee0 - * https://towardsdatascience.com/3-advanced-document-retrieval-techniques-to-improve-rag-systems-0703a2375e1c - -- Articles - * https://posts.specterops.io/summoning-ragnarok-with-your-nemesis-7c4f0577c93b?gi=7318858af6c3 - * https://blog.demir.io/advanced-rag-implementing-advanced-techniques-to-enhance-retrieval-augmented-generation-systems-0e07301e46f4 - * https://arxiv.org/abs/2312.10997 - * https://jxnl.co/writing/2024/05/22/systematically-improving-your-rag/ - * https://www.arcus.co/blog/rag-at-planet-scale - * https://d-star.ai/embeddings-are-not-all-you-need - -- Architecture Design - - https://medium.com/@yufan1602/modular-rag-and-rag-flow-part-ii-77b62bf8a5d3 - - https://www.anyscale.com/blog/a-comprehensive-guide-for-building-rag-based-llm-applications-part-1 - * https://github.com/ray-project/llm-applications - -Papers - - Rags to Riches - https://huggingface.co/papers/2406.12824 - * LLMs will use foreign knowledge sooner than parametric information. - - Lit Search - * https://arxiv.org/pdf/2407.18940 - * https://arxiv.org/abs/2407.18940 - - -- Building - * https://techcommunity.microsoft.com/t5/microsoft-developer-community/building-the-ultimate-nerdland-podcast-chatbot-with-rag-and-llm/ba-p/4175577 - * https://medium.com/@LakshmiNarayana_U/advanced-rag-techniques-in-ai-retrieval-a-deep-dive-into-the-chroma-course-d8b06118cde3 - * https://rito.hashnode.dev/building-a-multi-hop-qa-with-dspy-and-qdrant - * https://blog.gopenai.com/advanced-retrieval-augmented-generation-rag-techniques-5abad385ac66?gi=09e684acab4d - * https://www.youtube.com/watch?v=bNqSRNMgwhQ - * https://www.youtube.com/watch?v=7h6uDsfD7bg - * https://www.youtube.com/watch?v=Balro-DxFyk&list=PLwPYSl1MQp4FpIzn48ypesKYzLvUBQpPF&index=5 - * https://github.com/jxnl/n-levels-of-rag - * https://rito.hashnode.dev/building-a-multi-hop-qa-with-dspy-and-qdrant - -- Chunking - * https://archive.is/h0oBZ - * https://python.langchain.com/v0.1/docs/modules/data_connection/document_transformers/ - - -- Multi-Modal RAG - * https://docs.llamaindex.ai/en/v0.10.17/examples/multi_modal/multi_modal_pdf_tables.html - * https://archive.is/oIhNp - * https://arxiv.org/html/2407.01449v2 - -- Query Expansion - * https://arxiv.org/abs/2305.03653 - -- Cross-Encoder Ranking - * Deep Neural network that processes two input sequences together as a single input. Allows the model to directly compare and contrast the inputs, understanding their relationship in a more integrated and nuanced manner. - * https://www.sbert.net/examples/applications/retrieve_rerank/README.html - - - -### Aligning with the Money -1. Why is it needed in the first place? -2. Identify & Document the Context - * What is the business objective? - * What led to this objective being identified? - * Why is this the most ideal solution? - * What other solutions have been evaluated? -3. Identify the intended use patterns - * What questions will it answer? - * What answers and what kinds of answers are users expecting? -4. Identify the amount + type of data to be archived/referenced - * Need to identify what methods of metadata creation and settings will be the most cost efficient in time complexity. - * How will you be receiving the data? - * Will you be receiving the data or is it on you to obtain it? -5. What does success look like and how will you know you've achieved it? - * What are the key metrics/values to measure/track? - * How are these connected to the 'Success State'? - - - - - -### Building my RAG Solution -- **Outline** - * Modular architecture design -- **Pre-Retrieval** - * F -- **Retrieval** - * F -- **Post-Retrieval** - * -- **Generation & Post-Generation** - - Prompt Compression - * https://github.com/microsoft/LLMLingua - - **Citations** - * Contextcite: https://github.com/MadryLab/context-cite - - - -### RAG Process -1. Pre-Retrieval - - Raw data creation / Preparation - 1. Prepare data so that text-chunks are self-explanatory -2. **Retrieval** - 1. **Chunk Optimization** - - Naive - Fixed-size (in characters) Overlapping Sliding windows - * `limitations include imprecise control over context size, the risk of cutting words or sentences, and a lack of semantic consideration. Suitable for exploratory analysis but not recommended for tasks requiring deep semantic understanding.` - - Recursive Structure Aware Splitting - * `A hybrid method combining fixed-size sliding window and structure-aware splitting. It attempts to balance fixed chunk sizes with linguistic boundaries, offering precise context control. Implementation complexity is higher, with a risk of variable chunk sizes. Effective for tasks requiring granularity and semantic integrity but not recommended for quick tasks or unclear structural divisions.` - - Structure Aware Splitting (by sentence/paragraph) - * ` Respecting linguistic boundaries preserves semantic integrity, but challenges arise with varying structural complexity. Effective for tasks requiring context and semantics, but unsuitable for texts lacking defined structural divisions.` - - Context-Aware Splitting (Markdown/LaTeX/HTML) - * `ensures content types are not mixed within chunks, maintaining integrity. Challenges include understanding specific syntax and unsuitability for unstructured documents. Useful for structured documents but not recommended for unstructured content.` - - NLP Chunking: Tracking Topic Changes - * `based on semantic understanding, dividing text into chunks by detecting significant shifts in topics. Ensures semantic consistency but demands advanced NLP techniques. Effective for tasks requiring semantic context and topic continuity but not suitable for high topic overlap or simple chunking tasks.` - 2. **Enhancing Data Quality** - - Abbreviations/technical terms/links - * `To mitigate that issue, we can try to ingest that necessary additional context while processing the data, e.g. replace abbreviations with the full text by using an abbreviation translation table.` - 3. **Meta-data** - - You can add metadata to your vector data in all vector databases. Metadata can later help to (pre-)filter the entire vector database before we perform a vector search. - 4. **Optimize Indexing Structure** - * `Full Search vs. Approximate Nearest Neighbor, HNSW vs. IVFPQ` - - Types of Data: - 1. Text - * Chunked and turned into vector embeddings - 2. Images and Diagrams - * Turn into vector embeddings using a multi-modal/vision model - 3. Tables - * Summarized with an LLM, descriptions embedded and used for indexing - * After retrieval, table is used as is. - 4. Code snippets - * Chunked using ? - * Turned into vector embeddings using an embedding model - 1. Chunk Optimization - - Semantic splitter - optimize chunk size used for embedding - - Small-to-Big - - Sliding Window - - Summary of chunks - - Metadata Attachment - 2. **Multi-Representation Indexing** - Convert into compact retrieval units (i.e. summaries) - 1. Parent Document - 2. Dense X - 3. **Specialized Embeddings** - 1. Fine-tuned - 2. ColBERT - 4. **Heirarchical Indexing** - Tree of document summarization at various abstraction levels - 1. **RAPTOR** - Recursive Abstractive Processing for Tree-Organized Retrieval - * https://arxiv.org/pdf/2401.18059 - * `RAPTOR is a novel tree-based retrieval system designed for recursively embedding, clustering, and summarizing text segments. It constructs a tree from the bottom up, offering varying levels of summarization. During inference, RAPTOR retrieves information from this tree, incorporating data from longer documents at various levels of abstraction.` - * https://archive.is/Zgb13 - README - 5. **Knowledge Graphs / GraphRAG** - Use an LLM to construct a graph-based text index - * https://arxiv.org/pdf/2404.16130 - * https://github.com/microsoft/graphrag - - Occurs in two steps: - 1. Derives a knowledge graph from the source documents - 2. Generates community summaries for all closely connected entity groups - * Given a query, each community summary contributes to a partial response. These partial responses are then aggregated to form the final global answer. - - Workflow: - 1. Chunk Source documents - 2. Construct a knowledge graph by extracting entities and their relationships from each chunk. - 3. Simultaneously, Graph RAG employs a multi-stage iterative process. This process requires the LLM to determine if all entities have been extracted, similar to a binary classification problem. - 4. Element Instances → Element Summaries → Graph Communities → Community Summaries - * Graph RAG employs community detection algorithms to identify community structures within the graph, incorporating closely linked entities into the same community. - * `In this scenario, even if LLM fails to identify all variants of an entity consistently during extraction, community detection can help establish the connections between these variants. Once grouped into a community, it signifies that these variants refer to the same entity connotation, just with different expressions or synonyms. This is akin to entity disambiguation in the field of knowledge graph.` - * `After identifying the community, we can generate report-like summaries for each community within the Leiden hierarchy. These summaries are independently useful in understanding the global structure and semantics of the dataset. They can also be used to comprehend the corpus without any problems.` - 5. Community Summaries → Community Answers → Global Answer - 6. **HippoRAG** - * https://github.com/OSU-NLP-Group/HippoRAG - * https://arxiv.org/pdf/2405.14831 - * https://archive.is/Zgb13#selection-2093.24-2093.34 - 7. **spRAG/dsRAG** - README - * https://github.com/D-Star-AI/dsRAG - 5. **Choosing the right embedding model** - * F. - 6. **Self query** - * https://python.langchain.com/v0.1/docs/modules/data_connection/retrievers/self_query/ - 7. **Hybrid & Filtered Vector Search** - * Perform multiple search methods and combine results together - 1. Keyword Search(BM25) + Vector - 2. f - 8. **Query Construction** - - Create a query to interact with a specific DB - 1. Text-to-SQL - * Relational DBs - * Rewrite a query into a SQL query - 2. Text-to-Cyber - * Graph DBs - * Rewrite a query into a cypher query - 3. Self-query Retriever - * Vector DBs - * Auto-generate metadata filters from query - 9. **Query Translation** - 1. Query Decomposition - Decompose or re-phrase the input question - 1. Multi-Query - * https://archive.is/5y4iI - - Sub-Question Querying - * `The core idea of the sub-question strategy is to generate and propose sub-questions related to the main question during the question-answering process to better understand and answer the main question. These sub-questions are usually more specific and can help the system to understand the main question more deeply, thereby improving retrieval accuracy and providing correct answers.` - 1. First, the sub-question strategy generates multiple sub-questions from the user query using LLM (Large Language Model). - 2. Then, each sub-question undergoes the RAG process to obtain its own answer (retrieval generation). - 3. Finally, the answers to all sub-questions are merged to obtain the final answer. - 4. Sub Question prompt: - https://github.com/run-llama/llama_index/blob/main/llama-index-integrations/question_gen/llama-index-question-gen-openai/llama_index/question_gen/openai/base.py#L18-L45 - ``` - You are a world class state of the art agent. - - You have access to multiple tools, each representing a different data source or API. - Each of the tools has a name and a description, formatted as a JSON dictionary. - The keys of the dictionary are the names of the tools and the values are the \ - descriptions. - Your purpose is to help answer a complex user question by generating a list of sub \ - questions that can be answered by the tools. - - These are the guidelines you consider when completing your task: - * Be as specific as possible - * The sub questions should be relevant to the user question - * The sub questions should be answerable by the tools provided - * You can generate multiple sub questions for each tool - * Tools must be specified by their name, not their description - * You don't need to use a tool if you don't think it's relevant - - Output the list of sub questions by calling the SubQuestionList function. - ``` - 2. Step-Back Prompting - * http://arxiv.org/pdf/2310.06117 - * `technique that guides LLM to extract advanced concepts and basic principles from specific instances through abstraction, using these concepts and principles to guide reasoning. This approach significantly improves LLM’s ability to follow the correct reasoning path to solve problems.` - - Flow: - 1. Take in a question - `Estella Leopold went to what school in Aug 1954 and Nov 1954?` - 2. Create a(or multiple) stepback question - `What was Estella Leopold's education history?` - 3. Answer Stepback answer - 4. Perform reasoning using stepback question + answer to create final answer - 3. RAG-Fusion - Combining multiple data sources in one RAG (Walking RAG?) - - 3 parts: - 1. Query Generation - Generate multiple sub-queries from the user’s input to capture diverse perspectives and fully understand the user’s intent. - 2. Sub-query Retrieval - Retrieve relevant information for each sub-query from large datasets and repositories, ensuring comprehensive and in-depth search results. - 3. Reciprocal Rank Fusion - Merge the retrieved documents using Reciprocal Rank Fusion (RRF) to combine their ranks, prioritizing the most relevant and comprehensive results. - 2. Pseudo-Documents - Hypothetical documents - 1. HyDE - * https://arxiv.org/abs/2212.10496 - 10. **Query Enhancement / Rewriting** - - Replacing Acronyms with full phrasing - - Providing synonyms to industry terms - - Literally just ask the LLM to do it. - 11. **Query Extension** - 12. **Query Expansion** - * - 1. Query Expansion with a generated answer - * Paper: https://arxiv.org/abs/2212.10496 - * `We use the LLM to generate an answer, before performing the similarity search. If it is a question that can only be answered using our internal knowledge, we indirectly ask the model to hallucinate, and use the hallucinated answer to search for content that is similar to the answer and not the user query itself.` - * Given an input query, this method first instructs an LLM to provide a hypothetical answer, whatever its correctness. - * Then, the query and the generated answer are combined in a prompt and sent to the retrieval system. - - Implementations: - - HyDE (Hypothetical Document Embeddings) - - Rewrite-Retrieve-Read - - Step-Back Prompting - - Query2Doc - - ITER-RETGEN - - Others? - 2. Query Expansion with multiple related questions - * We ask the LLM to generate N questions related to the original query and then send them all to the retrieval system - * - 13. **Multiple System Prompts** - * Generate multiple prompts, consolidate answer - 14. **Query Routing** - Let LLM decide which datastore to use for information retrieval based on user's query - 1. Logical Routing - Let LLM choose DB based on question - 2. Semantic Routing - embed question and choose prompt based on similarity - 15. **Response Summarization** - Using summaries of returned items - 16. **Ranking*** - 1. Re-Rank - * https://div.beehiiv.com/p/advanced-rag-series-retrieval - 2. RankGPT - 3. RAG-Fusion - 17. **Refinement** - 1. CRAG - * https://arxiv.org/pdf/2401.15884 - * https://medium.com/@kbdhunga/corrective-rag-c-rag-and-control-flow-in-langgraph-d9edad7b5a2c - * https://medium.com/@djangoist/how-to-create-accurate-llm-responses-on-large-code-repositories-presenting-cgrag-a-new-feature-of-e77c0ffe432d - 18. **Active Retrieval** - re-retrieve and or retrieve from new data sources if retrieved documents are not relevant. - 1. CRAG -3. **Post-Retrieval** - 1. **Context Enrichment** - 1. Sentence Window Retriever - * `The text chunk with the highest similarity score represents the best-matching content found. Before sending the content to the LLM we add the k-sentences before and after the text chunk found. This makes sense since the information has a high probability to be connected to the middle part and maybe the piece of information in the middle text chunk is not complete.` - 2. Auto-Merging Retriever - * `The text chunk with the highest similarity score represents the best-matching content found. Before sending the content to the LLM we add each small text chunk's assigned “parent” chunks, which do not necessarily have to be the chunk before and after the text chunk found.` - * We can build on top of that concept and set up a whole hierarchy like a decision tree with different levels of Parent Nodes, Child Nodes and Leaf Nodes. We could for example have 3 levels, with different chunk sizes - See https://docs.llamaindex.ai/en/stable/examples/retrievers/auto_merging_retriever/ -4. **Generation & Post-Generation** - 1. **Self-Reflective RAG / Self-RAG** - - Fine-tuned models/first paper on it: - * https://arxiv.org/abs/2310.11511 - * https://github.com/AkariAsai/self-rag - - Articles - * https://blog.langchain.dev/agentic-rag-with-langgraph/ - - Info: - * We can use outside systems to quantify the quality of retrieval items and generations, and if necessary, re-perform the query or retrieval with a modified input. - 2. **Corrective RAG** - - Key Pieces: - 1. Retrieval Evaluator: - * A lightweight retrieval evaluator is introduced to assess the relevance of retrieved documents. - - It assigns a confidence score and triggers one of three actions: - * Correct: If the document is relevant, refine it to extract key knowledge. - * Incorrect: If the document is irrelevant, discard it and perform a web search for external knowledge. - * Ambiguous: If the evaluator is uncertain, combine internal and external knowledge sources. - 2. Decompose-then-Recompose Algorithm: - * A process to refine retrieved documents by breaking them down into smaller knowledge strips, filtering irrelevant content, and recomposing important information. - 3. Web Search for Corrections: - * When incorrect retrieval occurs, the system leverages large-scale web search to find more diverse and accurate external knowledge.` - 3. **Rewrite-Retrieve-Read (RRR)** - * https://arxiv.org/pdf/2305.14283 - 4. **Choosing the appropriate/correct model** - 5. **Agents** - 6. **Evaluation** - - Metrics: - - Generation - 1. Faithfulness - How factually accurate is the generated answer? - 2. Answer Relevancy - How relevant is the generated answer to the question? - - Retrieval - 1. Context Precision - 2. Context Recall - - Others - 1. Answer semantic Similarity - 2. Answer correctness - 1. Normalized Discounted Cumulative Gain (NDCG) - * https://www.evidentlyai.com/ranking-metrics/ndcg-metric#:~:text=DCG%20measures%20the%20total%20item,ranking%20quality%20in%20the%20dataset. - 2. Existing RAG Eval Frameworks - * RAGAS - https://archive.is/I8f2w - 3. LLM as a Judge - * We generate an evaluation dataset -> Then define a so-called critique agent with suitable criteria we want to evaluate -> Set up a test pipeline that automatically evaluates the responses of the LLMs based on the defined criteria. - 4. Usage Metrics - * Nothing beats real-world data. -5. **Delivery** - - - - -RAG-Fusion - Combining multiple data source in one RAG search - - -JSON file store Vector indexing - -### Chunking - https://github.com/D-Star-AI/dsRAG' -- **Improvements/Ideas** - * As part of chunk header summary, include where in the document this chunk is located, besides chunk #x, so instead this comes from the portion of hte document talking about XYZ in the greater context -- Chunk Headers - * The idea here is to add in higher-level context to the chunk by prepending a chunk header. This chunk header could be as simple as just the document title, or it could use a combination of document title, a concise document summary, and the full hierarchy of section and sub-section titles. -- Chunks -> segments* - * Large chunks provide better context to the LLM than small chunks, but they also make it harder to precisely retrieve specific pieces of information. Some queries (like simple factoid questions) are best handled by small chunks, while other queries (like higher-level questions) require very large chunks. - * We break documents up into chunks with metadata at the head of each chunk to help categorize it to the document/align it with the greater context -- **Semantic Sectioning** - * Semantic sectioning uses an LLM to break a document into sections. It works by annotating the document with line numbers and then prompting an LLM to identify the starting and ending lines for each “semantically cohesive section.” These sections should be anywhere from a few paragraphs to a few pages long. The sections then get broken into smaller chunks if needed. The LLM is also prompted to generate descriptive titles for each section. These section titles get used in the contextual chunk headers created by AutoContext, which provides additional context to the ranking models (embeddings and reranker), enabling better retrieval. - 1. Identify sections - 2. Split sections into chunks - 3. Add metadata header to each chunk - * `Document: X` - * `Section: X1` - * Alt: `Concise parent document summary` - * Other approaches/bits of info can help/experiment... -- **AutoContext** - * `AutoContext creates contextual chunk headers that contain document-level and section-level context, and prepends those chunk headers to the chunks prior to embedding them. This gives the embeddings a much more accurate and complete representation of the content and meaning of the text. In our testing, this feature leads to a dramatic improvement in retrieval quality. In addition to increasing the rate at which the correct information is retrieved, AutoContext also substantially reduces the rate at which irrelevant results show up in the search results. This reduces the rate at which the LLM misinterprets a piece of text in downstream chat and generation applications.` -- **Relevant Segment Extraction** - * Relevant Segment Extraction (RSE) is a query-time post-processing step that takes clusters of relevant chunks and intelligently combines them into longer sections of text that we call segments. These segments provide better context to the LLM than any individual chunk can. For simple factual questions, the answer is usually contained in a single chunk; but for more complex questions, the answer usually spans a longer section of text. The goal of RSE is to intelligently identify the section(s) of text that provide the most relevant information, without being constrained to fixed length chunks. -- **Topic Aware Chunking by Sentence** - * https://blog.gopenai.com/mastering-rag-chunking-techniques-for-enhanced-document-processing-8d5fd88f6b72?gi=2f39fdede29b - - -### Vector DBs -- Indexing mechanisms - * Locality-Sensitive Hashing (LSH) - * Hierarchical Graph Structure - * Inverted File Indexing - * Product Quantization - * Spatial Hashing - * Tree-Based Indexing variations -- Embedding algos - * Word2Vec - * GloVe - * Ada - * BERT - * Instructor -- Similarity Measurement Algos - * Cosine similarity - measuring the cosine of two angles - * Euclidean distance - measuring the distance between two points -- Indexing and Searching Algos - - Approximate Nearest Neighbor (ANN) - * FAISS - * Annoy - * IVF - * HNSW (Heirarchical Navigable small worlds) -- Vector Similarity Search - - `Inverted File (IVF)` - `indexes are used in vector similarity search to map the query vector to a smaller subset of the vector space, reducing the number of vectors compared to the query vector and speeding up Approximate Nearest Neighbor (ANN) search. IVF vectors are efficient and scalable, making them suitable for large-scale datasets. However, the results provided by IVF vectors are approximate, not exact, and creating an IVF index can be resource-intensive, especially for large datasets.` - - `Hierarchical Navigable Small World (HNSW)` - `graphs are among the top-performing indexes for vector similarity search. HNSW is a robust algorithm that produces state-of-the-art performance with fast search speeds and excellent recall. It creates a multi-layered graph, where each layer represents a subset of the data, to quickly traverse these layers to find approximate nearest neighbors. HNSW vectors are versatile and suitable for a wide range of applications, including those that require high-dimensional data spaces. However, the parameters of the HNSW algorithm can be tricky to tune for optimal performance, and creating an HNSW index can also be resource intensive.` -- **Vectorization Process** - - Usually several stages: - 1. Data Pre-processing - * `The initial stage involves preparing the raw data. For text, this might include tokenization (breaking down text into words or phrases), removing stop words, and normalizing the text (like lowercasing). For images, preprocessing might involve resizing, normalization, or augmentation.` - 2. Feature Extraction - * `The system extracts features from the preprocessed data. In text, features could be the frequency of words or the context in which they appear. For images, features could be various visual elements like edges, textures, or color histograms.` - 3. Embedding Generation - * `Using algorithms like Word2Vec for text or CNNs for images, the extracted features are transformed into numerical vectors. These vectors capture the essential qualities of the data in a dense format, typically in a high-dimensional space.` - 4. Dimensionality Reduction - * `Sometimes, the generated vectors might be very high-dimensional, which can be computationally intensive to process. Techniques like PCA (Principal Component Analysis) or t-SNE (t-Distributed Stochastic Neighbor Embedding) are used to reduce the dimensionality while preserving as much of the significant information as possible.` - 5. Normalization - * `Finally, the vectors are often normalized to have a uniform length. This step ensures consistency across the dataset and is crucial for accurately measuring distances or similarities between vectors.` - - - -### Semantic Re-Ranker -* `enhances retrieval quality by re-ranking search results based on deep learning models, ensuring the most relevant results are prioritized.` -- General Steps - 1. Initial Retrieval: a query is processed, and a set of potentially relevant results is fetched. This set is usually larger and broader, encompassing a wide array of documents or data points that might be relevant to the query. - 2. LLM / ML model used to identify relevance - 3. Re-Ranking Process: In this stage, the retrieved results are fed into the deep learning model along with the query. The model assesses each result for its relevance, considering factors such as semantic similarity, context matching, and the query's intent. - 4. Generating a Score: Each result is assigned a relevance score by the model. This scoring is based on how well the content of the result matches the query in terms of meaning, context, and intent. - 5. Sorting Results: Based on the scores assigned, the results are then sorted in descending order of relevance. The top-scoring results are deemed most relevant to the query and are presented to the user. - 6. Continuous Learning and Adaptation: Many Semantic Rankers are designed to learn and adapt over time. By analyzing user interactions with the search results (like which links are clicked), the Ranker can refine its scoring and sorting algorithms, enhancing its accuracy and relevance. -- **Relevance Metrics** -- List of: - 1. Precision and Recall: These are fundamental metrics in information retrieval. Precision measures the proportion of retrieved documents that are relevant, while recall measures the proportion of relevant documents that were retrieved. High precision means that most of the retrieved items are relevant, and high recall means that most of the relevant items are retrieved. - 2. F1 Score: The F1 Score is the harmonic mean of precision and recall. It provides a single metric that balances both precision and recall, useful in scenarios where it's important to find an equilibrium between finding as many relevant items as possible (recall) and ensuring that the retrieved items are mostly relevant (precision). - 3. Normalized Discounted Cumulative Gain (NDCG): Particularly useful in scenarios where the order of results is important (like web search), NDCG takes into account the position of relevant documents in the result list. The more relevant documents appearing higher in the search results, the better the NDCG. - 4. Mean Average Precision (MAP): MAP considers the order of retrieval and the precision at each rank in the result list. It’s especially useful in tasks where the order of retrieval is important but the user is likely to view only the top few results. - - - -### Issues in RAG -1. Indexing - - Issues: - 1. Chunking - 1. Relevance & Precision - * `Properly chunked documents ensure that the retrieved information is highly relevant to the query. If the chunks are too large, they may contain a lot of irrelevant information, diluting the useful content. Conversely, if they are too small, they might miss the broader context, leading to accurate responses but not sufficiently comprehensive.` - 2. Efficiency & Performance - * `The size and structure of the chunks affect the efficiency of the retrieval process. Smaller chunks can be retrieved and processed more quickly, reducing the overall latency of the system. However, there is a balance to be struck, as too many small chunks can overwhelm the retrieval system and negatively impact performance.` - 3. Quality of Generation - * `The quality of the generated output heavily depends on the input retrieved. Well-chunked documents ensure that the generator has access to coherent and contextually rich information, which leads to more informative, coherent, and contextually appropriate responses.` - 4. Scalability - * `As the corpus size grows, chunking becomes even more critical. A well-thought-out chunking strategy ensures that the system can scale effectively, managing more documents without a significant drop in retrieval speed or quality.` - 1. Incomplete Content Representation - * `The semantic information of chunks is influenced by the segmentation method, resulting in the loss or submergence of important information within longer contexts.` - 2. Inaccurate Chunk Similarity Search. - * `As data volume increases, noise in retrieval grows, leading to frequent matching with erroneous data, making the retrieval system fragile and unreliable.` - 3. Unclear Reference Trajectory. - * `The retrieved chunks may originate from any document, devoid of citation trails, potentially resulting in the presence of chunks from multiple different documents that, despite being semantically similar, contain content on entirely different topics.` - - Potential Solutions - - Chunk Optimization - - Sliding window - * overlapping chunks - - Small to Big - * Retrieve small chunks then collect parent from meta data - - Enhance data granularity - apply data cleaning techniques, like removing irrelevant information, confirming factual accuracy, updating outdated information, etc. - - Adding metadata, such as dates, purposes, or chapters, for filtering purposes. - - Structural Organization - - Heirarchical Index - * `In the hierarchical structure of documents, nodes are arranged in parent-child relationships, with chunks linked to them. Data summaries are stored at each node, aiding in the swift traversal of data and assisting the RAG system in determining which chunks to extract. This approach can also mitigate the illusion caused by block extraction issues.` - - Methods for constructing index: - 1. Structural awareness - paragraph and sentence segmentation in docs - 2. Content Awareness - inherent structure in PDF, HTML, Latex - 3. Semantic Awareness - Semantic recognition and segmentation of text based on NLP techniques, such as leveraging NLTK. - 4. Knowledge Graphs -2. Pre-Retrieval - - Issues: - - Poorly worded queries - - Language complexity and ambiguity - - Potential Solutions: - - Multi-Query - Expand original question into multiple - - Sub-Query - `The process of sub-question planning represents the generation of the necessary sub-questions to contextualize and fully answer the original question when combined. ` - - Chain-of-Verification(CoVe) - The expanded queries undergo validation by LLM to achieve the effect of reducing hallucinations. Validated expanded queries typically exhibit higher reliability. - * https://arxiv.org/abs/2309.11495 - - Query Transformation - - Rewrite - * The original queries are not always optimal for LLM retrieval, especially in real-world scenarios. Therefore, we can prompt LLM to rewrite the queries. - - HyDE - * `When responding to queries, LLM constructs hypothetical documents (assumed answers) instead of directly searching the query and its computed vectors in the vector database. It focuses on embedding similarity from answer to answer rather than seeking embedding similarity for the problem or query. In addition, it also includes Reverse HyDE, which focuses on retrieval from query to query.` - * https://medium.aiplanet.com/advanced-rag-improving-retrieval-using-hypothetical-document-embeddings-hyde-1421a8ec075a?gi=b7fa45dc0f32&source=post_page-----e69b32dc13a3-------------------------------- - - Reverse HyDE - * - - Step-back prompting - * https://arxiv.org/abs/2310.06117 - * https://cobusgreyling.medium.com/a-new-prompt-engineering-technique-has-been-introduced-called-step-back-prompting-b00e8954cacb - - Query Routing - * Based on varying queries, routing to distinct RAG pipeline,which is suitable for a versatile RAG system designed to accommodate diverse scenarios. - - Metadata Router/Filter - * `involves extracting keywords (entity) from the query, followed by filtering based on the keywords and metadata within the chunks to narrow down the search scope.` - - Semantic Router - * https://medium.com/ai-insights-cobet/beyond-basic-chatbots-how-semantic-router-is-changing-the-game-783dd959a32d - - CoVe - * https://sourajit16-02-93.medium.com/chain-of-verification-cove-understanding-implementation-e7338c7f4cb5 - * https://www.domingosenise.com/artificial-intelligence/chain-of-verification-cove-an-approach-for-reducing-hallucinations-in-llm-outcomes.html - - Multi-Query - - SubQuery - - Query Construction - - Text-to-Cypher - - Text-to-SQL - * https://blog.langchain.dev/query-construction/?source=post_page-----e69b32dc13a3-------------------------------- -3. Retrieval - - 3 Main considerations: - 1. Retrieval Efficiency - 2. Embedding Quality - 3. Alignment of tasks, data and models - - Sparse Retreiver - * EX: BM25, TF-IDF - - Dense Retriever - * ColBERT - * BGE/Cohere embedding/OpenAI-Ada-002 - - Retriever Fine-tuning - - SFT - - LSR (LM-Supervised Retriever) - - Reinforcement learning - - Adapter - * https://arxiv.org/pdf/2310.18347 - * https://arxiv.org/abs/2305.17331 - ` -4. Post-Retrieval - - Primary Challenges: - 1. Lost in the middle - 2. Noise/anti-fact chunks - 3. Context windows. - - Potential Solutions - - Re-Rank - * Re-rank implementation: https://towardsdatascience.com/enhancing-rag-pipelines-in-haystack-45f14e2bc9f5 - - Rule-based re-rank - * According to certain rules, metrics are calculated to rerank chunks. - * Some: Diversity; Relevance; MRR (Maximal Marginal Relevance, 1998) - - Model based rerank - * Utilize a language model to reorder the document chunks - - Compression & Selection - - LLMLingua - * https://github.com/microsoft/LLMLingua - * https://llmlingua.com/ - - RECOMP - * https://arxiv.org/pdf/2310.04408 - - Selective Context - * https://aclanthology.org/2023.emnlp-main.391.pdf - - Tagging Filter - * https://python.langchain.com/v0.1/docs/use_cases/tagging/ - - LLM Critique -5. Generator - * Utilize the LLM to generate answers based on the user’s query and the retrieved context information. - - Finetuning - * SFT - * RL - * Distillation - - Dual FT - * `In the RAG system, fine-tuning both the retriever and the generator simultaneously is a unique feature of the RAG system. It is important to note that the emphasis of system fine-tuning is on the coordination between the retriever and the generator. Fine-tuning the retriever and the generator separately separately belongs to the combination of the former two, rather than being part of Dual FT.` - * https://arxiv.org/pdf/2310.01352 -6. Orchestration - * `Orchestration refers to the modules used to control the RAG process. RAG no longer follows a fixed process, and it involves making decisions at key points and dynamically selecting the next step based on the results.` - - Scheduling - * `The Judge module assesses critical point in the RAG process, determining the need to retrieve external document repositories, the satisfaction of the answer, and the necessity of further exploration. It is typically used in recursive, iterative, and adaptive retrieval.` - - `Rule-base` - * `The next course of action is determined based on predefined rules. Typically, the generated answers are scored, and then the decision to continue or stop is made based on whether the scores meet predefined thresholds. Common thresholds include confidence levels for tokens.` - - `Prompt-base` - * `LLM autonomously determines the next course of action. There are primarily two approaches to achieve this. The first involves prompting LLM to reflect or make judgments based on the conversation history, as seen in the ReACT framework. The benefit here is the elimination of the need for fine-tuning the model. However, the output format of the judgment depends on the LLM’s adherence to instructions.` - * https://arxiv.org/pdf/2305.06983 - - Tuning based - * The second approach entails LLM generating specific tokens to trigger particular actions, a method that can be traced back to Toolformer and is applied in RAG, such as in Self-RAG. - * https://arxiv.org/pdf/2310.11511 - - Fusion - * `This concept originates from RAG Fusion. As mentioned in the previous section on Query Expansion, the current RAG process is no longer a singular pipeline. It often requires the expansion of retrieval scope or diversity through multiple branches. Therefore, following the expansion to multiple branches, the Fusion module is relied upon to merge multiple answers.` - - Possibility Ensemble - * `The fusion method is based on the weighted values of different tokens generated from multiple beranches, leading to the comprehensive selection of the final output. Weighted averaging is predominantly employed.` - * https://arxiv.org/pdf/2301.12652 - - Reciprocal Rank Fusion - * `RRF, is a technique that combines the rankings of multiple search result lists to generate a single unified ranking. Developed in collaboration with the University of Waterloo (CAN) and Google, RRF produces results that are more effective than reordering chunks under any single branch.` - * https://towardsdatascience.com/forget-rag-the-future-is-rag-fusion-1147298d8ad1 - * https://safjan.com/implementing-rank-fusion-in-python/ -- Semantic dissonance - * `the discordance between your task’s intended meaning, the RAG’s understanding of it, and the underlying knowledge that’s stored.` -- Poor explainability of embeddings -- Semantic Search tends to be directionally correct but inherently fuzzy - * Good for finding top-k results -- Significance of Dimensionality in Vector Embeddings - * `The dimensionality of a vector, which is the length of the vector, plays a crucial role. Higher-dimensional vectors can capture more information and subtle nuances of the data, leading to more accurate models. However, higher dimensionality also increases computational complexity. Therefore, finding the right balance in vector dimensionality is key to efficient and effective model performance.` - - -### Potential Improvements when building -https://gist.github.com/Donavan/62e238aa0a40ca88191255a070e356a2 -- **Chunking** - - Relevance & Precision - - Efficiency and Performance - - Quality of Generation - - Scalability -- **Embeddings** - 1. **Encoder Fine-Tuning** - * `Despite the high efficiency of modern Transformer Encoders, fine-tuning can still yield modest improvements in retrieval quality, especially when tailored to specific domains.` - 2. Ranker Fine-Tuning - * `Employing a cross-encoder for re-ranking can refine the selection of context, ensuring that only the most relevant text chunks are considered.` - 3. LLM Fine-Tuning - * `The advent of LLM fine-tuning APIs allows for the adaptation of models to specific datasets or tasks, enhancing their effectiveness and accuracy in generating responses.` -- **Constructing the Search Index** - 1. **Vector store index** - 2. **Heirarchical Indices** - * Two-tiered index, one for doc summaries the other for detailed chunks - * Filter through the summaries first then search the chunks - 3. **Hypothetical Questions and HyDE approach** - * A novel approach involves the generation of hypothetical questions for each text chunk. These questions are then vectorized and stored, replacing the traditional text vectors in the index. This method enhances semantic alignment between user queries and stored data, potentially leading to more accurate retrievals. The HyDE method reverses this process by generating hypothetical responses to queries, using these as additional data points to refine search accuracy. -- **Context Enrichment** - 1. **Sentence-Window retrieval** - * `This technique enhances search precision by embedding individual sentences and extending the search context to include neighboring sentences. This not only improves the relevance of the retrieved data but also provides the LLM with a richer context for generating responses.` - 2. **Auto-merging Retriever** (Parent Document Retriever) - * `Similar to the Sentence Window Retrieval, this method focuses on granularity but extends the context more broadly. Documents are segmented into a hierarchy of chunks, and smaller, more relevant pieces are initially retrieved. If multiple small chunks relate to a larger segment, they are merged to form a comprehensive context, which is then presented to the LLM.` - 3. **Fusion Retrieval** - * `The concept of fusion retrieval combines traditional keyword-based search methods, like TF-IDF or BM25, with modern vector-based search techniques. This hybrid approach, often implemented using algorithms like Reciprocal Rank Fusion (RRF), optimizes retrieval by integrating diverse similarity measures.` -- **Re-Ranking & Filtering** - * `After the initial retrieval of results using any of the aforementioned sophisticated algorithms, the focus shifts to refining these results through various post-processing techniques.` - * `Various systems enabling the fine-tuning of retrieval outcomes based on similarity scores, keywords, metadata, or through re-ranking with additional models. These models could include an LLM, a sentence-transformer cross-encoder, or even external reranking services like Cohere. Moreover, filtering can also be adjusted based on metadata attributes, such as the recency of the data, ensuring that the most relevant and timely information is prioritized. This stage is critical as it prepares the retrieved data for the final step — feeding it into an LLM to generate the precise answer.` - 1. f - 2. f -- **Query Transformations** - 1. **(Sub-)Query Decomposition** - * `For complex queries that are unlikely to yield direct comparisons or results from existing data (e.g., comparing GitHub stars between Langchain and LlamaIndex), an LLM can break down the query into simpler, more manageable sub-queries. Each sub-query can then be processed independently, with their results synthesized later to form a comprehensive response.` - * Multi Query Retriever and Sub Question Query Engine - - Step-back Prompting - * `method involves using an LLM to generate a broader or more general query from the original, complex query. The aim is to retrieve a higher-level context that can serve as a foundation for answering the more specific original query. The contexts from both the original and the generalized queries are then combined to enhance the final answer generation.` - - Query Rewriting - * https://archive.is/FCiaW - * `Another technique involves using an LLM to reformulate the initial query to improve the retrieval process` - 2. **Reference Citations** - - Direct Source Mention - * Require mention of source IDs directly in generated response. - - Fuzzy Matching - * Align portions of the response with their corresponding text chunks in the index. - - Research: - - Attribution Bench: https://osu-nlp-group.github.io/AttributionBench/ - * Finetuning T5 models outperform otherwise SOTA models. - * Complexity of questions and data are issues. - - ContextCite: https://gradientscience.org/contextcite/ - * Hot shit? - * https://gradientscience.org/contextcite-applications/ - - Metrics - Enabling LLMs to generate text with citations paper - * https://arxiv.org/abs/2305.14627 -- **Chat Engine** - 1. ContextChatEngine: - * `A straightforward approach where the LLM retrieves context relevant to the user’s query along with any previous chat history. This history is then used to inform the LLM’s response, ensuring continuity and relevance in the dialogue.` - 2. CondensePlusContextMode - * ` A more advanced technique where each interaction’s chat history and the last message are condensed into a new query. This refined query is used to retrieve relevant context, which, along with the original user message, is passed to the LLM for generating a response.` -- **Query Routing** - * `Query routing involves strategic decision-making powered by an LLM to determine the most effective subsequent action based on the user’s query. This could include decisions to summarize information, search specific data indices, or explore multiple routes to synthesize a comprehensive answer. Query routers are crucial for selecting the appropriate data source or index, especially in systems where data is stored across multiple platforms, such as vector stores, graph databases, or relational databases.` - - Query Routers - * F -- **Agents in RAG Systems** - 1. **Multi-Document Agent Scheme** - 2. **Walking RAG** - Multi-shot retrieval - - Have the LLM ask for more information as needed and perform searches for said information, to loop back in to asking the LLM if there's enough info. - - Things necessary to facillitate: - * We need to extract partial information from retrieved pieces of source data, so we can learn as we go. - * We need to find new places to look, informed by the source data as well as the question. - * We need to retrieve information from those specific places. - * Links: - * https://olickel.com/retrieval-augmented-research-1-basics - * https://olickel.com/retrieval-augmented-research-2-walking - * https://olickel.com/retrieval-augmented-research-3-use-the-whole-brain - 3. F -- **Response Synthesizer** - * `The simplest method might involve merely concatenating all relevant context with the query and processing it through an LLM. However, more nuanced approaches involve multiple LLM interactions to refine the context and enhance the quality of the final answer.` - 1. Iterative Refinement - * `Breaking down the retrieved context into manageable chunks and sequentially refining the response through multiple LLM interactions.` - 2. Context Summarization - * `Compressing the extensive retrieved context to fit within an LLM’s prompt limitations.` - 3. Multi-Answer Generation - * `Producing several responses from different context segments and then synthesizing these into a unified answer.` -- **Evaluating RAG Performance** - - - -- Semantic + Relevance Ranking - - One example: - * `rank = (cosine similarity) + (weight) x (relevance score)` -- Embedding models need to be fine-tuned to your data for best results - * `For your Q&A system built on support docs, you very well may find that question→question comparisons will materially improve performance opposed to question→support doc. Pragmatically, you can ask ChatGPT to generate example questions for each support doc and have a human expert curate them. In essence you’d be pre-populating your own Stack Overflow.` - - Can create semi-synthetic training data based on your documents - Want to take this “Stack Overflow” methodology one step further? - 1. For each document, ask ChatGPT to generate a list of 100 questions it can answer - 2. These questions won’t be perfect, so for each question you generate, compute cosine similarities with each other document - 3. Filter those questions which would rank the correct document #1 against every other document - 4. Identify the highest-quality questions by sorting those which have the highest difference between cosine similarity of the correct document and the second ranked document - 5. Send to human for further curation -- **Balancing Precision vs Recall** - - List of: - 1. Threshold Tuning: Adjusting the threshold for deciding whether a document is relevant or not can shift the balance between precision and recall. Lowering the threshold may increase recall but decrease precision, and vice versa. - 2. Query Expansion and Refinement: Enhancing the query with additional keywords (query expansion) can increase recall by retrieving a broader set of documents. Conversely, refining the query by adding more specific terms can improve precision. - 3. Relevance Feedback: Incorporating user feedback into the retrieval process can help refine the search results. Users' interactions with the results (clicks, time spent on a document, etc.) can provide valuable signals to adjust the balance between precision and recall. - 4. Use of Advanced Models: Employing more sophisticated models like deep neural networks can improve both precision and recall. These models are better at understanding complex queries and documents, leading to more accurate retrieval. - 5. Customizing Based on Use Case: Different applications may require a different balance of precision and recall. For instance, in a legal document search, precision might be more important to ensure that all retrieved documents are highly relevant. In a medical research scenario, recall might be prioritized to ensure no relevant studies are missed. - - - -- **Prompt Complexity** - 1. Single Fact Retrieval - 2. Multi-Fact Retrieval - 3. Discontigous multi-fact retrieval - 4. Simple Analysis questions - 5. Complex Analysis - 6. Research Level Questions diff --git a/Docs/RAG_Plan.md b/Docs/Design/RAG_Plan.md similarity index 100% rename from Docs/RAG_Plan.md rename to Docs/Design/RAG_Plan.md diff --git a/Docs/GUI-Front_Page.PNG b/Docs/GUI-Front_Page.PNG deleted file mode 100644 index a1d4cddc9b951c93845717af60f44fba3fe20c5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 215841 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%0DsNq{Yr@Kn6n;1bh3b{V-^$=B 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�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�s`G)t@B_9z4gv!ElgAF5e0I?|q=d496im^uhi7 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 UrTyox49`#A0Y5f+UAX$c0Qt2|x&QzG diff --git a/README.md b/README.md index 8ecbd3b31..d715285d5 100644 --- a/README.md +++ b/README.md @@ -583,6 +583,8 @@ In order of attempts: - https://github.com/fedirz/faster-whisper-server - https://github.com/transcriptionstream/transcriptionstream - https://github.com/lifan0127/ai-research-assistant +- Open Source: + * https://github.com/lfnovo/open_notebook - Commercial offerings: * Bit.ai * typeset.io/ diff --git a/summarize.py b/summarize.py index 53872bd5c..65584da0d 100644 --- a/summarize.py +++ b/summarize.py @@ -24,8 +24,8 @@ from App_Function_Libraries.Local_File_Processing_Lib import read_paths_from_file, process_local_file from App_Function_Libraries.DB.DB_Manager import add_media_to_database from App_Function_Libraries.Utils.System_Checks_Lib import cuda_check, platform_check, check_ffmpeg -from App_Function_Libraries.Utils.Utils import load_and_log_configs, create_download_directory, extract_text_from_segments, \ - cleanup_downloads +from App_Function_Libraries.Utils.Utils import load_and_log_configs, create_download_directory, \ + extract_text_from_segments, cleanup_downloads from App_Function_Libraries.Video_DL_Ingestion_Lib import download_video, extract_video_info # # 3rd-Party Module Imports @@ -82,6 +82,12 @@ log_file_path, maxBytes=max_bytes, backupCount=backup_count ) +global_api_endpoints = ["anthropic", "cohere", "groq", "openai", "huggingface", "openrouter", "deepseek", "mistral", "custom_openai_api", "llama", "ooba", "kobold", "tabby", "vllm", "ollama", "aphrodite"] +# Setup Default API Endpoint +loaded_config_data = load_and_log_configs() + +default_api_endpoint = loaded_config_data['default_api'] +print(f"Default API Endpoint: {default_api_endpoint}") # # ####################### From 50118235c0c5c114c1ca8b61fc7ff31ae872e502 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 22 Oct 2024 17:45:48 -0700 Subject: [PATCH 02/15] Added global API config + XML chunking/Ingestion --- .../Audio/Audio_Transcription_Lib.py | 2 +- App_Function_Libraries/Chunk_Lib.py | 61 ++++- App_Function_Libraries/Gradio_Related.py | 3 + .../Gradio_UI/Audio_ingestion_tab.py | 25 +- .../Gradio_UI/Book_Ingestion_tab.py | 22 +- .../Gradio_UI/Character_Chat_tab.py | 24 +- .../Gradio_UI/Character_interaction_tab.py | 45 +++- .../Gradio_UI/Chat_Workflows.py | 21 +- App_Function_Libraries/Gradio_UI/Chat_ui.py | 82 ++++-- .../Gradio_UI/Evaluations_Benchmarks_tab.py | 19 +- .../Gradio_UI/Explain_summarize_tab.py | 22 +- .../Gradio_UI/Live_Recording.py | 19 ++ .../Gradio_UI/Media_wiki_tab.py | 7 + .../Gradio_UI/Plaintext_tab_import.py | 64 ++--- .../Gradio_UI/Podcast_tab.py | 23 +- .../Gradio_UI/Prompt_Suggestion_tab.py | 21 +- .../Gradio_UI/RAG_Chat_tab.py | 21 +- .../Gradio_UI/RAG_QA_Chat_tab.py | 37 ++- .../Gradio_UI/Re_summarize_tab.py | 23 +- .../Gradio_UI/Video_transcription_tab.py | 22 +- .../Gradio_UI/Website_scraping_tab.py | 21 +- .../Gradio_UI/Writing_tab.py | 23 +- .../Gradio_UI/XML_Ingestion_Tab.py | 68 +++++ App_Function_Libraries/LLM_API_Calls_Local.py | 2 - .../Plaintext/Plaintext_Files.py | 56 +++++ .../Plaintext/XML_Ingestion_Lib.py | 46 ++++ App_Function_Libraries/Utils/Utils.py | 29 +++ Docs/Issues/ISSUES.md | 5 +- README.md | 237 ++++++++---------- summarize.py | 5 - 30 files changed, 757 insertions(+), 298 deletions(-) create mode 100644 App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py create mode 100644 App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py diff --git a/App_Function_Libraries/Audio/Audio_Transcription_Lib.py b/App_Function_Libraries/Audio/Audio_Transcription_Lib.py index 0dede119d..e78c86f22 100644 --- a/App_Function_Libraries/Audio/Audio_Transcription_Lib.py +++ b/App_Function_Libraries/Audio/Audio_Transcription_Lib.py @@ -332,4 +332,4 @@ def save_audio_temp(audio_data, sample_rate=16000): # # -####################################################################################################################### \ No newline at end of file +####################################################################################################################### diff --git a/App_Function_Libraries/Chunk_Lib.py b/App_Function_Libraries/Chunk_Lib.py index 3c02e106d..0b068f669 100644 --- a/App_Function_Libraries/Chunk_Lib.py +++ b/App_Function_Libraries/Chunk_Lib.py @@ -11,6 +11,7 @@ import logging import re from typing import Any, Dict, List, Optional, Tuple +import xml.etree.ElementTree as ET # # Import 3rd party from openai import OpenAI @@ -23,7 +24,6 @@ from sklearn.metrics.pairwise import cosine_similarity # # Import Local -from App_Function_Libraries.Tokenization_Methods_Lib import openai_tokenize from App_Function_Libraries.Utils.Utils import load_comprehensive_config # ####################################################################################################################### @@ -943,6 +943,65 @@ def chunk_ebook_by_chapters(text: str, chunk_options: Dict[str, Any]) -> List[Di # # End of ebook chapter chunking ####################################################################################################################### +# +# XML Chunking + +def chunk_xml(xml_text: str, chunk_options: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Chunk XML content while preserving structure. + """ + logging.debug("chunk_xml started...") + try: + root = ET.fromstring(xml_text) + chunks = [] + + # Get chunking parameters + max_size = chunk_options.get('max_size', 1000) + overlap = chunk_options.get('overlap', 0) + + # Process each major section/element + for element in root: + # Extract text content from the element and its children + text_content = [] + for child in element.iter(): + if child.text and child.text.strip(): + text_content.append(child.text.strip()) + + element_text = '\n'.join(text_content) + + # Use existing chunking methods based on the content + element_chunks = chunk_text( + element_text, + method=chunk_options.get('method', 'words'), + max_size=max_size, + overlap=overlap, + language=chunk_options.get('language', None) + ) + + # Add metadata for each chunk + for i, chunk in enumerate(element_chunks): + metadata = { + 'element_tag': element.tag, + 'element_attributes': dict(element.attrib), + 'chunk_index': i + 1, + 'total_chunks': len(element_chunks), + 'chunk_method': 'xml', + 'max_size': max_size, + 'overlap': overlap + } + chunks.append({ + 'text': chunk, + 'metadata': metadata + }) + + return chunks + except ET.ParseError as e: + logging.error(f"Error parsing XML: {str(e)}") + raise + +# +# End of XML Chunking +####################################################################################################################### ####################################################################################################################### # diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 56303dccb..5bfe026ca 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -66,6 +66,7 @@ # # Gradio UI Imports from App_Function_Libraries.Gradio_UI.Evaluations_Benchmarks_tab import create_geval_tab, create_infinite_bench_tab +from App_Function_Libraries.Gradio_UI.XML_Ingestion_Tab import create_xml_import_tab #from App_Function_Libraries.Local_LLM.Local_LLM_huggingface import create_huggingface_tab from App_Function_Libraries.Local_LLM.Local_LLM_ollama import create_ollama_tab # @@ -276,6 +277,7 @@ def launch_ui(share_public=None, server_mode=False): create_podcast_tab() create_import_book_tab() create_plain_text_import_tab() + create_xml_import_tab() create_website_scraping_tab() create_pdf_ingestion_tab() create_pdf_ingestion_test_tab() @@ -284,6 +286,7 @@ def launch_ui(share_public=None, server_mode=False): create_live_recording_tab() create_arxiv_tab() + with gr.TabItem("Text Search", id="text search", visible=True): create_search_tab() create_search_summaries_tab() diff --git a/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py b/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py index 83f38a60b..a9317ba17 100644 --- a/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py +++ b/App_Function_Libraries/Gradio_UI/Audio_ingestion_tab.py @@ -2,6 +2,7 @@ # Description: Gradio UI for ingesting audio files into the database # # Imports +import logging # # External Imports import gradio as gr @@ -11,7 +12,8 @@ from App_Function_Libraries.DB.DB_Manager import load_preset_prompts from App_Function_Libraries.Gradio_UI.Chat_ui import update_user_prompt from App_Function_Libraries.Gradio_UI.Gradio_Shared import whisper_models -from App_Function_Libraries.Utils.Utils import cleanup_temp_files +from App_Function_Libraries.Utils.Utils import cleanup_temp_files, default_api_endpoint, global_api_endpoints, \ + format_api_name # Import metrics logging from App_Function_Libraries.Metrics.metrics_logger import log_counter, log_histogram from App_Function_Libraries.Metrics.logger_config import logger @@ -22,6 +24,18 @@ def create_audio_processing_tab(): with gr.TabItem("Audio File Transcription + Summarization", visible=True): gr.Markdown("# Transcribe & Summarize Audio Files from URLs or Local Files!") + # Get and validate default value + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None + with gr.Row(): with gr.Column(): audio_url_input = gr.Textbox(label="Audio File URL(s)", placeholder="Enter the URL(s) of the audio file(s), one per line") @@ -106,12 +120,11 @@ def update_prompts(preset_name): inputs=preset_prompt, outputs=[custom_prompt_input, system_prompt_input] ) - global_api_endpoints + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM","ollama", "HuggingFace", "Custom-OpenAI-API"], - value=None, - label="API for Summarization (Optional)" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) api_key_input = gr.Textbox(label="API Key (if required)", placeholder="Enter your API key here", type="password") custom_keywords_input = gr.Textbox(label="Custom Keywords", placeholder="Enter custom keywords, comma-separated") diff --git a/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py b/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py index 28e60b09a..c560bedc7 100644 --- a/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py +++ b/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py @@ -10,10 +10,15 @@ # Imports # # External Imports +import logging + import gradio as gr # # Local Imports from App_Function_Libraries.Books.Book_Ingestion_Lib import process_zip_file, import_epub, import_file_handler +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # ######################################################################################################################## # @@ -22,6 +27,16 @@ def create_import_book_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Ebook(epub) Files", visible=True): with gr.Row(): with gr.Column(): @@ -56,10 +71,11 @@ def create_import_book_tab(): custom_prompt_input = gr.Textbox(label="Custom User Prompt", placeholder="Enter a custom user prompt for summarization (optional)") auto_summarize_checkbox = gr.Checkbox(label="Auto-summarize", value=False) + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace"], - label="API for Auto-summarization" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) api_key_input = gr.Textbox(label="API Key", type="password") diff --git a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py index f9723fdd0..90200a50b 100644 --- a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py +++ b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py @@ -34,7 +34,10 @@ delete_character_card, update_character_card, search_character_chats, ) -from App_Function_Libraries.Utils.Utils import sanitize_user_input +from App_Function_Libraries.Utils.Utils import sanitize_user_input, format_api_name, global_api_endpoints, \ + default_api_endpoint + + # ############################################################################################################ # @@ -252,6 +255,16 @@ def export_all_characters(): # Gradio tabs def create_character_card_interaction_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Chat with a Character Card", visible=True): gr.Markdown("# Chat with a Character Card") with gr.Row(): @@ -265,13 +278,10 @@ def create_character_card_interaction_tab(): load_characters_button = gr.Button("Load Existing Characters") character_dropdown = gr.Dropdown(label="Select Character", choices=[]) user_name_input = gr.Textbox(label="Your Name", placeholder="Enter your name here") + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[ - "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", - "Custom-OpenAI-API" - ], - value="HuggingFace", + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, label="API for Interaction (Mandatory)" ) api_key_input = gr.Textbox( diff --git a/App_Function_Libraries/Gradio_UI/Character_interaction_tab.py b/App_Function_Libraries/Gradio_UI/Character_interaction_tab.py index 0e629def1..645873274 100644 --- a/App_Function_Libraries/Gradio_UI/Character_interaction_tab.py +++ b/App_Function_Libraries/Gradio_UI/Character_interaction_tab.py @@ -20,6 +20,9 @@ from App_Function_Libraries.Chat import chat, load_characters, save_chat_history_to_db_wrapper from App_Function_Libraries.Gradio_UI.Chat_ui import chat_wrapper from App_Function_Libraries.Gradio_UI.Writing_tab import generate_writing_feedback +from App_Function_Libraries.Utils.Utils import default_api_endpoint, format_api_name, global_api_endpoints + + # ######################################################################################################################## # @@ -253,6 +256,16 @@ def character_interaction(character1: str, character2: str, api_endpoint: str, a def create_multiple_character_chat_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Multi-Character Chat", visible=True): characters, conversation, current_character, other_character = character_interaction_setup() @@ -264,13 +277,12 @@ def create_multiple_character_chat_tab(): character_selectors = [gr.Dropdown(label=f"Character {i + 1}", choices=list(characters.keys())) for i in range(4)] - api_endpoint = gr.Dropdown(label="API Endpoint", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", - "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", - "ollama", "HuggingFace", - "Custom-OpenAI-API"], - value="HuggingFace") + # Refactored API selection dropdown + api_endpoint = gr.Dropdown( + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Interaction (Optional)" + ) api_key = gr.Textbox(label="API Key (if required)", type="password") temperature = gr.Slider(label="Temperature", minimum=0.1, maximum=1.0, step=0.1, value=0.7) scenario = gr.Textbox(label="Scenario (optional)", lines=3) @@ -393,17 +405,26 @@ def take_turn_with_error_handling(conversation, current_index, char1, char2, cha # From `Fuzzlewumper` on Reddit. def create_narrator_controlled_conversation_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Narrator-Controlled Conversation", visible=True): gr.Markdown("# Narrator-Controlled Conversation") with gr.Row(): with gr.Column(scale=1): + # Refactored API selection dropdown api_endpoint = gr.Dropdown( - label="API Endpoint", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", - "Custom-OpenAI-API"], - value="HuggingFace" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Interaction (Optional)" ) api_key = gr.Textbox(label="API Key (if required)", type="password") temperature = gr.Slider(label="Temperature", minimum=0.1, maximum=1.0, step=0.1, value=0.7) diff --git a/App_Function_Libraries/Gradio_UI/Chat_Workflows.py b/App_Function_Libraries/Gradio_UI/Chat_Workflows.py index 8ad2ad22e..802df6de9 100644 --- a/App_Function_Libraries/Gradio_UI/Chat_Workflows.py +++ b/App_Function_Libraries/Gradio_UI/Chat_Workflows.py @@ -12,6 +12,8 @@ from App_Function_Libraries.Gradio_UI.Chat_ui import chat_wrapper, search_conversations, \ load_conversation from App_Function_Libraries.Chat import save_chat_history_to_db_wrapper +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + # ############################################################################################################ # @@ -24,6 +26,16 @@ def chat_workflows_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Chat Workflows", visible=True): gr.Markdown("# Workflows using LLMs") chat_history = gr.State([]) @@ -35,12 +47,11 @@ def chat_workflows_tab(): with gr.Row(): with gr.Column(): workflow_selector = gr.Dropdown(label="Select Workflow", choices=[wf['name'] for wf in workflows]) + # Refactored API selection dropdown api_selector = gr.Dropdown( - label="Select API Endpoint", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", - "Custom-OpenAI-API"], - value="HuggingFace" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Interaction (Optional)" ) api_key_input = gr.Textbox(label="API Key (optional)", type="password") temperature = gr.Slider(label="Temperature", minimum=0.00, maximum=1.0, step=0.05, value=0.7) diff --git a/App_Function_Libraries/Gradio_UI/Chat_ui.py b/App_Function_Libraries/Gradio_UI/Chat_ui.py index 91c2dcdbd..e1410a0bb 100644 --- a/App_Function_Libraries/Gradio_UI/Chat_ui.py +++ b/App_Function_Libraries/Gradio_UI/Chat_ui.py @@ -17,6 +17,7 @@ from App_Function_Libraries.DB.DB_Manager import add_chat_message, search_chat_conversations, create_chat_conversation, \ get_chat_messages, update_chat_message, delete_chat_message, load_preset_prompts, db from App_Function_Libraries.Gradio_UI.Gradio_Shared import update_dropdown, update_user_prompt +from App_Function_Libraries.Utils.Utils import default_api_endpoint, format_api_name, global_api_endpoints # @@ -201,6 +202,16 @@ def regenerate_last_message(history, media_content, selected_parts, api_endpoint return new_history, "Last message regenerated successfully." def create_chat_interface(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None custom_css = """ .chatbot-container .message-wrap .message { font-size: 14px !important; @@ -237,11 +248,12 @@ def create_chat_interface(): with gr.Row(): load_conversations_btn = gr.Button("Load Selected Conversation") - api_endpoint = gr.Dropdown(label="Select API Endpoint", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", - "Mistral", "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", - "HuggingFace"]) + # Refactored API selection dropdown + api_endpoint = gr.Dropdown( + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Interaction (Optional)" + ) api_key = gr.Textbox(label="API Key (if required)", type="password") custom_prompt_checkbox = gr.Checkbox(label="Use a Custom Prompt", value=False, @@ -412,6 +424,16 @@ def clear_chat(): def create_chat_interface_stacked(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None custom_css = """ .chatbot-container .message-wrap .message { font-size: 14px !important; @@ -446,10 +468,12 @@ def create_chat_interface_stacked(): search_conversations_btn = gr.Button("Search Conversations") load_conversations_btn = gr.Button("Load Selected Conversation") with gr.Column(): - api_endpoint = gr.Dropdown(label="Select API Endpoint", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", - "OpenRouter", "Mistral", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", - "VLLM", "ollama", "HuggingFace"]) + # Refactored API selection dropdown + api_endpoint = gr.Dropdown( + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Interaction (Optional)" + ) api_key = gr.Textbox(label="API Key (if required)", type="password") preset_prompt = gr.Dropdown(label="Select Preset Prompt", choices=load_preset_prompts(), @@ -571,6 +595,16 @@ def update_prompts(preset_name): # FIXME - System prompts def create_chat_interface_multi_api(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None custom_css = """ .chatbot-container .message-wrap .message { font-size: 14px !important; @@ -609,10 +643,12 @@ def create_chat_interface_multi_api(): for i in range(3): with gr.Column(): gr.Markdown(f"### Chat Window {i + 1}") - api_endpoint = gr.Dropdown(label=f"API Endpoint {i + 1}", - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", - "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", "Kobold", - "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace"]) + # Refactored API selection dropdown + api_endpoint = gr.Dropdown( + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Interaction (Optional)" + ) api_key = gr.Textbox(label=f"API Key {i + 1} (if required)", type="password") temperature = gr.Slider(label=f"Temperature {i + 1}", minimum=0.0, maximum=1.0, step=0.05, value=0.7) @@ -749,6 +785,16 @@ def regenerate_last_message(chat_history, chatbot, media_content, selected_parts def create_chat_interface_four(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None custom_css = """ .chatbot-container .message-wrap .message { font-size: 14px !important; @@ -781,13 +827,11 @@ def create_chat_interface_four(): def create_single_chat_interface(index, user_prompt_component): with gr.Column(): gr.Markdown(f"### Chat Window {index + 1}") + # Refactored API selection dropdown api_endpoint = gr.Dropdown( - label=f"API Endpoint {index + 1}", - choices=[ - "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", - "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", "Kobold", - "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace" - ] + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Interaction (Optional)" ) api_key = gr.Textbox( label=f"API Key {index + 1} (if required)", diff --git a/App_Function_Libraries/Gradio_UI/Evaluations_Benchmarks_tab.py b/App_Function_Libraries/Gradio_UI/Evaluations_Benchmarks_tab.py index 4e27d784f..e7d59d2ca 100644 --- a/App_Function_Libraries/Gradio_UI/Evaluations_Benchmarks_tab.py +++ b/App_Function_Libraries/Gradio_UI/Evaluations_Benchmarks_tab.py @@ -1,9 +1,12 @@ ################################################################################################### # Evaluations_Benchmarks_tab.py - Gradio code for G-Eval testing # We will use the G-Eval API to evaluate the quality of the generated summaries. +import logging import gradio as gr from App_Function_Libraries.Benchmarks_Evaluations.ms_g_eval import run_geval +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + def create_geval_tab(): with gr.Tab("G-Eval", visible=True): @@ -31,13 +34,25 @@ def create_geval_tab(): def create_infinite_bench_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.Tab("Infinite Bench", visible=True): gr.Markdown("# Infinite Bench Evaluation (Coming Soon)") with gr.Row(): with gr.Column(): + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=["OpenAI", "Anthropic", "Cohere", "Groq", "OpenRouter", "DeepSeek", "HuggingFace", "Mistral", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "Local-LLM", "Ollama"], - label="Select API" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization (Optional)" ) api_key_input = gr.Textbox(label="API Key (if required)", type="password") evaluate_button = gr.Button("Evaluate Summary") diff --git a/App_Function_Libraries/Gradio_UI/Explain_summarize_tab.py b/App_Function_Libraries/Gradio_UI/Explain_summarize_tab.py index 6f549b6b9..fbf5505ac 100644 --- a/App_Function_Libraries/Gradio_UI/Explain_summarize_tab.py +++ b/App_Function_Libraries/Gradio_UI/Explain_summarize_tab.py @@ -17,6 +17,9 @@ from App_Function_Libraries.Summarization.Summarization_General_Lib import summarize_with_openai, summarize_with_anthropic, \ summarize_with_cohere, summarize_with_groq, summarize_with_openrouter, summarize_with_deepseek, \ summarize_with_huggingface +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # # ############################################################################################################ @@ -24,6 +27,16 @@ # Functions: def create_summarize_explain_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Analyze Text", visible=True): gr.Markdown("# Analyze / Explain / Summarize Text without ingesting it into the DB") with gr.Row(): @@ -72,12 +85,11 @@ def create_summarize_explain_tab(): lines=3, visible=False, interactive=True) + # Refactored API selection dropdown api_endpoint = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", "Custom-OpenAI-API"], - value=None, - label="API to be used for request (Mandatory)" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) with gr.Row(): api_key_input = gr.Textbox(label="API Key (if required)", placeholder="Enter your API key here", diff --git a/App_Function_Libraries/Gradio_UI/Live_Recording.py b/App_Function_Libraries/Gradio_UI/Live_Recording.py index b19c3664d..5f27bd052 100644 --- a/App_Function_Libraries/Gradio_UI/Live_Recording.py +++ b/App_Function_Libraries/Gradio_UI/Live_Recording.py @@ -13,6 +13,8 @@ stop_recording) from App_Function_Libraries.DB.DB_Manager import add_media_to_database from App_Function_Libraries.Metrics.metrics_logger import log_counter, log_histogram +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + # ####################################################################################################################### # @@ -22,6 +24,16 @@ "distil-large-v2", "distil-medium.en", "distil-small.en"] def create_live_recording_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.Tab("Live Recording and Transcription", visible=True): gr.Markdown("# Live Audio Recording and Transcription") with gr.Row(): @@ -34,6 +46,13 @@ def create_live_recording_tab(): custom_title = gr.Textbox(label="Custom Title (for database)", visible=False) record_button = gr.Button("Start Recording") stop_button = gr.Button("Stop Recording") + # FIXME - Add a button to perform analysis/summarization on the transcription + # Refactored API selection dropdown + # api_name_input = gr.Dropdown( + # choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + # value=default_value, + # label="API for Summarization (Optional)" + # ) with gr.Column(): output = gr.Textbox(label="Transcription", lines=10) audio_output = gr.Audio(label="Recorded Audio", visible=False) diff --git a/App_Function_Libraries/Gradio_UI/Media_wiki_tab.py b/App_Function_Libraries/Gradio_UI/Media_wiki_tab.py index 9a1aeb93a..54475b818 100644 --- a/App_Function_Libraries/Gradio_UI/Media_wiki_tab.py +++ b/App_Function_Libraries/Gradio_UI/Media_wiki_tab.py @@ -32,6 +32,13 @@ def create_mediawiki_import_tab(): value="sentences", label="Chunking Method" ) + # FIXME - add API selection dropdown + Analysis/Summarization options + # Refactored API selection dropdown + # api_name_input = gr.Dropdown( + # choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + # value=default_value, + # label="API for Summarization (Optional)" + # ) chunk_size = gr.Slider(minimum=100, maximum=2000, value=1000, step=100, label="Chunk Size") chunk_overlap = gr.Slider(minimum=0, maximum=500, value=100, step=10, label="Chunk Overlap") # FIXME - Add checkbox for 'Enable Summarization upon ingestion' for API summarization of chunks diff --git a/App_Function_Libraries/Gradio_UI/Plaintext_tab_import.py b/App_Function_Libraries/Gradio_UI/Plaintext_tab_import.py index f16c12f8f..7c02f810a 100644 --- a/App_Function_Libraries/Gradio_UI/Plaintext_tab_import.py +++ b/App_Function_Libraries/Gradio_UI/Plaintext_tab_import.py @@ -6,6 +6,7 @@ ####################################################################################################################### # # Import necessary libraries +import logging import os import tempfile import zipfile @@ -17,12 +18,24 @@ # # Import Local libraries from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data +from App_Function_Libraries.Plaintext.Plaintext_Files import import_file_handler +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name # ####################################################################################################################### # # Functions: def create_plain_text_import_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Import Plain text & .docx Files", visible=True): with gr.Row(): with gr.Column(): @@ -52,60 +65,17 @@ def create_plain_text_import_tab(): ) custom_prompt_input = gr.Textbox(label="Custom User Prompt", placeholder="Enter a custom user prompt for summarization (optional)") auto_summarize_checkbox = gr.Checkbox(label="Auto-summarize", value=False) + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace"], - label="API for Auto-summarization" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) api_key_input = gr.Textbox(label="API Key", type="password") import_button = gr.Button("Import File(s)") with gr.Column(): import_output = gr.Textbox(label="Import Status") - - def import_plain_text_file(file_path, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key): - try: - # Determine the file type and convert if necessary - file_extension = os.path.splitext(file_path)[1].lower() - if file_extension == '.rtf': - with tempfile.NamedTemporaryFile(suffix='.md', delete=False) as temp_file: - convert_file(file_path, 'md', outputfile=temp_file.name) - file_path = temp_file.name - elif file_extension == '.docx': - content = docx2txt.process(file_path) - else: - with open(file_path, 'r', encoding='utf-8') as file: - content = file.read() - - # Process the content - return import_data(content, title, author, keywords, system_prompt, - user_prompt, auto_summarize, api_name, api_key) - except Exception as e: - return f"Error processing file: {str(e)}" - - def process_plain_text_zip_file(zip_file, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key): - results = [] - with tempfile.TemporaryDirectory() as temp_dir: - with zipfile.ZipFile(zip_file.name, 'r') as zip_ref: - zip_ref.extractall(temp_dir) - - for filename in os.listdir(temp_dir): - if filename.lower().endswith(('.md', '.txt', '.rtf', '.docx')): - file_path = os.path.join(temp_dir, filename) - result = import_plain_text_file(file_path, title, author, keywords, system_prompt, - user_prompt, auto_summarize, api_name, api_key) - results.append(f"File: {filename} - {result}") - - return "\n".join(results) - - def import_file_handler(file, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key): - if file.name.lower().endswith(('.md', '.txt', '.rtf', '.docx')): - return import_plain_text_file(file.name, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key) - elif file.name.lower().endswith('.zip'): - return process_plain_text_zip_file(file, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key) - else: - return "Unsupported file type. Please upload a .md, .txt, .rtf, .docx file or a .zip file containing these file types." - import_button.click( fn=import_file_handler, inputs=[import_file, title_input, author_input, keywords_input, system_prompt_input, diff --git a/App_Function_Libraries/Gradio_UI/Podcast_tab.py b/App_Function_Libraries/Gradio_UI/Podcast_tab.py index d31051f82..2372c1277 100644 --- a/App_Function_Libraries/Gradio_UI/Podcast_tab.py +++ b/App_Function_Libraries/Gradio_UI/Podcast_tab.py @@ -4,12 +4,17 @@ # Imports # # External Imports +import logging + import gradio as gr # # Local Imports from App_Function_Libraries.Audio.Audio_Files import process_podcast from App_Function_Libraries.DB.DB_Manager import load_preset_prompts from App_Function_Libraries.Gradio_UI.Gradio_Shared import whisper_models, update_user_prompt +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # ######################################################################################################################## # @@ -17,6 +22,16 @@ def create_podcast_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Podcast", visible=True): gr.Markdown("# Podcast Transcription and Ingestion", visible=True) with gr.Row(): @@ -96,11 +111,11 @@ def update_prompts(preset_name): outputs=[podcast_custom_prompt_input, system_prompt_input] ) + # Refactored API selection dropdown podcast_api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", - "Kobold", "Ooba", "Tabbyapi", "VLLM","ollama", "HuggingFace", "Custom-OpenAI-API"], - value=None, - label="API Name for Summarization (Optional)" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) podcast_api_key_input = gr.Textbox(label="API Key (if required)", type="password") podcast_whisper_model_input = gr.Dropdown(choices=whisper_models, value="medium", label="Whisper Model") diff --git a/App_Function_Libraries/Gradio_UI/Prompt_Suggestion_tab.py b/App_Function_Libraries/Gradio_UI/Prompt_Suggestion_tab.py index 98861d403..418b11f32 100644 --- a/App_Function_Libraries/Gradio_UI/Prompt_Suggestion_tab.py +++ b/App_Function_Libraries/Gradio_UI/Prompt_Suggestion_tab.py @@ -1,11 +1,14 @@ # Description: Gradio UI for Creating and Testing new Prompts # # Imports +import logging + import gradio as gr from App_Function_Libraries.Chat import chat from App_Function_Libraries.DB.SQLite_DB import add_or_update_prompt from App_Function_Libraries.Prompt_Engineering.Prompt_Engineering import generate_prompt, test_generated_prompt +from App_Function_Libraries.Utils.Utils import format_api_name, global_api_endpoints, default_api_endpoint # @@ -18,6 +21,16 @@ # Gradio tab for prompt suggestion and testing def create_prompt_suggestion_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Prompt Suggestion/Creation", visible=True): gr.Markdown("# Generate and Test AI Prompts with the Metaprompt Approach") @@ -30,11 +43,11 @@ def create_prompt_suggestion_tab(): placeholder="E.g., CUSTOMER_COMPLAINT, COMPANY_NAME") # API-related inputs + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=["OpenAI", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", - "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", "Custom-OpenAI-API"], - label="API Provider", - value="OpenAI" # Default selection + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Analysis (Optional)" ) api_key_input = gr.Textbox(label="API Key", placeholder="Enter your API key (if required)", diff --git a/App_Function_Libraries/Gradio_UI/RAG_Chat_tab.py b/App_Function_Libraries/Gradio_UI/RAG_Chat_tab.py index 4e4ba46ce..4e6c63a0e 100644 --- a/App_Function_Libraries/Gradio_UI/RAG_Chat_tab.py +++ b/App_Function_Libraries/Gradio_UI/RAG_Chat_tab.py @@ -10,12 +10,26 @@ # Local Imports from App_Function_Libraries.RAG.RAG_Library_2 import enhanced_rag_pipeline +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # ######################################################################################################################## # # Functions: def create_rag_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None + with gr.TabItem("RAG Search", visible=True): gr.Markdown("# Retrieval-Augmented Generation (RAG) Search") @@ -36,10 +50,11 @@ def create_rag_tab(): visible=False ) + # Refactored API selection dropdown api_choice = gr.Dropdown( - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace"], - label="Select API for RAG", - value="OpenAI" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Response (Optional)" ) search_button = gr.Button("Search") diff --git a/App_Function_Libraries/Gradio_UI/RAG_QA_Chat_tab.py b/App_Function_Libraries/Gradio_UI/RAG_QA_Chat_tab.py index 4b9f4cee8..2d9432ba3 100644 --- a/App_Function_Libraries/Gradio_UI/RAG_QA_Chat_tab.py +++ b/App_Function_Libraries/Gradio_UI/RAG_QA_Chat_tab.py @@ -34,12 +34,25 @@ from App_Function_Libraries.PDF.PDF_Ingestion_Lib import extract_text_and_format_from_pdf from App_Function_Libraries.RAG.RAG_Library_2 import generate_answer, enhanced_rag_pipeline from App_Function_Libraries.RAG.RAG_QA_Chat import search_database, rag_qa_chat +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # ######################################################################################################################## # # Functions: def create_rag_qa_chat_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("RAG QA Chat", visible=True): gr.Markdown("# RAG QA Chat") @@ -103,26 +116,11 @@ def update_conversation_list(): ) keywords = gr.Textbox(label="Keywords (comma-separated)", visible=True) + # Refactored API selection dropdown api_choice = gr.Dropdown( - choices=[ - "Local-LLM", - "OpenAI", - "Anthropic", - "Cohere", - "Groq", - "DeepSeek", - "Mistral", - "OpenRouter", - "Llama.cpp", - "Kobold", - "Ooba", - "Tabbyapi", - "VLLM", - "ollama", - "HuggingFace", - ], - label="Select API for RAG", - value="OpenAI", + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Chat Response (Optional)" ) with gr.Row(): @@ -560,7 +558,6 @@ def clear_chat_history(): ) - def create_rag_qa_notes_management_tab(): # New Management Tab with gr.TabItem("Notes Management", visible=True): diff --git a/App_Function_Libraries/Gradio_UI/Re_summarize_tab.py b/App_Function_Libraries/Gradio_UI/Re_summarize_tab.py index 2a51b0b85..f0181feda 100644 --- a/App_Function_Libraries/Gradio_UI/Re_summarize_tab.py +++ b/App_Function_Libraries/Gradio_UI/Re_summarize_tab.py @@ -15,7 +15,10 @@ from App_Function_Libraries.Gradio_UI.Gradio_Shared import fetch_item_details, fetch_items_by_keyword, \ fetch_items_by_content, fetch_items_by_title_or_url from App_Function_Libraries.Summarization.Summarization_General_Lib import summarize_chunk -from App_Function_Libraries.Utils.Utils import load_comprehensive_config +from App_Function_Libraries.Utils.Utils import load_comprehensive_config, default_api_endpoint, global_api_endpoints, \ + format_api_name + + # # ###################################################################################################################### @@ -23,6 +26,16 @@ # Functions: def create_resummary_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Re-Summarize", visible=True): gr.Markdown("# Re-Summarize Existing Content") with gr.Row(): @@ -35,10 +48,12 @@ def create_resummary_tab(): item_mapping = gr.State({}) with gr.Row(): + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=["Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM","ollama", "HuggingFace"], - value="Local-LLM", label="API Name") + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" + ) api_key_input = gr.Textbox(label="API Key", placeholder="Enter your API key here", type="password") chunking_options_checkbox = gr.Checkbox(label="Use Chunking", value=False) diff --git a/App_Function_Libraries/Gradio_UI/Video_transcription_tab.py b/App_Function_Libraries/Gradio_UI/Video_transcription_tab.py index 548cf821b..8bd5ec134 100644 --- a/App_Function_Libraries/Gradio_UI/Video_transcription_tab.py +++ b/App_Function_Libraries/Gradio_UI/Video_transcription_tab.py @@ -21,7 +21,8 @@ from App_Function_Libraries.Summarization.Summarization_General_Lib import perform_transcription, perform_summarization, \ save_transcription_and_summary from App_Function_Libraries.Utils.Utils import convert_to_seconds, safe_read_file, format_transcription, \ - create_download_directory, generate_unique_identifier, extract_text_from_segments + create_download_directory, generate_unique_identifier, extract_text_from_segments, default_api_endpoint, \ + global_api_endpoints, format_api_name from App_Function_Libraries.Video_DL_Ingestion_Lib import parse_and_expand_urls, extract_metadata, download_video from App_Function_Libraries.Benchmarks_Evaluations.ms_g_eval import run_geval # Import metrics logging @@ -32,6 +33,16 @@ # Functions: def create_video_transcription_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Video Transcription + Summarization", visible=True): gr.Markdown("# Transcribe & Summarize Videos from URLs") with gr.Row(): @@ -111,11 +122,12 @@ def update_prompts(preset_name): outputs=[custom_prompt_input, system_prompt_input] ) + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", "Custom-OpenAI-API"], - value=None, label="API Name (Mandatory)") + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" + ) api_key_input = gr.Textbox(label="API Key (Optional - Set in Config.txt)", placeholder="Enter your API key here", type="password") keywords_input = gr.Textbox(label="Keywords", placeholder="Enter keywords here (comma-separated)", diff --git a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py index 28e3d4fe5..b5ee21991 100644 --- a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py +++ b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py @@ -17,6 +17,7 @@ from playwright.async_api import TimeoutError, async_playwright from playwright.sync_api import sync_playwright +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name # # Local Imports from App_Function_Libraries.Web_Scraping.Article_Extractor_Lib import scrape_from_sitemap, scrape_by_url_level, \ @@ -254,6 +255,16 @@ async def scrape_with_retry(url: str, max_retries: int = 3, retry_delay: float = def create_website_scraping_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Website Scraping", visible=True): gr.Markdown("# Scrape Websites & Summarize Articles") with gr.Row(): @@ -340,13 +351,11 @@ def create_website_scraping_tab(): visible=False ) + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", - "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM", "ollama", "HuggingFace", - "Custom-OpenAI-API"], - value=None, - label="API Name (Mandatory for Summarization)" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" ) api_key_input = gr.Textbox( label="API Key (Mandatory if API Name is specified)", diff --git a/App_Function_Libraries/Gradio_UI/Writing_tab.py b/App_Function_Libraries/Gradio_UI/Writing_tab.py index e62206821..37f5a8fdd 100644 --- a/App_Function_Libraries/Gradio_UI/Writing_tab.py +++ b/App_Function_Libraries/Gradio_UI/Writing_tab.py @@ -4,11 +4,16 @@ # Imports # # External Imports +import logging + import gradio as gr import textstat # # Local Imports from App_Function_Libraries.Summarization.Summarization_General_Lib import perform_summarization +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + # ######################################################################################################################## # @@ -41,6 +46,16 @@ def grammar_style_check(input_text, custom_prompt, api_name, api_key, system_pro def create_grammar_style_check_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None with gr.TabItem("Grammar and Style Check", visible=True): with gr.Row(): with gr.Column(): @@ -74,11 +89,11 @@ def create_grammar_style_check_tab(): inputs=[custom_prompt_checkbox], outputs=[custom_prompt_input, system_prompt_input] ) + # Refactored API selection dropdown api_name_input = gr.Dropdown( - choices=[None, "Local-LLM", "OpenAI", "Anthropic", "Cohere", "Groq", "DeepSeek", "Mistral", "OpenRouter", - "Llama.cpp", "Kobold", "Ooba", "Tabbyapi", "VLLM","ollama", "HuggingFace", "Custom-OpenAI-API"], - value=None, - label="API for Grammar Check" + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Analysis (Optional)" ) api_key_input = gr.Textbox(label="API Key (if not set in Config_Files/config.txt)", placeholder="Enter your API key here", type="password") diff --git a/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py b/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py new file mode 100644 index 000000000..412f4d381 --- /dev/null +++ b/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py @@ -0,0 +1,68 @@ +# XML_Ingestion_Tab.py +# Description: This file contains functions for reading and writing XML files. +# +# Imports +import logging +import os +import xml.etree.ElementTree as ET +# +# External Imports +import gradio as gr + +from App_Function_Libraries.Plaintext.XML_Ingestion_Lib import import_xml_handler +# +# Local Imports +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name +# +####################################################################################################################### +# +# Functions: + +def create_xml_import_tab(): + try: + default_value = None + if default_api_endpoint: + if default_api_endpoint in global_api_endpoints: + default_value = format_api_name(default_api_endpoint) + else: + logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") + except Exception as e: + logging.error(f"Error setting default API endpoint: {str(e)}") + default_value = None + + with gr.TabItem("Import XML Files", visible=True): + with gr.Row(): + with gr.Column(): + gr.Markdown("# Import XML Files") + gr.Markdown("Upload XML files for import") + import_file = gr.File(label="Upload XML file", file_types=[".xml"]) + title_input = gr.Textbox(label="Title", placeholder="Enter the title of the content") + author_input = gr.Textbox(label="Author", placeholder="Enter the author's name") + keywords_input = gr.Textbox(label="Keywords", placeholder="Enter keywords, comma-separated") + system_prompt_input = gr.Textbox(label="System Prompt (for Summarization)", lines=3, + value="""[Your default system prompt here]""") + custom_prompt_input = gr.Textbox(label="Custom User Prompt", + placeholder="Enter a custom user prompt for summarization (optional)") + auto_summarize_checkbox = gr.Checkbox(label="Auto-summarize", value=False) + api_name_input = gr.Dropdown( + choices=["None"] + [format_api_name(api) for api in global_api_endpoints], + value=default_value, + label="API for Summarization/Analysis (Optional)" + ) + api_key_input = gr.Textbox(label="API Key", type="password") + import_button = gr.Button("Import XML File") + with gr.Column(): + import_output = gr.Textbox(label="Import Status") + + import_button.click( + fn=import_xml_handler, + inputs=[import_file, title_input, author_input, keywords_input, system_prompt_input, + custom_prompt_input, auto_summarize_checkbox, api_name_input, api_key_input], + outputs=import_output + ) + + return import_file, title_input, author_input, keywords_input, system_prompt_input, custom_prompt_input, auto_summarize_checkbox, api_name_input, api_key_input, import_button, import_output + +# +# End of XML_Ingestion_Tab.py +####################################################################################################################### diff --git a/App_Function_Libraries/LLM_API_Calls_Local.py b/App_Function_Libraries/LLM_API_Calls_Local.py index 4af711742..3d94fa95d 100644 --- a/App_Function_Libraries/LLM_API_Calls_Local.py +++ b/App_Function_Libraries/LLM_API_Calls_Local.py @@ -251,8 +251,6 @@ def chat_with_kobold(input_data, api_key, custom_prompt_input, kobold_api_ip="ht # FIXME # Values literally c/p from the api docs.... data = { - "max_context_length": 8096, - "max_length": 4096, "prompt": kobold_prompt, "temperature": 0.7, #"top_p": 0.9, diff --git a/App_Function_Libraries/Plaintext/Plaintext_Files.py b/App_Function_Libraries/Plaintext/Plaintext_Files.py index c9c7325b2..1aefc44a7 100644 --- a/App_Function_Libraries/Plaintext/Plaintext_Files.py +++ b/App_Function_Libraries/Plaintext/Plaintext_Files.py @@ -8,6 +8,13 @@ import logging import tempfile import zipfile + +from docx2txt import docx2txt +from pypandoc import convert_file + +from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data + + # # Non-Local Imports # @@ -16,3 +23,52 @@ ####################################################################################################################### # # Function Definitions + +def import_plain_text_file(file_path, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, + api_key): + try: + # Determine the file type and convert if necessary + file_extension = os.path.splitext(file_path)[1].lower() + if file_extension == '.rtf': + with tempfile.NamedTemporaryFile(suffix='.md', delete=False) as temp_file: + convert_file(file_path, 'md', outputfile=temp_file.name) + file_path = temp_file.name + elif file_extension == '.docx': + content = docx2txt.process(file_path) + else: + with open(file_path, 'r', encoding='utf-8') as file: + content = file.read() + + # Process the content + return import_data(content, title, author, keywords, system_prompt, + user_prompt, auto_summarize, api_name, api_key) + except Exception as e: + return f"Error processing file: {str(e)}" + +def process_plain_text_zip_file(zip_file, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key): + results = [] + with tempfile.TemporaryDirectory() as temp_dir: + with zipfile.ZipFile(zip_file.name, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + for filename in os.listdir(temp_dir): + if filename.lower().endswith(('.md', '.txt', '.rtf', '.docx')): + file_path = os.path.join(temp_dir, filename) + result = import_plain_text_file(file_path, title, author, keywords, system_prompt, + user_prompt, auto_summarize, api_name, api_key) + results.append(f"File: {filename} - {result}") + + return "\n".join(results) + + +def import_file_handler(file, title, author, keywords, system_prompt, user_prompt, auto_summarize, api_name, api_key): + if file.name.lower().endswith(('.md', '.txt', '.rtf', '.docx')): + return import_plain_text_file(file.name, title, author, keywords, system_prompt, user_prompt, auto_summarize, + api_name, api_key) + elif file.name.lower().endswith('.zip'): + return process_plain_text_zip_file(file, title, author, keywords, system_prompt, user_prompt, auto_summarize, + api_name, api_key) + else: + return "Unsupported file type. Please upload a .md, .txt, .rtf, .docx file or a .zip file containing these file types." + + diff --git a/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py b/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py new file mode 100644 index 000000000..bccd15ea0 --- /dev/null +++ b/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py @@ -0,0 +1,46 @@ +# XML_Ingestion.py +import logging +import os +import xml.etree.ElementTree as ET +import gradio as gr + +from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data +from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name + + +def xml_to_text(xml_file): + try: + tree = ET.parse(xml_file) + root = tree.getroot() + # Extract text content recursively + text_content = [] + for elem in root.iter(): + if elem.text and elem.text.strip(): + text_content.append(elem.text.strip()) + return '\n'.join(text_content) + except ET.ParseError as e: + logging.error(f"Error parsing XML file: {str(e)}") + return None + + +def import_xml_handler(import_file, title, author, keywords, system_prompt, + custom_prompt, auto_summarize, api_name, api_key): + if not import_file: + return "Please upload an XML file" + + try: + xml_text = xml_to_text(import_file.name) + if not xml_text: + return "Failed to extract text from XML file" + + # Use your existing import_data function + result = import_data(xml_text, title, author, keywords, system_prompt, + custom_prompt, auto_summarize, api_name, api_key) + return result + except Exception as e: + logging.error(f"Error processing XML file: {str(e)}") + return f"Error processing XML file: {str(e)}" + +# +# End of XML_Ingestion_Lib.py +####################################################################################################################### diff --git a/App_Function_Libraries/Utils/Utils.py b/App_Function_Libraries/Utils/Utils.py index 4e89c93a7..294571455 100644 --- a/App_Function_Libraries/Utils/Utils.py +++ b/App_Function_Libraries/Utils/Utils.py @@ -350,6 +350,35 @@ def load_and_log_configs(): logging.error(f"Error loading config: {str(e)}") return None +global_api_endpoints = ["anthropic", "cohere", "groq", "openai", "huggingface", "openrouter", "deepseek", "mistral", "custom_openai_api", "llama", "ooba", "kobold", "tabby", "vllm", "ollama", "aphrodite"] + +# Setup Default API Endpoint +loaded_config_data = load_and_log_configs() +default_api_endpoint = loaded_config_data['default_api'] + +def format_api_name(api): + name_mapping = { + "openai": "OpenAI", + "anthropic": "Anthropic", + "cohere": "Cohere", + "groq": "Groq", + "huggingface": "HuggingFace", + "openrouter": "OpenRouter", + "deepseek": "DeepSeek", + "mistral": "Mistral", + "custom_openai_api": "Custom-OpenAI-API", + "llama": "Llama.cpp", + "ooba": "Ooba", + "kobold": "Kobold", + "tabby": "Tabbyapi", + "vllm": "VLLM", + "ollama": "Ollama", + "aphrodite": "Aphrodite" + } + return name_mapping.get(api, api.title()) +print(f"Default API Endpoint: {default_api_endpoint}") + + # # End of Config loading diff --git a/Docs/Issues/ISSUES.md b/Docs/Issues/ISSUES.md index 2fee461e3..672053989 100644 --- a/Docs/Issues/ISSUES.md +++ b/Docs/Issues/ISSUES.md @@ -25,4 +25,7 @@ Create Documentation for how this can help https://stevenberlinjohnson.com/how-to-use-notebooklm-as-a-research-tool-6ad5c3a227cc?gi=9a0b63820ff0 Create a blog post - tldwproject.com \ No newline at end of file + tldwproject.com + +Linux Cuda + export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/user/venv/lib/python3.X/site-packages/nvidia/cudnn/lib/ \ No newline at end of file diff --git a/README.md b/README.md index d715285d5..6f295caf5 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,22 @@ - **MacOS:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/MacOS_Install_Update.sh` - `bash MacOS-Run-Install-Update.sh` - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. - - **Windows:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat && wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Run_tldw.bat` + - **Windows:** `curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat && curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Run_tldw.bat` - Then double-click the downloaded batch file `Windows_Install_Update.bat` to install it, and `Windows_Run_tldw.bat` to run it. - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. - If you don't have CUDA installed on your system and available in your system path, go here: https://github.com/Purfview/whisper-standalone-win/releases/download/Faster-Whisper-XXL/Faster-Whisper-XXL_r192.3.4_windows.7z - Extract the two files named `cudnn_ops_infer64_8.dll` and `cudnn_cnn_infer64_8.dll` from the 7z file to the `tldw` directory, and then run the `Windows_Run_tldw.bat` file. - This will allow you to use the faster whisper models with the app. Otherwise, you won't be able to perform transcription. + - **BE SURE TO UPDATE 'config.txt' WITH YOUR API KEYS AND SETTINGS!** + - You need to do this unless you want to manually input your API keys everytime you interact with a commercial LLM... +- **Run it as a WebApp** + * `python summarize.py -gui` - This requires you to either stuff your API keys into the `config.txt` file, or pass them into the app every time you want to use it. + * It exposes every CLI option, and has a nice toggle to make it 'simple' vs 'Advanced' + - Gives you access to the whole SQLite DB backing it, with search, tagging, and export functionality + * Yes, that's right. Everything you ingest, transcribe and summarize is tracked through a local(!) SQLite DB. + * So everything you might consume during your path of research, tracked and assimilated and tagged. + * All into a shareable, single-file DB that is open source and extremely well documented. (The DB format, not this project :P) + - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. - **Docker:** - There's a docker build for GPU use(Needs Nvidia CUDA Controller(?): https://github.com/rmusser01/tldw/blob/main/Helper_Scripts/Dockerfiles/tldw-nvidia_amd64_Dockerfile - and plain CPU use: https://github.com/rmusser01/tldw/blob/main/Helper_Scripts/Dockerfiles/tldw_Debian_cpu-Dockerfile @@ -106,44 +116,104 @@ All features are designed to run **locally** on your device, ensuring privacy an - **Writing Prompts**: Generate creative writing prompts based on your preferences. - -#### Less Quick Start +---------- +### Setting it up Manually

-**Less Quick Start - Click-Here** - -### Less Quick Start -1. **Download the Installer Script for your OS:** - - **Linux:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Linux_Install_Update.sh && wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Linux_Run_tldw.sh` - - **Windows:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat && wget wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Run_tldw.bat` - - **MacOS:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/MacOS_Install_Update.sh && wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/MacOS_Run_tldw.sh` -2. **Run the Installer Script:** - - **Linux:** - - `chmod +x Linux_Install_Update.sh && chmod +x ./Linux_Run_tldw.sh` - - `./Linux_Install_Update.sh` and then `./Linux_Run_tldw.sh` - - This will install `tldw` to the directory from where the script is ran. - - **Windows:** `Windows_Install_Update.bat` - - Double-click the downloaded batch file to install it. - - This will install `tldw` to the directory from where the script is ran. - - **MacOS:** `bash MacOS-Install_Updater.sh` - - `chmod +x MacOS_Install_Update.sh` and then `chmod +x ./MacOS_Run_tldw.sh` - - `./MacOS_Install_Update.sh` and then `./MacOS_Run_tldw.sh` - - This will install `tldw` to the directory from where the script is ran. -3. **Follow the prompts to install the necessary packages and setup the program.** -4. **You are Ready to Go! You should see `tldw` start up at the end of the script, assuming everything worked as expected** -5. **BE SURE TO UPDATE 'config.txt' WITH YOUR API KEYS AND SETTINGS!** - You need to do this unless you want to manually input your API keys everytime you interact with a commercial LLM... -- **Run it as a WebApp** - * `python summarize.py -gui` - This requires you to either stuff your API keys into the `config.txt` file, or pass them into the app every time you want to use it. - * It exposes every CLI option, and has a nice toggle to make it 'simple' vs 'Advanced' - - Gives you access to the whole SQLite DB backing it, with search, tagging, and export functionality - * Yes, that's right. Everything you ingest, transcribe and summarize is tracked through a local(!) SQLite DB. - * So everything you might consume during your path of research, tracked and assimilated and tagged. - * All into a shareable, single-file DB that is open source and extremely well documented. (The DB format, not this project :P) - - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. -
+**Manual Setup/Installation - Click-Here** +### Setup +- **Requirements** + - [Python3](https://www.python.org/downloads/windows/) - Make sure to add it to your PATH during installation. + - git - https://git-scm.com/downloads + - ffmpeg (Script will install this for you) - https://ffmpeg.org/download.html + - pandoc (Optional. For manual epub to markdown conversion) - https://pandoc.org/installing.html + - `pandoc -f epub -t markdown -o output.md input.epub` -> Can then import/ingest the markdown file into the DB. Only reason you would use this is because you have a large amount of epubs you would like to convert to plain text? idk. + - GPU Drivers/CUDA drivers or CPU-only PyTorch installation for ML processing + - Apparently there is a ROCm version of PyTorch. + - MS Pytorch: https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows -> `pip install torch-directml` + - Use the 'AMD_requests.txt' file to install the necessary packages for AMD GPU support. + - AMD Pytorch: https://rocm.docs.amd.com/projects/radeon/en/latest/docs/install/wsl/install-pytorch.html + - API keys for the LLMs you want to use (or use the local LLM option/Self-hosted) + - System RAM (8GB minimum, realistically 12GB) + - Disk Space (Depends on how much you ingest, 8GB or so should be fine for the total size of the project + DB) + - This can balloon real quick. The whisper model used for transcription can be 1-2GB per. + - Pytorch + other ML libraries will also cause the size to increase. + - As such, I would say you want at least 12GB of free space on your system to devote to the app. + - Text content itself is tiny, but the supporting libraries + ML models can be quite large. +- **Linux** + 1. Download necessary packages (Python3, ffmpeg - `sudo apt install ffmpeg` or `dnf install ffmpeg`, Update your GPU Drivers/CUDA drivers if you'll be running an LLM locally) + 2. Open a terminal, navigate to the directory you want to install the script in, and run the following commands: + 3. `git clone https://github.com/rmusser01/tldw` + 4. `cd tldw` + 5. Create a virtual env: `sudo python3 -m venv ./` + 6. Launch/activate your virtual environment: `source ./bin/activate` + 7. Setup the necessary python packages: + * Following is from: https://docs.nvidia.com/deeplearning/cudnn/latest/installation/linux.html + * If you don't already have cuda installed, `py -m pip install --upgrade pip wheel` & `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cu118` + * Or CPU Only: `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cpu` + * Also be sure to change `cuda` to `cpu` in `config.txt` + * https://pytorch.org/get-started/previous-versions/#linux-and-windows-3 + 8. Then see `Linux && Windows` +- **MacOS** + 1. I don't own a mac/have access to one reliably so I can't test this, but it should be the same as/similar to Linux. +- **Windows** + 1. Download necessary pre-requisites, Update your GPU drivers/CUDA drivers if you'll be running an LLM locally, ffmpeg will be installed by the script) + 2. Open a terminal, navigate to the directory you want to install the script in, and run the following commands: + 3. `git clone https://github.com/rmusser01/tldw` + 4. `cd tldw` + 5. Create a virtual env: `python3 -m venv ./` + 6. Launch/activate your virtual env: PowerShell: `. .\scripts\activate.ps1` or for CMD: `.\scripts\activate.bat` + 7. Setup the necessary python packages: + - Cuda + * https://docs.nvidia.com/deeplearning/cudnn/latest/installation/windows.html + * If you don't already have cuda installed, `py -m pip install --upgrade pip wheel` & `pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118` + - CPU Only: `pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu` + * https://pytorch.org/get-started/previous-versions/#linux-and-windows-3 + * Also be sure to change `cuda` to `cpu` in `config.txt` + - AMD + * `pip install torch-directml` + 8. See `Linux && Windows` +- **Linux && Windows** + 1. `pip install -r requirements.txt` - may take a bit of time... + 2. **GUI Usage:** + - Put your API keys and settings in the `config.txt` file. + - This is where you'll put your API keys for the LLMs you want to use, as well as any other settings you want to have set by default. (Like the IP of your local LLM to use for summarization) + - (make sure your in the python venv - Run `source ./bin/activate` or `.\scripts\activate.ps1` or `.\scripts\activate.bat` from the `tldw` directory) + - Run `python ./summarize.py -gui` - This will launch a webapp that will allow you to interact with the script in a more user-friendly manner. + * You can pass in the API keys for the LLMs you want to use in the `config.txt` file, or pass them in when you use the GUI. + * You can also download the generated transcript and summary as text files from the UI. + * You can also download the video/audio as files from the UI. (WIP - doesn't currently work) + * You can also access the SQLite DB that backs the app, with search, tagging, and export functionality. + 3. **Local LLM with the Script Usage:** + - (make sure your in the python venv - Run `source ./bin/activate` or `.\scripts\activate.ps1` or `.\scripts\activate.bat` from the `tldw` directory) + - I recognize some people may like the functionality and idea of it all, but don't necessarily know/want to know about LLMs/getting them working, so you can also have the script download and run a local model, using system RAM and llamafile/llama.cpp. + - Simply pass `--local_llm` to the script (`python summarize.py --local-llm`), and it'll ask you if you want to download a model, and which one you'd like to download. + - Then, after downloading and selecting a model, it'll launch the model using llamafile, so you'll have a browser window/tab opened with a frontend to the model/llama.cpp server. + - You'll also have the GUI open in another tab as well, a couple seconds after the model is launched, like normal. + - You can then interact with both at the same time, being able to ask questions directly to the model, or have the model ingest output from the transcript/summary and use it to ask questions you don't necessarily care to have stored within the DB. (All transcripts, URLs processed, prompts used, and summaries generated, are stored in the DB, so you can always go back and review them or re-prompt with them) +- **Setting up Backups** + - Manual backups are possible through the GUI. These use the `VACUUM` command to create a new DB file at your backup folder location. (default is `./tldw_DB_Backups/` + - If you'd like something more automated + don't have to think about it: https://litestream.io/getting-started/ + - This will allow you to have a backup of your DB that is always up-to-date, and can be restored with a single command. + It's free. +- **Encrypting your Database at rest using 7zip** + - 7zip since its cross-platform and easy to use. + - https://superuser.com/questions/1377414/how-to-encrypt-txt-files-with-aes256-via-windows-7z-command-line + - `7za u -mx -mhe -pPASSWORD ARCHIVE-FILE-NAME.7Z SOURCE-FILE` + - `-pPASSWORD` - sets the password to `PASSWORD` + - `u` - updates the archive + - `-mx` - sets the compression level to default (-mx1 == fastest, -mx9 == best) + - `-mhe` - encrypts the file headers - No unencrypted filenames in the archive +- **Setting up Epub to Markdown conversion with Pandoc** + - **Linux / MacOS / Windows** + - Download and install from: https://pandoc.org/installing.html +- **Converting Epub to markdown** + - `pandoc -f epub -t markdown -o output.md input.epub` +- **Ingest Converted text files en-masse** + - `python summarize.py --ingest_text_file --text_title "Title" --text_author "Author Name" -k additional,keywords` ----------- + +---------- ### More Detailed explanation of this project (tl/dw)
@@ -327,103 +397,6 @@ None of these companies exist to provide AI services in 2024. They’re only doi
----------- -### Setting it up Manually -
-**Manual Setup/Installation - Click-Here** - -### Setup -- **Requirements** - - Python3 - - ffmpeg (Script will install this for you) - - pandoc (Optional. For manual epub to markdown conversion) - https://pandoc.org/installing.html - - `pandoc -f epub -t markdown -o output.md input.epub` -> Can then import/ingest the markdown file into the DB. Only reason you would use this is because you have a large amount of epubs you would like to convert to plain text? idk. - - GPU Drivers/CUDA drivers or CPU-only PyTorch installation for ML processing - - Apparently there is a ROCm version of PyTorch. - - MS Pytorch: https://learn.microsoft.com/en-us/windows/ai/directml/pytorch-windows -> `pip install torch-directml` - - Use the 'AMD_requests.txt' file to install the necessary packages for AMD GPU support. - - AMD Pytorch: https://rocm.docs.amd.com/projects/radeon/en/latest/docs/install/wsl/install-pytorch.html - - API keys for the LLMs you want to use (or use the local LLM option/Self-hosted) - - System RAM (8GB minimum, realistically 12GB) - - Disk Space (Depends on how much you ingest, 8GB or so should be fine for the total size of the project + DB) - - This can balloon real quick. The whisper model used for transcription can be 1-2GB per. - - Pytorch + other ML libraries will also cause the size to increase. - - As such, I would say you want at least 12GB of free space on your system to devote to the app. - - Text content itself is tiny, but the supporting libraries + ML models can be quite large. -- **Linux** - 1. Download necessary packages (Python3, ffmpeg - `sudo apt install ffmpeg` or `dnf install ffmpeg`, Update your GPU Drivers/CUDA drivers if you'll be running an LLM locally) - 2. Open a terminal, navigate to the directory you want to install the script in, and run the following commands: - 3. `git clone https://github.com/rmusser01/tldw` - 4. `cd tldw` - 5. Create a virtual env: `sudo python3 -m venv ./` - 6. Launch/activate your virtual environment: `source ./bin/activate` - 7. Setup the necessary python packages: - * Following is from: https://docs.nvidia.com/deeplearning/cudnn/latest/installation/linux.html - * If you don't already have cuda installed, `py -m pip install --upgrade pip wheel` & `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cu118` - * Or CPU Only: `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cpu` - * Also be sure to change `cuda` to `cpu` in `config.txt` - * https://pytorch.org/get-started/previous-versions/#linux-and-windows-3 - 8. Then see `Linux && Windows` -- **MacOS** - 1. I don't own a mac/have access to one reliably so I can't test this, but it should be the same as/similar to Linux. -- **Windows** - 1. Download necessary packages ([Python3](https://www.python.org/downloads/windows/), Update your GPU drivers/CUDA drivers if you'll be running an LLM locally, ffmpeg will be installed by the script) - 2. Open a terminal, navigate to the directory you want to install the script in, and run the following commands: - 3. `git clone https://github.com/rmusser01/tldw` - 4. `cd tldw` - 5. Create a virtual env: `python3 -m venv ./` - 6. Launch/activate your virtual env: PowerShell: `. .\scripts\activate.ps1` or for CMD: `.\scripts\activate.bat` - 7. Setup the necessary python packages: - - Cuda - * https://docs.nvidia.com/deeplearning/cudnn/latest/installation/windows.html - * If you don't already have cuda installed, `py -m pip install --upgrade pip wheel` & `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cu118` - - CPU Only: `pip install torch==2.2.2 torchvision==0.17.2 torchaudio==2.2.2 --index-url https://download.pytorch.org/whl/cpu` - * https://pytorch.org/get-started/previous-versions/#linux-and-windows-3 - * Also be sure to change `cuda` to `cpu` in `config.txt` - - AMD - * `pip install torch-directml` - 8. See `Linux && Windows` -- **Linux && Windows** - 1. `pip install -r requirements.txt` - may take a bit of time... - 2. **GUI Usage:** - - Put your API keys and settings in the `config.txt` file. - - This is where you'll put your API keys for the LLMs you want to use, as well as any other settings you want to have set by default. (Like the IP of your local LLM to use for summarization) - - (make sure your in the python venv - Run `source ./bin/activate` or `.\scripts\activate.ps1` or `.\scripts\activate.bat` from the `tldw` directory) - - Run `python ./summarize.py -gui` - This will launch a webapp that will allow you to interact with the script in a more user-friendly manner. - * You can pass in the API keys for the LLMs you want to use in the `config.txt` file, or pass them in when you use the GUI. - * You can also download the generated transcript and summary as text files from the UI. - * You can also download the video/audio as files from the UI. (WIP - doesn't currently work) - * You can also access the SQLite DB that backs the app, with search, tagging, and export functionality. - 3. **Local LLM with the Script Usage:** - - (make sure your in the python venv - Run `source ./bin/activate` or `.\scripts\activate.ps1` or `.\scripts\activate.bat` from the `tldw` directory) - - I recognize some people may like the functionality and idea of it all, but don't necessarily know/want to know about LLMs/getting them working, so you can also have the script download and run a local model, using system RAM and llamafile/llama.cpp. - - Simply pass `--local_llm` to the script (`python summarize.py --local-llm`), and it'll ask you if you want to download a model, and which one you'd like to download. - - Then, after downloading and selecting a model, it'll launch the model using llamafile, so you'll have a browser window/tab opened with a frontend to the model/llama.cpp server. - - You'll also have the GUI open in another tab as well, a couple seconds after the model is launched, like normal. - - You can then interact with both at the same time, being able to ask questions directly to the model, or have the model ingest output from the transcript/summary and use it to ask questions you don't necessarily care to have stored within the DB. (All transcripts, URLs processed, prompts used, and summaries generated, are stored in the DB, so you can always go back and review them or re-prompt with them) -- **Setting up Backups** - - Manual backups are possible through the GUI. These use the `VACUUM` command to create a new DB file at your backup folder location. (default is `./tldw_DB_Backups/` - - If you'd like something more automated + don't have to think about it: https://litestream.io/getting-started/ - - This will allow you to have a backup of your DB that is always up-to-date, and can be restored with a single command. + It's free. -- **Encrypting your Database at rest using 7zip** - - 7zip since its cross-platform and easy to use. - - https://superuser.com/questions/1377414/how-to-encrypt-txt-files-with-aes256-via-windows-7z-command-line - - `7za u -mx -mhe -pPASSWORD ARCHIVE-FILE-NAME.7Z SOURCE-FILE` - - `-pPASSWORD` - sets the password to `PASSWORD` - - `u` - updates the archive - - `-mx` - sets the compression level to default (-mx1 == fastest, -mx9 == best) - - `-mhe` - encrypts the file headers - No unencrypted filenames in the archive -- **Setting up Epub to Markdown conversion with Pandoc** - - **Linux / MacOS / Windows** - - Download and install from: https://pandoc.org/installing.html -- **Converting Epub to markdown** - - `pandoc -f epub -t markdown -o output.md input.epub` -- **Ingest Converted text files en-masse** - - `python summarize.py --ingest_text_file --text_title "Title" --text_author "Author Name" -k additional,keywords` - -
- - ---------- ### Using tldw diff --git a/summarize.py b/summarize.py index 65584da0d..181e8a339 100644 --- a/summarize.py +++ b/summarize.py @@ -82,12 +82,7 @@ log_file_path, maxBytes=max_bytes, backupCount=backup_count ) -global_api_endpoints = ["anthropic", "cohere", "groq", "openai", "huggingface", "openrouter", "deepseek", "mistral", "custom_openai_api", "llama", "ooba", "kobold", "tabby", "vllm", "ollama", "aphrodite"] -# Setup Default API Endpoint -loaded_config_data = load_and_log_configs() -default_api_endpoint = loaded_config_data['default_api'] -print(f"Default API Endpoint: {default_api_endpoint}") # # ####################### From 67b280bb5133ddd0590f243898c35d7c7fdffd4e Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 22 Oct 2024 17:48:23 -0700 Subject: [PATCH 03/15] Update XML_Ingestion_Tab.py --- .../Gradio_UI/XML_Ingestion_Tab.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py b/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py index 412f4d381..86de89ef9 100644 --- a/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py +++ b/App_Function_Libraries/Gradio_UI/XML_Ingestion_Tab.py @@ -3,16 +3,13 @@ # # Imports import logging -import os -import xml.etree.ElementTree as ET # # External Imports import gradio as gr - -from App_Function_Libraries.Plaintext.XML_Ingestion_Lib import import_xml_handler # # Local Imports from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name +from App_Function_Libraries.Plaintext.XML_Ingestion_Lib import import_xml_handler # ####################################################################################################################### # @@ -37,13 +34,12 @@ def create_xml_import_tab(): gr.Markdown("Upload XML files for import") import_file = gr.File(label="Upload XML file", file_types=[".xml"]) title_input = gr.Textbox(label="Title", placeholder="Enter the title of the content") - author_input = gr.Textbox(label="Author", placeholder="Enter the author's name") keywords_input = gr.Textbox(label="Keywords", placeholder="Enter keywords, comma-separated") system_prompt_input = gr.Textbox(label="System Prompt (for Summarization)", lines=3, value="""[Your default system prompt here]""") custom_prompt_input = gr.Textbox(label="Custom User Prompt", placeholder="Enter a custom user prompt for summarization (optional)") - auto_summarize_checkbox = gr.Checkbox(label="Auto-summarize", value=False) + auto_summarize_checkbox = gr.Checkbox(label="Auto-summarize/analyze", value=False) api_name_input = gr.Dropdown( choices=["None"] + [format_api_name(api) for api in global_api_endpoints], value=default_value, @@ -56,12 +52,12 @@ def create_xml_import_tab(): import_button.click( fn=import_xml_handler, - inputs=[import_file, title_input, author_input, keywords_input, system_prompt_input, + inputs=[import_file, title_input, keywords_input, system_prompt_input, custom_prompt_input, auto_summarize_checkbox, api_name_input, api_key_input], outputs=import_output ) - return import_file, title_input, author_input, keywords_input, system_prompt_input, custom_prompt_input, auto_summarize_checkbox, api_name_input, api_key_input, import_button, import_output + return import_file, title_input, keywords_input, system_prompt_input, custom_prompt_input, auto_summarize_checkbox, api_name_input, api_key_input, import_button, import_output # # End of XML_Ingestion_Tab.py From 074c013d2ac8478e04238312f182b1cf373a17c3 Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 22 Oct 2024 18:01:53 -0700 Subject: [PATCH 04/15] XML Ingestion --- App_Function_Libraries/Chunk_Lib.py | 156 ++++++++++++++---- App_Function_Libraries/Gradio_Related.py | 7 - .../Plaintext/Plaintext_Files.py | 16 +- .../Plaintext/XML_Ingestion_Lib.py | 87 ++++++++-- App_Function_Libraries/Plaintext/__init__.py | 0 5 files changed, 201 insertions(+), 65 deletions(-) create mode 100644 App_Function_Libraries/Plaintext/__init__.py diff --git a/App_Function_Libraries/Chunk_Lib.py b/App_Function_Libraries/Chunk_Lib.py index 0b068f669..2a37d3538 100644 --- a/App_Function_Libraries/Chunk_Lib.py +++ b/App_Function_Libraries/Chunk_Lib.py @@ -946,57 +946,143 @@ def chunk_ebook_by_chapters(text: str, chunk_options: Dict[str, Any]) -> List[Di # # XML Chunking +def extract_xml_structure(element, path=""): + """ + Recursively extract XML structure and content. + Returns a list of (path, text) tuples. + """ + results = [] + current_path = f"{path}/{element.tag}" if path else element.tag + + # Get direct text content + if element.text and element.text.strip(): + results.append((current_path, element.text.strip())) + + # Process attributes if any + if element.attrib: + for key, value in element.attrib.items(): + results.append((f"{current_path}/@{key}", value)) + + # Process child elements + for child in element: + results.extend(extract_xml_structure(child, current_path)) + + return results + + def chunk_xml(xml_text: str, chunk_options: Dict[str, Any]) -> List[Dict[str, Any]]: """ - Chunk XML content while preserving structure. + Enhanced XML chunking that preserves structure and hierarchy. + Processes XML content into chunks while maintaining structural context. + + Args: + xml_text (str): The XML content as a string + chunk_options (Dict[str, Any]): Configuration options including: + - max_size (int): Maximum chunk size (default: 1000) + - overlap (int): Number of overlapping elements (default: 0) + - method (str): Chunking method (default: 'xml') + - language (str): Content language (default: 'english') + + Returns: + List[Dict[str, Any]]: List of chunks, each containing: + - text: The chunk content + - metadata: Chunk metadata including XML paths and chunking info """ - logging.debug("chunk_xml started...") + logging.debug("Starting XML chunking process...") + try: + # Parse XML content root = ET.fromstring(xml_text) chunks = [] - # Get chunking parameters + # Get chunking parameters with defaults max_size = chunk_options.get('max_size', 1000) overlap = chunk_options.get('overlap', 0) + language = chunk_options.get('language', 'english') + + logging.debug(f"Chunking parameters - max_size: {max_size}, overlap: {overlap}, language: {language}") - # Process each major section/element - for element in root: - # Extract text content from the element and its children - text_content = [] - for child in element.iter(): - if child.text and child.text.strip(): - text_content.append(child.text.strip()) - - element_text = '\n'.join(text_content) - - # Use existing chunking methods based on the content - element_chunks = chunk_text( - element_text, - method=chunk_options.get('method', 'words'), - max_size=max_size, - overlap=overlap, - language=chunk_options.get('language', None) - ) - - # Add metadata for each chunk - for i, chunk in enumerate(element_chunks): - metadata = { - 'element_tag': element.tag, - 'element_attributes': dict(element.attrib), - 'chunk_index': i + 1, - 'total_chunks': len(element_chunks), + # Extract full structure with hierarchy + xml_content = extract_xml_structure(root) + logging.debug(f"Extracted {len(xml_content)} XML elements") + + # Initialize chunking variables + current_chunk = [] + current_size = 0 + chunk_count = 0 + + # Process XML content into chunks + for path, content in xml_content: + # Calculate content size (by words) + content_size = len(content.split()) + + # Check if adding this content would exceed max_size + if current_size + content_size > max_size and current_chunk: + # Create chunk from current content + chunk_text = '\n'.join(f"{p}: {c}" for p, c in current_chunk) + chunk_count += 1 + + # Create chunk with metadata + chunks.append({ + 'text': chunk_text, + 'metadata': { + 'paths': [p for p, _ in current_chunk], + 'chunk_method': 'xml', + 'chunk_index': chunk_count, + 'max_size': max_size, + 'overlap': overlap, + 'language': language, + 'root_tag': root.tag, + 'xml_attributes': dict(root.attrib) + } + }) + + # Handle overlap if specified + if overlap > 0: + # Keep last few items for overlap + overlap_items = current_chunk[-overlap:] + current_chunk = overlap_items + current_size = sum(len(c.split()) for _, c in overlap_items) + logging.debug(f"Created overlap chunk with {len(overlap_items)} items") + else: + current_chunk = [] + current_size = 0 + + # Add current content to chunk + current_chunk.append((path, content)) + current_size += content_size + + # Process final chunk if content remains + if current_chunk: + chunk_text = '\n'.join(f"{p}: {c}" for p, c in current_chunk) + chunk_count += 1 + + chunks.append({ + 'text': chunk_text, + 'metadata': { + 'paths': [p for p, _ in current_chunk], 'chunk_method': 'xml', + 'chunk_index': chunk_count, 'max_size': max_size, - 'overlap': overlap + 'overlap': overlap, + 'language': language, + 'root_tag': root.tag, + 'xml_attributes': dict(root.attrib) } - chunks.append({ - 'text': chunk, - 'metadata': metadata - }) + }) + + # Update total chunks count in metadata + for chunk in chunks: + chunk['metadata']['total_chunks'] = chunk_count + logging.debug(f"XML chunking complete. Created {len(chunks)} chunks") return chunks + except ET.ParseError as e: - logging.error(f"Error parsing XML: {str(e)}") + logging.error(f"XML parsing error: {str(e)}") + raise + except Exception as e: + logging.error(f"Unexpected error during XML chunking: {str(e)}") raise # diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 5bfe026ca..59ad32fdb 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -286,7 +286,6 @@ def launch_ui(share_public=None, server_mode=False): create_live_recording_tab() create_arxiv_tab() - with gr.TabItem("Text Search", id="text search", visible=True): create_search_tab() create_search_summaries_tab() @@ -306,7 +305,6 @@ def launch_ui(share_public=None, server_mode=False): create_chat_management_tab() chat_workflows_tab() - with gr.TabItem("Character Chat", id="character chat group", visible=True): create_character_card_interaction_tab() create_character_chat_mgmt_tab() @@ -316,7 +314,6 @@ def launch_ui(share_public=None, server_mode=False): create_narrator_controlled_conversation_tab() create_export_characters_tab() - with gr.TabItem("View DB Items", id="view db items group", visible=True): # This one works create_view_all_with_versions_tab() @@ -324,7 +321,6 @@ def launch_ui(share_public=None, server_mode=False): create_viewing_tab() create_prompt_view_tab() - with gr.TabItem("Prompts", id='view prompts group', visible=True): create_prompt_view_tab() create_prompt_search_tab() @@ -332,7 +328,6 @@ def launch_ui(share_public=None, server_mode=False): create_prompt_clone_tab() create_prompt_suggestion_tab() - with gr.TabItem("Manage / Edit Existing Items", id="manage group", visible=True): create_media_edit_tab() create_manage_items_tab() @@ -340,7 +335,6 @@ def launch_ui(share_public=None, server_mode=False): # FIXME #create_compare_transcripts_tab() - with gr.TabItem("Embeddings Management", id="embeddings group", visible=True): create_embeddings_tab() create_view_embeddings_tab() @@ -358,7 +352,6 @@ def launch_ui(share_public=None, server_mode=False): from App_Function_Libraries.Gradio_UI.Writing_tab import create_mikupad_tab create_mikupad_tab() - with gr.TabItem("Keywords", id="keywords group", visible=True): create_view_keywords_tab() create_add_keyword_tab() diff --git a/App_Function_Libraries/Plaintext/Plaintext_Files.py b/App_Function_Libraries/Plaintext/Plaintext_Files.py index 1aefc44a7..f5038a967 100644 --- a/App_Function_Libraries/Plaintext/Plaintext_Files.py +++ b/App_Function_Libraries/Plaintext/Plaintext_Files.py @@ -3,22 +3,15 @@ # # Import necessary libraries import os -import re -from datetime import datetime -import logging import tempfile import zipfile - +# +# External Imports from docx2txt import docx2txt from pypandoc import convert_file - -from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data - - -# -# Non-Local Imports # # Local Imports +from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data # ####################################################################################################################### # @@ -71,4 +64,7 @@ def import_file_handler(file, title, author, keywords, system_prompt, user_promp else: return "Unsupported file type. Please upload a .md, .txt, .rtf, .docx file or a .zip file containing these file types." +# +# End of Plaintext_Files.py +####################################################################################################################### diff --git a/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py b/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py index bccd15ea0..2015cb29e 100644 --- a/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py +++ b/App_Function_Libraries/Plaintext/XML_Ingestion_Lib.py @@ -1,12 +1,20 @@ # XML_Ingestion.py +# Description: This file contains functions for reading and writing XML files. +# Imports import logging -import os import xml.etree.ElementTree as ET -import gradio as gr - +# +# External Imports +# +# Local Imports from App_Function_Libraries.Gradio_UI.Import_Functionality import import_data -from App_Function_Libraries.Utils.Utils import default_api_endpoint, global_api_endpoints, format_api_name - +from App_Function_Libraries.Summarization.Summarization_General_Lib import perform_summarization +from App_Function_Libraries.Chunk_Lib import chunk_xml +from App_Function_Libraries.DB.DB_Manager import add_media_to_database +# +####################################################################################################################### +# +# Functions: def xml_to_text(xml_file): try: @@ -29,14 +37,67 @@ def import_xml_handler(import_file, title, author, keywords, system_prompt, return "Please upload an XML file" try: - xml_text = xml_to_text(import_file.name) - if not xml_text: - return "Failed to extract text from XML file" - - # Use your existing import_data function - result = import_data(xml_text, title, author, keywords, system_prompt, - custom_prompt, auto_summarize, api_name, api_key) - return result + # Parse XML and extract text with structure + tree = ET.parse(import_file.name) + root = tree.getroot() + + # Create chunk options + chunk_options = { + 'method': 'xml', + 'max_size': 1000, # Adjust as needed + 'overlap': 200, # Adjust as needed + 'language': 'english' # Add language detection if needed + } + + # Use the chunk_xml function to get structured chunks + chunks = chunk_xml(ET.tostring(root, encoding='unicode'), chunk_options) + + # Convert chunks to segments format expected by add_media_to_database + segments = [] + for chunk in chunks: + segment = { + 'Text': chunk['text'], + 'metadata': chunk['metadata'] # Preserve XML structure metadata + } + segments.append(segment) + + # Create info_dict + info_dict = { + 'title': title or 'Untitled XML Document', + 'uploader': author or 'Unknown', + 'file_type': 'xml', + 'structure': root.tag # Save root element type + } + + # Process keywords + keyword_list = [kw.strip() for kw in keywords.split(',') if kw.strip()] if keywords else [] + + # Handle summarization + if auto_summarize and api_name and api_key: + # Combine all chunks for summarization + full_text = '\n'.join(chunk['text'] for chunk in chunks) + summary = perform_summarization(api_name, full_text, custom_prompt, api_key) + else: + summary = "No summary provided" + + # Add to database + result = add_media_to_database( + url=import_file.name, # Using filename as URL + info_dict=info_dict, + segments=segments, + summary=summary, + keywords=keyword_list, + custom_prompt_input=custom_prompt, + whisper_model="XML Import", + media_type="xml_document", + overwrite=False + ) + + return f"XML file '{import_file.name}' import complete. Database result: {result}" + + except ET.ParseError as e: + logging.error(f"XML parsing error: {str(e)}") + return f"Error parsing XML file: {str(e)}" except Exception as e: logging.error(f"Error processing XML file: {str(e)}") return f"Error processing XML file: {str(e)}" diff --git a/App_Function_Libraries/Plaintext/__init__.py b/App_Function_Libraries/Plaintext/__init__.py new file mode 100644 index 000000000..e69de29bb From cf7d5625cfc193802796a5ac809ae4173d8650be Mon Sep 17 00:00:00 2001 From: Robert Date: Tue, 22 Oct 2024 23:00:16 -0700 Subject: [PATCH 05/15] ffmpeg + support for books as other file types --- .../Books/Book_Ingestion_Lib.py | 144 ++++++++++++++++++ .../Gradio_UI/Book_Ingestion_tab.py | 3 +- .../Windows_Install_Update.bat | 2 +- 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/App_Function_Libraries/Books/Book_Ingestion_Lib.py b/App_Function_Libraries/Books/Book_Ingestion_Lib.py index ea7e415ec..488d1ba3e 100644 --- a/App_Function_Libraries/Books/Book_Ingestion_Lib.py +++ b/App_Function_Libraries/Books/Book_Ingestion_Lib.py @@ -18,6 +18,9 @@ import zipfile from datetime import datetime import logging +import xml.etree.ElementTree as ET +import html2text +import csv # # External Imports import ebooklib @@ -241,6 +244,147 @@ def process_zip_file(zip_file, return "\n".join(results) +def import_html(file_path, title=None, author=None, keywords=None, **kwargs): + """ + Imports an HTML file and converts it to markdown format. + """ + try: + logging.info(f"Importing HTML file from {file_path}") + h = html2text.HTML2Text() + h.ignore_links = False + + with open(file_path, 'r', encoding='utf-8') as file: + html_content = file.read() + + markdown_content = h.handle(html_content) + + # Extract title from HTML if not provided + if not title: + soup = BeautifulSoup(html_content, 'html.parser') + title_tag = soup.find('title') + title = title_tag.string if title_tag else os.path.basename(file_path) + + return process_markdown_content(markdown_content, file_path, title, author, keywords, **kwargs) + + except Exception as e: + logging.exception(f"Error importing HTML file: {str(e)}") + raise + + +def import_xml(file_path, title=None, author=None, keywords=None, **kwargs): + """ + Imports an XML file and converts it to markdown format. + """ + try: + logging.info(f"Importing XML file from {file_path}") + tree = ET.parse(file_path) + root = tree.getroot() + + # Convert XML to markdown + markdown_content = xml_to_markdown(root) + + return process_markdown_content(markdown_content, file_path, title, author, keywords, **kwargs) + + except Exception as e: + logging.exception(f"Error importing XML file: {str(e)}") + raise + + +def import_opml(file_path, title=None, author=None, keywords=None, **kwargs): + """ + Imports an OPML file and converts it to markdown format. + """ + try: + logging.info(f"Importing OPML file from {file_path}") + tree = ET.parse(file_path) + root = tree.getroot() + + # Extract title from OPML if not provided + if not title: + title_elem = root.find(".//title") + title = title_elem.text if title_elem is not None else os.path.basename(file_path) + + # Convert OPML to markdown + markdown_content = opml_to_markdown(root) + + return process_markdown_content(markdown_content, file_path, title, author, keywords, **kwargs) + + except Exception as e: + logging.exception(f"Error importing OPML file: {str(e)}") + raise + + +def xml_to_markdown(element, level=0): + """ + Recursively converts XML elements to markdown format. + """ + markdown = "" + + # Add element name as heading + if level > 0: + markdown += f"{'#' * min(level, 6)} {element.tag}\n\n" + + # Add element text if it exists + if element.text and element.text.strip(): + markdown += f"{element.text.strip()}\n\n" + + # Process child elements + for child in element: + markdown += xml_to_markdown(child, level + 1) + + return markdown + + +def opml_to_markdown(root): + """ + Converts OPML structure to markdown format. + """ + markdown = "# Table of Contents\n\n" + + def process_outline(outline, level=0): + result = "" + for item in outline.findall("outline"): + text = item.get("text", "") + result += f"{' ' * level}- {text}\n" + result += process_outline(item, level + 1) + return result + + body = root.find(".//body") + if body is not None: + markdown += process_outline(body) + + return markdown + + +def process_markdown_content(markdown_content, file_path, title, author, keywords, **kwargs): + """ + Processes markdown content and adds it to the database. + """ + info_dict = { + 'title': title or os.path.basename(file_path), + 'uploader': author or "Unknown", + 'ingestion_date': datetime.now().strftime('%Y-%m-%d') + } + + # Create segments (you may want to adjust the chunking method) + segments = [{'Text': markdown_content}] + + # Add to database + result = add_media_to_database( + url=file_path, + info_dict=info_dict, + segments=segments, + summary=kwargs.get('summary', "No summary provided"), + keywords=keywords.split(',') if keywords else [], + custom_prompt_input=kwargs.get('custom_prompt'), + whisper_model="Imported", + media_type="document", + overwrite=False + ) + + return f"Document '{title}' imported successfully. Database result: {result}" + + def import_file_handler(file, title, author, diff --git a/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py b/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py index c560bedc7..86a2b0488 100644 --- a/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py +++ b/App_Function_Libraries/Gradio_UI/Book_Ingestion_tab.py @@ -44,7 +44,8 @@ def create_import_book_tab(): gr.Markdown("Upload a single .epub file or a .zip file containing multiple .epub files") gr.Markdown( "🔗 **How to remove DRM from your ebooks:** [Reddit Guide](https://www.reddit.com/r/Calibre/comments/1ck4w8e/2024_guide_on_removing_drm_from_kobo_kindle_ebooks/)") - import_file = gr.File(label="Upload file for import", file_types=[".epub", ".zip"]) + import_file = gr.File(label="Upload file for import", + file_types=[".epub", ".zip", ".html", ".htm", ".xml", ".opml"]) title_input = gr.Textbox(label="Title", placeholder="Enter the title of the content (for single files)") author_input = gr.Textbox(label="Author", placeholder="Enter the author's name (for single files)") keywords_input = gr.Textbox(label="Keywords (like genre or publish year)", diff --git a/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat b/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat index ae02bc0c6..aa189defd 100644 --- a/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat +++ b/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat @@ -156,7 +156,7 @@ move ffmpeg\ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe . rmdir /s /q ffmpeg del ffmpeg.zip mkdir .\Bin -move ffmpeg .\Bin\ +move ffmpeg .\Bin goto :eof :cleanup From 0cdbd5e911b6f8460d5d8a565a005414b22f164c Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 17:55:18 -0700 Subject: [PATCH 06/15] Support for parsing CSVs with URLs, URL Metadata when scraping, Character DB Export fix + placeholder for DB Viewing --- App_Function_Libraries/Gradio_Related.py | 11 +- .../Gradio_UI/Character_Chat_tab.py | 106 ++++++++- .../Gradio_UI/View_DB_Items_tab.py | 192 ++++++++++++++- .../Gradio_UI/Website_scraping_tab.py | 46 +++- .../Web_Scraping/Article_Extractor_Lib.py | 224 +++++++++++++++++- README.md | 2 +- requirements.txt | 1 + 7 files changed, 547 insertions(+), 35 deletions(-) diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 59ad32fdb..44eb31ce4 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -62,7 +62,8 @@ from App_Function_Libraries.Gradio_UI.Website_scraping_tab import create_website_scraping_tab from App_Function_Libraries.Gradio_UI.Chat_Workflows import chat_workflows_tab from App_Function_Libraries.Gradio_UI.View_DB_Items_tab import create_prompt_view_tab, \ - create_view_all_with_versions_tab, create_viewing_tab + create_view_all_mediadb_with_versions_tab, create_viewing_mediadb_tab, create_view_all_rag_notes_tab, \ + create_viewing_ragdb_tab # # Gradio UI Imports from App_Function_Libraries.Gradio_UI.Evaluations_Benchmarks_tab import create_geval_tab, create_infinite_bench_tab @@ -315,10 +316,10 @@ def launch_ui(share_public=None, server_mode=False): create_export_characters_tab() with gr.TabItem("View DB Items", id="view db items group", visible=True): - # This one works - create_view_all_with_versions_tab() - # This one is WIP - create_viewing_tab() + create_view_all_mediadb_with_versions_tab() + create_viewing_mediadb_tab() + create_view_all_rag_notes_tab() + create_viewing_ragdb_tab() create_prompt_view_tab() with gr.TabItem("Prompts", id='view prompts group', visible=True): diff --git a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py index 90200a50b..fcc1f33af 100644 --- a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py +++ b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py @@ -2,10 +2,10 @@ # Description: Library for character card import functions # # Imports +from datetime import datetime import re import tempfile import uuid -from datetime import datetime import json import logging import io @@ -308,7 +308,7 @@ def create_character_card_interaction_tab(): auto_save_checkbox = gr.Checkbox(label="Save chats automatically", value=True) chat_media_name = gr.Textbox(label="Custom Chat Name (optional)", visible=True) save_chat_history_to_db = gr.Button("Save Chat History to Database") - save_status = gr.Textbox(label="Save Status", interactive=False) + save_status = gr.Textbox(label="Status", interactive=False) with gr.Column(scale=2): chat_history = gr.Chatbot(label="Conversation", height=800) @@ -1067,13 +1067,17 @@ def create_character_chat_mgmt_tab(): gr.Markdown("## Chat Management") select_chat = gr.Dropdown(label="Select Chat", choices=[], visible=False, interactive=True) load_chat_button = gr.Button("Load Selected Chat", visible=False) - conversation_list = gr.Dropdown(label="Select Conversation or Character", choices=[]) + conversation_list = gr.Dropdown(label="Select Conversatio", choices=[]) conversation_mapping = gr.State({}) with gr.Tabs(): with gr.TabItem("Edit", visible=True): chat_content = gr.TextArea(label="Chat/Character Content (JSON)", lines=20, max_lines=50) save_button = gr.Button("Save Changes") + export_chat_button = gr.Button("Export Current Conversation", variant="secondary") + export_all_chats_button = gr.Button("Export All Character Conversations", variant="secondary") + export_file = gr.File(label="Downloaded File", visible=False) + export_status = gr.Markdown("") delete_button = gr.Button("Delete Conversation/Character", variant="stop") with gr.TabItem("Preview", visible=True): @@ -1316,6 +1320,90 @@ def import_multiple_characters(files): return "Import results:\n" + "\n".join(results) + def export_current_conversation(selected_chat): + if not selected_chat: + return "Please select a conversation to export.", None + + try: + chat_id = int(selected_chat.split('(ID: ')[1].rstrip(')')) + chat = get_character_chat_by_id(chat_id) + + if not chat: + return "Selected chat not found.", None + + # Ensure chat_history is properly parsed + chat_history = chat['chat_history'] + if isinstance(chat_history, str): + chat_history = json.loads(chat_history) + + export_data = { + "conversation_id": chat['id'], + "conversation_name": chat['conversation_name'], + "character_id": chat['character_id'], + "chat_history": chat_history, + "exported_at": datetime.now().isoformat() + } + + # Convert to JSON string + json_str = json.dumps(export_data, indent=2, ensure_ascii=False) + + # Create file name + file_name = f"conversation_{chat['id']}_{chat['conversation_name']}.json" + + # Return file for download + return "Conversation exported successfully!", (file_name, json_str, "application/json") + + except Exception as e: + logging.error(f"Error exporting conversation: {e}") + return f"Error exporting conversation: {str(e)}", None + + def export_all_character_conversations(character_selection): + if not character_selection: + return "Please select a character first.", None + + try: + character_id = int(character_selection.split('(ID: ')[1].rstrip(')')) + character = get_character_card_by_id(character_id) + chats = get_character_chats(character_id=character_id) + + if not chats: + return "No conversations found for this character.", None + + # Process chat histories + conversations = [] + for chat in chats: + chat_history = chat['chat_history'] + if isinstance(chat_history, str): + chat_history = json.loads(chat_history) + + conversations.append({ + "conversation_id": chat['id'], + "conversation_name": chat['conversation_name'], + "chat_history": chat_history + }) + + export_data = { + "character": { + "id": character['id'], + "name": character['name'] + }, + "conversations": conversations, + "exported_at": datetime.now().isoformat() + } + + # Convert to JSON string + json_str = json.dumps(export_data, indent=2, ensure_ascii=False) + + # Create file name + file_name = f"all_conversations_{character['name']}_{character['id']}.json" + + # Return file for download + return "All conversations exported successfully!", (file_name, json_str, "application/json") + + except Exception as e: + logging.error(f"Error exporting all conversations: {e}") + return f"Error exporting conversations: {str(e)}", None + # Register new callback for character import import_characters_button.click( fn=import_multiple_characters, @@ -1378,6 +1466,18 @@ def import_multiple_characters(files): outputs=select_character ) + export_chat_button.click( + fn=export_current_conversation, + inputs=[select_chat], + outputs=[export_status, export_file] + ) + + export_all_chats_button.click( + fn=export_all_character_conversations, + inputs=[select_character], + outputs=[export_status, export_file] + ) + return ( character_files, import_characters_button, import_status, search_query, search_button, search_results, search_status, diff --git a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py index 5c577ea60..414f8dcbd 100644 --- a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py +++ b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py @@ -149,9 +149,9 @@ def extract_prompt_and_summary(content: str): return prompt, summary -def create_view_all_with_versions_tab(): - with gr.TabItem("View All Items", visible=True): - gr.Markdown("# View All Database Entries with Version Selection") +def create_view_all_mediadb_with_versions_tab(): + with gr.TabItem("View All MediaDB Items", visible=True): + gr.Markdown("# View All Media Database Entries with Version Selection") with gr.Row(): with gr.Column(scale=1): entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) @@ -280,9 +280,189 @@ def update_version_content(selected_item, item_mapping, selected_version): ) -def create_viewing_tab(): - with gr.TabItem("View Database Entries", visible=True): - gr.Markdown("# View Database Entries") +# FIXME - cHange to work with RAG DB +def create_view_all_rag_notes_tab(): + with gr.TabItem("View All RAG notes/Conversation Items", visible=True): + gr.Markdown("# View All RAG Notes/Conversation Entries") + with gr.Row(): + with gr.Column(scale=1): + entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) + page_number = gr.Number(value=1, label="Page Number", precision=0) + view_button = gr.Button("View Page") + next_page_button = gr.Button("Next Page") + previous_page_button = gr.Button("Previous Page") + with gr.Column(scale=2): + items_output = gr.Dropdown(label="Select Item to View Details", choices=[]) + version_dropdown = gr.Dropdown(label="Select Version", choices=[], visible=False) + with gr.Row(): + with gr.Column(scale=1): + pagination_info = gr.Textbox(label="Pagination Info", interactive=False) + with gr.Column(scale=2): + prompt_output = gr.Textbox(label="Prompt Used", visible=True) + summary_output = gr.HTML(label="Summary", visible=True) + transcription_output = gr.HTML(label="Transcription", visible=True) + + item_mapping = gr.State({}) + + def update_page(page, entries_per_page): + results, total_entries = fetch_paginated_data(page, entries_per_page) + total_pages = (total_entries + entries_per_page - 1) // entries_per_page + pagination = f"Page {page} of {total_pages} (Total items: {total_entries})" + + choices = [f"{item[1]} (ID: {item[0]})" for item in results] + new_item_mapping = {f"{item[1]} (ID: {item[0]})": item[0] for item in results} + + next_disabled = page >= total_pages + prev_disabled = page <= 1 + + return (gr.update(choices=choices, value=None), + pagination, + page, + gr.update(interactive=not next_disabled), + gr.update(interactive=not prev_disabled), + gr.update(visible=False, choices=[]), + "", "", "", + new_item_mapping) + + def format_as_html(content, title): + if content is None: + content = "No content available." + escaped_content = html.escape(str(content)) + formatted_content = escaped_content.replace('\n', '
') + return f""" +
+

{title}

+
+ {formatted_content} +
+
+ """ + + def display_item_details(selected_item, item_mapping): + if selected_item and item_mapping and selected_item in item_mapping: + media_id = item_mapping[selected_item] + prompt, summary, transcription = fetch_item_details(media_id) + versions = get_all_document_versions(media_id) + + # Filter out duplicate versions and sort them + unique_versions = list(set((v['version_number'], v['created_at']) for v in versions)) + unique_versions.sort(key=lambda x: x[0], reverse=True) + version_choices = [f"Version {v[0]} ({v[1]})" for v in unique_versions] + + summary_html = format_as_html(summary, "Summary") + transcription_html = format_as_html(transcription, "Transcription") + + return ( + gr.update(visible=True, choices=version_choices, + value=version_choices[0] if version_choices else None), + prompt if prompt is not None else "", + summary_html, + transcription_html + ) + return gr.update(visible=False, choices=[]), "", "", "" + + def update_version_content(selected_item, item_mapping, selected_version): + if selected_item and item_mapping and selected_item in item_mapping and selected_version: + media_id = item_mapping[selected_item] + version_number = int(selected_version.split()[1].split('(')[0]) + version_data = get_document_version(media_id, version_number) + + if 'error' not in version_data: + content = version_data['content'] + prompt, summary = extract_prompt_and_summary(content) + transcription = get_latest_transcription(media_id) + + summary_html = format_as_html(summary, "Summary") + transcription_html = format_as_html(transcription, "Transcription") + + return prompt if prompt is not None else "", summary_html, transcription_html + return gr.update(value=selected_item), gr.update(), gr.update() + + view_button.click( + fn=update_page, + inputs=[page_number, entries_per_page], + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] + ) + + next_page_button.click( + fn=lambda page, entries: update_page(page + 1, entries), + inputs=[page_number, entries_per_page], + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] + ) + + previous_page_button.click( + fn=lambda page, entries: update_page(max(1, page - 1), entries), + inputs=[page_number, entries_per_page], + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] + ) + + items_output.change( + fn=display_item_details, + inputs=[items_output, item_mapping], + outputs=[version_dropdown, prompt_output, summary_output, transcription_output] + ) + + version_dropdown.change( + fn=update_version_content, + inputs=[items_output, item_mapping, version_dropdown], + outputs=[prompt_output, summary_output, transcription_output] + ) + + +def create_viewing_mediadb_tab(): + with gr.TabItem("View Media Database Entries", visible=True): + gr.Markdown("# View Media Database Entries") + with gr.Row(): + with gr.Column(): + entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) + page_number = gr.Number(value=1, label="Page Number", precision=0) + view_button = gr.Button("View Page") + next_page_button = gr.Button("Next Page") + previous_page_button = gr.Button("Previous Page") + pagination_info = gr.Textbox(label="Pagination Info", interactive=False) + with gr.Column(): + results_display = gr.HTML() + + + def update_page(page, entries_per_page): + results, pagination, total_pages = view_database(page, entries_per_page) + next_disabled = page >= total_pages + prev_disabled = page <= 1 + return results, pagination, page, gr.update(interactive=not next_disabled), gr.update(interactive=not prev_disabled) + + def go_to_next_page(current_page, entries_per_page): + next_page = current_page + 1 + return update_page(next_page, entries_per_page) + + def go_to_previous_page(current_page, entries_per_page): + previous_page = max(1, current_page - 1) + return update_page(previous_page, entries_per_page) + + view_button.click( + fn=update_page, + inputs=[page_number, entries_per_page], + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + ) + + next_page_button.click( + fn=go_to_next_page, + inputs=[page_number, entries_per_page], + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + ) + + previous_page_button.click( + fn=go_to_previous_page, + inputs=[page_number, entries_per_page], + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + ) + +# FIXME - change to work with RAG DB +def create_viewing_ragdb_tab(): + with gr.TabItem("View RAG Database Entries", visible=True): + gr.Markdown("# View RAG Database Entries") with gr.Row(): with gr.Column(): entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) diff --git a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py index b5ee21991..80b19ba9f 100644 --- a/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py +++ b/App_Function_Libraries/Gradio_UI/Website_scraping_tab.py @@ -21,7 +21,7 @@ # # Local Imports from App_Function_Libraries.Web_Scraping.Article_Extractor_Lib import scrape_from_sitemap, scrape_by_url_level, \ - scrape_article, collect_bookmarks, scrape_and_summarize_multiple + scrape_article, collect_bookmarks, scrape_and_summarize_multiple, collect_urls_from_file from App_Function_Libraries.DB.DB_Manager import load_preset_prompts from App_Function_Libraries.Gradio_UI.Chat_ui import update_user_prompt from App_Function_Libraries.Summarization.Summarization_General_Lib import summarize @@ -374,19 +374,24 @@ def create_website_scraping_tab(): value="default,no_keyword_set", visible=True ) - # Updated: Added output to display parsed URLs bookmarks_file_input = gr.File( - label="Upload Bookmarks File", + label="Upload Bookmarks File/CSV", type="filepath", - file_types=[".json", ".html"], + file_types=[".json", ".html", ".csv"], # Added .csv visible=True ) + gr.Markdown(""" + Supported file formats: + - Chrome/Edge bookmarks (JSON) + - Firefox bookmarks (HTML) + - CSV file with 'url' column (optionally 'title' or 'name' column) + """) parsed_urls_output = gr.Textbox( - label="Parsed URLs from Bookmarks", - placeholder="URLs will be displayed here after uploading a bookmarks file.", + label="Parsed URLs", + placeholder="URLs will be displayed here after uploading a file.", lines=10, interactive=False, - visible=False # Initially hidden, shown only when URLs are parsed + visible=False ) scrape_button = gr.Button("Scrape and Summarize") @@ -463,21 +468,36 @@ def parse_bookmarks(file_path): logging.error(f"Error parsing bookmarks file: {str(e)}") return f"Error parsing bookmarks file: {str(e)}" - def show_parsed_urls(bookmarks_file): + def show_parsed_urls(urls_file): """ Determines whether to show the parsed URLs output. Args: - bookmarks_file: Uploaded file object. + urls_file: Uploaded file object. Returns: Tuple indicating visibility and content of parsed_urls_output. """ - if bookmarks_file is None: + if urls_file is None: return gr.update(visible=False), "" - file_path = bookmarks_file.name - parsed_urls = parse_bookmarks(file_path) - return gr.update(visible=True), parsed_urls + + file_path = urls_file.name + try: + # Use the unified collect_urls_from_file function + parsed_urls = collect_urls_from_file(file_path) + + # Format the URLs for display + formatted_urls = [] + for name, urls in parsed_urls.items(): + if isinstance(urls, list): + for url in urls: + formatted_urls.append(f"{name}: {url}") + else: + formatted_urls.append(f"{name}: {urls}") + + return gr.update(visible=True), "\n".join(formatted_urls) + except Exception as e: + return gr.update(visible=True), f"Error parsing file: {str(e)}" # Connect the parsing function to the file upload event bookmarks_file_input.change( diff --git a/App_Function_Libraries/Web_Scraping/Article_Extractor_Lib.py b/App_Function_Libraries/Web_Scraping/Article_Extractor_Lib.py index fb29c5e4c..e592b70be 100644 --- a/App_Function_Libraries/Web_Scraping/Article_Extractor_Lib.py +++ b/App_Function_Libraries/Web_Scraping/Article_Extractor_Lib.py @@ -19,7 +19,7 @@ import logging import os import tempfile -from typing import Any, Dict, List, Union, Optional +from typing import Any, Dict, List, Union, Optional, Tuple # # 3rd-Party Imports import asyncio @@ -28,10 +28,11 @@ import xml.etree.ElementTree as ET # # External Libraries +from bs4 import BeautifulSoup +import pandas as pd +from playwright.async_api import async_playwright import requests import trafilatura -from playwright.async_api import async_playwright -from bs4 import BeautifulSoup # # Import Local from App_Function_Libraries.DB.DB_Manager import ingest_article_to_db @@ -56,7 +57,7 @@ def get_page_title(url: str) -> str: return "Untitled" -async def scrape_article(url, custom_cookies: Optional[List[Dict[str, Any]]] = None): +async def scrape_article(url: str, custom_cookies: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: async def fetch_html(url: str) -> str: async with async_playwright() as p: browser = await p.chromium.launch(headless=True) @@ -67,7 +68,7 @@ async def fetch_html(url: str) -> str: await context.add_cookies(custom_cookies) page = await context.new_page() await page.goto(url) - await page.wait_for_load_state("networkidle") # Wait for the network to be idle + await page.wait_for_load_state("networkidle") content = await page.content() await browser.close() return content @@ -87,7 +88,16 @@ def extract_article_data(html: str, url: str) -> dict: } if downloaded: - result['content'] = downloaded + # Add metadata to content + result['content'] = ContentMetadataHandler.format_content_with_metadata( + url=url, + content=downloaded, + pipeline="Trafilatura", + additional_metadata={ + "extracted_date": metadata.date if metadata and metadata.date else 'N/A', + "author": metadata.author if metadata and metadata.author else 'N/A' + } + ) result['extraction_successful'] = True if metadata: @@ -211,7 +221,9 @@ def scrape_and_no_summarize_then_ingest(url, keywords, custom_article_title): # Step 2: Ingest the article into the database ingestion_result = ingest_article_to_db(url, title, author, content, keywords, ingestion_date, None, None) - return f"Title: {title}\nAuthor: {author}\nIngestion Result: {ingestion_result}\n\nArticle Contents: {content}" + # When displaying content, we might want to strip metadata + display_content = ContentMetadataHandler.strip_metadata(content) + return f"Title: {title}\nAuthor: {author}\nIngestion Result: {ingestion_result}\n\nArticle Contents: {display_content}" except Exception as e: logging.error(f"Error processing URL {url}: {str(e)}") return f"Failed to process URL {url}: {str(e)}" @@ -637,6 +649,72 @@ def collect_bookmarks(file_path: str) -> Dict[str, Union[str, List[str]]]: logging.error(f"Error loading bookmarks: {e}") return {} + +def parse_csv_urls(file_path: str) -> Dict[str, Union[str, List[str]]]: + """ + Parse URLs from a CSV file. The CSV should have at minimum a 'url' column, + and optionally a 'title' or 'name' column. + + :param file_path: Path to the CSV file + :return: Dictionary with titles/names as keys and URLs as values + """ + try: + # Read CSV file + df = pd.read_csv(file_path) + + # Check if required columns exist + if 'url' not in df.columns: + raise ValueError("CSV must contain a 'url' column") + + # Initialize result dictionary + urls_dict = {} + + # Determine which column to use as key + key_column = next((col for col in ['title', 'name'] if col in df.columns), None) + + for idx in range(len(df)): + url = df.iloc[idx]['url'].strip() + + # Use title/name if available, otherwise use URL as key + if key_column: + key = df.iloc[idx][key_column].strip() + else: + key = f"Article {idx + 1}" + + # Handle duplicate keys + if key in urls_dict: + if isinstance(urls_dict[key], list): + urls_dict[key].append(url) + else: + urls_dict[key] = [urls_dict[key], url] + else: + urls_dict[key] = url + + return urls_dict + + except pd.errors.EmptyDataError: + logging.error("The CSV file is empty") + return {} + except Exception as e: + logging.error(f"Error parsing CSV file: {str(e)}") + return {} + + +def collect_urls_from_file(file_path: str) -> Dict[str, Union[str, List[str]]]: + """ + Unified function to collect URLs from either bookmarks or CSV files. + + :param file_path: Path to the file (bookmarks or CSV) + :return: Dictionary with names as keys and URLs as values + """ + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + if ext == '.csv': + return parse_csv_urls(file_path) + else: + return collect_bookmarks(file_path) + # Usage: # from Article_Extractor_Lib import collect_bookmarks # @@ -663,6 +741,138 @@ def collect_bookmarks(file_path: str) -> Dict[str, Union[str, List[str]]]: # End of Bookmarking Parsing Functions ##################################################################### + +##################################################################### +# +# Article Scraping Metadata Functions + +class ContentMetadataHandler: + """Handles the addition and parsing of metadata for scraped content.""" + + METADATA_START = "[METADATA]" + METADATA_END = "[/METADATA]" + + @staticmethod + def format_content_with_metadata( + url: str, + content: str, + pipeline: str = "Trafilatura", + additional_metadata: Optional[Dict[str, Any]] = None + ) -> str: + """ + Format content with metadata header. + + Args: + url: The source URL + content: The scraped content + pipeline: The scraping pipeline used + additional_metadata: Optional dictionary of additional metadata to include + + Returns: + Formatted content with metadata header + """ + metadata = { + "url": url, + "ingestion_date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "content_hash": hashlib.sha256(content.encode('utf-8')).hexdigest(), + "scraping_pipeline": pipeline + } + + # Add any additional metadata + if additional_metadata: + metadata.update(additional_metadata) + + formatted_content = f"""{ContentMetadataHandler.METADATA_START} +{json.dumps(metadata, indent=2)} +{ContentMetadataHandler.METADATA_END} + +{content}""" + + return formatted_content + + @staticmethod + def extract_metadata(content: str) -> Tuple[Dict[str, Any], str]: + """ + Extract metadata and content separately. + + Args: + content: The full content including metadata + + Returns: + Tuple of (metadata dict, clean content) + """ + try: + metadata_start = content.index(ContentMetadataHandler.METADATA_START) + len( + ContentMetadataHandler.METADATA_START) + metadata_end = content.index(ContentMetadataHandler.METADATA_END) + metadata_json = content[metadata_start:metadata_end].strip() + metadata = json.loads(metadata_json) + clean_content = content[metadata_end + len(ContentMetadataHandler.METADATA_END):].strip() + return metadata, clean_content + except (ValueError, json.JSONDecodeError) as e: + return {}, content + + @staticmethod + def has_metadata(content: str) -> bool: + """ + Check if content contains metadata. + + Args: + content: The content to check + + Returns: + bool: True if metadata is present + """ + return (ContentMetadataHandler.METADATA_START in content and + ContentMetadataHandler.METADATA_END in content) + + @staticmethod + def strip_metadata(content: str) -> str: + """ + Remove metadata from content if present. + + Args: + content: The content to strip metadata from + + Returns: + Content without metadata + """ + try: + metadata_end = content.index(ContentMetadataHandler.METADATA_END) + return content[metadata_end + len(ContentMetadataHandler.METADATA_END):].strip() + except ValueError: + return content + + @staticmethod + def get_content_hash(content: str) -> str: + """ + Get hash of content without metadata. + + Args: + content: The content to hash + + Returns: + SHA-256 hash of the clean content + """ + clean_content = ContentMetadataHandler.strip_metadata(content) + return hashlib.sha256(clean_content.encode('utf-8')).hexdigest() + + @staticmethod + def content_changed(old_content: str, new_content: str) -> bool: + """ + Check if content has changed by comparing hashes. + + Args: + old_content: Previous version of content + new_content: New version of content + + Returns: + bool: True if content has changed + """ + old_hash = ContentMetadataHandler.get_content_hash(old_content) + new_hash = ContentMetadataHandler.get_content_hash(new_content) + return old_hash != new_hash + # # End of Article_Extractor_Lib.py ####################################################################################################################### diff --git a/README.md b/README.md index 6f295caf5..16ca7cee7 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ - **MacOS:** `wget https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/MacOS_Install_Update.sh` - `bash MacOS-Run-Install-Update.sh` - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. - - **Windows:** `curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat && curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Run_tldw.bat` + - **Windows:** `curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Install_Update.bat` && `curl -O https://raw.githubusercontent.com/rmusser01/tldw/main/Helper_Scripts/Installer_Scripts/Windows_Run_tldw.bat` - Then double-click the downloaded batch file `Windows_Install_Update.bat` to install it, and `Windows_Run_tldw.bat` to run it. - You should now have a web browser tab opened to `http://127.0.0.1:7860/` with the GUI for the app. - If you don't have CUDA installed on your system and available in your system path, go here: https://github.com/Purfview/whisper-standalone-win/releases/download/Faster-Whisper-XXL/Faster-Whisper-XXL_r192.3.4_windows.7z diff --git a/requirements.txt b/requirements.txt index 902567438..a2d59e9ed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ FlashRank fugashi # well fuck gradio. again. gradio==4.44.1 +html2text jieba Jinja2 joblib From 74e09703db922e612f48bd13b45be83fb5a6bef2 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 17:56:21 -0700 Subject: [PATCH 07/15] Update Character_Chat_tab.py --- App_Function_Libraries/Gradio_UI/Character_Chat_tab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py index fcc1f33af..e2ad29ecf 100644 --- a/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py +++ b/App_Function_Libraries/Gradio_UI/Character_Chat_tab.py @@ -1067,7 +1067,7 @@ def create_character_chat_mgmt_tab(): gr.Markdown("## Chat Management") select_chat = gr.Dropdown(label="Select Chat", choices=[], visible=False, interactive=True) load_chat_button = gr.Button("Load Selected Chat", visible=False) - conversation_list = gr.Dropdown(label="Select Conversatio", choices=[]) + conversation_list = gr.Dropdown(label="Select Conversation", choices=[]) conversation_mapping = gr.State({}) with gr.Tabs(): From e6c438eb0b27bfdaf2cce84a3a498e8c3b91f2de Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 18:24:36 -0700 Subject: [PATCH 08/15] Add new viewing tabs for RAG DB --- App_Function_Libraries/Gradio_Related.py | 4 +- .../Gradio_UI/View_DB_Items_tab.py | 345 +++++++++++++++++- 2 files changed, 346 insertions(+), 3 deletions(-) diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 44eb31ce4..5e930cb27 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -63,7 +63,7 @@ from App_Function_Libraries.Gradio_UI.Chat_Workflows import chat_workflows_tab from App_Function_Libraries.Gradio_UI.View_DB_Items_tab import create_prompt_view_tab, \ create_view_all_mediadb_with_versions_tab, create_viewing_mediadb_tab, create_view_all_rag_notes_tab, \ - create_viewing_ragdb_tab + create_viewing_ragdb_tab, create_mediadb_keyword_search_tab, create_ragdb_keyword_items_tab # # Gradio UI Imports from App_Function_Libraries.Gradio_UI.Evaluations_Benchmarks_tab import create_geval_tab, create_infinite_bench_tab @@ -318,8 +318,10 @@ def launch_ui(share_public=None, server_mode=False): with gr.TabItem("View DB Items", id="view db items group", visible=True): create_view_all_mediadb_with_versions_tab() create_viewing_mediadb_tab() + create_mediadb_keyword_search_tab() create_view_all_rag_notes_tab() create_viewing_ragdb_tab() + create_ragdb_keyword_items_tab() create_prompt_view_tab() with gr.TabItem("Prompts", id='view prompts group', visible=True): diff --git a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py index 414f8dcbd..862d24c0c 100644 --- a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py +++ b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py @@ -11,7 +11,11 @@ from App_Function_Libraries.DB.DB_Manager import view_database, get_all_document_versions, \ fetch_paginated_data, fetch_item_details, get_latest_transcription, list_prompts, fetch_prompt_details, \ load_preset_prompts -from App_Function_Libraries.DB.SQLite_DB import get_document_version +from App_Function_Libraries.DB.RAG_QA_Chat_DB import get_keywords_for_note, search_conversations_by_keywords, \ + get_notes_by_keywords, get_keywords_for_conversation, get_db_connection +from App_Function_Libraries.DB.SQLite_DB import get_document_version, fetch_items_by_keyword, fetch_all_keywords + + # #################################################################################################### # @@ -280,6 +284,140 @@ def update_version_content(selected_item, item_mapping, selected_version): ) +def create_mediadb_keyword_search_tab(): + with gr.TabItem("Search by Keyword", visible=True): + gr.Markdown("# Search Database Items by Keyword") + + with gr.Row(): + with gr.Column(scale=1): + # Keyword selection dropdown - initialize with empty list, will be populated on load + keyword_dropdown = gr.Dropdown( + label="Select Keyword", + choices=fetch_all_keywords(), # Initialize with keywords on creation + value=None + ) + entries_per_page = gr.Dropdown( + choices=[10, 20, 50, 100], + label="Entries per Page", + value=10 + ) + page_number = gr.Number( + value=1, + label="Page Number", + precision=0 + ) + + # Navigation buttons + refresh_keywords_button = gr.Button("Refresh Keywords") + view_button = gr.Button("View Results") + next_page_button = gr.Button("Next Page") + previous_page_button = gr.Button("Previous Page") + + # Pagination information + pagination_info = gr.Textbox( + label="Pagination Info", + interactive=False + ) + + with gr.Column(scale=2): + # Results area + results_table = gr.HTML( + label="Search Results" + ) + item_details = gr.HTML( + label="Item Details", + visible=True + ) + + def update_keyword_choices(): + try: + keywords = fetch_all_keywords() + return gr.update(choices=keywords) + except Exception as e: + return gr.update(choices=[], value=None) + + def search_items(keyword, page, entries_per_page): + try: + # Calculate offset for pagination + offset = (page - 1) * entries_per_page + + # Fetch items for the selected keyword + items = fetch_items_by_keyword(keyword) + total_items = len(items) + total_pages = (total_items + entries_per_page - 1) // entries_per_page + + # Paginate results + paginated_items = items[offset:offset + entries_per_page] + + # Generate HTML table for results + table_html = "" + table_html += "" + table_html += "" + + for item_id, title, url in paginated_items: + table_html += f""" + + + + + """ + table_html += "
TitleURL
{html.escape(title)}{html.escape(url)}
" + + # Update pagination info + pagination = f"Page {page} of {total_pages} (Total items: {total_items})" + + # Determine button states + next_disabled = page >= total_pages + prev_disabled = page <= 1 + + return ( + table_html, + pagination, + gr.update(interactive=not next_disabled), + gr.update(interactive=not prev_disabled) + ) + except Exception as e: + return ( + f"

Error: {str(e)}

", + "Error in pagination", + gr.update(interactive=False), + gr.update(interactive=False) + ) + + def go_to_next_page(keyword, current_page, entries_per_page): + next_page = current_page + 1 + return search_items(keyword, next_page, entries_per_page) + (next_page,) + + def go_to_previous_page(keyword, current_page, entries_per_page): + previous_page = max(1, current_page - 1) + return search_items(keyword, previous_page, entries_per_page) + (previous_page,) + + # Event handlers + refresh_keywords_button.click( + fn=update_keyword_choices, + inputs=[], + outputs=[keyword_dropdown] + ) + + view_button.click( + fn=search_items, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[results_table, pagination_info, next_page_button, previous_page_button] + ) + + next_page_button.click( + fn=go_to_next_page, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[results_table, pagination_info, next_page_button, previous_page_button, page_number] + ) + + previous_page_button.click( + fn=go_to_previous_page, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[results_table, pagination_info, next_page_button, previous_page_button, page_number] + ) + + # FIXME - cHange to work with RAG DB def create_view_all_rag_notes_tab(): with gr.TabItem("View All RAG notes/Conversation Items", visible=True): @@ -459,6 +597,10 @@ def go_to_previous_page(current_page, entries_per_page): outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) +##################################################################### +# +# RAG DB Viewing Functions: + # FIXME - change to work with RAG DB def create_viewing_ragdb_tab(): with gr.TabItem("View RAG Database Entries", visible=True): @@ -507,5 +649,204 @@ def go_to_previous_page(current_page, entries_per_page): outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) + +def create_ragdb_keyword_items_tab(): + with gr.TabItem("View Items by Keyword", visible=True): + gr.Markdown("# View Conversations and Notes by Keyword") + + with gr.Row(): + with gr.Column(scale=1): + # Keyword selection + keyword_dropdown = gr.Dropdown( + label="Select Keyword", + choices=[], + value=None, + multiselect=True + ) + entries_per_page = gr.Dropdown( + choices=[10, 20, 50, 100], + label="Entries per Page", + value=10 + ) + page_number = gr.Number( + value=1, + label="Page Number", + precision=0 + ) + + # Navigation buttons + refresh_keywords_button = gr.Button("Refresh Keywords") + view_button = gr.Button("View Items") + next_page_button = gr.Button("Next Page") + previous_page_button = gr.Button("Previous Page") + pagination_info = gr.Textbox( + label="Pagination Info", + interactive=False + ) + + with gr.Column(scale=2): + # Results tabs for conversations and notes + with gr.Tabs(): + with gr.Tab("Conversations"): + conversation_results = gr.HTML() + with gr.Tab("Notes"): + notes_results = gr.HTML() + + def update_keyword_choices(): + """Fetch all available keywords for the dropdown.""" + try: + query = "SELECT keyword FROM rag_qa_keywords ORDER BY keyword" + with get_db_connection() as conn: + cursor = conn.cursor() + cursor.execute(query) + keywords = [row[0] for row in cursor.fetchall()] + return gr.update(choices=keywords) + except Exception as e: + return gr.update(choices=[], value=None) + + def format_conversations_html(conversations_data): + """Format conversations data as HTML.""" + if not conversations_data: + return "

No conversations found for selected keywords.

" + + html_content = "
" + for conv_id, title in conversations_data: + html_content += f""" +
+

{html.escape(title)}

+

Conversation ID: {html.escape(conv_id)}

+

Keywords: {', '.join(html.escape(k) for k in get_keywords_for_conversation(conv_id))}

+
+ """ + html_content += "
" + return html_content + + def format_notes_html(notes_data): + """Format notes data as HTML.""" + if not notes_data: + return "

No notes found for selected keywords.

" + + html_content = "
" + for note_id, title, content, timestamp in notes_data: + keywords = get_keywords_for_note(note_id) + html_content += f""" +
+

{html.escape(title)}

+

Created: {timestamp}

+

Keywords: {', '.join(html.escape(k) for k in keywords)}

+
+ {html.escape(content)} +
+
+ """ + html_content += "
" + return html_content + + def view_items(keywords, page, entries_per_page): + if not keywords: + return ( + "

Please select at least one keyword.

", + "

Please select at least one keyword.

", + "No results", + gr.update(interactive=False), + gr.update(interactive=False) + ) + + try: + # Get conversations for selected keywords + conversations, conv_total_pages, conv_count = search_conversations_by_keywords( + keywords, page, entries_per_page + ) + + # Get notes for selected keywords + notes, notes_total_pages, notes_count = get_notes_by_keywords( + keywords, page, entries_per_page + ) + + # Format results as HTML + conv_html = format_conversations_html(conversations) + notes_html = format_notes_html(notes) + + # Create pagination info + pagination = f"Page {page} of {max(conv_total_pages, notes_total_pages)} " + pagination += f"(Conversations: {conv_count}, Notes: {notes_count})" + + # Determine button states + max_pages = max(conv_total_pages, notes_total_pages) + next_disabled = page >= max_pages + prev_disabled = page <= 1 + + return ( + conv_html, + notes_html, + pagination, + gr.update(interactive=not next_disabled), + gr.update(interactive=not prev_disabled) + ) + except Exception as e: + return ( + f"

Error: {str(e)}

", + f"

Error: {str(e)}

", + "Error in retrieval", + gr.update(interactive=False), + gr.update(interactive=False) + ) + + def go_to_next_page(keywords, current_page, entries_per_page): + return view_items(keywords, current_page + 1, entries_per_page) + + def go_to_previous_page(keywords, current_page, entries_per_page): + return view_items(keywords, max(1, current_page - 1), entries_per_page) + + # Event handlers + refresh_keywords_button.click( + fn=update_keyword_choices, + inputs=[], + outputs=[keyword_dropdown] + ) + + view_button.click( + fn=view_items, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[ + conversation_results, + notes_results, + pagination_info, + next_page_button, + previous_page_button + ] + ) + + next_page_button.click( + fn=go_to_next_page, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[ + conversation_results, + notes_results, + pagination_info, + next_page_button, + previous_page_button + ] + ) + + previous_page_button.click( + fn=go_to_previous_page, + inputs=[keyword_dropdown, page_number, entries_per_page], + outputs=[ + conversation_results, + notes_results, + pagination_info, + next_page_button, + previous_page_button + ] + ) + + # Initialize keyword dropdown on page load + keyword_dropdown.value = update_keyword_choices() + +# +# End of RAG DB Viewing tabs +################################################################ + # -#################################################################################################### \ No newline at end of file +####################################################################################################################### From dc13007901a9d438f45daa9288e012b74d6a505e Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 18:36:04 -0700 Subject: [PATCH 09/15] Update View_DB_Items_tab.py --- .../Gradio_UI/View_DB_Items_tab.py | 341 ++++++++++-------- 1 file changed, 195 insertions(+), 146 deletions(-) diff --git a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py index 862d24c0c..85ce9dfe6 100644 --- a/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py +++ b/App_Function_Libraries/Gradio_UI/View_DB_Items_tab.py @@ -12,7 +12,8 @@ fetch_paginated_data, fetch_item_details, get_latest_transcription, list_prompts, fetch_prompt_details, \ load_preset_prompts from App_Function_Libraries.DB.RAG_QA_Chat_DB import get_keywords_for_note, search_conversations_by_keywords, \ - get_notes_by_keywords, get_keywords_for_conversation, get_db_connection + get_notes_by_keywords, get_keywords_for_conversation, get_db_connection, get_all_conversations, load_chat_history, \ + get_notes from App_Function_Libraries.DB.SQLite_DB import get_document_version, fetch_items_by_keyword, fetch_all_keywords @@ -285,8 +286,8 @@ def update_version_content(selected_item, item_mapping, selected_version): def create_mediadb_keyword_search_tab(): - with gr.TabItem("Search by Keyword", visible=True): - gr.Markdown("# Search Database Items by Keyword") + with gr.TabItem("Search MediaDB by Keyword", visible=True): + gr.Markdown("# List Media Database Items by Keyword") with gr.Row(): with gr.Column(scale=1): @@ -418,141 +419,60 @@ def go_to_previous_page(keyword, current_page, entries_per_page): ) -# FIXME - cHange to work with RAG DB -def create_view_all_rag_notes_tab(): - with gr.TabItem("View All RAG notes/Conversation Items", visible=True): - gr.Markdown("# View All RAG Notes/Conversation Entries") +def create_viewing_mediadb_tab(): + with gr.TabItem("View Media Database Entries", visible=True): + gr.Markdown("# View Media Database Entries") with gr.Row(): - with gr.Column(scale=1): + with gr.Column(): entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) page_number = gr.Number(value=1, label="Page Number", precision=0) view_button = gr.Button("View Page") next_page_button = gr.Button("Next Page") previous_page_button = gr.Button("Previous Page") - with gr.Column(scale=2): - items_output = gr.Dropdown(label="Select Item to View Details", choices=[]) - version_dropdown = gr.Dropdown(label="Select Version", choices=[], visible=False) - with gr.Row(): - with gr.Column(scale=1): pagination_info = gr.Textbox(label="Pagination Info", interactive=False) - with gr.Column(scale=2): - prompt_output = gr.Textbox(label="Prompt Used", visible=True) - summary_output = gr.HTML(label="Summary", visible=True) - transcription_output = gr.HTML(label="Transcription", visible=True) + with gr.Column(): + results_display = gr.HTML() - item_mapping = gr.State({}) def update_page(page, entries_per_page): - results, total_entries = fetch_paginated_data(page, entries_per_page) - total_pages = (total_entries + entries_per_page - 1) // entries_per_page - pagination = f"Page {page} of {total_pages} (Total items: {total_entries})" - - choices = [f"{item[1]} (ID: {item[0]})" for item in results] - new_item_mapping = {f"{item[1]} (ID: {item[0]})": item[0] for item in results} - + results, pagination, total_pages = view_database(page, entries_per_page) next_disabled = page >= total_pages prev_disabled = page <= 1 + return results, pagination, page, gr.update(interactive=not next_disabled), gr.update(interactive=not prev_disabled) - return (gr.update(choices=choices, value=None), - pagination, - page, - gr.update(interactive=not next_disabled), - gr.update(interactive=not prev_disabled), - gr.update(visible=False, choices=[]), - "", "", "", - new_item_mapping) - - def format_as_html(content, title): - if content is None: - content = "No content available." - escaped_content = html.escape(str(content)) - formatted_content = escaped_content.replace('\n', '
') - return f""" -
-

{title}

-
- {formatted_content} -
-
- """ - - def display_item_details(selected_item, item_mapping): - if selected_item and item_mapping and selected_item in item_mapping: - media_id = item_mapping[selected_item] - prompt, summary, transcription = fetch_item_details(media_id) - versions = get_all_document_versions(media_id) - - # Filter out duplicate versions and sort them - unique_versions = list(set((v['version_number'], v['created_at']) for v in versions)) - unique_versions.sort(key=lambda x: x[0], reverse=True) - version_choices = [f"Version {v[0]} ({v[1]})" for v in unique_versions] - - summary_html = format_as_html(summary, "Summary") - transcription_html = format_as_html(transcription, "Transcription") - - return ( - gr.update(visible=True, choices=version_choices, - value=version_choices[0] if version_choices else None), - prompt if prompt is not None else "", - summary_html, - transcription_html - ) - return gr.update(visible=False, choices=[]), "", "", "" - - def update_version_content(selected_item, item_mapping, selected_version): - if selected_item and item_mapping and selected_item in item_mapping and selected_version: - media_id = item_mapping[selected_item] - version_number = int(selected_version.split()[1].split('(')[0]) - version_data = get_document_version(media_id, version_number) - - if 'error' not in version_data: - content = version_data['content'] - prompt, summary = extract_prompt_and_summary(content) - transcription = get_latest_transcription(media_id) - - summary_html = format_as_html(summary, "Summary") - transcription_html = format_as_html(transcription, "Transcription") + def go_to_next_page(current_page, entries_per_page): + next_page = current_page + 1 + return update_page(next_page, entries_per_page) - return prompt if prompt is not None else "", summary_html, transcription_html - return gr.update(value=selected_item), gr.update(), gr.update() + def go_to_previous_page(current_page, entries_per_page): + previous_page = max(1, current_page - 1) + return update_page(previous_page, entries_per_page) view_button.click( fn=update_page, inputs=[page_number, entries_per_page], - outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, - version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) next_page_button.click( - fn=lambda page, entries: update_page(page + 1, entries), + fn=go_to_next_page, inputs=[page_number, entries_per_page], - outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, - version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) previous_page_button.click( - fn=lambda page, entries: update_page(max(1, page - 1), entries), + fn=go_to_previous_page, inputs=[page_number, entries_per_page], - outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, - version_dropdown, prompt_output, summary_output, transcription_output, item_mapping] - ) - - items_output.change( - fn=display_item_details, - inputs=[items_output, item_mapping], - outputs=[version_dropdown, prompt_output, summary_output, transcription_output] - ) - - version_dropdown.change( - fn=update_version_content, - inputs=[items_output, item_mapping, version_dropdown], - outputs=[prompt_output, summary_output, transcription_output] + outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) +##################################################################### +# +# RAG DB Viewing Functions: -def create_viewing_mediadb_tab(): - with gr.TabItem("View Media Database Entries", visible=True): - gr.Markdown("# View Media Database Entries") +def create_viewing_ragdb_tab(): + with gr.TabItem("View RAG Database Entries", visible=True): + gr.Markdown("# View RAG Database Entries") with gr.Row(): with gr.Column(): entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) @@ -564,20 +484,60 @@ def create_viewing_mediadb_tab(): with gr.Column(): results_display = gr.HTML() + def format_conversations_table(conversations): + table_html = "" + table_html += """ + + + + + + """ + + for conv_id, title in conversations: + keywords = get_keywords_for_conversation(conv_id) + notes = get_notes(conv_id) + + table_html += f""" + + + + + + """ + table_html += "
TitleKeywordsNotes
{html.escape(title)}{html.escape(', '.join(keywords))}{len(notes)} note(s)
" + return table_html def update_page(page, entries_per_page): - results, pagination, total_pages = view_database(page, entries_per_page) - next_disabled = page >= total_pages - prev_disabled = page <= 1 - return results, pagination, page, gr.update(interactive=not next_disabled), gr.update(interactive=not prev_disabled) + try: + conversations, total_pages, total_count = get_all_conversations(page, entries_per_page) + results_html = format_conversations_table(conversations) + pagination = f"Page {page} of {total_pages} (Total conversations: {total_count})" + + next_disabled = page >= total_pages + prev_disabled = page <= 1 + + return ( + results_html, + pagination, + page, + gr.update(interactive=not next_disabled), + gr.update(interactive=not prev_disabled) + ) + except Exception as e: + return ( + f"

Error: {str(e)}

", + "Error in pagination", + page, + gr.update(interactive=False), + gr.update(interactive=False) + ) def go_to_next_page(current_page, entries_per_page): - next_page = current_page + 1 - return update_page(next_page, entries_per_page) + return update_page(current_page + 1, entries_per_page) def go_to_previous_page(current_page, entries_per_page): - previous_page = max(1, current_page - 1) - return update_page(previous_page, entries_per_page) + return update_page(max(1, current_page - 1), entries_per_page) view_button.click( fn=update_page, @@ -597,62 +557,151 @@ def go_to_previous_page(current_page, entries_per_page): outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] ) -##################################################################### -# -# RAG DB Viewing Functions: -# FIXME - change to work with RAG DB -def create_viewing_ragdb_tab(): - with gr.TabItem("View RAG Database Entries", visible=True): - gr.Markdown("# View RAG Database Entries") +def create_view_all_rag_notes_tab(): + with gr.TabItem("View All RAG notes/Conversation Items", visible=True): + gr.Markdown("# View All RAG Notes/Conversation Entries") with gr.Row(): - with gr.Column(): + with gr.Column(scale=1): entries_per_page = gr.Dropdown(choices=[10, 20, 50, 100], label="Entries per Page", value=10) page_number = gr.Number(value=1, label="Page Number", precision=0) view_button = gr.Button("View Page") next_page_button = gr.Button("Next Page") previous_page_button = gr.Button("Previous Page") + with gr.Column(scale=2): + items_output = gr.Dropdown(label="Select Conversation to View Details", choices=[]) + conversation_title = gr.Textbox(label="Conversation Title", visible=True) + with gr.Row(): + with gr.Column(scale=1): pagination_info = gr.Textbox(label="Pagination Info", interactive=False) - with gr.Column(): - results_display = gr.HTML() + with gr.Column(scale=2): + keywords_output = gr.Textbox(label="Keywords", visible=True) + chat_history_output = gr.HTML(label="Chat History", visible=True) + notes_output = gr.HTML(label="Associated Notes", visible=True) + item_mapping = gr.State({}) def update_page(page, entries_per_page): - results, pagination, total_pages = view_database(page, entries_per_page) - next_disabled = page >= total_pages - prev_disabled = page <= 1 - return results, pagination, page, gr.update(interactive=not next_disabled), gr.update(interactive=not prev_disabled) + try: + conversations, total_pages, total_count = get_all_conversations(page, entries_per_page) + pagination = f"Page {page} of {total_pages} (Total conversations: {total_count})" - def go_to_next_page(current_page, entries_per_page): - next_page = current_page + 1 - return update_page(next_page, entries_per_page) + choices = [f"{title} (ID: {conv_id})" for conv_id, title in conversations] + new_item_mapping = {f"{title} (ID: {conv_id})": conv_id for conv_id, title in conversations} - def go_to_previous_page(current_page, entries_per_page): - previous_page = max(1, current_page - 1) - return update_page(previous_page, entries_per_page) + next_disabled = page >= total_pages + prev_disabled = page <= 1 + + return ( + gr.update(choices=choices, value=None), + pagination, + page, + gr.update(interactive=not next_disabled), + gr.update(interactive=not prev_disabled), + "", # conversation_title + "", # keywords_output + "", # chat_history_output + "", # notes_output + new_item_mapping + ) + except Exception as e: + return ( + gr.update(choices=[], value=None), + f"Error: {str(e)}", + page, + gr.update(interactive=False), + gr.update(interactive=False), + "", "", "", "", + {} + ) + + def format_as_html(content, title): + if content is None: + content = "No content available." + escaped_content = html.escape(str(content)) + formatted_content = escaped_content.replace('\n', '
') + return f""" +
+

{title}

+
+ {formatted_content} +
+
+ """ + + def format_chat_history(messages): + html_content = "
" + for role, content in messages: + role_class = "assistant" if role.lower() == "assistant" else "user" + html_content += f""" +
+ {html.escape(role)}:
+ {html.escape(content)} +
+ """ + html_content += "
" + return html_content + + def display_conversation_details(selected_item, item_mapping): + if selected_item and item_mapping and selected_item in item_mapping: + conv_id = item_mapping[selected_item] + + # Get keywords + keywords = get_keywords_for_conversation(conv_id) + keywords_text = ", ".join(keywords) if keywords else "No keywords" + + # Get chat history + chat_messages, _, _ = load_chat_history(conv_id) + chat_html = format_chat_history(chat_messages) + + # Get associated notes + notes = get_notes(conv_id) + notes_html = "" + for note in notes: + notes_html += format_as_html(note, "Note") + if not notes: + notes_html = "

No notes associated with this conversation.

" + + return ( + selected_item.split(" (ID:")[0], # Conversation title + keywords_text, + chat_html, + notes_html + ) + return "", "", "", "" view_button.click( fn=update_page, inputs=[page_number, entries_per_page], - outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + conversation_title, keywords_output, chat_history_output, notes_output, item_mapping] ) next_page_button.click( - fn=go_to_next_page, + fn=lambda page, entries: update_page(page + 1, entries), inputs=[page_number, entries_per_page], - outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + conversation_title, keywords_output, chat_history_output, notes_output, item_mapping] ) previous_page_button.click( - fn=go_to_previous_page, + fn=lambda page, entries: update_page(max(1, page - 1), entries), inputs=[page_number, entries_per_page], - outputs=[results_display, pagination_info, page_number, next_page_button, previous_page_button] + outputs=[items_output, pagination_info, page_number, next_page_button, previous_page_button, + conversation_title, keywords_output, chat_history_output, notes_output, item_mapping] + ) + + items_output.change( + fn=display_conversation_details, + inputs=[items_output, item_mapping], + outputs=[conversation_title, keywords_output, chat_history_output, notes_output] ) def create_ragdb_keyword_items_tab(): - with gr.TabItem("View Items by Keyword", visible=True): - gr.Markdown("# View Conversations and Notes by Keyword") + with gr.TabItem("View RAG Notes/Conversations by Keyword", visible=True): + gr.Markdown("# View RAG Notes and Conversations by Keyword") with gr.Row(): with gr.Column(scale=1): @@ -687,10 +736,10 @@ def create_ragdb_keyword_items_tab(): with gr.Column(scale=2): # Results tabs for conversations and notes with gr.Tabs(): - with gr.Tab("Conversations"): - conversation_results = gr.HTML() with gr.Tab("Notes"): notes_results = gr.HTML() + with gr.Tab("Conversations"): + conversation_results = gr.HTML() def update_keyword_choices(): """Fetch all available keywords for the dropdown.""" From aea4b08980a5d364de84749f4821c84c3162f85e Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 18:46:14 -0700 Subject: [PATCH 10/15] Create Anki_Validation_tab.py --- .../Gradio_UI/Anki_Validation_tab.py | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py diff --git a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py new file mode 100644 index 000000000..d9a7623e2 --- /dev/null +++ b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py @@ -0,0 +1,300 @@ +# Anki_Validation_tab.py +# Description: Gradio functions for the Anki Validation tab +# +# Imports +import json +# +# External Imports +import gradio as gr +# +# Local Imports +# +############################################################################################################ +# +# Functions: + +def create_anki_validation_tab(): + with gr.TabItem("Anki Flashcard Validation", visible=True): + gr.Markdown("# Anki Flashcard Validation and Editor") + + with gr.Row(): + # Left Column: Input and Validation + with gr.Column(scale=1): + gr.Markdown("## Import or Create Flashcards") + flashcard_input = gr.TextArea( + label="Enter Flashcards (JSON format)", + placeholder='''{ + "cards": [ + { + "id": "CARD_001", + "type": "basic", + "front": "What is the capital of France?", + "back": "Paris", + "tags": ["geography", "europe"], + "note": "Remember: City of Light" + } + ] +}''', + lines=10 + ) + + import_file = gr.File( + label="Or Import JSON File", + file_types=[".json"] + ) + + validate_button = gr.Button("Validate Flashcards") + + # Right Column: Validation Results and Editor + with gr.Column(scale=1): + gr.Markdown("## Validation Results") + validation_status = gr.Markdown("") + + with gr.Accordion("Validation Rules", open=False): + gr.Markdown(""" + ### Required Fields: + - Unique ID + - Card Type (basic, cloze, reverse) + - Front content + - Back content + - At least one tag + + ### Content Rules: + - No empty fields + - Front side should be a clear question/prompt + - Back side should contain complete answer + - Cloze deletions must have valid syntax + - No duplicate IDs + """) + + with gr.Row(): + # Card Editor + gr.Markdown("## Card Editor") + with gr.Accordion("Edit Individual Cards", open=True): + card_selector = gr.Dropdown( + label="Select Card to Edit", + choices=[], + interactive=True + ) + + card_type = gr.Radio( + choices=["basic", "cloze", "reverse"], + label="Card Type", + value="basic" + ) + + front_content = gr.TextArea( + label="Front Content", + lines=3 + ) + + back_content = gr.TextArea( + label="Back Content", + lines=3 + ) + + tags_input = gr.TextArea( + label="Tags (comma-separated)", + lines=1 + ) + + notes_input = gr.TextArea( + label="Additional Notes", + lines=2 + ) + + update_card_button = gr.Button("Update Card") + delete_card_button = gr.Button("Delete Card", variant="stop") + + with gr.Row(): + # Export Options + gr.Markdown("## Export Options") + export_format = gr.Radio( + choices=["Anki CSV", "JSON", "Plain Text"], + label="Export Format", + value="Anki CSV" + ) + export_button = gr.Button("Export Valid Cards") + export_file = gr.File(label="Download Validated Cards") + export_status = gr.Markdown("") + + # Helper Functions + def validate_flashcards(content): + try: + data = json.loads(content) + validation_results = [] + is_valid = True + + if not isinstance(data, dict) or 'cards' not in data: + return False, "Invalid JSON format. Must contain 'cards' array." + + seen_ids = set() + for idx, card in enumerate(data['cards']): + card_issues = [] + + # Check required fields + if 'id' not in card: + card_issues.append("Missing ID") + elif card['id'] in seen_ids: + card_issues.append("Duplicate ID") + else: + seen_ids.add(card['id']) + + if 'type' not in card or card['type'] not in ['basic', 'cloze', 'reverse']: + card_issues.append("Invalid card type") + + if 'front' not in card or not card['front'].strip(): + card_issues.append("Missing front content") + + if 'back' not in card or not card['back'].strip(): + card_issues.append("Missing back content") + + if 'tags' not in card or not card['tags']: + card_issues.append("Missing tags") + + # Content-specific validation + if card.get('type') == 'cloze': + if '{{c1::' not in card['front']: + card_issues.append("Invalid cloze format") + + if card_issues: + is_valid = False + validation_results.append(f"Card {card['id']}: {', '.join(card_issues)}") + + return is_valid, "\n".join(validation_results) if validation_results else "All cards are valid!" + + except json.JSONDecodeError: + return False, "Invalid JSON format" + except Exception as e: + return False, f"Validation error: {str(e)}" + + def load_card_for_editing(card_selection, current_content): + if not card_selection or not current_content: + return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + + try: + data = json.loads(current_content) + selected_id = card_selection.split(" - ")[0] + + for card in data['cards']: + if card['id'] == selected_id: + return ( + card, + card['type'], + card['front'], + card['back'], + ", ".join(card['tags']), + card.get('note', '') + ) + + return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + + except Exception as e: + return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() + + def update_card(current_content, card_selection, card_type, front, back, tags, notes): + try: + data = json.loads(current_content) + selected_id = card_selection.split(" - ")[0] + + for card in data['cards']: + if card['id'] == selected_id: + card['type'] = card_type + card['front'] = front + card['back'] = back + card['tags'] = [tag.strip() for tag in tags.split(',')] + card['note'] = notes + + return json.dumps(data, indent=2), "Card updated successfully!" + + except Exception as e: + return current_content, f"Error updating card: {str(e)}" + + def export_cards(content, format_type): + try: + is_valid, validation_message = validate_flashcards(content) + if not is_valid: + return "Please fix validation issues before exporting.", None + + data = json.loads(content) + + if format_type == "Anki CSV": + output = "Front,Back,Tags,Type,Note\n" + for card in data['cards']: + output += f'"{card["front"]}","{card["back"]}","{" ".join(card["tags"])}","{card["type"]}","{card.get("note", "")}"\n' + return "Cards exported successfully!", ("anki_cards.csv", output, "text/csv") + + elif format_type == "JSON": + return "Cards exported successfully!", ("anki_cards.json", content, "application/json") + + else: # Plain Text + output = "" + for card in data['cards']: + output += f"Q: {card['front']}\nA: {card['back']}\n\n" + return "Cards exported successfully!", ("anki_cards.txt", output, "text/plain") + + except Exception as e: + return f"Export error: {str(e)}", None + + # Register callbacks + validate_button.click( + fn=validate_flashcards, + inputs=[flashcard_input], + outputs=[validation_status] + ) + + card_selector.change( + fn=load_card_for_editing, + inputs=[card_selector, flashcard_input], + outputs=[ + gr.State(), # For storing current card data + card_type, + front_content, + back_content, + tags_input, + notes_input + ] + ) + + update_card_button.click( + fn=update_card, + inputs=[ + flashcard_input, + card_selector, + card_type, + front_content, + back_content, + tags_input, + notes_input + ], + outputs=[flashcard_input, validation_status] + ) + + export_button.click( + fn=export_cards, + inputs=[flashcard_input, export_format], + outputs=[export_status, export_file] + ) + + return ( + flashcard_input, + import_file, + validate_button, + validation_status, + card_selector, + card_type, + front_content, + back_content, + tags_input, + notes_input, + update_card_button, + delete_card_button, + export_format, + export_button, + export_file, + export_status + ) + +# +# End of Anki_Validation_tab.py +############################################################################################################ From 30b1d8bfea1dca30111ccb28f1df7480a2927e08 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 19:23:56 -0700 Subject: [PATCH 11/15] Well, tried adding Anki generation, outlines requires a rust compiler though.... Will revisit to figure out how to do without outlines --- App_Function_Libraries/Gradio_Related.py | 4 + .../Gradio_UI/Anki_Validation_tab.py | 467 ++++++++++++++++++ App_Function_Libraries/Third_Party/Anki.py | 79 +++ requirements.txt | 2 + 4 files changed, 552 insertions(+) create mode 100644 App_Function_Libraries/Third_Party/Anki.py diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 5e930cb27..2d4f39e65 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -16,6 +16,7 @@ # # Local Imports from App_Function_Libraries.DB.DB_Manager import get_db_config +from App_Function_Libraries.Gradio_UI.Anki_Validation_tab import create_anki_validation_tab from App_Function_Libraries.Gradio_UI.Arxiv_tab import create_arxiv_tab from App_Function_Libraries.Gradio_UI.Audio_ingestion_tab import create_audio_processing_tab from App_Function_Libraries.Gradio_UI.Book_Ingestion_tab import create_import_book_tab @@ -378,6 +379,9 @@ def launch_ui(share_public=None, server_mode=False): create_restore_backup_tab() with gr.TabItem("Utilities", id="util group", visible=True): + # FIXME + #create_anki_generation_tab() + create_anki_validation_tab() create_utilities_yt_video_tab() create_utilities_yt_audio_tab() create_utilities_yt_timestamp_tab() diff --git a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py index d9a7623e2..1ab950336 100644 --- a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py +++ b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py @@ -3,16 +3,483 @@ # # Imports import json +import logging +from datetime import datetime +from typing import Dict, Any + # # External Imports import gradio as gr +#from outlines import models, prompts # # Local Imports +from App_Function_Libraries.Gradio_UI.Chat_ui import chat_wrapper +from App_Function_Libraries.Utils.Utils import default_api_endpoint, format_api_name, global_api_endpoints # ############################################################################################################ # # Functions: +# def create_anki_generation_tab(): +# try: +# default_value = None +# if default_api_endpoint: +# if default_api_endpoint in global_api_endpoints: +# default_value = format_api_name(default_api_endpoint) +# else: +# logging.warning(f"Default API endpoint '{default_api_endpoint}' not found in global_api_endpoints") +# except Exception as e: +# logging.error(f"Error setting default API endpoint: {str(e)}") +# default_value = None +# with gr.TabItem("Anki Flashcard Generation", visible=True): +# gr.Markdown("# Anki Flashcard Generation") +# chat_history = gr.State([]) +# generated_cards_state = gr.State({}) +# +# # Add progress tracking +# generation_progress = gr.Progress() +# status_message = gr.Status() +# +# with gr.Row(): +# # Left Column: Generation Controls +# with gr.Column(scale=1): +# gr.Markdown("## Content Input") +# source_text = gr.TextArea( +# label="Source Text or Topic", +# placeholder="Enter the text or topic you want to create flashcards from...", +# lines=5 +# ) +# +# # API Configuration +# api_endpoint = gr.Dropdown( +# choices=["None"] + [format_api_name(api) for api in global_api_endpoints], +# value=default_value, +# label="API for Card Generation" +# ) +# api_key = gr.Textbox(label="API Key (if required)", type="password") +# +# with gr.Accordion("Generation Settings", open=True): +# num_cards = gr.Slider( +# minimum=1, +# maximum=20, +# value=5, +# step=1, +# label="Number of Cards" +# ) +# +# card_types = gr.CheckboxGroup( +# choices=["basic", "cloze", "reverse"], +# value=["basic"], +# label="Card Types to Generate" +# ) +# +# difficulty_level = gr.Radio( +# choices=["beginner", "intermediate", "advanced"], +# value="intermediate", +# label="Difficulty Level" +# ) +# +# subject_area = gr.Dropdown( +# choices=[ +# "general", +# "language_learning", +# "science", +# "mathematics", +# "history", +# "geography", +# "computer_science", +# "custom" +# ], +# value="general", +# label="Subject Area" +# ) +# +# custom_subject = gr.Textbox( +# label="Custom Subject", +# visible=False, +# placeholder="Enter custom subject..." +# ) +# +# with gr.Accordion("Advanced Options", open=False): +# temperature = gr.Slider( +# label="Temperature", +# minimum=0.00, +# maximum=1.0, +# step=0.05, +# value=0.7 +# ) +# +# max_retries = gr.Slider( +# label="Max Retries on Error", +# minimum=1, +# maximum=5, +# step=1, +# value=3 +# ) +# +# include_examples = gr.Checkbox( +# label="Include example usage", +# value=True +# ) +# +# include_mnemonics = gr.Checkbox( +# label="Generate mnemonics", +# value=True +# ) +# +# include_hints = gr.Checkbox( +# label="Include hints", +# value=True +# ) +# +# tag_style = gr.Radio( +# choices=["broad", "specific", "hierarchical"], +# value="specific", +# label="Tag Style" +# ) +# +# system_prompt = gr.Textbox( +# label="System Prompt", +# value="You are an expert at creating effective Anki flashcards.", +# lines=2 +# ) +# +# generate_button = gr.Button("Generate Flashcards") +# regenerate_button = gr.Button("Regenerate", visible=False) +# error_log = gr.TextArea( +# label="Error Log", +# visible=False, +# lines=3 +# ) +# +# # Right Column: Chat Interface and Preview +# with gr.Column(scale=1): +# gr.Markdown("## Interactive Card Generation") +# chatbot = gr.Chatbot(height=400, elem_classes="chatbot-container") +# +# with gr.Row(): +# msg = gr.Textbox( +# label="Chat to refine cards", +# placeholder="Ask questions or request modifications..." +# ) +# submit_chat = gr.Button("Submit") +# +# gr.Markdown("## Generated Cards Preview") +# generated_cards = gr.JSON(label="Generated Flashcards") +# +# with gr.Row(): +# edit_generated = gr.Button("Edit in Validator") +# save_generated = gr.Button("Save to File") +# clear_chat = gr.Button("Clear Chat") +# +# generation_status = gr.Markdown("") +# download_file = gr.File(label="Download Cards", visible=False) +# +# # Helper Functions and Classes +# class AnkiCardGenerator: +# def __init__(self): +# self.schema = { +# "type": "object", +# "properties": { +# "cards": { +# "type": "array", +# "items": { +# "type": "object", +# "properties": { +# "id": {"type": "string"}, +# "type": {"type": "string", "enum": ["basic", "cloze", "reverse"]}, +# "front": {"type": "string"}, +# "back": {"type": "string"}, +# "tags": { +# "type": "array", +# "items": {"type": "string"} +# }, +# "note": {"type": "string"} +# }, +# "required": ["id", "type", "front", "back", "tags"] +# } +# } +# }, +# "required": ["cards"] +# } +# +# self.template = prompts.TextTemplate(""" +# Generate {num_cards} Anki flashcards about: {text} +# +# Requirements: +# - Difficulty: {difficulty} +# - Subject: {subject} +# - Card Types: {card_types} +# - Include Examples: {include_examples} +# - Include Mnemonics: {include_mnemonics} +# - Include Hints: {include_hints} +# - Tag Style: {tag_style} +# +# Each card must have: +# 1. Unique ID starting with CARD_ +# 2. Type (one of: basic, cloze, reverse) +# 3. Clear question/prompt on front +# 4. Comprehensive answer on back +# 5. Relevant tags including subject and difficulty +# 6. Optional note with study tips or mnemonics +# +# For cloze deletions, use the format {{c1::text to be hidden}}. +# +# Ensure each card: +# - Focuses on a single concept +# - Is clear and unambiguous +# - Uses appropriate formatting +# - Has relevant tags +# - Includes requested additional information +# """) +# +# async def generate_with_progress( +# self, +# text: str, +# config: Dict[str, Any], +# progress: gr.Progress +# ) -> GenerationResult: +# try: +# # Initialize progress +# progress(0, desc="Initializing generation...") +# +# # Configure model +# model = models.Model(config["api_endpoint"]) +# +# # Generate with schema validation +# progress(0.3, desc="Generating cards...") +# response = await model.generate( +# self.template, +# schema=self.schema, +# text=text, +# **config +# ) +# +# # Validate response +# progress(0.6, desc="Validating generated cards...") +# validated_cards = self.validate_cards(response) +# +# # Final processing +# progress(0.9, desc="Finalizing...") +# time.sleep(0.5) # Brief pause for UI feedback +# return GenerationResult( +# cards=validated_cards, +# error=None, +# status="Generation completed successfully!", +# progress=1.0 +# ) +# +# except Exception as e: +# logging.error(f"Card generation error: {str(e)}") +# return GenerationResult( +# cards=None, +# error=str(e), +# status=f"Error: {str(e)}", +# progress=1.0 +# ) +# +# def validate_cards(self, cards: Dict[str, Any]) -> Dict[str, Any]: +# """Validate and clean generated cards""" +# if not isinstance(cards, dict) or "cards" not in cards: +# raise ValueError("Invalid card format") +# +# seen_ids = set() +# cleaned_cards = [] +# +# for card in cards["cards"]: +# # Check ID uniqueness +# if card["id"] in seen_ids: +# card["id"] = f"{card['id']}_{len(seen_ids)}" +# seen_ids.add(card["id"]) +# +# # Validate card type +# if card["type"] not in ["basic", "cloze", "reverse"]: +# raise ValueError(f"Invalid card type: {card['type']}") +# +# # Check content +# if not card["front"].strip() or not card["back"].strip(): +# raise ValueError("Empty card content") +# +# # Validate cloze format +# if card["type"] == "cloze" and "{{c1::" not in card["front"]: +# raise ValueError("Invalid cloze format") +# +# # Clean and standardize tags +# if not isinstance(card["tags"], list): +# card["tags"] = [str(card["tags"])] +# card["tags"] = [tag.strip().lower() for tag in card["tags"] if tag.strip()] +# +# cleaned_cards.append(card) +# +# return {"cards": cleaned_cards} +# +# # Initialize generator +# generator = AnkiCardGenerator() +# +# async def generate_flashcards(*args): +# text, num_cards, card_types, difficulty, subject, custom_subject, \ +# include_examples, include_mnemonics, include_hints, tag_style, \ +# temperature, api_endpoint, api_key, system_prompt, max_retries = args +# +# actual_subject = custom_subject if subject == "custom" else subject +# +# config = { +# "num_cards": num_cards, +# "difficulty": difficulty, +# "subject": actual_subject, +# "card_types": card_types, +# "include_examples": include_examples, +# "include_mnemonics": include_mnemonics, +# "include_hints": include_hints, +# "tag_style": tag_style, +# "temperature": temperature, +# "api_endpoint": api_endpoint, +# "api_key": api_key, +# "system_prompt": system_prompt +# } +# +# errors = [] +# retry_count = 0 +# +# while retry_count < max_retries: +# try: +# result = await generator.generate_with_progress(text, config, generation_progress) +# +# if result.error: +# errors.append(f"Attempt {retry_count + 1}: {result.error}") +# retry_count += 1 +# await asyncio.sleep(1) +# continue +# +# return ( +# result.cards, +# gr.update(visible=True), +# result.status, +# gr.update(visible=False), +# [[None, "Cards generated! You can now modify them through chat."]] +# ) +# +# except Exception as e: +# errors.append(f"Attempt {retry_count + 1}: {str(e)}") +# retry_count += 1 +# await asyncio.sleep(1) +# +# error_log = "\n".join(errors) +# return ( +# None, +# gr.update(visible=False), +# "Failed to generate cards after all retries", +# gr.update(value=error_log, visible=True), +# [[None, "Failed to generate cards. Please check the error log."]] +# ) +# +# def save_generated_cards(cards): +# if not cards: +# return "No cards to save", None +# +# try: +# cards_json = json.dumps(cards, indent=2) +# current_time = datetime.now().strftime("%Y%m%d_%H%M%S") +# filename = f"anki_cards_{current_time}.json" +# +# return ( +# "Cards saved successfully!", +# (filename, cards_json, "application/json") +# ) +# except Exception as e: +# logging.error(f"Error saving cards: {e}") +# return f"Error saving cards: {str(e)}", None +# +# def clear_chat_history(): +# return [], [], "Chat cleared" +# +# def toggle_custom_subject(choice): +# return gr.update(visible=choice == "custom") +# +# def send_to_validator(cards): +# if not cards: +# return "No cards to validate" +# try: +# # Here you would integrate with your validation tab +# validated_cards = generator.validate_cards(cards) +# return "Cards validated and sent to validator" +# except Exception as e: +# logging.error(f"Validation error: {e}") +# return f"Validation error: {str(e)}" +# +# # Register callbacks +# subject_area.change( +# fn=toggle_custom_subject, +# inputs=subject_area, +# outputs=custom_subject +# ) +# +# generate_button.click( +# fn=generate_flashcards, +# inputs=[ +# source_text, num_cards, card_types, difficulty_level, +# subject_area, custom_subject, include_examples, +# include_mnemonics, include_hints, tag_style, +# temperature, api_endpoint, api_key, system_prompt, +# max_retries +# ], +# outputs=[ +# generated_cards, +# regenerate_button, +# generation_status, +# error_log, +# chatbot +# ] +# ) +# +# regenerate_button.click( +# fn=generate_flashcards, +# inputs=[ +# source_text, num_cards, card_types, difficulty_level, +# subject_area, custom_subject, include_examples, +# include_mnemonics, include_hints, tag_style, +# temperature, api_endpoint, api_key, system_prompt, +# max_retries +# ], +# outputs=[ +# generated_cards, +# regenerate_button, +# generation_status, +# error_log, +# chatbot +# ] +# ) +# +# clear_chat.click( +# fn=clear_chat_history, +# outputs=[chatbot, chat_history, generation_status] +# ) +# +# edit_generated.click( +# fn=send_to_validator, +# inputs=generated_cards, +# outputs=generation_status +# ) +# +# save_generated.click( +# fn=save_generated_cards, +# inputs=generated_cards, +# outputs=[generation_status, download_file] +# ) +# +# return ( +# source_text, num_cards, card_types, difficulty_level, +# subject_area, custom_subject, include_examples, +# include_mnemonics, include_hints, tag_style, +# api_endpoint, api_key, temperature, system_prompt, +# generate_button, regenerate_button, generated_cards, +# edit_generated, save_generated, clear_chat, +# generation_status, chatbot, msg, submit_chat, +# chat_history, generated_cards_state, download_file, +# error_log, max_retries +# ) + + def create_anki_validation_tab(): with gr.TabItem("Anki Flashcard Validation", visible=True): gr.Markdown("# Anki Flashcard Validation and Editor") diff --git a/App_Function_Libraries/Third_Party/Anki.py b/App_Function_Libraries/Third_Party/Anki.py new file mode 100644 index 000000000..01e50988d --- /dev/null +++ b/App_Function_Libraries/Third_Party/Anki.py @@ -0,0 +1,79 @@ +# Anki.py +# Description: Functions for Anki card generation +# +# Imports +# +# External Imports +from outlines import models, prompts +# Local Imports +# +############################################################################################################ +# +# Functions: + +def create_anki_schema(): + """Define schema for card generation using Outlines""" + return { + "type": "object", + "properties": { + "cards": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "type": {"type": "string", "enum": ["basic", "cloze", "reverse"]}, + "front": {"type": "string"}, + "back": {"type": "string"}, + "tags": { + "type": "array", + "items": {"type": "string"} + }, + "note": {"type": "string"} + }, + "required": ["id", "type", "front", "back", "tags"] + } + } + } + } + + +def generate_cards_with_outlines(text, num_cards, config): + """Generate cards using Outlines for structured output""" + schema = create_anki_schema() + + # Create prompt template + template = prompts.TextTemplate(""" + Generate {num_cards} Anki flashcards about: {text} + + Requirements: + - Difficulty: {difficulty} + - Subject: {subject} + - Card Types: {card_types} + + Each card must have: + 1. Unique ID + 2. Type (basic/cloze/reverse) + 3. Front content + 4. Back content + 5. Relevant tags + 6. Optional note/hint + """) + + # Configure model + model = models.Model(api_endpoint) + + # Generate with schema validation + response = model.generate( + template, + schema=schema, + num_cards=num_cards, + text=text, + **config + ) + + return response + +# +# End of Anki.py +############################################################################################################ diff --git a/requirements.txt b/requirements.txt index a2d59e9ed..ad95b2e62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,6 +26,8 @@ nltk numpy onnxruntime openai +# Requires rust compiler... +#outlines pandas Pillow playwright From df857da5410a8b603fdb6ebaf96e031339e7974b Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 19:52:37 -0700 Subject: [PATCH 12/15] Anki --- App_Function_Libraries/Gradio_Related.py | 4 +- .../Gradio_UI/Anki_Validation_tab.py | 513 ++++++++++++++++-- App_Function_Libraries/Third_Party/Anki.py | 392 ++++++++++--- 3 files changed, 807 insertions(+), 102 deletions(-) diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index 2d4f39e65..d1886f503 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -16,7 +16,8 @@ # # Local Imports from App_Function_Libraries.DB.DB_Manager import get_db_config -from App_Function_Libraries.Gradio_UI.Anki_Validation_tab import create_anki_validation_tab +from App_Function_Libraries.Gradio_UI.Anki_Validation_tab import create_anki_validation_tab, \ + create_anki_validation_tab_two from App_Function_Libraries.Gradio_UI.Arxiv_tab import create_arxiv_tab from App_Function_Libraries.Gradio_UI.Audio_ingestion_tab import create_audio_processing_tab from App_Function_Libraries.Gradio_UI.Book_Ingestion_tab import create_import_book_tab @@ -382,6 +383,7 @@ def launch_ui(share_public=None, server_mode=False): # FIXME #create_anki_generation_tab() create_anki_validation_tab() + create_anki_validation_tab_two() create_utilities_yt_video_tab() create_utilities_yt_audio_tab() create_utilities_yt_timestamp_tab() diff --git a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py index 1ab950336..cf5537c7a 100644 --- a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py +++ b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py @@ -537,53 +537,71 @@ def create_anki_validation_tab(): with gr.Row(): # Card Editor gr.Markdown("## Card Editor") - with gr.Accordion("Edit Individual Cards", open=True): - card_selector = gr.Dropdown( - label="Select Card to Edit", - choices=[], - interactive=True - ) + with gr.Row(): + with gr.Column(scale=1): + with gr.Accordion("Edit Individual Cards", open=True): + card_selector = gr.Dropdown( + label="Select Card to Edit", + choices=[], + interactive=True + ) - card_type = gr.Radio( - choices=["basic", "cloze", "reverse"], - label="Card Type", - value="basic" - ) + card_type = gr.Radio( + choices=["basic", "cloze", "reverse"], + label="Card Type", + value="basic" + ) - front_content = gr.TextArea( - label="Front Content", - lines=3 - ) + front_content = gr.TextArea( + label="Front Content", + lines=3 + ) - back_content = gr.TextArea( - label="Back Content", - lines=3 - ) + back_content = gr.TextArea( + label="Back Content", + lines=3 + ) - tags_input = gr.TextArea( - label="Tags (comma-separated)", - lines=1 - ) + tags_input = gr.TextArea( + label="Tags (comma-separated)", + lines=1 + ) - notes_input = gr.TextArea( - label="Additional Notes", - lines=2 - ) + notes_input = gr.TextArea( + label="Additional Notes", + lines=2 + ) - update_card_button = gr.Button("Update Card") - delete_card_button = gr.Button("Delete Card", variant="stop") + update_card_button = gr.Button("Update Card") + delete_card_button = gr.Button("Delete Card", variant="stop") with gr.Row(): - # Export Options - gr.Markdown("## Export Options") - export_format = gr.Radio( - choices=["Anki CSV", "JSON", "Plain Text"], - label="Export Format", - value="Anki CSV" - ) - export_button = gr.Button("Export Valid Cards") - export_file = gr.File(label="Download Validated Cards") - export_status = gr.Markdown("") + with gr.Column(scale=1): + # Export Options + gr.Markdown("## Export Options") + export_format = gr.Radio( + choices=["Anki CSV", "JSON", "Plain Text"], + label="Export Format", + value="Anki CSV" + ) + export_button = gr.Button("Export Valid Cards") + export_file = gr.File(label="Download Validated Cards") + export_status = gr.Markdown("") + with gr.Column(scale=1): + gr.Markdown("## Export Instructions") + gr.Markdown(""" + ### Anki CSV Format: + - Front, Back, Tags, Type, Note + - Use for importing into Anki + + ### JSON Format: + - JSON array of cards + - Use for custom processing + + ### Plain Text Format: + - Question and Answer pairs + - Use for manual review + """) # Helper Functions def validate_flashcards(content): @@ -765,3 +783,420 @@ def export_cards(content, format_type): # # End of Anki_Validation_tab.py ############################################################################################################ + +import json +import zipfile +import sqlite3 +import tempfile +import os +import shutil +import base64 +from pathlib import Path +import gradio as gr + + +def extract_media_from_apkg(zip_path, temp_dir): + """Extract and process media files from APKG.""" + media_files = {} + try: + # Extract media.json which maps filenames + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + if 'media' in zip_ref.namelist(): + media_json = json.loads(zip_ref.read('media').decode('utf-8')) + + # Extract all media files + for file_id, filename in media_json.items(): + if str(file_id) in zip_ref.namelist(): + file_data = zip_ref.read(str(file_id)) + file_path = os.path.join(temp_dir, filename) + + # Save file temporarily + with open(file_path, 'wb') as f: + f.write(file_data) + + # Convert to base64 for supported image types + if any(filename.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif']): + with open(file_path, 'rb') as f: + file_content = f.read() + file_ext = os.path.splitext(filename)[1].lower() + media_type = f"image/{file_ext[1:]}" + if file_ext == '.jpg': + media_type = "image/jpeg" + media_files[ + filename] = f"data:{media_type};base64,{base64.b64encode(file_content).decode('utf-8')}" + + # Clean up temporary file + os.remove(file_path) + + except Exception as e: + print(f"Error processing media: {str(e)}") + return media_files + + +def process_apkg_file(file_path): + """Extract and validate an APKG file, returning the card data and media files.""" + if not file_path: + return None, None, "No file provided" + + temp_dir = tempfile.mkdtemp() + try: + # Extract media files first + media_files = extract_media_from_apkg(file_path.name, temp_dir) + + # Extract APKG and process database + with zipfile.ZipFile(file_path.name, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + # Connect to the SQLite database + db_path = os.path.join(temp_dir, 'collection.anki2') + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get deck information + cursor.execute("SELECT decks, models FROM col") + decks_json, models_json = cursor.fetchone() + deck_info = { + "decks": json.loads(decks_json), + "models": json.loads(models_json) + } + + # Get cards and notes with media processing + cards_data = {"cards": []} + cursor.execute(""" + SELECT n.id, n.flds, n.tags, c.type, n.mid, m.name, n.sfld + FROM notes n + JOIN cards c ON c.nid = n.id + JOIN notetypes m ON m.id = n.mid + """) + + for row in cursor: + note_id, fields, tags, card_type, model_id, model_name, sort_field = row + fields_list = fields.split('\x1f') + + # Process fields for media references + processed_fields = [] + for field in fields_list: + # Replace media references with base64 data + for filename, base64_data in media_files.items(): + field = field.replace( + f' 1: + converted_type = 'basic' + else: + converted_type = 'basic' + + card_data = { + "id": f"APKG_{note_id}", + "type": converted_type, + "front": processed_fields[0], + "back": processed_fields[1] if len(processed_fields) > 1 else "", + "tags": tags.strip().split(" ") if tags.strip() else ["imported"], + "note": f"Imported from deck: {model_name}", + "has_media": any(' str: + """Sanitize HTML content while preserving valid image tags and basic formatting.""" + if not content: + return "" + + # Allow basic formatting and image tags + allowed_tags = {'img', 'b', 'i', 'u', 'div', 'br', 'p', 'span'} + allowed_attrs = {'src', 'alt', 'class', 'style'} + + # Remove potentially harmful attributes + content = re.sub(r'(on\w+)="[^"]*"', '', content) + content = re.sub(r'javascript:', '', content) + + # Parse and rebuild HTML + parser = HTMLParser() + parser.feed(content) + return content + + +def extract_media_from_apkg(zip_path: str, temp_dir: str) -> Dict[str, str]: + """Extract and process media files from APKG.""" + media_files = {} + try: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + if 'media' in zip_ref.namelist(): + media_json = json.loads(zip_ref.read('media').decode('utf-8')) + + for file_id, filename in media_json.items(): + if str(file_id) in zip_ref.namelist(): + file_data = zip_ref.read(str(file_id)) + file_path = os.path.join(temp_dir, filename) + + # Save file temporarily + with open(file_path, 'wb') as f: + f.write(file_data) + + # Process supported image types + if any(filename.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif']): + try: + with open(file_path, 'rb') as f: + file_content = f.read() + file_ext = os.path.splitext(filename)[1].lower() + media_type = f"image/{file_ext[1:]}" + if file_ext == '.jpg': + media_type = "image/jpeg" + media_files[ + filename] = f"data:{media_type};base64,{base64.b64encode(file_content).decode('utf-8')}" + except Exception as e: + print(f"Error processing image {filename}: {str(e)}") + + # Clean up temporary file + os.remove(file_path) + + except Exception as e: + print(f"Error processing media: {str(e)}") + return media_files + + +def validate_card_content(card: Dict[str, Any], seen_ids: set) -> list: + """Validate individual card content and structure.""" + issues = [] + + # Check required fields + if 'id' not in card: + issues.append("Missing ID") + elif card['id'] in seen_ids: + issues.append("Duplicate ID") + else: + seen_ids.add(card['id']) + + if 'type' not in card or card['type'] not in ['basic', 'cloze', 'reverse']: + issues.append("Invalid card type") + + if 'front' not in card or not card['front'].strip(): + issues.append("Missing front content") + + if 'back' not in card or not card['back'].strip(): + issues.append("Missing back content") + + if 'tags' not in card or not card['tags']: + issues.append("Missing tags") + + # Content-specific validation + if card.get('type') == 'cloze': + if '{{c1::' not in card['front']: + issues.append("Invalid cloze format") + + # Image validation + for field in ['front', 'back']: + if ' Tuple[Optional[Dict], Optional[Dict], str]: + """Process APKG file and extract cards, media, and deck information.""" + if not file_path: + return None, None, "No file provided" + + temp_dir = tempfile.mkdtemp() + try: + # Extract media files first + media_files = extract_media_from_apkg(file_path.name, temp_dir) + + # Process database + with zipfile.ZipFile(file_path.name, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + db_path = os.path.join(temp_dir, 'collection.anki2') + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get collection info + cursor.execute("SELECT decks, models FROM col") + decks_json, models_json = cursor.fetchone() + deck_info = { + "decks": json.loads(decks_json), + "models": json.loads(models_json) } - } - - -def generate_cards_with_outlines(text, num_cards, config): - """Generate cards using Outlines for structured output""" - schema = create_anki_schema() - - # Create prompt template - template = prompts.TextTemplate(""" - Generate {num_cards} Anki flashcards about: {text} - - Requirements: - - Difficulty: {difficulty} - - Subject: {subject} - - Card Types: {card_types} - - Each card must have: - 1. Unique ID - 2. Type (basic/cloze/reverse) - 3. Front content - 4. Back content - 5. Relevant tags - 6. Optional note/hint - """) - - # Configure model - model = models.Model(api_endpoint) - - # Generate with schema validation - response = model.generate( - template, - schema=schema, - num_cards=num_cards, - text=text, - **config - ) - - return response + + # Process cards and notes + cards_data = {"cards": []} + cursor.execute(""" + SELECT + n.id, n.flds, n.tags, c.type, n.mid, + m.name, n.sfld, m.flds, m.tmpls + FROM notes n + JOIN cards c ON c.nid = n.id + JOIN notetypes m ON m.id = n.mid + """) + + for row in cursor: + note_id, fields, tags, card_type, model_id, model_name, sort_field, fields_json, templates_json = row + fields_list = fields.split('\x1f') + fields_config = json.loads(fields_json) + templates = json.loads(templates_json) + + # Process fields with media + processed_fields = [] + for field in fields_list: + field_html = field + for filename, base64_data in media_files.items(): + field_html = field_html.replace( + f' 1 else "", + "tags": tags.strip().split(" ") if tags.strip() else ["imported"], + "note": f"Imported from deck: {model_name}", + "has_media": any(' Tuple[bool, str]: + """Validate flashcard content with enhanced image support.""" + try: + data = json.loads(content) + validation_results = [] + is_valid = True + + if not isinstance(data, dict) or 'cards' not in data: + return False, "Invalid JSON format. Must contain 'cards' array." + + seen_ids = set() + for idx, card in enumerate(data['cards']): + card_issues = validate_card_content(card, seen_ids) + + if card_issues: + is_valid = False + validation_results.append(f"Card {card['id']}: {', '.join(card_issues)}") + + return is_valid, "\n".join(validation_results) if validation_results else "All cards are valid!" + + except json.JSONDecodeError: + return False, "Invalid JSON format" + except Exception as e: + return False, f"Validation error: {str(e)}" + + +def handle_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Optional[Dict], str]: + """Handle file upload based on input type.""" + if not file: + return None, None, "No file uploaded" + + if input_type == "APKG": + cards_data, deck_info, message = process_apkg_file(file) + if cards_data: + return json.dumps(cards_data, indent=2), deck_info, message + return None, None, message + else: # JSON + try: + content = file.read().decode('utf-8') + json.loads(content) # Validate JSON + return content, None, "JSON file loaded successfully!" + except Exception as e: + return None, None, f"Error loading JSON file: {str(e)}" + + +def update_card_content( + current_content: str, + card_id: str, + card_type: str, + front: str, + back: str, + tags: str, + notes: str +) -> Tuple[str, str]: + """Update card content and return updated JSON.""" + try: + data = json.loads(current_content) + + for card in data['cards']: + if card['id'] == card_id: + # Sanitize input content + card['type'] = card_type + card['front'] = sanitize_html(front) + card['back'] = sanitize_html(back) + card['tags'] = [tag.strip() for tag in tags.split(',')] + card['note'] = notes + + # Update media status + card['has_media'] = ' Tuple[str, Optional[Tuple[str, str, str]]]: + """Export cards in the specified format.""" + try: + is_valid, validation_message = validate_flashcards(content) + if not is_valid: + return "Please fix validation issues before exporting.", None + + data = json.loads(content) + + if format_type == "Anki CSV": + output = "Front,Back,Tags,Type,Note\n" + for card in data['cards']: + output += f'"{card["front"]}","{card["back"]}","{" ".join(card["tags"])}","{card["type"]}","{card.get("note", "")}"\n' + return "Cards exported successfully!", ("anki_cards.csv", output, "text/csv") + + elif format_type == "JSON": + return "Cards exported successfully!", ("anki_cards.json", content, "application/json") + + else: # Plain Text + output = "" + for card in data['cards']: + # Replace image tags with placeholders + front = re.sub(r']+>', '[IMG]', card['front']) + back = re.sub(r']+>', '[IMG]', card['back']) + output += f"Q: {front}\nA: {back}\nTags: {', '.join(card['tags'])}\n\n" + return "Cards exported successfully!", ("anki_cards.txt", output, "text/plain") + + except Exception as e: + return f"Export error: {str(e)}", None + + +def generate_card_choices(content: str) -> list: + """Generate choices for card selector dropdown.""" + try: + data = json.loads(content) + return [f"{card['id']} - {card['front'][:50]}..." for card in data['cards']] + except: + return [] + + # # End of Anki.py From b7683d5de0e28230e71f305c032b426376f86696 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 20:04:33 -0700 Subject: [PATCH 13/15] Anki... --- .../Gradio_UI/Anki_Validation_tab.py | 392 ++++++++++++------ App_Function_Libraries/Third_Party/Anki.py | 38 ++ 2 files changed, 313 insertions(+), 117 deletions(-) diff --git a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py index cf5537c7a..7c9a2516a 100644 --- a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py +++ b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py @@ -2,11 +2,17 @@ # Description: Gradio functions for the Anki Validation tab # # Imports +from datetime import datetime +import base64 import json import logging -from datetime import datetime -from typing import Dict, Any - +import os +from pathlib import Path +import shutil +import sqlite3 +import tempfile +from typing import Dict, Any, Optional, Tuple +import zipfile # # External Imports import gradio as gr @@ -14,6 +20,8 @@ # # Local Imports from App_Function_Libraries.Gradio_UI.Chat_ui import chat_wrapper +from App_Function_Libraries.Third_Party.Anki import sanitize_html, generate_card_choices, update_card_content, \ + export_cards, load_card_for_editing from App_Function_Libraries.Utils.Utils import default_api_endpoint, format_api_name, global_api_endpoints # ############################################################################################################ @@ -784,17 +792,6 @@ def export_cards(content, format_type): # End of Anki_Validation_tab.py ############################################################################################################ -import json -import zipfile -import sqlite3 -import tempfile -import os -import shutil -import base64 -from pathlib import Path -import gradio as gr - - def extract_media_from_apkg(zip_path, temp_dir): """Extract and process media files from APKG.""" media_files = {} @@ -833,79 +830,87 @@ def extract_media_from_apkg(zip_path, temp_dir): return media_files -def process_apkg_file(file_path): - """Extract and validate an APKG file, returning the card data and media files.""" +def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], str]: + """Process APKG file and extract cards, media, and deck information.""" if not file_path: return None, None, "No file provided" temp_dir = tempfile.mkdtemp() + conn = None + try: # Extract media files first media_files = extract_media_from_apkg(file_path.name, temp_dir) - # Extract APKG and process database + # Process database with zipfile.ZipFile(file_path.name, 'r') as zip_ref: zip_ref.extractall(temp_dir) - # Connect to the SQLite database db_path = os.path.join(temp_dir, 'collection.anki2') - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Get deck information - cursor.execute("SELECT decks, models FROM col") - decks_json, models_json = cursor.fetchone() - deck_info = { - "decks": json.loads(decks_json), - "models": json.loads(models_json) - } - # Get cards and notes with media processing - cards_data = {"cards": []} - cursor.execute(""" - SELECT n.id, n.flds, n.tags, c.type, n.mid, m.name, n.sfld - FROM notes n - JOIN cards c ON c.nid = n.id - JOIN notetypes m ON m.id = n.mid - """) - - for row in cursor: - note_id, fields, tags, card_type, model_id, model_name, sort_field = row - fields_list = fields.split('\x1f') - - # Process fields for media references - processed_fields = [] - for field in fields_list: - # Replace media references with base64 data - for filename, base64_data in media_files.items(): - field = field.replace( - f' 1: - converted_type = 'basic' - else: - converted_type = 'basic' + # Use context manager for SQLite connection + with sqlite3.connect(db_path) as conn: + cursor = conn.cursor() - card_data = { - "id": f"APKG_{note_id}", - "type": converted_type, - "front": processed_fields[0], - "back": processed_fields[1] if len(processed_fields) > 1 else "", - "tags": tags.strip().split(" ") if tags.strip() else ["imported"], - "note": f"Imported from deck: {model_name}", - "has_media": any(' 1 else "", + "tags": tags.strip().split(" ") if tags.strip() else ["imported"], + "note": f"Imported from deck: {model_name}", + "has_media": any(' tuple: + """ + Load a card for editing and generate previews. + + Args: + card_selection (str): Selected card ID and preview text + current_content (str): Current JSON content + + Returns: + tuple: (card_type, front_content, back_content, tags, notes, front_preview, back_preview) + """ + if not card_selection or not current_content: + return "basic", "", "", "", "", "", "" + + try: + data = json.loads(current_content) + selected_id = card_selection.split(" - ")[0] + + for card in data['cards']: + if card['id'] == selected_id: + # Return all required fields with preview content + return ( + card['type'], + card['front'], + card['back'], + ", ".join(card['tags']), + card.get('note', ''), + sanitize_html(card['front']), + sanitize_html(card['back']) + ) + + return "basic", "", "", "", "", "", "" + + except Exception as e: + print(f"Error loading card: {str(e)}") + return "basic", "", "", "", "", "", "" + + def export_cards(content: str, format_type: str) -> Tuple[str, Optional[Tuple[str, str, str]]]: """Export cards in the specified format.""" try: From b0722c4997f7f63b0dd060a8d767a87d23537f10 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 20:41:28 -0700 Subject: [PATCH 14/15] more anki --- App_Function_Libraries/Gradio_Related.py | 4 +- .../Gradio_UI/Anki_Validation_tab.py | 608 ++---------------- App_Function_Libraries/Third_Party/Anki.py | 288 +++++++-- 3 files changed, 294 insertions(+), 606 deletions(-) diff --git a/App_Function_Libraries/Gradio_Related.py b/App_Function_Libraries/Gradio_Related.py index d1886f503..2d4f39e65 100644 --- a/App_Function_Libraries/Gradio_Related.py +++ b/App_Function_Libraries/Gradio_Related.py @@ -16,8 +16,7 @@ # # Local Imports from App_Function_Libraries.DB.DB_Manager import get_db_config -from App_Function_Libraries.Gradio_UI.Anki_Validation_tab import create_anki_validation_tab, \ - create_anki_validation_tab_two +from App_Function_Libraries.Gradio_UI.Anki_Validation_tab import create_anki_validation_tab from App_Function_Libraries.Gradio_UI.Arxiv_tab import create_arxiv_tab from App_Function_Libraries.Gradio_UI.Audio_ingestion_tab import create_audio_processing_tab from App_Function_Libraries.Gradio_UI.Book_Ingestion_tab import create_import_book_tab @@ -383,7 +382,6 @@ def launch_ui(share_public=None, server_mode=False): # FIXME #create_anki_generation_tab() create_anki_validation_tab() - create_anki_validation_tab_two() create_utilities_yt_video_tab() create_utilities_yt_audio_tab() create_utilities_yt_timestamp_tab() diff --git a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py index 7c9a2516a..7560d97b5 100644 --- a/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py +++ b/App_Function_Libraries/Gradio_UI/Anki_Validation_tab.py @@ -11,7 +11,7 @@ import shutil import sqlite3 import tempfile -from typing import Dict, Any, Optional, Tuple +from typing import Dict, Any, Optional, Tuple, List import zipfile # # External Imports @@ -20,8 +20,10 @@ # # Local Imports from App_Function_Libraries.Gradio_UI.Chat_ui import chat_wrapper -from App_Function_Libraries.Third_Party.Anki import sanitize_html, generate_card_choices, update_card_content, \ - export_cards, load_card_for_editing +from App_Function_Libraries.Third_Party.Anki import sanitize_html, generate_card_choices, \ + export_cards, load_card_for_editing, validate_flashcards, handle_file_upload, \ + validate_for_ui, update_card_with_validation, update_card_choices, format_validation_result, enhanced_file_upload, \ + handle_validation from App_Function_Libraries.Utils.Utils import default_api_endpoint, format_api_name, global_api_endpoints # ############################################################################################################ @@ -487,537 +489,7 @@ # error_log, max_retries # ) - def create_anki_validation_tab(): - with gr.TabItem("Anki Flashcard Validation", visible=True): - gr.Markdown("# Anki Flashcard Validation and Editor") - - with gr.Row(): - # Left Column: Input and Validation - with gr.Column(scale=1): - gr.Markdown("## Import or Create Flashcards") - flashcard_input = gr.TextArea( - label="Enter Flashcards (JSON format)", - placeholder='''{ - "cards": [ - { - "id": "CARD_001", - "type": "basic", - "front": "What is the capital of France?", - "back": "Paris", - "tags": ["geography", "europe"], - "note": "Remember: City of Light" - } - ] -}''', - lines=10 - ) - - import_file = gr.File( - label="Or Import JSON File", - file_types=[".json"] - ) - - validate_button = gr.Button("Validate Flashcards") - - # Right Column: Validation Results and Editor - with gr.Column(scale=1): - gr.Markdown("## Validation Results") - validation_status = gr.Markdown("") - - with gr.Accordion("Validation Rules", open=False): - gr.Markdown(""" - ### Required Fields: - - Unique ID - - Card Type (basic, cloze, reverse) - - Front content - - Back content - - At least one tag - - ### Content Rules: - - No empty fields - - Front side should be a clear question/prompt - - Back side should contain complete answer - - Cloze deletions must have valid syntax - - No duplicate IDs - """) - - with gr.Row(): - # Card Editor - gr.Markdown("## Card Editor") - with gr.Row(): - with gr.Column(scale=1): - with gr.Accordion("Edit Individual Cards", open=True): - card_selector = gr.Dropdown( - label="Select Card to Edit", - choices=[], - interactive=True - ) - - card_type = gr.Radio( - choices=["basic", "cloze", "reverse"], - label="Card Type", - value="basic" - ) - - front_content = gr.TextArea( - label="Front Content", - lines=3 - ) - - back_content = gr.TextArea( - label="Back Content", - lines=3 - ) - - tags_input = gr.TextArea( - label="Tags (comma-separated)", - lines=1 - ) - - notes_input = gr.TextArea( - label="Additional Notes", - lines=2 - ) - - update_card_button = gr.Button("Update Card") - delete_card_button = gr.Button("Delete Card", variant="stop") - - with gr.Row(): - with gr.Column(scale=1): - # Export Options - gr.Markdown("## Export Options") - export_format = gr.Radio( - choices=["Anki CSV", "JSON", "Plain Text"], - label="Export Format", - value="Anki CSV" - ) - export_button = gr.Button("Export Valid Cards") - export_file = gr.File(label="Download Validated Cards") - export_status = gr.Markdown("") - with gr.Column(scale=1): - gr.Markdown("## Export Instructions") - gr.Markdown(""" - ### Anki CSV Format: - - Front, Back, Tags, Type, Note - - Use for importing into Anki - - ### JSON Format: - - JSON array of cards - - Use for custom processing - - ### Plain Text Format: - - Question and Answer pairs - - Use for manual review - """) - - # Helper Functions - def validate_flashcards(content): - try: - data = json.loads(content) - validation_results = [] - is_valid = True - - if not isinstance(data, dict) or 'cards' not in data: - return False, "Invalid JSON format. Must contain 'cards' array." - - seen_ids = set() - for idx, card in enumerate(data['cards']): - card_issues = [] - - # Check required fields - if 'id' not in card: - card_issues.append("Missing ID") - elif card['id'] in seen_ids: - card_issues.append("Duplicate ID") - else: - seen_ids.add(card['id']) - - if 'type' not in card or card['type'] not in ['basic', 'cloze', 'reverse']: - card_issues.append("Invalid card type") - - if 'front' not in card or not card['front'].strip(): - card_issues.append("Missing front content") - - if 'back' not in card or not card['back'].strip(): - card_issues.append("Missing back content") - - if 'tags' not in card or not card['tags']: - card_issues.append("Missing tags") - - # Content-specific validation - if card.get('type') == 'cloze': - if '{{c1::' not in card['front']: - card_issues.append("Invalid cloze format") - - if card_issues: - is_valid = False - validation_results.append(f"Card {card['id']}: {', '.join(card_issues)}") - - return is_valid, "\n".join(validation_results) if validation_results else "All cards are valid!" - - except json.JSONDecodeError: - return False, "Invalid JSON format" - except Exception as e: - return False, f"Validation error: {str(e)}" - - def load_card_for_editing(card_selection, current_content): - if not card_selection or not current_content: - return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() - - try: - data = json.loads(current_content) - selected_id = card_selection.split(" - ")[0] - - for card in data['cards']: - if card['id'] == selected_id: - return ( - card, - card['type'], - card['front'], - card['back'], - ", ".join(card['tags']), - card.get('note', '') - ) - - return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() - - except Exception as e: - return {}, gr.update(), gr.update(), gr.update(), gr.update(), gr.update() - - def update_card(current_content, card_selection, card_type, front, back, tags, notes): - try: - data = json.loads(current_content) - selected_id = card_selection.split(" - ")[0] - - for card in data['cards']: - if card['id'] == selected_id: - card['type'] = card_type - card['front'] = front - card['back'] = back - card['tags'] = [tag.strip() for tag in tags.split(',')] - card['note'] = notes - - return json.dumps(data, indent=2), "Card updated successfully!" - - except Exception as e: - return current_content, f"Error updating card: {str(e)}" - - def export_cards(content, format_type): - try: - is_valid, validation_message = validate_flashcards(content) - if not is_valid: - return "Please fix validation issues before exporting.", None - - data = json.loads(content) - - if format_type == "Anki CSV": - output = "Front,Back,Tags,Type,Note\n" - for card in data['cards']: - output += f'"{card["front"]}","{card["back"]}","{" ".join(card["tags"])}","{card["type"]}","{card.get("note", "")}"\n' - return "Cards exported successfully!", ("anki_cards.csv", output, "text/csv") - - elif format_type == "JSON": - return "Cards exported successfully!", ("anki_cards.json", content, "application/json") - - else: # Plain Text - output = "" - for card in data['cards']: - output += f"Q: {card['front']}\nA: {card['back']}\n\n" - return "Cards exported successfully!", ("anki_cards.txt", output, "text/plain") - - except Exception as e: - return f"Export error: {str(e)}", None - - # Register callbacks - validate_button.click( - fn=validate_flashcards, - inputs=[flashcard_input], - outputs=[validation_status] - ) - - card_selector.change( - fn=load_card_for_editing, - inputs=[card_selector, flashcard_input], - outputs=[ - gr.State(), # For storing current card data - card_type, - front_content, - back_content, - tags_input, - notes_input - ] - ) - - update_card_button.click( - fn=update_card, - inputs=[ - flashcard_input, - card_selector, - card_type, - front_content, - back_content, - tags_input, - notes_input - ], - outputs=[flashcard_input, validation_status] - ) - - export_button.click( - fn=export_cards, - inputs=[flashcard_input, export_format], - outputs=[export_status, export_file] - ) - - return ( - flashcard_input, - import_file, - validate_button, - validation_status, - card_selector, - card_type, - front_content, - back_content, - tags_input, - notes_input, - update_card_button, - delete_card_button, - export_format, - export_button, - export_file, - export_status - ) - -# -# End of Anki_Validation_tab.py -############################################################################################################ - -def extract_media_from_apkg(zip_path, temp_dir): - """Extract and process media files from APKG.""" - media_files = {} - try: - # Extract media.json which maps filenames - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - if 'media' in zip_ref.namelist(): - media_json = json.loads(zip_ref.read('media').decode('utf-8')) - - # Extract all media files - for file_id, filename in media_json.items(): - if str(file_id) in zip_ref.namelist(): - file_data = zip_ref.read(str(file_id)) - file_path = os.path.join(temp_dir, filename) - - # Save file temporarily - with open(file_path, 'wb') as f: - f.write(file_data) - - # Convert to base64 for supported image types - if any(filename.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif']): - with open(file_path, 'rb') as f: - file_content = f.read() - file_ext = os.path.splitext(filename)[1].lower() - media_type = f"image/{file_ext[1:]}" - if file_ext == '.jpg': - media_type = "image/jpeg" - media_files[ - filename] = f"data:{media_type};base64,{base64.b64encode(file_content).decode('utf-8')}" - - # Clean up temporary file - os.remove(file_path) - - except Exception as e: - print(f"Error processing media: {str(e)}") - return media_files - - -def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], str]: - """Process APKG file and extract cards, media, and deck information.""" - if not file_path: - return None, None, "No file provided" - - temp_dir = tempfile.mkdtemp() - conn = None - - try: - # Extract media files first - media_files = extract_media_from_apkg(file_path.name, temp_dir) - - # Process database - with zipfile.ZipFile(file_path.name, 'r') as zip_ref: - zip_ref.extractall(temp_dir) - - db_path = os.path.join(temp_dir, 'collection.anki2') - - # Use context manager for SQLite connection - with sqlite3.connect(db_path) as conn: - cursor = conn.cursor() - - # Get collection info - cursor.execute("SELECT decks, models FROM col") - decks_json, models_json = cursor.fetchone() - deck_info = { - "decks": json.loads(decks_json), - "models": json.loads(models_json) - } - - # Process cards and notes - cards_data = {"cards": []} - cursor.execute(""" - SELECT - n.id, n.flds, n.tags, c.type, n.mid, - m.name, n.sfld, m.flds, m.tmpls - FROM notes n - JOIN cards c ON c.nid = n.id - JOIN notetypes m ON m.id = n.mid - """) - - rows = cursor.fetchall() # Fetch all rows before closing connection - - for row in rows: - note_id, fields, tags, card_type, model_id, model_name, sort_field, fields_json, templates_json = row - fields_list = fields.split('\x1f') - fields_config = json.loads(fields_json) - templates = json.loads(templates_json) - - # Process fields with media - processed_fields = [] - for field in fields_list: - field_html = field - for filename, base64_data in media_files.items(): - field_html = field_html.replace( - f' 1 else "", - "tags": tags.strip().split(" ") if tags.strip() else ["imported"], - "note": f"Imported from deck: {model_name}", - "has_media": any(' Tuple[str, List[str]]: + """Combined validation and card choice update.""" + validation_message = validate_for_ui(content) + card_choices = update_card_choices(content) + return validation_message, card_choices + def delete_card(card_selection, current_content): """Delete selected card and return updated content.""" if not card_selection or not current_content: @@ -1224,17 +702,6 @@ def process_validation_result(is_valid, message): else: return f"❌ {message}" - # Modified validation button callback - validate_button.click( - fn=lambda content: process_validation_result(*validate_flashcards(content)), - inputs=[flashcard_input], - outputs=[validation_status] - ).then( - fn=generate_card_choices, - inputs=[flashcard_input], - outputs=[card_selector] - ) - # Register event handlers input_type.change( fn=lambda t: ( @@ -1250,28 +717,33 @@ def process_validation_result(is_valid, message): import_json.upload( fn=handle_file_upload, inputs=[import_json, input_type], - outputs=[flashcard_input, deck_info, validation_status] + outputs=[ + flashcard_input, + deck_info, + validation_status, + card_selector + ] ) import_apkg.upload( - fn=handle_file_upload, + fn=enhanced_file_upload, inputs=[import_apkg, input_type], - outputs=[flashcard_input, deck_info, validation_status] - ).then( - fn=generate_card_choices, - inputs=[flashcard_input], - outputs=[card_selector] + outputs=[ + flashcard_input, + deck_info, + validation_status, + card_selector + ] ) # Validation handler validate_button.click( - fn=validate_flashcards, - inputs=[flashcard_input], - outputs=[validation_status] - ).then( - fn=generate_card_choices, - inputs=[flashcard_input], - outputs=[card_selector] + fn=lambda content, input_format: ( + handle_validation(content, input_format), + generate_card_choices(content) if content else [] + ), + inputs=[flashcard_input, input_type], + outputs=[validation_status, card_selector] ) # Card editing handlers @@ -1305,7 +777,7 @@ def process_validation_result(is_valid, message): # Card update handler update_card_button.click( - fn=update_card_content, + fn=update_card_with_validation, inputs=[ flashcard_input, card_selector, @@ -1315,11 +787,11 @@ def process_validation_result(is_valid, message): tags_input, notes_input ], - outputs=[flashcard_input, validation_status] - ).then( - fn=generate_card_choices, - inputs=[flashcard_input], - outputs=[card_selector] + outputs=[ + flashcard_input, + validation_status, + card_selector + ] ) # Delete card handler @@ -1357,4 +829,8 @@ def process_validation_result(is_valid, message): export_file, export_status, deck_info - ) \ No newline at end of file + ) + +# +# End of Anki_Validation_tab.py +############################################################################################################ diff --git a/App_Function_Libraries/Third_Party/Anki.py b/App_Function_Libraries/Third_Party/Anki.py index dd78c7b41..7fa4e948e 100644 --- a/App_Function_Libraries/Third_Party/Anki.py +++ b/App_Function_Libraries/Third_Party/Anki.py @@ -10,7 +10,7 @@ import shutil import base64 from pathlib import Path -from typing import Dict, Tuple, Optional, Any +from typing import Dict, Tuple, Optional, Any, List import re from html.parser import HTMLParser # @@ -136,43 +136,64 @@ def validate_card_content(card: Dict[str, Any], seen_ids: set) -> list: def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], str]: - """Process APKG file and extract cards, media, and deck information.""" + """Process APKG file with robust resource management.""" if not file_path: return None, None, "No file provided" - temp_dir = tempfile.mkdtemp() + temp_dir = None + db_conn = None + cursor = None + cards_data = {"cards": []} + deck_info = None + try: + # Create temporary directory + temp_dir = tempfile.mkdtemp() + # Extract media files first media_files = extract_media_from_apkg(file_path.name, temp_dir) - # Process database + # Extract APKG contents with zipfile.ZipFile(file_path.name, 'r') as zip_ref: zip_ref.extractall(temp_dir) db_path = os.path.join(temp_dir, 'collection.anki2') - conn = sqlite3.connect(db_path) - cursor = conn.cursor() - - # Get collection info - cursor.execute("SELECT decks, models FROM col") - decks_json, models_json = cursor.fetchone() - deck_info = { - "decks": json.loads(decks_json), - "models": json.loads(models_json) - } - - # Process cards and notes - cards_data = {"cards": []} - cursor.execute(""" - SELECT - n.id, n.flds, n.tags, c.type, n.mid, - m.name, n.sfld, m.flds, m.tmpls - FROM notes n - JOIN cards c ON c.nid = n.id - JOIN notetypes m ON m.id = n.mid - """) - - for row in cursor: + + # Process database with explicit connection management + db_conn = sqlite3.connect(db_path) + cursor = db_conn.cursor() + + try: + # Get collection info + cursor.execute("SELECT decks, models FROM col") + decks_json, models_json = cursor.fetchone() + deck_info = { + "decks": json.loads(decks_json), + "models": json.loads(models_json) + } + + # Process cards and notes + cursor.execute(""" + SELECT + n.id, n.flds, n.tags, c.type, n.mid, + m.name, n.sfld, m.flds, m.tmpls + FROM notes n + JOIN cards c ON c.nid = n.id + JOIN notetypes m ON m.id = n.mid + """) + + # Fetch all rows at once + rows = cursor.fetchall() + + finally: + # Close cursor and connection explicitly + if cursor: + cursor.close() + if db_conn: + db_conn.close() + + # Process the fetched data after closing database connection + for row in rows: note_id, fields, tags, card_type, model_id, model_name, sort_field, fields_json, templates_json = row fields_list = fields.split('\x1f') fields_config = json.loads(fields_json) @@ -211,7 +232,6 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s cards_data["cards"].append(card_data) - conn.close() return cards_data, deck_info, "APKG file processed successfully!" except sqlite3.Error as e: @@ -221,7 +241,38 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s except Exception as e: return None, None, f"Error processing APKG file: {str(e)}" finally: - shutil.rmtree(temp_dir) + # Ensure all database resources are closed + if cursor: + try: + cursor.close() + except: + pass + if db_conn: + try: + db_conn.close() + except: + pass + + # Clean up temporary directory + if temp_dir and os.path.exists(temp_dir): + try: + # Add small delay to ensure all file handles are released + import time + time.sleep(0.1) + + # Try to remove read-only attribute if present + for root, dirs, files in os.walk(temp_dir): + for fname in files: + full_path = os.path.join(root, fname) + try: + os.chmod(full_path, 0o777) + except: + pass + + # Remove directory and contents + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception as e: + print(f"Warning: Could not remove temporary directory {temp_dir}: {str(e)}") def validate_flashcards(content: str) -> Tuple[bool, str]: """Validate flashcard content with enhanced image support.""" @@ -248,24 +299,66 @@ def validate_flashcards(content: str) -> Tuple[bool, str]: except Exception as e: return False, f"Validation error: {str(e)}" +def enhanced_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Optional[Dict], str, List[str]]: + """Enhanced file upload handler with better error handling.""" + if not file: + return None, None, "❌ No file uploaded", [] -def handle_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Optional[Dict], str]: - """Handle file upload based on input type.""" + try: + if input_type == "APKG": + cards_data, deck_info, message = process_apkg_file(file) + if cards_data: + content = json.dumps(cards_data, indent=2) + choices = update_card_choices(content) + # Add count of successfully imported cards to message + card_count = len(cards_data.get("cards", [])) + success_message = f"✅ Successfully imported {card_count} cards from APKG file" + return content, deck_info, success_message, choices + return None, None, f"❌ {message}", [] + else: + # Original JSON file handling + content = file.read().decode('utf-8') + json.loads(content) # Validate JSON + return content, None, "✅ JSON file loaded successfully!", update_card_choices(content) + except Exception as e: + return None, None, f"❌ Error processing file: {str(e)}", [] + +def handle_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Optional[Dict], str, List[str]]: + """Handle file upload with proper validation message formatting and card choices update.""" if not file: - return None, None, "No file uploaded" + return None, None, "❌ No file uploaded", [] if input_type == "APKG": cards_data, deck_info, message = process_apkg_file(file) if cards_data: - return json.dumps(cards_data, indent=2), deck_info, message - return None, None, message + content = json.dumps(cards_data, indent=2) + return ( + content, + deck_info, + f"✅ {message}", + update_card_choices(content) + ) + return None, None, f"❌ {message}", [] else: # JSON try: content = file.read().decode('utf-8') json.loads(content) # Validate JSON - return content, None, "JSON file loaded successfully!" + return ( + content, + None, + "✅ JSON file loaded successfully!", + update_card_choices(content) + ) except Exception as e: - return None, None, f"Error loading JSON file: {str(e)}" + return None, None, f"❌ Error loading JSON file: {str(e)}", [] + +def update_card_choices(content: str) -> List[str]: + """Update card choices for the dropdown.""" + try: + data = json.loads(content) + return [f"{card['id']} - {card['front'][:50]}..." for card in data['cards']] + except: + return [] def update_card_content( @@ -277,7 +370,7 @@ def update_card_content( tags: str, notes: str ) -> Tuple[str, str]: - """Update card content and return updated JSON.""" + """Update card content and return updated JSON and status message.""" try: data = json.loads(current_content) @@ -378,7 +471,128 @@ def generate_card_choices(content: str) -> list: except: return [] +def format_validation_result(content: str) -> str: + """Format validation results for display in Markdown component.""" + try: + is_valid, message = validate_flashcards(content) + return f"✅ {message}" if is_valid else f"❌ {message}" + except Exception as e: + return f"❌ Error during validation: {str(e)}" + + +def validate_for_ui(content: str) -> str: + """Validate flashcards and return a formatted string for UI display.""" + if not content or not content.strip(): + return "❌ No content to validate. Please enter some flashcard data." + + try: + # First try to parse the JSON + try: + data = json.loads(content) + except json.JSONDecodeError as je: + # Provide more specific JSON error feedback + line_col = f" (line {je.lineno}, column {je.colno})" if hasattr(je, 'lineno') else "" + return f"❌ Invalid JSON format: {str(je)}{line_col}" + + # Check basic structure + if not isinstance(data, dict): + return "❌ Invalid format: Root element must be a JSON object" + + if "cards" not in data: + return '❌ Invalid format: Missing "cards" array in root object' + if not isinstance(data["cards"], list): + return '❌ Invalid format: "cards" must be an array' + + if not data["cards"]: + return "❌ No cards found in the data" + + # If we get here, perform the full validation + is_valid, message = validate_flashcards(content) + if is_valid: + return f"✅ {message}" + else: + return f"❌ {message}" + + except Exception as e: + return f"❌ Validation error: {str(e)}" + + +def update_card_with_validation( + current_content: str, + card_selection: str, + card_type: str, + front: str, + back: str, + tags: str, + notes: str +) -> Tuple[str, str, List[str]]: + """Update card and return properly formatted validation message and updated choices.""" + try: + # Unpack the tuple returned by update_card_content + updated_content, message = update_card_content( + current_content, + card_selection.split(" - ")[0], + card_type, + front, + back, + tags, + notes + ) + + if "successfully" in message: + return ( + updated_content, + f"✅ {message}", + update_card_choices(updated_content) + ) + else: + return ( + current_content, + f"❌ {message}", + update_card_choices(current_content) + ) + except Exception as e: + return ( + current_content, + f"❌ Error updating card: {str(e)}", + update_card_choices(current_content) + ) + + +def handle_validation(content: str, input_format: str) -> str: + """Handle validation for both JSON and APKG formats.""" + if not content: + return "❌ No content to validate" + + try: + data = json.loads(content) + + if not isinstance(data, dict): + return "❌ Invalid format: Root element must be a JSON object" + + if "cards" not in data: + return '❌ Invalid format: Missing "cards" array in root object' + + if not isinstance(data["cards"], list): + return '❌ Invalid format: "cards" must be an array' + + if not data["cards"]: + return "❌ No cards found in the data" + + # If input is from APKG, we've already validated during import + if input_format == "APKG": + card_count = len(data["cards"]) + return f"✅ Successfully validated {card_count} cards from APKG file" + + # For JSON input, perform full validation + is_valid, message = validate_flashcards(content) + return f"✅ {message}" if is_valid else f"❌ {message}" + + except json.JSONDecodeError as je: + return f"❌ Invalid JSON format: {str(je)}" + except Exception as e: + return f"❌ Validation error: {str(e)}" # # End of Anki.py From 36a52909df466872d983d7cc43eafbf4f359b0d7 Mon Sep 17 00:00:00 2001 From: Robert Date: Wed, 23 Oct 2024 20:49:57 -0700 Subject: [PATCH 15/15] and we have successful parsing of apkg files. Fuck Yes. --- App_Function_Libraries/Third_Party/Anki.py | 153 +++++++++++++-------- 1 file changed, 97 insertions(+), 56 deletions(-) diff --git a/App_Function_Libraries/Third_Party/Anki.py b/App_Function_Libraries/Third_Party/Anki.py index 7fa4e948e..bfe78773e 100644 --- a/App_Function_Libraries/Third_Party/Anki.py +++ b/App_Function_Libraries/Third_Party/Anki.py @@ -9,6 +9,8 @@ import os import shutil import base64 +import time +from datetime import datetime from pathlib import Path from typing import Dict, Tuple, Optional, Any, List import re @@ -55,11 +57,19 @@ def sanitize_html(content: str) -> str: return content -def extract_media_from_apkg(zip_path: str, temp_dir: str) -> Dict[str, str]: +def extract_media_from_apkg(zip_path: Any, temp_dir: str) -> Dict[str, str]: """Extract and process media files from APKG.""" media_files = {} try: - with zipfile.ZipFile(zip_path, 'r') as zip_ref: + # Handle file path whether it's a string or file object + if hasattr(zip_path, 'name'): + # It's a file object from Gradio + file_name = zip_path.name + else: + # It's a string path + file_name = str(zip_path) + + with zipfile.ZipFile(file_name, 'r') as zip_ref: if 'media' in zip_ref.namelist(): media_json = json.loads(zip_ref.read('media').decode('utf-8')) @@ -136,9 +146,16 @@ def validate_card_content(card: Dict[str, Any], seen_ids: set) -> list: def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], str]: - """Process APKG file with robust resource management.""" + """Process APKG file with support for different Anki database versions.""" if not file_path: return None, None, "No file provided" + # Handle file path whether it's a string or file object + if hasattr(file_path, 'name'): + # It's a file object from Gradio + file_name = file_path.name + else: + # It's a string path + file_name = str(file_path) temp_dir = None db_conn = None @@ -151,10 +168,11 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s temp_dir = tempfile.mkdtemp() # Extract media files first - media_files = extract_media_from_apkg(file_path.name, temp_dir) + media_files = extract_media_from_apkg(file_name, temp_dir) # Extract APKG contents - with zipfile.ZipFile(file_path.name, 'r') as zip_ref: + with zipfile.ZipFile(file_name, 'r') as zip_ref: + zip_ref.extractall(temp_dir) zip_ref.extractall(temp_dir) db_path = os.path.join(temp_dir, 'collection.anki2') @@ -172,32 +190,61 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s "models": json.loads(models_json) } - # Process cards and notes - cursor.execute(""" - SELECT - n.id, n.flds, n.tags, c.type, n.mid, - m.name, n.sfld, m.flds, m.tmpls - FROM notes n - JOIN cards c ON c.nid = n.id - JOIN notetypes m ON m.id = n.mid - """) - - # Fetch all rows at once - rows = cursor.fetchall() + # Check if we're dealing with an older or newer Anki version + try: + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='notetypes'") + has_notetypes = cursor.fetchone() is not None + + if has_notetypes: + # New Anki version (2.1.28+) + cursor.execute(""" + SELECT + n.id, n.flds, n.tags, c.type, n.mid, + m.name, n.sfld, m.flds, m.tmpls + FROM notes n + JOIN cards c ON c.nid = n.id + JOIN notetypes m ON m.id = n.mid + """) + else: + # Older Anki version + cursor.execute(""" + SELECT + n.id, n.flds, n.tags, c.type, n.mid, + m.name, n.sfld, m.flds, m.tmpls + FROM notes n + JOIN cards c ON c.nid = n.id + JOIN col AS m ON m.id = 1 AND json_extract(m.models, '$.' || n.mid) IS NOT NULL + """) + + rows = cursor.fetchall() + + except sqlite3.Error as e: + # Fallback query for very old Anki versions + cursor.execute(""" + SELECT + n.id, n.flds, n.tags, c.type, n.mid, + '', n.sfld, '[]', '[]' + FROM notes n + JOIN cards c ON c.nid = n.id + """) + rows = cursor.fetchall() finally: - # Close cursor and connection explicitly - if cursor: - cursor.close() - if db_conn: - db_conn.close() + cursor.close() + db_conn.close() - # Process the fetched data after closing database connection + # Process the fetched data for row in rows: - note_id, fields, tags, card_type, model_id, model_name, sort_field, fields_json, templates_json = row + note_id, fields, tags, card_type, model_id = row[0:5] + model_name = row[5] if row[5] else "Unknown Model" fields_list = fields.split('\x1f') - fields_config = json.loads(fields_json) - templates = json.loads(templates_json) + + try: + fields_config = json.loads(row[7]) if row[7] else [] + templates = json.loads(row[8]) if row[8] else [] + except json.JSONDecodeError: + fields_config = [] + templates = [] # Process fields with media processed_fields = [] @@ -210,28 +257,31 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s ) processed_fields.append(sanitize_html(field_html)) - # Determine card type + # Determine card type (simplified logic) converted_type = 'basic' - if 'cloze' in model_name.lower(): + if any('cloze' in str(t).lower() for t in templates): converted_type = 'cloze' - elif any('{{FrontSide}}' in t.get('afmt', '') for t in templates): + elif any('{{FrontSide}}' in str(t) for t in templates): converted_type = 'reverse' card_data = { "id": f"APKG_{note_id}", "type": converted_type, - "front": processed_fields[0], + "front": processed_fields[0] if processed_fields else "", "back": processed_fields[1] if len(processed_fields) > 1 else "", "tags": tags.strip().split(" ") if tags.strip() else ["imported"], "note": f"Imported from deck: {model_name}", "has_media": any(' Tuple[Optional[Dict], Optional[Dict], s except Exception as e: return None, None, f"Error processing APKG file: {str(e)}" finally: - # Ensure all database resources are closed + # Clean up resources if cursor: try: cursor.close() @@ -252,24 +302,15 @@ def process_apkg_file(file_path: str) -> Tuple[Optional[Dict], Optional[Dict], s db_conn.close() except: pass - - # Clean up temporary directory if temp_dir and os.path.exists(temp_dir): try: - # Add small delay to ensure all file handles are released - import time time.sleep(0.1) - - # Try to remove read-only attribute if present for root, dirs, files in os.walk(temp_dir): for fname in files: - full_path = os.path.join(root, fname) try: - os.chmod(full_path, 0o777) + os.chmod(os.path.join(root, fname), 0o777) except: pass - - # Remove directory and contents shutil.rmtree(temp_dir, ignore_errors=True) except Exception as e: print(f"Warning: Could not remove temporary directory {temp_dir}: {str(e)}") @@ -299,6 +340,7 @@ def validate_flashcards(content: str) -> Tuple[bool, str]: except Exception as e: return False, f"Validation error: {str(e)}" + def enhanced_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Optional[Dict], str, List[str]]: """Enhanced file upload handler with better error handling.""" if not file: @@ -310,10 +352,9 @@ def enhanced_file_upload(file: Any, input_type: str) -> Tuple[Optional[str], Opt if cards_data: content = json.dumps(cards_data, indent=2) choices = update_card_choices(content) - # Add count of successfully imported cards to message - card_count = len(cards_data.get("cards", [])) - success_message = f"✅ Successfully imported {card_count} cards from APKG file" - return content, deck_info, success_message, choices + # Validate the converted content + validation_msg = handle_validation(content, "APKG") + return content, deck_info, validation_msg, choices return None, None, f"❌ {message}", [] else: # Original JSON file handling @@ -562,7 +603,7 @@ def update_card_with_validation( def handle_validation(content: str, input_format: str) -> str: """Handle validation for both JSON and APKG formats.""" - if not content: + if not content or not content.strip(): return "❌ No content to validate" try: @@ -580,17 +621,17 @@ def handle_validation(content: str, input_format: str) -> str: if not data["cards"]: return "❌ No cards found in the data" - # If input is from APKG, we've already validated during import + card_count = len(data["cards"]) if input_format == "APKG": - card_count = len(data["cards"]) - return f"✅ Successfully validated {card_count} cards from APKG file" - - # For JSON input, perform full validation - is_valid, message = validate_flashcards(content) - return f"✅ {message}" if is_valid else f"❌ {message}" + return f"✅ Successfully imported and validated {card_count} cards from APKG file" + else: + # For JSON input, perform additional validation + is_valid, message = validate_flashcards(content) + return f"✅ {message}" if is_valid else f"❌ {message}" except json.JSONDecodeError as je: - return f"❌ Invalid JSON format: {str(je)}" + line_col = f" (line {je.lineno}, column {je.colno})" if hasattr(je, 'lineno') else "" + return f"❌ Invalid JSON format: {str(je)}{line_col}" except Exception as e: return f"❌ Validation error: {str(e)}"