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:
@@ -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)
|
||||
|
||||
0
decnet/web/router/emailgen/__init__.py
Normal file
0
decnet/web/router/emailgen/__init__.py
Normal file
126
decnet/web/router/emailgen/api_personas.py
Normal file
126
decnet/web/router/emailgen/api_personas.py
Normal 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),
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
375
decnet_web/src/components/PersonaGeneration.css
Normal file
375
decnet_web/src/components/PersonaGeneration.css
Normal 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); }
|
||||
560
decnet_web/src/components/PersonaGeneration.tsx
Normal file
560
decnet_web/src/components/PersonaGeneration.tsx
Normal 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 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 persona’s 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;
|
||||
0
tests/api/emailgen/__init__.py
Normal file
0
tests/api/emailgen/__init__.py
Normal file
170
tests/api/emailgen/test_personas_api.py
Normal file
170
tests/api/emailgen/test_personas_api.py
Normal 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",
|
||||
}
|
||||
Reference in New Issue
Block a user