25 KiB
CLAUDE.md — Website Checker: Complete Technical Knowledge Base
This document is the authoritative technical reference for the Website Checker desktop application. Keep it current whenever code changes are made.
1. Application Overview
Website Checker is a Python/Tkinter desktop application for shift-based website monitoring backed by a remote MySQL database. Regular users log in, view websites assigned to their shift, open each site, and mark it checked. Administrators manage users, websites, shifts, credentials, email reports, and AI-powered document analysis.
- UI: Python
tkinter+ttk - Database: Remote MySQL 5.7+ / MariaDB 10.3+ via
mysql-connector-python - Entry point:
app.py→class App(tk.Tk) - Default window: 1100×700, minimum 960×620
- Default theme: Light (toggleable to Dark)
- Platform: Windows desktop (primary); macOS/Linux supported with limitations
2. Project File Structure
website_checker/
├── app.py Entry point, shell, navigation, session management
├── config.py DB config, connection pool, schema DDL, migrations
├── models.py All database access (data layer)
├── requirements.txt
├── CLAUDE.md This file
├── README.md Setup and deployment guide
├── USER_MANUAL.md End-user documentation
├── utils/
│ ├── config_crypto.py Windows DPAPI encryption for config.ini credentials
│ ├── crypto.py Fernet credential encryption (website passwords)
│ ├── export.py CSV + Excel export
│ ├── scheduler.py Daily email report background daemon + SMTP helpers
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
└── views/
├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table
├── admin_log_view.py Activity log treeview
├── admin_shifts_view.py Shift CRUD + PDF export + inactive filter toggle
├── admin_users_view.py User CRUD + secure password reset dialog
├── admin_websites_view.py Website CRUD + visibility + credentials (collapsible)
├── ai_summary_view.py AI document analysis via Groq API
├── change_password_view.py Self-service password change (all roles)
├── email_settings_view.py SMTP / scheduled report config (3-mode security)
├── login_view.py Login form with rate-limiting countdown
├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs
├── settings_view.py DB connection settings dialog
└── user_dashboard_view.py User checklist — search, bulk check, notifications
3. Runtime Files
config.ini
Created on first launch. Contains [database], [email], [crypto], and [groq] sections.
Sensitive fields are DPAPI-encrypted (see §6 Security). Encrypted values have a dpapi: prefix.
[database]
host=your-mysql-host
port=3306
database=website_checker
user=dpapi:<base64-blob> ; encrypted
password=dpapi:<base64-blob> ; encrypted
[email]
enabled=false
smtp_host=smtp.example.com
smtp_port=587
smtp_user=sender@example.com
smtp_password=dpapi:<base64-blob> ; encrypted
security=starttls ; starttls | ssl | none
use_tls=true ; legacy compat field, derived from security
recipients=admin@example.com
send_time=18:00
last_sent_date=2025-01-01 ; ISO date, prevents re-send after restart
[groq]
api_key=dpapi:<base64-blob> ; encrypted
model=llama-3.3-70b-versatile
[crypto]
salt=<base64 32-byte salt — auto-generated>
app.log
UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module.
4. Database Schema
All tables use InnoDB/utf8mb4. Created by initialize_database() on first launch.
Safe ALTER TABLE migrations run automatically for columns added post-deployment.
New tables (ai_criteria, ai_analysis_log) verified with information_schema check and logged.
users
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| username | VARCHAR(100) UNIQUE | |
| password | VARCHAR(255) | bcrypt hash. Legacy SHA-256 (64 hex) auto-migrated on login |
| role | ENUM('admin','user') | |
| full_name | VARCHAR(200) | |
| is_active | TINYINT(1) | |
| failed_attempts | TINYINT UNSIGNED | Incremented on bad login; reset on success |
| locked_until | DATETIME NULL | Set when failed_attempts >= MAX_FAILED_ATTEMPTS (5) |
| created_at / updated_at | DATETIME |
websites
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| name | VARCHAR(200) | |
| url | TEXT | Validated by _validate_and_normalise_url (http/https only) |
| check_type | ENUM('daily','weekly') | weekly = once per ISO week |
| visibility | ENUM('all','assigned') | assigned = only users in website_users table |
| note | TEXT | Shown on user dashboard card |
| is_active | TINYINT(1) | Soft-delete |
| created_by | INT FK users SET NULL |
website_credentials
| Column | Notes |
|---|---|
| website_id FK | |
| username | Plaintext |
| password | Fernet-encrypted with enc: prefix |
| label | e.g. "Admin", "Read-only" |
shift_checks
One row per user per site per day (upserted). user_note optional.
activity_log
action codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED,
CREATE/UPDATE/DELETE_USER, CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, RESET_PASSWORD,
CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE,
EXPORT_CSV, EXPORT_EXCEL, EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS,
CREATE/UPDATE/DELETE_AI_CRITERION.
shifts
days_of_week is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat.
E.g. "23456" = Mon–Fri. Queried with LOCATE(DAYOFWEEK(CURDATE()), days_of_week) > 0.
shift_users (junction): (shift_id, user_id) PK, both CASCADE
shift_websites (junction): (shift_id, website_id) PK + sort_order
login_attempts: username, attempted_at, ip_address (indexed)
website_users (junction): (website_id, user_id) PK — for visibility='assigned'
ai_criteria
Stores evaluation criteria used by the AI to assess solicitation alignment.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| title | VARCHAR(200) | Short label shown in treeview |
| description | TEXT | Full criterion text sent to the AI prompt |
| is_active | TINYINT(1) | Inactive criteria excluded from AI prompt |
| sort_order | INT | Controls display and prompt ordering |
| created_by | INT FK users SET NULL | |
| created_at / updated_at | DATETIME |
ai_analysis_log
Persists every AI analysis result for history and audit purposes.
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| user_id | INT FK users SET NULL | |
| file_names | TEXT | Comma-separated filenames analyzed |
| model | VARCHAR(100) | Groq model used |
| verdict | ENUM('PURSUE','PASS','UNCLEAR') NULL | NULL when no criteria active |
| criteria_snapshot | TEXT NULL | Active criteria text at time of analysis |
| summary_text | MEDIUMTEXT | Full AI response |
| analyzed_at | DATETIME |
5. Module Details
app.py
- App(tk.Tk) — root window; manages login state, shell, sidebar, session timeout
- IDLE_TIMEOUT_MS = 1,800,000 (30 min); IDLE_WARNING_MS = 60,000 (1 min warning)
- _reset_idle_timer throttled: Motion events fire at most once per _IDLE_THROTTLE_S=5.0 seconds
- _idle_last_reset: monotonic timestamp used for throttle check
- Admin login → scheduler.start(); logout → scheduler.stop()
- Theme toggle destroys and rebuilds the entire shell (content panels re-instantiated)
config.py
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
- get_connection() → pool (pool_size=5)
- initialize_database() → idempotent DDL + ALTER TABLE migrations + table confirmation logs
- load_config() / save_config() → decrypt/encrypt DPAPI fields transparently
- migrate_plaintext_config() → one-time migration; encrypts any plain-text creds on startup
- config_exists() → True if host, database, user are set
- reload_db_config() → re-reads config.ini, resets pool
utils/config_crypto.py
Windows DPAPI-based encryption for config.ini sensitive values.
- encrypt_value(plaintext) → "dpapi:" string
- decrypt_value(stored) → plaintext; plain-text pass-through for legacy values
- is_encrypted(value) → True if value starts with "dpapi:"
- Tied to the current Windows user account — blob is unreadable on any other machine/account
- Graceful fallback: if pywin32 not available, values stored/returned as plain text
models.py
Each function opens+closes its own connection. All writes call log_action().
Password helpers:
- _hash_password(pw) → bcrypt rounds=12
- _verify_password(pw, stored) → handles bcrypt + legacy SHA-256
- _needs_rehash(stored) → True for 64-char hex (SHA-256)
Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15 Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
Key functions:
- get_today_checks(user_id) — shift-aware; respects visibility and check_type
- get_unchecked_report(target_date, user_id) — shift-scoped; only sites user was expected to check
- get_summary_report(date_from, date_to) — shift-scoped total_sites per user per day
- get_all_criteria() / get_active_criteria() — no LEFT JOIN; simple SELECT from ai_criteria
- save_ai_analysis() / get_ai_analysis_history() / get_ai_analysis_detail()
- delete_user() — guards: self-delete prevention + last-admin prevention
utils/crypto.py
- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto]
- encrypt(plaintext) → "enc:" + base64(Fernet token)
- decrypt(ciphertext) → plaintext; legacy plaintext (no enc: prefix) passes through unchanged
- reset_fernet() → force key reload after config.ini replacement
utils/ui_helpers.py
- ThemeManager singleton — initial="light". toggle(rebuild_callback) flips theme.
- COLOURS = _ColourProxy(dict) — always delegates to ThemeManager.get(). Import once, always current.
- THEMES["dark"] and THEMES["light"] each have 18 colour keys + 4 cal_* keys
- DateEntry — Frame subclass; .get() → "YYYY-MM-DD", .set(str). Calendar popup with prev/next month+year.
- make_scrollable_frame() — mousewheel scoped to Enter/Leave to prevent stale-widget crashes
- scrolled_text(parent, height, width) → (frame, tk.Text)
utils/scheduler.py
- Daemon thread; polls every 60 seconds
- Sends HTML email once per day when now >= send_time
- smtp_password DPAPI-encrypted on save / decrypted on load
- last_sent_date persisted to config.ini [email] last_sent_date — survives restarts
- start() / stop() called from app.py on admin login/logout
- start() guards against double-start: joins existing thread before spawning new one
- _make_smtp_server(host, port, security) — centralised connection with explicit ehlo() calls (required on Windows for reliable STARTTLS/SSL handshake)
- security field: "starttls" (port 587) | "ssl" (port 465) | "none" (port 25)
- test_smtp_connection() — 4-step diagnostic: DNS → TCP → TLS → Auth
- send_test_email() — sends real HTML test message through full pipeline
- Email report excludes users with total_sites=0 (no shift today); adds footnote count
views/email_settings_view.py
- Security mode: 3 radio buttons (STARTTLS/SSL·TLS/None) replacing old Use STARTTLS checkbox
- Selecting a security mode auto-fills the default port
- Loads legacy use_tls bool from config.ini and maps to security field on first load
- 🔌 Test Connection — step-by-step diagnostic output in status label
- 📧 Send Test Email — real email send; all buttons disabled during operation
- Save preserves last_sent_date in config.ini
views/ai_summary_view.py
AI-powered document analysis panel. Accessible from sidebar for both roles.
Key internals:
- Supported file types: .txt, .md, .csv, .pdf, .docx, .doc, .xlsx, .xls
- Groq API key and model selection visible only to admins
- Regular users use stored API key transparently
- Background thread for AI calls (prevents UI freeze)
- Notebook layout: ✨ Analyze tab + 🕑 History tab
- Extraction prompt: 12 labeled fields per document + 4-section Overall Summary
- Driving distance calculated from 2815 Hartland Rd, Falls Church VA (AI estimate)
- max_tokens=4096; temperature=0.2; MAX_CHARS_PER_FILE=14,000
Evaluation Criteria panel (collapsible):
- Admin: Add/Edit/Delete controls; CriterionDialog with live character counter (soft limit 500)
- Users: read-only treeview
- Active criteria appended to AI prompt; AI outputs RECOMMENDATION: PURSUE/PASS/UNCLEAR
- Verdict parsed by _parse_verdict() and shown as colour-coded banner (green/red/amber)
History tab:
- Every analysis saved to ai_analysis_log with verdict + criteria snapshot
- save_ai_analysis() called in _on_success() — never blocks the result display
- Filterable by verdict radio buttons (All/PURSUE/PASS/UNCLEAR/none)
- Double-click or View Result restores full output in Analyze tab
- Admins see all users' history; regular users see only their own
- Auto-refreshes on tab switch via <>
.doc reading (legacy binary Word) — 3-tier fallback:
- win32com (Word COM automation — requires MS Word installed)
- docx2txt (pure Python)
- Raw ASCII scrape from binary
URL safety: _validate_and_normalise_url() rejects non-http/https schemes.
views/admin_websites_view.py
- Website CRUD dialog with collapsible credentials section (hidden by default)
- _on_visibility_change uses before=self._cred_body (not creds_container — same parent)
- _validate_and_normalise_url() called in _save() before DB write
- URL validation: prepends https:// if no scheme; rejects javascript:/data:/file: etc.
views/admin_users_view.py
- 🔑 Reset Password button generates 16-char cryptographically random password
- _PasswordResetDialog: masked field, 👁 reveal, 📋 copy (2s flash), 120s auto-close, 30s clipboard wipe, countdown timer
- Logs RESET_PASSWORD to activity_log
views/admin_shifts_view.py
- "Show inactive" checkbox toggles visibility of inactive shifts in treeview
- _load_shifts filters by _show_inactive BooleanVar; no extra DB call
- _export_pdf(): reportlab A4; one section per active shift
views/user_dashboard_view.py
- _health_cache: {website_id: ("ok"|"slow"|"restricted"|"down", ms)}
- ok: 2xx/3xx ≤3000ms; slow: 2xx/3xx >3000ms; restricted: 4xx; down: 5xx/network error
- Notifications: single consolidated reminder per shift end-time group (not per site)
- Groups unchecked sites by end_time; fires one notification listing all sites
- _notified_sites key: (frozenset(names), end_t)
- Keyboard shortcuts: stored in _shortcut_ids; unbound in destroy()
- Credentials popup: 📋 copy (username 2s flash; password 2s flash + 15s clipboard wipe)
views/reports_view.py
- Tabs: Shift Detail / Unchecked / Summary / Chart
- All date fields use DateEntry (no manual text entry)
- Summary report: shift-scoped total_sites per user per day (not global count)
- Unchecked report: shift-scoped; only sites user was expected to check that day
- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green≥100% / amber≥50% / red<50%
views/login_view.py
- check_login_allowed(username) called before authenticate()
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
6. Authentication & Security
| Concern | Implementation |
|---|---|
| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login |
| Website credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
| Config credential protection | Windows DPAPI (CryptProtectData) — user-account-scoped |
| Encrypted fields | DB user, DB password, SMTP password, Groq API key |
| Login rate limiting | 5 attempts → 15-min lockout in DB |
| Session timeout | 30-min idle; 1-min warning; Motion events throttled to 1/5s |
| Password policy | 8+ chars, uppercase, digit, special character |
| Self-service change | Requires current password; strength meter; same-as-current guard |
| Clipboard security | Password copy auto-clears clipboard after 15 seconds |
| URL safety | _validate_and_normalise_url rejects non-http/https schemes |
| Admin self-delete | Blocked in delete_user() |
| Last-admin deletion | Blocked in delete_user() |
| Password reset display | _PasswordResetDialog: 120s auto-close, 30s clipboard wipe |
DPAPI Migration
migrate_plaintext_config() is called automatically on every startup (in _init_db).
It is a no-op if all sensitive fields are already encrypted (dpapi: prefix present).
On first run after this feature was added, it encrypts any existing plain-text values in-place.
7. Key Business Logic
check_type
- daily: shown every shift day
- weekly: hidden once checked this week (YEARWEEK ISO); reappears Monday
visibility
- all: every user in shift sees it
- assigned: only users in website_users table see it (enforced in both shift and legacy paths)
get_today_checks resolution
- Check if user has active shifts today (DAYOFWEEK match)
- If yes: shift websites filtered by check_type + visibility (website_users respected)
- If no: all active websites filtered by visibility + check_type (legacy fallback)
Unchecked report scope
Uses shift membership to determine "expected" sites per user per day. CROSS JOIN replaced with shift-aware subquery — no false positives for off-shift users.
Summary report total_sites
Correlated subquery counts shift-assigned sites per user per check_date day-of-week. A user in a 3-site shift shows 3 as total_sites, not the global count.
Soft deletes
Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first).
8. Configuration Constants (edit in source)
| File | Constant | Default |
|---|---|---|
| app.py | IDLE_TIMEOUT_MS | 1,800,000 (30 min) |
| app.py | IDLE_WARNING_MS | 60,000 (1 min) |
| app.py | APP_VERSION | "1.0.0" |
| app.py | VERSION_CHECK_ENABLED | False |
| app.py | App._IDLE_THROTTLE_S | 5.0 (seconds between Motion-event timer resets) |
| models.py | MAX_FAILED_ATTEMPTS | 5 |
| models.py | LOCKOUT_MINUTES | 15 |
| models.py | PW_MIN_LENGTH | 8 |
| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 |
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
| utils/crypto.py | _ITERATIONS | 100,000 |
| ai_summary_view.py | MAX_CHARS_PER_FILE | 14,000 |
| ai_summary_view.py | _OFFICE_ADDRESS | "2815 Hartland Road, Falls Church, VA 22043, USA" |
| ai_summary_view.py | CriterionDialog._DESC_SOFT_LIMIT | 500 (chars, soft guidance only) |
| admin_users_view.py | _PasswordResetDialog._AUTO_CLOSE_S | 120 (seconds before auto-close) |
| admin_users_view.py | _PasswordResetDialog._CLIP_CLEAR_S | 30 (seconds before clipboard wipe) |
| utils/scheduler.py | (timeout in _make_smtp_server) | 15 seconds |
9. Critical Gotchas
-
bind_all("") is NEVER used at module level. Always Enter/Leave scoped. Every view's destroy() calls unbind_all("") and _unbind_shortcuts().
-
COLOURS is a live proxy — delegates to ThemeManager.get() on every access. Never snapshot it into a local variable at class-creation time.
-
bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour.
-
config.ini sensitive fields are DPAPI-encrypted (dpapi: prefix). Website credential passwords are Fernet-encrypted (enc: prefix). Non-sensitive fields (host, port, db name, smtp_host, recipients) remain plain text.
-
get_today_checks GROUP BY includes sc.id to prevent row collisions when a user belongs to multiple shifts sharing the same website.
-
Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
-
The email scheduler last_sent_date is persisted to config.ini [email] last_sent_date (ISO format). A restart after the configured send_time will not re-send that day.
-
All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent cp1252 UnicodeEncodeError on Windows consoles.
-
DPAPI encrypted blobs are tied to the Windows user account that created them. If config.ini is copied to a different machine or user account, credentials cannot be decrypted. Users must re-enter credentials via the Settings dialog.
-
Theme toggle destroys and rebuilds the entire shell. Any unsaved in-memory state in the active panel is lost. AI summary output is not preserved across theme toggles (it is in ai_analysis_log; retrieve from History tab).
-
_make_smtp_server calls ehlo() explicitly before and after starttls() — this is required on Windows. Removing these calls breaks SMTP on many servers.
-
The security field in config.ini supersedes use_tls. On existing deployments without a security field, load_email_config() derives it from use_tls for backward compatibility.
-
get_all_criteria() does NOT join users table. The creator column is excluded intentionally — the treeview doesn't show it and the join is wasted I/O.
10. Dependencies
mysql-connector-python>=8.0.0
bcrypt>=4.0.0
openpyxl>=3.1.0
cryptography>=41.0.0
matplotlib>=3.7.0
plyer>=2.1.0
reportlab>=4.0.0
groq>=1.0.0
pypdf>=3.0.0
python-docx>=1.0.0
pywin32>=306
docx2txt>=0.8
stdlib used: tkinter, csv, smtplib, urllib.request, urllib.parse, configparser, threading, calendar, datetime, base64, re, tempfile, socket
11. First-Run Flow
- _boot() → config_exists() → False → SettingsView (locked modal)
- User enters DB creds → Test Connection → Save & Connect
- reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
- migrate_plaintext_config() → encrypts any plain-text credentials in config.ini
- Login screen shown
- ThemeManager(root, initial="light") applied; shell built
12. Sidebar Navigation
Admin role
Dashboard · Websites · Users · Shifts · Reports · Activity Log · 🤖 AI Summary · Settings · Sign Out
User role
My Shift · 🤖 AI Summary · Change Password · Sign Out
13. SMTP Configuration Reference
| Security Mode | Protocol | Default Port | Use When |
|---|---|---|---|
| STARTTLS | Plain → TLS upgrade | 587 | Office 365, Exchange, most corporate |
| SSL/TLS | TLS from first byte | 465 | Gmail direct, some providers |
| None | Unencrypted | 25 | Internal relay servers only |
Connection flow in _make_smtp_server:
- STARTTLS: SMTP() → ehlo() → starttls() → ehlo()
- SSL: SMTP_SSL() → ehlo()
- None: SMTP() → ehlo()
test_smtp_connection() diagnostic steps:
- DNS — getaddrinfo(); fails = hostname wrong or no network
- TCP — create_connection(); fails = firewall blocking the port
- TLS — _make_smtp_server(); fails = wrong security mode or port
- Auth — server.login(); fails = wrong username/password or App Password needed
14. Deployment Notes
- Python 3.9+ required. 3.12 tested on Windows.
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
- MySQL port 3306 must be reachable from client
- Default admin: username=admin / password=admin123 — change immediately
- pywin32 required for DPAPI encryption (Windows only). Install: pip install pywin32
- Microsoft Word recommended for .doc file support in AI Summary (falls back to docx2txt)
- Groq API key required for AI Summary feature — free at https://console.groq.com
- Gmail users: enable 2FA, then create an App Password at myaccount.google.com/apppasswords
- config.ini is machine- and user-account-specific. Do not share or copy between machines.
15. AI Summary Feature — Setup & Prompt Details
Setup (Admin only)
- Obtain a free Groq API key at https://console.groq.com
- Navigate to 🤖 AI Summary in the sidebar
- Enter the API key and select a model
- Click 💾 Save Settings — key stored DPAPI-encrypted in config.ini [groq]
Supported Models
- llama-3.3-70b-versatile (default, recommended)
- llama-3.1-8b-instant (faster, smaller)
- gemma2-9b-it
- mixtral-8x7b-32768
Prompt Strategy
Extraction: 12 labeled fields per document: Solicitation Number, Type, Set-Aside, Description/Scope, Work Site, Pre-Proposal Conference, POC, Square Footage, Driving Distance from 2815 Hartland Rd Falls Church VA (AI estimate), Last Day for Questions, Due Date, Other requirements.
Overall Summary: 4 sections: A. Scope of Work · B. Contract Period · C. Proposal Submission Requirements · D. Key Deadlines
Alignment evaluation (when criteria active):
- Each criterion assessed as MEETS / DOES NOT MEET / PARTIALLY MEETS
- RECOMMENDATION: PURSUE | PASS | UNCLEAR on its own line (machine-read by _parse_verdict)
- Executive Summary: 2-3 sentences
History & Audit
- Every analysis is auto-saved to ai_analysis_log (verdict + criteria snapshot at time of run)
- update_criterion() logs full description text in activity_log.detail for audit trail
- Admins see all users' analyses; users see only their own