diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index 8abb4443..4679a5cb 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -31,6 +31,7 @@ from .campaigns.api_list_campaign_identities import router as campaign_identitie from .campaigns.api_events import router as campaign_events_router from .orchestrator.api_list_events import router as orchestrator_list_router from .orchestrator.api_events import router as orchestrator_events_router +from .emailgen.api_personas import router as emailgen_personas_router from .transcripts import transcripts_router from .config.api_get_config import router as config_get_router from .config.api_update_config import router as config_update_router @@ -109,6 +110,11 @@ api_router.include_router(campaign_events_router) api_router.include_router(orchestrator_list_router) api_router.include_router(orchestrator_events_router) +# Emailgen — global persona pool CRUD for the dashboard's +# "Persona Generation" page. The worker reads from the same on-disk +# JSON file directly (see decnet.orchestrator.emailgen.global_pool). +api_router.include_router(emailgen_personas_router) + # Observability api_router.include_router(stats_router) api_router.include_router(stream_router) diff --git a/decnet/web/router/emailgen/__init__.py b/decnet/web/router/emailgen/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/decnet/web/router/emailgen/api_personas.py b/decnet/web/router/emailgen/api_personas.py new file mode 100644 index 00000000..10b95123 --- /dev/null +++ b/decnet/web/router/emailgen/api_personas.py @@ -0,0 +1,126 @@ +"""GET/PUT ``/api/v1/emailgen/personas`` — global persona pool CRUD. + +The "global pool" is a JSON file consumed by the emailgen worker for +fleet (MACVLAN/IPVLAN) and SWARM-shard mail deckies — see +:mod:`decnet.orchestrator.emailgen.global_pool`. MazeNET topology +mail deckies use ``Topology.email_personas`` instead and are +configured per-topology elsewhere. + +This endpoint is the API surface behind the dashboard's "Persona +Generation" page. Reads accept admin or viewer; writes are admin-only +because the persistence target is a config file the worker reads on +its hot path. + +Concurrency: last-write-wins. The pool is operator-curated and small +(<50 entries typically); the cost of a stronger model isn't justified. +""" +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException + +from decnet.logging import get_logger +from decnet.orchestrator.emailgen import global_pool +from decnet.orchestrator.emailgen.personas import EmailPersona, parse_personas +from decnet.telemetry import traced as _traced +from decnet.web.dependencies import require_admin, require_viewer +from decnet.web.db.models.common import MessageResponse # noqa: F401 - response shape + +router = APIRouter() +log = get_logger("api.emailgen.personas") + + +def _serialize(personas: list[EmailPersona]) -> list[dict[str, Any]]: + """Pydantic → plain dicts for the response body.""" + return [p.model_dump(exclude_none=False) for p in personas] + + +@router.get( + "/emailgen/personas", + tags=["Emailgen"], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.emailgen.list_personas") +async def list_personas( + user: dict = Depends(require_viewer), +) -> dict[str, Any]: + """Return the current global persona pool + the resolved file path. + + The ``path`` field lets the dashboard show operators where the file + lives on disk so a CLI-driven backup / git-tracked workflow stays + discoverable. + """ + # Reset the in-process cache before reading so a fresh CLI-driven + # ``decnet emailgen import-personas`` shows up immediately rather + # than waiting on the worker's mtime check. + global_pool.reset_cache() + personas = global_pool.load() + return { + "path": str(global_pool.resolve_path()), + "personas": _serialize(personas), + } + + +@router.put( + "/emailgen/personas", + tags=["Emailgen"], + responses={ + 400: {"description": "Invalid persona payload"}, + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.emailgen.replace_personas") +async def replace_personas( + body: dict[str, Any], + user: dict = Depends(require_admin), +) -> dict[str, Any]: + """Replace the entire global pool with the supplied list. + + Body shape: ``{"personas": [, ...]}``. + + Validation is the same path the worker uses (``parse_personas``): + invalid entries are dropped with a warning rather than failing the + whole request — operators see exactly what landed by reading back + the GET response. An entirely-invalid payload returns 400. + """ + raw = body.get("personas") + if not isinstance(raw, list): + raise HTTPException( + status_code=400, + detail="body.personas must be a list", + ) + + parsed = parse_personas(raw) + if raw and not parsed: + # Operator sent a non-empty list and *every* entry was invalid — + # almost certainly a schema mistake on their side; fail loudly + # rather than silently writing an empty pool. + raise HTTPException( + status_code=400, + detail=( + "All persona entries failed validation. Required fields: " + "name, email (user@host.tld), role, tone, mannerisms." + ), + ) + + dest = global_pool.resolve_path() + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text( + json.dumps(_serialize(parsed), indent=2, ensure_ascii=False), + encoding="utf-8", + ) + global_pool.reset_cache() + log.info( + "api.emailgen.replace_personas user=%s wrote=%d path=%s", + user.get("username", user.get("uuid")), len(parsed), dest, + ) + return { + "path": str(dest), + "personas": _serialize(parsed), + } diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index c32f268b..1efcb748 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -24,6 +24,7 @@ const IdentityDetail = lazy(() => import('./components/IdentityDetail')); const Campaigns = lazy(() => import('./components/Campaigns')); const CampaignDetail = lazy(() => import('./components/CampaignDetail')); const Orchestrator = lazy(() => import('./components/Orchestrator')); +const PersonaGeneration = lazy(() => import('./components/PersonaGeneration')); const Config = lazy(() => import('./components/Config')); const Bounty = lazy(() => import('./components/Bounty')); const Credentials = lazy(() => import('./components/Credentials')); @@ -123,6 +124,7 @@ const AuthedShell: React.FC = ({ onLogout, onSearch, searchQue } /> } /> } /> + } /> } /> } /> } /> diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 39066b83..62a22866 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -3,7 +3,7 @@ import { NavLink, useLocation } from 'react-router-dom'; import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, - ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, + ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; import './Layout.css'; @@ -34,6 +34,7 @@ const ROUTE_LABELS: Record = { '/identities': 'IDENTITIES', '/campaigns': 'CAMPAIGNS', '/orchestrator': 'ORCHESTRATOR', + '/persona-generation': 'PERSONA GENERATION', '/config': 'CONFIG', '/swarm-updates': 'REMOTE UPDATES', '/swarm/hosts': 'SWARM HOSTS', @@ -108,8 +109,10 @@ const Layout: React.FC = ({