Files
WebChecker--Web-app-/routes/ai_summary.py
T

484 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
- 12 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:** 34 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})