From 4427c134e05451cad60b897dbcfda43280d0a39a Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:45:01 -0700 Subject: [PATCH 01/31] Ignore virtual environment --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 88eb95ec..a20365dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + __pycache__/ *.py[cod] *$py.class @@ -5,3 +6,4 @@ model_files/ medscribe_env/ **/.DS_Store chat_data/ +.env/ From a523f3800b3ecbb6535e014781219eab1b120b84 Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Tue, 21 Jan 2025 10:56:00 -0700 Subject: [PATCH 02/31] added a txt file --- hugging-face-research.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 hugging-face-research.txt diff --git a/hugging-face-research.txt b/hugging-face-research.txt new file mode 100644 index 00000000..7117e619 --- /dev/null +++ b/hugging-face-research.txt @@ -0,0 +1 @@ +researching hugging face's reliability From 90aa292a6ea8048fadb9b2053825b85354997ef6 Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Tue, 21 Jan 2025 11:17:32 -0700 Subject: [PATCH 03/31] added some articles about ai generation --- hugging-face-research.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hugging-face-research.txt b/hugging-face-research.txt index 7117e619..2d9b558c 100644 --- a/hugging-face-research.txt +++ b/hugging-face-research.txt @@ -1 +1,7 @@ researching hugging face's reliability + +AI-driven generation of news summaries: https://hal.science/hal-04437765 + +Navigating dataset documentations in AI with Hugging-Face specificallyL: http://arxiv.org/pdf/2401.13822 + +For autobiographical summarization: https://www.tandfonline.com/doi/full/10.1080/10447318.2023.2286090 From bd3fd37d6a599c523c9e9c2dbb2140ea61ebbbb7 Mon Sep 17 00:00:00 2001 From: littlecelestedemon <45519253+littlecelestedemon@users.noreply.github.com> Date: Wed, 22 Jan 2025 10:44:09 -0700 Subject: [PATCH 04/31] updated hugging-face-research.txt added a medical summarization study with chatgpt --- hugging-face-research.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hugging-face-research.txt b/hugging-face-research.txt index 2d9b558c..da08d40a 100644 --- a/hugging-face-research.txt +++ b/hugging-face-research.txt @@ -5,3 +5,5 @@ AI-driven generation of news summaries: https://hal.science/hal-04437765 Navigating dataset documentations in AI with Hugging-Face specificallyL: http://arxiv.org/pdf/2401.13822 For autobiographical summarization: https://www.tandfonline.com/doi/full/10.1080/10447318.2023.2286090 + +For medical abstract summarization: https://research.ebsco.com/c/o46j2u/viewer/pdf/m5vdfbojwz From c377e6b7f1b45e51eeb612ed4f43fc6e357d7e85 Mon Sep 17 00:00:00 2001 From: littlecelestedemon <45519253+littlecelestedemon@users.noreply.github.com> Date: Wed, 22 Jan 2025 11:11:44 -0700 Subject: [PATCH 05/31] Update hugging-face-research.txt added a link for generating fake medical records --- hugging-face-research.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hugging-face-research.txt b/hugging-face-research.txt index da08d40a..09ce4873 100644 --- a/hugging-face-research.txt +++ b/hugging-face-research.txt @@ -7,3 +7,5 @@ Navigating dataset documentations in AI with Hugging-Face specificallyL: http:// For autobiographical summarization: https://www.tandfonline.com/doi/full/10.1080/10447318.2023.2286090 For medical abstract summarization: https://research.ebsco.com/c/o46j2u/viewer/pdf/m5vdfbojwz + +For generating medical records: https://academic.oup.com/jamia/article/27/1/99/5583723 From 11c89e0f5599cec4baca6b86f8c9ff790ec4f6f6 Mon Sep 17 00:00:00 2001 From: littlecelestedemon <45519253+littlecelestedemon@users.noreply.github.com> Date: Thu, 23 Jan 2025 09:50:06 -0700 Subject: [PATCH 06/31] Update and rename hugging-face-research.txt to sources.txt --- hugging-face-research.txt | 11 ----------- sources.txt | 13 +++++++++++++ 2 files changed, 13 insertions(+), 11 deletions(-) delete mode 100644 hugging-face-research.txt create mode 100644 sources.txt diff --git a/hugging-face-research.txt b/hugging-face-research.txt deleted file mode 100644 index 09ce4873..00000000 --- a/hugging-face-research.txt +++ /dev/null @@ -1,11 +0,0 @@ -researching hugging face's reliability - -AI-driven generation of news summaries: https://hal.science/hal-04437765 - -Navigating dataset documentations in AI with Hugging-Face specificallyL: http://arxiv.org/pdf/2401.13822 - -For autobiographical summarization: https://www.tandfonline.com/doi/full/10.1080/10447318.2023.2286090 - -For medical abstract summarization: https://research.ebsco.com/c/o46j2u/viewer/pdf/m5vdfbojwz - -For generating medical records: https://academic.oup.com/jamia/article/27/1/99/5583723 diff --git a/sources.txt b/sources.txt new file mode 100644 index 00000000..b5057295 --- /dev/null +++ b/sources.txt @@ -0,0 +1,13 @@ +Sources + +AI-driven generation of news summaries: https://hal.science/hal-04437765 +A study using GPT and Pegasus about each AI's accuracy (we can take their method of studying accuracy and apply it to our uses). Both models produced high quality summaries of news articles. + +Navigating dataset documentations in AI with Hugging-Face specifically: http://arxiv.org/pdf/2401.13822 +The Hugging Face community has a ways to go when it comes to documentation, but many of the top 100 dataset cards (86%) had filled out all of the suggested dataset cards section. + +For medical abstract summarization: https://research.ebsco.com/c/o46j2u/viewer/pdf/m5vdfbojwz +ChatGPT's summaries were 70% shorter, rated as 'high quality' (paper explains how they came to this conclusion), and low bias. + +For generating medical records: https://academic.oup.com/jamia/article/27/1/99/5583723 +If we need to, we can generate somewhat accurate medical records with AI with the processes listed in this article. From 0ab6f85ed9f7dc5d8429a843ba72d6d139ecd2de Mon Sep 17 00:00:00 2001 From: littlecelestedemon <45519253+littlecelestedemon@users.noreply.github.com> Date: Fri, 24 Jan 2025 18:00:33 -0700 Subject: [PATCH 07/31] Update sources.txt --- sources.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sources.txt b/sources.txt index b5057295..57902745 100644 --- a/sources.txt +++ b/sources.txt @@ -11,3 +11,13 @@ ChatGPT's summaries were 70% shorter, rated as 'high quality' (paper explains ho For generating medical records: https://academic.oup.com/jamia/article/27/1/99/5583723 If we need to, we can generate somewhat accurate medical records with AI with the processes listed in this article. + +Diagnostic Clinical Descision using AI: https://research.ebsco.com/c/o46j2u/search/details/2twy4tgkzb?q=summary%20evaluation%20ai + +Human centered test and evaluation of military AI: https://research.ebsco.com/c/o46j2u/search/details/ykosntpf7z?q=summary%20evaluation%20ai + +Medical abstract summary with AI: https://research.ebsco.com/c/o46j2u/search/details/d2afd65kjj?q=summary%20evaluation%20ai + +Evaluation of AI tools using the REACT framework: https://research.ebsco.com/c/o46j2u/search/details/j3d2odpqen?q=summary%20evaluation%20ai + +Use of AI in pathology: https://research.ebsco.com/c/o46j2u/search/details/7o7ngbt4vb?q=summary%20evaluation%20ai From 0352e7ee3c1f3614c342981df668aef6de16615b Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Sun, 26 Jan 2025 19:08:14 -0700 Subject: [PATCH 08/31] readded sources --- sources.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources.txt b/sources.txt index 57902745..2c1b264e 100644 --- a/sources.txt +++ b/sources.txt @@ -20,4 +20,4 @@ Medical abstract summary with AI: https://research.ebsco.com/c/o46j2u/search/det Evaluation of AI tools using the REACT framework: https://research.ebsco.com/c/o46j2u/search/details/j3d2odpqen?q=summary%20evaluation%20ai -Use of AI in pathology: https://research.ebsco.com/c/o46j2u/search/details/7o7ngbt4vb?q=summary%20evaluation%20ai +Use of AI in pathology: https://research.ebsco.com/c/o46j2u/search/details/7o7ngbt4vb?q=summary%20evaluation%20ai \ No newline at end of file From 09dd6f20b95c68db81f5a8ff0727c693c6f54cb0 Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Sun, 26 Jan 2025 23:09:07 -0700 Subject: [PATCH 09/31] added main and pegasus --- pegasus.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pegasus.py diff --git a/pegasus.py b/pegasus.py new file mode 100644 index 00000000..c2708a26 --- /dev/null +++ b/pegasus.py @@ -0,0 +1,27 @@ +# import pegasus +from transformers import PegasusForConditionalGeneration, PegasusTokenizer +from GUI import * + +class Pegasus: + def __init__ (self): + # load pegasus for summarization + self.model_name = "google/pegasus-xsum" + self.tokenizer = PegasusTokenizer.from_pretrained(self.model_name) # initialize pegasus' tokenizer + self.model = PegasusForConditionalGeneration.from_pretrained(self.model_name) # initialize pegasus model + + + def tokenize(self, input_text): + # tokenize text + tokenized = self.tokenizer(input_text, truncation = True, padding = "longest", return_tensors = "pt") + return tokenized + + def summarizer(self, tokenized_text): + # summarize + summarized = self.model.generate(**tokenized_text) + return summarized + + def detokenize(self, summarized_text): + # detokenize + summary = self.tokenizer.batch_decode (summarized_text) + return summary + From d8f3109fb1e8593dbd2c32aa2d47d8fd63214b5e Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:14:25 -0700 Subject: [PATCH 10/31] LLM page --- src/components/__init__.py | 3 +- src/components/llm_input.py | 118 ++++++++++++++++++++++++++++++++++++ src/gui.py | 2 +- src/pages/llms_page.py | 98 +++++++++++++++++++++++++++--- src/utils/chat.py | 5 +- src/utils/llm.py | 26 ++++++++ src/utils/llm_history.py | 0 7 files changed, 241 insertions(+), 11 deletions(-) create mode 100644 src/components/llm_input.py create mode 100644 src/utils/llm.py create mode 100644 src/utils/llm_history.py diff --git a/src/components/__init__.py b/src/components/__init__.py index ebeb5be1..3b9f08aa 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -3,4 +3,5 @@ from components.title_frame import TitleFrame from components.input_frame import InputFrame from components.chat_area import ChatArea -from components.loading_indicator import LoadingIndicator \ No newline at end of file +from components.loading_indicator import LoadingIndicator +from components.llm_input import LLMInput \ No newline at end of file diff --git a/src/components/llm_input.py b/src/components/llm_input.py new file mode 100644 index 00000000..84f8e510 --- /dev/null +++ b/src/components/llm_input.py @@ -0,0 +1,118 @@ +import tkinter as tk +from components.rounded_frame import RoundedFrame + +class LLMInput(tk.Frame): + def __init__(self, parent, send_callback, **kwargs): + super().__init__(parent, bg="#D2E9FC", **kwargs) + self.send_callback = send_callback + self.setup_input_area() + + def setup_input_area(self): + self.grid_columnconfigure(0, weight=1) + + # Container frame for input, button, and output + self.container = tk.Frame(self, bg="#D2E9FC") + self.container.grid(row=0, column=0, sticky="ew", padx=20, pady=15) + self.container.grid_columnconfigure(0, weight=1) + + # Input container with rounded corners + self.input_container = RoundedFrame(self.container, "#FFFFFF", radius=50) + self.input_container.grid(row=0, column=0, sticky="ew", padx=(0, 10)) + self.input_container.grid_columnconfigure(0, weight=1) + + # Button container frame for button with rounded corners + #self.button_container = RoundedFrame(self.container, "#FFFFFF", radius=50) + #self.button_container.grid(row=1, column=0, sticky="ew", padx=20, pady=(0,2)) + + # Output container frame with rounded corners + self.output_container = RoundedFrame(self.container, "#FFFFFF", radius=50) + self.output_container.grid(row=2, column=0, sticky="ew", padx=20, pady=(0,2)) + self.input_container.grid_columnconfigure(0, weight=1) + # Text input area (to input LLM path) + self.input_text = tk.Text( + self.input_container, + height=3, + font=("SF Pro Text", 14), + wrap=tk.WORD, + bd=0, + bg="white", + highlightthickness=0, + relief="flat" + ) + self.input_text.insert("1.0", "Type in model path here")# default text + #self.input_text.pack(fill="both", expand=True, padx=20, pady=2) + self.input_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + self.input_text.bind("", self.default) + self.input_text.bind("", self.default) + + # "Upload" button canvas + self.upload_button = tk.Canvas( + self.container, + width=100, + height=60, + bg="#D2E9FC", + highlightthickness=0, + cursor="hand2" + ) + self.upload_button.grid(row=1, column=0) + + # Draw upload button + padding = 10 + self.rectangle = self.upload_button.create_rectangle( + padding, padding, + 100, 50, + fill="#0A84FF", + outline="" + ) + + # Draw "Upload LLM" on upload button + self.upload_llm = self.upload_button.create_text( + 55, 30, + text="Upload LLM", + fill="white", + font=("SF Pro Text", 15), + anchor="center" + ) + + # Draw LLM status area + self.output_text = tk.Text( + self.output_container, + height=5, + font=("SF Pro Text", 14), + wrap=tk.WORD, + bd=0, + bg="white", + highlightthickness=0, + relief="flat" + ) + self.output_text.configure(state="disable") + #self.output_text.pack(fill="both", expand=True, padx=20, pady=2) + self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + + self.upload_button.bind("", self.handle_upload) + + def default(self, event): + current = self.input_text.get("1.0", tk.END) + if current == "Type in model path here\n": + self.input_text.delete("1.0", tk.END) + elif current == "\n": + self.input_text.insert("1.0", "Type in model path here") + + def handle_upload(self, event = None): + model_path = self.get_input() + print ("Clicked!") + if model_path: + self.clear_input() + self.send_callback(model_path) + + def get_input(self): + return self.input_text.get("1.0", "end-1c").strip() + + def clear_input(self): + self.input_text.delete("1.0", "end") + self.input_text.focus() + + + + + diff --git a/src/gui.py b/src/gui.py index cbc2a513..a4d7dbc7 100644 --- a/src/gui.py +++ b/src/gui.py @@ -19,7 +19,7 @@ def setup_gui(self): self._configure_root() self._configure_grid() self._setup_navbar() - self.show_page("chat") # Start with chat page + self.show_page("chat") # Start with chat page # change to starting with homepage def _configure_root(self): self.root.title("Assess.ai") diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 536f7ff8..2ac968a6 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -1,18 +1,102 @@ import tkinter as tk +from tkinter import ttk +from utils.llm import LLM +from components import ( + LLMInput, + TitleFrame, +) + class LLMsPage: def __init__(self, root): self.root = root + self.container = tk.Frame(self.root, bg="#D2E9FC") + self.container.grid(row=1, column=1, sticky="nsew") + self.setup_page() - + def setup_page(self): - container = tk.Frame(self.root, bg="#D2E9FC") - container.grid(row=1, column=1, sticky="nsew") - - # Placeholder content + """Initialize and set up the LLMs page """ + + self._configure_root() + self._configure_grid() + self._setup_styles() + self._initialize_components() + self._setup_bindings() + + """ Placeholder content tk.Label( - container, + self.container, text="LLMs", font=("SF Pro Display", 24, "bold"), bg="#D2E9FC" - ).pack(pady=20) \ No newline at end of file + ).pack(pady=20) + """ + def _configure_root(self): + """Configure root window settings""" + self.root.title("Assess.ai") + self.root.configure(bg="#D2E9FC") + + def _configure_grid(self): + """Configure grid layout""" + self.container.grid_rowconfigure(0, weight=0) # Title + self.container.grid_rowconfigure(1, weight=0) # LLM Input + self.container.grid_columnconfigure(0, weight=1) + + def _setup_styles(self): + """Setup ttk styles""" + style = ttk.Style() + + def _initialize_components(self): + # Title + self.title_frame = TitleFrame(self.container) + self.title_frame.grid(row=0, column=0, sticky="ew", pady=(0,50)) + + # LLM input area + self.LLMInput = LLMInput(self.container, self.send_path) + self.LLMInput.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 5)) + + + def _setup_bindings(self): + self.root.bind('', lambda e: self.root.destroy()) + + def send_path(self, model_path): + # if message is empty + if not model_path.strip(): + return + + # if message not empty + self.disable_input(True) # disable text input + self.LLMInput.output_text.configure(state="normal") + self.LLMInput.output_text.delete("1.0", tk.END) + #self.LLMInput.output_text.configure(state="disable") + + # connect to indicated LLM in Hugging Face + try: + self.LLM = LLM(model_path) + except OSError as e: + #self.LLMInput.output_text.configure(state="normal") + self.LLMInput.output_text.insert("1.0", "Unsuccessful. Try again.") + self.LLMInput.output_text.configure(state="disable") + self.disable_input(False) + print(f"Error handling LLM: {str(e)}") + + else: + #self.LLMInput.output_text.configure(state="normal") + self.LLMInput.output_text.insert("1.0", "Successful!") + self.LLMInput.output_text.configure(state="disable") + self.disable_input(False) + # if successfully connected, save in LLM.txt + + def disable_input(self, disable): + self.disable = disable + if disable == True: + self.LLMInput.input_text.configure(state="disable") + self.LLMInput.upload_button.configure(state="disable") + else: + self.LLMInput.input_text.configure(state="normal") + self.LLMInput.upload_button.configure(state="normal") + + + + diff --git a/src/utils/chat.py b/src/utils/chat.py index eac1600c..12a47706 100644 --- a/src/utils/chat.py +++ b/src/utils/chat.py @@ -8,14 +8,15 @@ def __init__(self): self.tokenizer = None self.model = None self.device = None - self.model_path = Path(__file__).parent / "../model_files" - + #self.model_path = Path(__file__).parent / "../model_files" + self.model_path = "google/pegasus-xsum" def _load_model(self): if self.model is None: self.tokenizer = PegasusTokenizer.from_pretrained(self.model_path) self.model = PegasusForConditionalGeneration.from_pretrained(self.model_path) self.device = torch.device('cpu') self.model = self.model.to(self.device) + # else load selected model def get_response(self, user_input): if not user_input.strip(): diff --git a/src/utils/llm.py b/src/utils/llm.py new file mode 100644 index 00000000..be790cbf --- /dev/null +++ b/src/utils/llm.py @@ -0,0 +1,26 @@ +from transformers import AutoTokenizer, AutoModelForSequenceClassification + +class LLM: + def __init__(self, pathname): + # load LLM from Hugging Face + self.model_name = pathname + + try: + self.tokenizer = AutoTokenizer.from_pretrained(pathname) + self.model = AutoModelForSequenceClassification.from_pretrained(pathname) + + except Exception as e: + print (f"Error handling LLM: {str(e)}") + + def tokenize (self, input_text): + # tokenize text + return self.tokenizer(input_text, padding=True, truncation=True, return_tensors="pt") + + def summarizer(self, tokenized_text): + # summarize text + return self.model.generate(**tokenized_text) + + def detokenize(self, summarized_text): + # detokenize text + return self.tokenizer.batch_decode(summarized_text) + diff --git a/src/utils/llm_history.py b/src/utils/llm_history.py new file mode 100644 index 00000000..e69de29b From 015f0cf3076a43cfc4bda52400d0bae8c91d4791 Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Tue, 28 Jan 2025 16:11:40 -0700 Subject: [PATCH 11/31] accidently modified this file, should not be diff --- src/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui.py b/src/gui.py index a4d7dbc7..a8b9c5e1 100644 --- a/src/gui.py +++ b/src/gui.py @@ -57,4 +57,4 @@ def show_page(self, page_name): elif page_name == "projects": self.current_page = ProjectsPage(self.root) elif page_name == "home": - self.current_page = HomePage(self.root) \ No newline at end of file + self.current_page = HomePage(self.root) From d3ac81686cbfdc2608b16dcf4832a5d4636df70b Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Tue, 28 Jan 2025 16:36:56 -0700 Subject: [PATCH 12/31] put in a way to find logo.png --- src/gui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/gui.py b/src/gui.py index a8b9c5e1..1aa0b062 100644 --- a/src/gui.py +++ b/src/gui.py @@ -1,4 +1,5 @@ import tkinter as tk +import os from components.navbar import Navbar from pages.chat_page import ChatPage from pages.evaluations_page import EvaluationsPage @@ -24,6 +25,11 @@ def setup_gui(self): def _configure_root(self): self.root.title("Assess.ai") self.root.configure(bg="#D2E9FC") + + # gets exact location of logo.png + __location__ = os.path.realpath(os.path.join(os.getcwd(),os.path.dirname(__file__))) + path = os.path.abspath(__location__) + path = path + "/assets/logo.png" def _configure_grid(self): # Configure grid weights From 969adeb2ff3e15ab12e5b7f960582331947c5fa2 Mon Sep 17 00:00:00 2001 From: littlecelestedemon Date: Tue, 28 Jan 2025 18:00:52 -0700 Subject: [PATCH 13/31] trying to get the logo to pop up --- src/pages/home_page.py | 10 +++++++++- src/pages/logo.png | Bin 0 -> 106067 bytes 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 src/pages/logo.png diff --git a/src/pages/home_page.py b/src/pages/home_page.py index b6e6ff00..e2ced04e 100644 --- a/src/pages/home_page.py +++ b/src/pages/home_page.py @@ -1,4 +1,6 @@ import tkinter as tk +import os +from tkinter import * class HomePage: def __init__(self, root): @@ -9,10 +11,16 @@ def setup_page(self): container = tk.Frame(self.root, bg="#D2E9FC") container.grid(row=1, column=1, sticky="nsew") + __location__ = os.path.realpath(os.path.join(os.getcwd(),os.path.dirname(__file__))) + path = os.path.abspath(__location__) + path = path + "/logo.png" + + print(path) + # Placeholder content tk.Label( container, text="Home", font=("SF Pro Display", 24, "bold"), bg="#D2E9FC" - ).pack(pady=20) \ No newline at end of file + ).pack(pady=20) diff --git a/src/pages/logo.png b/src/pages/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..89ec46bd621bee95d30b0bf8ecb151dfb48c7119 GIT binary patch literal 106067 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 tLz=n#v6Qo(K&rJzb9;PzjIDe;9 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 Date: Tue, 28 Jan 2025 19:49:49 -0700 Subject: [PATCH 14/31] tinkered with adding a photo to the homepage --- src/gui.py | 6 ------ src/main.py | 2 +- src/pages/home_page.py | 6 +++++- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/gui.py b/src/gui.py index 1aa0b062..a8b9c5e1 100644 --- a/src/gui.py +++ b/src/gui.py @@ -1,5 +1,4 @@ import tkinter as tk -import os from components.navbar import Navbar from pages.chat_page import ChatPage from pages.evaluations_page import EvaluationsPage @@ -25,11 +24,6 @@ def setup_gui(self): def _configure_root(self): self.root.title("Assess.ai") self.root.configure(bg="#D2E9FC") - - # gets exact location of logo.png - __location__ = os.path.realpath(os.path.join(os.getcwd(),os.path.dirname(__file__))) - path = os.path.abspath(__location__) - path = path + "/assets/logo.png" def _configure_grid(self): # Configure grid weights diff --git a/src/main.py b/src/main.py index 1d1fda83..993003b5 100644 --- a/src/main.py +++ b/src/main.py @@ -83,4 +83,4 @@ def load_model_background(): # 3. root.after() safely schedules UI updates from other threads # 4. Due to GIL, only one Python thread runs at a time # (but switches fast enough to appear concurrent) -""" \ No newline at end of file +""" diff --git a/src/pages/home_page.py b/src/pages/home_page.py index e2ced04e..1735780f 100644 --- a/src/pages/home_page.py +++ b/src/pages/home_page.py @@ -1,6 +1,6 @@ import tkinter as tk import os -from tkinter import * +from tkinter import PhotoImage class HomePage: def __init__(self, root): @@ -10,11 +10,15 @@ def __init__(self, root): def setup_page(self): container = tk.Frame(self.root, bg="#D2E9FC") container.grid(row=1, column=1, sticky="nsew") + + __location__ = os.path.realpath(os.path.join(os.getcwd(),os.path.dirname(__file__))) path = os.path.abspath(__location__) path = path + "/logo.png" + logo = PhotoImage(file=path) + print(path) # Placeholder content From ce8c302511ca9bab61534ff270e4c78e89513d43 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Sun, 2 Feb 2025 00:26:10 -0700 Subject: [PATCH 15/31] feat: add finetuning gui --- .gitignore | 1 + src/components/finetune_form.py | 231 ++++++++++++++++++++++++++++++++ src/components/navbar.py | 12 +- src/download_model.py | 20 ++- src/pages/finetune_page.py | 87 ++++++++++-- src/utils/chat.py | 2 + src/utils/chat_history.py | 2 +- src/utils/data_loader.py | 120 +++++++++++++++++ src/utils/fine_tuning.py | 150 +++++++++++++++++++++ 9 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 src/components/finetune_form.py create mode 100644 src/utils/data_loader.py create mode 100644 src/utils/fine_tuning.py diff --git a/.gitignore b/.gitignore index a20365dd..e3696912 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ medscribe_env/ **/.DS_Store chat_data/ .env/ +finetunedmodels/ diff --git a/src/components/finetune_form.py b/src/components/finetune_form.py new file mode 100644 index 00000000..7229d055 --- /dev/null +++ b/src/components/finetune_form.py @@ -0,0 +1,231 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from pathlib import Path +import json + +""" +Call backs: +User clicks "Start" → Form validates & calls start_callback +start_callback launches training thread +Training process calls progress_callback +progress_callback updates UI through update_status + +""" + +class FinetuneForm: + def __init__(self, parent, start_callback): + self.parent = parent + self.start_callback = start_callback + + # Define parameter limits for model fine-tuning + self.param_limits = { + "num_epochs": {"min": 1, "max": 20}, # Number of training epochs + "batch_size": {"min": 1, "max": 32}, # Batch size for training + "learning_rate": {"min": 1e-6, "max": 1e-3}, # Learning rate range + "max_samples": {"min": 10, "max": 10000}, # Maximum number of training samples + "max_length": {"min": 64, "max": 1024}, # Maximum sequence length + "num_beams": {"min": 1, "max": 8} # Number of beams for beam search + } + + self.default_config = { + "training": { + "num_epochs": 3, + "batch_size": 2, + "learning_rate": 2e-5, + "max_samples": 100, + "max_length": 256, + "num_beams": 4 + } + } + + self.setup_form() + + def setup_form(self): + form = tk.Frame(self.parent, bg="white") + form.grid(row=2, column=0, sticky="nsew", padx=30, pady=25) + form.grid_columnconfigure(1, weight=1) + + # Dataset path selection UI + tk.Label(form, text="Dataset Path", bg="white").grid(row=0, column=0, sticky="w", pady=5) + + self.path_var = tk.StringVar() + tk.Entry(form, textvariable=self.path_var).grid(row=0, column=1, sticky="ew", padx=(10,10), pady=5) + + tk.Button( + form, + text="Browse", + command=self.browse_dataset, + relief="solid", + bd=1 + ).grid(row=0, column=2, pady=5) + + tk.Label( + form, + text="Training Configuration", + font=("SF Pro Display", 14, "bold"), + bg="white" + ).grid(row=2, column=0, columnspan=3, sticky="w", pady=(20,10)) + + # Create configuration grid + config_frame = tk.Frame(form, bg="white") + config_frame.grid(row=3, column=0, columnspan=3, sticky="ew") + config_frame.grid_columnconfigure(1, weight=1) + config_frame.grid_columnconfigure(3, weight=1) + + # Create configuration input fields + self.config_vars = {} + row = 0 + col = 0 + for param, value in self.default_config["training"].items(): + limits = self.param_limits[param] + label_text = param.replace("_", " ").title() + + # Add label with parameter limits + tk.Label( + config_frame, + text=f"{label_text} ({limits['min']} - {limits['max']})", + bg="white" + ).grid(row=row, column=col*2, sticky="w", pady=5) + + # Create variable to store parameter value + var = tk.StringVar(value=str(value)) + self.config_vars[param] = var + + # Create entry field + entry = tk.Entry( + config_frame, + textvariable=var, + width=20 + ) + entry.grid(row=row, column=col*2+1, sticky="w", padx=(10,20), pady=8) + + # Checks if config for training is valid and not beyond limits + def validate_input(action, value, param=param): + if action == '1': # Insert action + try: + # Validate integer fields + if param in ["num_epochs", "batch_size", "max_samples", "max_length", + "num_beams"]: + if not value == "" and not value.isdigit(): #Check if value is a whole number and not a digit + return False + if value and int(value) > self.param_limits[param]["max"]: #Check if its within the limit + return False + else: + # Validate float fields + if value == "" or value == "-": + return True + try: + float_val = float(value) + if float_val > self.param_limits[param]["max"]: + return False + if float_val < self.param_limits[param]["min"]: + return False + except ValueError: + return False + except ValueError: + return False + return True + + # Register validation command + vcmd = (self.parent.register(validate_input), '%d', '%P') + entry.configure(validate='key', validatecommand=vcmd) + + # Layout management for grid + col = (col + 1) % 2 + if col == 0: + row += 1 + + # Create control button section + button_frame = tk.Frame(form, bg="white") + button_frame.grid(row=4, column=0, columnspan=3, sticky="ew", pady=20) + + self.start_btn = tk.Button( + button_frame, + text="Start Fine-tuning", + command=self.start_finetuning, + relief="solid", + bg="white", + bd=1 + ) + self.start_btn.pack(side=tk.LEFT, padx=5) + + # Training log section + tk.Label( + form, + text="Training Log", + font=("SF Pro Display", 14, "bold"), + bg="white" + ).grid(row=5, column=0, columnspan=3, sticky="w", pady=(20,10)) + + self.log_text = tk.Text( + form, + height=10, + width=50, + relief="solid", + bd=1, + font=("Courier", 10), + bg="#FAFAFA" + ) + self.log_text.grid(row=6, column=0, columnspan=3, sticky="ew") + + def validate_param(self, param, value): + # Check parameters + try: + limits = self.param_limits[param] + if param in ["num_epochs", "batch_size", "max_samples", "max_length", + "num_beams"]: + value = int(value) + else: + value = float(value) + + if value < limits["min"] or value > limits["max"]: + return False, f"{param} must be between {limits['min']} and {limits['max']}" + return True, value + except ValueError: + return False, f"Invalid value for {param}" + + def browse_dataset(self): + # Open file dialog for dataset selection + filetypes = ( + ('JSON files', '.json'), + ('JSONL files', '.jsonl'), + ('CSV files', '.csv'), + ('Text files', '.txt') + ) + filename = filedialog.askopenfilename( + title='Select Dataset File', + filetypes=filetypes + ) + if filename: + self.path_var.set(filename) + + def update_status(self, message, is_error=False): + # adds new messages to the log window that shows training progress + self.log_text.insert(tk.END, f"{message}\n") + self.log_text.see(tk.END) + + def get_current_config(self): + # Get and validate current configuration values + config = {"training": {}} + for param, var in self.config_vars.items(): + valid, result = self.validate_param(param, var.get()) + if not valid: + messagebox.showerror("Error", result) + return None + config["training"][param] = result + return json.dumps(config) + + def start_finetuning(self): + if not self.path_var.get(): + messagebox.showerror("Error", "Please select a dataset file") + return + + config = self.get_current_config() + if config is None: + return + + # Disable start button + self.start_btn.config(state="disabled") + + # Call the callback function directly + self.start_callback(config, self.path_var.get()) \ No newline at end of file diff --git a/src/components/navbar.py b/src/components/navbar.py index 6297a9ee..8f02f635 100644 --- a/src/components/navbar.py +++ b/src/components/navbar.py @@ -1,6 +1,6 @@ -import tkinter as tk from PIL import Image, ImageTk from pathlib import Path +import tkinter as tk # Navigation items nav_items = [ @@ -18,6 +18,7 @@ def __init__(self, parent, show_page_callback, **kwargs): self.show_page_callback = show_page_callback self.current_page = "chat" # Default page self.buttons = {} # Store button references + self.logo_photo = None # Keep reference to prevent garbage collection self._setup_navbar() def _setup_navbar(self): @@ -37,16 +38,18 @@ def _setup_navbar(self): logo_container.pack(fill="x", pady=(20, 30)) try: - logo_img = Image.open(Path("assets/logo.jpeg")) + logo_path = Path(__file__).parent.parent / "assets" / "logo.jpeg" + + logo_img = Image.open(logo_path) logo_width = 180 aspect_ratio = logo_img.height / logo_img.width logo_height = int(logo_width * aspect_ratio) logo_img = logo_img.resize((logo_width, logo_height), Image.Resampling.LANCZOS) - logo_photo = ImageTk.PhotoImage(logo_img) + self.logo_photo = ImageTk.PhotoImage(logo_img) logo_label = tk.Label( logo_container, - image=logo_photo, + image=self.logo_photo, bg="#FFFFFF" ) logo_label.pack(pady=(0, 20)) @@ -119,6 +122,7 @@ def set_active_page(self, page): fg="#1a1a1a", cursor="hand2" ) + # Hover effect def _on_hover(self, container, page): if page != self.current_page: diff --git a/src/download_model.py b/src/download_model.py index a2554553..c3fd77e2 100644 --- a/src/download_model.py +++ b/src/download_model.py @@ -8,9 +8,23 @@ def download_model(): # Save model and tokenizer to local directory print("Saving model locally...") - tokenizer.save_pretrained("model_files") - model.save_pretrained("model_files") + tokenizer.save_pretrained("../model_files/pegasus") + model.save_pretrained("../model_files/pegasus") print("Model downloaded and saved successfully!") if __name__ == "__main__": - download_model() \ No newline at end of file + download_model() + +""" +Model Details: +├── config.json (Architecture blueprint) +├── generation_config.json (Controls how the model generates text: beam search) +├── model.safetensors (Weights that change when doing finetuning) +├── special_tokens_map.json (Maps special tokens like [PAD], [EOS], [UNK] to their IDs, Used for handling start/end of text, padding, unknown words) +├── spiece.model (The tokenizer's vocabulary and rules, Defines how to split text into tokens) +└── tokenizer_config.json (Configuration for the tokenizer's behavior) + + + + +""" \ No newline at end of file diff --git a/src/pages/finetune_page.py b/src/pages/finetune_page.py index a2a0f729..691354f4 100644 --- a/src/pages/finetune_page.py +++ b/src/pages/finetune_page.py @@ -1,18 +1,89 @@ import tkinter as tk +from tkinter import ttk +from pathlib import Path +import threading +from utils.fine_tuning import FineTuner +from components.finetune_form import FinetuneForm class FinetunePage: def __init__(self, root): + # Initialize the main window self.root = root self.setup_page() def setup_page(self): - container = tk.Frame(self.root, bg="#D2E9FC") - container.grid(row=1, column=1, sticky="nsew") + # Create main container with light blue background + self.container = tk.Frame(self.root, bg="#EBF3FF") + self.container.grid(row=1, column=1, sticky="nsew") + self.container.grid_columnconfigure(0, weight=1) + + # Create white content frame + content = tk.Frame(self.container, bg="white") + content.grid(row=0, column=0, sticky="nsew", padx=30, pady=30) + content.grid_columnconfigure(0, weight=1) - # Placeholder content tk.Label( - container, - text="Finetune", - font=("SF Pro Display", 24, "bold"), - bg="#D2E9FC" - ).pack(pady=20) + content, + text="Fine-tune Model", + font=("SF Pro Display", 24), + bg="white" + ).grid(row=0, column=0, sticky="w", padx=20, pady=(20,10)) + + ttk.Separator(content, orient="horizontal").grid(row=1, column=0, sticky="ew", padx=20) + + # Initialize the form component + self.form = FinetuneForm(content, self.handle_start_finetuning) + + def run_finetuning(self, config, dataset_path): + """Execute fine-tuning process""" + try: + # Check for model directory + model_path = Path("../model_files/pegasus") + if not model_path.exists(): + raise FileNotFoundError(f"Model directory not found at {model_path}") + + # Initialize fine-tuning process + self.form.update_status("Loading model...") + fine_tuner = FineTuner(model_path, config) + + # Load dataset + self.form.update_status("Loading dataset...") + train_data = fine_tuner.load_dataset(dataset_path) + + self.form.update_status("Starting fine-tuning...") + + # Define progress callback function for actual training progress + def progress_callback(progress_info): + epoch = progress_info['epoch'] + total_epochs = progress_info['total_epochs'] + batch = progress_info['batch'] + total_batches = progress_info['total_batches'] + loss = progress_info['loss'] # This is now the actual training loss + + # Update status with real training progress + status_text = f"Epoch {epoch}/{total_epochs} - Batch {batch}/{total_batches} - Loss: {loss:.4f}" + self.root.after(0, lambda: self.form.update_status(status_text)) + + output_dir = fine_tuner.fine_tune( + train_data, + progress_callback=progress_callback + ) + + # Update status on completion + self.root.after(0, lambda: self.form.update_status(f"Fine-tuning complete! Model saved to: {output_dir}")) + self.root.after(0, lambda: self.form.start_btn.config(state="normal")) + + except Exception as e: + # Handle any errors during fine-tuning + self.root.after(0, lambda: self.form.update_status(f"Error: {str(e)}", is_error=True)) + self.root.after(0, lambda: self.form.start_btn.config(state="normal")) + + def handle_start_finetuning(self, config, dataset_path): + """Handle the start of fine-tuning process""" + # Start fine-tuning in a separate thread + thread = threading.Thread( + target=self.run_finetuning, + args=(config, dataset_path), + daemon=True + ) + thread.start() \ No newline at end of file diff --git a/src/utils/chat.py b/src/utils/chat.py index 12a47706..974624ec 100644 --- a/src/utils/chat.py +++ b/src/utils/chat.py @@ -10,6 +10,8 @@ def __init__(self): self.device = None #self.model_path = Path(__file__).parent / "../model_files" self.model_path = "google/pegasus-xsum" + + def _load_model(self): if self.model is None: self.tokenizer = PegasusTokenizer.from_pretrained(self.model_path) diff --git a/src/utils/chat_history.py b/src/utils/chat_history.py index 1f4eaca2..95e79f88 100644 --- a/src/utils/chat_history.py +++ b/src/utils/chat_history.py @@ -5,7 +5,7 @@ class SecureChatHistory: def __init__(self): - self.chat_history_path = Path("chat_data/chat_history.txt") + self.chat_history_path = Path("../chat_data/chat_history.txt") self._ensure_directories() def _ensure_directories(self): diff --git a/src/utils/data_loader.py b/src/utils/data_loader.py new file mode 100644 index 00000000..e40b3548 --- /dev/null +++ b/src/utils/data_loader.py @@ -0,0 +1,120 @@ +# Import required libraries +from torch.utils.data import Dataset +import json +from pathlib import Path +import csv + +class TextDataset(Dataset): + def __init__(self, data, tokenizer, max_length=256): + self.data = data + self.tokenizer = tokenizer + self.max_length = max_length + + def __len__(self): + # Return the total number of samples + return len(self.data) + + def __getitem__(self, idx): + # Get a single data item (input ID, attention mask, label, input text, target text) + item = self.data[idx] + # Tokenize input text with padding and truncation + inputs = self.tokenizer( + item['input_text'], + max_length=self.max_length, + truncation=True, + padding='max_length', + return_tensors='pt' + ) + + # Tokenize target text similarly + targets = self.tokenizer( + item['target_text'], + max_length=self.max_length, + truncation=True, + padding='max_length', + return_tensors='pt' + ) + + # Attention Mask: A binary mask (1s and 0s) that tells the model which tokens are real text and which are padding 1 means "pay attention to this token. 0 means "ignore this token (it's just padding)" + # Return processed tensors and original text + return { + 'input_ids': inputs['input_ids'].squeeze(), + 'attention_mask': inputs['attention_mask'].squeeze(), + 'labels': targets['input_ids'].squeeze(), + 'input_text': item['input_text'], + 'target_text': item['target_text'] + } + +def load_dataset(data_path, max_samples=None): + data_path = Path(data_path) + if not data_path.exists(): + raise FileNotFoundError(f"Data file not found at {data_path}") + + file_extension = data_path.suffix.lower() # Get file extension (.json, .csv...) + + try: + # Handle different file formats + if file_extension == '.json': + # Load standard JSON file + with open(data_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + elif file_extension == '.jsonl': + # Load JSON Lines file (one JSON object per line) + data = [] + with open(data_path, 'r', encoding='utf-8') as f: + for line in f: + if line.strip(): + data.append(json.loads(line)) + + elif file_extension == '.csv': + # Load CSV file with specific columns + data = [] + with open(data_path, 'r', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + if 'input_text' not in row or 'target_text' not in row: + raise ValueError("CSV must contain 'input_text' and 'target_text' columns") + data.append({ + 'input_text': row['input_text'], + 'target_text': row['target_text'] + }) + + elif file_extension == '.txt': + # Load custom format text file with INPUT: and TARGET: prefixes + data = [] + current_item = {} + with open(data_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + for line in lines: + line = line.strip() + if line.startswith('INPUT:'): + if current_item: + data.append(current_item) + current_item = {'input_text': line[6:].strip()} + elif line.startswith('TARGET:'): + if 'input_text' in current_item: + current_item['target_text'] = line[7:].strip() + data.append(current_item) + current_item = {} + + # Add last item if complete + if current_item and 'input_text' in current_item and 'target_text' in current_item: + data.append(current_item) + + else: + raise ValueError(f"Unsupported file format: {file_extension}") + + for item in data: + if not isinstance(item, dict) or 'input_text' not in item or 'target_text' not in item: + raise ValueError("Data must contain 'input_text' and 'target_text' fields") + + if max_samples is not None: + data = data[:max_samples] + + return data + + except Exception as e: + print(f"Error loading dataset: {str(e)}") + raise \ No newline at end of file diff --git a/src/utils/fine_tuning.py b/src/utils/fine_tuning.py new file mode 100644 index 00000000..50caf9d5 --- /dev/null +++ b/src/utils/fine_tuning.py @@ -0,0 +1,150 @@ +import torch +from torch.utils.data import DataLoader +from transformers import PegasusForConditionalGeneration, PegasusTokenizer +import json +from pathlib import Path +import time +from datetime import datetime +from .data_loader import TextDataset, load_dataset +import random + +# Set single thread for torch operations +torch.set_num_threads(1) +# Set random seeds for reproducibility +random.seed(42) +torch.manual_seed(42) + +""" +For fine-tuning, we use the pre-trained Pegasus model's architecture and train it further by having it try to generate target_text from input_text, +calculating the difference between what it generated and what the target_text actually should be (this difference is the "loss"), +and adjusting its weights to minimize this difference. Loss is calculated using cross entropy loss + +Data is organized into batches using DataLoader. +During each epoch (a complete pass through the dataset), the model processes these batches one at a time. +For each batch, it first makes predictions (forward pass) by trying to generate output from the input text, then calculates how wrong it was (loss). +Using this loss, it figures out how to adjust its weights to do better (backward pass through loss.backward()) and updates these weights using the Adam optimizer (optimizer.step()). +The loss value reported during training tells you how well the model is learning - a decreasing loss generally means the model is improving at the task. + +""" + +class FineTuner: + def __init__(self, model_path, config_str): + self.model_path = Path(model_path) + # Use GPU if available + self.device = torch.device('cpu') + + # Parse configuration from JSON string + self.config = json.loads(config_str)['training'] + + # Load pre-trained model and tokenizer + self.tokenizer = PegasusTokenizer.from_pretrained(self.model_path) + self.model = PegasusForConditionalGeneration.from_pretrained( + self.model_path, + low_cpu_mem_usage=True + ) + self.model.to(self.device) + + def load_dataset(self, data_path): + return load_dataset(data_path, max_samples=self.config['max_samples']) + + def fine_tune(self, train_data, progress_callback=None): + try: + # Create output directory with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + base_dir = Path("../model_files/finetunedmodels") + base_dir.mkdir(exist_ok=True) + + output_dir = base_dir / f"fine_tuned_{timestamp}" + output_dir.mkdir(parents=True) + + # Create dataset + dataset = TextDataset( + train_data, + self.tokenizer, + max_length=self.config['max_length'] + ) + + # Calculate training batches + total_samples = len(train_data) + batch_size = self.config['batch_size'] + total_batches = (total_samples + batch_size - 1) // batch_size + + # Create data loader for batch processing + train_loader = DataLoader( + dataset, + batch_size=batch_size, + shuffle=True, + num_workers=0, + drop_last=False + ) + + # Initialize optimizer with learning rate + optimizer = torch.optim.AdamW( + self.model.parameters(), + lr=self.config['learning_rate'] + ) + + print(f"Starting training with {total_samples} samples in {total_batches} batches per epoch") + + for epoch in range(self.config['num_epochs']): + self.model.train() + epoch_loss = 0 + batch_count = 0 + + for batch in train_loader: + # Get actual batch size + current_batch_size = batch['input_ids'].size(0) + + input_ids = batch['input_ids'].to(self.device) + attention_mask = batch['attention_mask'].to(self.device) + labels = batch['labels'].to(self.device) + + # Forward pass + outputs = self.model( + input_ids=input_ids, + attention_mask=attention_mask, + labels=labels + ) + + # Get cross entropy loss + loss = outputs.loss + epoch_loss += loss.item() * current_batch_size + batch_count += 1 + + # Backward pass + loss.backward() + optimizer.step() + optimizer.zero_grad() + + # Update progress + if progress_callback: + avg_loss = epoch_loss / (batch_count * batch_size) + progress = { + 'epoch': epoch + 1, + 'total_epochs': self.config['num_epochs'], + 'batch': batch_count, + 'total_batches': total_batches, + 'loss': avg_loss + } + progress_callback(progress) + + # Save checkpoint after each epoch + checkpoint_dir = output_dir / f"checkpoint-epoch-{epoch+1}" + checkpoint_dir.mkdir(exist_ok=True) + self.model.save_pretrained(checkpoint_dir) + self.tokenizer.save_pretrained(checkpoint_dir) + + print(f"Epoch {epoch+1}/{self.config['num_epochs']} completed. Average loss: {epoch_loss/total_samples:.4f}") + + # Save final model and tokenizer + self.model.save_pretrained(output_dir) + self.tokenizer.save_pretrained(output_dir) + + final_path = output_dir.absolute() + print(f"Model saved to: {final_path}") + + return final_path + + except Exception as e: + print(f"Training error: {str(e)}") + raise \ No newline at end of file From c783acbb2648e9cd1c13aa3d42db89f3b53fe00d Mon Sep 17 00:00:00 2001 From: ronantakizawa Date: Thu, 30 Jan 2025 10:21:22 -0700 Subject: [PATCH 16/31] fix: remove max length and num beams parameters for training --- src/components/finetune_form.py | 12 +++--------- src/utils/fine_tuning.py | 3 +-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/components/finetune_form.py b/src/components/finetune_form.py index 7229d055..62d911aa 100644 --- a/src/components/finetune_form.py +++ b/src/components/finetune_form.py @@ -23,8 +23,6 @@ def __init__(self, parent, start_callback): "batch_size": {"min": 1, "max": 32}, # Batch size for training "learning_rate": {"min": 1e-6, "max": 1e-3}, # Learning rate range "max_samples": {"min": 10, "max": 10000}, # Maximum number of training samples - "max_length": {"min": 64, "max": 1024}, # Maximum sequence length - "num_beams": {"min": 1, "max": 8} # Number of beams for beam search } self.default_config = { @@ -32,9 +30,7 @@ def __init__(self, parent, start_callback): "num_epochs": 3, "batch_size": 2, "learning_rate": 2e-5, - "max_samples": 100, - "max_length": 256, - "num_beams": 4 + "max_samples": 100 } } @@ -104,8 +100,7 @@ def validate_input(action, value, param=param): if action == '1': # Insert action try: # Validate integer fields - if param in ["num_epochs", "batch_size", "max_samples", "max_length", - "num_beams"]: + if param in ["num_epochs", "batch_size", "max_samples"]: if not value == "" and not value.isdigit(): #Check if value is a whole number and not a digit return False if value and int(value) > self.param_limits[param]["max"]: #Check if its within the limit @@ -172,8 +167,7 @@ def validate_param(self, param, value): # Check parameters try: limits = self.param_limits[param] - if param in ["num_epochs", "batch_size", "max_samples", "max_length", - "num_beams"]: + if param in ["num_epochs", "batch_size", "max_samples"]: value = int(value) else: value = float(value) diff --git a/src/utils/fine_tuning.py b/src/utils/fine_tuning.py index 50caf9d5..2995b5ec 100644 --- a/src/utils/fine_tuning.py +++ b/src/utils/fine_tuning.py @@ -60,8 +60,7 @@ def fine_tune(self, train_data, progress_callback=None): # Create dataset dataset = TextDataset( train_data, - self.tokenizer, - max_length=self.config['max_length'] + self.tokenizer ) # Calculate training batches From 818c2575ef552704499fb81b31b9f404a208505a Mon Sep 17 00:00:00 2001 From: Ronan Takizawa <71115970+ronantakizawa@users.noreply.github.com> Date: Fri, 31 Jan 2025 02:33:11 +0900 Subject: [PATCH 17/31] feat: Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b93bee..2fcfc165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1-30 +- Added Finetuning +- Added Finetuning via GUI + ## 01-28 ### Added From 1d819bca1d59744cfa8ca5f4e93053b4826b183e Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Mon, 27 Jan 2025 00:45:01 -0700 Subject: [PATCH 18/31] Ignore virtual environment --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e3696912..e8ff4a86 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ medscribe_env/ chat_data/ .env/ finetunedmodels/ +.env/ From 3ea486454b9efd148d148004306fea087d8819ae Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Thu, 30 Jan 2025 08:14:25 -0700 Subject: [PATCH 19/31] LLM page --- src/utils/chat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/chat.py b/src/utils/chat.py index 974624ec..9bbe8568 100644 --- a/src/utils/chat.py +++ b/src/utils/chat.py @@ -75,4 +75,4 @@ def get_response(self, user_input): except Exception as e: print(f"Error in get_response: {str(e)}") return f"An error occurred: {str(e)}" - \ No newline at end of file + From c0692b8fd2e26e98537817f5674b84c6e1ddc52b Mon Sep 17 00:00:00 2001 From: ronantakizawa Date: Fri, 31 Jan 2025 13:00:44 -0700 Subject: [PATCH 20/31] feat: added evaluation gui --- CHANGELOG.md | 5 + README.md | 7 +- requirements.txt | 34 +-- src/components/__init__.py | 4 +- src/components/evaluation_form.py | 50 +++++ src/components/finetune_form.py | 199 ++++-------------- src/components/form.py | 97 +++++++++ src/components/navbar.py | 3 +- ...{download_model.py => download_pegasus.py} | 3 - src/gui.py | 60 ------ src/main.py | 131 ++++++------ src/pages/chat_page.py | 70 ++++-- src/pages/evaluation_page.py | 94 +++++++++ src/pages/evaluations_page.py | 18 -- src/pages/finetune_page.py | 28 ++- src/utils/chat.py | 2 +- src/utils/chat_history.py | 44 +--- src/utils/data_loader.py | 95 ++++----- src/utils/evaluation.py | 148 +++++++++++++ src/utils/fine_tuning.py | 20 +- src/utils/model_config.py | 128 +++++++++++ 21 files changed, 772 insertions(+), 468 deletions(-) create mode 100644 src/components/evaluation_form.py create mode 100644 src/components/form.py rename src/{download_model.py => download_pegasus.py} (99%) delete mode 100644 src/gui.py create mode 100644 src/pages/evaluation_page.py delete mode 100644 src/pages/evaluations_page.py create mode 100644 src/utils/evaluation.py create mode 100644 src/utils/model_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcfc165..53c4b2da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1-31 +- Added Evaluation +- Added Evaluation via GUI +- Refactored form, main.py + ## 1-30 - Added Finetuning - Added Finetuning via GUI diff --git a/README.md b/README.md index 7db5e886..b7850607 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,9 @@ https://github.com/user-attachments/assets/82a473e8-4ae2-4352-b59b-a112da76b475 ## Features - Text Summarization with Pegasus LLM -- Chat history storage (Currently not encrypted) -- +- Chat history storage +- Finetune models +- Model Evaluation ## Installation 1. Clone this repository: @@ -29,7 +30,7 @@ pip install -r requirements.txt 1. Download the required model first (this is required before first run): ```bash cd src -python download_model.py +python download_pegasus.py ``` 2. Start the application: diff --git a/requirements.txt b/requirements.txt index ccd92688..40080a59 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,31 +1,3 @@ -accelerate==1.3.0 -certifi==2024.12.14 -cffi==1.17.1 -charset-normalizer==3.4.1 -cryptography==44.0.0 -filelock==3.17.0 -fsspec==2024.12.0 -huggingface-hub==0.27.1 -idna==3.10 -Jinja2==3.1.5 -MarkupSafe==3.0.2 -mpmath==1.3.0 -networkx==3.4.2 -numpy==1.24.3 -packaging==24.2 -pillow==11.1.0 -psutil==6.1.1 -pycparser==2.22 -PyYAML==6.0.2 -regex==2024.11.6 -requests==2.32.3 -safetensors==0.5.2 -sentencepiece==0.2.0 -sympy==1.13.3 -tk==0.1.0 -tokenizers==0.15.2 -torch==2.0.1 -tqdm==4.67.1 -transformers==4.36.2 -typing_extensions==4.12.2 -urllib3==2.3.0 \ No newline at end of file +rouge_score==0.1.2 +transformers==4.48.1 +torch==2.5.1 \ No newline at end of file diff --git a/src/components/__init__.py b/src/components/__init__.py index 3b9f08aa..2bda4dbd 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -4,4 +4,6 @@ from components.input_frame import InputFrame from components.chat_area import ChatArea from components.loading_indicator import LoadingIndicator -from components.llm_input import LLMInput \ No newline at end of file +from components.llm_input import LLMInput +from components.evaluation_form import EvaluationForm +from components.form import Form diff --git a/src/components/evaluation_form.py b/src/components/evaluation_form.py new file mode 100644 index 00000000..b9f8b7ad --- /dev/null +++ b/src/components/evaluation_form.py @@ -0,0 +1,50 @@ +import tkinter as tk +from .form import Form + +""" +Call backs: +User clicks "Start" → Form validates & calls start_callback +start_callback launches evaluation thread +Evaluation process calls progress_callback +progress_callback updates UI through update_status +""" + +class EvaluationForm(Form): + def __init__(self, parent, start_callback): + # Passes handle_start_evaluation from evaluation_page.py + super().__init__(parent, start_callback) + + def setup_form(self): + super().setup_form() + + # Evaluation Button + button_frame = tk.Frame(self.form, bg="white") + button_frame.grid(row=3, column=0, columnspan=3, sticky="ew", pady=20) + + self.start_btn = tk.Button( + button_frame, + text="Start Evaluation", + command=self.start_evaluation, + relief="solid", + bg="white", + bd=1 + ) + self.start_btn.pack(side=tk.LEFT, padx=5) + + def start_evaluation(self): + validation_result = self.validate() + if not validation_result: + return + + dataset_path, model_path, start_idx, end_idx = validation_result + + # Disable start button + self.start_btn.config(state="disabled") + + # Call the callback function with indices + self.start_callback( + dataset_path, + model_path, + start_idx, + end_idx + ) \ No newline at end of file diff --git a/src/components/finetune_form.py b/src/components/finetune_form.py index 62d911aa..e4c3ed46 100644 --- a/src/components/finetune_form.py +++ b/src/components/finetune_form.py @@ -1,7 +1,11 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -from pathlib import Path import json +import tkinter as tk +from utils.model_config import ( + validate_parameters, + param_limits, + default_config +) +from .form import Form """ Call backs: @@ -9,62 +13,26 @@ start_callback launches training thread Training process calls progress_callback progress_callback updates UI through update_status - """ -class FinetuneForm: +class FinetuneForm(Form): def __init__(self, parent, start_callback): - self.parent = parent - self.start_callback = start_callback - - # Define parameter limits for model fine-tuning - self.param_limits = { - "num_epochs": {"min": 1, "max": 20}, # Number of training epochs - "batch_size": {"min": 1, "max": 32}, # Batch size for training - "learning_rate": {"min": 1e-6, "max": 1e-3}, # Learning rate range - "max_samples": {"min": 10, "max": 10000}, # Maximum number of training samples - } - - self.default_config = { - "training": { - "num_epochs": 3, - "batch_size": 2, - "learning_rate": 2e-5, - "max_samples": 100 - } - } - - self.setup_form() + super().__init__(parent, start_callback) def setup_form(self): - form = tk.Frame(self.parent, bg="white") - form.grid(row=2, column=0, sticky="nsew", padx=30, pady=25) - form.grid_columnconfigure(1, weight=1) - - # Dataset path selection UI - tk.Label(form, text="Dataset Path", bg="white").grid(row=0, column=0, sticky="w", pady=5) - - self.path_var = tk.StringVar() - tk.Entry(form, textvariable=self.path_var).grid(row=0, column=1, sticky="ew", padx=(10,10), pady=5) + super().setup_form() - tk.Button( - form, - text="Browse", - command=self.browse_dataset, - relief="solid", - bd=1 - ).grid(row=0, column=2, pady=5) - + # Training Configuration label tk.Label( - form, + self.form, text="Training Configuration", font=("SF Pro Display", 14, "bold"), bg="white" - ).grid(row=2, column=0, columnspan=3, sticky="w", pady=(20,10)) + ).grid(row=3, column=0, columnspan=3, sticky="w", pady=(20,10)) # Create configuration grid - config_frame = tk.Frame(form, bg="white") - config_frame.grid(row=3, column=0, columnspan=3, sticky="ew") + config_frame = tk.Frame(self.form, bg="white") + config_frame.grid(row=4, column=0, columnspan=3, sticky="ew") config_frame.grid_columnconfigure(1, weight=1) config_frame.grid_columnconfigure(3, weight=1) @@ -72,8 +40,11 @@ def setup_form(self): self.config_vars = {} row = 0 col = 0 - for param, value in self.default_config["training"].items(): - limits = self.param_limits[param] + for param, value in default_config["training"].items(): + if param in ['start_idx', 'end_idx']: # Skip these as they're handled separately + continue + + limits = param_limits[param] label_text = param.replace("_", " ").title() # Add label with parameter limits @@ -95,44 +66,14 @@ def setup_form(self): ) entry.grid(row=row, column=col*2+1, sticky="w", padx=(10,20), pady=8) - # Checks if config for training is valid and not beyond limits - def validate_input(action, value, param=param): - if action == '1': # Insert action - try: - # Validate integer fields - if param in ["num_epochs", "batch_size", "max_samples"]: - if not value == "" and not value.isdigit(): #Check if value is a whole number and not a digit - return False - if value and int(value) > self.param_limits[param]["max"]: #Check if its within the limit - return False - else: - # Validate float fields - if value == "" or value == "-": - return True - try: - float_val = float(value) - if float_val > self.param_limits[param]["max"]: - return False - if float_val < self.param_limits[param]["min"]: - return False - except ValueError: - return False - except ValueError: - return False - return True - - # Register validation command - vcmd = (self.parent.register(validate_input), '%d', '%P') - entry.configure(validate='key', validatecommand=vcmd) - - # Layout management for grid + # Layout management for grid (For every 2 inputs skip a line) col = (col + 1) % 2 if col == 0: row += 1 - # Create control button section - button_frame = tk.Frame(form, bg="white") - button_frame.grid(row=4, column=0, columnspan=3, sticky="ew", pady=20) + # Create Finetuning button + button_frame = tk.Frame(self.form, bg="white") + button_frame.grid(row=6, column=0, columnspan=3, sticky="ew", pady=20) self.start_btn = tk.Button( button_frame, @@ -143,83 +84,25 @@ def validate_input(action, value, param=param): bd=1 ) self.start_btn.pack(side=tk.LEFT, padx=5) - - # Training log section - tk.Label( - form, - text="Training Log", - font=("SF Pro Display", 14, "bold"), - bg="white" - ).grid(row=5, column=0, columnspan=3, sticky="w", pady=(20,10)) - - self.log_text = tk.Text( - form, - height=10, - width=50, - relief="solid", - bd=1, - font=("Courier", 10), - bg="#FAFAFA" - ) - self.log_text.grid(row=6, column=0, columnspan=3, sticky="ew") - - def validate_param(self, param, value): - # Check parameters - try: - limits = self.param_limits[param] - if param in ["num_epochs", "batch_size", "max_samples"]: - value = int(value) - else: - value = float(value) - - if value < limits["min"] or value > limits["max"]: - return False, f"{param} must be between {limits['min']} and {limits['max']}" - return True, value - except ValueError: - return False, f"Invalid value for {param}" - - def browse_dataset(self): - # Open file dialog for dataset selection - filetypes = ( - ('JSON files', '.json'), - ('JSONL files', '.jsonl'), - ('CSV files', '.csv'), - ('Text files', '.txt') - ) - filename = filedialog.askopenfilename( - title='Select Dataset File', - filetypes=filetypes - ) - if filename: - self.path_var.set(filename) - - def update_status(self, message, is_error=False): - # adds new messages to the log window that shows training progress - self.log_text.insert(tk.END, f"{message}\n") - self.log_text.see(tk.END) - - def get_current_config(self): - # Get and validate current configuration values - config = {"training": {}} - for param, var in self.config_vars.items(): - valid, result = self.validate_param(param, var.get()) - if not valid: - messagebox.showerror("Error", result) - return None - config["training"][param] = result - return json.dumps(config) - + def start_finetuning(self): - if not self.path_var.get(): - messagebox.showerror("Error", "Please select a dataset file") + validation_result = self.validate() + if not validation_result: return - config = self.get_current_config() - if config is None: - return + dataset_path, model_path, start_idx, end_idx = validation_result + + # Build and validate config + config = {"training": {}} + + # Validate training parameters + for param, var in self.config_vars.items(): + value = validate_parameters(param, var.get()) + if not value: + return + config["training"][param] = value - # Disable start button + # Disable Finetuning button self.start_btn.config(state="disabled") - - # Call the callback function directly - self.start_callback(config, self.path_var.get()) \ No newline at end of file + # Start training + self.start_callback(json.dumps(config), dataset_path, model_path, start_idx, end_idx) \ No newline at end of file diff --git a/src/components/form.py b/src/components/form.py new file mode 100644 index 00000000..493a278e --- /dev/null +++ b/src/components/form.py @@ -0,0 +1,97 @@ +import tkinter as tk +from utils.model_config import validate_dataset_indices, browse_file + +class Form: + def __init__(self, parent, start_callback): + self.parent = parent + # Passes handle_start_evaluation from evaluation_page.py + self.start_callback = start_callback + self.setup_form() + # Calls the function below + + def setup_form(self): + self.form = tk.Frame(self.parent, bg="white") + self.form.grid(row=2, column=0, sticky="nsew", padx=30, pady=25) + self.form.grid_columnconfigure(1, weight=1) + + # Dataset path selection + tk.Label(self.form, text="Dataset Path", bg="white").grid(row=0, column=0, sticky="w", pady=5) + self.dataset_path_var = tk.StringVar() + tk.Entry(self.form, textvariable=self.dataset_path_var).grid(row=0, column=1, sticky="ew", padx=(10,10), pady=5) + tk.Button( + self.form, + text="Browse", + command=lambda: self.browse_file("dataset"), + relief="solid", + bd=1 + ).grid(row=0, column=2, pady=5) + + # Model path selection + tk.Label(self.form, text="Model Path", bg="white").grid(row=1, column=0, sticky="w", pady=5) + self.model_path_var = tk.StringVar() + tk.Entry(self.form, textvariable=self.model_path_var).grid(row=1, column=1, sticky="ew", padx=(10,10), pady=5) + tk.Button( + self.form, + text="Browse", + command=lambda: self.browse_file("model"), + relief="solid", + bd=1 + ).grid(row=1, column=2, pady=5) + + # Data range selection + range_frame = tk.Frame(self.form, bg="white") + range_frame.grid(row=2, column=0, columnspan=3, sticky="ew", pady=10) + range_frame.grid_columnconfigure(1, weight=1) + range_frame.grid_columnconfigure(3, weight=1) + + tk.Label(range_frame, text="Data Range", font=("SF Pro Display", 12, "bold"), bg="white").grid( + row=0, column=0, columnspan=4, sticky="w", pady=(10,5) + ) + + # Start index + tk.Label(range_frame, text="Start Index", bg="white").grid(row=1, column=0, sticky="w", padx=(0,5)) + self.start_idx_var = tk.StringVar(value="0") + tk.Entry(range_frame, textvariable=self.start_idx_var, width=10).grid(row=1, column=1, sticky="w") + + # End index + tk.Label(range_frame, text="End Index", bg="white").grid(row=1, column=2, sticky="w", padx=(20,5)) + self.end_idx_var = tk.StringVar(value="100") + tk.Entry(range_frame, textvariable=self.end_idx_var, width=10).grid(row=1, column=3, sticky="w") + + # Create the log label + self.log_label = tk.Label( + self.form, + text="Log", + font=("SF Pro Display", 14, "bold"), + bg="white" + ) + self.log_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(20,10)) + + self.log_text = tk.Text( + self.form, + height=10, + width=50, + relief="solid", + bd=1, + font=("Courier", 10), + bg="#FAFAFA" + ) + self.log_text.grid(row=5, column=0, columnspan=3, sticky="ew") + + def browse_file(self, file_type): + browse_file(file_type, self.dataset_path_var if file_type == "dataset" else self.model_path_var) + + def update_status(self, message, is_error=False): + # adds new messages to the log window + self.log_text.insert(tk.END, f"{message}\n") + self.log_text.see(tk.END) + + def validate(self): + # Validate indices + indices = validate_dataset_indices(self.start_idx_var.get(), self.end_idx_var.get()) + start_idx, end_idx = indices + if not indices: + return None + + return self.dataset_path_var.get(), self.model_path_var.get(), start_idx, end_idx + diff --git a/src/components/navbar.py b/src/components/navbar.py index 8f02f635..4290ab10 100644 --- a/src/components/navbar.py +++ b/src/components/navbar.py @@ -15,10 +15,11 @@ class Navbar(tk.Frame): def __init__(self, parent, show_page_callback, **kwargs): super().__init__(parent, bg="#FFFFFF", **kwargs) + # Set callback function self.show_page_callback = show_page_callback self.current_page = "chat" # Default page self.buttons = {} # Store button references - self.logo_photo = None # Keep reference to prevent garbage collection + self.logo_photo = None self._setup_navbar() def _setup_navbar(self): diff --git a/src/download_model.py b/src/download_pegasus.py similarity index 99% rename from src/download_model.py rename to src/download_pegasus.py index c3fd77e2..65c5f2aa 100644 --- a/src/download_model.py +++ b/src/download_pegasus.py @@ -24,7 +24,4 @@ def download_model(): ├── spiece.model (The tokenizer's vocabulary and rules, Defines how to split text into tokens) └── tokenizer_config.json (Configuration for the tokenizer's behavior) - - - """ \ No newline at end of file diff --git a/src/gui.py b/src/gui.py deleted file mode 100644 index a8b9c5e1..00000000 --- a/src/gui.py +++ /dev/null @@ -1,60 +0,0 @@ -import tkinter as tk -from components.navbar import Navbar -from pages.chat_page import ChatPage -from pages.evaluations_page import EvaluationsPage -from pages.llms_page import LLMsPage -from pages.finetune_page import FinetunePage -from pages.projects_page import ProjectsPage -from pages.home_page import HomePage - -class AssessAIGUI: - def __init__(self, root, chat_history, chatbot): - self.root = root - self.chat_history = chat_history - self.chatbot = chatbot - self.current_page = None - self.setup_gui() - - def setup_gui(self): - self._configure_root() - self._configure_grid() - self._setup_navbar() - self.show_page("chat") # Start with chat page # change to starting with homepage - - def _configure_root(self): - self.root.title("Assess.ai") - self.root.configure(bg="#D2E9FC") - - def _configure_grid(self): - # Configure grid weights - self.root.grid_rowconfigure(1, weight=1) # Main content area - self.root.grid_columnconfigure(1, weight=1) # Main content area - - def _setup_navbar(self): - self.navbar = Navbar(self.root, self.show_page) - self.navbar.grid(row=1, column=0, sticky="ns") - - def _clear_content(self): - # Remove the existing page - for widget in self.root.grid_slaves(): - - if int(widget.grid_info()["column"]) == 1: - widget.destroy() - - def show_page(self, page_name): - # Show the new page - self._clear_content() - - # Create new page based on selection - if page_name == "chat": - self.current_page = ChatPage(self.root, self.chat_history, self.chatbot) - elif page_name == "evaluations": - self.current_page = EvaluationsPage(self.root) - elif page_name == "llms": - self.current_page = LLMsPage(self.root) - elif page_name == "finetune": - self.current_page = FinetunePage(self.root) - elif page_name == "projects": - self.current_page = ProjectsPage(self.root) - elif page_name == "home": - self.current_page = HomePage(self.root) diff --git a/src/main.py b/src/main.py index 993003b5..afa54dd4 100644 --- a/src/main.py +++ b/src/main.py @@ -1,11 +1,66 @@ import tkinter as tk -import threading -from gui import AssessAIGUI -from utils.chat_history import SecureChatHistory -from utils.chat import ChatBot import os +from components.navbar import Navbar +from pages.chat_page import ChatPage +from pages.evaluation_page import EvaluationPage +from pages.llms_page import LLMsPage +from pages.finetune_page import FinetunePage +from pages.projects_page import ProjectsPage +from pages.home_page import HomePage + +# Set environment variable os.environ['TOKENIZERS_PARALLELISM'] = 'false' +class AssessAIGUI: + def __init__(self, root): + self.root = root + self.current_page = None + self.setup_gui() + # Calls function below + + def setup_gui(self): + self._configure_root() + self._configure_grid() + self._setup_navbar() + self.show_page("chat") # Start with chat page + + def _configure_root(self): + self.root.title("Assess.ai") + self.root.configure(bg="#D2E9FC") + + def _configure_grid(self): + # Configure grid weights + self.root.grid_rowconfigure(1, weight=1) # Main content area + self.root.grid_columnconfigure(1, weight=1) # Main content area + + def _setup_navbar(self): + # Set callback function for changing the page + self.navbar = Navbar(self.root, self.show_page) + self.navbar.grid(row=1, column=0, sticky="ns") + + def _clear_content(self): + # Remove the existing page + for widget in self.root.grid_slaves(): + if int(widget.grid_info()["column"]) == 1: + widget.destroy() + + def show_page(self, page_name): + # Show the new page + self._clear_content() + + if page_name == "chat": + self.current_page = ChatPage(self.root) + elif page_name == "evaluations": + self.current_page = EvaluationPage(self.root) + elif page_name == "llms": + self.current_page = LLMsPage(self.root) + elif page_name == "finetune": + self.current_page = FinetunePage(self.root) + elif page_name == "projects": + self.current_page = ProjectsPage(self.root) + elif page_name == "home": + self.current_page = HomePage(self.root) + def main(): # Initialize root and components root = tk.Tk() @@ -15,72 +70,12 @@ def main(): # Configure grid weights for the root window root.grid_rowconfigure(1, weight=1) # Make row 1 expandable root.grid_columnconfigure(1, weight=1) # Make column 1 expandable - - # Load chat history - chat_history = SecureChatHistory() - - # Create chatbot without loading model - chatbot = ChatBot() - - # Create GUI - app = AssessAIGUI(root, chat_history, chatbot) - - # Start model loading in background - def load_model_background(): - chatbot._load_model() - print("Model loaded and ready!") - - threading.Thread(target=load_model_background, daemon=True).start() + + # Initialize GUI + app = AssessAIGUI(root) # Start main loop root.mainloop() if __name__ == "__main__": - main() - -""" -We use threading in this app to keep app responsive while the LLM response is being returned. -Before without threading, the app would pause and not be responsive until the LLM response comes. -By using threading we allow the UI to work while doing other tasks. - -2 threads: Main Thread (UI), AI Response threads (Created when needing to get response). - -# THREAD FLOW DIAGRAM -# -# Main UI Thread AI Response Thread -# (tkinter) (spawned as needed) -# ================ ================== -# | | -# | | -# User types... | -# | | -# [Send clicked] | -# | | -# send_message() | -# | | -# |---> Add user message | -# | to chat | -# | | -# |---> Start AI Thread ------------->| -# | | -# | [Process AI response] -# [UI remains | -# responsive] [AI response ready] -# | | -# |<---------------------------------| -# | root.after() | -# [Show AI reply] (schedule UI | -# | update) | -# | | -# -# Notes: -# - Main UI Thread: Never blocks, handles all UI updates -# - AI Thread: Created for each response, dies after completion -# -# Key Points: -# 1. UI never freezes because heavy processing is offloaded -# 2. Direct message handling without queues -# 3. root.after() safely schedules UI updates from other threads -# 4. Due to GIL, only one Python thread runs at a time -# (but switches fast enough to appear concurrent) -""" + main() \ No newline at end of file diff --git a/src/pages/chat_page.py b/src/pages/chat_page.py index 69717c8e..7e0635e8 100644 --- a/src/pages/chat_page.py +++ b/src/pages/chat_page.py @@ -9,11 +9,15 @@ LoadingIndicator ) +from utils.chat_history import SecureChatHistory +from utils.chat import ChatBot + class ChatPage: - def __init__(self, root, chat_history, chatbot): + def __init__(self, root): + self.root = root - self.chat_history = chat_history - self.chatbot = chatbot + self.chat_history = SecureChatHistory() + self.chatbot = ChatBot() self.is_processing = False # Create main container for chat page @@ -23,28 +27,25 @@ def __init__(self, root, chat_history, chatbot): self.setup_screen() def setup_screen(self): - """Initialize and setup the chat screen""" self._configure_root() self._setup_styles() self._configure_grid() self._initialize_components() self._setup_bindings() # Load chat history after GUI is ready (100ms delay) + threading.Thread(target=self.chatbot._load_model, daemon=True).start() self.root.after(100, self.load_chat_history) def _configure_root(self): - """Configure root window settings""" self.root.title("Assess.ai") self.root.configure(bg="#D2E9FC") def _setup_styles(self): - """Setup ttk styles""" style = ttk.Style() style.configure("Chat.TFrame", background="#D2E9FC") style.configure("Round.TLabel", background="#D2E9FC") def _configure_grid(self): - """Configure grid layout""" self.container.grid_rowconfigure(0, weight=0) # Title self.container.grid_rowconfigure(1, weight=1) # Chat self.container.grid_rowconfigure(2, weight=0) # Input @@ -70,12 +71,10 @@ def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) def set_processing_state(self, is_processing): - """Update processing state and input frame state""" self.is_processing = is_processing self.root.after(0, self._update_input_state) def _update_input_state(self): - """Update input frame enabled/disabled state""" if hasattr(self.input_frame, 'input_field'): self.input_frame.input_field.configure(state='disabled' if self.is_processing else 'normal') if hasattr(self.input_frame, 'send_button'): @@ -98,8 +97,7 @@ def send_message(self, message): except Exception as e: print(f"Error sending message: {str(e)}") - self.add_message(f"Error: {str(e)}", is_user=False) - self.set_processing_state(False) + self.set_processing_state(True) def _get_ai_response_threaded(self, user_input, scroll_position): def get_response(): @@ -156,4 +154,52 @@ def load_chat_history(self): except Exception as e: print(f"Error loading chat history: {str(e)}") - self.root.after(50, lambda: self.chat_area.canvas.yview_moveto(5.0)) \ No newline at end of file + self.root.after(50, lambda: self.chat_area.canvas.yview_moveto(5.0)) + + +""" +We use threading in this app to keep app responsive while the LLM response is being returned. +Before without threading, the app would pause and not be responsive until the LLM response comes. +By using threading we allow the UI to work while doing other tasks. + +2 threads: Main Thread (UI), AI Response threads (Created when needing to get response). + +# THREAD FLOW DIAGRAM +# +# Main UI Thread AI Response Thread +# (tkinter) (spawned as needed) +# ================ ================== +# | | +# | | +# User types... | +# | | +# [Send clicked] | +# | | +# send_message() | +# | | +# |---> Add user message | +# | to chat | +# | | +# |---> Start AI Thread ------------->| +# | | +# | [Process AI response] +# [UI remains | +# responsive] [AI response ready] +# | | +# |<---------------------------------| +# | root.after() | +# [Show AI reply] (schedule UI | +# | update) | +# | | +# +# Notes: +# - Main UI Thread: Never blocks, handles all UI updates +# - AI Thread: Created for each response, dies after completion +# +# Key Points: +# 1. UI never freezes because heavy processing is offloaded +# 2. Direct message handling without queues +# 3. root.after() safely schedules UI updates from other threads +# 4. Due to GIL, only one Python thread runs at a time +# (but switches fast enough to appear concurrent) +""" \ No newline at end of file diff --git a/src/pages/evaluation_page.py b/src/pages/evaluation_page.py new file mode 100644 index 00000000..ea81779d --- /dev/null +++ b/src/pages/evaluation_page.py @@ -0,0 +1,94 @@ +import tkinter as tk +from tkinter import ttk +import threading +from pathlib import Path +from utils.evaluation import Evaluator +from components.evaluation_form import EvaluationForm + +class EvaluationPage: + def __init__(self, root): + self.root = root + # Run method right below + self.setup_page() + + def setup_page(self): + self.container = tk.Frame(self.root, bg="#EBF3FF") + self.container.grid(row=1, column=1, sticky="nsew") + self.container.grid_columnconfigure(0, weight=1) + + content = tk.Frame(self.container, bg="white") + content.grid(row=0, column=0, sticky="nsew", padx=30, pady=30) + content.grid_columnconfigure(0, weight=1) + + tk.Label( + content, + text="Evaluate Model", + font=("SF Pro Display", 24), + bg="white" + ).grid(row=0, column=0, sticky="w", padx=20, pady=(20,10)) + + ttk.Separator(content, orient="horizontal").grid(row=1, column=0, sticky="ew", padx=20) + + self.form = EvaluationForm(content, self.handle_start_evaluation) + + def run_evaluation(self, dataset_path, model_path, start_idx, end_idx): + try: + # Initialize evaluator + self.form.update_status("Loading model...") + evaluator = Evaluator(model_path) + + # Load dataset with specified range + self.form.update_status(f"Loading dataset (indices {start_idx}-{end_idx})...") + try: + test_data = evaluator.load_dataset(dataset_path, start_idx, end_idx) + self.form.update_status(f"Successfully loaded {len(test_data)} samples from index range {start_idx}-{end_idx}") + except ValueError as e: + raise ValueError(f"Failed to load dataset: {str(e)}") + + self.form.update_status("Starting evaluation...") + + # Define progress callback + def progress_callback(progress_info): + current = progress_info['current'] + total = progress_info['total'] + successful = progress_info['successful'] + rouge1 = progress_info['rouge1'] + + # Update status with current progress + status_text = ( + f"Evaluating samples {start_idx}-{end_idx}\n" + f"Current sample: {start_idx + current}/{end_idx} " + f"(Processed {successful} successfully)\n" + f"Current ROUGE-1: {rouge1:.4f}\n" + ) + self.root.after(0, lambda: self.form.update_status(status_text)) + + # Run evaluation + final_scores = evaluator.evaluate( + test_data, + progress_callback=progress_callback + ) + + # Update status with final scores + final_status = ( + f"\nEvaluation complete for samples {start_idx}-{end_idx}!\n" + f"Successfully processed {final_scores['processed_samples']} out of {final_scores['total_samples']} samples\n" + f"Final ROUGE-1: {final_scores['rouge1']:.4f}\n" + ) + self.root.after(0, lambda: self.form.update_status(final_status)) + self.root.after(0, lambda: self.form.start_btn.config(state="normal")) + + except Exception as e: + # Handle any errors during evaluation + self.root.after(0, lambda: self.form.update_status(f"Error: {str(e)}", is_error=True)) + self.root.after(0, lambda: self.form.start_btn.config(state="normal")) + + def handle_start_evaluation(self, dataset_path, model_path, start_idx, end_idx): + # Start evaluation in a separate thread + thread = threading.Thread( + target=self.run_evaluation, + # Passes parameters needed to run evaluation + args=(dataset_path, model_path, start_idx, end_idx), + daemon=True + ) + thread.start() \ No newline at end of file diff --git a/src/pages/evaluations_page.py b/src/pages/evaluations_page.py deleted file mode 100644 index 6141b40f..00000000 --- a/src/pages/evaluations_page.py +++ /dev/null @@ -1,18 +0,0 @@ -import tkinter as tk - -class EvaluationsPage: - def __init__(self, root): - self.root = root - self.setup_page() - - def setup_page(self): - container = tk.Frame(self.root, bg="#D2E9FC") - container.grid(row=1, column=1, sticky="nsew") - - # Placeholder content - tk.Label( - container, - text="Past Evaluations", - font=("SF Pro Display", 24, "bold"), - bg="#D2E9FC" - ).pack(pady=20) \ No newline at end of file diff --git a/src/pages/finetune_page.py b/src/pages/finetune_page.py index 691354f4..eeccf372 100644 --- a/src/pages/finetune_page.py +++ b/src/pages/finetune_page.py @@ -1,6 +1,5 @@ import tkinter as tk from tkinter import ttk -from pathlib import Path import threading from utils.fine_tuning import FineTuner from components.finetune_form import FinetuneForm @@ -34,21 +33,19 @@ def setup_page(self): # Initialize the form component self.form = FinetuneForm(content, self.handle_start_finetuning) - def run_finetuning(self, config, dataset_path): - """Execute fine-tuning process""" - try: - # Check for model directory - model_path = Path("../model_files/pegasus") - if not model_path.exists(): - raise FileNotFoundError(f"Model directory not found at {model_path}") - + def run_finetuning(self, config, dataset_path, model_path, start_idx, end_idx): + try: # Initialize fine-tuning process self.form.update_status("Loading model...") fine_tuner = FineTuner(model_path, config) # Load dataset - self.form.update_status("Loading dataset...") - train_data = fine_tuner.load_dataset(dataset_path) + self.form.update_status(f"Loading dataset") + try: + train_data = fine_tuner.load_dataset(dataset_path,start_idx,end_idx) + self.form.update_status(f"Successfully loaded {len(train_data)} samples from index range {start_idx}-{end_idx}") + except ValueError as e: + raise ValueError(f"Failed to load dataset: {str(e)}") self.form.update_status("Starting fine-tuning...") @@ -64,13 +61,13 @@ def progress_callback(progress_info): status_text = f"Epoch {epoch}/{total_epochs} - Batch {batch}/{total_batches} - Loss: {loss:.4f}" self.root.after(0, lambda: self.form.update_status(status_text)) - output_dir = fine_tuner.fine_tune( + output_directory = fine_tuner.fine_tune( train_data, progress_callback=progress_callback ) # Update status on completion - self.root.after(0, lambda: self.form.update_status(f"Fine-tuning complete! Model saved to: {output_dir}")) + self.root.after(0, lambda: self.form.update_status(f"Fine-tuning complete! Model saved to: {output_directory}")) self.root.after(0, lambda: self.form.start_btn.config(state="normal")) except Exception as e: @@ -78,12 +75,11 @@ def progress_callback(progress_info): self.root.after(0, lambda: self.form.update_status(f"Error: {str(e)}", is_error=True)) self.root.after(0, lambda: self.form.start_btn.config(state="normal")) - def handle_start_finetuning(self, config, dataset_path): - """Handle the start of fine-tuning process""" + def handle_start_finetuning(self, config, dataset_path, model_path, start_idx, end_idx): # Start fine-tuning in a separate thread thread = threading.Thread( target=self.run_finetuning, - args=(config, dataset_path), + args=(config, dataset_path, model_path, start_idx, end_idx), daemon=True ) thread.start() \ No newline at end of file diff --git a/src/utils/chat.py b/src/utils/chat.py index 9bbe8568..ba150866 100644 --- a/src/utils/chat.py +++ b/src/utils/chat.py @@ -1,6 +1,5 @@ from transformers import PegasusForConditionalGeneration, PegasusTokenizer import torch -import os from pathlib import Path class ChatBot: @@ -48,6 +47,7 @@ def get_response(self, user_input): inputs = {k: v.to(self.device) for k, v in inputs.items()} try: + # Don't track gradients when chatting to not affect model. with torch.no_grad(): output_ids = self.model.generate( **inputs, diff --git a/src/utils/chat_history.py b/src/utils/chat_history.py index 95e79f88..ef9106bb 100644 --- a/src/utils/chat_history.py +++ b/src/utils/chat_history.py @@ -9,17 +9,13 @@ def __init__(self): self._ensure_directories() def _ensure_directories(self): - """Create necessary directories and files""" + # Checks if chat history already exists. If it doesn't it creates one. self.chat_history_path.parent.mkdir(exist_ok=True) if not self.chat_history_path.exists(): self.chat_history_path.write_text("") - def initialize_encryption(self, password, max_retries=5): - """Kept for compatibility""" - pass - def save_chat_entry(self, entry_data): - """Save chat entry asynchronously""" + # Save chat history in the background try: entry = { 'timestamp': datetime.now().isoformat(), @@ -41,38 +37,20 @@ def write_to_file(): print(f"Failed to save chat entry: {str(e)}") def load_chat_history(self, limit=None): - """Load chat history with buffered reading""" entries = [] - BUFFER_SIZE = 8192 # 8KB buffer - try: with open(self.chat_history_path, 'r', - encoding='utf-8', - buffering=BUFFER_SIZE) as f: - - # Read lines in reverse if limit is set - if limit: - lines = f.readlines() - for line in reversed(lines[-limit:]): - if line.strip(): - try: - entry = json.loads(line.strip()) - if entry['type'] == 'chat_entry': - entries.insert(0, entry) - except json.JSONDecodeError: - continue - else: - for line in f: - if line.strip(): - try: - entry = json.loads(line.strip()) - if entry['type'] == 'chat_entry': - entries.append(entry) - except json.JSONDecodeError: - continue + encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + entry = json.loads(line.strip()) + if entry['type'] == 'chat_entry': + entries.append(entry) + except json.JSONDecodeError: + continue return entries - except Exception as e: print(f"Failed to load chat history: {str(e)}") return [] \ No newline at end of file diff --git a/src/utils/data_loader.py b/src/utils/data_loader.py index e40b3548..9434554e 100644 --- a/src/utils/data_loader.py +++ b/src/utils/data_loader.py @@ -4,6 +4,7 @@ from pathlib import Path import csv +# Object to convert text dataset into an object accessible by Pytorch's Dataloader class TextDataset(Dataset): def __init__(self, data, tokenizer, max_length=256): self.data = data @@ -45,74 +46,60 @@ def __getitem__(self, idx): 'target_text': item['target_text'] } -def load_dataset(data_path, max_samples=None): + +def load_dataset(data_path, start_idx=0, end_idx=100): data_path = Path(data_path) if not data_path.exists(): raise FileNotFoundError(f"Data file not found at {data_path}") - - file_extension = data_path.suffix.lower() # Get file extension (.json, .csv...) + + file_extension = data_path.suffix.lower() try: - # Handle different file formats - if file_extension == '.json': - # Load standard JSON file - with open(data_path, 'r', encoding='utf-8') as f: - data = json.load(f) + data = [] + with open(data_path, 'r', encoding='utf-8') as f: + # Handle different file formats + if file_extension == '.json': + data = json.load(f)[start_idx:end_idx] - elif file_extension == '.jsonl': - # Load JSON Lines file (one JSON object per line) - data = [] - with open(data_path, 'r', encoding='utf-8') as f: - for line in f: - if line.strip(): + elif file_extension == '.jsonl': + for i, line in enumerate(f): + if start_idx <= i < end_idx and line.strip(): data.append(json.loads(line)) - elif file_extension == '.csv': - # Load CSV file with specific columns - data = [] - with open(data_path, 'r', encoding='utf-8') as f: + elif file_extension == '.csv': reader = csv.DictReader(f) - for row in reader: - if 'input_text' not in row or 'target_text' not in row: - raise ValueError("CSV must contain 'input_text' and 'target_text' columns") - data.append({ - 'input_text': row['input_text'], - 'target_text': row['target_text'] - }) + for i, row in enumerate(reader): + if start_idx <= i < end_idx: + if 'input_text' not in row or 'target_text' not in row: + raise ValueError("CSV must contain 'input_text' and 'target_text' columns") + data.append({ + 'input_text': row['input_text'], + 'target_text': row['target_text'] + }) - elif file_extension == '.txt': - # Load custom format text file with INPUT: and TARGET: prefixes - data = [] - current_item = {} - with open(data_path, 'r', encoding='utf-8') as f: + elif file_extension == '.txt': lines = f.readlines() - - for line in lines: - line = line.strip() - if line.startswith('INPUT:'): - if current_item: - data.append(current_item) - current_item = {'input_text': line[6:].strip()} - elif line.startswith('TARGET:'): - if 'input_text' in current_item: - current_item['target_text'] = line[7:].strip() - data.append(current_item) - current_item = {} + for idx in range(start_idx, min(end_idx, len(lines)), 2): + if idx + 1 >= len(lines): + break - # Add last item if complete - if current_item and 'input_text' in current_item and 'target_text' in current_item: - data.append(current_item) - - else: - raise ValueError(f"Unsupported file format: {file_extension}") + input_line = lines[idx].strip() + target_line = lines[idx + 1].strip() + + if input_line.startswith('INPUT:') and target_line.startswith('TARGET:'): + data.append({ + 'input_text': input_line[6:].strip(), + 'target_text': target_line[7:].strip() + }) - for item in data: - if not isinstance(item, dict) or 'input_text' not in item or 'target_text' not in item: - raise ValueError("Data must contain 'input_text' and 'target_text' fields") - - if max_samples is not None: - data = data[:max_samples] + else: + raise ValueError(f"Unsupported file format: {file_extension}") + if not data: #There was no data found + raise ValueError("No valid data found in the specified range. Please check your dataset or specified indices") + # Validate data structure + if not all(isinstance(item, dict) and 'input_text' in item and 'target_text' in item for item in data): + raise ValueError("Data must contain 'input_text' and 'target_text' fields") return data except Exception as e: diff --git a/src/utils/evaluation.py b/src/utils/evaluation.py new file mode 100644 index 00000000..33593c3f --- /dev/null +++ b/src/utils/evaluation.py @@ -0,0 +1,148 @@ +import torch +from transformers import PegasusForConditionalGeneration, PegasusTokenizer +from rouge_score import rouge_scorer +from pathlib import Path +from .data_loader import load_dataset + + +""" +ROUGE-1: Measures unigram (single word) overlap +For example, if the target text has "the cat sat" and the generated text has "the cat ran", it counts matches of individual words ("the" and "cat" match) +""" + +class Evaluator: + def __init__(self, model_path): + self.model_path = Path(model_path) + # Use CPU + self.device = torch.device('cpu') + + self.tokenizer = PegasusTokenizer.from_pretrained(self.model_path) + self.model = PegasusForConditionalGeneration.from_pretrained( + self.model_path, + low_cpu_mem_usage=True + ) + self.model.to(self.device) + + # use_stemmer=True means the ROUGE scorer will use word stemming ("running" -> "run") + self.scorer = rouge_scorer.RougeScorer(['rouge1'], use_stemmer=True) + + self.max_input_length = 512 + self.max_output_length = 128 + self.min_output_length = 30 + + def load_dataset(self, data_path,start_idx,end_idx): + # Load dataset with dataset validation + return load_dataset(data_path, start_idx=start_idx, end_idx=end_idx) + + def generate_summary(self, text): + try: + if not text: + return "" + + # Tokenize with padding and truncation + inputs = self.tokenizer( + text, + max_length=self.max_input_length, + truncation=True, + padding='max_length', + return_tensors="pt" + ) + + # Generate summary + try: + with torch.no_grad(): + summary_ids = self.model.generate( + inputs["input_ids"].to(self.device), + max_length=self.max_output_length, + min_length=self.min_output_length, + num_beams=4, + length_penalty=2.0, + pad_token_id=self.tokenizer.pad_token_id, + bos_token_id=self.tokenizer.bos_token_id, + eos_token_id=self.tokenizer.eos_token_id + ) + except RuntimeError as e: + if "out of memory" in str(e): + torch.cuda.empty_cache() + return "" + raise + + # Decode summary + summary = self.tokenizer.decode(summary_ids[0], skip_special_tokens=True) + return summary.strip() + + except Exception as e: + print(f"Error generating summary: {str(e)}") + return "" + + def calculate_rouge_scores(self, reference, candidate): + # Reference = Target, Candidate = LLM generated summary. Use this terminology here for good practice + # Return F1 scores of + try: + if not reference or not candidate: + return None + scores = self.scorer.score(reference, candidate) + return { + 'rouge1': scores['rouge1'].fmeasure, # # This is the F1 score + } + except Exception as e: + print(f"Error calculating ROUGE scores: {str(e)}") + return None + + def evaluate(self, test_data, progress_callback=None): + try: + if not test_data: + raise ValueError("Test data is empty") + + total_samples = len(test_data) + successful_samples = 0 # Store this to report how many samples were processed + rouge_scores = [] + + for idx, item in enumerate(test_data): + try: + generated_summary = self.generate_summary(item.get('input_text', '')) + if not generated_summary: + raise ValueError(f"Failed to generate summary for sample {idx}. Summary is empty or invalid.") + # Calculate ROUGE scores between generated and target summaries + scores = self.calculate_rouge_scores( + item.get('target_text', ''), + generated_summary + ) + + if scores: + rouge_scores.append(scores) + successful_samples += 1 + + # Update progress + if progress_callback: + avg_scores = { + 'rouge1': sum(s['rouge1'] for s in rouge_scores) / len(rouge_scores) + } + + progress = { + 'current': idx + 1, + 'total': total_samples, + 'successful': successful_samples, + 'rouge1': avg_scores['rouge1'] + } + progress_callback(progress) + + except Exception as e: + print(f"Error processing sample {idx}: {str(e)}") + continue + + if not rouge_scores: + raise ValueError("No valid samples were processed") + + # Calculate final average scores + final_scores = { + 'rouge1': sum(s['rouge1'] for s in rouge_scores) / len(rouge_scores), + 'processed_samples': successful_samples, + 'total_samples': total_samples + } + + return final_scores + + except Exception as e: + print(f"Evaluation error: {str(e)}") + raise \ No newline at end of file diff --git a/src/utils/fine_tuning.py b/src/utils/fine_tuning.py index 2995b5ec..a212355d 100644 --- a/src/utils/fine_tuning.py +++ b/src/utils/fine_tuning.py @@ -3,7 +3,6 @@ from transformers import PegasusForConditionalGeneration, PegasusTokenizer import json from pathlib import Path -import time from datetime import datetime from .data_loader import TextDataset, load_dataset import random @@ -30,7 +29,7 @@ class FineTuner: def __init__(self, model_path, config_str): self.model_path = Path(model_path) - # Use GPU if available + # Use CPU self.device = torch.device('cpu') # Parse configuration from JSON string @@ -44,26 +43,29 @@ def __init__(self, model_path, config_str): ) self.model.to(self.device) - def load_dataset(self, data_path): - return load_dataset(data_path, max_samples=self.config['max_samples']) + def load_dataset(self, data_path,start_idx, end_idx): + # Load dataset with dataset validation + return load_dataset(data_path, start_idx=start_idx, end_idx=end_idx) def fine_tune(self, train_data, progress_callback=None): try: - # Create output directory with timestamp + # Create output directory with timestamp and range info timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + start_idx = self.config.get('start_idx', 0) + end_idx = self.config.get('end_idx', len(train_data)) base_dir = Path("../model_files/finetunedmodels") base_dir.mkdir(exist_ok=True) - output_dir = base_dir / f"fine_tuned_{timestamp}" + # Include range info in output directory name + output_dir = base_dir / f"fine_tuned_{timestamp}_range_{start_idx}_{end_idx}" output_dir.mkdir(parents=True) - # Create dataset + # Create TextDataset Object to easily load into Pytorch's Dataloader dataset = TextDataset( train_data, self.tokenizer ) - # Calculate training batches total_samples = len(train_data) batch_size = self.config['batch_size'] total_batches = (total_samples + batch_size - 1) // batch_size @@ -83,7 +85,7 @@ def fine_tune(self, train_data, progress_callback=None): lr=self.config['learning_rate'] ) - print(f"Starting training with {total_samples} samples in {total_batches} batches per epoch") + print(f"Starting training with {total_samples} samples (range {start_idx}-{end_idx}) in {total_batches} batches per epoch") for epoch in range(self.config['num_epochs']): self.model.train() diff --git a/src/utils/model_config.py b/src/utils/model_config.py new file mode 100644 index 00000000..99d82598 --- /dev/null +++ b/src/utils/model_config.py @@ -0,0 +1,128 @@ +import os +from tkinter import messagebox, filedialog +import json + +# Define parameter limits for model fine-tuning +param_limits = { + "num_epochs": {"min": 1, "max": 20}, # Number of training epochs + "batch_size": {"min": 1, "max": 32}, # Batch size for training + "learning_rate": {"min": 0.000001, "max": 0.001}, # Learning rate range +} + +default_config = { + "training": { + "num_epochs": 3, + "batch_size": 2, + "learning_rate": 0.001, + "start_idx": 0, + "end_idx": 100 + } +} + +def _validate_number_input(value, is_int=True, min_val=None, max_val=None): + if not value.strip(): + messagebox.showerror("Error", "Value cannot be empty") + return None + + try: + if is_int: + num_value = int(value) + if not float(value).is_integer(): # Check if it is actually an integer + messagebox.showerror("Error", "Value must be a whole number") + return None + else: + num_value = float(value) + + if (min_val is not None and num_value < min_val) or (max_val is not None and num_value > max_val): + messagebox.showerror("Error", f"Invalid indices") + return None + + return num_value + except ValueError: + messagebox.showerror("Error", "Please enter a valid number") + return None + +def validate_dataset_indices(start_idx_var, end_idx_var): + + # Validate start index + start_idx = _validate_number_input(start_idx_var, is_int=True, min_val=0) + if start_idx is None: + return None + + # Validate end index + end_idx = _validate_number_input(end_idx_var, is_int=True, min_val=start_idx + 1) + if end_idx is None: + return None + + if start_idx >= end_idx: + messagebox.showerror("Error", f"Start index ({start_idx}) must be less than end index ({end_idx})") + return None + + if end_idx - start_idx == 0: + messagebox.showerror("Error", f"Invalid Indices") + return None + + return start_idx, end_idx + +def validate_parameters(param, value): + if not value.strip(): + messagebox.showerror("Error", f"{param} cannot be empty") + return None + + try: + limits = param_limits[param] + + # Determine whether the parameter should be an integer or float + is_int = param in ["num_epochs", "batch_size"] + + num_value = _validate_number_input( + value, + is_int=is_int, # learning_rate will be validated as a float, others as ints + min_val=limits["min"], + max_val=limits["max"] + ) + + if num_value is None: + return None + + return num_value + + except Exception as e: + messagebox.showerror("Error", f"Invalid value for {param}: {str(e)}") + return None + +def browse_file(file_type, path_var): + if file_type == "dataset": + filetypes = ( + ('JSON files', '.json'), + ('JSONL files', '.jsonl'), + ('CSV files', '.csv'), + ('Text files', '.txt') + ) + title = 'Select Dataset File' + else: # model + filetypes = (('All files', '*.*'),) + title = 'Select Model Directory' + + if file_type == "model": + # Look for directories + filename = filedialog.askdirectory(title=title) + else: + # Look for files + filename = filedialog.askopenfilename(title=title, filetypes=filetypes) + + if not filename: + return None + + # Validation logic + if not filename.strip(): + messagebox.showerror("Error", f"Please select a {file_type} {'file' if file_type == 'dataset' else 'directory'}") + return None + + if not os.path.exists(filename): + messagebox.showerror("Error", f"{file_type.capitalize()} {'file' if file_type == 'dataset' else 'directory'} does not exist") + return None + + path_var.set(filename) + + return filename \ No newline at end of file From 61b33e8375b71a30bd5d95ba1431af43c9e8f20e Mon Sep 17 00:00:00 2001 From: ronantakizawa Date: Fri, 31 Jan 2025 14:58:27 -0700 Subject: [PATCH 21/31] feat: remove chat_history.py --- src/pages/chat_page.py | 33 +++++++------------ src/utils/chat.py | 69 +++++++++++++++++++++++++++++++++++---- src/utils/chat_history.py | 56 ------------------------------- 3 files changed, 75 insertions(+), 83 deletions(-) delete mode 100644 src/utils/chat_history.py diff --git a/src/pages/chat_page.py b/src/pages/chat_page.py index 7e0635e8..1fedbdbb 100644 --- a/src/pages/chat_page.py +++ b/src/pages/chat_page.py @@ -1,6 +1,7 @@ import tkinter as tk from tkinter import ttk import threading +from tkinter import messagebox from components import ( ChatBubble, InputFrame, @@ -8,16 +9,12 @@ ChatArea, LoadingIndicator ) - -from utils.chat_history import SecureChatHistory -from utils.chat import ChatBot +from utils.chat import ChatBot class ChatPage: def __init__(self, root): - self.root = root - self.chat_history = SecureChatHistory() - self.chatbot = ChatBot() + self.chatbot = ChatBot() self.is_processing = False # Create main container for chat page @@ -93,34 +90,28 @@ def send_message(self, message): self.set_processing_state(True) # Disable input while processing # Start AI response thread - self._get_ai_response_threaded(message, current_scroll) + self._get_ai_response_threaded(message) except Exception as e: print(f"Error sending message: {str(e)}") - self.set_processing_state(True) + self.set_processing_state(False) # Fixed: was True before - def _get_ai_response_threaded(self, user_input, scroll_position): + def _get_ai_response_threaded(self, user_input): def get_response(): try: response = self.chatbot.get_response(user_input) - self.root.after(0, self._handle_ai_response, response, user_input, scroll_position) + self.root.after(0, self._handle_ai_response, response) except Exception as e: self.root.after(0, self._handle_ai_error, f"Error: {str(e)}") - thread = threading.Thread(target=get_response) - thread.daemon = True - thread.start() + threading.Thread(target=get_response, daemon=True).start() - def _handle_ai_response(self, response, user_input, scroll_position): + def _handle_ai_response(self, response): try: self.loading.stop() self.add_message(response, is_user=False) # After adding AI response, ensure we scroll to bottom self.root.after(100, self.chat_area.smooth_scroll_to_bottom) - self.chat_history.save_chat_entry({ - 'user_input': user_input, - 'ai_response': response - }) self.set_processing_state(False) # Re-enable input after response except Exception as e: print(f"Error handling response: {str(e)}") @@ -146,7 +137,7 @@ def add_message(self, text, is_user=True): def load_chat_history(self): try: - entries = self.chat_history.load_chat_history() + entries = self.chatbot.load_chat_history() for entry in entries: chat_data = entry['data'] self.add_message(chat_data['user_input'], is_user=True) @@ -155,8 +146,8 @@ def load_chat_history(self): print(f"Error loading chat history: {str(e)}") self.root.after(50, lambda: self.chat_area.canvas.yview_moveto(5.0)) - - + + """ We use threading in this app to keep app responsive while the LLM response is being returned. Before without threading, the app would pause and not be responsive until the LLM response comes. diff --git a/src/utils/chat.py b/src/utils/chat.py index ba150866..1dac5a85 100644 --- a/src/utils/chat.py +++ b/src/utils/chat.py @@ -1,14 +1,65 @@ from transformers import PegasusForConditionalGeneration, PegasusTokenizer import torch from pathlib import Path +import json +import threading +from datetime import datetime class ChatBot: def __init__(self): self.tokenizer = None self.model = None self.device = None - #self.model_path = Path(__file__).parent / "../model_files" - self.model_path = "google/pegasus-xsum" + self.model_path = Path(__file__).parent / "../../model_files/pegasus" + self.chat_history_path = Path("../chat_data/chat_history.txt") + self._ensure_chat_directories() + + def _ensure_chat_directories(self): + # Checks if chat history already exists. If it doesn't it creates one. + self.chat_history_path.parent.mkdir(exist_ok=True) + if not self.chat_history_path.exists(): + self.chat_history_path.write_text("") + + def _save_chat_entry(self, entry_data): + # Save chat history in the background + try: + entry = { + 'timestamp': datetime.now().isoformat(), + 'data': entry_data, + 'type': 'chat_entry' + } + + # Write to file in background + def write_to_file(): + try: + with open(self.chat_history_path, 'a', encoding='utf-8') as f: + f.write(json.dumps(entry) + '\n') + except Exception as e: + print(f"Failed to write chat entry: {str(e)}") + + threading.Thread(target=write_to_file, daemon=True).start() + + except Exception as e: + print(f"Failed to save chat entry: {str(e)}") + + def load_chat_history(self, limit=None): + entries = [] + try: + with open(self.chat_history_path, 'r', + encoding='utf-8') as f: + for line in f: + if line.strip(): + try: + entry = json.loads(line.strip()) + if entry['type'] == 'chat_entry': + entries.append(entry) + except json.JSONDecodeError: + continue + + return entries + except Exception as e: + print(f"Failed to load chat history: {str(e)}") + return [] def _load_model(self): @@ -20,8 +71,6 @@ def _load_model(self): # else load selected model def get_response(self, user_input): - if not user_input.strip(): - return "Please enter a message." try: if self.model is None: @@ -70,9 +119,17 @@ def get_response(self, user_input): if not full_response: return "I apologize, but I couldn't generate a proper response. Please try rephrasing your input." - return " ".join(full_response) + response = " ".join(full_response) + + # Save the chat entry after successful response generation + self._save_chat_entry({ + 'user_input': user_input, + 'ai_response': response + }) + + return response except Exception as e: print(f"Error in get_response: {str(e)}") return f"An error occurred: {str(e)}" - + diff --git a/src/utils/chat_history.py b/src/utils/chat_history.py deleted file mode 100644 index ef9106bb..00000000 --- a/src/utils/chat_history.py +++ /dev/null @@ -1,56 +0,0 @@ -import json -import threading -from datetime import datetime -from pathlib import Path - -class SecureChatHistory: - def __init__(self): - self.chat_history_path = Path("../chat_data/chat_history.txt") - self._ensure_directories() - - def _ensure_directories(self): - # Checks if chat history already exists. If it doesn't it creates one. - self.chat_history_path.parent.mkdir(exist_ok=True) - if not self.chat_history_path.exists(): - self.chat_history_path.write_text("") - - def save_chat_entry(self, entry_data): - # Save chat history in the background - try: - entry = { - 'timestamp': datetime.now().isoformat(), - 'data': entry_data, - 'type': 'chat_entry' - } - - # Write to file in background - def write_to_file(): - try: - with open(self.chat_history_path, 'a', encoding='utf-8') as f: - f.write(json.dumps(entry) + '\n') - except Exception as e: - print(f"Failed to write chat entry: {str(e)}") - - threading.Thread(target=write_to_file, daemon=True).start() - - except Exception as e: - print(f"Failed to save chat entry: {str(e)}") - - def load_chat_history(self, limit=None): - entries = [] - try: - with open(self.chat_history_path, 'r', - encoding='utf-8') as f: - for line in f: - if line.strip(): - try: - entry = json.loads(line.strip()) - if entry['type'] == 'chat_entry': - entries.append(entry) - except json.JSONDecodeError: - continue - - return entries - except Exception as e: - print(f"Failed to load chat history: {str(e)}") - return [] \ No newline at end of file From 6f03c6451a7c0b153ed11514f44c0821ef08dac7 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Sun, 2 Feb 2025 00:21:53 -0700 Subject: [PATCH 22/31] Changes to LLM page --- src/components/llm_input.py | 20 +++++----- src/pages/llms_page.py | 77 ++++++++++++++++++++++++------------- src/utils/llm.py | 21 +++++----- 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/src/components/llm_input.py b/src/components/llm_input.py index 84f8e510..bb7f1b6b 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -45,8 +45,8 @@ def setup_input_area(self): self.input_text.bind("", self.default) self.input_text.bind("", self.default) - # "Upload" button canvas - self.upload_button = tk.Canvas( + # "Download" button canvas + self.download_button = tk.Canvas( self.container, width=100, height=60, @@ -54,21 +54,21 @@ def setup_input_area(self): highlightthickness=0, cursor="hand2" ) - self.upload_button.grid(row=1, column=0) + self.download_button.grid(row=1, column=0) - # Draw upload button + # Draw download button padding = 10 - self.rectangle = self.upload_button.create_rectangle( + self.rectangle = self.download_button.create_rectangle( padding, padding, 100, 50, fill="#0A84FF", outline="" ) - # Draw "Upload LLM" on upload button - self.upload_llm = self.upload_button.create_text( + # Draw "Download LLM" on download button + self.download_llm = self.download_button.create_text( 55, 30, - text="Upload LLM", + text="Download", fill="white", font=("SF Pro Text", 15), anchor="center" @@ -89,7 +89,7 @@ def setup_input_area(self): #self.output_text.pack(fill="both", expand=True, padx=20, pady=2) self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) - self.upload_button.bind("", self.handle_upload) + self.download_button.bind("", self.handle_download) def default(self, event): current = self.input_text.get("1.0", tk.END) @@ -98,7 +98,7 @@ def default(self, event): elif current == "\n": self.input_text.insert("1.0", "Type in model path here") - def handle_upload(self, event = None): + def handle_download(self, event = None): model_path = self.get_input() print ("Clicked!") if model_path: diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 2ac968a6..6f426cb8 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -1,5 +1,8 @@ +import sys import tkinter as tk +import io from tkinter import ttk +from subprocess import run from utils.llm import LLM from components import ( LLMInput, @@ -9,9 +12,11 @@ class LLMsPage: def __init__(self, root): + self.LLM = None self.root = root self.container = tk.Frame(self.root, bg="#D2E9FC") self.container.grid(row=1, column=1, sticky="nsew") + self.term_output = "" self.setup_page() @@ -24,14 +29,6 @@ def setup_page(self): self._initialize_components() self._setup_bindings() - """ Placeholder content - tk.Label( - self.container, - text="LLMs", - font=("SF Pro Display", 24, "bold"), - bg="#D2E9FC" - ).pack(pady=20) - """ def _configure_root(self): """Configure root window settings""" self.root.title("Assess.ai") @@ -56,7 +53,6 @@ def _initialize_components(self): self.LLMInput = LLMInput(self.container, self.send_path) self.LLMInput.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 5)) - def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) @@ -67,36 +63,63 @@ def send_path(self, model_path): # if message not empty self.disable_input(True) # disable text input - self.LLMInput.output_text.configure(state="normal") + self.disable_output(False) self.LLMInput.output_text.delete("1.0", tk.END) - #self.LLMInput.output_text.configure(state="disable") # connect to indicated LLM in Hugging Face try: - self.LLM = LLM(model_path) - except OSError as e: - #self.LLMInput.output_text.configure(state="normal") - self.LLMInput.output_text.insert("1.0", "Unsuccessful. Try again.") - self.LLMInput.output_text.configure(state="disable") + self.get_output(model_path) + self.LLM.download_LLM() # download model to file in directory + output = self.get_output(model_path) # get output from Hugging Face + self.LLMInput.output_text.insert("1.0", model_path + " was successfully downloaded! \n " + output) + + self.disable_output(True) self.disable_input(False) - print(f"Error handling LLM: {str(e)}") - else: - #self.LLMInput.output_text.configure(state="normal") - self.LLMInput.output_text.insert("1.0", "Successful!") - self.LLMInput.output_text.configure(state="disable") + except Exception as e: + self.LLMInput.output_text.insert("1.0", model_path + " could not be downloaded. Try again.") + self.disable_output(True) self.disable_input(False) - # if successfully connected, save in LLM.txt + print(f"Error handling LLM: {str(e)}") def disable_input(self, disable): - self.disable = disable - if disable == True: + if disable: self.LLMInput.input_text.configure(state="disable") - self.LLMInput.upload_button.configure(state="disable") + self.LLMInput.download_button.configure(state="disable") else: self.LLMInput.input_text.configure(state="normal") - self.LLMInput.upload_button.configure(state="normal") + self.LLMInput.download_button.configure(state="normal") + + def disable_output(self, disable): + if disable: + self.LLMInput.output_text.configure(state="disable") + else: + self.LLMInput.output_text.configure(state="normal") + + ### revisit this for later iterations + def get_output(self, model_path): + buffer = io.StringIO() + sys.stdout = buffer + sys.sterr = buffer + + try: + self.LLM = LLM(model_path) # create LLM + self.term_output = buffer.getvalue() + except Exception as e: + self.term_output = f"Error: {str(e)}" + + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + return self.term_output + + + + + + + + + - diff --git a/src/utils/llm.py b/src/utils/llm.py index be790cbf..1afa5d7b 100644 --- a/src/utils/llm.py +++ b/src/utils/llm.py @@ -1,16 +1,12 @@ -from transformers import AutoTokenizer, AutoModelForSequenceClassification +from transformers import AutoTokenizer, AutoModelForSeq2SeqLM class LLM: - def __init__(self, pathname): + def __init__(self, model_id): # load LLM from Hugging Face - self.model_name = pathname + self.model_id = model_id + self.tokenizer = AutoTokenizer.from_pretrained(model_id) + self.model = AutoModelForSeq2SeqLM.from_pretrained(model_id) - try: - self.tokenizer = AutoTokenizer.from_pretrained(pathname) - self.model = AutoModelForSequenceClassification.from_pretrained(pathname) - - except Exception as e: - print (f"Error handling LLM: {str(e)}") def tokenize (self, input_text): # tokenize text @@ -24,3 +20,10 @@ def detokenize(self, summarized_text): # detokenize text return self.tokenizer.batch_decode(summarized_text) + def download_LLM(self): + # save model and tokenizer + path_name = f"../model_files/{self.model_id.replace('/', '_')}" + self.model.save_pretrained ("../model_files/" + path_name) + self.tokenizer.save_pretrained("../model_files/" + path_name) + print (self.model_id + " was successfully downloaded!") + From 5810b6d3a57fad9f546740964182e3787ba71d79 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Sun, 2 Feb 2025 18:11:06 -0700 Subject: [PATCH 23/31] Displaying downloaded LLMs --- src/components/__init__.py | 1 + src/components/llm_input.py | 7 +++-- src/components/llm_list.py | 60 +++++++++++++++++++++++++++++++++++++ src/pages/llms_page.py | 12 +++++--- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 src/components/llm_list.py diff --git a/src/components/__init__.py b/src/components/__init__.py index 2bda4dbd..852858b7 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -7,3 +7,4 @@ from components.llm_input import LLMInput from components.evaluation_form import EvaluationForm from components.form import Form +from components.llm_list import LLMList \ No newline at end of file diff --git a/src/components/llm_input.py b/src/components/llm_input.py index bb7f1b6b..6ea31b87 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -26,8 +26,9 @@ def setup_input_area(self): # Output container frame with rounded corners self.output_container = RoundedFrame(self.container, "#FFFFFF", radius=50) - self.output_container.grid(row=2, column=0, sticky="ew", padx=20, pady=(0,2)) - self.input_container.grid_columnconfigure(0, weight=1) + self.output_container.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 10)) + self.output_container.grid_columnconfigure(0, weight=1) + # Text input area (to input LLM path) self.input_text = tk.Text( self.input_container, @@ -87,7 +88,7 @@ def setup_input_area(self): ) self.output_text.configure(state="disable") #self.output_text.pack(fill="both", expand=True, padx=20, pady=2) - self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=5) self.download_button.bind("", self.handle_download) diff --git a/src/components/llm_list.py b/src/components/llm_list.py new file mode 100644 index 00000000..47e392f0 --- /dev/null +++ b/src/components/llm_list.py @@ -0,0 +1,60 @@ +import tkinter as tk +import os +from components.rounded_frame import RoundedFrame + +class LLMList(tk.Frame): + def __init__(self, parent, **kwargs): + super().__init__(parent, bg="#D2E9FC", **kwargs) + self.setup_list_area() + + def setup_list_area(self): + self.grid_columnconfigure(0, weight=1) + + # Container frame for list + self.container = tk.Frame(self, bg="#D2E9FC") + self.container.grid(row=0, column=0, sticky="ew", padx=20, pady=15) + self.container.grid_columnconfigure(0, weight=1) + + # list container with rounded corners + self.list_container = RoundedFrame(self.container, "#FFFFFF", radius=50) + self.list_container.grid(row=0, column=0, sticky="ew", padx=(0, 10)) + self.list_container.grid_columnconfigure(0, weight=1) + + # LLM list area + self.list = tk.Listbox( + self.list_container, + height=20, + width=20, + font=("SF Pro Text", 14), + bd=0, + bg="white", + highlightthickness=0, + relief="flat" + ) + self.list.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + self.write_list(self.get_models()) + + + def get_models(self): + folder_list = [] + cwd = os.getcwd() # current directory + parent = os.path.dirname(cwd)# parent directory + model_dir = os.path.join(parent, "model_files") + # check if model_files + for folder in os.listdir(model_dir): # list all folders in model_files folder + if os.path.isdir(os.path.join(model_dir, folder)): + folder_list.append(folder) + return folder_list + + def write_list(self, list): + count = 0 + for i in list: + count += 1 + self.list.insert(count, i) + + + + + + + diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 6f426cb8..7b8a21b7 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -7,6 +7,7 @@ from components import ( LLMInput, TitleFrame, + LLMList ) @@ -17,12 +18,10 @@ def __init__(self, root): self.container = tk.Frame(self.root, bg="#D2E9FC") self.container.grid(row=1, column=1, sticky="nsew") self.term_output = "" - self.setup_page() def setup_page(self): """Initialize and set up the LLMs page """ - self._configure_root() self._configure_grid() self._setup_styles() @@ -38,6 +37,7 @@ def _configure_grid(self): """Configure grid layout""" self.container.grid_rowconfigure(0, weight=0) # Title self.container.grid_rowconfigure(1, weight=0) # LLM Input + self.container.grid_rowconfigure(2, weight=0) self.container.grid_columnconfigure(0, weight=1) def _setup_styles(self): @@ -53,6 +53,10 @@ def _initialize_components(self): self.LLMInput = LLMInput(self.container, self.send_path) self.LLMInput.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 5)) + # LLM list area + self.LLMList = LLMList(self.container) + self.LLMList.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 5)) + def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) @@ -68,6 +72,7 @@ def send_path(self, model_path): # connect to indicated LLM in Hugging Face try: + self.LLM = LLM(model_path) # create LLM self.get_output(model_path) self.LLM.download_LLM() # download model to file in directory output = self.get_output(model_path) # get output from Hugging Face @@ -100,10 +105,9 @@ def disable_output(self, disable): def get_output(self, model_path): buffer = io.StringIO() sys.stdout = buffer - sys.sterr = buffer + sys.stderr = buffer try: - self.LLM = LLM(model_path) # create LLM self.term_output = buffer.getvalue() except Exception as e: self.term_output = f"Error: {str(e)}" From a0610ba1835c74dcf76d954652a1a43adb5e15a1 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Mon, 3 Feb 2025 01:05:26 -0700 Subject: [PATCH 24/31] Multiple model upload feature for LLM Page --- src/components/llm_input.py | 7 ++++++ src/components/llm_list.py | 6 +++-- src/pages/llms_page.py | 5 +++- src/utils/llm.py | 46 ++++++++++++++++++++++++++++++------- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/components/llm_input.py b/src/components/llm_input.py index 6ea31b87..a8669ed6 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -40,7 +40,13 @@ def setup_input_area(self): highlightthickness=0, relief="flat" ) + """self.input_text = tk.Entry( + self.input_container, + + """ + self.input_text.tag_configure("center", justify='center') self.input_text.insert("1.0", "Type in model path here")# default text + self.input_text.tag_add("center", "1.0", "end") #self.input_text.pack(fill="both", expand=True, padx=20, pady=2) self.input_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) self.input_text.bind("", self.default) @@ -98,6 +104,7 @@ def default(self, event): self.input_text.delete("1.0", tk.END) elif current == "\n": self.input_text.insert("1.0", "Type in model path here") + self.input_text.tag_add("center", "1.0", "end") def handle_download(self, event = None): model_path = self.get_input() diff --git a/src/components/llm_list.py b/src/components/llm_list.py index 47e392f0..f1ee2957 100644 --- a/src/components/llm_list.py +++ b/src/components/llm_list.py @@ -47,10 +47,12 @@ def get_models(self): return folder_list def write_list(self, list): + self.list.delete(0,tk.END) # delete existing entries count = 0 - for i in list: + for model in list: count += 1 - self.list.insert(count, i) + self.list.insert(count, model) + diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 7b8a21b7..1762f110 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -73,7 +73,8 @@ def send_path(self, model_path): # connect to indicated LLM in Hugging Face try: self.LLM = LLM(model_path) # create LLM - self.get_output(model_path) + self.LLM.load_LLM() # load LLM + #self.get_output(model_path) self.LLM.download_LLM() # download model to file in directory output = self.get_output(model_path) # get output from Hugging Face self.LLMInput.output_text.insert("1.0", model_path + " was successfully downloaded! \n " + output) @@ -87,6 +88,8 @@ def send_path(self, model_path): self.disable_input(False) print(f"Error handling LLM: {str(e)}") + self.LLMList.write_list(self.LLMList.get_models()) + def disable_input(self, disable): if disable: self.LLMInput.input_text.configure(state="disable") diff --git a/src/utils/llm.py b/src/utils/llm.py index 1afa5d7b..f770e7ef 100644 --- a/src/utils/llm.py +++ b/src/utils/llm.py @@ -1,14 +1,22 @@ -from transformers import AutoTokenizer, AutoModelForSeq2SeqLM +from transformers import ( + AutoConfig, + AutoTokenizer, + AutoModelForSeq2SeqLM, + AutoModel, + AutoModelForCausalLM +) + class LLM: def __init__(self, model_id): - # load LLM from Hugging Face self.model_id = model_id - self.tokenizer = AutoTokenizer.from_pretrained(model_id) - self.model = AutoModelForSeq2SeqLM.from_pretrained(model_id) - + self.config = AutoConfig.from_pretrained(self.model_id) + self.model_type = self.config.model_type + self.model_class = None + self.tokenizer = None + self.model = None - def tokenize (self, input_text): + def tokenize(self, input_text): # tokenize text return self.tokenizer(input_text, padding=True, truncation=True, return_tensors="pt") @@ -23,7 +31,29 @@ def detokenize(self, summarized_text): def download_LLM(self): # save model and tokenizer path_name = f"../model_files/{self.model_id.replace('/', '_')}" - self.model.save_pretrained ("../model_files/" + path_name) + self.model.save_pretrained("../model_files/" + path_name) self.tokenizer.save_pretrained("../model_files/" + path_name) - print (self.model_id + " was successfully downloaded!") + print(self.model_id + " was successfully downloaded!") + + def load_LLM(self): + # check model type using AutoConfig + # dict of model classes + model_dict = { + "seq2seq": AutoModelForSeq2SeqLM, + "causallm": AutoModelForCausalLM, + "automodel": AutoModel + } + + # determine which model class -> which model type + if self.model_type in ["pegasus, pegasus_x", "t5", "mt5", "mbart"]: # models = AutoModelForSeq2SeqLM + self.model_class = model_dict["seq2seq"] + elif self.model_type in ["gpt2"]: + self.model_class = model_dict["causallm"] + elif self.model_type in ["bart"]: + self.model_class = model_dict["automodel"] + else: + self.model_class = model_dict["automodel"] # generic model class + # load model with correct configurations + self.tokenizer = AutoTokenizer.from_pretrained(self.model_id) + self.model = self.model_class.from_pretrained(self.model_id) \ No newline at end of file From fc21db4dbfd2567b70ebe0e3129fd781e0d2d915 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Mon, 3 Feb 2025 11:25:18 -0700 Subject: [PATCH 25/31] Change Download to Import for button --- src/components/llm_input.py | 20 ++++++++++---------- src/pages/llms_page.py | 10 +++++----- src/utils/llm.py | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/llm_input.py b/src/components/llm_input.py index a8669ed6..c72aafe9 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -52,8 +52,8 @@ def setup_input_area(self): self.input_text.bind("", self.default) self.input_text.bind("", self.default) - # "Download" button canvas - self.download_button = tk.Canvas( + # "Import" button canvas + self.import_button = tk.Canvas( self.container, width=100, height=60, @@ -61,21 +61,21 @@ def setup_input_area(self): highlightthickness=0, cursor="hand2" ) - self.download_button.grid(row=1, column=0) + self.import_button.grid(row=1, column=0) - # Draw download button + # Draw import button padding = 10 - self.rectangle = self.download_button.create_rectangle( + self.rectangle = self.import_button.create_rectangle( padding, padding, 100, 50, fill="#0A84FF", outline="" ) - # Draw "Download LLM" on download button - self.download_llm = self.download_button.create_text( + # Draw "Import" on import button + self.import_llm = self.import_button.create_text( 55, 30, - text="Download", + text="Import", fill="white", font=("SF Pro Text", 15), anchor="center" @@ -96,7 +96,7 @@ def setup_input_area(self): #self.output_text.pack(fill="both", expand=True, padx=20, pady=2) self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=5) - self.download_button.bind("", self.handle_download) + self.import_button.bind("", self.handle_import) def default(self, event): current = self.input_text.get("1.0", tk.END) @@ -106,7 +106,7 @@ def default(self, event): self.input_text.insert("1.0", "Type in model path here") self.input_text.tag_add("center", "1.0", "end") - def handle_download(self, event = None): + def handle_import(self, event = None): model_path = self.get_input() print ("Clicked!") if model_path: diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 1762f110..a3c0e7bc 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -75,15 +75,15 @@ def send_path(self, model_path): self.LLM = LLM(model_path) # create LLM self.LLM.load_LLM() # load LLM #self.get_output(model_path) - self.LLM.download_LLM() # download model to file in directory + self.LLM.import_LLM() # import model to file in directory output = self.get_output(model_path) # get output from Hugging Face - self.LLMInput.output_text.insert("1.0", model_path + " was successfully downloaded! \n " + output) + self.LLMInput.output_text.insert("1.0", model_path + " was successfully imported! \n " + output) self.disable_output(True) self.disable_input(False) except Exception as e: - self.LLMInput.output_text.insert("1.0", model_path + " could not be downloaded. Try again.") + self.LLMInput.output_text.insert("1.0", model_path + " could not be imported. Try again.") self.disable_output(True) self.disable_input(False) print(f"Error handling LLM: {str(e)}") @@ -93,10 +93,10 @@ def send_path(self, model_path): def disable_input(self, disable): if disable: self.LLMInput.input_text.configure(state="disable") - self.LLMInput.download_button.configure(state="disable") + self.LLMInput.import_button.configure(state="disable") else: self.LLMInput.input_text.configure(state="normal") - self.LLMInput.download_button.configure(state="normal") + self.LLMInput.import_button.configure(state="normal") def disable_output(self, disable): if disable: diff --git a/src/utils/llm.py b/src/utils/llm.py index f770e7ef..3c7f81ed 100644 --- a/src/utils/llm.py +++ b/src/utils/llm.py @@ -28,12 +28,12 @@ def detokenize(self, summarized_text): # detokenize text return self.tokenizer.batch_decode(summarized_text) - def download_LLM(self): + def import_LLM(self): # save model and tokenizer path_name = f"../model_files/{self.model_id.replace('/', '_')}" self.model.save_pretrained("../model_files/" + path_name) self.tokenizer.save_pretrained("../model_files/" + path_name) - print(self.model_id + " was successfully downloaded!") + print(self.model_id + " was successfully imported!") def load_LLM(self): # check model type using AutoConfig From 81e1b1352d1ce6228deeece3b60c01374533d166 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Thu, 6 Feb 2025 00:26:03 -0700 Subject: [PATCH 26/31] Updates to LLM Page UI --- src/components/__init__.py | 6 +- src/components/chat_area.py | 68 ------ src/components/chat_bubble.py | 47 ----- .../dataset_list.py} | 0 src/components/llm_details.py | 0 src/components/llm_input.py | 143 ++++++++++--- src/components/llm_list.py | 56 +++-- src/components/loading_indicator.py | 31 --- src/pages/chat_page.py | 196 ------------------ src/pages/datasets_page.py | 0 src/pages/indiv_page.py | 0 src/pages/llms_page.py | 63 +++--- 12 files changed, 191 insertions(+), 419 deletions(-) delete mode 100644 src/components/chat_area.py delete mode 100644 src/components/chat_bubble.py rename src/{utils/llm_history.py => components/dataset_list.py} (100%) create mode 100644 src/components/llm_details.py delete mode 100644 src/components/loading_indicator.py delete mode 100644 src/pages/chat_page.py create mode 100644 src/pages/datasets_page.py create mode 100644 src/pages/indiv_page.py diff --git a/src/components/__init__.py b/src/components/__init__.py index 852858b7..fe5524b5 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -1,10 +1,8 @@ from components.rounded_frame import RoundedFrame -from components.chat_bubble import ChatBubble from components.title_frame import TitleFrame from components.input_frame import InputFrame -from components.chat_area import ChatArea -from components.loading_indicator import LoadingIndicator from components.llm_input import LLMInput from components.evaluation_form import EvaluationForm from components.form import Form -from components.llm_list import LLMList \ No newline at end of file +from components.llm_list import LLMList +from components.dataset_list import DatasetList \ No newline at end of file diff --git a/src/components/chat_area.py b/src/components/chat_area.py deleted file mode 100644 index a957cd33..00000000 --- a/src/components/chat_area.py +++ /dev/null @@ -1,68 +0,0 @@ -import tkinter as tk -from tkinter import ttk - -class ChatArea: - """Manages the scrollable chat area of the application""" - def __init__(self, parent): - self.canvas = tk.Canvas( - parent, - bg="#D2E9FC", - highlightthickness=0, - bd=0 - ) - - self.scrollable_frame = tk.Frame( - self.canvas, - bg="#D2E9FC", - bd=0, - highlightthickness=0 - ) - - self.scrollbar = ttk.Scrollbar( - parent, - orient="vertical", - command=self.canvas.yview - ) - - self._setup_canvas() - self._configure_scrolling() - - def _setup_canvas(self): - """Configure canvas and scrollbar""" - self.canvas.configure(yscrollcommand=self.scrollbar.set) - self.canvas_frame = self.canvas.create_window( - (0, 0), - window=self.scrollable_frame, - anchor="nw", - width=self.canvas.winfo_reqwidth() - ) - - def _configure_scrolling(self): - """Setup scrolling behavior""" - self.scrollable_frame.bind( - "", - lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) - ) - self.canvas.bind( - "", - lambda e: self.canvas.itemconfig(self.canvas_frame, width=e.width) - ) - - def grid_components(self): - """Position components in the grid""" - self.canvas.grid(row=1, column=0, sticky="nsew", padx=20, pady=(0, 5)) - self.scrollbar.grid(row=1, column=1, sticky="ns") - - def smooth_scroll_to_bottom(self, steps=10): - """Smoothly scroll chat area to bottom""" - current = self.canvas.yview()[1] - target = 1.0 - if current < target: - step_size = (target - current) / steps - def step(count): - if count < steps: - self.canvas.yview_moveto(current + (step_size * count)) - self.canvas.after(10, lambda: step(count + 1)) - else: - self.canvas.yview_moveto(target) - step(1) \ No newline at end of file diff --git a/src/components/chat_bubble.py b/src/components/chat_bubble.py deleted file mode 100644 index a05891b5..00000000 --- a/src/components/chat_bubble.py +++ /dev/null @@ -1,47 +0,0 @@ -import tkinter as tk - -class ChatBubble(tk.Frame): - def __init__(self, parent, text, is_user=True, **kwargs): - super().__init__(parent, bg="#D2E9FC", **kwargs) - - # Configure grid to allow message expansion - self.grid_columnconfigure(0 if not is_user else 1, weight=1) - - # Calculate dynamic wraplength based on parent width - parent_width = parent.winfo_width() - wrap_width = min(int(parent_width * 0.7), 800) # Max 70% of parent width, up to 800px - - # Message container frame - msg_frame = tk.Frame( - self, - bg="#D2E9FC" - ) - msg_frame.grid( - row=0, - column=1 if is_user else 0, - sticky="e" if is_user else "w", - padx=(50 if is_user else 10, 10 if is_user else 50) - ) - - self.message = tk.Label( - msg_frame, - text=text, - wraplength=wrap_width, - justify=tk.LEFT, - bg="#0A84FF" if is_user else "#E9E9EB", - fg="white" if is_user else "black", - font=("SF Pro Text", 12), - padx=15, - pady=10, - anchor="w" # Left-align text - ) - self.message.pack(expand=True, fill="both") - - # Bind to window resize to update wraplength - self.bind('', self._on_resize) - - - def _on_resize(self, event): - # Update wraplength when window is resized - new_width = min(int(self.winfo_width() * 0.7), 800) - self.message.configure(wraplength=new_width) \ No newline at end of file diff --git a/src/utils/llm_history.py b/src/components/dataset_list.py similarity index 100% rename from src/utils/llm_history.py rename to src/components/dataset_list.py diff --git a/src/components/llm_details.py b/src/components/llm_details.py new file mode 100644 index 00000000..e69de29b diff --git a/src/components/llm_input.py b/src/components/llm_input.py index c72aafe9..afbc34be 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -1,38 +1,75 @@ import tkinter as tk from components.rounded_frame import RoundedFrame +from components.llm_list import LLMList class LLMInput(tk.Frame): - def __init__(self, parent, send_callback, **kwargs): + def __init__(self, parent, root, send_callback, **kwargs): super().__init__(parent, bg="#D2E9FC", **kwargs) self.send_callback = send_callback + self.root = root self.setup_input_area() def setup_input_area(self): self.grid_columnconfigure(0, weight=1) - # Container frame for input, button, and output + # Container frame for title, input, button, and output self.container = tk.Frame(self, bg="#D2E9FC") - self.container.grid(row=0, column=0, sticky="ew", padx=20, pady=15) - self.container.grid_columnconfigure(0, weight=1) + self.container.grid(row=0, column=0, sticky="nsew", padx=0, pady=15) + self.container.grid_rowconfigure(0, weight=1) + self.container.grid_columnconfigure(0, weight=1) # for input frame + self.container.grid_columnconfigure(1, weight=0) # for output frame + + # Input + button container + self.inputbutt_frame = tk.Frame(self.container, bg="#D2E9FC") + self.inputbutt_frame.grid(row=1, column=0, sticky="ew", padx=(5,10), pady=(20,5)) + self.inputbutt_frame.grid_columnconfigure(0, weight=1) + self.inputbutt_frame.grid_columnconfigure(1, weight=0) # Input container with rounded corners - self.input_container = RoundedFrame(self.container, "#FFFFFF", radius=50) - self.input_container.grid(row=0, column=0, sticky="ew", padx=(0, 10)) + self.input_container = RoundedFrame(self.inputbutt_frame, "#FFFFFF", radius=50) + self.input_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(20,10)) self.input_container.grid_columnconfigure(0, weight=1) - # Button container frame for button with rounded corners - #self.button_container = RoundedFrame(self.container, "#FFFFFF", radius=50) - #self.button_container.grid(row=1, column=0, sticky="ew", padx=20, pady=(0,2)) + # Output + LLM list container + self.outputlist_frame = tk.Frame(self.container, bg="#D2E9FC") + self.outputlist_frame.grid(row=2, column=0, sticky="ew", padx=(5,10), pady=(20,5)) + self.outputlist_frame.grid_rowconfigure(0, weight=1) + self.outputlist_frame.grid_rowconfigure(1, weight=1) + self.outputlist_frame.columnconfigure(0, weight=1) # for output box + self.outputlist_frame.columnconfigure(1, weight=1) # for list box # Output container frame with rounded corners - self.output_container = RoundedFrame(self.container, "#FFFFFF", radius=50) - self.output_container.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 10)) + self.output_container = RoundedFrame(self.outputlist_frame, "#FFFFFF", radius=50) + self.output_container.grid(row=1, column=0, sticky="nsew", padx=(20, 5), pady=10) + self.output_container.grid_rowconfigure(0, weight=1) self.output_container.grid_columnconfigure(0, weight=1) + # LLM list area + self.LLMList = LLMList(self.outputlist_frame, self.root) + self.LLMList.grid(row=1, column=1, sticky="nsew", padx=(10, 20), pady=10) + self.LLMList.grid_rowconfigure(0, weight=1) + + # "Import LLM" string variable + string = tk.StringVar() + string.set("Import LLM") + + # "Import LLM" area + self.import_label = tk.Label( + self.container, + textvariable=string, + bg="#D2E9FC", + font=("SF Pro Text", 40, "bold"), + fg="black", + anchor="w", + justify="left" + ) + self.import_label.grid(row=0, column=0, sticky="w", padx=20, pady=(20, 10)) + # Text input area (to input LLM path) self.input_text = tk.Text( self.input_container, - height=3, + height=1, + width=65, font=("SF Pro Text", 14), wrap=tk.WORD, bd=0, @@ -40,28 +77,25 @@ def setup_input_area(self): highlightthickness=0, relief="flat" ) - """self.input_text = tk.Entry( - self.input_container, - - """ + self.input_text.tag_configure("center", justify='center') - self.input_text.insert("1.0", "Type in model path here")# default text + self.input_text.insert("1.0", "Insert model path here") # default text self.input_text.tag_add("center", "1.0", "end") - #self.input_text.pack(fill="both", expand=True, padx=20, pady=2) - self.input_text.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + self.input_text.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) + self.input_container.grid_rowconfigure(0, weight=1) self.input_text.bind("", self.default) self.input_text.bind("", self.default) # "Import" button canvas self.import_button = tk.Canvas( - self.container, + self.inputbutt_frame, width=100, height=60, bg="#D2E9FC", highlightthickness=0, cursor="hand2" ) - self.import_button.grid(row=1, column=0) + self.import_button.grid(row=0, column=1, sticky="e", padx=(2,10), pady=20) # Draw import button padding = 10 @@ -81,10 +115,43 @@ def setup_input_area(self): anchor="center" ) + # "Import Status" string variable + string = tk.StringVar() + string.set("Import Status") + + # "Import LLM" area + self.import_status = tk.Label( + self.outputlist_frame, + textvariable=string, + bg="#D2E9FC", + font=("SF Pro Text", 20), + fg="black", + anchor="w", + justify="left" + ) + self.import_status.grid(row=0, column=0, sticky="w", padx=20, pady=(5, 5)) + + # "LLMs" string variable + string = tk.StringVar() + string.set("LLMs") + + # "LLMs" area + self.llm_list = tk.Label( + self.outputlist_frame, + textvariable=string, + bg="#D2E9FC", + font=("SF Pro Text", 20), + fg="black", + anchor="w", + justify="left" + ) + self.llm_list.grid(row=0, column=1, sticky="w", padx=20, pady=(5, 5)) + # Draw LLM status area self.output_text = tk.Text( self.output_container, height=5, + width=30, font=("SF Pro Text", 14), wrap=tk.WORD, bd=0, @@ -93,25 +160,47 @@ def setup_input_area(self): relief="flat" ) self.output_text.configure(state="disable") - #self.output_text.pack(fill="both", expand=True, padx=20, pady=2) - self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=5) + self.output_text.grid(row=0, column=0, sticky="ew", padx=20, pady=(10, 10)) self.import_button.bind("", self.handle_import) def default(self, event): current = self.input_text.get("1.0", tk.END) - if current == "Type in model path here\n": + if current == "Insert model path here\n": self.input_text.delete("1.0", tk.END) elif current == "\n": - self.input_text.insert("1.0", "Type in model path here") + self.input_text.insert("1.0", "Insert model path here") self.input_text.tag_add("center", "1.0", "end") def handle_import(self, event = None): + self.disable_output(False) + self.disable_input(True) + model_path = self.get_input() print ("Clicked!") - if model_path: + if model_path == "Insert model path here": + self.output_text.insert ("1.0", "Please insert model path") + return + elif model_path: self.clear_input() self.send_callback(model_path) + else: + self.LLMInput.output_text.insert("1.0", "Please insert model path") + return + + def disable_input(self, disable): + if disable: + self.input_text.configure(state="disable") + self.import_button.configure(state="disable") + else: + self.input_text.configure(state="normal") + self.import_button.configure(state="normal") + + def disable_output(self, disable): + if disable: + self.output_text.configure(state="disable") + else: + self.output_text.configure(state="normal") def get_input(self): return self.input_text.get("1.0", "end-1c").strip() @@ -124,3 +213,5 @@ def clear_input(self): + + diff --git a/src/components/llm_list.py b/src/components/llm_list.py index f1ee2957..947d4a18 100644 --- a/src/components/llm_list.py +++ b/src/components/llm_list.py @@ -3,38 +3,61 @@ from components.rounded_frame import RoundedFrame class LLMList(tk.Frame): - def __init__(self, parent, **kwargs): + def __init__(self, parent, root, **kwargs): super().__init__(parent, bg="#D2E9FC", **kwargs) + self.root = root + self.root = root self.setup_list_area() def setup_list_area(self): self.grid_columnconfigure(0, weight=1) - # Container frame for list - self.container = tk.Frame(self, bg="#D2E9FC") - self.container.grid(row=0, column=0, sticky="ew", padx=20, pady=15) - self.container.grid_columnconfigure(0, weight=1) - # list container with rounded corners - self.list_container = RoundedFrame(self.container, "#FFFFFF", radius=50) - self.list_container.grid(row=0, column=0, sticky="ew", padx=(0, 10)) + self.list_container = RoundedFrame(self, "#FFFFFF", radius=50) + self.list_container.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=(5,5)) self.list_container.grid_columnconfigure(0, weight=1) + self.list_container.grid_rowconfigure(0, weight=1) + + # Frame for LLM list + scrollbar + self.scrolllist_frame = tk.Frame(self.list_container, bg = "white") + self.scrolllist_frame.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) + self.scrolllist_frame.grid_columnconfigure(0, weight=1) + self.scrolllist_frame.grid_rowconfigure(0, weight=1) # LLM list area self.list = tk.Listbox( - self.list_container, - height=20, - width=20, + self.scrolllist_frame, + height=15, + width=30, font=("SF Pro Text", 14), bd=0, bg="white", highlightthickness=0, relief="flat" ) - self.list.grid(row=0, column=0, sticky="ew", padx=20, pady=2) + self.list.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # LLM list scrollbar + self.list_scrollbar = tk.Scrollbar(self.scrolllist_frame, orient="vertical", command=self.list.yview) + self.list_scrollbar.grid(row=0, column=1, sticky="ns") + + self.list.configure(yscrollcommand=self.list_scrollbar.set) + + # scroll binding + #self.list_frame.bind("", self.update_scroll) + self.list.bind("", self.mouse_scroll)# bind to mouse + # selection bind + self.list.bind("", self.model_selected) + self.write_list(self.get_models()) + def model_selected(self, event): + selected_model = self.list.curselection() + if selected_model: + model_path = self.list.get(selected_model) + self.root.show_page("model", model_path) + def get_models(self): folder_list = [] cwd = os.getcwd() # current directory @@ -53,7 +76,14 @@ def write_list(self, list): count += 1 self.list.insert(count, model) - + def mouse_scroll(self, event): + if event.delta: + self.list.yview_scroll(-1 * (event.delta // 120), "units") + elif event.num ==4: + self.list.yview_scroll(-1, "units") + elif event.num ==5: + self.list.yview_scroll(1, "units") + return "break" diff --git a/src/components/loading_indicator.py b/src/components/loading_indicator.py deleted file mode 100644 index cee8bacb..00000000 --- a/src/components/loading_indicator.py +++ /dev/null @@ -1,31 +0,0 @@ -from components.chat_bubble import ChatBubble - -class LoadingIndicator: - """Manages the loading animation state""" - def __init__(self, parent, root): - self.parent = parent - self.root = root - self.bubble = None - self.after_id = None - - def start(self): - """Start loading animation""" - self.bubble = ChatBubble(self.parent, "", is_user=False) - self.bubble.pack(anchor="w", padx=20, pady=5, fill="x") - self._animate() - - def stop(self): - """Stop loading animation and cleanup""" - if self.bubble: - self.bubble.destroy() - self.bubble = None - if self.after_id: - self.root.after_cancel(self.after_id) - self.after_id = None - - def _animate(self, dots=0): - """Animate the loading dots""" - if self.bubble: - dots = (dots % 3) + 1 - self.bubble.message.configure(text="Thinking" + "." * dots) - self.after_id = self.root.after(500, self._animate, dots) \ No newline at end of file diff --git a/src/pages/chat_page.py b/src/pages/chat_page.py deleted file mode 100644 index 1fedbdbb..00000000 --- a/src/pages/chat_page.py +++ /dev/null @@ -1,196 +0,0 @@ -import tkinter as tk -from tkinter import ttk -import threading -from tkinter import messagebox -from components import ( - ChatBubble, - InputFrame, - TitleFrame, - ChatArea, - LoadingIndicator -) -from utils.chat import ChatBot - -class ChatPage: - def __init__(self, root): - self.root = root - self.chatbot = ChatBot() - self.is_processing = False - - # Create main container for chat page - self.container = tk.Frame(root, bg="#D2E9FC") - self.container.grid(row=1, column=1, sticky="nsew") - - self.setup_screen() - - def setup_screen(self): - self._configure_root() - self._setup_styles() - self._configure_grid() - self._initialize_components() - self._setup_bindings() - # Load chat history after GUI is ready (100ms delay) - threading.Thread(target=self.chatbot._load_model, daemon=True).start() - self.root.after(100, self.load_chat_history) - - def _configure_root(self): - self.root.title("Assess.ai") - self.root.configure(bg="#D2E9FC") - - def _setup_styles(self): - style = ttk.Style() - style.configure("Chat.TFrame", background="#D2E9FC") - style.configure("Round.TLabel", background="#D2E9FC") - - def _configure_grid(self): - self.container.grid_rowconfigure(0, weight=0) # Title - self.container.grid_rowconfigure(1, weight=1) # Chat - self.container.grid_rowconfigure(2, weight=0) # Input - self.container.grid_columnconfigure(0, weight=1) - - def _initialize_components(self): - # Title - self.title_frame = TitleFrame(self.container) - self.title_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5)) - - # Chat area - self.chat_area = ChatArea(self.container) - self.chat_area.grid_components() - - # Loading indicator - self.loading = LoadingIndicator(self.chat_area.scrollable_frame, self.root) - - # Input area - self.input_frame = InputFrame(self.container, self.send_message) - self.input_frame.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 10)) - - def _setup_bindings(self): - self.root.bind('', lambda e: self.root.destroy()) - - def set_processing_state(self, is_processing): - self.is_processing = is_processing - self.root.after(0, self._update_input_state) - - def _update_input_state(self): - if hasattr(self.input_frame, 'input_field'): - self.input_frame.input_field.configure(state='disabled' if self.is_processing else 'normal') - if hasattr(self.input_frame, 'send_button'): - self.input_frame.send_button.configure(state='disabled' if self.is_processing else 'normal') - - def send_message(self, message): - if not message.strip() or self.is_processing: # Check processing state - return - - try: - # Add user message to chat - current_scroll = self.chat_area.canvas.yview()[0] - self.add_message(message, is_user=True) - self.root.update() - self.loading.start() - self.set_processing_state(True) # Disable input while processing - - # Start AI response thread - self._get_ai_response_threaded(message) - - except Exception as e: - print(f"Error sending message: {str(e)}") - self.set_processing_state(False) # Fixed: was True before - - def _get_ai_response_threaded(self, user_input): - def get_response(): - try: - response = self.chatbot.get_response(user_input) - self.root.after(0, self._handle_ai_response, response) - except Exception as e: - self.root.after(0, self._handle_ai_error, f"Error: {str(e)}") - - threading.Thread(target=get_response, daemon=True).start() - - def _handle_ai_response(self, response): - try: - self.loading.stop() - self.add_message(response, is_user=False) - # After adding AI response, ensure we scroll to bottom - self.root.after(100, self.chat_area.smooth_scroll_to_bottom) - self.set_processing_state(False) # Re-enable input after response - except Exception as e: - print(f"Error handling response: {str(e)}") - - def _handle_ai_error(self, error_msg): - self.loading.stop() - self.add_message(error_msg, is_user=False) - self.set_processing_state(False) # Re-enable input on error - - def add_message(self, text, is_user=True): - bubble = ChatBubble(self.chat_area.scrollable_frame, text, is_user=is_user) - bubble.pack( - anchor="e" if is_user else "w", # Right align for user, left for AI - padx=20, - pady=5, - fill="x" - ) - # Update the scroll region to include new message - self.chat_area.canvas.update_idletasks() - self.chat_area.canvas.configure(scrollregion=self.chat_area.canvas.bbox("all")) - - self.chat_area.smooth_scroll_to_bottom() - - def load_chat_history(self): - try: - entries = self.chatbot.load_chat_history() - for entry in entries: - chat_data = entry['data'] - self.add_message(chat_data['user_input'], is_user=True) - self.add_message(chat_data['ai_response'], is_user=False) - except Exception as e: - print(f"Error loading chat history: {str(e)}") - - self.root.after(50, lambda: self.chat_area.canvas.yview_moveto(5.0)) - - -""" -We use threading in this app to keep app responsive while the LLM response is being returned. -Before without threading, the app would pause and not be responsive until the LLM response comes. -By using threading we allow the UI to work while doing other tasks. - -2 threads: Main Thread (UI), AI Response threads (Created when needing to get response). - -# THREAD FLOW DIAGRAM -# -# Main UI Thread AI Response Thread -# (tkinter) (spawned as needed) -# ================ ================== -# | | -# | | -# User types... | -# | | -# [Send clicked] | -# | | -# send_message() | -# | | -# |---> Add user message | -# | to chat | -# | | -# |---> Start AI Thread ------------->| -# | | -# | [Process AI response] -# [UI remains | -# responsive] [AI response ready] -# | | -# |<---------------------------------| -# | root.after() | -# [Show AI reply] (schedule UI | -# | update) | -# | | -# -# Notes: -# - Main UI Thread: Never blocks, handles all UI updates -# - AI Thread: Created for each response, dies after completion -# -# Key Points: -# 1. UI never freezes because heavy processing is offloaded -# 2. Direct message handling without queues -# 3. root.after() safely schedules UI updates from other threads -# 4. Due to GIL, only one Python thread runs at a time -# (but switches fast enough to appear concurrent) -""" \ No newline at end of file diff --git a/src/pages/datasets_page.py b/src/pages/datasets_page.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/indiv_page.py b/src/pages/indiv_page.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index a3c0e7bc..0836ec2c 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -2,7 +2,6 @@ import tkinter as tk import io from tkinter import ttk -from subprocess import run from utils.llm import LLM from components import ( LLMInput, @@ -15,11 +14,24 @@ class LLMsPage: def __init__(self, root): self.LLM = None self.root = root + self.container = tk.Frame(self.root, bg="#D2E9FC") self.container.grid(row=1, column=1, sticky="nsew") - self.term_output = "" self.setup_page() + """ + # scrollbar logistics + self.canvas = tk.Canvas(self.root, bg="#D2E9FC") + self.canvas.grid(row=1, column=1, sticky="nsew") + self.create_scrollbar() + self.canvas.configure(yscrollcommand = self.scrollbar.set) + + + + self.container.update_idletasks() + self.canvas.config(scrollregion=self.canvas.bbox("all")) + """ + def setup_page(self): """Initialize and set up the LLMs page """ self._configure_root() @@ -37,7 +49,6 @@ def _configure_grid(self): """Configure grid layout""" self.container.grid_rowconfigure(0, weight=0) # Title self.container.grid_rowconfigure(1, weight=0) # LLM Input - self.container.grid_rowconfigure(2, weight=0) self.container.grid_columnconfigure(0, weight=1) def _setup_styles(self): @@ -50,24 +61,14 @@ def _initialize_components(self): self.title_frame.grid(row=0, column=0, sticky="ew", pady=(0,50)) # LLM input area - self.LLMInput = LLMInput(self.container, self.send_path) + self.LLMInput = LLMInput(self.container, self.root, self.send_path) self.LLMInput.grid(row=1, column=0, sticky="ew", padx=20, pady=(0, 5)) - # LLM list area - self.LLMList = LLMList(self.container) - self.LLMList.grid(row=2, column=0, sticky="ew", padx=20, pady=(0, 5)) - def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) def send_path(self, model_path): - # if message is empty - if not model_path.strip(): - return - - # if message not empty - self.disable_input(True) # disable text input - self.disable_output(False) + self.LLMInput.disable_input(True) # disable text input self.LLMInput.output_text.delete("1.0", tk.END) # connect to indicated LLM in Hugging Face @@ -78,31 +79,17 @@ def send_path(self, model_path): self.LLM.import_LLM() # import model to file in directory output = self.get_output(model_path) # get output from Hugging Face self.LLMInput.output_text.insert("1.0", model_path + " was successfully imported! \n " + output) - - self.disable_output(True) - self.disable_input(False) + self.LLMInput.disable_input(False) + self.LLMInput.disable_output(True) except Exception as e: self.LLMInput.output_text.insert("1.0", model_path + " could not be imported. Try again.") - self.disable_output(True) - self.disable_input(False) + self.LLMInput.disable_input(False) + self.LLMInput.disable_output(True) print(f"Error handling LLM: {str(e)}") - self.LLMList.write_list(self.LLMList.get_models()) - - def disable_input(self, disable): - if disable: - self.LLMInput.input_text.configure(state="disable") - self.LLMInput.import_button.configure(state="disable") - else: - self.LLMInput.input_text.configure(state="normal") - self.LLMInput.import_button.configure(state="normal") + self.LLMInput.LLMList.write_list(self.LLMInput.LLMList.get_models()) - def disable_output(self, disable): - if disable: - self.LLMInput.output_text.configure(state="disable") - else: - self.LLMInput.output_text.configure(state="normal") ### revisit this for later iterations def get_output(self, model_path): @@ -119,6 +106,14 @@ def get_output(self, model_path): sys.stderr = sys.__stderr__ return self.term_output + def create_scrollbar(self): + # create scrollbar + self.scrollbar = tk.Scrollbar(self.root, orient="vertical") + self.scrollbar.grid(row=0, column=1, sticky="ns") + self.scrollbar.config(command=self.canvas.yview) + + + From 414a5abd234c7bd23512501dad212f91d90c9bec Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:12:15 -0700 Subject: [PATCH 27/31] Updates to LLM UI --- src/components/llm_details.py | 0 src/components/llm_input.py | 40 +++++++++----------- src/components/llm_list.py | 36 ++++++++---------- src/pages/indiv_page.py | 70 +++++++++++++++++++++++++++++++++++ src/pages/llms_page.py | 2 +- 5 files changed, 104 insertions(+), 44 deletions(-) delete mode 100644 src/components/llm_details.py diff --git a/src/components/llm_details.py b/src/components/llm_details.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/llm_input.py b/src/components/llm_input.py index afbc34be..33298149 100644 --- a/src/components/llm_input.py +++ b/src/components/llm_input.py @@ -2,6 +2,7 @@ from components.rounded_frame import RoundedFrame from components.llm_list import LLMList + class LLMInput(tk.Frame): def __init__(self, parent, root, send_callback, **kwargs): super().__init__(parent, bg="#D2E9FC", **kwargs) @@ -16,27 +17,27 @@ def setup_input_area(self): self.container = tk.Frame(self, bg="#D2E9FC") self.container.grid(row=0, column=0, sticky="nsew", padx=0, pady=15) self.container.grid_rowconfigure(0, weight=1) - self.container.grid_columnconfigure(0, weight=1) # for input frame - self.container.grid_columnconfigure(1, weight=0) # for output frame + self.container.grid_columnconfigure(0, weight=1) # for input frame + self.container.grid_columnconfigure(1, weight=0) # for output frame # Input + button container self.inputbutt_frame = tk.Frame(self.container, bg="#D2E9FC") - self.inputbutt_frame.grid(row=1, column=0, sticky="ew", padx=(5,10), pady=(20,5)) + self.inputbutt_frame.grid(row=1, column=0, sticky="ew", padx=(5, 10), pady=(20, 5)) self.inputbutt_frame.grid_columnconfigure(0, weight=1) self.inputbutt_frame.grid_columnconfigure(1, weight=0) # Input container with rounded corners self.input_container = RoundedFrame(self.inputbutt_frame, "#FFFFFF", radius=50) - self.input_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(20,10)) + self.input_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=(20, 10)) self.input_container.grid_columnconfigure(0, weight=1) # Output + LLM list container self.outputlist_frame = tk.Frame(self.container, bg="#D2E9FC") - self.outputlist_frame.grid(row=2, column=0, sticky="ew", padx=(5,10), pady=(20,5)) + self.outputlist_frame.grid(row=2, column=0, sticky="ew", padx=(5, 10), pady=(20, 5)) self.outputlist_frame.grid_rowconfigure(0, weight=1) self.outputlist_frame.grid_rowconfigure(1, weight=1) - self.outputlist_frame.columnconfigure(0, weight=1) # for output box - self.outputlist_frame.columnconfigure(1, weight=1) # for list box + self.outputlist_frame.columnconfigure(0, weight=1) # for output box + self.outputlist_frame.columnconfigure(1, weight=1) # for list box # Output container frame with rounded corners self.output_container = RoundedFrame(self.outputlist_frame, "#FFFFFF", radius=50) @@ -79,7 +80,7 @@ def setup_input_area(self): ) self.input_text.tag_configure("center", justify='center') - self.input_text.insert("1.0", "Insert model path here") # default text + self.input_text.insert("1.0", "Insert model path here") # default text self.input_text.tag_add("center", "1.0", "end") self.input_text.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) self.input_container.grid_rowconfigure(0, weight=1) @@ -95,7 +96,7 @@ def setup_input_area(self): highlightthickness=0, cursor="hand2" ) - self.import_button.grid(row=0, column=1, sticky="e", padx=(2,10), pady=20) + self.import_button.grid(row=0, column=1, sticky="e", padx=(2, 10), pady=20) # Draw import button padding = 10 @@ -136,7 +137,7 @@ def setup_input_area(self): string.set("LLMs") # "LLMs" area - self.llm_list = tk.Label( + self.llm_listbox = tk.Label( self.outputlist_frame, textvariable=string, bg="#D2E9FC", @@ -145,7 +146,7 @@ def setup_input_area(self): anchor="w", justify="left" ) - self.llm_list.grid(row=0, column=1, sticky="w", padx=20, pady=(5, 5)) + self.llm_listbox.grid(row=0, column=1, sticky="w", padx=20, pady=(5, 5)) # Draw LLM status area self.output_text = tk.Text( @@ -172,20 +173,20 @@ def default(self, event): self.input_text.insert("1.0", "Insert model path here") self.input_text.tag_add("center", "1.0", "end") - def handle_import(self, event = None): + def handle_import(self, event=None): self.disable_output(False) self.disable_input(True) model_path = self.get_input() - print ("Clicked!") + print("Clicked!") if model_path == "Insert model path here": - self.output_text.insert ("1.0", "Please insert model path") - return + self.output_text.insert("1.0", "Please insert model path") + return elif model_path: self.clear_input() self.send_callback(model_path) else: - self.LLMInput.output_text.insert("1.0", "Please insert model path") + self.output_text.insert("1.0", "Please insert model path") return def disable_input(self, disable): @@ -208,10 +209,3 @@ def get_input(self): def clear_input(self): self.input_text.delete("1.0", "end") self.input_text.focus() - - - - - - - diff --git a/src/components/llm_list.py b/src/components/llm_list.py index 947d4a18..5814817e 100644 --- a/src/components/llm_list.py +++ b/src/components/llm_list.py @@ -1,12 +1,13 @@ import tkinter as tk import os from components.rounded_frame import RoundedFrame +#from main import AssessAIGUI + class LLMList(tk.Frame): def __init__(self, parent, root, **kwargs): super().__init__(parent, bg="#D2E9FC", **kwargs) - self.root = root - self.root = root + #self.main_gui = AssessAIGUI(root) # instance of AssessAIGUI self.setup_list_area() def setup_list_area(self): @@ -14,12 +15,12 @@ def setup_list_area(self): # list container with rounded corners self.list_container = RoundedFrame(self, "#FFFFFF", radius=50) - self.list_container.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=(5,5)) + self.list_container.grid(row=0, column=0, sticky="nsew", padx=(0, 5), pady=(5, 5)) self.list_container.grid_columnconfigure(0, weight=1) self.list_container.grid_rowconfigure(0, weight=1) # Frame for LLM list + scrollbar - self.scrolllist_frame = tk.Frame(self.list_container, bg = "white") + self.scrolllist_frame = tk.Frame(self.list_container, bg="white") self.scrolllist_frame.grid(row=0, column=0, sticky="nsew", padx=10, pady=10) self.scrolllist_frame.grid_columnconfigure(0, weight=1) self.scrolllist_frame.grid_rowconfigure(0, weight=1) @@ -44,33 +45,34 @@ def setup_list_area(self): self.list.configure(yscrollcommand=self.list_scrollbar.set) # scroll binding - #self.list_frame.bind("", self.update_scroll) - self.list.bind("", self.mouse_scroll)# bind to mouse + # self.list_frame.bind("", self.update_scroll) + self.list.bind("", self.mouse_scroll) # bind to mouse # selection bind self.list.bind("", self.model_selected) self.write_list(self.get_models()) - def model_selected(self, event): selected_model = self.list.curselection() if selected_model: model_path = self.list.get(selected_model) - self.root.show_page("model", model_path) + + #self.main_gui.show_page("model", model_path) + return def get_models(self): folder_list = [] - cwd = os.getcwd() # current directory - parent = os.path.dirname(cwd)# parent directory + cwd = os.getcwd() # current directory + parent = os.path.dirname(cwd) # parent directory model_dir = os.path.join(parent, "model_files") # check if model_files - for folder in os.listdir(model_dir): # list all folders in model_files folder + for folder in os.listdir(model_dir): # list all folders in model_files folder if os.path.isdir(os.path.join(model_dir, folder)): folder_list.append(folder) return folder_list def write_list(self, list): - self.list.delete(0,tk.END) # delete existing entries + self.list.delete(0, tk.END) # delete existing entries count = 0 for model in list: count += 1 @@ -79,14 +81,8 @@ def write_list(self, list): def mouse_scroll(self, event): if event.delta: self.list.yview_scroll(-1 * (event.delta // 120), "units") - elif event.num ==4: + elif event.num == 4: self.list.yview_scroll(-1, "units") - elif event.num ==5: + elif event.num == 5: self.list.yview_scroll(1, "units") return "break" - - - - - - diff --git a/src/pages/indiv_page.py b/src/pages/indiv_page.py index e69de29b..6645e22d 100644 --- a/src/pages/indiv_page.py +++ b/src/pages/indiv_page.py @@ -0,0 +1,70 @@ +import sys +import tkinter as tk +import io +from tkinter import ttk +from subprocess import run +from utils.llm import LLM +from components import ( + TitleFrame, +) + + +class LLMPage: + def __init__(self, root, show_page_callback): + self.show_page_callback = show_page_callback + self.LLM = None + self.root = root + self.container = tk.Frame(self.root, bg="#D2E9FC") + self.container.grid(row=1, column=1, sticky="nsew") + self.setup_page() + + def setup_page(self): + """Initialize and set up the LLM Individual page """ + self._configure_root() + self._configure_grid() + self._setup_styles() + self._initialize_components() + self._setup_bindings() + + def _configure_root(self): + """Configure root window settings""" + self.root.title("Assess.ai") + self.root.configure(bg="#D2E9FC") + + def _configure_grid(self): + """Configure grid layout""" + self.container.grid_rowconfigure(0, weight=0) # Title + self.container.grid_rowconfigure(1, weight=0) # LLM Name + self.container.grid_rowconfigure(2, weight=0) # LLM Details + self.container.grid_columnconfigure(0, weight=1) + + def _setup_styles(self): + """Setup ttk styles""" + style = ttk.Style() + + def _setup_bindings(self): + self.root.bind('', lambda e: self.root.destroy()) + + def _initialize_components(self): + # Title + self.title_frame = TitleFrame(self.container) + self.title_frame.grid(row=0, column=0, sticky="ew", pady=(0,50)) + + # LLM Name + + # LLM Details + + + + + + + + + + + + + + + diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 0836ec2c..562a6255 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -4,8 +4,8 @@ from tkinter import ttk from utils.llm import LLM from components import ( - LLMInput, TitleFrame, + LLMInput, LLMList ) From b20784b5d261a35ec1705fae28cd91bc16eeb1bd Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Sun, 9 Feb 2025 23:00:11 -0700 Subject: [PATCH 28/31] Added LLM specific pagepop-out window --- src/components/__init__.py | 4 +- src/components/dataset_list.py | 0 src/components/llm_details.py | 92 ++++++++++++++++++++++++++++++++++ src/components/llm_list.py | 28 +++++++---- src/components/navbar.py | 5 +- src/main.py | 16 +++--- src/pages/datasets_page.py | 0 src/pages/indiv_page.py | 22 +++----- src/pages/llms_page.py | 24 --------- 9 files changed, 130 insertions(+), 61 deletions(-) delete mode 100644 src/components/dataset_list.py create mode 100644 src/components/llm_details.py delete mode 100644 src/pages/datasets_page.py diff --git a/src/components/__init__.py b/src/components/__init__.py index fe5524b5..31a87395 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -1,8 +1,8 @@ from components.rounded_frame import RoundedFrame from components.title_frame import TitleFrame from components.input_frame import InputFrame -from components.llm_input import LLMInput from components.evaluation_form import EvaluationForm from components.form import Form +from components.llm_details import LLMDetails from components.llm_list import LLMList -from components.dataset_list import DatasetList \ No newline at end of file +from components.llm_input import LLMInput diff --git a/src/components/dataset_list.py b/src/components/dataset_list.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/llm_details.py b/src/components/llm_details.py new file mode 100644 index 00000000..3b68899a --- /dev/null +++ b/src/components/llm_details.py @@ -0,0 +1,92 @@ +import tkinter as tk +from components.rounded_frame import RoundedFrame +import json +import os +from itertools import islice +from collections import OrderedDict + +class LLMDetails(tk.Frame): + def __init__(self, parent, llm, **kwargs): + super().__init__(parent, bg="#D2E9FC", **kwargs) + self.llm = llm + self.setup_input_area() + + def setup_input_area(self): + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + + # Container frame for LLM Name, LLM Details + self.container = tk.Frame(self, bg="#D2E9FC") + self.container.grid(row=0, column=0, sticky="nsew", padx=0, pady=15) + self.container.grid_rowconfigure(0, weight=1) + self.container.grid_rowconfigure(1, weight=1) + self.container.grid_columnconfigure(0, weight=1) # for name frame + self.container.grid_columnconfigure(1, weight=1) # for details frame + + # LLM Name container + self.llm_name_container = tk.Frame(self.container, bg="#D2E9FC") + self.llm_name_container.grid(row=0, column=0, sticky="ew", padx=(5, 10), pady=(20, 5)) + self.llm_name_container.grid_columnconfigure(0, weight=1) + + # LLM details container frame with rounded corners + self.llm_details_container = RoundedFrame(self.container, "#FFFFFF", radius=50) + self.llm_details_container.grid(row=1, column=0, sticky="nsew", padx=(20, 5), pady=10) + self.llm_details_container.grid_rowconfigure(0, weight=1) + self.llm_details_container.grid_columnconfigure(0, weight=1) + + # "LLM Name" string variable + string = tk.StringVar() + string.set("LLM: " + self.llm) + + # "Import LLM" area + self.name_label = tk.Label( + self.llm_name_container, + textvariable=string, + bg="#D2E9FC", + font=("SF Pro Text", 30), + fg="black", + anchor="w", + justify="left" + ) + self.name_label.grid(row=0, column=0, sticky="w", padx=20, pady=(20, 10)) + + # LLM detail area + self.details = tk.Text( + self.llm_details_container, + height=30, + width=40, + font=("SF Pro Text", 14), + wrap=tk.WORD, + bd=0, + bg="white", + highlightthickness=0, + relief="flat" + ) + + self.details.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) + self.details.grid_rowconfigure(0, weight=1) + config = self.get_model_info() + + # get first 10 in config + first_twenty = dict(islice(config.items(), 20)) + rev = OrderedDict(reversed(list(first_twenty.items()))) + for key, value in rev.items(): + str_key = str(key) + str_value = str(value) + self.details.insert("1.0", "\n") + self.details.insert("1.0", str_key + ": " + str_value) + self.details.insert("1.0", "\n") + + + def get_model_info(self): + # cd to model_files + cwd = os.getcwd() + parent = os.path.dirname(cwd) + file = "model_files/" + self.llm + "/" + 'config.json' + model_dir = os.path.join(parent, file) + f = open(model_dir) # read config file of selected model + config = json.load(f) # load configurations + return config + + + diff --git a/src/components/llm_list.py b/src/components/llm_list.py index 5814817e..2b817cf8 100644 --- a/src/components/llm_list.py +++ b/src/components/llm_list.py @@ -1,13 +1,15 @@ import tkinter as tk +from tkinter import * import os from components.rounded_frame import RoundedFrame -#from main import AssessAIGUI - +from pages.indiv_page import LLMPage class LLMList(tk.Frame): def __init__(self, parent, root, **kwargs): super().__init__(parent, bg="#D2E9FC", **kwargs) - #self.main_gui = AssessAIGUI(root) # instance of AssessAIGUI + self.root = root + self.selected_model = None + self.detail_page = None self.setup_list_area() def setup_list_area(self): @@ -41,7 +43,6 @@ def setup_list_area(self): # LLM list scrollbar self.list_scrollbar = tk.Scrollbar(self.scrolllist_frame, orient="vertical", command=self.list.yview) self.list_scrollbar.grid(row=0, column=1, sticky="ns") - self.list.configure(yscrollcommand=self.list_scrollbar.set) # scroll binding @@ -53,12 +54,21 @@ def setup_list_area(self): self.write_list(self.get_models()) def model_selected(self, event): - selected_model = self.list.curselection() - if selected_model: - model_path = self.list.get(selected_model) + selection = self.list.curselection() + if selection: + self.selected_model = self.list.get(selection) + self.create_window(self.selected_model) + + def create_window(self, model_name): + # main window object + new_win = Toplevel(self.root) # create toplevel widget + + # set title + dimensions + new_win.title(model_name) + new_win.geometry("500x500") - #self.main_gui.show_page("model", model_path) - return + # populate window with model specific material + self.detail_page = LLMPage(new_win, self.selected_model) def get_models(self): folder_list = [] diff --git a/src/components/navbar.py b/src/components/navbar.py index 4290ab10..4160fa5f 100644 --- a/src/components/navbar.py +++ b/src/components/navbar.py @@ -5,11 +5,10 @@ # Navigation items nav_items = [ ("Home","home"), - ("Chat", "chat"), ("Evaluations", "evaluations"), ("LLMs", "llms"), ("Finetune", "finetune"), - ("Projects", "projects") + ("Datasets", "datasets") ] class Navbar(tk.Frame): @@ -17,7 +16,7 @@ def __init__(self, parent, show_page_callback, **kwargs): super().__init__(parent, bg="#FFFFFF", **kwargs) # Set callback function self.show_page_callback = show_page_callback - self.current_page = "chat" # Default page + self.current_page = "home" # Default page self.buttons = {} # Store button references self.logo_photo = None self._setup_navbar() diff --git a/src/main.py b/src/main.py index afa54dd4..9f38530f 100644 --- a/src/main.py +++ b/src/main.py @@ -1,13 +1,13 @@ import tkinter as tk import os from components.navbar import Navbar -from pages.chat_page import ChatPage from pages.evaluation_page import EvaluationPage from pages.llms_page import LLMsPage from pages.finetune_page import FinetunePage from pages.projects_page import ProjectsPage from pages.home_page import HomePage + # Set environment variable os.environ['TOKENIZERS_PARALLELISM'] = 'false' @@ -22,7 +22,6 @@ def setup_gui(self): self._configure_root() self._configure_grid() self._setup_navbar() - self.show_page("chat") # Start with chat page def _configure_root(self): self.root.title("Assess.ai") @@ -44,22 +43,21 @@ def _clear_content(self): if int(widget.grid_info()["column"]) == 1: widget.destroy() - def show_page(self, page_name): + def show_page(self, page_name, selected_model="None"): # Show the new page self._clear_content() - - if page_name == "chat": - self.current_page = ChatPage(self.root) - elif page_name == "evaluations": + + if page_name == "evaluations": self.current_page = EvaluationPage(self.root) elif page_name == "llms": self.current_page = LLMsPage(self.root) elif page_name == "finetune": self.current_page = FinetunePage(self.root) - elif page_name == "projects": - self.current_page = ProjectsPage(self.root) elif page_name == "home": self.current_page = HomePage(self.root) + elif page_name == "datasets": + self.current_page = DatasetsPage(self.root) + def main(): # Initialize root and components diff --git a/src/pages/datasets_page.py b/src/pages/datasets_page.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pages/indiv_page.py b/src/pages/indiv_page.py index 6645e22d..9e197097 100644 --- a/src/pages/indiv_page.py +++ b/src/pages/indiv_page.py @@ -2,20 +2,18 @@ import tkinter as tk import io from tkinter import ttk -from subprocess import run -from utils.llm import LLM from components import ( - TitleFrame, + LLMDetails ) class LLMPage: - def __init__(self, root, show_page_callback): - self.show_page_callback = show_page_callback - self.LLM = None + def __init__(self, root, llm): + self.LLM = llm self.root = root self.container = tk.Frame(self.root, bg="#D2E9FC") self.container.grid(row=1, column=1, sticky="nsew") + self.setup_page() def setup_page(self): @@ -33,9 +31,7 @@ def _configure_root(self): def _configure_grid(self): """Configure grid layout""" - self.container.grid_rowconfigure(0, weight=0) # Title - self.container.grid_rowconfigure(1, weight=0) # LLM Name - self.container.grid_rowconfigure(2, weight=0) # LLM Details + self.container.grid_rowconfigure(0, weight=1) # LLM Name + Details self.container.grid_columnconfigure(0, weight=1) def _setup_styles(self): @@ -46,13 +42,11 @@ def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) def _initialize_components(self): - # Title - self.title_frame = TitleFrame(self.container) - self.title_frame.grid(row=0, column=0, sticky="ew", pady=(0,50)) - # LLM Name + # Name + Details + self.details_frame = LLMDetails(self.container, self.LLM) + self.details_frame.grid(row=0, column=0, sticky="ew", pady=(0,50)) - # LLM Details diff --git a/src/pages/llms_page.py b/src/pages/llms_page.py index 562a6255..74f100c1 100644 --- a/src/pages/llms_page.py +++ b/src/pages/llms_page.py @@ -19,24 +19,10 @@ def __init__(self, root): self.container.grid(row=1, column=1, sticky="nsew") self.setup_page() - """ - # scrollbar logistics - self.canvas = tk.Canvas(self.root, bg="#D2E9FC") - self.canvas.grid(row=1, column=1, sticky="nsew") - self.create_scrollbar() - self.canvas.configure(yscrollcommand = self.scrollbar.set) - - - - self.container.update_idletasks() - self.canvas.config(scrollregion=self.canvas.bbox("all")) - """ - def setup_page(self): """Initialize and set up the LLMs page """ self._configure_root() self._configure_grid() - self._setup_styles() self._initialize_components() self._setup_bindings() @@ -51,10 +37,6 @@ def _configure_grid(self): self.container.grid_rowconfigure(1, weight=0) # LLM Input self.container.grid_columnconfigure(0, weight=1) - def _setup_styles(self): - """Setup ttk styles""" - style = ttk.Style() - def _initialize_components(self): # Title self.title_frame = TitleFrame(self.container) @@ -75,7 +57,6 @@ def send_path(self, model_path): try: self.LLM = LLM(model_path) # create LLM self.LLM.load_LLM() # load LLM - #self.get_output(model_path) self.LLM.import_LLM() # import model to file in directory output = self.get_output(model_path) # get output from Hugging Face self.LLMInput.output_text.insert("1.0", model_path + " was successfully imported! \n " + output) @@ -106,11 +87,6 @@ def get_output(self, model_path): sys.stderr = sys.__stderr__ return self.term_output - def create_scrollbar(self): - # create scrollbar - self.scrollbar = tk.Scrollbar(self.root, orient="vertical") - self.scrollbar.grid(row=0, column=1, sticky="ns") - self.scrollbar.config(command=self.canvas.yview) From e791b64a03357096446dd8f6eb6ce1046996dc9b Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Mon, 10 Feb 2025 22:04:02 -0700 Subject: [PATCH 29/31] Integration of Homepage with Kaylie --- src/components/navbar.py | 5 +- src/main.py | 7 +- src/pages/home_page.py | 145 +++++++++++++++++++++++++++++++++------ src/utils/llm.py | 10 +++ 4 files changed, 138 insertions(+), 29 deletions(-) diff --git a/src/components/navbar.py b/src/components/navbar.py index 4160fa5f..0493bc92 100644 --- a/src/components/navbar.py +++ b/src/components/navbar.py @@ -4,11 +4,10 @@ # Navigation items nav_items = [ - ("Home","home"), + ("Home", "home"), ("Evaluations", "evaluations"), ("LLMs", "llms"), ("Finetune", "finetune"), - ("Datasets", "datasets") ] class Navbar(tk.Frame): @@ -95,7 +94,7 @@ def _setup_navbar(self): btn.bind("", lambda e, b=btn_container, p=page: self._on_hover(b, p)) btn.bind("", lambda e, b=btn_container, p=page: self._on_leave(b, p)) - self.set_active_page("chat") + self.set_active_page("home") def _handle_click(self, page): if page != self.current_page: diff --git a/src/main.py b/src/main.py index 9f38530f..8fc7ddeb 100644 --- a/src/main.py +++ b/src/main.py @@ -43,7 +43,7 @@ def _clear_content(self): if int(widget.grid_info()["column"]) == 1: widget.destroy() - def show_page(self, page_name, selected_model="None"): + def show_page(self, page_name): # Show the new page self._clear_content() @@ -54,9 +54,7 @@ def show_page(self, page_name, selected_model="None"): elif page_name == "finetune": self.current_page = FinetunePage(self.root) elif page_name == "home": - self.current_page = HomePage(self.root) - elif page_name == "datasets": - self.current_page = DatasetsPage(self.root) + self.current_page = HomePage(self.root, self.show_page) def main(): @@ -71,6 +69,7 @@ def main(): # Initialize GUI app = AssessAIGUI(root) + app.show_page("home") # Start main loop root.mainloop() diff --git a/src/pages/home_page.py b/src/pages/home_page.py index 1735780f..8120673a 100644 --- a/src/pages/home_page.py +++ b/src/pages/home_page.py @@ -1,30 +1,131 @@ +import sys import tkinter as tk +import io import os +from tkinter import ttk from tkinter import PhotoImage +from subprocess import run +from pages.llms_page import LLMsPage +from utils.llm import LLM +from components import ( + LLMInput, + TitleFrame, + LLMList +) class HomePage: - def __init__(self, root): + def __init__(self, root, show_page_callback): + self.home = None self.root = root + self.container = tk.Frame(self.root, bg="#D2E9FC") + self.container.grid(row=1, column=1, sticky="nsew") + self.llmlist = None + self.show_page_callback = show_page_callback self.setup_page() - + + def setup_page(self): - container = tk.Frame(self.root, bg="#D2E9FC") - container.grid(row=1, column=1, sticky="nsew") - - - - __location__ = os.path.realpath(os.path.join(os.getcwd(),os.path.dirname(__file__))) - path = os.path.abspath(__location__) - path = path + "/logo.png" - - logo = PhotoImage(file=path) - - print(path) - - # Placeholder content - tk.Label( - container, - text="Home", - font=("SF Pro Display", 24, "bold"), - bg="#D2E9FC" - ).pack(pady=20) + """Initialize and set up the homepage page """ + self._configure_root() + self._configure_grid() + self._setup_styles() + self._initialize_components() + self._setup_bindings() + + + def _configure_root(self): + """Configure root window settings""" + self.root.title("Assess.ai") + self.root.configure(bg="#D2E9FC") + + def _configure_grid(self): + """Configure grid layout""" + self.container.grid_rowconfigure(0, weight=0) # Title + self.container.grid_rowconfigure(1, weight=0) #homepage label + self.container.grid_rowconfigure(2, weight=0) #button + self.container.grid_rowconfigure(3, weight=0) #labels + self.container.grid_rowconfigure(4, weight=1) #list boxes + self.container.grid_columnconfigure(0, weight=1) + self.container.grid_columnconfigure(1, weight=1) + + def _setup_styles(self): + """Setup ttk styles""" + style = ttk.Style() + + + def _initialize_components(self): + + # welcome label + title_lbl = tk.Label(self.container, text="WELCOME TO ASSESS.AI", font=("SF Pro Display", 24, "bold"), bg="#D2E9FC", fg="black") + title_lbl.grid(row=0,column=0, columnspan=2,pady=10,sticky="n") + + # homepage label + homepage_lbl = tk.Label(self.container, text="Homepage", font=("SF Pro Display", 18, "bold"), bg="#D2E9FC", fg="black") + homepage_lbl.grid(row=1,column=0, columnspan=2,pady=5,sticky="n") + + # nav button + self.llm_button = ttk.Button(self.container, text="New? Get started with LLM page", command=self.nav_to_llm) + # the command is a placeholder for now + self.llm_button.grid(row=2, column=0, columnspan=2, pady=10, padx=20, sticky="ew") + + # Listbox for past llms used + llm_lbl=tk.Label(self.container, text="Past LLMs Used:", font=("SF Pro Display", 14), bg="#D2E9FC", fg="black") + llm_lbl.grid(row=3,column=0, padx=20, pady=(5, 0),sticky="w") + self.llm_lb = tk.Listbox(self.container, height=10) + self.llm_lb.grid(row=4,column=0, padx=20, pady=10, sticky="nsew") + self.load_past_llms() #loading past llms CHANGE (changed) + + + # List box for past evals + eval_label = tk.Label(self.container, text="Past Evaluations:", font=("SF Pro Display", 14), bg="#D2E9FC", fg="black") + eval_label.grid(row=3,column=1, padx=20, pady=(5,0),sticky="w") + self.eval_lb = tk.Listbox(self.container, height=10) + self.eval_lb.grid(row=4, column=1, padx=20, pady=10, sticky="nsew") + # load eval results into lb + # placeholder content + self.load_evaluations() + + + def _setup_bindings(self): + self.root.bind('', lambda e: self.root.destroy()) + + + def load_evaluations(self): + # cd to parent directory + cwd = os.getcwd() # current directory + parent = os.path.dirname(cwd) # parent directory + eval_folder = os.path.join(parent, "eval_files") + if not os.path.exists(eval_folder): + print(f"Error: {eval_folder} directory does not exist.") + return + try: + eval_dirs = [d for d in os.listdir(eval_folder) if os.path.isdir(os.path.join(eval_folder, d))] + # Insert directory names into the Listbox + for eval_dir in eval_dirs: + self.eval_lb.insert(tk.END, eval_dir) # Add the directory name to Listbox (this could also be filename if saved differently) + + except FileNotFoundError: + print(f"Error: {eval_folder} not found.") + + + def nav_to_llm(self): + # change displaying page + print("navigating to llm page...") + self.show_page_callback("llms") + + + def load_past_llms(self): + # load past llms in to the listbox + self.llmlist = LLMList (self.container, self.root)# create LLMList object + imported_models = self.llmlist.get_models() # get_models + + # write to listbox + self.llm_lb.delete(0, tk.END) # delete existing entries + count = 0 + for model in imported_models: + count += 1 + self.llm_lb.insert(count, model) + + + + diff --git a/src/utils/llm.py b/src/utils/llm.py index 3c7f81ed..12336f12 100644 --- a/src/utils/llm.py +++ b/src/utils/llm.py @@ -29,12 +29,22 @@ def detokenize(self, summarized_text): return self.tokenizer.batch_decode(summarized_text) def import_LLM(self): + # check if LLM model_file folder already exists + # check for model_files path + folder_path = "../model_files" + + # if not, create folder + # save model and tokenizer path_name = f"../model_files/{self.model_id.replace('/', '_')}" self.model.save_pretrained("../model_files/" + path_name) self.tokenizer.save_pretrained("../model_files/" + path_name) print(self.model_id + " was successfully imported!") + def create_model_folder(self): + # create model_files folder + return + def load_LLM(self): # check model type using AutoConfig # dict of model classes From 1ba07beb22c50998afed1cd7ce7d4538e6f1fd0e Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Tue, 11 Feb 2025 01:13:37 -0700 Subject: [PATCH 30/31] integration --- src/components/__init__.py | 1 - src/pages/home_page.py | 1 - src/pages/indiv_page.py | 5 +---- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/components/__init__.py b/src/components/__init__.py index ebefce0e..150f0162 100644 --- a/src/components/__init__.py +++ b/src/components/__init__.py @@ -6,4 +6,3 @@ from components.llm_input import LLMInput from components.llm_list import LLMList from components.evaluation_visualizer import EvaluationVisualizer -from components.llm_details import LLMDetails diff --git a/src/pages/home_page.py b/src/pages/home_page.py index 01d14735..34b17e55 100644 --- a/src/pages/home_page.py +++ b/src/pages/home_page.py @@ -25,7 +25,6 @@ def __init__(self, root, show_page_callback): def setup_page(self): -<<<<<<< HEAD """Initialize and set up the homepage page """ self._configure_root() self._configure_grid() diff --git a/src/pages/indiv_page.py b/src/pages/indiv_page.py index 897654c4..aa31bc9d 100644 --- a/src/pages/indiv_page.py +++ b/src/pages/indiv_page.py @@ -1,11 +1,8 @@ -<<<<<<< HEAD import sys import tkinter as tk import io from tkinter import ttk -from components import ( - LLMDetails -) +from components.llm_details import LLMDetails class LLMPage: From 0db473928a60dc551dbe604e93b154e8790bb852 Mon Sep 17 00:00:00 2001 From: Silv1357 <116597286+Silv1357@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:04:14 -0700 Subject: [PATCH 31/31] Updates to Homepage --- sources.txt | 33 ++++++++++++++------------------- src/pages/home_page.py | 35 ++++++++++++++++++++++++----------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/sources.txt b/sources.txt index 2c1b264e..5ae0d13a 100644 --- a/sources.txt +++ b/sources.txt @@ -1,23 +1,18 @@ -Sources -AI-driven generation of news summaries: https://hal.science/hal-04437765 -A study using GPT and Pegasus about each AI's accuracy (we can take their method of studying accuracy and apply it to our uses). Both models produced high quality summaries of news articles. +Works Cited -Navigating dataset documentations in AI with Hugging-Face specifically: http://arxiv.org/pdf/2401.13822 -The Hugging Face community has a ways to go when it comes to documentation, but many of the top 100 dataset cards (86%) had filled out all of the suggested dataset cards section. +Avinash. “LLM Evaluation Metrics — BLEU, ROGUE and METEOR Explained.” Medium, 7 Aug. 2024, avinashselvam.medium.com/llm-evaluation-metrics-bleu-rogue-and-meteor-explained-a5d2b129e87f. Accessed 11 Feb. 2025. +Bais, Gourav. “LLM Evaluation for Text Summarization.” Neptune.ai, 25 Sept. 2024, neptune.ai/blog/llm-evaluation-text-summarization. Accessed 7 Feb. 2025. (photo) +Banerjee, Satanjeev, and Alon Lavie. METEOR: An Automatic Metric for MT Evaluation with Improved Correlation with Human Judgments. 2005. +Bajaj, Ganesh. “When to Use BLEU Score: Evaluating Text Generation with N-Gram Precision.” Medium, Artificial Intelligence in Plain English, 26 Sept. 2024, ai.plainenglish.io/when-to-use-bleu-score-evaluating-text-generation-with-n-gram-precision-3431829a641e. Accessed 7 Feb. 2025. (photo) +“BERT Score - a Hugging Face Space by Evaluate-Metric.” Huggingface.co, huggingface.co/spaces/evaluate-metric/bertscore. +courtzc. “Evaluating the Performance of LLM Summarization Prompts with G-Eval.” Microsoft.com, 25 June 2024, learn.microsoft.com/en-us/ai/playbook/technology-guidance/generative-ai/working-with-llms/evaluation/g-eval-metric-for-summarization. Accessed 11 Feb. 2025. +Falcão, Fabiano. “Metrics for Evaluating Summarization of Texts Performed by Transformers: How to Evaluate The….” Medium, 22 Apr. 2023, fabianofalcao.medium.com/metrics-for-evaluating-summarization-of-texts-performed-by-transformers-how-to-evaluate-the-b3ce68a309c3. +Issiaka Faissal Compaore, et al. “AI-Driven Generation of News Summaries: Leveraging GPT and Pegasus Summarizer for Efficient Information Extraction.” Hal.science, 5 Feb. 2024, hal.science/hal-04437765, https://hal.science/hal-04437765. Accessed 11 Feb. 2025. +Liu, Yang, et al. G-EVAL: NLG Evaluation Using GPT-4 with Better Human Alignment. +Otten, Neri Van, and Neri Van Otten. “METEOR Metric in NLP: How It Works & How to Tutorial in Python.” Spot Intelligence, 26 Aug. 2024, spotintelligence.com/2024/08/26/meteor-metric-in-nlp-how-it-works-how-to-tutorial-in-python/. +---. “ROUGE Metric in NLP: Complete Guide & How to Tutorial in Python.” Spot Intelligence, 12 Aug. 2024, spotintelligence.com/2024/08/12/rouge-metric-in-nlp/. +Ruman. “BERT Score Explained | Medium.” Medium, 17 May 2024, rumn.medium.com/bert-score-explained-8f384d37bb06. Accessed 7 Feb. 2025. (photo) +Yang, Xinyu, et al. “Navigating Dataset Documentations in AI: A Large-Scale Analysis of Dataset Cards on Hugging Face.” ArXiv.org, 24 Jan. 2024, arxiv.org/abs/2401.13822. Accessed 7 Feb. 2025. -For medical abstract summarization: https://research.ebsco.com/c/o46j2u/viewer/pdf/m5vdfbojwz -ChatGPT's summaries were 70% shorter, rated as 'high quality' (paper explains how they came to this conclusion), and low bias. -For generating medical records: https://academic.oup.com/jamia/article/27/1/99/5583723 -If we need to, we can generate somewhat accurate medical records with AI with the processes listed in this article. - -Diagnostic Clinical Descision using AI: https://research.ebsco.com/c/o46j2u/search/details/2twy4tgkzb?q=summary%20evaluation%20ai - -Human centered test and evaluation of military AI: https://research.ebsco.com/c/o46j2u/search/details/ykosntpf7z?q=summary%20evaluation%20ai - -Medical abstract summary with AI: https://research.ebsco.com/c/o46j2u/search/details/d2afd65kjj?q=summary%20evaluation%20ai - -Evaluation of AI tools using the REACT framework: https://research.ebsco.com/c/o46j2u/search/details/j3d2odpqen?q=summary%20evaluation%20ai - -Use of AI in pathology: https://research.ebsco.com/c/o46j2u/search/details/7o7ngbt4vb?q=summary%20evaluation%20ai \ No newline at end of file diff --git a/src/pages/home_page.py b/src/pages/home_page.py index 34b17e55..b37ce7c5 100644 --- a/src/pages/home_page.py +++ b/src/pages/home_page.py @@ -1,12 +1,8 @@ -import sys import tkinter as tk -import io +from tkinter import * import os from tkinter import ttk -from tkinter import PhotoImage -from subprocess import run -from pages.llms_page import LLMsPage -from utils.llm import LLM +from pages.indiv_page import LLMPage from components import ( LLMInput, TitleFrame, @@ -19,7 +15,7 @@ def __init__(self, root, show_page_callback): self.root = root self.container = tk.Frame(self.root, bg="#D2E9FC") self.container.grid(row=1, column=1, sticky="nsew") - self.llmlist = None + self.llmlist = LLMList (self.container, self.root) self.show_page_callback = show_page_callback self.setup_page() @@ -73,6 +69,8 @@ def _initialize_components(self): llm_lbl.grid(row=3,column=0, padx=20, pady=(5, 0),sticky="w") self.llm_lb = tk.Listbox(self.container, height=10) self.llm_lb.grid(row=4,column=0, padx=20, pady=10, sticky="nsew") + + self.llm_lb.bind("", self.model_selected) # bind list self.load_past_llms() #loading past llms CHANGE (changed) @@ -89,7 +87,6 @@ def _initialize_components(self): def _setup_bindings(self): self.root.bind('', lambda e: self.root.destroy()) - def load_evaluations(self): # cd to parent directory cwd = os.getcwd() # current directory @@ -107,16 +104,13 @@ def load_evaluations(self): except FileNotFoundError: print(f"Error: {eval_folder} not found.") - def nav_to_llm(self): # change displaying page print("navigating to llm page...") self.show_page_callback("llms") - def load_past_llms(self): # load past llms in to the listbox - self.llmlist = LLMList (self.container, self.root)# create LLMList object imported_models = self.llmlist.get_models() # get_models # write to listbox @@ -126,5 +120,24 @@ def load_past_llms(self): count += 1 self.llm_lb.insert(count, model) + def model_selected(self, event): + selection = self.llm_lb.curselection() + if selection: + self.selected_model = self.llm_lb.get(selection) + self.create_window(self.selected_model) + + def create_window(self, model_name): + # main window object + new_win = Toplevel(self.root) # create toplevel widget + + # set title + dimensions + new_win.title(model_name) + new_win.geometry("500x500") + + # populate window with model specific material + self.detail_page = LLMPage(new_win, self.selected_model) + + +