From 64419a445aa265558823433d23db5683416c0165 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Thu, 23 Apr 2026 16:27:28 -0400 Subject: [PATCH] 04/23 Add AI Analysis criteria --- config.py | 13 + models.py | 116 +++++++ views/ai_summary_view.py | 677 +++++++++++++++++++++++++++++---------- 3 files changed, 632 insertions(+), 174 deletions(-) diff --git a/config.py b/config.py index 6303789..b260213 100644 --- a/config.py +++ b/config.py @@ -309,6 +309,19 @@ def initialize_database(): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; """, + """ + CREATE TABLE IF NOT EXISTS ai_criteria ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(200) NOT NULL, + description TEXT NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, ] conn = None diff --git a/models.py b/models.py index 1b1b986..dae3c2f 100644 --- a/models.py +++ b/models.py @@ -1386,3 +1386,119 @@ def get_unchecked_sites_for_user(user_id: int): finally: if conn: conn.close() + +# ─── AI Criteria CRUD ───────────────────────────────────────────────────────── + +def get_all_criteria(): + """Return all AI evaluation criteria ordered by sort_order, then id.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT c.*, u.username AS creator + FROM ai_criteria c + LEFT JOIN users u ON u.id = c.created_by + ORDER BY c.sort_order, c.id + """ + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_active_criteria(): + """Return only active criteria for use in AI prompt construction.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT id, title, description + FROM ai_criteria + WHERE is_active = 1 + ORDER BY sort_order, id + """ + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def create_criterion(admin_id: int, title: str, description: str, + is_active: bool, sort_order: int) -> int: + """Insert a new AI evaluation criterion. Returns the new row id.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO ai_criteria (title, description, is_active, sort_order, created_by) + VALUES (%s, %s, %s, %s, %s) + """, + (title, description, int(is_active), sort_order, admin_id) + ) + conn.commit() + new_id = cur.lastrowid + cur.close() + log_action(admin_id, "CREATE_AI_CRITERION", "ai_criteria", new_id, + f"Created criterion '{title}' active={is_active} order={sort_order}.") + logger.info(f"AI criterion id={new_id} '{title}' created by admin_id={admin_id}.") + return new_id + finally: + if conn: + conn.close() + + +def update_criterion(admin_id: int, criterion_id: int, title: str, + description: str, is_active: bool, sort_order: int): + """Update an existing AI evaluation criterion.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + UPDATE ai_criteria + SET title=%s, description=%s, is_active=%s, sort_order=%s + WHERE id=%s + """, + (title, description, int(is_active), sort_order, criterion_id) + ) + conn.commit() + cur.close() + log_action(admin_id, "UPDATE_AI_CRITERION", "ai_criteria", criterion_id, + f"Updated criterion id={criterion_id} '{title}' active={is_active}.") + logger.info(f"AI criterion id={criterion_id} updated by admin_id={admin_id}.") + finally: + if conn: + conn.close() + + +def delete_criterion(admin_id: int, criterion_id: int): + """Hard-delete an AI evaluation criterion.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT title FROM ai_criteria WHERE id=%s", (criterion_id,)) + row = cur.fetchone() + title = row["title"] if row else str(criterion_id) + cur.execute("DELETE FROM ai_criteria WHERE id=%s", (criterion_id,)) + conn.commit() + cur.close() + log_action(admin_id, "DELETE_AI_CRITERION", "ai_criteria", criterion_id, + f"Deleted criterion id={criterion_id} '{title}'.") + logger.info(f"AI criterion id={criterion_id} '{title}' deleted by admin_id={admin_id}.") + finally: + if conn: + conn.close() diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index d48f73c..40f1bd5 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -7,17 +7,21 @@ 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 is focused on extracting procurement/solicitation fields: - Solicitation Number, Type, Set-Aside, Description, Work Site, - Pre-Proposal Conference, POC, Square Footage, Due Date, etc. +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: two-pane — left = file list / controls, right = summary output - Bottom: status bar + 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 @@ -28,12 +32,11 @@ from tkinter import ttk, filedialog from utils.ui_helpers import ( COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, - show_error, show_info, + show_error, show_info, confirm_delete, ) logger = logging.getLogger("ai_summary_view") -# -- Supported file extensions ------------------------------------------------- SUPPORTED_EXT = { ".txt", ".md", ".csv", ".pdf", @@ -51,7 +54,6 @@ FILE_DIALOG_TYPES = [ ("All files", "*.*"), ] -# -- Groq model options -------------------------------------------------------- GROQ_MODELS = [ "llama-3.3-70b-versatile", "llama-3.1-8b-instant", @@ -59,12 +61,10 @@ GROQ_MODELS = [ "mixtral-8x7b-32768", ] -# -- Config keys stored in config.ini ------------------------------------------ _CFG_SECTION = "groq" _CFG_KEY_KEY = "api_key" _CFG_KEY_MODEL = "model" -# -- Focused extraction prompt (procurement / solicitation) -------------------- _OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA" _EXTRACTION_PROMPT = """\ @@ -85,13 +85,12 @@ For EACH document, extract and clearly label the following fields \ 3. Set-Aside (e.g. Small Business, 8(a), N/A) 4. Description / Scope of Work 5. Work Site / Location(s) - 6. Pre-Proposal Conference / Site-Visit (date, time, full address — mandatory or optional) + 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: the Pre-Proposal Conference or Site-Visit address from field #6 \ -(if no conference, use the primary Work Site address from field #5) + - 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 @@ -102,28 +101,11 @@ driving time using major highways After the per-document breakdown, provide a detailed OVERALL SUMMARY covering: A. Scope of Work - - What services or work are being requested? - - What are the key performance requirements, quality standards, or \ -special conditions? - - Are there any specific technical, staffing, or equipment requirements? - B. Contract Period - - What is the anticipated base period of performance? - - Are there any option years or renewal provisions mentioned? - - What is the expected contract start date (if stated)? - C. Proposal Submission Requirements - - What documents, forms, or sections must be included in the proposal? - - Are there page limits, formatting requirements, or specific \ -submission instructions? - - What evaluation criteria or factors will be used to award the contract? - - Are there any bonding, insurance, licensing, or certification requirements? - D. Key Deadlines & Action Items - - List all critical dates in chronological order. - - Flag any mandatory attendance requirements (e.g. site visits). -Be precise, detailed, and use bullet points throughout. \ +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." @@ -131,6 +113,38 @@ 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. +""" + # ------------------------------------------------------------------------------ @@ -139,8 +153,8 @@ class AiSummaryView(ttk.Frame): super().__init__(parent) self.current_user = current_user self._is_admin = (current_user.get("role") == "admin") - self._files: list[str] = [] # list of absolute file paths - self._running = False # True while AI call is in-flight + self._files = [] + self._running = False self._api_key_var = tk.StringVar() self._model_var = tk.StringVar(value=GROQ_MODELS[0]) @@ -152,7 +166,6 @@ class AiSummaryView(ttk.Frame): # -- Config persistence ---------------------------------------------------- def _load_config(self): - """Read stored API key / model from config.ini [groq] section.""" try: import configparser from config import CONFIG_FILE @@ -170,7 +183,6 @@ class AiSummaryView(ttk.Frame): logger.warning(f"Could not load Groq config: {e}") def _save_config(self): - """Persist API key / model to config.ini [groq] section (key encrypted).""" try: import configparser from config import CONFIG_FILE @@ -192,38 +204,31 @@ class AiSummaryView(ttk.Frame): def _build_ui(self): C = COLOURS - # -- Page header ------------------------------------------------------- hdr = ttk.Frame(self) hdr.pack(fill="x", pady=(0, 10)) - ttk.Label(hdr, text="🤖 AI Document Summary", style="Heading.TLabel").pack(side="left") - ttk.Label(hdr, text="Upload documents and let AI extract key information.", style="Dim.TLabel").pack(side="left", padx=(12, 0)) - # -- Admin-only: API key / model config bar ---------------------------- if self._is_admin: self._build_admin_config_bar() - # -- Main two-pane area ------------------------------------------------ + self._build_criteria_panel() + pane = tk.PanedWindow(self, orient="horizontal", - bg=C["border"], sashwidth=4, - sashrelief="flat") + bg=C["border"], sashwidth=4, sashrelief="flat") pane.pack(fill="both", expand=True, pady=(0, 6)) - # Left pane -- file list left = tk.Frame(pane, bg=C["bg"]) pane.add(left, minsize=260, width=300) self._build_file_panel(left) - # Right pane -- summary output right = tk.Frame(pane, bg=C["bg"]) pane.add(right, minsize=350) self._build_output_panel(right) - # -- Status bar -------------------------------------------------------- status_bar = tk.Frame(self, bg=C["surface2"], pady=4) status_bar.pack(fill="x", side="bottom") tk.Label(status_bar, textvariable=self._status_var, @@ -231,17 +236,14 @@ class AiSummaryView(ttk.Frame): font=FONT_SMALL, anchor="w").pack(side="left", padx=10) def _build_admin_config_bar(self): - """Render the API key + model selector card (admin only).""" C = COLOURS cfg_card = tk.Frame(self, bg=C["surface"], pady=10, padx=14) cfg_card.pack(fill="x", pady=(0, 10)) - # Row 0 -- API key tk.Label(cfg_card, text="Groq API Key:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4) - self._key_entry = tk.Entry( cfg_card, textvariable=self._api_key_var, show="•", width=48, @@ -250,8 +252,6 @@ class AiSummaryView(ttk.Frame): relief="flat", font=FONT, ) self._key_entry.grid(row=0, column=1, sticky="ew", padx=(0, 8), pady=4) - - # Show / hide toggle self._eye_btn = tk.Button( cfg_card, text="👁", command=self._toggle_key_visibility, @@ -261,17 +261,14 @@ class AiSummaryView(ttk.Frame): ) self._eye_btn.grid(row=0, column=2, padx=(0, 12), pady=4) - # Row 1 -- model selector + save tk.Label(cfg_card, text="Model:", bg=C["surface"], fg=C["text_dim"], font=FONT_SMALL).grid(row=1, column=0, sticky="w", padx=(0, 8), pady=4) - ttk.Combobox( cfg_card, textvariable=self._model_var, values=GROQ_MODELS, state="readonly", width=30, ).grid(row=1, column=1, sticky="w", padx=(0, 8), pady=4) - tk.Button( cfg_card, text="💾 Save Settings", command=self._on_save_settings, @@ -280,22 +277,186 @@ class AiSummaryView(ttk.Frame): 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 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, @@ -303,7 +464,6 @@ class AiSummaryView(ttk.Frame): 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, @@ -313,64 +473,47 @@ class AiSummaryView(ttk.Frame): padx=10, pady=4, ).pack(side="right", padx=(4, 0)) - # File listbox with scrollbar list_frame = tk.Frame(parent, bg=C["bg"]) list_frame.pack(fill="both", expand=True, padx=6, pady=(4, 6)) - vsb = ttk.Scrollbar(list_frame, orient="vertical") vsb.pack(side="right", fill="y") - self._file_lb = tk.Listbox( list_frame, bg=C["surface"], fg=C["text"], selectbackground=C["accent"], selectforeground=C["white"], - activestyle="none", - relief="flat", font=FONT_SMALL, - yscrollcommand=vsb.set, - selectmode="extended", + activestyle="none", relief="flat", font=FONT_SMALL, + yscrollcommand=vsb.set, selectmode="extended", ) self._file_lb.pack(side="left", fill="both", expand=True) vsb.config(command=self._file_lb.yview) - # Right-click context menu self._ctx_menu = tk.Menu(self._file_lb, tearoff=0) self._ctx_menu.add_command(label="Remove selected", command=self._remove_selected) self._file_lb.bind("", self._show_ctx_menu) - # Run button (bottom of left pane) btn_frame = tk.Frame(parent, bg=C["bg"], pady=6) btn_frame.pack(fill="x", padx=6) - self._run_btn = tk.Button( - btn_frame, - text="✨ Analyze with AI", + 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, + relief="flat", font=FONT_BOLD, cursor="hand2", pady=10, ) self._run_btn.pack(fill="x") - - # Progress bar (hidden until running) self._progress = ttk.Progressbar(btn_frame, mode="indeterminate") - self._refresh_file_list() # -- Output panel ---------------------------------------------------------- def _build_output_panel(self, parent): C = COLOURS - - # Toolbar toolbar = tk.Frame(parent, bg=C["surface"], pady=8, padx=10) toolbar.pack(fill="x") - tk.Label(toolbar, text="📝 Extracted Information", bg=C["surface"], fg=C["text"], font=FONT_BOLD).pack(side="left") - tk.Button( toolbar, text="📋 Copy", command=self._copy_summary, @@ -379,7 +522,6 @@ class AiSummaryView(ttk.Frame): 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, @@ -388,7 +530,6 @@ class AiSummaryView(ttk.Frame): 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, @@ -397,35 +538,41 @@ class AiSummaryView(ttk.Frame): relief="flat", font=FONT_SMALL, cursor="hand2", ).pack(side="right", padx=(4, 0)) - # Text widget + # 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", + 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, + 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, - ) - + "placeholder", foreground=COLOURS["text_dim"], font=FONT_SMALL) self._set_output_placeholder() # -- File operations ------------------------------------------------------- @@ -453,8 +600,7 @@ class AiSummaryView(ttk.Frame): self._set_status(f"{added} file(s) added. {len(self._files)} total.") def _remove_selected(self): - selected = list(self._file_lb.curselection()) - for i in reversed(selected): + 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.") @@ -492,29 +638,24 @@ class AiSummaryView(ttk.Frame): 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" + "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" + " - 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." ) - if not self._is_admin: - placeholder = placeholder.replace( - "1. Add one or more documents using ➕ Add Files.\n" - "2. Click ✨ Analyze with AI.", - "1. Add one or more documents using ➕ Add Files.\n" - "2. Click ✨ Analyze with AI.\n\n" - "(Contact your administrator to configure the API key.)" - ) self._output_txt.insert("end", placeholder, "placeholder") self._output_txt.config(state="disabled") + self._hide_verdict_banner() def _clear_output(self): self._set_output_placeholder() @@ -550,6 +691,34 @@ class AiSummaryView(ttk.Frame): 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): @@ -566,7 +735,6 @@ class AiSummaryView(ttk.Frame): def _on_summarize(self): if self._running: return - api_key = self._api_key_var.get().strip() if not api_key: msg = ( @@ -578,22 +746,30 @@ class AiSummaryView(ttk.Frame): ) 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)), + 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[str]): + def _run_analyze(self, api_key: str, model: str, + file_paths: list, active_criteria: list): """Background thread: extract text -> call Groq -> post result.""" try: - # 1. Extract text from each file combined_text = "" for fp in file_paths: @@ -606,7 +782,7 @@ class AiSummaryView(ttk.Frame): f"\n\n{'─' * 40}\n" f"FILE: {os.path.basename(fp)}\n" f"{'─' * 40}\n" - f"[ERROR: Could not read this file — {ex}]\n" + f"[ERROR: Could not read this file - {ex}]\n" ) continue @@ -619,7 +795,6 @@ class AiSummaryView(ttk.Frame): ) continue - # Truncate very large files to stay within token limits MAX_CHARS_PER_FILE = 14_000 if len(text) > MAX_CHARS_PER_FILE: text = (text[:MAX_CHARS_PER_FILE] @@ -639,12 +814,19 @@ class AiSummaryView(ttk.Frame): )) return - # 2. Build prompt + # 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) - # 3. Call Groq API + 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.")) @@ -658,30 +840,41 @@ class AiSummaryView(ttk.Frame): ) result = response.choices[0].message.content.strip() - # 4. Build output header + # 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 + 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: self._on_success(t)) + 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): + 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))." + f"({len(self._files)} file(s)). Verdict: {verdict or 'N/A'}." ) def _on_error(self, exc: Exception): @@ -718,13 +911,180 @@ class AiSummaryView(ttk.Frame): # ------------------------------------------------------------------------------ -# Text extraction helpers +# 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}") + + 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) + + # 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) + + # Description + 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) + + # Sort order + ttk.Label(form, text="Sort Order").grid( + row=2, 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=2, column=1, sticky="w", pady=6) + + # 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=3, 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))) + + 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: - """Return the plain-text content of a supported file.""" ext = os.path.splitext(file_path)[1].lower() - if ext in (".txt", ".md", ".csv"): return _read_text_file(file_path) if ext == ".pdf": @@ -768,7 +1128,6 @@ def _read_pdf(path: str) -> str: def _read_docx(path: str) -> str: - """Read modern .docx (Office Open XML) via python-docx.""" try: import docx doc = docx.Document(path) @@ -781,16 +1140,6 @@ def _read_docx(path: str) -> str: def _read_doc(path: str) -> str: - """Read legacy .doc (binary OLE) files. - - Strategy (Windows): - 1. win32com — automates Word to save as temp .docx, then reads it. - Requires Microsoft Word to be installed; most reliable. - 2. docx2txt — pure-Python fallback that can sometimes handle simple .doc - files by treating them as ZIP-like structures (works for some .doc). - 3. Raw text scrape — last resort ASCII extraction from the binary. - """ - # --- Strategy 1: Word COM automation (Windows + Word installed) ----------- try: import win32com.client import tempfile @@ -801,13 +1150,11 @@ def _read_doc(path: str) -> str: try: abs_path = os.path.abspath(path) doc = word.Documents.Open(abs_path) - # Save as a temp .docx so python-docx can read it cleanly with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tf: tmp_path = tf.name - doc.SaveAs2(tmp_path, FileFormat=16) # 16 = wdFormatXMLDocument + doc.SaveAs2(tmp_path, FileFormat=16) doc.Close(False) - text = _read_docx(tmp_path) - return text + return _read_docx(tmp_path) finally: try: word.Quit() @@ -819,9 +1166,8 @@ def _read_doc(path: str) -> str: pass pythoncom.CoUninitialize() except Exception: - pass # Word not installed or COM error — try next strategy + pass - # --- Strategy 2: docx2txt (pure Python, works on some .doc files) -------- try: import docx2txt text = docx2txt.process(path) @@ -830,11 +1176,9 @@ def _read_doc(path: str) -> str: except Exception: pass - # --- Strategy 3: raw ASCII scrape from binary ---------------------------- try: with open(path, "rb") as f: raw = f.read() - # Extract printable ASCII runs of 4+ chars import re chunks = re.findall(rb"[ -~]{4,}", raw) text = "\n".join(c.decode("ascii", errors="ignore") for c in chunks) @@ -851,14 +1195,7 @@ def _read_doc(path: str) -> str: def _read_excel(path: str) -> str: - """ - Extract text from an Excel file via openpyxl (read-only mode). - Iteration stops as soon as accumulated content exceeds MAX_CHARS_PER_FILE - to avoid loading the entire workbook into memory for very large files. - The AI layer will truncate to MAX_CHARS_PER_FILE anyway, so there is no - value in continuing past that point. - """ - MAX_CHARS_PER_FILE = 14_000 # mirror the constant in AiSummaryView._run_ai + MAX_CHARS_PER_FILE = 14_000 try: import openpyxl wb = openpyxl.load_workbook(path, read_only=True, data_only=True) @@ -885,20 +1222,12 @@ def _read_excel(path: str) -> str: ) -# -- Utility helpers ----------------------------------------------------------- - def _file_icon(path: str) -> str: - ext = os.path.splitext(path)[1].lower() return { - ".pdf": "📄", - ".doc": "📝", - ".docx": "📝", - ".xlsx": "📊", - ".xls": "📊", - ".csv": "📋", - ".txt": "🗒", - ".md": "📓", - }.get(ext, "📁") + ".pdf": "📄", ".doc": "📝", ".docx": "📝", + ".xlsx": "📊", ".xls": "📊", ".csv": "📋", + ".txt": "🗒", ".md": "📓", + }.get(os.path.splitext(path)[1].lower(), "📁") def _human_size(n: int) -> str: