diff --git a/CLAUDE.md b/CLAUDE.md index a746c55..c992f6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AgentHub is a FastAPI-based AI Agent Management System with MongoDB backend. It provides role-based authentication (admin/user), agent CRUD operations, user management, and a web interface built with Jinja2 templates and Bootstrap 5. +AgentHub is a FastAPI-based AI Agent Management System with MongoDB backend. It provides three-tier role-based authentication (admin/readonly_admin/user), agent CRUD operations, client verification workflow, email notifications, user management, and a web interface built with Jinja2 templates and Bootstrap 5. ## Common Development Tasks @@ -33,6 +33,8 @@ Create `.env` file with: - `MAILGUN_FROM_EMAIL`: Sender address (default: `AgentHub `) - `TOKEN_USAGE_THRESHOLD`: Token count that triggers an alert (default: 100000) - `NOTIFICATION_COOLDOWN_HOURS`: Hours between repeat alerts for the same agent (default: 24) +- `CLIENT_AGENT_NOTIFY_EMAILS`: Comma-separated list of emails for client agent notifications +- `DAILY_DIGEST_HOUR`: Hour (24h format) to send daily digest (default: 7) ### Default Login Credentials - Admin: `admin@agenthub.com` / `admin123` @@ -46,22 +48,26 @@ Create `.env` file with: - JWT cookie-based authentication system - HTML routes for web interface - REST API endpoints for agent/user management -- Role-based access control (admin vs regular user) +- Three-tier role-based access control (admin / readonly_admin / user) +- Client verification workflow with email notifications +- Daily agent digest scheduler (APScheduler) - Template rendering with Jinja2 **Key Authentication Functions**: - `get_current_user_optional()`: Cookie-based auth for templates - `get_current_user_from_cookie()`: Required auth for API endpoints -- `require_admin()`: Admin-only access control +- `require_admin()`: Admin-only access control (write operations) +- `require_admin_or_readonly()`: Admin + readonly_admin access (read-only dashboard views) ### Data Layer **models.py**: Pydantic models for: -- `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`) +- `AiAgent`: Core agent model with comprehensive fields (includes `discipline`, `rating`, `client`, `client_name`, `studio_name`) - `UsageTimelineEntry`: Daily usage data including `message_count` and `token_count` -- `UserCreate/UserResponse`: User management models -- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `rating`, `rating_count`) -- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`) +- `UserCreate/UserResponse`: User management models (includes `role` field: `user`/`admin`/`readonly_admin`) +- `UserUpdate`: Includes `role` field for three-tier role management +- `AiAgentCreate/AiAgentResponse`: API request/response models (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `rating`, `rating_count`, `client`, `client_name`, `studio_name`, `verification_status`, `verified_by`, `verified_date`) +- `AgentCollectorCreate`: Collector API input model (includes `total_tokens`, `prompt_tokens`, `completion_tokens`, `discipline`, `client`, `client_name`, `studio_name`) - `AgentUsageStatsResponse`: Usage statistics response (includes `total_tokens`, `prompt_tokens`, `completion_tokens`) **crud.py**: Database operations using Motor (async MongoDB driver): @@ -72,13 +78,17 @@ Create `.env` file with: **database.py**: MongoDB connection setup with Motor async client - Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings` -- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)` +- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)` and index on `verification_status` -**notifications.py**: Optional Mailgun email notification system: +**notifications.py**: Mailgun email notification system: - `is_mailgun_configured()`: Returns False if env vars not set (gracefully disabled) - `send_mailgun_email()`: POST to Mailgun HTTP API with 10s timeout - `build_threshold_email()`: HTML email template for threshold alerts - `check_and_notify_threshold()`: Checks token usage against threshold, enforces cooldown via `token_notifications` collection, sends to admin users +- `send_client_agent_notification()`: Sends email when client-facing agent is created (to `CLIENT_AGENT_NOTIFY_EMAILS`) +- `build_client_agent_email()`: HTML email template for client agent notifications +- `send_daily_agent_digest()`: Queries agents created in last 24 hours, sends summary to all admin users +- `build_daily_digest_email()`: HTML email template for daily digest **auth.py**: JWT authentication with: - bcrypt password hashing @@ -96,7 +106,7 @@ Located in `templates/` directory: - **agent_management.html**: Agent dashboard with real data - **search.html**: Global search functionality - **user_management.html**: User management interface -- **admin/dashboard.html**: Admin statistics and management +- **admin/dashboard.html**: Admin statistics, management, and verification workflow ### Static Assets @@ -110,8 +120,13 @@ Located in `templates/` directory: ### Authentication Flow - Cookie-based JWT authentication -- Role-based access (admin/user permissions) -- Automatic redirects based on user role +- Three-tier role-based access: `user`, `admin`, `readonly_admin` + - `user`: Standard access, can manage own agents + - `admin`: Full access to admin dashboard, all write operations + - `readonly_admin`: Can view admin dashboard but all write actions (edit, delete, approve, create) are hidden +- `role` field on user documents; `is_admin` kept in sync for backward compatibility +- `require_admin_or_readonly()` dependency for read-only admin endpoints +- Automatic redirects based on user role (admin/readonly_admin → /admin, user → /agent-management) - Secure logout with token cleanup ### Agent Management @@ -122,6 +137,32 @@ Located in `templates/` directory: - Filtering by status, audit status (Audited / Not Audited), and discipline - Admin can view/manage all agents +### Client & Verification System +- `client` field on agents: `"yes"` or `"no"` (mandatory on registration form) +- `client_name`: free text, required when client is "yes" +- `studio_name`: optional free text field +- Registration form order: Name, Description, Purpose, Client (Yes/No), Client Name (conditional), Studio Name, Tool, then Version/Status/etc. +- When `client == "yes"`: agent auto-tagged with `verification_status = "needs_verification"` +- Verification tab on admin dashboard shows pending agents with Approve button +- `PUT /api/admin/agents/{id}/verify` — admin-only, sets status to "verified" with verifier info +- `GET /api/admin/agents/pending-verification` — returns agents needing verification +- Verification badges displayed on agent cards (orange "Needs Verification", green "Verified") + +### Client Agent Email Notification +- When `client == "yes"` on agent creation, sends email via Mailgun to `CLIENT_AGENT_NOTIFY_EMAILS` +- Subject: "Client Agent Created" +- Body includes: Agent Name, Description, Purpose, Client Name, Studio Name, Tool, Created By +- Non-blocking: failure does not break agent creation + +### Daily Agent Digest Email +- Scheduled via APScheduler at configured hour (default 7:00 AM, `DAILY_DIGEST_HOUR` env var) +- Queries agents created in last 24 hours +- Sends to all active admin users +- Body includes: Agent Name, Purpose, Description, Created By (email) +- Subject: "Agents Created Last 24 Hours" +- Skips sending if no agents created +- Can be manually triggered via `POST /api/admin/digest/send` (admin-only) + ### Discipline & Star Rating - `discipline` field classifies agents into business categories: Strategy, Creative, Oversight including delivery, Optimization, Back Office including operations, Pencil Agents - Required on registration form, optional on edit (to support legacy agents) @@ -161,6 +202,9 @@ Located in `templates/` directory: ### User Management - User registration with validation - Admin user creation capabilities +- Three-tier role system: `user`, `admin`, `readonly_admin` + - Role dropdown in admin user edit modal (replaces is_admin checkbox) + - `role` and `is_admin` fields kept in sync on update - Profile management - User statistics and administration @@ -182,8 +226,10 @@ Located in `templates/` directory: ### Authentication - Use cookie-based auth for web interface - API endpoints require `get_current_user_from_cookie()` dependency -- Admin endpoints use `require_admin()` dependency +- Write endpoints use `require_admin()` dependency +- Read-only admin endpoints use `require_admin_or_readonly()` dependency - Always validate user permissions for data access +- `readonly_admin` users can view admin dashboard but UI hides all write-action buttons via `admin-write-action` CSS class ### Template Context - Pass `current_user` to all templates for navigation @@ -213,4 +259,14 @@ Key dependencies from requirements.txt: - **pydantic**: Data validation - **jinja2**: Template engine - **python-multipart**: Form handling -- **requests**: HTTP client (used for Mailgun API calls) \ No newline at end of file +- **requests**: HTTP client (used for Mailgun API calls) +- **apscheduler**: Task scheduling (daily digest email) + +## API Endpoints (New) + +### Verification +- `GET /api/admin/agents/pending-verification` — List agents with verification status (admin + readonly_admin) +- `PUT /api/admin/agents/{agent_id}/verify` — Approve/verify an agent (admin only) + +### Daily Digest +- `POST /api/admin/digest/send` — Manually trigger the daily agent digest email (admin only) \ No newline at end of file diff --git a/PLAN-prompt-audit.md b/PLAN-prompt-audit.md new file mode 100644 index 0000000..a704119 --- /dev/null +++ b/PLAN-prompt-audit.md @@ -0,0 +1,588 @@ +# Agent Tracker: Prompt Audit & Auto-Audit — Implementation Plan + +## Context + +The admin team needs visibility into what each LibreChat agent's system prompt says and whether it poses business/legal/compliance risks. System prompts will arrive via the agent-sync pipeline (see the agent-sync plan). Agent Tracker needs to: (1) accept and store them, and (2) provide on-demand AI analysis using Google Gemini to classify agents against business category definitions and flag those needing scrutiny. + +### Business Category Definitions (used for backend analysis, not visible fields) + +- **Cat 1**: Created in the Oliver AI Sandbox, used behind the scenes for experimentation. Needs IT/Compliance consideration for safety. +- **Cat 1B**: Agents that may incur large cost but aren't Cat 2 or 3. +- **Cat 2**: Created in Pencil, exposed to clients but not for sale. Needs Legal consideration. +- **Cat 3**: Created in Pencil, sold to clients or sold via teams using them. Needs Commercial team consideration. + +## Architecture + +``` +Agent-sync pipeline → POST /agents collector API (now includes system_prompt) + → Stored on agent document + +Admin clicks "Run Audit" + → Reads stored system_prompt + tools from agent docs + → Sends to Google Gemini API for analysis + → Stores audit results on agent doc + audit_history collection + → Displays in new Prompt Audit tab +``` + +- **LLM**: Google Gemini (`gemini-2.5-pro`) via `google-generativeai` SDK +- **API Key**: Reuses existing `GOOGLE_API_KEY` (same key as the ai_qc project) +- **Trigger**: On-demand only (admin clicks "Run Audit") +- **No new DB connections** — prompts come through the existing collector API + +## Files to Modify + +### 1. MODIFY: `requirements.txt` + +Add: +``` +google-generativeai>=0.3.0 +``` + +### 2. MODIFY: `database.py` — Add audit_history collection + +After line 16 (after `agent_ratings_collection`), add: +```python +audit_history_collection = db.get_collection("audit_history") +``` + +In `ensure_indexes()`, add: +```python +await agents_collection.create_index([("audit_status", 1)]) +await audit_history_collection.create_index([("agent_id", 1), ("audit_date", -1)]) +``` + +Also update the import in `audit_analyzer.py` to use `audit_history_collection`. + +### 3. MODIFY: `models.py` — Add system_prompt + audit models + +**Add to `AgentCollectorCreate`** (around line 164, with the other optional fields): +```python +system_prompt: Optional[str] = None +``` + +**Add to `AiAgentResponse`** (around line 144, after `completion_tokens`): +```python +audit_status: Optional[str] = None +audit_date: Optional[str] = None +system_prompt: Optional[str] = None +``` + +**Add new model after line 210:** +```python +class AuditReviewRequest(BaseModel): + audit_status: str = Field(..., pattern="^(flagged|reviewed|cleared)$") + reviewer_notes: Optional[str] = None +``` + +### 4. NEW: `audit_analyzer.py` — Core audit module + +New file with two responsibilities: Gemini analysis and result storage. + +**A. Gemini API Analysis** + +```python +import os +import json +import re +import asyncio +from datetime import datetime +from bson import ObjectId + +import google.generativeai as genai + +from database import agents_collection, audit_history_collection + + +def is_gemini_configured() -> bool: + return bool(os.getenv("GOOGLE_API_KEY")) + + +def _get_model(): + api_key = os.getenv("GOOGLE_API_KEY") + genai.configure(api_key=api_key) + model_name = os.getenv("AUDIT_GEMINI_MODEL", "gemini-2.5-pro") + return genai.GenerativeModel(model_name) +``` + +Key functions: + +| Function | Purpose | +|----------|---------| +| `is_gemini_configured()` | Checks `GOOGLE_API_KEY` env var | +| `analyze_single_agent(agent_name, system_prompt, tools, description, model_name, author)` | Builds prompt with category definitions, calls Gemini, parses JSON response | +| `run_audit_batch(agents, concurrency=3)` | `asyncio.Semaphore` for rate-limit-safe parallel processing | +| `store_audit_result(agent_id, audit_data)` | `$set` on agent doc + insert into `audit_history` | +| `get_all_audit_results()` | Query agents with audit fields | +| `update_audit_review(agent_id, status, notes, reviewer_info)` | Mark as reviewed/cleared | + +**Gemini Prompt Design:** + +System instruction: +``` +You are an AI agent compliance analyst. Classify AI agents based on their system prompts +and tool configurations into risk categories. + +Respond ONLY with valid JSON matching this schema: +{ + "category": "1" | "1B" | "2" | "3", + "category_reasoning": "string", + "flags": ["array", "of", "strings"], + "summary": "2-3 sentence analysis", + "recommendations": "which team(s) should review and why", + "risk_level": "low" | "medium" | "high" | "critical" +} + +CATEGORY DEFINITIONS: +Cat 1 - Internal Sandbox/Experimentation (Oliver AI Sandbox, behind the scenes, needs IT/Compliance) +Cat 1B - High Cost Internal (may incur large cost, not Cat 2 or 3) +Cat 2 - Client-Exposed Not Sold (Pencil platform, exposed to clients, needs Legal) +Cat 3 - Client-Sold (Pencil platform, sold to clients, needs Commercial team) + +RISK LEVELS: +- low: Internal-only, limited capabilities +- medium: Internal with external tool access or moderate cost +- high: Client-facing or accesses sensitive data +- critical: Client-sold or handles financial/legal/PII data + +FLAGS TO CONSIDER: +internal_only, experimental, sandbox, client_facing, pencil_platform, +revenue_generating, not_for_sale, uses_external_tools, uses_code_interpreter, +uses_file_search, accesses_sensitive_data, handles_pii, high_cost, +resource_intensive, legal_review_needed, commercial_review_needed, +compliance_review_needed, no_instructions +``` + +User prompt per agent: +``` +Analyze this AI agent: + +AGENT NAME: {name} +DESCRIPTION: {description} +MODEL: {model} +AUTHOR: {author} + +SYSTEM PROMPT / INSTRUCTIONS: +--- +{system_prompt} +--- + +TOOLS: {tools_list} +TOOL RESOURCES: {tool_resources} +ACTIONS: {actions} +``` + +**Concurrency**: `asyncio.Semaphore(3)` — configurable via `AUDIT_CONCURRENCY` env var. ~50 agents completes in ~50-85 seconds. + +**Error handling per agent**: Wrap each call in try/except. On failure, return `{"error": "...", "agent_name": "..."}`. Parse JSON response with regex fallback if needed. All audited agents start with `audit_status = "flagged"`. + +### 5. MODIFY: `main.py` — 3 new endpoints + collector update + +**A. Update `map_agent_collector_to_internal()`** to pass through `system_prompt`: + +Add to the mapping dict: +```python +"system_prompt": collector_data.system_prompt, +``` + +**B. Update `create_agent_response()`** to include audit fields: + +Add to the response construction: +```python +audit_status=agent.get("audit_status"), +audit_date=agent.get("audit_date"), +system_prompt=agent.get("system_prompt"), +``` + +**C. Add 3 new admin-only endpoints** (after the analytics endpoint, ~line 1163): + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `POST /api/admin/audit/run` | POST | Reads stored `system_prompt` from agent docs, sends to Gemini. Optional `agent_id` body param for single agent. Returns `{status, total, audited_count, failed_count, skipped_count, results_summary}`. | +| `GET /api/admin/audit/results` | GET | Returns all agents with audit data + `config_status: {gemini_configured: bool}`. | +| `PUT /api/admin/audit/{agent_id}/review` | PUT | Admin marks agent as reviewed/cleared with optional `reviewer_notes`. | + +Pre-flight check: `POST /api/admin/audit/run` returns 503 if `GOOGLE_API_KEY` not set. + +### 6. MODIFY: `templates/admin/dashboard.html` — Add Prompt Audit tab + +**A. New tab** (4th tab, after Agents Management): +```html + +``` + +**B. Tab content layout:** +``` +Row 1: Header + [Run Audit] button (with spinner loading state) +Row 2: Summary cards — Audited | Flagged | Reviewed | Cleared | No Prompt +Row 3: Filters — [Category ▼] [Risk Level ▼] [Status ▼] [Search...] +Row 4: Results table: + Agent Name | Category | Risk Level | Flags | Status | Last Audited | Actions +Row 5: Agents without prompts notice (collapsible) +``` + +**C. Audit Detail Modal:** +- Agent name + basic info header +- LLM analysis summary +- Category badge + reasoning +- Flags list (as badges) +- Recommendations text +- System prompt (collapsible `
` block, can be long)
+- Tools config (collapsible)
+- Review controls: status dropdown (flagged/reviewed/cleared) + notes textarea + Save button
+- Review history trail (who reviewed, when)
+
+**D. JavaScript functions:**
+
+| Function | Purpose |
+|----------|---------|
+| `loadAuditResults()` | `GET /api/admin/audit/results` → populate table + summary cards + flagged badge count |
+| `runAudit(agentId?)` | `POST /api/admin/audit/run` with loading spinner. Disable button during run. On complete, show summary alert + reload results. |
+| `displayAuditResults(agents)` | Render table rows with color-coded badges |
+| `showAuditDetail(agentId)` | Open detail modal with full analysis |
+| `submitAuditReview(agentId)` | `PUT /api/admin/audit/{id}/review` → reload results |
+| `filterAuditResults()` | Client-side filtering by category, risk, status, search text |
+
+Wire up:
+- `DOMContentLoaded` → call `loadAuditResults()` alongside existing `loadAdminData()` and `loadAnalytics()`
+- Refresh button → also call `loadAuditResults()`
+
+**E. CSS additions:**
+```css
+.risk-critical { background-color: #dc3545; color: white; }
+.risk-high { background-color: #fd7e14; color: white; }
+.risk-medium { background-color: #ffc107; color: #212529; }
+.risk-low { background-color: #28a745; color: white; }
+
+.cat-1 { background-color: #28a745; color: white; }
+.cat-1b { background-color: #17a2b8; color: white; }
+.cat-2 { background-color: #6f42c1; color: white; }
+.cat-3 { background-color: #dc3545; color: white; }
+
+.audit-status-flagged { border-left: 4px solid #dc3545; }
+.audit-status-reviewed { border-left: 4px solid #ffc107; }
+.audit-status-cleared { border-left: 4px solid #28a745; }
+```
+
+**F. Config warning:** If `config_status.gemini_configured` is false in the results response, show an info card explaining that `GOOGLE_API_KEY` needs to be set, and hide the Run Audit button.
+
+## Environment Variables
+
+Add to `.env`:
+```
+GOOGLE_API_KEY=AIzaSyDMWN_PAnyU7bPmtWcEKq4LJfiu1KuwUsU
+AUDIT_GEMINI_MODEL=gemini-2.5-pro     # default, configurable
+AUDIT_CONCURRENCY=3                    # concurrent Gemini API calls
+```
+
+## Error Handling
+
+| Scenario | Handling |
+|----------|---------|
+| `GOOGLE_API_KEY` not set | 503 with message; UI shows config warning, hides Run button |
+| Agent has no `system_prompt` | Skip during audit, count as "skipped", show in "No Prompt" card |
+| Gemini API auth failure | Abort batch, return 503 |
+| Gemini rate limit | Retry with backoff; semaphore limits concurrency; mark agent as failed |
+| Prompt too long | Gemini 2.5 Pro has 1M token limit — unlikely to hit; truncate at 900K chars if needed |
+| JSON parse failure from Gemini | Regex extraction fallback `\{[\s\S]*\}`; if fails, store raw text as summary |
+| Partial failures | Continue processing remaining agents; report success/fail/skipped counts |
+
+## Design Decisions
+
+- **Prompts via agent-sync** — no second DB connection needed, reuses existing infrastructure
+- **All audited agents start as "flagged"** — admin must explicitly review/clear each
+- **Synchronous HTTP request** for v1 — admin waits with spinner (~30-120s for full audit)
+- **Gemini 2.5 Pro** — fast, 1M token context window, reuses existing Google API key
+- **Separate from quality_audit_status** — that's a manual human checkbox; this is automated analysis. They coexist.
+- **audit_history_collection** — historical record of each audit run
+
+## Implementation Order
+
+1. `requirements.txt` — add `google-generativeai`
+2. `database.py` — add `audit_history_collection` + indexes
+3. `models.py` — add `system_prompt` to collector model, audit fields to response, `AuditReviewRequest`
+4. `audit_analyzer.py` — new file (Gemini analysis + result storage)
+5. `main.py` — update collector mapping + `create_agent_response()` + add 3 audit endpoints
+6. `templates/admin/dashboard.html` — Prompt Audit tab (HTML, JS, CSS)
+
+## Verification
+
+1. Add `GOOGLE_API_KEY` to `.env`
+2. `pip install -r requirements.txt`
+3. `uvicorn main:app --reload --port 8000`
+4. Login as admin → `/admin` → click "Prompt Audit" tab
+5. Before agent-sync runs: agents show as "No prompt available"
+6. After agent-sync (with instructions included): `system_prompt` appears on agent docs
+7. Click "Run Audit" → spinner → results populate with category/risk/flags badges
+8. Click detail on an agent → see full LLM analysis + raw system prompt
+9. Mark as "Reviewed" with notes → status updates, reviewer trail shows
+10. Refresh → all data persists
+11. Test without `GOOGLE_API_KEY` → config warning shown gracefully
+
+## Files Changed Summary (for deployment)
+
+| File | Change |
+|------|--------|
+| `audit_analyzer.py` | **New file** |
+| `database.py` | Add collection + indexes |
+| `models.py` | Add fields + new model |
+| `main.py` | Update collector mapping + 3 new endpoints |
+| `templates/admin/dashboard.html` | Add Prompt Audit tab |
+| `requirements.txt` | Add `google-generativeai` |
+
+---
+
+# Phase 2: Read-Only Admin, Client Verification & Daily Digest
+
+## 1. Read-Only Admin Role
+
+### Problem
+Currently only two roles exist: `user` and `admin`. There's a need for users who can **view** the admin dashboard but not modify anything.
+
+### Solution
+Add a third role: `readonly_admin`.
+
+**A. Data Model Changes (`models.py`)**
+- Update `UserCreate` and user handling to support `role` values: `user`, `admin`, `readonly_admin`
+
+**B. Auth Changes (`auth.py` / `main.py`)**
+- Add `require_admin_or_readonly()` dependency — allows access to admin dashboard GET routes
+- Existing `require_admin()` stays as-is for write operations (create/update/delete)
+- `readonly_admin` users:
+  - Can access `/admin` dashboard and view all tabs
+  - Cannot run audits, edit agents, edit users, approve verifications, or perform any write action
+  - UI hides action buttons (edit, delete, approve, Run Audit) for readonly users
+
+**C. User Management UI (`templates/admin/dashboard.html`)**
+- In the user edit modal, add a role dropdown: `User` | `Admin` | `Read-Only Admin`
+- Admin can change any user's role (except their own demotion from admin)
+
+**D. API Changes (`main.py`)**
+- `GET /admin` → allow `admin` + `readonly_admin`
+- All `POST/PUT/DELETE` admin endpoints → require `admin` only (no change)
+- `GET /api/admin/*` data endpoints → allow `admin` + `readonly_admin`
+- New endpoint: `PUT /api/users/{user_id}/role` — admin-only, sets role
+
+---
+
+## 2. Client Verification System
+
+### Problem
+Agents created for clients need a verification step before they're considered approved for use.
+
+### Solution
+Add a `verification_status` field to agents and a verification workflow.
+
+**A. Data Model Changes (`models.py`)**
+- Add to `AiAgent` / `AiAgentResponse`:
+  - `client: Optional[str] = None` — `"yes"` or `"no"`
+  - `client_name: Optional[str] = None` — free text, required when `client == "yes"`
+  - `studio_name: Optional[str] = None` — free text, not mandatory
+  - `verification_status: Optional[str] = None` — `"needs_verification"` | `"verified"` | `None`
+  - `verified_by: Optional[str] = None` — user who verified
+  - `verified_date: Optional[str] = None` — when verified
+- Add to `AiAgentCreate`:
+  - `client: str` (mandatory, `"yes"` or `"no"`)
+  - `client_name: Optional[str] = None`
+  - `studio_name: Optional[str] = None`
+- Add to `AgentCollectorCreate`:
+  - `client: Optional[str] = None`
+  - `client_name: Optional[str] = None`
+  - `studio_name: Optional[str] = None`
+
+**B. Registration Form (`templates/agent_register.html`)**
+
+Reorder form fields to:
+1. **Agent Name** (mandatory)
+2. **Description** (stays the same)
+3. **Purpose** (stays the same)
+4. **Client** — dropdown: `Yes` / `No` (mandatory)
+   - 4a. If `Yes` → show a text input for **Client Name** (mandatory when visible)
+5. **Studio Name** — free text (not mandatory)
+6. **Tool** (stays the same)
+7. *...rest of form continues from Version, Status, etc.*
+
+JavaScript: toggle `client_name` field visibility based on `client` dropdown value.
+
+**C. Auto-Tagging Logic**
+- When `client == "yes"` on agent creation → set `verification_status = "needs_verification"`
+- When `client == "no"` or not set → `verification_status` remains `None`
+
+**D. Verification Tab on Admin Dashboard (`templates/admin/dashboard.html`)**
+
+New tab: **Verification** (between Agents Management and Prompt Audit tabs)
+```
+Tab content:
+- Header: "Agents Pending Verification"
+- Table: Agent Name | Client Name | Studio | Created By | Date Created | Status | Action
+- Each row with status "needs_verification" shows an [Approve ✓] button
+- Clicking Approve → calls PUT /api/admin/agents/{id}/verify → status changes to "verified"
+- Verified agents move to a collapsible "Recently Verified" section below
+- Filter: Show All | Needs Verification | Verified
+```
+
+**E. API Endpoints (`main.py`)**
+- `PUT /api/admin/agents/{agent_id}/verify` — admin-only, sets `verification_status = "verified"`, records `verified_by` and `verified_date`
+- `GET /api/admin/agents/pending-verification` — returns agents where `verification_status == "needs_verification"`
+
+**F. Agent Card Display**
+- Show verification badge on agent cards:
+  - `needs_verification` → orange badge: "Needs Verification"
+  - `verified` → green badge: "Verified"
+  - `None` → no badge (non-client agents)
+
+---
+
+## 3. Client Agent Email Notification
+
+### Problem
+When a client-facing agent is created, stakeholders need to be notified immediately.
+
+### Solution
+Trigger an email via Mailgun when `client == "yes"` on agent creation.
+
+**A. Mailgun Configuration (`.env`)**
+```
+MAILGUN_API_KEY=4fccfbb0606b55852d243fc848f0356c-c6620443-faf31917
+MAILGUN_DOMAIN=mg.oliver.solutions
+MAILGUN_FROM_EMAIL=AgentHub 
+CLIENT_AGENT_NOTIFY_EMAILS=EstellevanHeerden@oliver.agency
+```
+
+Note: `CLIENT_AGENT_NOTIFY_EMAILS` is a comma-separated list, making it easy to add recipients later.
+
+**B. Notification Logic (`notifications.py`)**
+
+New function: `send_client_agent_notification(agent_data: dict)`
+- **Trigger**: Called from the agent creation endpoint when `client == "yes"`
+- **Subject**: `Client Agent Created`
+- **Body** (HTML email):
+  ```
+  A new client-facing agent has been created and requires verification.
+
+  Agent Name: {agent_name}
+  Description: {description}
+  Purpose: {purpose}
+  Client: Yes
+  Client Name: {client_name}
+  Studio Name: {studio_name or "N/A"}
+  Tool: {tool}
+  Created By: {user_email}
+
+  Please review this agent in AgentHub.
+  ```
+- **To**: `CLIENT_AGENT_NOTIFY_EMAILS` env var (comma-split)
+- **Non-blocking**: Failure does not break agent creation (try/except, log warning)
+
+**C. Mailgun API Call**
+Uses existing `send_mailgun_email()` pattern from `notifications.py`:
+```
+POST https://api.mailgun.net/v3/mg.oliver.solutions/messages
+Auth: api:{MAILGUN_API_KEY}
+Form data: from, to, subject, html
+```
+
+---
+
+## 4. Daily Agent Digest Email
+
+### Problem
+Admins need a daily summary of all agents created in the last 24 hours for quick scanning.
+
+### Solution
+A scheduled daily email summarising new agents.
+
+**A. New Function (`notifications.py`)**
+
+`send_daily_agent_digest()`
+- **Query**: Find all agents with `created_date` in the last 24 hours
+- **Subject**: `Agents Created Last 24 Hours`
+- **Body** (HTML email, kept short and scannable):
+  ```
+  {count} agent(s) created in the last 24 hours:
+
+  ┌──────────────────────────────────────────────────┐
+  │ 1. Agent Name                                    │
+  │    Purpose: Brief purpose text                   │
+  │    Description: Brief description text           │
+  │    Created by: user@email.com                    │
+  │                                                  │
+  │ 2. Agent Name                                    │
+  │    Purpose: ...                                  │
+  │    Description: ...                              │
+  │    Created by: ...                               │
+  └──────────────────────────────────────────────────┘
+
+  View full details at {AGENTHUB_URL}/admin
+  ```
+- **To**: All active admin users (query `users` collection for `role == "admin"`)
+- **If no agents created**: Skip sending (don't send empty digest)
+
+**B. Scheduling**
+
+Option: Use `apscheduler` (add to `requirements.txt`):
+```python
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+
+scheduler = AsyncIOScheduler()
+scheduler.add_job(send_daily_agent_digest, 'cron', hour=7, minute=0)  # 7:00 AM daily
+scheduler.start()
+```
+
+Wire into FastAPI `startup` event in `main.py`.
+
+Alternatively, can be triggered via a cron job calling a new admin endpoint:
+- `POST /api/admin/digest/send` — admin-only, manually trigger the digest
+
+**C. Configuration (`.env`)**
+```
+DAILY_DIGEST_HOUR=7          # Hour to send (24h format), default 7
+DAILY_DIGEST_TIMEZONE=UTC    # Timezone, default UTC
+```
+
+---
+
+## Implementation Order (Phase 2)
+
+1. **Models** — Add `client`, `client_name`, `studio_name`, `verification_status`, `verified_by`, `verified_date` fields; update `role` to support `readonly_admin`
+2. **Database** — Add index on `verification_status`
+3. **Auth** — Add `require_admin_or_readonly()` dependency
+4. **Registration Form** — Reorder fields, add Client dropdown with conditional Client Name, add Studio Name
+5. **CRUD/main.py** — Auto-tag verification status on creation, add verification + role endpoints
+6. **Notifications** — Client agent email + daily digest function
+7. **Admin Dashboard** — Verification tab, role dropdown in user edit, readonly UI gating
+8. **Scheduler** — Wire up daily digest (apscheduler or cron endpoint)
+
+## Files Changed Summary (Phase 2)
+
+| File | Change |
+|------|--------|
+| `models.py` | Add client/verification/studio fields, readonly_admin role support |
+| `database.py` | Add index on `verification_status` |
+| `auth.py` | Add `require_admin_or_readonly()` |
+| `crud.py` | Verification queries, daily digest query |
+| `main.py` | Verification endpoints, role endpoint, digest endpoint, auth updates |
+| `notifications.py` | Client agent notification, daily digest email |
+| `templates/agent_register.html` | Reorder form, add Client/Studio fields |
+| `templates/admin/dashboard.html` | Verification tab, role dropdown, readonly gating |
+| `requirements.txt` | Add `apscheduler` (if using scheduler approach) |
+| `.env` | Add `CLIENT_AGENT_NOTIFY_EMAILS`, `DAILY_DIGEST_HOUR`, `DAILY_DIGEST_TIMEZONE` |
+
+## Verification (Phase 2)
+
+1. Create a user with `readonly_admin` role → confirm they can view `/admin` but all action buttons are hidden
+2. Register agent with Client = Yes, Client Name = "Test Client" → confirm `verification_status = "needs_verification"` and email sent to configured address
+3. Register agent with Client = No → confirm no verification tag, no email
+4. Admin dashboard → Verification tab → approve an agent → confirm status changes to "verified" with badge update
+5. Wait for scheduled time (or trigger manually) → confirm daily digest email arrives with correct agent list
+6. Test with no agents created → confirm no email sent
+
+---
+
+## Dependency on Agent-Sync
+
+This plan requires the agent-sync changes to be deployed (removing the `instructions` exclusion and adding `system_prompt` to the payload). Without it, agents will have no `system_prompt` stored and the audit will skip all agents. The audit tab will still load and show the "No Prompt" count gracefully.
diff --git a/crud.py b/crud.py
index 3f3bb8f..4ad31a2 100644
--- a/crud.py
+++ b/crud.py
@@ -218,20 +218,142 @@ async def search_agents(search_term: str, user_id: str = None):
     
     return await agents_collection.find(query).sort("created_at", -1).to_list(length=None)
 
-async def get_agent_stats():
+async def get_agent_stats(discipline_filter: str = None):
     """Get agent statistics"""
-    pipeline = [
-        {
-            "$group": {
-                "_id": "$agent_status",
-                "count": {"$sum": 1}
-            }
+    pipeline = []
+    if discipline_filter:
+        pipeline.append({"$match": {"discipline": discipline_filter}})
+    pipeline.append({
+        "$group": {
+            "_id": "$agent_status",
+            "count": {"$sum": 1}
         }
-    ]
-    
+    })
+
     stats = await agents_collection.aggregate(pipeline).to_list(length=None)
     return {stat["_id"]: stat["count"] for stat in stats}
 
+async def get_analytics_summary(status_filter: str = None, discipline_filter: str = None):
+    """Get aggregated analytics summary across all agents"""
+    match_filter = {}
+    if status_filter:
+        match_filter["agent_status"] = status_filter
+    if discipline_filter:
+        match_filter["discipline"] = discipline_filter
+
+    pipeline = []
+    if match_filter:
+        pipeline.append({"$match": match_filter})
+    pipeline.append({
+        "$group": {
+            "_id": None,
+            "total_messages": {"$sum": {"$ifNull": ["$total_messages", 0]}},
+            "total_tokens": {"$sum": {"$ifNull": ["$total_tokens", 0]}},
+            "prompt_tokens": {"$sum": {"$ifNull": ["$prompt_tokens", 0]}},
+            "completion_tokens": {"$sum": {"$ifNull": ["$completion_tokens", 0]}},
+            "conversation_count": {"$sum": {"$ifNull": ["$conversation_count", 0]}},
+            "unique_users": {"$sum": {"$ifNull": ["$unique_users", 0]}},
+        }
+    })
+    results = await agents_collection.aggregate(pipeline).to_list(length=1)
+    if results:
+        r = results[0]
+        del r["_id"]
+        return r
+    return {
+        "total_messages": 0, "total_tokens": 0, "prompt_tokens": 0,
+        "completion_tokens": 0, "conversation_count": 0, "unique_users": 0,
+    }
+
+async def get_discipline_breakdown(status_filter: str = None):
+    """Group agents by discipline field"""
+    pipeline = []
+    if status_filter:
+        pipeline.append({"$match": {"agent_status": status_filter}})
+    pipeline.append({"$group": {"_id": "$discipline", "count": {"$sum": 1}}})
+    results = await agents_collection.aggregate(pipeline).to_list(length=None)
+    return {(r["_id"] or "Unassigned"): r["count"] for r in results}
+
+async def get_aggregated_usage_timeline(limit_days: int = 90, status_filter: str = None, discipline_filter: str = None):
+    """Aggregate usage_timeline across all agents for system-wide daily totals"""
+    match_filter = {"usage_timeline": {"$exists": True, "$ne": []}}
+    if status_filter:
+        match_filter["agent_status"] = status_filter
+    if discipline_filter:
+        match_filter["discipline"] = discipline_filter
+
+    pipeline = [
+        {"$match": match_filter},
+        {"$unwind": "$usage_timeline"},
+        {
+            "$group": {
+                "_id": "$usage_timeline.date",
+                "message_count": {"$sum": {"$ifNull": ["$usage_timeline.message_count", 0]}},
+                "token_count": {"$sum": {"$ifNull": ["$usage_timeline.token_count", 0]}},
+            }
+        },
+        {"$sort": {"_id": 1}},
+        {"$limit": limit_days},
+    ]
+    results = await agents_collection.aggregate(pipeline).to_list(length=None)
+    return [{"date": r["_id"], "message_count": r["message_count"], "token_count": r["token_count"]} for r in results]
+
+async def get_top_agents(sort_field: str, limit: int = 5, status_filter: str = None, discipline_filter: str = None):
+    """Return top N agents by a given numeric field"""
+    match_filter = {sort_field: {"$exists": True, "$ne": None, "$gt": 0}}
+    if status_filter:
+        match_filter["agent_status"] = status_filter
+    if discipline_filter:
+        match_filter["discipline"] = discipline_filter
+
+    pipeline = [
+        {"$match": match_filter},
+        {"$sort": {sort_field: -1}},
+        {"$limit": limit},
+        {
+            "$project": {
+                "agent_name": 1,
+                "agent_status": 1,
+                "discipline": 1,
+                "total_messages": 1,
+                "total_tokens": 1,
+                "last_used": 1,
+            }
+        },
+    ]
+    results = await agents_collection.aggregate(pipeline).to_list(length=None)
+    for r in results:
+        r["_id"] = str(r["_id"])
+    return results
+
+async def get_recently_active_agents(limit: int = 10, status_filter: str = None, discipline_filter: str = None):
+    """Return recently active agents sorted by last_used"""
+    match_filter = {"last_used": {"$exists": True, "$ne": None}}
+    if status_filter:
+        match_filter["agent_status"] = status_filter
+    if discipline_filter:
+        match_filter["discipline"] = discipline_filter
+
+    pipeline = [
+        {"$match": match_filter},
+        {"$sort": {"last_used": -1}},
+        {"$limit": limit},
+        {
+            "$project": {
+                "agent_name": 1,
+                "agent_status": 1,
+                "discipline": 1,
+                "total_messages": 1,
+                "total_tokens": 1,
+                "last_used": 1,
+            }
+        },
+    ]
+    results = await agents_collection.aggregate(pipeline).to_list(length=None)
+    for r in results:
+        r["_id"] = str(r["_id"])
+    return results
+
 async def update_agent(agent_id: str, update_data: dict, user_id: str = None, admin_user_info: dict = None, last_edited_by: str = None):
     try:
         filter_query = {"_id": ObjectId(agent_id)}
diff --git a/database.py b/database.py
index c802dfb..2ace175 100644
--- a/database.py
+++ b/database.py
@@ -22,6 +22,7 @@ async def ensure_indexes():
             [("agent_id", 1), ("user_id", 1)],
             unique=True
         )
+        await agents_collection.create_index([("verification_status", 1)])
         print("Database indexes ensured successfully")
     except Exception as e:
         print(f"Warning: Failed to create indexes: {e}")
diff --git a/main.py b/main.py
index f7df4c9..5a36cfc 100644
--- a/main.py
+++ b/main.py
@@ -19,6 +19,7 @@ from fastapi import Header
 import csv
 import io
 import json
+import asyncio
 
 load_dotenv()
 
@@ -162,6 +163,14 @@ async def require_admin(current_user: dict = Depends(get_current_user_from_cooki
         raise HTTPException(status_code=403, detail="Admin access required")
     return current_user
 
+async def require_admin_or_readonly(current_user: dict = Depends(get_current_user_from_cookie)):
+    """Require admin or readonly_admin access"""
+    if current_user.get("is_admin"):
+        return current_user
+    if current_user.get("role") == "readonly_admin":
+        return current_user
+    raise HTTPException(status_code=403, detail="Admin access required")
+
 def sanitize_metadata(metadata: dict) -> dict[str, str]:
     """
     Sanitize agent metadata to ensure all values are strings.
@@ -228,6 +237,12 @@ def create_agent_response(agent: dict) -> models.AiAgentResponse:
         discipline=agent.get("discipline"),
         rating=agent.get("rating"),
         rating_count=agent.get("rating_count"),
+        client=agent.get("client"),
+        client_name=agent.get("client_name"),
+        studio_name=agent.get("studio_name"),
+        verification_status=agent.get("verification_status"),
+        verified_by=agent.get("verified_by"),
+        verified_date=agent.get("verified_date"),
         created_by=agent["created_by"],
         # Usage tracking fields
         usage_timeline=agent.get("usage_timeline"),
@@ -290,6 +305,9 @@ def map_agent_collector_to_internal(collector_data: models.AgentCollectorCreate)
         "agent_metadata": collector_data.metadata,
         "url": collector_data.url,
         "discipline": discipline,
+        "client": collector_data.client,
+        "client_name": collector_data.client_name,
+        "studio_name": collector_data.studio_name,
         # Usage tracking fields
         "usage_timeline": usage_timeline,
         "conversation_count": collector_data.conversation_count,
@@ -310,7 +328,8 @@ async def me(current_user: dict = Depends(get_current_user)):
         "email": current_user["email"],
         "full_name": current_user.get("full_name"),
         "is_active": current_user["is_active"],
-        "is_admin": current_user["is_admin"]
+        "is_admin": current_user["is_admin"],
+        "role": current_user.get("role", "admin" if current_user["is_admin"] else "user"),
     }
 
 @app.on_event("startup")
@@ -324,6 +343,23 @@ async def startup_event():
     if count > 0:
         print(f"Pencil Agents migration: updated {count} agent(s)")
 
+    # Start daily digest scheduler
+    try:
+        from apscheduler.schedulers.asyncio import AsyncIOScheduler
+        digest_hour = int(os.getenv("DAILY_DIGEST_HOUR", "7"))
+        scheduler = AsyncIOScheduler()
+        scheduler.add_job(
+            notifications.send_daily_agent_digest,
+            'cron',
+            hour=digest_hour,
+            minute=0,
+            id='daily_agent_digest',
+        )
+        scheduler.start()
+        print(f"Daily digest scheduler started (runs at {digest_hour}:00)")
+    except Exception as e:
+        print(f"Warning: Failed to start daily digest scheduler: {e}")
+
 # HTML Routes
 @app.get("/")
 async def home(request: Request):
@@ -344,7 +380,7 @@ async def home(request: Request):
     current_user = await get_current_user_optional(request)
     if current_user:
         # Redirect logged-in users appropriately
-        if current_user.get("is_admin"):
+        if current_user.get("is_admin") or current_user.get("role") == "readonly_admin":
             return RedirectResponse(url=get_app_url("admin"), status_code=303)
         else:
             return RedirectResponse(url=get_app_url("agent-management"), status_code=303)
@@ -441,9 +477,9 @@ async def login_form(
         # Create token (you can store this in session/cookie in a real app)
         token = auth.create_access_token({"sub": str(user["_id"])})
         
-        # Check if user is admin
-        if user.get("is_admin"):
-            # Admin goes to admin dashboard
+        # Check if user is admin or readonly_admin
+        if user.get("is_admin") or user.get("role") == "readonly_admin":
+            # Admin / readonly_admin goes to admin dashboard
             response = RedirectResponse(url=get_app_url("admin"), status_code=303)
         else:
             # Regular user goes to all agents page
@@ -589,6 +625,9 @@ async def agent_register_form(
     agent_tool: str = Form(...),
     agent_description: str = Form(None),
     agent_purpose: str = Form(None),
+    client: str = Form(...),
+    client_name: str = Form(None),
+    studio_name: str = Form(None),
     agent_version: str = Form(None),
     agent_status: str = Form("Development"),
     agent_location: str = Form(None),
@@ -606,9 +645,15 @@ async def agent_register_form(
         current_user = await get_current_user_optional(request)
         if not current_user:
             return RedirectResponse(url=get_app_url("login"), status_code=303)
-        
+
         user_id = str(current_user["_id"])
-        
+
+        # Validate client_name is provided when client is Yes
+        if client.lower() == "yes" and not client_name:
+            context = get_template_context(request, current_user)
+            context["error"] = "Client Name is required when Client is set to Yes."
+            return templates.TemplateResponse("agent_register.html", context)
+
         # Prepare agent data
         agent_data = {
             "agent_name": agent_name,
@@ -621,8 +666,15 @@ async def agent_register_form(
             "agent_department": agent_department,
             "agent_contact_person": agent_contact_person,
             "discipline": discipline,
+            "client": client.lower(),
+            "studio_name": studio_name,
         }
-        
+
+        # Handle client-specific fields
+        if client.lower() == "yes":
+            agent_data["client_name"] = client_name
+            agent_data["verification_status"] = "needs_verification"
+
         # Process tags, userbase, and capabilities (convert comma-separated to lists)
         if agent_tags:
             agent_data["agent_tags"] = [tag.strip() for tag in agent_tags.split(',') if tag.strip()]
@@ -630,7 +682,7 @@ async def agent_register_form(
             agent_data["agent_userbase"] = [user.strip() for user in agent_userbase.split(',') if user.strip()]
         if agent_capabilities:
             agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()]
-        
+
         # Handle Quality Audit - only admins can set it to True
         if quality_audit_status and current_user.get("is_admin"):
             # Admin is setting quality audit to true
@@ -639,32 +691,43 @@ async def agent_register_form(
             agent_data["quality_audit_updated_by"] = user_id
             agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
             agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email"))
-            
+
             # Validate Risk Factor when Quality Audit is checked
             if risk_factor is None or not (1 <= risk_factor <= 5):
                 context = get_template_context(request, current_user)
                 context["error"] = "Risk Factor (1-5) is required when Quality Audit is checked."
                 return templates.TemplateResponse("agent_register.html", context)
-            
+
             agent_data["risk_factor"] = risk_factor
         else:
             # Non-admin or quality audit not checked
             agent_data["quality_audit_status"] = False
             agent_data["risk_factor"] = None
-        
+
         # Remove None values
         agent_data = {k: v for k, v in agent_data.items() if v is not None}
-        
+
         # Create agent in database
         created_agent = await crud.create_agent(agent_data, user_id)
-        
+
+        # Send client agent notification if client is Yes (non-blocking)
+        if client.lower() == "yes":
+            try:
+                notification_data = {
+                    **agent_data,
+                    "created_by_email": current_user.get("email", "Unknown"),
+                }
+                notifications.send_client_agent_notification(notification_data)
+            except Exception as notify_err:
+                print(f"Client agent notification failed: {notify_err}")
+
         # Redirect to agent management with success message
         from urllib.parse import quote
         success_msg = quote(f"Agent '{agent_name}' registered successfully!")
         redirect_url = get_app_url(f"agent-management?success={success_msg}")
         print(f"DEBUG: Redirecting to: {redirect_url}")  # Debug line
         return RedirectResponse(
-            url=redirect_url, 
+            url=redirect_url,
             status_code=303
         )
         
@@ -745,7 +808,9 @@ async def agent_management_page(request: Request, view: Optional[str] = Query(No
 @app.get("/admin", response_class=HTMLResponse)
 async def admin_dashboard(request: Request):
     current_user = await get_current_user_optional(request)
-    if not current_user or not current_user.get("is_admin"):
+    if not current_user:
+        return RedirectResponse(url=get_app_url("login"), status_code=303)
+    if not current_user.get("is_admin") and current_user.get("role") != "readonly_admin":
         return RedirectResponse(url=get_app_url("login"), status_code=303)
     
     # Get statistics
@@ -1002,7 +1067,7 @@ async def toggle_admin_view(request: Request, current_user: dict = Depends(get_c
 
 # Admin endpoints
 @app.get("/api/admin/users", response_model=List[models.UserResponse])
-async def get_all_users(current_user: dict = Depends(require_admin)):
+async def get_all_users(current_user: dict = Depends(require_admin_or_readonly)):
 
     users = await crud.get_all_users()
     return [
@@ -1011,6 +1076,7 @@ async def get_all_users(current_user: dict = Depends(require_admin)):
             full_name=user.get("full_name"),
             is_active=user["is_active"],
             is_admin=user["is_admin"],
+            role=user.get("role", "admin" if user.get("is_admin") else "user"),
             auth_provider=user.get("auth_provider", "local")
         ) for user in users
     ]
@@ -1033,6 +1099,7 @@ async def admin_create_user(
             full_name=created_user.get("full_name"),
             is_active=created_user["is_active"],
             is_admin=created_user["is_admin"],
+            role=created_user.get("role", "admin" if created_user["is_admin"] else "user"),
             auth_provider=created_user.get("auth_provider", "local")
         )
     except ValueError as e:
@@ -1047,6 +1114,24 @@ async def update_user(email: str, user_update: models.UserUpdate, current_user:
 
     # Update the user
     update_data = user_update.model_dump(exclude_unset=True)
+
+    # Sync role and is_admin fields
+    if "role" in update_data:
+        role = update_data["role"]
+        if role == "admin":
+            update_data["is_admin"] = True
+        elif role == "readonly_admin":
+            update_data["is_admin"] = False
+        elif role == "user":
+            update_data["is_admin"] = False
+    elif "is_admin" in update_data:
+        if update_data["is_admin"]:
+            update_data["role"] = "admin"
+        else:
+            # Only reset to user if not already readonly_admin
+            if existing_user.get("role") != "readonly_admin":
+                update_data["role"] = "user"
+
     updated_user = await crud.update_user(str(existing_user["_id"]), update_data)
 
     if not updated_user:
@@ -1057,6 +1142,7 @@ async def update_user(email: str, user_update: models.UserUpdate, current_user:
         full_name=updated_user.get("full_name"),
         is_active=updated_user["is_active"],
         is_admin=updated_user["is_admin"],
+        role=updated_user.get("role", "admin" if updated_user["is_admin"] else "user"),
         auth_provider=updated_user.get("auth_provider", "local")
     )
 
@@ -1109,8 +1195,132 @@ async def change_password(
     except ValueError as e:
         raise HTTPException(status_code=400, detail=str(e))
 
+@app.get("/api/admin/agents/pending-verification")
+async def get_pending_verification(current_user: dict = Depends(require_admin_or_readonly)):
+    """Get agents pending verification"""
+    from database import agents_collection as ac
+    cursor = ac.find(
+        {"verification_status": {"$in": ["needs_verification", "verified"]}}
+    ).sort("created_at", -1)
+    agents = await cursor.to_list(length=None)
+    result = []
+    for agent in agents:
+        # Resolve creator email
+        created_by = agent.get("created_by", "")
+        created_by_email = created_by
+        if created_by and created_by != "agent_collector_api":
+            try:
+                from bson import ObjectId
+                creator = await crud.get_user_by_id(created_by)
+                if creator:
+                    created_by_email = creator.get("email", created_by)
+            except Exception:
+                pass
+        result.append({
+            "agent_id": str(agent["_id"]),
+            "agent_name": agent.get("agent_name"),
+            "client_name": agent.get("client_name"),
+            "studio_name": agent.get("studio_name"),
+            "created_by": created_by_email,
+            "created_at": agent["created_at"].isoformat() if agent.get("created_at") else None,
+            "verification_status": agent.get("verification_status"),
+            "verified_by": agent.get("verified_by"),
+            "verified_date": agent.get("verified_date"),
+        })
+    return result
+
+@app.put("/api/admin/agents/{agent_id}/verify")
+async def verify_agent(agent_id: str, current_user: dict = Depends(require_admin)):
+    """Approve/verify an agent (admin only)"""
+    from bson import ObjectId
+    from database import agents_collection as ac
+    agent = await crud.get_agent_by_id(agent_id)
+    if not agent:
+        raise HTTPException(status_code=404, detail="Agent not found")
+
+    result = await ac.update_one(
+        {"_id": ObjectId(agent_id)},
+        {"$set": {
+            "verification_status": "verified",
+            "verified_by": current_user.get("email", str(current_user["_id"])),
+            "verified_date": datetime.utcnow().isoformat(),
+            "updated_at": datetime.utcnow(),
+        }}
+    )
+    if result.modified_count:
+        return {"message": "Agent verified successfully"}
+    raise HTTPException(status_code=500, detail="Failed to verify agent")
+
+@app.post("/api/admin/digest/send")
+async def trigger_daily_digest(current_user: dict = Depends(require_admin)):
+    """Manually trigger the daily agent digest email"""
+    try:
+        await notifications.send_daily_agent_digest()
+        return {"message": "Daily digest sent successfully"}
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to send digest: {str(e)}")
+
+@app.get("/api/admin/analytics")
+async def get_admin_analytics(
+    status: Optional[str] = None,
+    discipline: Optional[str] = None,
+    days: int = 90,
+    current_user: dict = Depends(require_admin_or_readonly),
+):
+    """Get analytics data for admin dashboard"""
+    (
+        summary,
+        status_breakdown,
+        discipline_breakdown,
+        usage_timeline,
+        top_by_messages,
+        top_by_tokens,
+        recently_active,
+        all_users,
+    ) = await asyncio.gather(
+        crud.get_analytics_summary(status_filter=status, discipline_filter=discipline),
+        crud.get_agent_stats(discipline_filter=discipline),
+        crud.get_discipline_breakdown(status_filter=status),
+        crud.get_aggregated_usage_timeline(days, status_filter=status, discipline_filter=discipline),
+        crud.get_top_agents("total_messages", 5, status_filter=status, discipline_filter=discipline),
+        crud.get_top_agents("total_tokens", 5, status_filter=status, discipline_filter=discipline),
+        crud.get_recently_active_agents(10, status_filter=status, discipline_filter=discipline),
+        crud.get_all_users(),
+    )
+
+    # Rating distribution: count agents in each star bucket (1-5)
+    all_agents = await crud.get_all_agents(status_filter=status)
+    filtered_agents = all_agents
+    if discipline:
+        filtered_agents = [a for a in all_agents if a.get("discipline") == discipline]
+    rating_dist = {1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
+    for agent in filtered_agents:
+        r = agent.get("rating")
+        if r is not None:
+            bucket = max(1, min(5, round(r)))
+            rating_dist[bucket] += 1
+
+    total_users = len(all_users)
+    admin_users = sum(1 for u in all_users if u.get("is_admin"))
+    active_users = sum(1 for u in all_users if u.get("is_active"))
+
+    return {
+        "summary": summary,
+        "status_breakdown": status_breakdown,
+        "discipline_breakdown": discipline_breakdown,
+        "usage_timeline": usage_timeline,
+        "top_by_messages": top_by_messages,
+        "top_by_tokens": top_by_tokens,
+        "recently_active": recently_active,
+        "rating_distribution": rating_dist,
+        "total_users": total_users,
+        "admin_users": admin_users,
+        "active_users": active_users,
+        "total_agents": len(filtered_agents),
+    }
+
 @app.get("/api/admin/agents", response_model=List[models.AiAgentResponse])
-async def get_all_agents_admin(current_user: dict = Depends(require_admin)):
+async def get_all_agents_admin(current_user: dict = Depends(require_admin_or_readonly)):
 
     agents = await crud.get_all_agents()
     return [
diff --git a/models.py b/models.py
index b61ea03..4eeb951 100644
--- a/models.py
+++ b/models.py
@@ -53,12 +53,14 @@ class UserResponse(BaseModel):
     full_name: Optional[str] = None
     is_active: bool
     is_admin: bool
+    role: Optional[str] = "user"
     auth_provider: Optional[str] = "local"
 
 class UserUpdate(BaseModel):
     full_name: Optional[str] = None
     is_active: Optional[bool] = None
     is_admin: Optional[bool] = None
+    role: Optional[str] = Field(default=None, pattern="^(user|admin|readonly_admin)$")
 
 class Token(BaseModel):
     access_token: str
@@ -102,6 +104,9 @@ class AiAgentCreate(BaseModel):
     last_edited_by: Optional[str] = None
     discipline: Optional[str] = None
     rating: Optional[float] = Field(default=None, ge=1, le=5)
+    client: Optional[str] = None
+    client_name: Optional[str] = None
+    studio_name: Optional[str] = None
 
 class AiAgentResponse(BaseModel):
     agent_id: str
@@ -130,6 +135,12 @@ class AiAgentResponse(BaseModel):
     discipline: Optional[str] = None
     rating: Optional[float] = None
     rating_count: Optional[int] = None
+    client: Optional[str] = None
+    client_name: Optional[str] = None
+    studio_name: Optional[str] = None
+    verification_status: Optional[str] = None
+    verified_by: Optional[str] = None
+    verified_date: Optional[str] = None
     created_by: str
 
     # Usage tracking fields (new)
@@ -162,6 +173,9 @@ class AgentCollectorCreate(BaseModel):
     metadata: Optional[dict] = None
     url: Optional[str] = None
     discipline: Optional[str] = None
+    client: Optional[str] = None
+    client_name: Optional[str] = None
+    studio_name: Optional[str] = None
 
     # Usage tracking fields (new)
     usage_timeline: Optional[List[UsageTimelineEntry]] = None
diff --git a/notifications.py b/notifications.py
index 3a75c49..75ca028 100644
--- a/notifications.py
+++ b/notifications.py
@@ -1,7 +1,7 @@
 import os
 import requests
 from datetime import datetime, timedelta
-from database import notifications_collection, users_collection
+from database import notifications_collection, users_collection, agents_collection
 
 
 def is_mailgun_configured() -> bool:
@@ -105,3 +105,164 @@ async def check_and_notify_threshold(agent_name: str, total_tokens: int):
         "success": success,
         "sent_at": datetime.utcnow(),
     })
+
+
+def build_client_agent_email(agent_data: dict) -> str:
+    """Build HTML email body for a client agent creation notification."""
+    return f"""
+    
+    
+        
+

Client Agent Created

+
+
+

A new client-facing agent has been created and requires verification.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Agent Name{agent_data.get('agent_name', 'N/A')}
Description{agent_data.get('agent_description', 'N/A')}
Purpose{agent_data.get('agent_purpose', 'N/A')}
ClientYes
Client Name{agent_data.get('client_name', 'N/A')}
Studio Name{agent_data.get('studio_name', 'N/A')}
Tool{agent_data.get('agent_tool', 'N/A')}
Created By{agent_data.get('created_by_email', 'N/A')}
+

+ Please review this agent in AgentHub. +

+
+ + + """ + + +def send_client_agent_notification(agent_data: dict): + """Send email notification when a client-facing agent is created. Non-blocking.""" + if not is_mailgun_configured(): + return + + notify_emails_str = os.getenv("CLIENT_AGENT_NOTIFY_EMAILS", "") + if not notify_emails_str: + return + + to_emails = [e.strip() for e in notify_emails_str.split(",") if e.strip()] + if not to_emails: + return + + try: + subject = "Client Agent Created" + html_body = build_client_agent_email(agent_data) + send_mailgun_email(to_emails, subject, html_body) + except Exception as e: + print(f"Failed to send client agent notification: {e}") + + +def build_daily_digest_email(agents: list) -> str: + """Build HTML email body for the daily agent digest.""" + rows = "" + for i, agent in enumerate(agents, 1): + rows += f""" + + {i}. + + {agent.get('agent_name', 'N/A')}
+ Purpose: {agent.get('agent_purpose', 'N/A')}
+ Description: {agent.get('agent_description', 'N/A')}
+ Created by: {agent.get('created_by_email', 'N/A')} + + """ + + return f""" + + +
+

Agents Created Last 24 Hours

+
+
+

{len(agents)} agent(s) created in the last 24 hours:

+ + {rows} +
+

+ View full details in the AgentHub admin dashboard. +

+
+ + + """ + + +async def send_daily_agent_digest(): + """Send daily digest of agents created in the last 24 hours to all admins.""" + if not is_mailgun_configured(): + print("Daily digest: Mailgun not configured, skipping.") + return + + cutoff = datetime.utcnow() - timedelta(hours=24) + + # Find agents created in last 24 hours + cursor = agents_collection.find( + {"created_at": {"$gte": cutoff}}, + {"agent_name": 1, "agent_purpose": 1, "agent_description": 1, "created_by": 1} + ).sort("created_at", -1) + agents = await cursor.to_list(length=None) + + if not agents: + print("Daily digest: No agents created in last 24 hours, skipping.") + return + + # Resolve creator emails + for agent in agents: + created_by = agent.get("created_by", "") + if created_by == "agent_collector_api": + agent["created_by_email"] = "Agent Collector API" + else: + try: + from bson import ObjectId + user = await users_collection.find_one({"_id": ObjectId(created_by)}, {"email": 1}) + agent["created_by_email"] = user["email"] if user else created_by + except Exception: + agent["created_by_email"] = created_by + + # Get admin emails + admin_cursor = users_collection.find( + {"is_admin": True, "is_active": True}, + {"email": 1}, + ) + admin_emails = [doc["email"] async for doc in admin_cursor] + if not admin_emails: + print("Daily digest: No admin emails found, skipping.") + return + + subject = "Agents Created Last 24 Hours" + html_body = build_daily_digest_email(agents) + + try: + success = send_mailgun_email(admin_emails, subject, html_body) + print(f"Daily digest: Sent to {len(admin_emails)} admins, success={success}") + except Exception as e: + print(f"Daily digest: Failed to send: {e}") diff --git a/requirements.txt b/requirements.txt index e30cbb8..352fbe5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,4 @@ uvicorn==0.35.0 msal==1.26.0 itsdangerous==2.2.0 cryptography==41.0.7 +apscheduler>=3.10.0 diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 247f93d..9dfa1a5 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -25,48 +25,58 @@ -
-
-
-
- -
+
+
+
+

0

Total Users

-
-
-
- -
-
-

0

-

Admin Users

-
-
-
-
-
-
- -
+
+
+

0

Total Agents

-
-
-
- -
+
+
+
-

0

-

Active Users

+

0

+

Total Messages

+
+
+
+
+
+
+
+

0

+

Total Tokens

+
+
+
+
+
+
+
+

0

+

Conversations

+
+
+
+
+
+
+
+

0

+

Agent-User Sessions

@@ -79,7 +89,12 @@
+ +
+ +
+
+
+ Filters: + + + + +
+
+
+ +
+ +
+
+
System Usage Over Time (Last 90 Days)
+
+ + +
+
+
+ +
+
+
Token Breakdown
+
+ + +
+
+
+
+ +
+ +
+
+
Agents by Status
+
+ + +
+
+
+ +
+
+
Agents by Discipline
+
+ + +
+
+
+ +
+
+
Rating Distribution
+
+ + +
+
+
+
+ +
+ +
+
+
Top 5 Agents by Messages
+
+
+ + + + + +
#AgentStatusMessages
No data
+
+
+
+
+ +
+
+
Top 5 Agents by Tokens
+
+
+ + + + + +
#AgentStatusTokens
No data
+
+
+
+
+
+ +
+ +
+
+
Recently Active Agents
+
+
+ + + + + +
AgentStatusDisciplineMessagesTokensLast Used
No recently active agents
+
+
+
+
+
+
+ -
+
Users Management
-
@@ -180,6 +348,45 @@
+ + +
+
+
Agent Verification
+
+ +
+
+
+ + + + + + + + + + + + + + + + + +
Agent NameClient NameStudioCreated ByDate CreatedStatusAction
+
+ Loading... +
+
+
+
+
@@ -222,13 +429,22 @@
-
+
+
+ + +
Read-Only Admin can view the admin dashboard but cannot make changes.
+
-
- - -
The platform or environment where this agent operates
-
-
- @@ -65,24 +52,72 @@ -
Maximum 200 characters
+
+
+ + +
Is this agent for a client?
+
+ +
+ +
+ + +
+ +
+ + +
The platform or environment where this agent operates
+
+
-
@@ -301,11 +336,26 @@ // Simple form validation and duplicate submission prevention let formSubmitted = false; +function toggleClientName() { + const clientSelect = document.getElementById('agentClient'); + const clientNameSection = document.getElementById('clientNameSection'); + const clientNameInput = document.getElementById('agentClientName'); + + if (clientSelect.value === 'yes') { + clientNameSection.style.display = 'block'; + clientNameInput.required = true; + } else { + clientNameSection.style.display = 'none'; + clientNameInput.required = false; + clientNameInput.value = ''; + } +} + function toggleRiskFactor() { const qualityAuditStatus = document.getElementById('qualityAuditStatus'); const riskFactorSection = document.getElementById('riskFactorSection'); const riskFactor = document.getElementById('riskFactor'); - + if (qualityAuditStatus.checked) { riskFactorSection.style.display = 'block'; riskFactor.required = true; @@ -319,21 +369,37 @@ function toggleRiskFactor() { document.getElementById('agentForm').addEventListener('submit', function(e) { const agentName = document.getElementById('agentName').value.trim(); const agentTool = document.getElementById('agentTool').value.trim(); + const clientSelect = document.getElementById('agentClient'); const qualityAuditStatus = document.getElementById('qualityAuditStatus'); const riskFactor = document.getElementById('riskFactor'); - + if (!agentName) { e.preventDefault(); alert('Agent name is required!'); return false; } - + if (!agentTool) { e.preventDefault(); alert('Agent tool is required!'); return false; } + if (!clientSelect.value) { + e.preventDefault(); + alert('Client selection is required!'); + return false; + } + + if (clientSelect.value === 'yes') { + const clientName = document.getElementById('agentClientName').value.trim(); + if (!clientName) { + e.preventDefault(); + alert('Client Name is required when Client is set to Yes!'); + return false; + } + } + const discipline = document.getElementById('agentDiscipline').value; if (!discipline) { e.preventDefault(); @@ -347,22 +413,22 @@ document.getElementById('agentForm').addEventListener('submit', function(e) { alert('Risk Factor is required when Quality Audit is checked!'); return false; } - + // Prevent duplicate submissions if (formSubmitted) { e.preventDefault(); return false; } - + formSubmitted = true; - + // Disable the submit button to prevent multiple clicks const submitBtn = e.target.querySelector('button[type="submit"]'); if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = 'Registering...'; } - + return true; });