Add read-only admin role, client verification workflow, and email notifications
- Three-tier role system: user, admin, readonly_admin with dashboard gating - Client field (Yes/No) on registration with conditional Client Name and Studio Name - Auto-tag client agents as "needs_verification" with Verification tab on admin dashboard - Client agent email notification via Mailgun to configured recipients - Daily agent digest email scheduled via APScheduler (configurable hour) - Manual digest trigger endpoint: POST /api/admin/digest/send - Role dropdown replaces is_admin checkbox in user edit modal - Registration form reordered: Name, Description, Purpose, Client, Client Name, Studio, Tool - Stat card CSS fix for text truncation on admin dashboard - Updated CLAUDE.md documentation and PLAN file Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3bc757b99c
commit
6c231cb094
10 changed files with 2008 additions and 136 deletions
84
CLAUDE.md
84
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 <noreply@{MAILGUN_DOMAIN}>`)
|
||||
- `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)
|
||||
- **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)
|
||||
588
PLAN-prompt-audit.md
Normal file
588
PLAN-prompt-audit.md
Normal file
|
|
@ -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
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="audit-tab" data-bs-toggle="tab" href="#audit">
|
||||
<i class="fas fa-shield-alt me-2"></i>Prompt Audit
|
||||
<span class="badge bg-danger ms-1" id="auditFlaggedBadge" style="display:none;">0</span>
|
||||
</a>
|
||||
</li>
|
||||
```
|
||||
|
||||
**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 `<pre>` 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 <noreply@mg.oliver.solutions>
|
||||
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.
|
||||
140
crud.py
140
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)}
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
246
main.py
246
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 [
|
||||
|
|
|
|||
14
models.py
14
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
|
||||
|
|
|
|||
163
notifications.py
163
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"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #6f42c1, #5a32a3); padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="color: white; margin: 0;">Client Agent Created</h2>
|
||||
</div>
|
||||
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p>A new client-facing agent has been created and requires verification.</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Agent Name</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_name', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Description</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_description', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Purpose</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_purpose', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Client</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Client Name</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('client_name', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Studio Name</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('studio_name', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Tool</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('agent_tool', 'N/A')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; font-weight: bold; border-bottom: 1px solid #eee;">Created By</td>
|
||||
<td style="padding: 8px; border-bottom: 1px solid #eee;">{agent_data.get('created_by_email', 'N/A')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
Please review this agent in AgentHub.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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"""
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 10px; vertical-align: top; font-weight: bold; color: #333;">{i}.</td>
|
||||
<td style="padding: 10px;">
|
||||
<strong>{agent.get('agent_name', 'N/A')}</strong><br>
|
||||
<span style="color: #555;">Purpose: {agent.get('agent_purpose', 'N/A')}</span><br>
|
||||
<span style="color: #555;">Description: {agent.get('agent_description', 'N/A')}</span><br>
|
||||
<span style="color: #888; font-size: 0.9em;">Created by: {agent.get('created_by_email', 'N/A')}</span>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #2d6a4f, #40916c); padding: 20px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="color: white; margin: 0;">Agents Created Last 24 Hours</h2>
|
||||
</div>
|
||||
<div style="padding: 20px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p>{len(agents)} agent(s) created in the last 24 hours:</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||
{rows}
|
||||
</table>
|
||||
<p style="color: #666; font-size: 0.9em;">
|
||||
View full details in the AgentHub admin dashboard.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -38,3 +38,4 @@ uvicorn==0.35.0
|
|||
msal==1.26.0
|
||||
itsdangerous==2.2.0
|
||||
cryptography==41.0.7
|
||||
apscheduler>=3.10.0
|
||||
|
|
|
|||
|
|
@ -25,48 +25,58 @@
|
|||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div class="row mb-4 align-items-stretch">
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="totalUsers">0</h3>
|
||||
<p>Total Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card admin-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 id="adminUsers">0</h3>
|
||||
<p>Admin Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card agent-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-robot"></i>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card agent-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-robot"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="totalAgents">0</h3>
|
||||
<p>Total Agents</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="stat-card active-card">
|
||||
<div class="stat-icon">
|
||||
<i class="fas fa-user-check"></i>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-envelope"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="activeUsers">0</h3>
|
||||
<p>Active Users</p>
|
||||
<h3 id="totalMessages">0</h3>
|
||||
<p>Total Messages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-coins"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="totalTokens">0</h3>
|
||||
<p>Total Tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-comments"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="totalConversations">0</h3>
|
||||
<p>Conversations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
<div class="stat-card w-100">
|
||||
<div class="stat-icon"><i class="fas fa-user-friends"></i></div>
|
||||
<div class="stat-info">
|
||||
<h3 id="totalUniqueUsers">0</h3>
|
||||
<p>Agent-User Sessions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -79,7 +89,12 @@
|
|||
<div class="card-header bg-white">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="adminTabs">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="users-tab" data-bs-toggle="tab" href="#users">
|
||||
<a class="nav-link active" id="analytics-tab" data-bs-toggle="tab" href="#analytics">
|
||||
<i class="fas fa-chart-line me-2"></i>Analytics
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="users-tab" data-bs-toggle="tab" href="#users">
|
||||
<i class="fas fa-users me-2"></i>Users Management
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -88,16 +103,169 @@
|
|||
<i class="fas fa-robot me-2"></i>Agents Management
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="verification-tab" data-bs-toggle="tab" href="#verification">
|
||||
<i class="fas fa-check-circle me-2"></i>Verification
|
||||
<span class="badge bg-warning ms-1" id="verificationPendingBadge" style="display:none;">0</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content" id="adminTabsContent">
|
||||
<!-- Analytics Tab -->
|
||||
<div class="tab-pane fade show active" id="analytics">
|
||||
<!-- Analytics Filter Bar -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 p-3 bg-light rounded">
|
||||
<span class="fw-semibold text-muted me-1"><i class="fas fa-filter me-1"></i>Filters:</span>
|
||||
<select id="analyticsStatusFilter" class="form-select form-select-sm" style="width:auto;min-width:150px;">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Development">Development</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
<option value="Deprecated">Deprecated</option>
|
||||
</select>
|
||||
<select id="analyticsDisciplineFilter" class="form-select form-select-sm" style="width:auto;min-width:200px;">
|
||||
<option value="">All Disciplines</option>
|
||||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight including delivery</option>
|
||||
<option value="Optimization">Optimization</option>
|
||||
<option value="Back Office including operations">Back Office including operations</option>
|
||||
<option value="Pencil Agents">Pencil Agents</option>
|
||||
</select>
|
||||
<select id="analyticsTimeRange" class="form-select form-select-sm" style="width:auto;min-width:140px;">
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="60">Last 60 Days</option>
|
||||
<option value="90" selected>Last 90 Days</option>
|
||||
<option value="180">Last 180 Days</option>
|
||||
</select>
|
||||
<button id="analyticsApplyBtn" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-check me-1"></i>Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Usage Over Time Chart -->
|
||||
<div class="col-lg-8 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-chart-area me-2"></i>System Usage Over Time <span id="timeRangeLabel">(Last 90 Days)</span></h6></div>
|
||||
<div class="card-body">
|
||||
<div id="usageTimelineEmpty" class="text-center text-muted py-5" style="display:none;">No usage timeline data available</div>
|
||||
<canvas id="usageTimelineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Token Breakdown Doughnut -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-coins me-2"></i>Token Breakdown</h6></div>
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<div id="tokenBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No token data available</div>
|
||||
<canvas id="tokenBreakdownChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Status Breakdown Doughnut -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-signal me-2"></i>Agents by Status</h6></div>
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<div id="statusBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No agent data available</div>
|
||||
<canvas id="statusBreakdownChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Discipline Breakdown Doughnut -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-layer-group me-2"></i>Agents by Discipline</h6></div>
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<div id="disciplineBreakdownEmpty" class="text-center text-muted py-5" style="display:none;">No discipline data available</div>
|
||||
<canvas id="disciplineBreakdownChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rating Distribution Bar Chart -->
|
||||
<div class="col-lg-4 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-star me-2"></i>Rating Distribution</h6></div>
|
||||
<div class="card-body d-flex align-items-center justify-content-center">
|
||||
<div id="ratingDistEmpty" class="text-center text-muted py-5" style="display:none;">No rating data available</div>
|
||||
<canvas id="ratingDistChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Top 5 Agents by Messages -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-trophy me-2"></i>Top 5 Agents by Messages</h6></div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 analytics-table">
|
||||
<thead><tr><th>#</th><th>Agent</th><th>Status</th><th>Messages</th></tr></thead>
|
||||
<tbody id="topMessagesTbody">
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Top 5 Agents by Tokens -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-trophy me-2"></i>Top 5 Agents by Tokens</h6></div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 analytics-table">
|
||||
<thead><tr><th>#</th><th>Agent</th><th>Status</th><th>Tokens</th></tr></thead>
|
||||
<tbody id="topTokensTbody">
|
||||
<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<!-- Recently Active Agents -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white"><h6 class="mb-0"><i class="fas fa-clock me-2"></i>Recently Active Agents</h6></div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 analytics-table">
|
||||
<thead><tr><th>Agent</th><th>Status</th><th>Discipline</th><th>Messages</th><th>Tokens</th><th>Last Used</th></tr></thead>
|
||||
<tbody id="recentlyActiveTbody">
|
||||
<tr><td colspan="6" class="text-center text-muted py-3">No recently active agents</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Management Tab -->
|
||||
<div class="tab-pane fade show active" id="users">
|
||||
<div class="tab-pane fade" id="users">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">Users Management</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" onclick="showCreateUserModal()">
|
||||
<button class="btn btn-success admin-write-action" onclick="showCreateUserModal()">
|
||||
<i class="fas fa-user-plus me-2"></i>Create User
|
||||
</button>
|
||||
<div class="input-group" style="width: 300px;">
|
||||
|
|
@ -180,6 +348,45 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Tab -->
|
||||
<div class="tab-pane fade" id="verification">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="fas fa-check-circle me-2"></i>Agent Verification</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<select id="verificationFilter" class="form-select form-select-sm" style="width:auto;" onchange="filterVerificationAgents()">
|
||||
<option value="all">All</option>
|
||||
<option value="needs_verification" selected>Needs Verification</option>
|
||||
<option value="verified">Verified</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent Name</th>
|
||||
<th>Client Name</th>
|
||||
<th>Studio</th>
|
||||
<th>Created By</th>
|
||||
<th>Date Created</th>
|
||||
<th>Status</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="verificationTableBody">
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -222,13 +429,22 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<div class="form-check" style="display:none;">
|
||||
<input class="form-check-input" type="checkbox" id="editUserIsAdmin">
|
||||
<label class="form-check-label" for="editUserIsAdmin">
|
||||
User has admin privileges
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editUserRole" class="form-label">Role</label>
|
||||
<select class="form-select" id="editUserRole">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="readonly_admin">Read-Only Admin</option>
|
||||
</select>
|
||||
<div class="form-text">Read-Only Admin can view the admin dashboard but cannot make changes.</div>
|
||||
</div>
|
||||
<!-- Password Reset Section (only for local users) -->
|
||||
<div class="mb-3" id="passwordResetSection" style="display: none;">
|
||||
<hr>
|
||||
|
|
@ -544,11 +760,11 @@
|
|||
.stat-card {
|
||||
background: linear-gradient(135deg, #f3ae3e 0%, #f3ae3e 100%);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
min-height: 90px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
|
|
@ -565,21 +781,31 @@
|
|||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-right: 1rem;
|
||||
font-size: 2rem;
|
||||
margin-right: 0.75rem;
|
||||
opacity: 0.8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-info h3 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: normal;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-avatar-sm {
|
||||
|
|
@ -719,17 +945,48 @@
|
|||
color: white !important;
|
||||
}
|
||||
|
||||
.analytics-table th {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.analytics-table td {
|
||||
padding: 0.6rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.rank-badge {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.75rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #f3ae3e 0%, #e8983e 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.stat-icon {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
|
||||
.stat-info h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.stat-info p {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
|
@ -739,14 +996,24 @@
|
|||
<script>
|
||||
let allUsers = [];
|
||||
let allAgents = [];
|
||||
let verificationAgents = [];
|
||||
const isReadonlyAdmin = {% if current_user and current_user.get('role') == 'readonly_admin' %}true{% else %}false{% endif %};
|
||||
const isFullAdmin = {% if current_user and current_user.get('is_admin') %}true{% else %}false{% endif %};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAdminData();
|
||||
loadAnalytics();
|
||||
loadVerificationData();
|
||||
setupEventListeners();
|
||||
|
||||
// Hide write-action buttons for readonly admins
|
||||
if (isReadonlyAdmin && !isFullAdmin) {
|
||||
document.querySelectorAll('.admin-write-action').forEach(el => el.style.display = 'none');
|
||||
}
|
||||
});
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadAdminData);
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => { loadAdminData(); loadAnalytics(); loadVerificationData(); });
|
||||
document.getElementById('userSearch').addEventListener('input', filterUsers);
|
||||
document.getElementById('agentSearch').addEventListener('input', filterAgents);
|
||||
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
|
||||
|
|
@ -755,6 +1022,7 @@ function setupEventListeners() {
|
|||
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
|
||||
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
|
||||
document.getElementById('resetPasswordForm').addEventListener('submit', handleResetPasswordSubmit);
|
||||
document.getElementById('analyticsApplyBtn').addEventListener('click', loadAnalytics);
|
||||
}
|
||||
|
||||
async function loadAdminData() {
|
||||
|
|
@ -797,10 +1065,25 @@ async function loadAdminData() {
|
|||
}
|
||||
|
||||
function updateStatistics() {
|
||||
document.getElementById('totalUsers').textContent = allUsers.length;
|
||||
document.getElementById('adminUsers').textContent = allUsers.filter(u => u.is_admin).length;
|
||||
document.getElementById('totalAgents').textContent = allAgents.length;
|
||||
document.getElementById('activeUsers').textContent = allUsers.filter(u => u.is_active).length;
|
||||
// These two are still fed from the users/agents lists for the management tabs
|
||||
// The stat cards are now primarily updated by loadAnalytics()
|
||||
// Keep this as a fallback for basic counts from the existing load
|
||||
}
|
||||
|
||||
function updateAnalyticsStats(data) {
|
||||
document.getElementById('totalUsers').textContent = formatNumber(data.total_users || 0);
|
||||
document.getElementById('totalAgents').textContent = formatNumber(data.total_agents || 0);
|
||||
document.getElementById('totalMessages').textContent = formatNumber(data.summary.total_messages || 0);
|
||||
document.getElementById('totalTokens').textContent = formatNumber(data.summary.total_tokens || 0);
|
||||
document.getElementById('totalConversations').textContent = formatNumber(data.summary.conversation_count || 0);
|
||||
document.getElementById('totalUniqueUsers').textContent = formatNumber(data.summary.unique_users || 0);
|
||||
}
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n >= 1000000000) return (n / 1000000000).toFixed(1) + 'B';
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
||||
return n.toString();
|
||||
}
|
||||
|
||||
function displayUsers(users) {
|
||||
|
|
@ -834,9 +1117,12 @@ function displayUsers(users) {
|
|||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${user.is_admin ? 'bg-danger' : 'bg-primary'}">
|
||||
${user.is_admin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
${(() => {
|
||||
const role = user.role || (user.is_admin ? 'admin' : 'user');
|
||||
if (role === 'admin') return '<span class="badge bg-danger">Admin</span>';
|
||||
if (role === 'readonly_admin') return '<span class="badge bg-warning text-dark">Read-Only Admin</span>';
|
||||
return '<span class="badge bg-primary">User</span>';
|
||||
})()}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
|
||||
|
|
@ -850,10 +1136,10 @@ function displayUsers(users) {
|
|||
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewUserDetails('${user.email}')" title="View">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm me-1" onclick="editUser('${user.email}')" title="Edit">
|
||||
<button class="btn btn-outline-warning btn-sm me-1 admin-write-action" onclick="editUser('${user.email}')" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="toggleUserStatus('${user.email}')" title="${user.is_active ? 'Deactivate' : 'Activate'}">
|
||||
<button class="btn btn-outline-secondary btn-sm admin-write-action" onclick="toggleUserStatus('${user.email}')" title="${user.is_active ? 'Deactivate' : 'Activate'}">
|
||||
<i class="fas fa-${user.is_active ? 'ban' : 'check'}"></i>
|
||||
</button>
|
||||
</td>
|
||||
|
|
@ -881,7 +1167,10 @@ function displayAgents(agents) {
|
|||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-medium">${agent.agent_name}</div>
|
||||
<div class="fw-medium">${agent.agent_name}
|
||||
${agent.verification_status === 'needs_verification' ? ' <span class="badge bg-warning text-dark" style="font-size:0.65em;">Needs Verification</span>' : ''}
|
||||
${agent.verification_status === 'verified' ? ' <span class="badge bg-success" style="font-size:0.65em;">Verified</span>' : ''}
|
||||
</div>
|
||||
<small class="text-muted">${agent.agent_description || 'No description'}</small>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -903,10 +1192,10 @@ function displayAgents(agents) {
|
|||
<button class="btn btn-outline-primary btn-sm me-1" onclick="viewAgentDetails('${agent.agent_id}')">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-warning btn-sm me-1" onclick="editAgentAdmin('${agent.agent_id}')">
|
||||
<button class="btn btn-outline-warning btn-sm me-1 admin-write-action" onclick="editAgentAdmin('${agent.agent_id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteAgentAdmin('${agent.agent_id}')">
|
||||
<button class="btn btn-outline-danger btn-sm admin-write-action" onclick="deleteAgentAdmin('${agent.agent_id}')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
|
|
@ -997,12 +1286,16 @@ function editUser(email) {
|
|||
const authProvider = user.auth_provider || 'local';
|
||||
const isLocalAuth = authProvider === 'local';
|
||||
|
||||
// Determine role from user data
|
||||
const userRole = user.role || (user.is_admin ? 'admin' : 'user');
|
||||
|
||||
// Populate the edit form
|
||||
document.getElementById('editUserId').value = user.email;
|
||||
document.getElementById('editUserEmail').value = user.email;
|
||||
document.getElementById('editUserFullName').value = user.full_name || '';
|
||||
document.getElementById('editUserIsActive').checked = user.is_active;
|
||||
document.getElementById('editUserIsAdmin').checked = user.is_admin;
|
||||
document.getElementById('editUserRole').value = userRole;
|
||||
document.getElementById('editUserAuthProvider').value = authProvider;
|
||||
|
||||
// Update auth display
|
||||
|
|
@ -1043,7 +1336,8 @@ async function toggleUserStatus(email) {
|
|||
body: JSON.stringify({
|
||||
full_name: user.full_name,
|
||||
is_active: newStatus,
|
||||
is_admin: user.is_admin
|
||||
is_admin: user.is_admin,
|
||||
role: user.role || (user.is_admin ? 'admin' : 'user')
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -1124,12 +1418,13 @@ function showError(message) {
|
|||
|
||||
async function handleEditUserSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
const email = document.getElementById('editUserId').value;
|
||||
const fullName = document.getElementById('editUserFullName').value;
|
||||
const isActive = document.getElementById('editUserIsActive').checked;
|
||||
const isAdmin = document.getElementById('editUserIsAdmin').checked;
|
||||
|
||||
const role = document.getElementById('editUserRole').value;
|
||||
const isAdmin = (role === 'admin');
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ base_path }}/api/admin/users/${encodeURIComponent(email)}`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -1140,7 +1435,8 @@ async function handleEditUserSubmit(e) {
|
|||
body: JSON.stringify({
|
||||
full_name: fullName,
|
||||
is_active: isActive,
|
||||
is_admin: isAdmin
|
||||
is_admin: isAdmin,
|
||||
role: role
|
||||
})
|
||||
});
|
||||
|
||||
|
|
@ -1431,6 +1727,363 @@ async function handleResetPasswordSubmit(e) {
|
|||
}
|
||||
}
|
||||
|
||||
// ===================== Analytics =====================
|
||||
let analyticsCharts = {};
|
||||
|
||||
function destroyChart(key) {
|
||||
if (analyticsCharts[key]) {
|
||||
analyticsCharts[key].destroy();
|
||||
analyticsCharts[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
'Active': '#28a745',
|
||||
'Development': '#ffc107',
|
||||
'Inactive': '#dc3545',
|
||||
'Deprecated': '#6c757d'
|
||||
};
|
||||
|
||||
const DISCIPLINE_COLORS = [
|
||||
'#f3ae3e', '#4e79a7', '#59a14f', '#e15759', '#76b7b2', '#edc949', '#af7aa1'
|
||||
];
|
||||
|
||||
async function loadAnalytics() {
|
||||
try {
|
||||
const status = document.getElementById('analyticsStatusFilter').value;
|
||||
const discipline = document.getElementById('analyticsDisciplineFilter').value;
|
||||
const days = document.getElementById('analyticsTimeRange').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
if (discipline) params.set('discipline', discipline);
|
||||
params.set('days', days);
|
||||
|
||||
const qs = params.toString();
|
||||
const url = '{{ base_path }}/api/admin/analytics' + (qs ? '?' + qs : '');
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
|
||||
// Update time range label in chart header
|
||||
const label = document.getElementById('timeRangeLabel');
|
||||
if (label) label.textContent = '(Last ' + days + ' Days)';
|
||||
|
||||
updateAnalyticsStats(data);
|
||||
renderUsageTimeline(data.usage_timeline);
|
||||
renderTokenBreakdown(data.summary.prompt_tokens, data.summary.completion_tokens);
|
||||
renderStatusBreakdown(data.status_breakdown);
|
||||
renderDisciplineBreakdown(data.discipline_breakdown);
|
||||
renderRatingDistribution(data.rating_distribution);
|
||||
renderTopTable('topMessagesTbody', data.top_by_messages, 'total_messages');
|
||||
renderTopTable('topTokensTbody', data.top_by_tokens, 'total_tokens');
|
||||
renderRecentlyActive(data.recently_active);
|
||||
} catch (err) {
|
||||
console.error('Failed to load analytics:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsageTimeline(timeline) {
|
||||
destroyChart('usageTimeline');
|
||||
const canvas = document.getElementById('usageTimelineChart');
|
||||
const emptyEl = document.getElementById('usageTimelineEmpty');
|
||||
|
||||
if (!timeline || timeline.length === 0) {
|
||||
canvas.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
const labels = timeline.map(t => t.date);
|
||||
const messages = timeline.map(t => t.message_count);
|
||||
const tokens = timeline.map(t => t.token_count);
|
||||
const hasTokens = tokens.some(v => v > 0);
|
||||
|
||||
const datasets = [{
|
||||
label: 'Messages',
|
||||
data: messages,
|
||||
borderColor: '#4e79a7',
|
||||
backgroundColor: 'rgba(78,121,167,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y'
|
||||
}];
|
||||
|
||||
const scales = {
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Messages' }, position: 'left' },
|
||||
x: { ticks: { maxTicksLimit: 12 } }
|
||||
};
|
||||
|
||||
if (hasTokens) {
|
||||
datasets.push({
|
||||
label: 'Tokens',
|
||||
data: tokens,
|
||||
borderColor: '#f3ae3e',
|
||||
backgroundColor: 'rgba(243,174,62,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
});
|
||||
scales.y1 = { beginAtZero: true, title: { display: true, text: 'Tokens' }, position: 'right', grid: { drawOnChartArea: false } };
|
||||
}
|
||||
|
||||
analyticsCharts.usageTimeline = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTokenBreakdown(prompt, completion) {
|
||||
destroyChart('tokenBreakdown');
|
||||
const canvas = document.getElementById('tokenBreakdownChart');
|
||||
const emptyEl = document.getElementById('tokenBreakdownEmpty');
|
||||
|
||||
if (!prompt && !completion) {
|
||||
canvas.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
analyticsCharts.tokenBreakdown = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Prompt (Input)', 'Completion (Output)'],
|
||||
datasets: [{ data: [prompt, completion], backgroundColor: ['#4e79a7', '#f3ae3e'] }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatusBreakdown(statusData) {
|
||||
destroyChart('statusBreakdown');
|
||||
const canvas = document.getElementById('statusBreakdownChart');
|
||||
const emptyEl = document.getElementById('statusBreakdownEmpty');
|
||||
|
||||
const entries = Object.entries(statusData || {}).filter(([, v]) => v > 0);
|
||||
if (entries.length === 0) {
|
||||
canvas.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
analyticsCharts.statusBreakdown = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: entries.map(([k]) => k || 'Unknown'),
|
||||
datasets: [{
|
||||
data: entries.map(([, v]) => v),
|
||||
backgroundColor: entries.map(([k]) => STATUS_COLORS[k] || '#adb5bd')
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderDisciplineBreakdown(disciplineData) {
|
||||
destroyChart('disciplineBreakdown');
|
||||
const canvas = document.getElementById('disciplineBreakdownChart');
|
||||
const emptyEl = document.getElementById('disciplineBreakdownEmpty');
|
||||
|
||||
const entries = Object.entries(disciplineData || {}).filter(([, v]) => v > 0);
|
||||
if (entries.length === 0) {
|
||||
canvas.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
analyticsCharts.disciplineBreakdown = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: entries.map(([k]) => k),
|
||||
datasets: [{
|
||||
data: entries.map(([, v]) => v),
|
||||
backgroundColor: entries.map((_, i) => DISCIPLINE_COLORS[i % DISCIPLINE_COLORS.length])
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRatingDistribution(ratingData) {
|
||||
destroyChart('ratingDist');
|
||||
const canvas = document.getElementById('ratingDistChart');
|
||||
const emptyEl = document.getElementById('ratingDistEmpty');
|
||||
|
||||
const values = [1, 2, 3, 4, 5].map(k => (ratingData || {})[k] || 0);
|
||||
if (values.every(v => v === 0)) {
|
||||
canvas.style.display = 'none';
|
||||
emptyEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
canvas.style.display = 'block';
|
||||
emptyEl.style.display = 'none';
|
||||
|
||||
analyticsCharts.ratingDist = new Chart(canvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['1 Star', '2 Stars', '3 Stars', '4 Stars', '5 Stars'],
|
||||
datasets: [{
|
||||
label: 'Agents',
|
||||
data: values,
|
||||
backgroundColor: '#f3ae3e',
|
||||
borderRadius: 6
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopTable(tbodyId, agents, field) {
|
||||
const tbody = document.getElementById(tbodyId);
|
||||
if (!agents || agents.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">No data</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = agents.map((a, i) => `
|
||||
<tr>
|
||||
<td><span class="rank-badge">${i + 1}</span></td>
|
||||
<td class="fw-medium">${a.agent_name}</td>
|
||||
<td><span class="badge status-${a.agent_status || 'Development'}">${a.agent_status || 'Development'}</span></td>
|
||||
<td class="fw-bold">${formatNumber(a[field] || 0)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderRecentlyActive(agents) {
|
||||
const tbody = document.getElementById('recentlyActiveTbody');
|
||||
if (!agents || agents.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-3">No recently active agents</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = agents.map(a => `
|
||||
<tr>
|
||||
<td class="fw-medium">${a.agent_name}</td>
|
||||
<td><span class="badge status-${a.agent_status || 'Development'}">${a.agent_status || 'Development'}</span></td>
|
||||
<td>${a.discipline || '<span class="text-muted">-</span>'}</td>
|
||||
<td>${formatNumber(a.total_messages || 0)}</td>
|
||||
<td>${formatNumber(a.total_tokens || 0)}</td>
|
||||
<td>${formatDate(a.last_used)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
|
||||
// ===== Verification Tab Functions =====
|
||||
|
||||
async function loadVerificationData() {
|
||||
try {
|
||||
const response = await fetch('{{ base_path }}/api/admin/agents/pending-verification', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) return;
|
||||
verificationAgents = await response.json();
|
||||
|
||||
// Update badge count
|
||||
const pending = verificationAgents.filter(a => a.verification_status === 'needs_verification');
|
||||
const badge = document.getElementById('verificationPendingBadge');
|
||||
if (pending.length > 0) {
|
||||
badge.textContent = pending.length;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
|
||||
filterVerificationAgents();
|
||||
} catch (error) {
|
||||
console.error('Error loading verification data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function filterVerificationAgents() {
|
||||
const filter = document.getElementById('verificationFilter').value;
|
||||
let filtered = verificationAgents;
|
||||
if (filter !== 'all') {
|
||||
filtered = verificationAgents.filter(a => a.verification_status === filter);
|
||||
}
|
||||
displayVerificationAgents(filtered);
|
||||
}
|
||||
|
||||
function displayVerificationAgents(agents) {
|
||||
const tbody = document.getElementById('verificationTableBody');
|
||||
if (!agents.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-4">No agents to display</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = agents.map(agent => {
|
||||
const statusBadge = agent.verification_status === 'verified'
|
||||
? '<span class="badge bg-success">Verified</span>'
|
||||
: '<span class="badge bg-warning text-dark">Needs Verification</span>';
|
||||
|
||||
const actionBtn = agent.verification_status === 'needs_verification' && !isReadonlyAdmin
|
||||
? `<button class="btn btn-success btn-sm admin-write-action" onclick="approveAgent('${agent.agent_id}')">
|
||||
<i class="fas fa-check me-1"></i>Approve
|
||||
</button>`
|
||||
: (agent.verified_by ? `<small class="text-muted">by ${agent.verified_by}<br>${formatDate(agent.verified_date)}</small>` : '');
|
||||
|
||||
return `<tr>
|
||||
<td class="fw-medium">${agent.agent_name || 'N/A'}</td>
|
||||
<td>${agent.client_name || 'N/A'}</td>
|
||||
<td>${agent.studio_name || 'N/A'}</td>
|
||||
<td>${agent.created_by || 'N/A'}</td>
|
||||
<td>${formatDate(agent.created_at)}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${actionBtn}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function approveAgent(agentId) {
|
||||
if (!confirm('Are you sure you want to verify this agent?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ base_path }}/api/admin/agents/${agentId}/verify`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showSuccess('Agent verified successfully');
|
||||
await loadVerificationData();
|
||||
await loadAdminData();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showError(error.detail || 'Failed to verify agent');
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to verify agent');
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -27,34 +27,21 @@
|
|||
<label for="agentName" class="form-label">
|
||||
<i class="fas fa-tag me-2"></i>Agent Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="agent_name"
|
||||
class="form-control form-control-lg"
|
||||
id="agentName"
|
||||
<input type="text"
|
||||
name="agent_name"
|
||||
class="form-control form-control-lg"
|
||||
id="agentName"
|
||||
placeholder="Enter agent name"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="agentTool" class="form-label">
|
||||
<i class="fas fa-tools me-2"></i>Tool *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="agent_tool"
|
||||
class="form-control form-control-lg"
|
||||
id="agentTool"
|
||||
placeholder="e.g., chat-sandbox (LibreChat), Copilot, Custom"
|
||||
required>
|
||||
<div class="form-text">The platform or environment where this agent operates</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="agentDescription" class="form-label">
|
||||
<i class="fas fa-align-left me-2"></i>Description
|
||||
</label>
|
||||
<textarea name="agent_description"
|
||||
class="form-control"
|
||||
id="agentDescription"
|
||||
<textarea name="agent_description"
|
||||
class="form-control"
|
||||
id="agentDescription"
|
||||
rows="3"
|
||||
maxlength="300"
|
||||
placeholder="Describe what this agent does"></textarea>
|
||||
|
|
@ -65,24 +52,72 @@
|
|||
<label for="agentPurpose" class="form-label">
|
||||
<i class="fas fa-bullseye me-2"></i>Purpose
|
||||
</label>
|
||||
<input type="text"
|
||||
name="agent_purpose"
|
||||
class="form-control"
|
||||
id="agentPurpose"
|
||||
<input type="text"
|
||||
name="agent_purpose"
|
||||
class="form-control"
|
||||
id="agentPurpose"
|
||||
maxlength="200"
|
||||
placeholder="What is the main purpose of this agent?">
|
||||
<div class="form-text">Maximum 200 characters</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<label for="agentClient" class="form-label">
|
||||
<i class="fas fa-handshake me-2"></i>Client *
|
||||
</label>
|
||||
<select name="client" class="form-select" id="agentClient" required onchange="toggleClientName()">
|
||||
<option value="">Select</option>
|
||||
<option value="yes">Yes</option>
|
||||
<option value="no">No</option>
|
||||
</select>
|
||||
<div class="form-text">Is this agent for a client?</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4" id="clientNameSection" style="display: none;">
|
||||
<label for="agentClientName" class="form-label">
|
||||
<i class="fas fa-building me-2"></i>Client Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="client_name"
|
||||
class="form-control"
|
||||
id="agentClientName"
|
||||
placeholder="Enter client name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="agentStudioName" class="form-label">
|
||||
<i class="fas fa-film me-2"></i>Studio Name
|
||||
</label>
|
||||
<input type="text"
|
||||
name="studio_name"
|
||||
class="form-control"
|
||||
id="agentStudioName"
|
||||
placeholder="Enter studio name (optional)">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="agentTool" class="form-label">
|
||||
<i class="fas fa-tools me-2"></i>Tool *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="agent_tool"
|
||||
class="form-control form-control-lg"
|
||||
id="agentTool"
|
||||
placeholder="e.g., chat-sandbox (LibreChat), Copilot, Custom"
|
||||
required>
|
||||
<div class="form-text">The platform or environment where this agent operates</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<label for="agentVersion" class="form-label">
|
||||
<i class="fas fa-code-branch me-2"></i>Version
|
||||
</label>
|
||||
<input type="text"
|
||||
name="agent_version"
|
||||
class="form-control"
|
||||
id="agentVersion"
|
||||
<input type="text"
|
||||
name="agent_version"
|
||||
class="form-control"
|
||||
id="agentVersion"
|
||||
placeholder="e.g., 1.0.0">
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
|
|
@ -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 = '<i class="fas fa-spinner fa-spin me-2"></i>Registering...';
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue