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 0000000..ab2671e Binary files /dev/null and b/example.xlsx differ diff --git a/requirements.txt b/requirements.txt index 9286445..63e1ff3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,6 @@ cryptography>=41.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"