From 87cf54124aa4612312a80ede75a99fa4cea04990 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 22 Apr 2026 14:32:58 -0400 Subject: [PATCH] 04/22 Add AI Analyze document --- app.py | 11 +- config.ini | 4 + example.xlsx | Bin 0 -> 10985 bytes requirements.txt | 3 + views/ai_summary_view.py | 804 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 example.xlsx create mode 100644 views/ai_summary_view.py diff --git a/app.py b/app.py index f007a11..e6e96fe 100644 --- a/app.py +++ b/app.py @@ -148,11 +148,13 @@ class App(tk.Tk): ("📊 My Shifts", "shifts", self._show_shifts), ("📑 Reports", "reports", self._show_reports), ("📧 Email Reports","email", self._show_email_settings), + ("🤖 AI Summary", "ai_summary", self._show_ai_summary), ("⚙ Settings", "settings", self._show_settings), ] else: nav_items = [ - ("📊 My Shifts", "shifts", self._show_shifts), + ("📊 My Shifts", "shifts", self._show_shifts), + ("🤖 AI Summary", "ai_summary", self._show_ai_summary), ] for label, key, cmd in nav_items: @@ -311,6 +313,12 @@ class App(tk.Tk): from views.email_settings_view import EmailSettingsView EmailSettingsView(self, self.current_user) + def _show_ai_summary(self): + self._clear_content() + self._set_active_nav("ai_summary") + from views.ai_summary_view import AiSummaryView + AiSummaryView(self.content, self.current_user).pack(fill="both", expand=True) + def _on_settings_saved(self): """Called after settings are saved — reload config and reconnect.""" from config import reload_db_config @@ -481,6 +489,7 @@ class App(tk.Tk): "shifts": self._show_shifts, "reports": self._show_reports, "email": self._show_email_settings, + "ai_summary": self._show_ai_summary, "settings": self._show_settings, } if active in nav_map: diff --git a/config.ini b/config.ini index 22495aa..8fecba8 100644 --- a/config.ini +++ b/config.ini @@ -8,3 +8,7 @@ password = 7x+MxGmks_3U [crypto] salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ= +[groq] +api_key = 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 +model = llama-3.3-70b-versatile + diff --git a/example.xlsx b/example.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ab2671e76dd47a5fa7559aca28e6d3581dd46259 GIT binary patch literal 10985 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.0.0 matplotlib>=3.7.0 plyer>=2.1.0 reportlab>=4.0.0 +groq>=1.0.0 +pypdf>=3.0.0 +python-docx>=1.0.0 diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py new file mode 100644 index 0000000..0816060 --- /dev/null +++ b/views/ai_summary_view.py @@ -0,0 +1,804 @@ +""" +views/ai_summary_view.py — AI Document Summary panel. + +Allows any logged-in user to upload one or more document files +(PDF, DOCX, TXT, CSV, XLSX, MD) and receive an AI-generated summary +powered by the Groq API. + +Behavior by role: + Admin : sees API key config + model selector (can save settings) + User : settings are hidden; uses the stored API key transparently + +The AI prompt is focused on extracting procurement/solicitation fields: + Solicitation Number, Type, Set-Aside, Description, Work Site, + Pre-Proposal Conference, POC, Square Footage, Due Date, etc. + +Layout +------ + Top : page header + (admin only) API-key / model config bar + Middle: two-pane — left = file list / controls, right = summary output + Bottom: status bar +""" + +import os +import logging +import threading +import tkinter as tk +from tkinter import ttk, filedialog + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + show_error, show_info, +) + +logger = logging.getLogger("ai_summary_view") + +# -- Supported file extensions ------------------------------------------------- +SUPPORTED_EXT = { + ".txt", ".md", ".csv", + ".pdf", + ".docx", + ".xlsx", ".xls", +} + +FILE_DIALOG_TYPES = [ + ("Supported documents", + "*.txt *.md *.csv *.pdf *.docx *.xlsx *.xls"), + ("Text files", "*.txt *.md *.csv"), + ("PDF files", "*.pdf"), + ("Word documents", "*.docx"), + ("Excel files", "*.xlsx *.xls"), + ("All files", "*.*"), +] + +# -- Groq model options -------------------------------------------------------- +GROQ_MODELS = [ + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "gemma2-9b-it", + "mixtral-8x7b-32768", +] + +# -- Config keys stored in config.ini ------------------------------------------ +_CFG_SECTION = "groq" +_CFG_KEY_KEY = "api_key" +_CFG_KEY_MODEL = "model" + +# -- Focused extraction prompt (procurement / solicitation) -------------------- +_EXTRACTION_PROMPT = """\ +You are an expert government procurement analyst. +The user has provided {n} document(s). Your job is to extract specific \ +information from each document and present it in a clean, structured format. + +For EACH document, extract and clearly label the following fields \ +(write "N/A" if a field is not found): + + 1. Solicitation Number + 2. Solicitation Type (e.g. RFP, RFQ, IFB, etc.) + 3. Set-Aside (e.g. Small Business, 8(a), N/A) + 4. Description / Scope of Work + 5. Work Site / Location(s) + 6. Pre-Proposal Conference / Site-Visit (date, time, location — mandatory or optional) + 7. Point of Contact (POC) (name, phone, email) + 8. Total Square Footage (if applicable) + 9. Driving Distance / Travel Time to Pre-Proposal Conference + 10. Last Day to Submit Questions + 11. Due Date & Time + 12. Any other notable requirements or deadlines + +After the per-document breakdown, provide a detailed OVERALL SUMMARY covering: + + A. Scope of Work + - What services or work are being requested? + - What are the key performance requirements, quality standards, or \ +special conditions? + - Are there any specific technical, staffing, or equipment requirements? + + B. Contract Period + - What is the anticipated base period of performance? + - Are there any option years or renewal provisions mentioned? + - What is the expected contract start date (if stated)? + + C. Proposal Submission Requirements + - What documents, forms, or sections must be included in the proposal? + - Are there page limits, formatting requirements, or specific \ +submission instructions? + - What evaluation criteria or factors will be used to award the contract? + - Are there any bonding, insurance, licensing, or certification requirements? + + D. Key Deadlines & Action Items + - List all critical dates in chronological order. + - Flag any mandatory attendance requirements (e.g. site visits). + +Be precise, detailed, and use bullet points throughout. \ +If information is not explicitly stated in the documents, note it as \ +"Not specified in the document." + +DOCUMENTS: +{documents} +""" + + +# ------------------------------------------------------------------------------ + +class AiSummaryView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._is_admin = (current_user.get("role") == "admin") + self._files: list[str] = [] # list of absolute file paths + self._running = False # True while AI call is in-flight + + self._api_key_var = tk.StringVar() + self._model_var = tk.StringVar(value=GROQ_MODELS[0]) + self._status_var = tk.StringVar(value="Ready.") + + self._load_config() + self._build_ui() + + # -- Config persistence ---------------------------------------------------- + + def _load_config(self): + """Read stored API key / model from config.ini [groq] section.""" + try: + import configparser + from config import CONFIG_FILE + cfg = configparser.ConfigParser() + cfg.read(CONFIG_FILE, encoding="utf-8") + if cfg.has_section(_CFG_SECTION): + self._api_key_var.set( + cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")) + model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL, + fallback=GROQ_MODELS[0]) + if model in GROQ_MODELS: + self._model_var.set(model) + except Exception as e: + logger.warning(f"Could not load Groq config: {e}") + + def _save_config(self): + """Persist API key / model to config.ini [groq] section.""" + try: + import configparser + from config import CONFIG_FILE + cfg = configparser.ConfigParser() + cfg.read(CONFIG_FILE, encoding="utf-8") + if not cfg.has_section(_CFG_SECTION): + cfg.add_section(_CFG_SECTION) + cfg.set(_CFG_SECTION, _CFG_KEY_KEY, self._api_key_var.get().strip()) + cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get()) + with open(CONFIG_FILE, "w", encoding="utf-8") as fh: + cfg.write(fh) + except Exception as e: + logger.warning(f"Could not save Groq config: {e}") + + # -- UI construction ------------------------------------------------------- + + def _build_ui(self): + C = COLOURS + + # -- Page header ------------------------------------------------------- + hdr = ttk.Frame(self) + hdr.pack(fill="x", pady=(0, 10)) + + ttk.Label(hdr, text="🤖 AI Document Summary", + style="Heading.TLabel").pack(side="left") + + ttk.Label(hdr, + text="Upload documents and let AI extract key information.", + style="Dim.TLabel").pack(side="left", padx=(12, 0)) + + # -- Admin-only: API key / model config bar ---------------------------- + if self._is_admin: + self._build_admin_config_bar() + + # -- Main two-pane area ------------------------------------------------ + pane = tk.PanedWindow(self, orient="horizontal", + bg=C["border"], sashwidth=4, + sashrelief="flat") + pane.pack(fill="both", expand=True, pady=(0, 6)) + + # Left pane -- file list + left = tk.Frame(pane, bg=C["bg"]) + pane.add(left, minsize=260, width=300) + self._build_file_panel(left) + + # Right pane -- summary output + right = tk.Frame(pane, bg=C["bg"]) + pane.add(right, minsize=350) + self._build_output_panel(right) + + # -- Status bar -------------------------------------------------------- + status_bar = tk.Frame(self, bg=C["surface2"], pady=4) + status_bar.pack(fill="x", side="bottom") + tk.Label(status_bar, textvariable=self._status_var, + bg=C["surface2"], fg=C["text_dim"], + font=FONT_SMALL, anchor="w").pack(side="left", padx=10) + + def _build_admin_config_bar(self): + """Render the API key + model selector card (admin only).""" + C = COLOURS + cfg_card = tk.Frame(self, bg=C["surface"], pady=10, padx=14) + cfg_card.pack(fill="x", pady=(0, 10)) + + # Row 0 -- API key + tk.Label(cfg_card, text="Groq API Key:", + bg=C["surface"], fg=C["text_dim"], + font=FONT_SMALL).grid(row=0, column=0, sticky="w", + padx=(0, 8), pady=4) + + self._key_entry = tk.Entry( + cfg_card, textvariable=self._api_key_var, + show="•", width=48, + bg=C["surface2"], fg=C["text"], + insertbackground=C["text"], + relief="flat", font=FONT, + ) + self._key_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4) + + # Show / hide toggle + self._eye_btn = tk.Button( + cfg_card, text="👁", + command=self._toggle_key_visibility, + bg=C["surface2"], fg=C["text_dim"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", padx=6, + ) + self._eye_btn.grid(row=0, column=2, padx=(0, 12), pady=4) + + # Row 1 -- model selector + save + tk.Label(cfg_card, text="Model:", + bg=C["surface"], fg=C["text_dim"], + font=FONT_SMALL).grid(row=1, column=0, sticky="w", + padx=(0, 8), pady=4) + + ttk.Combobox( + cfg_card, textvariable=self._model_var, + values=GROQ_MODELS, state="readonly", width=30, + ).grid(row=1, column=1, sticky="w", padx=(0, 8), pady=4) + + tk.Button( + cfg_card, text="💾 Save Settings", + command=self._on_save_settings, + bg=C["surface2"], fg=C["text"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ).grid(row=1, column=2, padx=(0, 12), pady=4) + + cfg_card.columnconfigure(1, weight=1) + + # -- File panel ------------------------------------------------------------ + + def _build_file_panel(self, parent): + C = COLOURS + + # Toolbar + toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) + toolbar.pack(fill="x") + + tk.Label(toolbar, text="📂 Files", + bg=C["surface"], fg=C["text"], + font=FONT_BOLD).pack(side="left") + + tk.Button( + toolbar, text="✕ Clear All", + command=self._clear_files, + bg=C["surface"], fg=C["danger"], + activebackground=C["danger"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + ).pack(side="right", padx=(4, 0)) + + tk.Button( + toolbar, text="➕ Add Files", + command=self._add_files, + bg=C["accent"], fg=C["white"], + activebackground=C["accent_hover"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ).pack(side="right", padx=(4, 0)) + + # File listbox with scrollbar + list_frame = tk.Frame(parent, bg=C["bg"]) + list_frame.pack(fill="both", expand=True, padx=6, pady=(4, 6)) + + vsb = ttk.Scrollbar(list_frame, orient="vertical") + vsb.pack(side="right", fill="y") + + self._file_lb = tk.Listbox( + list_frame, + bg=C["surface"], fg=C["text"], + selectbackground=C["accent"], selectforeground=C["white"], + activestyle="none", + relief="flat", font=FONT_SMALL, + yscrollcommand=vsb.set, + selectmode="extended", + ) + self._file_lb.pack(side="left", fill="both", expand=True) + vsb.config(command=self._file_lb.yview) + + # Right-click context menu + self._ctx_menu = tk.Menu(self._file_lb, tearoff=0) + self._ctx_menu.add_command(label="Remove selected", + command=self._remove_selected) + self._file_lb.bind("", self._show_ctx_menu) + + # Run button (bottom of left pane) + btn_frame = tk.Frame(parent, bg=C["bg"], pady=6) + btn_frame.pack(fill="x", padx=6) + + self._run_btn = tk.Button( + btn_frame, + text="✨ Analyze with AI", + command=self._on_summarize, + bg=C["accent"], fg=C["white"], + activebackground=C["accent_hover"], activeforeground=C["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", + pady=10, + ) + self._run_btn.pack(fill="x") + + # Progress bar (hidden until running) + self._progress = ttk.Progressbar(btn_frame, mode="indeterminate") + + self._refresh_file_list() + + # -- Output panel ---------------------------------------------------------- + + def _build_output_panel(self, parent): + C = COLOURS + + # Toolbar + toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) + toolbar.pack(fill="x") + + tk.Label(toolbar, text="📝 Extracted Information", + bg=C["surface"], fg=C["text"], + font=FONT_BOLD).pack(side="left") + + tk.Button( + toolbar, text="📋 Copy", + command=self._copy_summary, + bg=C["surface2"], fg=C["text"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + tk.Button( + toolbar, text="💾 Save as TXT", + command=self._save_summary, + bg=C["surface2"], fg=C["text"], + activebackground=C["success"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + tk.Button( + toolbar, text="🗑 Clear", + command=self._clear_output, + bg=C["surface"], fg=C["danger"], + activebackground=C["danger"], activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + ).pack(side="right", padx=(4, 0)) + + # Text widget + txt_frame = tk.Frame(parent, bg=C["bg"]) + txt_frame.pack(fill="both", expand=True, padx=6, pady=(4, 6)) + + vsb = ttk.Scrollbar(txt_frame, orient="vertical") + vsb.pack(side="right", fill="y") + hsb = ttk.Scrollbar(txt_frame, orient="horizontal") + hsb.pack(side="bottom", fill="x") + + self._output_txt = tk.Text( + txt_frame, + wrap="word", + bg=C["surface"], fg=C["text"], + insertbackground=C["text"], + relief="flat", font=FONT, + padx=12, pady=10, + yscrollcommand=vsb.set, + xscrollcommand=hsb.set, + state="disabled", + ) + self._output_txt.pack(side="left", fill="both", expand=True) + vsb.config(command=self._output_txt.yview) + hsb.config(command=self._output_txt.xview) + + self._output_txt.tag_configure( + "placeholder", foreground=COLOURS["text_dim"], + font=FONT_SMALL, + ) + + self._set_output_placeholder() + + # -- File operations ------------------------------------------------------- + + def _add_files(self): + paths = filedialog.askopenfilenames( + title="Select documents to analyze", + filetypes=FILE_DIALOG_TYPES, + ) + added = 0 + for p in paths: + ext = os.path.splitext(p)[1].lower() + if ext not in SUPPORTED_EXT: + show_error( + f"Unsupported file type: {ext}\n\n" + f"Supported: {', '.join(sorted(SUPPORTED_EXT))}", + title="Unsupported File", + ) + continue + if p not in self._files: + self._files.append(p) + added += 1 + if added: + self._refresh_file_list() + self._set_status(f"{added} file(s) added. {len(self._files)} total.") + + def _remove_selected(self): + selected = list(self._file_lb.curselection()) + for i in reversed(selected): + del self._files[i] + self._refresh_file_list() + self._set_status(f"{len(self._files)} file(s) remaining.") + + def _clear_files(self): + self._files.clear() + self._refresh_file_list() + self._set_status("File list cleared.") + + def _show_ctx_menu(self, event): + try: + self._file_lb.selection_set(self._file_lb.nearest(event.y)) + self._ctx_menu.tk_popup(event.x_root, event.y_root) + finally: + self._ctx_menu.grab_release() + + def _refresh_file_list(self): + self._file_lb.delete(0, "end") + for path in self._files: + icon = _file_icon(path) + size = _human_size(os.path.getsize(path)) + self._file_lb.insert( + "end", f" {icon} {os.path.basename(path)} ({size})") + + # -- Output helpers -------------------------------------------------------- + + def _set_output_text(self, text: str): + self._output_txt.config(state="normal") + self._output_txt.delete("1.0", "end") + self._output_txt.insert("end", text) + self._output_txt.config(state="disabled") + + def _set_output_placeholder(self): + self._output_txt.config(state="normal") + self._output_txt.delete("1.0", "end") + placeholder = ( + "Extracted information will appear here.\n\n" + "1. Add one or more documents using ➕ Add Files.\n" + "2. Click ✨ Analyze with AI.\n\n" + "The AI will extract:\n" + " • Solicitation Number & Type\n" + " • Set-Aside\n" + " • Description / Scope of Work\n" + " • Work Site / Location(s)\n" + " • Pre-Proposal Conference details\n" + " • Point of Contact (POC)\n" + " • Square Footage\n" + " • Due Date & Time\n" + " • Key deadlines & action items" + ) + if not self._is_admin: + placeholder = placeholder.replace( + "1. Add one or more documents using ➕ Add Files.\n" + "2. Click ✨ Analyze with AI.", + "1. Add one or more documents using ➕ Add Files.\n" + "2. Click ✨ Analyze with AI.\n\n" + "(Contact your administrator to configure the API key.)" + ) + self._output_txt.insert("end", placeholder, "placeholder") + self._output_txt.config(state="disabled") + + def _clear_output(self): + self._set_output_placeholder() + self._set_status("Output cleared.") + + def _copy_summary(self): + text = self._output_txt.get("1.0", "end").strip() + if not text or text.startswith("Extracted information"): + show_error("Nothing to copy yet.") + return + self.clipboard_clear() + self.clipboard_append(text) + self._set_status("Output copied to clipboard.") + + def _save_summary(self): + text = self._output_txt.get("1.0", "end").strip() + if not text or text.startswith("Extracted information"): + show_error("Nothing to save yet.") + return + path = filedialog.asksaveasfilename( + title="Save extracted information as", + defaultextension=".txt", + filetypes=[("Text file", "*.txt"), ("All files", "*.*")], + initialfile="ai_extraction.txt", + ) + if not path: + return + try: + with open(path, "w", encoding="utf-8") as f: + f.write(text) + self._set_status(f"Saved to: {path}") + show_info(f"File saved to:\n{path}") + except Exception as e: + show_error(f"Could not save file:\n{e}") + + # -- Settings (admin only) ------------------------------------------------- + + def _toggle_key_visibility(self): + current = self._key_entry.cget("show") + self._key_entry.config(show="" if current == "•" else "•") + + def _on_save_settings(self): + self._save_config() + self._set_status("Groq settings saved.") + show_info("Settings saved successfully.") + + # -- Analyze --------------------------------------------------------------- + + def _on_summarize(self): + if self._running: + return + + api_key = self._api_key_var.get().strip() + if not api_key: + msg = ( + "No Groq API key is configured.\n\n" + "Please ask your administrator to set one up." + if not self._is_admin else + "Please enter your Groq API key above.\n\n" + "You can get one for free at https://console.groq.com" + ) + show_error(msg, title="API Key Required") + return + + if not self._files: + show_error("Please add at least one document file.", title="No Files") + return + + self._start_spinner() + threading.Thread( + target=self._run_analyze, + args=(api_key, self._model_var.get(), list(self._files)), + daemon=True, + ).start() + + def _run_analyze(self, api_key: str, model: str, file_paths: list[str]): + """Background thread: extract text -> call Groq -> post result.""" + try: + # 1. Extract text from each file + combined_text = "" + + for fp in file_paths: + self.after(0, lambda f=fp: self._set_status( + f"Reading: {os.path.basename(f)} ...")) + try: + text = _extract_text(fp) + except Exception as ex: + combined_text += ( + f"\n\n{'─' * 40}\n" + f"FILE: {os.path.basename(fp)}\n" + f"{'─' * 40}\n" + f"[ERROR: Could not read this file — {ex}]\n" + ) + continue + + if not text.strip(): + combined_text += ( + f"\n\n{'─' * 40}\n" + f"FILE: {os.path.basename(fp)}\n" + f"{'─' * 40}\n" + f"[No readable text found in this file.]\n" + ) + continue + + # Truncate very large files to stay within token limits + MAX_CHARS_PER_FILE = 14_000 + if len(text) > MAX_CHARS_PER_FILE: + text = (text[:MAX_CHARS_PER_FILE] + + "\n\n[... content truncated to fit token limit ...]") + + combined_text += ( + f"\n\n{'─' * 40}\n" + f"FILE: {os.path.basename(fp)}\n" + f"{'─' * 40}\n" + f"{text}" + ) + + if not combined_text.strip(): + self.after(0, lambda: ( + self._stop_spinner(), + show_error("No readable text found in the selected files."), + )) + return + + # 2. Build prompt + n = len(file_paths) + prompt = _EXTRACTION_PROMPT.format( + n=n, documents=combined_text) + + # 3. Call Groq API + self.after(0, lambda: self._set_status( + "Sending to Groq AI ... please wait.")) + + from groq import Groq + client = Groq(api_key=api_key) + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=0.2, + max_tokens=4096, + ) + result = response.choices[0].message.content.strip() + + # 4. Build output header + file_list_str = "\n".join( + f" - {os.path.basename(fp)}" for fp in file_paths + ) + header = ( + f"AI Extraction | Model: {model}\n" + f"Files analyzed ({n}):\n{file_list_str}\n" + f"{'=' * 60}\n\n" + ) + full_output = header + result + + self.after(0, lambda t=full_output: self._on_success(t)) + + except Exception as exc: + self.after(0, lambda e=exc: self._on_error(e)) + + def _on_success(self, text: str): + self._stop_spinner() + self._set_output_text(text) + self._set_status("Analysis complete.") + logger.info( + f"[AI SUMMARY] Completed for user " + f"'{self.current_user.get('username')}' " + f"({len(self._files)} file(s))." + ) + + def _on_error(self, exc: Exception): + self._stop_spinner() + err_msg = str(exc) + if "401" in err_msg or "invalid_api_key" in err_msg.lower(): + err_msg = "Invalid API key. Please check your Groq API key and try again." + elif "429" in err_msg or "rate_limit" in err_msg.lower(): + err_msg = "Rate limit reached. Please wait a moment and try again." + elif "connection" in err_msg.lower(): + err_msg = "Could not connect to Groq. Please check your internet connection." + show_error(f"AI analysis failed:\n\n{err_msg}", title="Groq API Error") + self._set_status("Error - see popup for details.") + logger.error(f"[AI SUMMARY] Error: {exc}") + + # -- Spinner helpers ------------------------------------------------------- + + def _start_spinner(self): + self._running = True + self._run_btn.config(state="disabled", text="⏳ Processing ...") + self._progress.pack(fill="x", pady=(6, 0)) + self._progress.start(12) + + def _stop_spinner(self): + self._running = False + self._progress.stop() + self._progress.pack_forget() + self._run_btn.config(state="normal", text="✨ Analyze with AI") + + # -- Status bar ------------------------------------------------------------ + + def _set_status(self, msg: str): + self._status_var.set(msg) + + +# ------------------------------------------------------------------------------ +# Text extraction helpers +# ------------------------------------------------------------------------------ + +def _extract_text(file_path: str) -> str: + """Return the plain-text content of a supported file.""" + ext = os.path.splitext(file_path)[1].lower() + + if ext in (".txt", ".md", ".csv"): + return _read_text_file(file_path) + if ext == ".pdf": + return _read_pdf(file_path) + if ext == ".docx": + return _read_docx(file_path) + if ext in (".xlsx", ".xls"): + return _read_excel(file_path) + raise ValueError(f"Unsupported file type: {ext}") + + +def _read_text_file(path: str) -> str: + for enc in ("utf-8", "utf-8-sig", "cp1252", "latin-1"): + try: + with open(path, "r", encoding=enc) as f: + return f.read() + except UnicodeDecodeError: + continue + raise ValueError("Cannot decode text file with common encodings.") + + +def _read_pdf(path: str) -> str: + try: + import pypdf + reader = pypdf.PdfReader(path) + return "\n".join(page.extract_text() or "" for page in reader.pages) + except ImportError: + pass + try: + import PyPDF2 + with open(path, "rb") as f: + reader = PyPDF2.PdfReader(f) + return "\n".join(page.extract_text() or "" for page in reader.pages) + except ImportError: + raise ImportError( + "PDF reading requires 'pypdf'.\n" + "Install it with: pip install pypdf" + ) + + +def _read_docx(path: str) -> str: + try: + import docx + doc = docx.Document(path) + return "\n".join(p.text for p in doc.paragraphs) + except ImportError: + raise ImportError( + "DOCX reading requires 'python-docx'.\n" + "Install it with: pip install python-docx" + ) + + +def _read_excel(path: str) -> str: + try: + import openpyxl + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + lines = [] + for sheet in wb.worksheets: + lines.append(f"[Sheet: {sheet.title}]") + for row in sheet.iter_rows(values_only=True): + row_str = "\t".join( + str(v) if v is not None else "" for v in row) + if row_str.strip(): + lines.append(row_str) + return "\n".join(lines) + except ImportError: + raise ImportError( + "Excel reading requires 'openpyxl'.\n" + "Install it with: pip install openpyxl" + ) + + +# -- Utility helpers ----------------------------------------------------------- + +def _file_icon(path: str) -> str: + ext = os.path.splitext(path)[1].lower() + return { + ".pdf": "📄", + ".docx": "📝", + ".xlsx": "📊", + ".xls": "📊", + ".csv": "📋", + ".txt": "🗒", + ".md": "📓", + }.get(ext, "📁") + + +def _human_size(n: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.0f} {unit}" + n /= 1024 + return f"{n:.1f} GB"