""" 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() pane = tk.PanedWindow(self, orient="horizontal", bg=C["border"], sashwidth=4, sashrelief="flat") pane.pack(fill="both", expand=True, pady=(0, 6)) 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) 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("", lambda _: self._open_edit_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 _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("", 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 self.after(0, lambda t=full_output, v=verdict: self._on_success(t, v)) except Exception as exc: self.after(0, lambda e=exc: self._on_error(e)) def _on_success(self, text: str, verdict): 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'}." ) def _on_error(self, exc: Exception): self._stop_spinner() err_msg = str(exc) if "401" in err_msg or "invalid_api_key" in err_msg.lower(): err_msg = "Invalid API key. Please check your Groq API key and try again." elif "429" in err_msg or "rate_limit" in err_msg.lower(): err_msg = "Rate limit reached. Please wait a moment and try again." elif "connection" in err_msg.lower(): err_msg = "Could not connect to Groq. Please check your internet connection." show_error(f"AI analysis failed:\n\n{err_msg}", title="Groq API Error") self._set_status("Error - see popup for details.") logger.error(f"[AI SUMMARY] Error: {exc}") # -- Spinner helpers ------------------------------------------------------- def _start_spinner(self): self._running = True self._run_btn.config(state="disabled", text="⏳ Processing ...") self._progress.pack(fill="x", pady=(6, 0)) self._progress.start(12) def _stop_spinner(self): self._running = False self._progress.stop() self._progress.pack_forget() self._run_btn.config(state="normal", text="✨ Analyze with AI") # -- Status bar ------------------------------------------------------------ def _set_status(self, msg: str): self._status_var.set(msg) # ------------------------------------------------------------------------------ # Criterion Add / Edit Dialog (admin only) # ------------------------------------------------------------------------------ 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, False) self.grab_set() self._build_ui() self._centre() def _centre(self): self.update_idletasks() w, h = 560, 400 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=6, 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("", _update_char_count) # Also update when content is inserted programmatically (edit pre-fill) self._desc_txt.bind("<>", _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"