# Project export: Moral sycophancy benchmarking

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Seen those posts where ChatGPT or Claude tells you that the user they're valid for doing morally insane things? Quantify this effect across different models with these benchmarking questions.
- Devpost: https://devpost.com/software/moral-sycophancy-benchmarking
- GitHub: https://github.com/sanskriti-ss/benchmarking
- Video: https://player.vimeo.com/video/1165160816?byline=0&portrait=0&title=0#t=
- Team: 2 GitHub contributor(s) — sanskriti-ss (33 commits), pbenn28 (4 commits)

## Devpost submission (written by the team)

### Inspiration

We saw too many twitter posts where ChatGPT was telling people their crazy behaviour was okay. We're worried about the downstream effects of people using a sycophantic chatbot therapist to validate their poor moral decisions.

### What it does

The benchmarking compares moral sycophancy across models, giving a z-score on agreeableness (where 0 is the average human, -1 is 1 standard deviation more agreeable than the avg human, etc.).

### How we built it

We built it using python, deployed it on Render (and used inbuilt data storage), and some special UI magic. We generated most of the questions ourselves, and pulled a few that we've seen being problematic from various posts.

### Challenges we ran into

Figuring out how to do the scoring and generating good questions that really pushed the models!

### Accomplishments we're proud of

It works! It's pretty modular so we can also just call more models and use more APIs and get more testing done. We'll use this after the hackathon and build on it (please give us more API credits!)

### What we learned

Most models are still Very Bad at being disagreeable in order to convey truth, assuming that their stated values are close to something that that is true. But so are humans! How often do you lie to your friends to make them feel nice rather than doing the kind thing and being honest? Non-trivial solutions.

### What's next

More questions! The API credits from OpenAI and Anthropic were very limited (especially 3 requests per minute with OpenAI), so we would love more credits that we could use to test more models and make some better charts :)

## README (from the GitHub repository)

# LLM Agreeability Benchmark

A web application for testing how different LLMs respond to misconceptions and ethical dilemmas. Deploy it on Render and let users run their own benchmarks with their own API keys.

Our main findings are [here](main_findings.md).

## Features

- **Multi-LLM Support**: Test OpenAI (GPT) and Anthropic (Claude) models
- **Secure API Key Storage**: User API keys are encrypted and stored in MongoDB
- **Customizable Questions**: Users can edit and add their own test questions
- **No Keys Exposed Client-Side**: All API calls happen server-side

## Quick Start (Local Development)

1. **Clone and install dependencies:**
   ```bash
   pip install -r requirements.txt
   ```

2. **Set up MongoDB:**
   - Create a free cluster at [MongoDB Atlas](https://www.mongodb.com/atlas)
   - Get your connection string and add it to `.env`

3. **Generate an encryption key:**
   ```bash
   python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
   ```
   Add the output to `ENCRYPTION_KEY` in `.env`

4. **Run the app:**
   ```bash
   python app.py
   ```
   Visit `http://localhost:5000`

## Deploy on Render

1. Push this repo to GitHub

2. Create a new **Web Service** on [Render](https://render.com)

3. Connect your GitHub repo

4. Set environment variables in Render dashboard:
   - `MONGO_URI`: Your MongoDB Atlas connection string
   - `ENCRYPTION_KEY`: Generate using the command above
   - `FLASK_SECRET_KEY`: Will be auto-generated

5. Deploy! Render will use `render.yaml` configuration

## Environment Variables

| Variable | Description | Required |
|----------|-------------|----------|
| `MONGO_URI` | MongoDB connection string | Yes |
| `ENCRYPTION_KEY` | Fernet key for encrypting API keys | Yes |
| `FLASK_SECRET_KEY` | Flask session secret | Auto-generated |

## Project Structure

```
├── app.py              # Main Flask application
├── llm_framework.py    # Original CLI framework (for reference)
├── questions.csv       # Default test questions
├── requirements.txt    # Python dependencies
├── render.yaml         # Render deployment config
├── templates/
│   ├── index.html      # Dashboard
│   ├── api_keys.html   # API key management
│   ├── questions.html  # Question editor
│   └── results.html    # Results page
└── .env                # Local environment (not committed)
```

## Security Notes

- User API keys are encrypted at rest using Fernet symmetric encryption
- Keys are never exposed to the frontend
- Each user gets a unique session ID stored in a cookie
- MongoDB Atlas provides secure, cloud-hosted database


## Detected evidence (automated analysis)

Indexed codebase: 15 recognized source files, 203 KB.
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (20 of 20)

```
.gitignore
app.py
benchmark_test.py
data/benchmark_overall.csv
data/benchmark_subsections.csv
generate_radar_charts.py
llm_framework.py
main_findings.md
questions.csv
README.md
render.yaml
requirements.txt
templates/admin_login.html
templates/admin.html
templates/api_keys_old.html
templates/api_keys.html
templates/contribute.html
templates/index.html
templates/questions.html
templates/results.html
```

### Dependencies

- requirements.txt: cryptography@>=41.0.0, flask@>=2.3.0, gunicorn@>=21.0.0, psycopg2-binary@>=2.9.0, python-dotenv@>=1.0.0, requests@>=2.31.0

### Recent commits (newest first)

- updating to run w/o database
- readme update
- (from the main website)
- adding the pngs for the website
- Delete framework directory
- dynamically_adjusting_truncation
- minor ui_centering
- radar charts
- results from latest run
- contribute section
- rubric + more models
- rate limiting issues
- rate limiting fixing
- updated requirements
- ui change
- switching to postgresql
- added to nav
- adding in notes from research (why imp)
- ordering bug
- disp explanation

## Key source files (fetched from GitHub, selected and truncated for size)

### main_findings.md

```markdown
# LLM Sycophancy & Agreeability Benchmark

> **Why measuring how language models handle disagreement matters for safe, trustworthy AI deployment (and usage!).**

---

## Why This Matters

Sycophancy (the tendency of AI models to tell users what they want to hear rather than what is true) is one of the most inconspicuous problems with current AI-usage.

| | |
|---|---|
| **False beliefs** | When models validate misconceptions, users form false beliefs reinforced by the perceived authority of AI |
| **Medical risk** | LLMs showed up to **100% compliance** with logically flawed requests, generating dangerous misinformation *(npj Digital Medicine, 2025)* |
| **Dependency** | Sycophantic AI has been shown to **decrease prosocial intentions** and promote dependency in users *(Sharma et al., 2025)* |
| **Moral conflicts** | Models affirm **whichever side a user adopts** 48% of the time, regardless of ethical merits *(ELEPHANT, 2025)* |
| **Vulnerable users** | AI companions that validate rather than challenge can **reinforce negative emotions** and enable harmful decision-making *(Nature Machine Intelligence, 2025)* |

---

## At a Glance

```
 58%        of LLM interactions showed sycophantic behavior across models
 45pp        more face-preserving than humans in general advice
 29%        sycophantic answers from GPT-5 on math proofs
```

---

## Previous Research

There has been notable research on LLM sycophancy since 2023. Sycophancy is the name the field uses to describe the group of distinct, independently steerable tendencies that make LLMs generally more agreeable and placating.

### Sycophancy Is Multidimensional

- **Sycophantic agreement** (conforming to user beliefs), **sycophantic praise** (excessive flattery), and **social sycophancy** (preserving self-image) are encoded along distinct linear directions in model latent space *(ICLR 2026)*
- Each behavior can be **independently amplified or suppressed** using activation steering, suggesting separate learned representations
- Syco-Bench found **weak correlations between different sycophancy tests**, suggesting that each captures a relatively independent aspect

### Measuring Social Sycophancy

- The ELEPHANT benchmark characterizes sycophancy as excessive preservation of a user's desired self-image, testing across 11 models *(ICLR 2026)*
- LLMs preserve user face **45 percentage points more than humans** even when users describe manipulation, deception, or relational harm
- When prompted with perspectives from either side of a moral conflict, models affirm the user's position regardless of ethical merit

### Domain-Specific Risks

- **Medicine:** All five tested frontier LLMs showed high initial compliance with prompts that misrepresent drug relationships *(npj Digital Medicine)*
- **Mathematics:** Even the best models produce sycophantic answers **29% of the time** on deliberately false theorem proving tasks *(BrokenMath, 2025)*
- **Video understanding:** Video-LLMs align with user input e
[truncated — 10450 more characters]
```

### requirements.txt

```
flask>=2.3.0
psycopg2-binary>=2.9.0
cryptography>=41.0.0
python-dotenv>=1.0.0
requests>=2.31.0
gunicorn>=21.0.0

```

### app.py

```python
import os
import uuid
import json as json_module
import requests
import time
import csv
from datetime import datetime
from flask import Flask, render_template, request, jsonify, session, redirect, url_for, flash
from cryptography.fernet import Fernet
from werkzeug.security import check_password_hash
from dotenv import load_dotenv

load_dotenv()

app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY") or "dev-secret-key-change-in-production"

# Database setup — PostgreSQL in production, in-memory fallback when unavailable
DATABASE_URL = os.getenv("DATABASE_URL")
NO_DATABASE_MODE = os.getenv("NO_DATABASE_MODE", "").lower() in {"1", "true", "yes", "on"}
USE_DB = bool(DATABASE_URL) and not NO_DATABASE_MODE
DB_AVAILABLE = False


def init_local_storage():
    global _local_users, _local_questions, _local_contributions, _local_calibrations, _session_api_keys
    _local_users = {}
    _local_questions = {}
    _local_contributions = []
    _local_calibrations = []
    _session_api_keys = {}


if USE_DB:
    try:
        import psycopg2
        import psycopg2.extras

        def get_db():
            conn = psycopg2.connect(DATABASE_URL)
            conn.autocommit = True
            return conn

        # Create tables on startup
        with get_db() as conn:
            with conn.cursor() as cur:
                cur.execute("""
                    CREATE TABLE IF NOT EXISTS users (
                        user_id TEXT PRIMARY KEY,
                        api_keys JSONB NOT NULL DEFAULT '{}'
                    )
                """)
                cur.execute("""
                    CREATE TABLE IF NOT EXISTS questions (
                        user_id TEXT PRIMARY KEY,
                        questions JSONB NOT NULL DEFAULT '[]'
                    )
                """)
                cur.execute("""
                    CREATE TABLE IF NOT EXISTS contributions (
                        id SERIAL PRIMARY KEY,
                        name TEXT,
                        email TEXT,
                        age TEXT,
                        description TEXT,
                        question_suggestion TEXT,
                        created_at TIMESTAMP DEFAULT NOW()
                    )
                """)
                cur.execute("""
                    CREATE TABLE IF NOT EXISTS calibrations (
                        id SERIAL PRIMARY KEY,
                        name TEXT,
                        email TEXT,
                        age TEXT,
                        description TEXT,
                        question TEXT,
                        response_neg1 TEXT,
                        response_0 TEXT,
                        response_pos1 TEXT,
                        created_at TIMESTAMP DEFAULT NOW()
                    )
                """)
        DB_AVAILABLE = True
    except Exception as e:
        # Keep the service alive when the DB is unavailable (expired plan, bad URL, etc.)
        print(f"Database unavailable, falling back to in-memory mode: {e}")
        USE_DB = False
        init_local_storage()
else:
    init_local_storage()

# Encryption for API keys
ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY")
if ENCRYPTION_KEY:
    fernet = Fernet(ENCRYPTION_KEY.encode())
else:
    fernet = None

def encrypt_key(api_key: str) -> str:
    if fernet and api_key:
        return fernet.encrypt(api_key.encode()).decode()
    return api_key

def decrypt_key(encrypted_key: str) -> str:
    if fernet and encrypted_key:
        return fernet.decrypt(encrypted_key.encode()).decode()
    return encrypted_key

# ===== LLM Call Functions =====
def call_openai(prompt: str, api_key: str) -> str:
    url = "https://api.openai.com/v1/chat/completions"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    data = {
        "model": "gpt-3.5-turbo",
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 500
    }
    tries = 0
    while tries < 3:
        try:
            response = requests.post(url, headers=headers, json=data, timeout=30)
            result = response.json()

            # Check for rate limit or API error
            if "error" in result:
                msg = result['error'].get('message', str(result['error']))
                if 'rate limit' in msg.lower() or response.status_code == 429:
                    tries += 1
                    if tries < 3:
                        time.sleep(25)
                        continue
                return f"Error: {msg}"

            if "choices" not in result or not result["choices"]:
                return f"Error: Unexpected response - {result}"

            return result["choices"][0]["message"]["content"]
        except KeyError as e:
            return f"Error: Missing key {str(e)} in response"
        except Exception as e:
            if 'rate limit' in str(e).lower():
                tries += 1
                if tries < 3:
                    time.sleep(25)
                    continue
            return f"Error: {str(e)}"

def call_anthropic(prompt: str, api_key: str) -> str:
    url = "https://api.anthropic.com/v1/messages"
    headers = {
        "x-api-key": api_key,
        "Content-Type": "application/json; charset=utf-8",
        "anthropic-version": "2023-06-01"
    }
    data = {
        "model": "claude-3-haiku-20240307",
        "max_tokens": 500,
        "messages": [{"role": "user", "content": prompt}]
    }
    tries = 0
    while tries < 3:
        try:
            response = requests.post(url, headers=headers, json=data, timeout=30)
            result = response.json()
            # Check for API error
            if "error" in result:
                msg = result['error'].get('message', str(result['error']))
                if 'overload' in msg.lower() or 'overloaded' in msg.lower():
                    tries += 1
                    if tries < 3:
                        time.sleep(10)
                        continue
                
[truncated — 21473 more characters]
```

### render.yaml

```yaml
services:
  - type: web
    name: llm-benchmark
    env: python
    plan: free
    buildCommand: pip install -r requirements.txt
    startCommand: gunicorn app:app --bind 0.0.0.0:$PORT
    envVars:
      - key: MONGO_URI
        sync: false
      - key: ENCRYPTION_KEY
        sync: false
      - key: FLASK_SECRET_KEY
        generateValue: true
      - key: PYTHON_VERSION
        value: 3.11.0

```

### llm_framework.py

```python
import csv
import requests
from typing import List, Dict, Callable
import os
from dotenv import load_dotenv

# Define LLM API call functions (modular, customizable)
def call_openai(prompt: str, api_key: str) -> str:
    url = "https://api.openai.com/v1/chat/completions"
    headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
    data = {
        "model": "gpt-3.5-turbo",
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 100
    }
    response = requests.post(url, headers=headers, json=data)
    return response.json()["choices"][0]["message"]["content"]

def call_anthropic(prompt: str, api_key: str) -> str:
    url = "https://api.anthropic.com/v1/messages"
    headers = {"x-api-key": api_key, "Content-Type": "application/json"}
    data = {
        "model": "claude-2.1",
        "max_tokens": 100,
        "messages": [{"role": "user", "content": prompt}]
    }
    response = requests.post(url, headers=headers, json=data)
    return response.json()["choices"][0]["message"]["content"]

# Add more LLMs here if we get credits

LLM_FUNCTIONS = {
    "openai": call_openai,
    "anthropic": call_anthropic,
    # Add more as needed
}


# Load API keys from .env file
load_dotenv()
API_KEYS = {
    "openai": os.getenv("OPENAI_API_KEY"),
    "anthropic": os.getenv("ANTHROPIC_API_KEY"),
    # Add more as needed
}

def ask_llms(questions: List[str], llms: List[str], api_keys: Dict[str, str]) -> List[Dict[str, str]]:
    results = []
    for q in questions:
        row = {"question": q}
        for llm in llms:
            func = LLM_FUNCTIONS[llm]
            row[llm] = func(q, api_keys[llm])
        results.append(row)
    return results

def main():
    # Read questions
    with open("questions.csv", "r") as f:
        reader = csv.DictReader(f)
        questions = [row["question"] for row in reader]

    llms = list(LLM_FUNCTIONS.keys())
    responses = ask_llms(questions, llms, API_KEYS)

    # Write responses
    with open("responses.csv", "w", newline="") as f:
        fieldnames = ["question"] + llms
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for row in responses:
            writer.writerow(row)

if __name__ == "__main__":
    # Ensure .env is set up
    if not API_KEYS["openai"] or not API_KEYS["anthropic"]:
        print("Please set your API keys in the .env file.")
    else:
        main()

```

### benchmark_test.py

```python
#!/usr/bin/env python3
"""
Standalone benchmark test script.
Runs all 4 Dedalus models against questions_test.csv, scores them, and saves results.

Usage:
    export DEDALUS_API_KEY="your-key"
    export ANTHROPIC_API_KEY="your-key"   # needed for scoring
    python benchmark_test.py
"""

import os
import csv
import json
import time
import sys

# Reuse functions from the main app
from app import call_dedalus, score_agreeability, DEDALUS_MODELS

MODELS = {
    "dedalus-gpt4o": "openai/gpt-4o",
    "dedalus-claude-sonnet": "anthropic/claude-sonnet-4-20250514",
    "dedalus-gemini-flash": "google/gemini-2.5-flash",
    "dedalus-grok3": "xai/grok-3",
}

MODEL_LABELS = {
    "dedalus-gpt4o": "GPT-4o",
    "dedalus-claude-sonnet": "Claude Sonnet 4",
    "dedalus-gemini-flash": "Gemini 2.5 Flash",
    "dedalus-grok3": "Grok 3",
}


def load_questions(path="questions_test.csv"):
    questions = []
    with open(path, "r", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            questions.append(row["question"])
    return questions


def main():
    dedalus_key = os.getenv("DEDALUS_API_KEY", "").strip()
    anthropic_key = os.getenv("ANTHROPIC_API_KEY", "").strip()

    if not dedalus_key:
        print("Error: DEDALUS_API_KEY not set")
        sys.exit(1)
    if not anthropic_key:
        print("Error: ANTHROPIC_API_KEY not set (needed for scoring)")
        sys.exit(1)

    questions = load_questions()
    print(f"Loaded {len(questions)} questions from questions_test.csv\n")

    model_keys = list(MODELS.keys())
    results = []

    for i, question in enumerate(questions):
        print(f"--- Question {i + 1}/{len(questions)} ---")
        print(f"  {question[:80]}...")
        row = {"question": question}

        for model_key in model_keys:
            model_id = MODELS[model_key]
            label = MODEL_LABELS[model_key]
            print(f"  Calling {label} ({model_id})...", end=" ", flush=True)
            response = call_dedalus(question, dedalus_key, model_id)
            if response.startswith("Error:"):
                print(f"FAILED: {response}")
            else:
                print(f"OK ({len(response)} chars)")
            row[model_key] = response
            # Small delay between calls to avoid rate limits
            time.sleep(2)

        results.append(row)
        print()

    # Score all results
    print("Scoring agreeability with Claude...")
    scores = score_agreeability(results, anthropic_key, model_keys)

    # Build output
    output = {
        "questions": questions,
        "models": MODELS,
        "results": results,
        "scores": scores,
    }

    out_path = "benchmark_results.json"
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(output, f, indent=2, ensure_ascii=False)
    print(f"\nResults saved to {out_path}")

    # Print summary
    print("\n" + "=" * 70)
    print("BENCHMARK SCORES")
    print("=" * 70)
    print(f"  {'Model':25s} {'Syco':>7s} {'Factual':>8s} {'Placat':>8s} {'Epist':>8s}")
    print("-" * 70)
    for model_key in model_keys:
        label = MODEL_LABELS[model_key]
        if model_key in scores:
            d = scores[model_key]
            if isinstance(d, dict):
                syc = d.get("sycophancy", d.get("score", 0))
                fa = d.get("factual_accuracy", 0)
                pl = d.get("placating", 0)
                ep = d.get("epistemic_transparency", 0)
                print(f"  {label:25s} {float(syc):+7.2f} {float(fa):8.2f} {float(pl):8.2f} {float(ep):8.2f}")
                if d.get("explanation"):
                    print(f"    {d['explanation']}")
            else:
                print(f"  {label:25s} {float(d):+7.2f}")
        else:
            print(f"  {label:25s}  (no score)")
    print("=" * 70)


if __name__ == "__main__":
    main()

```

### generate_radar_charts.py

```python
"""
Generate radar charts for the LLM Agreeability Benchmark results.

Reads CSV data from data/ and outputs PNG charts to static/charts/.
Rerun this script whenever you update the CSV files.

Usage:
    python generate_radar_charts.py
"""

import os
import csv
import math

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import numpy as np


# ── Paths ──────────────────────────────────────────────────────────
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
OUTPUT_DIR = os.path.join(BASE_DIR, "static", "charts")

OVERALL_CSV = os.path.join(DATA_DIR, "benchmark_overall.csv")
SUBSECTION_CSV = os.path.join(DATA_DIR, "benchmark_subsections.csv")

# ── Visual config ──────────────────────────────────────────────────
MODEL_COLORS = {
    "Claude Sonnet 4": "#4a7c6f",
    "Gemini 2.5 Flash": "#c8913a",
    "GPT-4o": "#b07040",
    "Grok 3": "#8a6e5e",
}

DIMENSIONS = ["sycophancy", "factual_accuracy", "placating", "epistemic_transparency"]
DIMENSION_LABELS = ["Sycophancy\n(z-score)", "Factual\nAccuracy", "Placating\nBehavior", "Epistemic\nTransp."]

# All axes are plotted on [-1, +1].
# Since polar charts can't show negative radii, we shift values:
#   raw -1 → r=0 (center), raw +1 → r=2 (edge)
# The axis labels are remapped to show the true [-1, +1] scale.
#
# Sycophancy is already in roughly [-1, +1] range (z-scores clamped).
# Factual accuracy & epistemic transparency are [0, 1] → mapped to [-1, +1].
# Placating is [0, 1] but inverted (lower = better) → mapped & flipped.

AXIS_MIN, AXIS_MAX = -1.0, 1.0
R_OFFSET = 1.0  # shift so that -1 → 0, +1 → 2


def to_radar(val):
    """Shift a [-1, +1] value to radar radius [0, 2]."""
    return val + R_OFFSET


def raw_to_axis(val, vmin=0.0, vmax=1.0):
    """Map a raw value from [vmin, vmax] to [-1, +1]."""
    return 2.0 * (val - vmin) / (vmax - vmin) - 1.0


def normalize_row(row):
    """Convert raw scores to radar radii (shifted [-1,+1] → [0,2])."""
    syco = float(row["sycophancy"])
    syco_clamped = max(-1.0, min(1.0, syco))  # clamp z-score to [-1, 1]
    fa = float(row["factual_accuracy"])
    plac = float(row["placating"])
    et = float(row["epistemic_transparency"])

    return [
        to_radar(syco_clamped),
        to_radar(raw_to_axis(fa)),           # 0→-1, 1→+1
        to_radar(raw_to_axis(1.0 - plac)),   # invert: low placating = +1
        to_radar(raw_to_axis(et)),           # 0→-1, 1→+1
    ]


# ── Data loading ───────────────────────────────────────────────────
def load_overall():
    """Return list of dicts from the overall CSV."""
    with open(OVERALL_CSV, newline="") as f:
        return list(csv.DictReader(f))


def load_subsections():
    """Return dict: {model_name: {category: row_dict}}."""
    result = {}
    with open(SUBSECTION_CSV, newline="") as f:
        for row in csv.DictReader(f):
            model = row["model"]
            cat = row["category"]
            result.setdefault(model, {})[cat] = row
    return result


# ── Radar chart drawing ────────────────────────────────────────────
def draw_radar(ax, values, color, label=None, fill_alpha=0.15):
    """Draw a single radar polygon on an existing polar axis."""
    n = len(values)
    angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()
    values_closed = values + [values[0]]
    angles_closed = angles + [angles[0]]

    ax.plot(angles_closed, values_closed, "o-", color=color, linewidth=2,
            markersize=5, label=label)
    ax.fill(angles_closed, values_closed, color=color, alpha=fill_alpha)


def setup_radar_axes(ax, labels):
    """Configure a polar axis for radar chart display with [-1, +1] scale."""
    n = len(labels)
    angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()

    ax.set_theta_offset(np.pi / 2)
    ax.set_theta_direction(-1)
    ax.set_thetagrids(np.degrees(angles), labels, fontsize=8, fontweight="500",
                      color="#3a3a3a")
    # Push axis labels outward so they don't overlap the chart
    for label in ax.get_xticklabels():
        label.set_y(label.get_position()[1] - 0.05)

    # Radial axis: 0→2 corresponds to displayed [-1, +1]
    ax.set_ylim(0, 2.0)
    # Ticks at the real values -1, -0.5, 0, +0.5, +1 → radii 0, 0.5, 1, 1.5, 2
    ax.set_yticks([0.0, 0.5, 1.0, 1.5, 2.0])
    ax.set_yticklabels(["-1.0", "-0.5", "0.0", "+0.5", "+1.0"], fontsize=7,
                       color="#999")
    ax.yaxis.grid(True, color="#ddd", linewidth=0.5)
    ax.xaxis.grid(True, color="#ccc", linewidth=0.5)
    ax.spines["polar"].set_visible(False)


# ── Chart generators ───────────────────────────────────────────────
def generate_per_model_radars(overall_data):
    """Create one radar chart per model showing the 4 rubric dimensions."""
    os.makedirs(OUTPUT_DIR, exist_ok=True)

    for row in overall_data:
        model = row["model"]
        values = normalize_row(row)
        color = MODEL_COLORS.get(model, "#666")

        fig, ax = plt.subplots(figsize=(5, 5), subplot_kw=dict(polar=True))
        fig.patch.set_facecolor("#fafaf8")
        ax.set_facecolor("#fafaf8")

        setup_radar_axes(ax, DIMENSION_LABELS)
        draw_radar(ax, values, color, fill_alpha=0.20)

        ax.set_title(model, fontsize=14, fontweight="600", color="#2a2a2a",
                     pad=24)

        # Add raw score annotations
        raw = {
            "sycophancy": float(row["sycophancy"]),
            "factual_accuracy": float(row["factual_accuracy"]),
            "placating": float(row["placating"]),
            "epistemic_transparency": float(row["epistemic_transparency"]),
        }
        n = len(values)
        angles = np.linspace(0, 2 * np.pi, n, endpoint=False)
        for i, (angle, val) in enumerate(zip(angles, values)):
            dim = DIMENSIONS[i]
            raw_val = raw[dim]
            label_text = f"{raw_val:+.2f}" if dim == "sycophancy" else f"{raw_val:.2f}"
            offset = 0.12
   
[truncated — 4619 more characters]
```

### templates/admin_login.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Admin Login - LLM Benchmark</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&family=Source+Sans+3:wght@300;400;500;600&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
    <style>
        :root {
            --paper: #f5f0e8;
            --paper-light: #faf7f2;
            --ink: #2c2417;
            --ink-light: #5c5347;
            --ink-faint: #9c9488;
            --accent: #2a6b5e;
            --accent-light: #3d8b7a;
            --accent-faint: rgba(42,107,94,0.08);
            --accent-border: rgba(42,107,94,0.2);
            --divider: rgba(44,36,23,0.1);
            --card: rgba(255,253,249,0.7);
            --card-border: rgba(44,36,23,0.08);
        }
        * { box-sizing: border-box; margin: 0; }
        body {
            font-family: 'Source Sans 3', sans-serif;
            background: var(--paper);
            color: var(--ink);
            -webkit-font-smoothing: antialiased;
        }
        .paper-bg {
            background-color: var(--paper);
            background-image:
                url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='fine'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23fine)' opacity='0.12'/%3E%3C/svg%3E"),
                url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='coarse'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.3' numOctaves='3' seed='5' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23coarse)' opacity='0.07'/%3E%3C/svg%3E"),
                url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='fiber'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.02 0.6' numOctaves='6' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23fiber)' opacity='0.06'/%3E%3C/svg%3E");
        }
        .font-serif { font-family: 'Cormorant Garamond', Georgia, serif; }
        .font-mono { font-family: 'JetBrains Mono', monospace; }
        .card {
            background: var(--card);
            border: 1px solid var(--card-border);
            border-radius: 10px;
            box-shadow: 0 1px 3px rgba(44,36,23,0.04);
        }
        .form-input {
            width: 100%;
            padding: 11px 14px;
            background: var(--paper-light);
            border: 1px solid var(--card-border);
            border-radius: 7px;
            color: var(--ink);
            font-family: 'JetBrains Mono', monospace;
            font-size: 14px;
            outline: none;
            transition: border-color 0.15s, box-shadow 0.15s;
        }
        .form-input:focus {
            border-color: var(--accent);
            box-shadow: 0 0 0 3px var(--accent-faint);
        }
        .submit-btn {
            width: 100%;
            background: var(--accent);
            color: #fff;
            font-family: 'Source Sans 3', sans-serif;
            font-weight: 600;
            font-size: 14px;
            padding: 11px 28px;
            border-radius: 7px;
            border: none;
            cursor: pointer;
            transition: all 0.15s;
            box-shadow: 0 1px 3px rgba(42,107,94,0.2);
        }
        .submit-btn:hover { background: var(--accent-light); }
        .flash-msg {
            padding: 12px 16px;
            border-radius: 8px;
            font-size: 14px;
            margin-bottom: 16px;
            background: rgba(180,80,50,0.08);
            color: #a04030;
            border: 1px solid rgba(180,80,50,0.2);
        }
    </style>
</head>
<body class="paper-bg min-h-screen flex items-center justify-center">
    <div class="w-full max-w-sm px-6">
        <div class="text-center mb-8">
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="mx-auto mb-3">
                <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
            </svg>
            <h1 class="font-serif text-2xl font-bold" style="color:var(--ink);">Admin Login</h1>
            <p class="text-sm mt-1" style="color:var(--ink-faint);">Agreeability Bench</p>
        </div>

        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="flash-msg">{{ message }}</div>
                {% endfor %}
            {% endif %}
        {% endwith %}

        <div class="card p-6">
            <form method="POST">
                <div class="mb-5">
                    <label class="block text-sm font-medium mb-1.5" style="color:var(--ink);">Password</label>
                    <input type="password" name="password" class="form-input" placeholder="Enter admin password" required autofocus>
                </div>
                <button type="submit" class="submit-btn">Login</button>
            </form>
        </div>
    </div>
</body>
</html>

```

### templates/api_keys_old.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>API Keys - LLM Benchmark</title>
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&display=swap" rel="stylesheet">
    <style>
        .papyrus-bg { 
            background: linear-gradient(135deg, #f5e6d3 0%, #e8d4b8 50%, #f0e0c8 100%); 
            background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%' height='100%' filter='url(%23noise)' opacity='0.08'/%3E%3C/svg%3E"), 
                            linear-gradient(135deg, #f5e6d3 0%, #e8d4b8 50%, #f0e0c8 100%);
        }
        .scroll-container {
            position: relative;
            background: linear-gradient(180deg, #fdf6e3 0%, #f5e6d3 50%, #ede0c8 100%);
            border: 3px solid #8b7355;
            box-shadow: inset 0 0 30px rgba(139,119,101,0.3), 0 8px 20px rgba(0,0,0,0.15);
        }
        .scroll-container::before,
        .scroll-container::after {
            content: '';
            position: absolute;
            left: 0;
            right: 0;
            height: 25px;
            background: linear-gradient(90deg, #654321 0%, #8b4513 10%, #a0522d 50%, #8b4513 90%, #654321 100%);
            border: 2px solid #4a3520;
        }
        .scroll-container::before { top: -25px; box-shadow: 0 -3px 10px rgba(0,0,0,0.3); }
        .scroll-container::after { bottom: -25px; box-shadow: 0 3px 10px rgba(0,0,0,0.3); }
        .scroll-roll-left, .scroll-roll-right {
            position: absolute; top: -30px; bottom: -30px; width: 20px;
            background: radial-gradient(ellipse at center, #a0522d 0%, #654321 100%);
            border: 2px solid #4a3520; z-index: 10;
        }
        .scroll-roll-left { left: -20px; border-radius: 10px 0 0 10px; }
        .scroll-roll-right { right: -20px; border-radius: 0 10px 10px 0; }
        .scroll-title {
            background: url('/static/scroll_bg.jpg') center/cover no-repeat;
            border: 3px solid #4a3520;
            position: relative;
        }
        .scroll-header::before, .scroll-header::after {
            content: '⚜'; position: absolute; top: 50%; transform: translateY(-50%);
            font-size: 1.5rem; color: #d4a574;
        }
        .scroll-header::before { left: 15px; }
        .scroll-header::after { right: 15px; }
        .font-cinzel { font-family: 'Cinzel', serif; }
        .font-crimson { font-family: 'Crimson Text', serif; }
        .parchment-btn {
            background: linear-gradient(180deg, #c4956a 0%, #a67c52 100%);
            border: 2px solid #654321;
            box-shadow: inset 0 1px 0 rgba(255,255,255,0.3), 0 2px 5px rgba(0,0,0,0.2);
        }
        .parchment-btn:hover { background: linear-gradient(180deg, #d4a574 0%, #b8896a 100%); }
        .wax-seal {
            display: inline-block; width: 20px; height: 20px;
            background: radial-gradient(circle at 30% 30%, #c41e3a 0%, #8b0000 100%);
            border-radius: 50%; margin-right: 8px;
            box-shadow: inset -2px -2px 4px rgba(0,0,0,0.3), 1px 1px 3px rgba(0,0,0,0.2);
        }
        .parchment-input {
            background: rgba(253, 246, 227, 0.8);
            border: 2px solid #8b7355;
        }
    </style>

            <em>API keys are stored securely with encryption and never exposed to the frontend.</em></p>
        </div>

        {% if configured_keys %}
        <div class="scroll-container p-6 mb-12 mx-4 relative">
            <div class="scroll-roll-left"></div>
            <div class="scroll-roll-right"></div>
            <h2 class="text-xl font-cinzel font-semibold text-amber-900 mb-4 flex items-center"><span class="wax-seal"></span> Currently Configured</h2>
            <p class="font-crimson">You have API keys configured for:</p>
            <ul class="mt-3 ml-5 list-disc">
                {% for key in configured_keys %}
                    <li class="text-green-800 font-semibold font-cinzel">✓ {{ key|upper }}</li>
                {% endfor %}
            </ul>
        </div>
        {% endif %}

        <div class="scroll-container p-6 mb-12 mx-4 relative">
            <div class="scroll-roll-left"></div>
            <div class="scroll-roll-right"></div>
            <h2 class="text-xl font-cinzel font-semibold text-amber-900 mb-4 flex items-center"><span class="wax-seal"></span> Update API Keys</h2>
            <form method="POST">
                <div class="mb-5">
                    <label for="openai_key" class="block mb-2 font-cinzel font-semibold text-amber-800">OpenAI API Key</label>
                    <input type="password" id="openai_key" name="openai_key" placeholder="sk-..." 
                           class="parchment-input w-full p-3 text-base focus:outline-none focus:ring-2 focus:ring-amber-500 font-crimson">
                    <small class="text-amber-600 block mt-1 font-crimson italic">Get your key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-amber-700 hover:text-amber-900 underline decoration-wavy">OpenAI Dashboard</a></small>
                </div>
                
                <div class="mb-5">
                    <label for="anthropic_key" class="block mb-2 font-cinzel font-semibold text-amber-800">Anthropic API Key</label>
                    <input type="password" id="anthropic_key" name="anthropic_key" placeholder="sk-ant-..." 
                           class="parchment-input w-full p-3 text-base focus:outline-none focus:ring-2 focus:ring-amber-500 font-crimson">
                    <small class="text-amber-600 block mt-1 font-crimson italic">Get your key from <a href="https://console.
[truncated — 353 more characters]
```

[5 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]