# SPDX-License-Identifier: AGPL-3.0-or-later """GET/PUT ``/topologies/{id}/personas`` — per-topology email persona pool. The global pool (``decnet/web/router/emailgen/api_personas.py``) drives non-MazeNET fleet/SWARM-shard mail deckies. MazeNET topology mail deckies use ``Topology.email_personas`` instead — one JSON-serialized list per topology, parsed by the emailgen scheduler each tick. This endpoint is the API surface behind the dashboard's per-topology "Personas" editor. Reads accept admin or viewer; writes are admin-only. Concurrency: last-write-wins. The list is operator-curated and small (typically <20 entries); no need for optimistic versioning here. """ from __future__ import annotations import json from typing import Any from fastapi import APIRouter, Depends, HTTPException from decnet.logging import get_logger from decnet.realism.personas import EmailPersona, parse_personas from decnet.telemetry import traced as _traced from decnet.web.dependencies import repo, require_admin, require_viewer router = APIRouter() log = get_logger("api.topology.personas") def _serialize(personas: list[EmailPersona]) -> list[dict[str, Any]]: return [p.model_dump(exclude_none=False) for p in personas] @router.get( "/{topology_id}/personas", tags=["MazeNET Topologies"], responses={ 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Topology not found"}, }, ) @_traced("api.topology.list_personas") async def list_topology_personas( topology_id: str, _viewer: dict = Depends(require_viewer), ) -> dict[str, Any]: """Return the topology's persona list and its language default. ``language_default`` is included so the editor can show which language unset entries fall back to — same fallback the scheduler applies when building prompts. """ topo = await repo.get_topology(topology_id) if topo is None: raise HTTPException(status_code=404, detail="Topology not found") language_default = topo.language_default or "en" personas = parse_personas( topo.email_personas, language_default=language_default, ) return { "topology_id": topology_id, "topology_name": topo.name, "language_default": language_default, "personas": _serialize(personas), } @router.put( "/{topology_id}/personas", tags=["MazeNET Topologies"], responses={ 400: {"description": "Invalid persona payload"}, 401: {"description": "Could not validate credentials"}, 403: {"description": "Insufficient permissions"}, 404: {"description": "Topology not found"}, }, ) @_traced("api.topology.replace_personas") async def replace_topology_personas( topology_id: str, body: dict[str, Any], user: dict = Depends(require_admin), ) -> dict[str, Any]: """Replace the topology's persona list. Body shape: ``{"personas": [, ...]}``. Drop-invalid semantics mirror the global-pool endpoint: bad entries are skipped with a warning rather than failing the whole request, but a wholly invalid payload returns 400 so a schema mistake doesn't silently wipe the list. """ raw = body.get("personas") if not isinstance(raw, list): raise HTTPException( status_code=400, detail="body.personas must be a list", ) topo = await repo.get_topology(topology_id) if topo is None: raise HTTPException(status_code=404, detail="Topology not found") language_default = topo.language_default or "en" parsed = parse_personas(raw, language_default=language_default) if raw and not parsed: raise HTTPException( status_code=400, detail=( "All persona entries failed validation. Required fields: " "name, email (user@host.tld), role, tone, mannerisms." ), ) serialized = _serialize(parsed) payload = json.dumps(serialized, ensure_ascii=False) updated = await repo.set_topology_email_personas(topology_id, payload) if not updated: # Race: row vanished between the get and the update. raise HTTPException(status_code=404, detail="Topology not found") log.info( "api.topology.replace_personas user=%s topology=%s wrote=%d", user.get("username", user.get("uuid")), topology_id, len(parsed), ) return { "topology_id": topology_id, "topology_name": topo.name, "language_default": language_default, "personas": serialized, }