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:
parent
54ecd31bdd
commit
938691e598
10 changed files with 774 additions and 377 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
397
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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Add table
Reference in a new issue