484 lines
15 KiB
Python
484 lines
15 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, 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)
|
||
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"
|
||
|
||
# Document text limit per API call.
|
||
# Stage 1 prompt template is ~1.8 KB overhead; 14 KB of doc text keeps the total
|
||
# JSON payload well under Groq's request size limit on all plan tiers.
|
||
_TEXT_LIMIT = 14_000
|
||
|
||
# Analyst persona — injected as the system message in every API call.
|
||
_SYSTEM_PROMPT = (
|
||
"You are a senior government procurement analyst supporting a small business BD team. "
|
||
"Extract and organize solicitation information with precision. "
|
||
"Format all output in clean Markdown with tables and section headers. "
|
||
"Write \"Not specified\" for any field not found in the documents — never guess."
|
||
)
|
||
|
||
# Stage 1 — extraction prompt (always sent). Kept compact to minimise payload size.
|
||
_EXTRACTION_PROMPT = """\
|
||
Analyze the {n} solicitation document(s) below as one combined package.
|
||
Fill in every table and section. Write **"Not specified"** for missing fields.
|
||
|
||
Our office: **{office}** — use as origin for travel estimates.
|
||
|
||
---
|
||
|
||
## Solicitation Overview
|
||
|
||
| Field | Value |
|
||
|---|---|
|
||
| Solicitation Number | |
|
||
| Solicitation Type | (RFP / RFQ / IFB / IDIQ / BPA / etc.) |
|
||
| Issuing Agency / Office | |
|
||
| Set-Aside | (Small Business / 8(a) / SDVOSB / HUBZone / WOSB / Unrestricted / etc.) |
|
||
| Contract Type | (FFP / T&M / Cost-Plus / IDIQ / etc.) |
|
||
| Contract Period | |
|
||
| NAICS Code | |
|
||
| Estimated Value | |
|
||
|
||
---
|
||
|
||
## Scope of Work
|
||
|
||
*(Detail required services, deliverables, and performance standards. Use bullet points.)*
|
||
|
||
---
|
||
|
||
## Performance Location(s)
|
||
|
||
*(All work sites; whether remote or on-site work is permitted.)*
|
||
|
||
---
|
||
|
||
## Key Dates & Deadlines
|
||
|
||
| Milestone | Date & Time |
|
||
|---|---|
|
||
| Pre-Proposal Conference / Site Visit | |
|
||
| Questions Due | |
|
||
| **Proposal Due** | |
|
||
| Award Date | |
|
||
| Period of Performance Start | |
|
||
| Other Deadlines | |
|
||
|
||
---
|
||
|
||
## Pre-Proposal Conference / Site Visit
|
||
|
||
| | |
|
||
|---|---|
|
||
| Date & Time | |
|
||
| Full Address | |
|
||
| Attendance | (Mandatory / Optional / N/A) |
|
||
| RSVP Required | |
|
||
|
||
---
|
||
|
||
## Point of Contact
|
||
|
||
| | |
|
||
|---|---|
|
||
| Name / Title | |
|
||
| Phone | |
|
||
| Email | |
|
||
| Questions Submission Method | |
|
||
|
||
---
|
||
|
||
## Proposal Requirements
|
||
|
||
*(Format, page limits, volumes, submission method, number of copies.)*
|
||
|
||
---
|
||
|
||
## Evaluation Factors
|
||
|
||
*(Government's evaluation criteria and weights as stated in the solicitation.)*
|
||
|
||
---
|
||
|
||
## Travel & Logistics
|
||
|
||
| | |
|
||
|---|---|
|
||
| Origin | {office} |
|
||
| Destination | |
|
||
| Estimated Driving Distance | |
|
||
| Estimated Drive Time | |
|
||
|
||
*(AI estimates — verify with a mapping service before scheduling.)*
|
||
|
||
---
|
||
|
||
## Red Flags & Notable Requirements
|
||
|
||
*(Security clearances, bonding, certifications, teaming restrictions, tight timelines, incumbent indicators, or other bid/no-bid risk factors.)*
|
||
|
||
---
|
||
|
||
## Total Square Footage
|
||
|
||
*(If applicable; write "N/A" if not.)*
|
||
|
||
---
|
||
|
||
## Overall Summary
|
||
|
||
- **What Is Being Procured:**
|
||
- **Contract Period & Value:**
|
||
- **Key Constraints:**
|
||
- **Immediate Action Items & Deadlines:**
|
||
|
||
---
|
||
|
||
DOCUMENTS:
|
||
{documents}"""
|
||
|
||
# Criteria evaluation — appended to the extraction prompt in the same API call.
|
||
# A single call avoids the rate-limit (429) that two back-to-back calls trigger
|
||
# on Groq's free tier.
|
||
_CRITERIA_SUFFIX = """
|
||
|
||
---
|
||
|
||
## Opportunity Alignment Evaluation
|
||
|
||
Now evaluate this opportunity against our company's criteria below.
|
||
|
||
**Our criteria:**
|
||
{criteria_list}
|
||
|
||
For each criterion provide:
|
||
- ✅ MEETS / ⚠️ PARTIALLY MEETS / ❌ DOES NOT MEET / ❓ CANNOT DETERMINE
|
||
- 1–2 sentences with specific evidence from the documents above.
|
||
|
||
**Overall Recommendation** — write exactly one of these lines (machine-read, do not alter):
|
||
|
||
RECOMMENDATION: PURSUE
|
||
RECOMMENDATION: PASS
|
||
RECOMMENDATION: UNCLEAR
|
||
|
||
PURSUE = clearly aligns with most criteria and is competitive.
|
||
PASS = fails one or more critical criteria or presents unacceptable risk.
|
||
UNCLEAR = insufficient information for a confident decision.
|
||
|
||
**Executive Summary:** 3–4 sentences in plain business language explaining the recommendation."""
|
||
|
||
|
||
def _call_groq(api_key: str, model: str, text: str, criteria: list) -> dict:
|
||
"""Single Groq API call: extraction + optional criteria evaluation in one request."""
|
||
truncated = len(text) > _TEXT_LIMIT
|
||
doc_text = text[:_TEXT_LIMIT]
|
||
n = doc_text.count("=== ") or 1
|
||
|
||
prompt = _EXTRACTION_PROMPT.format(
|
||
n=n, office=_OFFICE_ADDRESS, documents=doc_text
|
||
)
|
||
|
||
if criteria:
|
||
criteria_list = "\n".join(
|
||
f"{i+1}. **{c['title']}**: {c['description']}"
|
||
for i, c in enumerate(criteria)
|
||
)
|
||
prompt += _CRITERIA_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": "system", "content": _SYSTEM_PROMPT},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
"max_tokens": 4096,
|
||
"temperature": 0.1,
|
||
},
|
||
timeout=120,
|
||
)
|
||
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 ""
|
||
|
||
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
|
||
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/<int:analysis_id>/delete", methods=["POST"])
|
||
@login_required
|
||
def delete_analysis(analysis_id):
|
||
user = session["user"]
|
||
if user["role"] != "admin":
|
||
return jsonify({"error": "Access denied"}), 403
|
||
record = get_ai_analysis_detail(analysis_id)
|
||
if not record:
|
||
return jsonify({"error": "Not found"}), 404
|
||
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})
|