From 938691e598bcaf417236bf015fec0242aac3d2c7 Mon Sep 17 00:00:00 2001 From: nickviljoen Date: Tue, 12 May 2026 22:45:29 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 1 + PLAN-prompt-audit.md | 253 ++++++++++++++++----- audit_analyzer.py | 83 ++++--- main.py | 397 +++++++++++++++++++++------------ notifications.py | 26 ++- requirements.txt | 1 + static/style.css | 25 ++- templates/admin/dashboard.html | 275 ++++++++++++++++++----- templates/base.html | 6 +- templates/nav.html | 84 ------- 10 files changed, 774 insertions(+), 377 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 708d2ea..b23155d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `) +- `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 diff --git a/PLAN-prompt-audit.md b/PLAN-prompt-audit.md index a704119..2574d69 100644 --- a/PLAN-prompt-audit.md +++ b/PLAN-prompt-audit.md @@ -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 `
` block, can be long)
+- Instructions (collapsible `
` block, can be long)
 - Tools config (collapsible)
 - Review controls: status dropdown (flagged/reviewed/cleared) + notes textarea + Save button
 - Review history trail (who reviewed, when)
@@ -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                     │
+└─────────────────────────────────────────────────────────────────┘
+```
diff --git a/audit_analyzer.py b/audit_analyzer.py
index be589ee..861f1bf 100644
--- a/audit_analyzer.py
+++ b/audit_analyzer.py
@@ -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]
     }
 
diff --git a/main.py b/main.py
index 3e58d49..cc60bee 100644
--- a/main.py
+++ b/main.py
@@ -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")
diff --git a/notifications.py b/notifications.py
index 1a79dc3..8fc2834 100644
--- a/notifications.py
+++ b/notifications.py
@@ -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 ")
+    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(
         {
diff --git a/requirements.txt b/requirements.txt
index 2d724d5..6322887 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/static/style.css b/static/style.css
index 7cb1d29..5362b04 100644
--- a/static/style.css
+++ b/static/style.css
@@ -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);
diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html
index b7b1424..2346208 100644
--- a/templates/admin/dashboard.html
+++ b/templates/admin/dashboard.html
@@ -355,52 +355,78 @@
 
             
             
-
+
Agent Management
-
- - - - - - -
- - -
+ + + + +
+
+
+ Filters: + + + + + + + +
+ +
@@ -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() { : '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 = ''; +} + {% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index ff8a7c2..3b55be6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -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); diff --git a/templates/nav.html b/templates/nav.html index 8b6c510..fc42cde 100644 --- a/templates/nav.html +++ b/templates/nav.html @@ -30,18 +30,6 @@ Admin - - Export CSV - - - Import CSV - - - - Delete by CSV - - {% endif %} {% endif %}
@@ -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 = ''; - } \ No newline at end of file