895 lines
32 KiB
Python
895 lines
32 KiB
Python
"""
|
||
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",
|
||
".doc", ".docx",
|
||
".xlsx", ".xls",
|
||
}
|
||
|
||
FILE_DIALOG_TYPES = [
|
||
("Supported documents",
|
||
"*.txt *.md *.csv *.pdf *.docx *.doc *.xlsx *.xls"),
|
||
("Text files", "*.txt *.md *.csv"),
|
||
("PDF files", "*.pdf"),
|
||
("Word documents", "*.docx *.doc"),
|
||
("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) --------------------
|
||
_OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA"
|
||
|
||
_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.
|
||
|
||
IMPORTANT — Our office is located at:
|
||
{office}
|
||
Use this as the ORIGIN address for all driving distance and travel time \
|
||
calculations in field #9 below.
|
||
|
||
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, full address — mandatory or optional)
|
||
7. Point of Contact (POC) (name, phone, email)
|
||
8. Total Square Footage (if applicable)
|
||
9. Driving Distance & Travel Time
|
||
- Origin: {office}
|
||
- Destination: the Pre-Proposal Conference or Site-Visit address from field #6 \
|
||
(if no conference, use the primary Work Site address from field #5)
|
||
- Provide your best estimate of driving distance (miles) and typical \
|
||
driving time using major highways
|
||
- Note that these are AI estimates; actual times may vary with traffic
|
||
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
|
||
from utils.config_crypto import decrypt_value
|
||
cfg = configparser.ConfigParser()
|
||
cfg.read(CONFIG_FILE, encoding="utf-8")
|
||
if cfg.has_section(_CFG_SECTION):
|
||
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
|
||
self._api_key_var.set(decrypt_value(raw_key))
|
||
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 (key encrypted)."""
|
||
try:
|
||
import configparser
|
||
from config import CONFIG_FILE
|
||
from utils.config_crypto import encrypt_value
|
||
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,
|
||
encrypt_value(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, office=_OFFICE_ADDRESS, 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 == ".doc":
|
||
return _read_doc(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:
|
||
"""Read modern .docx (Office Open XML) via python-docx."""
|
||
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_doc(path: str) -> str:
|
||
"""Read legacy .doc (binary OLE) files.
|
||
|
||
Strategy (Windows):
|
||
1. win32com — automates Word to save as temp .docx, then reads it.
|
||
Requires Microsoft Word to be installed; most reliable.
|
||
2. docx2txt — pure-Python fallback that can sometimes handle simple .doc
|
||
files by treating them as ZIP-like structures (works for some .doc).
|
||
3. Raw text scrape — last resort ASCII extraction from the binary.
|
||
"""
|
||
# --- Strategy 1: Word COM automation (Windows + Word installed) -----------
|
||
try:
|
||
import win32com.client
|
||
import tempfile
|
||
import pythoncom
|
||
pythoncom.CoInitialize()
|
||
word = win32com.client.Dispatch("Word.Application")
|
||
word.Visible = False
|
||
try:
|
||
abs_path = os.path.abspath(path)
|
||
doc = word.Documents.Open(abs_path)
|
||
# Save as a temp .docx so python-docx can read it cleanly
|
||
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tf:
|
||
tmp_path = tf.name
|
||
doc.SaveAs2(tmp_path, FileFormat=16) # 16 = wdFormatXMLDocument
|
||
doc.Close(False)
|
||
text = _read_docx(tmp_path)
|
||
return text
|
||
finally:
|
||
try:
|
||
word.Quit()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except Exception:
|
||
pass
|
||
pythoncom.CoUninitialize()
|
||
except Exception:
|
||
pass # Word not installed or COM error — try next strategy
|
||
|
||
# --- Strategy 2: docx2txt (pure Python, works on some .doc files) --------
|
||
try:
|
||
import docx2txt
|
||
text = docx2txt.process(path)
|
||
if text and text.strip():
|
||
return text
|
||
except Exception:
|
||
pass
|
||
|
||
# --- Strategy 3: raw ASCII scrape from binary ----------------------------
|
||
try:
|
||
with open(path, "rb") as f:
|
||
raw = f.read()
|
||
# Extract printable ASCII runs of 4+ chars
|
||
import re
|
||
chunks = re.findall(rb"[ -~]{4,}", raw)
|
||
text = "\n".join(c.decode("ascii", errors="ignore") for c in chunks)
|
||
if text.strip():
|
||
return text
|
||
except Exception:
|
||
pass
|
||
|
||
raise ValueError(
|
||
f"Could not extract text from '{os.path.basename(path)}'.\n"
|
||
"For best results with .doc files, install Microsoft Word "
|
||
"or convert the file to .docx before uploading."
|
||
)
|
||
|
||
|
||
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": "📄",
|
||
".doc": "📝",
|
||
".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"
|