feat(web): Persona Generation page under AUTOMATION

New dashboard surface for editing the global emailgen persona pool —
the JSON file fleet (MACVLAN/IPVLAN) and SWARM-shard mail deckies pull
from.  MazeNET topology personas are out of scope here; they're
configured per-topology in the topology editor.

Backend:
* GET/PUT /api/v1/emailgen/personas — admin-write, viewer-read.  PUT
  validates with the same Pydantic schema the worker uses
  (parse_personas), drops invalid entries with a warning, returns 400
  only when the entire payload fails.  Path is operator-discoverable
  on every response so a CLI-driven backup workflow stays visible.

Frontend:
* PersonaGeneration.tsx + .css — table + add/edit modal with the full
  EmailPersona schema (name, email, role, tone, mannerisms list,
  language, signature, active hours, reply latency, uses_llms_heavily).
  Local edits are batched; explicit "SAVE CHANGES" writes back, with a
  dirty-indicator pill and a "DISCARD" reset.  Email uniqueness is
  enforced client-side so the scheduler never picks the same persona
  as both sender + recipient.
* Sidebar AUTOMATION group gains a "Persona Generation" entry next to
  Orchestrator; route registered at /persona-generation.

The worker reads the same on-disk file the API writes — see
decnet.orchestrator.emailgen.global_pool.  The API resets the
in-process cache on every read/write so the worker picks up dashboard
edits within its next tick rather than waiting on mtime.
This commit is contained in:
2026-04-27 09:55:42 -04:00
parent 818aebadfc
commit f046634d6e
9 changed files with 1248 additions and 5 deletions

View File

@@ -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)

View File

View File

@@ -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": [<EmailPersona>, ...]}``.
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),
}

View File

@@ -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<AuthedShellProps> = ({ onLogout, onSearch, searchQue
<Route path="/campaigns" element={<Campaigns />} />
<Route path="/campaigns/:id" element={<CampaignDetail />} />
<Route path="/orchestrator" element={<Orchestrator />} />
<Route path="/persona-generation" element={<PersonaGeneration />} />
<Route path="/config" element={<Config />} />
<Route path="/swarm-updates" element={<RemoteUpdates />} />
<Route path="/swarm/hosts" element={<SwarmHosts />} />

View File

@@ -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<string, string> = {
'/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<LayoutProps> = ({
<nav className="sidebar-nav">
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavItem to="/fleet" icon={<Server size={20} />} label="Decoy Fleet" open={sidebarOpen} />
<NavItem to="/mazenet" icon={<Network size={20} />} label="MazeNET" open={sidebarOpen} />
<NavGroup label="DEPLOY" icon={<Server size={20} />} open={sidebarOpen}>
<NavItem to="/fleet" icon={<Server size={18} />} label="Decoy Fleet" open={sidebarOpen} indent />
<NavItem to="/mazenet" icon={<Network size={18} />} label="MazeNET" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="ALERTS" icon={<Bell size={20} />} open={sidebarOpen}>
<NavItem
to="/live-logs"
@@ -127,15 +130,16 @@ const Layout: React.FC<LayoutProps> = ({
indent
/>
</NavGroup>
<NavItem to="/bounty" icon={<Archive size={20} />} label="Bounty" open={sidebarOpen} />
<NavItem to="/credentials" icon={<Lock size={20} />} label="Credentials" open={sidebarOpen} />
<NavGroup label="THREAT DATA" icon={<Activity size={20} />} open={sidebarOpen}>
<NavItem to="/attackers" icon={<Activity size={18} />} label="Attackers" open={sidebarOpen} indent />
<NavItem to="/identities" icon={<Fingerprint size={18} />} label="Identities" open={sidebarOpen} indent />
<NavItem to="/campaigns" icon={<Crosshair size={18} />} label="Campaigns" open={sidebarOpen} indent />
<NavItem to="/credentials" icon={<Lock size={18} />} label="Credentials" open={sidebarOpen} indent />
<NavItem to="/bounty" icon={<Archive size={18} />} label="Bounty" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="AUTOMATION" icon={<Zap size={20} />} open={sidebarOpen}>
<NavItem to="/orchestrator" icon={<Cpu size={18} />} label="Orchestrator" open={sidebarOpen} indent />
<NavItem to="/persona-generation" icon={<Mail size={18} />} label="Persona Generation" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />

View File

@@ -0,0 +1,375 @@
/* Persona Generation page — global pool CRUD UI for fleet/shard mail
deckies' fake-employee personas. Mirrors Webhooks.css visual language
(chips, table, modal) so dashboard density stays consistent. */
.persona-gen-root {
padding: 18px 24px;
color: var(--text);
}
.persona-gen-root .page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.persona-gen-root .page-title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-gen-root .header-line {
display: flex;
align-items: center;
gap: 10px;
}
.persona-gen-root .header-line h1 {
font-size: 1.1rem;
letter-spacing: 2px;
margin: 0;
text-transform: uppercase;
}
.persona-gen-root .page-sub {
font-size: 0.7rem;
letter-spacing: 1.4px;
color: var(--dim);
text-transform: uppercase;
}
.persona-gen-root .violet-accent { color: var(--violet); }
.persona-gen-root .dirty-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(245, 158, 11, 0.12);
color: var(--amber, #f59e0b);
font-size: 0.62rem;
letter-spacing: 1.4px;
text-transform: uppercase;
border: 1px solid var(--amber, #f59e0b);
}
/* Info banner explaining scope (non-MazeNET) + showing pool path. */
.persona-gen-root .info-banner {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-left: 3px solid var(--violet);
padding: 10px 14px;
margin-bottom: 14px;
font-size: 0.78rem;
line-height: 1.5;
}
.persona-gen-root .info-banner em { color: var(--matrix); font-style: normal; }
.persona-gen-root .info-line { margin-top: 6px; font-size: 0.72rem; }
/* Action row */
.persona-gen-root .controls-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.persona-gen-root .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.7rem;
letter-spacing: 1.5px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.15s;
}
.persona-gen-root .btn:hover:not(:disabled) {
border-color: var(--matrix);
color: var(--matrix);
}
.persona-gen-root .btn:disabled { opacity: 0.4; cursor: not-allowed; }
.persona-gen-root .btn.primary { border-color: var(--matrix); color: var(--matrix); }
.persona-gen-root .btn.primary:hover:not(:disabled) { background: rgba(0, 255, 65, 0.07); }
.persona-gen-root .btn.ghost { background: transparent; }
.persona-gen-root .error-line {
color: var(--alert);
font-size: 0.72rem;
display: inline-flex;
gap: 6px;
align-items: center;
}
/* Table */
.persona-gen-root .persona-list {
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.18);
}
.persona-gen-root .persona-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.persona-gen-root .persona-table thead th {
text-align: left;
padding: 8px 10px;
letter-spacing: 1.4px;
font-size: 0.66rem;
color: var(--dim);
text-transform: uppercase;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
}
.persona-gen-root .persona-table tbody td {
padding: 8px 10px;
border-bottom: 1px dashed var(--border);
vertical-align: middle;
}
.persona-gen-root .persona-table tbody tr:hover {
background: rgba(0, 255, 65, 0.04);
}
.persona-gen-root .row-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.persona-gen-root .icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
padding: 4px 6px;
cursor: pointer;
transition: all 0.12s;
}
.persona-gen-root .icon-btn:hover {
border-color: var(--matrix);
color: var(--matrix);
}
.persona-gen-root .icon-btn.danger:hover {
border-color: var(--alert);
color: var(--alert);
}
/* Tone chips — subtle palette differentiation */
.persona-gen-root .tone-chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--border);
font-size: 0.62rem;
letter-spacing: 1.5px;
text-transform: uppercase;
}
.persona-gen-root .tone-chip.tone-formal { border-color: var(--violet); color: var(--violet); }
.persona-gen-root .tone-chip.tone-direct { border-color: var(--matrix); color: var(--matrix); }
.persona-gen-root .tone-chip.tone-casual { border-color: var(--amber, #f59e0b); color: var(--amber, #f59e0b); }
.persona-gen-root .tone-chip.tone-technical { border-color: var(--cyan, #22d3ee); color: var(--cyan, #22d3ee); }
.persona-gen-root .chip {
display: inline-block;
padding: 1px 8px;
border: 1px solid var(--border);
font-size: 0.62rem;
letter-spacing: 1.4px;
text-transform: uppercase;
}
.persona-gen-root .dim-chip { color: var(--dim); }
.persona-gen-root .warn-chip {
border-color: var(--amber, #f59e0b);
color: var(--amber, #f59e0b);
}
.persona-gen-root .mono { font-family: var(--font-mono); font-size: 0.74rem; }
.persona-gen-root .matrix-text { color: var(--matrix); }
.persona-gen-root .dim { color: var(--dim); }
/* ── Modal ──────────────────────────────────────────────────────── */
.persona-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
backdrop-filter: blur(2px);
}
.persona-modal {
background: var(--bg);
border: 1px solid var(--border);
width: min(640px, 92vw);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.persona-modal .bd-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.persona-modal .bd-head h3 {
margin: 0;
font-size: 0.85rem;
letter-spacing: 1.6px;
display: inline-flex;
align-items: center;
gap: 8px;
}
.persona-modal .close-btn {
background: transparent;
border: none;
color: var(--dim);
cursor: pointer;
}
.persona-modal .close-btn:hover { color: var(--alert); }
.persona-modal .bd-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.persona-modal .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-modal .field-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
}
.persona-modal .field-label {
font-size: 0.66rem;
letter-spacing: 1.4px;
color: var(--dim);
text-transform: uppercase;
}
.persona-modal .field input[type="text"],
.persona-modal .field input[type="email"],
.persona-modal .field select,
.persona-modal .field textarea {
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
font-size: 0.82rem;
font-family: inherit;
}
.persona-modal .field input:focus,
.persona-modal .field select:focus,
.persona-modal .field textarea:focus {
outline: none;
border-color: var(--matrix);
}
.persona-modal .check-field {
flex-direction: row;
align-items: flex-start;
gap: 10px;
}
.persona-modal .check-field input[type="checkbox"] {
margin-top: 4px;
}
.persona-modal .mannerism-input-row {
display: flex;
gap: 6px;
}
.persona-modal .mannerism-input-row input { flex: 1; }
.persona-modal .mannerism-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.persona-modal .mannerism-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
font-size: 0.78rem;
}
.persona-modal .mannerism-list li span {
flex: 1;
word-break: break-word;
}
.persona-modal .draft-error {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--alert);
font-size: 0.74rem;
}
.persona-modal .bd-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.persona-modal .btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: var(--bg-elev);
border: 1px solid var(--border);
color: var(--text);
font-size: 0.7rem;
letter-spacing: 1.5px;
text-transform: uppercase;
cursor: pointer;
}
.persona-modal .btn.primary { border-color: var(--matrix); color: var(--matrix); }
.persona-modal .btn.ghost { background: transparent; }
.persona-modal .btn:hover:not(:disabled) { border-color: var(--matrix); color: var(--matrix); }

View File

@@ -0,0 +1,560 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Mail, Plus, Pencil, Trash2, Save, X, Check, AlertTriangle,
} from '../icons';
import api from '../utils/api';
import { useToast } from './Toasts/useToast';
import EmptyState from './EmptyState/EmptyState';
import './PersonaGeneration.css';
type Tone = 'formal' | 'direct' | 'casual' | 'technical';
type ReplyLatency = 'fast' | 'normal' | 'slow';
interface EmailPersona {
name: string;
email: string;
role: string;
tone: Tone;
mannerisms: string[];
language: string | null;
signature: string | null;
active_hours: string;
reply_latency: ReplyLatency;
uses_llms_heavily: boolean;
}
interface PersonasResponse {
path: string;
personas: EmailPersona[];
}
const BLANK: EmailPersona = {
name: '',
email: '',
role: '',
tone: 'formal',
mannerisms: [],
language: 'en',
signature: null,
active_hours: '09:00-18:00',
reply_latency: 'normal',
uses_llms_heavily: false,
};
const TONES: Tone[] = ['formal', 'direct', 'casual', 'technical'];
const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow'];
function extractErrorDetail(err: unknown, fallback: string): string {
const e = err as {
response?: { status?: number; data?: { detail?: string } };
message?: string;
};
if (e?.response?.data?.detail) return e.response.data.detail;
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
if (e?.response?.status === 401) return 'Session expired — please log in again';
if (e?.message) return e.message;
return fallback;
}
/** Light client-side validation — server re-validates with the same
* Pydantic schema the worker uses, this is just the early-warn UX. */
function validate(p: EmailPersona): string | null {
if (!p.name.trim()) return 'name is required';
if (!p.email.trim()) return 'email is required';
if (!p.email.includes('@') || !p.email.split('@')[1]?.includes('.')) {
return 'email must look like user@domain.tld';
}
if (!p.role.trim()) return 'role is required';
if (p.mannerisms.length > 12) return 'at most 12 mannerisms per persona';
return null;
}
const PersonaGeneration: React.FC = () => {
const { push } = useToast();
const [path, setPath] = useState<string>('');
const [personas, setPersonas] = useState<EmailPersona[]>([]);
const [serverPersonas, setServerPersonas] = useState<EmailPersona[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [editingIdx, setEditingIdx] = useState<number | null>(null);
const [draft, setDraft] = useState<EmailPersona>(BLANK);
const [draftError, setDraftError] = useState<string | null>(null);
const [mannerismDraft, setMannerismDraft] = useState('');
const dirty = useMemo(
() => JSON.stringify(personas) !== JSON.stringify(serverPersonas),
[personas, serverPersonas],
);
const fetchPersonas = async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<PersonasResponse>('/emailgen/personas');
const list = res.data.personas ?? [];
setPersonas(list);
setServerPersonas(list);
setPath(res.data.path ?? '');
} catch (err) {
setError(extractErrorDetail(err, 'Failed to load personas'));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPersonas();
}, []);
const openAdd = () => {
setEditingIdx(null);
setDraft({ ...BLANK });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const openEdit = (idx: number) => {
setEditingIdx(idx);
setDraft({ ...personas[idx] });
setMannerismDraft('');
setDraftError(null);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setDraft(BLANK);
setEditingIdx(null);
setMannerismDraft('');
setDraftError(null);
};
const saveDraft = () => {
const err = validate(draft);
if (err) {
setDraftError(err);
return;
}
// Email uniqueness — same address across two personas would let
// the scheduler pick "John" as both sender and recipient.
const dupeIdx = personas.findIndex(
(p, i) => p.email === draft.email && i !== editingIdx,
);
if (dupeIdx !== -1) {
setDraftError(`email already used by "${personas[dupeIdx].name}"`);
return;
}
if (editingIdx === null) {
setPersonas([...personas, draft]);
} else {
const next = personas.slice();
next[editingIdx] = draft;
setPersonas(next);
}
closeModal();
};
const removePersona = (idx: number) => {
if (!confirm(`Remove ${personas[idx].name}?`)) return;
setPersonas(personas.filter((_, i) => i !== idx));
};
const addMannerism = () => {
const t = mannerismDraft.trim();
if (!t) return;
if (draft.mannerisms.includes(t)) {
setMannerismDraft('');
return;
}
setDraft({ ...draft, mannerisms: [...draft.mannerisms, t] });
setMannerismDraft('');
};
const removeMannerism = (idx: number) => {
setDraft({
...draft,
mannerisms: draft.mannerisms.filter((_, i) => i !== idx),
});
};
const saveAll = async () => {
setSaving(true);
setError(null);
try {
const res = await api.put<PersonasResponse>('/emailgen/personas', {
personas,
});
const list = res.data.personas ?? [];
setPersonas(list);
setServerPersonas(list);
setPath(res.data.path ?? path);
push({
text: `SAVED ${list.length} PERSONA${list.length === 1 ? '' : 'S'}`,
tone: 'matrix',
icon: 'check',
});
} catch (err) {
const msg = extractErrorDetail(err, 'Failed to save personas');
setError(msg);
push({ text: msg.toUpperCase(), tone: 'alert', icon: 'alert-triangle' });
} finally {
setSaving(false);
}
};
const discardChanges = () => {
if (!dirty) return;
if (!confirm('Discard unsaved changes?')) return;
setPersonas(serverPersonas);
};
return (
<div className="persona-gen-root">
<div className="page-header">
<div className="page-title-group">
<div className="header-line">
<Mail size={22} className="violet-accent" />
<h1>PERSONA GENERATION</h1>
{dirty && (
<span className="dirty-pill">
<AlertTriangle size={12} />
UNSAVED CHANGES
</span>
)}
</div>
<span className="page-sub">
GLOBAL POOL · FLEET (MACVLAN/IPVLAN) + SWARM-SHARD MAIL DECKIES
</span>
</div>
</div>
<div className="info-banner">
<div>
<strong>Scope:</strong> personas listed here drive emailgen against{' '}
<em>non-MazeNET</em> mail deckies (unihost MACVLAN/IPVLAN, SWARM
shards). MazeNET topologies have their own per-topology persona
list configured in the topology editor.
</div>
{path && (
<div className="info-line">
<span className="dim">FILE</span>{' '}
<span className="mono matrix-text">{path}</span>
</div>
)}
</div>
<div className="controls-row">
<button className="btn primary" onClick={openAdd}>
<Plus size={12} />
ADD PERSONA
</button>
<button
className="btn"
onClick={saveAll}
disabled={!dirty || saving}
>
<Save size={12} />
{saving ? 'SAVING…' : 'SAVE CHANGES'}
</button>
<button
className="btn ghost"
onClick={discardChanges}
disabled={!dirty || saving}
>
DISCARD
</button>
{error && (
<span className="error-line">
<AlertTriangle size={12} /> {error}
</span>
)}
</div>
<div className="persona-list">
{loading ? (
<EmptyState icon={Mail} title="LOADING…" />
) : personas.length === 0 ? (
<EmptyState
icon={Mail}
title="NO PERSONAS CONFIGURED"
hint="add at least 2 to start the emailgen worker against fleet/shard mail deckies"
/>
) : (
<table className="persona-table">
<thead>
<tr>
<th>NAME</th>
<th>EMAIL</th>
<th>ROLE</th>
<th>TONE</th>
<th>LANG</th>
<th>HOURS</th>
<th>REPLY</th>
<th>MANNERISMS</th>
<th>FLAGS</th>
<th />
</tr>
</thead>
<tbody>
{personas.map((p, idx) => (
<tr key={`${p.email}-${idx}`}>
<td>{p.name}</td>
<td className="mono">{p.email}</td>
<td>{p.role}</td>
<td>
<span className={`tone-chip tone-${p.tone}`}>{p.tone}</span>
</td>
<td>
<span className="chip dim-chip">
{(p.language ?? 'en').toUpperCase()}
</span>
</td>
<td className="mono">{p.active_hours}</td>
<td>{p.reply_latency}</td>
<td className="dim">
{p.mannerisms.length === 0
? '—'
: `${p.mannerisms.length} item${p.mannerisms.length === 1 ? '' : 's'}`}
</td>
<td>
{p.uses_llms_heavily && (
<span
className="chip warn-chip"
title="Em-dash suppression lifted for this persona"
>
LLM-HEAVY
</span>
)}
</td>
<td className="row-actions">
<button
className="icon-btn"
onClick={() => openEdit(idx)}
aria-label={`Edit ${p.name}`}
>
<Pencil size={14} />
</button>
<button
className="icon-btn danger"
onClick={() => removePersona(idx)}
aria-label={`Remove ${p.name}`}
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{modalOpen && (
<div
className="persona-modal-backdrop"
onClick={(e) => {
if (e.target === e.currentTarget) closeModal();
}}
>
<div className="persona-modal">
<div className="bd-head">
<h3>
<Mail size={14} />
{editingIdx === null ? 'ADD PERSONA' : 'EDIT PERSONA'}
</h3>
<button
className="close-btn"
onClick={closeModal}
aria-label="Close"
>
<X size={16} />
</button>
</div>
<div className="bd-body">
<label className="field">
<span className="field-label">NAME *</span>
<input
type="text"
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="John Smith"
/>
</label>
<label className="field">
<span className="field-label">EMAIL *</span>
<input
type="email"
value={draft.email}
onChange={(e) => setDraft({ ...draft, email: e.target.value })}
placeholder="john.smith@corp.com"
/>
</label>
<label className="field">
<span className="field-label">ROLE *</span>
<input
type="text"
value={draft.role}
onChange={(e) => setDraft({ ...draft, role: e.target.value })}
placeholder="Chief Operating Officer"
/>
</label>
<div className="field-row">
<label className="field">
<span className="field-label">TONE</span>
<select
value={draft.tone}
onChange={(e) => setDraft({ ...draft, tone: e.target.value as Tone })}
>
{TONES.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">LANGUAGE</span>
<input
type="text"
maxLength={8}
value={draft.language ?? ''}
onChange={(e) =>
setDraft({ ...draft, language: e.target.value || null })
}
placeholder="en"
/>
</label>
<label className="field">
<span className="field-label">REPLY LATENCY</span>
<select
value={draft.reply_latency}
onChange={(e) =>
setDraft({
...draft,
reply_latency: e.target.value as ReplyLatency,
})
}
>
{LATENCIES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
</label>
</div>
<label className="field">
<span className="field-label">ACTIVE HOURS</span>
<input
type="text"
value={draft.active_hours}
onChange={(e) =>
setDraft({ ...draft, active_hours: e.target.value })
}
placeholder="09:00-18:00 (wraps OK e.g. 22:00-06:00)"
/>
</label>
<label className="field">
<span className="field-label">MANNERISMS (12)</span>
<div className="mannerism-input-row">
<input
type="text"
value={mannerismDraft}
onChange={(e) => setMannerismDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addMannerism();
}
}}
placeholder="opens with 'Hey' not 'Dear'"
/>
<button
type="button"
className="btn ghost"
onClick={addMannerism}
disabled={!mannerismDraft.trim() || draft.mannerisms.length >= 12}
>
<Plus size={12} /> ADD
</button>
</div>
<ul className="mannerism-list">
{draft.mannerisms.map((m, i) => (
<li key={i}>
<span>{m}</span>
<button
type="button"
className="icon-btn danger"
onClick={() => removeMannerism(i)}
aria-label={`Remove mannerism ${i + 1}`}
>
<X size={12} />
</button>
</li>
))}
</ul>
</label>
<label className="field">
<span className="field-label">SIGNATURE (optional)</span>
<textarea
rows={3}
value={draft.signature ?? ''}
onChange={(e) =>
setDraft({
...draft,
signature: e.target.value || null,
})
}
placeholder="-- John&#10;COO, ACME Corp"
/>
</label>
<label className="field check-field">
<input
type="checkbox"
checked={draft.uses_llms_heavily}
onChange={(e) =>
setDraft({ ...draft, uses_llms_heavily: e.target.checked })
}
/>
<span>
<strong>Uses LLMs heavily</strong>
<span className="dim">
{' — em-dash suppression lifted; this personas output may '}
contain natural em-dashes.
</span>
</span>
</label>
{draftError && (
<div className="draft-error">
<AlertTriangle size={12} /> {draftError}
</div>
)}
</div>
<div className="bd-actions">
<button className="btn ghost" onClick={closeModal}>
CANCEL
</button>
<button className="btn primary" onClick={saveDraft}>
<Check size={12} />
{editingIdx === null ? 'ADD' : 'UPDATE'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PersonaGeneration;

View File

View File

@@ -0,0 +1,170 @@
"""GET/PUT /api/v1/emailgen/personas — global persona pool CRUD."""
from __future__ import annotations
import json
import pytest
from decnet.orchestrator.emailgen import global_pool
from decnet.web.router.emailgen.api_personas import (
list_personas,
replace_personas,
)
@pytest.fixture(autouse=True)
def _reset_pool():
global_pool.reset_cache()
yield
global_pool.reset_cache()
_VALID = [
{
"name": "John Smith",
"email": "john@corp.com",
"role": "COO",
"tone": "formal",
"mannerisms": ["uses 'Best regards'"],
},
{
"name": "Sarah Johnson",
"email": "sarah@corp.com",
"role": "PM",
"tone": "direct",
"mannerisms": ["uses bullets"],
},
]
@pytest.mark.asyncio
async def test_list_returns_empty_when_no_pool(tmp_path, monkeypatch):
monkeypatch.setenv(
"DECNET_EMAILGEN_PERSONAS", str(tmp_path / "missing.json"),
)
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert result["personas"] == []
assert result["path"].endswith("missing.json")
@pytest.mark.asyncio
async def test_list_returns_existing_pool(tmp_path, monkeypatch):
pool = tmp_path / "pool.json"
pool.write_text(json.dumps(_VALID))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(pool))
result = await list_personas(user={"uuid": "u", "role": "viewer"})
assert len(result["personas"]) == 2
assert {p["email"] for p in result["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_replace_writes_canonical_file(tmp_path, monkeypatch):
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": _VALID},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert len(result["personas"]) == 2
assert dest.exists()
written = json.loads(dest.read_text())
assert {p["email"] for p in written} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_replace_with_empty_list_clears_pool(tmp_path, monkeypatch):
"""Operator deliberately wiping the pool is allowed — empty list is
valid and means "no fleet personas, skip fleet mail deckies"."""
dest = tmp_path / "pool.json"
dest.write_text(json.dumps(_VALID))
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": []},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert result["personas"] == []
assert json.loads(dest.read_text()) == []
@pytest.mark.asyncio
async def test_replace_rejects_non_list_payload(tmp_path, monkeypatch):
from fastapi import HTTPException
monkeypatch.setenv(
"DECNET_EMAILGEN_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
body={"personas": "not-a-list"},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_replace_rejects_all_invalid_payload(tmp_path, monkeypatch):
"""Sending a non-empty list where *every* entry is invalid is almost
certainly an operator schema mistake — fail loudly rather than
silently writing an empty pool."""
from fastapi import HTTPException
monkeypatch.setenv(
"DECNET_EMAILGEN_PERSONAS", str(tmp_path / "pool.json"),
)
with pytest.raises(HTTPException) as exc:
await replace_personas(
body={"personas": [{"name": "broken", "email": "no-at-symbol"}]},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert exc.value.status_code == 400
assert "validation" in exc.value.detail.lower()
@pytest.mark.asyncio
async def test_replace_drops_partially_invalid_entries(tmp_path, monkeypatch):
"""One bad apple doesn't kill the request — invalid entries get
dropped, valid ones land, response shows what stuck."""
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
result = await replace_personas(
body={"personas": [
_VALID[0],
{"name": "broken", "email": "no-at-symbol"},
_VALID[1],
]},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
assert len(result["personas"]) == 2
assert {p["email"] for p in result["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
@pytest.mark.asyncio
async def test_get_then_put_round_trips_through_pool(tmp_path, monkeypatch):
"""The worker reads the same file the API writes — verify the
write-then-read cycle leaves the pool in the expected state."""
dest = tmp_path / "pool.json"
monkeypatch.setenv("DECNET_EMAILGEN_PERSONAS", str(dest))
await replace_personas(
body={"personas": _VALID},
user={"uuid": "u", "role": "admin", "username": "anti"},
)
listed = await list_personas(user={"uuid": "u", "role": "viewer"})
assert {p["email"] for p in listed["personas"]} == {
"john@corp.com", "sarah@corp.com",
}
# And the worker's loader sees the same data.
loaded = global_pool.load()
assert {p.email for p in loaded} == {
"john@corp.com", "sarah@corp.com",
}