04/22 Add AI Analyze document

This commit is contained in:
2026-04-22 14:32:58 -04:00
parent bcfbf516df
commit 87cf54124a
5 changed files with 821 additions and 1 deletions
+9
View File
@@ -148,11 +148,13 @@ class App(tk.Tk):
("📊 My Shifts", "shifts", self._show_shifts), ("📊 My Shifts", "shifts", self._show_shifts),
("📑 Reports", "reports", self._show_reports), ("📑 Reports", "reports", self._show_reports),
("📧 Email Reports","email", self._show_email_settings), ("📧 Email Reports","email", self._show_email_settings),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("⚙ Settings", "settings", self._show_settings), ("⚙ Settings", "settings", self._show_settings),
] ]
else: else:
nav_items = [ 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: for label, key, cmd in nav_items:
@@ -311,6 +313,12 @@ class App(tk.Tk):
from views.email_settings_view import EmailSettingsView from views.email_settings_view import EmailSettingsView
EmailSettingsView(self, self.current_user) 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): def _on_settings_saved(self):
"""Called after settings are saved — reload config and reconnect.""" """Called after settings are saved — reload config and reconnect."""
from config import reload_db_config from config import reload_db_config
@@ -481,6 +489,7 @@ class App(tk.Tk):
"shifts": self._show_shifts, "shifts": self._show_shifts,
"reports": self._show_reports, "reports": self._show_reports,
"email": self._show_email_settings, "email": self._show_email_settings,
"ai_summary": self._show_ai_summary,
"settings": self._show_settings, "settings": self._show_settings,
} }
if active in nav_map: if active in nav_map:
+4
View File
@@ -8,3 +8,7 @@ password = 7x+MxGmks_3U
[crypto] [crypto]
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ= salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
[groq]
api_key = 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
model = llama-3.3-70b-versatile
BIN
View File
Binary file not shown.
+3
View File
@@ -5,3 +5,6 @@ cryptography>=41.0.0
matplotlib>=3.7.0 matplotlib>=3.7.0
plyer>=2.1.0 plyer>=2.1.0
reportlab>=4.0.0 reportlab>=4.0.0
groq>=1.0.0
pypdf>=3.0.0
python-docx>=1.0.0
+804
View File
@@ -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("<Button-3>", 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"