Add filtered XLSX export, fix completion reminders, consolidate admin UI

Export & filtering
- Replace /api/admin/agents/export/csv with /api/admin/agents/export/xlsx
  (openpyxl). Multi-line system prompts stay inside one cell instead of
  fragmenting into thousands of rows when opened in Excel.
- Accept filter query params on export: status, discipline, audit,
  business_entity, agent_classification, autonomy_level, risks_only, search.
- Move Export/Import CSV/Delete by CSV buttons into the Agents Management
  tab, drop the duplicate links from the top nav, and rebuild the cramped
  filter row as a wrappable two-row layout.
- Add a Discipline dropdown to the Agents Management filter row to match
  the Prompt Audit tab.

Completion-reminder emails (fix for the broken Complete-button links)
- Add h:Reply-To header to every Mailgun send so users can reply to a real
  mailbox instead of noreply@. Default Nick.Viljoen@oliver.agency, overridable
  via NOTIFICATION_REPLY_TO env var.
- send_completion_reminders now skips with an error log when
  AGENTHUB_PUBLIC_URL is unset instead of mailing relative links email
  clients can't follow.

UI polish
- Restrict the 5-second alert auto-hide to .alert-dismissible so the
  load-bearing 'unresolved owner' banner stays visible until acted on.
- Move the orange brand gradient to a fixed body::before pseudo-element so
  it can't be covered by the white content card or scrolled out of view.
- Lock the navbar to viewport top (position: fixed) and reserve body
  padding-top so content doesn't sit beneath it.

Audit polish (carried over from previous WIP)
- Add batch-state tracking to audit_analyzer for in-flight progress visibility.
- Update PLAN-prompt-audit.md to match the shipped behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nickviljoen 2026-05-12 22:45:29 +02:00
parent 54ecd31bdd
commit 938691e598
10 changed files with 774 additions and 377 deletions

View file

@ -36,6 +36,7 @@ Create `.env` file with:
- `MAILGUN_API_KEY`: Mailgun API key (if not set, notifications are silently disabled)
- `MAILGUN_DOMAIN`: Mailgun sending domain (e.g. your-domain.mailgun.org)
- `MAILGUN_FROM_EMAIL`: Sender address (default: `AgentHub <noreply@{MAILGUN_DOMAIN}>`)
- `NOTIFICATION_REPLY_TO`: Reply-To address attached to every Mailgun send (default: `Nick.Viljoen@oliver.agency`). Lets recipients reply to a real mailbox instead of the `noreply@` sender.
- `TOKEN_USAGE_THRESHOLD`: Weekly 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

View file

@ -14,20 +14,31 @@ The admin team needs visibility into what each LibreChat agent's system prompt s
## Architecture
```
Agent-sync pipeline → POST /agents collector API (now includes system_prompt)
→ Stored on agent document
Agent-sync pipeline → POST /agents collector API (includes instructions)
→ Stored on agent document (instructions field)
→ Auto-triggers classification for new/updated agents
→ Sends to Google Gemini API
→ Stores: discipline, department, category,
risk_level, client detection, audit results
→ If client work detected:
→ Sets client = "yes"
→ Sets verification_status = "needs_verification"
→ Agent appears on Verification tab
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
Manual "Run Audit" button (admin dashboard)
Batch-processes all agents (or unclassified only)
→ Same Gemini analysis pipeline
Used for one-off catch-up of existing 600+ agents
Results displayed in Prompt Audit tab
```
- **LLM**: Google Gemini (`gemini-2.5-pro`) via `google-generativeai` SDK
- **LLM**: Google Gemini (`gemini-2.5-pro`) via `google-genai` SDK
- **API Key**: Reuses existing `GOOGLE_API_KEY` (same key as the ai_qc project)
- **Trigger**: On-demand only (admin clicks "Run Audit")
- **Trigger**: Two modes:
1. **Automatic** — runs after collector API creates/updates an agent (non-blocking background task)
2. **Manual** — admin clicks "Run Audit" for batch processing or re-audit
- **No new DB connections** — prompts come through the existing collector API
- **Field used**: `instructions` (already stored on agent documents from LibreChat sync)
## Files to Modify
@ -35,7 +46,7 @@ Admin clicks "Run Audit"
Add:
```
google-generativeai>=0.3.0
google-genai>=1.0.0
```
### 2. MODIFY: `database.py` — Add audit_history collection
@ -53,18 +64,16 @@ 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
### 3. MODIFY: `models.py` — Add audit models
**Add to `AgentCollectorCreate`** (around line 164, with the other optional fields):
```python
system_prompt: Optional[str] = None
```
**Note:** The `instructions` field already exists on `AgentCollectorCreate`, `AiAgentCreate`, `AiAgent`, and `AiAgentResponse` (added in 2026-03-26 update). No changes needed for that field.
**Add to `AiAgentResponse`** (around line 144, after `completion_tokens`):
**Add to `AiAgentResponse`** (around line 146, after `instructions`):
```python
audit_status: Optional[str] = None
audit_date: Optional[str] = None
system_prompt: Optional[str] = None
audit_category: Optional[str] = None
audit_risk_level: Optional[str] = None
```
**Add new model after line 210:**
@ -109,9 +118,11 @@ 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 |
| `analyze_single_agent(agent_name, instructions, tools, description, model_name, author)` | Builds prompt with category/discipline/client 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` |
| `store_audit_result(agent_id, audit_data)` | `$set` on agent doc (includes discipline, department, client flags) + insert into `audit_history` |
| `apply_client_detection(agent_id, audit_data)` | If `is_client_work == true`: sets `client = "yes"`, `verification_status = "needs_verification"`, and `client_name` if detected. Does NOT overwrite if agent already has `client = "yes"` set manually. |
| `classify_single_agent(agent_id)` | Convenience function for post-sync: loads agent doc, runs `analyze_single_agent`, stores result, applies client detection. Used by automatic trigger. |
| `get_all_audit_results()` | Query agents with audit fields |
| `update_audit_review(agent_id, status, notes, reviewer_info)` | Mark as reviewed/cleared |
@ -119,13 +130,23 @@ Key functions:
System instruction:
```
You are an AI agent compliance analyst. Classify AI agents based on their system prompts
and tool configurations into risk categories.
You are an AI agent compliance analyst and classifier. Analyse AI agents based on their
system prompts/instructions and metadata. You must:
1. Classify into business risk categories
2. Assign a business discipline
3. Infer the department/team from the instructions
4. Detect whether the agent is used for client-specific work
Respond ONLY with valid JSON matching this schema:
{
"category": "1" | "1B" | "2" | "3",
"category_reasoning": "string",
"discipline": "Strategy" | "Creative" | "Oversight including delivery" | "Optimization" | "Back Office including operations" | "Pencil Agents",
"discipline_reasoning": "string — why this discipline was chosen",
"department": "string or null — inferred team/department from instructions (e.g. Project Management, Creative Services, Finance, Media, Strategy). null if not determinable",
"is_client_work": true | false,
"client_work_reasoning": "string — evidence from instructions that this agent handles client-specific work, references client names, brands, deliverables, or external-facing outputs. Empty string if not client work",
"client_name_detected": "string or null — specific client or brand name found in instructions, null if none",
"flags": ["array", "of", "strings"],
"summary": "2-3 sentence analysis",
"recommendations": "which team(s) should review and why",
@ -138,6 +159,30 @@ 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)
DISCIPLINE DEFINITIONS (pick the best fit):
- Strategy: Agents focused on strategic planning, research, market analysis, insights
- Creative: Agents focused on creative work, content creation, design, copywriting
- Oversight including delivery: Agents focused on project management, delivery, QA, oversight, compliance
- Optimization: Agents focused on performance optimization, data analysis, efficiency, media planning
- Back Office including operations: Agents focused on internal operations, HR, finance, IT support, admin tasks
- Pencil Agents: Agents built on or for the Pencil platform specifically
DEPARTMENT INFERENCE:
Look for clues in the instructions about which team or role uses this agent. Examples:
- "I am a project manager" → department: "Project Management"
- "help the media team" → department: "Media"
- "creative brief" → department: "Creative"
- If no department/team clues found, set department to null
CLIENT WORK DETECTION:
An agent is client work (is_client_work = true) if the instructions reference:
- Specific client names or brand names
- Client deliverables, client presentations, client reports
- External-facing outputs meant for clients
- Client briefs, client feedback, client approvals
- Work explicitly described as "for the client" or "client-facing"
Do NOT flag as client work if the agent merely mentions "users" or "stakeholders" generically.
RISK LEVELS:
- low: Internal-only, limited capabilities
- medium: Internal with external tool access or moderate cost
@ -154,16 +199,16 @@ compliance_review_needed, no_instructions
User prompt per agent:
```
Analyze this AI agent:
Analyse this AI agent:
AGENT NAME: {name}
DESCRIPTION: {description}
MODEL: {model}
AUTHOR: {author}
SYSTEM PROMPT / INSTRUCTIONS:
INSTRUCTIONS (SYSTEM PROMPT):
---
{system_prompt}
{instructions}
---
TOOLS: {tools_list}
@ -171,18 +216,24 @@ TOOL RESOURCES: {tool_resources}
ACTIONS: {actions}
```
**Concurrency**: `asyncio.Semaphore(3)` — configurable via `AUDIT_CONCURRENCY` env var. ~50 agents completes in ~50-85 seconds.
**Concurrency**: `asyncio.Semaphore(3)` — configurable via `AUDIT_CONCURRENCY` env var. ~50 agents completes in ~50-85 seconds. For the one-off batch of 600+ agents, expect ~10-17 minutes at concurrency 3.
**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
**Auto-classification post-sync**: For single-agent classification triggered by the collector API, no semaphore needed — it's one call at a time. Uses `classify_single_agent()` which runs `analyze_single_agent` + `store_audit_result` + `apply_client_detection` in sequence.
**A. Update `map_agent_collector_to_internal()`** to pass through `system_prompt`:
**One-off batch for existing agents**: Use the `POST /api/admin/audit/run` endpoint (or a management script) to process all 600+ existing agents. The `instructions` field is already populated from LibreChat sync. Agents without `instructions` will be skipped (counted as `skipped_count`). The batch will:
1. Auto-assign `discipline` from the existing list
2. Infer `department` from the instructions
3. Classify into Cat 1/1B/2/3
4. Detect client work and auto-flag for verification
5. Store all results on agent documents
Add to the mapping dict:
```python
"system_prompt": collector_data.system_prompt,
```
### 5. MODIFY: `main.py` — 3 new endpoints + automatic post-sync trigger
**A. `map_agent_collector_to_internal()` — no change needed**
The `instructions` field is already mapped through the collector API (added in the 2026-03-26 update).
**B. Update `create_agent_response()`** to include audit fields:
@ -190,14 +241,32 @@ 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):
**C. Automatic post-sync classification trigger**
In the collector API endpoint (`POST /agents`), after an agent is created or updated, trigger classification as a **non-blocking background task**:
```python
from audit_analyzer import classify_single_agent, is_gemini_configured
# Inside the collector endpoint, after successful create/update:
if is_gemini_configured():
# Fire-and-forget: classify in background, don't block the sync response
asyncio.create_task(classify_single_agent(agent_id))
```
Key behaviours:
- **Non-blocking**: The collector API returns immediately; classification runs in background
- **Idempotent**: If the agent already has `audit_status`, re-classification overwrites with fresh results
- **Graceful degradation**: If `GOOGLE_API_KEY` not set, classification is silently skipped
- **Client detection**: If Gemini returns `is_client_work = true`, the background task auto-sets `client = "yes"` and `verification_status = "needs_verification"` on the agent doc (does NOT overwrite manually-set values)
**D. Add 3 admin 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}`. |
| `POST /api/admin/audit/run` | POST | Batch audit: reads stored `instructions` from agent docs, sends to Gemini. Optional `agent_id` body param for single agent. Optional `unclassified_only` param (default false) to only process agents without `audit_status`. 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`. |
@ -217,21 +286,24 @@ Pre-flight check: `POST /api/admin/audit/run` returns 503 if `GOOGLE_API_KEY` no
**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 1: Header + [Run Audit] button (with spinner loading state) + [Run Unclassified Only] button
Row 2: Summary cards — Audited | Flagged | Reviewed | Cleared | Client Detected | No Instructions
Row 3: Filters — [Category ▼] [Risk Level ▼] [Discipline ▼] [Status ▼] [Search...]
Row 4: Results table:
Agent Name | Category | Risk Level | Flags | Status | Last Audited | Actions
Row 5: Agents without prompts notice (collapsible)
Agent Name | Discipline | Department | Category | Risk Level | Client Work | Flags | Status | Last Audited | Actions
Row 5: Agents without instructions notice (collapsible)
```
**C. Audit Detail Modal:**
- Agent name + basic info header
- LLM analysis summary
- **Discipline** badge + reasoning
- **Department** (inferred from instructions)
- Category badge + reasoning
- **Client work detection**: Yes/No badge + reasoning + detected client name (if any)
- Flags list (as badges)
- Recommendations text
- System prompt (collapsible `<pre>` block, can be long)
- Instructions (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)
@ -293,21 +365,27 @@ AUDIT_CONCURRENCY=3 # concurrent Gemini API calls
## Design Decisions
- **Prompts via agent-sync** — no second DB connection needed, reuses existing infrastructure
- **Instructions via agent-sync** — `instructions` field already flows through the collector API, no second DB connection needed
- **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)
- **Automatic post-sync classification** — non-blocking `asyncio.create_task()` after collector API create/update, so sync is never slowed down
- **Manual batch for catch-up** — "Run Audit" button for the initial 600+ agent batch and future re-audits. Synchronous with spinner (~10-17 mins for full batch)
- **Gemini 2.5 Pro** — fast, 1M token context window, reuses existing Google API key
- **Discipline auto-assignment** — Gemini picks from the existing discipline list; written to the `discipline` field on the agent doc (overwrites only if currently empty, to respect manual edits)
- **Department inference** — free text field inferred from instructions; written to `agent_department` on the agent doc (overwrites only if currently empty)
- **Client work auto-detection** — Gemini scans instructions for client references; if detected, sets `client = "yes"` and `verification_status = "needs_verification"`. Does NOT overwrite if `client` is already manually set to `"yes"` or `"no"`.
- **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
- **No keyword list initially** — Gemini handles all client/discipline/department detection via LLM reasoning. Keyword overrides can be added later if needed.
## Implementation Order
1. `requirements.txt` — add `google-generativeai`
1. `requirements.txt` — add `google-genai`
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
3. `models.py` — add audit fields to response model, add `AuditReviewRequest`
4. `audit_analyzer.py` — new file (Gemini analysis + result storage + client detection + discipline/department assignment)
5. `main.py` — update `create_agent_response()` + add 3 audit endpoints + add automatic post-sync trigger in collector API
6. `templates/admin/dashboard.html` — Prompt Audit tab (HTML, JS, CSS)
7. **One-off batch run** — deploy, then trigger `POST /api/admin/audit/run` to classify all 600+ existing agents
## Verification
@ -315,24 +393,28 @@ AUDIT_CONCURRENCY=3 # concurrent Gemini API calls
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
5. Agents with `instructions` show as ready for audit; agents without show in "No Instructions" count
6. Click "Run Audit" → spinner → results populate with category/risk/discipline/department/flags badges
7. Verify discipline was auto-assigned from the existing list
8. Verify department was inferred from instructions (or null if not determinable)
9. Check agents where `is_client_work = true` → confirm they now appear on the Verification tab with `verification_status = "needs_verification"`
10. Click detail on an agent → see full LLM analysis + raw instructions + client work reasoning
11. Mark as "Reviewed" with notes → status updates, reviewer trail shows
12. Test automatic trigger: POST a new agent via collector API → verify it auto-classifies within seconds
13. Refresh → all data persists
14. Test without `GOOGLE_API_KEY` → config warning shown gracefully, auto-trigger silently skipped
15. **One-off batch**: Run `POST /api/admin/audit/run` to process all 600+ existing agents, verify results
## 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` |
| `audit_analyzer.py` | **New file** — Gemini analysis, discipline/department assignment, client detection, batch processing |
| `database.py` | Add `audit_history_collection` + indexes |
| `models.py` | Add audit fields to response model + `AuditReviewRequest` |
| `main.py` | 3 new admin endpoints + automatic post-sync classification trigger in collector API + update `create_agent_response()` |
| `templates/admin/dashboard.html` | Add Prompt Audit tab with discipline/department/client columns |
| `requirements.txt` | Add `google-genai` |
---
@ -585,4 +667,55 @@ DAILY_DIGEST_TIMEZONE=UTC # Timezone, default UTC
## 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.
The `instructions` field is already flowing through the agent-sync pipeline and stored on agent documents. No further sync changes are required. Agents without `instructions` will be skipped during audit (counted as "No Instructions") and can be re-audited once their instructions arrive via a future sync.
## Flow Summary
```
┌─────────────────────────────────────────────────────────────────┐
│ DAILY SYNC FLOW │
│ │
│ LibreChat ──sync──▶ POST /agents (collector API) │
│ │ │
│ ▼ │
│ Agent stored/updated in DB │
│ (includes instructions field) │
│ │ │
│ ▼ │
│ asyncio.create_task(classify_single_agent) │
│ │ │
│ ▼ │
│ Gemini Analysis │
│ ├─ discipline (from defined list) │
│ ├─ department (free text, inferred) │
│ ├─ category (1/1B/2/3) │
│ ├─ risk_level │
│ ├─ is_client_work │
│ └─ flags, summary, recommendations │
│ │ │
│ ▼ │
│ Store results on agent doc │
│ If client work detected: │
│ ├─ client = "yes" │
│ ├─ verification_status = "needs_verification" │
│ └─ Agent appears on Verification tab │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ONE-OFF / MANUAL BATCH │
│ │
│ Admin clicks "Run Audit" on dashboard │
│ │ │
│ ▼ │
│ POST /api/admin/audit/run │
│ (processes all agents, or unclassified_only=true) │
│ │ │
│ ▼ │
│ Same Gemini pipeline as above, batched with semaphore(3) │
│ ~600 agents ≈ 10-17 minutes │
│ │ │
│ ▼ │
│ Results displayed in Prompt Audit tab │
│ Client agents flagged on Verification tab │
└─────────────────────────────────────────────────────────────────┘
```

View file

@ -18,6 +18,41 @@ if not logger.handlers:
logger.addHandler(handler)
_batch_state = {
"running": False,
"mode": None,
"started_at": None,
"completed_at": None,
"total": 0,
"audited": 0,
"failed": 0,
"skipped": 0,
"current_agent": None,
"error": None,
"started_by": None,
}
def get_batch_state() -> dict:
return dict(_batch_state)
def _reset_batch_state(mode: str, started_by: str = None):
_batch_state.update({
"running": True,
"mode": mode,
"started_at": datetime.utcnow().isoformat(),
"completed_at": None,
"total": 0,
"audited": 0,
"failed": 0,
"skipped": 0,
"current_agent": None,
"error": None,
"started_by": started_by,
})
def is_gemini_configured() -> bool:
return bool(os.getenv("GOOGLE_API_KEY"))
@ -362,7 +397,8 @@ async def run_audit_batch(unclassified_only: bool = False, single_agent_id: str
concurrency: int = None) -> dict:
"""Run audit on multiple agents with concurrency control.
Returns summary dict with counts and results.
Updates the module-level _batch_state as it progresses so the frontend
can poll /api/admin/audit/status. Returns summary dict with counts and results.
"""
if not is_gemini_configured():
return {"error": "GOOGLE_API_KEY not configured"}
@ -370,7 +406,6 @@ async def run_audit_batch(unclassified_only: bool = False, single_agent_id: str
if concurrency is None:
concurrency = int(os.getenv("AUDIT_CONCURRENCY", "2"))
# Build query
query = {}
if single_agent_id:
query["_id"] = ObjectId(single_agent_id)
@ -380,33 +415,31 @@ async def run_audit_batch(unclassified_only: bool = False, single_agent_id: str
agents = await agents_collection.find(query).to_list(length=None)
total = len(agents)
audited = 0
failed = 0
skipped = 0
results = []
# Filter out agents without instructions first
agents_with_instructions = []
for agent in agents:
if not agent.get("instructions"):
skipped += 1
_batch_state["skipped"] += 1
else:
agents_with_instructions.append(agent)
logger.info(f"Audit batch: {total} total, {len(agents_with_instructions)} with instructions, {skipped} skipped")
_batch_state["total"] = total
logger.info(f"Audit batch: {total} total, {len(agents_with_instructions)} with instructions, {_batch_state['skipped']} skipped")
# Process agents sequentially in small batches to respect Gemini rate limits
batch_size = concurrency
for i in range(0, len(agents_with_instructions), batch_size):
batch = agents_with_instructions[i:i + batch_size]
for agent in batch:
agent_id = str(agent["_id"])
agent_name = agent.get("agent_name", "Unknown")
tools = ", ".join(agent.get("agent_capabilities", []) or [])
_batch_state["current_agent"] = agent_name
try:
audit_data = await analyze_single_agent(
agent_name=agent.get("agent_name", "Unknown"),
agent_name=agent_name,
instructions=agent.get("instructions"),
tools=tools,
description=agent.get("agent_description", ""),
@ -414,40 +447,40 @@ async def run_audit_batch(unclassified_only: bool = False, single_agent_id: str
)
if audit_data.get("error"):
failed += 1
results.append({"agent_name": agent.get("agent_name"), "error": audit_data["error"]})
_batch_state["failed"] += 1
results.append({"agent_name": agent_name, "error": audit_data["error"]})
else:
await store_audit_result(agent_id, audit_data)
await apply_classification_fields(agent_id, audit_data)
audited += 1
_batch_state["audited"] += 1
results.append({
"agent_name": agent.get("agent_name"),
"agent_name": agent_name,
"category": audit_data.get("category"),
"discipline": audit_data.get("discipline"),
"risk_level": audit_data.get("risk_level"),
"is_client_work": audit_data.get("is_client_work")
})
logger.info(f"[{audited + failed}/{len(agents_with_instructions)}] "
f"Classified '{agent.get('agent_name')}' -> Cat {audit_data.get('category')}")
logger.info(f"[{_batch_state['audited'] + _batch_state['failed']}/{len(agents_with_instructions)}] "
f"Classified '{agent_name}' -> Cat {audit_data.get('category')}")
except Exception as e:
failed += 1
results.append({"agent_name": agent.get("agent_name"), "error": str(e)})
logger.error(f"[{audited + failed}/{len(agents_with_instructions)}] "
f"Failed '{agent.get('agent_name')}': {e}")
_batch_state["failed"] += 1
results.append({"agent_name": agent_name, "error": str(e)})
logger.error(f"[{_batch_state['audited'] + _batch_state['failed']}/{len(agents_with_instructions)}] "
f"Failed '{agent_name}': {e}")
# Pause between batches to avoid rate limits (4 seconds per batch)
if i + batch_size < len(agents_with_instructions):
await asyncio.sleep(4)
logger.info(f"Audit batch complete: {audited} audited, {failed} failed, {skipped} skipped")
_batch_state["current_agent"] = None
logger.info(f"Audit batch complete: {_batch_state['audited']} audited, {_batch_state['failed']} failed, {_batch_state['skipped']} skipped")
return {
"status": "completed",
"total": total,
"audited_count": audited,
"failed_count": failed,
"skipped_count": skipped,
"audited_count": _batch_state["audited"],
"failed_count": _batch_state["failed"],
"skipped_count": _batch_state["skipped"],
"results_summary": results[:50]
}

397
main.py
View file

@ -23,6 +23,8 @@ import csv
import io
import json
import asyncio
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font
load_dotenv()
@ -1632,15 +1634,37 @@ async def trigger_completion_reminders(
raise HTTPException(status_code=500, detail=f"Failed to send completion reminders: {str(e)}")
# Prompt Audit Endpoints
async def _audit_batch_runner(unclassified_only: bool, agent_id: str):
"""Background wrapper around run_audit_batch that flips _batch_state.running off when done."""
try:
await audit_analyzer.run_audit_batch(
unclassified_only=unclassified_only,
single_agent_id=agent_id
)
except Exception as e:
audit_analyzer._batch_state["error"] = str(e)
audit_analyzer.logger.exception("Audit batch crashed")
finally:
audit_analyzer._batch_state["running"] = False
audit_analyzer._batch_state["completed_at"] = datetime.utcnow().isoformat()
audit_analyzer._batch_state["current_agent"] = None
@app.post("/api/admin/audit/run")
async def run_audit(
request: Request,
current_user: dict = Depends(require_admin)
):
"""Run Gemini-based audit on agents. Optional body: {agent_id, unclassified_only}"""
"""Kick off a Gemini audit batch in the background. Optional body: {agent_id, unclassified_only}.
Returns immediately with {status: started}. Poll /api/admin/audit/status for progress.
"""
if not audit_analyzer.is_gemini_configured():
raise HTTPException(status_code=503, detail="GOOGLE_API_KEY not configured")
if audit_analyzer._batch_state.get("running"):
raise HTTPException(status_code=409, detail="An audit batch is already running")
body = {}
try:
body = await request.json()
@ -1650,15 +1674,24 @@ async def run_audit(
agent_id = body.get("agent_id")
unclassified_only = body.get("unclassified_only", False)
result = await audit_analyzer.run_audit_batch(
unclassified_only=unclassified_only,
single_agent_id=agent_id
)
if agent_id:
mode = "single"
elif unclassified_only:
mode = "unclassified"
else:
mode = "full"
if result.get("error"):
raise HTTPException(status_code=503, detail=result["error"])
audit_analyzer._reset_batch_state(mode=mode, started_by=current_user.get("email"))
return result
asyncio.create_task(_audit_batch_runner(unclassified_only=unclassified_only, agent_id=agent_id))
return {"status": "started", "mode": mode}
@app.get("/api/admin/audit/status")
async def audit_status(current_user: dict = Depends(require_admin_or_readonly)):
"""Current state of the audit batch (poll while running)."""
return audit_analyzer.get_batch_state()
@app.get("/api/admin/audit/results")
async def get_audit_results(current_user: dict = Depends(require_admin_or_readonly)):
@ -1753,149 +1786,231 @@ async def get_all_agents_admin(current_user: dict = Depends(require_admin_or_rea
create_agent_response(agent) for agent in agents
]
@app.get("/api/admin/agents/export/csv")
async def export_agents_csv(current_user: dict = Depends(require_admin)):
"""Export all agent data to CSV (excluding usage data)"""
EXPORT_FIELDNAMES = [
"agent_id",
"agent_name",
"agent_tool",
"agent_description",
"agent_purpose",
"agent_version",
"agent_status",
"agent_location",
"agent_department",
"agent_contact_person",
"agent_created_at",
"agent_updated_at",
"agent_tags",
"agent_metadata",
"agent_userbase",
"agent_capabilities",
"url",
"quality_audit_status",
"quality_audit_updated_by",
"quality_audit_updated_at",
"quality_audit_updated_by_name",
"risk_factor",
"last_edited_by",
"created_by",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"discipline",
"rating",
"rating_count",
"instructions",
"business_entity",
"client",
"client_scope",
"client_name",
"studio_name",
"agent_classification",
"autonomy_level",
"ip_ownership",
"foundation_model",
"validated_by",
"validation_date",
"evals_method",
"registration_complete",
"safety_off_switch_confirmed",
"safety_access_rights_confirmed",
"pii_handles_pii",
"pii_legal_ref",
"pii_data_types",
"pii_consent_recorded",
"decl_governance",
"decl_accuracy",
"decl_upkeep",
]
def _export_row(agent: dict) -> dict:
return {
"agent_id": str(agent["_id"]),
"agent_name": agent.get("agent_name", ""),
"agent_tool": agent.get("agent_tool", ""),
"agent_description": agent.get("agent_description", ""),
"agent_purpose": agent.get("agent_purpose", ""),
"agent_version": agent.get("agent_version", ""),
"agent_status": agent.get("agent_status", ""),
"agent_location": agent.get("agent_location", ""),
"agent_department": agent.get("agent_department", ""),
"agent_contact_person": agent.get("agent_contact_person", ""),
"agent_created_at": agent["created_at"].isoformat() if agent.get("created_at") else "",
"agent_updated_at": agent["updated_at"].isoformat() if agent.get("updated_at") else "",
"agent_tags": "|".join(agent.get("agent_tags", [])) if agent.get("agent_tags") else "",
"agent_userbase": "|".join(agent.get("agent_userbase", [])) if agent.get("agent_userbase") else "",
"agent_capabilities": "|".join(agent.get("agent_capabilities", [])) if agent.get("agent_capabilities") else "",
"agent_metadata": json.dumps(sanitize_metadata(agent.get("agent_metadata"))) if agent.get("agent_metadata") else "",
"url": agent.get("url", ""),
"quality_audit_status": str(agent.get("quality_audit_status", False)),
"quality_audit_updated_by": agent.get("quality_audit_updated_by", ""),
"quality_audit_updated_at": agent.get("quality_audit_updated_at", ""),
"quality_audit_updated_by_name": agent.get("quality_audit_updated_by_name", ""),
"risk_factor": str(agent.get("risk_factor", "")) if agent.get("risk_factor") is not None else "",
"last_edited_by": agent.get("last_edited_by", ""),
"created_by": agent.get("created_by", ""),
"total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else "",
"prompt_tokens": str(agent.get("prompt_tokens", "")) if agent.get("prompt_tokens") is not None else "",
"completion_tokens": str(agent.get("completion_tokens", "")) if agent.get("completion_tokens") is not None else "",
"discipline": agent.get("discipline", ""),
"rating": str(agent.get("rating", "")) if agent.get("rating") is not None else "",
"rating_count": str(agent.get("rating_count", "")) if agent.get("rating_count") is not None else "",
"instructions": agent.get("instructions", ""),
"business_entity": agent.get("business_entity", ""),
"client": agent.get("client", ""),
"client_scope": agent.get("client_scope", ""),
"client_name": agent.get("client_name", ""),
"studio_name": agent.get("studio_name", ""),
"agent_classification": agent.get("agent_classification", ""),
"autonomy_level": agent.get("autonomy_level", ""),
"ip_ownership": agent.get("ip_ownership", ""),
"foundation_model": agent.get("foundation_model", ""),
"validated_by": agent.get("validated_by", ""),
"validation_date": agent.get("validation_date", ""),
"evals_method": agent.get("evals_method", ""),
"registration_complete": str(agent.get("registration_complete", "")) if agent.get("registration_complete") is not None else "",
"safety_off_switch_confirmed": str((agent.get("safety") or {}).get("off_switch_confirmed", "")) if agent.get("safety") else "",
"safety_access_rights_confirmed": str((agent.get("safety") or {}).get("access_rights_confirmed", "")) if agent.get("safety") else "",
"pii_handles_pii": str((agent.get("pii") or {}).get("handles_pii", "")) if agent.get("pii") else "",
"pii_legal_ref": (agent.get("pii") or {}).get("legal_ref", "") if agent.get("pii") else "",
"pii_data_types": (agent.get("pii") or {}).get("data_types", "") if agent.get("pii") else "",
"pii_consent_recorded": str((agent.get("pii") or {}).get("consent_recorded", "")) if agent.get("pii") else "",
"decl_governance": str((agent.get("declarations") or {}).get("governance", "")) if agent.get("declarations") else "",
"decl_accuracy": str((agent.get("declarations") or {}).get("accuracy", "")) if agent.get("declarations") else "",
"decl_upkeep": str((agent.get("declarations") or {}).get("upkeep", "")) if agent.get("declarations") else "",
}
def _matches_export_filters(
agent: dict,
status: str,
discipline: str,
audit: str,
business_entity: str,
agent_classification: str,
autonomy_level: str,
risks_only: bool,
search: str,
) -> bool:
if status and agent.get("agent_status") != status:
return False
if discipline and agent.get("discipline") != discipline:
return False
if audit == "audited" and not agent.get("quality_audit_status"):
return False
if audit == "not_audited" and agent.get("quality_audit_status"):
return False
if business_entity and agent.get("business_entity") != business_entity:
return False
if agent_classification and agent.get("agent_classification") != agent_classification:
return False
if autonomy_level and agent.get("autonomy_level") != autonomy_level:
return False
if risks_only:
pii_yes = bool((agent.get("pii") or {}).get("handles_pii"))
ip_shared = agent.get("ip_ownership") == "Shared/TBD"
autopilot = agent.get("autonomy_level") == "Autopilot"
if not (pii_yes or ip_shared or autopilot):
return False
if search:
needle = search.lower()
haystack = " ".join(
str(agent.get(f) or "") for f in (
"agent_name", "agent_department", "agent_purpose",
"agent_description", "agent_contact_person", "discipline",
)
).lower()
if needle not in haystack:
return False
return True
@app.get("/api/admin/agents/export/xlsx")
async def export_agents_xlsx(
current_user: dict = Depends(require_admin),
status: str = Query("", description="Filter by agent_status"),
discipline: str = Query("", description="Filter by discipline (Strategy | Creative | ...)"),
audit: str = Query("", description="audited | not_audited"),
business_entity: str = Query("", description="Filter by business_entity"),
agent_classification: str = Query("", description="Utility | Functional | Supervisory | Guardian"),
autonomy_level: str = Query("", description="Human-Led | Hybrid | Autopilot"),
risks_only: bool = Query(False, description="Only PII=Yes, IP=Shared/TBD, or Autopilot"),
search: str = Query("", description="Case-insensitive substring across name/department/purpose/description/contact/discipline"),
):
"""Export agent data as a real .xlsx file. Multi-line instructions stay in one cell.
Optional filters mirror the admin dashboard Agent Management tab; with no params,
every agent is exported."""
# Fetch all agents from database
agents = await crud.get_all_agents()
# Create CSV in memory
output = io.StringIO()
# Define CSV columns (all fields except usage-related ones)
fieldnames = [
"agent_id",
"agent_name",
"agent_tool",
"agent_description",
"agent_purpose",
"agent_version",
"agent_status",
"agent_location",
"agent_department",
"agent_contact_person",
"agent_created_at",
"agent_updated_at",
"agent_tags",
"agent_metadata",
"agent_userbase",
"agent_capabilities",
"url",
"quality_audit_status",
"quality_audit_updated_by",
"quality_audit_updated_at",
"quality_audit_updated_by_name",
"risk_factor",
"last_edited_by",
"created_by",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"discipline",
"rating",
"rating_count",
"instructions",
# Governance / registration fields (introduced 2026-05)
"business_entity",
"client",
"client_scope",
"client_name",
"studio_name",
"agent_classification",
"autonomy_level",
"ip_ownership",
"foundation_model",
"validated_by",
"validation_date",
"evals_method",
"registration_complete",
"safety_off_switch_confirmed",
"safety_access_rights_confirmed",
"pii_handles_pii",
"pii_legal_ref",
"pii_data_types",
"pii_consent_recorded",
"decl_governance",
"decl_accuracy",
"decl_upkeep",
agents = [
a for a in agents if _matches_export_filters(
a, status, discipline, audit, business_entity, agent_classification,
autonomy_level, risks_only, search,
)
]
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
wb = Workbook()
ws = wb.active
ws.title = "agents"
ws.append(EXPORT_FIELDNAMES)
# Write agent data
header_font = Font(bold=True)
for cell in ws[1]:
cell.font = header_font
wrap = Alignment(wrap_text=True, vertical="top")
for agent in agents:
row = {
"agent_id": str(agent["_id"]),
"agent_name": agent.get("agent_name", ""),
"agent_tool": agent.get("agent_tool", ""),
"agent_description": agent.get("agent_description", ""),
"agent_purpose": agent.get("agent_purpose", ""),
"agent_version": agent.get("agent_version", ""),
"agent_status": agent.get("agent_status", ""),
"agent_location": agent.get("agent_location", ""),
"agent_department": agent.get("agent_department", ""),
"agent_contact_person": agent.get("agent_contact_person", ""),
"agent_created_at": agent["created_at"].isoformat() if agent.get("created_at") else "",
"agent_updated_at": agent["updated_at"].isoformat() if agent.get("updated_at") else "",
# Convert lists to pipe-separated strings for CSV compatibility
"agent_tags": "|".join(agent.get("agent_tags", [])) if agent.get("agent_tags") else "",
"agent_userbase": "|".join(agent.get("agent_userbase", [])) if agent.get("agent_userbase") else "",
"agent_capabilities": "|".join(agent.get("agent_capabilities", [])) if agent.get("agent_capabilities") else "",
# Convert metadata dict to JSON string
"agent_metadata": json.dumps(sanitize_metadata(agent.get("agent_metadata"))) if agent.get("agent_metadata") else "",
"url": agent.get("url", ""),
"quality_audit_status": str(agent.get("quality_audit_status", False)),
"quality_audit_updated_by": agent.get("quality_audit_updated_by", ""),
"quality_audit_updated_at": agent.get("quality_audit_updated_at", ""),
"quality_audit_updated_by_name": agent.get("quality_audit_updated_by_name", ""),
"risk_factor": str(agent.get("risk_factor", "")) if agent.get("risk_factor") is not None else "",
"last_edited_by": agent.get("last_edited_by", ""),
"created_by": agent.get("created_by", ""),
"total_tokens": str(agent.get("total_tokens", "")) if agent.get("total_tokens") is not None else "",
"prompt_tokens": str(agent.get("prompt_tokens", "")) if agent.get("prompt_tokens") is not None else "",
"completion_tokens": str(agent.get("completion_tokens", "")) if agent.get("completion_tokens") is not None else "",
"discipline": agent.get("discipline", ""),
"rating": str(agent.get("rating", "")) if agent.get("rating") is not None else "",
"rating_count": str(agent.get("rating_count", "")) if agent.get("rating_count") is not None else "",
"instructions": agent.get("instructions", ""),
"business_entity": agent.get("business_entity", ""),
"client": agent.get("client", ""),
"client_scope": agent.get("client_scope", ""),
"client_name": agent.get("client_name", ""),
"studio_name": agent.get("studio_name", ""),
"agent_classification": agent.get("agent_classification", ""),
"autonomy_level": agent.get("autonomy_level", ""),
"ip_ownership": agent.get("ip_ownership", ""),
"foundation_model": agent.get("foundation_model", ""),
"validated_by": agent.get("validated_by", ""),
"validation_date": agent.get("validation_date", ""),
"evals_method": agent.get("evals_method", ""),
"registration_complete": str(agent.get("registration_complete", "")) if agent.get("registration_complete") is not None else "",
"safety_off_switch_confirmed": str((agent.get("safety") or {}).get("off_switch_confirmed", "")) if agent.get("safety") else "",
"safety_access_rights_confirmed": str((agent.get("safety") or {}).get("access_rights_confirmed", "")) if agent.get("safety") else "",
"pii_handles_pii": str((agent.get("pii") or {}).get("handles_pii", "")) if agent.get("pii") else "",
"pii_legal_ref": (agent.get("pii") or {}).get("legal_ref", "") if agent.get("pii") else "",
"pii_data_types": (agent.get("pii") or {}).get("data_types", "") if agent.get("pii") else "",
"pii_consent_recorded": str((agent.get("pii") or {}).get("consent_recorded", "")) if agent.get("pii") else "",
"decl_governance": str((agent.get("declarations") or {}).get("governance", "")) if agent.get("declarations") else "",
"decl_accuracy": str((agent.get("declarations") or {}).get("accuracy", "")) if agent.get("declarations") else "",
"decl_upkeep": str((agent.get("declarations") or {}).get("upkeep", "")) if agent.get("declarations") else "",
}
writer.writerow(row)
row = _export_row(agent)
ws.append([row[name] for name in EXPORT_FIELDNAMES])
for cell in ws[ws.max_row]:
cell.alignment = wrap
# Get CSV content
csv_content = output.getvalue()
output.close()
ws.freeze_panes = "A2"
# Width heuristic: clamp to the longest value in the column, capped at 60 chars
# so the instructions column stays readable instead of stretching to thousands.
for col_idx, name in enumerate(EXPORT_FIELDNAMES, start=1):
max_len = len(name)
for row in ws.iter_rows(min_row=2, min_col=col_idx, max_col=col_idx, values_only=True):
v = row[0]
if v is None:
continue
# Only consider the first line for width — multi-line cells wrap.
first_line = str(v).split("\n", 1)[0]
if len(first_line) > max_len:
max_len = len(first_line)
ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = min(max_len + 2, 60)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
# Return as downloadable file
return StreamingResponse(
iter([csv_content]),
media_type="text/csv",
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename=agents_export_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
}
"Content-Disposition": f"attachment; filename=agents_export_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.xlsx"
},
)
@app.post("/api/admin/agents/import/csv")

View file

@ -1,3 +1,4 @@
import logging
import os
import requests
from datetime import datetime, timedelta
@ -8,6 +9,8 @@ from database import (
completion_reminders_collection,
)
logger = logging.getLogger(__name__)
def is_mailgun_configured() -> bool:
"""Check if Mailgun environment variables are set."""
@ -19,16 +22,21 @@ def send_mailgun_email(to_emails: list[str], subject: str, html_body: str) -> bo
api_key = os.getenv("MAILGUN_API_KEY")
domain = os.getenv("MAILGUN_DOMAIN")
from_email = os.getenv("MAILGUN_FROM_EMAIL", f"AgentHub <noreply@{domain}>")
reply_to = os.getenv("NOTIFICATION_REPLY_TO", "Nick.Viljoen@oliver.agency")
data = {
"from": from_email,
"to": to_emails,
"subject": subject,
"html": html_body,
}
if reply_to:
data["h:Reply-To"] = reply_to
response = requests.post(
f"https://api.mailgun.net/v3/{domain}/messages",
auth=("api", api_key),
data={
"from": from_email,
"to": to_emails,
"subject": subject,
"html": html_body,
},
data=data,
timeout=10,
)
return response.status_code == 200
@ -306,6 +314,12 @@ async def send_completion_reminders(force: bool = False) -> dict:
max_nudges = int(os.getenv("COMPLETION_REMINDER_MAX_NUDGES", "4"))
public_url = os.getenv("AGENTHUB_PUBLIC_URL", "").rstrip("/")
if not public_url:
logger.error(
"AGENTHUB_PUBLIC_URL is not set; skipping completion reminders to avoid sending broken links"
)
return {"status": "skipped", "reason": "public_url_not_configured"}
# Gather all incomplete agents that have a contact email to nudge against.
cursor = agents_collection.find(
{

View file

@ -40,3 +40,4 @@ itsdangerous==2.2.0
cryptography==41.0.7
apscheduler>=3.10.0
google-genai>=1.0.0
openpyxl>=3.1,<4

View file

@ -23,15 +23,33 @@
}
body {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
margin: 0;
padding: 0;
/* Reserve space for the fixed navbar below so page content isn't hidden under it. */
padding-top: 64px;
color: var(--text-dark);
font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
min-height: 100vh;
background: transparent !important;
}
/* Orange brand backdrop. Painted on a fixed pseudo-element behind everything so
no child element (e.g. .container's white bg) can cover it. Always visible
around the centred white card regardless of viewport size or scroll. */
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
.container {
max-width: 1200px;
/* Always leave at least ~1rem of body gradient visible on each side, so the
orange frame doesn't disappear when the viewport is narrower than 1200px. */
width: calc(100% - 2rem);
margin: 2rem auto;
padding: 2rem;
background-color: var(--bg-white);
@ -42,6 +60,11 @@ body {
}
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1100;
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);

View file

@ -355,52 +355,78 @@
<!-- Agents Management Tab -->
<div class="tab-pane fade" id="agents">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h5 class="mb-0">Agent Management</h5>
<div class="d-flex gap-2">
<select class="form-select" id="agentStatusFilter" style="width: auto;">
<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 class="form-select" id="agentAuditFilter" style="width: auto;">
<option value="">All Audit</option>
<option value="audited">Audited</option>
<option value="not_audited">Not Audited</option>
</select>
<select class="form-select" id="agentBusinessEntityFilter" style="width: auto;">
<option value="">All Entities</option>
<option value="OLIVER">OLIVER</option>
<option value="DARE">DARE</option>
<option value="Brandtech Group">Brandtech Group</option>
<option value="Pencil">Pencil</option>
<option value="Jellyfish">Jellyfish</option>
<option value="Adjust">Adjust</option>
<option value="Other">Other</option>
</select>
<select class="form-select" id="agentTypeFilter" style="width: auto;">
<option value="">All Types</option>
<option value="Utility">Utility</option>
<option value="Functional">Functional</option>
<option value="Supervisory">Supervisory</option>
<option value="Guardian">Guardian</option>
</select>
<select class="form-select" id="agentAutonomyFilter" style="width: auto;">
<option value="">All Autonomy</option>
<option value="Human-Led">Human-Led</option>
<option value="Hybrid">Hybrid</option>
<option value="Autopilot">Autopilot</option>
</select>
<button type="button" class="btn btn-outline-danger btn-sm" id="adminComplianceRiskBtn"
title="Show only PII=Yes, IP=Shared/TBD, or Autopilot agents">
<i class="fas fa-shield-alt me-1"></i>Risks
<div class="d-flex flex-wrap gap-2 admin-write-action">
<button type="button" class="btn btn-success btn-sm" id="exportFilteredXlsxBtn"
title="Download agents matching the filters below as an Excel file">
<i class="fas fa-file-excel me-1"></i>Export
</button>
<div class="input-group" style="width: 300px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="agentSearch" placeholder="Search agents...">
</div>
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="document.getElementById('csv-import-input').click()">
<i class="fas fa-file-upload me-1"></i>Import CSV
</button>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="document.getElementById('csv-delete-input').click()">
<i class="fas fa-trash-alt me-1"></i>Delete by CSV
</button>
<input type="file" id="csv-import-input" accept=".csv" style="display:none;" onchange="uploadCsv(this)">
<input type="file" id="csv-delete-input" accept=".csv" style="display:none;" onchange="deleteCsv(this)">
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-3 p-2 bg-light rounded">
<span class="fw-semibold text-muted me-1"><i class="fas fa-filter me-1"></i>Filters:</span>
<select class="form-select form-select-sm" id="agentStatusFilter" style="width:auto;min-width:130px;">
<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 class="form-select form-select-sm" id="agentDisciplineFilter" style="width:auto;min-width:180px;">
<option value="">All Disciplines</option>
<option value="Strategy">Strategy</option>
<option value="Creative">Creative</option>
<option value="Oversight including delivery">Oversight incl. delivery</option>
<option value="Optimisation">Optimisation</option>
<option value="Back Office including operations">Back Office incl. ops</option>
<option value="Pencil Agents">Pencil Agents</option>
</select>
<select class="form-select form-select-sm" id="agentAuditFilter" style="width:auto;min-width:120px;">
<option value="">All Audit</option>
<option value="audited">Audited</option>
<option value="not_audited">Not Audited</option>
</select>
<select class="form-select form-select-sm" id="agentBusinessEntityFilter" style="width:auto;min-width:140px;">
<option value="">All Entities</option>
<option value="OLIVER">OLIVER</option>
<option value="DARE">DARE</option>
<option value="Brandtech Group">Brandtech Group</option>
<option value="Pencil">Pencil</option>
<option value="Jellyfish">Jellyfish</option>
<option value="Adjust">Adjust</option>
<option value="Other">Other</option>
</select>
<select class="form-select form-select-sm" id="agentTypeFilter" style="width:auto;min-width:120px;">
<option value="">All Types</option>
<option value="Utility">Utility</option>
<option value="Functional">Functional</option>
<option value="Supervisory">Supervisory</option>
<option value="Guardian">Guardian</option>
</select>
<select class="form-select form-select-sm" id="agentAutonomyFilter" style="width:auto;min-width:140px;">
<option value="">All Autonomy</option>
<option value="Human-Led">Human-Led</option>
<option value="Hybrid">Hybrid</option>
<option value="Autopilot">Autopilot</option>
</select>
<button type="button" class="btn btn-outline-danger btn-sm" id="adminComplianceRiskBtn"
title="Show only PII=Yes, IP=Shared/TBD, or Autopilot agents">
<i class="fas fa-shield-alt me-1"></i>Risks
</button>
<div class="input-group input-group-sm" style="width:260px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="agentSearch" placeholder="Search agents...">
</div>
</div>
@ -1343,6 +1369,7 @@ function setupEventListeners() {
document.getElementById('userSearch').addEventListener('input', filterUsers);
document.getElementById('agentSearch').addEventListener('input', filterAgents);
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
document.getElementById('agentDisciplineFilter')?.addEventListener('change', filterAgents);
document.getElementById('agentAuditFilter').addEventListener('change', filterAgents);
document.getElementById('agentBusinessEntityFilter')?.addEventListener('change', filterAgents);
document.getElementById('agentTypeFilter')?.addEventListener('change', filterAgents);
@ -1356,6 +1383,20 @@ function setupEventListeners() {
: '<i class="fas fa-shield-alt me-1"></i>Risks';
filterAgents();
});
document.getElementById('exportFilteredXlsxBtn')?.addEventListener('click', () => {
const params = new URLSearchParams();
const setIf = (k, v) => { if (v) params.set(k, v); };
setIf('status', document.getElementById('agentStatusFilter').value);
setIf('discipline', document.getElementById('agentDisciplineFilter')?.value);
setIf('audit', document.getElementById('agentAuditFilter').value);
setIf('business_entity', document.getElementById('agentBusinessEntityFilter')?.value);
setIf('agent_classification', document.getElementById('agentTypeFilter')?.value);
setIf('autonomy_level', document.getElementById('agentAutonomyFilter')?.value);
if (adminComplianceRiskOnly) params.set('risks_only', 'true');
setIf('search', document.getElementById('agentSearch').value.trim());
const qs = params.toString();
window.location.href = '{{ base_path }}/api/admin/agents/export/xlsx' + (qs ? '?' + qs : '');
});
document.getElementById('editUserForm').addEventListener('submit', handleEditUserSubmit);
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
@ -1559,6 +1600,7 @@ let adminComplianceRiskOnly = false;
function filterAgents() {
const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
const statusFilter = document.getElementById('agentStatusFilter').value;
const disciplineFilter = document.getElementById('agentDisciplineFilter')?.value || '';
const auditFilter = document.getElementById('agentAuditFilter').value;
const entityFilter = document.getElementById('agentBusinessEntityFilter')?.value || '';
const typeFilter = document.getElementById('agentTypeFilter')?.value || '';
@ -1568,6 +1610,7 @@ function filterAgents() {
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
(agent.agent_description || '').toLowerCase().includes(searchTerm);
const matchesStatus = !statusFilter || agent.agent_status === statusFilter;
const matchesDiscipline = !disciplineFilter || agent.discipline === disciplineFilter;
const matchesAudit = !auditFilter ||
(auditFilter === 'audited' && agent.quality_audit_status) ||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
@ -1579,7 +1622,7 @@ function filterAgents() {
(agent.ip_ownership === 'Shared/TBD') ||
(agent.autonomy_level === 'Autopilot')
);
return matchesSearch && matchesStatus && matchesAudit
return matchesSearch && matchesStatus && matchesDiscipline && matchesAudit
&& matchesEntity && matchesType && matchesAutonomy && matchesCompliance;
});
@ -2463,6 +2506,25 @@ async function loadAuditResults() {
updateAuditSummary();
displayAuditResults(auditAgents);
if (data.config_status?.gemini_configured) {
try {
const statusResp = await fetch(`{{ base_path }}/api/admin/audit/status`, { credentials: 'include' });
if (statusResp.ok) {
const state = await statusResp.json();
if (state.running) {
const progressEl = document.getElementById('auditProgress');
const progressText = document.getElementById('auditProgressText');
const runBtn = document.getElementById('runAuditBtn');
const runUnclassifiedBtn = document.getElementById('runAuditUnclassifiedBtn');
if (progressEl) progressEl.style.display = 'block';
if (runBtn) runBtn.disabled = true;
if (runUnclassifiedBtn) runUnclassifiedBtn.disabled = true;
pollAuditStatus(progressText, progressEl, runBtn, runUnclassifiedBtn);
}
}
} catch (e) { /* status check is best-effort */ }
}
} catch (error) {
console.error('Failed to load audit results:', error);
}
@ -2575,6 +2637,8 @@ function filterAuditResults() {
displayAuditResults(filtered);
}
let auditPollTimer = null;
async function runAudit(unclassifiedOnly) {
const progressEl = document.getElementById('auditProgress');
const progressText = document.getElementById('auditProgressText');
@ -2583,8 +2647,8 @@ async function runAudit(unclassifiedOnly) {
if (progressEl) progressEl.style.display = 'block';
if (progressText) progressText.textContent = unclassifiedOnly
? 'Running audit on unclassified agents...'
: 'Running full audit on all agents... This may take 10-17 minutes for 600+ agents.';
? 'Starting audit on unclassified agents...'
: 'Starting full audit on all agents...';
if (runBtn) runBtn.disabled = true;
if (runUnclassifiedBtn) runUnclassifiedBtn.disabled = true;
@ -2596,21 +2660,58 @@ async function runAudit(unclassifiedOnly) {
body: JSON.stringify({ unclassified_only: unclassifiedOnly })
});
const result = await response.json();
const text = await response.text();
let result;
try { result = JSON.parse(text); } catch { result = { detail: text || 'Unexpected response' }; }
if (response.ok) {
showSuccess(`Audit complete: ${result.audited_count} audited, ${result.skipped_count} skipped, ${result.failed_count} failed`);
await loadAuditResults();
await loadVerificationData(); // Refresh verification tab in case new client agents were detected
} else {
showError(result.detail || 'Audit failed');
if (!response.ok) {
showError(result.detail || `Audit failed (HTTP ${response.status})`);
finalizeAuditUi(progressEl, runBtn, runUnclassifiedBtn);
return;
}
pollAuditStatus(progressText, progressEl, runBtn, runUnclassifiedBtn);
} catch (error) {
showError('Audit request failed: ' + error.message);
} finally {
if (progressEl) progressEl.style.display = 'none';
if (runBtn) runBtn.disabled = false;
if (runUnclassifiedBtn) runUnclassifiedBtn.disabled = false;
finalizeAuditUi(progressEl, runBtn, runUnclassifiedBtn);
}
}
function finalizeAuditUi(progressEl, runBtn, runUnclassifiedBtn) {
if (auditPollTimer) { clearTimeout(auditPollTimer); auditPollTimer = null; }
if (progressEl) progressEl.style.display = 'none';
if (runBtn) runBtn.disabled = false;
if (runUnclassifiedBtn) runUnclassifiedBtn.disabled = false;
}
async function pollAuditStatus(progressText, progressEl, runBtn, runUnclassifiedBtn) {
try {
const response = await fetch(`{{ base_path }}/api/admin/audit/status`, { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const state = await response.json();
const done = state.audited + state.failed + state.skipped;
const total = state.total || 0;
const cur = state.current_agent ? ` — ${state.current_agent}` : '';
if (state.running) {
if (progressText) progressText.textContent =
`Auditing… ${done}/${total} (${state.audited} ok, ${state.failed} failed, ${state.skipped} skipped)${cur}`;
auditPollTimer = setTimeout(() => pollAuditStatus(progressText, progressEl, runBtn, runUnclassifiedBtn), 3000);
return;
}
if (state.error) {
showError('Audit failed: ' + state.error);
} else {
showSuccess(`Audit complete: ${state.audited} audited, ${state.skipped} skipped, ${state.failed} failed`);
}
await loadAuditResults();
await loadVerificationData();
finalizeAuditUi(progressEl, runBtn, runUnclassifiedBtn);
} catch (error) {
showError('Could not read audit status: ' + error.message);
finalizeAuditUi(progressEl, runBtn, runUnclassifiedBtn);
}
}
@ -2735,5 +2836,63 @@ async function submitAuditReview() {
}
}
async function uploadCsv(input) {
if (!input.files || !input.files[0]) return;
const formData = new FormData();
formData.append('file', input.files[0]);
try {
const response = await fetch('{{ base_path }}/api/admin/agents/import/csv', {
method: 'POST',
body: formData,
credentials: 'include',
});
const result = await response.json();
if (response.ok && result.success) {
let msg = `Import complete!\nImported: ${result.imported}\nSkipped: ${result.skipped}\nErrors: ${result.errors}`;
if (result.errors > 0) {
msg += `\n\nFirst few errors:\n${result.error_details.join('\n')}`;
}
alert(msg);
window.location.reload();
} else {
alert('Import failed: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error uploading CSV: ' + error.message);
}
input.value = '';
}
async function deleteCsv(input) {
if (!input.files || !input.files[0]) return;
if (!confirm('WARNING: This will permanently delete all agents found in the CSV file. Are you sure you want to proceed?')) {
input.value = '';
return;
}
const formData = new FormData();
formData.append('file', input.files[0]);
try {
const response = await fetch('{{ base_path }}/api/admin/agents/delete/csv', {
method: 'POST',
body: formData,
credentials: 'include',
});
const result = await response.json();
if (response.ok && result.success) {
let msg = `Deletion complete!\nDeleted: ${result.deleted}\nNot Found: ${result.not_found}\nErrors: ${result.errors}`;
if (result.errors > 0) {
msg += `\n\nFirst few errors:\n${result.error_details.join('\n')}`;
}
alert(msg);
window.location.reload();
} else {
alert('Deletion failed: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error deleting via CSV: ' + error.message);
}
input.value = '';
}
</script>
{% endblock %}

View file

@ -84,9 +84,11 @@
});
});
// Auto-hide alerts after 5 seconds
// Auto-hide dismissible flash messages after 5 seconds.
// Permanent banners (e.g. the unresolved-owner notice on the admin dashboard)
// are .alert without .alert-dismissible and must stay visible until acted on.
setTimeout(() => {
document.querySelectorAll('.alert').forEach(alert => {
document.querySelectorAll('.alert.alert-dismissible').forEach(alert => {
alert.style.transition = 'opacity 0.5s ease';
alert.style.opacity = '0';
setTimeout(() => alert.remove(), 500);

View file

@ -30,18 +30,6 @@
<a class="nav-link" href="{{ base_path }}/admin">
<i class="fas fa-tachometer-alt me-1"></i>Admin
</a>
<a class="nav-link" href="{{ base_path }}/api/admin/agents/export/csv" download>
<i class="fas fa-download me-1"></i>Export CSV
</a>
<a class="nav-link" href="#" onclick="document.getElementById('csv-import-input').click(); return false;">
<i class="fas fa-file-upload me-1"></i>Import CSV
</a>
<input type="file" id="csv-import-input" accept=".csv" style="display: none;" onchange="uploadCsv(this)">
<a class="nav-link text-danger" href="#"
onclick="document.getElementById('csv-delete-input').click(); return false;">
<i class="fas fa-trash-alt me-1"></i>Delete by CSV
</a>
<input type="file" id="csv-delete-input" accept=".csv" style="display: none;" onchange="deleteCsv(this)">
{% endif %}
{% endif %}
</div>
@ -207,76 +195,4 @@
}
}
async function uploadCsv(input) {
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('{{ base_path }}/api/admin/agents/import/csv', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
let msg = `Import complete!\nImported: ${result.imported}\nSkipped: ${result.skipped}\nErrors: ${result.errors}`;
if (result.errors > 0) {
msg += `\n\nFirst few errors:\n${result.error_details.join('\n')}`;
}
alert(msg);
window.location.reload();
} else {
alert('Import failed: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error uploading CSV:', error);
alert('Error uploading CSV: ' + error.message);
}
// Reset input
input.value = '';
}
async function deleteCsv(input) {
if (!input.files || !input.files[0]) return;
if (!confirm("WARNING: This will permanently delete all agents found in the CSV file. Are you sure you want to proceed?")) {
input.value = '';
return;
}
const file = input.files[0];
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('{{ base_path }}/api/admin/agents/delete/csv', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
let msg = `Deletion complete!\nDeleted: ${result.deleted}\nNot Found: ${result.not_found}\nErrors: ${result.errors}`;
if (result.errors > 0) {
msg += `\n\nFirst few errors:\n${result.error_details.join('\n')}`;
}
alert(msg);
window.location.reload();
} else {
alert('Deletion failed: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error deleting via CSV:', error);
alert('Error deleting via CSV: ' + error.message);
}
// Reset input
input.value = '';
}
</script>