""" routes/ai_summary.py — AI document analysis routes. """ import logging import json import io import os import re import requests as http_requests from flask import (Blueprint, render_template, request, redirect, url_for, flash, session, jsonify) from models import ( get_all_criteria, get_active_criteria, create_criterion, update_criterion, delete_criterion, save_ai_analysis, get_ai_analysis_history, get_ai_analysis_detail, delete_ai_analysis, log_action, ) from config import get_setting from utils.decorators import login_required, admin_required logger = logging.getLogger("routes.ai_summary") ai_summary_bp = Blueprint("ai_summary", __name__, url_prefix="/ai-summary") GROQ_MODELS = [ "llama-3.3-70b-versatile", "llama-3.1-8b-instant", "gemma2-9b-it", ] SUPPORTED_EXT = {".txt", ".md", ".csv", ".pdf", ".doc", ".docx", ".xlsx", ".xls"} @ai_summary_bp.route("/") @login_required def ai_summary(): user = session["user"] criteria = get_all_criteria() history = get_ai_analysis_history( user_id=(None if user["role"] == "admin" else user["id"]) ) groq_key = get_setting("groq.api_key", "") groq_model = get_setting("groq.model", GROQ_MODELS[0]) return render_template("ai_summary.html", criteria=criteria, history=history, groq_key_set=bool(groq_key), groq_model=groq_model, groq_models=GROQ_MODELS, is_admin=user["role"] == "admin") @ai_summary_bp.route("/analyze", methods=["POST"]) @login_required def analyze(): user = session["user"] files = request.files.getlist("documents[]") model = request.form.get("model", get_setting("groq.model", GROQ_MODELS[0])) # Read API key: env var takes priority, then DB setting api_key = (os.environ.get("GROQ_API_KEY") or "").strip() or get_setting("groq.api_key", "").strip() if not api_key: return jsonify({"error": "Groq API key is not configured. Set GROQ_API_KEY in .env or via Admin → Settings."}), 400 if not files or all(f.filename == "" for f in files): return jsonify({"error": "No files uploaded."}), 400 texts = [] file_names = [] errors = [] for f in files: if not f.filename: continue ext = ("." + f.filename.rsplit(".", 1)[-1].lower()) if "." in f.filename else "" if ext not in SUPPORTED_EXT: errors.append(f"{f.filename}: unsupported file type") continue file_names.append(f.filename) try: content = _extract_text(f, ext) if content and content.strip(): texts.append(f"=== {f.filename} ===\n{content.strip()}") else: errors.append(f"{f.filename}: no text could be extracted") except Exception as e: logger.warning(f"Could not extract text from {f.filename}: {e}") errors.append(f"{f.filename}: {e}") if not texts: detail = "; ".join(errors) if errors else "Check the file format and try again." return jsonify({"error": f"Could not extract text from any uploaded file. {detail}"}), 400 criteria = get_active_criteria() combined = "\n\n".join(texts) try: result = _call_groq(api_key, model, combined, criteria) except Exception as e: logger.error(f"Groq API error: {e}") return jsonify({"error": f"AI analysis failed: {e}"}), 500 criteria_snap = json.dumps([ {"title": c["title"], "description": c["description"]} for c in criteria ]) analysis_id = save_ai_analysis( user["id"], ", ".join(file_names), model, result.get("verdict"), criteria_snap, result.get("summary", "") ) log_action(user["id"], "AI_ANALYSIS", "ai_analysis_log", analysis_id, f"AI analysis on {len(file_names)} file(s). Verdict: {result.get('verdict')}.") return jsonify({"analysis_id": analysis_id, "model": model, "file_count": len(file_names), "file_errors": errors, **result}) # Magic-byte signatures for each supported binary format. # Office Open XML (docx/xlsx) and legacy OLE (doc/xls) share these headers. _FILE_MAGIC: dict = { ".pdf": b"%PDF", ".docx": b"PK\x03\x04", ".xlsx": b"PK\x03\x04", ".doc": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", ".xls": b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", } def _validate_magic(data: bytes, ext: str) -> None: """Raise ValueError if the file's actual bytes don't match its extension.""" magic = _FILE_MAGIC.get(ext) if magic: if data[: len(magic)] != magic: raise ValueError( f"File content does not match the declared {ext} format " "(possible disguised upload)." ) elif ext in (".txt", ".csv", ".md"): # Text files must be decodable; binary data masquerading as text is rejected. try: data[:512].decode("utf-8") except UnicodeDecodeError: raise ValueError( "Text file does not appear to be valid UTF-8. " "Ensure the file is a plain text document." ) def _extract_text(file_obj, ext: str) -> str: """Extract plain text from an uploaded file object.""" data = file_obj.read() _validate_magic(data, ext) if ext in (".txt", ".md", ".csv"): return data.decode("utf-8", errors="replace") if ext == ".pdf": import pypdf reader = pypdf.PdfReader(io.BytesIO(data)) pages = [] for page in reader.pages: t = page.extract_text() if t: pages.append(t) return "\n".join(pages) if ext in (".docx", ".doc"): # Use python-docx (installed as 'docx') — do NOT use docx2txt from docx import Document doc = Document(io.BytesIO(data)) lines = [para.text for para in doc.paragraphs if para.text.strip()] return "\n".join(lines) if ext in (".xlsx", ".xls"): import openpyxl wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True) lines = [] for ws in wb.worksheets: for row in ws.iter_rows(values_only=True): line = "\t".join(str(c) if c is not None else "" for c in row) if line.strip(): lines.append(line) return "\n".join(lines) return "" # Office address used as origin for distance/travel-time estimates. _OFFICE_ADDRESS = "2815 Hartland Road, Falls Church, VA 22043, USA" # Stage 1 — extraction prompt (always sent) _EXTRACTION_PROMPT = """You are an expert government procurement analyst. The user has provided {n} document(s). Treat all provided content as a single combined source — do not analyze each file individually. Extract the following information once, consolidating details from all documents. 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. Extract and clearly label the following fields (write "N/A" if 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 Then 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}""" # Stage 2 — criteria evaluation suffix (appended only 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.""" def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict: """Call the Groq chat completions REST API directly (no SDK required).""" import re n = text.count("=== ") or 1 # count file separators for the prompt header truncated = len(text) > 14000 # Build the two-stage prompt matching the desktop app exactly prompt = _EXTRACTION_PROMPT.format( n=n, office=_OFFICE_ADDRESS, documents=text[:14000] ) if criteria: criteria_list = "\n".join( f" {i+1}. {c['title']}: {c['description']}" for i, c in enumerate(criteria) ) prompt += _CRITERIA_PROMPT_SUFFIX.format(criteria_list=criteria_list) response = http_requests.post( "https://api.groq.com/openai/v1/chat/completions", headers={ "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, json={ "model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": 4096, "temperature": 0.2, }, timeout=90, ) if not response.ok: logger.error(f"Groq API {response.status_code}: {response.text[:300]}") response.raise_for_status() content = response.json()["choices"][0]["message"]["content"] or "" # Parse the machine-readable RECOMMENDATION label (only present when criteria used) verdict = None if criteria: match = re.search( r"RECOMMENDATION\s*:\s*(PURSUE|PASS|UNCLEAR)", content, re.IGNORECASE, ) if match: verdict = match.group(1).upper() return {"verdict": verdict, "summary": content, "truncated": truncated} # ─── Criteria Management (admin only) ───────────────────────────────────────── @ai_summary_bp.route("/criteria/create", methods=["POST"]) @admin_required def create_criterion_view(): admin = session["user"] title = request.form.get("title", "").strip() desc = request.form.get("description", "").strip() is_active = request.form.get("is_active", "1") == "1" try: sort_order = int(request.form.get("sort_order", 0) or 0) except (ValueError, TypeError): sort_order = 0 try: create_criterion(admin["id"], title, desc, is_active, sort_order) flash(f"Criterion '{title}' created.", "success") except Exception as e: flash(f"Error: {e}", "danger") return redirect(url_for("ai_summary.ai_summary")) @ai_summary_bp.route("/criteria//edit", methods=["POST"]) @admin_required def edit_criterion_view(criterion_id): admin = session["user"] title = request.form.get("title", "").strip() desc = request.form.get("description", "").strip() is_active = request.form.get("is_active", "1") == "1" try: sort_order = int(request.form.get("sort_order", 0) or 0) except (ValueError, TypeError): sort_order = 0 try: update_criterion(admin["id"], criterion_id, title, desc, is_active, sort_order) flash(f"Criterion '{title}' updated.", "success") except Exception as e: flash(f"Error: {e}", "danger") return redirect(url_for("ai_summary.ai_summary")) @ai_summary_bp.route("/criteria//delete", methods=["POST"]) @admin_required def delete_criterion_view(criterion_id): admin = session["user"] try: delete_criterion(admin["id"], criterion_id) flash("Criterion deleted.", "success") except Exception as e: flash(f"Error: {e}", "danger") return redirect(url_for("ai_summary.ai_summary")) @ai_summary_bp.route("/history/") @login_required def analysis_detail(analysis_id): record = get_ai_analysis_detail(analysis_id) if not record: return jsonify({"error": "Not found"}), 404 user = session["user"] if user["role"] != "admin" and record["user_id"] != user["id"]: return jsonify({"error": "Access denied"}), 403 return jsonify({ "id": record["id"], "username": record.get("username"), "file_names": record["file_names"], "model": record["model"], "verdict": record["verdict"], "analyzed_at": str(record["analyzed_at"]), "summary_text": record["summary_text"], "criteria_snapshot": record.get("criteria_snapshot") or "", }) @ai_summary_bp.route("/history//delete", methods=["POST"]) @login_required def delete_analysis(analysis_id): record = get_ai_analysis_detail(analysis_id) if not record: return jsonify({"error": "Not found"}), 404 user = session["user"] if user["role"] != "admin" and record["user_id"] != user["id"]: return jsonify({"error": "Access denied"}), 403 delete_ai_analysis(analysis_id) log_action(user["id"], "DELETE_AI_ANALYSIS", "ai_analysis_log", analysis_id, f"Deleted AI analysis id={analysis_id}.") return jsonify({"ok": True})