Add registration form redesign with completion flow for LibreChat-synced agents
Rebuilds the agent registration form into 7 governance sections (Identity, Classification, Autonomy, IP, Tech Stack, Data Safety, Performance, Declarations) and introduces a completion flow for agents that come in via the LibreChat collector without the new required fields. - New form fields: business_entity, client_scope, agent_classification, autonomy_level, ip_ownership, foundation_model, validated_by/date, evals_method, plus nested safety / pii / declarations objects and a registration_complete flag. - registration_complete defaults to true for form-submitted agents and false for collector-created ones; existing agents are grandfathered via a startup migration. - Owner-by-email lookup so LibreChat-synced agents surface in the user's "My Agents" view with an Incomplete badge and Complete CTA. Submitting the completion form reassigns created_by from the collector marker to the user. - Daily APScheduler job sends a digest reminder email per owner with a 7-day cooldown and 4-nudge cap (configurable). Manual trigger via POST /api/admin/completion-reminders/send. - Admin banner + modal for collector agents whose contact email doesn't match an active user, with one-click reassignment. - Gemini audit extended to also return agent_classification and an autonomy hint; applied to agents on next batch run alongside discipline/department. - New filter dimensions on agent management + admin dashboard: Business Entity, Agent Type, Autonomy, plus a Compliance Risks quick toggle. - CSV export/import gains 21 columns covering all governance fields. - Discipline 'Optimization' renamed to 'Optimisation' with idempotent startup migration; Gemini system prompt and template dropdowns updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
32b08f8b0c
commit
54ecd31bdd
10 changed files with 2119 additions and 462 deletions
79
CLAUDE.md
79
CLAUDE.md
|
|
@ -41,6 +41,12 @@ Create `.env` file with:
|
|||
- `CLIENT_AGENT_NOTIFY_EMAILS`: Comma-separated list of emails for client agent notifications
|
||||
- `WEEKLY_DIGEST_HOUR`: Hour (24h format) to send weekly digest on Mondays (default: 7)
|
||||
|
||||
#### Optional: Completion-Reminder Emails (LibreChat completion flow)
|
||||
- `COMPLETION_REMINDER_COOLDOWN_DAYS`: Min days between reminders for the same user (default: 7)
|
||||
- `COMPLETION_REMINDER_MAX_NUDGES`: After this many nudges, stop emailing the user (default: 4)
|
||||
- `COMPLETION_REMINDER_HOUR`: Hour (24h) for the daily reminder cron (default: 8)
|
||||
- `AGENTHUB_PUBLIC_URL`: Public absolute URL used in email links (e.g. `https://agenthub.example.com`). If unset, links are relative and won't work in email clients.
|
||||
|
||||
### Default Login Credentials
|
||||
- Admin: `admin@agenthub.com` / `admin123`
|
||||
- Test User: `test@example.com` / `testpass123`
|
||||
|
|
@ -83,7 +89,7 @@ Create `.env` file with:
|
|||
- All operations use ObjectId for MongoDB document IDs
|
||||
|
||||
**database.py**: MongoDB connection setup with Motor async client
|
||||
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`
|
||||
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`, `completion_reminders`
|
||||
- `ensure_indexes()`: Creates compound unique index on `agent_ratings(agent_id, user_id)`, indexes on `verification_status`, `audit_status`, and `audit_history(agent_id, audit_date)`
|
||||
|
||||
**audit_analyzer.py**: Gemini-powered agent classification and audit system:
|
||||
|
|
@ -254,7 +260,7 @@ Located in `templates/` directory:
|
|||
- Async operations using Motor driver
|
||||
- Indexed queries for performance
|
||||
- Data aggregation for statistics
|
||||
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`
|
||||
- Collections: `users`, `agents`, `agent_usage`, `token_notifications`, `agent_ratings`, `audit_history`, `completion_reminders`
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
|
|
@ -316,4 +322,71 @@ Key dependencies from requirements.txt:
|
|||
### Prompt Audit
|
||||
- `POST /api/admin/audit/run` — Run Gemini audit batch. Optional JSON body: `{agent_id, unclassified_only}`. Returns `{status, total, audited_count, failed_count, skipped_count, results_summary}` (admin only)
|
||||
- `GET /api/admin/audit/results` — Get all agents with audit data + `config_status.gemini_configured` (admin + readonly_admin)
|
||||
- `PUT /api/admin/audit/{agent_id}/review` — Mark audit as reviewed/cleared with `{audit_status, reviewer_notes}` (admin only)
|
||||
- `PUT /api/admin/audit/{agent_id}/review` — Mark audit as reviewed/cleared with `{audit_status, reviewer_notes}` (admin only)
|
||||
|
||||
### Registration Completion Flow (LibreChat-synced agents)
|
||||
- `GET /api/agents/incomplete` — Current user's incomplete agents (admins see all). Used by the agent management page banner. Owner resolution: `created_by == user_id` OR (`created_by == "agent_collector_api"` AND `agent_contact_person == user_email`).
|
||||
- `GET /agent-complete/{agent_id}` — HTML form (parameterised `agent_register.html` in completion mode). Pre-fills existing fields, required new governance fields blank. Auth: agent owner (by ID or email) or admin.
|
||||
- `POST /agent-complete/{agent_id}` — Submit completion. Sets `registration_complete=True`, reassigns `created_by` from `"agent_collector_api"` to the submitting user's ID.
|
||||
- `POST /api/admin/completion-reminders/send` — Manually trigger the daily reminder digest. Optional `?force=true` to bypass cooldown + max-nudges cap (admin only).
|
||||
- `GET /api/admin/agents/unresolved-owner` — Collector-created agents whose `agent_contact_person` doesn't match an active user (admin only)
|
||||
- `PUT /api/admin/agents/{agent_id}/reassign-owner` — Admin reassigns ownership; body `{new_contact_email, new_owner_user_id?}`. Resolves user by email if `new_owner_user_id` omitted (admin only)
|
||||
|
||||
## Registration Form Redesign (2026-05)
|
||||
|
||||
The agent registration form was rebuilt around 7 governance sections per `Indext2.html` mockup. See `PLAN-registration-form-redesign.md` for full design rationale.
|
||||
|
||||
### New schema fields on agents
|
||||
Top-level (all `Optional`):
|
||||
- `business_entity` (enum: OLIVER, DARE, Brandtech Group, Pencil, Jellyfish, Adjust, Other) — required on form
|
||||
- `client_scope` (enum: `internal`, `all`, `specific`) — required on form. Maps to legacy `client` field: `specific`→`yes`, else→`no`. Both fields stay in sync to preserve existing verification + Mailgun client-agent-email flows.
|
||||
- `agent_classification` (enum: Utility, Functional, Supervisory, Guardian) — required on form
|
||||
- `autonomy_level` (enum: Human-Led, Hybrid, Autopilot) — required on form
|
||||
- `ip_ownership` (enum: Brandtech IP, Client IP, Shared/TBD) — required on form
|
||||
- `foundation_model` (string) — optional, dropdown with optgroups by provider
|
||||
- `validated_by`, `validation_date`, `evals_method` — optional Performance & Testing fields
|
||||
- `registration_complete` (bool) — `True` for form-submitted agents, `False` for collector-created. Drives the completion-reminder flow.
|
||||
|
||||
Nested objects:
|
||||
- `safety: { off_switch_confirmed, access_rights_confirmed }` — booleans, recorded but don't block submission
|
||||
- `pii: { handles_pii, legal_ref, data_types, consent_recorded }` — Yes/No radio with conditional fields
|
||||
- `declarations: { governance, accuracy, upkeep }` — three required checkboxes
|
||||
|
||||
Module-level enum constants live in `models.py` (`BUSINESS_ENTITIES`, `CLIENT_SCOPES`, `AGENT_CLASSIFICATIONS`, `AUTONOMY_LEVELS`, `IP_OWNERSHIPS`, `DISCIPLINES`).
|
||||
|
||||
### Form field mapping
|
||||
- "Studio / Department" form input → stored as `studio_name` (not `agent_department` — `agent_department` continues to be auto-populated by Gemini audit)
|
||||
- Author / Contact Person — autofilled from logged-in user's email; not a user-editable input
|
||||
- Capabilities — 8 fixed checkboxes ∪ comma-split values from "Other" free-text input, stored as `agent_capabilities: list[str]`
|
||||
|
||||
### Discipline rename
|
||||
Discipline value `Optimization` (US) was renamed to `Optimisation` (UK) in 2026-05. Migration runs on startup (`crud.migrate_optimisation_spelling`). All template dropdowns + Gemini system prompt updated. Defensive `Optimization → Optimisation` normalisation in `audit_analyzer.store_audit_result` and `apply_classification_fields`.
|
||||
|
||||
### Required-field enforcement
|
||||
The `/agent-register` POST handler enforces required fields server-side via `Form(...)` defaults plus explicit conditional checks (e.g. `client_name` required if `client_scope == "specific"`, all 3 declarations required). Existing agents are grandfathered via the startup migration that backfilled `registration_complete` based on `created_by`.
|
||||
|
||||
### Completion flow for LibreChat-synced agents
|
||||
LibreChat-synced agents enter via the collector with `created_by="agent_collector_api"` and only a subset of fields populated. They land with `registration_complete=False` (set in `crud.create_agent_from_collector` via `setdefault`). Owners are nudged via daily APScheduler email job (`notifications.send_completion_reminders`):
|
||||
|
||||
1. Job groups incomplete agents by lowercased `agent_contact_person` and resolves to active users (case-insensitive email match)
|
||||
2. Per-user cooldown (default 7 days) + nudge cap (default 4) tracked in `completion_reminders` collection
|
||||
3. Single digest email per user with one "Complete →" CTA per agent
|
||||
4. Submitting the completion form flips `registration_complete=True` AND reassigns `created_by` from the marker to the submitting user's ID — they then own it normally for future edits
|
||||
|
||||
`agent_register.html` is parameterised: `mode="register"` (default) or `mode="complete"`. The completion-mode handler `_build_completion_context()` in `main.py` pre-computes capability sets, derived `client_scope` from legacy `client` field, and PII/safety pre-fills. Special case: `agent_purpose` renders blank if it equals `agent_description` (collector defaults purpose=description for ~94% of agents per prod data analysis).
|
||||
|
||||
### Gemini audit auto-classification (extended 2026-05)
|
||||
`audit_analyzer.SYSTEM_INSTRUCTION` schema extended to also return:
|
||||
- `agent_classification` (Utility/Functional/Supervisory/Guardian) — auto-applied to `agent_classification` field if empty
|
||||
- `autonomy_level_hint` (Human-Led/Hybrid/Autopilot/null) — auto-applied to `autonomy_level` if empty
|
||||
- `agent_classification_reasoning`, `autonomy_reasoning` — stored on agent doc as audit fields (not used as primary values)
|
||||
|
||||
Running `POST /api/admin/audit/run` after deploy auto-fills these on the ~225 agents with instructions, leaving only the truly non-inferable fields (business_entity, ip_ownership, declarations) for the human owner via the completion form.
|
||||
|
||||
### Filter dimensions
|
||||
Both `agent_management.html` and `admin/dashboard.html` agent views support filtering by:
|
||||
- Business Entity, Agent Type, Autonomy Level (new dropdowns)
|
||||
- "Compliance risks" quick toggle — shows agents where `pii.handles_pii=true` OR `ip_ownership="Shared/TBD"` OR `autonomy_level="Autopilot"`
|
||||
|
||||
### CSV roundtrip for backfilling
|
||||
`/api/admin/agents/export/csv` includes 21 new columns covering all governance fields. Import accepts them; nested objects (`safety`, `pii`, `declarations`) only build up if at least one cell is populated, so partially-filled CSVs don't clobber existing data with `False`. Defensive `_csv_bool()` helper returns `None` for empty cells.
|
||||
|
|
@ -37,17 +37,23 @@ system prompts/instructions and metadata. You must:
|
|||
2. Assign a business discipline
|
||||
3. Infer the department/team from the instructions
|
||||
4. Detect whether the agent is used for client-specific work
|
||||
5. Infer an Agent Type (single-task / workflow / orchestrator / guardrail)
|
||||
6. Infer an autonomy hint (does the agent ask the user for sign-off, run partly automated, or fully automated?)
|
||||
|
||||
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": "Strategy" | "Creative" | "Oversight including delivery" | "Optimisation" | "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",
|
||||
"agent_classification": "Utility" | "Functional" | "Supervisory" | "Guardian",
|
||||
"agent_classification_reasoning": "string - one short sentence on why this type was chosen",
|
||||
"autonomy_level_hint": "Human-Led" | "Hybrid" | "Autopilot" | null,
|
||||
"autonomy_reasoning": "string - one short sentence on autonomy signals in the prompt; empty string if no signal",
|
||||
"flags": ["array", "of", "strings"],
|
||||
"summary": "2-3 sentence analysis",
|
||||
"recommendations": "which team(s) should review and why",
|
||||
|
|
@ -64,7 +70,7 @@ 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
|
||||
- Optimisation: Agents focused on performance optimisation, 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
|
||||
|
||||
|
|
@ -90,6 +96,20 @@ RISK LEVELS:
|
|||
- high: Client-facing or accesses sensitive data
|
||||
- critical: Client-sold or handles financial/legal/PII data
|
||||
|
||||
AGENT TYPE DEFINITIONS:
|
||||
- Utility: Single-purpose tool that does one specific task — summariser, formatter, tone adjuster, glossary lookup.
|
||||
- Functional: Multi-step agent completing a defined workflow — brief writer, campaign planner, document analyser.
|
||||
- Supervisory: Orchestrates or oversees other agents — coordinator, router, planner, multi-agent dispatcher.
|
||||
- Guardian: Monitors, filters, and enforces safety/compliance guardrails on other agents or outputs.
|
||||
|
||||
AUTONOMY HINT:
|
||||
Look in the instructions for explicit signals about how independently the agent acts.
|
||||
- "Human-Led": prompt says "ask the user", "confirm before", "wait for approval", "always check with"
|
||||
- "Hybrid": some steps automated, others gated — "you may automatically X but must confirm Y"
|
||||
- "Autopilot": runs end-to-end without asking — "autonomously", "without confirmation", "do not ask"
|
||||
- null: no clear signal in the prompt — leave for the human to decide.
|
||||
This is a hint only; the human can override on the registration form.
|
||||
|
||||
FLAGS TO CONSIDER:
|
||||
internal_only, experimental, sandbox, client_facing, pencil_platform,
|
||||
revenue_generating, not_for_sale, uses_external_tools, uses_code_interpreter,
|
||||
|
|
@ -138,6 +158,10 @@ def _parse_json_response(text: str) -> dict:
|
|||
"is_client_work": False,
|
||||
"client_work_reasoning": "",
|
||||
"client_name_detected": None,
|
||||
"agent_classification": None,
|
||||
"agent_classification_reasoning": "",
|
||||
"autonomy_level_hint": None,
|
||||
"autonomy_reasoning": "",
|
||||
"flags": ["parse_error"],
|
||||
"summary": text[:500],
|
||||
"recommendations": "Manual review required — automated analysis failed to produce structured output",
|
||||
|
|
@ -207,6 +231,10 @@ async def store_audit_result(agent_id: str, audit_data: dict):
|
|||
"""Store audit results on the agent document and in audit_history."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
audit_discipline = audit_data.get("discipline")
|
||||
if audit_discipline == "Optimization":
|
||||
audit_discipline = "Optimisation"
|
||||
|
||||
# Update agent document with audit fields
|
||||
update_fields = {
|
||||
"audit_status": "flagged",
|
||||
|
|
@ -217,12 +245,16 @@ async def store_audit_result(agent_id: str, audit_data: dict):
|
|||
"audit_flags": audit_data.get("flags", []),
|
||||
"audit_recommendations": audit_data.get("recommendations"),
|
||||
"audit_category_reasoning": audit_data.get("category_reasoning"),
|
||||
"audit_discipline": audit_data.get("discipline"),
|
||||
"audit_discipline": audit_discipline,
|
||||
"audit_discipline_reasoning": audit_data.get("discipline_reasoning"),
|
||||
"audit_department": audit_data.get("department"),
|
||||
"audit_is_client_work": audit_data.get("is_client_work", False),
|
||||
"audit_client_work_reasoning": audit_data.get("client_work_reasoning", ""),
|
||||
"audit_client_name_detected": audit_data.get("client_name_detected"),
|
||||
"audit_agent_classification": audit_data.get("agent_classification"),
|
||||
"audit_agent_classification_reasoning": audit_data.get("agent_classification_reasoning"),
|
||||
"audit_autonomy_level_hint": audit_data.get("autonomy_level_hint"),
|
||||
"audit_autonomy_reasoning": audit_data.get("autonomy_reasoning"),
|
||||
}
|
||||
|
||||
await agents_collection.update_one(
|
||||
|
|
@ -252,6 +284,8 @@ async def apply_classification_fields(agent_id: str, audit_data: dict):
|
|||
|
||||
# Auto-assign discipline if not already set
|
||||
discipline = audit_data.get("discipline")
|
||||
if discipline == "Optimization":
|
||||
discipline = "Optimisation"
|
||||
if discipline and not agent.get("discipline"):
|
||||
update_fields["discipline"] = discipline
|
||||
|
||||
|
|
@ -269,6 +303,17 @@ async def apply_classification_fields(agent_id: str, audit_data: dict):
|
|||
if detected_name and not agent.get("client_name"):
|
||||
update_fields["client_name"] = detected_name
|
||||
|
||||
# Auto-assign agent_classification if Gemini gave a usable answer and the field is empty
|
||||
classification = audit_data.get("agent_classification")
|
||||
if classification in ("Utility", "Functional", "Supervisory", "Guardian") and not agent.get("agent_classification"):
|
||||
update_fields["agent_classification"] = classification
|
||||
|
||||
# Autonomy is a hint — only fill if Gemini was confident enough to return non-null AND
|
||||
# the field is empty. The user can override on the registration form.
|
||||
autonomy_hint = audit_data.get("autonomy_level_hint")
|
||||
if autonomy_hint in ("Human-Led", "Hybrid", "Autopilot") and not agent.get("autonomy_level"):
|
||||
update_fields["autonomy_level"] = autonomy_hint
|
||||
|
||||
if update_fields:
|
||||
await agents_collection.update_one(
|
||||
{"_id": ObjectId(agent_id)},
|
||||
|
|
|
|||
160
crud.py
160
crud.py
|
|
@ -659,6 +659,12 @@ async def create_agent_from_collector(agent_data: dict):
|
|||
"updated_at": now,
|
||||
}
|
||||
|
||||
# Collector-sourced agents always start incomplete — they're missing the new
|
||||
# governance fields (business_entity, autonomy_level, ip_ownership, declarations,
|
||||
# etc.) that only the form / completion flow can populate. Owner gets nudged via
|
||||
# the completion-reminder email until they finish registration.
|
||||
agent_doc.setdefault("registration_complete", False)
|
||||
|
||||
try:
|
||||
result = await agents_collection.insert_one(agent_doc)
|
||||
agent_doc["_id"] = result.inserted_id
|
||||
|
|
@ -721,6 +727,124 @@ async def update_agent_average_rating(agent_id: str):
|
|||
)
|
||||
return stats
|
||||
|
||||
async def get_incomplete_agents_for_user(user_email: str, user_id: str, is_admin: bool = False):
|
||||
"""Return agents needing the new registration form completed.
|
||||
|
||||
Owner resolution mirrors the completion-flow design (PLAN §5b):
|
||||
- direct ownership (`created_by == user_id`), or
|
||||
- LibreChat-synced ownership (`created_by == "agent_collector_api"` AND
|
||||
`agent_contact_person == user_email`).
|
||||
|
||||
Admins see all incomplete agents.
|
||||
"""
|
||||
incomplete_filter = {"registration_complete": {"$ne": True}}
|
||||
|
||||
if is_admin:
|
||||
query = incomplete_filter
|
||||
else:
|
||||
ownership = {"$or": [
|
||||
{"created_by": user_id},
|
||||
{"$and": [
|
||||
{"created_by": "agent_collector_api"},
|
||||
{"agent_contact_person": user_email},
|
||||
]},
|
||||
]}
|
||||
query = {"$and": [incomplete_filter, ownership]}
|
||||
|
||||
cursor = agents_collection.find(query).sort("created_at", -1)
|
||||
return await cursor.to_list(length=None)
|
||||
|
||||
|
||||
async def get_unresolved_owner_agents():
|
||||
"""Return collector-created agents whose `agent_contact_person` doesn't match any active user.
|
||||
|
||||
These are the ~5% of LibreChat-synced agents that won't get a completion-reminder
|
||||
email and need an admin to either reassign ownership or delete them.
|
||||
"""
|
||||
# All collector-created agents
|
||||
cursor = agents_collection.find(
|
||||
{"created_by": "agent_collector_api"},
|
||||
{"agent_name": 1, "agent_contact_person": 1, "registration_complete": 1, "created_at": 1, "_id": 1}
|
||||
)
|
||||
candidates = await cursor.to_list(length=None)
|
||||
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
# Resolve which contact emails belong to actual active users (case-insensitive).
|
||||
contact_emails = {(a.get("agent_contact_person") or "").strip().lower() for a in candidates}
|
||||
contact_emails.discard("")
|
||||
user_cursor = users_collection.find(
|
||||
{"is_active": True, "email": {"$ne": None}},
|
||||
{"email": 1},
|
||||
)
|
||||
known_user_emails = set()
|
||||
async for u in user_cursor:
|
||||
e = (u.get("email") or "").strip().lower()
|
||||
if e:
|
||||
known_user_emails.add(e)
|
||||
|
||||
unresolved = []
|
||||
for a in candidates:
|
||||
email = (a.get("agent_contact_person") or "").strip().lower()
|
||||
if not email or email not in known_user_emails:
|
||||
unresolved.append(a)
|
||||
return unresolved
|
||||
|
||||
|
||||
async def reassign_agent_owner(agent_id: str, new_owner_user_id: str, new_contact_email: str):
|
||||
"""Admin tool: reassign a collector-created agent to a real user.
|
||||
|
||||
Sets `created_by` to the user's ID and `agent_contact_person` to their email,
|
||||
so they pick up the completion-reminder flow on the next run.
|
||||
"""
|
||||
update = {"updated_at": datetime.utcnow()}
|
||||
if new_owner_user_id:
|
||||
update["created_by"] = new_owner_user_id
|
||||
if new_contact_email:
|
||||
update["agent_contact_person"] = new_contact_email
|
||||
await agents_collection.update_one(
|
||||
{"_id": ObjectId(agent_id)},
|
||||
{"$set": update},
|
||||
)
|
||||
return await agents_collection.find_one({"_id": ObjectId(agent_id)})
|
||||
|
||||
|
||||
async def complete_agent_registration(
|
||||
agent_id: str,
|
||||
completion_data: dict,
|
||||
completing_user_id: str,
|
||||
completing_user_email: str,
|
||||
):
|
||||
"""Apply registration-form completion to an agent.
|
||||
|
||||
- Writes all completion fields onto the agent document.
|
||||
- Sets `registration_complete = True`.
|
||||
- If the agent was originally created by the collector marker, reassigns
|
||||
`created_by` to the submitting user so they can manage it normally afterwards.
|
||||
"""
|
||||
existing = await agents_collection.find_one({"_id": ObjectId(agent_id)})
|
||||
if not existing:
|
||||
raise ValueError(f"Agent {agent_id} not found")
|
||||
|
||||
update_fields = {
|
||||
**completion_data,
|
||||
"registration_complete": True,
|
||||
"last_edited_by": completing_user_email,
|
||||
"updated_at": datetime.utcnow(),
|
||||
}
|
||||
|
||||
# Reassign ownership from the collector marker to the user who finished registration.
|
||||
if existing.get("created_by") == "agent_collector_api":
|
||||
update_fields["created_by"] = completing_user_id
|
||||
|
||||
await agents_collection.update_one(
|
||||
{"_id": ObjectId(agent_id)},
|
||||
{"$set": update_fields},
|
||||
)
|
||||
return await agents_collection.find_one({"_id": ObjectId(agent_id)})
|
||||
|
||||
|
||||
async def migrate_pencil_agents_discipline():
|
||||
"""Update agents with 'pencil' in name that have no discipline set to 'Pencil Agents'"""
|
||||
result = await agents_collection.update_many(
|
||||
|
|
@ -735,3 +859,39 @@ async def migrate_pencil_agents_discipline():
|
|||
{"$set": {"discipline": "Pencil Agents"}}
|
||||
)
|
||||
return result.modified_count
|
||||
|
||||
|
||||
async def migrate_optimisation_spelling():
|
||||
"""Rename discipline from 'Optimization' (US) to 'Optimisation' (UK)."""
|
||||
result = await agents_collection.update_many(
|
||||
{"discipline": "Optimization"},
|
||||
{"$set": {"discipline": "Optimisation"}}
|
||||
)
|
||||
return result.modified_count
|
||||
|
||||
|
||||
async def backfill_registration_complete():
|
||||
"""One-time backfill of `registration_complete` for agents that pre-date the field.
|
||||
|
||||
Form-registered agents are grandfathered as complete (existing fields are valid under the
|
||||
legacy schema). Collector-created agents are marked incomplete so they enter the
|
||||
completion-reminder flow.
|
||||
"""
|
||||
form_result = await agents_collection.update_many(
|
||||
{
|
||||
"registration_complete": {"$exists": False},
|
||||
"created_by": {"$ne": "agent_collector_api"},
|
||||
},
|
||||
{"$set": {"registration_complete": True}},
|
||||
)
|
||||
collector_result = await agents_collection.update_many(
|
||||
{
|
||||
"registration_complete": {"$exists": False},
|
||||
"created_by": "agent_collector_api",
|
||||
},
|
||||
{"$set": {"registration_complete": False}},
|
||||
)
|
||||
return {
|
||||
"form_complete": form_result.modified_count,
|
||||
"collector_incomplete": collector_result.modified_count,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ agent_usage_collection = db.get_collection("agent_usage")
|
|||
notifications_collection = db.get_collection("token_notifications")
|
||||
agent_ratings_collection = db.get_collection("agent_ratings")
|
||||
audit_history_collection = db.get_collection("audit_history")
|
||||
completion_reminders_collection = db.get_collection("completion_reminders")
|
||||
|
||||
async def ensure_indexes():
|
||||
"""Create database indexes for performance"""
|
||||
|
|
@ -25,7 +26,13 @@ async def ensure_indexes():
|
|||
)
|
||||
await agents_collection.create_index([("verification_status", 1)])
|
||||
await agents_collection.create_index([("audit_status", 1)])
|
||||
await agents_collection.create_index([("business_entity", 1)])
|
||||
await agents_collection.create_index([("agent_classification", 1)])
|
||||
await agents_collection.create_index([("autonomy_level", 1)])
|
||||
await agents_collection.create_index([("ip_ownership", 1)])
|
||||
await agents_collection.create_index([("registration_complete", 1)])
|
||||
await audit_history_collection.create_index([("agent_id", 1), ("audit_date", -1)])
|
||||
await completion_reminders_collection.create_index([("user_email", 1)], unique=True)
|
||||
print("Database indexes ensured successfully")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to create indexes: {e}")
|
||||
|
|
|
|||
598
main.py
598
main.py
|
|
@ -5,6 +5,8 @@ from fastapi.templating import Jinja2Templates
|
|||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse, StreamingResponse
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from database import users_collection
|
||||
import crud
|
||||
import models
|
||||
import auth
|
||||
|
|
@ -350,10 +352,24 @@ async def startup_event():
|
|||
if count > 0:
|
||||
print(f"Pencil Agents migration: updated {count} agent(s)")
|
||||
|
||||
# Start weekly digest scheduler (runs Monday mornings)
|
||||
# Rename discipline 'Optimization' -> 'Optimisation' (UK spelling, 2026-05)
|
||||
opt_count = await crud.migrate_optimisation_spelling()
|
||||
if opt_count > 0:
|
||||
print(f"Optimisation spelling migration: updated {opt_count} agent(s)")
|
||||
|
||||
# Backfill registration_complete for agents that pre-date the field (2026-05)
|
||||
reg_counts = await crud.backfill_registration_complete()
|
||||
if reg_counts["form_complete"] or reg_counts["collector_incomplete"]:
|
||||
print(
|
||||
f"registration_complete backfill: {reg_counts['form_complete']} form-registered marked complete, "
|
||||
f"{reg_counts['collector_incomplete']} collector-created marked incomplete"
|
||||
)
|
||||
|
||||
# Start schedulers (weekly digest + daily completion-reminder)
|
||||
try:
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
digest_hour = int(os.getenv("WEEKLY_DIGEST_HOUR", "7"))
|
||||
completion_hour = int(os.getenv("COMPLETION_REMINDER_HOUR", "8"))
|
||||
scheduler = AsyncIOScheduler()
|
||||
scheduler.add_job(
|
||||
notifications.send_weekly_agent_digest,
|
||||
|
|
@ -363,10 +379,17 @@ async def startup_event():
|
|||
minute=0,
|
||||
id='weekly_agent_digest',
|
||||
)
|
||||
scheduler.add_job(
|
||||
notifications.send_completion_reminders,
|
||||
'cron',
|
||||
hour=completion_hour,
|
||||
minute=15,
|
||||
id='completion_reminders',
|
||||
)
|
||||
scheduler.start()
|
||||
print(f"Weekly digest scheduler started (runs Monday at {digest_hour}:00)")
|
||||
print(f"Schedulers started: weekly digest Mondays {digest_hour}:00, completion reminders daily {completion_hour}:15")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to start weekly digest scheduler: {e}")
|
||||
print(f"Warning: Failed to start schedulers: {e}")
|
||||
|
||||
# HTML Routes
|
||||
@app.get("/")
|
||||
|
|
@ -629,40 +652,76 @@ async def agent_register_page(request: Request):
|
|||
@app.post("/agent-register", response_class=HTMLResponse)
|
||||
async def agent_register_form(
|
||||
request: Request,
|
||||
# Identity
|
||||
agent_name: str = Form(...),
|
||||
agent_tool: str = Form(...),
|
||||
business_entity: str = Form(...),
|
||||
agent_status: str = Form("Development"),
|
||||
client_scope: str = Form(...),
|
||||
client_name: str = Form(None),
|
||||
studio_dept: str = Form(None),
|
||||
agent_location: str = Form(None),
|
||||
# Classification & Purpose
|
||||
agent_classification: str = Form(...),
|
||||
discipline: str = Form(...),
|
||||
agent_description: str = Form(None),
|
||||
agent_purpose: str = Form(None),
|
||||
client: str = Form(...),
|
||||
client_name: str = Form(None),
|
||||
studio_name: str = Form(None),
|
||||
agent_version: str = Form(None),
|
||||
agent_status: str = Form("Development"),
|
||||
agent_location: str = Form(None),
|
||||
agent_department: str = Form(None),
|
||||
agent_contact_person: str = Form(None),
|
||||
discipline: str = Form(...),
|
||||
agent_tags: str = Form(None),
|
||||
agent_userbase: str = Form(None),
|
||||
agent_capabilities: str = Form(None),
|
||||
quality_audit_status: bool = Form(False),
|
||||
risk_factor: int = Form(None)
|
||||
# Autonomy & Safety
|
||||
autonomy_level: str = Form(...),
|
||||
off_switch_confirmed: bool = Form(False),
|
||||
access_rights_confirmed: bool = Form(False),
|
||||
# IP
|
||||
ip_ownership: str = Form(...),
|
||||
# Tech Stack
|
||||
foundation_model: str = Form(None),
|
||||
agent_tool: str = Form(...),
|
||||
agent_version: str = Form(None),
|
||||
capabilities: List[str] = Form(default=[]),
|
||||
capabilities_other: str = Form(None),
|
||||
# Data Safety
|
||||
pii_check: str = Form("No"),
|
||||
pii_legal_ref: str = Form(None),
|
||||
pii_data_types: str = Form(None),
|
||||
pii_consent: bool = Form(False),
|
||||
# Performance & Testing
|
||||
validated_by: str = Form(None),
|
||||
validation_date: str = Form(None),
|
||||
evals_method: str = Form(None),
|
||||
# Declarations
|
||||
decl_governance: bool = Form(False),
|
||||
decl_accuracy: bool = Form(False),
|
||||
decl_upkeep: bool = Form(False),
|
||||
):
|
||||
try:
|
||||
# Get user from cookie - require authentication
|
||||
current_user = await get_current_user_optional(request)
|
||||
if not current_user:
|
||||
return RedirectResponse(url=get_app_url("login"), status_code=303)
|
||||
|
||||
user_id = str(current_user["_id"])
|
||||
|
||||
# Validate client_name is provided when client is Yes
|
||||
if client.lower() == "yes" and not client_name:
|
||||
# Conditional: client_name required when scope is "specific"
|
||||
if client_scope == "specific" and not (client_name and client_name.strip()):
|
||||
context = get_template_context(request, current_user)
|
||||
context["error"] = "Client Name is required when Client is set to Yes."
|
||||
context["error"] = "Client Name is required when Client Scope is 'Built for a specific client'."
|
||||
return templates.TemplateResponse("agent_register.html", context)
|
||||
|
||||
# Prepare agent data
|
||||
# All three declarations are required (form enforces; defend at API too)
|
||||
if not (decl_governance and decl_accuracy and decl_upkeep):
|
||||
context = get_template_context(request, current_user)
|
||||
context["error"] = "All three declarations must be agreed to before submitting."
|
||||
return templates.TemplateResponse("agent_register.html", context)
|
||||
|
||||
# Map client_scope -> legacy `client` for compatibility with verification + email flows
|
||||
legacy_client = "yes" if client_scope == "specific" else "no"
|
||||
|
||||
# Capabilities: 8 checkboxes + comma-separated free text "Other"
|
||||
cap_list = list(capabilities or [])
|
||||
if capabilities_other:
|
||||
cap_list.extend([c.strip() for c in capabilities_other.split(",") if c.strip()])
|
||||
|
||||
handles_pii = pii_check == "Yes"
|
||||
|
||||
agent_data = {
|
||||
"agent_name": agent_name,
|
||||
"agent_tool": agent_tool,
|
||||
|
|
@ -671,74 +730,73 @@ async def agent_register_form(
|
|||
"agent_version": agent_version,
|
||||
"agent_status": agent_status,
|
||||
"agent_location": agent_location,
|
||||
"agent_department": agent_department,
|
||||
"agent_contact_person": agent_contact_person,
|
||||
"agent_contact_person": current_user.get("email"),
|
||||
"discipline": discipline,
|
||||
"client": client.lower(),
|
||||
"studio_name": studio_name,
|
||||
"client": legacy_client,
|
||||
"client_scope": client_scope,
|
||||
"studio_name": studio_dept,
|
||||
"business_entity": business_entity,
|
||||
"agent_classification": agent_classification,
|
||||
"autonomy_level": autonomy_level,
|
||||
"ip_ownership": ip_ownership,
|
||||
"foundation_model": foundation_model,
|
||||
"validated_by": validated_by,
|
||||
"validation_date": validation_date,
|
||||
"evals_method": evals_method,
|
||||
"registration_complete": True,
|
||||
"safety": {
|
||||
"off_switch_confirmed": off_switch_confirmed,
|
||||
"access_rights_confirmed": access_rights_confirmed,
|
||||
},
|
||||
"pii": {
|
||||
"handles_pii": handles_pii,
|
||||
"legal_ref": pii_legal_ref if handles_pii else None,
|
||||
"data_types": pii_data_types if handles_pii else None,
|
||||
"consent_recorded": pii_consent if handles_pii else False,
|
||||
},
|
||||
"declarations": {
|
||||
"governance": decl_governance,
|
||||
"accuracy": decl_accuracy,
|
||||
"upkeep": decl_upkeep,
|
||||
},
|
||||
}
|
||||
|
||||
# Handle client-specific fields
|
||||
if client.lower() == "yes":
|
||||
if cap_list:
|
||||
agent_data["agent_capabilities"] = cap_list
|
||||
|
||||
# Client-specific fields
|
||||
if legacy_client == "yes":
|
||||
agent_data["client_name"] = client_name
|
||||
agent_data["verification_status"] = "needs_verification"
|
||||
|
||||
# Process tags, userbase, and capabilities (convert comma-separated to lists)
|
||||
# Tags / userbase parsing (existing pattern)
|
||||
if agent_tags:
|
||||
agent_data["agent_tags"] = [tag.strip() for tag in agent_tags.split(',') if tag.strip()]
|
||||
agent_data["agent_tags"] = [t.strip() for t in agent_tags.split(",") if t.strip()]
|
||||
if agent_userbase:
|
||||
agent_data["agent_userbase"] = [user.strip() for user in agent_userbase.split(',') if user.strip()]
|
||||
if agent_capabilities:
|
||||
agent_data["agent_capabilities"] = [cap.strip() for cap in agent_capabilities.split(',') if cap.strip()]
|
||||
agent_data["agent_userbase"] = [u.strip() for u in agent_userbase.split(",") if u.strip()]
|
||||
|
||||
# Handle Quality Audit - only admins can set it to True
|
||||
if quality_audit_status and current_user.get("is_admin"):
|
||||
# Admin is setting quality audit to true
|
||||
from datetime import datetime
|
||||
agent_data["quality_audit_status"] = True
|
||||
agent_data["quality_audit_updated_by"] = user_id
|
||||
agent_data["quality_audit_updated_at"] = datetime.utcnow().isoformat()
|
||||
agent_data["quality_audit_updated_by_name"] = current_user.get("full_name", current_user.get("email"))
|
||||
|
||||
# Validate Risk Factor when Quality Audit is checked
|
||||
if risk_factor is None or not (1 <= risk_factor <= 5):
|
||||
context = get_template_context(request, current_user)
|
||||
context["error"] = "Risk Factor (1-5) is required when Quality Audit is checked."
|
||||
return templates.TemplateResponse("agent_register.html", context)
|
||||
|
||||
agent_data["risk_factor"] = risk_factor
|
||||
else:
|
||||
# Non-admin or quality audit not checked
|
||||
agent_data["quality_audit_status"] = False
|
||||
agent_data["risk_factor"] = None
|
||||
|
||||
# Remove None values
|
||||
# Strip None top-level values (preserve nested objects + booleans)
|
||||
agent_data = {k: v for k, v in agent_data.items() if v is not None}
|
||||
|
||||
# Create agent in database
|
||||
created_agent = await crud.create_agent(agent_data, user_id)
|
||||
|
||||
# Send client agent notification if client is Yes (non-blocking)
|
||||
if client.lower() == "yes":
|
||||
# Client-agent email notification (non-blocking)
|
||||
if legacy_client == "yes":
|
||||
try:
|
||||
notification_data = {
|
||||
notifications.send_client_agent_notification({
|
||||
**agent_data,
|
||||
"created_by_email": current_user.get("email", "Unknown"),
|
||||
}
|
||||
notifications.send_client_agent_notification(notification_data)
|
||||
})
|
||||
except Exception as notify_err:
|
||||
print(f"Client agent notification failed: {notify_err}")
|
||||
|
||||
# Redirect to agent management with success message
|
||||
from urllib.parse import quote
|
||||
success_msg = quote(f"Agent '{agent_name}' registered successfully!")
|
||||
redirect_url = get_app_url(f"agent-management?success={success_msg}")
|
||||
print(f"DEBUG: Redirecting to: {redirect_url}") # Debug line
|
||||
return RedirectResponse(
|
||||
url=redirect_url,
|
||||
status_code=303
|
||||
url=get_app_url(f"agent-management?success={success_msg}"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
except ValueError as e:
|
||||
# Handle duplicate agent name error
|
||||
current_user = await get_current_user_optional(request)
|
||||
|
|
@ -753,6 +811,233 @@ async def agent_register_form(
|
|||
get_template_context(request, current_user, error=f"Agent registration failed: {str(e)}")
|
||||
)
|
||||
|
||||
# ─── Completion flow for incomplete (e.g. LibreChat-synced) agents ──────────
|
||||
KNOWN_CAPABILITIES = ["RAG", "Web", "API/MCP", "Image Gen", "Code Execution",
|
||||
"File Operations", "Email/Calendar", "Multi-Agent"]
|
||||
|
||||
|
||||
def _user_can_complete(agent: dict, current_user: dict) -> bool:
|
||||
if current_user.get("is_admin") or current_user.get("role") == "admin":
|
||||
return True
|
||||
user_id = str(current_user["_id"])
|
||||
if agent.get("created_by") == user_id:
|
||||
return True
|
||||
if agent.get("created_by") == "agent_collector_api":
|
||||
agent_email = (agent.get("agent_contact_person") or "").lower()
|
||||
user_email = (current_user.get("email") or "").lower()
|
||||
return bool(agent_email) and agent_email == user_email
|
||||
return False
|
||||
|
||||
|
||||
def _build_completion_context(agent: dict) -> dict:
|
||||
"""Compute pre-fill values for the completion form template."""
|
||||
caps = agent.get("agent_capabilities") or []
|
||||
known = [c for c in caps if c in KNOWN_CAPABILITIES]
|
||||
other = ", ".join(c for c in caps if c not in KNOWN_CAPABILITIES)
|
||||
|
||||
# Derive client_scope: explicit field wins, else derive from legacy `client`
|
||||
scope = agent.get("client_scope")
|
||||
if not scope:
|
||||
if agent.get("client") == "yes":
|
||||
scope = "specific"
|
||||
elif agent.get("client") == "no":
|
||||
scope = "internal"
|
||||
|
||||
pii = agent.get("pii") or {}
|
||||
safety = agent.get("safety") or {}
|
||||
|
||||
return {
|
||||
"agent": agent,
|
||||
"mode": "complete",
|
||||
"prefilled_known_capabilities": known,
|
||||
"prefilled_other_capabilities": other,
|
||||
"prefilled_client_scope": scope or "",
|
||||
"prefilled_pii_handles": bool(pii.get("handles_pii")),
|
||||
"prefilled_off_switch": bool(safety.get("off_switch_confirmed")),
|
||||
"prefilled_access_rights": bool(safety.get("access_rights_confirmed")),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/agents/incomplete")
|
||||
async def list_incomplete_agents(request: Request):
|
||||
"""Return current user's incomplete agents (admins see all)."""
|
||||
current_user = await get_current_user_from_cookie(request)
|
||||
agents = await crud.get_incomplete_agents_for_user(
|
||||
user_email=current_user.get("email") or "",
|
||||
user_id=str(current_user["_id"]),
|
||||
is_admin=bool(current_user.get("is_admin") or current_user.get("role") == "admin"),
|
||||
)
|
||||
# Convert ObjectIds to strings for JSON
|
||||
for a in agents:
|
||||
a["_id"] = str(a["_id"])
|
||||
a["agent_id"] = a["_id"]
|
||||
return agents
|
||||
|
||||
|
||||
@app.get("/agent-complete/{agent_id}", response_class=HTMLResponse)
|
||||
async def agent_complete_page(request: Request, agent_id: str):
|
||||
current_user = await get_current_user_optional(request)
|
||||
if not current_user:
|
||||
return RedirectResponse(url=get_app_url("login"), status_code=303)
|
||||
|
||||
agent = await crud.get_agent_by_id(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
|
||||
if not _user_can_complete(agent, current_user):
|
||||
raise HTTPException(status_code=403, detail="Not authorised to complete this registration")
|
||||
|
||||
context = get_template_context(request, current_user)
|
||||
context.update(_build_completion_context(agent))
|
||||
return templates.TemplateResponse("agent_register.html", context)
|
||||
|
||||
|
||||
@app.post("/agent-complete/{agent_id}", response_class=HTMLResponse)
|
||||
async def agent_complete_submit(
|
||||
agent_id: str,
|
||||
request: Request,
|
||||
# Identity
|
||||
agent_name: str = Form(...),
|
||||
business_entity: str = Form(...),
|
||||
agent_status: str = Form("Development"),
|
||||
client_scope: str = Form(...),
|
||||
client_name: str = Form(None),
|
||||
studio_dept: str = Form(None),
|
||||
agent_location: str = Form(None),
|
||||
# Classification & Purpose
|
||||
agent_classification: str = Form(...),
|
||||
discipline: str = Form(...),
|
||||
agent_description: str = Form(None),
|
||||
agent_purpose: str = Form(None),
|
||||
agent_tags: str = Form(None),
|
||||
agent_userbase: str = Form(None),
|
||||
# Autonomy & Safety
|
||||
autonomy_level: str = Form(...),
|
||||
off_switch_confirmed: bool = Form(False),
|
||||
access_rights_confirmed: bool = Form(False),
|
||||
# IP
|
||||
ip_ownership: str = Form(...),
|
||||
# Tech Stack
|
||||
foundation_model: str = Form(None),
|
||||
agent_tool: str = Form(...),
|
||||
agent_version: str = Form(None),
|
||||
capabilities: List[str] = Form(default=[]),
|
||||
capabilities_other: str = Form(None),
|
||||
# Data Safety
|
||||
pii_check: str = Form("No"),
|
||||
pii_legal_ref: str = Form(None),
|
||||
pii_data_types: str = Form(None),
|
||||
pii_consent: bool = Form(False),
|
||||
# Performance & Testing
|
||||
validated_by: str = Form(None),
|
||||
validation_date: str = Form(None),
|
||||
evals_method: str = Form(None),
|
||||
# Declarations
|
||||
decl_governance: bool = Form(False),
|
||||
decl_accuracy: bool = Form(False),
|
||||
decl_upkeep: bool = Form(False),
|
||||
):
|
||||
current_user = await get_current_user_optional(request)
|
||||
if not current_user:
|
||||
return RedirectResponse(url=get_app_url("login"), status_code=303)
|
||||
|
||||
agent = await crud.get_agent_by_id(agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
if not _user_can_complete(agent, current_user):
|
||||
raise HTTPException(status_code=403, detail="Not authorised to complete this registration")
|
||||
|
||||
# Re-render the form on validation failure.
|
||||
def _rerender_with_error(msg: str):
|
||||
context = get_template_context(request, current_user, error=msg)
|
||||
context.update(_build_completion_context(agent))
|
||||
return templates.TemplateResponse("agent_register.html", context)
|
||||
|
||||
if client_scope == "specific" and not (client_name and client_name.strip()):
|
||||
return _rerender_with_error("Client Name is required when Client Scope is 'Built for a specific client'.")
|
||||
|
||||
if not (decl_governance and decl_accuracy and decl_upkeep):
|
||||
return _rerender_with_error("All three declarations must be agreed to before submitting.")
|
||||
|
||||
legacy_client = "yes" if client_scope == "specific" else "no"
|
||||
|
||||
cap_list = list(capabilities or [])
|
||||
if capabilities_other:
|
||||
cap_list.extend([c.strip() for c in capabilities_other.split(",") if c.strip()])
|
||||
|
||||
handles_pii = pii_check == "Yes"
|
||||
|
||||
completion_data = {
|
||||
"agent_name": agent_name,
|
||||
"agent_tool": agent_tool,
|
||||
"agent_description": agent_description,
|
||||
"agent_purpose": agent_purpose,
|
||||
"agent_version": agent_version,
|
||||
"agent_status": agent_status,
|
||||
"agent_location": agent_location,
|
||||
"discipline": discipline,
|
||||
"client": legacy_client,
|
||||
"client_scope": client_scope,
|
||||
"studio_name": studio_dept,
|
||||
"business_entity": business_entity,
|
||||
"agent_classification": agent_classification,
|
||||
"autonomy_level": autonomy_level,
|
||||
"ip_ownership": ip_ownership,
|
||||
"foundation_model": foundation_model,
|
||||
"validated_by": validated_by,
|
||||
"validation_date": validation_date,
|
||||
"evals_method": evals_method,
|
||||
"safety": {
|
||||
"off_switch_confirmed": off_switch_confirmed,
|
||||
"access_rights_confirmed": access_rights_confirmed,
|
||||
},
|
||||
"pii": {
|
||||
"handles_pii": handles_pii,
|
||||
"legal_ref": pii_legal_ref if handles_pii else None,
|
||||
"data_types": pii_data_types if handles_pii else None,
|
||||
"consent_recorded": pii_consent if handles_pii else False,
|
||||
},
|
||||
"declarations": {
|
||||
"governance": decl_governance,
|
||||
"accuracy": decl_accuracy,
|
||||
"upkeep": decl_upkeep,
|
||||
},
|
||||
}
|
||||
|
||||
if cap_list:
|
||||
completion_data["agent_capabilities"] = cap_list
|
||||
|
||||
if legacy_client == "yes":
|
||||
completion_data["client_name"] = client_name
|
||||
# Only set verification_status if not already verified (don't reset prior approvals)
|
||||
if agent.get("verification_status") != "verified":
|
||||
completion_data["verification_status"] = "needs_verification"
|
||||
|
||||
if agent_tags:
|
||||
completion_data["agent_tags"] = [t.strip() for t in agent_tags.split(",") if t.strip()]
|
||||
if agent_userbase:
|
||||
completion_data["agent_userbase"] = [u.strip() for u in agent_userbase.split(",") if u.strip()]
|
||||
|
||||
completion_data = {k: v for k, v in completion_data.items() if v is not None}
|
||||
|
||||
try:
|
||||
await crud.complete_agent_registration(
|
||||
agent_id,
|
||||
completion_data,
|
||||
completing_user_id=str(current_user["_id"]),
|
||||
completing_user_email=current_user.get("email", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
return _rerender_with_error(f"Failed to save: {str(e)}")
|
||||
|
||||
from urllib.parse import quote
|
||||
success_msg = quote(f"Registration completed for '{agent_name}'.")
|
||||
return RedirectResponse(
|
||||
url=get_app_url(f"agent-management?view=my&success={success_msg}"),
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
current_user = await get_current_user_optional(request)
|
||||
|
|
@ -1268,6 +1553,84 @@ async def trigger_weekly_digest(current_user: dict = Depends(require_admin)):
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send digest: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/api/admin/agents/unresolved-owner")
|
||||
async def list_unresolved_owner_agents(current_user: dict = Depends(require_admin)):
|
||||
"""List collector-created agents whose contact_person doesn't match any active user.
|
||||
|
||||
These agents won't be picked up by the completion-reminder flow until an admin
|
||||
reassigns ownership via PUT /api/admin/agents/{id}/reassign-owner.
|
||||
"""
|
||||
agents = await crud.get_unresolved_owner_agents()
|
||||
return [
|
||||
{
|
||||
"agent_id": str(a["_id"]),
|
||||
"agent_name": a.get("agent_name"),
|
||||
"agent_contact_person": a.get("agent_contact_person"),
|
||||
"registration_complete": a.get("registration_complete", False),
|
||||
"created_at": a["created_at"].isoformat() if a.get("created_at") else None,
|
||||
}
|
||||
for a in agents
|
||||
]
|
||||
|
||||
|
||||
class ReassignOwnerRequest(BaseModel):
|
||||
new_contact_email: str
|
||||
new_owner_user_id: Optional[str] = None # if omitted, looked up by email
|
||||
|
||||
|
||||
@app.put("/api/admin/agents/{agent_id}/reassign-owner")
|
||||
async def reassign_owner(
|
||||
agent_id: str,
|
||||
body: ReassignOwnerRequest,
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Admin: reassign a collector-created agent to a real user (by email).
|
||||
|
||||
Resolves the user by email if `new_owner_user_id` not supplied. After this,
|
||||
the user will start receiving completion-reminder emails on the next daily run.
|
||||
"""
|
||||
target_email = body.new_contact_email.strip().lower()
|
||||
if "@" not in target_email:
|
||||
raise HTTPException(status_code=400, detail="new_contact_email must be a valid email")
|
||||
|
||||
new_owner_id = body.new_owner_user_id
|
||||
if not new_owner_id:
|
||||
# Look up by email (case-insensitive)
|
||||
user = await users_collection.find_one(
|
||||
{"email": {"$regex": f"^{re.escape(target_email)}$", "$options": "i"}}
|
||||
)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f"No user found with email {target_email}")
|
||||
new_owner_id = str(user["_id"])
|
||||
|
||||
updated = await crud.reassign_agent_owner(agent_id, new_owner_id, body.new_contact_email)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Agent not found")
|
||||
return {
|
||||
"status": "ok",
|
||||
"agent_id": agent_id,
|
||||
"new_owner_user_id": new_owner_id,
|
||||
"new_contact_email": body.new_contact_email,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/completion-reminders/send")
|
||||
async def trigger_completion_reminders(
|
||||
force: bool = Query(False, description="Bypass per-user cooldown and nudge cap"),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Manually trigger the completion-reminder digest email run.
|
||||
|
||||
Use `?force=true` to ignore the cooldown / max-nudges cap (e.g., for testing or
|
||||
after an outage where users haven't been emailed for a while).
|
||||
"""
|
||||
try:
|
||||
summary = await notifications.send_completion_reminders(force=force)
|
||||
return summary
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send completion reminders: {str(e)}")
|
||||
|
||||
# Prompt Audit Endpoints
|
||||
@app.post("/api/admin/audit/run")
|
||||
async def run_audit(
|
||||
|
|
@ -1432,7 +1795,30 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
|
|||
"discipline",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"instructions"
|
||||
"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",
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
|
|
@ -1473,7 +1859,29 @@ async def export_agents_csv(current_user: dict = Depends(require_admin)):
|
|||
"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", "")
|
||||
"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)
|
||||
|
||||
|
|
@ -1519,6 +1927,12 @@ async def import_agents_csv(
|
|||
skipped_count += 1
|
||||
continue
|
||||
|
||||
def _csv_bool(val):
|
||||
"""Parse CSV boolean cell; empty / unrecognised → None so we don't overwrite with False."""
|
||||
if val is None or val == "":
|
||||
return None
|
||||
return val.strip().lower() in ("true", "1", "yes", "y")
|
||||
|
||||
# Parse fields into agent_data first
|
||||
agent_data = {
|
||||
"agent_name": agent_name_from_csv,
|
||||
|
|
@ -1535,8 +1949,54 @@ async def import_agents_csv(
|
|||
"risk_factor": int(row.get("risk_factor")) if row.get("risk_factor") else None,
|
||||
"discipline": row.get("discipline") or None,
|
||||
"rating": float(row.get("rating")) if row.get("rating") else None,
|
||||
"total_tokens": int(row.get("total_tokens")) if row.get("total_tokens") else None
|
||||
"total_tokens": int(row.get("total_tokens")) if row.get("total_tokens") else None,
|
||||
# Governance / registration fields
|
||||
"business_entity": row.get("business_entity") or None,
|
||||
"client": row.get("client") or None,
|
||||
"client_scope": row.get("client_scope") or None,
|
||||
"client_name": row.get("client_name") or None,
|
||||
"studio_name": row.get("studio_name") or None,
|
||||
"agent_classification": row.get("agent_classification") or None,
|
||||
"autonomy_level": row.get("autonomy_level") or None,
|
||||
"ip_ownership": row.get("ip_ownership") or None,
|
||||
"foundation_model": row.get("foundation_model") or None,
|
||||
"validated_by": row.get("validated_by") or None,
|
||||
"validation_date": row.get("validation_date") or None,
|
||||
"evals_method": row.get("evals_method") or None,
|
||||
"registration_complete": _csv_bool(row.get("registration_complete")),
|
||||
}
|
||||
|
||||
# Build nested safety / pii / declarations objects only when at least one
|
||||
# cell is populated, so empty CSV rows don't clobber existing nested data.
|
||||
safety_off = _csv_bool(row.get("safety_off_switch_confirmed"))
|
||||
safety_access = _csv_bool(row.get("safety_access_rights_confirmed"))
|
||||
if safety_off is not None or safety_access is not None:
|
||||
agent_data["safety"] = {
|
||||
"off_switch_confirmed": safety_off,
|
||||
"access_rights_confirmed": safety_access,
|
||||
}
|
||||
|
||||
pii_handles = _csv_bool(row.get("pii_handles_pii"))
|
||||
pii_legal = row.get("pii_legal_ref") or None
|
||||
pii_data = row.get("pii_data_types") or None
|
||||
pii_consent = _csv_bool(row.get("pii_consent_recorded"))
|
||||
if any(v is not None for v in (pii_handles, pii_legal, pii_data, pii_consent)):
|
||||
agent_data["pii"] = {
|
||||
"handles_pii": pii_handles,
|
||||
"legal_ref": pii_legal,
|
||||
"data_types": pii_data,
|
||||
"consent_recorded": pii_consent,
|
||||
}
|
||||
|
||||
decl_gov = _csv_bool(row.get("decl_governance"))
|
||||
decl_acc = _csv_bool(row.get("decl_accuracy"))
|
||||
decl_upk = _csv_bool(row.get("decl_upkeep"))
|
||||
if any(v is not None for v in (decl_gov, decl_acc, decl_upk)):
|
||||
agent_data["declarations"] = {
|
||||
"governance": decl_gov,
|
||||
"accuracy": decl_acc,
|
||||
"upkeep": decl_upk,
|
||||
}
|
||||
|
||||
# Handle lists (pipe separated)
|
||||
if row.get("agent_tags"):
|
||||
|
|
|
|||
95
models.py
95
models.py
|
|
@ -8,6 +8,41 @@ class UsageTimelineEntry(BaseModel):
|
|||
token_count: int = Field(0, ge=0, description="Number of tokens consumed on this date")
|
||||
|
||||
|
||||
# Closed-list values for the new governance/registration fields. Kept as module-level
|
||||
# constants so the form, API, and migrations all reference the same source of truth.
|
||||
BUSINESS_ENTITIES = ["OLIVER", "DARE", "Brandtech Group", "Pencil", "Jellyfish", "Adjust", "Other"]
|
||||
CLIENT_SCOPES = ["internal", "all", "specific"]
|
||||
AGENT_CLASSIFICATIONS = ["Utility", "Functional", "Supervisory", "Guardian"]
|
||||
AUTONOMY_LEVELS = ["Human-Led", "Hybrid", "Autopilot"]
|
||||
IP_OWNERSHIPS = ["Brandtech IP", "Client IP", "Shared/TBD"]
|
||||
DISCIPLINES = [
|
||||
"Strategy",
|
||||
"Creative",
|
||||
"Oversight including delivery",
|
||||
"Optimisation",
|
||||
"Back Office including operations",
|
||||
"Pencil Agents",
|
||||
]
|
||||
|
||||
|
||||
class AgentSafety(BaseModel):
|
||||
off_switch_confirmed: Optional[bool] = None
|
||||
access_rights_confirmed: Optional[bool] = None
|
||||
|
||||
|
||||
class AgentPII(BaseModel):
|
||||
handles_pii: Optional[bool] = None
|
||||
legal_ref: Optional[str] = None
|
||||
data_types: Optional[str] = None
|
||||
consent_recorded: Optional[bool] = None
|
||||
|
||||
|
||||
class AgentDeclarations(BaseModel):
|
||||
governance: Optional[bool] = None
|
||||
accuracy: Optional[bool] = None
|
||||
upkeep: Optional[bool] = None
|
||||
|
||||
|
||||
class AiAgent(BaseModel):
|
||||
agent_id: int
|
||||
agent_name: str
|
||||
|
|
@ -36,6 +71,21 @@ class AiAgent(BaseModel):
|
|||
rating: float | None = Field(default=None, title="Star rating (1-5)", ge=1, le=5)
|
||||
instructions: str | None = Field(default=None, title="System prompt / instructions from LibreChat")
|
||||
|
||||
# Governance / registration fields (introduced 2026-05)
|
||||
business_entity: Optional[str] = Field(default=None, title="Group company the agent sits under")
|
||||
client_scope: Optional[str] = Field(default=None, title="internal / all / specific")
|
||||
agent_classification: Optional[str] = Field(default=None, title="Utility / Functional / Supervisory / Guardian")
|
||||
autonomy_level: Optional[str] = Field(default=None, title="Human-Led / Hybrid / Autopilot")
|
||||
ip_ownership: Optional[str] = Field(default=None, title="Brandtech IP / Client IP / Shared/TBD")
|
||||
foundation_model: Optional[str] = Field(default=None, title="Primary LLM provider/model")
|
||||
safety: Optional[AgentSafety] = None
|
||||
pii: Optional[AgentPII] = None
|
||||
validated_by: Optional[str] = None
|
||||
validation_date: Optional[str] = None
|
||||
evals_method: Optional[str] = None
|
||||
declarations: Optional[AgentDeclarations] = None
|
||||
registration_complete: Optional[bool] = Field(default=None, title="Whether all required registration fields are populated")
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -110,6 +160,21 @@ class AiAgentCreate(BaseModel):
|
|||
studio_name: Optional[str] = None
|
||||
instructions: Optional[str] = None
|
||||
|
||||
# Governance / registration fields (introduced 2026-05)
|
||||
business_entity: Optional[str] = None
|
||||
client_scope: Optional[str] = Field(default=None, pattern="^(internal|all|specific)$")
|
||||
agent_classification: Optional[str] = Field(default=None, pattern="^(Utility|Functional|Supervisory|Guardian)$")
|
||||
autonomy_level: Optional[str] = Field(default=None, pattern="^(Human-Led|Hybrid|Autopilot)$")
|
||||
ip_ownership: Optional[str] = None
|
||||
foundation_model: Optional[str] = None
|
||||
safety: Optional[AgentSafety] = None
|
||||
pii: Optional[AgentPII] = None
|
||||
validated_by: Optional[str] = None
|
||||
validation_date: Optional[str] = None
|
||||
evals_method: Optional[str] = None
|
||||
declarations: Optional[AgentDeclarations] = None
|
||||
registration_complete: Optional[bool] = None
|
||||
|
||||
class AiAgentResponse(BaseModel):
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
|
|
@ -161,6 +226,21 @@ class AiAgentResponse(BaseModel):
|
|||
prompt_tokens: Optional[int] = None
|
||||
completion_tokens: Optional[int] = None
|
||||
|
||||
# Governance / registration fields (introduced 2026-05)
|
||||
business_entity: Optional[str] = None
|
||||
client_scope: Optional[str] = None
|
||||
agent_classification: Optional[str] = None
|
||||
autonomy_level: Optional[str] = None
|
||||
ip_ownership: Optional[str] = None
|
||||
foundation_model: Optional[str] = None
|
||||
safety: Optional[AgentSafety] = None
|
||||
pii: Optional[AgentPII] = None
|
||||
validated_by: Optional[str] = None
|
||||
validation_date: Optional[str] = None
|
||||
evals_method: Optional[str] = None
|
||||
declarations: Optional[AgentDeclarations] = None
|
||||
registration_complete: Optional[bool] = None
|
||||
|
||||
# Agent Collector API Models (for compatibility with agent_collector app)
|
||||
class AgentCollectorCreate(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
|
|
@ -185,6 +265,21 @@ class AgentCollectorCreate(BaseModel):
|
|||
studio_name: Optional[str] = None
|
||||
instructions: Optional[str] = None
|
||||
|
||||
# Governance / registration fields — accepted optionally so the collector
|
||||
# can opt into sending them later without an API change.
|
||||
business_entity: Optional[str] = None
|
||||
client_scope: Optional[str] = None
|
||||
agent_classification: Optional[str] = None
|
||||
autonomy_level: Optional[str] = None
|
||||
ip_ownership: Optional[str] = None
|
||||
foundation_model: Optional[str] = None
|
||||
safety: Optional[AgentSafety] = None
|
||||
pii: Optional[AgentPII] = None
|
||||
validated_by: Optional[str] = None
|
||||
validation_date: Optional[str] = None
|
||||
evals_method: Optional[str] = None
|
||||
declarations: Optional[AgentDeclarations] = None
|
||||
|
||||
# Usage tracking fields (new)
|
||||
usage_timeline: Optional[List[UsageTimelineEntry]] = None
|
||||
conversation_count: Optional[int] = Field(default=None, ge=0)
|
||||
|
|
|
|||
169
notifications.py
169
notifications.py
|
|
@ -1,7 +1,12 @@
|
|||
import os
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from database import notifications_collection, users_collection, agents_collection
|
||||
from database import (
|
||||
notifications_collection,
|
||||
users_collection,
|
||||
agents_collection,
|
||||
completion_reminders_collection,
|
||||
)
|
||||
|
||||
|
||||
def is_mailgun_configured() -> bool:
|
||||
|
|
@ -232,6 +237,168 @@ def build_weekly_digest_email(agents: list) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def build_completion_reminder_email(user_name: str, agents: list, public_url: str) -> str:
|
||||
"""Build the HTML body for a completion-reminder digest sent to one owner."""
|
||||
rows = ""
|
||||
for a in agents:
|
||||
agent_id = str(a.get("_id"))
|
||||
link = f"{public_url.rstrip('/')}/agent-complete/{agent_id}" if public_url else f"/agent-complete/{agent_id}"
|
||||
rows += f"""
|
||||
<tr style="border-bottom: 1px solid #eee;">
|
||||
<td style="padding: 12px; vertical-align: top;">
|
||||
<strong style="color: #1e293b;">{a.get('agent_name', 'N/A')}</strong><br>
|
||||
<span style="color: #64748b; font-size: 0.85em;">
|
||||
{(a.get('agent_description') or 'No description')[:140]}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px; vertical-align: middle; text-align: right; white-space: nowrap;">
|
||||
<a href="{link}"
|
||||
style="background: #f3ae3e; color: white; padding: 8px 16px; border-radius: 6px;
|
||||
text-decoration: none; font-weight: 600; font-size: 0.85em;">Complete →</a>
|
||||
</td>
|
||||
</tr>"""
|
||||
|
||||
count = len(agents)
|
||||
plural = "agent needs" if count == 1 else "agents need"
|
||||
|
||||
return f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 640px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #f3ae3e, #d4922a); padding: 22px; border-radius: 8px 8px 0 0;">
|
||||
<h2 style="color: white; margin: 0; font-size: 1.3em;">Agent registration needs your attention</h2>
|
||||
</div>
|
||||
<div style="padding: 22px; border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px;">
|
||||
<p style="margin-top: 0;">Hi {user_name},</p>
|
||||
<p>
|
||||
<strong>{count}</strong> {plural} a few extra details before
|
||||
{('it can' if count == 1 else 'they can')} be considered fully registered in AgentHub.
|
||||
These are agents that came in from LibreChat — please confirm the existing fields
|
||||
and fill in the new governance fields (Business Entity, Agent Type, Autonomy Level,
|
||||
IP Ownership, declarations).
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 18px 0;">
|
||||
{rows}
|
||||
</table>
|
||||
<p style="color: #64748b; font-size: 0.85em; margin-bottom: 0;">
|
||||
Click "Complete" on any agent above to fill in the missing fields.
|
||||
You'll be assigned as the agent's owner once you save.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
async def send_completion_reminders(force: bool = False) -> dict:
|
||||
"""Daily job: nudge owners whose LibreChat-synced agents need registration completed.
|
||||
|
||||
- Groups incomplete agents by their resolved owner email (via `agent_contact_person`).
|
||||
- Per-user cooldown (default 7 days, COMPLETION_REMINDER_COOLDOWN_DAYS).
|
||||
- Per-user nudge cap (default 4, COMPLETION_REMINDER_MAX_NUDGES); after the cap we
|
||||
stop emailing — the agents stay incomplete and admins can pick them up.
|
||||
- Pass `force=True` to bypass cooldown + cap (used by the manual-trigger endpoint).
|
||||
- Non-blocking — failures are logged, never raise.
|
||||
"""
|
||||
if not is_mailgun_configured():
|
||||
return {"status": "skipped", "reason": "mailgun_not_configured"}
|
||||
|
||||
cooldown_days = int(os.getenv("COMPLETION_REMINDER_COOLDOWN_DAYS", "7"))
|
||||
max_nudges = int(os.getenv("COMPLETION_REMINDER_MAX_NUDGES", "4"))
|
||||
public_url = os.getenv("AGENTHUB_PUBLIC_URL", "").rstrip("/")
|
||||
|
||||
# Gather all incomplete agents that have a contact email to nudge against.
|
||||
cursor = agents_collection.find(
|
||||
{
|
||||
"registration_complete": {"$ne": True},
|
||||
"agent_contact_person": {"$nin": [None, ""]},
|
||||
},
|
||||
{"agent_name": 1, "agent_contact_person": 1, "agent_description": 1, "_id": 1},
|
||||
)
|
||||
incomplete_agents = await cursor.to_list(length=None)
|
||||
|
||||
# Group by lowercased contact email.
|
||||
by_email: dict[str, list] = {}
|
||||
for agent in incomplete_agents:
|
||||
email = (agent.get("agent_contact_person") or "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
continue
|
||||
by_email.setdefault(email, []).append(agent)
|
||||
|
||||
if not by_email:
|
||||
return {"status": "ok", "users_emailed": 0, "reason": "no_incomplete_agents_with_owners"}
|
||||
|
||||
# Resolve which owner emails actually map to active users (case-insensitive).
|
||||
user_cursor = users_collection.find(
|
||||
{"is_active": True, "email": {"$regex": "@", "$options": "i"}},
|
||||
{"email": 1, "full_name": 1},
|
||||
)
|
||||
users_by_lower_email: dict[str, dict] = {}
|
||||
async for u in user_cursor:
|
||||
email = (u.get("email") or "").strip().lower()
|
||||
if email:
|
||||
users_by_lower_email[email] = u
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=cooldown_days)
|
||||
summary = {
|
||||
"status": "ok",
|
||||
"users_emailed": 0,
|
||||
"agents_total": 0,
|
||||
"skipped_cooldown": 0,
|
||||
"skipped_max_nudges": 0,
|
||||
"skipped_unresolved": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
for email_lower, agents_for_user in by_email.items():
|
||||
user = users_by_lower_email.get(email_lower)
|
||||
if not user:
|
||||
summary["skipped_unresolved"] += 1
|
||||
continue
|
||||
|
||||
existing = await completion_reminders_collection.find_one({"user_email": email_lower})
|
||||
if existing and not force:
|
||||
if existing.get("nudge_count", 0) >= max_nudges:
|
||||
summary["skipped_max_nudges"] += 1
|
||||
continue
|
||||
last_sent = existing.get("last_sent_at")
|
||||
if last_sent and last_sent >= cutoff:
|
||||
summary["skipped_cooldown"] += 1
|
||||
continue
|
||||
|
||||
full_name = user.get("full_name") or user.get("email") or email_lower
|
||||
recipient = user.get("email") # send to the canonically-cased email
|
||||
subject = f"[AgentHub] {len(agents_for_user)} agent(s) need registration completed"
|
||||
html_body = build_completion_reminder_email(full_name, agents_for_user, public_url)
|
||||
|
||||
try:
|
||||
success = send_mailgun_email([recipient], subject, html_body)
|
||||
except Exception as e:
|
||||
print(f"Completion reminder failed for {email_lower}: {e}")
|
||||
success = False
|
||||
|
||||
await completion_reminders_collection.update_one(
|
||||
{"user_email": email_lower},
|
||||
{
|
||||
"$set": {
|
||||
"last_sent_at": datetime.utcnow(),
|
||||
"last_success": success,
|
||||
"last_agent_count": len(agents_for_user),
|
||||
},
|
||||
"$inc": {"nudge_count": 1},
|
||||
"$setOnInsert": {"first_sent_at": datetime.utcnow()},
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
|
||||
if success:
|
||||
summary["users_emailed"] += 1
|
||||
summary["agents_total"] += len(agents_for_user)
|
||||
else:
|
||||
summary["failed"] += 1
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
async def send_weekly_agent_digest():
|
||||
"""Send weekly digest of agents created in the last 7 days to all admins."""
|
||||
if not is_mailgun_configured():
|
||||
|
|
|
|||
|
|
@ -24,6 +24,52 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unresolved-owner banner (collector agents whose contact email doesn't match a user) -->
|
||||
<div class="row mb-3" id="unresolvedOwnerRow" style="display:none;">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info d-flex align-items-center justify-content-between" role="alert" style="border-radius:10px;">
|
||||
<div>
|
||||
<i class="fas fa-user-slash me-2"></i>
|
||||
<strong id="unresolvedOwnerCount"></strong>
|
||||
<span class="text-muted small ms-2">— these won't receive completion-reminder emails until reassigned.</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-info text-white" data-bs-toggle="modal" data-bs-target="#unresolvedOwnerModal">
|
||||
<i class="fas fa-list me-1"></i>Review & reassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unresolved-owner modal -->
|
||||
<div class="modal fade" id="unresolvedOwnerModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-user-slash me-2"></i>Agents with unresolved owners</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small">
|
||||
These agents were created via the LibreChat collector but their <code>agent_contact_person</code>
|
||||
email doesn't match any active AgentHub user. Reassign each one to a real user — they'll
|
||||
then start receiving completion-reminder emails on the next daily run.
|
||||
</p>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>Stored contact</th>
|
||||
<th>Reassign to (email)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="unresolvedOwnerTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4 align-items-stretch">
|
||||
<div class="col-lg-2 col-md-4 mb-3 d-flex">
|
||||
|
|
@ -138,7 +184,7 @@
|
|||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight including delivery</option>
|
||||
<option value="Optimization">Optimization</option>
|
||||
<option value="Optimisation">Optimisation</option>
|
||||
<option value="Back Office including operations">Back Office including operations</option>
|
||||
<option value="Pencil Agents">Pencil Agents</option>
|
||||
</select>
|
||||
|
|
@ -324,6 +370,33 @@
|
|||
<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
|
||||
</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...">
|
||||
|
|
@ -496,7 +569,7 @@
|
|||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight incl. delivery</option>
|
||||
<option value="Optimization">Optimization</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>
|
||||
|
|
@ -827,7 +900,7 @@
|
|||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight including delivery</option>
|
||||
<option value="Optimization">Optimization</option>
|
||||
<option value="Optimisation">Optimisation</option>
|
||||
<option value="Back Office including operations">Back Office including operations</option>
|
||||
<option value="Pencil Agents">Pencil Agents</option>
|
||||
</select>
|
||||
|
|
@ -1210,6 +1283,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
loadAnalytics();
|
||||
loadVerificationData();
|
||||
loadAuditResults();
|
||||
loadUnresolvedOwners();
|
||||
setupEventListeners();
|
||||
|
||||
// Hide write-action buttons for readonly admins
|
||||
|
|
@ -1218,12 +1292,70 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
|
||||
async function loadUnresolvedOwners() {
|
||||
if (!isFullAdmin) return; // readonly admins can't reassign
|
||||
try {
|
||||
const r = await fetch('{{ base_path }}/api/admin/agents/unresolved-owner', { credentials: 'include' });
|
||||
if (!r.ok) return;
|
||||
const list = await r.json();
|
||||
if (!list.length) return;
|
||||
document.getElementById('unresolvedOwnerRow').style.display = '';
|
||||
document.getElementById('unresolvedOwnerCount').textContent =
|
||||
list.length + (list.length === 1 ? ' agent has' : ' agents have') + ' an unresolved owner';
|
||||
const tbody = document.getElementById('unresolvedOwnerTableBody');
|
||||
tbody.innerHTML = list.map(a => `
|
||||
<tr data-agent-id="${a.agent_id}">
|
||||
<td><strong>${a.agent_name || '(no name)'}</strong></td>
|
||||
<td><code>${a.agent_contact_person || '(empty)'}</code></td>
|
||||
<td><input type="email" class="form-control form-control-sm reassign-email" placeholder="user@oliver.agency"></td>
|
||||
<td><button class="btn btn-sm btn-primary reassign-btn">Reassign</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
tbody.querySelectorAll('.reassign-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const tr = btn.closest('tr');
|
||||
const agentId = tr.dataset.agentId;
|
||||
const email = tr.querySelector('.reassign-email').value.trim();
|
||||
if (!email) { alert('Enter an email'); return; }
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await fetch(`{{ base_path }}/api/admin/agents/${agentId}/reassign-owner`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_contact_email: email }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) { alert(data.detail || 'Failed'); btn.disabled = false; btn.textContent = 'Reassign'; return; }
|
||||
tr.style.opacity = '0.5';
|
||||
btn.textContent = '✓ Done';
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
btn.disabled = false; btn.textContent = 'Reassign';
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { /* non-blocking */ }
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => { loadAdminData(); loadAnalytics(); loadVerificationData(); loadAuditResults(); });
|
||||
document.getElementById('userSearch').addEventListener('input', filterUsers);
|
||||
document.getElementById('agentSearch').addEventListener('input', filterAgents);
|
||||
document.getElementById('agentStatusFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('agentAuditFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('agentBusinessEntityFilter')?.addEventListener('change', filterAgents);
|
||||
document.getElementById('agentTypeFilter')?.addEventListener('change', filterAgents);
|
||||
document.getElementById('agentAutonomyFilter')?.addEventListener('change', filterAgents);
|
||||
document.getElementById('adminComplianceRiskBtn')?.addEventListener('click', function() {
|
||||
adminComplianceRiskOnly = !adminComplianceRiskOnly;
|
||||
this.classList.toggle('btn-outline-danger', !adminComplianceRiskOnly);
|
||||
this.classList.toggle('btn-danger', adminComplianceRiskOnly);
|
||||
this.innerHTML = adminComplianceRiskOnly
|
||||
? '<i class="fas fa-times me-1"></i>Clear risks'
|
||||
: '<i class="fas fa-shield-alt me-1"></i>Risks';
|
||||
filterAgents();
|
||||
});
|
||||
document.getElementById('editUserForm').addEventListener('submit', handleEditUserSubmit);
|
||||
document.getElementById('editAgentForm').addEventListener('submit', handleEditAgentSubmit);
|
||||
document.getElementById('createUserForm').addEventListener('submit', handleCreateUserSubmit);
|
||||
|
|
@ -1422,10 +1554,15 @@ function filterUsers() {
|
|||
displayUsers(filtered);
|
||||
}
|
||||
|
||||
let adminComplianceRiskOnly = false;
|
||||
|
||||
function filterAgents() {
|
||||
const searchTerm = document.getElementById('agentSearch').value.toLowerCase();
|
||||
const statusFilter = document.getElementById('agentStatusFilter').value;
|
||||
const auditFilter = document.getElementById('agentAuditFilter').value;
|
||||
const entityFilter = document.getElementById('agentBusinessEntityFilter')?.value || '';
|
||||
const typeFilter = document.getElementById('agentTypeFilter')?.value || '';
|
||||
const autonomyFilter = document.getElementById('agentAutonomyFilter')?.value || '';
|
||||
|
||||
let filtered = allAgents.filter(agent => {
|
||||
const matchesSearch = agent.agent_name.toLowerCase().includes(searchTerm) ||
|
||||
|
|
@ -1434,7 +1571,16 @@ function filterAgents() {
|
|||
const matchesAudit = !auditFilter ||
|
||||
(auditFilter === 'audited' && agent.quality_audit_status) ||
|
||||
(auditFilter === 'not_audited' && !agent.quality_audit_status);
|
||||
return matchesSearch && matchesStatus && matchesAudit;
|
||||
const matchesEntity = !entityFilter || agent.business_entity === entityFilter;
|
||||
const matchesType = !typeFilter || agent.agent_classification === typeFilter;
|
||||
const matchesAutonomy = !autonomyFilter || agent.autonomy_level === autonomyFilter;
|
||||
const matchesCompliance = !adminComplianceRiskOnly || (
|
||||
((agent.pii && agent.pii.handles_pii === true)) ||
|
||||
(agent.ip_ownership === 'Shared/TBD') ||
|
||||
(agent.autonomy_level === 'Autopilot')
|
||||
);
|
||||
return matchesSearch && matchesStatus && matchesAudit
|
||||
&& matchesEntity && matchesType && matchesAutonomy && matchesCompliance;
|
||||
});
|
||||
|
||||
displayAgents(filtered);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Incomplete-registration banner (populated by JS) -->
|
||||
<div class="row mb-3" id="incompleteBannerRow" style="display:none;">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning d-flex align-items-center justify-content-between" role="alert" style="border-radius:10px;">
|
||||
<div>
|
||||
<i class="fas fa-exclamation-circle me-2"></i>
|
||||
<strong id="incompleteBannerCount"></strong>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-warning" id="filterIncompleteBtn">
|
||||
<i class="fas fa-filter me-1"></i>Show only incomplete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Toggle Tabs -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
|
|
@ -105,7 +120,7 @@
|
|||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight including delivery</option>
|
||||
<option value="Optimization">Optimization</option>
|
||||
<option value="Optimisation">Optimisation</option>
|
||||
<option value="Back Office including operations">Back Office including operations</option>
|
||||
<option value="Pencil Agents">Pencil Agents</option>
|
||||
</select>
|
||||
|
|
@ -138,6 +153,44 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Second filter row: governance dimensions + compliance quick filter -->
|
||||
<div class="row align-items-center mt-3">
|
||||
<div class="col-md-3 mb-3 mb-md-0">
|
||||
<select class="form-select" id="businessEntityFilter">
|
||||
<option value="">All Business 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>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3 mb-md-0">
|
||||
<select class="form-select" id="agentTypeFilter">
|
||||
<option value="">All Agent Types</option>
|
||||
<option value="Utility">Utility</option>
|
||||
<option value="Functional">Functional</option>
|
||||
<option value="Supervisory">Supervisory</option>
|
||||
<option value="Guardian">Guardian</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3 mb-md-0">
|
||||
<select class="form-select" id="autonomyFilter">
|
||||
<option value="">All Autonomy Levels</option>
|
||||
<option value="Human-Led">Human-Led</option>
|
||||
<option value="Hybrid">Hybrid</option>
|
||||
<option value="Autopilot">Autopilot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3 mb-md-0">
|
||||
<button type="button" class="btn btn-outline-danger w-100" id="complianceRiskBtn"
|
||||
title="Show only agents with PII=Yes, IP=Shared/TBD, or Autopilot autonomy">
|
||||
<i class="fas fa-shield-alt me-1"></i>Compliance risks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -350,7 +403,7 @@
|
|||
<option value="Strategy">Strategy</option>
|
||||
<option value="Creative">Creative</option>
|
||||
<option value="Oversight including delivery">Oversight including delivery</option>
|
||||
<option value="Optimization">Optimization</option>
|
||||
<option value="Optimisation">Optimisation</option>
|
||||
<option value="Back Office including operations">Back Office including operations</option>
|
||||
<option value="Pencil Agents">Pencil Agents</option>
|
||||
</select>
|
||||
|
|
@ -582,6 +635,18 @@ function setupEventListeners() {
|
|||
document.getElementById('auditFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('disciplineFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('ratingFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('businessEntityFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('agentTypeFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('autonomyFilter').addEventListener('change', filterAgents);
|
||||
document.getElementById('complianceRiskBtn').addEventListener('click', function() {
|
||||
complianceRiskOnly = !complianceRiskOnly;
|
||||
this.classList.toggle('btn-outline-danger', !complianceRiskOnly);
|
||||
this.classList.toggle('btn-danger', complianceRiskOnly);
|
||||
this.innerHTML = complianceRiskOnly
|
||||
? '<i class="fas fa-times me-1"></i>Showing risks — clear'
|
||||
: '<i class="fas fa-shield-alt me-1"></i>Compliance risks';
|
||||
filterAgents();
|
||||
});
|
||||
document.getElementById('sortBy').addEventListener('change', sortAndDisplayAgents);
|
||||
document.getElementById('refreshBtn').addEventListener('click', function() {
|
||||
// Reload both datasets to ensure counts are accurate
|
||||
|
|
@ -600,6 +665,33 @@ function setupEventListeners() {
|
|||
document.getElementById('myAgentsTab').addEventListener('change', function() {
|
||||
if (this.checked) switchToView('my');
|
||||
});
|
||||
|
||||
// Incomplete-registration banner: load count and wire the filter button
|
||||
loadIncompleteCount();
|
||||
document.getElementById('filterIncompleteBtn').addEventListener('click', function() {
|
||||
incompleteOnly = !incompleteOnly;
|
||||
this.classList.toggle('btn-warning', !incompleteOnly);
|
||||
this.classList.toggle('btn-secondary', incompleteOnly);
|
||||
this.innerHTML = incompleteOnly
|
||||
? '<i class="fas fa-times me-1"></i>Showing incomplete only — clear'
|
||||
: '<i class="fas fa-filter me-1"></i>Show only incomplete';
|
||||
filterAgents();
|
||||
});
|
||||
}
|
||||
|
||||
let incompleteOnly = false;
|
||||
let complianceRiskOnly = false;
|
||||
async function loadIncompleteCount() {
|
||||
try {
|
||||
const r = await fetch('{{ base_path }}/api/agents/incomplete', { credentials: 'include' });
|
||||
if (!r.ok) return;
|
||||
const list = await r.json();
|
||||
if (list.length > 0) {
|
||||
document.getElementById('incompleteBannerRow').style.display = '';
|
||||
document.getElementById('incompleteBannerCount').textContent =
|
||||
list.length + (list.length === 1 ? ' agent needs' : ' agents need') + ' registration completed';
|
||||
}
|
||||
} catch (e) { /* non-blocking */ }
|
||||
}
|
||||
|
||||
async function loadAgentsForCurrentView() {
|
||||
|
|
@ -735,6 +827,7 @@ function displayAgents(agentsToShow) {
|
|||
<span class="agent-status status-${agent.agent_status || 'Development'}">
|
||||
${agent.agent_status || 'Development'}
|
||||
</span>
|
||||
${agent.registration_complete === false ? `<span class="badge bg-warning text-dark" title="This agent's registration form has not been completed"><i class="fas fa-exclamation-circle me-1"></i>Incomplete</span>` : ''}
|
||||
${agent.agent_version ? `<span class="badge bg-light text-dark">v${agent.agent_version}</span>` : ''}
|
||||
${agent.discipline ? `<span class="badge bg-purple" title="Discipline"><i class="fas fa-layer-group me-1"></i>${agent.discipline}</span>` : ''}
|
||||
${agent.rating ? `<span class="card-star-rating" title="Rating: ${agent.rating}/5">${getCardStars(agent.rating)} <small class="text-muted">${agent.rating.toFixed(1)}${agent.rating_count ? ' (' + agent.rating_count + ')' : ''}</small></span>` : ''}
|
||||
|
|
@ -754,6 +847,11 @@ function displayAgents(agentsToShow) {
|
|||
<div class="text-end">
|
||||
<small class="text-muted">${formatDate(agent.agent_created_at)}</small>
|
||||
<div class="mt-1">
|
||||
${(agent.registration_complete === false && canUserEditAgent(agent)) ? `
|
||||
<a class="btn btn-warning btn-sm me-1" href="{{ base_path }}/agent-complete/${agent.agent_id}" onclick="event.stopPropagation();" title="Complete this agent's registration">
|
||||
<i class="fas fa-check-circle me-1"></i>Complete
|
||||
</a>
|
||||
` : ''}
|
||||
${canUserEditAgent(agent) ? `
|
||||
<button class="btn btn-outline-primary btn-sm me-1" onclick="event.stopPropagation(); editAgent('${agent.agent_id}')">
|
||||
<i class="fas fa-edit"></i>
|
||||
|
|
@ -1229,7 +1327,24 @@ function filterAgents() {
|
|||
const matchesRating = !ratingFilter ||
|
||||
(ratingFilter === 'unrated' && !agent.rating) ||
|
||||
(ratingFilter !== 'unrated' && agent.rating && agent.rating >= parseFloat(ratingFilter));
|
||||
return matchesSearch && matchesStatus && matchesAudit && matchesDiscipline && matchesRating;
|
||||
const matchesIncomplete = !incompleteOnly || agent.registration_complete === false;
|
||||
|
||||
const businessEntityFilter = document.getElementById('businessEntityFilter').value;
|
||||
const agentTypeFilter = document.getElementById('agentTypeFilter').value;
|
||||
const autonomyFilter = document.getElementById('autonomyFilter').value;
|
||||
const matchesEntity = !businessEntityFilter || agent.business_entity === businessEntityFilter;
|
||||
const matchesType = !agentTypeFilter || agent.agent_classification === agentTypeFilter;
|
||||
const matchesAutonomy = !autonomyFilter || agent.autonomy_level === autonomyFilter;
|
||||
|
||||
// Compliance risks: any of PII=Yes, IP=Shared/TBD, Autopilot autonomy
|
||||
const matchesCompliance = !complianceRiskOnly || (
|
||||
((agent.pii && agent.pii.handles_pii === true)) ||
|
||||
(agent.ip_ownership === 'Shared/TBD') ||
|
||||
(agent.autonomy_level === 'Autopilot')
|
||||
);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesAudit && matchesDiscipline && matchesRating
|
||||
&& matchesIncomplete && matchesEntity && matchesType && matchesAutonomy && matchesCompliance;
|
||||
});
|
||||
|
||||
displayAgents(filtered);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue