04/22 Add AI Analyze document
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user