Files
WebChecker/views/ai_summary_view.py
T

1688 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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:
import configparser
from config import CONFIG_FILE
from utils.config_crypto import decrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
if cfg.has_section(_CFG_SECTION):
raw_key = cfg.get(_CFG_SECTION, _CFG_KEY_KEY, fallback="")
self._api_key_var.set(decrypt_value(raw_key))
model = cfg.get(_CFG_SECTION, _CFG_KEY_MODEL,
fallback=GROQ_MODELS[0])
if model in GROQ_MODELS:
self._model_var.set(model)
except Exception as e:
logger.warning(f"Could not load Groq config: {e}")
def _save_config(self):
try:
import configparser
from config import CONFIG_FILE
from utils.config_crypto import encrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
if not cfg.has_section(_CFG_SECTION):
cfg.add_section(_CFG_SECTION)
cfg.set(_CFG_SECTION, _CFG_KEY_KEY,
encrypt_value(self._api_key_var.get().strip()))
cfg.set(_CFG_SECTION, _CFG_KEY_MODEL, self._model_var.get())
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
except Exception as e:
logger.warning(f"Could not save Groq config: {e}")
# -- UI construction -------------------------------------------------------
def _build_ui(self):
C = COLOURS
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"