381 lines
14 KiB
Python
381 lines
14 KiB
Python
"""
|
|
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, 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), **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/<int:criterion_id>/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/<int:criterion_id>/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/<int:analysis_id>")
|
|
@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"],
|
|
})
|