1676 lines
64 KiB
Python
1676 lines
64 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)
|
||
sees Criteria panel with Add / Edit / Delete controls
|
||
User : settings are hidden; uses the stored API key transparently
|
||
sees Criteria panel in read-only mode
|
||
|
||
The AI prompt has two stages:
|
||
1. Extract procurement/solicitation fields from the document(s).
|
||
2. Evaluate the opportunity against the configured criteria and
|
||
render a PURSUE / PASS / UNCLEAR verdict with per-criterion reasoning.
|
||
|
||
Layout
|
||
------
|
||
Top : page header + (admin only) API-key / model config bar
|
||
Middle : Criteria panel (collapsible)
|
||
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, confirm_delete,
|
||
)
|
||
|
||
logger = logging.getLogger("ai_summary_view")
|
||
|
||
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_MODELS = [
|
||
"llama-3.3-70b-versatile",
|
||
"llama-3.1-8b-instant",
|
||
"gemma2-9b-it",
|
||
"mixtral-8x7b-32768",
|
||
]
|
||
|
||
_CFG_SECTION = "groq"
|
||
_CFG_KEY_KEY = "api_key"
|
||
_CFG_KEY_MODEL = "model"
|
||
|
||
_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)
|
||
7. Point of Contact (POC) (name, phone, email)
|
||
8. Total Square Footage (if applicable)
|
||
9. Driving Distance & Travel Time
|
||
- Origin: {office}
|
||
- Destination: Pre-Proposal Conference or primary Work Site address
|
||
- 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
|
||
B. Contract Period
|
||
C. Proposal Submission Requirements
|
||
D. Key Deadlines & Action Items
|
||
|
||
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}
|
||
"""
|
||
|
||
# Criteria alignment prompt suffix — appended when active criteria exist
|
||
_CRITERIA_PROMPT_SUFFIX = """
|
||
|
||
================================================================================
|
||
OPPORTUNITY ALIGNMENT EVALUATION
|
||
================================================================================
|
||
|
||
After completing the extraction and summary above, evaluate whether this
|
||
opportunity aligns with our company's interests based on the following criteria.
|
||
|
||
OUR EVALUATION CRITERIA:
|
||
{criteria_list}
|
||
|
||
For EACH criterion above:
|
||
- State whether the opportunity MEETS, DOES NOT MEET, or PARTIALLY MEETS it.
|
||
- Provide a brief, specific explanation citing details from the document(s).
|
||
|
||
Then provide an OVERALL RECOMMENDATION using EXACTLY one of these three labels
|
||
on its own line (this label is machine-read — do not alter it):
|
||
|
||
RECOMMENDATION: PURSUE
|
||
RECOMMENDATION: PASS
|
||
RECOMMENDATION: UNCLEAR
|
||
|
||
Use PURSUE if the opportunity clearly meets most criteria and presents strong
|
||
alignment. Use PASS if it clearly fails key criteria. Use UNCLEAR if the
|
||
documents lack sufficient information to make a confident determination.
|
||
|
||
End with a 2-3 sentence EXECUTIVE SUMMARY explaining your recommendation
|
||
in plain business language.
|
||
"""
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
|
||
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 = []
|
||
self._running = False
|
||
|
||
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):
|
||
try:
|
||
from config import get_setting
|
||
from utils.config_crypto import decrypt_value
|
||
raw_key = get_setting("groq.api_key", "")
|
||
if raw_key:
|
||
self._api_key_var.set(decrypt_value(raw_key))
|
||
model = get_setting("groq.model", 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):
|
||
try:
|
||
from config import set_setting
|
||
from utils.config_crypto import encrypt_value
|
||
set_setting("groq.api_key", encrypt_value(self._api_key_var.get().strip()))
|
||
set_setting("groq.model", self._model_var.get())
|
||
except Exception as e:
|
||
logger.warning(f"Could not save Groq config: {e}")
|
||
|
||
# -- UI construction -------------------------------------------------------
|
||
|
||
def _build_ui(self):
|
||
C = COLOURS
|
||
|
||
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))
|
||
|
||
if self._is_admin:
|
||
self._build_admin_config_bar()
|
||
|
||
self._build_criteria_panel()
|
||
|
||
# Notebook — Analyze tab (existing layout) + History tab (new)
|
||
self._nb = ttk.Notebook(self)
|
||
self._nb.pack(fill="both", expand=True, pady=(0, 6))
|
||
|
||
# ── Analyze tab ───────────────────────────────────────────────────────
|
||
analyze_tab = tk.Frame(self._nb, bg=C["bg"])
|
||
self._nb.add(analyze_tab, text="✨ Analyze")
|
||
|
||
pane = tk.PanedWindow(analyze_tab, orient="horizontal",
|
||
bg=C["border"], sashwidth=4, sashrelief="flat")
|
||
pane.pack(fill="both", expand=True)
|
||
|
||
left = tk.Frame(pane, bg=C["bg"])
|
||
pane.add(left, minsize=260, width=300)
|
||
self._build_file_panel(left)
|
||
|
||
right = tk.Frame(pane, bg=C["bg"])
|
||
pane.add(right, minsize=350)
|
||
self._build_output_panel(right)
|
||
|
||
# ── History tab ───────────────────────────────────────────────────────
|
||
history_tab = tk.Frame(self._nb, bg=C["bg"])
|
||
self._nb.add(history_tab, text="🕑 History")
|
||
self._build_history_panel(history_tab)
|
||
self._nb.bind("<<NotebookTabChanged>>", self._on_tab_changed)
|
||
|
||
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):
|
||
C = COLOURS
|
||
cfg_card = tk.Frame(self, bg=C["surface"], pady=10, padx=14)
|
||
cfg_card.pack(fill="x", pady=(0, 10))
|
||
|
||
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)
|
||
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)
|
||
|
||
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)
|
||
|
||
# -- Criteria panel --------------------------------------------------------
|
||
|
||
def _build_criteria_panel(self):
|
||
"""
|
||
Collapsible criteria panel.
|
||
Admin: full CRUD controls.
|
||
User: read-only treeview.
|
||
"""
|
||
C = COLOURS
|
||
self._criteria_card = tk.Frame(self, bg=C["surface"], padx=14, pady=8)
|
||
self._criteria_card.pack(fill="x", pady=(0, 10))
|
||
|
||
hdr = tk.Frame(self._criteria_card, bg=C["surface"])
|
||
hdr.pack(fill="x")
|
||
|
||
tk.Label(hdr, text="📋 Evaluation Criteria",
|
||
bg=C["surface"], fg=C["text"],
|
||
font=FONT_BOLD).pack(side="left")
|
||
|
||
self._criteria_expanded = True
|
||
self._criteria_toggle_btn = tk.Button(
|
||
hdr, text="▲ Collapse",
|
||
command=self._toggle_criteria_panel,
|
||
bg=C["surface"], fg=C["text_dim"],
|
||
activebackground=C["surface2"], activeforeground=C["text"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2", padx=6,
|
||
)
|
||
self._criteria_toggle_btn.pack(side="right")
|
||
|
||
if self._is_admin:
|
||
tk.Button(
|
||
hdr, text="+ Add Criterion",
|
||
command=self._open_add_criterion,
|
||
bg=C["accent"], fg=C["white"],
|
||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||
padx=8, pady=3,
|
||
).pack(side="right", padx=(0, 6))
|
||
|
||
# Collapsible body
|
||
self._criteria_body = tk.Frame(self._criteria_card, bg=C["surface"])
|
||
self._criteria_body.pack(fill="x", pady=(8, 0))
|
||
|
||
cols = ("Order", "Title", "Description", "Active")
|
||
widths = [55, 180, 400, 55]
|
||
self._criteria_tree = ttk.Treeview(
|
||
self._criteria_body, columns=cols,
|
||
show="headings", selectmode="browse", height=5,
|
||
)
|
||
for col, w in zip(cols, widths):
|
||
self._criteria_tree.heading(col, text=col)
|
||
self._criteria_tree.column(col, width=w,
|
||
anchor="center" if w <= 60 else "w")
|
||
self._criteria_tree.tag_configure("inactive", foreground=C["text_dim"])
|
||
|
||
vsb = ttk.Scrollbar(self._criteria_body, orient="vertical",
|
||
command=self._criteria_tree.yview)
|
||
self._criteria_tree.configure(yscrollcommand=vsb.set)
|
||
vsb.pack(side="right", fill="y")
|
||
self._criteria_tree.pack(side="left", fill="x", expand=True)
|
||
|
||
if self._is_admin:
|
||
btn_bar = tk.Frame(self._criteria_card, bg=C["surface"], pady=4)
|
||
btn_bar.pack(fill="x")
|
||
tk.Button(
|
||
btn_bar, text="✎ Edit",
|
||
command=self._open_edit_criterion,
|
||
bg=C["surface2"], fg=C["text"],
|
||
activebackground=C["accent"], activeforeground=C["white"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||
padx=8, pady=3,
|
||
).pack(side="left", padx=(0, 4))
|
||
tk.Button(
|
||
btn_bar, text="✕ Delete",
|
||
command=self._delete_criterion,
|
||
bg=C["surface2"], fg=C["danger"],
|
||
activebackground=C["danger"], activeforeground=C["white"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||
padx=8, pady=3,
|
||
).pack(side="left", padx=(0, 4))
|
||
self._criteria_tree.bind("<Double-1>",
|
||
lambda _: self._open_edit_criterion())
|
||
else:
|
||
# User (read-only): provide a View Details button and double-click binding
|
||
btn_bar = tk.Frame(self._criteria_card, bg=C["surface"], pady=4)
|
||
btn_bar.pack(fill="x")
|
||
tk.Button(
|
||
btn_bar, text="👁 View Details",
|
||
command=self._open_view_criterion,
|
||
bg=C["surface2"], fg=C["text"],
|
||
activebackground=C["accent"], activeforeground=C["white"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||
padx=8, pady=3,
|
||
).pack(side="left", padx=(0, 4))
|
||
self._criteria_tree.bind("<Double-1>",
|
||
lambda _: self._open_view_criterion())
|
||
|
||
tk.Label(
|
||
self._criteria_card,
|
||
text=(
|
||
"These criteria are used by the AI to evaluate opportunities. "
|
||
"Add, edit, or delete criteria to refine alignment decisions."
|
||
if self._is_admin else
|
||
"These criteria are used by the AI to evaluate whether "
|
||
"the opportunity is worth pursuing."
|
||
),
|
||
bg=C["surface"], fg=C["text_dim"],
|
||
font=FONT_SMALL, wraplength=900, justify="left",
|
||
).pack(anchor="w", pady=(4, 0))
|
||
|
||
self._load_criteria_tree()
|
||
|
||
def _toggle_criteria_panel(self):
|
||
if self._criteria_expanded:
|
||
self._criteria_body.pack_forget()
|
||
self._criteria_toggle_btn.config(text="▼ Expand")
|
||
self._criteria_expanded = False
|
||
else:
|
||
self._criteria_body.pack(fill="x", pady=(8, 0))
|
||
self._criteria_toggle_btn.config(text="▲ Collapse")
|
||
self._criteria_expanded = True
|
||
|
||
def _load_criteria_tree(self):
|
||
self._criteria_tree.delete(*self._criteria_tree.get_children())
|
||
try:
|
||
from models import get_all_criteria
|
||
for row in get_all_criteria():
|
||
active = "✔" if row["is_active"] else "✘"
|
||
tag = "" if row["is_active"] else "inactive"
|
||
desc = (row["description"] or "")[:80]
|
||
if len(row["description"] or "") > 80:
|
||
desc += "…"
|
||
self._criteria_tree.insert(
|
||
"", "end", iid=str(row["id"]), tags=(tag,),
|
||
values=(row["sort_order"], row["title"], desc, active),
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Failed to load criteria: {e}")
|
||
|
||
def _get_selected_criterion_id(self):
|
||
sel = self._criteria_tree.selection()
|
||
return int(sel[0]) if sel else None
|
||
|
||
def _open_add_criterion(self):
|
||
CriterionDialog(self, self.current_user,
|
||
criterion_data=None, on_save=self._load_criteria_tree)
|
||
|
||
def _open_edit_criterion(self):
|
||
cid = self._get_selected_criterion_id()
|
||
if not cid:
|
||
show_error("Please select a criterion to edit.")
|
||
return
|
||
try:
|
||
from models import get_all_criteria
|
||
all_rows = {r["id"]: r for r in get_all_criteria()}
|
||
data = all_rows.get(cid)
|
||
except Exception as e:
|
||
show_error(f"Could not load criterion: {e}")
|
||
return
|
||
if not data:
|
||
show_error("Criterion not found.")
|
||
return
|
||
CriterionDialog(self, self.current_user,
|
||
criterion_data=data, on_save=self._load_criteria_tree)
|
||
|
||
def _open_view_criterion(self):
|
||
"""Open a read-only detail dialog for the selected criterion (user role)."""
|
||
cid = self._get_selected_criterion_id()
|
||
if not cid:
|
||
show_error("Please select a criterion to view.")
|
||
return
|
||
try:
|
||
from models import get_all_criteria
|
||
all_rows = {r["id"]: r for r in get_all_criteria()}
|
||
data = all_rows.get(cid)
|
||
except Exception as e:
|
||
show_error(f"Could not load criterion: {e}")
|
||
return
|
||
if not data:
|
||
show_error("Criterion not found.")
|
||
return
|
||
logger.info(
|
||
f"Criterion id={cid} viewed by user_id={self.current_user['id']}."
|
||
)
|
||
CriterionDetailDialog(self, data)
|
||
|
||
def _delete_criterion(self):
|
||
cid = self._get_selected_criterion_id()
|
||
if not cid:
|
||
show_error("Please select a criterion to delete.")
|
||
return
|
||
vals = self._criteria_tree.item(cid, "values")
|
||
title = vals[1] if vals else str(cid)
|
||
if confirm_delete(title):
|
||
try:
|
||
from models import delete_criterion
|
||
delete_criterion(self.current_user["id"], cid)
|
||
logger.info(f"Criterion id={cid} deleted by "
|
||
f"admin_id={self.current_user['id']}.")
|
||
show_info(f"Criterion '{title}' deleted.")
|
||
self._load_criteria_tree()
|
||
except Exception as e:
|
||
show_error(f"Delete failed:\n{e}")
|
||
|
||
# -- File panel ------------------------------------------------------------
|
||
|
||
def _build_file_panel(self, parent):
|
||
C = COLOURS
|
||
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))
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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")
|
||
self._progress = ttk.Progressbar(btn_frame, mode="indeterminate")
|
||
self._refresh_file_list()
|
||
|
||
# -- Output panel ----------------------------------------------------------
|
||
|
||
def _build_output_panel(self, parent):
|
||
C = COLOURS
|
||
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))
|
||
|
||
# Verdict banner — hidden until a result with criteria arrives
|
||
self._verdict_frame = tk.Frame(parent, bg=C["surface2"], pady=10)
|
||
self._verdict_label = tk.Label(
|
||
self._verdict_frame, text="",
|
||
bg=C["surface2"], fg=C["white"],
|
||
font=(FONT_BOLD[0], 13, "bold"),
|
||
)
|
||
self._verdict_label.pack(side="left", padx=16)
|
||
self._verdict_sub = tk.Label(
|
||
self._verdict_frame, text="",
|
||
bg=C["surface2"], fg=C["white"],
|
||
font=FONT_SMALL,
|
||
)
|
||
self._verdict_sub.pack(side="left", padx=4)
|
||
# Not packed initially — shown after successful analysis with criteria
|
||
|
||
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):
|
||
for i in reversed(list(self._file_lb.curselection())):
|
||
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\n\n"
|
||
"If evaluation criteria are configured, the AI will also assess\n"
|
||
"whether the opportunity aligns with your company's interests."
|
||
)
|
||
self._output_txt.insert("end", placeholder, "placeholder")
|
||
self._output_txt.config(state="disabled")
|
||
self._hide_verdict_banner()
|
||
|
||
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}")
|
||
|
||
# -- Verdict banner --------------------------------------------------------
|
||
|
||
def _show_verdict_banner(self, verdict: str):
|
||
"""Display a colour-coded alignment verdict above the output text."""
|
||
C = COLOURS
|
||
colours = {
|
||
"PURSUE": (C["success"],
|
||
"✅ PURSUE THIS OPPORTUNITY",
|
||
"Strong alignment with your evaluation criteria."),
|
||
"PASS": (C["danger"],
|
||
"🚫 PASS ON THIS OPPORTUNITY",
|
||
"Does not meet one or more key criteria."),
|
||
"UNCLEAR": (C["warning"],
|
||
"⚠️ UNCLEAR — REVIEW MANUALLY",
|
||
"Insufficient information for a confident determination."),
|
||
}
|
||
bg, label, sub = colours.get(
|
||
verdict.upper(),
|
||
(C["surface2"], f"RECOMMENDATION: {verdict}", ""),
|
||
)
|
||
self._verdict_frame.config(bg=bg)
|
||
self._verdict_label.config(text=label, bg=bg, fg=C["white"])
|
||
self._verdict_sub.config(text=sub, bg=bg, fg=C["white"])
|
||
self._verdict_frame.pack(fill="x", pady=(0, 4))
|
||
|
||
def _hide_verdict_banner(self):
|
||
self._verdict_frame.pack_forget()
|
||
|
||
# -- 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
|
||
|
||
# Fetch active criteria to include in the prompt
|
||
try:
|
||
from models import get_active_criteria
|
||
active_criteria = get_active_criteria()
|
||
except Exception as e:
|
||
logger.warning(f"Could not load active criteria for prompt: {e}")
|
||
active_criteria = []
|
||
|
||
self._start_spinner()
|
||
threading.Thread(
|
||
target=self._run_analyze,
|
||
args=(api_key, self._model_var.get(),
|
||
list(self._files), active_criteria),
|
||
daemon=True,
|
||
).start()
|
||
|
||
def _run_analyze(self, api_key: str, model: str,
|
||
file_paths: list, active_criteria: list):
|
||
"""Background thread: extract text -> call Groq -> post result."""
|
||
try:
|
||
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
|
||
|
||
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
|
||
|
||
# Build prompt; append criteria section when criteria are active
|
||
n = len(file_paths)
|
||
prompt = _EXTRACTION_PROMPT.format(
|
||
n=n, office=_OFFICE_ADDRESS, documents=combined_text)
|
||
|
||
if active_criteria:
|
||
criteria_list = "\n".join(
|
||
f" {i+1}. {c['title']}: {c['description']}"
|
||
for i, c in enumerate(active_criteria)
|
||
)
|
||
prompt += _CRITERIA_PROMPT_SUFFIX.format(
|
||
criteria_list=criteria_list)
|
||
|
||
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()
|
||
|
||
# Parse machine-readable verdict (only present when criteria used)
|
||
verdict = _parse_verdict(result) if active_criteria else None
|
||
|
||
file_list_str = "\n".join(
|
||
f" - {os.path.basename(fp)}" for fp in file_paths)
|
||
criteria_note = (
|
||
f"\nCriteria evaluated: {len(active_criteria)}"
|
||
if active_criteria else
|
||
"\nNo evaluation criteria configured."
|
||
)
|
||
header = (
|
||
f"AI Extraction | Model: {model}\n"
|
||
f"Files analyzed ({n}):\n{file_list_str}\n"
|
||
f"{criteria_note}\n"
|
||
f"{'=' * 60}\n\n"
|
||
)
|
||
full_output = header + result
|
||
|
||
# Build artefacts for history persistence
|
||
file_names_str = ", ".join(os.path.basename(fp) for fp in file_paths)
|
||
criteria_snapshot = (
|
||
"\n".join(
|
||
f"{i+1}. {c['title']}: {c['description']}"
|
||
for i, c in enumerate(active_criteria)
|
||
) if active_criteria else None
|
||
)
|
||
|
||
self.after(0, lambda t=full_output, v=verdict,
|
||
fn=file_names_str, m=model, cs=criteria_snapshot:
|
||
self._on_success(t, v, fn, m, cs))
|
||
|
||
except Exception as exc:
|
||
self.after(0, lambda e=exc: self._on_error(e))
|
||
|
||
def _on_success(self, text: str, verdict, file_names: str,
|
||
model: str, criteria_snapshot: str):
|
||
self._stop_spinner()
|
||
self._set_output_text(text)
|
||
if verdict:
|
||
self._show_verdict_banner(verdict)
|
||
else:
|
||
self._hide_verdict_banner()
|
||
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)). Verdict: {verdict or 'N/A'}."
|
||
)
|
||
# Persist to ai_analysis_log so the history panel can display it
|
||
try:
|
||
from models import save_ai_analysis
|
||
save_ai_analysis(
|
||
user_id=self.current_user["id"],
|
||
file_names=file_names,
|
||
model=model,
|
||
verdict=verdict,
|
||
criteria_snapshot=criteria_snapshot,
|
||
summary_text=text,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[AI SUMMARY] Could not save analysis to history: {e}")
|
||
# Refresh history panel if it exists (it is built lazily on tab switch)
|
||
if hasattr(self, "_refresh_history"):
|
||
try:
|
||
self._refresh_history()
|
||
except Exception:
|
||
pass
|
||
|
||
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")
|
||
|
||
# -- History panel --------------------------------------------------------
|
||
|
||
def _build_history_panel(self, parent):
|
||
"""
|
||
Build the analysis history tab.
|
||
Admin: sees all users' analyses.
|
||
User: sees only their own.
|
||
Treeview columns: Date/Time, User (admin only), Files, Model, Verdict
|
||
Double-click or View button restores the full result in the output panel.
|
||
"""
|
||
C = COLOURS
|
||
|
||
# ── Toolbar ───────────────────────────────────────────────────────────
|
||
toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10)
|
||
toolbar.pack(fill="x")
|
||
|
||
tk.Label(toolbar, text="📜 Analysis History",
|
||
bg=C["surface"], fg=C["text"],
|
||
font=FONT_BOLD).pack(side="left")
|
||
|
||
tk.Button(
|
||
toolbar, text="🔍 View Result",
|
||
command=self._history_view_selected,
|
||
bg=C["accent"], fg=C["white"],
|
||
activebackground=C["accent_hover"], activeforeground=C["white"],
|
||
relief="flat", font=FONT_SMALL, cursor="hand2",
|
||
padx=8, pady=4,
|
||
).pack(side="right", padx=(4, 0))
|
||
|
||
tk.Button(
|
||
toolbar, text="↻ Refresh",
|
||
command=self._refresh_history,
|
||
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))
|
||
|
||
# ── Verdict filter ────────────────────────────────────────────────────
|
||
filter_frame = tk.Frame(parent, bg=C["surface"], padx=10, pady=4)
|
||
filter_frame.pack(fill="x")
|
||
|
||
tk.Label(filter_frame, text="Filter by verdict:",
|
||
bg=C["surface"], fg=C["text_dim"],
|
||
font=FONT_SMALL).pack(side="left")
|
||
|
||
self._hist_filter_var = tk.StringVar(value="All")
|
||
for label in ("All", "PURSUE", "PASS", "UNCLEAR", "— none —"):
|
||
tk.Radiobutton(
|
||
filter_frame, text=label,
|
||
variable=self._hist_filter_var, value=label,
|
||
command=self._apply_history_filter,
|
||
bg=C["surface"], fg=C["text"],
|
||
activebackground=C["surface"],
|
||
activeforeground=C["accent"],
|
||
selectcolor=C["surface2"],
|
||
font=FONT_SMALL, cursor="hand2",
|
||
).pack(side="left", padx=(8, 0))
|
||
|
||
# ── Treeview ──────────────────────────────────────────────────────────
|
||
tree_frame = tk.Frame(parent, bg=C["bg"])
|
||
tree_frame.pack(fill="both", expand=True, padx=6, pady=(4, 4))
|
||
|
||
if self._is_admin:
|
||
cols = ("ID", "Date/Time", "User", "Files", "Model", "Verdict")
|
||
widths = [40, 140, 100, 280, 160, 80]
|
||
else:
|
||
cols = ("ID", "Date/Time", "Files", "Model", "Verdict")
|
||
widths = [40, 140, 360, 160, 80]
|
||
|
||
self._hist_tree = ttk.Treeview(
|
||
tree_frame, columns=cols,
|
||
show="headings", selectmode="browse",
|
||
)
|
||
for col, w in zip(cols, widths):
|
||
self._hist_tree.heading(col, text=col)
|
||
self._hist_tree.column(
|
||
col, width=w,
|
||
anchor="center" if col in ("ID", "Verdict") else "w",
|
||
)
|
||
|
||
# Colour-code verdict rows
|
||
self._hist_tree.tag_configure("PURSUE", foreground=C["success"])
|
||
self._hist_tree.tag_configure("PASS", foreground=C["danger"])
|
||
self._hist_tree.tag_configure("UNCLEAR", foreground=C["warning"])
|
||
self._hist_tree.tag_configure("none", foreground=C["text_dim"])
|
||
|
||
vsb = ttk.Scrollbar(tree_frame, orient="vertical",
|
||
command=self._hist_tree.yview)
|
||
self._hist_tree.configure(yscrollcommand=vsb.set)
|
||
vsb.pack(side="right", fill="y")
|
||
self._hist_tree.pack(side="left", fill="both", expand=True)
|
||
|
||
self._hist_tree.bind("<Double-1>",
|
||
lambda _: self._history_view_selected())
|
||
|
||
# ── Detail strip ─────────────────────────────────────────────────────
|
||
detail_bar = tk.Frame(parent, bg=C["surface2"], pady=6, padx=10)
|
||
detail_bar.pack(fill="x", side="bottom")
|
||
self._hist_detail_lbl = tk.Label(
|
||
detail_bar, text="Select a row to see details.",
|
||
bg=C["surface2"], fg=C["text_dim"],
|
||
font=FONT_SMALL, anchor="w", wraplength=900, justify="left",
|
||
)
|
||
self._hist_detail_lbl.pack(fill="x")
|
||
self._hist_tree.bind("<<TreeviewSelect>>", self._on_history_select)
|
||
|
||
# Store all loaded rows for client-side filtering
|
||
self._hist_all_rows = []
|
||
|
||
self._refresh_history()
|
||
|
||
def _refresh_history(self):
|
||
"""Reload history from DB and repopulate the treeview."""
|
||
try:
|
||
from models import get_ai_analysis_history
|
||
uid = None if self._is_admin else self.current_user["id"]
|
||
self._hist_all_rows = get_ai_analysis_history(user_id=uid, limit=200)
|
||
except Exception as e:
|
||
logger.error(f"Could not load AI analysis history: {e}")
|
||
self._hist_all_rows = []
|
||
self._apply_history_filter()
|
||
|
||
def _apply_history_filter(self):
|
||
"""Re-populate the treeview using the active verdict filter."""
|
||
self._hist_tree.delete(*self._hist_tree.get_children())
|
||
verdict_filter = self._hist_filter_var.get()
|
||
|
||
for row in self._hist_all_rows:
|
||
verdict = row.get("verdict") or ""
|
||
# Map filter labels to DB values
|
||
if verdict_filter == "All":
|
||
pass
|
||
elif verdict_filter == "— none —":
|
||
if verdict:
|
||
continue
|
||
elif verdict != verdict_filter:
|
||
continue
|
||
|
||
dt_str = str(row.get("analyzed_at", ""))[:16]
|
||
files = (row.get("file_names") or "")[:60]
|
||
if len(row.get("file_names") or "") > 60:
|
||
files += "…"
|
||
tag = verdict if verdict else "none"
|
||
|
||
if self._is_admin:
|
||
values = (
|
||
row["id"], dt_str,
|
||
row.get("username") or "—",
|
||
files, row.get("model") or "",
|
||
verdict or "—",
|
||
)
|
||
else:
|
||
values = (
|
||
row["id"], dt_str,
|
||
files, row.get("model") or "",
|
||
verdict or "—",
|
||
)
|
||
|
||
self._hist_tree.insert(
|
||
"", "end", iid=str(row["id"]),
|
||
tags=(tag,), values=values,
|
||
)
|
||
|
||
def _on_history_select(self, event=None):
|
||
"""Show a one-line detail strip when a history row is selected."""
|
||
sel = self._hist_tree.selection()
|
||
if not sel:
|
||
return
|
||
analysis_id = int(sel[0])
|
||
try:
|
||
from models import get_ai_analysis_detail
|
||
detail = get_ai_analysis_detail(analysis_id)
|
||
except Exception:
|
||
return
|
||
if not detail:
|
||
return
|
||
criteria_info = (
|
||
f" | Criteria: {detail['criteria_snapshot'][:80]}…"
|
||
if detail.get("criteria_snapshot") else
|
||
" | No criteria evaluated"
|
||
)
|
||
self._hist_detail_lbl.config(
|
||
text=(
|
||
f"ID {detail['id']} | "
|
||
f"{str(detail.get('analyzed_at', ''))[:16]} | "
|
||
f"Model: {detail.get('model', '')} | "
|
||
f"Verdict: {detail.get('verdict') or 'N/A'}"
|
||
f"{criteria_info}"
|
||
)
|
||
)
|
||
|
||
def _history_view_selected(self):
|
||
"""
|
||
Load the selected history entry's full summary into the output panel
|
||
and switch to the Analyze tab so the user can read it.
|
||
"""
|
||
sel = self._hist_tree.selection()
|
||
if not sel:
|
||
show_error("Please select an analysis to view.")
|
||
return
|
||
analysis_id = int(sel[0])
|
||
try:
|
||
from models import get_ai_analysis_detail
|
||
detail = get_ai_analysis_detail(analysis_id)
|
||
except Exception as e:
|
||
show_error(f"Could not load analysis:\n{e}")
|
||
return
|
||
if not detail:
|
||
show_error("Analysis record not found.")
|
||
return
|
||
|
||
# Switch to Analyze tab
|
||
self._nb.select(0)
|
||
# Restore output
|
||
self._set_output_text(detail["summary_text"])
|
||
verdict = detail.get("verdict")
|
||
if verdict:
|
||
self._show_verdict_banner(verdict)
|
||
else:
|
||
self._hide_verdict_banner()
|
||
self._set_status(
|
||
f"Viewing history entry #{analysis_id} "
|
||
f"({str(detail.get('analyzed_at', ''))[:16]})"
|
||
)
|
||
|
||
def _on_tab_changed(self, event=None):
|
||
"""Refresh history data whenever the user switches to the History tab."""
|
||
try:
|
||
current = self._nb.index(self._nb.select())
|
||
if current == 1: # History tab is index 1
|
||
self._refresh_history()
|
||
except Exception:
|
||
pass
|
||
|
||
# -- Status bar ------------------------------------------------------------
|
||
|
||
def _set_status(self, msg: str):
|
||
self._status_var.set(msg)
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Criterion Add / Edit Dialog (admin only)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
class CriterionDetailDialog(tk.Toplevel):
|
||
"""Read-only modal dialog that displays the full details of an AI evaluation criterion.
|
||
|
||
Shown to regular users who click 'View Details' or double-click a row in the
|
||
criteria treeview. Admins continue to use CriterionDialog (edit mode) instead.
|
||
"""
|
||
|
||
def __init__(self, parent, criterion_data: dict):
|
||
super().__init__(parent)
|
||
self._data = criterion_data
|
||
|
||
self.title("Criterion Details")
|
||
self.configure(bg=COLOURS["bg"])
|
||
self.resizable(False, False)
|
||
self.grab_set()
|
||
self._build_ui()
|
||
self._centre()
|
||
|
||
def _centre(self):
|
||
self.update_idletasks()
|
||
w, h = 560, 380
|
||
x = (self.winfo_screenwidth() - w) // 2
|
||
y = (self.winfo_screenheight() - h) // 2
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
|
||
def _build_ui(self):
|
||
C = COLOURS
|
||
d = self._data
|
||
|
||
# ── Header ────────────────────────────────────────────────────────────
|
||
hdr = tk.Frame(self, bg=C["surface"], padx=24, pady=14)
|
||
hdr.pack(fill="x")
|
||
|
||
status_text = "Active" if d.get("is_active") else "Inactive"
|
||
status_color = C["accent"] if d.get("is_active") else C["danger"]
|
||
|
||
tk.Label(
|
||
hdr,
|
||
text=d.get("title") or "Untitled",
|
||
bg=C["surface"], fg=C["text"],
|
||
font=FONT_HEADING, wraplength=480, justify="left",
|
||
).pack(anchor="w")
|
||
|
||
meta_row = tk.Frame(hdr, bg=C["surface"])
|
||
meta_row.pack(anchor="w", pady=(4, 0))
|
||
tk.Label(
|
||
meta_row,
|
||
text=f"Sort order: {d.get('sort_order', 0)}",
|
||
bg=C["surface"], fg=C["text_dim"],
|
||
font=FONT_SMALL,
|
||
).pack(side="left")
|
||
tk.Label(
|
||
meta_row, text=" · ",
|
||
bg=C["surface"], fg=C["text_dim"],
|
||
font=FONT_SMALL,
|
||
).pack(side="left")
|
||
tk.Label(
|
||
meta_row, text=status_text,
|
||
bg=C["surface"], fg=status_color,
|
||
font=FONT_SMALL,
|
||
).pack(side="left")
|
||
|
||
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=0)
|
||
|
||
# ── Description (scrollable, read-only) ───────────────────────────────
|
||
body = tk.Frame(self, bg=C["bg"], padx=24, pady=16)
|
||
body.pack(fill="both", expand=True)
|
||
|
||
tk.Label(
|
||
body, text="Description",
|
||
bg=C["bg"], fg=C["text_dim"],
|
||
font=FONT_SMALL,
|
||
).pack(anchor="w", pady=(0, 4))
|
||
|
||
txt_frame = tk.Frame(body, bg=C["surface2"])
|
||
txt_frame.pack(fill="both", expand=True)
|
||
|
||
vsb = ttk.Scrollbar(txt_frame, orient="vertical")
|
||
vsb.pack(side="right", fill="y")
|
||
|
||
txt = tk.Text(
|
||
txt_frame, wrap="word",
|
||
bg=C["surface2"], fg=C["text"],
|
||
relief="flat", font=FONT,
|
||
state="normal",
|
||
yscrollcommand=vsb.set,
|
||
padx=10, pady=8,
|
||
)
|
||
txt.insert("1.0", d.get("description") or "No description provided.")
|
||
txt.config(state="disabled") # read-only after inserting content
|
||
txt.pack(fill="both", expand=True)
|
||
vsb.config(command=txt.yview)
|
||
|
||
# ── Footer ────────────────────────────────────────────────────────────
|
||
ttk.Separator(self, orient="horizontal").pack(fill="x")
|
||
footer = ttk.Frame(self)
|
||
footer.pack(fill="x", padx=24, pady=12)
|
||
ttk.Button(
|
||
footer, text="Close",
|
||
command=self.destroy,
|
||
).pack(side="right")
|
||
|
||
|
||
class CriterionDialog(tk.Toplevel):
|
||
"""Modal dialog for creating or editing an AI evaluation criterion."""
|
||
|
||
def __init__(self, parent, current_user: dict,
|
||
criterion_data, on_save):
|
||
super().__init__(parent)
|
||
self.current_user = current_user
|
||
self.criterion_data = criterion_data
|
||
self.on_save = on_save
|
||
self.is_edit = criterion_data is not None
|
||
|
||
self.title("Edit Criterion" if self.is_edit else "Add Criterion")
|
||
self.configure(bg=COLOURS["bg"])
|
||
self.resizable(False, True) # allow vertical resize for varied DPI/fonts
|
||
self.grab_set()
|
||
self._build_ui()
|
||
self._centre()
|
||
|
||
def _centre(self):
|
||
self.update_idletasks()
|
||
# Height increased to 520 so buttons are always visible at standard DPI.
|
||
# The dialog is vertically resizable so higher-DPI systems can expand it.
|
||
w, h = 560, 520
|
||
x = (self.winfo_screenwidth() - w) // 2
|
||
y = (self.winfo_screenheight() - h) // 2
|
||
self.geometry(f"{w}x{h}+{x}+{y}")
|
||
|
||
# Recommended character limit for criterion descriptions.
|
||
# Beyond this the AI prompt grows large and eats into document token budget.
|
||
_DESC_SOFT_LIMIT = 500
|
||
|
||
def _build_ui(self):
|
||
C = COLOURS
|
||
|
||
ttk.Label(self,
|
||
text="Edit Criterion" if self.is_edit else "New Criterion",
|
||
style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4))
|
||
|
||
form = ttk.Frame(self)
|
||
form.pack(fill="x", padx=24, pady=8)
|
||
form.columnconfigure(1, weight=1)
|
||
|
||
# Row 0 — Title
|
||
ttk.Label(form, text="Title *").grid(
|
||
row=0, column=0, sticky="w", padx=(0, 10), pady=6)
|
||
self._title_var = tk.StringVar()
|
||
ttk.Entry(form, textvariable=self._title_var).grid(
|
||
row=0, column=1, sticky="ew", pady=6)
|
||
|
||
# Row 1 — Description text area
|
||
ttk.Label(form, text="Description *").grid(
|
||
row=1, column=0, sticky="nw", padx=(0, 10), pady=6)
|
||
|
||
desc_frame = tk.Frame(form, bg=C["surface2"])
|
||
desc_frame.grid(row=1, column=1, sticky="ew", pady=6)
|
||
desc_vsb = ttk.Scrollbar(desc_frame, orient="vertical")
|
||
desc_vsb.pack(side="right", fill="y")
|
||
self._desc_txt = tk.Text(
|
||
desc_frame, height=5, wrap="word",
|
||
bg=C["surface2"], fg=C["text"],
|
||
insertbackground=C["text"],
|
||
relief="flat", font=FONT,
|
||
yscrollcommand=desc_vsb.set,
|
||
)
|
||
self._desc_txt.pack(fill="both", expand=True, padx=4, pady=4)
|
||
desc_vsb.config(command=self._desc_txt.yview)
|
||
|
||
# Row 2 — Live character counter (guidance, not a hard block)
|
||
self._char_lbl = tk.Label(
|
||
form,
|
||
text=f"0 / {self._DESC_SOFT_LIMIT} chars",
|
||
bg=C["bg"], fg=C["text_dim"],
|
||
font=FONT_SMALL, anchor="e",
|
||
)
|
||
self._char_lbl.grid(row=2, column=1, sticky="e", pady=(0, 4))
|
||
|
||
def _update_char_count(event=None):
|
||
n = len(self._desc_txt.get("1.0", "end-1c"))
|
||
over = n > self._DESC_SOFT_LIMIT
|
||
colour = C["danger"] if over else C["text_dim"]
|
||
label = (
|
||
f"{n} / {self._DESC_SOFT_LIMIT} chars"
|
||
f" ⚠ exceeds recommended limit" if over else
|
||
f"{n} / {self._DESC_SOFT_LIMIT} chars"
|
||
)
|
||
self._char_lbl.config(text=label, fg=colour)
|
||
|
||
self._desc_txt.bind("<KeyRelease>", _update_char_count)
|
||
# Also update when content is inserted programmatically (edit pre-fill)
|
||
self._desc_txt.bind("<<Modified>>", _update_char_count)
|
||
|
||
# Row 3 — Sort order
|
||
ttk.Label(form, text="Sort Order").grid(
|
||
row=3, column=0, sticky="w", padx=(0, 10), pady=6)
|
||
self._order_var = tk.StringVar(value="0")
|
||
ttk.Spinbox(form, from_=0, to=999,
|
||
textvariable=self._order_var, width=8).grid(
|
||
row=3, column=1, sticky="w", pady=6)
|
||
|
||
# Row 4 — Active flag
|
||
self._active_var = tk.BooleanVar(value=True)
|
||
tk.Checkbutton(
|
||
form, text="Active (included in AI evaluation)",
|
||
variable=self._active_var,
|
||
bg=C["bg"], fg=C["text"],
|
||
activebackground=C["bg"], activeforeground=C["accent"],
|
||
selectcolor=C["surface2"], font=FONT, cursor="hand2",
|
||
).grid(row=4, column=1, sticky="w", pady=6)
|
||
|
||
tk.Label(
|
||
self,
|
||
text=("Tip: Be specific. e.g. 'Work site must be within 50 miles "
|
||
"of Falls Church, VA' or 'Solicitation must be small-business "
|
||
"set-aside.' The AI evaluates each criterion against the document."),
|
||
bg=C["bg"], fg=C["text_dim"],
|
||
font=FONT_SMALL, wraplength=500, justify="left",
|
||
).pack(anchor="w", padx=24, pady=(0, 8))
|
||
|
||
ttk.Separator(self, orient="horizontal").pack(fill="x", padx=24, pady=8)
|
||
|
||
btn_frame = ttk.Frame(self)
|
||
btn_frame.pack(fill="x", padx=24, pady=(0, 20))
|
||
ttk.Button(btn_frame, text="Save",
|
||
command=self._save).pack(side="right", padx=(6, 0))
|
||
ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton",
|
||
command=self.destroy).pack(side="right")
|
||
|
||
# Pre-populate when editing
|
||
if self.is_edit:
|
||
d = self.criterion_data
|
||
self._title_var.set(d.get("title") or "")
|
||
self._desc_txt.insert("1.0", d.get("description") or "")
|
||
self._order_var.set(str(d.get("sort_order", 0)))
|
||
self._active_var.set(bool(d.get("is_active", True)))
|
||
# Trigger counter update now that content has been inserted
|
||
_update_char_count()
|
||
|
||
def _save(self):
|
||
title = self._title_var.get().strip()
|
||
desc = self._desc_txt.get("1.0", "end-1c").strip()
|
||
|
||
if not title:
|
||
show_error("Title is required.")
|
||
return
|
||
if not desc:
|
||
show_error("Description is required.")
|
||
return
|
||
try:
|
||
order = int(self._order_var.get())
|
||
except ValueError:
|
||
order = 0
|
||
|
||
try:
|
||
if self.is_edit:
|
||
from models import update_criterion
|
||
update_criterion(
|
||
self.current_user["id"], self.criterion_data["id"],
|
||
title, desc, self._active_var.get(), order,
|
||
)
|
||
logger.info(f"Criterion id={self.criterion_data['id']} "
|
||
f"updated by admin_id={self.current_user['id']}.")
|
||
show_info("Criterion updated successfully.")
|
||
else:
|
||
from models import create_criterion
|
||
create_criterion(
|
||
self.current_user["id"],
|
||
title, desc, self._active_var.get(), order,
|
||
)
|
||
logger.info(f"New criterion '{title}' created by "
|
||
f"admin_id={self.current_user['id']}.")
|
||
show_info("Criterion added successfully.")
|
||
|
||
self.on_save()
|
||
self.destroy()
|
||
except Exception as e:
|
||
show_error(f"Save failed:\n{e}")
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Verdict parser
|
||
# ------------------------------------------------------------------------------
|
||
|
||
def _parse_verdict(ai_response: str):
|
||
"""
|
||
Scan the AI response for the machine-readable RECOMMENDATION line.
|
||
Returns 'PURSUE', 'PASS', or 'UNCLEAR', or None if not found.
|
||
The prompt instructs the AI to produce exactly one of these tokens,
|
||
making a simple regex scan reliable.
|
||
"""
|
||
import re
|
||
match = re.search(
|
||
r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)",
|
||
ai_response, re.IGNORECASE,
|
||
)
|
||
return match.group(1).upper() if match else None
|
||
|
||
|
||
# ------------------------------------------------------------------------------
|
||
# Text extraction helpers (unchanged from previous version)
|
||
# ------------------------------------------------------------------------------
|
||
|
||
def _extract_text(file_path: str) -> str:
|
||
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:
|
||
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:
|
||
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)
|
||
with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tf:
|
||
tmp_path = tf.name
|
||
doc.SaveAs2(tmp_path, FileFormat=16)
|
||
doc.Close(False)
|
||
return _read_docx(tmp_path)
|
||
finally:
|
||
try:
|
||
word.Quit()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
os.unlink(tmp_path)
|
||
except Exception:
|
||
pass
|
||
pythoncom.CoUninitialize()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
import docx2txt
|
||
text = docx2txt.process(path)
|
||
if text and text.strip():
|
||
return text
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
with open(path, "rb") as f:
|
||
raw = f.read()
|
||
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:
|
||
MAX_CHARS_PER_FILE = 14_000
|
||
try:
|
||
import openpyxl
|
||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||
lines = []
|
||
total_chars = 0
|
||
for sheet in wb.worksheets:
|
||
header = f"[Sheet: {sheet.title}]"
|
||
lines.append(header)
|
||
total_chars += len(header) + 1
|
||
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)
|
||
total_chars += len(row_str) + 1
|
||
if total_chars >= MAX_CHARS_PER_FILE:
|
||
lines.append("[... content truncated to fit token limit ...]")
|
||
return "\n".join(lines)
|
||
return "\n".join(lines)
|
||
except ImportError:
|
||
raise ImportError(
|
||
"Excel reading requires 'openpyxl'.\n"
|
||
"Install it with: pip install openpyxl"
|
||
)
|
||
|
||
|
||
def _file_icon(path: str) -> str:
|
||
return {
|
||
".pdf": "📄", ".doc": "📝", ".docx": "📝",
|
||
".xlsx": "📊", ".xls": "📊", ".csv": "📋",
|
||
".txt": "🗒", ".md": "📓",
|
||
}.get(os.path.splitext(path)[1].lower(), "📁")
|
||
|
||
|
||
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" |