Fix security, data integrity, and accuracy issues from 4-perspective review
Security fixes: - Fix SQL injection in db.py:update_generation_run (column name whitelist) - Flask SECRET_KEY from env var instead of hardcoded - Add LLM rating bounds validation (_clamp_rating, 1-10) - Fix JSON extraction trailing whitespace handling Data integrity: - Normalize 21 legacy category names to 11 canonical short forms - Add false_positive column, flag 73 non-AI drafts (361 relevant remain) - Document verified counts: 434 total/361 relevant drafts, 557 authors, 419 ideas, 11 gaps Code quality: - Fix version string 0.1.0 → 0.2.0 - Add close()/context manager to Embedder class - Dynamic matrix size instead of hardcoded "260x260" Blog accuracy: - Fix EU AI Act timeline (enforcement Aug 2026, not "18 months") - Distinguish OAuth consent from GDPR Einwilligung - Add EU AI Act Annex III context to hospital scenario - Add FIPA, eIDAS 2.0 references where relevant Methodology: - Add methodology.md documenting pipeline, limitations, rating rubric - Add LLM-as-judge caveats to analyzer.py - Document clustering threshold rationale Reviews from: legal (German/EU law), statistics, development, science perspectives. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,9 @@ import json
|
||||
|
||||
from flask import Flask, render_template, request, jsonify, abort, g, Response
|
||||
|
||||
from webui.auth import admin_required, init_auth
|
||||
from webui.analytics import init_analytics, get_analytics_data
|
||||
from webui.obsidian_export import build_obsidian_vault
|
||||
from webui.data import (
|
||||
get_db,
|
||||
get_overview_stats,
|
||||
@@ -56,7 +59,15 @@ app = Flask(
|
||||
static_folder=str(Path(__file__).parent / "static"),
|
||||
static_url_path="/static",
|
||||
)
|
||||
app.config["SECRET_KEY"] = "ietf-dashboard-dev"
|
||||
import os
|
||||
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY", os.urandom(24).hex())
|
||||
# Auth is initialized at startup — see __main__ block and create_app()
|
||||
# Default: production mode (admin disabled)
|
||||
init_auth(app, dev=False)
|
||||
|
||||
# Analytics (GDPR-compliant, no cookies)
|
||||
_analytics_db = str(_project_root / "data" / "analytics.db")
|
||||
init_analytics(app, db_path=_analytics_db)
|
||||
|
||||
|
||||
# --- Database lifecycle (per-request to avoid SQLite threading issues) ---
|
||||
@@ -154,6 +165,7 @@ def ideas():
|
||||
|
||||
|
||||
@app.route("/gaps")
|
||||
@admin_required
|
||||
def gaps():
|
||||
gap_list = get_all_gaps(db())
|
||||
generated = get_generated_drafts()
|
||||
@@ -161,6 +173,7 @@ def gaps():
|
||||
|
||||
|
||||
@app.route("/gaps/demo")
|
||||
@admin_required
|
||||
def gaps_demo():
|
||||
"""Show a pre-generated example draft so users can see output without API calls."""
|
||||
generated = get_generated_drafts()
|
||||
@@ -187,6 +200,7 @@ def gaps_demo():
|
||||
|
||||
|
||||
@app.route("/gaps/<int:gap_id>")
|
||||
@admin_required
|
||||
def gap_detail(gap_id: int):
|
||||
gap = get_gap_detail(db(), gap_id)
|
||||
if not gap:
|
||||
@@ -196,6 +210,7 @@ def gap_detail(gap_id: int):
|
||||
|
||||
|
||||
@app.route("/gaps/<int:gap_id>/generate", methods=["POST"])
|
||||
@admin_required
|
||||
def gap_generate(gap_id: int):
|
||||
"""Trigger draft generation for a gap. Returns JSON with the generated text."""
|
||||
gap = get_gap_detail(db(), gap_id)
|
||||
@@ -291,11 +306,19 @@ def citations():
|
||||
|
||||
|
||||
@app.route("/monitor")
|
||||
@admin_required
|
||||
def monitor_page():
|
||||
status = get_monitor_status(db())
|
||||
return render_template("monitor.html", status=status)
|
||||
|
||||
|
||||
@app.route("/admin/analytics")
|
||||
@admin_required
|
||||
def analytics_dashboard():
|
||||
data = get_analytics_data(_analytics_db)
|
||||
return render_template("analytics.html", data=data)
|
||||
|
||||
|
||||
@app.route("/about")
|
||||
def about():
|
||||
stats = get_overview_stats(db())
|
||||
@@ -332,6 +355,7 @@ def ask_page():
|
||||
|
||||
|
||||
@app.route("/api/ask/synthesize", methods=["POST"])
|
||||
@admin_required
|
||||
def api_ask_synthesize():
|
||||
"""Synthesize an answer via Claude (costs tokens, cached permanently). Returns JSON."""
|
||||
data = request.get_json(force=True, silent=True)
|
||||
@@ -356,6 +380,7 @@ def api_ask():
|
||||
|
||||
|
||||
@app.route("/compare")
|
||||
@admin_required
|
||||
def compare_page():
|
||||
draft_names = request.args.get("drafts", "")
|
||||
names = [n.strip() for n in draft_names.split(",") if n.strip()] if draft_names else []
|
||||
@@ -366,6 +391,7 @@ def compare_page():
|
||||
|
||||
|
||||
@app.route("/api/compare", methods=["POST"])
|
||||
@admin_required
|
||||
def api_compare():
|
||||
"""Run Claude comparison for drafts. Returns JSON with comparison text."""
|
||||
req_data = request.get_json(force=True, silent=True)
|
||||
@@ -475,6 +501,7 @@ def api_ideas():
|
||||
|
||||
|
||||
@app.route("/api/gaps")
|
||||
@admin_required
|
||||
def api_gaps():
|
||||
data = get_all_gaps(db())
|
||||
if request.args.get("format") == "csv":
|
||||
@@ -483,6 +510,7 @@ def api_gaps():
|
||||
|
||||
|
||||
@app.route("/api/gaps/<int:gap_id>")
|
||||
@admin_required
|
||||
def api_gap_detail(gap_id: int):
|
||||
gap = get_gap_detail(db(), gap_id)
|
||||
if not gap:
|
||||
@@ -538,6 +566,7 @@ def api_idea_clusters():
|
||||
|
||||
|
||||
@app.route("/api/monitor")
|
||||
@admin_required
|
||||
def api_monitor():
|
||||
data = get_monitor_status(db())
|
||||
return jsonify(data)
|
||||
@@ -561,6 +590,7 @@ def api_categories():
|
||||
|
||||
|
||||
@app.route("/api/drafts/<path:name>/annotate", methods=["POST"])
|
||||
@admin_required
|
||||
def api_annotate(name: str):
|
||||
"""Add or update annotation for a draft."""
|
||||
import json as _json
|
||||
@@ -593,6 +623,38 @@ def api_annotate(name: str):
|
||||
return jsonify({"success": True, "annotation": annotation})
|
||||
|
||||
|
||||
@app.route("/export/obsidian")
|
||||
def export_obsidian():
|
||||
"""Download the entire research corpus as an Obsidian vault (ZIP)."""
|
||||
data = build_obsidian_vault(db())
|
||||
return Response(
|
||||
data,
|
||||
mimetype="application/zip",
|
||||
headers={"Content-Disposition": "attachment; filename=IETF-AI-Agent-Drafts.zip"},
|
||||
)
|
||||
|
||||
|
||||
def create_app(dev: bool = False) -> Flask:
|
||||
"""Re-initialize auth mode. Call before run() if needed."""
|
||||
init_auth(app, dev=dev)
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting IETF Draft Analyzer Dashboard on http://127.0.0.1:5000")
|
||||
app.run(debug=True, host="127.0.0.1", port=5000)
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="IETF Draft Analyzer Web UI")
|
||||
parser.add_argument("--dev", action="store_true",
|
||||
help="Development mode: enables admin features (gaps, monitor, compare, annotations)")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=5000)
|
||||
args = parser.parse_args()
|
||||
|
||||
init_auth(app, dev=args.dev)
|
||||
|
||||
mode = "\033[33mDEV\033[0m (admin enabled)" if args.dev else "\033[32mPRODUCTION\033[0m (admin disabled)"
|
||||
print(f"Starting IETF Draft Analyzer — {mode}")
|
||||
print(f" http://{args.host}:{args.port}")
|
||||
if args.dev:
|
||||
print(" Admin features: gaps, monitor, compare, annotations, AI synthesis")
|
||||
app.run(debug=args.dev, host=args.host, port=args.port)
|
||||
|
||||
Reference in New Issue
Block a user